@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.
- 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 -119
- 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 +507 -38
- 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 +88 -15
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1193 -185
- 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 +105 -36
- 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,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:
|
|
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
|
|
42
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
|
143
|
+
return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
|
|
92
144
|
}
|
|
93
|
-
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
|
+
};
|
|
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=${
|
|
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
|
-
|
|
153
|
-
|
|
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(
|
|
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
|
|
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
|
|
186
|
-
|
|
187
|
-
unlinkSync(
|
|
188
|
-
|
|
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
|
|
213
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
320
|
-
if (!
|
|
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
|
|
412
|
+
return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
|
|
344
413
|
}
|
|
345
414
|
catch {
|
|
346
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();
|