diff --git a/CHANGELOG.md b/CHANGELOG.md index 508b47616..45f173508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 972bc4b1d..9f7f53444 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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 diff --git a/packages/mcp/src/root.ts b/packages/mcp/src/root.ts new file mode 100644 index 000000000..1b6b7b1b2 --- /dev/null +++ b/packages/mcp/src/root.ts @@ -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()) +} diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts index ba711d268..46ee3831b 100644 --- a/packages/mcp/src/stdio.ts +++ b/packages/mcp/src/stdio.ts @@ -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. diff --git a/packages/mcp/tests/root.test.ts b/packages/mcp/tests/root.test.ts new file mode 100644 index 000000000..4c1e3aef5 --- /dev/null +++ b/packages/mcp/tests/root.test.ts @@ -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()) + }) +}) diff --git a/tools/release-packages/src/publish-dirs.ts b/tools/release-packages/src/publish-dirs.ts index 5d2996da8..e7e97b26f 100644 --- a/tools/release-packages/src/publish-dirs.ts +++ b/tools/release-packages/src/publish-dirs.ts @@ -62,8 +62,31 @@ async function copyRecursive(from: string, to: string): Promise { 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] diff --git a/tools/release-packages/src/tarballs.ts b/tools/release-packages/src/tarballs.ts index e97670693..de9d29f2e 100644 --- a/tools/release-packages/src/tarballs.ts +++ b/tools/release-packages/src/tarballs.ts @@ -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 + exports?: PackageExports name: string } @@ -15,6 +20,17 @@ export function packageBinTargets(packageJSON: PackageJSON): Record): string[] { + return packageExportTargets(packageJSON.exports) +} + export async function tarballEntries(tarballPath: string): Promise> { 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 { + 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 { 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) } } diff --git a/tools/release-packages/tests/publish-dirs.test.ts b/tools/release-packages/tests/publish-dirs.test.ts index ccf314a33..03439facb 100644 --- a/tools/release-packages/tests/publish-dirs.test.ts +++ b/tools/release-packages/tests/publish-dirs.test.ts @@ -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' }) diff --git a/tools/release-packages/tests/tarballs.test.ts b/tools/release-packages/tests/tarballs.test.ts index e0b1bb246..1a68d3d1a 100644 --- a/tools/release-packages/tests/tarballs.test.ts +++ b/tools/release-packages/tests/tarballs.test.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']) + }) +})