create-egregore 0.14.2 → 0.15.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.
Files changed (3) hide show
  1. package/lib/auth.js +12 -5
  2. package/lib/local.js +94 -105
  3. package/package.json +1 -1
package/lib/auth.js CHANGED
@@ -5,7 +5,8 @@
5
5
 
6
6
  const https = require("node:https");
7
7
 
8
- const CLIENT_ID = "Iv23li2obNsAjakoK2RE"; // GitHub App — clean consent, permissions via installation
8
+ const GITHUB_APP_CLIENT_ID = "Iv23li2obNsAjakoK2RE"; // GitHub App — founder flow (clean install screen)
9
+ const OAUTH_APP_CLIENT_ID = "Ov23liYZjJbbbNgnJ9vH"; // OAuth App — join flow (repo:invite only)
9
10
 
10
11
  function post(url, body) {
11
12
  return new Promise((resolve, reject) => {
@@ -72,12 +73,18 @@ function copyToClipboard(text) {
72
73
  /**
73
74
  * Run GitHub Device Flow.
74
75
  * Returns the access token string, or throws on failure.
76
+ * @param {object} [opts] - Options
77
+ * @param {string} [opts.mode] - "join" uses OAuth App with repo:invite scope
75
78
  */
76
- async function deviceFlow(ui) {
79
+ async function deviceFlow(ui, opts = {}) {
80
+ const isJoin = opts.mode === "join";
81
+ const clientId = isJoin ? OAUTH_APP_CLIENT_ID : GITHUB_APP_CLIENT_ID;
82
+ const scope = isJoin ? "repo:invite" : "";
83
+
77
84
  // 1. Request device code
78
85
  const codeResp = await post("https://github.com/login/device/code", {
79
- client_id: CLIENT_ID,
80
- scope: "",
86
+ client_id: clientId,
87
+ scope,
81
88
  });
82
89
 
83
90
  if (!codeResp.device_code) {
@@ -111,7 +118,7 @@ async function deviceFlow(ui) {
111
118
  await sleep(interval);
112
119
 
113
120
  const tokenResp = await post("https://github.com/login/oauth/access_token", {
114
- client_id: CLIENT_ID,
121
+ client_id: clientId,
115
122
  device_code,
116
123
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
117
124
  });
package/lib/local.js CHANGED
@@ -168,6 +168,43 @@ function cloneRepo(url, dir, token, ui, label) {
168
168
  ui.success(`Cloned ${label}`);
169
169
  }
170
170
 
171
+ /**
172
+ * Convert HTTPS GitHub URL to SSH format.
173
+ * https://github.com/org/repo.git → git@github.com:org/repo.git
174
+ */
175
+ function toSSH(url) {
176
+ const m = url.match(/https:\/\/github\.com\/(.+?)(?:\.git)?$/);
177
+ return m ? `git@github.com:${m[1]}.git` : null;
178
+ }
179
+
180
+ /**
181
+ * Clone a repo, trying SSH first then HTTPS.
182
+ * GIT_TERMINAL_PROMPT=0 prevents the hanging username/password prompt.
183
+ */
184
+ function cloneWithFallback(httpsUrl, dir, timeout = 60000) {
185
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
186
+ const sshUrl = toSSH(httpsUrl);
187
+
188
+ // Try SSH first (most devs have SSH keys; GitHub no longer supports HTTPS password auth)
189
+ if (sshUrl) {
190
+ try {
191
+ execFileSync("git", ["clone", sshUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
192
+ return true;
193
+ } catch {
194
+ // Clean up partial clone so HTTPS fallback can use the same dir
195
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
196
+ }
197
+ }
198
+
199
+ // Fall back to HTTPS (works if user has gh CLI or a credential helper configured)
200
+ try {
201
+ execFileSync("git", ["clone", httpsUrl, dir], { stdio: "pipe", encoding: "utf-8", timeout, env });
202
+ return true;
203
+ } catch {}
204
+
205
+ return false;
206
+ }
207
+
171
208
  function setGitIdentity(dir, user) {
172
209
  try {
173
210
  run(`git config user.name "${user.name}"`, { cwd: dir });
@@ -568,18 +605,11 @@ async function localJoinFlow(orgArg, ui) {
568
605
  explicitRepo = null;
569
606
  }
570
607
 
571
- // 1. Install GitHub App — needed for the token to access repository resources
608
+ // 1. GitHub auth — OAuth with repo:invite scope (narrow: just invitation acceptance)
572
609
  ui.info(`Joining ${ui.bold(orgLogin)}. First, sign in with GitHub.\n`);
573
- ui.info("Install the Egregore app on your GitHub account.");
574
- ui.info("This lets Egregore access the repos you've been invited to.\n");
575
- openBrowser(APP_INSTALL_URL);
576
- await ui.prompt("Press Enter when done...");
577
-
578
- // 2. GitHub auth (auto-approved after App install)
579
- ui.info("");
580
610
  let githubToken;
581
611
  try {
582
- githubToken = await deviceFlow(ui);
612
+ githubToken = await deviceFlow(ui, { mode: "join" });
583
613
  ui.success("Authenticated with GitHub");
584
614
  } catch (err) {
585
615
  ui.error(`GitHub auth failed: ${err.message}`);
@@ -589,54 +619,50 @@ async function localJoinFlow(orgArg, ui) {
589
619
  // 2. Get user info
590
620
  const user = await getUser(githubToken);
591
621
 
592
- // 3. Accept pending invitations (may silently fail with GitHub App tokens)
622
+ // 3. Accept pending invitations
593
623
  console.log("");
594
- const s1 = ui.spinner("Checking for invitations...");
624
+ const s1 = ui.spinner("Accepting invitations...");
595
625
  await acceptPendingInvitations(githubToken, orgLogin, ui);
596
- s1.stop("Checked invitations");
626
+ s1.stop("Invitations accepted");
627
+
628
+ // 4. Determine repo name
629
+ const repoName = explicitRepo || "egregore";
630
+ if (!repoName) {
631
+ ui.error("Usage: npx create-egregore join <org>/<repo>");
632
+ process.exit(1);
633
+ }
597
634
 
598
- // 4. Find egregore repo
599
- let repoName = await findEgregoreRepo(githubToken, orgLogin, explicitRepo);
635
+ // 5. Clone repos using git credentials (SSH keys / gh CLI / credential helper)
636
+ // The OAuth token only has repo:invite scope — cloning uses the joiner's own git setup.
637
+ const base = process.cwd();
638
+ const egregoreDir = path.join(base, repoName);
600
639
 
601
- // 4b. If repo not found, the user likely has a pending invitation that couldn't
602
- // be accepted automatically. Open the repo page so they can accept with one click.
603
- if (!repoName && explicitRepo) {
604
- console.log("");
605
- ui.info("Accept the repo invitation in your browser.");
606
- openBrowser(`https://github.com/${orgLogin}/${explicitRepo}`);
607
- await ui.prompt("Press Enter after accepting...");
608
-
609
- const s2 = ui.spinner("Retrying...");
610
- repoName = await findEgregoreRepo(githubToken, orgLogin, explicitRepo);
611
- if (!repoName) {
612
- s2.fail(`Still can't access ${orgLogin}/${explicitRepo}`);
613
- ui.error("Make sure the org admin has set up Egregore and invited you.");
614
- process.exit(1);
615
- }
616
- s2.stop("Access confirmed");
617
- } else if (!repoName) {
618
- ui.error(`No egregore repo found in ${orgLogin}`);
619
- ui.error("Make sure the org admin has set up Egregore and invited you.");
620
- ui.info("If the repo has a custom name, use: npx create-egregore join <org>/<repo>");
640
+ console.log("");
641
+ const s2 = ui.spinner(`Cloning ${orgLogin}/${repoName}...`);
642
+ if (cloneWithFallback(`https://github.com/${orgLogin}/${repoName}.git`, egregoreDir)) {
643
+ s2.stop(`Cloned ${repoName}`);
644
+ } else {
645
+ s2.fail(`Could not clone ${orgLogin}/${repoName}`);
646
+ ui.error("Make sure you have git credentials set up (SSH keys or gh auth login).");
647
+ ui.error("Also verify the org admin has set up Egregore and invited you.");
621
648
  process.exit(1);
622
649
  }
650
+ setGitIdentity(egregoreDir, user);
623
651
 
624
- // 5. Read egregore.json from remote
625
- const s3 = ui.spinner(`Reading config from ${orgLogin}/${repoName}...`);
626
- const configRaw = await getFileContent(githubToken, orgLogin, repoName, "egregore.json");
627
- if (!configRaw) {
628
- s3.fail("Could not read egregore.json");
629
- ui.error("The egregore repo exists but has no egregore.json. Ask the admin to set up first.");
652
+ // 6. Read egregore.json from local clone
653
+ const configPath = path.join(egregoreDir, "egregore.json");
654
+ if (!fs.existsSync(configPath)) {
655
+ ui.error("The repo has no egregore.json. Ask the admin to set up first.");
630
656
  process.exit(1);
631
657
  }
632
658
  let config;
633
659
  try {
634
- config = JSON.parse(configRaw);
660
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
635
661
  } catch {
636
- s3.fail("Invalid egregore.json");
662
+ ui.error("Invalid egregore.json");
637
663
  process.exit(1);
638
664
  }
639
- s3.stop(`Found ${ui.bold(config.org_name || orgLogin)}`);
665
+ ui.success(`Found ${ui.bold(config.org_name || orgLogin)}`);
640
666
 
641
667
  const orgName = config.org_name || orgLogin;
642
668
  const memoryRepoName = config.memory_repo
@@ -644,61 +670,33 @@ async function localJoinFlow(orgArg, ui) {
644
670
  : `${orgLogin}-memory`;
645
671
  const managedRepos = config.repos || [];
646
672
 
647
- // 5b. Check access to ALL repos — open browser for any that need invitation acceptance
648
- const allRepos = [repoName, memoryRepoName, ...managedRepos];
649
- const inaccessible = [];
650
- for (const repo of allRepos) {
651
- if (!(await repoExists(githubToken, orgLogin, repo))) {
652
- inaccessible.push(repo);
653
- }
654
- }
655
- if (inaccessible.length > 0) {
656
- console.log("");
657
- ui.info(`Accept ${inaccessible.length === 1 ? "the invitation" : `${inaccessible.length} invitations`} in your browser.`);
658
- for (const repo of inaccessible) {
659
- openBrowser(`https://github.com/${orgLogin}/${repo}`);
660
- }
661
- await ui.prompt("Press Enter after accepting...");
662
-
663
- // Retry — only block on main + memory, managed repos are best-effort
664
- for (const repo of [repoName, memoryRepoName]) {
665
- if (inaccessible.includes(repo) && !(await repoExists(githubToken, orgLogin, repo))) {
666
- ui.error(`Still can't access ${orgLogin}/${repo}`);
667
- ui.error("Make sure the org admin has set up Egregore and invited you.");
668
- process.exit(1);
669
- }
670
- }
671
- ui.success("Access confirmed");
672
- }
673
-
674
- // 6. Clone locally
675
- const base = process.cwd();
676
- const forkUrl = `https://github.com/${orgLogin}/${repoName}.git`;
673
+ // 7. Clone memory + managed repos
677
674
  const memoryUrl = config.memory_repo || `https://github.com/${orgLogin}/${memoryRepoName}.git`;
678
- const egregoreDir = path.join(base, repoName);
679
675
  const memoryDir = path.join(base, memoryRepoName);
680
676
 
681
- const totalSteps = 5 + managedRepos.length;
682
- configureGitCredentials(githubToken);
677
+ const totalSteps = 4 + managedRepos.length;
678
+ // Store the invite token for later use (invitation acceptance, not cloning)
679
+ const inviteToken = githubToken;
683
680
 
684
- ui.step(1, totalSteps, "Cloning egregore...");
685
- cloneRepo(forkUrl, egregoreDir, githubToken, ui, "egregore");
686
- setGitIdentity(egregoreDir, user);
687
-
688
- ui.step(2, totalSteps, "Cloning shared memory...");
689
- cloneRepo(memoryUrl, memoryDir, githubToken, ui, "memory");
681
+ // Clone memory using SSH-first fallback (same as main repo)
682
+ ui.step(1, totalSteps, "Cloning shared memory...");
683
+ if (cloneWithFallback(memoryUrl, memoryDir)) {
684
+ ui.success("Cloned memory");
685
+ } else {
686
+ ui.warn("Could not clone memory repo — you may not have access yet.");
687
+ }
690
688
  setGitIdentity(memoryDir, user);
691
689
 
692
- // 7. Symlink
693
- ui.step(3, totalSteps, "Linking memory...");
690
+ // Symlink
691
+ ui.step(2, totalSteps, "Linking memory...");
694
692
  createSymlink(egregoreDir, memoryDir, ui);
695
693
 
696
- // 8. Write .env
697
- ui.step(4, totalSteps, "Writing credentials...");
698
- fs.writeFileSync(path.join(egregoreDir, ".env"), `GITHUB_TOKEN=${githubToken}\n`, { mode: 0o600 });
694
+ // Write .env — store invite token for session-start invitation checks
695
+ ui.step(3, totalSteps, "Writing credentials...");
696
+ fs.writeFileSync(path.join(egregoreDir, ".env"), `GITHUB_TOKEN=${inviteToken}\n`, { mode: 0o600 });
699
697
  ui.success("Credentials saved");
700
698
 
701
- // 9. "What should we call you?" + write state
699
+ // "What should we call you?" + write state
702
700
  console.log("");
703
701
  const displayName = await ui.prompt(`What should we call you? [${user.login}]:`);
704
702
 
@@ -715,30 +713,21 @@ async function localJoinFlow(orgArg, ui) {
715
713
  };
716
714
  fs.writeFileSync(path.join(egregoreDir, ".egregore-state.json"), JSON.stringify(state, null, 2) + "\n");
717
715
 
718
- // 10. Register + alias
719
- ui.step(5, totalSteps, "Registering instance...");
716
+ // Register + alias
717
+ ui.step(4, totalSteps, "Registering instance...");
720
718
  registerInstance(repoName, orgName, egregoreDir);
721
719
  const alias = await installShellAlias(egregoreDir, ui);
722
720
 
723
- // 11. Clone managed repos (retry once — git servers may lag behind invitation acceptance)
721
+ // Clone managed repos using SSH-first fallback
724
722
  for (let i = 0; i < managedRepos.length; i++) {
725
723
  const mr = managedRepos[i];
726
- ui.step(6 + i, totalSteps, `Cloning ${mr}...`);
724
+ ui.step(5 + i, totalSteps, `Cloning ${mr}...`);
727
725
  const repoDir = path.join(base, mr);
728
- try {
729
- const repoUrl = `https://github.com/${orgLogin}/${mr}.git`;
730
- cloneRepo(repoUrl, repoDir, githubToken, ui, mr);
726
+ if (cloneWithFallback(`https://github.com/${orgLogin}/${mr}.git`, repoDir)) {
731
727
  setGitIdentity(repoDir, user);
732
- } catch {
733
- // Retry after a short delay — invitation acceptance may not have propagated
734
- await new Promise((r) => setTimeout(r, 3000));
735
- try {
736
- const repoUrl = `https://github.com/${orgLogin}/${mr}.git`;
737
- cloneRepo(repoUrl, repoDir, githubToken, ui, mr);
738
- setGitIdentity(repoDir, user);
739
- } catch {
740
- ui.warn(`Could not clone ${mr} — you may not have access yet.`);
741
- }
728
+ ui.success(`Cloned ${mr}`);
729
+ } else {
730
+ ui.warn(`Could not clone ${mr} — you may not have access yet.`);
742
731
  }
743
732
  }
744
733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "Set up Egregore for your team in one command",
5
5
  "license": "MIT",
6
6
  "bin": {