From b99a2c147f11b887dd6c9db241c7ad19025f1fff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:32:46 +0800 Subject: [PATCH] fix(scripts): address review findings in the local bundle registry - The summary lists only the catalog roots as installable specs; the other 29 archives stay in the run manifest, where the installer resolves them itself. - Only a pnpm entrypoint receives pnpm's arguments, a catalog root counts as a required edge for platform admission, and a signal that stops packing reports the interrupted exit instead of an exception. - The packed rehearsal names the Agent SDK's musl artifact on musl hosts again, and its comments match what the fixture does. - The cache test registers its pnpm children for termination, and a new case keeps one profile and its real lockfile: pnpm refuses a new run's URL with ERR_PNPM_TARBALL_URL_MISMATCH until the stale resolution is cleared with pnpm's own `pnpm clean --lockfile` remedy, which the desktop README now documents for same-version iteration. --- .../profiles/official-installer-packed.e2e.ts | 7 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- scripts/dev-bundle-registry.ts | 25 +++-- scripts/local-bundle-registry.spec.ts | 101 +++++++++++++++--- scripts/local-bundle-registry.ts | 14 +-- 7 files changed, 115 insertions(+), 40 deletions(-) diff --git a/apps/cli/tests/profiles/official-installer-packed.e2e.ts b/apps/cli/tests/profiles/official-installer-packed.e2e.ts index 400969638e..b662f264f4 100644 --- a/apps/cli/tests/profiles/official-installer-packed.e2e.ts +++ b/apps/cli/tests/profiles/official-installer-packed.e2e.ts @@ -59,8 +59,8 @@ it.skipIf(!built)('installs the exported registry\'s local closure and switches timeout: 900_000, retry: 0, }, async () => { - // The product under test is a real source-independent installation, and the fixture packs the two providers into it, - // so the product ships neither and both stay available to the Official catalog. + // The product under test is a real source-independent installation that ships neither provider, so both stay + // available to the Official catalog and only the local registry can supply them. const fixture = await createPackedInstallation('dsh-official-packed-', []) const { temporary, installation, home, profile, environment } = fixture @@ -108,7 +108,8 @@ it.skipIf(!built)('installs the exported registry\'s local closure and switches packages, localDependency: '@deepseek-ai/dsh-brand', nativePackages: { - '@deepseek-ai/dsh-subagent-claude-code': `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`, + // The Agent SDK publishes a musl variant of its Linux artifact, which is what pnpm installs there. + '@deepseek-ai/dsh-subagent-claude-code': `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}${hostPlatform().libc?.[0] === 'musl' ? '-musl' : ''}`, '@deepseek-ai/dsh-subagent-codex': `@openai/codex-${process.platform}-${process.arch}`, }, }, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c4c4b58186..9c95cd3111 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -48,8 +48,8 @@ en: 57869a5e3885f843 zh: 7cfc01b2dcbebd78 /deepseek-harness-desktop/package/local-official-bundles-from-a-loopback-registry: - en: e6a1ea5aa317af5b - zh: 70499589cde60903 + en: 5f05072ec82cc62d + zh: a3d63b58d1ef2db7 /deepseek-harness-desktop/package/runtime-file-selection: en: e7c1eb8e78fe46e3 zh: b7ea497dd75be524 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bbbc8b2977..57eb2cb333 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -262,7 +262,7 @@ The command validates the catalog and the workspace inventory, runs `pnpm run bu In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. -A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL. To test changed code at the same version, run the command again and remove, then reinstall the bundle; the Official **Update** control does not appear while the installed and running versions match. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. +A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ec1ac81dc2..d8256885dc 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -264,7 +264,7 @@ pnpm run dev:bundle-registry 在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 -一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL。要在同一版本上验证改动后的代码,请重新运行命令,然后移除并重新安装该 bundle;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 +一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 diff --git a/scripts/dev-bundle-registry.ts b/scripts/dev-bundle-registry.ts index 86978fd983..14313b25bd 100644 --- a/scripts/dev-bundle-registry.ts +++ b/scripts/dev-bundle-registry.ts @@ -67,23 +67,23 @@ async function runStep(root: string, args: readonly string[], signal: AbortSigna /** * Print the installation summary the developer copies from. - * @param options - run directory, registry URL, packed artifacts, source evidence, and foreign-target members. + * Only the catalog roots are installable specs; the rest of the closure is a + * dependency the installer resolves itself and stays in the run manifest. + * @param options - run directory, registry URL, catalog roots, packed artifacts, source evidence. */ function printSummary(options: { readonly directory: string readonly url: string + readonly entries: readonly string[] readonly artifacts: readonly BundleArtifact[] readonly source: ReturnType - readonly foreignTargets: readonly string[] }): void { + const roots = options.entries.flatMap(name => options.artifacts.filter(artifact => artifact.name === name)) process.stdout.write(`Source: ${options.source.commit}${options.source.dirty ? ' (dirty worktree)' : ''}, DSH ${options.source.dshVersion}\n`) - if (options.foreignTargets.length > 0) { - process.stdout.write(`Other platforms: ${options.foreignTargets.join(', ')} (served; pnpm skips them on this host)\n`) - } process.stdout.write(`Artifacts: ${options.directory}\n`) process.stdout.write(`Registry: ${options.url}\n`) - process.stdout.write('Packages:\n') - for (const artifact of options.artifacts) process.stdout.write(` ${artifact.name}@${artifact.version}\n`) + process.stdout.write(`Packages (${String(options.artifacts.length)} archives served):\n`) + for (const artifact of roots) process.stdout.write(` ${artifact.name}@${artifact.version}\n`) process.stdout.write('In Plugins > Add plugin, use an exact package above and this custom registry URL.\n') process.stdout.write('Keep this process running during package operations. Ctrl+C stops the registry.\n') } @@ -96,9 +96,6 @@ function runId(): string { /** Build, pack, and serve the local bundle closure until a signal stops the run. */ async function main(): Promise { const root = resolve(import.meta.dirname, '..') - if (!process.features.typescript) { - throw new Error('dev:bundle-registry: Node.js TypeScript type stripping is unavailable; remove --no-experimental-strip-types from NODE_OPTIONS') - } const abort = new AbortController() // A persistent listener, so a second signal during shutdown cannot fall back to the default kill; // the second signal is an explicit forced exit instead. @@ -146,7 +143,7 @@ async function main(): Promise { registry = await startBundleRegistry({ directory, artifacts, workspaceNames: selection.workspaceNames, }) - printSummary({ directory, url: registry.url, artifacts, source, foreignTargets: selection.foreignTargets }) + printSummary({ directory, url: registry.url, entries, artifacts, source }) await new Promise((resolveStop) => { if (abort.signal.aborted) resolveStop() else abort.signal.addEventListener('abort', () => { resolveStop() }, { once: true }) @@ -156,8 +153,10 @@ async function main(): Promise { process.stdout.write('Registry stopped. Artifacts and installed packages stay on disk.\n') } catch (error) { if (registry !== undefined) await registry.close() - if (error instanceof Interrupted) { - process.stderr.write(`dev:bundle-registry: ${error.message}\n`) + // A signal that stopped packing or packing's child is the same stop as a stopped build step. + if (error instanceof Interrupted || abort.signal.aborted) { + const stage = error instanceof Interrupted ? error.message : 'interrupted during preparation' + process.stderr.write(`dev:bundle-registry: ${stage}\n`) process.exitCode = 130 return } diff --git a/scripts/local-bundle-registry.spec.ts b/scripts/local-bundle-registry.spec.ts index 55c4feb284..c297daa6c6 100644 --- a/scripts/local-bundle-registry.spec.ts +++ b/scripts/local-bundle-registry.spec.ts @@ -435,6 +435,50 @@ describe('run record', () => { }) }) +/** + * Run one pnpm command in a profile-shaped directory, terminating and awaiting it with the test. + * @param options - consumer directory, environment, the store and cache directories an installing + * command shares, and the arguments to append. + * @returns The settled child result. + */ +async function pnpmIn(options: { + readonly consumer: string + readonly environment: NodeJS.ProcessEnv + readonly store?: string + readonly cache?: string + readonly args: readonly string[] +}): Promise<{ exitCode?: number | null; stdout: string; stderr: string; timedOut: boolean }> { + const invocation = pnpmInvocationFor([ + ...options.args, + ...options.store === undefined ? [] : ['--store-dir', options.store], + ...options.cache === undefined ? [] : ['--cache-dir', options.cache], + '--config.fetch-retries=0', + ]) + const child = execa(invocation.command, invocation.args, { + cwd: options.consumer, env: options.environment, extendEnv: false, reject: false, timeout: 240_000, + }) + onTestFinished(async () => { + child.kill('SIGKILL') + await child.catch(() => undefined) + }) + return child +} + +/** Prepare one isolated profile-shaped consumer directory. */ +function consumerDirectory(parent: string, name: string): string { + const consumer = join(parent, name) + mkdirSync(consumer, { recursive: true }) + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name, private: true, dependencies: {} })) + writeFileSync(join(consumer, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n') + return consumer +} + +/** The payload a consumer's installed fixture carries, or a marker when nothing is installed. */ +function installedPayload(consumer: string): string { + try { return readFileSync(join(consumer, 'node_modules', '@dsh-local', 'fixture', 'lib', 'index.js'), 'utf8').trim() } + catch { return '(absent)' } +} + describe('package cache freshness', () => { it('installs a rebuilt archive at the same version through a retained pnpm store', { timeout: 300_000 }, async () => { const temporary = temporaryDirectory('dsh-local-registry-cache-') @@ -453,24 +497,15 @@ describe('package cache freshness', () => { directory: destination, artifacts: [artifact], workspaceNames: ['@dsh-local/fixture'], }) onTestFinished(() => registry.close()) - const consumer = join(temporary, run, 'consumer') - mkdirSync(consumer, { recursive: true }) - writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: `consumer-${run}`, private: true, dependencies: {} })) - writeFileSync(join(consumer, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n') - const invocation = pnpmInvocationFor([ - 'add', '@dsh-local/fixture@1.0.0', `--registry=${registry.url}`, - '--store-dir', store, '--cache-dir', cache, '--config.lockfile=false', - ]) - const result = await execa(invocation.command, invocation.args, { - cwd: consumer, env: environment, extendEnv: false, reject: false, timeout: 240_000, + const consumer = consumerDirectory(temporary, `consumer-${run}`) + const result = await pnpmIn({ + consumer, environment, store, cache, + args: ['add', '@dsh-local/fixture@1.0.0', `--registry=${registry.url}`], }) expect(result.timedOut, `${result.stdout}\n${result.stderr}`).toBe(false) expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0) await registry.close() - return { - payload: readFileSync(join(consumer, 'node_modules', '@dsh-local', 'fixture', 'lib', 'index.js'), 'utf8'), - settings: readFileSync(join(consumer, 'pnpm-workspace.yaml'), 'utf8'), - } + return { payload: installedPayload(consumer), settings: readFileSync(join(consumer, 'pnpm-workspace.yaml'), 'utf8') } } const first = await install('first', 'export default "first build"\n') @@ -484,3 +519,41 @@ describe('package cache freshness', () => { expect(second.payload).toContain('second build') }) }) + +describe('same-version iteration', () => { + it('needs the earlier run\'s stale resolution cleared before a new run at the same version installs', { timeout: 300_000 }, async () => { + const temporary = temporaryDirectory('dsh-local-registry-iteration-') + const store = join(temporary, 'store') + const userConfig = join(temporary, 'empty.npmrc') + writeFileSync(userConfig, '') + const environment = { ...process.env, npm_config_userconfig: userConfig } + const consumer = consumerDirectory(temporary, 'profile') + const serve = async (tag: string, payload: string) => { + const destination = join(temporary, tag) + mkdirSync(destination, { recursive: true }) + const { artifact } = fabricate({ destination, name: '@dsh-local/fixture', version: '1.0.0', source: `export default ${payload}\n` }) + return startBundleRegistry({ directory: destination, artifacts: [artifact], workspaceNames: ['@dsh-local/fixture'] }) + } + const add = (url: string) => pnpmIn({ consumer, environment, store, args: ['add', '@dsh-local/fixture@1.0.0', `--registry=${url}`] }) + + const first = await serve('run-a', '"A"') + expect((await add(first.url)).exitCode).toBe(0) + expect(installedPayload(consumer)).toContain('"A"') + await first.close() + + // A new run answers at a new URL, so the recorded tarball no longer verifies against it. + const second = await serve('run-b', '"B"') + onTestFinished(() => second.close()) + const refused = await add(second.url) + expect(refused.exitCode).not.toBe(0) + expect(`${refused.stdout}${refused.stderr}`).toContain('ERR_PNPM_TARBALL_URL_MISMATCH') + expect(installedPayload(consumer)).toContain('"A"') + + // pnpm's own remedy for that error: drop the stale resolution, then resolve again from the new run. + const cleaned = await pnpmIn({ consumer, environment, args: ['clean', '--lockfile'] }) + expect(cleaned.exitCode, `${cleaned.stdout}\n${cleaned.stderr}`).toBe(0) + const reinstalled = await add(second.url) + expect(reinstalled.exitCode, `${reinstalled.stdout}\n${reinstalled.stderr}`).toBe(0) + expect(installedPayload(consumer)).toContain('"B"') + }) +}) diff --git a/scripts/local-bundle-registry.ts b/scripts/local-bundle-registry.ts index 5add6eb426..564e81d14c 100644 --- a/scripts/local-bundle-registry.ts +++ b/scripts/local-bundle-registry.ts @@ -4,7 +4,7 @@ * * A packaged application installs an Official bundle as `name@`, and the plugin manager asks a custom registry *alone* - * ([registry plan](../../packages/boot/plugin-manager/src/registry.ts)). Every + * ([registry plan](../packages/boot/plugin-manager/src/registry.ts)). Every * transitive dependency of the packed bundles must therefore be answerable by * this process: repository-owned packages come from the run's own artifacts, * and genuinely external packages keep npm's real metadata and bytes through a @@ -157,11 +157,12 @@ export interface ReleaseEntry { */ export function pnpmInvocationFor(args: readonly string[]): { command: string; args: string[] } { const entrypoint = process.env.npm_execpath - if (entrypoint === undefined || entrypoint === '') { - const [command, ...prefix] = pnpmCommand() - return { command, args: [...prefix, ...args] } + // `npm run` and `yarn run` set the same variable; handing them pnpm's arguments would write a different lockfile. + if (entrypoint !== undefined && /[\\/]pnpm[\\/]/u.test(entrypoint) && /\.[cm]?js$/u.test(entrypoint)) { + return pnpmInvocation(args) } - return pnpmInvocation(args) + const [command, ...prefix] = pnpmCommand() + return { command, args: [...prefix, ...args] } } /** @@ -686,6 +687,7 @@ function describePlatform(constraint: PlatformConstraint): string { * @returns True when a required dependency edge reaches the target. */ function reachedByRequiredEdge(target: string, entries: readonly string[], selected: ReadonlyMap): boolean { + if (entries.includes(target)) return true const visited = new Set() const pending = [...entries] for (let index = 0; index < pending.length; index += 1) { @@ -728,7 +730,7 @@ function dependencyPath(name: string, parents: ReadonlyMap, entr * @returns The package-relative path. */ function payloadPath(file: string): string { - return file.replaceAll('\\', '/').replace(/^\.\/+/u, '').replace(/^package\//u, '') + return file.replaceAll('\\', '/').replace(/^\.\/+/u, '') } /**