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
默认仍写死 --preserve-database(不改默认行为);显式 --clear-database 时不再传它、逐字透传 dev.mjs 既有语义;根栈因 manual migration / Reordering table 失败时补一段可操作提示(打印库名与 data dir、两条出路、清库会丢数据的警告),提示文案抽成纯函数并补单测;文档补一句
682 lines
20 KiB
JavaScript
682 lines
20 KiB
JavaScript
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();
|
||
}
|