fix node npm detection (#1163)
This commit is contained in:
@@ -24,6 +24,7 @@ export interface CodingAgentMutationResult extends CodingAgentsStatus {
|
||||
success: boolean
|
||||
tool: CodingAgentToolStatus
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface CodingAgentConfigFileContent {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface PreviewStatus {
|
||||
export interface PreviewActionResponse extends PreviewStatus {
|
||||
success: boolean
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
// Config-based model types
|
||||
|
||||
@@ -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<PreviewStatus & { success?: boolean; message?: string }>, successKey: string) {
|
||||
async function runAction(action: string, fn: () => Promise<PreviewStatus & { success?: boolean; message?: string; code?: string }>, 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 = ''
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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: "削除しました",
|
||||
|
||||
@@ -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: "삭제됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "刪除完成",
|
||||
|
||||
@@ -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: "删除完成",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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*<!doctype html/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
|
||||
throw new Error('Skill recommendations file was not found')
|
||||
}
|
||||
if (requestSeq === recommendationsRequestSeq) {
|
||||
recommendations.value = text
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 | undefined | null>): string {
|
||||
const seen = new Set<string>()
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
: 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<string> {
|
||||
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], {
|
||||
|
||||
@@ -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,17 +1064,7 @@ 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 logFd = openPreviewLogFile()
|
||||
appendPreviewActionLog(`spawn preview process: ${command} ${commandArgs.join(' ')}`)
|
||||
previewProcess = spawn(command, commandArgs, {
|
||||
cwd: getPreviewDir(),
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
windowsHide: true,
|
||||
env: {
|
||||
const env = {
|
||||
...getCurrentNodeEnv(),
|
||||
NODE_ENV: 'development',
|
||||
PORT: String(PREVIEW_BACKEND_PORT),
|
||||
@@ -995,7 +1076,16 @@ export async function startPreview(ctx: any) {
|
||||
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: ${execution.command} ${execution.args.join(' ')}`)
|
||||
previewProcess = spawn(execution.command, execution.args, {
|
||||
cwd: getPreviewDir(),
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
windowsHide: true,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiMode>(['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<string[]> {
|
||||
try {
|
||||
const lookupCommand = process.platform === 'win32' ? 'where' : 'which'
|
||||
@@ -703,6 +803,7 @@ export async function installCodingAgent(id: string): Promise<CodingAgentMutatio
|
||||
tool: status,
|
||||
tools: allStatus.tools,
|
||||
message: normalizeError(err),
|
||||
code: normalizeErrorCode(err),
|
||||
}
|
||||
} finally {
|
||||
installingTools.delete(tool.id)
|
||||
@@ -752,6 +853,7 @@ export async function deleteCodingAgent(id: string): Promise<CodingAgentMutation
|
||||
tool: status,
|
||||
tools: allStatus.tools,
|
||||
message: normalizeError(err),
|
||||
code: normalizeErrorCode(err),
|
||||
}
|
||||
} finally {
|
||||
deletingTools.delete(tool.id)
|
||||
|
||||
Reference in New Issue
Block a user