create-egregore 0.18.16 → 0.18.18
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/auth.js +31 -6
- package/lib/local.js +315 -121
- package/lib/ui.js +262 -35
- package/package.json +2 -2
- package/runtime/codex/bin/lib/identity.sh +10 -0
- package/runtime/codex/bin/telemetry.sh +54 -13
package/lib/auth.js
CHANGED
|
@@ -110,6 +110,26 @@ function explainNoGhHeadless(ui) {
|
|
|
110
110
|
ui?.info?.(` Install for any OS: ${GH_INSTALL_URL}`);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Status text for the ACTIVE github.com account only. `gh auth status`
|
|
114
|
+
// writes to stderr on some versions; capture both streams. With multiple
|
|
115
|
+
// accounts the plain form lists every account (including broken ones), so
|
|
116
|
+
// try --active first (gh ≥ 2.61) and otherwise cut the active account's
|
|
117
|
+
// block out of the combined output.
|
|
118
|
+
function ghActiveStatusText() {
|
|
119
|
+
let run = spawnSync("gh", ["auth", "status", "--active", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
|
|
120
|
+
let text = `${run.stdout || ""}${run.stderr || ""}`;
|
|
121
|
+
if (run.error || /unknown flag/i.test(text)) {
|
|
122
|
+
run = spawnSync("gh", ["auth", "status", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
|
|
123
|
+
text = `${run.stdout || ""}${run.stderr || ""}`;
|
|
124
|
+
}
|
|
125
|
+
// Split into per-account blocks (each starts at a "... account <login>"
|
|
126
|
+
// line) and keep the one marked active. Single-account/older gh output has
|
|
127
|
+
// no "Active account" marker — return it whole.
|
|
128
|
+
const blocks = text.split(/\n(?=\s*[✓✗xX-]?\s*(?:Logged in to|Failed to log in to))/);
|
|
129
|
+
const active = blocks.find((b) => /Active account:\s*true/i.test(b));
|
|
130
|
+
return active || text;
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
function tryGhAuth() {
|
|
114
134
|
if (process.env.EGREGORE_SKIP_GH === "1") return null;
|
|
115
135
|
|
|
@@ -118,16 +138,21 @@ function tryGhAuth() {
|
|
|
118
138
|
|
|
119
139
|
if (!ghCliExists()) return null;
|
|
120
140
|
|
|
121
|
-
// gh auth
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const tokenRun = spawnSync("gh", ["auth", "token"], { stdio: "pipe", encoding: "utf-8" });
|
|
141
|
+
// The token is the source of truth: `gh auth token` succeeds iff the ACTIVE
|
|
142
|
+
// account is usable. Do NOT gate on `gh auth status`'s exit code — on
|
|
143
|
+
// multi-account setups it is non-zero when ANY configured account has a
|
|
144
|
+
// revoked/expired token, even though the active account is fine, which used
|
|
145
|
+
// to hard-fail installs until the user logged the stale accounts out.
|
|
146
|
+
const tokenRun = spawnSync("gh", ["auth", "token", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
|
|
127
147
|
if (tokenRun.status !== 0) return null;
|
|
128
148
|
const token = (tokenRun.stdout || "").trim();
|
|
129
149
|
if (!token) return null;
|
|
130
150
|
|
|
151
|
+
// Status output is metadata only (scopes + login). Prefer --active (gh ≥
|
|
152
|
+
// 2.61) so stale sibling accounts don't pollute the output; fall back to the
|
|
153
|
+
// plain form and pick the active account's block out of it.
|
|
154
|
+
const statusText = ghActiveStatusText();
|
|
155
|
+
|
|
131
156
|
// Parse declared token scopes when gh exposes them. Some gh versions and
|
|
132
157
|
// fine-grained auth paths do not print a classic Token scopes line. Treat
|
|
133
158
|
// that as "unknown" and continue with the first-party gh token instead of
|
package/lib/local.js
CHANGED
|
@@ -198,6 +198,63 @@ async function putFileContent(token, owner, repo, filePath, content, message) {
|
|
|
198
198
|
return ghApi("PUT", `/repos/${owner}/${repo}/contents/${filePath}`, token, body);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
async function paginateRepos(token, baseUrl) {
|
|
202
|
+
let all = [];
|
|
203
|
+
let page = 1;
|
|
204
|
+
while (page <= 5) { // cap at 500 repos
|
|
205
|
+
const url = `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}page=${page}`;
|
|
206
|
+
const { status, data } = await ghApi("GET", url, token);
|
|
207
|
+
if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
|
|
208
|
+
all = all.concat(data);
|
|
209
|
+
if (data.length < 100) break; // last page
|
|
210
|
+
page++;
|
|
211
|
+
}
|
|
212
|
+
return all;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pickerRepoFilter(r) {
|
|
216
|
+
return !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function toPickerRepo(r) {
|
|
220
|
+
return { name: r.name, language: r.language || "", description: r.description || "" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Teams the user belongs to in this org. Big orgs bury a team's repos past
|
|
224
|
+
// the 500-most-recently-updated cap of the org-wide listing, so a team scope
|
|
225
|
+
// is often the only practical way to find them. Non-fatal on error.
|
|
226
|
+
async function listUserTeams(token, org) {
|
|
227
|
+
try {
|
|
228
|
+
const teams = await paginateRepos(token, "/user/teams?per_page=100");
|
|
229
|
+
const orgLc = org.toLowerCase();
|
|
230
|
+
return teams
|
|
231
|
+
.filter((t) => t.organization && (t.organization.login || "").toLowerCase() === orgLc)
|
|
232
|
+
.map((t) => ({ name: t.name, slug: t.slug, reposCount: t.repos_count }));
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function listTeamRepos(token, org, teamSlug) {
|
|
239
|
+
const repos = await paginateRepos(token, `/orgs/${org}/teams/${teamSlug}/repos?per_page=100`);
|
|
240
|
+
return repos.filter(pickerRepoFilter).map(toPickerRepo);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Live search across the WHOLE org/account via GitHub's search index —
|
|
244
|
+
// unlike the listing endpoints this isn't bounded to a recency window, so
|
|
245
|
+
// the picker can reach any repo the token can see. Non-fatal on error.
|
|
246
|
+
async function searchOrgRepos(token, owner, isOrg, term) {
|
|
247
|
+
try {
|
|
248
|
+
const qualifier = isOrg ? `org:${owner}` : `user:${owner}`;
|
|
249
|
+
const q = encodeURIComponent(`${qualifier} ${term} in:name archived:false`);
|
|
250
|
+
const { status, data } = await ghApi("GET", `/search/repositories?q=${q}&per_page=30`, token);
|
|
251
|
+
if (status !== 200 || !data || !Array.isArray(data.items)) return [];
|
|
252
|
+
return data.items.filter(pickerRepoFilter).map(toPickerRepo);
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
201
258
|
async function listOrgRepos(token, owner, isOrg) {
|
|
202
259
|
// Fetch all repos (public + private). Paginate to get beyond the first 100.
|
|
203
260
|
//
|
|
@@ -214,16 +271,7 @@ async function listOrgRepos(token, owner, isOrg) {
|
|
|
214
271
|
? `/orgs/${owner}/repos?per_page=100&sort=updated&type=all`
|
|
215
272
|
: `/user/repos?per_page=100&sort=updated&affiliation=owner`;
|
|
216
273
|
|
|
217
|
-
let allRepos =
|
|
218
|
-
let page = 1;
|
|
219
|
-
while (page <= 5) { // cap at 500 repos
|
|
220
|
-
const url = `${baseUrl}&page=${page}`;
|
|
221
|
-
const { status, data } = await ghApi("GET", url, token);
|
|
222
|
-
if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
|
|
223
|
-
allRepos = allRepos.concat(data);
|
|
224
|
-
if (data.length < 100) break; // last page
|
|
225
|
-
page++;
|
|
226
|
-
}
|
|
274
|
+
let allRepos = await paginateRepos(token, baseUrl);
|
|
227
275
|
|
|
228
276
|
// Personal /user/repos returns repos across all of the user's accounts
|
|
229
277
|
// (their personal namespace + any orgs they're a member of). When the
|
|
@@ -233,9 +281,7 @@ async function listOrgRepos(token, owner, isOrg) {
|
|
|
233
281
|
allRepos = allRepos.filter((r) => r.owner && r.owner.login === owner);
|
|
234
282
|
}
|
|
235
283
|
|
|
236
|
-
return allRepos
|
|
237
|
-
.filter((r) => !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory"))
|
|
238
|
-
.map((r) => ({ name: r.name, language: r.language || "", description: r.description || "" }));
|
|
284
|
+
return allRepos.filter(pickerRepoFilter).map(toPickerRepo);
|
|
239
285
|
}
|
|
240
286
|
|
|
241
287
|
async function addCollaborator(token, owner, repo, username) {
|
|
@@ -607,130 +653,218 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
607
653
|
// 2. Get user info
|
|
608
654
|
const user = await getUser(githubToken);
|
|
609
655
|
|
|
610
|
-
// 3.
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
s
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
656
|
+
// 3–6. Home → new/existing → naming → repo picker → repo names, as a
|
|
657
|
+
// step machine so esc walks BACKWARDS through the flow (field request:
|
|
658
|
+
// a wrong org pick shouldn't mean Ctrl-C and start over). Every answer
|
|
659
|
+
// lives in `st`, so a revisited step re-offers the previous choice.
|
|
660
|
+
// Creation (step 7 below) is the point of no return — no back from there.
|
|
661
|
+
const slugifyName = (s) => (s || "")
|
|
662
|
+
.toLowerCase()
|
|
663
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
664
|
+
.replace(/-+/g, "-")
|
|
665
|
+
.replace(/^-|-$/g, "");
|
|
666
|
+
const BACK = ui.BACK;
|
|
667
|
+
const SKIP = Symbol("step-skip"); // deterministic step — passes through in both directions
|
|
668
|
+
|
|
669
|
+
const st = {
|
|
670
|
+
orgs: null, // fetched once
|
|
671
|
+
githubOrg: null, isOrg: false,
|
|
672
|
+
isNewProject: null,
|
|
673
|
+
orgName: null, description: "", newProjectRepo: null,
|
|
674
|
+
selectedRepoNames: null, // raw picker selection (preserved across back)
|
|
675
|
+
selectedRepos: [], // enriched {name, base_branch}
|
|
676
|
+
repoName: null, memoryRepoName: null,
|
|
677
|
+
reposCache: null, // {org, repos, teamRepoCount}
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const stepHome = async () => {
|
|
681
|
+
if (!st.orgs) {
|
|
682
|
+
ui.section("Pick a home", "where should this Egregore live?");
|
|
683
|
+
const s = ui.spinner("Checking your GitHub accounts...");
|
|
684
|
+
st.orgs = await listOrgs(githubToken);
|
|
685
|
+
s.stop("Accounts loaded");
|
|
686
|
+
}
|
|
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;
|
|
626
694
|
}
|
|
627
695
|
const orgChoice = await ui.choose(
|
|
628
696
|
"Where should Egregore live?",
|
|
629
|
-
|
|
697
|
+
[
|
|
698
|
+
{ label: user.login, description: "Personal account" },
|
|
699
|
+
...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
|
|
700
|
+
],
|
|
630
701
|
"Don't see your org? Authorize it for your account on GitHub, then re-run.",
|
|
702
|
+
{ escAction: "back" },
|
|
631
703
|
);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
704
|
+
if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
|
|
705
|
+
st.githubOrg = orgChoice.label;
|
|
706
|
+
st.isOrg = st.githubOrg !== user.login;
|
|
707
|
+
return true;
|
|
708
|
+
};
|
|
635
709
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
710
|
+
const stepWorkType = async () => {
|
|
711
|
+
// In-repo detection already answered this — pass through both ways.
|
|
712
|
+
if (ctx.prefilledRepo) {
|
|
713
|
+
if (st.isNewProject === null) {
|
|
714
|
+
st.isNewProject = false;
|
|
715
|
+
ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
|
|
716
|
+
}
|
|
717
|
+
return SKIP;
|
|
718
|
+
}
|
|
645
719
|
const action = await ui.choose("What are you working on?", [
|
|
646
720
|
{ label: "Starting something new", description: "Create a project from scratch" },
|
|
647
721
|
{ label: "Existing project", description: "Connect repos I already have" },
|
|
648
|
-
]);
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
ui.section("Name it", "what's this Egregore called?");
|
|
654
|
-
let orgName, description, selectedRepos = [], newProjectRepo = null;
|
|
655
|
-
|
|
656
|
-
if (isNewProject) {
|
|
657
|
-
orgName = await ui.prompt("name your Egregore (team or group name)", { default: githubOrg });
|
|
658
|
-
description = await ui.prompt("what are you building? (one line)");
|
|
659
|
-
|
|
660
|
-
// Derive the egregore + memory repo names now so we can validate the
|
|
661
|
-
// optional project repo name against them before the user commits to it.
|
|
662
|
-
// Matches the final derivation at line ~395 below.
|
|
663
|
-
const slugifyRepo = (s) => s.toLowerCase()
|
|
664
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
665
|
-
.replace(/-+/g, "-")
|
|
666
|
-
.replace(/^-|-$/g, "") || "egregore";
|
|
667
|
-
const predictedRepoName = slugifyRepo(orgName);
|
|
668
|
-
const predictedMemoryRepoName = `${predictedRepoName}-memory`;
|
|
669
|
-
|
|
670
|
-
// Ask about project repo
|
|
671
|
-
ui.note([
|
|
672
|
-
"Your Egregore gets its own repo (memory + config) in your account.",
|
|
673
|
-
"Have a code project too? Egregore can add a repo for it in your account.",
|
|
674
|
-
]);
|
|
722
|
+
], undefined, { escAction: "back" });
|
|
723
|
+
if (action === BACK) return BACK;
|
|
724
|
+
st.isNewProject = action.label.startsWith("Starting");
|
|
725
|
+
return true;
|
|
726
|
+
};
|
|
675
727
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
728
|
+
const stepNameProject = async () => {
|
|
729
|
+
ui.section("Name it", "what's this Egregore called?");
|
|
730
|
+
if (st.isNewProject) {
|
|
731
|
+
const name = await ui.promptOrBack("name your Egregore (team or group name)", { default: st.orgName || st.githubOrg });
|
|
732
|
+
if (name === BACK) return BACK;
|
|
733
|
+
st.orgName = name;
|
|
734
|
+
const desc = await ui.promptOrBack("what are you building? (one line)", { default: st.description || undefined });
|
|
735
|
+
if (desc === BACK) return BACK;
|
|
736
|
+
st.description = desc;
|
|
737
|
+
|
|
738
|
+
// Validate the optional project repo name against the names the
|
|
739
|
+
// Egregore/memory repos WILL get (editable later, derived for now).
|
|
740
|
+
const predictedRepoName = slugifyName(st.orgName) || "egregore";
|
|
741
|
+
const predictedMemoryRepoName = `${predictedRepoName}-memory`;
|
|
742
|
+
ui.note([
|
|
743
|
+
"Your Egregore gets its own repo (memory + config) in your account.",
|
|
744
|
+
"Have a code project too? Egregore can add a repo for it in your account.",
|
|
745
|
+
]);
|
|
746
|
+
for (;;) {
|
|
747
|
+
const projectName = await ui.promptOrBack("code project repo name (Enter to skip)", { default: st.newProjectRepo || undefined });
|
|
748
|
+
if (projectName === BACK) return BACK;
|
|
749
|
+
if (!projectName) { st.newProjectRepo = null; break; }
|
|
750
|
+
const slug = slugifyName(projectName);
|
|
751
|
+
if (!slug) {
|
|
752
|
+
ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (slug === predictedRepoName) {
|
|
756
|
+
ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
|
|
757
|
+
ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (slug === predictedMemoryRepoName) {
|
|
761
|
+
ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
st.newProjectRepo = slug;
|
|
680
765
|
break;
|
|
681
766
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
|
|
689
|
-
ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
|
|
690
|
-
continue;
|
|
691
|
-
}
|
|
692
|
-
if (slug === predictedMemoryRepoName) {
|
|
693
|
-
ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
|
-
newProjectRepo = slug;
|
|
697
|
-
break;
|
|
767
|
+
} else {
|
|
768
|
+
const name = await ui.promptOrBack("what should we call this Egregore?", { default: st.orgName || st.githubOrg });
|
|
769
|
+
if (name === BACK) return BACK;
|
|
770
|
+
st.orgName = name;
|
|
771
|
+
st.description = "";
|
|
772
|
+
st.newProjectRepo = null;
|
|
698
773
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
|
|
702
|
-
description = "";
|
|
774
|
+
return true;
|
|
775
|
+
};
|
|
703
776
|
|
|
777
|
+
const stepRepos = async () => {
|
|
778
|
+
if (st.isNewProject) {
|
|
779
|
+
st.selectedRepoNames = null;
|
|
780
|
+
st.selectedRepos = [];
|
|
781
|
+
return SKIP;
|
|
782
|
+
}
|
|
704
783
|
// Auto-adopt the user's current repo if it's under the chosen GitHub owner.
|
|
705
784
|
const autoAdopt =
|
|
706
785
|
ctx.prefilledRepo &&
|
|
707
786
|
ctx.prefilledRepo.owner &&
|
|
708
|
-
ctx.prefilledRepo.owner.toLowerCase() === githubOrg.toLowerCase()
|
|
787
|
+
ctx.prefilledRepo.owner.toLowerCase() === st.githubOrg.toLowerCase()
|
|
709
788
|
? ctx.prefilledRepo.name
|
|
710
789
|
: null;
|
|
711
790
|
|
|
712
|
-
// Multi-select repos to manage
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
791
|
+
// Multi-select repos to manage. The org-wide listing is capped at the
|
|
792
|
+
// 500 most recently updated repos, which buries a team's repos in a big
|
|
793
|
+
// org — so the user's team repos are loaded too and boosted to the top,
|
|
794
|
+
// and typing in the picker live-searches the WHOLE org (see
|
|
795
|
+
// searchOrgRepos), so no repo is unreachable. The load is cached per
|
|
796
|
+
// owner so back-navigation doesn't refetch.
|
|
797
|
+
if (!st.reposCache || st.reposCache.org !== st.githubOrg) {
|
|
798
|
+
const s0 = ui.spinner("Loading repos...");
|
|
799
|
+
const repos = [];
|
|
800
|
+
let teamRepoCount = 0;
|
|
801
|
+
try {
|
|
802
|
+
const [orgRepos, teams] = await Promise.all([
|
|
803
|
+
listOrgRepos(githubToken, st.githubOrg, st.isOrg),
|
|
804
|
+
st.isOrg ? listUserTeams(githubToken, st.githubOrg) : Promise.resolve([]),
|
|
805
|
+
]);
|
|
806
|
+
const teamRepoLists = await Promise.all(
|
|
807
|
+
teams.map((t) => listTeamRepos(githubToken, st.githubOrg, t.slug).catch(() => [])),
|
|
808
|
+
);
|
|
809
|
+
const seen = new Set();
|
|
810
|
+
for (const list of teamRepoLists) {
|
|
811
|
+
for (const r of list) {
|
|
812
|
+
if (seen.has(r.name)) continue;
|
|
813
|
+
seen.add(r.name);
|
|
814
|
+
repos.push(r);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
teamRepoCount = repos.length;
|
|
818
|
+
for (const r of orgRepos) {
|
|
819
|
+
if (seen.has(r.name)) continue;
|
|
820
|
+
seen.add(r.name);
|
|
821
|
+
repos.push(r);
|
|
822
|
+
}
|
|
823
|
+
s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
|
|
824
|
+
} catch {
|
|
825
|
+
s0.stop("Could not load repos");
|
|
826
|
+
}
|
|
827
|
+
st.reposCache = { org: st.githubOrg, repos, teamRepoCount };
|
|
828
|
+
}
|
|
829
|
+
const repos = st.reposCache.repos;
|
|
830
|
+
|
|
831
|
+
const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
|
|
832
|
+
if (autoAdopt) {
|
|
833
|
+
console.log("");
|
|
834
|
+
ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
|
|
835
|
+
}
|
|
836
|
+
const picked = await ui.multiSelect(
|
|
837
|
+
"Which other repos should Egregore manage?",
|
|
838
|
+
filtered,
|
|
839
|
+
{
|
|
840
|
+
remoteSearch: (term) => searchOrgRepos(githubToken, st.githubOrg, st.isOrg, term),
|
|
841
|
+
preselected: st.selectedRepoNames || [],
|
|
842
|
+
escAction: "back",
|
|
843
|
+
},
|
|
844
|
+
);
|
|
845
|
+
if (picked === BACK) return BACK;
|
|
846
|
+
let selectedRepos = picked;
|
|
847
|
+
|
|
848
|
+
// Non-interactive terminals get no live search — keep a typed-names
|
|
849
|
+
// escape hatch there, validated so a typo doesn't become a ghost repo.
|
|
850
|
+
if (!(ui.canInteract && ui.canInteract())) {
|
|
851
|
+
const extraRaw = await ui.prompt("any repos not listed? exact names, comma-separated (Enter to skip)");
|
|
852
|
+
for (const raw of (extraRaw || "").split(",")) {
|
|
853
|
+
const name = raw.trim();
|
|
854
|
+
if (!name || selectedRepos.includes(name)) continue;
|
|
855
|
+
if (await repoExists(githubToken, st.githubOrg, name)) {
|
|
856
|
+
selectedRepos.push(name);
|
|
857
|
+
ui.success(`Added ${name}`);
|
|
858
|
+
} else {
|
|
859
|
+
ui.warn(`No repo ${st.githubOrg}/${name} — skipping`);
|
|
722
860
|
}
|
|
723
|
-
selectedRepos = await ui.multiSelect("Which other repos should Egregore manage?", filtered);
|
|
724
|
-
} else {
|
|
725
|
-
ui.info("No repos found yet.");
|
|
726
861
|
}
|
|
727
|
-
} catch {
|
|
728
|
-
s0.stop("Could not load repos");
|
|
729
862
|
}
|
|
730
863
|
if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
|
|
731
864
|
selectedRepos.unshift(autoAdopt);
|
|
732
865
|
}
|
|
733
866
|
if (autoAdopt) skipRepos.add(autoAdopt);
|
|
867
|
+
st.selectedRepoNames = [...selectedRepos];
|
|
734
868
|
|
|
735
869
|
// Detect default branch for each selected repo and convert to {name, base_branch} format
|
|
736
870
|
if (selectedRepos.length > 0) {
|
|
@@ -738,14 +872,13 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
738
872
|
const reposWithBranch = [];
|
|
739
873
|
for (const repoName of selectedRepos) {
|
|
740
874
|
try {
|
|
741
|
-
const info = await detectRepoBranches(githubToken, githubOrg, repoName);
|
|
875
|
+
const info = await detectRepoBranches(githubToken, st.githubOrg, repoName);
|
|
742
876
|
reposWithBranch.push({ name: repoName, base_branch: info ? info.defaultBranch : "main" });
|
|
743
877
|
} catch {
|
|
744
878
|
reposWithBranch.push({ name: repoName, base_branch: "main" });
|
|
745
879
|
}
|
|
746
880
|
}
|
|
747
881
|
s0b.stop("Checked branches");
|
|
748
|
-
// Replace flat list with enriched objects
|
|
749
882
|
selectedRepos = reposWithBranch;
|
|
750
883
|
console.log("");
|
|
751
884
|
ui.info("Detected branch setup:");
|
|
@@ -754,19 +887,76 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
754
887
|
}
|
|
755
888
|
console.log("");
|
|
756
889
|
}
|
|
890
|
+
st.selectedRepos = selectedRepos;
|
|
891
|
+
return true;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const stepRepoNames = async () => {
|
|
895
|
+
// Derived names shown as editable prompts — creating repos with silently
|
|
896
|
+
// derived names was a repeated field complaint.
|
|
897
|
+
const derivedSlug = slugifyName(st.orgName) || "egregore";
|
|
898
|
+
console.log("");
|
|
899
|
+
ui.note([
|
|
900
|
+
`Two repos will be created in ${ui.bold(st.githubOrg)}: the Egregore repo`,
|
|
901
|
+
"(config + skills) and its shared-memory repo. Enter keeps the defaults.",
|
|
902
|
+
]);
|
|
903
|
+
for (;;) {
|
|
904
|
+
const answer = await ui.promptOrBack("Egregore repo name", { default: st.repoName || derivedSlug });
|
|
905
|
+
if (answer === BACK) return BACK;
|
|
906
|
+
const candidate = slugifyName(answer);
|
|
907
|
+
if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
|
|
908
|
+
if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
|
|
909
|
+
st.repoName = candidate;
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
for (;;) {
|
|
913
|
+
const answer = await ui.promptOrBack("memory repo name", { default: st.memoryRepoName || `${st.repoName}-memory` });
|
|
914
|
+
if (answer === BACK) return BACK;
|
|
915
|
+
const candidate = slugifyName(answer);
|
|
916
|
+
if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
|
|
917
|
+
if (candidate === st.repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
|
|
918
|
+
if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
|
|
919
|
+
st.memoryRepoName = candidate;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
return true;
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
// Direction-aware driver: BACK rewinds one step; SKIP (deterministic
|
|
926
|
+
// steps) passes through in whichever direction we're moving, so backing
|
|
927
|
+
// over an auto-answered step doesn't bounce the user forward again.
|
|
928
|
+
const steps = [stepHome, stepWorkType, stepNameProject, stepRepos, stepRepoNames];
|
|
929
|
+
let stepIdx = 0;
|
|
930
|
+
let stepDir = 1;
|
|
931
|
+
while (stepIdx < steps.length) {
|
|
932
|
+
const result = await steps[stepIdx]();
|
|
933
|
+
if (result === BACK) {
|
|
934
|
+
stepDir = -1;
|
|
935
|
+
stepIdx = Math.max(0, stepIdx - 1);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (result === SKIP) {
|
|
939
|
+
stepIdx += stepDir;
|
|
940
|
+
if (stepIdx < 0) { stepIdx = 0; stepDir = 1; }
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
stepDir = 1;
|
|
944
|
+
stepIdx++;
|
|
757
945
|
}
|
|
758
946
|
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
947
|
+
const githubOrg = st.githubOrg;
|
|
948
|
+
const isOrg = st.isOrg;
|
|
949
|
+
const isNewProject = st.isNewProject;
|
|
950
|
+
const orgName = st.orgName;
|
|
951
|
+
const description = st.description;
|
|
952
|
+
let newProjectRepo = st.newProjectRepo;
|
|
953
|
+
let selectedRepos = st.selectedRepos;
|
|
954
|
+
const repoName = st.repoName;
|
|
955
|
+
const memoryRepoName = st.memoryRepoName;
|
|
956
|
+
const nameSlug = slugifyName(orgName) || "egregore";
|
|
765
957
|
// Org slug prefixed with GitHub owner — globally unique across all egregores
|
|
766
|
-
const ghPrefix = githubOrg
|
|
958
|
+
const ghPrefix = slugifyName(githubOrg);
|
|
767
959
|
const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
|
|
768
|
-
const repoName = nameSlug;
|
|
769
|
-
const memoryRepoName = `${nameSlug}-memory`;
|
|
770
960
|
|
|
771
961
|
// Guard: if egregore repo already exists with a config, don't overwrite
|
|
772
962
|
console.log("");
|
|
@@ -1169,7 +1359,11 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
1169
1359
|
...pickable.map((m) => ({ label: m.login, description: `@${githubOrg} member` })),
|
|
1170
1360
|
{ label: "Type a username", description: "invite someone not in the list" },
|
|
1171
1361
|
];
|
|
1172
|
-
const picked = await ui.choose(
|
|
1362
|
+
const picked = await ui.choose(
|
|
1363
|
+
first ? "who should we invite?" : "invite someone else?",
|
|
1364
|
+
choices,
|
|
1365
|
+
first ? "Inviting is optional — the first option skips this step." : undefined,
|
|
1366
|
+
);
|
|
1173
1367
|
if (picked.label === stopChoice.label) break;
|
|
1174
1368
|
if (picked.label === "Type a username") {
|
|
1175
1369
|
invUsername = await ui.prompt("GitHub username");
|
package/lib/ui.js
CHANGED
|
@@ -226,6 +226,60 @@ function prompt(question, { default: defaultValue } = {}) {
|
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
// Line input that can step backwards: esc resolves BACK, enter resolves the
|
|
230
|
+
// typed value (or the default). Raw-mode single-line editor — append +
|
|
231
|
+
// backspace only, which covers the short names these prompts collect.
|
|
232
|
+
// Falls back to the plain readline prompt when not interactive.
|
|
233
|
+
function promptOrBack(question, { default: defaultValue } = {}) {
|
|
234
|
+
if (!canInteract()) return prompt(question, { default: defaultValue });
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
let buf = "";
|
|
237
|
+
const label = defaultValue
|
|
238
|
+
? ` ${CYAN}?${RESET} ${question} ${DIM}[${defaultValue}]${RESET} ${DIM}(esc back)${RESET} `
|
|
239
|
+
: ` ${CYAN}?${RESET} ${question} ${DIM}(esc back)${RESET} `;
|
|
240
|
+
const render = () => process.stdout.write(`\r\x1b[2K${label}${buf}`);
|
|
241
|
+
const cleanup = () => {
|
|
242
|
+
process.stdin.setRawMode(false);
|
|
243
|
+
process.stdin.pause();
|
|
244
|
+
process.stdin.removeListener("keypress", onKey);
|
|
245
|
+
};
|
|
246
|
+
const onKey = (_str, key) => {
|
|
247
|
+
if (!key) return;
|
|
248
|
+
if (key.ctrl && key.name === "c") {
|
|
249
|
+
cleanup();
|
|
250
|
+
process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
|
|
251
|
+
process.exit(130);
|
|
252
|
+
}
|
|
253
|
+
if (key.name === "escape") {
|
|
254
|
+
cleanup();
|
|
255
|
+
process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
|
|
256
|
+
resolve(BACK);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (key.name === "return") {
|
|
260
|
+
cleanup();
|
|
261
|
+
process.stdout.write("\n");
|
|
262
|
+
resolve(buf.trim() || (defaultValue ?? ""));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (key.name === "backspace") {
|
|
266
|
+
buf = buf.slice(0, -1);
|
|
267
|
+
render();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
|
|
271
|
+
buf += _str;
|
|
272
|
+
render();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
readline.emitKeypressEvents(process.stdin);
|
|
276
|
+
process.stdin.setRawMode(true);
|
|
277
|
+
process.stdin.resume();
|
|
278
|
+
process.stdin.on("keypress", onKey);
|
|
279
|
+
render();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
229
283
|
async function confirm(question, defaultYes = false) {
|
|
230
284
|
const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
|
|
231
285
|
const answer = await prompt(`${question} ${hint}`);
|
|
@@ -254,12 +308,37 @@ function visualRows(line, width = process.stdout.columns || 80) {
|
|
|
254
308
|
|
|
255
309
|
// Render a picker frame and return { lines: number } so the next frame can
|
|
256
310
|
// rewind and clear. Shared by choose + multiSelect.
|
|
257
|
-
|
|
311
|
+
//
|
|
312
|
+
// `input` makes the type-to-search field a first-class affordance: the
|
|
313
|
+
// labelled input renders on the question line even when empty, and the REAL
|
|
314
|
+
// terminal cursor is parked right after the typed text so it blinks where
|
|
315
|
+
// keystrokes land — users shouldn't have to discover typing by accident.
|
|
316
|
+
let _parkedRows = 0;
|
|
317
|
+
|
|
318
|
+
function unparkCursor() {
|
|
319
|
+
if (_parkedRows > 0) {
|
|
320
|
+
process.stdout.write(`\x1b[${_parkedRows}B\r`);
|
|
321
|
+
_parkedRows = 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderPicker({ question, filterText, lines, help, input }) {
|
|
258
326
|
const out = [];
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
327
|
+
const width = process.stdout.columns || 80;
|
|
328
|
+
const label = (input && input.label) || "filter";
|
|
329
|
+
const showInput = !!input || !!filterText;
|
|
330
|
+
|
|
331
|
+
// Bare header (no ANSI, no cursor glyph) drives the park-position math.
|
|
332
|
+
const headerPlain = ` ◆ ${question} ${label}: ${filterText}`;
|
|
333
|
+
// Park only when the header fits one row — wrapped rows break the math.
|
|
334
|
+
const canPark = showInput && visualRows(headerPlain, width) === 1 && headerPlain.length + 1 < width;
|
|
335
|
+
|
|
336
|
+
if (showInput) {
|
|
337
|
+
const glyph = canPark ? "" : `${CYAN}▌${RESET}`;
|
|
338
|
+
out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}${label}:${RESET} ${filterText}${glyph}`);
|
|
339
|
+
} else {
|
|
340
|
+
out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}`);
|
|
341
|
+
}
|
|
263
342
|
out.push("");
|
|
264
343
|
for (const line of lines) out.push(line);
|
|
265
344
|
if (help) {
|
|
@@ -268,11 +347,17 @@ function renderPicker({ question, filterText, lines, help }) {
|
|
|
268
347
|
}
|
|
269
348
|
const text = out.join("\n") + "\n";
|
|
270
349
|
process.stdout.write(text);
|
|
271
|
-
const
|
|
272
|
-
|
|
350
|
+
const rows = out.reduce((sum, line) => sum + visualRows(line, width), 0);
|
|
351
|
+
_parkedRows = 0;
|
|
352
|
+
if (canPark) {
|
|
353
|
+
process.stdout.write(`\x1b[${rows}A\x1b[${headerPlain.length + 1}G`);
|
|
354
|
+
_parkedRows = rows;
|
|
355
|
+
}
|
|
356
|
+
return rows;
|
|
273
357
|
}
|
|
274
358
|
|
|
275
359
|
function clearLines(n) {
|
|
360
|
+
unparkCursor();
|
|
276
361
|
if (n <= 0) return;
|
|
277
362
|
// Move up n lines and clear from cursor to end of screen.
|
|
278
363
|
process.stdout.write(`\x1b[${n}A\x1b[0J`);
|
|
@@ -289,24 +374,33 @@ function filterOptions(options, filterText, getText) {
|
|
|
289
374
|
return result;
|
|
290
375
|
}
|
|
291
376
|
|
|
377
|
+
// ── back navigation ─────────────────────────────────────────────────
|
|
378
|
+
//
|
|
379
|
+
// Multi-step flows (the founder setup) let the user step backwards with
|
|
380
|
+
// esc. Pickers and prompts opt in via `escAction: "back"` — esc then
|
|
381
|
+
// resolves to the BACK sentinel instead of aborting the process, and the
|
|
382
|
+
// caller's step machine rewinds. Ctrl-C always remains the hard abort.
|
|
383
|
+
const BACK = Symbol.for("egregore.ui.back");
|
|
384
|
+
|
|
292
385
|
// ── choose: interactive single-select picker ────────────────────────
|
|
293
386
|
//
|
|
294
387
|
// options: [{ label, description }]
|
|
295
|
-
// returns: the picked option (same object reference as in options[])
|
|
388
|
+
// returns: the picked option (same object reference as in options[]),
|
|
389
|
+
// or BACK when opts.escAction === "back" and the user pressed esc
|
|
296
390
|
// keys:
|
|
297
391
|
// ↑ ↓ navigate
|
|
298
392
|
// enter select
|
|
299
393
|
// type filter (case-insensitive substring match on label + desc)
|
|
300
394
|
// backspace pop filter
|
|
301
|
-
// esc cancel
|
|
395
|
+
// esc cancel — or go back with opts.escAction: "back"
|
|
302
396
|
// ctrl-c abort process
|
|
303
397
|
|
|
304
|
-
async function choose(question, options, hint) {
|
|
305
|
-
if (!canInteract()) return legacyChoose(question, options, hint);
|
|
306
|
-
return interactiveChoose(question, options, hint);
|
|
398
|
+
async function choose(question, options, hint, opts = {}) {
|
|
399
|
+
if (!canInteract()) return legacyChoose(question, options, hint, opts);
|
|
400
|
+
return interactiveChoose(question, options, hint, opts);
|
|
307
401
|
}
|
|
308
402
|
|
|
309
|
-
async function interactiveChoose(question, options, hint) {
|
|
403
|
+
async function interactiveChoose(question, options, hint, opts = {}) {
|
|
310
404
|
return new Promise((resolve) => {
|
|
311
405
|
let filterText = "";
|
|
312
406
|
let visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
|
|
@@ -363,12 +457,13 @@ async function interactiveChoose(question, options, hint) {
|
|
|
363
457
|
for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
|
|
364
458
|
if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
|
|
365
459
|
}
|
|
460
|
+
const escHint = opts.escAction === "back" ? `${DIM}esc${RESET} back` : `${DIM}esc${RESET} cancel`;
|
|
366
461
|
if (!first) clearLines(lastLineCount);
|
|
367
462
|
lastLineCount = renderPicker({
|
|
368
463
|
question,
|
|
369
464
|
filterText,
|
|
370
465
|
lines,
|
|
371
|
-
help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${
|
|
466
|
+
help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${escHint}`,
|
|
372
467
|
});
|
|
373
468
|
};
|
|
374
469
|
|
|
@@ -385,10 +480,31 @@ async function interactiveChoose(question, options, hint) {
|
|
|
385
480
|
resolve(value);
|
|
386
481
|
};
|
|
387
482
|
|
|
483
|
+
const goBack = () => {
|
|
484
|
+
cleanup();
|
|
485
|
+
clearLines(lastLineCount);
|
|
486
|
+
process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
|
|
487
|
+
resolve(BACK);
|
|
488
|
+
};
|
|
489
|
+
|
|
388
490
|
const onKey = (_str, key) => {
|
|
389
491
|
if (!key) return;
|
|
492
|
+
if (key.name === "escape" && filterText) {
|
|
493
|
+
// fzf convention: first esc clears an active filter; only a clean
|
|
494
|
+
// state goes back / cancels.
|
|
495
|
+
filterText = "";
|
|
496
|
+
visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
|
|
497
|
+
cursor = firstEnabled();
|
|
498
|
+
render();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (key.name === "escape" && opts.escAction === "back") {
|
|
502
|
+
goBack();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
390
505
|
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
391
506
|
cleanup();
|
|
507
|
+
unparkCursor();
|
|
392
508
|
process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
|
|
393
509
|
process.exit(130);
|
|
394
510
|
}
|
|
@@ -430,7 +546,7 @@ async function interactiveChoose(question, options, hint) {
|
|
|
430
546
|
});
|
|
431
547
|
}
|
|
432
548
|
|
|
433
|
-
async function legacyChoose(question, options, hint) {
|
|
549
|
+
async function legacyChoose(question, options, hint, opts = {}) {
|
|
434
550
|
process.stdout.write(`\n ${CYAN}◆${RESET} ${BOLD}${question}${RESET}\n\n`);
|
|
435
551
|
const labelWidth = Math.min(40, Math.max(...options.map((o) => o.label.length)));
|
|
436
552
|
for (let i = 0; i < options.length; i++) {
|
|
@@ -441,8 +557,10 @@ async function legacyChoose(question, options, hint) {
|
|
|
441
557
|
process.stdout.write(` ${DIM}${num})${RESET} ${BOLD}${paddedLabel}${RESET}${desc}\n`);
|
|
442
558
|
}
|
|
443
559
|
if (hint) process.stdout.write(`\n ${DIM}${hint}${RESET}\n`);
|
|
560
|
+
if (opts.escAction === "back") process.stdout.write(` ${DIM}type "back" to go back${RESET}\n`);
|
|
444
561
|
while (true) {
|
|
445
562
|
const answer = await prompt(`pick ${DIM}[1-${options.length}]${RESET}:`);
|
|
563
|
+
if (opts.escAction === "back" && /^(b|back)$/i.test(answer)) return BACK;
|
|
446
564
|
if (!answer) { warn(`Enter a number between 1 and ${options.length}.`); continue; }
|
|
447
565
|
const n = parseInt(answer, 10);
|
|
448
566
|
if (n >= 1 && n <= options.length) {
|
|
@@ -461,58 +579,146 @@ async function legacyChoose(question, options, hint) {
|
|
|
461
579
|
// ── multiSelect: interactive multi-select picker ────────────────────
|
|
462
580
|
//
|
|
463
581
|
// options: [{ name, language?, description? }]
|
|
582
|
+
// opts.remoteSearch: async (term) => [{ name, language?, description? }]
|
|
583
|
+
// Hybrid search source: typing filters the local pool instantly AND
|
|
584
|
+
// (debounced) queries the remote source, merging new results into the
|
|
585
|
+
// pool. Lets a picker seeded with a bounded window (e.g. the 500 most
|
|
586
|
+
// recently updated repos of a huge org) reach everything the API can
|
|
587
|
+
// search — no pagination, no separate "type the exact name" prompt.
|
|
464
588
|
// returns: array of picked names
|
|
465
589
|
// keys:
|
|
466
590
|
// ↑ ↓ navigate
|
|
467
591
|
// space toggle current
|
|
468
|
-
// a
|
|
592
|
+
// ctrl-a toggle all (respecting filter)
|
|
469
593
|
// enter confirm
|
|
470
594
|
// type filter
|
|
471
595
|
// esc confirm with current selection
|
|
472
596
|
|
|
473
|
-
async function multiSelect(question, options) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return
|
|
597
|
+
async function multiSelect(question, options, opts = {}) {
|
|
598
|
+
// Remote search can populate an empty pool, but only interactively —
|
|
599
|
+
// the legacy numbered fallback has no live search.
|
|
600
|
+
if (!canInteract()) return options.length === 0 ? [] : legacyMultiSelect(question, options);
|
|
601
|
+
if (options.length === 0 && !opts.remoteSearch) return [];
|
|
602
|
+
return interactiveMultiSelect(question, options, opts);
|
|
477
603
|
}
|
|
478
604
|
|
|
479
|
-
|
|
605
|
+
const MULTISELECT_MAX_ROWS = 12;
|
|
606
|
+
const REMOTE_SEARCH_DEBOUNCE_MS = 300;
|
|
607
|
+
|
|
608
|
+
async function interactiveMultiSelect(question, options, opts = {}) {
|
|
480
609
|
return new Promise((resolve) => {
|
|
481
610
|
let cursor = 0;
|
|
482
611
|
let filterText = "";
|
|
483
|
-
|
|
484
|
-
|
|
612
|
+
// opts.preselected re-arms a previous selection — used when the step
|
|
613
|
+
// machine navigates back to the picker so choices aren't lost.
|
|
614
|
+
const picked = new Set((opts.preselected || []).filter((n) => options.some((o) => o.name === n)));
|
|
615
|
+
// The pool grows as remote results merge in; seed order is preserved so
|
|
616
|
+
// boosted entries (e.g. the user's team repos) stay on top when idle.
|
|
617
|
+
const pool = [...options];
|
|
618
|
+
const poolNames = new Set(pool.map((o) => o.name));
|
|
619
|
+
const optText = (o) => `${o.name} ${o.description || ""} ${o.language || ""}`;
|
|
620
|
+
let visible = filterOptions(pool, filterText, optText);
|
|
485
621
|
let lastLineCount = 0;
|
|
622
|
+
let searchTimer = null;
|
|
623
|
+
let searching = false;
|
|
486
624
|
|
|
487
|
-
const nameWidth =
|
|
625
|
+
const nameWidth = () =>
|
|
626
|
+
Math.min(36, Math.max(12, ...pool.map((o) => (o.name || "").length)));
|
|
488
627
|
|
|
489
628
|
const render = (first = false) => {
|
|
490
|
-
const
|
|
629
|
+
const w = nameWidth();
|
|
630
|
+
const renderRow = (entry, i) => {
|
|
491
631
|
const { opt } = entry;
|
|
492
632
|
const isCursor = i === cursor;
|
|
493
633
|
const isPicked = picked.has(opt.name);
|
|
494
634
|
const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
|
|
495
635
|
const checkbox = isPicked ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
|
|
496
|
-
const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name,
|
|
636
|
+
const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, w)}${RESET}` : pad(opt.name, w);
|
|
497
637
|
const lang = opt.language ? ` ${DIM}(${opt.language})${RESET}` : "";
|
|
498
638
|
const desc = opt.description ? ` ${DIM}${opt.description}${RESET}` : "";
|
|
499
639
|
return ` ${cursorMark} ${checkbox} ${name}${lang}${desc}`;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Bounded viewport that follows the cursor — a huge org must not dump
|
|
643
|
+
// hundreds of rows into the terminal. "↑/↓ N more" mark the remainder.
|
|
644
|
+
const lines = [];
|
|
645
|
+
const total = visible.length;
|
|
646
|
+
let start = 0;
|
|
647
|
+
if (total > MULTISELECT_MAX_ROWS) {
|
|
648
|
+
start = Math.min(
|
|
649
|
+
Math.max(0, cursor - Math.floor(MULTISELECT_MAX_ROWS / 2)),
|
|
650
|
+
total - MULTISELECT_MAX_ROWS,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const end = Math.min(total, start + MULTISELECT_MAX_ROWS);
|
|
654
|
+
if (start > 0) lines.push(` ${DIM}↑ ${start} more${RESET}`);
|
|
655
|
+
for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
|
|
656
|
+
if (end < total) lines.push(` ${DIM}↓ ${total - end} more${RESET}`);
|
|
657
|
+
if (total === 0) {
|
|
658
|
+
lines.push(searching ? ` ${DIM}searching…${RESET}` : ` ${DIM}(no matches)${RESET}`);
|
|
503
659
|
}
|
|
660
|
+
|
|
504
661
|
lines.push("");
|
|
505
|
-
|
|
662
|
+
// Selected tray: picked names stay visible even when the current
|
|
663
|
+
// filter hides their rows — a selection must never look lost.
|
|
664
|
+
if (picked.size > 0) {
|
|
665
|
+
const names = [...picked].join(", ");
|
|
666
|
+
const maxW = Math.max(20, (process.stdout.columns || 80) - 20);
|
|
667
|
+
const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
|
|
668
|
+
lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
|
|
669
|
+
} else {
|
|
670
|
+
lines.push(` ${DIM}0 selected${RESET}`);
|
|
671
|
+
}
|
|
672
|
+
if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
|
|
673
|
+
|
|
674
|
+
const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
|
|
675
|
+
const escHint = opts.escAction === "back" ? ` · ${DIM}esc${RESET} back` : "";
|
|
506
676
|
if (!first) clearLines(lastLineCount);
|
|
507
677
|
lastLineCount = renderPicker({
|
|
508
678
|
question,
|
|
509
679
|
filterText,
|
|
510
680
|
lines,
|
|
511
|
-
|
|
681
|
+
input: { label: opts.remoteSearch ? "search" : "filter" },
|
|
682
|
+
help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}${escHint}`,
|
|
512
683
|
});
|
|
513
684
|
};
|
|
514
685
|
|
|
686
|
+
const refreshVisible = () => {
|
|
687
|
+
visible = filterOptions(pool, filterText, optText);
|
|
688
|
+
if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Debounced remote search: merge results into the pool, ignore stale
|
|
692
|
+
// responses (the term changed while the request was in flight).
|
|
693
|
+
const scheduleRemoteSearch = () => {
|
|
694
|
+
if (!opts.remoteSearch) return;
|
|
695
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
696
|
+
const term = filterText.trim();
|
|
697
|
+
if (term.length < 2) { searching = false; return; }
|
|
698
|
+
searching = true;
|
|
699
|
+
searchTimer = setTimeout(() => {
|
|
700
|
+
Promise.resolve(opts.remoteSearch(term))
|
|
701
|
+
.then((results) => {
|
|
702
|
+
if (filterText.trim() !== term) return; // stale
|
|
703
|
+
for (const r of results || []) {
|
|
704
|
+
if (!r || !r.name || poolNames.has(r.name)) continue;
|
|
705
|
+
poolNames.add(r.name);
|
|
706
|
+
pool.push(r);
|
|
707
|
+
}
|
|
708
|
+
searching = false;
|
|
709
|
+
refreshVisible();
|
|
710
|
+
render();
|
|
711
|
+
})
|
|
712
|
+
.catch(() => {
|
|
713
|
+
if (filterText.trim() !== term) return;
|
|
714
|
+
searching = false; // network hiccup — local filter still works
|
|
715
|
+
render();
|
|
716
|
+
});
|
|
717
|
+
}, REMOTE_SEARCH_DEBOUNCE_MS);
|
|
718
|
+
};
|
|
719
|
+
|
|
515
720
|
const cleanup = () => {
|
|
721
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
516
722
|
process.stdin.setRawMode(false);
|
|
517
723
|
process.stdin.pause();
|
|
518
724
|
process.stdin.removeListener("keypress", onKey);
|
|
@@ -544,8 +750,24 @@ async function interactiveMultiSelect(question, options) {
|
|
|
544
750
|
|
|
545
751
|
const onKey = (_str, key) => {
|
|
546
752
|
if (!key) return;
|
|
753
|
+
if (key.name === "escape" && filterText) {
|
|
754
|
+
filterText = "";
|
|
755
|
+
refreshVisible();
|
|
756
|
+
cursor = 0;
|
|
757
|
+
searching = false;
|
|
758
|
+
render();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (key.name === "escape" && opts.escAction === "back") {
|
|
762
|
+
cleanup();
|
|
763
|
+
clearLines(lastLineCount);
|
|
764
|
+
process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
|
|
765
|
+
resolve(BACK);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
547
768
|
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
548
769
|
cleanup();
|
|
770
|
+
unparkCursor();
|
|
549
771
|
process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
|
|
550
772
|
process.exit(130);
|
|
551
773
|
}
|
|
@@ -565,22 +787,27 @@ async function interactiveMultiSelect(question, options) {
|
|
|
565
787
|
render();
|
|
566
788
|
return;
|
|
567
789
|
}
|
|
568
|
-
|
|
790
|
+
// ctrl-a, not bare "a": with live type-to-search, a bare letter must
|
|
791
|
+
// always go to the filter — typing "alpha-api" on an empty filter used
|
|
792
|
+
// to toggle-all on the first keystroke.
|
|
793
|
+
if (key.ctrl && key.name === "a") {
|
|
569
794
|
toggleAll();
|
|
570
795
|
render();
|
|
571
796
|
return;
|
|
572
797
|
}
|
|
573
798
|
if (key.name === "backspace") {
|
|
574
799
|
filterText = filterText.slice(0, -1);
|
|
575
|
-
|
|
800
|
+
refreshVisible();
|
|
576
801
|
cursor = 0;
|
|
802
|
+
scheduleRemoteSearch();
|
|
577
803
|
render();
|
|
578
804
|
return;
|
|
579
805
|
}
|
|
580
806
|
if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
|
|
581
807
|
filterText += _str;
|
|
582
|
-
|
|
808
|
+
refreshVisible();
|
|
583
809
|
cursor = 0;
|
|
810
|
+
scheduleRemoteSearch();
|
|
584
811
|
render();
|
|
585
812
|
}
|
|
586
813
|
};
|
|
@@ -886,7 +1113,7 @@ module.exports = {
|
|
|
886
1113
|
banner, bannerCompact, section, note, card,
|
|
887
1114
|
info, success, warn, error, step,
|
|
888
1115
|
dim, bold, cyan, green, yellow, red,
|
|
889
|
-
spinner, prompt, choose, multiSelect, confirm,
|
|
1116
|
+
spinner, prompt, promptOrBack, choose, multiSelect, confirm, canInteract, BACK,
|
|
890
1117
|
commandBox,
|
|
891
1118
|
canShowScreens, welcomeScreen, summaryScreen,
|
|
892
1119
|
_renderWelcomeBody, _readPkgVersion,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-egregore",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.18",
|
|
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 test/launcher-refocus-redraw.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 test/multiselect-remote-search.test.js test/back-navigation.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
|
},
|
|
@@ -162,3 +162,13 @@ PROJ_HASH=$(echo -n "$SCRIPT_DIR" | md5 2>/dev/null || echo -n "$SCRIPT_DIR" | m
|
|
|
162
162
|
echo "$EGREGORE_SESSION_ID" > "$SESSION_ID_DIR/session-${PROJ_HASH}.id"
|
|
163
163
|
# Also write project-local file (CLAUDE.md reads this — no glob needed)
|
|
164
164
|
echo "$EGREGORE_SESSION_ID" > "$SCRIPT_DIR/.egregore-session-id"
|
|
165
|
+
|
|
166
|
+
# Model stamp hashes the git common-dir root because telemetry.sh resolves it that way.
|
|
167
|
+
MODEL_REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || MODEL_REPO_ROOT="$SCRIPT_DIR"
|
|
168
|
+
MODEL_PROJ_HASH=$(echo -n "$MODEL_REPO_ROOT" | md5 2>/dev/null || echo -n "$MODEL_REPO_ROOT" | md5sum 2>/dev/null | cut -d' ' -f1)
|
|
169
|
+
MODEL_STAMP_FILE="$SESSION_ID_DIR/session-model-${MODEL_PROJ_HASH}"
|
|
170
|
+
if [ -n "${CLAUDE_MODEL:-}" ]; then
|
|
171
|
+
echo "$CLAUDE_MODEL" > "$MODEL_STAMP_FILE"
|
|
172
|
+
else
|
|
173
|
+
rm -f "$MODEL_STAMP_FILE"
|
|
174
|
+
fi
|
|
@@ -98,6 +98,13 @@ _resolve_org() {
|
|
|
98
98
|
fi
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
_resolve_proj_hash() {
|
|
102
|
+
# Use git common dir so worktrees hash to the same path as the main checkout
|
|
103
|
+
local repo_root
|
|
104
|
+
repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
|
|
105
|
+
echo -n "$repo_root" | md5 2>/dev/null || echo -n "$repo_root" | md5sum 2>/dev/null | cut -d' ' -f1
|
|
106
|
+
}
|
|
107
|
+
|
|
101
108
|
_resolve_session_id() {
|
|
102
109
|
if [ -n "${EGREGORE_SESSION_ID:-}" ]; then
|
|
103
110
|
echo "$EGREGORE_SESSION_ID"
|
|
@@ -106,11 +113,8 @@ _resolve_session_id() {
|
|
|
106
113
|
|
|
107
114
|
# Fallback: read from file written by session-start.sh
|
|
108
115
|
# (env vars from hooks don't propagate into the Claude Code agent)
|
|
109
|
-
# Use git common dir so worktrees hash to the same path as the main checkout
|
|
110
|
-
local repo_root
|
|
111
|
-
repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
|
|
112
116
|
local proj_hash
|
|
113
|
-
proj_hash=$(
|
|
117
|
+
proj_hash=$(_resolve_proj_hash)
|
|
114
118
|
local sid_file="$HOME/.egregore/session-${proj_hash}.id"
|
|
115
119
|
if [ -f "$sid_file" ]; then
|
|
116
120
|
cat "$sid_file" 2>/dev/null || echo "unknown"
|
|
@@ -119,6 +123,45 @@ _resolve_session_id() {
|
|
|
119
123
|
fi
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
_resolve_model() {
|
|
127
|
+
local proj_hash
|
|
128
|
+
proj_hash=$(_resolve_proj_hash)
|
|
129
|
+
local model_file="$HOME/.egregore/session-model-${proj_hash}"
|
|
130
|
+
if [ ! -s "$model_file" ]; then
|
|
131
|
+
return 0
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
local model=""
|
|
135
|
+
IFS= read -r model < "$model_file" || true
|
|
136
|
+
if [ -n "$model" ]; then
|
|
137
|
+
printf '%s\n' "$model"
|
|
138
|
+
fi
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_stamp_model_if_absent() {
|
|
142
|
+
local compact_data="$1"
|
|
143
|
+
local has_model=""
|
|
144
|
+
has_model=$(printf '%s' "$compact_data" | jq -e 'has("model")' 2>/dev/null || true)
|
|
145
|
+
if [ "$has_model" != "false" ]; then
|
|
146
|
+
printf '%s' "$compact_data"
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
local model=""
|
|
151
|
+
model=$(_resolve_model 2>/dev/null || true)
|
|
152
|
+
if [ -z "$model" ]; then
|
|
153
|
+
printf '%s' "$compact_data"
|
|
154
|
+
return 0
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
local stamped_data=""
|
|
158
|
+
if stamped_data=$(printf '%s' "$compact_data" | jq -c --arg m "$model" 'if has("model") then . else . + {model:$m} end' 2>/dev/null); then
|
|
159
|
+
printf '%s' "$stamped_data"
|
|
160
|
+
else
|
|
161
|
+
printf '%s' "$compact_data"
|
|
162
|
+
fi
|
|
163
|
+
}
|
|
164
|
+
|
|
122
165
|
# --- emit: append event to local JSONL buffer ---
|
|
123
166
|
cmd_emit() {
|
|
124
167
|
local event_type="${1:?Usage: telemetry.sh emit <type> <json>}"
|
|
@@ -129,12 +172,15 @@ cmd_emit() {
|
|
|
129
172
|
return 0
|
|
130
173
|
fi
|
|
131
174
|
|
|
175
|
+
local ts
|
|
176
|
+
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
177
|
+
# Compact data to single line — callers may pass pretty-printed JSON
|
|
178
|
+
local compact_data
|
|
179
|
+
compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
|
|
180
|
+
compact_data=$(_stamp_model_if_absent "$compact_data")
|
|
181
|
+
|
|
132
182
|
# Debug mode: print to stderr instead of writing
|
|
133
183
|
if _is_debug; then
|
|
134
|
-
local ts
|
|
135
|
-
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
136
|
-
local compact_data
|
|
137
|
-
compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
|
|
138
184
|
printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}\n' \
|
|
139
185
|
"$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data" >&2
|
|
140
186
|
return 0
|
|
@@ -144,11 +190,6 @@ cmd_emit() {
|
|
|
144
190
|
mkdir -p "$BUFFER_DIR"
|
|
145
191
|
|
|
146
192
|
# Build event line (outside lock for minimal hold time)
|
|
147
|
-
local ts
|
|
148
|
-
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
149
|
-
# Compact data to single line — callers may pass pretty-printed JSON
|
|
150
|
-
local compact_data
|
|
151
|
-
compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
|
|
152
193
|
local line
|
|
153
194
|
line=$(printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}' \
|
|
154
195
|
"$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data")
|