forgemap 0.1.0-dev.22-d31fc67 → 0.1.0-dev.26-b896caf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
+ import { readdir, readFile, mkdir, writeFile, stat, rm, rmdir, rename, access } from "node:fs/promises";
5
+ import { colors, formatTree } from "consola/utils";
6
+ import { isAbsolute, resolve, dirname, join } from "pathe";
7
+ import { homedir } from "node:os";
4
8
  import { existsSync } from "node:fs";
5
- import { mkdir, writeFile, readdir } from "node:fs/promises";
6
- import { dirname, isAbsolute, resolve, join } from "pathe";
7
9
  import { loadConfig } from "c12";
8
10
  import { spawn } from "node:child_process";
9
- import { homedir } from "node:os";
10
- import { colors, formatTree } from "consola/utils";
11
+ import { createHash } from "node:crypto";
11
12
  import Fuse from "fuse.js";
12
13
  const cdCommand = defineCommand({
13
14
  meta: {
@@ -34,7 +35,47 @@ const cdCommand = defineCommand({
34
35
  process.exitCode = 1;
35
36
  }
36
37
  });
37
- const DEFAULT_CONFIG = {
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 = {
38
79
  root: ".",
39
80
  defaultForge: "github",
40
81
  forges: {
@@ -47,24 +88,21 @@ const DEFAULT_CONFIG = {
47
88
  };
48
89
  async function loadForgeMapConfig(options = {}) {
49
90
  const envConfig = process.env.FORGEMAP_CONFIG;
50
- const explicit = options.configFile ?? envConfig;
51
- const cwd = explicit ? dirname(explicit) : options.cwd ?? process.cwd();
91
+ const startDir = options.cwd ?? process.cwd();
92
+ const explicit = options.configFile ?? envConfig ?? findConfigUp(startDir) ?? findGlobalConfig();
93
+ const cwd = explicit ? dirname(explicit) : startDir;
52
94
  const { config, configFile } = await loadConfig({
53
95
  name: "forgemap",
54
96
  cwd,
55
97
  configFile: explicit ? explicit : "forgemap.config",
56
98
  rcFile: false,
57
99
  globalRc: false,
58
- dotenv: false,
59
- defaults: DEFAULT_CONFIG
100
+ dotenv: false
60
101
  });
61
102
  const merged = {
62
- root: config.root ?? DEFAULT_CONFIG.root,
63
- defaultForge: config.defaultForge ?? DEFAULT_CONFIG.defaultForge,
64
- forges: {
65
- ...DEFAULT_CONFIG.forges,
66
- ...config.forges
67
- }
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
68
106
  };
69
107
  return {
70
108
  config: merged,
@@ -81,6 +119,57 @@ function execInherit(command, args) {
81
119
  });
82
120
  });
83
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
+ }
84
173
  function hasCommand(command) {
85
174
  return new Promise((resolvePromise) => {
86
175
  const child = spawn(
@@ -94,6 +183,115 @@ function hasCommand(command) {
94
183
  child.on("close", (code) => resolvePromise(code === 0));
95
184
  });
96
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
+ }
97
295
  const githubAdapter = {
98
296
  async clone({ owner, repo, dest }) {
99
297
  if (!await hasCommand("gh")) {
@@ -110,17 +308,81 @@ const githubAdapter = {
110
308
  if (code !== 0) {
111
309
  throw new Error(`gh repo clone exited with code ${code}`);
112
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;
113
373
  }
114
374
  };
115
375
  function getForgeAdapter(type) {
116
376
  switch (type) {
117
377
  case "github":
118
378
  return githubAdapter;
379
+ case "git":
380
+ return gitAdapter;
119
381
  case "gitlab":
120
382
  case "gitea":
121
383
  case "codeberg":
122
384
  throw new Error(
123
- `Forge type "${type}" is not implemented yet. Only "github" is supported in this release.`
385
+ `Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`
124
386
  );
125
387
  default: {
126
388
  const exhaustive = type;
@@ -128,6 +390,275 @@ function getForgeAdapter(type) {
128
390
  }
129
391
  }
130
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
+ }
131
662
  const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
132
663
  const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
133
664
  const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
@@ -181,93 +712,409 @@ function parseSlug(input) {
181
712
  }
182
713
  throw new Error(`Unrecognized slug format: ${input}`);
183
714
  }
184
- function expandTilde(p) {
185
- if (p === "~") return homedir();
186
- if (p.startsWith("~/")) return resolve(homedir(), p.slice(2));
187
- return p;
188
- }
189
- function resolveRoot(root, configDir) {
190
- const expanded = expandTilde(root);
191
- if (isAbsolute(expanded)) return expanded;
192
- return resolve(configDir, expanded);
193
- }
194
- function findForgeByHost(forges, host) {
195
- for (const [name, forge] of Object.entries(forges)) {
196
- if (forge.host.toLowerCase() === host.toLowerCase()) {
197
- return { name, forge };
198
- }
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 {
199
734
  }
200
- return void 0;
735
+ return { repo, origin, owner, name, lastCommitUnix, dirty, unpushed };
201
736
  }
202
- function resolveSlug(parsed, options) {
203
- const { config, configDir } = options;
204
- let forgeName;
205
- let forge;
206
- if (parsed.forgeName) {
207
- const candidate = config.forges[parsed.forgeName];
208
- if (!candidate) {
209
- throw new Error(
210
- `Forge "${parsed.forgeName}" is not defined in forgemap.config`
211
- );
212
- }
213
- forgeName = parsed.forgeName;
214
- forge = candidate;
215
- } else if (parsed.host) {
216
- const match = findForgeByHost(config.forges, parsed.host);
217
- if (!match) {
218
- throw new Error(
219
- `No forge configured for host "${parsed.host}". Add it to forgemap.config.ts.`
220
- );
221
- }
222
- forgeName = match.name;
223
- forge = match.forge;
224
- } else {
225
- const candidate = config.forges[config.defaultForge];
226
- if (!candidate) {
227
- throw new Error(
228
- `Default forge "${config.defaultForge}" is not defined in forgemap.config`
229
- );
230
- }
231
- forgeName = config.defaultForge;
232
- forge = candidate;
737
+ async function classifyRemotes(candidates) {
738
+ const byType = /* @__PURE__ */ new Map();
739
+ for (const c of candidates) {
740
+ const list = byType.get(c.repo.forge.type);
741
+ if (list) list.push(c);
742
+ else byType.set(c.repo.forge.type, [c]);
233
743
  }
234
- const root = resolveRoot(config.root, configDir);
235
- const localPath = join(root, forge.dir, parsed.owner, parsed.repo);
236
- return {
237
- forgeName,
238
- forge,
239
- owner: parsed.owner,
240
- repo: parsed.repo,
241
- localPath
242
- };
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;
243
794
  }
244
- const cloneCommand = defineCommand({
795
+ function ageDays(lastCommitUnix) {
796
+ return Math.floor(
797
+ Date.now() / 1e3 / DAY_SECONDS - lastCommitUnix / DAY_SECONDS
798
+ );
799
+ }
800
+ const cleanupCommand = defineCommand({
245
801
  meta: {
246
- name: "clone",
247
- description: "Clone a repo into the configured local layout"
802
+ name: "cleanup",
803
+ description: "List stale, clean, fully-pushed repos whose remote still exists, then delete them locally after confirmation"
248
804
  },
249
805
  args: {
250
- slug: {
251
- type: "positional",
252
- description: "owner/repo, forge:owner/repo, or full URL",
253
- required: true
806
+ days: {
807
+ type: "string",
808
+ description: "Minimum age in days since the last commit (default 365)",
809
+ default: "365"
254
810
  },
255
- config: {
811
+ forge: {
256
812
  type: "string",
257
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
258
- }
259
- },
260
- async run({ args }) {
261
- const loaded = await loadForgeMapConfig({ configFile: args.config });
262
- const parsed = parseSlug(args.slug);
263
- const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
264
- const resolved = resolveSlug(parsed, {
265
- config: loaded.config,
266
- configDir
267
- });
268
- if (existsSync(resolved.localPath)) {
269
- consola.info(`Already cloned at ${resolved.localPath}`);
270
- return;
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;
1016
+ }
1017
+ function findForgeByHost(forges, host) {
1018
+ for (const [name, forge] of Object.entries(forges)) {
1019
+ if (forge.host.toLowerCase() === host.toLowerCase()) {
1020
+ return { name, forge };
1021
+ }
1022
+ }
1023
+ return void 0;
1024
+ }
1025
+ function resolveSlug(parsed, options) {
1026
+ const { config, configDir } = options;
1027
+ let forgeName;
1028
+ let forge;
1029
+ if (parsed.forgeName) {
1030
+ const candidate = config.forges[parsed.forgeName];
1031
+ if (!candidate) {
1032
+ throw new Error(
1033
+ `Forge "${parsed.forgeName}" is not defined in forgemap.config`
1034
+ );
1035
+ }
1036
+ forgeName = parsed.forgeName;
1037
+ forge = candidate;
1038
+ } else if (parsed.host) {
1039
+ const match = findForgeByHost(config.forges, parsed.host);
1040
+ if (!match) {
1041
+ throw new Error(
1042
+ `No forge configured for host "${parsed.host}". Add it to forgemap.config.ts.`
1043
+ );
1044
+ }
1045
+ forgeName = match.name;
1046
+ forge = match.forge;
1047
+ } else {
1048
+ const candidate = config.forges[config.defaultForge];
1049
+ if (!candidate) {
1050
+ throw new Error(
1051
+ `Default forge "${config.defaultForge}" is not defined in forgemap.config`
1052
+ );
1053
+ }
1054
+ forgeName = config.defaultForge;
1055
+ forge = candidate;
1056
+ }
1057
+ const root = resolveRoot(config.root, configDir);
1058
+ const localPath = join(root, forge.dir, parsed.owner, parsed.repo);
1059
+ return {
1060
+ forgeName,
1061
+ forge,
1062
+ owner: parsed.owner,
1063
+ repo: parsed.repo,
1064
+ localPath
1065
+ };
1066
+ }
1067
+ const cloneCommand = defineCommand({
1068
+ meta: {
1069
+ name: "clone",
1070
+ description: "Clone a repo into the configured local layout"
1071
+ },
1072
+ args: {
1073
+ slug: {
1074
+ type: "positional",
1075
+ description: "owner/repo, forge:owner/repo, or full URL",
1076
+ required: true
1077
+ },
1078
+ ssh: {
1079
+ type: "boolean",
1080
+ description: "Force the SSH URL form (git-type forges only)",
1081
+ default: false
1082
+ },
1083
+ https: {
1084
+ type: "boolean",
1085
+ description: "Force the HTTPS URL form (git-type forges only)",
1086
+ default: false
1087
+ },
1088
+ config: {
1089
+ type: "string",
1090
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1091
+ }
1092
+ },
1093
+ async run({ args }) {
1094
+ if (args.ssh && args.https) {
1095
+ consola.error("--ssh and --https are mutually exclusive.");
1096
+ process.exitCode = 1;
1097
+ return;
1098
+ }
1099
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1100
+ const parsed = parseSlug(args.slug);
1101
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
1102
+ const resolved = resolveSlug(parsed, {
1103
+ config: loaded.config,
1104
+ configDir
1105
+ });
1106
+ let protocol;
1107
+ if (args.ssh) protocol = "ssh";
1108
+ else if (args.https) protocol = "https";
1109
+ if (protocol && resolved.forge.type !== "git") {
1110
+ consola.warn(
1111
+ `--${protocol} is ignored for type "${resolved.forge.type}" — the adapter selects the URL itself.`
1112
+ );
1113
+ protocol = void 0;
1114
+ }
1115
+ if (existsSync(resolved.localPath)) {
1116
+ consola.info(`Already cloned at ${resolved.localPath}`);
1117
+ return;
271
1118
  }
272
1119
  await mkdir(dirname(resolved.localPath), { recursive: true });
273
1120
  const adapter = getForgeAdapter(resolved.forge.type);
@@ -275,14 +1122,217 @@ const cloneCommand = defineCommand({
275
1122
  forge: resolved.forge,
276
1123
  owner: resolved.owner,
277
1124
  repo: resolved.repo,
278
- dest: resolved.localPath
1125
+ dest: resolved.localPath,
1126
+ protocol
279
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
+ );
280
1139
  consola.success(
281
1140
  `Cloned ${resolved.owner}/${resolved.repo} → ${resolved.localPath}`
282
1141
  );
283
1142
  }
284
1143
  });
285
- const TEMPLATE = `/**
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 = `/**
286
1336
  * forgemap configuration.
287
1337
  *
288
1338
  * For type-safe authoring, install forgemap and switch to:
@@ -290,19 +1340,63 @@ const TEMPLATE = `/**
290
1340
  * export default defineForgeMapConfig({ ... });
291
1341
  *
292
1342
  * @type {import('forgemap').ForgeMapUserConfig}
293
- */
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}
294
1363
  export default {
295
- root: '.',
296
- defaultForge: 'github',
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",
297
1392
  forges: {
298
1393
  github: {
299
- type: 'github',
300
- host: 'github.com',
301
- dir: 'comGithub'
1394
+ type: "github",
1395
+ host: "github.com",
1396
+ dir: "comGithub"
302
1397
  }
303
1398
  }
304
1399
  };
305
- `;
306
1400
  const configInitCommand = defineCommand({
307
1401
  meta: {
308
1402
  name: "init",
@@ -321,23 +1415,20 @@ const configInitCommand = defineCommand({
321
1415
  }
322
1416
  },
323
1417
  async run({ args }) {
324
- const outDir = resolve(process.cwd(), args.out);
325
- const target = join(outDir, "forgemap.config.ts");
326
- await mkdir(dirname(target), { recursive: true });
327
- try {
328
- await writeFile(target, TEMPLATE, {
329
- encoding: "utf8",
330
- flag: args.force ? "w" : "wx"
331
- });
332
- } catch (error) {
333
- if (error.code === "EEXIST") {
334
- consola.error(`${target} already exists. Use --force to overwrite.`);
335
- process.exitCode = 1;
336
- return;
337
- }
338
- throw error;
1418
+ const result = await writeConfigFile(DEFAULT_CONFIG, {
1419
+ outDir: args.out,
1420
+ force: args.force
1421
+ });
1422
+ if (!result) {
1423
+ const target = join(
1424
+ resolve(process.cwd(), args.out),
1425
+ "forgemap.config.ts"
1426
+ );
1427
+ consola.error(`${target} already exists. Use --force to overwrite.`);
1428
+ process.exitCode = 1;
1429
+ return;
339
1430
  }
340
- consola.success(`Wrote ${target}`);
1431
+ consola.success(`Wrote ${result.path}`);
341
1432
  }
342
1433
  });
343
1434
  const configShowCommand = defineCommand({
@@ -350,30 +1441,582 @@ const configShowCommand = defineCommand({
350
1441
  type: "string",
351
1442
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
352
1443
  }
353
- },
354
- async run({ args }) {
355
- const loaded = await loadForgeMapConfig({ configFile: args.config });
356
- process.stdout.write(
357
- JSON.stringify(
358
- {
359
- configFile: loaded.configFile ?? null,
360
- cwd: loaded.cwd,
361
- config: loaded.config
362
- },
363
- null,
364
- 2
365
- ) + "\n"
366
- );
367
- }
368
- });
369
- const configCommand = defineCommand({
370
- meta: {
371
- name: "config",
372
- description: "Manage the forgemap config file"
373
- },
374
- subCommands: {
375
- init: configInitCommand,
376
- show: configShowCommand
1444
+ },
1445
+ async run({ args }) {
1446
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1447
+ process.stdout.write(
1448
+ JSON.stringify(
1449
+ {
1450
+ configFile: loaded.configFile ?? null,
1451
+ cwd: loaded.cwd,
1452
+ config: loaded.config
1453
+ },
1454
+ null,
1455
+ 2
1456
+ ) + "\n"
1457
+ );
1458
+ }
1459
+ });
1460
+ const configCommand = defineCommand({
1461
+ meta: {
1462
+ name: "config",
1463
+ description: "Manage the forgemap config file"
1464
+ },
1465
+ subCommands: {
1466
+ init: configInitCommand,
1467
+ show: configShowCommand
1468
+ }
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
+ });
377
2020
  }
378
2021
  });
379
2022
  function platformOpen(localPath) {
@@ -459,39 +2102,6 @@ const pathCommand = defineCommand({
459
2102
  `);
460
2103
  }
461
2104
  });
462
- async function listDirs(path) {
463
- try {
464
- const entries = await readdir(path, { withFileTypes: true });
465
- return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
466
- } catch (error) {
467
- if (error.code === "ENOENT") return [];
468
- throw error;
469
- }
470
- }
471
- async function scanRepos(options) {
472
- const { config, configDir } = options;
473
- const root = resolveRoot(config.root, configDir);
474
- const repos = [];
475
- for (const [forgeName, forge] of Object.entries(config.forges)) {
476
- const forgeRoot = join(root, forge.dir);
477
- const owners = await listDirs(forgeRoot);
478
- for (const owner of owners) {
479
- const ownerPath = join(forgeRoot, owner);
480
- const repoNames = await listDirs(ownerPath);
481
- for (const repo of repoNames) {
482
- repos.push({
483
- forgeName,
484
- forge,
485
- owner,
486
- repo,
487
- localPath: join(ownerPath, repo),
488
- slug: `${owner}/${repo}`
489
- });
490
- }
491
- }
492
- }
493
- return repos;
494
- }
495
2105
  const pickCommand = defineCommand({
496
2106
  meta: {
497
2107
  name: "pick",
@@ -542,32 +2152,66 @@ const pickCommand = defineCommand({
542
2152
  process.exitCode = 1;
543
2153
  return;
544
2154
  }
545
- const choice = await consola.prompt("Select a repo", {
546
- type: "select",
547
- options: candidates.map((r) => ({
548
- label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
549
- value: r.localPath,
550
- hint: r.localPath
551
- }))
552
- });
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
+ }
553
2189
  if (typeof choice === "string" && choice) {
554
- process.stdout.write(`${choice}
2190
+ realWrite.call(out, `${choice}
555
2191
  `);
556
2192
  }
557
2193
  }
558
2194
  });
559
- function renderTree(repos) {
560
- const groups = /* @__PURE__ */ new Map();
2195
+ function renderTree$1(repos) {
2196
+ const byForge = /* @__PURE__ */ new Map();
561
2197
  for (const r of repos) {
562
- const list = groups.get(r.forgeName);
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);
563
2204
  if (list) list.push(r);
564
- else groups.set(r.forgeName, [r]);
2205
+ else owners.set(r.owner, [r]);
565
2206
  }
566
2207
  return formatTree(
567
- Array.from(groups, ([forge, items]) => ({
2208
+ Array.from(byForge, ([forge, owners]) => ({
568
2209
  text: colors.bold(forge),
569
- children: items.map((r) => ({
570
- text: `${colors.cyan(r.slug)} ${colors.dim(r.localPath)}`
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
+ }))
571
2215
  }))
572
2216
  }))
573
2217
  );
@@ -625,7 +2269,7 @@ const searchCommand = defineCommand({
625
2269
  return;
626
2270
  }
627
2271
  if (format === "pretty") {
628
- process.stdout.write(`${renderTree(items)}
2272
+ process.stdout.write(`${renderTree$1(items)}
629
2273
  `);
630
2274
  return;
631
2275
  }
@@ -637,12 +2281,29 @@ const searchCommand = defineCommand({
637
2281
  }
638
2282
  }
639
2283
  });
640
- const SUPPORTED = ["zsh", "bash", "fish"];
641
- function detectShell() {
642
- const env = process.env.SHELL ?? "";
643
- if (env.endsWith("/fish")) return "fish";
644
- if (env.endsWith("/bash")) return "bash";
645
- return "zsh";
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
+ );
646
2307
  }
647
2308
  function renderPosix(name) {
648
2309
  return `# forgemap shell integration — drop into your ~/.zshrc / ~/.bashrc:
@@ -710,34 +2371,445 @@ end
710
2371
  const shellInitCommand = defineCommand({
711
2372
  meta: {
712
2373
  name: "shell-init",
713
- description: 'Print a shell wrapper that adds `forgemap cd <slug>` as a real cd. Source it via `eval "$(forgemap shell-init)"`.'
2374
+ description: 'Print (or --install) a shell wrapper that adds `forgemap cd <slug>` as a real cd. Source it via `eval "$(forgemap shell-init)"`.'
714
2375
  },
715
2376
  args: {
716
2377
  shell: {
717
2378
  type: "positional",
718
- description: `Shell flavor (${SUPPORTED.join(", ")}). Auto-detected from $SHELL if omitted.`,
2379
+ description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
719
2380
  required: false
720
2381
  },
721
2382
  name: {
722
2383
  type: "string",
723
2384
  description: "Name of the generated wrapper function (default: forgemap)",
724
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
725
2391
  }
726
2392
  },
727
2393
  async run({ args }) {
728
2394
  const requested = args.shell ?? detectShell();
729
- if (!SUPPORTED.includes(requested)) {
2395
+ if (!SUPPORTED_SHELLS.includes(requested)) {
730
2396
  consola.error(
731
- `Unsupported shell "${requested}". Supported: ${SUPPORTED.join(", ")}.`
2397
+ `Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`
732
2398
  );
733
2399
  process.exitCode = 1;
734
2400
  return;
735
2401
  }
736
2402
  const name = args.name || "forgemap";
2403
+ if (args.install) {
2404
+ await install(requested, name);
2405
+ return;
2406
+ }
737
2407
  const out = requested === "fish" ? renderFish(name) : renderPosix(name);
738
2408
  process.stdout.write(out);
739
2409
  }
740
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
+ });
741
2813
  const rootCommand = defineCommand({
742
2814
  meta: {
743
2815
  name: "forgemap",
@@ -745,11 +2817,17 @@ const rootCommand = defineCommand({
745
2817
  },
746
2818
  subCommands: {
747
2819
  clone: cloneCommand,
2820
+ import: importCommand,
2821
+ cleanup: cleanupCommand,
748
2822
  cd: cdCommand,
749
2823
  path: pathCommand,
750
2824
  open: openCommand,
751
2825
  search: searchCommand,
752
2826
  pick: pickCommand,
2827
+ status: statusCommand,
2828
+ sync: syncCommand,
2829
+ validate: validateCommand,
2830
+ completion: completionCommand,
753
2831
  "shell-init": shellInitCommand,
754
2832
  config: configCommand
755
2833
  }