scripts/package-deployment.mjs
Rama main · réplica de lectura del repositorio original.
/** 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 <framework> --manager <tgz> --kit <tgz> --image <repository@sha256> --output <new-directory> [--python <executable>]');
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; }
}