import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { appIdentifier, configFileName, defaultRuntimeConfigDirCandidates, } from './agent-swarm-test-chat.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repositoryRoot = path.resolve(appRoot, '..', '..'); const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); const defaultConfigPath = path.join(appRoot, configFileName); const localConfigFileName = 'game-creator.config.local.json'; const gameCreatorConfigSchemaVersion = 'game-creator-config.v2'; const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const windowsPrivateAclScript = String.raw` $ErrorActionPreference = 'Stop' $target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_AGC_PRIVATE_PATH) $isDirectory = [System.Boolean]::Parse($env:GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY) $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User $inheritance = [System.Security.AccessControl.InheritanceFlags]::None if ($isDirectory) { $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit $security = [System.Security.AccessControl.DirectorySecurity]::new() } else { $security = [System.Security.AccessControl.FileSecurity]::new() } $security.SetOwner($currentSid) $security.SetAccessRuleProtection($true, $false) $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( $currentSid, [System.Security.AccessControl.FileSystemRights]::FullControl, $inheritance, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) $security.AddAccessRule($rule) | Out-Null $targetItem = Get-Item -LiteralPath $target -Force $targetItem.SetAccessControl($security) $verified = $targetItem.GetAccessControl() $owner = $verified.GetOwner([System.Security.Principal.SecurityIdentifier]) $rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) if (-not $owner.Equals($currentSid) -or -not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { throw 'Windows private DACL owner, inheritance, or ACE count verification failed' } $verifiedRule = $rules[0] if ( -not $verifiedRule.IdentityReference.Equals($currentSid) -or $verifiedRule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or [int]$verifiedRule.FileSystemRights -ne [int][System.Security.AccessControl.FileSystemRights]::FullControl -or $verifiedRule.InheritanceFlags -ne $inheritance -or $verifiedRule.PropagationFlags -ne [System.Security.AccessControl.PropagationFlags]::None -or $verifiedRule.IsInherited ) { throw 'Windows private DACL access rule verification failed' } `; export const gameCreatorProviderPresets = Object.freeze([ { id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1', apiKind: 'openai_responses', }, { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKind: 'openai_chat', }, { id: 'anthropic', label: 'Anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-3-5-sonnet-latest', apiKind: 'anthropic', }, { id: 'ark', label: '火山 Ark', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-seed-1-6', apiKind: 'openai_chat', }, { id: 'custom', label: '自定义', baseUrl: '', model: '', apiKind: 'openai_chat', }, ]); export const configWizardUsage = `用法:npm run agc:config -- [选项] 交互式配置 AI 游戏创作客户端的默认 LLM Provider。API Key 使用隐藏输入, 保存到与 GUI 相同的 AppData 配置文件,不接受 --api-key 参数。 选项: --config-dir <绝对路径> 写入以 ${appIdentifier} 命名的独立 AppData 目录 --configure-only 保存并检查配置,不继续启动真实 Swarm 测试 -h, --help 显示帮助`; export function parseConfigWizardArguments(args) { const options = { configDir: null, configureOnly: false, help: false }; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument === '--config-dir') { if (options.configDir) throw new Error('--config-dir 只能指定一次'); const value = args[index + 1]?.trim(); if (!value || value.startsWith('--')) { throw new Error('--config-dir 缺少目录路径'); } if (!path.isAbsolute(value)) throw new Error('--config-dir 必须是绝对路径'); options.configDir = path.resolve(value); index += 1; } else if (argument === '--configure-only') { options.configureOnly = true; } else if (argument === '--help' || argument === '-h') { options.help = true; } else if (argument === '--api-key' || argument.startsWith('--api-key=')) { throw new Error('API Key 不允许通过命令行参数传入,请使用隐藏输入'); } else { throw new Error(`未知选项:${argument}`); } } return options; } export function resolveGameCreatorAppConfigDir({ explicitConfigDir = null, platform = process.platform, environment = process.env, homeDirectory = os.homedir(), } = {}) { const platformPath = platform === 'win32' ? path.win32 : path.posix; if (explicitConfigDir) { if (!platformPath.isAbsolute(explicitConfigDir)) { throw new Error('配置目录必须是绝对路径'); } return platformPath.resolve(explicitConfigDir); } const [candidate] = defaultRuntimeConfigDirCandidates({ platform, environment, homeDirectory, }); if (!candidate) throw new Error('无法确定当前平台的 AppData 配置目录'); return platformPath.resolve(candidate); } export function normalizeWizardBaseUrl(value) { const input = value.trim(); if (!input) throw new Error('Base URL 不能为空'); let url; try { url = new URL(input); } catch { throw new Error('Base URL 必须是有效 URL'); } const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { throw new Error('Base URL 必须使用 HTTPS;本机 loopback 可使用 HTTP'); } if (url.username || url.password || url.search || url.hash) { throw new Error('Base URL 不能包含账号、密码、查询参数或片段'); } return input.replace(/\/+$/u, ''); } export function buildGameCreatorWizardConfig(existingConfig, llmInput) { const source = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) ? existingConfig : {}; const previousLlm = source.llm && typeof source.llm === 'object' && !Array.isArray(source.llm) ? source.llm : {}; const apiKey = llmInput.apiKey.trim(); const model = llmInput.model.trim(); if (!apiKey) throw new Error('API Key 不能为空'); if (!model) throw new Error('模型不能为空'); if ( !['openai_responses', 'openai_chat', 'anthropic'].includes(llmInput.apiKind) ) { throw new Error('API 类型无效'); } return { ...source, schemaVersion: gameCreatorConfigSchemaVersion, agentMode: 'provider', llm: { ...previousLlm, apiKey, baseUrl: normalizeWizardBaseUrl(llmInput.baseUrl), model, apiKind: llmInput.apiKind, reasoningEffort: llmInput.apiKind === 'anthropic' ? 'default' : 'high', ...(llmInput.apiKind === 'anthropic' ? { webSearchEnabled: false } : {}), }, }; } function runChildCapture(command, args, options = {}) { return new Promise((resolve, reject) => { const { timeoutMs = 10_000, ...spawnOptions } = options; const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...spawnOptions, }); let stdout = ''; let stderr = ''; let settled = false; const finish = (error, result = null) => { if (settled) return; settled = true; clearTimeout(timeout); if (error) reject(error); else resolve(result); }; const timeout = setTimeout(() => { child.kill('SIGKILL'); finish(new Error(`子命令超时:${command}`)); }, timeoutMs); timeout.unref?.(); child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); child.once('error', (error) => finish(error)); child.once('exit', (code, signal) => { finish(null, { code, signal, stdout, stderr }); }); }); } function runGitCapture(args) { return runChildCapture('git', args, { env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, }); } async function runGitCheck(runGit, args, label) { try { return await runGit(args); } catch (error) { throw new Error( `无法安全检查 ${label}:Git 命令不可用或启动失败:${error.message}`, { cause: error }, ); } } function throwUnexpectedGitResult(label, result) { const detail = result.stderr?.trim() || result.stdout?.trim(); const status = result.signal ? `signal=${result.signal}` : `code=${result.code ?? 'unknown'}`; throw new Error( `无法安全检查 ${label}:Git 检查异常(${status})${detail ? `:${detail}` : ''}`, ); } export async function secureWindowsGameCreatorPathForCurrentUser( targetPath, { isDirectory }, ) { const result = await runChildCapture( 'powershell.exe', [ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', windowsPrivateAclScript, ], { env: { ...process.env, GENARRATIVE_AGC_PRIVATE_PATH: targetPath, GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory), }, }, ); if (result.code !== 0 || result.signal) { const detail = result.stderr.trim() || result.stdout.trim(); throw new Error( `建立 Windows 当前用户私有 DACL 失败${detail ? `:${detail}` : ''}`, ); } } async function ensurePrivateConfigDirectory( configDir, { platform = process.platform, secureWindowsPath = secureWindowsGameCreatorPathForCurrentUser, } = {}, ) { await mkdir(configDir, { recursive: true, mode: 0o700 }); const metadata = await lstat(configDir); if (!metadata.isDirectory() || metadata.isSymbolicLink()) { throw new Error(`配置目录必须是无符号链接普通目录:${configDir}`); } if (platform === 'win32') { await secureWindowsPath(configDir, { isDirectory: true }); } else { await chmod(configDir, 0o700); } } async function readConfigFile(configPath, { optional = false } = {}) { const metadata = await lstat(configPath).catch((error) => { if (error?.code === 'ENOENT') return null; throw error; }); if (!metadata && optional) return null; if (metadata && (!metadata.isFile() || metadata.isSymbolicLink())) { throw new Error(`配置文件必须是无符号链接普通文件:${configPath}`); } const sourcePath = metadata ? configPath : defaultConfigPath; try { const config = JSON.parse(await readFile(sourcePath, 'utf8')); if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error('配置根节点必须是 JSON 对象'); } return config; } catch (error) { throw new Error(`读取客户端配置失败:${sourcePath}:${error.message}`); } } function mergePresentObjectProperties(base, patch) { const result = base && typeof base === 'object' && !Array.isArray(base) ? { ...base } : {}; if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return result; for (const [key, value] of Object.entries(patch)) { if (value !== null) result[key] = value; } return result; } export function mergeGameCreatorConfigLayers(baseConfig, overlayConfig) { const base = baseConfig && typeof baseConfig === 'object' && !Array.isArray(baseConfig) ? baseConfig : {}; const overlay = overlayConfig && typeof overlayConfig === 'object' && !Array.isArray(overlayConfig) ? overlayConfig : {}; const merged = { ...base }; for (const [key, value] of Object.entries(overlay)) { if (value !== null) merged[key] = value; } if (overlay.llm !== null && overlay.llm !== undefined) { merged.llm = mergePresentObjectProperties(base.llm, overlay.llm); } if (overlay.editorApi !== null && overlay.editorApi !== undefined) { merged.editorApi = mergePresentObjectProperties( base.editorApi, overlay.editorApi, ); } if (overlay.agentLlm !== null && overlay.agentLlm !== undefined) { const agentLlm = mergePresentObjectProperties(base.agentLlm, null); for (const [agentId, patch] of Object.entries(overlay.agentLlm ?? {})) { if (patch !== null) { agentLlm[agentId] = mergePresentObjectProperties( base.agentLlm?.[agentId], patch, ); } } merged.agentLlm = agentLlm; } return merged; } async function secureOptionalExistingGameCreatorConfigFile( configPath, options = {}, ) { try { await secureExistingGameCreatorConfigFile(configPath, options); return true; } catch (error) { if (error?.code === 'ENOENT') return false; throw error; } } export async function readGameCreatorWizardConfigState( configDir, options = {}, ) { const configPath = path.join(configDir, configFileName); const localConfigPath = path.join(configDir, localConfigFileName); await ensurePrivateConfigDirectory(configDir, options); await secureOptionalExistingGameCreatorConfigFile(configPath, options); await secureOptionalExistingGameCreatorConfigFile(localConfigPath, options); const primaryConfig = await readConfigFile(configPath); const localConfig = await readConfigFile(localConfigPath, { optional: true }); const effectiveConfig = mergeGameCreatorConfigLayers( primaryConfig, localConfig, ); return { configPath, localConfigPath, effectiveConfig, writeConfig: { ...primaryConfig, llm: effectiveConfig.llm, }, }; } async function resolvePathThroughExistingAncestor(targetPath) { let cursor = path.resolve(targetPath); const missingSegments = []; for (;;) { try { const canonical = await realpath(cursor); return path.join(canonical, ...missingSegments.reverse()); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; } const parent = path.dirname(cursor); if (parent === cursor) throw new Error(`无法解析配置路径:${targetPath}`); missingSegments.push(path.basename(cursor)); cursor = parent; } } function pathIsWithin(rootPath, targetPath) { const relative = path.relative(rootPath, targetPath); return ( relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ); } async function findContainingGitRoot(targetPath, runGit) { let cursor = targetPath; for (;;) { const metadata = await lstat(cursor).catch((error) => { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; }); if (metadata) break; const parent = path.dirname(cursor); if (parent === cursor) return null; cursor = parent; } const result = await runGitCheck( runGit, ['-C', cursor, 'rev-parse', '--show-toplevel'], 'Git 仓库边界', ); if ( result.code === 128 && !result.signal && /^fatal: not a git repository\b/mu.test(result.stderr) ) { return null; } if (result.code !== 0 || result.signal) { throwUnexpectedGitResult('Git 仓库边界', result); } const gitRoot = result.stdout.trim(); if (!gitRoot) throwUnexpectedGitResult('Git 仓库边界', result); return realpath(gitRoot); } async function isGitTrackedConfigPath(configPath, gitRoot, runGit) { if (!gitRoot || !pathIsWithin(gitRoot, configPath)) return false; const relativePath = path.relative(gitRoot, configPath); const result = await runGitCheck( runGit, ['-C', gitRoot, 'ls-files', '--error-unmatch', '--', relativePath], '配置文件跟踪状态', ); if (result.code === 0 && !result.signal) return true; if (result.code === 1 && !result.signal) return false; throwUnexpectedGitResult('配置文件跟踪状态', result); } export async function assertSafeGameCreatorConfigDestination( configDir, { runGit = runGitCapture, requireDedicatedLeaf = false } = {}, ) { const resolvedConfigDir = path.resolve(configDir); const canonicalConfigDir = await resolvePathThroughExistingAncestor(configDir); if ( requireDedicatedLeaf && (path.basename(resolvedConfigDir) !== appIdentifier || path.basename(canonicalConfigDir) !== appIdentifier) ) { throw new Error( `--config-dir 必须指向独立的 ${appIdentifier} AppData 目录`, ); } const canonicalConfigPath = path.join(canonicalConfigDir, configFileName); const containingGitRoot = await findContainingGitRoot( canonicalConfigDir, runGit, ); if ( await isGitTrackedConfigPath(canonicalConfigPath, containingGitRoot, runGit) ) { throw new Error( `拒绝覆盖 Git 已跟踪的客户端配置文件:${canonicalConfigPath}`, ); } const canonicalRepositoryRoot = await realpath(repositoryRoot); if ( pathIsWithin(repositoryRoot, resolvedConfigDir) || pathIsWithin(canonicalRepositoryRoot, canonicalConfigDir) || (containingGitRoot && pathIsWithin(containingGitRoot, canonicalConfigDir)) ) { throw new Error('--config-dir 必须位于 Git 仓库之外的 AppData 目录'); } return canonicalConfigDir; } export async function writeGameCreatorConfigAtomically( configPath, config, { platform = process.platform, secureWindowsPath = secureWindowsGameCreatorPathForCurrentUser, } = {}, ) { const configDir = path.dirname(configPath); await ensurePrivateConfigDirectory(configDir, { platform, secureWindowsPath, }); const existing = await lstat(configPath).catch((error) => { if (error?.code === 'ENOENT') return null; throw error; }); if (existing && (!existing.isFile() || existing.isSymbolicLink())) { throw new Error(`配置文件必须是无符号链接普通文件:${configPath}`); } const temporaryPath = path.join( configDir, `.${path.basename(configPath)}.tmp.${process.pid}.${randomUUID()}`, ); let temporaryFile = null; try { temporaryFile = await open(temporaryPath, 'wx', 0o600); if (platform === 'win32') { await secureWindowsPath(temporaryPath, { isDirectory: false }); } else { await chmod(temporaryPath, 0o600); } await temporaryFile.writeFile(`${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', }); await temporaryFile.sync(); await temporaryFile.close(); temporaryFile = null; await rename(temporaryPath, configPath); if (platform === 'win32') { await secureWindowsPath(configPath, { isDirectory: false }); } else { await chmod(configPath, 0o600); } } catch (error) { await temporaryFile?.close().catch(() => {}); await rm(temporaryPath, { force: true }).catch(() => {}); throw new Error(`保存客户端配置失败:${error.message}`); } return configPath; } async function secureExistingGameCreatorConfigFile( configPath, { platform = process.platform, secureWindowsPath = secureWindowsGameCreatorPathForCurrentUser, } = {}, ) { const metadata = await lstat(configPath); if (!metadata.isFile() || metadata.isSymbolicLink()) { throw new Error(`配置文件必须是无符号链接普通文件:${configPath}`); } if (platform === 'win32') { await secureWindowsPath(configPath, { isDirectory: false }); } else { await chmod(configPath, 0o600); } } export async function writeGameCreatorWizardConfig( configState, config, options = {}, ) { await writeGameCreatorConfigAtomically( configState.configPath, config, options, ); const localMetadata = await lstat(configState.localConfigPath).catch( (error) => { if (error?.code === 'ENOENT') return null; throw error; }, ); if (!localMetadata) return configState.configPath; await secureExistingGameCreatorConfigFile( configState.localConfigPath, options, ); const localConfig = await readConfigFile(configState.localConfigPath); if (localConfig && Object.prototype.hasOwnProperty.call(localConfig, 'llm')) { const { llm: _removedLlm, ...remainingOverlay } = localConfig; await writeGameCreatorConfigAtomically( configState.localConfigPath, remainingOverlay, options, ); } return configState.configPath; } async function askVisible( prompt, fallback = '', { input = process.stdin, output = process.stdout } = {}, ) { const readline = createInterface({ input, output, }); try { const answer = (await readline.question(prompt)).trim(); return answer || fallback; } finally { readline.close(); } } function terminationSignals(platform = process.platform) { return platform === 'win32' ? ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP']; } export async function askHidden( prompt, { input = process.stdin, output = process.stdout, signalSource = process, terminateForSignal = (signal) => process.kill(process.pid, signal), platform = process.platform, } = {}, ) { if (!input.isTTY || typeof input.setRawMode !== 'function') { return askVisible(`${prompt}(当前输入源不会回显): `, '', { input, output, }); } output.write(`${prompt}(隐藏输入): `); const previousRawMode = Boolean(input.isRaw); const wasPaused = input.isPaused(); return new Promise((resolve, reject) => { let value = ''; let settled = false; const signalHandlers = new Map(); const finish = (error = null, signal = null) => { if (settled) return; settled = true; input.off('data', onData); input.off('end', onEnd); input.off('error', onError); for (const [registeredSignal, handler] of signalHandlers) { signalSource.off(registeredSignal, handler); } const errors = error ? [error] : []; try { input.setRawMode(previousRawMode); } catch (restoreError) { errors.push( new Error(`恢复终端 raw mode 失败:${restoreError.message}`, { cause: restoreError, }), ); } if (wasPaused) { try { input.pause(); } catch (pauseError) { errors.push( new Error(`恢复终端监听状态失败:${pauseError.message}`, { cause: pauseError, }), ); } } try { output.write('\n'); } catch (outputError) { errors.push( new Error(`结束隐藏输入提示失败:${outputError.message}`, { cause: outputError, }), ); } if (signal) { try { terminateForSignal(signal); errors.push(new Error(`已恢复终端,等待 ${signal} 终止进程`)); } catch (signalError) { errors.push( new Error( `恢复终端后重新发送 ${signal} 失败:${signalError.message}`, { cause: signalError, }, ), ); } } if (errors.length === 1) reject(errors[0]); else if (errors.length > 1) { reject( new AggregateError( errors, errors.map((currentError) => currentError.message).join(';'), ), ); } else resolve(value.trim()); }; const onData = (chunk) => { for (const character of chunk.toString('utf8')) { if (character === '\u0003') { finish(new Error('已取消配置')); return; } if (character === '\r' || character === '\n') { finish(); return; } if (character === '\u007f' || character === '\b') { value = [...value].slice(0, -1).join(''); } else if (character >= ' ') { value += character; } } }; const onEnd = () => finish(new Error('隐藏输入在完成前已结束')); const onError = (error) => finish(new Error(`隐藏输入读取失败:${error.message}`, { cause: error })); for (const signal of terminationSignals(platform)) { const handler = () => finish(new Error(`隐藏输入收到 ${signal}`), signal); signalHandlers.set(signal, handler); signalSource.once(signal, handler); } input.on('data', onData); input.once('end', onEnd); input.once('error', onError); try { input.setRawMode(true); input.resume(); } catch (error) { finish(new Error(`启用隐藏输入失败:${error.message}`, { cause: error })); } }); } async function askChoice(title, choices, fallbackIndex = 0) { console.log(title); choices.forEach((choice, index) => console.log(` ${index + 1}. ${choice}`)); for (;;) { const answer = await askVisible(`请选择 [${fallbackIndex + 1}]: `); if (!answer) return fallbackIndex; const index = Number(answer) - 1; if (Number.isInteger(index) && index >= 0 && index < choices.length) return index; console.log('请输入列表中的数字。'); } } async function askRequired(prompt, fallback = '') { for (;;) { const value = await askVisible(prompt, fallback); if (value.trim()) return value.trim(); console.log('该项不能为空。'); } } async function askYesNo(prompt, fallback = true) { const answer = ( await askVisible(`${prompt} ${fallback ? '[Y/n]' : '[y/N]'}: `) ).toLowerCase(); if (!answer) return fallback; return ['y', 'yes', '是'].includes(answer); } async function selectLlmInput() { const presetIndex = await askChoice( '选择 LLM Provider:', gameCreatorProviderPresets.map((preset) => preset.label), ); const preset = gameCreatorProviderPresets[presetIndex]; let apiKind = preset.apiKind; if (preset.id === 'custom') { const apiKindIndex = await askChoice( '选择 API 协议:', ['OpenAI Responses', 'OpenAI Chat Completions', 'Anthropic Messages'], 1, ); apiKind = ['openai_responses', 'openai_chat', 'anthropic'][apiKindIndex]; } const baseUrl = await askRequired( `Base URL${preset.baseUrl ? ` [${preset.baseUrl}]` : ''}: `, preset.baseUrl, ); const model = await askRequired( `模型${preset.model ? ` [${preset.model}]` : ''}: `, preset.model, ); let apiKey = ''; while (!apiKey) { apiKey = await askHidden('API Key'); if (!apiKey) console.log('API Key 不能为空。'); } return { apiKey, baseUrl, model, apiKind }; } function delay(milliseconds) { return new Promise((resolve) => { const timer = setTimeout(resolve, milliseconds); timer.unref?.(); }); } function posixProcessGroupExists(processGroupId) { try { process.kill(-processGroupId, 0); return true; } catch (error) { if (error?.code === 'ESRCH') return false; if (error?.code === 'EPERM') return true; throw error; } } async function waitForPosixProcessGroupExit(processGroupId, timeoutMs) { const deadline = Date.now() + timeoutMs; while (posixProcessGroupExists(processGroupId)) { if (Date.now() >= deadline) return false; await delay(50); } return true; } async function terminateChildProcessTree(child, exitPromise, hasExited) { if (!child.pid) return; if (process.platform === 'win32') { const result = await runChildCapture( 'taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { timeoutMs: 5_000 }, ); if (result.code !== 0 || result.signal) { const detail = result.stderr.trim() || result.stdout.trim(); throw new Error( `taskkill 未能收束子进程树${detail ? `:${detail}` : ''}`, ); } await Promise.race([exitPromise, delay(2_000)]); if (!hasExited()) { throw new Error(`Windows 子进程树根进程未在时限内退出:pid=${child.pid}`); } return; } try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } if (await waitForPosixProcessGroupExit(child.pid, 3_000)) return; try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } if (!(await waitForPosixProcessGroupExit(child.pid, 2_000))) { throw new Error(`子进程树未在时限内退出:pid=${child.pid}`); } } async function runChild(command, args, options = {}) { const child = spawn(command, args, { stdio: 'inherit', detached: process.platform !== 'win32', ...options, }); let exited = false; const exitPromise = new Promise((resolve) => { child.once('error', (error) => { exited = true; resolve({ kind: 'error', error }); }); child.once('exit', (code, signal) => { exited = true; resolve({ kind: 'exit', code, signal }); }); }); const signalHandlers = new Map(); const signalPromise = new Promise((resolve) => { for (const signal of terminationSignals()) { const handler = () => resolve({ kind: 'signal', signal }); signalHandlers.set(signal, handler); process.once(signal, handler); } }); try { const outcome = await Promise.race([exitPromise, signalPromise]); if (outcome.kind === 'signal') { await terminateChildProcessTree(child, exitPromise, () => exited); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } process.kill(process.pid, outcome.signal); throw new Error(`已收束子进程树,等待 ${outcome.signal} 终止进程`); } if (outcome.kind === 'error') throw outcome.error; if (outcome.code !== 0 || outcome.signal) { throw new Error( `子命令失败:code=${outcome.code ?? ''} signal=${outcome.signal ?? ''}`, ); } } finally { for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } } } async function verifySavedConfig(configDir) { await runChild( cargoCommand, [ 'run', '--manifest-path', cargoManifestPath, '--', '--config-dir', configDir, '--llm-status', ], { cwd: appRoot }, ); } export async function runConfigWizard(options) { if (!process.stdin.isTTY && !options.configureOnly) { throw new Error('交互式配置需要终端 TTY'); } const configDir = resolveGameCreatorAppConfigDir({ explicitConfigDir: options.configDir, }); const safeConfigDir = await assertSafeGameCreatorConfigDestination( configDir, { requireDedicatedLeaf: true, }, ); const configState = await readGameCreatorWizardConfigState(safeConfigDir); const llmInput = await selectLlmInput(); const config = buildGameCreatorWizardConfig( configState.writeConfig, llmInput, ); await writeGameCreatorWizardConfig(configState, config); console.log(`配置已保存:${configState.configPath}`); console.log('正在检查 LLM 配置...'); await verifySavedConfig(safeConfigDir); console.log('LLM 配置检查通过。'); if (!options.configureOnly && (await askYesNo('立即启动真实 Swarm 测试?'))) { await runChild(npmCommand, ['run', 'test:chat', '--'], { cwd: appRoot }); } return configState.configPath; } async function main() { const options = parseConfigWizardArguments(process.argv.slice(2)); if (options.help) { console.log(configWizardUsage); return; } await runConfigWizard(options); } const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''; if (entryPath === import.meta.url) { main().catch((error) => { console.error(`agc:config 失败:${error.message}`); process.exitCode = 1; }); }