@marcusrbrown/infra 0.9.18 → 0.10.1

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