@marcusrbrown/infra 0.9.12 → 0.9.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -265,6 +265,79 @@ describe('backupGatewayCa — atomic write (COR2)', () => {
265
265
  })
266
266
  })
267
267
 
268
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
269
+
270
+ describe('backupGatewayCa — SSH command includes UserKnownHostsFile', () => {
271
+ let tmpOutput: string
272
+
273
+ beforeEach(() => {
274
+ tmpOutput = `/tmp/test-backup-known-hosts-${Date.now()}.tar`
275
+ })
276
+
277
+ afterEach(async () => {
278
+ try {
279
+ ;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
280
+ } catch {
281
+ // ignore
282
+ }
283
+ })
284
+
285
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
286
+ let capturedCmd: string[] = []
287
+ const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
288
+ capturedCmd = cmd
289
+ return {
290
+ stdout: new ReadableStream({
291
+ start(controller) {
292
+ controller.enqueue(new Uint8Array([1, 2, 3]))
293
+ controller.close()
294
+ },
295
+ }),
296
+ stderr: new ReadableStream({
297
+ start(controller) {
298
+ controller.close()
299
+ },
300
+ }),
301
+ exited: Promise.resolve(0),
302
+ }
303
+ }
304
+
305
+ await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
306
+
307
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
308
+ expect(knownHostsIdx).toBeGreaterThan(-1)
309
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
310
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
311
+ })
312
+
313
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
314
+ let capturedCmd: string[] = []
315
+ const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
316
+ capturedCmd = cmd
317
+ return {
318
+ stdout: new ReadableStream({
319
+ start(controller) {
320
+ controller.enqueue(new Uint8Array([1, 2, 3]))
321
+ controller.close()
322
+ },
323
+ }),
324
+ stderr: new ReadableStream({
325
+ start(controller) {
326
+ controller.close()
327
+ },
328
+ }),
329
+ exited: Promise.resolve(0),
330
+ }
331
+ }
332
+
333
+ await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
334
+
335
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
336
+ expect(strictIdx).toBeGreaterThan(-1)
337
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
338
+ })
339
+ })
340
+
268
341
  // ─── SEC1: Tmp file born with mode 0600 (no chmod race) ──────────────────────
269
342
 
270
343
  describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically', () => {
@@ -5,6 +5,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
5
5
  import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
6
6
 
7
7
  import {z} from 'zod'
8
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
8
9
  import {validateGatewayHost} from './host'
9
10
 
10
11
  declare const process: {
@@ -69,6 +70,7 @@ export async function backupGatewayCa(
69
70
  'BatchMode=yes',
70
71
  '-o',
71
72
  'StrictHostKeyChecking=yes',
73
+ ...buildKnownHostsArgs(),
72
74
  `root@${opts.host}`,
73
75
  `docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine tar -cf - -C /src ${CA_CERT_FILE} ${CA_KEY_FILE}`,
74
76
  ]
@@ -220,3 +220,44 @@ describe('tail forwarding', () => {
220
220
  expect(capturedCmd.join(' ')).toContain('--tail=25')
221
221
  })
222
222
  })
223
+
224
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
225
+
226
+ describe('streamGatewayLogs — SSH command includes UserKnownHostsFile', () => {
227
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
228
+ delete process.env.CI
229
+
230
+ const {streamGatewayLogs} = await import('./logs')
231
+
232
+ let capturedCmd: string[] = []
233
+ const spawnCapture: SpawnFn = (cmd, _opts) => {
234
+ capturedCmd = cmd
235
+ return {exited: Promise.resolve(0)}
236
+ }
237
+
238
+ await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
239
+
240
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
241
+ expect(knownHostsIdx).toBeGreaterThan(-1)
242
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
243
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
244
+ })
245
+
246
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
247
+ delete process.env.CI
248
+
249
+ const {streamGatewayLogs} = await import('./logs')
250
+
251
+ let capturedCmd: string[] = []
252
+ const spawnCapture: SpawnFn = (cmd, _opts) => {
253
+ capturedCmd = cmd
254
+ return {exited: Promise.resolve(0)}
255
+ }
256
+
257
+ await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
258
+
259
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
260
+ expect(strictIdx).toBeGreaterThan(-1)
261
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
262
+ })
263
+ })
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateGatewayHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -88,6 +89,7 @@ export async function streamGatewayLogs(
88
89
  'BatchMode=yes',
89
90
  '-o',
90
91
  'StrictHostKeyChecking=yes',
92
+ ...buildKnownHostsArgs(),
91
93
  `root@${opts.host}`,
92
94
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
93
95
  ]
@@ -403,6 +403,162 @@ describe('restoreGatewayCa — SEC2: unguessable remote tmp path via mktemp', ()
403
403
  })
404
404
  })
405
405
 
406
+ // ─── SSH and SCP commands include repo-pinned UserKnownHostsFile ─────────────
407
+
408
+ describe('restoreGatewayCa — SSH and SCP commands include UserKnownHostsFile', () => {
409
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to every ssh invocation', async () => {
410
+ const tarDir = `/tmp/test-kh-ssh-tar-dir-${Date.now()}`
411
+ const realTar = `/tmp/test-kh-ssh-tar-${Date.now()}.tar`
412
+ const certContent = 'CERT'
413
+ const keyContent = 'KEY'
414
+ Bun.spawnSync(['mkdir', '-p', tarDir])
415
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
416
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
417
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
418
+
419
+ const capturedCmds: string[][] = []
420
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
421
+ capturedCmds.push([...cmd])
422
+ const cmdStr = cmd.join(' ')
423
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
424
+ const isMktemp = cmdStr.includes('mktemp')
425
+ const encoder = new TextEncoder()
426
+ let stdout = ''
427
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
428
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-test.tar\n'
429
+ else stdout = certContent
430
+ return {
431
+ stdout: new ReadableStream({
432
+ start(controller) {
433
+ controller.enqueue(encoder.encode(stdout))
434
+ controller.close()
435
+ },
436
+ }),
437
+ stderr: new ReadableStream({
438
+ start(controller) {
439
+ controller.close()
440
+ },
441
+ }),
442
+ exited: Promise.resolve(0),
443
+ }
444
+ }
445
+
446
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
447
+
448
+ // Every ssh invocation must include UserKnownHostsFile
449
+ const sshCmds = capturedCmds.filter(c => c[0] === 'ssh')
450
+ expect(sshCmds.length).toBeGreaterThan(0)
451
+ for (const sshCmd of sshCmds) {
452
+ const knownHostsIdx = sshCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
453
+ expect(knownHostsIdx).toBeGreaterThan(-1)
454
+ expect(sshCmd[knownHostsIdx - 1]).toBe('-o')
455
+ expect(sshCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
456
+ }
457
+
458
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
459
+ })
460
+
461
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to the scp invocation', async () => {
462
+ const tarDir = `/tmp/test-kh-scp-tar-dir-${Date.now()}`
463
+ const realTar = `/tmp/test-kh-scp-tar-${Date.now()}.tar`
464
+ const certContent = 'CERT'
465
+ const keyContent = 'KEY'
466
+ Bun.spawnSync(['mkdir', '-p', tarDir])
467
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
468
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
469
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
470
+
471
+ const capturedCmds: string[][] = []
472
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
473
+ capturedCmds.push([...cmd])
474
+ const cmdStr = cmd.join(' ')
475
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
476
+ const isMktemp = cmdStr.includes('mktemp')
477
+ const encoder = new TextEncoder()
478
+ let stdout = ''
479
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
480
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-scp-test.tar\n'
481
+ else stdout = certContent
482
+ return {
483
+ stdout: new ReadableStream({
484
+ start(controller) {
485
+ controller.enqueue(encoder.encode(stdout))
486
+ controller.close()
487
+ },
488
+ }),
489
+ stderr: new ReadableStream({
490
+ start(controller) {
491
+ controller.close()
492
+ },
493
+ }),
494
+ exited: Promise.resolve(0),
495
+ }
496
+ }
497
+
498
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
499
+
500
+ const scpCmd = capturedCmds.find(c => c[0] === 'scp')
501
+ expect(scpCmd).toBeDefined()
502
+ const knownHostsIdx = scpCmd!.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
503
+ expect(knownHostsIdx).toBeGreaterThan(-1)
504
+ expect(scpCmd![knownHostsIdx - 1]).toBe('-o')
505
+ expect(scpCmd![knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
506
+
507
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
508
+ })
509
+
510
+ it('does not weaken StrictHostKeyChecking in ssh or scp commands', async () => {
511
+ const tarDir = `/tmp/test-kh-strict-tar-dir-${Date.now()}`
512
+ const realTar = `/tmp/test-kh-strict-tar-${Date.now()}.tar`
513
+ const certContent = 'CERT'
514
+ const keyContent = 'KEY'
515
+ Bun.spawnSync(['mkdir', '-p', tarDir])
516
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
517
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
518
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
519
+
520
+ const capturedCmds: string[][] = []
521
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
522
+ capturedCmds.push([...cmd])
523
+ const cmdStr = cmd.join(' ')
524
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
525
+ const isMktemp = cmdStr.includes('mktemp')
526
+ const encoder = new TextEncoder()
527
+ let stdout = ''
528
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
529
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-strict-test.tar\n'
530
+ else stdout = certContent
531
+ return {
532
+ stdout: new ReadableStream({
533
+ start(controller) {
534
+ controller.enqueue(encoder.encode(stdout))
535
+ controller.close()
536
+ },
537
+ }),
538
+ stderr: new ReadableStream({
539
+ start(controller) {
540
+ controller.close()
541
+ },
542
+ }),
543
+ exited: Promise.resolve(0),
544
+ }
545
+ }
546
+
547
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
548
+
549
+ // All ssh and scp commands must have StrictHostKeyChecking=yes
550
+ const sshAndScpCmds = capturedCmds.filter(c => c[0] === 'ssh' || c[0] === 'scp')
551
+ expect(sshAndScpCmds.length).toBeGreaterThan(0)
552
+ for (const cmd of sshAndScpCmds) {
553
+ const strictIdx = cmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
554
+ expect(strictIdx).toBeGreaterThan(-1)
555
+ expect(cmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
556
+ }
557
+
558
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
559
+ })
560
+ })
561
+
406
562
  // ─── COR1: validateBackupArchive ─────────────────────────────────────────────
407
563
 
408
564
  describe('validateBackupArchive — COR1', () => {
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateGatewayHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -38,7 +39,16 @@ export type RestoreResult = {ok: true; confirmed: true} | {ok: false; error: str
38
39
 
39
40
  /** Returns the argv array for an SSH command to the given host. */
40
41
  function sshCommand(host: string, remote: string): string[] {
41
- return ['ssh', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', `root@${host}`, remote]
42
+ return [
43
+ 'ssh',
44
+ '-o',
45
+ 'BatchMode=yes',
46
+ '-o',
47
+ 'StrictHostKeyChecking=yes',
48
+ ...buildKnownHostsArgs(),
49
+ `root@${host}`,
50
+ remote,
51
+ ]
42
52
  }
43
53
 
44
54
  async function spawnText(
@@ -143,6 +153,7 @@ export async function restoreGatewayCa(
143
153
  'BatchMode=yes',
144
154
  '-o',
145
155
  'StrictHostKeyChecking=yes',
156
+ ...buildKnownHostsArgs(),
146
157
  opts.input,
147
158
  `root@${opts.host}:${tmpRemote}`,
148
159
  ]
@@ -139,6 +139,71 @@ function makeSpawnError(message: string): SpawnFn {
139
139
  }
140
140
  }
141
141
 
142
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
143
+
144
+ describe('getGatewayComposeStatus — SSH command includes UserKnownHostsFile', () => {
145
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
146
+ const {getGatewayComposeStatus} = await import('./status')
147
+ let capturedCmd: string[] = []
148
+
149
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
150
+ capturedCmd = cmd
151
+ const encoder = new TextEncoder()
152
+ return {
153
+ stdout: new ReadableStream({
154
+ start(controller) {
155
+ controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
156
+ controller.close()
157
+ },
158
+ }),
159
+ stderr: new ReadableStream({
160
+ start(c) {
161
+ c.close()
162
+ },
163
+ }),
164
+ exited: Promise.resolve(0),
165
+ }
166
+ }
167
+
168
+ await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
169
+
170
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
171
+ expect(knownHostsIdx).toBeGreaterThan(-1)
172
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
173
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
174
+ })
175
+
176
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
177
+ const {getGatewayComposeStatus} = await import('./status')
178
+ let capturedCmd: string[] = []
179
+
180
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
181
+ capturedCmd = cmd
182
+ const encoder = new TextEncoder()
183
+ return {
184
+ stdout: new ReadableStream({
185
+ start(controller) {
186
+ controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
187
+ controller.close()
188
+ },
189
+ }),
190
+ stderr: new ReadableStream({
191
+ start(c) {
192
+ c.close()
193
+ },
194
+ }),
195
+ exited: Promise.resolve(0),
196
+ }
197
+ }
198
+
199
+ await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
200
+
201
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
202
+ expect(strictIdx).toBeGreaterThan(-1)
203
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
204
+ })
205
+ })
206
+
142
207
  describe('getGatewayComposeStatus — host validation (SEC1)', () => {
143
208
  it('rejects a leading-hyphen host and does not invoke ssh', async () => {
144
209
  const {getGatewayComposeStatus} = await import('./status')
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
4
  import {z} from 'zod'
5
5
 
6
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
6
7
  import {redactHost} from '../../lib/redact'
7
8
  // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
8
9
  import {validateGatewayHost} from './host'
@@ -108,6 +109,7 @@ export async function getGatewayComposeStatus(
108
109
  'BatchMode=yes',
109
110
  '-o',
110
111
  'StrictHostKeyChecking=yes',
112
+ ...buildKnownHostsArgs(),
111
113
  `root@${host}`,
112
114
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
113
115
  ]
@@ -181,23 +181,73 @@ describe('keeweb status helpers', () => {
181
181
  })
182
182
 
183
183
  describe('checkContentHash', () => {
184
- it('resolves dist path at correct depth from restructured location', async () => {
185
- // Regression test for P1 path depth bug: after restructure from
186
- // commands/keeweb-status.ts to commands/keeweb/status.ts, the path
187
- // needs 5 levels of ../ to reach apps/keeweb/dist/index.html
188
- let capturedPath = ''
189
- fileSpy = spyOn(Bun, 'file').mockImplementation((...args: unknown[]) => {
190
- capturedPath = String(args[0])
191
- return {exists: async () => false} as ReturnType<typeof Bun.file>
192
- })
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
+ )
193
195
 
194
- await checkContentHash(false)
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)
195
235
 
196
- expect(capturedPath).toContain('apps/keeweb/dist/index.html')
197
- expect(capturedPath).not.toContain('commands/keeweb/apps')
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')
198
244
  })
199
245
 
200
- it('returns warning when the local dist file does not exist', async () => {
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'
201
251
  fileSpy = spyOn(Bun, 'file').mockImplementation(
202
252
  () =>
203
253
  ({
@@ -205,10 +255,111 @@ describe('keeweb status helpers', () => {
205
255
  }) as ReturnType<typeof Bun.file>,
206
256
  )
207
257
 
208
- const result = await checkContentHash(false)
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)
209
358
 
210
359
  expect(result.level).toBe('warning')
211
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')
212
363
  })
213
364
  })
214
365
  })
@@ -10,7 +10,7 @@ const SITE_URL = 'https://kw.igg.ms/'
10
10
  const GH_REPO = 'marcusrbrown/infra'
11
11
  const HTTP_TIMEOUT_MS = 10_000
12
12
 
13
- type CheckLevel = 'ok' | 'warning' | 'error'
13
+ type CheckLevel = 'ok' | 'info' | 'warning' | 'error'
14
14
 
15
15
  interface CheckResult {
16
16
  title: string
@@ -31,6 +31,10 @@ function levelLabel(level: CheckLevel): string {
31
31
  return 'OK'
32
32
  }
33
33
 
34
+ if (level === 'info') {
35
+ return 'INFO'
36
+ }
37
+
34
38
  if (level === 'warning') {
35
39
  return 'WARN'
36
40
  }
@@ -192,8 +196,44 @@ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
192
196
  }
193
197
  }
194
198
 
195
- export async function checkContentHash(verbose: boolean): Promise<CheckResult> {
196
- const distIndexPath = path.resolve(import.meta.dir, '../../../../../apps/keeweb/dist/index.html')
199
+ /**
200
+ * Detect whether moduleDir belongs to a source checkout of this monorepo.
201
+ *
202
+ * A source checkout has the repo root at 5 levels above the keeweb command
203
+ * directory (packages/cli/src/commands/keeweb → repo root). We confirm the
204
+ * layout by checking for a source-only marker file that is never shipped in
205
+ * the CLI package. This is more robust than checking for `node_modules` in
206
+ * the path, which misses unpacked tarballs and global-style installs.
207
+ */
208
+ async function isSourceCheckout(resolvedModuleDir: string): Promise<boolean> {
209
+ // Candidate repo root is 5 levels up from packages/cli/src/commands/keeweb
210
+ const candidateRoot = path.resolve(resolvedModuleDir, '../../../../../')
211
+ // apps/keeweb/src/build.ts is a source-only file never included in the CLI package
212
+ const sourceMarker = path.join(candidateRoot, 'apps', 'keeweb', 'src', 'build.ts')
213
+ return Bun.file(sourceMarker).exists()
214
+ }
215
+
216
+ export async function checkContentHash(verbose: boolean, moduleDir?: string): Promise<CheckResult> {
217
+ const resolvedModuleDir = moduleDir ?? import.meta.dir
218
+ const distIndexPath = path.resolve(resolvedModuleDir, '../../../../../apps/keeweb/dist/index.html')
219
+
220
+ // When running from a packaged install (bunx, npm global, unpacked tarball, etc.),
221
+ // the resolved path has no repo apps/keeweb source tree — the KeeWeb dist is not
222
+ // shipped with the CLI package. Emit a clean, non-path-noisy message instead of
223
+ // a scary temp path. We detect source checkout by looking for a source-only marker
224
+ // file rather than relying on `node_modules` in the path (which misses tarballs).
225
+ const sourceCheckout = await isSourceCheckout(resolvedModuleDir)
226
+ if (!sourceCheckout) {
227
+ return {
228
+ title: 'Content hash',
229
+ level: 'info',
230
+ summary: 'Content hash not available: local KeeWeb dist is only present in a source checkout',
231
+ details: verbose
232
+ ? ['Clone the repo and run: bun run --cwd apps/keeweb build', 'Then re-run status from the source checkout']
233
+ : undefined,
234
+ }
235
+ }
236
+
197
237
  const localFile = Bun.file(distIndexPath)
198
238
  const localExists = await localFile.exists()
199
239
 
@@ -152,3 +152,40 @@ describe('logs command', () => {
152
152
  expect(spawnCalled).toBe(false)
153
153
  })
154
154
  })
155
+
156
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
157
+
158
+ describe('streamUmamiLogs — SSH command includes UserKnownHostsFile', () => {
159
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
160
+ delete process.env.CI
161
+
162
+ let capturedCmd: string[] = []
163
+ const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
164
+ capturedCmd = cmd
165
+ return {exited: Promise.resolve(0)}
166
+ }
167
+
168
+ await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
169
+
170
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
171
+ expect(knownHostsIdx).toBeGreaterThan(-1)
172
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
173
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
174
+ })
175
+
176
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
177
+ delete process.env.CI
178
+
179
+ let capturedCmd: string[] = []
180
+ const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
181
+ capturedCmd = cmd
182
+ return {exited: Promise.resolve(0)}
183
+ }
184
+
185
+ await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
186
+
187
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
188
+ expect(strictIdx).toBeGreaterThan(-1)
189
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
190
+ })
191
+ })
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateUmamiHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -91,6 +92,7 @@ export async function streamUmamiLogs(
91
92
  'BatchMode=yes',
92
93
  '-o',
93
94
  'StrictHostKeyChecking=yes',
95
+ ...buildKnownHostsArgs(),
94
96
  `root@${opts.host}`,
95
97
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
96
98
  ]
@@ -133,6 +133,70 @@ function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
133
133
  }
134
134
  }
135
135
 
136
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
137
+
138
+ describe('getUmamiComposeStatus — SSH command includes UserKnownHostsFile', () => {
139
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
140
+ let capturedCmd: string[] = []
141
+
142
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
143
+ capturedCmd = cmd
144
+ const enc = new TextEncoder()
145
+ return {
146
+ stdout: new ReadableStream({
147
+ start(c) {
148
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
149
+ c.close()
150
+ },
151
+ }),
152
+ stderr: new ReadableStream({
153
+ start(c) {
154
+ c.close()
155
+ },
156
+ }),
157
+ exited: Promise.resolve(0),
158
+ }
159
+ }
160
+
161
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
162
+
163
+ // Find the UserKnownHostsFile option in the ssh command
164
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
165
+ expect(knownHostsIdx).toBeGreaterThan(-1)
166
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
167
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
168
+ })
169
+
170
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
171
+ let capturedCmd: string[] = []
172
+
173
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
174
+ capturedCmd = cmd
175
+ const enc = new TextEncoder()
176
+ return {
177
+ stdout: new ReadableStream({
178
+ start(c) {
179
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
180
+ c.close()
181
+ },
182
+ }),
183
+ stderr: new ReadableStream({
184
+ start(c) {
185
+ c.close()
186
+ },
187
+ }),
188
+ exited: Promise.resolve(0),
189
+ }
190
+ }
191
+
192
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
193
+
194
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
195
+ expect(strictIdx).toBeGreaterThan(-1)
196
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
197
+ })
198
+ })
199
+
136
200
  describe('getUmamiComposeStatus', () => {
137
201
  it('returns service rows from NDJSON output', async () => {
138
202
  const ndjson = [
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
4
  import {z} from 'zod'
5
5
 
6
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
6
7
  import {redactHost} from '../../lib/redact'
7
8
  import {validateUmamiHost} from './host'
8
9
 
@@ -102,6 +103,7 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
102
103
  'BatchMode=yes',
103
104
  '-o',
104
105
  'StrictHostKeyChecking=yes',
106
+ ...buildKnownHostsArgs(),
105
107
  `root@${host}`,
106
108
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
107
109
  ]
@@ -276,6 +276,30 @@ describe('repo conventions', () => {
276
276
  expect(offenders).toEqual([])
277
277
  })
278
278
 
279
+ it('opencode.jsonc mcp.infra.command uses local repo source (not bunx published package)', async () => {
280
+ const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
281
+ const stripped = jsoncText
282
+ .split('\n')
283
+ .map(line => (/^\s*\/\//.test(line) ? '' : line))
284
+ .join('\n')
285
+ .replaceAll(/,(\s*[}\]])/g, '$1')
286
+ const opencode = JSON.parse(stripped) as {mcp?: {infra?: {command?: unknown}}}
287
+
288
+ const infraCommand = opencode.mcp?.infra?.command
289
+ expect(Array.isArray(infraCommand), 'mcp.infra.command must be an array').toBe(true)
290
+
291
+ const cmd = infraCommand as string[]
292
+ expect(
293
+ cmd,
294
+ 'mcp.infra.command must be ["bun", "run", "packages/cli/src/cli.ts", "mcp"] — use local source, not bunx',
295
+ ).toEqual(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'])
296
+
297
+ // Explicit regression guard: bunx @marcusrbrown/infra resolves stale published
298
+ // package cache and exits before the MCP handshake, causing connection closed.
299
+ expect(cmd.join(' ')).not.toContain('bunx')
300
+ expect(cmd.join(' ')).not.toContain('@marcusrbrown/infra')
301
+ })
302
+
279
303
  it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
280
304
  // Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
281
305
  // trailing commas). Strategy:
@@ -0,0 +1,192 @@
1
+ import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'
2
+ import {tmpdir} from 'node:os'
3
+ import {join, resolve} from 'node:path'
4
+
5
+ import {describe, expect, it} from 'bun:test'
6
+
7
+ import {buildKnownHostsArgs, resolveKnownHostsPath, resolveRepoKnownHostsPath} from './known-hosts'
8
+
9
+ // ─── resolveRepoKnownHostsPath ────────────────────────────────────────────────
10
+
11
+ describe('resolveRepoKnownHostsPath', () => {
12
+ it('returns the path to .github/known_hosts when it exists at the repo root', () => {
13
+ // The actual repo has .github/known_hosts — this test runs from the repo root
14
+ const result = resolveRepoKnownHostsPath()
15
+
16
+ expect(result).not.toBeNull()
17
+ expect(result).toMatch(/\.github[/\\]known_hosts$/)
18
+ if (result) expect(existsSync(result)).toBe(true)
19
+ })
20
+
21
+ it('returns null when a custom base path has no .github/known_hosts', () => {
22
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
23
+ mkdirSync(tmpDir, {recursive: true})
24
+
25
+ try {
26
+ const result = resolveRepoKnownHostsPath(tmpDir)
27
+ expect(result).toBeNull()
28
+ } finally {
29
+ rmSync(tmpDir, {recursive: true, force: true})
30
+ }
31
+ })
32
+
33
+ it('returns the path when a custom base path has .github/known_hosts', () => {
34
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
35
+ const githubDir = join(tmpDir, '.github')
36
+ mkdirSync(githubDir, {recursive: true})
37
+ writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
38
+
39
+ try {
40
+ const result = resolveRepoKnownHostsPath(tmpDir)
41
+ expect(result).not.toBeNull()
42
+ expect(result).toBe(join(tmpDir, '.github', 'known_hosts'))
43
+ } finally {
44
+ rmSync(tmpDir, {recursive: true, force: true})
45
+ }
46
+ })
47
+ })
48
+
49
+ // ─── resolveKnownHostsPath (new: multi-layout resolution) ────────────────────
50
+
51
+ describe('resolveKnownHostsPath', () => {
52
+ it('returns the packaged resource path when only src/resources/known_hosts exists (installed package layout)', () => {
53
+ // Simulate: node_modules/@marcusrbrown/infra/src/lib/known-hosts.ts
54
+ // Only src/resources/known_hosts exists; no .github/known_hosts at any ancestor.
55
+ const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
56
+ const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
57
+ const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
58
+ mkdirSync(libDir, {recursive: true})
59
+ mkdirSync(resourcesDir, {recursive: true})
60
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
61
+
62
+ try {
63
+ // Pass the simulated import.meta.dir (the lib dir inside the package)
64
+ const result = resolveKnownHostsPath(libDir)
65
+ expect(result).not.toBeNull()
66
+ expect(result).toBe(join(resourcesDir, 'known_hosts'))
67
+ } finally {
68
+ rmSync(tmpDir, {recursive: true, force: true})
69
+ }
70
+ })
71
+
72
+ it('prefers .github/known_hosts over packaged resource when both exist (repo checkout layout)', () => {
73
+ // Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
74
+ // tmpDir is the "repo root"; libDir is tmpDir/packages/cli/src/lib.
75
+ const tmpDir = join(tmpdir(), `known-hosts-both-${Date.now()}`)
76
+ const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
77
+ const githubDir = join(tmpDir, '.github')
78
+ const resourcesDir = join(tmpDir, 'packages', 'cli', 'src', 'resources')
79
+ mkdirSync(libDir, {recursive: true})
80
+ mkdirSync(githubDir, {recursive: true})
81
+ mkdirSync(resourcesDir, {recursive: true})
82
+ writeFileSync(join(githubDir, 'known_hosts'), 'repo.example.com ssh-ed25519 AAAA...\n')
83
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'packaged.example.com ssh-ed25519 AAAA...\n')
84
+
85
+ try {
86
+ // lib is at tmpDir/packages/cli/src/lib — 4 levels up is tmpDir (the "repo root")
87
+ const result = resolveKnownHostsPath(libDir)
88
+ expect(result).not.toBeNull()
89
+ // Should prefer .github/known_hosts (repo checkout)
90
+ expect(result).toBe(join(githubDir, 'known_hosts'))
91
+ } finally {
92
+ rmSync(tmpDir, {recursive: true, force: true})
93
+ }
94
+ })
95
+
96
+ it('throws a clear error when neither .github/known_hosts nor packaged resource exists', () => {
97
+ const tmpDir = join(tmpdir(), `known-hosts-none-${Date.now()}`)
98
+ const libDir = join(tmpDir, 'src', 'lib')
99
+ mkdirSync(libDir, {recursive: true})
100
+
101
+ try {
102
+ expect(() => resolveKnownHostsPath(libDir)).toThrow(
103
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
104
+ )
105
+ } finally {
106
+ rmSync(tmpDir, {recursive: true, force: true})
107
+ }
108
+ })
109
+ })
110
+
111
+ // ─── buildKnownHostsArgs ──────────────────────────────────────────────────────
112
+
113
+ describe('buildKnownHostsArgs', () => {
114
+ it('returns UserKnownHostsFile args when .github/known_hosts exists at repo root', () => {
115
+ const args = buildKnownHostsArgs()
116
+
117
+ // The actual repo has .github/known_hosts
118
+ expect(args).toHaveLength(2)
119
+ expect(args[0]).toBe('-o')
120
+ expect(args[1]).toMatch(/^UserKnownHostsFile=/)
121
+ expect(args[1]).toMatch(/\.github[/\\]known_hosts$/)
122
+ })
123
+
124
+ it('throws when no known_hosts file exists (fail-closed behavior)', () => {
125
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
126
+ const libDir = join(tmpDir, 'src', 'lib')
127
+ mkdirSync(libDir, {recursive: true})
128
+
129
+ try {
130
+ expect(() => buildKnownHostsArgs(libDir)).toThrow(
131
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
132
+ )
133
+ } finally {
134
+ rmSync(tmpDir, {recursive: true, force: true})
135
+ }
136
+ })
137
+
138
+ it('returns UserKnownHostsFile args pointing to the packaged resource in installed layout', () => {
139
+ const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
140
+ const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
141
+ const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
142
+ mkdirSync(libDir, {recursive: true})
143
+ mkdirSync(resourcesDir, {recursive: true})
144
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
145
+
146
+ try {
147
+ const args = buildKnownHostsArgs(libDir)
148
+ expect(args).toHaveLength(2)
149
+ expect(args[0]).toBe('-o')
150
+ expect(args[1]).toBe(`UserKnownHostsFile=${join(resourcesDir, 'known_hosts')}`)
151
+ } finally {
152
+ rmSync(tmpDir, {recursive: true, force: true})
153
+ }
154
+ })
155
+
156
+ it('returns UserKnownHostsFile args pointing to the custom base path (repo checkout)', () => {
157
+ // Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
158
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
159
+ const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
160
+ const githubDir = join(tmpDir, '.github')
161
+ mkdirSync(libDir, {recursive: true})
162
+ mkdirSync(githubDir, {recursive: true})
163
+ writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
164
+
165
+ try {
166
+ const args = buildKnownHostsArgs(libDir)
167
+ expect(args).toHaveLength(2)
168
+ expect(args[0]).toBe('-o')
169
+ expect(args[1]).toBe(`UserKnownHostsFile=${join(githubDir, 'known_hosts')}`)
170
+ } finally {
171
+ rmSync(tmpDir, {recursive: true, force: true})
172
+ }
173
+ })
174
+ })
175
+
176
+ // ─── Drift guard: packaged resource matches .github/known_hosts ───────────────
177
+
178
+ describe('packaged known_hosts resource drift guard', () => {
179
+ const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..', '..')
180
+ const REPO_KNOWN_HOSTS = join(REPO_ROOT, '.github', 'known_hosts')
181
+ const PACKAGED_RESOURCE = resolve(import.meta.dir, '..', 'resources', 'known_hosts')
182
+
183
+ it('src/resources/known_hosts exists in the package source tree', () => {
184
+ expect(existsSync(PACKAGED_RESOURCE)).toBe(true)
185
+ })
186
+
187
+ it('src/resources/known_hosts matches .github/known_hosts (no drift)', async () => {
188
+ const repoContent = await Bun.file(REPO_KNOWN_HOSTS).text()
189
+ const packagedContent = await Bun.file(PACKAGED_RESOURCE).text()
190
+ expect(packagedContent).toBe(repoContent)
191
+ })
192
+ })
@@ -0,0 +1,76 @@
1
+ import {existsSync} from 'node:fs'
2
+ import {join, resolve} from 'node:path'
3
+
4
+ const FAIL_CLOSED_ERROR =
5
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout'
6
+
7
+ /**
8
+ * Resolve the path to the repo-pinned `.github/known_hosts` file.
9
+ *
10
+ * SSH commands in this CLI use `StrictHostKeyChecking=yes` but the invoking
11
+ * user's `~/.ssh/known_hosts` may not contain the pinned host keys for
12
+ * `metrics.fro.bot`, `gateway.fro.bot`, etc. The repo ships authoritative
13
+ * host keys in `.github/known_hosts`. When that file exists, SSH commands
14
+ * should use it via `UserKnownHostsFile=<path>` so host verification succeeds
15
+ * without weakening `StrictHostKeyChecking`.
16
+ *
17
+ * @param repoRoot - Optional override for the repo root directory. Defaults to
18
+ * the actual repo root resolved relative to this file's location. Pass a
19
+ * custom path in tests to avoid touching the real filesystem.
20
+ * @returns Absolute path to `.github/known_hosts` if it exists, otherwise null.
21
+ */
22
+ export function resolveRepoKnownHostsPath(repoRoot?: string): string | null {
23
+ // This file lives at packages/cli/src/lib/known-hosts.ts
24
+ // Repo root is 4 levels up: lib → src → cli → packages → repo root
25
+ const base = repoRoot ?? resolve(import.meta.dir, '..', '..', '..', '..')
26
+ const candidate = join(base, '.github', 'known_hosts')
27
+ return existsSync(candidate) ? candidate : null
28
+ }
29
+
30
+ /**
31
+ * Resolve the pinned known_hosts file from either layout:
32
+ *
33
+ * 1. Repo checkout: `.github/known_hosts` at the monorepo root (4 levels up
34
+ * from `src/lib/`).
35
+ * 2. Installed package: `src/resources/known_hosts` bundled alongside the
36
+ * source (1 level up from `src/lib/`, then into `resources/`).
37
+ *
38
+ * Prefers the repo checkout layout when both exist. Throws a clear error if
39
+ * neither is found — fail-closed, never silently falls back to user
40
+ * `~/.ssh/known_hosts`.
41
+ *
42
+ * @param libDir - Override for the directory containing this file (for tests).
43
+ * Defaults to `import.meta.dir`.
44
+ * @returns Absolute path to the pinned known_hosts file.
45
+ * @throws Error if no pinned known_hosts file is found.
46
+ */
47
+ export function resolveKnownHostsPath(libDir?: string): string {
48
+ const dir = libDir ?? import.meta.dir
49
+
50
+ // Layout 1: repo checkout — walk up 4 levels from src/lib to repo root
51
+ const repoRoot = resolve(dir, '..', '..', '..', '..')
52
+ const repoCandidate = join(repoRoot, '.github', 'known_hosts')
53
+ if (existsSync(repoCandidate)) return repoCandidate
54
+
55
+ // Layout 2: installed package — src/resources/known_hosts is 1 level up from src/lib
56
+ const packagedCandidate = resolve(dir, '..', 'resources', 'known_hosts')
57
+ if (existsSync(packagedCandidate)) return packagedCandidate
58
+
59
+ throw new Error(FAIL_CLOSED_ERROR)
60
+ }
61
+
62
+ /**
63
+ * Build the SSH `-o UserKnownHostsFile=<path>` argument pair for the
64
+ * repo-pinned known_hosts file.
65
+ *
66
+ * Returns `['-o', 'UserKnownHostsFile=<path>']` when the file is found.
67
+ * Throws a clear error if no pinned known_hosts file is available — fail-closed,
68
+ * never silently omits `UserKnownHostsFile` for SSH commands that rely on
69
+ * pinned verification.
70
+ *
71
+ * @param libDir - Override for the directory containing this file (for tests).
72
+ */
73
+ export function buildKnownHostsArgs(libDir?: string): string[] {
74
+ const path = resolveKnownHostsPath(libDir)
75
+ return ['-o', `UserKnownHostsFile=${path}`]
76
+ }
@@ -0,0 +1,28 @@
1
+ box.heatvision.co ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJNjkvxUDLB432BdSnrfYGgYyfUQxrdwV6sNqETNkyqs
2
+ box.heatvision.co ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPnP2/6iqHUeOJX4+aVcFpaal0lLdT6sZrRSF95+MgyCY+p3K21Kgr2xp9eyGJ8splnaJv7Kwv1V7+HC3CMXoqM=
3
+ box.heatvision.co ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCpOziObL6mGk7hT113yo8kElbut5DU6xXoUYw8QWc4ix8iHxJV6T1hLl4wemFkSdA1jUhqhfR94Wdk7DtSGUiq9LidwfmL9ZsrAlnThLJeugZE1f+EtIye9hIqpzmmqMZvHeYFu3zbiB2/Xpc/krTmfKjIOsI0MeOKPAKEiFLG1DU9i5bWKs2ktC8TZCqp+Pfc4xt54Ug4lfLspbGbRZ96SQuxns+SSfyQW5peH13ZMl92/GGA2Tdh3bHausOYI6AvUimd/0mVc6b0bSWVS/k1bdb27rE/6TrZZ4ICXElGkgNndOsX5lFNcJdU+T6pJicA5fO8mpZ/ysakvyesmPigXxYsW3Q/tQkBoQXcKcxi+ItqU/ahJ1I4DObKnvJlu5htr3Gzo4dO5sxpDdufECqgicauFZzvXzD49XZvctRG01ZSy6ZmIekBsiTFHaTMO8zV88/AwVOT8CrgbrmIiwUG6ZfXCPFMztu4Z5IwxMWRnKG2GHWGNBb2/F5PDAz/Xrc=
4
+
5
+ # cliproxy droplet (147.182.133.210 / cliproxy.fro.bot)
6
+ cliproxy.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
7
+ cliproxy.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
8
+ cliproxy.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
9
+ |1|h7erWkle+dN76VrVVAiQPmOSee4=|LkpzPt8SQjsCbnPm+txg5Me1SYM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
10
+ |1|hNHRbNSfyK/BBEM32tiaVoRnRRA=|6KslRRxr9KNaKlbnzkdg1/tQokA= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
11
+ |1|NMQwWGuubNdklyTxDZ5rRgmaq0k=|J3xvvUOXXMYgC0vvsn1zvRDlSVQ= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
12
+
13
+ # gateway droplet (162.243.163.59 / gateway.fro.bot)
14
+ gateway.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
15
+ gateway.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
16
+ gateway.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
17
+
18
+ |1|8fBUi0jyLBNX5eBIp6NllCFFwVw=|gC1yH9h2zglwFYgKVhvslvDqgqE= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
19
+ |1|XsFKIlbq2aifqw1if+WGnRz0MBs=|LrHknyfL8diBLT1G7+ss8+9Kbaw= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
20
+ |1|Ytflq7+lWpR/TNEQ8C/wvirEIsc=|hcnjv25+apKJxy6UlAHhtR0baRI= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
21
+
22
+ # umami droplet (167.99.236.120 / metrics.fro.bot)
23
+ metrics.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
24
+ metrics.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
25
+ metrics.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=
26
+ |1|8o6qExp0klmE80pMU2CYvmbGqSQ=|064FdS/NWWEIU7EjmGBCwCek+DE= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
27
+ |1|1nwXP2iRwBq0GXyE94EXYJuaUCQ=|Wxoe6EvzjZbk02Hr4mx1YZOciUM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
28
+ |1|lwRlQiSSd0h7iti76fvRXTtbkSw=|D//yCCMd1iZftlbjJ0NhKCCf5/8= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=