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 +4 -1
- package/lib/local.js +77 -23
- package/lib/setup.js +15 -0
- package/lib/ui.js +7 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
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