Files
Genarrative/scripts/dev-all.mjs
T
suzmii bde909315e
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m19s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m55s
Project CI / Backend tests (pull_request) Successful in 9m11s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m46s
Project CI / Native shell tests (pull_request) Successful in 8m35s
Project CI / Repository checks (pull_request) Successful in 7m12s
feat(开发脚本): dev:all 支持 --clear-database 并提示本地库 schema 冲突
默认仍写死 --preserve-database(不改默认行为);显式 --clear-database 时不再传它、逐字透传 dev.mjs 既有语义;根栈因 manual migration / Reordering table 失败时补一段可操作提示(打印库名与 data dir、两条出路、清库会丢数据的警告),提示文案抽成纯函数并补单测;文档补一句
2026-10-06 19:41:36 +08:00

682 lines
20 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.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveAgcDevEndpoint } from '../apps/ai-game-creator-shell/scripts/dev-port.mjs';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const agcDatabase = 'genarrative-game-creator-dev';
const agcDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
const agcDataDirArgument = 'server-rs/.spacetimedb/ai-game-creator/data';
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
// AGC 配套本地库的 schema 冲突识别:SpacetimeDB 拒绝自动迁移时会在服务端日志里留下这两类特征串
// (`… needs manual migration` / `Reordering table <name> requires a manual migration`)。
const localSchemaConflictPattern =
/manual migration|Reordering table/i;
// 唯一允许「冲突即清库」的显式开关:dev:all 默认写死 --preserve-database(不删库),
// 只有调用方显式传 --clear-database(dev.mjs 既有语义:preserve=false → -c=on-conflict)才清库。
const clearDatabaseOption = '--clear-database';
const spacetimeStartLogName = 'logs/dev-spacetime-start.log';
const rootReadyTimeoutMs = 660_000;
const rootReadyPollMs = 500;
const childGracefulTimeoutMs = 2_500;
const childForceTimeoutMs = 2_000;
const agcReadyPollMs = 500;
const rootOnlyOptions = new Set([
'--database',
'--spacetime-data-dir',
'--database=',
'--spacetime-data-dir=',
]);
const optionsWithValues = new Set([
'--api-host',
'--api-port',
'--bgfilter-worker-host',
'--bgfilter-worker-port',
'--web-host',
'--web-port',
'--admin-web-host',
'--admin-web-port',
'--spacetime-host',
'--spacetime-port',
'--port-range',
'--log',
'--spacetime-timeout-seconds',
'--api-timeout-seconds',
'--migration-bootstrap-secret',
]);
function isRootOnlyOption(argument) {
return [...rootOnlyOptions].some(
(option) => argument === option || argument.startsWith(option),
);
}
function validateDevAllArgs(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument.startsWith('-')) {
throw new Error(
`npm run dev:all 只接受 dev 选项,不接受子命令或位置参数: ${argument}`,
);
}
if (isRootOnlyOption(argument)) {
throw new Error(
'npm run dev:all 固定使用 AGC 配套 database 与 SpacetimeDB data dir,不能覆盖 --database 或 --spacetime-data-dir',
);
}
if (optionsWithValues.has(argument)) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`dev:all 选项 ${argument} 缺少值`);
}
index += 1;
}
}
}
/// 判定根栈失败是否由「本地库 schema 与源码不一致」造成(纯函数,供单测钉住)。
export function isLocalSchemaConflictOutput(output = '') {
return localSchemaConflictPattern.test(String(output ?? ''));
}
/// 读 SpacetimeDB 启动日志尾部(失败诊断用):文件不存在或读取失败都返回空串,绝不抛。
function readSpacetimeStartLogTail(dataDir = agcDataDir, maxBytes = 64 * 1024) {
const logPath = resolve(dataDir, spacetimeStartLogName);
if (!existsSync(logPath)) {
return '';
}
try {
const text = readFileSync(logPath, 'utf8');
return text.length > maxBytes ? text.slice(-maxBytes) : text;
} catch {
return '';
}
}
/// 生成可操作提示(纯函数):库名与 data dir 打实际值,给出两条出路,并明确清库会丢本地数据。
export function buildLocalSchemaConflictHint({
database = agcDatabase,
dataDir = agcDataDirArgument,
} = {}) {
return [
'[dev:all] 本地 SpacetimeDB schema 与源码不一致:本次 publish 需要手动迁移而被拒(不是代码问题)。',
` 目标库:${database}`,
` 数据目录:${dataDir}`,
' 两条出路:',
` ① 清库重建后重跑:npm run dev:all -- ${clearDatabaseOption}`,
` ② 只想先起根栈自愈:npm run dev -- --database ${database} --spacetime-data-dir ${dataDir}(不带 --preserve-database)`,
' ⚠ ① 与 ② 都会丢弃该本地库里的数据(仅本地开发数据,生产库不受影响)。',
' ⚠ 默认不加开关时 dev:all 会保留该库,冲突不会自愈——这是有意为之,避免误清库。',
].join('\n');
}
function buildRootDevArgs(argv = []) {
validateDevAllArgs(argv);
// 默认继续写死 --preserve-database(不改默认,避免误清库);
// 只有调用方显式传 --clear-database 时才交给根栈按 dev.mjs 既有语义(preserve=false → -c=on-conflict)清库。
const wantsClearDatabase = argv.includes(clearDatabaseOption);
return [
'run',
'dev',
'--',
'--database',
agcDatabase,
'--spacetime-data-dir',
agcDataDirArgument,
...(wantsClearDatabase ? [] : ['--preserve-database']),
'--no-interactive',
...argv,
];
}
function buildDevAllBaseEnvironment(argv = [], baseEnv = process.env) {
let portRange;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--port-range') {
portRange = argv[index + 1];
index += 1;
}
}
return portRange
? { ...baseEnv, GENARRATIVE_DEV_PORT_RANGE: portRange }
: { ...baseEnv };
}
function buildDevAllEnvironment(baseEnv = process.env, agcVitePort) {
if (!Number.isInteger(agcVitePort) || agcVitePort < 1024) {
throw new Error(`AGC Vite 端口无效: ${agcVitePort}`);
}
return {
...baseEnv,
GENARRATIVE_AGC_VITE_PORT: String(agcVitePort),
AGC_DEV_ADMIN_WEB: '0',
};
}
function readDevStackState(path = devStackStatePath) {
if (!existsSync(path)) {
return null;
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
async function isHttpReady(url, timeoutMs = 1_000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response.status >= 200 && response.status < 300;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function areRootFrontendsReady(state) {
const urls = [
state?.services?.web?.url,
state?.services?.['admin-web']?.url,
].filter(Boolean);
if (urls.length !== 2) {
return false;
}
const results = await Promise.all(urls.map((url) => isHttpReady(url)));
return results.every(Boolean);
}
async function readAgcFrontendMarker(endpoint, timeoutMs = 1_000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(endpoint.markerUrl, {
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const marker = await response.json();
return marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell'
? marker
: null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function waitForAgcFrontend(child, endpoint, options = {}) {
const timeoutMs = options.timeoutMs ?? rootReadyTimeoutMs;
const pollMs = options.pollMs ?? agcReadyPollMs;
const readMarker = options.readMarker ?? readAgcFrontendMarker;
const childExit = waitForChildExit(child);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await readMarker(endpoint)) {
return true;
}
const result = await Promise.race([
childExit,
wait(pollMs).then(() => null),
]);
if (result) {
const detail = result.error
? result.error.message
: result.signal
? `signal=${result.signal}`
: `code=${result.code ?? 1}`;
throw new Error(`AGC Vite 未就绪即退出: ${detail}`);
}
}
throw new Error(`等待 AGC Vite 就绪超时:${endpoint.url}`);
}
function isActiveService(service, expectedRepoRoot, expectedInstanceId) {
return Boolean(
service &&
service.status === 'running' &&
service.url &&
service.repoRoot &&
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
service.instanceId === expectedInstanceId,
);
}
function isSharedRootStackReady(
state,
{
expectedRepoRoot = repoRoot,
expectedDatabase = agcDatabase,
expectedDataDir = agcDataDir,
} = {},
) {
if (!state || state.command !== 'all') {
return false;
}
const instanceId = String(state.instanceId ?? '').trim();
if (!instanceId || state.database !== expectedDatabase) {
return false;
}
if (
!state.repoRoot ||
resolve(state.repoRoot) !== resolve(expectedRepoRoot) ||
!state.spacetimeDataDir ||
resolve(state.spacetimeDataDir) !== resolve(expectedDataDir)
) {
return false;
}
const services = state.services ?? {};
const spacetime = services.spacetime;
const requiredServices = [
services['api-server'],
services['bgfilter-worker'],
services.web,
services['admin-web'],
];
if (
!spacetime ||
!['running', 'reused'].includes(spacetime.status) ||
!spacetime.url ||
!spacetime.repoRoot ||
resolve(spacetime.repoRoot) !== resolve(expectedRepoRoot) ||
spacetime.instanceId !== instanceId
) {
return false;
}
return requiredServices.every((service) =>
isActiveService(service, expectedRepoRoot, instanceId),
);
}
function formatServiceStatus(service) {
return service?.status === 'reused' ? '已复用' : '已启动';
}
function buildStartupSummary(state, agcEndpoint) {
const services = state?.services ?? {};
const rows = [
['SpacetimeDB', services.spacetime],
['BgFilter worker', services['bgfilter-worker']],
['API server', services['api-server']],
['Web 主站', services.web],
['Admin 后台', services['admin-web']],
].map(([name, service]) => ({
项目: name,
状态: formatServiceStatus(service),
地址: service?.url ?? '—',
}));
return [
...rows,
{ 项目: 'AGC Vite', 状态: '已启动', 地址: agcEndpoint.url },
{ 项目: 'Tauri AGC', 状态: '已启动', 地址: '本地窗口' },
];
}
function getDisplayWidth(value) {
return [...String(value)].reduce(
(width, character) => width + (character.charCodeAt(0) > 0xff ? 2 : 1),
0,
);
}
function renderStartupTable(rows) {
const columns = ['项目', '状态', '地址'];
const values = rows.map((row) => columns.map((column) => row[column] ?? '—'));
const widths = columns.map((column, index) =>
Math.max(
getDisplayWidth(column),
...values.map((row) => getDisplayWidth(row[index])),
),
);
const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
const renderRow = (row) =>
`| ${row.map((value, index) => `${value}${' '.repeat(widths[index] - getDisplayWidth(value))}`).join(' | ')} |`;
return [
border,
renderRow(columns),
border,
...values.map(renderRow),
border,
].join('\n');
}
function printStartupSummary(state, agcEndpoint) {
console.log(
`\n[dev:all] 全部开发服务已启动\n${renderStartupTable(buildStartupSummary(state, agcEndpoint))}`,
);
}
function waitForChildExit(child) {
if (!child) {
return Promise.resolve({ code: null, signal: null, error: null });
}
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({
code: child.exitCode,
signal: child.signalCode,
error: null,
});
}
return new Promise((resolveExit) => {
const onError = (error) => {
cleanup();
resolveExit({ code: null, signal: null, error });
};
const onExit = (code, signal) => {
cleanup();
resolveExit({ code, signal, error: null });
};
const cleanup = () => {
child.off('error', onError);
child.off('exit', onExit);
};
child.once('error', onError);
child.once('exit', onExit);
});
}
function buildManagedSpawnOptions(env) {
return {
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
detached: process.platform !== 'win32',
windowsHide: process.platform === 'win32',
};
}
function spawnManaged(command, args, env) {
return spawn(command, args, buildManagedSpawnOptions(env));
}
function wait(ms) {
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
}
async function waitForRootStack(rootChild, options = {}) {
const timeoutMs = options.timeoutMs ?? rootReadyTimeoutMs;
const pollMs = options.pollMs ?? rootReadyPollMs;
const readState = options.readState ?? (() => readDevStackState());
const expected = options.expected ?? {};
const childExit = waitForChildExit(rootChild);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const state = readState();
if (
isSharedRootStackReady(state, expected) &&
(options.checkFrontends === false || (await areRootFrontendsReady(state)))
) {
return state;
}
const result = await Promise.race([
childExit,
wait(pollMs).then(() => null),
]);
if (result) {
const detail = result.error
? result.error.message
: result.signal
? `signal=${result.signal}`
: `code=${result.code ?? 1}`;
throw new Error(`根开发栈未就绪即退出: ${detail}`);
}
}
throw new Error('等待根开发栈五个服务就绪超时');
}
async function runTaskkill(pid) {
return new Promise((resolveKill) => {
const killer = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
shell: false,
});
const timer = setTimeout(() => {
killer.kill('SIGKILL');
resolveKill();
}, childGracefulTimeoutMs + childForceTimeoutMs);
killer.once('error', () => {
clearTimeout(timer);
resolveKill();
});
killer.once('exit', () => {
clearTimeout(timer);
resolveKill();
});
});
}
async function terminateChildTree(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
if (process.platform === 'win32') {
if (Number.isInteger(child.pid)) {
await runTaskkill(child.pid);
} else {
child.kill('SIGTERM');
}
return;
}
const processGroupId = Number.isInteger(child.pid) ? -child.pid : child.pid;
if (!Number.isInteger(processGroupId)) {
return;
}
try {
process.kill(processGroupId, 'SIGTERM');
} catch {
return;
}
const stoppedGracefully = await waitUntilChildExit(
child,
childGracefulTimeoutMs,
);
if (stoppedGracefully) {
return;
}
try {
process.kill(processGroupId, 'SIGKILL');
} catch {
return;
}
await waitUntilChildExit(child, childForceTimeoutMs);
}
async function waitUntilChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return true;
}
const result = await Promise.race([
waitForChildExit(child),
wait(timeoutMs).then(() => null),
]);
return result !== null;
}
async function runDevAll(argv = process.argv.slice(2), dependencies = {}) {
validateDevAllArgs(argv);
const resolveEndpoint = dependencies.resolveEndpoint ?? resolveAgcDevEndpoint;
const spawnProcess = dependencies.spawnProcess ?? spawnManaged;
const stopProcessTree = dependencies.stopProcessTree ?? terminateChildTree;
const readState = dependencies.readState ?? (() => readDevStackState());
const waitForRoot = dependencies.waitForRoot ?? waitForRootStack;
const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend;
let rootChild = null;
let agcChild = null;
let shuttingDown = false;
let signalExitCode = 0;
let stopPromise = null;
const signalHandlers = new Map();
const stopAll = () => {
if (stopPromise) {
return stopPromise;
}
shuttingDown = true;
stopPromise = (async () => {
await stopProcessTree(agcChild);
await stopProcessTree(rootChild);
})();
return stopPromise;
};
const handleSignal = (signal) => {
signalExitCode = signal === 'SIGTERM' ? 143 : 130;
void stopAll();
};
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => handleSignal(signal);
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const baseEnv = buildDevAllBaseEnvironment(argv);
const endpoint = await resolveEndpoint({
env: baseEnv,
strictConfigured: true,
});
const env = buildDevAllEnvironment(baseEnv, endpoint.port);
const rootArgs = buildRootDevArgs(argv);
console.log(
`[dev:all] 保留 AGC Vite 端口 ${endpoint.url},启动共享根开发栈`,
);
rootChild = spawnProcess(npmCommand, rootArgs, env);
const rootExit = waitForChildExit(rootChild);
let rootState;
try {
rootState = await waitForRoot(rootChild, {
readState,
expected: {
expectedRepoRoot: repoRoot,
expectedDatabase: agcDatabase,
expectedDataDir: agcDataDir,
},
});
} catch (error) {
// 根栈失败原因常常只在 SpacetimeDB 启动日志里(子进程 stdio 是 inherit,dev:all 抓不到它的 stdout):
// 命中「本地库 schema 需手动迁移」时补一段可操作提示,否则原样抛出。
if (isLocalSchemaConflictOutput(readSpacetimeStartLogTail())) {
console.error(buildLocalSchemaConflictHint());
}
throw error;
}
if (rootChild.exitCode !== null || rootChild.signalCode !== null) {
throw new Error('根开发栈已退出,不能启动 AGC');
}
if (shuttingDown) {
await stopAll();
return signalExitCode || 1;
}
console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri');
agcChild = spawnProcess(npmCommand, ['run', 'agc'], env);
const agcExit = waitForChildExit(agcChild);
await waitForAgc(agcChild, endpoint);
if (rootChild.exitCode !== null || rootChild.signalCode !== null) {
throw new Error('根开发栈在 AGC 就绪前退出');
}
if (shuttingDown) {
await stopAll();
return signalExitCode || 1;
}
printStartupSummary(readState() ?? rootState, endpoint);
const result = await Promise.race([
rootExit.then((exit) => ({ owner: 'root', exit })),
agcExit.then((exit) => ({ owner: 'agc', exit })),
]);
if (shuttingDown) {
await stopAll();
return signalExitCode || 1;
}
const detail = result.exit.error
? result.exit.error.message
: result.exit.signal
? `signal=${result.exit.signal}`
: `code=${result.exit.code ?? 0}`;
const exitCode =
result.exit.error || result.exit.signal ? 1 : (result.exit.code ?? 0);
console.error(`[dev:all] ${result.owner} 进程退出: ${detail}`);
await stopAll();
return exitCode;
} catch (error) {
console.error(
`[dev:all] ${error instanceof Error ? error.message : error}`,
);
await stopAll();
return signalExitCode || 1;
} finally {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
export {
agcDatabase,
agcDataDir,
buildDevAllBaseEnvironment,
buildDevAllEnvironment,
buildManagedSpawnOptions,
buildRootDevArgs,
buildStartupSummary,
isHttpReady,
isSharedRootStackReady,
readDevStackState,
renderStartupTable,
runDevAll,
validateDevAllArgs,
};
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
) {
process.exitCode = await runDevAll();
}