create-egregore 0.18.17 → 0.19.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.
@@ -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;