@join-pantheon/cli 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,28 @@
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 const realSys = () => ({
12
+ platform: platform(),
13
+ exec: (c, a) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) ?? "",
14
+ uid: process.getuid?.() ?? 501,
15
+ user: (() => { try {
16
+ return userInfo().username;
17
+ }
18
+ catch {
19
+ return process.env.USER ?? "";
20
+ } })(),
21
+ });
22
+ export function serviceLogDir() {
23
+ return join(CONFIG_DIR, "logs");
24
+ }
25
+ export const serviceLogFile = (kind) => join(serviceLogDir(), `${kind}.log`);
10
26
  const SERVICE_BASE_PATH = [
11
27
  "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
12
28
  ];
@@ -22,7 +38,7 @@ export function servicesUnavailable(plat = platform()) {
22
38
  export function plistPath(kind = "worker") {
23
39
  return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
24
40
  }
25
- function unitPath(kind) {
41
+ export function unitPath(kind) {
26
42
  return join(homedir(), ".config", "systemd", "user", `pantheon-${kind}.service`);
27
43
  }
28
44
  function xml(s) {
@@ -71,7 +87,11 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
71
87
  </plist>
72
88
  `;
73
89
  }
74
- export function systemdUnit(kind, cwd, args) {
90
+ export function systemdQuote(arg) {
91
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
92
+ }
93
+ const systemdValue = (v) => v.replace(/%/g, "%%");
94
+ export function systemdUnit(kind, cwd, args, path = servicePath()) {
75
95
  const description = kind === "relay"
76
96
  ? "pantheon relay — the coordination plane for a space"
77
97
  : kind === "worker"
@@ -83,8 +103,9 @@ After=network-online.target
83
103
 
84
104
  [Service]
85
105
  Type=simple
86
- WorkingDirectory=${cwd}
87
- ExecStart=${argvFor(kind, args).join(" ")}
106
+ WorkingDirectory=${systemdValue(cwd)}
107
+ ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
108
+ Environment=${systemdQuote(`PATH=${path}`)}
88
109
  Restart=always
89
110
  RestartSec=30
90
111
 
@@ -92,29 +113,43 @@ RestartSec=30
92
113
  WantedBy=default.target
93
114
  `;
94
115
  }
95
- export function installService(kind, cwd, args) {
96
- const noServices = servicesUnavailable();
116
+ export function lingerEnabled(sys = realSys()) {
117
+ try {
118
+ const out = sys.exec("loginctl", ["show-user", sys.user || String(sys.uid), "--property=Linger"]);
119
+ const m = /Linger=(yes|no)/.exec(out);
120
+ return m ? m[1] === "yes" : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ export function installService(kind, cwd, args, sys = realSys()) {
127
+ const noServices = servicesUnavailable(sys.platform);
97
128
  if (noServices)
98
129
  throw new Error(noServices.replace(/^not available — /, ""));
99
130
  const out = [];
100
131
  const abs = resolve(cwd);
101
- const logDir = join(abs, ".pantheon");
102
- mkdirSync(logDir, { recursive: true });
103
- if (platform() === "darwin") {
132
+ const logDir = serviceLogDir();
133
+ if (sys.platform === "darwin") {
134
+ mkdirSync(logDir, { recursive: true });
104
135
  const p = plistPath(kind);
105
136
  mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
106
137
  writeFileSync(p, servicePlist(kind, abs, logDir, args));
107
138
  out.push(` wrote ${p}`);
108
139
  try {
109
- execFileSync("launchctl", ["unload", p], { stdio: "ignore" });
140
+ sys.exec("launchctl", ["enable", `gui/${sys.uid}/${LABEL(kind)}`]);
141
+ }
142
+ catch { }
143
+ try {
144
+ sys.exec("launchctl", ["unload", p]);
110
145
  }
111
146
  catch { }
112
147
  try {
113
- execFileSync("launchctl", ["load", "-w", p], { stdio: "ignore" });
148
+ sys.exec("launchctl", ["load", "-w", p]);
114
149
  out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
115
150
  }
116
151
  catch (e) {
117
- out.push(` could not load it: ${e.message}`);
152
+ out.push(` could not load it: ${e.message.split("\n")[0]}`);
118
153
  out.push(` load it yourself with: launchctl load -w ${p}`);
119
154
  }
120
155
  out.push(` logs: ${join(logDir, `${kind}.log`)}`);
@@ -122,15 +157,15 @@ export function installService(kind, cwd, args) {
122
157
  }
123
158
  else {
124
159
  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()}`);
160
+ throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
126
161
  }
127
162
  const unit = unitPath(kind);
128
163
  mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
129
164
  writeFileSync(unit, systemdUnit(kind, abs, args));
130
165
  out.push(` wrote ${unit}`);
131
166
  try {
132
- execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
133
- execFileSync("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
167
+ sys.exec("systemctl", ["--user", "daemon-reload"]);
168
+ sys.exec("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`]);
134
169
  out.push(` enabled — the ${kind} now starts at login and restarts if it dies`);
135
170
  out.push(` logs: journalctl --user -u pantheon-${kind} -f`);
136
171
  out.push(` stop: pantheon service uninstall ${kind}`);
@@ -138,12 +173,15 @@ export function installService(kind, cwd, args) {
138
173
  catch (e) {
139
174
  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
175
  }
176
+ if (lingerEnabled(sys) === false) {
177
+ out.push(` ! the ${kind} stops when you log out (closing an SSH session counts). To keep it running: loginctl enable-linger ${sys.user || "$USER"}`);
178
+ }
141
179
  }
142
180
  return out;
143
181
  }
144
- export function uninstallService(kind) {
182
+ export function uninstallService(kind, sys = realSys()) {
145
183
  const out = [];
146
- if (platform() === "win32") {
184
+ if (sys.platform === "win32") {
147
185
  const stray = unitPath(kind);
148
186
  if (existsSync(stray)) {
149
187
  unlinkSync(stray);
@@ -151,17 +189,22 @@ export function uninstallService(kind) {
151
189
  }
152
190
  return [` no ${kind} service installed`];
153
191
  }
154
- if (platform() === "darwin") {
192
+ if (sys.platform === "darwin") {
155
193
  const p = plistPath(kind);
156
194
  if (!existsSync(p))
157
195
  return [` no ${kind} service installed`];
196
+ let stopped = false;
158
197
  try {
159
- execFileSync("launchctl", ["unload", "-w", p], { stdio: "ignore" });
160
- out.push(" unloaded");
198
+ sys.exec("launchctl", ["bootout", `gui/${sys.uid}/${LABEL(kind)}`]);
199
+ stopped = true;
161
200
  }
162
- catch {
163
- out.push(" was not loaded");
201
+ catch { }
202
+ try {
203
+ sys.exec("launchctl", ["unload", "-w", p]);
204
+ stopped = true;
164
205
  }
206
+ catch { }
207
+ out.push(stopped ? " stopped" : " was not loaded");
165
208
  unlinkSync(p);
166
209
  out.push(` removed ${p}`);
167
210
  }
@@ -170,44 +213,134 @@ export function uninstallService(kind) {
170
213
  if (!existsSync(unit))
171
214
  return [` no ${kind} service installed`];
172
215
  try {
173
- execFileSync("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
216
+ sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
174
217
  out.push(" disabled and stopped");
175
218
  }
176
219
  catch {
177
220
  out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
178
221
  }
179
222
  unlinkSync(unit);
223
+ try {
224
+ sys.exec("systemctl", ["--user", "daemon-reload"]);
225
+ }
226
+ catch { }
180
227
  out.push(` removed ${unit}`);
181
228
  }
182
229
  return out;
183
230
  }
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;
231
+ export function disableService(kind, sys = realSys()) {
232
+ if (sys.platform === "darwin") {
233
+ try {
234
+ sys.exec("launchctl", ["bootout", `gui/${sys.uid}/${LABEL(kind)}`]);
235
+ }
236
+ catch { }
237
+ sys.exec("launchctl", ["disable", `gui/${sys.uid}/${LABEL(kind)}`]);
238
+ return;
239
+ }
240
+ sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
241
+ }
242
+ const realExec = (c, a) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
243
+ function outOf(exec, c, a) {
188
244
  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`];
245
+ return { out: String(exec(c, a) ?? ""), ok: true };
194
246
  }
195
247
  catch (e) {
196
- if (platform() === "darwin") {
197
- try {
248
+ return { out: String(e.stdout ?? ""), ok: false };
249
+ }
250
+ }
251
+ export function serviceState(kind, exec = realExec, plat = platform(), uid = process.getuid?.() ?? 501) {
252
+ if (plat === "darwin") {
253
+ const dis = outOf(exec, "launchctl", ["print-disabled", `gui/${uid}`]).out;
254
+ const disabled = new RegExp(`"${LABEL(kind).replace(/\./g, "\\.")}"\\s*=>\\s*(disabled|true)`).test(dis);
255
+ const pr = outOf(exec, "launchctl", ["print", `gui/${uid}/${LABEL(kind)}`]);
256
+ return { disabled, loaded: pr.ok, running: pr.ok && /^\s*state = running/m.test(pr.out) };
257
+ }
258
+ const en = outOf(exec, "systemctl", ["--user", "is-enabled", `pantheon-${kind}`]).out.trim();
259
+ const act = outOf(exec, "systemctl", ["--user", "is-active", `pantheon-${kind}`]).out.trim();
260
+ return { disabled: /^(disabled|masked)/.test(en), loaded: true, running: act === "active" || act === "activating" };
261
+ }
262
+ export function restartService(kind, exec = realExec, opts = {}) {
263
+ const plat = opts.platform ?? platform();
264
+ if (!serviceInstalled(kind, plat))
265
+ return [` ${kind}: not installed here`];
266
+ const uid = process.getuid?.() ?? 501;
267
+ const st = serviceState(kind, exec, plat, uid);
268
+ if (st.disabled)
269
+ return [` ${kind}: left stopped (you stopped it) — start it again: pantheon service install ${kind}`];
270
+ try {
271
+ if (plat === "darwin") {
272
+ if (st.loaded && !st.running)
273
+ return [` ${kind}: not running — left as it is (launchd starts it again on its own schedule)`];
274
+ if (!st.loaded) {
198
275
  exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
199
276
  return [` ${kind}: was not loaded in launchd — loaded and started on the new code`];
200
277
  }
201
- catch { }
278
+ if (opts.reload) {
279
+ exec("launchctl", ["bootout", `gui/${uid}/${LABEL(kind)}`]);
280
+ exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
281
+ return [` ${kind}: reloaded with its new definition and restarted on the new code`];
282
+ }
283
+ exec("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL(kind)}`]);
284
+ return [` ${kind}: restarted on the new code`];
202
285
  }
286
+ if (!st.running)
287
+ return [` ${kind}: not running — left as it is (it starts at login)`];
288
+ if (opts.reload)
289
+ exec("systemctl", ["--user", "daemon-reload"]);
290
+ exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
291
+ return [` ${kind}: restarted on the new code`];
292
+ }
293
+ catch (e) {
203
294
  return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
204
295
  }
205
296
  }
206
- export function serviceInstalled(kind) {
297
+ export function refreshServiceDefinition(kind, plat = platform()) {
298
+ try {
299
+ if (plat === "darwin") {
300
+ const p = plistPath(kind);
301
+ if (!existsSync(p))
302
+ return { changed: false };
303
+ const xml = readFileSync(p, "utf8");
304
+ const log = /<key>StandardOutPath<\/key>\s*<string>([^<]*)<\/string>/.exec(xml)?.[1] ?? "";
305
+ const logDir = serviceLogDir();
306
+ if (unxml(log).startsWith(logDir + "/"))
307
+ return { changed: false };
308
+ const argv = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => unxml(m[1]));
309
+ const at = argv.indexOf(kind);
310
+ const cwd = unxml(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(xml)?.[1] ?? homedir());
311
+ if (at < 0)
312
+ return { changed: false };
313
+ mkdirSync(logDir, { recursive: true });
314
+ writeFileSync(p, servicePlist(kind, cwd, logDir, argv.slice(at + 1)));
315
+ return { changed: true, line: ` ${kind}: its log now goes to ${join(logDir, `${kind}.log`)}${log ? ` (was ${unxml(log)})` : ""}` };
316
+ }
317
+ if (plat === "win32")
318
+ return { changed: false };
319
+ const u = unitPath(kind);
320
+ if (!existsSync(u))
321
+ return { changed: false };
322
+ const unit = readFileSync(u, "utf8");
323
+ if (/^Environment="PATH=/m.test(unit))
324
+ return { changed: false };
325
+ const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
326
+ const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
327
+ const at = words.indexOf(kind);
328
+ const cwd = (/^WorkingDirectory=(.*)$/m.exec(unit)?.[1] ?? homedir()).replace(/%%/g, "%");
329
+ if (at < 0)
330
+ return { changed: false };
331
+ writeFileSync(u, systemdUnit(kind, cwd, words.slice(at + 1)));
332
+ return { changed: true, line: ` ${kind}: its unit now quotes its paths and carries your PATH` };
333
+ }
334
+ catch (e) {
335
+ return { changed: false, line: ` ${kind}: could not refresh its service definition (${e.message.split("\n")[0]})` };
336
+ }
337
+ }
338
+ const unxml = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
339
+ export function serviceInstalled(kind, plat = platform()) {
207
340
  try {
208
- if (platform() === "win32")
341
+ if (plat === "win32")
209
342
  return false;
210
- return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
343
+ return existsSync(plat === "darwin" ? plistPath(kind) : unitPath(kind));
211
344
  }
212
345
  catch {
213
346
  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,
@@ -5,6 +5,7 @@ import { CONFIG_DIR } from "./config.js";
5
5
  import { repoRoot, PKG_NAME } from "./upgrade.js";
6
6
  import { VERSION } from "./version.js";
7
7
  import { npmSpawn } from "./which.js";
8
+ import { proxyEnvFor } from "./network.js";
8
9
  export function semverGt(x, y) {
9
10
  const p = (s) => s.split(".").map((n) => Number(n) || 0);
10
11
  const [a, b] = [p(x), p(y)];
@@ -81,7 +82,7 @@ export function defaultDeps(home) {
81
82
  },
82
83
  npmLatest: () => {
83
84
  try {
84
- const s = npmSpawn(["view", PKG_NAME, "version"]);
85
+ const s = npmSpawn(["view", PKG_NAME, "version"], undefined, undefined, proxyEnvFor());
85
86
  return execFileSync(s.command, s.args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000, ...s.options }).trim() || null;
86
87
  }
87
88
  catch {
package/dist/upgrade.js CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { installForUser } from "./install.js";
6
6
  import { npmSpawn } from "./which.js";
7
+ import { proxyEnvFor } from "./network.js";
7
8
  import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
8
9
  export function repoRoot() {
9
10
  return join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -30,7 +31,7 @@ export function rewireVia(root, fallback, run = (file, args) => execFileSync(pro
30
31
  export function defaultDeps() {
31
32
  const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
32
33
  const npm = (args, opts) => {
33
- const s = npmSpawn(args);
34
+ const s = npmSpawn(args, undefined, undefined, proxyEnvFor());
34
35
  return execFileSync(s.command, s.args, { encoding: "utf8", ...opts, ...s.options });
35
36
  };
36
37
  return {