@join-pantheon/cli 0.33.1 → 0.33.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/accounts.js +10 -0
- package/dist/agents.js +11 -0
- package/dist/board.js +55 -9
- package/dist/claude-confine.js +1 -1
- package/dist/cli.js +556 -89
- package/dist/client.js +8 -4
- package/dist/config.js +18 -4
- package/dist/continued.js +5 -0
- package/dist/desktop-app.js +103 -0
- package/dist/doctor.js +84 -32
- package/dist/folder-guard.js +12 -0
- package/dist/folder-snapshot.js +432 -0
- package/dist/install.js +15 -8
- package/dist/machine.js +84 -4
- package/dist/managed.js +2 -2
- package/dist/mcp.js +5 -3
- package/dist/network.js +156 -0
- package/dist/notice-linux.js +6 -5
- package/dist/notice-windows.js +2 -2
- package/dist/notice.js +13 -0
- package/dist/notifier.js +29 -2
- package/dist/notify.js +11 -0
- package/dist/onboard-defaults.js +37 -1
- package/dist/pins.js +7 -1
- package/dist/plans.js +23 -0
- package/dist/protocol.js +5 -0
- package/dist/quickstart.js +17 -11
- package/dist/refusal.js +4 -4
- package/dist/relay.js +332 -22
- package/dist/repo-find.js +6 -3
- package/dist/roster-file.js +50 -5
- package/dist/tray.js +12 -1
- package/dist/uninstall.js +38 -3
- package/dist/uri-handler.js +3 -2
- package/dist/verify-act.js +17 -0
- package/dist/worker-fleet.js +140 -5
- package/dist/worker.js +201 -63
- package/installer/install.ps1 +251 -15
- package/installer/install.sh +147 -18
- package/package.json +1 -1
- package/helper/macos/prebuilt/manifest.json +0 -29
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/macos/prebuilt/pantheon-tray +0 -0
package/dist/relay.js
CHANGED
|
@@ -18,6 +18,7 @@ import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, s
|
|
|
18
18
|
import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
|
|
19
19
|
import { FEEDBACK_ADDRESS } from "./feedback.js";
|
|
20
20
|
import { routeDecision } from "./profile.js";
|
|
21
|
+
const presentSessions = (m) => [...m.sessions.values()].filter((s) => !s.quiet);
|
|
21
22
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
22
23
|
let known = false;
|
|
23
24
|
let busy = false;
|
|
@@ -272,6 +273,8 @@ function versionBelow(a, b) {
|
|
|
272
273
|
}
|
|
273
274
|
return false;
|
|
274
275
|
}
|
|
276
|
+
export const RECOVERY_PROOF_MS = 15 * 60_000;
|
|
277
|
+
const RECOVERY_KEEP_MS = 7 * 86_400_000;
|
|
275
278
|
const LOCKED_MSG = "too many wrong codes from this device or your network in the last hour — wait an hour, then request a new code";
|
|
276
279
|
const STORAGE_UNHEALTHY_MS = 5 * 60_000;
|
|
277
280
|
function isStorageError(e) {
|
|
@@ -465,6 +468,7 @@ export class Relay {
|
|
|
465
468
|
deletedSpaces = new Map();
|
|
466
469
|
authEpoch = 0;
|
|
467
470
|
rotateOnRemove;
|
|
471
|
+
recoveryWaitMs;
|
|
468
472
|
backupTimer;
|
|
469
473
|
installerGate;
|
|
470
474
|
upgradeHold;
|
|
@@ -510,6 +514,10 @@ export class Relay {
|
|
|
510
514
|
spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
|
|
511
515
|
};
|
|
512
516
|
this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
|
|
517
|
+
{
|
|
518
|
+
const h = Number(process.env.PANTHEON_RECOVERY_WAIT_HOURS ?? "24");
|
|
519
|
+
this.recoveryWaitMs = opts.recoveryWaitMs ?? (Number.isFinite(h) && h >= 0 ? h : 24) * 3_600_000;
|
|
520
|
+
}
|
|
513
521
|
this.upgradeHold = upgradeHoldFrom(opts.upgradeHoldAt ?? process.env.PANTHEON_HOLD_UPGRADES_AT, VERSION, this.log);
|
|
514
522
|
this.installerGate = opts.installerGate ?? new InstallerGate({ log: this.log, ...(this.upgradeHold ? { version: this.upgradeHold } : {}) });
|
|
515
523
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
@@ -624,8 +632,8 @@ export class Relay {
|
|
|
624
632
|
const detail = (s) => ({
|
|
625
633
|
name: s.name,
|
|
626
634
|
members: s.members.size,
|
|
627
|
-
online: [...s.members.values()].filter((m) => m.
|
|
628
|
-
sessions: [...s.members.values()].reduce((n, m) => n + m.
|
|
635
|
+
online: [...s.members.values()].filter((m) => presentSessions(m).length > 0).length,
|
|
636
|
+
sessions: [...s.members.values()].reduce((n, m) => n + presentSessions(m).length, 0),
|
|
629
637
|
goals: s.goals.size,
|
|
630
638
|
seq: s.ledger.head,
|
|
631
639
|
});
|
|
@@ -982,6 +990,23 @@ export class Relay {
|
|
|
982
990
|
this.noteAccountRemoval(who.account.username);
|
|
983
991
|
return this.json(res, 200, { ok: true });
|
|
984
992
|
}
|
|
993
|
+
if (url.pathname === "/account/recover" || url.pathname === "/account/recover/withdraw") {
|
|
994
|
+
if (!this.authRateOk(ip))
|
|
995
|
+
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
996
|
+
const method = url.pathname === "/account/recover" && req.method === "GET" ? "GET" : "POST";
|
|
997
|
+
if (req.method !== method)
|
|
998
|
+
return this.json(res, 405, { error: "method not allowed" });
|
|
999
|
+
const who = this.verifySignedAccount(url, method, url.pathname, ip);
|
|
1000
|
+
if (!who)
|
|
1001
|
+
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
1002
|
+
if (method === "GET")
|
|
1003
|
+
return this.json(res, 200, { seats: this.recoverableSeats(who.account, who.device) });
|
|
1004
|
+
const name = url.searchParams.get("space") ?? "";
|
|
1005
|
+
const r = url.pathname === "/account/recover/withdraw"
|
|
1006
|
+
? this.withdrawRecovery(who.account, who.device, name, ip)
|
|
1007
|
+
: this.recoverSeat(who.account, who.device, name, ip);
|
|
1008
|
+
return this.json(res, r.status, r.body);
|
|
1009
|
+
}
|
|
985
1010
|
if (url.pathname === "/audit" && req.method === "GET") {
|
|
986
1011
|
const who = this.verifySignedAccount(url, "GET", "/audit", ip);
|
|
987
1012
|
if (!who)
|
|
@@ -1332,7 +1357,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1332
1357
|
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1333
1358
|
for (const sp of this.spaces.values()) {
|
|
1334
1359
|
seen.add(safeOf(sp.name));
|
|
1335
|
-
const sessions = [...sp.members.values()].flatMap((m) =>
|
|
1360
|
+
const sessions = [...sp.members.values()].flatMap((m) => presentSessions(m));
|
|
1336
1361
|
out.push(spaceHealth({
|
|
1337
1362
|
name: sp.name, records: sp.ledger.all(), members: sp.allow.all(),
|
|
1338
1363
|
online: sessions.length, interactive: sessions.filter((x) => x.role === "interactive").length,
|
|
@@ -1373,7 +1398,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1373
1398
|
const name = sp.name;
|
|
1374
1399
|
out.set(safeOf(name), {
|
|
1375
1400
|
name, live: true, id: sp.allow.peekId(),
|
|
1376
|
-
members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => m.
|
|
1401
|
+
members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => presentSessions(m).length > 0).length,
|
|
1377
1402
|
acts: sp.ledger.head, goals: sp.goals.size, open_goals: [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: sp.obligations.size,
|
|
1378
1403
|
last: sp.lastTouch, bytes: size(safeOf(name)),
|
|
1379
1404
|
activity: activity(sp.ledger.all(), now, (r) => `${name}/${r.from}`), refusals: sp.store.refusals,
|
|
@@ -1703,6 +1728,239 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1703
1728
|
}
|
|
1704
1729
|
this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
|
|
1705
1730
|
}
|
|
1731
|
+
wakeForRelay(name) {
|
|
1732
|
+
const live = this.live(name);
|
|
1733
|
+
if (live)
|
|
1734
|
+
return live;
|
|
1735
|
+
if (!name || this.aliasOf(name) || !this.spaceOnDisk(name))
|
|
1736
|
+
return null;
|
|
1737
|
+
try {
|
|
1738
|
+
const allow = new Allowlist(this.dataDir, safeName(name));
|
|
1739
|
+
let token = allow.currentTokenFromInvites();
|
|
1740
|
+
if (!token) {
|
|
1741
|
+
token = randomBytes(16).toString("hex");
|
|
1742
|
+
allow.rotateToken(token);
|
|
1743
|
+
}
|
|
1744
|
+
return this.getOrCreateSpace(name, token);
|
|
1745
|
+
}
|
|
1746
|
+
catch (e) {
|
|
1747
|
+
this.noteStorageFailure(`[${name}] opening it for a recovery`, e);
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
rosterOf(name) {
|
|
1752
|
+
const live = this.live(name);
|
|
1753
|
+
if (live)
|
|
1754
|
+
return live.allow;
|
|
1755
|
+
if (!name || this.aliasOf(name) || !this.spaceOnDisk(name))
|
|
1756
|
+
return null;
|
|
1757
|
+
try {
|
|
1758
|
+
return new Allowlist(this.dataDir, safeName(name));
|
|
1759
|
+
}
|
|
1760
|
+
catch {
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
allRosters() {
|
|
1765
|
+
const out = [];
|
|
1766
|
+
const seen = new Set();
|
|
1767
|
+
for (const sp of this.spaces.values()) {
|
|
1768
|
+
seen.add(safeName(sp.name));
|
|
1769
|
+
out.push({ name: sp.name, allow: sp.allow, live: sp });
|
|
1770
|
+
}
|
|
1771
|
+
let files = [];
|
|
1772
|
+
try {
|
|
1773
|
+
files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
|
|
1774
|
+
}
|
|
1775
|
+
catch { }
|
|
1776
|
+
for (const f of files) {
|
|
1777
|
+
const stem = f.slice(0, -".members.json".length);
|
|
1778
|
+
if (seen.has(stem))
|
|
1779
|
+
continue;
|
|
1780
|
+
try {
|
|
1781
|
+
const m = statSync(join(this.dataDir, f)).mtimeMs;
|
|
1782
|
+
let c = this.rosterCache.get(stem);
|
|
1783
|
+
if (!c || c.mtimeMs !== m) {
|
|
1784
|
+
c = { mtimeMs: m, allow: new Allowlist(this.dataDir, stem) };
|
|
1785
|
+
this.rosterCache.set(stem, c);
|
|
1786
|
+
}
|
|
1787
|
+
out.push({ name: c.allow.recordedName() ?? stem, allow: c.allow, live: undefined });
|
|
1788
|
+
}
|
|
1789
|
+
catch { }
|
|
1790
|
+
}
|
|
1791
|
+
return out;
|
|
1792
|
+
}
|
|
1793
|
+
rosterCache = new Map();
|
|
1794
|
+
recoverableSeats(acct, device) {
|
|
1795
|
+
const A = this.accounts;
|
|
1796
|
+
const u = acct.username;
|
|
1797
|
+
const out = [];
|
|
1798
|
+
for (const { name, allow } of this.allRosters()) {
|
|
1799
|
+
const e = allow.get(u);
|
|
1800
|
+
if (!e?.admitted)
|
|
1801
|
+
continue;
|
|
1802
|
+
const founder = allow.founder() === u;
|
|
1803
|
+
const alone = !allow.all().some((x) => x.admitted && x.member !== u);
|
|
1804
|
+
const fp = fingerprint(e.publicKey);
|
|
1805
|
+
if (e.publicKey === device.pubkey) {
|
|
1806
|
+
const inv = this.openRecoveryInvite(allow, u);
|
|
1807
|
+
out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, ...(inv ? { invite: inv.token } : {}) });
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
const pend = allow.recoveryOf(u);
|
|
1811
|
+
if (pend && pend.pubkey === device.pubkey) {
|
|
1812
|
+
out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at });
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp });
|
|
1816
|
+
}
|
|
1817
|
+
return out.sort((a, b) => a.space.localeCompare(b.space));
|
|
1818
|
+
}
|
|
1819
|
+
openRecoveryInvite(allow, member) {
|
|
1820
|
+
const all = allow.invites;
|
|
1821
|
+
for (const i of all.values())
|
|
1822
|
+
if (i.recovery_for === member && !i.used_by && Date.now() < i.expires_at)
|
|
1823
|
+
return i;
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
recoverSeat(acct, device, name, ip) {
|
|
1827
|
+
const A = this.accounts;
|
|
1828
|
+
const u = acct.username;
|
|
1829
|
+
if (!name)
|
|
1830
|
+
return { status: 400, body: { error: "which team? pantheon recover <team>" } };
|
|
1831
|
+
const noSeat = { status: 404, body: { reason: "no_seat", error: `you (${u}) have no seat in a team called "${name}" on this relay — pantheon recover lists the ones you have` } };
|
|
1832
|
+
const roster = this.rosterOf(name);
|
|
1833
|
+
const seat = roster?.get(u);
|
|
1834
|
+
if (!roster || !seat?.admitted)
|
|
1835
|
+
return noSeat;
|
|
1836
|
+
const known = seat.publicKey === device.pubkey || roster.recoveryOf(u)?.pubkey === device.pubkey;
|
|
1837
|
+
if (!known) {
|
|
1838
|
+
const refused = this.recoveryProofRefusal(A, acct, device, name, seat, roster.founder() === u);
|
|
1839
|
+
if (refused)
|
|
1840
|
+
return refused;
|
|
1841
|
+
}
|
|
1842
|
+
const sp = this.wakeForRelay(name);
|
|
1843
|
+
const e = sp?.allow.get(u);
|
|
1844
|
+
if (!sp || !e?.admitted)
|
|
1845
|
+
return noSeat;
|
|
1846
|
+
if (e.publicKey === device.pubkey) {
|
|
1847
|
+
const inv = this.openRecoveryInvite(sp.allow, u) ?? this.mintRecoveryInvite(sp, u);
|
|
1848
|
+
return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: e.moved?.how ?? null } };
|
|
1849
|
+
}
|
|
1850
|
+
const pend = sp.allow.recoveryOf(u);
|
|
1851
|
+
const now = Date.now();
|
|
1852
|
+
if (pend && pend.pubkey === device.pubkey) {
|
|
1853
|
+
if (now >= pend.due_at) {
|
|
1854
|
+
if (now - pend.due_at > RECOVERY_KEEP_MS) {
|
|
1855
|
+
sp.allow.cancelRecovery(u);
|
|
1856
|
+
return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${sp.name}` } };
|
|
1857
|
+
}
|
|
1858
|
+
const inv = this.completeRecovery(sp, u, device.pubkey, "waited", null, null, ip, acct.id);
|
|
1859
|
+
return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "waited" } };
|
|
1860
|
+
}
|
|
1861
|
+
return { status: 202, body: { state: "waiting", space: sp.name, due_at: pend.due_at, fp: pend.fp, others: this.othersIn(sp, u) } };
|
|
1862
|
+
}
|
|
1863
|
+
const again = this.recoveryProofRefusal(A, acct, device, sp.name, e, sp.allow.founder() === u);
|
|
1864
|
+
if (again)
|
|
1865
|
+
return again;
|
|
1866
|
+
const oldFp = fingerprint(e.publicKey);
|
|
1867
|
+
const others = this.othersIn(sp, u);
|
|
1868
|
+
if (!others.length) {
|
|
1869
|
+
const inv = this.completeRecovery(sp, u, device.pubkey, "alone", null, null, ip, acct.id);
|
|
1870
|
+
return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "alone" } };
|
|
1871
|
+
}
|
|
1872
|
+
const r = {
|
|
1873
|
+
member: u, pubkey: device.pubkey, fp: device.fingerprint, label: device.label,
|
|
1874
|
+
account: acct.id, requested_at: now, due_at: now + this.recoveryWaitMs,
|
|
1875
|
+
};
|
|
1876
|
+
sp.allow.requestRecovery(r);
|
|
1877
|
+
if (this.email.sendNotice) {
|
|
1878
|
+
void this.email.sendNotice(acct.email, `A request to move your seat in ${sp.name}`, `A device signed in to your Pantheon account ("${r.label}", key ${r.fp}) asked to move your seat in the team "${sp.name}" to it. ` +
|
|
1879
|
+
`It moves ${new Date(r.due_at).toUTCString()} unless someone cancels, or sooner if a teammate confirms it.\n\n` +
|
|
1880
|
+
`If this was you, nothing to do. If it was not: on a machine that still holds your seat, run pantheon recover cancel ${u} — and secure this email account, since whoever asked could read its sign-in code.`).catch(() => false);
|
|
1881
|
+
}
|
|
1882
|
+
A.audit({ event: "seat_recovery_requested", actor_account: acct.id, space: sp.name, target: u, detail: `${oldFp} → ${r.fp}`, ip });
|
|
1883
|
+
this.log(`[${sp.name}] ? RECOVERY ${u}: ${oldFp} → ${r.fp} (${r.label}), due ${new Date(r.due_at).toISOString()}`);
|
|
1884
|
+
const hours = Math.round(this.recoveryWaitMs / 3_600_000);
|
|
1885
|
+
this.commit(sp, {
|
|
1886
|
+
from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
|
|
1887
|
+
body: `${u} asked to move their seat in this team to a new device ("${r.label}", key ${r.fp}), after signing in with the code emailed to their account. ` +
|
|
1888
|
+
`It moves ${hours ? `in ${hours} hour${hours === 1 ? "" : "s"}` : "when they ask again"} unless someone cancels, or sooner when a teammate confirms it. ` +
|
|
1889
|
+
`If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${sp.name}. ` +
|
|
1890
|
+
`If it is not them: pantheon recover cancel ${u} --space ${sp.name}.`,
|
|
1891
|
+
meta: { notice: "seat_recovery", recovery: { member: u, fp: r.fp, from_fp: oldFp, state: "waiting", due_at: r.due_at } },
|
|
1892
|
+
});
|
|
1893
|
+
this.broadcast(sp, { t: "members", members: this.members(sp) });
|
|
1894
|
+
return { status: 202, body: { state: "waiting", space: sp.name, due_at: r.due_at, fp: r.fp, others } };
|
|
1895
|
+
}
|
|
1896
|
+
recoveryProofRefusal(A, acct, device, name, seat, founder) {
|
|
1897
|
+
const proof = A.lastEmailProof(acct.id, device.fingerprint);
|
|
1898
|
+
if (!proof || Date.now() - proof > RECOVERY_PROOF_MS) {
|
|
1899
|
+
return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${name} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${name} again` } };
|
|
1900
|
+
}
|
|
1901
|
+
const oldFp = fingerprint(seat.publicKey);
|
|
1902
|
+
if (!A.wasDeviceOf(acct.id, oldFp, seat.moved?.at ?? seat.first_seen)) {
|
|
1903
|
+
this.log(`[${name}] ! recovery refused for ${acct.username}: the seat's key ${oldFp} was not a device of their account when the seat was taken`);
|
|
1904
|
+
return { status: 403, body: { reason: "unproven", error: `the key holding your seat in "${name}" (${oldFp}) was not signed in to this account when the seat was taken (an account deleted and made again is a new account, and records over a year old are gone), so the relay cannot tell it was yours. ` +
|
|
1905
|
+
(founder ? `A founder's seat cannot be handed over any other way: start a new team (pantheon start) and invite your teammates to it.` : `A teammate who has checked it is you can retire it instead (pantheon admit ${acct.username} --forget) and invite you again.`) } };
|
|
1906
|
+
}
|
|
1907
|
+
return null;
|
|
1908
|
+
}
|
|
1909
|
+
withdrawRecovery(acct, device, name, ip) {
|
|
1910
|
+
const sp = name && this.rosterOf(name)?.recoveryOf(acct.username)?.pubkey === device.pubkey ? this.wakeForRelay(name) : null;
|
|
1911
|
+
const pend = sp?.allow.recoveryOf(acct.username);
|
|
1912
|
+
if (!sp || !pend || pend.pubkey !== device.pubkey)
|
|
1913
|
+
return { status: 404, body: { reason: "nothing_waiting", error: "this machine has no seat move waiting in that team" } };
|
|
1914
|
+
this.endRecovery(sp, acct.username, `withdrawn by ${acct.username} from the new device`, acct.username, ip);
|
|
1915
|
+
return { status: 200, body: { state: "withdrawn", space: sp.name } };
|
|
1916
|
+
}
|
|
1917
|
+
othersIn(sp, member) {
|
|
1918
|
+
return sp.allow.all().filter((x) => x.admitted && x.member !== member).map((x) => x.member);
|
|
1919
|
+
}
|
|
1920
|
+
endRecovery(sp, member, why, actor, ip) {
|
|
1921
|
+
const r = sp.allow.cancelRecovery(member);
|
|
1922
|
+
if (!r)
|
|
1923
|
+
return;
|
|
1924
|
+
this.accounts?.audit({ event: "seat_recovery_cancelled", actor_account: this.accounts.accountByUsername(actor)?.id ?? null, space: sp.name, target: member, detail: `${r.fp}: ${why}`, ip });
|
|
1925
|
+
this.log(`[${sp.name}] - RECOVERY of ${member} to ${r.fp} ended: ${why}`);
|
|
1926
|
+
this.commit(sp, {
|
|
1927
|
+
from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
|
|
1928
|
+
body: `${member}'s seat stays on its current key: the request to move it to ${r.fp} was ${why}.`,
|
|
1929
|
+
meta: { notice: "seat_recovery", recovery: { member, fp: r.fp, state: "ended" } },
|
|
1930
|
+
});
|
|
1931
|
+
this.broadcast(sp, { t: "members", members: this.members(sp) });
|
|
1932
|
+
}
|
|
1933
|
+
completeRecovery(sp, member, pubkey, how, by, vouch, ip, accountId) {
|
|
1934
|
+
const before = sp.allow.get(member);
|
|
1935
|
+
const fromFp = fingerprint(before.publicKey);
|
|
1936
|
+
const e = sp.allow.moveSeat(member, pubkey, { at: Date.now(), how, ...(by ? { by } : {}), ...(vouch ? { vouch } : {}) });
|
|
1937
|
+
if (!e)
|
|
1938
|
+
throw new Error(`no admitted seat for ${member} to move`);
|
|
1939
|
+
const live = sp.members.get(member);
|
|
1940
|
+
for (const s of live?.sessions.values() ?? [])
|
|
1941
|
+
this.hangUp(s.ws, "KEY_MISMATCH");
|
|
1942
|
+
live?.sessions.clear();
|
|
1943
|
+
if (this.rotateOnRemove)
|
|
1944
|
+
this.rotateSpaceToken(sp, `${member}'s seat moved to a new key`);
|
|
1945
|
+
const inv = this.mintRecoveryInvite(sp, member);
|
|
1946
|
+
const toFp = fingerprint(pubkey);
|
|
1947
|
+
this.accounts?.audit({ event: "seat_recovered", actor_account: accountId ?? this.accounts.accountByUsername(member)?.id ?? null, space: sp.name, target: member, detail: `${fromFp} → ${toFp} (${how}${by ? ` by ${by}` : ""})`, ip });
|
|
1948
|
+
this.log(`[${sp.name}] * RECOVERED ${member}: ${fromFp} → ${toFp} (${how}${by ? ` by ${by}` : ""})`);
|
|
1949
|
+
const word = how === "alone" ? "alone in the team" : how === "confirmed" ? `confirmed by ${by}` : "nobody cancelled in the time it waited";
|
|
1950
|
+
this.commit(sp, {
|
|
1951
|
+
from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
|
|
1952
|
+
body: `${member} moved to a new device: their seat now has key ${toFp} (was ${fromFp}). They signed in with the code emailed to their account; ${word}.` +
|
|
1953
|
+
(how === "confirmed" ? "" : how === "waited" ? ` Your machine keeps trusting the old key until you check the new one with ${member}: pantheon trust ${member}` : ""),
|
|
1954
|
+
meta: { notice: "seat_recovery", recovery: { member, fp: toFp, from_fp: fromFp, state: "moved", how, ...(by ? { by } : {}) } },
|
|
1955
|
+
});
|
|
1956
|
+
this.broadcast(sp, { t: "members", members: this.members(sp) });
|
|
1957
|
+
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
1958
|
+
return inv;
|
|
1959
|
+
}
|
|
1960
|
+
mintRecoveryInvite(sp, member) {
|
|
1961
|
+
const e = sp.allow.get(member);
|
|
1962
|
+
return sp.allow.mintInvite(member, e.may_wake, 7 * 24 * 60, randomBytes(18).toString("base64url"), this.ensureToken(sp), member);
|
|
1963
|
+
}
|
|
1706
1964
|
noteAccountRemoval(username) {
|
|
1707
1965
|
this.authEpoch++;
|
|
1708
1966
|
const u = normalizeUsername(username);
|
|
@@ -2209,7 +2467,7 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
2209
2467
|
const revokeForm = (token) => `<form method=POST action="/admin/action" class=inl onsubmit="return confirm('Revoke this invite? The link stops working immediately.')"><input type=hidden name=csrf value="${csrfTok}"><input type=hidden name=action value=revoke_invite><input type=hidden name=id value="${esc(name)}"><input type=hidden name=token value="${esc(token.slice(-6))}"><button class="act danger">revoke</button></form>`;
|
|
2210
2468
|
const memberRows = entries.map((e) => {
|
|
2211
2469
|
const m = sp?.members.get(e.member);
|
|
2212
|
-
const online = m ? m.
|
|
2470
|
+
const online = m ? presentSessions(m).length : 0;
|
|
2213
2471
|
return `<tr><td><a href="${link({ member: e.member, before: undefined })}"><code>${esc(e.member)}</code></a></td><td>${e.admitted ? '<span class="pill ok">admitted</span>' : '<span class="pill">pending</span>'}</td><td>${e.may_wake ? "yes" : "no"}</td><td>${online ? `<span class="pill ok">${online} session${online === 1 ? "" : "s"}</span> ${esc(m.agent)}` : "<span class=dim>offline</span>"}</td><td class=dim>${m ? ago(m.last_seen) : when(e.first_seen)}</td></tr>`;
|
|
2214
2472
|
}).join("");
|
|
2215
2473
|
const repos = allow.repoList();
|
|
@@ -2245,7 +2503,7 @@ ${ADMIN_CSS}
|
|
|
2245
2503
|
${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchParams.get("done"))}</div>` : ""}
|
|
2246
2504
|
<main>
|
|
2247
2505
|
<div class=kpis>
|
|
2248
|
-
<div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => m.
|
|
2506
|
+
<div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => presentSessions(m).length).length : 0} online now · ${openInvites.length} unused invite${openInvites.length === 1 ? "" : "s"}</div></div>
|
|
2249
2507
|
<div class=kpi><div class=l>Goals</div><div class=v>${open}<small>/ ${goals.length} open</small></div><div class=s>${sp ? `${sp.obligations.size} obligation${sp.obligations.size === 1 ? "" : "s"} owed right now` : "asleep — reloads on the next connect"}</div></div>
|
|
2250
2508
|
<div class=kpi><div class=l>Messages today</div><div class=v>${a.messages_24h}<small>/ ${a.messages} retained</small></div><div class=s>${a.senders_24h.length} delegate${a.senders_24h.length === 1 ? "" : "s"} active today · ${a.sessions_24h} sessions</div></div>
|
|
2251
2509
|
<div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${a.by_act_24h.DELIVERED ?? 0} deliver${(a.by_act_24h.DELIVERED ?? 0) === 1 ? "y" : "ies"}</div></div>
|
|
@@ -2388,7 +2646,7 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
|
|
|
2388
2646
|
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:12vh auto;padding:0 1.2rem;color:#222}code,pre{background:#f4f4f5;border-radius:5px}code{padding:2px 6px}pre{padding:.6rem .8rem;overflow-x:auto}h1{font-size:1.6rem}</style>
|
|
2389
2647
|
<h1>pantheon</h1>
|
|
2390
2648
|
<p>Your coding agent and your teammates' agents, coordinating across machines.</p>
|
|
2391
|
-
${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, paste one line into a terminal on your own computer. It brings its own Node, needs no admin rights, and asks for your email and a 6-digit code.</p>
|
|
2649
|
+
${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, paste one line into a terminal on your own computer. It brings its own Node, needs no admin rights, and asks for your email and a 6-digit code (it is emailed; check spam if it does not arrive).</p>
|
|
2392
2650
|
<p>Mac or Linux (Terminal):</p>
|
|
2393
2651
|
<pre><code>${esc(sh)}</code></pre>
|
|
2394
2652
|
<p>Windows (Command Prompt or PowerShell):</p>
|
|
@@ -2396,7 +2654,7 @@ ${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, past
|
|
|
2396
2654
|
<p>Invited to a team? Use the line in your invite instead. Already have Node.js 22.13 or newer: <code>npm install -g @join-pantheon/cli</code>, then <code>pantheon start</code>.</p>` : `<p>This is a relay endpoint, not a website. With Node.js 22.13 or newer, install the CLI and start a team:</p>
|
|
2397
2655
|
<pre><code>npm install -g @join-pantheon/cli
|
|
2398
2656
|
pantheon start</code></pre>
|
|
2399
|
-
<p>It asks for your email and sends you a 6-digit code. Invited to a team? Use the line in your invite.</p>`}`);
|
|
2657
|
+
<p>It asks for your email and sends you a 6-digit code (check spam if it does not arrive). Invited to a team? Use the line in your invite.</p>`}`);
|
|
2400
2658
|
}
|
|
2401
2659
|
startInstallerCheck() { this.installerGate.start(); }
|
|
2402
2660
|
async serveInstaller(req, res, name) {
|
|
@@ -2462,6 +2720,7 @@ pantheon start</code></pre>
|
|
|
2462
2720
|
};
|
|
2463
2721
|
let sessionId = "";
|
|
2464
2722
|
let role = "interactive";
|
|
2723
|
+
let quiet = false;
|
|
2465
2724
|
let myPubkey = "";
|
|
2466
2725
|
let delegated = null;
|
|
2467
2726
|
let authEpoch = -1, authOk = true;
|
|
@@ -2701,10 +2960,21 @@ pantheon start</code></pre>
|
|
|
2701
2960
|
}
|
|
2702
2961
|
if (verdict === "key-mismatch") {
|
|
2703
2962
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
2963
|
+
const moved = sp.allow.get(m.member)?.moved;
|
|
2964
|
+
if (moved && moved.from_fp === fingerprint(m.pubkey)) {
|
|
2965
|
+
const how = moved.how === "confirmed" ? `, confirmed by ${moved.by}` : moved.how === "alone" ? "" : ", after nobody cancelled it";
|
|
2966
|
+
return reject("KEY_MISMATCH", `"${m.member}"'s seat in "${sp.name}" moved to another device on ${new Date(moved.at).toISOString().slice(0, 16).replace("T", " ")} UTC — signed in with the code emailed to ${m.member}'s account${how}. This machine's key no longer opens it. ` +
|
|
2967
|
+
`If that was you, use the new machine (or run pantheon recover ${sp.name} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
|
|
2968
|
+
}
|
|
2969
|
+
const recover = this.requireAccounts && this.accounts
|
|
2970
|
+
? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${sp.name} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
|
|
2971
|
+
: "";
|
|
2704
2972
|
if (sp.allow.founder() === m.member) {
|
|
2705
|
-
return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key
|
|
2973
|
+
return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key.` + (recover ||
|
|
2974
|
+
` A founder's key cannot be replaced: if this is really you on another machine, restore your original ~/.pantheon/config.json (the key is in it). Without it, join under another name (--me <new-name>) or start a new team (pantheon start).`));
|
|
2706
2975
|
}
|
|
2707
|
-
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>)
|
|
2976
|
+
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>).` + (recover ||
|
|
2977
|
+
` If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`));
|
|
2708
2978
|
}
|
|
2709
2979
|
if (verdict === "revoked") {
|
|
2710
2980
|
const e = sp.allow.get(m.member);
|
|
@@ -2747,8 +3017,9 @@ pantheon start</code></pre>
|
|
|
2747
3017
|
clearHelloTimer();
|
|
2748
3018
|
sessionId = acctSession ?? this.mintSession();
|
|
2749
3019
|
role = delegated ? "worker" : m.role;
|
|
2750
|
-
|
|
2751
|
-
|
|
3020
|
+
quiet = m.upkeep === true && role === "cli";
|
|
3021
|
+
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg, quiet);
|
|
3022
|
+
const firstSession = !quiet && presentSessions(me).length === 1;
|
|
2752
3023
|
const prior = typeof m.session === "string" ? m.session : "";
|
|
2753
3024
|
const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
|
|
2754
3025
|
if (stale && stale.role === m.role) {
|
|
@@ -2798,7 +3069,7 @@ pantheon start</code></pre>
|
|
|
2798
3069
|
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
2799
3070
|
for (const r of page) {
|
|
2800
3071
|
this.send(ws, { t: "act", act: r });
|
|
2801
|
-
if (r.seq > me.delivered_seq)
|
|
3072
|
+
if (!quiet && r.seq > me.delivered_seq)
|
|
2802
3073
|
me.delivered_seq = r.seq;
|
|
2803
3074
|
}
|
|
2804
3075
|
from = page[page.length - 1].seq;
|
|
@@ -3151,6 +3422,29 @@ pantheon start</code></pre>
|
|
|
3151
3422
|
return fail("BAD_META", "goal_members, organization_profile and notice are written by the relay, not sent", m.cid);
|
|
3152
3423
|
if (m.act === "NOTE" && m.body.startsWith("repo registered with this space: "))
|
|
3153
3424
|
return fail("BAD_META", "that line is written by the relay, not sent", m.cid);
|
|
3425
|
+
let recoveryStep = null;
|
|
3426
|
+
if (m.act === "NOTE" && m.meta && "recovery" in m.meta) {
|
|
3427
|
+
const rq = m.meta.recovery;
|
|
3428
|
+
const target = typeof rq?.member === "string" ? rq.member : "";
|
|
3429
|
+
const action = rq?.action;
|
|
3430
|
+
if (action !== "confirm" && action !== "cancel")
|
|
3431
|
+
return fail("BAD_META", "recovery.action is confirm or cancel", m.cid);
|
|
3432
|
+
if (delegated || !m.sig || m.sig.role !== "member")
|
|
3433
|
+
return fail("RECOVERY_BY_PERSON", "only a person, signing with their own key, confirms or cancels a seat move — not a woken run", m.cid);
|
|
3434
|
+
if (!space.allow.get(me.id)?.admitted)
|
|
3435
|
+
return fail("NOT_ADMITTED", `only an admitted member of "${space.name}" can confirm or cancel a seat move`, m.cid);
|
|
3436
|
+
const pend = space.allow.recoveryOf(target);
|
|
3437
|
+
if (!pend)
|
|
3438
|
+
return fail("NO_RECOVERY", `no move of ${target || "anyone"}'s seat is waiting in "${space.name}"`, m.cid);
|
|
3439
|
+
if (rq.fp !== pend.fp)
|
|
3440
|
+
return fail("RECOVERY_KEY", `the key waiting for ${target} is ${pend.fp}, not ${String(rq.fp)} — check it with them again`, m.cid);
|
|
3441
|
+
if (action === "confirm" && target === me.id)
|
|
3442
|
+
return fail("RECOVERY_SELF", "a seat move is confirmed by a teammate, not by the person whose seat it is", m.cid);
|
|
3443
|
+
const curFp = fingerprint(space.allow.get(target).publicKey);
|
|
3444
|
+
if (action === "confirm" && rq.from_fp !== curFp)
|
|
3445
|
+
return fail("RECOVERY_KEY", `${target}'s seat is now on ${curFp}; the confirmation must name it (from_fp) — run the command again`, m.cid);
|
|
3446
|
+
recoveryStep = { action, member: target, pend };
|
|
3447
|
+
}
|
|
3154
3448
|
const fromRepo = m.repo && space.allow.hasRepo(m.repo) ? m.repo : null;
|
|
3155
3449
|
const { from_repo: _forged, ...senderMeta } = (m.meta ?? {});
|
|
3156
3450
|
const hasMeta = !!m.meta && (Object.keys(senderMeta).length > 0 || _forged === undefined);
|
|
@@ -3159,7 +3453,7 @@ pantheon start</code></pre>
|
|
|
3159
3453
|
: undefined;
|
|
3160
3454
|
const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
|
|
3161
3455
|
const prior = space.recent.get(fp);
|
|
3162
|
-
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
3456
|
+
if (prior && !recoveryStep && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
3163
3457
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
3164
3458
|
return;
|
|
3165
3459
|
}
|
|
@@ -3193,7 +3487,7 @@ pantheon start</code></pre>
|
|
|
3193
3487
|
}
|
|
3194
3488
|
}
|
|
3195
3489
|
const wasFull = this.spaceFull(space);
|
|
3196
|
-
if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
|
|
3490
|
+
if (wasFull && !recoveryStep && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
|
|
3197
3491
|
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
3198
3492
|
const rec = this.commit(space, {
|
|
3199
3493
|
...(m.sig && m.id ? { id: m.id } : {}),
|
|
@@ -3226,6 +3520,15 @@ pantheon start</code></pre>
|
|
|
3226
3520
|
space.recent.delete(k);
|
|
3227
3521
|
}
|
|
3228
3522
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
3523
|
+
if (recoveryStep) {
|
|
3524
|
+
const { action, member: who, pend } = recoveryStep;
|
|
3525
|
+
if (space.allow.recoveryOf(who)?.pubkey === pend.pubkey) {
|
|
3526
|
+
if (action === "cancel")
|
|
3527
|
+
this.endRecovery(space, who, `cancelled by ${me.id}`, me.id, ip);
|
|
3528
|
+
else
|
|
3529
|
+
this.completeRecovery(space, who, pend.pubkey, "confirmed", me.id, rec, ip, pend.account);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3229
3532
|
}
|
|
3230
3533
|
catch (e) {
|
|
3231
3534
|
const cidPart = reqCid ? { cid: reqCid } : {};
|
|
@@ -3279,7 +3582,7 @@ pantheon start</code></pre>
|
|
|
3279
3582
|
v.at = Date.now();
|
|
3280
3583
|
}
|
|
3281
3584
|
const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
|
|
3282
|
-
if (me.
|
|
3585
|
+
if (presentSessions(me).length === 0 && role !== "cli" && !quiet && stillMember) {
|
|
3283
3586
|
me.last_seen = Date.now();
|
|
3284
3587
|
this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
|
|
3285
3588
|
}
|
|
@@ -4135,12 +4438,16 @@ pantheon start</code></pre>
|
|
|
4135
4438
|
if (dropped)
|
|
4136
4439
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|
|
4137
4440
|
}
|
|
4138
|
-
attach(sp, id, display, agent, session, role, ws, pkg) {
|
|
4441
|
+
attach(sp, id, display, agent, session, role, ws, pkg, quiet = false) {
|
|
4139
4442
|
let m = sp.members.get(id);
|
|
4140
4443
|
if (!m) {
|
|
4141
|
-
m = { id, display, agent, last_seen: Date.now(), sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] };
|
|
4444
|
+
m = { id, display, agent, last_seen: quiet ? 0 : Date.now(), sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] };
|
|
4142
4445
|
sp.members.set(id, m);
|
|
4143
4446
|
}
|
|
4447
|
+
if (quiet) {
|
|
4448
|
+
m.sessions.set(session, { id: session, role, agent, ...(pkg ? { pkg } : {}), ws, quiet: true });
|
|
4449
|
+
return m;
|
|
4450
|
+
}
|
|
4144
4451
|
m.display = display;
|
|
4145
4452
|
m.agent = agent;
|
|
4146
4453
|
m.last_seen = Date.now();
|
|
@@ -4152,10 +4459,10 @@ pantheon start</code></pre>
|
|
|
4152
4459
|
return workState(m, Date.now(), this.busyTtlMs, this.pruneMs);
|
|
4153
4460
|
}
|
|
4154
4461
|
roster(sp) {
|
|
4155
|
-
return [...sp.members.values()].map((m) => ({
|
|
4462
|
+
return [...sp.members.values()].filter((m) => m.last_seen > 0 || presentSessions(m).length > 0).map((m) => ({
|
|
4156
4463
|
id: m.id, display: m.display, agent: m.agent,
|
|
4157
|
-
online: m.
|
|
4158
|
-
sessions:
|
|
4464
|
+
online: presentSessions(m).length > 0, last_seen: m.last_seen,
|
|
4465
|
+
sessions: presentSessions(m).map((s) => ({
|
|
4159
4466
|
id: s.id, role: s.role, agent: s.agent, ...(s.pkg ? { pkg: s.pkg } : {}),
|
|
4160
4467
|
})),
|
|
4161
4468
|
...(this.work(m) ? { work: this.work(m) } : {}),
|
|
@@ -4171,6 +4478,8 @@ pantheon start</code></pre>
|
|
|
4171
4478
|
...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
|
|
4172
4479
|
...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
|
|
4173
4480
|
...(sp.allow.founder() === e.member ? { founder: true } : {}),
|
|
4481
|
+
...((r) => r ? { recovery: { fp: r.fp, label: r.label, requested_at: r.requested_at, due_at: r.due_at } } : {})(sp.allow.recoveryOf(e.member)),
|
|
4482
|
+
...(e.moved ? { moved: e.moved } : {}),
|
|
4174
4483
|
}));
|
|
4175
4484
|
}
|
|
4176
4485
|
send(ws, msg) {
|
|
@@ -4185,7 +4494,8 @@ pantheon start</code></pre>
|
|
|
4185
4494
|
for (const s of m.sessions.values())
|
|
4186
4495
|
if (s.ws.readyState === 1) {
|
|
4187
4496
|
s.ws.send(payload);
|
|
4188
|
-
|
|
4497
|
+
if (!s.quiet)
|
|
4498
|
+
reached = true;
|
|
4189
4499
|
}
|
|
4190
4500
|
if (reached && seq !== null && seq > m.delivered_seq)
|
|
4191
4501
|
m.delivered_seq = seq;
|
package/dist/repo-find.js
CHANGED
|
@@ -44,12 +44,15 @@ export function reposUnder(dir, depth = 3, budget = { left: 4000 }) {
|
|
|
44
44
|
return out;
|
|
45
45
|
}
|
|
46
46
|
export function holdsRepo(dir, budget = 20_000) {
|
|
47
|
+
return repoInside(dir, budget) !== null;
|
|
48
|
+
}
|
|
49
|
+
export function repoInside(dir, budget = 20_000) {
|
|
47
50
|
let left = budget;
|
|
48
51
|
const stack = [resolve(dir)];
|
|
49
52
|
while (stack.length) {
|
|
50
53
|
const d = stack.pop();
|
|
51
54
|
if (left-- <= 0)
|
|
52
|
-
return
|
|
55
|
+
return "too-large";
|
|
53
56
|
let entries = [];
|
|
54
57
|
try {
|
|
55
58
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -59,12 +62,12 @@ export function holdsRepo(dir, budget = 20_000) {
|
|
|
59
62
|
}
|
|
60
63
|
for (const e of entries) {
|
|
61
64
|
if (e.name === ".git")
|
|
62
|
-
return
|
|
65
|
+
return d;
|
|
63
66
|
if (e.isDirectory())
|
|
64
67
|
stack.push(join(d, e.name));
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
|
-
return
|
|
70
|
+
return null;
|
|
68
71
|
}
|
|
69
72
|
export function commonCodeRoots(home = homedir()) {
|
|
70
73
|
return ["code", "Code", "src", "dev", "Developer", "projects", "Projects", "workspace", "work", "repos", "git", "github", "GitHub", "Documents/GitHub", "Documents/code", "Sites"]
|