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.
This commit is contained in:
Tianyi Cui committed 2026-10-07 14:07:28 +08:00
1 parent c26b658e61
commit b99a2c147f
7 files changed
+115 -40

No files matched your search

@@ -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}`,
},
},
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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/<profile>`, 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.
+1 -1
View File
@@ -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/<profile>` 中运行 `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。
+12 -13
View File
@@ -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<typeof sourceEvidence>
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<void> {
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<void> {
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<void>((resolveStop) => {
if (abort.signal.aborted) resolveStop()
else abort.signal.addEventListener('abort', () => { resolveStop() }, { once: true })
@@ -156,8 +153,10 @@ async function main(): Promise<void> {
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
}
+87 -14
View File
@@ -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"')
})
})
+8 -6
View File
@@ -4,7 +4,7 @@
*
* A packaged application installs an Official bundle as `name@<running DSH
* version>`, 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<string, WorkspacePackage>): boolean {
if (entries.includes(target)) return true
const visited = new Set<string>()
const pending = [...entries]
for (let index = 0; index < pending.length; index += 1) {
@@ -728,7 +730,7 @@ function dependencyPath(name: string, parents: ReadonlyMap<string, string>, entr
* @returns The package-relative path.
*/
function payloadPath(file: string): string {
return file.replaceAll('\\', '/').replace(/^\.\/+/u, '').replace(/^package\//u, '')
return file.replaceAll('\\', '/').replace(/^\.\/+/u, '')
}
/**