Scert/build/toolchain.mjs

180 lines
8.0 KiB
JavaScript

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { arch, platform, release, version } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
const exactVersion = /^\d+\.\d+\.\d+$/;
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
function requireCondition(condition, message) {
if (!condition) {
throw new Error(message);
}
}
export function validateFrontend(pkg, lock, npmrc, actual) {
for (const name of ['node', 'npm']) {
const expected = pkg.engines?.[name];
requireCondition(exactVersion.test(expected ?? ''), `${name} 必须固定精确版本。`);
requireCondition(actual[name] === expected, `${name} 版本不匹配,需要 ${expected}。`);
const engine = pkg.devEngines?.[name === 'node' ? 'runtime' : 'packageManager'];
requireCondition(
engine?.name === name && engine.version === expected && engine.onFail === 'error',
`${name} 的开发工具链拒绝策略不一致。`,
);
requireCondition(lock.packages?.['']?.engines?.[name] === expected, `${name} 锁文件版本声明不一致。`);
}
requireCondition(pkg.packageManager === `npm@${pkg.engines.npm}`, '包管理器声明不一致。');
requireCondition(lock.lockfileVersion === 3, '前端必须使用第三版锁文件。');
requireCondition(pkg.name === lock.name && pkg.version === lock.version, '前端锁文件项目标识不一致。');
for (const section of ['dependencies', 'devDependencies']) {
const declared = pkg[section] ?? {};
const locked = lock.packages[''][section] ?? {};
requireCondition(
JSON.stringify(Object.keys(declared).sort()) === JSON.stringify(Object.keys(locked).sort()),
`前端 ${section} 与锁文件不一致。`,
);
for (const [name, expected] of Object.entries(declared)) {
requireCondition(exactVersion.test(expected), `依赖 ${name} 必须固定精确版本。`);
requireCondition(
locked[name] === expected && lock.packages[`node_modules/${name}`]?.version === expected,
`依赖 ${name} 与锁文件不一致。`,
);
}
}
const settings = new Map();
for (const line of npmrc.split(/\r?\n/)) {
const value = line.trim();
if (!value || value.startsWith('#') || value.startsWith(';')) continue;
const match = /^([a-z-]+)=(.*)$/.exec(value);
requireCondition(match && !settings.has(match[1]), '前端 npm 配置包含无效项或重复项。');
settings.set(match[1], match[2]);
}
for (const name of ['engine-strict', 'ignore-scripts', 'save-exact']) {
requireCondition(settings.get(name) === 'true', `前端 npm 配置必须启用 ${name}。`);
}
requireCondition(settings.size === 3, '前端 npm 配置存在未审查的额外设置。');
requireCondition(!pkg.allowScripts && !pkg.dangerouslyAllowAllScripts, '禁止额外放行依赖安装脚本。');
}
export function validateGoVersion(goMod, actual) {
const expected = /^go (\d+\.\d+\.\d+)\s*$/m.exec(goMod)?.[1];
requireCondition(expected, '后端模块必须声明精确 Go 版本。');
requireCondition(actual === `go${expected}`, `实际 Go 工具链不匹配,需要 go${expected}。`);
return expected;
}
export function validateEnvironment(env, host) {
requireCondition(['win32', 'linux'].includes(host.platform) && host.arch === 'x64', '构建机仅支持 Windows/Linux amd64。');
requireCondition(!env.ESBUILD_BINARY_PATH, '禁止使用锁文件之外的 esbuild 可执行文件。');
requireCondition(!env.GOWORK || env.GOWORK === 'off', '正式构建禁止使用外部 Go 工作区。');
requireCondition(!env.GOFLAGS, '正式构建禁止通过 GOFLAGS 覆盖依赖或编译策略。');
}
export function npmEnvironment(env = process.env) {
// npm run 会把用户级白名单导出为环境变量;子项目安装却拒绝该变量。
// 仅移除这项不适用于项目安装的继承策略,安装命令仍强制禁止全部脚本。
return Object.fromEntries(Object.entries(env).filter(([name]) => name.toLowerCase() !== 'npm_config_allow_scripts'));
}
function execute(command, args, cwd, env = process.env) {
const useShell = process.platform === 'win32' && command === npmCommand;
if (useShell) {
requireCondition(args.every((arg) => /^[a-zA-Z0-9=_./-]+$/.test(arg)), 'npm 参数含有不允许的命令解释器字符。');
}
const options = {
cwd,
env: command === npmCommand ? npmEnvironment(env) : env,
encoding: 'utf8',
windowsHide: true,
// Windows 的 npm.cmd 需要命令解释器;参数仅来自本文件的固定值。
shell: useShell,
maxBuffer: 16 * 1024 * 1024,
};
const result = useShell
? spawnSync(`${command} ${args.join(' ')}`, options)
: spawnSync(command, args, options);
if (result.error || result.status !== 0) {
throw new Error(`${command} 执行失败。\n${result.stderr ?? ''}`);
}
return result;
}
export function inputHashes(backend, frontend) {
const files = {
'backend/go.mod': join(backend, 'go.mod'),
'backend/go.sum': join(backend, 'go.sum'),
'frontend/package.json': join(frontend, 'package.json'),
'frontend/package-lock.json': join(frontend, 'package-lock.json'),
'frontend/.npmrc': join(frontend, '.npmrc'),
};
return Object.fromEntries(Object.entries(files).map(([name, path]) => [
name,
createHash('sha256').update(readFileSync(path)).digest('hex'),
]));
}
export function installDependencies(frontend) {
// 不信任使用者的 npm 全局/用户设置,命令行参数显式覆盖;保留构建所需的可选平台包。
const result = execute(npmCommand, [
'ci', '--ignore-scripts', '--engine-strict', '--include=dev', '--include=optional',
'--no-audit', '--no-fund',
], frontend);
process.stderr.write(result.stdout);
process.stderr.write(result.stderr);
}
export function inspectToolchain(backend, frontend, install = false) {
const host = { platform: platform(), arch: arch(), release: release(), version: version() };
validateEnvironment(process.env, host);
const pkg = JSON.parse(readFileSync(join(frontend, 'package.json'), 'utf8'));
const lock = JSON.parse(readFileSync(join(frontend, 'package-lock.json'), 'utf8'));
const npmrc = readFileSync(join(frontend, '.npmrc'), 'utf8');
const actual = { node: process.versions.node, npm: execute(npmCommand, ['--version'], frontend).stdout.trim() };
validateFrontend(pkg, lock, npmrc, actual);
const goEnv = { ...process.env, GOWORK: 'off' };
const goVersion = execute('go', ['env', 'GOVERSION'], backend, goEnv).stdout.trim();
validateGoVersion(readFileSync(join(backend, 'go.mod'), 'utf8'), goVersion);
const hashes = inputHashes(backend, frontend);
execute('go', ['mod', 'verify'], backend, goEnv);
if (install) installDependencies(frontend);
requireCondition(JSON.stringify(hashes) === JSON.stringify(inputHashes(backend, frontend)), '依赖安装改变了锁文件或版本策略,拒绝构建。');
return {
node: actual.node,
npm: actual.npm,
go: goVersion,
host,
frontend_dependencies: { ...pkg.dependencies, ...pkg.devDependencies },
dependency_scripts: 'disabled',
dependency_install: install ? 'npm-ci' : 'not-run',
input_sha256: hashes,
};
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const { values } = parseArgs({
options: {
backend: { type: 'string' },
frontend: { type: 'string' },
install: { type: 'boolean', default: false },
},
});
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const report = inspectToolchain(
resolve(values.backend ?? join(root, 'backend')),
resolve(values.frontend ?? join(root, 'frontend')),
values.install,
);
process.stdout.write(`${JSON.stringify(report)}\n`);
} catch (error) {
console.error(`构建工具链检查失败:${error.message}`);
process.exitCode = 1;
}
}