@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.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/dist/cli/client.d.ts +80 -0
- package/dist/cli/client.js +294 -0
- package/dist/cli/client.js.map +1 -0
- package/dist/cli/daemon-unit.d.ts +58 -0
- package/dist/cli/daemon-unit.js +289 -0
- package/dist/cli/daemon-unit.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1482 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +53 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/logs.d.ts +43 -0
- package/dist/cli/logs.js +232 -0
- package/dist/cli/logs.js.map +1 -0
- package/dist/cli/tether.d.ts +3 -0
- package/dist/cli/tether.js +65 -0
- package/dist/cli/tether.js.map +1 -0
- package/dist/cli/watchdog.d.ts +1 -0
- package/dist/cli/watchdog.js +81 -0
- package/dist/cli/watchdog.js.map +1 -0
- package/dist/core/builds.d.ts +20 -0
- package/dist/core/builds.js +93 -0
- package/dist/core/builds.js.map +1 -0
- package/dist/core/caller-env.d.ts +17 -0
- package/dist/core/caller-env.js +77 -0
- package/dist/core/caller-env.js.map +1 -0
- package/dist/core/diagnostics.d.ts +68 -0
- package/dist/core/diagnostics.js +34 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/env-vars.d.ts +35 -0
- package/dist/core/env-vars.js +32 -0
- package/dist/core/env-vars.js.map +1 -0
- package/dist/core/events.d.ts +10 -0
- package/dist/core/events.js +42 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/exec.d.ts +22 -0
- package/dist/core/exec.js +84 -0
- package/dist/core/exec.js.map +1 -0
- package/dist/core/journal.d.ts +227 -0
- package/dist/core/journal.js +395 -0
- package/dist/core/journal.js.map +1 -0
- package/dist/core/logs.d.ts +56 -0
- package/dist/core/logs.js +259 -0
- package/dist/core/logs.js.map +1 -0
- package/dist/core/manifest.d.ts +292 -0
- package/dist/core/manifest.js +179 -0
- package/dist/core/manifest.js.map +1 -0
- package/dist/core/namespaces.d.ts +7 -0
- package/dist/core/namespaces.js +59 -0
- package/dist/core/namespaces.js.map +1 -0
- package/dist/core/paths.d.ts +31 -0
- package/dist/core/paths.js +65 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/policy.d.ts +94 -0
- package/dist/core/policy.js +122 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/ports.d.ts +52 -0
- package/dist/core/ports.js +94 -0
- package/dist/core/ports.js.map +1 -0
- package/dist/core/presets.d.ts +25 -0
- package/dist/core/presets.js +86 -0
- package/dist/core/presets.js.map +1 -0
- package/dist/core/procscan.d.ts +111 -0
- package/dist/core/procscan.js +342 -0
- package/dist/core/procscan.js.map +1 -0
- package/dist/core/retention.d.ts +97 -0
- package/dist/core/retention.js +322 -0
- package/dist/core/retention.js.map +1 -0
- package/dist/core/sleep.d.ts +51 -0
- package/dist/core/sleep.js +99 -0
- package/dist/core/sleep.js.map +1 -0
- package/dist/core/tree-ledger.d.ts +39 -0
- package/dist/core/tree-ledger.js +92 -0
- package/dist/core/tree-ledger.js.map +1 -0
- package/dist/core/types.d.ts +21 -0
- package/dist/core/types.js +6 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/units.d.ts +20 -0
- package/dist/core/units.js +69 -0
- package/dist/core/units.js.map +1 -0
- package/dist/core/upkeep.d.ts +90 -0
- package/dist/core/upkeep.js +176 -0
- package/dist/core/upkeep.js.map +1 -0
- package/dist/core/util.d.ts +46 -0
- package/dist/core/util.js +168 -0
- package/dist/core/util.js.map +1 -0
- package/dist/core/version.d.ts +39 -0
- package/dist/core/version.js +144 -0
- package/dist/core/version.js.map +1 -0
- package/dist/core/worktree.d.ts +51 -0
- package/dist/core/worktree.js +309 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/daemon/budget.d.ts +155 -0
- package/dist/daemon/budget.js +288 -0
- package/dist/daemon/budget.js.map +1 -0
- package/dist/daemon/election.d.ts +9 -0
- package/dist/daemon/election.js +140 -0
- package/dist/daemon/election.js.map +1 -0
- package/dist/daemon/engine.d.ts +1703 -0
- package/dist/daemon/engine.js +5305 -0
- package/dist/daemon/engine.js.map +1 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +412 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/proxy.d.ts +108 -0
- package/dist/daemon/proxy.js +615 -0
- package/dist/daemon/proxy.js.map +1 -0
- package/dist/daemon/supervisor.d.ts +101 -0
- package/dist/daemon/supervisor.js +493 -0
- package/dist/daemon/supervisor.js.map +1 -0
- package/dist/drivers/appliances.d.ts +11 -0
- package/dist/drivers/appliances.js +174 -0
- package/dist/drivers/appliances.js.map +1 -0
- package/dist/drivers/datastores.d.ts +144 -0
- package/dist/drivers/datastores.js +775 -0
- package/dist/drivers/datastores.js.map +1 -0
- package/dist/drivers/preview.d.ts +69 -0
- package/dist/drivers/preview.js +605 -0
- package/dist/drivers/preview.js.map +1 -0
- package/dist/drivers/types.d.ts +70 -0
- package/dist/drivers/types.js +13 -0
- package/dist/drivers/types.js.map +1 -0
- package/package.json +63 -4
- package/schema/backlot.schema.json +549 -0
- 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
|