@join-pantheon/cli 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/accounts.js CHANGED
@@ -168,6 +168,8 @@ export class Accounts {
168
168
  const existing = this.accountByEmail(e);
169
169
  if (existing && existing.status === "active")
170
170
  return { ok: false, reason: "email_taken" };
171
+ if (existing && existing.status === "suspended")
172
+ return { ok: false, reason: "blocked" };
171
173
  const byName = this.accountByUsername(u);
172
174
  if (byName && byName.email !== e)
173
175
  return { ok: false, reason: "username_taken" };
@@ -177,9 +179,12 @@ export class Accounts {
177
179
  if (!acct || acct.status !== "active")
178
180
  return { ok: false, reason: "no_such_account" };
179
181
  }
180
- const prev = this.db.prepare("SELECT created_at FROM verifications WHERE email = ?").get(e);
182
+ const prev = this.db.prepare("SELECT created_at, expires_at, pending_pubkey FROM verifications WHERE email = ?").get(e);
181
183
  if (prev && now() - prev.created_at < CODE_RESEND_COOLDOWN_MS)
182
184
  return { ok: false, reason: "too_soon" };
185
+ if (prev && now() < prev.expires_at && prev.pending_pubkey && prev.pending_pubkey !== opts.pubkey) {
186
+ return { ok: false, reason: "in_progress_elsewhere" };
187
+ }
183
188
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
184
189
  this.db.prepare(`
185
190
  INSERT INTO verifications (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
@@ -192,7 +197,7 @@ export class Accounts {
192
197
  `).run(e, sha(code), purpose, purpose === "signup" ? normalizeUsername(opts.username) : null, opts.pubkey, opts.label, now() + CODE_TTL_MS, now());
193
198
  return { ok: true, code };
194
199
  }
195
- verify(email, code, ip) {
200
+ verify(email, code, ip, pubkey) {
196
201
  const e = normalizeEmail(email);
197
202
  const v = this.db.prepare("SELECT * FROM verifications WHERE email = ?").get(e);
198
203
  if (!v)
@@ -203,6 +208,8 @@ export class Accounts {
203
208
  }
204
209
  if (v.attempts >= MAX_ATTEMPTS)
205
210
  return { ok: false, reason: "too_many_attempts" };
211
+ if (pubkey && v.pending_pubkey && pubkey !== v.pending_pubkey)
212
+ return { ok: false, reason: "other_device" };
206
213
  const want = Buffer.from(v.code_hash, "hex");
207
214
  const got = Buffer.from(sha(code), "hex");
208
215
  const good = want.length === got.length && timingSafeEqual(want, got);
@@ -216,6 +223,10 @@ export class Accounts {
216
223
  let account;
217
224
  if (v.purpose === "signup") {
218
225
  const existing = this.accountByEmail(e);
226
+ if (existing?.status === "suspended") {
227
+ this.db.exec("ROLLBACK");
228
+ return { ok: false, reason: "suspended" };
229
+ }
219
230
  if (existing) {
220
231
  account = existing;
221
232
  run("UPDATE accounts SET status = 'active', username = ?, tos_at = COALESCE(tos_at, ?) WHERE id = ?").run(v.pending_username, now(), account.id);
package/dist/agents.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { homedir, platform } from "node:os";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { constants as osConstants, homedir, platform } from "node:os";
5
5
  import { join, dirname, isAbsolute } from "node:path";
6
6
  import { launcher, kiroDir } from "./install.js";
7
7
  import { agentSpawn, which } from "./which.js";
@@ -122,6 +122,8 @@ export const SIGN_IN_CHECK = {
122
122
  kiro: { args: ["whoami", "--format", "json"], fix: "run `kiro-cli login` (or set KIRO_API_KEY)" },
123
123
  };
124
124
  export const NEEDS_SIGN_IN_BEFORE_SPAWN = new Set(["kiro"]);
125
+ export const NOT_FOR_UNATTENDED_RUNS = new Set(["kiro"]);
126
+ export const KIRO_UNATTENDED_REFUSAL = "Kiro cannot yet be fenced to its worktree for unattended runs; the worker uses another installed agent, or none";
125
127
  export function kiroSignedIn(binary, run = runStatus, env = process.env, home = homedir(), os = platform()) {
126
128
  if (env.KIRO_API_KEY)
127
129
  return { ok: true, detail: "signed in (KIRO_API_KEY in the environment)" };
@@ -211,6 +213,12 @@ export function chooseAgent(preferred, opts = {}) {
211
213
  if (!order.some((o) => o.backend.id === a.backend.id))
212
214
  order.push({ ...a, because: available.length === 1 ? "only one on PATH" : "first signed in on PATH" });
213
215
  const skipped = [];
216
+ const usable = order.filter((c) => !NOT_FOR_UNATTENDED_RUNS.has(c.backend.id));
217
+ for (const c of order)
218
+ if (NOT_FOR_UNATTENDED_RUNS.has(c.backend.id) && usable.length)
219
+ skipped.push({ backend: c.backend, why: KIRO_UNATTENDED_REFUSAL });
220
+ if (usable.length)
221
+ order.splice(0, order.length, ...usable);
214
222
  for (const c of order) {
215
223
  const s = state(c.backend, c.binary);
216
224
  if (!s || s.ok || s.unknown)
@@ -221,6 +229,8 @@ export function chooseAgent(preferred, opts = {}) {
221
229
  return { backend: first.backend, binary: first.binary, signIn: state(first.backend, first.binary), because: `${first.because} (none signed in)`, skipped };
222
230
  }
223
231
  export function unattendedRefusal(c) {
232
+ if (NOT_FOR_UNATTENDED_RUNS.has(c.backend.id))
233
+ return KIRO_UNATTENDED_REFUSAL;
224
234
  if (!NEEDS_SIGN_IN_BEFORE_SPAWN.has(c.backend.id) || c.signIn?.ok)
225
235
  return null;
226
236
  return `${c.backend.label} is not signed in (${c.signIn?.detail ?? "not checked"}), and signed out it opens a browser to log in, so it is not started while you are away — ${SIGN_IN_CHECK[c.backend.id].fix}`;
@@ -229,6 +239,12 @@ export function describeChoice(c) {
229
239
  const head = `${c.backend.label}${c.signIn ? ` — ${c.signIn.detail}` : ""} (${c.because})`;
230
240
  return c.skipped.length ? `${head}; skipped ${c.skipped.map((s) => `${s.backend.label} — ${s.why}`).join("; ")}` : head;
231
241
  }
242
+ export function skippedSummary(c) {
243
+ if (!c.skipped.length)
244
+ return "";
245
+ const why = [...new Set(c.skipped.map((s) => (s.why === KIRO_UNATTENDED_REFUSAL ? `${s.backend.label} cannot run unattended yet` : "not signed in")))];
246
+ return `${c.skipped.length} skipped (${why.join("; ")})`;
247
+ }
232
248
  export function resolveAgent(preferred) {
233
249
  const c = chooseAgent(preferred, { check: null });
234
250
  return { backend: c.backend, binary: c.binary };
@@ -279,6 +295,9 @@ export function headlessSpawn(opts) {
279
295
  }
280
296
  if (opts.backend.id === "codex") {
281
297
  const entry = { command: l.command, args: l.args };
298
+ const win = (opts.os ?? platform()) === "win32";
299
+ const str = (v) => (win ? tomlLiteral(v) : JSON.stringify(v));
300
+ const list = (vs) => (win ? `[${vs.map(tomlLiteral).join(", ")}]` : JSON.stringify(vs));
282
301
  return {
283
302
  command: opts.binary,
284
303
  args: [
@@ -288,10 +307,10 @@ export function headlessSpawn(opts) {
288
307
  : ["--sandbox", "read-only"]),
289
308
  "--skip-git-repo-check",
290
309
  "--ignore-user-config",
291
- "-c", `mcp_servers.pantheon.command=${JSON.stringify(entry.command)}`,
292
- "-c", `mcp_servers.pantheon.args=${JSON.stringify(entry.args)}`,
293
- "-c", `mcp_servers.pantheon.env=${tomlInlineTable(fileEnv)}`,
294
- "-c", `mcp_servers.pantheon.default_tools_approval_mode="approve"`,
310
+ "-c", `mcp_servers.pantheon.command=${str(entry.command)}`,
311
+ "-c", `mcp_servers.pantheon.args=${list(entry.args)}`,
312
+ "-c", `mcp_servers.pantheon.env=${tomlInlineTable(fileEnv, win)}`,
313
+ "-c", `mcp_servers.pantheon.default_tools_approval_mode=${str("approve")}`,
295
314
  opts.prompt,
296
315
  ],
297
316
  env: procEnv,
@@ -397,8 +416,11 @@ function readCopilotConfig() {
397
416
  return {};
398
417
  }
399
418
  }
400
- export function tomlInlineTable(kv) {
401
- return `{${Object.entries(kv).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ")}}`;
419
+ export function tomlInlineTable(kv, literal = false) {
420
+ return `{${Object.entries(kv).map(([k, v]) => `${k} = ${literal ? tomlLiteral(v) : JSON.stringify(v)}`).join(", ")}}`;
421
+ }
422
+ export function tomlLiteral(v) {
423
+ return /['\r\n]/.test(v) ? JSON.stringify(v) : `'${v}'`;
402
424
  }
403
425
  export function materializeSpawnFiles(worktree, spawn) {
404
426
  for (const f of spawn.writeFiles ?? []) {
@@ -419,4 +441,350 @@ export function backendFromLabel(label) {
419
441
  return undefined;
420
442
  return agentById(label);
421
443
  }
444
+ export const REPO_AGENT_CONFIG = [
445
+ ".claude/settings.json",
446
+ ".claude/settings.local.json",
447
+ ".cursor/hooks.json",
448
+ ".cursor/mcp.json",
449
+ ".cursor/cli.json",
450
+ ".github/hooks",
451
+ ".mcp.json",
452
+ ".vscode/mcp.json",
453
+ ".kiro",
454
+ ".codex",
455
+ ];
456
+ export const AGENT_EDIT_SUFFIX = ".pantheon-agent-edit";
457
+ const quarantineDir = (stashRoot, worktree) => join(stashRoot, "quarantine", createHash("sha256").update(worktree).digest("hex").slice(0, 16));
458
+ const isLink = (p) => { try {
459
+ return lstatSync(p).isSymbolicLink();
460
+ }
461
+ catch {
462
+ return false;
463
+ } };
464
+ export function hashPath(p) {
465
+ let st;
466
+ try {
467
+ st = lstatSync(p);
468
+ }
469
+ catch {
470
+ return null;
471
+ }
472
+ const h = createHash("sha256");
473
+ if (st.isSymbolicLink())
474
+ h.update(`l:${readlinkSync(p)}`);
475
+ else if (st.isDirectory()) {
476
+ h.update("d:");
477
+ for (const name of readdirSync(p).sort())
478
+ h.update(`${name}\0${hashPath(join(p, name)) ?? ""}\0`);
479
+ }
480
+ else
481
+ h.update("f:").update(readFileSync(p));
482
+ return h.digest("hex");
483
+ }
484
+ function headOf(worktree) {
485
+ try {
486
+ return execFileSync("git", ["-C", worktree, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim();
487
+ }
488
+ catch {
489
+ return "";
490
+ }
491
+ }
492
+ const pause = (ms) => { try {
493
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
494
+ }
495
+ catch { } };
496
+ export function moveSync(from, to, rename = renameSync) {
497
+ for (let attempt = 0;; attempt++) {
498
+ try {
499
+ rename(from, to);
500
+ return;
501
+ }
502
+ catch (e) {
503
+ const code = e.code;
504
+ if (code === "EXDEV") {
505
+ cpSync(from, to, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
506
+ if (hashPath(to) !== hashPath(from)) {
507
+ rmSync(to, { recursive: true, force: true });
508
+ throw e;
509
+ }
510
+ rmSync(from, { recursive: true, force: true });
511
+ return;
512
+ }
513
+ if ((code === "EPERM" || code === "EBUSY" || code === "EACCES") && attempt < 5) {
514
+ pause(100 * (attempt + 1));
515
+ continue;
516
+ }
517
+ throw e;
518
+ }
519
+ }
520
+ }
521
+ function movableUnit(root, rel) {
522
+ const parts = rel.split("/");
523
+ for (let i = 1; i <= parts.length; i++) {
524
+ const sub = parts.slice(0, i).join("/");
525
+ let st;
526
+ try {
527
+ st = lstatSync(join(root, sub));
528
+ }
529
+ catch {
530
+ return null;
531
+ }
532
+ if (st.isSymbolicLink())
533
+ return sub;
534
+ }
535
+ return rel;
536
+ }
537
+ function realParents(root, rel) {
538
+ const parts = rel.split("/").slice(0, -1);
539
+ for (let i = 1; i <= parts.length; i++) {
540
+ const p = join(root, parts.slice(0, i).join("/"));
541
+ try {
542
+ if (lstatSync(p).isDirectory())
543
+ continue;
544
+ moveSync(p, freeName(`${p}${AGENT_EDIT_SUFFIX}`));
545
+ }
546
+ catch { }
547
+ mkdirSync(p);
548
+ }
549
+ }
550
+ function freeName(base) {
551
+ const taken = (p) => { try {
552
+ lstatSync(p);
553
+ return true;
554
+ }
555
+ catch {
556
+ return false;
557
+ } };
558
+ if (!taken(base))
559
+ return base;
560
+ for (let i = 2;; i++)
561
+ if (!taken(`${base}-${i}`))
562
+ return `${base}-${i}`;
563
+ }
564
+ const writeManifest = (dir, m) => writeFileSync(join(dir, "manifest.json"), JSON.stringify(m), { mode: 0o600 });
565
+ function keepDiscarded(stashRoot, dir, rel) {
566
+ const to = join(stashRoot, "quarantine-kept", `${Date.now()}-${randomBytes(3).toString("hex")}`, rel);
567
+ try {
568
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
569
+ moveSync(join(dir, "files", rel), to);
570
+ return to;
571
+ }
572
+ catch {
573
+ return join(dir, "files", rel);
574
+ }
575
+ }
576
+ function restoreFrom(stashRoot, dir, m, mode) {
577
+ const r = { restored: [], setAside: [], discarded: [], lines: [] };
578
+ const left = [];
579
+ const headNow = mode === "recover" ? headOf(m.worktree) : m.head;
580
+ for (const e of m.moved) {
581
+ const from = join(dir, "files", e.rel);
582
+ if (!existsSync(from) && !isLink(from))
583
+ continue;
584
+ const to = join(m.worktree, e.rel);
585
+ try {
586
+ if (headNow !== m.head) {
587
+ const kept = keepDiscarded(stashRoot, dir, e.rel);
588
+ r.discarded.push(e.rel);
589
+ r.lines.push(`${e.rel}: not put back — the worktree moved from ${m.head.slice(0, 7) || "(no commit)"} to ${headNow.slice(0, 7) || "(no commit)"} since it was held; the held copy is at ${kept}`);
590
+ continue;
591
+ }
592
+ const now = hashPath(to);
593
+ if (now === e.hash) {
594
+ rmSync(from, { recursive: true, force: true });
595
+ r.restored.push(e.rel);
596
+ continue;
597
+ }
598
+ if (now !== null && m.wrote[e.rel] !== now) {
599
+ if (mode === "recover") {
600
+ const kept = keepDiscarded(stashRoot, dir, e.rel);
601
+ r.discarded.push(e.rel);
602
+ r.lines.push(`${e.rel}: not put back — what is there now differs from what was held, and is kept; the held copy is at ${kept}`);
603
+ continue;
604
+ }
605
+ const aside = freeName(`${to}${AGENT_EDIT_SUFFIX}`);
606
+ moveSync(to, aside);
607
+ r.setAside.push(e.rel);
608
+ r.lines.push(`${e.rel}: the run wrote here while it was held; its version is kept at ${aside}, and the repo's is back`);
609
+ }
610
+ else if (now !== null) {
611
+ rmSync(to, { recursive: true, force: true });
612
+ }
613
+ realParents(m.worktree, e.rel);
614
+ moveSync(from, to);
615
+ r.restored.push(e.rel);
616
+ }
617
+ catch (err) {
618
+ left.push(e);
619
+ r.lines.push(`${e.rel}: could not be put back (${err.message.split("\n")[0]}); it stays held for the next run`);
620
+ }
621
+ }
622
+ if (left.length)
623
+ writeManifest(dir, { ...m, moved: left });
624
+ else
625
+ rmSync(dir, { recursive: true, force: true });
626
+ return r;
627
+ }
628
+ function readManifest(file) {
629
+ try {
630
+ const m = JSON.parse(readFileSync(file, "utf8"));
631
+ if (m.v !== 2 || typeof m.worktree !== "string" || !Array.isArray(m.moved))
632
+ return null;
633
+ return { v: 2, worktree: m.worktree, head: typeof m.head === "string" ? m.head : "", moved: m.moved, wrote: m.wrote ?? {} };
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ export function recoverRepoAgentConfig(worktree, stashRoot) {
640
+ const dir = quarantineDir(stashRoot, worktree);
641
+ const m = readManifest(join(dir, "manifest.json"));
642
+ if (!m) {
643
+ if (existsSync(join(dir, "manifest.json"))) {
644
+ const kept = join(stashRoot, "quarantine-kept", `${Date.now()}-legacy`);
645
+ try {
646
+ mkdirSync(dirname(kept), { recursive: true });
647
+ moveSync(dir, kept);
648
+ }
649
+ catch { }
650
+ return { restored: [], setAside: [], discarded: ["*"], lines: [`an older held set for ${worktree} was not put back (it predates the checks that make that safe); it is kept at ${kept}`] };
651
+ }
652
+ return { restored: [], setAside: [], discarded: [], lines: [] };
653
+ }
654
+ return restoreFrom(stashRoot, dir, m, "recover");
655
+ }
656
+ export function pendingQuarantines(runHomesRoot) {
657
+ const out = [];
658
+ let homes = [];
659
+ try {
660
+ homes = readdirSync(runHomesRoot);
661
+ }
662
+ catch {
663
+ return out;
664
+ }
665
+ for (const h of homes) {
666
+ const q = join(runHomesRoot, h, "quarantine");
667
+ let sets = [];
668
+ try {
669
+ sets = readdirSync(q);
670
+ }
671
+ catch {
672
+ continue;
673
+ }
674
+ for (const s of sets) {
675
+ const m = readManifest(join(q, s, "manifest.json"));
676
+ if (m)
677
+ out.push({ runHome: join(runHomesRoot, h), worktree: m.worktree, head: m.head, paths: m.moved.map((e) => e.rel), dir: join(q, s) });
678
+ }
679
+ }
680
+ return out;
681
+ }
682
+ export function restorePendingQuarantines(runHomesRoot) {
683
+ const lines = [];
684
+ for (const p of pendingQuarantines(runHomesRoot)) {
685
+ if (!existsSync(p.worktree)) {
686
+ lines.push(`held agent config for ${p.worktree} (${p.paths.join(", ")}): that worktree is gone, so there is nothing to put it back into`);
687
+ continue;
688
+ }
689
+ const r = recoverRepoAgentConfig(p.worktree, p.runHome);
690
+ if (r.restored.length)
691
+ lines.push(`put back ${r.restored.join(", ")} in ${p.worktree}`);
692
+ lines.push(...r.lines);
693
+ }
694
+ return lines;
695
+ }
696
+ const openFences = new Set();
697
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
698
+ const onSignal = (sig) => {
699
+ const onlyOurs = process.listenerCount(sig) === 1;
700
+ for (const f of [...openFences]) {
701
+ try {
702
+ f();
703
+ }
704
+ catch { }
705
+ }
706
+ if (onlyOurs) {
707
+ untrap();
708
+ process.exit(128 + (osConstants.signals[sig] ?? 15));
709
+ }
710
+ };
711
+ const onExit = () => { for (const f of [...openFences]) {
712
+ try {
713
+ f();
714
+ }
715
+ catch { }
716
+ } };
717
+ let trapped = false;
718
+ function trap() {
719
+ if (trapped)
720
+ return;
721
+ trapped = true;
722
+ for (const s of SIGNALS)
723
+ process.on(s, onSignal);
724
+ process.on("exit", onExit);
725
+ }
726
+ function untrap() {
727
+ if (!trapped)
728
+ return;
729
+ trapped = false;
730
+ for (const s of SIGNALS)
731
+ process.removeListener(s, onSignal);
732
+ process.removeListener("exit", onExit);
733
+ }
734
+ export function quarantineRepoAgentConfig(worktree, stashRoot) {
735
+ const recovered = recoverRepoAgentConfig(worktree, stashRoot);
736
+ const dir = quarantineDir(stashRoot, worktree);
737
+ const m = { v: 2, worktree, head: headOf(worktree), moved: [], wrote: {} };
738
+ mkdirSync(join(dir, "files"), { recursive: true, mode: 0o700 });
739
+ let done = null;
740
+ const restore = () => {
741
+ if (done)
742
+ return { restored: [], setAside: [], discarded: [], lines: [] };
743
+ openFences.delete(restore);
744
+ if (!openFences.size)
745
+ untrap();
746
+ done = m.moved.length ? restoreFrom(stashRoot, dir, m, "run") : { restored: [], setAside: [], discarded: [], lines: [] };
747
+ if (!m.moved.length)
748
+ rmSync(dir, { recursive: true, force: true });
749
+ return done;
750
+ };
751
+ try {
752
+ for (const rel of REPO_AGENT_CONFIG) {
753
+ const unit = movableUnit(worktree, rel);
754
+ if (!unit || m.moved.some((u) => unit === u.rel || unit.startsWith(`${u.rel}/`)))
755
+ continue;
756
+ const hash = hashPath(join(worktree, unit));
757
+ const to = join(dir, "files", unit);
758
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
759
+ moveSync(join(worktree, unit), to);
760
+ m.moved.push({ rel: unit, hash });
761
+ writeManifest(dir, m);
762
+ if (m.moved.length === 1) {
763
+ openFences.add(restore);
764
+ trap();
765
+ }
766
+ }
767
+ }
768
+ catch (e) {
769
+ restore();
770
+ throw e;
771
+ }
772
+ if (!m.moved.length)
773
+ rmSync(dir, { recursive: true, force: true });
774
+ return {
775
+ moved: m.moved.map((e) => e.rel),
776
+ wrote: (rel) => {
777
+ const unit = m.moved.find((e) => rel === e.rel || rel.startsWith(`${e.rel}/`));
778
+ if (!unit || unit.rel !== rel)
779
+ return;
780
+ const h = hashPath(join(worktree, rel));
781
+ if (h) {
782
+ m.wrote[rel] = h;
783
+ writeManifest(dir, m);
784
+ }
785
+ },
786
+ restore,
787
+ recovered,
788
+ };
789
+ }
422
790
  //# sourceMappingURL=agents.js.map