@zswarm/core 0.1.9 → 0.2.1
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/assets/host-install.sh +120 -0
- package/assets/host-report.sh +29 -0
- package/assets/slot.py +324 -0
- package/dist/host/cli.d.ts +13 -0
- package/dist/host/cli.js +214 -0
- package/dist/host/crew.d.ts +81 -0
- package/dist/host/crew.js +279 -0
- package/dist/host/host.d.ts +55 -0
- package/dist/host/host.js +165 -0
- package/dist/host/relay.d.ts +116 -0
- package/dist/host/relay.js +476 -0
- package/dist/host/serve-service.d.ts +51 -0
- package/dist/host/serve-service.js +150 -0
- package/dist/host/services.d.ts +103 -0
- package/dist/host/services.js +345 -0
- package/dist/host/slot.d.ts +11 -0
- package/dist/host/slot.js +74 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/ops/dispatch.js +25 -2
- package/dist/ops/types.d.ts +3 -0
- package/dist/schema.js +10 -4
- package/dist/zellij/binary.js +4 -1
- package/package.json +2 -1
package/dist/host/cli.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { ZellijError } from "../errors.js";
|
|
3
|
+
import { crewCheck, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
|
|
4
|
+
import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
|
|
5
|
+
import { pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
|
|
6
|
+
import { runSlot } from "./slot.js";
|
|
7
|
+
/**
|
|
8
|
+
* Host commands: CLI-only (not MCP ops), because they run long, pass argv
|
|
9
|
+
* through or print plain text. `zswarm <command> --help` prints the usage.
|
|
10
|
+
*/
|
|
11
|
+
/** `--flag value` pairs and positionals; boolean flags are listed in `bools`. */
|
|
12
|
+
export function parseHostArgs(argv, bools = []) {
|
|
13
|
+
const flags = {};
|
|
14
|
+
const rest = [];
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
if (!a.startsWith("--")) {
|
|
18
|
+
rest.push(a);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (bools.includes(a)) {
|
|
22
|
+
flags[a] = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const v = argv[++i];
|
|
26
|
+
if (v === undefined)
|
|
27
|
+
throw new ZellijError("usage", `${a} needs a value`);
|
|
28
|
+
flags[a] = v;
|
|
29
|
+
}
|
|
30
|
+
return { flags, rest };
|
|
31
|
+
}
|
|
32
|
+
function str(v) {
|
|
33
|
+
return typeof v === "string" ? v : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Print a JSON result the way the other ops do; exit 0 on ok. */
|
|
36
|
+
async function json(work) {
|
|
37
|
+
try {
|
|
38
|
+
const data = await work();
|
|
39
|
+
process.stdout.write(`${JSON.stringify({ ok: true, data }, null, 2)}\n`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const e = err instanceof ZellijError ? { code: err.code, message: err.message, details: err.details } : { code: "failed", message: String(err) };
|
|
44
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: e }, null, 2)}\n`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function crew(argv) {
|
|
49
|
+
const [sub, ...args] = argv;
|
|
50
|
+
const { flags, rest } = parseHostArgs(args);
|
|
51
|
+
const session = rest[0] ?? str(flags["--session"]) ?? "";
|
|
52
|
+
const runAs = str(flags["--run-as"]);
|
|
53
|
+
switch (sub) {
|
|
54
|
+
case "up":
|
|
55
|
+
return json(() => {
|
|
56
|
+
const layout = str(flags["--layout"]);
|
|
57
|
+
if (!layout)
|
|
58
|
+
throw new ZellijError("usage", "crew up needs --layout <file.kdl>");
|
|
59
|
+
return crewUp({
|
|
60
|
+
session,
|
|
61
|
+
layout,
|
|
62
|
+
config: str(flags["--config"]),
|
|
63
|
+
envFile: str(flags["--env-file"]),
|
|
64
|
+
firstPane: str(flags["--first-pane"]),
|
|
65
|
+
runAs,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
case "down":
|
|
69
|
+
return json(() => crewDown({ session, runAs }));
|
|
70
|
+
case "status":
|
|
71
|
+
return json(() => crewStatus({ session, runAs }));
|
|
72
|
+
case "start":
|
|
73
|
+
return crewStart(session);
|
|
74
|
+
case "check":
|
|
75
|
+
return crewCheck(session);
|
|
76
|
+
default:
|
|
77
|
+
process.stderr.write(`${COMMANDS.crew.usage}\n`);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function relay(argv) {
|
|
82
|
+
const { flags } = parseHostArgs(argv, ["--foreground", "--prune"]);
|
|
83
|
+
const run = str(flags["--run"]);
|
|
84
|
+
if (run)
|
|
85
|
+
return runRelayDir(run);
|
|
86
|
+
const waitId = str(flags["--wait"]);
|
|
87
|
+
if (waitId) {
|
|
88
|
+
const message = await waitForRelay(waitId, { timeoutMin: Number(str(flags["--timeout-min"]) ?? 240) });
|
|
89
|
+
if (message === null) {
|
|
90
|
+
process.stderr.write(`relay ${waitId}: no message before the timeout\n`);
|
|
91
|
+
return 3;
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(message);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
if (flags["--prune"]) {
|
|
97
|
+
return json(() => ({ removed: pruneRelays(Number(str(flags["--days"]) ?? 14)) }));
|
|
98
|
+
}
|
|
99
|
+
return json(() => startRelay({
|
|
100
|
+
id: str(flags["--id"]),
|
|
101
|
+
serve: str(flags["--serve"]),
|
|
102
|
+
session: str(flags["--session"]),
|
|
103
|
+
to: str(flags["--to"]) ?? "",
|
|
104
|
+
bodyFile: str(flags["--body-file"]),
|
|
105
|
+
from: str(flags["--from"]),
|
|
106
|
+
done: str(flags["--done"]) ?? "",
|
|
107
|
+
replyTo: str(flags["--reply-to"]) ?? "",
|
|
108
|
+
replySession: str(flags["--reply-session"]),
|
|
109
|
+
check: str(flags["--check"]),
|
|
110
|
+
message: str(flags["--message"]),
|
|
111
|
+
timeoutMin: flags["--timeout-min"] ? Number(str(flags["--timeout-min"])) : undefined,
|
|
112
|
+
foreground: flags["--foreground"] === true,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
async function host(argv) {
|
|
116
|
+
const [sub, ...args] = argv;
|
|
117
|
+
const { flags } = parseHostArgs(args, ["--script", "--sudo"]);
|
|
118
|
+
const install = {
|
|
119
|
+
ssh: str(flags["--ssh"]),
|
|
120
|
+
sudo: flags["--sudo"] === true,
|
|
121
|
+
prefix: str(flags["--prefix"]),
|
|
122
|
+
binDir: str(flags["--bin-dir"]),
|
|
123
|
+
node: str(flags["--node"]),
|
|
124
|
+
zellij: str(flags["--zellij"]),
|
|
125
|
+
zswarm: str(flags["--zswarm"]),
|
|
126
|
+
};
|
|
127
|
+
switch (sub) {
|
|
128
|
+
case "install":
|
|
129
|
+
if (flags["--script"]) {
|
|
130
|
+
// For a caller with its own root channel: sh -s -- <args> < script
|
|
131
|
+
process.stdout.write(readFileSync(hostScriptPath("host-install.sh"), "utf8"));
|
|
132
|
+
process.stderr.write(`args: ${hostInstallArgs(install).join(" ")}\n`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
return json(() => hostInstall(install));
|
|
136
|
+
case "doctor": {
|
|
137
|
+
const res = await hostDoctor({ ssh: install.ssh, session: str(flags["--session"]), serve: str(flags["--serve"]) }).catch((err) => ({ error: err }));
|
|
138
|
+
if ("error" in res)
|
|
139
|
+
return json(() => Promise.reject(res.error));
|
|
140
|
+
process.stdout.write(`${JSON.stringify({ ok: res.ok, data: res }, null, 2)}\n`);
|
|
141
|
+
return res.ok ? 0 : 1;
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
process.stderr.write(`${COMMANDS.host.usage}\n`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const COMMANDS = {
|
|
149
|
+
slot: {
|
|
150
|
+
usage: [
|
|
151
|
+
"zswarm slot [--project NAME] [--] COMMAND [ARG ...] run COMMAND inside a build slot",
|
|
152
|
+
"zswarm slot --status show the host's slots and holders",
|
|
153
|
+
"zswarm slot --setup --slots N [--jobs J] [--group G] create slots.conf and lock files (root)",
|
|
154
|
+
"zswarm slot --setup-project NAME --cap M [--group G] cap a project below the host count (root)",
|
|
155
|
+
"zswarm slot --path print the bundled slot.py path",
|
|
156
|
+
].join("\n"),
|
|
157
|
+
run: (argv) => runSlot(argv),
|
|
158
|
+
},
|
|
159
|
+
crew: {
|
|
160
|
+
usage: [
|
|
161
|
+
"zswarm crew up <session> --layout L.kdl [--config C.kdl] [--env-file F] [--first-pane NAME] [--run-as USER]",
|
|
162
|
+
" keep the session running from its layout (service + 2-min watchdog)",
|
|
163
|
+
"zswarm crew status <session> [--run-as USER] services, session, panes",
|
|
164
|
+
"zswarm crew down <session> [--run-as USER] remove the services and kill the session",
|
|
165
|
+
"zswarm crew start|check <session> what the service and the watchdog run",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
run: crew,
|
|
168
|
+
},
|
|
169
|
+
relay: {
|
|
170
|
+
usage: [
|
|
171
|
+
"zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
|
|
172
|
+
" [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
|
|
173
|
+
" [--timeout-min 240] [--foreground]",
|
|
174
|
+
" send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
|
|
175
|
+
" waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane",
|
|
176
|
+
"zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
|
|
177
|
+
"zswarm relay --prune [--days 14] remove old relay dirs and undelivered messages",
|
|
178
|
+
].join("\n"),
|
|
179
|
+
run: relay,
|
|
180
|
+
},
|
|
181
|
+
host: {
|
|
182
|
+
usage: [
|
|
183
|
+
"zswarm host install --ssh DEST [--sudo] [--prefix /usr/local/lib/zswarm] [--bin-dir /usr/local/bin]",
|
|
184
|
+
" [--node V] [--zellij V] [--zswarm V] Node.js, zswarm and Zellij on a Linux/macOS host",
|
|
185
|
+
"zswarm host install --script [same options] print the script (run it as: sh -s -- <args printed on stderr>)",
|
|
186
|
+
"zswarm host doctor [--ssh DEST] [--serve TARGET] [--session S] tools, disk, services, serve, session, panes",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
run: host,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
export const HOST_COMMANDS = Object.keys(COMMANDS);
|
|
192
|
+
export function hostUsage() {
|
|
193
|
+
return Object.values(COMMANDS).map((c) => c.usage).join("\n") + "\n";
|
|
194
|
+
}
|
|
195
|
+
/** Run a host command; resolves to the process exit code. */
|
|
196
|
+
export async function runHostCommand(argv) {
|
|
197
|
+
const [name, ...rest] = argv;
|
|
198
|
+
const command = name ? COMMANDS[name] : undefined;
|
|
199
|
+
if (!command) {
|
|
200
|
+
process.stderr.write(`unknown host command ${name ?? ""}\n${hostUsage()}`);
|
|
201
|
+
return 2;
|
|
202
|
+
}
|
|
203
|
+
if (rest[0] === "-h" || rest[0] === "--help") {
|
|
204
|
+
process.stdout.write(`${command.usage}\n`);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return await command.run(rest);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
process.stderr.write(`zswarm ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Runner, type ServiceAccount, type ServiceHost, type ServiceSpec } from "./services.js";
|
|
2
|
+
export declare const CREW_WATCH_SEC = 120;
|
|
3
|
+
export type CrewDeps = {
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
/** The crew directory; defaults to ~/.zswarm/crews/<session>. */
|
|
6
|
+
dir?: string;
|
|
7
|
+
/** Runs zellij; injected in tests. */
|
|
8
|
+
zellij?: (args: string[], env: NodeJS.ProcessEnv) => {
|
|
9
|
+
status: number;
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
};
|
|
13
|
+
/** Live session names; injected in tests. */
|
|
14
|
+
sessions?: (env: NodeJS.ProcessEnv) => Promise<string[]>;
|
|
15
|
+
/** Pane titles (with exited flags) of a session; injected in tests. */
|
|
16
|
+
panes?: (session: string, env: NodeJS.ProcessEnv) => Promise<Array<{
|
|
17
|
+
title: string;
|
|
18
|
+
exited: boolean;
|
|
19
|
+
}>>;
|
|
20
|
+
sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
log?: (line: string) => void;
|
|
22
|
+
/** Restarts a systemd user unit (the watchdog on Linux); injected in tests. */
|
|
23
|
+
restartUnit?: (unit: string) => number;
|
|
24
|
+
};
|
|
25
|
+
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
26
|
+
export declare const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
27
|
+
export declare function checkCrewSession(session: string | undefined): string;
|
|
28
|
+
export declare function crewDir(account: ServiceAccount, session: string, env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
/** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
|
|
30
|
+
export declare function firstLayoutPane(kdl: string): string | undefined;
|
|
31
|
+
/** KEY=VALUE lines (optional `export`, quotes, comments) into an object. */
|
|
32
|
+
export declare function parseEnvFile(text: string): Record<string, string>;
|
|
33
|
+
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
34
|
+
export declare function crewStart(session: string, deps?: CrewDeps): Promise<number>;
|
|
35
|
+
/** Watchdog: restart the session if its server died or its first pane is gone. */
|
|
36
|
+
export declare function crewCheck(session: string, deps?: CrewDeps): Promise<number>;
|
|
37
|
+
export type CrewUpInput = {
|
|
38
|
+
session: string;
|
|
39
|
+
layout: string;
|
|
40
|
+
config?: string;
|
|
41
|
+
envFile?: string;
|
|
42
|
+
firstPane?: string;
|
|
43
|
+
runAs?: string;
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
account?: ServiceAccount;
|
|
46
|
+
run?: Runner;
|
|
47
|
+
fs?: ServiceHost["fs"];
|
|
48
|
+
launch?: {
|
|
49
|
+
execPath: string;
|
|
50
|
+
scriptPath: string;
|
|
51
|
+
};
|
|
52
|
+
/** Copies crew files into the crew dir; injected in tests. */
|
|
53
|
+
copy?: (account: ServiceAccount, from: string, to: string, secret: boolean) => void;
|
|
54
|
+
writeMeta?: (account: ServiceAccount, path: string, text: string) => void;
|
|
55
|
+
};
|
|
56
|
+
export declare function crewServiceSpecs(input: {
|
|
57
|
+
account: ServiceAccount;
|
|
58
|
+
session: string;
|
|
59
|
+
launch: {
|
|
60
|
+
execPath: string;
|
|
61
|
+
scriptPath: string;
|
|
62
|
+
};
|
|
63
|
+
env: NodeJS.ProcessEnv;
|
|
64
|
+
}): ServiceSpec[];
|
|
65
|
+
export declare function crewUp(input: CrewUpInput): Record<string, unknown>;
|
|
66
|
+
export declare function crewStatus(input: {
|
|
67
|
+
session: string;
|
|
68
|
+
runAs?: string;
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
account?: ServiceAccount;
|
|
71
|
+
run?: Runner;
|
|
72
|
+
deps?: CrewDeps;
|
|
73
|
+
}): Promise<Record<string, unknown>>;
|
|
74
|
+
export declare function crewDown(input: {
|
|
75
|
+
session: string;
|
|
76
|
+
runAs?: string;
|
|
77
|
+
env?: NodeJS.ProcessEnv;
|
|
78
|
+
account?: ServiceAccount;
|
|
79
|
+
run?: Runner;
|
|
80
|
+
fs?: ServiceHost["fs"];
|
|
81
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { posix } 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
|
+
import { resolveZellijBinary } from "../zellij/binary.js";
|
|
9
|
+
import { defaultRunner, ensureAccountDir, installServices, removeServices, serviceAccount, serviceStates, writeAccountFile, writeSecretFile, } from "./services.js";
|
|
10
|
+
// Service accounts are Linux or macOS accounts: their paths are POSIX paths
|
|
11
|
+
// even when this code runs (in tests) on Windows.
|
|
12
|
+
const { join } = posix;
|
|
13
|
+
/**
|
|
14
|
+
* `zswarm crew`: keep a Zellij session built from a layout running on a host.
|
|
15
|
+
*
|
|
16
|
+
* up copy the layout (and optional config and env file) into
|
|
17
|
+
* ~/.zswarm/crews/<session>/, install a oneshot service that starts
|
|
18
|
+
* the session and a watchdog that restarts it every two minutes if
|
|
19
|
+
* its server died or its layout did not take
|
|
20
|
+
* start what the service runs: start the session, prove the first layout
|
|
21
|
+
* pane exists, retry up to three times
|
|
22
|
+
* check what the watchdog runs: start again only if needed
|
|
23
|
+
* status services, session and panes
|
|
24
|
+
* down remove the services and kill the session
|
|
25
|
+
*
|
|
26
|
+
* Zellij now and then starts `attach --create-background --layout` as a
|
|
27
|
+
* plain one-pane session; the first-pane check is what catches that.
|
|
28
|
+
*/
|
|
29
|
+
const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
30
|
+
const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
|
|
31
|
+
export const CREW_WATCH_SEC = 120;
|
|
32
|
+
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
33
|
+
export const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
34
|
+
export function checkCrewSession(session) {
|
|
35
|
+
const s = session?.trim() ?? "";
|
|
36
|
+
if (!SESSION.test(s))
|
|
37
|
+
throw new ZellijError("bad_arg", `invalid session name ${JSON.stringify(s)}`);
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
export function crewDir(account, session, env = process.env) {
|
|
41
|
+
const base = !account.foreign && env.ZSWARM_STATE_DIR?.trim() ? defaultStateDir(env) : join(account.home, ".zswarm");
|
|
42
|
+
return join(base, "crews", session);
|
|
43
|
+
}
|
|
44
|
+
/** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
|
|
45
|
+
export function firstLayoutPane(kdl) {
|
|
46
|
+
for (const line of kdl.split("\n")) {
|
|
47
|
+
const code = line.replace(/\/\/.*$/, "");
|
|
48
|
+
if (!/^\s*pane\b/.test(code))
|
|
49
|
+
continue;
|
|
50
|
+
const m = /\bname\s*=?\s*"([^"]+)"/.exec(code);
|
|
51
|
+
if (m)
|
|
52
|
+
return m[1];
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** KEY=VALUE lines (optional `export`, quotes, comments) into an object. */
|
|
57
|
+
export function parseEnvFile(text) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
60
|
+
const line = raw.trim();
|
|
61
|
+
if (!line || line.startsWith("#"))
|
|
62
|
+
continue;
|
|
63
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
64
|
+
if (!m)
|
|
65
|
+
continue;
|
|
66
|
+
let value = m[2].trim();
|
|
67
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
68
|
+
value = value.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
out[m[1]] = value;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function readCrewFiles(dir) {
|
|
75
|
+
const meta = join(dir, "crew.json");
|
|
76
|
+
if (!existsSync(meta))
|
|
77
|
+
throw new ZellijError("usage", `no crew in ${dir}; run zswarm crew up first`);
|
|
78
|
+
return JSON.parse(readFileSync(meta, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
async function defaultSessions(env) {
|
|
81
|
+
const res = await dispatchZswarm({ op: "sessions" }, undefined, { env });
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
return [];
|
|
84
|
+
return (res.data.sessions ?? []).map((s) => s.name ?? "");
|
|
85
|
+
}
|
|
86
|
+
async function defaultPanes(session, env) {
|
|
87
|
+
const res = await dispatchZswarm({ op: "list", session }, undefined, { env });
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
return [];
|
|
90
|
+
const panes = res.data.panes ?? [];
|
|
91
|
+
return panes.map((p) => ({ title: p.title ?? "", exited: p.exited === true }));
|
|
92
|
+
}
|
|
93
|
+
function defaultZellij(args, env) {
|
|
94
|
+
const res = spawnSync(resolveZellijBinary(env), args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
|
|
95
|
+
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
96
|
+
}
|
|
97
|
+
function crewEnv(dir, files, env) {
|
|
98
|
+
const extra = files.env ? parseEnvFile(readFileSync(join(dir, files.env), "utf8")) : {};
|
|
99
|
+
// No bus inside the start/check helpers: they list panes once and exit.
|
|
100
|
+
return { ...env, TERM: env.TERM || "xterm-256color", ...extra, ZSWARM_BUS: "0" };
|
|
101
|
+
}
|
|
102
|
+
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
103
|
+
export async function crewStart(session, deps = {}) {
|
|
104
|
+
const env0 = deps.env ?? process.env;
|
|
105
|
+
const dir = deps.dir ?? crewDir(serviceAccount(undefined), checkCrewSession(session), env0);
|
|
106
|
+
const files = readCrewFiles(dir);
|
|
107
|
+
const env = crewEnv(dir, files, env0);
|
|
108
|
+
const zellij = deps.zellij ?? defaultZellij;
|
|
109
|
+
const sessions = deps.sessions ?? defaultSessions;
|
|
110
|
+
const panes = deps.panes ?? defaultPanes;
|
|
111
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
112
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
113
|
+
const config = files.config ? ["--config", join(dir, files.config)] : [];
|
|
114
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
115
|
+
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
116
|
+
zellij([...config, "--layout", join(dir, files.layout), "attach", "--create-background", session], env);
|
|
117
|
+
for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
|
|
118
|
+
await sleep(500);
|
|
119
|
+
await sleep(2_000);
|
|
120
|
+
if ((await panes(session, env)).some((p) => p.title === files.first)) {
|
|
121
|
+
log(`session ${session} up with its layout (attempt ${attempt})`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
125
|
+
zellij(["kill-session", session], env);
|
|
126
|
+
await sleep(1_000);
|
|
127
|
+
}
|
|
128
|
+
log(`session ${session} never took its layout`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
/** Watchdog: restart the session if its server died or its first pane is gone. */
|
|
132
|
+
export async function crewCheck(session, deps = {}) {
|
|
133
|
+
const env0 = deps.env ?? process.env;
|
|
134
|
+
const dir = deps.dir ?? crewDir(serviceAccount(undefined), checkCrewSession(session), env0);
|
|
135
|
+
const files = readCrewFiles(dir);
|
|
136
|
+
const env = crewEnv(dir, files, env0);
|
|
137
|
+
const sessions = deps.sessions ?? defaultSessions;
|
|
138
|
+
const panes = deps.panes ?? defaultPanes;
|
|
139
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
140
|
+
if ((await sessions(env)).includes(session) && (await panes(session, env)).some((p) => p.title === files.first)) {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
log(`session ${session} is gone or lost its layout; restarting`);
|
|
144
|
+
(deps.zellij ?? defaultZellij)(["kill-session", session], env);
|
|
145
|
+
const unit = env0[CREW_UNIT_ENV]?.trim();
|
|
146
|
+
if (unit) {
|
|
147
|
+
// Under systemd a server started here would live in the watchdog's cgroup
|
|
148
|
+
// and die when this check exits; the crew unit owns the session instead.
|
|
149
|
+
const restart = deps.restartUnit ?? ((u) => spawnSync("systemctl", ["--user", "restart", u], { stdio: "inherit" }).status ?? 1);
|
|
150
|
+
return restart(unit);
|
|
151
|
+
}
|
|
152
|
+
return await crewStart(session, deps);
|
|
153
|
+
}
|
|
154
|
+
function copyForAccount(account, from, to, secret) {
|
|
155
|
+
ensureAccountDir(account, to.slice(0, to.lastIndexOf("/")));
|
|
156
|
+
if (secret) {
|
|
157
|
+
writeSecretFile(account, to, readFileSync(from, "utf8"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Read as the caller, write as the account (no chown through a planted symlink).
|
|
161
|
+
writeAccountFile(account, to, readFileSync(from), 0o644);
|
|
162
|
+
}
|
|
163
|
+
export function crewServiceSpecs(input) {
|
|
164
|
+
const { account, session, launch } = input;
|
|
165
|
+
const base = account.foreign
|
|
166
|
+
? { PATH: FOREIGN_PATH }
|
|
167
|
+
: { PATH: input.env.PATH ?? FOREIGN_PATH };
|
|
168
|
+
for (const key of ["ZSWARM_BIN", "ZSWARM_STATE_DIR"]) {
|
|
169
|
+
const v = input.env[key]?.trim();
|
|
170
|
+
if (v && !(account.foreign && key === "ZSWARM_STATE_DIR"))
|
|
171
|
+
base[key] = v;
|
|
172
|
+
}
|
|
173
|
+
const cli = [launch.execPath, launch.scriptPath, "crew"];
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
name: `crew-${session}`,
|
|
177
|
+
description: `zswarm crew: Zellij session ${session} from its layout`,
|
|
178
|
+
kind: "oneshot",
|
|
179
|
+
argv: [...cli, "start", session],
|
|
180
|
+
env: base,
|
|
181
|
+
workingDir: account.home,
|
|
182
|
+
// The forked Zellij server and its panes outlive a stop or restart of the
|
|
183
|
+
// unit (a rerun of crew up must not kill working agents); crew down
|
|
184
|
+
// kills the session itself.
|
|
185
|
+
killMode: "process",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: `crew-${session}-watch`,
|
|
189
|
+
description: `zswarm crew: restart session ${session} if it died`,
|
|
190
|
+
kind: "interval",
|
|
191
|
+
intervalSec: CREW_WATCH_SEC,
|
|
192
|
+
argv: [...cli, "check", session],
|
|
193
|
+
env: account.platform === "linux" ? { ...base, [CREW_UNIT_ENV]: `zswarm-crew-${session}.service` } : base,
|
|
194
|
+
workingDir: account.home,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
export function crewUp(input) {
|
|
199
|
+
const env = input.env ?? process.env;
|
|
200
|
+
const run = input.run ?? defaultRunner;
|
|
201
|
+
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
202
|
+
const session = checkCrewSession(input.session);
|
|
203
|
+
if (!existsSync(input.layout))
|
|
204
|
+
throw new ZellijError("usage", `layout ${input.layout} not found`);
|
|
205
|
+
const kdl = readFileSync(input.layout, "utf8");
|
|
206
|
+
const first = input.firstPane?.trim() || firstLayoutPane(kdl);
|
|
207
|
+
if (!first) {
|
|
208
|
+
throw new ZellijError("usage", "the layout names no pane; give panes a name or pass --first-pane");
|
|
209
|
+
}
|
|
210
|
+
const launch = input.launch ?? resolveServeCliLaunch({ env });
|
|
211
|
+
const dir = crewDir(account, session, env);
|
|
212
|
+
const copy = input.copy ?? copyForAccount;
|
|
213
|
+
const files = { layout: "layout.kdl", first };
|
|
214
|
+
copy(account, input.layout, join(dir, "layout.kdl"), false);
|
|
215
|
+
if (input.config) {
|
|
216
|
+
copy(account, input.config, join(dir, "config.kdl"), false);
|
|
217
|
+
files.config = "config.kdl";
|
|
218
|
+
}
|
|
219
|
+
if (input.envFile) {
|
|
220
|
+
copy(account, input.envFile, join(dir, "env"), true);
|
|
221
|
+
files.env = "env";
|
|
222
|
+
}
|
|
223
|
+
// crew.json is written last, so a half-copied crew never starts.
|
|
224
|
+
(input.writeMeta ?? writeSecretFile)(account, join(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
|
|
225
|
+
const specs = crewServiceSpecs({ account, session, launch, env });
|
|
226
|
+
const done = installServices({ account, run, fs: input.fs }, specs);
|
|
227
|
+
const states = serviceStates({ account, run }, specs);
|
|
228
|
+
return {
|
|
229
|
+
session,
|
|
230
|
+
user: account.user,
|
|
231
|
+
dir,
|
|
232
|
+
firstPane: first,
|
|
233
|
+
services: states,
|
|
234
|
+
ready: states.every((s) => s.active),
|
|
235
|
+
actions: done.lines,
|
|
236
|
+
notes: done.notes,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export async function crewStatus(input) {
|
|
240
|
+
const env = input.env ?? process.env;
|
|
241
|
+
const run = input.run ?? defaultRunner;
|
|
242
|
+
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
243
|
+
const session = checkCrewSession(input.session);
|
|
244
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
245
|
+
const services = serviceStates({ account, run }, specs);
|
|
246
|
+
const out = { session, user: account.user, services };
|
|
247
|
+
if (account.foreign) {
|
|
248
|
+
out.note = `session and panes belong to ${account.user}: run zswarm crew status ${session} as that user`;
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
const files = existsSync(join(crewDir(account, session, env), "crew.json"))
|
|
252
|
+
? readCrewFiles(crewDir(account, session, env))
|
|
253
|
+
: undefined;
|
|
254
|
+
const live = (await (input.deps?.sessions ?? defaultSessions)(env)).includes(session);
|
|
255
|
+
const panes = live ? await (input.deps?.panes ?? defaultPanes)(session, { ...env, ZSWARM_BUS: "0" }) : [];
|
|
256
|
+
out.live = live;
|
|
257
|
+
out.firstPane = files?.first ?? null;
|
|
258
|
+
out.layoutTook = files ? panes.some((p) => p.title === files.first) : null;
|
|
259
|
+
out.panes = panes;
|
|
260
|
+
out.healthy = services.every((s) => s.active) && live && out.layoutTook !== false && panes.every((p) => !p.exited);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
export function crewDown(input) {
|
|
264
|
+
const env = input.env ?? process.env;
|
|
265
|
+
const run = input.run ?? defaultRunner;
|
|
266
|
+
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
267
|
+
const session = checkCrewSession(input.session);
|
|
268
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
269
|
+
const lines = removeServices({ account, run, fs: input.fs }, specs);
|
|
270
|
+
// The Zellij server belongs to the account: kill it as that user.
|
|
271
|
+
const zellij = env.ZSWARM_BIN?.trim() || "zellij";
|
|
272
|
+
const kill = account.foreign
|
|
273
|
+
? account.platform === "linux"
|
|
274
|
+
? run("runuser", ["-u", account.user, "--", "env", `XDG_RUNTIME_DIR=/run/user/${account.uid}`, `PATH=${FOREIGN_PATH}`, zellij, "kill-session", session])
|
|
275
|
+
: run("sudo", ["-u", account.user, "-H", "env", `PATH=${FOREIGN_PATH}`, zellij, "kill-session", session])
|
|
276
|
+
: run(zellij, ["kill-session", session]);
|
|
277
|
+
lines.push(kill.status === 0 ? `session ${session} killed` : `session ${session} was not running`);
|
|
278
|
+
return { session, user: account.user, actions: lines };
|
|
279
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OpsResult } from "../ops/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `zswarm host install` puts Node.js, zswarm and Zellij on a Linux or macOS
|
|
4
|
+
* host over SSH (or prints the script, for callers with their own root
|
|
5
|
+
* channel). `zswarm host doctor` checks a host: tools, disk, sessions and
|
|
6
|
+
* services over SSH, then serve, the session and its panes through the
|
|
7
|
+
* normal transport.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hostScriptPath(name: "host-install.sh" | "host-report.sh"): string;
|
|
10
|
+
export type SshRunner = (dest: string, remoteCommand: string[], stdin: string) => {
|
|
11
|
+
status: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const defaultSsh: SshRunner;
|
|
16
|
+
export type HostInstallInput = {
|
|
17
|
+
ssh?: string;
|
|
18
|
+
sudo?: boolean;
|
|
19
|
+
prefix?: string;
|
|
20
|
+
binDir?: string;
|
|
21
|
+
node?: string;
|
|
22
|
+
zellij?: string;
|
|
23
|
+
zswarm?: string;
|
|
24
|
+
run?: SshRunner;
|
|
25
|
+
};
|
|
26
|
+
export declare function hostInstallArgs(input: HostInstallInput): string[];
|
|
27
|
+
export declare function hostInstall(input: HostInstallInput): {
|
|
28
|
+
steps: Record<string, string>;
|
|
29
|
+
output: string;
|
|
30
|
+
};
|
|
31
|
+
export type HostCheck = {
|
|
32
|
+
id: string;
|
|
33
|
+
state: "pass" | "warn" | "fail";
|
|
34
|
+
detail: string;
|
|
35
|
+
};
|
|
36
|
+
/** KEY=VALUE lines from host-report.sh; `service=` lines are collected. */
|
|
37
|
+
export declare function parseHostReport(text: string): {
|
|
38
|
+
facts: Record<string, string>;
|
|
39
|
+
services: string[];
|
|
40
|
+
};
|
|
41
|
+
export declare function hostChecks(facts: Record<string, string>, services: string[], session?: string): HostCheck[];
|
|
42
|
+
export type HostDoctorInput = {
|
|
43
|
+
ssh?: string;
|
|
44
|
+
session?: string;
|
|
45
|
+
serve?: string;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
run?: SshRunner;
|
|
48
|
+
call?: (args: Record<string, unknown>, env: NodeJS.ProcessEnv) => Promise<OpsResult>;
|
|
49
|
+
};
|
|
50
|
+
export declare function hostDoctor(input: HostDoctorInput): Promise<{
|
|
51
|
+
ok: boolean;
|
|
52
|
+
checks: HostCheck[];
|
|
53
|
+
facts?: Record<string, string>;
|
|
54
|
+
transport?: unknown;
|
|
55
|
+
}>;
|