@join-pantheon/cli 0.18.2 → 0.19.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/README.md +60 -29
- 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 +155 -25
- 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 +4 -4
package/README.md
CHANGED
|
@@ -28,8 +28,7 @@ npm install -g @join-pantheon/cli
|
|
|
28
28
|
Or run it without installing at all:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
npx @join-pantheon/cli
|
|
32
|
-
npx @join-pantheon/cli join <invite> # them
|
|
31
|
+
npx @join-pantheon/cli # guided sign-in; it tells you what to run next
|
|
33
32
|
```
|
|
34
33
|
|
|
35
34
|
**First, verify your email (once per person):**
|
|
@@ -55,9 +54,13 @@ pantheon start
|
|
|
55
54
|
**Your teammate, from their clone of the project:**
|
|
56
55
|
|
|
57
56
|
```bash
|
|
58
|
-
pantheon join
|
|
57
|
+
pantheon join pantheons://relay.joinpantheon.network/acme#C1qhJfdHBaPjuG3jF48vcqrP
|
|
59
58
|
```
|
|
60
59
|
|
|
60
|
+
Signed in, `pantheon start` puts your space on the hosted relay, so that link works from any
|
|
61
|
+
network — no tunnel, no VPN. The space is named after your repo; if another team already took
|
|
62
|
+
that name, start picks `<repo>-<you>` and says so (or choose your own with `--space`).
|
|
63
|
+
|
|
61
64
|
That's it — one command each sets up the **whole machine**: a relay and space (you) or membership
|
|
62
65
|
(them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
|
|
63
66
|
every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
|
|
@@ -88,7 +91,10 @@ Need another (a third person, or your own second machine)? `pantheon invite`.
|
|
|
88
91
|
|
|
89
92
|
### Reaching each other across cities
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
On the hosted relay there is nothing to work out: every signed-in machine dials
|
|
95
|
+
`relay.joinpantheon.network`. The rest of this section is for **self-hosting** (`pantheon start
|
|
96
|
+
--relay …`, or a machine with no account). Then `pantheon start` runs a relay on your machine and
|
|
97
|
+
works out where a teammate can reach it. If Tailscale is running it uses your tailnet address, which
|
|
92
98
|
is private and needs no TLS. Otherwise it tells you the address is local-only and how to fix it:
|
|
93
99
|
|
|
94
100
|
```
|
|
@@ -114,7 +120,7 @@ internet, and whether you were actually admitted. Non-zero exit means don't star
|
|
|
114
120
|
<details><summary>The long way round, if you want to control each piece</summary>
|
|
115
121
|
|
|
116
122
|
`pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
|
|
117
|
-
· `pantheon install` (project-scope
|
|
123
|
+
· `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
|
|
118
124
|
allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
|
|
119
125
|
does, one step at a time.
|
|
120
126
|
|
|
@@ -222,7 +228,7 @@ Human decisions use a separate, model-free path. `ESCALATE` creates a durable
|
|
|
222
228
|
```sh
|
|
223
229
|
pantheon service install notifier # starts at login; no wake permission needed
|
|
224
230
|
pantheon profile set --surface codex # codex | claude-code | cursor
|
|
225
|
-
pantheon profile set --name "
|
|
231
|
+
pantheon profile set --name "Alex" --title "Head of Product" \
|
|
226
232
|
--responsibilities product-ui,gtm,marketing \
|
|
227
233
|
--human-required product-ui,gtm,marketing
|
|
228
234
|
pantheon directory # shared responsibility and authority graph
|
|
@@ -249,7 +255,8 @@ local. Before a consequential decision, agents call `pantheon_route_decision`
|
|
|
249
255
|
with explicit domains. Matching `human_required` owners receive separate
|
|
250
256
|
`HUMAN_REQUIRED` acts; matching `agent_can_decide` owners may answer through
|
|
251
257
|
their delegate. Missing authority fails closed and escalates to the caller's
|
|
252
|
-
human.
|
|
258
|
+
human. The security model is written up at
|
|
259
|
+
[joinpantheon.network/security](https://www.joinpantheon.network/security).
|
|
253
260
|
|
|
254
261
|
Delegation is one hop, time-bounded, and preserves the original authority
|
|
255
262
|
level: a human-only decision cannot become agent-decidable through delegation.
|
|
@@ -312,7 +319,7 @@ dead air on an unattended machine. And the worker finally says what it is doing:
|
|
|
312
319
|
and cap refusal is logged with its reason, and `worker-state.json` feeds the same line to
|
|
313
320
|
`pantheon ps` and the menu bar.
|
|
314
321
|
|
|
315
|
-
Proven by
|
|
322
|
+
Proven by end-to-end tests with real workers and a real relay: a full unattended round trip where the asking
|
|
316
323
|
session closes before the reply and both workers finish the goal anyway; deliver→review→accept
|
|
317
324
|
with no human; the cap and lock governors under two workers per member.
|
|
318
325
|
|
|
@@ -328,12 +335,11 @@ the same "no shell unless you said so" rule spelled in its own dialect:
|
|
|
328
335
|
| codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
|
|
329
336
|
| cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
|
|
330
337
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
ignored), and codex silently skips a newly installed hook until you approve it with `/hooks`.
|
|
338
|
+
All three backends are verified with **real live woken runs** — an idle machine answering a
|
|
339
|
+
peer's ASK end to end with Claude Code, Cursor, and Codex. Codex quirks the tooling handles for
|
|
340
|
+
you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
|
|
341
|
+
`.codex/` is ignored), and codex silently skips a newly installed hook until you approve it with
|
|
342
|
+
`/hooks`.
|
|
337
343
|
|
|
338
344
|
### Personas — telling the worker who to be
|
|
339
345
|
|
|
@@ -401,19 +407,25 @@ on each laptop; what crosses the wire is acts, contracts, diff summaries and dec
|
|
|
401
407
|
## Commands
|
|
402
408
|
|
|
403
409
|
```
|
|
404
|
-
pantheon
|
|
405
|
-
pantheon
|
|
406
|
-
pantheon join
|
|
407
|
-
pantheon
|
|
408
|
-
pantheon status who
|
|
409
|
-
pantheon watch
|
|
410
|
-
pantheon
|
|
411
|
-
pantheon
|
|
412
|
-
pantheon
|
|
410
|
+
pantheon signup / verify / login verify your email once; sign a second machine in
|
|
411
|
+
pantheon start create your team on the hosted relay, get the line to send
|
|
412
|
+
pantheon join <line> join a teammate's team from their line
|
|
413
|
+
pantheon invite another single-use line (third person, second machine)
|
|
414
|
+
pantheon status · brief · team who is here, what needs you, who is in the space
|
|
415
|
+
pantheon watch live tail of everything the delegates say to each other
|
|
416
|
+
pantheon doctor preflight: node, keys, MCP entry, relay, membership
|
|
417
|
+
pantheon worker / service stay answerable while you are away (install as a service)
|
|
418
|
+
pantheon persona list / init / show — what a woken worker may do
|
|
419
|
+
pantheon sessions · logout your devices and sessions; sign this machine out
|
|
420
|
+
pantheon account delete remove your account and everything the relay holds on you
|
|
421
|
+
pantheon upgrade pull the latest release and re-wire your agents
|
|
422
|
+
pantheon relay run your own relay (self-hosting)
|
|
423
|
+
pantheon mcp the stdio MCP server — your agent runs this, not you
|
|
413
424
|
```
|
|
414
425
|
|
|
415
|
-
`pantheon watch` is how a human stays oriented without
|
|
416
|
-
appended to
|
|
426
|
+
`pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
|
|
427
|
+
being in the loop; every act is also appended to the relay's ledger, so the full history is
|
|
428
|
+
replayable.
|
|
417
429
|
|
|
418
430
|
## What this still is not
|
|
419
431
|
|
|
@@ -429,10 +441,29 @@ appended to `pantheon-data/<space>.jsonl`, so the full history is replayable wit
|
|
|
429
441
|
- **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
|
|
430
442
|
can execute commands on your machine. Grant `--may-wake` deliberately.
|
|
431
443
|
|
|
432
|
-
##
|
|
444
|
+
## Privacy, data, and support
|
|
445
|
+
|
|
446
|
+
The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
|
|
447
|
+
verified email, username, and device public keys; the IPs and timestamps of your sessions and
|
|
448
|
+
an audit trail of identity and membership events; and, per space, the acts your delegates
|
|
449
|
+
exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
|
|
450
|
+
machine — the relay has no filesystem or shell access by design.
|
|
451
|
+
|
|
452
|
+
- `pantheon sessions` shows every device and session on your account; `revoke` signs one out.
|
|
453
|
+
- `pantheon audit` is your own trail; `pantheon account delete` removes your account, devices,
|
|
454
|
+
sessions, and memberships.
|
|
455
|
+
- Prefer to run your own? `pantheon relay` is the whole server; point machines at it with
|
|
456
|
+
`pantheon start --relay wss://your-host`. Accounts are optional there.
|
|
457
|
+
|
|
458
|
+
Security details and how to report a vulnerability:
|
|
459
|
+
[joinpantheon.network/security](https://www.joinpantheon.network/security). Everything else:
|
|
460
|
+
[joinpantheon.network](https://www.joinpantheon.network).
|
|
461
|
+
|
|
462
|
+
## Upgrading
|
|
433
463
|
|
|
434
464
|
```bash
|
|
435
|
-
|
|
436
|
-
node --test dist/test/fabric.test.js # protocol, obligations, guards, auth, claims, durability
|
|
437
|
-
node --test dist/test/mcp.test.js # real stdio JSON-RPC against the MCP server
|
|
465
|
+
pantheon upgrade # installs the latest release and re-wires your agents
|
|
438
466
|
```
|
|
467
|
+
|
|
468
|
+
Every command prints its version and nudges you when a newer release exists. Restart your agent
|
|
469
|
+
session after upgrading so it loads the new MCP server.
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
|
|
3
3
|
import { rmSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { WebSocketServer } from "ws";
|
|
6
7
|
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
|
|
@@ -78,7 +79,7 @@ export class Relay {
|
|
|
78
79
|
dataDir;
|
|
79
80
|
log;
|
|
80
81
|
http = createServer((req, res) => this.onHttp(req, res));
|
|
81
|
-
wss = new WebSocketServer({ noServer: true });
|
|
82
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
|
|
82
83
|
heartbeat;
|
|
83
84
|
compactedAt = new Map();
|
|
84
85
|
sessionSeq = 0;
|
|
@@ -88,8 +89,11 @@ export class Relay {
|
|
|
88
89
|
pruneMs;
|
|
89
90
|
accounts;
|
|
90
91
|
requireAccounts;
|
|
92
|
+
trustLocal;
|
|
91
93
|
email;
|
|
92
94
|
authHits = new Map();
|
|
95
|
+
adminHits = new Map();
|
|
96
|
+
emailSends = [];
|
|
93
97
|
adminToken;
|
|
94
98
|
backupTimer;
|
|
95
99
|
constructor(opts = {}) {
|
|
@@ -97,6 +101,9 @@ export class Relay {
|
|
|
97
101
|
this.dataDir = opts.dataDir ?? "./pantheon-data";
|
|
98
102
|
this.log = opts.log ?? ((l) => console.error(l));
|
|
99
103
|
this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
|
|
104
|
+
this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
|
|
105
|
+
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
106
|
+
: !this.requireAccounts;
|
|
100
107
|
this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
|
|
101
108
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
102
109
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
@@ -160,20 +167,26 @@ export class Relay {
|
|
|
160
167
|
return this.onAuthHttp(req, res, url, ip);
|
|
161
168
|
}
|
|
162
169
|
if (url.pathname === "/health") {
|
|
170
|
+
const adminView = !!this.adminToken && this.adminTokenPresented(req, url);
|
|
171
|
+
const detail = (s) => ({
|
|
172
|
+
name: s.name,
|
|
173
|
+
members: s.members.size,
|
|
174
|
+
online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
|
|
175
|
+
sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
|
|
176
|
+
goals: s.goals.size,
|
|
177
|
+
seq: s.ledger.head,
|
|
178
|
+
});
|
|
179
|
+
const asked = url.searchParams.get("space");
|
|
180
|
+
const one = asked ? this.spaces.get(asked) : undefined;
|
|
163
181
|
return this.json(res, 200, {
|
|
164
182
|
ok: true,
|
|
165
183
|
v: PROTOCOL_VERSION,
|
|
166
184
|
pkg: VERSION,
|
|
167
185
|
space_count: this.spaces.size,
|
|
168
186
|
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
|
-
})),
|
|
187
|
+
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
188
|
+
: asked ? { spaces: one ? [detail(one)] : [] }
|
|
189
|
+
: {}),
|
|
177
190
|
});
|
|
178
191
|
}
|
|
179
192
|
if (url.pathname === "/history") {
|
|
@@ -263,7 +276,7 @@ export class Relay {
|
|
|
263
276
|
onAuthHttp(req, res, url, ip) {
|
|
264
277
|
if (url.pathname === "/")
|
|
265
278
|
return this.landingPage(res);
|
|
266
|
-
if (url.pathname === "/admin" || url.pathname === "/admin/export" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
|
|
279
|
+
if (url.pathname === "/admin" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
|
|
267
280
|
const A0 = this.accounts;
|
|
268
281
|
if (!A0 || !this.adminToken)
|
|
269
282
|
return this.json(res, 404, { error: "not found" });
|
|
@@ -271,10 +284,17 @@ export class Relay {
|
|
|
271
284
|
const fromCookie = cookieVal(req, "pantheon_admin");
|
|
272
285
|
const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
|
|
273
286
|
if (url.pathname === "/admin/login" && req.method === "POST") {
|
|
287
|
+
if (!this.adminLoginOk(ip)) {
|
|
288
|
+
res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
|
|
289
|
+
return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
|
|
290
|
+
}
|
|
274
291
|
return this.readForm(req, (body) => {
|
|
275
|
-
if (!tokenOk(body.token ?? ""))
|
|
292
|
+
if (!tokenOk(body.token ?? "")) {
|
|
293
|
+
this.noteAdminFail(ip);
|
|
276
294
|
return this.adminLoginPage(res, true);
|
|
277
|
-
|
|
295
|
+
}
|
|
296
|
+
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
297
|
+
res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
|
|
278
298
|
return void res.end();
|
|
279
299
|
});
|
|
280
300
|
}
|
|
@@ -288,6 +308,11 @@ export class Relay {
|
|
|
288
308
|
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
289
309
|
return this.adminExport(res, A0, url);
|
|
290
310
|
}
|
|
311
|
+
if (url.pathname === "/admin/backup") {
|
|
312
|
+
if (!authed)
|
|
313
|
+
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
314
|
+
return this.adminBackup(res, A0);
|
|
315
|
+
}
|
|
291
316
|
if (!authed)
|
|
292
317
|
return this.adminLoginPage(res, false);
|
|
293
318
|
return this.adminDashboard(res, A0);
|
|
@@ -315,7 +340,11 @@ export class Relay {
|
|
|
315
340
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
316
341
|
}
|
|
317
342
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
343
|
+
if (!this.emailBudgetOk())
|
|
344
|
+
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
318
345
|
const sent = await this.email.sendCode(b.email, started.code, "signup");
|
|
346
|
+
if (sent)
|
|
347
|
+
this.emailSends.push(Date.now());
|
|
319
348
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
320
349
|
});
|
|
321
350
|
}
|
|
@@ -331,7 +360,11 @@ export class Relay {
|
|
|
331
360
|
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
361
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
333
362
|
}
|
|
363
|
+
if (!this.emailBudgetOk())
|
|
364
|
+
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
334
365
|
const sent = await this.email.sendCode(b.email, started.code, "login");
|
|
366
|
+
if (sent)
|
|
367
|
+
this.emailSends.push(Date.now());
|
|
335
368
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
336
369
|
});
|
|
337
370
|
}
|
|
@@ -426,8 +459,48 @@ export class Relay {
|
|
|
426
459
|
return null;
|
|
427
460
|
return { account, device };
|
|
428
461
|
}
|
|
462
|
+
localTrusted(ip) {
|
|
463
|
+
return this.trustLocal && isTrustedSource(ip);
|
|
464
|
+
}
|
|
465
|
+
adminTokenPresented(req, url) {
|
|
466
|
+
if (!this.adminToken)
|
|
467
|
+
return false;
|
|
468
|
+
const t = url.searchParams.get("token")
|
|
469
|
+
?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "")
|
|
470
|
+
?? "";
|
|
471
|
+
if (!t)
|
|
472
|
+
return false;
|
|
473
|
+
const a = Buffer.from(t), b = Buffer.from(this.adminToken);
|
|
474
|
+
return a.length === b.length && timingSafeEqualBuf(a, b);
|
|
475
|
+
}
|
|
476
|
+
adminLoginOk(ip) {
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
|
|
479
|
+
return recent.length < 10;
|
|
480
|
+
}
|
|
481
|
+
noteAdminFail(ip) {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
|
|
484
|
+
recent.push(now);
|
|
485
|
+
this.adminHits.set(ip, recent);
|
|
486
|
+
if (this.adminHits.size > 10_000) {
|
|
487
|
+
for (const [k, v] of this.adminHits) {
|
|
488
|
+
const live = v.filter((t) => now - t < 300_000);
|
|
489
|
+
if (live.length)
|
|
490
|
+
this.adminHits.set(k, live);
|
|
491
|
+
else
|
|
492
|
+
this.adminHits.delete(k);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
emailBudgetOk() {
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
|
|
499
|
+
const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
500
|
+
return this.emailSends.length < cap;
|
|
501
|
+
}
|
|
429
502
|
authRateOk(ip) {
|
|
430
|
-
if (
|
|
503
|
+
if (this.localTrusted(ip))
|
|
431
504
|
return true;
|
|
432
505
|
const now = Date.now();
|
|
433
506
|
const recent = (this.authHits.get(ip) ?? []).filter((t) => now - t < 60_000);
|
|
@@ -579,6 +652,41 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
579
652
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
580
653
|
res.end(csv);
|
|
581
654
|
}
|
|
655
|
+
adminBackup(res, A) {
|
|
656
|
+
try {
|
|
657
|
+
A.backup(join(this.dataDir, "backups"));
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
this.log(`! backup snapshot before archive failed: ${e.message}`);
|
|
661
|
+
}
|
|
662
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
663
|
+
const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, "."], { stdio: ["ignore", "pipe", "pipe"] });
|
|
664
|
+
let err = "";
|
|
665
|
+
tar.stderr.on("data", (c) => { err += String(c); });
|
|
666
|
+
tar.on("error", (e) => {
|
|
667
|
+
this.log(`! backup archive failed to start: ${e.message}`);
|
|
668
|
+
if (!res.headersSent)
|
|
669
|
+
this.json(res, 500, { error: `could not start tar: ${e.message}` });
|
|
670
|
+
});
|
|
671
|
+
tar.stdout.once("data", () => {
|
|
672
|
+
if (!res.headersSent) {
|
|
673
|
+
res.writeHead(200, { "content-type": "application/gzip", "content-disposition": `attachment; filename="pantheon-data-${stamp}.tgz"` });
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
tar.stdout.pipe(res);
|
|
677
|
+
tar.on("close", (code) => {
|
|
678
|
+
if (code !== 0) {
|
|
679
|
+
this.log(`! backup archive exited ${code}: ${err.trim()}`);
|
|
680
|
+
if (!res.headersSent)
|
|
681
|
+
this.json(res, 500, { error: `tar exited ${code}` });
|
|
682
|
+
}
|
|
683
|
+
res.end();
|
|
684
|
+
});
|
|
685
|
+
res.on("close", () => { try {
|
|
686
|
+
tar.kill();
|
|
687
|
+
}
|
|
688
|
+
catch { } });
|
|
689
|
+
}
|
|
582
690
|
landingPage(res) {
|
|
583
691
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
584
692
|
res.end(`<!doctype html><meta charset=utf-8><title>pantheon</title>
|
|
@@ -616,6 +724,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
616
724
|
}
|
|
617
725
|
catch { } }, 10);
|
|
618
726
|
};
|
|
727
|
+
let helloTimer = setTimeout(() => {
|
|
728
|
+
if (!space) {
|
|
729
|
+
try {
|
|
730
|
+
ws.close(1008, "HANDSHAKE_TIMEOUT");
|
|
731
|
+
}
|
|
732
|
+
catch { }
|
|
733
|
+
}
|
|
734
|
+
}, 20_000);
|
|
735
|
+
const clearHelloTimer = () => { if (helloTimer) {
|
|
736
|
+
clearTimeout(helloTimer);
|
|
737
|
+
helloTimer = null;
|
|
738
|
+
} };
|
|
739
|
+
ws.on("close", clearHelloTimer);
|
|
619
740
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
620
741
|
ws.on("message", (raw) => {
|
|
621
742
|
let msg;
|
|
@@ -644,19 +765,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
644
765
|
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
645
766
|
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
646
767
|
}
|
|
647
|
-
let enrolled = null;
|
|
648
768
|
let sp;
|
|
649
769
|
if (m.enroll) {
|
|
650
770
|
sp = this.spaces.get(m.space) ?? null;
|
|
651
771
|
if (!sp)
|
|
652
772
|
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
773
|
}
|
|
661
774
|
else {
|
|
662
775
|
if (!this.spaces.has(m.space)) {
|
|
@@ -671,7 +784,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
671
784
|
this.noteSpaceCreate(ip);
|
|
672
785
|
}
|
|
673
786
|
let acctSession = null;
|
|
674
|
-
if (this.requireAccounts && this.accounts && !
|
|
787
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
675
788
|
const auth = this.accounts.authorize(m.pubkey, m.member);
|
|
676
789
|
if (!auth.ok) {
|
|
677
790
|
const msg = auth.reason === "username_mismatch"
|
|
@@ -683,6 +796,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
683
796
|
m.member = auth.account.username;
|
|
684
797
|
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
|
|
685
798
|
}
|
|
799
|
+
let enrolled = null;
|
|
800
|
+
if (m.enroll) {
|
|
801
|
+
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
802
|
+
if (!r.ok) {
|
|
803
|
+
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
804
|
+
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
805
|
+
}
|
|
806
|
+
enrolled = sp.token;
|
|
807
|
+
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
808
|
+
}
|
|
686
809
|
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
687
810
|
if (verdict === "key-mismatch") {
|
|
688
811
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
@@ -701,6 +824,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
701
824
|
return;
|
|
702
825
|
}
|
|
703
826
|
space = sp;
|
|
827
|
+
clearHelloTimer();
|
|
704
828
|
sessionId = acctSession ?? this.mintSession();
|
|
705
829
|
role = m.role;
|
|
706
830
|
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
@@ -1273,7 +1397,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1273
1397
|
}
|
|
1274
1398
|
mayCreateSpace(ip) {
|
|
1275
1399
|
const now = Date.now();
|
|
1276
|
-
if (!
|
|
1400
|
+
if (!this.localTrusted(ip)) {
|
|
1277
1401
|
const recent = (this.spaceCreates.get(ip) ?? []).filter((t) => now - t < 3_600_000);
|
|
1278
1402
|
this.spaceCreates.set(ip, recent);
|
|
1279
1403
|
if (recent.length >= this.limits.spaceCreatesPerHourPerIp) {
|
|
@@ -1291,7 +1415,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1291
1415
|
return null;
|
|
1292
1416
|
}
|
|
1293
1417
|
noteSpaceCreate(ip) {
|
|
1294
|
-
if (
|
|
1418
|
+
if (this.localTrusted(ip))
|
|
1295
1419
|
return;
|
|
1296
1420
|
const arr = this.spaceCreates.get(ip) ?? [];
|
|
1297
1421
|
arr.push(Date.now());
|
|
@@ -1360,12 +1484,18 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1360
1484
|
if (existing)
|
|
1361
1485
|
return existing.token === token ? existing : null;
|
|
1362
1486
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1487
|
+
const allow = new Allowlist(this.dataDir, safe);
|
|
1488
|
+
const match = allow.tokenMatches(token);
|
|
1489
|
+
if (match === false)
|
|
1490
|
+
return null;
|
|
1491
|
+
if (match === null)
|
|
1492
|
+
allow.bindToken(token);
|
|
1363
1493
|
const store = new StateStore(this.dataDir, safe);
|
|
1364
1494
|
const restored = store.load();
|
|
1365
1495
|
const sp = {
|
|
1366
1496
|
name, token,
|
|
1367
1497
|
ledger: new Ledger(this.dataDir, safe),
|
|
1368
|
-
allow
|
|
1498
|
+
allow,
|
|
1369
1499
|
store,
|
|
1370
1500
|
members: new Map(),
|
|
1371
1501
|
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.1",
|
|
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": {
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"README.md",
|
|
38
38
|
"LICENSE"
|
|
39
39
|
],
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"url": "
|
|
40
|
+
"homepage": "https://www.joinpantheon.network",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://www.joinpantheon.network/security"
|
|
43
43
|
},
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"keywords": [
|