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.
- package/lib/local.js +62 -17
- package/lib/ui.js +7 -2
- 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
|
-
|
|
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;
|
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
|
-
|
|
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}
|
|
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