@zswarm/core 0.2.4 → 0.2.6

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/dist/host/crew.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { posix } from "node:path";
3
+ import { dirname, posix, win32 } from "node:path";
4
4
  import { ZellijError } from "../errors.js";
5
5
  import { dispatchZswarm } from "../ops/dispatch.js";
6
6
  import { resolveServeCliLaunch } from "../ops/serve-install.js";
7
7
  import { defaultStateDir } from "../state.js";
8
8
  import { resolveZellijBinary } from "../zellij/binary.js";
9
+ import { closeCrewClient, crewClientCommand, crewClientMarker, crewTaskCommand, defaultPowerShellExec, ensureCrewClient, inspectCrewTasks, installCrewTasks, removeCrewTasks, } from "./crew-windows.js";
9
10
  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.
11
+ // Linux and macOS accounts carry POSIX paths even when this code runs (in
12
+ // tests) on Windows; a Windows crew account carries native Windows paths.
12
13
  const { join } = posix;
13
14
  /**
14
15
  * `zswarm crew`: keep a Zellij session built from a layout running on a host.
@@ -23,12 +24,37 @@ const { join } = posix;
23
24
  * status services, session and panes
24
25
  * down remove the services and kill the session
25
26
  *
27
+ * On Windows there is no systemd or launchd: `up` registers two Scheduled
28
+ * Tasks under `\zswarm\` (a start task and a two-minute watchdog running
29
+ * `crew check`) and the start path keeps an attached client in a minimized
30
+ * console window, because a background-only Windows session renders empty
31
+ * panes and drops pasted input.
32
+ *
26
33
  * Zellij now and then starts `attach --create-background --layout` as a
27
34
  * plain one-pane session; the first-pane check is what catches that.
28
35
  */
29
36
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
30
37
  const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
31
38
  export const CREW_WATCH_SEC = 120;
39
+ /** The Windows client window: real PowerShell, or the test seam. */
40
+ function windowsClient(deps, env) {
41
+ if (deps.client)
42
+ return deps.client;
43
+ const runPowerShell = deps.runPowerShell ?? defaultPowerShellExec;
44
+ const launch = deps.launch ?? resolveServeCliLaunch({ env });
45
+ return {
46
+ ensure: (session) => ensureCrewClient({
47
+ session,
48
+ clientCommand: crewClientCommand({ session, launch }),
49
+ runPowerShell,
50
+ }),
51
+ close: async (session) => (await closeCrewClient({ session, runPowerShell })).closed,
52
+ };
53
+ }
54
+ /** A task row shaped like the systemd/launchd `services` entries. */
55
+ function taskStateView(task) {
56
+ return { name: task.name, unit: task.path, state: task.state, active: task.active };
57
+ }
32
58
  /** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
33
59
  export const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
34
60
  export function checkCrewSession(session) {
@@ -38,8 +64,13 @@ export function checkCrewSession(session) {
38
64
  return s;
39
65
  }
40
66
  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);
67
+ // A Windows account's home and state dir are native paths; joining them with
68
+ // posix.join would leave a mixed `C:\Users\x/.zswarm` for Zellij to open.
69
+ const path = account.platform === "win32" ? win32 : posix;
70
+ const base = !account.foreign && env.ZSWARM_STATE_DIR?.trim()
71
+ ? defaultStateDir(env)
72
+ : path.join(account.home, ".zswarm");
73
+ return path.join(base, "crews", session);
43
74
  }
44
75
  /** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
45
76
  export function firstLayoutPane(kdl) {
@@ -72,7 +103,7 @@ export function parseEnvFile(text) {
72
103
  return out;
73
104
  }
74
105
  function readCrewFiles(dir) {
75
- const meta = join(dir, "crew.json");
106
+ const meta = crewFile(dir, "crew.json");
76
107
  if (!existsSync(meta))
77
108
  throw new ZellijError("usage", `no crew in ${dir}; run zswarm crew up first`);
78
109
  return JSON.parse(readFileSync(meta, "utf8"));
@@ -102,15 +133,25 @@ function defaultZellij(args, env) {
102
133
  const res = spawnSync(resolveZellijBinary(env), args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
103
134
  return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
104
135
  }
136
+ /**
137
+ * Zellij, sharing this process's console. `attach --create` needs a real
138
+ * terminal — with a pipe on stdin it exits 0 having created nothing, which is
139
+ * exactly the "session that never renders" a Windows crew must avoid.
140
+ */
141
+ function defaultZellijInteractive(args, env) {
142
+ const res = spawnSync(resolveZellijBinary(env), args, { env, stdio: "inherit" });
143
+ return { status: res.status ?? 1, stdout: "", stderr: res.error?.message ?? "" };
144
+ }
105
145
  function crewEnv(dir, files, env) {
106
- const extra = files.env ? parseEnvFile(readFileSync(join(dir, files.env), "utf8")) : {};
146
+ const extra = files.env ? parseEnvFile(readFileSync(crewFile(dir, files.env), "utf8")) : {};
107
147
  // No bus inside the start/check helpers: they list panes once and exit.
108
148
  return { ...env, TERM: env.TERM || "xterm-256color", ...extra, ZSWARM_BUS: "0" };
109
149
  }
110
150
  /** Start the session from its layout and prove the first pane exists; up to three tries. */
111
151
  export async function crewStart(session, deps = {}) {
112
152
  const env0 = deps.env ?? process.env;
113
- const dir = deps.dir ?? crewDir(serviceAccount(undefined), checkCrewSession(session), env0);
153
+ const account = deps.account ?? serviceAccount(undefined);
154
+ const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
114
155
  const files = readCrewFiles(dir);
115
156
  const env = crewEnv(dir, files, env0);
116
157
  const zellij = deps.zellij ?? defaultZellij;
@@ -118,10 +159,37 @@ export async function crewStart(session, deps = {}) {
118
159
  const panes = deps.panes ?? defaultPanes;
119
160
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
120
161
  const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
121
- const config = files.config ? ["--config", join(dir, files.config)] : [];
162
+ const config = files.config ? ["--config", crewFile(dir, files.config)] : [];
163
+ if (account.platform === "win32") {
164
+ // Windows: a session created with `attach --create-background` renders
165
+ // empty panes and drops pasted input, so the session is created BY an
166
+ // attached client instead — a minimized console window running
167
+ // `zellij --layout ... attach --create`, which `crew client` starts. That
168
+ // window is also what the watchdog reopens when it dies.
169
+ const client = windowsClient(deps, env);
170
+ for (let attempt = 1; attempt <= 3; attempt++) {
171
+ zellij(["delete-session", session], env); // clears only a dead session of this name
172
+ await client.close(session); // a window left over from the previous attempt
173
+ const opened = await client.ensure(session);
174
+ log(`client window ${opened.opened ? "opened" : "already open"} for ${session}`);
175
+ for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
176
+ await sleep(1_000);
177
+ await sleep(3_000);
178
+ if ((await panes(session, env)).some((p) => p.title === files.first)) {
179
+ log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
180
+ return 0;
181
+ }
182
+ log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
183
+ zellij(["kill-session", session], env);
184
+ await client.close(session);
185
+ await sleep(1_000);
186
+ }
187
+ log(`session ${session} never took its layout`);
188
+ return 1;
189
+ }
122
190
  for (let attempt = 1; attempt <= 3; attempt++) {
123
191
  zellij(["delete-session", session], env); // clears only a dead session of this name
124
- zellij([...config, "--layout", join(dir, files.layout), "attach", "--create-background", session], env);
192
+ zellij([...config, "--layout", crewFile(dir, files.layout), "attach", "--create-background", session], env);
125
193
  for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
126
194
  await sleep(500);
127
195
  await sleep(2_000);
@@ -136,16 +204,63 @@ export async function crewStart(session, deps = {}) {
136
204
  log(`session ${session} never took its layout`);
137
205
  return 1;
138
206
  }
207
+ /**
208
+ * Zellij's own client variables. A client started from inside a pane inherits
209
+ * `ZELLIJ=0`, `ZELLIJ_SESSION_NAME`, ...; a nested client then exits without
210
+ * creating anything. The console window is opened from whatever process asked
211
+ * for the crew (an agent pane, a scheduled task), so strip them all.
212
+ */
213
+ const ZELLIJ_CLIENT_ENV = /^ZELLIJ(_|$)/;
214
+ function withoutZellijClientEnv(env) {
215
+ const out = {};
216
+ for (const [key, value] of Object.entries(env)) {
217
+ if (ZELLIJ_CLIENT_ENV.test(key))
218
+ continue;
219
+ out[key] = value;
220
+ }
221
+ return out;
222
+ }
223
+ /**
224
+ * `crew client`: what the minimized console window runs. It reads the crew dir
225
+ * (so an env file's variables reach Zellij without being written into a task or
226
+ * a command line) and attaches with `--create`, which is what makes a Windows
227
+ * session render — a background-only session leaves pane grids empty.
228
+ */
229
+ export function crewClient(session, deps = {}) {
230
+ const env0 = deps.env ?? process.env;
231
+ const account = deps.account ?? serviceAccount(undefined);
232
+ const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
233
+ const files = readCrewFiles(dir);
234
+ const env = withoutZellijClientEnv(crewEnv(dir, files, env0));
235
+ const args = [
236
+ ...(files.config ? ["--config", crewFile(dir, files.config)] : []),
237
+ "--layout", crewFile(dir, files.layout),
238
+ "attach", "--create", session,
239
+ ];
240
+ // OSC 0 titles the console window; the marker is how status and down find it.
241
+ // Only on a real console — a piped run (tests, logs) has no window to title.
242
+ if (process.stdout.isTTY)
243
+ process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
244
+ return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
245
+ }
139
246
  /** Watchdog: restart the session if its server died or its first pane is gone. */
140
247
  export async function crewCheck(session, deps = {}) {
141
248
  const env0 = deps.env ?? process.env;
142
- const dir = deps.dir ?? crewDir(serviceAccount(undefined), checkCrewSession(session), env0);
249
+ const account = deps.account ?? serviceAccount(undefined);
250
+ const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
143
251
  const files = readCrewFiles(dir);
144
252
  const env = crewEnv(dir, files, env0);
145
253
  const sessions = deps.sessions ?? defaultSessions;
146
254
  const panes = deps.panes ?? defaultPanes;
147
255
  const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
148
256
  if ((await sessions(env)).includes(session) && (await panes(session, env)).some((p) => p.title === files.first)) {
257
+ if (account.platform === "win32") {
258
+ // The session surviving is not enough on Windows: without the attached
259
+ // client window its panes render empty, so the watchdog reopens it.
260
+ const opened = await windowsClient(deps, env).ensure(session);
261
+ if (opened.opened)
262
+ log(`client window reopened for ${session}`);
263
+ }
149
264
  return 0;
150
265
  }
151
266
  log(`session ${session} is gone or lost its layout; restarting`);
@@ -159,8 +274,26 @@ export async function crewCheck(session, deps = {}) {
159
274
  }
160
275
  return await crewStart(session, deps);
161
276
  }
277
+ /** A file inside a crew dir, which is a Windows path for a win32 account. */
278
+ function crewFile(dir, name) {
279
+ return dir.includes("\\") ? win32.join(dir, name) : join(dir, name);
280
+ }
281
+ /**
282
+ * The env a Windows crew task action reproduces. Not the whole caller env (it
283
+ * can hold tokens) and not PATH (the interactive task inherits the user's, and
284
+ * a host PATH would not fit in a Windows command line).
285
+ */
286
+ function crewTaskEnv(env) {
287
+ const out = {};
288
+ for (const key of ["ZSWARM_BIN", "ZSWARM_STATE_DIR"]) {
289
+ const value = env[key]?.trim();
290
+ if (value)
291
+ out[key] = value;
292
+ }
293
+ return out;
294
+ }
162
295
  function copyForAccount(account, from, to, secret) {
163
- ensureAccountDir(account, to.slice(0, to.lastIndexOf("/")));
296
+ ensureAccountDir(account, dirname(to));
164
297
  if (secret) {
165
298
  writeSecretFile(account, to, readFileSync(from, "utf8"));
166
299
  return;
@@ -203,7 +336,7 @@ export function crewServiceSpecs(input) {
203
336
  },
204
337
  ];
205
338
  }
206
- export function crewUp(input) {
339
+ export async function crewUp(input) {
207
340
  const env = input.env ?? process.env;
208
341
  const run = input.run ?? defaultRunner;
209
342
  const account = input.account ?? serviceAccount(input.runAs, run);
@@ -219,17 +352,42 @@ export function crewUp(input) {
219
352
  const dir = crewDir(account, session, env);
220
353
  const copy = input.copy ?? copyForAccount;
221
354
  const files = { layout: "layout.kdl", first };
222
- copy(account, input.layout, join(dir, "layout.kdl"), false);
355
+ copy(account, input.layout, crewFile(dir, "layout.kdl"), false);
223
356
  if (input.config) {
224
- copy(account, input.config, join(dir, "config.kdl"), false);
357
+ copy(account, input.config, crewFile(dir, "config.kdl"), false);
225
358
  files.config = "config.kdl";
226
359
  }
227
360
  if (input.envFile) {
228
- copy(account, input.envFile, join(dir, "env"), true);
361
+ copy(account, input.envFile, crewFile(dir, "env"), true);
229
362
  files.env = "env";
230
363
  }
231
364
  // crew.json is written last, so a half-copied crew never starts.
232
- (input.writeMeta ?? writeSecretFile)(account, join(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
365
+ (input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
366
+ if (account.platform === "win32") {
367
+ // Windows has no systemd/launchd: two Scheduled Tasks (a start task and a
368
+ // two-minute watchdog) plus the minimized client window the start task
369
+ // opens, because a background-only session renders empty panes.
370
+ const taskEnv = crewTaskEnv(env);
371
+ const installed = await installCrewTasks({
372
+ session,
373
+ startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
374
+ watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
375
+ runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
376
+ });
377
+ const states = installed.tasks.map(taskStateView);
378
+ return {
379
+ session,
380
+ user: account.user,
381
+ dir,
382
+ firstPane: first,
383
+ services: states,
384
+ ready: states.every((s) => s.active),
385
+ tasks: installed.tasks,
386
+ clientWindow: installed.client,
387
+ actions: installed.lines,
388
+ notes: installed.notes,
389
+ };
390
+ }
233
391
  const specs = crewServiceSpecs({ account, session, launch, env });
234
392
  const done = installServices({ account, run, fs: input.fs }, specs);
235
393
  const states = serviceStates({ account, run }, specs);
@@ -249,14 +407,28 @@ export async function crewStatus(input) {
249
407
  const run = input.run ?? defaultRunner;
250
408
  const account = input.account ?? serviceAccount(input.runAs, run);
251
409
  const session = checkCrewSession(input.session);
252
- const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
253
- const services = serviceStates({ account, run }, specs);
254
- const out = { session, user: account.user, services };
410
+ const out = { session, user: account.user };
255
411
  if (account.foreign) {
412
+ const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
413
+ out.services = serviceStates({ account, run }, specs);
256
414
  out.note = `session and panes belong to ${account.user}: run zswarm crew status ${session} as that user`;
257
415
  return out;
258
416
  }
259
- const files = existsSync(join(crewDir(account, session, env), "crew.json"))
417
+ let client = null;
418
+ if (account.platform === "win32") {
419
+ const inspected = await inspectCrewTasks({
420
+ session,
421
+ runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
422
+ });
423
+ out.services = inspected.tasks.map(taskStateView);
424
+ client = inspected.client;
425
+ out.clientWindow = inspected.client;
426
+ }
427
+ else {
428
+ const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
429
+ out.services = serviceStates({ account, run }, specs);
430
+ }
431
+ const files = existsSync(crewFile(crewDir(account, session, env), "crew.json"))
260
432
  ? readCrewFiles(crewDir(account, session, env))
261
433
  : undefined;
262
434
  const live = (await (input.deps?.sessions ?? defaultSessions)(env)).includes(session);
@@ -267,17 +439,34 @@ export async function crewStatus(input) {
267
439
  out.firstPane = files?.first ?? null;
268
440
  out.layoutTook = files ? panes.some((p) => p.title === files.first) : null;
269
441
  out.panes = panes;
270
- out.healthy = services.every((s) => s.active) && live && out.layoutTook !== false && panes.every((p) => !p.exited);
442
+ out.healthy =
443
+ out.services.every((s) => s.active) &&
444
+ live &&
445
+ out.layoutTook !== false &&
446
+ panes.every((p) => !p.exited) &&
447
+ (client ? client.present : true);
271
448
  if (live && exited) {
272
449
  out.note = `an exited session named ${session} is also listed (resurrectable); remove it with zswarm sessions --prune-exited`;
273
450
  }
274
451
  return out;
275
452
  }
276
- export function crewDown(input) {
453
+ export async function crewDown(input) {
277
454
  const env = input.env ?? process.env;
278
455
  const run = input.run ?? defaultRunner;
279
456
  const account = input.account ?? serviceAccount(input.runAs, run);
280
457
  const session = checkCrewSession(input.session);
458
+ if (account.platform === "win32") {
459
+ const runPowerShell = input.runPowerShell ?? defaultPowerShellExec;
460
+ const removed = await removeCrewTasks({ session, runPowerShell });
461
+ const lines = [...removed.lines];
462
+ const closed = await closeCrewClient({ session, runPowerShell });
463
+ lines.push(closed.closed.length > 0
464
+ ? `client window closed (pids ${closed.closed.join(", ")})`
465
+ : "no client window was open");
466
+ const kill = run(resolveZellijBinary(env), ["kill-session", session]);
467
+ lines.push(kill.status === 0 ? `session ${session} killed` : `session ${session} was not running`);
468
+ return { session, user: account.user, actions: lines };
469
+ }
281
470
  const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
282
471
  const lines = removeServices({ account, run, fs: input.fs }, specs);
283
472
  // The Zellij server belongs to the account: kill it as that user.
@@ -2,9 +2,12 @@
2
2
  * Background services for host commands (serve, crew): systemd user units
3
3
  * on Linux (with linger, so they run without a login), launchd on macOS (a
4
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.
5
+ * LaunchDaemon that runs as that user, for accounts that never log in). On
6
+ * Windows there is no systemd or launchd here: `serviceAccount` returns the
7
+ * signed-in user (`platform: "win32"`, uid/gid 0) so crew can install a
8
+ * scheduled task instead, and `--run-as` is refused. Secrets never go into
9
+ * unit files or plists: services read them from mode-600 files named in
10
+ * their environment.
8
11
  */
9
12
  export type ServiceKind =
10
13
  /** Long-running; restarted when it exits. */
@@ -31,7 +34,7 @@ export type ServiceSpec = {
31
34
  workingDir?: string;
32
35
  };
33
36
  export type ServiceAccount = {
34
- platform: "linux" | "darwin";
37
+ platform: "linux" | "darwin" | "win32";
35
38
  user: string;
36
39
  uid: number;
37
40
  gid: number;
@@ -1,10 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir, userInfo } from "node:os";
4
- import { posix } from "node:path";
4
+ import { dirname, posix } from "node:path";
5
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.
6
+ // Linux and macOS accounts carry POSIX paths even when this code runs (in
7
+ // tests) on Windows; a Windows crew account carries native Windows paths.
8
8
  const { join } = posix;
9
9
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
10
10
  export const defaultRunner = (cmd, args) => {
@@ -22,6 +22,12 @@ function hostSleep(host, ms) {
22
22
  /** The account services are installed for: the current user, or `runAs` when root. */
23
23
  export function serviceAccount(runAs, run = defaultRunner) {
24
24
  const platform = process.platform;
25
+ if (platform === "win32") {
26
+ if (runAs?.trim()) {
27
+ throw new ZellijError("usage", "--run-as is Linux and macOS only; a Windows crew task runs as the signed-in user");
28
+ }
29
+ return { platform: "win32", user: userInfo().username, uid: 0, gid: 0, home: process.env.USERPROFILE || homedir(), foreign: false };
30
+ }
25
31
  if (platform !== "linux" && platform !== "darwin") {
26
32
  throw new ZellijError("usage", `host services are for Linux and macOS (this is ${platform}); on Windows use serve --install`);
27
33
  }
@@ -72,6 +78,10 @@ function runAsAccount(account, argv, input) {
72
78
  }
73
79
  /** Write a file in the account's home as the account (see accountArgv). */
74
80
  export function writeAccountFile(account, path, text, mode) {
81
+ if (account.platform === "win32") {
82
+ writeFileSync(path, text);
83
+ return;
84
+ }
75
85
  if (!installingFor(account)) {
76
86
  writeFileSync(path, text, { mode });
77
87
  chmodSync(path, mode);
@@ -416,6 +426,10 @@ function must(res, what) {
416
426
  }
417
427
  /** Create dir and any missing parents, owned by the account when installing for it. */
418
428
  export function ensureAccountDir(account, dir, mode = 0o700) {
429
+ if (account.platform === "win32") {
430
+ mkdirSync(dir, { recursive: true });
431
+ return;
432
+ }
419
433
  const missing = [];
420
434
  for (let d = dir; d && d !== "/" && !existsSync(d); d = d.slice(0, d.lastIndexOf("/")) || "/")
421
435
  missing.unshift(d);
@@ -428,7 +442,7 @@ export function ensureAccountDir(account, dir, mode = 0o700) {
428
442
  }
429
443
  /** Write a mode-600 file for the account, creating its directory (700) as needed. */
430
444
  export function writeSecretFile(account, path, text) {
431
- ensureAccountDir(account, path.slice(0, path.lastIndexOf("/")));
445
+ ensureAccountDir(account, dirname(path));
432
446
  writeAccountFile(account, path, text, 0o600);
433
447
  }
434
448
  export function readSecretFile(path) {
package/dist/index.d.ts CHANGED
@@ -29,11 +29,11 @@ export type { RoutingContext } from "./ops/routing.js";
29
29
  export type { DispatchDeps, OpsResult, ServeInstallDeps } from "./ops/types.js";
30
30
  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";
31
31
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
32
- export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
32
+ export { cliUsage, commandUsage, wantsHelp, extractOp, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
33
33
  export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
34
34
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
35
35
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
36
36
  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";
37
- 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 { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
38
38
  export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
39
39
  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,11 +27,11 @@ export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STA
27
27
  export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
28
28
  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";
29
29
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
30
- export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
30
+ export { cliUsage, commandUsage, wantsHelp, extractOp, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
31
31
  export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
32
32
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
33
33
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
34
34
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
35
- export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
35
+ export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
36
36
  export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
37
37
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
package/dist/schema.d.ts CHANGED
@@ -12,6 +12,8 @@ export type ParamSpec = {
12
12
  type: ParamType;
13
13
  /** CLI flags; empty means the parameter is reachable through MCP only. */
14
14
  flags: string[];
15
+ /** Ops this flag belongs to; omitted means it applies to every op (routing/global flags). */
16
+ ops?: readonly OpName[];
15
17
  /** Repeatable flags collect into an array (`--key a --key b`). */
16
18
  repeat?: boolean;
17
19
  /** Local CLI preprocessing, excluded from the MCP protocol. */
@@ -24,4 +26,29 @@ export declare const PARAMS: readonly ParamSpec[];
24
26
  export declare function mcpInputSchema(): Record<string, unknown>;
25
27
  export declare const MCP_TOOL_DESCRIPTION: string;
26
28
  export declare function cliUsage(): string;
29
+ /**
30
+ * True when argv asks for help: `--help` or `-h` as a flag of its own, not the
31
+ * value of a flag that takes one (`--body -h` sends "-h") and not after `--`.
32
+ */
33
+ export declare function wantsHelp(argv: readonly string[]): boolean;
34
+ /**
35
+ * Per-command help for `zswarm <op> --help`: the usage line, the positional
36
+ * shorthand, and only the flags whose `ops` include this op. A flag without
37
+ * `ops` (session, --local, --ssh, --fresh, --serve) applies to every op.
38
+ * Render from the same PARAMS table that parses the flags, so they cannot drift.
39
+ */
40
+ export declare function commandUsage(op: OpName): string;
41
+ /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
42
+ /**
43
+ * Pull the op out of argv, tolerating flags before it.
44
+ *
45
+ * The op normally comes first. When it does not — `zswarm --session crew list`
46
+ * — the first token is a flag, and taking argv[0] blindly reported the flag as
47
+ * an unknown op. Only positions that already errored are affected: if argv[0]
48
+ * is a real op it wins, so `send reviewer list` still sends the word "list".
49
+ */
50
+ export declare function extractOp(argv: string[]): {
51
+ op: string;
52
+ rest: string[];
53
+ };
27
54
  export declare function parseCliArgv(argv: string[]): Record<string, unknown>;