create-egregore 0.15.1 → 0.16.1

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) {
@@ -317,16 +338,42 @@ async function localFounderFlow(ui) {
317
338
  } catch {
318
339
  s0.stop("Could not load repos");
319
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
+ }
320
364
  }
321
365
 
322
366
  // 6. Derive repo names from team name
323
- const slug = orgName
367
+ const nameSlug = orgName
324
368
  .toLowerCase()
325
369
  .replace(/[^a-z0-9-]/g, "-")
326
370
  .replace(/-+/g, "-")
327
371
  .replace(/^-|-$/g, "") || "egregore";
328
- const repoName = slug;
329
- 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`;
330
377
 
331
378
  // Guard: if egregore repo already exists with a config, don't overwrite
332
379
  console.log("");
@@ -397,13 +444,13 @@ async function localFounderFlow(ui) {
397
444
  const s3 = ui.spinner(`Creating ${newProjectRepo}...`);
398
445
  const projExists = await repoExists(githubToken, githubOrg, newProjectRepo);
399
446
  if (projExists) {
400
- selectedRepos.push(newProjectRepo);
447
+ selectedRepos.push({ name: newProjectRepo });
401
448
  s3.stop(`${newProjectRepo} already exists — added to managed repos`);
402
449
  } else {
403
450
  try {
404
451
  await createRepo(githubToken, githubOrg, newProjectRepo, isOrg, description || "");
405
452
  if (await waitForRepo(githubToken, githubOrg, newProjectRepo)) {
406
- selectedRepos.push(newProjectRepo);
453
+ selectedRepos.push({ name: newProjectRepo });
407
454
  s3.stop(`Created ${newProjectRepo}`);
408
455
  } else {
409
456
  s3.fail(`${newProjectRepo} creation timed out`);
@@ -438,6 +485,15 @@ async function localFounderFlow(ui) {
438
485
  );
439
486
  } catch {}
440
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
+
441
497
  // 9. Init memory dirs on remote (so joiners get the structure)
442
498
  const memDirs = ["people", "handoffs", "knowledge/decisions", "knowledge/patterns", "knowledge/findings", "quests", "wraps"];
443
499
  for (const d of memDirs) {
@@ -459,6 +515,7 @@ async function localFounderFlow(ui) {
459
515
  ui.step(1, totalSteps, "Cloning egregore...");
460
516
  cloneRepo(forkUrl, egregoreDir, githubToken, ui, "egregore");
461
517
  setGitIdentity(egregoreDir, user);
518
+ ensureDevelop(egregoreDir);
462
519
 
463
520
  ui.step(2, totalSteps, "Cloning shared memory...");
464
521
  cloneRepo(memoryUrl, memoryDir, githubToken, ui, "memory");
@@ -519,15 +576,20 @@ async function localFounderFlow(ui) {
519
576
 
520
577
  // 16. Clone managed repos
521
578
  for (let i = 0; i < selectedRepos.length; i++) {
522
- const mr = selectedRepos[i];
523
- ui.step(5 + i, totalSteps, `Cloning ${mr}...`);
524
- 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);
525
583
  try {
526
- const repoUrl = `https://github.com/${githubOrg}/${mr}.git`;
527
- cloneRepo(repoUrl, repoDir, githubToken, ui, mr);
584
+ const repoUrl = `https://github.com/${githubOrg}/${mrName}.git`;
585
+ cloneRepo(repoUrl, repoDir, githubToken, ui, mrName);
528
586
  setGitIdentity(repoDir, user);
587
+ // New repos get develop branch (our workflow). Existing repos keep their default.
588
+ if (isNewProject) {
589
+ ensureDevelop(repoDir);
590
+ }
529
591
  } catch {
530
- ui.warn(`Could not clone ${mr} — you may not have access.`);
592
+ ui.warn(`Could not clone ${mrName} — you may not have access.`);
531
593
  }
532
594
  }
533
595
 
@@ -545,8 +607,9 @@ async function localFounderFlow(ui) {
545
607
  // Add as collaborator to all repos
546
608
  await addCollaborator(githubToken, githubOrg, repoName, invUsername);
547
609
  await addCollaborator(githubToken, githubOrg, memoryRepoName, invUsername);
548
- for (const mr of selectedRepos) {
549
- 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(() => {});
550
613
  }
551
614
 
552
615
  // Create person file
@@ -585,8 +648,9 @@ async function localFounderFlow(ui) {
585
648
  ui.info("Your workspace:");
586
649
  ui.info(` ${ui.cyan(`./${repoName}/`)} — Your Egregore instance`);
587
650
  ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
588
- for (const mr of selectedRepos) {
589
- 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`);
590
654
  }
591
655
  console.log("");
592
656
  ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
@@ -648,6 +712,7 @@ async function localJoinFlow(orgArg, ui) {
648
712
  process.exit(1);
649
713
  }
650
714
  setGitIdentity(egregoreDir, user);
715
+ ensureDevelop(egregoreDir);
651
716
 
652
717
  // 6. Read egregore.json from local clone
653
718
  const configPath = path.join(egregoreDir, "egregore.json");
@@ -720,14 +785,15 @@ async function localJoinFlow(orgArg, ui) {
720
785
 
721
786
  // Clone managed repos using SSH-first fallback
722
787
  for (let i = 0; i < managedRepos.length; i++) {
723
- const mr = managedRepos[i];
724
- ui.step(5 + i, totalSteps, `Cloning ${mr}...`);
725
- const repoDir = path.join(base, mr);
726
- if (cloneWithFallback(`https://github.com/${orgLogin}/${mr}.git`, repoDir)) {
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)) {
727
793
  setGitIdentity(repoDir, user);
728
- ui.success(`Cloned ${mr}`);
794
+ ui.success(`Cloned ${mrName}`);
729
795
  } else {
730
- ui.warn(`Could not clone ${mr} — you may not have access yet.`);
796
+ ui.warn(`Could not clone ${mrName} — you may not have access yet.`);
731
797
  }
732
798
  }
733
799
 
@@ -761,8 +827,9 @@ async function localJoinFlow(orgArg, ui) {
761
827
  ui.info("Your workspace:");
762
828
  ui.info(` ${ui.cyan(`./${repoName}/`)} — Egregore instance`);
763
829
  ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
764
- for (const mr of managedRepos) {
765
- 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`);
766
833
  }
767
834
  console.log("");
768
835
  ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
package/lib/setup.js CHANGED
@@ -118,26 +118,21 @@ async function install(data, ui, targetDir) {
118
118
 
119
119
  const totalSteps = 5 + repos.length;
120
120
 
121
- // Configure git credential helper for HTTPS cloning
122
- configureGitCredentials(github_token);
123
-
124
121
  // Accept pending repo collaboration invitations (joiners get invited as collaborators)
125
122
  await acceptPendingInvitations(github_token, github_org, ui);
126
123
 
127
- // Embed token in URLs as fallback for private repos (credential helper may not work)
128
- const authedForkUrl = embedToken(fork_url, github_token);
129
- const authedMemoryUrl = embedToken(memory_url, github_token);
130
-
131
- // 1. Clone fork
124
+ // 1. Clone fork (SSH first, HTTPS fallback — no token embedding)
132
125
  ui.step(1, totalSteps, "Cloning egregore...");
133
126
  if (fs.existsSync(egregoreDir)) {
134
127
  ui.warn("egregore/ already exists — pulling latest");
135
128
  run("git pull", { cwd: egregoreDir });
136
129
  } else {
137
- execFileSync("git", ["clone", authedForkUrl, egregoreDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
138
- try { run(`git remote set-url origin ${fork_url}`, { cwd: egregoreDir }); } catch {}
130
+ if (!cloneRepo(fork_url, egregoreDir)) {
131
+ throw new Error(`Command failed: git clone ${fork_url} ${egregoreDir}\nCloning into '${egregoreDir}'...\nMake sure you have SSH keys set up or run 'gh auth login' first.`);
132
+ }
139
133
  }
140
134
  try { run("git config credential.helper store", { cwd: egregoreDir }); } catch {}
135
+ ensureDevelop(egregoreDir);
141
136
  ui.success("Cloned egregore");
142
137
 
143
138
  // Set repo-local git identity from GitHub user (not machine-global config)
@@ -156,8 +151,9 @@ async function install(data, ui, targetDir) {
156
151
  ui.warn(`${memoryDirName}/ already exists — pulling latest`);
157
152
  run("git pull", { cwd: memoryDir });
158
153
  } else {
159
- execFileSync("git", ["clone", authedMemoryUrl, memoryDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
160
- try { run(`git remote set-url origin ${memory_url}`, { cwd: memoryDir }); } catch {}
154
+ if (!cloneRepo(memory_url, memoryDir)) {
155
+ ui.warn(`Could not clone memory repo — you may need SSH keys or 'gh auth login'`);
156
+ }
161
157
  }
162
158
  try { run("git config credential.helper store", { cwd: memoryDir }); } catch {}
163
159
  if (github_username) {
@@ -242,10 +238,12 @@ async function install(data, ui, targetDir) {
242
238
  run("git pull", { cwd: repoDir });
243
239
  } else {
244
240
  const repoUrl = `https://github.com/${github_org}/${repoName}.git`;
245
- execFileSync("git", ["clone", embedToken(repoUrl, github_token), repoDir], { stdio: "pipe", encoding: "utf-8", timeout: 60000 });
246
- try { run(`git remote set-url origin ${repoUrl}`, { cwd: repoDir }); } catch {}
241
+ if (!cloneRepo(repoUrl, repoDir)) {
242
+ throw new Error(`Clone failed for ${repoName}`);
243
+ }
247
244
  }
248
245
  try { run("git config credential.helper store", { cwd: repoDir }); } catch {}
246
+ ensureDevelop(repoDir);
249
247
  clonedRepos.push(repoName);
250
248
  ui.success(`Cloned ${repoName}`);
251
249
  } catch {
@@ -274,6 +272,51 @@ async function install(data, ui, targetDir) {
274
272
  console.log("");
275
273
  }
276
274
 
275
+ /**
276
+ * Ensure a cloned repo has a develop branch. Creates from default branch and pushes if missing.
277
+ * Idempotent — safe to call on repos that already have develop.
278
+ */
279
+ function ensureDevelop(repoDir) {
280
+ try {
281
+ // Check if develop already exists on remote
282
+ const remoteRefs = execFileSync("git", ["-C", repoDir, "ls-remote", "--heads", "origin", "develop"], {
283
+ stdio: "pipe", encoding: "utf-8", timeout: 15000,
284
+ }).trim();
285
+ if (remoteRefs) return; // develop already exists on remote
286
+
287
+ // Detect default branch (main, master, or whatever origin/HEAD points to)
288
+ let defaultBranch = "";
289
+ try {
290
+ const head = execFileSync("git", ["-C", repoDir, "symbolic-ref", "refs/remotes/origin/HEAD"], {
291
+ stdio: "pipe", encoding: "utf-8", timeout: 5000,
292
+ }).trim();
293
+ defaultBranch = head.replace("refs/remotes/origin/", "");
294
+ } catch {}
295
+ if (!defaultBranch) {
296
+ for (const candidate of ["main", "master"]) {
297
+ try {
298
+ execFileSync("git", ["-C", repoDir, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], {
299
+ stdio: "pipe", timeout: 5000,
300
+ });
301
+ defaultBranch = candidate;
302
+ break;
303
+ } catch {}
304
+ }
305
+ }
306
+ if (!defaultBranch) return; // no default branch found — skip
307
+
308
+ // Create develop from default branch and push
309
+ execFileSync("git", ["-C", repoDir, "branch", "develop", `origin/${defaultBranch}`], {
310
+ stdio: "pipe", encoding: "utf-8", timeout: 5000,
311
+ });
312
+ execFileSync("git", ["-C", repoDir, "push", "-u", "origin", "develop"], {
313
+ stdio: "pipe", encoding: "utf-8", timeout: 30000,
314
+ });
315
+ } catch {
316
+ // Non-fatal — session-start.sh will retry on first launch
317
+ }
318
+ }
319
+
277
320
  function embedToken(url, token) {
278
321
  try {
279
322
  return url.replace("https://github.com/", `https://x-access-token:${token}@github.com/`);
@@ -282,6 +325,37 @@ function embedToken(url, token) {
282
325
  }
283
326
  }
284
327
 
328
+ function toSSH(url) {
329
+ const m = url.match(/https:\/\/github\.com\/(.+?)(?:\.git)?$/);
330
+ return m ? `git@github.com:${m[1]}.git` : null;
331
+ }
332
+
333
+ /**
334
+ * Clone a repo, trying SSH first then HTTPS (no token embedded).
335
+ * The repo:invite-scoped OAuth token can't clone — we rely on the
336
+ * user's own git credentials (SSH keys / gh CLI / credential helper).
337
+ */
338
+ function cloneRepo(httpsUrl, dir, timeout = 60000) {
339
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
340
+ const sshUrl = toSSH(httpsUrl);
341
+
342
+ if (sshUrl) {
343
+ try {
344
+ execFileSync("git", ["clone", sshUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
345
+ return true;
346
+ } catch {
347
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
348
+ }
349
+ }
350
+
351
+ try {
352
+ execFileSync("git", ["clone", httpsUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
353
+ return true;
354
+ } catch {}
355
+
356
+ return false;
357
+ }
358
+
285
359
  function configureGitCredentials(token) {
286
360
  try {
287
361
  // Use --global since this runs before any repos are cloned (no local .git exists)
@@ -415,5 +489,6 @@ module.exports = {
415
489
  embedToken,
416
490
  configureGitCredentials,
417
491
  initMemoryDirs,
492
+ ensureDevelop,
418
493
  run,
419
494
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Set up Egregore for your team in one command",
5
5
  "license": "MIT",
6
6
  "bin": {