@join-pantheon/cli 0.28.0 → 0.29.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.
@@ -83,10 +83,11 @@ export class Allowlist {
83
83
  get hasToken() {
84
84
  return this.tokenHash !== null;
85
85
  }
86
- mintInvite(by, mayWake, ttlMinutes, token) {
86
+ mintInvite(by, mayWake, ttlMinutes, token, spaceToken) {
87
87
  const inv = {
88
88
  token, created_by: by, created_at: Date.now(),
89
89
  expires_at: Date.now() + ttlMinutes * 60_000, may_wake: mayWake,
90
+ ...(spaceToken ? { space_token: spaceToken } : {}),
90
91
  };
91
92
  this.invites.set(token, inv);
92
93
  this.flush();
@@ -101,6 +102,9 @@ export class Allowlist {
101
102
  if (Date.now() > inv.expires_at)
102
103
  return { ok: false, why: "that invite has expired — ask for a fresh one" };
103
104
  const existing = this.entries.get(member);
105
+ if (existing?.revoked_at && inv.created_at <= existing.revoked_at) {
106
+ return { ok: false, why: `"${member}" was removed from this team${existing.revoked_by ? ` by ${existing.revoked_by}` : ""}, so an invite from before that cannot bring them back — ask the founder for a new one` };
107
+ }
104
108
  if (existing && existing.publicKey !== publicKey) {
105
109
  return {
106
110
  ok: false,
@@ -191,7 +195,7 @@ export class Allowlist {
191
195
  }
192
196
  if (cur.publicKey !== publicKey)
193
197
  return "key-mismatch";
194
- return cur.admitted ? "ok" : "pending";
198
+ return cur.admitted ? "ok" : cur.revoked_at ? "revoked" : "pending";
195
199
  }
196
200
  admit(member, by, mayWake) {
197
201
  const e = this.entries.get(member);
@@ -200,6 +204,8 @@ export class Allowlist {
200
204
  e.admitted = true;
201
205
  e.admitted_by = by;
202
206
  e.may_wake = mayWake;
207
+ delete e.revoked_at;
208
+ delete e.revoked_by;
203
209
  this.flush();
204
210
  return e;
205
211
  }
@@ -207,18 +213,28 @@ export class Allowlist {
207
213
  const e = this.entries.get(member);
208
214
  return e && e.admitted ? e.publicKey : null;
209
215
  }
216
+ cancelInvitesBy(member) {
217
+ for (const [t, i] of this.invites)
218
+ if (i.created_by === member && !i.used_by)
219
+ this.invites.delete(t);
220
+ }
210
221
  forget(member) {
211
222
  const gone = this.entries.delete(member);
223
+ this.cancelInvitesBy(member);
212
224
  if (gone)
213
225
  this.flush();
214
226
  return gone;
215
227
  }
216
- revoke(member) {
228
+ revoke(member, by) {
217
229
  const e = this.entries.get(member);
218
230
  if (!e)
219
231
  return false;
220
232
  e.admitted = false;
221
233
  e.may_wake = false;
234
+ e.revoked_at = Date.now();
235
+ if (by)
236
+ e.revoked_by = by;
237
+ this.cancelInvitesBy(member);
222
238
  this.flush();
223
239
  return true;
224
240
  }
@@ -74,6 +74,8 @@ export function diagnoseRun(f, probe = gitProbe) {
74
74
  fix: `It can still answer and escalate. To let it deliver work, point the worker at a checkout: pantheon service install worker from inside the repo.`,
75
75
  };
76
76
  }
77
+ if (!f.allowBash && !(f.needsCommands || BLOCKED_RE.test(f.transcript ?? f.detail) || BLOCKED_RE.test(f.detail)))
78
+ return silent(f);
77
79
  if (!f.allowBash) {
78
80
  return {
79
81
  cause: "no_commands",
@@ -102,12 +104,21 @@ export function diagnoseRun(f, probe = gitProbe) {
102
104
  lines: [`git status --short # ${list(dirty)}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${on}` : ""}`],
103
105
  };
104
106
  }
105
- return {
106
- cause: "silent",
107
- says: "The run finished cleanly but sent nothing back, so the request is still open.",
108
- fix: `Read what it did at ${f.runLog}. If it was right to stay silent, answer the teammate yourself; if not, the transcript says where it stopped.`,
109
- dir: f.worktree,
110
- lines: [`cat ${f.runLog}`],
111
- };
107
+ return silent(f);
108
+ }
109
+ const silent = (f) => ({
110
+ cause: "silent",
111
+ says: "The run finished cleanly but sent nothing back, so the request is still open.",
112
+ fix: `Read what it did at ${f.runLog}. If it was right to stay silent, answer the teammate yourself; if not, the transcript says where it stopped.`,
113
+ dir: f.worktree,
114
+ lines: [`cat ${f.runLog}`],
115
+ });
116
+ export const BLOCKED_RE = /(?:permission|approval)[^\n]{0,40}\b(?:denied|required|needed|to use (?:the )?(?:bash|shell))|\bnot (?:allowed|permitted) to (?:run|execute|use (?:the )?(?:bash|shell))|\b(?:bash|shell|command|tool)\b[^\n]{0,30}\b(?:was |is )?(?:blocked|denied|not allowed|rejected)\b|read-only sandbox|sandbox[^\n]{0,20}read-only/i;
117
+ export function workNeedsCommands(kind, trigger, triggerAct) {
118
+ if (triggerAct === "DELIVERED")
119
+ return true;
120
+ if (kind === "REVISE" && /\bDELIVERED\b/.test(trigger))
121
+ return true;
122
+ return /\bDELIVER(?:ED)?\b|\b(?:commit|push)\b(?! ?(?:message|hook))|\bpull request\b|\bopen a PR\b|\bimplement\b/i.test(trigger);
112
123
  }
113
124
  //# sourceMappingURL=run-failure.js.map
package/dist/service.js CHANGED
@@ -1,12 +1,47 @@
1
- import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
1
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { homedir, platform } from "node:os";
3
+ import { homedir, platform, userInfo } from "node:os";
4
4
  import { which } from "./which.js";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { launcher } from "./install.js";
8
+ import { CONFIG_DIR } from "./config.js";
8
9
  const HERE = dirname(fileURLToPath(import.meta.url));
9
10
  const LABEL = (kind) => `dev.pantheon.${kind}`;
11
+ export function borrowedHome() {
12
+ if (process.env.PANTHEON_SERVICES_ANY_HOME === "1")
13
+ return false;
14
+ try {
15
+ return resolve(homedir()) !== resolve(userInfo().homedir);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ const READ_ONLY = (c, a) => c === "launchctl" ? ["print", "print-disabled", "list"].includes(a[0] ?? "")
22
+ : c === "systemctl" ? a.some((x) => ["is-enabled", "is-active", "status", "show", "show-environment"].includes(x))
23
+ : true;
24
+ function guardedExecFile(c, a) {
25
+ if ((c === "launchctl" || c === "systemctl") && !READ_ONLY(c, a) && borrowedHome()) {
26
+ throw new Error(`refused: ${c} ${a[0] ?? ""} would change this user's real services, but HOME (${homedir()}) is not the user's home — a test or a sandbox. Set PANTHEON_SERVICES_ANY_HOME=1 to allow it.`);
27
+ }
28
+ return execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) ?? "";
29
+ }
30
+ export const realSys = () => ({
31
+ platform: platform(),
32
+ exec: guardedExecFile,
33
+ uid: process.getuid?.() ?? 501,
34
+ user: (() => { try {
35
+ return userInfo().username;
36
+ }
37
+ catch {
38
+ return process.env.USER ?? "";
39
+ } })(),
40
+ });
41
+ export function serviceLogDir() {
42
+ return join(CONFIG_DIR, "logs");
43
+ }
44
+ export const serviceLogFile = (kind) => join(serviceLogDir(), `${kind}.log`);
10
45
  const SERVICE_BASE_PATH = [
11
46
  "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
12
47
  ];
@@ -22,7 +57,7 @@ export function servicesUnavailable(plat = platform()) {
22
57
  export function plistPath(kind = "worker") {
23
58
  return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
24
59
  }
25
- function unitPath(kind) {
60
+ export function unitPath(kind) {
26
61
  return join(homedir(), ".config", "systemd", "user", `pantheon-${kind}.service`);
27
62
  }
28
63
  function xml(s) {
@@ -71,7 +106,11 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
71
106
  </plist>
72
107
  `;
73
108
  }
74
- export function systemdUnit(kind, cwd, args) {
109
+ export function systemdQuote(arg) {
110
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
111
+ }
112
+ const systemdValue = (v) => v.replace(/%/g, "%%");
113
+ export function systemdUnit(kind, cwd, args, path = servicePath()) {
75
114
  const description = kind === "relay"
76
115
  ? "pantheon relay — the coordination plane for a space"
77
116
  : kind === "worker"
@@ -83,8 +122,9 @@ After=network-online.target
83
122
 
84
123
  [Service]
85
124
  Type=simple
86
- WorkingDirectory=${cwd}
87
- ExecStart=${argvFor(kind, args).join(" ")}
125
+ WorkingDirectory=${systemdValue(cwd)}
126
+ ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
127
+ Environment=${systemdQuote(`PATH=${path}`)}
88
128
  Restart=always
89
129
  RestartSec=30
90
130
 
@@ -92,29 +132,43 @@ RestartSec=30
92
132
  WantedBy=default.target
93
133
  `;
94
134
  }
95
- export function installService(kind, cwd, args) {
96
- const noServices = servicesUnavailable();
135
+ export function lingerEnabled(sys = realSys()) {
136
+ try {
137
+ const out = sys.exec("loginctl", ["show-user", sys.user || String(sys.uid), "--property=Linger"]);
138
+ const m = /Linger=(yes|no)/.exec(out);
139
+ return m ? m[1] === "yes" : null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ export function installService(kind, cwd, args, sys = realSys()) {
146
+ const noServices = servicesUnavailable(sys.platform);
97
147
  if (noServices)
98
148
  throw new Error(noServices.replace(/^not available — /, ""));
99
149
  const out = [];
100
150
  const abs = resolve(cwd);
101
- const logDir = join(abs, ".pantheon");
102
- mkdirSync(logDir, { recursive: true });
103
- if (platform() === "darwin") {
151
+ const logDir = serviceLogDir();
152
+ if (sys.platform === "darwin") {
153
+ mkdirSync(logDir, { recursive: true });
104
154
  const p = plistPath(kind);
105
155
  mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
106
156
  writeFileSync(p, servicePlist(kind, abs, logDir, args));
107
157
  out.push(` wrote ${p}`);
108
158
  try {
109
- execFileSync("launchctl", ["unload", p], { stdio: "ignore" });
159
+ sys.exec("launchctl", ["enable", `gui/${sys.uid}/${LABEL(kind)}`]);
160
+ }
161
+ catch { }
162
+ try {
163
+ sys.exec("launchctl", ["unload", p]);
110
164
  }
111
165
  catch { }
112
166
  try {
113
- execFileSync("launchctl", ["load", "-w", p], { stdio: "ignore" });
167
+ sys.exec("launchctl", ["load", "-w", p]);
114
168
  out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
115
169
  }
116
170
  catch (e) {
117
- out.push(` could not load it: ${e.message}`);
171
+ out.push(` could not load it: ${e.message.split("\n")[0]}`);
118
172
  out.push(` load it yourself with: launchctl load -w ${p}`);
119
173
  }
120
174
  out.push(` logs: ${join(logDir, `${kind}.log`)}`);
@@ -122,15 +176,15 @@ export function installService(kind, cwd, args) {
122
176
  }
123
177
  else {
124
178
  if (kind === "menubar" || kind === "notifier") {
125
- throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${platform()}`);
179
+ throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
126
180
  }
127
181
  const unit = unitPath(kind);
128
182
  mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
129
183
  writeFileSync(unit, systemdUnit(kind, abs, args));
130
184
  out.push(` wrote ${unit}`);
131
185
  try {
132
- execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
133
- execFileSync("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
186
+ sys.exec("systemctl", ["--user", "daemon-reload"]);
187
+ sys.exec("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`]);
134
188
  out.push(` enabled — the ${kind} now starts at login and restarts if it dies`);
135
189
  out.push(` logs: journalctl --user -u pantheon-${kind} -f`);
136
190
  out.push(` stop: pantheon service uninstall ${kind}`);
@@ -138,12 +192,15 @@ export function installService(kind, cwd, args) {
138
192
  catch (e) {
139
193
  throw new Error(`the ${kind} unit is written at ${unit} but could not be enabled — systemd --user is not running here (${e.message.split("\n")[0]}). When it is, run: systemctl --user enable --now pantheon-${kind} (WSL: set [boot] systemd=true in /etc/wsl.conf and restart WSL)`);
140
194
  }
195
+ if (lingerEnabled(sys) === false) {
196
+ out.push(` ! the ${kind} stops when you log out (closing an SSH session counts). To keep it running: loginctl enable-linger ${sys.user || "$USER"}`);
197
+ }
141
198
  }
142
199
  return out;
143
200
  }
144
- export function uninstallService(kind) {
201
+ export function uninstallService(kind, sys = realSys()) {
145
202
  const out = [];
146
- if (platform() === "win32") {
203
+ if (sys.platform === "win32") {
147
204
  const stray = unitPath(kind);
148
205
  if (existsSync(stray)) {
149
206
  unlinkSync(stray);
@@ -151,17 +208,22 @@ export function uninstallService(kind) {
151
208
  }
152
209
  return [` no ${kind} service installed`];
153
210
  }
154
- if (platform() === "darwin") {
211
+ if (sys.platform === "darwin") {
155
212
  const p = plistPath(kind);
156
213
  if (!existsSync(p))
157
214
  return [` no ${kind} service installed`];
215
+ let stopped = false;
158
216
  try {
159
- execFileSync("launchctl", ["unload", "-w", p], { stdio: "ignore" });
160
- out.push(" unloaded");
217
+ sys.exec("launchctl", ["bootout", `gui/${sys.uid}/${LABEL(kind)}`]);
218
+ stopped = true;
161
219
  }
162
- catch {
163
- out.push(" was not loaded");
220
+ catch { }
221
+ try {
222
+ sys.exec("launchctl", ["unload", "-w", p]);
223
+ stopped = true;
164
224
  }
225
+ catch { }
226
+ out.push(stopped ? " stopped" : " was not loaded");
165
227
  unlinkSync(p);
166
228
  out.push(` removed ${p}`);
167
229
  }
@@ -170,44 +232,134 @@ export function uninstallService(kind) {
170
232
  if (!existsSync(unit))
171
233
  return [` no ${kind} service installed`];
172
234
  try {
173
- execFileSync("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
235
+ sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
174
236
  out.push(" disabled and stopped");
175
237
  }
176
238
  catch {
177
239
  out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
178
240
  }
179
241
  unlinkSync(unit);
242
+ try {
243
+ sys.exec("systemctl", ["--user", "daemon-reload"]);
244
+ }
245
+ catch { }
180
246
  out.push(` removed ${unit}`);
181
247
  }
182
248
  return out;
183
249
  }
184
- export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
185
- if (!serviceInstalled(kind))
186
- return [` ${kind}: not installed here`];
187
- const uid = process.getuid?.() ?? 501;
250
+ export function disableService(kind, sys = realSys()) {
251
+ if (sys.platform === "darwin") {
252
+ try {
253
+ sys.exec("launchctl", ["bootout", `gui/${sys.uid}/${LABEL(kind)}`]);
254
+ }
255
+ catch { }
256
+ sys.exec("launchctl", ["disable", `gui/${sys.uid}/${LABEL(kind)}`]);
257
+ return;
258
+ }
259
+ sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
260
+ }
261
+ const realExec = guardedExecFile;
262
+ function outOf(exec, c, a) {
188
263
  try {
189
- if (platform() === "darwin")
190
- exec("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL(kind)}`]);
191
- else
192
- exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
193
- return [` ${kind}: restarted on the new code`];
264
+ return { out: String(exec(c, a) ?? ""), ok: true };
194
265
  }
195
266
  catch (e) {
196
- if (platform() === "darwin") {
197
- try {
267
+ return { out: String(e.stdout ?? ""), ok: false };
268
+ }
269
+ }
270
+ export function serviceState(kind, exec = realExec, plat = platform(), uid = process.getuid?.() ?? 501) {
271
+ if (plat === "darwin") {
272
+ const dis = outOf(exec, "launchctl", ["print-disabled", `gui/${uid}`]).out;
273
+ const disabled = new RegExp(`"${LABEL(kind).replace(/\./g, "\\.")}"\\s*=>\\s*(disabled|true)`).test(dis);
274
+ const pr = outOf(exec, "launchctl", ["print", `gui/${uid}/${LABEL(kind)}`]);
275
+ return { disabled, loaded: pr.ok, running: pr.ok && /^\s*state = running/m.test(pr.out) };
276
+ }
277
+ const en = outOf(exec, "systemctl", ["--user", "is-enabled", `pantheon-${kind}`]).out.trim();
278
+ const act = outOf(exec, "systemctl", ["--user", "is-active", `pantheon-${kind}`]).out.trim();
279
+ return { disabled: /^(disabled|masked)/.test(en), loaded: true, running: act === "active" || act === "activating" };
280
+ }
281
+ export function restartService(kind, exec = realExec, opts = {}) {
282
+ const plat = opts.platform ?? platform();
283
+ if (!serviceInstalled(kind, plat))
284
+ return [` ${kind}: not installed here`];
285
+ const uid = process.getuid?.() ?? 501;
286
+ const st = serviceState(kind, exec, plat, uid);
287
+ if (st.disabled)
288
+ return [` ${kind}: left stopped (you stopped it) — start it again: pantheon service install ${kind}`];
289
+ try {
290
+ if (plat === "darwin") {
291
+ if (st.loaded && !st.running)
292
+ return [` ${kind}: not running — left as it is (launchd starts it again on its own schedule)`];
293
+ if (!st.loaded) {
198
294
  exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
199
295
  return [` ${kind}: was not loaded in launchd — loaded and started on the new code`];
200
296
  }
201
- catch { }
297
+ if (opts.reload) {
298
+ exec("launchctl", ["bootout", `gui/${uid}/${LABEL(kind)}`]);
299
+ exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
300
+ return [` ${kind}: reloaded with its new definition and restarted on the new code`];
301
+ }
302
+ exec("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL(kind)}`]);
303
+ return [` ${kind}: restarted on the new code`];
202
304
  }
305
+ if (!st.running)
306
+ return [` ${kind}: not running — left as it is (it starts at login)`];
307
+ if (opts.reload)
308
+ exec("systemctl", ["--user", "daemon-reload"]);
309
+ exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
310
+ return [` ${kind}: restarted on the new code`];
311
+ }
312
+ catch (e) {
203
313
  return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
204
314
  }
205
315
  }
206
- export function serviceInstalled(kind) {
316
+ export function refreshServiceDefinition(kind, plat = platform()) {
317
+ try {
318
+ if (plat === "darwin") {
319
+ const p = plistPath(kind);
320
+ if (!existsSync(p))
321
+ return { changed: false };
322
+ const xml = readFileSync(p, "utf8");
323
+ const log = /<key>StandardOutPath<\/key>\s*<string>([^<]*)<\/string>/.exec(xml)?.[1] ?? "";
324
+ const logDir = serviceLogDir();
325
+ if (unxml(log).startsWith(logDir + "/"))
326
+ return { changed: false };
327
+ const argv = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => unxml(m[1]));
328
+ const at = argv.indexOf(kind);
329
+ const cwd = unxml(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(xml)?.[1] ?? homedir());
330
+ if (at < 0)
331
+ return { changed: false };
332
+ mkdirSync(logDir, { recursive: true });
333
+ writeFileSync(p, servicePlist(kind, cwd, logDir, argv.slice(at + 1)));
334
+ return { changed: true, line: ` ${kind}: its log now goes to ${join(logDir, `${kind}.log`)}${log ? ` (was ${unxml(log)})` : ""}` };
335
+ }
336
+ if (plat === "win32")
337
+ return { changed: false };
338
+ const u = unitPath(kind);
339
+ if (!existsSync(u))
340
+ return { changed: false };
341
+ const unit = readFileSync(u, "utf8");
342
+ if (/^Environment="PATH=/m.test(unit))
343
+ return { changed: false };
344
+ const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
345
+ const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
346
+ const at = words.indexOf(kind);
347
+ const cwd = (/^WorkingDirectory=(.*)$/m.exec(unit)?.[1] ?? homedir()).replace(/%%/g, "%");
348
+ if (at < 0)
349
+ return { changed: false };
350
+ writeFileSync(u, systemdUnit(kind, cwd, words.slice(at + 1)));
351
+ return { changed: true, line: ` ${kind}: its unit now quotes its paths and carries your PATH` };
352
+ }
353
+ catch (e) {
354
+ return { changed: false, line: ` ${kind}: could not refresh its service definition (${e.message.split("\n")[0]})` };
355
+ }
356
+ }
357
+ const unxml = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
358
+ export function serviceInstalled(kind, plat = platform()) {
207
359
  try {
208
- if (platform() === "win32")
360
+ if (plat === "win32")
209
361
  return false;
210
- return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
362
+ return existsSync(plat === "darwin" ? plistPath(kind) : unitPath(kind));
211
363
  }
212
364
  catch {
213
365
  return false;
@@ -27,7 +27,7 @@ export const ALERT_OPTIONS = [
27
27
  { id: "decisions", label: "Only decisions that need me (recommended) — never for ordinary agent traffic." },
28
28
  { id: "deliveries", label: "Decisions, and when a teammate delivers work to me." },
29
29
  { id: "worker", label: "Decisions, deliveries, and when my own worker finishes a run." },
30
- { id: "none", label: "None — I will look at pantheon status and the menu bar." },
30
+ { id: "none", label: "None — I will look at pantheon status (and the menu bar, on a Mac)." },
31
31
  ];
32
32
  export function guideDefaults(profile, cfg) {
33
33
  const n = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
@@ -42,6 +42,9 @@ export function guideDefaults(profile, cfg) {
42
42
  persona: false,
43
43
  };
44
44
  }
45
+ export function optOutsFrom(argv) {
46
+ return { worker: argv.includes("--no-worker"), notifications: argv.includes("--no-notifications") };
47
+ }
45
48
  export function detectBaseBranch(project) {
46
49
  const git = (...a) => { try {
47
50
  return execFileSync("git", a, { cwd: project, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -154,6 +157,9 @@ export function parseSetupFlags(argv) {
154
157
  return { preset, errors };
155
158
  }
156
159
  export async function runGuide(io, ctx, deps = {}, preset = {}) {
160
+ const optOut = ctx.optOut ?? {};
161
+ const os = deps.platform ?? process.platform;
162
+ const mac = os === "darwin";
157
163
  const install = deps.installService ?? realInstall;
158
164
  const uninstall = deps.uninstallService ?? realUninstall;
159
165
  const installed = deps.serviceInstalled ?? realInstalled;
@@ -283,7 +289,10 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
283
289
  catch (e) {
284
290
  warnings.push(`agent wiring not re-applied (${e.message}) — run pantheon install --user`);
285
291
  }
286
- if (a.wake === "no") {
292
+ if (optOut.worker && a.wake !== "no") {
293
+ applied.push(`worker: not installed (--no-worker) — ${a.wake === "anyone" ? "teammates may hand you work once it is on" : "only people you approve may hand you work once it is on"}: pantheon service install worker`);
294
+ }
295
+ else if (a.wake === "no") {
287
296
  if (installed("worker")) {
288
297
  try {
289
298
  uninstall("worker");
@@ -313,6 +322,8 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
313
322
  else {
314
323
  applied.push("worker: run this again from your project's repo to switch it on there");
315
324
  }
325
+ const waitIn = mac ? "pantheon status, approvals, and the menu bar" : "pantheon status and pantheon approvals";
326
+ const chosenAlerts = ALERT_OPTIONS.find((o) => o.id === a.notifications).label.split(" — ")[0].replace(/ \(recommended\)/, "");
316
327
  if (a.notifications === "none") {
317
328
  if (installed("notifier")) {
318
329
  try {
@@ -320,10 +331,16 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
320
331
  }
321
332
  catch { }
322
333
  }
323
- applied.push("alerts off — decisions still wait in pantheon status, approvals, and the menu bar");
334
+ applied.push(`alerts off — decisions still wait in ${waitIn}`);
335
+ }
336
+ else if (optOut.notifications) {
337
+ applied.push(`alerts: not installed (--no-notifications) — decisions wait in ${waitIn}. Your choice (${chosenAlerts.toLowerCase()}) is saved for when you turn them on${mac ? ": pantheon service install notifier" : ""}`);
338
+ }
339
+ else if (!mac) {
340
+ applied.push(`alerts: none on this machine — desktop alerts are macOS-only. Decisions wait in ${waitIn} (your choice, ${chosenAlerts.toLowerCase()}, is saved)`);
324
341
  }
325
342
  else {
326
- if (!installed("notifier") && process.platform === "darwin") {
343
+ if (!installed("notifier")) {
327
344
  if (existsSync(hudBinaryPath(deps.home))) {
328
345
  try {
329
346
  install("notifier", deps.home ?? homedir(), []);
package/dist/store.js CHANGED
@@ -20,8 +20,7 @@ export class StateStore {
20
20
  for (const g of snap.goals ?? [])
21
21
  goals.set(g.id, g);
22
22
  for (const o of snap.obligations ?? []) {
23
- const { claimed_by: _drop, ...rest } = o;
24
- obligations.set(o.id, rest);
23
+ obligations.set(o.id, o.claimed_by ? { ...o, claimed_by: { ...o.claimed_by, at: Date.now() } } : o);
25
24
  }
26
25
  for (const p of snap.profiles ?? [])
27
26
  profiles.set(p.member, p);
@@ -41,9 +40,16 @@ export class StateStore {
41
40
  if (!this.dirty)
42
41
  return;
43
42
  this.dirty = false;
44
- this.flush(goals, obligations, profiles);
43
+ try {
44
+ this.flush(goals, obligations, profiles);
45
+ }
46
+ catch (e) {
47
+ this.dirty = true;
48
+ this.onError?.(e);
49
+ }
45
50
  }, 250);
46
51
  }
52
+ onError;
47
53
  flush(goals, obligations, profiles) {
48
54
  const snap = {
49
55
  v: 2,
package/dist/which.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { accessSync, constants, existsSync, statSync } from "node:fs";
3
- import { posix, win32 } from "node:path";
2
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, join, posix, resolve, win32 } from "node:path";
4
4
  import { platform } from "node:os";
5
5
  export const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.PS1";
6
6
  const isWindows = (os = platform()) => os === "win32";
@@ -83,10 +83,72 @@ export function npmSpawn(args, os = platform(), resolveNpm = () => which("npm",
83
83
  const s = agentSpawn(resolveNpm() ?? "npm.cmd", args, os);
84
84
  return { ...s, options: { ...s.options, ...env } };
85
85
  }
86
- const cmdQuote = (a) => (/[\s"&|<>^()]/.test(a) || a === "" ? `"${a.replace(/"/g, '\\"')}"` : a);
87
- export function agentSpawn(binary, args, os = platform()) {
86
+ const CMD_UNSAFE = /["%!^&|<>\r\n\0]/;
87
+ export const cmdSafe = (a) => !CMD_UNSAFE.test(a);
88
+ const cmdQuote = (a) => {
89
+ if (!cmdSafe(a))
90
+ throw new CmdLineUnsafe(a);
91
+ return /[\s&|<>^()]/.test(a) || a === "" ? `"${a.replace(/(\\+)$/, "$1$1")}"` : a;
92
+ };
93
+ export class CmdLineUnsafe extends Error {
94
+ arg;
95
+ constructor(arg, binary, why) {
96
+ super(`${binary ? `${binary} ${why ?? "is a Windows command script Pantheon could not read"}, and ` : ""}this text cannot be passed through cmd.exe safely (it contains a quote, a %, a !, a cmd.exe operator or a line break) — it was not run`);
97
+ this.arg = arg;
98
+ this.name = "CmdLineUnsafe";
99
+ }
100
+ }
101
+ export function inspectCmdShim(shim, read = (f) => { try {
102
+ return readFileSync(f, "utf8");
103
+ }
104
+ catch {
105
+ return null;
106
+ } }, exists = existsSync, depth = 0) {
107
+ const text = read(shim);
108
+ if (text === null)
109
+ return { why: "is a Windows command script Pantheon could not read" };
110
+ if (text.length > 64 * 1024)
111
+ return { why: "was read but not recognised as a package manager's shim (it is too large to be one)" };
112
+ const dir = dirname(shim);
113
+ const re = /"%(?:~dp0|dp0%)\\?([^"%\r\n]+)"[ \t]*%\*[ \t]*\)?[ \t]*\r?$/gm;
114
+ let rel = null;
115
+ for (let m = re.exec(text); m; m = re.exec(text))
116
+ rel = m[1];
117
+ if (!rel)
118
+ return { why: "was read but not recognised as a package manager's shim" };
119
+ const parts = rel.split(/[\\/]+/).filter(Boolean);
120
+ if (!parts.length)
121
+ return { why: "was read but not recognised as a package manager's shim" };
122
+ const target = resolve(dir, ...parts);
123
+ if (!exists(target))
124
+ return { why: `was read, but what it runs is not there (${target})` };
125
+ if (/\.exe$/i.test(target))
126
+ return { command: target, args: [] };
127
+ if (/\.(?:cmd|bat)$/i.test(target)) {
128
+ if (depth >= 4)
129
+ return { why: "was read, but it runs shims that run shims too deep to follow" };
130
+ return inspectCmdShim(target, read, exists, depth + 1);
131
+ }
132
+ const js = /\.(?:c|m)?js$/i.test(target);
133
+ if (!js && (/\.[A-Za-z0-9]+$/.test(target) || !/\bnode(?:\.exe)?\b/i.test(text))) {
134
+ return { why: `was read, but it runs ${target}, which is not something Pantheon can start without cmd.exe` };
135
+ }
136
+ const localNode = join(dir, "node.exe");
137
+ return { command: exists(localNode) ? localNode : process.execPath, args: [target] };
138
+ }
139
+ export function resolveCmdShim(shim, read, exists) {
140
+ const r = inspectCmdShim(shim, read, exists);
141
+ return "command" in r ? r : null;
142
+ }
143
+ export function agentSpawn(binary, args, os = platform(), shim = (f) => inspectCmdShim(f)) {
88
144
  if (!isWindows(os) || !/\.(cmd|bat)$/i.test(binary))
89
145
  return { command: binary, args: [...args], options: {} };
146
+ if (!args.every(cmdSafe) || !cmdSafe(binary)) {
147
+ const direct = shim(binary);
148
+ if (!direct || !("command" in direct))
149
+ throw new CmdLineUnsafe(args.find((a) => !cmdSafe(a)) ?? binary, binary, direct?.why);
150
+ return { command: direct.command, args: [...direct.args, ...args], options: {} };
151
+ }
90
152
  const line = [binary, ...args].map(cmdQuote).join(" ");
91
153
  return {
92
154
  command: process.env.ComSpec || "cmd.exe",
package/dist/workdir.js CHANGED
@@ -9,6 +9,12 @@ export function gitActivity(path) {
9
9
  }
10
10
  return 0;
11
11
  }
12
+ export function mentions(text, name) {
13
+ if (!name)
14
+ return false;
15
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ return new RegExp(`(^|[^A-Za-z0-9._-])${esc}(?=$|[\\/]|\\.git(?![A-Za-z0-9_-])|\\.(?![A-Za-z0-9_-])|[^A-Za-z0-9._-])`).test(text);
17
+ }
12
18
  export function pickWorkdir(bindings, hints, team, activity = gitActivity, exists = existsSync) {
13
19
  const live = bindings.filter((b) => exists(b.path));
14
20
  if (!live.length) {
@@ -17,7 +23,7 @@ export function pickWorkdir(bindings, hints, team, activity = gitActivity, exist
17
23
  note: `no folder on this machine is bound to "${team}", so this opens in your home folder. To fix it, run pantheon use ${team} --here inside the repo.`,
18
24
  };
19
25
  }
20
- const named = live.filter((b) => hints.some((h) => (b.origin ? h.includes(b.origin) : false) || h.includes(b.path)));
26
+ const named = live.filter((b) => hints.some((h) => (b.origin ? mentions(h, b.origin) : false) || mentions(h, b.path)));
21
27
  const pool = named.length ? named : live;
22
28
  const chosen = [...pool].sort((x, y) => (activity(y.path) - activity(x.path)) || (y.bound_at - x.bound_at))[0];
23
29
  const others = live.filter((b) => b.path !== chosen.path).map((b) => basename(b.path));