diff --git a/packages/client/src/api/coding-agents.ts b/packages/client/src/api/coding-agents.ts index acb41a5..5d9614c 100644 --- a/packages/client/src/api/coding-agents.ts +++ b/packages/client/src/api/coding-agents.ts @@ -24,6 +24,7 @@ export interface CodingAgentMutationResult extends CodingAgentsStatus { success: boolean tool: CodingAgentToolStatus message?: string + code?: string } export interface CodingAgentConfigFileContent { diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index d16c4d1..b2a9837 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -35,6 +35,7 @@ export interface PreviewStatus { export interface PreviewActionResponse extends PreviewStatus { success: boolean message?: string + code?: string } // Config-based model types diff --git a/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue b/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue index a16ae85..dfbeb8f 100644 --- a/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue +++ b/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue @@ -42,6 +42,23 @@ function applyErrorStatus(err: any) { } catch {} } +function errorCodeMessage(code?: string, fallback?: string): string { + if (code === 'node_environment_missing') return t('githubPreview.nodeEnvironmentMissing') + return fallback || t('githubPreview.actionFailed') +} + +function parseErrorPayload(err: any): { message?: string; code?: string } | null { + const messageText = String(err?.message || '') + const jsonStart = messageText.indexOf('{') + if (jsonStart < 0) return null + try { + const parsed = JSON.parse(messageText.slice(jsonStart)) + return parsed && typeof parsed === 'object' ? parsed : null + } catch { + return null + } +} + async function loadStatus() { status.value = await fetchPreviewStatus() if (!selectedTag.value && status.value.current_tag) { @@ -71,19 +88,20 @@ async function handleRefresh() { } } -async function runAction(action: string, fn: () => Promise, successKey: string) { +async function runAction(action: string, fn: () => Promise, successKey: string) { actionLoading.value = action try { const res = await fn() status.value = res if (res.success === false) { - message.warning(res.message || t('githubPreview.actionFailed')) + message.warning(errorCodeMessage(res.code, res.message)) return } message.success(t(successKey)) } catch (err: any) { applyErrorStatus(err) - message.error(err?.message || t('githubPreview.actionFailed')) + const payload = parseErrorPayload(err) + message.error(errorCodeMessage(payload?.code, payload?.message || err?.message)) } finally { actionLoading.value = '' } diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index f652ebf..46048aa 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -1015,6 +1015,7 @@ jobTriggered: 'Job ausgelost', yes: "Ja", no: "Nein", actionFailed: "Aktion fehlgeschlagen", + nodeEnvironmentMissing: "Node/npm wurde nicht erkannt. Bitte installiere Node.js und versuche es erneut.", prepareSuccess: "Vorschaucode ist bereit", installSuccess: "Abhängigkeiten installiert", startSuccess: "Vorschau gestartet", @@ -1038,6 +1039,7 @@ jobTriggered: 'Job ausgelost', installing: "Installiere", installSuccess: "Installiert", installFailed: "Installation fehlgeschlagen", + nodeEnvironmentMissing: "Node/npm wurde nicht erkannt. Bitte installiere Node.js und versuche es erneut.", deleteNow: "Loschen", deleting: "Losche", deleteSuccess: "Gelöscht", diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index bef3bad..f09328c 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -1117,6 +1117,7 @@ export default { yes: "Yes", no: "No", actionFailed: "Action failed", + nodeEnvironmentMissing: "Node/npm was not detected. Please install Node.js and try again.", prepareSuccess: "Preview code is ready", installSuccess: "Dependencies installed", startSuccess: "Preview started", @@ -1140,6 +1141,7 @@ export default { installing: "Installing", installSuccess: "Installed", installFailed: "Install failed", + nodeEnvironmentMissing: "Node/npm was not detected. Please install Node.js and try again.", deleteNow: "Delete", deleting: "Deleting", deleteSuccess: "Deleted", diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index eb8f341..17bd928 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -1015,6 +1015,7 @@ jobTriggered: 'Job ejecutado', yes: "Sí", no: "No", actionFailed: "Acción fallida", + nodeEnvironmentMissing: "No se detectó Node/npm. Instala Node.js y vuelve a intentarlo.", prepareSuccess: "Código de vista previa listo", installSuccess: "Dependencias instaladas", startSuccess: "Vista previa iniciada", @@ -1038,6 +1039,7 @@ jobTriggered: 'Job ejecutado', installing: "Instalando", installSuccess: "Instalado", installFailed: "Error de instalación", + nodeEnvironmentMissing: "No se detectó Node/npm. Instala Node.js y vuelve a intentarlo.", deleteNow: "Eliminar", deleting: "Eliminando", deleteSuccess: "Eliminado", diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 6cfddf3..34b52de 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -1015,6 +1015,7 @@ jobTriggered: 'Job declenche', yes: "Oui", no: "Non", actionFailed: "Échec de l’action", + nodeEnvironmentMissing: "Node/npm n’a pas été détecté. Installez Node.js puis réessayez.", prepareSuccess: "Code de prévisualisation prêt", installSuccess: "Dépendances installées", startSuccess: "Prévisualisation démarrée", @@ -1038,6 +1039,7 @@ jobTriggered: 'Job declenche', installing: "Installation", installSuccess: "Installé", installFailed: "Échec de l’installation", + nodeEnvironmentMissing: "Node/npm n’a pas été détecté. Installez Node.js puis réessayez.", deleteNow: "Supprimer", deleting: "Suppression", deleteSuccess: "Supprimé", diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index ad57f09..008423b 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -1015,6 +1015,7 @@ export default { yes: "はい", no: "いいえ", actionFailed: "操作に失敗しました", + nodeEnvironmentMissing: "Node/npm が検出されませんでした。Node.js をインストールしてから再試行してください。", prepareSuccess: "プレビューコードの準備が完了しました", installSuccess: "依存関係をインストールしました", startSuccess: "プレビューを起動しました", @@ -1038,6 +1039,7 @@ export default { installing: "インストール中", installSuccess: "インストールしました", installFailed: "インストールに失敗しました", + nodeEnvironmentMissing: "Node/npm が検出されませんでした。Node.js をインストールしてから再試行してください。", deleteNow: "削除", deleting: "削除中", deleteSuccess: "削除しました", diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 02603a0..2af9e63 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -1015,6 +1015,7 @@ export default { yes: "예", no: "아니요", actionFailed: "작업 실패", + nodeEnvironmentMissing: "Node/npm 환경을 찾을 수 없습니다. Node.js를 설치한 뒤 다시 시도하세요.", prepareSuccess: "미리보기 코드가 준비되었습니다", installSuccess: "의존성이 설치되었습니다", startSuccess: "미리보기가 시작되었습니다", @@ -1038,6 +1039,7 @@ export default { installing: "설치 중", installSuccess: "설치됨", installFailed: "설치 실패", + nodeEnvironmentMissing: "Node/npm 환경을 찾을 수 없습니다. Node.js를 설치한 뒤 다시 시도하세요.", deleteNow: "삭제", deleting: "삭제 중", deleteSuccess: "삭제됨", diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 6664df3..47d91b6 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -1015,6 +1015,7 @@ jobTriggered: 'Job acionado', yes: "Sim", no: "Não", actionFailed: "Ação falhou", + nodeEnvironmentMissing: "Node/npm não foi detectado. Instale o Node.js e tente novamente.", prepareSuccess: "Código de prévia pronto", installSuccess: "Dependências instaladas", startSuccess: "Prévia iniciada", @@ -1038,6 +1039,7 @@ jobTriggered: 'Job acionado', installing: "Instalando", installSuccess: "Instalado", installFailed: "Falha na instalação", + nodeEnvironmentMissing: "Node/npm não foi detectado. Instale o Node.js e tente novamente.", deleteNow: "Excluir", deleting: "Excluindo", deleteSuccess: "Excluído", diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index b39d8b3..b94741d 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -1109,6 +1109,7 @@ export default { yes: "是", no: "否", actionFailed: "操作失敗", + nodeEnvironmentMissing: "未偵測到可用的 Node/npm 環境,請先安裝 Node.js 後重試。", prepareSuccess: "預覽程式碼已準備好", installSuccess: "依賴安裝完成", startSuccess: "預覽已啟動", @@ -1132,6 +1133,7 @@ export default { installing: "安裝中", installSuccess: "安裝完成", installFailed: "安裝失敗", + nodeEnvironmentMissing: "未偵測到可用的 Node/npm 環境,請先安裝 Node.js 後重試。", deleteNow: "刪除", deleting: "刪除中", deleteSuccess: "刪除完成", diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 3b4935b..e7fc373 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -1109,6 +1109,7 @@ export default { yes: "是", no: "否", actionFailed: "操作失败", + nodeEnvironmentMissing: "未检测到可用的 Node/npm 环境,请先安装 Node.js 后重试。", prepareSuccess: "预览代码已准备好", installSuccess: "依赖安装完成", startSuccess: "预览已启动", @@ -1132,6 +1133,7 @@ export default { installing: "安装中", installSuccess: "安装完成", installFailed: "安装失败", + nodeEnvironmentMissing: "未检测到可用的 Node/npm 环境,请先安装 Node.js 后重试。", deleteNow: "删除", deleting: "删除中", deleteSuccess: "删除完成", diff --git a/packages/client/src/views/hermes/CodingAgentsView.vue b/packages/client/src/views/hermes/CodingAgentsView.vue index a2237ab..3ab0c90 100644 --- a/packages/client/src/views/hermes/CodingAgentsView.vue +++ b/packages/client/src/views/hermes/CodingAgentsView.vue @@ -307,6 +307,23 @@ function currentLaunchRequest() { } } +function codingAgentMessage(code?: string, fallback?: string, fallbackKey = 'codingAgents.installFailed'): string { + if (code === 'node_environment_missing') return t('codingAgents.nodeEnvironmentMissing') + return fallback || t(fallbackKey) +} + +function parseErrorPayload(err: any): { message?: string; code?: string } | null { + const messageText = String(err?.message || '') + const jsonStart = messageText.indexOf('{') + if (jsonStart < 0) return null + try { + const parsed = JSON.parse(messageText.slice(jsonStart)) + return parsed && typeof parsed === 'object' ? parsed : null + } catch { + return null + } +} + async function launchBuiltInTerminal() { if (!useGlobalLaunchConfig.value && (!launchProvider.value || !launchModel.value)) { message.error(t('codingAgents.selectProviderModel')) @@ -352,10 +369,11 @@ async function handleInstall(id: CodingAgentId) { if (result.success) { message.success(t('codingAgents.installSuccess')) } else { - message.error(result.message || t('codingAgents.installFailed')) + message.error(codingAgentMessage(result.code, result.message, 'codingAgents.installFailed')) } } catch (err: any) { - message.error(err?.message || t('codingAgents.installFailed')) + const payload = parseErrorPayload(err) + message.error(codingAgentMessage(payload?.code, payload?.message || err?.message, 'codingAgents.installFailed')) } finally { installing.value[id] = false } @@ -369,10 +387,11 @@ async function handleDelete(id: CodingAgentId) { if (result.success) { message.success(t('codingAgents.deleteSuccess')) } else { - message.error(result.message || t('codingAgents.deleteFailed')) + message.error(codingAgentMessage(result.code, result.message, 'codingAgents.deleteFailed')) } } catch (err: any) { - message.error(err?.message || t('codingAgents.deleteFailed')) + const payload = parseErrorPayload(err) + message.error(codingAgentMessage(payload?.code, payload?.message || err?.message, 'codingAgents.deleteFailed')) } finally { deleting.value[id] = false } diff --git a/packages/client/src/views/hermes/SkillsView.vue b/packages/client/src/views/hermes/SkillsView.vue index 132bb7a..07178b0 100644 --- a/packages/client/src/views/hermes/SkillsView.vue +++ b/packages/client/src/views/hermes/SkillsView.vue @@ -77,6 +77,9 @@ async function loadRecommendations() { const response = await fetch(recommendationsPath.value) if (!response.ok) throw new Error(`HTTP ${response.status}`) const text = await response.text() + if (/^\s*]/i.test(text)) { + throw new Error('Skill recommendations file was not found') + } if (requestSeq === recommendationsRequestSeq) { recommendations.value = text } diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 86313d3..d0d227d 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -35,7 +35,8 @@ extraResources: - "!node_modules/node-pty/prebuilds/!(${platform}-${arch})/**" - "!node_modules/node-pty/build/**" - "!packages/desktop/**" - - "!**/{.git,.github,docs,tests,playwright.config.ts,*.md,README*,scripts,*.map}" + - "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}" + - "!node_modules/**/*.md" - from: "resources/python/${os}-${arch}" to: "python" filter: diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 93b8a1c..39abb30 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -1,13 +1,16 @@ -import { ChildProcess, spawn } from 'node:child_process' -import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { ChildProcess, execFile, spawn } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, readdirSync } from 'node:fs' import { createServer } from 'node:net' +import { homedir } from 'node:os' import { dirname, delimiter, join } from 'node:path' import { randomBytes } from 'node:crypto' +import { promisify } from 'node:util' import { app } from 'electron' import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths' const DEFAULT_PORT = 8748 const READY_TIMEOUT_MS = 30_000 +const execFileAsync = promisify(execFile) let serverProc: ChildProcess | null = null let cachedToken: string | null = null @@ -43,6 +46,93 @@ function ensureNativeModules() { } } +const COMMON_USER_BIN_DIRS = process.platform === 'win32' + ? [] + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ] +const PATH_MARKER_START = '__HERMES_DESKTOP_PATH_START__' +const PATH_MARKER_END = '__HERMES_DESKTOP_PATH_END__' + +function mergePathEntries(...paths: Array): string { + const seen = new Set() + const entries: string[] = [] + for (const rawPath of paths) { + if (!rawPath) continue + for (const entry of rawPath.split(delimiter)) { + const trimmed = entry.trim() + if (!trimmed) continue + const key = process.platform === 'win32' ? trimmed.toLowerCase() : trimmed + if (seen.has(key)) continue + seen.add(key) + entries.push(trimmed) + } + } + return entries.join(delimiter) +} + +function extractMarkedPath(output: string): string | null { + const start = output.lastIndexOf(PATH_MARKER_START) + const end = output.lastIndexOf(PATH_MARKER_END) + if (start < 0 || end <= start) return null + const value = output.slice(start + PATH_MARKER_START.length, end).trim() + return value || null +} + +function compareNodeVersionDesc(left: string, right: string): number { + const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const diff = (rightParts[index] || 0) - (leftParts[index] || 0) + if (diff !== 0) return diff + } + return right.localeCompare(left) +} + +function getNvmNodeBinPaths(): string { + if (process.platform === 'win32') return '' + + const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm') + const versionsDir = join(nvmDir, 'versions', 'node') + if (!existsSync(versionsDir)) return '' + + try { + return readdirSync(versionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(compareNodeVersionDesc) + .map(version => join(versionsDir, version, 'bin')) + .filter(binDir => existsSync(binDir)) + .join(delimiter) + } catch { + return '' + } +} + +async function getLoginShellPath(): Promise { + if (process.platform === 'win32') return null + + const shell = process.env.SHELL?.trim() || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh') + if (!existsSync(shell)) return null + + try { + const { stdout } = await execFileAsync(shell, ['-l', '-c', `printf '\\n${PATH_MARKER_START}%s${PATH_MARKER_END}\\n' "$PATH"`], { + encoding: 'utf-8', + timeout: 1500, + windowsHide: true, + env: process.env, + }) + return extractMarkedPath(stdout) || stdout.trim() || null + } catch { + return null + } +} + export function getToken(): string { return ensureToken() } @@ -111,6 +201,15 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { : join(pythonDir(), 'bin', 'python3') const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) + const loginShellPath = await getLoginShellPath() + const nvmNodeBinPaths = getNvmNodeBinPaths() + const runtimePath = mergePathEntries( + dirname(hermesBin()), + loginShellPath, + nvmNodeBinPaths, + process.env.PATH, + COMMON_USER_BIN_DIRS.join(delimiter), + ) // Run via Electron's "run as Node" mode — Electron binary doubles as Node. const env: NodeJS.ProcessEnv = { @@ -151,7 +250,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { AUTH_TOKEN: token, PORT: String(port), // Prepend bundled Python's bin to PATH so any incidental `python` resolution lands on ours - PATH: [dirname(hermesBin()), process.env.PATH].filter(Boolean).join(delimiter), + PATH: runtimePath, } serverProc = spawn(process.execPath, [entry], { diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts index 1ca3e18..dd7b47b 100644 --- a/packages/server/src/controllers/update.ts +++ b/packages/server/src/controllers/update.ts @@ -1,11 +1,12 @@ import { execFileSync, spawn, type ChildProcess } from 'child_process' import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs' import { createServer } from 'net' -import { delimiter, dirname, join, resolve } from 'path' +import { delimiter, dirname, extname, join, resolve } from 'path' import { getWebUiHome } from '../config' let updateInProgress = false let previewProcess: ChildProcess | null = null +const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing' const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview' const PREVIEW_HOME_DIR_NAME = 'hermes-web-ui-pereview-home' @@ -144,6 +145,96 @@ function getNpmBin() { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } +function windowsCommandNeedsShell(command: string): boolean { + const extension = extname(command).toLowerCase() + return extension === '.cmd' || extension === '.bat' +} + +function commandExecution(command: string, args: string[]): { command: string; args: string[] } { + if (process.platform === 'win32' && windowsCommandNeedsShell(command)) { + const commandArg = / /.test(command) ? `"${command}"` : command + const argsString = args.map(arg => / /.test(arg) ? `"${arg}"` : arg).join(' ') + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', `${commandArg} ${argsString}`], + } + } + return { command, args } +} + +function nodeEnvironmentMissingError(): Error { + const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.') + ;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE + return err +} + +function isNodeEnvironmentMissingError(err: any): boolean { + const text = [ + err?.code, + err?.message, + err?.stderr?.toString?.(), + err?.stdout?.toString?.(), + ].filter(Boolean).join('\n').toLowerCase() + return text.includes('enoent') || + text.includes('spawn npm') || + text.includes('npm: command not found') || + text.includes('npm not found') || + text.includes('node: command not found') || + text.includes('node not found') +} + +function normalizeNodeToolError(err: any): { message: string; code?: string } { + if (isNodeEnvironmentMissingError(err)) { + return { message: nodeEnvironmentMissingError().message, code: NODE_ENVIRONMENT_MISSING_CODE } + } + return { message: err?.stderr?.toString() || err?.message || String(err) } +} + +function findCommandPath(command: string, env: NodeJS.ProcessEnv): string | null { + try { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which' + const stdout = execFileSync(lookupCommand, [command], { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + env, + windowsHide: true, + }) + return stdout.split(/\r?\n/).map((line: string) => line.trim()).find(Boolean) || null + } catch { + return null + } +} + +function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null { + const binDir = dirname(npmBin) + if (process.platform === 'win32') { + const node = join(binDir, 'node.exe') + const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null + } + + const node = join(binDir, 'node') + const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null +} + +function npmExecution(args: string[], env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const bundledNpmCli = getNpmCliPath() + if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] } + + const npmBin = findCommandPath(getNpmBin(), env) || findCommandPath('npm', env) + if (!npmBin) throw nodeEnvironmentMissingError() + + const npmCli = npmCliFromNpmBin(npmBin) + if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] } + + const nodeBin = findCommandPath(process.platform === 'win32' ? 'node.exe' : 'node', env) || findCommandPath('node', env) + if (!nodeBin) throw nodeEnvironmentMissingError() + + return commandExecution(npmBin, args) +} + function isTermuxRuntime() { const prefix = process.env.PREFIX || '' return prefix.includes('/com.termux/') || @@ -167,21 +258,20 @@ function getCurrentNodeEnv() { } function runNpm(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) { - const npmCli = getNpmCliPath() - const command = npmCli ? process.execPath : getNpmBin() - const commandArgs = npmCli ? [npmCli, ...args] : args + const env = { + ...getCurrentNodeEnv(), + ...options.env, + } + const execution = npmExecution(args, env) const label = options.logLabel || '' - if (label) appendPreviewActionLog(`${label}: ${command} ${commandArgs.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`) + if (label) appendPreviewActionLog(`${label}: ${execution.command} ${execution.args.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`) try { - const output = execFileSync(command, commandArgs, { + const output = execFileSync(execution.command, execution.args, { encoding: 'utf-8', timeout: options.timeout, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...getCurrentNodeEnv(), - ...options.env, - }, + env, cwd: options.cwd, windowsHide: true, }).trim() @@ -931,9 +1021,10 @@ export async function installPreview(ctx: any) { } ctx.body = previewPayload({ success: true, message: output }) } catch (err: any) { - appendPreviewActionLog(`npm install failed: ${err.stderr?.toString() || err.message || String(err)}`) + const normalized = normalizeNodeToolError(err) + appendPreviewActionLog(`npm install failed: ${normalized.message}`) ctx.status = 500 - ctx.body = previewPayload({ success: false, message: err.stderr?.toString() || err.message || String(err) }) + ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code }) } } @@ -973,29 +1064,28 @@ export async function startPreview(ctx: any) { await assertPreviewPortsAvailable() - const npmCli = getNpmCliPath() - const command = npmCli ? process.execPath : getNpmBin() - const commandArgs = npmCli ? [npmCli, 'run', 'dev'] : ['run', 'dev'] + const env = { + ...getCurrentNodeEnv(), + NODE_ENV: 'development', + PORT: String(PREVIEW_BACKEND_PORT), + HERMES_WEB_UI_HOME: getPreviewHomeDir(), + HERMES_WEBUI_STATE_DIR: getPreviewHomeDir(), + HERMES_AGENT_BRIDGE_ENDPOINT: getPreviewAgentBridgeEndpoint(), + HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE), + AUTH_TOKEN: '', + HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT), + HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT), + VITE_HERMES_PREVIEW: '1', + } + const execution = npmExecution(['run', 'dev'], env) const logFd = openPreviewLogFile() - appendPreviewActionLog(`spawn preview process: ${command} ${commandArgs.join(' ')}`) - previewProcess = spawn(command, commandArgs, { + appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`) + previewProcess = spawn(execution.command, execution.args, { cwd: getPreviewDir(), detached: true, stdio: ['ignore', logFd, logFd], windowsHide: true, - env: { - ...getCurrentNodeEnv(), - NODE_ENV: 'development', - PORT: String(PREVIEW_BACKEND_PORT), - HERMES_WEB_UI_HOME: getPreviewHomeDir(), - HERMES_WEBUI_STATE_DIR: getPreviewHomeDir(), - HERMES_AGENT_BRIDGE_ENDPOINT: getPreviewAgentBridgeEndpoint(), - HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE), - AUTH_TOKEN: '', - HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT), - HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT), - VITE_HERMES_PREVIEW: '1', - }, + env, }) closeSync(logFd) previewProcess.on('exit', () => { @@ -1013,10 +1103,11 @@ export async function startPreview(ctx: any) { appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`) ctx.body = previewPayload({ success: true, message: 'Preview started' }) } catch (err: any) { - appendPreviewActionLog(`npm run dev failed: ${err.stderr?.toString() || err.message || String(err)}`) + const normalized = normalizeNodeToolError(err) + appendPreviewActionLog(`npm run dev failed: ${normalized.message}`) await stopPreviewProcess() ctx.status = 500 - ctx.body = previewPayload({ success: false, message: err.message || String(err) }) + ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code }) } } diff --git a/packages/server/src/services/coding-agents.ts b/packages/server/src/services/coding-agents.ts index a250c6e..1f18a31 100644 --- a/packages/server/src/services/coding-agents.ts +++ b/packages/server/src/services/coding-agents.ts @@ -1,5 +1,5 @@ import { execFile } from 'child_process' -import { existsSync, realpathSync } from 'fs' +import { existsSync, readdirSync, realpathSync } from 'fs' import { mkdir, readFile, stat, writeFile } from 'fs/promises' import { homedir } from 'os' import { delimiter, dirname, extname, join } from 'path' @@ -15,6 +15,7 @@ const LAUNCH_API_MODES = new Set(['chat_completions', 'codex_responses' const CODING_AGENT_HOME_DIR = 'coding-agent' const CODEX_MODEL_CATALOG_FILE = 'codex-model-catalog.json' const CODEX_CATALOG_BASE_INSTRUCTIONS = 'You are Codex, a coding agent. Be precise, safe, and helpful.' +const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing' export type CodingAgentId = 'claude-code' | 'codex' @@ -41,6 +42,7 @@ export interface CodingAgentMutationResult extends CodingAgentsStatus { success: boolean tool: CodingAgentToolStatus message?: string + code?: string } export interface CodingAgentConfigFileDefinition { @@ -163,6 +165,70 @@ function getNpmBin() { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } +function compareNodeVersionDesc(left: string, right: string): number { + const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const diff = (rightParts[index] || 0) - (leftParts[index] || 0) + if (diff !== 0) return diff + } + return right.localeCompare(left) +} + +function getNvmNodeBinPaths(): string { + if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return '' + + const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm') + const versionsDir = join(nvmDir, 'versions', 'node') + if (!existsSync(versionsDir)) return '' + + try { + return readdirSync(versionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(compareNodeVersionDesc) + .map(version => join(versionsDir, version, 'bin')) + .filter(binDir => existsSync(binDir)) + .join(delimiter) + } catch { + return '' + } +} + +function nodeEnvironmentMissingError(): Error { + const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.') + ;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE + return err +} + +function isNodeEnvironmentMissingError(err: any): boolean { + const text = [ + err?.code, + err?.message, + typeof err?.stderr === 'string' ? err.stderr : '', + typeof err?.stdout === 'string' ? err.stdout : '', + ].filter(Boolean).join('\n').toLowerCase() + return text.includes('enoent') || + text.includes('spawn npm') || + text.includes('npm: command not found') || + text.includes('npm not found') || + text.includes('node: command not found') || + text.includes('node not found') +} + +function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null { + const binDir = dirname(npmBin) + if (process.platform === 'win32') { + const node = join(binDir, 'node.exe') + const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null + } + + const node = join(binDir, 'node') + const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null +} + function normalizeScopeSegment(value: string | undefined, fallback: string, label: string): string { // Replace invalid filename characters with underscores // Windows invalid chars: < > : " / \ | ? * @@ -468,34 +534,68 @@ function getScopedConfigFileDefinition(id: string, key: string, scopeInput: Codi function getCurrentNodeEnv(): NodeJS.ProcessEnv { return { ...process.env, - PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter), + PATH: [getNodeBinDir(), getNvmNodeBinPaths(), process.env.PATH].filter(Boolean).join(delimiter), npm_node_execpath: process.execPath, } } +async function npmExecution(args: string[], env: NodeJS.ProcessEnv): Promise<{ command: string; args: string[] }> { + const bundledNpmCli = getNpmCliPath() + if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] } + + let npmBin: string | null = null + for (const command of [...new Set([getNpmBin(), 'npm'])]) { + const paths = await findCommandPaths(command, env) + if (paths[0]) { + npmBin = paths[0] + break + } + } + if (!npmBin) throw nodeEnvironmentMissingError() + + const npmCli = npmCliFromNpmBin(npmBin) + if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] } + + let nodeBin: string | null = null + for (const command of [...new Set([process.platform === 'win32' ? 'node.exe' : 'node', 'node'])]) { + const paths = await findCommandPaths(command, env) + if (paths[0]) { + nodeBin = paths[0] + break + } + } + if (!nodeBin) throw nodeEnvironmentMissingError() + + return commandExecution(npmBin, args) +} + async function runNpm(args: string[], options: { timeout?: number; env?: NodeJS.ProcessEnv } = {}) { - const npmCli = getNpmCliPath() - const command = npmCli ? process.execPath : getNpmBin() - const commandArgs = npmCli ? [npmCli, ...args] : args - return execFileAsync(command, commandArgs, { + const env = { + ...getCurrentNodeEnv(), + ...options.env, + } + const execution = await npmExecution(args, env) + return execFileAsync(execution.command, execution.args, { encoding: 'utf-8', timeout: options.timeout, windowsHide: true, maxBuffer: 10 * 1024 * 1024, - env: { - ...getCurrentNodeEnv(), - ...options.env, - }, + env, }) } function normalizeError(err: any): string { + if (isNodeEnvironmentMissingError(err)) return nodeEnvironmentMissingError().message const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' const message = stderr || stdout || err?.message || String(err) return message.split(/\r?\n/).filter(Boolean).slice(0, 4).join('\n') } +function normalizeErrorCode(err: any): string | undefined { + return isNodeEnvironmentMissingError(err) ? NODE_ENVIRONMENT_MISSING_CODE : undefined +} + async function findCommandPaths(command: string, env: NodeJS.ProcessEnv): Promise { try { const lookupCommand = process.platform === 'win32' ? 'where' : 'which' @@ -703,6 +803,7 @@ export async function installCodingAgent(id: string): Promise