mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: Stabilize desktop CLI launcher TTY startup
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
This commit is contained in:
parent
e1c2f54f14
commit
dc0307f4c1
@ -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"',
|
||||
)
|
||||
|
||||
@ -166,7 +166,7 @@ async function ensureDesktopCliLauncherInstalledImpl(): Promise<DesktopCliLaunch
|
||||
let lastError: string | null = null
|
||||
|
||||
try {
|
||||
await syncLauncherBinary(sourcePath, launcherPath)
|
||||
await syncLauncher(sourcePath, launcherPath)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@ -236,9 +236,14 @@ function resolveBundledSidecarSourcePath(): string | null {
|
||||
return launcher.command
|
||||
}
|
||||
|
||||
async function syncLauncherBinary(sourcePath: string, targetPath: string) {
|
||||
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
|
||||
}
|
||||
@ -262,6 +267,57 @@ async function syncLauncherBinary(sourcePath: string, targetPath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user