goke 6.13.0 → 6.14.1

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.
@@ -282,6 +282,97 @@ process.exit(0)
282
282
  // Should return false because heartbeat is stale (even though PID is alive)
283
283
  expect(await ctx.isRunning()).toBe(false);
284
284
  });
285
+ test('start with attach: true pipes stdout/stderr and waits for exit', async () => {
286
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-attach.mjs');
287
+ // This helper writes to stdout/stderr and exits after a short delay.
288
+ // The PID file setup mirrors the standard daemon helper.
289
+ fs.writeFileSync(helperScript, `
290
+ import fs from 'node:fs'
291
+ import path from 'node:path'
292
+ import os from 'node:os'
293
+ import crypto from 'node:crypto'
294
+
295
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
296
+ const cliName = process.env.TEST_CLI_NAME || 'test-daemon-cli'
297
+ const cmdName = process.env.TEST_CMD_NAME || 'attach-test'
298
+ const safeName = cliName + '--' + cmdName
299
+ const pidFile = path.join(DAEMON_DIR, safeName + '.pid.json')
300
+
301
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
302
+
303
+ const instanceId = crypto.randomBytes(8).toString('hex')
304
+ const pidData = { pid: process.pid, id: instanceId, startedAt: Date.now(), heartbeatAt: Date.now() }
305
+ fs.writeFileSync(pidFile, JSON.stringify(pidData), { mode: 0o600 })
306
+
307
+ // Write to stdout and stderr so the parent can see it
308
+ console.log('DAEMON_STDOUT_MESSAGE')
309
+ console.error('DAEMON_STDERR_MESSAGE')
310
+
311
+ // Exit after a short delay
312
+ setTimeout(() => {
313
+ try {
314
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
315
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
316
+ } catch {}
317
+ process.exit(0)
318
+ }, 500)
319
+ `);
320
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'attach-test'));
321
+ const { DaemonContext } = await import('../daemon.js');
322
+ const ctx = new DaemonContext('test-daemon-cli', 'attach-test', [
323
+ process.execPath, helperScript, 'attach-test',
324
+ ]);
325
+ // attach: true should wait for the daemon to finish
326
+ await ctx.start({ attach: true, timeoutMs: 10_000 });
327
+ // After start resolves, the daemon should have exited and cleaned up its PID file
328
+ expect(await ctx.isRunning()).toBe(false);
329
+ try {
330
+ fs.unlinkSync(helperScript);
331
+ }
332
+ catch { }
333
+ }, 15_000);
334
+ test('start with attach: true throws on non-zero exit', async () => {
335
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-attach-fail.mjs');
336
+ fs.writeFileSync(helperScript, `
337
+ import fs from 'node:fs'
338
+ import path from 'node:path'
339
+ import os from 'node:os'
340
+ import crypto from 'node:crypto'
341
+
342
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
343
+ const cliName = process.env.TEST_CLI_NAME || 'test-daemon-cli'
344
+ const cmdName = process.env.TEST_CMD_NAME || 'attach-fail'
345
+ const safeName = cliName + '--' + cmdName
346
+ const pidFile = path.join(DAEMON_DIR, safeName + '.pid.json')
347
+
348
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
349
+
350
+ const instanceId = crypto.randomBytes(8).toString('hex')
351
+ const pidData = { pid: process.pid, id: instanceId, startedAt: Date.now(), heartbeatAt: Date.now() }
352
+ fs.writeFileSync(pidFile, JSON.stringify(pidData), { mode: 0o600 })
353
+
354
+ console.error('Something went wrong')
355
+
356
+ setTimeout(() => {
357
+ try {
358
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
359
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
360
+ } catch {}
361
+ process.exit(1)
362
+ }, 500)
363
+ `);
364
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'attach-fail'));
365
+ const { DaemonContext } = await import('../daemon.js');
366
+ const ctx = new DaemonContext('test-daemon-cli', 'attach-fail', [
367
+ process.execPath, helperScript, 'attach-fail',
368
+ ]);
369
+ await expect(ctx.start({ attach: true, timeoutMs: 10_000 }))
370
+ .rejects.toThrow('exited with code 1');
371
+ try {
372
+ fs.unlinkSync(helperScript);
373
+ }
374
+ catch { }
375
+ }, 15_000);
285
376
  test('daemon context has correct command name from parsed cli', async () => {
286
377
  const { default: goke } = await import('../index.js');
287
378
  const cli = goke('my-app');
package/dist/daemon.d.ts CHANGED
@@ -29,6 +29,15 @@ interface DaemonStartOptions {
29
29
  timeoutMs?: number;
30
30
  /** Extra environment variables passed to the daemon process. */
31
31
  env?: Record<string, string>;
32
+ /**
33
+ * When true, pipe daemon stdout/stderr to the parent process and wait
34
+ * for the daemon to exit before resolving. This lets interactive users
35
+ * see real-time logs and error messages from the daemon.
36
+ *
37
+ * When false (default), the daemon runs fully detached with no stdio
38
+ * and start() returns as soon as the PID file is confirmed.
39
+ */
40
+ attach?: boolean;
32
41
  }
33
42
  /**
34
43
  * Daemon context available on every command's execution context.
@@ -57,6 +66,11 @@ declare class DaemonContext {
57
66
  /**
58
67
  * Spawn the current command as a detached background daemon process.
59
68
  * Kills any existing daemon for this command first.
69
+ *
70
+ * When `attach: true`, pipes daemon stdout/stderr to the parent and
71
+ * waits for the daemon to exit. Throws if the daemon exits with a
72
+ * non-zero code. This is useful for interactive login flows where the
73
+ * user wants to see real-time logs and error messages.
60
74
  */
61
75
  start(options?: DaemonStartOptions): Promise<void>;
62
76
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AA6IH,UAAU,kBAAkB;IAC1B,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC7B;AAED;;;;;;;;;;GAUG;AACH,cAAM,aAAa;;IACjB,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;gBAYxB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAc1C;;;;;;OAMG;IACH,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa;IAkF9C;;;OAGG;IACG,KAAK,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuCxD;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAkB3B;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAcpC;AAED;;;GAGG;AACH,iBAAS,mBAAmB,CAC1B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GACvC,aAAa,CAEf;AAED,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,CAAA;AAC7C,YAAY,EAAE,kBAAkB,EAAE,CAAA"}
1
+ {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AA6IH,UAAU,kBAAkB;IAC1B,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5B;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED;;;;;;;;;;GAUG;AACH,cAAM,aAAa;;IACjB,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;gBAYxB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAc1C;;;;;;OAMG;IACH,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa;IAkF9C;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAoExD;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAkB3B;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAcpC;AAED;;;GAGG;AACH,iBAAS,mBAAmB,CAC1B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GACvC,aAAa,CAEf;AAED,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,CAAA;AAC7C,YAAY,EAAE,kBAAkB,EAAE,CAAA"}
package/dist/daemon.js CHANGED
@@ -259,9 +259,15 @@ class DaemonContext {
259
259
  /**
260
260
  * Spawn the current command as a detached background daemon process.
261
261
  * Kills any existing daemon for this command first.
262
+ *
263
+ * When `attach: true`, pipes daemon stdout/stderr to the parent and
264
+ * waits for the daemon to exit. Throws if the daemon exits with a
265
+ * non-zero code. This is useful for interactive login flows where the
266
+ * user wants to see real-time logs and error messages.
262
267
  */
263
268
  async start(options) {
264
269
  const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000;
270
+ const attach = options?.attach ?? false;
265
271
  // Kill existing daemon if running
266
272
  await this.stop();
267
273
  const env = {
@@ -275,18 +281,47 @@ class DaemonContext {
275
281
  const execPath = this.#argv[0];
276
282
  const args = this.#argv.slice(1);
277
283
  const child = spawn(execPath, args, {
278
- detached: true,
279
- stdio: 'ignore',
284
+ // Detach only when running in background so the daemon outlives the
285
+ // parent. In attached mode, keep the child in the same process group
286
+ // so signals propagate naturally and the parent stays alive.
287
+ detached: !attach,
288
+ stdio: attach ? ['ignore', 'inherit', 'inherit'] : 'ignore',
280
289
  env,
281
290
  });
282
- child.unref();
291
+ // Only unref when detached (non-attached) so the parent can exit
292
+ // immediately. In attached mode, the parent must stay alive to wait for
293
+ // the child's 'close' event — unref() would let the event loop drain
294
+ // and the parent would exit before the child finishes.
295
+ if (!attach) {
296
+ child.unref();
297
+ }
298
+ // Register close/error listeners immediately after spawn, before the
299
+ // PID file polling loop. If the child exits very quickly (writes PID
300
+ // file then exits), registering listeners after seeing the PID file
301
+ // could miss the 'close' event and hang the promise forever.
302
+ let childDone;
303
+ if (attach) {
304
+ childDone = new Promise((resolve, reject) => {
305
+ child.on('close', (code) => {
306
+ if (code && code !== 0) {
307
+ reject(new Error(`Daemon "${this.#cliName} ${this.#commandName}" exited with code ${code}`));
308
+ }
309
+ else {
310
+ resolve();
311
+ }
312
+ });
313
+ child.on('error', reject);
314
+ });
315
+ }
283
316
  // Brief wait to confirm the daemon started and wrote its PID file
284
317
  const startDeadline = Date.now() + 5000;
285
318
  while (Date.now() < startDeadline) {
286
319
  await new Promise((r) => setTimeout(r, 100));
287
320
  const pidData = readPidFile(this.#pidFile);
288
321
  if (pidData && isProcessAlive(pidData.pid)) {
289
- return;
322
+ if (!attach)
323
+ return;
324
+ return childDone;
290
325
  }
291
326
  }
292
327
  throw new Error(`Failed to start daemon for "${this.#cliName} ${this.#commandName}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goke",
3
- "version": "6.13.0",
3
+ "version": "6.14.1",
4
4
  "type": "module",
5
5
  "description": "Simple yet powerful framework for building command-line apps. Inspired by cac.",
6
6
  "repository": {
@@ -318,6 +318,100 @@ process.exit(0)
318
318
  expect(await ctx.isRunning()).toBe(false)
319
319
  })
320
320
 
321
+ test('start with attach: true pipes stdout/stderr and waits for exit', async () => {
322
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-attach.mjs')
323
+ // This helper writes to stdout/stderr and exits after a short delay.
324
+ // The PID file setup mirrors the standard daemon helper.
325
+ fs.writeFileSync(helperScript, `
326
+ import fs from 'node:fs'
327
+ import path from 'node:path'
328
+ import os from 'node:os'
329
+ import crypto from 'node:crypto'
330
+
331
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
332
+ const cliName = process.env.TEST_CLI_NAME || 'test-daemon-cli'
333
+ const cmdName = process.env.TEST_CMD_NAME || 'attach-test'
334
+ const safeName = cliName + '--' + cmdName
335
+ const pidFile = path.join(DAEMON_DIR, safeName + '.pid.json')
336
+
337
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
338
+
339
+ const instanceId = crypto.randomBytes(8).toString('hex')
340
+ const pidData = { pid: process.pid, id: instanceId, startedAt: Date.now(), heartbeatAt: Date.now() }
341
+ fs.writeFileSync(pidFile, JSON.stringify(pidData), { mode: 0o600 })
342
+
343
+ // Write to stdout and stderr so the parent can see it
344
+ console.log('DAEMON_STDOUT_MESSAGE')
345
+ console.error('DAEMON_STDERR_MESSAGE')
346
+
347
+ // Exit after a short delay
348
+ setTimeout(() => {
349
+ try {
350
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
351
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
352
+ } catch {}
353
+ process.exit(0)
354
+ }, 500)
355
+ `)
356
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'attach-test'))
357
+
358
+ const { DaemonContext } = await import('../daemon.js')
359
+ const ctx = new DaemonContext('test-daemon-cli', 'attach-test', [
360
+ process.execPath, helperScript, 'attach-test',
361
+ ])
362
+
363
+ // attach: true should wait for the daemon to finish
364
+ await ctx.start({ attach: true, timeoutMs: 10_000 })
365
+
366
+ // After start resolves, the daemon should have exited and cleaned up its PID file
367
+ expect(await ctx.isRunning()).toBe(false)
368
+
369
+ try { fs.unlinkSync(helperScript) } catch {}
370
+ }, 15_000)
371
+
372
+ test('start with attach: true throws on non-zero exit', async () => {
373
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-attach-fail.mjs')
374
+ fs.writeFileSync(helperScript, `
375
+ import fs from 'node:fs'
376
+ import path from 'node:path'
377
+ import os from 'node:os'
378
+ import crypto from 'node:crypto'
379
+
380
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
381
+ const cliName = process.env.TEST_CLI_NAME || 'test-daemon-cli'
382
+ const cmdName = process.env.TEST_CMD_NAME || 'attach-fail'
383
+ const safeName = cliName + '--' + cmdName
384
+ const pidFile = path.join(DAEMON_DIR, safeName + '.pid.json')
385
+
386
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
387
+
388
+ const instanceId = crypto.randomBytes(8).toString('hex')
389
+ const pidData = { pid: process.pid, id: instanceId, startedAt: Date.now(), heartbeatAt: Date.now() }
390
+ fs.writeFileSync(pidFile, JSON.stringify(pidData), { mode: 0o600 })
391
+
392
+ console.error('Something went wrong')
393
+
394
+ setTimeout(() => {
395
+ try {
396
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
397
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
398
+ } catch {}
399
+ process.exit(1)
400
+ }, 500)
401
+ `)
402
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'attach-fail'))
403
+
404
+ const { DaemonContext } = await import('../daemon.js')
405
+ const ctx = new DaemonContext('test-daemon-cli', 'attach-fail', [
406
+ process.execPath, helperScript, 'attach-fail',
407
+ ])
408
+
409
+ await expect(ctx.start({ attach: true, timeoutMs: 10_000 }))
410
+ .rejects.toThrow('exited with code 1')
411
+
412
+ try { fs.unlinkSync(helperScript) } catch {}
413
+ }, 15_000)
414
+
321
415
  test('daemon context has correct command name from parsed cli', async () => {
322
416
  const { default: goke } = await import('../index.js')
323
417
  const cli = goke('my-app')
package/src/daemon.ts CHANGED
@@ -169,6 +169,15 @@ interface DaemonStartOptions {
169
169
  timeoutMs?: number
170
170
  /** Extra environment variables passed to the daemon process. */
171
171
  env?: Record<string, string>
172
+ /**
173
+ * When true, pipe daemon stdout/stderr to the parent process and wait
174
+ * for the daemon to exit before resolving. This lets interactive users
175
+ * see real-time logs and error messages from the daemon.
176
+ *
177
+ * When false (default), the daemon runs fully detached with no stdio
178
+ * and start() returns as soon as the PID file is confirmed.
179
+ */
180
+ attach?: boolean
172
181
  }
173
182
 
174
183
  /**
@@ -305,9 +314,15 @@ class DaemonContext {
305
314
  /**
306
315
  * Spawn the current command as a detached background daemon process.
307
316
  * Kills any existing daemon for this command first.
317
+ *
318
+ * When `attach: true`, pipes daemon stdout/stderr to the parent and
319
+ * waits for the daemon to exit. Throws if the daemon exits with a
320
+ * non-zero code. This is useful for interactive login flows where the
321
+ * user wants to see real-time logs and error messages.
308
322
  */
309
323
  async start(options?: DaemonStartOptions): Promise<void> {
310
324
  const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000
325
+ const attach = options?.attach ?? false
311
326
 
312
327
  // Kill existing daemon if running
313
328
  await this.stop()
@@ -325,12 +340,39 @@ class DaemonContext {
325
340
  const args = this.#argv.slice(1)
326
341
 
327
342
  const child = spawn(execPath, args, {
328
- detached: true,
329
- stdio: 'ignore',
343
+ // Detach only when running in background so the daemon outlives the
344
+ // parent. In attached mode, keep the child in the same process group
345
+ // so signals propagate naturally and the parent stays alive.
346
+ detached: !attach,
347
+ stdio: attach ? ['ignore', 'inherit', 'inherit'] : 'ignore',
330
348
  env,
331
349
  })
332
350
 
333
- child.unref()
351
+ // Only unref when detached (non-attached) so the parent can exit
352
+ // immediately. In attached mode, the parent must stay alive to wait for
353
+ // the child's 'close' event — unref() would let the event loop drain
354
+ // and the parent would exit before the child finishes.
355
+ if (!attach) {
356
+ child.unref()
357
+ }
358
+
359
+ // Register close/error listeners immediately after spawn, before the
360
+ // PID file polling loop. If the child exits very quickly (writes PID
361
+ // file then exits), registering listeners after seeing the PID file
362
+ // could miss the 'close' event and hang the promise forever.
363
+ let childDone: Promise<void> | undefined
364
+ if (attach) {
365
+ childDone = new Promise<void>((resolve, reject) => {
366
+ child.on('close', (code) => {
367
+ if (code && code !== 0) {
368
+ reject(new Error(`Daemon "${this.#cliName} ${this.#commandName}" exited with code ${code}`))
369
+ } else {
370
+ resolve()
371
+ }
372
+ })
373
+ child.on('error', reject)
374
+ })
375
+ }
334
376
 
335
377
  // Brief wait to confirm the daemon started and wrote its PID file
336
378
  const startDeadline = Date.now() + 5000
@@ -338,7 +380,8 @@ class DaemonContext {
338
380
  await new Promise((r) => setTimeout(r, 100))
339
381
  const pidData = readPidFile(this.#pidFile)
340
382
  if (pidData && isProcessAlive(pidData.pid)) {
341
- return
383
+ if (!attach) return
384
+ return childDone
342
385
  }
343
386
  }
344
387