goke 6.12.3 → 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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Tests for the daemon background process support.
3
+ *
4
+ * Tests the DaemonContext lifecycle: PID file management, isDaemon detection,
5
+ * start/stop/isRunning, forCommand, heartbeat, and instance ID safety.
6
+ *
7
+ * Server-mode tests (isDaemon=true) run in child processes to avoid
8
+ * scheduling process.exit() timers inside the vitest runner.
9
+ */
10
+
11
+ import { describe, expect, test, afterEach } from 'vitest'
12
+ import { execFile } from 'node:child_process'
13
+ import { promisify } from 'node:util'
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import os from 'node:os'
17
+
18
+ const execFileAsync = promisify(execFile)
19
+
20
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
21
+
22
+ function pidFilePath(cliName: string, commandName: string): string {
23
+ const safeName = `${cliName}--${commandName}`
24
+ .replace(/\s+/g, '-')
25
+ .replace(/[^a-zA-Z0-9_-]/g, '')
26
+ return path.join(DAEMON_DIR, `${safeName}.pid.json`)
27
+ }
28
+
29
+ function readPidFile(filePath: string): { pid: number; id: string; startedAt: number; heartbeatAt: number } | null {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ function isProcessAlive(pid: number): boolean {
38
+ try {
39
+ process.kill(pid, 0)
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ async function killIfAlive(pid: number): Promise<void> {
47
+ if (isProcessAlive(pid)) {
48
+ try { process.kill(pid, 'SIGKILL') } catch {}
49
+ }
50
+ }
51
+
52
+ // Track PIDs to clean up after tests
53
+ const spawnedPids: number[] = []
54
+ const testPidFiles: string[] = []
55
+
56
+ afterEach(async () => {
57
+ for (const pid of spawnedPids) {
58
+ await killIfAlive(pid)
59
+ }
60
+ spawnedPids.length = 0
61
+
62
+ for (const f of testPidFiles) {
63
+ try { fs.unlinkSync(f) } catch {}
64
+ }
65
+ testPidFiles.length = 0
66
+ })
67
+
68
+ // Helper script that simulates a daemon process: writes PID file with
69
+ // instance ID and heartbeat, stays alive until SIGTERM or timeout.
70
+ function writeDaemonHelper(scriptPath: string): void {
71
+ fs.writeFileSync(scriptPath, `
72
+ import fs from 'node:fs'
73
+ import path from 'node:path'
74
+ import os from 'node:os'
75
+ import crypto from 'node:crypto'
76
+
77
+ const DAEMON_DIR = path.join(os.homedir(), '.config', 'goke', 'daemons')
78
+ const cliName = process.env.TEST_CLI_NAME || 'test-daemon-cli'
79
+ const cmdName = process.env.TEST_CMD_NAME || 'bg'
80
+ const safeName = cliName + '--' + cmdName
81
+ const pidFile = path.join(DAEMON_DIR, safeName + '.pid.json')
82
+
83
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
84
+
85
+ const instanceId = crypto.randomBytes(8).toString('hex')
86
+ const pidData = { pid: process.pid, id: instanceId, startedAt: Date.now(), heartbeatAt: Date.now() }
87
+ fs.writeFileSync(pidFile, JSON.stringify(pidData), { mode: 0o600 })
88
+
89
+ const hb = setInterval(() => {
90
+ try {
91
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
92
+ if (current.id === instanceId) {
93
+ current.heartbeatAt = Date.now()
94
+ fs.writeFileSync(pidFile, JSON.stringify(current), { mode: 0o600 })
95
+ }
96
+ } catch {}
97
+ }, 2000)
98
+ hb.unref()
99
+
100
+ const timeoutMs = Number(process.env.GOKE_DAEMON_TIMEOUT) || 60000
101
+ const timer = setTimeout(() => {
102
+ try {
103
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
104
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
105
+ } catch {}
106
+ process.exit(0)
107
+ }, timeoutMs)
108
+
109
+ process.on('SIGTERM', () => {
110
+ clearTimeout(timer)
111
+ clearInterval(hb)
112
+ try {
113
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
114
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
115
+ } catch {}
116
+ process.exit(0)
117
+ })
118
+
119
+ process.on('exit', () => {
120
+ try {
121
+ const current = JSON.parse(fs.readFileSync(pidFile, 'utf-8'))
122
+ if (current.id === instanceId) fs.unlinkSync(pidFile)
123
+ } catch {}
124
+ })
125
+ `)
126
+ }
127
+
128
+ describe('DaemonContext', () => {
129
+ test('isDaemon is false by default (client mode)', async () => {
130
+ const { default: goke } = await import('../index.js')
131
+ const cli = goke('test-cli')
132
+ let isDaemon: boolean | undefined
133
+
134
+ cli.command('run', 'test').action((opts, ctx) => {
135
+ isDaemon = ctx.daemon.isDaemon
136
+ })
137
+
138
+ await cli.parse(['node', 'test', 'run'], { run: true })
139
+ expect(isDaemon).toBe(false)
140
+ })
141
+
142
+ test('isDaemon is true when GOKE_DAEMON=1 (tested in child process)', async () => {
143
+ // Run a small script in a child process that creates a DaemonContext
144
+ // with the env var set and prints isDaemon. This avoids scheduling
145
+ // process.exit() timers inside the vitest process.
146
+ // Uses the compiled dist/ so plain Node can import it (no tsx needed).
147
+ const scriptPath = path.join(os.tmpdir(), 'goke-daemon-is-daemon-test.mjs')
148
+ const distDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'dist')
149
+ fs.writeFileSync(scriptPath, `
150
+ import { DaemonContext } from '${distDir}/daemon.js'
151
+ const ctx = new DaemonContext('test-is-daemon', 'cmd', ['node', 'test'])
152
+ console.log(ctx.isDaemon ? 'SERVER' : 'CLIENT')
153
+ // Exit immediately to not leave the daemon alive
154
+ process.exit(0)
155
+ `)
156
+ testPidFiles.push(pidFilePath('test-is-daemon', 'cmd'))
157
+
158
+ const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
159
+ env: { ...process.env, GOKE_DAEMON: '1', GOKE_DAEMON_TIMEOUT: '1000' },
160
+ })
161
+ expect(stdout.trim()).toBe('SERVER')
162
+ }, 10_000)
163
+
164
+ test('isRunning returns false when no daemon is running', async () => {
165
+ const { default: goke } = await import('../index.js')
166
+ const cli = goke('test-cli')
167
+ let running: boolean | undefined
168
+
169
+ cli.command('check', 'test').action(async (opts, ctx) => {
170
+ running = await ctx.daemon.isRunning()
171
+ })
172
+
173
+ await cli.parse(['node', 'test', 'check'], { run: true })
174
+ expect(running).toBe(false)
175
+ })
176
+
177
+ test('start spawns a detached daemon, isRunning returns true, stop kills it', async () => {
178
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-start.mjs')
179
+ writeDaemonHelper(helperScript)
180
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'bg'))
181
+
182
+ const { DaemonContext } = await import('../daemon.js')
183
+ const ctx = new DaemonContext('test-daemon-cli', 'bg', [process.execPath, helperScript, 'bg'])
184
+
185
+ expect(ctx.isDaemon).toBe(false)
186
+ expect(await ctx.isRunning()).toBe(false)
187
+
188
+ await ctx.start({ timeoutMs: 30_000 })
189
+ expect(await ctx.isRunning()).toBe(true)
190
+
191
+ const pidData = readPidFile(pidFilePath('test-daemon-cli', 'bg'))
192
+ expect(pidData).not.toBeNull()
193
+ expect(pidData!.id).toBeTruthy()
194
+ spawnedPids.push(pidData!.pid)
195
+
196
+ await ctx.stop()
197
+ await new Promise((r) => setTimeout(r, 200))
198
+ expect(await ctx.isRunning()).toBe(false)
199
+
200
+ try { fs.unlinkSync(helperScript) } catch {}
201
+ }, 15_000)
202
+
203
+ test('start kills existing daemon before spawning new one', async () => {
204
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-replace.mjs')
205
+ writeDaemonHelper(helperScript)
206
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'bg'))
207
+
208
+ const { DaemonContext } = await import('../daemon.js')
209
+ const ctx = new DaemonContext('test-daemon-cli', 'bg', [process.execPath, helperScript, 'bg'])
210
+
211
+ await ctx.start({ timeoutMs: 30_000 })
212
+ const firstPid = readPidFile(pidFilePath('test-daemon-cli', 'bg'))
213
+ expect(firstPid).not.toBeNull()
214
+ spawnedPids.push(firstPid!.pid)
215
+ const firstId = firstPid!.id
216
+
217
+ await ctx.start({ timeoutMs: 30_000 })
218
+ const secondPid = readPidFile(pidFilePath('test-daemon-cli', 'bg'))
219
+ expect(secondPid).not.toBeNull()
220
+ spawnedPids.push(secondPid!.pid)
221
+
222
+ // PIDs and instance IDs should differ
223
+ expect(secondPid!.pid).not.toBe(firstPid!.pid)
224
+ expect(secondPid!.id).not.toBe(firstId)
225
+
226
+ expect(isProcessAlive(firstPid!.pid)).toBe(false)
227
+ expect(isProcessAlive(secondPid!.pid)).toBe(true)
228
+
229
+ await ctx.stop()
230
+ try { fs.unlinkSync(helperScript) } catch {}
231
+ }, 15_000)
232
+
233
+ test('stop is idempotent when no daemon running', async () => {
234
+ const { DaemonContext } = await import('../daemon.js')
235
+ const ctx = new DaemonContext('nonexistent-cli', 'nope', ['node', 'nope'])
236
+ await ctx.stop()
237
+ await ctx.stop()
238
+ })
239
+
240
+ test('forCommand returns context for a different command', async () => {
241
+ const { DaemonContext } = await import('../daemon.js')
242
+ const loginCtx = new DaemonContext('myapp', 'login', ['node', 'myapp', 'login'])
243
+ const meCtx = loginCtx.forCommand('me')
244
+
245
+ // They should reference different PID files
246
+ expect(await loginCtx.isRunning()).toBe(false)
247
+ expect(await meCtx.isRunning()).toBe(false)
248
+
249
+ // forCommand context is always client mode
250
+ expect(meCtx.isDaemon).toBe(false)
251
+ })
252
+
253
+ test('forCommand can check and stop another commands daemon', async () => {
254
+ const helperScript = path.join(os.tmpdir(), 'goke-daemon-test-forcommand.mjs')
255
+ writeDaemonHelper(helperScript)
256
+ testPidFiles.push(pidFilePath('test-daemon-cli', 'bg'))
257
+
258
+ const { DaemonContext } = await import('../daemon.js')
259
+
260
+ // Start daemon for "bg" command
261
+ const bgCtx = new DaemonContext('test-daemon-cli', 'bg', [process.execPath, helperScript, 'bg'])
262
+ await bgCtx.start({ timeoutMs: 30_000 })
263
+
264
+ const pidData = readPidFile(pidFilePath('test-daemon-cli', 'bg'))
265
+ if (pidData) spawnedPids.push(pidData.pid)
266
+
267
+ // Create a context for "me" command and use forCommand to check "bg"
268
+ const meCtx = new DaemonContext('test-daemon-cli', 'me', ['node', 'test', 'me'])
269
+ const bgFromMe = meCtx.forCommand('bg')
270
+
271
+ expect(await bgFromMe.isRunning()).toBe(true)
272
+
273
+ await bgFromMe.stop()
274
+ await new Promise((r) => setTimeout(r, 200))
275
+ expect(await bgFromMe.isRunning()).toBe(false)
276
+
277
+ try { fs.unlinkSync(helperScript) } catch {}
278
+ }, 15_000)
279
+
280
+ test('stale PID file is cleaned up by isRunning', async () => {
281
+ const pidFile = pidFilePath('stale-test', 'cmd')
282
+ testPidFiles.push(pidFile)
283
+
284
+ // Write a PID file with a PID that doesn't exist
285
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
286
+ fs.writeFileSync(pidFile, JSON.stringify({
287
+ pid: 999999999,
288
+ id: 'stale-instance',
289
+ startedAt: Date.now(),
290
+ heartbeatAt: Date.now(),
291
+ }))
292
+
293
+ const { DaemonContext } = await import('../daemon.js')
294
+ const ctx = new DaemonContext('stale-test', 'cmd', ['node', 'test'])
295
+
296
+ expect(await ctx.isRunning()).toBe(false)
297
+ // PID file should have been cleaned up
298
+ expect(fs.existsSync(pidFile)).toBe(false)
299
+ })
300
+
301
+ test('PID file with stale heartbeat is treated as not running', async () => {
302
+ const pidFile = pidFilePath('heartbeat-test', 'cmd')
303
+ testPidFiles.push(pidFile)
304
+
305
+ // Write a PID file with current process PID but very old heartbeat
306
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
307
+ fs.writeFileSync(pidFile, JSON.stringify({
308
+ pid: process.pid, // alive PID
309
+ id: 'old-heartbeat',
310
+ startedAt: Date.now() - 60000,
311
+ heartbeatAt: Date.now() - 60000, // 60s old, well past the 15s threshold
312
+ }))
313
+
314
+ const { DaemonContext } = await import('../daemon.js')
315
+ const ctx = new DaemonContext('heartbeat-test', 'cmd', ['node', 'test'])
316
+
317
+ // Should return false because heartbeat is stale (even though PID is alive)
318
+ expect(await ctx.isRunning()).toBe(false)
319
+ })
320
+
321
+ test('daemon context has correct command name from parsed cli', async () => {
322
+ const { default: goke } = await import('../index.js')
323
+ const cli = goke('my-app')
324
+ let capturedDaemon: any
325
+
326
+ cli.command('auth login', 'Login').action((opts, ctx) => {
327
+ capturedDaemon = ctx.daemon
328
+ })
329
+
330
+ await cli.parse(['node', 'my-app', 'auth', 'login'], { run: true })
331
+
332
+ expect(capturedDaemon).toBeDefined()
333
+ expect(capturedDaemon.isDaemon).toBe(false)
334
+ })
335
+ })
@@ -140,7 +140,7 @@ describe('error formatting', () => {
140
140
  expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`"error: connection refused"`)
141
141
  })
142
142
 
143
- test('error output includes stack trace', async () => {
143
+ test('GokeError (validation) omits stack trace', async () => {
144
144
  const stderr = createTestOutputStream()
145
145
  const cli = goke('mycli', { stderr, exit: () => {} })
146
146
 
@@ -152,10 +152,30 @@ describe('error formatting', () => {
152
152
  await cli.parse('node bin build --unknown'.split(' '))
153
153
  } catch {}
154
154
 
155
- // Verify that stderr contains "error:" prefix and a stack trace with "at" lines
156
155
  const text = stderr.text
157
156
  expect(text).toContain('error:')
158
157
  expect(text).toContain('Unknown option `--unknown`')
158
+ // GokeError is a user-facing error; stack trace should be suppressed
159
+ expect(text).not.toMatch(/at /)
160
+ })
161
+
162
+ test('unexpected error still includes stack trace', async () => {
163
+ const stderr = createTestOutputStream()
164
+ const cli = goke('mycli', { stderr, exit: () => {} })
165
+
166
+ cli
167
+ .command('deploy', 'Deploy app')
168
+ .action(async () => {
169
+ throw new Error('unexpected crash')
170
+ })
171
+
172
+ await cli.parse('node bin deploy'.split(' '))
173
+ await new Promise(resolve => setTimeout(resolve, 10))
174
+
175
+ const text = stderr.text
176
+ expect(text).toContain('error:')
177
+ expect(text).toContain('unexpected crash')
178
+ // Non-GokeError should still show the stack trace
159
179
  expect(text).toMatch(/at /)
160
180
  })
161
181
  })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Browser-safe daemon stub.
3
+ *
4
+ * Provides the same DaemonContext interface as daemon.ts but without
5
+ * Node.js dependencies. start() throws; everything else is a no-op.
6
+ * Used via the #daemon conditional import in browser/edge runtimes.
7
+ */
8
+
9
+ class DaemonContext {
10
+ readonly isDaemon = false as const
11
+
12
+ constructor() {}
13
+
14
+ forCommand(_commandName: string): DaemonContext {
15
+ return new DaemonContext()
16
+ }
17
+
18
+ async start(): Promise<void> {
19
+ throw new Error('ctx.daemon.start() is only available in Node.js runtimes.')
20
+ }
21
+
22
+ async stop(): Promise<void> {}
23
+
24
+ async isRunning(): Promise<boolean> {
25
+ return false
26
+ }
27
+ }
28
+
29
+ function createDaemonContext(): DaemonContext {
30
+ return new DaemonContext()
31
+ }
32
+
33
+ export { DaemonContext, createDaemonContext }
34
+ export type { DaemonStartOptions } from './daemon.js'