forgemap 0.1.0 → 0.4.0-dev.41-29cc4d0
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 +135 -22
- package/dist/bin/forgemap.mjs +2503 -52
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +7 -2
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -1,13 +1,81 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
3
|
import consola from "consola";
|
|
6
|
-
import {
|
|
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";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
7
9
|
import { loadConfig } from "c12";
|
|
8
10
|
import { spawn } from "node:child_process";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import Fuse from "fuse.js";
|
|
13
|
+
const cdCommand = defineCommand({
|
|
14
|
+
meta: {
|
|
15
|
+
name: "cd",
|
|
16
|
+
description: "Change directory into a repo (requires shell integration)"
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
slug: {
|
|
20
|
+
type: "positional",
|
|
21
|
+
description: "owner/repo, forge:owner/repo, full URL, or fuzzy query",
|
|
22
|
+
required: false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async run() {
|
|
26
|
+
consola.error(
|
|
27
|
+
"forgemap cd needs shell integration to actually change directory."
|
|
28
|
+
);
|
|
29
|
+
consola.info("Source the wrapper once and try again:");
|
|
30
|
+
consola.info(' eval "$(forgemap shell-init)" # zsh/bash');
|
|
31
|
+
consola.info(" forgemap shell-init fish | source # fish");
|
|
32
|
+
consola.info(
|
|
33
|
+
"Or, if you just want the path on stdout, use: forgemap path <slug>"
|
|
34
|
+
);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
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 = {
|
|
11
79
|
root: ".",
|
|
12
80
|
defaultForge: "github",
|
|
13
81
|
forges: {
|
|
@@ -20,24 +88,21 @@ const DEFAULT_CONFIG = {
|
|
|
20
88
|
};
|
|
21
89
|
async function loadForgeMapConfig(options = {}) {
|
|
22
90
|
const envConfig = process.env.FORGEMAP_CONFIG;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
91
|
+
const startDir = options.cwd ?? process.cwd();
|
|
92
|
+
const explicit = options.configFile ?? envConfig ?? findConfigUp(startDir) ?? findGlobalConfig();
|
|
93
|
+
const cwd = explicit ? dirname(explicit) : startDir;
|
|
25
94
|
const { config, configFile } = await loadConfig({
|
|
26
95
|
name: "forgemap",
|
|
27
96
|
cwd,
|
|
28
97
|
configFile: explicit ? explicit : "forgemap.config",
|
|
29
98
|
rcFile: false,
|
|
30
99
|
globalRc: false,
|
|
31
|
-
dotenv: false
|
|
32
|
-
defaults: DEFAULT_CONFIG
|
|
100
|
+
dotenv: false
|
|
33
101
|
});
|
|
34
102
|
const merged = {
|
|
35
|
-
root: config.root ?? DEFAULT_CONFIG.root,
|
|
36
|
-
defaultForge: config.defaultForge ?? DEFAULT_CONFIG.defaultForge,
|
|
37
|
-
forges:
|
|
38
|
-
...DEFAULT_CONFIG.forges,
|
|
39
|
-
...config.forges
|
|
40
|
-
}
|
|
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
|
|
41
106
|
};
|
|
42
107
|
return {
|
|
43
108
|
config: merged,
|
|
@@ -54,6 +119,57 @@ function execInherit(command, args) {
|
|
|
54
119
|
});
|
|
55
120
|
});
|
|
56
121
|
}
|
|
122
|
+
function execCapture(command, args, options = {}) {
|
|
123
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
124
|
+
const child = spawn(command, args, {
|
|
125
|
+
cwd: options.cwd,
|
|
126
|
+
env: options.env ? { ...process.env, ...options.env } : void 0,
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
128
|
+
});
|
|
129
|
+
let stdout = "";
|
|
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
|
+
}
|
|
144
|
+
child.stdout?.on("data", (chunk) => {
|
|
145
|
+
stdout += chunk.toString();
|
|
146
|
+
});
|
|
147
|
+
child.stderr?.on("data", (chunk) => {
|
|
148
|
+
stderr += chunk.toString();
|
|
149
|
+
});
|
|
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
|
+
});
|
|
158
|
+
child.on("close", (code) => {
|
|
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
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
57
173
|
function hasCommand(command) {
|
|
58
174
|
return new Promise((resolvePromise) => {
|
|
59
175
|
const child = spawn(
|
|
@@ -67,6 +183,115 @@ function hasCommand(command) {
|
|
|
67
183
|
child.on("close", (code) => resolvePromise(code === 0));
|
|
68
184
|
});
|
|
69
185
|
}
|
|
186
|
+
const REMOTE_TIMEOUT_MS = 1e4;
|
|
187
|
+
function buildCloneUrl(opts) {
|
|
188
|
+
const forge = opts.forge;
|
|
189
|
+
const protocol = opts.protocol ?? forge.protocol ?? "ssh";
|
|
190
|
+
if (protocol === "https") {
|
|
191
|
+
return `https://${forge.host}/${opts.owner}/${opts.repo}.git`;
|
|
192
|
+
}
|
|
193
|
+
return `git@${forge.host}:${opts.owner}/${opts.repo}.git`;
|
|
194
|
+
}
|
|
195
|
+
const gitAdapter = {
|
|
196
|
+
async clone(options) {
|
|
197
|
+
if (!await hasCommand("git")) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"`git` is not installed. Install it from https://git-scm.com/ and try again."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const url = buildCloneUrl(options);
|
|
203
|
+
const { code } = await execInherit("git", ["clone", url, options.dest]);
|
|
204
|
+
if (code !== 0) {
|
|
205
|
+
throw new Error(`git clone exited with code ${code}`);
|
|
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 };
|
|
234
|
+
}
|
|
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
|
+
}
|
|
70
295
|
const githubAdapter = {
|
|
71
296
|
async clone({ owner, repo, dest }) {
|
|
72
297
|
if (!await hasCommand("gh")) {
|
|
@@ -83,17 +308,81 @@ const githubAdapter = {
|
|
|
83
308
|
if (code !== 0) {
|
|
84
309
|
throw new Error(`gh repo clone exited with code ${code}`);
|
|
85
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;
|
|
86
373
|
}
|
|
87
374
|
};
|
|
88
375
|
function getForgeAdapter(type) {
|
|
89
376
|
switch (type) {
|
|
90
377
|
case "github":
|
|
91
378
|
return githubAdapter;
|
|
379
|
+
case "git":
|
|
380
|
+
return gitAdapter;
|
|
92
381
|
case "gitlab":
|
|
93
382
|
case "gitea":
|
|
94
383
|
case "codeberg":
|
|
95
384
|
throw new Error(
|
|
96
|
-
`Forge type "${type}" is not implemented yet.
|
|
385
|
+
`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`
|
|
97
386
|
);
|
|
98
387
|
default: {
|
|
99
388
|
const exhaustive = type;
|
|
@@ -101,6 +390,275 @@ function getForgeAdapter(type) {
|
|
|
101
390
|
}
|
|
102
391
|
}
|
|
103
392
|
}
|
|
393
|
+
async function listDirs$1(path) {
|
|
394
|
+
try {
|
|
395
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
396
|
+
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.code === "ENOENT") return [];
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async function scanRepos(options) {
|
|
403
|
+
const { config, configDir } = options;
|
|
404
|
+
const root = resolveRoot(config.root, configDir);
|
|
405
|
+
const repos = [];
|
|
406
|
+
for (const [forgeName, forge] of Object.entries(config.forges)) {
|
|
407
|
+
const forgeRoot = join(root, forge.dir);
|
|
408
|
+
const owners = await listDirs$1(forgeRoot);
|
|
409
|
+
for (const owner of owners) {
|
|
410
|
+
const ownerPath = join(forgeRoot, owner);
|
|
411
|
+
const repoNames = await listDirs$1(ownerPath);
|
|
412
|
+
for (const repo of repoNames) {
|
|
413
|
+
repos.push({
|
|
414
|
+
forgeName,
|
|
415
|
+
forge,
|
|
416
|
+
owner,
|
|
417
|
+
repo,
|
|
418
|
+
localPath: join(ownerPath, repo),
|
|
419
|
+
slug: `${owner}/${repo}`
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return repos;
|
|
425
|
+
}
|
|
426
|
+
const DEFAULT_TTL_MS = 6e4;
|
|
427
|
+
function ttl() {
|
|
428
|
+
const env = process.env.FORGEMAP_CACHE_TTL_MS;
|
|
429
|
+
if (!env) return DEFAULT_TTL_MS;
|
|
430
|
+
const parsed = Number.parseInt(env, 10);
|
|
431
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TTL_MS;
|
|
432
|
+
}
|
|
433
|
+
function cacheDir() {
|
|
434
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
435
|
+
return xdg ? join(xdg, "forgemap") : join(homedir(), ".cache", "forgemap");
|
|
436
|
+
}
|
|
437
|
+
function cachePath(root) {
|
|
438
|
+
const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
|
|
439
|
+
return join(cacheDir(), `scan-${hash}.json`);
|
|
440
|
+
}
|
|
441
|
+
async function safeStat(path) {
|
|
442
|
+
try {
|
|
443
|
+
const s = await stat(path);
|
|
444
|
+
return Math.trunc(s.mtimeMs);
|
|
445
|
+
} catch {
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function safeListDirs(path) {
|
|
450
|
+
try {
|
|
451
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
452
|
+
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
453
|
+
} catch {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function computeFingerprint(config, configDir) {
|
|
458
|
+
const root = resolveRoot(config.root, configDir);
|
|
459
|
+
const perForge = await Promise.all(
|
|
460
|
+
Object.values(config.forges).map(async (forge) => {
|
|
461
|
+
const forgeRoot = join(root, forge.dir);
|
|
462
|
+
const [forgeMtime, owners] = await Promise.all([
|
|
463
|
+
safeStat(forgeRoot),
|
|
464
|
+
safeListDirs(forgeRoot)
|
|
465
|
+
]);
|
|
466
|
+
const ownerEntries = await Promise.all(
|
|
467
|
+
owners.map(async (owner) => {
|
|
468
|
+
const ownerPath = join(forgeRoot, owner);
|
|
469
|
+
return [ownerPath, await safeStat(ownerPath)];
|
|
470
|
+
})
|
|
471
|
+
);
|
|
472
|
+
return [[forgeRoot, forgeMtime], ...ownerEntries];
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
const entries = [[root, await safeStat(root)]];
|
|
476
|
+
for (const group of perForge) entries.push(...group);
|
|
477
|
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
478
|
+
return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
|
|
479
|
+
}
|
|
480
|
+
async function readCacheFile(file) {
|
|
481
|
+
try {
|
|
482
|
+
const raw = await readFile(file, "utf8");
|
|
483
|
+
return JSON.parse(raw);
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async function writeCacheFile(file, payload) {
|
|
489
|
+
await mkdir(cacheDir(), { recursive: true });
|
|
490
|
+
await writeFile(file, JSON.stringify(payload), "utf8");
|
|
491
|
+
}
|
|
492
|
+
async function scanReposCached(options) {
|
|
493
|
+
const { config, configDir, useCache = true, trustTtl = true } = options;
|
|
494
|
+
const root = resolveRoot(config.root, configDir);
|
|
495
|
+
const file = cachePath(root);
|
|
496
|
+
if (useCache) {
|
|
497
|
+
const cached = await readCacheFile(file);
|
|
498
|
+
if (cached) {
|
|
499
|
+
const age = Date.now() - cached.writtenAt;
|
|
500
|
+
if (trustTtl && age < ttl()) {
|
|
501
|
+
return cached.repos;
|
|
502
|
+
}
|
|
503
|
+
const fingerprint2 = await computeFingerprint(config, configDir);
|
|
504
|
+
if (cached.fingerprint === fingerprint2) {
|
|
505
|
+
await writeCacheFile(file, { ...cached, writtenAt: Date.now() });
|
|
506
|
+
return cached.repos;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const repos = await scanRepos({ config, configDir });
|
|
511
|
+
const fingerprint = await computeFingerprint(config, configDir);
|
|
512
|
+
await writeCacheFile(file, {
|
|
513
|
+
fingerprint,
|
|
514
|
+
writtenAt: Date.now(),
|
|
515
|
+
repos
|
|
516
|
+
});
|
|
517
|
+
return repos;
|
|
518
|
+
}
|
|
519
|
+
async function appendCachedRepo(options, repo) {
|
|
520
|
+
const { config, configDir } = options;
|
|
521
|
+
const root = resolveRoot(config.root, configDir);
|
|
522
|
+
const file = cachePath(root);
|
|
523
|
+
const cached = await readCacheFile(file);
|
|
524
|
+
if (!cached) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (cached.repos.some((r) => r.localPath === repo.localPath)) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
await writeCacheFile(file, {
|
|
531
|
+
fingerprint: await computeFingerprint(config, configDir),
|
|
532
|
+
writtenAt: Date.now(),
|
|
533
|
+
repos: [...cached.repos, repo]
|
|
534
|
+
});
|
|
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
|
+
}
|
|
104
662
|
const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
|
|
105
663
|
const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
|
|
106
664
|
const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
|
|
@@ -154,15 +712,307 @@ function parseSlug(input) {
|
|
|
154
712
|
}
|
|
155
713
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
156
714
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 {
|
|
734
|
+
}
|
|
735
|
+
return { repo, origin, owner, name, lastCommitUnix, dirty, unpushed };
|
|
161
736
|
}
|
|
162
|
-
function
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
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]);
|
|
743
|
+
}
|
|
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;
|
|
794
|
+
}
|
|
795
|
+
function ageDays(lastCommitUnix) {
|
|
796
|
+
return Math.floor(
|
|
797
|
+
Date.now() / 1e3 / DAY_SECONDS - lastCommitUnix / DAY_SECONDS
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const cleanupCommand = defineCommand({
|
|
801
|
+
meta: {
|
|
802
|
+
name: "cleanup",
|
|
803
|
+
description: "List stale, clean, fully-pushed repos whose remote still exists, then delete them locally after confirmation"
|
|
804
|
+
},
|
|
805
|
+
args: {
|
|
806
|
+
days: {
|
|
807
|
+
type: "string",
|
|
808
|
+
description: "Minimum age in days since the last commit (default 365)",
|
|
809
|
+
default: "365"
|
|
810
|
+
},
|
|
811
|
+
forge: {
|
|
812
|
+
type: "string",
|
|
813
|
+
description: "Restrict to a single forge alias"
|
|
814
|
+
},
|
|
815
|
+
"dry-run": {
|
|
816
|
+
type: "boolean",
|
|
817
|
+
description: "Only list candidates; never prompt or delete",
|
|
818
|
+
default: false
|
|
819
|
+
},
|
|
820
|
+
yes: {
|
|
821
|
+
type: "boolean",
|
|
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",
|
|
838
|
+
default: false
|
|
839
|
+
},
|
|
840
|
+
config: {
|
|
841
|
+
type: "string",
|
|
842
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
async run({ args }) {
|
|
846
|
+
const days = Number.parseInt(args.days, 10);
|
|
847
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
848
|
+
consola.error(`Invalid --days value "${args.days}".`);
|
|
849
|
+
process.exitCode = 1;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
853
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
854
|
+
let repos = await scanReposCached({
|
|
855
|
+
config: loaded.config,
|
|
856
|
+
configDir,
|
|
857
|
+
useCache: !args["no-cache"]
|
|
858
|
+
});
|
|
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;
|
|
166
1016
|
}
|
|
167
1017
|
function findForgeByHost(forges, host) {
|
|
168
1018
|
for (const [name, forge] of Object.entries(forges)) {
|
|
@@ -225,12 +1075,27 @@ const cloneCommand = defineCommand({
|
|
|
225
1075
|
description: "owner/repo, forge:owner/repo, or full URL",
|
|
226
1076
|
required: true
|
|
227
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
|
+
},
|
|
228
1088
|
config: {
|
|
229
1089
|
type: "string",
|
|
230
1090
|
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
231
1091
|
}
|
|
232
1092
|
},
|
|
233
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
|
+
}
|
|
234
1099
|
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
235
1100
|
const parsed = parseSlug(args.slug);
|
|
236
1101
|
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
@@ -238,6 +1103,15 @@ const cloneCommand = defineCommand({
|
|
|
238
1103
|
config: loaded.config,
|
|
239
1104
|
configDir
|
|
240
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
|
+
}
|
|
241
1115
|
if (existsSync(resolved.localPath)) {
|
|
242
1116
|
consola.info(`Already cloned at ${resolved.localPath}`);
|
|
243
1117
|
return;
|
|
@@ -248,34 +1122,281 @@ const cloneCommand = defineCommand({
|
|
|
248
1122
|
forge: resolved.forge,
|
|
249
1123
|
owner: resolved.owner,
|
|
250
1124
|
repo: resolved.repo,
|
|
251
|
-
dest: resolved.localPath
|
|
1125
|
+
dest: resolved.localPath,
|
|
1126
|
+
protocol
|
|
252
1127
|
});
|
|
1128
|
+
await appendCachedRepo(
|
|
1129
|
+
{ config: loaded.config, configDir },
|
|
1130
|
+
{
|
|
1131
|
+
forgeName: resolved.forgeName,
|
|
1132
|
+
forge: resolved.forge,
|
|
1133
|
+
owner: resolved.owner,
|
|
1134
|
+
repo: resolved.repo,
|
|
1135
|
+
localPath: resolved.localPath,
|
|
1136
|
+
slug: `${resolved.owner}/${resolved.repo}`
|
|
1137
|
+
}
|
|
1138
|
+
);
|
|
253
1139
|
consola.success(
|
|
254
1140
|
`Cloned ${resolved.owner}/${resolved.repo} → ${resolved.localPath}`
|
|
255
1141
|
);
|
|
256
1142
|
}
|
|
257
1143
|
});
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
1198
|
+
const SUBCOMMANDS = [
|
|
1199
|
+
"clone",
|
|
1200
|
+
"import",
|
|
1201
|
+
"cleanup",
|
|
1202
|
+
"cd",
|
|
1203
|
+
"path",
|
|
1204
|
+
"open",
|
|
1205
|
+
"search",
|
|
1206
|
+
"pick",
|
|
1207
|
+
"status",
|
|
1208
|
+
"sync",
|
|
1209
|
+
"validate",
|
|
1210
|
+
"shell-init",
|
|
1211
|
+
"completion",
|
|
1212
|
+
"config"
|
|
1213
|
+
];
|
|
1214
|
+
const SLUG_COMMANDS = ["clone", "cd", "path", "open", "search", "pick"];
|
|
1215
|
+
function renderBash() {
|
|
1216
|
+
return `# forgemap bash completion — drop into your ~/.bashrc:
|
|
1217
|
+
# eval "$(forgemap completion bash)"
|
|
1218
|
+
_forgemap_completion() {
|
|
1219
|
+
local cur prev cmd words
|
|
1220
|
+
COMPREPLY=()
|
|
1221
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
1222
|
+
cmd="\${COMP_WORDS[1]}"
|
|
1223
|
+
|
|
1224
|
+
if [ "$COMP_CWORD" = "1" ]; then
|
|
1225
|
+
COMPREPLY=( $(compgen -W "${SUBCOMMANDS.join(" ")}" -- "$cur") )
|
|
1226
|
+
return
|
|
1227
|
+
fi
|
|
1228
|
+
|
|
1229
|
+
case "$cmd" in
|
|
1230
|
+
${SLUG_COMMANDS.join("|")})
|
|
1231
|
+
local slugs
|
|
1232
|
+
slugs=$(forgemap search '' --format slug 2>/dev/null)
|
|
1233
|
+
COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
|
|
1234
|
+
;;
|
|
1235
|
+
esac
|
|
1236
|
+
}
|
|
1237
|
+
complete -F _forgemap_completion forgemap
|
|
1238
|
+
`;
|
|
1239
|
+
}
|
|
1240
|
+
function renderZsh() {
|
|
1241
|
+
return `# forgemap zsh completion — drop into your ~/.zshrc:
|
|
1242
|
+
# eval "$(forgemap completion zsh)"
|
|
1243
|
+
_forgemap() {
|
|
1244
|
+
local context state line
|
|
1245
|
+
local -a subcommands slug_cmds
|
|
1246
|
+
subcommands=(${SUBCOMMANDS.map((s) => `'${s}'`).join(" ")})
|
|
1247
|
+
slug_cmds=(${SLUG_COMMANDS.map((s) => `'${s}'`).join(" ")})
|
|
1248
|
+
|
|
1249
|
+
_arguments -C \\
|
|
1250
|
+
'1: :->cmd' \\
|
|
1251
|
+
'*::arg:->args'
|
|
1252
|
+
|
|
1253
|
+
case "$state" in
|
|
1254
|
+
cmd) _describe 'forgemap subcommand' subcommands ;;
|
|
1255
|
+
args)
|
|
1256
|
+
if (( $slug_cmds[(I)$words[1]] )); then
|
|
1257
|
+
local -a slugs
|
|
1258
|
+
slugs=("\${(@f)$(forgemap search '' --format slug 2>/dev/null)}")
|
|
1259
|
+
_describe 'slug' slugs
|
|
1260
|
+
fi
|
|
1261
|
+
;;
|
|
1262
|
+
esac
|
|
1263
|
+
}
|
|
1264
|
+
compdef _forgemap forgemap
|
|
1265
|
+
`;
|
|
1266
|
+
}
|
|
1267
|
+
function renderFish$1() {
|
|
1268
|
+
const slugCmdsList = SLUG_COMMANDS.map((s) => `"${s}"`).join(" ");
|
|
1269
|
+
return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
|
|
1270
|
+
# forgemap completion fish | source
|
|
1271
|
+
|
|
1272
|
+
# Subcommands (depth 1).
|
|
1273
|
+
complete -c forgemap -f -n '__fish_use_subcommand' -a '${SUBCOMMANDS.join(" ")}'
|
|
1274
|
+
|
|
1275
|
+
# Slugs (depth 2) for commands that take one.
|
|
1276
|
+
function __forgemap_needs_slug
|
|
1277
|
+
set -l tokens (commandline -opc)
|
|
1278
|
+
set -l slug_cmds ${slugCmdsList}
|
|
1279
|
+
if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
|
|
1280
|
+
return 0
|
|
1281
|
+
end
|
|
1282
|
+
return 1
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
complete -c forgemap -f -n '__forgemap_needs_slug' \\
|
|
1286
|
+
-a '(forgemap search "" --format slug 2>/dev/null)'
|
|
1287
|
+
`;
|
|
1288
|
+
}
|
|
1289
|
+
const completionCommand = defineCommand({
|
|
1290
|
+
meta: {
|
|
1291
|
+
name: "completion",
|
|
1292
|
+
description: 'Print a shell completion script. Source via `eval "$(forgemap completion)"`.'
|
|
1293
|
+
},
|
|
1294
|
+
args: {
|
|
1295
|
+
shell: {
|
|
1296
|
+
type: "positional",
|
|
1297
|
+
description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
|
|
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
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
async run({ args }) {
|
|
1307
|
+
const requested = args.shell ?? detectShell();
|
|
1308
|
+
if (!SUPPORTED_SHELLS.includes(requested)) {
|
|
1309
|
+
consola.error(
|
|
1310
|
+
`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
1311
|
+
);
|
|
1312
|
+
process.exitCode = 1;
|
|
1313
|
+
return;
|
|
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
|
+
}
|
|
1331
|
+
const out = requested === "fish" ? renderFish$1() : requested === "zsh" ? renderZsh() : renderBash();
|
|
1332
|
+
process.stdout.write(out);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
const HEADER = `/**
|
|
1336
|
+
* forgemap configuration.
|
|
1337
|
+
*
|
|
1338
|
+
* For type-safe authoring, install forgemap and switch to:
|
|
1339
|
+
* import { defineForgeMapConfig } from 'forgemap/config';
|
|
1340
|
+
* export default defineForgeMapConfig({ ... });
|
|
1341
|
+
*
|
|
1342
|
+
* @type {import('forgemap').ForgeMapUserConfig}
|
|
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}
|
|
1363
|
+
export default {
|
|
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",
|
|
1392
|
+
forges: {
|
|
1393
|
+
github: {
|
|
1394
|
+
type: "github",
|
|
1395
|
+
host: "github.com",
|
|
1396
|
+
dir: "comGithub"
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
279
1400
|
const configInitCommand = defineCommand({
|
|
280
1401
|
meta: {
|
|
281
1402
|
name: "init",
|
|
@@ -294,16 +1415,20 @@ const configInitCommand = defineCommand({
|
|
|
294
1415
|
}
|
|
295
1416
|
},
|
|
296
1417
|
async run({ args }) {
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
);
|
|
300
1427
|
consola.error(`${target} already exists. Use --force to overwrite.`);
|
|
301
1428
|
process.exitCode = 1;
|
|
302
1429
|
return;
|
|
303
1430
|
}
|
|
304
|
-
|
|
305
|
-
await writeFile(target, TEMPLATE, "utf8");
|
|
306
|
-
consola.success(`Wrote ${target}`);
|
|
1431
|
+
consola.success(`Wrote ${result.path}`);
|
|
307
1432
|
}
|
|
308
1433
|
});
|
|
309
1434
|
const configShowCommand = defineCommand({
|
|
@@ -342,6 +1467,613 @@ const configCommand = defineCommand({
|
|
|
342
1467
|
show: configShowCommand
|
|
343
1468
|
}
|
|
344
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
|
+
});
|
|
2022
|
+
function platformOpen(localPath) {
|
|
2023
|
+
const distro = process.env.WSL_DISTRO_NAME;
|
|
2024
|
+
if (distro) {
|
|
2025
|
+
const winPath = `\\\\wsl$\\${distro}${localPath.replaceAll("/", "\\")}`;
|
|
2026
|
+
return { cmd: "explorer.exe", args: [winPath] };
|
|
2027
|
+
}
|
|
2028
|
+
if (process.platform === "darwin") {
|
|
2029
|
+
return { cmd: "open", args: [localPath] };
|
|
2030
|
+
}
|
|
2031
|
+
return { cmd: "xdg-open", args: [localPath] };
|
|
2032
|
+
}
|
|
2033
|
+
const openCommand = defineCommand({
|
|
2034
|
+
meta: {
|
|
2035
|
+
name: "open",
|
|
2036
|
+
description: "Open a repo in the OS file manager (Explorer on WSL, Finder on macOS, xdg-open elsewhere)"
|
|
2037
|
+
},
|
|
2038
|
+
args: {
|
|
2039
|
+
slug: {
|
|
2040
|
+
type: "positional",
|
|
2041
|
+
description: "owner/repo, forge:owner/repo, or full URL",
|
|
2042
|
+
required: true
|
|
2043
|
+
},
|
|
2044
|
+
config: {
|
|
2045
|
+
type: "string",
|
|
2046
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2047
|
+
}
|
|
2048
|
+
},
|
|
2049
|
+
async run({ args }) {
|
|
2050
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2051
|
+
const parsed = parseSlug(args.slug);
|
|
2052
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2053
|
+
const resolved = resolveSlug(parsed, {
|
|
2054
|
+
config: loaded.config,
|
|
2055
|
+
configDir
|
|
2056
|
+
});
|
|
2057
|
+
const { cmd, args: cmdArgs } = platformOpen(resolved.localPath);
|
|
2058
|
+
consola.info(`Opening ${resolved.localPath}`);
|
|
2059
|
+
const child = spawn(cmd, cmdArgs, {
|
|
2060
|
+
stdio: "ignore",
|
|
2061
|
+
detached: true
|
|
2062
|
+
});
|
|
2063
|
+
child.on("error", (error) => {
|
|
2064
|
+
if (error.code === "ENOENT") {
|
|
2065
|
+
consola.error(
|
|
2066
|
+
`Could not find \`${cmd}\`. Install it (or open the path manually).`
|
|
2067
|
+
);
|
|
2068
|
+
process.exitCode = 1;
|
|
2069
|
+
} else {
|
|
2070
|
+
consola.error(error.message);
|
|
2071
|
+
process.exitCode = 1;
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
child.unref();
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
345
2077
|
const pathCommand = defineCommand({
|
|
346
2078
|
meta: {
|
|
347
2079
|
name: "path",
|
|
@@ -370,6 +2102,714 @@ const pathCommand = defineCommand({
|
|
|
370
2102
|
`);
|
|
371
2103
|
}
|
|
372
2104
|
});
|
|
2105
|
+
const pickCommand = defineCommand({
|
|
2106
|
+
meta: {
|
|
2107
|
+
name: "pick",
|
|
2108
|
+
description: "Interactively pick a cloned repo from the configured layout and print its path"
|
|
2109
|
+
},
|
|
2110
|
+
args: {
|
|
2111
|
+
query: {
|
|
2112
|
+
type: "positional",
|
|
2113
|
+
description: "Optional fuzzy filter applied before showing the picker",
|
|
2114
|
+
required: false
|
|
2115
|
+
},
|
|
2116
|
+
config: {
|
|
2117
|
+
type: "string",
|
|
2118
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2119
|
+
}
|
|
2120
|
+
},
|
|
2121
|
+
async run({ args }) {
|
|
2122
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2123
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2124
|
+
const all = await scanRepos({ config: loaded.config, configDir });
|
|
2125
|
+
let candidates;
|
|
2126
|
+
if (args.query) {
|
|
2127
|
+
const fuse = new Fuse(all, {
|
|
2128
|
+
keys: ["slug", "owner", "repo"],
|
|
2129
|
+
threshold: 0.3,
|
|
2130
|
+
ignoreLocation: true
|
|
2131
|
+
});
|
|
2132
|
+
candidates = fuse.search(args.query).map((r) => r.item);
|
|
2133
|
+
} else {
|
|
2134
|
+
candidates = all;
|
|
2135
|
+
}
|
|
2136
|
+
if (candidates.length === 0) {
|
|
2137
|
+
consola.error(
|
|
2138
|
+
args.query ? `No repos match "${args.query}".` : "No repos found under the configured root."
|
|
2139
|
+
);
|
|
2140
|
+
process.exitCode = 1;
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
if (candidates.length === 1) {
|
|
2144
|
+
process.stdout.write(`${candidates[0].localPath}
|
|
2145
|
+
`);
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (!process.stdin.isTTY) {
|
|
2149
|
+
consola.error(
|
|
2150
|
+
"pick requires an interactive terminal. Use `forgemap search` for non-interactive output."
|
|
2151
|
+
);
|
|
2152
|
+
process.exitCode = 1;
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
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
|
+
}
|
|
2189
|
+
if (typeof choice === "string" && choice) {
|
|
2190
|
+
realWrite.call(out, `${choice}
|
|
2191
|
+
`);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
function renderTree$1(repos) {
|
|
2196
|
+
const byForge = /* @__PURE__ */ new Map();
|
|
2197
|
+
for (const r of repos) {
|
|
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);
|
|
2204
|
+
if (list) list.push(r);
|
|
2205
|
+
else owners.set(r.owner, [r]);
|
|
2206
|
+
}
|
|
2207
|
+
return formatTree(
|
|
2208
|
+
Array.from(byForge, ([forge, owners]) => ({
|
|
2209
|
+
text: colors.bold(forge),
|
|
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
|
+
}))
|
|
2215
|
+
}))
|
|
2216
|
+
}))
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
const searchCommand = defineCommand({
|
|
2220
|
+
meta: {
|
|
2221
|
+
name: "search",
|
|
2222
|
+
description: "Fuzzy-search cloned repos by owner/repo and print matching repos"
|
|
2223
|
+
},
|
|
2224
|
+
args: {
|
|
2225
|
+
query: {
|
|
2226
|
+
type: "positional",
|
|
2227
|
+
description: "Search term (matched fuzzily against <owner>/<repo>)",
|
|
2228
|
+
required: true
|
|
2229
|
+
},
|
|
2230
|
+
format: {
|
|
2231
|
+
type: "string",
|
|
2232
|
+
description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
|
|
2233
|
+
default: "auto"
|
|
2234
|
+
},
|
|
2235
|
+
limit: {
|
|
2236
|
+
type: "string",
|
|
2237
|
+
description: "Maximum number of matches to print (default: unlimited)"
|
|
2238
|
+
},
|
|
2239
|
+
config: {
|
|
2240
|
+
type: "string",
|
|
2241
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2242
|
+
}
|
|
2243
|
+
},
|
|
2244
|
+
async run({ args }) {
|
|
2245
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2246
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2247
|
+
const repos = await scanRepos({ config: loaded.config, configDir });
|
|
2248
|
+
const fuse = new Fuse(repos, {
|
|
2249
|
+
keys: ["slug", "owner", "repo"],
|
|
2250
|
+
threshold: 0.3,
|
|
2251
|
+
ignoreLocation: true,
|
|
2252
|
+
includeScore: true
|
|
2253
|
+
});
|
|
2254
|
+
const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
|
|
2255
|
+
const results = fuse.search(args.query, limit ? { limit } : void 0);
|
|
2256
|
+
const items = results.map((r) => r.item);
|
|
2257
|
+
const allowed = ["auto", "pretty", "path", "slug"];
|
|
2258
|
+
if (!allowed.includes(args.format)) {
|
|
2259
|
+
consola.error(
|
|
2260
|
+
`Invalid --format value "${args.format}". Allowed: ${allowed.join(", ")}.`
|
|
2261
|
+
);
|
|
2262
|
+
process.exitCode = 1;
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
const requested = args.format;
|
|
2266
|
+
const format = requested === "auto" ? process.stdout.isTTY ? "pretty" : "path" : requested;
|
|
2267
|
+
if (items.length === 0) {
|
|
2268
|
+
if (format === "pretty") consola.info(`No matches for "${args.query}".`);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (format === "pretty") {
|
|
2272
|
+
process.stdout.write(`${renderTree$1(items)}
|
|
2273
|
+
`);
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
for (const item of items) {
|
|
2277
|
+
process.stdout.write(
|
|
2278
|
+
`${format === "slug" ? item.slug : item.localPath}
|
|
2279
|
+
`
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
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
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
function renderPosix(name) {
|
|
2309
|
+
return `# forgemap shell integration — drop into your ~/.zshrc / ~/.bashrc:
|
|
2310
|
+
# eval "$(forgemap shell-init)"
|
|
2311
|
+
#
|
|
2312
|
+
# Wraps the forgemap binary so that \`${name} cd <slug>\` actually changes
|
|
2313
|
+
# directory in this shell. All other subcommands fall through unchanged.
|
|
2314
|
+
|
|
2315
|
+
${name}() {
|
|
2316
|
+
if [ "$1" = "cd" ]; then
|
|
2317
|
+
shift
|
|
2318
|
+
local target
|
|
2319
|
+
if [ "$#" -eq 0 ]; then
|
|
2320
|
+
target=$(command forgemap pick) || return $?
|
|
2321
|
+
else
|
|
2322
|
+
local matches
|
|
2323
|
+
matches=$(command forgemap search "$1" --format path)
|
|
2324
|
+
local count
|
|
2325
|
+
count=$(printf '%s' "$matches" | grep -c '^/' || true)
|
|
2326
|
+
if [ "$count" = "1" ]; then
|
|
2327
|
+
target="$matches"
|
|
2328
|
+
elif [ "$count" = "0" ]; then
|
|
2329
|
+
echo "forgemap cd: no match for $1" >&2
|
|
2330
|
+
return 1
|
|
2331
|
+
else
|
|
2332
|
+
target=$(command forgemap pick "$1") || return $?
|
|
2333
|
+
fi
|
|
2334
|
+
fi
|
|
2335
|
+
[ -n "$target" ] && builtin cd "$target"
|
|
2336
|
+
return
|
|
2337
|
+
fi
|
|
2338
|
+
command forgemap "$@"
|
|
2339
|
+
}
|
|
2340
|
+
`;
|
|
2341
|
+
}
|
|
2342
|
+
function renderFish(name) {
|
|
2343
|
+
return `# forgemap shell integration — drop into your ~/.config/fish/config.fish:
|
|
2344
|
+
# forgemap shell-init fish | source
|
|
2345
|
+
|
|
2346
|
+
function ${name} --description "forgemap with cd interception"
|
|
2347
|
+
if test (count $argv) -ge 1 -a "$argv[1]" = "cd"
|
|
2348
|
+
set --erase argv[1]
|
|
2349
|
+
set target ""
|
|
2350
|
+
if test (count $argv) -eq 0
|
|
2351
|
+
set target (command forgemap pick); or return $status
|
|
2352
|
+
else
|
|
2353
|
+
set matches (command forgemap search $argv[1] --format path)
|
|
2354
|
+
set count (count $matches)
|
|
2355
|
+
if test $count -eq 1
|
|
2356
|
+
set target $matches[1]
|
|
2357
|
+
else if test $count -eq 0
|
|
2358
|
+
echo "forgemap cd: no match for $argv[1]" >&2
|
|
2359
|
+
return 1
|
|
2360
|
+
else
|
|
2361
|
+
set target (command forgemap pick $argv[1]); or return $status
|
|
2362
|
+
end
|
|
2363
|
+
end
|
|
2364
|
+
test -n "$target"; and builtin cd $target
|
|
2365
|
+
return
|
|
2366
|
+
end
|
|
2367
|
+
command forgemap $argv
|
|
2368
|
+
end
|
|
2369
|
+
`;
|
|
2370
|
+
}
|
|
2371
|
+
const shellInitCommand = defineCommand({
|
|
2372
|
+
meta: {
|
|
2373
|
+
name: "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)"`.'
|
|
2375
|
+
},
|
|
2376
|
+
args: {
|
|
2377
|
+
shell: {
|
|
2378
|
+
type: "positional",
|
|
2379
|
+
description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
|
|
2380
|
+
required: false
|
|
2381
|
+
},
|
|
2382
|
+
name: {
|
|
2383
|
+
type: "string",
|
|
2384
|
+
description: "Name of the generated wrapper function (default: forgemap)",
|
|
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
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
async run({ args }) {
|
|
2394
|
+
const requested = args.shell ?? detectShell();
|
|
2395
|
+
if (!SUPPORTED_SHELLS.includes(requested)) {
|
|
2396
|
+
consola.error(
|
|
2397
|
+
`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
2398
|
+
);
|
|
2399
|
+
process.exitCode = 1;
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
const name = args.name || "forgemap";
|
|
2403
|
+
if (args.install) {
|
|
2404
|
+
await install(requested, name);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
const out = requested === "fish" ? renderFish(name) : renderPosix(name);
|
|
2408
|
+
process.stdout.write(out);
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
function statusLine(row) {
|
|
2412
|
+
if (row.error || !row.status) {
|
|
2413
|
+
return `${colors.cyan(row.repo.repo)} ${colors.red(`error: ${row.error ?? "unknown"}`)}`;
|
|
2414
|
+
}
|
|
2415
|
+
const s = row.status;
|
|
2416
|
+
const parts = [colors.cyan(row.repo.repo)];
|
|
2417
|
+
const aheadBehind = [];
|
|
2418
|
+
if (s.ahead > 0) aheadBehind.push(colors.green(`↑${s.ahead}`));
|
|
2419
|
+
if (s.behind > 0) aheadBehind.push(colors.yellow(`↓${s.behind}`));
|
|
2420
|
+
if (aheadBehind.length > 0) parts.push(aheadBehind.join(" "));
|
|
2421
|
+
parts.push(s.dirty ? colors.red("●") : colors.green("✓"));
|
|
2422
|
+
parts.push(colors.gray(s.branch));
|
|
2423
|
+
if (s.lastCommit) {
|
|
2424
|
+
parts.push(colors.dim(`${s.lastCommit.sha} ${s.lastCommit.relativeDate}`));
|
|
2425
|
+
}
|
|
2426
|
+
return parts.join(" ");
|
|
2427
|
+
}
|
|
2428
|
+
function renderTree(rows) {
|
|
2429
|
+
const byForge = /* @__PURE__ */ new Map();
|
|
2430
|
+
for (const row of rows) {
|
|
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);
|
|
2437
|
+
if (list) list.push(row);
|
|
2438
|
+
else owners.set(row.repo.owner, [row]);
|
|
2439
|
+
}
|
|
2440
|
+
return formatTree(
|
|
2441
|
+
Array.from(byForge, ([forge, owners]) => ({
|
|
2442
|
+
text: colors.bold(forge),
|
|
2443
|
+
children: Array.from(owners, ([owner, items]) => ({
|
|
2444
|
+
text: owner,
|
|
2445
|
+
children: items.map((row) => ({ text: statusLine(row) }))
|
|
2446
|
+
}))
|
|
2447
|
+
}))
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
const ALLOWED_FORMATS = ["pretty", "json"];
|
|
2451
|
+
const statusCommand = defineCommand({
|
|
2452
|
+
meta: {
|
|
2453
|
+
name: "status",
|
|
2454
|
+
description: "Show branch, dirty, ahead/behind, and last commit per repo"
|
|
2455
|
+
},
|
|
2456
|
+
args: {
|
|
2457
|
+
format: {
|
|
2458
|
+
type: "string",
|
|
2459
|
+
description: "Output format: pretty (default) or json",
|
|
2460
|
+
default: "pretty"
|
|
2461
|
+
},
|
|
2462
|
+
forge: {
|
|
2463
|
+
type: "string",
|
|
2464
|
+
description: "Restrict to a single forge alias"
|
|
2465
|
+
},
|
|
2466
|
+
query: {
|
|
2467
|
+
type: "string",
|
|
2468
|
+
description: "Fuzzy filter against <owner>/<repo>"
|
|
2469
|
+
},
|
|
2470
|
+
"no-cache": {
|
|
2471
|
+
type: "boolean",
|
|
2472
|
+
description: "Skip the scanned-repos cache",
|
|
2473
|
+
default: false
|
|
2474
|
+
},
|
|
2475
|
+
config: {
|
|
2476
|
+
type: "string",
|
|
2477
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2478
|
+
}
|
|
2479
|
+
},
|
|
2480
|
+
async run({ args }) {
|
|
2481
|
+
if (!ALLOWED_FORMATS.includes(args.format)) {
|
|
2482
|
+
consola.error(
|
|
2483
|
+
`Invalid --format value "${args.format}". Allowed: ${ALLOWED_FORMATS.join(", ")}.`
|
|
2484
|
+
);
|
|
2485
|
+
process.exitCode = 1;
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2489
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2490
|
+
let repos = await scanReposCached({
|
|
2491
|
+
config: loaded.config,
|
|
2492
|
+
configDir,
|
|
2493
|
+
useCache: !args["no-cache"]
|
|
2494
|
+
});
|
|
2495
|
+
if (args.forge) {
|
|
2496
|
+
repos = repos.filter((r) => r.forgeName === args.forge);
|
|
2497
|
+
}
|
|
2498
|
+
if (args.query) {
|
|
2499
|
+
const fuse = new Fuse(repos, {
|
|
2500
|
+
keys: ["slug", "owner", "repo"],
|
|
2501
|
+
threshold: 0.3,
|
|
2502
|
+
ignoreLocation: true
|
|
2503
|
+
});
|
|
2504
|
+
repos = fuse.search(args.query).map((r) => r.item);
|
|
2505
|
+
}
|
|
2506
|
+
const rows = await Promise.all(
|
|
2507
|
+
repos.map(async (repo) => {
|
|
2508
|
+
try {
|
|
2509
|
+
return { repo, status: await getRepoStatus(repo.localPath) };
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
return { repo, status: null, error: error.message };
|
|
2512
|
+
}
|
|
2513
|
+
})
|
|
2514
|
+
);
|
|
2515
|
+
if (args.format === "json") {
|
|
2516
|
+
process.stdout.write(
|
|
2517
|
+
`${JSON.stringify(
|
|
2518
|
+
rows.map((r) => ({
|
|
2519
|
+
forge: r.repo.forgeName,
|
|
2520
|
+
owner: r.repo.owner,
|
|
2521
|
+
repo: r.repo.repo,
|
|
2522
|
+
localPath: r.repo.localPath,
|
|
2523
|
+
status: r.status,
|
|
2524
|
+
error: r.error ?? null
|
|
2525
|
+
})),
|
|
2526
|
+
null,
|
|
2527
|
+
2
|
|
2528
|
+
)}
|
|
2529
|
+
`
|
|
2530
|
+
);
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (rows.length === 0) {
|
|
2534
|
+
consola.info("No repos to report on.");
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
process.stdout.write(`${renderTree(rows)}
|
|
2538
|
+
`);
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
async function runWithConcurrency(items, limit, task) {
|
|
2542
|
+
const queue = [...items];
|
|
2543
|
+
const workers = Array.from(
|
|
2544
|
+
{ length: Math.min(limit, queue.length) },
|
|
2545
|
+
async () => {
|
|
2546
|
+
while (queue.length > 0) {
|
|
2547
|
+
const next = queue.shift();
|
|
2548
|
+
if (!next) return;
|
|
2549
|
+
await task(next);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
);
|
|
2553
|
+
await Promise.all(workers);
|
|
2554
|
+
}
|
|
2555
|
+
const syncCommand = defineCommand({
|
|
2556
|
+
meta: {
|
|
2557
|
+
name: "sync",
|
|
2558
|
+
description: "Run git fetch (or --pull) across every cloned repo, in parallel"
|
|
2559
|
+
},
|
|
2560
|
+
args: {
|
|
2561
|
+
pull: {
|
|
2562
|
+
type: "boolean",
|
|
2563
|
+
description: "Pull --ff-only instead of fetch. Dirty working trees are skipped.",
|
|
2564
|
+
default: false
|
|
2565
|
+
},
|
|
2566
|
+
concurrency: {
|
|
2567
|
+
type: "string",
|
|
2568
|
+
description: "Number of parallel workers (default: 4)"
|
|
2569
|
+
},
|
|
2570
|
+
sequential: {
|
|
2571
|
+
type: "boolean",
|
|
2572
|
+
description: "Run one repo at a time (overrides --concurrency)",
|
|
2573
|
+
default: false
|
|
2574
|
+
},
|
|
2575
|
+
forge: {
|
|
2576
|
+
type: "string",
|
|
2577
|
+
description: "Restrict to a single forge alias"
|
|
2578
|
+
},
|
|
2579
|
+
query: {
|
|
2580
|
+
type: "string",
|
|
2581
|
+
description: "Fuzzy filter against <owner>/<repo>"
|
|
2582
|
+
},
|
|
2583
|
+
"no-cache": {
|
|
2584
|
+
type: "boolean",
|
|
2585
|
+
description: "Skip the scanned-repos cache",
|
|
2586
|
+
default: false
|
|
2587
|
+
},
|
|
2588
|
+
config: {
|
|
2589
|
+
type: "string",
|
|
2590
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2593
|
+
async run({ args }) {
|
|
2594
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2595
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2596
|
+
let repos = await scanReposCached({
|
|
2597
|
+
config: loaded.config,
|
|
2598
|
+
configDir,
|
|
2599
|
+
useCache: !args["no-cache"]
|
|
2600
|
+
});
|
|
2601
|
+
if (args.forge) {
|
|
2602
|
+
repos = repos.filter((r) => r.forgeName === args.forge);
|
|
2603
|
+
}
|
|
2604
|
+
if (args.query) {
|
|
2605
|
+
const fuse = new Fuse(repos, {
|
|
2606
|
+
keys: ["slug", "owner", "repo"],
|
|
2607
|
+
threshold: 0.3,
|
|
2608
|
+
ignoreLocation: true
|
|
2609
|
+
});
|
|
2610
|
+
repos = fuse.search(args.query).map((r) => r.item);
|
|
2611
|
+
}
|
|
2612
|
+
if (repos.length === 0) {
|
|
2613
|
+
consola.info("Nothing to sync.");
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const concurrency = args.sequential ? 1 : args.concurrency ? Math.max(1, Number.parseInt(args.concurrency, 10)) : 4;
|
|
2617
|
+
consola.info(
|
|
2618
|
+
`Syncing ${repos.length} repo(s) — ${args.pull ? "pull" : "fetch"}, concurrency ${concurrency}`
|
|
2619
|
+
);
|
|
2620
|
+
const outcomes = [];
|
|
2621
|
+
await runWithConcurrency(repos, concurrency, async (repo) => {
|
|
2622
|
+
try {
|
|
2623
|
+
if (args.pull && !await isClean(repo.localPath)) {
|
|
2624
|
+
outcomes.push({
|
|
2625
|
+
repo,
|
|
2626
|
+
status: "skipped",
|
|
2627
|
+
message: "dirty working tree"
|
|
2628
|
+
});
|
|
2629
|
+
consola.warn(`${colors.dim(repo.slug)} — skipped (dirty)`);
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const result = args.pull ? await pullRepo(repo.localPath) : await fetchRepo(repo.localPath);
|
|
2633
|
+
if (result.code === 0) {
|
|
2634
|
+
outcomes.push({ repo, status: "synced" });
|
|
2635
|
+
consola.success(colors.dim(repo.slug));
|
|
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}`;
|
|
2638
|
+
outcomes.push({
|
|
2639
|
+
repo,
|
|
2640
|
+
status: "failed",
|
|
2641
|
+
message
|
|
2642
|
+
});
|
|
2643
|
+
consola.fail(
|
|
2644
|
+
`${colors.dim(repo.slug)} — ${outcomes.at(-1)?.message}`
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
outcomes.push({
|
|
2649
|
+
repo,
|
|
2650
|
+
status: "failed",
|
|
2651
|
+
message: error.message
|
|
2652
|
+
});
|
|
2653
|
+
consola.fail(`${colors.dim(repo.slug)} — ${error.message}`);
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
const synced = outcomes.filter((o) => o.status === "synced").length;
|
|
2657
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2658
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2659
|
+
consola.info(
|
|
2660
|
+
`Done — ${colors.green(`${synced} synced`)}, ${colors.yellow(`${skipped} skipped`)}, ${colors.red(`${failed} failed`)}`
|
|
2661
|
+
);
|
|
2662
|
+
if (failed > 0) {
|
|
2663
|
+
process.exitCode = 1;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
const KNOWN_TYPES = /* @__PURE__ */ new Set(["github", "gitlab", "gitea", "codeberg", "git"]);
|
|
2668
|
+
function validateForge(name, forge) {
|
|
2669
|
+
if (!KNOWN_TYPES.has(forge.type)) {
|
|
2670
|
+
return {
|
|
2671
|
+
name: `forge "${name}"`,
|
|
2672
|
+
severity: "fail",
|
|
2673
|
+
message: `unknown type "${forge.type}"`
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
if (!forge.host?.trim()) {
|
|
2677
|
+
return {
|
|
2678
|
+
name: `forge "${name}"`,
|
|
2679
|
+
severity: "fail",
|
|
2680
|
+
message: "host is empty"
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
if (!forge.dir?.trim()) {
|
|
2684
|
+
return {
|
|
2685
|
+
name: `forge "${name}"`,
|
|
2686
|
+
severity: "fail",
|
|
2687
|
+
message: "dir is empty"
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
return {
|
|
2691
|
+
name: `forge "${name}"`,
|
|
2692
|
+
severity: "ok",
|
|
2693
|
+
message: `${forge.type} at ${forge.host}`
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
async function runChecks(config, configDir) {
|
|
2697
|
+
const checks = [];
|
|
2698
|
+
for (const [name, forge] of Object.entries(config.forges)) {
|
|
2699
|
+
checks.push(validateForge(name, forge));
|
|
2700
|
+
}
|
|
2701
|
+
checks.push(
|
|
2702
|
+
config.forges[config.defaultForge] ? {
|
|
2703
|
+
name: "defaultForge",
|
|
2704
|
+
severity: "ok",
|
|
2705
|
+
message: `→ ${config.defaultForge}`
|
|
2706
|
+
} : {
|
|
2707
|
+
name: "defaultForge",
|
|
2708
|
+
severity: "fail",
|
|
2709
|
+
message: `"${config.defaultForge}" is not in forges`
|
|
2710
|
+
}
|
|
2711
|
+
);
|
|
2712
|
+
const root = resolveRoot(config.root, configDir);
|
|
2713
|
+
try {
|
|
2714
|
+
await access(root);
|
|
2715
|
+
checks.push({
|
|
2716
|
+
name: "root directory",
|
|
2717
|
+
severity: "ok",
|
|
2718
|
+
message: root
|
|
2719
|
+
});
|
|
2720
|
+
} catch {
|
|
2721
|
+
checks.push({
|
|
2722
|
+
name: "root directory",
|
|
2723
|
+
severity: "fail",
|
|
2724
|
+
message: `${root} does not exist (mkdir -p it or fix root in config)`
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
const types = new Set(Object.values(config.forges).map((f) => f.type));
|
|
2728
|
+
const needsGit = types.has("git") || types.size > 0;
|
|
2729
|
+
const needsGh = types.has("github");
|
|
2730
|
+
if (needsGit) {
|
|
2731
|
+
checks.push(
|
|
2732
|
+
await hasCommand("git") ? { name: "git CLI", severity: "ok", message: "on PATH" } : {
|
|
2733
|
+
name: "git CLI",
|
|
2734
|
+
severity: "fail",
|
|
2735
|
+
message: "install from https://git-scm.com/"
|
|
2736
|
+
}
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
if (needsGh) {
|
|
2740
|
+
if (await hasCommand("gh")) {
|
|
2741
|
+
checks.push({ name: "gh CLI", severity: "ok", message: "on PATH" });
|
|
2742
|
+
const auth = await execCapture("gh", ["auth", "status"]);
|
|
2743
|
+
checks.push(
|
|
2744
|
+
auth.code === 0 ? { name: "gh auth", severity: "ok", message: "authenticated" } : {
|
|
2745
|
+
name: "gh auth",
|
|
2746
|
+
severity: "warn",
|
|
2747
|
+
message: "not logged in — run `gh auth login`"
|
|
2748
|
+
}
|
|
2749
|
+
);
|
|
2750
|
+
} else {
|
|
2751
|
+
checks.push({
|
|
2752
|
+
name: "gh CLI",
|
|
2753
|
+
severity: "fail",
|
|
2754
|
+
message: "install from https://cli.github.com/"
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return checks;
|
|
2759
|
+
}
|
|
2760
|
+
function severitySymbol(severity) {
|
|
2761
|
+
if (severity === "ok") return colors.green("✓");
|
|
2762
|
+
if (severity === "warn") return colors.yellow("!");
|
|
2763
|
+
return colors.red("✗");
|
|
2764
|
+
}
|
|
2765
|
+
const validateCommand = defineCommand({
|
|
2766
|
+
meta: {
|
|
2767
|
+
name: "validate",
|
|
2768
|
+
description: "Preflight: check the config schema, required CLI tools, and root directory"
|
|
2769
|
+
},
|
|
2770
|
+
args: {
|
|
2771
|
+
json: {
|
|
2772
|
+
type: "boolean",
|
|
2773
|
+
description: "Emit a machine-readable JSON report",
|
|
2774
|
+
default: false
|
|
2775
|
+
},
|
|
2776
|
+
config: {
|
|
2777
|
+
type: "string",
|
|
2778
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
2779
|
+
}
|
|
2780
|
+
},
|
|
2781
|
+
async run({ args }) {
|
|
2782
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
2783
|
+
const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
|
|
2784
|
+
const checks = await runChecks(loaded.config, configDir);
|
|
2785
|
+
const ok = checks.every((c) => c.severity !== "fail");
|
|
2786
|
+
if (args.json) {
|
|
2787
|
+
process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}
|
|
2788
|
+
`);
|
|
2789
|
+
} else {
|
|
2790
|
+
for (const c of checks) {
|
|
2791
|
+
process.stdout.write(
|
|
2792
|
+
`${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}
|
|
2793
|
+
`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
process.stdout.write(
|
|
2797
|
+
`
|
|
2798
|
+
${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}
|
|
2799
|
+
`
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
if (!ok) {
|
|
2803
|
+
process.exitCode = 1;
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (!loaded.configFile) {
|
|
2807
|
+
consola.warn(
|
|
2808
|
+
"No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one."
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
});
|
|
373
2813
|
const rootCommand = defineCommand({
|
|
374
2814
|
meta: {
|
|
375
2815
|
name: "forgemap",
|
|
@@ -377,7 +2817,18 @@ const rootCommand = defineCommand({
|
|
|
377
2817
|
},
|
|
378
2818
|
subCommands: {
|
|
379
2819
|
clone: cloneCommand,
|
|
2820
|
+
import: importCommand,
|
|
2821
|
+
cleanup: cleanupCommand,
|
|
2822
|
+
cd: cdCommand,
|
|
380
2823
|
path: pathCommand,
|
|
2824
|
+
open: openCommand,
|
|
2825
|
+
search: searchCommand,
|
|
2826
|
+
pick: pickCommand,
|
|
2827
|
+
status: statusCommand,
|
|
2828
|
+
sync: syncCommand,
|
|
2829
|
+
validate: validateCommand,
|
|
2830
|
+
completion: completionCommand,
|
|
2831
|
+
"shell-init": shellInitCommand,
|
|
381
2832
|
config: configCommand
|
|
382
2833
|
}
|
|
383
2834
|
});
|