Merge pull request #612 from open-pencil/fix/589-published-packages-windows-mcp
fix(mcp): repair published exports and Windows roots
This commit is contained in:
commit
5689eccc0c
|
|
@ -71,6 +71,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Make published package export conditions resolve to files included in npm tarballs.
|
||||
- Use the user's home directory as the default MCP file root on Windows, avoiding the caller's unreliable working directory.
|
||||
- Open legacy raw `.fig` files that store the Kiwi document and thumbnail without a ZIP wrapper. (#582)
|
||||
- Preserve a frame's auto-layout HUG sizing mode when converting it into a component with `create_component`, so later padding changes still resize the component as expected.
|
||||
- Run `openpencil import` on Node so the npm-installed CLI no longer fails with `Bun is not defined`. (#575)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
import { resolveMCPRoot } from '#mcp/root'
|
||||
import { startServer } from '#mcp/server'
|
||||
import { readToolPolicyFromEnv } from '#mcp/tool/policy'
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|||
` platform path. Parent dir created 0o700. Mainly for test isolation.\n` +
|
||||
` OPENPENCIL_MCP_TCP Deprecated — TCP is controlled by PORT (>0 = on, 0 = off)\n` +
|
||||
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for MCP and RPC auth\n` +
|
||||
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: current working directory)\n` +
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
|
||||
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: home directory on Windows, current working directory elsewhere)\n` +
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
|
||||
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit\n` +
|
||||
` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` +
|
||||
` OPENPENCIL_MCP_APP_TIMEOUT_MS If set, close the server and remove its discovery\n` +
|
||||
|
|
@ -75,7 +76,7 @@ const handle = await startServer({
|
|||
socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null,
|
||||
enableEval: toolPolicy.allowEval,
|
||||
disabledTools: toolPolicy.disabledTools,
|
||||
mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(),
|
||||
mcpRoot: resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT),
|
||||
// Auth token: undefined → auto-generate, empty string → disable auth,
|
||||
// non-empty → use trimmed value. Whitespace-only is rejected to prevent a
|
||||
// silent fallback to an auto-generated token when the operator intended to
|
||||
|
|
|
|||
5
packages/mcp/src/root.ts
Normal file
5
packages/mcp/src/root.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { homedir, platform } from 'node:os'
|
||||
|
||||
export function resolveMCPRoot(value: string | undefined, runtimePlatform = platform()): string {
|
||||
return value?.trim() || (runtimePlatform === 'win32' ? homedir() : process.cwd())
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
|
||||
import { resolveMCPRoot } from '#mcp/root'
|
||||
import { MCP_VERSION, registerTools } from '#mcp/server'
|
||||
import { createStdioRPCBridge } from '#mcp/stdio/bridge'
|
||||
import type { ToolPolicy } from '#mcp/tool/metadata'
|
||||
|
|
@ -22,7 +23,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|||
` OPENPENCIL_MCP_SOCKET Override socket path (auto-discovered from discovery file when unset)\n` +
|
||||
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for RPC auth\n` +
|
||||
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools\n` +
|
||||
` (default: cwd when run standalone, home directory when app-spawned)\n` +
|
||||
` (default: home directory on Windows, cwd elsewhere)\n` +
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
|
||||
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n`
|
||||
)
|
||||
|
|
@ -36,7 +37,7 @@ const toolPolicy: ToolPolicy = {
|
|||
? ((await readDiscoveryFile())?.disabledTools ?? [])
|
||||
: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS)
|
||||
}
|
||||
const mcpRoot = process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd()
|
||||
const mcpRoot = resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT)
|
||||
// Auth token: undefined → auto-discover from discovery file, empty string →
|
||||
// disable auth, whitespace-only → reject (same fail-fast as index.ts to catch
|
||||
// misconfiguration), otherwise → use the trimmed value.
|
||||
|
|
|
|||
14
packages/mcp/tests/root.test.ts
Normal file
14
packages/mcp/tests/root.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { resolveMCPRoot } from '../src/root'
|
||||
|
||||
describe('resolveMCPRoot', () => {
|
||||
test('honors an explicit root', () => {
|
||||
expect(resolveMCPRoot(' /designs ')).toBe('/designs')
|
||||
})
|
||||
|
||||
test('uses the home directory on Windows', () => {
|
||||
expect(resolveMCPRoot(undefined, 'win32')).toBe(homedir())
|
||||
})
|
||||
})
|
||||
|
|
@ -62,8 +62,31 @@ async function copyRecursive(from: string, to: string): Promise<void> {
|
|||
await copyFile(from, to)
|
||||
}
|
||||
|
||||
interface PackageExports {
|
||||
[key: string]: PackageExports | string | undefined
|
||||
}
|
||||
|
||||
interface PublishPackageJSON extends PackageJSON {
|
||||
exports?: PackageExports
|
||||
imports?: PackageExports
|
||||
}
|
||||
|
||||
function isPackageExports(value: unknown): value is PackageExports {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function removeUnpublishedConditions(value: PackageExports | undefined): void {
|
||||
if (!value) return
|
||||
delete value.bun
|
||||
for (const child of Object.values(value)) {
|
||||
if (isPackageExports(child)) removeUnpublishedConditions(child)
|
||||
}
|
||||
}
|
||||
|
||||
export function publishPackageJSON(source: PackageJSON, coreVersion: string): PackageJSON {
|
||||
const json = structuredClone(source)
|
||||
const json = structuredClone(source) as PublishPackageJSON
|
||||
removeUnpublishedConditions(json.exports)
|
||||
removeUnpublishedConditions(json.imports)
|
||||
|
||||
for (const field of PACKAGE_FIELDS) {
|
||||
const dependencies = json[field]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ import { promisify } from 'node:util'
|
|||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type PackageExports = {
|
||||
[key: string]: PackageExports | string | null | undefined
|
||||
}
|
||||
|
||||
type PackageJSON = {
|
||||
bin?: Record<string, string> | string
|
||||
exports?: PackageExports
|
||||
name: string
|
||||
}
|
||||
|
||||
|
|
@ -15,6 +20,17 @@ export function packageBinTargets(packageJSON: PackageJSON): Record<string, stri
|
|||
return packageJSON.bin ?? {}
|
||||
}
|
||||
|
||||
function packageExportTargets(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value]
|
||||
if (Array.isArray(value)) return value.flatMap(packageExportTargets)
|
||||
if (!value || typeof value !== 'object') return []
|
||||
return Object.values(value).flatMap(packageExportTargets)
|
||||
}
|
||||
|
||||
export function packageExportTargetPaths(packageJSON: Pick<PackageJSON, 'exports'>): string[] {
|
||||
return packageExportTargets(packageJSON.exports)
|
||||
}
|
||||
|
||||
export async function tarballEntries(tarballPath: string): Promise<Set<string>> {
|
||||
const { stdout } = await execFileAsync('tar', ['-tf', tarballPath], { encoding: 'utf8' })
|
||||
return new Set(stdout.trim().split('\n').filter(Boolean))
|
||||
|
|
@ -39,9 +55,38 @@ export async function validateTarballBinTargets(tarballPath: string): Promise<vo
|
|||
}
|
||||
}
|
||||
|
||||
function exportTargetPattern(target: string): RegExp {
|
||||
const escaped = target.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`)
|
||||
}
|
||||
export async function validateTarballExportTargets(tarballPath: string): Promise<void> {
|
||||
const entries = await tarballEntries(tarballPath)
|
||||
const packageJSON = await tarballPackageJSON(tarballPath)
|
||||
|
||||
for (const target of packageExportTargetPaths(packageJSON)) {
|
||||
if (!target.startsWith('./')) continue
|
||||
const relativeTarget = target.slice(2)
|
||||
let matchingEntries: string[]
|
||||
if (relativeTarget.includes('*')) {
|
||||
const pattern = exportTargetPattern(relativeTarget)
|
||||
matchingEntries = [...entries].filter((entry) => pattern.test(entry.slice('package/'.length)))
|
||||
} else {
|
||||
const exactEntry = `package/${relativeTarget}`
|
||||
matchingEntries = entries.has(exactEntry) ? [exactEntry] : []
|
||||
}
|
||||
if (matchingEntries.length === 0) {
|
||||
throw new Error(
|
||||
`${tarballPath}: export target missing from tarball: package/${relativeTarget}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function validatePackedTarballs(directory: string): Promise<void> {
|
||||
const tarballs = (await readdir(directory)).filter((name) => name.endsWith('.tgz'))
|
||||
for (const tarball of tarballs) {
|
||||
await validateTarballBinTargets(join(directory, tarball))
|
||||
const path = join(directory, tarball)
|
||||
await validateTarballBinTargets(path)
|
||||
await validateTarballExportTargets(path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ async function fixtureRoot() {
|
|||
version: '1.0.0',
|
||||
scripts: { build: 'tsdown' },
|
||||
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
|
||||
exports: { '.': { bun: './src/index.ts', import: './dist/index.js' } },
|
||||
devDependencies: { typescript: '^5.0.0' },
|
||||
publishConfig: { access: 'public', main: './dist/index.js', types: './dist/index.d.ts' }
|
||||
},
|
||||
|
|
@ -38,6 +39,7 @@ describe('publishPackageJSON', () => {
|
|||
name: '@open-pencil/example',
|
||||
scripts: { build: 'tsdown' },
|
||||
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
|
||||
exports: { '.': { bun: './src/index.ts', import: './dist/index.js' } },
|
||||
devDependencies: { typescript: '^5.0.0' },
|
||||
publishConfig: { access: 'public', main: './dist/index.js' }
|
||||
},
|
||||
|
|
@ -47,6 +49,7 @@ describe('publishPackageJSON', () => {
|
|||
expect(json).toEqual({
|
||||
name: '@open-pencil/example',
|
||||
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
|
||||
exports: { '.': { import: './dist/index.js' } },
|
||||
main: './dist/index.js'
|
||||
})
|
||||
})
|
||||
|
|
@ -86,6 +89,7 @@ describe('preparePublishDirectories', () => {
|
|||
name: '@open-pencil/example',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
|
||||
exports: { '.': { import: './dist/index.js' } },
|
||||
main: './dist/index.js',
|
||||
types: './dist/index.d.ts'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { packageBinTargets } from '../src/tarballs'
|
||||
import { packageBinTargets, packageExportTargetPaths } from '../src/tarballs'
|
||||
|
||||
describe('packageBinTargets', () => {
|
||||
test('normalizes string bin fields', () => {
|
||||
|
|
@ -17,3 +17,28 @@ describe('packageBinTargets', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('package export targets', () => {
|
||||
test('collects wildcard targets from conditional exports', () => {
|
||||
expect(
|
||||
packageExportTargetPaths({ exports: { './feature/*': { import: './dist/*.js' } } })
|
||||
).toEqual(['./dist/*.js'])
|
||||
})
|
||||
|
||||
test('collects nested wildcard targets from conditional exports', () => {
|
||||
expect(
|
||||
packageExportTargetPaths({ exports: { './feature/*': { import: './dist/*.js' } } })
|
||||
).toEqual(['./dist/*.js'])
|
||||
})
|
||||
|
||||
test('collects targets from conditional exports', () => {
|
||||
expect(
|
||||
packageExportTargetPaths({
|
||||
exports: {
|
||||
'.': { types: './dist/index.d.ts', import: './dist/index.js' },
|
||||
'./feature': { import: './dist/feature.js' }
|
||||
}
|
||||
})
|
||||
).toEqual(['./dist/index.d.ts', './dist/index.js', './dist/feature.js'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue