@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,1482 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * runly CLI. Contract: every verb accepts --json (stdout = data, stderr =
4
+ * human); exit codes are contractual — 0 ok, 1 work-error, 2 env-error,
5
+ * 3 infra-error, 64 usage. See docs/architecture.md §11.
6
+ */
7
+ import { ensureDaemon, daemonInfo, rpc, classifyClientError, awaitDaemonGone, daemonStopTimeoutMs } from './client.js';
8
+ import { dbCopyTag, isAlive, startTime } from '../core/procscan.js';
9
+ import { join } from 'node:path';
10
+ import { stateRoot } from '../core/paths.js';
11
+ import { BUILD, VERSION, rebuiltSince, versionSkew } from '../core/version.js';
12
+ import { installKind } from './install.js';
13
+ import { installUnit, installedUnit, startUnit, uninstallUnit, unitPlan } from './daemon-unit.js';
14
+ import { collectCallerEnv } from '../core/caller-env.js';
15
+ import { loadStack, manifestDeprecations } from '../core/manifest.js';
16
+ import { parsePresetArgs } from '../core/presets.js';
17
+ import { BrokerError } from '../core/util.js';
18
+ import { parseSince, showLogs } from './logs.js';
19
+ import { autoTether } from './tether.js';
20
+ import { formatDuration, formatSize, parseDuration } from '../core/units.js';
21
+ import { exportLines, shellValue } from '../core/env-vars.js';
22
+ const USAGE = `runly — puts a working instance of a web application in front of you.
23
+
24
+ Usage:
25
+ runly up [service...] [--preset [DATASTORE=]NAME]... [--reset-data|--pristine]
26
+ [--rebuild] [--ttl <minutes>] [--holder-pid <pid>] [--env]
27
+ lease THIS worktree's one environment and start the
28
+ named services plus their depends_on closure (none
29
+ named = every service). ADDITIVE: never stops a
30
+ service that is already running. Every up applies the
31
+ worktree as it is now: the due upkeep rules, then the
32
+ build: of every service it runs — except a
33
+ build: {run, when: [globs]} whose matched files are
34
+ unchanged since its last successful build and
35
+ whose outputs: are still what it left
36
+ ('build <svc>: skipped (when: unchanged)'); --rebuild
37
+ runs every build regardless.
38
+ A running service whose build OUTPUT changed (its
39
+ outputs: globs; none declared = always) is restarted;
40
+ one whose output is unchanged, or that has no build:,
41
+ keeps running. Dependents are not restarted.
42
+ Every datastore keeps its data; --preset reloads ONLY
43
+ that datastore from its template and restarts the
44
+ running services that use it. --reset-data restores
45
+ every datastore; --pristine also re-runs every upkeep
46
+ rule and build (it never deletes worktree files) and
47
+ bakes the templates again — a template shared with
48
+ other worktrees of this stack name (an
49
+ @rebake-template key) is left to them, and this
50
+ worktree gets a private one.
51
+ A service idle for 10 minutes (no runly verb on the
52
+ environment, no bytes through its port; idle: per
53
+ service) is stopped, its port kept; the next
54
+ connection starts it again and is held until it is up.
55
+ Builds run in depends_on waves (parallel within a
56
+ wave); builds: serial / build: {serial: true} opt out.
57
+ Waits in the server-wide load budget's queue when the
58
+ box is full ('runly plan' says whether it would).
59
+ With services named, the other running services are
60
+ left alone (not built, not restarted).
61
+ Prints a short summary — what started or restarted,
62
+ and why a full rebind happened (--json: the context
63
+ blob); --env prints the 'ctx --env' lines instead.
64
+ runly down [service...] stop just these services (none named = all of them).
65
+ The lease, the data and the public ports stay
66
+ runly plan [service...] [--rebuild]
67
+ what an up would build and start, what that costs
68
+ (resources:, or the default — said so), and whether
69
+ it starts now or would wait (and for what)
70
+ runly ctx [--env] a short summary of the environment (URLs, service
71
+ states, datastores and the preset each holds, the
72
+ login, a failed last up); --json for the full context
73
+ blob.
74
+ --env prints 'export KEY=value' lines instead:
75
+ RUNLY_ENV_ID, RUNLY_PORT_<PORT>, RUNLY_URL_<SERVICE>,
76
+ RUNLY_DATASTORE_<NAME>_URL, RUNLY_DATASTORE_<NAME>_PRESET,
77
+ RUNLY_LOGIN_USER and RUNLY_LOGIN_PASSWORD (names
78
+ uppercased, any other character as _). Run your tests
79
+ with them: eval "$(runly ctx --env)" && npm test
80
+ runly ps [--all] what runs for this worktree: each service (state, public
81
+ and internal port, pid, idle time, memory) and each
82
+ database copy. --all for the whole server, with a
83
+ WORKTREE column
84
+ runly db new <datastore> [--preset NAME] [--holder-pid <pid>]
85
+ a fresh copy of a datastore from the same template the
86
+ environments use — no lease, no ports, no services.
87
+ Prints its name, url, database and preset. It is
88
+ dropped when
89
+ its holder process exits (--holder-pid, or the Claude
90
+ Code session), when this worktree goes away, or by
91
+ 'db drop'
92
+ runly db with <datastore> [--preset NAME] [--runly-exit N] -- <cmd...>
93
+ run a command against a fresh copy (RUNLY_DB_URL; the
94
+ database's own name in RUNLY_DB_DATABASE; runly's
95
+ handle in RUNLY_DB_COPY), drop the copy when it
96
+ exits, and exit with its exit code. The command dies
97
+ with this CLI however it dies. runly's own failures
98
+ are marked 'runly db with:' on stderr (with --json a
99
+ {"runlyDbWith":…} line) and exit 1/2/3, or N with
100
+ --runly-exit N
101
+ runly db ls [--all] this worktree's database copies (--all: every one)
102
+ runly db drop <name> drop one copy now
103
+ runly warm run this worktree's due upkeep rules and its service
104
+ builds now — no lease, no services. For an idle worktree
105
+ just moved to a new commit
106
+ runly exec <cmd...> run a command in the worktree, on this terminal, with
107
+ your environment plus the lease's ports, URLs and
108
+ connection strings (the RUNLY_* names of 'ctx --env',
109
+ and BACKLOT_PORT_*, BACKLOT_URL_*, BACKLOT_DS_*).
110
+ Stopped services start first. Exits with the
111
+ command's code; --json collects stdout, stderr and
112
+ exitCode. No deadline unless BACKLOT_CMD_TIMEOUT_S
113
+ runly logs [service...] [--lines N] [--since up|<duration>] [--grep <re>]
114
+ [-f|--follow [--until <re>] [--timeout <s>]] [--build]
115
+ the services' output (last 40 lines), interleaved by
116
+ time with a 'service | ' prefix when there are several
117
+ (none named = all). --since up: only the current process
118
+ of each; --since 10m: the last ten minutes. -f keeps
119
+ following; --until exits 0 at the first matching line
120
+ of each service's current process (lines before its
121
+ last start never match); --timeout exits 124 when it
122
+ runs out. --grep that matches nothing exits 1.
123
+ --build: the output of upkeep and of each service's
124
+ last build, a section each with its own last N lines
125
+ and a header saying what was cut
126
+ runly reset-data [--preset [DATASTORE=]NAME]...
127
+ restore the data of the current lease, each datastore
128
+ with the preset it holds (or the one named)
129
+ runly token [--role <r>] [--raw]
130
+ mint an auth token via the stack's auth.token hook
131
+ (role default: admin; a name, also passed as
132
+ RUNLY_ROLE). Default output is JSON
133
+ ({token, role}); --raw prints the bare token, which is
134
+ what an Authorization header wants
135
+ runly release end the current lease; the environment stays for the
136
+ next up
137
+ runly destroy tear down everything runly holds for this worktree
138
+ now: services, data, copies, ports, lease, and the
139
+ upkeep rules without outputs:. For a worktree pool
140
+ taking a worktree back
141
+ runly preview <service> [--ttl <minutes>] [--https-port N]
142
+ publish a service from your lease on a public quick
143
+ tunnel (Cloudflare by default). The URL is unauthenticated
144
+ — anyone with the link reaches the service. Requires
145
+ cloudflared on PATH (or BACKLOT_CLOUDFLARED).
146
+ With preview.publisher: tailscale it is served on this
147
+ machine's tailnet name instead (tailnet only);
148
+ --https-port pins that port for this publish.
149
+ It lasts as long as the lease: a daemon restart
150
+ publishes it again (same address where the publisher
151
+ can keep one; ctx --json: previewRestore says how
152
+ that went)
153
+ runly preview stop stop the preview tunnel on your lease (also one a
154
+ restart could not publish again)
155
+ runly status daemon, environments, the load budget and recent
156
+ warnings (--json: everything)
157
+ runly appliance ls|start|stop [name]
158
+ shared backing servers: probe, ensure up, explicit stop
159
+ runly pool ls|recycle [<env-id>] [--force]|reconcile|gc|doctor [--fix]
160
+ recycle with an env-id recycles exactly that one; with
161
+ none, every environment. A LEASED environment is never
162
+ taken without --force (--all is the old spelling).
163
+ reconcile reaps degraded environments now; gc reclaims
164
+ service processes orphaned by an ungraceful exit;
165
+ doctor lists what runly left behind (databases,
166
+ templates, copies, processes, listeners, state dirs)
167
+ and --fix removes it — only runly's own
168
+ runly doctor daemon health and drift, without acting on it
169
+ runly daemon stop stop the daemon and wait for it (60 s,
170
+ BACKLOT_DAEMON_STOP_TIMEOUT_MS); environments are
171
+ recovered on next use
172
+ runly daemon install [--print] [--env NAME]...
173
+ supervise the daemon: a systemd user unit (Linux) or
174
+ launchd agent (macOS) that restarts it within seconds
175
+ when it crashes; the CLI then starts the daemon
176
+ through it. --print shows the unit without writing it.
177
+ The unit carries PATH, an allowlist (DOTNET_*, NODE_*,
178
+ JAVA_HOME, DOCKER_*, LANG/LC_*, proxies, SSL_CERT_*),
179
+ BACKLOT_*, what the manifest's commands reference and
180
+ each --env NAME — never an env_from input; it prints
181
+ what it captured and left out.
182
+ A daemon already running moves under it on 'runly update'
183
+ runly daemon uninstall disable and remove that unit
184
+ runly update [--check] [--force]
185
+ make the RUNNING daemon be the INSTALLED build. An
186
+ upgrade replaces the files on disk but not the daemon
187
+ already in memory, and the socket carries no version —
188
+ so a new CLI would keep being served by the old code.
189
+ This restarts it. Leases SURVIVE; services stop and
190
+ each holder's next verb rebinds. --check reports the
191
+ versions and who would rebind, and changes nothing.
192
+ runly never installs itself: it prints the command
193
+ for your install and leaves it to you.
194
+ runly --version the version of this CLI
195
+
196
+ Holding an environment:
197
+
198
+ Under Claude Code, up and db new tether to the session automatically
199
+ (CLAUDE_PID, when it is a live ancestor of the CLI): the environment lives
200
+ while the session does and is torn down a minute after it ends.
201
+ BACKLOT_TETHER=off opts out.
202
+
203
+ --ttl <minutes> the lease length (default 30) — the form for other
204
+ agents and scripts. up renews it. Only for an
205
+ untethered lease: it skips the automatic tether, and
206
+ is refused next to --holder-pid.
207
+ --holder-pid <pid> For an interactive shell, or any caller that OUTLIVES the
208
+ (BACKLOT_HOLDER_PID) command. Ties the environment to that process: a minute
209
+ after it exits, the environment is torn down (services,
210
+ data, ports, lease); while it lives, the lease does not
211
+ expire.
212
+
213
+ 'BACKLOT_HOLDER_PID=$$ runly up' works at a shell prompt and CANNOT work from an
214
+ agent harness: each command runs in a fresh shell, so '$$' names a process that has
215
+ already exited. Such a lease would be reclaimable the instant it was created — the
216
+ environment would be torn down while you were still using it — so runly refuses
217
+ the bind instead. Use --ttl.
218
+
219
+ up and reset-data accept --preset NAME (one datastore), or repeatable
220
+ --preset DATASTORE=NAME; a named datastore is reloaded from its template, every
221
+ other one keeps its data. ctx reports the preset each datastore holds. db new and
222
+ db with take --preset NAME for their one datastore.
223
+
224
+ Verbs that act on a lease or a copy take --holder <name> to act for another
225
+ holder than the caller's worktree (any subdirectory of it counts).
226
+
227
+ Every verb accepts --json. Long verbs (up/warm/reset-data) show live progress
228
+ on a terminal (stderr); force with --progress, silence with --quiet. stdout stays clean.
229
+ Exit codes: 0 ok · 1 work-error · 2 env-error · 3 infra-error · 64 usage.
230
+
231
+ Removed verbs and flags exit 64 naming their replacement: sync, bind, pull, run,
232
+ job, --watch, --detach, --pull, --ref (decision 0032) and --data-only (decision
233
+ 0034; use 'runly db new|with').`;
234
+ const rawArgv = process.argv.slice(2);
235
+ const verb = rawArgv[0];
236
+ // Known flags and whether each takes a value. A proper single-pass parser so a
237
+ // flag's value is never mis-bound as a positional (and an inner command's own
238
+ // flags survive) — the F1 class of argv bugs. Everything after a lone `--`, and
239
+ // EVERYTHING for `exec`, is treated as a raw passthrough command.
240
+ const VALUE_FLAGS = new Set(['--holder', '--holder-pid', '--ttl', '--role', '--lines', '--ref', '--spec', '--preset', '--https-port', '--since', '--grep', '--until', '--timeout', '--runly-exit']);
241
+ const BOOL_FLAGS = new Set(['--print', '--json', '--env', '--watch', '--reset-data', '--pristine', '--pull', '--detach', '--all', '--force', '--raw', '--data-only', '--progress', '--quiet', '--check', '--rebuild', '--follow', '--build', '--fix']);
242
+ const flagVals = new Map();
243
+ const presetArgs = [];
244
+ /** `daemon install --env NAME` (repeatable): a variable to capture into the unit beyond the allowlist. */
245
+ const unitEnvNames = [];
246
+ const flags = new Set();
247
+ const positional = [];
248
+ let passthrough = null; // for `exec` / after `--`
249
+ {
250
+ const body = rawArgv.slice(1);
251
+ for (let i = 0; i < body.length; i++) {
252
+ // `-f` is the one short flag: `logs -f`, as tail(1) has it.
253
+ const a = body[i] === '-f' && verb === 'logs' && passthrough === null ? '--follow' : body[i];
254
+ // `exec` consumes the entire remainder verbatim (its own flags included),
255
+ // except a leading `--json` which is ours; `--` also opens passthrough.
256
+ if (verb === 'exec' && passthrough === null && a !== '--json' && !a.startsWith('--')) {
257
+ passthrough = body.slice(i);
258
+ break;
259
+ }
260
+ if (a === '--') {
261
+ passthrough = body.slice(i + 1);
262
+ break;
263
+ }
264
+ // `--env` is a switch for ctx/up, and takes a NAME for `daemon install`.
265
+ if (a === '--env' && verb === 'daemon') {
266
+ const v = body[i + 1];
267
+ if (v === undefined || v.startsWith('--') || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(v)) {
268
+ console.error(`runly daemon install: --env needs a variable name, got '${v ?? ''}'`);
269
+ process.exit(64);
270
+ }
271
+ unitEnvNames.push(v);
272
+ i++;
273
+ continue;
274
+ }
275
+ if (VALUE_FLAGS.has(a)) {
276
+ const v = body[i + 1];
277
+ if (v === undefined) {
278
+ console.error(`runly: ${a} needs a value`);
279
+ process.exit(64);
280
+ }
281
+ flagVals.set(a, v);
282
+ if (a === '--preset')
283
+ presetArgs.push(v);
284
+ i++;
285
+ }
286
+ else if (BOOL_FLAGS.has(a)) {
287
+ flags.add(a);
288
+ }
289
+ else if (a.startsWith('--')) {
290
+ console.error(`runly: unknown flag '${a}'`);
291
+ process.exit(64);
292
+ }
293
+ else {
294
+ positional.push(a);
295
+ }
296
+ }
297
+ }
298
+ const json = flags.has('--json');
299
+ const flagValue = (name) => flagVals.get(name);
300
+ const out = (data) => console.log(json ? JSON.stringify(data, null, json ? 0 : 2) : humanize(data));
301
+ const errExit = (e) => {
302
+ const code = e.class === 'work-error' ? 1 : e.class === 'infra-error' ? 3 : 2;
303
+ if (json)
304
+ console.log(JSON.stringify({ ok: false, error: e }));
305
+ else {
306
+ console.error(`runly: [${e.class ?? e.code ?? 'error'}] ${e.message}${e.source ? ` (${e.source})` : ''}`);
307
+ if (e.logExcerpt)
308
+ console.error(`--- log excerpt ---\n${e.logExcerpt}`);
309
+ }
310
+ process.exit(code);
311
+ };
312
+ /** POSIX single-quote quoting: safe for anything, including embedded quotes. */
313
+ function shellQuote(arg) {
314
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
315
+ }
316
+ function humanize(data) {
317
+ return JSON.stringify(data, null, 2);
318
+ }
319
+ // Progress -> stderr, shown for humans (a TTY) or on --progress; --quiet forces
320
+ // off. Never touches stdout, so the --json payload stays clean for agents.
321
+ const showProgress = flags.has('--progress') || (process.stderr.isTTY === true && !flags.has('--quiet') && !flags.has('--json'));
322
+ let lastProgressLen = 0;
323
+ const progress = showProgress
324
+ ? (phase) => {
325
+ const line = ` ⋯ ${phase}`;
326
+ // Redraw in place on a TTY so the phase log stays a single moving line.
327
+ if (process.stderr.isTTY) {
328
+ process.stderr.write(`\r${line}${' '.repeat(Math.max(0, lastProgressLen - line.length))}`);
329
+ lastProgressLen = line.length;
330
+ }
331
+ else {
332
+ process.stderr.write(line + '\n');
333
+ }
334
+ }
335
+ : undefined;
336
+ const endProgress = () => {
337
+ if (showProgress && process.stderr.isTTY && lastProgressLen)
338
+ process.stderr.write('\r' + ' '.repeat(lastProgressLen) + '\r');
339
+ };
340
+ /** --ttl is in MINUTES; accepts a bare number or an explicit `<n>m`. Returns ms or undefined if invalid. */
341
+ function parseTtlMinutes(v) {
342
+ const m = /^(\d+(?:\.\d+)?)m?$/.exec(v.trim());
343
+ if (!m)
344
+ return undefined;
345
+ const mins = Number(m[1]);
346
+ if (!Number.isFinite(mins) || mins <= 0)
347
+ return undefined;
348
+ return mins * 60_000;
349
+ }
350
+ function hygiene() {
351
+ if (flags.has('--pristine'))
352
+ return 'pristine';
353
+ if (flags.has('--reset-data'))
354
+ return 'reset-data';
355
+ return undefined;
356
+ }
357
+ async function main() {
358
+ if (presetArgs.length > 0 && !['up', 'reset-data', 'db'].includes(verb ?? '')) {
359
+ console.error('runly: --preset is supported by up, reset-data and db');
360
+ process.exit(64);
361
+ }
362
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h') {
363
+ console.log(USAGE);
364
+ return;
365
+ }
366
+ // Answered without touching the socket: "what version am I" must work when
367
+ // the daemon is down, wedged, or refusing to start — those are exactly the
368
+ // moments someone asks.
369
+ if (verb === '--version' || verb === '-v' || verb === 'version') {
370
+ // Bare string on stdout for a human and for `$(runly --version)`; out()
371
+ // would JSON-quote it. --json keeps the object shape every other verb has.
372
+ if (json)
373
+ out({ version: VERSION });
374
+ else
375
+ console.log(VERSION);
376
+ return;
377
+ }
378
+ // Removed by decision 0032. Named, not merely unknown, so a script that used
379
+ // them learns what replaced them. Answered before the daemon is contacted.
380
+ const D = '(decision 0032)';
381
+ const removed = {
382
+ pull: `'runly pull' was removed ${D}: environments run in your worktree, so outputs are written in place`,
383
+ sync: `'runly sync' was removed ${D}: 'runly up' runs the due upkeep and every build, and restarts the services whose build output changed`,
384
+ bind: `'runly bind' was removed ${D}: use 'runly up', which applies the worktree as it is now; a ref is bound by checking it out ('git checkout <ref>', or a separate worktree)`,
385
+ run: `'runly run' was removed ${D}: run your checks yourself against the environment — 'runly ctx --env' prints its ports, URLs, connection strings and login as KEY=value lines`,
386
+ job: `'runly job' was removed with 'runly run' ${D}`,
387
+ };
388
+ if (removed[verb]) {
389
+ console.error(`runly: ${removed[verb]}`);
390
+ process.exit(64);
391
+ }
392
+ const removedFlags = {
393
+ '--pull': `--pull was removed ${D}: outputs are written in place`,
394
+ '--detach': `--detach was removed with 'runly run' ${D}`,
395
+ '--watch': `--watch was removed ${D}: re-run 'runly up' after a change; it runs the due upkeep and the builds, and restarts what changed`,
396
+ '--ref': `--ref was removed ${D}: environments run in your worktree; check the ref out ('git checkout <ref>', or a separate worktree) and run 'runly up'`,
397
+ };
398
+ // Removed by decision 0034: datastores are no longer leased through an
399
+ // environment of their own.
400
+ removedFlags['--data-only'] =
401
+ `--data-only was removed (decision 0034): a database without an environment is 'runly db new <datastore>' ` +
402
+ `(prints its url; dropped when its holder or worktree goes, or by 'runly db drop'), or 'runly db with <datastore> -- <cmd>', ` +
403
+ `which drops it when the command exits. To keep the environment but stop its services: 'runly down'`;
404
+ for (const [flag, msg] of Object.entries(removedFlags)) {
405
+ if (flags.has(flag)) {
406
+ console.error(`runly: ${msg}`);
407
+ process.exit(64);
408
+ }
409
+ }
410
+ const known = ['up', 'down', 'ctx', 'ps', 'plan', 'db', 'warm', 'exec', 'logs', 'token', 'reset-data', 'release', 'destroy', 'preview', 'status', 'doctor', 'appliance', 'pool', 'daemon', 'update'];
411
+ if (!known.includes(verb)) {
412
+ console.error(`runly: unknown verb '${verb}'\n\n${USAGE}`);
413
+ process.exit(64);
414
+ }
415
+ if (flags.has('--env')) {
416
+ if (verb !== 'ctx' && verb !== 'up') {
417
+ console.error(`runly ${verb}: --env is for 'ctx' and 'up'`);
418
+ process.exit(64);
419
+ }
420
+ if (json) {
421
+ console.error(`runly ${verb}: --env and --json are alternatives — pick one`);
422
+ process.exit(64);
423
+ }
424
+ }
425
+ // Collect before autospawn: a malformed binding manifest must not start a
426
+ // shared daemon with input values in its inherited environment.
427
+ const callerEnv = verb === 'up' ? collectCallerEnv(process.cwd()) : undefined;
428
+ // A manifest section runly accepts but no longer acts on (`checks:`) loads
429
+ // with a one-line warning on stderr; stdout stays clean for --json.
430
+ if (!['status', 'doctor', 'daemon', 'update', 'pool'].includes(verb)) {
431
+ try {
432
+ // Not validated here: the daemon validates the manifest it acts on, and the
433
+ // schema compile cost every CLI call ~100 ms (ctx, ps, logs are hot paths).
434
+ for (const w of ((st) => manifestDeprecations(st.manifest, st.file))(loadStack(process.cwd(), { validate: false })))
435
+ console.error(`runly: warning: ${w}`);
436
+ }
437
+ catch {
438
+ /* no manifest here, or an invalid one — the verb itself reports that */
439
+ }
440
+ }
441
+ // `db` names its one datastore positionally, so its --preset is a bare name.
442
+ let dbPreset;
443
+ if (verb === 'db') {
444
+ if (presetArgs.length > 1) {
445
+ console.error('runly db: --preset is given once — a copy is of one datastore');
446
+ process.exit(64);
447
+ }
448
+ const raw = presetArgs[0];
449
+ if (raw !== undefined) {
450
+ const at = raw.indexOf('=');
451
+ if (at >= 0 && raw.slice(0, at) !== positional[1]) {
452
+ console.error(`runly db: --preset ${raw} names another datastore than '${positional[1] ?? ''}'`);
453
+ process.exit(64);
454
+ }
455
+ dbPreset = at >= 0 ? raw.slice(at + 1) : raw;
456
+ }
457
+ const sub = positional[0];
458
+ if (!['new', 'with', 'ls', 'drop'].includes(sub ?? '')) {
459
+ console.error(`runly db: ${sub ? `unknown subcommand '${sub}'` : 'which subcommand?'} (new <datastore> | with <datastore> -- <cmd...> | ls | drop <name>)`);
460
+ process.exit(64);
461
+ }
462
+ if ((sub === 'new' || sub === 'with') && !positional[1]) {
463
+ console.error(`runly db ${sub}: which datastore? (runly db ${sub} <datastore>${sub === 'with' ? ' -- <cmd...>' : ''})`);
464
+ process.exit(64);
465
+ }
466
+ if (sub === 'with' && (passthrough === null || passthrough.length === 0)) {
467
+ console.error('runly db with: no command given (runly db with <datastore> -- <cmd...>)');
468
+ process.exit(64);
469
+ }
470
+ if (sub === 'drop' && !positional[1]) {
471
+ console.error('runly db drop: which copy? (the name from db new or db ls)');
472
+ process.exit(64);
473
+ }
474
+ }
475
+ let presets;
476
+ if (presetArgs.length > 0 && verb !== 'db') {
477
+ const manifest = loadStack(process.cwd()).manifest;
478
+ presets = parsePresetArgs(manifest, presetArgs);
479
+ }
480
+ if (verb === 'daemon' && (positional[0] === 'install' || positional[0] === 'uninstall')) {
481
+ await daemonUnitVerb(positional[0]);
482
+ return;
483
+ }
484
+ if (verb === 'daemon' && positional[0] !== 'stop') {
485
+ console.error('runly daemon: stop | install [--print] | uninstall');
486
+ process.exit(64);
487
+ }
488
+ const stopping = verb === 'daemon' && positional[0] === 'stop';
489
+ const daemon = stopping ? await daemonInfo() : await ensureDaemon(process.cwd());
490
+ if (!daemon) {
491
+ out({ stopping: true, stopped: true });
492
+ return;
493
+ }
494
+ // Version skew is REFUSED, not warned about.
495
+ //
496
+ // An old daemon does not reject arguments it has never heard of — it ignores
497
+ // them. A 0.8.0 daemon asked for `up --data-only` boots the whole application
498
+ // into what the caller believes is a datastore-only lease, and says nothing.
499
+ // That is the shape of issue #41: a wrong result that reads as a bug in
500
+ // another subsystem, and there it cost ~30 agents 14 hours. A warning on
501
+ // stderr does not reach a --json consumer at all, so the only honest answer
502
+ // is to stop.
503
+ //
504
+ // infra-error (exit 3), never env-error: an agent branches on the class
505
+ // MECHANICALLY (decision 0010) and env-error tells it to recycle an
506
+ // environment, which cannot fix a daemon running the wrong code.
507
+ //
508
+ // The three verbs that can REMEDY skew are exempt, or there would be no way
509
+ // out of it from a script.
510
+ const SKEW_EXEMPT = new Set(['update', 'doctor', 'daemon']);
511
+ const skew = versionSkew(VERSION, daemon.version);
512
+ if (skew && !SKEW_EXEMPT.has(verb)) {
513
+ errExit({ class: 'infra-error', message: skew.message, source: 'daemon' });
514
+ }
515
+ const cwd = process.cwd();
516
+ const holder = flagValue('--holder');
517
+ // The CLI exits per invocation, so ITS pid is useless as a liveness signal.
518
+ // This must be the long-lived caller — the agent process, or a supervising
519
+ // shell. Given one, the daemon releases the lease the moment it dies instead
520
+ // of holding the environment for the rest of the TTL.
521
+ const holderPidSource = flagValue('--holder-pid') !== undefined ? '--holder-pid' : 'BACKLOT_HOLDER_PID';
522
+ const holderPidRaw = flagValue('--holder-pid') ?? process.env.BACKLOT_HOLDER_PID;
523
+ let holderPid;
524
+ if (holderPidRaw !== undefined && holderPidRaw !== '') {
525
+ holderPid = Number(holderPidRaw);
526
+ if (!Number.isInteger(holderPid) || holderPid <= 0) {
527
+ console.error(`runly: ${holderPidSource} expects a process id, got '${holderPidRaw}'`);
528
+ process.exit(64);
529
+ }
530
+ // A holder that is ALREADY dead is worse than no holder at all: the daemon
531
+ // releases such a lease on its very next sweep, so the environment goes
532
+ // back in the pool while the caller is still using it — and the next bind
533
+ // hands that caller's database to somebody else, silently. The pattern
534
+ // that produces this is `BACKLOT_HOLDER_PID=$$ runly up` from an agent
535
+ // harness, which runs every command in a fresh shell: `$$` is a shell that
536
+ // has already exited. Refuse, and name the form that works.
537
+ if (!isAlive(holderPid)) {
538
+ console.error(`runly: ${holderPidSource} ${holderPid} is not a live process — the lease would be reclaimable the moment it is created.\n` +
539
+ ` If this came from '$$': each agent command runs in a fresh shell, so that shell is already gone.\n` +
540
+ ` Under Claude Code, drop ${holderPidSource}: runly tethers the lease to the agent by itself (CLAUDE_PID, when it is a live ancestor).\n` +
541
+ ` Elsewhere use 'runly ${verb} --ttl <minutes>'; ${holderPidSource} is for interactive shells that outlive the command.`);
542
+ process.exit(64);
543
+ }
544
+ }
545
+ // No explicit holder process: tether to the agent this CLI runs under, when
546
+ // it can be found (decision 0035) — Claude Code's CLAUDE_PID, if it is a
547
+ // live ancestor. BACKLOT_TETHER=off opts out.
548
+ // `--ttl` asks for a lease that ends at a deadline: it is the untethered
549
+ // form, so it opts out of the automatic tether, and next to an explicit
550
+ // holder process (which keeps the lease alive while it lives) it means
551
+ // nothing and is refused.
552
+ if (flagValue('--ttl') !== undefined && holderPid !== undefined && (verb === 'up' || verb === 'db')) {
553
+ console.error(`runly: --ttl sets the length of an untethered lease; a lease held by ${holderPidSource} ${holderPid} lives as long as that process — drop one of them`);
554
+ process.exit(64);
555
+ }
556
+ if (holderPid === undefined && flagValue('--ttl') === undefined && (verb === 'up' || (verb === 'db' && positional[0] === 'new')))
557
+ holderPid = autoTether();
558
+ let res;
559
+ switch (verb) {
560
+ case 'up': {
561
+ const ttl = flagValue('--ttl');
562
+ let ttlMs;
563
+ if (ttl !== undefined) {
564
+ ttlMs = parseTtlMinutes(ttl);
565
+ if (ttlMs === undefined) {
566
+ console.error(`runly: --ttl expects minutes (a positive number), got '${ttl}'`);
567
+ process.exit(64);
568
+ }
569
+ }
570
+ res = await rpc('up', { cwd, holder, holderPid, hygiene: hygiene(), ttlMs, services: positional, callerEnv, presets, rebuild: flags.has('--rebuild') }, progress);
571
+ endProgress();
572
+ // The skipped builds are named even without a progress stream (decision 0038).
573
+ if (res.ok && !json && !showProgress) {
574
+ const builds = res.data.bindDiagnostics?.builds ?? [];
575
+ for (const b of builds)
576
+ if (b.reason === 'when-unchanged')
577
+ console.error(`build ${b.service}: skipped (when: unchanged)`);
578
+ }
579
+ if (res.ok && !json) {
580
+ const c = res.data;
581
+ if (c.previewNotice)
582
+ console.error(`runly: ${c.previewNotice}`);
583
+ // `up --env`: the environment as shell exports, ready for eval.
584
+ for (const line of flags.has('--env') ? exportLines(c) : ctxSummary(c, 'up'))
585
+ console.log(line);
586
+ return;
587
+ }
588
+ break;
589
+ }
590
+ case 'down':
591
+ res = await rpc('down', { cwd, holder, services: positional }, progress);
592
+ endProgress();
593
+ if (res.ok && !json) {
594
+ const d = res.data;
595
+ console.log(d.stopped.length ? `stopped ${d.stopped.join(', ')}` : 'nothing was running');
596
+ if (d.dependentsStillRunning.length) {
597
+ console.error(`runly: note: ${d.dependentsStillRunning.join(', ')} still run${d.dependentsStillRunning.length === 1 ? 's' : ''} and depend${d.dependentsStillRunning.length === 1 ? 's' : ''} on what is now down`);
598
+ }
599
+ if (d.previewNotice)
600
+ console.error(`runly: ${d.previewNotice}`);
601
+ return;
602
+ }
603
+ break;
604
+ case 'plan':
605
+ res = await rpc('plan', { cwd, holder, services: positional, rebuild: flags.has('--rebuild') });
606
+ if (res.ok && !json) {
607
+ for (const line of planLines(res.data))
608
+ console.log(line);
609
+ return;
610
+ }
611
+ break;
612
+ case 'destroy':
613
+ res = await rpc('destroy', { cwd }, progress);
614
+ endProgress();
615
+ if (res.ok && !json) {
616
+ for (const line of destroyLines(res.data))
617
+ console.log(line);
618
+ return;
619
+ }
620
+ break;
621
+ case 'ps':
622
+ res = await rpc('ps', { cwd, all: flags.has('--all') });
623
+ if (res.ok && !json) {
624
+ for (const line of psLines(res.data, flags.has('--all')))
625
+ console.log(line);
626
+ return;
627
+ }
628
+ break;
629
+ case 'db': {
630
+ const sub = positional[0];
631
+ if (sub === 'new') {
632
+ res = await rpc('db-new', { cwd, holder, holderPid, datastore: positional[1], preset: dbPreset }, progress);
633
+ endProgress();
634
+ if (res.ok && !json) {
635
+ const d = res.data;
636
+ console.log(`name=${shellValue(d.name)}\nurl=${shellValue(d.url)}\ndatabase=${shellValue(d.ns)}\npreset=${shellValue(d.preset)}`);
637
+ return;
638
+ }
639
+ break;
640
+ }
641
+ if (sub === 'with') {
642
+ // stdout is the command's: everything runly says goes to stderr,
643
+ // prefixed `runly db with:` (with --json, one JSON line at the end).
644
+ // runly's own failures exit 1/2/3 like every verb — or --runly-exit N
645
+ // (125, say, as docker run does), so a caller can tell them from the
646
+ // command's own codes, which always pass through unchanged.
647
+ const own = flagValue('--runly-exit');
648
+ const ownCode = own === undefined ? undefined : Number(own);
649
+ if (own !== undefined && (!Number.isInteger(ownCode) || ownCode < 1 || ownCode > 255)) {
650
+ console.error(`runly db with: --runly-exit expects an exit code (1-255), got '${own}'`);
651
+ process.exit(64);
652
+ }
653
+ const report = (r) => { if (json)
654
+ console.error(JSON.stringify({ runlyDbWith: r })); };
655
+ // The copy is tethered to THIS process: the CLI outlives the command,
656
+ // so if it is killed the daemon's reaper drops the copy.
657
+ const created = await rpc('db-new', { cwd, holder, holderPid: process.pid, datastore: positional[1], preset: dbPreset }, progress);
658
+ endProgress();
659
+ if (!created.ok) {
660
+ const e = created.error;
661
+ const code = ownCode ?? (e.class === 'work-error' ? 1 : e.class === 'infra-error' ? 3 : 2);
662
+ console.error(`runly db with: [${e.class ?? 'error'}] ${e.message}${e.source ? ` (${e.source})` : ''}`);
663
+ if (e.logExcerpt)
664
+ console.error(`--- log excerpt ---\n${e.logExcerpt}`);
665
+ report({ from: 'runly', exitCode: code, error: e });
666
+ process.exit(code);
667
+ }
668
+ const copy = created.data;
669
+ const code = await runWithCopy(passthrough, copy);
670
+ const dropped = await rpc('db-drop', { name: copy.name }).catch((err) => ({ ok: false, error: { message: err.message } }));
671
+ if (!dropped.ok)
672
+ console.error(`runly db with: dropping ${copy.name} failed (${dropped.error.message}) — the sweeper retries it`);
673
+ report({ from: 'command', exitCode: code, copy: copy.name, database: copy.ns, dropped: dropped.ok });
674
+ process.exit(code);
675
+ }
676
+ if (sub === 'ls') {
677
+ res = await rpc('db-ls', { cwd, all: flags.has('--all') });
678
+ if (res.ok && !json) {
679
+ for (const line of dbLines(res.data.copies))
680
+ console.log(line);
681
+ return;
682
+ }
683
+ break;
684
+ }
685
+ res = await rpc('db-drop', { name: positional[1] });
686
+ break;
687
+ }
688
+ case 'ctx':
689
+ res = await rpc('ctx', { cwd, holder });
690
+ if (res.ok && flags.has('--env')) {
691
+ // `export KEY=value` lines — the interface a repo's own scripts (its
692
+ // tests, its smoke checks) read: eval "$(runly ctx --env)" && npm test.
693
+ for (const line of exportLines(res.data))
694
+ console.log(line);
695
+ return;
696
+ }
697
+ if (res.ok && !json) {
698
+ for (const line of ctxSummary(res.data, 'ctx'))
699
+ console.log(line);
700
+ return;
701
+ }
702
+ break;
703
+ case 'warm': {
704
+ res = await rpc('warm', { cwd }, progress);
705
+ endProgress();
706
+ if (!res.ok)
707
+ break;
708
+ const w = res.data;
709
+ if (json)
710
+ console.log(JSON.stringify(w));
711
+ else {
712
+ // One line per step: what it was, what happened, how long it took. The
713
+ // upkeep COMMAND is never printed — commands may carry credentials —
714
+ // only the rule's position and its trigger glob.
715
+ const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
716
+ console.log(`warming ${w.root}`);
717
+ for (const st of w.steps) {
718
+ const what = st.kind === 'upkeep' ? `upkeep rule ${st.index} (${st.when})` : `build '${st.service}'`;
719
+ console.log(` ${what}: ${st.status}${st.status === 'ran' || st.status === 'failed' ? ` in ${secs(st.durationMs)}` : ''}${st.reason ? ` — ${st.reason}` : ''}`);
720
+ }
721
+ console.log(`${w.ok ? 'warm' : 'failed'} after ${secs(w.durationMs)}`);
722
+ if (w.failure) {
723
+ console.error(`runly: [${w.failure.class}] ${w.failure.message}${w.failure.source ? ` (${w.failure.source})` : ''}`);
724
+ if (w.failure.logExcerpt)
725
+ console.error(`--- log excerpt ---\n${w.failure.logExcerpt}`);
726
+ }
727
+ }
728
+ process.exitCode = w.ok ? 0 : 1;
729
+ return;
730
+ }
731
+ case 'exec': {
732
+ // The whole passthrough is the command, verbatim — its own --flags intact.
733
+ //
734
+ // One token is a SHELL STRING: `exec 'echo hi > out.txt'` must keep its
735
+ // redirection and operators. Several tokens mean the caller's own shell
736
+ // already split them, so their boundaries are real and must survive —
737
+ // joining on spaces turned `exec cat "my file.txt"` into two arguments.
738
+ const parts = passthrough ?? positional;
739
+ const cmd = parts.length === 1 ? parts[0] : parts.map(shellQuote).join(' ');
740
+ if (!cmd) {
741
+ console.error('runly exec: no command given');
742
+ process.exit(64);
743
+ }
744
+ // The daemon resumes the environment and hands back its variables; the
745
+ // command runs HERE (decision 0040): the caller's stdio and environment,
746
+ // its real exit code, and no lock on the environment while it runs.
747
+ res = await rpc('exec-env', { cwd, holder });
748
+ if (!res.ok)
749
+ break;
750
+ const d = res.data;
751
+ const r = await runExec(parts, cmd, d, json);
752
+ if (r.timedOutS !== undefined) {
753
+ errExit({ class: 'work-error', message: `exec timed out after ${r.timedOutS}s (its process group was killed; BACKLOT_CMD_TIMEOUT_S sets the deadline)`, source: 'exec', logExcerpt: r.stderr?.slice(-800) });
754
+ }
755
+ if (json)
756
+ console.log(JSON.stringify({ ok: r.code === 0, exitCode: r.code, stdout: r.stdout, stderr: r.stderr }));
757
+ return process.exit(r.code);
758
+ }
759
+ case 'logs': {
760
+ const rawLines = flagValue('--lines');
761
+ const lines = rawLines === undefined ? undefined : Number(rawLines);
762
+ // NaN reached the daemon as slice(-NaN) and quietly returned the WHOLE
763
+ // log — the opposite of what a bounded --lines asks for.
764
+ if (lines !== undefined && (!Number.isInteger(lines) || lines <= 0)) {
765
+ console.error(`runly logs: --lines expects a positive integer, got '${rawLines}'`);
766
+ process.exit(64);
767
+ }
768
+ const since = flagValue('--since');
769
+ if (since !== undefined && !parseSince(since)) {
770
+ console.error(`runly logs: --since expects 'up' or a duration (90s, 10m, 2h), got '${since}'`);
771
+ process.exit(64);
772
+ }
773
+ const regex = (flag) => {
774
+ const v = flagValue(flag);
775
+ if (v === undefined)
776
+ return undefined;
777
+ try {
778
+ return new RegExp(v);
779
+ }
780
+ catch (e) {
781
+ console.error(`runly logs: ${flag} is not a valid regular expression: ${e.message}`);
782
+ return process.exit(64);
783
+ }
784
+ };
785
+ const grep = regex('--grep');
786
+ const until = regex('--until');
787
+ const rawTimeout = flagValue('--timeout');
788
+ const timeoutMs = rawTimeout === undefined ? undefined : parseDuration(rawTimeout);
789
+ if (rawTimeout !== undefined && (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
790
+ console.error(`runly logs: --timeout expects seconds or a duration (30, 90s, 5m), got '${rawTimeout}'`);
791
+ process.exit(64);
792
+ }
793
+ const follow = flags.has('--follow') || until !== undefined;
794
+ if (timeoutMs !== undefined && !follow) {
795
+ console.error('runly logs: --timeout bounds -f/--until; without them there is nothing to wait for');
796
+ process.exit(64);
797
+ }
798
+ res = await rpc('logs-spec', { cwd, holder, services: positional, build: flags.has('--build') });
799
+ if (!res.ok)
800
+ break;
801
+ const spec = res.data;
802
+ const code = await showLogs(spec, { lines, since, grep, follow, until, timeoutMs, json, prefix: spec.files.length !== 1 });
803
+ const within = `${rawTimeout ?? ''}${/^\d+$/.test(rawTimeout ?? '') ? 's' : ''}`;
804
+ if (code === 124)
805
+ console.error(until ? `runly logs: --until /${until.source}/ did not match within ${within}` : `runly logs: --timeout ${within} ran out (exit 124, decision 0038)`);
806
+ process.exit(code);
807
+ break;
808
+ }
809
+ case 'reset-data':
810
+ res = await rpc('reset-data', { cwd, holder, presets }, progress);
811
+ endProgress();
812
+ break;
813
+ case 'token': {
814
+ res = await rpc('token', { cwd, holder, role: flagValue('--role') ?? 'admin' });
815
+ // --raw prints the bare token and nothing else. The wrapper's default is a
816
+ // JSON object, but a manifest's own auth.token script prints the bare
817
+ // string — so the documented "prints the plaintext token on stdout"
818
+ // described the script, not this command, and callers piped
819
+ // `{"token":"tk_…","role":"human"}` straight into an Authorization header
820
+ // and read the resulting 401 as a permissions problem.
821
+ if (res?.ok && flags.has('--raw')) {
822
+ console.log(String(res.data.token));
823
+ process.exit(0);
824
+ }
825
+ break;
826
+ }
827
+ case 'release':
828
+ res = await rpc('release', { cwd, holder });
829
+ break;
830
+ case 'preview': {
831
+ const sub = positional[0];
832
+ if (sub === 'stop') {
833
+ res = await rpc('preview-stop', { cwd, holder });
834
+ }
835
+ else if (!sub) {
836
+ console.error('runly preview: which service? (usage: runly preview <service> | runly preview stop)');
837
+ process.exit(64);
838
+ }
839
+ else {
840
+ const ttl = flagValue('--ttl');
841
+ let ttlMs;
842
+ if (ttl !== undefined) {
843
+ ttlMs = parseTtlMinutes(ttl);
844
+ if (ttlMs === undefined) {
845
+ console.error(`runly: --ttl expects minutes (a positive number), got '${ttl}'`);
846
+ process.exit(64);
847
+ }
848
+ }
849
+ // --https-port pins the tailnet port for THIS publish only (tailscale
850
+ // publisher), so one long-lived environment can keep a remembered
851
+ // address without pinning it in the shared manifest — where every other
852
+ // environment of the stack would then collide on it.
853
+ const portFlag = flagValue('--https-port');
854
+ let httpsPort;
855
+ if (portFlag !== undefined) {
856
+ httpsPort = Number(portFlag);
857
+ if (!Number.isInteger(httpsPort) || httpsPort < 1 || httpsPort > 65535) {
858
+ console.error(`runly: --https-port expects a port number (1-65535), got '${portFlag}'`);
859
+ process.exit(64);
860
+ }
861
+ }
862
+ res = await rpc('preview', {
863
+ cwd,
864
+ holder,
865
+ service: sub,
866
+ ...(ttlMs !== undefined ? { ttlMs } : {}),
867
+ ...(httpsPort !== undefined ? { httpsPort } : {}),
868
+ });
869
+ }
870
+ break;
871
+ }
872
+ case 'status':
873
+ res = await rpc('status', {});
874
+ if (res.ok && !json) {
875
+ for (const line of statusLines(res.data))
876
+ console.log(line);
877
+ return;
878
+ }
879
+ break;
880
+ case 'doctor':
881
+ res = await rpc('doctor', { cliVersion: VERSION });
882
+ break;
883
+ case 'appliance': {
884
+ const sub = positional[0] ?? 'ls';
885
+ const name = positional[1];
886
+ if (sub === 'ls')
887
+ res = await rpc('appliance-ls', { cwd });
888
+ else if (sub === 'start')
889
+ res = await rpc('appliance-start', { cwd, name });
890
+ else if (sub === 'stop') {
891
+ if (!name) {
892
+ console.error('runly appliance stop: a name is required (stopping everything is never implicit)');
893
+ process.exit(64);
894
+ }
895
+ res = await rpc('appliance-stop', { cwd, name });
896
+ }
897
+ else {
898
+ console.error(`runly appliance: unknown subcommand '${sub}' (ls | start | stop)`);
899
+ process.exit(64);
900
+ }
901
+ break;
902
+ }
903
+ case 'pool': {
904
+ const sub = positional[0] ?? 'ls';
905
+ if (sub === 'ls')
906
+ res = await rpc('status', {});
907
+ else if (sub === 'recycle') {
908
+ // The env id used to be parsed and then dropped, so `recycle <env-id>`
909
+ // recycled the WHOLE pool — the opposite of what naming one asks for.
910
+ res = await rpc('pool-recycle', { envId: positional[1], force: flags.has('--force') || flags.has('--all') });
911
+ }
912
+ else if (sub === 'reconcile')
913
+ res = await rpc('pool-reconcile', {});
914
+ else if (sub === 'gc')
915
+ res = await rpc('pool-gc', {});
916
+ else if (sub === 'doctor') {
917
+ res = await rpc('pool-doctor', { cwd, fix: flags.has('--fix') });
918
+ if (res.ok && !json) {
919
+ for (const line of doctorLines(res.data))
920
+ console.log(line);
921
+ return;
922
+ }
923
+ }
924
+ else {
925
+ console.error(`runly pool: unknown subcommand '${sub}' (ls | recycle | reconcile | gc | doctor [--fix])`);
926
+ process.exit(64);
927
+ }
928
+ break;
929
+ }
930
+ case 'daemon': {
931
+ res = await rpc('shutdown', {});
932
+ if (!res.ok)
933
+ break;
934
+ if (!(await awaitDaemonGone(daemon.pid, daemonStopTimeoutMs()))) {
935
+ errExit({
936
+ class: 'infra-error',
937
+ message: `the daemon accepted shutdown and is still shutting down after ${Math.round(daemonStopTimeoutMs() / 1000)}s — ` +
938
+ `service teardown may still be in progress. Do not issue another stop; wait for ${daemon.pid !== undefined ? `pid ${daemon.pid}` : 'the daemon'} to exit, ` +
939
+ `and check ${join(stateRoot(), 'daemon.log')} if it never does.`,
940
+ source: 'daemon',
941
+ });
942
+ return;
943
+ }
944
+ res = { ok: true, data: { stopping: true, stopped: true } };
945
+ break;
946
+ }
947
+ case 'update': {
948
+ const install = installKind();
949
+ // THE case this verb exists for is an old daemon — and an old daemon has
950
+ // never heard of `update-plan`. Calling it unconditionally made `update`
951
+ // fail with "daemon does not know verb 'update-plan'" against every
952
+ // pre-0.9.0 daemon, which is every daemon anyone was running when 0.9.0
953
+ // shipped: the one situation it was built to fix was the one it could not.
954
+ //
955
+ // (The tests missed it because BACKLOT_FAKE_VERSION runs THIS code while
956
+ // claiming an old version, so the stand-in always knew the new verbs — the
957
+ // same "a stand-in must model the thing it stands in for" lesson as the
958
+ // ping stand-ins, not applied here. tests/daemon-update.test.ts now drives
959
+ // a stub that answers the way an old daemon really does.)
960
+ const legacyVerb = (e) => /does not know verb/i.test(e.message);
961
+ const planRes = await rpc('update-plan', { cliVersion: VERSION });
962
+ if (!planRes.ok && !legacyVerb(planRes.error)) {
963
+ errExit(planRes.error);
964
+ return;
965
+ }
966
+ const legacy = !planRes.ok;
967
+ // An old daemon cannot describe its own state, but `status` is a verb every
968
+ // version has — enough to name the holders who will have to rebind, and to
969
+ // learn the pid to wait on.
970
+ let plan;
971
+ if (legacy) {
972
+ const st = await rpc('status', {});
973
+ const s = (st.ok ? st.data : {});
974
+ plan = {
975
+ daemon: 'pre-0.9.0',
976
+ daemonPid: s.pid,
977
+ busy: [], // unknowable: no old daemon can be asked what is in flight
978
+ leases: (s.envs ?? [])
979
+ .filter((e) => e.lease)
980
+ .map((e) => ({ envId: String(e.id), holder: String(e.lease?.holder), kind: String(e.lease?.kind) })),
981
+ };
982
+ }
983
+ else {
984
+ plan = planRes.data;
985
+ }
986
+ // Same version, different build: a checkout rebuilt (or a package
987
+ // reinstalled) since the daemon started. Not skew — nothing refuses on it —
988
+ // but exactly what `update` is for.
989
+ const rebuilt = !skew && rebuiltSince(BUILD, plan.daemonBuild);
990
+ const report = {
991
+ cli: VERSION,
992
+ daemon: plan.daemon,
993
+ build: { cli: BUILD, daemon: plan.daemonBuild ?? null, rebuilt },
994
+ journalSchema: plan.journalSchema,
995
+ install: install.kind,
996
+ installRoot: install.root,
997
+ skew: skew ? { direction: skew.direction, message: skew.message } : null,
998
+ busy: plan.busy,
999
+ // Named, not refused: a restart keeps every lease and costs its holder
1000
+ // one rebind. See Engine.assertRestartable for why that is not consent
1001
+ // worth asking for — and why an in-flight operation is.
1002
+ holdersWhoMustRebind: plan.leases,
1003
+ upgradeHint: install.upgradeHint,
1004
+ ...(legacy
1005
+ ? {
1006
+ note: `the running daemon predates 'update-plan', so it cannot report what is in flight — ` +
1007
+ `the in-flight refusal cannot apply here, and the restart proceeds as 'daemon stop' always has`,
1008
+ }
1009
+ : {}),
1010
+ };
1011
+ // --check is the diagnose half, and it never restarts anything. `doctor`
1012
+ // reports skew too; this reports what a restart would DO.
1013
+ if (flags.has('--check')) {
1014
+ out({ ...report, restarted: false });
1015
+ process.exit(0);
1016
+ }
1017
+ // Already the installed build: say so and stop. An update that restarts
1018
+ // unconditionally would make `runly update` in a script a recurring
1019
+ // outage for every lease holder on the box, for no gain.
1020
+ if (!skew && !rebuilt) {
1021
+ out({
1022
+ ...report,
1023
+ restarted: false,
1024
+ note: `the running daemon is already runly ${plan.daemon} — nothing to do. If you have not upgraded yet: ${install.upgradeHint}`,
1025
+ });
1026
+ process.exit(0);
1027
+ }
1028
+ // `daemon-restart` carries the refusals (in-flight, downgrade). An old
1029
+ // daemon has neither the verb nor anything to refuse WITH, so fall back to
1030
+ // `shutdown` — which every version has, and which is exactly what
1031
+ // `daemon stop` has always done. Without this fallback there is no way out
1032
+ // of skew for the daemon that most needs one.
1033
+ let stopRes = await rpc('daemon-restart', { cliVersion: VERSION, force: flags.has('--force') });
1034
+ if (!stopRes.ok && legacyVerb(stopRes.error))
1035
+ stopRes = await rpc('shutdown', {});
1036
+ if (!stopRes.ok) {
1037
+ errExit(stopRes.error);
1038
+ return;
1039
+ }
1040
+ // The result frame arrives ~50ms BEFORE the process exits, so a respawn
1041
+ // that pings immediately would be answered by the dying daemon, conclude
1042
+ // one is already up, and leave the old build serving — an update that
1043
+ // reports success and changes nothing.
1044
+ if (!(await awaitDaemonGone(plan.daemonPid))) {
1045
+ errExit({
1046
+ class: 'infra-error',
1047
+ message: `the daemon accepted the restart but is still answering — it may be wedged mid-shutdown; check the log and retry`,
1048
+ source: 'daemon',
1049
+ });
1050
+ }
1051
+ // Spawned from THIS CLI's dist, which is what makes the new daemon the
1052
+ // installed build.
1053
+ const now = await ensureDaemon(cwd);
1054
+ const remaining = versionSkew(VERSION, now.version);
1055
+ if (remaining) {
1056
+ errExit({
1057
+ class: 'infra-error',
1058
+ message: `restarted, but the daemon now answering is ${now.version ?? 'unversioned'} rather than ${VERSION} — ` +
1059
+ `another runly install owns this state root (${install.root} is this one). Check which one is on your PATH.`,
1060
+ source: 'daemon',
1061
+ });
1062
+ }
1063
+ out({ ...report, restarted: true, from: plan.daemon, to: now.version, daemonPid: now.pid, ...(rebuilt ? { reason: 'rebuilt' } : {}) });
1064
+ break;
1065
+ }
1066
+ default:
1067
+ process.exit(64);
1068
+ }
1069
+ if (!res)
1070
+ process.exit(0);
1071
+ if (!res.ok) {
1072
+ errExit(res.error);
1073
+ return;
1074
+ }
1075
+ out(res.data);
1076
+ }
1077
+ function planLines(p) {
1078
+ const out = [];
1079
+ out.push(`${p.stack}: ${p.verdict}`);
1080
+ if (p.items.length === 0)
1081
+ out.push(' nothing to build or start — everything named already runs');
1082
+ for (const it of p.items) {
1083
+ const cost = it.skipped ? `skipped (${it.skipped})` : `${formatSize(it.memoryBytes)}, ${it.cpu} cpu${it.declared ? '' : ' (default — not declared in resources:)'}`;
1084
+ out.push(` ${it.kind.padEnd(9)} ${it.name.padEnd(20)} ${cost}`);
1085
+ }
1086
+ out.push(` needs ${formatSize(p.need.memoryBytes)}, ${Math.round(p.need.cpu * 10) / 10} cpu (starts summed, the largest build on top)`);
1087
+ if (p.budget.enabled) {
1088
+ out.push(` budget ${formatSize(p.budget.memoryBytes)}, ${p.budget.cpu} cpu — runly has committed ${formatSize(p.committed.memoryBytes)}, ${Math.round(p.committed.cpu * 10) / 10} cpu; ${p.queue} waiting`);
1089
+ }
1090
+ else
1091
+ out.push(' budget off (BACKLOT_BUDGET=off)');
1092
+ out.push(` box: ${p.machine.availableBytes === null ? '' : `${formatSize(p.machine.availableBytes)} of `}${formatSize(p.machine.totalBytes)} available, load ${p.machine.load1.toFixed(1)} on ${p.machine.cores} cores; a wait gives up after ${formatDuration(p.budget.waitMs)}`);
1093
+ if (p.defaultsAssumed.length)
1094
+ out.push(` note: ${p.defaultsAssumed.length} cost(s) are the conservative default — declare resources: in the manifest for an exact plan`);
1095
+ return out;
1096
+ }
1097
+ function doctorLines(d) {
1098
+ if (d.findings.length === 0)
1099
+ return ['pool doctor: nothing left behind'];
1100
+ const out = d.findings.map((f) => `${f.fixed ? 'removed' : f.error ? 'FAILED ' : d.fix && f.kind !== 'foreign-namespace' ? 'kept ' : 'found '} ${f.kind.padEnd(17)} ${f.what} — ${f.detail}${f.error ? ` (${f.error})` : ''}`);
1101
+ if (!d.fix && !d.clean)
1102
+ out.push(`dry run — 'runly pool doctor --fix' removes what is listed (never foreign-namespace)`);
1103
+ return out;
1104
+ }
1105
+ /**
1106
+ * `runly daemon install [--print]` / `runly daemon uninstall` (decision 0039):
1107
+ * a systemd user unit (Linux) or launchd agent (macOS) that restarts a crashed
1108
+ * daemon within seconds. Install writes and enables it; it starts the daemon
1109
+ * through it when none runs, and otherwise says how to move the running one
1110
+ * under it (`runly update`, which keeps leases). Uninstall disables and
1111
+ * removes it; a running daemon keeps running, unsupervised.
1112
+ */
1113
+ async function daemonUnitVerb(sub) {
1114
+ const plan = unitPlan(undefined, unitEnvNames);
1115
+ if (!plan) {
1116
+ errExit({ class: 'work-error', message: `runly daemon ${sub}: no supervisor for ${process.platform} (systemd user units on Linux, launchd on macOS)`, source: 'daemon' });
1117
+ return;
1118
+ }
1119
+ const e = plan.environment;
1120
+ const environment = { captured: e.captured, notSet: e.notSet, excludedInputs: e.excluded, leftOut: e.leftOut };
1121
+ // What the unit carries, and what it does not, said before anything is written.
1122
+ const sayEnvironment = () => {
1123
+ console.error(`runly daemon install: the unit's environment — captured from this shell: ${e.captured.join(', ') || 'nothing'}`);
1124
+ if (e.notSet.length)
1125
+ console.error(` referenced by the manifest or --env but not set in this shell (not captured): ${e.notSet.join(', ')}`);
1126
+ if (e.excluded.length)
1127
+ console.error(` env_from inputs, never stored in a unit (each 'up' supplies them): ${e.excluded.join(', ')}`);
1128
+ console.error(` ${e.leftOut} other variable(s) of this shell were left out; add one with --env NAME`);
1129
+ };
1130
+ if (sub === 'install' && flags.has('--print')) {
1131
+ if (json)
1132
+ out({ kind: plan.kind, name: plan.name, path: plan.path, content: plan.content, environment });
1133
+ else {
1134
+ sayEnvironment();
1135
+ process.stdout.write(plan.content);
1136
+ }
1137
+ return;
1138
+ }
1139
+ if (sub === 'uninstall') {
1140
+ const installed = installedUnit();
1141
+ if (!installed) {
1142
+ out({ kind: plan.kind, name: plan.name, path: plan.path, removed: false, detail: 'no unit is installed for this state root' });
1143
+ return;
1144
+ }
1145
+ const r = uninstallUnit(installed);
1146
+ out({ kind: plan.kind, name: plan.name, path: plan.path, removed: true, steps: r.steps, detail: 'the running daemon (if any) keeps running without a supervisor; the next verb that finds none autospawns one' });
1147
+ return;
1148
+ }
1149
+ if (sub === 'install' && !json)
1150
+ sayEnvironment();
1151
+ const r = installUnit(plan);
1152
+ if (r.error) {
1153
+ errExit({ class: 'infra-error', message: `runly daemon install: ${r.error}`, source: 'daemon', logExcerpt: r.steps.join('\n') });
1154
+ return;
1155
+ }
1156
+ const running = await daemonInfo().catch(() => null);
1157
+ let started = false;
1158
+ if (!running) {
1159
+ started = startUnit(plan);
1160
+ if (started) {
1161
+ const deadline = Date.now() + 10_000;
1162
+ while (Date.now() < deadline && !(await daemonInfo().catch(() => null)))
1163
+ await new Promise((res) => setTimeout(res, 100));
1164
+ }
1165
+ }
1166
+ const next = running
1167
+ ? `a daemon (pid ${running.pid}) is already running outside the unit — 'runly update' restarts it under the unit (leases survive)`
1168
+ : started
1169
+ ? `the daemon now runs under ${plan.kind === 'systemd' ? `${plan.name}.service` : plan.name}`
1170
+ : `the unit is installed; the next runly verb starts the daemon through it`;
1171
+ out({ kind: plan.kind, name: plan.name, path: plan.path, installed: true, started, steps: r.steps, next, environment });
1172
+ }
1173
+ /**
1174
+ * Run `runly exec`'s command in the worktree (decision 0040). One token is a
1175
+ * shell string, several keep the caller's word splits. The environment is the
1176
+ * caller's own plus the environment's variables and the exec tag. Plain: the
1177
+ * caller's stdio, nothing buffered or cut. `--json`: stdout and stderr are
1178
+ * collected whole for the JSON answer. The exit code is the command's (128+n
1179
+ * for a signal). No deadline unless BACKLOT_CMD_TIMEOUT_S is set; while the
1180
+ * command runs, the environment is touched every minute (it is in use).
1181
+ */
1182
+ async function runExec(parts, cmd, d, json) {
1183
+ const { spawn } = await import('node:child_process');
1184
+ const { constants } = await import('node:os');
1185
+ const env = { ...process.env, ...d.vars };
1186
+ const fromTerminal = process.stdin.isTTY === true || process.stdout.isTTY === true || process.stderr.isTTY === true;
1187
+ // Without a terminal the command leads a group of its own, so a deadline or
1188
+ // a signal reaches everything it started; at a terminal it stays in ours.
1189
+ const ownGroup = !fromTerminal;
1190
+ const stdio = json ? ['inherit', 'pipe', 'pipe'] : ['inherit', 'inherit', 'inherit'];
1191
+ const child = parts.length === 1
1192
+ ? spawn(cmd, { cwd: d.root, env, stdio, shell: true, detached: ownGroup })
1193
+ : spawn(parts[0], parts.slice(1), { cwd: d.root, env, stdio, detached: ownGroup });
1194
+ const out = [];
1195
+ const err = [];
1196
+ child.stdout?.on('data', (b) => out.push(b));
1197
+ child.stderr?.on('data', (b) => err.push(b));
1198
+ const signalAll = (sig) => {
1199
+ try {
1200
+ if (ownGroup && child.pid !== undefined)
1201
+ process.kill(-child.pid, sig);
1202
+ else
1203
+ child.kill(sig);
1204
+ }
1205
+ catch {
1206
+ /* already gone */
1207
+ }
1208
+ };
1209
+ const onInt = fromTerminal ? () => undefined : () => signalAll('SIGINT');
1210
+ const onTerm = () => signalAll('SIGTERM');
1211
+ const onHup = () => signalAll('SIGHUP');
1212
+ process.on('SIGINT', onInt);
1213
+ process.on('SIGTERM', onTerm);
1214
+ process.on('SIGHUP', onHup);
1215
+ const heartbeat = setInterval(() => void rpc('exec-touch', { envId: d.envId }).catch(() => null), 60_000);
1216
+ heartbeat.unref();
1217
+ const raw = process.env.BACKLOT_CMD_TIMEOUT_S;
1218
+ const deadlineS = raw !== undefined && raw !== '' && Number(raw) > 0 ? Number(raw) : undefined;
1219
+ let timedOut = false;
1220
+ const timer = deadlineS === undefined ? undefined : setTimeout(() => {
1221
+ timedOut = true;
1222
+ signalAll('SIGKILL');
1223
+ }, deadlineS * 1000);
1224
+ const code = await new Promise((resolve) => {
1225
+ child.on('error', (e) => {
1226
+ console.error(`runly exec: could not start '${parts[0]}': ${e.message}`);
1227
+ resolve(127);
1228
+ });
1229
+ child.on('close', (c, signal) => resolve(c ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)));
1230
+ });
1231
+ if (timer)
1232
+ clearTimeout(timer);
1233
+ clearInterval(heartbeat);
1234
+ process.off('SIGINT', onInt);
1235
+ process.off('SIGTERM', onTerm);
1236
+ process.off('SIGHUP', onHup);
1237
+ const stdout = json ? Buffer.concat(out).toString() : undefined;
1238
+ const stderr = json ? Buffer.concat(err).toString() : undefined;
1239
+ return { code, stdout, stderr, timedOutS: timedOut ? deadlineS : undefined };
1240
+ }
1241
+ /**
1242
+ * Run `db with`'s command against the copy, its exit code as ours. One token
1243
+ * is a shell string (as for exec); several keep the caller's own word splits.
1244
+ * SIGINT/SIGTERM/SIGHUP are passed on, so the copy is dropped after the
1245
+ * command stops.
1246
+ *
1247
+ * The command runs under a watchdog (`watchdog.js`) that holds a pipe from
1248
+ * this process: when the CLI dies without the chance to stop it (SIGKILL,
1249
+ * OOM, a kill of its process group that the command's own group escaped),
1250
+ * the pipe closes and the watchdog takes the command down — it must not go on
1251
+ * working against a copy the daemon is about to drop (decision 0039). The
1252
+ * daemon backs it up: the command carries the copy's tag (`BACKLOT_DB_COPY`,
1253
+ * inherited by everything it starts: Linux finds them all by it), and the
1254
+ * watchdog's pid and start time are recorded on the copy. Without a terminal
1255
+ * it leads its own process group, which is signalled as a whole; at a
1256
+ * terminal it stays in ours, so job control (Ctrl-C, reading the terminal)
1257
+ * behaves as before.
1258
+ */
1259
+ async function runWithCopy(parts, copy) {
1260
+ const { spawn } = await import('node:child_process');
1261
+ const { constants } = await import('node:os');
1262
+ const { fileURLToPath } = await import('node:url');
1263
+ const { dirname } = await import('node:path');
1264
+ const env = { ...process.env, ...dbVars(copy), ...dbCopyTag(copy.name, stateRoot()) };
1265
+ const fromTerminal = process.stdin.isTTY === true || process.stdout.isTTY === true || process.stderr.isTTY === true;
1266
+ const ownGroup = !fromTerminal;
1267
+ const watchdog = join(dirname(fileURLToPath(import.meta.url)), 'watchdog.js');
1268
+ const child = spawn(process.execPath, [watchdog, parts.length === 1 ? 'shell' : 'argv', ownGroup ? 'group' : 'nogroup', ...parts], {
1269
+ stdio: ['inherit', 'inherit', 'inherit', 'pipe'],
1270
+ env,
1271
+ detached: ownGroup,
1272
+ });
1273
+ // The tie: this end closes when this process dies, however it dies.
1274
+ child.stdio[3]?.unref?.();
1275
+ // Listen before anything is awaited: a command that ends while the
1276
+ // attach RPC is in flight must not be missed.
1277
+ const ended = new Promise((resolve) => {
1278
+ child.on('error', (err) => {
1279
+ console.error(`runly db with: could not start '${parts[0]}': ${err.message}`);
1280
+ resolve(127);
1281
+ });
1282
+ child.on('exit', (code, signal) => resolve(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)));
1283
+ });
1284
+ if (child.pid !== undefined) {
1285
+ // Best effort: a daemon that cannot record it still finds the command by
1286
+ // its tag on Linux.
1287
+ const attached = await rpc('db-attach', { name: copy.name, pid: child.pid, start: startTime(child.pid), pgid: ownGroup ? child.pid : undefined }).catch(() => null);
1288
+ if (attached && !attached.ok)
1289
+ console.error(`runly db with: could not record the command on ${copy.name} (${attached.error.message})`);
1290
+ }
1291
+ const forward = (sig) => () => {
1292
+ try {
1293
+ // In its own group, the whole group: the watchdog, the shell and the
1294
+ // real command beneath it.
1295
+ if (ownGroup && child.pid !== undefined)
1296
+ process.kill(-child.pid, sig);
1297
+ else
1298
+ child.kill(sig);
1299
+ }
1300
+ catch {
1301
+ /* already gone */
1302
+ }
1303
+ };
1304
+ // Ctrl-C at a terminal signals the whole foreground process group, and the
1305
+ // child is in ours: it already has its SIGINT. Forwarding sent it a second
1306
+ // one, which makes many tools (npm, a test runner) abort their own cleanup.
1307
+ // Only a SIGINT sent to this process alone (no terminal) is passed on.
1308
+ const onInt = fromTerminal ? () => undefined : forward('SIGINT');
1309
+ const onTerm = forward('SIGTERM');
1310
+ const onHup = forward('SIGHUP');
1311
+ process.on('SIGINT', onInt);
1312
+ process.on('SIGTERM', onTerm);
1313
+ process.on('SIGHUP', onHup);
1314
+ const code = await ended;
1315
+ process.off('SIGINT', onInt);
1316
+ process.off('SIGTERM', onTerm);
1317
+ process.off('SIGHUP', onHup);
1318
+ return code;
1319
+ }
1320
+ /**
1321
+ * What `db with`'s command reads (and `db new` prints): RUNLY_DB_URL to
1322
+ * connect; RUNLY_DB_DATABASE, the database's own name on its server (the file
1323
+ * for sqlite); RUNLY_DB_COPY, runly's handle for `db drop`. RUNLY_DB_NAME is
1324
+ * the handle too, kept for the scripts that read it.
1325
+ */
1326
+ function dbVars(copy) {
1327
+ return { RUNLY_DB_URL: copy.url, RUNLY_DB_DATABASE: copy.ns, RUNLY_DB_COPY: copy.name, RUNLY_DB_NAME: copy.name };
1328
+ }
1329
+ /** A plain, aligned table; `-` for an empty cell. */
1330
+ function table(header, rows) {
1331
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
1332
+ const line = (cells) => cells.map((c, i) => (c || '-').padEnd(widths[i])).join(' ').trimEnd();
1333
+ return [line(header), ...rows.map(line)];
1334
+ }
1335
+ const ago = (ms) => (ms === null ? '' : ms < 60_000 ? `${Math.round(ms / 1000)}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`);
1336
+ const mb = (bytes) => (bytes === null ? '' : `${Math.round(bytes / (1024 * 1024))}M`);
1337
+ function dbLines(copies) {
1338
+ if (copies.length === 0)
1339
+ return ['no database copies'];
1340
+ return table(['NAME', 'DATASTORE', 'PRESET', 'STATE', 'HOLDER', 'CREATED'], copies.map((c) => [
1341
+ c.name, c.datastore, c.preset, c.state,
1342
+ `${c.holder}${c.holderPid !== null ? ` (pid ${c.holderPid}${c.holderAlive === false ? ', gone' : ''})` : ''}`,
1343
+ `${ago(Date.now() - c.createdAt)} ago`,
1344
+ ]));
1345
+ }
1346
+ /** The plain `status` (0.19): the daemon, each environment in a line, the budget, recent warnings. `--json` is the full blob. */
1347
+ function statusLines(d) {
1348
+ const out = [`runly daemon pid ${d.pid} (${d.supervisor === 'autospawn' ? 'autospawned' : `under ${d.supervisor}`}), ${d.envs.length} of at most ${d.poolMaxTotal} environment(s)`];
1349
+ if (d.envs.length) {
1350
+ out.push('');
1351
+ out.push(...table(['ENV', 'STATE', 'IDLE', 'WORKTREE', 'SUMMARY'], d.envs.map((e) => [e.id, e.state, ago(e.idleMs), e.worktree, e.summary])));
1352
+ }
1353
+ if (d.budget.enabled) {
1354
+ out.push('');
1355
+ out.push(`budget: ${formatSize(d.budget.committedMemoryBytes)} of ${formatSize(d.budget.memoryBytes)}, ${Math.round(d.budget.committedCpu * 10) / 10} of ${d.budget.cpu} cpu committed${d.budget.waiting ? `; ${d.budget.waiting} waiting` : ''}`);
1356
+ }
1357
+ for (const c of d.ports.conflicts)
1358
+ out.push(`port conflict: ${c}`);
1359
+ const notable = d.events.filter((e) => e.level === 'warn' || e.level === 'error').slice(-5);
1360
+ if (notable.length) {
1361
+ out.push('');
1362
+ out.push('recent warnings:');
1363
+ for (const e of notable)
1364
+ out.push(` ${ago(Date.now() - e.at)} ago ${e.level}${e.envId ? ` ${e.envId}` : ''}: ${e.detail ?? e.kind ?? ''}`);
1365
+ }
1366
+ out.push(`'runly status --json' for everything`);
1367
+ return out;
1368
+ }
1369
+ /** The plain `destroy` (0.19): what went, what could not. */
1370
+ function destroyLines(d) {
1371
+ const out = [d.environments.length ? `destroyed ${d.environments.join(', ')} (services, data, ports, lease)` : `no environment for ${d.worktree}`];
1372
+ if (d.copies.length)
1373
+ out.push(`dropped database copies: ${d.copies.join(', ')}`);
1374
+ for (const c of d.copiesNotDropped)
1375
+ out.push(`could not drop ${typeof c === 'string' ? c : `${c.name}${c.error ? `: ${c.error}` : ''}`} — 'runly pool doctor' lists it`);
1376
+ if (d.upkeepForgotten)
1377
+ out.push(`forgot ${d.upkeepForgotten} upkeep rule(s) with no outputs: the next 'up' runs them again`);
1378
+ return out;
1379
+ }
1380
+ function psLines(d, all) {
1381
+ const out = [];
1382
+ if (d.services.length === 0) {
1383
+ out.push(all ? 'no environments on this server — nothing runs' : `no environment for this worktree — 'runly up' starts one`);
1384
+ }
1385
+ else {
1386
+ // --all spans worktrees, so each row says which one it is.
1387
+ const header = ['ENV', ...(all ? ['WORKTREE'] : []), 'SERVICE', 'STATE', 'PORT', 'INTERNAL', 'PID', 'IDLE', 'STOPS IN', 'RSS'];
1388
+ out.push(...table(header, d.services.map((s) => [
1389
+ s.env, ...(all ? [s.worktree ?? ''] : []), s.service, s.state, s.publicPort === null ? '' : String(s.publicPort), s.internalPort === null ? '' : String(s.internalPort),
1390
+ s.pid === null ? '' : String(s.pid), ago(s.idleMs), s.idleStopInMs === null || s.idleStopInMs === undefined ? '' : ago(s.idleStopInMs), mb(s.rssBytes),
1391
+ ])));
1392
+ // A failed service says how it ended and where to look (decision 0039).
1393
+ for (const s of d.services)
1394
+ if (s.failure)
1395
+ out.push(`${s.service} ${s.failure.detail} — '${s.failure.hint}' shows why; the next 'runly up' retries it`);
1396
+ for (const f of d.failedUps ?? [])
1397
+ out.push(`${f.env}: the last 'runly up' failed${f.class ? ` [${f.class}]` : ''}: ${f.message} — ${f.hint}`);
1398
+ const running = d.services.filter((s) => s.state === 'running' || s.state === 'starting').length;
1399
+ if (running === 0 && (d.failedUps ?? []).length === 0)
1400
+ out.push(`nothing is running — ${d.services.length} service(s) idle, stopped or down; the next 'runly up' or connection starts what is wanted`);
1401
+ }
1402
+ if (d.budget?.enabled) {
1403
+ out.push('');
1404
+ out.push(`budget: ${formatSize(d.budget.committedMemoryBytes)} of ${formatSize(d.budget.memoryBytes)}, ${Math.round(d.budget.committedCpu * 10) / 10} of ${d.budget.cpu} cpu committed${d.budget.waiting ? `; ${d.budget.waiting} waiting` : ''}`);
1405
+ }
1406
+ out.push('');
1407
+ out.push(...dbLines(d.databases));
1408
+ return out;
1409
+ }
1410
+ /** A full-rebind reason code (bindDiagnostics.reasons) in words. */
1411
+ function rebindReason(code) {
1412
+ const words = {
1413
+ 'upkeep-required': 'an upkeep rule ran',
1414
+ 'environment-not-running': 'its services were not running',
1415
+ 'service-process-unhealthy': 'a service process was not healthy',
1416
+ 'environment-inputs-changed': 'the env_from inputs changed',
1417
+ 'manifest-changed': 'the manifest changed',
1418
+ 'public-port-moved': 'a public port moved',
1419
+ };
1420
+ if (code.startsWith('hygiene-'))
1421
+ return `--${code.slice('hygiene-'.length)} was asked for`;
1422
+ return words[code] ?? code;
1423
+ }
1424
+ /**
1425
+ * The plain (non-`--json`) `up` and `ctx`: a few lines a person or an agent
1426
+ * reads at a glance — where each service is, what each datastore holds, how
1427
+ * to log in. `--json` is the full blob, unchanged; `--env` the shell exports.
1428
+ */
1429
+ function ctxSummary(c, verb) {
1430
+ const out = [];
1431
+ // A tethered lease lives as long as its agent: a deadline would be a lie.
1432
+ const until = !c.lease
1433
+ ? ' — no lease'
1434
+ : c.lease.holderPid
1435
+ ? ` — held by agent ${c.lease.holderPid}`
1436
+ : ` — lease until ${new Date(c.lease.expiresAt).toLocaleTimeString()}`;
1437
+ const d = c.bindDiagnostics;
1438
+ const how = verb === 'up' && d
1439
+ ? ` (${d.reuse === 'rebound' || !d.reuse ? 'bound' : d.reuse}${d.durationMs !== undefined ? ` in ${formatDuration(d.durationMs)}` : ''}${d.started?.length ? `; started ${d.started.join(', ')}` : ''}${d.restarted?.length ? `; restarted ${d.restarted.join(', ')}` : ''})`
1440
+ : '';
1441
+ out.push(`${c.stack} ${c.envId} ${c.state}${how}${until}`);
1442
+ // Why everything was stopped and started again (a full rebind), in words.
1443
+ // A new environment's first bind needs no explaining.
1444
+ const why = verb === 'up' && d && (d.reuse === 'rebound' || !d.reuse) && !d.reasons?.includes('new-environment') ? (d.reasons ?? []).map(rebindReason) : [];
1445
+ if (why.length)
1446
+ out.push(` full rebind: ${why.join('; ')}`);
1447
+ const names = Object.keys(c.services ?? c.urls ?? {});
1448
+ const width = Math.max(0, ...names.map((n) => n.length));
1449
+ for (const n of names) {
1450
+ const state = c.services?.[n] ?? 'running';
1451
+ const url = c.urls?.[n] ?? '';
1452
+ out.push(` ${n.padEnd(width)} ${state.padEnd(7)} ${url}${c.previewUrls?.[n] ? ` (preview ${c.previewUrls[n]})` : ''}`.trimEnd());
1453
+ }
1454
+ for (const [n, f] of Object.entries(c.failures ?? {}))
1455
+ out.push(` ${n} ${f.detail} — '${f.hint}' shows why; the next 'runly up' retries it`);
1456
+ if (c.lastUpFailed)
1457
+ out.push(` the last 'runly up' failed${c.lastUpFailed.class ? ` [${c.lastUpFailed.class}]` : ''}: ${c.lastUpFailed.message} — ${c.lastUpFailed.hint}`);
1458
+ for (const [n, ds] of Object.entries(c.datastores ?? {}))
1459
+ out.push(` datastore ${n}: ${ds.url}${ds.preset ? ` (${ds.preset})` : ''}`);
1460
+ if (c.logins)
1461
+ out.push(` login: ${c.logins.user} / ${c.logins.password}`);
1462
+ out.push(` 'runly ctx --env' for shell exports, --json for everything`);
1463
+ return out;
1464
+ }
1465
+ main().catch((err) => {
1466
+ if (err instanceof BrokerError) {
1467
+ errExit(err.toJSON());
1468
+ return;
1469
+ }
1470
+ const msg = String(err.message ?? err);
1471
+ // Agents branch on the error class MECHANICALLY (decision 0010), so a
1472
+ // client-side failure must not masquerade as env-error: that tells the agent
1473
+ // to recycle an environment, which cannot fix an unreachable or wedged
1474
+ // daemon. Anything that is not a classified daemon response is infra.
1475
+ const cls = classifyClientError(err);
1476
+ if (json)
1477
+ console.log(JSON.stringify({ ok: false, error: { class: cls, message: msg } }));
1478
+ else
1479
+ console.error(`runly: [${cls}] ${msg}`);
1480
+ process.exit(cls === 'infra-error' ? 3 : 2);
1481
+ });
1482
+ //# sourceMappingURL=index.js.map