create-egregore 0.19.0 → 0.19.3

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/bin/cli.js CHANGED
@@ -36,6 +36,7 @@ const {
36
36
  registerInstance,
37
37
  installLauncherBinary,
38
38
  normalizeRuntime,
39
+ redactToken,
39
40
  } = require("../lib/setup");
40
41
  const { detectContext, isWritable, isSystemDir } = require("../lib/context");
41
42
 
@@ -888,6 +889,8 @@ async function joinFlow(api, githubToken, choice, ctx) {
888
889
  }
889
890
 
890
891
  main().catch((err) => {
891
- ui.error(err.message);
892
+ // Last-resort net: never let a credential reach the terminal via an
893
+ // unhandled error message (cloneRepo already redacts at the source).
894
+ ui.error(redactToken(err && err.message));
892
895
  process.exit(1);
893
896
  });
package/lib/local.js CHANGED
@@ -25,6 +25,7 @@ const {
25
25
  installLauncherBinary,
26
26
  launchOrInstruct,
27
27
  embedToken,
28
+ redactToken,
28
29
  configureGitCredentials,
29
30
  gitCloneEnv,
30
31
  initMemoryDirs,
@@ -54,6 +55,16 @@ async function listOrgs(token) {
54
55
  return data.map((o) => ({ login: o.login }));
55
56
  }
56
57
 
58
+ // Can the token see this org at all? /user/orgs is blind to fine-grained
59
+ // tokens and to private/SSO-gated membership, so the picker lets users type an
60
+ // org by hand — this validates the typed login is reachable (a fine-grained
61
+ // PAT scoped to the org returns 200 here) so a typo fails now, not at
62
+ // repo-creation time.
63
+ async function orgReachable(token, org) {
64
+ const { status } = await ghApi("GET", `/orgs/${org}`, token);
65
+ return status === 200;
66
+ }
67
+
57
68
  async function listOrgMembers(token, org) {
58
69
  // Returns [{login}]. Only visible members (public or visible to the token).
59
70
  // Non-fatal on error — caller falls back to a text prompt.
@@ -94,6 +105,18 @@ async function createRepo(token, owner, name, isOrg, description) {
94
105
  description: description || "Egregore shared memory",
95
106
  });
96
107
  if (status === 422) return { already_exists: true };
108
+ // 403 here almost always means the token can't write to THIS owner — the
109
+ // classic fine-grained-PAT case, where the token is scoped to one org and
110
+ // we're pointed at the personal account (or the wrong org). Turn GitHub's
111
+ // opaque "Resource not accessible by personal access token" into a next step.
112
+ if (status === 403) {
113
+ const where = isOrg ? `the ${owner} organization` : `your personal account (${owner})`;
114
+ throw new Error(
115
+ `GitHub refused repo creation in ${where} (403). ` +
116
+ `A fine-grained token is scoped to a single owner — re-run and choose that owner ` +
117
+ `as the home, or re-auth with \`gh auth login\` (unset GH_TOKEN first if it's set).`,
118
+ );
119
+ }
97
120
  if (status !== 201 && status !== 200) {
98
121
  const msg = typeof data === "object" ? data.message || JSON.stringify(data) : data;
99
122
  throw new Error(`Create repo failed (HTTP ${status}): ${msg}`);
@@ -320,12 +343,20 @@ function cloneRepo(url, dir, token, ui, label) {
320
343
  ui.warn(`${path.basename(dir)}/ already exists — pulling latest`);
321
344
  run("git pull", { cwd: dir });
322
345
  } else {
323
- execFileSync("git", ["clone", embedToken(url, token), dir], {
324
- stdio: "pipe",
325
- encoding: "utf-8",
326
- timeout: 60000,
327
- env: gitCloneEnv(),
328
- });
346
+ try {
347
+ execFileSync("git", ["clone", embedToken(url, token), dir], {
348
+ stdio: "pipe",
349
+ encoding: "utf-8",
350
+ timeout: 60000,
351
+ env: gitCloneEnv(),
352
+ });
353
+ } catch (err) {
354
+ // execFileSync puts the full command — including the embedded token — in
355
+ // err.message. Rethrow a redacted error that keeps git's stderr (the
356
+ // useful "remote: ..." lines) but never the credential.
357
+ const detail = [err.message, err.stderr].filter(Boolean).join("\n");
358
+ throw new Error(redactToken(detail, token));
359
+ }
329
360
  try {
330
361
  run(`git remote set-url origin ${url}`, { cwd: dir });
331
362
  } catch {}
@@ -677,6 +708,26 @@ async function localFounderFlow(ui, ctx = {}) {
677
708
  reposCache: null, // {org, repos, teamRepoCount}
678
709
  };
679
710
 
711
+ const ENTER_ORG_LABEL = "Enter an organization name…";
712
+
713
+ const promptForOrg = async () => {
714
+ // Free-text org entry for the cases /user/orgs can't see (fine-grained
715
+ // tokens, private/SSO-gated membership). Validate reachability here so a
716
+ // typo or an unauthorized org fails now, with a clear message, instead of
717
+ // as an opaque 403 mid-creation.
718
+ while (true) {
719
+ const raw = await ui.prompt("Organization login (exact, e.g. acme-inc — blank to go back)");
720
+ const org = (raw || "").trim();
721
+ if (!org) return null;
722
+ const s = ui.spinner(`Checking ${org}…`);
723
+ if (await orgReachable(githubToken, org)) {
724
+ s.stop(`Found ${org}`);
725
+ return org;
726
+ }
727
+ s.fail(`Can't reach an org named "${org}" with this login — check the spelling, or authorize your token for it on GitHub.`);
728
+ }
729
+ };
730
+
680
731
  const stepHome = async () => {
681
732
  if (!st.orgs) {
682
733
  ui.section("Pick a home", "where should this Egregore live?");
@@ -684,24 +735,27 @@ async function localFounderFlow(ui, ctx = {}) {
684
735
  st.orgs = await listOrgs(githubToken);
685
736
  s.stop("Accounts loaded");
686
737
  }
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
- );
738
+ // Always offer manual org entry. /user/orgs returns nothing for
739
+ // fine-grained tokens and for orgs with private/SSO-gated membership, so
740
+ // auto-discovery alone silently strands those users on their personal
741
+ // account with no way to reach the org they actually want.
742
+ const options = [
743
+ { label: user.login, description: "Personal account" },
744
+ ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
745
+ { label: ENTER_ORG_LABEL, description: "Type an org login (needed for fine-grained tokens)" },
746
+ ];
747
+ const hint = st.orgs.length === 0
748
+ ? "Couldn't auto-detect your orgs (normal with fine-grained tokens) — pick your account or enter an org."
749
+ : "Don't see your org? Enter it below, or authorize your token for it on GitHub.";
750
+ const orgChoice = await ui.choose("Where should Egregore live?", options, hint, { escAction: "back" });
704
751
  if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
752
+ if (orgChoice.label === ENTER_ORG_LABEL) {
753
+ const org = await promptForOrg();
754
+ if (!org) return stepHome(); // blank entry → re-show the picker (orgs cached)
755
+ st.githubOrg = org;
756
+ st.isOrg = true;
757
+ return true;
758
+ }
705
759
  st.githubOrg = orgChoice.label;
706
760
  st.isOrg = st.githubOrg !== user.login;
707
761
  return true;
package/lib/setup.js CHANGED
@@ -596,6 +596,20 @@ function embedToken(url, token) {
596
596
  }
597
597
  }
598
598
 
599
+ // Strip credentials from any text before it reaches the terminal. execFileSync
600
+ // bakes the full argv into its thrown error message, so an authenticated
601
+ // `git clone https://x-access-token:<pat>@github.com/...` leaks the token
602
+ // verbatim if that error is printed. Redacts the known token, any userinfo in
603
+ // an https URL, and bare GitHub token shapes as a backstop.
604
+ function redactToken(text, token) {
605
+ let s = text == null ? "" : String(text);
606
+ if (token) s = s.split(token).join("***");
607
+ s = s.replace(/(https:\/\/)[^@/\s]+@/g, "$1***@"); // https://user:secret@host → https://***@host
608
+ s = s.replace(/gh[posru]_[A-Za-z0-9_]+/g, "***"); // ghp_/gho_/ghs_/ghu_/ghr_
609
+ s = s.replace(/github_pat_[A-Za-z0-9_]+/g, "***");
610
+ return s;
611
+ }
612
+
599
613
  function toSSH(url) {
600
614
  const m = url.match(/https:\/\/github\.com\/(.+?)(?:\.git)?$/);
601
615
  return m ? `git@github.com:${m[1]}.git` : null;
@@ -1255,6 +1269,7 @@ module.exports = {
1255
1269
  ensurePersonOnboardingWitness,
1256
1270
  ensureInstallerMemberRole,
1257
1271
  embedToken,
1272
+ redactToken,
1258
1273
  configureGitCredentials,
1259
1274
  gitCloneEnv,
1260
1275
  assistantInstallCommand,
package/lib/ui.js CHANGED
@@ -729,19 +729,24 @@ async function interactiveMultiSelect(question, options, opts = {}) {
729
729
  const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
730
730
  lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
731
731
  } else {
732
- lines.push(` ${DIM}0 selected${RESET}`);
732
+ // Nothing picked is a valid state — this picker never enforces a
733
+ // minimum. Say so, so an empty Enter doesn't read as "stuck".
734
+ lines.push(` ${DIM}0 selected — none required${RESET}`);
733
735
  }
734
736
  if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
735
737
 
736
738
  const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
737
739
  const escHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
740
+ // Enter always confirms the current selection, empty included — label it
741
+ // "skip" while nothing is picked so the exit is discoverable.
742
+ const enterHint = picked.size === 0 ? "skip" : "confirm";
738
743
  if (!first) clearLines(lastLineCount);
739
744
  lastLineCount = renderPicker({
740
745
  question,
741
746
  filterText,
742
747
  lines,
743
748
  input: { label: opts.remoteSearch ? "search" : "filter" },
744
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}${escHint}`,
749
+ help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} ${enterHint} · ${searchHint}${escHint}`,
745
750
  });
746
751
  };
747
752
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.19.0",
3
+ "version": "0.19.3",
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": {