@marcusrbrown/infra 0.10.3 → 0.10.5

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.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -613,3 +613,143 @@ describe('getGatewayComposeStatus — SSH error redaction', () => {
613
613
  expect(result.error).not.toContain(secretHost.toLowerCase())
614
614
  })
615
615
  })
616
+
617
+ // ─── SSH identity injection (GATEWAY_SSH_KEY) ─────────────────────────────────
618
+
619
+ describe('getGatewayComposeStatus — SSH identity injection', () => {
620
+ let originalEnv: Record<string, string | undefined>
621
+
622
+ beforeEach(() => {
623
+ originalEnv = {GATEWAY_SSH_KEY: process.env.GATEWAY_SSH_KEY}
624
+ })
625
+
626
+ afterEach(() => {
627
+ if (originalEnv.GATEWAY_SSH_KEY === undefined) {
628
+ delete process.env.GATEWAY_SSH_KEY
629
+ } else {
630
+ process.env.GATEWAY_SSH_KEY = originalEnv.GATEWAY_SSH_KEY
631
+ }
632
+ })
633
+
634
+ it('includes -i <path> and IdentitiesOnly=yes in ssh argv when GATEWAY_SSH_KEY is set', async () => {
635
+ const {getGatewayComposeStatus} = await import('./status')
636
+ process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
637
+
638
+ let capturedCmd: string[] = []
639
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
640
+ capturedCmd = cmd
641
+ const enc = new TextEncoder()
642
+ return {
643
+ stdout: new ReadableStream({
644
+ start(c) {
645
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
646
+ c.close()
647
+ },
648
+ }),
649
+ stderr: new ReadableStream({
650
+ start(c) {
651
+ c.close()
652
+ },
653
+ }),
654
+ exited: Promise.resolve(0),
655
+ }
656
+ }
657
+
658
+ await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
659
+
660
+ const iIdx = capturedCmd.indexOf('-i')
661
+ expect(iIdx).toBeGreaterThan(-1)
662
+ expect(capturedCmd[iIdx + 1]).toBeTruthy() // path to temp key file
663
+
664
+ const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
665
+ expect(identitiesOnlyIdx).toBeGreaterThan(-1)
666
+
667
+ const destination = capturedCmd.find(arg => arg.includes('@'))
668
+ expect(destination).toBe('root@gateway.example.com')
669
+ })
670
+
671
+ it('does not include -i or IdentitiesOnly=yes when GATEWAY_SSH_KEY is absent', async () => {
672
+ const {getGatewayComposeStatus} = await import('./status')
673
+ delete process.env.GATEWAY_SSH_KEY
674
+
675
+ let capturedCmd: string[] = []
676
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
677
+ capturedCmd = cmd
678
+ const enc = new TextEncoder()
679
+ return {
680
+ stdout: new ReadableStream({
681
+ start(c) {
682
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
683
+ c.close()
684
+ },
685
+ }),
686
+ stderr: new ReadableStream({
687
+ start(c) {
688
+ c.close()
689
+ },
690
+ }),
691
+ exited: Promise.resolve(0),
692
+ }
693
+ }
694
+
695
+ await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
696
+
697
+ expect(capturedCmd.indexOf('-i')).toBe(-1)
698
+ expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
699
+ })
700
+
701
+ it('cleans up the temp key file after the SSH command completes', async () => {
702
+ const {getGatewayComposeStatus} = await import('./status')
703
+ const {statSync} = await import('node:fs')
704
+ process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
705
+
706
+ let capturedKeyPath: string | undefined
707
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
708
+ const iIdx = cmd.indexOf('-i')
709
+ if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
710
+ const enc = new TextEncoder()
711
+ return {
712
+ stdout: new ReadableStream({
713
+ start(c) {
714
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
715
+ c.close()
716
+ },
717
+ }),
718
+ stderr: new ReadableStream({
719
+ start(c) {
720
+ c.close()
721
+ },
722
+ }),
723
+ exited: Promise.resolve(0),
724
+ }
725
+ }
726
+
727
+ await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
728
+
729
+ expect(capturedKeyPath).toBeTruthy()
730
+ const keyPath = capturedKeyPath
731
+ if (keyPath) {
732
+ expect(() => statSync(keyPath)).toThrow()
733
+ }
734
+ })
735
+
736
+ it('cleans up the temp key dir even when spawn throws synchronously', async () => {
737
+ const {getGatewayComposeStatus} = await import('./status')
738
+ const {statSync} = await import('node:fs')
739
+ process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
740
+
741
+ let capturedKeyPath: string | undefined
742
+ const throwingSpawn: SpawnFn = (cmd, _opts) => {
743
+ const iIdx = cmd.indexOf('-i')
744
+ if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
745
+ throw new Error('spawn failed')
746
+ }
747
+
748
+ await expect(getGatewayComposeStatus('gateway.example.com', throwingSpawn)).rejects.toThrow('spawn failed')
749
+
750
+ expect(capturedKeyPath).toBeTruthy()
751
+ if (capturedKeyPath) {
752
+ expect(() => statSync(capturedKeyPath as string)).toThrow()
753
+ }
754
+ })
755
+ })
@@ -5,6 +5,7 @@ import {z} from 'zod'
5
5
 
6
6
  import {buildKnownHostsArgs} from '../../lib/known-hosts'
7
7
  import {redactHost} from '../../lib/redact'
8
+ import {buildIdentityArgs} from '../../lib/ssh-identity'
8
9
  // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
9
10
  import {validateGatewayHost} from './host'
10
11
 
@@ -103,13 +104,17 @@ export async function getGatewayComposeStatus(
103
104
  ): Promise<GatewayStatusResult> {
104
105
  validateGatewayHost(host)
105
106
 
107
+ const knownHostsArgs = buildKnownHostsArgs()
108
+ const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.GATEWAY_SSH_KEY)
109
+
106
110
  const sshCmd = [
107
111
  'ssh',
108
112
  '-o',
109
113
  'BatchMode=yes',
110
114
  '-o',
111
115
  'StrictHostKeyChecking=yes',
112
- ...buildKnownHostsArgs(),
116
+ ...knownHostsArgs,
117
+ ...identityArgs,
113
118
  `root@${host}`,
114
119
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
115
120
  ]
@@ -120,13 +125,20 @@ export async function getGatewayComposeStatus(
120
125
  ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
121
126
  }
122
127
 
123
- const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
128
+ let stdoutText: string
129
+ let stderrText: string
130
+ let exitCode: number
124
131
 
125
- const [stdoutText, stderrText, exitCode] = await Promise.all([
126
- new Response(child.stdout).text(),
127
- new Response(child.stderr).text(),
128
- child.exited,
129
- ])
132
+ try {
133
+ const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
134
+ ;[stdoutText, stderrText, exitCode] = await Promise.all([
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ child.exited,
138
+ ])
139
+ } finally {
140
+ cleanup()
141
+ }
130
142
 
131
143
  if (exitCode !== 0) {
132
144
  const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
@@ -514,3 +514,120 @@ describe('getUmamiComposeStatus — SSH error redaction', () => {
514
514
  expect(result.error).not.toContain(secretHost.toLowerCase())
515
515
  })
516
516
  })
517
+
518
+ // ─── SSH identity injection (UMAMI_SSH_KEY) ───────────────────────────────────
519
+
520
+ describe('getUmamiComposeStatus — SSH identity injection', () => {
521
+ let originalEnv: Record<string, string | undefined>
522
+
523
+ beforeEach(() => {
524
+ originalEnv = {UMAMI_SSH_KEY: process.env.UMAMI_SSH_KEY}
525
+ })
526
+
527
+ afterEach(() => {
528
+ if (originalEnv.UMAMI_SSH_KEY === undefined) {
529
+ delete process.env.UMAMI_SSH_KEY
530
+ } else {
531
+ process.env.UMAMI_SSH_KEY = originalEnv.UMAMI_SSH_KEY
532
+ }
533
+ })
534
+
535
+ it('includes -i <path> and IdentitiesOnly=yes in ssh argv when UMAMI_SSH_KEY is set', async () => {
536
+ process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
537
+
538
+ let capturedCmd: string[] = []
539
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
540
+ capturedCmd = cmd
541
+ const enc = new TextEncoder()
542
+ return {
543
+ stdout: new ReadableStream({
544
+ start(c) {
545
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
546
+ c.close()
547
+ },
548
+ }),
549
+ stderr: new ReadableStream({
550
+ start(c) {
551
+ c.close()
552
+ },
553
+ }),
554
+ exited: Promise.resolve(0),
555
+ }
556
+ }
557
+
558
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
559
+
560
+ const iIdx = capturedCmd.indexOf('-i')
561
+ expect(iIdx).toBeGreaterThan(-1)
562
+ expect(capturedCmd[iIdx + 1]).toBeTruthy()
563
+
564
+ const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
565
+ expect(identitiesOnlyIdx).toBeGreaterThan(-1)
566
+
567
+ const destination = capturedCmd.find(arg => arg.includes('@'))
568
+ expect(destination).toBe('root@metrics.fro.bot')
569
+ })
570
+
571
+ it('does not include -i or IdentitiesOnly=yes when UMAMI_SSH_KEY is absent', async () => {
572
+ delete process.env.UMAMI_SSH_KEY
573
+
574
+ let capturedCmd: string[] = []
575
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
576
+ capturedCmd = cmd
577
+ const enc = new TextEncoder()
578
+ return {
579
+ stdout: new ReadableStream({
580
+ start(c) {
581
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
582
+ c.close()
583
+ },
584
+ }),
585
+ stderr: new ReadableStream({
586
+ start(c) {
587
+ c.close()
588
+ },
589
+ }),
590
+ exited: Promise.resolve(0),
591
+ }
592
+ }
593
+
594
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
595
+
596
+ expect(capturedCmd.indexOf('-i')).toBe(-1)
597
+ expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
598
+ })
599
+
600
+ it('cleans up the temp key file after the SSH command completes', async () => {
601
+ const {statSync} = await import('node:fs')
602
+ process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
603
+
604
+ let capturedKeyPath: string | undefined
605
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
606
+ const iIdx = cmd.indexOf('-i')
607
+ if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
608
+ const enc = new TextEncoder()
609
+ return {
610
+ stdout: new ReadableStream({
611
+ start(c) {
612
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
613
+ c.close()
614
+ },
615
+ }),
616
+ stderr: new ReadableStream({
617
+ start(c) {
618
+ c.close()
619
+ },
620
+ }),
621
+ exited: Promise.resolve(0),
622
+ }
623
+ }
624
+
625
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
626
+
627
+ expect(capturedKeyPath).toBeTruthy()
628
+ const keyPath = capturedKeyPath
629
+ if (keyPath) {
630
+ expect(() => statSync(keyPath)).toThrow()
631
+ }
632
+ })
633
+ })
@@ -5,6 +5,7 @@ import {z} from 'zod'
5
5
 
6
6
  import {buildKnownHostsArgs} from '../../lib/known-hosts'
7
7
  import {redactHost} from '../../lib/redact'
8
+ import {buildIdentityArgs} from '../../lib/ssh-identity'
8
9
  import {validateUmamiHost} from './host'
9
10
 
10
11
  declare const process: {
@@ -97,13 +98,17 @@ function defaultSpawn(
97
98
  export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<UmamiStatusResult> {
98
99
  validateUmamiHost(host)
99
100
 
101
+ const knownHostsArgs = buildKnownHostsArgs()
102
+ const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.UMAMI_SSH_KEY)
103
+
100
104
  const sshCmd = [
101
105
  'ssh',
102
106
  '-o',
103
107
  'BatchMode=yes',
104
108
  '-o',
105
109
  'StrictHostKeyChecking=yes',
106
- ...buildKnownHostsArgs(),
110
+ ...knownHostsArgs,
111
+ ...identityArgs,
107
112
  `root@${host}`,
108
113
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
109
114
  ]
@@ -114,13 +119,20 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
114
119
  ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
115
120
  }
116
121
 
117
- const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
122
+ let stdoutText: string
123
+ let stderrText: string
124
+ let exitCode: number
118
125
 
119
- const [stdoutText, stderrText, exitCode] = await Promise.all([
120
- new Response(child.stdout).text(),
121
- new Response(child.stderr).text(),
122
- child.exited,
123
- ])
126
+ try {
127
+ const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
128
+ ;[stdoutText, stderrText, exitCode] = await Promise.all([
129
+ new Response(child.stdout).text(),
130
+ new Response(child.stderr).text(),
131
+ child.exited,
132
+ ])
133
+ } finally {
134
+ cleanup()
135
+ }
124
136
 
125
137
  if (exitCode !== 0) {
126
138
  return {
@@ -1,4 +1,4 @@
1
- import {describe, expect, it} from 'bun:test'
1
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
2
 
3
3
  import {getVpnStatusSummary, parseWgShowOutput, type WgShowResult} from './shared'
4
4
 
@@ -332,3 +332,123 @@ describe('WgShowResult type', () => {
332
332
  expect(result.peerCount).toBe(2)
333
333
  })
334
334
  })
335
+
336
+ // ─── SSH identity injection (VPN_SSH_KEY) ─────────────────────────────────────
337
+
338
+ describe('getVpnWgStatus — SSH identity injection', () => {
339
+ let originalEnv: Record<string, string | undefined>
340
+
341
+ beforeEach(() => {
342
+ originalEnv = {VPN_SSH_KEY: process.env.VPN_SSH_KEY}
343
+ })
344
+
345
+ afterEach(() => {
346
+ if (originalEnv.VPN_SSH_KEY === undefined) {
347
+ delete process.env.VPN_SSH_KEY
348
+ } else {
349
+ process.env.VPN_SSH_KEY = originalEnv.VPN_SSH_KEY
350
+ }
351
+ })
352
+
353
+ it('includes -i <path> and IdentitiesOnly=yes in ssh argv when VPN_SSH_KEY is set', async () => {
354
+ const {getVpnWgStatus} = await import('./shared')
355
+ process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
356
+
357
+ const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
358
+ let capturedCmd: string[] = []
359
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
360
+ capturedCmd = cmd
361
+ const enc = new TextEncoder()
362
+ return {
363
+ stdout: new ReadableStream({
364
+ start(c) {
365
+ c.enqueue(enc.encode(wgOutput))
366
+ c.close()
367
+ },
368
+ }),
369
+ stderr: new ReadableStream({
370
+ start(c) {
371
+ c.close()
372
+ },
373
+ }),
374
+ exited: Promise.resolve(0),
375
+ }
376
+ }
377
+
378
+ await getVpnWgStatus('1.2.3.4', capturingSpawn)
379
+
380
+ const iIdx = capturedCmd.indexOf('-i')
381
+ expect(iIdx).toBeGreaterThan(-1)
382
+ expect(capturedCmd[iIdx + 1]).toBeTruthy()
383
+
384
+ const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
385
+ expect(identitiesOnlyIdx).toBeGreaterThan(-1)
386
+ })
387
+
388
+ it('does not include -i or IdentitiesOnly=yes when VPN_SSH_KEY is absent', async () => {
389
+ const {getVpnWgStatus} = await import('./shared')
390
+ delete process.env.VPN_SSH_KEY
391
+
392
+ const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
393
+ let capturedCmd: string[] = []
394
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
395
+ capturedCmd = cmd
396
+ const enc = new TextEncoder()
397
+ return {
398
+ stdout: new ReadableStream({
399
+ start(c) {
400
+ c.enqueue(enc.encode(wgOutput))
401
+ c.close()
402
+ },
403
+ }),
404
+ stderr: new ReadableStream({
405
+ start(c) {
406
+ c.close()
407
+ },
408
+ }),
409
+ exited: Promise.resolve(0),
410
+ }
411
+ }
412
+
413
+ await getVpnWgStatus('1.2.3.4', capturingSpawn)
414
+
415
+ expect(capturedCmd.indexOf('-i')).toBe(-1)
416
+ expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
417
+ })
418
+
419
+ it('cleans up the temp key file after the SSH command completes', async () => {
420
+ const {getVpnWgStatus} = await import('./shared')
421
+ const {statSync} = await import('node:fs')
422
+ process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
423
+
424
+ const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
425
+ let capturedKeyPath: string | undefined
426
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
427
+ const iIdx = cmd.indexOf('-i')
428
+ if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
429
+ const enc = new TextEncoder()
430
+ return {
431
+ stdout: new ReadableStream({
432
+ start(c) {
433
+ c.enqueue(enc.encode(wgOutput))
434
+ c.close()
435
+ },
436
+ }),
437
+ stderr: new ReadableStream({
438
+ start(c) {
439
+ c.close()
440
+ },
441
+ }),
442
+ exited: Promise.resolve(0),
443
+ }
444
+ }
445
+
446
+ await getVpnWgStatus('1.2.3.4', capturingSpawn)
447
+
448
+ expect(capturedKeyPath).toBeTruthy()
449
+ const keyPath = capturedKeyPath
450
+ if (keyPath) {
451
+ expect(() => statSync(keyPath)).toThrow()
452
+ }
453
+ })
454
+ })
@@ -2,6 +2,7 @@ import type {StatusSummary} from '../status'
2
2
 
3
3
  import {buildKnownHostsArgs} from '../../lib/known-hosts'
4
4
  import {redactHost} from '../../lib/redact'
5
+ import {buildIdentityArgs} from '../../lib/ssh-identity'
5
6
  import {validateVpnHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -79,13 +80,17 @@ export interface VpnStatusResult {
79
80
  export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<VpnStatusResult> {
80
81
  validateVpnHost(host)
81
82
 
83
+ const knownHostsArgs = buildKnownHostsArgs()
84
+ const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.VPN_SSH_KEY)
85
+
82
86
  const sshCmd = [
83
87
  'ssh',
84
88
  '-o',
85
89
  'BatchMode=yes',
86
90
  '-o',
87
91
  'StrictHostKeyChecking=yes',
88
- ...buildKnownHostsArgs(),
92
+ ...knownHostsArgs,
93
+ ...identityArgs,
89
94
  `ubuntu@${host}`,
90
95
  'sudo wg show wg0',
91
96
  ]
@@ -96,13 +101,20 @@ export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn
96
101
  ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
97
102
  }
98
103
 
99
- const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
100
-
101
- const [stdoutText, stderrText, exitCode] = await Promise.all([
102
- new Response(child.stdout).text(),
103
- new Response(child.stderr).text(),
104
- child.exited,
105
- ])
104
+ let stdoutText: string
105
+ let stderrText: string
106
+ let exitCode: number
107
+
108
+ try {
109
+ const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
110
+ ;[stdoutText, stderrText, exitCode] = await Promise.all([
111
+ new Response(child.stdout).text(),
112
+ new Response(child.stderr).text(),
113
+ child.exited,
114
+ ])
115
+ } finally {
116
+ cleanup()
117
+ }
106
118
 
107
119
  if (exitCode !== 0) {
108
120
  const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
@@ -0,0 +1,134 @@
1
+ import {readFileSync, statSync} from 'node:fs'
2
+ import {describe, expect, it} from 'bun:test'
3
+
4
+ import {buildIdentityArgs, materializeIdentityFile} from './ssh-identity'
5
+
6
+ // ─── materializeIdentityFile ──────────────────────────────────────────────────
7
+
8
+ describe('materializeIdentityFile', () => {
9
+ it('writes the key to a temp file and returns a path', () => {
10
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
11
+ const {path, cleanup} = materializeIdentityFile(key)
12
+
13
+ try {
14
+ expect(path).toBeTruthy()
15
+ const stat = statSync(path)
16
+ expect(stat.isFile()).toBe(true)
17
+ } finally {
18
+ cleanup()
19
+ }
20
+ })
21
+
22
+ it('writes the file with mode 0600', () => {
23
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
24
+ const {path, cleanup} = materializeIdentityFile(key)
25
+
26
+ try {
27
+ const stat = statSync(path)
28
+ const mode = stat.mode & 0o777
29
+ expect(mode).toBe(0o600)
30
+ } finally {
31
+ cleanup()
32
+ }
33
+ })
34
+
35
+ it('ensures the key file ends with a newline', () => {
36
+ const keyWithoutNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----'
37
+ const {path, cleanup} = materializeIdentityFile(keyWithoutNewline)
38
+
39
+ try {
40
+ const text = readFileSync(path, 'utf8')
41
+ expect(text.endsWith('\n')).toBe(true)
42
+ } finally {
43
+ cleanup()
44
+ }
45
+ })
46
+
47
+ it('does not double-add a newline when key already ends with one', () => {
48
+ const keyWithNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
49
+ const {path, cleanup} = materializeIdentityFile(keyWithNewline)
50
+
51
+ try {
52
+ const text = readFileSync(path, 'utf8')
53
+ expect(text.endsWith('\n\n')).toBe(false)
54
+ expect(text.endsWith('\n')).toBe(true)
55
+ } finally {
56
+ cleanup()
57
+ }
58
+ })
59
+
60
+ it('cleanup removes the temp file and directory', () => {
61
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
62
+ const {path, cleanup} = materializeIdentityFile(key)
63
+ const dir = path.slice(0, path.lastIndexOf('/'))
64
+
65
+ cleanup()
66
+
67
+ // Both the key file and its containing temp dir must be gone.
68
+ expect(() => statSync(path)).toThrow()
69
+ expect(() => statSync(dir)).toThrow()
70
+ })
71
+
72
+ it('cleanup is idempotent (calling twice does not throw)', () => {
73
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
74
+ const {cleanup} = materializeIdentityFile(key)
75
+
76
+ cleanup()
77
+ expect(() => cleanup()).not.toThrow()
78
+ })
79
+ })
80
+
81
+ // ─── buildIdentityArgs ────────────────────────────────────────────────────────
82
+
83
+ describe('buildIdentityArgs', () => {
84
+ it('returns [-i, <path>, -o, IdentitiesOnly=yes] when a key is provided', () => {
85
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
86
+ const {args, cleanup} = buildIdentityArgs(key)
87
+
88
+ try {
89
+ expect(args).toHaveLength(4)
90
+ expect(args[0]).toBe('-i')
91
+ expect(args[1]).toBeTruthy() // path to temp file
92
+ expect(args[2]).toBe('-o')
93
+ expect(args[3]).toBe('IdentitiesOnly=yes')
94
+ } finally {
95
+ cleanup()
96
+ }
97
+ })
98
+
99
+ it('returns empty args and a no-op cleanup when key is empty string', () => {
100
+ const {args, cleanup} = buildIdentityArgs('')
101
+
102
+ expect(args).toHaveLength(0)
103
+ expect(() => cleanup()).not.toThrow()
104
+ })
105
+
106
+ it('returns empty args and a no-op cleanup when key is whitespace-only', () => {
107
+ const {args, cleanup} = buildIdentityArgs(' \n ')
108
+
109
+ expect(args).toHaveLength(0)
110
+ expect(() => cleanup()).not.toThrow()
111
+ })
112
+
113
+ it('returns empty args and a no-op cleanup when key is undefined', () => {
114
+ const {args, cleanup} = buildIdentityArgs(undefined)
115
+
116
+ expect(args).toHaveLength(0)
117
+ expect(() => cleanup()).not.toThrow()
118
+ })
119
+
120
+ it('the -i path points to a real 0600 file', () => {
121
+ const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
122
+ const {args, cleanup} = buildIdentityArgs(key)
123
+
124
+ try {
125
+ const keyPath = args[1]
126
+ expect(keyPath).toBeTruthy()
127
+ const stat = statSync(keyPath as string)
128
+ const mode = stat.mode & 0o777
129
+ expect(mode).toBe(0o600)
130
+ } finally {
131
+ cleanup()
132
+ }
133
+ })
134
+ })
@@ -0,0 +1,76 @@
1
+ import {chmodSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
2
+ import {tmpdir} from 'node:os'
3
+ import {join} from 'node:path'
4
+
5
+ /**
6
+ * A materialized private-key file plus a best-effort cleanup callback.
7
+ * The cleanup removes the temp directory containing the key file.
8
+ */
9
+ export interface MaterializedIdentity {
10
+ path: string
11
+ cleanup: () => void
12
+ }
13
+
14
+ // NOTE: This intentionally duplicates the key-materialization logic in
15
+ // packages/shared/server/droplet-helpers.ts. The published CLI must not depend
16
+ // on @marcusrbrown/infra-shared (that dep breaks npm install for external users).
17
+
18
+ /**
19
+ * Writes a private key to a 0600 temp file (with a guaranteed single trailing
20
+ * newline) and returns its path plus an idempotent best-effort cleanup callback.
21
+ *
22
+ * The trailing newline guards against env/secret injection stripping it
23
+ * (OpenSSH rejects keys without it). Empty/whitespace-key guards live in
24
+ * `buildIdentityArgs`; this function materializes whatever key bytes it receives.
25
+ */
26
+ export function materializeIdentityFile(privateKey: string): MaterializedIdentity {
27
+ const dir = mkdtempSync(join(tmpdir(), 'infra-ssh-key-'))
28
+ const path = join(dir, 'id')
29
+
30
+ const cleanup = (): void => {
31
+ try {
32
+ rmSync(dir, {recursive: true, force: true})
33
+ } catch {
34
+ // Best-effort: a missing temp dir (already cleaned) is fine.
35
+ }
36
+ }
37
+
38
+ try {
39
+ const contents = privateKey.endsWith('\n') ? privateKey : `${privateKey}\n`
40
+ writeFileSync(path, contents, {mode: 0o600})
41
+ chmodSync(path, 0o600)
42
+ } catch (error) {
43
+ // Don't leave a partial 0600 key (or its temp dir) behind on write failure.
44
+ cleanup()
45
+ throw error
46
+ }
47
+
48
+ return {path, cleanup}
49
+ }
50
+
51
+ /**
52
+ * Result of `buildIdentityArgs` — the SSH argv fragment plus a cleanup callback.
53
+ * When the key is absent/empty, `args` is empty and `cleanup` is a no-op.
54
+ */
55
+ export interface IdentityArgs {
56
+ /** SSH argv fragment: `['-i', '<path>', '-o', 'IdentitiesOnly=yes']` or `[]`. */
57
+ args: string[]
58
+ /** Removes the temp key file. Idempotent and safe to call multiple times. */
59
+ cleanup: () => void
60
+ }
61
+
62
+ /**
63
+ * Builds the `-i <keyfile> -o IdentitiesOnly=yes` SSH argv fragment from an
64
+ * optional private key string.
65
+ *
66
+ * When `privateKey` is absent, empty, or whitespace-only, returns empty args
67
+ * and a no-op cleanup so callers fall back to ssh-agent behaviour unchanged.
68
+ */
69
+ export function buildIdentityArgs(privateKey: string | undefined): IdentityArgs {
70
+ if (!privateKey?.trim()) {
71
+ return {args: [], cleanup: () => undefined}
72
+ }
73
+
74
+ const {path, cleanup} = materializeIdentityFile(privateKey)
75
+ return {args: ['-i', path, '-o', 'IdentitiesOnly=yes'], cleanup}
76
+ }