@join-pantheon/cli 0.33.3 → 0.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/accounts.js CHANGED
@@ -2,9 +2,31 @@ import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
4
4
  import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, rmSync } from "node:fs";
5
+ import { Worker } from "node:worker_threads";
5
6
  import { dirname, join } from "node:path";
6
7
  import { fingerprint } from "./identity.js";
7
8
  const nodeRequire = createRequire(import.meta.url);
9
+ const BACKUP_WORKER = `
10
+ const { workerData, parentPort } = require("node:worker_threads");
11
+ const fs = require("node:fs"), path = require("node:path");
12
+ const warn = process.emitWarning.bind(process);
13
+ process.emitWarning = (w, ...r) => { if (String((w && w.message) || w).includes("SQLite")) return; return warn(w, ...r); };
14
+ try {
15
+ const { DatabaseSync } = require("node:sqlite");
16
+ const { db, dir, file, keep } = workerData;
17
+ const conn = new DatabaseSync(db);
18
+ try {
19
+ conn.exec("PRAGMA busy_timeout = 10000");
20
+ conn.exec("VACUUM INTO '" + file.replace(/'/g, "''") + "'");
21
+ } finally { conn.close(); }
22
+ try { fs.chmodSync(file, 0o600); } catch {}
23
+ const snaps = fs.readdirSync(dir).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
24
+ for (const old of snaps.slice(0, Math.max(0, snaps.length - keep))) { try { fs.rmSync(path.join(dir, old)); } catch {} }
25
+ parentPort.postMessage({ ok: true });
26
+ } catch (e) {
27
+ parentPort.postMessage({ ok: false, error: (e && e.message) || String(e) });
28
+ }
29
+ `;
8
30
  const CODE_TTL_MS = 10 * 60 * 1_000;
9
31
  const CODE_RESEND_COOLDOWN_MS = 60 * 1_000;
10
32
  const MAX_ATTEMPTS = 5;
@@ -28,8 +50,10 @@ export function validEmail(email) {
28
50
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
29
51
  }
30
52
  export class Accounts {
53
+ dbPath;
31
54
  db;
32
55
  constructor(dbPath) {
56
+ this.dbPath = dbPath;
33
57
  if (dbPath !== ":memory:") {
34
58
  mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 });
35
59
  try {
@@ -660,6 +684,35 @@ export class Accounts {
660
684
  }
661
685
  return file;
662
686
  }
687
+ backupAsync(dir, keep = 14) {
688
+ if (this.dbPath === ":memory:") {
689
+ try {
690
+ return Promise.resolve(this.backup(dir, keep));
691
+ }
692
+ catch (e) {
693
+ return Promise.reject(e);
694
+ }
695
+ }
696
+ mkdirSync(dir, { recursive: true });
697
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
698
+ const file = join(dir, `accounts-${stamp}.db`);
699
+ return new Promise((resolve, reject) => {
700
+ const w = new Worker(BACKUP_WORKER, { eval: true, workerData: { db: this.dbPath, dir, file, keep } });
701
+ let settled = false;
702
+ w.once("message", (m) => { settled = true; if (m.ok)
703
+ resolve(file);
704
+ else
705
+ reject(new Error(m.error ?? "backup failed")); });
706
+ w.once("error", (e) => { if (!settled) {
707
+ settled = true;
708
+ reject(e);
709
+ } });
710
+ w.once("exit", (code) => { if (!settled) {
711
+ settled = true;
712
+ reject(new Error(`backup worker exited ${code}`));
713
+ } });
714
+ });
715
+ }
663
716
  auditLog(opts = {}) {
664
717
  const where = [];
665
718
  const args = [];
package/dist/board.js CHANGED
@@ -527,8 +527,7 @@ export async function startBoardServer(o = {}) {
527
527
  sendJson(res, 400, { ok: false, error: "bad request" });
528
528
  return;
529
529
  }
530
- const teams = lastStatus?.status.spaces ?? [];
531
- const r = await deps.cli(["history", "--goal", goal, "--json", "--last", "40", ...(teams.length > 1 ? ["--space", space] : [])], 20_000);
530
+ const r = await deps.cli(["history", "--goal", goal, "--json", "--last", "40", "--space", space], 20_000);
532
531
  try {
533
532
  const acts = JSON.parse(r.out);
534
533
  sendJson(res, 200, { ok: true, acts: acts.map((a) => ({ id: String(a.id ?? ""), act: String(a.act ?? ""), from: String(a.from ?? ""), ts: Number(a.ts) || 0, body: String(a.body ?? "").slice(0, 6000), ...(a.summary ? { summary: String(a.summary) } : {}) })) });
package/dist/cli.js CHANGED
@@ -38,7 +38,9 @@ import { homedir } from "node:os";
38
38
  import { removeHook, decideStop, writeIntent, clearIntent, readIntent, repairNote, installHooks, agentHookStates } from "./notify.js";
39
39
  import { pending, render as renderPending } from "./pending.js";
40
40
  import { signedRead } from "./reads.js";
41
- import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel, bindableFolder } from "./quickstart.js";
41
+ import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel, bindableFolder, startReconnects } from "./quickstart.js";
42
+ import { alreadyMemberLine, askedLine, configureLines, fetchSeats, inUseLine, offerSeats, seatsForTeam, seatsToMove, startGate, RECOVER_WAITING_EXIT } from "./recover-offer.js";
43
+ import { shellArg, teamNameProblem } from "./team-name.js";
42
44
  import { folderRefusalHere, isTemporaryRefusal, projectFolderAnswer } from "./folder-guard.js";
43
45
  import { installService, serviceInstalled, serviceLogFile, uninstallService } from "./service.js";
44
46
  import { LEGACY_TASK_NAME, hostAlive, hostFiles, readHostState, recordedTaskName } from "./win-host.js";
@@ -114,6 +116,39 @@ async function requireSignInOnAccountsRelay(relayFlag) {
114
116
  if (hasAccounts)
115
117
  die(`sign in first — ${relayFlag} verifies email.${signInHint("NOT_SIGNED_IN", relayFlag)}\n Then run this same pantheon start again.`);
116
118
  }
119
+ async function accountSeats(relay) {
120
+ const id = identity();
121
+ if (!id?.account)
122
+ return null;
123
+ if (relay && !sameHost(relay.replace(/^ws/i, "http"), id.relay.replace(/^ws/i, "http")))
124
+ return null;
125
+ const base = id.relay.replace(/^ws/, "http").replace(/\/+$/, "");
126
+ return Promise.race([
127
+ fetchSeats(() => signedFetch(id, "GET", "/account/recover").then(async (x) => ({ status: x.status, j: await relayJson(x, `${base}/account/recover`) }))),
128
+ new Promise((res) => setTimeout(() => res(null), 8_000).unref()),
129
+ ]);
130
+ }
131
+ async function askOnce(q) {
132
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
133
+ try {
134
+ return await questionOrNull(rl, q);
135
+ }
136
+ finally {
137
+ rl.close();
138
+ }
139
+ }
140
+ async function runRecover(team) {
141
+ const { spawnSync } = await import("node:child_process");
142
+ const keep = argv.filter((a) => /^--(no-worker|no-hook|no-notifications|no-menubar|no-guide|yes)$/.test(a));
143
+ return spawnSync(process.execPath, [process.argv[1], "recover", team, ...keep], { stdio: "inherit" }).status ?? 1;
144
+ }
145
+ async function offerSeatsHere(seats, o = {}) {
146
+ const list = seats === undefined ? await accountSeats() : seats;
147
+ if (!list)
148
+ return null;
149
+ configureLines({ launcher: managedLayout()?.launcher ?? null });
150
+ return offerSeats(list, { tty: o.tty ?? !!process.stdin.isTTY, ask: askOnce, recover: runRecover, log: (l) => console.error(l) });
151
+ }
117
152
  async function interactiveOnboard(relayArg, opts = {}) {
118
153
  const prior = identity();
119
154
  const relay = relayArg ?? prior?.relay ?? process.env.PANTHEON_RELAY ?? DEFAULT_RELAY;
@@ -257,6 +292,10 @@ async function interactiveOnboard(relayArg, opts = {}) {
257
292
  console.error(`\n ${C("You're in")} — signed in as ${B(acct.username)} <${acct.email}> on this machine.\n`);
258
293
  if (opts.joining || opts.starting)
259
294
  return true;
295
+ rl.close();
296
+ const res = await offerSeatsHere();
297
+ if (res?.offered.length)
298
+ console.error("");
260
299
  if (opts.signin) {
261
300
  console.error(` ${B("Signed in — tell your agent to continue;")} it will run the join or start again.`);
262
301
  console.error(D(` (On your own? ${C("pantheon start")} creates a team; ${C("pantheon join <invite-link>")} joins one.)`));
@@ -626,11 +665,23 @@ async function teamLimitAdvice(warning) {
626
665
  const repoLabel = (r) => r.origin ?? basename(r.root);
627
666
  const repoShare = (r) => (r.origin ? r.origin.replace(/^.*[:/]([^:/]+)$/, "$1") : basename(r.root));
628
667
  const teamFlag = (cfg) => (flag("space") ? ` --space ${cfg.space}` : "");
668
+ function sayDefaultAfter(before, gone) {
669
+ if (before.default_space !== gone.id)
670
+ return;
671
+ const now = loadAll();
672
+ const left = Object.values(now?.spaces ?? {});
673
+ if (left.length === 1)
674
+ console.error(D(` "${left[0].name}" is your default team now.`));
675
+ else if (left.length > 1)
676
+ console.error(D(` It was your default team; none is picked now. A command run from a folder bound to no team asks which (pantheon use <team> picks a default). The menu bar, alerts and worker cover all ${left.length} teams either way.`));
677
+ }
629
678
  function need() {
630
679
  const r = resolveFor(process.cwd());
631
680
  if (flag("space") && !["start", "profile"].includes(cmd)) {
632
681
  const f = loadAll();
633
682
  const m = f && findMembership(f, flag("space"));
683
+ if ((!f || !m) && r.cfg && r.cfg.space === flag("space") && r.reason === "env")
684
+ return r.cfg;
634
685
  if (!f || !m)
635
686
  die(`you are not in a team called "${flag("space")}". Yours: ${Object.values(f?.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
636
687
  return r.cfg && (r.cfg.spaceId === m.id || r.cfg.space === m.name) && r.reason !== "default" ? r.cfg : configFor(f, m, null);
@@ -1191,13 +1242,13 @@ function usageFor(command, help) {
1191
1242
  return out.length ? out.join("\n") : null;
1192
1243
  }
1193
1244
  const COMMAND_FLAGS = {
1194
- start: "--space <name> --relay <url> --me <name> --port <n> --ttl <minutes> --no-worker --no-hook --no-notifications --no-menubar --reset --yes\n and every setup answer as a flag (--name, --title, --wake, --commands, --alerts, --decides …): pantheon setup --questions",
1245
+ start: "--space <name> --relay <url> --me <name> --port <n> --ttl <minutes> --no-worker --no-hook --no-notifications --no-menubar --reset --yes --new-team (a team even though you have a seat to bring here)\n and every setup answer as a flag (--name, --title, --wake, --commands, --alerts, --decides …): pantheon setup --questions",
1195
1246
  join: "<invite link | line | code> --relay <url> --me <name> --no-worker --no-hook --no-notifications --no-menubar --reset --yes\n and every setup answer as a flag: pantheon setup --questions",
1196
1247
  invite: "--list --revoke <token|all> --may-wake | --no-wake --ttl <minutes>",
1197
1248
  admit: "<member> --may-wake | --no-wake --revoke --forget --yes",
1198
1249
  wake: "allow <member> | deny <member> | list --space <team>",
1199
1250
  trust: "[<member>] --yes --space <team>",
1200
- recover: "[<team>] | withdraw <team> | confirm <member> | cancel <member> [--yes] --space <team> --no-worker --no-hook --no-notifications --no-menubar",
1251
+ recover: "[<team>] | withdraw <team> | confirm <member> | cancel <member> [--yes] --space <team> --no-worker --no-hook --no-notifications --no-menubar\n exits 3 when the move was asked for and waits for a teammate (or a day); 0 when it moved",
1201
1252
  say: "<ACT> <text> --to <member>[,<member>] --goal <id> --ref <id | seq | last> --summary '<one line>' --domains <a,b> --branch --commit --remote (DELIVERED) --space <team>",
1202
1253
  goal: "list | open \"<title>\" --with <member>[,…] [--acceptance \"…\"] [--deadline <ISO time>] [--list] | close <id> \"<summary>\"",
1203
1254
  history: "--goal <id> --from <member> --last <n> --since <seq> --short --presence --json",
@@ -1371,8 +1422,48 @@ async function main() {
1371
1422
  if (setupFlags.errors.length)
1372
1423
  die(setupFlags.errors.join("\n") + "\n pantheon setup --questions lists every answer");
1373
1424
  const wp = unattendedProject();
1425
+ const named = flag("space") ?? null;
1426
+ const startFile = loadAll();
1427
+ const localTeam = named && startFile ? findMembership(startFile, named) : undefined;
1374
1428
  await requireSignInOnHostedRelay(flag("relay"));
1375
1429
  await requireSignInOnAccountsRelay(flag("relay"));
1430
+ const reconnect = startReconnects({ ...(named ? { space: named } : {}), reset: argv.includes("--reset") });
1431
+ const newTeam = argv.includes("--new-team") || argv.includes("--yes");
1432
+ let seatsKnown = false;
1433
+ if (!reconnect && !localTeam && identity()?.account) {
1434
+ const seats = await accountSeats(flag("relay"));
1435
+ seatsKnown = !!seats;
1436
+ if (seats) {
1437
+ const tty = !!process.stdin.isTTY;
1438
+ const mine = named ? seats.find((x) => x.space.toLowerCase() === named.toLowerCase()) : undefined;
1439
+ if (mine) {
1440
+ console.error(`\n\x1b[33m ${alreadyMemberLine(mine.space)}\x1b[0m`);
1441
+ if (mine.in_use === true)
1442
+ console.error(` ${inUseLine(mine)}`);
1443
+ if (!tty || mine.in_use === true)
1444
+ process.exit(1);
1445
+ const a = ((await askOnce(` Move your seat in ${mine.space} to this machine now? [Y/n] `)) ?? "n").trim().toLowerCase();
1446
+ process.exit(a === "" || a === "y" || a === "yes" ? await runRecover(mine.space) : 1);
1447
+ }
1448
+ const res = named || tty || newTeam || !seatsToMove(seats).length
1449
+ ? await offerSeatsHere(seats, { tty: tty && !named }) : null;
1450
+ const gate = await startGate({ named, seats, moved: res?.moved ?? [], asked: res?.asked ?? [], tty, newTeam, ask: askOnce });
1451
+ if (!gate.go) {
1452
+ console.error("");
1453
+ for (const l of gate.lines)
1454
+ console.error(gate.code === 1 ? `\x1b[33m ${l}\x1b[0m` : ` ${l}`);
1455
+ process.exit(gate.code);
1456
+ }
1457
+ if (!named && !argv.includes("--new-team") && argv.includes("--yes") && seatsToMove(seats).some((x) => !res?.moved.includes(x.space) && !res?.asked.includes(x.space))) {
1458
+ console.error(D(` (--yes for a new team beside a seat you have is going away: use --new-team)`));
1459
+ }
1460
+ }
1461
+ }
1462
+ if (named && !localTeam && seatsKnown) {
1463
+ const bad = teamNameProblem(named);
1464
+ if (bad)
1465
+ die(bad);
1466
+ }
1376
1467
  console.error(`${B("pantheon start")}\n`);
1377
1468
  if (!flag("space") && !repoInfo(process.cwd()))
1378
1469
  console.error(D(` No git repo here, so the team will be named after this folder: "${teamNameFor(process.cwd())}". Pass --space <name> to choose.\n`));
@@ -1544,6 +1635,14 @@ async function main() {
1544
1635
  }
1545
1636
  case "init": {
1546
1637
  const had = argv.includes("--reset") ? null : identity();
1638
+ if (flag("space") && !flag("token")) {
1639
+ const f0 = loadAll();
1640
+ if (!(f0 && findMembership(f0, flag("space")))) {
1641
+ const bad = teamNameProblem(flag("space"));
1642
+ if (bad)
1643
+ die(bad);
1644
+ }
1645
+ }
1547
1646
  const kp = had?.publicKey && had.privateKey ? { publicKey: had.publicKey, privateKey: had.privateKey } : newKeypair();
1548
1647
  const cfg = {
1549
1648
  relay: flag("relay", "ws://localhost:8787"),
@@ -1614,6 +1713,17 @@ async function main() {
1614
1713
  const ok = await interactiveOnboard(parsed.relay, { joining: parsed.space });
1615
1714
  if (!ok || !identity()?.account)
1616
1715
  die(`not signed in yet — the invite is still good.\n When the code arrives: pantheon verify <code> (check spam if it is not in your inbox)\n Then run this same \`pantheon join\` command again.`);
1716
+ const mineAll = await accountSeats(parsed.relay);
1717
+ const own = mineAll ? seatsForTeam(mineAll, parsed.space) : null;
1718
+ const offer = own ? await offerSeatsHere(own) : null;
1719
+ if (offer?.moved.length) {
1720
+ console.error(`\n ${C("done")} — your seat in ${offer.moved[0]} is on this machine; the invite was not needed.`);
1721
+ return;
1722
+ }
1723
+ if (offer?.asked.length) {
1724
+ console.error(`\n The invite is not needed for a team you are already in. ${askedLine(offer.asked[0])}`);
1725
+ process.exit(RECOVER_WAITING_EXIT);
1726
+ }
1617
1727
  console.error("");
1618
1728
  }
1619
1729
  if (!identity()?.account && sameHost(parsed.relay, DEFAULT_RELAY)) {
@@ -2698,6 +2808,7 @@ async function main() {
2698
2808
  const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
2699
2809
  removeMembership(m.id);
2700
2810
  console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} folder binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
2811
+ sayDefaultAfter(all, m);
2701
2812
  return;
2702
2813
  }
2703
2814
  case "uninstall":
@@ -2721,6 +2832,7 @@ async function main() {
2721
2832
  }
2722
2833
  removeMembership(m.id);
2723
2834
  console.error(`${B("left")} "${m.name}" — its token is gone from this machine${bound.length ? `, and ${bound.length} binding${bound.length === 1 ? "" : "s"} with it (${bound.join(", ")})` : ""}.`);
2835
+ sayDefaultAfter(all, m);
2724
2836
  if (others === null)
2725
2837
  console.error(D(`The relay could not be reached to check who else is in "${m.name}". If there are teammates, they still list you; one of them removes you with: pantheon admit ${all.member} --revoke`));
2726
2838
  else if (others.length)
@@ -3204,6 +3316,13 @@ async function main() {
3204
3316
  delete nextCfg.pendingAuth;
3205
3317
  save(nextCfg);
3206
3318
  console.error(`${C("verified")} — you are ${B(acct.username)} <${acct.email}> on this device.`);
3319
+ const offer = await offerSeatsHere();
3320
+ if (offer?.moved.length)
3321
+ return;
3322
+ if (offer?.offered.length) {
3323
+ console.error(D(`\nOr, for a team you are not in yet: pantheon join <invite>`));
3324
+ return;
3325
+ }
3207
3326
  console.error(pending.purpose === "signup"
3208
3327
  ? D(`\nNext: pantheon start (create a team) · or pantheon join <invite> (join one)`)
3209
3328
  : D(`\nThis device is now signed in. pantheon start / pantheon join work as your account.`));
@@ -3648,6 +3767,7 @@ async function main() {
3648
3767
  console.log(`${C("withdrawn")} — your seat in ${team} stays where it was.`);
3649
3768
  return;
3650
3769
  }
3770
+ let picked = null;
3651
3771
  if (!sub) {
3652
3772
  const r = await call("GET", "/account/recover");
3653
3773
  if (r.status === 404)
@@ -3658,18 +3778,28 @@ async function main() {
3658
3778
  console.log(`${B(`Your seats as ${acct.username}`)} ${D(`(this device: ${fingerprint(id.publicKey)})`)}\n`);
3659
3779
  if (!seats.length)
3660
3780
  console.log(D(" none — you are in no team on this relay yet"));
3781
+ const pickable = seats.filter((s) => s.state === "ready" || s.state === "movable");
3661
3782
  for (const s of seats) {
3662
- const name = s.space.padEnd(18);
3783
+ const n = pickable.indexOf(s);
3784
+ const name = `${n >= 0 ? `${n + 1}. ` : " "}${s.space}`.padEnd(21);
3663
3785
  const line = s.state === "here" ? D("on this machine") :
3664
- s.state === "ready" ? `moved here — save it on this machine: ${C(`pantheon recover ${s.space}`)}` :
3786
+ s.state === "ready" ? `moved here — save it on this machine: ${C(`pantheon recover ${shellArg(s.space)}`)}` :
3665
3787
  s.state === "waiting" ? `waiting — moves ${new Date(s.due_at).toLocaleString()} unless someone cancels; a teammate can confirm it sooner` :
3666
- s.state === "movable" ? `on another of your devices (${s.fp}) — move it here: ${C(`pantheon recover ${s.space}`)}${D(s.alone ? " (you are alone in it: at once)" : " (a teammate confirms, or it waits a day)")}` :
3788
+ s.state === "movable" ? `on another of your devices (${s.fp}) — move it here: ${C(`pantheon recover ${shellArg(s.space)}`)}${D(s.alone ? " (you are alone in it: at once)" : " (a teammate confirms, or it waits a day)")}` :
3667
3789
  D(`its key ${s.fp} was never signed in to your account here, so it cannot be moved by email — a teammate who has checked it is you: pantheon admit ${acct.username} --forget, then a new invite`);
3668
3790
  console.log(` ${name}${line}${s.founder ? D(" (founder)") : ""}`);
3669
3791
  }
3670
- return;
3792
+ if (!pickable.length || !process.stdin.isTTY)
3793
+ return;
3794
+ const a = ((await askOnce(`\n Move which seat to this machine? Its number (Enter for none): `)) ?? "").trim();
3795
+ const i = Number(a);
3796
+ if (!a)
3797
+ return;
3798
+ if (!Number.isInteger(i) || i < 1 || i > pickable.length)
3799
+ die(`no seat numbered ${a} — run pantheon recover again`);
3800
+ picked = pickable[i - 1].space;
3671
3801
  }
3672
- const team = sub;
3802
+ const team = sub ?? picked;
3673
3803
  let r = await call("POST", "/account/recover", { space: team });
3674
3804
  if (r.status === 403 && r.j.reason === "sign_in_again" && process.stdin.isTTY) {
3675
3805
  console.error(`${B("First, prove it is you")} — a code goes to ${C(acct.email)} (look in spam too if it is not there in a minute).`);
@@ -3694,7 +3824,7 @@ async function main() {
3694
3824
  }
3695
3825
  rl.close();
3696
3826
  if (!ok)
3697
- die(`not verified — run pantheon recover ${team} again for a new code`);
3827
+ die(`not verified — run pantheon recover ${shellArg(team)} again for a new code`);
3698
3828
  r = await call("POST", "/account/recover", { space: team });
3699
3829
  }
3700
3830
  if (r.status === 404 && !r.j.reason)
@@ -3705,9 +3835,9 @@ async function main() {
3705
3835
  const others = (r.j.others ?? []);
3706
3836
  console.log(`${B(`Asked: your seat in ${team} moves to this machine`)} ${D(`(new key ${String(r.j.fp)})`)}`);
3707
3837
  console.log(` It moves when a teammate confirms it — ${others.join(", ") || "your teammates"} ${others.length === 1 ? "was" : "were"} told — or on its own ${new Date(Number(r.j.due_at)).toLocaleString()} if nobody cancels.`);
3708
- console.log(` Speed it up: ask one of them to check your key with you (read them "this device" from pantheon whoami ) and run ${C(`pantheon recover confirm ${acct.username} --space ${team}`)}`);
3709
- console.log(D(` Once it has moved, run pantheon recover ${team} again here to save the team on this machine.`));
3710
- return;
3838
+ console.log(` Speed it up: ask one of them to check your key with you (read them "this device" from pantheon whoami ) and run ${C(`pantheon recover confirm ${acct.username} --space ${shellArg(team)}`)}`);
3839
+ console.log(D(` Once it has moved, run pantheon recover ${shellArg(team)} again here to save the team on this machine.`));
3840
+ process.exit(RECOVER_WAITING_EXIT);
3711
3841
  }
3712
3842
  const token = String(r.j.invite ?? "");
3713
3843
  if (r.j.state !== "moved" || !token)
@@ -3717,7 +3847,7 @@ async function main() {
3717
3847
  console.error(D(` If the old machine is lost or not yours any more, sign it out of your account too: pantheon sessions (then pantheon sessions revoke <device-id> --device)\n`));
3718
3848
  const { spawnSync: run } = await import("node:child_process");
3719
3849
  const invite = toInvite({ ...id, space: String(r.j.space ?? team), token });
3720
- const keep = argv.slice(2).filter((a) => /^--(no-worker|no-hook|no-notifications|no-menubar|yes)$/.test(a));
3850
+ const keep = argv.slice(1).filter((a) => /^--(no-worker|no-hook|no-notifications|no-menubar|no-guide|yes)$/.test(a));
3721
3851
  const res = run(process.execPath, [process.argv[1], "join", invite, ...keep], { stdio: "inherit" });
3722
3852
  process.exit(res.status ?? 1);
3723
3853
  }
@@ -4087,9 +4217,21 @@ async function main() {
4087
4217
  if (argv.includes("--only-action") && !hud)
4088
4218
  refuse("that action was already taken or is no longer there — click it again");
4089
4219
  }
4090
- const cfg = (() => {
4091
- if (!hud?.space)
4220
+ let cfg = (() => {
4221
+ if (!hud?.space) {
4222
+ const r = resolveFor(process.cwd());
4223
+ if (r.cfg?.space)
4224
+ return need();
4225
+ const f = loadAll();
4226
+ const first = Object.values(f?.spaces ?? {})[0];
4227
+ if (f && first && (!hud || ["details", "open", "fix", "done", "wake"].includes(hud.action)))
4228
+ return configFor(f, first, null);
4229
+ if (hud && (hud.action === "reply" || hud.action === "retract")) {
4230
+ console.log(`not sent: which team it belongs to was not recorded — answer it from the board`);
4231
+ process.exit(1);
4232
+ }
4092
4233
  return need();
4234
+ }
4093
4235
  const f = loadAll();
4094
4236
  const m = f ? findMembership(f, hud.space) : undefined;
4095
4237
  if (f && m)
@@ -4222,6 +4364,13 @@ async function main() {
4222
4364
  const wa = ref ? loadPolicy().wakeAsks?.[ref] : undefined;
4223
4365
  if (!wa || !hud.member || wa.member !== hud.member)
4224
4366
  notSent("that question is no longer open on this machine — pantheon wake list shows who may wake it");
4367
+ if (!hud.space) {
4368
+ const f = loadAll();
4369
+ const m = f ? findMembership(f, wa.team) : undefined;
4370
+ if (!f || !m)
4371
+ notSent(`that question is about the team "${wa.team}", which this machine is no longer in`);
4372
+ cfg = configFor(f, m, null);
4373
+ }
4225
4374
  if (![cfg.spaceId, cfg.space].includes(wa.team))
4226
4375
  notSent(`that question is about the team "${wa.team}", not "${hud.space ?? cfg.space}" — answer it from that team's board`);
4227
4376
  const allow = hud.allow === true;
@@ -4252,11 +4401,22 @@ async function main() {
4252
4401
  return;
4253
4402
  }
4254
4403
  if (hud?.action === "done") {
4255
- const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
4256
- await c2.connect().catch((e) => die(connectFailure(e, cfg.relay)));
4257
- const o = hud.decision ? c2.obligations.find((x) => x.id === hud.decision) : undefined;
4258
- if (!o) {
4259
- c2.close();
4404
+ const tries = hud.space ? [cfg] : (() => { const f = loadAll(); const all = f ? Object.values(f.spaces ?? {}).map((m) => configFor(f, m, null)) : []; return all.length ? all : [cfg]; })();
4405
+ let c2 = null;
4406
+ let o;
4407
+ for (const tc of tries) {
4408
+ const c = new PantheonClient(tc, undefined, { role: "cli" });
4409
+ const ok = await c.connect().then(() => true, (e) => { if (tries.length === 1)
4410
+ die(connectFailure(e, tc.relay)); return false; });
4411
+ const hit = ok && hud.decision ? c.obligations.find((x) => x.id === hud.decision) : undefined;
4412
+ if (hit) {
4413
+ c2 = c;
4414
+ o = hit;
4415
+ break;
4416
+ }
4417
+ c.close();
4418
+ }
4419
+ if (!c2 || !o) {
4260
4420
  console.error(D("that decision is no longer waiting on you"));
4261
4421
  return;
4262
4422
  }
@@ -4810,8 +4970,9 @@ async function main() {
4810
4970
  return;
4811
4971
  }
4812
4972
  case "menubar": {
4813
- need();
4814
- const { ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
4973
+ const { ensureTrayBinary, writeTrayCommands, menubarRefusal } = await import("./tray.js");
4974
+ if (menubarRefusal(loadAll(), load()))
4975
+ need();
4815
4976
  const { spawn } = await import("node:child_process");
4816
4977
  let binary;
4817
4978
  try {
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ import { join, resolve, sep } from "node:path";
6
6
  import { VERSION } from "./version.js";
7
7
  import { repoName } from "./repo-route.js";
8
8
  import { markPantheonHome } from "./home-safety.js";
9
+ import { TEAM_NAME } from "./team-name.js";
9
10
  export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
10
11
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
11
12
  export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
@@ -596,7 +597,7 @@ export function toInvite(cfg) {
596
597
  }
597
598
  export const JOIN_LINK_BASE = "https://www.joinpantheon.network/join";
598
599
  const HOSTED_RELAY = "wss://relay.joinpantheon.network";
599
- const JOIN_SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
600
+ const JOIN_SLUG = TEAM_NAME;
600
601
  export function toJoinLink(cfg) {
601
602
  if (cfg.relay.replace(/\/+$/, "") !== HOSTED_RELAY || !JOIN_SLUG.test(cfg.space))
602
603
  return null;