/** Build only the manager closure; authors' plugin sources never enter this workspace. */ import { createHash } from 'node:crypto'; import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { commandSpec, normalizeEnvironment } from '../packages/plugin-manager/src/process.mjs'; const isolated = ['--config.node-linker=isolated', '--config.dedupe-peer-dependents=false']; export const managerToolInputs = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'packages/plugin-kit', 'packages/plugin-manager']; /** * 管理器工具构建输入的哈希:上面每个路径下所有已跟踪文件的 Git 对象号。 * * 输入没变时,上一次成功发布的管理器归档可以直接复用:构建一次实测 18.6 秒(pnpm 安装 + * kit 与 manager 两次构建 + 打包),而复用只需按摘要核验再安装一次(约 1.2 秒)。 * * 哈希取自 Git 树而不是工作区:内容一致、不受时间戳影响,也不把未跟踪文件算成输入。 */ export function managerToolInputsHash(git, revision) { const entries = []; for (const record of git(['ls-tree', '-r', '-z', revision, '--', ...managerToolInputs]).split('\0')) { if (!record) continue; const separator = record.indexOf('\t'); const [, type, object] = record.slice(0, separator).split(' '); if (type === 'blob') entries.push(`${record.slice(separator + 1)}\0${object}`); } return createHash('sha256').update(entries.sort().join('\n')).digest('hex'); } function command(bin, args, options) { const cli = commandSpec(bin, options); const result = spawnSync(cli.command, [...cli.prefix, ...args], { windowsHide: true, stdio: 'inherit', ...options }); if (result.error) throw result.error; if (result.status !== 0) throw Object.assign(new Error(`管理器工具准备失败:${bin} ${args[0]} (${result.status ?? result.signal})`), { signal: result.signal }); return result.stdout?.trim() ?? ''; } /** Shared by the disposable local workspace and the Docker manager-builder stage. */ export function buildManagerArchive({ root, archive, execute = command, env = normalizeEnvironment(process.env) }) { if (!root || !archive) throw new Error('Manager tooling requires explicit root and archive paths.'); root = resolve(root); archive = resolve(archive); if (existsSync(archive)) throw new Error(`管理器归档已存在,请使用新的输出位置:${archive}`); mkdirSync(dirname(archive), { recursive: true }); const run = args => execute('pnpm', [...isolated, ...args], { cwd: root, env }); run(['--filter', 'dsh-plugin-manager-workspace', '--filter', '@dsh-plugin-manager/plugin-manager...', 'install', '--frozen-lockfile']); for (const name of ['plugin-kit', 'plugin-manager']) run(['--filter', `@dsh-plugin-manager/${name}`, 'build']); run(['--filter', '@dsh-plugin-manager/plugin-manager', 'pack', '--out', archive]); if (!existsSync(archive)) throw new Error('管理器构建未生成归档。'); return archive; } /** Source builds and release packaging install the same verified, relocatable archive. */ export function installManagerArchive({ archive, output, version, execute = command, env = normalizeEnvironment(process.env) }) { if (!archive || !output || !version) throw new Error('Manager installation requires archive, output and version.'); archive = resolve(archive); output = resolve(output); if (!lstatSync(archive).isFile() || lstatSync(archive).isSymbolicLink()) throw new Error('Manager archive must be a regular file.'); mkdirSync(output, { recursive: true }); const installedArchive = resolve(output, 'plugin-manager.tgz'); if (readdirSync(output).some(name => name !== 'plugin-manager.tgz') || (existsSync(installedArchive) && installedArchive !== archive)) throw new Error(`工具输出目录必须为空:${output}`); const bytes = readFileSync(archive); if (installedArchive !== archive) copyFileSync(archive, installedArchive); // npm otherwise walks to a parent package and can modify the source workspace. writeFileSync(resolve(output, 'package.json'), '{"private":true,"type":"module"}\n', { flag: 'wx' }); execute('npm', ['install', '--prefix', output, '--offline', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', '--bin-links=false', './plugin-manager.tgz'], { cwd: output, env }); const packageRoot = resolve(output, 'node_modules/@dsh-plugin-manager/plugin-manager'); const cli = resolve(packageRoot, 'dist/cli.mjs'); const metadata = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'))); if (metadata.name !== '@dsh-plugin-manager/plugin-manager' || metadata.version !== version || !existsSync(cli)) throw new Error('管理器归档名称、版本或入口不匹配。'); const actual = execute(process.execPath, [cli, '--version'], { cwd: output, env, stdio: 'pipe', encoding: 'utf8' }); if (actual.trim() !== version || !readFileSync(installedArchive).equals(bytes) || !readFileSync(archive).equals(bytes)) throw new Error('已安装工具版本或归档内容不一致。'); execute(process.execPath, [cli, 'paths', '--root', resolve(output, 'verification-project')], { cwd: output, env, stdio: 'pipe', encoding: 'utf8' }); return { archive: installedArchive, sha256: createHash('sha256').update(bytes).digest('hex'), cli, toolRoot: output }; } /** * A real installation produces the same self-contained tooling layout as the deployment zip. * * `archive` 传入一个已核验的归档时跳过重建:把它装进本次的工具目录并做同样的版本与入口核验。 * 当前源码发布路径不传它(`deploy/scripts/source-input.mjs` 每次按本次输入重建工具);归档的身份由 * 调用方核验,这里返回的 `sha256` 是实际安装内容重新算出来的摘要,调用方据此再核对一次。 */ export function prepareManagerTooling({ root, output, execute = command, env = normalizeEnvironment(process.env), archive: reused } = {}) { if (!root || !output) throw new Error('Manager tooling requires explicit root and output paths.'); root = resolve(root); output = resolve(output); if (existsSync(output) && readdirSync(output).length) throw new Error(`工具输出目录必须为空:${output}`); mkdirSync(output, { recursive: true }); const version = JSON.parse(readFileSync(resolve(root, 'packages/plugin-manager/package.json'))).version; if (reused !== undefined) { const source = resolve(reused); if (!existsSync(source) || !lstatSync(source).isFile() || lstatSync(source).isSymbolicLink()) throw new Error('复用的管理器归档必须是普通文件。'); const installed = resolve(output, 'plugin-manager.tgz'); copyFileSync(source, installed); return installManagerArchive({ archive: installed, output, version, execute, env }); } const parent = realpathSync(tmpdir()); const source = realpathSync(mkdtempSync(resolve(parent, 'dsh-manager-source-'))); const archive = resolve(output, 'plugin-manager.tgz'); try { for (const input of managerToolInputs) cpSync(resolve(root, input), resolve(source, input), { recursive: true, filter: path => { if (['node_modules', 'dist', 'coverage', '.git', '.local'].includes(path.split(/[\\/]/u).at(-1))) return false; if (lstatSync(path).isSymbolicLink()) throw new Error(`管理器源码输入不能是链接:${path}`); return true; }, }); buildManagerArchive({ root: source, archive, execute, env }); return installManagerArchive({ archive, output, version, execute, env }); } finally { if (dirname(source) !== parent || !source.startsWith(resolve(parent, 'dsh-manager-source-'))) throw new Error('Unsafe temporary tooling cleanup path.'); rmSync(source, { recursive: true, force: true }); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const args = process.argv.slice(2), values = {}; while (args.length) { const flag = args.shift(); if (!['--root', '--archive', '--output'].includes(flag) || values[flag] || !args[0] || args[0].startsWith('--')) throw new Error('用法:manager-tooling.mjs --root <源码根> (--archive | --output <工具目录>)'); values[flag] = args.shift(); } if (!values['--root'] || Boolean(values['--archive']) === Boolean(values['--output'])) throw new Error('必须明确源码根,并选择 archive 或 output。'); if (values['--archive']) buildManagerArchive({ root: values['--root'], archive: values['--archive'] }); else prepareManagerTooling({ root: values['--root'], output: values['--output'] }); } catch (error) { console.error(error.message); process.exitCode = 1; } }