@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background services for host commands (serve, crew): systemd user units
|
|
3
|
+
* on Linux (with linger, so they run without a login), launchd on macOS (a
|
|
4
|
+
* LaunchAgent for the current user, or, from root with `runAs`, a
|
|
5
|
+
* LaunchDaemon that runs as that user, for accounts that never log in).
|
|
6
|
+
* Secrets never go into unit files or plists: services read them from
|
|
7
|
+
* mode-600 files named in their environment.
|
|
8
|
+
*/
|
|
9
|
+
export type ServiceKind =
|
|
10
|
+
/** Long-running; restarted when it exits. */
|
|
11
|
+
"daemon"
|
|
12
|
+
/** Runs once at start (its children may outlive it); counted as up afterwards. */
|
|
13
|
+
| "oneshot"
|
|
14
|
+
/** Runs every `intervalSec` seconds. */
|
|
15
|
+
| "interval";
|
|
16
|
+
export type ServiceSpec = {
|
|
17
|
+
/** Unique within the account: `zswarm-<name>` (systemd) or `zswarm.<name>` (launchd). */
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
kind: ServiceKind;
|
|
21
|
+
argv: string[];
|
|
22
|
+
env?: Record<string, string>;
|
|
23
|
+
intervalSec?: number;
|
|
24
|
+
/** Names of other specs this one starts after (systemd ordering only). */
|
|
25
|
+
after?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* systemd only: "process" leaves children (a forked Zellij server) alive
|
|
28
|
+
* when the unit stops or restarts; the default kills the whole cgroup.
|
|
29
|
+
*/
|
|
30
|
+
killMode?: "process";
|
|
31
|
+
workingDir?: string;
|
|
32
|
+
};
|
|
33
|
+
export type ServiceAccount = {
|
|
34
|
+
platform: "linux" | "darwin";
|
|
35
|
+
user: string;
|
|
36
|
+
uid: number;
|
|
37
|
+
gid: number;
|
|
38
|
+
home: string;
|
|
39
|
+
/** Installing as root for another account (`runAs`). */
|
|
40
|
+
foreign: boolean;
|
|
41
|
+
};
|
|
42
|
+
export type RunResult = {
|
|
43
|
+
status: number;
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
};
|
|
47
|
+
export type Runner = (cmd: string, args: string[]) => RunResult;
|
|
48
|
+
export type ServiceState = {
|
|
49
|
+
name: string;
|
|
50
|
+
unit: string;
|
|
51
|
+
state: string;
|
|
52
|
+
active: boolean;
|
|
53
|
+
};
|
|
54
|
+
export type ServiceHost = {
|
|
55
|
+
account: ServiceAccount;
|
|
56
|
+
run: Runner;
|
|
57
|
+
/** File writes; injected in tests. */
|
|
58
|
+
fs?: {
|
|
59
|
+
write(path: string, text: string, mode: number): void;
|
|
60
|
+
remove(path: string): void;
|
|
61
|
+
exists(path: string): boolean;
|
|
62
|
+
mkdir(path: string): void;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export declare const defaultRunner: Runner;
|
|
66
|
+
/** The account services are installed for: the current user, or `runAs` when root. */
|
|
67
|
+
export declare function serviceAccount(runAs?: string, run?: Runner): ServiceAccount;
|
|
68
|
+
/**
|
|
69
|
+
* The argv prefix that runs a command as the account: `runuser` on Linux,
|
|
70
|
+
* `sudo -u` on macOS. Root writes into the account's home through it, so the
|
|
71
|
+
* kernel refuses what the account itself could not do (a symlink it planted
|
|
72
|
+
* towards a root-owned file, say) and new files are the account's own. (Not
|
|
73
|
+
* seteuid in this process: Node disables it on Linux while io_uring may be
|
|
74
|
+
* enabled, CVE-2024-22017.)
|
|
75
|
+
*/
|
|
76
|
+
export declare function accountArgv(account: ServiceAccount): string[];
|
|
77
|
+
/** Write a file in the account's home as the account (see accountArgv). */
|
|
78
|
+
export declare function writeAccountFile(account: ServiceAccount, path: string, text: string | Buffer, mode: number): void;
|
|
79
|
+
export declare function systemdUnitName(name: string, kind: ServiceKind): string;
|
|
80
|
+
export declare function systemdUnits(spec: ServiceSpec): Array<{
|
|
81
|
+
file: string;
|
|
82
|
+
text: string;
|
|
83
|
+
}>;
|
|
84
|
+
/** Linger keeps the user's services running without a login session. */
|
|
85
|
+
export declare function ensureLinger(host: ServiceHost): {
|
|
86
|
+
linger: boolean;
|
|
87
|
+
note?: string;
|
|
88
|
+
};
|
|
89
|
+
export declare function launchdLabel(account: ServiceAccount, name: string): string;
|
|
90
|
+
export declare function launchdPlist(account: ServiceAccount, spec: ServiceSpec): string;
|
|
91
|
+
/** Write, (re)load and start the services. Returns what it did, one line per unit. */
|
|
92
|
+
export declare function installServices(host: ServiceHost, specs: ServiceSpec[]): {
|
|
93
|
+
lines: string[];
|
|
94
|
+
notes: string[];
|
|
95
|
+
};
|
|
96
|
+
/** Stop and delete the services; missing ones are fine. */
|
|
97
|
+
export declare function removeServices(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): string[];
|
|
98
|
+
export declare function serviceStates(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): ServiceState[];
|
|
99
|
+
/** Create dir and any missing parents, owned by the account when installing for it. */
|
|
100
|
+
export declare function ensureAccountDir(account: ServiceAccount, dir: string, mode?: number): void;
|
|
101
|
+
/** Write a mode-600 file for the account, creating its directory (700) as needed. */
|
|
102
|
+
export declare function writeSecretFile(account: ServiceAccount, path: string, text: string): void;
|
|
103
|
+
export declare function readSecretFile(path: string): string | undefined;
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, userInfo } from "node:os";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
import { ZellijError } from "../errors.js";
|
|
6
|
+
// Service accounts are Linux or macOS accounts: their paths are POSIX paths
|
|
7
|
+
// even when this code runs (in tests) on Windows.
|
|
8
|
+
const { join } = posix;
|
|
9
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
10
|
+
export const defaultRunner = (cmd, args) => {
|
|
11
|
+
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
|
12
|
+
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
|
|
13
|
+
};
|
|
14
|
+
/** The account services are installed for: the current user, or `runAs` when root. */
|
|
15
|
+
export function serviceAccount(runAs, run = defaultRunner) {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
if (platform !== "linux" && platform !== "darwin") {
|
|
18
|
+
throw new ZellijError("usage", `host services are for Linux and macOS (this is ${platform}); on Windows use serve --install`);
|
|
19
|
+
}
|
|
20
|
+
const me = userInfo();
|
|
21
|
+
const target = runAs?.trim();
|
|
22
|
+
if (!target || target === me.username) {
|
|
23
|
+
return { platform, user: me.username, uid: me.uid, gid: me.gid, home: process.env.HOME || homedir(), foreign: false };
|
|
24
|
+
}
|
|
25
|
+
if (me.uid !== 0) {
|
|
26
|
+
throw new ZellijError("usage", `--run-as ${target} needs root`);
|
|
27
|
+
}
|
|
28
|
+
const id = (flag) => {
|
|
29
|
+
const res = run("id", [flag, target]);
|
|
30
|
+
if (res.status !== 0)
|
|
31
|
+
throw new ZellijError("usage", `no such user ${target}`);
|
|
32
|
+
return Number(res.stdout.trim());
|
|
33
|
+
};
|
|
34
|
+
const home = platform === "darwin"
|
|
35
|
+
? (run("dscl", [".", "-read", `/Users/${target}`, "NFSHomeDirectory"]).stdout.split(":").pop() ?? "").trim()
|
|
36
|
+
: (run("getent", ["passwd", target]).stdout.split(":")[5] ?? "").trim();
|
|
37
|
+
if (!home)
|
|
38
|
+
throw new ZellijError("usage", `cannot find the home of ${target}`);
|
|
39
|
+
return { platform, user: target, uid: id("-u"), gid: id("-g"), home, foreign: true };
|
|
40
|
+
}
|
|
41
|
+
/** Root installing for another account (`--run-as`). */
|
|
42
|
+
function installingFor(account) {
|
|
43
|
+
return account.foreign && typeof process.geteuid === "function" && process.geteuid() === 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The argv prefix that runs a command as the account: `runuser` on Linux,
|
|
47
|
+
* `sudo -u` on macOS. Root writes into the account's home through it, so the
|
|
48
|
+
* kernel refuses what the account itself could not do (a symlink it planted
|
|
49
|
+
* towards a root-owned file, say) and new files are the account's own. (Not
|
|
50
|
+
* seteuid in this process: Node disables it on Linux while io_uring may be
|
|
51
|
+
* enabled, CVE-2024-22017.)
|
|
52
|
+
*/
|
|
53
|
+
export function accountArgv(account) {
|
|
54
|
+
return account.platform === "linux"
|
|
55
|
+
? ["runuser", "-u", account.user, "--"]
|
|
56
|
+
: ["sudo", "-n", "-u", account.user, "-H", "--"];
|
|
57
|
+
}
|
|
58
|
+
function runAsAccount(account, argv, input) {
|
|
59
|
+
const [cmd, ...rest] = [...accountArgv(account), ...argv];
|
|
60
|
+
const res = spawnSync(cmd, rest, { input, encoding: "utf8" });
|
|
61
|
+
if (res.status !== 0) {
|
|
62
|
+
throw new ZellijError("failed", `${argv[0]} as ${account.user} failed: ${(res.stderr || res.error?.message || "").trim().slice(0, 300)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Write a file in the account's home as the account (see accountArgv). */
|
|
66
|
+
export function writeAccountFile(account, path, text, mode) {
|
|
67
|
+
if (!installingFor(account)) {
|
|
68
|
+
writeFileSync(path, text, { mode });
|
|
69
|
+
chmodSync(path, mode);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
runAsAccount(account, ["sh", "-c", 'umask 077 && cat > "$1" && chmod "$2" "$1"', "sh", path, mode.toString(8)], text);
|
|
73
|
+
}
|
|
74
|
+
function realFs(account) {
|
|
75
|
+
return {
|
|
76
|
+
write(path, text, mode) {
|
|
77
|
+
// LaunchDaemons are root's own directory; everything else is in the account's home.
|
|
78
|
+
if (path.startsWith("/Library/"))
|
|
79
|
+
writeFileSync(path, text, { mode });
|
|
80
|
+
else
|
|
81
|
+
writeAccountFile(account, path, text, mode);
|
|
82
|
+
},
|
|
83
|
+
remove(path) {
|
|
84
|
+
rmSync(path, { force: true });
|
|
85
|
+
},
|
|
86
|
+
exists(path) {
|
|
87
|
+
return existsSync(path);
|
|
88
|
+
},
|
|
89
|
+
mkdir(path) {
|
|
90
|
+
ensureAccountDir(account, path, 0o755);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function checkName(name) {
|
|
95
|
+
if (!NAME.test(name))
|
|
96
|
+
throw new ZellijError("bad_arg", `invalid service name ${JSON.stringify(name)}`);
|
|
97
|
+
}
|
|
98
|
+
// ------------------------------------------------------------------ systemd
|
|
99
|
+
export function systemdUnitName(name, kind) {
|
|
100
|
+
return `zswarm-${name}.${kind === "interval" ? "timer" : "service"}`;
|
|
101
|
+
}
|
|
102
|
+
/** systemd quoting for one ExecStart word or Environment= value. */
|
|
103
|
+
function sdQuote(word) {
|
|
104
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
|
|
105
|
+
return word.replace(/%/g, "%%");
|
|
106
|
+
return `"${word.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
|
|
107
|
+
}
|
|
108
|
+
export function systemdUnits(spec) {
|
|
109
|
+
checkName(spec.name);
|
|
110
|
+
const service = `zswarm-${spec.name}.service`;
|
|
111
|
+
const after = (spec.after ?? []).map((n) => `zswarm-${n}.service`);
|
|
112
|
+
const lines = ["[Unit]", `Description=${spec.description}`];
|
|
113
|
+
if (after.length)
|
|
114
|
+
lines.push(`After=${after.join(" ")}`, `Wants=${after.join(" ")}`);
|
|
115
|
+
lines.push("", "[Service]");
|
|
116
|
+
if (spec.kind === "daemon")
|
|
117
|
+
lines.push("Type=simple", "Restart=on-failure", "RestartSec=5");
|
|
118
|
+
else
|
|
119
|
+
lines.push("Type=oneshot");
|
|
120
|
+
if (spec.kind === "oneshot")
|
|
121
|
+
lines.push("RemainAfterExit=yes");
|
|
122
|
+
if (spec.killMode)
|
|
123
|
+
lines.push(`KillMode=${spec.killMode}`);
|
|
124
|
+
for (const [key, value] of Object.entries(spec.env ?? {}))
|
|
125
|
+
lines.push(`Environment=${sdQuote(`${key}=${value}`)}`);
|
|
126
|
+
if (spec.workingDir)
|
|
127
|
+
lines.push(`WorkingDirectory=${spec.workingDir}`);
|
|
128
|
+
lines.push(`ExecStart=${spec.argv.map(sdQuote).join(" ")}`);
|
|
129
|
+
if (spec.kind !== "interval")
|
|
130
|
+
lines.push("", "[Install]", "WantedBy=default.target");
|
|
131
|
+
const out = [{ file: service, text: lines.join("\n") + "\n" }];
|
|
132
|
+
if (spec.kind === "interval") {
|
|
133
|
+
const every = Math.max(30, Math.floor(spec.intervalSec ?? 120));
|
|
134
|
+
out.push({
|
|
135
|
+
file: `zswarm-${spec.name}.timer`,
|
|
136
|
+
text: [
|
|
137
|
+
"[Unit]",
|
|
138
|
+
`Description=${spec.description} (every ${every}s)`,
|
|
139
|
+
"",
|
|
140
|
+
"[Timer]",
|
|
141
|
+
"OnBootSec=3min",
|
|
142
|
+
`OnUnitActiveSec=${every}s`,
|
|
143
|
+
"",
|
|
144
|
+
"[Install]",
|
|
145
|
+
"WantedBy=timers.target",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n"),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function systemctl(host, args) {
|
|
153
|
+
const a = host.account;
|
|
154
|
+
return a.foreign
|
|
155
|
+
? host.run("systemctl", ["--user", "-M", `${a.user}@.host`, ...args])
|
|
156
|
+
: host.run("systemctl", ["--user", ...args]);
|
|
157
|
+
}
|
|
158
|
+
/** Linger keeps the user's services running without a login session. */
|
|
159
|
+
export function ensureLinger(host) {
|
|
160
|
+
const a = host.account;
|
|
161
|
+
const shown = host.run("loginctl", ["show-user", a.user, "-p", "Linger", "--value"]);
|
|
162
|
+
if (shown.stdout.trim() === "yes")
|
|
163
|
+
return { linger: true };
|
|
164
|
+
const res = host.run("loginctl", ["enable-linger", a.user]);
|
|
165
|
+
if (res.status === 0)
|
|
166
|
+
return { linger: true };
|
|
167
|
+
return {
|
|
168
|
+
linger: false,
|
|
169
|
+
note: `linger is off for ${a.user} (services stop at logout); run: sudo loginctl enable-linger ${a.user}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// ------------------------------------------------------------------ launchd
|
|
173
|
+
export function launchdLabel(account, name) {
|
|
174
|
+
return account.foreign ? `zswarm.${account.user}.${name}` : `zswarm.${name}`;
|
|
175
|
+
}
|
|
176
|
+
function launchdPlistPath(account, label) {
|
|
177
|
+
return account.foreign
|
|
178
|
+
? `/Library/LaunchDaemons/${label}.plist`
|
|
179
|
+
: join(account.home, "Library", "LaunchAgents", `${label}.plist`);
|
|
180
|
+
}
|
|
181
|
+
function xml(text) {
|
|
182
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
183
|
+
}
|
|
184
|
+
export function launchdPlist(account, spec) {
|
|
185
|
+
checkName(spec.name);
|
|
186
|
+
const label = launchdLabel(account, spec.name);
|
|
187
|
+
const log = join(account.home, "Library", "Logs", "zswarm", `${spec.name}.log`);
|
|
188
|
+
const env = { HOME: account.home, USER: account.user, ...(spec.env ?? {}) };
|
|
189
|
+
const lines = [
|
|
190
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
191
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
192
|
+
'<plist version="1.0">',
|
|
193
|
+
"<dict>",
|
|
194
|
+
` <key>Label</key><string>${xml(label)}</string>`,
|
|
195
|
+
];
|
|
196
|
+
if (account.foreign)
|
|
197
|
+
lines.push(` <key>UserName</key><string>${xml(account.user)}</string>`);
|
|
198
|
+
lines.push(` <key>WorkingDirectory</key><string>${xml(spec.workingDir ?? account.home)}</string>`, " <key>ProgramArguments</key>", " <array>", ...spec.argv.map((a) => ` <string>${xml(a)}</string>`), " </array>", " <key>EnvironmentVariables</key>", " <dict>", ...Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`), " </dict>",
|
|
199
|
+
// A oneshot that forks (a Zellij server) must not take its children down on exit.
|
|
200
|
+
" <key>AbandonProcessGroup</key><true/>");
|
|
201
|
+
if (spec.kind === "interval") {
|
|
202
|
+
lines.push(` <key>StartInterval</key><integer>${Math.max(30, Math.floor(spec.intervalSec ?? 120))}</integer>`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
lines.push(" <key>RunAtLoad</key><true/>");
|
|
206
|
+
}
|
|
207
|
+
if (spec.kind === "daemon") {
|
|
208
|
+
lines.push(" <key>KeepAlive</key><true/>", " <key>ThrottleInterval</key><integer>10</integer>");
|
|
209
|
+
}
|
|
210
|
+
lines.push(` <key>StandardOutPath</key><string>${xml(log)}</string>`, ` <key>StandardErrorPath</key><string>${xml(log)}</string>`, "</dict>", "</plist>", "");
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|
|
213
|
+
function launchdDomain(host) {
|
|
214
|
+
const a = host.account;
|
|
215
|
+
if (a.foreign)
|
|
216
|
+
return "system";
|
|
217
|
+
const gui = `gui/${a.uid}`;
|
|
218
|
+
return host.run("launchctl", ["print", gui]).status === 0 ? gui : `user/${a.uid}`;
|
|
219
|
+
}
|
|
220
|
+
// ------------------------------------------------------------------ common
|
|
221
|
+
/** Write, (re)load and start the services. Returns what it did, one line per unit. */
|
|
222
|
+
export function installServices(host, specs) {
|
|
223
|
+
const fs = host.fs ?? realFs(host.account);
|
|
224
|
+
const a = host.account;
|
|
225
|
+
const lines = [];
|
|
226
|
+
const notes = [];
|
|
227
|
+
if (a.platform === "linux") {
|
|
228
|
+
const dir = join(a.home, ".config", "systemd", "user");
|
|
229
|
+
fs.mkdir(join(a.home, ".config"));
|
|
230
|
+
fs.mkdir(join(a.home, ".config", "systemd"));
|
|
231
|
+
fs.mkdir(dir);
|
|
232
|
+
const linger = ensureLinger(host);
|
|
233
|
+
if (linger.note)
|
|
234
|
+
notes.push(linger.note);
|
|
235
|
+
const enable = [];
|
|
236
|
+
for (const spec of specs) {
|
|
237
|
+
for (const unit of systemdUnits(spec))
|
|
238
|
+
fs.write(join(dir, unit.file), unit.text, 0o644);
|
|
239
|
+
enable.push(systemdUnitName(spec.name, spec.kind));
|
|
240
|
+
}
|
|
241
|
+
must(systemctl(host, ["daemon-reload"]), "systemctl --user daemon-reload");
|
|
242
|
+
for (const unit of enable) {
|
|
243
|
+
must(systemctl(host, ["enable", unit]), `enable ${unit}`);
|
|
244
|
+
// restart, not start: a reinstall must pick up changed arguments.
|
|
245
|
+
must(systemctl(host, ["restart", unit]), `start ${unit}`);
|
|
246
|
+
lines.push(`${unit} started`);
|
|
247
|
+
}
|
|
248
|
+
return { lines, notes };
|
|
249
|
+
}
|
|
250
|
+
const domain = launchdDomain(host);
|
|
251
|
+
fs.mkdir(join(a.home, "Library", "Logs", "zswarm"));
|
|
252
|
+
if (!a.foreign)
|
|
253
|
+
fs.mkdir(join(a.home, "Library", "LaunchAgents"));
|
|
254
|
+
if (domain.startsWith("user/")) {
|
|
255
|
+
notes.push(`no GUI login for ${a.user}: LaunchAgents load now but only return after a login; install from root with --run-as ${a.user} for a service that survives reboots`);
|
|
256
|
+
}
|
|
257
|
+
for (const spec of specs) {
|
|
258
|
+
const label = launchdLabel(a, spec.name);
|
|
259
|
+
const path = launchdPlistPath(a, label);
|
|
260
|
+
fs.write(path, launchdPlist(a, spec), 0o644);
|
|
261
|
+
host.run("launchctl", ["bootout", `${domain}/${label}`]);
|
|
262
|
+
must(host.run("launchctl", ["bootstrap", domain, path]), `launchctl bootstrap ${domain} ${label}`);
|
|
263
|
+
lines.push(`${label} loaded in ${domain}`);
|
|
264
|
+
}
|
|
265
|
+
return { lines, notes };
|
|
266
|
+
}
|
|
267
|
+
/** Stop and delete the services; missing ones are fine. */
|
|
268
|
+
export function removeServices(host, specs) {
|
|
269
|
+
const fs = host.fs ?? realFs(host.account);
|
|
270
|
+
const a = host.account;
|
|
271
|
+
const lines = [];
|
|
272
|
+
if (a.platform === "linux") {
|
|
273
|
+
const dir = join(a.home, ".config", "systemd", "user");
|
|
274
|
+
for (const spec of specs) {
|
|
275
|
+
const unit = systemdUnitName(spec.name, spec.kind);
|
|
276
|
+
systemctl(host, ["disable", "--now", unit]);
|
|
277
|
+
if (spec.kind === "interval")
|
|
278
|
+
systemctl(host, ["stop", `zswarm-${spec.name}.service`]);
|
|
279
|
+
fs.remove(join(dir, `zswarm-${spec.name}.service`));
|
|
280
|
+
fs.remove(join(dir, `zswarm-${spec.name}.timer`));
|
|
281
|
+
lines.push(`${unit} removed`);
|
|
282
|
+
}
|
|
283
|
+
systemctl(host, ["daemon-reload"]);
|
|
284
|
+
return lines;
|
|
285
|
+
}
|
|
286
|
+
const domain = launchdDomain(host);
|
|
287
|
+
for (const spec of specs) {
|
|
288
|
+
const label = launchdLabel(a, spec.name);
|
|
289
|
+
host.run("launchctl", ["bootout", `${domain}/${label}`]);
|
|
290
|
+
fs.remove(launchdPlistPath(a, label));
|
|
291
|
+
lines.push(`${label} removed`);
|
|
292
|
+
}
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
export function serviceStates(host, specs) {
|
|
296
|
+
const a = host.account;
|
|
297
|
+
if (a.platform === "linux") {
|
|
298
|
+
return specs.map((spec) => {
|
|
299
|
+
const unit = systemdUnitName(spec.name, spec.kind);
|
|
300
|
+
const state = systemctl(host, ["is-active", unit]).stdout.trim() || "unknown";
|
|
301
|
+
return { name: spec.name, unit, state, active: state === "active" };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const domain = launchdDomain(host);
|
|
305
|
+
return specs.map((spec) => {
|
|
306
|
+
const label = launchdLabel(a, spec.name);
|
|
307
|
+
const res = host.run("launchctl", ["print", `${domain}/${label}`]);
|
|
308
|
+
if (res.status !== 0)
|
|
309
|
+
return { name: spec.name, unit: label, state: "not loaded", active: false };
|
|
310
|
+
const state = /^\s*state = (.+)$/m.exec(res.stdout)?.[1]?.trim() ?? "loaded";
|
|
311
|
+
// Oneshot and interval jobs sit "not running" between runs; loaded is up for them.
|
|
312
|
+
const active = spec.kind === "daemon" ? state === "running" : true;
|
|
313
|
+
return { name: spec.name, unit: label, state, active };
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function must(res, what) {
|
|
317
|
+
if (res.status !== 0) {
|
|
318
|
+
throw new ZellijError("failed", `${what} failed: ${(res.stderr || res.stdout).trim().slice(0, 300)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Create dir and any missing parents, owned by the account when installing for it. */
|
|
322
|
+
export function ensureAccountDir(account, dir, mode = 0o700) {
|
|
323
|
+
const missing = [];
|
|
324
|
+
for (let d = dir; d && d !== "/" && !existsSync(d); d = d.slice(0, d.lastIndexOf("/")) || "/")
|
|
325
|
+
missing.unshift(d);
|
|
326
|
+
for (const d of missing) {
|
|
327
|
+
if (installingFor(account))
|
|
328
|
+
runAsAccount(account, ["mkdir", "-m", mode.toString(8), d]);
|
|
329
|
+
else
|
|
330
|
+
mkdirSync(d, { mode });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Write a mode-600 file for the account, creating its directory (700) as needed. */
|
|
334
|
+
export function writeSecretFile(account, path, text) {
|
|
335
|
+
ensureAccountDir(account, path.slice(0, path.lastIndexOf("/")));
|
|
336
|
+
writeAccountFile(account, path, text, 0o600);
|
|
337
|
+
}
|
|
338
|
+
export function readSecretFile(path) {
|
|
339
|
+
try {
|
|
340
|
+
return readFileSync(path, "utf8");
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zswarm slot` runs the bundled slot.py: Node has no flock(2), and the
|
|
3
|
+
* slot's guarantees (locks freed by the kernel when the holder dies,
|
|
4
|
+
* close-on-exec so leftover daemons never hold a slot) need it. The script
|
|
5
|
+
* also runs on its own; `zswarm slot --path` prints where it is.
|
|
6
|
+
*/
|
|
7
|
+
export declare function slotScriptPath(): string;
|
|
8
|
+
/** The Python interpreter slot.py runs under: ZSWARM_PYTHON, else python3 (py -3 on Windows). */
|
|
9
|
+
export declare function findPython(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string[] | null;
|
|
10
|
+
/** Run slot.py with argv, forwarding stdio and signals; resolves to its exit code. */
|
|
11
|
+
export declare function runSlot(argv: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* `zswarm slot` runs the bundled slot.py: Node has no flock(2), and the
|
|
6
|
+
* slot's guarantees (locks freed by the kernel when the holder dies,
|
|
7
|
+
* close-on-exec so leftover daemons never hold a slot) need it. The script
|
|
8
|
+
* also runs on its own; `zswarm slot --path` prints where it is.
|
|
9
|
+
*/
|
|
10
|
+
export function slotScriptPath() {
|
|
11
|
+
return fileURLToPath(new URL("../../assets/slot.py", import.meta.url));
|
|
12
|
+
}
|
|
13
|
+
/** The Python interpreter slot.py runs under: ZSWARM_PYTHON, else python3 (py -3 on Windows). */
|
|
14
|
+
export function findPython(env = process.env, platform = process.platform) {
|
|
15
|
+
const explicit = env.ZSWARM_PYTHON?.trim();
|
|
16
|
+
if (explicit)
|
|
17
|
+
return [explicit];
|
|
18
|
+
const candidates = platform === "win32" ? [["py", "-3"], ["python"], ["python3"]] : [["python3"]];
|
|
19
|
+
for (const cmd of candidates) {
|
|
20
|
+
const probe = spawnSync(cmd[0], [...cmd.slice(1), "-c", "import sys; sys.exit(sys.version_info < (3, 9))"], {
|
|
21
|
+
env,
|
|
22
|
+
stdio: "ignore",
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
});
|
|
25
|
+
if (probe.status === 0)
|
|
26
|
+
return cmd;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Run slot.py with argv, forwarding stdio and signals; resolves to its exit code. */
|
|
31
|
+
export async function runSlot(argv, env = process.env) {
|
|
32
|
+
const script = slotScriptPath();
|
|
33
|
+
if (argv[0] === "--path") {
|
|
34
|
+
process.stdout.write(`${script}\n`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (!existsSync(script)) {
|
|
38
|
+
process.stderr.write(`slot: ${script} is missing from this install\n`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const python = findPython(env);
|
|
42
|
+
if (!python) {
|
|
43
|
+
process.stderr.write("slot: needs Python 3.9+ (python3, or set ZSWARM_PYTHON)\n");
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
const child = spawn(python[0], [...python.slice(1), script, ...argv], { env, stdio: "inherit" });
|
|
47
|
+
const forward = (signal) => () => {
|
|
48
|
+
child.kill(signal);
|
|
49
|
+
};
|
|
50
|
+
const handlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((sig) => {
|
|
51
|
+
const handler = forward(sig);
|
|
52
|
+
process.on(sig, handler);
|
|
53
|
+
return [sig, handler];
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
return await new Promise((resolve) => {
|
|
57
|
+
child.on("error", (err) => {
|
|
58
|
+
process.stderr.write(`slot: ${err.message}\n`);
|
|
59
|
+
resolve(2);
|
|
60
|
+
});
|
|
61
|
+
child.on("exit", (code, signal) => {
|
|
62
|
+
resolve(code ?? (signal ? 128 + (signalNumber(signal) ?? 1) : 1));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
for (const [sig, handler] of handlers)
|
|
68
|
+
process.off(sig, handler);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function signalNumber(signal) {
|
|
72
|
+
const table = { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 };
|
|
73
|
+
return table[signal];
|
|
74
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,3 +29,10 @@ export type { DispatchDeps, OpsResult, ServeInstallDeps } from "./ops/types.js";
|
|
|
29
29
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, type DoctorCheck, type DoctorCheckScope, type DoctorCheckState, type DoctorReport, type DoctorRoute, type HostInspectInput, } from "./ops/doctor.js";
|
|
30
30
|
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
31
31
|
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
|
|
32
|
+
export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
|
|
33
|
+
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
34
|
+
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
|
|
35
|
+
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
|
|
36
|
+
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
|
|
37
|
+
export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayConfig, type RelayDeps, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
|
|
38
|
+
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
|
package/dist/index.js
CHANGED
|
@@ -27,3 +27,10 @@ export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT
|
|
|
27
27
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, } from "./ops/doctor.js";
|
|
28
28
|
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
29
29
|
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
|
|
30
|
+
export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
|
|
31
|
+
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
32
|
+
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
|
|
33
|
+
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
|
|
34
|
+
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
|
|
35
|
+
export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
|
|
36
|
+
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -26,6 +26,7 @@ import { DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, uninstallServeLogo
|
|
|
26
26
|
import { serveCallTimeout, } from "./serve.js";
|
|
27
27
|
import { doctorOp } from "./doctor.js";
|
|
28
28
|
import { forwardServe } from "./serve-tunnel.js";
|
|
29
|
+
import { installServeService, uninstallServeService } from "../host/serve-service.js";
|
|
29
30
|
/**
|
|
30
31
|
* Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
|
|
31
32
|
* and serve (and remote IPC). `--ssh` sets the destination for this call and
|
|
@@ -114,12 +115,19 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
114
115
|
}
|
|
115
116
|
catch { /* An absent title/command/ID gets a fresh direct observation. */ }
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
// observeMs bounds the retries for an absent pane, not the first listing:
|
|
119
|
+
// a loaded Zellij can take seconds to list, and that is not absence. The
|
|
120
|
+
// retry window starts once the first listing is in.
|
|
121
|
+
let until = Number.POSITIVE_INFINITY;
|
|
118
122
|
let pane;
|
|
119
123
|
let fresh = isTrue(args.fresh) || Boolean(op);
|
|
120
124
|
for (;;) {
|
|
121
|
-
const left =
|
|
125
|
+
const left = until === Number.POSITIVE_INFINITY || observeMs === 0
|
|
126
|
+
? budget.require()
|
|
127
|
+
: Math.min(budget.require(), Math.max(1, until - clock.now()));
|
|
122
128
|
panes ??= await client.listPanes(session, left, { fresh });
|
|
129
|
+
if (until === Number.POSITIVE_INFINITY)
|
|
130
|
+
until = Math.min(budget.deadline, clock.now() + observeMs);
|
|
123
131
|
throwIfAborted(signal);
|
|
124
132
|
try {
|
|
125
133
|
pane = client.resolvePane(panes, to);
|
|
@@ -184,6 +192,21 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
184
192
|
min: 1,
|
|
185
193
|
max: 900_000,
|
|
186
194
|
});
|
|
195
|
+
const servePlatform = deps.serveInstall?.platform ?? process.platform;
|
|
196
|
+
if (servePlatform !== "win32" && (isTrue(args.clear) || isTrue(args.install))) {
|
|
197
|
+
// Linux and macOS: a systemd user unit or launchd job (host/serve-service).
|
|
198
|
+
const input = {
|
|
199
|
+
listen: typeof args.listen === "string" ? args.listen : undefined,
|
|
200
|
+
session: optionalString(args.session) ?? undefined,
|
|
201
|
+
runAs: optionalString(args.runAs) ?? undefined,
|
|
202
|
+
timeoutMs,
|
|
203
|
+
env,
|
|
204
|
+
...deps.serveService,
|
|
205
|
+
};
|
|
206
|
+
return isTrue(args.clear)
|
|
207
|
+
? { ok: true, data: uninstallServeService(input) }
|
|
208
|
+
: { ok: true, data: await installServeService(input) };
|
|
209
|
+
}
|
|
187
210
|
if (isTrue(args.clear)) {
|
|
188
211
|
const cleared = await uninstallServeLogon({
|
|
189
212
|
platform: deps.serveInstall?.platform ?? process.platform,
|
package/dist/ops/types.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type OpsResult = ({
|
|
|
15
15
|
};
|
|
16
16
|
import type { GitClient } from "../git.js";
|
|
17
17
|
import type { Policy } from "../policy.js";
|
|
18
|
+
import type { ServeServiceInput } from "../host/serve-service.js";
|
|
18
19
|
import type { StateStore } from "../state.js";
|
|
19
20
|
/** Injectable clock, git, state, and policy so timing/IO ops stay testable. */
|
|
20
21
|
export type DispatchDeps = {
|
|
@@ -46,6 +47,8 @@ export type DispatchDeps = {
|
|
|
46
47
|
networkInterfaces?: () => NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>;
|
|
47
48
|
/** Narrow injectables for Windows `serve --install` / `--clear`. */
|
|
48
49
|
serveInstall?: ServeInstallDeps;
|
|
50
|
+
/** Linux/macOS serve --install/--clear seams (tests). */
|
|
51
|
+
serveService?: Partial<ServeServiceInput>;
|
|
49
52
|
};
|
|
50
53
|
export type ServePowerShellResult = {
|
|
51
54
|
code: number;
|