mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
The desktop app exposed the bundled sidecar directly as the user shell command, which could be suspended by macOS job control when the interactive TUI read from the controlling terminal. Unix installs now write a lightweight launcher wrapper, using a nested PTY on macOS interactive terminals while preserving direct exec behavior for non-interactive commands and Windows binaries. Constraint: Bundled desktop sidecars must remain usable from user terminals without requiring a separate CLI installation Rejected: Copy the Bun sidecar directly on macOS | interactive TUI startup can suspend with SIGTTIN Confidence: high Scope-risk: narrow Directive: Do not remove the macOS PTY wrapper without testing interactive TUI startup in a real terminal Tested: bun test src/server/__tests__/desktop-cli-launcher.test.ts Tested: bun run check:server Tested: claude-haha --version Tested: Real TTY interactive launch no longer printed suspended tty input
533 lines
14 KiB
TypeScript
533 lines
14 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 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()
|
|
}
|