@rpcbase/cli 0.181.0 → 0.183.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/cli",
3
- "version": "0.181.0",
3
+ "version": "0.183.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -53,11 +53,10 @@ const hashTree = (rootDir) => {
53
53
  }
54
54
  }
55
55
 
56
- const buildRsyncCmd = ({ includeArgs, excludeArgs, keyPath, source, dest, remote }) => {
56
+ const buildRsyncCmd = ({ filterArgs, keyPath, source, dest, remote }) => {
57
57
  const parts = [
58
58
  "rsync -az --delete --filter=':- .gitignore'",
59
- includeArgs.join(" "),
60
- excludeArgs,
59
+ filterArgs,
61
60
  remote ? `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"` : null,
62
61
  source,
63
62
  dest,
@@ -75,15 +74,27 @@ const runRsync = (cmd) => {
75
74
  }
76
75
  }
77
76
 
78
- // Pull remote compose files into a temp dir, mirror local ones into another, then hash trees to detect diffs.
77
+ // Pull tracked deployment files into temp dirs, then hash the trees to detect diffs.
79
78
  // Callers await this check; errors bubble up except for benign rsync statuses handled in runRsync.
80
- export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log, extraExcludes = [] }) => {
81
- const includeArgs = ["--include='*/'", "--include='*compose*.yml'", "--exclude='*'"]
82
- const excludeArgs = baseExcludeList
79
+ export const detectComposeChanges = async ({
80
+ keyPath,
81
+ user,
82
+ host,
83
+ deployDir,
84
+ localSource = "./",
85
+ log,
86
+ extraExcludes = [],
87
+ includePatterns = ["*compose*.yml"],
88
+ }) => {
89
+ const excludePatterns = baseExcludeList
83
90
  // dev compose files are not relevant for prod deploy checks
84
91
  .concat(["compose.dev.yml"])
85
92
  .concat(extraExcludes)
86
- .map((p) => `--exclude='${p}'`)
93
+
94
+ const filterArgs = excludePatterns.map((pattern) => `--exclude='${pattern}'`)
95
+ .concat(["--include='*/'"])
96
+ .concat(includePatterns.map((pattern) => `--include='${pattern}'`))
97
+ .concat(["--exclude='*'"])
87
98
  .join(" ")
88
99
 
89
100
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rb-compose-"))
@@ -102,8 +113,7 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
102
113
 
103
114
  try {
104
115
  const remoteSyncCmd = buildRsyncCmd({
105
- includeArgs,
106
- excludeArgs,
116
+ filterArgs,
107
117
  keyPath,
108
118
  source: `${user}@${host}:~/apps/${deployDir}/`,
109
119
  dest: `${remoteDir}/`,
@@ -113,10 +123,9 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
113
123
  runRsync(remoteSyncCmd)
114
124
 
115
125
  const localSyncCmd = buildRsyncCmd({
116
- includeArgs,
117
- excludeArgs,
126
+ filterArgs,
118
127
  keyPath,
119
- source: "./",
128
+ source: `${localSource.replace(/\/$/, "")}/`,
120
129
  dest: `${localDir}/`,
121
130
  remote: false,
122
131
  })
@@ -150,7 +159,7 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
150
159
  if (missingLocal.length) summary.push(`Only on remote: ${missingLocal.join(", ")}`)
151
160
  if (missingRemote.length) summary.push(`Only on local: ${missingRemote.join(", ")}`)
152
161
  if (changed.length) summary.push(`Content differs: ${changed.join(", ")}`)
153
- if (!hasChanges) summary.push("Compose files are identical.")
162
+ if (!hasChanges) summary.push("Tracked deployment files are identical.")
154
163
 
155
164
  return { hasChanges, output: summary.join("\n") }
156
165
  } finally {
@@ -7,6 +7,180 @@ import validator from "validator"
7
7
 
8
8
  import { purgeCloudflareCaches } from "./purge-cloudflare.js"
9
9
  import { baseExcludeList, detectComposeChanges } from "./detect-compose-changes.js"
10
+ import { createPrivateApiClient } from "./private-api.js"
11
+
12
+
13
+ const deploymentInstances = ["a", "b"]
14
+
15
+ const normalizeInstance = (instance) => {
16
+ if (!instance) return null
17
+ const normalized = String(instance).trim().toLowerCase()
18
+ return deploymentInstances.includes(normalized) ? normalized : null
19
+ }
20
+
21
+ const parseInstance = (instance) => {
22
+ const normalized = normalizeInstance(instance)
23
+ if (!normalized) throw new Error(`Invalid deployment instance: ${instance}`)
24
+ return normalized
25
+ }
26
+
27
+ const getOtherInstance = (instance) => instance === "a" ? "b" : "a"
28
+
29
+ const getInstanceDeployDir = (baseDeployDir, instance) => `${baseDeployDir}-${instance}`
30
+
31
+ const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms))
32
+
33
+ const sharedInfrastructureIncludePatterns = [
34
+ "infrastructure/compose.yml",
35
+ "infrastructure/compose.instances.yml",
36
+ "infrastructure/package.json",
37
+ "infrastructure/package-lock.json",
38
+ "infrastructure/ensure-*",
39
+ "infrastructure/mongot/***",
40
+ ".env",
41
+ ".env.*",
42
+ ]
43
+
44
+ const sharedInfrastructureExcludePatterns = [
45
+ "infrastructure/data/***",
46
+ ]
47
+
48
+ const toArray = (value) => {
49
+ if (value == null) return []
50
+ return Array.isArray(value) ? value : [value]
51
+ }
52
+
53
+ const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`
54
+
55
+ const withTrailingSlash = (value) => `${String(value).replace(/\/$/, "")}/`
56
+
57
+ const resolveFrom = (baseDir, value) => (
58
+ path.isAbsolute(value) ? value : path.resolve(baseDir, value)
59
+ )
60
+
61
+ const toRelativePattern = (baseDir, value) => {
62
+ const resolved = resolveFrom(baseDir, value)
63
+ const relative = path.relative(baseDir, resolved)
64
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null
65
+ return relative.split(path.sep).join("/")
66
+ }
67
+
68
+ const buildRsyncFilterArgs = ({ includes = [], excludes = [] }) => {
69
+ const excludeArgs = excludes.map((pattern) => `--exclude=${shellQuote(pattern)}`)
70
+
71
+ if (includes.length === 0) return excludeArgs.join(" ")
72
+
73
+ return excludeArgs
74
+ .concat(["--include='*/'"])
75
+ .concat(includes.map((pattern) => `--include=${shellQuote(pattern)}`))
76
+ .concat(["--exclude='*'"])
77
+ .join(" ")
78
+ }
79
+
80
+ const prepareDeploySource = ({
81
+ sourceDir,
82
+ infrastructureDir,
83
+ sharedEnvFiles,
84
+ includePatterns,
85
+ excludePatterns,
86
+ log,
87
+ }) => {
88
+ if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
89
+ throw new Error(`Deploy source directory does not exist: ${sourceDir}`)
90
+ }
91
+
92
+ const defaultInfrastructureDir = path.resolve(sourceDir, "infrastructure")
93
+ const needsStaging = (
94
+ sourceDir !== process.cwd() ||
95
+ infrastructureDir !== defaultInfrastructureDir ||
96
+ sharedEnvFiles.length > 0 ||
97
+ includePatterns.length > 0
98
+ )
99
+
100
+ if (!needsStaging) {
101
+ return {
102
+ deployRoot: sourceDir,
103
+ sharedEnvFileNames: [],
104
+ cleanup: () => {},
105
+ }
106
+ }
107
+
108
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rb-deploy-"))
109
+ const deployRoot = path.join(tempRoot, "root")
110
+ fs.mkdirSync(deployRoot)
111
+ const cleanup = () => {
112
+ try {
113
+ fs.rmSync(tempRoot, { recursive: true, force: true })
114
+ } catch (err) {
115
+ console.warn(`Cleanup failed for ${tempRoot}: ${err}`)
116
+ }
117
+ }
118
+
119
+ try {
120
+ const sharedEnvSourceExcludes = sharedEnvFiles
121
+ .map((filePath) => toRelativePattern(sourceDir, filePath))
122
+ .filter(Boolean)
123
+
124
+ const sourceFilterArgs = buildRsyncFilterArgs({
125
+ includes: includePatterns,
126
+ excludes: baseExcludeList.concat(sharedEnvSourceExcludes).concat(excludePatterns),
127
+ })
128
+
129
+ const sourceRsyncCmd = [
130
+ "rsync -a",
131
+ includePatterns.length > 0 ? "--prune-empty-dirs" : null,
132
+ "--delete",
133
+ sourceFilterArgs,
134
+ shellQuote(withTrailingSlash(sourceDir)),
135
+ shellQuote(withTrailingSlash(deployRoot)),
136
+ ].filter(Boolean).join(" ")
137
+
138
+ log(`Preparing deploy source: ${sourceRsyncCmd}`)
139
+ execSync(sourceRsyncCmd, { stdio: "ignore" })
140
+
141
+ if (fs.existsSync(infrastructureDir)) {
142
+ const deployInfrastructureDir = path.join(deployRoot, "infrastructure")
143
+ fs.mkdirSync(deployInfrastructureDir, { recursive: true })
144
+
145
+ const infrastructureRsyncCmd = [
146
+ "rsync -a",
147
+ "--delete",
148
+ buildRsyncFilterArgs({ excludes: baseExcludeList.concat(["data/"]).concat(excludePatterns) }),
149
+ shellQuote(withTrailingSlash(infrastructureDir)),
150
+ shellQuote(withTrailingSlash(deployInfrastructureDir)),
151
+ ].filter(Boolean).join(" ")
152
+
153
+ log(`Preparing infrastructure source: ${infrastructureRsyncCmd}`)
154
+ execSync(infrastructureRsyncCmd, { stdio: "ignore" })
155
+ } else if (infrastructureDir !== defaultInfrastructureDir) {
156
+ throw new Error(`Infrastructure directory does not exist: ${infrastructureDir}`)
157
+ }
158
+
159
+ const sharedEnvFileNames = []
160
+ for (const sharedEnvFile of sharedEnvFiles) {
161
+ const sourcePath = resolveFrom(sourceDir, sharedEnvFile)
162
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
163
+ throw new Error(`Shared env file does not exist: ${sourcePath}`)
164
+ }
165
+
166
+ const fileName = path.basename(sourcePath)
167
+ if (sharedEnvFileNames.includes(fileName)) {
168
+ throw new Error(`Multiple shared env files resolve to ${fileName}`)
169
+ }
170
+ sharedEnvFileNames.push(fileName)
171
+ fs.copyFileSync(sourcePath, path.join(deployRoot, fileName))
172
+ }
173
+
174
+ return {
175
+ deployRoot,
176
+ sharedEnvFileNames,
177
+ cleanup,
178
+ }
179
+ } catch (err) {
180
+ cleanup()
181
+ throw err
182
+ }
183
+ }
10
184
 
11
185
 
12
186
  export const deploy = async (argv) => {
@@ -50,12 +224,17 @@ export const deploy = async (argv) => {
50
224
 
51
225
  const host = RB_SSH_HOST
52
226
  const user = RB_SSH_USER
53
- const deployDir = RB_DEPLOY_DIR
227
+ const baseDeployDir = RB_DEPLOY_DIR
228
+ const sourceDir = resolveFrom(process.cwd(), argv.sourceDir || ".")
229
+ const infrastructureDir = resolveFrom(sourceDir, argv.infrastructureDir || "infrastructure")
230
+ const sharedEnvFiles = toArray(argv.sharedEnvFile)
231
+ const deployIncludePatterns = toArray(argv.include)
232
+ const deployExcludePatterns = toArray(argv.exclude).concat(toArray(argv.ignore))
54
233
  let keyPath = RB_SSH_KEY
55
234
 
56
235
  let tempKeyPath = null
236
+ let preparedDeploy = null
57
237
 
58
- // Create an SSH function that can be reused
59
238
  const ssh = async (command, stdioInherit = false) => {
60
239
  const sshCommand = `ssh -i "${keyPath}" -o StrictHostKeyChecking=no ${user}@${host} "${command}"`
61
240
  log(`${command}`)
@@ -66,6 +245,47 @@ export const deploy = async (argv) => {
66
245
  return res
67
246
  }
68
247
 
248
+ const syncSharedEnvFiles = (deployRoot, sharedEnvFileNames) => {
249
+ const envFiles = sharedEnvFileNames.length > 0
250
+ ? sharedEnvFileNames
251
+ : fs.readdirSync(deployRoot)
252
+ .filter((name) => name === ".env" || name.startsWith(".env."))
253
+
254
+ if (envFiles.length === 0) return
255
+
256
+ const rsyncCmd = [
257
+ "rsync -avz",
258
+ `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
259
+ ...envFiles.map((name) => shellQuote(path.join(deployRoot, name))),
260
+ `${user}@${host}:~/apps/${baseDeployDir}/`,
261
+ ].join(" ")
262
+
263
+ log(`Running: ${rsyncCmd}`)
264
+ execSync(rsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
265
+ }
266
+
267
+ const runInstanceAction = async (action, instance) => {
268
+ await ssh(`cd ~/apps/${baseDeployDir}/infrastructure && node ctrl.js ${action}-instance ${instance} prod`, true)
269
+ }
270
+
271
+ const waitForInstance = async (instance, target) => {
272
+ const port = Number(target?.port)
273
+ if (!Number.isFinite(port) || port <= 0) throw new Error(`Cannot healthcheck instance ${instance}: invalid port`)
274
+
275
+ const url = `http://127.0.0.1:${port}/`
276
+ for (let attempt = 1; attempt <= 60; attempt += 1) {
277
+ try {
278
+ await ssh(`node -e "fetch('${url}').then((res)=>process.exit(res.status < 500 ? 0 : 1)).catch(()=>process.exit(1))"`)
279
+ console.log(`Instance ${instance} is healthy at ${url}`)
280
+ return
281
+ } catch {
282
+ await sleep(1000)
283
+ }
284
+ }
285
+
286
+ throw new Error(`Instance ${instance} did not become healthy at ${url}`)
287
+ }
288
+
69
289
  try {
70
290
  if (keyPath.startsWith("~")) keyPath = keyPath.replace("~", os.homedir())
71
291
 
@@ -86,57 +306,169 @@ export const deploy = async (argv) => {
86
306
  log(`Connecting to ${host} as ${user} using key at ${keyPath}`)
87
307
  const res = await ssh("echo connection-ok")
88
308
  console.log(`${user}@${host}`, res)
89
- await ssh(`mkdir -p ~/apps/${deployDir}`)
90
-
91
- const { hasChanges: infrastructureChanged, output: infrastructureDiff } = await detectComposeChanges({
92
- keyPath,
93
- user,
94
- host,
95
- deployDir,
96
- log,
97
- extraExcludes: argv.ignore || [],
98
- })
309
+ const useBlueGreen = argv.blueGreen || argv.rollback
310
+ const siteId = baseDeployDir
99
311
 
100
- if (infrastructureChanged) {
101
- console.log("Infrastructure compose files differ between local and remote (info only; deploy proceeds).")
102
- if (argv.verbose) console.log(infrastructureDiff)
103
- }
312
+ const privateApi = useBlueGreen ? createPrivateApiClient({env: argv.env}) : null
104
313
 
105
- const excludeList = baseExcludeList.concat(argv.ignore)
314
+ if (argv.rollback) {
315
+ const statusRes = await privateApi.get(`/_private/v1/sites/${siteId}/deployment`)
316
+ const deployment = statusRes.data?.deployment
317
+ const activeInstance = parseInstance(deployment?.activeInstance)
318
+ const previousInstance = parseInstance(deployment?.previousInstance)
106
319
 
107
- const excludeArgs = excludeList.map((p) => `--exclude='${p}'`).join(" ")
320
+ const previousTarget = deployment?.instances?.[previousInstance]
321
+ const activeTarget = deployment?.instances?.[activeInstance]
322
+ if (!previousTarget) throw new Error(`Cannot rollback ${siteId}: instance ${previousInstance} is not configured`)
323
+ if (!activeTarget) throw new Error(`Cannot rollback ${siteId}: active instance ${activeInstance} is not configured`)
108
324
 
109
- const rsyncCmd = [
110
- "rsync -avz -C",
111
- excludeArgs,
112
- "--delete",
113
- `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
114
- ".",
115
- `${user}@${host}:~/apps/${deployDir}/`,
116
- ].join(" ")
325
+ console.log(`Starting rollback instance ${previousInstance}`)
326
+ await runInstanceAction("up", previousInstance)
327
+ await waitForInstance(previousInstance, previousTarget)
328
+ console.log(`Rolling back ${siteId}: ${activeInstance} -> ${previousInstance}`)
329
+ await privateApi.post(`/_private/v1/sites/${siteId}/lock`)
330
+ try {
331
+ await privateApi.post(`/_private/v1/sites/${siteId}/deployment/rollback`)
332
+ } finally {
333
+ await privateApi.post(`/_private/v1/sites/${siteId}/unlock`)
334
+ }
335
+ await runInstanceAction("down", activeInstance)
336
+ console.log(`Rollback complete: ${siteId} is on instance ${previousInstance}`)
337
+ } else {
338
+ preparedDeploy = prepareDeploySource({
339
+ sourceDir,
340
+ infrastructureDir,
341
+ sharedEnvFiles,
342
+ includePatterns: deployIncludePatterns,
343
+ excludePatterns: deployExcludePatterns,
344
+ log,
345
+ })
117
346
 
118
- log(`Running: ${rsyncCmd}`)
119
- execSync(rsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
347
+ const deployRoot = preparedDeploy.deployRoot
348
+ const deployInfrastructureDir = path.join(deployRoot, "infrastructure")
349
+ let deployDir = baseDeployDir
350
+ let targetInstance = null
351
+ let activeInstance = null
352
+ let target = null
120
353
 
121
- await ssh(`cd ~/apps/${deployDir}/infrastructure && [ -f package.json ] && npm ci || true`)
354
+ if (useBlueGreen) {
355
+ const statusRes = await privateApi.get(`/_private/v1/sites/${siteId}/deployment`)
356
+ const deployment = statusRes.data?.deployment
357
+ activeInstance = parseInstance(deployment?.activeInstance)
358
+ targetInstance = getOtherInstance(activeInstance)
359
+ target = deployment?.instances?.[targetInstance]
360
+ if (!target) throw new Error(`Cannot deploy ${siteId}: instance ${targetInstance} is not configured`)
122
361
 
123
- log("Restarting application...")
124
- await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js down prod`, true)
125
- log("Application stopped. Starting it up again...")
126
- await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js up prod`, true)
362
+ deployDir = getInstanceDeployDir(baseDeployDir, targetInstance)
363
+ console.log(`Deploying ${siteId} to inactive instance ${targetInstance} at ${deployDir}`)
364
+ }
127
365
 
128
- if (argv.pruneDocker !== false) {
129
- log("Pruning dangling docker images on the remote host...")
130
- try {
131
- await ssh("docker image prune -f")
132
- } catch (pruneError) {
133
- console.warn(
134
- `Warning: docker image prune failed (deploy continues): ${pruneError instanceof Error ? pruneError.message : pruneError}`,
135
- )
366
+ await ssh(`mkdir -p ~/apps/${deployDir}`)
367
+ if (useBlueGreen) {
368
+ await ssh(`mkdir -p ~/apps/${baseDeployDir}`)
136
369
  }
137
- }
138
370
 
139
- log("Deployment complete ✅")
371
+ const hasInfrastructure = fs.existsSync(deployInfrastructureDir)
372
+ if (!useBlueGreen || hasInfrastructure) {
373
+ const infrastructureDeployDir = useBlueGreen ? baseDeployDir : deployDir
374
+ const { hasChanges: infrastructureChanged, output: infrastructureDiff } = await detectComposeChanges({
375
+ keyPath,
376
+ user,
377
+ host,
378
+ deployDir: infrastructureDeployDir,
379
+ localSource: deployRoot,
380
+ log,
381
+ extraExcludes: useBlueGreen
382
+ ? sharedInfrastructureExcludePatterns.concat(deployExcludePatterns)
383
+ : deployExcludePatterns,
384
+ includePatterns: useBlueGreen
385
+ ? sharedInfrastructureIncludePatterns.concat(preparedDeploy.sharedEnvFileNames)
386
+ : undefined,
387
+ })
388
+
389
+ if (infrastructureChanged) {
390
+ if (useBlueGreen) {
391
+ if (argv.verbose) console.log(infrastructureDiff)
392
+ throw new Error("Shared infrastructure files changed. Deploy or restart the shared infrastructure explicitly before running a blue/green app deploy.")
393
+ }
394
+ console.log("Infrastructure compose files differ between local and remote (info only; deploy proceeds).")
395
+ if (argv.verbose) console.log(infrastructureDiff)
396
+ }
397
+ }
398
+
399
+ if (useBlueGreen && hasInfrastructure) {
400
+ await ssh(`mkdir -p ~/apps/${baseDeployDir}/infrastructure`)
401
+ syncSharedEnvFiles(deployRoot, preparedDeploy.sharedEnvFileNames)
402
+
403
+ const infrastructureExcludeArgs = baseExcludeList
404
+ .concat(["data/"])
405
+ .concat(deployExcludePatterns)
406
+
407
+ const infrastructureRsyncCmd = [
408
+ "rsync -avz -C",
409
+ buildRsyncFilterArgs({ excludes: infrastructureExcludeArgs }),
410
+ "--delete",
411
+ `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
412
+ shellQuote(withTrailingSlash(deployInfrastructureDir)),
413
+ `${user}@${host}:~/apps/${baseDeployDir}/infrastructure/`,
414
+ ].join(" ")
415
+
416
+ log(`Running: ${infrastructureRsyncCmd}`)
417
+ execSync(infrastructureRsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
418
+ await ssh(`cd ~/apps/${baseDeployDir}/infrastructure && [ -f package.json ] && npm ci || true`)
419
+ }
420
+
421
+ const excludeList = baseExcludeList
422
+ .concat(useBlueGreen ? ["infrastructure/"] : [])
423
+ .concat(useBlueGreen ? preparedDeploy.sharedEnvFileNames : [])
424
+ .concat(deployExcludePatterns)
425
+
426
+ const rsyncCmd = [
427
+ "rsync -avz -C",
428
+ buildRsyncFilterArgs({ excludes: excludeList }),
429
+ "--delete",
430
+ `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
431
+ shellQuote(withTrailingSlash(deployRoot)),
432
+ `${user}@${host}:~/apps/${deployDir}/`,
433
+ ].join(" ")
434
+
435
+ log(`Running: ${rsyncCmd}`)
436
+ execSync(rsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
437
+
438
+ if (useBlueGreen) {
439
+ console.log(`Starting instance ${targetInstance}`)
440
+ await runInstanceAction("up", targetInstance)
441
+ await waitForInstance(targetInstance, target)
442
+
443
+ console.log(`Switching ${siteId}: ${activeInstance} -> ${targetInstance}`)
444
+ await privateApi.post(`/_private/v1/sites/${siteId}/lock`)
445
+ try {
446
+ await privateApi.post(`/_private/v1/sites/${siteId}/deployment/switch`, {instance: targetInstance})
447
+ } finally {
448
+ await privateApi.post(`/_private/v1/sites/${siteId}/unlock`)
449
+ }
450
+ await runInstanceAction("down", activeInstance)
451
+ } else {
452
+ await ssh(`cd ~/apps/${deployDir}/infrastructure && [ -f package.json ] && npm ci || true`)
453
+ log("Restarting application...")
454
+ await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js down prod`, true)
455
+ log("Application stopped. Starting it up again...")
456
+ await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js up prod`, true)
457
+ }
458
+
459
+ if (argv.pruneDocker !== false) {
460
+ log("Pruning dangling docker images on the remote host...")
461
+ try {
462
+ await ssh("docker image prune -f")
463
+ } catch (pruneError) {
464
+ console.warn(
465
+ `Warning: docker image prune failed (deploy continues): ${pruneError instanceof Error ? pruneError.message : pruneError}`,
466
+ )
467
+ }
468
+ }
469
+
470
+ log("Deployment complete ✅")
471
+ }
140
472
  } catch (error) {
141
473
  console.error(
142
474
  `Deployment failed: ${error instanceof Error ? error.message : error}`,
@@ -151,6 +483,10 @@ export const deploy = async (argv) => {
151
483
  console.warn(`Failed to remove temporary key file: ${cleanupError}`)
152
484
  }
153
485
  }
486
+
487
+ if (preparedDeploy) {
488
+ preparedDeploy.cleanup()
489
+ }
154
490
  }
155
491
 
156
492
  try {
@@ -0,0 +1,26 @@
1
+ import fs from "fs"
2
+ import https from "https"
3
+
4
+ import axios from "axios"
5
+
6
+
7
+ const requiredEnv = (env, name) => {
8
+ const value = env[name]
9
+ if (!value) throw new Error(`Missing required environment variable: ${name}`)
10
+ return value
11
+ }
12
+
13
+ export const createPrivateApiClient = ({env, timeoutMs = 15_000}) => {
14
+ const httpsAgent = new https.Agent({
15
+ cert: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CLIENT_CERT")),
16
+ key: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CLIENT_KEY")),
17
+ ca: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CA")),
18
+ })
19
+
20
+ return axios.create({
21
+ baseURL: requiredEnv(env, "RB_PROXY_PRIVATE_API_URL"),
22
+ timeout: timeoutMs,
23
+ httpsAgent,
24
+ validateStatus: (status) => status >= 200 && status < 300,
25
+ })
26
+ }
package/src/index.js CHANGED
@@ -74,6 +74,41 @@ yargs(hideBin(process.argv))
74
74
  type: "array",
75
75
  default: [],
76
76
  })
77
+ .option("source-dir", {
78
+ describe: "Directory to use as the deployment source",
79
+ type: "string",
80
+ default: ".",
81
+ })
82
+ .option("infrastructure-dir", {
83
+ describe: "Infrastructure directory, relative to source-dir unless absolute",
84
+ type: "string",
85
+ default: "infrastructure",
86
+ })
87
+ .option("shared-env-file", {
88
+ describe: "Env file to sync to the shared deployment directory",
89
+ type: "array",
90
+ default: [],
91
+ })
92
+ .option("include", {
93
+ describe: "Only include matching source paths in the deployment root",
94
+ type: "array",
95
+ default: [],
96
+ })
97
+ .option("exclude", {
98
+ describe: "Exclude matching source paths from the deployment root and rsync commands",
99
+ type: "array",
100
+ default: [],
101
+ })
102
+ .option("blue-green", {
103
+ describe: "Deploy to the inactive blue/green instance and switch the reverse proxy",
104
+ type: "boolean",
105
+ default: false,
106
+ })
107
+ .option("rollback", {
108
+ describe: "Rollback the reverse proxy to the previous instance",
109
+ type: "boolean",
110
+ default: false,
111
+ })
77
112
  },
78
113
  async (argv) => {
79
114
  await deploy(argv)