#!/usr/bin/env node import { spawn } from 'node:child_process'; import { callSpacetimeProcedureViaCli, ensureProcedureOk, validateSpacetimeDatabaseName, } from '../spacetime-migration-common.mjs'; const PROCEDURE = 'rotate_editor_generation_runtime_service_identity_and_return'; function usage() { console.error( '用法: production-runtime-writer-identity-rotate.mjs --database --server-url --operator-identity <64-hex> --operator-user-id --next-writer-identity <64-hex> --confirm-next-writer-identity <64-hex> --note ', ); } function readValue(argv, index, name) { const value = argv[index + 1]; if (!value || value.startsWith('--')) { throw new Error(`${name} 缺少参数值。`); } return value; } function normalizeIdentity(value, name) { const identity = value.trim().replace(/^0x/u, '').toLowerCase(); if (!/^[0-9a-f]{64}$/u.test(identity)) { throw new Error(`${name} 必须是 64 位十六进制 SpacetimeDB identity。`); } return identity; } async function readCurrentCliIdentity() { const output = await new Promise((resolve, reject) => { const child = spawn('spacetime', ['login', 'show'], { shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); let text = ''; child.stdout.on('data', (chunk) => { text += chunk.toString(); }); child.stderr.on('data', (chunk) => { text += chunk.toString(); }); child.on('error', reject); child.on('exit', (code, signal) => { if (signal) { reject(new Error(`spacetime login show 被信号中断: ${signal}`)); } else if (code !== 0) { reject(new Error(`无法读取当前 SpacetimeDB CLI identity,退出码 ${code}`)); } else { resolve(text); } }); }); const identities = [...output.matchAll(/\b[0-9a-f]{64}\b/giu)].map((match) => match[0].toLowerCase(), ); if (identities.length !== 1) { throw new Error('spacetime login show 未返回唯一的当前 CLI identity。'); } return identities[0]; } function parseOptions(argv) { const options = { database: '', serverUrl: '', operatorIdentity: '', operatorUserId: '', nextWriterIdentity: '', confirmNextWriterIdentity: '', note: '', passthrough: [], }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-h') { usage(); process.exit(0); } if (arg === '--database') { options.database = readValue(argv, index, arg); } else if (arg === '--server-url') { options.serverUrl = readValue(argv, index, arg); } else if (arg === '--operator-identity') { options.operatorIdentity = readValue(argv, index, arg); } else if (arg === '--operator-user-id') { options.operatorUserId = readValue(argv, index, arg); } else if (arg === '--next-writer-identity') { options.nextWriterIdentity = readValue(argv, index, arg); } else if (arg === '--confirm-next-writer-identity') { options.confirmNextWriterIdentity = readValue(argv, index, arg); } else if (arg === '--note') { options.note = readValue(argv, index, arg); } else { throw new Error(`未知参数: ${arg}`); } index += 1; } return options; } try { const options = parseOptions(process.argv.slice(2)); validateSpacetimeDatabaseName(options.database); if (!/^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/u.test(options.serverUrl)) { throw new Error('--server-url 必须是无路径、无查询参数的显式 http(s) URL。'); } const operatorIdentity = normalizeIdentity(options.operatorIdentity, '--operator-identity'); const nextWriterIdentity = normalizeIdentity( options.nextWriterIdentity, '--next-writer-identity', ); const confirmedIdentity = normalizeIdentity( options.confirmNextWriterIdentity, '--confirm-next-writer-identity', ); if (nextWriterIdentity !== confirmedIdentity) { throw new Error('--confirm-next-writer-identity 必须与 --next-writer-identity 完全一致。'); } if (nextWriterIdentity === operatorIdentity) { throw new Error('runtime writer identity 不能设为 migration operator identity。'); } const currentCliIdentity = await readCurrentCliIdentity(); if (operatorIdentity !== currentCliIdentity) { throw new Error('--operator-identity 必须与当前 SpacetimeDB CLI 登录 identity 一致。'); } const operatorUserId = options.operatorUserId.trim(); if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/u.test(operatorUserId)) { throw new Error('--operator-user-id 格式不合法。'); } const note = options.note.trim(); if (!note || note.length > 500) { throw new Error('--note 必填且不能超过 500 个字符。'); } const result = await callSpacetimeProcedureViaCli( { database: options.database, serverUrl: options.serverUrl, passthrough: [] }, PROCEDURE, { next_writer_identity_hex: nextWriterIdentity, operator_user_id: operatorUserId, note, }, ); ensureProcedureOk(result); console.log( `[runtime-writer-identity-rotation] 已完成 identity rotation: database=${options.database}, operator_identity=${operatorIdentity}, next_writer_identity=${nextWriterIdentity}; 审计记录已写入 editor_generation_runtime_identity_rotation。`, ); } catch (error) { console.error( `[runtime-writer-identity-rotation] 操作失败;必须使用已授权的 migration operator CLI 登录态。${error instanceof Error ? ` ${error.message}` : String(error)}`, ); process.exit(1); }