298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
import { execFile } from "node:child_process";
|
||
import { lstat, mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
import { promisify } from "node:util";
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
const workspaceRoot = process.cwd();
|
||
const validTargets = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"];
|
||
const defaultEvidenceRoot = "docs/evidence/devtool-import";
|
||
const pathSymlinkUnsupportedReason = "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED";
|
||
const pathRealpathEscapeReason = "DEVTOOL_IMPORT_PATH_REALPATH_ESCAPE";
|
||
|
||
try {
|
||
await main();
|
||
} catch (error) {
|
||
printJson(errorPayload("FAIL", error));
|
||
process.exitCode = 1;
|
||
}
|
||
|
||
async function main() {
|
||
const options = parseArgs(process.argv.slice(2));
|
||
validateOutPath(options);
|
||
await ensureApiBuild();
|
||
const { DevToolImportEvidenceRecorder, selectReferencePlatform } = await importApiModule("minigame-conversion/devtool-import-evidence.js");
|
||
const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot, evidenceRoot: defaultEvidenceRoot });
|
||
const selection =
|
||
options.target === "wechat_minigame" ? selectReferencePlatform({}) : selectReferencePlatform({ businessPrimaryTarget: options.target });
|
||
|
||
if (!options.allowManualPassedEvidence) {
|
||
const blockerLogPath = await writeDefaultBlockerLog(options);
|
||
const blocker = await recorder.recordBlocker({
|
||
target: options.target,
|
||
gameVersionId: options.gameVersionId,
|
||
result: "blocked",
|
||
reasonCode: "DEVTOOL_UNAVAILABLE",
|
||
logPath: blockerLogPath,
|
||
nextAction: "Run the target developer tool import manually, keep the real import log, then rerun with --allow-manual-passed-evidence, --tool-name, --tool-version and --log.",
|
||
createdAt: isoTimestamp()
|
||
});
|
||
const blockerPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocker.json`;
|
||
printJson({
|
||
status: "NO_GO",
|
||
target: blocker.target,
|
||
gameVersionId: blocker.gameVersionId,
|
||
result: blocker.result,
|
||
reasonCode: blocker.reasonCode,
|
||
blockerPath,
|
||
nextAction: blocker.nextAction
|
||
});
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
|
||
if (!options.toolName || !options.toolVersion || !options.log) {
|
||
throw new Error("--allow-manual-passed-evidence requires --tool-name, --tool-version and --log");
|
||
}
|
||
|
||
const projectChecksum = await recorder.checksumProjectDirectory(options.project);
|
||
const evidence = await recorder.recordPassedEvidence({
|
||
target: options.target,
|
||
gameVersionId: options.gameVersionId,
|
||
referencePlatformReason: selection.referencePlatformReason,
|
||
toolName: options.toolName,
|
||
toolVersion: options.toolVersion,
|
||
importedProjectPath: options.project,
|
||
projectChecksum,
|
||
logPath: options.log,
|
||
...(options.screenshot === undefined ? {} : { screenshotPath: options.screenshot }),
|
||
createdAt: isoTimestamp()
|
||
});
|
||
const defaultPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.json`;
|
||
if (options.out !== defaultPath) {
|
||
await moveDefaultEvidenceToOut(defaultPath, options.out);
|
||
}
|
||
|
||
printJson({
|
||
status: "PASS",
|
||
target: evidence.target,
|
||
gameVersionId: evidence.gameVersionId,
|
||
result: evidence.result,
|
||
evidencePath: options.out,
|
||
projectChecksum: evidence.projectChecksum
|
||
});
|
||
}
|
||
|
||
function parseArgs(args) {
|
||
const options = {};
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--") continue;
|
||
if (arg === "--allow-manual-passed-evidence") {
|
||
options.allowManualPassedEvidence = true;
|
||
continue;
|
||
}
|
||
const valueArgs = ["--target", "--game-version-id", "--project", "--out", "--tool-name", "--tool-version", "--log", "--screenshot"];
|
||
if (valueArgs.includes(arg)) {
|
||
const value = args[index + 1];
|
||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`);
|
||
options[toCamelCase(arg.slice(2))] = value;
|
||
index += 1;
|
||
continue;
|
||
}
|
||
throw new Error(`Unsupported argument: ${arg}`);
|
||
}
|
||
|
||
const required = ["target", "gameVersionId", "project", "out"];
|
||
for (const field of required) {
|
||
if (!options[field]) throw new Error(`Missing required --${toKebabCase(field)}`);
|
||
}
|
||
if (!validTargets.includes(options.target)) throw new Error(`Unsupported --target: ${options.target}`);
|
||
assertSafeFileSegment(options.gameVersionId, "game-version-id");
|
||
for (const field of ["project", "out", "log", "screenshot"]) {
|
||
if (options[field] !== undefined) assertControlledRelativePath(options[field], field);
|
||
}
|
||
return options;
|
||
}
|
||
|
||
function validateOutPath(options) {
|
||
const expectedName = `${options.target}-${options.gameVersionId}.json`;
|
||
const expectedPath = `${defaultEvidenceRoot}/${expectedName}`;
|
||
if (options.out !== expectedPath) {
|
||
// 当前 recorder 只按 target/gameVersionId 写默认命名;CLI 精确约束 out,避免证据输出路径被参数扩展。
|
||
throw new Error(`--out must be ${expectedPath}`);
|
||
}
|
||
}
|
||
|
||
async function ensureApiBuild() {
|
||
try {
|
||
await execFileAsync("pnpm", ["--filter", "@huijing/api", "exec", "tsc", "-p", "tsconfig.json"], {
|
||
cwd: workspaceRoot,
|
||
maxBuffer: 1024 * 1024 * 16
|
||
});
|
||
} catch (error) {
|
||
const detail = [error.stdout, error.stderr].filter(Boolean).join("\n");
|
||
throw new Error(`@huijing/api TypeScript build failed${detail ? `\n${detail}` : ""}`);
|
||
}
|
||
}
|
||
|
||
async function importApiModule(modulePath) {
|
||
return import(pathToFileURL(`${workspaceRoot}/apps/api/dist/modules/${modulePath}`).href);
|
||
}
|
||
|
||
async function writeDefaultBlockerLog(options) {
|
||
const logPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocked.log`;
|
||
const absoluteEvidenceRoot = await prepareSafeWorkspaceOutputRoot(defaultEvidenceRoot, "evidenceRoot");
|
||
const absoluteLogPath = path.resolve(workspaceRoot, logPath);
|
||
if (!isInsideOrSame(absoluteEvidenceRoot, absoluteLogPath)) throw new Error("blocker log path escaped evidence root");
|
||
// 默认路径只记录 No-Go 原因,不记录 passed 结果;开发者工具真实导入材料必须由显式 manual passed mode 提供。
|
||
await writeFile(
|
||
absoluteLogPath,
|
||
[
|
||
"S5 No-Go: developer tool import was not executed by this CLI.",
|
||
`target=${options.target}`,
|
||
`gameVersionId=${options.gameVersionId}`,
|
||
`project=${options.project}`,
|
||
"nextAction=rerun with audited manual import log and --allow-manual-passed-evidence",
|
||
""
|
||
].join("\n"),
|
||
"utf8"
|
||
);
|
||
return logPath;
|
||
}
|
||
|
||
async function prepareSafeWorkspaceOutputRoot(repositoryPath, fieldPath) {
|
||
const resolvedRoot = resolveWorkspacePath(repositoryPath, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", fieldPath);
|
||
// 创建输出目录前后都检查已存在父链;否则 mkdir/writeFile 会跟随 workspace 内 symlink 写到工作区外。
|
||
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
|
||
await mkdir(resolvedRoot, { recursive: true });
|
||
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
|
||
|
||
const stats = await lstat(resolvedRoot);
|
||
if (!stats.isDirectory()) {
|
||
throw pathBoundaryError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Devtool import evidence root is not a directory", fieldPath, {
|
||
repositoryPath
|
||
});
|
||
}
|
||
return resolvedRoot;
|
||
}
|
||
|
||
async function assertSafeExistingPathSegments(resolvedPath, repositoryPath, fieldPath) {
|
||
const workspaceRootReal = await realpath(workspaceRoot);
|
||
const relativePath = path.relative(workspaceRoot, resolvedPath);
|
||
let currentPath = workspaceRoot;
|
||
|
||
for (const segment of relativePath.split(path.sep)) {
|
||
if (segment.length === 0) continue;
|
||
currentPath = path.join(currentPath, segment);
|
||
let stats;
|
||
try {
|
||
stats = await lstat(currentPath);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (stats.isSymbolicLink()) {
|
||
throw pathBoundaryError(pathSymlinkUnsupportedReason, "Devtool import evidence paths do not support symlinks", fieldPath, {
|
||
repositoryPath
|
||
});
|
||
}
|
||
|
||
const currentReal = await realpath(currentPath);
|
||
if (!isInsideOrSame(workspaceRootReal, currentReal)) {
|
||
throw pathBoundaryError(pathRealpathEscapeReason, "Devtool import evidence real path escaped workspace root", fieldPath, {
|
||
repositoryPath,
|
||
realPath: currentReal
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveWorkspacePath(repositoryPath, reasonCode, fieldPath) {
|
||
assertControlledRelativePath(repositoryPath, fieldPath);
|
||
const resolved = path.resolve(workspaceRoot, repositoryPath);
|
||
if (!isInsideOrSame(workspaceRoot, resolved)) {
|
||
throw pathBoundaryError(reasonCode, "Path escaped workspace root", fieldPath, { repositoryPath });
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
function pathBoundaryError(reasonCode, message, fieldPath, details = {}) {
|
||
return Object.assign(new Error(message), {
|
||
reasonCode,
|
||
path: fieldPath,
|
||
details
|
||
});
|
||
}
|
||
|
||
async function moveDefaultEvidenceToOut(defaultPath, outPath) {
|
||
const absoluteDefaultPath = path.resolve(workspaceRoot, defaultPath);
|
||
const absoluteOutPath = path.resolve(workspaceRoot, outPath);
|
||
if (!isInsideOrSame(workspaceRoot, absoluteDefaultPath) || !isInsideOrSame(workspaceRoot, absoluteOutPath)) {
|
||
throw new Error("evidence output path escaped workspace root");
|
||
}
|
||
await mkdir(path.dirname(absoluteOutPath), { recursive: true });
|
||
await rename(absoluteDefaultPath, absoluteOutPath);
|
||
}
|
||
|
||
function toCamelCase(value) {
|
||
return value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
|
||
}
|
||
|
||
function toKebabCase(value) {
|
||
return value.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
||
}
|
||
|
||
function assertSafeFileSegment(value, fieldPath) {
|
||
if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
|
||
throw new Error(`Unsafe --${fieldPath}: ${value}`);
|
||
}
|
||
}
|
||
|
||
function assertControlledRelativePath(value, fieldPath) {
|
||
if (!isControlledRelativePath(value)) throw new Error(`Unsafe --${toKebabCase(fieldPath)} path: ${value}`);
|
||
}
|
||
|
||
function isControlledRelativePath(value) {
|
||
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
|
||
return false;
|
||
}
|
||
let decodedValue;
|
||
try {
|
||
decodedValue = decodeURIComponent(value);
|
||
} catch {
|
||
return false;
|
||
}
|
||
for (const candidate of [value, decodedValue]) {
|
||
if (candidate.startsWith("/") || candidate.startsWith("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
|
||
const segments = candidate.split("/");
|
||
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isInsideOrSame(rootPath, candidatePath) {
|
||
return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`);
|
||
}
|
||
|
||
function isoTimestamp() {
|
||
return "2026-06-05T00:00:00.000Z";
|
||
}
|
||
|
||
function stableJsonText(value) {
|
||
return `${JSON.stringify(value, null, 2)}\n`;
|
||
}
|
||
|
||
function printJson(value) {
|
||
process.stdout.write(stableJsonText(value));
|
||
}
|
||
|
||
function errorPayload(status, error) {
|
||
return {
|
||
status,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
...(error && typeof error === "object" && "reasonCode" in error ? { reasonCode: error.reasonCode, path: error.path } : {})
|
||
};
|
||
}
|