create-egregore 0.18.9 → 0.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/local.js +110 -13
  2. package/package.json +1 -1
package/lib/local.js CHANGED
@@ -125,6 +125,62 @@ async function getFileContent(token, owner, repo, filePath) {
125
125
  return Buffer.from(data.content, "base64").toString("utf-8");
126
126
  }
127
127
 
128
+ // True if `owner` resolves to a real GitHub account (user OR org — both live
129
+ // under /users/{login}). Lets join distinguish "wrong owner" from "owner has
130
+ // no Egregore", which is the difference between a typo and a missing repo.
131
+ async function ownerExists(token, owner) {
132
+ const { status } = await ghApi("GET", `/users/${owner}`, token);
133
+ return status === 200;
134
+ }
135
+
136
+ // Repo names under `owner` that the authenticated user can actually reach —
137
+ // org membership or a (already-accepted) personal-repo collaborator invite.
138
+ // /user/repos is the only endpoint that honors private + collaborator repos
139
+ // across arbitrary owners, so we list it and filter by owner.
140
+ async function listAccessibleRepos(token, owner) {
141
+ const ownerLc = owner.toLowerCase();
142
+ const names = [];
143
+ let page = 1;
144
+ while (page <= 4) { // cap at 400 accessible repos
145
+ const url = `/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member&page=${page}`;
146
+ const { status, data } = await ghApi("GET", url, token);
147
+ if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
148
+ for (const r of data) {
149
+ if (r.owner?.login?.toLowerCase() === ownerLc) names.push(r.name);
150
+ }
151
+ if (data.length < 100) break;
152
+ page += 1;
153
+ }
154
+ return names;
155
+ }
156
+
157
+ // Discover every repo under `owner` that carries an egregore.json on its
158
+ // default branch. Probes the conventional name first, then Egregore-looking
159
+ // names, then the rest — capped so a large account doesn't fan out forever.
160
+ // Returns [{ repo, config }] so the caller never re-fetches the config.
161
+ async function discoverEgregoreRepos(token, owner) {
162
+ const seen = new Set();
163
+ const candidates = [];
164
+ const add = (name) => {
165
+ const key = name.toLowerCase();
166
+ if (!seen.has(key)) { seen.add(key); candidates.push(name); }
167
+ };
168
+ add("egregore"); // conventional default — also covers public repos not in the list
169
+ const accessible = await listAccessibleRepos(token, owner);
170
+ for (const n of accessible.filter((n) => /egregore/i.test(n))) add(n);
171
+ for (const n of accessible.filter((n) => !/egregore/i.test(n))) add(n);
172
+
173
+ const MAX_PROBES = 20;
174
+ const found = [];
175
+ for (const repo of candidates.slice(0, MAX_PROBES)) {
176
+ let raw = null;
177
+ try { raw = await getFileContent(token, owner, repo, "egregore.json"); } catch { raw = null; }
178
+ if (!raw) continue;
179
+ try { found.push({ repo, config: JSON.parse(raw) }); } catch { /* malformed config — skip */ }
180
+ }
181
+ return found;
182
+ }
183
+
128
184
  async function putFileContent(token, owner, repo, filePath, content, message) {
129
185
  // Get SHA if file already exists
130
186
  const { status: existStatus, data: existData } = await ghApi(
@@ -1253,22 +1309,63 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
1253
1309
  await acceptPendingInvitations(githubToken, orgLogin, ui);
1254
1310
  s1.stop("Invitations accepted");
1255
1311
 
1256
- // 4. Resolve egregore.json before cloning. The shared install() helper owns
1257
- // all local filesystem setup after this point.
1258
- const repoName = explicitRepo || "egregore";
1259
- const s2 = ui.spinner(`Reading ${orgLogin}/${repoName}/egregore.json...`);
1260
- let config;
1312
+ // 4. Resolve which repo under `orgLogin` holds egregore.json. An explicit
1313
+ // repo is read directly; a bare owner is discovered (the founder flow
1314
+ // names the repo after the org slug, NOT "egregore", so we can't assume).
1315
+ // The shared install() helper owns all local setup after this point.
1316
+ const s2 = ui.spinner(
1317
+ explicitRepo
1318
+ ? `Reading ${orgLogin}/${explicitRepo}/egregore.json...`
1319
+ : `Finding the Egregore in ${orgLogin}...`,
1320
+ );
1321
+ let candidates = [];
1261
1322
  try {
1262
- const raw = await getFileContent(githubToken, orgLogin, repoName, "egregore.json");
1263
- if (!raw) throw new Error("missing");
1264
- config = JSON.parse(raw);
1265
- s2.stop("Resolved Egregore config");
1266
- } catch (err) {
1267
- s2.fail("Could not read egregore.json");
1268
- ui.error(`Make sure ${orgLogin}/${repoName} exists and you accepted the GitHub invitation.`);
1269
- ui.info(`Ask the admin to resend: ${joinCommand(orgLogin, repoName)}`);
1323
+ if (explicitRepo) {
1324
+ const raw = await getFileContent(githubToken, orgLogin, explicitRepo, "egregore.json");
1325
+ if (raw) candidates = [{ repo: explicitRepo, config: JSON.parse(raw) }];
1326
+ } else {
1327
+ candidates = await discoverEgregoreRepos(githubToken, orgLogin);
1328
+ }
1329
+ } catch {
1330
+ candidates = [];
1331
+ }
1332
+
1333
+ if (candidates.length === 0) {
1334
+ s2.fail("Could not find an Egregore to join");
1335
+ const exists = await ownerExists(githubToken, orgLogin);
1336
+ if (!exists) {
1337
+ // The owner itself isn't a GitHub account — almost always a repo name
1338
+ // passed where owner/repo was meant (e.g. `join xenocracy` for
1339
+ // `fcdagdelen/xenocracy`).
1340
+ ui.error(`No GitHub user or organization named ${ui.bold(orgLogin)}.`);
1341
+ ui.info(`Join targets are ${ui.bold("owner/repo")} — e.g. ${ui.bold(joinCommand("acme", "acme"))}.`);
1342
+ } else if (explicitRepo) {
1343
+ ui.error(`${ui.bold(`${orgLogin}/${explicitRepo}`)} has no egregore.json on its default branch.`);
1344
+ ui.info("Confirm the repo name, that egregore.json is committed to the default branch, and that you accepted the GitHub invitation.");
1345
+ } else {
1346
+ ui.error(`No Egregore repo found under ${ui.bold(orgLogin)} that you can access.`);
1347
+ ui.info(`If you know it, pass the repo: ${ui.bold(joinCommand(orgLogin, "<repo>"))}. Otherwise ask the admin for the exact ${ui.bold("owner/repo")}.`);
1348
+ }
1270
1349
  process.exit(1);
1271
1350
  }
1351
+
1352
+ let repoName;
1353
+ let config;
1354
+ if (candidates.length === 1) {
1355
+ ({ repo: repoName, config } = candidates[0]);
1356
+ s2.stop(`Found ${orgLogin}/${repoName}`);
1357
+ } else {
1358
+ s2.stop(`Found ${candidates.length} Egregores in ${orgLogin}`);
1359
+ const picked = await ui.choose(
1360
+ `Which Egregore in ${orgLogin}?`,
1361
+ candidates.map((c) => ({
1362
+ label: `${orgLogin}/${c.repo}`,
1363
+ description: c.config.org_name || "",
1364
+ _candidate: c,
1365
+ })),
1366
+ );
1367
+ ({ repo: repoName, config } = picked._candidate);
1368
+ }
1272
1369
  ui.success(`Found ${ui.bold(config.org_name || orgLogin)}`);
1273
1370
 
1274
1371
  const orgName = config.org_name || orgLogin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.9",
3
+ "version": "0.18.10",
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": {