@join-pantheon/cli 0.29.1 → 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.
- package/README.md +45 -22
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +197 -14
- package/dist/claude-confine.js +283 -0
- package/dist/cli.js +725 -120
- package/dist/client.js +37 -2
- package/dist/command-state.js +79 -0
- package/dist/config.js +211 -10
- package/dist/doctor.js +103 -13
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +210 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +24 -0
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +494 -32
- package/dist/mcp-supervisor.js +25 -6
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +25 -10
- package/dist/network.js +39 -8
- package/dist/notify.js +17 -6
- package/dist/pending.js +8 -14
- package/dist/persona.js +21 -0
- package/dist/profile.js +4 -1
- package/dist/quickstart.js +82 -14
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1143 -180
- package/dist/roster-file.js +169 -23
- package/dist/run-failure.js +83 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +84 -34
- package/dist/setup-guide.js +28 -9
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +11 -2
- package/dist/update-check.js +2 -0
- package/dist/which.js +69 -12
- package/dist/worker-policy.js +137 -3
- package/dist/worker.js +702 -81
- package/helper/macos/menubar.swift +15 -1
- package/package.json +2 -2
package/dist/service.js
CHANGED
|
@@ -2,7 +2,7 @@ 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";
|
|
@@ -57,8 +57,44 @@ export function servicesUnavailable(plat = platform()) {
|
|
|
57
57
|
export function plistPath(kind = "worker") {
|
|
58
58
|
return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
|
|
59
59
|
}
|
|
60
|
-
export function
|
|
61
|
-
|
|
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;
|
|
62
98
|
}
|
|
63
99
|
function xml(s) {
|
|
64
100
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -70,16 +106,13 @@ function argvFor(kind, args) {
|
|
|
70
106
|
const abs = whichPantheon();
|
|
71
107
|
return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
|
|
72
108
|
}
|
|
73
|
-
export function whichPantheon() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const p = execFileSync("/usr/bin/which", ["pantheon"], { encoding: "utf8" }).trim();
|
|
78
|
-
return p.startsWith("/") && existsSync(p) ? p : null;
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
109
|
+
export function whichPantheon(find = which) {
|
|
110
|
+
const p = find("pantheon");
|
|
111
|
+
if (!p)
|
|
81
112
|
return null;
|
|
82
|
-
|
|
113
|
+
if (platform() === "win32")
|
|
114
|
+
return p;
|
|
115
|
+
return p.startsWith("/") && existsSync(p) ? p : null;
|
|
83
116
|
}
|
|
84
117
|
export function servicePlist(kind, cwd, logDir, args) {
|
|
85
118
|
const argv = argvFor(kind, args);
|
|
@@ -107,9 +140,15 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
|
107
140
|
`;
|
|
108
141
|
}
|
|
109
142
|
export function systemdQuote(arg) {
|
|
110
|
-
return `"${arg
|
|
143
|
+
return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
|
|
111
144
|
}
|
|
112
|
-
const
|
|
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
|
+
};
|
|
113
152
|
export function systemdUnit(kind, cwd, args, path = servicePath()) {
|
|
114
153
|
const description = kind === "relay"
|
|
115
154
|
? "pantheon relay — the coordination plane for a space"
|
|
@@ -124,7 +163,7 @@ After=network-online.target
|
|
|
124
163
|
Type=simple
|
|
125
164
|
WorkingDirectory=${systemdValue(cwd)}
|
|
126
165
|
ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
|
|
127
|
-
Environment=${
|
|
166
|
+
Environment=${systemdEnvQuote(`PATH=${path}`)}
|
|
128
167
|
Restart=always
|
|
129
168
|
RestartSec=30
|
|
130
169
|
|
|
@@ -147,6 +186,9 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
147
186
|
if (noServices)
|
|
148
187
|
throw new Error(noServices.replace(/^not available — /, ""));
|
|
149
188
|
const out = [];
|
|
189
|
+
let loaded = true;
|
|
190
|
+
let reason;
|
|
191
|
+
const done = () => Object.assign(out, { loaded, ...(reason ? { reason } : {}) });
|
|
150
192
|
const abs = resolve(cwd);
|
|
151
193
|
const logDir = serviceLogDir();
|
|
152
194
|
if (sys.platform === "darwin") {
|
|
@@ -168,8 +210,16 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
168
210
|
out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
|
|
169
211
|
}
|
|
170
212
|
catch (e) {
|
|
171
|
-
|
|
172
|
-
|
|
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}`);
|
|
173
223
|
}
|
|
174
224
|
out.push(` logs: ${join(logDir, `${kind}.log`)}`);
|
|
175
225
|
out.push(` stop: pantheon service uninstall ${kind}`);
|
|
@@ -178,8 +228,8 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
178
228
|
if (kind === "menubar" || kind === "notifier") {
|
|
179
229
|
throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
|
|
180
230
|
}
|
|
181
|
-
const unit = unitPath(kind);
|
|
182
|
-
mkdirSync(
|
|
231
|
+
const unit = unitPath(kind, systemdUserEnv(sys));
|
|
232
|
+
mkdirSync(dirname(unit), { recursive: true });
|
|
183
233
|
writeFileSync(unit, systemdUnit(kind, abs, args));
|
|
184
234
|
out.push(` wrote ${unit}`);
|
|
185
235
|
try {
|
|
@@ -196,17 +246,15 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
196
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"}`);
|
|
197
247
|
}
|
|
198
248
|
}
|
|
199
|
-
return
|
|
249
|
+
return done();
|
|
200
250
|
}
|
|
201
251
|
export function uninstallService(kind, sys = realSys()) {
|
|
202
252
|
const out = [];
|
|
203
253
|
if (sys.platform === "win32") {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
unlinkSync(
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
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`];
|
|
210
258
|
}
|
|
211
259
|
if (sys.platform === "darwin") {
|
|
212
260
|
const p = plistPath(kind);
|
|
@@ -228,8 +276,8 @@ export function uninstallService(kind, sys = realSys()) {
|
|
|
228
276
|
out.push(` removed ${p}`);
|
|
229
277
|
}
|
|
230
278
|
else {
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
279
|
+
const units = unitPaths(kind, systemdUserEnv(sys)).filter((u) => existsSync(u));
|
|
280
|
+
if (!units.length)
|
|
233
281
|
return [` no ${kind} service installed`];
|
|
234
282
|
try {
|
|
235
283
|
sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
|
|
@@ -238,12 +286,14 @@ export function uninstallService(kind, sys = realSys()) {
|
|
|
238
286
|
catch {
|
|
239
287
|
out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
|
|
240
288
|
}
|
|
241
|
-
|
|
289
|
+
for (const u of units)
|
|
290
|
+
unlinkSync(u);
|
|
242
291
|
try {
|
|
243
292
|
sys.exec("systemctl", ["--user", "daemon-reload"]);
|
|
244
293
|
}
|
|
245
294
|
catch { }
|
|
246
|
-
|
|
295
|
+
for (const u of units)
|
|
296
|
+
out.push(` removed ${u}`);
|
|
247
297
|
}
|
|
248
298
|
return out;
|
|
249
299
|
}
|
|
@@ -335,11 +385,11 @@ export function refreshServiceDefinition(kind, plat = platform()) {
|
|
|
335
385
|
}
|
|
336
386
|
if (plat === "win32")
|
|
337
387
|
return { changed: false };
|
|
338
|
-
const u =
|
|
339
|
-
if (!
|
|
388
|
+
const u = existingUnitPath(kind);
|
|
389
|
+
if (!u)
|
|
340
390
|
return { changed: false };
|
|
341
391
|
const unit = readFileSync(u, "utf8");
|
|
342
|
-
if (/^Environment="PATH=/m.test(unit))
|
|
392
|
+
if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit))
|
|
343
393
|
return { changed: false };
|
|
344
394
|
const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
|
|
345
395
|
const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
|
|
@@ -359,7 +409,7 @@ export function serviceInstalled(kind, plat = platform()) {
|
|
|
359
409
|
try {
|
|
360
410
|
if (plat === "win32")
|
|
361
411
|
return false;
|
|
362
|
-
return
|
|
412
|
+
return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
|
|
363
413
|
}
|
|
364
414
|
catch {
|
|
365
415
|
return false;
|
package/dist/setup-guide.js
CHANGED
|
@@ -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
|
|
23
|
-
{ id: "approved", label: "Only people I approve one by one — invites do not grant it; I run `pantheon
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
signedObligations(cfg),
|
|
112
121
|
]);
|
|
113
122
|
const space = health
|
|
114
123
|
?.spaces?.find((s) => s.name === cfg.space);
|
package/dist/update-check.js
CHANGED
|
@@ -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();
|