@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.
- package/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /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
|
-
})
|