create-egregore 0.18.15 → 0.18.17
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 +146 -31
- package/lib/ui.js +149 -26
- package/package.json +5 -5
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) {
|
|
@@ -709,24 +755,67 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
709
755
|
? ctx.prefilledRepo.name
|
|
710
756
|
: null;
|
|
711
757
|
|
|
712
|
-
// Multi-select repos to manage
|
|
758
|
+
// Multi-select repos to manage. The org-wide listing is capped at the
|
|
759
|
+
// 500 most recently updated repos, which buries a team's repos in a big
|
|
760
|
+
// org — so the user's team repos are loaded too and boosted to the top,
|
|
761
|
+
// and typing in the picker live-searches the WHOLE org (see
|
|
762
|
+
// searchOrgRepos), so no repo is unreachable.
|
|
713
763
|
const s0 = ui.spinner("Loading repos...");
|
|
764
|
+
let repos = [];
|
|
765
|
+
let teamRepoCount = 0;
|
|
714
766
|
try {
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
767
|
+
const [orgRepos, teams] = await Promise.all([
|
|
768
|
+
listOrgRepos(githubToken, githubOrg, isOrg),
|
|
769
|
+
isOrg ? listUserTeams(githubToken, githubOrg) : Promise.resolve([]),
|
|
770
|
+
]);
|
|
771
|
+
const teamRepoLists = await Promise.all(
|
|
772
|
+
teams.map((t) => listTeamRepos(githubToken, githubOrg, t.slug).catch(() => [])),
|
|
773
|
+
);
|
|
774
|
+
const seen = new Set();
|
|
775
|
+
for (const list of teamRepoLists) {
|
|
776
|
+
for (const r of list) {
|
|
777
|
+
if (seen.has(r.name)) continue;
|
|
778
|
+
seen.add(r.name);
|
|
779
|
+
repos.push(r);
|
|
722
780
|
}
|
|
723
|
-
selectedRepos = await ui.multiSelect("Which other repos should Egregore manage?", filtered);
|
|
724
|
-
} else {
|
|
725
|
-
ui.info("No repos found yet.");
|
|
726
781
|
}
|
|
782
|
+
teamRepoCount = repos.length;
|
|
783
|
+
for (const r of orgRepos) {
|
|
784
|
+
if (seen.has(r.name)) continue;
|
|
785
|
+
seen.add(r.name);
|
|
786
|
+
repos.push(r);
|
|
787
|
+
}
|
|
788
|
+
s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
|
|
727
789
|
} catch {
|
|
728
790
|
s0.stop("Could not load repos");
|
|
729
791
|
}
|
|
792
|
+
|
|
793
|
+
const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
|
|
794
|
+
if (autoAdopt) {
|
|
795
|
+
console.log("");
|
|
796
|
+
ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
|
|
797
|
+
}
|
|
798
|
+
selectedRepos = await ui.multiSelect(
|
|
799
|
+
"Which other repos should Egregore manage?",
|
|
800
|
+
filtered,
|
|
801
|
+
{ remoteSearch: (term) => searchOrgRepos(githubToken, githubOrg, isOrg, term) },
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
// Non-interactive terminals get no live search — keep a typed-names
|
|
805
|
+
// escape hatch there, validated so a typo doesn't become a ghost repo.
|
|
806
|
+
if (!(ui.canInteract && ui.canInteract())) {
|
|
807
|
+
const extraRaw = await ui.prompt("any repos not listed? exact names, comma-separated (Enter to skip)");
|
|
808
|
+
for (const raw of (extraRaw || "").split(",")) {
|
|
809
|
+
const name = raw.trim();
|
|
810
|
+
if (!name || selectedRepos.includes(name)) continue;
|
|
811
|
+
if (await repoExists(githubToken, githubOrg, name)) {
|
|
812
|
+
selectedRepos.push(name);
|
|
813
|
+
ui.success(`Added ${name}`);
|
|
814
|
+
} else {
|
|
815
|
+
ui.warn(`No repo ${githubOrg}/${name} — skipping`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
730
819
|
if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
|
|
731
820
|
selectedRepos.unshift(autoAdopt);
|
|
732
821
|
}
|
|
@@ -756,17 +845,39 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
756
845
|
}
|
|
757
846
|
}
|
|
758
847
|
|
|
759
|
-
// 6. Derive repo names from team name
|
|
760
|
-
|
|
848
|
+
// 6. Derive repo names from team name — then let the founder edit them.
|
|
849
|
+
// Creating repos with silently derived names was a repeated field
|
|
850
|
+
// complaint: founders want to choose what lands in their org.
|
|
851
|
+
const slugifyName = (s) => (s || "")
|
|
761
852
|
.toLowerCase()
|
|
762
853
|
.replace(/[^a-z0-9-]/g, "-")
|
|
763
854
|
.replace(/-+/g, "-")
|
|
764
|
-
.replace(/^-|-$/g, "")
|
|
855
|
+
.replace(/^-|-$/g, "");
|
|
856
|
+
const nameSlug = slugifyName(orgName) || "egregore";
|
|
765
857
|
// Org slug prefixed with GitHub owner — globally unique across all egregores
|
|
766
|
-
const ghPrefix = githubOrg
|
|
858
|
+
const ghPrefix = slugifyName(githubOrg);
|
|
767
859
|
const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
|
|
768
|
-
|
|
769
|
-
|
|
860
|
+
|
|
861
|
+
console.log("");
|
|
862
|
+
ui.note([
|
|
863
|
+
`Two repos will be created in ${ui.bold(githubOrg)}: the Egregore repo`,
|
|
864
|
+
"(config + skills) and its shared-memory repo. Enter keeps the defaults.",
|
|
865
|
+
]);
|
|
866
|
+
let repoName;
|
|
867
|
+
for (;;) {
|
|
868
|
+
repoName = slugifyName(await ui.prompt("Egregore repo name", { default: nameSlug }));
|
|
869
|
+
if (!repoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
|
|
870
|
+
if (newProjectRepo && repoName === newProjectRepo) { ui.warn(`"${repoName}" is already your project repo. Pick a different name.`); continue; }
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
let memoryRepoName;
|
|
874
|
+
for (;;) {
|
|
875
|
+
memoryRepoName = slugifyName(await ui.prompt("memory repo name", { default: `${repoName}-memory` }));
|
|
876
|
+
if (!memoryRepoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
|
|
877
|
+
if (memoryRepoName === repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
|
|
878
|
+
if (newProjectRepo && memoryRepoName === newProjectRepo) { ui.warn(`"${memoryRepoName}" is already your project repo. Pick a different name.`); continue; }
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
770
881
|
|
|
771
882
|
// Guard: if egregore repo already exists with a config, don't overwrite
|
|
772
883
|
console.log("");
|
|
@@ -1169,7 +1280,11 @@ async function localFounderFlow(ui, ctx = {}) {
|
|
|
1169
1280
|
...pickable.map((m) => ({ label: m.login, description: `@${githubOrg} member` })),
|
|
1170
1281
|
{ label: "Type a username", description: "invite someone not in the list" },
|
|
1171
1282
|
];
|
|
1172
|
-
const picked = await ui.choose(
|
|
1283
|
+
const picked = await ui.choose(
|
|
1284
|
+
first ? "who should we invite?" : "invite someone else?",
|
|
1285
|
+
choices,
|
|
1286
|
+
first ? "Inviting is optional — the first option skips this step." : undefined,
|
|
1287
|
+
);
|
|
1173
1288
|
if (picked.label === stopChoice.label) break;
|
|
1174
1289
|
if (picked.label === "Type a username") {
|
|
1175
1290
|
invUsername = await ui.prompt("GitHub username");
|
package/lib/ui.js
CHANGED
|
@@ -254,12 +254,37 @@ function visualRows(line, width = process.stdout.columns || 80) {
|
|
|
254
254
|
|
|
255
255
|
// Render a picker frame and return { lines: number } so the next frame can
|
|
256
256
|
// rewind and clear. Shared by choose + multiSelect.
|
|
257
|
-
|
|
257
|
+
//
|
|
258
|
+
// `input` makes the type-to-search field a first-class affordance: the
|
|
259
|
+
// labelled input renders on the question line even when empty, and the REAL
|
|
260
|
+
// terminal cursor is parked right after the typed text so it blinks where
|
|
261
|
+
// keystrokes land — users shouldn't have to discover typing by accident.
|
|
262
|
+
let _parkedRows = 0;
|
|
263
|
+
|
|
264
|
+
function unparkCursor() {
|
|
265
|
+
if (_parkedRows > 0) {
|
|
266
|
+
process.stdout.write(`\x1b[${_parkedRows}B\r`);
|
|
267
|
+
_parkedRows = 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderPicker({ question, filterText, lines, help, input }) {
|
|
258
272
|
const out = [];
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
273
|
+
const width = process.stdout.columns || 80;
|
|
274
|
+
const label = (input && input.label) || "filter";
|
|
275
|
+
const showInput = !!input || !!filterText;
|
|
276
|
+
|
|
277
|
+
// Bare header (no ANSI, no cursor glyph) drives the park-position math.
|
|
278
|
+
const headerPlain = ` ◆ ${question} ${label}: ${filterText}`;
|
|
279
|
+
// Park only when the header fits one row — wrapped rows break the math.
|
|
280
|
+
const canPark = showInput && visualRows(headerPlain, width) === 1 && headerPlain.length + 1 < width;
|
|
281
|
+
|
|
282
|
+
if (showInput) {
|
|
283
|
+
const glyph = canPark ? "" : `${CYAN}▌${RESET}`;
|
|
284
|
+
out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}${label}:${RESET} ${filterText}${glyph}`);
|
|
285
|
+
} else {
|
|
286
|
+
out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}`);
|
|
287
|
+
}
|
|
263
288
|
out.push("");
|
|
264
289
|
for (const line of lines) out.push(line);
|
|
265
290
|
if (help) {
|
|
@@ -268,11 +293,17 @@ function renderPicker({ question, filterText, lines, help }) {
|
|
|
268
293
|
}
|
|
269
294
|
const text = out.join("\n") + "\n";
|
|
270
295
|
process.stdout.write(text);
|
|
271
|
-
const
|
|
272
|
-
|
|
296
|
+
const rows = out.reduce((sum, line) => sum + visualRows(line, width), 0);
|
|
297
|
+
_parkedRows = 0;
|
|
298
|
+
if (canPark) {
|
|
299
|
+
process.stdout.write(`\x1b[${rows}A\x1b[${headerPlain.length + 1}G`);
|
|
300
|
+
_parkedRows = rows;
|
|
301
|
+
}
|
|
302
|
+
return rows;
|
|
273
303
|
}
|
|
274
304
|
|
|
275
305
|
function clearLines(n) {
|
|
306
|
+
unparkCursor();
|
|
276
307
|
if (n <= 0) return;
|
|
277
308
|
// Move up n lines and clear from cursor to end of screen.
|
|
278
309
|
process.stdout.write(`\x1b[${n}A\x1b[0J`);
|
|
@@ -389,6 +420,7 @@ async function interactiveChoose(question, options, hint) {
|
|
|
389
420
|
if (!key) return;
|
|
390
421
|
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
391
422
|
cleanup();
|
|
423
|
+
unparkCursor();
|
|
392
424
|
process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
|
|
393
425
|
process.exit(130);
|
|
394
426
|
}
|
|
@@ -461,58 +493,143 @@ async function legacyChoose(question, options, hint) {
|
|
|
461
493
|
// ── multiSelect: interactive multi-select picker ────────────────────
|
|
462
494
|
//
|
|
463
495
|
// options: [{ name, language?, description? }]
|
|
496
|
+
// opts.remoteSearch: async (term) => [{ name, language?, description? }]
|
|
497
|
+
// Hybrid search source: typing filters the local pool instantly AND
|
|
498
|
+
// (debounced) queries the remote source, merging new results into the
|
|
499
|
+
// pool. Lets a picker seeded with a bounded window (e.g. the 500 most
|
|
500
|
+
// recently updated repos of a huge org) reach everything the API can
|
|
501
|
+
// search — no pagination, no separate "type the exact name" prompt.
|
|
464
502
|
// returns: array of picked names
|
|
465
503
|
// keys:
|
|
466
504
|
// ↑ ↓ navigate
|
|
467
505
|
// space toggle current
|
|
468
|
-
// a
|
|
506
|
+
// ctrl-a toggle all (respecting filter)
|
|
469
507
|
// enter confirm
|
|
470
508
|
// type filter
|
|
471
509
|
// esc confirm with current selection
|
|
472
510
|
|
|
473
|
-
async function multiSelect(question, options) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return
|
|
511
|
+
async function multiSelect(question, options, opts = {}) {
|
|
512
|
+
// Remote search can populate an empty pool, but only interactively —
|
|
513
|
+
// the legacy numbered fallback has no live search.
|
|
514
|
+
if (!canInteract()) return options.length === 0 ? [] : legacyMultiSelect(question, options);
|
|
515
|
+
if (options.length === 0 && !opts.remoteSearch) return [];
|
|
516
|
+
return interactiveMultiSelect(question, options, opts);
|
|
477
517
|
}
|
|
478
518
|
|
|
479
|
-
|
|
519
|
+
const MULTISELECT_MAX_ROWS = 12;
|
|
520
|
+
const REMOTE_SEARCH_DEBOUNCE_MS = 300;
|
|
521
|
+
|
|
522
|
+
async function interactiveMultiSelect(question, options, opts = {}) {
|
|
480
523
|
return new Promise((resolve) => {
|
|
481
524
|
let cursor = 0;
|
|
482
525
|
let filterText = "";
|
|
483
526
|
const picked = new Set();
|
|
484
|
-
|
|
527
|
+
// The pool grows as remote results merge in; seed order is preserved so
|
|
528
|
+
// boosted entries (e.g. the user's team repos) stay on top when idle.
|
|
529
|
+
const pool = [...options];
|
|
530
|
+
const poolNames = new Set(pool.map((o) => o.name));
|
|
531
|
+
const optText = (o) => `${o.name} ${o.description || ""} ${o.language || ""}`;
|
|
532
|
+
let visible = filterOptions(pool, filterText, optText);
|
|
485
533
|
let lastLineCount = 0;
|
|
534
|
+
let searchTimer = null;
|
|
535
|
+
let searching = false;
|
|
486
536
|
|
|
487
|
-
const nameWidth =
|
|
537
|
+
const nameWidth = () =>
|
|
538
|
+
Math.min(36, Math.max(12, ...pool.map((o) => (o.name || "").length)));
|
|
488
539
|
|
|
489
540
|
const render = (first = false) => {
|
|
490
|
-
const
|
|
541
|
+
const w = nameWidth();
|
|
542
|
+
const renderRow = (entry, i) => {
|
|
491
543
|
const { opt } = entry;
|
|
492
544
|
const isCursor = i === cursor;
|
|
493
545
|
const isPicked = picked.has(opt.name);
|
|
494
546
|
const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
|
|
495
547
|
const checkbox = isPicked ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
|
|
496
|
-
const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name,
|
|
548
|
+
const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, w)}${RESET}` : pad(opt.name, w);
|
|
497
549
|
const lang = opt.language ? ` ${DIM}(${opt.language})${RESET}` : "";
|
|
498
550
|
const desc = opt.description ? ` ${DIM}${opt.description}${RESET}` : "";
|
|
499
551
|
return ` ${cursorMark} ${checkbox} ${name}${lang}${desc}`;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// Bounded viewport that follows the cursor — a huge org must not dump
|
|
555
|
+
// hundreds of rows into the terminal. "↑/↓ N more" mark the remainder.
|
|
556
|
+
const lines = [];
|
|
557
|
+
const total = visible.length;
|
|
558
|
+
let start = 0;
|
|
559
|
+
if (total > MULTISELECT_MAX_ROWS) {
|
|
560
|
+
start = Math.min(
|
|
561
|
+
Math.max(0, cursor - Math.floor(MULTISELECT_MAX_ROWS / 2)),
|
|
562
|
+
total - MULTISELECT_MAX_ROWS,
|
|
563
|
+
);
|
|
503
564
|
}
|
|
565
|
+
const end = Math.min(total, start + MULTISELECT_MAX_ROWS);
|
|
566
|
+
if (start > 0) lines.push(` ${DIM}↑ ${start} more${RESET}`);
|
|
567
|
+
for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
|
|
568
|
+
if (end < total) lines.push(` ${DIM}↓ ${total - end} more${RESET}`);
|
|
569
|
+
if (total === 0) {
|
|
570
|
+
lines.push(searching ? ` ${DIM}searching…${RESET}` : ` ${DIM}(no matches)${RESET}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
504
573
|
lines.push("");
|
|
505
|
-
|
|
574
|
+
// Selected tray: picked names stay visible even when the current
|
|
575
|
+
// filter hides their rows — a selection must never look lost.
|
|
576
|
+
if (picked.size > 0) {
|
|
577
|
+
const names = [...picked].join(", ");
|
|
578
|
+
const maxW = Math.max(20, (process.stdout.columns || 80) - 20);
|
|
579
|
+
const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
|
|
580
|
+
lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
|
|
581
|
+
} else {
|
|
582
|
+
lines.push(` ${DIM}0 selected${RESET}`);
|
|
583
|
+
}
|
|
584
|
+
if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
|
|
585
|
+
|
|
586
|
+
const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
|
|
506
587
|
if (!first) clearLines(lastLineCount);
|
|
507
588
|
lastLineCount = renderPicker({
|
|
508
589
|
question,
|
|
509
590
|
filterText,
|
|
510
591
|
lines,
|
|
511
|
-
|
|
592
|
+
input: { label: opts.remoteSearch ? "search" : "filter" },
|
|
593
|
+
help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}`,
|
|
512
594
|
});
|
|
513
595
|
};
|
|
514
596
|
|
|
597
|
+
const refreshVisible = () => {
|
|
598
|
+
visible = filterOptions(pool, filterText, optText);
|
|
599
|
+
if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Debounced remote search: merge results into the pool, ignore stale
|
|
603
|
+
// responses (the term changed while the request was in flight).
|
|
604
|
+
const scheduleRemoteSearch = () => {
|
|
605
|
+
if (!opts.remoteSearch) return;
|
|
606
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
607
|
+
const term = filterText.trim();
|
|
608
|
+
if (term.length < 2) { searching = false; return; }
|
|
609
|
+
searching = true;
|
|
610
|
+
searchTimer = setTimeout(() => {
|
|
611
|
+
Promise.resolve(opts.remoteSearch(term))
|
|
612
|
+
.then((results) => {
|
|
613
|
+
if (filterText.trim() !== term) return; // stale
|
|
614
|
+
for (const r of results || []) {
|
|
615
|
+
if (!r || !r.name || poolNames.has(r.name)) continue;
|
|
616
|
+
poolNames.add(r.name);
|
|
617
|
+
pool.push(r);
|
|
618
|
+
}
|
|
619
|
+
searching = false;
|
|
620
|
+
refreshVisible();
|
|
621
|
+
render();
|
|
622
|
+
})
|
|
623
|
+
.catch(() => {
|
|
624
|
+
if (filterText.trim() !== term) return;
|
|
625
|
+
searching = false; // network hiccup — local filter still works
|
|
626
|
+
render();
|
|
627
|
+
});
|
|
628
|
+
}, REMOTE_SEARCH_DEBOUNCE_MS);
|
|
629
|
+
};
|
|
630
|
+
|
|
515
631
|
const cleanup = () => {
|
|
632
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
516
633
|
process.stdin.setRawMode(false);
|
|
517
634
|
process.stdin.pause();
|
|
518
635
|
process.stdin.removeListener("keypress", onKey);
|
|
@@ -546,6 +663,7 @@ async function interactiveMultiSelect(question, options) {
|
|
|
546
663
|
if (!key) return;
|
|
547
664
|
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
548
665
|
cleanup();
|
|
666
|
+
unparkCursor();
|
|
549
667
|
process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
|
|
550
668
|
process.exit(130);
|
|
551
669
|
}
|
|
@@ -565,22 +683,27 @@ async function interactiveMultiSelect(question, options) {
|
|
|
565
683
|
render();
|
|
566
684
|
return;
|
|
567
685
|
}
|
|
568
|
-
|
|
686
|
+
// ctrl-a, not bare "a": with live type-to-search, a bare letter must
|
|
687
|
+
// always go to the filter — typing "alpha-api" on an empty filter used
|
|
688
|
+
// to toggle-all on the first keystroke.
|
|
689
|
+
if (key.ctrl && key.name === "a") {
|
|
569
690
|
toggleAll();
|
|
570
691
|
render();
|
|
571
692
|
return;
|
|
572
693
|
}
|
|
573
694
|
if (key.name === "backspace") {
|
|
574
695
|
filterText = filterText.slice(0, -1);
|
|
575
|
-
|
|
696
|
+
refreshVisible();
|
|
576
697
|
cursor = 0;
|
|
698
|
+
scheduleRemoteSearch();
|
|
577
699
|
render();
|
|
578
700
|
return;
|
|
579
701
|
}
|
|
580
702
|
if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
|
|
581
703
|
filterText += _str;
|
|
582
|
-
|
|
704
|
+
refreshVisible();
|
|
583
705
|
cursor = 0;
|
|
706
|
+
scheduleRemoteSearch();
|
|
584
707
|
render();
|
|
585
708
|
}
|
|
586
709
|
};
|
|
@@ -886,7 +1009,7 @@ module.exports = {
|
|
|
886
1009
|
banner, bannerCompact, section, note, card,
|
|
887
1010
|
info, success, warn, error, step,
|
|
888
1011
|
dim, bold, cyan, green, yellow, red,
|
|
889
|
-
spinner, prompt, choose, multiSelect, confirm,
|
|
1012
|
+
spinner, prompt, choose, multiSelect, confirm, canInteract,
|
|
890
1013
|
commandBox,
|
|
891
1014
|
canShowScreens, welcomeScreen, summaryScreen,
|
|
892
1015
|
_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.17",
|
|
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",
|
|
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
|
},
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
],
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
"url": "git+https://github.com/egregore-labs/egregore.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/egregore-labs/egregore"
|
|
36
36
|
}
|