feat(scripts): serve locally built Official bundles from a loopback registry
A packaged application installs an Official on-demand bundle as `name@<running DSH version>`, and the plugin manager asks a custom registry alone, so the unsigned test DMG cannot install a locally built provider before its release reaches npm: each packed manifest still names the rest of the repository by version, and ordinary bundle tarballs therefore resolve against a registry that does not have them yet. `pnpm run dev:bundle-registry` validates the Official catalog and the workspace inventory, builds the official artifacts, packs the catalog's complete local dependency closure with the release families' own payload and manifest checks, records the run in `dist/test-bundles/<run-id>/bundle-registry.json` as its completion marker, and only then serves it from 127.0.0.1 on an allocated port under a fresh namespace. Repository-owned names are answered from this run or refused locally; third-party packages keep npm's real metadata and bytes through a fixed redirect. The run publishes nothing, changes no global npm/pnpm configuration, and leaves DMG packaging untouched. The packed installer rehearsal now installs that closure into an isolated profile with no `file:` override, checkout link, ignore list, or scoped registry standing in for a package the registry should answer, and it asserts a transitive repository package served only by this run.
This commit is contained in:
1 parent
c4bda67252
commit
73d853a9ae
9 files changed
+1646
-107
No files matched your search
@@ -22,7 +22,6 @@ function applied(stage, result) {
|
||||
|
||||
async function exercise(ctx, config) {
|
||||
const initial = await ctx.pluginManager.listBundles()
|
||||
await fetch(`${config.registry}catalog-observed`)
|
||||
const steps = []
|
||||
for (const name of config.packages) {
|
||||
const spec = initial.find(bundle => bundle.name === name)?.installTarget?.spec
|
||||
@@ -34,6 +33,8 @@ async function exercise(ctx, config) {
|
||||
applied(`install ${name}`, install)
|
||||
const manifest = JSON.parse(await readFile(join(config.profile, 'package.json'), 'utf8'))
|
||||
const installed = (await ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)
|
||||
// A transitive repository package the registry served, absent from npm at this version.
|
||||
const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version
|
||||
const provider = name.endsWith('-codex') ? 'codex' : 'claude-code'
|
||||
const providerEnabled = ctx.subagents.list().includes(provider)
|
||||
const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name])
|
||||
@@ -53,8 +54,8 @@ async function exercise(ctx, config) {
|
||||
const removed = await ctx.pluginManager.removeBundle(name)
|
||||
applied(`remove ${name}`, removed)
|
||||
const absent = (await ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)
|
||||
steps.push({ name, install, savedVersion: manifest.dependencies?.[name], installed, providerEnabled, nativeArtifact, presets,
|
||||
unavailable, unchangedAfterUnavailable, off, disabled, on, enabled, removed, absent })
|
||||
steps.push({ name, install, savedVersion: manifest.dependencies?.[name], installed, localDependency, providerEnabled, nativeArtifact,
|
||||
presets, unavailable, unchangedAfterUnavailable, off, disabled, on, enabled, removed, absent })
|
||||
}
|
||||
return { initial, steps, sessionCount: ctx.sessions.list().length }
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
/** Install actual packed Official providers through the ordinary manager in a source-independent Web process. */
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createReadStream, existsSync, globSync } from 'node:fs'
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -10,7 +8,21 @@ import { execa } from 'execa'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager'
|
||||
import { packedManifest } from '../../../../scripts/release/tarball.ts'
|
||||
import { getDshRuntimeVersion } from '../../../../packages/boot/app-boot/src/plugin-compatibility.ts'
|
||||
import { generateOfficialBundleCatalog } from '../../../../scripts/gen-official-bundle-catalog.ts'
|
||||
import {
|
||||
REGISTRY_MANIFEST_FILE,
|
||||
createRegistryManifest,
|
||||
hostPlatform,
|
||||
packBundleArtifacts,
|
||||
readReleaseMembers,
|
||||
readWorkspacePackages,
|
||||
selectBundleDependencies,
|
||||
sourceEvidence,
|
||||
startBundleRegistry,
|
||||
verifyBuiltBundles,
|
||||
writeRegistryManifest,
|
||||
} from '../../../../scripts/local-bundle-registry.ts'
|
||||
import { assertInstalledTree, createPackedInstallation } from './packed-installation.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
@@ -28,6 +40,7 @@ interface Observation {
|
||||
installed: BundleInfo
|
||||
providerEnabled: boolean
|
||||
nativeArtifact: boolean
|
||||
localDependency: string
|
||||
presets: Array<{ id: string; broken?: string }>
|
||||
unavailable: ChangeResult
|
||||
unchangedAfterUnavailable: boolean
|
||||
@@ -40,111 +53,48 @@ interface Observation {
|
||||
}>
|
||||
}
|
||||
|
||||
it.skipIf(!built)('installs exact Official versions and switches/removes them through the generic package manager', {
|
||||
// Real tarball packing and isolated dependency installation own this budget; no native model request runs.
|
||||
timeout: 360_000,
|
||||
it.skipIf(!built)('installs the exported registry\'s local closure and switches Official bundles through the generic manager', {
|
||||
// Real packing of two dependency closures, an isolated product installation, and an isolated plugin installation
|
||||
// own this budget; no native model request runs.
|
||||
timeout: 900_000,
|
||||
retry: 0,
|
||||
}, async () => {
|
||||
const fixture = await createPackedInstallation('dsh-official-packed-', packages)
|
||||
const { temporary, installation, home, profile, environment, tarballs, members, externalVersions } = fixture
|
||||
const manifests = new Map(packages.map((name) => {
|
||||
const archive = tarballs.get(name)
|
||||
if (archive === undefined) throw new Error(`missing provider archive ${name}`)
|
||||
return [name, { archive, manifest: packedManifest(archive) }]
|
||||
// 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.
|
||||
const fixture = await createPackedInstallation('dsh-official-packed-', [])
|
||||
const { temporary, installation, home, profile, environment } = fixture
|
||||
|
||||
// The registry packs and serves the catalog's own local dependency closure; no checkout link or file: override
|
||||
// stands in for a package the registry should answer.
|
||||
const entries = generateOfficialBundleCatalog(root).map(entry => entry.packageName)
|
||||
const workspace = readWorkspacePackages(root)
|
||||
verifyBuiltBundles({ entries, packages: workspace })
|
||||
const selection = selectBundleDependencies({ entries, packages: workspace, platform: hostPlatform() })
|
||||
const runDirectory = join(temporary, 'registry')
|
||||
const artifacts = await packBundleArtifacts({
|
||||
root, destination: runDirectory, members: selection.members, release: readReleaseMembers(root),
|
||||
})
|
||||
const version = artifacts.find(artifact => artifact.name === packages[0])?.version
|
||||
if (version === undefined) throw new Error('the local registry packed no claude-code provider')
|
||||
for (const name of packages) expect(artifacts.find(artifact => artifact.name === name), name).toMatchObject({ version })
|
||||
await writeRegistryManifest(join(runDirectory, REGISTRY_MANIFEST_FILE), createRegistryManifest({
|
||||
buildId: 'packed-e2e',
|
||||
source: sourceEvidence(root, getDshRuntimeVersion()),
|
||||
entries,
|
||||
workspaceNames: selection.workspaceNames,
|
||||
artifacts,
|
||||
}))
|
||||
const version = manifests.get(packages[0]!)?.manifest.version
|
||||
if (typeof version !== 'string') throw new Error('packed provider has no version')
|
||||
const integrity = new Map(await Promise.all([...manifests].map(async ([name, entry]) =>
|
||||
[name, `sha512-${createHash('sha512').update(await readFile(entry.archive)).digest('base64')}`] as const)))
|
||||
const requests: string[] = []
|
||||
let requestsBeforeCatalog: string[] | undefined
|
||||
let registry = ''
|
||||
const namespace = `/${randomUUID()}/`
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = new URL(request.url ?? '/', 'http://registry.invalid').pathname
|
||||
if (!pathname.startsWith(namespace)) { response.writeHead(404).end(); return }
|
||||
const path = decodeURIComponent(pathname.slice(namespace.length))
|
||||
if (path === 'catalog-observed') {
|
||||
requestsBeforeCatalog = [...requests]
|
||||
response.end('ok')
|
||||
return
|
||||
}
|
||||
requests.push(path)
|
||||
const archive = [...manifests].find(([name]) => path === `tarballs/${name}`)?.[1].archive
|
||||
if (archive !== undefined) {
|
||||
response.setHeader('content-type', 'application/octet-stream')
|
||||
createReadStream(archive).on('error', error => response.destroy(error)).pipe(response)
|
||||
return
|
||||
}
|
||||
const entry = manifests.get(path)
|
||||
if (entry === undefined) {
|
||||
// Only package publication is local: third-party metadata and payloads retain npm's real bytes.
|
||||
response.writeHead(302, { location: `https://registry.npmjs.org/${request.url?.slice(namespace.length) ?? ''}` }).end()
|
||||
return
|
||||
}
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify({ name: path, 'dist-tags': { latest: version }, time: { [version]: '2000-01-01T00:00:00.000Z' },
|
||||
versions: { [version]: { ...entry.manifest, dist: { tarball: `${registry}tarballs/${encodeURIComponent(path)}`, integrity: integrity.get(path) } } },
|
||||
}))
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
server.closeAllConnections()
|
||||
if (!server.listening) return
|
||||
await new Promise<void>((resolveClose, reject) => {
|
||||
server.close((error) => { if (error) reject(error); else resolveClose() })
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolveListen() })
|
||||
const registry = await startBundleRegistry({
|
||||
directory: runDirectory, artifacts, workspaceNames: selection.workspaceNames,
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('test registry has no TCP address')
|
||||
registry = `http://127.0.0.1:${String(address.port)}${namespace}`
|
||||
onTestFinished(() => registry.close())
|
||||
|
||||
const dependencies = new Set(packages)
|
||||
for (const name of dependencies) {
|
||||
const member = members.get(name)
|
||||
if (member === undefined) throw new Error(`missing packed dependency ${name}`)
|
||||
for (const [dependency, range] of Object.entries(member.manifest.dependencies as Record<string, string> | undefined ?? {})) {
|
||||
if (range.startsWith('workspace:')) dependencies.add(dependency)
|
||||
}
|
||||
}
|
||||
for (const name of packages) {
|
||||
dependencies.delete(name)
|
||||
expect(existsSync(join(installation, 'node_modules', name))).toBe(false)
|
||||
}
|
||||
await writeFile(join(profile, 'package.json'), JSON.stringify({ name: 'packed-official-profile', private: true, dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] } },
|
||||
}))
|
||||
const lock = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as {
|
||||
packages: Record<string, { os?: string[]; cpu?: string[]; libc?: string[] }>
|
||||
snapshots: Record<string, { optionalDependencies?: Record<string, string> }>
|
||||
}
|
||||
const report = process.report.getReport() as { header: { glibcVersionRuntime?: string } }
|
||||
const libc = process.platform === 'linux' ? report.header.glibcVersionRuntime === undefined ? 'musl' : 'glibc' : undefined
|
||||
// The fixture owns one host installation; foreign native artifacts are never executed or required here.
|
||||
const foreignPackages = new Set(Object.entries(lock.packages).filter(([, constraints]) =>
|
||||
([['os', process.platform], ['cpu', process.arch], ['libc', libc]] as const).some(([field, current]) => {
|
||||
const allowed = constraints[field]
|
||||
return current !== undefined && allowed !== undefined && !allowed.includes(current)
|
||||
&& !allowed.includes('any') && allowed.every(value => !value.startsWith('!'))
|
||||
})).map(([name]) => name))
|
||||
const ignoredOptionalDependencies = [...new Set(Object.values(lock.snapshots).flatMap(snapshot =>
|
||||
Object.entries(snapshot.optionalDependencies ?? {})
|
||||
.filter(([name, version]) => foreignPackages.has(`${name}@${version}`) || foreignPackages.has(version)).map(([name]) => name)))]
|
||||
const nativePackages = {
|
||||
'@deepseek-ai/dsh-subagent-claude-code': `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}${libc === 'musl' ? '-musl' : ''}`,
|
||||
'@deepseek-ai/dsh-subagent-codex': `@openai/codex-${process.platform}-${process.arch}`,
|
||||
}
|
||||
for (const name of Object.values(nativePackages)) expect(ignoredOptionalDependencies).not.toContain(name)
|
||||
await writeFile(join(profile, 'pnpm-workspace.yaml'), yaml.dump({ packages: ['.'], nodeLinker: 'hoisted', autoInstallPeers: false,
|
||||
ignoredOptionalDependencies,
|
||||
overrides: { ...externalVersions, ...Object.fromEntries([...dependencies].map(name => [name, `file:${tarballs.get(name)!}`])) },
|
||||
}))
|
||||
const externalScopes = new Set(Object.keys(externalVersions)
|
||||
.filter(name => name.startsWith('@') && !name.startsWith('@deepseek-ai/')).map(name => name.slice(0, name.indexOf('/'))))
|
||||
await writeFile(join(profile, '.npmrc'), [...externalScopes].map(scope => `${scope}:registry=https://registry.npmjs.org/`).join('\n') + '\n')
|
||||
// Only the settings a real profile gets. No override, ignore list, or scoped .npmrc may route a package around the
|
||||
// registry, so every dependency of the packed bundles is answered by this run or by its fixed upstream redirect.
|
||||
await writeFile(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n')
|
||||
const userConfig = join(temporary, 'empty.npmrc')
|
||||
await writeFile(userConfig, '')
|
||||
environment.npm_config_userconfig = userConfig
|
||||
@@ -152,7 +102,16 @@ it.skipIf(!built)('installs exact Official versions and switches/removes them th
|
||||
await writeFile(observer, await readFile(new URL('./fixtures/official-installer-observer.mjs', import.meta.url), 'utf8'))
|
||||
const patch = join(temporary, 'official-installer.patch.yml')
|
||||
await writeFile(patch, yaml.dump([{ insert: [{ id: 'official-installer-observer', name: observer,
|
||||
config: { registry, profile, packages, nativePackages },
|
||||
config: {
|
||||
registry: registry.url,
|
||||
profile,
|
||||
packages,
|
||||
localDependency: '@deepseek-ai/dsh-brand',
|
||||
nativePackages: {
|
||||
'@deepseek-ai/dsh-subagent-claude-code': `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`,
|
||||
'@deepseek-ai/dsh-subagent-codex': `@openai/codex-${process.platform}-${process.arch}`,
|
||||
},
|
||||
},
|
||||
}] }]))
|
||||
const child = execa(process.execPath, [join(installation, 'node_modules/@deepseek-ai/dsh/lib/bin.js'),
|
||||
'web', '--patch', patch, '--no-open', '--port', '0'], {
|
||||
@@ -170,10 +129,10 @@ it.skipIf(!built)('installs exact Official versions and switches/removes them th
|
||||
})
|
||||
void child.then((result) => { reject(new Error(`Web process exited before observation:\n${result.stdout}\n${result.stderr}`)) })
|
||||
})
|
||||
expect(requestsBeforeCatalog).toEqual([])
|
||||
for (const name of packages) expect(observed.initial.find(entry => entry.name === name)).toMatchObject({
|
||||
official: true, availability: 'missing', installed: false, enabled: false, installTarget: { spec: `${name}@${version}`, version },
|
||||
})
|
||||
expect(observed.steps).toHaveLength(packages.length)
|
||||
for (const step of observed.steps) {
|
||||
expect(step.install, JSON.stringify(step.install)).toMatchObject({ application: 'applied', enabled: true, version })
|
||||
expect(step.savedVersion).toBe(version)
|
||||
@@ -182,6 +141,8 @@ it.skipIf(!built)('installs exact Official versions and switches/removes them th
|
||||
.toEqual(['preset-cordis', 'preset-ptc', 'preset-standard'])
|
||||
expect(step.providerEnabled).toBe(true)
|
||||
expect(step.nativeArtifact).toBe(true)
|
||||
// A transitive repository package the closure needs can only have come from this run's artifacts.
|
||||
expect(step.localDependency).toBe(version)
|
||||
expect(step.presets.every(preset => preset.broken === undefined)).toBe(true)
|
||||
expect(step.unavailable).toMatchObject({ application: 'failed', stage: 'install' })
|
||||
expect(step.unchangedAfterUnavailable).toBe(true)
|
||||
@@ -192,10 +153,14 @@ it.skipIf(!built)('installs exact Official versions and switches/removes them th
|
||||
expect(step.removed, JSON.stringify(step.removed)).toMatchObject({ application: 'applied' })
|
||||
expect(step.absent).toMatchObject({ official: true, availability: 'missing', enabled: false, installed: false })
|
||||
}
|
||||
expect(observed.steps).toHaveLength(packages.length)
|
||||
expect(observed.sessionCount).toBe(0)
|
||||
expect(globSync('sessions/**/*.jsonl*', { cwd: home })).toEqual([])
|
||||
await assertInstalledTree(profile)
|
||||
// The completed run records every package it served, so a failure can be matched to the exact checkout.
|
||||
const record = JSON.parse(await readFile(join(runDirectory, REGISTRY_MANIFEST_FILE), 'utf8')) as { buildId: string; packages: unknown[] }
|
||||
expect(record.buildId).toBe('packed-e2e')
|
||||
expect(record.packages).toHaveLength(selection.members.length)
|
||||
expect(record.packages).toEqual(expect.arrayContaining([expect.objectContaining({ name: packages[0], version })]))
|
||||
} finally {
|
||||
lines.close()
|
||||
child.kill('SIGTERM')
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing:
|
||||
en: 57869a5e3885f843
|
||||
zh: 7cfc01b2dcbebd78
|
||||
/deepseek-harness-desktop/package/local-official-bundles-from-a-loopback-registry:
|
||||
en: e6a1ea5aa317af5b
|
||||
zh: 70499589cde60903
|
||||
/deepseek-harness-desktop/package/runtime-file-selection:
|
||||
en: e7c1eb8e78fe46e3
|
||||
zh: b7ea497dd75be524
|
||||
|
||||
@@ -250,6 +250,22 @@ Add `--check` to validate local configuration and tools without building, or `--
|
||||
|
||||
These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application.
|
||||
|
||||
### Local Official bundles from a loopback registry
|
||||
|
||||
The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure:
|
||||
|
||||
```sh
|
||||
pnpm run dev:bundle-registry
|
||||
```
|
||||
|
||||
The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles/<run-id>/` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Runtime file selection
|
||||
|
||||
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
|
||||
|
||||
@@ -252,6 +252,22 @@ pnpm run package:desktop:mac:x64:unsigned
|
||||
|
||||
这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。
|
||||
|
||||
### 用回环 registry 安装本地 Official 包
|
||||
|
||||
测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包:
|
||||
|
||||
```sh
|
||||
pnpm run dev:bundle-registry
|
||||
```
|
||||
|
||||
该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles/<run-id>/` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。
|
||||
|
||||
在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。
|
||||
|
||||
一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL。要在同一版本上验证改动后的代码,请重新运行命令,然后移除并重新安装该 bundle;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。`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。
|
||||
|
||||
### 运行时文件筛选
|
||||
|
||||
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
|
||||
"start:web": "node --import tsx/esm apps/cli/src/bin.ts web",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"dev:bundle-registry": "tsx scripts/dev-bundle-registry.ts",
|
||||
"postinstall": "node scripts/install-lefthook.mjs",
|
||||
"gen-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts",
|
||||
"verify-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts --check",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Build, pack, and serve the Official on-demand bundles' local dependency
|
||||
* closure as a loopback registry, so a packaged test app can install them
|
||||
* before their release reaches npm.
|
||||
*
|
||||
* The run is a build/test utility, not a Harness application launcher: it
|
||||
* starts no agent and needs no credentials. It shares only the artifact
|
||||
* transport with the release path; packing, manifests, and validation stay with
|
||||
* the owners that already perform them.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
import { getDshRuntimeVersion } from '../packages/boot/app-boot/src/plugin-compatibility.ts'
|
||||
import { generateOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
|
||||
import {
|
||||
REGISTRY_MANIFEST_FILE,
|
||||
REGISTRY_OUTPUT_ROOT,
|
||||
createRegistryManifest,
|
||||
hostPlatform,
|
||||
packBundleArtifacts,
|
||||
pnpmInvocationFor,
|
||||
readReleaseMembers,
|
||||
readWorkspacePackages,
|
||||
selectBundleDependencies,
|
||||
sourceEvidence,
|
||||
startBundleRegistry,
|
||||
verifyBuiltBundles,
|
||||
verifyReleaseVersions,
|
||||
writeRegistryManifest,
|
||||
type BundleArtifact,
|
||||
} from './local-bundle-registry.ts'
|
||||
|
||||
/** The preparation step a signal stopped, so the failure is not reported as a command's. */
|
||||
class Interrupted extends Error {
|
||||
/**
|
||||
* @param stage - the step that was running when the signal arrived.
|
||||
*/
|
||||
constructor(readonly stage: string) {
|
||||
super(`interrupted during ${stage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one package script in the repository root, propagating its exit status.
|
||||
* @param root - repository root the command runs in.
|
||||
* @param args - pnpm arguments.
|
||||
* @param signal - abort signal that stops the child on SIGINT/SIGTERM.
|
||||
* @param label - step name used in the failure message.
|
||||
*/
|
||||
async function runStep(root: string, args: readonly string[], signal: AbortSignal, label: string): Promise<void> {
|
||||
const invocation = pnpmInvocationFor(args)
|
||||
let result
|
||||
try {
|
||||
result = await execa(invocation.command, invocation.args, {
|
||||
cwd: root, stdio: 'inherit', reject: false, killDescendants: true, cancelSignal: signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw new Interrupted(label)
|
||||
throw error
|
||||
}
|
||||
if (result.isCanceled || signal.aborted) throw new Interrupted(label)
|
||||
if (result.exitCode !== 0) throw new Error(`${label} exited with ${String(result.exitCode ?? result.signal)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the installation summary the developer copies from.
|
||||
* @param options - run directory, registry URL, packed artifacts, source evidence, and foreign-target members.
|
||||
*/
|
||||
function printSummary(options: {
|
||||
readonly directory: string
|
||||
readonly url: string
|
||||
readonly artifacts: readonly BundleArtifact[]
|
||||
readonly source: ReturnType<typeof sourceEvidence>
|
||||
readonly foreignTargets: readonly string[]
|
||||
}): void {
|
||||
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('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')
|
||||
}
|
||||
|
||||
/** Build one run directory name: sortable by time and unique per invocation. */
|
||||
function runId(): string {
|
||||
return `${new Date().toISOString().replace(/[:.]/gu, '-')}-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
/** 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.
|
||||
let stopping = false
|
||||
const onSignal = (signal: NodeJS.Signals): void => {
|
||||
if (stopping) {
|
||||
process.exit(130)
|
||||
return
|
||||
}
|
||||
stopping = true
|
||||
abort.abort(signal)
|
||||
}
|
||||
process.on('SIGINT', onSignal)
|
||||
process.on('SIGTERM', onSignal)
|
||||
|
||||
let registry
|
||||
try {
|
||||
// 1. The catalog is the only root list; the inventory is how a local package is distinguished from an external one.
|
||||
const entries = generateOfficialBundleCatalog(root).map(entry => entry.packageName)
|
||||
const packages = readWorkspacePackages(root)
|
||||
const selection = selectBundleDependencies({ entries, packages, platform: hostPlatform() })
|
||||
const release = readReleaseMembers(root)
|
||||
verifyReleaseVersions(root)
|
||||
|
||||
// 2. Current official artifacts, so the run never serves an earlier build.
|
||||
await runStep(root, ['run', 'build:official'], abort.signal, 'build:official')
|
||||
verifyBuiltBundles({ entries, packages })
|
||||
|
||||
// 3. One immutable, run-owned directory; a later run never overwrites these bytes.
|
||||
const buildId = runId()
|
||||
const directory = join(root, REGISTRY_OUTPUT_ROOT, buildId)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const artifacts = await packBundleArtifacts({
|
||||
root, destination: directory, members: selection.members, release, signal: abort.signal,
|
||||
})
|
||||
|
||||
// 4. The manifest is the completion marker: only a fully packed run has one.
|
||||
const source = sourceEvidence(root, getDshRuntimeVersion())
|
||||
const manifest = createRegistryManifest({
|
||||
buildId, source, entries, workspaceNames: selection.workspaceNames, artifacts,
|
||||
})
|
||||
await writeRegistryManifest(join(directory, REGISTRY_MANIFEST_FILE), manifest)
|
||||
|
||||
// 5. Serve, then announce: the summary is never printed for a registry that did not listen.
|
||||
registry = await startBundleRegistry({
|
||||
directory, artifacts, workspaceNames: selection.workspaceNames,
|
||||
})
|
||||
printSummary({ directory, url: registry.url, artifacts, source, foreignTargets: selection.foreignTargets })
|
||||
await new Promise<void>((resolveStop) => {
|
||||
if (abort.signal.aborted) resolveStop()
|
||||
else abort.signal.addEventListener('abort', () => { resolveStop() }, { once: true })
|
||||
})
|
||||
await registry.close()
|
||||
registry = undefined
|
||||
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`)
|
||||
process.exitCode = 130
|
||||
return
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
process.off('SIGINT', onSignal)
|
||||
process.off('SIGTERM', onSignal)
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,456 @@
|
||||
/** Behavior of the local Official-bundle registry: closure selection, archive validation, HTTP, and shutdown. */
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
import { generateOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
|
||||
import {
|
||||
createRegistryManifest,
|
||||
hostPlatform,
|
||||
pnpmInvocationFor,
|
||||
readPackedArtifacts,
|
||||
readReleaseMembers,
|
||||
readWorkspacePackages,
|
||||
selectBundleDependencies,
|
||||
startBundleRegistry,
|
||||
writeRegistryManifest,
|
||||
type BundleArtifact,
|
||||
type ReleaseEntry,
|
||||
type WorkspacePackage,
|
||||
} from './local-bundle-registry.ts'
|
||||
import { releaseFamily, type ReleaseMember } from './release/families.ts'
|
||||
import { capture } from './release/process.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/** A private temporary root this spec owns, removed with the test. */
|
||||
function temporaryDirectory(prefix: string): string {
|
||||
const directory = realpathSync(mkdtempSync(join(tmpdir(), prefix)))
|
||||
onTestFinished(() => { rmSync(directory, { recursive: true, force: true }) })
|
||||
return directory
|
||||
}
|
||||
|
||||
/** One fabricated workspace package with a `1.0.0` version line. */
|
||||
function workspacePackage(name: string, manifest: Record<string, unknown>): WorkspacePackage {
|
||||
return { name, directory: `/workspace/${name.replace('@', '').replace('/', '-')}`, manifest: { name, version: '1.0.0', ...manifest } }
|
||||
}
|
||||
|
||||
/** An inventory of fabricated packages, keyed the way discovery keys them. */
|
||||
function inventory(...members: WorkspacePackage[]): Map<string, WorkspacePackage> {
|
||||
return new Map(members.map(member => [member.name, member]))
|
||||
}
|
||||
|
||||
/** One fabricated package's archive together with the identities needed to read it back. */
|
||||
interface Fixture {
|
||||
readonly member: WorkspacePackage
|
||||
readonly entry: ReleaseEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack one fabricated package archive into a run directory.
|
||||
* @param options - destination, identity, source text, dependency ranges, and an optional declared bundle patch.
|
||||
* @returns The workspace identity and the release entry its tarball must satisfy.
|
||||
*/
|
||||
function packFixture(options: {
|
||||
readonly destination: string
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
readonly source: string
|
||||
readonly dependencies?: Record<string, string>
|
||||
readonly patch?: string
|
||||
}): Fixture {
|
||||
const staging = join(options.destination, `${options.name.replace('@', '').replace('/', '-')}-staging`)
|
||||
const manifest: Record<string, unknown> = {
|
||||
name: options.name,
|
||||
version: options.version,
|
||||
main: 'lib/index.js',
|
||||
...options.dependencies === undefined ? {} : { dependencies: options.dependencies },
|
||||
...options.patch === undefined ? {} : { dsh: { bundle: { patch: options.patch } } },
|
||||
}
|
||||
mkdirSync(join(staging, 'package', 'lib'), { recursive: true })
|
||||
writeFileSync(join(staging, 'package', 'package.json'), JSON.stringify(manifest))
|
||||
writeFileSync(join(staging, 'package', 'lib', 'index.js'), options.source)
|
||||
if (options.patch !== undefined) writeFileSync(join(staging, 'package', options.patch), '[]\n')
|
||||
const file = `${options.name.replace('@', '').replace('/', '-')}-${options.version}.tgz`
|
||||
capture('tar', ['-czf', join(options.destination, file), '-C', staging, 'package'])
|
||||
const releaseMember: ReleaseMember = { directory: staging, name: options.name, version: options.version, manifest }
|
||||
return {
|
||||
member: workspacePackage(options.name, { version: options.version, ...manifest }),
|
||||
entry: { family: releaseFamily('vendor'), member: releaseMember },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a fixture archive and read it back through the real archive validation.
|
||||
* @param options - the fixture to pack.
|
||||
* @returns The servable artifact and the identities it was packed from.
|
||||
*/
|
||||
function fabricate(options: Parameters<typeof packFixture>[0]): Fixture & { readonly artifact: BundleArtifact } {
|
||||
const fixture = packFixture(options)
|
||||
const [artifact] = readPackedArtifacts({
|
||||
destination: options.destination,
|
||||
members: [fixture.member],
|
||||
release: new Map([[fixture.member.name, fixture.entry]]),
|
||||
})
|
||||
if (artifact === undefined) throw new Error('fabricated artifact was not read back')
|
||||
return { ...fixture, artifact }
|
||||
}
|
||||
|
||||
/** Start a registry for one fabricated run, closed with the test. */
|
||||
async function servingRun(options: {
|
||||
readonly destination: string
|
||||
readonly artifacts: readonly BundleArtifact[]
|
||||
readonly workspaceNames: readonly string[]
|
||||
}): Promise<string> {
|
||||
const registry = await startBundleRegistry({
|
||||
directory: options.destination, artifacts: options.artifacts, workspaceNames: options.workspaceNames,
|
||||
})
|
||||
onTestFinished(() => registry.close())
|
||||
return registry.url
|
||||
}
|
||||
|
||||
describe('catalog and local closure', () => {
|
||||
it('selects the real catalog closure from this checkout', () => {
|
||||
const entries = generateOfficialBundleCatalog(root).map(entry => entry.packageName)
|
||||
expect(entries).toEqual([...ON_DEMAND_BUNDLES])
|
||||
const packages = readWorkspacePackages(root)
|
||||
const release = readReleaseMembers(root)
|
||||
const selection = selectBundleDependencies({ entries, packages, platform: hostPlatform() })
|
||||
|
||||
expect(selection.members.map(member => member.name)).toEqual(expect.arrayContaining([...entries]))
|
||||
for (const member of selection.members) {
|
||||
expect(member.manifest.private, member.name).not.toBe(true)
|
||||
expect(release.has(member.name), member.name).toBe(true)
|
||||
}
|
||||
expect(selection.workspaceNames).toEqual(expect.arrayContaining(selection.members.map(member => member.name)))
|
||||
// A vendor package keeps its own version line, so a run must not assume one shared version.
|
||||
expect(packages.get('@deepseek-ai/cordis')?.manifest.version)
|
||||
.not.toBe(packages.get('@deepseek-ai/dsh-brand')?.manifest.version)
|
||||
// Nothing in this closure is built for another platform, on any host.
|
||||
expect(selection.foreignTargets).toEqual([])
|
||||
})
|
||||
|
||||
it('follows install, optional, and required peer edges but not optional peers', () => {
|
||||
const selection = selectBundleDependencies({
|
||||
entries: ['@scope/root'],
|
||||
packages: inventory(
|
||||
workspacePackage('@scope/root', {
|
||||
dependencies: { '@scope/required': 'workspace:*' },
|
||||
optionalDependencies: { '@scope/optional': 'workspace:*' },
|
||||
peerDependencies: { '@scope/peer': 'workspace:*', '@scope/unused-peer': 'workspace:*' },
|
||||
peerDependenciesMeta: { '@scope/unused-peer': { optional: true } },
|
||||
}),
|
||||
workspacePackage('@scope/required', {}),
|
||||
workspacePackage('@scope/optional', { dependencies: { '@scope/transitive': 'workspace:*' } }),
|
||||
workspacePackage('@scope/peer', {}),
|
||||
workspacePackage('@scope/transitive', {}),
|
||||
workspacePackage('@scope/unused-peer', {}),
|
||||
workspacePackage('@scope/unreferenced', {}),
|
||||
),
|
||||
platform: { os: ['darwin'] },
|
||||
})
|
||||
expect(selection.members.map(member => member.name)).toEqual([
|
||||
'@scope/optional', '@scope/peer', '@scope/required', '@scope/root', '@scope/transitive',
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a dependency that is not a workspace package to its own registry', () => {
|
||||
const selection = selectBundleDependencies({
|
||||
entries: ['@scope/root'],
|
||||
packages: inventory(workspacePackage('@scope/root', {
|
||||
dependencies: { '@other/external': '^1.0.0', 'plain-external': '^2.0.0' },
|
||||
})),
|
||||
platform: {},
|
||||
})
|
||||
expect(selection.members.map(member => member.name)).toEqual(['@scope/root'])
|
||||
})
|
||||
|
||||
it('fails when a catalog root is not a workspace package', () => {
|
||||
expect(() => selectBundleDependencies({ entries: ['@scope/missing'], packages: inventory(), platform: {} }))
|
||||
.toThrow(/cannot resolve workspace package @scope\/missing \(catalog entry @scope\/missing\)/u)
|
||||
})
|
||||
|
||||
it('rejects an empty or duplicate catalog', () => {
|
||||
expect(() => selectBundleDependencies({ entries: [], packages: inventory(), platform: {} }))
|
||||
.toThrow(/no on-demand entries/u)
|
||||
expect(() => selectBundleDependencies({ entries: ['@scope/a', '@scope/a'], packages: inventory(), platform: {} }))
|
||||
.toThrow(/must be distinct/u)
|
||||
})
|
||||
|
||||
it('fails on a required edge that targets another platform, naming the path', () => {
|
||||
expect(() => selectBundleDependencies({
|
||||
entries: ['@scope/root'],
|
||||
packages: inventory(
|
||||
workspacePackage('@scope/root', { dependencies: { '@scope/native-win': 'workspace:*' } }),
|
||||
workspacePackage('@scope/native-win', { os: ['win32'] }),
|
||||
),
|
||||
platform: { os: ['darwin'], cpu: ['arm64'] },
|
||||
})).toThrow(/@scope\/root -> @scope\/native-win requires @scope\/native-win, which targets os=win32; this host is os=darwin cpu=arm64/u)
|
||||
})
|
||||
|
||||
it('keeps an optional-only foreign-target member and reports it', () => {
|
||||
const selection = selectBundleDependencies({
|
||||
entries: ['@scope/root'],
|
||||
packages: inventory(
|
||||
workspacePackage('@scope/root', { optionalDependencies: { '@scope/native-win': 'workspace:*' } }),
|
||||
workspacePackage('@scope/native-win', { os: ['win32'] }),
|
||||
),
|
||||
platform: { os: ['darwin'] },
|
||||
})
|
||||
expect(selection.members.map(member => member.name)).toEqual(['@scope/native-win', '@scope/root'])
|
||||
expect(selection.foreignTargets).toEqual(['@scope/native-win'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('packed archives', () => {
|
||||
it('describes exactly the bytes it will serve', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-archive-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n' })
|
||||
const bytes = readFileSync(artifact.archive)
|
||||
expect(artifact.bytes).toBe(bytes.byteLength)
|
||||
expect(artifact.integrity).toBe(`sha512-${createHash('sha512').update(bytes).digest('base64')}`)
|
||||
expect(artifact.shasum).toBe(createHash('sha1').update(bytes).digest('hex'))
|
||||
expect(artifact.file).toBe('scope-pkg-1.0.0.tgz')
|
||||
expect(artifact.platform).toEqual({})
|
||||
expect(artifact.manifest.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('rejects an archive that lacks the main it declares', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-payload-')
|
||||
const staging = join(destination, 'staging')
|
||||
mkdirSync(join(staging, 'package'), { recursive: true })
|
||||
writeFileSync(join(staging, 'package', 'package.json'), JSON.stringify({
|
||||
name: '@scope/pkg', version: '1.0.0', main: 'lib/index.js', dsh: { bundle: { patch: 'cordis.patch.yml' } },
|
||||
}))
|
||||
capture('tar', ['-czf', join(destination, 'scope-pkg-1.0.0.tgz'), '-C', staging, 'package'])
|
||||
const member = workspacePackage('@scope/pkg', { version: '1.0.0' })
|
||||
const entry: ReleaseEntry = {
|
||||
family: releaseFamily('vendor'), member: { directory: staging, name: '@scope/pkg', version: '1.0.0', manifest: member.manifest },
|
||||
}
|
||||
expect(() => readPackedArtifacts({ destination, members: [member], release: new Map([['@scope/pkg', entry]]) }))
|
||||
.toThrow(/@scope\/pkg: packed archive lacks its declared main lib\/index\.js/u)
|
||||
})
|
||||
|
||||
it('rejects a bundle whose patch is missing from its archive', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-patch-')
|
||||
const { member, entry } = packFixture({
|
||||
destination, name: '@scope/bundle', version: '1.0.0', source: 'export default 1\n', patch: 'cordis.patch.yml',
|
||||
})
|
||||
const archive = join(destination, 'scope-bundle-1.0.0.tgz')
|
||||
const stripped = join(destination, 'stripped')
|
||||
mkdirSync(join(stripped, 'package'), { recursive: true })
|
||||
writeFileSync(join(stripped, 'package', 'package.json'), JSON.stringify({
|
||||
name: '@scope/bundle', version: '1.0.0', main: 'lib/index.js', dsh: { bundle: { patch: 'cordis.patch.yml' } },
|
||||
}))
|
||||
mkdirSync(join(stripped, 'package', 'lib'), { recursive: true })
|
||||
writeFileSync(join(stripped, 'package', 'lib', 'index.js'), 'export default 1\n')
|
||||
capture('tar', ['-czf', archive, '-C', stripped, 'package'])
|
||||
expect(() => readPackedArtifacts({ destination, members: [member], release: new Map([['@scope/bundle', entry]]) }))
|
||||
.toThrow(/@scope\/bundle: packed archive lacks its declared bundle patch cordis\.patch\.yml/u)
|
||||
})
|
||||
|
||||
it('rejects a missing archive and a package that is not a public release member', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-missing-')
|
||||
const missing = workspacePackage('@scope/pkg', { version: '1.0.0' })
|
||||
const entry: ReleaseEntry = {
|
||||
family: releaseFamily('vendor'), member: { directory: destination, name: '@scope/pkg', version: '1.0.0', manifest: missing.manifest },
|
||||
}
|
||||
expect(() => readPackedArtifacts({ destination, members: [missing], release: new Map([['@scope/pkg', entry]]) }))
|
||||
.toThrow(/@scope\/pkg produced no tarball/u)
|
||||
fabricate({ destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n' })
|
||||
expect(() => readPackedArtifacts({ destination, members: [missing], release: new Map() }))
|
||||
.toThrow(/not a public release member/u)
|
||||
})
|
||||
|
||||
it('rejects a packed manifest that keeps a workspace protocol', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-protocol-')
|
||||
const { member, entry } = packFixture({
|
||||
destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n',
|
||||
dependencies: { '@scope/dependency': 'workspace:*' },
|
||||
})
|
||||
// The fixture writes the manifest verbatim, so a protocol a real pack rewrites is still observable here.
|
||||
expect(() => readPackedArtifacts({ destination, members: [member], release: new Map([['@scope/pkg', entry]]) }))
|
||||
.toThrow(/packed dependencies\.@scope\/dependency still declares workspace:\*/u)
|
||||
})
|
||||
|
||||
it('rejects a packed local dependency this run does not serve at the required version', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-version-')
|
||||
const consumer = packFixture({
|
||||
destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n',
|
||||
dependencies: { '@scope/dependency': '^2.0.0' },
|
||||
})
|
||||
const dependency = packFixture({ destination, name: '@scope/dependency', version: '1.0.0', source: 'export default 2\n' })
|
||||
const release = new Map([[consumer.member.name, consumer.entry], [dependency.member.name, dependency.entry]])
|
||||
expect(() => readPackedArtifacts({ destination, members: [consumer.member, dependency.member], release }))
|
||||
.toThrow(/@scope\/pkg: packed dependencies\.@scope\/dependency requires \^2\.0\.0, but this run serves 1\.0\.0/u)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registry HTTP', () => {
|
||||
it('answers scoped packuments, version documents, and dist tags for its own artifacts', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-http-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.2.3-alpha.1', source: 'export default 1\n' })
|
||||
const url = await servingRun({ destination, artifacts: [artifact], workspaceNames: ['@scope/local', '@scope/absent'] })
|
||||
|
||||
// pnpm encodes the scope separator; an unencoded path names the same package.
|
||||
for (const path of ['@scope%2flocal', '@scope/local']) {
|
||||
const response = await fetch(`${url}${path}`)
|
||||
expect(response.status, path).toBe(200)
|
||||
const packument = await response.json() as {
|
||||
name: string
|
||||
'dist-tags': Record<string, string>
|
||||
versions: Record<string, { dist: { tarball: string; integrity: string } }>
|
||||
time: Record<string, string>
|
||||
}
|
||||
expect(packument.name).toBe('@scope/local')
|
||||
expect(packument['dist-tags']).toEqual({ latest: '1.2.3-alpha.1', next: '1.2.3-alpha.1' })
|
||||
expect(packument.versions['1.2.3-alpha.1']?.dist.tarball).toBe(`${url}tarballs/scope-local-1.2.3-alpha.1.tgz`)
|
||||
expect(packument.versions['1.2.3-alpha.1']?.dist.integrity).toBe(artifact.integrity)
|
||||
// The only publication time a run can claim is when its own archives were packed.
|
||||
expect(packument.time['1.2.3-alpha.1']).toBe(artifact.packedAt)
|
||||
}
|
||||
for (const path of ['@scope%2flocal/1.2.3-alpha.1', '@scope/local/latest', '@scope/local/next']) {
|
||||
const response = await fetch(`${url}${path}`)
|
||||
expect(response.status, path).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ name: '@scope/local', version: '1.2.3-alpha.1' })
|
||||
}
|
||||
const unknown = await fetch(`${url}@scope%2flocal/9.9.9`)
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toMatchObject({ reason: 'this run serves @scope/[email protected] only' })
|
||||
})
|
||||
|
||||
it('serves exactly the archived bytes, and no body for HEAD', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-tarball-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
const url = await servingRun({ destination, artifacts: [artifact], workspaceNames: ['@scope/local'] })
|
||||
const response = await fetch(`${url}tarballs/${artifact.file}`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(readFileSync(artifact.archive))
|
||||
const head = await fetch(`${url}tarballs/${artifact.file}`, { method: 'HEAD' })
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe(String(artifact.bytes))
|
||||
expect(await head.text()).toBe('')
|
||||
expect((await fetch(`${url}tarballs/no-such-package-1.0.0.tgz`)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('keeps a repository-owned miss local and redirects only external packages', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-miss-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
const url = await servingRun({
|
||||
destination, artifacts: [artifact], workspaceNames: ['@scope/local', '@deepseek-ai/dsh-private-thing'],
|
||||
})
|
||||
const owned = await fetch(`${url}@deepseek-ai%2fdsh-private-thing`)
|
||||
expect(owned.status).toBe(404)
|
||||
const refusal = await owned.json() as { reason?: string }
|
||||
expect(refusal.reason ?? '').toContain('repository-owned')
|
||||
// A repository-owned name that is not even in the closure must never reach npm.
|
||||
expect((await fetch(`${url}@deepseek-ai%2fdsh-private-thing/1.0.0`)).status).toBe(404)
|
||||
|
||||
const external = await fetch(`${url}@anthropic-ai%2fclaude-agent-sdk`, { redirect: 'manual' })
|
||||
expect(external.status).toBe(302)
|
||||
expect(external.headers.get('location')).toBe('https://registry.npmjs.org/@anthropic-ai%2fclaude-agent-sdk')
|
||||
})
|
||||
|
||||
it('rejects out-of-namespace, malformed, and non-GET requests, then stops listening', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-reject-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
const registry = await startBundleRegistry({ directory: destination, artifacts: [artifact], workspaceNames: ['@scope/local'] })
|
||||
onTestFinished(() => registry.close())
|
||||
const { origin, pathname } = new URL(registry.url)
|
||||
|
||||
expect((await fetch(`${origin}/@scope%2flocal`)).status).toBe(404)
|
||||
expect((await fetch(`${origin}${pathname}%zz`)).status).toBe(404)
|
||||
expect((await fetch(`${registry.url}@scope%2flocal/%2e%2e%2f%2e%2e%2fetc%2fpasswd`)).status).toBe(404)
|
||||
expect((await fetch(`${registry.url}@scope%2flocal/1.0.0/extra`)).status).toBe(404)
|
||||
expect((await fetch(`${registry.url}tarballs/%2e%2e%2f%2e%2e%2fetc%2fpasswd`)).status).toBe(404)
|
||||
expect((await fetch(`${registry.url}-/ping`)).status).toBe(404)
|
||||
expect((await fetch(registry.url, { method: 'POST', body: 'x' })).status).toBe(405)
|
||||
|
||||
const other = await startBundleRegistry({ directory: destination, artifacts: [artifact], workspaceNames: ['@scope/local'] })
|
||||
onTestFinished(() => other.close())
|
||||
expect(other.url).not.toBe(registry.url)
|
||||
await registry.close()
|
||||
await expect(fetch(`${registry.url}@scope%2flocal`)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('run record', () => {
|
||||
it('names the run, its membership, and the integrity of every served archive', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-manifest-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
const manifest = createRegistryManifest({
|
||||
buildId: 'run-1',
|
||||
source: { commit: 'a'.repeat(40), dirty: true, dshVersion: '0.0.0-test' },
|
||||
entries: ['@scope/local'],
|
||||
workspaceNames: ['@scope/local', '@scope/absent'],
|
||||
artifacts: [artifact],
|
||||
})
|
||||
expect(manifest).toEqual({
|
||||
schemaVersion: 1,
|
||||
buildId: 'run-1',
|
||||
source: { commit: 'a'.repeat(40), dirty: true, dshVersion: '0.0.0-test' },
|
||||
entries: ['@scope/local'],
|
||||
workspaceNames: ['@scope/local', '@scope/absent'],
|
||||
packages: [{ name: '@scope/local', version: '1.0.0', file: artifact.file, bytes: artifact.bytes, integrity: artifact.integrity }],
|
||||
})
|
||||
const path = join(destination, 'bundle-registry.json')
|
||||
await writeRegistryManifest(path, manifest)
|
||||
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(manifest)
|
||||
expect(readFileSync(path, 'utf8').endsWith('}\n')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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-')
|
||||
const store = join(temporary, 'store')
|
||||
const cache = join(temporary, 'cache')
|
||||
const userConfig = join(temporary, 'empty.npmrc')
|
||||
writeFileSync(userConfig, '')
|
||||
// A retained store, metadata cache, and npm configuration, so the second installation cannot pass by starting empty.
|
||||
const environment = { ...process.env, npm_config_userconfig: userConfig }
|
||||
|
||||
const install = async (run: string, source: string): Promise<{ payload: string; settings: string }> => {
|
||||
const destination = join(temporary, run, 'artifacts')
|
||||
mkdirSync(destination, { recursive: true })
|
||||
const { artifact } = fabricate({ destination, name: '@dsh-local/fixture', version: '1.0.0', source })
|
||||
const registry = await startBundleRegistry({
|
||||
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/[email protected]', `--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,
|
||||
})
|
||||
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'),
|
||||
}
|
||||
}
|
||||
|
||||
const first = await install('first', 'export default "first build"\n')
|
||||
expect(first.payload).toContain('first build')
|
||||
// pnpm 11's default minimumReleaseAge is 24 hours, and its non-strict mode records the exemption it granted
|
||||
// instead of faking an older publication time for a locally packed artifact.
|
||||
expect(first.settings).toContain('minimumReleaseAgeExclude')
|
||||
expect(first.settings).toContain('@dsh-local/[email protected]')
|
||||
|
||||
const second = await install('second', 'export default "second build"\n')
|
||||
expect(second.payload).toContain('second build')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* Pack the local dependency closure of the Official on-demand bundles and serve
|
||||
* it as a loopback npm registry.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* fixed redirect. Publication is untouched; only one development run's
|
||||
* transport is local.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createReadStream, existsSync, globSync, readFileSync, statSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
import yaml from 'js-yaml'
|
||||
import semver from 'semver'
|
||||
import { pnpmInvocation } from './pnpm-invocation.ts'
|
||||
import { capture, pnpmCommand } from './release/process.ts'
|
||||
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './release/families.ts'
|
||||
import { packedManifest, tarballFiles } from './release/tarball.ts'
|
||||
|
||||
/** Run directories a registry invocation owns, relative to the repository root. */
|
||||
export const REGISTRY_OUTPUT_ROOT = 'dist/test-bundles'
|
||||
|
||||
/** Manifest filename whose presence marks a run directory as complete. */
|
||||
export const REGISTRY_MANIFEST_FILE = 'bundle-registry.json'
|
||||
|
||||
/** Registry every external package is redirected to, with the request path preserved. */
|
||||
export const UPSTREAM_REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
/** Installation edges a consumer cannot load without. */
|
||||
const REQUIRED_SECTIONS = ['dependencies'] as const
|
||||
|
||||
/** Edges pnpm may skip on this host; their packages still need local metadata. */
|
||||
const OPTIONAL_SECTIONS = ['optionalDependencies'] as const
|
||||
|
||||
/** Edges that constrain an installation without installing on the package's behalf. */
|
||||
const PEER_SECTIONS = ['peerDependencies'] as const
|
||||
|
||||
/** One workspace package available to pack and serve. */
|
||||
export interface WorkspacePackage {
|
||||
/** Package name exactly as it is declared. */
|
||||
readonly name: string
|
||||
/** Absolute package directory. */
|
||||
readonly directory: string
|
||||
/** Parsed `package.json`. */
|
||||
readonly manifest: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Platform constraints a package declares for its own artifacts. */
|
||||
export interface PlatformConstraint {
|
||||
/** Accepted `process.platform` values, which may be `!value` negations. */
|
||||
readonly os?: readonly string[]
|
||||
/** Accepted `process.arch` values, which may be `!value` negations. */
|
||||
readonly cpu?: readonly string[]
|
||||
/** Accepted libc values on Linux, which may be `!value` negations. */
|
||||
readonly libc?: readonly string[]
|
||||
}
|
||||
|
||||
/** One packed package archive the registry answers with. */
|
||||
export interface BundleArtifact {
|
||||
/** Package name from the packed manifest. */
|
||||
readonly name: string
|
||||
/** Packed version, served exactly. */
|
||||
readonly version: string
|
||||
/** Absolute archive path in the run directory. */
|
||||
readonly archive: string
|
||||
/** Tarball filename inside the run directory. */
|
||||
readonly file: string
|
||||
/** Archive size in bytes, as served. */
|
||||
readonly bytes: number
|
||||
/** SHA-512 subresource integrity of the served bytes. */
|
||||
readonly integrity: string
|
||||
/** SHA-1 hex digest of the served bytes. */
|
||||
readonly shasum: string
|
||||
/** Manifest contained in the archive, which the registry serves as the version metadata. */
|
||||
readonly manifest: Record<string, unknown>
|
||||
/** Platform constraints the packed manifest declares for itself. */
|
||||
readonly platform: PlatformConstraint
|
||||
/** Dist tag this version publishes under, from its release family. */
|
||||
readonly distTag?: string
|
||||
/** When this run packed the archive; the only publication time the run can honestly claim. */
|
||||
readonly packedAt: string
|
||||
}
|
||||
|
||||
/** The catalog roots together with the local packages an ordinary installation needs. */
|
||||
export interface BundleSelection {
|
||||
/** Catalog roots, in admission order. */
|
||||
readonly entries: readonly string[]
|
||||
/** Local closure, sorted by package name. */
|
||||
readonly members: readonly WorkspacePackage[]
|
||||
/** Every repository-owned name, so a request for one is a local miss rather than an upstream redirect. */
|
||||
readonly workspaceNames: readonly string[]
|
||||
/** Members built for another platform, kept so pnpm reads their metadata and applies its own skip. */
|
||||
readonly foreignTargets: readonly string[]
|
||||
}
|
||||
|
||||
/** What one run records beside its artifacts; the file is written only once the run is complete. */
|
||||
export interface RegistryManifest {
|
||||
/** Manifest layout version. */
|
||||
readonly schemaVersion: 1
|
||||
/** Unique run directory name. */
|
||||
readonly buildId: string
|
||||
/** Which bytes these artifacts were packed from. */
|
||||
readonly source: {
|
||||
/** Full Git object id of the packed checkout. */
|
||||
readonly commit: string
|
||||
/** Whether the checkout had uncommitted changes, so the commit alone is not the snapshot. */
|
||||
readonly dirty: boolean
|
||||
/** Running DSH version the catalog installation target names. */
|
||||
readonly dshVersion: string
|
||||
}
|
||||
/** Catalog roots in admission order. */
|
||||
readonly entries: readonly string[]
|
||||
/** Every repository-owned name. */
|
||||
readonly workspaceNames: readonly string[]
|
||||
/** Packed packages, sorted by name. */
|
||||
readonly packages: readonly {
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
readonly file: string
|
||||
readonly bytes: number
|
||||
readonly integrity: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/** A running loopback registry serving one immutable run directory. */
|
||||
export interface BundleRegistry {
|
||||
/** Registry URL ending in the run's fresh namespace and a slash, as `--registry` takes it. */
|
||||
readonly url: string
|
||||
/** Stop dispatching, drop open connections, and resolve once the listener is gone. */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** One public release member and the family that owns its payload rules. */
|
||||
export interface ReleaseEntry {
|
||||
/** Family owning this member's payload and version rules. */
|
||||
readonly family: ReleaseFamily
|
||||
/** The member as its family publishes it. */
|
||||
readonly member: ReleaseMember
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve pnpm for a shell-free child process.
|
||||
*
|
||||
* A package script's own pnpm is authoritative, because it owns the workspace
|
||||
* lockfile and store; a direct invocation (`pnpm exec`, a test runner) has no
|
||||
* lifecycle environment, so the workspace's own pnpm dependency runs instead.
|
||||
* @param args - pnpm arguments.
|
||||
* @returns A command and argument array suitable for `spawn` without a shell.
|
||||
*/
|
||||
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] }
|
||||
}
|
||||
return pnpmInvocation(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pnpm workspace inventory.
|
||||
* @param root - repository root containing `pnpm-workspace.yaml`.
|
||||
* @returns Workspace packages indexed by name.
|
||||
*/
|
||||
export function readWorkspacePackages(root: string): Map<string, WorkspacePackage> {
|
||||
const settings: unknown = yaml.load(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'))
|
||||
if (!isRecord(settings)) throw new Error('pnpm-workspace.yaml must be a mapping')
|
||||
const patterns = settings.packages
|
||||
if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every(value => typeof value === 'string')) {
|
||||
throw new Error('pnpm-workspace.yaml must list package patterns')
|
||||
}
|
||||
const packages = new Map<string, WorkspacePackage>()
|
||||
const paths = globSync(patterns.map(pattern => `${pattern}/package.json`), { cwd: root })
|
||||
.map(path => path.replaceAll('\\', '/'))
|
||||
.sort()
|
||||
for (const path of paths) {
|
||||
const manifest = manifestAt(join(root, path))
|
||||
const name = manifest.name
|
||||
if (typeof name !== 'string' || name === '') throw new Error(`${path} must declare a string name`)
|
||||
if (packages.has(name)) throw new Error(`duplicate workspace package ${name}`)
|
||||
packages.set(name, { name, directory: resolve(root, path, '..'), manifest })
|
||||
}
|
||||
if (packages.size === 0) throw new Error('the pnpm workspace inventory is empty')
|
||||
return packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Index the release families' public members by package name.
|
||||
* @param root - repository root containing the release families.
|
||||
* @returns Public release members keyed by package name.
|
||||
*/
|
||||
export function readReleaseMembers(root: string): Map<string, ReleaseEntry> {
|
||||
const members = new Map<string, ReleaseEntry>()
|
||||
for (const id of ['dsh', 'vendor']) {
|
||||
const family = releaseFamily(id)
|
||||
for (const member of family.members(root)) members.set(member.name, { family, member })
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert each family's own version baseline, which is what one tag publishes.
|
||||
* @param root - repository root containing the release families.
|
||||
*/
|
||||
export function verifyReleaseVersions(root: string): void {
|
||||
for (const id of ['dsh', 'vendor']) {
|
||||
const family = releaseFamily(id)
|
||||
family.verifyVersions(family.members(root))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require every catalog root to be a built, patch-carrying bundle in this checkout.
|
||||
*
|
||||
* The catalog generator already checks admission metadata; this checks the
|
||||
* artifacts a packed consumer would actually load, so a stale or partial build
|
||||
* fails before any archive is served.
|
||||
* @param options - catalog roots in admission order and the workspace inventory.
|
||||
*/
|
||||
export function verifyBuiltBundles(options: {
|
||||
readonly entries: readonly string[]
|
||||
readonly packages: ReadonlyMap<string, WorkspacePackage>
|
||||
}): void {
|
||||
for (const name of options.entries) {
|
||||
const member = options.packages.get(name)
|
||||
if (member === undefined) throw new Error(`catalog entry ${name} is not a workspace package`)
|
||||
const main = member.manifest.main
|
||||
if (typeof main !== 'string' || main === '') throw new Error(`${name} declares no package main to load`)
|
||||
const entry = resolve(member.directory, main)
|
||||
if (!existsSync(entry) || !statSync(entry).isFile()) {
|
||||
throw new Error(`${name}: built runtime is missing at ${entry}; run pnpm run build:official first`)
|
||||
}
|
||||
const dsh = member.manifest.dsh
|
||||
if (!isRecord(dsh) || !isRecord(dsh.bundle)) throw new Error(`${name} declares no dsh.bundle`)
|
||||
const patches = declaredBundlePatches(member.manifest)
|
||||
if (patches.length === 0) throw new Error(`${name}: dsh.bundle.patch must be a file path or a list of file paths`)
|
||||
for (const patch of patches) {
|
||||
const path = join(member.directory, patch)
|
||||
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`${name}: bundle patch ${path} is missing`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the platform constraints a manifest declares for its own artifacts.
|
||||
* @param manifest - package manifest.
|
||||
* @returns The declared `os`, `cpu`, and `libc` lists.
|
||||
*/
|
||||
export function platformConstraint(manifest: Record<string, unknown>): PlatformConstraint {
|
||||
const lists: { os?: readonly string[]; cpu?: readonly string[]; libc?: readonly string[] } = {}
|
||||
for (const field of ['os', 'cpu', 'libc'] as const) {
|
||||
const value = manifest[field]
|
||||
if (Array.isArray(value) && value.every(entry => typeof entry === 'string')) lists[field] = value
|
||||
}
|
||||
return lists
|
||||
}
|
||||
|
||||
/**
|
||||
* This host's platform values, for comparing against a package's own constraints.
|
||||
* @returns Host `os`, `cpu`, and libc.
|
||||
*/
|
||||
export function hostPlatform(): PlatformConstraint {
|
||||
if (process.platform !== 'linux') return { os: [process.platform], cpu: [process.arch] }
|
||||
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } }
|
||||
return { os: [process.platform], cpu: [process.arch], libc: [report.header?.glibcVersionRuntime === undefined ? 'musl' : 'glibc'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a package's declared platform accepts this host.
|
||||
* @param constraint - declared constraints.
|
||||
* @param host - host `os`, `cpu`, and libc values.
|
||||
* @returns True when the package may run here.
|
||||
*/
|
||||
export function matchesPlatform(constraint: PlatformConstraint, host: PlatformConstraint): boolean {
|
||||
return allowsValue(constraint.os, host.os?.[0])
|
||||
&& allowsValue(constraint.cpu, host.cpu?.[0])
|
||||
&& allowsValue(constraint.libc, host.libc?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the catalog roots and the local packages an ordinary installation needs.
|
||||
*
|
||||
* Every dependency naming a workspace package is followed, whatever its section
|
||||
* or protocol, so a locally owned requirement is never left to npm. A required
|
||||
* edge naming a package built for another platform fails here, because the run
|
||||
* cannot supply the artifact that installer would need; an optional edge is
|
||||
* kept so pnpm still reads its metadata and applies its own platform skip.
|
||||
* @param options - catalog roots, workspace inventory, and the host to judge platform edges against.
|
||||
* @returns The closure, the repository-owned names, and the foreign-target members it kept.
|
||||
*/
|
||||
export function selectBundleDependencies(options: {
|
||||
readonly entries: readonly string[]
|
||||
readonly packages: ReadonlyMap<string, WorkspacePackage>
|
||||
readonly platform: PlatformConstraint
|
||||
}): BundleSelection {
|
||||
const { entries, packages, platform } = options
|
||||
if (entries.length === 0) throw new Error('the Official catalog has no on-demand entries')
|
||||
if (new Set(entries).size !== entries.length) throw new Error('Official catalog entries must be distinct')
|
||||
const selected = new Map<string, WorkspacePackage>()
|
||||
const parents = new Map<string, string>()
|
||||
const queued = new Set<string>()
|
||||
const queue: string[] = []
|
||||
const enqueue = (name: string, parent: string | undefined): void => {
|
||||
if (selected.has(name) || queued.has(name)) return
|
||||
queued.add(name)
|
||||
if (parent !== undefined) parents.set(name, parent)
|
||||
queue.push(name)
|
||||
}
|
||||
for (const entry of entries) enqueue(entry, undefined)
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const name = queue[index] as string
|
||||
const member = packages.get(name)
|
||||
if (member === undefined) {
|
||||
throw new Error(`local bundle closure cannot resolve workspace package ${name} (${dependencyPath(name, parents, entries)})`)
|
||||
}
|
||||
selected.set(name, member)
|
||||
for (const name of installedEdges(member.manifest)) {
|
||||
if (packages.has(name)) enqueue(name, member.name)
|
||||
}
|
||||
}
|
||||
|
||||
const foreign: string[] = []
|
||||
for (const [name, member] of selected) {
|
||||
if (matchesPlatform(platformConstraint(member.manifest), platform)) continue
|
||||
if (reachedByRequiredEdge(name, entries, selected)) {
|
||||
throw new Error(
|
||||
`${dependencyPath(name, parents, entries)} requires ${name}, which targets`
|
||||
+ ` ${describePlatform(platformConstraint(member.manifest))}; this host is ${describePlatform(platform)}.`
|
||||
+ ' This run cannot supply a matching artifact for that package.',
|
||||
)
|
||||
}
|
||||
foreign.push(name)
|
||||
}
|
||||
|
||||
return {
|
||||
entries: [...entries],
|
||||
members: [...selected.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||
workspaceNames: [...packages.keys()].sort(),
|
||||
foreignTargets: foreign.sort(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack the selected packages into one run directory and validate every archive
|
||||
* against the release family that owns it.
|
||||
* @param options - repository root, destination, selected members, and the public release index.
|
||||
* @returns Packed artifacts sorted by package name.
|
||||
*/
|
||||
export async function packBundleArtifacts(options: {
|
||||
readonly root: string
|
||||
readonly destination: string
|
||||
readonly members: readonly WorkspacePackage[]
|
||||
readonly release: ReadonlyMap<string, ReleaseEntry>
|
||||
readonly signal?: AbortSignal
|
||||
}): Promise<BundleArtifact[]> {
|
||||
const { root, destination, members, release } = options
|
||||
if (members.length === 0) throw new Error('no local packages were selected to pack')
|
||||
await mkdir(destination, { recursive: true })
|
||||
const invocation = pnpmInvocationFor([
|
||||
'pack', '--recursive', `--workspace-concurrency=${String(Math.min(4, members.length))}`,
|
||||
...members.map(member => `--filter=${member.name}`), '--pack-destination', destination,
|
||||
])
|
||||
const result = await execa(invocation.command, invocation.args, {
|
||||
cwd: root, reject: false, killDescendants: true,
|
||||
...options.signal === undefined ? {} : { cancelSignal: options.signal },
|
||||
})
|
||||
if (result.isCanceled) throw new Error('packing was cancelled')
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`pnpm pack exited with ${String(result.exitCode ?? result.signal)}:\n${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
return readPackedArtifacts({ destination, members, release })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate already packed archives and describe them as servable artifacts.
|
||||
* @param options - run directory, the members that were packed into it, and the public release index.
|
||||
* @returns Packed artifacts sorted by package name.
|
||||
*/
|
||||
export function readPackedArtifacts(options: {
|
||||
readonly destination: string
|
||||
readonly members: readonly WorkspacePackage[]
|
||||
readonly release: ReadonlyMap<string, ReleaseEntry>
|
||||
}): BundleArtifact[] {
|
||||
const { destination, members, release } = options
|
||||
const packed = members.map((member) => {
|
||||
const entry = release.get(member.name)
|
||||
if (entry === undefined) {
|
||||
throw new Error(`${member.name}: not a public release member; the local registry does not republish a private or unreleased package`)
|
||||
}
|
||||
const file = tarballName(entry.member)
|
||||
const archive = join(destination, file)
|
||||
if (!existsSync(archive)) throw new Error(`${member.name} produced no tarball at ${archive}`)
|
||||
const contained = tarballFiles(archive)
|
||||
entry.family.validatePayload(entry.member, contained)
|
||||
const manifest = packedManifest(archive)
|
||||
entry.family.validatePackedManifest(entry.member, manifest)
|
||||
const main = manifest.main
|
||||
if (typeof main === 'string' && main !== '' && !contained.includes(`package/${payloadPath(main)}`)) {
|
||||
throw new Error(`${entry.member.name}: packed archive lacks its declared main ${main}; rebuild before serving`)
|
||||
}
|
||||
// A bundle whose patch is missing installs and then never loads its rows.
|
||||
for (const patch of declaredBundlePatches(manifest)) {
|
||||
if (!contained.includes(`package/${payloadPath(patch)}`)) {
|
||||
throw new Error(`${entry.member.name}: packed archive lacks its declared bundle patch ${patch}`)
|
||||
}
|
||||
}
|
||||
const bytes = readFileSync(archive)
|
||||
const distTag = entry.family.distTagForVersion(entry.member.version)
|
||||
return {
|
||||
name: entry.member.name,
|
||||
version: entry.member.version,
|
||||
archive,
|
||||
file,
|
||||
bytes: bytes.byteLength,
|
||||
integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
|
||||
shasum: createHash('sha1').update(bytes).digest('hex'),
|
||||
manifest,
|
||||
platform: platformConstraint(manifest),
|
||||
...distTag === undefined ? {} : { distTag },
|
||||
packedAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
verifyPackedClosure(packed)
|
||||
return packed.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the record a completed run directory carries.
|
||||
* @param options - run identity, selection, artifacts, and source evidence.
|
||||
* @returns The manifest to write last.
|
||||
*/
|
||||
export function createRegistryManifest(options: {
|
||||
readonly buildId: string
|
||||
readonly source: RegistryManifest['source']
|
||||
readonly entries: readonly string[]
|
||||
readonly workspaceNames: readonly string[]
|
||||
readonly artifacts: readonly BundleArtifact[]
|
||||
}): RegistryManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
buildId: options.buildId,
|
||||
source: options.source,
|
||||
entries: [...options.entries],
|
||||
workspaceNames: [...options.workspaceNames],
|
||||
packages: [...options.artifacts]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(artifact => ({
|
||||
name: artifact.name,
|
||||
version: artifact.version,
|
||||
file: artifact.file,
|
||||
bytes: artifact.bytes,
|
||||
integrity: artifact.integrity,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the completion marker into a run directory.
|
||||
* @param path - absolute manifest path inside the run directory.
|
||||
* @param manifest - the record to serialize.
|
||||
*/
|
||||
export async function writeRegistryManifest(path: string, manifest: RegistryManifest): Promise<void> {
|
||||
await writeFile(path, `${JSON.stringify(manifest, undefined, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which checkout produced a run.
|
||||
* @param root - repository root.
|
||||
* @param dshVersion - running DSH version the catalog installation target names.
|
||||
* @returns The commit, whether the tree was dirty, and the version served.
|
||||
*/
|
||||
export function sourceEvidence(root: string, dshVersion: string): RegistryManifest['source'] {
|
||||
return {
|
||||
commit: capture('git', ['rev-parse', 'HEAD'], { cwd: root }),
|
||||
dirty: capture('git', ['status', '--porcelain'], { cwd: root }) !== '',
|
||||
dshVersion,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the loopback registry for one run's artifacts.
|
||||
*
|
||||
* The listener binds `127.0.0.1` on an allocated port and answers under a fresh
|
||||
* namespace, so two runs never share a URL even when the port is reused.
|
||||
* @param options - the run's artifacts and directory, repository-owned names, and upstream registry.
|
||||
* @returns The registry URL and its close operation.
|
||||
*/
|
||||
export async function startBundleRegistry(options: {
|
||||
readonly directory: string
|
||||
readonly artifacts: readonly BundleArtifact[]
|
||||
readonly workspaceNames: readonly string[]
|
||||
readonly upstream?: string
|
||||
}): Promise<BundleRegistry> {
|
||||
if (options.artifacts.length === 0) throw new Error('the local registry was started without artifacts')
|
||||
const upstream = options.upstream ?? UPSTREAM_REGISTRY
|
||||
if (!upstream.endsWith('/')) throw new Error(`upstream registry must end with a slash: ${upstream}`)
|
||||
const index = new Map(options.artifacts.map(artifact => [artifact.name, artifact]))
|
||||
const owned = new Set(options.workspaceNames)
|
||||
const namespace = randomUUID()
|
||||
// Assigned once the listener reports its allocated port; requests cannot arrive before then.
|
||||
let origin = ''
|
||||
const server = createServer((request, response) => {
|
||||
const method = request.method ?? 'GET'
|
||||
const head = method === 'HEAD'
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
respond(response, 405, { error: 'Method Not Allowed', reason: `${method} is not served` }, false)
|
||||
return
|
||||
}
|
||||
const raw = request.url ?? '/'
|
||||
const query = raw.indexOf('?')
|
||||
const path = query === -1 ? raw : raw.slice(0, query)
|
||||
const prefix = `/${namespace}/`
|
||||
if (!path.startsWith(prefix)) {
|
||||
respond(response, 404, { error: 'Not found', reason: 'request is outside this run\'s registry namespace' }, head)
|
||||
return
|
||||
}
|
||||
const suffix = path.slice(prefix.length)
|
||||
if (suffix.startsWith('tarballs/')) {
|
||||
serveTarball(suffix.slice('tarballs/'.length), response, head, options.directory, index)
|
||||
return
|
||||
}
|
||||
const requested = selection(suffix)
|
||||
if (requested === undefined) {
|
||||
respond(response, 404, { error: 'Not found', reason: 'malformed package path' }, head)
|
||||
return
|
||||
}
|
||||
const artifact = index.get(requested.name)
|
||||
if (artifact !== undefined) {
|
||||
serveMetadata(artifact, requested.version, response, head, origin)
|
||||
return
|
||||
}
|
||||
if (owned.has(requested.name)) {
|
||||
respond(response, 404, {
|
||||
error: 'Not found',
|
||||
reason: `${requested.name} is repository-owned but not part of this run's local closure; rebuild the registry after changing the catalog`,
|
||||
}, head)
|
||||
return
|
||||
}
|
||||
// Only package publication is local: third-party metadata and payloads keep npm's real bytes.
|
||||
response.writeHead(302, { location: `${upstream}${suffix}`, 'cache-control': 'no-store' })
|
||||
response.end()
|
||||
})
|
||||
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', rejectListen)
|
||||
resolveListen()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
await closeServer(server)
|
||||
throw new Error('the local registry has no TCP address')
|
||||
}
|
||||
origin = `http://127.0.0.1:${String(address.port)}/${namespace}/`
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
url: origin,
|
||||
close: () => {
|
||||
closing ??= closeServer(server)
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON manifest from disk.
|
||||
* @param path - absolute `package.json` path.
|
||||
* @returns The parsed object.
|
||||
*/
|
||||
function manifestAt(path: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (!isRecord(parsed)) throw new Error(`${path} is not a JSON object`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a parsed value is a JSON object.
|
||||
* @param value - value to inspect.
|
||||
* @returns True for a non-null, non-array object.
|
||||
*/
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one dependency section as declared ranges.
|
||||
* @param manifest - package manifest.
|
||||
* @param section - section name.
|
||||
* @returns Name and range pairs, with malformed entries rejected.
|
||||
*/
|
||||
function dependencyRanges(manifest: Record<string, unknown>, section: string): [string, string][] {
|
||||
const value = manifest[section]
|
||||
if (value === undefined) return []
|
||||
if (!isRecord(value)) throw new Error(`${section} must be a mapping`)
|
||||
return Object.entries(value).map(([name, range]) => {
|
||||
if (typeof range !== 'string') throw new Error(`${section}.${name} must be a string range`)
|
||||
return [name, range]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Every package this manifest installs on a consumer: required, optional, and
|
||||
* required peers.
|
||||
* @param manifest - package manifest.
|
||||
* @returns Dependency names in section order.
|
||||
*/
|
||||
function installedEdges(manifest: Record<string, unknown>): string[] {
|
||||
const names = [...REQUIRED_SECTIONS, ...OPTIONAL_SECTIONS].flatMap(section => dependencyRanges(manifest, section).map(([name]) => name))
|
||||
for (const section of PEER_SECTIONS) {
|
||||
for (const [name] of dependencyRanges(manifest, section)) {
|
||||
if (!optionalPeer(manifest, name)) names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a package opts out of a peer it declares.
|
||||
* @param manifest - package manifest.
|
||||
* @param name - peer package name.
|
||||
* @returns True when the peer is optional.
|
||||
*/
|
||||
function optionalPeer(manifest: Record<string, unknown>, name: string): boolean {
|
||||
const metadata = manifest.peerDependenciesMeta
|
||||
if (!isRecord(metadata)) return false
|
||||
const entry = metadata[name]
|
||||
return isRecord(entry) && entry.optional === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one constrained value accepts the host's own value.
|
||||
* @param constraint - accepted values, which may be only `!value` negations.
|
||||
* @param current - the host's value, or undefined when the field does not apply here.
|
||||
* @returns True when the host value is allowed.
|
||||
*/
|
||||
function allowsValue(constraint: readonly string[] | undefined, current: string | undefined): boolean {
|
||||
if (constraint === undefined || constraint.length === 0) return true
|
||||
// A field the host does not have (libc off Linux) constrains nothing here.
|
||||
if (current === undefined) return true
|
||||
const positives = constraint.filter(value => !value.startsWith('!'))
|
||||
if (positives.length > 0 && !positives.includes('any') && !positives.includes(current)) return false
|
||||
return !constraint.includes(`!${current}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a platform constraint for a failure message.
|
||||
* @param constraint - declared or host constraints.
|
||||
* @returns A compact description.
|
||||
*/
|
||||
function describePlatform(constraint: PlatformConstraint): string {
|
||||
const parts = (['os', 'cpu', 'libc'] as const).flatMap(field => (constraint[field] ?? []).map(value => `${field}=${value}`))
|
||||
return parts.length === 0 ? 'any platform' : parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any required edge reaches a package, ignoring optional and peer edges.
|
||||
* @param target - package to look for.
|
||||
* @param entries - catalog roots.
|
||||
* @param selected - the whole closure.
|
||||
* @returns True when a required dependency edge reaches the target.
|
||||
*/
|
||||
function reachedByRequiredEdge(target: string, entries: readonly string[], selected: ReadonlyMap<string, WorkspacePackage>): boolean {
|
||||
const visited = new Set<string>()
|
||||
const pending = [...entries]
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
const name = pending[index] as string
|
||||
if (visited.has(name)) continue
|
||||
visited.add(name)
|
||||
const member = selected.get(name)
|
||||
if (member === undefined) continue
|
||||
for (const section of REQUIRED_SECTIONS) {
|
||||
for (const [dependency] of dependencyRanges(member.manifest, section)) {
|
||||
if (dependency === target) return true
|
||||
if (selected.has(dependency)) pending.push(dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dependency path that reached a package, for a failure message.
|
||||
* @param name - the package being explained.
|
||||
* @param parents - the first edge that reached each package.
|
||||
* @param entries - catalog roots, which have no parent.
|
||||
* @returns A `root -> ... -> name` chain.
|
||||
*/
|
||||
function dependencyPath(name: string, parents: ReadonlyMap<string, string>, entries: readonly string[]): string {
|
||||
const chain = [name]
|
||||
let current = parents.get(name)
|
||||
while (current !== undefined) {
|
||||
chain.unshift(current)
|
||||
current = parents.get(current)
|
||||
}
|
||||
return chain.length === 1 && entries.includes(name) ? `catalog entry ${name}` : chain.join(' -> ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a manifest path or tarball member to its package-relative form, the
|
||||
* same rule the publication payload check applies.
|
||||
* @param file - manifest path or archive member.
|
||||
* @returns The package-relative path.
|
||||
*/
|
||||
function payloadPath(file: string): string {
|
||||
return file.replaceAll('\\', '/').replace(/^\.\/+/u, '').replace(/^package\//u, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle patch files a manifest declares, as written.
|
||||
* @param manifest - packed or declared package manifest.
|
||||
* @returns Package-relative patch paths, or nothing when the package is not a bundle.
|
||||
*/
|
||||
function declaredBundlePatches(manifest: Record<string, unknown>): string[] {
|
||||
const dsh = manifest.dsh
|
||||
if (!isRecord(dsh) || !isRecord(dsh.bundle)) return []
|
||||
const declared = dsh.bundle.patch
|
||||
if (typeof declared === 'string') return [declared]
|
||||
return Array.isArray(declared) && declared.every(entry => typeof entry === 'string') ? declared : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject packed manifests that would send installation back to the checkout or
|
||||
* to a package this run does not serve.
|
||||
* @param artifacts - packed artifacts of one run.
|
||||
*/
|
||||
function verifyPackedClosure(artifacts: readonly BundleArtifact[]): void {
|
||||
const served = new Map(artifacts.map(artifact => [artifact.name, artifact]))
|
||||
for (const artifact of artifacts) {
|
||||
for (const section of [...REQUIRED_SECTIONS, ...OPTIONAL_SECTIONS, ...PEER_SECTIONS]) {
|
||||
for (const [name, range] of dependencyRanges(artifact.manifest, section)) {
|
||||
if (/^(?:workspace|file|link|portal):/u.test(range)) {
|
||||
throw new Error(`${artifact.name}: packed ${section}.${name} still declares ${range}`)
|
||||
}
|
||||
const local = served.get(name)
|
||||
if (local !== undefined && !semver.satisfies(local.version, range, { includePrerelease: true })) {
|
||||
throw new Error(`${artifact.name}: packed ${section}.${name} requires ${range}, but this run serves ${local.version}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The package a registry request path selects. */
|
||||
interface PackageRequest {
|
||||
/** Package name, with its scope when it has one. */
|
||||
readonly name: string
|
||||
/** Exact version or dist tag the path asks for, absent for a packument request. */
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a request path into the package name and version it selects.
|
||||
* @param suffix - the raw request path after the namespace.
|
||||
* @returns The selected package, or undefined when the path is not a package path.
|
||||
*/
|
||||
function selection(suffix: string): PackageRequest | undefined {
|
||||
const decoded = decodeSegment(suffix)
|
||||
if (decoded === undefined) return undefined
|
||||
const segments = decoded.split('/')
|
||||
// Reject traversal, registry administration paths, and empty segments before any name is considered.
|
||||
if (segments.some(segment => segment === '' || segment === '.' || segment === '..') || segments[0]?.startsWith('-') === true) {
|
||||
return undefined
|
||||
}
|
||||
if (segments.length === 1 && segments[0] !== undefined) return { name: segments[0] }
|
||||
if (!decoded.startsWith('@')) {
|
||||
const [name, version] = segments
|
||||
return name !== undefined && version !== undefined && segments.length === 2 ? { name, version } : undefined
|
||||
}
|
||||
if (segments.length === 2) return { name: decoded }
|
||||
if (segments.length === 3) {
|
||||
const version = segments[2]
|
||||
return version === undefined ? undefined : { name: segments.slice(0, 2).join('/'), version }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one percent-encoded path.
|
||||
* @param value - raw path text.
|
||||
* @returns The decoded text, or undefined when the encoding is malformed.
|
||||
*/
|
||||
function decodeSegment(value: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one archive's bytes from the artifact index.
|
||||
* @param file - raw requested tarball filename.
|
||||
* @param response - the response to write.
|
||||
* @param head - whether this is a HEAD request, whose body is omitted.
|
||||
* @param directory - the run directory holding the archives.
|
||||
* @param index - the run's artifact index.
|
||||
*/
|
||||
function serveTarball(
|
||||
file: string,
|
||||
response: ServerResponse,
|
||||
head: boolean,
|
||||
directory: string,
|
||||
index: ReadonlyMap<string, BundleArtifact>,
|
||||
): void {
|
||||
const decoded = decodeSegment(file)
|
||||
const artifact = decoded === undefined ? undefined : [...index.values()].find(entry => entry.file === decoded)
|
||||
if (artifact === undefined) {
|
||||
respond(response, 404, { error: 'Not found', reason: 'no archive with that name in this run' }, head)
|
||||
return
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(artifact.bytes),
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
if (head) {
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const stream = createReadStream(join(directory, artifact.file))
|
||||
// A client that disconnects mid-archive must not fail an unrelated request.
|
||||
response.on('close', () => stream.destroy())
|
||||
stream.on('error', (error) => { response.destroy(error) })
|
||||
stream.pipe(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the packument, a version document, or a dist-tag document for one artifact.
|
||||
* @param artifact - the artifact being described.
|
||||
* @param version - requested version or dist tag, or undefined for the whole packument.
|
||||
* @param response - the response to write.
|
||||
* @param head - whether this is a HEAD request, whose body is omitted.
|
||||
* @param origin - the run's registry URL.
|
||||
*/
|
||||
function serveMetadata(
|
||||
artifact: BundleArtifact,
|
||||
version: string | undefined,
|
||||
response: ServerResponse,
|
||||
head: boolean,
|
||||
origin: string,
|
||||
): void {
|
||||
const tags: Record<string, string> = { latest: artifact.version }
|
||||
if (artifact.distTag !== undefined && artifact.distTag !== 'latest') tags[artifact.distTag] = artifact.version
|
||||
const document = {
|
||||
...artifact.manifest,
|
||||
dist: {
|
||||
tarball: `${origin}tarballs/${encodeURIComponent(artifact.file)}`,
|
||||
integrity: artifact.integrity,
|
||||
shasum: artifact.shasum,
|
||||
},
|
||||
}
|
||||
if (version === undefined) {
|
||||
respond(response, 200, {
|
||||
name: artifact.name,
|
||||
'dist-tags': tags,
|
||||
versions: { [artifact.version]: document },
|
||||
time: { created: artifact.packedAt, modified: artifact.packedAt, [artifact.version]: artifact.packedAt },
|
||||
}, head)
|
||||
return
|
||||
}
|
||||
if (version !== artifact.version && !Object.hasOwn(tags, version)) {
|
||||
respond(response, 404, {
|
||||
error: 'Not found',
|
||||
reason: `this run serves ${artifact.name}@${artifact.version} only`,
|
||||
}, head)
|
||||
return
|
||||
}
|
||||
respond(response, 200, document, head)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one JSON response.
|
||||
* @param response - the response to write.
|
||||
* @param status - HTTP status.
|
||||
* @param body - JSON body.
|
||||
* @param head - whether this is a HEAD request, whose body is omitted.
|
||||
*/
|
||||
function respond(response: ServerResponse, status: number, body: unknown, head: boolean): void {
|
||||
const text = `${JSON.stringify(body)}\n`
|
||||
response.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(text)),
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
response.end(head ? undefined : text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a server and everything it is still serving.
|
||||
* @param server - the listener to close.
|
||||
* @returns Resolves once the listener is gone.
|
||||
*/
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.closeAllConnections()
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user