forgemap 0.1.0-dev.22-d31fc67 → 0.1.0-dev.23-9e4d2ae

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.
@@ -2,10 +2,11 @@
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
4
  import { existsSync } from "node:fs";
5
- import { mkdir, writeFile, readdir } from "node:fs/promises";
5
+ import { readdir, readFile, mkdir, writeFile, stat, access } from "node:fs/promises";
6
6
  import { dirname, isAbsolute, resolve, join } from "pathe";
7
7
  import { loadConfig } from "c12";
8
8
  import { spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
9
10
  import { homedir } from "node:os";
10
11
  import { colors, formatTree } from "consola/utils";
11
12
  import Fuse from "fuse.js";
@@ -55,16 +56,12 @@ async function loadForgeMapConfig(options = {}) {
55
56
  configFile: explicit ? explicit : "forgemap.config",
56
57
  rcFile: false,
57
58
  globalRc: false,
58
- dotenv: false,
59
- defaults: DEFAULT_CONFIG
59
+ dotenv: false
60
60
  });
61
61
  const merged = {
62
62
  root: config.root ?? DEFAULT_CONFIG.root,
63
63
  defaultForge: config.defaultForge ?? DEFAULT_CONFIG.defaultForge,
64
- forges: {
65
- ...DEFAULT_CONFIG.forges,
66
- ...config.forges
67
- }
64
+ forges: config.forges && Object.keys(config.forges).length > 0 ? config.forges : DEFAULT_CONFIG.forges
68
65
  };
69
66
  return {
70
67
  config: merged,
@@ -81,6 +78,26 @@ function execInherit(command, args) {
81
78
  });
82
79
  });
83
80
  }
81
+ function execCapture(command, args, options = {}) {
82
+ return new Promise((resolvePromise, rejectPromise) => {
83
+ const child = spawn(command, args, {
84
+ cwd: options.cwd,
85
+ stdio: ["ignore", "pipe", "pipe"]
86
+ });
87
+ let stdout = "";
88
+ let stderr = "";
89
+ child.stdout?.on("data", (chunk) => {
90
+ stdout += chunk.toString();
91
+ });
92
+ child.stderr?.on("data", (chunk) => {
93
+ stderr += chunk.toString();
94
+ });
95
+ child.on("error", rejectPromise);
96
+ child.on("close", (code) => {
97
+ resolvePromise({ code: code ?? 0, stdout, stderr });
98
+ });
99
+ });
100
+ }
84
101
  function hasCommand(command) {
85
102
  return new Promise((resolvePromise) => {
86
103
  const child = spawn(
@@ -94,6 +111,28 @@ function hasCommand(command) {
94
111
  child.on("close", (code) => resolvePromise(code === 0));
95
112
  });
96
113
  }
114
+ function buildCloneUrl(opts) {
115
+ const forge = opts.forge;
116
+ const protocol = opts.protocol ?? forge.protocol ?? "ssh";
117
+ if (protocol === "https") {
118
+ return `https://${forge.host}/${opts.owner}/${opts.repo}.git`;
119
+ }
120
+ return `git@${forge.host}:${opts.owner}/${opts.repo}.git`;
121
+ }
122
+ const gitAdapter = {
123
+ async clone(options) {
124
+ if (!await hasCommand("git")) {
125
+ throw new Error(
126
+ "`git` is not installed. Install it from https://git-scm.com/ and try again."
127
+ );
128
+ }
129
+ const url = buildCloneUrl(options);
130
+ const { code } = await execInherit("git", ["clone", url, options.dest]);
131
+ if (code !== 0) {
132
+ throw new Error(`git clone exited with code ${code}`);
133
+ }
134
+ }
135
+ };
97
136
  const githubAdapter = {
98
137
  async clone({ owner, repo, dest }) {
99
138
  if (!await hasCommand("gh")) {
@@ -116,11 +155,13 @@ function getForgeAdapter(type) {
116
155
  switch (type) {
117
156
  case "github":
118
157
  return githubAdapter;
158
+ case "git":
159
+ return gitAdapter;
119
160
  case "gitlab":
120
161
  case "gitea":
121
162
  case "codeberg":
122
163
  throw new Error(
123
- `Forge type "${type}" is not implemented yet. Only "github" is supported in this release.`
164
+ `Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`
124
165
  );
125
166
  default: {
126
167
  const exhaustive = type;
@@ -128,6 +169,159 @@ function getForgeAdapter(type) {
128
169
  }
129
170
  }
130
171
  }
172
+ function expandTilde(p) {
173
+ if (p === "~") return homedir();
174
+ if (p.startsWith("~/")) return resolve(homedir(), p.slice(2));
175
+ return p;
176
+ }
177
+ function resolveRoot(root, configDir) {
178
+ const expanded = expandTilde(root);
179
+ if (isAbsolute(expanded)) return expanded;
180
+ return resolve(configDir, expanded);
181
+ }
182
+ async function listDirs(path) {
183
+ try {
184
+ const entries = await readdir(path, { withFileTypes: true });
185
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
186
+ } catch (error) {
187
+ if (error.code === "ENOENT") return [];
188
+ throw error;
189
+ }
190
+ }
191
+ async function scanRepos(options) {
192
+ const { config, configDir } = options;
193
+ const root = resolveRoot(config.root, configDir);
194
+ const repos = [];
195
+ for (const [forgeName, forge] of Object.entries(config.forges)) {
196
+ const forgeRoot = join(root, forge.dir);
197
+ const owners = await listDirs(forgeRoot);
198
+ for (const owner of owners) {
199
+ const ownerPath = join(forgeRoot, owner);
200
+ const repoNames = await listDirs(ownerPath);
201
+ for (const repo of repoNames) {
202
+ repos.push({
203
+ forgeName,
204
+ forge,
205
+ owner,
206
+ repo,
207
+ localPath: join(ownerPath, repo),
208
+ slug: `${owner}/${repo}`
209
+ });
210
+ }
211
+ }
212
+ }
213
+ return repos;
214
+ }
215
+ const DEFAULT_TTL_MS = 6e4;
216
+ function ttl() {
217
+ const env = process.env.FORGEMAP_CACHE_TTL_MS;
218
+ if (!env) return DEFAULT_TTL_MS;
219
+ const parsed = Number.parseInt(env, 10);
220
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TTL_MS;
221
+ }
222
+ function cacheDir() {
223
+ const xdg = process.env.XDG_CACHE_HOME;
224
+ return xdg ? join(xdg, "forgemap") : join(homedir(), ".cache", "forgemap");
225
+ }
226
+ function cachePath(root) {
227
+ const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
228
+ return join(cacheDir(), `scan-${hash}.json`);
229
+ }
230
+ async function safeStat(path) {
231
+ try {
232
+ const s = await stat(path);
233
+ return Math.trunc(s.mtimeMs);
234
+ } catch {
235
+ return 0;
236
+ }
237
+ }
238
+ async function safeListDirs(path) {
239
+ try {
240
+ const entries = await readdir(path, { withFileTypes: true });
241
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
242
+ } catch {
243
+ return [];
244
+ }
245
+ }
246
+ async function computeFingerprint(config, configDir) {
247
+ const root = resolveRoot(config.root, configDir);
248
+ const perForge = await Promise.all(
249
+ Object.values(config.forges).map(async (forge) => {
250
+ const forgeRoot = join(root, forge.dir);
251
+ const [forgeMtime, owners] = await Promise.all([
252
+ safeStat(forgeRoot),
253
+ safeListDirs(forgeRoot)
254
+ ]);
255
+ const ownerEntries = await Promise.all(
256
+ owners.map(async (owner) => {
257
+ const ownerPath = join(forgeRoot, owner);
258
+ return [ownerPath, await safeStat(ownerPath)];
259
+ })
260
+ );
261
+ return [[forgeRoot, forgeMtime], ...ownerEntries];
262
+ })
263
+ );
264
+ const entries = [[root, await safeStat(root)]];
265
+ for (const group of perForge) entries.push(...group);
266
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
267
+ return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
268
+ }
269
+ async function readCacheFile(file) {
270
+ try {
271
+ const raw = await readFile(file, "utf8");
272
+ return JSON.parse(raw);
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ async function writeCacheFile(file, payload) {
278
+ await mkdir(cacheDir(), { recursive: true });
279
+ await writeFile(file, JSON.stringify(payload), "utf8");
280
+ }
281
+ async function scanReposCached(options) {
282
+ const { config, configDir, useCache = true, trustTtl = true } = options;
283
+ const root = resolveRoot(config.root, configDir);
284
+ const file = cachePath(root);
285
+ if (useCache) {
286
+ const cached = await readCacheFile(file);
287
+ if (cached) {
288
+ const age = Date.now() - cached.writtenAt;
289
+ if (trustTtl && age < ttl()) {
290
+ return cached.repos;
291
+ }
292
+ const fingerprint2 = await computeFingerprint(config, configDir);
293
+ if (cached.fingerprint === fingerprint2) {
294
+ await writeCacheFile(file, { ...cached, writtenAt: Date.now() });
295
+ return cached.repos;
296
+ }
297
+ }
298
+ }
299
+ const repos = await scanRepos({ config, configDir });
300
+ const fingerprint = await computeFingerprint(config, configDir);
301
+ await writeCacheFile(file, {
302
+ fingerprint,
303
+ writtenAt: Date.now(),
304
+ repos
305
+ });
306
+ return repos;
307
+ }
308
+ async function appendCachedRepo(options, repo) {
309
+ const { config, configDir } = options;
310
+ const root = resolveRoot(config.root, configDir);
311
+ const file = cachePath(root);
312
+ const cached = await readCacheFile(file);
313
+ if (!cached) {
314
+ return;
315
+ }
316
+ if (cached.repos.some((r) => r.localPath === repo.localPath)) {
317
+ return;
318
+ }
319
+ await writeCacheFile(file, {
320
+ fingerprint: await computeFingerprint(config, configDir),
321
+ writtenAt: Date.now(),
322
+ repos: [...cached.repos, repo]
323
+ });
324
+ }
131
325
  const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
132
326
  const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
133
327
  const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
@@ -181,16 +375,6 @@ function parseSlug(input) {
181
375
  }
182
376
  throw new Error(`Unrecognized slug format: ${input}`);
183
377
  }
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
378
  function findForgeByHost(forges, host) {
195
379
  for (const [name, forge] of Object.entries(forges)) {
196
380
  if (forge.host.toLowerCase() === host.toLowerCase()) {
@@ -252,12 +436,27 @@ const cloneCommand = defineCommand({
252
436
  description: "owner/repo, forge:owner/repo, or full URL",
253
437
  required: true
254
438
  },
439
+ ssh: {
440
+ type: "boolean",
441
+ description: "Force the SSH URL form (git-type forges only)",
442
+ default: false
443
+ },
444
+ https: {
445
+ type: "boolean",
446
+ description: "Force the HTTPS URL form (git-type forges only)",
447
+ default: false
448
+ },
255
449
  config: {
256
450
  type: "string",
257
451
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
258
452
  }
259
453
  },
260
454
  async run({ args }) {
455
+ if (args.ssh && args.https) {
456
+ consola.error("--ssh and --https are mutually exclusive.");
457
+ process.exitCode = 1;
458
+ return;
459
+ }
261
460
  const loaded = await loadForgeMapConfig({ configFile: args.config });
262
461
  const parsed = parseSlug(args.slug);
263
462
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
@@ -265,6 +464,15 @@ const cloneCommand = defineCommand({
265
464
  config: loaded.config,
266
465
  configDir
267
466
  });
467
+ let protocol;
468
+ if (args.ssh) protocol = "ssh";
469
+ else if (args.https) protocol = "https";
470
+ if (protocol && resolved.forge.type !== "git") {
471
+ consola.warn(
472
+ `--${protocol} is ignored for type "${resolved.forge.type}" — the adapter selects the URL itself.`
473
+ );
474
+ protocol = void 0;
475
+ }
268
476
  if (existsSync(resolved.localPath)) {
269
477
  consola.info(`Already cloned at ${resolved.localPath}`);
270
478
  return;
@@ -275,13 +483,146 @@ const cloneCommand = defineCommand({
275
483
  forge: resolved.forge,
276
484
  owner: resolved.owner,
277
485
  repo: resolved.repo,
278
- dest: resolved.localPath
486
+ dest: resolved.localPath,
487
+ protocol
279
488
  });
489
+ await appendCachedRepo(
490
+ { config: loaded.config, configDir },
491
+ {
492
+ forgeName: resolved.forgeName,
493
+ forge: resolved.forge,
494
+ owner: resolved.owner,
495
+ repo: resolved.repo,
496
+ localPath: resolved.localPath,
497
+ slug: `${resolved.owner}/${resolved.repo}`
498
+ }
499
+ );
280
500
  consola.success(
281
501
  `Cloned ${resolved.owner}/${resolved.repo} → ${resolved.localPath}`
282
502
  );
283
503
  }
284
504
  });
505
+ const SUPPORTED$1 = ["zsh", "bash", "fish"];
506
+ const SUBCOMMANDS = [
507
+ "clone",
508
+ "cd",
509
+ "path",
510
+ "open",
511
+ "search",
512
+ "pick",
513
+ "status",
514
+ "sync",
515
+ "validate",
516
+ "shell-init",
517
+ "completion",
518
+ "config"
519
+ ];
520
+ const SLUG_COMMANDS = ["clone", "cd", "path", "open", "search", "pick"];
521
+ function detectShell$1() {
522
+ const env = process.env.SHELL ?? "";
523
+ if (env.endsWith("/fish")) return "fish";
524
+ if (env.endsWith("/bash")) return "bash";
525
+ return "zsh";
526
+ }
527
+ function renderBash() {
528
+ return `# forgemap bash completion — drop into your ~/.bashrc:
529
+ # eval "$(forgemap completion bash)"
530
+ _forgemap_completion() {
531
+ local cur prev cmd words
532
+ COMPREPLY=()
533
+ cur="\${COMP_WORDS[COMP_CWORD]}"
534
+ cmd="\${COMP_WORDS[1]}"
535
+
536
+ if [ "$COMP_CWORD" = "1" ]; then
537
+ COMPREPLY=( $(compgen -W "${SUBCOMMANDS.join(" ")}" -- "$cur") )
538
+ return
539
+ fi
540
+
541
+ case "$cmd" in
542
+ ${SLUG_COMMANDS.join("|")})
543
+ local slugs
544
+ slugs=$(forgemap search '' --format slug 2>/dev/null)
545
+ COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
546
+ ;;
547
+ esac
548
+ }
549
+ complete -F _forgemap_completion forgemap
550
+ `;
551
+ }
552
+ function renderZsh() {
553
+ return `# forgemap zsh completion — drop into your ~/.zshrc:
554
+ # eval "$(forgemap completion zsh)"
555
+ _forgemap() {
556
+ local context state line
557
+ local -a subcommands slug_cmds
558
+ subcommands=(${SUBCOMMANDS.map((s) => `'${s}'`).join(" ")})
559
+ slug_cmds=(${SLUG_COMMANDS.map((s) => `'${s}'`).join(" ")})
560
+
561
+ _arguments -C \\
562
+ '1: :->cmd' \\
563
+ '*::arg:->args'
564
+
565
+ case "$state" in
566
+ cmd) _describe 'forgemap subcommand' subcommands ;;
567
+ args)
568
+ if (( $slug_cmds[(I)$words[1]] )); then
569
+ local -a slugs
570
+ slugs=("\${(@f)$(forgemap search '' --format slug 2>/dev/null)}")
571
+ _describe 'slug' slugs
572
+ fi
573
+ ;;
574
+ esac
575
+ }
576
+ compdef _forgemap forgemap
577
+ `;
578
+ }
579
+ function renderFish$1() {
580
+ const slugCmdsList = SLUG_COMMANDS.map((s) => `"${s}"`).join(" ");
581
+ return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
582
+ # forgemap completion fish | source
583
+
584
+ # Subcommands (depth 1).
585
+ complete -c forgemap -f -n '__fish_use_subcommand' -a '${SUBCOMMANDS.join(" ")}'
586
+
587
+ # Slugs (depth 2) for commands that take one.
588
+ function __forgemap_needs_slug
589
+ set -l tokens (commandline -opc)
590
+ set -l slug_cmds ${slugCmdsList}
591
+ if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
592
+ return 0
593
+ end
594
+ return 1
595
+ end
596
+
597
+ complete -c forgemap -f -n '__forgemap_needs_slug' \\
598
+ -a '(forgemap search "" --format slug 2>/dev/null)'
599
+ `;
600
+ }
601
+ const completionCommand = defineCommand({
602
+ meta: {
603
+ name: "completion",
604
+ description: 'Print a shell completion script. Source via `eval "$(forgemap completion)"`.'
605
+ },
606
+ args: {
607
+ shell: {
608
+ type: "positional",
609
+ description: `Shell flavor (${SUPPORTED$1.join(", ")}). Auto-detected from $SHELL if omitted.`,
610
+ required: false
611
+ }
612
+ },
613
+ async run({ args }) {
614
+ const requested = args.shell ?? detectShell$1();
615
+ if (!SUPPORTED$1.includes(requested)) {
616
+ consola.error(
617
+ `Unsupported shell "${requested}". Supported: ${SUPPORTED$1.join(", ")}.`
618
+ );
619
+ process.exitCode = 1;
620
+ return;
621
+ }
622
+ const out = requested === "fish" ? renderFish$1() : requested === "zsh" ? renderZsh() : renderBash();
623
+ process.stdout.write(out);
624
+ }
625
+ });
285
626
  const TEMPLATE = `/**
286
627
  * forgemap configuration.
287
628
  *
@@ -459,39 +800,6 @@ const pathCommand = defineCommand({
459
800
  `);
460
801
  }
461
802
  });
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
803
  const pickCommand = defineCommand({
496
804
  meta: {
497
805
  name: "pick",
@@ -556,7 +864,7 @@ const pickCommand = defineCommand({
556
864
  }
557
865
  }
558
866
  });
559
- function renderTree(repos) {
867
+ function renderTree$1(repos) {
560
868
  const groups = /* @__PURE__ */ new Map();
561
869
  for (const r of repos) {
562
870
  const list = groups.get(r.forgeName);
@@ -625,7 +933,7 @@ const searchCommand = defineCommand({
625
933
  return;
626
934
  }
627
935
  if (format === "pretty") {
628
- process.stdout.write(`${renderTree(items)}
936
+ process.stdout.write(`${renderTree$1(items)}
629
937
  `);
630
938
  return;
631
939
  }
@@ -738,6 +1046,448 @@ const shellInitCommand = defineCommand({
738
1046
  process.stdout.write(out);
739
1047
  }
740
1048
  });
1049
+ async function gitIn(cwd, args) {
1050
+ return execCapture("git", args, { cwd });
1051
+ }
1052
+ async function getRepoStatus(localPath) {
1053
+ const status = {
1054
+ branch: "HEAD",
1055
+ detached: false,
1056
+ dirty: false,
1057
+ ahead: 0,
1058
+ behind: 0,
1059
+ lastCommit: null
1060
+ };
1061
+ const branchResult = await gitIn(localPath, ["branch", "--show-current"]);
1062
+ status.branch = branchResult.stdout.trim() || "HEAD";
1063
+ status.detached = !status.branch || status.branch === "HEAD";
1064
+ const porcelain = await gitIn(localPath, ["status", "--porcelain"]);
1065
+ status.dirty = porcelain.stdout.trim().length > 0;
1066
+ const aheadBehind = await gitIn(localPath, [
1067
+ "rev-list",
1068
+ "--left-right",
1069
+ "--count",
1070
+ "@{u}...HEAD"
1071
+ ]);
1072
+ if (aheadBehind.code === 0) {
1073
+ const match = aheadBehind.stdout.trim().match(/^(\d+)\s+(\d+)$/);
1074
+ if (match) {
1075
+ status.behind = Number(match[1]);
1076
+ status.ahead = Number(match[2]);
1077
+ }
1078
+ }
1079
+ const lastCommit = await gitIn(localPath, ["log", "-1", "--format=%h|%cr"]);
1080
+ if (lastCommit.code === 0) {
1081
+ const [sha, relativeDate] = lastCommit.stdout.trim().split("|");
1082
+ if (sha && relativeDate) {
1083
+ status.lastCommit = { sha, relativeDate };
1084
+ }
1085
+ }
1086
+ return status;
1087
+ }
1088
+ async function fetchRepo(localPath) {
1089
+ return gitIn(localPath, ["fetch", "--all", "--prune"]);
1090
+ }
1091
+ async function pullRepo(localPath) {
1092
+ return gitIn(localPath, ["pull", "--ff-only"]);
1093
+ }
1094
+ async function isClean(localPath) {
1095
+ const result = await gitIn(localPath, ["status", "--porcelain"]);
1096
+ return result.code === 0 && result.stdout.trim().length === 0;
1097
+ }
1098
+ function statusLine(row) {
1099
+ if (row.error || !row.status) {
1100
+ return `${colors.cyan(row.repo.slug)} ${colors.red(`error: ${row.error ?? "unknown"}`)}`;
1101
+ }
1102
+ const s = row.status;
1103
+ const parts = [colors.cyan(row.repo.slug)];
1104
+ const aheadBehind = [];
1105
+ if (s.ahead > 0) aheadBehind.push(colors.green(`↑${s.ahead}`));
1106
+ if (s.behind > 0) aheadBehind.push(colors.yellow(`↓${s.behind}`));
1107
+ if (aheadBehind.length > 0) parts.push(aheadBehind.join(" "));
1108
+ parts.push(s.dirty ? colors.red("●") : colors.green("✓"));
1109
+ parts.push(colors.gray(s.branch));
1110
+ if (s.lastCommit) {
1111
+ parts.push(colors.dim(`${s.lastCommit.sha} ${s.lastCommit.relativeDate}`));
1112
+ }
1113
+ return parts.join(" ");
1114
+ }
1115
+ function renderTree(rows) {
1116
+ const groups = /* @__PURE__ */ new Map();
1117
+ for (const row of rows) {
1118
+ const list = groups.get(row.repo.forgeName);
1119
+ if (list) list.push(row);
1120
+ else groups.set(row.repo.forgeName, [row]);
1121
+ }
1122
+ return formatTree(
1123
+ Array.from(groups, ([forge, items]) => ({
1124
+ text: colors.bold(forge),
1125
+ children: items.map((row) => ({ text: statusLine(row) }))
1126
+ }))
1127
+ );
1128
+ }
1129
+ const ALLOWED_FORMATS = ["pretty", "json"];
1130
+ const statusCommand = defineCommand({
1131
+ meta: {
1132
+ name: "status",
1133
+ description: "Show branch, dirty, ahead/behind, and last commit per repo"
1134
+ },
1135
+ args: {
1136
+ format: {
1137
+ type: "string",
1138
+ description: "Output format: pretty (default) or json",
1139
+ default: "pretty"
1140
+ },
1141
+ forge: {
1142
+ type: "string",
1143
+ description: "Restrict to a single forge alias"
1144
+ },
1145
+ query: {
1146
+ type: "string",
1147
+ description: "Fuzzy filter against <owner>/<repo>"
1148
+ },
1149
+ "no-cache": {
1150
+ type: "boolean",
1151
+ description: "Skip the scanned-repos cache",
1152
+ default: false
1153
+ },
1154
+ config: {
1155
+ type: "string",
1156
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1157
+ }
1158
+ },
1159
+ async run({ args }) {
1160
+ if (!ALLOWED_FORMATS.includes(args.format)) {
1161
+ consola.error(
1162
+ `Invalid --format value "${args.format}". Allowed: ${ALLOWED_FORMATS.join(", ")}.`
1163
+ );
1164
+ process.exitCode = 1;
1165
+ return;
1166
+ }
1167
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1168
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
1169
+ let repos = await scanReposCached({
1170
+ config: loaded.config,
1171
+ configDir,
1172
+ useCache: !args["no-cache"]
1173
+ });
1174
+ if (args.forge) {
1175
+ repos = repos.filter((r) => r.forgeName === args.forge);
1176
+ }
1177
+ if (args.query) {
1178
+ const fuse = new Fuse(repos, {
1179
+ keys: ["slug", "owner", "repo"],
1180
+ threshold: 0.3,
1181
+ ignoreLocation: true
1182
+ });
1183
+ repos = fuse.search(args.query).map((r) => r.item);
1184
+ }
1185
+ const rows = await Promise.all(
1186
+ repos.map(async (repo) => {
1187
+ try {
1188
+ return { repo, status: await getRepoStatus(repo.localPath) };
1189
+ } catch (error) {
1190
+ return { repo, status: null, error: error.message };
1191
+ }
1192
+ })
1193
+ );
1194
+ if (args.format === "json") {
1195
+ process.stdout.write(
1196
+ `${JSON.stringify(
1197
+ rows.map((r) => ({
1198
+ forge: r.repo.forgeName,
1199
+ owner: r.repo.owner,
1200
+ repo: r.repo.repo,
1201
+ localPath: r.repo.localPath,
1202
+ status: r.status,
1203
+ error: r.error ?? null
1204
+ })),
1205
+ null,
1206
+ 2
1207
+ )}
1208
+ `
1209
+ );
1210
+ return;
1211
+ }
1212
+ if (rows.length === 0) {
1213
+ consola.info("No repos to report on.");
1214
+ return;
1215
+ }
1216
+ process.stdout.write(`${renderTree(rows)}
1217
+ `);
1218
+ }
1219
+ });
1220
+ async function runWithConcurrency(items, limit, task) {
1221
+ const queue = [...items];
1222
+ const workers = Array.from(
1223
+ { length: Math.min(limit, queue.length) },
1224
+ async () => {
1225
+ while (queue.length > 0) {
1226
+ const next = queue.shift();
1227
+ if (!next) return;
1228
+ await task(next);
1229
+ }
1230
+ }
1231
+ );
1232
+ await Promise.all(workers);
1233
+ }
1234
+ const syncCommand = defineCommand({
1235
+ meta: {
1236
+ name: "sync",
1237
+ description: "Run git fetch (or --pull) across every cloned repo, in parallel"
1238
+ },
1239
+ args: {
1240
+ pull: {
1241
+ type: "boolean",
1242
+ description: "Pull --ff-only instead of fetch. Dirty working trees are skipped.",
1243
+ default: false
1244
+ },
1245
+ concurrency: {
1246
+ type: "string",
1247
+ description: "Number of parallel workers (default: 4)"
1248
+ },
1249
+ sequential: {
1250
+ type: "boolean",
1251
+ description: "Run one repo at a time (overrides --concurrency)",
1252
+ default: false
1253
+ },
1254
+ forge: {
1255
+ type: "string",
1256
+ description: "Restrict to a single forge alias"
1257
+ },
1258
+ query: {
1259
+ type: "string",
1260
+ description: "Fuzzy filter against <owner>/<repo>"
1261
+ },
1262
+ "no-cache": {
1263
+ type: "boolean",
1264
+ description: "Skip the scanned-repos cache",
1265
+ default: false
1266
+ },
1267
+ config: {
1268
+ type: "string",
1269
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1270
+ }
1271
+ },
1272
+ async run({ args }) {
1273
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1274
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
1275
+ let repos = await scanReposCached({
1276
+ config: loaded.config,
1277
+ configDir,
1278
+ useCache: !args["no-cache"]
1279
+ });
1280
+ if (args.forge) {
1281
+ repos = repos.filter((r) => r.forgeName === args.forge);
1282
+ }
1283
+ if (args.query) {
1284
+ const fuse = new Fuse(repos, {
1285
+ keys: ["slug", "owner", "repo"],
1286
+ threshold: 0.3,
1287
+ ignoreLocation: true
1288
+ });
1289
+ repos = fuse.search(args.query).map((r) => r.item);
1290
+ }
1291
+ if (repos.length === 0) {
1292
+ consola.info("Nothing to sync.");
1293
+ return;
1294
+ }
1295
+ const concurrency = args.sequential ? 1 : args.concurrency ? Math.max(1, Number.parseInt(args.concurrency, 10)) : 4;
1296
+ consola.info(
1297
+ `Syncing ${repos.length} repo(s) — ${args.pull ? "pull" : "fetch"}, concurrency ${concurrency}`
1298
+ );
1299
+ const outcomes = [];
1300
+ await runWithConcurrency(repos, concurrency, async (repo) => {
1301
+ try {
1302
+ if (args.pull && !await isClean(repo.localPath)) {
1303
+ outcomes.push({
1304
+ repo,
1305
+ status: "skipped",
1306
+ message: "dirty working tree"
1307
+ });
1308
+ consola.warn(`${colors.dim(repo.slug)} — skipped (dirty)`);
1309
+ return;
1310
+ }
1311
+ const result = args.pull ? await pullRepo(repo.localPath) : await fetchRepo(repo.localPath);
1312
+ if (result.code === 0) {
1313
+ outcomes.push({ repo, status: "synced" });
1314
+ consola.success(colors.dim(repo.slug));
1315
+ } else {
1316
+ outcomes.push({
1317
+ repo,
1318
+ status: "failed",
1319
+ message: (result.stderr || result.stdout).trim().split("\n")[0]
1320
+ });
1321
+ consola.fail(
1322
+ `${colors.dim(repo.slug)} — ${outcomes.at(-1)?.message}`
1323
+ );
1324
+ }
1325
+ } catch (error) {
1326
+ outcomes.push({
1327
+ repo,
1328
+ status: "failed",
1329
+ message: error.message
1330
+ });
1331
+ consola.fail(`${colors.dim(repo.slug)} — ${error.message}`);
1332
+ }
1333
+ });
1334
+ const synced = outcomes.filter((o) => o.status === "synced").length;
1335
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
1336
+ const failed = outcomes.filter((o) => o.status === "failed").length;
1337
+ consola.info(
1338
+ `Done — ${colors.green(`${synced} synced`)}, ${colors.yellow(`${skipped} skipped`)}, ${colors.red(`${failed} failed`)}`
1339
+ );
1340
+ if (failed > 0) {
1341
+ process.exitCode = 1;
1342
+ }
1343
+ }
1344
+ });
1345
+ const KNOWN_TYPES = /* @__PURE__ */ new Set(["github", "gitlab", "gitea", "codeberg", "git"]);
1346
+ function validateForge(name, forge) {
1347
+ if (!KNOWN_TYPES.has(forge.type)) {
1348
+ return {
1349
+ name: `forge "${name}"`,
1350
+ severity: "fail",
1351
+ message: `unknown type "${forge.type}"`
1352
+ };
1353
+ }
1354
+ if (!forge.host?.trim()) {
1355
+ return {
1356
+ name: `forge "${name}"`,
1357
+ severity: "fail",
1358
+ message: "host is empty"
1359
+ };
1360
+ }
1361
+ if (!forge.dir?.trim()) {
1362
+ return {
1363
+ name: `forge "${name}"`,
1364
+ severity: "fail",
1365
+ message: "dir is empty"
1366
+ };
1367
+ }
1368
+ return {
1369
+ name: `forge "${name}"`,
1370
+ severity: "ok",
1371
+ message: `${forge.type} at ${forge.host}`
1372
+ };
1373
+ }
1374
+ async function runChecks(config, configDir) {
1375
+ const checks = [];
1376
+ for (const [name, forge] of Object.entries(config.forges)) {
1377
+ checks.push(validateForge(name, forge));
1378
+ }
1379
+ checks.push(
1380
+ config.forges[config.defaultForge] ? {
1381
+ name: "defaultForge",
1382
+ severity: "ok",
1383
+ message: `→ ${config.defaultForge}`
1384
+ } : {
1385
+ name: "defaultForge",
1386
+ severity: "fail",
1387
+ message: `"${config.defaultForge}" is not in forges`
1388
+ }
1389
+ );
1390
+ const root = resolveRoot(config.root, configDir);
1391
+ try {
1392
+ await access(root);
1393
+ checks.push({
1394
+ name: "root directory",
1395
+ severity: "ok",
1396
+ message: root
1397
+ });
1398
+ } catch {
1399
+ checks.push({
1400
+ name: "root directory",
1401
+ severity: "fail",
1402
+ message: `${root} does not exist (mkdir -p it or fix root in config)`
1403
+ });
1404
+ }
1405
+ const types = new Set(Object.values(config.forges).map((f) => f.type));
1406
+ const needsGit = types.has("git") || types.size > 0;
1407
+ const needsGh = types.has("github");
1408
+ if (needsGit) {
1409
+ checks.push(
1410
+ await hasCommand("git") ? { name: "git CLI", severity: "ok", message: "on PATH" } : {
1411
+ name: "git CLI",
1412
+ severity: "fail",
1413
+ message: "install from https://git-scm.com/"
1414
+ }
1415
+ );
1416
+ }
1417
+ if (needsGh) {
1418
+ if (await hasCommand("gh")) {
1419
+ checks.push({ name: "gh CLI", severity: "ok", message: "on PATH" });
1420
+ const auth = await execCapture("gh", ["auth", "status"]);
1421
+ checks.push(
1422
+ auth.code === 0 ? { name: "gh auth", severity: "ok", message: "authenticated" } : {
1423
+ name: "gh auth",
1424
+ severity: "warn",
1425
+ message: "not logged in — run `gh auth login`"
1426
+ }
1427
+ );
1428
+ } else {
1429
+ checks.push({
1430
+ name: "gh CLI",
1431
+ severity: "fail",
1432
+ message: "install from https://cli.github.com/"
1433
+ });
1434
+ }
1435
+ }
1436
+ return checks;
1437
+ }
1438
+ function severitySymbol(severity) {
1439
+ if (severity === "ok") return colors.green("✓");
1440
+ if (severity === "warn") return colors.yellow("!");
1441
+ return colors.red("✗");
1442
+ }
1443
+ const validateCommand = defineCommand({
1444
+ meta: {
1445
+ name: "validate",
1446
+ description: "Preflight: check the config schema, required CLI tools, and root directory"
1447
+ },
1448
+ args: {
1449
+ json: {
1450
+ type: "boolean",
1451
+ description: "Emit a machine-readable JSON report",
1452
+ default: false
1453
+ },
1454
+ config: {
1455
+ type: "string",
1456
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1457
+ }
1458
+ },
1459
+ async run({ args }) {
1460
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1461
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
1462
+ const checks = await runChecks(loaded.config, configDir);
1463
+ const ok = checks.every((c) => c.severity !== "fail");
1464
+ if (args.json) {
1465
+ process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}
1466
+ `);
1467
+ } else {
1468
+ for (const c of checks) {
1469
+ process.stdout.write(
1470
+ `${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}
1471
+ `
1472
+ );
1473
+ }
1474
+ process.stdout.write(
1475
+ `
1476
+ ${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}
1477
+ `
1478
+ );
1479
+ }
1480
+ if (!ok) {
1481
+ process.exitCode = 1;
1482
+ return;
1483
+ }
1484
+ if (!loaded.configFile) {
1485
+ consola.warn(
1486
+ "No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one."
1487
+ );
1488
+ }
1489
+ }
1490
+ });
741
1491
  const rootCommand = defineCommand({
742
1492
  meta: {
743
1493
  name: "forgemap",
@@ -750,6 +1500,10 @@ const rootCommand = defineCommand({
750
1500
  open: openCommand,
751
1501
  search: searchCommand,
752
1502
  pick: pickCommand,
1503
+ status: statusCommand,
1504
+ sync: syncCommand,
1505
+ validate: validateCommand,
1506
+ completion: completionCommand,
753
1507
  "shell-init": shellInitCommand,
754
1508
  config: configCommand
755
1509
  }