@theford/runly 0.0.0-stage → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/dist/cli/client.d.ts +80 -0
  4. package/dist/cli/client.js +294 -0
  5. package/dist/cli/client.js.map +1 -0
  6. package/dist/cli/daemon-unit.d.ts +58 -0
  7. package/dist/cli/daemon-unit.js +289 -0
  8. package/dist/cli/daemon-unit.js.map +1 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +1482 -0
  11. package/dist/cli/index.js.map +1 -0
  12. package/dist/cli/install.d.ts +9 -0
  13. package/dist/cli/install.js +53 -0
  14. package/dist/cli/install.js.map +1 -0
  15. package/dist/cli/logs.d.ts +43 -0
  16. package/dist/cli/logs.js +232 -0
  17. package/dist/cli/logs.js.map +1 -0
  18. package/dist/cli/tether.d.ts +3 -0
  19. package/dist/cli/tether.js +65 -0
  20. package/dist/cli/tether.js.map +1 -0
  21. package/dist/cli/watchdog.d.ts +1 -0
  22. package/dist/cli/watchdog.js +81 -0
  23. package/dist/cli/watchdog.js.map +1 -0
  24. package/dist/core/builds.d.ts +20 -0
  25. package/dist/core/builds.js +93 -0
  26. package/dist/core/builds.js.map +1 -0
  27. package/dist/core/caller-env.d.ts +17 -0
  28. package/dist/core/caller-env.js +77 -0
  29. package/dist/core/caller-env.js.map +1 -0
  30. package/dist/core/diagnostics.d.ts +68 -0
  31. package/dist/core/diagnostics.js +34 -0
  32. package/dist/core/diagnostics.js.map +1 -0
  33. package/dist/core/env-vars.d.ts +35 -0
  34. package/dist/core/env-vars.js +32 -0
  35. package/dist/core/env-vars.js.map +1 -0
  36. package/dist/core/events.d.ts +10 -0
  37. package/dist/core/events.js +42 -0
  38. package/dist/core/events.js.map +1 -0
  39. package/dist/core/exec.d.ts +22 -0
  40. package/dist/core/exec.js +84 -0
  41. package/dist/core/exec.js.map +1 -0
  42. package/dist/core/journal.d.ts +227 -0
  43. package/dist/core/journal.js +395 -0
  44. package/dist/core/journal.js.map +1 -0
  45. package/dist/core/logs.d.ts +56 -0
  46. package/dist/core/logs.js +259 -0
  47. package/dist/core/logs.js.map +1 -0
  48. package/dist/core/manifest.d.ts +292 -0
  49. package/dist/core/manifest.js +179 -0
  50. package/dist/core/manifest.js.map +1 -0
  51. package/dist/core/namespaces.d.ts +7 -0
  52. package/dist/core/namespaces.js +59 -0
  53. package/dist/core/namespaces.js.map +1 -0
  54. package/dist/core/paths.d.ts +31 -0
  55. package/dist/core/paths.js +65 -0
  56. package/dist/core/paths.js.map +1 -0
  57. package/dist/core/policy.d.ts +94 -0
  58. package/dist/core/policy.js +122 -0
  59. package/dist/core/policy.js.map +1 -0
  60. package/dist/core/ports.d.ts +52 -0
  61. package/dist/core/ports.js +94 -0
  62. package/dist/core/ports.js.map +1 -0
  63. package/dist/core/presets.d.ts +25 -0
  64. package/dist/core/presets.js +86 -0
  65. package/dist/core/presets.js.map +1 -0
  66. package/dist/core/procscan.d.ts +111 -0
  67. package/dist/core/procscan.js +342 -0
  68. package/dist/core/procscan.js.map +1 -0
  69. package/dist/core/retention.d.ts +97 -0
  70. package/dist/core/retention.js +322 -0
  71. package/dist/core/retention.js.map +1 -0
  72. package/dist/core/sleep.d.ts +51 -0
  73. package/dist/core/sleep.js +99 -0
  74. package/dist/core/sleep.js.map +1 -0
  75. package/dist/core/tree-ledger.d.ts +39 -0
  76. package/dist/core/tree-ledger.js +92 -0
  77. package/dist/core/tree-ledger.js.map +1 -0
  78. package/dist/core/types.d.ts +21 -0
  79. package/dist/core/types.js +6 -0
  80. package/dist/core/types.js.map +1 -0
  81. package/dist/core/units.d.ts +20 -0
  82. package/dist/core/units.js +69 -0
  83. package/dist/core/units.js.map +1 -0
  84. package/dist/core/upkeep.d.ts +90 -0
  85. package/dist/core/upkeep.js +176 -0
  86. package/dist/core/upkeep.js.map +1 -0
  87. package/dist/core/util.d.ts +46 -0
  88. package/dist/core/util.js +168 -0
  89. package/dist/core/util.js.map +1 -0
  90. package/dist/core/version.d.ts +39 -0
  91. package/dist/core/version.js +144 -0
  92. package/dist/core/version.js.map +1 -0
  93. package/dist/core/worktree.d.ts +51 -0
  94. package/dist/core/worktree.js +309 -0
  95. package/dist/core/worktree.js.map +1 -0
  96. package/dist/daemon/budget.d.ts +155 -0
  97. package/dist/daemon/budget.js +288 -0
  98. package/dist/daemon/budget.js.map +1 -0
  99. package/dist/daemon/election.d.ts +9 -0
  100. package/dist/daemon/election.js +140 -0
  101. package/dist/daemon/election.js.map +1 -0
  102. package/dist/daemon/engine.d.ts +1703 -0
  103. package/dist/daemon/engine.js +5305 -0
  104. package/dist/daemon/engine.js.map +1 -0
  105. package/dist/daemon/index.d.ts +2 -0
  106. package/dist/daemon/index.js +412 -0
  107. package/dist/daemon/index.js.map +1 -0
  108. package/dist/daemon/proxy.d.ts +108 -0
  109. package/dist/daemon/proxy.js +615 -0
  110. package/dist/daemon/proxy.js.map +1 -0
  111. package/dist/daemon/supervisor.d.ts +101 -0
  112. package/dist/daemon/supervisor.js +493 -0
  113. package/dist/daemon/supervisor.js.map +1 -0
  114. package/dist/drivers/appliances.d.ts +11 -0
  115. package/dist/drivers/appliances.js +174 -0
  116. package/dist/drivers/appliances.js.map +1 -0
  117. package/dist/drivers/datastores.d.ts +144 -0
  118. package/dist/drivers/datastores.js +775 -0
  119. package/dist/drivers/datastores.js.map +1 -0
  120. package/dist/drivers/preview.d.ts +69 -0
  121. package/dist/drivers/preview.js +605 -0
  122. package/dist/drivers/preview.js.map +1 -0
  123. package/dist/drivers/types.d.ts +70 -0
  124. package/dist/drivers/types.js +13 -0
  125. package/dist/drivers/types.js.map +1 -0
  126. package/package.json +63 -4
  127. package/schema/backlot.schema.json +549 -0
  128. package/schema/runly.schema.json +556 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * CLI-side client: auto-spawn the daemon on first use (tmux/Docker pattern,
3
+ * decision 0009) and speak JSON over the unix socket.
4
+ */
5
+ import { request } from 'node:http';
6
+ import { spawn } from 'node:child_process';
7
+ import { openSync, statSync, readSync, closeSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { installedUnit, startUnit, unitRunsThisBuild } from './daemon-unit.js';
11
+ import { socketPath, stateRoot } from '../core/paths.js';
12
+ import { isAlive } from '../core/procscan.js';
13
+ import { collectCallerEnv } from '../core/caller-env.js';
14
+ /** A wedged daemon must not hang a caller forever. Overridable for tests. */
15
+ const RPC_TIMEOUT_MS = () => Number(process.env.BACKLOT_RPC_TIMEOUT_MS ?? 15 * 60_000);
16
+ /**
17
+ * Classify a client-side failure for the exit-code contract (decision 0010).
18
+ *
19
+ * Agents branch on the class mechanically, so an unreachable or wedged daemon
20
+ * must NOT report env-error: that tells the agent to recycle an environment,
21
+ * which cannot fix a broken daemon. Anything that never became a classified
22
+ * daemon response is infrastructure.
23
+ */
24
+ export function classifyClientError(err) {
25
+ const tagged = err?.backlotClass;
26
+ if (tagged === 'infra-error' || tagged === 'env-error')
27
+ return tagged;
28
+ const msg = String(err?.message ?? err);
29
+ return /daemon (did not|closed|is not)|ECONNREFUSED|ENOENT|EACCES|EPIPE|ECONNRESET|socket/i.test(msg)
30
+ ? 'infra-error'
31
+ : 'env-error';
32
+ }
33
+ /**
34
+ * The daemon streams newline-delimited frames: {type:'progress',phase} … then
35
+ * one {type:'result',ok,…}. `onProgress` (optional) sees each phase; the
36
+ * promise resolves on the result frame. Consumers that ignore progress (for example,
37
+ * ping, most verbs) just don't pass it — the frames are consumed and dropped.
38
+ */
39
+ export function rpc(verb, args, onProgress, deadlineMs) {
40
+ return new Promise((resolve, reject) => {
41
+ const req = request({ socketPath: socketPath(), path: '/rpc', method: 'POST', headers: { 'content-type': 'application/json' } }, (res) => {
42
+ let buf = '';
43
+ let result;
44
+ res.on('data', (d) => {
45
+ buf += d;
46
+ let nl;
47
+ while ((nl = buf.indexOf('\n')) >= 0) {
48
+ const line = buf.slice(0, nl);
49
+ buf = buf.slice(nl + 1);
50
+ if (!line.trim())
51
+ continue;
52
+ let frame;
53
+ try {
54
+ frame = JSON.parse(line);
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ if (frame.type === 'progress')
60
+ onProgress?.(String(frame.phase ?? ''));
61
+ else if (frame.type === 'result') {
62
+ result = frame.ok ? { ok: true, data: frame.data } : { ok: false, error: frame.error };
63
+ }
64
+ else {
65
+ // Back-compat: an un-typed object is a bare result.
66
+ result = frame;
67
+ }
68
+ }
69
+ });
70
+ res.on('end', () => (result ? resolve(result) : reject(new Error('daemon closed the stream without a result frame'))));
71
+ });
72
+ req.on('error', reject);
73
+ // setTimeout ALONE is inert: Node emits 'timeout' and does nothing else, so
74
+ // a wedged daemon left the CLI (and any agent driving it) hanging forever.
75
+ // Destroying the socket is what turns the deadline into a real one.
76
+ req.setTimeout(RPC_TIMEOUT_MS(), () => {
77
+ req.destroy(Object.assign(new Error(`daemon did not respond to '${verb}' within ${Math.round(RPC_TIMEOUT_MS() / 1000)}s — it may be wedged; check ${join(stateRoot(), 'daemon.log')}`), {
78
+ backlotClass: 'infra-error',
79
+ }));
80
+ });
81
+ // Shutdown polling has its own wall-clock budget. A stalled or streaming
82
+ // ping must not extend it to the ordinary RPC inactivity timeout.
83
+ if (deadlineMs !== undefined) {
84
+ const deadline = setTimeout(() => req.destroy(Object.assign(new Error(`daemon did not respond to '${verb}' before the deadline`), {
85
+ backlotClass: 'infra-error',
86
+ })), deadlineMs);
87
+ deadline.unref();
88
+ req.once('close', () => clearTimeout(deadline));
89
+ }
90
+ req.end(JSON.stringify({ verb, args }));
91
+ });
92
+ }
93
+ async function probe(deadlineMs) {
94
+ try {
95
+ const res = await rpc('ping', {}, undefined, deadlineMs);
96
+ if (!res.ok) {
97
+ return { state: 'unresponsive', error: Object.assign(new Error(res.error.message), { backlotClass: 'infra-error' }) };
98
+ }
99
+ const d = (res.data ?? {});
100
+ return { state: 'up', info: { pid: d.pid, version: d.version, journalSchema: d.journalSchema } };
101
+ }
102
+ catch (error) {
103
+ const code = error.code;
104
+ if (code === 'ENOENT' || code === 'ECONNREFUSED')
105
+ return { state: 'absent' };
106
+ return { state: 'unresponsive', error: error };
107
+ }
108
+ }
109
+ async function ping() {
110
+ const p = await probe();
111
+ return p.state === 'up' ? p.info : null;
112
+ }
113
+ /** Inspect without spawning; only an absent socket/listener means no daemon. */
114
+ export async function daemonInfo() {
115
+ const p = await probe();
116
+ if (p.state === 'unresponsive')
117
+ throw p.error;
118
+ return p.state === 'up' ? p.info : null;
119
+ }
120
+ /**
121
+ * How long `daemon stop` waits for the acknowledged shutdown to finish. The
122
+ * engine serialises a verified group kill per service (SIGTERM grace plus a
123
+ * post-SIGKILL wait) across every supervisor before it reaps, so a shared box
124
+ * with many signal-trapping services legitimately needs more than `update`'s
125
+ * default window.
126
+ */
127
+ export const DAEMON_STOP_TIMEOUT_MS = 60_000;
128
+ /** The stop window, overridable (BACKLOT_DAEMON_STOP_TIMEOUT_MS) so a test of the timeout need not sit through a minute. */
129
+ export const daemonStopTimeoutMs = () => {
130
+ const v = Number(process.env.BACKLOT_DAEMON_STOP_TIMEOUT_MS);
131
+ return Number.isFinite(v) && v > 0 ? v : DAEMON_STOP_TIMEOUT_MS;
132
+ };
133
+ /**
134
+ * Wait for the daemon to be really gone — not answering AND not running.
135
+ *
136
+ * `update` needs this between the restart request and the respawn, and
137
+ * `daemon stop` (with `DAEMON_STOP_TIMEOUT_MS`) before it reports `stopped`,
138
+ * for two distinct reasons:
139
+ *
140
+ * - `shutdown` returns its result frame ~50ms BEFORE the process exits, so
141
+ * pinging straight away is answered by the daemon that is about to die,
142
+ * ensureDaemon concludes one is already up, and the update silently does
143
+ * nothing.
144
+ * - The socket is removed a moment BEFORE the process exits and releases the
145
+ * election lock. A daemon spawned in that window finds the lock held by a
146
+ * still-live holder, and electSelf refuses to break a live claim (correctly —
147
+ * that check is what stops two daemons binding the same journal), so it
148
+ * concedes with exit 0. The client then reads "our child exited cleanly, a
149
+ * winner must exist" and waits out a full window for a winner that was never
150
+ * started. Waiting on the PID closes it.
151
+ *
152
+ * Each ping is cut at the remaining budget, and a ping that had to be cut is
153
+ * evidence of a daemon, not of its absence: a listener that never answered is
154
+ * still a listener. Only a probe that found no socket or no listener, with the
155
+ * recorded pid (when known) also dead, counts as gone.
156
+ */
157
+ export async function awaitDaemonGone(oldPid, timeoutMs = 15_000) {
158
+ const deadline = Date.now() + timeoutMs;
159
+ while (Date.now() < deadline) {
160
+ const p = await probe(Math.max(1, deadline - Date.now()));
161
+ const running = oldPid !== undefined && isAlive(oldPid);
162
+ if (p.state === 'absent' && !running)
163
+ return true;
164
+ await new Promise((r) => setTimeout(r, 50));
165
+ }
166
+ return false;
167
+ }
168
+ export async function ensureDaemon(cwd = process.cwd()) {
169
+ // Validate the socket path FIRST: ping() swallows every throw into "not up",
170
+ // which would turn socketPath()'s loud sun_path refusal into a silent spawn
171
+ // of a daemon bound to a truncated (colliding) socket.
172
+ socketPath();
173
+ const existing = await ping();
174
+ if (existing)
175
+ return existing;
176
+ // NOTE: the daemon is spawned from THIS CLI's own dist — which is what makes
177
+ // `update` work (the next invocation after a stop brings up the installed
178
+ // build) and equally what makes skew possible (an upgrade cannot replace a
179
+ // daemon that is already running).
180
+ // A supervised daemon (decision 0039) is started through its unit, so it
181
+ // stays supervised: spawning one here would run it outside systemd/launchd.
182
+ // The election still guards against two; if the unit does not answer, the
183
+ // ordinary spawn below is the fallback.
184
+ const unit = installedUnit();
185
+ if (unit && unitRunsThisBuild(unit) && startUnit(unit)) {
186
+ const deadline = Date.now() + 10_000;
187
+ while (Date.now() < deadline) {
188
+ const info = await ping();
189
+ if (info)
190
+ return info;
191
+ await new Promise((r) => setTimeout(r, 100));
192
+ }
193
+ }
194
+ const daemonEntry = join(dirname(fileURLToPath(import.meta.url)), '..', 'daemon', 'index.js');
195
+ const daemonEnv = { ...process.env };
196
+ if (cwd !== undefined) {
197
+ try {
198
+ // Input values travel in the lease request, never in the shared daemon's
199
+ // ambient environment. Otherwise the FIRST caller leaks them into every
200
+ // future check, exec and unrelated service on this daemon.
201
+ for (const name of Object.keys(collectCallerEnv(cwd)))
202
+ delete daemonEnv[name];
203
+ }
204
+ catch {
205
+ // status/doctor/update also work outside a stack or with a broken manifest.
206
+ // Binding clients validate/collect their input envelope before spawning.
207
+ }
208
+ }
209
+ // HOME/XDG inputs may legitimately be service-specific. Pin routing before
210
+ // the stripped daemon recomputes its state root from its own environment.
211
+ daemonEnv.BACKLOT_STATE_DIR = stateRoot();
212
+ // A daemon autospawned from inside `runly db with`'s command must not carry
213
+ // that copy's tag: reaping the copy kills what carries it (decision 0039).
214
+ delete daemonEnv.BACKLOT_DB_COPY;
215
+ // Nor a supervisor's marks: an autospawned daemon is supervised by nobody,
216
+ // whatever service the CLI that spawned it runs under.
217
+ delete daemonEnv.RUNLY_SUPERVISOR;
218
+ delete daemonEnv.INVOCATION_ID;
219
+ const log = openSync(join(stateRoot(), 'daemon.log'), 'a');
220
+ // node:sqlite (the journal) prints "SQLite is an experimental feature" on
221
+ // every spawn, burying daemon.log's signal. Suppress ONLY that warning class,
222
+ // and only for the daemon we spawn — a direct CLI or daemon run still warns.
223
+ const child = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', daemonEntry], {
224
+ // The state root, as under the systemd/launchd unit — never the caller's
225
+ // directory: a worktree the daemon outlives (it may be deleted while the
226
+ // daemon still sits in it), and the cwd of anything the daemon starts
227
+ // without one of its own.
228
+ cwd: stateRoot(),
229
+ detached: true,
230
+ stdio: ['ignore', log, log],
231
+ env: daemonEnv,
232
+ });
233
+ child.unref();
234
+ const pingUntil = async (deadline) => {
235
+ while (Date.now() < deadline) {
236
+ const info = await ping();
237
+ if (info)
238
+ return info;
239
+ await new Promise((r) => setTimeout(r, 100));
240
+ }
241
+ return null;
242
+ };
243
+ const up = await pingUntil(Date.now() + 10_000);
244
+ if (up)
245
+ return up;
246
+ // Cold-start stampede: parallel verbs on a cold state dir each spawn a
247
+ // daemon, the singleton election picks one, and every loser exits 0. Our
248
+ // child exiting CLEANLY therefore proves a winner exists somewhere — so give
249
+ // that winner one more window instead of failing a healthy cold start.
250
+ if (child.exitCode === 0) {
251
+ const winner = await pingUntil(Date.now() + 10_000);
252
+ if (winner)
253
+ return winner;
254
+ }
255
+ // Name the REASON, not just the log path. A daemon that refuses to start now
256
+ // has causes a human must act on rather than retry — the journal-from-the-
257
+ // future refusal (JOURNAL_SCHEMA_VERSION) is one — and "see daemon.log" sent
258
+ // every one of them through a file the caller has to go and find.
259
+ throw new Error(`daemon did not come up — see ${join(stateRoot(), 'daemon.log')}${lastLogLine()}`);
260
+ }
261
+ /** The tail of daemon.log, for a start failure whose cause is already written down. */
262
+ function lastLogLine() {
263
+ try {
264
+ const path = join(stateRoot(), 'daemon.log');
265
+ const size = statSync(path).size;
266
+ const fd = openSync(path, 'r');
267
+ try {
268
+ // Tail only: this log is append-only for the life of a state root.
269
+ const len = Math.min(size, 4096);
270
+ const buf = Buffer.alloc(len);
271
+ readSync(fd, buf, 0, len, size - len);
272
+ // Pick the line that CARRIES the reason. A refusal thrown while the
273
+ // daemon's module graph is still initialising (the journal-from-the-future
274
+ // check runs in an Engine field initialiser) surfaces as an uncaught
275
+ // top-level throw, so the literal last line is Node's own
276
+ // "Node.js v22.15.1" footer — useless to the person reading it.
277
+ const lines = buf
278
+ .toString('utf8')
279
+ .split('\n')
280
+ .map((l) => l.trim())
281
+ .filter((l) => l !== '' && !l.startsWith('at ') && !/^Node\.js v/.test(l) && !/^\^+$/.test(l));
282
+ const errorLine = lines.filter((l) => /^[A-Za-z]*Error: /.test(l)).pop();
283
+ const chosen = errorLine ?? lines[lines.length - 1];
284
+ return chosen ? `\n last log line: ${chosen.slice(0, 500)}` : '';
285
+ }
286
+ finally {
287
+ closeSync(fd);
288
+ }
289
+ }
290
+ catch {
291
+ return ''; // best-effort: never turn a diagnostic into a second failure
292
+ }
293
+ }
294
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/cli/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ export type UnitKind = 'systemd' | 'launchd';
2
+ export interface UnitPlan {
3
+ kind: UnitKind;
4
+ /** systemd unit name (without .service) or launchd label. */
5
+ name: string;
6
+ /** Where the unit file goes. */
7
+ path: string;
8
+ content: string;
9
+ }
10
+ /** `runly-daemon`, or `runly-daemon-<hash>` for a non-default state root. */
11
+ export declare function unitBaseName(root?: string): string;
12
+ export declare function unitKind(): UnitKind | null;
13
+ export interface CapturedEnv {
14
+ /** What goes into the unit (BACKLOT_STATE_DIR and RUNLY_SUPERVISOR included). */
15
+ env: Record<string, string>;
16
+ /** Names taken from the installing shell, sorted. */
17
+ captured: string[];
18
+ /** Names the manifest's commands reference, or `--env` asked for, that this shell does not set. */
19
+ notSet: string[];
20
+ /** The manifest's `env_from` inputs: supplied per `up`, never stored in a unit. */
21
+ excluded: string[];
22
+ /** How many other variables of this shell were left out. */
23
+ leftOut: number;
24
+ }
25
+ /**
26
+ * What the daemon runs with under the unit: the installing shell's PATH, the
27
+ * allowlist above, the BACKLOT_* settings, every variable the stack's commands
28
+ * reference (when installed from a stack) and each `--env NAME` — never an
29
+ * `env_from` input, which travels with each `up`. RUNLY_SUPERVISOR tells the
30
+ * daemon who restarts it (`status.supervisor`).
31
+ */
32
+ export declare function captureEnvironment(root: string, kind: UnitKind, extra?: string[], cwd?: string, from?: NodeJS.ProcessEnv): CapturedEnv;
33
+ export declare function unitPlan(root?: string, extraEnv?: string[], opts?: {
34
+ capture?: boolean;
35
+ }): (UnitPlan & {
36
+ environment: CapturedEnv;
37
+ }) | null;
38
+ /** The installed unit for this state root, if any. */
39
+ export declare function installedUnit(root?: string): UnitPlan | null;
40
+ /**
41
+ * Does the installed unit run THIS CLI's daemon? A unit written by another
42
+ * install (a global one, while this CLI is a checkout) would start a daemon of
43
+ * another version, which the version gate then refuses — and `runly update`
44
+ * would restart the same wrong one. Such a unit is left alone.
45
+ */
46
+ export declare function unitRunsThisBuild(plan: UnitPlan): boolean;
47
+ /** Write and enable the unit. Does not start it (the caller decides: a daemon may already run). */
48
+ export declare function installUnit(plan: UnitPlan): {
49
+ steps: string[];
50
+ error?: string;
51
+ };
52
+ /** Disable and remove the unit. The daemon that runs keeps running, unsupervised. */
53
+ export declare function uninstallUnit(plan: UnitPlan): {
54
+ steps: string[];
55
+ error?: string;
56
+ };
57
+ /** Start the installed unit (the CLI's autospawn path when one is installed). True when the start was accepted. */
58
+ export declare function startUnit(plan: UnitPlan): boolean;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Supervision for the daemon (decision 0039): `runly daemon install` writes a
3
+ * systemd user unit (Linux) or a launchd agent (macOS) that runs the daemon
4
+ * with restart-on-failure, so a crashed daemon is back within seconds —
5
+ * `recover()` re-holds the public ports and reaps what the dead one left —
6
+ * instead of waiting for the next CLI command.
7
+ *
8
+ * It coexists with the CLI's autospawn. The socket lock (election) still
9
+ * decides who serves, so there is never a second daemon; and once the unit is
10
+ * installed, a CLI that finds no daemon starts the UNIT rather than spawning
11
+ * one of its own, so the daemon stays supervised across `runly update` and
12
+ * `runly daemon stop`.
13
+ *
14
+ * One unit per state root: the default root gets `runly-daemon`, any other
15
+ * (`BACKLOT_STATE_DIR`) a name with a hash of its path, so test daemons and
16
+ * parallel installs never touch each other's unit.
17
+ */
18
+ import { execFileSync } from 'node:child_process';
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { stateRoot } from '../core/paths.js';
25
+ import { loadStack } from '../core/manifest.js';
26
+ /** The default state root, as paths.ts derives it without BACKLOT_STATE_DIR. */
27
+ function defaultStateRoot() {
28
+ const base = process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state');
29
+ return join(base, 'backlot');
30
+ }
31
+ /** `runly-daemon`, or `runly-daemon-<hash>` for a non-default state root. */
32
+ export function unitBaseName(root = stateRoot()) {
33
+ if (root === defaultStateRoot())
34
+ return 'runly-daemon';
35
+ return `runly-daemon-${createHash('sha256').update(root).digest('hex').slice(0, 8)}`;
36
+ }
37
+ export function unitKind() {
38
+ if (process.platform === 'linux')
39
+ return 'systemd';
40
+ if (process.platform === 'darwin')
41
+ return 'launchd';
42
+ return null;
43
+ }
44
+ const daemonEntry = () => join(dirname(fileURLToPath(import.meta.url)), '..', 'daemon', 'index.js');
45
+ /**
46
+ * The installing shell's variables a daemon needs and a unit would not have:
47
+ * a unit starts from systemd's or launchd's bare environment, while services,
48
+ * builds, upkeep and datastore commands run with the daemon's. Missing
49
+ * DOTNET_ROOT made every template bake of a .NET stack fail under the unit.
50
+ */
51
+ const CAPTURE_EXACT = ['PATH', 'LANG', 'DOTNET_ROOT', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'JAVA_HOME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'];
52
+ const CAPTURE_PREFIXES = ['DOTNET_', 'LC_', 'NODE_', 'NVM_', 'SSL_CERT_'];
53
+ /** runly's own per-process tags and per-call inputs: never baked into a unit. */
54
+ const NEVER_CAPTURE = ['BACKLOT_STATE_DIR', 'BACKLOT_HOLDER_PID', 'BACKLOT_DB_COPY', 'BACKLOT_ENV_ID', 'BACKLOT_SERVICE', 'BACKLOT_STATE_ROOT', 'BACKLOT_FAKE_VERSION', 'RUNLY_SUPERVISOR', 'INVOCATION_ID'];
55
+ /**
56
+ * `$NAME` / `${NAME}` the stack's commands read from the daemon's environment
57
+ * — run, build, upkeep, datastore and appliance commands, auth.token — when
58
+ * the install runs in a stack. Best effort: nothing when there is no manifest.
59
+ */
60
+ function manifestReferences(cwd) {
61
+ try {
62
+ const m = loadStack(cwd, { validate: false }).manifest;
63
+ const texts = [];
64
+ for (const s of Object.values(m.services ?? {})) {
65
+ texts.push(s.run, typeof s.build === 'string' ? s.build : s.build?.run ?? '', s.ready?.cmd ?? '');
66
+ }
67
+ for (const u of m.upkeep ?? [])
68
+ texts.push(u.run);
69
+ for (const d of Object.values(m.datastores ?? {}))
70
+ texts.push(d.create ?? '', d.drop ?? '', d.template_restore ?? '', d.list ?? '');
71
+ for (const a of Object.values(m.appliances ?? {}))
72
+ texts.push(a.start ?? '', a.stop ?? '', a.ready ?? '');
73
+ texts.push(m.auth?.token ?? '');
74
+ const names = new Set();
75
+ for (const t of texts)
76
+ for (const match of t.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/g))
77
+ names.add(match[1]);
78
+ const inputs = Object.values(m.services ?? {}).flatMap((s) => Object.keys(s.env_from ?? {}));
79
+ return { names: [...names], inputs };
80
+ }
81
+ catch {
82
+ return { names: [], inputs: [] };
83
+ }
84
+ }
85
+ /**
86
+ * What the daemon runs with under the unit: the installing shell's PATH, the
87
+ * allowlist above, the BACKLOT_* settings, every variable the stack's commands
88
+ * reference (when installed from a stack) and each `--env NAME` — never an
89
+ * `env_from` input, which travels with each `up`. RUNLY_SUPERVISOR tells the
90
+ * daemon who restarts it (`status.supervisor`).
91
+ */
92
+ export function captureEnvironment(root, kind, extra = [], cwd = process.cwd(), from = process.env) {
93
+ const refs = manifestReferences(cwd);
94
+ const inputs = new Set(refs.inputs);
95
+ const wanted = new Set([...refs.names, ...extra]);
96
+ const env = {};
97
+ const captured = [];
98
+ const notSet = [];
99
+ let leftOut = 0;
100
+ for (const [k, v] of Object.entries(from)) {
101
+ if (v === undefined)
102
+ continue;
103
+ const take = !NEVER_CAPTURE.includes(k) && !inputs.has(k) && (k.startsWith('BACKLOT_') || CAPTURE_EXACT.includes(k) || CAPTURE_PREFIXES.some((p) => k.startsWith(p)) || wanted.has(k));
104
+ if (take) {
105
+ env[k] = v;
106
+ captured.push(k);
107
+ }
108
+ else
109
+ leftOut++;
110
+ }
111
+ for (const k of wanted)
112
+ if (from[k] === undefined && !inputs.has(k) && !NEVER_CAPTURE.includes(k))
113
+ notSet.push(k);
114
+ env.BACKLOT_STATE_DIR = root;
115
+ env.RUNLY_SUPERVISOR = kind;
116
+ return { env, captured: captured.sort(), notSet: notSet.sort(), excluded: [...inputs].filter((k) => from[k] !== undefined).sort(), leftOut };
117
+ }
118
+ /** systemd quoting for one Environment= assignment. */
119
+ const systemdEnv = (k, v) => `Environment="${`${k}=${v}`.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
120
+ const systemdArg = (a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).replace(/%/g, '%%').replace(/\$/g, '$$$$');
121
+ /**
122
+ * A path in a setting that takes the rest of the line (WorkingDirectory=,
123
+ * StandardOutput=append:): never quoted — systemd reads quotes as part of the
124
+ * path and refuses it as not absolute — only `%` escaped. A state root with
125
+ * spaces works this way (verified with `systemd-analyze --user verify`).
126
+ */
127
+ const systemdPath = (p) => p.replace(/%/g, '%%');
128
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
129
+ export function unitPlan(root = stateRoot(), extraEnv = [], opts = {}) {
130
+ const kind = unitKind();
131
+ if (!kind)
132
+ return null;
133
+ const name = unitBaseName(root);
134
+ const argv = [process.execPath, '--disable-warning=ExperimentalWarning', daemonEntry()];
135
+ // Locating an installed unit needs only its name and path, not the capture.
136
+ const environment = opts.capture === false
137
+ ? { env: { BACKLOT_STATE_DIR: root, RUNLY_SUPERVISOR: kind }, captured: [], notSet: [], excluded: [], leftOut: 0 }
138
+ : captureEnvironment(root, kind, extraEnv);
139
+ const env = environment.env;
140
+ const log = join(root, 'daemon.log');
141
+ if (kind === 'systemd') {
142
+ const dir = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
143
+ const content = [
144
+ '# Written by `runly daemon install` (decision 0039). `runly daemon uninstall` removes it.',
145
+ '[Unit]',
146
+ `Description=runly daemon (state root ${root})`,
147
+ 'Documentation=https://github.com/ChristianKohlberg/backlot/blob/main/docs/overview.md#the-daemon',
148
+ '',
149
+ '[Service]',
150
+ 'Type=simple',
151
+ `ExecStart=${argv.map(systemdArg).join(' ')}`,
152
+ ...Object.entries(env).map(([k, v]) => systemdEnv(k, v)),
153
+ `WorkingDirectory=${systemdPath(root)}`,
154
+ // A crash (non-zero exit, a fatal signal) restarts it within seconds;
155
+ // `runly daemon stop` and `runly update` exit 0 and are not undone.
156
+ 'Restart=on-failure',
157
+ 'RestartSec=1',
158
+ // Services are the daemon's children by design and must survive its
159
+ // crash (decision 0009): stop and restart signal the daemon alone, which
160
+ // stops its services itself on a clean stop.
161
+ 'KillMode=process',
162
+ // `daemon stop` waits up to 60 s for services to stop; leave room.
163
+ 'TimeoutStopSec=90',
164
+ `StandardOutput=append:${systemdPath(log)}`,
165
+ `StandardError=append:${systemdPath(log)}`,
166
+ '',
167
+ '[Install]',
168
+ 'WantedBy=default.target',
169
+ '',
170
+ ].join('\n');
171
+ return { kind, name, path: join(dir, `${name}.service`), content, environment };
172
+ }
173
+ const label = name.replace(/^runly-daemon/, 'dev.runly.daemon');
174
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
175
+ <!-- Written by \`runly daemon install\` (decision 0039). \`runly daemon uninstall\` removes it. -->
176
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
177
+ <plist version="1.0">
178
+ <dict>
179
+ <key>Label</key><string>${xml(label)}</string>
180
+ <key>ProgramArguments</key>
181
+ <array>
182
+ ${argv.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
183
+ </array>
184
+ <key>EnvironmentVariables</key>
185
+ <dict>
186
+ ${Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n')}
187
+ </dict>
188
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
189
+ <key>RunAtLoad</key><true/>
190
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
191
+ <key>ThrottleInterval</key><integer>2</integer>
192
+ <key>AbandonProcessGroup</key><true/>
193
+ <key>StandardOutPath</key><string>${xml(log)}</string>
194
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
195
+ </dict>
196
+ </plist>
197
+ `;
198
+ return { kind, name: label, path: join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`), content, environment };
199
+ }
200
+ /** The installed unit for this state root, if any. */
201
+ export function installedUnit(root = stateRoot()) {
202
+ const plan = unitPlan(root, [], { capture: false });
203
+ return plan && existsSync(plan.path) ? plan : null;
204
+ }
205
+ /**
206
+ * Does the installed unit run THIS CLI's daemon? A unit written by another
207
+ * install (a global one, while this CLI is a checkout) would start a daemon of
208
+ * another version, which the version gate then refuses — and `runly update`
209
+ * would restart the same wrong one. Such a unit is left alone.
210
+ */
211
+ export function unitRunsThisBuild(plan) {
212
+ try {
213
+ const xmlEntry = daemonEntry().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
214
+ const text = readFileSync(plan.path, 'utf8');
215
+ return text.includes(daemonEntry()) || text.includes(xmlEntry);
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ function run(cmd, args) {
222
+ try {
223
+ const output = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 });
224
+ return { ok: true, output: output.trim() };
225
+ }
226
+ catch (err) {
227
+ const e = err;
228
+ return { ok: false, output: String(e.stderr || e.message || err).trim() };
229
+ }
230
+ }
231
+ const uid = () => (typeof process.getuid === 'function' ? process.getuid() : 0);
232
+ const systemctl = (...args) => run(process.env.BACKLOT_SYSTEMCTL || 'systemctl', ['--user', ...args]);
233
+ const launchctl = (...args) => run(process.env.BACKLOT_LAUNCHCTL || 'launchctl', args);
234
+ /** Write and enable the unit. Does not start it (the caller decides: a daemon may already run). */
235
+ export function installUnit(plan) {
236
+ const steps = [];
237
+ mkdirSync(dirname(plan.path), { recursive: true });
238
+ const previous = existsSync(plan.path) ? readFileSync(plan.path, 'utf8') : null;
239
+ writeFileSync(plan.path, plan.content);
240
+ steps.push(`${previous === null ? 'wrote' : previous === plan.content ? 'kept' : 'updated'} ${plan.path}`);
241
+ if (plan.kind === 'systemd') {
242
+ for (const args of [['daemon-reload'], ['enable', `${plan.name}.service`]]) {
243
+ const r = systemctl(...args);
244
+ if (!r.ok)
245
+ return { steps, error: `systemctl --user ${args.join(' ')} failed: ${r.output}` };
246
+ steps.push(`systemctl --user ${args.join(' ')}`);
247
+ }
248
+ }
249
+ else {
250
+ // bootstrap loads it (and, with RunAtLoad, starts it); reloading an
251
+ // already-loaded label needs a bootout first.
252
+ launchctl('bootout', `gui/${uid()}/${plan.name}`);
253
+ const r = launchctl('bootstrap', `gui/${uid()}`, plan.path);
254
+ if (!r.ok)
255
+ return { steps, error: `launchctl bootstrap failed: ${r.output}` };
256
+ steps.push(`launchctl bootstrap gui/${uid()} ${plan.path}`);
257
+ }
258
+ return { steps };
259
+ }
260
+ /** Disable and remove the unit. The daemon that runs keeps running, unsupervised. */
261
+ export function uninstallUnit(plan) {
262
+ const steps = [];
263
+ if (plan.kind === 'systemd') {
264
+ const r = systemctl('disable', `${plan.name}.service`);
265
+ if (r.ok)
266
+ steps.push(`systemctl --user disable ${plan.name}.service`);
267
+ rmSync(plan.path, { force: true });
268
+ steps.push(`removed ${plan.path}`);
269
+ const reload = systemctl('daemon-reload');
270
+ if (reload.ok)
271
+ steps.push('systemctl --user daemon-reload');
272
+ }
273
+ else {
274
+ // bootout stops a launchd job; the daemon is restarted by the next verb.
275
+ const r = launchctl('bootout', `gui/${uid()}/${plan.name}`);
276
+ if (r.ok)
277
+ steps.push(`launchctl bootout gui/${uid()}/${plan.name}`);
278
+ rmSync(plan.path, { force: true });
279
+ steps.push(`removed ${plan.path}`);
280
+ }
281
+ return { steps };
282
+ }
283
+ /** Start the installed unit (the CLI's autospawn path when one is installed). True when the start was accepted. */
284
+ export function startUnit(plan) {
285
+ if (plan.kind === 'systemd')
286
+ return systemctl('start', `${plan.name}.service`).ok;
287
+ return launchctl('kickstart', `gui/${uid()}/${plan.name}`).ok;
288
+ }
289
+ //# sourceMappingURL=daemon-unit.js.map