071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
695 lines
21 KiB
JavaScript
695 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { callSpacetimeProcedureViaCli } from './spacetime-migration-common.mjs';
|
|
|
|
const PROCEDURE_NAME = 'repair_editor_canvas_resources_and_return';
|
|
const PLAN_SCHEMA_VERSION = 1;
|
|
const MAX_PLAN_BYTES = 1024 * 1024;
|
|
const MAX_CANVAS_COUNT = 100;
|
|
const MAX_ACTIONS_PER_CANVAS = 16;
|
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const ACTION_KINDS = new Set([
|
|
'reuse_project_resource',
|
|
'restore_project_resource',
|
|
]);
|
|
const RESTORABLE_ASSET_KINDS = new Set(['background-music', 'sound-effect']);
|
|
|
|
function usage() {
|
|
return `用法:
|
|
node scripts/spacetime-repair-editor-canvas-resources.mjs \\
|
|
--database <name> --server <name-or-url> --plan-file <absolute-0600-json> \\
|
|
[--apply --confirm-plan-sha256 <dry-run-plan-sha256>]
|
|
|
|
默认逐画布调用 ${PROCEDURE_NAME} 执行 dry-run,不修改数据。
|
|
只有显式追加 --apply,并用 --confirm-plan-sha256 绑定同一份 dry-run plan,才执行写入。
|
|
plan 必须位于仓库外,是当前用户持有、权限严格为 0600 的普通文件,且路径链不能包含符号链接。
|
|
必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`;
|
|
}
|
|
|
|
export function parseOptions(argv) {
|
|
const options = {
|
|
apply: false,
|
|
confirmPlanSha256: '',
|
|
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
|
|
passthrough: [],
|
|
planFile: '',
|
|
server: process.env.GENARRATIVE_SPACETIME_SERVER || '',
|
|
serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '',
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const readValue = () => {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${arg} 缺少参数值。`);
|
|
}
|
|
index += 1;
|
|
return value.trim();
|
|
};
|
|
|
|
if (arg === '--database') {
|
|
options.database = readValue();
|
|
} else if (arg === '--server') {
|
|
options.server = readValue();
|
|
} else if (arg === '--server-url') {
|
|
options.serverUrl = readValue();
|
|
} else if (arg === '--plan-file') {
|
|
options.planFile = readValue();
|
|
} else if (arg === '--confirm-plan-sha256') {
|
|
options.confirmPlanSha256 = readValue().toLowerCase();
|
|
} else if (arg === '--apply') {
|
|
options.apply = true;
|
|
} else if (arg === '--no-config') {
|
|
options.passthrough.push(arg);
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`未知参数: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
export async function readRepairPlan(planFile, { repoRoot = REPO_ROOT } = {}) {
|
|
assertSafeAbsolutePath(planFile, '--plan-file');
|
|
const resolvedPath = path.resolve(planFile);
|
|
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
let canonicalPath;
|
|
let canonicalRepoRoot;
|
|
try {
|
|
canonicalPath = await realpath(resolvedPath);
|
|
canonicalRepoRoot = await realpath(resolvedRepoRoot);
|
|
} catch {
|
|
throw new Error('--plan-file 无法解析或不存在。');
|
|
}
|
|
const pathWithoutSystemTempAlias =
|
|
await normalizeSystemTempAlias(resolvedPath);
|
|
if (canonicalPath !== pathWithoutSystemTempAlias) {
|
|
throw new Error('--plan-file 路径链不能包含符号链接。');
|
|
}
|
|
if (isPathInside(canonicalPath, canonicalRepoRoot)) {
|
|
throw new Error('--plan-file 必须位于仓库外,避免真实 ID 进入工作区。');
|
|
}
|
|
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(canonicalPath);
|
|
} catch {
|
|
throw new Error('--plan-file 无法读取元数据。');
|
|
}
|
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
throw new Error('--plan-file 必须是普通文件且不能是符号链接。');
|
|
}
|
|
if ((metadata.mode & 0o777) !== 0o600) {
|
|
throw new Error('--plan-file 权限必须严格为 0600。');
|
|
}
|
|
if (
|
|
typeof process.getuid === 'function' &&
|
|
metadata.uid !== process.getuid()
|
|
) {
|
|
throw new Error('--plan-file 必须由当前执行用户持有。');
|
|
}
|
|
if (metadata.size <= 0 || metadata.size > MAX_PLAN_BYTES) {
|
|
throw new Error(`--plan-file 大小必须在 1 到 ${MAX_PLAN_BYTES} 字节之间。`);
|
|
}
|
|
|
|
let bytes;
|
|
try {
|
|
bytes = await readFile(canonicalPath);
|
|
} catch {
|
|
throw new Error('--plan-file 无法读取。');
|
|
}
|
|
const planSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
let value;
|
|
try {
|
|
value = JSON.parse(bytes.toString('utf8'));
|
|
} catch {
|
|
throw new Error('--plan-file 不是合法 JSON。');
|
|
}
|
|
|
|
return {
|
|
plan: validateRepairPlan(value),
|
|
planSha256,
|
|
};
|
|
}
|
|
|
|
export function validateRepairPlan(value) {
|
|
assertPlainObject(value, 'plan');
|
|
assertExactKeys(
|
|
value,
|
|
[
|
|
'schema_version',
|
|
'expected_canvas_count',
|
|
'expected_action_count',
|
|
'canvases',
|
|
],
|
|
'plan',
|
|
);
|
|
if (value.schema_version !== PLAN_SCHEMA_VERSION) {
|
|
throw new Error(`plan.schema_version 必须为 ${PLAN_SCHEMA_VERSION}。`);
|
|
}
|
|
const expectedCanvasCount = readPositiveInteger(
|
|
value.expected_canvas_count,
|
|
'plan.expected_canvas_count',
|
|
);
|
|
const expectedActionCount = readPositiveInteger(
|
|
value.expected_action_count,
|
|
'plan.expected_action_count',
|
|
);
|
|
if (!Array.isArray(value.canvases) || value.canvases.length === 0) {
|
|
throw new Error('plan.canvases 必须是非空数组。');
|
|
}
|
|
if (value.canvases.length > MAX_CANVAS_COUNT) {
|
|
throw new Error(`plan.canvases 不能超过 ${MAX_CANVAS_COUNT} 项。`);
|
|
}
|
|
|
|
const canvasIds = new Set();
|
|
const projectIds = new Set();
|
|
let actionCount = 0;
|
|
const canvases = value.canvases.map((canvas, canvasIndex) => {
|
|
const label = `plan.canvases[${canvasIndex}]`;
|
|
assertPlainObject(canvas, label);
|
|
assertExactKeys(
|
|
canvas,
|
|
[
|
|
'canvas_id',
|
|
'project_id',
|
|
'owner_user_id',
|
|
'expected_revision',
|
|
'expected_canvas_layout_sha256',
|
|
'expected_project_layout_sha256',
|
|
'actions',
|
|
],
|
|
label,
|
|
);
|
|
const normalized = {
|
|
canvas_id: readIdentifier(canvas.canvas_id, `${label}.canvas_id`),
|
|
project_id: readIdentifier(canvas.project_id, `${label}.project_id`),
|
|
owner_user_id: readIdentifier(
|
|
canvas.owner_user_id,
|
|
`${label}.owner_user_id`,
|
|
),
|
|
expected_revision: readNonNegativeInteger(
|
|
canvas.expected_revision,
|
|
`${label}.expected_revision`,
|
|
),
|
|
expected_canvas_layout_sha256: readSha256(
|
|
canvas.expected_canvas_layout_sha256,
|
|
`${label}.expected_canvas_layout_sha256`,
|
|
),
|
|
expected_project_layout_sha256: readSha256(
|
|
canvas.expected_project_layout_sha256,
|
|
`${label}.expected_project_layout_sha256`,
|
|
),
|
|
actions: [],
|
|
};
|
|
if (
|
|
canvasIds.has(normalized.canvas_id) ||
|
|
projectIds.has(normalized.project_id)
|
|
) {
|
|
throw new Error(
|
|
`${label}.canvas_id 或 project_id 与 plan 内其它画布重复。`,
|
|
);
|
|
}
|
|
canvasIds.add(normalized.canvas_id);
|
|
projectIds.add(normalized.project_id);
|
|
|
|
if (!Array.isArray(canvas.actions) || canvas.actions.length === 0) {
|
|
throw new Error(`${label}.actions 必须是非空数组。`);
|
|
}
|
|
if (canvas.actions.length > MAX_ACTIONS_PER_CANVAS) {
|
|
throw new Error(
|
|
`${label}.actions 不能超过 ${MAX_ACTIONS_PER_CANVAS} 项。`,
|
|
);
|
|
}
|
|
const layerIds = new Set();
|
|
normalized.actions = canvas.actions.map((action, actionIndex) => {
|
|
const actionLabel = `${label}.actions[${actionIndex}]`;
|
|
const normalizedAction = validateRepairAction(action, actionLabel);
|
|
if (layerIds.has(normalizedAction.layer_id)) {
|
|
throw new Error(`${actionLabel}.layer_id 在同一画布内重复。`);
|
|
}
|
|
layerIds.add(normalizedAction.layer_id);
|
|
return normalizedAction;
|
|
});
|
|
actionCount += normalized.actions.length;
|
|
return normalized;
|
|
});
|
|
|
|
if (expectedCanvasCount !== canvases.length) {
|
|
throw new Error('plan.expected_canvas_count 与 canvases 实际数量不一致。');
|
|
}
|
|
if (expectedActionCount !== actionCount) {
|
|
throw new Error('plan.expected_action_count 与 actions 实际数量不一致。');
|
|
}
|
|
|
|
return {
|
|
schema_version: PLAN_SCHEMA_VERSION,
|
|
expected_canvas_count: expectedCanvasCount,
|
|
expected_action_count: expectedActionCount,
|
|
canvases,
|
|
};
|
|
}
|
|
|
|
function validateRepairAction(action, label) {
|
|
assertPlainObject(action, label);
|
|
assertExactKeys(
|
|
action,
|
|
[
|
|
'action_kind',
|
|
'layer_id',
|
|
'expected_resource_id',
|
|
'replacement_resource_id',
|
|
'expected_layout_source_resource_id',
|
|
'asset_object_id',
|
|
'asset_kind',
|
|
],
|
|
label,
|
|
);
|
|
const actionKind = readIdentifier(action.action_kind, `${label}.action_kind`);
|
|
if (!ACTION_KINDS.has(actionKind)) {
|
|
throw new Error(
|
|
`${label}.action_kind 只支持 reuse_project_resource 或 restore_project_resource。`,
|
|
);
|
|
}
|
|
const normalized = {
|
|
action_kind: actionKind,
|
|
layer_id: readIdentifier(action.layer_id, `${label}.layer_id`),
|
|
expected_resource_id: readIdentifier(
|
|
action.expected_resource_id,
|
|
`${label}.expected_resource_id`,
|
|
),
|
|
replacement_resource_id: readNullableIdentifier(
|
|
action.replacement_resource_id,
|
|
`${label}.replacement_resource_id`,
|
|
),
|
|
expected_layout_source_resource_id: readNullableIdentifier(
|
|
action.expected_layout_source_resource_id,
|
|
`${label}.expected_layout_source_resource_id`,
|
|
),
|
|
asset_object_id: readNullableIdentifier(
|
|
action.asset_object_id,
|
|
`${label}.asset_object_id`,
|
|
),
|
|
asset_kind: readNullableIdentifier(
|
|
action.asset_kind,
|
|
`${label}.asset_kind`,
|
|
),
|
|
};
|
|
|
|
if (actionKind === 'reuse_project_resource') {
|
|
if (!normalized.replacement_resource_id) {
|
|
throw new Error(
|
|
`${label}.replacement_resource_id 为复用资源动作的必填项。`,
|
|
);
|
|
}
|
|
if (!normalized.expected_layout_source_resource_id) {
|
|
throw new Error(
|
|
`${label}.expected_layout_source_resource_id 为复用资源动作的必填项。`,
|
|
);
|
|
}
|
|
if (
|
|
normalized.replacement_resource_id === normalized.expected_resource_id
|
|
) {
|
|
throw new Error(
|
|
`${label}.replacement_resource_id 不能等于 expected_resource_id。`,
|
|
);
|
|
}
|
|
if (normalized.asset_object_id || normalized.asset_kind) {
|
|
throw new Error(
|
|
`${label} 的复用资源动作不能携带 asset_object_id / asset_kind。`,
|
|
);
|
|
}
|
|
} else {
|
|
if (normalized.replacement_resource_id) {
|
|
throw new Error(
|
|
`${label} 的补资源动作不能携带 replacement_resource_id。`,
|
|
);
|
|
}
|
|
if (normalized.expected_layout_source_resource_id) {
|
|
throw new Error(
|
|
`${label} 的补资源动作不能携带 expected_layout_source_resource_id。`,
|
|
);
|
|
}
|
|
if (!normalized.asset_object_id || !normalized.asset_kind) {
|
|
throw new Error(
|
|
`${label}.asset_object_id / asset_kind 为补资源动作的必填项。`,
|
|
);
|
|
}
|
|
if (!RESTORABLE_ASSET_KINDS.has(normalized.asset_kind)) {
|
|
throw new Error(
|
|
`${label}.asset_kind 只支持 background-music 或 sound-effect。`,
|
|
);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export async function executeRepairPlan({
|
|
options,
|
|
plan,
|
|
planSha256,
|
|
callProcedure = callSpacetimeProcedureViaCli,
|
|
updatedAtMicros = Date.now() * 1_000,
|
|
}) {
|
|
validateExecutionOptions(options, planSha256);
|
|
const summary = {
|
|
ok: true,
|
|
dry_run: !options.apply,
|
|
plan_sha256: planSha256,
|
|
canvas_count: plan.expected_canvas_count,
|
|
action_count: plan.expected_action_count,
|
|
processed_canvas_count: 0,
|
|
matched_layer_count: 0,
|
|
updated_layer_count: 0,
|
|
inserted_resource_count: 0,
|
|
already_repaired_canvas_count: 0,
|
|
failures: [],
|
|
};
|
|
|
|
for (let index = 0; index < plan.canvases.length; index += 1) {
|
|
const canvas = plan.canvases[index];
|
|
const ordinal = index + 1;
|
|
const input = buildProcedureInput(canvas, updatedAtMicros, !options.apply);
|
|
let result;
|
|
try {
|
|
result = await callProcedure(options, PROCEDURE_NAME, input);
|
|
} catch {
|
|
throw new Error(
|
|
`第 ${ordinal} 个画布调用失败;详细信息已隐藏,且未继续处理后续画布。`,
|
|
);
|
|
}
|
|
|
|
const normalizedResult = normalizePublicResult(result, !options.apply);
|
|
if (!normalizedResult.ok) {
|
|
const failure = {
|
|
canvas_ordinal: ordinal,
|
|
error_code: normalizedResult.error_code,
|
|
};
|
|
if (options.apply) {
|
|
throw new Error(
|
|
`第 ${ordinal} 个画布修复失败,error_code=${failure.error_code};此前成功处理 ${summary.processed_canvas_count} 个画布,已停止。`,
|
|
);
|
|
}
|
|
summary.failures.push(failure);
|
|
continue;
|
|
}
|
|
|
|
summary.processed_canvas_count += 1;
|
|
summary.matched_layer_count += normalizedResult.matched_layer_count;
|
|
summary.updated_layer_count += normalizedResult.updated_layer_count;
|
|
summary.inserted_resource_count += normalizedResult.inserted_resource_count;
|
|
if (normalizedResult.already_repaired) {
|
|
summary.already_repaired_canvas_count += 1;
|
|
}
|
|
}
|
|
|
|
summary.ok = summary.failures.length === 0;
|
|
return summary;
|
|
}
|
|
|
|
export function buildProcedureInput(canvas, updatedAtMicros, dryRun) {
|
|
return {
|
|
canvas_id: canvas.canvas_id,
|
|
project_id: canvas.project_id,
|
|
owner_user_id: canvas.owner_user_id,
|
|
expected_revision: canvas.expected_revision,
|
|
expected_canvas_layout_sha256: canvas.expected_canvas_layout_sha256,
|
|
expected_project_layout_sha256: canvas.expected_project_layout_sha256,
|
|
remaps: canvas.actions
|
|
.filter((action) => action.action_kind === 'reuse_project_resource')
|
|
.map((action) => ({
|
|
layer_id: action.layer_id,
|
|
expected_missing_resource_id: action.expected_resource_id,
|
|
replacement_resource_id: action.replacement_resource_id,
|
|
expected_layout_source_resource_id:
|
|
action.expected_layout_source_resource_id,
|
|
})),
|
|
restores: canvas.actions
|
|
.filter((action) => action.action_kind === 'restore_project_resource')
|
|
.map((action) => ({
|
|
layer_id: action.layer_id,
|
|
resource_id: action.expected_resource_id,
|
|
asset_object_id: action.asset_object_id,
|
|
asset_kind: action.asset_kind,
|
|
})),
|
|
updated_at_micros: updatedAtMicros,
|
|
dry_run: dryRun,
|
|
};
|
|
}
|
|
|
|
function validateExecutionOptions(options, planSha256) {
|
|
validateConnectionOptions(options);
|
|
if (options.apply) {
|
|
if (!SHA256_PATTERN.test(options.confirmPlanSha256 || '')) {
|
|
throw new Error('--apply 必须传入 64 位 --confirm-plan-sha256。');
|
|
}
|
|
if (options.confirmPlanSha256 !== planSha256) {
|
|
throw new Error('--confirm-plan-sha256 与当前 plan 不一致,拒绝 apply。');
|
|
}
|
|
} else if (options.confirmPlanSha256) {
|
|
throw new Error('--confirm-plan-sha256 只能与 --apply 一起使用。');
|
|
}
|
|
}
|
|
|
|
function validateConnectionOptions(options) {
|
|
if (!options.database) {
|
|
throw new Error(
|
|
'必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。',
|
|
);
|
|
}
|
|
if (!options.server && !options.serverUrl) {
|
|
throw new Error(
|
|
'必须显式传入 --server / --server-url,不使用默认 cloud target。',
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizePublicResult(result, expectedDryRun) {
|
|
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
throw new Error('procedure 返回结构无效;详细信息已隐藏。');
|
|
}
|
|
if (typeof result.ok !== 'boolean') {
|
|
throw new Error('procedure 返回的 ok 无效。');
|
|
}
|
|
if (!result.ok) {
|
|
return {
|
|
ok: false,
|
|
matched_layer_count: 0,
|
|
updated_layer_count: 0,
|
|
inserted_resource_count: 0,
|
|
already_repaired: false,
|
|
error_code: 'repair_rejected',
|
|
};
|
|
}
|
|
const repair = result.repair;
|
|
if (
|
|
!repair ||
|
|
typeof repair !== 'object' ||
|
|
Array.isArray(repair) ||
|
|
repair.dry_run !== expectedDryRun
|
|
) {
|
|
throw new Error('procedure 返回的 repair / dry_run 与请求不一致。');
|
|
}
|
|
return {
|
|
ok: true,
|
|
matched_layer_count: readResultCount(
|
|
repair.matched_layer_count,
|
|
'matched_layer_count',
|
|
),
|
|
updated_layer_count: readResultCount(
|
|
repair.remapped_layer_count,
|
|
'remapped_layer_count',
|
|
),
|
|
inserted_resource_count: readResultCount(
|
|
repair.restored_resource_count,
|
|
'restored_resource_count',
|
|
),
|
|
already_repaired: repair.already_repaired === true,
|
|
error_code: null,
|
|
};
|
|
}
|
|
|
|
function readResultCount(value, name) {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new Error(`procedure 返回的 ${name} 无效。`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertSafeAbsolutePath(value, name) {
|
|
if (typeof value !== 'string' || !value || !path.isAbsolute(value)) {
|
|
throw new Error(`${name} 必须是绝对路径。`);
|
|
}
|
|
if (
|
|
value.includes('\0') ||
|
|
/[\r\n]/u.test(value) ||
|
|
value === path.parse(value).root
|
|
) {
|
|
throw new Error(`${name} 不是安全的文件路径。`);
|
|
}
|
|
}
|
|
|
|
function isPathInside(candidate, root) {
|
|
const relative = path.relative(root, candidate);
|
|
return (
|
|
relative === '' ||
|
|
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
async function normalizeSystemTempAlias(candidate) {
|
|
const resolvedTempRoot = path.resolve(tmpdir());
|
|
if (!isPathInside(candidate, resolvedTempRoot)) {
|
|
return candidate;
|
|
}
|
|
|
|
const canonicalTempRoot = await realpath(resolvedTempRoot);
|
|
return path.join(
|
|
canonicalTempRoot,
|
|
path.relative(resolvedTempRoot, candidate),
|
|
);
|
|
}
|
|
|
|
function assertPlainObject(value, label) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`${label} 必须是对象。`);
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(value, expectedKeys, label) {
|
|
const expected = new Set(expectedKeys);
|
|
const actualKeys = Object.keys(value);
|
|
const unexpected = actualKeys.filter((key) => !expected.has(key));
|
|
const missing = expectedKeys.filter((key) => !Object.hasOwn(value, key));
|
|
if (unexpected.length > 0 || missing.length > 0) {
|
|
throw new Error(`${label} 字段不完整或包含未知字段。`);
|
|
}
|
|
}
|
|
|
|
function readIdentifier(value, label) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.length > 512 ||
|
|
value !== value.trim() ||
|
|
value.includes('\0') ||
|
|
/[\r\n]/u.test(value)
|
|
) {
|
|
throw new Error(`${label} 必须是非空且不含控制字符的字符串。`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readNullableIdentifier(value, label) {
|
|
return value === null ? null : readIdentifier(value, label);
|
|
}
|
|
|
|
function readSha256(value, label) {
|
|
if (typeof value !== 'string' || !SHA256_PATTERN.test(value.toLowerCase())) {
|
|
throw new Error(`${label} 必须是 64 位十六进制 SHA-256。`);
|
|
}
|
|
return value.toLowerCase();
|
|
}
|
|
|
|
function readPositiveInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error(`${label} 必须是正整数。`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readNonNegativeInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new Error(`${label} 必须是非负整数。`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function runCli(argv, dependencies = {}) {
|
|
const options = parseOptions(argv);
|
|
if (options.help) {
|
|
console.log(usage());
|
|
return { ok: true, help: true };
|
|
}
|
|
if (!options.planFile) {
|
|
throw new Error('必须传入 --plan-file。');
|
|
}
|
|
validateConnectionOptions(options);
|
|
const readPlan = dependencies.readPlan || readRepairPlan;
|
|
const { plan, planSha256 } = await readPlan(options.planFile);
|
|
const summary = await executeRepairPlan({
|
|
options,
|
|
plan,
|
|
planSha256,
|
|
callProcedure: dependencies.callProcedure,
|
|
updatedAtMicros: dependencies.updatedAtMicros,
|
|
});
|
|
let verification = null;
|
|
if (options.apply && summary.ok) {
|
|
const verificationSummary = await executeRepairPlan({
|
|
options: {
|
|
...options,
|
|
apply: false,
|
|
confirmPlanSha256: '',
|
|
},
|
|
plan,
|
|
planSha256,
|
|
callProcedure: dependencies.callProcedure,
|
|
updatedAtMicros: dependencies.updatedAtMicros,
|
|
});
|
|
if (
|
|
!verificationSummary.ok ||
|
|
verificationSummary.processed_canvas_count !==
|
|
plan.expected_canvas_count ||
|
|
verificationSummary.already_repaired_canvas_count !==
|
|
plan.expected_canvas_count
|
|
) {
|
|
throw new Error('apply 后自动 dry-run 复核未全部命中 already_repaired。');
|
|
}
|
|
verification = {
|
|
ok: true,
|
|
processed_canvas_count: verificationSummary.processed_canvas_count,
|
|
already_repaired_canvas_count:
|
|
verificationSummary.already_repaired_canvas_count,
|
|
};
|
|
}
|
|
console.log(JSON.stringify({ ...summary, verification }, null, 2));
|
|
if (!options.apply && summary.ok) {
|
|
console.log(
|
|
`dry-run 已通过;确认匿名计数后,使用 --apply --confirm-plan-sha256 ${summary.plan_sha256} 重跑同一 plan。`,
|
|
);
|
|
}
|
|
if (!summary.ok) {
|
|
process.exitCode = 1;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
function isCliEntry() {
|
|
const entry = process.argv[1];
|
|
return entry
|
|
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
|
|
: false;
|
|
}
|
|
|
|
if (isCliEntry()) {
|
|
runCli(process.argv.slice(2)).catch((error) => {
|
|
console.error(
|
|
`[spacetime:editor-canvas-resources:repair] ${
|
|
error instanceof Error ? error.message : '操作失败;详细信息已隐藏。'
|
|
}`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|