create-egregore 0.16.2 → 0.17.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/bin/cli.js +164 -17
- package/lib/auth.js +76 -14
- package/lib/context.js +107 -0
- package/lib/local.js +30 -6
- package/lib/setup.js +13 -2
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* npx create-egregore join <org> (local mode — join existing)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const path = require("node:path");
|
|
13
15
|
const ui = require("../lib/ui");
|
|
14
16
|
const { EgregoreAPI } = require("../lib/api");
|
|
15
17
|
const { deviceFlow } = require("../lib/auth");
|
|
16
18
|
const { install, ghApi } = require("../lib/setup");
|
|
19
|
+
const { detectContext, isWritable, isSystemDir } = require("../lib/context");
|
|
17
20
|
|
|
18
21
|
const API_URL = process.env.EGREGORE_API_URL || "https://egregore-production-55f2.up.railway.app";
|
|
19
22
|
|
|
@@ -29,6 +32,8 @@ function parseArgs(argv) {
|
|
|
29
32
|
args.api = argv[++i];
|
|
30
33
|
} else if (argv[i] === "--open") {
|
|
31
34
|
args.open = true;
|
|
35
|
+
} else if (argv[i] === "--force") {
|
|
36
|
+
args.force = true;
|
|
32
37
|
} else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
33
38
|
args.help = true;
|
|
34
39
|
} else if (!argv[i].startsWith("-")) {
|
|
@@ -42,6 +47,104 @@ function parseArgs(argv) {
|
|
|
42
47
|
return args;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* If cwd is inside a git repo, show the "sibling shell" notice and offer a
|
|
52
|
+
* redirect. Returns { targetDir, prefilledRepo } — prefilledRepo is non-null
|
|
53
|
+
* only when we detected a repo. Caller passes both through to downstream flows.
|
|
54
|
+
*
|
|
55
|
+
* Bypassed entirely by --force.
|
|
56
|
+
*/
|
|
57
|
+
async function maybeRedirect(force) {
|
|
58
|
+
if (force) return { targetDir: process.cwd(), prefilledRepo: null };
|
|
59
|
+
|
|
60
|
+
const ctx = detectContext();
|
|
61
|
+
if (!ctx) return { targetDir: process.cwd(), prefilledRepo: null };
|
|
62
|
+
|
|
63
|
+
const { repoName, parentDir, originOwner, originName } = ctx;
|
|
64
|
+
|
|
65
|
+
// Non-TTY guard: we can't prompt. Fail loudly so CI/Docker/piped scripts
|
|
66
|
+
// don't hang forever on readline.
|
|
67
|
+
if (!process.stdin.isTTY) {
|
|
68
|
+
console.log("");
|
|
69
|
+
ui.error(`Detected a git repo at ./${repoName} but stdin is not a TTY — cannot prompt.`);
|
|
70
|
+
ui.info("Re-run from outside the repo, or pass --force to install here anyway.");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parentWritable = isWritable(parentDir);
|
|
75
|
+
const parentIsSystem = isSystemDir(parentDir);
|
|
76
|
+
|
|
77
|
+
console.log("");
|
|
78
|
+
ui.warn(`You're inside a git repo: ${ui.bold(`./${repoName}`)}`);
|
|
79
|
+
console.log("");
|
|
80
|
+
ui.info("Egregore lives as a sibling shell that manages your repos,");
|
|
81
|
+
ui.info("not inside them. Your repo stays where it is.");
|
|
82
|
+
|
|
83
|
+
if (parentIsSystem) {
|
|
84
|
+
console.log("");
|
|
85
|
+
ui.warn(`The parent directory (${parentDir}) looks like a system or home root — not a good place for an Egregore workspace.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const choices = [];
|
|
89
|
+
// Offer the parent redirect only if it's writable AND not a system dir.
|
|
90
|
+
if (parentWritable && !parentIsSystem) {
|
|
91
|
+
choices.push({
|
|
92
|
+
label: `Install in ${parentDir}`,
|
|
93
|
+
description: `Set up alongside ${repoName} and add it to managed repos (Recommended)`,
|
|
94
|
+
action: "parent",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
choices.push({
|
|
98
|
+
label: "Pick a different directory",
|
|
99
|
+
description: "Choose where to install Egregore",
|
|
100
|
+
action: "pick",
|
|
101
|
+
});
|
|
102
|
+
choices.push({
|
|
103
|
+
label: "Cancel",
|
|
104
|
+
description: "Exit without changes",
|
|
105
|
+
action: "cancel",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const choice = await ui.choose("How would you like to proceed?", choices);
|
|
109
|
+
|
|
110
|
+
if (choice.action === "cancel") {
|
|
111
|
+
console.log("");
|
|
112
|
+
ui.info("Cancelled. Run from a clean directory, or pass --force to override.");
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let targetDir = parentDir;
|
|
117
|
+
if (choice.action === "pick") {
|
|
118
|
+
const answer = await ui.prompt("Install directory (absolute path):");
|
|
119
|
+
if (!answer) {
|
|
120
|
+
ui.error("No directory provided");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
targetDir = path.resolve(answer);
|
|
124
|
+
if (isSystemDir(targetDir)) {
|
|
125
|
+
ui.error(`${targetDir} looks like a system directory — pick something under your home or projects folder.`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
|
130
|
+
fs.accessSync(targetDir, fs.constants.W_OK);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
ui.error(`Cannot write to ${targetDir}: ${err.message}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
targetDir,
|
|
139
|
+
prefilledRepo: {
|
|
140
|
+
name: originName || repoName,
|
|
141
|
+
owner: originOwner,
|
|
142
|
+
localName: repoName,
|
|
143
|
+
toplevel: ctx.toplevel,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
45
148
|
function showHelp() {
|
|
46
149
|
ui.banner();
|
|
47
150
|
ui.info("Usage:");
|
|
@@ -51,9 +154,10 @@ function showHelp() {
|
|
|
51
154
|
ui.info(" npx create-egregore --token <setup-token> Install from website");
|
|
52
155
|
ui.info("");
|
|
53
156
|
ui.info("Options:");
|
|
54
|
-
ui.info(" --open
|
|
157
|
+
ui.info(" --open Local mode — no API server needed");
|
|
55
158
|
ui.info(" --token <token> Setup token from egregore.xyz");
|
|
56
159
|
ui.info(" --api <url> API URL override");
|
|
160
|
+
ui.info(" --force Skip the in-repo detection check");
|
|
57
161
|
ui.info(" -h, --help Show this help");
|
|
58
162
|
ui.info("");
|
|
59
163
|
}
|
|
@@ -68,10 +172,12 @@ async function main() {
|
|
|
68
172
|
|
|
69
173
|
ui.banner();
|
|
70
174
|
|
|
175
|
+
const ctx = await maybeRedirect(args.force);
|
|
176
|
+
|
|
71
177
|
if (args.token) {
|
|
72
|
-
// ===== Connected mode: token provided
|
|
178
|
+
// ===== Connected mode: token provided =====
|
|
73
179
|
const api = new EgregoreAPI(args.api || API_URL);
|
|
74
|
-
await tokenFlow(api, args.token);
|
|
180
|
+
await tokenFlow(api, args.token, ctx);
|
|
75
181
|
} else if (args.subcommand === "join") {
|
|
76
182
|
// ===== Local mode: join existing =====
|
|
77
183
|
if (!args.joinOrg) {
|
|
@@ -79,19 +185,19 @@ async function main() {
|
|
|
79
185
|
process.exit(1);
|
|
80
186
|
}
|
|
81
187
|
const { localJoinFlow } = require("../lib/local");
|
|
82
|
-
await localJoinFlow(args.joinOrg, ui);
|
|
188
|
+
await localJoinFlow(args.joinOrg, ui, ctx);
|
|
83
189
|
} else if (args.open) {
|
|
84
190
|
// ===== Local mode: founder (explicit opt-in) =====
|
|
85
191
|
const { localFounderFlow } = require("../lib/local");
|
|
86
|
-
await localFounderFlow(ui);
|
|
192
|
+
await localFounderFlow(ui, ctx);
|
|
87
193
|
} else {
|
|
88
194
|
// ===== Interactive API flow (default — existing behavior) =====
|
|
89
195
|
const api = new EgregoreAPI(args.api || API_URL);
|
|
90
|
-
await interactiveFlow(api);
|
|
196
|
+
await interactiveFlow(api, ctx);
|
|
91
197
|
}
|
|
92
198
|
}
|
|
93
199
|
|
|
94
|
-
async function tokenFlow(api, token) {
|
|
200
|
+
async function tokenFlow(api, token, ctx) {
|
|
95
201
|
const s = ui.spinner("Claiming setup token...");
|
|
96
202
|
let data;
|
|
97
203
|
try {
|
|
@@ -119,15 +225,30 @@ async function tokenFlow(api, token) {
|
|
|
119
225
|
}
|
|
120
226
|
}
|
|
121
227
|
|
|
228
|
+
// Token-mode setups already have their repo list baked in server-side. If
|
|
229
|
+
// the user's current working repo happens to match a managed-repo name, skip
|
|
230
|
+
// the clone/pull/configure step for it so we don't clobber uncommitted work.
|
|
231
|
+
const skipRepos = new Set();
|
|
232
|
+
if (ctx.prefilledRepo && Array.isArray(data.repos)) {
|
|
233
|
+
for (const r of data.repos) {
|
|
234
|
+
const n = typeof r === "string" ? r : r && r.name;
|
|
235
|
+
if (n && n === ctx.prefilledRepo.name) skipRepos.add(n);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
122
238
|
try {
|
|
123
|
-
await install(data, ui);
|
|
239
|
+
await install(data, ui, ctx.targetDir, { skipRepos });
|
|
240
|
+
if (ctx.prefilledRepo && skipRepos.size === 0) {
|
|
241
|
+
console.log("");
|
|
242
|
+
ui.info(`Tip: add your current repo to managed_repos with:`);
|
|
243
|
+
ui.info(` /add ${ctx.prefilledRepo.toplevel || `../${ctx.prefilledRepo.localName}`}`);
|
|
244
|
+
}
|
|
124
245
|
} catch (err) {
|
|
125
246
|
ui.error(`Install failed: ${err.message}`);
|
|
126
247
|
process.exit(1);
|
|
127
248
|
}
|
|
128
249
|
}
|
|
129
250
|
|
|
130
|
-
async function interactiveFlow(api) {
|
|
251
|
+
async function interactiveFlow(api, ctx) {
|
|
131
252
|
// 1. GitHub auth
|
|
132
253
|
ui.info("Let's set up Egregore. First, sign in with GitHub.\n");
|
|
133
254
|
let githubToken;
|
|
@@ -227,13 +348,13 @@ async function interactiveFlow(api) {
|
|
|
227
348
|
// 5. Execute
|
|
228
349
|
console.log("");
|
|
229
350
|
if (choice.action === "join") {
|
|
230
|
-
await joinFlow(api, githubToken, choice);
|
|
351
|
+
await joinFlow(api, githubToken, choice, ctx);
|
|
231
352
|
} else {
|
|
232
|
-
await setupFlow(api, githubToken, choice);
|
|
353
|
+
await setupFlow(api, githubToken, choice, ctx);
|
|
233
354
|
}
|
|
234
355
|
}
|
|
235
356
|
|
|
236
|
-
async function setupFlow(api, githubToken, choice) {
|
|
357
|
+
async function setupFlow(api, githubToken, choice, ctx) {
|
|
237
358
|
const orgName = await ui.prompt(`Organization display name [${choice.login}]:`);
|
|
238
359
|
const name = orgName || choice.login;
|
|
239
360
|
|
|
@@ -241,19 +362,37 @@ async function setupFlow(api, githubToken, choice) {
|
|
|
241
362
|
const instanceInput = await ui.prompt(`Instance name (e.g. "ops", "research") [leave blank for default]:`);
|
|
242
363
|
const instanceName = instanceInput || undefined;
|
|
243
364
|
|
|
365
|
+
// If user was inside a repo owned by the chosen org, auto-adopt it.
|
|
366
|
+
const autoAdopt =
|
|
367
|
+
ctx.prefilledRepo &&
|
|
368
|
+
ctx.prefilledRepo.owner &&
|
|
369
|
+
ctx.prefilledRepo.owner.toLowerCase() === choice.login.toLowerCase()
|
|
370
|
+
? ctx.prefilledRepo.name
|
|
371
|
+
: null;
|
|
372
|
+
|
|
244
373
|
// Repo picker — show org repos for selection
|
|
245
374
|
let selectedRepos = [];
|
|
246
375
|
try {
|
|
247
376
|
const repoData = await api.getOrgRepos(githubToken, choice.login);
|
|
248
377
|
if (repoData.repos && repoData.repos.length > 0) {
|
|
378
|
+
const filtered = autoAdopt
|
|
379
|
+
? repoData.repos.filter((r) => r.name !== autoAdopt)
|
|
380
|
+
: repoData.repos;
|
|
381
|
+
if (autoAdopt) {
|
|
382
|
+
console.log("");
|
|
383
|
+
ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
|
|
384
|
+
}
|
|
249
385
|
selectedRepos = await ui.multiSelect(
|
|
250
|
-
"Which repos should Egregore manage?",
|
|
251
|
-
|
|
386
|
+
"Which other repos should Egregore manage?",
|
|
387
|
+
filtered,
|
|
252
388
|
);
|
|
253
389
|
}
|
|
254
390
|
} catch {
|
|
255
391
|
// Non-fatal — continue without repos
|
|
256
392
|
}
|
|
393
|
+
if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
|
|
394
|
+
selectedRepos.unshift(autoAdopt);
|
|
395
|
+
}
|
|
257
396
|
|
|
258
397
|
// Offer to create a new project repo
|
|
259
398
|
console.log("");
|
|
@@ -306,7 +445,8 @@ async function setupFlow(api, githubToken, choice) {
|
|
|
306
445
|
|
|
307
446
|
// Claim the token immediately
|
|
308
447
|
const data = await api.claimToken(result.setup_token);
|
|
309
|
-
|
|
448
|
+
const skipRepos = autoAdopt ? new Set([autoAdopt]) : new Set();
|
|
449
|
+
await install(data, ui, ctx.targetDir, { skipRepos });
|
|
310
450
|
|
|
311
451
|
if (result.telegram_invite_link) {
|
|
312
452
|
console.log("");
|
|
@@ -319,7 +459,7 @@ async function setupFlow(api, githubToken, choice) {
|
|
|
319
459
|
}
|
|
320
460
|
}
|
|
321
461
|
|
|
322
|
-
async function joinFlow(api, githubToken, choice) {
|
|
462
|
+
async function joinFlow(api, githubToken, choice, ctx) {
|
|
323
463
|
const displayName = choice.repo_name ? `${choice.login} (${choice.repo_name})` : choice.login;
|
|
324
464
|
const s = ui.spinner(`Joining ${ui.bold(displayName)}...`);
|
|
325
465
|
try {
|
|
@@ -330,7 +470,14 @@ async function joinFlow(api, githubToken, choice) {
|
|
|
330
470
|
s.stop("Joined");
|
|
331
471
|
|
|
332
472
|
const data = await api.claimToken(result.setup_token);
|
|
333
|
-
|
|
473
|
+
const skipRepos = new Set();
|
|
474
|
+
if (ctx.prefilledRepo && Array.isArray(data.repos)) {
|
|
475
|
+
for (const r of data.repos) {
|
|
476
|
+
const n = typeof r === "string" ? r : r && r.name;
|
|
477
|
+
if (n && n === ctx.prefilledRepo.name) skipRepos.add(n);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
await install(data, ui, ctx.targetDir, { skipRepos });
|
|
334
481
|
} catch (err) {
|
|
335
482
|
s.fail("Join failed");
|
|
336
483
|
ui.error(err.message);
|
package/lib/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ const https = require("node:https");
|
|
|
7
7
|
|
|
8
8
|
const GITHUB_APP_CLIENT_ID = "Iv23li2obNsAjakoK2RE"; // GitHub App — founder flow (clean install screen)
|
|
9
9
|
const OAUTH_APP_CLIENT_ID = "Ov23liYZjJbbbNgnJ9vH"; // OAuth App — join flow (repo:invite only)
|
|
10
|
+
const SUPPORT_URL = "https://github.com/egregore-labs/egregore";
|
|
10
11
|
|
|
11
12
|
function post(url, body) {
|
|
12
13
|
return new Promise((resolve, reject) => {
|
|
@@ -45,6 +46,26 @@ function sleep(ms) {
|
|
|
45
46
|
return new Promise((r) => setTimeout(r, ms));
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* POST with retry + exponential backoff. Handles transient network errors
|
|
51
|
+
* (ECONNREFUSED, ECONNRESET, ETIMEDOUT) by retrying up to `retries` times
|
|
52
|
+
* with a 1s / 2s / 4s backoff. Throws the last error if all retries fail.
|
|
53
|
+
*/
|
|
54
|
+
async function postWithRetry(url, body, retries = 3) {
|
|
55
|
+
let lastErr;
|
|
56
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return await post(url, body);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
lastErr = err;
|
|
61
|
+
if (attempt < retries - 1) {
|
|
62
|
+
await sleep(1000 * Math.pow(2, attempt));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw lastErr;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
function openBrowser(url) {
|
|
49
70
|
const { execFile } = require("node:child_process");
|
|
50
71
|
if (process.platform === "darwin") {
|
|
@@ -81,14 +102,27 @@ async function deviceFlow(ui, opts = {}) {
|
|
|
81
102
|
const clientId = isJoin ? OAUTH_APP_CLIENT_ID : GITHUB_APP_CLIENT_ID;
|
|
82
103
|
const scope = isJoin ? "repo:invite" : "";
|
|
83
104
|
|
|
84
|
-
// 1. Request device code
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
// 1. Request device code (with retry — handles transient network hiccups
|
|
106
|
+
// and GitHub edge server blips that caused ECONNREFUSED in the field)
|
|
107
|
+
let codeResp;
|
|
108
|
+
try {
|
|
109
|
+
codeResp = await postWithRetry("https://github.com/login/device/code", {
|
|
110
|
+
client_id: clientId,
|
|
111
|
+
scope,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Could not reach GitHub after 3 attempts: ${err.message}\n` +
|
|
116
|
+
`Check your internet connection and try again.\n` +
|
|
117
|
+
`If this keeps happening, report it at ${SUPPORT_URL}/issues`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
89
120
|
|
|
90
121
|
if (!codeResp.device_code) {
|
|
91
|
-
throw new Error(
|
|
122
|
+
throw new Error(
|
|
123
|
+
`GitHub device flow did not return a code.\n` +
|
|
124
|
+
`This is unusual — please report at ${SUPPORT_URL}/issues`
|
|
125
|
+
);
|
|
92
126
|
}
|
|
93
127
|
|
|
94
128
|
const { device_code, user_code, verification_uri, interval: pollInterval } = codeResp;
|
|
@@ -109,19 +143,40 @@ async function deviceFlow(ui, opts = {}) {
|
|
|
109
143
|
ui.info("");
|
|
110
144
|
openBrowser(verifyUrl);
|
|
111
145
|
|
|
112
|
-
// 3. Poll for token
|
|
146
|
+
// 3. Poll for token. Transient network errors (one bad packet, WiFi blip,
|
|
147
|
+
// GitHub edge restart) must NOT kill the whole flow — the user might
|
|
148
|
+
// be mid-authorize on the browser side. Catch per-poll errors and
|
|
149
|
+
// continue; the outer 5-minute timeout handles genuinely broken networks.
|
|
113
150
|
let interval = (pollInterval || 5) * 1000;
|
|
114
151
|
const timeout = 300000; // 5 minutes
|
|
115
152
|
const start = Date.now();
|
|
153
|
+
let consecutiveNetworkErrors = 0;
|
|
154
|
+
let networkWarningShown = false;
|
|
116
155
|
|
|
117
156
|
while (Date.now() - start < timeout) {
|
|
118
157
|
await sleep(interval);
|
|
119
158
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
let tokenResp;
|
|
160
|
+
try {
|
|
161
|
+
tokenResp = await post("https://github.com/login/oauth/access_token", {
|
|
162
|
+
client_id: clientId,
|
|
163
|
+
device_code,
|
|
164
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
165
|
+
});
|
|
166
|
+
consecutiveNetworkErrors = 0;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
consecutiveNetworkErrors++;
|
|
169
|
+
// After 3 consecutive failures, surface a note so the user knows
|
|
170
|
+
// something is off — but keep polling until the outer timeout.
|
|
171
|
+
if (consecutiveNetworkErrors >= 3 && !networkWarningShown) {
|
|
172
|
+
ui.warn(
|
|
173
|
+
`Network trouble reaching GitHub (${err.message}). Still trying.\n` +
|
|
174
|
+
`If this persists, report at ${SUPPORT_URL}/issues`
|
|
175
|
+
);
|
|
176
|
+
networkWarningShown = true;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
125
180
|
|
|
126
181
|
if (tokenResp.access_token) {
|
|
127
182
|
return tokenResp.access_token;
|
|
@@ -135,10 +190,17 @@ async function deviceFlow(ui, opts = {}) {
|
|
|
135
190
|
continue;
|
|
136
191
|
}
|
|
137
192
|
|
|
138
|
-
throw new Error(
|
|
193
|
+
throw new Error(
|
|
194
|
+
`${tokenResp.error_description || tokenResp.error || "Auth failed"}\n` +
|
|
195
|
+
`If you need help, see ${SUPPORT_URL}`
|
|
196
|
+
);
|
|
139
197
|
}
|
|
140
198
|
|
|
141
|
-
throw new Error(
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Timed out waiting for authorization (5 minutes).\n` +
|
|
201
|
+
`If the browser tab didn't open, visit the verification URL manually and enter your code.\n` +
|
|
202
|
+
`If this keeps happening, report at ${SUPPORT_URL}/issues`
|
|
203
|
+
);
|
|
142
204
|
}
|
|
143
205
|
|
|
144
206
|
module.exports = { deviceFlow, openBrowser };
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether cwd is inside a git work-tree.
|
|
3
|
+
*
|
|
4
|
+
* Egregore installs as a sibling shell alongside managed repos. If a user runs
|
|
5
|
+
* `npx create-egregore` from inside an existing git repo, we want to redirect
|
|
6
|
+
* them to the repo's parent directory and auto-adopt their repo as a managed
|
|
7
|
+
* repo. This module is the single source of truth for that detection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execFileSync } = require("node:child_process");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
|
|
14
|
+
function gitOut(args, cwd) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync("git", args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} [cwd=process.cwd()]
|
|
29
|
+
* @returns {null | { toplevel: string, repoName: string, parentDir: string, originOwner: string|null, originName: string|null }}
|
|
30
|
+
*/
|
|
31
|
+
function detectContext(cwd = process.cwd()) {
|
|
32
|
+
let toplevel = gitOut(["rev-parse", "--show-toplevel"], cwd);
|
|
33
|
+
if (!toplevel) return null;
|
|
34
|
+
|
|
35
|
+
// Submodule → resolve to superproject
|
|
36
|
+
const sup = gitOut(["rev-parse", "--show-superproject-working-tree"], cwd);
|
|
37
|
+
if (sup) toplevel = sup;
|
|
38
|
+
|
|
39
|
+
// Worktree → resolve to main work-tree so parentDir isn't under .git/worktrees
|
|
40
|
+
const commonDir = gitOut(["rev-parse", "--git-common-dir"], toplevel);
|
|
41
|
+
if (commonDir) {
|
|
42
|
+
const absCommon = path.isAbsolute(commonDir) ? commonDir : path.resolve(toplevel, commonDir);
|
|
43
|
+
if (path.basename(absCommon) === ".git") {
|
|
44
|
+
const mainWorktree = path.dirname(absCommon);
|
|
45
|
+
if (mainWorktree && mainWorktree !== toplevel && fs.existsSync(mainWorktree)) {
|
|
46
|
+
toplevel = mainWorktree;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const repoName = path.basename(toplevel);
|
|
52
|
+
const parentDir = path.dirname(toplevel);
|
|
53
|
+
|
|
54
|
+
let originOwner = null;
|
|
55
|
+
let originName = null;
|
|
56
|
+
const origin = gitOut(["-C", toplevel, "remote", "get-url", "origin"]);
|
|
57
|
+
if (origin) {
|
|
58
|
+
const m = origin.match(/(?:github\.com[:/])([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
59
|
+
if (m) {
|
|
60
|
+
originOwner = m[1];
|
|
61
|
+
originName = m[2];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { toplevel, repoName, parentDir, originOwner, originName };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isWritable(dir) {
|
|
69
|
+
try {
|
|
70
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return true if `dir` looks like a system or shared location where planting
|
|
79
|
+
* an egregore instance + memory repo + managed-repo clones would be disruptive
|
|
80
|
+
* or confusing. Detected: filesystem root, OS user roots, tmp/opt/usr/var,
|
|
81
|
+
* and the user's $HOME itself (which happens when someone runs `git init ~`
|
|
82
|
+
* for dotfile management).
|
|
83
|
+
*/
|
|
84
|
+
function isSystemDir(dir) {
|
|
85
|
+
if (!dir) return true;
|
|
86
|
+
const resolved = path.resolve(dir);
|
|
87
|
+
const blocklist = new Set([
|
|
88
|
+
"/",
|
|
89
|
+
"/Users",
|
|
90
|
+
"/home",
|
|
91
|
+
"/root",
|
|
92
|
+
"/tmp",
|
|
93
|
+
"/private/tmp",
|
|
94
|
+
"/opt",
|
|
95
|
+
"/usr",
|
|
96
|
+
"/usr/local",
|
|
97
|
+
"/var",
|
|
98
|
+
"/etc",
|
|
99
|
+
"/private/var",
|
|
100
|
+
]);
|
|
101
|
+
if (blocklist.has(resolved)) return true;
|
|
102
|
+
const home = require("node:os").homedir();
|
|
103
|
+
if (home && path.resolve(home) === resolved) return true;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { detectContext, isWritable, isSystemDir };
|
package/lib/local.js
CHANGED
|
@@ -250,7 +250,9 @@ const APP_INSTALL_URL = "https://github.com/apps/egregore-labs/installations/new
|
|
|
250
250
|
|
|
251
251
|
// ── Flow 1: Founder (local mode) ───────────────────────────────────
|
|
252
252
|
|
|
253
|
-
async function localFounderFlow(ui) {
|
|
253
|
+
async function localFounderFlow(ui, ctx = {}) {
|
|
254
|
+
// Names of repos already on disk as the user's own workdir — skip clone/pull.
|
|
255
|
+
const skipRepos = ctx.skipRepos instanceof Set ? new Set(ctx.skipRepos) : new Set();
|
|
254
256
|
// 1. Install GitHub App — user picks which repos/accounts to grant access to
|
|
255
257
|
ui.info("Let's set up Egregore.\n");
|
|
256
258
|
ui.info("First, install the Egregore app on your GitHub account.");
|
|
@@ -325,19 +327,36 @@ async function localFounderFlow(ui) {
|
|
|
325
327
|
orgName = nameInput || githubOrg;
|
|
326
328
|
description = "";
|
|
327
329
|
|
|
330
|
+
// Auto-adopt the user's current repo if it's under the chosen GitHub owner.
|
|
331
|
+
const autoAdopt =
|
|
332
|
+
ctx.prefilledRepo &&
|
|
333
|
+
ctx.prefilledRepo.owner &&
|
|
334
|
+
ctx.prefilledRepo.owner.toLowerCase() === githubOrg.toLowerCase()
|
|
335
|
+
? ctx.prefilledRepo.name
|
|
336
|
+
: null;
|
|
337
|
+
|
|
328
338
|
// Multi-select repos to manage
|
|
329
339
|
const s0 = ui.spinner("Loading repos...");
|
|
330
340
|
try {
|
|
331
341
|
const repos = await listOrgRepos(githubToken, githubOrg, isOrg);
|
|
332
342
|
s0.stop("Loaded repos");
|
|
333
343
|
if (repos.length > 0) {
|
|
334
|
-
|
|
344
|
+
const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
|
|
345
|
+
if (autoAdopt) {
|
|
346
|
+
console.log("");
|
|
347
|
+
ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
|
|
348
|
+
}
|
|
349
|
+
selectedRepos = await ui.multiSelect("Which other repos should Egregore manage?", filtered);
|
|
335
350
|
} else {
|
|
336
351
|
ui.info("No repos found yet.");
|
|
337
352
|
}
|
|
338
353
|
} catch {
|
|
339
354
|
s0.stop("Could not load repos");
|
|
340
355
|
}
|
|
356
|
+
if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
|
|
357
|
+
selectedRepos.unshift(autoAdopt);
|
|
358
|
+
}
|
|
359
|
+
if (autoAdopt) skipRepos.add(autoAdopt);
|
|
341
360
|
|
|
342
361
|
// Detect default branch for each selected repo and convert to {name, base_branch} format
|
|
343
362
|
if (selectedRepos.length > 0) {
|
|
@@ -503,7 +522,7 @@ async function localFounderFlow(ui) {
|
|
|
503
522
|
}
|
|
504
523
|
|
|
505
524
|
// 10. Clone locally
|
|
506
|
-
const base = process.cwd();
|
|
525
|
+
const base = ctx.targetDir || process.cwd();
|
|
507
526
|
const egregoreDir = path.join(base, repoName);
|
|
508
527
|
const memoryDir = path.join(base, memoryRepoName);
|
|
509
528
|
const forkUrl = `https://github.com/${githubOrg}/${repoName}.git`;
|
|
@@ -574,11 +593,16 @@ async function localFounderFlow(ui) {
|
|
|
574
593
|
} catch {}
|
|
575
594
|
}
|
|
576
595
|
|
|
577
|
-
// 16. Clone managed repos
|
|
596
|
+
// 16. Clone managed repos — skipRepos contains the user's current working
|
|
597
|
+
// repo if auto-adopted, so we don't pull/reconfigure their live workdir.
|
|
578
598
|
for (let i = 0; i < selectedRepos.length; i++) {
|
|
579
599
|
const entry = selectedRepos[i];
|
|
580
600
|
const mrName = typeof entry === "string" ? entry : entry.name;
|
|
581
601
|
ui.step(5 + i, totalSteps, `Cloning ${mrName}...`);
|
|
602
|
+
if (skipRepos.has(mrName)) {
|
|
603
|
+
ui.success(`Using existing ${mrName}/ (your current repo)`);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
582
606
|
const repoDir = path.join(base, mrName);
|
|
583
607
|
try {
|
|
584
608
|
const repoUrl = `https://github.com/${githubOrg}/${mrName}.git`;
|
|
@@ -659,7 +683,7 @@ async function localFounderFlow(ui) {
|
|
|
659
683
|
|
|
660
684
|
// ── Flow 2: Join (local mode) ──────────────────────────────────────
|
|
661
685
|
|
|
662
|
-
async function localJoinFlow(orgArg, ui) {
|
|
686
|
+
async function localJoinFlow(orgArg, ui, ctx = {}) {
|
|
663
687
|
// Parse org/repo if provided (e.g., "acme-org/ops-team")
|
|
664
688
|
let orgLogin, explicitRepo;
|
|
665
689
|
if (orgArg.includes("/")) {
|
|
@@ -698,7 +722,7 @@ async function localJoinFlow(orgArg, ui) {
|
|
|
698
722
|
|
|
699
723
|
// 5. Clone repos using git credentials (SSH keys / gh CLI / credential helper)
|
|
700
724
|
// The OAuth token only has repo:invite scope — cloning uses the joiner's own git setup.
|
|
701
|
-
const base = process.cwd();
|
|
725
|
+
const base = ctx.targetDir || process.cwd();
|
|
702
726
|
const egregoreDir = path.join(base, repoName);
|
|
703
727
|
|
|
704
728
|
console.log("");
|
package/lib/setup.js
CHANGED
|
@@ -101,11 +101,16 @@ async function acceptPendingInvitations(githubToken, githubOrg, ui) {
|
|
|
101
101
|
*
|
|
102
102
|
* @param {object} data - from /api/org/claim/{token}
|
|
103
103
|
* @param {object} ui - UI module for output
|
|
104
|
-
* @param {string} targetDir - where to install (default: cwd)
|
|
104
|
+
* @param {string} [targetDir] - where to install (default: cwd)
|
|
105
|
+
* @param {object} [opts] - optional { skipRepos: Set<string> } — names of managed
|
|
106
|
+
* repos that already exist on disk as the user's own working repo. Skipped
|
|
107
|
+
* entirely (no clone, no pull, no config writes, no develop-branch push) to
|
|
108
|
+
* avoid clobbering uncommitted work or pushing branches to their origin.
|
|
105
109
|
*/
|
|
106
|
-
async function install(data, ui, targetDir) {
|
|
110
|
+
async function install(data, ui, targetDir, opts = {}) {
|
|
107
111
|
const { fork_url, memory_url, github_token, org_name, github_org, slug, api_key, repos = [], telegram_group_link, github_username, github_name, transcript_sharing } = data;
|
|
108
112
|
const base = targetDir || process.cwd();
|
|
113
|
+
const skipRepos = opts.skipRepos instanceof Set ? opts.skipRepos : new Set();
|
|
109
114
|
|
|
110
115
|
// Directory name from fork URL (what git clone would use), e.g. "egregore-core"
|
|
111
116
|
const forkDirName = fork_url.split("/").pop().replace(/\.git$/, "");
|
|
@@ -233,6 +238,12 @@ async function install(data, ui, targetDir) {
|
|
|
233
238
|
const repoName = typeof repoEntry === 'string' ? repoEntry : repoEntry.name;
|
|
234
239
|
ui.step(6 + i, totalSteps, `Cloning ${repoName}...`);
|
|
235
240
|
const repoDir = path.join(base, repoName);
|
|
241
|
+
// Auto-adopted from user's current working repo — already on disk, don't touch it.
|
|
242
|
+
if (skipRepos.has(repoName)) {
|
|
243
|
+
ui.success(`Using existing ${repoName}/ (your current repo)`);
|
|
244
|
+
clonedRepos.push(repoName);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
236
247
|
try {
|
|
237
248
|
if (fs.existsSync(repoDir)) {
|
|
238
249
|
ui.warn(`${repoName}/ already exists — pulling latest`);
|