/** Assemble portable deployment and author starter archives from reviewed release inputs. */ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { verifyPackage } from '../packages/plugin-manager/src/verify-package.mjs'; import { composeReleases } from '../packages/plugin-manager/src/compose-release.mjs'; import { loadRelease } from '../packages/plugin-manager/src/release.mjs'; import { selectVerification } from '../packages/plugin-manager/src/verification.mjs'; import { renderSiteTemplate } from '../packages/plugin-manager/src/framework-config.mjs'; import { createPublicBuildView, publicInputFiles, writePublicInputRecord } from '../packages/plugin-manager/src/public-build-view.mjs'; import { hash, readArchive } from '../packages/plugin-manager/src/state.mjs'; import { installManagerArchive } from './manager-tooling.mjs'; const managerCli = '/opt/plugin-manager/node_modules/@dsh-plugin-manager/plugin-manager/dist/cli.mjs'; const hostCli = '/opt/dsh-runtime/node_modules/@deepseek-ai/dsh/lib/bin.js'; const digestReference = /^[a-z0-9][a-z0-9._:/-]*@sha256:[a-f0-9]{64}$/u; const json = path => JSON.parse(readFileSync(path, 'utf8')); const write = (path, content) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); }; function command(bin, args, options = {}) { const result = spawnSync(bin, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024, ...options }); if (result.error || result.status !== 0) throw new Error(`${bin} ${args[0]} failed: ${result.error?.message ?? result.stderr ?? result.status}`); return result.stdout.trim(); } /** Match the installed archive label and fixed version probes against the immutable image. */ export function runtimeFromImage(image, version, managerHash, execute = command) { if (!digestReference.test(image)) throw new Error('Runtime image must be a registry/repository@sha256 reference.'); if (!/^[a-f0-9]{64}$/u.test(managerHash)) throw new Error('A verified manager archive SHA256 is required.'); const [info] = JSON.parse(execute('docker', ['image', 'inspect', image])); const platform = `${info?.Os}/${info?.Architecture}`; if (!/^linux\/(amd64|arm64)$/u.test(platform) || !/^sha256:[a-f0-9]{64}$/u.test(info?.Id)) throw new Error('Runtime image identity or platform is invalid.'); if (!info.RepoDigests?.includes(image)) throw new Error('Inspected image does not own the requested repository digest.'); if (info.Config?.Labels?.['com.dsh-plugin-manager.manager.sha256'] !== managerHash) throw new Error('Runtime manager archive SHA256 does not match the release archive.'); const hostCommit = info.Config?.Labels?.['org.opencontainers.image.revision']; const hostVersion = info.Config?.Labels?.['org.opencontainers.image.version']; if (!/^[a-f0-9]{40}$/u.test(hostCommit) || typeof hostVersion !== 'string' || !hostVersion) throw new Error('Runtime is missing the verified host identity labels.'); if (JSON.stringify(info.Config?.Entrypoint) !== JSON.stringify(['node', managerCli, 'container-start', '--root', '/opt/plugin-project'])) throw new Error('Runtime does not expose the supported manager container entry.'); for (const [cli, expected] of [[managerCli, version], [hostCli, hostVersion]]) { const actual = execute('docker', ['run', '--rm', '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--entrypoint', 'node', info.Id, cli, '--version']); if (actual !== expected) throw new Error(`Runtime version does not match: ${cli}`); } return { platform, image, hostCommit }; } function toolArchive(path, name, version, entry) { const manifest = JSON.parse(readArchive(path, ['-xzOf', '-', 'package/package.json']).toString('utf8')); if (manifest.name !== name || manifest.version !== version) throw new Error(`${name} archive does not match framework ${version}.`); return verifyPackage({ package: name, version, verifyFiles: [entry] }, path); } /** The allowlist belongs to this release recipe, not to a recursively copied checkout. */ function copyPublic(source, target) { const info = lstatSync(source); if (info.isSymbolicLink()) throw new Error(`Release input must not be a link: ${source}`); if (info.isDirectory()) { mkdirSync(target, { recursive: true }); for (const name of readdirSync(source)) { if (['node_modules', '.git', '.local', 'dist', 'coverage'].includes(name)) throw new Error(`Unexpected generated/private starter input: ${source}/${name}`); copyPublic(join(source, name), join(target, name)); } } else if (info.isFile()) { mkdirSync(dirname(target), { recursive: true }); cpSync(source, target); } else throw new Error(`Release input is not a regular file: ${source}`); } const shell = `#!/usr/bin/env bash set -Eeuo pipefail ROOT="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd)" command -v node >/dev/null || { printf 'Missing prerequisite: Node.js. See README.md.\\n' >&2; exit 1; } CLI="$ROOT/tools/node_modules/@dsh-plugin-manager/plugin-manager/dist/cli.mjs" HELP=false for argument in "$@"; do [[ "$argument" != "--help" ]] || HELP=true; done if [[ ( $# -eq 0 || "$1" == "release" || "$1" == --* ) && "$HELP" == false ]] && command -v flock >/dev/null; then mkdir -p "$ROOT/.local" exec flock -n "$ROOT/.local/source-release.lock" node "$CLI" release-site --root "$ROOT" "$@" fi exec node "$CLI" release-site --root "$ROOT" "$@" `; const powershell = `$ErrorActionPreference = 'Stop' if (-not (Get-Command node -CommandType Application -ErrorAction SilentlyContinue)) { throw 'Missing prerequisite: Node.js. See README.md.' } & node (Join-Path $PSScriptRoot 'tools/node_modules/@dsh-plugin-manager/plugin-manager/dist/cli.mjs') release-site --root $PSScriptRoot @args exit $LASTEXITCODE `; /** Return staged trees so their real independent consumption can be checked before publication. */ export function assembleDeployment({ root, manager, kit, images, output }, { inspectRuntime = runtimeFromImage, installTools = installManagerArchive } = {}) { if (![root, manager, kit, output].every(value => typeof value === 'string' && value)) throw new Error('Explicit root, tool archives and new output are required.'); root = resolve(root); output = resolve(output); manager = resolve(manager); kit = resolve(kit); const version = json(join(root, 'package.json')).version; if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error('Expected a stable framework version.'); if (existsSync(output) && readdirSync(output).length) throw new Error('Release output must be new or empty.'); if (!Array.isArray(images) || !images.length) throw new Error('At least one verified public runtime is required.'); const managerHash = hash(readFileSync(manager)), kitHash = hash(readFileSync(kit)); toolArchive(manager, '@dsh-plugin-manager/plugin-manager', version, 'dist/cli.mjs'); toolArchive(kit, '@dsh-plugin-manager/plugin-kit', version, 'dist/index.mjs'); const runtimes = images.map(image => inspectRuntime(image, version, managerHash)); if (new Set(runtimes.map(runtime => runtime.platform)).size !== runtimes.length) throw new Error('Only one runtime per platform may be published.'); for (const doc of ['deploy/DEPLOYMENT.md', 'deploy/STARTERS.md', 'incoming/README.md', 'LICENSE']) if (!existsSync(join(root, doc))) throw new Error(`Missing public release document: ${doc}`); const deployment = join(output, 'dsh-deployment'), starters = join(output, 'dsh-starters'); mkdirSync(deployment, { recursive: true }); mkdirSync(starters); const tooling = installTools({ archive: manager, output: join(deployment, 'tools'), version }); if (tooling.sha256 !== managerHash) throw new Error('Installed manager bytes differ from the verified release archive.'); const metadata = { schemaVersion: 1, frameworkVersion: version, manager: { version, sha256: tooling.sha256 }, runtimes }; write(join(deployment, 'framework-runtime.json'), `${JSON.stringify(metadata, null, 2)}\n`); // 统一部署路径需要内置源码与公开构建输入:发行包必须自带,不能让站点在含 external 的现场生成。 // source/ 是只含公开目录的源码材料,它的清单/workspace/锁与 tools/builtin-build/ 都逐字节来自 // **本次公共框架根文件**(同一份输入),打包后立刻核对字节,避免交付走样。 const source = join(deployment, 'source'); createPublicBuildView({ root, output: source, inputs: root }); const builtinBuild = join(deployment, 'tools/builtin-build'); mkdirSync(builtinBuild, { recursive: true }); for (const name of publicInputFiles) copyPublic(join(root, name), join(builtinBuild, name)); for (const name of publicInputFiles) { if (!readFileSync(join(builtinBuild, name)).equals(readFileSync(join(root, name)))) throw new Error(`公开构建输入交付后字节不一致:${name}`); if (!readFileSync(join(source, name)).equals(readFileSync(join(root, name)))) throw new Error(`source/ 的公开构建输入与根文件不一致:${name}`); } // 交付记录随包产出:站点侧用的是独立交付目录,没有记录就直接拒绝构造构建视图。记录取自刚写下的 // 字节(发行目录没有提交身份,只记来源种类),时间戳一律不写,发行包保持可复现。 writePublicInputRecord(builtinBuild, { version, sourceKind: 'release', sourceCommit: null, sourceModified: null }); write(join(deployment, 'build.sh'), shell); write(join(deployment, 'build.ps1'), powershell); write(join(deployment, 'env.conf.example'), renderSiteTemplate('archives')); write(join(deployment, '.gitignore'), '.local/\nincoming/*\n!incoming/README.md\n'); copyPublic(join(root, 'deploy/DEPLOYMENT.md'), join(deployment, 'README.md')); copyPublic(join(root, 'incoming/README.md'), join(deployment, 'incoming/README.md')); copyPublic(join(root, 'LICENSE'), join(deployment, 'LICENSE')); copyPublic(join(root, 'deploy/STARTERS.md'), join(starters, 'README.md')); copyPublic(join(root, 'LICENSE'), join(starters, 'LICENSE')); for (const name of ['standalone-plugin', 'standalone-kit']) { const source = join(root, 'examples', name), target = join(starters, name); for (const path of ['src', 'package.json', 'cordis.patch.yml', 'README.md', name === 'standalone-kit' ? 'tsdown.config.mjs' : 'build.mjs']) copyPublic(join(source, path), join(target, path)); copyPublic(join(root, 'LICENSE'), join(target, 'LICENSE')); write(join(target, '.gitignore'), 'node_modules/\ndist/\n.local/\n'); if (name === 'standalone-kit') { copyPublic(kit, join(target, 'vendor/plugin-kit.tgz')); if (hash(readFileSync(join(target, 'vendor/plugin-kit.tgz'))) !== kitHash) throw new Error('Kit bytes changed after verification.'); const manifest = json(join(target, 'package.json')); manifest.devDependencies = { ...manifest.devDependencies, '@dsh-plugin-manager/plugin-kit': 'file:vendor/plugin-kit.tgz' }; write(join(target, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); } } return { version, deployment, starters, metadata }; } // Python's standard zipfile preserves a portable file tree, without shell interpolation. const zipProgram = `import pathlib, stat, sys, zipfile root, output = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive: for path in sorted(root.rglob('*')): if path.is_symlink(): raise ValueError('ZIP input cannot contain links: ' + str(path)) if not path.is_file(): continue relative = path.relative_to(root) if '.local' in relative.parts or '.git' in relative.parts: raise ValueError('Private release path: ' + str(relative)) if path.name in ('package-lock.json', '.package-lock.json') and relative.parts[0] == 'tools': continue info = zipfile.ZipInfo(root.name + '/' + relative.as_posix(), (2020, 1, 1, 0, 0, 0)) info.create_system = 3 info.external_attr = (stat.S_IFREG | (0o755 if path.suffix == '.sh' else 0o644)) << 16 archive.writestr(info, path.read_bytes(), compress_type=zipfile.ZIP_DEFLATED) with zipfile.ZipFile(output) as archive: if archive.testzip() is not None: raise ValueError('ZIP checksum verification failed') `; export function zipTree(root, archive, python = process.platform === 'win32' ? 'python' : 'python3') { command(python, ['-c', zipProgram, root, archive]); } export function packageDeployment(options, dependencies) { const result = assembleDeployment(options, dependencies); const archives = [ [result.deployment, `dsh-plugin-manager-deployment-${result.version}.zip`], [result.starters, `dsh-plugin-manager-starters-${result.version}.zip`], ].map(([source, filename]) => { const path = resolve(options.output, filename); zipTree(source, path, options.python); return path; }); return { ...result, archives }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const options = { images: [] }, values = { '--root': 'root', '--manager': 'manager', '--kit': 'kit', '--output': 'output', '--python': 'python' }; const args = process.argv.slice(2); while (args.length) { const flag = args.shift(), value = args.shift(); if ((!Object.hasOwn(values, flag) && flag !== '--image') || !value || value.startsWith('--')) throw new Error('Usage: package-deployment.mjs --root --manager --kit --image --output [--python ]'); if (flag === '--image') options.images.push(value); else if (options[values[flag]]) throw new Error(`Repeated argument: ${flag}`); else options[values[flag]] = value; } console.log(JSON.stringify(packageDeployment(options))); } catch (error) { console.error(error.message); process.exitCode = 1; } }