diff --git a/src/server/__tests__/desktop-cli-launcher.test.ts b/src/server/__tests__/desktop-cli-launcher.test.ts index 5be027b7..6d53fde7 100644 --- a/src/server/__tests__/desktop-cli-launcher.test.ts +++ b/src/server/__tests__/desktop-cli-launcher.test.ts @@ -62,7 +62,7 @@ describe('ensureDesktopCliLauncherInstalled', () => { await rm(tempSourceDir, { recursive: true, force: true }) }) - unixOnly('copies the bundled sidecar into the user bin dir and configures PATH', async () => { + unixOnly('installs a launcher wrapper in the user bin dir and configures PATH', async () => { const sourcePath = join(tempSourceDir, 'claude-sidecar') await writeFile(sourcePath, '#!/bin/sh\necho desktop-sidecar\n', 'utf8') await chmod(sourcePath, 0o755) @@ -80,7 +80,10 @@ describe('ensureDesktopCliLauncherInstalled', () => { expect(status.needsTerminalRestart).toBe(true) expect(status.configTarget).toBe(shellConfigPath) - expect(await readFile(launcherPath, 'utf8')).toContain('desktop-sidecar') + const launcher = await readFile(launcherPath, 'utf8') + expect(launcher).toContain(`SIDECAR='${sourcePath}'`) + expect(launcher).toContain('cli --app-root "$APP_ROOT" "$@"') + expect(launcher).toContain('/usr/bin/script -q /dev/null') expect(await readFile(shellConfigPath, 'utf8')).toContain( 'export PATH="$HOME/.local/bin:$PATH"', ) diff --git a/src/server/services/desktopCliLauncherService.ts b/src/server/services/desktopCliLauncherService.ts index 8e1a9b99..e520d6d5 100644 --- a/src/server/services/desktopCliLauncherService.ts +++ b/src/server/services/desktopCliLauncherService.ts @@ -166,7 +166,7 @@ async function ensureDesktopCliLauncherInstalledImpl(): Promise 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)