rollbridge 0.1.13 → 0.1.15

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.
Files changed (33) hide show
  1. package/README.md +6 -5
  2. package/compose.yml +17 -0
  3. package/docs/cli.md +28 -8
  4. package/docs/troubleshooting.md +18 -0
  5. package/package.json +1 -1
  6. package/src/cli.js +57 -18
  7. package/src/daemon-runtime.js +316 -0
  8. package/src/daemon.js +25 -4
  9. package/test/completion.test.js +1 -0
  10. package/test/daemon-bootstrap.test.js +40 -7
  11. package/test/daemon-runtime.test.js +90 -0
  12. package/test/fixtures/dummy-app.js +1 -1
  13. package/test/package-metadata.test.js +39 -0
  14. package/test/release-runtime-retention.test.js +357 -0
  15. package/test/rollbridge.test.js +3 -1
  16. package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +0 -28
  17. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +0 -13
  18. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +0 -1
  19. package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +0 -1
  20. package/tmp/worker-control/rollbridge-bootstrap/plan.md +0 -9
  21. package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +0 -3
  22. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +0 -25
  23. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +0 -1
  24. package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +0 -1
  25. package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +0 -14
  26. package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +0 -1
  27. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +0 -3
  28. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +0 -38
  29. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +0 -1
  30. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +0 -1
  31. package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +0 -1
  32. package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +0 -1
  33. package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +0 -1
package/README.md CHANGED
@@ -409,11 +409,12 @@ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
409
409
  ```
410
410
 
411
411
  The four bootstrap inputs are all-or-nothing and use absolute config/release
412
- paths. Rollbridge binds its proxy and control listeners, activates the release
413
- through the normal deploy path, and stays foreground. A failed activation stops
414
- only processes started by that attempt and exits non-zero; persisted processes
415
- from a previous daemon are reported as orphans and are never recovered or killed
416
- implicitly; their live PID records remain in `statePath` for explicit recovery.
412
+ paths. Rollbridge binds its proxy, activates the release through the normal
413
+ deploy path, then exposes the control socket and stays foreground. A failed
414
+ activation stops only processes started by that attempt and exits non-zero;
415
+ persisted processes from a previous daemon are reported as orphans and are never
416
+ recovered or killed implicitly; their live PID records remain in `statePath` for
417
+ explicit recovery.
417
418
 
418
419
  Start the daemon only when it is not already running:
419
420
 
package/compose.yml ADDED
@@ -0,0 +1,17 @@
1
+ # Canonical Rollbridge development service.
2
+ name: rollbridge
3
+
4
+ services:
5
+ dev:
6
+ image: threadwire-dev:local
7
+ entrypoint: ["/bin/sh", "-c"]
8
+ init: true
9
+ user: "1000:1000"
10
+ working_dir: /home/dev/rollbridge
11
+ environment:
12
+ GH_CONFIG_DIR: /home/dev/.config/gh
13
+ HOME: /home/dev
14
+ volumes:
15
+ - ${DEV_HOME_PATH:-/home/dev}:/home/dev
16
+ - ${GH_CONFIG_SOURCE_PATH:?Set GH_CONFIG_SOURCE_PATH in .env}:/home/dev/.config/gh:ro
17
+ command: ["exec sleep infinity"]
package/docs/cli.md CHANGED
@@ -35,11 +35,12 @@ such as systemd (see `examples/rollbridge.service`).
35
35
 
36
36
  For boot/crash recovery, pass an explicit absolute `--config` and all three
37
37
  release options together. Rollbridge validates every bootstrap input before it
38
- binds listeners or starts processes, then binds the listeners and activates that
39
- exact release through the same deploy path used by the control socket: services,
38
+ binds listeners or starts processes, then binds the proxy and activates that
39
+ exact release through the normal deploy path: services,
40
40
  companions, the proxied process and health check, traffic switching, singletons,
41
- and service-template refresh. It then remains in the foreground with the normal
42
- signal behavior.
41
+ and service-template refresh. Only after activation succeeds does it expose the
42
+ control socket, preventing another deployment from overlapping bootstrap. It
43
+ then remains in the foreground with the normal signal behavior.
43
44
 
44
45
  Bootstrap paths must be absolute and normalized, the release path must be an
45
46
  accessible directory, and release id/revision values accept letters, numbers,
@@ -47,10 +48,10 @@ dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
47
48
  or number). Supplying only some bootstrap options, or an invalid value, exits
48
49
  non-zero before listeners start. Activation failure emits a structured
49
50
  `bootstrap activation failed` event, cleans up processes owned by that attempt,
50
- and exits non-zero without inventing an active release. `statePath` entries from
51
- a previous daemon remain advisory orphans: bootstrap never runs recovery and
52
- never signals those processes, and retains their live PID records in `statePath`
53
- for explicit recovery.
51
+ and exits non-zero without exposing the control socket or inventing an active
52
+ release. `statePath` entries from a previous daemon remain advisory orphans:
53
+ bootstrap never runs recovery and never signals those processes, and retains
54
+ their live PID records in `statePath` for explicit recovery.
54
55
 
55
56
  With no release options, daemon behavior is unchanged: it starts listener-only
56
57
  and waits for control-socket deployments.
@@ -61,6 +62,7 @@ and waits for control-socket deployments.
61
62
  rollbridge ensure-daemon [--config <path>]
62
63
  [--daemon-log-path <path>]
63
64
  [--daemon-pid-path <path>]
65
+ [--daemon-runtime-path <path>]
64
66
  [--daemon-start-timeout-ms <ms>]
65
67
  ```
66
68
 
@@ -68,11 +70,24 @@ Starts the daemon as a detached process **only if** the control socket is not
68
70
  already accepting commands, waits until it responds, then prints the daemon
69
71
  status JSON. Idempotent — safe to call before every deploy.
70
72
 
73
+ Before starting a detached daemon, Rollbridge atomically copies its runtime code
74
+ and production dependency closure into a content-addressed directory outside
75
+ the invoking release. This keeps the long-lived daemon valid when deploy
76
+ retention removes that release. A responsive daemon is reused only when its
77
+ runtime identity matches the invoking Rollbridge installation; a legacy or
78
+ mismatched daemon causes the command to fail before any deploy is sent. Stop and
79
+ restart such a daemon explicitly during a safe maintenance handoff.
80
+
71
81
  - `--daemon-log-path <path>` — file the detached daemon's stdout/stderr is
72
82
  appended to. Default: `/tmp/rollbridge-<application>.log`. See
73
83
  [`logging.md`](logging.md) for the log format and rotation guidance.
74
84
  - `--daemon-pid-path <path>` — file the detached daemon's PID is written to.
75
85
  Default: `/tmp/rollbridge-<application>.pid`.
86
+ - `--daemon-runtime-path <path>` — parent directory for content-addressed daemon
87
+ runtime snapshots. Default:
88
+ `/tmp/rollbridge-<user-id>-<application-hash>-runtime`. The directory must be owned
89
+ by the current user and must not be group/world writable; preparation or
90
+ validation failure aborts before daemon startup or deploy handoff.
76
91
  - `--daemon-start-timeout-ms <ms>` — how long to wait for the daemon to accept
77
92
  control commands before failing. Default: `10000`.
78
93
 
@@ -86,6 +101,7 @@ rollbridge deploy --release-path <path>
86
101
  [--ensure-daemon]
87
102
  [--daemon-log-path <path>]
88
103
  [--daemon-pid-path <path>]
104
+ [--daemon-runtime-path <path>]
89
105
  [--daemon-start-timeout-ms <ms>]
90
106
  ```
91
107
 
@@ -154,6 +170,10 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
154
170
  `lastMemoryRestartAt`, and `children` (the process tree: each group member's
155
171
  `pid`, `command`, and `rssBytes`).
156
172
 
173
+ `daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
174
+ its runtime `format`, package `version`, content `digest`, and absolute `path`.
175
+ `ensure-daemon` uses this attestation before reusing a responsive daemon.
176
+
157
177
  When [`statePath`](config.md#statepath) is configured, status also includes an
158
178
  `orphans` array: managed processes from a **previous** daemon that are still
159
179
  alive (`id`, `pid`, `releaseId`) — for example after the daemon restarted but its
@@ -1,5 +1,23 @@
1
1
  # Troubleshooting
2
2
 
3
+ ## Legacy or mismatched daemon runtime
4
+
5
+ **Symptom.** `deploy --ensure-daemon` or `ensure-daemon` reports that the
6
+ running daemon has a legacy or mismatched runtime and confirms that the deploy
7
+ was not sent.
8
+
9
+ **Cause.** A daemon already owns the stable proxy/control socket, but it cannot
10
+ attest to the same immutable Rollbridge runtime as the CLI preparing the deploy.
11
+ Rollbridge does not silently restart it because rebinding the proxy could cause
12
+ downtime or abandon managed processes.
13
+
14
+ **Fix.** Keep the current release active, explicitly stop and restart the daemon
15
+ with the intended Rollbridge installation during a safe handoff, then retry the
16
+ deploy. If durable runtime preparation itself fails, check permissions for
17
+ `--daemon-runtime-path` (default
18
+ `/tmp/rollbridge-<user-id>-<application-hash>-runtime`) before retrying. The directory
19
+ must be private to the invoking user.
20
+
3
21
  Start with these three commands — they diagnose most problems without guessing:
4
22
 
5
23
  - `rollbridge validate` — config errors, with an example fix for each.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/cli.js CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  import fs from "node:fs"
4
4
  import fsPromises from "node:fs/promises"
5
+ import {createHash} from "node:crypto"
5
6
  import path from "node:path"
6
7
  import {spawn} from "node:child_process"
7
8
  import {Command} from "commander"
8
9
  import RollbridgeDaemon from "./daemon.js"
10
+ import {loadDaemonRuntimeIdentity, prepareDaemonRuntime} from "./daemon-runtime.js"
9
11
  import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
10
12
  import {runEnvironmentChecks, runReleaseChecks} from "./doctor.js"
11
13
  import {predeployCleanup} from "./predeploy-cleanup.js"
@@ -37,9 +39,10 @@ export async function runCli(argv) {
37
39
  const bootstrap = await validateDaemonBootstrapOptions(options)
38
40
  const configPath = await resolveConfigPath(options.config)
39
41
  const config = await loadConfig(configPath)
40
- const daemon = new RollbridgeDaemon({config, configPath})
42
+ const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
43
+ const daemon = new RollbridgeDaemon({config, configPath, runtime})
41
44
 
42
- await daemon.start()
45
+ await daemon.start({exposeControl: !bootstrap})
43
46
 
44
47
  const shutdown = async () => {
45
48
  await daemon.shutdown()
@@ -52,6 +55,7 @@ export async function runCli(argv) {
52
55
  if (bootstrap) {
53
56
  try {
54
57
  await daemon.deploy(bootstrap)
58
+ await daemon.exposeControl()
55
59
  } catch {
56
60
  daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
57
61
  await daemon.shutdown()
@@ -70,6 +74,7 @@ export async function runCli(argv) {
70
74
  .option("--ensure-daemon", "Start the Rollbridge daemon if it is not already running")
71
75
  .option("--daemon-log-path <path>", "Log path used when --ensure-daemon starts the daemon")
72
76
  .option("--daemon-pid-path <path>", "PID file path used when --ensure-daemon starts the daemon")
77
+ .option("--daemon-runtime-path <path>", "Directory for durable daemon runtime snapshots")
73
78
  .option("--daemon-start-timeout-ms <ms>", "How long to wait for an ensured daemon to accept control commands")
74
79
  .action(async (options) => {
75
80
  const configPath = await resolveConfigPath(options.config)
@@ -77,11 +82,11 @@ export async function runCli(argv) {
77
82
 
78
83
  if (options.ensureDaemon) {
79
84
  await ensureDaemonRunning({
80
- argv,
81
85
  config,
82
86
  configPath,
83
87
  logPath: options.daemonLogPath,
84
88
  pidPath: options.daemonPidPath,
89
+ runtimePath: options.daemonRuntimePath,
85
90
  timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
86
91
  })
87
92
  }
@@ -124,16 +129,17 @@ export async function runCli(argv) {
124
129
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
125
130
  .option("--daemon-log-path <path>", "Daemon log path")
126
131
  .option("--daemon-pid-path <path>", "Daemon PID file path")
132
+ .option("--daemon-runtime-path <path>", "Directory for durable daemon runtime snapshots")
127
133
  .option("--daemon-start-timeout-ms <ms>", "How long to wait for the daemon to accept control commands")
128
134
  .action(async (options) => {
129
135
  const configPath = await resolveConfigPath(options.config)
130
136
  const config = await loadConfig(configPath)
131
137
  const response = await ensureDaemonRunning({
132
- argv,
133
138
  config,
134
139
  configPath,
135
140
  logPath: options.daemonLogPath,
136
141
  pidPath: options.daemonPidPath,
142
+ runtimePath: options.daemonRuntimePath,
137
143
  timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
138
144
  })
139
145
 
@@ -757,27 +763,34 @@ async function validateDaemonBootstrapOptions(options) {
757
763
  /**
758
764
  * Starts a daemon when needed and waits until it accepts status commands.
759
765
  * @param {object} args - Options.
760
- * @param {string[]} args.argv - Original CLI argv.
761
766
  * @param {import("./config.js").RollbridgeConfig} args.config - Loaded config.
762
767
  * @param {string} args.configPath - Config path.
763
768
  * @param {string | undefined} args.logPath - Optional daemon log path.
764
769
  * @param {string | undefined} args.pidPath - Optional daemon PID path.
770
+ * @param {string | undefined} args.runtimePath - Optional durable runtime directory.
765
771
  * @param {number} args.timeoutMs - Startup timeout.
766
772
  * @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
767
773
  */
768
- async function ensureDaemonRunning({argv, config, configPath, logPath, pidPath, timeoutMs}) {
774
+ async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtimePath, timeoutMs}) {
775
+ const runtime = await prepareDaemonRuntime(runtimePath || defaultDaemonRuntimePath(config))
769
776
  const existingStatus = await daemonStatus(config)
770
777
 
771
- if (existingStatus) return existingStatus
778
+ if (existingStatus) {
779
+ assertCompatibleDaemonRuntime(existingStatus, runtime)
780
+ return existingStatus
781
+ }
772
782
 
773
783
  await startDaemonProcess({
774
- argv,
775
784
  configPath,
776
785
  logPath: logPath || defaultDaemonLogPath(config),
777
- pidPath: pidPath || defaultDaemonPidPath(config)
786
+ pidPath: pidPath || defaultDaemonPidPath(config),
787
+ runtime
778
788
  })
779
789
 
780
- return await waitForDaemonStatus(config, timeoutMs)
790
+ const startedStatus = await waitForDaemonStatus(config, timeoutMs)
791
+
792
+ assertCompatibleDaemonRuntime(startedStatus, runtime)
793
+ return startedStatus
781
794
  }
782
795
 
783
796
  /**
@@ -804,17 +817,13 @@ async function daemonStatus(config) {
804
817
  /**
805
818
  * Starts the foreground daemon command as a detached child.
806
819
  * @param {object} args - Options.
807
- * @param {string[]} args.argv - Original CLI argv.
808
820
  * @param {string} args.configPath - Config path.
809
821
  * @param {string} args.logPath - Log file path.
810
822
  * @param {string} args.pidPath - PID file path.
823
+ * @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} args.runtime - Prepared runtime.
811
824
  * @returns {Promise<void>} Resolves after the child has been spawned.
812
825
  */
813
- async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
814
- const binPath = argv[1] || process.argv[1]
815
-
816
- if (!binPath) throw new Error("Unable to determine Rollbridge CLI path for daemon startup")
817
-
826
+ async function startDaemonProcess({configPath, logPath, pidPath, runtime}) {
818
827
  await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
819
828
  await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
820
829
 
@@ -822,9 +831,9 @@ async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
822
831
  const stderrFd = fs.openSync(logPath, "a")
823
832
 
824
833
  try {
825
- const child = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {
834
+ const child = spawn(process.execPath, [path.join(runtime.path, "bin", "rollbridge"), "daemon", "--config", configPath], {
826
835
  detached: true,
827
- env: process.env,
836
+ env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtime.path, "runtime.json")},
828
837
  stdio: ["ignore", stdoutFd, stderrFd]
829
838
  })
830
839
 
@@ -882,6 +891,36 @@ function defaultDaemonPidPath(config) {
882
891
  return `/tmp/rollbridge-${config.application}.pid`
883
892
  }
884
893
 
894
+ /**
895
+ * @param {import("./config.js").RollbridgeConfig} config - Loaded config.
896
+ * @returns {string} Default durable daemon runtime directory.
897
+ */
898
+ function defaultDaemonRuntimePath(config) {
899
+ const userId = typeof process.getuid === "function" ? process.getuid() : "user"
900
+ const applicationHash = createHash("sha256").update(config.application).digest("hex").slice(0, 16)
901
+
902
+ return `/tmp/rollbridge-${userId}-${applicationHash}-runtime`
903
+ }
904
+
905
+ /**
906
+ * Refuses to hand a deploy to a daemon whose immutable runtime does not match this CLI.
907
+ * @param {Record<string, import("./json.js").JsonValue>} status - Existing daemon status.
908
+ * @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} expected - Expected runtime.
909
+ * @returns {void}
910
+ */
911
+ function assertCompatibleDaemonRuntime(status, expected) {
912
+ const runtime = status.daemonRuntime
913
+ const compatible = runtime && typeof runtime === "object" && !Array.isArray(runtime) &&
914
+ runtime.format === expected.format && runtime.version === expected.version && runtime.digest === expected.digest
915
+
916
+ if (compatible) return
917
+
918
+ throw new Error(
919
+ "The running Rollbridge daemon has a legacy or mismatched runtime. " +
920
+ "The deploy was not sent. Keep the current release active, then explicitly stop and restart the daemon with this Rollbridge installation before retrying."
921
+ )
922
+ }
923
+
885
924
  /**
886
925
  * @param {string | undefined} value - Raw timeout value.
887
926
  * @returns {number} Timeout in milliseconds.
@@ -0,0 +1,316 @@
1
+ // @ts-check
2
+
3
+ import {createHash} from "node:crypto"
4
+ import {execFile} from "node:child_process"
5
+ import fs from "node:fs/promises"
6
+ import {createRequire} from "node:module"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import {promisify} from "node:util"
10
+ import {fileURLToPath} from "node:url"
11
+
12
+ const RUNTIME_FORMAT = 1
13
+ const execFileAsync = promisify(execFile)
14
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
15
+
16
+ /**
17
+ * @typedef {{digest: string, format: number, path: string, version: string}} DaemonRuntimeIdentity
18
+ */
19
+
20
+ /**
21
+ * Atomically prepares a content-addressed Rollbridge runtime outside the package tree.
22
+ * @param {string} basePath - Stable parent directory for runtime snapshots.
23
+ * @returns {Promise<DaemonRuntimeIdentity>} Prepared runtime identity.
24
+ */
25
+ export async function prepareDaemonRuntime(basePath) {
26
+ await fs.mkdir(basePath, {mode: 0o700, recursive: true})
27
+ await validateRuntimeBase(basePath)
28
+
29
+ const stagingPath = await fs.mkdtemp(path.join(basePath, ".prepare-"))
30
+
31
+ try {
32
+ await copyPackageClosure(packageRoot, stagingPath, new Set(), true)
33
+ const digest = await directoryDigest(stagingPath)
34
+ const version = await packageVersion(stagingPath)
35
+ const runtimePath = path.join(basePath, digest)
36
+ const identity = {digest, format: RUNTIME_FORMAT, path: runtimePath, version}
37
+
38
+ await fs.writeFile(path.join(stagingPath, "runtime.json"), `${JSON.stringify(identity, null, 2)}\n`, {mode: 0o600})
39
+ await validateRuntime(stagingPath, identity)
40
+
41
+ try {
42
+ await fs.rename(stagingPath, runtimePath)
43
+ } catch (error) {
44
+ const fileError = /** @type {Error & {code?: string}} */ (error)
45
+
46
+ if (!hasCode(fileError, "EEXIST") && !hasCode(fileError, "ENOTEMPTY")) throw error
47
+ await validateRuntime(runtimePath, identity)
48
+ await fs.rm(stagingPath, {force: true, recursive: true})
49
+ }
50
+
51
+ await validateRuntime(runtimePath, identity)
52
+ return identity
53
+ } catch (error) {
54
+ await fs.rm(stagingPath, {force: true, recursive: true}).catch(() => {})
55
+ throw error
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Rejects a runtime path beneath any ancestor that another local user could replace.
61
+ * @param {string} basePath - Runtime parent directory.
62
+ * @returns {Promise<void>} Resolves when the full path is safely anchored.
63
+ */
64
+ async function validateRuntimeBase(basePath) {
65
+ const absolutePath = path.resolve(basePath)
66
+ const rootPath = path.parse(absolutePath).root
67
+ const relativeParts = path.relative(rootPath, absolutePath).split(path.sep).filter(Boolean)
68
+ const paths = [rootPath]
69
+
70
+ for (const part of relativeParts) paths.push(path.join(paths.at(-1) || rootPath, part))
71
+
72
+ for (const candidatePath of paths) {
73
+ const stats = await fs.lstat(candidatePath)
74
+
75
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
76
+ throw new Error(`Every Rollbridge daemon runtime path component must be a real directory: ${candidatePath}`)
77
+ }
78
+
79
+ if (typeof process.getuid !== "function") continue
80
+
81
+ const ownedByTrustedUser = stats.uid === 0 || stats.uid === process.getuid()
82
+ const writableByOthers = (stats.mode & 0o022) !== 0
83
+ const sticky = (stats.mode & 0o1000) !== 0
84
+
85
+ if (candidatePath !== absolutePath && !ownedByTrustedUser) {
86
+ throw new Error(`Rollbridge daemon runtime ancestor must be owned by root or the current user: ${candidatePath}`)
87
+ }
88
+
89
+ if (candidatePath !== absolutePath && writableByOthers && !sticky) {
90
+ throw new Error(`Rollbridge daemon runtime ancestor must be sticky or not writable by group or other users: ${candidatePath}`)
91
+ }
92
+ }
93
+
94
+ const stats = await fs.lstat(absolutePath)
95
+
96
+ if (typeof process.getuid === "function" && stats.uid !== process.getuid()) {
97
+ throw new Error(`Rollbridge daemon runtime path must be owned by the current user: ${absolutePath}`)
98
+ }
99
+
100
+ if ((stats.mode & 0o022) !== 0) {
101
+ throw new Error(`Rollbridge daemon runtime path must not be writable by group or other users: ${absolutePath}`)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Reads and validates the runtime identity supplied to a detached daemon.
107
+ * @param {string | undefined} manifestPath - Runtime manifest path.
108
+ * @returns {Promise<DaemonRuntimeIdentity>} Validated identity.
109
+ */
110
+ export async function loadDaemonRuntimeIdentity(manifestPath) {
111
+ if (!manifestPath) return await currentPackageIdentity()
112
+
113
+ const runtimePath = path.dirname(manifestPath)
114
+ const identity = parseIdentity(JSON.parse(await fs.readFile(manifestPath, "utf8")))
115
+
116
+ if (path.resolve(identity.path) !== path.resolve(runtimePath)) {
117
+ throw new Error(`Daemon runtime manifest path mismatch: expected ${runtimePath}, got ${identity.path}`)
118
+ }
119
+
120
+ await validateRuntime(runtimePath, identity)
121
+ return identity
122
+ }
123
+
124
+ /**
125
+ * @returns {Promise<DaemonRuntimeIdentity>} Identity of the package running a foreground daemon.
126
+ */
127
+ export async function currentPackageIdentity() {
128
+ return {
129
+ digest: await packageClosureDigest(packageRoot),
130
+ format: RUNTIME_FORMAT,
131
+ path: packageRoot,
132
+ version: await packageVersion(packageRoot)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * @param {string} sourcePath - Source package directory.
138
+ * @param {string} destinationPath - Destination package directory.
139
+ * @param {Set<string>} ancestry - Real package paths in the current dependency chain.
140
+ * @param {boolean} root - Whether this is Rollbridge itself.
141
+ * @returns {Promise<void>} Resolves when copied.
142
+ */
143
+ async function copyPackageClosure(sourcePath, destinationPath, ancestry, root) {
144
+ const realSourcePath = await fs.realpath(sourcePath)
145
+
146
+ if (ancestry.has(realSourcePath)) return
147
+
148
+ const nextAncestry = new Set(ancestry).add(realSourcePath)
149
+ const packageJson = JSON.parse(await fs.readFile(path.join(realSourcePath, "package.json"), "utf8"))
150
+
151
+ await fs.mkdir(destinationPath, {recursive: true})
152
+
153
+ if (root) {
154
+ for (const entry of ["bin", "src", "package.json"]) {
155
+ await fs.cp(path.join(realSourcePath, entry), path.join(destinationPath, entry), {dereference: true, recursive: true})
156
+ }
157
+ } else {
158
+ await fs.cp(realSourcePath, destinationPath, {
159
+ dereference: true,
160
+ filter: (source) => path.relative(realSourcePath, source).split(path.sep)[0] !== "node_modules",
161
+ recursive: true
162
+ })
163
+ }
164
+
165
+ for (const dependency of Object.keys(packageJson.dependencies || {}).sort()) {
166
+ const dependencySource = await resolvePackageRoot(realSourcePath, dependency)
167
+ const dependencyDestination = path.join(destinationPath, "node_modules", ...dependency.split("/"))
168
+
169
+ await copyPackageClosure(dependencySource, dependencyDestination, nextAncestry, false)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @param {string} parentPackagePath - Requiring package root.
175
+ * @param {string} dependency - Dependency package name.
176
+ * @returns {Promise<string>} Resolved dependency package root.
177
+ */
178
+ async function resolvePackageRoot(parentPackagePath, dependency) {
179
+ const require = createRequire(path.join(parentPackagePath, "package.json"))
180
+ let candidate = path.dirname(require.resolve(dependency))
181
+
182
+ while (candidate !== path.dirname(candidate)) {
183
+ try {
184
+ const metadata = JSON.parse(await fs.readFile(path.join(candidate, "package.json"), "utf8"))
185
+
186
+ if (metadata.name === dependency) return candidate
187
+ } catch (error) {
188
+ if (!hasCode(/** @type {Error & {code?: string}} */ (error), "ENOENT")) throw error
189
+ }
190
+
191
+ candidate = path.dirname(candidate)
192
+ }
193
+
194
+ throw new Error(`Unable to resolve package root for Rollbridge runtime dependency ${dependency}`)
195
+ }
196
+
197
+ /**
198
+ * @param {string} runtimePath - Runtime directory.
199
+ * @param {DaemonRuntimeIdentity} identity - Expected identity.
200
+ * @returns {Promise<void>} Resolves when valid.
201
+ */
202
+ async function validateRuntime(runtimePath, identity) {
203
+ const manifestIdentity = parseIdentity(JSON.parse(await fs.readFile(path.join(runtimePath, "runtime.json"), "utf8")))
204
+ const actualDigest = await directoryDigest(runtimePath, new Set(["runtime.json"]))
205
+ const actualVersion = await packageVersion(runtimePath)
206
+ const manifestMatches = manifestIdentity.format === identity.format && manifestIdentity.digest === identity.digest &&
207
+ manifestIdentity.version === identity.version && path.resolve(manifestIdentity.path) === path.resolve(identity.path)
208
+
209
+ if (!manifestMatches || identity.format !== RUNTIME_FORMAT || actualDigest !== identity.digest || actualVersion !== identity.version) {
210
+ throw new Error(`Rollbridge daemon runtime validation failed at ${runtimePath}`)
211
+ }
212
+
213
+ await execFileAsync(process.execPath, [path.join(runtimePath, "bin", "rollbridge"), "--help"], {
214
+ env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtimePath, "runtime.json")},
215
+ timeout: 10000
216
+ })
217
+ }
218
+
219
+ /**
220
+ * @param {string} sourcePath - Package root.
221
+ * @returns {Promise<string>} Closure digest.
222
+ */
223
+ async function packageClosureDigest(sourcePath) {
224
+ const temporaryPath = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-digest-"))
225
+
226
+ try {
227
+ await copyPackageClosure(sourcePath, temporaryPath, new Set(), true)
228
+ return await directoryDigest(temporaryPath)
229
+ } finally {
230
+ await fs.rm(temporaryPath, {force: true, recursive: true})
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param {string} rootPath - Directory to hash.
236
+ * @param {Set<string>} [ignored] - Root-relative paths to ignore.
237
+ * @returns {Promise<string>} SHA-256 digest.
238
+ */
239
+ async function directoryDigest(rootPath, ignored = new Set()) {
240
+ const hash = createHash("sha256")
241
+
242
+ for (const relativePath of await listFiles(rootPath)) {
243
+ if (ignored.has(relativePath)) continue
244
+ const filePath = path.join(rootPath, relativePath)
245
+ hash.update(relativePath)
246
+ hash.update("\0")
247
+ hash.update(await fs.readFile(filePath))
248
+ hash.update("\0")
249
+ }
250
+
251
+ return hash.digest("hex")
252
+ }
253
+
254
+ /**
255
+ * @param {string} rootPath - Directory to walk.
256
+ * @returns {Promise<string[]>} Sorted files.
257
+ */
258
+ async function listFiles(rootPath) {
259
+ /** @type {string[]} */
260
+ const files = []
261
+
262
+ /**
263
+ * @param {string} relativePath - Root-relative directory.
264
+ * @returns {Promise<void>} Resolves after walking the directory.
265
+ */
266
+ async function walk(relativePath) {
267
+ const entries = await fs.readdir(path.join(rootPath, relativePath), {withFileTypes: true})
268
+
269
+ for (const entry of entries.sort((first, second) => first.name.localeCompare(second.name))) {
270
+ const childPath = path.join(relativePath, entry.name)
271
+
272
+ if (entry.isDirectory()) await walk(childPath)
273
+ else if (entry.isFile()) files.push(childPath)
274
+ else throw new Error(`Unsupported entry in Rollbridge runtime: ${path.join(rootPath, childPath)}`)
275
+ }
276
+ }
277
+
278
+ await walk("")
279
+ return files
280
+ }
281
+
282
+ /**
283
+ * @param {string} rootPath - Package root.
284
+ * @returns {Promise<string>} Package version.
285
+ */
286
+ async function packageVersion(rootPath) {
287
+ const metadata = JSON.parse(await fs.readFile(path.join(rootPath, "package.json"), "utf8"))
288
+
289
+ if (typeof metadata.version !== "string" || metadata.version.length === 0) throw new Error("Rollbridge package version is missing")
290
+ return metadata.version
291
+ }
292
+
293
+ /**
294
+ * @param {import("./json.js").JsonValue} value - Parsed manifest.
295
+ * @returns {DaemonRuntimeIdentity} Valid identity.
296
+ */
297
+ function parseIdentity(value) {
298
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Rollbridge daemon runtime manifest")
299
+
300
+ const identity = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
301
+
302
+ if (identity.format !== RUNTIME_FORMAT || typeof identity.digest !== "string" || !/^[a-f0-9]{64}$/.test(identity.digest) || typeof identity.path !== "string" || typeof identity.version !== "string") {
303
+ throw new Error("Invalid Rollbridge daemon runtime manifest")
304
+ }
305
+
306
+ return {digest: identity.digest, format: identity.format, path: identity.path, version: identity.version}
307
+ }
308
+
309
+ /**
310
+ * @param {Error & {code?: string} | null | undefined} error - Error.
311
+ * @param {string} code - Error code.
312
+ * @returns {boolean} Whether it matches.
313
+ */
314
+ function hasCode(error, code) {
315
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === code)
316
+ }