@join-pantheon/cli 0.18.2 → 0.19.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 +9 -2
- package/dist/cli.js +62 -9
- package/dist/client.js +1 -1
- package/dist/doctor.js +6 -6
- package/dist/install.js +8 -2
- package/dist/notifier.js +9 -0
- package/dist/quickstart.js +49 -20
- package/dist/relay.js +113 -24
- package/dist/roster-file.js +28 -2
- package/dist/tray.js +3 -1
- package/dist/update-check.js +46 -14
- package/dist/upgrade.js +29 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,9 +55,13 @@ pantheon start
|
|
|
55
55
|
**Your teammate, from their clone of the project:**
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
pantheon join
|
|
58
|
+
pantheon join pantheons://relay.joinpantheon.network/acme#C1qhJfdHBaPjuG3jF48vcqrP
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Signed in, `pantheon start` puts your space on the hosted relay, so that link works from any
|
|
62
|
+
network — no tunnel, no VPN. The space is named after your repo; if another team already took
|
|
63
|
+
that name, start picks `<repo>-<you>` and says so (or choose your own with `--space`).
|
|
64
|
+
|
|
61
65
|
That's it — one command each sets up the **whole machine**: a relay and space (you) or membership
|
|
62
66
|
(them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
|
|
63
67
|
every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
|
|
@@ -88,7 +92,10 @@ Need another (a third person, or your own second machine)? `pantheon invite`.
|
|
|
88
92
|
|
|
89
93
|
### Reaching each other across cities
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
On the hosted relay there is nothing to work out: every signed-in machine dials
|
|
96
|
+
`relay.joinpantheon.network`. The rest of this section is for **self-hosting** (`pantheon start
|
|
97
|
+
--relay …`, or a machine with no account). Then `pantheon start` runs a relay on your machine and
|
|
98
|
+
works out where a teammate can reach it. If Tailscale is running it uses your tailnet address, which
|
|
92
99
|
is private and needs no TLS. Otherwise it tells you the address is local-only and how to fix it:
|
|
93
100
|
|
|
94
101
|
```
|
package/dist/cli.js
CHANGED
|
@@ -89,12 +89,14 @@ async function interactiveOnboard(relayArg) {
|
|
|
89
89
|
}
|
|
90
90
|
let started = null;
|
|
91
91
|
let mode = "signup";
|
|
92
|
+
let signupUsername = "";
|
|
92
93
|
while (!started || started.status !== 200) {
|
|
93
94
|
const u = await ask(" Pick a username: ");
|
|
94
95
|
if (!validUsername(u.toLowerCase())) {
|
|
95
96
|
console.error(D(" 2–32 chars: letters, digits, . _ - (not at the ends)"));
|
|
96
97
|
continue;
|
|
97
98
|
}
|
|
99
|
+
signupUsername = u;
|
|
98
100
|
started = await postJson("/auth/signup", { email, username: u, pubkey: kp.publicKey, label }).catch((e) => { console.error(D(` could not reach the relay: ${e.message}`)); return null; });
|
|
99
101
|
if (!started)
|
|
100
102
|
return;
|
|
@@ -117,15 +119,28 @@ async function interactiveOnboard(relayArg) {
|
|
|
117
119
|
console.error(D(` ${started?.j.error ?? "sign-up failed"}`));
|
|
118
120
|
return;
|
|
119
121
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
+
const authUser = mode === "signup" ? signupUsername : undefined;
|
|
123
|
+
save({ ...(prior ?? {}), relay, member: signupUsername || prior?.member || "me",
|
|
124
|
+
space: prior?.space ?? "", token: prior?.token ?? "",
|
|
125
|
+
publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
|
|
126
|
+
pendingAuth: { email, ...(authUser ? { username: authUser } : {}), purpose: mode } });
|
|
127
|
+
if (started.j.sent === false && started.j.via === "resend") {
|
|
128
|
+
console.error(`\n ${D("The relay could not send the email just now.")} Type 'resend' to retry, or run ${C("pantheon")} again shortly.\n`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const via = started.j.via === "resend" ? "" : `\n ${D("(this is a dev relay — your code is printed in its log)")}`;
|
|
132
|
+
console.error(`\n ${C("Sent a 6-digit code")} to ${B(email)}. Check your inbox.${via}\n`);
|
|
133
|
+
}
|
|
122
134
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
123
135
|
const code = await ask(" Enter the code (or 'resend'): ");
|
|
124
136
|
if (/^resend$/i.test(code)) {
|
|
125
|
-
await postJson(mode === "signup" ? "/auth/signup" : "/auth/login", mode === "signup"
|
|
126
|
-
? { email, username:
|
|
127
|
-
: { email, pubkey: kp.publicKey, label });
|
|
128
|
-
|
|
137
|
+
const rs = await postJson(mode === "signup" ? "/auth/signup" : "/auth/login", mode === "signup"
|
|
138
|
+
? { email, username: signupUsername, pubkey: kp.publicKey, label }
|
|
139
|
+
: { email, pubkey: kp.publicKey, label }).catch(() => null);
|
|
140
|
+
if (rs && rs.status === 200 && rs.j.sent !== false)
|
|
141
|
+
console.error(D(" sent a fresh code."));
|
|
142
|
+
else
|
|
143
|
+
console.error(D(` could not resend: ${rs?.j.error ?? "the relay did not send an email"} — try again in a minute.`));
|
|
129
144
|
continue;
|
|
130
145
|
}
|
|
131
146
|
const v = await postJson("/auth/verify", { email, code });
|
|
@@ -195,8 +210,16 @@ function die(msg) {
|
|
|
195
210
|
console.error(`pantheon: ${msg}`);
|
|
196
211
|
process.exit(1);
|
|
197
212
|
}
|
|
213
|
+
function sameHost(a, b) {
|
|
214
|
+
try {
|
|
215
|
+
return new URL(a).host === new URL(b).host;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
198
221
|
function need() {
|
|
199
|
-
return load() ?? die("not configured. Run `pantheon
|
|
222
|
+
return load() ?? die("not configured. Run `pantheon signup` then `pantheon start` (create a team), or `pantheon join <invite>` (join one).");
|
|
200
223
|
}
|
|
201
224
|
function printGranted(granted) {
|
|
202
225
|
if (!granted.length)
|
|
@@ -303,7 +326,8 @@ ${B("less common")}
|
|
|
303
326
|
pantheon whoami your name and fingerprint
|
|
304
327
|
pantheon pending what a live agent window has not yet seen
|
|
305
328
|
pantheon say <ACT> <text> send one act by hand (NOTE, ASK, ANSWER, PROPOSE, ...)
|
|
306
|
-
pantheon
|
|
329
|
+
pantheon invite mint another single-use invite link to send
|
|
330
|
+
pantheon install re-wire this machine's agents (after a Node/agent change)
|
|
307
331
|
pantheon mcp the server your agent runs; not for you
|
|
308
332
|
pantheon limits the guard values in force
|
|
309
333
|
pantheon version the pantheon version this machine is running
|
|
@@ -350,6 +374,12 @@ async function main() {
|
|
|
350
374
|
for (const w of r.warnings)
|
|
351
375
|
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
352
376
|
printGranted(r.granted);
|
|
377
|
+
if (!r.claimed) {
|
|
378
|
+
console.error(`\n\x1b[31m No team was created.\x1b[0m ${D("Fix the warning above, then run `pantheon start` again.")}`);
|
|
379
|
+
if (r.relay)
|
|
380
|
+
await r.relay.close().catch(() => { });
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
353
383
|
if (r.invite) {
|
|
354
384
|
console.error(`\n${B("Send your teammate this one line — it sets up their whole machine:")}\n`);
|
|
355
385
|
console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
|
|
@@ -391,6 +421,21 @@ async function main() {
|
|
|
391
421
|
console.error(D(`health: http://localhost:${port}/health`));
|
|
392
422
|
console.error(D(`\nFor a colleague on another network, expose this port (ssh -R, cloudflared, tailscale)`));
|
|
393
423
|
console.error(D(`and hand them the invite from \`pantheon init\`. Ctrl-C to stop.`));
|
|
424
|
+
let shuttingDown = false;
|
|
425
|
+
const shutdown = async (sig) => {
|
|
426
|
+
if (shuttingDown)
|
|
427
|
+
return;
|
|
428
|
+
shuttingDown = true;
|
|
429
|
+
console.error(D(`\n${sig} — flushing state and closing…`));
|
|
430
|
+
try {
|
|
431
|
+
await relay.close();
|
|
432
|
+
}
|
|
433
|
+
catch { }
|
|
434
|
+
process.exit(0);
|
|
435
|
+
};
|
|
436
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
437
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
438
|
+
await new Promise(() => { });
|
|
394
439
|
return;
|
|
395
440
|
}
|
|
396
441
|
case "init": {
|
|
@@ -443,6 +488,9 @@ async function main() {
|
|
|
443
488
|
catch (e) {
|
|
444
489
|
die(e.message);
|
|
445
490
|
}
|
|
491
|
+
if (!load()?.account && sameHost(parsed.relay, DEFAULT_RELAY)) {
|
|
492
|
+
die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run this same \`pantheon join\` command again — the invite is still good.`);
|
|
493
|
+
}
|
|
446
494
|
const wp = unattendedProject();
|
|
447
495
|
console.error(`${B("pantheon join")}\n`);
|
|
448
496
|
const r = await qsJoin({
|
|
@@ -553,7 +601,7 @@ async function main() {
|
|
|
553
601
|
console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
|
|
554
602
|
console.log(B("members"));
|
|
555
603
|
if (!peers.length)
|
|
556
|
-
console.log(` ${D("(nobody else yet — send them
|
|
604
|
+
console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
|
|
557
605
|
for (const p of peers) {
|
|
558
606
|
const behind = p.seen_seq === undefined ? null : client.head - p.seen_seq;
|
|
559
607
|
const reach = behind === null
|
|
@@ -1275,6 +1323,11 @@ async function main() {
|
|
|
1275
1323
|
save({ ...(prior ?? {}), relay, member: username ?? prior?.member ?? "me", space: prior?.space ?? "", token: prior?.token ?? "",
|
|
1276
1324
|
publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
|
|
1277
1325
|
pendingAuth: { email, ...(username ? { username } : {}), purpose } });
|
|
1326
|
+
if (r.j.sent === false && r.j.via === "resend") {
|
|
1327
|
+
console.error(`${B("signup accepted, but the email could not be sent just now.")}`);
|
|
1328
|
+
console.error(`Retry in a minute: ${C(`pantheon ${purpose} --email ${email}${username ? ` --username ${username}` : ""}`)}`);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1278
1331
|
const via = r.j.via === "resend" ? "" : D(" (dev relay: the code is in the relay log)");
|
|
1279
1332
|
console.error(`${B("check your email")} — we sent a 6-digit code to ${C(email)}.${via}`);
|
|
1280
1333
|
console.error(`Then run: ${C("pantheon verify <code>")}`);
|
package/dist/client.js
CHANGED
|
@@ -57,7 +57,7 @@ export class PantheonClient {
|
|
|
57
57
|
return null;
|
|
58
58
|
const port = u.port || "8787";
|
|
59
59
|
try {
|
|
60
|
-
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
|
|
60
|
+
const res = await fetch(`http://127.0.0.1:${port}/health?space=${encodeURIComponent(this.cfg.space)}`, { signal: AbortSignal.timeout(1200) });
|
|
61
61
|
const body = (await res.json());
|
|
62
62
|
if (!body.spaces?.some((sp) => sp.name === this.cfg.space))
|
|
63
63
|
return null;
|
package/dist/doctor.js
CHANGED
|
@@ -42,9 +42,9 @@ export async function doctor(projectDir) {
|
|
|
42
42
|
const out = [];
|
|
43
43
|
const add = (level, name, detail, fix) => out.push({ level, name, detail, ...(fix ? { fix } : {}) });
|
|
44
44
|
const major = Number(process.versions.node.split(".")[0]);
|
|
45
|
-
major >=
|
|
45
|
+
major >= 22
|
|
46
46
|
? add("pass", "node", `v${process.versions.node}`)
|
|
47
|
-
: add("fail", "node", `v${process.versions.node} is too old`, "
|
|
47
|
+
: add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22 or newer (the relay uses node:sqlite) — install it and re-run");
|
|
48
48
|
let cfg = null;
|
|
49
49
|
try {
|
|
50
50
|
cfg = load();
|
|
@@ -53,7 +53,7 @@ export async function doctor(projectDir) {
|
|
|
53
53
|
add("fail", "config", `unreadable: ${e.message}`);
|
|
54
54
|
}
|
|
55
55
|
if (!cfg) {
|
|
56
|
-
add("fail", "config", "no config found", "run `pantheon
|
|
56
|
+
add("fail", "config", "no config found", "run `pantheon signup` (new here) then `pantheon start` (create a team), or `pantheon join <invite>` (join one)");
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
59
|
add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`);
|
|
@@ -79,12 +79,12 @@ export async function doctor(projectDir) {
|
|
|
79
79
|
add("pass", "mcp entry", "portable (`pantheon mcp`) — safe to commit, works on your teammate's machine");
|
|
80
80
|
}
|
|
81
81
|
else {
|
|
82
|
-
add("fail", "mcp entry", `hard-codes this machine: ${entry.command}`, "
|
|
82
|
+
add("fail", "mcp entry", `hard-codes this machine: ${entry.command}`, "make sure `pantheon` is on your PATH, then run `pantheon install` again — it writes the portable `pantheon mcp` entry. Committed as-is it silently fails on the other machine: the MCP server never starts and no error is shown.");
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
onPath()
|
|
86
86
|
? add("pass", "PATH", "`pantheon` resolves by name")
|
|
87
|
-
: add("warn", "PATH", "`pantheon` is not on PATH", "
|
|
87
|
+
: add("warn", "PATH", "`pantheon` is not on PATH", "install it globally (`npm install -g @join-pantheon/cli`) and make sure your npm global bin dir (`npm bin -g`) is on PATH");
|
|
88
88
|
const inRepo = sh("git rev-parse --is-inside-work-tree", projectDir) === "true";
|
|
89
89
|
if (!inRepo) {
|
|
90
90
|
add("warn", "git", `${projectDir} is not a git repo`, "DELIVERED requires a pushed branch + commit, so the work needs to live in a repo you both clone");
|
|
@@ -151,7 +151,7 @@ export async function doctor(projectDir) {
|
|
|
151
151
|
admittedPeers.length
|
|
152
152
|
? add("pass", "peers", admittedPeers
|
|
153
153
|
.map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "))
|
|
154
|
-
: add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate
|
|
154
|
+
: add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate an invite from `pantheon invite` (or the one `pantheon start` printed)");
|
|
155
155
|
if (pending.length) {
|
|
156
156
|
add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} --may-wake`);
|
|
157
157
|
}
|
package/dist/install.js
CHANGED
|
@@ -115,7 +115,8 @@ export function install(projectDir, cfg, force) {
|
|
|
115
115
|
out.push(" machine's node binary and checkout path. It works here and silently fails on any");
|
|
116
116
|
out.push(" other machine — the MCP server just never starts, with no error.");
|
|
117
117
|
out.push("");
|
|
118
|
-
out.push(" Fix it before committing: \x1b[1mnpm
|
|
118
|
+
out.push(" Fix it before committing: install pantheon globally (\x1b[1mnpm install -g @join-pantheon/cli\x1b[0m)");
|
|
119
|
+
out.push(" and put your npm global bin (`npm bin -g`) on PATH, then re-run `pantheon install`");
|
|
119
120
|
out.push(" Or keep it local-only: add .mcp.json to .gitignore and have your teammate run");
|
|
120
121
|
out.push(" `pantheon install` on their own machine instead.");
|
|
121
122
|
}
|
|
@@ -127,13 +128,18 @@ function localEntry() {
|
|
|
127
128
|
function userEntry() {
|
|
128
129
|
try {
|
|
129
130
|
const abs = execFileSync("sh", ["-lc", "command -v pantheon"], { encoding: "utf8" }).trim();
|
|
130
|
-
if (abs)
|
|
131
|
+
if (abs && !isVersionManagedPath(abs))
|
|
131
132
|
return { command: abs, args: ["mcp"] };
|
|
133
|
+
if (abs)
|
|
134
|
+
return { command: "pantheon", args: ["mcp"] };
|
|
132
135
|
}
|
|
133
136
|
catch { }
|
|
134
137
|
const l = launcher();
|
|
135
138
|
return { command: l.command, args: l.args };
|
|
136
139
|
}
|
|
140
|
+
function isVersionManagedPath(p) {
|
|
141
|
+
return /[\/\\]\.nvm[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.fnm[\/\\]|[\/\\]\.asdf[\/\\]installs[\/\\]nodejs[\/\\]|[\/\\]n[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.volta[\/\\]tools[\/\\]/.test(p);
|
|
142
|
+
}
|
|
137
143
|
function claudeUserScope(entry) {
|
|
138
144
|
const tool = "claude code";
|
|
139
145
|
try {
|
package/dist/notifier.js
CHANGED
|
@@ -53,10 +53,19 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
|
|
|
53
53
|
throw new Error(`Pantheon notification helper source is missing: ${source}`);
|
|
54
54
|
const binary = hudBinaryPath(home);
|
|
55
55
|
mkdirSync(dirname(binary), { recursive: true });
|
|
56
|
+
requireDeveloperTools("notifications", "notifier");
|
|
56
57
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
57
58
|
chmodSync(binary, 0o755);
|
|
58
59
|
return binary;
|
|
59
60
|
}
|
|
61
|
+
export function requireDeveloperTools(what, service) {
|
|
62
|
+
try {
|
|
63
|
+
execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error(`building ${what} needs the Xcode Command Line Tools — run \`xcode-select --install\`, then \`pantheon service install ${service}\``);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
60
69
|
export function sendDesktopNotification(title, subtitle, body, profile, prompt = "", eyebrow = "Decision needed") {
|
|
61
70
|
if (platform() !== "darwin")
|
|
62
71
|
return false;
|
package/dist/quickstart.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { PantheonClient } from "./client.js";
|
|
6
|
-
import { load, save, toInvite } from "./config.js";
|
|
6
|
+
import { load, save, toInvite, DEFAULT_RELAY } from "./config.js";
|
|
7
7
|
import { newKeypair } from "./identity.js";
|
|
8
8
|
import { installForUser } from "./install.js";
|
|
9
9
|
import { writeIntent, installHooks } from "./notify.js";
|
|
@@ -131,12 +131,15 @@ export function findReach(port) {
|
|
|
131
131
|
}
|
|
132
132
|
export async function start(opts) {
|
|
133
133
|
const warnings = [];
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const priorCfg = load();
|
|
135
|
+
const signedInRelay = priorCfg?.account ? (priorCfg.relay ?? DEFAULT_RELAY) : undefined;
|
|
136
|
+
const chosen = opts.relay ?? signedInRelay;
|
|
137
|
+
const reach = chosen
|
|
138
|
+
? (/127\.0\.0\.1|localhost|\[::1\]/.test(chosen)
|
|
139
|
+
? { url: chosen, how: "as given", shareable: false,
|
|
137
140
|
hint: `That address is this machine only — a teammate cannot reach it.\n` +
|
|
138
141
|
` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
|
|
139
|
-
: { url:
|
|
142
|
+
: { url: chosen, how: opts.relay ? "as given" : "your signed-in relay", shareable: true })
|
|
140
143
|
: findReach(opts.port);
|
|
141
144
|
let relay = null;
|
|
142
145
|
const health = reach.url.replace(/^ws/, "http") + "/health";
|
|
@@ -149,11 +152,11 @@ export async function start(opts) {
|
|
|
149
152
|
await relay.listen();
|
|
150
153
|
opts.log(` relay started on ${reach.url} (${reach.how})`);
|
|
151
154
|
}
|
|
152
|
-
const existing =
|
|
155
|
+
const existing = priorCfg;
|
|
153
156
|
const hasOwnSpace = !!existing?.space && !!existing?.token;
|
|
154
157
|
const reuse = !!existing && !opts.reset && hasOwnSpace && (!opts.space || opts.space === existing.space);
|
|
155
158
|
const keepIdentity = !!existing && !opts.reset && !!existing.publicKey && !!existing.privateKey;
|
|
156
|
-
|
|
159
|
+
let cfg = reuse
|
|
157
160
|
? { ...existing, relay: reach.url, agent: detectAgent() }
|
|
158
161
|
: {
|
|
159
162
|
relay: reach.url,
|
|
@@ -176,22 +179,47 @@ export async function start(opts) {
|
|
|
176
179
|
else if (existing && existing.space) {
|
|
177
180
|
warnings.push(`replacing your identity in "${existing.space}" — that space is no longer reachable from this machine`);
|
|
178
181
|
}
|
|
179
|
-
const client = new PantheonClient(cfg);
|
|
180
182
|
let invite = null;
|
|
183
|
+
let claimed = false;
|
|
184
|
+
const claim = async (c) => {
|
|
185
|
+
const client = new PantheonClient(c);
|
|
186
|
+
try {
|
|
187
|
+
await client.connect();
|
|
188
|
+
save(c);
|
|
189
|
+
opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}`);
|
|
190
|
+
const inv = await client.makeInvite(true, opts.ttlMinutes);
|
|
191
|
+
invite = toInvite({ ...c, token: inv.url });
|
|
192
|
+
claimed = true;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
client.close();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
181
198
|
try {
|
|
182
|
-
await
|
|
183
|
-
save(cfg);
|
|
184
|
-
opts.log(` space ${cfg.space} ${reuse ? "— reconnected" : "— created, you own it"}`);
|
|
185
|
-
const inv = await client.makeInvite(true, opts.ttlMinutes);
|
|
186
|
-
invite = toInvite({ ...cfg, token: inv.url });
|
|
199
|
+
await claim(cfg);
|
|
187
200
|
}
|
|
188
201
|
catch (e) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
202
|
+
const msg = e.message;
|
|
203
|
+
const nameTaken = !reuse && /BAD_TOKEN/.test(msg);
|
|
204
|
+
if (nameTaken && !opts.space) {
|
|
205
|
+
const alt = `${cfg.space}-${cfg.member}`;
|
|
206
|
+
opts.log(` space "${cfg.space}" is already another team's space on this relay — using "${alt}"`);
|
|
207
|
+
cfg = { ...cfg, space: alt };
|
|
208
|
+
try {
|
|
209
|
+
await claim(cfg);
|
|
210
|
+
}
|
|
211
|
+
catch (e2) {
|
|
212
|
+
warnings.push(`could not claim the space "${alt}": ${e2.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else if (nameTaken) {
|
|
216
|
+
warnings.push(`the name "${cfg.space}" is already another team's space on this relay — pick a different one: pantheon start --space <name>`);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
warnings.push(`could not claim the space: ${msg}`);
|
|
220
|
+
if (!reuse && existing)
|
|
221
|
+
warnings.push(`your existing config was left untouched`);
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
const wired = wireUp({
|
|
197
225
|
hook: opts.hook !== false,
|
|
@@ -201,7 +229,7 @@ export async function start(opts) {
|
|
|
201
229
|
...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
|
|
202
230
|
});
|
|
203
231
|
warnings.push(...wired.warnings);
|
|
204
|
-
return { cfg, invite, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
|
|
232
|
+
return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
|
|
205
233
|
}
|
|
206
234
|
export async function join(opts) {
|
|
207
235
|
const existing = opts.reset ? null : load();
|
|
@@ -218,6 +246,7 @@ export async function join(opts) {
|
|
|
218
246
|
member: opts.me ?? existing?.member ?? (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-"),
|
|
219
247
|
agent: detectAgent(),
|
|
220
248
|
...keys,
|
|
249
|
+
...(existing?.account ? { account: existing.account } : {}),
|
|
221
250
|
};
|
|
222
251
|
const client = new PantheonClient(cfg, undefined, { enroll: opts.invite.token });
|
|
223
252
|
await client.connect();
|
package/dist/relay.js
CHANGED
|
@@ -78,7 +78,7 @@ export class Relay {
|
|
|
78
78
|
dataDir;
|
|
79
79
|
log;
|
|
80
80
|
http = createServer((req, res) => this.onHttp(req, res));
|
|
81
|
-
wss = new WebSocketServer({ noServer: true });
|
|
81
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
|
|
82
82
|
heartbeat;
|
|
83
83
|
compactedAt = new Map();
|
|
84
84
|
sessionSeq = 0;
|
|
@@ -88,8 +88,11 @@ export class Relay {
|
|
|
88
88
|
pruneMs;
|
|
89
89
|
accounts;
|
|
90
90
|
requireAccounts;
|
|
91
|
+
trustLocal;
|
|
91
92
|
email;
|
|
92
93
|
authHits = new Map();
|
|
94
|
+
adminHits = new Map();
|
|
95
|
+
emailSends = [];
|
|
93
96
|
adminToken;
|
|
94
97
|
backupTimer;
|
|
95
98
|
constructor(opts = {}) {
|
|
@@ -97,6 +100,9 @@ export class Relay {
|
|
|
97
100
|
this.dataDir = opts.dataDir ?? "./pantheon-data";
|
|
98
101
|
this.log = opts.log ?? ((l) => console.error(l));
|
|
99
102
|
this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
|
|
103
|
+
this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
|
|
104
|
+
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
105
|
+
: !this.requireAccounts;
|
|
100
106
|
this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
|
|
101
107
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
102
108
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
@@ -160,20 +166,26 @@ export class Relay {
|
|
|
160
166
|
return this.onAuthHttp(req, res, url, ip);
|
|
161
167
|
}
|
|
162
168
|
if (url.pathname === "/health") {
|
|
169
|
+
const adminView = !!this.adminToken && this.adminTokenPresented(req, url);
|
|
170
|
+
const detail = (s) => ({
|
|
171
|
+
name: s.name,
|
|
172
|
+
members: s.members.size,
|
|
173
|
+
online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
|
|
174
|
+
sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
|
|
175
|
+
goals: s.goals.size,
|
|
176
|
+
seq: s.ledger.head,
|
|
177
|
+
});
|
|
178
|
+
const asked = url.searchParams.get("space");
|
|
179
|
+
const one = asked ? this.spaces.get(asked) : undefined;
|
|
163
180
|
return this.json(res, 200, {
|
|
164
181
|
ok: true,
|
|
165
182
|
v: PROTOCOL_VERSION,
|
|
166
183
|
pkg: VERSION,
|
|
167
184
|
space_count: this.spaces.size,
|
|
168
185
|
space_capacity: this.limits.maxSpaces,
|
|
169
|
-
spaces: [...this.spaces.values()].map(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
|
|
173
|
-
sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
|
|
174
|
-
goals: s.goals.size,
|
|
175
|
-
seq: s.ledger.head,
|
|
176
|
-
})),
|
|
186
|
+
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
187
|
+
: asked ? { spaces: one ? [detail(one)] : [] }
|
|
188
|
+
: {}),
|
|
177
189
|
});
|
|
178
190
|
}
|
|
179
191
|
if (url.pathname === "/history") {
|
|
@@ -271,10 +283,17 @@ export class Relay {
|
|
|
271
283
|
const fromCookie = cookieVal(req, "pantheon_admin");
|
|
272
284
|
const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
|
|
273
285
|
if (url.pathname === "/admin/login" && req.method === "POST") {
|
|
286
|
+
if (!this.adminLoginOk(ip)) {
|
|
287
|
+
res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
|
|
288
|
+
return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
|
|
289
|
+
}
|
|
274
290
|
return this.readForm(req, (body) => {
|
|
275
|
-
if (!tokenOk(body.token ?? ""))
|
|
291
|
+
if (!tokenOk(body.token ?? "")) {
|
|
292
|
+
this.noteAdminFail(ip);
|
|
276
293
|
return this.adminLoginPage(res, true);
|
|
277
|
-
|
|
294
|
+
}
|
|
295
|
+
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
296
|
+
res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
|
|
278
297
|
return void res.end();
|
|
279
298
|
});
|
|
280
299
|
}
|
|
@@ -315,7 +334,11 @@ export class Relay {
|
|
|
315
334
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
316
335
|
}
|
|
317
336
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
337
|
+
if (!this.emailBudgetOk())
|
|
338
|
+
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
318
339
|
const sent = await this.email.sendCode(b.email, started.code, "signup");
|
|
340
|
+
if (sent)
|
|
341
|
+
this.emailSends.push(Date.now());
|
|
319
342
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
320
343
|
});
|
|
321
344
|
}
|
|
@@ -331,7 +354,11 @@ export class Relay {
|
|
|
331
354
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : "no active account for that email — `pantheon signup` first";
|
|
332
355
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
333
356
|
}
|
|
357
|
+
if (!this.emailBudgetOk())
|
|
358
|
+
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
334
359
|
const sent = await this.email.sendCode(b.email, started.code, "login");
|
|
360
|
+
if (sent)
|
|
361
|
+
this.emailSends.push(Date.now());
|
|
335
362
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
336
363
|
});
|
|
337
364
|
}
|
|
@@ -426,8 +453,48 @@ export class Relay {
|
|
|
426
453
|
return null;
|
|
427
454
|
return { account, device };
|
|
428
455
|
}
|
|
456
|
+
localTrusted(ip) {
|
|
457
|
+
return this.trustLocal && isTrustedSource(ip);
|
|
458
|
+
}
|
|
459
|
+
adminTokenPresented(req, url) {
|
|
460
|
+
if (!this.adminToken)
|
|
461
|
+
return false;
|
|
462
|
+
const t = url.searchParams.get("token")
|
|
463
|
+
?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "")
|
|
464
|
+
?? "";
|
|
465
|
+
if (!t)
|
|
466
|
+
return false;
|
|
467
|
+
const a = Buffer.from(t), b = Buffer.from(this.adminToken);
|
|
468
|
+
return a.length === b.length && timingSafeEqualBuf(a, b);
|
|
469
|
+
}
|
|
470
|
+
adminLoginOk(ip) {
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
|
|
473
|
+
return recent.length < 10;
|
|
474
|
+
}
|
|
475
|
+
noteAdminFail(ip) {
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
|
|
478
|
+
recent.push(now);
|
|
479
|
+
this.adminHits.set(ip, recent);
|
|
480
|
+
if (this.adminHits.size > 10_000) {
|
|
481
|
+
for (const [k, v] of this.adminHits) {
|
|
482
|
+
const live = v.filter((t) => now - t < 300_000);
|
|
483
|
+
if (live.length)
|
|
484
|
+
this.adminHits.set(k, live);
|
|
485
|
+
else
|
|
486
|
+
this.adminHits.delete(k);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
emailBudgetOk() {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
|
|
493
|
+
const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
494
|
+
return this.emailSends.length < cap;
|
|
495
|
+
}
|
|
429
496
|
authRateOk(ip) {
|
|
430
|
-
if (
|
|
497
|
+
if (this.localTrusted(ip))
|
|
431
498
|
return true;
|
|
432
499
|
const now = Date.now();
|
|
433
500
|
const recent = (this.authHits.get(ip) ?? []).filter((t) => now - t < 60_000);
|
|
@@ -616,6 +683,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
616
683
|
}
|
|
617
684
|
catch { } }, 10);
|
|
618
685
|
};
|
|
686
|
+
let helloTimer = setTimeout(() => {
|
|
687
|
+
if (!space) {
|
|
688
|
+
try {
|
|
689
|
+
ws.close(1008, "HANDSHAKE_TIMEOUT");
|
|
690
|
+
}
|
|
691
|
+
catch { }
|
|
692
|
+
}
|
|
693
|
+
}, 20_000);
|
|
694
|
+
const clearHelloTimer = () => { if (helloTimer) {
|
|
695
|
+
clearTimeout(helloTimer);
|
|
696
|
+
helloTimer = null;
|
|
697
|
+
} };
|
|
698
|
+
ws.on("close", clearHelloTimer);
|
|
619
699
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
620
700
|
ws.on("message", (raw) => {
|
|
621
701
|
let msg;
|
|
@@ -644,19 +724,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
644
724
|
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
645
725
|
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
646
726
|
}
|
|
647
|
-
let enrolled = null;
|
|
648
727
|
let sp;
|
|
649
728
|
if (m.enroll) {
|
|
650
729
|
sp = this.spaces.get(m.space) ?? null;
|
|
651
730
|
if (!sp)
|
|
652
731
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
653
|
-
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
654
|
-
if (!r.ok) {
|
|
655
|
-
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
656
|
-
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
657
|
-
}
|
|
658
|
-
enrolled = sp.token;
|
|
659
|
-
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
660
732
|
}
|
|
661
733
|
else {
|
|
662
734
|
if (!this.spaces.has(m.space)) {
|
|
@@ -671,7 +743,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
671
743
|
this.noteSpaceCreate(ip);
|
|
672
744
|
}
|
|
673
745
|
let acctSession = null;
|
|
674
|
-
if (this.requireAccounts && this.accounts && !
|
|
746
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
675
747
|
const auth = this.accounts.authorize(m.pubkey, m.member);
|
|
676
748
|
if (!auth.ok) {
|
|
677
749
|
const msg = auth.reason === "username_mismatch"
|
|
@@ -683,6 +755,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
683
755
|
m.member = auth.account.username;
|
|
684
756
|
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
|
|
685
757
|
}
|
|
758
|
+
let enrolled = null;
|
|
759
|
+
if (m.enroll) {
|
|
760
|
+
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
761
|
+
if (!r.ok) {
|
|
762
|
+
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
763
|
+
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
764
|
+
}
|
|
765
|
+
enrolled = sp.token;
|
|
766
|
+
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
767
|
+
}
|
|
686
768
|
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
687
769
|
if (verdict === "key-mismatch") {
|
|
688
770
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
@@ -701,6 +783,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
701
783
|
return;
|
|
702
784
|
}
|
|
703
785
|
space = sp;
|
|
786
|
+
clearHelloTimer();
|
|
704
787
|
sessionId = acctSession ?? this.mintSession();
|
|
705
788
|
role = m.role;
|
|
706
789
|
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
@@ -1273,7 +1356,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1273
1356
|
}
|
|
1274
1357
|
mayCreateSpace(ip) {
|
|
1275
1358
|
const now = Date.now();
|
|
1276
|
-
if (!
|
|
1359
|
+
if (!this.localTrusted(ip)) {
|
|
1277
1360
|
const recent = (this.spaceCreates.get(ip) ?? []).filter((t) => now - t < 3_600_000);
|
|
1278
1361
|
this.spaceCreates.set(ip, recent);
|
|
1279
1362
|
if (recent.length >= this.limits.spaceCreatesPerHourPerIp) {
|
|
@@ -1291,7 +1374,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1291
1374
|
return null;
|
|
1292
1375
|
}
|
|
1293
1376
|
noteSpaceCreate(ip) {
|
|
1294
|
-
if (
|
|
1377
|
+
if (this.localTrusted(ip))
|
|
1295
1378
|
return;
|
|
1296
1379
|
const arr = this.spaceCreates.get(ip) ?? [];
|
|
1297
1380
|
arr.push(Date.now());
|
|
@@ -1360,12 +1443,18 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1360
1443
|
if (existing)
|
|
1361
1444
|
return existing.token === token ? existing : null;
|
|
1362
1445
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1446
|
+
const allow = new Allowlist(this.dataDir, safe);
|
|
1447
|
+
const match = allow.tokenMatches(token);
|
|
1448
|
+
if (match === false)
|
|
1449
|
+
return null;
|
|
1450
|
+
if (match === null)
|
|
1451
|
+
allow.bindToken(token);
|
|
1363
1452
|
const store = new StateStore(this.dataDir, safe);
|
|
1364
1453
|
const restored = store.load();
|
|
1365
1454
|
const sp = {
|
|
1366
1455
|
name, token,
|
|
1367
1456
|
ledger: new Ledger(this.dataDir, safe),
|
|
1368
|
-
allow
|
|
1457
|
+
allow,
|
|
1369
1458
|
store,
|
|
1370
1459
|
members: new Map(),
|
|
1371
1460
|
recent: new Map(),
|
package/dist/roster-file.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { fingerprint } from "./identity.js";
|
|
5
|
+
function hashToken(token) {
|
|
6
|
+
return createHash("sha256").update(token).digest("hex");
|
|
7
|
+
}
|
|
4
8
|
export class Allowlist {
|
|
5
9
|
entries = new Map();
|
|
6
10
|
invites = new Map();
|
|
11
|
+
tokenHash = null;
|
|
7
12
|
file;
|
|
8
13
|
constructor(dir, space) {
|
|
9
14
|
this.file = join(dir, `${space}.members.json`);
|
|
@@ -14,14 +19,35 @@ export class Allowlist {
|
|
|
14
19
|
this.entries.set(e.member, e);
|
|
15
20
|
for (const i of doc.invites ?? [])
|
|
16
21
|
this.invites.set(i.token, i);
|
|
22
|
+
this.tokenHash = doc.tokenHash ?? null;
|
|
17
23
|
}
|
|
18
24
|
}
|
|
19
25
|
flush() {
|
|
20
26
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
21
|
-
|
|
27
|
+
const tmp = `${this.file}.tmp`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify({
|
|
22
29
|
members: [...this.entries.values()],
|
|
23
30
|
invites: [...this.invites.values()],
|
|
31
|
+
...(this.tokenHash ? { tokenHash: this.tokenHash } : {}),
|
|
24
32
|
}, null, 2) + "\n", { mode: 0o600 });
|
|
33
|
+
renameSync(tmp, this.file);
|
|
34
|
+
}
|
|
35
|
+
tokenMatches(token) {
|
|
36
|
+
if (!this.tokenHash)
|
|
37
|
+
return null;
|
|
38
|
+
const a = Buffer.from(hashToken(token), "hex");
|
|
39
|
+
const b = Buffer.from(this.tokenHash, "hex");
|
|
40
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
41
|
+
}
|
|
42
|
+
bindToken(token) {
|
|
43
|
+
const h = hashToken(token);
|
|
44
|
+
if (this.tokenHash === h)
|
|
45
|
+
return;
|
|
46
|
+
this.tokenHash = h;
|
|
47
|
+
this.flush();
|
|
48
|
+
}
|
|
49
|
+
get hasToken() {
|
|
50
|
+
return this.tokenHash !== null;
|
|
25
51
|
}
|
|
26
52
|
mintInvite(by, mayWake, ttlMinutes, token) {
|
|
27
53
|
const inv = {
|
package/dist/tray.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { CONFIG_DIR, load } from "./config.js";
|
|
7
7
|
import { workerProc, notifierProc } from "./machine.js";
|
|
8
8
|
import { agentHookStates } from "./notify.js";
|
|
9
|
+
import { requireDeveloperTools } from "./notifier.js";
|
|
9
10
|
import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
|
|
10
11
|
import { VERSION } from "./version.js";
|
|
11
12
|
import { LIMITS } from "./protocol.js";
|
|
@@ -22,6 +23,7 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
|
|
|
22
23
|
throw new Error(`menu bar source is missing: ${source}`);
|
|
23
24
|
const binary = trayBinaryPath(home);
|
|
24
25
|
mkdirSync(dirname(binary), { recursive: true });
|
|
26
|
+
requireDeveloperTools("the menu bar", "menubar");
|
|
25
27
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
26
28
|
chmodSync(binary, 0o755);
|
|
27
29
|
return binary;
|
|
@@ -75,7 +77,7 @@ export async function trayStatus(cfg = load()) {
|
|
|
75
77
|
const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
76
78
|
const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
|
|
77
79
|
const [health, obligations] = await Promise.all([
|
|
78
|
-
getJson(`${base}/health`),
|
|
80
|
+
getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
|
|
79
81
|
getJson(`${base}/obligations?${q}&member=${encodeURIComponent(`human:${cfg.member}`)}`),
|
|
80
82
|
]);
|
|
81
83
|
const space = health
|
package/dist/update-check.js
CHANGED
|
@@ -2,27 +2,50 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { CONFIG_DIR } from "./config.js";
|
|
5
|
-
import { repoRoot } from "./upgrade.js";
|
|
5
|
+
import { repoRoot, PKG_NAME } from "./upgrade.js";
|
|
6
|
+
import { VERSION } from "./version.js";
|
|
7
|
+
export function semverGt(x, y) {
|
|
8
|
+
const p = (s) => s.split(".").map((n) => Number(n) || 0);
|
|
9
|
+
const [a, b] = [p(x), p(y)];
|
|
10
|
+
for (let i = 0; i < 3; i++) {
|
|
11
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
12
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
6
16
|
export function updateStatus(deps) {
|
|
7
17
|
const cache = deps.readCache();
|
|
8
18
|
if (cache && deps.now() - cache.checkedAt < deps.throttleMs) {
|
|
9
|
-
return { behind: cache.behind, refreshed: false };
|
|
19
|
+
return { behind: cache.behind, refreshed: false, latest: cache.latest };
|
|
20
|
+
}
|
|
21
|
+
if (deps.isGit()) {
|
|
22
|
+
try {
|
|
23
|
+
const behind = deps.fetchBehind();
|
|
24
|
+
deps.writeCache({ checkedAt: deps.now(), behind });
|
|
25
|
+
return { behind, refreshed: true };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { behind: cache?.behind ?? 0, refreshed: false };
|
|
29
|
+
}
|
|
10
30
|
}
|
|
11
|
-
if (!deps.isGit())
|
|
12
|
-
return { behind: cache?.behind ?? 0, refreshed: false };
|
|
13
31
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
const latest = deps.npmLatest();
|
|
33
|
+
if (!latest)
|
|
34
|
+
return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
|
|
35
|
+
const behind = semverGt(latest, deps.current()) ? 1 : 0;
|
|
36
|
+
deps.writeCache({ checkedAt: deps.now(), behind, latest });
|
|
37
|
+
return { behind, refreshed: true, latest };
|
|
17
38
|
}
|
|
18
39
|
catch {
|
|
19
|
-
return { behind: cache?.behind ?? 0, refreshed: false };
|
|
40
|
+
return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
|
|
20
41
|
}
|
|
21
42
|
}
|
|
22
|
-
export function versionLine(version, behind) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
export function versionLine(version, behind, latest) {
|
|
44
|
+
if (behind <= 0)
|
|
45
|
+
return `pantheon ${version}`;
|
|
46
|
+
return latest
|
|
47
|
+
? `pantheon ${version} · ${latest} available — run \`pantheon upgrade\``
|
|
48
|
+
: `pantheon ${version} · ${behind} update${behind === 1 ? "" : "s"} behind — run \`pantheon upgrade\``;
|
|
26
49
|
}
|
|
27
50
|
const cachePath = (home) => join(home ?? CONFIG_DIR, "update-check.json");
|
|
28
51
|
export function defaultDeps(home) {
|
|
@@ -53,10 +76,19 @@ export function defaultDeps(home) {
|
|
|
53
76
|
const out = execFileSync("git", ["-C", root, "rev-list", "--count", "HEAD..@{u}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2500 }).trim();
|
|
54
77
|
return Number(out) || 0;
|
|
55
78
|
},
|
|
79
|
+
npmLatest: () => {
|
|
80
|
+
try {
|
|
81
|
+
return execFileSync("npm", ["view", PKG_NAME, "version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
current: () => VERSION,
|
|
56
88
|
};
|
|
57
89
|
}
|
|
58
90
|
export function updateBanner(version, deps = defaultDeps()) {
|
|
59
|
-
const { behind } = updateStatus(deps);
|
|
60
|
-
return versionLine(version, behind);
|
|
91
|
+
const { behind, latest } = updateStatus(deps);
|
|
92
|
+
return versionLine(version, behind, latest);
|
|
61
93
|
}
|
|
62
94
|
//# sourceMappingURL=update-check.js.map
|
package/dist/upgrade.js
CHANGED
|
@@ -15,6 +15,7 @@ function pkgVersion(root) {
|
|
|
15
15
|
return "unknown";
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
export const PKG_NAME = "@join-pantheon/cli";
|
|
18
19
|
export function defaultDeps() {
|
|
19
20
|
const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
20
21
|
return {
|
|
@@ -43,6 +44,15 @@ export function defaultDeps() {
|
|
|
43
44
|
catch { }
|
|
44
45
|
return up;
|
|
45
46
|
},
|
|
47
|
+
npmUpgrade: () => execFileSync("npm", ["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
|
|
48
|
+
npmLatest: () => {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync("npm", ["view", `${PKG_NAME}`, "version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
46
56
|
};
|
|
47
57
|
}
|
|
48
58
|
export function upgrade(deps = defaultDeps()) {
|
|
@@ -51,7 +61,25 @@ export function upgrade(deps = defaultDeps()) {
|
|
|
51
61
|
const steps = [];
|
|
52
62
|
const stop = (name, detail) => ({ root, from, to: from, steps: [...steps, { name, ok: false, detail }], ok: false, alreadyCurrent: false, restart: [] });
|
|
53
63
|
if (!deps.isGit(root)) {
|
|
54
|
-
|
|
64
|
+
try {
|
|
65
|
+
deps.npmUpgrade();
|
|
66
|
+
steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@latest` });
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return stop("npm", `\`npm install -g ${PKG_NAME}@latest\` failed: ${e.message.split("\n")[0]}. ` +
|
|
70
|
+
`You may need to re-run it with the right permissions (or your Node version manager's global scope).`);
|
|
71
|
+
}
|
|
72
|
+
let wired = [];
|
|
73
|
+
try {
|
|
74
|
+
wired = deps.rewire();
|
|
75
|
+
steps.push({ name: "wire", ok: true, detail: wired.map((w) => `${w.tool}: ${w.status}`).join(" ") });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
steps.push({ name: "wire", ok: false, detail: `could not refresh agent config: ${e.message.split("\n")[0]} (run \`pantheon install --user\`)` });
|
|
79
|
+
}
|
|
80
|
+
const to = deps.npmLatest() ?? "latest";
|
|
81
|
+
const restart = [...deps.running(), "your agent session"];
|
|
82
|
+
return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
|
|
55
83
|
}
|
|
56
84
|
const dirty = deps.dirty(root);
|
|
57
85
|
if (dirty.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|