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'); 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; } } } function buildRootDevArgs(argv = []) { validateDevAllArgs(argv); return [ 'run', 'dev', '--', '--database', agcDatabase, '--spacetime-data-dir', agcDataDirArgument, '--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); const rootState = await waitForRoot(rootChild, { readState, expected: { expectedRepoRoot: repoRoot, expectedDatabase: agcDatabase, expectedDataDir: agcDataDir, }, }); 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(); }