@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/which.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
3
4
  import { dirname, join, posix, resolve, win32 } from "node:path";
4
5
  import { platform } from "node:os";
@@ -40,35 +41,82 @@ export function whichWindows(name, env = process.env, exists = runnable) {
40
41
  }
41
42
  return null;
42
43
  }
43
- export function which(name, os = platform(), env = process.env) {
44
+ export function which(name, os = platform(), env = process.env, opts = {}) {
44
45
  if (isWindows(os))
45
46
  return whichWindows(name, env);
46
- return loginShellWhich(name) ?? posixOnPath(name, env);
47
+ const ok = opts.runnable ?? runnable;
48
+ const wsl = os === "linux" && (opts.wsl ?? onWsl(env));
49
+ const usable = (p) => ok(p) && !(wsl && isWslWindowsShim(p));
50
+ const login = (opts.login ?? loginShellLookup)(name);
51
+ if (login?.found && usable(login.found))
52
+ return login.found;
53
+ if (login?.found && login.path) {
54
+ const hit = posixOnPath(name, { PATH: login.path }, usable);
55
+ if (hit)
56
+ return hit;
57
+ }
58
+ return posixOnPath(name, env, usable);
47
59
  }
48
- function posixOnPath(name, env) {
60
+ export const isWslWindowsShim = (p) => /^\/mnt\/[a-z]\//i.test(p) && !/\.exe$/i.test(p);
61
+ function onWsl(env) {
62
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP)
63
+ return true;
64
+ try {
65
+ return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function posixOnPath(name, env, ok = runnable) {
49
72
  if (!name)
50
73
  return null;
51
74
  if (name.includes("/"))
52
- return runnable(name) ? name : null;
75
+ return ok(name) ? name : null;
53
76
  for (const dir of (env.PATH ?? "").split(":")) {
54
77
  if (!dir)
55
78
  continue;
56
79
  const full = posix.join(dir, name);
57
- if (runnable(full))
80
+ if (ok(full))
58
81
  return full;
59
82
  }
60
83
  return null;
61
84
  }
62
- function loginShellWhich(name) {
85
+ export function loginShellLookup(name, run = runLoginShell) {
86
+ if (!name)
87
+ return null;
88
+ const mark = `__pantheon_which_${randomBytes(6).toString("hex")}__`;
89
+ let out;
63
90
  try {
64
- const out = execFileSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
65
- if (!out)
66
- return null;
67
- return out.startsWith("/") && !runnable(out) ? null : out;
91
+ out = run(`printf '\\n%s\\n' "$1"; command -v "$2"; printf '\\n%s\\n' "$1"; printf '%s' "$PATH"`, [mark, name]);
68
92
  }
69
93
  catch {
70
94
  return null;
71
95
  }
96
+ return parseLoginAnswer(out, mark);
97
+ }
98
+ export function parseLoginAnswer(out, mark) {
99
+ const open = `\n${mark}\n`;
100
+ const first = out.indexOf(open);
101
+ if (first < 0)
102
+ return null;
103
+ const rest = out.slice(first + open.length);
104
+ const second = rest.indexOf(open);
105
+ const said = (second < 0 ? rest : rest.slice(0, second)).split("\n").map((l) => l.trim()).filter(Boolean);
106
+ const path = second < 0 ? null : rest.slice(second + open.length);
107
+ const found = said.length === 1 && said[0].startsWith("/") ? said[0] : null;
108
+ return { found, path };
109
+ }
110
+ function runLoginShell(script, args) {
111
+ try {
112
+ return execFileSync("sh", ["-lc", script, "sh", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 });
113
+ }
114
+ catch (e) {
115
+ const out = e.stdout;
116
+ if (typeof out === "string" && out)
117
+ return out;
118
+ throw e;
119
+ }
72
120
  }
73
121
  export const onPathNamed = (name, os = platform(), env = process.env) => which(name, os, env) !== null;
74
122
  export function spawnShape(binary, os = platform()) {
@@ -112,8 +160,11 @@ catch {
112
160
  const dir = dirname(shim);
113
161
  const re = /"%(?:~dp0|dp0%)\\?([^"%\r\n]+)"[ \t]*%\*[ \t]*\)?[ \t]*\r?$/gm;
114
162
  let rel = null;
115
- for (let m = re.exec(text); m; m = re.exec(text))
163
+ let before = "";
164
+ for (let m = re.exec(text); m; m = re.exec(text)) {
116
165
  rel = m[1];
166
+ before = text.slice(text.lastIndexOf("\n", m.index) + 1, m.index);
167
+ }
117
168
  if (!rel)
118
169
  return { why: "was read but not recognised as a package manager's shim" };
119
170
  const parts = rel.split(/[\\/]+/).filter(Boolean);
@@ -134,7 +185,13 @@ catch {
134
185
  return { why: `was read, but it runs ${target}, which is not something Pantheon can start without cmd.exe` };
135
186
  }
136
187
  const localNode = join(dir, "node.exe");
137
- return { command: exists(localNode) ? localNode : process.execPath, args: [target] };
188
+ return { command: exists(localNode) ? localNode : process.execPath, args: [...shimNodeFlags(before), target] };
189
+ }
190
+ export function shimNodeFlags(linePrefix) {
191
+ const m = /(?:"%_prog%"|"%~?dp0%?\\?node\.exe"|(?:^|[\s&|(])node(?:\.exe)?)((?:[ \t]+-[^\s"%]+)*)[ \t]*$/i.exec(linePrefix);
192
+ if (!m || !m[1])
193
+ return [];
194
+ return m[1].trim().split(/[ \t]+/).filter((f) => /^--?[A-Za-z0-9][\w.-]*(?:=[^\s"%^&|<>!]*)?$/.test(f));
138
195
  }
139
196
  export function resolveCmdShim(shim, read, exists) {
140
197
  const r = inspectCmdShim(shim, read, exists);
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_PATH } from "./config.js";
4
+ import { trustWrite } from "./home-guard.js";
5
+ import { parseJsonText } from "./text-file.js";
4
6
  export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
5
7
  export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
6
8
  export function policyHome(home) {
@@ -9,19 +11,120 @@ export function policyHome(home) {
9
11
  export function policyPath(home) {
10
12
  return join(policyHome(home), "worker-policy.json");
11
13
  }
14
+ const isRecord = (x) => !!x && typeof x === "object" && !Array.isArray(x);
15
+ function readWakers(x) {
16
+ if (!isRecord(x))
17
+ return undefined;
18
+ const out = {};
19
+ for (const [team, v] of Object.entries(x)) {
20
+ if (!Array.isArray(v))
21
+ continue;
22
+ const ids = v.filter((m) => typeof m === "string" && m.length > 0);
23
+ if (ids.length)
24
+ out[team] = ids;
25
+ }
26
+ return out;
27
+ }
12
28
  export function loadPolicy(home) {
13
29
  try {
14
- const raw = JSON.parse(readFileSync(policyPath(home), "utf8"));
15
- return { grants: raw.grants ?? {}, asks: raw.asks ?? {}, denied: raw.denied ?? {}, attempts: raw.attempts ?? {} };
30
+ const raw = parseJsonText(readFileSync(policyPath(home), "utf8"));
31
+ const obj = (v) => (isRecord(v) ? v : {});
32
+ const wakers = readWakers(raw.wakers);
33
+ return {
34
+ grants: obj(raw.grants), asks: obj(raw.asks), denied: obj(raw.denied), attempts: obj(raw.attempts),
35
+ ...(wakers ? { wakers } : {}),
36
+ ...(isRecord(raw.wakersCarried) ? { wakersCarried: raw.wakersCarried } : {}),
37
+ ...(isRecord(raw.wakersDeferred) ? { wakersDeferred: raw.wakersDeferred } : {}),
38
+ };
16
39
  }
17
40
  catch {
18
41
  return { grants: {}, asks: {}, denied: {}, attempts: {} };
19
42
  }
20
43
  }
44
+ export function setWaker(p, team, member, approved) {
45
+ const w = (p.wakers ??= {});
46
+ const cur = new Set(w[team] ?? []);
47
+ if (approved)
48
+ cur.add(member);
49
+ else
50
+ cur.delete(member);
51
+ if (cur.size)
52
+ w[team] = [...cur].sort();
53
+ else
54
+ delete w[team];
55
+ }
56
+ export const CARRY_REASON = {
57
+ "you-admitted": "you approved them",
58
+ anyone: "you had chosen that anyone may wake this machine",
59
+ "old-default": "wake was never chosen here, and before 0.30 that meant anyone the team lets",
60
+ founder: "the team's founder, and wake was never restricted here",
61
+ };
62
+ export function carryOverWakers(p, team, me, members, choice, now = Date.now()) {
63
+ if (!team || p.wakersCarried?.[team])
64
+ return null;
65
+ const all = members.filter((m) => m.admitted);
66
+ const others = all.filter((m) => m.member !== me);
67
+ const anyone = choice === "anyone" || choice === "unset-before-0.30";
68
+ if (!relayKnowsAdmitters(members, me) && !anyone)
69
+ return "deferred";
70
+ const founder = (all.find((m) => m.founder) ?? all.find((m) => m.admitted_by === m.member))?.member;
71
+ const carried = [];
72
+ for (const m of others) {
73
+ if (!m.may_wake)
74
+ continue;
75
+ const why = m.admitted_by === me ? "you-admitted"
76
+ : choice === "anyone" ? "anyone"
77
+ : choice === "unset-before-0.30" ? "old-default"
78
+ : choice === undefined && m.member === founder ? "founder"
79
+ : null;
80
+ if (why)
81
+ carried.push({ member: m.member, why });
82
+ }
83
+ carried.sort((a, b) => a.member.localeCompare(b.member));
84
+ for (const c of carried)
85
+ setWaker(p, team, c.member, true);
86
+ (p.wakersCarried ??= {})[team] = now;
87
+ if (p.wakersDeferred) {
88
+ delete p.wakersDeferred[team];
89
+ if (!Object.keys(p.wakersDeferred).length)
90
+ delete p.wakersDeferred;
91
+ }
92
+ return carried;
93
+ }
94
+ export const V030_AT = Date.parse("2026-09-25T06:00:00Z");
95
+ export const OLD_RELAY_WAKE = "this relay is older than 0.30, so who may wake this machine is still decided by the team (may_wake) — until it upgrades; then the approvals you had are carried over, and anyone else needs: pantheon wake allow <name>";
96
+ export function relayKnowsAdmitters(members, me) {
97
+ const all = members.filter((m) => m.admitted);
98
+ return !all.some((m) => m.member !== me) || all.some((m) => m.admitted_by !== undefined);
99
+ }
100
+ export function setLocalWaker(teams, member, approved, home, env = process.env) {
101
+ if (inWokenRun(env))
102
+ return { ok: false, why: "a woken run cannot change who may wake this machine — a person has to run this here" };
103
+ const keys = [...new Set(teams.filter((t) => !!t))];
104
+ if (!keys.length || !member)
105
+ return { ok: false, why: "no team to record it under" };
106
+ const p = loadPolicy(home);
107
+ const was = isApprovedWaker(p, keys, member);
108
+ if (approved) {
109
+ if (!was)
110
+ setWaker(p, keys[0], member, true);
111
+ }
112
+ else
113
+ for (const k of keys)
114
+ setWaker(p, k, member, false);
115
+ if (was === approved)
116
+ return { ok: true, changed: false };
117
+ savePolicy(p, home);
118
+ return { ok: true, changed: true };
119
+ }
120
+ export function isApprovedWaker(p, teams, member) {
121
+ return teams.some((t) => { const list = t ? p.wakers?.[t] : undefined; return Array.isArray(list) && list.includes(member); });
122
+ }
21
123
  export function savePolicy(p, home) {
22
124
  const file = policyPath(home);
23
125
  mkdirSync(dirname(file), { recursive: true });
24
126
  writeFileSync(file, JSON.stringify(p, null, 2) + "\n", { mode: 0o600 });
127
+ trustWrite(file);
25
128
  }
26
129
  export function useGrant(p, cap) {
27
130
  const g = p.grants[cap];
@@ -61,6 +164,31 @@ export function parseGrantAnswer(body) {
61
164
  return "deny";
62
165
  return null;
63
166
  }
167
+ export const WOKEN_RUN_ENV = "PANTHEON_WOKEN_RUN";
168
+ export const inWokenRun = (env = process.env) => !!env[WOKEN_RUN_ENV];
169
+ export const CAPABILITY_ARGS = { self_upgrade: "self-upgrade", run_commands: "run-commands" };
170
+ export function applyLocalAnswer(refActId, body, home, env = process.env) {
171
+ if (!refActId)
172
+ return null;
173
+ const p = loadPolicy(home);
174
+ if (!Object.values(p.asks).some((a) => a?.act === refActId))
175
+ return null;
176
+ if (inWokenRun(env))
177
+ return { applied: false, why: "a woken run cannot grant this machine anything — a person has to answer it here" };
178
+ const r = applyAnswer(p, refActId, body);
179
+ if (!r)
180
+ return null;
181
+ savePolicy(p, home);
182
+ return { applied: true, ...r };
183
+ }
184
+ export function describeLocalAnswer(g) {
185
+ if (!g.applied)
186
+ return `not granted: ${g.why}`;
187
+ const name = CAPABILITY_ARGS[g.cap];
188
+ if (g.verdict === "deny")
189
+ return `denied ${name} on this machine — the worker will not ask again for a day`;
190
+ return `granted ${name} on this machine${g.verdict === "allow-once" ? " for its next use" : ""} — revoke: pantheon policy revoke ${name}`;
191
+ }
64
192
  export function applyAnswer(p, refActId, body) {
65
193
  const entry = Object.entries(p.asks).find(([, a]) => a.act === refActId);
66
194
  if (!entry)
@@ -73,9 +201,15 @@ export function applyAnswer(p, refActId, body) {
73
201
  if (verdict === "deny")
74
202
  p.denied[cap] = Date.now();
75
203
  else
76
- grant(p, cap, verdict === "allow-always" ? "always" : "once", "answered-request");
204
+ grant(p, cap, verdict === "allow-always" ? "always" : "once", "answered-here");
77
205
  return { cap, verdict };
78
206
  }
207
+ export function dropLedgerGrants(p) {
208
+ const gone = Object.keys(p.grants).filter((c) => p.grants[c]?.by === "answered-request");
209
+ for (const c of gone)
210
+ delete p.grants[c];
211
+ return gone;
212
+ }
79
213
  export const UPGRADE_RETRY_MS = 60 * 60 * 1_000;
80
214
  export function needsSelfUpgrade(localPkg, relayPkg, lastAttempt, now = Date.now()) {
81
215
  if (!relayPkg || relayPkg === localPkg)