forgemap 0.1.0-dev.23-9e4d2ae → 0.1.0-dev.26-b896caf
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/README.md +58 -7
- package/dist/bin/forgemap.mjs +1537 -213
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +1 -1
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
import consola from "consola";
|
|
4
|
+
import { readdir, readFile, mkdir, writeFile, stat, rm, rmdir, rename, access } from "node:fs/promises";
|
|
5
|
+
import { colors, formatTree } from "consola/utils";
|
|
6
|
+
import { isAbsolute, resolve, dirname, join } from "pathe";
|
|
7
|
+
import { homedir } from "node:os";
|
|
4
8
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { readdir, readFile, mkdir, writeFile, stat, access } from "node:fs/promises";
|
|
6
|
-
import { dirname, isAbsolute, resolve, join } from "pathe";
|
|
7
9
|
import { loadConfig } from "c12";
|
|
8
10
|
import { spawn } from "node:child_process";
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
|
-
import { colors, formatTree } from "consola/utils";
|
|
12
12
|
import Fuse from "fuse.js";
|
|
13
13
|
const cdCommand = defineCommand({
|
|
14
14
|
meta: {
|
|
@@ -35,7 +35,47 @@ const cdCommand = defineCommand({
|
|
|
35
35
|
process.exitCode = 1;
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
|
-
|
|
38
|
+
function expandTilde(p) {
|
|
39
|
+
if (p === "~") return homedir();
|
|
40
|
+
if (p.startsWith("~/")) return resolve(homedir(), p.slice(2));
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
function resolveRoot(root, configDir) {
|
|
44
|
+
const expanded = expandTilde(root);
|
|
45
|
+
if (isAbsolute(expanded)) return expanded;
|
|
46
|
+
return resolve(configDir, expanded);
|
|
47
|
+
}
|
|
48
|
+
const CONFIG_BASENAMES = [
|
|
49
|
+
"forgemap.config.ts",
|
|
50
|
+
"forgemap.config.mts",
|
|
51
|
+
"forgemap.config.cts",
|
|
52
|
+
"forgemap.config.js",
|
|
53
|
+
"forgemap.config.mjs",
|
|
54
|
+
"forgemap.config.cjs",
|
|
55
|
+
"forgemap.config.json"
|
|
56
|
+
];
|
|
57
|
+
function findConfigUp(start) {
|
|
58
|
+
let dir = resolve(start);
|
|
59
|
+
for (; ; ) {
|
|
60
|
+
for (const base of CONFIG_BASENAMES) {
|
|
61
|
+
const candidate = join(dir, base);
|
|
62
|
+
if (existsSync(candidate)) return candidate;
|
|
63
|
+
}
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) return void 0;
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function findGlobalConfig() {
|
|
70
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
71
|
+
const dir = join(base, "forgemap");
|
|
72
|
+
for (const baseName of CONFIG_BASENAMES) {
|
|
73
|
+
const candidate = join(dir, baseName);
|
|
74
|
+
if (existsSync(candidate)) return candidate;
|
|
75
|
+
}
|
|
76
|
+
return void 0;
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_CONFIG$1 = {
|
|
39
79
|
root: ".",
|
|
40
80
|
defaultForge: "github",
|
|
41
81
|
forges: {
|
|
@@ -48,8 +88,9 @@ const DEFAULT_CONFIG = {
|
|
|
48
88
|
};
|
|
49
89
|
async function loadForgeMapConfig(options = {}) {
|
|
50
90
|
const envConfig = process.env.FORGEMAP_CONFIG;
|
|
51
|
-
const
|
|
52
|
-
const
|
|
91
|
+
const startDir = options.cwd ?? process.cwd();
|
|
92
|
+
const explicit = options.configFile ?? envConfig ?? findConfigUp(startDir) ?? findGlobalConfig();
|
|
93
|
+
const cwd = explicit ? dirname(explicit) : startDir;
|
|
53
94
|
const { config, configFile } = await loadConfig({
|
|
54
95
|
name: "forgemap",
|
|
55
96
|
cwd,
|
|
@@ -59,9 +100,9 @@ async function loadForgeMapConfig(options = {}) {
|
|
|
59
100
|
dotenv: false
|
|
60
101
|
});
|
|
61
102
|
const merged = {
|
|
62
|
-
root: config.root ?? DEFAULT_CONFIG.root,
|
|
63
|
-
defaultForge: config.defaultForge ?? DEFAULT_CONFIG.defaultForge,
|
|
64
|
-
forges: config.forges && Object.keys(config.forges).length > 0 ? config.forges : DEFAULT_CONFIG.forges
|
|
103
|
+
root: config.root ?? DEFAULT_CONFIG$1.root,
|
|
104
|
+
defaultForge: config.defaultForge ?? DEFAULT_CONFIG$1.defaultForge,
|
|
105
|
+
forges: config.forges && Object.keys(config.forges).length > 0 ? config.forges : DEFAULT_CONFIG$1.forges
|
|
65
106
|
};
|
|
66
107
|
return {
|
|
67
108
|
config: merged,
|
|
@@ -82,19 +123,50 @@ function execCapture(command, args, options = {}) {
|
|
|
82
123
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
83
124
|
const child = spawn(command, args, {
|
|
84
125
|
cwd: options.cwd,
|
|
126
|
+
env: options.env ? { ...process.env, ...options.env } : void 0,
|
|
85
127
|
stdio: ["ignore", "pipe", "pipe"]
|
|
86
128
|
});
|
|
87
129
|
let stdout = "";
|
|
88
130
|
let stderr = "";
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
let settled = false;
|
|
133
|
+
let timer;
|
|
134
|
+
let killer;
|
|
135
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
child.kill("SIGTERM");
|
|
139
|
+
killer = setTimeout(() => child.kill("SIGKILL"), 2e3);
|
|
140
|
+
killer.unref();
|
|
141
|
+
}, options.timeoutMs);
|
|
142
|
+
timer.unref();
|
|
143
|
+
}
|
|
89
144
|
child.stdout?.on("data", (chunk) => {
|
|
90
145
|
stdout += chunk.toString();
|
|
91
146
|
});
|
|
92
147
|
child.stderr?.on("data", (chunk) => {
|
|
93
148
|
stderr += chunk.toString();
|
|
94
149
|
});
|
|
95
|
-
child.on("error",
|
|
150
|
+
child.on("error", (error) => {
|
|
151
|
+
if (timer) clearTimeout(timer);
|
|
152
|
+
if (killer) clearTimeout(killer);
|
|
153
|
+
if (!settled) {
|
|
154
|
+
settled = true;
|
|
155
|
+
rejectPromise(error);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
96
158
|
child.on("close", (code) => {
|
|
97
|
-
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
if (killer) clearTimeout(killer);
|
|
161
|
+
if (!settled) {
|
|
162
|
+
settled = true;
|
|
163
|
+
resolvePromise({
|
|
164
|
+
code: code ?? (timedOut ? 124 : 0),
|
|
165
|
+
stdout,
|
|
166
|
+
stderr,
|
|
167
|
+
timedOut
|
|
168
|
+
});
|
|
169
|
+
}
|
|
98
170
|
});
|
|
99
171
|
});
|
|
100
172
|
}
|
|
@@ -111,6 +183,7 @@ function hasCommand(command) {
|
|
|
111
183
|
child.on("close", (code) => resolvePromise(code === 0));
|
|
112
184
|
});
|
|
113
185
|
}
|
|
186
|
+
const REMOTE_TIMEOUT_MS = 1e4;
|
|
114
187
|
function buildCloneUrl(opts) {
|
|
115
188
|
const forge = opts.forge;
|
|
116
189
|
const protocol = opts.protocol ?? forge.protocol ?? "ssh";
|
|
@@ -131,8 +204,94 @@ const gitAdapter = {
|
|
|
131
204
|
if (code !== 0) {
|
|
132
205
|
throw new Error(`git clone exited with code ${code}`);
|
|
133
206
|
}
|
|
207
|
+
},
|
|
208
|
+
async checkRemote(input) {
|
|
209
|
+
if (!await hasCommand("git")) {
|
|
210
|
+
return { state: "unknown", reason: "git not installed" };
|
|
211
|
+
}
|
|
212
|
+
const url = input.originUrl ?? buildCloneUrl(input);
|
|
213
|
+
const result = await execCapture("git", ["ls-remote", url], {
|
|
214
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
215
|
+
env: {
|
|
216
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
217
|
+
GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oConnectTimeout=5"
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
if (result.timedOut) {
|
|
221
|
+
return { state: "unknown", reason: "ls-remote timed out" };
|
|
222
|
+
}
|
|
223
|
+
if (result.code === 0) {
|
|
224
|
+
return {
|
|
225
|
+
state: "exists",
|
|
226
|
+
canonical: { owner: input.owner, repo: input.repo }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (isRepoMissing(result.stderr)) {
|
|
230
|
+
return { state: "gone" };
|
|
231
|
+
}
|
|
232
|
+
const reason = result.stderr.split("\n").map((line) => line.trim()).find(Boolean) ?? `git ls-remote exited with code ${result.code}`;
|
|
233
|
+
return { state: "unknown", reason };
|
|
134
234
|
}
|
|
135
235
|
};
|
|
236
|
+
function isRepoMissing(stderr) {
|
|
237
|
+
const s = stderr.toLowerCase();
|
|
238
|
+
return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
|
|
239
|
+
}
|
|
240
|
+
async function mapLimit(items, limit, fn) {
|
|
241
|
+
const results = Array.from({ length: items.length });
|
|
242
|
+
const max = Math.max(1, Math.min(limit, items.length));
|
|
243
|
+
let next = 0;
|
|
244
|
+
async function worker() {
|
|
245
|
+
while (next < items.length) {
|
|
246
|
+
const index = next++;
|
|
247
|
+
results[index] = await fn(items[index], index);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
251
|
+
return results;
|
|
252
|
+
}
|
|
253
|
+
const GRAPHQL_CHUNK = 100;
|
|
254
|
+
const FALLBACK_CONCURRENCY = 8;
|
|
255
|
+
const GH_TIMEOUT_MS = 2e4;
|
|
256
|
+
async function checkOne(owner, repo) {
|
|
257
|
+
const result = await execCapture(
|
|
258
|
+
"gh",
|
|
259
|
+
["api", `repos/${owner}/${repo}`, "--jq", ".full_name"],
|
|
260
|
+
{ timeoutMs: GH_TIMEOUT_MS }
|
|
261
|
+
);
|
|
262
|
+
if (result.timedOut) {
|
|
263
|
+
return { state: "unknown", reason: "gh api timed out" };
|
|
264
|
+
}
|
|
265
|
+
if (result.code !== 0) {
|
|
266
|
+
if (/404|not found/i.test(result.stderr)) return { state: "gone" };
|
|
267
|
+
return {
|
|
268
|
+
state: "unknown",
|
|
269
|
+
reason: result.stderr.trim() || `gh api exited with code ${result.code}`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const fullName = result.stdout.trim();
|
|
273
|
+
const [canonicalOwner, canonicalRepo] = fullName.split("/");
|
|
274
|
+
if (!canonicalOwner || !canonicalRepo) {
|
|
275
|
+
return { state: "unknown", reason: "could not parse gh api full_name" };
|
|
276
|
+
}
|
|
277
|
+
const canonical = { owner: canonicalOwner, repo: canonicalRepo };
|
|
278
|
+
if (canonicalOwner === owner && canonicalRepo === repo) {
|
|
279
|
+
return { state: "exists", canonical };
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
state: "moved",
|
|
283
|
+
canonical,
|
|
284
|
+
canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function buildQuery(chunk) {
|
|
288
|
+
const fields = chunk.map(
|
|
289
|
+
(input, i) => ` r${i}: repository(owner: ${JSON.stringify(input.owner)}, name: ${JSON.stringify(input.repo)}) { nameWithOwner }`
|
|
290
|
+
).join("\n");
|
|
291
|
+
return `query {
|
|
292
|
+
${fields}
|
|
293
|
+
}`;
|
|
294
|
+
}
|
|
136
295
|
const githubAdapter = {
|
|
137
296
|
async clone({ owner, repo, dest }) {
|
|
138
297
|
if (!await hasCommand("gh")) {
|
|
@@ -149,6 +308,68 @@ const githubAdapter = {
|
|
|
149
308
|
if (code !== 0) {
|
|
150
309
|
throw new Error(`gh repo clone exited with code ${code}`);
|
|
151
310
|
}
|
|
311
|
+
},
|
|
312
|
+
async checkRemote({
|
|
313
|
+
owner,
|
|
314
|
+
repo
|
|
315
|
+
}) {
|
|
316
|
+
if (!await hasCommand("gh")) {
|
|
317
|
+
return { state: "unknown", reason: "gh not installed" };
|
|
318
|
+
}
|
|
319
|
+
return checkOne(owner, repo);
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
|
|
323
|
+
* does not follow rename redirects, so a hit means `exists`; a null/miss
|
|
324
|
+
* could be either `gone` or `moved` and is disambiguated with a single
|
|
325
|
+
* (redirect-following) REST call, run concurrency-limited.
|
|
326
|
+
*/
|
|
327
|
+
async checkRemotes(inputs) {
|
|
328
|
+
if (inputs.length === 0) return [];
|
|
329
|
+
if (!await hasCommand("gh")) {
|
|
330
|
+
return inputs.map(() => ({
|
|
331
|
+
state: "unknown",
|
|
332
|
+
reason: "gh not installed"
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
const results = Array.from(
|
|
336
|
+
{ length: inputs.length },
|
|
337
|
+
() => null
|
|
338
|
+
);
|
|
339
|
+
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
|
|
340
|
+
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK);
|
|
341
|
+
const res = await execCapture(
|
|
342
|
+
"gh",
|
|
343
|
+
["api", "graphql", "-f", `query=${buildQuery(chunk)}`],
|
|
344
|
+
{ timeoutMs: GH_TIMEOUT_MS }
|
|
345
|
+
);
|
|
346
|
+
let data = null;
|
|
347
|
+
try {
|
|
348
|
+
data = JSON.parse(res.stdout).data ?? null;
|
|
349
|
+
} catch {
|
|
350
|
+
data = null;
|
|
351
|
+
}
|
|
352
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
353
|
+
const node = data?.[`r${i}`];
|
|
354
|
+
if (node?.nameWithOwner) {
|
|
355
|
+
const [owner, repo] = node.nameWithOwner.split("/");
|
|
356
|
+
if (owner && repo) {
|
|
357
|
+
results[start + i] = {
|
|
358
|
+
state: "exists",
|
|
359
|
+
canonical: { owner, repo }
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const pending = results.flatMap((r, i) => r === null ? [i] : []);
|
|
366
|
+
await mapLimit(pending, FALLBACK_CONCURRENCY, async (index) => {
|
|
367
|
+
results[index] = await checkOne(
|
|
368
|
+
inputs[index].owner,
|
|
369
|
+
inputs[index].repo
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
return results;
|
|
152
373
|
}
|
|
153
374
|
};
|
|
154
375
|
function getForgeAdapter(type) {
|
|
@@ -169,17 +390,7 @@ function getForgeAdapter(type) {
|
|
|
169
390
|
}
|
|
170
391
|
}
|
|
171
392
|
}
|
|
172
|
-
function
|
|
173
|
-
if (p === "~") return homedir();
|
|
174
|
-
if (p.startsWith("~/")) return resolve(homedir(), p.slice(2));
|
|
175
|
-
return p;
|
|
176
|
-
}
|
|
177
|
-
function resolveRoot(root, configDir) {
|
|
178
|
-
const expanded = expandTilde(root);
|
|
179
|
-
if (isAbsolute(expanded)) return expanded;
|
|
180
|
-
return resolve(configDir, expanded);
|
|
181
|
-
}
|
|
182
|
-
async function listDirs(path) {
|
|
393
|
+
async function listDirs$1(path) {
|
|
183
394
|
try {
|
|
184
395
|
const entries = await readdir(path, { withFileTypes: true });
|
|
185
396
|
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
@@ -194,10 +405,10 @@ async function scanRepos(options) {
|
|
|
194
405
|
const repos = [];
|
|
195
406
|
for (const [forgeName, forge] of Object.entries(config.forges)) {
|
|
196
407
|
const forgeRoot = join(root, forge.dir);
|
|
197
|
-
const owners = await listDirs(forgeRoot);
|
|
408
|
+
const owners = await listDirs$1(forgeRoot);
|
|
198
409
|
for (const owner of owners) {
|
|
199
410
|
const ownerPath = join(forgeRoot, owner);
|
|
200
|
-
const repoNames = await listDirs(ownerPath);
|
|
411
|
+
const repoNames = await listDirs$1(ownerPath);
|
|
201
412
|
for (const repo of repoNames) {
|
|
202
413
|
repos.push({
|
|
203
414
|
forgeName,
|
|
@@ -322,6 +533,132 @@ async function appendCachedRepo(options, repo) {
|
|
|
322
533
|
repos: [...cached.repos, repo]
|
|
323
534
|
});
|
|
324
535
|
}
|
|
536
|
+
async function removeCachedRepo(options, localPath) {
|
|
537
|
+
const { config, configDir } = options;
|
|
538
|
+
const root = resolveRoot(config.root, configDir);
|
|
539
|
+
const file = cachePath(root);
|
|
540
|
+
const cached = await readCacheFile(file);
|
|
541
|
+
if (!cached) return;
|
|
542
|
+
const next = cached.repos.filter((r) => r.localPath !== localPath);
|
|
543
|
+
if (next.length === cached.repos.length) return;
|
|
544
|
+
await writeCacheFile(file, {
|
|
545
|
+
fingerprint: await computeFingerprint(config, configDir),
|
|
546
|
+
writtenAt: Date.now(),
|
|
547
|
+
repos: next
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async function gitIn(cwd, args) {
|
|
551
|
+
return execCapture("git", args, { cwd });
|
|
552
|
+
}
|
|
553
|
+
const NETWORK_TIMEOUT_MS = 3e4;
|
|
554
|
+
async function gitNetwork(cwd, args) {
|
|
555
|
+
return execCapture("git", args, {
|
|
556
|
+
cwd,
|
|
557
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
558
|
+
env: {
|
|
559
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
560
|
+
GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oConnectTimeout=5"
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
async function getRepoStatus(localPath) {
|
|
565
|
+
const status = {
|
|
566
|
+
branch: "HEAD",
|
|
567
|
+
detached: false,
|
|
568
|
+
dirty: false,
|
|
569
|
+
ahead: 0,
|
|
570
|
+
behind: 0,
|
|
571
|
+
lastCommit: null
|
|
572
|
+
};
|
|
573
|
+
const branchResult = await gitIn(localPath, ["branch", "--show-current"]);
|
|
574
|
+
status.branch = branchResult.stdout.trim() || "HEAD";
|
|
575
|
+
status.detached = !status.branch || status.branch === "HEAD";
|
|
576
|
+
const porcelain = await gitIn(localPath, ["status", "--porcelain"]);
|
|
577
|
+
status.dirty = porcelain.stdout.trim().length > 0;
|
|
578
|
+
const aheadBehind = await gitIn(localPath, [
|
|
579
|
+
"rev-list",
|
|
580
|
+
"--left-right",
|
|
581
|
+
"--count",
|
|
582
|
+
"@{u}...HEAD"
|
|
583
|
+
]);
|
|
584
|
+
if (aheadBehind.code === 0) {
|
|
585
|
+
const match = aheadBehind.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
586
|
+
if (match) {
|
|
587
|
+
status.behind = Number(match[1]);
|
|
588
|
+
status.ahead = Number(match[2]);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const lastCommit = await gitIn(localPath, ["log", "-1", "--format=%h|%cr"]);
|
|
592
|
+
if (lastCommit.code === 0) {
|
|
593
|
+
const [sha, relativeDate] = lastCommit.stdout.trim().split("|");
|
|
594
|
+
if (sha && relativeDate) {
|
|
595
|
+
status.lastCommit = { sha, relativeDate };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return status;
|
|
599
|
+
}
|
|
600
|
+
async function fetchRepo(localPath) {
|
|
601
|
+
return gitNetwork(localPath, ["fetch", "--all", "--prune"]);
|
|
602
|
+
}
|
|
603
|
+
async function pullRepo(localPath) {
|
|
604
|
+
return gitNetwork(localPath, ["pull", "--ff-only"]);
|
|
605
|
+
}
|
|
606
|
+
async function isClean(localPath) {
|
|
607
|
+
const result = await gitIn(localPath, ["status", "--porcelain"]);
|
|
608
|
+
return result.code === 0 && result.stdout.trim().length === 0;
|
|
609
|
+
}
|
|
610
|
+
async function isGitRepo(localPath) {
|
|
611
|
+
const result = await gitIn(localPath, ["rev-parse", "--is-inside-work-tree"]);
|
|
612
|
+
return result.code === 0 && result.stdout.trim() === "true";
|
|
613
|
+
}
|
|
614
|
+
async function getOriginUrl(localPath) {
|
|
615
|
+
const result = await gitIn(localPath, ["remote", "get-url", "origin"]);
|
|
616
|
+
if (result.code !== 0) return null;
|
|
617
|
+
const url = result.stdout.trim();
|
|
618
|
+
return url.length > 0 ? url : null;
|
|
619
|
+
}
|
|
620
|
+
async function getRemotes(localPath) {
|
|
621
|
+
const result = await gitIn(localPath, [
|
|
622
|
+
"config",
|
|
623
|
+
"--get-regexp",
|
|
624
|
+
"^remote\\..*\\.url$"
|
|
625
|
+
]);
|
|
626
|
+
if (result.code !== 0) return [];
|
|
627
|
+
const remotes = [];
|
|
628
|
+
for (const line of result.stdout.split("\n")) {
|
|
629
|
+
const trimmed = line.trim();
|
|
630
|
+
if (!trimmed) continue;
|
|
631
|
+
const match = trimmed.match(/^remote\.(.+)\.url\s+(.+)$/);
|
|
632
|
+
if (match) remotes.push({ name: match[1], url: match[2] });
|
|
633
|
+
}
|
|
634
|
+
return remotes;
|
|
635
|
+
}
|
|
636
|
+
async function setOriginUrl(localPath, url) {
|
|
637
|
+
return gitIn(localPath, ["remote", "set-url", "origin", url]);
|
|
638
|
+
}
|
|
639
|
+
async function getLastCommitUnix(localPath) {
|
|
640
|
+
const result = await gitIn(localPath, [
|
|
641
|
+
"log",
|
|
642
|
+
"--branches",
|
|
643
|
+
"-1",
|
|
644
|
+
"--format=%ct"
|
|
645
|
+
]);
|
|
646
|
+
if (result.code !== 0) return null;
|
|
647
|
+
const ts = Number.parseInt(result.stdout.trim(), 10);
|
|
648
|
+
return Number.isFinite(ts) ? ts : null;
|
|
649
|
+
}
|
|
650
|
+
async function hasUnpushedCommits(localPath) {
|
|
651
|
+
const result = await gitIn(localPath, [
|
|
652
|
+
"log",
|
|
653
|
+
"--branches",
|
|
654
|
+
"--not",
|
|
655
|
+
"--remotes",
|
|
656
|
+
"--format=%H",
|
|
657
|
+
"-1"
|
|
658
|
+
]);
|
|
659
|
+
if (result.code !== 0) return true;
|
|
660
|
+
return result.stdout.trim().length > 0;
|
|
661
|
+
}
|
|
325
662
|
const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
|
|
326
663
|
const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
|
|
327
664
|
const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
|
|
@@ -375,75 +712,129 @@ function parseSlug(input) {
|
|
|
375
712
|
}
|
|
376
713
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
377
714
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
715
|
+
const DAY_SECONDS = 86400;
|
|
716
|
+
const LOCAL_CONCURRENCY$1 = 16;
|
|
717
|
+
const REMOTE_CONCURRENCY$1 = 10;
|
|
718
|
+
async function evaluate(repo, cutoffUnix) {
|
|
719
|
+
if (!await isGitRepo(repo.localPath)) return null;
|
|
720
|
+
const origin = await getOriginUrl(repo.localPath);
|
|
721
|
+
if (!origin) return null;
|
|
722
|
+
const lastCommitUnix = await getLastCommitUnix(repo.localPath);
|
|
723
|
+
if (lastCommitUnix === null || lastCommitUnix > cutoffUnix) return null;
|
|
724
|
+
const status = await getRepoStatus(repo.localPath);
|
|
725
|
+
const dirty = status.dirty;
|
|
726
|
+
const unpushed = await hasUnpushedCommits(repo.localPath);
|
|
727
|
+
let owner = repo.owner;
|
|
728
|
+
let name = repo.repo;
|
|
729
|
+
try {
|
|
730
|
+
const parsed = parseSlug(origin);
|
|
731
|
+
owner = parsed.owner;
|
|
732
|
+
name = parsed.repo;
|
|
733
|
+
} catch {
|
|
383
734
|
}
|
|
384
|
-
return
|
|
735
|
+
return { repo, origin, owner, name, lastCommitUnix, dirty, unpushed };
|
|
385
736
|
}
|
|
386
|
-
function
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (!candidate) {
|
|
393
|
-
throw new Error(
|
|
394
|
-
`Forge "${parsed.forgeName}" is not defined in forgemap.config`
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
forgeName = parsed.forgeName;
|
|
398
|
-
forge = candidate;
|
|
399
|
-
} else if (parsed.host) {
|
|
400
|
-
const match = findForgeByHost(config.forges, parsed.host);
|
|
401
|
-
if (!match) {
|
|
402
|
-
throw new Error(
|
|
403
|
-
`No forge configured for host "${parsed.host}". Add it to forgemap.config.ts.`
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
forgeName = match.name;
|
|
407
|
-
forge = match.forge;
|
|
408
|
-
} else {
|
|
409
|
-
const candidate = config.forges[config.defaultForge];
|
|
410
|
-
if (!candidate) {
|
|
411
|
-
throw new Error(
|
|
412
|
-
`Default forge "${config.defaultForge}" is not defined in forgemap.config`
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
forgeName = config.defaultForge;
|
|
416
|
-
forge = candidate;
|
|
737
|
+
async function classifyRemotes(candidates) {
|
|
738
|
+
const byType = /* @__PURE__ */ new Map();
|
|
739
|
+
for (const c of candidates) {
|
|
740
|
+
const list = byType.get(c.repo.forge.type);
|
|
741
|
+
if (list) list.push(c);
|
|
742
|
+
else byType.set(c.repo.forge.type, [c]);
|
|
417
743
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
744
|
+
const results = /* @__PURE__ */ new Map();
|
|
745
|
+
await Promise.all(
|
|
746
|
+
Array.from(byType, async ([type, items]) => {
|
|
747
|
+
const inputs = items.map((c) => ({
|
|
748
|
+
forge: c.repo.forge,
|
|
749
|
+
owner: c.owner,
|
|
750
|
+
repo: c.name,
|
|
751
|
+
originUrl: c.origin
|
|
752
|
+
}));
|
|
753
|
+
let adapter;
|
|
754
|
+
try {
|
|
755
|
+
adapter = getForgeAdapter(type);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
for (const c of items) {
|
|
758
|
+
results.set(c.repo.localPath, {
|
|
759
|
+
state: "unknown",
|
|
760
|
+
reason: error.message
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
let res;
|
|
766
|
+
if (adapter.checkRemotes) {
|
|
767
|
+
try {
|
|
768
|
+
res = await adapter.checkRemotes(inputs);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
res = inputs.map(() => ({
|
|
771
|
+
state: "unknown",
|
|
772
|
+
reason: error.message
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
775
|
+
} else if (adapter.checkRemote) {
|
|
776
|
+
const check = adapter.checkRemote;
|
|
777
|
+
res = await mapLimit(inputs, REMOTE_CONCURRENCY$1, async (inp) => {
|
|
778
|
+
try {
|
|
779
|
+
return await check(inp);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
return { state: "unknown", reason: error.message };
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
} else {
|
|
785
|
+
res = inputs.map(() => ({
|
|
786
|
+
state: "unknown",
|
|
787
|
+
reason: `${type} has no remote check`
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
items.forEach((c, i) => results.set(c.repo.localPath, res[i]));
|
|
791
|
+
})
|
|
792
|
+
);
|
|
793
|
+
return results;
|
|
427
794
|
}
|
|
428
|
-
|
|
795
|
+
function ageDays(lastCommitUnix) {
|
|
796
|
+
return Math.floor(
|
|
797
|
+
Date.now() / 1e3 / DAY_SECONDS - lastCommitUnix / DAY_SECONDS
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const cleanupCommand = defineCommand({
|
|
429
801
|
meta: {
|
|
430
|
-
name: "
|
|
431
|
-
description: "
|
|
802
|
+
name: "cleanup",
|
|
803
|
+
description: "List stale, clean, fully-pushed repos whose remote still exists, then delete them locally after confirmation"
|
|
432
804
|
},
|
|
433
805
|
args: {
|
|
434
|
-
|
|
435
|
-
type: "
|
|
436
|
-
description: "
|
|
437
|
-
|
|
806
|
+
days: {
|
|
807
|
+
type: "string",
|
|
808
|
+
description: "Minimum age in days since the last commit (default 365)",
|
|
809
|
+
default: "365"
|
|
438
810
|
},
|
|
439
|
-
|
|
811
|
+
forge: {
|
|
812
|
+
type: "string",
|
|
813
|
+
description: "Restrict to a single forge alias"
|
|
814
|
+
},
|
|
815
|
+
"dry-run": {
|
|
440
816
|
type: "boolean",
|
|
441
|
-
description: "
|
|
817
|
+
description: "Only list candidates; never prompt or delete",
|
|
442
818
|
default: false
|
|
443
819
|
},
|
|
444
|
-
|
|
820
|
+
yes: {
|
|
445
821
|
type: "boolean",
|
|
446
|
-
description: "
|
|
822
|
+
description: "Skip the interactive confirmation (deletes immediately)",
|
|
823
|
+
default: false
|
|
824
|
+
},
|
|
825
|
+
"include-dirty": {
|
|
826
|
+
type: "boolean",
|
|
827
|
+
description: "Also delete repos with uncommitted changes (those changes are lost)",
|
|
828
|
+
default: false
|
|
829
|
+
},
|
|
830
|
+
"include-unpushed": {
|
|
831
|
+
type: "boolean",
|
|
832
|
+
description: "Also delete repos with unpushed commits (those commits are lost)",
|
|
833
|
+
default: false
|
|
834
|
+
},
|
|
835
|
+
"no-cache": {
|
|
836
|
+
type: "boolean",
|
|
837
|
+
description: "Skip the scanned-repos cache",
|
|
447
838
|
default: false
|
|
448
839
|
},
|
|
449
840
|
config: {
|
|
@@ -452,27 +843,275 @@ const cloneCommand = defineCommand({
|
|
|
452
843
|
}
|
|
453
844
|
},
|
|
454
845
|
async run({ args }) {
|
|
455
|
-
|
|
456
|
-
|
|
846
|
+
const days = Number.parseInt(args.days, 10);
|
|
847
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
848
|
+
consola.error(`Invalid --days value "${args.days}".`);
|
|
457
849
|
process.exitCode = 1;
|
|
458
850
|
return;
|
|
459
851
|
}
|
|
460
852
|
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
461
|
-
const parsed = parseSlug(args.slug);
|
|
462
853
|
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
463
|
-
|
|
854
|
+
let repos = await scanReposCached({
|
|
464
855
|
config: loaded.config,
|
|
465
|
-
configDir
|
|
856
|
+
configDir,
|
|
857
|
+
useCache: !args["no-cache"]
|
|
466
858
|
});
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
859
|
+
if (args.forge) repos = repos.filter((r) => r.forgeName === args.forge);
|
|
860
|
+
const cutoffUnix = Math.floor(Date.now() / 1e3) - days * DAY_SECONDS;
|
|
861
|
+
const stale = (await mapLimit(
|
|
862
|
+
repos,
|
|
863
|
+
LOCAL_CONCURRENCY$1,
|
|
864
|
+
(repo) => evaluate(repo, cutoffUnix)
|
|
865
|
+
)).filter((c) => c !== null);
|
|
866
|
+
const includeDirty = Boolean(args["include-dirty"]);
|
|
867
|
+
const includeUnpushed = Boolean(args["include-unpushed"]);
|
|
868
|
+
const localOk = (c) => (!c.dirty || includeDirty) && (!c.unpushed || includeUnpushed);
|
|
869
|
+
const remoteStates = await classifyRemotes(stale.filter(localOk));
|
|
870
|
+
const candidates = [];
|
|
871
|
+
const kept = [];
|
|
872
|
+
for (const c of stale) {
|
|
873
|
+
if (c.dirty && !includeDirty) {
|
|
874
|
+
kept.push({ repo: c, reason: "uncommitted changes" });
|
|
875
|
+
} else if (c.unpushed && !includeUnpushed) {
|
|
876
|
+
kept.push({ repo: c, reason: "unpushed commits" });
|
|
877
|
+
} else {
|
|
878
|
+
const state = remoteStates.get(c.repo.localPath)?.state;
|
|
879
|
+
if (state === "exists" || state === "moved") candidates.push(c);
|
|
880
|
+
else {
|
|
881
|
+
kept.push({
|
|
882
|
+
repo: c,
|
|
883
|
+
reason: state === "gone" ? "remote no longer exists" : "remote unreachable"
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
candidates.sort((a, b) => a.lastCommitUnix - b.lastCommitUnix);
|
|
889
|
+
kept.sort((a, b) => a.repo.lastCommitUnix - b.repo.lastCommitUnix);
|
|
890
|
+
if (candidates.length > 0) {
|
|
891
|
+
process.stdout.write(
|
|
892
|
+
`${colors.bold(`${candidates.length} repo(s) eligible for cleanup`)} ${colors.dim(`(idle ${days}+ days, remote exists)`)}
|
|
893
|
+
|
|
894
|
+
`
|
|
895
|
+
);
|
|
896
|
+
for (const c of candidates) {
|
|
897
|
+
const flags = [
|
|
898
|
+
c.dirty ? colors.red("dirty") : "",
|
|
899
|
+
c.unpushed ? colors.red("unpushed") : ""
|
|
900
|
+
].filter(Boolean).join(" ");
|
|
901
|
+
process.stdout.write(
|
|
902
|
+
` ${colors.cyan(`${c.repo.forgeName}:${c.repo.slug}`)} ${colors.dim(`${ageDays(c.lastCommitUnix)}d idle`)}${flags ? ` ${flags}` : ""} ${colors.dim(c.repo.localPath)}
|
|
903
|
+
`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
process.stdout.write("\n");
|
|
907
|
+
}
|
|
908
|
+
if (kept.length > 0) {
|
|
909
|
+
process.stdout.write(
|
|
910
|
+
`${colors.dim(`${kept.length} idle repo(s) kept (not safe to delete):`)}
|
|
911
|
+
`
|
|
912
|
+
);
|
|
913
|
+
for (const k of kept) {
|
|
914
|
+
process.stdout.write(
|
|
915
|
+
` ${colors.dim(`${k.repo.repo.forgeName}:${k.repo.repo.slug} ${ageDays(k.repo.lastCommitUnix)}d idle — ${k.reason}`)}
|
|
916
|
+
`
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
process.stdout.write("\n");
|
|
920
|
+
}
|
|
921
|
+
const root = resolveRoot(loaded.config.root, configDir);
|
|
922
|
+
if (args["dry-run"]) {
|
|
923
|
+
const empties = await findEmptyDirs(root, loaded.config);
|
|
924
|
+
if (empties.length > 0) {
|
|
925
|
+
process.stdout.write(
|
|
926
|
+
`${colors.dim(`${empties.length} empty folder(s) would be removed:`)}
|
|
927
|
+
`
|
|
928
|
+
);
|
|
929
|
+
for (const e of empties) {
|
|
930
|
+
process.stdout.write(` ${colors.dim(e)}
|
|
931
|
+
`);
|
|
932
|
+
}
|
|
933
|
+
process.stdout.write("\n");
|
|
934
|
+
}
|
|
935
|
+
consola.info(
|
|
936
|
+
candidates.length > 0 ? "Dry run — nothing deleted." : "Nothing to delete."
|
|
937
|
+
);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (candidates.length > 0) {
|
|
941
|
+
const losing = candidates.filter((c) => c.dirty || c.unpushed).length;
|
|
942
|
+
if (losing > 0) {
|
|
943
|
+
consola.warn(
|
|
944
|
+
`${losing} of these have uncommitted/unpushed work that will be permanently lost.`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
let confirmed = args.yes;
|
|
948
|
+
if (!confirmed) {
|
|
949
|
+
const answer = await consola.prompt(
|
|
950
|
+
`Type "yes" to delete these ${candidates.length} repo(s) locally:`,
|
|
951
|
+
{ type: "text", cancel: "null" }
|
|
952
|
+
);
|
|
953
|
+
confirmed = typeof answer === "string" && answer.trim() === "yes";
|
|
954
|
+
}
|
|
955
|
+
if (!confirmed) {
|
|
956
|
+
consola.info("Aborted — nothing deleted.");
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
for (const c of candidates) {
|
|
960
|
+
await rm(c.repo.localPath, { recursive: true, force: true });
|
|
961
|
+
await removeCachedRepo(
|
|
962
|
+
{ config: loaded.config, configDir },
|
|
963
|
+
c.repo.localPath
|
|
964
|
+
);
|
|
965
|
+
consola.success(`Deleted ${c.repo.localPath}`);
|
|
966
|
+
}
|
|
967
|
+
consola.success(`Removed ${candidates.length} repo(s).`);
|
|
968
|
+
}
|
|
969
|
+
const emptied = await pruneEmptyDirs(root, loaded.config);
|
|
970
|
+
if (emptied > 0) {
|
|
971
|
+
consola.success(`Removed ${emptied} empty folder(s).`);
|
|
972
|
+
} else if (candidates.length === 0) {
|
|
973
|
+
consola.info("Nothing to clean up.");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
async function safeReaddir(path) {
|
|
978
|
+
try {
|
|
979
|
+
return await readdir(path);
|
|
980
|
+
} catch {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function findEmptyDirs(root, config) {
|
|
985
|
+
const empties = [];
|
|
986
|
+
for (const forge of Object.values(config.forges)) {
|
|
987
|
+
const serverPath = join(root, forge.dir);
|
|
988
|
+
const owners = await safeReaddir(serverPath);
|
|
989
|
+
if (owners === null) continue;
|
|
990
|
+
let emptyCount = 0;
|
|
991
|
+
for (const owner of owners) {
|
|
992
|
+
const ownerPath = join(serverPath, owner);
|
|
993
|
+
const inner = await safeReaddir(ownerPath);
|
|
994
|
+
if (inner !== null && inner.length === 0) {
|
|
995
|
+
empties.push(ownerPath);
|
|
996
|
+
emptyCount++;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (owners.length === 0 || emptyCount === owners.length) {
|
|
1000
|
+
empties.push(serverPath);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return empties;
|
|
1004
|
+
}
|
|
1005
|
+
async function pruneEmptyDirs(root, config) {
|
|
1006
|
+
const empties = await findEmptyDirs(root, config);
|
|
1007
|
+
let removed = 0;
|
|
1008
|
+
for (const dir of empties) {
|
|
1009
|
+
try {
|
|
1010
|
+
await rmdir(dir);
|
|
1011
|
+
removed++;
|
|
1012
|
+
} catch {
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return removed;
|
|
1016
|
+
}
|
|
1017
|
+
function findForgeByHost(forges, host) {
|
|
1018
|
+
for (const [name, forge] of Object.entries(forges)) {
|
|
1019
|
+
if (forge.host.toLowerCase() === host.toLowerCase()) {
|
|
1020
|
+
return { name, forge };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return void 0;
|
|
1024
|
+
}
|
|
1025
|
+
function resolveSlug(parsed, options) {
|
|
1026
|
+
const { config, configDir } = options;
|
|
1027
|
+
let forgeName;
|
|
1028
|
+
let forge;
|
|
1029
|
+
if (parsed.forgeName) {
|
|
1030
|
+
const candidate = config.forges[parsed.forgeName];
|
|
1031
|
+
if (!candidate) {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`Forge "${parsed.forgeName}" is not defined in forgemap.config`
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
forgeName = parsed.forgeName;
|
|
1037
|
+
forge = candidate;
|
|
1038
|
+
} else if (parsed.host) {
|
|
1039
|
+
const match = findForgeByHost(config.forges, parsed.host);
|
|
1040
|
+
if (!match) {
|
|
1041
|
+
throw new Error(
|
|
1042
|
+
`No forge configured for host "${parsed.host}". Add it to forgemap.config.ts.`
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
forgeName = match.name;
|
|
1046
|
+
forge = match.forge;
|
|
1047
|
+
} else {
|
|
1048
|
+
const candidate = config.forges[config.defaultForge];
|
|
1049
|
+
if (!candidate) {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
`Default forge "${config.defaultForge}" is not defined in forgemap.config`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
forgeName = config.defaultForge;
|
|
1055
|
+
forge = candidate;
|
|
1056
|
+
}
|
|
1057
|
+
const root = resolveRoot(config.root, configDir);
|
|
1058
|
+
const localPath = join(root, forge.dir, parsed.owner, parsed.repo);
|
|
1059
|
+
return {
|
|
1060
|
+
forgeName,
|
|
1061
|
+
forge,
|
|
1062
|
+
owner: parsed.owner,
|
|
1063
|
+
repo: parsed.repo,
|
|
1064
|
+
localPath
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
const cloneCommand = defineCommand({
|
|
1068
|
+
meta: {
|
|
1069
|
+
name: "clone",
|
|
1070
|
+
description: "Clone a repo into the configured local layout"
|
|
1071
|
+
},
|
|
1072
|
+
args: {
|
|
1073
|
+
slug: {
|
|
1074
|
+
type: "positional",
|
|
1075
|
+
description: "owner/repo, forge:owner/repo, or full URL",
|
|
1076
|
+
required: true
|
|
1077
|
+
},
|
|
1078
|
+
ssh: {
|
|
1079
|
+
type: "boolean",
|
|
1080
|
+
description: "Force the SSH URL form (git-type forges only)",
|
|
1081
|
+
default: false
|
|
1082
|
+
},
|
|
1083
|
+
https: {
|
|
1084
|
+
type: "boolean",
|
|
1085
|
+
description: "Force the HTTPS URL form (git-type forges only)",
|
|
1086
|
+
default: false
|
|
1087
|
+
},
|
|
1088
|
+
config: {
|
|
1089
|
+
type: "string",
|
|
1090
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
async run({ args }) {
|
|
1094
|
+
if (args.ssh && args.https) {
|
|
1095
|
+
consola.error("--ssh and --https are mutually exclusive.");
|
|
1096
|
+
process.exitCode = 1;
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
1100
|
+
const parsed = parseSlug(args.slug);
|
|
1101
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
1102
|
+
const resolved = resolveSlug(parsed, {
|
|
1103
|
+
config: loaded.config,
|
|
1104
|
+
configDir
|
|
1105
|
+
});
|
|
1106
|
+
let protocol;
|
|
1107
|
+
if (args.ssh) protocol = "ssh";
|
|
1108
|
+
else if (args.https) protocol = "https";
|
|
1109
|
+
if (protocol && resolved.forge.type !== "git") {
|
|
1110
|
+
consola.warn(
|
|
1111
|
+
`--${protocol} is ignored for type "${resolved.forge.type}" — the adapter selects the URL itself.`
|
|
1112
|
+
);
|
|
1113
|
+
protocol = void 0;
|
|
1114
|
+
}
|
|
476
1115
|
if (existsSync(resolved.localPath)) {
|
|
477
1116
|
consola.info(`Already cloned at ${resolved.localPath}`);
|
|
478
1117
|
return;
|
|
@@ -502,9 +1141,64 @@ const cloneCommand = defineCommand({
|
|
|
502
1141
|
);
|
|
503
1142
|
}
|
|
504
1143
|
});
|
|
505
|
-
const
|
|
1144
|
+
const SUPPORTED_SHELLS = ["zsh", "bash", "fish"];
|
|
1145
|
+
function detectShell() {
|
|
1146
|
+
const env = process.env.SHELL ?? "";
|
|
1147
|
+
if (env.endsWith("/fish")) return "fish";
|
|
1148
|
+
if (env.endsWith("/bash")) return "bash";
|
|
1149
|
+
return "zsh";
|
|
1150
|
+
}
|
|
1151
|
+
function rcFileFor(shell) {
|
|
1152
|
+
const home = homedir();
|
|
1153
|
+
if (shell === "fish") return join(home, ".config", "fish", "config.fish");
|
|
1154
|
+
if (shell === "bash") return join(home, ".bashrc");
|
|
1155
|
+
return join(home, ".zshrc");
|
|
1156
|
+
}
|
|
1157
|
+
function escapeRegExp(s) {
|
|
1158
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1159
|
+
}
|
|
1160
|
+
function stripBlocks(content, labels) {
|
|
1161
|
+
let out = content;
|
|
1162
|
+
for (const label of labels) {
|
|
1163
|
+
const l = escapeRegExp(label);
|
|
1164
|
+
const re = new RegExp(
|
|
1165
|
+
`\\n*# >>> forgemap ${l} >>>[\\s\\S]*?# <<< forgemap ${l} <<<\\n?`,
|
|
1166
|
+
"g"
|
|
1167
|
+
);
|
|
1168
|
+
out = out.replace(re, "");
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
async function installRcBlock(shell, label, lines, legacyLabels = []) {
|
|
1173
|
+
const rcFile = rcFileFor(shell);
|
|
1174
|
+
let existing = "";
|
|
1175
|
+
try {
|
|
1176
|
+
existing = await readFile(rcFile, "utf8");
|
|
1177
|
+
} catch {
|
|
1178
|
+
}
|
|
1179
|
+
const allLabels = [label, ...legacyLabels];
|
|
1180
|
+
const hadAny = allLabels.some(
|
|
1181
|
+
(l) => existing.includes(`# >>> forgemap ${l} >>>`)
|
|
1182
|
+
);
|
|
1183
|
+
const block = `# >>> forgemap ${label} >>>
|
|
1184
|
+
${lines.join("\n")}
|
|
1185
|
+
# <<< forgemap ${label} <<<
|
|
1186
|
+
`;
|
|
1187
|
+
const cleaned = stripBlocks(existing, allLabels).replace(/\s*$/, "");
|
|
1188
|
+
const next = cleaned.length > 0 ? `${cleaned}
|
|
1189
|
+
|
|
1190
|
+
${block}` : block;
|
|
1191
|
+
if (next === existing) {
|
|
1192
|
+
return { status: "present", rcFile };
|
|
1193
|
+
}
|
|
1194
|
+
await mkdir(dirname(rcFile), { recursive: true });
|
|
1195
|
+
await writeFile(rcFile, next, "utf8");
|
|
1196
|
+
return { status: hadAny ? "updated" : "installed", rcFile };
|
|
1197
|
+
}
|
|
506
1198
|
const SUBCOMMANDS = [
|
|
507
1199
|
"clone",
|
|
1200
|
+
"import",
|
|
1201
|
+
"cleanup",
|
|
508
1202
|
"cd",
|
|
509
1203
|
"path",
|
|
510
1204
|
"open",
|
|
@@ -518,12 +1212,6 @@ const SUBCOMMANDS = [
|
|
|
518
1212
|
"config"
|
|
519
1213
|
];
|
|
520
1214
|
const SLUG_COMMANDS = ["clone", "cd", "path", "open", "search", "pick"];
|
|
521
|
-
function detectShell$1() {
|
|
522
|
-
const env = process.env.SHELL ?? "";
|
|
523
|
-
if (env.endsWith("/fish")) return "fish";
|
|
524
|
-
if (env.endsWith("/bash")) return "bash";
|
|
525
|
-
return "zsh";
|
|
526
|
-
}
|
|
527
1215
|
function renderBash() {
|
|
528
1216
|
return `# forgemap bash completion — drop into your ~/.bashrc:
|
|
529
1217
|
# eval "$(forgemap completion bash)"
|
|
@@ -606,24 +1294,45 @@ const completionCommand = defineCommand({
|
|
|
606
1294
|
args: {
|
|
607
1295
|
shell: {
|
|
608
1296
|
type: "positional",
|
|
609
|
-
description: `Shell flavor (${
|
|
1297
|
+
description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
|
|
610
1298
|
required: false
|
|
1299
|
+
},
|
|
1300
|
+
install: {
|
|
1301
|
+
type: "boolean",
|
|
1302
|
+
description: "Append the completion loader to your shell's rc file (idempotent) instead of printing",
|
|
1303
|
+
default: false
|
|
611
1304
|
}
|
|
612
1305
|
},
|
|
613
1306
|
async run({ args }) {
|
|
614
|
-
const requested = args.shell ?? detectShell
|
|
615
|
-
if (!
|
|
1307
|
+
const requested = args.shell ?? detectShell();
|
|
1308
|
+
if (!SUPPORTED_SHELLS.includes(requested)) {
|
|
616
1309
|
consola.error(
|
|
617
|
-
`Unsupported shell "${requested}". Supported: ${
|
|
1310
|
+
`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
618
1311
|
);
|
|
619
1312
|
process.exitCode = 1;
|
|
620
1313
|
return;
|
|
621
1314
|
}
|
|
1315
|
+
if (args.install) {
|
|
1316
|
+
const loader = requested === "fish" ? "forgemap completion fish | source" : `eval "$(forgemap completion ${requested})"`;
|
|
1317
|
+
const { status, rcFile } = await installRcBlock(requested, "completion", [
|
|
1318
|
+
loader
|
|
1319
|
+
]);
|
|
1320
|
+
if (status === "present") {
|
|
1321
|
+
consola.info(`forgemap completion already present in ${rcFile}.`);
|
|
1322
|
+
} else {
|
|
1323
|
+
const verb = status === "updated" ? "Updated" : "Added";
|
|
1324
|
+
consola.success(`${verb} forgemap completion in ${rcFile}.`);
|
|
1325
|
+
consola.info(
|
|
1326
|
+
`Run \`source ${rcFile}\` or restart your shell to activate it.`
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
622
1331
|
const out = requested === "fish" ? renderFish$1() : requested === "zsh" ? renderZsh() : renderBash();
|
|
623
1332
|
process.stdout.write(out);
|
|
624
1333
|
}
|
|
625
1334
|
});
|
|
626
|
-
const
|
|
1335
|
+
const HEADER = `/**
|
|
627
1336
|
* forgemap configuration.
|
|
628
1337
|
*
|
|
629
1338
|
* For type-safe authoring, install forgemap and switch to:
|
|
@@ -631,19 +1340,63 @@ const TEMPLATE = `/**
|
|
|
631
1340
|
* export default defineForgeMapConfig({ ... });
|
|
632
1341
|
*
|
|
633
1342
|
* @type {import('forgemap').ForgeMapUserConfig}
|
|
634
|
-
|
|
1343
|
+
*/`;
|
|
1344
|
+
function quoteKey(name) {
|
|
1345
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name}'`;
|
|
1346
|
+
}
|
|
1347
|
+
function renderForge(forge) {
|
|
1348
|
+
const lines = [
|
|
1349
|
+
` type: '${forge.type}',`,
|
|
1350
|
+
` host: '${forge.host}',`,
|
|
1351
|
+
` dir: '${forge.dir}'`
|
|
1352
|
+
];
|
|
1353
|
+
if (forge.type === "git" && forge.protocol) {
|
|
1354
|
+
lines.splice(1, 0, ` protocol: '${forge.protocol}',`);
|
|
1355
|
+
}
|
|
1356
|
+
return `{
|
|
1357
|
+
${lines.join("\n")}
|
|
1358
|
+
}`;
|
|
1359
|
+
}
|
|
1360
|
+
function renderConfigModule(config) {
|
|
1361
|
+
const forgeEntries = Object.entries(config.forges).map(([name, forge]) => ` ${quoteKey(name)}: ${renderForge(forge)}`).join(",\n");
|
|
1362
|
+
return `${HEADER}
|
|
635
1363
|
export default {
|
|
636
|
-
root: '.',
|
|
637
|
-
defaultForge: '
|
|
1364
|
+
root: '${config.root}',
|
|
1365
|
+
defaultForge: '${config.defaultForge}',
|
|
1366
|
+
forges: {
|
|
1367
|
+
${forgeEntries}
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
`;
|
|
1371
|
+
}
|
|
1372
|
+
async function writeConfigFile(config, options) {
|
|
1373
|
+
const outDir = resolve(process.cwd(), options.outDir);
|
|
1374
|
+
const target = join(outDir, "forgemap.config.ts");
|
|
1375
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1376
|
+
try {
|
|
1377
|
+
await writeFile(target, renderConfigModule(config), {
|
|
1378
|
+
encoding: "utf8",
|
|
1379
|
+
flag: options.force ? "w" : "wx"
|
|
1380
|
+
});
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (error.code === "EEXIST") {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
return { path: target };
|
|
1388
|
+
}
|
|
1389
|
+
const DEFAULT_CONFIG = {
|
|
1390
|
+
root: ".",
|
|
1391
|
+
defaultForge: "github",
|
|
638
1392
|
forges: {
|
|
639
1393
|
github: {
|
|
640
|
-
type:
|
|
641
|
-
host:
|
|
642
|
-
dir:
|
|
1394
|
+
type: "github",
|
|
1395
|
+
host: "github.com",
|
|
1396
|
+
dir: "comGithub"
|
|
643
1397
|
}
|
|
644
1398
|
}
|
|
645
1399
|
};
|
|
646
|
-
`;
|
|
647
1400
|
const configInitCommand = defineCommand({
|
|
648
1401
|
meta: {
|
|
649
1402
|
name: "init",
|
|
@@ -662,23 +1415,20 @@ const configInitCommand = defineCommand({
|
|
|
662
1415
|
}
|
|
663
1416
|
},
|
|
664
1417
|
async run({ args }) {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
throw error;
|
|
1418
|
+
const result = await writeConfigFile(DEFAULT_CONFIG, {
|
|
1419
|
+
outDir: args.out,
|
|
1420
|
+
force: args.force
|
|
1421
|
+
});
|
|
1422
|
+
if (!result) {
|
|
1423
|
+
const target = join(
|
|
1424
|
+
resolve(process.cwd(), args.out),
|
|
1425
|
+
"forgemap.config.ts"
|
|
1426
|
+
);
|
|
1427
|
+
consola.error(`${target} already exists. Use --force to overwrite.`);
|
|
1428
|
+
process.exitCode = 1;
|
|
1429
|
+
return;
|
|
680
1430
|
}
|
|
681
|
-
consola.success(`Wrote ${
|
|
1431
|
+
consola.success(`Wrote ${result.path}`);
|
|
682
1432
|
}
|
|
683
1433
|
});
|
|
684
1434
|
const configShowCommand = defineCommand({
|
|
@@ -717,6 +1467,558 @@ const configCommand = defineCommand({
|
|
|
717
1467
|
show: configShowCommand
|
|
718
1468
|
}
|
|
719
1469
|
});
|
|
1470
|
+
async function listDirs(path) {
|
|
1471
|
+
try {
|
|
1472
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
1473
|
+
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
if (error.code === "ENOENT") return [];
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
async function discoverForgemapLayout(path) {
|
|
1480
|
+
const repos = [];
|
|
1481
|
+
for (const serverDir of await listDirs(path)) {
|
|
1482
|
+
const serverPath = join(path, serverDir);
|
|
1483
|
+
for (const owner of await listDirs(serverPath)) {
|
|
1484
|
+
const ownerPath = join(serverPath, owner);
|
|
1485
|
+
for (const repo of await listDirs(ownerPath)) {
|
|
1486
|
+
repos.push({
|
|
1487
|
+
serverDir,
|
|
1488
|
+
owner,
|
|
1489
|
+
repo,
|
|
1490
|
+
localPath: join(ownerPath, repo)
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return repos;
|
|
1496
|
+
}
|
|
1497
|
+
function forgeTypeForHost(host) {
|
|
1498
|
+
return host === "github.com" ? "github" : "git";
|
|
1499
|
+
}
|
|
1500
|
+
function deriveConfig(reports, path) {
|
|
1501
|
+
const forges = {};
|
|
1502
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1503
|
+
const byServer = /* @__PURE__ */ new Map();
|
|
1504
|
+
for (const report of reports) {
|
|
1505
|
+
const list = byServer.get(report.repo.serverDir);
|
|
1506
|
+
if (list) list.push(report);
|
|
1507
|
+
else byServer.set(report.repo.serverDir, [report]);
|
|
1508
|
+
}
|
|
1509
|
+
for (const [serverDir, group] of byServer) {
|
|
1510
|
+
counts.set(serverDir, group.length);
|
|
1511
|
+
const hostTally = /* @__PURE__ */ new Map();
|
|
1512
|
+
for (const report of group) {
|
|
1513
|
+
if (report.originHost) {
|
|
1514
|
+
hostTally.set(
|
|
1515
|
+
report.originHost,
|
|
1516
|
+
(hostTally.get(report.originHost) ?? 0) + 1
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const host = [...hostTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
|
1521
|
+
const type = host ? forgeTypeForHost(host) : "git";
|
|
1522
|
+
forges[serverDir] = { type, host, dir: serverDir };
|
|
1523
|
+
}
|
|
1524
|
+
const names = Object.keys(forges);
|
|
1525
|
+
const defaultForge = names.slice().sort((a, b) => {
|
|
1526
|
+
const aGh = forges[a].type === "github" ? 1 : 0;
|
|
1527
|
+
const bGh = forges[b].type === "github" ? 1 : 0;
|
|
1528
|
+
if (aGh !== bGh) return bGh - aGh;
|
|
1529
|
+
return (counts.get(b) ?? 0) - (counts.get(a) ?? 0);
|
|
1530
|
+
})[0] ?? "";
|
|
1531
|
+
return { root: path, defaultForge, forges };
|
|
1532
|
+
}
|
|
1533
|
+
async function analyzeLocal(repo, options) {
|
|
1534
|
+
const report = {
|
|
1535
|
+
repo,
|
|
1536
|
+
originUrl: null,
|
|
1537
|
+
originHost: null,
|
|
1538
|
+
remotes: [],
|
|
1539
|
+
findings: []
|
|
1540
|
+
};
|
|
1541
|
+
if (!await isGitRepo(repo.localPath)) {
|
|
1542
|
+
report.findings.push({
|
|
1543
|
+
kind: "not-a-git-repo",
|
|
1544
|
+
severity: "warn",
|
|
1545
|
+
message: "not a git repository"
|
|
1546
|
+
});
|
|
1547
|
+
return { report, parsed: null };
|
|
1548
|
+
}
|
|
1549
|
+
report.remotes = await getRemotes(repo.localPath);
|
|
1550
|
+
report.originUrl = await getOriginUrl(repo.localPath);
|
|
1551
|
+
if (!report.originUrl) {
|
|
1552
|
+
const names = report.remotes.map((r) => r.name).filter((n) => n !== "origin");
|
|
1553
|
+
report.findings.push({
|
|
1554
|
+
kind: "no-origin",
|
|
1555
|
+
severity: "warn",
|
|
1556
|
+
message: names.length > 0 ? `no origin remote (other remotes: ${names.join(", ")})` : "no origin remote"
|
|
1557
|
+
});
|
|
1558
|
+
return { report, parsed: null };
|
|
1559
|
+
}
|
|
1560
|
+
if (report.remotes.length > 1) {
|
|
1561
|
+
report.findings.push({
|
|
1562
|
+
kind: "multiple-remotes",
|
|
1563
|
+
severity: "warn",
|
|
1564
|
+
message: `${report.remotes.length} remotes configured; comparing origin`
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
let parsed = null;
|
|
1568
|
+
try {
|
|
1569
|
+
parsed = parseSlug(report.originUrl);
|
|
1570
|
+
report.originHost = parsed.host ?? null;
|
|
1571
|
+
} catch {
|
|
1572
|
+
report.findings.push({
|
|
1573
|
+
kind: "origin-mismatch",
|
|
1574
|
+
severity: "warn",
|
|
1575
|
+
message: `could not parse origin URL: ${report.originUrl}`
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
if (parsed && (parsed.owner !== repo.owner || parsed.repo !== repo.repo)) {
|
|
1579
|
+
const to = join(options.path, repo.serverDir, parsed.owner, parsed.repo);
|
|
1580
|
+
report.findings.push({
|
|
1581
|
+
kind: "origin-mismatch",
|
|
1582
|
+
severity: "warn",
|
|
1583
|
+
message: `folder ${repo.owner}/${repo.repo} != origin ${parsed.owner}/${parsed.repo}`,
|
|
1584
|
+
fix: { action: "move-folder", from: repo.localPath, to }
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
return { report, parsed };
|
|
1588
|
+
}
|
|
1589
|
+
function pushRemoteFinding(report, parsed, result, path) {
|
|
1590
|
+
const { repo } = report;
|
|
1591
|
+
switch (result.state) {
|
|
1592
|
+
case "exists":
|
|
1593
|
+
break;
|
|
1594
|
+
case "moved": {
|
|
1595
|
+
const to = join(
|
|
1596
|
+
path,
|
|
1597
|
+
repo.serverDir,
|
|
1598
|
+
result.canonical.owner,
|
|
1599
|
+
result.canonical.repo
|
|
1600
|
+
);
|
|
1601
|
+
const fix = result.canonicalUrl ? {
|
|
1602
|
+
action: "set-origin-url",
|
|
1603
|
+
localPath: repo.localPath,
|
|
1604
|
+
url: result.canonicalUrl
|
|
1605
|
+
} : to !== repo.localPath ? { action: "move-folder", from: repo.localPath, to } : void 0;
|
|
1606
|
+
report.findings.push({
|
|
1607
|
+
kind: "remote-moved",
|
|
1608
|
+
severity: "warn",
|
|
1609
|
+
message: `remote moved to ${result.canonical.owner}/${result.canonical.repo}`,
|
|
1610
|
+
fix
|
|
1611
|
+
});
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
case "gone":
|
|
1615
|
+
report.findings.push({
|
|
1616
|
+
kind: "remote-gone",
|
|
1617
|
+
severity: "warn",
|
|
1618
|
+
message: `remote ${parsed.owner}/${parsed.repo} no longer exists`
|
|
1619
|
+
});
|
|
1620
|
+
break;
|
|
1621
|
+
case "unknown":
|
|
1622
|
+
report.findings.push({
|
|
1623
|
+
kind: "remote-check-unknown",
|
|
1624
|
+
severity: "warn",
|
|
1625
|
+
message: `remote check inconclusive: ${result.reason}`
|
|
1626
|
+
});
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
async function checkForgeGroup(forge, items, options, bump) {
|
|
1631
|
+
const inputs = items.map((it) => ({
|
|
1632
|
+
forge,
|
|
1633
|
+
owner: it.parsed.owner,
|
|
1634
|
+
repo: it.parsed.repo,
|
|
1635
|
+
originUrl: it.report.originUrl ?? void 0
|
|
1636
|
+
}));
|
|
1637
|
+
let adapter;
|
|
1638
|
+
try {
|
|
1639
|
+
adapter = getForgeAdapter(forge.type);
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
for (const it of items) {
|
|
1642
|
+
it.report.findings.push({
|
|
1643
|
+
kind: "remote-check-unknown",
|
|
1644
|
+
severity: "warn",
|
|
1645
|
+
message: `remote check inconclusive: ${error.message}`
|
|
1646
|
+
});
|
|
1647
|
+
bump();
|
|
1648
|
+
}
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (adapter.checkRemotes) {
|
|
1652
|
+
let results;
|
|
1653
|
+
try {
|
|
1654
|
+
results = await adapter.checkRemotes(inputs);
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
results = inputs.map(() => ({
|
|
1657
|
+
state: "unknown",
|
|
1658
|
+
reason: error.message
|
|
1659
|
+
}));
|
|
1660
|
+
}
|
|
1661
|
+
items.forEach((it, i) => {
|
|
1662
|
+
pushRemoteFinding(it.report, it.parsed, results[i], options.path);
|
|
1663
|
+
bump();
|
|
1664
|
+
});
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const check = adapter.checkRemote;
|
|
1668
|
+
await mapLimit(items, REMOTE_CONCURRENCY, async (it, i) => {
|
|
1669
|
+
let result;
|
|
1670
|
+
try {
|
|
1671
|
+
result = check ? await check(inputs[i]) : { state: "unknown", reason: `${forge.type} has no remote check` };
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
result = { state: "unknown", reason: error.message };
|
|
1674
|
+
}
|
|
1675
|
+
pushRemoteFinding(it.report, it.parsed, result, options.path);
|
|
1676
|
+
bump();
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
const LOCAL_CONCURRENCY = 16;
|
|
1680
|
+
const REMOTE_CONCURRENCY = 10;
|
|
1681
|
+
async function analyzeImport(options) {
|
|
1682
|
+
const discovered = await discoverForgemapLayout(options.path);
|
|
1683
|
+
const locals = await mapLimit(
|
|
1684
|
+
discovered,
|
|
1685
|
+
LOCAL_CONCURRENCY,
|
|
1686
|
+
(repo) => analyzeLocal(repo, options)
|
|
1687
|
+
);
|
|
1688
|
+
const reports = locals.map((l) => l.report);
|
|
1689
|
+
const derived = deriveConfig(reports, options.path);
|
|
1690
|
+
for (const { report, parsed } of locals) {
|
|
1691
|
+
const forge = derived.forges[report.repo.serverDir];
|
|
1692
|
+
if (parsed?.host && forge?.host && parsed.host !== forge.host) {
|
|
1693
|
+
report.findings.push({
|
|
1694
|
+
kind: "host-unmatched",
|
|
1695
|
+
severity: "warn",
|
|
1696
|
+
message: `origin host ${parsed.host} differs from forge host ${forge.host}`
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const checkable = locals.flatMap(
|
|
1701
|
+
(l) => l.report.originUrl && l.parsed ? [{ report: l.report, parsed: l.parsed }] : []
|
|
1702
|
+
);
|
|
1703
|
+
if (!options.remoteCheck) {
|
|
1704
|
+
for (const { report } of checkable) {
|
|
1705
|
+
report.findings.push({
|
|
1706
|
+
kind: "remote-check-skipped",
|
|
1707
|
+
severity: "ok",
|
|
1708
|
+
message: "remote check skipped (--no-remote-check)"
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return { root: options.path, derived, reports };
|
|
1712
|
+
}
|
|
1713
|
+
const total = checkable.length;
|
|
1714
|
+
let done = 0;
|
|
1715
|
+
const bump = () => {
|
|
1716
|
+
done++;
|
|
1717
|
+
options.onProgress?.(done, total);
|
|
1718
|
+
};
|
|
1719
|
+
options.onProgress?.(0, total);
|
|
1720
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1721
|
+
for (const item of checkable) {
|
|
1722
|
+
const key = item.report.repo.serverDir;
|
|
1723
|
+
const list = groups.get(key);
|
|
1724
|
+
if (list) list.push(item);
|
|
1725
|
+
else groups.set(key, [item]);
|
|
1726
|
+
}
|
|
1727
|
+
await Promise.all(
|
|
1728
|
+
Array.from(groups, ([serverDir, items]) => {
|
|
1729
|
+
const forge = derived.forges[serverDir];
|
|
1730
|
+
if (!forge) {
|
|
1731
|
+
for (const it of items) {
|
|
1732
|
+
it.report.findings.push({
|
|
1733
|
+
kind: "remote-check-unknown",
|
|
1734
|
+
severity: "warn",
|
|
1735
|
+
message: "no forge derived for this server dir"
|
|
1736
|
+
});
|
|
1737
|
+
bump();
|
|
1738
|
+
}
|
|
1739
|
+
return Promise.resolve();
|
|
1740
|
+
}
|
|
1741
|
+
return checkForgeGroup(forge, items, options, bump);
|
|
1742
|
+
})
|
|
1743
|
+
);
|
|
1744
|
+
return { root: options.path, derived, reports };
|
|
1745
|
+
}
|
|
1746
|
+
const ALLOWED_TYPES = ["forgemap"];
|
|
1747
|
+
const ALLOWED_FORMATS$1 = ["pretty", "json"];
|
|
1748
|
+
function isImportType(value) {
|
|
1749
|
+
return ALLOWED_TYPES.includes(value);
|
|
1750
|
+
}
|
|
1751
|
+
function severitySymbol$1(severity) {
|
|
1752
|
+
if (severity === "fail") return colors.red("✗");
|
|
1753
|
+
if (severity === "warn") return colors.yellow("!");
|
|
1754
|
+
return colors.green("✓");
|
|
1755
|
+
}
|
|
1756
|
+
function worstSeverity(findings) {
|
|
1757
|
+
if (findings.some((f) => f.severity === "fail")) return "fail";
|
|
1758
|
+
if (findings.some((f) => f.severity === "warn")) return "warn";
|
|
1759
|
+
return "ok";
|
|
1760
|
+
}
|
|
1761
|
+
function hasIssues(report) {
|
|
1762
|
+
return report.findings.some((f) => f.severity !== "ok");
|
|
1763
|
+
}
|
|
1764
|
+
function repoLine(report) {
|
|
1765
|
+
const symbol = severitySymbol$1(worstSeverity(report.findings));
|
|
1766
|
+
const name = colors.cyan(report.repo.repo);
|
|
1767
|
+
const issues = report.findings.filter((f) => f.severity !== "ok");
|
|
1768
|
+
if (issues.length === 0) return `${symbol} ${name}`;
|
|
1769
|
+
const summary = issues.map((f) => f.message).join("; ");
|
|
1770
|
+
return `${symbol} ${name} ${colors.dim(summary)}`;
|
|
1771
|
+
}
|
|
1772
|
+
function renderReports(reports) {
|
|
1773
|
+
const byServer = /* @__PURE__ */ new Map();
|
|
1774
|
+
for (const report of reports) {
|
|
1775
|
+
let owners = byServer.get(report.repo.serverDir);
|
|
1776
|
+
if (!owners) {
|
|
1777
|
+
owners = /* @__PURE__ */ new Map();
|
|
1778
|
+
byServer.set(report.repo.serverDir, owners);
|
|
1779
|
+
}
|
|
1780
|
+
const list = owners.get(report.repo.owner);
|
|
1781
|
+
if (list) list.push(report);
|
|
1782
|
+
else owners.set(report.repo.owner, [report]);
|
|
1783
|
+
}
|
|
1784
|
+
return formatTree(
|
|
1785
|
+
Array.from(byServer, ([serverDir, owners]) => ({
|
|
1786
|
+
text: colors.bold(serverDir),
|
|
1787
|
+
children: Array.from(owners, ([owner, items]) => ({
|
|
1788
|
+
text: owner,
|
|
1789
|
+
children: items.map((report) => ({ text: repoLine(report) }))
|
|
1790
|
+
}))
|
|
1791
|
+
}))
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
function renderDerived(config) {
|
|
1795
|
+
return formatTree([
|
|
1796
|
+
{
|
|
1797
|
+
text: colors.bold("Derived config"),
|
|
1798
|
+
children: Object.entries(config.forges).map(([name, forge]) => ({
|
|
1799
|
+
text: `${colors.cyan(name)} ${colors.dim(
|
|
1800
|
+
`${forge.type} @ ${forge.host || "(unknown host)"} → ${forge.dir}`
|
|
1801
|
+
)}`
|
|
1802
|
+
}))
|
|
1803
|
+
}
|
|
1804
|
+
]);
|
|
1805
|
+
}
|
|
1806
|
+
async function applyFixes(reports) {
|
|
1807
|
+
const applied = [];
|
|
1808
|
+
const fixes = reports.flatMap(
|
|
1809
|
+
(r) => r.findings.flatMap((f) => f.fix ? [f.fix] : [])
|
|
1810
|
+
);
|
|
1811
|
+
for (const fix of fixes) {
|
|
1812
|
+
if (fix.action !== "set-origin-url") continue;
|
|
1813
|
+
const result = await setOriginUrl(fix.localPath, fix.url);
|
|
1814
|
+
if (result.code === 0) {
|
|
1815
|
+
applied.push(fix);
|
|
1816
|
+
consola.success(`origin → ${fix.url}`);
|
|
1817
|
+
} else {
|
|
1818
|
+
consola.warn(
|
|
1819
|
+
`failed to set origin for ${fix.localPath}: ${result.stderr.trim()}`
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
for (const fix of fixes) {
|
|
1824
|
+
if (fix.action !== "move-folder") continue;
|
|
1825
|
+
if (existsSync(fix.to)) {
|
|
1826
|
+
consola.warn(`skip move: target exists ${fix.to}`);
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
await mkdir(dirname(fix.to), { recursive: true });
|
|
1830
|
+
await rename(fix.from, fix.to);
|
|
1831
|
+
applied.push(fix);
|
|
1832
|
+
consola.success(`moved ${fix.from} → ${fix.to}`);
|
|
1833
|
+
}
|
|
1834
|
+
return applied;
|
|
1835
|
+
}
|
|
1836
|
+
function augmentConfig(existing, derived) {
|
|
1837
|
+
const forges = { ...existing.forges };
|
|
1838
|
+
const conflicts = [];
|
|
1839
|
+
for (const [name, forge] of Object.entries(derived.forges)) {
|
|
1840
|
+
const current = existing.forges[name];
|
|
1841
|
+
if (!current) {
|
|
1842
|
+
forges[name] = forge;
|
|
1843
|
+
} else if (current.host !== forge.host) {
|
|
1844
|
+
conflicts.push(name);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return { merged: { ...existing, forges }, conflicts };
|
|
1848
|
+
}
|
|
1849
|
+
const importCommand = defineCommand({
|
|
1850
|
+
meta: {
|
|
1851
|
+
name: "import",
|
|
1852
|
+
description: "Adopt an existing repo tree: reconcile folders against git remotes and derive a config"
|
|
1853
|
+
},
|
|
1854
|
+
args: {
|
|
1855
|
+
path: {
|
|
1856
|
+
type: "positional",
|
|
1857
|
+
description: "Directory laid out as <server>/<owner>/<repo>",
|
|
1858
|
+
required: true
|
|
1859
|
+
},
|
|
1860
|
+
type: {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
description: 'Layout type (currently only "forgemap")',
|
|
1863
|
+
default: "forgemap"
|
|
1864
|
+
},
|
|
1865
|
+
format: {
|
|
1866
|
+
type: "string",
|
|
1867
|
+
description: "Output format: pretty (default) or json",
|
|
1868
|
+
default: "pretty"
|
|
1869
|
+
},
|
|
1870
|
+
"remote-check": {
|
|
1871
|
+
type: "boolean",
|
|
1872
|
+
description: "Check each remote for existence/moves (default true)",
|
|
1873
|
+
default: true
|
|
1874
|
+
},
|
|
1875
|
+
fix: {
|
|
1876
|
+
type: "boolean",
|
|
1877
|
+
description: "Apply corrections (move folders, repoint origin URLs)",
|
|
1878
|
+
default: false
|
|
1879
|
+
},
|
|
1880
|
+
"write-config": {
|
|
1881
|
+
type: "boolean",
|
|
1882
|
+
description: "Write/augment forgemap.config.ts from the derived structure",
|
|
1883
|
+
default: true
|
|
1884
|
+
},
|
|
1885
|
+
out: {
|
|
1886
|
+
type: "string",
|
|
1887
|
+
description: "Directory to write the derived config into (defaults to <path>)"
|
|
1888
|
+
},
|
|
1889
|
+
force: {
|
|
1890
|
+
type: "boolean",
|
|
1891
|
+
description: "Overwrite an existing config instead of augmenting it",
|
|
1892
|
+
default: false
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
async run({ args }) {
|
|
1896
|
+
if (!isImportType(args.type)) {
|
|
1897
|
+
consola.error(
|
|
1898
|
+
`Invalid --type value "${args.type}". Allowed: ${ALLOWED_TYPES.join(", ")}.`
|
|
1899
|
+
);
|
|
1900
|
+
process.exitCode = 1;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (!ALLOWED_FORMATS$1.includes(args.format)) {
|
|
1904
|
+
consola.error(
|
|
1905
|
+
`Invalid --format value "${args.format}". Allowed: ${ALLOWED_FORMATS$1.join(", ")}.`
|
|
1906
|
+
);
|
|
1907
|
+
process.exitCode = 1;
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const path = resolve(process.cwd(), args.path);
|
|
1911
|
+
try {
|
|
1912
|
+
const s = await stat(path);
|
|
1913
|
+
if (!s.isDirectory()) {
|
|
1914
|
+
consola.error(`${path} is not a directory.`);
|
|
1915
|
+
process.exitCode = 1;
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
} catch {
|
|
1919
|
+
consola.error(`${path} does not exist.`);
|
|
1920
|
+
process.exitCode = 1;
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const showProgress = args["remote-check"] && Boolean(process.stderr.isTTY);
|
|
1924
|
+
let clearLen = 0;
|
|
1925
|
+
const onProgress = showProgress ? (done, total) => {
|
|
1926
|
+
const msg = `⏳ Checking remotes ${done}/${total}`;
|
|
1927
|
+
process.stderr.write(`\r${msg} `);
|
|
1928
|
+
clearLen = msg.length + 1;
|
|
1929
|
+
} : void 0;
|
|
1930
|
+
const result = await analyzeImport({
|
|
1931
|
+
path,
|
|
1932
|
+
type: args.type,
|
|
1933
|
+
remoteCheck: args["remote-check"],
|
|
1934
|
+
onProgress
|
|
1935
|
+
});
|
|
1936
|
+
if (clearLen > 0) {
|
|
1937
|
+
process.stderr.write(`\r${" ".repeat(clearLen)}\r`);
|
|
1938
|
+
}
|
|
1939
|
+
const applied = args.fix ? await applyFixes(result.reports) : [];
|
|
1940
|
+
const withFindings = result.reports.filter(hasIssues).length;
|
|
1941
|
+
const fixable = result.reports.reduce(
|
|
1942
|
+
(n, r) => n + r.findings.filter((f) => f.fix).length,
|
|
1943
|
+
0
|
|
1944
|
+
);
|
|
1945
|
+
if (args.format === "json") {
|
|
1946
|
+
process.stdout.write(
|
|
1947
|
+
`${JSON.stringify(
|
|
1948
|
+
{
|
|
1949
|
+
path,
|
|
1950
|
+
type: args.type,
|
|
1951
|
+
derived: result.derived,
|
|
1952
|
+
repos: result.reports.map((r) => ({
|
|
1953
|
+
serverDir: r.repo.serverDir,
|
|
1954
|
+
owner: r.repo.owner,
|
|
1955
|
+
repo: r.repo.repo,
|
|
1956
|
+
localPath: r.repo.localPath,
|
|
1957
|
+
originUrl: r.originUrl,
|
|
1958
|
+
remotes: r.remotes,
|
|
1959
|
+
findings: r.findings
|
|
1960
|
+
})),
|
|
1961
|
+
...args.fix ? { applied } : {},
|
|
1962
|
+
summary: { repos: result.reports.length, withFindings, fixable }
|
|
1963
|
+
},
|
|
1964
|
+
null,
|
|
1965
|
+
2
|
|
1966
|
+
)}
|
|
1967
|
+
`
|
|
1968
|
+
);
|
|
1969
|
+
} else {
|
|
1970
|
+
process.stdout.write(
|
|
1971
|
+
`${colors.dim(`Scanned ${path} (${args.type})`)}
|
|
1972
|
+
|
|
1973
|
+
`
|
|
1974
|
+
);
|
|
1975
|
+
if (result.reports.length === 0) {
|
|
1976
|
+
consola.info("No repos found.");
|
|
1977
|
+
} else {
|
|
1978
|
+
process.stdout.write(`${renderReports(result.reports)}
|
|
1979
|
+
|
|
1980
|
+
`);
|
|
1981
|
+
}
|
|
1982
|
+
process.stdout.write(`${renderDerived(result.derived)}
|
|
1983
|
+
|
|
1984
|
+
`);
|
|
1985
|
+
process.stdout.write(
|
|
1986
|
+
`${colors.bold(`${result.reports.length} repos`)}, ${withFindings} with findings, ${fixable} fixable${args.fix ? `, ${applied.length} fixed` : ""}
|
|
1987
|
+
`
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
if (!args["write-config"]) return;
|
|
1991
|
+
if (result.reports.length === 0 && !args.force) return;
|
|
1992
|
+
const outDir = args.out ? resolve(process.cwd(), args.out) : path;
|
|
1993
|
+
const writableRoot = outDir === path ? "." : path;
|
|
1994
|
+
const target = join(outDir, "forgemap.config.ts");
|
|
1995
|
+
if (existsSync(target) && !args.force) {
|
|
1996
|
+
const loaded = await loadForgeMapConfig({ configFile: target });
|
|
1997
|
+
const { merged, conflicts } = augmentConfig(
|
|
1998
|
+
loaded.config,
|
|
1999
|
+
result.derived
|
|
2000
|
+
);
|
|
2001
|
+
for (const name of conflicts) {
|
|
2002
|
+
consola.warn(
|
|
2003
|
+
`forge "${name}" already exists with a different host — left untouched`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
await writeConfigFile(merged, { outDir, force: true });
|
|
2007
|
+
consola.success(`Augmented ${target}`);
|
|
2008
|
+
} else {
|
|
2009
|
+
const written = await writeConfigFile(
|
|
2010
|
+
{ ...result.derived, root: writableRoot },
|
|
2011
|
+
{ outDir, force: args.force }
|
|
2012
|
+
);
|
|
2013
|
+
if (written) consola.success(`Wrote ${written.path}`);
|
|
2014
|
+
}
|
|
2015
|
+
await scanReposCached({
|
|
2016
|
+
config: result.derived,
|
|
2017
|
+
configDir: path,
|
|
2018
|
+
useCache: false
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
720
2022
|
function platformOpen(localPath) {
|
|
721
2023
|
const distro = process.env.WSL_DISTRO_NAME;
|
|
722
2024
|
if (distro) {
|
|
@@ -850,32 +2152,66 @@ const pickCommand = defineCommand({
|
|
|
850
2152
|
process.exitCode = 1;
|
|
851
2153
|
return;
|
|
852
2154
|
}
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
2155
|
+
const out = process.stdout;
|
|
2156
|
+
const realWrite = out.write;
|
|
2157
|
+
const saved = {
|
|
2158
|
+
rows: Object.getOwnPropertyDescriptor(out, "rows"),
|
|
2159
|
+
columns: Object.getOwnPropertyDescriptor(out, "columns"),
|
|
2160
|
+
isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
|
|
2161
|
+
};
|
|
2162
|
+
const fake = (key, value) => {
|
|
2163
|
+
Object.defineProperty(out, key, { configurable: true, value });
|
|
2164
|
+
};
|
|
2165
|
+
const restore = (key) => {
|
|
2166
|
+
if (saved[key]) Object.defineProperty(out, key, saved[key]);
|
|
2167
|
+
else delete out[key];
|
|
2168
|
+
};
|
|
2169
|
+
out.write = process.stderr.write.bind(process.stderr);
|
|
2170
|
+
fake("rows", process.stderr.rows ?? 24);
|
|
2171
|
+
fake("columns", process.stderr.columns ?? 80);
|
|
2172
|
+
fake("isTTY", true);
|
|
2173
|
+
let choice;
|
|
2174
|
+
try {
|
|
2175
|
+
choice = await consola.prompt("Select a repo", {
|
|
2176
|
+
type: "select",
|
|
2177
|
+
options: candidates.map((r) => ({
|
|
2178
|
+
label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
|
|
2179
|
+
value: r.localPath,
|
|
2180
|
+
hint: r.localPath
|
|
2181
|
+
}))
|
|
2182
|
+
});
|
|
2183
|
+
} finally {
|
|
2184
|
+
out.write = realWrite;
|
|
2185
|
+
restore("rows");
|
|
2186
|
+
restore("columns");
|
|
2187
|
+
restore("isTTY");
|
|
2188
|
+
}
|
|
861
2189
|
if (typeof choice === "string" && choice) {
|
|
862
|
-
|
|
2190
|
+
realWrite.call(out, `${choice}
|
|
863
2191
|
`);
|
|
864
2192
|
}
|
|
865
2193
|
}
|
|
866
2194
|
});
|
|
867
2195
|
function renderTree$1(repos) {
|
|
868
|
-
const
|
|
2196
|
+
const byForge = /* @__PURE__ */ new Map();
|
|
869
2197
|
for (const r of repos) {
|
|
870
|
-
|
|
2198
|
+
let owners = byForge.get(r.forgeName);
|
|
2199
|
+
if (!owners) {
|
|
2200
|
+
owners = /* @__PURE__ */ new Map();
|
|
2201
|
+
byForge.set(r.forgeName, owners);
|
|
2202
|
+
}
|
|
2203
|
+
const list = owners.get(r.owner);
|
|
871
2204
|
if (list) list.push(r);
|
|
872
|
-
else
|
|
2205
|
+
else owners.set(r.owner, [r]);
|
|
873
2206
|
}
|
|
874
2207
|
return formatTree(
|
|
875
|
-
Array.from(
|
|
2208
|
+
Array.from(byForge, ([forge, owners]) => ({
|
|
876
2209
|
text: colors.bold(forge),
|
|
877
|
-
children:
|
|
878
|
-
text:
|
|
2210
|
+
children: Array.from(owners, ([owner, items]) => ({
|
|
2211
|
+
text: owner,
|
|
2212
|
+
children: items.map((r) => ({
|
|
2213
|
+
text: `${colors.cyan(r.repo)} ${colors.dim(r.localPath)}`
|
|
2214
|
+
}))
|
|
879
2215
|
}))
|
|
880
2216
|
}))
|
|
881
2217
|
);
|
|
@@ -945,12 +2281,29 @@ const searchCommand = defineCommand({
|
|
|
945
2281
|
}
|
|
946
2282
|
}
|
|
947
2283
|
});
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
2284
|
+
async function install(shell, name) {
|
|
2285
|
+
const nameArg = name !== "forgemap" ? ` --name ${name}` : "";
|
|
2286
|
+
const loaders = shell === "fish" ? [
|
|
2287
|
+
`forgemap shell-init fish${nameArg} | source`,
|
|
2288
|
+
"forgemap completion fish | source"
|
|
2289
|
+
] : [
|
|
2290
|
+
`eval "$(forgemap shell-init ${shell}${nameArg})"`,
|
|
2291
|
+
`eval "$(forgemap completion ${shell})"`
|
|
2292
|
+
];
|
|
2293
|
+
const { status, rcFile } = await installRcBlock(shell, "shell", loaders, [
|
|
2294
|
+
"shell-init"
|
|
2295
|
+
]);
|
|
2296
|
+
if (status === "present") {
|
|
2297
|
+
consola.info(`forgemap shell integration already present in ${rcFile}.`);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const verb = status === "updated" ? "Updated" : "Added";
|
|
2301
|
+
consola.success(
|
|
2302
|
+
`${verb} forgemap shell integration (cd + completion) in ${rcFile}.`
|
|
2303
|
+
);
|
|
2304
|
+
consola.info(
|
|
2305
|
+
`Run \`source ${rcFile}\` or restart your shell to activate it.`
|
|
2306
|
+
);
|
|
954
2307
|
}
|
|
955
2308
|
function renderPosix(name) {
|
|
956
2309
|
return `# forgemap shell integration — drop into your ~/.zshrc / ~/.bashrc:
|
|
@@ -1018,89 +2371,49 @@ end
|
|
|
1018
2371
|
const shellInitCommand = defineCommand({
|
|
1019
2372
|
meta: {
|
|
1020
2373
|
name: "shell-init",
|
|
1021
|
-
description: 'Print a shell wrapper that adds `forgemap cd <slug>` as a real cd. Source it via `eval "$(forgemap shell-init)"`.'
|
|
2374
|
+
description: 'Print (or --install) a shell wrapper that adds `forgemap cd <slug>` as a real cd. Source it via `eval "$(forgemap shell-init)"`.'
|
|
1022
2375
|
},
|
|
1023
2376
|
args: {
|
|
1024
2377
|
shell: {
|
|
1025
2378
|
type: "positional",
|
|
1026
|
-
description: `Shell flavor (${
|
|
2379
|
+
description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
|
|
1027
2380
|
required: false
|
|
1028
2381
|
},
|
|
1029
2382
|
name: {
|
|
1030
2383
|
type: "string",
|
|
1031
2384
|
description: "Name of the generated wrapper function (default: forgemap)",
|
|
1032
2385
|
default: "forgemap"
|
|
2386
|
+
},
|
|
2387
|
+
install: {
|
|
2388
|
+
type: "boolean",
|
|
2389
|
+
description: "Append the loader to your shell's rc file (idempotent) instead of printing",
|
|
2390
|
+
default: false
|
|
1033
2391
|
}
|
|
1034
2392
|
},
|
|
1035
2393
|
async run({ args }) {
|
|
1036
2394
|
const requested = args.shell ?? detectShell();
|
|
1037
|
-
if (!
|
|
2395
|
+
if (!SUPPORTED_SHELLS.includes(requested)) {
|
|
1038
2396
|
consola.error(
|
|
1039
|
-
`Unsupported shell "${requested}". Supported: ${
|
|
2397
|
+
`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
1040
2398
|
);
|
|
1041
2399
|
process.exitCode = 1;
|
|
1042
2400
|
return;
|
|
1043
2401
|
}
|
|
1044
2402
|
const name = args.name || "forgemap";
|
|
2403
|
+
if (args.install) {
|
|
2404
|
+
await install(requested, name);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
1045
2407
|
const out = requested === "fish" ? renderFish(name) : renderPosix(name);
|
|
1046
2408
|
process.stdout.write(out);
|
|
1047
2409
|
}
|
|
1048
2410
|
});
|
|
1049
|
-
async function gitIn(cwd, args) {
|
|
1050
|
-
return execCapture("git", args, { cwd });
|
|
1051
|
-
}
|
|
1052
|
-
async function getRepoStatus(localPath) {
|
|
1053
|
-
const status = {
|
|
1054
|
-
branch: "HEAD",
|
|
1055
|
-
detached: false,
|
|
1056
|
-
dirty: false,
|
|
1057
|
-
ahead: 0,
|
|
1058
|
-
behind: 0,
|
|
1059
|
-
lastCommit: null
|
|
1060
|
-
};
|
|
1061
|
-
const branchResult = await gitIn(localPath, ["branch", "--show-current"]);
|
|
1062
|
-
status.branch = branchResult.stdout.trim() || "HEAD";
|
|
1063
|
-
status.detached = !status.branch || status.branch === "HEAD";
|
|
1064
|
-
const porcelain = await gitIn(localPath, ["status", "--porcelain"]);
|
|
1065
|
-
status.dirty = porcelain.stdout.trim().length > 0;
|
|
1066
|
-
const aheadBehind = await gitIn(localPath, [
|
|
1067
|
-
"rev-list",
|
|
1068
|
-
"--left-right",
|
|
1069
|
-
"--count",
|
|
1070
|
-
"@{u}...HEAD"
|
|
1071
|
-
]);
|
|
1072
|
-
if (aheadBehind.code === 0) {
|
|
1073
|
-
const match = aheadBehind.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
1074
|
-
if (match) {
|
|
1075
|
-
status.behind = Number(match[1]);
|
|
1076
|
-
status.ahead = Number(match[2]);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
const lastCommit = await gitIn(localPath, ["log", "-1", "--format=%h|%cr"]);
|
|
1080
|
-
if (lastCommit.code === 0) {
|
|
1081
|
-
const [sha, relativeDate] = lastCommit.stdout.trim().split("|");
|
|
1082
|
-
if (sha && relativeDate) {
|
|
1083
|
-
status.lastCommit = { sha, relativeDate };
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
return status;
|
|
1087
|
-
}
|
|
1088
|
-
async function fetchRepo(localPath) {
|
|
1089
|
-
return gitIn(localPath, ["fetch", "--all", "--prune"]);
|
|
1090
|
-
}
|
|
1091
|
-
async function pullRepo(localPath) {
|
|
1092
|
-
return gitIn(localPath, ["pull", "--ff-only"]);
|
|
1093
|
-
}
|
|
1094
|
-
async function isClean(localPath) {
|
|
1095
|
-
const result = await gitIn(localPath, ["status", "--porcelain"]);
|
|
1096
|
-
return result.code === 0 && result.stdout.trim().length === 0;
|
|
1097
|
-
}
|
|
1098
2411
|
function statusLine(row) {
|
|
1099
2412
|
if (row.error || !row.status) {
|
|
1100
|
-
return `${colors.cyan(row.repo.
|
|
2413
|
+
return `${colors.cyan(row.repo.repo)} ${colors.red(`error: ${row.error ?? "unknown"}`)}`;
|
|
1101
2414
|
}
|
|
1102
2415
|
const s = row.status;
|
|
1103
|
-
const parts = [colors.cyan(row.repo.
|
|
2416
|
+
const parts = [colors.cyan(row.repo.repo)];
|
|
1104
2417
|
const aheadBehind = [];
|
|
1105
2418
|
if (s.ahead > 0) aheadBehind.push(colors.green(`↑${s.ahead}`));
|
|
1106
2419
|
if (s.behind > 0) aheadBehind.push(colors.yellow(`↓${s.behind}`));
|
|
@@ -1113,16 +2426,24 @@ function statusLine(row) {
|
|
|
1113
2426
|
return parts.join(" ");
|
|
1114
2427
|
}
|
|
1115
2428
|
function renderTree(rows) {
|
|
1116
|
-
const
|
|
2429
|
+
const byForge = /* @__PURE__ */ new Map();
|
|
1117
2430
|
for (const row of rows) {
|
|
1118
|
-
|
|
2431
|
+
let owners = byForge.get(row.repo.forgeName);
|
|
2432
|
+
if (!owners) {
|
|
2433
|
+
owners = /* @__PURE__ */ new Map();
|
|
2434
|
+
byForge.set(row.repo.forgeName, owners);
|
|
2435
|
+
}
|
|
2436
|
+
const list = owners.get(row.repo.owner);
|
|
1119
2437
|
if (list) list.push(row);
|
|
1120
|
-
else
|
|
2438
|
+
else owners.set(row.repo.owner, [row]);
|
|
1121
2439
|
}
|
|
1122
2440
|
return formatTree(
|
|
1123
|
-
Array.from(
|
|
2441
|
+
Array.from(byForge, ([forge, owners]) => ({
|
|
1124
2442
|
text: colors.bold(forge),
|
|
1125
|
-
children:
|
|
2443
|
+
children: Array.from(owners, ([owner, items]) => ({
|
|
2444
|
+
text: owner,
|
|
2445
|
+
children: items.map((row) => ({ text: statusLine(row) }))
|
|
2446
|
+
}))
|
|
1126
2447
|
}))
|
|
1127
2448
|
);
|
|
1128
2449
|
}
|
|
@@ -1313,10 +2634,11 @@ const syncCommand = defineCommand({
|
|
|
1313
2634
|
outcomes.push({ repo, status: "synced" });
|
|
1314
2635
|
consola.success(colors.dim(repo.slug));
|
|
1315
2636
|
} else {
|
|
2637
|
+
const message = result.timedOut ? "timed out (remote unreachable)" : (result.stderr || result.stdout).trim().split("\n")[0] || `git exited with code ${result.code}`;
|
|
1316
2638
|
outcomes.push({
|
|
1317
2639
|
repo,
|
|
1318
2640
|
status: "failed",
|
|
1319
|
-
message
|
|
2641
|
+
message
|
|
1320
2642
|
});
|
|
1321
2643
|
consola.fail(
|
|
1322
2644
|
`${colors.dim(repo.slug)} — ${outcomes.at(-1)?.message}`
|
|
@@ -1495,6 +2817,8 @@ const rootCommand = defineCommand({
|
|
|
1495
2817
|
},
|
|
1496
2818
|
subCommands: {
|
|
1497
2819
|
clone: cloneCommand,
|
|
2820
|
+
import: importCommand,
|
|
2821
|
+
cleanup: cleanupCommand,
|
|
1498
2822
|
cd: cdCommand,
|
|
1499
2823
|
path: pathCommand,
|
|
1500
2824
|
open: openCommand,
|