@join-pantheon/cli 0.21.0 → 0.21.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 +7 -3
- package/dist/accounts.js +30 -8
- package/dist/cli.js +70 -8
- package/dist/helpers-build.js +23 -0
- package/dist/notifier.js +3 -0
- package/dist/relay.js +29 -8
- package/dist/service.js +14 -0
- package/dist/setup-guide.js +30 -0
- package/dist/tray.js +3 -0
- package/dist/upgrade.js +11 -1
- package/helper/macos/menubar.swift +47 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -499,8 +499,12 @@ Security details and how to report a vulnerability:
|
|
|
499
499
|
## Upgrading
|
|
500
500
|
|
|
501
501
|
```bash
|
|
502
|
-
pantheon upgrade # installs the latest release and
|
|
502
|
+
pantheon upgrade # installs the latest release and finishes the job
|
|
503
503
|
```
|
|
504
504
|
|
|
505
|
-
Every command prints its version and nudges you when a newer release exists.
|
|
506
|
-
|
|
505
|
+
Every command prints its version and nudges you when a newer release exists. `pantheon upgrade`
|
|
506
|
+
installs the new release, re-wires your agents, restarts pantheon's own background services (the
|
|
507
|
+
worker, alerts, menu bar) so they load the new code, and asks any setup question the new version
|
|
508
|
+
has that you have never answered. The one thing it cannot do is restart your agent windows: quit
|
|
509
|
+
and reopen them so they load the new server. A self-hosted relay is shared with your teammates, so
|
|
510
|
+
it prints the restart for that and leaves the timing to you.
|
package/dist/accounts.js
CHANGED
|
@@ -123,6 +123,11 @@ export class Accounts {
|
|
|
123
123
|
this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
|
|
124
124
|
if (!cols.has("limits_json"))
|
|
125
125
|
this.db.exec("ALTER TABLE accounts ADD COLUMN limits_json TEXT");
|
|
126
|
+
const scols = new Set(this.db.prepare("PRAGMA table_info(sessions)").all().map((c) => c.name));
|
|
127
|
+
if (!scols.has("ended_at"))
|
|
128
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN ended_at INTEGER");
|
|
129
|
+
if (!scols.has("role"))
|
|
130
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT");
|
|
126
131
|
}
|
|
127
132
|
close() { this.db.close(); }
|
|
128
133
|
accountByUsername(username) {
|
|
@@ -249,18 +254,28 @@ export class Accounts {
|
|
|
249
254
|
this.audit({ actor_account: accountId, event: "device_added", target: d.fingerprint });
|
|
250
255
|
return d;
|
|
251
256
|
}
|
|
252
|
-
openSession(accountId, deviceId, space, ip) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
openSession(accountId, deviceId, space, ip, role = null) {
|
|
258
|
+
const prior = this.db.prepare("SELECT ip FROM sessions WHERE device_id = ? ORDER BY created_at DESC LIMIT 1").get(deviceId);
|
|
259
|
+
const s = { id: newId("ses"), account_id: accountId, device_id: deviceId, space, ip, created_at: now(), last_seen: now(), revoked_at: null, ended_at: null, role };
|
|
260
|
+
this.db.prepare("INSERT INTO sessions (id, account_id, device_id, space, ip, created_at, last_seen, revoked_at, ended_at, role) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)")
|
|
261
|
+
.run(s.id, s.account_id, s.device_id, s.space, s.ip, s.created_at, s.last_seen, role);
|
|
262
|
+
if (!prior)
|
|
263
|
+
this.audit({ actor_account: accountId, event: "session_created", space: space || null, target: s.id, ip });
|
|
264
|
+
else if (prior.ip !== ip)
|
|
265
|
+
this.audit({ actor_account: accountId, event: "device_new_ip", space: space || null, target: deviceId, detail: `${prior.ip} → ${ip}`, ip });
|
|
257
266
|
return s;
|
|
258
267
|
}
|
|
259
268
|
touchSession(id) {
|
|
260
|
-
this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL").run(now(), id);
|
|
269
|
+
this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL AND ended_at IS NULL").run(now(), id);
|
|
270
|
+
}
|
|
271
|
+
endAllOpenSessions() {
|
|
272
|
+
return Number(this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = last_seen WHERE ended_at IS NULL").run(now()).changes);
|
|
273
|
+
}
|
|
274
|
+
endSession(id) {
|
|
275
|
+
this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = ? WHERE id = ? AND ended_at IS NULL").run(now(), now(), id);
|
|
261
276
|
}
|
|
262
277
|
sessionsFor(accountId) {
|
|
263
|
-
return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? ORDER BY created_at DESC").all(accountId);
|
|
278
|
+
return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND ended_at IS NULL ORDER BY created_at DESC").all(accountId);
|
|
264
279
|
}
|
|
265
280
|
devicesFor(accountId) {
|
|
266
281
|
return this.db.prepare("SELECT * FROM devices WHERE account_id = ? ORDER BY created_at ASC").all(accountId);
|
|
@@ -451,6 +466,12 @@ export class Accounts {
|
|
|
451
466
|
allDevices() {
|
|
452
467
|
return this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
453
468
|
}
|
|
469
|
+
liveSessions(limit = 500) {
|
|
470
|
+
return this.db.prepare("SELECT * FROM sessions WHERE ended_at IS NULL AND revoked_at IS NULL ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
471
|
+
}
|
|
472
|
+
endedSessionsSince(sinceMs) {
|
|
473
|
+
return this.db.prepare("SELECT COUNT(*) c FROM sessions WHERE ended_at IS NOT NULL AND ended_at >= ?").get(sinceMs).c;
|
|
474
|
+
}
|
|
454
475
|
allSessions(limit = 500) {
|
|
455
476
|
return this.db.prepare("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
456
477
|
}
|
|
@@ -460,7 +481,7 @@ export class Accounts {
|
|
|
460
481
|
accounts: n("SELECT COUNT(*) c FROM accounts"),
|
|
461
482
|
devices: n("SELECT COUNT(*) c FROM devices"),
|
|
462
483
|
sessions: n("SELECT COUNT(*) c FROM sessions"),
|
|
463
|
-
live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL"),
|
|
484
|
+
live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL AND ended_at IS NULL"),
|
|
464
485
|
audit: n("SELECT COUNT(*) c FROM audit"),
|
|
465
486
|
};
|
|
466
487
|
}
|
|
@@ -468,6 +489,7 @@ export class Accounts {
|
|
|
468
489
|
const sessionDays = opts.sessionDays ?? 30;
|
|
469
490
|
const auditDays = opts.auditDays ?? 365;
|
|
470
491
|
const sCut = now() - sessionDays * 86_400_000;
|
|
492
|
+
this.db.prepare("DELETE FROM sessions WHERE ended_at IS NOT NULL AND ended_at < ?").run(now() - 7 * 86_400_000);
|
|
471
493
|
const s = this.db.prepare("DELETE FROM sessions WHERE last_seen < ?").run(sCut);
|
|
472
494
|
let a = { changes: 0 };
|
|
473
495
|
if (auditDays > 0)
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ process.emitWarning = ((warning, ...rest) => {
|
|
|
7
7
|
return _emitWarning(warning, ...rest);
|
|
8
8
|
});
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
10
11
|
import { dirname, join, resolve } from "node:path";
|
|
11
12
|
import { PantheonClient } from "./client.js";
|
|
12
13
|
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
|
|
@@ -289,6 +290,24 @@ async function guidedSetup(cfg, project, opts = {}) {
|
|
|
289
290
|
c.close();
|
|
290
291
|
}
|
|
291
292
|
},
|
|
293
|
+
...(project ? {
|
|
294
|
+
setRepoBase: async (base) => {
|
|
295
|
+
const info = repoInfo(project);
|
|
296
|
+
if (!info)
|
|
297
|
+
throw new Error("not inside a git repo");
|
|
298
|
+
const { repo: _r, repoOrigin: _o, ...plain } = cfg;
|
|
299
|
+
const c = new PantheonClient(plain, undefined, { role: "cli" });
|
|
300
|
+
try {
|
|
301
|
+
await c.connect();
|
|
302
|
+
await c.bindRepo(info.fingerprint, info.origin, base);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
c.close();
|
|
306
|
+
}
|
|
307
|
+
if (cfg.spaceId)
|
|
308
|
+
bindRepo(info, cfg.spaceId);
|
|
309
|
+
},
|
|
310
|
+
} : {}),
|
|
292
311
|
});
|
|
293
312
|
console.error("");
|
|
294
313
|
for (const l of r.applied)
|
|
@@ -301,6 +320,15 @@ async function guidedSetup(cfg, project, opts = {}) {
|
|
|
301
320
|
rl?.close();
|
|
302
321
|
}
|
|
303
322
|
}
|
|
323
|
+
function workerMidRun() {
|
|
324
|
+
try {
|
|
325
|
+
const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
|
|
326
|
+
return !!s.at && Date.now() - s.at < 10 * 60_000 && /run/i.test(String(s.state ?? ""));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
304
332
|
function printReady(tools) {
|
|
305
333
|
const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
|
|
306
334
|
console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
|
|
@@ -396,7 +424,7 @@ ${D("more: pantheon help --all")}
|
|
|
396
424
|
const HELP_ALL = HELP + `
|
|
397
425
|
${B("less common")}
|
|
398
426
|
pantheon relay run only the relay, in this terminal
|
|
399
|
-
pantheon service install [relay|worker|notifier|menubar] | uninstall
|
|
427
|
+
pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
|
|
400
428
|
run a long-lived component in the background, from login
|
|
401
429
|
pantheon members the full list, with key fingerprints
|
|
402
430
|
pantheon whoami your name and fingerprint
|
|
@@ -654,15 +682,43 @@ async function main() {
|
|
|
654
682
|
return;
|
|
655
683
|
}
|
|
656
684
|
console.error(`\n${B(`upgraded ${r.from} → ${r.to}`)}`);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
685
|
+
const { restartService, serviceInstalled } = await import("./service.js");
|
|
686
|
+
console.error("");
|
|
687
|
+
if (process.platform === "darwin") {
|
|
688
|
+
const { trayStale, ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
|
|
689
|
+
const { notifierStale, ensureNativeNotifier } = await import("./notifier.js");
|
|
690
|
+
for (const [stale, build, what] of [[trayStale, () => { ensureTrayBinary(); writeTrayCommands(); }, "menu bar"], [notifierStale, ensureNativeNotifier, "alerts"]]) {
|
|
691
|
+
if (!stale())
|
|
692
|
+
continue;
|
|
693
|
+
try {
|
|
694
|
+
build();
|
|
695
|
+
console.error(` ${what}: rebuilt from this release's source`);
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
console.error(` ${what}: could not rebuild (${e.message.split("\n")[0]})`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
for (const kind of ["worker", "notifier", "menubar"]) {
|
|
703
|
+
if (!serviceInstalled(kind))
|
|
704
|
+
continue;
|
|
705
|
+
if (kind === "worker" && workerMidRun()) {
|
|
706
|
+
console.error(` ${D("worker: a run is in progress — left alone. When it is done:")} pantheon service restart worker`);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
for (const l of restartService(kind))
|
|
710
|
+
console.error(l);
|
|
711
|
+
}
|
|
712
|
+
if (r.restart.includes("relay"))
|
|
713
|
+
console.error(` relay: ${D("shared with your teammates, so it is yours to time:")} launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay`);
|
|
714
|
+
const cfgNow = load();
|
|
715
|
+
if (cfgNow) {
|
|
716
|
+
if (loadProfile(cfgNow).agent.wake_default === undefined)
|
|
717
|
+
await guidedSetup(cfgNow, repoInfo(process.cwd())?.root ?? null);
|
|
663
718
|
else
|
|
664
|
-
console.error(
|
|
719
|
+
console.error(D(`\n Your setup answers still apply — pantheon setup to change them.`));
|
|
665
720
|
}
|
|
721
|
+
console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
|
|
666
722
|
return;
|
|
667
723
|
}
|
|
668
724
|
case "setup": {
|
|
@@ -2111,6 +2167,12 @@ async function main() {
|
|
|
2111
2167
|
console.error(l);
|
|
2112
2168
|
return;
|
|
2113
2169
|
}
|
|
2170
|
+
if (sub === "restart") {
|
|
2171
|
+
const { restartService } = await import("./service.js");
|
|
2172
|
+
for (const l of restartService(kind))
|
|
2173
|
+
console.error(l);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2114
2176
|
if (kind === "relay") {
|
|
2115
2177
|
const data = resolveDataDir(flag("data")).dir;
|
|
2116
2178
|
const port = flag("port", "8787");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
export const helperHashPath = (binary) => `${binary}.source.sha256`;
|
|
4
|
+
export function sourceHash(source) {
|
|
5
|
+
return createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function recordHelperBuild(binary, source) {
|
|
8
|
+
try {
|
|
9
|
+
writeFileSync(helperHashPath(binary), sourceHash(source) + "\n");
|
|
10
|
+
}
|
|
11
|
+
catch { }
|
|
12
|
+
}
|
|
13
|
+
export function helperStale(binary, source) {
|
|
14
|
+
if (!existsSync(binary) || !existsSync(source))
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(helperHashPath(binary), "utf8").trim() !== sourceHash(source);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=helpers-build.js.map
|
package/dist/notifier.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { helperStale, recordHelperBuild } from "./helpers-build.js";
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { platform } from "node:os";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -56,8 +57,10 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
|
|
|
56
57
|
requireDeveloperTools("notifications", "notifier");
|
|
57
58
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
58
59
|
chmodSync(binary, 0o755);
|
|
60
|
+
recordHelperBuild(binary, source);
|
|
59
61
|
return binary;
|
|
60
62
|
}
|
|
63
|
+
export function notifierStale(home = CONFIG_DIR) { return helperStale(hudBinaryPath(home), hudSourcePath()); }
|
|
61
64
|
export function requireDeveloperTools(what, service) {
|
|
62
65
|
try {
|
|
63
66
|
execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
|
package/dist/relay.js
CHANGED
|
@@ -145,8 +145,12 @@ export class Relay {
|
|
|
145
145
|
this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
146
146
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
147
147
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
148
|
-
if (this.accounts)
|
|
148
|
+
if (this.accounts) {
|
|
149
149
|
this.backfillSpaceOwners();
|
|
150
|
+
const closed = this.accounts.endAllOpenSessions();
|
|
151
|
+
if (closed)
|
|
152
|
+
this.log(`ended ${closed} session${closed === 1 ? "" : "s"} left open by the previous relay process`);
|
|
153
|
+
}
|
|
150
154
|
this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
|
|
151
155
|
const envNum = (k) => {
|
|
152
156
|
const v = process.env[k];
|
|
@@ -925,7 +929,8 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
925
929
|
const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
926
930
|
const accounts = A.allAccounts();
|
|
927
931
|
const devices = A.allDevices();
|
|
928
|
-
const sessions = A.
|
|
932
|
+
const sessions = A.liveSessions(200);
|
|
933
|
+
const endedToday = A.endedSessionsSince(Date.now() - DAY);
|
|
929
934
|
const audit = A.auditLog({ limit: 300 });
|
|
930
935
|
const blocks = A.allBlocks();
|
|
931
936
|
const verifs = A.recentVerifications(50);
|
|
@@ -970,7 +975,7 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
970
975
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
971
976
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
972
977
|
<div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
|
|
973
|
-
<div class=stat><b>${sessions.filter((s) =>
|
|
978
|
+
<div class=stat><b>${sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>connections heartbeating / open</span></div>
|
|
974
979
|
<div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
|
|
975
980
|
<div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
|
|
976
981
|
<div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
|
|
@@ -1013,10 +1018,11 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
1013
1018
|
<p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
|
|
1014
1019
|
|
|
1015
1020
|
<h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
|
|
1016
|
-
<tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1017
|
-
${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
|
|
1018
|
-
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=
|
|
1021
|
+
<tr><th>session</th><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1022
|
+
${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
|
|
1023
|
+
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
|
|
1019
1024
|
</table></div>
|
|
1025
|
+
<p class=note>One row per open connection — an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes; ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours. "Stale" means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
|
|
1020
1026
|
|
|
1021
1027
|
<h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
|
|
1022
1028
|
<tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
|
|
@@ -1250,12 +1256,20 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1250
1256
|
let space = null;
|
|
1251
1257
|
let me = null;
|
|
1252
1258
|
let acct = null;
|
|
1259
|
+
let dbSession = null;
|
|
1260
|
+
let lastTouch = 0;
|
|
1261
|
+
const touch = () => {
|
|
1262
|
+
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1263
|
+
lastTouch = Date.now();
|
|
1264
|
+
this.accounts?.touchSession(dbSession);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1253
1267
|
let sessionId = "";
|
|
1254
1268
|
let role = "interactive";
|
|
1255
1269
|
let lastMsg = "";
|
|
1256
1270
|
let alive = true;
|
|
1257
1271
|
const nonce = newNonce();
|
|
1258
|
-
ws.on("pong", () => { alive = true; });
|
|
1272
|
+
ws.on("pong", () => { alive = true; touch(); });
|
|
1259
1273
|
const probe = ws;
|
|
1260
1274
|
probe._alive = () => alive;
|
|
1261
1275
|
probe._clear = () => { alive = false; };
|
|
@@ -1303,6 +1317,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1303
1317
|
}
|
|
1304
1318
|
const m = parsed.data;
|
|
1305
1319
|
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1320
|
+
touch();
|
|
1306
1321
|
if (m.t === "ping")
|
|
1307
1322
|
return this.send(ws, { t: "pong" });
|
|
1308
1323
|
if (m.t === "hello") {
|
|
@@ -1370,7 +1385,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1370
1385
|
}
|
|
1371
1386
|
m.member = auth.account.username;
|
|
1372
1387
|
acct = auth.account;
|
|
1373
|
-
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
|
|
1388
|
+
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
|
|
1389
|
+
dbSession = acctSession;
|
|
1390
|
+
lastTouch = Date.now();
|
|
1374
1391
|
}
|
|
1375
1392
|
let enrolled = null;
|
|
1376
1393
|
if (m.enroll) {
|
|
@@ -1702,6 +1719,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1702
1719
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
1703
1720
|
});
|
|
1704
1721
|
ws.on("close", () => {
|
|
1722
|
+
if (dbSession) {
|
|
1723
|
+
this.accounts?.endSession(dbSession);
|
|
1724
|
+
dbSession = null;
|
|
1725
|
+
}
|
|
1705
1726
|
if (!space || !me)
|
|
1706
1727
|
return;
|
|
1707
1728
|
me.sessions.delete(sessionId);
|
package/dist/service.js
CHANGED
|
@@ -138,6 +138,20 @@ export function uninstallService(kind) {
|
|
|
138
138
|
}
|
|
139
139
|
return out;
|
|
140
140
|
}
|
|
141
|
+
export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
|
|
142
|
+
if (!serviceInstalled(kind))
|
|
143
|
+
return [` ${kind}: not installed here`];
|
|
144
|
+
try {
|
|
145
|
+
if (platform() === "darwin")
|
|
146
|
+
exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
|
|
147
|
+
else
|
|
148
|
+
exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
|
|
149
|
+
return [` ${kind}: restarted on the new code`];
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
141
155
|
export function serviceInstalled(kind) {
|
|
142
156
|
try {
|
|
143
157
|
return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
|
package/dist/setup-guide.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
|
|
@@ -39,6 +40,21 @@ export function guideDefaults(profile, cfg) {
|
|
|
39
40
|
persona: false,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
43
|
+
export function detectBaseBranch(project) {
|
|
44
|
+
const git = (...a) => { try {
|
|
45
|
+
return execFileSync("git", a, { cwd: project, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "";
|
|
49
|
+
} };
|
|
50
|
+
if (!git("remote", "get-url", "origin"))
|
|
51
|
+
return null;
|
|
52
|
+
const head = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD");
|
|
53
|
+
if (head)
|
|
54
|
+
return head.replace(/^origin\//, "");
|
|
55
|
+
const cur = git("rev-parse", "--abbrev-ref", "HEAD");
|
|
56
|
+
return cur && cur !== "HEAD" ? cur : null;
|
|
57
|
+
}
|
|
42
58
|
export async function runGuide(io, ctx, deps = {}) {
|
|
43
59
|
const install = deps.installService ?? realInstall;
|
|
44
60
|
const uninstall = deps.uninstallService ?? realUninstall;
|
|
@@ -72,6 +88,11 @@ export async function runGuide(io, ctx, deps = {}) {
|
|
|
72
88
|
const p = await io.choose("Should your worker follow a short written playbook for this repo? It says what a woken run may do here and what it must leave to you. You can edit it any time.", ["Yes — write a starter playbook now (recommended)", "Not now"], 0);
|
|
73
89
|
a.persona = p === 0;
|
|
74
90
|
}
|
|
91
|
+
const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
|
|
92
|
+
if (detected) {
|
|
93
|
+
const b = await io.choose("Which branch should your worker start from, and deliveries be checked against? Usually the branch this project deploys or releases from.", [`${detected} (what origin calls its default)`, "A different branch"], 0);
|
|
94
|
+
a.base = b === 0 ? detected : ((await io.ask("Branch name", detected)).trim() || detected);
|
|
95
|
+
}
|
|
75
96
|
}
|
|
76
97
|
let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
|
|
77
98
|
const authority = { ...next.authority };
|
|
@@ -169,6 +190,15 @@ export async function runGuide(io, ctx, deps = {}) {
|
|
|
169
190
|
applied.push(`playbook written: ${personaFile} — commit it so every machine on this repo follows it`);
|
|
170
191
|
}
|
|
171
192
|
}
|
|
193
|
+
if (a.base && deps.setRepoBase) {
|
|
194
|
+
try {
|
|
195
|
+
await deps.setRepoBase(a.base);
|
|
196
|
+
applied.push(`workers on this repo cut from ${a.base}, and deliveries are checked against it`);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
warnings.push(`could not register the base branch with the team (${e.message}) — later: pantheon repo add --base ${a.base}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
172
202
|
return { answers: a, applied, warnings, ...(personaFile ? { personaFile } : {}) };
|
|
173
203
|
}
|
|
174
204
|
export function starterPersona(a) {
|
package/dist/tray.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { helperStale, recordHelperBuild } from "./helpers-build.js";
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { platform } from "node:os";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -26,8 +27,10 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
|
|
|
26
27
|
requireDeveloperTools("the menu bar", "menubar");
|
|
27
28
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
28
29
|
chmodSync(binary, 0o755);
|
|
30
|
+
recordHelperBuild(binary, source);
|
|
29
31
|
return binary;
|
|
30
32
|
}
|
|
33
|
+
export function trayStale(home = CONFIG_DIR) { return helperStale(trayBinaryPath(home), traySourcePath()); }
|
|
31
34
|
export function writeTrayCommands(home = CONFIG_DIR) {
|
|
32
35
|
const bin = whichPantheon() ?? "pantheon";
|
|
33
36
|
const dir = join(home, "bin");
|
package/dist/upgrade.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { installForUser } from "./install.js";
|
|
6
|
-
import { relayProc, workerProc } from "./machine.js";
|
|
6
|
+
import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
|
|
7
7
|
export function repoRoot() {
|
|
8
8
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
9
|
}
|
|
@@ -42,6 +42,16 @@ export function defaultDeps() {
|
|
|
42
42
|
up.push("worker");
|
|
43
43
|
}
|
|
44
44
|
catch { }
|
|
45
|
+
try {
|
|
46
|
+
if (notifierProc().running)
|
|
47
|
+
up.push("notifier");
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
if (menubarProc().running)
|
|
52
|
+
up.push("menubar");
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
45
55
|
return up;
|
|
46
56
|
},
|
|
47
57
|
npmUpgrade: () => execFileSync("npm", ["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
|
|
@@ -118,21 +118,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
///
|
|
124
|
-
///
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
121
|
+
enum MarkMode { case full, idle, off }
|
|
122
|
+
|
|
123
|
+
/// The Three Stones mark — the product's own logo, the same three arcs the
|
|
124
|
+
/// website draws: a lintel across the top and a gate at the bottom around a
|
|
125
|
+
/// centre nobody owns. Drawn with paths rather than loaded from an asset, so it
|
|
126
|
+
/// is a crisp template image at any menu bar scale and takes the system tint.
|
|
127
|
+
/// State rides on its weight: full when peers can wake work here, lighter when
|
|
128
|
+
/// connected but nothing unattended will run, faint with a slash when the relay
|
|
129
|
+
/// is unreachable or this machine is not in a space. Never rotate it.
|
|
130
|
+
private func markImage(_ mode: MarkMode) -> NSImage {
|
|
131
|
+
let img = NSImage(size: NSSize(width: 18, height: 18), flipped: false) { rect in
|
|
132
|
+
let s = rect.width / 24.0 // the mark is authored on a 24-unit grid
|
|
133
|
+
let c = NSPoint(x: rect.midX, y: rect.midY)
|
|
134
|
+
let r = 8.9 * s
|
|
135
|
+
let stones = NSBezierPath()
|
|
136
|
+
stones.lineWidth = 3.4 * s
|
|
137
|
+
stones.lineCapStyle = .round
|
|
138
|
+
for (a, b) in [(51.0, 129.0), (171.0, 249.0), (291.0, 369.0)] { // lintel, left stone, right stone
|
|
139
|
+
let rad = a * .pi / 180
|
|
140
|
+
stones.move(to: NSPoint(x: c.x + r * cos(rad), y: c.y + r * sin(rad)))
|
|
141
|
+
stones.appendArc(withCenter: c, radius: r, startAngle: a, endAngle: b, clockwise: false)
|
|
142
|
+
}
|
|
143
|
+
let alpha: CGFloat = mode == .full ? 1.0 : mode == .idle ? 0.58 : 0.32
|
|
144
|
+
NSColor.black.withAlphaComponent(alpha).setStroke()
|
|
145
|
+
stones.stroke()
|
|
146
|
+
if mode == .off {
|
|
147
|
+
let slash = NSBezierPath()
|
|
148
|
+
slash.lineWidth = 2.2 * s
|
|
149
|
+
slash.lineCapStyle = .round
|
|
150
|
+
slash.move(to: NSPoint(x: rect.minX + 4 * s, y: rect.minY + 4 * s))
|
|
151
|
+
slash.line(to: NSPoint(x: rect.maxX - 4 * s, y: rect.maxY - 4 * s))
|
|
152
|
+
NSColor.black.setStroke()
|
|
153
|
+
slash.stroke()
|
|
154
|
+
}
|
|
155
|
+
return true
|
|
135
156
|
}
|
|
157
|
+
img.isTemplate = true
|
|
158
|
+
img.accessibilityDescription = "pantheon"
|
|
159
|
+
return img
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// The glyph is the product's one-word status; a count rides alongside when
|
|
163
|
+
/// decisions wait on the human — the badge is the point.
|
|
164
|
+
private func paint() {
|
|
165
|
+
let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
|
|
166
|
+
item.button?.image = markImage(mode)
|
|
167
|
+
item.button?.title = state.decisions.isEmpty ? "" : " \(state.decisions.count)"
|
|
168
|
+
item.button?.imagePosition = .imageLeading
|
|
136
169
|
item.button?.toolTip = state.configured
|
|
137
170
|
? "pantheon — \(state.member) in \(state.space)\(state.relayOk ? "" : " (relay unreachable)")"
|
|
138
171
|
: "pantheon — not joined to a space yet"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.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": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"relay": "node dist/cli.js relay",
|
|
16
16
|
"test": "PANTHEON_HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
|
|
17
17
|
"prepare": "npm run build",
|
|
18
|
-
"prepublishOnly": "npm run build &&
|
|
18
|
+
"prepublishOnly": "npm run build && npm test",
|
|
19
19
|
"start": "node dist/cli.js relay"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|