create-egregore 0.15.0 → 0.16.0

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/api.js CHANGED
@@ -95,6 +95,13 @@ class EgregoreAPI {
95
95
  });
96
96
  }
97
97
 
98
+ async registerOrg(githubToken, { org_name, github_org, slug }) {
99
+ return request("POST", `${this.base}/api/org/register`, {
100
+ headers: { Authorization: `Bearer ${githubToken}` },
101
+ body: { org_name, github_org, slug },
102
+ });
103
+ }
104
+
98
105
  async exchangeCode(code) {
99
106
  return request("POST", `${this.base}/api/auth/github/callback`, {
100
107
  body: { code },
package/lib/local.js CHANGED
@@ -15,8 +15,10 @@ const {
15
15
  embedToken,
16
16
  configureGitCredentials,
17
17
  initMemoryDirs,
18
+ ensureDevelop,
18
19
  run,
19
20
  } = require("./setup");
21
+ const { EgregoreAPI } = require("./api");
20
22
 
21
23
  // Template repo for new egregore instances (OSS)
22
24
  const TEMPLATE_OWNER = "egregore-labs";
@@ -146,6 +148,25 @@ async function addCollaborator(token, owner, repo, username) {
146
148
  return status === 201 || status === 204;
147
149
  }
148
150
 
151
+ // ── Branch detection for existing repos ────────────────────────────
152
+
153
+ async function detectRepoBranches(token, owner, repoName) {
154
+ const { status, data } = await ghApi("GET", `/repos/${owner}/${repoName}/branches?per_page=100`, token);
155
+ if (status !== 200 || !Array.isArray(data)) return null;
156
+ const branches = data.map((b) => b.name);
157
+ // Detect default branch via repo API
158
+ const { status: repoStatus, data: repoData } = await ghApi("GET", `/repos/${owner}/${repoName}`, token);
159
+ const defaultBranch = repoStatus === 200 ? repoData.default_branch || "main" : "main";
160
+ return {
161
+ name: repoName,
162
+ defaultBranch,
163
+ hasDevelop: branches.includes("develop"),
164
+ branches,
165
+ branchCount: branches.length,
166
+ contributors: branches.filter((b) => b.startsWith("dev/")).map((b) => b.split("/")[1]).filter((v, i, a) => a.indexOf(v) === i),
167
+ };
168
+ }
169
+
149
170
  // ── Clone + local file helpers ──────────────────────────────────────
150
171
 
151
172
  function cloneRepo(url, dir, token, ui, label) {
@@ -168,6 +189,43 @@ function cloneRepo(url, dir, token, ui, label) {
168
189
  ui.success(`Cloned ${label}`);
169
190
  }
170
191
 
192
+ /**
193
+ * Convert HTTPS GitHub URL to SSH format.
194
+ * https://github.com/org/repo.git → git@github.com:org/repo.git
195
+ */
196
+ function toSSH(url) {
197
+ const m = url.match(/https:\/\/github\.com\/(.+?)(?:\.git)?$/);
198
+ return m ? `git@github.com:${m[1]}.git` : null;
199
+ }
200
+
201
+ /**
202
+ * Clone a repo, trying SSH first then HTTPS.
203
+ * GIT_TERMINAL_PROMPT=0 prevents the hanging username/password prompt.
204
+ */
205
+ function cloneWithFallback(httpsUrl, dir, timeout = 60000) {
206
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
207
+ const sshUrl = toSSH(httpsUrl);
208
+
209
+ // Try SSH first (most devs have SSH keys; GitHub no longer supports HTTPS password auth)
210
+ if (sshUrl) {
211
+ try {
212
+ execFileSync("git", ["clone", sshUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
213
+ return true;
214
+ } catch {
215
+ // Clean up partial clone so HTTPS fallback can use the same dir
216
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
217
+ }
218
+ }
219
+
220
+ // Fall back to HTTPS (works if user has gh CLI or a credential helper configured)
221
+ try {
222
+ execFileSync("git", ["clone", httpsUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
223
+ return true;
224
+ } catch {}
225
+
226
+ return false;
227
+ }
228
+
171
229
  function setGitIdentity(dir, user) {
172
230
  try {
173
231
  run(`git config user.name "${user.name}"`, { cwd: dir });
@@ -280,16 +338,42 @@ async function localFounderFlow(ui) {
280
338
  } catch {
281
339
  s0.stop("Could not load repos");
282
340
  }
341
+
342
+ // Detect default branch for each selected repo and convert to {name, base_branch} format
343
+ if (selectedRepos.length > 0) {
344
+ const s0b = ui.spinner("Checking branch setup...");
345
+ const reposWithBranch = [];
346
+ for (const repoName of selectedRepos) {
347
+ try {
348
+ const info = await detectRepoBranches(githubToken, githubOrg, repoName);
349
+ reposWithBranch.push({ name: repoName, base_branch: info ? info.defaultBranch : "main" });
350
+ } catch {
351
+ reposWithBranch.push({ name: repoName, base_branch: "main" });
352
+ }
353
+ }
354
+ s0b.stop("Checked branches");
355
+ // Replace flat list with enriched objects
356
+ selectedRepos = reposWithBranch;
357
+ console.log("");
358
+ ui.info("Detected branch setup:");
359
+ for (const repo of reposWithBranch) {
360
+ ui.info(` ${ui.cyan(repo.name)} — PRs will target ${ui.bold(repo.base_branch)}`);
361
+ }
362
+ console.log("");
363
+ }
283
364
  }
284
365
 
285
366
  // 6. Derive repo names from team name
286
- const slug = orgName
367
+ const nameSlug = orgName
287
368
  .toLowerCase()
288
369
  .replace(/[^a-z0-9-]/g, "-")
289
370
  .replace(/-+/g, "-")
290
371
  .replace(/^-|-$/g, "") || "egregore";
291
- const repoName = slug;
292
- const memoryRepoName = `${slug}-memory`;
372
+ // Org slug prefixed with GitHub owner — globally unique across all egregores
373
+ const ghPrefix = githubOrg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
374
+ const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
375
+ const repoName = nameSlug;
376
+ const memoryRepoName = `${nameSlug}-memory`;
293
377
 
294
378
  // Guard: if egregore repo already exists with a config, don't overwrite
295
379
  console.log("");
@@ -360,13 +444,13 @@ async function localFounderFlow(ui) {
360
444
  const s3 = ui.spinner(`Creating ${newProjectRepo}...`);
361
445
  const projExists = await repoExists(githubToken, githubOrg, newProjectRepo);
362
446
  if (projExists) {
363
- selectedRepos.push(newProjectRepo);
447
+ selectedRepos.push({ name: newProjectRepo });
364
448
  s3.stop(`${newProjectRepo} already exists — added to managed repos`);
365
449
  } else {
366
450
  try {
367
451
  await createRepo(githubToken, githubOrg, newProjectRepo, isOrg, description || "");
368
452
  if (await waitForRepo(githubToken, githubOrg, newProjectRepo)) {
369
- selectedRepos.push(newProjectRepo);
453
+ selectedRepos.push({ name: newProjectRepo });
370
454
  s3.stop(`Created ${newProjectRepo}`);
371
455
  } else {
372
456
  s3.fail(`${newProjectRepo} creation timed out`);
@@ -401,6 +485,15 @@ async function localFounderFlow(ui) {
401
485
  );
402
486
  } catch {}
403
487
 
488
+ // 8b. Register org with Egregore API (non-blocking — local mode works without it)
489
+ // Respects DO_NOT_TRACK (https://consult.gov.uk/digital-communications/online-advertising/supporting_documents/DNT%20standard%20v10.pdf)
490
+ if (!process.env.DO_NOT_TRACK && !process.env.EGREGORE_NO_TELEMETRY) {
491
+ try {
492
+ const api = new EgregoreAPI();
493
+ await api.registerOrg(githubToken, { org_name: orgName, github_org: githubOrg, slug });
494
+ } catch {}
495
+ }
496
+
404
497
  // 9. Init memory dirs on remote (so joiners get the structure)
405
498
  const memDirs = ["people", "handoffs", "knowledge/decisions", "knowledge/patterns", "knowledge/findings", "quests", "wraps"];
406
499
  for (const d of memDirs) {
@@ -422,6 +515,7 @@ async function localFounderFlow(ui) {
422
515
  ui.step(1, totalSteps, "Cloning egregore...");
423
516
  cloneRepo(forkUrl, egregoreDir, githubToken, ui, "egregore");
424
517
  setGitIdentity(egregoreDir, user);
518
+ ensureDevelop(egregoreDir);
425
519
 
426
520
  ui.step(2, totalSteps, "Cloning shared memory...");
427
521
  cloneRepo(memoryUrl, memoryDir, githubToken, ui, "memory");
@@ -482,15 +576,20 @@ async function localFounderFlow(ui) {
482
576
 
483
577
  // 16. Clone managed repos
484
578
  for (let i = 0; i < selectedRepos.length; i++) {
485
- const mr = selectedRepos[i];
486
- ui.step(5 + i, totalSteps, `Cloning ${mr}...`);
487
- const repoDir = path.join(base, mr);
579
+ const entry = selectedRepos[i];
580
+ const mrName = typeof entry === "string" ? entry : entry.name;
581
+ ui.step(5 + i, totalSteps, `Cloning ${mrName}...`);
582
+ const repoDir = path.join(base, mrName);
488
583
  try {
489
- const repoUrl = `https://github.com/${githubOrg}/${mr}.git`;
490
- cloneRepo(repoUrl, repoDir, githubToken, ui, mr);
584
+ const repoUrl = `https://github.com/${githubOrg}/${mrName}.git`;
585
+ cloneRepo(repoUrl, repoDir, githubToken, ui, mrName);
491
586
  setGitIdentity(repoDir, user);
587
+ // New repos get develop branch (our workflow). Existing repos keep their default.
588
+ if (isNewProject) {
589
+ ensureDevelop(repoDir);
590
+ }
492
591
  } catch {
493
- ui.warn(`Could not clone ${mr} — you may not have access.`);
592
+ ui.warn(`Could not clone ${mrName} — you may not have access.`);
494
593
  }
495
594
  }
496
595
 
@@ -508,8 +607,9 @@ async function localFounderFlow(ui) {
508
607
  // Add as collaborator to all repos
509
608
  await addCollaborator(githubToken, githubOrg, repoName, invUsername);
510
609
  await addCollaborator(githubToken, githubOrg, memoryRepoName, invUsername);
511
- for (const mr of selectedRepos) {
512
- await addCollaborator(githubToken, githubOrg, mr, invUsername).catch(() => {});
610
+ for (const entry of selectedRepos) {
611
+ const mrName = typeof entry === "string" ? entry : entry.name;
612
+ await addCollaborator(githubToken, githubOrg, mrName, invUsername).catch(() => {});
513
613
  }
514
614
 
515
615
  // Create person file
@@ -548,8 +648,9 @@ async function localFounderFlow(ui) {
548
648
  ui.info("Your workspace:");
549
649
  ui.info(` ${ui.cyan(`./${repoName}/`)} — Your Egregore instance`);
550
650
  ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
551
- for (const mr of selectedRepos) {
552
- ui.info(` ${ui.cyan(`./${mr}/`)} — Managed repo`);
651
+ for (const entry of selectedRepos) {
652
+ const mrName = typeof entry === "string" ? entry : entry.name;
653
+ ui.info(` ${ui.cyan(`./${mrName}/`)} — Managed repo`);
553
654
  }
554
655
  console.log("");
555
656
  ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
@@ -602,16 +703,16 @@ async function localJoinFlow(orgArg, ui) {
602
703
 
603
704
  console.log("");
604
705
  const s2 = ui.spinner(`Cloning ${orgLogin}/${repoName}...`);
605
- try {
606
- execFileSync("git", ["clone", `https://github.com/${orgLogin}/${repoName}.git`, egregoreDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
706
+ if (cloneWithFallback(`https://github.com/${orgLogin}/${repoName}.git`, egregoreDir)) {
607
707
  s2.stop(`Cloned ${repoName}`);
608
- } catch (err) {
708
+ } else {
609
709
  s2.fail(`Could not clone ${orgLogin}/${repoName}`);
610
710
  ui.error("Make sure you have git credentials set up (SSH keys or gh auth login).");
611
711
  ui.error("Also verify the org admin has set up Egregore and invited you.");
612
712
  process.exit(1);
613
713
  }
614
714
  setGitIdentity(egregoreDir, user);
715
+ ensureDevelop(egregoreDir);
615
716
 
616
717
  // 6. Read egregore.json from local clone
617
718
  const configPath = path.join(egregoreDir, "egregore.json");
@@ -642,12 +743,11 @@ async function localJoinFlow(orgArg, ui) {
642
743
  // Store the invite token for later use (invitation acceptance, not cloning)
643
744
  const inviteToken = githubToken;
644
745
 
645
- // Clone memory using git credentials (same as main repo — no token in URL)
746
+ // Clone memory using SSH-first fallback (same as main repo)
646
747
  ui.step(1, totalSteps, "Cloning shared memory...");
647
- try {
648
- execFileSync("git", ["clone", memoryUrl, memoryDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
748
+ if (cloneWithFallback(memoryUrl, memoryDir)) {
649
749
  ui.success("Cloned memory");
650
- } catch {
750
+ } else {
651
751
  ui.warn("Could not clone memory repo — you may not have access yet.");
652
752
  }
653
753
  setGitIdentity(memoryDir, user);
@@ -683,17 +783,17 @@ async function localJoinFlow(orgArg, ui) {
683
783
  registerInstance(repoName, orgName, egregoreDir);
684
784
  const alias = await installShellAlias(egregoreDir, ui);
685
785
 
686
- // Clone managed repos using git credentials
786
+ // Clone managed repos using SSH-first fallback
687
787
  for (let i = 0; i < managedRepos.length; i++) {
688
- const mr = managedRepos[i];
689
- ui.step(5 + i, totalSteps, `Cloning ${mr}...`);
690
- const repoDir = path.join(base, mr);
691
- try {
692
- execFileSync("git", ["clone", `https://github.com/${orgLogin}/${mr}.git`, repoDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
788
+ const entry = managedRepos[i];
789
+ const mrName = typeof entry === "string" ? entry : entry.name;
790
+ ui.step(5 + i, totalSteps, `Cloning ${mrName}...`);
791
+ const repoDir = path.join(base, mrName);
792
+ if (cloneWithFallback(`https://github.com/${orgLogin}/${mrName}.git`, repoDir)) {
693
793
  setGitIdentity(repoDir, user);
694
- ui.success(`Cloned ${mr}`);
695
- } catch {
696
- ui.warn(`Could not clone ${mr} — you may not have access yet.`);
794
+ ui.success(`Cloned ${mrName}`);
795
+ } else {
796
+ ui.warn(`Could not clone ${mrName} — you may not have access yet.`);
697
797
  }
698
798
  }
699
799
 
@@ -727,8 +827,9 @@ async function localJoinFlow(orgArg, ui) {
727
827
  ui.info("Your workspace:");
728
828
  ui.info(` ${ui.cyan(`./${repoName}/`)} — Egregore instance`);
729
829
  ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
730
- for (const mr of managedRepos) {
731
- ui.info(` ${ui.cyan(`./${mr}/`)} — Managed repo`);
830
+ for (const entry of managedRepos) {
831
+ const mrName = typeof entry === "string" ? entry : entry.name;
832
+ ui.info(` ${ui.cyan(`./${mrName}/`)} — Managed repo`);
732
833
  }
733
834
  console.log("");
734
835
  ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
package/lib/setup.js CHANGED
@@ -138,6 +138,7 @@ async function install(data, ui, targetDir) {
138
138
  try { run(`git remote set-url origin ${fork_url}`, { cwd: egregoreDir }); } catch {}
139
139
  }
140
140
  try { run("git config credential.helper store", { cwd: egregoreDir }); } catch {}
141
+ ensureDevelop(egregoreDir);
141
142
  ui.success("Cloned egregore");
142
143
 
143
144
  // Set repo-local git identity from GitHub user (not machine-global config)
@@ -246,6 +247,7 @@ async function install(data, ui, targetDir) {
246
247
  try { run(`git remote set-url origin ${repoUrl}`, { cwd: repoDir }); } catch {}
247
248
  }
248
249
  try { run("git config credential.helper store", { cwd: repoDir }); } catch {}
250
+ ensureDevelop(repoDir);
249
251
  clonedRepos.push(repoName);
250
252
  ui.success(`Cloned ${repoName}`);
251
253
  } catch {
@@ -274,6 +276,51 @@ async function install(data, ui, targetDir) {
274
276
  console.log("");
275
277
  }
276
278
 
279
+ /**
280
+ * Ensure a cloned repo has a develop branch. Creates from default branch and pushes if missing.
281
+ * Idempotent — safe to call on repos that already have develop.
282
+ */
283
+ function ensureDevelop(repoDir) {
284
+ try {
285
+ // Check if develop already exists on remote
286
+ const remoteRefs = execFileSync("git", ["-C", repoDir, "ls-remote", "--heads", "origin", "develop"], {
287
+ stdio: "pipe", encoding: "utf-8", timeout: 15000,
288
+ }).trim();
289
+ if (remoteRefs) return; // develop already exists on remote
290
+
291
+ // Detect default branch (main, master, or whatever origin/HEAD points to)
292
+ let defaultBranch = "";
293
+ try {
294
+ const head = execFileSync("git", ["-C", repoDir, "symbolic-ref", "refs/remotes/origin/HEAD"], {
295
+ stdio: "pipe", encoding: "utf-8", timeout: 5000,
296
+ }).trim();
297
+ defaultBranch = head.replace("refs/remotes/origin/", "");
298
+ } catch {}
299
+ if (!defaultBranch) {
300
+ for (const candidate of ["main", "master"]) {
301
+ try {
302
+ execFileSync("git", ["-C", repoDir, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], {
303
+ stdio: "pipe", timeout: 5000,
304
+ });
305
+ defaultBranch = candidate;
306
+ break;
307
+ } catch {}
308
+ }
309
+ }
310
+ if (!defaultBranch) return; // no default branch found — skip
311
+
312
+ // Create develop from default branch and push
313
+ execFileSync("git", ["-C", repoDir, "branch", "develop", `origin/${defaultBranch}`], {
314
+ stdio: "pipe", encoding: "utf-8", timeout: 5000,
315
+ });
316
+ execFileSync("git", ["-C", repoDir, "push", "-u", "origin", "develop"], {
317
+ stdio: "pipe", encoding: "utf-8", timeout: 30000,
318
+ });
319
+ } catch {
320
+ // Non-fatal — session-start.sh will retry on first launch
321
+ }
322
+ }
323
+
277
324
  function embedToken(url, token) {
278
325
  try {
279
326
  return url.replace("https://github.com/", `https://x-access-token:${token}@github.com/`);
@@ -415,5 +462,6 @@ module.exports = {
415
462
  embedToken,
416
463
  configureGitCredentials,
417
464
  initMemoryDirs,
465
+ ensureDevelop,
418
466
  run,
419
467
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Set up Egregore for your team in one command",
5
5
  "license": "MIT",
6
6
  "bin": {