@join-pantheon/cli 0.29.0 → 0.30.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.
Files changed (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -119
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +507 -38
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +88 -15
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1193 -185
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +105 -36
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/dist/service.js CHANGED
@@ -2,15 +2,34 @@ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from "
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { homedir, platform, userInfo } from "node:os";
4
4
  import { which } from "./which.js";
5
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { launcher } from "./install.js";
8
8
  import { CONFIG_DIR } from "./config.js";
9
9
  const HERE = dirname(fileURLToPath(import.meta.url));
10
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
+ }
11
30
  export const realSys = () => ({
12
31
  platform: platform(),
13
- exec: (c, a) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) ?? "",
32
+ exec: guardedExecFile,
14
33
  uid: process.getuid?.() ?? 501,
15
34
  user: (() => { try {
16
35
  return userInfo().username;
@@ -38,8 +57,44 @@ export function servicesUnavailable(plat = platform()) {
38
57
  export function plistPath(kind = "worker") {
39
58
  return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
40
59
  }
41
- export function unitPath(kind) {
42
- return join(homedir(), ".config", "systemd", "user", `pantheon-${kind}.service`);
60
+ export function parseSystemdEnvironment(out) {
61
+ const env = {};
62
+ for (const line of out.split(/\r?\n/)) {
63
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
64
+ if (!m)
65
+ continue;
66
+ let v = m[2];
67
+ if (v.startsWith("$'") && v.endsWith("'"))
68
+ v = v.slice(2, -1).replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
69
+ env[m[1]] = v;
70
+ }
71
+ return env;
72
+ }
73
+ const sdEnvCache = new WeakMap();
74
+ export function systemdUserEnv(sys = realSys()) {
75
+ if (sys.platform !== "linux" || (sys.borrowed ?? borrowedHome)())
76
+ return {};
77
+ const home = homedir();
78
+ const hit = sdEnvCache.get(sys.exec);
79
+ if (hit && hit.home === home)
80
+ return hit.env;
81
+ let env = {};
82
+ try {
83
+ env = parseSystemdEnvironment(sys.exec("systemctl", ["--user", "show-environment"]));
84
+ }
85
+ catch { }
86
+ sdEnvCache.set(sys.exec, { home, env });
87
+ return env;
88
+ }
89
+ export function unitPath(kind, env = systemdUserEnv()) {
90
+ const xdg = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(homedir(), ".config");
91
+ return join(xdg, "systemd", "user", `pantheon-${kind}.service`);
92
+ }
93
+ export function unitPaths(kind, env = systemdUserEnv(), shellEnv = process.env) {
94
+ return [...new Set([unitPath(kind, env), unitPath(kind, {}), unitPath(kind, shellEnv)])];
95
+ }
96
+ export function existingUnitPath(kind, env = systemdUserEnv(), shellEnv = process.env) {
97
+ return unitPaths(kind, env, shellEnv).find((p) => existsSync(p)) ?? null;
43
98
  }
44
99
  function xml(s) {
45
100
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -51,16 +106,13 @@ function argvFor(kind, args) {
51
106
  const abs = whichPantheon();
52
107
  return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
53
108
  }
54
- export function whichPantheon() {
55
- if (platform() === "win32")
56
- return which("pantheon");
57
- try {
58
- const p = execFileSync("/usr/bin/which", ["pantheon"], { encoding: "utf8" }).trim();
59
- return p.startsWith("/") && existsSync(p) ? p : null;
60
- }
61
- catch {
109
+ export function whichPantheon(find = which) {
110
+ const p = find("pantheon");
111
+ if (!p)
62
112
  return null;
63
- }
113
+ if (platform() === "win32")
114
+ return p;
115
+ return p.startsWith("/") && existsSync(p) ? p : null;
64
116
  }
65
117
  export function servicePlist(kind, cwd, logDir, args) {
66
118
  const argv = argvFor(kind, args);
@@ -88,9 +140,15 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
88
140
  `;
89
141
  }
90
142
  export function systemdQuote(arg) {
91
- return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
143
+ return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
92
144
  }
93
- const systemdValue = (v) => v.replace(/%/g, "%%");
145
+ const cEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
146
+ export const systemdEnvQuote = (assignment) => `"${cEscape(assignment).replace(/%/g, "%%")}"`;
147
+ const systemdValue = (v) => {
148
+ if (/[\r\n]/.test(v))
149
+ throw new Error(`a path with a line break in it cannot be written into a systemd unit: ${JSON.stringify(v)}`);
150
+ return v.replace(/%/g, "%%");
151
+ };
94
152
  export function systemdUnit(kind, cwd, args, path = servicePath()) {
95
153
  const description = kind === "relay"
96
154
  ? "pantheon relay — the coordination plane for a space"
@@ -105,7 +163,7 @@ After=network-online.target
105
163
  Type=simple
106
164
  WorkingDirectory=${systemdValue(cwd)}
107
165
  ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
108
- Environment=${systemdQuote(`PATH=${path}`)}
166
+ Environment=${systemdEnvQuote(`PATH=${path}`)}
109
167
  Restart=always
110
168
  RestartSec=30
111
169
 
@@ -128,6 +186,9 @@ export function installService(kind, cwd, args, sys = realSys()) {
128
186
  if (noServices)
129
187
  throw new Error(noServices.replace(/^not available — /, ""));
130
188
  const out = [];
189
+ let loaded = true;
190
+ let reason;
191
+ const done = () => Object.assign(out, { loaded, ...(reason ? { reason } : {}) });
131
192
  const abs = resolve(cwd);
132
193
  const logDir = serviceLogDir();
133
194
  if (sys.platform === "darwin") {
@@ -149,8 +210,16 @@ export function installService(kind, cwd, args, sys = realSys()) {
149
210
  out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
150
211
  }
151
212
  catch (e) {
152
- out.push(` could not load it: ${e.message.split("\n")[0]}`);
153
- out.push(` load it yourself with: launchctl load -w ${p}`);
213
+ const why = e.message.split("\n")[0];
214
+ loaded = false;
215
+ reason = why;
216
+ out.push(` could not load it: ${why}`);
217
+ if ((sys.borrowed ?? borrowedHome)()) {
218
+ reason = `HOME (${homedir()}) is not your own home, so launchd was not touched`;
219
+ out.push(` left unloaded — ${reason}; your own services are unchanged`);
220
+ }
221
+ else
222
+ out.push(` load it yourself with: launchctl load -w ${p}`);
154
223
  }
155
224
  out.push(` logs: ${join(logDir, `${kind}.log`)}`);
156
225
  out.push(` stop: pantheon service uninstall ${kind}`);
@@ -159,8 +228,8 @@ export function installService(kind, cwd, args, sys = realSys()) {
159
228
  if (kind === "menubar" || kind === "notifier") {
160
229
  throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
161
230
  }
162
- const unit = unitPath(kind);
163
- mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
231
+ const unit = unitPath(kind, systemdUserEnv(sys));
232
+ mkdirSync(dirname(unit), { recursive: true });
164
233
  writeFileSync(unit, systemdUnit(kind, abs, args));
165
234
  out.push(` wrote ${unit}`);
166
235
  try {
@@ -177,17 +246,15 @@ export function installService(kind, cwd, args, sys = realSys()) {
177
246
  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
247
  }
179
248
  }
180
- return out;
249
+ return done();
181
250
  }
182
251
  export function uninstallService(kind, sys = realSys()) {
183
252
  const out = [];
184
253
  if (sys.platform === "win32") {
185
- const stray = unitPath(kind);
186
- if (existsSync(stray)) {
187
- unlinkSync(stray);
188
- return [` removed ${stray}`];
189
- }
190
- return [` no ${kind} service installed`];
254
+ const strays = unitPaths(kind, {}).filter((u) => existsSync(u));
255
+ for (const u of strays)
256
+ unlinkSync(u);
257
+ return strays.length ? strays.map((u) => ` removed ${u}`) : [` no ${kind} service installed`];
191
258
  }
192
259
  if (sys.platform === "darwin") {
193
260
  const p = plistPath(kind);
@@ -209,8 +276,8 @@ export function uninstallService(kind, sys = realSys()) {
209
276
  out.push(` removed ${p}`);
210
277
  }
211
278
  else {
212
- const unit = unitPath(kind);
213
- if (!existsSync(unit))
279
+ const units = unitPaths(kind, systemdUserEnv(sys)).filter((u) => existsSync(u));
280
+ if (!units.length)
214
281
  return [` no ${kind} service installed`];
215
282
  try {
216
283
  sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
@@ -219,12 +286,14 @@ export function uninstallService(kind, sys = realSys()) {
219
286
  catch {
220
287
  out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
221
288
  }
222
- unlinkSync(unit);
289
+ for (const u of units)
290
+ unlinkSync(u);
223
291
  try {
224
292
  sys.exec("systemctl", ["--user", "daemon-reload"]);
225
293
  }
226
294
  catch { }
227
- out.push(` removed ${unit}`);
295
+ for (const u of units)
296
+ out.push(` removed ${u}`);
228
297
  }
229
298
  return out;
230
299
  }
@@ -239,7 +308,7 @@ export function disableService(kind, sys = realSys()) {
239
308
  }
240
309
  sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
241
310
  }
242
- const realExec = (c, a) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
311
+ const realExec = guardedExecFile;
243
312
  function outOf(exec, c, a) {
244
313
  try {
245
314
  return { out: String(exec(c, a) ?? ""), ok: true };
@@ -316,11 +385,11 @@ export function refreshServiceDefinition(kind, plat = platform()) {
316
385
  }
317
386
  if (plat === "win32")
318
387
  return { changed: false };
319
- const u = unitPath(kind);
320
- if (!existsSync(u))
388
+ const u = existingUnitPath(kind);
389
+ if (!u)
321
390
  return { changed: false };
322
391
  const unit = readFileSync(u, "utf8");
323
- if (/^Environment="PATH=/m.test(unit))
392
+ if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit))
324
393
  return { changed: false };
325
394
  const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
326
395
  const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
@@ -340,7 +409,7 @@ export function serviceInstalled(kind, plat = platform()) {
340
409
  try {
341
410
  if (plat === "win32")
342
411
  return false;
343
- return existsSync(plat === "darwin" ? plistPath(kind) : unitPath(kind));
412
+ return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
344
413
  }
345
414
  catch {
346
415
  return false;
@@ -3,7 +3,7 @@ import { approvalSummary, installForUser } from "./install.js";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
6
+ import { applyProfileInit, loadProfile, profilePath, saveProfile, wakeMode } from "./profile.js";
7
7
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
8
  import { personaDir } from "./persona.js";
9
9
  import { hudBinaryPath } from "./notifier.js";
@@ -19,8 +19,8 @@ export const DECISION_AREAS = [
19
19
  ];
20
20
  export const TITLE_OPTIONS = ["Founder", "Engineer", "Designer", "Product", "Operations", "Something else"];
21
21
  export const WAKE_OPTIONS = [
22
- { id: "anyone", label: "Yes — anyone on my teams (recommended). Invites you send grant it; you can revoke a person any time." },
23
- { id: "approved", label: "Only people I approve one by one — invites do not grant it; I run `pantheon admit <name> --may-wake` per person." },
22
+ { id: "anyone", label: "Yes — anyone on my teams. Invites you send grant it; you can revoke a person any time." },
23
+ { id: "approved", label: "Only people I approve one by one (the default) — invites do not grant it; I run `pantheon wake allow <name>` per person, on this machine." },
24
24
  { id: "no", label: "No — this machine only works while I am at it." },
25
25
  ];
26
26
  export const ALERT_OPTIONS = [
@@ -32,7 +32,7 @@ export const ALERT_OPTIONS = [
32
32
  export function guideDefaults(profile, cfg) {
33
33
  const n = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
34
34
  return {
35
- wake: profile.agent.wake_default ?? "anyone",
35
+ wake: wakeMode(profile),
36
36
  commands: profile.agent.allow_bash ?? false,
37
37
  approval: profile.agent.approval ?? "approve",
38
38
  name: profile.human.display_name || cfg.display || cfg.member,
@@ -69,7 +69,9 @@ export function questionnaire() {
69
69
  "",
70
70
  "Only 3 and 4 need the person every time. Ask 2 whenever there is a repo (it is the one default people are",
71
71
  "surprised by). The rest have defaults you may take and say out loud:",
72
- " --wake anyone --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
72
+ " --wake approved --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
73
+ "--wake approved (the default) means nobody wakes this machine until they approve a person with `pantheon wake allow <name>`.",
74
+ "Never pick --wake anyone for them: it is their call, and only if they say so.",
73
75
  "Without a repo, --wake, --commands, --playbook and --base do nothing yet: skip them and say so.",
74
76
  "",
75
77
  "1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
@@ -98,7 +100,7 @@ export function questionnaire() {
98
100
  " These are switches: pass --approve-all or --ask-before-sending with no value. Either way the install",
99
101
  " output prints the exact line written to ~/.codex/config.toml, and pantheon leave removes it.",
100
102
  "",
101
- "Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
103
+ "Example: pantheon setup --yes --wake approved --commands yes --name \"Praneet Sinha\" --title Founder \\",
102
104
  " --decides deployment --alerts decisions --playbook yes --base main",
103
105
  ].join("\n");
104
106
  }
@@ -198,7 +200,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
198
200
  io.say(` • Wake: ${a.wake === "no" ? "off — this machine only works while you are at it"
199
201
  : !ctx.project ? "on once this machine has a repo (run pantheon setup from your project's repo)"
200
202
  : a.wake === "anyone" ? "teammates may hand work to this machine while you're away; invites you send grant it, and you can revoke anyone (pantheon admit <name> --revoke)"
201
- : "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
203
+ : "only people you approve by hand, on this machine (pantheon wake allow <name>)"}`);
202
204
  io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
203
205
  io.say(` • Approval: ${a.approval === "approve"
204
206
  ? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor, Codex, and Kiro's IDE and pantheon agent; Copilot asks once per tool per folder)"
@@ -317,7 +319,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
317
319
  }
318
320
  applied.push(a.wake === "anyone"
319
321
  ? "wake permission: invites you send grant it (revoke one person: pantheon admit <name> --revoke)"
320
- : "wake permission: only people you grant by hand (pantheon admit <name> --may-wake)");
322
+ : "wake permission: only people you approve on this machine (pantheon wake allow <name>)");
321
323
  }
322
324
  else {
323
325
  applied.push("worker: run this again from your project's repo to switch it on there");
@@ -403,8 +405,25 @@ ${never.map((n) => `- ${n}`).join("\n")}
403
405
  When in doubt, ask the teammate who woke you rather than ${who}; if only ${who} can answer, ESCALATE and finish.
404
406
  `;
405
407
  }
408
+ export function questionOrNull(rl, q) {
409
+ return new Promise((res) => {
410
+ let done = false;
411
+ const onClose = () => { if (!done) {
412
+ done = true;
413
+ res(null);
414
+ } };
415
+ rl.once?.("close", onClose);
416
+ try {
417
+ rl.question(q, (a) => { if (done)
418
+ return; done = true; rl.off?.("close", onClose); res(a.trim()); });
419
+ }
420
+ catch {
421
+ onClose();
422
+ }
423
+ });
424
+ }
406
425
  export function terminalIO(rl, out) {
407
- const ask = (q) => new Promise((res) => rl.question(q, (x) => res(x.trim())));
426
+ const ask = async (q) => (await questionOrNull(rl, q)) ?? "";
408
427
  return {
409
428
  say: out,
410
429
  async ask(question, def) {
package/dist/spend.js CHANGED
@@ -31,9 +31,13 @@ export function since(ms, home) {
31
31
  byPeer[r.triggeredBy] = (byPeer[r.triggeredBy] ?? 0) + 1;
32
32
  return { runs: runs.length, failed: runs.filter((r) => !r.ok).length, byPeer };
33
33
  }
34
- export function recentRuns(ms, home, limit = 12) {
34
+ export function recentRuns(ms, home, limit = 12, only) {
35
35
  const from = Date.now() - ms;
36
- return read(home).runs.filter((r) => r.at > from).sort((a, b) => b.at - a.at).slice(0, limit);
36
+ const teams = (only?.teams ?? []).filter((t) => !!t);
37
+ return read(home).runs
38
+ .filter((r) => r.at > from)
39
+ .filter((r) => !only || (!!r.space && teams.includes(r.space) && (!r.member || r.member === only.member)))
40
+ .sort((a, b) => b.at - a.at).slice(0, limit);
37
41
  }
38
42
  export function allowed(caps, home) {
39
43
  const hour = since(3_600_000, home).runs;
package/dist/store.js CHANGED
@@ -1,22 +1,86 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { readFileSync, existsSync, mkdirSync, renameSync, openSync, writeSync, fsyncSync, closeSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { emptyRefusals } from "./admin-stats.js";
4
+ export function ensurePrivateDir(dir) {
5
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
6
+ try {
7
+ chmodSync(dir, 0o700);
8
+ }
9
+ catch { }
10
+ }
11
+ export function writeFileAtomic(file, data, mode = 0o600) {
12
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
13
+ const tmp = `${file}.tmp`;
14
+ const fd = openSync(tmp, "w", mode);
15
+ try {
16
+ writeSync(fd, typeof data === "string" ? Buffer.from(data) : data);
17
+ fsyncSync(fd);
18
+ }
19
+ finally {
20
+ closeSync(fd);
21
+ }
22
+ renameRetrying(tmp, file);
23
+ fsyncDir(dirname(file));
24
+ }
25
+ const sleepSync = (ms) => { try {
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ catch { } };
29
+ export function renameRetrying(from, to, o = {}) {
30
+ const platform = o.platform ?? process.platform;
31
+ const rename = o.rename ?? renameSync;
32
+ for (let i = 0;; i++) {
33
+ try {
34
+ rename(from, to);
35
+ return;
36
+ }
37
+ catch (e) {
38
+ if (platform !== "win32" || i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
39
+ throw e;
40
+ sleepSync(o.waitMs ?? 10);
41
+ }
42
+ }
43
+ }
44
+ export function fsyncDir(dir) {
45
+ let fd = null;
46
+ try {
47
+ fd = openSync(dir, "r");
48
+ fsyncSync(fd);
49
+ }
50
+ catch { }
51
+ finally {
52
+ if (fd !== null)
53
+ try {
54
+ closeSync(fd);
55
+ }
56
+ catch { }
57
+ }
58
+ }
4
59
  export class StateStore {
5
60
  file;
6
61
  timer = null;
7
62
  dirty = false;
8
63
  refusals = emptyRefusals();
64
+ headOf;
65
+ rebuilt = null;
66
+ extra = {};
9
67
  constructor(dir, space) {
10
68
  this.file = join(dir, `${space}.state.json`);
11
69
  }
12
- load() {
70
+ load(opts = {}) {
13
71
  const goals = new Map();
14
72
  const obligations = new Map();
15
73
  const profiles = new Map();
16
- if (!existsSync(this.file))
17
- return { goals, obligations, profiles };
74
+ if (!existsSync(this.file)) {
75
+ return { goals, obligations, profiles, unclean: opts.expectHistory ? "the state snapshot is missing but the ledger has history" : null };
76
+ }
18
77
  try {
19
78
  const snap = JSON.parse(readFileSync(this.file, "utf8"));
79
+ if (!snap || typeof snap !== "object" || Array.isArray(snap)
80
+ || (snap.goals !== undefined && !Array.isArray(snap.goals))
81
+ || (snap.obligations !== undefined && !Array.isArray(snap.obligations))) {
82
+ throw new Error("not a state snapshot");
83
+ }
20
84
  for (const g of snap.goals ?? [])
21
85
  goals.set(g.id, g);
22
86
  for (const o of snap.obligations ?? []) {
@@ -24,12 +88,32 @@ export class StateStore {
24
88
  }
25
89
  for (const p of snap.profiles ?? [])
26
90
  profiles.set(p.member, p);
91
+ const known = new Set(["v", "goals", "obligations", "profiles", "refusals", "ledger_head", "rebuilt", "saved_at"]);
92
+ this.extra = {};
93
+ for (const [k, v] of Object.entries(snap))
94
+ if (!known.has(k))
95
+ this.extra[k] = v;
27
96
  if (snap.refusals)
28
97
  this.refusals = { total: snap.refusals.total ?? 0, by_code: snap.refusals.by_code ?? {}, recent: snap.refusals.recent ?? [], stamps: snap.refusals.stamps ?? [] };
98
+ return {
99
+ goals, obligations, profiles, unclean: null,
100
+ ...(typeof snap.ledger_head === "number" && Number.isFinite(snap.ledger_head) ? { ledgerHead: snap.ledger_head } : {}),
101
+ ...(typeof snap.rebuilt === "string" && snap.rebuilt ? { rebuilt: snap.rebuilt } : {}),
102
+ };
29
103
  }
30
- catch {
104
+ catch (e) {
105
+ const why = `the state snapshot could not be read (${e.message})`;
106
+ let quarantined;
107
+ if (opts.quarantine) {
108
+ const aside = `${this.file}.corrupt-${Date.now()}`;
109
+ try {
110
+ renameSync(this.file, aside);
111
+ quarantined = aside;
112
+ }
113
+ catch { }
114
+ }
115
+ return { goals: new Map(), obligations: new Map(), profiles: new Map(), unclean: why, ...(quarantined ? { quarantined } : {}) };
31
116
  }
32
- return { goals, obligations, profiles };
33
117
  }
34
118
  save(goals, obligations, profiles) {
35
119
  this.dirty = true;
@@ -52,17 +136,17 @@ export class StateStore {
52
136
  onError;
53
137
  flush(goals, obligations, profiles) {
54
138
  const snap = {
139
+ ...this.extra,
55
140
  v: 2,
56
141
  goals: [...goals.values()],
57
142
  obligations: [...obligations.values()],
58
143
  profiles: [...profiles.values()],
59
144
  ...(this.refusals.total ? { refusals: this.refusals } : {}),
145
+ ...(this.headOf ? { ledger_head: this.headOf() } : {}),
146
+ ...(this.rebuilt ? { rebuilt: this.rebuilt } : {}),
60
147
  saved_at: Date.now(),
61
148
  };
62
- mkdirSync(dirname(this.file), { recursive: true });
63
- const tmp = `${this.file}.tmp`;
64
- writeFileSync(tmp, JSON.stringify(snap, null, 2) + "\n", { mode: 0o600 });
65
- renameSync(tmp, this.file);
149
+ writeFileAtomic(this.file, JSON.stringify(snap, null, 2) + "\n");
66
150
  }
67
151
  close(goals, obligations, profiles) {
68
152
  if (this.timer) {
@@ -0,0 +1,74 @@
1
+ export const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
2
+ export const parseJsonText = (text) => JSON.parse(stripBom(text));
3
+ export function stripJsonc(text) {
4
+ let out = "";
5
+ let i = 0;
6
+ const n = text.length;
7
+ while (i < n) {
8
+ const ch = text[i];
9
+ if (ch === '"') {
10
+ let j = i + 1;
11
+ while (j < n && text[j] !== '"')
12
+ j += text[j] === "\\" ? 2 : 1;
13
+ out += text.slice(i, j + 1);
14
+ i = j + 1;
15
+ continue;
16
+ }
17
+ if (ch === "/" && text[i + 1] === "/") {
18
+ while (i < n && text[i] !== "\n")
19
+ i++;
20
+ continue;
21
+ }
22
+ if (ch === "/" && text[i + 1] === "*") {
23
+ const end = text.indexOf("*/", i + 2);
24
+ i = end < 0 ? n : end + 2;
25
+ continue;
26
+ }
27
+ if (ch === ",") {
28
+ let j = i + 1;
29
+ for (;;) {
30
+ while (j < n && /\s/.test(text[j]))
31
+ j++;
32
+ if (text[j] === "/" && text[j + 1] === "/") {
33
+ while (j < n && text[j] !== "\n")
34
+ j++;
35
+ continue;
36
+ }
37
+ if (text[j] === "/" && text[j + 1] === "*") {
38
+ const e = text.indexOf("*/", j + 2);
39
+ j = e < 0 ? n : e + 2;
40
+ continue;
41
+ }
42
+ break;
43
+ }
44
+ if (text[j] === "}" || text[j] === "]") {
45
+ i++;
46
+ continue;
47
+ }
48
+ }
49
+ out += ch;
50
+ i++;
51
+ }
52
+ return out;
53
+ }
54
+ export function isJsonc(text) {
55
+ const t = stripBom(text);
56
+ try {
57
+ JSON.parse(t);
58
+ return false;
59
+ }
60
+ catch { }
61
+ try {
62
+ JSON.parse(stripJsonc(t));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function eolOf(text) {
70
+ const i = text.indexOf("\n");
71
+ return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n";
72
+ }
73
+ export const withEol = (text, eol) => eol === "\n" ? text : text.replace(/\r?\n/g, "\r\n");
74
+ //# sourceMappingURL=text-file.js.map
package/dist/tray.js CHANGED
@@ -11,6 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
11
11
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
12
12
  import { VERSION } from "./version.js";
13
13
  import { LIMITS } from "./protocol.js";
14
+ import { signedRead } from "./reads.js";
14
15
  import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, waitingPrompt, workerSummary } from "./decision-ui.js";
15
16
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
16
17
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -75,6 +76,15 @@ async function getJson(url) {
75
76
  clearTimeout(t);
76
77
  }
77
78
  }
79
+ async function signedObligations(cfg) {
80
+ try {
81
+ const res = await signedRead(cfg, "/obligations", {}, { timeoutMs: LIMITS.pendingTimeoutMs });
82
+ return res.ok ? await res.json() : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
78
88
  export async function trayStatus(cfg = load(), f = loadAll()) {
79
89
  const ms = Object.values(f?.spaces ?? {});
80
90
  if (f && ms.length > 1) {
@@ -105,10 +115,9 @@ async function trayStatusOne(cfg) {
105
115
  if (!cfg)
106
116
  return { configured: false, version: VERSION };
107
117
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
108
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
109
118
  const [health, obligations] = await Promise.all([
110
119
  getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
111
- getJson(`${base}/obligations?${q}`),
120
+ signedObligations(cfg),
112
121
  ]);
113
122
  const space = health
114
123
  ?.spaces?.find((s) => s.name === cfg.space);
@@ -20,6 +20,8 @@ export function updateStatus(deps) {
20
20
  if (cache && deps.now() - cache.checkedAt < deps.throttleMs) {
21
21
  return { behind: cache.behind, refreshed: false, latest: cache.latest };
22
22
  }
23
+ if (process.env["PANTHEON_NO_UPDATE_CHECK"])
24
+ return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
23
25
  if (deps.isGit()) {
24
26
  try {
25
27
  const behind = deps.fetchBehind();