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.
- package/assets/egregore-launcher.js +121 -51
- package/lib/local.js +155 -96
- package/lib/ui.js +381 -33
- package/package.json +2 -2
- package/runtime/codex/.codex/spec-manifest.json +7 -1
- package/runtime/codex/AGENTS.md +7 -1
- package/runtime/codex/bin/codex-render-spec.mjs +7 -0
- package/runtime/codex/bin/lib/git-sync.sh +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// egregore-launcher-version: 0.
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 ·
|
|
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" | "
|
|
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
|
-
|
|
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
|
|
1639
|
-
|
|
1640
|
-
|
|
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(
|
|
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
|
-
//
|
|
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}${
|
|
1699
|
-
: `
|
|
1700
|
-
footer =
|
|
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 =
|
|
1752
|
+
footer = band(`${key("?", "toggles this hint")} ${key("q", "quits")}`, 46);
|
|
1708
1753
|
} else if (state.message && Date.now() < state.messageUntil) {
|
|
1709
|
-
const
|
|
1710
|
-
footer =
|
|
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
|
-
?
|
|
1714
|
-
:
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2019
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
"
|
|
744
|
-
"
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?", {
|
|
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
|
{},
|