@zswarm/core 0.1.8 → 0.2.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.
@@ -0,0 +1,476 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { ZellijError } from "../errors.js";
5
+ import { dispatchZswarm } from "../ops/dispatch.js";
6
+ import { resolveServeCliLaunch } from "../ops/serve-install.js";
7
+ import { defaultStateDir } from "../state.js";
8
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
9
+ /** Spread over about an hour: Zellij on a loaded machine can stall for minutes. */
10
+ const DEFAULT_DELAYS = [0, 30, 60, 120, 300, 600, 900, 1500];
11
+ /** Consecutive matches of the done regex without a passing check before UNCONFIRMED. */
12
+ const CONFIRM_TRIES = 3;
13
+ const SERVE_DOWN_TRIES = 3;
14
+ /** A wait that only finds `seen` lines returns at once; poll no faster than this. */
15
+ const STALE_MATCH_POLL_MS = 15_000;
16
+ export function relaysDir(env = process.env) {
17
+ return join(defaultStateDir(env), "relays");
18
+ }
19
+ function writePrivate(path, text) {
20
+ writeFileSync(path, text, { mode: 0o600 });
21
+ chmodSync(path, 0o600);
22
+ }
23
+ function delaysFrom(env, given) {
24
+ if (given)
25
+ return given;
26
+ const raw = env.ZSWARM_RELAY_DELAYS?.trim();
27
+ if (!raw)
28
+ return DEFAULT_DELAYS;
29
+ const parsed = raw.split(",").map((x) => Number(x.trim()));
30
+ return parsed.every((n) => Number.isFinite(n) && n >= 0) ? parsed : DEFAULT_DELAYS;
31
+ }
32
+ function serveEnv(cfg, env) {
33
+ const out = { ...env, ZSWARM_SERVE: cfg.serve, ZSWARM_BUS: "0", ZSWARM_LOG: "0" };
34
+ if (cfg.token)
35
+ out.ZSWARM_SERVE_TOKEN = cfg.token;
36
+ delete out.ZSWARM_SSH;
37
+ return out;
38
+ }
39
+ function localEnv(env) {
40
+ const out = { ...env };
41
+ for (const key of ["ZSWARM_SERVE", "ZSWARM_SERVE_TOKEN", "ZSWARM_SSH"])
42
+ delete out[key];
43
+ return out;
44
+ }
45
+ function defaultRunCheck(script, env) {
46
+ const res = spawnSync(script, [], { env, encoding: "utf8", timeout: 120_000 });
47
+ return { status: res.status ?? 1, stdout: res.stdout ?? "" };
48
+ }
49
+ /** Lines of `screen` that match the done regex, as the relay compares them. */
50
+ export function doneLines(screen, done) {
51
+ const re = new RegExp(done);
52
+ return screen.split("\n").map((line) => line.trimEnd()).filter((line) => re.test(line));
53
+ }
54
+ /**
55
+ * The first done line in `screen` that is not accounted for by `seen`,
56
+ * counting repeats: a line seen once still counts when it shows up twice.
57
+ * A regex that only matches across lines cannot be baselined; its match counts.
58
+ */
59
+ export function freshDoneLine(screen, done, seen = []) {
60
+ const lines = doneLines(screen, done);
61
+ if (lines.length === 0)
62
+ return new RegExp(done, "m").exec(screen)?.[0] ?? null;
63
+ const left = new Map();
64
+ for (const line of seen)
65
+ left.set(line, (left.get(line) ?? 0) + 1);
66
+ for (const line of lines) {
67
+ const n = left.get(line) ?? 0;
68
+ if (n === 0)
69
+ return line;
70
+ left.set(line, n - 1);
71
+ }
72
+ return null;
73
+ }
74
+ /** Fill {id} {status} {note} {match} {output} {pane} in a message template. */
75
+ export function fillMessage(template, fields) {
76
+ return template.replace(/\{(id|status|note|match|output|pane)\}/g, (_m, key) => fields[key] ?? "");
77
+ }
78
+ const DEFAULT_TEMPLATE = "RELAY {id} STATUS {status} pane {pane}\n{output}{note}\n";
79
+ const NOTES = {
80
+ TIMEOUT: "no done line or passing check before the deadline; dump the pane once, do not resend blindly.",
81
+ PANE_DIED: "the target pane is gone or exited; check the crew (zswarm crew status) before resending.",
82
+ SERVE_DOWN: "serve was unreachable for three checks in a row; run zswarm doctor against it.",
83
+ UNCONFIRMED: "the pane printed the done line but the check never passed; dump the pane once for the error.",
84
+ };
85
+ /** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
86
+ export async function deliverMessage(cfg, body, dir, deps = {}) {
87
+ const env = deps.env ?? process.env;
88
+ const call = deps.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
89
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
90
+ const log = deps.log ?? (() => { });
91
+ writeFileSync(join(dir, "message.txt"), body);
92
+ const delays = delaysFrom(env, deps.delays);
93
+ for (const [i, delay] of delays.entries()) {
94
+ await sleep(delay * 1000);
95
+ // Generous Zellij budgets: this runs exactly when the machine is busiest.
96
+ const args = {
97
+ op: "send", to: cfg.replyTo, body, from: cfg.from, fresh: true, settleMs: 800, observeMs: 30_000, timeoutMs: 60_000,
98
+ };
99
+ if (cfg.replySession)
100
+ args.session = cfg.replySession;
101
+ const res = await call(args, localEnv(env)).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
102
+ if (res.ok) {
103
+ log(`message attempt ${i + 1}: delivered, submitted=${String(res.data?.submitted)}`);
104
+ return true;
105
+ }
106
+ log(`message attempt ${i + 1} failed: ${res.error.code}: ${res.error.message.slice(0, 200)}`);
107
+ }
108
+ const fallback = join(relaysDir(env), "undelivered");
109
+ mkdirSync(fallback, { recursive: true, mode: 0o700 });
110
+ writeFileSync(join(fallback, `${cfg.id}.txt`), body);
111
+ log(`message not delivered after ${delays.length} attempts; left in ${join(fallback, `${cfg.id}.txt`)}`);
112
+ return false;
113
+ }
114
+ /** The waiting loop; returns the final status and the message it delivered or left behind. */
115
+ export async function relayLoop(dir, deps = {}) {
116
+ const env = deps.env ?? process.env;
117
+ const cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
118
+ const call = deps.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
119
+ const runCheck = deps.runCheck ?? defaultRunCheck;
120
+ const now = deps.now ?? Date.now;
121
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
122
+ const log = deps.log ?? ((line) => appendFileSync(join(dir, "relay.log"), `${new Date().toISOString()} ${line}\n`));
123
+ const remote = serveEnv(cfg, env);
124
+ const check = cfg.check ? join(dir, cfg.check) : undefined;
125
+ const checkEnv = (status, match) => ({
126
+ ...localEnv(env),
127
+ ZSWARM_RELAY_ID: cfg.id,
128
+ ZSWARM_RELAY_STATUS: status,
129
+ ZSWARM_RELAY_MATCH: match,
130
+ ZSWARM_RELAY_PANE: cfg.to,
131
+ ZSWARM_RELAY_DIR: dir,
132
+ });
133
+ const deadline = now() + cfg.timeoutMin * 60_000;
134
+ let status = "TIMEOUT";
135
+ let output = "";
136
+ let match = "";
137
+ let confirmTries = 0;
138
+ let serveDown = 0;
139
+ log(`relay start id=${cfg.id} to=${cfg.to} serve=${cfg.serve} done=${cfg.done}`);
140
+ while (now() < deadline) {
141
+ const started = now();
142
+ const chunk = Math.floor(Math.min(600_000, Math.max(5_000, deadline - now())));
143
+ const args = { op: "wait", to: cfg.to, for: "match", regex: true, match: cfg.done, timeoutMs: chunk, max: 3000 };
144
+ if (cfg.session)
145
+ args.session = cfg.session;
146
+ const res = await call(args, remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
147
+ const reportedMatch = res.ok && res.data?.reason === "match";
148
+ let matched = false;
149
+ let stale = false;
150
+ if (reportedMatch) {
151
+ const text = String(res.data?.matched ?? res.data?.text ?? "");
152
+ const line = freshDoneLine(text, cfg.done, cfg.seen);
153
+ matched = line !== null;
154
+ stale = !matched;
155
+ if (line !== null)
156
+ match = new RegExp(cfg.done).exec(line)?.[0] ?? line;
157
+ }
158
+ if (check) {
159
+ const r = runCheck(check, checkEnv(matched ? "MATCHED" : "POLL", match));
160
+ log(`wait ok=${res.ok} matched=${matched}${stale ? " (seen before start)" : ""} check=${r.status}`);
161
+ if (r.status === 0) {
162
+ status = "DONE";
163
+ output = r.stdout;
164
+ break;
165
+ }
166
+ }
167
+ else {
168
+ log(`wait ok=${res.ok} matched=${matched}${stale ? " (seen before start)" : ""}`);
169
+ if (matched) {
170
+ status = "DONE";
171
+ break;
172
+ }
173
+ }
174
+ if (!res.ok) {
175
+ // Tell a dead pane from a dead tunnel: list the session once.
176
+ const listArgs = { op: "list" };
177
+ if (cfg.session)
178
+ listArgs.session = cfg.session;
179
+ const listed = await call(listArgs, remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
180
+ if (listed.ok) {
181
+ const panes = listed.data.panes ?? [];
182
+ const pane = panes.find((p) => p.title === cfg.to || p.id === cfg.to);
183
+ if (!pane || pane.exited) {
184
+ status = "PANE_DIED";
185
+ break;
186
+ }
187
+ serveDown = 0;
188
+ }
189
+ else if (++serveDown >= SERVE_DOWN_TRIES) {
190
+ status = "SERVE_DOWN";
191
+ break;
192
+ }
193
+ await sleep(60_000);
194
+ continue;
195
+ }
196
+ serveDown = 0;
197
+ // A wait normally blocks for its chunk; never spin on one that returns early,
198
+ // and a line that matched before the start keeps matching until it scrolls off.
199
+ if (stale)
200
+ await sleep(STALE_MATCH_POLL_MS);
201
+ else if (!matched && now() - started < 1_000)
202
+ await sleep(5_000);
203
+ if (matched && check && ++confirmTries >= CONFIRM_TRIES) {
204
+ status = "UNCONFIRMED";
205
+ break;
206
+ }
207
+ if (matched)
208
+ await sleep(30_000); // the done line stays on screen; give the check time
209
+ }
210
+ let body;
211
+ if (status !== "DONE" && check) {
212
+ const r = runCheck(check, checkEnv(status, match));
213
+ if (r.status === 0 && r.stdout.trim())
214
+ output = r.stdout;
215
+ }
216
+ if (status === "DONE" && output.trim()) {
217
+ body = output;
218
+ }
219
+ else {
220
+ const template = cfg.message ? readFileSync(join(dir, cfg.message), "utf8") : DEFAULT_TEMPLATE;
221
+ body = fillMessage(template, {
222
+ id: cfg.id,
223
+ status,
224
+ note: status === "DONE" ? "" : NOTES[status],
225
+ match,
226
+ output: output && !output.endsWith("\n") ? `${output}\n` : output,
227
+ pane: cfg.to,
228
+ });
229
+ }
230
+ log(`relay end status=${status}`);
231
+ const delivered = await deliverMessage(cfg, body, dir, { ...deps, log });
232
+ return { status, delivered };
233
+ }
234
+ /** Write the relay directory: config (600), snapshots of the check and template. */
235
+ export function prepareRelay(cfg, sources, env) {
236
+ if (!ID.test(cfg.id))
237
+ throw new ZellijError("bad_arg", `invalid relay id ${JSON.stringify(cfg.id)}`);
238
+ try {
239
+ new RegExp(cfg.done);
240
+ }
241
+ catch {
242
+ throw new ZellijError("bad_arg", `--done is not a valid regex: ${cfg.done}`);
243
+ }
244
+ const dir = join(relaysDir(env), `${cfg.id}-${Math.floor(Date.now() / 1000)}`);
245
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
246
+ // A new relay under this id supersedes the last one's result: --wait must not
247
+ // return it (a restart within the same second even reuses the directory).
248
+ rmSync(join(dir, "message.txt"), { force: true });
249
+ rmSync(join(relaysDir(env), "undelivered", `${cfg.id}.txt`), { force: true });
250
+ const out = { ...cfg };
251
+ if (sources.check) {
252
+ if (!existsSync(sources.check))
253
+ throw new ZellijError("usage", `check script ${sources.check} not found`);
254
+ out.check = `check-${basename(sources.check)}`;
255
+ copyFileSync(sources.check, join(dir, out.check));
256
+ chmodSync(join(dir, out.check), 0o700);
257
+ }
258
+ if (sources.message) {
259
+ if (!existsSync(sources.message))
260
+ throw new ZellijError("usage", `message template ${sources.message} not found`);
261
+ out.message = "message.tmpl";
262
+ copyFileSync(sources.message, join(dir, out.message));
263
+ }
264
+ writePrivate(join(dir, "config.json"), `${JSON.stringify(out, null, 1)}\n`);
265
+ return dir;
266
+ }
267
+ function updateConfig(dir, patch) {
268
+ const path = join(dir, "config.json");
269
+ const cfg = { ...JSON.parse(readFileSync(path, "utf8")), ...patch };
270
+ writePrivate(path, `${JSON.stringify(cfg, null, 1)}\n`);
271
+ }
272
+ /** Remove the token copy once the relay no longer needs it. */
273
+ export function scrubRelayToken(dir) {
274
+ const path = join(dir, "config.json");
275
+ try {
276
+ const cfg = JSON.parse(readFileSync(path, "utf8"));
277
+ delete cfg.token;
278
+ writePrivate(path, `${JSON.stringify(cfg, null, 1)}\n`);
279
+ }
280
+ catch {
281
+ // already gone
282
+ }
283
+ }
284
+ /** Start the detached relay so it outlives the caller (and the caller's process group). */
285
+ export function launchRelay(dir, env = process.env) {
286
+ const launch = resolveServeCliLaunch({ env });
287
+ const argv = [launch.execPath, launch.scriptPath, "relay", "--run", dir];
288
+ const log = join(dir, "relay.log");
289
+ const name = basename(dir).replace(/[^A-Za-z0-9._-]/g, "-");
290
+ if (process.platform === "darwin") {
291
+ const label = `zswarm.relay.${name}`;
292
+ const res = spawnSync("launchctl", [
293
+ "submit", "-l", label, "-o", log, "-e", log, "--",
294
+ "/usr/bin/env", `PATH=${env.PATH ?? "/usr/bin:/bin"}`, `HOME=${env.HOME ?? ""}`,
295
+ ...(env.ZSWARM_STATE_DIR ? [`ZSWARM_STATE_DIR=${env.ZSWARM_STATE_DIR}`] : []),
296
+ `ZSWARM_RELAY_LABEL=${label}`, ...argv,
297
+ ], { encoding: "utf8" });
298
+ if (res.status !== 0)
299
+ throw new ZellijError("failed", `launchctl submit failed: ${(res.stderr ?? "").trim().slice(0, 200)}`);
300
+ return `launchd job ${label}`;
301
+ }
302
+ if (process.platform === "linux" && spawnSync("systemctl", ["--user", "is-system-running"], { encoding: "utf8" }).status !== null) {
303
+ const unit = `zswarm-relay-${name}`;
304
+ const res = spawnSync("systemd-run", ["--user", "--collect", "--quiet", `--unit=${unit}`,
305
+ `--setenv=PATH=${env.PATH ?? "/usr/bin:/bin"}`,
306
+ ...(env.ZSWARM_STATE_DIR ? [`--setenv=ZSWARM_STATE_DIR=${env.ZSWARM_STATE_DIR}`] : []),
307
+ ...argv], { encoding: "utf8" });
308
+ if (res.status === 0)
309
+ return `systemd unit ${unit}`;
310
+ }
311
+ const out = openLog(log);
312
+ const child = spawn(argv[0], argv.slice(1), { detached: true, stdio: ["ignore", out, out], env, windowsHide: true });
313
+ child.unref();
314
+ return `pid ${child.pid}`;
315
+ }
316
+ /** A file descriptor the detached child can inherit. */
317
+ function openLog(path) {
318
+ return openSync(path, "a");
319
+ }
320
+ /** Send the body now, then leave a relay waiting for the done line. */
321
+ export async function startRelay(input) {
322
+ const env = input.env ?? process.env;
323
+ const serve = input.serve?.trim() || env.ZSWARM_SERVE?.trim();
324
+ if (!serve)
325
+ throw new ZellijError("usage", "relay needs --serve or ZSWARM_SERVE (the host that owns the target pane)");
326
+ if (!input.to || !input.replyTo || !input.done)
327
+ throw new ZellijError("usage", "relay needs --to, --done and --reply-to");
328
+ const id = input.id?.trim() || `relay-${Date.now().toString(36)}`;
329
+ const cfg = {
330
+ id,
331
+ serve,
332
+ token: env.ZSWARM_SERVE_TOKEN?.trim() || undefined,
333
+ session: input.session,
334
+ to: input.to,
335
+ done: input.done,
336
+ replyTo: input.replyTo,
337
+ // The detached job gets a minimal env, so pin the caller's session now.
338
+ replySession: input.replySession?.trim() || env.ZSWARM_SESSION?.trim() || env.ZELLIJ_SESSION_NAME?.trim() || undefined,
339
+ from: input.from?.trim() || "zswarm-relay",
340
+ timeoutMin: input.timeoutMin ?? 240,
341
+ };
342
+ // Read the body before the relay directory (and its token copy) exists.
343
+ const body = input.bodyFile ? readFileSync(input.bodyFile, "utf8") : undefined;
344
+ const dir = prepareRelay(cfg, { check: input.check, message: input.message }, env);
345
+ const call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
346
+ const remote = serveEnv(cfg, env);
347
+ const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
348
+ let sent;
349
+ let warning;
350
+ let how;
351
+ try {
352
+ if (body !== undefined) {
353
+ const res = await call(target({ op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 }), remote);
354
+ if (!res.ok)
355
+ throw new ZellijError(res.error.code, `send failed: ${res.error.message}`, res.error.details);
356
+ sent = res.data?.submitted === true ? "submitted" : "unverified";
357
+ if (sent !== "submitted") {
358
+ // One dump; one Enter if the body still sits in the composer.
359
+ const dump = await call(target({ op: "dump", to: cfg.to, max: 2000 }), remote);
360
+ const screen = dump.ok ? String(dump.data.text ?? "") : "";
361
+ const tail = screen.trim().split("\n").slice(-4).join("\n");
362
+ const firstLine = body.trim().split("\n")[0]?.slice(0, 60) ?? "";
363
+ if ((firstLine && tail.includes(firstLine)) || tail.includes("[zswarm from=") || tail.includes("[Pasted text")) {
364
+ await call(target({ op: "keys", to: cfg.to, keys: "Enter" }), remote);
365
+ sent = "enter-pressed";
366
+ }
367
+ }
368
+ if (doneLines(body, cfg.done).length > 0) {
369
+ warning = "--done matches a line of the body; that line is ignored while it stays on screen, so an identical done line is only seen once it scrolls off";
370
+ }
371
+ }
372
+ // Whatever already matches --done (the echoed body, an earlier task's done
373
+ // line) is not this task finishing; the relay ignores those lines.
374
+ const baseline = await call(target({ op: "dump", to: cfg.to, max: 3000 }), remote);
375
+ const seen = baseline.ok ? doneLines(String(baseline.data.text ?? ""), cfg.done) : [];
376
+ cfg.seen = seen;
377
+ updateConfig(dir, { seen });
378
+ if (!input.foreground)
379
+ how = (input.launch ?? launchRelay)(dir, env);
380
+ }
381
+ catch (err) {
382
+ // Nothing will run from this directory: take the token copy with it.
383
+ rmSync(dir, { recursive: true, force: true });
384
+ throw err;
385
+ }
386
+ const extra = warning ? { warning } : {};
387
+ if (input.foreground) {
388
+ const result = await relayLoop(dir, input.deps).finally(() => scrubRelayToken(dir));
389
+ return { id, dir, sent: sent ?? null, ...extra, ...result };
390
+ }
391
+ return { id, dir, sent: sent ?? null, ...extra, relay: how };
392
+ }
393
+ /** What `zswarm relay --run <dir>` does inside the detached job. */
394
+ export async function runRelayDir(dir, deps = {}) {
395
+ const stop = (sig) => () => {
396
+ appendFileSync(join(dir, "relay.log"), `${new Date().toISOString()} relay stopped by ${sig}\n`);
397
+ scrubRelayToken(dir);
398
+ process.exit(143);
399
+ };
400
+ const onTerm = stop("SIGTERM");
401
+ const onHup = stop("SIGHUP");
402
+ process.once("SIGTERM", onTerm);
403
+ process.once("SIGHUP", onHup);
404
+ let code = 1;
405
+ try {
406
+ const res = await relayLoop(dir, deps);
407
+ code = res.delivered ? 0 : 3;
408
+ }
409
+ finally {
410
+ scrubRelayToken(dir);
411
+ process.off("SIGTERM", onTerm);
412
+ process.off("SIGHUP", onHup);
413
+ const label = (deps.env ?? process.env).ZSWARM_RELAY_LABEL;
414
+ if (label && process.platform === "darwin") {
415
+ // Remove this relay's own launchd job so launchd neither keeps nor restarts it.
416
+ spawnSync("launchctl", ["remove", label]);
417
+ }
418
+ }
419
+ return code;
420
+ }
421
+ /**
422
+ * The pull path: block until relay `id` has its final message (delivered or
423
+ * not) and return it. Needs no paste, so a busy Zellij cannot lose it.
424
+ */
425
+ export async function waitForRelay(id, opts = {}) {
426
+ const env = opts.env ?? process.env;
427
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
428
+ const now = opts.now ?? Date.now;
429
+ const deadline = now() + (opts.timeoutMin ?? 240) * 60_000;
430
+ const root = relaysDir(env);
431
+ // Only this id's runs (`build`, not `build-2`), and only the newest one: an
432
+ // older run's message is not this relay's result.
433
+ const own = new RegExp(`^${id.replace(/[.]/g, "\\.")}-(\\d+)$`);
434
+ const nonEmpty = (path) => existsSync(path) && statSync(path).size > 0;
435
+ for (;;) {
436
+ const runs = existsSync(root)
437
+ ? readdirSync(root).flatMap((name) => {
438
+ const m = own.exec(name);
439
+ return m ? [{ dir: join(root, name), startedMs: Number(m[1]) * 1000 }] : [];
440
+ }).sort((a, b) => b.startedMs - a.startedMs)
441
+ : [];
442
+ const newest = runs[0];
443
+ const message = newest ? join(newest.dir, "message.txt") : undefined;
444
+ if (message && nonEmpty(message))
445
+ return readFileSync(message, "utf8");
446
+ const undelivered = join(root, "undelivered", `${id}.txt`);
447
+ if (nonEmpty(undelivered) && (!newest || statSync(undelivered).mtimeMs >= newest.startedMs)) {
448
+ return readFileSync(undelivered, "utf8");
449
+ }
450
+ if (now() >= deadline)
451
+ return null;
452
+ await sleep(opts.pollMs ?? 15_000);
453
+ }
454
+ }
455
+ /** Remove relay directories and undelivered messages older than `days`. */
456
+ export function pruneRelays(days = 14, env = process.env) {
457
+ const root = relaysDir(env);
458
+ if (!existsSync(root))
459
+ return [];
460
+ const cutoff = Date.now() - days * 86_400_000;
461
+ const removed = [];
462
+ for (const sub of [root, join(root, "undelivered")]) {
463
+ if (!existsSync(sub))
464
+ continue;
465
+ for (const name of readdirSync(sub)) {
466
+ const path = join(sub, name);
467
+ if (name === "undelivered")
468
+ continue;
469
+ if (statSync(path).mtimeMs < cutoff) {
470
+ rmSync(path, { recursive: true, force: true });
471
+ removed.push(path);
472
+ }
473
+ }
474
+ }
475
+ return removed;
476
+ }
@@ -0,0 +1,51 @@
1
+ import type { OpsResult } from "../ops/types.js";
2
+ import { type Runner, type ServiceAccount, type ServiceHost, type ServiceSpec } from "./services.js";
3
+ /** Env a service-run serve reads its token from (never the unit or plist). */
4
+ export declare const SERVE_TOKEN_FILE_ENV = "ZSWARM_SERVE_TOKEN_FILE";
5
+ /** Env that makes serve wait (ms) for its --session to exist before listening. */
6
+ export declare const SERVE_AWAIT_SESSION_ENV = "ZSWARM_SERVE_AWAIT_SESSION_MS";
7
+ export type ServeServiceInput = {
8
+ listen?: string;
9
+ session?: string;
10
+ runAs?: string;
11
+ timeoutMs?: number;
12
+ env?: NodeJS.ProcessEnv;
13
+ /** Injected in tests. */
14
+ account?: ServiceAccount;
15
+ run?: Runner;
16
+ fs?: ServiceHost["fs"];
17
+ writeSecret?: (account: ServiceAccount, path: string, text: string) => void;
18
+ readSecret?: (path: string) => string | undefined;
19
+ probe?: (target: string, token: string) => Promise<OpsResult>;
20
+ list?: (target: string, session: string, token: string) => Promise<OpsResult>;
21
+ launch?: {
22
+ execPath: string;
23
+ scriptPath: string;
24
+ };
25
+ now?: () => number;
26
+ sleep?: (ms: number) => Promise<void>;
27
+ };
28
+ export declare function serveServiceName(session?: string): string;
29
+ export declare function serveTokenPath(account: ServiceAccount, session: string | undefined, env: NodeJS.ProcessEnv): string;
30
+ /** Read the token a service-run serve uses: ZSWARM_SERVE_TOKEN, else ZSWARM_SERVE_TOKEN_FILE. */
31
+ export declare function serveTokenFromEnv(env: NodeJS.ProcessEnv, read?: (path: string) => string | undefined): string | undefined;
32
+ /** The unit or plist for a serve service; exported for tests and docs. */
33
+ export declare function serveServiceSpec(input: {
34
+ account: ServiceAccount;
35
+ listen: string;
36
+ session?: string;
37
+ tokenPath: string;
38
+ launch: {
39
+ execPath: string;
40
+ scriptPath: string;
41
+ };
42
+ env: NodeJS.ProcessEnv;
43
+ }): ServiceSpec;
44
+ /**
45
+ * serve --install on Linux and macOS: a token file (kept across reinstalls
46
+ * unless ZSWARM_SERVE_TOKEN is given), a daemon service, then wait for an
47
+ * authenticated hello and, with --session, the session's pane list.
48
+ */
49
+ export declare function installServeService(input?: ServeServiceInput): Promise<Record<string, unknown>>;
50
+ /** serve --clear on Linux and macOS: stop and remove the service; the token file stays. */
51
+ export declare function uninstallServeService(input?: ServeServiceInput): Record<string, unknown>;
@@ -0,0 +1,150 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { posix } from "node:path";
3
+ import { ZellijError } from "../errors.js";
4
+ import { resolveServeCliLaunch, serveTaskChildEnv } from "../ops/serve-install.js";
5
+ import { callServe, parseListenAddress, probeServe } from "../ops/serve.js";
6
+ import { defaultStateDir } from "../state.js";
7
+ import { defaultRunner, installServices, readSecretFile, removeServices, serviceAccount, serviceStates, writeSecretFile, } from "./services.js";
8
+ // Service accounts are Linux or macOS accounts: their paths are POSIX paths
9
+ // even when this code runs (in tests) on Windows.
10
+ const { join } = posix;
11
+ /** Env a service-run serve reads its token from (never the unit or plist). */
12
+ export const SERVE_TOKEN_FILE_ENV = "ZSWARM_SERVE_TOKEN_FILE";
13
+ /** Env that makes serve wait (ms) for its --session to exist before listening. */
14
+ export const SERVE_AWAIT_SESSION_ENV = "ZSWARM_SERVE_AWAIT_SESSION_MS";
15
+ const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
16
+ const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
17
+ export function serveServiceName(session) {
18
+ return session ? `serve-${session}` : "serve";
19
+ }
20
+ function stateDirFor(account, env) {
21
+ if (!account.foreign && env.ZSWARM_STATE_DIR?.trim())
22
+ return defaultStateDir(env);
23
+ return join(account.home, ".zswarm");
24
+ }
25
+ export function serveTokenPath(account, session, env) {
26
+ return join(stateDirFor(account, env), "serve", `${serveServiceName(session)}.token`);
27
+ }
28
+ /** Read the token a service-run serve uses: ZSWARM_SERVE_TOKEN, else ZSWARM_SERVE_TOKEN_FILE. */
29
+ export function serveTokenFromEnv(env, read = readSecretFile) {
30
+ const direct = env.ZSWARM_SERVE_TOKEN?.trim();
31
+ if (direct)
32
+ return direct;
33
+ const file = env[SERVE_TOKEN_FILE_ENV]?.trim();
34
+ if (!file)
35
+ return undefined;
36
+ const text = read(file);
37
+ if (text === undefined)
38
+ throw new ZellijError("serve_auth", `cannot read ${SERVE_TOKEN_FILE_ENV} (${file})`);
39
+ return text.trim() || undefined;
40
+ }
41
+ function checkSession(session) {
42
+ const s = session?.trim();
43
+ if (!s)
44
+ return undefined;
45
+ if (!SESSION.test(s))
46
+ throw new ZellijError("bad_arg", `invalid session name ${JSON.stringify(s)}`);
47
+ return s;
48
+ }
49
+ /** The unit or plist for a serve service; exported for tests and docs. */
50
+ export function serveServiceSpec(input) {
51
+ const { account, session } = input;
52
+ const base = account.foreign
53
+ ? { PATH: FOREIGN_PATH, ...(input.env.ZSWARM_BIN ? { ZSWARM_BIN: input.env.ZSWARM_BIN } : {}) }
54
+ : serveTaskChildEnv(input.env);
55
+ const env = { ...base, [SERVE_TOKEN_FILE_ENV]: input.tokenPath };
56
+ if (session) {
57
+ env.ZSWARM_SESSION = session;
58
+ env[SERVE_AWAIT_SESSION_ENV] = "60000";
59
+ }
60
+ return {
61
+ name: serveServiceName(session),
62
+ description: `zswarm serve on ${input.listen}${session ? ` for session ${session}` : ""}`,
63
+ kind: "daemon",
64
+ argv: [
65
+ input.launch.execPath,
66
+ input.launch.scriptPath,
67
+ "serve",
68
+ "--listen",
69
+ input.listen,
70
+ ...(session ? ["--session", session] : []),
71
+ ],
72
+ env,
73
+ after: session ? [`crew-${session}`] : undefined,
74
+ };
75
+ }
76
+ /**
77
+ * serve --install on Linux and macOS: a token file (kept across reinstalls
78
+ * unless ZSWARM_SERVE_TOKEN is given), a daemon service, then wait for an
79
+ * authenticated hello and, with --session, the session's pane list.
80
+ */
81
+ export async function installServeService(input = {}) {
82
+ const env = input.env ?? process.env;
83
+ const run = input.run ?? defaultRunner;
84
+ const account = input.account ?? serviceAccount(input.runAs, run);
85
+ const session = checkSession(input.session);
86
+ const { label } = parseListenAddress(input.listen);
87
+ // Resolve everything that can fail before the first write.
88
+ const launch = input.launch ?? resolveServeCliLaunch({ env });
89
+ const tokenPath = serveTokenPath(account, session, env);
90
+ const read = input.readSecret ?? readSecretFile;
91
+ const write = input.writeSecret ?? writeSecretFile;
92
+ let token = env.ZSWARM_SERVE_TOKEN?.trim() || read(tokenPath)?.trim();
93
+ const minted = !token;
94
+ if (!token)
95
+ token = randomBytes(24).toString("hex");
96
+ write(account, tokenPath, `${token}\n`);
97
+ const spec = serveServiceSpec({ account, listen: label, session, tokenPath, launch, env });
98
+ const done = installServices({ account, run, fs: input.fs }, [spec]);
99
+ const now = input.now ?? Date.now;
100
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
101
+ const deadline = now() + Math.max(1_000, input.timeoutMs ?? 30_000);
102
+ const probe = input.probe ?? ((target, tok) => probeServe(target, { token: tok, timeoutMs: 5_000 }));
103
+ const list = input.list ??
104
+ ((target, s, tok) => callServe(target, { op: "list", session: s }, { token: tok, timeoutMs: 10_000 }));
105
+ let last;
106
+ let ready = false;
107
+ while (now() < deadline) {
108
+ last = await probe(label, token);
109
+ if (last.ok && session)
110
+ last = await list(label, session, token);
111
+ if (last.ok) {
112
+ ready = true;
113
+ break;
114
+ }
115
+ await sleep(1_000);
116
+ }
117
+ const result = {
118
+ installed: true,
119
+ ready,
120
+ service: spec.name,
121
+ user: account.user,
122
+ listen: label,
123
+ session: session ?? null,
124
+ tokenFile: tokenPath,
125
+ tokenMinted: minted,
126
+ actions: done.lines,
127
+ notes: done.notes,
128
+ };
129
+ if (!ready) {
130
+ throw new ZellijError("serve_not_ready", `serve service installed but not ready on ${label}`, {
131
+ ...result,
132
+ lastError: last && !last.ok ? last.error : undefined,
133
+ remedy: account.platform === "linux"
134
+ ? `journalctl --user -u zswarm-${spec.name} (as ${account.user})`
135
+ : `${account.home}/Library/Logs/zswarm/${spec.name}.log`,
136
+ });
137
+ }
138
+ return result;
139
+ }
140
+ /** serve --clear on Linux and macOS: stop and remove the service; the token file stays. */
141
+ export function uninstallServeService(input = {}) {
142
+ const run = input.run ?? defaultRunner;
143
+ const account = input.account ?? serviceAccount(input.runAs, run);
144
+ const session = checkSession(input.session);
145
+ const name = serveServiceName(session);
146
+ const host = { account, run, fs: input.fs };
147
+ const before = serviceStates(host, [{ name, kind: "daemon" }])[0];
148
+ const lines = removeServices(host, [{ name, kind: "daemon" }]);
149
+ return { removed: true, service: name, user: account.user, wasActive: before?.active ?? false, actions: lines };
150
+ }