goke 6.13.0 → 6.14.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.
@@ -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;IAoDxD;;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 = {
@@ -276,7 +282,7 @@ class DaemonContext {
276
282
  const args = this.#argv.slice(1);
277
283
  const child = spawn(execPath, args, {
278
284
  detached: true,
279
- stdio: 'ignore',
285
+ stdio: attach ? ['ignore', 'inherit', 'inherit'] : 'ignore',
280
286
  env,
281
287
  });
282
288
  child.unref();
@@ -286,7 +292,20 @@ class DaemonContext {
286
292
  await new Promise((r) => setTimeout(r, 100));
287
293
  const pidData = readPidFile(this.#pidFile);
288
294
  if (pidData && isProcessAlive(pidData.pid)) {
289
- return;
295
+ if (!attach)
296
+ return;
297
+ // Attached mode: wait for the daemon process to exit
298
+ return new Promise((resolve, reject) => {
299
+ child.on('close', (code) => {
300
+ if (code && code !== 0) {
301
+ reject(new Error(`Daemon "${this.#cliName} ${this.#commandName}" exited with code ${code}`));
302
+ }
303
+ else {
304
+ resolve();
305
+ }
306
+ });
307
+ child.on('error', reject);
308
+ });
290
309
  }
291
310
  }
292
311
  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.0",
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()
@@ -326,7 +341,7 @@ class DaemonContext {
326
341
 
327
342
  const child = spawn(execPath, args, {
328
343
  detached: true,
329
- stdio: 'ignore',
344
+ stdio: attach ? ['ignore', 'inherit', 'inherit'] : 'ignore',
330
345
  env,
331
346
  })
332
347
 
@@ -338,7 +353,19 @@ class DaemonContext {
338
353
  await new Promise((r) => setTimeout(r, 100))
339
354
  const pidData = readPidFile(this.#pidFile)
340
355
  if (pidData && isProcessAlive(pidData.pid)) {
341
- return
356
+ if (!attach) return
357
+
358
+ // Attached mode: wait for the daemon process to exit
359
+ return new Promise<void>((resolve, reject) => {
360
+ child.on('close', (code) => {
361
+ if (code && code !== 0) {
362
+ reject(new Error(`Daemon "${this.#cliName} ${this.#commandName}" exited with code ${code}`))
363
+ } else {
364
+ resolve()
365
+ }
366
+ })
367
+ child.on('error', reject)
368
+ })
342
369
  }
343
370
  }
344
371