#!/usr/bin/env node import { createHash } from 'node:crypto'; import { lstat, readFile, realpath } from 'node:fs/promises'; 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 --server --plan-file \\ [--apply --confirm-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; try { canonicalPath = await realpath(resolvedPath); } catch { throw new Error('--plan-file 无法解析或不存在。'); } if (canonicalPath !== resolvedPath) { throw new Error('--plan-file 路径链不能包含符号链接。'); } if (isPathInside(canonicalPath, resolvedRepoRoot)) { 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)) ); } 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; }); }