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/dist/__test__/daemon.test.d.ts +11 -0
- package/dist/__test__/daemon.test.d.ts.map +1 -0
- package/dist/__test__/daemon.test.js +296 -0
- package/dist/__test__/index.test.js +18 -2
- package/dist/__test__/readme-examples.test.js +30 -0
- package/dist/daemon-browser.d.ts +19 -0
- package/dist/daemon-browser.d.ts.map +1 -0
- package/dist/daemon-browser.js +25 -0
- package/dist/daemon.d.ts +80 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +337 -0
- package/dist/goke.d.ts +3 -0
- package/dist/goke.d.ts.map +1 -1
- package/dist/goke.js +23 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/__test__/daemon.test.ts +335 -0
- package/src/__test__/index.test.ts +22 -2
- package/src/__test__/readme-examples.test.ts +38 -0
- package/src/daemon-browser.ts +34 -0
- package/src/daemon.ts +404 -0
- package/src/goke.ts +31 -4
- package/src/index.ts +1 -0
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
|
-
|
|
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"
|