@marcusrbrown/infra 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,949 +0,0 @@
1
- import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
2
- import {readFile, stat} from 'node:fs/promises'
3
- import {tmpdir} from 'node:os'
4
- import {join, resolve} from 'node:path'
5
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
6
-
7
- import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn, type SpawnFn} from './client'
8
-
9
- // ─── Helpers ──────────────────────────────────────────────────────────────────
10
-
11
- function makeKeypairGen(privateKey: string, publicKey: string): KeypairGenFn {
12
- return async () => ({privateKey, publicKey})
13
- }
14
-
15
- function makePeersJson(peers: {name: string; publicKey: string; tunnelIp: string}[]): string {
16
- return `${JSON.stringify({peers}, null, 2)}\n`
17
- }
18
-
19
- /**
20
- * Inert spawn stub — never touches real processes.
21
- * Returns a mock proc: stdin {write, end}, exited resolves 0, empty stdout/stderr.
22
- * Inject this into every call that doesn't explicitly test sync behavior.
23
- */
24
- function makeNoopSpawnFn(): SpawnFn {
25
- return (_cmd, _opts) => ({
26
- stdout: new ReadableStream({
27
- start(c) {
28
- c.enqueue(new TextEncoder().encode(''))
29
- c.close()
30
- },
31
- }),
32
- stderr: new ReadableStream({
33
- start(c) {
34
- c.enqueue(new TextEncoder().encode(''))
35
- c.close()
36
- },
37
- }),
38
- stdin: {
39
- write: (_data: Uint8Array) => {},
40
- end: () => {},
41
- },
42
- exited: Promise.resolve(0),
43
- })
44
- }
45
-
46
- // ─── generateKeypair ─────────────────────────────────────────────────────────
47
-
48
- describe('generateKeypair', () => {
49
- it('returns an object with privateKey and publicKey strings via injected keypairGen', async () => {
50
- // Use dependency injection — never depends on host `wg` binary.
51
- const mockPrivateKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
52
- const mockPublicKey = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
53
- const keypairGen = makeKeypairGen(mockPrivateKey, mockPublicKey)
54
-
55
- // vpnClientAdd uses keypairGen internally; test the injected path directly
56
- // by calling the mock and asserting the returned shape
57
- const keypair = await keypairGen()
58
-
59
- expect(typeof keypair.privateKey).toBe('string')
60
- expect(typeof keypair.publicKey).toBe('string')
61
- expect(keypair.privateKey).toBe(mockPrivateKey)
62
- expect(keypair.publicKey).toBe(mockPublicKey)
63
- })
64
-
65
- it('vpnClientAdd uses the injected keypairGen — no dependency on host wg binary', async () => {
66
- // This test verifies that vpnClientAdd correctly uses the injected keypairGen
67
- // and returns the keypair values from it (not from a real wg invocation).
68
- const {mkdtempSync: mkdtemp, mkdirSync, writeFileSync: writeFile, rmSync: rm} = await import('node:fs')
69
- const {tmpdir} = await import('node:os')
70
- const {join: joinPath} = await import('node:path')
71
-
72
- const tmpDir = mkdtemp(joinPath(tmpdir(), 'vpn-keypair-test-'))
73
- const peersJsonPath = joinPath(tmpDir, 'peers.json')
74
- const clientsDir = joinPath(tmpDir, 'clients')
75
- mkdirSync(clientsDir, {recursive: true})
76
- writeFile(peersJsonPath, `${JSON.stringify({peers: []}, null, 2)}\n`)
77
-
78
- try {
79
- const injectedPrivateKey = 'INJECTEDPRIVATEKEYVALUE1234567890123456789='
80
- const injectedPublicKey = 'INJECTEDPUBLICKEYVALUE12345678901234567890='
81
- const keypairGen = makeKeypairGen(injectedPrivateKey, injectedPublicKey)
82
-
83
- const result = await vpnClientAdd('testdevice', {
84
- peersJsonPath,
85
- clientsDir,
86
- serverPublicKey: 'SERVERPUBKEY==',
87
- endpoint: '1.2.3.4',
88
- keypairGen,
89
- spawnFn: makeNoopSpawnFn(),
90
- })
91
-
92
- // The result must use the injected keypair values
93
- expect(result.tunnelIp).toBe('10.8.0.2')
94
-
95
- // Client .conf must contain the injected private key
96
- const {readFile} = await import('node:fs/promises')
97
- const confContent = await readFile(result.confPath, 'utf-8')
98
- expect(confContent).toContain(injectedPrivateKey)
99
-
100
- // peers.json must contain the injected public key
101
- const peersContent = await readFile(peersJsonPath, 'utf-8')
102
- expect(peersContent).toContain(injectedPublicKey)
103
- expect(peersContent).not.toContain(injectedPrivateKey)
104
- } finally {
105
- rm(tmpDir, {recursive: true, force: true})
106
- }
107
- })
108
- })
109
-
110
- // ─── vpnClientAdd ─────────────────────────────────────────────────────────────
111
-
112
- describe('vpnClientAdd', () => {
113
- let tmpDir: string
114
- let peersJsonPath: string
115
- let clientsDir: string
116
-
117
- beforeEach(() => {
118
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
119
- peersJsonPath = join(tmpDir, 'peers.json')
120
- clientsDir = join(tmpDir, 'clients')
121
- mkdirSync(clientsDir, {recursive: true})
122
-
123
- // Start with empty peers.json
124
- writeFileSync(peersJsonPath, makePeersJson([]))
125
- })
126
-
127
- afterEach(() => {
128
- rmSync(tmpDir, {recursive: true, force: true})
129
- })
130
-
131
- it('generates a keypair, assigns next IP, appends public key to peers.json, writes client .conf', async () => {
132
- const keypairGen = makeKeypairGen('PRIVATEKEYVALUE==', 'PUBLICKEYVALUE==')
133
-
134
- const result = await vpnClientAdd('laptop', {
135
- peersJsonPath,
136
- clientsDir,
137
- serverPublicKey: 'SERVERPUBKEY==',
138
- endpoint: '1.2.3.4',
139
- keypairGen,
140
- spawnFn: makeNoopSpawnFn(),
141
- })
142
-
143
- // Check result
144
- expect(result.tunnelIp).toBe('10.8.0.2')
145
- expect(result.confPath).toBe(join(clientsDir, 'laptop.conf'))
146
-
147
- // Check peers.json was updated with PUBLIC key only
148
- const peersContent = await readFile(peersJsonPath, 'utf-8')
149
- const peersData = JSON.parse(peersContent) as {peers: {name: string; publicKey: string; tunnelIp: string}[]}
150
- expect(peersData.peers).toHaveLength(1)
151
- expect(peersData.peers[0]?.name).toBe('laptop')
152
- expect(peersData.peers[0]?.publicKey).toBe('PUBLICKEYVALUE==')
153
- expect(peersData.peers[0]?.tunnelIp).toBe('10.8.0.2')
154
-
155
- // Check client .conf was written with PRIVATE key
156
- const confContent = await readFile(result.confPath, 'utf-8')
157
- expect(confContent).toContain('PRIVATEKEYVALUE==')
158
- expect(confContent).toContain('SERVERPUBKEY==')
159
- expect(confContent).toContain('1.2.3.4:51820')
160
- expect(confContent).toContain('10.8.0.2/32')
161
- expect(confContent).toContain('AllowedIPs = 0.0.0.0/0')
162
- })
163
-
164
- it('assigns sequential IPs for multiple clients', async () => {
165
- const keypairGen1 = makeKeypairGen('PRIV1==', 'PUB1==')
166
- const keypairGen2 = makeKeypairGen('PRIV2==', 'PUB2==')
167
-
168
- await vpnClientAdd('laptop', {
169
- peersJsonPath,
170
- clientsDir,
171
- serverPublicKey: 'SERVERPUBKEY==',
172
- endpoint: '1.2.3.4',
173
- keypairGen: keypairGen1,
174
- spawnFn: makeNoopSpawnFn(),
175
- })
176
-
177
- const result2 = await vpnClientAdd('phone', {
178
- peersJsonPath,
179
- clientsDir,
180
- serverPublicKey: 'SERVERPUBKEY==',
181
- endpoint: '1.2.3.4',
182
- keypairGen: keypairGen2,
183
- spawnFn: makeNoopSpawnFn(),
184
- })
185
-
186
- expect(result2.tunnelIp).toBe('10.8.0.3')
187
- })
188
-
189
- it('supports split-tunnel via allowedIps option', async () => {
190
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
191
-
192
- const result = await vpnClientAdd('laptop', {
193
- peersJsonPath,
194
- clientsDir,
195
- serverPublicKey: 'SERVERPUBKEY==',
196
- endpoint: '1.2.3.4',
197
- keypairGen,
198
- allowedIps: '10.0.0.0/8,192.168.0.0/16',
199
- spawnFn: makeNoopSpawnFn(),
200
- })
201
-
202
- const confContent = await readFile(result.confPath, 'utf-8')
203
- expect(confContent).toContain('AllowedIPs = 10.0.0.0/8,192.168.0.0/16')
204
- })
205
-
206
- it('throws when a peer with the same name already exists', async () => {
207
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
208
-
209
- await vpnClientAdd('laptop', {
210
- peersJsonPath,
211
- clientsDir,
212
- serverPublicKey: 'SERVERPUBKEY==',
213
- endpoint: '1.2.3.4',
214
- keypairGen,
215
- spawnFn: makeNoopSpawnFn(),
216
- })
217
-
218
- await expect(
219
- vpnClientAdd('laptop', {
220
- peersJsonPath,
221
- clientsDir,
222
- serverPublicKey: 'SERVERPUBKEY==',
223
- endpoint: '1.2.3.4',
224
- keypairGen,
225
- spawnFn: makeNoopSpawnFn(),
226
- }),
227
- ).rejects.toThrow('already exists')
228
- })
229
-
230
- it('refuses to write client .conf outside the clients directory', async () => {
231
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
232
-
233
- // Attempt path traversal: name with ../ to escape clients dir
234
- await expect(
235
- vpnClientAdd('../evil', {
236
- peersJsonPath,
237
- clientsDir,
238
- serverPublicKey: 'SERVERPUBKEY==',
239
- endpoint: '1.2.3.4',
240
- keypairGen,
241
- spawnFn: makeNoopSpawnFn(),
242
- }),
243
- ).rejects.toThrow()
244
- })
245
-
246
- it('client .conf contains private key but peers.json contains only public key', async () => {
247
- const keypairGen = makeKeypairGen('SECRETPRIVATEKEY==', 'PUBLICKEY==')
248
-
249
- const result = await vpnClientAdd('laptop', {
250
- peersJsonPath,
251
- clientsDir,
252
- serverPublicKey: 'SERVERPUBKEY==',
253
- endpoint: '1.2.3.4',
254
- keypairGen,
255
- spawnFn: makeNoopSpawnFn(),
256
- })
257
-
258
- // peers.json must NOT contain the private key
259
- const peersContent = await readFile(peersJsonPath, 'utf-8')
260
- expect(peersContent).not.toContain('SECRETPRIVATEKEY==')
261
- expect(peersContent).toContain('PUBLICKEY==')
262
-
263
- // client .conf must contain the private key
264
- const confContent = await readFile(result.confPath, 'utf-8')
265
- expect(confContent).toContain('SECRETPRIVATEKEY==')
266
- })
267
-
268
- // Fix #2: ENOENT bootstrap on fresh checkout
269
- it('succeeds on fresh checkout (no peers.json) — creates first peer', async () => {
270
- // Do NOT write peers.json — simulate fresh checkout
271
- const freshDir = mkdtempSync(join(tmpdir(), 'vpn-fresh-test-'))
272
- const freshPeersJsonPath = join(freshDir, 'peers.json')
273
- const freshClientsDir = join(freshDir, 'clients')
274
- mkdirSync(freshClientsDir, {recursive: true})
275
-
276
- try {
277
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
278
- const result = await vpnClientAdd('firstpeer', {
279
- peersJsonPath: freshPeersJsonPath,
280
- clientsDir: freshClientsDir,
281
- serverPublicKey: 'SERVERPUBKEY==',
282
- endpoint: '1.2.3.4',
283
- keypairGen,
284
- spawnFn: makeNoopSpawnFn(),
285
- })
286
-
287
- expect(result.tunnelIp).toBe('10.8.0.2')
288
-
289
- // peers.json must now exist with the new peer
290
- const peersContent = await readFile(freshPeersJsonPath, 'utf-8')
291
- const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
292
- expect(peersData.peers).toHaveLength(1)
293
- expect(peersData.peers[0]?.name).toBe('firstpeer')
294
-
295
- // client .conf must exist with private key
296
- const confContent = await readFile(result.confPath, 'utf-8')
297
- expect(confContent).toContain('PRIV==')
298
- } finally {
299
- rmSync(freshDir, {recursive: true, force: true})
300
- }
301
- })
302
-
303
- // Fix #4: write client .conf BEFORE gh sync
304
- it('client .conf exists even when spawnFn throws (conf written before sync)', async () => {
305
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
306
- const throwingSpawnFn: SpawnFn = () => {
307
- throw new Error('gh not found')
308
- }
309
-
310
- const warnMessages: string[] = []
311
- const originalWarn = console.warn
312
- console.warn = (...args: unknown[]) => {
313
- warnMessages.push(args.join(' '))
314
- }
315
-
316
- try {
317
- const result = await vpnClientAdd('laptop', {
318
- peersJsonPath,
319
- clientsDir,
320
- serverPublicKey: 'SERVERPUBKEY==',
321
- endpoint: '1.2.3.4',
322
- keypairGen,
323
- spawnFn: throwingSpawnFn,
324
- })
325
-
326
- // .conf must exist with the private key
327
- const confContent = await readFile(result.confPath, 'utf-8')
328
- expect(confContent).toContain('PRIV==')
329
-
330
- // A warning must have been printed (not a throw)
331
- const allWarnings = warnMessages.join('\n')
332
- expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
333
- } finally {
334
- console.warn = originalWarn
335
- }
336
- })
337
- })
338
-
339
- // ─── vpnClientList ────────────────────────────────────────────────────────────
340
-
341
- describe('vpnClientList', () => {
342
- let tmpDir: string
343
- let peersJsonPath: string
344
-
345
- beforeEach(() => {
346
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
347
- peersJsonPath = join(tmpDir, 'peers.json')
348
- })
349
-
350
- afterEach(() => {
351
- rmSync(tmpDir, {recursive: true, force: true})
352
- })
353
-
354
- it('returns empty array when no peers', async () => {
355
- writeFileSync(peersJsonPath, makePeersJson([]))
356
-
357
- const peers = await vpnClientList(peersJsonPath)
358
-
359
- expect(peers).toHaveLength(0)
360
- })
361
-
362
- it('returns all peers with name, publicKey, tunnelIp', async () => {
363
- writeFileSync(
364
- peersJsonPath,
365
- makePeersJson([
366
- {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
367
- {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
368
- ]),
369
- )
370
-
371
- const peers = await vpnClientList(peersJsonPath)
372
-
373
- expect(peers).toHaveLength(2)
374
- expect(peers[0]?.name).toBe('laptop')
375
- expect(peers[0]?.publicKey).toBe('PUB1==')
376
- expect(peers[0]?.tunnelIp).toBe('10.8.0.2')
377
- expect(peers[1]?.name).toBe('phone')
378
- })
379
-
380
- // Fix #2: list on fresh checkout (no peers.json) returns empty
381
- it('returns empty array when peers.json does not exist (fresh checkout)', async () => {
382
- // Do NOT write peers.json
383
- const peers = await vpnClientList(peersJsonPath)
384
- expect(peers).toHaveLength(0)
385
- })
386
- })
387
-
388
- // ─── vpnClientRemove ──────────────────────────────────────────────────────────
389
-
390
- describe('vpnClientRemove', () => {
391
- let tmpDir: string
392
- let peersJsonPath: string
393
-
394
- beforeEach(() => {
395
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
396
- peersJsonPath = join(tmpDir, 'peers.json')
397
- })
398
-
399
- afterEach(() => {
400
- rmSync(tmpDir, {recursive: true, force: true})
401
- })
402
-
403
- it('removes a peer from peers.json', async () => {
404
- writeFileSync(
405
- peersJsonPath,
406
- makePeersJson([
407
- {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
408
- {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
409
- ]),
410
- )
411
-
412
- await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
413
-
414
- const peersContent = await readFile(peersJsonPath, 'utf-8')
415
- const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
416
- expect(peersData.peers).toHaveLength(1)
417
- expect(peersData.peers[0]?.name).toBe('phone')
418
- })
419
-
420
- it('throws when removing a non-existent peer', async () => {
421
- writeFileSync(peersJsonPath, makePeersJson([]))
422
-
423
- await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
424
- 'not found',
425
- )
426
- })
427
-
428
- it('after remove, list reflects the removal', async () => {
429
- writeFileSync(
430
- peersJsonPath,
431
- makePeersJson([
432
- {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
433
- {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
434
- ]),
435
- )
436
-
437
- await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
438
- const peers = await vpnClientList(peersJsonPath)
439
-
440
- expect(peers).toHaveLength(1)
441
- expect(peers[0]?.name).toBe('phone')
442
- })
443
-
444
- // Fix #2: remove of nonexistent peer on fresh checkout gives not-found error, not ENOENT
445
- it('throws not-found (not ENOENT) when removing from a nonexistent peers.json', async () => {
446
- // Do NOT write peers.json — simulate fresh checkout
447
- await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
448
- 'not found',
449
- )
450
- })
451
- })
452
-
453
- // ─── gh secret sync after vpnClientAdd ───────────────────────────────────────
454
-
455
- describe('vpnClientAdd: gh secret sync', () => {
456
- let tmpDir: string
457
- let peersJsonPath: string
458
- let clientsDir: string
459
-
460
- beforeEach(() => {
461
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-sync-test-'))
462
- peersJsonPath = join(tmpDir, 'peers.json')
463
- clientsDir = join(tmpDir, 'clients')
464
- mkdirSync(clientsDir, {recursive: true})
465
- writeFileSync(peersJsonPath, makePeersJson([]))
466
- })
467
-
468
- afterEach(() => {
469
- rmSync(tmpDir, {recursive: true, force: true})
470
- })
471
-
472
- it('spawns gh secret set with roster on STDIN after successful add', async () => {
473
- const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
474
- const spawnCalls: {cmd: string[]; stdin: string}[] = []
475
-
476
- const mockSpawnFn: SpawnFn = (cmd, _opts) => {
477
- let stdinContent = ''
478
- const stdinPipe = {
479
- write: (data: Uint8Array) => {
480
- stdinContent += new TextDecoder().decode(data)
481
- },
482
- end: () => {
483
- spawnCalls.push({cmd, stdin: stdinContent})
484
- },
485
- }
486
- return {
487
- stdout: new ReadableStream({
488
- start(c) {
489
- c.enqueue(new TextEncoder().encode(''))
490
- c.close()
491
- },
492
- }),
493
- stderr: new ReadableStream({
494
- start(c) {
495
- c.enqueue(new TextEncoder().encode(''))
496
- c.close()
497
- },
498
- }),
499
- stdin: stdinPipe,
500
- exited: Promise.resolve(0),
501
- }
502
- }
503
-
504
- await vpnClientAdd('newpeer', {
505
- peersJsonPath,
506
- clientsDir,
507
- serverPublicKey: 'SERVERPUBKEY==',
508
- endpoint: '1.2.3.4',
509
- keypairGen,
510
- spawnFn: mockSpawnFn,
511
- })
512
-
513
- // Must have spawned gh secret set
514
- const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
515
- expect(ghCall).toBeDefined()
516
-
517
- // The new peer's public key must appear in STDIN, not in argv
518
- expect(ghCall?.stdin).toContain('NEWPUBKEY==')
519
- const argvContainsPubkey = ghCall?.cmd.some(arg => arg.includes('NEWPUBKEY=='))
520
- expect(argvContainsPubkey).toBe(false)
521
-
522
- // Must pass --env vpn and --repo marcusrbrown/infra
523
- expect(ghCall?.cmd).toContain('--env')
524
- expect(ghCall?.cmd).toContain('vpn')
525
- expect(ghCall?.cmd).toContain('--repo')
526
- expect(ghCall?.cmd).toContain('marcusrbrown/infra')
527
- })
528
-
529
- it('gh failure → command still succeeds, warning printed with remediation', async () => {
530
- const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
531
- const warnMessages: string[] = []
532
- const originalWarn = console.warn
533
- console.warn = (...args: unknown[]) => {
534
- warnMessages.push(args.join(' '))
535
- }
536
-
537
- const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
538
- return {
539
- stdout: new ReadableStream({
540
- start(c) {
541
- c.enqueue(new TextEncoder().encode(''))
542
- c.close()
543
- },
544
- }),
545
- stderr: new ReadableStream({
546
- start(c) {
547
- c.enqueue(new TextEncoder().encode('auth error'))
548
- c.close()
549
- },
550
- }),
551
- stdin: {
552
- write: (_data: Uint8Array) => {},
553
- end: () => {},
554
- },
555
- exited: Promise.resolve(1), // gh fails
556
- }
557
- }
558
-
559
- try {
560
- // Must NOT throw even though gh fails
561
- const result = await vpnClientAdd('newpeer', {
562
- peersJsonPath,
563
- clientsDir,
564
- serverPublicKey: 'SERVERPUBKEY==',
565
- endpoint: '1.2.3.4',
566
- keypairGen,
567
- spawnFn: mockSpawnFn,
568
- })
569
-
570
- // The add itself must succeed
571
- expect(result.tunnelIp).toBe('10.8.0.2')
572
-
573
- // A warning must have been printed with remediation
574
- const allWarnings = warnMessages.join('\n')
575
- expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
576
- expect(allWarnings).toMatch(/gh secret set/)
577
- } finally {
578
- console.warn = originalWarn
579
- }
580
- })
581
-
582
- // Fix #3: spawnFn throws synchronously → command still succeeds + warning printed
583
- it('spawnFn throws synchronously → command still succeeds, warning printed with remediation', async () => {
584
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
585
- const warnMessages: string[] = []
586
- const originalWarn = console.warn
587
- console.warn = (...args: unknown[]) => {
588
- warnMessages.push(args.join(' '))
589
- }
590
-
591
- const throwingSpawnFn: SpawnFn = () => {
592
- throw new Error('ENOENT: gh not found')
593
- }
594
-
595
- try {
596
- const result = await vpnClientAdd('newpeer', {
597
- peersJsonPath,
598
- clientsDir,
599
- serverPublicKey: 'SERVERPUBKEY==',
600
- endpoint: '1.2.3.4',
601
- keypairGen,
602
- spawnFn: throwingSpawnFn,
603
- })
604
-
605
- // Command must succeed
606
- expect(result.tunnelIp).toBe('10.8.0.2')
607
-
608
- // Warning must be printed with remediation
609
- const allWarnings = warnMessages.join('\n')
610
- expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
611
- expect(allWarnings).toMatch(/gh secret set/)
612
- } finally {
613
- console.warn = originalWarn
614
- }
615
- })
616
-
617
- // Fix #3: timeout — spawnFn whose exited never resolves → warning after timeout
618
- it('spawnFn whose exited never resolves → warning after timeout (injectable timeoutMs)', async () => {
619
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
620
- const warnMessages: string[] = []
621
- const originalWarn = console.warn
622
- console.warn = (...args: unknown[]) => {
623
- warnMessages.push(args.join(' '))
624
- }
625
-
626
- const hangingSpawnFn: SpawnFn = (_cmd, _opts) => ({
627
- stdout: new ReadableStream({
628
- start(c) {
629
- c.enqueue(new TextEncoder().encode(''))
630
- c.close()
631
- },
632
- }),
633
- stderr: new ReadableStream({
634
- start(c) {
635
- c.enqueue(new TextEncoder().encode(''))
636
- c.close()
637
- },
638
- }),
639
- stdin: {
640
- write: (_data: Uint8Array) => {},
641
- end: () => {},
642
- },
643
- // Never resolves
644
- exited: new Promise<number>(() => {}),
645
- })
646
-
647
- try {
648
- // Use a very short timeout (50ms) so the test doesn't hang
649
- const result = await vpnClientAdd('newpeer', {
650
- peersJsonPath,
651
- clientsDir,
652
- serverPublicKey: 'SERVERPUBKEY==',
653
- endpoint: '1.2.3.4',
654
- keypairGen,
655
- spawnFn: hangingSpawnFn,
656
- syncTimeoutMs: 50,
657
- })
658
-
659
- // Command must succeed
660
- expect(result.tunnelIp).toBe('10.8.0.2')
661
-
662
- // Warning must be printed
663
- const allWarnings = warnMessages.join('\n')
664
- expect(allWarnings).toMatch(/VPN_PEERS|stale|warning|timeout/i)
665
- } finally {
666
- console.warn = originalWarn
667
- }
668
- })
669
-
670
- // Fix #5: remediation message must include --repo marcusrbrown/infra
671
- it('remediation message includes --repo marcusrbrown/infra', async () => {
672
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
673
- const warnMessages: string[] = []
674
- const originalWarn = console.warn
675
- console.warn = (...args: unknown[]) => {
676
- warnMessages.push(args.join(' '))
677
- }
678
-
679
- const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
680
- stdout: new ReadableStream({
681
- start(c) {
682
- c.enqueue(new TextEncoder().encode(''))
683
- c.close()
684
- },
685
- }),
686
- stderr: new ReadableStream({
687
- start(c) {
688
- c.enqueue(new TextEncoder().encode('error'))
689
- c.close()
690
- },
691
- }),
692
- stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
693
- exited: Promise.resolve(1),
694
- })
695
-
696
- try {
697
- await vpnClientAdd('newpeer', {
698
- peersJsonPath,
699
- clientsDir,
700
- serverPublicKey: 'SERVERPUBKEY==',
701
- endpoint: '1.2.3.4',
702
- keypairGen,
703
- spawnFn: failingSpawnFn,
704
- })
705
-
706
- const allWarnings = warnMessages.join('\n')
707
- expect(allWarnings).toContain('--repo marcusrbrown/infra')
708
- } finally {
709
- console.warn = originalWarn
710
- }
711
- })
712
- })
713
-
714
- // ─── gh secret sync after vpnClientRemove ────────────────────────────────────
715
-
716
- describe('vpnClientRemove: gh secret sync', () => {
717
- let tmpDir: string
718
- let peersJsonPath: string
719
-
720
- beforeEach(() => {
721
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-remove-sync-test-'))
722
- peersJsonPath = join(tmpDir, 'peers.json')
723
- writeFileSync(
724
- peersJsonPath,
725
- makePeersJson([
726
- {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
727
- {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
728
- ]),
729
- )
730
- })
731
-
732
- afterEach(() => {
733
- rmSync(tmpDir, {recursive: true, force: true})
734
- })
735
-
736
- it('spawns gh secret set with updated roster on STDIN after successful remove', async () => {
737
- const spawnCalls: {cmd: string[]; stdin: string}[] = []
738
-
739
- const mockSpawnFn: SpawnFn = (cmd, _opts) => {
740
- let stdinContent = ''
741
- const stdinPipe = {
742
- write: (data: Uint8Array) => {
743
- stdinContent += new TextDecoder().decode(data)
744
- },
745
- end: () => {
746
- spawnCalls.push({cmd, stdin: stdinContent})
747
- },
748
- }
749
- return {
750
- stdout: new ReadableStream({
751
- start(c) {
752
- c.enqueue(new TextEncoder().encode(''))
753
- c.close()
754
- },
755
- }),
756
- stderr: new ReadableStream({
757
- start(c) {
758
- c.enqueue(new TextEncoder().encode(''))
759
- c.close()
760
- },
761
- }),
762
- stdin: stdinPipe,
763
- exited: Promise.resolve(0),
764
- }
765
- }
766
-
767
- await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
768
-
769
- // Must have spawned gh secret set
770
- const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
771
- expect(ghCall).toBeDefined()
772
-
773
- // Removed peer must NOT appear in STDIN
774
- expect(ghCall?.stdin).not.toContain('PUB1==')
775
- // Remaining peer must appear in STDIN
776
- expect(ghCall?.stdin).toContain('PUB2==')
777
-
778
- // Roster bytes must not appear in argv
779
- const argvContainsRoster = ghCall?.cmd.some(arg => arg.includes('PUB1==') || arg.includes('PUB2=='))
780
- expect(argvContainsRoster).toBe(false)
781
- })
782
-
783
- it('gh failure on remove → command still succeeds, warning printed', async () => {
784
- const warnMessages: string[] = []
785
- const originalWarn = console.warn
786
- console.warn = (...args: unknown[]) => {
787
- warnMessages.push(args.join(' '))
788
- }
789
-
790
- const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
791
- return {
792
- stdout: new ReadableStream({
793
- start(c) {
794
- c.enqueue(new TextEncoder().encode(''))
795
- c.close()
796
- },
797
- }),
798
- stderr: new ReadableStream({
799
- start(c) {
800
- c.enqueue(new TextEncoder().encode('network error'))
801
- c.close()
802
- },
803
- }),
804
- stdin: {
805
- write: (_data: Uint8Array) => {},
806
- end: () => {},
807
- },
808
- exited: Promise.resolve(1), // gh fails
809
- }
810
- }
811
-
812
- try {
813
- // Must NOT throw even though gh fails
814
- await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
815
-
816
- // A warning must have been printed with remediation
817
- const allWarnings = warnMessages.join('\n')
818
- expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
819
- expect(allWarnings).toMatch(/gh secret set/)
820
- } finally {
821
- console.warn = originalWarn
822
- }
823
- })
824
-
825
- // Fix #5: remediation message must include --repo marcusrbrown/infra
826
- it('remediation message on remove failure includes --repo marcusrbrown/infra', async () => {
827
- const warnMessages: string[] = []
828
- const originalWarn = console.warn
829
- console.warn = (...args: unknown[]) => {
830
- warnMessages.push(args.join(' '))
831
- }
832
-
833
- const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
834
- stdout: new ReadableStream({
835
- start(c) {
836
- c.enqueue(new TextEncoder().encode(''))
837
- c.close()
838
- },
839
- }),
840
- stderr: new ReadableStream({
841
- start(c) {
842
- c.enqueue(new TextEncoder().encode('error'))
843
- c.close()
844
- },
845
- }),
846
- stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
847
- exited: Promise.resolve(1),
848
- })
849
-
850
- try {
851
- await vpnClientRemove('laptop', peersJsonPath, {spawnFn: failingSpawnFn})
852
-
853
- const allWarnings = warnMessages.join('\n')
854
- expect(allWarnings).toContain('--repo marcusrbrown/infra')
855
- } finally {
856
- console.warn = originalWarn
857
- }
858
- })
859
- })
860
-
861
- // ─── Write-guard: client .conf must stay in clients/ ─────────────────────────
862
-
863
- describe('write-guard: client .conf path safety', () => {
864
- let tmpDir: string
865
- let peersJsonPath: string
866
- let clientsDir: string
867
-
868
- beforeEach(() => {
869
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
870
- peersJsonPath = join(tmpDir, 'peers.json')
871
- clientsDir = join(tmpDir, 'clients')
872
- mkdirSync(clientsDir, {recursive: true})
873
- writeFileSync(peersJsonPath, makePeersJson([]))
874
- })
875
-
876
- afterEach(() => {
877
- rmSync(tmpDir, {recursive: true, force: true})
878
- })
879
-
880
- it('resolves the output path under clientsDir', async () => {
881
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
882
-
883
- const result = await vpnClientAdd('mydevice', {
884
- peersJsonPath,
885
- clientsDir,
886
- serverPublicKey: 'SERVERPUBKEY==',
887
- endpoint: '1.2.3.4',
888
- keypairGen,
889
- spawnFn: makeNoopSpawnFn(),
890
- })
891
-
892
- // The resolved path must be under clientsDir
893
- const resolvedClientsDir = resolve(clientsDir)
894
- expect(result.confPath.startsWith(resolvedClientsDir)).toBe(true)
895
- })
896
-
897
- it('rejects a name that would escape the clients directory via path traversal', async () => {
898
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
899
-
900
- // Path traversal attempt
901
- await expect(
902
- vpnClientAdd('../../etc/passwd', {
903
- peersJsonPath,
904
- clientsDir,
905
- serverPublicKey: 'SERVERPUBKEY==',
906
- endpoint: '1.2.3.4',
907
- keypairGen,
908
- spawnFn: makeNoopSpawnFn(),
909
- }),
910
- ).rejects.toThrow()
911
- })
912
- })
913
-
914
- // ─── client .conf file permissions ───────────────────────────────────────────
915
-
916
- describe('vpnClientAdd: client .conf file permissions', () => {
917
- let tmpDir: string
918
- let peersJsonPath: string
919
- let clientsDir: string
920
-
921
- beforeEach(() => {
922
- tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-perm-test-'))
923
- peersJsonPath = join(tmpDir, 'peers.json')
924
- clientsDir = join(tmpDir, 'clients')
925
- mkdirSync(clientsDir, {recursive: true})
926
- writeFileSync(peersJsonPath, makePeersJson([]))
927
- })
928
-
929
- afterEach(() => {
930
- rmSync(tmpDir, {recursive: true, force: true})
931
- })
932
-
933
- it('writes client .conf with mode 0o600 (owner read/write only)', async () => {
934
- const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
935
-
936
- const result = await vpnClientAdd('laptop', {
937
- peersJsonPath,
938
- clientsDir,
939
- serverPublicKey: 'SERVERPUBKEY==',
940
- endpoint: '1.2.3.4',
941
- keypairGen,
942
- spawnFn: makeNoopSpawnFn(),
943
- })
944
-
945
- const fileStat = await stat(result.confPath)
946
- const permBits = fileStat.mode & 0o777
947
- expect(permBits).toBe(0o600)
948
- })
949
- })