Skip to content

Commit b7fde72

Browse files
brandonkachenclaude
andcommitted
feat(cli): enable daemon in binary via self-spawning
**Problem**: Daemon was disabled in binary builds because the separate daemon script wasn't available in the compiled executable. **Solution**: Binary now spawns itself in daemon mode using a special --internal-daemon flag. This provides full terminal color polling in both dev and binary modes with no external dependencies. **Architecture**: - Created entry.tsx as new entry point that routes based on flag - Binary checks for --internal-daemon flag at startup - If present: runs daemon logic (OSC polling + socket server) - If absent: loads main CLI app (index.tsx) - Theme system spawns binary itself instead of looking for script file **Benefits**: ✅ Full theme detection in binary mode (terminal + IDE + OS) ✅ No external dependencies or file extraction needed ✅ Same codebase for dev and binary modes ✅ Clean process management with health checks **Changes**: - src/entry.tsx: New entry point with flag routing - src/utils/terminal-theme-daemon-runner.ts: Wrapper for daemon main - src/utils/terminal-theme-daemon.ts: Export runDaemonMain(), conditional auto-run - src/utils/theme-system.ts: Self-spawn logic for binary mode - scripts/build-binary.ts: Use entry.tsx as compilation entry point **Testing**: ✅ Binary compiles successfully (78MB) ✅ Main process spawns daemon child process ✅ Socket communication works ✅ Clean cleanup on exit ✅ All 20 unit tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 0fbef4c commit b7fde72

File tree

5 files changed

+114
-45
lines changed

5 files changed

+114
-45
lines changed

cli/scripts/build-binary.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ async function main() {
156156

157157
const buildArgs = [
158158
'build',
159-
'src/index.tsx',
159+
'src/entry.tsx',
160160
'--compile',
161161
`--target=${targetInfo.bunTarget}`,
162162
`--outfile=${outputFile}`,

cli/src/entry.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Entry point for the CLI binary
5+
* Checks --internal-daemon flag and routes to either daemon or main app
6+
*/
7+
8+
// Check if running in daemon mode (self-spawned for terminal theme polling)
9+
if (process.argv.includes('--internal-daemon')) {
10+
// Import and run daemon
11+
import('./utils/terminal-theme-daemon-runner').then(async (mod) => {
12+
try {
13+
await mod.runDaemon()
14+
// Daemon runs indefinitely, but if it exits:
15+
process.exit(0)
16+
} catch (err) {
17+
console.error('Daemon error:', err)
18+
process.exit(1)
19+
}
20+
})
21+
} else {
22+
// Normal app mode - import and run main app
23+
import('./index')
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Daemon runner - exports the daemon main function
3+
* This is called when the binary is spawned with --internal-daemon flag
4+
*/
5+
6+
// Import the daemon main function
7+
import { runDaemonMain } from './terminal-theme-daemon'
8+
9+
export async function runDaemon() {
10+
await runDaemonMain()
11+
}

cli/src/utils/terminal-theme-daemon.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ const IDLE_EXIT_MS = 15_000
1515
const HEALTH_CHECK_INTERVAL_MS = 10_000
1616

1717
// Socket configuration
18-
const SOCKET_PATH = process.env.SOCKET_PATH || '/tmp/codebuff-terminal-theme.sock'
18+
const SOCKET_PATH = process.env.SOCKET_PATH || (
19+
process.platform === 'win32'
20+
? `\\\\.\\pipe\\codebuff-terminal-theme-${process.pid}`
21+
: '/tmp/codebuff-terminal-theme.sock'
22+
)
1923
const PARENT_PID = process.env.PARENT_PID ? parseInt(process.env.PARENT_PID, 10) : null
2024

2125
// Protocol constants
@@ -84,7 +88,9 @@ function cleanup() {
8488
if (server) server.close()
8589
} catch {}
8690
try {
87-
if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH)
91+
if (process.platform !== 'win32') {
92+
if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH)
93+
}
8894
} catch {}
8995
if (pollInterval) {
9096
try { clearInterval(pollInterval) } catch {}
@@ -116,12 +122,14 @@ async function pollAndBroadcast() {
116122
}
117123
}
118124

119-
async function main() {
120-
// Clean up stale socket
121-
if (existsSync(SOCKET_PATH)) {
122-
try {
123-
unlinkSync(SOCKET_PATH)
124-
} catch {}
125+
export async function runDaemonMain() {
126+
// Clean up stale socket on Unix only; Windows named pipes are not filesystem entries
127+
if (process.platform !== 'win32') {
128+
if (existsSync(SOCKET_PATH)) {
129+
try {
130+
unlinkSync(SOCKET_PATH)
131+
} catch {}
132+
}
125133
}
126134

127135
// Create Unix domain socket server
@@ -164,11 +172,13 @@ async function main() {
164172
})
165173

166174
server.listen(SOCKET_PATH, () => {
167-
// Set restrictive permissions (owner read/write only)
168-
try {
169-
chmodSync(SOCKET_PATH, 0o600)
170-
} catch {
171-
// Best effort; some platforms may not support chmod on sockets
175+
// Set restrictive permissions (owner read/write only) on Unix domain sockets
176+
if (process.platform !== 'win32') {
177+
try {
178+
chmodSync(SOCKET_PATH, 0o600)
179+
} catch {
180+
// Best effort; some platforms may not support chmod on sockets
181+
}
172182
}
173183
})
174184

@@ -180,14 +190,20 @@ async function main() {
180190
pollAndBroadcast().catch(() => {})
181191
}, POLL_INTERVAL_MS)
182192
// Don't keep the event loop alive for the interval alone
183-
;(pollInterval as any)?.unref?.()
193+
const intervalTimer = pollInterval as any
194+
if (intervalTimer?.unref) {
195+
intervalTimer.unref()
196+
}
184197

185198
// Health check: verify parent process is still alive
186199
if (PARENT_PID) {
187200
healthCheckInterval = setInterval(() => {
188201
performHealthCheck()
189202
}, HEALTH_CHECK_INTERVAL_MS)
190-
;(healthCheckInterval as any)?.unref?.()
203+
const healthTimer = healthCheckInterval as any
204+
if (healthTimer?.unref) {
205+
healthTimer.unref()
206+
}
191207
}
192208

193209
// If no one connects, exit after idle period
@@ -205,4 +221,7 @@ process.on('exit', () => cleanup())
205221
process.on('uncaughtException', () => { try { cleanup() } finally { process.exit(1) } })
206222
process.on('unhandledRejection', () => { try { cleanup() } finally { process.exit(1) } })
207223

208-
main().catch(() => { try { cleanup() } finally { process.exit(1) } })
224+
// Only auto-run if executed directly (not imported)
225+
if (import.meta.url === `file://${process.argv[1]}`) {
226+
runDaemonMain().catch(() => { try { cleanup() } finally { process.exit(1) } })
227+
}

cli/src/utils/theme-system.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ function getSocketDir(): string {
282282
* Get socket path for daemon communication
283283
*/
284284
function getSocketPath(): string {
285+
// On Windows use a named pipe path: \\ . \pipe\<name>
286+
if (process.platform === 'win32') {
287+
// Keep name unique per-process to avoid collisions
288+
return `\\\\.\\pipe\\codebuff-terminal-theme-${process.pid}`
289+
}
290+
// Unix-like: use a filesystem socket under a secure runtime dir
285291
const dir = getSocketDir()
286292
return join(dir, `codebuff-terminal-theme-${process.pid}.sock`)
287293
}
@@ -454,47 +460,56 @@ const startTerminalColorPolling = () => {
454460
// Allow disabling polling via env
455461
if (themeWatchDisabled()) return
456462

457-
// In compiled binary builds, skip external daemon spawn (no filesystem)
458-
if (process.env.CODEBUFF_IS_BINARY === 'true') {
459-
logger.debug(
460-
{ source: 'theme', reason: 'compiled-binary' },
461-
'skip terminal polling in binary build',
462-
)
463-
return
464-
}
465-
466463
const supportsOSC = terminalLikelySupportsOSC()
467464
if (!supportsOSC) {
468465
logger.debug({ source: 'theme', reason: 'no-osc-support' }, 'skip terminal polling')
469466
return
470467
}
471468

472469
try {
473-
// Resolve compiled daemon in dist, fallback to TS in src during dev
474-
const jsPathRoot = join(__dirname, 'terminal-theme-daemon.js')
475-
const jsPathUtils = join(__dirname, 'utils', 'terminal-theme-daemon.js')
476-
const tsPath = join(__dirname, 'terminal-theme-daemon.ts')
477-
const daemonPath =
478-
(existsSync(jsPathRoot) && jsPathRoot) ||
479-
(existsSync(jsPathUtils) && jsPathUtils) ||
480-
(existsSync(tsPath) && tsPath) ||
481-
''
482-
483-
logger.debug(
484-
{ source: 'theme', daemonPath, exists: existsSync(daemonPath) },
485-
'resolved terminal theme daemon path',
486-
)
470+
// Determine the executable to spawn
471+
// In binary mode: spawn ourselves with --internal-daemon flag
472+
// In dev mode: spawn the TS file directly with bun/node
473+
let daemonExec: string
474+
let daemonArgs: string[]
475+
476+
if (process.env.CODEBUFF_IS_BINARY === 'true') {
477+
// Binary build: use the current executable
478+
daemonExec = process.execPath
479+
daemonArgs = ['--internal-daemon']
480+
logger.debug(
481+
{ source: 'theme', executable: daemonExec },
482+
'spawning daemon from binary (self-spawn)',
483+
)
484+
} else {
485+
// Dev mode: look for the daemon script
486+
const jsPathRoot = join(__dirname, 'terminal-theme-daemon.js')
487+
const jsPathUtils = join(__dirname, 'utils', 'terminal-theme-daemon.js')
488+
const tsPath = join(__dirname, 'terminal-theme-daemon.ts')
489+
const daemonPath =
490+
(existsSync(jsPathRoot) && jsPathRoot) ||
491+
(existsSync(jsPathUtils) && jsPathUtils) ||
492+
(existsSync(tsPath) && tsPath) ||
493+
''
494+
495+
if (!daemonPath) {
496+
logger.debug(
497+
{ source: 'theme' },
498+
'no daemon script available; skipping terminal polling',
499+
)
500+
return
501+
}
487502

488-
if (!daemonPath) {
503+
daemonExec = process.execPath
504+
daemonArgs = [daemonPath]
489505
logger.debug(
490-
{ source: 'theme' },
491-
'no daemon path available; skipping terminal polling',
506+
{ source: 'theme', daemonPath },
507+
'spawning daemon from script file',
492508
)
493-
return
494509
}
495510

496511
// Spawn completely detached with no stdio connection
497-
pollerProcess = spawn(process.execPath, [daemonPath], {
512+
pollerProcess = spawn(daemonExec, daemonArgs, {
498513
detached: true,
499514
stdio: 'ignore',
500515
env: {

0 commit comments

Comments
 (0)