rollbridge 0.1.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.
package/src/cli.js ADDED
@@ -0,0 +1,359 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs"
4
+ import fsPromises from "node:fs/promises"
5
+ import path from "node:path"
6
+ import {spawn} from "node:child_process"
7
+ import {Command} from "commander"
8
+ import RollbridgeDaemon from "./daemon.js"
9
+ import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
10
+ import {sendControlCommand} from "./control-client.js"
11
+
12
+ const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
13
+
14
+ /**
15
+ * Runs the CLI.
16
+ * @param {string[]} argv - Process argv.
17
+ * @returns {Promise<void>} Resolves when complete.
18
+ */
19
+ export async function runCli(argv) {
20
+ const program = new Command()
21
+
22
+ program
23
+ .name("rollbridge")
24
+ .description("Zero-downtime process supervisor and local traffic switcher.")
25
+ .showHelpAfterError()
26
+
27
+ program
28
+ .command("daemon")
29
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
30
+ .action(async (options) => {
31
+ const configPath = await resolveConfigPath(options.config)
32
+ const config = await loadConfig(configPath)
33
+ const daemon = new RollbridgeDaemon({config})
34
+
35
+ await daemon.start()
36
+
37
+ const shutdown = async () => {
38
+ await daemon.shutdown()
39
+ process.exit(0)
40
+ }
41
+
42
+ process.once("SIGINT", () => { void shutdown() })
43
+ process.once("SIGTERM", () => { void shutdown() })
44
+ })
45
+
46
+ program
47
+ .command("deploy")
48
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
49
+ .requiredOption("--release-path <path>", "Release path")
50
+ .option("--release-id <id>", "Release id")
51
+ .option("--revision <sha>", "Revision")
52
+ .option("--ensure-daemon", "Start the Rollbridge daemon if it is not already running")
53
+ .option("--daemon-log-path <path>", "Log path used when --ensure-daemon starts the daemon")
54
+ .option("--daemon-pid-path <path>", "PID file path used when --ensure-daemon starts the daemon")
55
+ .option("--daemon-start-timeout-ms <ms>", "How long to wait for an ensured daemon to accept control commands")
56
+ .action(async (options) => {
57
+ const configPath = await resolveConfigPath(options.config)
58
+ const config = await loadConfig(configPath)
59
+
60
+ if (options.ensureDaemon) {
61
+ await ensureDaemonRunning({
62
+ argv,
63
+ config,
64
+ configPath,
65
+ logPath: options.daemonLogPath,
66
+ pidPath: options.daemonPidPath,
67
+ timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
68
+ })
69
+ }
70
+
71
+ const response = await sendControlCommand({
72
+ command: {
73
+ command: "deploy",
74
+ releaseId: options.releaseId,
75
+ releasePath: options.releasePath,
76
+ revision: options.revision
77
+ },
78
+ path: config.control.path
79
+ })
80
+
81
+ console.log(JSON.stringify(response, null, 2))
82
+ })
83
+
84
+ program
85
+ .command("ensure-daemon")
86
+ .description("Start the daemon if the control socket is not already accepting commands.")
87
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
88
+ .option("--daemon-log-path <path>", "Daemon log path")
89
+ .option("--daemon-pid-path <path>", "Daemon PID file path")
90
+ .option("--daemon-start-timeout-ms <ms>", "How long to wait for the daemon to accept control commands")
91
+ .action(async (options) => {
92
+ const configPath = await resolveConfigPath(options.config)
93
+ const config = await loadConfig(configPath)
94
+ const response = await ensureDaemonRunning({
95
+ argv,
96
+ config,
97
+ configPath,
98
+ logPath: options.daemonLogPath,
99
+ pidPath: options.daemonPidPath,
100
+ timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
101
+ })
102
+
103
+ console.log(JSON.stringify(response, null, 2))
104
+ })
105
+
106
+ program
107
+ .command("status")
108
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
109
+ .action(async (options) => {
110
+ const configPath = await resolveConfigPath(options.config)
111
+ const config = await loadConfig(configPath)
112
+ const response = await sendControlCommand({
113
+ command: {command: "status"},
114
+ path: config.control.path
115
+ })
116
+
117
+ console.log(JSON.stringify(response, null, 2))
118
+ })
119
+
120
+ program
121
+ .command("stop")
122
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
123
+ .option("--release-id <id>", "Release id")
124
+ .action(async (options) => {
125
+ const configPath = await resolveConfigPath(options.config)
126
+ const config = await loadConfig(configPath)
127
+ const response = await sendControlCommand({
128
+ command: {
129
+ command: "stop",
130
+ releaseId: options.releaseId
131
+ },
132
+ path: config.control.path
133
+ })
134
+
135
+ console.log(JSON.stringify(response, null, 2))
136
+ })
137
+
138
+ program
139
+ .command("shutdown")
140
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
141
+ .action(async (options) => {
142
+ const configPath = await resolveConfigPath(options.config)
143
+ const config = await loadConfig(configPath)
144
+ const response = await sendControlCommand({
145
+ command: {command: "shutdown"},
146
+ path: config.control.path
147
+ })
148
+
149
+ console.log(JSON.stringify(response, null, 2))
150
+ })
151
+
152
+ program
153
+ .command("validate")
154
+ .description("Parse the config and report all errors without starting the daemon.")
155
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
156
+ .action(async (options) => {
157
+ let configPath
158
+
159
+ try {
160
+ configPath = await resolveConfigPath(options.config)
161
+ } catch (error) {
162
+ console.error(error instanceof Error ? error.message : String(error))
163
+ process.exitCode = 1
164
+ return
165
+ }
166
+
167
+ const {config, issues} = await validateConfigFile(configPath)
168
+
169
+ if (issues.length === 0) {
170
+ const processCount = config.processes.length
171
+
172
+ console.log(`${configPath} is valid: ${processCount} ${processCount === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}.`)
173
+ return
174
+ }
175
+
176
+ console.error(`Found ${issues.length} configuration ${issues.length === 1 ? "issue" : "issues"} in ${configPath}:`)
177
+
178
+ issues.forEach((issue, index) => {
179
+ console.error(`\n${index + 1}. ${issue.message}`)
180
+ console.error(` Fix: ${issue.fix}`)
181
+ })
182
+
183
+ process.exitCode = 1
184
+ })
185
+
186
+ await program.parseAsync(argv)
187
+ }
188
+
189
+ /**
190
+ * Reads, parses, and validates a config file, collecting read, parse, and validation issues.
191
+ * @param {string} configPath - Config file path.
192
+ * @returns {Promise<{config: import("./config.js").RollbridgeConfig, issues: import("./config.js").ConfigIssue[]}>} Best-effort config and any issues.
193
+ */
194
+ async function validateConfigFile(configPath) {
195
+ try {
196
+ const {absolutePath, rawConfig} = await parseConfigFile(configPath)
197
+
198
+ return validateConfig(rawConfig, absolutePath)
199
+ } catch (error) {
200
+ const {config} = validateConfig({}, configPath)
201
+ const message = error instanceof Error ? error.message : String(error)
202
+
203
+ return {config, issues: [{fix: "Ensure the file exists and exports a default Rollbridge config object.", message}]}
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Starts a daemon when needed and waits until it accepts status commands.
209
+ * @param {object} args - Options.
210
+ * @param {string[]} args.argv - Original CLI argv.
211
+ * @param {import("./config.js").RollbridgeConfig} args.config - Loaded config.
212
+ * @param {string} args.configPath - Config path.
213
+ * @param {string | undefined} args.logPath - Optional daemon log path.
214
+ * @param {string | undefined} args.pidPath - Optional daemon PID path.
215
+ * @param {number} args.timeoutMs - Startup timeout.
216
+ * @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
217
+ */
218
+ async function ensureDaemonRunning({argv, config, configPath, logPath, pidPath, timeoutMs}) {
219
+ const existingStatus = await daemonStatus(config)
220
+
221
+ if (existingStatus) return existingStatus
222
+
223
+ await startDaemonProcess({
224
+ argv,
225
+ configPath,
226
+ logPath: logPath || defaultDaemonLogPath(config),
227
+ pidPath: pidPath || defaultDaemonPidPath(config)
228
+ })
229
+
230
+ return await waitForDaemonStatus(config, timeoutMs)
231
+ }
232
+
233
+ /**
234
+ * @param {import("./config.js").RollbridgeConfig} config - Loaded config.
235
+ * @returns {Promise<Record<string, import("./json.js").JsonValue> | undefined>} Status when the daemon responds.
236
+ */
237
+ async function daemonStatus(config) {
238
+ try {
239
+ return await sendControlCommand({
240
+ command: {command: "status"},
241
+ path: config.control.path
242
+ })
243
+ } catch (error) {
244
+ const errorWithCode = error && typeof error === "object"
245
+ ? /** @type {{code?: string}} */ (error)
246
+ : undefined
247
+
248
+ if (isMissingDaemonError(errorWithCode)) return undefined
249
+
250
+ throw error
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Starts the foreground daemon command as a detached child.
256
+ * @param {object} args - Options.
257
+ * @param {string[]} args.argv - Original CLI argv.
258
+ * @param {string} args.configPath - Config path.
259
+ * @param {string} args.logPath - Log file path.
260
+ * @param {string} args.pidPath - PID file path.
261
+ * @returns {Promise<void>} Resolves after the child has been spawned.
262
+ */
263
+ async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
264
+ const binPath = argv[1] || process.argv[1]
265
+
266
+ if (!binPath) throw new Error("Unable to determine Rollbridge CLI path for daemon startup")
267
+
268
+ await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
269
+ await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
270
+
271
+ const stdoutFd = fs.openSync(logPath, "a")
272
+ const stderrFd = fs.openSync(logPath, "a")
273
+
274
+ try {
275
+ const child = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {
276
+ detached: true,
277
+ env: process.env,
278
+ stdio: ["ignore", stdoutFd, stderrFd]
279
+ })
280
+
281
+ child.unref()
282
+
283
+ if (child.pid) {
284
+ await fsPromises.writeFile(pidPath, `${child.pid}\n`)
285
+ }
286
+ } finally {
287
+ fs.closeSync(stdoutFd)
288
+ fs.closeSync(stderrFd)
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Waits until a daemon answers status commands.
294
+ * @param {import("./config.js").RollbridgeConfig} config - Loaded config.
295
+ * @param {number} timeoutMs - Timeout in milliseconds.
296
+ * @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
297
+ */
298
+ async function waitForDaemonStatus(config, timeoutMs) {
299
+ const deadline = Date.now() + timeoutMs
300
+ let lastError = /** @type {Error | undefined} */ (undefined)
301
+
302
+ while (Date.now() < deadline) {
303
+ try {
304
+ const status = await daemonStatus(config)
305
+
306
+ if (status) return status
307
+ } catch (error) {
308
+ lastError = error instanceof Error ? error : new Error(String(error))
309
+ }
310
+
311
+ await new Promise((resolve) => setTimeout(resolve, 100))
312
+ }
313
+
314
+ const detail = lastError ? ` Last error: ${lastError.message}` : ""
315
+
316
+ throw new Error(`Rollbridge daemon did not become ready within ${timeoutMs}ms.${detail}`)
317
+ }
318
+
319
+ /**
320
+ * @param {import("./config.js").RollbridgeConfig} config - Loaded config.
321
+ * @returns {string} Default daemon log path.
322
+ */
323
+ function defaultDaemonLogPath(config) {
324
+ return `/tmp/rollbridge-${config.application}.log`
325
+ }
326
+
327
+ /**
328
+ * @param {import("./config.js").RollbridgeConfig} config - Loaded config.
329
+ * @returns {string} Default daemon PID path.
330
+ */
331
+ function defaultDaemonPidPath(config) {
332
+ return `/tmp/rollbridge-${config.application}.pid`
333
+ }
334
+
335
+ /**
336
+ * @param {string | undefined} value - Raw timeout value.
337
+ * @returns {number} Timeout in milliseconds.
338
+ */
339
+ function normalizeTimeoutMs(value) {
340
+ if (value === undefined) return DEFAULT_DAEMON_START_TIMEOUT_MS
341
+
342
+ const timeoutMs = Number(value)
343
+
344
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
345
+ throw new Error("--daemon-start-timeout-ms must be a positive number")
346
+ }
347
+
348
+ return timeoutMs
349
+ }
350
+
351
+ /**
352
+ * @param {{code?: string} | Error | null | undefined} error - Error value.
353
+ * @returns {boolean} True when the error means no daemon is accepting commands.
354
+ */
355
+ function isMissingDaemonError(error) {
356
+ if (!error || typeof error !== "object" || !("code" in error)) return false
357
+
358
+ return error.code === "ENOENT" || error.code === "ECONNREFUSED"
359
+ }