6fe646ba2b
补齐Anthropic strict能力门控、传输schema、缓存usage和真实端点验收 隔离可选MCP服务目录失败并安全重定位局部schema引用 修复PowerShell 7环境下Windows私有ACL检查 统一角色与图标素材落库的用户prompt语义 合并权威画布快照与本地待保存或在途布局并补齐回归 同步更新技术文档、项目记忆和验证门禁
1048 lines
32 KiB
JavaScript
1048 lines
32 KiB
JavaScript
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;
|
||
});
|
||
}
|