mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-22 02:24:46 +08:00
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:
parent
b2e572aa14
commit
df65d2a892
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 }> =>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
|
||||
@ -86,7 +86,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 196,
|
||||
version: 197,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ export interface OpenClawState {
|
||||
|
||||
export const initialState: OpenClawState = {
|
||||
gatewayStatus: 'stopped',
|
||||
gatewayPort: 18789,
|
||||
gatewayPort: 18790,
|
||||
channels: [],
|
||||
lastHealthCheck: null,
|
||||
selectedModelUniqId: null
|
||||
|
||||
Loading…
Reference in New Issue
Block a user