rollbridge 0.1.5 → 0.1.6

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 CHANGED
@@ -7,7 +7,8 @@ import {spawn} from "node:child_process"
7
7
  import {Command} from "commander"
8
8
  import RollbridgeDaemon from "./daemon.js"
9
9
  import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
10
- import {runEnvironmentChecks} from "./doctor.js"
10
+ import {runEnvironmentChecks, runReleaseChecks} from "./doctor.js"
11
+ import {recoverOrphans} from "./recover.js"
11
12
  import {sendControlCommand} from "./control-client.js"
12
13
 
13
14
  const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
@@ -82,6 +83,25 @@ export async function runCli(argv) {
82
83
  console.log(JSON.stringify(response, null, 2))
83
84
  })
84
85
 
86
+ program
87
+ .command("rollback")
88
+ .description("Roll back to a previous release: re-start it, health-check it, and switch traffic.")
89
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
90
+ .option("--release-id <id>", "Release id to roll back to (defaults to the most recently retired release)")
91
+ .action(async (options) => {
92
+ const configPath = await resolveConfigPath(options.config)
93
+ const config = await loadConfig(configPath)
94
+ const response = await sendControlCommand({
95
+ command: {
96
+ command: "rollback",
97
+ releaseId: options.releaseId
98
+ },
99
+ path: config.control.path
100
+ })
101
+
102
+ console.log(JSON.stringify(response, null, 2))
103
+ })
104
+
85
105
  program
86
106
  .command("ensure-daemon")
87
107
  .description("Start the daemon if the control socket is not already accepting commands.")
@@ -228,6 +248,9 @@ export async function runCli(argv) {
228
248
  .command("doctor")
229
249
  .description("Check the environment before starting the daemon: config, control socket, and proxy port.")
230
250
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
251
+ .option("--release-path <path>", "Also pre-flight a prepared release: render its templates and check the release and working directories")
252
+ .option("--release-id <id>", "Release id used when rendering templates (defaults to --revision or the release path basename)")
253
+ .option("--revision <sha>", "Revision used when rendering templates (defaults to --release-id)")
231
254
  .option("--json", "Output machine-readable JSON")
232
255
  .action(async (options) => {
233
256
  let configPath
@@ -252,6 +275,10 @@ export async function runCli(argv) {
252
275
  } else {
253
276
  checks.push({detail: `valid: ${config.processes.length} ${config.processes.length === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}`, name: "config", ok: true})
254
277
  checks.push(...await runEnvironmentChecks(config))
278
+
279
+ if (options.releasePath) {
280
+ checks.push(...await runReleaseChecks(config, {releaseId: options.releaseId, releasePath: options.releasePath, revision: options.revision}))
281
+ }
255
282
  }
256
283
 
257
284
  const failed = checks.filter((check) => !check.ok).length
@@ -295,9 +322,271 @@ export async function runCli(argv) {
295
322
  console.log(formatLogSources(sources, options.process))
296
323
  })
297
324
 
325
+ program
326
+ .command("events")
327
+ .description("Print recent structured daemon events (deploys, switches, stops, crashes, restarts, failures).")
328
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
329
+ .option("--limit <count>", "Show only the most recent <count> events")
330
+ .option("--json", "Output machine-readable JSON")
331
+ .action(async (options) => {
332
+ let limit
333
+
334
+ if (options.limit !== undefined) {
335
+ limit = Number(options.limit)
336
+
337
+ if (!Number.isInteger(limit) || limit < 1) {
338
+ console.error("--limit must be a positive integer.")
339
+ process.exitCode = 1
340
+ return
341
+ }
342
+ }
343
+
344
+ const configPath = await resolveConfigPath(options.config)
345
+ const config = await loadConfig(configPath)
346
+ const response = await sendControlCommand({
347
+ command: {command: "events", limit},
348
+ path: config.control.path
349
+ })
350
+ const events = /** @type {import("./event-log.js").DaemonEvent[]} */ (response.events ?? [])
351
+
352
+ if (options.json) {
353
+ console.log(JSON.stringify(events, null, 2))
354
+ return
355
+ }
356
+
357
+ console.log(formatEvents(events))
358
+ })
359
+
360
+ program
361
+ .command("recover")
362
+ .description("Stop orphaned processes left by a crashed daemon (reads statePath; lists them unless --force).")
363
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
364
+ .option("--force", "Stop the orphaned processes; without it, recover only lists them")
365
+ .action(async (options) => {
366
+ const configPath = await resolveConfigPath(options.config)
367
+ const config = await loadConfig(configPath)
368
+ const result = await recoverOrphans({config, force: Boolean(options.force)})
369
+
370
+ if ("error" in result) {
371
+ console.error(result.error)
372
+ process.exitCode = 1
373
+ return
374
+ }
375
+
376
+ if (result.remaining.length > 0) {
377
+ console.error(formatRecoverResult(result))
378
+ process.exitCode = 1
379
+ return
380
+ }
381
+
382
+ console.log(formatRecoverResult(result))
383
+ })
384
+
385
+ program
386
+ .command("completion")
387
+ .description("Print a shell completion script. Enable with: source <(rollbridge completion <shell>)")
388
+ .argument("<shell>", "Shell to generate completion for (bash or zsh)")
389
+ .action((shell) => {
390
+ if (shell !== "bash" && shell !== "zsh") {
391
+ console.error(`Unsupported shell "${shell}". Supported shells: bash, zsh.`)
392
+ process.exitCode = 1
393
+ return
394
+ }
395
+
396
+ console.log(generateCompletionScript(program, shell))
397
+ })
398
+
298
399
  await program.parseAsync(argv)
299
400
  }
300
401
 
402
+ /**
403
+ * @typedef {import("./recover.js").OrphanProcess} OrphanProcess
404
+ */
405
+
406
+ /**
407
+ * Formats the result of a recover run (the report case, not the error case).
408
+ * @param {{cleared: boolean, forced: boolean, orphans: OrphanProcess[], remaining: OrphanProcess[]}} result - Recover report.
409
+ * @returns {string} Human-readable summary.
410
+ */
411
+ export function formatRecoverResult(result) {
412
+ if (result.orphans.length === 0) {
413
+ return result.forced ? "No orphaned processes found; cleared the state file." : "No orphaned processes found."
414
+ }
415
+
416
+ if (!result.forced) {
417
+ return [
418
+ `Found ${orphanCountLabel(result.orphans.length)} (run with --force to stop):`,
419
+ ...listOrphans(result.orphans),
420
+ "Review the list first — a recycled pid could be an unrelated process."
421
+ ].join("\n")
422
+ }
423
+
424
+ const remainingPids = new Set(result.remaining.map((orphan) => orphan.pid))
425
+ const stopped = result.orphans.filter((orphan) => !remainingPids.has(orphan.pid))
426
+ const lines = []
427
+
428
+ if (stopped.length > 0) lines.push(`Stopped ${orphanCountLabel(stopped.length)}:`, ...listOrphans(stopped))
429
+
430
+ if (result.remaining.length > 0) {
431
+ lines.push(
432
+ `Could not stop ${orphanCountLabel(result.remaining.length)} — still running (check permissions/ownership):`,
433
+ ...listOrphans(result.remaining),
434
+ "Left the state file in place so you can investigate and re-run recover."
435
+ )
436
+ }
437
+
438
+ return lines.join("\n")
439
+ }
440
+
441
+ /**
442
+ * @param {number} count - Number of orphaned processes.
443
+ * @returns {string} A pluralized label such as "1 orphaned process" or "3 orphaned processes".
444
+ */
445
+ function orphanCountLabel(count) {
446
+ return `${count} orphaned process${count === 1 ? "" : "es"}`
447
+ }
448
+
449
+ /**
450
+ * @param {OrphanProcess[]} orphans - Orphans to render.
451
+ * @returns {string[]} One indented line per orphan.
452
+ */
453
+ function listOrphans(orphans) {
454
+ return orphans.map((orphan) => ` ${orphan.id} (pid ${orphan.pid}${orphan.releaseId ? `, release ${orphan.releaseId}` : ""})`)
455
+ }
456
+
457
+ /**
458
+ * @typedef {{name: string, options: string[], valueOptions: string[]}} CompletionCommand
459
+ */
460
+
461
+ /**
462
+ * Builds a shell completion script by introspecting the CLI's commands and options,
463
+ * so completions never drift from the actual command surface.
464
+ * @param {import("commander").Command} program - Configured CLI program.
465
+ * @param {"bash" | "zsh"} shell - Target shell.
466
+ * @returns {string} A sourceable completion script.
467
+ */
468
+ export function generateCompletionScript(program, shell) {
469
+ const commands = describeCommands(program)
470
+
471
+ return shell === "zsh" ? zshCompletionScript(commands) : bashCompletionScript(commands)
472
+ }
473
+
474
+ /**
475
+ * @param {import("commander").Command} program - Configured CLI program.
476
+ * @returns {CompletionCommand[]} Each command's name, long option flags, and value-taking option flags.
477
+ */
478
+ function describeCommands(program) {
479
+ return program.commands.map((command) => {
480
+ /** @type {string[]} */
481
+ const options = []
482
+ /** @type {string[]} */
483
+ const valueOptions = []
484
+
485
+ for (const option of command.options) {
486
+ if (!option.long) continue
487
+
488
+ options.push(option.long)
489
+ if (option.required || option.optional) valueOptions.push(option.long)
490
+ }
491
+
492
+ return {name: command.name(), options, valueOptions}
493
+ })
494
+ }
495
+
496
+ /**
497
+ * @param {CompletionCommand[]} commands - Command descriptors.
498
+ * @returns {string} A bash completion script.
499
+ */
500
+ function bashCompletionScript(commands) {
501
+ const names = commands.map((command) => command.name).join(" ")
502
+ const branches = commands
503
+ .map((command) => ` ${command.name})\n opts="${command.options.join(" ")}"\n values="${command.valueOptions.join(" ")}"\n ;;`)
504
+ .join("\n")
505
+
506
+ return `# rollbridge bash completion
507
+ # Enable with: source <(rollbridge completion bash)
508
+ _rollbridge() {
509
+ local cur prev cmd opts values i
510
+ cur="\${COMP_WORDS[COMP_CWORD]}"
511
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
512
+
513
+ cmd=""
514
+ for ((i = 1; i < COMP_CWORD; i++)); do
515
+ case "\${COMP_WORDS[i]}" in
516
+ -*) ;;
517
+ *) cmd="\${COMP_WORDS[i]}"; break ;;
518
+ esac
519
+ done
520
+
521
+ if [[ -z "$cmd" ]]; then
522
+ COMPREPLY=( $(compgen -W "${names}" -- "$cur") )
523
+ return
524
+ fi
525
+
526
+ opts=""
527
+ values=""
528
+ case "$cmd" in
529
+ ${branches}
530
+ esac
531
+
532
+ if [[ -n "$values" && " $values " == *" $prev "* ]]; then
533
+ COMPREPLY=( $(compgen -f -- "$cur") )
534
+ return
535
+ fi
536
+
537
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
538
+ }
539
+ complete -F _rollbridge rollbridge
540
+ `
541
+ }
542
+
543
+ /**
544
+ * @param {CompletionCommand[]} commands - Command descriptors.
545
+ * @returns {string} A zsh completion script.
546
+ */
547
+ function zshCompletionScript(commands) {
548
+ const names = commands.map((command) => command.name).join(" ")
549
+ const branches = commands
550
+ .map((command) => ` ${command.name}) compadd -- ${command.options.join(" ")} ;;`)
551
+ .join("\n")
552
+
553
+ return `#compdef rollbridge
554
+ # rollbridge zsh completion
555
+ # Enable with: source <(rollbridge completion zsh)
556
+ _rollbridge() {
557
+ local -a commands
558
+ commands=(${names})
559
+
560
+ if (( CURRENT == 2 )); then
561
+ compadd -- $commands
562
+ return
563
+ fi
564
+
565
+ case "\${words[2]}" in
566
+ ${branches}
567
+ esac
568
+ }
569
+ compdef _rollbridge rollbridge
570
+ `
571
+ }
572
+
573
+ /**
574
+ * Formats structured daemon events as human-readable lines.
575
+ * @param {import("./event-log.js").DaemonEvent[]} events - Recent events, oldest first.
576
+ * @returns {string} One line per event, or a placeholder when empty.
577
+ */
578
+ export function formatEvents(events) {
579
+ if (events.length === 0) return "No events recorded yet."
580
+
581
+ return events
582
+ .map((event) => {
583
+ const data = Object.keys(event.data).length > 0 ? ` ${JSON.stringify(event.data)}` : ""
584
+
585
+ return `${event.at} ${event.message}${data}`
586
+ })
587
+ .join("\n")
588
+ }
589
+
301
590
  /**
302
591
  * @typedef {{id: string, logs: import("./managed-process.js").ManagedProcessLog[], source: string}} LogSource
303
592
  */
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import fs from "node:fs/promises"
4
+ import os from "node:os"
4
5
  import path from "node:path"
5
6
  import {pathToFileURL} from "node:url"
6
7
 
@@ -10,11 +11,13 @@ import {pathToFileURL} from "node:url"
10
11
  * @typedef {{path: string, startDelayMs: number, timeoutMs: number, intervalMs: number}} HealthConfig
11
12
  * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
12
13
  * @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
13
- * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, outputLines: number, policy: ProcessPolicy, port?: PortRange, restart: RestartConfig, restartDelayMs: number, command: string}} ProcessConfig
14
- * @typedef {{mode?: number, path: string}} ControlConfig
14
+ * @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
15
+ * @typedef {{drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
16
+ * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
17
+ * @typedef {{group?: number | string, mode?: number, owner?: number | string, path: string}} ControlConfig
15
18
  * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
16
19
  * @typedef {{keep: number, maxAgeMs: number}} ReleaseRetentionConfig
17
- * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig}} RollbridgeConfig
20
+ * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig, statePath?: string}} RollbridgeConfig
18
21
  * @typedef {{fix: string, message: string}} ConfigIssue
19
22
  */
20
23
 
@@ -125,15 +128,18 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
125
128
  const processesSource = arrayAt(source.processes, "processes", issues)
126
129
  const proxy = normalizeProxy(proxySource, issues)
127
130
  const control = {
131
+ group: normalizeControlPrincipal(controlSource.group, "control.group", issues),
128
132
  mode: normalizeSocketMode(controlSource.mode, "control.mode", issues),
133
+ owner: normalizeControlPrincipal(controlSource.owner, "control.owner", issues),
129
134
  path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
130
135
  }
131
136
  const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
132
137
  const releaseRetention = normalizeReleaseRetention(objectAt(source.releaseRetention, "releaseRetention", issues, {}), issues)
138
+ const statePath = source.statePath === undefined || source.statePath === null ? undefined : normalizeString(source.statePath, "statePath", issues)
133
139
 
134
140
  validateProcessSet(processes, issues)
135
141
 
136
- return {config: {application, control, processes, proxy, releaseRetention}, issues}
142
+ return {config: {application, control, processes, proxy, releaseRetention, statePath}, issues}
137
143
  }
138
144
 
139
145
  /**
@@ -176,7 +182,7 @@ function normalizeProcess(value, index, proxy, issues) {
176
182
  if (!isPlainObject(value)) {
177
183
  issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
178
184
 
179
- return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restart: defaultRestartConfig(), restartDelayMs: 1000}
185
+ return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
180
186
  }
181
187
 
182
188
  const source = value
@@ -188,11 +194,16 @@ function normalizeProcess(value, index, proxy, issues) {
188
194
  gracefulStopMs: normalizeNumber(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, issues, {default: proxy.forceStopTimeoutMs}),
189
195
  health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
190
196
  id: normalizeString(source.id, `processes[${index}].id`, issues),
197
+ lifecycle: normalizeLifecycle(source.lifecycle, `processes[${index}].lifecycle`, issues),
198
+ memory: normalizeMemory(source.memory, `processes[${index}].memory`, issues),
199
+ nonBlockingDrain: normalizeBoolean(source.nonBlockingDrain, `processes[${index}].nonBlockingDrain`, issues, false),
191
200
  outputLines: normalizeOutputLines(source.outputLines, `processes[${index}].outputLines`, issues),
192
201
  policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
193
202
  port: normalizePortRange(source.port, `processes[${index}].port`, issues),
203
+ replicas: normalizeReplicas(source.replicas, `processes[${index}].replicas`, issues),
194
204
  restart: normalizeRestart(source.restart, `processes[${index}].restart`, issues),
195
- restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000})
205
+ restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000}),
206
+ stopSignal: normalizeStopSignal(source.stopSignal, `processes[${index}].stopSignal`, issues)
196
207
  }
197
208
  }
198
209
 
@@ -265,6 +276,88 @@ function normalizeBackoffFactor(value, key, issues) {
265
276
  return factor
266
277
  }
267
278
 
279
+ /**
280
+ * @param {JsonValue} value - Raw memory supervision config.
281
+ * @param {string} key - Config key.
282
+ * @param {ConfigIssue[]} issues - Issue collector.
283
+ * @returns {MemoryConfig | undefined} Normalized memory config, or undefined when monitoring is off.
284
+ */
285
+ function normalizeMemory(value, key, issues) {
286
+ if (value === undefined || value === null) return undefined
287
+
288
+ if (!isPlainObject(value)) {
289
+ issues.push({fix: `Set ${key} to a mapping with limitBytes (and optionally warnBytes, checkIntervalMs), or omit it to disable memory supervision.`, message: `${key} must be an object`})
290
+
291
+ return undefined
292
+ }
293
+
294
+ const limitBytes = normalizeNumber(value.limitBytes, `${key}.limitBytes`, issues, {default: 0})
295
+ const warnBytes = normalizeNumber(value.warnBytes, `${key}.warnBytes`, issues, {default: 0})
296
+ const checkIntervalMs = normalizeNumber(value.checkIntervalMs, `${key}.checkIntervalMs`, issues, {default: 5000})
297
+
298
+ if (!Number.isInteger(limitBytes) || limitBytes <= 0) {
299
+ issues.push({fix: `Set ${key}.limitBytes to a positive integer number of bytes, e.g. 536870912 (512 MiB).`, message: `${key}.limitBytes must be a positive integer`})
300
+ }
301
+
302
+ if (!Number.isInteger(warnBytes) || warnBytes < 0) {
303
+ issues.push({fix: `Set ${key}.warnBytes to a non-negative integer number of bytes below limitBytes, e.g. 402653184 (384 MiB).`, message: `${key}.warnBytes must be a non-negative integer`})
304
+ }
305
+
306
+ if (checkIntervalMs <= 0) {
307
+ issues.push({fix: `Set ${key}.checkIntervalMs to a positive number of milliseconds, e.g. 5000.`, message: `${key}.checkIntervalMs must be a positive number`})
308
+ }
309
+
310
+ return {checkIntervalMs, limitBytes, warnBytes}
311
+ }
312
+
313
+ /**
314
+ * @param {JsonValue} value - Raw lifecycle config.
315
+ * @param {string} key - Config key.
316
+ * @param {ConfigIssue[]} issues - Issue collector.
317
+ * @returns {LifecycleConfig} Normalized lifecycle hooks (no commands and a 0 drain by default).
318
+ */
319
+ function normalizeLifecycle(value, key, issues) {
320
+ if (value === undefined || value === null) return {drainTimeoutMs: 0}
321
+
322
+ if (!isPlainObject(value)) {
323
+ issues.push({fix: `Set ${key} to a mapping with optional quietCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
324
+
325
+ return {drainTimeoutMs: 0}
326
+ }
327
+
328
+ const drainTimeoutMs = normalizeNumber(value.drainTimeoutMs, `${key}.drainTimeoutMs`, issues, {default: 0})
329
+ /** @type {LifecycleConfig} */
330
+ const lifecycle = {drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)}
331
+
332
+ if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues)
333
+ if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues)
334
+ if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues)
335
+
336
+ if (lifecycle.drainCommand !== undefined && lifecycle.drainTimeoutMs <= 0) {
337
+ issues.push({fix: `Set ${key}.drainTimeoutMs to a positive number to bound ${key}.drainCommand; with 0 the drain step is skipped and the command never runs.`, message: `${key}.drainCommand requires a positive ${key}.drainTimeoutMs`})
338
+ }
339
+
340
+ return lifecycle
341
+ }
342
+
343
+ /**
344
+ * @param {JsonValue} value - Raw stop signal.
345
+ * @param {string} key - Config key.
346
+ * @param {ConfigIssue[]} issues - Issue collector.
347
+ * @returns {string} The graceful-stop signal name (default "SIGTERM").
348
+ */
349
+ function normalizeStopSignal(value, key, issues) {
350
+ if (value === undefined || value === null) return "SIGTERM"
351
+
352
+ if (typeof value === "string" && Object.prototype.hasOwnProperty.call(os.constants.signals, value)) {
353
+ return value
354
+ }
355
+
356
+ issues.push({fix: `Set ${key} to a signal name such as "SIGTERM", "SIGINT", or "SIGQUIT".`, message: `${key} must be a valid signal name`})
357
+
358
+ return "SIGTERM"
359
+ }
360
+
268
361
  /**
269
362
  * @param {JsonValue} value - Raw output retention value.
270
363
  * @param {string} key - Config key.
@@ -283,6 +376,40 @@ function normalizeOutputLines(value, key, issues) {
283
376
  return outputLines
284
377
  }
285
378
 
379
+ /**
380
+ * @param {JsonValue} value - Raw replica count.
381
+ * @param {string} key - Config key.
382
+ * @param {ConfigIssue[]} issues - Issue collector.
383
+ * @returns {number} Number of replicas to run (default 1).
384
+ */
385
+ function normalizeReplicas(value, key, issues) {
386
+ const replicas = normalizeNumber(value, key, issues, {default: 1})
387
+
388
+ if (!Number.isInteger(replicas) || replicas < 1) {
389
+ issues.push({fix: `Set ${key} to a positive integer number of replicas, e.g. 1 or 4.`, message: `${key} must be a positive integer`})
390
+
391
+ return 1
392
+ }
393
+
394
+ return replicas
395
+ }
396
+
397
+ /**
398
+ * @param {JsonValue} value - Raw boolean value.
399
+ * @param {string} key - Config key.
400
+ * @param {ConfigIssue[]} issues - Issue collector.
401
+ * @param {boolean} fallback - Default when unset.
402
+ * @returns {boolean} The boolean value, or the fallback when unset/invalid.
403
+ */
404
+ function normalizeBoolean(value, key, issues, fallback) {
405
+ if (value === undefined || value === null) return fallback
406
+ if (typeof value === "boolean") return value
407
+
408
+ issues.push({fix: `Set ${key} to true or false.`, message: `${key} must be a boolean`})
409
+
410
+ return fallback
411
+ }
412
+
286
413
  /**
287
414
  * @param {Record<string, JsonValue>} source - Raw release retention config.
288
415
  * @param {ConfigIssue[]} issues - Issue collector.
@@ -339,6 +466,26 @@ function normalizeSocketMode(value, key, issues) {
339
466
  return undefined
340
467
  }
341
468
 
469
+ /**
470
+ * @param {JsonValue} value - Raw owner/group value.
471
+ * @param {string} key - Config key.
472
+ * @param {ConfigIssue[]} issues - Issue collector.
473
+ * @returns {number | string | undefined} A numeric id, a name (resolved at bind time), or undefined when unset.
474
+ */
475
+ function normalizeControlPrincipal(value, key, issues) {
476
+ if (value === undefined || value === null) return undefined
477
+
478
+ if (typeof value === "number") {
479
+ if (Number.isInteger(value) && value >= 0) return value
480
+ } else if (typeof value === "string" && value.length > 0) {
481
+ return value
482
+ }
483
+
484
+ issues.push({fix: `Set ${key} to a non-negative integer id or a user/group name, e.g. "deploy".`, message: `${key} must be a non-negative integer id or a name`})
485
+
486
+ return undefined
487
+ }
488
+
342
489
  /**
343
490
  * Validates cross-process rules: unique ids, exactly one proxied process, and proxied ports.
344
491
  * @param {ProcessConfig[]} processes - Normalized processes.
@@ -356,6 +503,22 @@ function validateProcessSet(processes, issues) {
356
503
  }
357
504
 
358
505
  seenIds.add(processConfig.id)
506
+
507
+ if (processConfig.id.includes("#")) {
508
+ issues.push({fix: `Remove "#" from the process id "${processConfig.id}"; it is reserved for replica instance ids (e.g. "${processConfig.id.replace(/#/g, "")}#0").`, message: `Process id "${processConfig.id}" must not contain "#"`})
509
+ }
510
+
511
+ if (processConfig.replicas > 1 && (processConfig.policy !== "companion" || processConfig.port)) {
512
+ issues.push({fix: `Run replicas (${processConfig.replicas}) only on a companion process without a port; "${processConfig.id}" is ${processConfig.policy}${processConfig.port ? " with a port" : ""}.`, message: `Process "${processConfig.id}" can only set replicas > 1 on a companion process without a port`})
513
+ }
514
+
515
+ if (processConfig.nonBlockingDrain && processConfig.policy !== "companion") {
516
+ issues.push({fix: `Set nonBlockingDrain only on a companion process; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set nonBlockingDrain on a companion process`})
517
+ }
518
+
519
+ if (processConfig.lifecycle.stopCommand && processConfig.stopSignal !== "SIGTERM") {
520
+ issues.push({fix: `Drop the custom stopSignal "${processConfig.stopSignal}" or lifecycle.stopCommand from "${processConfig.id}": with a stopCommand set, Rollbridge runs it to stop the process instead of sending stopSignal, so the signal is never used.`, message: `Process "${processConfig.id}" sets both lifecycle.stopCommand and a custom stopSignal, but stopCommand replaces stopSignal`})
521
+ }
359
522
  }
360
523
 
361
524
  const proxiedProcesses = processes.filter((processConfig) => processConfig.policy === "proxied")