diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 20cf4a1ea..d8cb7d8c5 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -568,7 +568,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 detached: isPosix, - stdio: 'inherit', + stdio: options.stdio ?? 'inherit', }); const lifecycle = { failure: null, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 7cc06d82a..048ac9ec6 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -33,6 +33,7 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG'; const designDebugEnabled = process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1'; +const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const args = [...argv]; @@ -103,11 +104,27 @@ function prepareBundledResourcesBeforeTauri( } function spawnTauriCli(argv, { env = process.env } = {}) { - return spawnChild(process.execPath, [tauriCliPath, ...argv], { + const child = spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, env, shell: false, + stdio: ['inherit', 'pipe', 'pipe'], }); + let output = ''; + let announced = false; + const forwardOutput = (stream, target) => { + stream?.on('data', (chunk) => { + target.write(chunk); + output = `${output}${chunk.toString()}`.slice(-4096); + if (!announced && output.includes('Running DevCommand')) { + announced = true; + console.log(tauriDevReadyMarker); + } + }); + }; + forwardOutput(child.stdout, process.stdout); + forwardOutput(child.stderr, process.stderr); + return child; } /// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bc30babb5..21e23ba35 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -78,7 +78,7 @@ npm run dev:all `npm run dev:all` 保持 `npm run dev` 的主站完整栈语义不变,使用 AGC 当前配套后端的数据库 `genarrative-game-creator-dev` 与 data dir `server-rs/.spacetimedb/ai-game-creator/data` 启动一份 SpacetimeDB、BgFilter worker、api-server、主站 Vite 和后台 Vite;待这五个服务就绪后,再启动 AGC Vite 与 Tauri。AGC 通过匹配的 `.app/dev-stack.json` 复用这份后端,且设置 `AGC_DEV_ADMIN_WEB=0`,因此一键入口只保留一份管理后台。该入口不接受覆盖 database 或 SpacetimeDB data dir 的参数;需要独立数据库时分别使用 `npm run dev` / `npm run agc`。 -一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 可访问后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。 +一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 和 Tauri `Running DevCommand` 就绪标记都出现后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs index 06d29a9df..3dd75afe3 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -20,6 +20,7 @@ const rootReadyPollMs = 500; const childGracefulTimeoutMs = 2_500; const childForceTimeoutMs = 2_000; const agcReadyPollMs = 500; +const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; const rootOnlyOptions = new Set([ '--database', @@ -278,9 +279,39 @@ function buildStartupSummary(state, agcEndpoint) { ]; } -function printStartupSummary(state, agcEndpoint, table = console.table) { - console.log('\n[dev:all] 全部开发服务已启动'); - table(buildStartupSummary(state, agcEndpoint)); +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) { @@ -313,19 +344,53 @@ function waitForChildExit(child) { }); } -function buildManagedSpawnOptions(env) { +function buildManagedSpawnOptions(env, { captureOutput = false } = {}) { return { cwd: repoRoot, env, - stdio: 'inherit', + stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : '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 createTauriReadyPromise(child) { + return new Promise((resolveReady) => { + let ready = false; + let output = ''; + const resolveOnce = () => { + if (!ready) { + ready = true; + resolveReady(); + } + }; + const forward = (stream, target) => { + stream?.on('data', (chunk) => { + target.write(chunk); + output = `${output}${chunk.toString()}`.slice(-4096); + if (output.includes(tauriDevReadyMarker)) { + resolveOnce(); + } + }); + }; + forward(child.stdout, process.stdout); + forward(child.stderr, process.stderr); + child.once('error', resolveOnce); + child.once('exit', resolveOnce); + }); +} + +function spawnManaged(command, args, env, options = {}) { + const child = spawn(command, args, buildManagedSpawnOptions(env, options)); + child.tauriReadyPromise = options.captureOutput + ? createTauriReadyPromise(child) + : Promise.resolve(); + return child; +} + +function waitForTauriProcess(child) { + return child?.tauriReadyPromise ?? Promise.resolve(); } function wait(ms) { @@ -449,6 +514,7 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { const readState = dependencies.readState ?? (() => readDevStackState()); const waitForRoot = dependencies.waitForRoot ?? waitForRootStack; const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend; + const waitForTauri = dependencies.waitForTauri ?? waitForTauriProcess; let rootChild = null; let agcChild = null; @@ -511,9 +577,12 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { } console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri'); - agcChild = spawnProcess(npmCommand, ['run', 'agc'], env); + agcChild = spawnProcess(npmCommand, ['run', 'agc'], env, { + captureOutput: true, + }); const agcExit = waitForChildExit(agcChild); await waitForAgc(agcChild, endpoint); + await waitForTauri(agcChild); if (rootChild.exitCode !== null || rootChild.signalCode !== null) { throw new Error('根开发栈在 AGC 就绪前退出'); } @@ -565,6 +634,7 @@ export { buildStartupSummary, isSharedRootStackReady, readDevStackState, + renderStartupTable, runDevAll, validateDevAllArgs, }; diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index 894dfbe7e..bf86ff53d 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -11,6 +11,7 @@ import { buildRootDevArgs, buildStartupSummary, isSharedRootStackReady, + renderStartupTable, runDevAll, validateDevAllArgs, } from './dev-all.mjs'; @@ -91,6 +92,9 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(options.shell).toBe(process.platform === 'win32'); expect(options.windowsHide).toBe(process.platform === 'win32'); expect(options.detached).toBe(process.platform !== 'win32'); + expect( + buildManagedSpawnOptions(process.env, { captureOutput: true }).stdio, + ).toEqual(['inherit', 'pipe', 'pipe']); }); test('按最终端口构造完整启动摘要表', () => { @@ -118,6 +122,17 @@ describe('dev:all 参数和共享开发栈合同', () => { ); }); + test('渲染摘要表时不输出字符串引号', () => { + const rows = buildStartupSummary(makeReadyState(), { + url: 'http://127.0.0.1:3080/', + }); + const table = renderStartupTable(rows); + + expect(table).not.toContain("'"); + expect(table).toContain('| Web 主站'); + expect(table).toContain('http://127.0.0.1:3080/'); + }); + test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { const root = resolve('fixture-root'); const ready = makeReadyState(root);