goke 6.12.2 → 6.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Background daemon support for goke CLIs.
3
+ *
4
+ * Lets a command fork itself into a detached background process. The daemon
5
+ * is identified by CLI name + command name, with a PID file for lifecycle
6
+ * management. No HTTP server, no ports. Communication between client and
7
+ * daemon happens via shared files (config, auth, state) that the CLI
8
+ * already manages.
9
+ *
10
+ * How it works:
11
+ * 1. Command action checks `ctx.daemon.isDaemon` to branch behavior
12
+ * 2. Client calls `ctx.daemon.start()` which re-spawns the same CLI
13
+ * command with GOKE_DAEMON=1 env var, detached + unref'd
14
+ * 3. Daemon process runs the same action, but `isDaemon` is true
15
+ * 4. Daemon auto-exits after timeoutMs
16
+ * 5. PID file tracks the running daemon for stop/isRunning checks
17
+ *
18
+ * PID file safety:
19
+ * Each daemon writes a unique instance ID (random hex) into the PID file.
20
+ * A heartbeat timestamp is updated every 5 seconds. `isRunning()` checks
21
+ * both that the PID is alive AND the heartbeat is recent (< 15s). This
22
+ * prevents false positives from PID reuse after a daemon crash.
23
+ * Cleanup handlers only remove the PID file if its ID matches the current
24
+ * instance, so a new daemon won't have its file deleted by an old one's
25
+ * exit handler firing late.
26
+ */
27
+
28
+ import { spawn } from 'node:child_process'
29
+ import fs from 'node:fs'
30
+ import path from 'node:path'
31
+ import os from 'node:os'
32
+ import crypto from 'node:crypto'
33
+
34
+ // ─── PID file management ───
35
+
36
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
37
+
38
+ /**
39
+ * Build the PID file path for a daemon identified by CLI name + command name.
40
+ * Example: ~/.config/goke/daemons/playwriter--cloud-login.pid.json
41
+ */
42
+ function pidFilePath(cliName: string, commandName: string): string {
43
+ const safeName = `${cliName}--${commandName}`
44
+ .replace(/\s+/g, '-')
45
+ .replace(/[^a-zA-Z0-9_-]/g, '')
46
+ return path.join(DAEMON_DIR, `${safeName}.pid.json`)
47
+ }
48
+
49
+ interface PidFileData {
50
+ pid: number
51
+ /** Random hex string unique to this daemon instance. Prevents PID reuse confusion. */
52
+ id: string
53
+ startedAt: number
54
+ /** Updated every ~5s by the daemon. Stale heartbeat = daemon is dead. */
55
+ heartbeatAt: number
56
+ }
57
+
58
+ function readPidFile(filePath: string): PidFileData | null {
59
+ try {
60
+ const raw = fs.readFileSync(filePath, 'utf-8')
61
+ const data = JSON.parse(raw) as PidFileData
62
+ if (typeof data.pid !== 'number' || typeof data.id !== 'string') {
63
+ return null
64
+ }
65
+ return data
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ function writePidFile(filePath: string, data: PidFileData): void {
72
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
73
+ fs.writeFileSync(filePath, JSON.stringify(data), { encoding: 'utf-8', mode: 0o600 })
74
+ }
75
+
76
+ /**
77
+ * Remove a PID file only if it belongs to the given instance.
78
+ * Prevents a dying daemon from deleting a newer daemon's PID file.
79
+ */
80
+ function removePidFileIfOwned(filePath: string, instanceId: string): void {
81
+ try {
82
+ const current = readPidFile(filePath)
83
+ if (current && current.id === instanceId) {
84
+ fs.unlinkSync(filePath)
85
+ }
86
+ } catch {
87
+ // already gone or permission issue
88
+ }
89
+ }
90
+
91
+ function removePidFile(filePath: string): void {
92
+ try {
93
+ fs.unlinkSync(filePath)
94
+ } catch {
95
+ // already gone
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check if a process with the given PID is still alive.
101
+ * Uses signal 0 which doesn't actually send a signal, just checks existence.
102
+ */
103
+ function isProcessAlive(pid: number): boolean {
104
+ try {
105
+ process.kill(pid, 0)
106
+ return true
107
+ } catch {
108
+ return false
109
+ }
110
+ }
111
+
112
+ /** Max age in ms for a heartbeat to be considered fresh. */
113
+ const HEARTBEAT_STALE_MS = 15_000
114
+
115
+ /** Interval in ms between heartbeat updates. */
116
+ const HEARTBEAT_INTERVAL_MS = 5_000
117
+
118
+ /**
119
+ * Check if a PID file represents a daemon that is truly running.
120
+ * Requires both: PID alive AND heartbeat recent.
121
+ */
122
+ function isDaemonAlive(data: PidFileData): boolean {
123
+ if (!isProcessAlive(data.pid)) {
124
+ return false
125
+ }
126
+ // If heartbeat is stale, the process might be alive but not our daemon (PID reuse)
127
+ const heartbeatAge = Date.now() - data.heartbeatAt
128
+ return heartbeatAge < HEARTBEAT_STALE_MS
129
+ }
130
+
131
+ /**
132
+ * Kill a process by PID. Tries SIGTERM first, then SIGKILL after a delay.
133
+ */
134
+ async function killProcess(pid: number): Promise<void> {
135
+ if (!isProcessAlive(pid)) {
136
+ return
137
+ }
138
+
139
+ try {
140
+ process.kill(pid, 'SIGTERM')
141
+ } catch {
142
+ return // already dead
143
+ }
144
+
145
+ // Wait up to 3 seconds for graceful shutdown
146
+ const deadline = Date.now() + 3000
147
+ while (Date.now() < deadline) {
148
+ if (!isProcessAlive(pid)) {
149
+ return
150
+ }
151
+ await new Promise((r) => setTimeout(r, 100))
152
+ }
153
+
154
+ // Force kill if still alive
155
+ try {
156
+ process.kill(pid, 'SIGKILL')
157
+ } catch {
158
+ // already dead
159
+ }
160
+ }
161
+
162
+ // ─── Daemon context ───
163
+
164
+ const DAEMON_ENV_KEY = 'GOKE_DAEMON'
165
+ const DAEMON_TIMEOUT_ENV_KEY = 'GOKE_DAEMON_TIMEOUT'
166
+
167
+ interface DaemonStartOptions {
168
+ /** Auto-exit timeout in milliseconds. Default: 10 minutes. */
169
+ timeoutMs?: number
170
+ /** Extra environment variables passed to the daemon process. */
171
+ env?: Record<string, string>
172
+ }
173
+
174
+ /**
175
+ * Daemon context available on every command's execution context.
176
+ *
177
+ * Lets a command fork itself into a background process. The client side
178
+ * calls `start()` to spawn the daemon. The daemon side checks `isDaemon`
179
+ * and does its work. Communication happens via shared files.
180
+ *
181
+ * Use `forCommand()` to get a daemon context for a different command.
182
+ * This is useful for commands like `me` or `logout` that need to check
183
+ * or stop the `login` daemon.
184
+ */
185
+ class DaemonContext {
186
+ /** True when this process IS the background daemon. */
187
+ readonly isDaemon: boolean
188
+
189
+ #cliName: string
190
+ #commandName: string
191
+ #argv: string[]
192
+ #env: Record<string, string | undefined>
193
+ #pidFile: string
194
+ #instanceId: string | null = null
195
+ #heartbeatInterval: ReturnType<typeof setInterval> | null = null
196
+ #timeoutTimer: ReturnType<typeof setTimeout> | null = null
197
+
198
+ constructor(
199
+ cliName: string,
200
+ commandName: string,
201
+ argv: string[],
202
+ env?: Record<string, string | undefined>,
203
+ ) {
204
+ this.#cliName = cliName
205
+ this.#commandName = commandName
206
+ this.#argv = argv
207
+ this.#env = env ?? process.env
208
+ this.#pidFile = pidFilePath(cliName, commandName)
209
+ this.isDaemon = this.#env[DAEMON_ENV_KEY] === '1'
210
+
211
+ if (this.isDaemon) {
212
+ this.#setupDaemonProcess()
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get a daemon context for a different command on the same CLI.
218
+ * Useful for cross-command daemon management (e.g. `me` checking `login` daemon).
219
+ *
220
+ * The returned context is always in client mode (isDaemon=false) regardless
221
+ * of the current process's daemon state, since it represents a different command.
222
+ */
223
+ forCommand(commandName: string): DaemonContext {
224
+ // Strip daemon env vars so the returned context is always client mode,
225
+ // even when called from inside a daemon process. Without this, GOKE_DAEMON=1
226
+ // leaks through and the new context enters server mode, writing a PID file
227
+ // for the wrong command.
228
+ const env = { ...this.#env }
229
+ delete env[DAEMON_ENV_KEY]
230
+ delete env[DAEMON_TIMEOUT_ENV_KEY]
231
+ return new DaemonContext(this.#cliName, commandName, this.#argv, env)
232
+ }
233
+
234
+ /**
235
+ * Set up the daemon process: write PID file, start heartbeat,
236
+ * schedule auto-exit, handle signals and exit for graceful cleanup.
237
+ */
238
+ #setupDaemonProcess(): void {
239
+ this.#instanceId = crypto.randomBytes(8).toString('hex')
240
+ const now = Date.now()
241
+
242
+ const pidData: PidFileData = {
243
+ pid: process.pid,
244
+ id: this.#instanceId,
245
+ startedAt: now,
246
+ heartbeatAt: now,
247
+ }
248
+ writePidFile(this.#pidFile, pidData)
249
+
250
+ // Heartbeat: update the PID file timestamp every 5 seconds so clients
251
+ // can distinguish a live daemon from a stale PID (PID reuse scenario).
252
+ this.#heartbeatInterval = setInterval(() => {
253
+ const current = readPidFile(this.#pidFile)
254
+ if (current && current.id === this.#instanceId) {
255
+ current.heartbeatAt = Date.now()
256
+ writePidFile(this.#pidFile, current)
257
+ }
258
+ }, HEARTBEAT_INTERVAL_MS)
259
+ // Heartbeat should not keep the process alive on its own
260
+ this.#heartbeatInterval.unref()
261
+
262
+ const timeoutMs = Number(this.#env[DAEMON_TIMEOUT_ENV_KEY]) || 10 * 60 * 1000
263
+ this.#timeoutTimer = setTimeout(() => {
264
+ this.#cleanup()
265
+ process.exit(0)
266
+ }, timeoutMs)
267
+ // unref so the timer alone doesn't keep the process alive. The daemon
268
+ // stays alive as long as real work keeps the event loop open (polling
269
+ // timers, HTTP servers, etc.). When all work finishes, the process
270
+ // exits naturally. The timeout is a safety net, not a keepalive.
271
+ this.#timeoutTimer.unref()
272
+
273
+ const cleanupAndExit = () => {
274
+ this.#cleanup()
275
+ process.exit(0)
276
+ }
277
+
278
+ process.on('SIGTERM', cleanupAndExit)
279
+ process.on('SIGINT', cleanupAndExit)
280
+
281
+ // Clean up PID file on any exit (including uncaught exceptions, action throws, etc.)
282
+ // Only remove if the file still belongs to this instance.
283
+ process.on('exit', () => {
284
+ if (this.#instanceId) {
285
+ if (this.#heartbeatInterval) clearInterval(this.#heartbeatInterval)
286
+ removePidFileIfOwned(this.#pidFile, this.#instanceId)
287
+ }
288
+ })
289
+ }
290
+
291
+ #cleanup(): void {
292
+ if (this.#timeoutTimer) {
293
+ clearTimeout(this.#timeoutTimer)
294
+ this.#timeoutTimer = null
295
+ }
296
+ if (this.#heartbeatInterval) {
297
+ clearInterval(this.#heartbeatInterval)
298
+ this.#heartbeatInterval = null
299
+ }
300
+ if (this.#instanceId) {
301
+ removePidFileIfOwned(this.#pidFile, this.#instanceId)
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Spawn the current command as a detached background daemon process.
307
+ * Kills any existing daemon for this command first.
308
+ */
309
+ async start(options?: DaemonStartOptions): Promise<void> {
310
+ const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000
311
+
312
+ // Kill existing daemon if running
313
+ await this.stop()
314
+
315
+ const env: Record<string, string | undefined> = {
316
+ ...this.#env,
317
+ [DAEMON_ENV_KEY]: '1',
318
+ [DAEMON_TIMEOUT_ENV_KEY]: String(timeoutMs),
319
+ ...options?.env,
320
+ }
321
+
322
+ // Re-spawn the same command. argv[0] is the node/bun binary,
323
+ // the rest is the CLI invocation (e.g. ["./bin.js", "cloud", "login"]).
324
+ const execPath = this.#argv[0]
325
+ const args = this.#argv.slice(1)
326
+
327
+ const child = spawn(execPath, args, {
328
+ detached: true,
329
+ stdio: 'ignore',
330
+ env,
331
+ })
332
+
333
+ child.unref()
334
+
335
+ // Brief wait to confirm the daemon started and wrote its PID file
336
+ const startDeadline = Date.now() + 5000
337
+ while (Date.now() < startDeadline) {
338
+ await new Promise((r) => setTimeout(r, 100))
339
+ const pidData = readPidFile(this.#pidFile)
340
+ if (pidData && isProcessAlive(pidData.pid)) {
341
+ return
342
+ }
343
+ }
344
+
345
+ throw new Error(`Failed to start daemon for "${this.#cliName} ${this.#commandName}"`)
346
+ }
347
+
348
+ /**
349
+ * Stop the running daemon for this command.
350
+ */
351
+ async stop(): Promise<void> {
352
+ const pidData = readPidFile(this.#pidFile)
353
+ if (!pidData) {
354
+ return
355
+ }
356
+
357
+ // Only kill if this is actually our daemon (alive + fresh heartbeat).
358
+ // Without this check, a stale PID file with a reused PID could cause
359
+ // us to kill an unrelated process.
360
+ if (!isDaemonAlive(pidData)) {
361
+ removePidFile(this.#pidFile)
362
+ return
363
+ }
364
+
365
+ await killProcess(pidData.pid)
366
+ removePidFile(this.#pidFile)
367
+ }
368
+
369
+ /**
370
+ * Check if the daemon for this command is currently running.
371
+ * Verifies both that the PID is alive and the heartbeat is recent
372
+ * to protect against PID reuse after a crash.
373
+ */
374
+ async isRunning(): Promise<boolean> {
375
+ const pidData = readPidFile(this.#pidFile)
376
+ if (!pidData) {
377
+ return false
378
+ }
379
+
380
+ if (!isDaemonAlive(pidData)) {
381
+ // Stale PID file, clean up
382
+ removePidFile(this.#pidFile)
383
+ return false
384
+ }
385
+
386
+ return true
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Create a DaemonContext for a command.
392
+ * Called internally by goke when building the execution context.
393
+ */
394
+ function createDaemonContext(
395
+ cliName: string,
396
+ commandName: string,
397
+ argv: string[],
398
+ env?: Record<string, string | undefined>,
399
+ ): DaemonContext {
400
+ return new DaemonContext(cliName, commandName, argv, env)
401
+ }
402
+
403
+ export { DaemonContext, createDaemonContext }
404
+ export type { DaemonStartOptions }
package/src/goke.ts CHANGED
@@ -19,6 +19,8 @@ import { COMPLETION_FLAG, generateCompletionScript, installCompletions, uninstal
19
19
  import type { ShellType } from './completions.js'
20
20
  import type { GokeFs } from './goke-fs.js'
21
21
  import { EventEmitter, fs as runtimeFs, openInBrowser, process } from '#runtime'
22
+ import { createDaemonContext } from '#daemon'
23
+ import type { DaemonContext } from '#daemon'
22
24
 
23
25
  // ─── Node.js platform constants ───
24
26
 
@@ -1033,6 +1035,8 @@ interface GokeExecutionContext {
1033
1035
  console: GokeConsole
1034
1036
  fs: GokeFs
1035
1037
  process: GokeProcess
1038
+ /** Daemon context for running the current command as a background process. */
1039
+ daemon: DaemonContext
1036
1040
  }
1037
1041
 
1038
1042
  /**
@@ -1144,7 +1148,10 @@ function createConsole(stdout: GokeOutputStream, stderr: GokeOutputStream): Goke
1144
1148
  function formatCliError(err: Error): string {
1145
1149
  const lines: string[] = []
1146
1150
  lines.push(`${pc.red(pc.bold('error:'))} ${err.message}`)
1147
- if (err.stack) {
1151
+ // GokeError is a user-facing validation/usage error (unknown options, missing
1152
+ // values, invalid types, schema coercion failures). The stack trace is
1153
+ // internal noise for these — only show it for unexpected errors.
1154
+ if (!(err instanceof GokeError) && err.stack) {
1148
1155
  // Extract just the stack frames (skip the first line which is the message)
1149
1156
  const stackLines = err.stack.split('\n').slice(1)
1150
1157
  if (stackLines.length > 0) {
@@ -1312,6 +1319,7 @@ class Goke<Opts = {}> extends EventEmitter {
1312
1319
  ? createConsole(stdout, stderr)
1313
1320
  : this.console
1314
1321
  const exitFn = override?.exit ?? this.exit
1322
+ const commandName = this.matchedCommandName || ''
1315
1323
  return {
1316
1324
  console: contextConsole,
1317
1325
  fs: override?.fs ?? this.fs,
@@ -1327,6 +1335,12 @@ class Goke<Opts = {}> extends EventEmitter {
1327
1335
  throw new GokeProcessExit(code)
1328
1336
  },
1329
1337
  },
1338
+ daemon: createDaemonContext(
1339
+ this.name,
1340
+ commandName,
1341
+ override?.argv ?? this.rawArgs,
1342
+ override?.env ?? this.env,
1343
+ ),
1330
1344
  }
1331
1345
  }
1332
1346
 
@@ -2379,7 +2393,7 @@ function generateDocs({ cli, basePath = '.' }: GenerateDocsOptions): DocPage[] {
2379
2393
  lines.push('|---------|-------------|')
2380
2394
  for (const cmd of visibleCommands) {
2381
2395
  if (cmd.isDefaultCommand) continue
2382
- const desc = cmd.description.split('\n')[0].trim()
2396
+ const desc = escapeAngleBrackets(cmd.description.split('\n')[0].trim())
2383
2397
  const slug = cmd.name.replace(/\s+/g, '-')
2384
2398
  lines.push(`| [\`${cmd.name}\`](${basePath}/${slug}.md) | ${desc} |`)
2385
2399
  }
@@ -2404,7 +2418,7 @@ function generateDocs({ cli, basePath = '.' }: GenerateDocsOptions): DocPage[] {
2404
2418
  lines.push('')
2405
2419
 
2406
2420
  if (cmd.description) {
2407
- lines.push(cmd.description)
2421
+ lines.push(escapeAngleBrackets(cmd.description))
2408
2422
  lines.push('')
2409
2423
  }
2410
2424
 
@@ -2476,6 +2490,19 @@ function generateDocs({ cli, basePath = '.' }: GenerateDocsOptions): DocPage[] {
2476
2490
  return pages
2477
2491
  }
2478
2492
 
2493
+ /**
2494
+ * Wraps bare `<word>` angle-bracket placeholders in backticks so MDX parsers
2495
+ * don't interpret them as JSX tags. Skips content already inside inline code
2496
+ * (single backticks) or fenced code blocks.
2497
+ */
2498
+ function escapeAngleBrackets(text: string): string {
2499
+ // Split on inline code spans to avoid double-wrapping
2500
+ return text.replace(/(`.+?`)|(<[a-zA-Z_][\w.-]*>)/g, (match, codeSpan) => {
2501
+ if (codeSpan) return match // already inside backticks
2502
+ return `\`${match}\``
2503
+ })
2504
+ }
2505
+
2479
2506
  function formatOptionsTable(options: Option[]): string {
2480
2507
  const lines: string[] = []
2481
2508
  lines.push('| Option | Default | Description |')
@@ -2483,7 +2510,7 @@ function formatOptionsTable(options: Option[]): string {
2483
2510
  for (const opt of options) {
2484
2511
  const defaultVal = opt.default !== undefined ? `\`${String(opt.default)}\`` : '-'
2485
2512
  // Escape pipe characters in description for markdown tables
2486
- const desc = opt.description.replace(/\|/g, '\\|').replace(/\n/g, ' ')
2513
+ const desc = escapeAngleBrackets(opt.description.replace(/\|/g, '\\|').replace(/\n/g, ' '))
2487
2514
  lines.push(`| \`${opt.rawName}\` | ${defaultVal} | ${desc} |`)
2488
2515
  }
2489
2516
  return lines.join('\n')
package/src/index.ts CHANGED
@@ -23,3 +23,4 @@ export type { StandardTypedV1, StandardJSONSchemaV1, JsonSchema } from "./coerce
23
23
  export { GokeError, coerceBySchema, extractJsonSchema, wrapJsonSchema, isStandardSchema, extractSchemaMetadata } from "./coerce.js"
24
24
  export { detectAgent, agentInfo, agent, isAgent } from "./agents.js"
25
25
  export type { AgentName, AgentInfo } from "./agents.js"
26
+ export type { DaemonContext, DaemonStartOptions } from "./daemon.js"