forgemap 0.7.0 → 0.8.0-dev.133-c1be991
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 +16 -10
- package/dist/bin/forgemap.mjs +669 -276
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +3 -3
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -76,7 +76,8 @@ function findConfigUp(start) {
|
|
|
76
76
|
}
|
|
77
77
|
/** Fallback config under $XDG_CONFIG_HOME/forgemap (or ~/.config/forgemap). */
|
|
78
78
|
function findGlobalConfig() {
|
|
79
|
-
const
|
|
79
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
80
|
+
const dir = join(base, "forgemap");
|
|
80
81
|
for (const baseName of CONFIG_BASENAMES) {
|
|
81
82
|
const candidate = join(dir, baseName);
|
|
82
83
|
if (existsSync(candidate)) return candidate;
|
|
@@ -175,36 +176,186 @@ async function loadForgeMapConfig(options = {}) {
|
|
|
175
176
|
};
|
|
176
177
|
}
|
|
177
178
|
//#endregion
|
|
179
|
+
//#region src/utils/concurrency.ts
|
|
180
|
+
/**
|
|
181
|
+
* Map over `items` running at most `limit` calls of `fn` at once, preserving
|
|
182
|
+
* input order in the result. Keeps `import` from spawning one subprocess per
|
|
183
|
+
* repo all at once when checking remotes across a large tree.
|
|
184
|
+
*/
|
|
185
|
+
async function mapLimit(items, limit, fn) {
|
|
186
|
+
const results = Array.from({ length: items.length });
|
|
187
|
+
const max = Math.max(1, Math.min(limit, items.length));
|
|
188
|
+
let next = 0;
|
|
189
|
+
async function worker() {
|
|
190
|
+
while (next < items.length) {
|
|
191
|
+
const index = next++;
|
|
192
|
+
results[index] = await fn(items[index], index);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The entry that marks a directory as a repo. A **file** counts as much as a
|
|
200
|
+
* directory: linked worktrees and submodules record their git dir in a `.git`
|
|
201
|
+
* file, and an `isDirectory()` test would skip them silently.
|
|
202
|
+
*/
|
|
203
|
+
var GIT_MARKER = ".git";
|
|
204
|
+
/**
|
|
205
|
+
* How many namespace segments a forge type accepts. GitHub, Gitea and Codeberg
|
|
206
|
+
* have exactly one level of owner; GitLab nests arbitrarily, and `git` is the
|
|
207
|
+
* documented fallback for a GitLab-shaped remote, so it nests too.
|
|
208
|
+
*/
|
|
209
|
+
function namespaceDepthLimit(type) {
|
|
210
|
+
switch (type) {
|
|
211
|
+
case "gitlab":
|
|
212
|
+
case "git": return 9;
|
|
213
|
+
case "github":
|
|
214
|
+
case "gitea":
|
|
215
|
+
case "codeberg": return 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Check a parsed namespace against the forge it was resolved to. Returns an
|
|
220
|
+
* error message, or `null` when the depth is acceptable.
|
|
221
|
+
*
|
|
222
|
+
* This lives here rather than in `parseSlug` on purpose: the parser has no
|
|
223
|
+
* forge, and keeping it pure is worth more than an earlier error message.
|
|
224
|
+
*/
|
|
225
|
+
function checkNamespaceDepth(forgeName, type, namespace) {
|
|
226
|
+
const depth = namespace.split("/").filter(Boolean).length;
|
|
227
|
+
const limit = namespaceDepthLimit(type);
|
|
228
|
+
if (depth <= limit) return null;
|
|
229
|
+
if (limit === 1) return `Forge "${forgeName}" (type ${type}) does not support nested namespaces: "${namespace}" has ${depth} segments, expected 1.`;
|
|
230
|
+
return `Namespace "${namespace}" is ${depth} segments deep; forgemap supports at most ${limit}.`;
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
178
233
|
//#region src/repos/scan.ts
|
|
179
|
-
|
|
234
|
+
var WALK_CONCURRENCY = 32;
|
|
235
|
+
async function readEntries$1(path) {
|
|
180
236
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
237
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
238
|
+
return {
|
|
239
|
+
dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
|
|
240
|
+
isRepo: entries.some((e) => e.name === GIT_MARKER)
|
|
241
|
+
};
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
185
244
|
}
|
|
186
245
|
}
|
|
187
|
-
async function
|
|
246
|
+
async function safeStat(path) {
|
|
247
|
+
try {
|
|
248
|
+
const s = await stat(path);
|
|
249
|
+
return Math.trunc(s.mtimeMs);
|
|
250
|
+
} catch {
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* One rule for every forge type: a directory holding a `.git` entry **is** a
|
|
256
|
+
* repo, and everything above it is namespace. The walk stops at the first
|
|
257
|
+
* marker and never descends into a repo, which keeps submodules and nested
|
|
258
|
+
* checkouts out without a special case for either.
|
|
259
|
+
*
|
|
260
|
+
* `segments` is the path accumulated below the forge dir; the repo takes the
|
|
261
|
+
* last one and the namespace the rest, so a repo needs at least two.
|
|
262
|
+
*
|
|
263
|
+
* The cap counts those segments inclusive of the repo's own — a namespace at
|
|
264
|
+
* exactly MAX_NAMESPACE_DEPTH must still have its repo visited, or the
|
|
265
|
+
* resolver would accept a path the scanner can never find.
|
|
266
|
+
*/
|
|
267
|
+
async function walk(ctx, dirPath, segments) {
|
|
268
|
+
if (segments.length > 10) {
|
|
269
|
+
ctx.hints.push({
|
|
270
|
+
path: dirPath,
|
|
271
|
+
reason: "too-deep"
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const entries = await readEntries$1(dirPath);
|
|
276
|
+
if (!entries) {
|
|
277
|
+
if (segments.length === 0) ctx.entries.push([dirPath, "d:0:[]"]);
|
|
278
|
+
else ctx.hints.push({
|
|
279
|
+
path: dirPath,
|
|
280
|
+
reason: "no-repo"
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (entries.isRepo) {
|
|
285
|
+
ctx.entries.push([dirPath, "r"]);
|
|
286
|
+
if (segments.length < 2) {
|
|
287
|
+
ctx.hints.push({
|
|
288
|
+
path: dirPath,
|
|
289
|
+
reason: "missing-namespace"
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const owner = segments.slice(0, -1).join("/");
|
|
294
|
+
const repo = segments.at(-1);
|
|
295
|
+
ctx.repos.push({
|
|
296
|
+
forgeName: ctx.forgeName,
|
|
297
|
+
forge: ctx.forge,
|
|
298
|
+
owner,
|
|
299
|
+
repo,
|
|
300
|
+
localPath: dirPath,
|
|
301
|
+
slug: `${owner}/${repo}`
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const names = [...entries.dirs].sort();
|
|
306
|
+
const mtime = await safeStat(dirPath);
|
|
307
|
+
ctx.entries.push([dirPath, `d:${mtime}:${JSON.stringify(names)}`]);
|
|
308
|
+
if (names.length === 0) {
|
|
309
|
+
if (segments.length > 0) ctx.hints.push({
|
|
310
|
+
path: dirPath,
|
|
311
|
+
reason: "no-repo"
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await mapLimit(names, WALK_CONCURRENCY, (name) => walk(ctx, join(dirPath, name), [...segments, name]));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Walk the configured layout once, producing the repos, the branches that
|
|
319
|
+
* yielded none, and a fingerprint of what was observed.
|
|
320
|
+
*
|
|
321
|
+
* The fingerprint records each namespace directory's mtime **and** its sorted
|
|
322
|
+
* child names, plus which directories turned out to be repos. The names are
|
|
323
|
+
* what make it reliable: mtimes compare at millisecond granularity, so two
|
|
324
|
+
* clones landing inside the same millisecond hash identically and a stale
|
|
325
|
+
* cache would win. Recording the repo classification is what catches a plain
|
|
326
|
+
* directory becoming a checkout (`git init`) without any name moving at all.
|
|
327
|
+
*/
|
|
328
|
+
async function scanLayout(options) {
|
|
188
329
|
const { config, configDir } = options;
|
|
189
330
|
const root = resolveRoot(config.root, configDir);
|
|
331
|
+
const contexts = await mapLimit(Object.entries(config.forges), WALK_CONCURRENCY, async ([forgeName, forge]) => {
|
|
332
|
+
const ctx = {
|
|
333
|
+
forgeName,
|
|
334
|
+
forge,
|
|
335
|
+
repos: [],
|
|
336
|
+
hints: [],
|
|
337
|
+
entries: []
|
|
338
|
+
};
|
|
339
|
+
await walk(ctx, join(root, forge.dir), []);
|
|
340
|
+
return ctx;
|
|
341
|
+
});
|
|
342
|
+
const entries = [[root, `m:${await safeStat(root)}`]];
|
|
190
343
|
const repos = [];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const repoNames = await listDirs$1(ownerPath);
|
|
197
|
-
for (const repo of repoNames) repos.push({
|
|
198
|
-
forgeName,
|
|
199
|
-
forge,
|
|
200
|
-
owner,
|
|
201
|
-
repo,
|
|
202
|
-
localPath: join(ownerPath, repo),
|
|
203
|
-
slug: `${owner}/${repo}`
|
|
204
|
-
});
|
|
205
|
-
}
|
|
344
|
+
const hints = [];
|
|
345
|
+
for (const ctx of contexts) {
|
|
346
|
+
repos.push(...ctx.repos);
|
|
347
|
+
hints.push(...ctx.hints);
|
|
348
|
+
entries.push(...ctx.entries);
|
|
206
349
|
}
|
|
207
|
-
|
|
350
|
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
351
|
+
return {
|
|
352
|
+
repos,
|
|
353
|
+
hints,
|
|
354
|
+
fingerprint: createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex")
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
async function scanRepos(options) {
|
|
358
|
+
return (await scanLayout(options)).repos;
|
|
208
359
|
}
|
|
209
360
|
//#endregion
|
|
210
361
|
//#region src/repos/cache.ts
|
|
@@ -223,54 +374,18 @@ function cachePath(root) {
|
|
|
223
374
|
const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
|
|
224
375
|
return join(cacheDir(), `scan-${hash}.json`);
|
|
225
376
|
}
|
|
226
|
-
async function safeStat(path) {
|
|
227
|
-
try {
|
|
228
|
-
const s = await stat(path);
|
|
229
|
-
return Math.trunc(s.mtimeMs);
|
|
230
|
-
} catch {
|
|
231
|
-
return 0;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
async function safeListDirs(path) {
|
|
235
|
-
try {
|
|
236
|
-
return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
237
|
-
} catch {
|
|
238
|
-
return [];
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
377
|
/**
|
|
242
|
-
* Fingerprint of the layout
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* The repo names are what make the check reliable. An owner's mtime
|
|
248
|
-
* alone is not enough: a repo cloned beside an existing one moves only
|
|
249
|
-
* that mtime, and mtimes are compared at millisecond granularity, so
|
|
250
|
-
* two clones landing inside the same millisecond hash identically and
|
|
251
|
-
* a stale cache wins. Listing the names makes invalidation independent
|
|
252
|
-
* of clock and filesystem timestamp resolution.
|
|
253
|
-
*
|
|
254
|
-
* Stats are issued in parallel: one batch per forge for its forge.dir +
|
|
255
|
-
* owner list, all forges in parallel. Beats the sequential version by
|
|
256
|
-
* an order of magnitude at thousands of owners.
|
|
378
|
+
* Fingerprint of the layout, computed by the same walk that finds the repos —
|
|
379
|
+
* see {@link scanLayout}, which owns what goes into it. Since a directory is
|
|
380
|
+
* only known to be a repo once its entries have been read, the fingerprint
|
|
381
|
+
* cannot be cheaper than the scan; producing both from one walk is what keeps
|
|
382
|
+
* the cold path from paying for the tree twice.
|
|
257
383
|
*/
|
|
258
384
|
async function computeFingerprint(config, configDir) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const ownerEntries = await Promise.all(owners.map(async (owner) => {
|
|
264
|
-
const ownerPath = join(forgeRoot, owner);
|
|
265
|
-
const [mtime, repos] = await Promise.all([safeStat(ownerPath), safeListDirs(ownerPath)]);
|
|
266
|
-
return [ownerPath, `${mtime}:${JSON.stringify(repos.sort())}`];
|
|
267
|
-
}));
|
|
268
|
-
return [[forgeRoot, String(forgeMtime)], ...ownerEntries];
|
|
269
|
-
}));
|
|
270
|
-
const entries = [[root, String(await safeStat(root))]];
|
|
271
|
-
for (const group of perForge) entries.push(...group);
|
|
272
|
-
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
273
|
-
return createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex");
|
|
385
|
+
return (await scanLayout({
|
|
386
|
+
config,
|
|
387
|
+
configDir
|
|
388
|
+
})).fingerprint;
|
|
274
389
|
}
|
|
275
390
|
async function readCacheFile(file) {
|
|
276
391
|
try {
|
|
@@ -292,26 +407,35 @@ async function scanReposCached(options) {
|
|
|
292
407
|
if (cached) {
|
|
293
408
|
const age = Date.now() - cached.writtenAt;
|
|
294
409
|
if (trustTtl && age < ttl()) return cached.repos;
|
|
295
|
-
const
|
|
296
|
-
|
|
410
|
+
const scan = await scanLayout({
|
|
411
|
+
config,
|
|
412
|
+
configDir
|
|
413
|
+
});
|
|
414
|
+
if (cached.fingerprint === scan.fingerprint) {
|
|
297
415
|
await writeCacheFile(file, {
|
|
298
416
|
...cached,
|
|
299
417
|
writtenAt: Date.now()
|
|
300
418
|
});
|
|
301
419
|
return cached.repos;
|
|
302
420
|
}
|
|
421
|
+
await writeCacheFile(file, {
|
|
422
|
+
fingerprint: scan.fingerprint,
|
|
423
|
+
writtenAt: Date.now(),
|
|
424
|
+
repos: scan.repos
|
|
425
|
+
});
|
|
426
|
+
return scan.repos;
|
|
303
427
|
}
|
|
304
428
|
}
|
|
305
|
-
const
|
|
429
|
+
const scan = await scanLayout({
|
|
306
430
|
config,
|
|
307
431
|
configDir
|
|
308
432
|
});
|
|
309
433
|
await writeCacheFile(file, {
|
|
310
|
-
fingerprint:
|
|
434
|
+
fingerprint: scan.fingerprint,
|
|
311
435
|
writtenAt: Date.now(),
|
|
312
|
-
repos
|
|
436
|
+
repos: scan.repos
|
|
313
437
|
});
|
|
314
|
-
return repos;
|
|
438
|
+
return scan.repos;
|
|
315
439
|
}
|
|
316
440
|
/**
|
|
317
441
|
* Append a freshly-cloned repo to the cache without touching the
|
|
@@ -348,9 +472,15 @@ async function removeCachedRepo(options, localPath) {
|
|
|
348
472
|
}
|
|
349
473
|
//#endregion
|
|
350
474
|
//#region src/utils/exec.ts
|
|
351
|
-
function execInherit(command, args) {
|
|
475
|
+
function execInherit(command, args, options = {}) {
|
|
352
476
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
353
|
-
const child = spawn(command, args, {
|
|
477
|
+
const child = spawn(command, args, {
|
|
478
|
+
env: options.env ? {
|
|
479
|
+
...process.env,
|
|
480
|
+
...options.env
|
|
481
|
+
} : void 0,
|
|
482
|
+
stdio: "inherit"
|
|
483
|
+
});
|
|
354
484
|
child.on("error", rejectPromise);
|
|
355
485
|
child.on("close", (code) => {
|
|
356
486
|
resolvePromise({ code: code ?? 0 });
|
|
@@ -476,33 +606,13 @@ function isRepoMissing(stderr) {
|
|
|
476
606
|
return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
|
|
477
607
|
}
|
|
478
608
|
//#endregion
|
|
479
|
-
//#region src/utils/concurrency.ts
|
|
480
|
-
/**
|
|
481
|
-
* Map over `items` running at most `limit` calls of `fn` at once, preserving
|
|
482
|
-
* input order in the result. Keeps `import` from spawning one subprocess per
|
|
483
|
-
* repo all at once when checking remotes across a large tree.
|
|
484
|
-
*/
|
|
485
|
-
async function mapLimit(items, limit, fn) {
|
|
486
|
-
const results = Array.from({ length: items.length });
|
|
487
|
-
const max = Math.max(1, Math.min(limit, items.length));
|
|
488
|
-
let next = 0;
|
|
489
|
-
async function worker() {
|
|
490
|
-
while (next < items.length) {
|
|
491
|
-
const index = next++;
|
|
492
|
-
results[index] = await fn(items[index], index);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
496
|
-
return results;
|
|
497
|
-
}
|
|
498
|
-
//#endregion
|
|
499
609
|
//#region src/forges/github.ts
|
|
500
|
-
var GRAPHQL_CHUNK = 100;
|
|
501
|
-
var FALLBACK_CONCURRENCY = 8;
|
|
610
|
+
var GRAPHQL_CHUNK$1 = 100;
|
|
611
|
+
var FALLBACK_CONCURRENCY$1 = 8;
|
|
502
612
|
var GH_TIMEOUT_MS = 2e4;
|
|
503
613
|
/** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
|
|
504
614
|
* repo issues, so the returned full_name reveals the canonical owner/repo. */
|
|
505
|
-
async function checkOne(owner, repo) {
|
|
615
|
+
async function checkOne$1(owner, repo) {
|
|
506
616
|
const result = await execCapture("gh", [
|
|
507
617
|
"api",
|
|
508
618
|
`repos/${owner}/${repo}`,
|
|
@@ -539,7 +649,7 @@ async function checkOne(owner, repo) {
|
|
|
539
649
|
canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
|
|
540
650
|
};
|
|
541
651
|
}
|
|
542
|
-
function buildQuery(chunk) {
|
|
652
|
+
function buildQuery$1(chunk) {
|
|
543
653
|
return `query {\n${chunk.map((input, i) => ` r${i}: repository(owner: ${JSON.stringify(input.owner)}, name: ${JSON.stringify(input.repo)}) { nameWithOwner }`).join("\n")}\n}`;
|
|
544
654
|
}
|
|
545
655
|
var githubAdapter = {
|
|
@@ -558,7 +668,7 @@ var githubAdapter = {
|
|
|
558
668
|
state: "unknown",
|
|
559
669
|
reason: "gh not installed"
|
|
560
670
|
};
|
|
561
|
-
return checkOne(owner, repo);
|
|
671
|
+
return checkOne$1(owner, repo);
|
|
562
672
|
},
|
|
563
673
|
/**
|
|
564
674
|
* One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
|
|
@@ -573,13 +683,13 @@ var githubAdapter = {
|
|
|
573
683
|
reason: "gh not installed"
|
|
574
684
|
}));
|
|
575
685
|
const results = Array.from({ length: inputs.length }, () => null);
|
|
576
|
-
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
|
|
577
|
-
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK);
|
|
686
|
+
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK$1) {
|
|
687
|
+
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK$1);
|
|
578
688
|
const res = await execCapture("gh", [
|
|
579
689
|
"api",
|
|
580
690
|
"graphql",
|
|
581
691
|
"-f",
|
|
582
|
-
`query=${buildQuery(chunk)}`
|
|
692
|
+
`query=${buildQuery$1(chunk)}`
|
|
583
693
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
584
694
|
let data = null;
|
|
585
695
|
try {
|
|
@@ -601,8 +711,153 @@ var githubAdapter = {
|
|
|
601
711
|
}
|
|
602
712
|
}
|
|
603
713
|
}
|
|
714
|
+
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY$1, async (index) => {
|
|
715
|
+
results[index] = await checkOne$1(inputs[index].owner, inputs[index].repo);
|
|
716
|
+
});
|
|
717
|
+
return results;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/forges/gitlab.ts
|
|
722
|
+
var GRAPHQL_CHUNK = 100;
|
|
723
|
+
var FALLBACK_CONCURRENCY = 8;
|
|
724
|
+
var GLAB_TIMEOUT_MS = 2e4;
|
|
725
|
+
var MISSING_GLAB = "GitLab CLI (`glab`) is not installed. Install it from https://gitlab.com/gitlab-org/cli and run `glab auth login`.";
|
|
726
|
+
/**
|
|
727
|
+
* Pin every invocation to the forge's own host. `glab repo clone` has no
|
|
728
|
+
* `--hostname` flag, and leaning on the user's global `glab config set host`
|
|
729
|
+
* would make behaviour depend on state forgemap never set.
|
|
730
|
+
*/
|
|
731
|
+
function glabEnv(forge) {
|
|
732
|
+
return { GITLAB_HOST: forge.host };
|
|
733
|
+
}
|
|
734
|
+
/** Split GitLab's `full path` into namespace and project. */
|
|
735
|
+
function splitFullPath(fullPath) {
|
|
736
|
+
const segments = fullPath.split("/").filter(Boolean);
|
|
737
|
+
if (segments.length < 2) return null;
|
|
738
|
+
return {
|
|
739
|
+
owner: segments.slice(0, -1).join("/"),
|
|
740
|
+
repo: segments.at(-1)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Single-project REST check. `projects/<url-encoded path>` answers with
|
|
745
|
+
* `path_with_namespace`, GitLab's counterpart to GitHub's `full_name`, so a
|
|
746
|
+
* differing answer is a move and a 404 is a deletion.
|
|
747
|
+
*/
|
|
748
|
+
async function checkOne(forge, owner, repo) {
|
|
749
|
+
const result = await execCapture("glab", ["api", `projects/${encodeURIComponent(`${owner}/${repo}`)}`], {
|
|
750
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
751
|
+
env: glabEnv(forge)
|
|
752
|
+
});
|
|
753
|
+
if (result.timedOut) return {
|
|
754
|
+
state: "unknown",
|
|
755
|
+
reason: "glab api timed out"
|
|
756
|
+
};
|
|
757
|
+
if (result.code !== 0) {
|
|
758
|
+
if (/404|not found/i.test(result.stderr)) return { state: "gone" };
|
|
759
|
+
return {
|
|
760
|
+
state: "unknown",
|
|
761
|
+
reason: result.stderr.trim() || `glab api exited with code ${result.code}`
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
let fullPath;
|
|
765
|
+
try {
|
|
766
|
+
fullPath = JSON.parse(result.stdout).path_with_namespace;
|
|
767
|
+
} catch {
|
|
768
|
+
fullPath = void 0;
|
|
769
|
+
}
|
|
770
|
+
const canonical = fullPath ? splitFullPath(fullPath) : null;
|
|
771
|
+
if (!canonical) return {
|
|
772
|
+
state: "unknown",
|
|
773
|
+
reason: "could not parse glab api path_with_namespace"
|
|
774
|
+
};
|
|
775
|
+
if (canonical.owner === owner && canonical.repo === repo) return {
|
|
776
|
+
state: "exists",
|
|
777
|
+
canonical
|
|
778
|
+
};
|
|
779
|
+
return {
|
|
780
|
+
state: "moved",
|
|
781
|
+
canonical,
|
|
782
|
+
canonicalUrl: `https://${forge.host}/${canonical.owner}/${canonical.repo}.git`
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function buildQuery(chunk) {
|
|
786
|
+
return `query {\n${chunk.map((input, i) => ` r${i}: project(fullPath: ${JSON.stringify(`${input.owner}/${input.repo}`)}) { fullPath }`).join("\n")}\n}`;
|
|
787
|
+
}
|
|
788
|
+
var gitlabAdapter = {
|
|
789
|
+
async clone({ forge, owner, repo, dest }) {
|
|
790
|
+
if (!await hasCommand("glab")) throw new Error(MISSING_GLAB);
|
|
791
|
+
const { code } = await execInherit("glab", [
|
|
792
|
+
"repo",
|
|
793
|
+
"clone",
|
|
794
|
+
`${owner}/${repo}`,
|
|
795
|
+
dest
|
|
796
|
+
], { env: glabEnv(forge) });
|
|
797
|
+
if (code !== 0) throw new Error(`glab repo clone exited with code ${code}`);
|
|
798
|
+
},
|
|
799
|
+
async checkRemote({ forge, owner, repo }) {
|
|
800
|
+
if (!await hasCommand("glab")) return {
|
|
801
|
+
state: "unknown",
|
|
802
|
+
reason: "glab not installed"
|
|
803
|
+
};
|
|
804
|
+
return checkOne(forge, owner, repo);
|
|
805
|
+
},
|
|
806
|
+
/**
|
|
807
|
+
* Mirrors the GitHub adapter: one aliased GraphQL request resolves up to
|
|
808
|
+
* GRAPHQL_CHUNK projects, and each miss — `null` could mean gone *or*
|
|
809
|
+
* renamed — costs a single REST call to tell the two apart.
|
|
810
|
+
*
|
|
811
|
+
* A batch may span several configured GitLab forges, so the inputs are
|
|
812
|
+
* grouped by host before they are chunked: one server must never be asked
|
|
813
|
+
* about another's projects. Where the same full path exists on both — a
|
|
814
|
+
* public mirror of an internal project — the wrong server would otherwise
|
|
815
|
+
* answer `exists` for a project this one does not have.
|
|
816
|
+
*/
|
|
817
|
+
async checkRemotes(inputs) {
|
|
818
|
+
if (inputs.length === 0) return [];
|
|
819
|
+
if (!await hasCommand("glab")) return inputs.map(() => ({
|
|
820
|
+
state: "unknown",
|
|
821
|
+
reason: "glab not installed"
|
|
822
|
+
}));
|
|
823
|
+
const results = Array.from({ length: inputs.length }, () => null);
|
|
824
|
+
const byHost = /* @__PURE__ */ new Map();
|
|
825
|
+
inputs.forEach((input, index) => {
|
|
826
|
+
const indices = byHost.get(input.forge.host);
|
|
827
|
+
if (indices) indices.push(index);
|
|
828
|
+
else byHost.set(input.forge.host, [index]);
|
|
829
|
+
});
|
|
830
|
+
for (const indices of byHost.values()) for (let start = 0; start < indices.length; start += GRAPHQL_CHUNK) {
|
|
831
|
+
const slice = indices.slice(start, start + GRAPHQL_CHUNK);
|
|
832
|
+
const chunk = slice.map((index) => inputs[index]);
|
|
833
|
+
const res = await execCapture("glab", [
|
|
834
|
+
"api",
|
|
835
|
+
"graphql",
|
|
836
|
+
"-f",
|
|
837
|
+
`query=${buildQuery(chunk)}`
|
|
838
|
+
], {
|
|
839
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
840
|
+
env: glabEnv(chunk[0].forge)
|
|
841
|
+
});
|
|
842
|
+
let data = null;
|
|
843
|
+
try {
|
|
844
|
+
const parsed = JSON.parse(res.stdout);
|
|
845
|
+
data = parsed.data ?? parsed;
|
|
846
|
+
} catch {
|
|
847
|
+
data = null;
|
|
848
|
+
}
|
|
849
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
850
|
+
const node = data?.[`r${i}`];
|
|
851
|
+
const canonical = node?.fullPath ? splitFullPath(node.fullPath) : null;
|
|
852
|
+
if (canonical) results[slice[i]] = {
|
|
853
|
+
state: "exists",
|
|
854
|
+
canonical
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
}
|
|
604
858
|
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
|
|
605
|
-
|
|
859
|
+
const input = inputs[index];
|
|
860
|
+
results[index] = await checkOne(input.forge, input.owner, input.repo);
|
|
606
861
|
});
|
|
607
862
|
return results;
|
|
608
863
|
}
|
|
@@ -612,8 +867,8 @@ var githubAdapter = {
|
|
|
612
867
|
function getForgeAdapter(type) {
|
|
613
868
|
switch (type) {
|
|
614
869
|
case "github": return githubAdapter;
|
|
870
|
+
case "gitlab": return gitlabAdapter;
|
|
615
871
|
case "git": return gitAdapter;
|
|
616
|
-
case "gitlab":
|
|
617
872
|
case "gitea":
|
|
618
873
|
case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
|
|
619
874
|
default: throw new Error(`Unknown forge type: ${String(type)}`);
|
|
@@ -621,21 +876,29 @@ function getForgeAdapter(type) {
|
|
|
621
876
|
}
|
|
622
877
|
//#endregion
|
|
623
878
|
//#region src/slug/parse.ts
|
|
624
|
-
var
|
|
625
|
-
var
|
|
626
|
-
var
|
|
879
|
+
var SEGMENT = String.raw`[\w.-]+`;
|
|
880
|
+
var SHORT_RE = new RegExp(`^(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
881
|
+
var NAMED_RE = new RegExp(`^(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
882
|
+
var SSH_RE = new RegExp(`^git@(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT}?)(?:\\.git)?$`);
|
|
627
883
|
function stripGitSuffix(repo) {
|
|
628
884
|
return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
|
|
629
885
|
}
|
|
630
886
|
/**
|
|
887
|
+
* Split `<namespace…>/<repo>` into its two halves: the **last** segment is the
|
|
888
|
+
* repo, everything before it the namespace. Collapses to today's result
|
|
889
|
+
* whenever there are exactly two segments.
|
|
890
|
+
*/
|
|
891
|
+
function splitPath(segments) {
|
|
892
|
+
return {
|
|
893
|
+
owner: segments.slice(0, -1).join("/"),
|
|
894
|
+
repo: stripGitSuffix(segments.at(-1))
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
631
898
|
* Whether the input is *shaped* like a strict slug. Every form
|
|
632
|
-
* {@link parseSlug} accepts — `
|
|
633
|
-
* URL — contains a `/`, so a bare term like `gild` can never be one and is
|
|
899
|
+
* {@link parseSlug} accepts — `namespace/repo`, `forge:namespace/repo`, SSH
|
|
900
|
+
* and URL — contains a `/`, so a bare term like `gild` can never be one and is
|
|
634
901
|
* free to be treated as a fuzzy query instead.
|
|
635
|
-
*
|
|
636
|
-
* Shaped-like is deliberately not the same as valid: `foo/bar/baz` is shaped
|
|
637
|
-
* like a slug, so it stays a hard parse error rather than silently degrading
|
|
638
|
-
* into a fuzzy search for something the user clearly meant as a slug.
|
|
639
902
|
*/
|
|
640
903
|
function looksLikeSlug(input) {
|
|
641
904
|
return input.trim().includes("/");
|
|
@@ -646,8 +909,7 @@ function parseSlug(input) {
|
|
|
646
909
|
const ssh = SSH_RE.exec(trimmed);
|
|
647
910
|
if (ssh) return {
|
|
648
911
|
host: ssh[1],
|
|
649
|
-
|
|
650
|
-
repo: stripGitSuffix(ssh[3])
|
|
912
|
+
...splitPath([...ssh[2].split("/"), ssh[3]])
|
|
651
913
|
};
|
|
652
914
|
if (/^https?:\/\//.test(trimmed)) {
|
|
653
915
|
let url;
|
|
@@ -656,25 +918,22 @@ function parseSlug(input) {
|
|
|
656
918
|
} catch {
|
|
657
919
|
throw new Error(`Invalid URL: ${trimmed}`);
|
|
658
920
|
}
|
|
659
|
-
|
|
660
|
-
|
|
921
|
+
let segments = url.pathname.split("/").filter(Boolean);
|
|
922
|
+
const separator = segments.indexOf("-");
|
|
923
|
+
if (separator !== -1) segments = segments.slice(0, separator);
|
|
924
|
+
if (segments.length < 2) throw new Error(`URL must contain a namespace and repo: ${trimmed}`);
|
|
661
925
|
return {
|
|
662
926
|
host: url.host,
|
|
663
|
-
|
|
664
|
-
repo: stripGitSuffix(segments[1])
|
|
927
|
+
...splitPath(segments)
|
|
665
928
|
};
|
|
666
929
|
}
|
|
667
930
|
const named = NAMED_RE.exec(trimmed);
|
|
668
931
|
if (named) return {
|
|
669
932
|
forgeName: named[1],
|
|
670
|
-
|
|
671
|
-
repo: stripGitSuffix(named[3])
|
|
933
|
+
...splitPath([...named[2].split("/"), named[3]])
|
|
672
934
|
};
|
|
673
935
|
const short = SHORT_RE.exec(trimmed);
|
|
674
|
-
if (short) return
|
|
675
|
-
owner: short[1],
|
|
676
|
-
repo: stripGitSuffix(short[2])
|
|
677
|
-
};
|
|
936
|
+
if (short) return splitPath([...short[1].split("/"), short[2]]);
|
|
678
937
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
679
938
|
}
|
|
680
939
|
//#endregion
|
|
@@ -958,16 +1217,31 @@ function remoteBlocker(state) {
|
|
|
958
1217
|
if (state === "exists" || state === "moved") return null;
|
|
959
1218
|
return state === "gone" ? "remote no longer exists" : "remote unreachable";
|
|
960
1219
|
}
|
|
961
|
-
/**
|
|
1220
|
+
/**
|
|
1221
|
+
* Check each candidate's remote, grouped by forge **type and host** so an
|
|
1222
|
+
* adapter can batch.
|
|
1223
|
+
*
|
|
1224
|
+
* The host is half the key, not a detail: two configured forges of one type —
|
|
1225
|
+
* gitlab.com beside a self-hosted instance — are two different servers, and a
|
|
1226
|
+
* batch spanning both would ask one of them about the other's projects. Where
|
|
1227
|
+
* the same path exists on each (a public mirror of an internal project), that
|
|
1228
|
+
* answers `exists` for the wrong server, and `remoteBlocker` reads `exists` as
|
|
1229
|
+
* "safe to delete".
|
|
1230
|
+
*/
|
|
962
1231
|
async function classifyRemotes(candidates) {
|
|
963
|
-
const
|
|
1232
|
+
const groups = /* @__PURE__ */ new Map();
|
|
964
1233
|
for (const c of candidates) {
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
1234
|
+
const { type, host } = c.repo.forge;
|
|
1235
|
+
const key = `${type}\0${host}`;
|
|
1236
|
+
const group = groups.get(key);
|
|
1237
|
+
if (group) group.items.push(c);
|
|
1238
|
+
else groups.set(key, {
|
|
1239
|
+
type,
|
|
1240
|
+
items: [c]
|
|
1241
|
+
});
|
|
968
1242
|
}
|
|
969
1243
|
const results = /* @__PURE__ */ new Map();
|
|
970
|
-
await Promise.all(Array.from(
|
|
1244
|
+
await Promise.all(Array.from(groups.values(), async ({ type, items }) => {
|
|
971
1245
|
const inputs = items.map((c) => ({
|
|
972
1246
|
forge: c.repo.forge,
|
|
973
1247
|
owner: c.owner,
|
|
@@ -1013,35 +1287,43 @@ async function classifyRemotes(candidates) {
|
|
|
1013
1287
|
}));
|
|
1014
1288
|
return results;
|
|
1015
1289
|
}
|
|
1016
|
-
|
|
1290
|
+
/**
|
|
1291
|
+
* Collect the empty namespace directories at and below `path`, deepest first,
|
|
1292
|
+
* and report whether `path` itself turned out to be one.
|
|
1293
|
+
*
|
|
1294
|
+
* A namespace is empty when it holds nothing, or holds only namespaces that
|
|
1295
|
+
* are themselves empty — which is what makes this follow a nested layout
|
|
1296
|
+
* rather than the single owner level it used to assume. The walk never enters
|
|
1297
|
+
* a repo: a `.git` entry means the directory is a checkout, and a checkout is
|
|
1298
|
+
* never a leftover however empty its subdirectories are.
|
|
1299
|
+
*/
|
|
1300
|
+
async function collectEmptyDirs(path, depth, empties) {
|
|
1301
|
+
if (depth > 10) return false;
|
|
1302
|
+
let entries;
|
|
1017
1303
|
try {
|
|
1018
|
-
|
|
1304
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
1019
1305
|
} catch {
|
|
1020
|
-
return
|
|
1306
|
+
return false;
|
|
1021
1307
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1308
|
+
if (entries.some((e) => e.name === ".git")) return false;
|
|
1309
|
+
if (entries.length === 0) {
|
|
1310
|
+
empties.push(path);
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
if (entries.some((e) => !e.isDirectory())) return false;
|
|
1314
|
+
let allEmpty = true;
|
|
1315
|
+
for (const entry of entries) if (!await collectEmptyDirs(join(path, entry.name), depth + 1, empties)) allEmpty = false;
|
|
1316
|
+
if (allEmpty) empties.push(path);
|
|
1317
|
+
return allEmpty;
|
|
1318
|
+
}
|
|
1319
|
+
/** Empty namespace directories (server dir included) under the configured
|
|
1320
|
+
* forge dirs, deepest first. Detection only — no removal. */
|
|
1025
1321
|
async function findEmptyDirs(root, config) {
|
|
1026
1322
|
const empties = [];
|
|
1027
|
-
for (const forge of Object.values(config.forges))
|
|
1028
|
-
const serverPath = join(root, forge.dir);
|
|
1029
|
-
const owners = await safeReaddir(serverPath);
|
|
1030
|
-
if (owners === null) continue;
|
|
1031
|
-
let emptyCount = 0;
|
|
1032
|
-
for (const owner of owners) {
|
|
1033
|
-
const ownerPath = join(serverPath, owner);
|
|
1034
|
-
const inner = await safeReaddir(ownerPath);
|
|
1035
|
-
if (inner !== null && inner.length === 0) {
|
|
1036
|
-
empties.push(ownerPath);
|
|
1037
|
-
emptyCount++;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
if (owners.length === 0 || emptyCount === owners.length) empties.push(serverPath);
|
|
1041
|
-
}
|
|
1323
|
+
for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
|
|
1042
1324
|
return empties;
|
|
1043
1325
|
}
|
|
1044
|
-
/** Remove the dirs from findEmptyDirs (
|
|
1326
|
+
/** Remove the dirs from findEmptyDirs (children before their parents). */
|
|
1045
1327
|
async function pruneEmptyDirs(root, config) {
|
|
1046
1328
|
const empties = await findEmptyDirs(root, config);
|
|
1047
1329
|
let removed = 0;
|
|
@@ -1233,7 +1515,10 @@ function resolveSlug(parsed, options) {
|
|
|
1233
1515
|
forgeName = config.defaultForge;
|
|
1234
1516
|
forge = candidate;
|
|
1235
1517
|
}
|
|
1236
|
-
const
|
|
1518
|
+
const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
|
|
1519
|
+
if (depthError) throw new Error(depthError);
|
|
1520
|
+
const root = resolveRoot(config.root, configDir);
|
|
1521
|
+
const localPath = join(root, forge.dir, parsed.owner, parsed.repo);
|
|
1237
1522
|
return {
|
|
1238
1523
|
forgeName,
|
|
1239
1524
|
forge,
|
|
@@ -1417,7 +1702,8 @@ ${forgeEntries}
|
|
|
1417
1702
|
/** Write a `forgemap.config.ts` into `outDir`. Returns null when the file
|
|
1418
1703
|
* already exists and `force` is not set (the caller decides how to report). */
|
|
1419
1704
|
async function writeConfigFile(config, options) {
|
|
1420
|
-
const
|
|
1705
|
+
const outDir = resolve(process.cwd(), options.outDir);
|
|
1706
|
+
const target = join(outDir, "forgemap.config.ts");
|
|
1421
1707
|
await mkdir(dirname(target), { recursive: true });
|
|
1422
1708
|
try {
|
|
1423
1709
|
await writeFile(target, renderConfigModule(config), {
|
|
@@ -1441,6 +1727,57 @@ var DEFAULT_CONFIG = {
|
|
|
1441
1727
|
dir: "comGithub"
|
|
1442
1728
|
} }
|
|
1443
1729
|
};
|
|
1730
|
+
var configInitCommand = defineCommand({
|
|
1731
|
+
meta: {
|
|
1732
|
+
name: "init",
|
|
1733
|
+
description: "Create a forgemap.config.ts in the current (or given) directory"
|
|
1734
|
+
},
|
|
1735
|
+
args: {
|
|
1736
|
+
out: {
|
|
1737
|
+
type: "string",
|
|
1738
|
+
description: "Directory to write forgemap.config.ts into",
|
|
1739
|
+
default: "."
|
|
1740
|
+
},
|
|
1741
|
+
force: {
|
|
1742
|
+
type: "boolean",
|
|
1743
|
+
description: "Overwrite if forgemap.config.ts already exists",
|
|
1744
|
+
default: false
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
async run({ args }) {
|
|
1748
|
+
const result = await writeConfigFile(DEFAULT_CONFIG, {
|
|
1749
|
+
outDir: args.out,
|
|
1750
|
+
force: args.force
|
|
1751
|
+
});
|
|
1752
|
+
if (!result) {
|
|
1753
|
+
const target = join(resolve(process.cwd(), args.out), "forgemap.config.ts");
|
|
1754
|
+
consola.error(`${target} already exists. Use --force to overwrite.`);
|
|
1755
|
+
process.exitCode = 1;
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
consola.success(`Wrote ${result.path}`);
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
//#endregion
|
|
1762
|
+
//#region src/commands/config/show.ts
|
|
1763
|
+
var configShowCommand = defineCommand({
|
|
1764
|
+
meta: {
|
|
1765
|
+
name: "show",
|
|
1766
|
+
description: "Print the resolved forgemap config and its source path"
|
|
1767
|
+
},
|
|
1768
|
+
args: { config: {
|
|
1769
|
+
type: "string",
|
|
1770
|
+
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
1771
|
+
} },
|
|
1772
|
+
async run({ args }) {
|
|
1773
|
+
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
1774
|
+
process.stdout.write(JSON.stringify({
|
|
1775
|
+
configFile: loaded.configFile ?? null,
|
|
1776
|
+
cwd: loaded.cwd,
|
|
1777
|
+
config: loaded.config
|
|
1778
|
+
}, null, 2) + "\n");
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1444
1781
|
//#endregion
|
|
1445
1782
|
//#region src/commands/config/index.ts
|
|
1446
1783
|
var configCommand = defineCommand({
|
|
@@ -1449,55 +1786,8 @@ var configCommand = defineCommand({
|
|
|
1449
1786
|
description: "Manage the forgemap config file"
|
|
1450
1787
|
},
|
|
1451
1788
|
subCommands: {
|
|
1452
|
-
init:
|
|
1453
|
-
|
|
1454
|
-
name: "init",
|
|
1455
|
-
description: "Create a forgemap.config.ts in the current (or given) directory"
|
|
1456
|
-
},
|
|
1457
|
-
args: {
|
|
1458
|
-
out: {
|
|
1459
|
-
type: "string",
|
|
1460
|
-
description: "Directory to write forgemap.config.ts into",
|
|
1461
|
-
default: "."
|
|
1462
|
-
},
|
|
1463
|
-
force: {
|
|
1464
|
-
type: "boolean",
|
|
1465
|
-
description: "Overwrite if forgemap.config.ts already exists",
|
|
1466
|
-
default: false
|
|
1467
|
-
}
|
|
1468
|
-
},
|
|
1469
|
-
async run({ args }) {
|
|
1470
|
-
const result = await writeConfigFile(DEFAULT_CONFIG, {
|
|
1471
|
-
outDir: args.out,
|
|
1472
|
-
force: args.force
|
|
1473
|
-
});
|
|
1474
|
-
if (!result) {
|
|
1475
|
-
const target = join(resolve(process.cwd(), args.out), "forgemap.config.ts");
|
|
1476
|
-
consola.error(`${target} already exists. Use --force to overwrite.`);
|
|
1477
|
-
process.exitCode = 1;
|
|
1478
|
-
return;
|
|
1479
|
-
}
|
|
1480
|
-
consola.success(`Wrote ${result.path}`);
|
|
1481
|
-
}
|
|
1482
|
-
}),
|
|
1483
|
-
show: defineCommand({
|
|
1484
|
-
meta: {
|
|
1485
|
-
name: "show",
|
|
1486
|
-
description: "Print the resolved forgemap config and its source path"
|
|
1487
|
-
},
|
|
1488
|
-
args: { config: {
|
|
1489
|
-
type: "string",
|
|
1490
|
-
description: "Path to forgemap.config.ts (overrides walk-up discovery)"
|
|
1491
|
-
} },
|
|
1492
|
-
async run({ args }) {
|
|
1493
|
-
const loaded = await loadForgeMapConfig({ configFile: args.config });
|
|
1494
|
-
process.stdout.write(JSON.stringify({
|
|
1495
|
-
configFile: loaded.configFile ?? null,
|
|
1496
|
-
cwd: loaded.cwd,
|
|
1497
|
-
config: loaded.config
|
|
1498
|
-
}, null, 2) + "\n");
|
|
1499
|
-
}
|
|
1500
|
-
})
|
|
1789
|
+
init: configInitCommand,
|
|
1790
|
+
show: configShowCommand
|
|
1501
1791
|
}
|
|
1502
1792
|
});
|
|
1503
1793
|
//#endregion
|
|
@@ -2215,14 +2505,16 @@ var forgeRemoveCommand = defineCommand({
|
|
|
2215
2505
|
if (!(key in forges)) return fail(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
|
|
2216
2506
|
const remaining = Object.keys(forges).filter((k) => k !== key);
|
|
2217
2507
|
let newDefault;
|
|
2218
|
-
if (loaded.config.defaultForge === key && remaining.length > 0)
|
|
2219
|
-
if (
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2508
|
+
if (loaded.config.defaultForge === key && remaining.length > 0) {
|
|
2509
|
+
if (typeof args.default === "string") {
|
|
2510
|
+
if (!remaining.includes(args.default)) return fail(`Cannot set default to "${args.default}" — not a remaining forge (${remaining.join(", ")}).`);
|
|
2511
|
+
newDefault = args.default;
|
|
2512
|
+
} else if (tty) {
|
|
2513
|
+
const answer = await promptSelect(`"${key}" is the default forge. Pick a new default:`, [...remaining, LEAVE_UNSET]);
|
|
2514
|
+
if (answer === null) return abort();
|
|
2515
|
+
if (answer !== LEAVE_UNSET) newDefault = answer;
|
|
2516
|
+
} else consola.warn(`Removing the default forge "${key}"; defaultForge now points at a missing forge. Pass --default to reassign it.`);
|
|
2517
|
+
}
|
|
2226
2518
|
consola.info(`Remove forge "${key}" from ${file}${newDefault ? ` (new default: "${newDefault}")` : ""}`);
|
|
2227
2519
|
if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort();
|
|
2228
2520
|
if (await applyChange(file, (c) => {
|
|
@@ -2254,34 +2546,51 @@ var forgeCommand = defineCommand({
|
|
|
2254
2546
|
});
|
|
2255
2547
|
//#endregion
|
|
2256
2548
|
//#region src/repos/import.ts
|
|
2257
|
-
async function
|
|
2549
|
+
async function readEntries(path) {
|
|
2258
2550
|
try {
|
|
2259
|
-
|
|
2551
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
2552
|
+
return {
|
|
2553
|
+
dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
|
|
2554
|
+
isRepo: entries.some((e) => e.name === GIT_MARKER)
|
|
2555
|
+
};
|
|
2260
2556
|
} catch (error) {
|
|
2261
|
-
if (error.code === "ENOENT") return
|
|
2557
|
+
if (error.code === "ENOENT") return null;
|
|
2262
2558
|
throw error;
|
|
2263
2559
|
}
|
|
2264
2560
|
}
|
|
2265
2561
|
/**
|
|
2266
|
-
* Structure-driven
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2562
|
+
* Structure-driven walk of `<path>/<serverDir>/<namespace…>/<repo>`. Unlike
|
|
2563
|
+
* `scanRepos` this is config-free: every top-level directory is a candidate
|
|
2564
|
+
* server dir, and the names are discovered rather than configured.
|
|
2565
|
+
*
|
|
2566
|
+
* A `.git` entry ends a branch, so a nested namespace is adopted as readily as
|
|
2567
|
+
* a flat one and a repo's own subdirectories are never mistaken for more of
|
|
2568
|
+
* the layout. A branch that dead-ends without one is still surfaced — that is
|
|
2569
|
+
* the candidate `analyzeLocal` reports as `not-a-git-repo`, which is the whole
|
|
2570
|
+
* reason `import` looks at directories a scan would simply skip.
|
|
2269
2571
|
*/
|
|
2270
|
-
async function
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
}
|
|
2572
|
+
async function discoverBelow(serverDir, dirPath, segments, found) {
|
|
2573
|
+
if (segments.length > 10) return;
|
|
2574
|
+
const entries = await readEntries(dirPath);
|
|
2575
|
+
const isLeaf = entries === null || entries.dirs.length === 0;
|
|
2576
|
+
if (segments.length >= 2 && (entries?.isRepo || isLeaf)) {
|
|
2577
|
+
found.push({
|
|
2578
|
+
serverDir,
|
|
2579
|
+
owner: segments.slice(0, -1).join("/"),
|
|
2580
|
+
repo: segments.at(-1),
|
|
2581
|
+
localPath: dirPath
|
|
2582
|
+
});
|
|
2583
|
+
return;
|
|
2283
2584
|
}
|
|
2284
|
-
return
|
|
2585
|
+
if (entries === null || entries.isRepo) return;
|
|
2586
|
+
for (const name of entries.dirs) await discoverBelow(serverDir, join(dirPath, name), [...segments, name], found);
|
|
2587
|
+
}
|
|
2588
|
+
async function discoverForgemapLayout(path) {
|
|
2589
|
+
const root = await readEntries(path);
|
|
2590
|
+
if (!root) return [];
|
|
2591
|
+
const found = [];
|
|
2592
|
+
for (const serverDir of root.dirs) await discoverBelow(serverDir, join(path, serverDir), [], found);
|
|
2593
|
+
return found;
|
|
2285
2594
|
}
|
|
2286
2595
|
function forgeTypeForHost(host) {
|
|
2287
2596
|
return host === "github.com" ? "github" : "git";
|
|
@@ -2421,13 +2730,11 @@ function pushRemoteFinding(report, parsed, result, path) {
|
|
|
2421
2730
|
message: `remote ${parsed.owner}/${parsed.repo} no longer exists`
|
|
2422
2731
|
});
|
|
2423
2732
|
break;
|
|
2424
|
-
case "unknown":
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
});
|
|
2430
|
-
break;
|
|
2733
|
+
case "unknown": report.findings.push({
|
|
2734
|
+
kind: "remote-check-unknown",
|
|
2735
|
+
severity: "warn",
|
|
2736
|
+
message: `remote check inconclusive: ${result.reason}`
|
|
2737
|
+
});
|
|
2431
2738
|
}
|
|
2432
2739
|
}
|
|
2433
2740
|
/** Run the network check for one forge's repos, preferring the batched
|
|
@@ -2943,7 +3250,7 @@ var infoCommand = defineCommand({
|
|
|
2943
3250
|
async run({ args }) {
|
|
2944
3251
|
const binary = resolveBinary(process.argv[1]);
|
|
2945
3252
|
const info = {
|
|
2946
|
-
version: "0.
|
|
3253
|
+
version: "0.8.0-dev.133-c1be991",
|
|
2947
3254
|
build: detectBuild(binary.resolved),
|
|
2948
3255
|
binary,
|
|
2949
3256
|
node: process.version,
|
|
@@ -3069,6 +3376,7 @@ function renderTree$1(repos) {
|
|
|
3069
3376
|
var listCommand = defineCommand({
|
|
3070
3377
|
meta: {
|
|
3071
3378
|
name: "list",
|
|
3379
|
+
alias: "ls",
|
|
3072
3380
|
description: "List cloned repos; with a query, fuzzy-match by owner/repo and print matches"
|
|
3073
3381
|
},
|
|
3074
3382
|
args: {
|
|
@@ -3754,6 +4062,7 @@ async function runChecks(config, configDir) {
|
|
|
3754
4062
|
const types = new Set(Object.values(config.forges).map((f) => f.type));
|
|
3755
4063
|
const needsGit = types.has("git") || types.size > 0;
|
|
3756
4064
|
const needsGh = types.has("github");
|
|
4065
|
+
const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
|
|
3757
4066
|
if (needsGit) checks.push(await hasCommand("git") ? {
|
|
3758
4067
|
name: "git CLI",
|
|
3759
4068
|
severity: "ok",
|
|
@@ -3763,29 +4072,94 @@ async function runChecks(config, configDir) {
|
|
|
3763
4072
|
severity: "fail",
|
|
3764
4073
|
message: "install from https://git-scm.com/"
|
|
3765
4074
|
});
|
|
3766
|
-
if (needsGh)
|
|
3767
|
-
|
|
4075
|
+
if (needsGh) {
|
|
4076
|
+
if (await hasCommand("gh")) {
|
|
4077
|
+
checks.push({
|
|
4078
|
+
name: "gh CLI",
|
|
4079
|
+
severity: "ok",
|
|
4080
|
+
message: "on PATH"
|
|
4081
|
+
});
|
|
4082
|
+
const auth = await execCapture("gh", ["auth", "status"]);
|
|
4083
|
+
checks.push(auth.code === 0 ? {
|
|
4084
|
+
name: "gh auth",
|
|
4085
|
+
severity: "ok",
|
|
4086
|
+
message: "authenticated"
|
|
4087
|
+
} : {
|
|
4088
|
+
name: "gh auth",
|
|
4089
|
+
severity: "warn",
|
|
4090
|
+
message: "not logged in — run `gh auth login`"
|
|
4091
|
+
});
|
|
4092
|
+
} else checks.push({
|
|
3768
4093
|
name: "gh CLI",
|
|
3769
|
-
severity: "
|
|
3770
|
-
message: "
|
|
4094
|
+
severity: "fail",
|
|
4095
|
+
message: "install from https://cli.github.com/"
|
|
3771
4096
|
});
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4097
|
+
}
|
|
4098
|
+
if (gitlabForges.length > 0) {
|
|
4099
|
+
if (await hasCommand("glab")) {
|
|
4100
|
+
checks.push({
|
|
4101
|
+
name: "glab CLI",
|
|
4102
|
+
severity: "ok",
|
|
4103
|
+
message: "on PATH"
|
|
4104
|
+
});
|
|
4105
|
+
for (const [name, forge] of gitlabForges) {
|
|
4106
|
+
const auth = await execCapture("glab", [
|
|
4107
|
+
"auth",
|
|
4108
|
+
"status",
|
|
4109
|
+
"--hostname",
|
|
4110
|
+
forge.host
|
|
4111
|
+
]);
|
|
4112
|
+
checks.push(auth.code === 0 ? {
|
|
4113
|
+
name: `glab auth (${name})`,
|
|
4114
|
+
severity: "ok",
|
|
4115
|
+
message: `authenticated at ${forge.host}`
|
|
4116
|
+
} : {
|
|
4117
|
+
name: `glab auth (${name})`,
|
|
4118
|
+
severity: "warn",
|
|
4119
|
+
message: `not logged in — run \`glab auth login --hostname ${forge.host}\``
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
} else checks.push({
|
|
4123
|
+
name: "glab CLI",
|
|
4124
|
+
severity: "fail",
|
|
4125
|
+
message: "install from https://gitlab.com/gitlab-org/cli"
|
|
3781
4126
|
});
|
|
3782
|
-
}
|
|
3783
|
-
|
|
3784
|
-
severity: "fail",
|
|
3785
|
-
message: "install from https://cli.github.com/"
|
|
3786
|
-
});
|
|
4127
|
+
}
|
|
4128
|
+
checks.push(await layoutCheck(config, configDir));
|
|
3787
4129
|
return checks;
|
|
3788
4130
|
}
|
|
4131
|
+
var HINT_LABEL = {
|
|
4132
|
+
"no-repo": "holds no git repo",
|
|
4133
|
+
"missing-namespace": "is a repo with no namespace above it",
|
|
4134
|
+
"too-deep": `is deeper than 10 levels`
|
|
4135
|
+
};
|
|
4136
|
+
var HINTS_SHOWN = 5;
|
|
4137
|
+
/**
|
|
4138
|
+
* A repo is a directory holding a `.git` entry, so a branch that never reaches
|
|
4139
|
+
* one simply drops out of `list`, `status` and `pick`. That is easy to read as
|
|
4140
|
+
* "where did my repo go", which is exactly the question this command exists to
|
|
4141
|
+
* answer — so name the branches rather than leaving them silent. A hint, never
|
|
4142
|
+
* a failure: an odd layout is not a broken one.
|
|
4143
|
+
*/
|
|
4144
|
+
async function layoutCheck(config, configDir) {
|
|
4145
|
+
const { repos, hints } = await scanLayout({
|
|
4146
|
+
config,
|
|
4147
|
+
configDir
|
|
4148
|
+
});
|
|
4149
|
+
const count = `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
|
|
4150
|
+
if (hints.length === 0) return {
|
|
4151
|
+
name: "layout",
|
|
4152
|
+
severity: "ok",
|
|
4153
|
+
message: count
|
|
4154
|
+
};
|
|
4155
|
+
const shown = hints.slice(0, HINTS_SHOWN).map((hint) => `${hint.path} ${HINT_LABEL[hint.reason]}`);
|
|
4156
|
+
const rest = hints.length > HINTS_SHOWN ? ` (+${hints.length - HINTS_SHOWN} more)` : "";
|
|
4157
|
+
return {
|
|
4158
|
+
name: "layout",
|
|
4159
|
+
severity: "warn",
|
|
4160
|
+
message: `${count}; ${shown.join(", ")}${rest}`
|
|
4161
|
+
};
|
|
4162
|
+
}
|
|
3789
4163
|
function severitySymbol(severity) {
|
|
3790
4164
|
if (severity === "ok") return colors.green("✓");
|
|
3791
4165
|
if (severity === "warn") return colors.yellow("!");
|
|
@@ -3857,6 +4231,21 @@ var SHELL_POSITIONAL = /* @__PURE__ */ new Set(["completion", "shell-init"]);
|
|
|
3857
4231
|
function argsOf(cmd) {
|
|
3858
4232
|
return cmd.args ?? {};
|
|
3859
4233
|
}
|
|
4234
|
+
/** Subcommand aliases, read off citty's own `meta.alias` so the completion and
|
|
4235
|
+
* the dispatcher cannot disagree about what `ls` means. Every forgemap command
|
|
4236
|
+
* declares `meta` as a plain object literal, never a thunk. */
|
|
4237
|
+
function aliasesOf(cmd) {
|
|
4238
|
+
const alias = cmd.meta?.alias;
|
|
4239
|
+
if (!alias) return [];
|
|
4240
|
+
return Array.isArray(alias) ? [...alias] : [alias];
|
|
4241
|
+
}
|
|
4242
|
+
/** Every name that reaches a command on the command line: its own, plus its
|
|
4243
|
+
* aliases. This is what the *recognition* arms key on — `commandSpecs()`'s
|
|
4244
|
+
* `name` alone is what the depth-1 *suggestion* list offers, which is how an
|
|
4245
|
+
* alias completes its arguments without being advertised as a command. */
|
|
4246
|
+
function namesOf(spec) {
|
|
4247
|
+
return [spec.name, ...spec.aliases];
|
|
4248
|
+
}
|
|
3860
4249
|
/** Flag names a command exposes, derived from its `defineCommand` args so a new
|
|
3861
4250
|
* flag surfaces in completion automatically. Positionals are handled
|
|
3862
4251
|
* separately; negatable booleans (those with a `negativeDescription`) also get
|
|
@@ -3895,6 +4284,7 @@ function commandSpecs() {
|
|
|
3895
4284
|
["forge", forgeCommand]
|
|
3896
4285
|
].map(([name, cmd]) => ({
|
|
3897
4286
|
name,
|
|
4287
|
+
aliases: aliasesOf(cmd),
|
|
3898
4288
|
flags: flagsOf(cmd),
|
|
3899
4289
|
flagValues: STATIC_FLAG_VALUES[name] ?? {},
|
|
3900
4290
|
positionalValues: SHELL_POSITIONAL.has(name) ? [...SUPPORTED_SHELLS] : [],
|
|
@@ -3902,17 +4292,17 @@ function commandSpecs() {
|
|
|
3902
4292
|
}));
|
|
3903
4293
|
}
|
|
3904
4294
|
function flagValuePairs(specs) {
|
|
3905
|
-
return specs.flatMap((s) => Object.entries(s.flagValues).map(([flag, values]) => [
|
|
3906
|
-
|
|
4295
|
+
return specs.flatMap((s) => namesOf(s).flatMap((name) => Object.entries(s.flagValues).map(([flag, values]) => [
|
|
4296
|
+
name,
|
|
3907
4297
|
flag,
|
|
3908
4298
|
values
|
|
3909
|
-
]));
|
|
4299
|
+
])));
|
|
3910
4300
|
}
|
|
3911
4301
|
function renderBash(specs) {
|
|
3912
4302
|
const names = specs.map((s) => s.name).join(" ");
|
|
3913
4303
|
const valueArms = flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) COMPREPLY=( $(compgen -W "${values.join(" ")}" -- "$cur") ); return ;;`).join("\n");
|
|
3914
|
-
const flagArms = specs.filter((s) => s.flags.length > 0).
|
|
3915
|
-
const slugCmds = specs.filter((s) => s.slugs).
|
|
4304
|
+
const flagArms = specs.filter((s) => s.flags.length > 0).flatMap((s) => namesOf(s).map((name) => ` ${name}) flags="${s.flags.join(" ")}" ;;`)).join("\n");
|
|
4305
|
+
const slugCmds = specs.filter((s) => s.slugs).flatMap(namesOf);
|
|
3916
4306
|
return `# forgemap bash completion — drop into your ~/.bashrc:
|
|
3917
4307
|
# eval "$(forgemap completion bash)"
|
|
3918
4308
|
_forgemap_completion() {
|
|
@@ -3947,7 +4337,7 @@ ${[slugCmds.length > 0 ? ` ${slugCmds.join("|")})
|
|
|
3947
4337
|
local slugs
|
|
3948
4338
|
slugs=$(forgemap list --format slug 2>/dev/null)
|
|
3949
4339
|
COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
|
|
3950
|
-
;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.
|
|
4340
|
+
;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${namesOf(s).join("|")}) COMPREPLY=( $(compgen -W "${s.positionalValues.join(" ")}" -- "$cur") ) ;;`)].filter(Boolean).join("\n")}
|
|
3951
4341
|
esac
|
|
3952
4342
|
}
|
|
3953
4343
|
complete -F _forgemap_completion forgemap
|
|
@@ -3976,19 +4366,19 @@ ${flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) compad
|
|
|
3976
4366
|
# Flag names for the current subcommand.
|
|
3977
4367
|
if [[ "$cur" == -* ]]; then
|
|
3978
4368
|
case "$cmd" in
|
|
3979
|
-
${specs.filter((s) => s.flags.length > 0).
|
|
4369
|
+
${specs.filter((s) => s.flags.length > 0).flatMap((s) => namesOf(s).map((name) => ` ${name}) compadd -- ${s.flags.join(" ")}; return ;;`)).join("\n")}
|
|
3980
4370
|
esac
|
|
3981
4371
|
return
|
|
3982
4372
|
fi
|
|
3983
4373
|
|
|
3984
4374
|
# Positional values (repo slugs, or a shell name).
|
|
3985
4375
|
case "$cmd" in
|
|
3986
|
-
${specs.filter((s) => s.slugs).
|
|
4376
|
+
${specs.filter((s) => s.slugs).flatMap(namesOf).join("|")})
|
|
3987
4377
|
local -a slugs
|
|
3988
4378
|
slugs=("\${(@f)$(forgemap list --format slug 2>/dev/null)}")
|
|
3989
4379
|
_describe 'slug' slugs
|
|
3990
4380
|
;;
|
|
3991
|
-
${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.
|
|
4381
|
+
${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${namesOf(s).join("|")}) compadd ${s.positionalValues.join(" ")} ;;`).join("\n")}
|
|
3992
4382
|
esac
|
|
3993
4383
|
}
|
|
3994
4384
|
compdef _forgemap forgemap
|
|
@@ -4000,7 +4390,7 @@ function renderFish(specs) {
|
|
|
4000
4390
|
const long = flag.replace(/^--/, "");
|
|
4001
4391
|
const values = s.flagValues[flag];
|
|
4002
4392
|
const valuePart = values ? ` -x -a '${values.join(" ")}'` : "";
|
|
4003
|
-
return `complete -c forgemap -n '__fish_seen_subcommand_from ${s.
|
|
4393
|
+
return `complete -c forgemap -n '__fish_seen_subcommand_from ${namesOf(s).join(" ")}' -l ${long}${valuePart}`;
|
|
4004
4394
|
})).join("\n");
|
|
4005
4395
|
const shellCmds = specs.filter((s) => s.positionalValues.length > 0);
|
|
4006
4396
|
return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
|
|
@@ -4015,7 +4405,7 @@ ${flagLines}
|
|
|
4015
4405
|
# Shell flavor for completion / shell-init (depth 2).
|
|
4016
4406
|
function __forgemap_needs_shell
|
|
4017
4407
|
set -l tokens (commandline -opc)
|
|
4018
|
-
set -l shell_cmds ${shellCmds.map((
|
|
4408
|
+
set -l shell_cmds ${shellCmds.flatMap(namesOf).map((name) => `"${name}"`).join(" ")}
|
|
4019
4409
|
if test (count $tokens) -ge 2; and contains $tokens[2] $shell_cmds
|
|
4020
4410
|
return 0
|
|
4021
4411
|
end
|
|
@@ -4026,7 +4416,7 @@ complete -c forgemap -f -n '__forgemap_needs_shell' -a '${shellCmds[0]?.position
|
|
|
4026
4416
|
# Slugs (depth 2) for commands that take one.
|
|
4027
4417
|
function __forgemap_needs_slug
|
|
4028
4418
|
set -l tokens (commandline -opc)
|
|
4029
|
-
set -l slug_cmds ${specs.filter((s) => s.slugs).map((
|
|
4419
|
+
set -l slug_cmds ${specs.filter((s) => s.slugs).flatMap(namesOf).map((name) => `"${name}"`).join(" ")}
|
|
4030
4420
|
if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
|
|
4031
4421
|
return 0
|
|
4032
4422
|
end
|
|
@@ -4077,11 +4467,11 @@ var completionCommand = defineCommand({
|
|
|
4077
4467
|
}
|
|
4078
4468
|
});
|
|
4079
4469
|
//#endregion
|
|
4080
|
-
//#region src/
|
|
4081
|
-
|
|
4470
|
+
//#region src/cli.ts
|
|
4471
|
+
var rootCommand = defineCommand({
|
|
4082
4472
|
meta: {
|
|
4083
4473
|
name: "forgemap",
|
|
4084
|
-
version: "0.
|
|
4474
|
+
version: "0.8.0-dev.133-c1be991",
|
|
4085
4475
|
description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
|
|
4086
4476
|
},
|
|
4087
4477
|
subCommands: {
|
|
@@ -4103,7 +4493,10 @@ runMain(defineCommand({
|
|
|
4103
4493
|
config: configCommand,
|
|
4104
4494
|
forge: forgeCommand
|
|
4105
4495
|
}
|
|
4106
|
-
})
|
|
4496
|
+
});
|
|
4497
|
+
//#endregion
|
|
4498
|
+
//#region src/bin/forgemap.ts
|
|
4499
|
+
runMain(rootCommand);
|
|
4107
4500
|
//#endregion
|
|
4108
4501
|
|
|
4109
4502
|
//# sourceMappingURL=forgemap.mjs.map
|