Files
ONE-OS/axhub-make/vite-plugins/configApiPlugin.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules,
and temp folders to ensure clean and lightweight version tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:12:25 +08:00

1867 lines
60 KiB
TypeScript
Raw Permalink 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 type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { exec, execFile, spawn, spawnSync } from 'node:child_process';
import {
commandExists,
decodeOutput,
getPreferredNpmCommand,
getSpawnCommandSpec,
runCommand,
runCommandSync,
} from '../scripts/utils/command-runtime.mjs';
type ProjectDefaults = {
defaultTheme?: string | null;
};
type ProjectInfo = {
name?: string | null;
description?: string | null;
};
type LegacyPromptClient = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
type GeniePromptClient = 'genie:claude' | 'genie:cursor' | 'genie:codex' | 'genie:gemini' | 'genie:opencode';
type LocalPromptClient = 'local:cursor' | 'local:qoder';
type PromptClient = GeniePromptClient | LocalPromptClient;
type MainIDE = 'cursor' | 'trae' | 'vscode' | 'trae_cn' | 'windsurf' | 'kiro' | 'qoder' | 'antigravity';
const MAIN_IDE_VALUES: MainIDE[] = ['cursor', 'trae', 'vscode', 'trae_cn', 'windsurf', 'kiro', 'qoder', 'antigravity'];
const MAIN_IDE_APP_NAMES: Record<MainIDE, string> = {
cursor: 'Cursor',
trae: 'Trae',
vscode: 'Visual Studio Code',
trae_cn: 'Trae CN',
windsurf: 'Windsurf',
kiro: 'Kiro',
qoder: 'Qoder',
antigravity: 'Antigravity',
};
const MAIN_IDE_WINDOWS_APP_PATH_NAMES: Record<MainIDE, string[]> = {
cursor: ['Cursor'],
trae: ['Trae', 'TRAE'],
vscode: ['Visual Studio Code', 'Visual Studio Code Insiders'],
trae_cn: ['Trae CN', 'TRAE CN', 'Trae', 'TRAE'],
windsurf: ['Windsurf'],
kiro: ['Kiro'],
qoder: ['Qoder'],
antigravity: ['Antigravity'],
};
const MAIN_IDE_WINDOWS_COMMAND_CANDIDATES: Record<MainIDE, string[]> = {
cursor: ['cursor'],
trae: ['trae'],
vscode: ['code', 'code-insiders'],
trae_cn: ['trae-cn', 'trae_cn', 'trae'],
windsurf: ['windsurf'],
kiro: ['kiro'],
qoder: ['qoder'],
antigravity: ['antigravity'],
};
const MAIN_IDE_WINDOWS_EXECUTABLE_NAMES: Record<MainIDE, string[]> = {
cursor: ['Cursor.exe'],
trae: ['TRAE.exe', 'Trae.exe'],
vscode: ['Code.exe', 'Code - Insiders.exe'],
trae_cn: ['Trae CN.exe', 'TRAE CN.exe', 'TRAE.exe', 'Trae.exe'],
windsurf: ['Windsurf.exe'],
kiro: ['Kiro.exe'],
qoder: ['Qoder.exe'],
antigravity: ['Antigravity.exe'],
};
type AutomationConfig = {
defaultPromptClient?: PromptClient | null;
defaultIDE?: MainIDE | null;
};
type AssistantConfig = {
webBaseUrl?: string | null;
apiBaseUrl?: string | null;
};
type AssistantRuntimeSource = 'axhub-genie' | 'config' | 'cloudcli' | 'env' | 'default';
type AssistantHealthStatus =
| 'ready'
| 'missing_cli'
| 'cli_error'
| 'runtime_unreachable'
| 'needs_update';
type AssistantCommandSource = 'axhub-genie' | 'cloudcli' | 'default';
type AssistantHealthHints = {
installGlobal: string;
start: string;
status: string;
};
type AssistantHealthInfo = {
status: AssistantHealthStatus;
message: string;
checkedAt: string;
commandSource: AssistantCommandSource;
hints: AssistantHealthHints;
};
type AssistantRuntimeInfo = {
webBaseUrl: string;
apiBaseUrl: string;
projectPath: string;
source: AssistantRuntimeSource;
health: AssistantHealthInfo;
};
type AssistantRuntimeResolveOptions = {
autoStart?: boolean;
};
type AssistantBootstrapMode = 'install_global' | 'start_existing';
type AssistantProbeStatus = 'ready' | 'missing_cli' | 'needs_update' | 'cli_error' | 'not_running';
type AssistantProbeResult = {
status: AssistantProbeStatus;
message: string;
commandSource: Exclude<AssistantCommandSource, 'default'>;
config: AssistantConfig | null;
};
const ASSISTANT_START_CHECK_DELAY_MS = 500;
type SystemConfig = {
server: Record<string, any>;
projectDefaults?: ProjectDefaults;
projectInfo?: ProjectInfo;
automation?: AutomationConfig;
assistant?: AssistantConfig;
};
type AgentDocsPaths = {
configPath: string;
agentsTemplatePath: string;
agentsPath: string;
claudePath: string;
};
const DEFAULT_ASSISTANT_WEB_BASE_URL = 'http://localhost:32123';
const DEFAULT_ASSISTANT_API_BASE_URL = 'http://localhost:32123/api';
const DEFAULT_ASSISTANT_HEALTH_URL = `${DEFAULT_ASSISTANT_WEB_BASE_URL}/health`;
const ASSISTANT_SERVICE_ID = '@axhub/genie';
const ASSISTANT_SERVICE_NAME = 'Axhub Genie';
const ASSISTANT_RUNTIME_LOG_PREFIX = '[assistant-runtime]';
const ASSISTANT_RUNTIME_DEBUG_LOGS_ENABLED = false;
const ASSISTANT_STATUS_TIMEOUT_MS = 8_000;
const COMMAND_AVAILABILITY_TIMEOUT_MS = 2_000;
const PROJECT_OVERVIEW_DOC_RELATIVE_PATH = 'src/docs/project-overview.md';
function logAssistantRuntime(level: 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>) {
if (!ASSISTANT_RUNTIME_DEBUG_LOGS_ENABLED && level !== 'error') {
return;
}
const payload = meta ? `${ASSISTANT_RUNTIME_LOG_PREFIX} ${message}` : `${ASSISTANT_RUNTIME_LOG_PREFIX} ${message}`;
if (level === 'error') {
if (meta) {
console.error(payload, meta);
return;
}
console.error(payload);
return;
}
if (level === 'warn') {
if (meta) {
console.warn(payload, meta);
return;
}
console.warn(payload);
return;
}
if (meta) {
console.info(payload, meta);
return;
}
console.info(payload);
}
function getAssistantHealthHints(): AssistantHealthHints {
return {
installGlobal: 'npx @axhub/genie',
start: 'axhub-genie',
status: 'axhub-genie status',
};
}
function normalizeOptionalString(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function normalizeInlineText(value: unknown): string | null {
const normalized = normalizeOptionalString(value);
if (!normalized) return null;
return normalized.replace(/\r?\n+/g, ' ').trim() || null;
}
function normalizeProjectDefaults(value: unknown): ProjectDefaults {
if (!value || typeof value !== 'object') {
return { defaultTheme: null };
}
const defaults = value as ProjectDefaults;
return {
defaultTheme: normalizeOptionalString(defaults.defaultTheme)
};
}
function normalizeProjectInfo(value: unknown): ProjectInfo {
if (!value || typeof value !== 'object') {
return { name: null, description: null };
}
const info = value as ProjectInfo;
return {
name: normalizeInlineText(info.name),
description: normalizeInlineText(info.description)
};
}
function normalizePromptClient(value: unknown): PromptClient | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (
normalized === 'genie:claude'
|| normalized === 'genie:cursor'
|| normalized === 'genie:codex'
|| normalized === 'genie:gemini'
|| normalized === 'genie:opencode'
) return normalized;
if (normalized === 'local:cursor' || normalized === 'local:qoder') return normalized;
if (normalized === 'claude') return 'genie:claude';
if (normalized === 'cursor') return 'genie:cursor';
if (normalized === 'codex') return 'genie:codex';
if (normalized === 'gemini') return 'genie:gemini';
if (normalized === 'opencode') return 'genie:opencode';
return null;
}
function normalizeExecutePromptClient(value: unknown): LegacyPromptClient | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (
normalized === 'claude'
|| normalized === 'cursor'
|| normalized === 'codex'
|| normalized === 'gemini'
|| normalized === 'opencode'
) {
return normalized;
}
return null;
}
function normalizeMainIDE(value: unknown): MainIDE | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase() as MainIDE;
return MAIN_IDE_VALUES.includes(normalized) ? normalized : null;
}
function normalizeAutomationConfig(value: unknown): AutomationConfig {
if (!value || typeof value !== 'object') {
return { defaultPromptClient: null, defaultIDE: null };
}
const config = value as AutomationConfig;
return {
defaultPromptClient: normalizePromptClient(config.defaultPromptClient),
defaultIDE: normalizeMainIDE(config.defaultIDE),
};
}
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/g, '');
}
function normalizeBaseUrl(value: unknown): string | null {
const normalized = normalizeOptionalString(value);
if (!normalized) return null;
return trimTrailingSlashes(normalized);
}
function normalizeAssistantConfig(value: unknown): AssistantConfig {
if (!value || typeof value !== 'object') {
return { webBaseUrl: null, apiBaseUrl: null };
}
const config = value as AssistantConfig;
return {
webBaseUrl: normalizeBaseUrl(config.webBaseUrl),
apiBaseUrl: normalizeBaseUrl(config.apiBaseUrl),
};
}
function readSystemConfig(configPath: string): SystemConfig {
let config: SystemConfig = { server: { host: 'localhost', allowLAN: true } };
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
config = JSON.parse(fileContent);
}
if (!config.server || typeof config.server !== 'object') {
config.server = { host: 'localhost', allowLAN: true };
}
if (config.server.allowLAN === undefined) {
config.server.allowLAN = true;
}
config.projectDefaults = normalizeProjectDefaults(config.projectDefaults);
config.projectInfo = normalizeProjectInfo(config.projectInfo);
config.automation = normalizeAutomationConfig(config.automation);
config.assistant = normalizeAssistantConfig(config.assistant);
return config;
}
function extractAssistantConfigFromStatusPayload(parsed: any): AssistantConfig | null {
const endpoint = parsed?.endpoint ?? parsed?.assistant?.endpoint ?? parsed?.runtime?.endpoint ?? {};
const webBaseUrl = normalizeBaseUrl(
endpoint?.frontendUrl
?? endpoint?.webBaseUrl
?? endpoint?.webUrl
?? parsed?.frontendUrl
?? parsed?.webBaseUrl
?? parsed?.webUrl
);
const apiBaseUrl = normalizeBaseUrl(
endpoint?.apiBaseUrl
?? endpoint?.apiUrl
?? parsed?.apiBaseUrl
?? parsed?.apiUrl
);
if (!webBaseUrl && !apiBaseUrl) {
return null;
}
return {
webBaseUrl,
apiBaseUrl,
};
}
function containsNeedsUpdateHint(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
return /(need\s*update|needs\s*update|outdated|upgrade|please\s*update|版本过旧|需要更新|请更新)/i.test(normalized);
}
function containsNotRunningHint(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
return /(not\s*running|service\s*not\s*running|not\s*started|未启动|尚未启动|未运行|服务未运行)/i.test(normalized);
}
function containsMissingCommandHint(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
return /(not\s+recognized\s+as\s+an?\s+internal|command\s+not\s+found|no\s+such\s+file|未找到|不是内部或外部命令)/i.test(normalized);
}
function readAssistantStatusFromCli(command: 'axhub-genie' | 'cloudcli', args: string[]): AssistantProbeResult {
const commandSource: Exclude<AssistantCommandSource, 'default'> = command === 'axhub-genie' ? 'axhub-genie' : 'cloudcli';
try {
if (!commandExists(command)) {
return {
status: 'missing_cli',
message: `未检测到 ${command} 命令`,
commandSource,
config: null,
};
}
const result = runCommandSync({
command,
args,
timeoutMs: ASSISTANT_STATUS_TIMEOUT_MS,
});
if (result.error) {
const error = result.error as NodeJS.ErrnoException;
const errCode = error.code;
if (errCode === 'ENOENT') {
return {
status: 'missing_cli',
message: `未检测到 ${command} 命令`,
commandSource,
config: null,
};
}
if (errCode === 'ETIMEDOUT' || /timed?\s*out/i.test(error.message || '')) {
return {
status: 'not_running',
message: `${command} status 执行超时(>${ASSISTANT_STATUS_TIMEOUT_MS}ms请确认服务已启动后重试`,
commandSource,
config: null,
};
}
return {
status: 'cli_error',
message: `${command} 执行失败: ${error.message || 'unknown error'}`,
commandSource,
config: null,
};
}
const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : '';
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
const mergedOutput = [stdout, stderr].filter(Boolean).join('\n');
if (containsMissingCommandHint(mergedOutput)) {
return {
status: 'missing_cli',
message: `未检测到 ${command} 命令`,
commandSource,
config: null,
};
}
if (result.status !== 0) {
if (command === 'axhub-genie' && containsNeedsUpdateHint(mergedOutput)) {
return {
status: 'needs_update',
message: `检测到 ${command} 版本可能过旧,请更新后重试`,
commandSource,
config: null,
};
}
if (command === 'axhub-genie' && containsNotRunningHint(mergedOutput)) {
return {
status: 'not_running',
message: `${command} 服务未启动`,
commandSource,
config: null,
};
}
return {
status: 'cli_error',
message: `${command} status 执行失败${mergedOutput ? `: ${mergedOutput}` : ''}`,
commandSource,
config: null,
};
}
if (!stdout) {
return {
status: 'cli_error',
message: `${command} status 未返回有效输出`,
commandSource,
config: null,
};
}
let parsed: any = null;
try {
parsed = JSON.parse(stdout);
} catch {
return {
status: 'cli_error',
message: `${command} status 返回内容无法解析为 JSON`,
commandSource,
config: null,
};
}
const config = extractAssistantConfigFromStatusPayload(parsed);
const running = typeof parsed?.running === 'boolean' ? parsed.running : null;
if (running === false) {
return {
status: 'not_running',
message: `${command} 服务未启动`,
commandSource,
config,
};
}
if (!config) {
return {
status: 'cli_error',
message: `${command} status 返回中未发现可用地址`,
commandSource,
config: null,
};
}
return {
status: 'ready',
message: `${command} 已就绪`,
commandSource,
config,
};
} catch (error: any) {
return {
status: 'cli_error',
message: `${command} status 检查失败: ${error?.message || 'unknown error'}`,
commandSource,
config: null,
};
}
}
function readAxhubGenieStatus(): AssistantProbeResult {
const probe = readAssistantStatusFromCli('axhub-genie', ['status', '--json']);
const level = probe.status === 'ready' ? 'info' : 'warn';
logAssistantRuntime(level, 'axhub-genie status --json', {
status: probe.status,
message: probe.message,
config: probe.config || null,
});
return probe;
}
function readCloudCliAssistantStatus(): AssistantProbeResult {
return readAssistantStatusFromCli('cloudcli', ['status', '--json']);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function runExecutableCommandInBackground(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const spawnSpec = getSpawnCommandSpec(command, args, process.platform);
logAssistantRuntime('info', '后台执行可执行命令', {
command,
args,
spawnCommand: spawnSpec.command,
spawnArgs: spawnSpec.args,
cwd,
platform: process.platform,
});
const child = spawn(spawnSpec.command, spawnSpec.args, {
cwd,
detached: true,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: spawnSpec.windowsHide,
shell: false,
});
if (typeof (child as any)?.once !== 'function') {
if (typeof child.unref === 'function') {
child.unref();
}
resolve();
return;
}
let settled = false;
let stderrText = '';
if (child.stderr && typeof (child.stderr as any).on === 'function') {
(child.stderr as any).on('data', (chunk: Buffer | string) => {
const text = typeof chunk === 'string' ? chunk : decodeOutput(chunk);
if (!text) return;
if (stderrText.length >= 4000) return;
stderrText += text;
});
}
const finish = (error?: Error) => {
if (settled) return;
settled = true;
if (typeof child.unref === 'function') {
child.unref();
}
if (error) {
reject(error);
return;
}
resolve();
};
child.once('error', (error) => {
logAssistantRuntime('error', '可执行命令触发失败', {
command,
args,
cwd,
error: (error as Error)?.message || 'unknown error',
});
finish(error as Error);
});
child.once('spawn', () => {
logAssistantRuntime('info', '可执行命令已触发', {
command,
args,
cwd,
pid: typeof child.pid === 'number' ? child.pid : null,
});
if ((child.stderr as any)?.unref && typeof (child.stderr as any).unref === 'function') {
(child.stderr as any).unref();
}
setTimeout(() => finish(), 150);
});
child.once('exit', (code, signal) => {
const stderrSnippet = stderrText.trim().slice(0, 500);
const exitMeta = {
command,
args,
cwd,
code,
signal,
stderr: stderrSnippet || null,
};
if (typeof code === 'number' && code !== 0) {
logAssistantRuntime('error', '可执行命令异常退出', exitMeta);
finish(new Error(`命令退出 code=${code}${stderrSnippet ? ` stderr=${stderrSnippet}` : ''}`));
return;
}
logAssistantRuntime('warn', '可执行命令提前退出', exitMeta);
finish();
});
setTimeout(() => finish(), 500);
});
}
async function startAxhubGenieAndWait(projectPath: string): Promise<AssistantProbeResult> {
const startCommand = getAssistantHealthHints().start;
logAssistantRuntime('info', '准备自动启动 Axhub Genie', {
command: startCommand,
cwd: projectPath,
});
try {
await runExecutableCommandInBackground(startCommand, [], projectPath);
} catch (error: any) {
logAssistantRuntime('error', '启动命令触发失败', {
command: startCommand,
cwd: projectPath,
error: error?.message || 'unknown error',
});
return {
status: 'cli_error',
message: `自动启动 Axhub Genie 失败: ${error?.message || 'unknown error'}`,
commandSource: 'axhub-genie',
config: null,
};
}
await sleep(ASSISTANT_START_CHECK_DELAY_MS);
const probe = readAxhubGenieStatus();
if (probe.status === 'ready') {
logAssistantRuntime('info', 'Axhub Genie 自动启动成功', {
config: probe.config || null,
});
return {
...probe,
message: 'Axhub Genie 已自动启动并就绪',
};
}
if (probe.status === 'missing_cli' || probe.status === 'needs_update') {
logAssistantRuntime('warn', '自动启动提前结束', {
reason: probe.status,
message: probe.message,
});
return probe;
}
if (probe.status === 'cli_error') {
logAssistantRuntime('error', '自动启动失败cli_error', {
message: probe.message,
});
return {
...probe,
status: 'not_running',
message: `${probe.message}。Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试`,
};
}
logAssistantRuntime('error', '自动启动失败(启动后单次检查未就绪)', {
status: probe.status,
message: probe.message,
config: probe.config || null,
});
return {
...probe,
status: 'not_running',
message: 'Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试',
};
}
function resolveRuntimeEndpoints(params: {
statusConfig: AssistantConfig | null;
configAssistant: AssistantConfig;
envAssistant: AssistantConfig;
}): { webBaseUrl: string; apiBaseUrl: string; source: AssistantRuntimeSource } {
const candidates: Array<{ source: AssistantRuntimeSource; value: AssistantConfig | null }> = [
{ source: 'axhub-genie', value: params.statusConfig },
{ source: 'config', value: params.configAssistant },
{ source: 'env', value: params.envAssistant },
{
source: 'default',
value: {
webBaseUrl: DEFAULT_ASSISTANT_WEB_BASE_URL,
apiBaseUrl: DEFAULT_ASSISTANT_API_BASE_URL,
},
},
];
let webBaseUrl: string | null = null;
let apiBaseUrl: string | null = null;
let source: AssistantRuntimeSource = 'default';
for (const candidate of candidates) {
const value = candidate.value;
if (!value) continue;
if (!webBaseUrl && value.webBaseUrl) {
webBaseUrl = value.webBaseUrl;
if (source === 'default') {
source = candidate.source;
}
}
if (!apiBaseUrl && value.apiBaseUrl) {
apiBaseUrl = value.apiBaseUrl;
if (source === 'default') {
source = candidate.source;
}
}
if (webBaseUrl && apiBaseUrl) {
break;
}
}
return {
webBaseUrl: normalizeBaseUrl(webBaseUrl) || DEFAULT_ASSISTANT_WEB_BASE_URL,
apiBaseUrl: apiBaseUrl || DEFAULT_ASSISTANT_API_BASE_URL,
source,
};
}
function getAssistantBootstrapHints() {
return getAssistantHealthHints();
}
function createAssistantHealthInfo(params: {
status: AssistantHealthStatus;
message: string;
commandSource: AssistantCommandSource;
}): AssistantHealthInfo {
return {
status: params.status,
message: params.message,
checkedAt: new Date().toISOString(),
commandSource: params.commandSource,
hints: getAssistantHealthHints(),
};
}
function normalizeAssistantBootstrapMode(value: unknown): AssistantBootstrapMode | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (normalized === 'install_global' || normalized === 'start_existing') {
return normalized;
}
return null;
}
function getPreferredNpmCommandForBootstrap(): string {
return getPreferredNpmCommand();
}
function buildAssistantBootstrapCommand(mode: AssistantBootstrapMode): string {
const hints = getAssistantHealthHints();
if (mode === 'install_global') {
const npmCommand = getPreferredNpmCommandForBootstrap();
return `${npmCommand} install -g @axhub/genie && ${hints.start}`;
}
return hints.start;
}
async function runAssistantBootstrap(mode: AssistantBootstrapMode, cwd: string): Promise<void> {
const startCommand = getAssistantHealthHints().start;
if (mode === 'install_global') {
const npmCommand = getPreferredNpmCommandForBootstrap();
const installResult = await runCommand({
command: npmCommand,
args: ['install', '-g', '@axhub/genie'],
cwd,
capture: true,
timeoutMs: 180_000,
});
if (installResult.code !== 0) {
throw new Error(installResult.stderr || installResult.stdout || 'npm install -g @axhub/genie failed');
}
}
await runExecutableCommandInBackground(startCommand, [], cwd);
}
function isCommandAvailable(command: string, args: string[] = ['--version']): boolean {
try {
if (!commandExists(command)) {
return false;
}
const execution = runCommandSync({
command,
args,
timeoutMs: COMMAND_AVAILABILITY_TIMEOUT_MS,
});
return execution.status === 0;
} catch {
return false;
}
}
function validateBootstrapPrerequisites(mode: AssistantBootstrapMode): string | null {
if (mode === 'install_global') {
if (!isCommandAvailable('npm')) {
return 'npm 未安装,无法自动安装 Axhub Genie';
}
return null;
}
if (!isCommandAvailable('axhub-genie')) {
return '未检测到 axhub-genie 命令,请先安装后重试';
}
return null;
}
async function verifyAssistantHealthEndpoint(): Promise<{ ok: boolean; message: string }> {
const healthUrl = DEFAULT_ASSISTANT_HEALTH_URL;
try {
const response = await fetch(healthUrl, { method: 'GET' });
if (!response.ok) {
return { ok: false, message: `/health 探测失败: status ${response.status}` };
}
const appIdentifier = response.headers.get('X-App-Identifier') || response.headers.get('x-app-identifier') || '';
let payload: any = null;
try {
payload = await response.json();
} catch {
return { ok: false, message: '/health 响应不是有效 JSON' };
}
const serviceId = payload?.service?.id || '';
const serviceName = payload?.service?.name || '';
const idMatched = serviceId === ASSISTANT_SERVICE_ID || appIdentifier === ASSISTANT_SERVICE_ID;
const nameMatched = typeof serviceName === 'string' && serviceName.toLowerCase().includes(ASSISTANT_SERVICE_NAME.toLowerCase());
if (!idMatched && !nameMatched) {
return { ok: false, message: '健康检查服务身份不匹配(非 Axhub Genie' };
}
if (payload?.status !== 'ok') {
return { ok: false, message: `健康检查状态异常: ${String(payload?.status || 'unknown')}` };
}
return { ok: true, message: 'Axhub Genie 健康检查通过' };
} catch (error: any) {
return { ok: false, message: `健康检查请求失败: ${error?.message || 'unknown error'}` };
}
}
async function resolveAssistantRuntime(
config: SystemConfig,
projectPath: string,
options?: AssistantRuntimeResolveOptions,
): Promise<AssistantRuntimeInfo> {
const shouldAutoStart = options?.autoStart !== false;
logAssistantRuntime('info', '开始解析助手运行时', { projectPath, autoStart: shouldAutoStart });
const healthProbe = await verifyAssistantHealthEndpoint();
logAssistantRuntime(healthProbe.ok ? 'info' : 'warn', '固定地址健康检查结果', {
healthUrl: DEFAULT_ASSISTANT_HEALTH_URL,
ok: healthProbe.ok,
message: healthProbe.message,
});
if (healthProbe.ok) {
return {
webBaseUrl: DEFAULT_ASSISTANT_WEB_BASE_URL,
apiBaseUrl: DEFAULT_ASSISTANT_API_BASE_URL,
projectPath,
source: 'default',
health: createAssistantHealthInfo({
status: 'ready',
message: healthProbe.message,
commandSource: 'default',
}),
};
}
const configAssistant = normalizeAssistantConfig(config.assistant);
const envAssistant: AssistantConfig = {
webBaseUrl: normalizeBaseUrl(process.env.AXHUB_ASSISTANT_WEB_BASE_URL),
apiBaseUrl: normalizeBaseUrl(process.env.AXHUB_ASSISTANT_API_BASE_URL),
};
let axhubGenieStatus = readAxhubGenieStatus();
if (axhubGenieStatus.status === 'not_running' && shouldAutoStart) {
axhubGenieStatus = await startAxhubGenieAndWait(projectPath);
} else if (axhubGenieStatus.status === 'not_running' && !shouldAutoStart) {
logAssistantRuntime('info', '检测到未启动,但本次请求禁用自动启动', {
projectPath,
});
}
const resolvedEndpoints = resolveRuntimeEndpoints({
statusConfig: axhubGenieStatus.status === 'ready' ? axhubGenieStatus.config : null,
configAssistant,
envAssistant,
});
logAssistantRuntime('info', '地址解析结果', {
source: resolvedEndpoints.source,
webBaseUrl: resolvedEndpoints.webBaseUrl,
apiBaseUrl: resolvedEndpoints.apiBaseUrl,
status: axhubGenieStatus.status,
});
let healthStatus: AssistantHealthStatus = 'runtime_unreachable';
let healthMessage = '未找到可用的助手地址,请确认 Axhub Genie 已启动';
const commandSource: AssistantCommandSource = 'axhub-genie';
if (axhubGenieStatus.status === 'ready') {
healthStatus = 'ready';
healthMessage = `已通过 axhub-genie status --json 获取服务地址(默认 /health 探测失败:${healthProbe.message}`;
} else if (axhubGenieStatus.status === 'missing_cli') {
healthStatus = 'missing_cli';
healthMessage = '未检测到 axhub-genie 命令,请先安装后重试';
} else if (axhubGenieStatus.status === 'needs_update') {
healthStatus = 'needs_update';
healthMessage = axhubGenieStatus.message;
} else if (axhubGenieStatus.status === 'not_running') {
healthStatus = 'runtime_unreachable';
healthMessage = 'Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试';
} else if (axhubGenieStatus.status === 'cli_error') {
healthStatus = 'cli_error';
healthMessage = axhubGenieStatus.message;
}
const runtime = {
webBaseUrl: resolvedEndpoints.webBaseUrl,
apiBaseUrl: resolvedEndpoints.apiBaseUrl,
projectPath,
source: resolvedEndpoints.source,
health: createAssistantHealthInfo({
status: healthStatus,
message: healthMessage,
commandSource,
}),
};
logAssistantRuntime(healthStatus === 'ready' ? 'info' : 'warn', '运行时判定完成', {
healthStatus,
healthMessage,
commandSource,
source: runtime.source,
webBaseUrl: runtime.webBaseUrl,
apiBaseUrl: runtime.apiBaseUrl,
});
return runtime;
}
export const __assistantRuntimeTestUtils = {
extractAssistantConfigFromStatusPayload,
containsNeedsUpdateHint,
normalizeAssistantBootstrapMode,
buildAssistantBootstrapCommand,
getAssistantBootstrapHints,
validateBootstrapPrerequisites,
readAssistantStatusFromCli,
startAxhubGenieAndWait,
resolveRuntimeEndpoints,
resolveAssistantRuntime,
verifyAssistantHealthEndpoint,
};
function buildAgentApiUrl(apiBaseUrl: string): string {
const normalized = trimTrailingSlashes(apiBaseUrl);
if (/\/api$/i.test(normalized)) {
return `${normalized}/agent`;
}
return `${normalized}/api/agent`;
}
type AgentStreamNavigation = {
sessionId: string;
sessionUrl: string;
};
function extractAgentStreamError(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
if (typeof record.error === 'string' && record.error.trim()) {
return record.error.trim();
}
if (typeof record.message === 'string' && record.message.trim()) {
return record.message.trim();
}
const data = record.data;
if (!data || typeof data !== 'object') {
return null;
}
const nested = data as Record<string, unknown>;
if (typeof nested.error === 'string' && nested.error.trim()) {
return nested.error.trim();
}
if (typeof nested.message === 'string' && nested.message.trim()) {
return nested.message.trim();
}
return null;
}
function parseSseJsonEvent(rawBlock: string): Record<string, unknown> | null {
const trimmedBlock = rawBlock.trim();
if (!trimmedBlock) {
return null;
}
const dataLines = trimmedBlock
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const parsed = JSON.parse(payloadText);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
async function readAgentNavigationFromSse(response: Response, webBaseUrl: string): Promise<AgentStreamNavigation> {
if (!response.body) {
throw new Error('Agent API 未返回可读取的数据流');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let lastKnownSessionId = '';
let lastKnownSessionUrl = '';
let terminalError: string | null = null;
const resolveNavigation = (): AgentStreamNavigation | null => {
if (!lastKnownSessionId && !lastKnownSessionUrl) {
return null;
}
const resolvedUrl = lastKnownSessionUrl
|| (lastKnownSessionId ? `${webBaseUrl}/session/${encodeURIComponent(lastKnownSessionId)}` : '');
if (!resolvedUrl) {
return null;
}
return {
sessionId: lastKnownSessionId,
sessionUrl: resolvedUrl,
};
};
const consumeEvent = (payload: Record<string, unknown>) => {
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
const sessionUrl = typeof payload.sessionUrl === 'string' ? payload.sessionUrl.trim() : '';
if (sessionId) {
lastKnownSessionId = sessionId;
}
if (sessionUrl) {
lastKnownSessionUrl = sessionUrl;
}
const eventType = typeof payload.type === 'string' ? payload.type.trim() : '';
if (eventType === 'session-created' || eventType === 'open-only') {
return resolveNavigation();
}
if (eventType === 'session-aborted') {
terminalError = 'Session aborted';
return null;
}
if (eventType === 'codex-error' || eventType === 'claude-error' || eventType === 'error') {
terminalError = extractAgentStreamError(payload) || terminalError;
return null;
}
return resolveNavigation();
};
try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let separatorIndex = buffer.search(/\r?\n\r?\n/);
while (separatorIndex >= 0) {
const rawBlock = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + (buffer[separatorIndex] === '\r' ? 4 : 2));
const payload = parseSseJsonEvent(rawBlock);
if (payload) {
const navigation = consumeEvent(payload);
if (navigation) {
void reader.cancel().catch(() => undefined);
return navigation;
}
}
separatorIndex = buffer.search(/\r?\n\r?\n/);
}
if (done) {
const tailPayload = parseSseJsonEvent(buffer);
if (tailPayload) {
const navigation = consumeEvent(tailPayload);
if (navigation) {
return navigation;
}
}
break;
}
}
} finally {
reader.releaseLock();
}
if (terminalError) {
throw new Error(`Agent API 调用失败: ${terminalError}`);
}
throw new Error('Agent API 返回缺少 sessionUrl/sessionId');
}
function quoteForShell(value: string) {
return `"${String(value).replace(/["\\$`]/g, '\\$&')}"`;
}
function quoteForPowerShellSingle(value: string) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function resolveWindowsExecutablePath(candidates: string[]): string | null {
for (const candidate of candidates) {
const trimmed = candidate.trim();
if (!trimmed) continue;
const result = spawnSync('where', [trimmed], {
encoding: 'utf8',
windowsHide: true,
});
if (result.status !== 0) continue;
const lines = String(result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) continue;
const exePath = lines.find((line) => /\.exe$/i.test(line));
if (exePath) {
return exePath;
}
const commandWrapper = lines.find((line) => /\.(cmd|bat)$/i.test(line));
if (commandWrapper) {
const inferredExePath = commandWrapper.replace(/\.(cmd|bat)$/i, '.exe');
if (fs.existsSync(inferredExePath)) {
return inferredExePath;
}
return commandWrapper;
}
return lines[0] || null;
}
return null;
}
function resolveWindowsExecutableFromRegistry(executableNames: string[]): string | null {
const keyRoots = [
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths',
'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths',
'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths',
];
for (const executableName of executableNames) {
const normalizedName = executableName.trim();
if (!normalizedName) continue;
for (const keyRoot of keyRoots) {
const key = `${keyRoot}\\${normalizedName}`;
const query = spawnSync('reg', ['query', key, '/ve'], {
encoding: 'utf8',
windowsHide: true,
});
if (query.status !== 0 || query.error) {
continue;
}
const output = String(query.stdout || '');
const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const matchedLine = lines.find((line) => /REG_\w+/i.test(line));
if (!matchedLine) {
continue;
}
const valueMatch = matchedLine.match(/REG_\w+\s+(.+)$/i);
if (!valueMatch || !valueMatch[1]) {
continue;
}
const resolvedPath = valueMatch[1].trim().replace(/^"|"$/g, '');
if (resolvedPath && fs.existsSync(resolvedPath)) {
return resolvedPath;
}
}
}
return null;
}
function tryOpenWindowsIDEByAppPathNames(
appPathNames: string[],
targetPath: string,
callback: (error: Error | null, stdout?: string | Buffer, stderr?: string | Buffer) => void,
) {
const candidates = appPathNames.map((name) => name.trim()).filter(Boolean);
const tryNext = (index: number) => {
if (index >= candidates.length) {
callback(new Error('No compatible Windows app path name found'));
return;
}
execFile(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process -FilePath ${quoteForPowerShellSingle(candidates[index])} -ArgumentList ${quoteForPowerShellSingle(targetPath)} -ErrorAction Stop`,
],
{ windowsHide: true },
(error, stdout, stderr) => {
if (!error) {
callback(null, stdout, stderr);
return;
}
tryNext(index + 1);
},
);
};
tryNext(0);
}
function buildProjectInfoSection(
projectInfo: ProjectInfo,
projectDefaults: ProjectDefaults,
hasProjectOverviewDoc: boolean
): string {
const lines: string[] = [];
const projectName = normalizeInlineText(projectInfo.name);
const projectDescription = normalizeInlineText(projectInfo.description);
const defaultTheme = normalizeOptionalString(projectDefaults.defaultTheme);
if (projectName) lines.push(`- 项目名称:${projectName}`);
if (projectDescription) lines.push(`- 项目简介:${projectDescription}`);
if (hasProjectOverviewDoc) lines.push(`- 项目总文档:\`${PROJECT_OVERVIEW_DOC_RELATIVE_PATH}\``);
if (defaultTheme) lines.push(`- 默认主题:\`src/themes/${defaultTheme}\``);
if (!lines.length) return '';
return ['## 📌 项目信息', '', ...lines].join('\n');
}
function renderAgentsTemplate(
template: string,
projectInfo: ProjectInfo,
projectDefaults: ProjectDefaults,
hasProjectOverviewDoc: boolean
) {
const projectInfoSection = buildProjectInfoSection(projectInfo, projectDefaults, hasProjectOverviewDoc);
let content = template;
if (content.includes('{{PROJECT_INFO_SECTION}}')) {
content = content.replace('{{PROJECT_INFO_SECTION}}', projectInfoSection);
return content;
}
const sectionRegex = /^## 📌 项目信息[\s\S]*?(?=^##\s|\s*$)/m;
if (sectionRegex.test(content)) {
return content.replace(sectionRegex, projectInfoSection);
}
return content;
}
function writeAgentDocs(
templatePath: string,
agentsPath: string,
claudePath: string,
projectInfo: ProjectInfo,
projectDefaults: ProjectDefaults
): boolean {
if (!fs.existsSync(templatePath)) return false;
const template = fs.readFileSync(templatePath, 'utf8');
const projectRoot = path.dirname(templatePath);
const projectOverviewDocPath = path.resolve(projectRoot, PROJECT_OVERVIEW_DOC_RELATIVE_PATH);
const nextAgentsContent = renderAgentsTemplate(
template,
projectInfo,
projectDefaults,
fs.existsSync(projectOverviewDocPath)
);
fs.writeFileSync(agentsPath, nextAgentsContent, 'utf8');
fs.writeFileSync(claudePath, nextAgentsContent, 'utf8');
return true;
}
function syncAgentDocsFromConfig(paths: AgentDocsPaths): void {
const { configPath, agentsTemplatePath, agentsPath, claudePath } = paths;
let config: SystemConfig = { server: { host: 'localhost', allowLAN: true } };
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
config = JSON.parse(fileContent);
}
const projectDefaults = normalizeProjectDefaults(config.projectDefaults);
const projectInfo = normalizeProjectInfo(config.projectInfo);
writeAgentDocs(agentsTemplatePath, agentsPath, claudePath, projectInfo, projectDefaults);
}
/**
* 配置管理 API 插件
* 提供配置文件的读取和保存功能
*/
export function configApiPlugin(): Plugin {
const projectRoot = path.resolve(__dirname, '..');
const configPath = path.resolve(projectRoot, '.axhub', 'make', 'axhub.config.json');
const agentsPath = path.resolve(projectRoot, 'AGENTS.md');
const claudePath = path.resolve(projectRoot, 'CLAUDE.md');
const agentsTemplatePath = path.resolve(projectRoot, 'AGENTS.template.md');
return {
name: 'config-api-plugin',
configureServer(server: any) {
try {
syncAgentDocsFromConfig({
configPath,
agentsTemplatePath,
agentsPath,
claudePath
});
} catch (e: any) {
console.warn('Failed to sync AGENTS.md on server start:', e?.message || e);
}
// GET /api/config - 读取配置
server.middlewares.use(async (req: any, res: any, next: any) => {
if (req.method === 'GET' && req.url === '/api/config') {
try {
const config = readSystemConfig(configPath);
// 移除 port 字段(不对外暴露,固定使用 51720 起始)
if (config.server && 'port' in config.server) {
delete config.server.port;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
...config,
projectPath: projectRoot,
}));
} catch (e: any) {
console.error('Error reading config:', e);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: e?.message || 'Failed to read config' }));
}
return;
}
if (req.method === 'GET' && req.url?.startsWith('/api/assistant/runtime')) {
try {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/api/assistant/runtime') {
next();
return;
}
const autoStartParam = (url.searchParams.get('autoStart') || '').trim().toLowerCase();
const shouldAutoStart = !(autoStartParam === '0' || autoStartParam === 'false' || autoStartParam === 'no');
const config = readSystemConfig(configPath);
const runtime = await resolveAssistantRuntime(config, projectRoot, {
autoStart: shouldAutoStart,
});
// When accessed via LAN, rewrite localhost URLs so remote clients can reach the API
const requestHost = String(req.headers?.host || '').split(':')[0];
if (requestHost && requestHost !== 'localhost' && requestHost !== '127.0.0.1') {
const rewriteLocalhostUrl = (urlStr: string): string => {
try {
const parsed = new URL(urlStr);
if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
parsed.hostname = requestHost;
return parsed.toString().replace(/\/+$/, '');
}
} catch { /* keep original */ }
return urlStr;
};
if (runtime.apiBaseUrl) runtime.apiBaseUrl = rewriteLocalhostUrl(runtime.apiBaseUrl);
if (runtime.webBaseUrl) runtime.webBaseUrl = rewriteLocalhostUrl(runtime.webBaseUrl);
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(runtime));
} catch (e: any) {
const requestHost = String(req.headers?.host || '').split(':')[0];
const fallbackApiHost = (requestHost && requestHost !== 'localhost' && requestHost !== '127.0.0.1')
? requestHost : 'localhost';
const fallbackWebHost = fallbackApiHost;
const fallback: AssistantRuntimeInfo = {
webBaseUrl: `http://${fallbackWebHost}:${DEFAULT_ASSISTANT_WEB_BASE_URL.match(/:(\d+)/)?.[1] || '32123'}`,
apiBaseUrl: `http://${fallbackApiHost}:32123/api`,
projectPath: projectRoot,
source: 'default',
health: createAssistantHealthInfo({
status: 'runtime_unreachable',
message: '助手运行时检查失败,请稍后重试',
commandSource: 'default',
}),
};
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(fallback));
}
return;
}
if (req.method === 'POST' && req.url === '/api/assistant/bootstrap') {
const chunks: Buffer[] = [];
let totalLength = 0;
req.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > 1024 * 10) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', async () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
const body = raw ? JSON.parse(raw) : {};
const mode = normalizeAssistantBootstrapMode(body?.mode);
if (!mode) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid bootstrap mode', hints: getAssistantBootstrapHints() }));
return;
}
const prerequisiteError = validateBootstrapPrerequisites(mode);
if (prerequisiteError) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: prerequisiteError, hints: getAssistantBootstrapHints() }));
return;
}
await runAssistantBootstrap(mode, projectRoot);
const config = readSystemConfig(configPath);
const runtime = await resolveAssistantRuntime(config, projectRoot);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
mode,
message: '已触发启动,请稍后重试',
runtime,
}));
} catch (e: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: e?.message || '无法自动启动 Axhub Genie',
hints: getAssistantBootstrapHints(),
}));
}
});
return;
}
// POST /api/config - 保存配置
if (req.method === 'POST' && req.url === '/api/config') {
const chunks: Buffer[] = [];
let totalLength = 0;
req.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > 1024 * 10) { // 10KB 限制
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
const newConfig: SystemConfig = JSON.parse(raw);
// 验证配置格式
if (!newConfig.server || typeof newConfig.server !== 'object') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid config format' }));
return;
}
// 移除 port 字段(不允许配置,固定使用 51720 起始)
if (newConfig.server && 'port' in newConfig.server) {
delete newConfig.server.port;
}
// 校验/归一化 projectDefaults
const projectDefaults = normalizeProjectDefaults(newConfig.projectDefaults);
const projectInfo = normalizeProjectInfo(newConfig.projectInfo);
const automation = normalizeAutomationConfig(newConfig.automation);
const assistant = normalizeAssistantConfig(newConfig.assistant);
newConfig.projectDefaults = projectDefaults;
newConfig.projectInfo = projectInfo;
newConfig.automation = automation;
newConfig.assistant = assistant;
// 使用模板生成 AGENTS.md项目参考规范
if (!writeAgentDocs(agentsTemplatePath, agentsPath, claudePath, projectInfo, projectDefaults)) {
console.warn('AGENTS.template.md not found, skip regenerating AGENTS.md');
}
// 保存配置文件
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
message: '配置已保存(已根据模板同步 AGENTS.md'
}));
} catch (e: any) {
console.error('Error saving config:', e);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: e?.message || 'Failed to save config' }));
}
});
return;
}
if (req.method === 'POST' && req.url === '/api/ide/open') {
const chunks: Buffer[] = [];
let totalLength = 0;
req.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > 1024 * 10) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
const body = JSON.parse(raw || '{}');
let configuredIDE: MainIDE | null = null;
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
const savedConfig = JSON.parse(fileContent) as SystemConfig;
configuredIDE = normalizeMainIDE(savedConfig?.automation?.defaultIDE);
}
const ide = normalizeMainIDE(body?.ide) || configuredIDE;
if (!ide) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Main IDE is not configured' }));
return;
}
const rawTargetPath = typeof body?.targetPath === 'string' ? body.targetPath.trim() : '';
const targetPath = rawTargetPath ? rawTargetPath : projectRoot;
const absoluteTargetPath = path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRoot, targetPath);
const ideAppName = MAIN_IDE_APP_NAMES[ide];
const windowsAppPathNames = MAIN_IDE_WINDOWS_APP_PATH_NAMES[ide] || [ideAppName];
const command = process.platform === 'win32'
? `powershell -NoProfile -Command Start-Process -FilePath ${quoteForPowerShellSingle(windowsAppPathNames[0] || ideAppName)} -ArgumentList ${quoteForPowerShellSingle(absoluteTargetPath)} -ErrorAction Stop`
: `open -a ${quoteForShell(ideAppName)} ${quoteForShell(absoluteTargetPath)}`;
const handleOpenCommandResult = (error: Error | null, _stdout?: string | Buffer, stderr?: string | Buffer) => {
const stderrText = typeof stderr === 'string'
? stderr.trim()
: Buffer.isBuffer(stderr)
? stderr.toString('utf8').trim()
: '';
if (error && stderrText) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: `打开 ${ideAppName} 失败: ${stderrText || error.message}` }));
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
ide,
targetPath: absoluteTargetPath,
command,
}));
};
if (process.platform === 'win32') {
const executableCandidates = [
...(MAIN_IDE_WINDOWS_COMMAND_CANDIDATES[ide] || []),
ideAppName,
];
const executableNameCandidates = MAIN_IDE_WINDOWS_EXECUTABLE_NAMES[ide] || [];
const executablePath =
resolveWindowsExecutablePath(executableCandidates)
|| resolveWindowsExecutablePath(executableNameCandidates)
|| resolveWindowsExecutableFromRegistry(executableNameCandidates);
if (executablePath) {
const spawnSpec = getSpawnCommandSpec(executablePath, [absoluteTargetPath], process.platform);
const child = spawn(spawnSpec.command, spawnSpec.args, {
detached: true,
stdio: 'ignore',
windowsHide: spawnSpec.windowsHide,
shell: false,
});
child.once('error', (spawnError) => {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: `打开 ${ideAppName} 失败: ${spawnError.message}` }));
});
child.once('spawn', () => {
child.unref();
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
ide,
targetPath: absoluteTargetPath,
command: `${quoteForShell(executablePath)} ${quoteForShell(absoluteTargetPath)}`,
}));
});
return;
}
tryOpenWindowsIDEByAppPathNames(
windowsAppPathNames,
absoluteTargetPath,
handleOpenCommandResult,
);
} else {
exec(command, handleOpenCommandResult);
}
} catch (e: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: e?.message || 'Failed to open IDE' }));
}
});
return;
}
if (req.method === 'POST' && req.url === '/api/prompt/execute') {
const chunks: Buffer[] = [];
let totalLength = 0;
req.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > 1024 * 1024) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', async () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
const body = JSON.parse(raw || '{}');
const client = normalizeExecutePromptClient(body?.client);
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
const scene = typeof body?.scene === 'string' ? body.scene.trim() : '';
if (!client) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid client' }));
return;
}
if (!prompt) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Prompt is required' }));
return;
}
if (!scene) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Scene is required' }));
return;
}
const config = readSystemConfig(configPath);
const assistantRuntime = await resolveAssistantRuntime(config, projectRoot);
if (assistantRuntime.health.status !== 'ready') {
throw new Error(`${assistantRuntime.health.message}。可尝试:${getAssistantHealthHints().installGlobal}`);
}
const provider = client;
const agentApiUrl = buildAgentApiUrl(assistantRuntime.apiBaseUrl);
const upstreamResponse = await fetch(agentApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream, application/json',
},
body: JSON.stringify({
projectPath: assistantRuntime.projectPath,
provider,
message: prompt,
stream: true,
}),
});
if (!upstreamResponse.ok) {
const upstreamText = await upstreamResponse.text();
let upstreamData: any = null;
try {
upstreamData = upstreamText ? JSON.parse(upstreamText) : null;
} catch {
upstreamData = null;
}
const upstreamError = upstreamData?.error || upstreamData?.message || upstreamText || `status ${upstreamResponse.status}`;
throw new Error(`Agent API 调用失败: ${upstreamError}`);
}
const navigation = await readAgentNavigationFromSse(upstreamResponse, assistantRuntime.webBaseUrl);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
url: navigation.sessionUrl,
sessionId: navigation.sessionId,
scene,
provider,
}));
} catch (e: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: e?.message || 'Failed to execute prompt' }));
}
});
return;
}
next();
});
}
};
}