@marcusrbrown/infra 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,467 +0,0 @@
1
- import type {goke} from 'goke'
2
-
3
- import {mkdir, writeFile} from 'node:fs/promises'
4
- import {resolve} from 'node:path'
5
-
6
- import {z} from 'zod'
7
- import {addPeer, readPeersOrEmpty, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
8
-
9
- declare const process: {
10
- env: Record<string, string | undefined>
11
- exitCode?: number
12
- }
13
-
14
- // ─── Types ────────────────────────────────────────────────────────────────────
15
-
16
- export interface Keypair {
17
- privateKey: string
18
- publicKey: string
19
- }
20
-
21
- export type KeypairGenFn = () => Promise<Keypair>
22
-
23
- /** Minimal subset of Bun.Subprocess used for gh secret sync. */
24
- export interface SyncSpawnResult {
25
- stdout: ReadableStream<Uint8Array>
26
- stderr: ReadableStream<Uint8Array>
27
- stdin?: {write: (data: Uint8Array) => void; end: () => void}
28
- exited: Promise<number>
29
- }
30
-
31
- export interface SyncSpawnOpts {
32
- stdout: 'pipe'
33
- stderr: 'pipe'
34
- stdin: 'pipe'
35
- }
36
-
37
- /** Injectable spawn function for gh secret sync — defaults to Bun.spawn. */
38
- export type SpawnFn = (cmd: string[], opts: SyncSpawnOpts) => SyncSpawnResult
39
-
40
- export interface VpnClientAddOpts {
41
- peersJsonPath: string
42
- clientsDir: string
43
- serverPublicKey: string
44
- endpoint: string
45
- keypairGen?: KeypairGenFn
46
- spawnFn?: SpawnFn
47
- allowedIps?: string
48
- dns?: string
49
- /** Timeout in ms for gh secret sync. Defaults to 15000. Injectable for tests. */
50
- syncTimeoutMs?: number
51
- }
52
-
53
- export interface VpnClientAddResult {
54
- tunnelIp: string
55
- confPath: string
56
- }
57
-
58
- // ─── WireGuard keypair generation ─────────────────────────────────────────────
59
-
60
- /**
61
- * Generate a WireGuard keypair using the local `wg` binary.
62
- *
63
- * Assumption: `wg` is installed locally (wireguard-tools package).
64
- * The private key is generated locally and written ONLY to the gitignored
65
- * clients/ directory — it never leaves the local machine.
66
- */
67
- export async function generateKeypair(): Promise<Keypair> {
68
- // Step 1: generate private key
69
- const genkeyProc = Bun.spawn(['wg', 'genkey'], {
70
- stdout: 'pipe',
71
- stderr: 'pipe',
72
- })
73
-
74
- const [privateKeyRaw, genkeyStderr, genkeyExit] = await Promise.all([
75
- new Response(genkeyProc.stdout).text(),
76
- new Response(genkeyProc.stderr).text(),
77
- genkeyProc.exited,
78
- ])
79
-
80
- if (genkeyExit !== 0) {
81
- throw new Error(`wg genkey failed (exit ${genkeyExit}): ${genkeyStderr.trim()}`)
82
- }
83
-
84
- const privateKey = privateKeyRaw.trim()
85
-
86
- // Step 2: derive public key from private key
87
- const encoder = new TextEncoder()
88
- const pubkeyProc = Bun.spawn(['wg', 'pubkey'], {
89
- stdin: new ReadableStream({
90
- start(controller) {
91
- controller.enqueue(encoder.encode(`${privateKey}\n`))
92
- controller.close()
93
- },
94
- }),
95
- stdout: 'pipe',
96
- stderr: 'pipe',
97
- })
98
-
99
- const [publicKeyRaw, pubkeyStderr, pubkeyExit] = await Promise.all([
100
- new Response(pubkeyProc.stdout).text(),
101
- new Response(pubkeyProc.stderr).text(),
102
- pubkeyProc.exited,
103
- ])
104
-
105
- if (pubkeyExit !== 0) {
106
- throw new Error(`wg pubkey failed (exit ${pubkeyExit}): ${pubkeyStderr.trim()}`)
107
- }
108
-
109
- return {
110
- privateKey,
111
- publicKey: publicKeyRaw.trim(),
112
- }
113
- }
114
-
115
- // ─── gh secret sync ───────────────────────────────────────────────────────────
116
-
117
- const GH_SYNC_REPO = 'marcusrbrown/infra'
118
- const GH_SYNC_ENV = 'vpn'
119
- const GH_SYNC_SECRET = 'VPN_PEERS'
120
-
121
- /**
122
- * The exact remediation command — mirrors the real spawn command exactly.
123
- * Includes --repo so the operator can run it from any directory.
124
- */
125
- const GH_SYNC_REMEDIATION = `gh secret set ${GH_SYNC_SECRET} --env ${GH_SYNC_ENV} --repo ${GH_SYNC_REPO} < apps/vpn/config/peers.json`
126
-
127
- function defaultSpawnFn(cmd: string[], opts: SyncSpawnOpts): SyncSpawnResult {
128
- return Bun.spawn(cmd, opts)
129
- }
130
-
131
- /**
132
- * Sync the updated peer roster to the VPN_PEERS GitHub Environment secret.
133
- *
134
- * Pipes the JSON via stdin to `gh secret set VPN_PEERS --env vpn --repo marcusrbrown/infra`.
135
- * The roster bytes NEVER appear in argv — stdin only.
136
- *
137
- * On failure: prints a warning with the exact remediation command. Does NOT throw.
138
- * The local peers.json write has already succeeded; the secret being stale is recoverable.
139
- *
140
- * Hardening:
141
- * - Wraps the entire spawn + stdin write + exited await in try/catch.
142
- * If spawnFn throws synchronously (e.g. ENOENT for missing gh), prints warning and returns.
143
- * - Races proc.exited against timeoutMs (default 15s). On timeout, kills the proc and warns.
144
- * - Drains stdout to avoid pipe-buffer deadlock.
145
- */
146
- async function syncPeersSecret(peersJson: string, spawnFn: SpawnFn, timeoutMs = 15_000): Promise<void> {
147
- const cmd = ['gh', 'secret', 'set', GH_SYNC_SECRET, '--env', GH_SYNC_ENV, '--repo', GH_SYNC_REPO]
148
-
149
- let proc: SyncSpawnResult
150
- try {
151
- proc = spawnFn(cmd, {stdout: 'pipe', stderr: 'pipe', stdin: 'pipe'})
152
- } catch {
153
- console.warn(
154
- `[warn] VPN_PEERS secret sync failed: could not spawn gh. ` +
155
- `The local peers.json was updated but the VPN_PEERS secret is stale. ` +
156
- `Remediation: ${GH_SYNC_REMEDIATION}`,
157
- )
158
- return
159
- }
160
-
161
- if (!proc.stdin) {
162
- console.warn(
163
- `[warn] VPN_PEERS secret sync skipped: spawn did not provide stdin pipe. ` +
164
- `Remediation: ${GH_SYNC_REMEDIATION}`,
165
- )
166
- return
167
- }
168
-
169
- try {
170
- proc.stdin.write(new TextEncoder().encode(peersJson))
171
- proc.stdin.end()
172
-
173
- // Race proc.exited against timeout to avoid hanging indefinitely.
174
- // Drain stdout concurrently to avoid pipe-buffer deadlock.
175
- let timedOut = false
176
- const timeoutHandle = {kill: () => {}}
177
-
178
- const timeoutPromise = new Promise<number>(resolveTimeout => {
179
- const id = setTimeout(() => {
180
- timedOut = true
181
- resolveTimeout(-1)
182
- }, timeoutMs)
183
- timeoutHandle.kill = () => clearTimeout(id)
184
- })
185
-
186
- const [exitCode] = await Promise.all([
187
- Promise.race([proc.exited, timeoutPromise]),
188
- // Drain stdout to prevent pipe-buffer deadlock
189
- new Response(proc.stdout).text().catch(() => ''),
190
- ])
191
-
192
- timeoutHandle.kill()
193
-
194
- if (timedOut) {
195
- console.warn(
196
- `[warn] VPN_PEERS secret sync timed out after ${timeoutMs}ms. ` +
197
- `The local peers.json was updated but the VPN_PEERS secret is stale. ` +
198
- `Remediation: ${GH_SYNC_REMEDIATION}`,
199
- )
200
- return
201
- }
202
-
203
- const stderr = await new Response(proc.stderr).text().catch(() => '')
204
-
205
- if (exitCode !== 0) {
206
- console.warn(
207
- `[warn] VPN_PEERS secret sync failed (gh exited ${exitCode}${stderr.trim() ? `: ${stderr.trim()}` : ''}). ` +
208
- `The local peers.json was updated but the VPN_PEERS secret is stale. ` +
209
- `Remediation: ${GH_SYNC_REMEDIATION}`,
210
- )
211
- }
212
- } catch {
213
- console.warn(
214
- `[warn] VPN_PEERS secret sync failed unexpectedly. ` +
215
- `The local peers.json was updated but the VPN_PEERS secret is stale. ` +
216
- `Remediation: ${GH_SYNC_REMEDIATION}`,
217
- )
218
- }
219
- }
220
-
221
- // ─── Write-guard ──────────────────────────────────────────────────────────────
222
-
223
- /**
224
- * Validate that the resolved output path is under the expected clients directory.
225
- *
226
- * This is a runtime guard (not gitignore-only) that refuses to write client
227
- * .conf files (which contain private keys) outside the gitignored clients/ dir.
228
- *
229
- * @throws {Error} if the resolved path escapes the clients directory.
230
- */
231
- function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
232
- const resolvedClientsDir = resolve(clientsDir)
233
- const resolvedConfPath = resolve(confPath)
234
-
235
- if (!resolvedConfPath.startsWith(`${resolvedClientsDir}/`) && resolvedConfPath !== resolvedClientsDir) {
236
- throw new Error(
237
- `Security: refusing to write client .conf outside the clients directory. ` +
238
- `Resolved path "${resolvedConfPath}" is not under "${resolvedClientsDir}". ` +
239
- `Client private keys must only be written to the gitignored clients/ directory.`,
240
- )
241
- }
242
- }
243
-
244
- // ─── Core operations ──────────────────────────────────────────────────────────
245
-
246
- /**
247
- * Add a new VPN client peer.
248
- *
249
- * 1. Generates a WireGuard keypair locally (via `wg genkey` / `wg pubkey`).
250
- * 2. Assigns the next available tunnel IP via addPeer.
251
- * 3. Appends the PUBLIC key + tunnel IP to peers.json.
252
- * 4. Writes the client .conf (with PRIVATE key) to clients/<name>.conf.
253
- * 5. Best-effort syncs the updated roster to the VPN_PEERS GitHub secret.
254
- *
255
- * Security invariants:
256
- * - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
257
- * - peers.json contains ONLY the public key — never the private key.
258
- * - The output path is validated before writing (runtime guard, not gitignore-only).
259
- *
260
- * Ordering: local peers.json write → client .conf write → THEN best-effort sync.
261
- * The .conf holds the user's ONLY copy of the private key; it must be written
262
- * before the best-effort sync so a sync failure never loses the private key.
263
- */
264
- export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
265
- const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
266
- const keypairGen = opts.keypairGen ?? generateKeypair
267
- const spawnFn = opts.spawnFn ?? defaultSpawnFn
268
- const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
269
-
270
- // Validate the output path before doing any work
271
- const confPath = resolve(clientsDir, `${name}.conf`)
272
- assertPathUnderClientsDir(confPath, clientsDir)
273
-
274
- // Generate keypair
275
- const keypair = await keypairGen()
276
-
277
- // Read current peers — returns {peers: []} on fresh checkout (ENOENT)
278
- const peersFile = await readPeersOrEmpty(peersJsonPath)
279
-
280
- // Add peer (throws if duplicate name)
281
- const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
282
- name,
283
- publicKey: keypair.publicKey,
284
- })
285
-
286
- // Write updated peers.json (PUBLIC key only — never private key)
287
- const updatedPeersFile = {peers: updatedPeers}
288
- await writePeers(peersJsonPath, updatedPeersFile)
289
-
290
- // Render client .conf (contains PRIVATE key)
291
- const confContent = renderClientConfig({
292
- clientPrivateKey: keypair.privateKey,
293
- serverPublicKey,
294
- endpoint,
295
- tunnelIp,
296
- allowedIps,
297
- dns,
298
- })
299
-
300
- // Ensure clients directory exists
301
- await mkdir(clientsDir, {recursive: true})
302
-
303
- // Write client .conf to the gitignored clients/ directory
304
- // MUST happen BEFORE gh sync — this is the user's ONLY copy of the private key.
305
- await writeFile(confPath, confContent, {mode: 0o600})
306
-
307
- // Sync updated roster to VPN_PEERS GitHub secret (best-effort — failure is a warning, not fatal)
308
- const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}\n`
309
- await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs)
310
-
311
- return {tunnelIp, confPath}
312
- }
313
-
314
- /**
315
- * List all VPN client peers from peers.json.
316
- * Returns an empty array when peers.json does not exist (fresh checkout).
317
- */
318
- export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
319
- const peersFile = await readPeersOrEmpty(peersJsonPath)
320
- return peersFile.peers
321
- }
322
-
323
- export interface VpnClientRemoveOpts {
324
- spawnFn?: SpawnFn
325
- syncTimeoutMs?: number
326
- }
327
-
328
- /**
329
- * Remove a VPN client peer from peers.json.
330
- *
331
- * Note: The client .conf file in clients/ is NOT deleted — the operator
332
- * should delete it manually. The peer is removed from peers.json so the
333
- * next deploy will revoke access.
334
- *
335
- * After a successful local write, syncs the updated roster to the VPN_PEERS
336
- * GitHub Environment secret (best-effort — failure is a warning, not fatal).
337
- */
338
- export async function vpnClientRemove(
339
- name: string,
340
- peersJsonPath: string,
341
- opts: VpnClientRemoveOpts = {},
342
- ): Promise<void> {
343
- const spawnFn = opts.spawnFn ?? defaultSpawnFn
344
- const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
345
-
346
- // Read current peers — returns {peers: []} on fresh checkout (ENOENT)
347
- const peersFile = await readPeersOrEmpty(peersJsonPath)
348
- const {peers: updatedPeers} = removePeer(peersFile.peers, name)
349
- const updatedPeersFile = {peers: updatedPeers}
350
- await writePeers(peersJsonPath, updatedPeersFile)
351
-
352
- // Sync updated roster to VPN_PEERS GitHub secret (best-effort — failure is a warning, not fatal)
353
- const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}\n`
354
- await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs)
355
- }
356
-
357
- // ─── Default paths ────────────────────────────────────────────────────────────
358
-
359
- function resolveDefaultPeersJsonPath(): string {
360
- // packages/cli/src/commands/vpn/ → repo root → apps/vpn/config/peers.json
361
- return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'config', 'peers.json')
362
- }
363
-
364
- function resolveDefaultClientsDir(): string {
365
- // packages/cli/src/commands/vpn/ → repo root → apps/vpn/clients/
366
- return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'clients')
367
- }
368
-
369
- // ─── Command registration ─────────────────────────────────────────────────────
370
-
371
- export function registerVpnClient(cli: ReturnType<typeof goke>): void {
372
- cli
373
- .command(
374
- 'vpn client add <name>',
375
- 'Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).',
376
- )
377
- .option(
378
- '--split-tunnel [cidrs]',
379
- z.string().describe('Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).'),
380
- )
381
- .option('--dns [dns]', z.string().describe('DNS server for the client config.'))
382
- .option('--server-pubkey [key]', z.string().describe('Server public key. Falls back to VPN_SERVER_PUBKEY env var.'))
383
- .option(
384
- '--endpoint [host]',
385
- z.string().describe('VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.'),
386
- )
387
- .example('# Add a new client named "laptop"')
388
- .example('infra vpn client add laptop')
389
- .example('# Add a split-tunnel client')
390
- .example('infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16')
391
- .action(async (name, options) => {
392
- const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY
393
- const endpoint = options.endpoint ?? process.env.VPN_HOST
394
-
395
- if (!serverPublicKey) {
396
- console.error('Server public key is required. Pass --server-pubkey or set VPN_SERVER_PUBKEY.')
397
- process.exitCode = 1
398
- return
399
- }
400
-
401
- if (!endpoint) {
402
- console.error('VPN endpoint is required. Pass --endpoint or set VPN_HOST.')
403
- process.exitCode = 1
404
- return
405
- }
406
-
407
- try {
408
- const result = await vpnClientAdd(name as string, {
409
- peersJsonPath: resolveDefaultPeersJsonPath(),
410
- clientsDir: resolveDefaultClientsDir(),
411
- serverPublicKey,
412
- endpoint,
413
- allowedIps: options.splitTunnel ?? undefined,
414
- dns: options.dns ?? undefined,
415
- })
416
-
417
- console.log(`Client "${name as string}" added.`)
418
- console.log(` Tunnel IP: ${result.tunnelIp}`)
419
- console.log(` Config: ${result.confPath}`)
420
- console.log('')
421
- console.log('Next step: run `infra vpn deploy` to apply the new peer to the VPN box.')
422
- } catch (error) {
423
- console.error(error instanceof Error ? error.message : String(error))
424
- process.exitCode = 1
425
- }
426
- })
427
-
428
- cli.command('vpn client list', 'List all VPN client peers from peers.json.').action(async () => {
429
- try {
430
- const peers = await vpnClientList(resolveDefaultPeersJsonPath())
431
-
432
- if (peers.length === 0) {
433
- console.log('No peers configured.')
434
- return
435
- }
436
-
437
- console.log('Name Tunnel IP Public Key')
438
- console.log('─────────────────────────────────────────────────────────')
439
- for (const peer of peers) {
440
- const name = peer.name.padEnd(16)
441
- const ip = peer.tunnelIp.padEnd(15)
442
- console.log(`${name} ${ip} ${peer.publicKey}`)
443
- }
444
- } catch (error) {
445
- console.error(error instanceof Error ? error.message : String(error))
446
- process.exitCode = 1
447
- }
448
- })
449
-
450
- cli
451
- .command(
452
- 'vpn client remove <name>',
453
- 'Remove a VPN client peer from peers.json and sync the roster to the VPN_PEERS GitHub secret (best-effort). Run `infra vpn deploy` to revoke access.',
454
- )
455
- .example('# Remove a client named "laptop"')
456
- .example('infra vpn client remove laptop')
457
- .action(async name => {
458
- try {
459
- await vpnClientRemove(name as string, resolveDefaultPeersJsonPath())
460
- console.log(`Peer "${name as string}" removed from peers.json.`)
461
- console.log('Next step: run `infra vpn deploy` to revoke access on the VPN box.')
462
- } catch (error) {
463
- console.error(error instanceof Error ? error.message : String(error))
464
- process.exitCode = 1
465
- }
466
- })
467
- }
@@ -1,208 +0,0 @@
1
- import {resolve} from 'node:path'
2
- import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
3
-
4
- import {getVpnDeployEnv, validateVpnRemotePreconditions} from './deploy'
5
-
6
- const cliDir = resolve(import.meta.dir, '../../..')
7
-
8
- const envKeys = ['HOME', 'PATH', 'SSH_AUTH_SOCK', 'VPN_HOST'] as const
9
-
10
- type ManagedEnvKey = (typeof envKeys)[number]
11
-
12
- let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
13
-
14
- function restoreManagedEnv(): void {
15
- for (const key of envKeys) {
16
- const value = originalEnv[key]
17
- if (value === undefined) {
18
- delete process.env[key]
19
- continue
20
- }
21
- process.env[key] = value
22
- }
23
- }
24
-
25
- function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
26
- restoreManagedEnv()
27
- for (const [key, value] of Object.entries(overrides)) {
28
- if (value === undefined) {
29
- delete process.env[key]
30
- continue
31
- }
32
- process.env[key as ManagedEnvKey] = value
33
- }
34
- }
35
-
36
- async function runDeployCommand(
37
- args: string[],
38
- envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
39
- ): Promise<{stdout: string; stderr: string; exitCode: number}> {
40
- const env: Record<string, string> = {}
41
-
42
- for (const [key, value] of Object.entries(process.env)) {
43
- if (value !== undefined) {
44
- env[key] = value
45
- }
46
- }
47
-
48
- for (const [key, value] of Object.entries(envOverrides)) {
49
- if (value === undefined) {
50
- delete env[key]
51
- } else {
52
- env[key] = value
53
- }
54
- }
55
-
56
- if (!Object.prototype.hasOwnProperty.call(envOverrides, 'HOME') && !env.HOME) {
57
- env.HOME = '/tmp/test-home'
58
- }
59
-
60
- if (!Object.prototype.hasOwnProperty.call(envOverrides, 'PATH') && !env.PATH) {
61
- env.PATH = '/usr/bin:/bin'
62
- }
63
-
64
- if (!Object.prototype.hasOwnProperty.call(envOverrides, 'SSH_AUTH_SOCK') && !env.SSH_AUTH_SOCK) {
65
- env.SSH_AUTH_SOCK = '/tmp/test-sock'
66
- }
67
-
68
- env.NO_COLOR = '1'
69
-
70
- const proc = Bun.spawn(['bun', 'src/cli.ts', 'vpn', 'deploy', ...args], {
71
- cwd: cliDir,
72
- env,
73
- stdout: 'pipe',
74
- stderr: 'pipe',
75
- })
76
-
77
- const [stdout, stderr, exitCode] = await Promise.all([
78
- new Response(proc.stdout).text(),
79
- new Response(proc.stderr).text(),
80
- proc.exited,
81
- ])
82
-
83
- return {stdout, stderr, exitCode}
84
- }
85
-
86
- describe('vpn deploy', () => {
87
- beforeEach(() => {
88
- originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
89
- })
90
-
91
- afterEach(() => {
92
- restoreManagedEnv()
93
- })
94
-
95
- describe('getVpnDeployEnv', () => {
96
- it('returns the expected deploy environment when required variables are present', () => {
97
- setManagedEnv({
98
- VPN_HOST: '1.2.3.4',
99
- HOME: '/tmp/test-home',
100
- PATH: '/usr/bin:/bin',
101
- SSH_AUTH_SOCK: '/tmp/test-sock',
102
- })
103
-
104
- const env = getVpnDeployEnv()
105
-
106
- expect(env.VPN_HOST).toBe('1.2.3.4')
107
- expect(env.HOME).toBe('/tmp/test-home')
108
- expect(env.PATH).toBe('/usr/bin:/bin')
109
- expect(env.SSH_AUTH_SOCK).toBe('/tmp/test-sock')
110
- })
111
-
112
- it('throws when SSH_AUTH_SOCK is missing', () => {
113
- setManagedEnv({
114
- VPN_HOST: '1.2.3.4',
115
- HOME: '/tmp/test-home',
116
- PATH: '/usr/bin:/bin',
117
- SSH_AUTH_SOCK: undefined,
118
- })
119
-
120
- expect(() => getVpnDeployEnv()).toThrow('SSH_AUTH_SOCK')
121
- })
122
-
123
- it('throws when PATH is missing', () => {
124
- setManagedEnv({
125
- VPN_HOST: '1.2.3.4',
126
- HOME: '/tmp/test-home',
127
- PATH: undefined,
128
- SSH_AUTH_SOCK: '/tmp/test-sock',
129
- })
130
-
131
- expect(() => getVpnDeployEnv()).toThrow('PATH')
132
- })
133
- })
134
-
135
- describe('validateVpnRemotePreconditions', () => {
136
- it('throws when gh is not available', () => {
137
- const spy = spyOn(Bun, 'which').mockReturnValue(null)
138
-
139
- try {
140
- expect(() => validateVpnRemotePreconditions()).toThrow('gh')
141
- } finally {
142
- spy.mockRestore()
143
- }
144
- })
145
-
146
- it('does not throw when gh is available', () => {
147
- const spy = spyOn(Bun, 'which').mockReturnValue('/usr/bin/gh')
148
-
149
- try {
150
- expect(() => validateVpnRemotePreconditions()).not.toThrow()
151
- } finally {
152
- spy.mockRestore()
153
- }
154
- })
155
- })
156
-
157
- describe('--remote (default) dry-run', () => {
158
- it('prints planned actions without invoking gh', async () => {
159
- const {stdout, exitCode} = await runDeployCommand(['--dry-run'], {
160
- HOME: '/tmp/test-home',
161
- PATH: process.env.PATH ?? '/usr/bin:/bin',
162
- SSH_AUTH_SOCK: '/tmp/test-sock',
163
- })
164
-
165
- expect(exitCode).toBe(0)
166
- expect(stdout).toContain('Dry run')
167
- expect(stdout).toContain('Deploy VPN')
168
- })
169
- })
170
-
171
- describe('--local --dry-run', () => {
172
- it('prints planned local actions without spawning bun run', async () => {
173
- const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
174
- HOME: '/tmp/test-home',
175
- PATH: process.env.PATH ?? '/usr/bin:/bin',
176
- SSH_AUTH_SOCK: '/tmp/test-sock',
177
- })
178
-
179
- expect(exitCode).toBe(0)
180
- expect(stdout).toContain('Dry run')
181
- expect(stdout).toContain('local')
182
- })
183
-
184
- it('succeeds even when SSH_AUTH_SOCK is unset', async () => {
185
- const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
186
- HOME: '/tmp/test-home',
187
- PATH: process.env.PATH ?? '/usr/bin:/bin',
188
- SSH_AUTH_SOCK: undefined,
189
- })
190
-
191
- expect(exitCode).toBe(0)
192
- expect(stdout).toContain('Dry run')
193
- })
194
- })
195
-
196
- describe('--force-server-key --dry-run', () => {
197
- it('includes --force-server-key in the planned command', async () => {
198
- const {stdout, exitCode} = await runDeployCommand(['--local', '--force-server-key', '--dry-run'], {
199
- HOME: '/tmp/test-home',
200
- PATH: process.env.PATH ?? '/usr/bin:/bin',
201
- SSH_AUTH_SOCK: '/tmp/test-sock',
202
- })
203
-
204
- expect(exitCode).toBe(0)
205
- expect(stdout).toContain('--force-server-key')
206
- })
207
- })
208
- })