@join-pantheon/cli 0.31.2 → 0.32.0-rc.2

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 CHANGED
@@ -325,7 +325,10 @@ pantheon service install menubar
325
325
 
326
326
  It is a thin shell: every fact comes from `pantheon tray-status` and every action runs a
327
327
  pantheon command, so it holds no state, no network of its own, and no credentials. Like the
328
- notification HUD it ships as Swift source and compiles locally at install.
328
+ notification HUD it ships as Swift source plus a universal (Apple silicon and Intel), ad-hoc
329
+ signed binary built from exactly that source at publish time, so installing it needs no Xcode
330
+ Command Line Tools. Only if the shipped binary does not match its source, or does not run on
331
+ this Mac, is it compiled locally — and that alone needs the Command Line Tools.
329
332
 
330
333
  Human decisions use a separate, model-free path. `ESCALATE` creates a durable
331
334
  `HUMAN` obligation; install native desktop alerts with:
@@ -3,7 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { writeInside } from "./safe-write.js";
5
5
  import { homedir, platform, tmpdir } from "node:os";
6
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
7
+ import { expectedLauncher } from "./managed.js";
7
8
  import { agentSpawn } from "./which.js";
8
9
  export const NO_CLAUDE_FEATURES = { version: null, permissionPrompts: false, settings: false, settingSources: false, disallowedTools: false };
9
10
  export function parseClaudeHelp(help, version = null) {
@@ -139,7 +140,7 @@ function userExcludedCommands(file) {
139
140
  }
140
141
  }
141
142
  const SECRET_FILE_GLOBS = [".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*", ".npmrc", ".netrc", "credentials*", "*.keystore", "service-account*.json"];
142
- const PANTHEON_HOME_ENTRIES = ["config.json", "config.json.bak", "config.json.lock", "worker-policy.json", "profile.json", "continued.json", "bin", "ops", "run-homes", "logs", "mcp", "data"];
143
+ const PANTHEON_HOME_ENTRIES = ["config.json", "config.json.bak", "config.json.lock", "worker-policy.json", "profile.json", "continued.json", "bin", "runtime", "ops", "run-homes", "logs", "mcp", "data"];
143
144
  function aroundWorktree(top, keeps, named) {
144
145
  const keep = (typeof keeps === "string" ? [keeps] : [...keeps]).map((k) => resolve(k));
145
146
  const out = [];
@@ -167,6 +168,14 @@ function aroundWorktree(top, keeps, named) {
167
168
  walk(top, true);
168
169
  return out;
169
170
  }
171
+ function managedTargets(home, os) {
172
+ const launcherAt = expectedLauncher(home, os);
173
+ if (!launcherAt)
174
+ return [];
175
+ const P = os === "win32" ? win32 : posix;
176
+ const bin = P.dirname(launcherAt);
177
+ return [{ abs: bin, dir: true }, { abs: P.join(P.dirname(bin), "runtime"), dir: true }];
178
+ }
170
179
  export function claudeRunSettings(i) {
171
180
  const home = resolve(i.home ?? homedir());
172
181
  const os = i.os ?? platform();
@@ -176,6 +185,7 @@ export function claudeRunSettings(i) {
176
185
  ...SECRET_HOME_PATHS.map((p) => ({ abs: join(home, ...p.rel.split("/")), dir: p.dir })),
177
186
  { abs: resolve(i.pantheonHome), dir: true },
178
187
  { abs: resolve(i.runHome), dir: true },
188
+ ...managedTargets(home, os),
179
189
  ];
180
190
  const seen = new Set();
181
191
  const denied = [];
package/dist/cli.js CHANGED
@@ -47,6 +47,7 @@ import { connectFailure, signInHint } from "./refusal.js";
47
47
  import * as mcpWindows from "./mcp-windows.js";
48
48
  import { updateBanner } from "./update-check.js";
49
49
  import { inWokenRun } from "./worker-policy.js";
50
+ import { joinInstructions, managedLayout, relayServesInstaller } from "./managed.js";
50
51
  const argv = process.argv.slice(2);
51
52
  for (let i = argv.length - 1; i >= 1; i--) {
52
53
  const m = /^(--[a-z][a-z0-9-]*)=(.*)$/i.exec(argv[i]);
@@ -565,14 +566,16 @@ function windowsOverridesForBoundRepos() {
565
566
  for (const r of windowsRepoOverrides(dirs))
566
567
  console.error(` ${D("claude code in")} ${r.dir} ${D(r.status)}`);
567
568
  }
568
- function printJoinSteps(invite) {
569
+ async function printJoinSteps(invite, relay) {
570
+ const j = joinInstructions(invite, relay, await relayServesInstaller(relay));
569
571
  console.error(`\n${B("For a teammate who'd rather not type commands, forward this whole block:")}\n`);
570
- console.error(D(` You're invited to my Pantheon team. Paste these two lines into a terminal`));
571
- console.error(D(` (Terminal on a Mac, Command Prompt on Windows), one at a time:`));
572
- console.error(` ${C("npm i -g @join-pantheon/cli")}`);
573
- console.error(` ${C(`pantheon join ${invite}`)}`);
574
- console.error(D(` The second line asks for your email, sends you a 6-digit code, and sets up`));
575
- console.error(D(` the coding agents on your computer. Needs Node.js 22.13 or newer: nodejs.org.`));
572
+ console.error(D(` ${j.intro}`));
573
+ for (const s of j.steps) {
574
+ console.error(D(` ${s.label}`));
575
+ for (const l of s.lines)
576
+ console.error(` ${C(l)}`);
577
+ }
578
+ console.error(D(` ${j.outro}`));
576
579
  }
577
580
  const HUD_ACTION_MAX_AGE_MS = 30_000;
578
581
  function unattendedProject() {
@@ -917,6 +920,7 @@ ${B("control")}
917
920
  (auto-detects claude / codex / cursor / copilot / kiro on this machine)
918
921
  pantheon spend woken runs on this machine, and what pantheon cannot see
919
922
  pantheon leave remove what pantheon put on this machine
923
+ pantheon uninstall leave, then remove Pantheon itself: its own Node, the pantheon command, the PATH lines
920
924
  pantheon panic emergency stop: ends the run in flight and the worker, and switches off wake rights in every team you are in
921
925
  pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
922
926
  pantheon network [--proxy <url> | --direct | --clear] how this machine reaches the relay; save a proxy for every Pantheon process here
@@ -973,6 +977,7 @@ const COMMAND_FLAGS = {
973
977
  history: "--goal <id> --from <member> --last <n> --since <seq> --short --presence --json",
974
978
  network: "--proxy <url> [--no-proxy <hosts>] | --direct | --clear",
975
979
  leave: "[<team>] --yes --keep-identity --stop-relay --project <dir>",
980
+ uninstall: "--yes --keep-identity --stop-relay --project <dir>",
976
981
  stop: "[relay|worker|notifier|menubar|all] --port <n>",
977
982
  logs: "[relay|worker|notifier|menubar] --follow --n <lines>",
978
983
  worker: "--agent <claude-code|codex|cursor|copilot|kiro|auto> --persona <name> --project <dir> --allow-bash --max-runs <n> --max-runs-day <n> --grace <s> --once",
@@ -1074,7 +1079,7 @@ async function main() {
1074
1079
  if (helpAt.some((a) => a === "--help" || a === "-h")) {
1075
1080
  const u = usageFor(cmd, HELP_ALL);
1076
1081
  const flags = COMMAND_FLAGS[cmd];
1077
- const teamCmd = !["start", "join", "signup", "login", "verify", "whoami", "relay", "help", "version", "upgrade", "network", "ps", "stop", "service", "hook", "mcp", "pending", "tray-status"].includes(cmd);
1082
+ const teamCmd = !["start", "join", "signup", "login", "verify", "whoami", "relay", "help", "version", "upgrade", "network", "ps", "stop", "service", "hook", "mcp", "pending", "tray-status", "uninstall"].includes(cmd);
1078
1083
  const shown = flags && teamCmd && !/--space/.test(flags) ? `${flags}\n --space <team> (the team to act on, when this folder is bound to another or none)` : flags;
1079
1084
  console.log(u || shown ? `${u ?? ` pantheon ${cmd}`}${shown ? `\n\n${B("flags")}\n ${shown}` : ""}\n\n${D("nothing was run · all commands: pantheon help --all")}` : HELP);
1080
1085
  return;
@@ -1188,7 +1193,7 @@ async function main() {
1188
1193
  console.error(r.inviteMayWake
1189
1194
  ? ` ${B("It also lets them wake workers:")} ${D("their requests can start an agent run on this machine while you are away (you chose wake \"anyone\"). Take it back: pantheon admit <name> --revoke")}`
1190
1195
  : D(` It lets them join and talk to your agent. It does not let them wake this machine on its own: the first time their request needs it while you are away, you are asked (Always / Just this once / No). Approve ahead: pantheon wake allow <name>`));
1191
- printJoinSteps(link ?? r.invite);
1196
+ await printJoinSteps(link ?? r.invite, r.cfg?.relay);
1192
1197
  }
1193
1198
  if (!r.reach.shareable && r.reach.hint) {
1194
1199
  console.error(`\n\x1b[33m That address only works on this machine.\x1b[0m`);
@@ -1250,7 +1255,7 @@ async function main() {
1250
1255
  console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
1251
1256
  if (inv.note)
1252
1257
  console.error(`\x1b[33m ! ${inv.note}\x1b[0m`);
1253
- printJoinSteps(link ?? raw);
1258
+ await printJoinSteps(link ?? raw, cfg.relay);
1254
1259
  client.close();
1255
1260
  return;
1256
1261
  }
@@ -1279,6 +1284,7 @@ async function main() {
1279
1284
  dataDir: resolveDataDir(flag("data")).dir,
1280
1285
  });
1281
1286
  const { port, host } = await relay.listen();
1287
+ relay.startInstallerCheck();
1282
1288
  console.error(`${B("pantheon relay")} listening on ${C(`ws://${host}:${port}`)}`);
1283
1289
  console.error(D(`ledger: ${resolveDataDir(flag("data")).dir}`));
1284
1290
  console.error(D(`health: http://localhost:${port}/health`));
@@ -1521,7 +1527,7 @@ async function main() {
1521
1527
  for (const s of r.steps)
1522
1528
  console.error(` ${s.ok ? C("ok") : "\x1b[31mFAIL\x1b[0m"} ${s.name.padEnd(6)} ${s.detail}`);
1523
1529
  if (!r.ok) {
1524
- console.error(`\n${B("upgrade stopped")} — nothing was changed. Fix the above and run \`pantheon upgrade\` again.`);
1530
+ console.error(`\n${B("upgrade stopped")} — nothing was changed.${r.needsPerson ? "" : " Fix the above and run `pantheon upgrade` again."}`);
1525
1531
  process.exit(1);
1526
1532
  }
1527
1533
  if (r.alreadyCurrent) {
@@ -2394,8 +2400,17 @@ async function main() {
2394
2400
  console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} folder binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
2395
2401
  return;
2396
2402
  }
2403
+ case "uninstall":
2397
2404
  case "leave": {
2398
- if (argv[1] && !argv[1].startsWith("--")) {
2405
+ const uninstalling = cmd === "uninstall";
2406
+ const self = uninstalling ? "uninstall" : "leave";
2407
+ if (uninstalling) {
2408
+ if (inWokenRun())
2409
+ die("a woken run cannot uninstall Pantheon — a person has to run this here");
2410
+ if (argv[1] && !argv[1].startsWith("--"))
2411
+ die(`pantheon uninstall takes no team. To drop just one: pantheon leave ${argv[1]}`);
2412
+ }
2413
+ if (!uninstalling && argv[1] && !argv[1].startsWith("--")) {
2399
2414
  const all = loadAll() ?? die("not configured");
2400
2415
  const m = findMembership(all, argv[1]) ?? die(`you are not in a team called "${argv[1]}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
2401
2416
  const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).map((b) => b.origin ?? b.path);
@@ -2431,14 +2446,29 @@ async function main() {
2431
2446
  const stranded = [...standing].filter(([, v]) => v.founder && v.others?.length === 0).map(([k]) => k);
2432
2447
  if (stranded.length && !argv.includes("--yes")) {
2433
2448
  die(`you founded ${stranded.map((t) => `"${t}"`).join(", ")} and nobody else is in ${stranded.length === 1 ? "it" : "them"}. Leaving keeps ${stranded.length === 1 ? "it" : "them"} on the relay — holding your plan's team slot — and takes away the only way to delete ${stranded.length === 1 ? "it" : "them"}.\n` +
2434
- ` Delete first: ${stranded.map((t) => `pantheon space delete ${t} --yes`).join(" · ")}\n Leave anyway: pantheon leave --yes`);
2449
+ ` Delete first: ${stranded.map((t) => `pantheon space delete ${t} --yes`).join(" · ")}\n Leave anyway: pantheon ${self} --yes`);
2435
2450
  }
2436
2451
  const orphaned = [...standing].filter(([, v]) => v.founder && (v.others?.length ?? 0) > 0).map(([k]) => k);
2437
2452
  if (orphaned.length && !argv.includes("--yes")) {
2438
2453
  die(`you founded ${orphaned.map((t) => `"${t}"`).join(", ")}, and others are in ${orphaned.length === 1 ? "it" : "them"}. Leaving removes this machine's key: you could not rejoin as yourself, and only a founder can remove a member or delete a team — nobody there could, ever again.\n` +
2439
- ` To step out but keep the way back: ${orphaned.map((t) => `pantheon leave ${t}`).join(" · ")} (keeps your key)\n Leave anyway: pantheon leave --yes`);
2454
+ ` To step out but keep the way back: ${orphaned.map((t) => `pantheon leave ${t}`).join(" · ")} (keeps your key)\n Leave anyway: pantheon ${self} --yes`);
2455
+ }
2456
+ const { managedInstall, removeManagedInstall, NPM_UNINSTALL } = await import("./uninstall.js");
2457
+ const install = uninstalling ? managedInstall(homedir()) : null;
2458
+ const runningManaged = managedLayout();
2459
+ if (uninstalling && !argv.includes("--yes")) {
2460
+ const teams = plan.teams.length && !keepIdentity ? `leaves ${plan.teams.map((t) => `"${t.name}"`).join(", ")} (this machine's key goes), ` : "";
2461
+ const what = `this removes Pantheon from this machine: it ${teams}uninstalls its services, hooks and agent entries` +
2462
+ (install?.present ? `, and deletes Pantheon's own Node in ${install.runtime}, the pantheon command and the PATH lines the installer added.` : ".");
2463
+ if (!process.stdin.isTTY)
2464
+ die(`${what}\n If you mean it: pantheon uninstall --yes`);
2465
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
2466
+ const answer = (await questionOrNull(rl, `${what[0].toUpperCase()}${what.slice(1)} Type "yes" to continue: `)) ?? "";
2467
+ rl.close();
2468
+ if (answer.toLowerCase() !== "yes")
2469
+ die("nothing was changed.");
2440
2470
  }
2441
- if (plan.teams.length && !keepIdentity && !argv.includes("--yes")) {
2471
+ if (!uninstalling && plan.teams.length && !keepIdentity && !argv.includes("--yes")) {
2442
2472
  const list = plan.teams.map((t) => `"${t.name}"`).join(", ");
2443
2473
  const what = plan.teams.length > 1 ? `this machine is in ${plan.teams.length} teams — ${list}. Leaving removes its key, so it leaves ALL of them.` : `this removes Pantheon and this machine's key, so it leaves ${list}.`;
2444
2474
  if (!process.stdin.isTTY)
@@ -2449,7 +2479,7 @@ async function main() {
2449
2479
  if (answer.toLowerCase() !== "yes")
2450
2480
  die(`nothing was changed.${plan.teams.length > 1 ? " Leave one team with: pantheon leave <team>" : ""}`);
2451
2481
  }
2452
- console.error(`${B("leaving pantheon")}${plan.teams.length ? D(` — ${plan.teams.map((t) => t.name).join(", ")}`) : ""}\n`);
2482
+ console.error(`${B(uninstalling ? "uninstalling pantheon" : "leaving pantheon")}${plan.teams.length ? D(` — ${plan.teams.map((t) => t.name).join(", ")}`) : ""}\n`);
2453
2483
  const { uninstallMachine, realUninstallIo } = await import("./machine.js");
2454
2484
  const { removeUserMcpEntries } = await import("./install.js");
2455
2485
  const { killRecordedRun } = await import("./worker.js");
@@ -2529,10 +2559,19 @@ async function main() {
2529
2559
  rmSync(join(CONFIG_DIR, "worktrees"), { recursive: true, force: true });
2530
2560
  }
2531
2561
  catch { }
2532
- const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity);
2562
+ const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling });
2533
2563
  removed.push(...rest.removed);
2534
2564
  left.push(...rest.left);
2535
2565
  }
2566
+ if (uninstalling) {
2567
+ if (install?.present) {
2568
+ const r = removeManagedInstall({ home: homedir(), install, running: runningManaged });
2569
+ removed.push(...r.removed);
2570
+ left.push(...r.left);
2571
+ }
2572
+ if (!runningManaged)
2573
+ left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
2574
+ }
2536
2575
  console.error(`\n${B("Removed")}`);
2537
2576
  for (const r of removed)
2538
2577
  console.error(` ${C("✓")} ${r}`);
package/dist/doctor.js CHANGED
@@ -8,9 +8,11 @@ import { FEEDBACK_ADDRESS } from "./feedback.js";
8
8
  import { loadProfile, profileInitialized, reservedDomains, teamProfile, wakeMode } from "./profile.js";
9
9
  import { isApprovedWaker, loadPolicy, relayKnowsAdmitters, unsandboxedAllowed } from "./worker-policy.js";
10
10
  import { commandState } from "./command-state.js";
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
12
  import { homedir, platform } from "node:os";
13
- import { join, resolve } from "node:path";
13
+ import { dirname, join, resolve, sep } from "node:path";
14
+ import { expectedLauncher, managedLayout, runtimeNodeFit, runtimeVerdict } from "./managed.js";
15
+ import { PKG_NAME } from "./upgrade.js";
14
16
  import { PantheonClient } from "./client.js";
15
17
  import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
16
18
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
@@ -19,11 +21,14 @@ import { hookState, readIntent, agentHookStates } from "./notify.js";
19
21
  import { loadPersonas } from "./persona.js";
20
22
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
21
23
  import { agentById, chooseAgent, describeChoice, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
22
- import { onPathNamed } from "./which.js";
24
+ import { loginShellLookup, npmSpawn, onPathNamed, which, whichWindows } from "./which.js";
23
25
  import { VERSION } from "./version.js";
24
26
  import { serviceInstalled, servicesUnavailable } from "./service.js";
27
+ import { hasPrebuilt } from "./helpers-build.js";
28
+ import { traySourcePath, TRAY_PREBUILT } from "./tray.js";
29
+ import { hudSourcePath, HUD_PREBUILT } from "./notifier.js";
25
30
  import { hostEnv, platformName } from "./host-env.js";
26
- import { currentPlan, explainNetError, routeFor, redactProxy } from "./network.js";
31
+ import { currentPlan, explainNetError, proxyEnvFor, routeFor, redactProxy } from "./network.js";
27
32
  import { refusalOf } from "./refusal.js";
28
33
  const HOSTED_RELAY = "relay.joinpantheon.network";
29
34
  const git = (args, cwd) => {
@@ -109,12 +114,64 @@ export function nodeSupported(version) {
109
114
  const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
110
115
  return major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
111
116
  }
117
+ function runtimeFacts() {
118
+ const os = platform();
119
+ const layout = managedLayout(process.execPath, os);
120
+ const expected = expectedLauncher(homedir(), os);
121
+ const root = layout?.root ?? (expected ? dirname(dirname(expected)) : null);
122
+ const real = (p) => { try {
123
+ return realpathSync(p);
124
+ }
125
+ catch {
126
+ return resolve(p);
127
+ } };
128
+ const rootReal = root ? real(root) : null;
129
+ const ours = (p) => {
130
+ if (!rootReal)
131
+ return false;
132
+ const r = real(p);
133
+ return r === rootReal || r.startsWith(rootReal + sep);
134
+ };
135
+ const firstOnPath = os === "win32"
136
+ ? [whichWindows("pantheon", process.env)]
137
+ : [which("pantheon", os, process.env, { login: () => null }), loginShellLookup("pantheon")?.found ?? null];
138
+ return { layout, nodeVersion: process.versions.node, expected, expectedExists: !!expected && existsSync(expected), firstOnPath, ours };
139
+ }
140
+ function newestRelease() {
141
+ if (process.env["PANTHEON_NO_UPDATE_CHECK"])
142
+ return null;
143
+ try {
144
+ const s = npmSpawn(["view", PKG_NAME, "version", "engines.node", "--json"], undefined, undefined, proxyEnvFor());
145
+ const j = JSON.parse(execFileSync(s.command, s.args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000, ...s.options }));
146
+ return typeof j.version === "string" && typeof j["engines.node"] === "string" ? { version: j.version, range: j["engines.node"] } : null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
112
152
  export async function doctor(projectDir) {
113
153
  const out = [];
114
154
  const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
115
155
  nodeSupported(process.versions.node)
116
156
  ? add("pass", "node", `v${process.versions.node}`, undefined, `v${process.versions.node}`)
117
157
  : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22.13 or newer — node:sqlite, which the relay uses, is behind a flag through 22.12 — install a current Node LTS and re-run", `v${process.versions.node}, too old`);
158
+ {
159
+ const facts = runtimeFacts();
160
+ const v = runtimeVerdict(facts);
161
+ if (v)
162
+ add(v.level, "runtime", v.detail, v.fix, v.share);
163
+ if (facts.layout) {
164
+ const relay = (() => { try {
165
+ return load()?.relay ?? null;
166
+ }
167
+ catch {
168
+ return null;
169
+ } })();
170
+ const fit = runtimeNodeFit(facts.layout, process.versions.node, newestRelease(), relay);
171
+ if (fit)
172
+ add(fit.level, "runtime node", fit.detail, fit.fix, fit.share);
173
+ }
174
+ }
118
175
  const host = hostEnv();
119
176
  if (host.ephemeral) {
120
177
  add("warn", "machine", `${platformName(host.platform)} in ${host.kind === "container" ? "a container" : "a cloud workspace"} — ${host.evidence.join("; ")}`, `this is not the computer you work on, which is why it reports ${platformName(host.platform)} to someone who may be sitting at a Mac. The team and your account are fine here, but the menu bar, desktop alerts and the worker belong on your own machine — run the setup from a terminal there.`, `${host.platform} in ${host.kind === "container" ? "a container" : "a cloud workspace"}`);
@@ -489,8 +546,9 @@ export async function doctor(projectDir) {
489
546
  }
490
547
  }
491
548
  if (host.desktop) {
549
+ const needsTools = (name, source) => hasPrebuilt(name, source) ? "" : "needs the Xcode Command Line Tools — xcode-select --install — because this build has no prebuilt helper for it; ";
492
550
  if (!serviceInstalled("menubar")) {
493
- add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice", "not installed");
551
+ add("warn", "menu bar", "not installed on a Mac that can run it", `install it with: pantheon service install menubar (${needsTools(TRAY_PREBUILT, traySourcePath())}from a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice`, "not installed");
494
552
  }
495
553
  else if (!menubarProc().running) {
496
554
  add("warn", "menu bar", "installed, but not running — launchd is not holding it, so there is no icon right now", "pantheon service restart menubar (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
@@ -499,7 +557,7 @@ export async function doctor(projectDir) {
499
557
  add("pass", "menu bar", "installed and running — status, pause/resume, and a badge when a decision needs you", undefined, "installed and running");
500
558
  }
501
559
  if (!serviceInstalled("notifier")) {
502
- add("warn", "alerts", "not installed — no desktop notification when a teammate needs you", "pantheon service install notifier (--no-notifications at setup also leaves it off; that is fine if it was your choice)", "not installed");
560
+ add("warn", "alerts", "not installed — no desktop notification when a teammate needs you", `pantheon service install notifier (${needsTools(HUD_PREBUILT, hudSourcePath())}--no-notifications at setup also leaves it off; that is fine if it was your choice)`, "not installed");
503
561
  }
504
562
  else if (!notifierProc().running) {
505
563
  add("warn", "alerts", "installed, but not running — nothing on this Mac will show you a teammate's message", "pantheon service restart notifier (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
@@ -1,6 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  export const helperHashPath = (binary) => `${binary}.source.sha256`;
7
+ const HERE = dirname(fileURLToPath(import.meta.url));
8
+ export const prebuiltDir = () => resolve(HERE, "..", "helper", "macos", "prebuilt");
4
9
  export function sourceHash(source) {
5
10
  return createHash("sha256").update(readFileSync(source)).digest("hex");
6
11
  }
@@ -20,4 +25,67 @@ export function helperStale(binary, source) {
20
25
  return true;
21
26
  }
22
27
  }
28
+ export function findPrebuilt(name, source, dir = prebuiltDir()) {
29
+ let manifest;
30
+ try {
31
+ manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
32
+ }
33
+ catch {
34
+ return { reason: "this Pantheon build carries no prebuilt helpers" };
35
+ }
36
+ const entry = manifest?.helpers?.[name];
37
+ if (!entry)
38
+ return { reason: `no prebuilt ${name} in this build` };
39
+ if (entry.source_sha256 !== sourceHash(source))
40
+ return { reason: `the prebuilt ${name} was built from a different ${entry.source} than this release ships` };
41
+ const path = join(dir, name);
42
+ try {
43
+ if (createHash("sha256").update(readFileSync(path)).digest("hex") !== entry.sha256)
44
+ return { reason: `the prebuilt ${name} does not match its manifest` };
45
+ }
46
+ catch {
47
+ return { reason: `the prebuilt ${name} is missing` };
48
+ }
49
+ return { path };
50
+ }
51
+ export const hasPrebuilt = (name, source, dir = prebuiltDir()) => existsSync(source) && "path" in findPrebuilt(name, source, dir);
52
+ export function requireDeveloperTools(what, service, why) {
53
+ try {
54
+ execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
55
+ }
56
+ catch {
57
+ throw new Error(`building ${what} needs the Xcode Command Line Tools${why ? ` (${why}, so it is compiled here)` : ""} — run \`xcode-select --install\`, then \`pantheon service install ${service}\``);
58
+ }
59
+ }
60
+ export function installHelper(o, deps = {}) {
61
+ const found = findPrebuilt(o.name, o.source, deps.prebuiltDir ?? prebuiltDir());
62
+ let why = "reason" in found ? found.reason : "";
63
+ if ("path" in found) {
64
+ const tmp = `${o.binary}.${process.pid}.new`;
65
+ try {
66
+ copyFileSync(found.path, tmp);
67
+ chmodSync(tmp, 0o755);
68
+ try {
69
+ execFileSync(deps.xattr ?? "/usr/bin/xattr", ["-d", "com.apple.quarantine", tmp], { stdio: "pipe" });
70
+ }
71
+ catch { }
72
+ execFileSync(tmp, ["version"], { stdio: "pipe", timeout: 10_000 });
73
+ renameSync(tmp, o.binary);
74
+ recordHelperBuild(o.binary, o.source);
75
+ return { binary: o.binary, via: "prebuilt" };
76
+ }
77
+ catch (e) {
78
+ try {
79
+ rmSync(tmp, { force: true });
80
+ }
81
+ catch { }
82
+ why = `the prebuilt ${o.name} does not run on this Mac (${e.message.split("\n")[0]})`;
83
+ }
84
+ }
85
+ (deps.requireTools ?? requireDeveloperTools)(o.what, o.service, why);
86
+ execFileSync(deps.swiftc ?? "/usr/bin/swiftc", ["-O", o.source, "-o", o.binary], { stdio: "pipe" });
87
+ chmodSync(o.binary, 0o755);
88
+ recordHelperBuild(o.binary, o.source);
89
+ return { binary: o.binary, via: "compiled", reason: why };
90
+ }
23
91
  //# sourceMappingURL=helpers-build.js.map
package/dist/install.js CHANGED
@@ -11,17 +11,20 @@ import { agentSpawn, onPathNamed, which } from "./which.js";
11
11
  import { FEEDBACK_ADDRESS } from "./feedback.js";
12
12
  import { kiroDir, kiroAgentFile, refreshKiroAgentAllowed } from "./kiro-files.js";
13
13
  import { eolOf, isJsonc, stripBom, stripJsonc, withEol } from "./text-file.js";
14
+ import { childNodeFlags, managedLayout, managedLauncher } from "./managed.js";
14
15
  const HERE = dirname(fileURLToPath(import.meta.url));
15
16
  export function onPath() {
16
17
  return onPathNamed("pantheon");
17
18
  }
18
- export function launcher(force, os = platform()) {
19
+ export function launcher(force, os = platform(), managed = managedLauncher()) {
19
20
  const portable = force === "portable" || (force !== "local" && onPath());
21
+ if (!portable && managed && os !== "win32")
22
+ return { command: managed, args: ["mcp"], portable: false };
20
23
  if (os === "win32")
21
- return { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"], portable: false };
24
+ return { command: process.execPath, args: [...childNodeFlags(), resolve(HERE, "cli.js"), "mcp"], portable: false };
22
25
  return portable
23
26
  ? { command: "pantheon", args: ["mcp"], portable: true }
24
- : { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"], portable: false };
27
+ : { command: process.execPath, args: [...childNodeFlags(), resolve(HERE, "cli.js"), "mcp"], portable: false };
25
28
  }
26
29
  function readConfigJson(file) {
27
30
  return readConfigDoc(file).doc;
@@ -200,19 +203,29 @@ export function install(projectDir, cfg, force, os = platform(), claudeLocal = c
200
203
  return out;
201
204
  }
202
205
  function localEntry() {
203
- return { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"] };
206
+ return { command: process.execPath, args: [...childNodeFlags(), resolve(HERE, "cli.js"), "mcp"] };
204
207
  }
205
- function userEntry(os = platform()) {
208
+ const realEntryDeps = (os) => ({
209
+ execPath: process.execPath, cli: resolve(HERE, "cli.js"), exists: existsSync, find: (n) => which(n),
210
+ managed: managedLayout(process.execPath, os),
211
+ });
212
+ export function userEntry(os = platform(), d = realEntryDeps(os)) {
213
+ if (d.managed) {
214
+ if (os === "win32" && d.exists(d.managed.cli))
215
+ return { command: d.managed.node, args: ["--use-system-ca", d.managed.cli, "mcp"] };
216
+ if (os !== "win32" && d.exists(d.managed.launcher))
217
+ return { command: d.managed.launcher, args: ["mcp"] };
218
+ }
206
219
  if (os === "win32") {
207
- const node = process.execPath;
208
- const entry = resolve(HERE, "cli.js");
209
- if (existsSync(entry))
220
+ const node = d.execPath;
221
+ const entry = d.cli;
222
+ if (d.exists(entry))
210
223
  return { command: node, args: [entry, "mcp"] };
211
- const abs = which("pantheon");
224
+ const abs = d.find("pantheon");
212
225
  return abs ? { command: abs, args: ["mcp"] } : { command: "pantheon", args: ["mcp"] };
213
226
  }
214
227
  try {
215
- const abs = which("pantheon") ?? "";
228
+ const abs = d.find("pantheon") ?? "";
216
229
  if (abs && !isVersionManagedPath(abs))
217
230
  return { command: abs, args: ["mcp"] };
218
231
  if (abs)
@@ -698,7 +711,8 @@ export function isOurMcpEntry(e) {
698
711
  const base = command.split(/[\\/]/).pop() ?? "";
699
712
  if (/^pantheon(\.cmd|\.exe)?$/i.test(base))
700
713
  return true;
701
- return args.length >= 2 && typeof args[0] === "string" && /[\\/]cli\.js$/.test(args[0]);
714
+ const script = args.find((a) => !(typeof a === "string" && a.startsWith("--")));
715
+ return args.length >= 2 && typeof script === "string" && /[\\/]cli\.js$/.test(script);
702
716
  }
703
717
  export function removeMcpJsonEntry(file, key = "mcpServers") {
704
718
  try {