Files
Genarrative/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs
T
kdletters 6fe646ba2b
Project CI / Frontend tests (push) Failing after 21s
Project CI / Repository checks (push) Successful in 58s
Project CI / Native shell tests (push) Failing after 3m7s
Project CI / Backend tests (push) Successful in 3m33s
修复开放Issue 115、118、127和128
补齐Anthropic strict能力门控、传输schema、缓存usage和真实端点验收
隔离可选MCP服务目录失败并安全重定位局部schema引用
修复PowerShell 7环境下Windows私有ACL检查
统一角色与图标素材落库的用户prompt语义
合并权威画布快照与本地待保存或在途布局并补齐回归
同步更新技术文档、项目记忆和验证门禁
2026-08-03 14:03:20 +08:00

1048 lines
32 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 { 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 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,
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;
}
if (overlay.mcpServers !== null && overlay.mcpServers !== undefined) {
merged.mcpServers = mergePresentObjectProperties(
base.mcpServers,
overlay.mcpServers,
);
}
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;
});
}