refactor: extract unified shell env utilities for process spawning (#12857)

### What this PR does

Before this PR:
Shell environment refresh, executable lookup, and process spawning logic
was duplicated across `OpenClawService` and `PluginService`, each with
their own Windows `.cmd` handling, timeout management, and shell env
management.

After this PR:
Three reusable utilities centralize this logic in `process.ts`:
- `findExecutableInEnv()` — refresh cache + get shell env + find command
(cross-platform, with Windows `.cmd`/`.exe` fallback)
- `spawnWithEnv()` — spawn processes with proper Windows `.cmd` file
handling via `cmd.exe`
- `executeInEnv()` — execute command with captured output, timeout
support, and automatic shell env

`OpenClawService` and `PluginService` are refactored to use these
utilities, removing ~180 lines of duplicated code.

### Why we need it and why it was done in this way

The following tradeoffs were made:
- `checkGitAvailable` continues to use `findGitPath` (not
`findExecutableInEnv`) to preserve the `LOCALAPPDATA` git fallback on
Windows for per-user Git installations.

The following alternatives were considered:
- Merging `findCommandInShellEnv` and `findExecutable` into a single
function — rejected because they have different responsibilities
(cross-platform shell lookup vs. Windows-only filesystem/where.exe
fallback) and different sync/async signatures.

### Breaking changes

None. All changes are internal refactoring with no API or behavioral
changes.

### Special notes for your reviewer

- The `PluginService` refactoring actually improves behavior: the old
code used `findExecutable('git')` (Windows-only) and spawned without
passing the shell environment. The new code properly uses the shell env
for all platforms.
- `spawnWithEnv` defaults `stdio` to `'pipe'` to match the previous
behavior of the removed `spawnOpenClaw` method.
- The old `detached: false` option in `spawnOpenClaw` was the default
for `spawn`, so omitting it is not a behavioral change.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. You want a
user-guide update if it's a user facing feature.

### Release note

```release-note
NONE
```

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: dev <dev@cherry-ai.com>
This commit is contained in:
SuYao 2026-02-11 19:00:47 -08:00 committed by GitHub
parent b2e572aa14
commit df65d2a892
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 353 additions and 362 deletions

View File

@ -412,6 +412,7 @@ export enum IpcChannel {
OpenClaw_CheckNpmAvailable = 'openclaw:check-npm-available',
OpenClaw_CheckGitAvailable = 'openclaw:check-git-available',
OpenClaw_GetNodeDownloadUrl = 'openclaw:get-node-download-url',
OpenClaw_GetGitDownloadUrl = 'openclaw:get-git-download-url',
OpenClaw_Install = 'openclaw:install',
OpenClaw_Uninstall = 'openclaw:uninstall',
OpenClaw_InstallProgress = 'openclaw:install-progress',

View File

@ -1140,6 +1140,7 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.OpenClaw_CheckNpmAvailable, openClawService.checkNpmAvailable)
ipcMain.handle(IpcChannel.OpenClaw_CheckGitAvailable, checkGitAvailable)
ipcMain.handle(IpcChannel.OpenClaw_GetNodeDownloadUrl, openClawService.getNodeDownloadUrl)
ipcMain.handle(IpcChannel.OpenClaw_GetGitDownloadUrl, openClawService.getGitDownloadUrl)
ipcMain.handle(IpcChannel.OpenClaw_Install, openClawService.install)
ipcMain.handle(IpcChannel.OpenClaw_Uninstall, openClawService.uninstall)
ipcMain.handle(IpcChannel.OpenClaw_StartGateway, openClawService.startGateway)

View File

@ -8,8 +8,8 @@ import { exec } from '@expo/sudo-prompt'
import { loggerService } from '@logger'
import { isLinux, isMac, isWin } from '@main/constant'
import { isUserInChina } from '@main/utils/ipService'
import { findCommandInShellEnv, findExecutable, findGitPath } from '@main/utils/process'
import getShellEnv, { refreshShellEnvCache } from '@main/utils/shell-env'
import { findExecutableInEnv, spawnWithEnv } from '@main/utils/process'
import getShellEnv from '@main/utils/shell-env'
import { IpcChannel } from '@shared/IpcChannel'
import { hasAPIVersion, withoutTrailingSlash } from '@shared/utils'
import type { Model, Provider, ProviderType, VertexProvider } from '@types'
@ -26,7 +26,7 @@ const OPENCLAW_CONFIG_DIR = path.join(os.homedir(), '.openclaw')
const OPENCLAW_ORIGINAL_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json')
// Cherry Studio's isolated config (read/write) — OpenClaw reads the OPENCLAW_CONFIG_PATH env var to locate this
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.cherry.json')
const DEFAULT_GATEWAY_PORT = 18789
const DEFAULT_GATEWAY_PORT = 18790
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error'
@ -119,10 +119,15 @@ class OpenClawService {
private gatewayPort: number = DEFAULT_GATEWAY_PORT
private gatewayAuthToken: string = ''
private get gatewayUrl(): string {
return `ws://127.0.0.1:${this.gatewayPort}`
}
constructor() {
this.checkInstalled = this.checkInstalled.bind(this)
this.checkNpmAvailable = this.checkNpmAvailable.bind(this)
this.getNodeDownloadUrl = this.getNodeDownloadUrl.bind(this)
this.getGitDownloadUrl = this.getGitDownloadUrl.bind(this)
this.install = this.install.bind(this)
this.uninstall = this.uninstall.bind(this)
this.startGateway = this.startGateway.bind(this)
@ -151,40 +156,17 @@ class OpenClawService {
* Refreshes shell env cache to detect newly installed Node.js
*/
public async checkNpmAvailable(): Promise<{ available: boolean; path: string | null }> {
// Refresh cache to detect newly installed Node.js without app restart
refreshShellEnvCache()
const shellEnv = await getShellEnv()
// Log PATH for debugging npm detection issues
const envPath = shellEnv.PATH || shellEnv.Path || ''
logger.debug(`Checking npm availability with PATH: ${envPath}`)
let npmPath: string | null = null
if (isWin) {
// On Windows, npm is a .cmd file, use findExecutable with .cmd extension
// Note: findExecutable is synchronous (uses execFileSync) which is acceptable here
// since we're already in an async context and Windows file ops are fast
// Pass shellEnv so where.exe uses the refreshed PATH instead of stale process.env
npmPath = findExecutable('npm', {
extensions: ['.cmd', '.exe'],
commonPaths: [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'npm.cmd'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'nodejs', 'npm.cmd'),
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'npm.cmd')
],
env: shellEnv
})
} else {
npmPath = await findCommandInShellEnv('npm', shellEnv)
}
const { path: npmPath } = await findExecutableInEnv('npm', {
extensions: ['.cmd', '.exe'],
commonPaths: [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'npm.cmd'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'nodejs', 'npm.cmd'),
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'npm.cmd')
]
})
logger.debug(`npm check result: ${npmPath ? `found at ${npmPath}` : 'not found'}`)
return {
available: npmPath !== null,
path: npmPath
}
return { available: npmPath !== null, path: npmPath }
}
/**
@ -206,6 +188,23 @@ class OpenClawService {
return 'https://nodejs.org/en/download'
}
/**
* Get Git download URL based on current OS and architecture
*/
public getGitDownloadUrl(): string {
const version = '2.53.0'
if (isWin) {
const winArch = process.arch === 'arm64' ? 'arm64' : '64-bit'
return `https://github.com/git-for-windows/git/releases/download/v${version}.windows.1/Git-${version}-${winArch}.exe`
} else if (isMac) {
return 'https://git-scm.com/download/mac'
} else if (isLinux) {
return 'https://git-scm.com/download/linux'
}
return 'https://git-scm.com/downloads'
}
/**
* Send install progress to renderer
*/
@ -221,31 +220,35 @@ class OpenClawService {
public async install(): Promise<{ success: boolean; message: string }> {
const inChina = await isUserInChina()
// For China users, install the Chinese-specific package with Chinese npm mirror
// For other users, install the standard openclaw package
const packageName = inChina ? '@qingchencloud/openclaw-zh@latest' : 'openclaw@latest'
const registryArg = inChina ? `--registry=${NPM_MIRROR_CN}` : ''
// Find npm path for use in sudo command (sudo runs in clean environment without user PATH)
const npmCheck = await this.checkNpmAvailable()
const npmPath = npmCheck.path || 'npm'
const npmDir = path.dirname(npmPath)
const npmCommand = `npm install -g ${packageName} ${registryArg}`.trim()
const npmCommandWithPath = `PATH=${npmDir}:$PATH ${npmPath} install -g ${packageName} ${registryArg}`.trim()
logger.info(`Installing OpenClaw with command: ${npmCommand}`)
this.sendInstallProgress(`Running: ${npmCommand}`)
const npmArgs = ['install', '-g', packageName]
if (registryArg) npmArgs.push(registryArg)
// Keep the command string for logging and sudo retry
const npmCommand = `"${npmPath}" install -g ${packageName} ${registryArg}`.trim()
logger.info(`Installing OpenClaw with command: ${npmPath} ${npmArgs.join(' ')}`)
this.sendInstallProgress(`Running: ${npmPath} ${npmArgs.join(' ')}`)
// Use shell environment to find npm (handles GUI launch where process.env.PATH is limited)
const shellEnv = await getShellEnv()
const pathKey = isWin ? (shellEnv.Path !== undefined ? 'Path' : 'PATH') : 'PATH'
let currentPath = shellEnv[pathKey] || ''
// Try to find git and ensure it's in PATH for npm (some packages require git during install)
// The frontend already gates on git availability with a localized UI; this is best-effort PATH augmentation
const gitPath = await findGitPath(shellEnv)
const nodeDir = path.dirname(npmPath)
if (!currentPath.split(path.delimiter).includes(nodeDir)) {
currentPath = `${nodeDir}${path.delimiter}${currentPath}`
shellEnv[pathKey] = currentPath
logger.info(`Added Node.js directory to PATH: ${nodeDir}`)
}
const { path: gitPath } = await findExecutableInEnv('git', { refreshCache: false })
if (gitPath) {
const gitDir = path.dirname(gitPath)
const pathKey = isWin ? (shellEnv.Path !== undefined ? 'Path' : 'PATH') : 'PATH'
const currentPath = shellEnv[pathKey] || ''
if (!currentPath.split(path.delimiter).includes(gitDir)) {
shellEnv[pathKey] = `${gitDir}${path.delimiter}${currentPath}`
logger.info(`Added git directory to PATH: ${gitDir}`)
@ -256,19 +259,13 @@ class OpenClawService {
return new Promise((resolve) => {
try {
let installProcess: ChildProcess
if (isWin) {
installProcess = spawn('cmd.exe', ['/c', npmCommand], {
stdio: 'pipe',
env: shellEnv
})
} else {
installProcess = spawn('/bin/bash', ['-c', npmCommand], {
stdio: 'pipe',
env: shellEnv
})
}
// On Windows, pre-quote the path so cmd.exe /d /s /c handles spaces correctly
const spawnCmd = isWin ? `"${npmPath}"` : npmPath
const installProcess = spawn(spawnCmd, npmArgs, {
stdio: 'pipe',
env: shellEnv,
shell: true
})
let stderr = ''
@ -311,7 +308,7 @@ class OpenClawService {
this.sendInstallProgress('Permission denied. Requesting administrator access...')
// Use full npm path since sudo runs in clean environment without user PATH
exec(npmCommandWithPath, { name: 'Cherry Studio' }, (error, stdout) => {
exec(npmCommand, { name: 'Cherry Studio' }, (error, stdout) => {
if (error) {
logger.error('Sudo install failed:', error)
this.sendInstallProgress(`Installation failed: ${error.message}`, 'error')
@ -353,36 +350,27 @@ class OpenClawService {
await this.stopGateway()
}
// Find npm path for use in sudo command (sudo runs in clean environment without user PATH)
const npmCheck = await this.checkNpmAvailable()
const npmPath = npmCheck.path || 'npm'
const npmDir = path.dirname(npmPath)
// Uninstall both packages to handle both standard and Chinese editions
const npmCommand = 'npm uninstall -g openclaw @qingchencloud/openclaw-zh'
// Include PATH so that npm can find node (npm is a shell script that calls node)
const npmCommandWithPath = `PATH=${npmDir}:$PATH ${npmPath} uninstall -g openclaw @qingchencloud/openclaw-zh`
logger.info(`Uninstalling OpenClaw with command: ${npmCommand}`)
this.sendInstallProgress(`Running: ${npmCommand}`)
const npmArgs = ['uninstall', '-g', 'openclaw', '@qingchencloud/openclaw-zh']
// Keep the command string for logging and sudo retry
const npmCommand = `"${npmPath}" uninstall -g openclaw @qingchencloud/openclaw-zh`
logger.info(`Uninstalling OpenClaw with command: ${npmPath} ${npmArgs.join(' ')}`)
this.sendInstallProgress(`Running: ${npmPath} ${npmArgs.join(' ')}`)
// Use shell environment to find npm (handles GUI launch where process.env.PATH is limited)
const shellEnv = await getShellEnv()
return new Promise((resolve) => {
try {
let uninstallProcess: ChildProcess
if (isWin) {
uninstallProcess = spawn('cmd.exe', ['/c', npmCommand], {
stdio: 'pipe',
env: shellEnv
})
} else {
uninstallProcess = spawn('/bin/bash', ['-c', npmCommand], {
stdio: 'pipe',
env: shellEnv
})
}
const spawnCmd = isWin ? `"${npmPath}"` : npmPath
const uninstallProcess = spawn(spawnCmd, npmArgs, {
stdio: 'pipe',
env: shellEnv,
shell: true
})
let stderr = ''
@ -422,8 +410,7 @@ class OpenClawService {
logger.info('Permission denied, retrying uninstall with sudo-prompt...')
this.sendInstallProgress('Permission denied. Requesting administrator access...')
// Use full npm path since sudo runs in clean environment without user PATH
exec(npmCommandWithPath, { name: 'Cherry Studio' }, (error, stdout) => {
exec(npmCommand, { name: 'Cherry Studio' }, (error, stdout) => {
if (error) {
logger.error('Sudo uninstall failed:', error)
this.sendInstallProgress(`Uninstallation failed: ${error.message}`, 'error')
@ -513,7 +500,9 @@ class OpenClawService {
let processExited = false
logger.info(`Spawning gateway process: ${openclawPath} gateway --port ${this.gatewayPort}`)
this.gatewayProcess = this.spawnOpenClaw(openclawPath, ['gateway', '--port', String(this.gatewayPort)], shellEnv)
this.gatewayProcess = spawnWithEnv(openclawPath, ['gateway', '--port', String(this.gatewayPort)], {
env: { ...shellEnv, OPENCLAW_CONFIG_PATH }
})
logger.info(`Gateway process spawned with pid: ${this.gatewayProcess.pid}`)
// Monitor stderr for error messages
@ -571,10 +560,14 @@ class OpenClawService {
return
}
// Fallback: also check if port is open (in case CLI status check is unreliable)
const portOpen = await this.checkPortOpen(this.gatewayPort)
if (portOpen) {
logger.info(`Gateway port ${this.gatewayPort} is open (verified via port check after ${pollCount} polls)`)
await new Promise((r) => setTimeout(r, 2000))
if (processExited) {
logger.info(`Spawned process exited but port ${this.gatewayPort} is open — reusing existing gateway`)
return
}
logger.info(`Gateway port ${this.gatewayPort} is open and process alive (verified after ${pollCount} polls)`)
return
}
}
@ -589,13 +582,7 @@ class OpenClawService {
*/
public async stopGateway(): Promise<{ success: boolean; message: string }> {
try {
// If we have a process reference, kill it first
if (this.gatewayProcess) {
await this.killProcess(this.gatewayProcess)
this.gatewayProcess = null
}
// Use CLI to stop any gateway (handles external processes too)
// Use CLI to stop gateway (handles graceful shutdown, lock/PID cleanup)
const openclawPath = await this.findOpenClawBinary()
if (openclawPath) {
const shellEnv = await getShellEnv()
@ -604,14 +591,25 @@ class OpenClawService {
// Verify stop was successful
const stillRunning = await this.checkGatewayStatus(openclawPath, shellEnv)
if (stillRunning) {
logger.warn('Gateway still running after stop attempt')
return {
success: false,
message: 'Failed to stop gateway. Try running: openclaw gateway stop'
// CLI stop failed — fall back to killing the process directly
if (this.gatewayProcess) {
logger.warn('Gateway still running after CLI stop, falling back to kill')
await this.killProcess(this.gatewayProcess)
} else {
logger.warn('Gateway still running after stop attempt and no process reference')
return {
success: false,
message: 'Failed to stop gateway. Try running: openclaw gateway stop'
}
}
}
} else if (this.gatewayProcess) {
// No CLI binary found — kill directly as last resort
logger.warn('OpenClaw binary not found, killing process directly')
await this.killProcess(this.gatewayProcess)
}
this.gatewayProcess = null
this.gatewayStatus = 'stopped'
logger.info('Gateway stopped')
return { success: true, message: 'Gateway stopped successfully' }
@ -646,7 +644,9 @@ class OpenClawService {
*/
private async runGatewayStop(openclawPath: string, env: Record<string, string>): Promise<void> {
return new Promise((resolve) => {
const proc = this.spawnOpenClaw(openclawPath, ['gateway', 'stop'], env)
const proc = spawnWithEnv(openclawPath, ['gateway', 'stop', '--url', this.gatewayUrl], {
env: { ...env, OPENCLAW_CONFIG_PATH }
})
const timeout = setTimeout(() => {
proc.kill('SIGKILL')
@ -892,35 +892,21 @@ class OpenClawService {
* On Windows, npm global packages create .cmd wrapper scripts, not .exe files
*/
private async findOpenClawBinary(): Promise<string | null> {
// Refresh cache to detect newly installed OpenClaw without app restart
refreshShellEnvCache()
const home = os.homedir()
// Try PATH lookup in user's login shell environment (best for npm global installs)
const shellEnv = await getShellEnv()
const binaryPath = await findCommandInShellEnv('openclaw', shellEnv)
// Use unified lookup: refresh cache + shell env + findCommandInShellEnv + Windows .cmd fallback
const { path: binaryPath } = await findExecutableInEnv('openclaw', {
extensions: ['.cmd', '.exe'],
commonPaths: isWin ? [path.join(home, 'AppData', 'Roaming', 'npm', 'openclaw.cmd')] : []
})
if (binaryPath) {
return binaryPath
}
// On Windows, npm global installs create .cmd files, not .exe files
// findCommandInShellEnv only accepts .exe, so we need to search for .cmd/.bat or files without extension
if (isWin) {
const cmdPath = await this.findNpmGlobalCmd('openclaw', shellEnv)
if (cmdPath) {
return cmdPath
}
}
// Check common locations as fallback
// Check common filesystem locations as fallback
const binaryName = isWin ? 'openclaw.exe' : 'openclaw'
const home = os.homedir()
const possiblePaths = isWin
? [
path.join(home, 'AppData', 'Local', 'openclaw', binaryName),
path.join(home, '.openclaw', 'bin', binaryName),
// Also check for .cmd in npm global locations
path.join(home, 'AppData', 'Roaming', 'npm', 'openclaw.cmd')
]
? [path.join(home, 'AppData', 'Local', 'openclaw', binaryName), path.join(home, '.openclaw', 'bin', binaryName)]
: [
path.join(home, '.openclaw', 'bin', binaryName),
path.join(home, '.local', 'bin', binaryName),
@ -938,96 +924,33 @@ class OpenClawService {
return null
}
/**
* Find npm global command on Windows using 'where' command
* Accepts .cmd files (npm global wrappers) and files without extension
* Uses shell environment to find commands in paths set by nvm, etc.
*/
private async findNpmGlobalCmd(command: string, shellEnv: Record<string, string>): Promise<string | null> {
return new Promise((resolve) => {
const child = spawn('where', [command], {
env: shellEnv,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const timeoutId = setTimeout(() => {
child.kill('SIGKILL')
resolve(null)
}, 5000)
child.stdout.on('data', (data) => {
output += data.toString()
})
child.on('close', (code) => {
clearTimeout(timeoutId)
if (code === 0 && output.trim()) {
const paths = output.trim().split(/\r?\n/)
// Prefer .cmd files (npm global wrappers), then accept files without .exe extension
// Skip .exe files as they should be found by findCommandInShellEnv
const cmdPath = paths.find((p) => p.toLowerCase().endsWith('.cmd'))
if (cmdPath) {
logger.info(`Found npm global command '${command}' at: ${cmdPath}`)
resolve(cmdPath)
return
}
// Accept files without extension (e.g., openclaw without .exe)
const noExtPath = paths.find((p) => !p.toLowerCase().endsWith('.exe'))
if (noExtPath) {
logger.info(`Found command '${command}' (no extension) at: ${noExtPath}`)
resolve(noExtPath)
return
}
}
resolve(null)
})
child.on('error', () => {
clearTimeout(timeoutId)
resolve(null)
})
})
}
/**
* Spawn OpenClaw process with proper Windows handling
* On Windows, .cmd files and npm shims (no extension) need to be executed via cmd.exe
*/
private spawnOpenClaw(openclawPath: string, args: string[], env: Record<string, string>): ChildProcess {
const lowerPath = openclawPath.toLowerCase()
// OpenClaw reads OPENCLAW_CONFIG_PATH env var to locate its config file.
// Set it to Cherry Studio's isolated config to avoid modifying the user's original openclaw.json.
const spawnEnv = { ...env, OPENCLAW_CONFIG_PATH: OPENCLAW_CONFIG_PATH }
// On Windows, use cmd.exe for .cmd files and files without .exe extension (npm shims)
if (isWin && !lowerPath.endsWith('.exe')) {
return spawn('cmd.exe', ['/c', openclawPath, ...args], {
detached: false,
stdio: 'pipe',
env: spawnEnv
})
}
return spawn(openclawPath, args, {
detached: false,
stdio: 'pipe',
env: spawnEnv
})
}
/**
* Check gateway status using `openclaw gateway status` command
* Returns true if gateway is running
*/
private async checkGatewayStatus(openclawPath: string, env: Record<string, string>): Promise<boolean> {
return new Promise((resolve) => {
const statusProcess = this.spawnOpenClaw(openclawPath, ['gateway', 'status'], env)
const statusProcess = spawnWithEnv(openclawPath, ['gateway', 'status', '--url', this.gatewayUrl], {
env: { ...env, OPENCLAW_CONFIG_PATH }
})
let stdout = ''
let resolved = false
const doResolve = (value: boolean) => {
if (resolved) return
resolved = true
resolve(value)
}
const timeoutId = setTimeout(() => {
logger.debug('Gateway status check timed out after 2s')
// On timeout, check stdout accumulated so far before giving up
const lowerStdout = stdout.toLowerCase()
const isRunning = lowerStdout.includes('listening')
logger.debug(`Gateway status check timed out after 10s, stdout indicates running: ${isRunning}`)
statusProcess.kill('SIGKILL')
resolve(false)
}, 2000)
doResolve(isRunning)
}, 10_000)
statusProcess.stdout?.on('data', (data) => {
stdout += data.toString()
@ -1036,14 +959,14 @@ class OpenClawService {
statusProcess.on('close', (code) => {
clearTimeout(timeoutId)
const lowerStdout = stdout.toLowerCase()
const isRunning = code === 0 && (lowerStdout.includes('listening') || lowerStdout.includes('running'))
const isRunning = (code === 0 || code === null) && lowerStdout.includes('listening')
logger.debug('Gateway status check result:', { code, stdout: stdout.trim(), isRunning })
resolve(isRunning)
doResolve(isRunning)
})
statusProcess.on('error', () => {
clearTimeout(timeoutId)
resolve(false)
doResolve(false)
})
})
}

View File

@ -1,4 +1,3 @@
import { spawn } from 'node:child_process'
import * as crypto from 'node:crypto'
import { loggerService } from '@logger'
@ -10,7 +9,7 @@ import {
parsePluginMetadata,
parseSkillMetadata
} from '@main/utils/markdownParser'
import { findExecutable } from '@main/utils/process'
import { executeInEnv, findExecutableInEnv } from '@main/utils/process'
import {
type GetAgentResponse,
type InstalledPlugin,
@ -492,57 +491,35 @@ export class PluginService {
}
private async cloneRepository(repoUrl: string, destDir: string): Promise<void> {
const gitCommand = findExecutable('git') ?? 'git'
const branch = await this.resolveDefaultBranch(gitCommand, repoUrl)
const { path: gitPath, env } = await findExecutableInEnv('git')
const gitCommand = gitPath ?? 'git'
const branch = await this.resolveDefaultBranch(gitCommand, repoUrl, env)
if (branch) {
await this.executeCommand(gitCommand, ['clone', '--depth', '1', '--branch', branch, '--', repoUrl, destDir])
await executeInEnv(gitCommand, ['clone', '--depth', '1', '--branch', branch, '--', repoUrl, destDir], { env })
return
}
try {
await this.executeCommand(gitCommand, ['clone', '--depth', '1', '--', repoUrl, destDir])
await executeInEnv(gitCommand, ['clone', '--depth', '1', '--', repoUrl, destDir], { env })
} catch (error: unknown) {
logger.warn('Default clone failed, retrying with master branch', {
repoUrl,
error: error instanceof Error ? error.message : String(error)
})
await this.executeCommand(gitCommand, ['clone', '--depth', '1', '--branch', 'master', '--', repoUrl, destDir])
await executeInEnv(gitCommand, ['clone', '--depth', '1', '--branch', 'master', '--', repoUrl, destDir], { env })
}
}
/**
* Execute a command and optionally capture its output
*/
private executeCommand(command: string, args: string[], options?: { capture?: boolean }): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(command, args, { stdio: 'pipe' })
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString()
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve(options?.capture ? stdout : '')
} else {
reject(new Error(stderr || `Command failed with code ${code}`))
}
})
})
}
private async resolveDefaultBranch(command: string, repoUrl: string): Promise<string | null> {
private async resolveDefaultBranch(
command: string,
repoUrl: string,
env?: Record<string, string>
): Promise<string | null> {
try {
const output = await this.executeCommand(command, ['ls-remote', '--symref', '--', repoUrl, 'HEAD'], {
capture: true
const output = await executeInEnv(command, ['ls-remote', '--symref', '--', repoUrl, 'HEAD'], {
capture: true,
env
})
const match = output.match(/ref: refs\/heads\/([^\s]+)/)
return match?.[1] ?? null

View File

@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { execFileSync, spawn } from 'child_process'
import { type ChildProcess, execFileSync, spawn, type SpawnOptions } from 'child_process'
import fs from 'fs'
import os from 'os'
import path from 'path'
@ -228,13 +228,10 @@ export function findExecutable(name: string, options?: FindExecutableOptions): s
const commonPaths = options?.commonPaths ?? []
// Special handling for git - check common installation paths first
// Uses getCommonGitRoots() which includes ProgramFiles, ProgramFiles(x86), and LOCALAPPDATA
if (name === 'git') {
const defaultGitPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')
]
for (const gitPath of defaultGitPaths) {
for (const root of getCommonGitRoots()) {
const gitPath = path.join(root, 'cmd', 'git.exe')
if (fs.existsSync(gitPath)) {
logger.debug(`Found ${name} at common path`, { path: gitPath })
return gitPath
@ -299,9 +296,127 @@ export function findExecutable(name: string, options?: FindExecutableOptions): s
}
}
// ============================================================================
// Unified Shell Environment Utilities
// ============================================================================
/**
* Find an executable with automatic shell environment refresh.
* Handles the full "refresh cache + get shell env + find command" flow.
* Cross-platform: uses findCommandInShellEnv first, falls back to findExecutable on Windows.
*
* @returns Both the found path and the shell env (callers often need the env for spawn)
*/
export async function findExecutableInEnv(
name: string,
options?: {
/** Whether to refresh the shell env cache first (default: true) */
refreshCache?: boolean
/** Windows-only: file extensions to accept in findExecutable fallback */
extensions?: string[]
/** Windows-only: common paths to check as filesystem fallback */
commonPaths?: string[]
}
): Promise<{ path: string | null; env: Record<string, string> }> {
if (options?.refreshCache !== false) {
refreshShellEnvCache()
}
const env = await getShellEnv()
// Cross-platform: try shell environment lookup first
const found = await findCommandInShellEnv(name, env)
if (found) {
return { path: found, env }
}
// Windows fallback: findExecutable handles .cmd/.exe filtering and security checks
if (isWin) {
const winPath = findExecutable(name, {
extensions: options?.extensions,
commonPaths: options?.commonPaths,
env
})
return { path: winPath, env }
}
return { path: null, env }
}
/**
* Spawn a process with proper Windows handling for .cmd files and npm shims.
* On Windows, .cmd files and files without .exe extension are executed via cmd.exe.
*/
export function spawnWithEnv(
command: string,
args: string[],
options: SpawnOptions & { env: Record<string, string> }
): ChildProcess {
if (isWin && !command.toLowerCase().endsWith('.exe')) {
return spawn('cmd.exe', ['/c', command, ...args], { ...options, stdio: options.stdio ?? 'pipe' })
}
return spawn(command, args, { ...options, stdio: options.stdio ?? 'pipe' })
}
/**
* Execute a command and return its output.
* Uses spawnWithEnv internally for proper Windows .cmd handling.
* If no env is provided, automatically uses the shell environment.
*/
export async function executeInEnv(
command: string,
args: string[],
options?: {
/** Capture and return stdout (default: false) */
capture?: boolean
/** Environment variables (defaults to getShellEnv()) */
env?: Record<string, string>
/** Timeout in milliseconds */
timeout?: number
}
): Promise<string> {
const env = options?.env ?? (await getShellEnv())
return new Promise<string>((resolve, reject) => {
const child = spawnWithEnv(command, args, { env })
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString()
})
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (options?.timeout) {
timeoutId = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Command timed out after ${options.timeout}ms`))
}, options.timeout)
}
child.on('error', (err) => {
if (timeoutId) clearTimeout(timeoutId)
reject(err)
})
child.on('close', (code) => {
if (timeoutId) clearTimeout(timeoutId)
if (code === 0) {
resolve(options?.capture ? stdout : '')
} else {
reject(new Error(stderr || `Command failed with code ${code}`))
}
})
})
}
/**
* Common Git installation root directories on Windows
* Used by both findGit() and findGitBash() to check fallback paths
* Used by findExecutable() (git special case) and findGitBash() to check fallback paths
*/
function getCommonGitRoots(): string[] {
return [
@ -311,62 +426,15 @@ function getCommonGitRoots(): string[] {
]
}
/**
* Find git executable path in the given shell environment
* Cross-platform: uses findGit on Windows, findCommandInShellEnv on Unix
* @param shellEnv - The shell environment from getShellEnv()
* @returns Full path to git executable or null if not found
*/
export async function findGitPath(shellEnv: Record<string, string>): Promise<string | null> {
return isWin ? findGit(shellEnv) : await findCommandInShellEnv('git', shellEnv)
}
/**
* Find git.exe on Windows
* Checks PATH, common install paths, and LOCALAPPDATA
* @returns Full path to git.exe or null if not found
*/
export function findGit(env?: Record<string, string>): string | null {
if (!isWin) {
return null
}
// 1. Find git.exe via findExecutable (checks PATH + common Git install paths)
const gitPath = findExecutable('git', env ? { env } : undefined)
if (gitPath) {
return gitPath
}
// 2. Fallback: check common Git installation paths directly
for (const root of getCommonGitRoots()) {
const fullPath = path.join(root, 'cmd', 'git.exe')
if (fs.existsSync(fullPath)) {
logger.debug('Found git.exe at common path', { path: fullPath })
return fullPath
}
}
logger.debug('git.exe not found - checked PATH and common paths')
return null
}
/**
* Check if git is available in the user's environment
* Refreshes shell env cache to detect newly installed Git
* @returns Object with availability status and path to git executable
*/
export async function checkGitAvailable(): Promise<{ available: boolean; path: string | null }> {
refreshShellEnvCache()
const shellEnv = await getShellEnv()
const gitPath = await findGitPath(shellEnv)
const { path: gitPath } = await findExecutableInEnv('git')
logger.debug(`git check result: ${gitPath ? `found at ${gitPath}` : 'not found'}`)
return {
available: gitPath !== null,
path: gitPath
}
return { available: gitPath !== null, path: gitPath }
}
/**

View File

@ -676,6 +676,7 @@ const api = {
checkGitAvailable: (): Promise<{ available: boolean; path: string | null }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_CheckGitAvailable),
getNodeDownloadUrl: (): Promise<string> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetNodeDownloadUrl),
getGitDownloadUrl: (): Promise<string> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetGitDownloadUrl),
install: (): Promise<{ success: boolean; message: string }> => ipcRenderer.invoke(IpcChannel.OpenClaw_Install),
uninstall: (): Promise<{ success: boolean; message: string }> => ipcRenderer.invoke(IpcChannel.OpenClaw_Uninstall),
startGateway: (port?: number): Promise<{ success: boolean; message: string }> =>

View File

@ -88,6 +88,7 @@ const OpenClawPage: FC = () => {
const [npmMissing, setNpmMissing] = useState(false)
const [gitMissing, setGitMissing] = useState(false)
const [nodeDownloadUrl, setNodeDownloadUrl] = useState<string>('https://nodejs.org/')
const [gitDownloadUrl, setGitDownloadUrl] = useState<string>('https://git-scm.com/downloads')
// Fetch Node.js download URL and poll npm availability when npmMissing is shown
useEffect(() => {
@ -114,10 +115,16 @@ const OpenClawPage: FC = () => {
return () => clearInterval(pollInterval)
}, [npmMissing])
// Poll git availability when gitMissing is shown
// Fetch Git download URL and poll git availability when gitMissing is shown
useEffect(() => {
if (!gitMissing) return
// Fetch the download URL from main process
window.api.openclaw
.getGitDownloadUrl()
.then(setGitDownloadUrl)
.catch(() => {})
const pollInterval = setInterval(async () => {
try {
const gitCheck = await window.api.openclaw.checkGitAvailable()
@ -436,8 +443,9 @@ const OpenClawPage: FC = () => {
)
const renderNotInstalledContent = () => (
<div id="content-container" className="flex flex-1 overflow-y-auto py-5">
<div className="mx-auto min-h-fit w-130">
<div id="content-container" className="flex flex-1 flex-col overflow-y-auto py-5">
<div className="flex-1" />
<div className="mx-auto min-h-fit w-130 shrink-0">
<Result
icon={<Avatar src={OpenClawLogo} size={64} shape="square" style={{ borderRadius: 12 }} />}
title={t('openclaw.not_installed.title')}
@ -461,60 +469,60 @@ const OpenClawPage: FC = () => {
</Space>
}
/>
{npmMissing && (
<Alert
message={t('openclaw.npm_missing.title')}
description={
<div>
<p>{t('openclaw.npm_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(nodeDownloadUrl, '_blank')}>
{t('openclaw.npm_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.npm_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setNpmMissing(false)}
className="mt-4 rounded-lg!"
style={{ width: 580, marginLeft: -30 }}
/>
)}
{gitMissing && (
<Alert
message={t('openclaw.git_missing.title')}
description={
<div>
<p>{t('openclaw.git_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open('https://git-scm.com/downloads', '_blank')}>
{t('openclaw.git_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.git_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setGitMissing(false)}
className="mt-4 rounded-lg!"
style={{ width: 580, marginLeft: -30 }}
/>
)}
<div className="mt-4 space-y-3" style={{ width: 580, marginLeft: -30 }}>
{npmMissing && (
<Alert
message={t('openclaw.npm_missing.title')}
description={
<div>
<p>{t('openclaw.npm_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(nodeDownloadUrl, '_blank')}>
{t('openclaw.npm_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.npm_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setNpmMissing(false)}
className="rounded-lg!"
/>
)}
{gitMissing && (
<Alert
message={t('openclaw.git_missing.title')}
description={
<div>
<p>{t('openclaw.git_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(gitDownloadUrl, '_blank')}>
{t('openclaw.git_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.git_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setGitMissing(false)}
className="rounded-lg!"
/>
)}
</div>
{installError && (
<Alert
message={installError}
@ -527,6 +535,7 @@ const OpenClawPage: FC = () => {
{showLogs && installLogs.length > 0 && renderLogContainer()}
</div>
<div className="flex-1" />
</div>
)

View File

@ -86,7 +86,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 196,
version: 197,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@ -3219,6 +3219,17 @@ const migrateConfig = {
logger.error('migrate 196 error', error as Error)
return state
}
},
'197': (state: RootState) => {
try {
if (state.openclaw.gatewayPort === 18789) {
state.openclaw.gatewayPort = 18790
}
return state
} catch (error) {
logger.error('migrate 197 error', error as Error)
return state
}
}
}

View File

@ -27,7 +27,7 @@ export interface OpenClawState {
export const initialState: OpenClawState = {
gatewayStatus: 'stopped',
gatewayPort: 18789,
gatewayPort: 18790,
channels: [],
lastHealthCheck: null,
selectedModelUniqId: null