create-egregore 0.18.18 → 0.19.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.5.27
2
+ // egregore-launcher-version: 0.6.0
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -30,7 +30,7 @@ const https = require("node:https");
30
30
  const { spawnSync, spawn } = require("node:child_process");
31
31
  const readline = require("node:readline");
32
32
 
33
- const VERSION = "0.5.27";
33
+ const VERSION = "0.6.0";
34
34
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
35
35
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
36
36
  const PUBLIC_NPX_PACKAGE = "create-egregore";
@@ -424,7 +424,7 @@ function runtimePickerLines(inst, runtimes, stored, cursor) {
424
424
  const labelWidth = Math.max(...runtimes.map((r) => runtimeLabel(r).length));
425
425
  const lines = [
426
426
  "",
427
- ` ${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · ${DIM}1-${runtimes.length}${RESET} quick select · ${DIM}esc${RESET} cancel`,
427
+ ` ${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · ${DIM}1-${runtimes.length}${RESET} quick select · ${DIM}q${RESET} cancel`,
428
428
  ` ${CYAN}◆${RESET} ${BOLD}Launch ${title} with which harness?${RESET}`,
429
429
  "",
430
430
  ];
@@ -494,7 +494,9 @@ function pickRuntimeInteractive(inst, runtimes, stored) {
494
494
  cleanup();
495
495
  process.exit(130);
496
496
  }
497
- if (key.name === "escape" || key.name === "q") return finish(null);
497
+ // Esc never quits (it reads as "back", and there's nowhere back to go
498
+ // from here) — q is the only cancel key besides Ctrl+C.
499
+ if (key.name === "q") return finish(null);
498
500
  if (key.name === "up" || (key.ctrl && key.name === "p") || key.name === "k") {
499
501
  cursor = (cursor - 1 + runtimes.length) % runtimes.length;
500
502
  render();
@@ -832,10 +834,9 @@ function cmdHelp() {
832
834
  ` ${DIM}n / +${RESET} create a new Egregore`,
833
835
  ` ${DIM}u${RESET} set selected as default`,
834
836
  ` ${DIM}i${RESET} invite a GitHub user to selected instance`,
835
- ` ${DIM}r${RESET} remove selected (with confirm)`,
836
- ` ${DIM}p${RESET} prune entries with missing paths (with confirm)`,
837
+ ` ${DIM}d${RESET} delete selected entry (offers cleanup of ${RED}✗${RESET} missing ones)`,
837
838
  ` ${DIM}?${RESET} toggle help overlay`,
838
- ` ${DIM}q / esc${RESET} quit browser`,
839
+ ` ${DIM}q${RESET} quit browser (esc goes back — it never quits)`,
839
840
  ``,
840
841
  ` ${BOLD}REGISTRY${RESET}`,
841
842
  ` ${DIM}instances:${RESET} ~/.egregore/instances.json`,
@@ -1463,7 +1464,7 @@ function renderRuntimePanel({ inst, cursor, width, height }) {
1463
1464
  }
1464
1465
 
1465
1466
  lines.push(emptyBoxLine(innerWidth, margin));
1466
- lines.push(boxLine(`${DIM}↑↓ navigate · enter select · 1-${runtimes.length} quick select · esc back · q quit${RESET}`, innerWidth, margin));
1467
+ lines.push(boxLine(`${DIM}↑↓ navigate · enter select · 1-${runtimes.length} quick select · ← back · q quit${RESET}`, innerWidth, margin));
1467
1468
  lines.push(`${margin}${DIM}└${"─".repeat(innerWidth)}┘${RESET}`);
1468
1469
  return lines;
1469
1470
  }
@@ -1482,6 +1483,15 @@ async function browser(args = {}) {
1482
1483
  return numberedPicker(registry, args.runtimeOverride);
1483
1484
  }
1484
1485
 
1486
+ // Entries whose directory is gone are dead registry rows. They stay listed
1487
+ // (marked ✗ missing) so the problem is visible in place — cleanup is offered
1488
+ // through `d`, never done automatically. Cached per session; refreshed after
1489
+ // every registry mutation instead of stat-ing on each keypress render.
1490
+ let stalePaths = new Set(registry.filter((e) => !fs.existsSync(e.path)).map((e) => e.path));
1491
+ const refreshStale = () => {
1492
+ stalePaths = new Set(registry.filter((e) => !fs.existsSync(e.path)).map((e) => e.path));
1493
+ };
1494
+
1485
1495
  // State
1486
1496
  const state = {
1487
1497
  filter: "",
@@ -1493,7 +1503,7 @@ async function browser(args = {}) {
1493
1503
  runtimeCursor: 0,
1494
1504
  cursor: initialCursorForCwd(registry),
1495
1505
  showHelp: false,
1496
- modal: null, // { type: "remove" | "prune", onYes, onNo }
1506
+ modal: null, // { type: "remove" | "sweep", onYes, onNo }
1497
1507
  tick: 0,
1498
1508
  message: null, // ephemeral info line (e.g. "default set to X")
1499
1509
  messageKind: "info", // "info" | "success" | "warn"
@@ -1585,7 +1595,10 @@ async function browser(args = {}) {
1585
1595
  out.push(ANSI.clearLine + "\n");
1586
1596
 
1587
1597
  // Layout: list takes top 60%, preview takes rest, footer is last 2 lines
1588
- const footerRows = 2;
1598
+ // (3 while stale entries exist — the cleanup notice gets its own row so
1599
+ // the layout doesn't jump when modals or messages come and go).
1600
+ const staleCount = stalePaths.size;
1601
+ const footerRows = staleCount > 0 ? 3 : 2;
1589
1602
  const usedRows = heroLines.length + 1;
1590
1603
  const availableRows = Math.max(8, h - usedRows - footerRows - 2);
1591
1604
  const listRows = Math.max(4, Math.floor(availableRows * 0.48));
@@ -1631,13 +1644,21 @@ async function browser(args = {}) {
1631
1644
  void origIdx;
1632
1645
  const isCursor = idx === state.cursor;
1633
1646
  const isDefault = e.slug === def;
1647
+ const isMissing = stalePaths.has(e.path);
1634
1648
  const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
1635
1649
  const defMark = isDefault ? `${GREEN}●${RESET}` : " ";
1636
1650
  const slug = isCursor
1637
1651
  ? `${BOLD}${CYAN}${pad(e.slug, slugW)}${RESET}`
1638
- : (isDefault ? `${BOLD}${pad(e.slug, slugW)}${RESET}` : pad(e.slug, slugW));
1639
- const name = pad(e.name || "", nameW);
1640
- const pathDim = `${DIM}${truncate(e.path, Math.max(0, listAvail - slugW - nameW - 10))}${RESET}`;
1652
+ : (isDefault
1653
+ ? `${BOLD}${pad(e.slug, slugW)}${RESET}`
1654
+ : (isMissing ? `${DIM}${pad(e.slug, slugW)}${RESET}` : pad(e.slug, slugW)));
1655
+ const name = isMissing && !isCursor
1656
+ ? `${DIM}${pad(e.name || "", nameW)}${RESET}`
1657
+ : pad(e.name || "", nameW);
1658
+ const pathAvail = Math.max(0, listAvail - slugW - nameW - 10);
1659
+ const pathDim = isMissing
1660
+ ? `${RED}✗${RESET} ${DIM}${truncate(e.path, Math.max(8, pathAvail - 12))}${RESET} ${RED}(missing)${RESET}`
1661
+ : `${DIM}${truncate(e.path, pathAvail)}${RESET}`;
1641
1662
  const line = ` ${cursorMark} ${defMark} ${slug} ${name} ${pathDim}`;
1642
1663
  out.push(M + " ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1643
1664
  }
@@ -1661,7 +1682,9 @@ async function browser(args = {}) {
1661
1682
  // value to blockWidth-15 so every line's right edge lands two cols inside
1662
1683
  // the box border, level with the box inner content (gutter+blockWidth-2).
1663
1684
  const valW = Math.max(16, blockWidth - 15);
1664
- previewLines.push(` ${label("path")} ${truncate(selected.path, valW)}`);
1685
+ previewLines.push(stalePaths.has(selected.path)
1686
+ ? ` ${label("path")} ${RED}✗${RESET} ${truncate(selected.path, Math.max(8, valW - 12))} ${RED}(missing)${RESET}`
1687
+ : ` ${label("path")} ${truncate(selected.path, valW)}`);
1665
1688
  if (pv.org) previewLines.push(` ${label("org")} ${truncate(`${pv.org} ${DIM}(${pv.githubOrg || "?"})${RESET}`, valW)}`);
1666
1689
  if (pv.branch) previewLines.push(` ${label("branch")} ${BOLD}${pv.branch}${RESET}`);
1667
1690
  if (pv.lastCommit) previewLines.push(` ${label("last")} ${DIM}${truncate(pv.lastCommit, valW)}${RESET}`);
@@ -1690,32 +1713,54 @@ async function browser(args = {}) {
1690
1713
  // Divider
1691
1714
  out.push(DIM + M + "─".repeat(blockWidth) + RESET + ANSI.clearLine + "\n");
1692
1715
 
1693
- // Footer
1716
+ // Notice row — persistent while dead entries exist, quiet otherwise. The
1717
+ // row is reserved either way (footerRows above) so toggling modals/filter
1718
+ // doesn't shift the layout.
1719
+ if (staleCount > 0) {
1720
+ const noticeIdle = !state.modal && !state.filterMode && !state.inviteMode;
1721
+ const noun = staleCount === 1 ? "entry points to a missing folder" : "entries point to missing folders";
1722
+ out.push(noticeIdle
1723
+ ? `${M} ${YELLOW}!${RESET} ${staleCount} ${noun} — ${BOLD}d${RESET} ${DIM}to clean up${RESET}${ANSI.clearLine}\n`
1724
+ : ANSI.clearLine + "\n");
1725
+ }
1726
+
1727
+ // Footer — a status band in the banner's accent color (the terminal's
1728
+ // cyan slot) anchors the keys; the plain dim strip was easy to miss.
1729
+ // Band color doubles as a mode signal: accent = browsing, yellow = a
1730
+ // question needs answering, green = success flash. Input states
1731
+ // (filter, invite) stay unbanded so the caret keeps its contrast.
1732
+ // Content on the band is fg-black with BOLD/UNBOLD only — a RESET
1733
+ // mid-line would kill the background fill.
1734
+ const UNBOLD = E(22);
1735
+ const band = (content, bg) => colorOn
1736
+ ? `${E(bg)}${E(30)}${pad(` ${content}`, blockWidth)}${RESET}${ANSI.clearLine}`
1737
+ : ` ${stripAnsi(content)}${ANSI.clearLine}`;
1738
+ const key = (k, label) => `${BOLD}${k}${UNBOLD} ${label}`;
1694
1739
  let footer;
1695
1740
  if (state.modal) {
1696
1741
  const m = state.modal;
1697
1742
  const prompt = m.type === "remove"
1698
- ? `remove ${BOLD}${m.inst.slug}${RESET}? ${DIM}[y/N]${RESET}`
1699
- : `prune ${BOLD}${m.count}${RESET} stale entries? ${DIM}[y/N]${RESET}`;
1700
- footer = ` ${YELLOW}?${RESET} ${prompt}${ANSI.clearLine}`;
1743
+ ? `remove ${BOLD}${m.inst.slug}${UNBOLD}${m.missing ? " (folder missing)" : ""}? [y/N]`
1744
+ : `remove ${BOLD}${m.count}${UNBOLD} ${m.count === 1 ? "entry" : "entries"} with missing folders? [y/N]`;
1745
+ footer = band(`? ${prompt}`, 43);
1701
1746
  } else if (state.filterMode) {
1702
1747
  footer = ` ${DIM}typing filter · enter/esc to exit filter mode${RESET}${ANSI.clearLine}`;
1703
1748
  } else if (state.inviteMode) {
1704
1749
  const target = state.inviteInst?.slug || "selected";
1705
1750
  footer = ` ${CYAN}/invite${RESET} ${DIM}${target}:${RESET} ${state.inviteUsername}${CYAN}▌${RESET} ${DIM}enter send · esc cancel${RESET}${ANSI.clearLine}`;
1706
1751
  } else if (state.showHelp) {
1707
- footer = ` ${DIM}? toggles this hint · q quits${RESET}${ANSI.clearLine}`;
1752
+ footer = band(`${key("?", "toggles this hint")} ${key("q", "quits")}`, 46);
1708
1753
  } else if (state.message && Date.now() < state.messageUntil) {
1709
- const kindColor = state.messageKind === "success" ? GREEN : state.messageKind === "warn" ? YELLOW : CYAN;
1710
- footer = ` ${kindColor}●${RESET} ${state.message}${ANSI.clearLine}`;
1754
+ const bg = state.messageKind === "success" ? 42 : state.messageKind === "warn" ? 43 : 46;
1755
+ footer = band(`● ${stripAnsi(state.message)}`, bg);
1711
1756
  } else {
1712
1757
  footer = blockWidth < 96
1713
- ? ` ${DIM}↑↓${RESET} nav ${DIM}enter${RESET} select ${DIM}n/+${RESET} new ${DIM}i${RESET} invite ${DIM}?${RESET} help ${DIM}q${RESET} quit${ANSI.clearLine}`
1714
- : ` ${DIM}↑↓${RESET} nav ${DIM}enter${RESET} select ${DIM}n/+${RESET} new ${DIM}/${RESET} filter ${DIM}i${RESET} invite ${DIM}u${RESET} default ${DIM}r${RESET} rm ${DIM}p${RESET} prune ${DIM}?${RESET} help ${DIM}q${RESET} quit${ANSI.clearLine}`;
1758
+ ? band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("i", "invite")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46)
1759
+ : band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("/", "filter")} ${key("i", "invite")} ${key("u", "default")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46);
1715
1760
  }
1716
1761
  out.push(M + footer + "\n");
1717
1762
  if (state.showHelp) {
1718
- out.push(`${M} ${DIM}● default · ❯ cursor · n/+ starts guided setup · i opens /invite${RESET}${ANSI.clearLine}`);
1763
+ out.push(M + band("● default · ❯ cursor · ✗ folder missing · d deletes an entry · esc never quits", 46));
1719
1764
  } else {
1720
1765
  out.push(ANSI.clearLine);
1721
1766
  }
@@ -1788,7 +1833,9 @@ async function browser(args = {}) {
1788
1833
  if (state.runtimeInst) {
1789
1834
  const runtimes = runtimeChoicesFor(state.runtimeInst, args.runtimeOverride);
1790
1835
  if (key.ctrl && key.name === "c") { cleanup(); process.exit(130); }
1791
- if (key.name === "escape") {
1836
+ // ← is the advertised back key (enter descends, ← ascends); esc is a
1837
+ // silent alias for the reflex — it goes back, never quits.
1838
+ if (key.name === "escape" || key.name === "left") {
1792
1839
  state.runtimeInst = null;
1793
1840
  state.runtimeCursor = 0;
1794
1841
  render();
@@ -1827,16 +1874,23 @@ async function browser(args = {}) {
1827
1874
  return;
1828
1875
  }
1829
1876
 
1830
- // Modal handling takes priority
1877
+ // Modal handling takes priority. The modal is cleared BEFORE its
1878
+ // callback runs so a callback can chain a follow-up modal (the delete →
1879
+ // sweep offer). Esc is a full cancel: it closes the modal and skips the
1880
+ // chain — n/enter decline this modal but still allow the follow-up.
1831
1881
  if (state.modal) {
1882
+ const m = state.modal;
1832
1883
  if ((chunk && /^[yY]/.test(chunk))) {
1833
- state.modal.onYes();
1834
1884
  state.modal = null;
1885
+ m.onYes();
1835
1886
  render();
1836
- } else if (key.name === "escape" || (chunk && /^[nN]/.test(chunk)) || key.name === "return") {
1837
- state.modal.onNo?.();
1887
+ } else if (key.name === "escape") {
1838
1888
  state.modal = null;
1839
1889
  render();
1890
+ } else if ((chunk && /^[nN]/.test(chunk)) || key.name === "return") {
1891
+ state.modal = null;
1892
+ m.onNo?.();
1893
+ render();
1840
1894
  }
1841
1895
  return;
1842
1896
  }
@@ -1908,13 +1962,20 @@ async function browser(args = {}) {
1908
1962
 
1909
1963
  // Normal mode
1910
1964
  if (key.ctrl && key.name === "c") { cleanup(); process.exit(130); }
1911
- if (key.name === "q" || key.name === "escape") {
1965
+ // Esc never quits: at the root it only clears an active filter. Users
1966
+ // press esc expecting either "back" or "abort" — both are safe when it
1967
+ // can't eject them to the shell. A flash points at the real quit key.
1968
+ if (key.name === "escape") {
1912
1969
  if (state.filter) {
1913
1970
  state.filter = "";
1914
1971
  clampCursor();
1915
1972
  render();
1916
1973
  return;
1917
1974
  }
1975
+ flashMessage(`${BOLD}q${RESET} quits — esc never does`, "info");
1976
+ return;
1977
+ }
1978
+ if (key.name === "q") {
1918
1979
  cleanup();
1919
1980
  resolve();
1920
1981
  return;
@@ -2000,40 +2061,49 @@ async function browser(args = {}) {
2000
2061
  flashMessage(`default set to ${BOLD}${pick.slug}${RESET}`, "success");
2001
2062
  return;
2002
2063
  }
2003
- if (chunk === "r") {
2064
+ // d = the one delete verb (r kept as an unadvertised alias for muscle
2065
+ // memory). Deleting confirms first; when other entries with missing
2066
+ // folders remain, a sweep offer chains after — whether the first modal
2067
+ // was accepted or declined, so pressing d anywhere still reaches the
2068
+ // cleanup the notice line promises. Esc cancels without chaining.
2069
+ if (chunk === "d" || chunk === "r") {
2004
2070
  const pick = visible()[state.cursor]?.e;
2005
2071
  if (!pick) return;
2072
+ const offerSweep = () => {
2073
+ const stale = registry.filter((e) => !fs.existsSync(e.path));
2074
+ if (stale.length === 0) return;
2075
+ state.modal = {
2076
+ type: "sweep",
2077
+ count: stale.length,
2078
+ onYes: () => {
2079
+ const bak = backupRegistry();
2080
+ registry = registry.filter((e) => fs.existsSync(e.path));
2081
+ writeRegistry(registry);
2082
+ refreshStale();
2083
+ clampCursor();
2084
+ flashMessage(`removed ${stale.length} · ${registry.length} remain · backup: ${path.basename(bak)}`, "success");
2085
+ },
2086
+ };
2087
+ };
2088
+ const pickMissing = !fs.existsSync(pick.path);
2006
2089
  state.modal = {
2007
2090
  type: "remove",
2008
2091
  inst: pick,
2092
+ missing: pickMissing,
2009
2093
  onYes: () => {
2010
2094
  const bak = backupRegistry();
2011
2095
  registry = registry.filter((e) => e.path !== pick.path);
2012
2096
  writeRegistry(registry);
2013
2097
  previewCache.delete(pick.path);
2098
+ refreshStale();
2014
2099
  clampCursor();
2015
2100
  flashMessage(`removed ${BOLD}${pick.slug}${RESET} · backup: ${path.basename(bak)}`, "success");
2101
+ offerSweep();
2016
2102
  },
2017
- };
2018
- render();
2019
- return;
2020
- }
2021
- if (chunk === "p") {
2022
- const stale = registry.filter((e) => !fs.existsSync(e.path));
2023
- if (stale.length === 0) {
2024
- flashMessage(`registry is clean — all ${registry.length} paths exist.`, "success");
2025
- return;
2026
- }
2027
- state.modal = {
2028
- type: "prune",
2029
- count: stale.length,
2030
- onYes: () => {
2031
- const bak = backupRegistry();
2032
- registry = registry.filter((e) => fs.existsSync(e.path));
2033
- writeRegistry(registry);
2034
- clampCursor();
2035
- flashMessage(`pruned ${stale.length} · ${registry.length} remain · backup: ${path.basename(bak)}`, "success");
2036
- },
2103
+ // Declining a healthy row still routes to cleanup (the notice's d
2104
+ // lands here); declining a missing row means "leave it" — don't
2105
+ // immediately re-offer the same removal.
2106
+ onNo: () => { if (!pickMissing) offerSweep(); },
2037
2107
  };
2038
2108
  render();
2039
2109
  return;
package/lib/local.js CHANGED
@@ -54,6 +54,16 @@ async function listOrgs(token) {
54
54
  return data.map((o) => ({ login: o.login }));
55
55
  }
56
56
 
57
+ // Can the token see this org at all? /user/orgs is blind to fine-grained
58
+ // tokens and to private/SSO-gated membership, so the picker lets users type an
59
+ // org by hand — this validates the typed login is reachable (a fine-grained
60
+ // PAT scoped to the org returns 200 here) so a typo fails now, not at
61
+ // repo-creation time.
62
+ async function orgReachable(token, org) {
63
+ const { status } = await ghApi("GET", `/orgs/${org}`, token);
64
+ return status === 200;
65
+ }
66
+
57
67
  async function listOrgMembers(token, org) {
58
68
  // Returns [{login}]. Only visible members (public or visible to the token).
59
69
  // Non-fatal on error — caller falls back to a text prompt.
@@ -94,6 +104,18 @@ async function createRepo(token, owner, name, isOrg, description) {
94
104
  description: description || "Egregore shared memory",
95
105
  });
96
106
  if (status === 422) return { already_exists: true };
107
+ // 403 here almost always means the token can't write to THIS owner — the
108
+ // classic fine-grained-PAT case, where the token is scoped to one org and
109
+ // we're pointed at the personal account (or the wrong org). Turn GitHub's
110
+ // opaque "Resource not accessible by personal access token" into a next step.
111
+ if (status === 403) {
112
+ const where = isOrg ? `the ${owner} organization` : `your personal account (${owner})`;
113
+ throw new Error(
114
+ `GitHub refused repo creation in ${where} (403). ` +
115
+ `A fine-grained token is scoped to a single owner — re-run and choose that owner ` +
116
+ `as the home, or re-auth with \`gh auth login\` (unset GH_TOKEN first if it's set).`,
117
+ );
118
+ }
97
119
  if (status !== 201 && status !== 200) {
98
120
  const msg = typeof data === "object" ? data.message || JSON.stringify(data) : data;
99
121
  throw new Error(`Create repo failed (HTTP ${status}): ${msg}`);
@@ -539,14 +561,14 @@ async function maybeSetupTelegramGroup(ui, {
539
561
  ui.info(`Bot add link: ${ui.cyan(botLink)}`);
540
562
  openBrowser(botLink);
541
563
 
542
- await ui.prompt("Press Enter here after the bot is in the group");
564
+ await ui.prompt("Press Enter here after the bot is in the group", { hint: "enter continue · ctrl-c quit" });
543
565
  ui.note([
544
566
  "Step 4: In Telegram, copy a group invite link.",
545
567
  "Paste it below so teammate invites include the Telegram group too.",
546
568
  "If you skip this, Egregore will still work, but invites will not include Telegram.",
547
569
  ]);
548
570
 
549
- const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)");
571
+ const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)", { hint: "paste the link · enter skip" });
550
572
  const cleaned = groupLink.trim();
551
573
  let storedGroupLink = "";
552
574
  if (cleaned && !looksLikeUrl(cleaned)) {
@@ -677,6 +699,26 @@ async function localFounderFlow(ui, ctx = {}) {
677
699
  reposCache: null, // {org, repos, teamRepoCount}
678
700
  };
679
701
 
702
+ const ENTER_ORG_LABEL = "Enter an organization name…";
703
+
704
+ const promptForOrg = async () => {
705
+ // Free-text org entry for the cases /user/orgs can't see (fine-grained
706
+ // tokens, private/SSO-gated membership). Validate reachability here so a
707
+ // typo or an unauthorized org fails now, with a clear message, instead of
708
+ // as an opaque 403 mid-creation.
709
+ while (true) {
710
+ const raw = await ui.prompt("Organization login (exact, e.g. acme-inc — blank to go back)");
711
+ const org = (raw || "").trim();
712
+ if (!org) return null;
713
+ const s = ui.spinner(`Checking ${org}…`);
714
+ if (await orgReachable(githubToken, org)) {
715
+ s.stop(`Found ${org}`);
716
+ return org;
717
+ }
718
+ s.fail(`Can't reach an org named "${org}" with this login — check the spelling, or authorize your token for it on GitHub.`);
719
+ }
720
+ };
721
+
680
722
  const stepHome = async () => {
681
723
  if (!st.orgs) {
682
724
  ui.section("Pick a home", "where should this Egregore live?");
@@ -684,24 +726,27 @@ async function localFounderFlow(ui, ctx = {}) {
684
726
  st.orgs = await listOrgs(githubToken);
685
727
  s.stop("Accounts loaded");
686
728
  }
687
- if (st.orgs.length === 0) {
688
- if (!st.githubOrg) {
689
- st.githubOrg = user.login;
690
- st.isOrg = false;
691
- ui.info(`Using your personal account: ${ui.bold(st.githubOrg)}`);
692
- }
693
- return SKIP;
694
- }
695
- const orgChoice = await ui.choose(
696
- "Where should Egregore live?",
697
- [
698
- { label: user.login, description: "Personal account" },
699
- ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
700
- ],
701
- "Don't see your org? Authorize it for your account on GitHub, then re-run.",
702
- { escAction: "back" },
703
- );
729
+ // Always offer manual org entry. /user/orgs returns nothing for
730
+ // fine-grained tokens and for orgs with private/SSO-gated membership, so
731
+ // auto-discovery alone silently strands those users on their personal
732
+ // account with no way to reach the org they actually want.
733
+ const options = [
734
+ { label: user.login, description: "Personal account" },
735
+ ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
736
+ { label: ENTER_ORG_LABEL, description: "Type an org login (needed for fine-grained tokens)" },
737
+ ];
738
+ const hint = st.orgs.length === 0
739
+ ? "Couldn't auto-detect your orgs (normal with fine-grained tokens) — pick your account or enter an org."
740
+ : "Don't see your org? Enter it below, or authorize your token for it on GitHub.";
741
+ const orgChoice = await ui.choose("Where should Egregore live?", options, hint, { escAction: "back" });
704
742
  if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
743
+ if (orgChoice.label === ENTER_ORG_LABEL) {
744
+ const org = await promptForOrg();
745
+ if (!org) return stepHome(); // blank entry → re-show the picker (orgs cached)
746
+ st.githubOrg = org;
747
+ st.isOrg = true;
748
+ return true;
749
+ }
705
750
  st.githubOrg = orgChoice.label;
706
751
  st.isOrg = st.githubOrg !== user.login;
707
752
  return true;
@@ -725,52 +770,92 @@ async function localFounderFlow(ui, ctx = {}) {
725
770
  return true;
726
771
  };
727
772
 
728
- const stepNameProject = async () => {
729
- ui.section("Name it", "what's this Egregore called?");
730
- if (st.isNewProject) {
731
- const name = await ui.promptOrBack("name your Egregore (team or group name)", { default: st.orgName || st.githubOrg });
732
- if (name === BACK) return BACK;
733
- st.orgName = name;
734
- const desc = await ui.promptOrBack("what are you building? (one line)", { default: st.description || undefined });
735
- if (desc === BACK) return BACK;
736
- st.description = desc;
737
-
738
- // Validate the optional project repo name against the names the
739
- // Egregore/memory repos WILL get (editable later, derived for now).
740
- const predictedRepoName = slugifyName(st.orgName) || "egregore";
741
- const predictedMemoryRepoName = `${predictedRepoName}-memory`;
742
- ui.note([
743
- "Your Egregore gets its own repo (memory + config) in your account.",
744
- "Have a code project too? Egregore can add a repo for it in your account.",
745
- ]);
746
- for (;;) {
747
- const projectName = await ui.promptOrBack("code project repo name (Enter to skip)", { default: st.newProjectRepo || undefined });
748
- if (projectName === BACK) return BACK;
749
- if (!projectName) { st.newProjectRepo = null; break; }
750
- const slug = slugifyName(projectName);
751
- if (!slug) {
752
- ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
753
- continue;
754
- }
755
- if (slug === predictedRepoName) {
756
- ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
757
- ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
758
- continue;
759
- }
760
- if (slug === predictedMemoryRepoName) {
761
- ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
762
- continue;
763
- }
764
- st.newProjectRepo = slug;
765
- break;
766
- }
767
- } else {
768
- const name = await ui.promptOrBack("what should we call this Egregore?", { default: st.orgName || st.githubOrg });
769
- if (name === BACK) return BACK;
770
- st.orgName = name;
773
+ const stepNameManifest = async () => {
774
+ // One live manifest instead of scattered naming prompts: name the thing
775
+ // first, watch the repo plan derive from it in place, override any line.
776
+ // Every collision check runs against names VISIBLE on screen — the old
777
+ // flow rejected a code-repo name for colliding with an Egregore repo
778
+ // name the user had never been shown.
779
+ console.log("");
780
+ const isNew = st.isNewProject;
781
+ const validSlug = (v) => (slugifyName(v)
782
+ ? null
783
+ : "That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
784
+ const fields = [
785
+ {
786
+ key: "orgName",
787
+ label: "Egregore",
788
+ note: "team or group name",
789
+ hint: "type your preferred Egregore name or hit enter to pick the naming below",
790
+ initial: st.orgName || undefined,
791
+ derive: () => st.githubOrg,
792
+ },
793
+ {
794
+ key: "repoName",
795
+ label: "Egregore repo",
796
+ note: "config + skills",
797
+ hint: `enter keeps this name · type to change · created in ${st.githubOrg}`,
798
+ tree: true,
799
+ initial: st.repoName || undefined,
800
+ derive: (v) => slugifyName(v.orgName) || "egregore",
801
+ normalize: (raw) => slugifyName(raw),
802
+ validate: (v) => validSlug(v),
803
+ },
804
+ {
805
+ key: "memoryRepoName",
806
+ label: "memory repo",
807
+ note: "shared memory",
808
+ hint: "enter keeps this name · type to change",
809
+ tree: true,
810
+ initial: st.memoryRepoName || undefined,
811
+ derive: (v) => `${v.repoName || "egregore"}-memory`,
812
+ normalize: (raw) => slugifyName(raw),
813
+ validate: (v, others) => validSlug(v)
814
+ || (v === others.repoName ? "Memory repo can't share the Egregore repo's name." : null),
815
+ },
816
+ ...(isNew ? [{
817
+ key: "codeRepo",
818
+ label: "code repo",
819
+ note: "your project · optional",
820
+ hint: "add a repo for your code project — or enter to skip",
821
+ tree: true,
822
+ optional: true,
823
+ initial: st.newProjectRepo || undefined,
824
+ normalize: (raw) => slugifyName(raw),
825
+ validate: (v, others) => validSlug(v)
826
+ || (v === others.repoName ? `"${v}" is the Egregore repo above. Pick a different name — say, ${v}-app.` : null)
827
+ || (v === others.memoryRepoName ? `"${v}" is the memory repo above. Pick a different name.` : null),
828
+ }] : []),
829
+ ];
830
+ const result = await ui.manifestForm({
831
+ title: "Name it",
832
+ subtitle: `repos land in ${st.githubOrg}`,
833
+ fields,
834
+ });
835
+ if (result === BACK) return BACK;
836
+ const v = result.values;
837
+ st.orgName = v.orgName;
838
+ st.repoName = v.repoName;
839
+ st.memoryRepoName = v.memoryRepoName;
840
+ st.newProjectRepo = isNew ? (v.codeRepo || null) : null;
841
+ return true;
842
+ };
843
+
844
+ // The one-liner is repo metadata, not a naming decision — it lives OUTSIDE
845
+ // the manifest so enter on the name lands directly in the naming tree
846
+ // (the manifest's hint promises exactly that).
847
+ const stepDescription = async () => {
848
+ if (!st.isNewProject) {
771
849
  st.description = "";
772
- st.newProjectRepo = null;
850
+ return SKIP;
773
851
  }
852
+ console.log("");
853
+ const desc = await ui.promptOrBack("what are you building? (one line)", {
854
+ default: st.description || undefined,
855
+ hint: "one line on what you're making · enter to skip · ↑ back",
856
+ });
857
+ if (desc === BACK) return BACK;
858
+ st.description = desc;
774
859
  return true;
775
860
  };
776
861
 
@@ -891,41 +976,12 @@ async function localFounderFlow(ui, ctx = {}) {
891
976
  return true;
892
977
  };
893
978
 
894
- const stepRepoNames = async () => {
895
- // Derived names shown as editable prompts — creating repos with silently
896
- // derived names was a repeated field complaint.
897
- const derivedSlug = slugifyName(st.orgName) || "egregore";
898
- console.log("");
899
- ui.note([
900
- `Two repos will be created in ${ui.bold(st.githubOrg)}: the Egregore repo`,
901
- "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
902
- ]);
903
- for (;;) {
904
- const answer = await ui.promptOrBack("Egregore repo name", { default: st.repoName || derivedSlug });
905
- if (answer === BACK) return BACK;
906
- const candidate = slugifyName(answer);
907
- if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
908
- if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
909
- st.repoName = candidate;
910
- break;
911
- }
912
- for (;;) {
913
- const answer = await ui.promptOrBack("memory repo name", { default: st.memoryRepoName || `${st.repoName}-memory` });
914
- if (answer === BACK) return BACK;
915
- const candidate = slugifyName(answer);
916
- if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
917
- if (candidate === st.repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
918
- if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
919
- st.memoryRepoName = candidate;
920
- break;
921
- }
922
- return true;
923
- };
924
-
925
979
  // Direction-aware driver: BACK rewinds one step; SKIP (deterministic
926
980
  // steps) passes through in whichever direction we're moving, so backing
927
981
  // over an auto-answered step doesn't bounce the user forward again.
928
- const steps = [stepHome, stepWorkType, stepNameProject, stepRepos, stepRepoNames];
982
+ // ALL naming (Egregore + repo plan) happens in the manifest BEFORE the
983
+ // existing-project repo picker — name the thing first, then attach repos.
984
+ const steps = [stepHome, stepWorkType, stepNameManifest, stepDescription, stepRepos];
929
985
  let stepIdx = 0;
930
986
  let stepDir = 1;
931
987
  while (stepIdx < steps.length) {
@@ -1146,7 +1202,10 @@ async function localFounderFlow(ui, ctx = {}) {
1146
1202
 
1147
1203
  // 14. "What should we call you?" + write state
1148
1204
  ui.section("Your name", "this is how teammates will see you in handoffs");
1149
- const displayName = await ui.prompt("what should we call you?", { default: user.login });
1205
+ const displayName = await ui.prompt("what should we call you?", {
1206
+ default: user.login,
1207
+ hint: `type a display name · enter keeps ${user.login}`,
1208
+ });
1150
1209
  const memberRole = await ensureInstallerMemberRole(
1151
1210
  { usage_type: "founder_group", member_role: ctx.memberRole },
1152
1211
  {},