@marcusrbrown/infra 0.13.0 → 0.13.2

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.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,30 +0,0 @@
1
- import type {goke} from 'goke'
2
-
3
- export const KEEWEB_URL = 'https://kw.igg.ms/'
4
-
5
- type CliInstance = ReturnType<typeof goke>
6
-
7
- export function getOpenerCommand(): string {
8
- return process.platform === 'darwin' ? 'open' : 'xdg-open'
9
- }
10
-
11
- export function registerKeewebOpen(cli: CliInstance): void {
12
- cli.command('keeweb open', 'Open KeeWeb in the default browser').action(async () => {
13
- console.log(KEEWEB_URL)
14
-
15
- const opener = getOpenerCommand()
16
- const openerPath = Bun.which(opener)
17
-
18
- if (!openerPath) {
19
- console.log(`No browser launcher found (${opener}). Open the URL above manually.`)
20
- return
21
- }
22
-
23
- // Fire-and-forget — don't await. macOS `open` returns immediately but
24
- // Linux `xdg-open` may block until the browser closes.
25
- Bun.spawn([opener, KEEWEB_URL], {
26
- stdout: 'ignore',
27
- stderr: 'ignore',
28
- })
29
- })
30
- }
@@ -1,509 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from 'bun:test'
2
-
3
- import {createCapturedCtx, expectCapturedToInclude} from '../../__test__/mcp-ctx-fixture'
4
- import {
5
- checkContentHash,
6
- checkHttpReachability,
7
- checkLastDeploy,
8
- formatDate,
9
- formatDurationMs,
10
- hashSha256,
11
- keewebStatusAction,
12
- } from './status'
13
-
14
- const originalFetch = globalThis.fetch
15
-
16
- type SpawnResult = ReturnType<typeof Bun.spawn>
17
-
18
- function createFetchImplementation(
19
- handler: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => ReturnType<typeof fetch>,
20
- ): typeof fetch {
21
- return Object.assign(
22
- (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => handler(input, init),
23
- {preconnect: originalFetch.preconnect},
24
- )
25
- }
26
-
27
- function textStream(value: string): ReadableStream<Uint8Array> {
28
- return new ReadableStream({
29
- start(controller) {
30
- controller.enqueue(new TextEncoder().encode(value))
31
- controller.close()
32
- },
33
- })
34
- }
35
-
36
- function mockSpawnResult(exitCode: number, stdout: string, stderr = ''): SpawnResult {
37
- return {
38
- exited: Promise.resolve(exitCode),
39
- stdout: textStream(stdout),
40
- stderr: textStream(stderr),
41
- } as unknown as SpawnResult
42
- }
43
-
44
- describe('keeweb status helpers', () => {
45
- let fetchMock: ReturnType<typeof mock<typeof fetch>>
46
- let fileSpy: ReturnType<typeof spyOn<typeof Bun, 'file'>> | undefined
47
- let spawnSpy: ReturnType<typeof spyOn<typeof Bun, 'spawn'>> | undefined
48
-
49
- beforeEach(() => {
50
- fetchMock = mock(
51
- createFetchImplementation(async () => {
52
- throw new Error('Unexpected fetch call')
53
- }),
54
- )
55
- globalThis.fetch = Object.assign(fetchMock, {preconnect: originalFetch.preconnect})
56
- })
57
-
58
- afterEach(() => {
59
- globalThis.fetch = originalFetch
60
- fileSpy?.mockRestore()
61
- fileSpy = undefined
62
- spawnSpy?.mockRestore()
63
- spawnSpy = undefined
64
- })
65
-
66
- describe('hashSha256', () => {
67
- it('returns the known digest for hello', () => {
68
- expect(hashSha256('hello')).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
69
- })
70
-
71
- it('returns the known digest for an empty string', () => {
72
- expect(hashSha256('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
73
- })
74
- })
75
-
76
- describe('formatDate', () => {
77
- it('formats valid ISO dates', () => {
78
- const value = '2026-01-15T10:30:00Z'
79
- const formatted = formatDate(value)
80
-
81
- expect(formatted).not.toBe(value)
82
- expect(formatted).not.toContain('NaN')
83
- expect(formatted).toContain('2026')
84
- })
85
-
86
- it('passes through invalid dates', () => {
87
- expect(formatDate('not-a-date')).toBe('not-a-date')
88
- })
89
- })
90
-
91
- describe('formatDurationMs', () => {
92
- it('formats positive durations', () => {
93
- expect(formatDurationMs(1234)).toBe('1234ms')
94
- })
95
-
96
- it('formats zero duration', () => {
97
- expect(formatDurationMs(0)).toBe('0ms')
98
- })
99
-
100
- it('clamps negative durations to zero', () => {
101
- expect(formatDurationMs(-5)).toBe('0ms')
102
- })
103
- })
104
-
105
- describe('checkHttpReachability', () => {
106
- it('returns ok for HTTP 200', async () => {
107
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
108
-
109
- const result = await checkHttpReachability(false)
110
-
111
- expect(result.level).toBe('ok')
112
- expect(result.summary).toContain('GET https://kw.igg.ms/ → 200')
113
- })
114
-
115
- it('returns error for HTTP 500', async () => {
116
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('error', {status: 500})))
117
-
118
- const result = await checkHttpReachability(false)
119
-
120
- expect(result.level).toBe('error')
121
- expect(result.summary).toContain('GET https://kw.igg.ms/ → 500')
122
- })
123
-
124
- it('returns error details for network failures', async () => {
125
- fetchMock.mockImplementation(
126
- createFetchImplementation(async () => {
127
- throw new Error('Network timeout')
128
- }),
129
- )
130
-
131
- const result = await checkHttpReachability(true)
132
-
133
- expect(result.level).toBe('error')
134
- expect(result.summary).toContain('Network timeout')
135
- expect(result.details).toEqual(['URL: https://kw.igg.ms/', 'Timeout: 10000ms'])
136
- })
137
- })
138
-
139
- describe('checkLastDeploy', () => {
140
- it('returns ok for valid gh output', async () => {
141
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
142
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
143
- )
144
-
145
- const result = await checkLastDeploy(false)
146
-
147
- expect(result.level).toBe('ok')
148
- expect(result.summary).toBe(formatDate('2026-01-15T10:30:00Z'))
149
- expect(result.details).toEqual(['Run URL: https://example.com'])
150
- })
151
-
152
- it('returns warning when no successful runs are found', async () => {
153
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => mockSpawnResult(0, '[]'))
154
-
155
- const result = await checkLastDeploy(false)
156
-
157
- expect(result.level).toBe('warning')
158
- expect(result.summary).toContain('No successful')
159
- })
160
-
161
- it('returns warning when gh exits non-zero', async () => {
162
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => mockSpawnResult(1, '', 'boom'))
163
-
164
- const result = await checkLastDeploy(false)
165
-
166
- expect(result.level).toBe('warning')
167
- expect(result.summary).toContain('gh exited 1')
168
- })
169
-
170
- it('returns warning when gh cannot be invoked', async () => {
171
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => {
172
- throw new Error('gh not found')
173
- })
174
-
175
- const result = await checkLastDeploy(false)
176
-
177
- expect(result.level).toBe('warning')
178
- expect(result.summary).toContain('Unable to query')
179
- expect(result.summary).toContain('gh not found')
180
- })
181
- })
182
-
183
- describe('checkContentHash', () => {
184
- it('does not emit a path-noisy warning when running from a packaged install (bunx)', async () => {
185
- // Simulate packaged layout: moduleDir is inside node_modules (bunx temp install)
186
- const packagedDir = '/private/var/folders/xx/T/bunx-1234/node_modules/@marcusrbrown/infra/src/commands/keeweb'
187
- // Bun.file should not be called for the dist path in packaged mode,
188
- // but if it is, return false to avoid false positives
189
- fileSpy = spyOn(Bun, 'file').mockImplementation(
190
- () =>
191
- ({
192
- exists: async () => false,
193
- }) as ReturnType<typeof Bun.file>,
194
- )
195
-
196
- const result = await checkContentHash(false, packagedDir)
197
-
198
- // Must NOT contain the bogus node_modules path in the summary
199
- expect(result.summary).not.toContain('node_modules/apps/keeweb')
200
- expect(result.summary).not.toContain('/private/var/folders')
201
- // Must clearly communicate that local dist is unavailable from packaged CLI
202
- expect(result.summary.toLowerCase()).toMatch(/not available|unavailable|packaged|source checkout/)
203
- // Level should be info (neutral — not a warning for packaged installs)
204
- expect(result.level).toBe('info')
205
- })
206
-
207
- it('does not emit path-noisy warning for any node_modules install path', async () => {
208
- // Simulate npm/npx global install: node_modules path but not bunx temp
209
- const npmInstallDir = '/usr/local/lib/node_modules/@marcusrbrown/infra/src/commands/keeweb'
210
- fileSpy = spyOn(Bun, 'file').mockImplementation(
211
- () =>
212
- ({
213
- exists: async () => false,
214
- }) as ReturnType<typeof Bun.file>,
215
- )
216
-
217
- const result = await checkContentHash(false, npmInstallDir)
218
-
219
- expect(result.summary).not.toContain('node_modules/apps/keeweb')
220
- expect(result.summary.toLowerCase()).toMatch(/not available|unavailable|packaged|source checkout/)
221
- })
222
-
223
- it('does not emit path-noisy warning when running from an unpacked tarball outside node_modules', async () => {
224
- // Simulate unpacked tarball layout: e.g. `npm pack` + extract to /tmp/pkg/package/
225
- // The path does NOT contain node_modules, but also has no repo apps/keeweb/src tree.
226
- const unpackedTarballDir = '/tmp/pkg/package/src/commands/keeweb'
227
- fileSpy = spyOn(Bun, 'file').mockImplementation(
228
- () =>
229
- ({
230
- exists: async () => false,
231
- }) as ReturnType<typeof Bun.file>,
232
- )
233
-
234
- const result = await checkContentHash(false, unpackedTarballDir)
235
-
236
- // Must NOT contain the raw temp path in the summary
237
- expect(result.summary).not.toContain('/tmp/pkg/package')
238
- // Must NOT contain the path-noisy "Local dist file not found: ..." message
239
- expect(result.summary).not.toMatch(/Local dist file not found/)
240
- // Must clearly communicate that local dist is unavailable from packaged CLI
241
- expect(result.summary.toLowerCase()).toMatch(/not available|unavailable|packaged|source checkout/)
242
- // Level should be info (neutral — not a warning for packaged installs)
243
- expect(result.level).toBe('info')
244
- })
245
-
246
- it('packaged install content hash level is info, not warning', async () => {
247
- // Packaged/non-source installs should not degrade status with a warning.
248
- // The content hash check is a source-only local comparison; when not in a
249
- // source checkout, the result should be neutral (info), not warning.
250
- const packagedDir = '/private/var/folders/xx/T/bunx-1234/node_modules/@marcusrbrown/infra/src/commands/keeweb'
251
- fileSpy = spyOn(Bun, 'file').mockImplementation(
252
- () =>
253
- ({
254
- exists: async () => false,
255
- }) as ReturnType<typeof Bun.file>,
256
- )
257
-
258
- const result = await checkContentHash(false, packagedDir)
259
-
260
- expect(result.level).toBe('info')
261
- expect(result.summary.toLowerCase()).toMatch(/not available|unavailable|packaged|source checkout/)
262
- })
263
-
264
- it('source checkout with matching hashes returns ok', async () => {
265
- // Simulate source checkout: marker file exists, dist file exists, hashes match.
266
- const sourceCheckoutDir = '/home/user/projects/infra/packages/cli/src/commands/keeweb'
267
- const htmlContent = '<html>KeeWeb</html>'
268
-
269
- fileSpy = spyOn(Bun, 'file').mockImplementation((pathArg: unknown) => {
270
- const p = String(pathArg)
271
- if (p.endsWith('apps/keeweb/src/build.ts')) {
272
- return {exists: async () => true} as ReturnType<typeof Bun.file>
273
- }
274
- if (p.endsWith('apps/keeweb/dist/index.html')) {
275
- return {
276
- exists: async () => true,
277
- text: async () => htmlContent,
278
- } as ReturnType<typeof Bun.file>
279
- }
280
- return {exists: async () => false} as ReturnType<typeof Bun.file>
281
- })
282
-
283
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response(htmlContent, {status: 200})))
284
-
285
- const result = await checkContentHash(false, sourceCheckoutDir)
286
-
287
- expect(result.level).toBe('ok')
288
- expect(result.summary).toContain('matches')
289
- })
290
-
291
- it('source checkout with mismatched hashes returns warning', async () => {
292
- // Simulate source checkout: marker file exists, dist file exists, hashes differ.
293
- const sourceCheckoutDir = '/home/user/projects/infra/packages/cli/src/commands/keeweb'
294
- const localHtml = '<html>KeeWeb local</html>'
295
- const remoteHtml = '<html>KeeWeb remote</html>'
296
-
297
- fileSpy = spyOn(Bun, 'file').mockImplementation((pathArg: unknown) => {
298
- const p = String(pathArg)
299
- if (p.endsWith('apps/keeweb/src/build.ts')) {
300
- return {exists: async () => true} as ReturnType<typeof Bun.file>
301
- }
302
- if (p.endsWith('apps/keeweb/dist/index.html')) {
303
- return {
304
- exists: async () => true,
305
- text: async () => localHtml,
306
- } as ReturnType<typeof Bun.file>
307
- }
308
- return {exists: async () => false} as ReturnType<typeof Bun.file>
309
- })
310
-
311
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response(remoteHtml, {status: 200})))
312
-
313
- const result = await checkContentHash(false, sourceCheckoutDir)
314
-
315
- expect(result.level).toBe('warning')
316
- expect(result.summary).toContain('differs')
317
- })
318
-
319
- it('resolves dist path at correct depth from restructured location (path-sensitive mock)', async () => {
320
- // Regression test for P1 path depth bug: after restructure from
321
- // commands/keeweb-status.ts to commands/keeweb/status.ts, the path
322
- // needs 5 levels of ../ to reach apps/keeweb/dist/index.html.
323
- //
324
- // Uses path-sensitive mock: marker returns true, dist returns false.
325
- // This exercises the dist path check and lets us assert the correct path.
326
- const capturedPaths: string[] = []
327
- fileSpy = spyOn(Bun, 'file').mockImplementation((pathArg: unknown) => {
328
- const p = String(pathArg)
329
- capturedPaths.push(p)
330
- if (p.endsWith('apps/keeweb/src/build.ts')) {
331
- return {exists: async () => true} as ReturnType<typeof Bun.file>
332
- }
333
- return {exists: async () => false} as ReturnType<typeof Bun.file>
334
- })
335
-
336
- await checkContentHash(false)
337
-
338
- // The dist path must appear in the captured paths
339
- const distPath = capturedPaths.find(p => p.endsWith('apps/keeweb/dist/index.html'))
340
- expect(distPath).toBeDefined()
341
- expect(distPath).not.toContain('commands/keeweb/apps')
342
- })
343
-
344
- it('returns warning when the local dist file does not exist in source checkout (path-sensitive mock)', async () => {
345
- // Simulate source checkout: marker exists, dist file missing (needs a build).
346
- // Uses path-sensitive mock instead of call-count ordering.
347
- const sourceCheckoutDir = '/home/user/projects/infra/packages/cli/src/commands/keeweb'
348
- fileSpy = spyOn(Bun, 'file').mockImplementation((pathArg: unknown) => {
349
- const p = String(pathArg)
350
- if (p.endsWith('apps/keeweb/src/build.ts')) {
351
- return {exists: async () => true} as ReturnType<typeof Bun.file>
352
- }
353
- // dist/index.html → missing
354
- return {exists: async () => false} as ReturnType<typeof Bun.file>
355
- })
356
-
357
- const result = await checkContentHash(false, sourceCheckoutDir)
358
-
359
- expect(result.level).toBe('warning')
360
- expect(result.summary).toContain('not found')
361
- expect(result.details).toEqual(expect.arrayContaining(['Run: bun run --cwd apps/keeweb build']))
362
- expect(result.summary).not.toContain('node_modules')
363
- })
364
- })
365
- })
366
-
367
- describe('keewebStatusAction (Tier-2 ctx capture)', () => {
368
- let fetchMock: ReturnType<typeof mock<typeof fetch>>
369
- let fileSpy: ReturnType<typeof spyOn<typeof Bun, 'file'>> | undefined
370
- let spawnSpy: ReturnType<typeof spyOn<typeof Bun, 'spawn'>> | undefined
371
-
372
- beforeEach(() => {
373
- fetchMock = mock(
374
- createFetchImplementation(async () => {
375
- throw new Error('Unexpected fetch call')
376
- }),
377
- )
378
- globalThis.fetch = Object.assign(fetchMock, {preconnect: originalFetch.preconnect})
379
- })
380
-
381
- afterEach(() => {
382
- globalThis.fetch = originalFetch
383
- fileSpy?.mockRestore()
384
- fileSpy = undefined
385
- spawnSpy?.mockRestore()
386
- spawnSpy = undefined
387
- })
388
-
389
- it('writes "KeeWeb status" header to ctx.console.log (not global console)', async () => {
390
- // Mock HTTP 200
391
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
392
-
393
- // Mock gh CLI returning a successful run
394
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
395
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
396
- )
397
-
398
- // Mock local dist file as missing (avoids filesystem dependency)
399
- fileSpy = spyOn(Bun, 'file').mockImplementation(
400
- () =>
401
- ({
402
- exists: async () => false,
403
- }) as ReturnType<typeof Bun.file>,
404
- )
405
-
406
- const {ctx, captured} = createCapturedCtx()
407
-
408
- await keewebStatusAction({verbose: false}, ctx)
409
-
410
- // 'KeeWeb status' is the header line in the formatted output — the contract
411
- // marker confirming the action printed to ctx.console.log (captured) rather
412
- // than the real global console (which would not appear in `captured`).
413
- expect(expectCapturedToInclude(captured, 'KeeWeb status')).toBe(true)
414
- // Summary footer is always emitted; pairing it with the header proves both
415
- // ends of the formatted output flow through ctx capture.
416
- expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
417
- })
418
-
419
- it('writes HTTP check result to captured stdout', async () => {
420
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
421
-
422
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
423
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
424
- )
425
-
426
- fileSpy = spyOn(Bun, 'file').mockImplementation(
427
- () =>
428
- ({
429
- exists: async () => false,
430
- }) as ReturnType<typeof Bun.file>,
431
- )
432
-
433
- const {ctx, captured} = createCapturedCtx()
434
-
435
- await keewebStatusAction({verbose: false}, ctx)
436
-
437
- expect(expectCapturedToInclude(captured, 'HTTP reachability')).toBe(true)
438
- expect(expectCapturedToInclude(captured, '[OK]')).toBe(true)
439
- })
440
-
441
- it('writes summary line to captured stdout', async () => {
442
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
443
-
444
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
445
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
446
- )
447
-
448
- fileSpy = spyOn(Bun, 'file').mockImplementation(
449
- () =>
450
- ({
451
- exists: async () => false,
452
- }) as ReturnType<typeof Bun.file>,
453
- )
454
-
455
- const {ctx, captured} = createCapturedCtx()
456
-
457
- await keewebStatusAction({verbose: false}, ctx)
458
-
459
- expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
460
- expect(expectCapturedToInclude(captured, '3 checks')).toBe(true)
461
- })
462
-
463
- it('calls ctx.process.exit(1) when there are errors', async () => {
464
- // HTTP 500 → error level
465
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('error', {status: 500})))
466
-
467
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
468
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
469
- )
470
-
471
- fileSpy = spyOn(Bun, 'file').mockImplementation(
472
- () =>
473
- ({
474
- exists: async () => false,
475
- }) as ReturnType<typeof Bun.file>,
476
- )
477
-
478
- const {ctx, captured} = createCapturedCtx()
479
-
480
- // ctx.process.exit throws MockProcessExit — catch it
481
- await expect(keewebStatusAction({verbose: false}, ctx)).rejects.toMatchObject({
482
- name: 'MockProcessExit',
483
- code: 1,
484
- })
485
-
486
- expect(captured.exit).toEqual({code: 1})
487
- })
488
-
489
- it('does not call ctx.process.exit when all checks pass', async () => {
490
- fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
491
-
492
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
493
- mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
494
- )
495
-
496
- fileSpy = spyOn(Bun, 'file').mockImplementation(
497
- () =>
498
- ({
499
- exists: async () => false,
500
- }) as ReturnType<typeof Bun.file>,
501
- )
502
-
503
- const {ctx, captured} = createCapturedCtx()
504
-
505
- await keewebStatusAction({verbose: false}, ctx)
506
-
507
- expect(captured.exit).toBeNull()
508
- })
509
- })