cc-haha/src/server/services/desktopCliLauncherService.ts
程序员阿江(Relakkes) 5fea7033e3 Expose the bundled desktop CLI outside the app
The desktop app already shipped a bundled sidecar, but only desktop-managed
sessions could see it. This change installs a `claude-haha` launcher into the
user bin directory, wires PATH setup so new terminals can resolve it, and keeps
desktop installer sessions aligned on the same bundled sidecar resolution path.
The desktop install surface now also reports whether the launcher is ready or
still waiting on a terminal restart.

Constraint: The worktree already contains unrelated icon, docs, and UI changes, so this commit stages only the bundled CLI launcher slice
Rejected: Tell users to install the official Claude CLI separately | it breaks the desktop out-of-box install story
Rejected: Keep the bundled CLI reachable only inside desktop-managed shells | system terminals would still be unable to call the packaged runtime
Rejected: Symlink directly into the app bundle instead of copying to user bin | moving or replacing the app bundle would leave a stale launcher behind
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Proxy-backed non-Anthropic providers still depend on the desktop server; do not assume this launcher makes every provider fully standalone
Tested: bun test src/server/__tests__/desktop-cli-launcher.test.ts src/server/__tests__/settings.test.ts; bun test src/server/__tests__/conversation-service.test.ts; bun test src/utils/shell/bashProvider.test.ts; cd desktop && bun x vitest run sidecars/launcherRouting.test.ts src/components/settings/InstallCenter.test.tsx; cd desktop && bun run lint; cd desktop && bun run build; cargo check --manifest-path desktop/src-tauri/Cargo.toml
Not-tested: Manual packaged desktop app install plus real Terminal/iTerm/PowerShell invocation on fresh macOS and Windows machines
2026-04-23 12:12:49 +08:00

477 lines
13 KiB
TypeScript

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 = '# >>> Claude Code Haha PATH >>>'
const PATH_BLOCK_END = '# <<< Claude Code Haha 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 syncLauncherBinary(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 syncLauncherBinary(sourcePath: string, targetPath: string) {
await mkdir(dirname(targetPath), { recursive: true })
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 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()
}