create-egregore 0.18.8 → 0.18.10

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.23
2
+ // egregore-launcher-version: 0.5.24
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.23";
33
+ const VERSION = "0.5.24";
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";
@@ -1429,7 +1429,9 @@ function renderRuntimePanel({ inst, cursor, width, height }) {
1429
1429
  const runtimes = runtimeChoicesFor(inst);
1430
1430
  const stored = storedRuntimeFor(inst);
1431
1431
  const title = inst.slug || inst.name || path.basename(inst.path);
1432
- const outerWidth = Math.min(Math.max(50, width - 2), 112);
1432
+ // Same cap/margin as renderHeroPanel so the centered box keeps its position
1433
+ // when you move from the instance list to the assistant picker.
1434
+ const outerWidth = Math.min(Math.max(50, width - 2), 126);
1433
1435
  const innerWidth = Math.max(48, outerWidth - 2);
1434
1436
  const margin = " ".repeat(Math.max(0, Math.floor((width - outerWidth) / 2)));
1435
1437
  const label = " assistant ";
@@ -1521,9 +1523,17 @@ async function browser(args = {}) {
1521
1523
 
1522
1524
  // ── render ────────────────────────────
1523
1525
  const render = () => {
1526
+ if (done) return; // the parent stays alive under the spawned assistant — never redraw after teardown
1524
1527
  const w = process.stdout.columns || 80;
1525
1528
  const h = process.stdout.rows || 24;
1526
1529
 
1530
+ // One centered block: the hero/runtime box, the list, dividers, preview and
1531
+ // footer all share this gutter and width so the whole UI centers together
1532
+ // (matches renderHeroPanel's outerWidth/margin math).
1533
+ const blockWidth = Math.min(Math.max(50, w - 2), 126);
1534
+ const gutter = Math.max(0, Math.floor((w - blockWidth) / 2));
1535
+ const M = " ".repeat(gutter);
1536
+
1527
1537
  const out = [];
1528
1538
  // Home AND clear-to-end-of-screen each frame. The per-line ANSI.clearLine
1529
1539
  // calls below are belt-and-suspenders; some terminals (notably Warp) don't
@@ -1575,9 +1585,9 @@ async function browser(args = {}) {
1575
1585
  const listRows = Math.max(4, Math.floor(availableRows * 0.48));
1576
1586
  const previewRows = Math.max(3, availableRows - listRows - 2); // -2 for dividers
1577
1587
 
1578
- // List
1588
+ // List — kept within the centered block so long paths don't spill past it.
1579
1589
  const listPad = 4;
1580
- const listAvail = w - listPad * 2;
1590
+ const listAvail = blockWidth - 4;
1581
1591
  const slugW = Math.min(22, Math.max(6, ...registry.map((r) => (r.slug || "").length)));
1582
1592
  const nameW = Math.min(24, Math.max(6, ...registry.map((r) => (r.name || "").length)));
1583
1593
 
@@ -1594,7 +1604,7 @@ async function browser(args = {}) {
1594
1604
  if (idx >= v.length) {
1595
1605
  if (v.length === 0 && i === Math.floor(listRows / 2)) {
1596
1606
  const msg = state.filter ? "(no matches)" : "(empty)";
1597
- out.push(" ".repeat(listPad) + DIM + msg + RESET + ANSI.clearLine + "\n");
1607
+ out.push(M + " ".repeat(listPad) + DIM + msg + RESET + ANSI.clearLine + "\n");
1598
1608
  } else {
1599
1609
  out.push(ANSI.clearLine + "\n");
1600
1610
  }
@@ -1608,7 +1618,7 @@ async function browser(args = {}) {
1608
1618
  const label = isCursor ? `${BOLD}${item.label}${RESET}` : item.label;
1609
1619
  const desc = `${DIM}${item.description}${RESET}`;
1610
1620
  const line = ` ${cursorMark} ${pad(plus, slugW)} ${pad(label, nameW)} ${truncate(desc, Math.max(0, listAvail - slugW - nameW - 10))}`;
1611
- out.push(" ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1621
+ out.push(M + " ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1612
1622
  continue;
1613
1623
  }
1614
1624
  const { e, origIdx } = item;
@@ -1623,11 +1633,11 @@ async function browser(args = {}) {
1623
1633
  const name = pad(e.name || "", nameW);
1624
1634
  const pathDim = `${DIM}${truncate(e.path, Math.max(0, listAvail - slugW - nameW - 10))}${RESET}`;
1625
1635
  const line = ` ${cursorMark} ${defMark} ${slug} ${name} ${pathDim}`;
1626
- out.push(" ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1636
+ out.push(M + " ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1627
1637
  }
1628
1638
 
1629
1639
  // Divider
1630
- out.push(DIM + " " + "─".repeat(w - 4) + RESET + ANSI.clearLine + "\n");
1640
+ out.push(DIM + M + "─".repeat(blockWidth) + RESET + ANSI.clearLine + "\n");
1631
1641
 
1632
1642
  // Preview
1633
1643
  const previewLines = [];
@@ -1641,15 +1651,20 @@ async function browser(args = {}) {
1641
1651
  previewLines.push(`${DIM} (no selection)${RESET}`);
1642
1652
  } else {
1643
1653
  const label = (k) => `${DIM}${pad(k, 10)}${RESET}`;
1644
- previewLines.push(` ${label("path")} ${selected.path}`);
1645
- if (pv.org) previewLines.push(` ${label("org")} ${pv.org} ${DIM}(${pv.githubOrg || "?"})${RESET}`);
1654
+ // A preview line is " " + label(10) + " " + value = 13 + value; bound the
1655
+ // value to blockWidth-15 so every line's right edge lands two cols inside
1656
+ // the box border, level with the box inner content (gutter+blockWidth-2).
1657
+ const valW = Math.max(16, blockWidth - 15);
1658
+ previewLines.push(` ${label("path")} ${truncate(selected.path, valW)}`);
1659
+ if (pv.org) previewLines.push(` ${label("org")} ${truncate(`${pv.org} ${DIM}(${pv.githubOrg || "?"})${RESET}`, valW)}`);
1646
1660
  if (pv.branch) previewLines.push(` ${label("branch")} ${BOLD}${pv.branch}${RESET}`);
1647
- if (pv.lastCommit) previewLines.push(` ${label("last")} ${DIM}${truncate(pv.lastCommit, w - 18)}${RESET}`);
1661
+ if (pv.lastCommit) previewLines.push(` ${label("last")} ${DIM}${truncate(pv.lastCommit, valW)}${RESET}`);
1648
1662
  if (pv.repos && pv.repos.length) {
1649
- previewLines.push(` ${label("repos")} ${pv.repos.slice(0, 4).join(", ")}${pv.repos.length > 4 ? DIM + ` (+${pv.repos.length - 4} more)` + RESET : ""}`);
1663
+ const reposText = `${pv.repos.slice(0, 4).join(", ")}${pv.repos.length > 4 ? ` (+${pv.repos.length - 4} more)` : ""}`;
1664
+ previewLines.push(` ${label("repos")} ${truncate(reposText, valW)}`);
1650
1665
  }
1651
1666
  if (pv.skills) {
1652
- const skillLine = selectedWorkflowLine(pv, Math.max(16, w - 20));
1667
+ const skillLine = selectedWorkflowLine(pv, Math.max(16, blockWidth - 20));
1653
1668
  previewLines.push(` ${label("invoke")} ${skillLine}`);
1654
1669
  previewLines.push(` ${label("skills")} Codex ${BOLD}${pv.skills.native.length}${RESET} native, ${BOLD}${pv.skills.adapterCount}${RESET} adapters; Claude ${BOLD}${pv.skills.claudeCount || 0}${RESET}`);
1655
1670
  }
@@ -1663,11 +1678,11 @@ async function browser(args = {}) {
1663
1678
  }
1664
1679
  for (let i = 0; i < previewRows; i++) {
1665
1680
  const line = previewLines[i] || "";
1666
- out.push(line + ANSI.clearLine + "\n");
1681
+ out.push((line ? M + line : "") + ANSI.clearLine + "\n");
1667
1682
  }
1668
1683
 
1669
1684
  // Divider
1670
- out.push(DIM + " " + "─".repeat(w - 4) + RESET + ANSI.clearLine + "\n");
1685
+ out.push(DIM + M + "─".repeat(blockWidth) + RESET + ANSI.clearLine + "\n");
1671
1686
 
1672
1687
  // Footer
1673
1688
  let footer;
@@ -1688,13 +1703,13 @@ async function browser(args = {}) {
1688
1703
  const kindColor = state.messageKind === "success" ? GREEN : state.messageKind === "warn" ? YELLOW : CYAN;
1689
1704
  footer = ` ${kindColor}●${RESET} ${state.message}${ANSI.clearLine}`;
1690
1705
  } else {
1691
- footer = w < 96
1706
+ footer = blockWidth < 96
1692
1707
  ? ` ${DIM}↑↓${RESET} nav ${DIM}enter${RESET} select ${DIM}n/+${RESET} new ${DIM}i${RESET} invite ${DIM}?${RESET} help ${DIM}q${RESET} quit${ANSI.clearLine}`
1693
1708
  : ` ${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}`;
1694
1709
  }
1695
- out.push(footer + "\n");
1710
+ out.push(M + footer + "\n");
1696
1711
  if (state.showHelp) {
1697
- out.push(` ${DIM}● default · ❯ cursor · n/+ starts guided setup · i opens /invite${RESET}${ANSI.clearLine}`);
1712
+ out.push(`${M} ${DIM}● default · ❯ cursor · n/+ starts guided setup · i opens /invite${RESET}${ANSI.clearLine}`);
1698
1713
  } else {
1699
1714
  out.push(ANSI.clearLine);
1700
1715
  }
@@ -1706,12 +1721,30 @@ async function browser(args = {}) {
1706
1721
  process.stdout.write(ANSI.altScreenEnter + ANSI.mouseOff + ANSI.cursorHide + ANSI.clear + ANSI.home);
1707
1722
  const isMouseEvent = makeMouseFilter();
1708
1723
  let done = false;
1724
+ // Named so cleanup() can detach them. The launcher is NOT a true exec — it
1725
+ // stays alive as the parent of the spawned assistant (claude/codex) for the
1726
+ // whole session. The load-bearing detach is the RESIZE listener: Ghostty (and
1727
+ // other terminals) re-send SIGWINCH when a tab regains focus, and a stale
1728
+ // "resize"→render() repaints the launcher frame on top of the running
1729
+ // assistant — exactly the "I switched tabs and the launcher screen came back"
1730
+ // bug. The SIGINT/SIGTERM/exit handlers are dropped too because they're now
1731
+ // pointless: during the live session the child holds the tty in raw mode, so
1732
+ // Ctrl-C reaches it as a 0x03 byte (the parent never sees a SIGINT); and an
1733
+ // out-of-band kill -INT/-TERM exits the parent 130/143 by Node's default
1734
+ // disposition anyway — identical to the explicit handlers we remove.
1735
+ const onResize = () => render(); // render() itself no-ops once done is set
1736
+ let onSigint;
1737
+ let onSigterm;
1709
1738
  const cleanup = () => {
1710
1739
  if (done) return;
1711
1740
  done = true;
1712
1741
  process.stdin.setRawMode(false);
1713
1742
  process.stdin.pause();
1714
1743
  process.stdin.removeAllListeners("keypress");
1744
+ process.stdout.removeListener("resize", onResize);
1745
+ if (onSigint) process.removeListener("SIGINT", onSigint);
1746
+ if (onSigterm) process.removeListener("SIGTERM", onSigterm);
1747
+ process.removeListener("exit", cleanup);
1715
1748
  // Clear the alt buffer BEFORE leaving it. A well-behaved terminal discards
1716
1749
  // the alt buffer and restores the primary screen on \x1b[?1049l, so this is
1717
1750
  // a harmless no-op there. Warp, however, dumps the alt buffer's contents
@@ -1720,10 +1753,12 @@ async function browser(args = {}) {
1720
1753
  // guided "+ new" setup) starts on a clean screen.
1721
1754
  process.stdout.write(ANSI.mouseOff + ANSI.cursorShow + ANSI.clear + ANSI.home + ANSI.altScreenLeave);
1722
1755
  };
1756
+ onSigint = () => { cleanup(); process.exit(130); };
1757
+ onSigterm = () => { cleanup(); process.exit(143); };
1723
1758
  process.on("exit", cleanup);
1724
- process.on("SIGINT", () => { cleanup(); process.exit(130); });
1725
- process.on("SIGTERM", () => { cleanup(); process.exit(143); });
1726
- process.stdout.on("resize", () => render());
1759
+ process.on("SIGINT", onSigint);
1760
+ process.on("SIGTERM", onSigterm);
1761
+ process.stdout.on("resize", onResize);
1727
1762
 
1728
1763
  readline.emitKeypressEvents(process.stdin);
1729
1764
  process.stdin.setRawMode(true);
package/lib/local.js CHANGED
@@ -125,6 +125,62 @@ async function getFileContent(token, owner, repo, filePath) {
125
125
  return Buffer.from(data.content, "base64").toString("utf-8");
126
126
  }
127
127
 
128
+ // True if `owner` resolves to a real GitHub account (user OR org — both live
129
+ // under /users/{login}). Lets join distinguish "wrong owner" from "owner has
130
+ // no Egregore", which is the difference between a typo and a missing repo.
131
+ async function ownerExists(token, owner) {
132
+ const { status } = await ghApi("GET", `/users/${owner}`, token);
133
+ return status === 200;
134
+ }
135
+
136
+ // Repo names under `owner` that the authenticated user can actually reach —
137
+ // org membership or a (already-accepted) personal-repo collaborator invite.
138
+ // /user/repos is the only endpoint that honors private + collaborator repos
139
+ // across arbitrary owners, so we list it and filter by owner.
140
+ async function listAccessibleRepos(token, owner) {
141
+ const ownerLc = owner.toLowerCase();
142
+ const names = [];
143
+ let page = 1;
144
+ while (page <= 4) { // cap at 400 accessible repos
145
+ const url = `/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member&page=${page}`;
146
+ const { status, data } = await ghApi("GET", url, token);
147
+ if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
148
+ for (const r of data) {
149
+ if (r.owner?.login?.toLowerCase() === ownerLc) names.push(r.name);
150
+ }
151
+ if (data.length < 100) break;
152
+ page += 1;
153
+ }
154
+ return names;
155
+ }
156
+
157
+ // Discover every repo under `owner` that carries an egregore.json on its
158
+ // default branch. Probes the conventional name first, then Egregore-looking
159
+ // names, then the rest — capped so a large account doesn't fan out forever.
160
+ // Returns [{ repo, config }] so the caller never re-fetches the config.
161
+ async function discoverEgregoreRepos(token, owner) {
162
+ const seen = new Set();
163
+ const candidates = [];
164
+ const add = (name) => {
165
+ const key = name.toLowerCase();
166
+ if (!seen.has(key)) { seen.add(key); candidates.push(name); }
167
+ };
168
+ add("egregore"); // conventional default — also covers public repos not in the list
169
+ const accessible = await listAccessibleRepos(token, owner);
170
+ for (const n of accessible.filter((n) => /egregore/i.test(n))) add(n);
171
+ for (const n of accessible.filter((n) => !/egregore/i.test(n))) add(n);
172
+
173
+ const MAX_PROBES = 20;
174
+ const found = [];
175
+ for (const repo of candidates.slice(0, MAX_PROBES)) {
176
+ let raw = null;
177
+ try { raw = await getFileContent(token, owner, repo, "egregore.json"); } catch { raw = null; }
178
+ if (!raw) continue;
179
+ try { found.push({ repo, config: JSON.parse(raw) }); } catch { /* malformed config — skip */ }
180
+ }
181
+ return found;
182
+ }
183
+
128
184
  async function putFileContent(token, owner, repo, filePath, content, message) {
129
185
  // Get SHA if file already exists
130
186
  const { status: existStatus, data: existData } = await ghApi(
@@ -1253,22 +1309,63 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
1253
1309
  await acceptPendingInvitations(githubToken, orgLogin, ui);
1254
1310
  s1.stop("Invitations accepted");
1255
1311
 
1256
- // 4. Resolve egregore.json before cloning. The shared install() helper owns
1257
- // all local filesystem setup after this point.
1258
- const repoName = explicitRepo || "egregore";
1259
- const s2 = ui.spinner(`Reading ${orgLogin}/${repoName}/egregore.json...`);
1260
- let config;
1312
+ // 4. Resolve which repo under `orgLogin` holds egregore.json. An explicit
1313
+ // repo is read directly; a bare owner is discovered (the founder flow
1314
+ // names the repo after the org slug, NOT "egregore", so we can't assume).
1315
+ // The shared install() helper owns all local setup after this point.
1316
+ const s2 = ui.spinner(
1317
+ explicitRepo
1318
+ ? `Reading ${orgLogin}/${explicitRepo}/egregore.json...`
1319
+ : `Finding the Egregore in ${orgLogin}...`,
1320
+ );
1321
+ let candidates = [];
1261
1322
  try {
1262
- const raw = await getFileContent(githubToken, orgLogin, repoName, "egregore.json");
1263
- if (!raw) throw new Error("missing");
1264
- config = JSON.parse(raw);
1265
- s2.stop("Resolved Egregore config");
1266
- } catch (err) {
1267
- s2.fail("Could not read egregore.json");
1268
- ui.error(`Make sure ${orgLogin}/${repoName} exists and you accepted the GitHub invitation.`);
1269
- ui.info(`Ask the admin to resend: ${joinCommand(orgLogin, repoName)}`);
1323
+ if (explicitRepo) {
1324
+ const raw = await getFileContent(githubToken, orgLogin, explicitRepo, "egregore.json");
1325
+ if (raw) candidates = [{ repo: explicitRepo, config: JSON.parse(raw) }];
1326
+ } else {
1327
+ candidates = await discoverEgregoreRepos(githubToken, orgLogin);
1328
+ }
1329
+ } catch {
1330
+ candidates = [];
1331
+ }
1332
+
1333
+ if (candidates.length === 0) {
1334
+ s2.fail("Could not find an Egregore to join");
1335
+ const exists = await ownerExists(githubToken, orgLogin);
1336
+ if (!exists) {
1337
+ // The owner itself isn't a GitHub account — almost always a repo name
1338
+ // passed where owner/repo was meant (e.g. `join xenocracy` for
1339
+ // `fcdagdelen/xenocracy`).
1340
+ ui.error(`No GitHub user or organization named ${ui.bold(orgLogin)}.`);
1341
+ ui.info(`Join targets are ${ui.bold("owner/repo")} — e.g. ${ui.bold(joinCommand("acme", "acme"))}.`);
1342
+ } else if (explicitRepo) {
1343
+ ui.error(`${ui.bold(`${orgLogin}/${explicitRepo}`)} has no egregore.json on its default branch.`);
1344
+ ui.info("Confirm the repo name, that egregore.json is committed to the default branch, and that you accepted the GitHub invitation.");
1345
+ } else {
1346
+ ui.error(`No Egregore repo found under ${ui.bold(orgLogin)} that you can access.`);
1347
+ ui.info(`If you know it, pass the repo: ${ui.bold(joinCommand(orgLogin, "<repo>"))}. Otherwise ask the admin for the exact ${ui.bold("owner/repo")}.`);
1348
+ }
1270
1349
  process.exit(1);
1271
1350
  }
1351
+
1352
+ let repoName;
1353
+ let config;
1354
+ if (candidates.length === 1) {
1355
+ ({ repo: repoName, config } = candidates[0]);
1356
+ s2.stop(`Found ${orgLogin}/${repoName}`);
1357
+ } else {
1358
+ s2.stop(`Found ${candidates.length} Egregores in ${orgLogin}`);
1359
+ const picked = await ui.choose(
1360
+ `Which Egregore in ${orgLogin}?`,
1361
+ candidates.map((c) => ({
1362
+ label: `${orgLogin}/${c.repo}`,
1363
+ description: c.config.org_name || "",
1364
+ _candidate: c,
1365
+ })),
1366
+ );
1367
+ ({ repo: repoName, config } = picked._candidate);
1368
+ }
1272
1369
  ui.success(`Found ${ui.bold(config.org_name || orgLogin)}`);
1273
1370
 
1274
1371
  const orgName = config.org_name || orgLogin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.8",
3
+ "version": "0.18.10",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code or Codex.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "build:runtime": "node scripts/build-runtime-bundle.mjs",
14
- "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js",
14
+ "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js",
15
15
  "test:e2e": "node --test test/e2e-local.test.js",
16
16
  "test:all": "node scripts/build-runtime-bundle.mjs --check && node --test test/"
17
17
  },
@@ -36,13 +36,16 @@ EMPTY='{"fields":[],"values":[]}'
36
36
  if [ -z "$API_URL" ] || [ -z "$API_KEY" ]; then
37
37
  GRAPH_REASON="missing_config"
38
38
  else
39
- # Store HTTP status code alongside response body
39
+ # Store HTTP status code alongside response body. curl already emits 000
40
+ # on transport failure; keep the exit status separate so it cannot become
41
+ # 000000 and fall through as an invalid JSON response.
42
+ CURL_STATUS=0
40
43
  HTTP_CODE=$(curl -s -o "$TMPDIR/activity_raw.json" -w "%{http_code}" \
41
44
  "${API_URL}/api/activity/dashboard?github_username=$(printf '%s' "$GH_USER" | jq -sRr @uri)" \
42
45
  -H "Authorization: Bearer $API_KEY" \
43
- --connect-timeout 5 --max-time 15 2>/dev/null || echo "000")
46
+ --connect-timeout 5 --max-time 15 2>/dev/null) || CURL_STATUS=$?
44
47
 
45
- if [ "$HTTP_CODE" = "000" ]; then
48
+ if [ "$CURL_STATUS" -ne 0 ] || [ "$HTTP_CODE" = "000" ] || ! [[ "$HTTP_CODE" =~ ^[0-9][0-9][0-9]$ ]]; then
46
49
  GRAPH_REASON="unreachable"
47
50
  elif [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
48
51
  GRAPH_REASON="auth_error"
@@ -44,12 +44,16 @@ SESSION_ID=$(cat "$HOME/.egregore/session-${PROJ_HASH}.id" 2>/dev/null || echo "
44
44
  if [ -z "$API_URL" ] || [ -z "$API_KEY" ]; then
45
45
  GRAPH_REASON="missing_config"
46
46
  else
47
+ # Keep curl's transport status separate from %{http_code}; otherwise a
48
+ # failed request can concatenate 000 + fallback text and look like an
49
+ # unexpected API response.
50
+ CURL_STATUS=0
47
51
  HTTP_CODE=$(curl -s -o "$TMPDIR/dashboard_raw.json" -w "%{http_code}" \
48
52
  "${API_URL}/api/personal/dashboard?github_username=$(printf '%s' "$GH_USER" | jq -sRr @uri)&time_range=$(printf '%s' "$TIME_RANGE" | jq -sRr @uri)&session_id=$(printf '%s' "$SESSION_ID" | jq -sRr @uri)" \
49
53
  -H "Authorization: Bearer $API_KEY" \
50
- --connect-timeout 5 --max-time 15 2>/dev/null || echo "000")
54
+ --connect-timeout 5 --max-time 15 2>/dev/null) || CURL_STATUS=$?
51
55
 
52
- if [ "$HTTP_CODE" = "000" ]; then
56
+ if [ "$CURL_STATUS" -ne 0 ] || [ "$HTTP_CODE" = "000" ] || ! [[ "$HTTP_CODE" =~ ^[0-9][0-9][0-9]$ ]]; then
53
57
  GRAPH_REASON="unreachable"
54
58
  elif [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
55
59
  GRAPH_REASON="auth_error"