diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ddec325d76..f50786a29d 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -17,18 +17,35 @@ * under the License. */ -import { readFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { DESKTOP_NIGHTLY_FEED_URL, resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage, } from '../../scripts/desktop-nightly.mjs'; +import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +async function stageReleaseManifests({ packager }) { + const stage = await packager.info.tempDirManager.createTempDir({ + prefix: 'maka-release-manifests', + }); + for (const name of ['mcp', 'runtime', 'runtime-host']) { + const directory = join(stage, name); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, 'package.json'), + `${JSON.stringify(workspaceReleaseManifest(readManifest(`../../packages/${name}/package.json`)), null, 2)}\n`, + ); + } + packager.config.files.push({ from: stage, to: 'node_modules/@maka' }); +} + const rootManifest = readManifest('../../package.json'); const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ rootManifest, @@ -41,6 +58,7 @@ const baseDesktopBuilderConfig = { productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, + beforePack: stageReleaseManifests, extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', @@ -58,6 +76,7 @@ const baseDesktopBuilderConfig = { 'dist/**/*', 'dist-renderer/**/*', 'package.json', + '!node_modules/@maka/{mcp,runtime,runtime-host}/package.json', '!**/__tests__/**', // FakeBackend and the Desktop E2E candidate bootstrap live under // `test-only/`; they must not reach a packaged app. diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index aec5b3bd6c..dde3561b17 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -46,6 +46,7 @@ import { releaseNpmEnvironment, resolveReleaseWorkspacePackages, resolveWorkspaceReleaseFiles, + workspaceReleaseManifest, } from './release-cli-file-policy.mjs'; const execFileAsync = promisify(execFile); @@ -205,31 +206,25 @@ export async function stageWorkspacePackages(installRoot, workspacePackages) { ...workspacePackages.map(async ({ directory, manifest, workspacePath }) => { const targetDirectory = join(installRoot, workspacePath); await mkdir(targetDirectory, { recursive: true }); - await copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')); + await writeFile( + join(targetDirectory, 'package.json'), + `${JSON.stringify(workspaceReleaseManifest(manifest), null, 2)}\n`, + ); await Promise.all( resolveWorkspaceReleaseFiles(directory, manifest).map(async (releaseFile) => { const source = join(directory, ...releaseFile.split('/')); const target = join(targetDirectory, ...releaseFile.split('/')); await mkdir(dirname(target), { recursive: true }); - await cp(source, target, { recursive: true }); + await cp(source, target, { + recursive: true, + filter: (path) => !isMakaDevelopmentArtifact(relative(directory, path)), + }); }), ); - await pruneMakaDevelopmentArtifacts(targetDirectory); }), ]); } -async function pruneMakaDevelopmentArtifacts(directory, root = directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (isMakaDevelopmentArtifact(relative(root, path))) { - await rm(path, { recursive: entry.isDirectory(), force: true }); - } else if (entry.isDirectory()) { - await pruneMakaDevelopmentArtifacts(path, root); - } - } -} - export async function listDependencyPatchNames() { let entries; try { diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 1c9262a401..334554aa76 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -175,6 +175,32 @@ test('Desktop packaging derives the Runtime Host setup package from product mani ]); }); +test('Desktop stages release manifests before electron-builder builds the archive', async (t) => { + const stage = await mkdtemp(join(tmpdir(), 'maka-desktop-release-manifests-')); + t.after(() => rm(stage, { recursive: true, force: true })); + const files = [{ filter: [...desktopBuilderConfig.files] }]; + + await desktopBuilderConfig.beforePack({ + packager: { + config: { files }, + info: { tempDirManager: { createTempDir: async () => stage } }, + }, + }); + + assert.equal(files.length, 2); + assert.ok( + files[0].filter.includes('!node_modules/@maka/{mcp,runtime,runtime-host}/package.json'), + ); + assert.deepEqual(files.at(-1), { from: stage, to: 'node_modules/@maka' }); + for (const name of ['mcp', 'runtime', 'runtime-host']) { + const projected = JSON.parse(await readFile(join(stage, name, 'package.json'))); + assert.equal( + Object.keys(projected.exports).some((subpath) => subpath.startsWith('./test-only/')), + false, + ); + } +}); + test('Desktop packaging does not distribute the retired bundled Git runtime', () => { const resources = desktopBuilderConfig.extraResources.map(({ from, to }) => ({ from, to })); assert.equal( @@ -554,14 +580,22 @@ test('standalone packaging applies the shared CLI file policy to dependencies', } }); -test('standalone workspace staging keeps runtime files and removes Maka development output', async () => { +test('standalone workspace staging keeps manifests consistent with copied files', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-standalone-workspace-')); const workspace = join(root, 'workspace'); const install = join(root, 'install'); try { + const manifest = { + name: '@maka/example', + exports: { + '.': './dist/index.js', + './test-only/fixture': './dist/__tests__/fixture.js', + }, + scripts: { build: 'tsc' }, + }; await mkdir(join(workspace, 'dist', '__tests__'), { recursive: true }); await mkdir(install); - await writeFile(join(workspace, 'package.json'), '{}\n'); + await writeFile(join(workspace, 'package.json'), `${JSON.stringify(manifest)}\n`); await writeFile(join(workspace, 'dist', 'index.js'), 'runtime\n'); await writeFile(join(workspace, 'dist', 'dev-cli.js'), 'development\n'); await writeFile(join(workspace, 'dist', 'index.d.ts'), 'development\n'); @@ -571,11 +605,15 @@ test('standalone workspace staging keeps runtime files and removes Maka developm await stageWorkspacePackages(install, [ { directory: workspace, - manifest: { name: '@maka/example' }, + manifest, workspacePath: 'packages/example', }, ]); + assert.deepEqual(JSON.parse(await readFile(join(install, 'packages/example/package.json'))), { + name: '@maka/example', + exports: { '.': './dist/index.js' }, + }); const staged = join(install, 'packages', 'example', 'dist'); assert.equal(await readFile(join(staged, 'index.js'), 'utf8'), 'runtime\n'); for (const path of ['dev-cli.js', 'index.d.ts', 'index.js.map', '__tests__/fixture.js']) { diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index 5f1d58acba..b7c9d5d300 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -245,6 +245,55 @@ export function isMakaDevelopmentArtifact(relativePath) { ); } +function releaseExportTarget(target) { + if (typeof target === 'string') { + return isMakaDevelopmentArtifact(target) ? undefined : target; + } + if (Array.isArray(target)) { + const projected = target.map(releaseExportTarget).filter((entry) => entry !== undefined); + return projected.length === 0 ? undefined : projected; + } + if (target && typeof target === 'object') { + const projected = Object.fromEntries( + Object.entries(target) + .map(([condition, value]) => [condition, releaseExportTarget(value)]) + .filter(([, value]) => value !== undefined), + ); + return Object.keys(projected).length === 0 ? undefined : projected; + } + return target; +} + +const WORKSPACE_RELEASE_MANIFEST_FIELDS = [ + 'name', + 'version', + 'description', + 'license', + 'type', + 'sideEffects', + 'main', + 'exports', + 'bin', + 'engines', + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', +]; + +export function workspaceReleaseManifest(manifest) { + const releaseManifest = Object.fromEntries( + WORKSPACE_RELEASE_MANIFEST_FIELDS.filter((field) => manifest[field] !== undefined).map( + (field) => [field, manifest[field]], + ), + ); + if (releaseManifest.exports !== undefined) { + const projected = releaseExportTarget(releaseManifest.exports); + releaseManifest.exports = projected === undefined ? {} : projected; + } + return releaseManifest; +} + export function isCurrentDevelopmentJavaScript( workspaceRoot, relativePath, diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 4f321c86c4..d01b3e1b1d 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -38,6 +38,7 @@ import { orderWorkspaceBuilds, renderNpmReadme, resolveWorkspaceReleaseFiles, + workspaceReleaseManifest, workspaceReleaseFiles, } from './release-cli-file-policy.mjs'; @@ -51,6 +52,62 @@ test('the npm README receives the canonical WIP disclaimer exactly once', () => }); describe('CLI release file policy', () => { + test('release manifests omit exports whose targets are excluded from Maka artifacts', () => { + const repoRoot = resolve(import.meta.dirname, '..'); + for (const [directory, omitted] of Object.entries({ + mcp: ['./test-only/stdio-server'], + 'runtime-host': [ + './test-only/client-capability-host', + './test-only/execution-candidate-e2e-main', + ], + runtime: ['./test-only/fake-backend', './test-only/observation-text-reader'], + })) { + const manifestPath = join(repoRoot, 'packages', directory, 'package.json'); + const source = JSON.parse(readFileSync(manifestPath, 'utf8')); + const projected = workspaceReleaseManifest(source); + + assert.deepEqual( + Object.keys(source.exports).filter((subpath) => !Object.hasOwn(projected.exports, subpath)), + omitted, + ); + assert.deepEqual(JSON.parse(readFileSync(manifestPath, 'utf8')), source); + } + }); + + test('release manifests project conditional exports leaf by leaf', () => { + const source = { + exports: { + '.': { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + './fallback': [ + './dist/__tests__/fixture.js', + { types: './dist/fallback.d.ts', default: './dist/fallback.js' }, + ], + }, + }; + const original = structuredClone(source); + + assert.deepEqual(workspaceReleaseManifest(source).exports, { + '.': { default: './dist/index.js' }, + './fallback': [{ default: './dist/fallback.js' }], + }); + assert.deepEqual(source, original); + }); + + test('release manifests preserve a top-level export target array', () => { + assert.deepEqual( + workspaceReleaseManifest({ + exports: [ + './dist/__tests__/fixture.js', + { types: './dist/index.d.ts', default: './dist/index.js' }, + ], + }).exports, + [{ default: './dist/index.js' }], + ); + }); + test('derives the runtime workspace build order from production dependencies', () => { const manifests = new Map([ ['maka-agent', { name: 'maka-agent', dependencies: { '@maka/eval': '0.1.0' } }], diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 3af096661a..3ca6e535d2 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -48,6 +48,7 @@ import { releaseNpmEnvironment, resolveReleaseWorkspacePackages, resolveWorkspaceReleaseFiles, + workspaceReleaseManifest, } from './release-cli-file-policy.mjs'; const repoRoot = resolve(import.meta.dirname, '..'); @@ -425,27 +426,7 @@ function dependencyDestination(dependency) { function copyInternalPackage(source, destination) { mkdirSync(destination, { recursive: true, mode: 0o755 }); const manifest = readJson(join(source, 'package.json')); - const allowedFields = [ - 'name', - 'version', - 'description', - 'license', - 'type', - 'sideEffects', - 'main', - 'exports', - 'bin', - 'engines', - 'dependencies', - 'optionalDependencies', - 'peerDependencies', - 'peerDependenciesMeta', - ]; - const releaseManifest = Object.fromEntries( - allowedFields - .filter((field) => manifest[field] !== undefined) - .map((field) => [field, manifest[field]]), - ); + const releaseManifest = workspaceReleaseManifest(manifest); writeFileSync(join(destination, 'package.json'), `${JSON.stringify(releaseManifest, null, 2)}\n`); for (const releaseFile of resolveWorkspaceReleaseFiles(source, manifest)) { if (releaseFile === 'dist') copyRuntimeDist(source, destination, manifest.name);