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