games-development-ai/scripts/smoke-devtool-import.mjs
2026-06-05 17:25:33 +08:00

298 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 } : {})
};
}