codev / src /server /services /desktopCliLauncherService.ts
chenbhao's picture
chore: h5 fix
101eacf
Raw
History Blame Contribute Delete
14.8 kB
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import {
chmod,
copyFile,
mkdir,
readFile,
rename,
stat,
unlink,
writeFile,
} from 'node:fs/promises'
import { homedir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { resolveClaudeCliLauncher } from '../../utils/desktopBundledCli.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { getShellConfigPaths } from '../../utils/shellConfig.js'
import { getUserBinDir } from '../../utils/xdg.js'
const DESKTOP_CLI_NAME = 'claude-haha'
const PATH_BLOCK_START = '# >>> Versper AI Claw PATH >>>'
const PATH_BLOCK_END = '# <<< Versper AI Claw PATH <<<'
const WINDOWS_PATH_TARGET = 'Windows User PATH'
const WINDOWS_USER_BIN_EXPR = '%USERPROFILE%\\.local\\bin'
export type DesktopCliLauncherStatus = {
supported: boolean
command: string
installed: boolean
launcherPath: string
binDir: string
pathConfigured: boolean
pathInCurrentShell: boolean
availableInNewTerminals: boolean
needsTerminalRestart: boolean
configTarget: string | null
lastError: string | null
}
let inFlightEnsure: Promise<DesktopCliLauncherStatus> | null = null
export function getDesktopCliCommandName(
platform: NodeJS.Platform = process.platform,
) {
return platform === 'win32' ? `${DESKTOP_CLI_NAME}.exe` : DESKTOP_CLI_NAME
}
export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env) {
return env.HOME || env.USERPROFILE || homedir()
}
export function isPathEntryPresent(
pathValue: string | undefined,
targetDir: string,
platform: NodeJS.Platform = process.platform,
homeDir: string = resolveHomeDir(),
) {
if (!pathValue) return false
if (platform === 'win32') {
const normalizedTarget = normalizeWindowsPathEntry(targetDir, homeDir)
return pathValue
.split(';')
.map((entry) => normalizeWindowsPathEntry(entry, homeDir))
.some((entry) => entry === normalizedTarget)
}
const normalizedTarget = resolve(targetDir)
return pathValue
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.some((entry) => {
try {
return resolve(entry) === normalizedTarget
} catch {
return false
}
})
}
export function upsertManagedPathBlock(
existingContent: string,
block: string,
): string {
const escapedStart = PATH_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedEnd = PATH_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, 'm')
const nextBlock = `${block.trimEnd()}\n`
if (pattern.test(existingContent)) {
return existingContent.replace(pattern, nextBlock)
}
const trimmed = existingContent.trimEnd()
if (!trimmed) {
return nextBlock
}
return `${trimmed}\n\n${nextBlock}`
}
export function buildManagedPathBlock(
shellType: 'zsh' | 'bash' | 'fish',
binDir: string,
homeDir: string = resolveHomeDir(),
) {
const defaultBinDir = join(homeDir, '.local', 'bin')
const pathExpr = resolve(binDir) === resolve(defaultBinDir) ? '$HOME/.local/bin' : binDir
if (shellType === 'fish') {
return [
PATH_BLOCK_START,
`if not contains "${pathExpr}" $PATH`,
` set -gx PATH "${pathExpr}" $PATH`,
'end',
PATH_BLOCK_END,
].join('\n')
}
return [
PATH_BLOCK_START,
`export PATH="${pathExpr}:$PATH"`,
PATH_BLOCK_END,
].join('\n')
}
export async function ensureDesktopCliLauncherInstalled(): Promise<DesktopCliLauncherStatus> {
if (inFlightEnsure) {
return inFlightEnsure
}
const promise = ensureDesktopCliLauncherInstalledImpl()
inFlightEnsure = promise
try {
return await promise
} finally {
if (inFlightEnsure === promise) {
inFlightEnsure = null
}
}
}
async function ensureDesktopCliLauncherInstalledImpl(): Promise<DesktopCliLauncherStatus> {
const homeDir = resolveHomeDir()
const binDir = getUserBinDir({ homedir: homeDir })
const launcherPath = join(binDir, getDesktopCliCommandName())
const sourcePath = resolveBundledSidecarSourcePath()
if (!sourcePath) {
return buildStatus({
supported: false,
launcherPath,
binDir,
command: DESKTOP_CLI_NAME,
installed: false,
pathConfigured: false,
pathInCurrentShell: isPathEntryPresent(process.env.PATH, binDir),
configTarget: null,
lastError: null,
})
}
let lastError: string | null = null
try {
await syncLauncher(sourcePath, launcherPath)
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
const installed = await isUsableLauncher(launcherPath)
const currentPathReady = isPathEntryPresent(process.env.PATH, binDir)
let pathConfigured = currentPathReady
let configTarget: string | null = null
try {
if (process.platform === 'win32') {
const windowsResult = await ensureWindowsUserPathConfigured(binDir, homeDir)
pathConfigured = currentPathReady || windowsResult.pathConfigured
configTarget = windowsResult.configTarget
lastError ||= windowsResult.lastError
} else {
const unixResult = await ensureUnixShellPathConfigured(binDir, homeDir)
pathConfigured = currentPathReady || unixResult.pathConfigured
configTarget = unixResult.configTarget
lastError ||= unixResult.lastError
}
} catch (error) {
lastError ||= error instanceof Error ? error.message : String(error)
}
return buildStatus({
supported: true,
command: DESKTOP_CLI_NAME,
launcherPath,
binDir,
installed,
pathConfigured,
pathInCurrentShell: currentPathReady,
configTarget,
lastError,
})
}
function buildStatus(
input: Omit<
DesktopCliLauncherStatus,
'availableInNewTerminals' | 'needsTerminalRestart'
>,
): DesktopCliLauncherStatus {
const availableInNewTerminals =
input.installed && (input.pathInCurrentShell || input.pathConfigured)
return {
...input,
availableInNewTerminals,
needsTerminalRestart:
availableInNewTerminals && !input.pathInCurrentShell,
}
}
function resolveBundledSidecarSourcePath(): string | null {
const launcher = resolveClaudeCliLauncher({
cliPath: process.env.CLAUDE_CLI_PATH,
execPath: process.execPath,
})
if (!launcher || launcher.kind !== 'sidecar') {
return null
}
return launcher.command
}
async function syncLauncher(sourcePath: string, targetPath: string) {
await mkdir(dirname(targetPath), { recursive: true })
if (process.platform !== 'win32') {
await syncUnixLauncherWrapper(sourcePath, targetPath)
return
}
if (await filesMatch(sourcePath, targetPath)) {
return
}
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
await copyFile(sourcePath, tempPath)
if (process.platform !== 'win32') {
await chmod(tempPath, 0o755)
}
try {
if (process.platform === 'win32') {
await replaceWindowsBinary(tempPath, targetPath)
} else {
await rename(tempPath, targetPath)
await chmod(targetPath, 0o755)
}
} finally {
await unlink(tempPath).catch(() => undefined)
}
}
async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) {
const wrapper = buildUnixLauncherWrapper(sourcePath)
const existing = await readFile(targetPath, 'utf8').catch(() => null)
if (existing === wrapper) {
await chmod(targetPath, 0o755)
return
}
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
await writeFile(tempPath, wrapper, { encoding: 'utf8', mode: 0o755 })
try {
await rename(tempPath, targetPath)
await chmod(targetPath, 0o755)
} finally {
await unlink(tempPath).catch(() => undefined)
}
}
function buildUnixLauncherWrapper(sourcePath: string) {
const quotedSource = shellSingleQuote(sourcePath)
const quotedAppRoot = shellSingleQuote(dirname(sourcePath))
return `#!/usr/bin/env bash
set -euo pipefail
SIDECAR=${quotedSource}
APP_ROOT=${quotedAppRoot}
if [[ ! -x "$SIDECAR" ]]; then
echo "claude-haha launcher could not find bundled sidecar: $SIDECAR" >&2
exit 127
fi
# Bun-compiled macOS sidecars can be suspended by job control while reading the
# controlling TTY directly. A nested PTY keeps the terminal foreground handoff
# stable for the interactive TUI; non-interactive commands keep direct exec
# semantics and exit codes.
if [[ "$(uname -s)" == "Darwin" && -t 0 && -t 1 && -x /usr/bin/script ]]; then
exec /usr/bin/script -q /dev/null "$SIDECAR" cli --app-root "$APP_ROOT" "$@"
fi
exec "$SIDECAR" cli --app-root "$APP_ROOT" "$@"
`
}
function shellSingleQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`
}
async function replaceWindowsBinary(tempPath: string, targetPath: string) {
try {
await unlink(targetPath)
} catch {
// noop
}
try {
await rename(tempPath, targetPath)
return
} catch {
// The existing executable may still be in use. Rename it away and retry.
}
const backupPath = `${targetPath}.old.${Date.now()}`
try {
await rename(targetPath, backupPath)
} catch {
// noop
}
await rename(tempPath, targetPath)
await unlink(backupPath).catch(() => undefined)
}
async function filesMatch(sourcePath: string, targetPath: string) {
try {
const [sourceStats, targetStats] = await Promise.all([
stat(sourcePath),
stat(targetPath),
])
if (!sourceStats.isFile() || !targetStats.isFile()) {
return false
}
if (sourceStats.size !== targetStats.size) {
return false
}
const [sourceHash, targetHash] = await Promise.all([
hashFile(sourcePath),
hashFile(targetPath),
])
return sourceHash === targetHash
} catch {
return false
}
}
async function hashFile(filePath: string) {
return await new Promise<string>((resolvePromise, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => resolvePromise(hash.digest('hex')))
})
}
async function isUsableLauncher(filePath: string) {
try {
const fileStats = await stat(filePath)
return fileStats.isFile() && fileStats.size > 0
} catch {
return false
}
}
async function ensureUnixShellPathConfigured(
binDir: string,
homeDir: string,
): Promise<{
pathConfigured: boolean
configTarget: string | null
lastError: string | null
}> {
const shellType = resolveShellType()
const configPaths = getShellConfigPaths({ env: process.env, homedir: homeDir })
const configTarget =
configPaths[shellType] ??
(process.platform === 'darwin' ? configPaths.zsh : configPaths.bash)
if (!configTarget) {
return {
pathConfigured: false,
configTarget: null,
lastError: 'Could not resolve a shell config file for PATH setup',
}
}
const block = buildManagedPathBlock(shellType, binDir, homeDir)
const existingContent = await readFile(configTarget, 'utf8').catch(() => '')
const nextContent = upsertManagedPathBlock(existingContent, block)
if (nextContent !== existingContent) {
await mkdir(dirname(configTarget), { recursive: true })
await writeFile(configTarget, nextContent, 'utf8')
}
return {
pathConfigured: true,
configTarget,
lastError: null,
}
}
async function ensureWindowsUserPathConfigured(
binDir: string,
homeDir: string,
): Promise<{
pathConfigured: boolean
configTarget: string
lastError: string | null
}> {
const userPath = await readWindowsUserPath()
if (isPathEntryPresent(userPath, binDir, 'win32', homeDir)) {
return {
pathConfigured: true,
configTarget: WINDOWS_PATH_TARGET,
lastError: null,
}
}
const script = [
`$bin = [Environment]::ExpandEnvironmentVariables('${WINDOWS_USER_BIN_EXPR}')`,
`$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')`,
`$segments = @()`,
`if ($userPath) { $segments = $userPath.Split(';') | Where-Object { $_ -and $_.Trim() -ne '' } }`,
`$normalized = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\').ToLowerInvariant() }`,
`if (-not ($normalized -contains $bin.TrimEnd('\\').ToLowerInvariant())) {`,
` $newPath = if ([string]::IsNullOrWhiteSpace($userPath)) { '${WINDOWS_USER_BIN_EXPR}' } else { '${WINDOWS_USER_BIN_EXPR};' + $userPath }`,
` [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')`,
` $signature = @'`,
`using System;`,
`using System.Runtime.InteropServices;`,
`public static class NativeMethods {`,
` [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]`,
` public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, IntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out IntPtr lpdwResult);`,
`}`,
`'@`,
` Add-Type -TypeDefinition $signature -ErrorAction SilentlyContinue | Out-Null`,
` $HWND_BROADCAST = [IntPtr]0xffff`,
` $WM_SETTINGCHANGE = 0x1A`,
` $SMTO_ABORTIFHUNG = 0x2`,
` $result = [IntPtr]::Zero`,
` [void][NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [IntPtr]::Zero, 'Environment', $SMTO_ABORTIFHUNG, 5000, [ref]$result)`,
`}`,
].join('\n')
const result = await execFileNoThrow(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ useCwd: false },
)
if (result.code !== 0) {
return {
pathConfigured: false,
configTarget: WINDOWS_PATH_TARGET,
lastError:
result.stderr.trim() ||
result.stdout.trim() ||
result.error ||
'Failed to update Windows user PATH',
}
}
return {
pathConfigured: true,
configTarget: WINDOWS_PATH_TARGET,
lastError: null,
}
}
function resolveShellType(): 'zsh' | 'bash' | 'fish' {
const shellPath = process.env.SHELL || ''
if (shellPath.includes('fish')) return 'fish'
if (shellPath.includes('bash')) return 'bash'
if (shellPath.includes('zsh')) return 'zsh'
return process.platform === 'darwin' ? 'zsh' : 'bash'
}
async function readWindowsUserPath() {
const result = await execFileNoThrow(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`[Environment]::GetEnvironmentVariable('Path', 'User')`,
],
{ useCwd: false },
)
if (result.code !== 0) {
return ''
}
return result.stdout.trim()
}
function normalizeWindowsPathEntry(entry: string, homeDir: string) {
return entry
.trim()
.replace(/^"+|"+$/g, '')
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/%HOMEDRIVE%%HOMEPATH%/gi, homeDir)
.replace(/\//g, '\\')
.replace(/\\+$/, '')
.toLowerCase()
}