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

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/dist/relay.js CHANGED
@@ -6,6 +6,8 @@ import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
9
+ import { installerScript, InstallerGate } from "./installer.js";
10
+ import { installerLine } from "./managed.js";
9
11
  import { Ledger, SpaceFull } from "./ledger.js";
10
12
  import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } from "./identity.js";
11
13
  import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
@@ -434,6 +436,7 @@ export class Relay {
434
436
  authEpoch = 0;
435
437
  rotateOnRemove;
436
438
  backupTimer;
439
+ installerGate;
437
440
  get features() { return this.opts.features ?? RELAY_FEATURES; }
438
441
  constructor(opts = {}) {
439
442
  this.opts = opts;
@@ -471,6 +474,7 @@ export class Relay {
471
474
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
472
475
  };
473
476
  this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
477
+ this.installerGate = opts.installerGate ?? new InstallerGate({ log: this.log });
474
478
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
475
479
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
476
480
  const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
@@ -510,6 +514,7 @@ export class Relay {
510
514
  }
511
515
  if (this.backupTimer)
512
516
  clearInterval(this.backupTimer);
517
+ this.installerGate.close();
513
518
  if (this.accounts && !this.opts.accounts)
514
519
  this.accounts.close();
515
520
  if (this.heartbeat)
@@ -596,6 +601,12 @@ export class Relay {
596
601
  : {}),
597
602
  });
598
603
  }
604
+ if (url.pathname === "/install.sh" || url.pathname === "/install.ps1") {
605
+ if (req.method !== "GET" && req.method !== "HEAD")
606
+ return this.json(res, 405, { error: "GET only" });
607
+ void this.serveInstaller(req, res, url.pathname.slice(1));
608
+ return;
609
+ }
599
610
  if (url.pathname === "/join") {
600
611
  if (!this.authRateOk(ip))
601
612
  return this.json(res, 429, { error: "too many lookups from your network — wait a minute" });
@@ -730,7 +741,7 @@ export class Relay {
730
741
  }
731
742
  onAuthHttp(req, res, url, ip) {
732
743
  if (url.pathname === "/")
733
- return this.landingPage(res);
744
+ return this.landingPage(req, res);
734
745
  if (url.pathname === "/admin" || url.pathname === "/admin/space" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/action" || url.pathname === "/admin/login" || url.pathname === "/admin/logout" || url.pathname === "/admin/report") {
735
746
  const A0 = this.accounts;
736
747
  if (!A0 || !this.adminToken)
@@ -2318,16 +2329,66 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
2318
2329
  }
2319
2330
  catch { } });
2320
2331
  }
2321
- landingPage(res) {
2332
+ landingPage(req, res) {
2333
+ const host = String(req.headers.host ?? "").replace(/[^A-Za-z0-9.:\[\]-]/g, "");
2334
+ const proto = String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "https" ? "https" : "http";
2335
+ const base = host ? `${proto}://${host}` : null;
2336
+ const sh = installerLine(base, "sh", ["start"]), win = installerLine(base, "windows", ["start"]);
2337
+ const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2322
2338
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
2323
2339
  res.end(`<!doctype html><meta charset=utf-8><title>pantheon</title>
2324
- <style>body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.2rem;color:#222}code{background:#f4f4f5;padding:2px 6px;border-radius:5px}h1{font-size:1.6rem}</style>
2340
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:12vh auto;padding:0 1.2rem;color:#222}code,pre{background:#f4f4f5;border-radius:5px}code{padding:2px 6px}pre{padding:.6rem .8rem;overflow-x:auto}h1{font-size:1.6rem}</style>
2325
2341
  <h1>pantheon</h1>
2326
2342
  <p>Your coding agent and your teammates' agents, coordinating across machines.</p>
2327
- <p>This is a relay endpoint, not a website. To join, install the CLI and sign up:</p>
2343
+ ${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, paste one line into a terminal on your own computer. It brings its own Node, needs no admin rights, and asks for your email and a 6-digit code.</p>
2344
+ <p>Mac or Linux (Terminal):</p>
2345
+ <pre><code>${esc(sh)}</code></pre>
2346
+ <p>Windows (Command Prompt or PowerShell):</p>
2347
+ <pre><code>${esc(win)}</code></pre>
2348
+ <p>Invited to a team? Use the line in your invite instead. Already have Node.js 22.13 or newer: <code>npm install -g @join-pantheon/cli</code>, then <code>pantheon start</code>.</p>` : `<p>This is a relay endpoint, not a website. With Node.js 22.13 or newer, install the CLI and start a team:</p>
2328
2349
  <pre><code>npm install -g @join-pantheon/cli
2329
- pantheon signup --email you@work.com --username you</code></pre>
2330
- <p>You'll get a 6-digit code by email, then <code>pantheon verify &lt;code&gt;</code>.</p>`);
2350
+ pantheon start</code></pre>
2351
+ <p>It asks for your email and sends you a 6-digit code. Invited to a team? Use the line in your invite.</p>`}`);
2352
+ }
2353
+ startInstallerCheck() { this.installerGate.start(); }
2354
+ async serveInstaller(req, res, name) {
2355
+ const plain = (code, body, extra = {}) => {
2356
+ res.writeHead(code, {
2357
+ "content-type": "text/plain; charset=utf-8",
2358
+ "content-length": Buffer.byteLength(body),
2359
+ "cache-control": "no-store",
2360
+ "x-content-type-options": "nosniff",
2361
+ ...extra,
2362
+ });
2363
+ res.end(req.method === "HEAD" ? undefined : body);
2364
+ };
2365
+ try {
2366
+ const choice = await this.installerGate.choose();
2367
+ if (res.writableEnded || res.destroyed)
2368
+ return;
2369
+ if ("unavailable" in choice) {
2370
+ const sentence = `pantheon install failed: ${choice.unavailable}`.replace(/'/g, "");
2371
+ return plain(200, name === "install.sh"
2372
+ ? `#!/bin/sh\nprintf '%s\\n' '${sentence}' >&2\nexit 1\n`
2373
+ : `Write-Host '${sentence}' -ForegroundColor Red\nthrow 'Pantheon was not installed.'\n`, { "retry-after": "120", "x-pantheon-installer": "unavailable" });
2374
+ }
2375
+ const body = installerScript(name, undefined, choice.version);
2376
+ if (body === null) {
2377
+ const sentence = "pantheon install failed: this relay cannot serve the installer (its installer files or version are missing). Install with npm instead: npm install -g @join-pantheon/cli";
2378
+ return plain(200, name === "install.sh" ? `#!/bin/sh\nprintf '%s\\n' '${sentence}' >&2\nexit 1\n` : `Write-Host '${sentence}' -ForegroundColor Red\nthrow 'Pantheon was not installed.'\n`, { "x-pantheon-installer": "unavailable" });
2379
+ }
2380
+ plain(200, body, { "x-pantheon-installs": choice.version });
2381
+ }
2382
+ catch (e) {
2383
+ this.log(`! ${name} failed: ${e?.message ?? e}`);
2384
+ try {
2385
+ if (!res.headersSent)
2386
+ this.json(res, 500, { error: "the relay could not handle that request" });
2387
+ else
2388
+ res.end();
2389
+ }
2390
+ catch { }
2391
+ }
2331
2392
  }
2332
2393
  json(res, code, body) {
2333
2394
  const s = JSON.stringify(body);
package/dist/service.js CHANGED
@@ -2,9 +2,10 @@ 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, isAbsolute, join, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, posix, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { launcher } from "./install.js";
8
+ import { childNodeFlags, managedLauncher, managedLayout } from "./managed.js";
8
9
  import { CONFIG_DIR } from "./config.js";
9
10
  const HERE = dirname(fileURLToPath(import.meta.url));
10
11
  const LABEL = (kind) => `dev.pantheon.${kind}`;
@@ -45,8 +46,10 @@ export const serviceLogFile = (kind) => join(serviceLogDir(), `${kind}.log`);
45
46
  const SERVICE_BASE_PATH = [
46
47
  "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
47
48
  ];
48
- export function servicePath(envPath = process.env.PATH ?? "") {
49
- return [...new Set([...envPath.split(":"), ...SERVICE_BASE_PATH].filter(Boolean))].join(":");
49
+ export function servicePath(envPath = process.env.PATH ?? "", managed = managedLayout()) {
50
+ const first = managed ? [managed.binDir] : [];
51
+ const last = managed ? [posix.dirname(managed.node)] : [];
52
+ return [...new Set([...first, ...envPath.split(":"), ...SERVICE_BASE_PATH, ...last].filter(Boolean))].join(":");
50
53
  }
51
54
  export function servicesUnavailable(plat = platform()) {
52
55
  if (plat !== "win32")
@@ -99,14 +102,19 @@ export function existingUnitPath(kind, env = systemdUserEnv(), shellEnv = proces
99
102
  function xml(s) {
100
103
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
101
104
  }
102
- function argvFor(kind, args) {
105
+ function argvFor(kind, args, managed = managedLauncher()) {
106
+ if (managed)
107
+ return [managed, kind, ...args];
103
108
  const l = launcher();
104
109
  if (!l.portable)
105
110
  return [l.command, ...l.args.slice(0, -1), kind, ...args];
106
111
  const abs = whichPantheon();
107
- return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
112
+ return abs ? [abs, kind, ...args] : [process.execPath, ...childNodeFlags(), resolve(HERE, "cli.js"), kind, ...args];
108
113
  }
109
- export function whichPantheon(find = which) {
114
+ export const serviceArgv = (kind, args, managed) => argvFor(kind, args, managed);
115
+ export function whichPantheon(find = which, managed = managedLauncher()) {
116
+ if (managed)
117
+ return managed;
110
118
  const p = find("pantheon");
111
119
  if (!p)
112
120
  return null;
package/dist/tray.js CHANGED
@@ -1,14 +1,12 @@
1
1
  import { loadPolicy } from "./worker-policy.js";
2
- import { execFileSync } from "node:child_process";
3
- import { helperStale, recordHelperBuild } from "./helpers-build.js";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { helperStale, installHelper } from "./helpers-build.js";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
4
  import { platform } from "node:os";
6
5
  import { dirname, join, resolve } from "node:path";
7
6
  import { fileURLToPath } from "node:url";
8
7
  import { CONFIG_DIR, load, loadAll, configFor } from "./config.js";
9
8
  import { workerProc, notifierProc } from "./machine.js";
10
9
  import { agentHookStates } from "./notify.js";
11
- import { requireDeveloperTools } from "./notifier.js";
12
10
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
13
11
  import { VERSION } from "./version.js";
14
12
  import { LIMITS } from "./protocol.js";
@@ -18,7 +16,8 @@ import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
18
16
  const HERE = dirname(fileURLToPath(import.meta.url));
19
17
  export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
20
18
  export const traySourcePath = () => resolve(HERE, "..", "helper", "macos", "menubar.swift");
21
- export function ensureTrayBinary(home = CONFIG_DIR) {
19
+ export const TRAY_PREBUILT = "pantheon-tray";
20
+ export function ensureTrayBinary(home = CONFIG_DIR, deps = {}) {
22
21
  if (platform() !== "darwin")
23
22
  throw new Error("the pantheon menu bar is currently macOS-only");
24
23
  const source = traySourcePath();
@@ -26,11 +25,7 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
26
25
  throw new Error(`menu bar source is missing: ${source}`);
27
26
  const binary = trayBinaryPath(home);
28
27
  mkdirSync(dirname(binary), { recursive: true });
29
- requireDeveloperTools("the menu bar", "menubar");
30
- execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
31
- chmodSync(binary, 0o755);
32
- recordHelperBuild(binary, source);
33
- return binary;
28
+ return installHelper({ name: TRAY_PREBUILT, source, binary, what: "the menu bar", service: "menubar" }, deps).binary;
34
29
  }
35
30
  export function trayStale(home = CONFIG_DIR) { return helperStale(trayBinaryPath(home), traySourcePath()); }
36
31
  export function writeTrayCommands(home = CONFIG_DIR) {
@@ -0,0 +1,194 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { posix, win32 } from "node:path";
5
+ import { expectedLauncher } from "./managed.js";
6
+ export const BEGIN_MARK = "# >>> pantheon >>>";
7
+ export const END_MARK = "# <<< pantheon <<<";
8
+ export const PROFILES = [".profile", ".bash_profile", ".bashrc", ".zshrc", ".zprofile"];
9
+ export const FISH_CONF = ".config/fish/conf.d/pantheon.fish";
10
+ export function withoutPathBlock(text) {
11
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
12
+ const bare = (l) => l.replace(/\n$/, "").replace(/\r$/, "");
13
+ const out = [];
14
+ let blocks = 0;
15
+ for (let i = 0; i < lines.length; i++) {
16
+ if (bare(lines[i]) !== BEGIN_MARK) {
17
+ out.push(lines[i]);
18
+ continue;
19
+ }
20
+ let end = i + 1;
21
+ while (end < lines.length && bare(lines[end]) !== END_MARK)
22
+ end++;
23
+ if (end >= lines.length)
24
+ return { error: `a "${BEGIN_MARK}" line with no "${END_MARK}" after it` };
25
+ if (out.length && bare(out[out.length - 1]) === "")
26
+ out.pop();
27
+ blocks++;
28
+ i = end;
29
+ }
30
+ return blocks ? { text: out.join(""), blocks } : null;
31
+ }
32
+ export function expandWindows(s, env) {
33
+ const keys = Object.keys(env);
34
+ return s.replace(/%([^%;]+)%/g, (all, name) => {
35
+ const k = keys.find((x) => x.toLowerCase() === name.toLowerCase());
36
+ return k !== undefined && env[k] !== undefined ? env[k] : all;
37
+ });
38
+ }
39
+ export function withoutPathEntry(raw, binDir, env) {
40
+ const norm = (p) => expandWindows(p, env).trim().replace(/\\+$/, "").toLowerCase();
41
+ const want = norm(binDir);
42
+ const parts = raw.split(";");
43
+ const kept = parts.filter((p) => p === "" || norm(p) !== want);
44
+ return kept.length === parts.length ? null : kept.join(";");
45
+ }
46
+ export function registryUserPath(run = (script, env) => execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60_000, windowsHide: true, env })) {
47
+ return {
48
+ read: () => {
49
+ const out = run("$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment'); " +
50
+ "if ($null -eq $k) { 'NONE'; exit 0 }; " +
51
+ "try { $v = $k.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } finally { $k.Close() }; " +
52
+ "if ($null -eq $v) { 'NONE' } else { 'B64:' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$v)) }", process.env).trim();
53
+ if (out === "NONE")
54
+ return null;
55
+ const m = /B64:([A-Za-z0-9+/=]*)/.exec(out);
56
+ if (!m)
57
+ throw new Error(`the user PATH could not be read (${out.split("\n")[0] || "no answer"})`);
58
+ return Buffer.from(m[1], "base64").toString("utf8");
59
+ },
60
+ write: (value) => {
61
+ run("$v = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:PANTHEON_USER_PATH_B64)); " +
62
+ "$k = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment'); " +
63
+ "try { if ($v -eq '') { $k.DeleteValue('Path', $false) } else { $k.SetValue('Path', $v, [Microsoft.Win32.RegistryValueKind]::ExpandString) } } finally { $k.Close() }; " +
64
+ "[Environment]::SetEnvironmentVariable('PANTHEON_PATH_UPDATED', '1', 'User'); " +
65
+ "[Environment]::SetEnvironmentVariable('PANTHEON_PATH_UPDATED', $null, 'User')", { ...process.env, PANTHEON_USER_PATH_B64: Buffer.from(value, "utf8").toString("base64") });
66
+ },
67
+ };
68
+ }
69
+ export const realUninstallFs = {
70
+ exists: (p) => existsSync(p),
71
+ isSymlink: (p) => { try {
72
+ return lstatSync(p).isSymbolicLink();
73
+ }
74
+ catch {
75
+ return false;
76
+ } },
77
+ read: (p) => readFileSync(p, "utf8"),
78
+ write: (p, text) => writeFileSync(p, text),
79
+ remove: (p) => rmSync(p, { recursive: true, force: true }),
80
+ removeIfEmpty: (p) => { try {
81
+ if (!readdirSync(p).length)
82
+ rmdirSync(p);
83
+ }
84
+ catch { } },
85
+ };
86
+ export function managedInstall(home, os = platform(), env = process.env, fs = realUninstallFs) {
87
+ const launcher = expectedLauncher(home, os, env);
88
+ if (!launcher)
89
+ return null;
90
+ const p = os === "win32" ? win32 : posix;
91
+ const binDir = p.dirname(launcher);
92
+ const root = p.dirname(binDir);
93
+ const runtime = p.join(root, "runtime");
94
+ const log = os === "win32" ? p.join(root, "install.log") : p.join(root, "logs", "install.log");
95
+ return { os, root, runtime, binDir, launcher, log, present: fs.exists(runtime) || fs.exists(launcher) };
96
+ }
97
+ export function removeManagedInstall(o) {
98
+ const fs = o.fs ?? realUninstallFs;
99
+ const env = o.env ?? process.env;
100
+ const { install: m } = o;
101
+ const removed = [], left = [];
102
+ const p = m.os === "win32" ? win32 : posix;
103
+ if (m.os === "win32") {
104
+ try {
105
+ const io = o.userPath ?? registryUserPath();
106
+ const raw = io.read();
107
+ const next = raw === null ? null : withoutPathEntry(raw, m.binDir, env);
108
+ if (next !== null) {
109
+ io.write(next);
110
+ removed.push(`${m.binDir} from your user PATH (the other entries as they were)`);
111
+ }
112
+ }
113
+ catch (e) {
114
+ left.push(`${m.binDir} in your user PATH — could not change it (${e.message.split("\n")[0]}); remove it in Settings > System > About > Advanced system settings > Environment Variables`);
115
+ }
116
+ }
117
+ else {
118
+ for (const name of PROFILES) {
119
+ const f = p.join(o.home, name);
120
+ if (!fs.exists(f))
121
+ continue;
122
+ let text;
123
+ try {
124
+ text = fs.read(f);
125
+ }
126
+ catch (e) {
127
+ left.push(`the PATH block in ${f} — could not read it (${e.message.split("\n")[0]})`);
128
+ continue;
129
+ }
130
+ const r = withoutPathBlock(text);
131
+ if (!r)
132
+ continue;
133
+ if ("error" in r) {
134
+ left.push(`${f} — ${r.error}, so it was left as it is; delete the pantheon lines yourself`);
135
+ continue;
136
+ }
137
+ try {
138
+ if (r.text === "" && !fs.isSymlink(f)) {
139
+ fs.remove(f);
140
+ removed.push(`${f} (it held only Pantheon's PATH lines)`);
141
+ }
142
+ else {
143
+ fs.write(f, r.text);
144
+ removed.push(`the PATH lines in ${f}`);
145
+ }
146
+ }
147
+ catch (e) {
148
+ left.push(`the PATH block in ${f} — could not write it (${e.message.split("\n")[0]}); delete the lines from "${BEGIN_MARK}" to "${END_MARK}" yourself`);
149
+ }
150
+ }
151
+ const fish = p.join(o.home, FISH_CONF);
152
+ if (fs.exists(fish)) {
153
+ let ours = false;
154
+ try {
155
+ ours = fs.read(fish).includes(".pantheon/bin");
156
+ }
157
+ catch { }
158
+ if (ours) {
159
+ try {
160
+ fs.remove(fish);
161
+ removed.push(fish);
162
+ }
163
+ catch (e) {
164
+ left.push(`${fish} — could not remove (${e.message.split("\n")[0]})`);
165
+ }
166
+ }
167
+ else
168
+ left.push(`${fish} — it does not look like the installer's, so it was left`);
169
+ }
170
+ }
171
+ const lockedHere = m.os === "win32" && !!o.running && o.running.root.toLowerCase() === m.root.toLowerCase();
172
+ if (lockedHere) {
173
+ left.push(`${m.root} — Windows keeps Pantheon's node.exe locked while this command runs. Close your agent windows and this one, then in a new PowerShell: Remove-Item -Recurse -Force "$env:LOCALAPPDATA\\Pantheon"`);
174
+ return { removed, left };
175
+ }
176
+ for (const [path, what] of [[m.launcher, "the pantheon command"], [m.log, "the installer's log"], [m.runtime, "Pantheon's own Node and the CLI in it"]]) {
177
+ if (!fs.exists(path))
178
+ continue;
179
+ try {
180
+ fs.remove(path);
181
+ removed.push(`${what} (${path})`);
182
+ }
183
+ catch (e) {
184
+ const busy = /EBUSY|EPERM|EACCES/.test(String(e.code ?? e.message));
185
+ left.push(`${what}, ${path} — could not remove (${e.message.split("\n")[0]})` +
186
+ (m.os === "win32" && busy ? `; an agent window is still running it. Close them, then in PowerShell: Remove-Item -Recurse -Force "${path}"` : `; remove it with: rm -rf '${path.replace(/'/g, "'\\''")}'`));
187
+ }
188
+ }
189
+ for (const dir of [m.binDir, ...(m.os === "win32" ? [] : [p.dirname(m.log)]), m.root])
190
+ fs.removeIfEmpty(dir);
191
+ return { removed, left };
192
+ }
193
+ export const NPM_UNINSTALL = "npm uninstall -g @join-pantheon/cli";
194
+ //# sourceMappingURL=uninstall.js.map
package/dist/upgrade.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, posix, win32 } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { installForUser } from "./install.js";
6
6
  import { npmSpawn } from "./which.js";
7
7
  import { proxyEnvFor } from "./network.js";
8
8
  import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
9
+ import { load } from "./config.js";
10
+ import { childNodeFlags, installerLineHere, managedLayout, nodeSatisfies, nodeTooOldSentence, runtimeNodeVersion } from "./managed.js";
9
11
  export function repoRoot() {
10
12
  return join(dirname(fileURLToPath(import.meta.url)), "..");
11
13
  }
@@ -18,7 +20,20 @@ function pkgVersion(root) {
18
20
  }
19
21
  }
20
22
  export const PKG_NAME = "@join-pantheon/cli";
21
- export function rewireVia(root, fallback, run = (file, args) => execFileSync(process.execPath, [file, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120_000 })) {
23
+ export function npmInstallArgs(version, managed = managedLayout()) {
24
+ const P = /\.exe$/i.test(managed?.node ?? "") ? win32 : posix;
25
+ const own = managed ? ["--prefix", managed.nodeDir, "--cache", P.join(managed.root, "runtime", "npm-cache"), "--fetch-timeout=60000", "--fetch-retries=2"] : [];
26
+ return ["install", "-g", ...own, `${PKG_NAME}@${version ?? "latest"}`, "--no-audit", "--no-fund"];
27
+ }
28
+ export function npmFailed(spec, e, managed, os = process.platform, relay) {
29
+ const first = String(e?.message ?? e).split("\n")[0];
30
+ if (managed) {
31
+ return `\`${managed.npm} install -g ${PKG_NAME}@${spec}\` failed: ${first}. Run the installer again to repair it: ${installerLineHere(relay, os)}`;
32
+ }
33
+ return `\`npm install -g ${PKG_NAME}@${spec}\` failed: ${first}. `
34
+ + `You may need to re-run it with the right permissions (or your Node version manager's global scope).`;
35
+ }
36
+ export function rewireVia(root, fallback, run = (file, args) => execFileSync(process.execPath, [...childNodeFlags(), file, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120_000 })) {
22
37
  try {
23
38
  const out = JSON.parse(run(join(root, "dist", "cli.js"), ["rewire", "--json"]));
24
39
  if (Array.isArray(out) && out.every((o) => o && typeof o.tool === "string" && typeof o.status === "string")) {
@@ -70,7 +85,7 @@ export function defaultDeps() {
70
85
  catch { }
71
86
  return up;
72
87
  },
73
- npmUpgrade: (version) => npm(["install", "-g", `${PKG_NAME}@${version ?? "latest"}`, "--no-audit", "--no-fund"], { stdio: ["ignore", "pipe", "pipe"] }),
88
+ npmUpgrade: (version) => npm(npmInstallArgs(version), { stdio: ["ignore", "pipe", "pipe"] }),
74
89
  npmLatest: () => {
75
90
  try {
76
91
  return npm(["view", `${PKG_NAME}`, "version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
@@ -79,6 +94,22 @@ export function defaultDeps() {
79
94
  return null;
80
95
  }
81
96
  },
97
+ engines: (spec) => {
98
+ try {
99
+ const out = npm(["view", `${PKG_NAME}@${spec}`, "engines.node"], { stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 }).trim();
100
+ return out.split("\n").at(-1).trim().replace(/^['"]|['"]$/g, "") || null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ },
106
+ runtimeNode: () => runtimeNodeVersion(),
107
+ relay: (() => { try {
108
+ return load()?.relay ?? null;
109
+ }
110
+ catch {
111
+ return null;
112
+ } })(),
82
113
  };
83
114
  }
84
115
  export function upgrade(deps = defaultDeps(), opts = {}) {
@@ -88,6 +119,15 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
88
119
  const steps = [];
89
120
  const stop = (name, detail) => ({ root, from, to: from, steps: [...steps, { name, ok: false, detail }], ok: false, alreadyCurrent: false, restart: [] });
90
121
  if (!deps.isGit(root)) {
122
+ const managed = deps.managed === undefined ? managedLayout() : deps.managed;
123
+ if (managed && deps.engines) {
124
+ const range = deps.engines(spec);
125
+ const node = deps.runtimeNode ? deps.runtimeNode() : runtimeNodeVersion(managed);
126
+ if (range && nodeSatisfies(node, range) === false) {
127
+ const why = nodeTooOldSentence(spec === "latest" ? null : spec, range, node, deps.relay);
128
+ return { ...stop("node", why), needsPerson: why };
129
+ }
130
+ }
91
131
  try {
92
132
  deps.npmUpgrade(spec === "latest" ? undefined : spec);
93
133
  steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@${spec}` });
@@ -96,8 +136,7 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
96
136
  if (spec !== "latest" && /ETARGET|No matching version|notarget/i.test(String(e.stderr ?? "") + e.message)) {
97
137
  return { root, from, to: from, steps: [...steps, { name: "npm", ok: true, detail: `${PKG_NAME}@${spec} is not on npm yet` }], ok: true, alreadyCurrent: true, restart: [] };
98
138
  }
99
- return stop("npm", `\`npm install -g ${PKG_NAME}@${spec}\` failed: ${e.message.split("\n")[0]}. ` +
100
- `You may need to re-run it with the right permissions (or your Node version manager's global scope).`);
139
+ return stop("npm", npmFailed(spec, e, managed, process.platform, deps.relay));
101
140
  }
102
141
  let wired = [];
103
142
  try {
package/dist/which.js CHANGED
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { dirname, join, posix, resolve, win32 } from "node:path";
5
5
  import { platform } from "node:os";
6
+ import { managedLayout } from "./managed.js";
6
7
  export const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.PS1";
7
8
  const isWindows = (os = platform()) => os === "win32";
8
9
  function runnable(file) {
@@ -124,11 +125,18 @@ export function spawnShape(binary, os = platform()) {
124
125
  return { command: binary, shell: false };
125
126
  return { command: binary, shell: /\.(cmd|bat)$/i.test(binary) };
126
127
  }
127
- export function npmSpawn(args, os = platform(), resolveNpm = () => which("npm", os), proxyEnv = {}) {
128
- const env = Object.keys(proxyEnv).length ? { env: { ...process.env, ...proxyEnv } } : {};
128
+ export function npmSpawn(args, os = platform(), resolveNpm, proxyEnv = {}, managed = managedLayout(process.execPath, os)) {
129
+ const extra = { ...proxyEnv };
130
+ if (managed && !resolveNpm) {
131
+ const dir = isWindows(os) ? win32.dirname(managed.node) : posix.dirname(managed.node);
132
+ const key = isWindows(os) ? Object.keys(process.env).find((k) => k.toUpperCase() === "PATH") ?? "Path" : "PATH";
133
+ extra[key] = [dir, process.env[key] ?? ""].filter(Boolean).join(isWindows(os) ? ";" : ":");
134
+ return { command: managed.node, args: ["--use-system-ca", managed.npmCli, ...args], options: { env: { ...process.env, ...extra } } };
135
+ }
136
+ const env = Object.keys(extra).length ? { env: { ...process.env, ...extra } } : {};
129
137
  if (!isWindows(os))
130
138
  return { command: "npm", args: [...args], options: { ...env } };
131
- const s = agentSpawn(resolveNpm() ?? "npm.cmd", args, os);
139
+ const s = agentSpawn((resolveNpm ?? (() => which("npm", os)))() ?? "npm.cmd", args, os);
132
140
  return { ...s, options: { ...s.options, ...env } };
133
141
  }
134
142
  const CMD_UNSAFE = /["%!^&|<>\r\n\0]/;
@@ -68,6 +68,8 @@ export function loadPolicy(home) {
68
68
  ...(isRecord(raw.wakeOnce) ? { wakeOnce: Object.fromEntries(Object.entries(raw.wakeOnce).filter(([, v]) => typeof v === "number" && v > 0 && v < 100)) } : {}),
69
69
  ...(isRecord(raw.wakeNo) ? { wakeNo: Object.fromEntries(Object.entries(raw.wakeNo).filter(([, v]) => typeof v === "number")) } : {}),
70
70
  ...(isRecord(raw.wakeTeamFlag) ? { wakeTeamFlag: Object.fromEntries(Object.entries(raw.wakeTeamFlag).filter(([, v]) => typeof v === "number")) } : {}),
71
+ ...(isRecord(raw.upgradeHeld) && typeof raw.upgradeHeld.version === "string" && typeof raw.upgradeHeld.node === "string"
72
+ ? { upgradeHeld: { version: raw.upgradeHeld.version, node: raw.upgradeHeld.node, at: Number(raw.upgradeHeld.at) || 0, why: String(raw.upgradeHeld.why ?? "") } } : {}),
71
73
  };
72
74
  }
73
75
  catch {
package/dist/worker.js CHANGED
@@ -18,7 +18,8 @@ import { headlessSpawn, materializeSpawnFiles, chooseAgent, agentById, describeC
18
18
  import { activeOutages, detectOutage, earliest, whenAgain } from "./agent-outage.js";
19
19
  import { agentSpawn, CmdLineUnsafe } from "./which.js";
20
20
  import { VERSION } from "./version.js";
21
- import { applyAnswer, CAPABILITY_ARGS, CARRY_REASON, carryOverWakers, dropLedgerGrants, hasWakeOnce, isApprovedWaker, mayAskWake, takeWakeOnce, OLD_RELAY_WAKE, relayKnowsAdmitters, loadPolicy, mayAsk, needsSelfUpgrade, parseGrantAnswer, recordAsk, savePolicy, useGrant, WOKEN_RUN_ENV, takeTeamFlagOnYes, unsandboxedAllowed, policyPath, } from "./worker-policy.js";
21
+ import { applyAnswer, CAPABILITY_ARGS, CARRY_REASON, carryOverWakers, dropLedgerGrants, hasWakeOnce, isApprovedWaker, mayAskWake, takeWakeOnce, OLD_RELAY_WAKE, relayKnowsAdmitters, loadPolicy, mayAsk, needsSelfUpgrade, parseGrantAnswer, recordAsk, savePolicy, useGrant, WOKEN_RUN_ENV, takeTeamFlagOnYes, unsandboxedAllowed, policyPath, isNewer, } from "./worker-policy.js";
22
+ import { runtimeNodeVersion } from "./managed.js";
22
23
  import { agentHookStates, repairHook } from "./notify.js";
23
24
  import { fence, assess } from "./untrusted.js";
24
25
  import { allowed, closeStartedRun, finishRun, recordRun, startRun } from "./spend.js";
@@ -3143,13 +3144,19 @@ export class Worker {
3143
3144
  const lastAttempt = policy.attempts.self_upgrade ?? null;
3144
3145
  if (!needsSelfUpgrade(VERSION, relayPkg, lastAttempt))
3145
3146
  return;
3147
+ const nodeNow = this.opts.selfUpgrade?.node ?? (() => runtimeNodeVersion());
3148
+ const held = policy.upgradeHeld;
3149
+ if (held && held.version === relayPkg && held.node === nodeNow())
3150
+ return;
3146
3151
  if (policy.grants.self_upgrade) {
3147
3152
  const slot = this.takeSlot();
3148
3153
  if ("held" in slot)
3149
3154
  return;
3150
3155
  try {
3156
+ const grantBefore = policy.grants.self_upgrade;
3151
3157
  useGrant(policy, "self_upgrade");
3152
3158
  policy.attempts.self_upgrade = Date.now();
3159
+ delete policy.upgradeHeld;
3153
3160
  savePolicy(policy, home);
3154
3161
  this.writeState("upgrading", `pantheon ${VERSION} -> ${relayPkg} (relay is ahead)`);
3155
3162
  this.opts.log(`* self-upgrade: relay runs ${relayPkg}, this machine runs ${VERSION} — upgrading (granted by this machine's human)`);
@@ -3161,20 +3168,36 @@ export class Worker {
3161
3168
  result = { ok: false, alreadyCurrent: false, from: VERSION, to: relayPkg ?? "?" };
3162
3169
  this.opts.log(`! self-upgrade failed: ${e.message}`);
3163
3170
  }
3164
- if (result.ok && result.to === result.from) {
3171
+ if (result.needsPerson) {
3172
+ if (grantBefore)
3173
+ policy.grants.self_upgrade = grantBefore;
3174
+ delete policy.attempts.self_upgrade;
3175
+ policy.upgradeHeld = { version: relayPkg ?? result.to, node: nodeNow(), at: Date.now(), why: result.needsPerson };
3176
+ savePolicy(policy, home);
3177
+ this.writeState("needs you", result.needsPerson);
3178
+ this.opts.log(`! self-upgrade needs you: ${result.needsPerson}`);
3179
+ try {
3180
+ this.opts.notify?.({ kind: "worker", who: "Your worker", heading: "Pantheon needs a newer Node", context: this.cfg.space, body: result.needsPerson, ttl: 0, sound: false });
3181
+ }
3182
+ catch { }
3183
+ return;
3184
+ }
3185
+ const behindDisk = result.ok && result.to === result.from && isNewer(result.to, VERSION);
3186
+ if (result.ok && result.to === result.from && !behindDisk) {
3165
3187
  delete policy.attempts.self_upgrade;
3166
3188
  savePolicy(policy, home);
3167
3189
  this.opts.log(`* self-upgrade: nothing newer than ${result.to} to install yet (the relay runs ${relayPkg}); will check again`);
3168
3190
  return;
3169
3191
  }
3170
- if (result.ok && !result.alreadyCurrent) {
3192
+ if (result.ok && (!result.alreadyCurrent || behindDisk)) {
3193
+ const from = behindDisk ? VERSION : result.from;
3171
3194
  await client.send({
3172
3195
  act: "NOTE", goal: null, after_seq: Math.max(client.readSeq, client.seq),
3173
- summary: `self-upgraded pantheon ${result.from} -> ${result.to}; worker restarting on the new build`,
3174
- body: `This machine's worker upgraded pantheon ${result.from} -> ${result.to} (fast-forward pull + rebuild, granted by its human) and is restarting on the new build. Agent windows on this machine opened on 0.24 or later switch to the new version by themselves; a window opened on an older pantheon needs one reconnect (/mcp → pantheon → Reconnect, or reopen it).`,
3196
+ summary: `self-upgraded pantheon ${from} -> ${result.to}; worker restarting on the new build`,
3197
+ body: `This machine's worker upgraded pantheon ${from} -> ${result.to} (fast-forward pull + rebuild, granted by its human) and is restarting on the new build. Agent windows on this machine opened on 0.24 or later switch to the new version by themselves; a window opened on an older pantheon needs one reconnect (/mcp → pantheon → Reconnect, or reopen it).`,
3175
3198
  }).catch((e) => this.opts.log(`! could not report the upgrade on the ledger: ${e.message}`));
3176
3199
  await this.bringMachineAlong();
3177
- this.opts.log(`* self-upgrade done: ${result.from} -> ${result.to}; restarting worker to load it`);
3200
+ this.opts.log(`* self-upgrade done: ${from} -> ${result.to}; restarting worker to load it`);
3178
3201
  (this.opts.selfUpgrade?.restart ?? (() => process.exit(0)))();
3179
3202
  }
3180
3203
  else if (!result.ok) {
@@ -3512,7 +3535,7 @@ export class Worker {
3512
3535
  async runRealUpgrade(version) {
3513
3536
  const { upgrade } = await import("./upgrade.js");
3514
3537
  const r = upgrade(undefined, version ? { version } : {});
3515
- return { ok: r.ok, alreadyCurrent: r.alreadyCurrent, from: r.from, to: r.to };
3538
+ return { ok: r.ok, alreadyCurrent: r.alreadyCurrent, from: r.from, to: r.to, ...(r.needsPerson ? { needsPerson: r.needsPerson } : {}) };
3516
3539
  }
3517
3540
  writeState(state, detail) {
3518
3541
  const line = `${state}|${detail}`;