208 lines
9.4 KiB
JavaScript
208 lines
9.4 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 validateSourceLineEndings(output) {
|
|
const records = output.split('\0');
|
|
requireCondition(records.pop() === '', '源码换行检查输出不完整。');
|
|
requireCondition(records.length > 0, '源码目录没有受跟踪文件,不能确认构建输入。');
|
|
const invalid = [];
|
|
for (const record of records) {
|
|
const match = /^i\/\S+\s+w\/(\S+)\s+attr\/[^\t]*\t([\s\S]+)$/.exec(record);
|
|
requireCondition(match, '无法解析源码换行检查结果。');
|
|
const [, ending, path] = match;
|
|
requireCondition(['lf', 'crlf', 'mixed', 'none', '-text'].includes(ending), '源码换行检查返回未知文件状态。');
|
|
if (ending === 'crlf' || ending === 'mixed') invalid.push(path);
|
|
}
|
|
requireCondition(invalid.length === 0,
|
|
`源码文件存在非规范换行,拒绝构建:${invalid.slice(0, 5).join('、')}。请从当前提交重新克隆后构建;工具不会改写源文件。`);
|
|
return { policy: 'lf', tracked_files: records.length };
|
|
}
|
|
|
|
export function inspectSourceLineEndings(directory) {
|
|
// Git 干净状态会忽略 CRLF/LF 差异;必须读取工作树的实际换行状态。
|
|
const result = execute('git', ['ls-files', '--eol', '-z', '--', '.'], directory);
|
|
return validateSourceLineEndings(result.stdout);
|
|
}
|
|
|
|
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 sourceLineEndings = {
|
|
backend: inspectSourceLineEndings(backend),
|
|
frontend: inspectSourceLineEndings(frontend),
|
|
};
|
|
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,
|
|
source_line_endings: sourceLineEndings,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|