@marcusrbrown/infra 0.9.18 → 0.10.0

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.
@@ -0,0 +1,313 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {mkdir, writeFile} from 'node:fs/promises'
4
+ import {resolve} from 'node:path'
5
+
6
+ import {
7
+ addPeer,
8
+ readPeers,
9
+ removePeer,
10
+ renderClientConfig,
11
+ writePeers,
12
+ type Peer,
13
+ } from '@marcusrbrown/infra-shared/vpn/peers'
14
+ import {z} from 'zod'
15
+
16
+ declare const process: {
17
+ env: Record<string, string | undefined>
18
+ exitCode?: number
19
+ }
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export interface Keypair {
24
+ privateKey: string
25
+ publicKey: string
26
+ }
27
+
28
+ export type KeypairGenFn = () => Promise<Keypair>
29
+
30
+ export interface VpnClientAddOpts {
31
+ peersJsonPath: string
32
+ clientsDir: string
33
+ serverPublicKey: string
34
+ endpoint: string
35
+ keypairGen?: KeypairGenFn
36
+ allowedIps?: string
37
+ dns?: string
38
+ }
39
+
40
+ export interface VpnClientAddResult {
41
+ tunnelIp: string
42
+ confPath: string
43
+ }
44
+
45
+ // ─── WireGuard keypair generation ─────────────────────────────────────────────
46
+
47
+ /**
48
+ * Generate a WireGuard keypair using the local `wg` binary.
49
+ *
50
+ * Assumption: `wg` is installed locally (wireguard-tools package).
51
+ * The private key is generated locally and written ONLY to the gitignored
52
+ * clients/ directory — it never leaves the local machine.
53
+ */
54
+ export async function generateKeypair(): Promise<Keypair> {
55
+ // Step 1: generate private key
56
+ const genkeyProc = Bun.spawn(['wg', 'genkey'], {
57
+ stdout: 'pipe',
58
+ stderr: 'pipe',
59
+ })
60
+
61
+ const [privateKeyRaw, genkeyStderr, genkeyExit] = await Promise.all([
62
+ new Response(genkeyProc.stdout).text(),
63
+ new Response(genkeyProc.stderr).text(),
64
+ genkeyProc.exited,
65
+ ])
66
+
67
+ if (genkeyExit !== 0) {
68
+ throw new Error(`wg genkey failed (exit ${genkeyExit}): ${genkeyStderr.trim()}`)
69
+ }
70
+
71
+ const privateKey = privateKeyRaw.trim()
72
+
73
+ // Step 2: derive public key from private key
74
+ const encoder = new TextEncoder()
75
+ const pubkeyProc = Bun.spawn(['wg', 'pubkey'], {
76
+ stdin: new ReadableStream({
77
+ start(controller) {
78
+ controller.enqueue(encoder.encode(`${privateKey}\n`))
79
+ controller.close()
80
+ },
81
+ }),
82
+ stdout: 'pipe',
83
+ stderr: 'pipe',
84
+ })
85
+
86
+ const [publicKeyRaw, pubkeyStderr, pubkeyExit] = await Promise.all([
87
+ new Response(pubkeyProc.stdout).text(),
88
+ new Response(pubkeyProc.stderr).text(),
89
+ pubkeyProc.exited,
90
+ ])
91
+
92
+ if (pubkeyExit !== 0) {
93
+ throw new Error(`wg pubkey failed (exit ${pubkeyExit}): ${pubkeyStderr.trim()}`)
94
+ }
95
+
96
+ return {
97
+ privateKey,
98
+ publicKey: publicKeyRaw.trim(),
99
+ }
100
+ }
101
+
102
+ // ─── Write-guard ──────────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Validate that the resolved output path is under the expected clients directory.
106
+ *
107
+ * This is a runtime guard (not gitignore-only) that refuses to write client
108
+ * .conf files (which contain private keys) outside the gitignored clients/ dir.
109
+ *
110
+ * @throws {Error} if the resolved path escapes the clients directory.
111
+ */
112
+ function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
113
+ const resolvedClientsDir = resolve(clientsDir)
114
+ const resolvedConfPath = resolve(confPath)
115
+
116
+ if (!resolvedConfPath.startsWith(`${resolvedClientsDir}/`) && resolvedConfPath !== resolvedClientsDir) {
117
+ throw new Error(
118
+ `Security: refusing to write client .conf outside the clients directory. ` +
119
+ `Resolved path "${resolvedConfPath}" is not under "${resolvedClientsDir}". ` +
120
+ `Client private keys must only be written to the gitignored clients/ directory.`,
121
+ )
122
+ }
123
+ }
124
+
125
+ // ─── Core operations ──────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Add a new VPN client peer.
129
+ *
130
+ * 1. Generates a WireGuard keypair locally (via `wg genkey` / `wg pubkey`).
131
+ * 2. Assigns the next available tunnel IP via addPeer.
132
+ * 3. Appends the PUBLIC key + tunnel IP to peers.json.
133
+ * 4. Writes the client .conf (with PRIVATE key) to clients/<name>.conf.
134
+ *
135
+ * Security invariants:
136
+ * - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
137
+ * - peers.json contains ONLY the public key — never the private key.
138
+ * - The output path is validated before writing (runtime guard, not gitignore-only).
139
+ */
140
+ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
141
+ const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
142
+ const keypairGen = opts.keypairGen ?? generateKeypair
143
+
144
+ // Validate the output path before doing any work
145
+ const confPath = resolve(clientsDir, `${name}.conf`)
146
+ assertPathUnderClientsDir(confPath, clientsDir)
147
+
148
+ // Generate keypair
149
+ const keypair = await keypairGen()
150
+
151
+ // Read current peers
152
+ const peersFile = await readPeers(peersJsonPath)
153
+
154
+ // Add peer (throws if duplicate name)
155
+ const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
156
+ name,
157
+ publicKey: keypair.publicKey,
158
+ })
159
+
160
+ // Write updated peers.json (PUBLIC key only — never private key)
161
+ await writePeers(peersJsonPath, {peers: updatedPeers})
162
+
163
+ // Render client .conf (contains PRIVATE key)
164
+ const confContent = renderClientConfig({
165
+ clientPrivateKey: keypair.privateKey,
166
+ serverPublicKey,
167
+ endpoint,
168
+ tunnelIp,
169
+ allowedIps,
170
+ dns,
171
+ })
172
+
173
+ // Ensure clients directory exists
174
+ await mkdir(clientsDir, {recursive: true})
175
+
176
+ // Write client .conf to the gitignored clients/ directory
177
+ await writeFile(confPath, confContent, {mode: 0o600})
178
+
179
+ return {tunnelIp, confPath}
180
+ }
181
+
182
+ /**
183
+ * List all VPN client peers from peers.json.
184
+ */
185
+ export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
186
+ const peersFile = await readPeers(peersJsonPath)
187
+ return peersFile.peers
188
+ }
189
+
190
+ /**
191
+ * Remove a VPN client peer from peers.json.
192
+ *
193
+ * Note: The client .conf file in clients/ is NOT deleted — the operator
194
+ * should delete it manually. The peer is removed from peers.json so the
195
+ * next deploy will revoke access.
196
+ */
197
+ export async function vpnClientRemove(name: string, peersJsonPath: string): Promise<void> {
198
+ const peersFile = await readPeers(peersJsonPath)
199
+ const {peers: updatedPeers} = removePeer(peersFile.peers, name)
200
+ await writePeers(peersJsonPath, {peers: updatedPeers})
201
+ }
202
+
203
+ // ─── Default paths ────────────────────────────────────────────────────────────
204
+
205
+ function resolveDefaultPeersJsonPath(): string {
206
+ // packages/cli/src/commands/vpn/ → repo root → apps/vpn/config/peers.json
207
+ return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'config', 'peers.json')
208
+ }
209
+
210
+ function resolveDefaultClientsDir(): string {
211
+ // packages/cli/src/commands/vpn/ → repo root → apps/vpn/clients/
212
+ return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'clients')
213
+ }
214
+
215
+ // ─── Command registration ─────────────────────────────────────────────────────
216
+
217
+ export function registerVpnClient(cli: ReturnType<typeof goke>): void {
218
+ cli
219
+ .command(
220
+ 'vpn client add <name>',
221
+ 'Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, and writes the client .conf to apps/vpn/clients/.',
222
+ )
223
+ .option(
224
+ '--split-tunnel [cidrs]',
225
+ z.string().describe('Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).'),
226
+ )
227
+ .option('--dns [dns]', z.string().describe('DNS server for the client config.'))
228
+ .option('--server-pubkey [key]', z.string().describe('Server public key. Falls back to VPN_SERVER_PUBKEY env var.'))
229
+ .option(
230
+ '--endpoint [host]',
231
+ z.string().describe('VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.'),
232
+ )
233
+ .example('# Add a new client named "laptop"')
234
+ .example('infra vpn client add laptop')
235
+ .example('# Add a split-tunnel client')
236
+ .example('infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16')
237
+ .action(async (name, options) => {
238
+ const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY
239
+ const endpoint = options.endpoint ?? process.env.VPN_HOST
240
+
241
+ if (!serverPublicKey) {
242
+ console.error('Server public key is required. Pass --server-pubkey or set VPN_SERVER_PUBKEY.')
243
+ process.exitCode = 1
244
+ return
245
+ }
246
+
247
+ if (!endpoint) {
248
+ console.error('VPN endpoint is required. Pass --endpoint or set VPN_HOST.')
249
+ process.exitCode = 1
250
+ return
251
+ }
252
+
253
+ try {
254
+ const result = await vpnClientAdd(name as string, {
255
+ peersJsonPath: resolveDefaultPeersJsonPath(),
256
+ clientsDir: resolveDefaultClientsDir(),
257
+ serverPublicKey,
258
+ endpoint,
259
+ allowedIps: options.splitTunnel ?? undefined,
260
+ dns: options.dns ?? undefined,
261
+ })
262
+
263
+ console.log(`Client "${name as string}" added.`)
264
+ console.log(` Tunnel IP: ${result.tunnelIp}`)
265
+ console.log(` Config: ${result.confPath}`)
266
+ console.log('')
267
+ console.log('Next step: run `infra vpn deploy` to apply the new peer to the VPN box.')
268
+ } catch (error) {
269
+ console.error(error instanceof Error ? error.message : String(error))
270
+ process.exitCode = 1
271
+ }
272
+ })
273
+
274
+ cli.command('vpn client list', 'List all VPN client peers from peers.json.').action(async () => {
275
+ try {
276
+ const peers = await vpnClientList(resolveDefaultPeersJsonPath())
277
+
278
+ if (peers.length === 0) {
279
+ console.log('No peers configured.')
280
+ return
281
+ }
282
+
283
+ console.log('Name Tunnel IP Public Key')
284
+ console.log('─────────────────────────────────────────────────────────')
285
+ for (const peer of peers) {
286
+ const name = peer.name.padEnd(16)
287
+ const ip = peer.tunnelIp.padEnd(15)
288
+ console.log(`${name} ${ip} ${peer.publicKey}`)
289
+ }
290
+ } catch (error) {
291
+ console.error(error instanceof Error ? error.message : String(error))
292
+ process.exitCode = 1
293
+ }
294
+ })
295
+
296
+ cli
297
+ .command(
298
+ 'vpn client remove <name>',
299
+ 'Remove a VPN client peer from peers.json. Run `infra vpn deploy` to revoke access.',
300
+ )
301
+ .example('# Remove a client named "laptop"')
302
+ .example('infra vpn client remove laptop')
303
+ .action(async name => {
304
+ try {
305
+ await vpnClientRemove(name as string, resolveDefaultPeersJsonPath())
306
+ console.log(`Peer "${name as string}" removed from peers.json.`)
307
+ console.log('Next step: run `infra vpn deploy` to revoke access on the VPN box.')
308
+ } catch (error) {
309
+ console.error(error instanceof Error ? error.message : String(error))
310
+ process.exitCode = 1
311
+ }
312
+ })
313
+ }
@@ -0,0 +1,208 @@
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
+ })
@@ -0,0 +1,143 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {z} from 'zod'
4
+
5
+ declare const process: {
6
+ env: Record<string, string | undefined>
7
+ exitCode?: number
8
+ }
9
+
10
+ const REPO = 'marcusrbrown/infra'
11
+ const WORKFLOW_NAME = 'Deploy VPN'
12
+ const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml'
13
+
14
+ type CliInstance = ReturnType<typeof goke>
15
+
16
+ // ─── Env helpers ─────────────────────────────────────────────────────────────
17
+
18
+ export function getVpnDeployEnv(): Record<string, string> {
19
+ const path = process.env.PATH
20
+ const home = process.env.HOME
21
+ const sshAuthSock = process.env.SSH_AUTH_SOCK
22
+
23
+ if (!path) {
24
+ throw new Error('PATH is required for local deploy')
25
+ }
26
+
27
+ if (!home) {
28
+ throw new Error('HOME is required for local deploy')
29
+ }
30
+
31
+ if (!sshAuthSock) {
32
+ throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
33
+ }
34
+
35
+ return {
36
+ PATH: path,
37
+ HOME: home,
38
+ SSH_AUTH_SOCK: sshAuthSock,
39
+ VPN_HOST: process.env.VPN_HOST ?? '',
40
+ }
41
+ }
42
+
43
+ export function validateVpnRemotePreconditions(): void {
44
+ if (!Bun.which('gh')) {
45
+ throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
46
+ }
47
+ }
48
+
49
+ // ─── Command registration ─────────────────────────────────────────────────────
50
+
51
+ export function registerVpnDeploy(cli: CliInstance): void {
52
+ cli
53
+ .command(
54
+ 'vpn deploy',
55
+ 'Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.',
56
+ )
57
+ .option(
58
+ '--local',
59
+ z
60
+ .boolean()
61
+ .default(false)
62
+ .describe('Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.'),
63
+ )
64
+ .option(
65
+ '--dry-run',
66
+ z
67
+ .boolean()
68
+ .default(false)
69
+ .describe(
70
+ 'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
71
+ ),
72
+ )
73
+ .option(
74
+ '--force-server-key',
75
+ z
76
+ .boolean()
77
+ .default(false)
78
+ .describe(
79
+ 'Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.',
80
+ ),
81
+ )
82
+ .example('# Trigger remote GitHub Actions deploy (default mode)')
83
+ .example('infra vpn deploy')
84
+ .example('# Validate local deploy preconditions and planned command')
85
+ .example('infra vpn deploy --local --dry-run')
86
+ .example('# Run local deploy with explicit SSH agent context')
87
+ .example('infra vpn deploy --local')
88
+ .action(async options => {
89
+ if (options.local) {
90
+ const command = [
91
+ 'bun',
92
+ 'run',
93
+ '--cwd',
94
+ 'apps/vpn',
95
+ 'deploy',
96
+ ...(options.forceServerKey ? ['--force-server-key'] : []),
97
+ ]
98
+
99
+ if (options.dryRun) {
100
+ console.log('Dry run: local vpn deploy')
101
+ console.log(`- command: ${command.join(' ')}`)
102
+ return
103
+ }
104
+
105
+ const env = getVpnDeployEnv()
106
+
107
+ const child = Bun.spawn(command, {
108
+ env,
109
+ stdout: 'inherit',
110
+ stderr: 'inherit',
111
+ })
112
+
113
+ const exitCode = await child.exited
114
+ if (exitCode !== 0) {
115
+ throw new Error(`Local deploy failed with exit code ${exitCode}`)
116
+ }
117
+
118
+ return
119
+ }
120
+
121
+ validateVpnRemotePreconditions()
122
+
123
+ if (options.dryRun) {
124
+ console.log('Dry run: remote vpn deploy')
125
+ console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
126
+ console.log(`- workflow URL: ${WORKFLOW_URL}`)
127
+ return
128
+ }
129
+
130
+ const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
131
+ stdout: 'inherit',
132
+ stderr: 'inherit',
133
+ })
134
+
135
+ const exitCode = await child.exited
136
+ if (exitCode !== 0) {
137
+ throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
138
+ }
139
+
140
+ console.log(`Workflow triggered: ${WORKFLOW_URL}`)
141
+ console.log('Approve the vpn environment deployment in GitHub Actions to continue.')
142
+ })
143
+ }