rollbridge 0.1.2 → 0.1.5

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/doctor.js ADDED
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+
3
+ import {constants as fsConstants} from "node:fs"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import path from "node:path"
7
+ import {inspectControlSocket} from "./daemon.js"
8
+
9
+ /**
10
+ * @typedef {{detail: string, name: string, ok: boolean}} DoctorCheck
11
+ */
12
+
13
+ /**
14
+ * Runs pre-flight environment checks for a validated config: control socket
15
+ * reachability, control-socket directory writability, and proxy port availability.
16
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
17
+ * @returns {Promise<DoctorCheck[]>} One check per probed aspect.
18
+ */
19
+ export async function runEnvironmentChecks(config) {
20
+ /** @type {DoctorCheck[]} */
21
+ const checks = []
22
+ const socketInspection = await inspectControlSocketSafely(config.control.path)
23
+
24
+ checks.push(controlSocketCheck(config, socketInspection))
25
+ checks.push(await controlSocketDirectoryCheck(config))
26
+ checks.push(await proxyPortCheck(config))
27
+
28
+ return checks
29
+ }
30
+
31
+ /**
32
+ * @param {string} socketPath - Control socket path.
33
+ * @returns {Promise<{alive: boolean, application?: string} | {error: string}>} Probe result, or the probe error.
34
+ */
35
+ async function inspectControlSocketSafely(socketPath) {
36
+ try {
37
+ return await inspectControlSocket(socketPath)
38
+ } catch (error) {
39
+ return {error: error instanceof Error ? error.message : String(error)}
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
45
+ * @param {{alive: boolean, application?: string} | {error: string}} inspection - Control socket probe result.
46
+ * @returns {DoctorCheck} Control socket reachability check.
47
+ */
48
+ function controlSocketCheck(config, inspection) {
49
+ const socketPath = config.control.path
50
+
51
+ if ("error" in inspection) {
52
+ return {detail: `could not probe ${socketPath}: ${inspection.error}`, name: "control socket", ok: false}
53
+ }
54
+
55
+ if (!inspection.alive) {
56
+ return {detail: `no daemon running; ${socketPath} is free to bind`, name: "control socket", ok: true}
57
+ }
58
+
59
+ if (inspection.application === undefined) {
60
+ return {detail: `another process is already listening on ${socketPath}; the daemon would fail to bind it`, name: "control socket", ok: false}
61
+ }
62
+
63
+ return {detail: `a Rollbridge daemon for "${inspection.application}" is already running on ${socketPath}; stop it before starting another`, name: "control socket", ok: false}
64
+ }
65
+
66
+ /**
67
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
68
+ * @returns {Promise<DoctorCheck>} Whether the control socket's directory is writable.
69
+ */
70
+ async function controlSocketDirectoryCheck(config) {
71
+ const directory = path.dirname(path.resolve(config.control.path))
72
+
73
+ try {
74
+ // Creating a Unix socket needs both write and search (execute) permission on the directory.
75
+ await fs.access(directory, fsConstants.W_OK | fsConstants.X_OK)
76
+
77
+ return {detail: `${directory} is writable`, name: "control socket directory", ok: true}
78
+ } catch {
79
+ return {detail: `${directory} is missing or not writable`, name: "control socket directory", ok: false}
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
85
+ * @returns {Promise<DoctorCheck>} Whether the proxy port can be bound.
86
+ */
87
+ async function proxyPortCheck(config) {
88
+ const address = `${config.proxy.host}:${config.proxy.port}`
89
+ const bind = await canBindPort(config.proxy.host, config.proxy.port)
90
+
91
+ if (bind.ok) {
92
+ return {detail: `${address} is available`, name: "proxy port", ok: true}
93
+ }
94
+
95
+ return {detail: `${address} is unavailable (${bind.code})`, name: "proxy port", ok: false}
96
+ }
97
+
98
+ /**
99
+ * @param {string} host - Bind host.
100
+ * @param {number} port - Candidate port.
101
+ * @returns {Promise<{ok: true} | {code: string, ok: false}>} Whether the port can be bound.
102
+ */
103
+ async function canBindPort(host, port) {
104
+ return await new Promise((resolve) => {
105
+ const server = net.createServer()
106
+
107
+ server.once("error", (error) => {
108
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "EUNKNOWN"
109
+
110
+ resolve({code, ok: false})
111
+ })
112
+ server.listen(port, host, () => server.close(() => resolve({ok: true})))
113
+ })
114
+ }
package/src/health.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * @returns {Promise<void>} Resolves when healthy.
10
10
  */
11
11
  export async function waitForHealth({health, host, port}) {
12
+ if (health.startDelayMs > 0) {
13
+ await new Promise((resolve) => setTimeout(resolve, health.startDelayMs))
14
+ }
15
+
12
16
  const deadline = Date.now() + health.timeoutMs
13
17
  const url = `http://${host}:${port}${health.path}`
14
18
  let lastError = "no attempts"
@@ -8,8 +8,8 @@ import {spawn} from "node:child_process"
8
8
  * @typedef {"starting" | "running" | "stopping" | "stopped" | "failed"} ManagedProcessState
9
9
  * @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
10
10
  * @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
11
- * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, outputLines: number, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
12
- * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
11
+ * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, outputLines: number, restart: import("./config.js").RestartConfig, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
12
+ * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, restarts: number, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
13
13
  */
14
14
 
15
15
  export default class ManagedProcess extends EventEmitter {
@@ -21,11 +21,12 @@ export default class ManagedProcess extends EventEmitter {
21
21
  * @param {string} args.id - Process id.
22
22
  * @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger callback.
23
23
  * @param {number} args.outputLines - Recent stdout/stderr lines to retain and report.
24
+ * @param {import("./config.js").RestartConfig} [args.restart] - Restart policy (defaults to unlimited restarts with a constant delay).
24
25
  * @param {number} args.restartDelayMs - Restart delay.
25
26
  * @param {() => boolean} args.shouldRestart - Restart policy callback.
26
27
  * @param {number} args.stopTimeoutMs - Stop timeout.
27
28
  */
28
- constructor({command, cwd, env, id, logger, outputLines, restartDelayMs, shouldRestart, stopTimeoutMs}) {
29
+ constructor({command, cwd, env, id, logger, outputLines, restart = {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}, restartDelayMs, shouldRestart, stopTimeoutMs}) {
29
30
  super()
30
31
 
31
32
  this.command = command
@@ -34,11 +35,15 @@ export default class ManagedProcess extends EventEmitter {
34
35
  this.id = id
35
36
  this.logger = logger
36
37
  this.outputLines = outputLines
38
+ this.restart = restart
37
39
  this.restartDelayMs = restartDelayMs
38
40
  this.shouldRestart = shouldRestart
39
41
  this.stopTimeoutMs = stopTimeoutMs
40
42
  this.state = /** @type {ManagedProcessState} */ ("stopped")
41
43
  this.logs = /** @type {ManagedProcessLog[]} */ ([])
44
+ this.restarts = 0
45
+ this.recentRestarts = /** @type {number[]} */ ([])
46
+ this.startedAtMs = /** @type {number | undefined} */ (undefined)
42
47
  this.intentionalStop = false
43
48
  this.restartTimer = undefined
44
49
  this.child = undefined
@@ -77,6 +82,7 @@ export default class ManagedProcess extends EventEmitter {
77
82
 
78
83
  child.once("spawn", () => {
79
84
  this.state = "running"
85
+ this.startedAtMs = Date.now()
80
86
  this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
81
87
  this.emit("started")
82
88
  resolve(undefined)
@@ -103,6 +109,7 @@ export default class ManagedProcess extends EventEmitter {
103
109
  this.env = definition.env
104
110
  this.logger = definition.logger
105
111
  this.outputLines = definition.outputLines
112
+ this.restart = definition.restart
106
113
  this.restartDelayMs = definition.restartDelayMs
107
114
  this.shouldRestart = definition.shouldRestart
108
115
  this.stopTimeoutMs = definition.stopTimeoutMs
@@ -143,13 +150,66 @@ export default class ManagedProcess extends EventEmitter {
143
150
  this.emit("exit", {code, signal})
144
151
 
145
152
  if (!wasIntentional && this.shouldRestart()) {
146
- this.restartTimer = setTimeout(() => {
147
- this.restartTimer = undefined
148
- this.start().catch((error) => {
149
- this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
150
- })
151
- }, this.restartDelayMs)
153
+ this.scheduleRestart()
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Schedules an automatic restart per the restart policy, or gives up once the policy's limit is reached.
159
+ * @returns {void}
160
+ */
161
+ scheduleRestart() {
162
+ const {backoffFactor, maxRestarts, windowMs} = this.restart
163
+
164
+ // Fast path: unlimited restarts with a constant delay needs no per-restart bookkeeping.
165
+ // The delay is constant across attempts here (backoffFactor is 1), so restartDelayFor(0)
166
+ // gives the right value while still applying any maxDelayMs cap.
167
+ if (maxRestarts === undefined && backoffFactor === 1) {
168
+ this.queueRestart(this.restartDelayFor(0))
169
+
170
+ return
171
+ }
172
+
173
+ const now = Date.now()
174
+
175
+ if (windowMs > 0) {
176
+ this.recentRestarts = this.recentRestarts.filter((time) => time > now - windowMs)
152
177
  }
178
+
179
+ if (maxRestarts !== undefined && this.recentRestarts.length >= maxRestarts) {
180
+ this.logger("restart limit reached", {id: this.id, maxRestarts, windowMs})
181
+
182
+ return
183
+ }
184
+
185
+ const delay = this.restartDelayFor(this.recentRestarts.length)
186
+
187
+ this.recentRestarts.push(now)
188
+ this.queueRestart(delay)
189
+ }
190
+
191
+ /**
192
+ * @param {number} attempt - Number of restarts already counted in the current window.
193
+ * @returns {number} Backed-off restart delay in milliseconds, capped by maxDelayMs when set.
194
+ */
195
+ restartDelayFor(attempt) {
196
+ const backedOff = this.restartDelayMs * this.restart.backoffFactor ** attempt
197
+
198
+ return this.restart.maxDelayMs > 0 ? Math.min(backedOff, this.restart.maxDelayMs) : backedOff
199
+ }
200
+
201
+ /**
202
+ * @param {number} delayMs - Delay before the restart attempt.
203
+ * @returns {void}
204
+ */
205
+ queueRestart(delayMs) {
206
+ this.restartTimer = setTimeout(() => {
207
+ this.restartTimer = undefined
208
+ this.restarts += 1
209
+ this.start().catch((error) => {
210
+ this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
211
+ })
212
+ }, delayMs)
153
213
  }
154
214
 
155
215
  /**
@@ -229,7 +289,10 @@ export default class ManagedProcess extends EventEmitter {
229
289
  id: this.id,
230
290
  logs: this.logs.slice(-this.outputLines),
231
291
  pid: this.pid,
232
- state: this.state
292
+ restarts: this.restarts,
293
+ startedAt: this.startedAtMs === undefined ? undefined : new Date(this.startedAtMs).toISOString(),
294
+ state: this.state,
295
+ uptimeMs: this.state === "running" && this.startedAtMs !== undefined ? Date.now() - this.startedAtMs : undefined
233
296
  }
234
297
  }
235
298
  }
@@ -67,18 +67,54 @@ export default class ReleaseGroup extends EventEmitter {
67
67
  if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
68
68
  await waitForHealth({
69
69
  health: processConfig.health,
70
- host: this.config.proxy.host,
70
+ host: this.config.proxy.upstreamHost,
71
71
  port: this.ports[processConfig.id]
72
72
  })
73
73
  }
74
74
  }
75
75
  } catch (error) {
76
76
  this.state = "failed"
77
+ this.logStartupFailure(error instanceof Error ? error : String(error))
77
78
  await this.stop()
78
79
  throw error
79
80
  }
80
81
  }
81
82
 
83
+ /**
84
+ * @param {string} id - Process id.
85
+ * @returns {ManagedProcess | undefined} This release's managed process with the given id, if present.
86
+ */
87
+ getProcess(id) {
88
+ return this.processes.get(id)
89
+ }
90
+
91
+ /**
92
+ * Logs process diagnostics before failed startup cleanup stops and removes the release processes.
93
+ * @param {Error | string} error - Startup failure.
94
+ * @returns {void}
95
+ */
96
+ logStartupFailure(error) {
97
+ this.logger("release startup failed", {
98
+ error: error instanceof Error ? error.message : error,
99
+ releaseId: this.releaseId
100
+ })
101
+
102
+ for (const processInstance of this.processes.values()) {
103
+ const status = processInstance.status()
104
+
105
+ this.logger("release startup process status", {
106
+ command: status.command,
107
+ exitCode: status.exitCode ?? null,
108
+ exitSignal: status.exitSignal ?? null,
109
+ logs: status.logs,
110
+ pid: status.pid ?? null,
111
+ processId: status.id,
112
+ releaseId: this.releaseId,
113
+ state: status.state
114
+ })
115
+ }
116
+ }
117
+
82
118
  /**
83
119
  * Starts companions before the proxied process so release-local dependencies are available before health checks.
84
120
  * @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
@@ -112,7 +148,7 @@ export default class ReleaseGroup extends EventEmitter {
112
148
  }
113
149
 
114
150
  this.ports[processConfig.id] = await findAvailablePort({
115
- host: this.config.proxy.host,
151
+ host: this.config.proxy.upstreamHost,
116
152
  range: processConfig.port,
117
153
  usedPorts
118
154
  })
@@ -142,6 +178,7 @@ export default class ReleaseGroup extends EventEmitter {
142
178
  id: processConfig.id,
143
179
  logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
144
180
  outputLines: processConfig.outputLines,
181
+ restart: processConfig.restart,
145
182
  restartDelayMs: processConfig.restartDelayMs,
146
183
  shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
147
184
  stopTimeoutMs: processConfig.gracefulStopMs
@@ -186,7 +223,8 @@ export default class ReleaseGroup extends EventEmitter {
186
223
  processId: processConfig.id,
187
224
  proxy: {
188
225
  host: this.config.proxy.host,
189
- port: this.config.proxy.port
226
+ port: this.config.proxy.port,
227
+ upstreamHost: this.config.proxy.upstreamHost
190
228
  },
191
229
  releaseId: this.releaseId,
192
230
  releasePath: this.releasePath,
@@ -211,7 +249,7 @@ export default class ReleaseGroup extends EventEmitter {
211
249
 
212
250
  return {
213
251
  process: processInstance,
214
- target: `http://${this.config.proxy.host}:${port}`
252
+ target: `http://${this.config.proxy.upstreamHost}:${port}`
215
253
  }
216
254
  }
217
255
 
@@ -55,6 +55,21 @@ test("validateConfig returns a normalized config and no issues for a valid confi
55
55
  assert.equal(config.proxy.port, 8182)
56
56
  })
57
57
 
58
+ test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
59
+ const {config, issues} = validateConfig({
60
+ application: "demo",
61
+ control: {path: "/tmp/demo.sock"},
62
+ processes: [
63
+ {command: "run web", health: {path: "/ping"}, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
64
+ ],
65
+ proxy: {host: "0.0.0.0", port: 8182}
66
+ })
67
+
68
+ assert.deepEqual(issues, [])
69
+ assert.equal(config.proxy.host, "0.0.0.0")
70
+ assert.equal(config.proxy.upstreamHost, "127.0.0.1")
71
+ })
72
+
58
73
  test("validateConfig defaults outputLines and accepts a positive override", () => {
59
74
  const {config, issues} = validateConfig({
60
75
  application: "demo",
@@ -71,6 +86,46 @@ test("validateConfig defaults outputLines and accepts a positive override", () =
71
86
  assert.equal(config.processes[1].outputLines, 5)
72
87
  })
73
88
 
89
+ test("validateConfig defaults the restart policy, accepts overrides, and rejects bad values", () => {
90
+ /**
91
+ * @param {import("../src/json.js").JsonValue} restart - Restart policy under test, or undefined to omit it.
92
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
93
+ */
94
+ const validateRestart = (restart) => validateConfig({
95
+ application: "demo",
96
+ control: {path: "/tmp/demo.sock"},
97
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}, restart}],
98
+ proxy: {host: "127.0.0.1", port: 8182}
99
+ })
100
+
101
+ const defaulted = validateRestart(undefined)
102
+
103
+ assert.deepEqual(defaulted.issues, [])
104
+ assert.deepEqual(defaulted.config.processes[0].restart, {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
105
+
106
+ const custom = validateRestart({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
107
+
108
+ assert.deepEqual(custom.issues, [])
109
+ assert.deepEqual(custom.config.processes[0].restart, {backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
110
+
111
+ // maxRestarts: 0 disables automatic restarts.
112
+ const disabled = validateRestart({maxRestarts: 0})
113
+
114
+ assert.deepEqual(disabled.issues, [])
115
+ assert.equal(disabled.config.processes[0].restart.maxRestarts, 0)
116
+
117
+ const invalid = validateRestart({backoffFactor: 0.5, maxDelayMs: -1, maxRestarts: -2, windowMs: -3})
118
+ const messages = invalid.issues.map((issue) => issue.message)
119
+
120
+ assert.ok(messages.includes("processes[0].restart.backoffFactor must be a number greater than or equal to 1"), JSON.stringify(messages))
121
+ assert.ok(messages.includes("processes[0].restart.maxRestarts must be a non-negative integer"), JSON.stringify(messages))
122
+ assert.ok(messages.includes("processes[0].restart.maxDelayMs must be a non-negative number"), JSON.stringify(messages))
123
+ assert.ok(messages.includes("processes[0].restart.windowMs must be a non-negative number"), JSON.stringify(messages))
124
+
125
+ // A fractional maxRestarts is rejected (it must be a whole number of restarts).
126
+ assert.ok(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer"))
127
+ })
128
+
74
129
  test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
75
130
  const {issues} = validateConfig({
76
131
  application: "demo",
@@ -117,6 +172,62 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
117
172
  assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
118
173
  })
119
174
 
175
+ test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
176
+ /**
177
+ * @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
178
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
179
+ */
180
+ const validateHealth = (health) => validateConfig({
181
+ application: "demo",
182
+ control: {path: "/tmp/demo.sock"},
183
+ processes: [{command: "run web", health, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
184
+ proxy: {host: "127.0.0.1", port: 8182}
185
+ })
186
+
187
+ const defaulted = validateHealth({path: "/ping"})
188
+
189
+ assert.deepEqual(defaulted.issues, [])
190
+ assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
191
+
192
+ const custom = validateHealth({path: "/ping", startDelayMs: 2000})
193
+
194
+ assert.deepEqual(custom.issues, [])
195
+ assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
196
+
197
+ const negative = validateHealth({path: "/ping", startDelayMs: -1})
198
+
199
+ assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
200
+ })
201
+
202
+ test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
203
+ /**
204
+ * @param {import("../src/json.js").JsonValue} releaseRetention - Retention config under test, or undefined.
205
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
206
+ */
207
+ const validateRetention = (releaseRetention) => validateConfig({
208
+ application: "demo",
209
+ control: {path: "/tmp/demo.sock"},
210
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
211
+ proxy: {host: "127.0.0.1", port: 8182},
212
+ releaseRetention
213
+ })
214
+
215
+ const defaulted = validateRetention(undefined)
216
+
217
+ assert.deepEqual(defaulted.issues, [])
218
+ assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
219
+
220
+ const custom = validateRetention({keep: 3, maxAgeMs: 60000})
221
+
222
+ assert.deepEqual(custom.issues, [])
223
+ assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
224
+
225
+ const invalid = validateRetention({keep: -1, maxAgeMs: -5})
226
+
227
+ assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
228
+ assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
229
+ })
230
+
120
231
  test("normalizeConfig throws an aggregated error listing every issue", () => {
121
232
  assert.throws(
122
233
  () => normalizeConfig({
@@ -178,6 +289,40 @@ test("validate CLI command accepts a valid config without setting a failure exit
178
289
  }
179
290
  })
180
291
 
292
+ test("validate --json emits a machine-readable result", async () => {
293
+ const validPath = await writeConfig({
294
+ application: "demo",
295
+ control: {path: "/tmp/rollbridge-json-valid.sock"},
296
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
297
+ proxy: {host: "127.0.0.1", port: 8182}
298
+ })
299
+ const invalidPath = await writeConfig({
300
+ application: "demo",
301
+ processes: [{command: "run web", id: "web", policy: "proxied"}],
302
+ proxy: {port: 8182}
303
+ })
304
+
305
+ try {
306
+ const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
307
+
308
+ assert.equal(valid.valid, true)
309
+ assert.deepEqual(valid.issues, [])
310
+ assert.equal(valid.config.processes, 1)
311
+ assert.notEqual(process.exitCode, 1)
312
+
313
+ const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
314
+
315
+ assert.equal(invalid.valid, false)
316
+ assert.equal(invalid.config, null)
317
+ assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
318
+ assert.equal(process.exitCode, 1)
319
+ } finally {
320
+ process.exitCode = 0
321
+ await fs.rm(path.dirname(validPath), {force: true, recursive: true})
322
+ await fs.rm(path.dirname(invalidPath), {force: true, recursive: true})
323
+ }
324
+ })
325
+
181
326
  /**
182
327
  * @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
183
328
  * @returns {Promise<string>} Path to the written config module.