create-egregore 0.19.0 → 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.
Files changed (3) hide show
  1. package/lib/local.js +62 -17
  2. package/lib/ui.js +7 -2
  3. package/package.json +1 -1
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}`);
@@ -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;
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.2",
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": {