forgemap 0.5.0 → 0.6.0-dev-main.107-882ecab

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.
@@ -3,12 +3,13 @@ import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
4
  import { access, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
5
5
  import { colors, formatTree } from "consola/utils";
6
- import { dirname, isAbsolute, join, resolve } from "pathe";
6
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "pathe";
7
7
  import { homedir } from "node:os";
8
8
  import { existsSync, readFileSync, realpathSync } from "node:fs";
9
9
  import { loadConfig } from "c12";
10
10
  import { createHash } from "node:crypto";
11
11
  import { spawn } from "node:child_process";
12
+ import { updateConfig } from "c12/update";
12
13
  import Fuse from "fuse.js";
13
14
  //#region src/commands/cd.ts
14
15
  /**
@@ -81,6 +82,39 @@ function findGlobalConfig() {
81
82
  if (existsSync(candidate)) return candidate;
82
83
  }
83
84
  }
85
+ /**
86
+ * Every `forgemap.config.*` a change could be written to, in resolution order:
87
+ * one per directory walking up from `start` (nearest first, mirroring the
88
+ * loader's first-basename-wins rule), then the global config. Used by the
89
+ * `forge` command to let the user pick a target when more than one exists.
90
+ */
91
+ function discoverConfigFiles(start = process.cwd()) {
92
+ const found = [];
93
+ const seen = /* @__PURE__ */ new Set();
94
+ let dir = resolve(start);
95
+ for (;;) {
96
+ for (const base of CONFIG_BASENAMES) {
97
+ const candidate = join(dir, base);
98
+ if (existsSync(candidate)) {
99
+ seen.add(candidate);
100
+ found.push({
101
+ path: candidate,
102
+ source: "walk-up"
103
+ });
104
+ break;
105
+ }
106
+ }
107
+ const parent = dirname(dir);
108
+ if (parent === dir) break;
109
+ dir = parent;
110
+ }
111
+ const global = findGlobalConfig();
112
+ if (global && !seen.has(global)) found.push({
113
+ path: global,
114
+ source: "global"
115
+ });
116
+ return found;
117
+ }
84
118
  var DEFAULT_CONFIG$1 = {
85
119
  root: ".",
86
120
  defaultForge: "github",
@@ -117,6 +151,7 @@ async function loadForgeMapConfig(options = {}) {
117
151
  }
118
152
  }
119
153
  }
154
+ if (explicit) explicit = resolve(startDir, explicit);
120
155
  const cwd = explicit ? dirname(explicit) : startDir;
121
156
  const { config, configFile } = await loadConfig({
122
157
  name: "forgemap",
@@ -204,10 +239,17 @@ async function safeListDirs(path) {
204
239
  }
205
240
  }
206
241
  /**
207
- * Fingerprint of every directory mtime down to depth 3 (root, forge.dir,
208
- * owner). Catches new clones / removals at any of those levels. Stops
209
- * short of stat-ing each repo dir — that would mostly duplicate the
210
- * scan it's meant to avoid.
242
+ * Fingerprint of the layout: directory mtimes for root, forge.dir and
243
+ * every owner, plus the repo names each owner holds. Catches new clones
244
+ * / removals at any of those levels. Stops short of stat-ing each repo
245
+ * dir — that would mostly duplicate the scan it's meant to avoid.
246
+ *
247
+ * The repo names are what make the check reliable. An owner's mtime
248
+ * alone is not enough: a repo cloned beside an existing one moves only
249
+ * that mtime, and mtimes are compared at millisecond granularity, so
250
+ * two clones landing inside the same millisecond hash identically and
251
+ * a stale cache wins. Listing the names makes invalidation independent
252
+ * of clock and filesystem timestamp resolution.
211
253
  *
212
254
  * Stats are issued in parallel: one batch per forge for its forge.dir +
213
255
  * owner list, all forges in parallel. Beats the sequential version by
@@ -220,14 +262,15 @@ async function computeFingerprint(config, configDir) {
220
262
  const [forgeMtime, owners] = await Promise.all([safeStat(forgeRoot), safeListDirs(forgeRoot)]);
221
263
  const ownerEntries = await Promise.all(owners.map(async (owner) => {
222
264
  const ownerPath = join(forgeRoot, owner);
223
- return [ownerPath, await safeStat(ownerPath)];
265
+ const [mtime, repos] = await Promise.all([safeStat(ownerPath), safeListDirs(ownerPath)]);
266
+ return [ownerPath, `${mtime}:${JSON.stringify(repos.sort())}`];
224
267
  }));
225
- return [[forgeRoot, forgeMtime], ...ownerEntries];
268
+ return [[forgeRoot, String(forgeMtime)], ...ownerEntries];
226
269
  }));
227
- const entries = [[root, await safeStat(root)]];
270
+ const entries = [[root, String(await safeStat(root))]];
228
271
  for (const group of perForge) entries.push(...group);
229
272
  entries.sort((a, b) => a[0].localeCompare(b[0]));
230
- return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
273
+ return createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex");
231
274
  }
232
275
  async function readCacheFile(file) {
233
276
  try {
@@ -1335,146 +1378,6 @@ async function installRcBlock(shell, label, lines, legacyLabels = []) {
1335
1378
  };
1336
1379
  }
1337
1380
  //#endregion
1338
- //#region src/commands/completion.ts
1339
- var SUBCOMMANDS = [
1340
- "clone",
1341
- "import",
1342
- "cleanup",
1343
- "delete",
1344
- "cd",
1345
- "path",
1346
- "open",
1347
- "search",
1348
- "pick",
1349
- "status",
1350
- "sync",
1351
- "validate",
1352
- "shell-init",
1353
- "completion",
1354
- "config"
1355
- ];
1356
- var SLUG_COMMANDS = [
1357
- "clone",
1358
- "cd",
1359
- "path",
1360
- "open",
1361
- "search",
1362
- "pick",
1363
- "delete"
1364
- ];
1365
- function renderBash() {
1366
- return `# forgemap bash completion — drop into your ~/.bashrc:
1367
- # eval "$(forgemap completion bash)"
1368
- _forgemap_completion() {
1369
- local cur prev cmd words
1370
- COMPREPLY=()
1371
- cur="\${COMP_WORDS[COMP_CWORD]}"
1372
- cmd="\${COMP_WORDS[1]}"
1373
-
1374
- if [ "$COMP_CWORD" = "1" ]; then
1375
- COMPREPLY=( $(compgen -W "${SUBCOMMANDS.join(" ")}" -- "$cur") )
1376
- return
1377
- fi
1378
-
1379
- case "$cmd" in
1380
- ${SLUG_COMMANDS.join("|")})
1381
- local slugs
1382
- slugs=$(forgemap search '' --format slug 2>/dev/null)
1383
- COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
1384
- ;;
1385
- esac
1386
- }
1387
- complete -F _forgemap_completion forgemap
1388
- `;
1389
- }
1390
- function renderZsh() {
1391
- return `# forgemap zsh completion — drop into your ~/.zshrc:
1392
- # eval "$(forgemap completion zsh)"
1393
- _forgemap() {
1394
- local context state line
1395
- local -a subcommands slug_cmds
1396
- subcommands=(${SUBCOMMANDS.map((s) => `'${s}'`).join(" ")})
1397
- slug_cmds=(${SLUG_COMMANDS.map((s) => `'${s}'`).join(" ")})
1398
-
1399
- _arguments -C \\
1400
- '1: :->cmd' \\
1401
- '*::arg:->args'
1402
-
1403
- case "$state" in
1404
- cmd) _describe 'forgemap subcommand' subcommands ;;
1405
- args)
1406
- if (( $slug_cmds[(I)$words[1]] )); then
1407
- local -a slugs
1408
- slugs=("\${(@f)$(forgemap search '' --format slug 2>/dev/null)}")
1409
- _describe 'slug' slugs
1410
- fi
1411
- ;;
1412
- esac
1413
- }
1414
- compdef _forgemap forgemap
1415
- `;
1416
- }
1417
- function renderFish$1() {
1418
- const slugCmdsList = SLUG_COMMANDS.map((s) => `"${s}"`).join(" ");
1419
- return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
1420
- # forgemap completion fish | source
1421
-
1422
- # Subcommands (depth 1).
1423
- complete -c forgemap -f -n '__fish_use_subcommand' -a '${SUBCOMMANDS.join(" ")}'
1424
-
1425
- # Slugs (depth 2) for commands that take one.
1426
- function __forgemap_needs_slug
1427
- set -l tokens (commandline -opc)
1428
- set -l slug_cmds ${slugCmdsList}
1429
- if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
1430
- return 0
1431
- end
1432
- return 1
1433
- end
1434
-
1435
- complete -c forgemap -f -n '__forgemap_needs_slug' \\
1436
- -a '(forgemap search "" --format slug 2>/dev/null)'
1437
- `;
1438
- }
1439
- var completionCommand = defineCommand({
1440
- meta: {
1441
- name: "completion",
1442
- description: "Print a shell completion script. Source via `eval \"$(forgemap completion)\"`."
1443
- },
1444
- args: {
1445
- shell: {
1446
- type: "positional",
1447
- description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
1448
- required: false
1449
- },
1450
- install: {
1451
- type: "boolean",
1452
- description: "Append the completion loader to your shell's rc file (idempotent) instead of printing",
1453
- default: false
1454
- }
1455
- },
1456
- async run({ args }) {
1457
- const requested = args.shell ?? detectShell();
1458
- if (!SUPPORTED_SHELLS.includes(requested)) {
1459
- consola.error(`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`);
1460
- process.exitCode = 1;
1461
- return;
1462
- }
1463
- if (args.install) {
1464
- const { status, rcFile } = await installRcBlock(requested, "completion", [requested === "fish" ? "forgemap completion fish | source" : `eval "$(forgemap completion ${requested})"`]);
1465
- if (status === "present") consola.info(`forgemap completion already present in ${rcFile}.`);
1466
- else {
1467
- const verb = status === "updated" ? "Updated" : "Added";
1468
- consola.success(`${verb} forgemap completion in ${rcFile}.`);
1469
- consola.info(`Run \`source ${rcFile}\` or restart your shell to activate it.`);
1470
- }
1471
- return;
1472
- }
1473
- const out = requested === "fish" ? renderFish$1() : requested === "zsh" ? renderZsh() : renderBash();
1474
- process.stdout.write(out);
1475
- }
1476
- });
1477
- //#endregion
1478
1381
  //#region src/config/write.ts
1479
1382
  var HEADER = `/**
1480
1383
  * forgemap configuration.
@@ -1729,65 +1632,686 @@ var deleteCommand = defineCommand({
1729
1632
  }
1730
1633
  });
1731
1634
  //#endregion
1732
- //#region src/repos/import.ts
1733
- async function listDirs(path) {
1734
- try {
1735
- return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
1736
- } catch (error) {
1737
- if (error.code === "ENOENT") return [];
1738
- throw error;
1635
+ //#region src/config/forges.ts
1636
+ /** Every forge `type` the config schema accepts, in prompt/display order. */
1637
+ var FORGE_TYPES = [
1638
+ "github",
1639
+ "gitlab",
1640
+ "gitea",
1641
+ "codeberg",
1642
+ "git"
1643
+ ];
1644
+ /** Canonical host per forge type, offered as the host prompt's default. The
1645
+ * self-hosted flavors (`gitea`, plain `git`) have no universal host, so none
1646
+ * is suggested for them. */
1647
+ var DEFAULT_HOSTS = {
1648
+ github: "github.com",
1649
+ gitlab: "gitlab.com",
1650
+ codeberg: "codeberg.org"
1651
+ };
1652
+ /** Git clone protocols, in prompt order (`ssh` is the schema default). */
1653
+ var GIT_PROTOCOLS = ["ssh", "https"];
1654
+ /** Reject empty / whitespace-only keys; any other string is a valid map key.
1655
+ * Returns an error message, or `null` when the key is acceptable. */
1656
+ function validateForgeKey(raw) {
1657
+ if (raw.trim().length === 0) return "Forge key must not be empty.";
1658
+ return null;
1659
+ }
1660
+ /** Whether `value` is one of the schema's forge types (narrows a raw flag). */
1661
+ function isForgeType(value) {
1662
+ return FORGE_TYPES.includes(value);
1663
+ }
1664
+ /** Whether `value` is a supported git protocol. */
1665
+ function isGitProtocol(value) {
1666
+ return GIT_PROTOCOLS.includes(value);
1667
+ }
1668
+ /** Build a `ForgeConfig` from collected input, keeping `protocol` only when it
1669
+ * is the non-default (`https`) git protocol. */
1670
+ function buildForge(input) {
1671
+ if (input.type === "git") {
1672
+ const forge = {
1673
+ type: "git",
1674
+ host: input.host,
1675
+ dir: input.dir
1676
+ };
1677
+ if (input.protocol === "https") forge.protocol = "https";
1678
+ return forge;
1739
1679
  }
1680
+ return {
1681
+ type: input.type,
1682
+ host: input.host,
1683
+ dir: input.dir
1684
+ };
1685
+ }
1686
+ function addForge(config, key, forge) {
1687
+ if (!config.forges) config.forges = {};
1688
+ config.forges[key] = forge;
1689
+ }
1690
+ function removeForge(config, key) {
1691
+ if (config.forges) delete config.forges[key];
1740
1692
  }
1693
+ function setDefaultForge(config, key) {
1694
+ config.defaultForge = key;
1695
+ }
1696
+ /** Apply a partial change to an existing forge in place. Clears `protocol`
1697
+ * whenever the resulting type is not `git`, since it is meaningless there. */
1698
+ function editForge(config, key, patch) {
1699
+ const forge = config.forges?.[key];
1700
+ if (!forge) return;
1701
+ if (patch.type !== void 0) forge.type = patch.type;
1702
+ if (patch.host !== void 0) forge.host = patch.host;
1703
+ if (patch.dir !== void 0) forge.dir = patch.dir;
1704
+ if (forge.type !== "git") delete forge.protocol;
1705
+ else if (patch.protocol === null) delete forge.protocol;
1706
+ else if (patch.protocol !== void 0) forge.protocol = patch.protocol;
1707
+ }
1708
+ //#endregion
1709
+ //#region src/config/mutate.ts
1741
1710
  /**
1742
- * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
1743
- * Unlike `scanRepos`, this is config-free: every top-level directory is a
1744
- * candidate server dir, and the names are discovered rather than configured.
1711
+ * Apply an in-place mutation to a `forgemap.config.*` file, preserving its
1712
+ * formatting and comments.
1713
+ *
1714
+ * `.ts`/`.mts`/`.js`/… are round-tripped through c12's `updateConfig`, which
1715
+ * parses the module with magicast and edits the exported object literal — it
1716
+ * transparently unwraps a `defineForgeMapConfig(...)` call. Plain `.json`
1717
+ * configs, which magicast/updateConfig refuse, are read, mutated and written
1718
+ * back directly.
1719
+ *
1720
+ * Rejects when the source can't be edited safely (e.g. forges built dynamically
1721
+ * rather than declared as a literal); callers surface that as a manual-edit
1722
+ * fallback rather than crashing.
1745
1723
  */
1746
- async function discoverForgemapLayout(path) {
1747
- const repos = [];
1748
- for (const serverDir of await listDirs(path)) {
1749
- const serverPath = join(path, serverDir);
1750
- for (const owner of await listDirs(serverPath)) {
1751
- const ownerPath = join(serverPath, owner);
1752
- for (const repo of await listDirs(ownerPath)) repos.push({
1753
- serverDir,
1754
- owner,
1755
- repo,
1756
- localPath: join(ownerPath, repo)
1757
- });
1724
+ async function mutateConfigFile(path, mutate) {
1725
+ if (extname(path) === ".json") {
1726
+ const current = JSON.parse(await readFile(path, "utf8"));
1727
+ mutate(current);
1728
+ await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf8");
1729
+ return;
1730
+ }
1731
+ await updateConfig({
1732
+ cwd: dirname(path),
1733
+ configFile: "forgemap.config",
1734
+ onUpdate: (config) => {
1735
+ mutate(config);
1758
1736
  }
1737
+ });
1738
+ }
1739
+ //#endregion
1740
+ //#region src/repos/picker.ts
1741
+ /**
1742
+ * Show the interactive repo picker and return the chosen local path
1743
+ * (undefined when the user cancels).
1744
+ *
1745
+ * `$(forgemap pick)` / `$(forgemap path <q>)` captures stdout, so the
1746
+ * interactive TUI must not go there. consola/clack writes the UI to stdout AND
1747
+ * reads stdout.rows/columns for layout — but a captured stdout is a pipe (no
1748
+ * rows → nothing renders). So for the duration of the prompt: route stdout
1749
+ * writes to stderr (the real TTY) and borrow stderr's dimensions, then
1750
+ * restore. stdout stays clean for the chosen path only.
1751
+ *
1752
+ * Callers must check {@link canPrompt} first — without a TTY on stdin there is
1753
+ * nobody to answer.
1754
+ */
1755
+ async function promptRepoChoice(candidates) {
1756
+ const out = process.stdout;
1757
+ const realWrite = out.write;
1758
+ const saved = {
1759
+ rows: Object.getOwnPropertyDescriptor(out, "rows"),
1760
+ columns: Object.getOwnPropertyDescriptor(out, "columns"),
1761
+ isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
1762
+ };
1763
+ const fake = (key, value) => {
1764
+ Object.defineProperty(out, key, {
1765
+ configurable: true,
1766
+ value
1767
+ });
1768
+ };
1769
+ const restore = (key) => {
1770
+ if (saved[key]) Object.defineProperty(out, key, saved[key]);
1771
+ else delete out[key];
1772
+ };
1773
+ out.write = process.stderr.write.bind(process.stderr);
1774
+ fake("rows", process.stderr.rows ?? 24);
1775
+ fake("columns", process.stderr.columns ?? 80);
1776
+ fake("isTTY", true);
1777
+ let choice;
1778
+ try {
1779
+ choice = await consola.prompt("Select a repo", {
1780
+ type: "select",
1781
+ options: candidates.map((r) => ({
1782
+ label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
1783
+ value: r.localPath,
1784
+ hint: r.localPath
1785
+ }))
1786
+ });
1787
+ } finally {
1788
+ out.write = realWrite;
1789
+ restore("rows");
1790
+ restore("columns");
1791
+ restore("isTTY");
1759
1792
  }
1760
- return repos;
1793
+ return typeof choice === "string" && choice ? choice : void 0;
1761
1794
  }
1762
- function forgeTypeForHost(host) {
1763
- return host === "github.com" ? "github" : "git";
1795
+ /** Whether an interactive prompt can be shown at all. */
1796
+ function canPrompt() {
1797
+ return Boolean(process.stdin.isTTY);
1798
+ }
1799
+ //#endregion
1800
+ //#region src/commands/forge/shared.ts
1801
+ /** Whether interactive prompts can be shown (a TTY on stdin to answer them). */
1802
+ function interactive() {
1803
+ return canPrompt();
1804
+ }
1805
+ /** Prompt for free text. Returns the raw string, or `null` when cancelled. */
1806
+ async function promptText(message, placeholder) {
1807
+ const answer = await consola.prompt(message, {
1808
+ type: "text",
1809
+ placeholder,
1810
+ cancel: "null"
1811
+ });
1812
+ return typeof answer === "string" ? answer : null;
1813
+ }
1814
+ /** Prompt to pick one of `options`. Returns the value, or `null` when cancelled. */
1815
+ async function promptSelect(message, options) {
1816
+ const answer = await consola.prompt(message, {
1817
+ type: "select",
1818
+ options: [...options],
1819
+ cancel: "null"
1820
+ });
1821
+ return typeof answer === "string" && answer ? answer : null;
1822
+ }
1823
+ /** Yes/no confirmation. Returns `false` when declined or cancelled. */
1824
+ async function confirmChange(message) {
1825
+ return await consola.prompt(message, {
1826
+ type: "confirm",
1827
+ cancel: "null"
1828
+ }) === true;
1764
1829
  }
1765
1830
  /**
1766
- * Derive a `root` + one forge per server dir from the analyzed reports.
1767
- * Host (and therefore type) come from the dominant origin host of the repos
1768
- * under each server dir.
1831
+ * Choose which config file `add` writes to.
1832
+ * - `--config <path>` always wins (created when it does not exist).
1833
+ * - otherwise discover candidates: one → use it; several with a TTY → present a
1834
+ * select (the final step before confirming); several without a TTY → nearest.
1835
+ * - nothing discovered → a fresh `forgemap.config.ts` in the cwd.
1836
+ *
1837
+ * Returns `null` when the user cancels the select.
1769
1838
  */
1770
- function deriveConfig(reports, path) {
1771
- const forges = {};
1772
- const counts = /* @__PURE__ */ new Map();
1773
- const byServer = /* @__PURE__ */ new Map();
1774
- for (const report of reports) {
1775
- const list = byServer.get(report.repo.serverDir);
1776
- if (list) list.push(report);
1777
- else byServer.set(report.repo.serverDir, [report]);
1778
- }
1779
- for (const [serverDir, group] of byServer) {
1780
- counts.set(serverDir, group.length);
1781
- const hostTally = /* @__PURE__ */ new Map();
1782
- for (const report of group) if (report.originHost) hostTally.set(report.originHost, (hostTally.get(report.originHost) ?? 0) + 1);
1783
- const host = [...hostTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
1784
- forges[serverDir] = {
1785
- type: host ? forgeTypeForHost(host) : "git",
1786
- host,
1787
- dir: serverDir
1839
+ async function resolveAddTarget(explicit) {
1840
+ if (explicit) {
1841
+ const path = resolve(process.cwd(), explicit);
1842
+ return {
1843
+ path,
1844
+ create: !existsSync(path)
1788
1845
  };
1789
1846
  }
1790
- return {
1847
+ const candidates = discoverConfigFiles();
1848
+ if (candidates.length === 0) return {
1849
+ path: join(process.cwd(), "forgemap.config.ts"),
1850
+ create: true
1851
+ };
1852
+ if (candidates.length === 1 || !interactive()) return {
1853
+ path: candidates[0].path,
1854
+ create: false
1855
+ };
1856
+ const choice = await consola.prompt("Which config file should this change be written to?", {
1857
+ type: "select",
1858
+ options: candidates.map((c) => ({
1859
+ label: relative(process.cwd(), c.path) || c.path,
1860
+ value: c.path,
1861
+ hint: c.source
1862
+ })),
1863
+ cancel: "null"
1864
+ });
1865
+ if (typeof choice !== "string" || !choice) {
1866
+ consola.info("Aborted — nothing changed.");
1867
+ return null;
1868
+ }
1869
+ return {
1870
+ path: choice,
1871
+ create: false
1872
+ };
1873
+ }
1874
+ /**
1875
+ * The config file `edit`/`remove` operate on — the forge already lives in a real
1876
+ * file, so `--config` or the resolved config file is used. Prints an error and
1877
+ * returns `null` when only the built-in defaults are in effect (no file).
1878
+ */
1879
+ function existingConfigFile(loaded, explicit) {
1880
+ if (explicit) return resolve(process.cwd(), explicit);
1881
+ if (!loaded.configFile) {
1882
+ consola.error("No forgemap config file found. Run `forgemap config init` or `forgemap forge add` first.");
1883
+ return null;
1884
+ }
1885
+ return loaded.configFile;
1886
+ }
1887
+ /**
1888
+ * Round-trip `mutate` into `path`; on failure (a config too dynamic to rewrite)
1889
+ * report it and print the change for manual application instead of crashing.
1890
+ * Returns whether the file was updated.
1891
+ */
1892
+ async function applyChange(path, mutate, manualHint) {
1893
+ try {
1894
+ await mutateConfigFile(path, mutate);
1895
+ return true;
1896
+ } catch (error) {
1897
+ consola.error(`Could not update ${path} automatically: ${error.message}`);
1898
+ consola.info("Apply this change by hand instead:");
1899
+ manualHint();
1900
+ return false;
1901
+ }
1902
+ }
1903
+ /** Print a forge as a `forgemap.config` block (the manual-edit fallback). */
1904
+ function printManualForge(key, forge) {
1905
+ consola.log(` ${key}: {`);
1906
+ consola.log(` type: '${forge.type}',`);
1907
+ consola.log(` host: '${forge.host}',`);
1908
+ consola.log(` dir: '${forge.dir}'${forge.protocol ? "," : ""}`);
1909
+ if (forge.protocol) consola.log(` protocol: '${forge.protocol}'`);
1910
+ consola.log(" }");
1911
+ }
1912
+ //#endregion
1913
+ //#region src/commands/forge/add.ts
1914
+ var forgeAddCommand = defineCommand({
1915
+ meta: {
1916
+ name: "add",
1917
+ description: "Add a forge to the config (prompts for anything not passed)"
1918
+ },
1919
+ args: {
1920
+ key: {
1921
+ type: "positional",
1922
+ required: false,
1923
+ description: "Forge key, e.g. github or work"
1924
+ },
1925
+ type: {
1926
+ type: "string",
1927
+ description: `Forge type (${FORGE_TYPES.join(", ")})`
1928
+ },
1929
+ host: {
1930
+ type: "string",
1931
+ description: "Forge host, e.g. github.com"
1932
+ },
1933
+ dir: {
1934
+ type: "string",
1935
+ description: "Directory under root, e.g. comGithub"
1936
+ },
1937
+ protocol: {
1938
+ type: "string",
1939
+ description: `Clone protocol for type=git (${GIT_PROTOCOLS.join(", ")})`
1940
+ },
1941
+ default: {
1942
+ type: "boolean",
1943
+ description: "Set this forge as the default",
1944
+ default: false
1945
+ },
1946
+ config: {
1947
+ type: "string",
1948
+ description: "Path to the forgemap config file to modify"
1949
+ },
1950
+ yes: {
1951
+ type: "boolean",
1952
+ description: "Skip the confirmation prompt",
1953
+ default: false
1954
+ }
1955
+ },
1956
+ async run({ args }) {
1957
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1958
+ const tty = interactive();
1959
+ let key = typeof args.key === "string" ? args.key.trim() : "";
1960
+ if (!key && tty) {
1961
+ const answer = await promptText("Forge key (e.g. github, work):");
1962
+ if (answer === null) return abort$2();
1963
+ key = answer.trim();
1964
+ }
1965
+ const keyError = validateForgeKey(key);
1966
+ if (keyError) return fail$2(keyError);
1967
+ if (loaded.configFile && key in loaded.config.forges) return fail$2(`Forge "${key}" already exists. Use \`forgemap forge edit ${key}\` to change it.`);
1968
+ let type;
1969
+ if (typeof args.type === "string") {
1970
+ if (!isForgeType(args.type)) return fail$2(invalidType$1(args.type));
1971
+ type = args.type;
1972
+ } else if (tty) {
1973
+ const answer = await promptSelect("Forge type:", FORGE_TYPES);
1974
+ if (answer === null || !isForgeType(answer)) return abort$2();
1975
+ type = answer;
1976
+ }
1977
+ if (!type) return fail$2("Missing forge type. Pass --type.");
1978
+ const suggestedHost = DEFAULT_HOSTS[type] ?? "";
1979
+ let host = typeof args.host === "string" ? args.host.trim() : "";
1980
+ if (!host && tty) {
1981
+ const answer = await promptText("Host:", suggestedHost);
1982
+ if (answer === null) return abort$2();
1983
+ host = answer.trim() || suggestedHost;
1984
+ } else if (!host) host = suggestedHost;
1985
+ if (!host) return fail$2("Missing host. Pass --host.");
1986
+ let dir = typeof args.dir === "string" ? args.dir.trim() : "";
1987
+ if (!dir && tty) {
1988
+ const answer = await promptText("Directory (under root):");
1989
+ if (answer === null) return abort$2();
1990
+ dir = answer.trim();
1991
+ }
1992
+ if (!dir) return fail$2("Missing directory. Pass --dir.");
1993
+ let protocol;
1994
+ if (type === "git") {
1995
+ if (typeof args.protocol === "string") {
1996
+ if (!isGitProtocol(args.protocol)) return fail$2(invalidProtocol$1(args.protocol));
1997
+ protocol = args.protocol;
1998
+ } else if (tty) {
1999
+ const answer = await promptSelect("Clone protocol:", GIT_PROTOCOLS);
2000
+ if (answer !== null && isGitProtocol(answer)) protocol = answer;
2001
+ }
2002
+ }
2003
+ let makeDefault = args.default === true;
2004
+ if (!loaded.configFile) makeDefault = true;
2005
+ else if (!makeDefault && tty) makeDefault = await confirmChange(`Set "${key}" as the default forge?`);
2006
+ const forge = buildForge({
2007
+ type,
2008
+ host,
2009
+ dir,
2010
+ protocol
2011
+ });
2012
+ const target = await resolveAddTarget(args.config);
2013
+ if (!target) return;
2014
+ consola.info(`Add forge "${key}" (${type} → ${host}) into ${target.create ? "new " : ""}${target.path}`);
2015
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort$2();
2016
+ if (target.create) {
2017
+ const written = await writeConfigFile({
2018
+ root: loaded.config.root,
2019
+ defaultForge: key,
2020
+ forges: { [key]: forge }
2021
+ }, { outDir: dirname(target.path) });
2022
+ if (!written) return fail$2(`${target.path} already exists.`);
2023
+ consola.success(`Added forge "${key}" — wrote ${written.path}`);
2024
+ return;
2025
+ }
2026
+ if (await applyChange(target.path, (c) => {
2027
+ addForge(c, key, forge);
2028
+ if (makeDefault) setDefaultForge(c, key);
2029
+ }, () => printManualForge(key, forge))) consola.success(`Added forge "${key}" to ${target.path}`);
2030
+ else process.exitCode = 1;
2031
+ }
2032
+ });
2033
+ function fail$2(message) {
2034
+ consola.error(message);
2035
+ process.exitCode = 1;
2036
+ }
2037
+ function abort$2() {
2038
+ consola.info("Aborted — nothing changed.");
2039
+ }
2040
+ function invalidType$1(value) {
2041
+ return `Invalid type "${value}". Expected one of: ${FORGE_TYPES.join(", ")}.`;
2042
+ }
2043
+ function invalidProtocol$1(value) {
2044
+ return `Invalid protocol "${value}". Expected one of: ${GIT_PROTOCOLS.join(", ")}.`;
2045
+ }
2046
+ //#endregion
2047
+ //#region src/commands/forge/edit.ts
2048
+ var forgeEditCommand = defineCommand({
2049
+ meta: {
2050
+ name: "edit",
2051
+ description: "Edit an existing forge (prompts for fields when none are passed)"
2052
+ },
2053
+ args: {
2054
+ key: {
2055
+ type: "positional",
2056
+ required: false,
2057
+ description: "Forge key to edit"
2058
+ },
2059
+ type: {
2060
+ type: "string",
2061
+ description: `New forge type (${FORGE_TYPES.join(", ")})`
2062
+ },
2063
+ host: {
2064
+ type: "string",
2065
+ description: "New host"
2066
+ },
2067
+ dir: {
2068
+ type: "string",
2069
+ description: "New directory under root"
2070
+ },
2071
+ protocol: {
2072
+ type: "string",
2073
+ description: `New clone protocol for type=git (${GIT_PROTOCOLS.join(", ")})`
2074
+ },
2075
+ config: {
2076
+ type: "string",
2077
+ description: "Path to the forgemap config file to modify"
2078
+ },
2079
+ yes: {
2080
+ type: "boolean",
2081
+ description: "Skip the confirmation prompt",
2082
+ default: false
2083
+ }
2084
+ },
2085
+ async run({ args }) {
2086
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
2087
+ const tty = interactive();
2088
+ const file = existingConfigFile(loaded, args.config);
2089
+ if (!file) {
2090
+ process.exitCode = 1;
2091
+ return;
2092
+ }
2093
+ const forges = loaded.config.forges;
2094
+ let key = typeof args.key === "string" ? args.key.trim() : "";
2095
+ if (!key && tty) {
2096
+ const answer = await promptSelect("Which forge should be edited?", Object.keys(forges));
2097
+ if (answer === null) return abort$1();
2098
+ key = answer;
2099
+ }
2100
+ if (!key) return fail$1("Missing forge key. Pass it as an argument.");
2101
+ const current = forges[key];
2102
+ if (!current) return fail$1(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
2103
+ const currentProtocol = current.type === "git" ? current.protocol : void 0;
2104
+ const patch = {};
2105
+ if (typeof args.type === "string") {
2106
+ if (!isForgeType(args.type)) return fail$1(invalidType(args.type));
2107
+ patch.type = args.type;
2108
+ } else if (tty) {
2109
+ const answer = await promptSelect(`Type (current: ${current.type}):`, FORGE_TYPES);
2110
+ if (answer === null) return abort$1();
2111
+ if (isForgeType(answer)) patch.type = answer;
2112
+ }
2113
+ const resultType = patch.type ?? current.type;
2114
+ if (typeof args.host === "string") patch.host = args.host.trim();
2115
+ else if (tty) {
2116
+ const answer = await promptText(`Host (current: ${current.host}):`, current.host);
2117
+ if (answer === null) return abort$1();
2118
+ if (answer.trim()) patch.host = answer.trim();
2119
+ }
2120
+ if (typeof args.dir === "string") patch.dir = args.dir.trim();
2121
+ else if (tty) {
2122
+ const answer = await promptText(`Directory (current: ${current.dir}):`, current.dir);
2123
+ if (answer === null) return abort$1();
2124
+ if (answer.trim()) patch.dir = answer.trim();
2125
+ }
2126
+ if (resultType === "git") {
2127
+ if (typeof args.protocol === "string") {
2128
+ if (!isGitProtocol(args.protocol)) return fail$1(invalidProtocol(args.protocol));
2129
+ patch.protocol = args.protocol;
2130
+ } else if (tty) {
2131
+ const answer = await promptSelect("Clone protocol:", GIT_PROTOCOLS);
2132
+ if (answer !== null && isGitProtocol(answer)) patch.protocol = answer;
2133
+ }
2134
+ }
2135
+ if (!hasChanges(patch)) return fail$1("Nothing to change. Pass --type, --host, --dir or --protocol.");
2136
+ consola.info(`Edit forge "${key}" in ${file}`);
2137
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort$1();
2138
+ const merged = mergeForge(current, currentProtocol, patch, resultType);
2139
+ if (await applyChange(file, (c) => editForge(c, key, patch), () => {
2140
+ consola.log(`Update the "${key}" entry to:`);
2141
+ printManualForge(key, merged);
2142
+ })) consola.success(`Edited forge "${key}" in ${file}`);
2143
+ else process.exitCode = 1;
2144
+ }
2145
+ });
2146
+ function hasChanges(patch) {
2147
+ return patch.type !== void 0 || patch.host !== void 0 || patch.dir !== void 0 || patch.protocol !== void 0;
2148
+ }
2149
+ function mergeForge(current, currentProtocol, patch, resultType) {
2150
+ const protocol = resultType === "git" ? patch.protocol ?? currentProtocol : void 0;
2151
+ return {
2152
+ type: resultType,
2153
+ host: patch.host ?? current.host,
2154
+ dir: patch.dir ?? current.dir,
2155
+ ...protocol ? { protocol } : {}
2156
+ };
2157
+ }
2158
+ function fail$1(message) {
2159
+ consola.error(message);
2160
+ process.exitCode = 1;
2161
+ }
2162
+ function abort$1() {
2163
+ consola.info("Aborted — nothing changed.");
2164
+ }
2165
+ function invalidType(value) {
2166
+ return `Invalid type "${value}". Expected one of: ${FORGE_TYPES.join(", ")}.`;
2167
+ }
2168
+ function invalidProtocol(value) {
2169
+ return `Invalid protocol "${value}". Expected one of: ${GIT_PROTOCOLS.join(", ")}.`;
2170
+ }
2171
+ //#endregion
2172
+ //#region src/commands/forge/remove.ts
2173
+ var LEAVE_UNSET = "— leave unset —";
2174
+ var forgeRemoveCommand = defineCommand({
2175
+ meta: {
2176
+ name: "remove",
2177
+ description: "Remove a forge from the config"
2178
+ },
2179
+ args: {
2180
+ key: {
2181
+ type: "positional",
2182
+ required: false,
2183
+ description: "Forge key to remove"
2184
+ },
2185
+ default: {
2186
+ type: "string",
2187
+ description: "When removing the default forge, reassign the default to this"
2188
+ },
2189
+ config: {
2190
+ type: "string",
2191
+ description: "Path to the forgemap config file to modify"
2192
+ },
2193
+ yes: {
2194
+ type: "boolean",
2195
+ description: "Skip the confirmation prompt",
2196
+ default: false
2197
+ }
2198
+ },
2199
+ async run({ args }) {
2200
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
2201
+ const tty = interactive();
2202
+ const file = existingConfigFile(loaded, args.config);
2203
+ if (!file) {
2204
+ process.exitCode = 1;
2205
+ return;
2206
+ }
2207
+ const forges = loaded.config.forges;
2208
+ let key = typeof args.key === "string" ? args.key.trim() : "";
2209
+ if (!key && tty) {
2210
+ const answer = await promptSelect("Which forge should be removed?", Object.keys(forges));
2211
+ if (answer === null) return abort();
2212
+ key = answer;
2213
+ }
2214
+ if (!key) return fail("Missing forge key. Pass it as an argument.");
2215
+ if (!(key in forges)) return fail(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
2216
+ const remaining = Object.keys(forges).filter((k) => k !== key);
2217
+ let newDefault;
2218
+ if (loaded.config.defaultForge === key && remaining.length > 0) if (typeof args.default === "string") {
2219
+ if (!remaining.includes(args.default)) return fail(`Cannot set default to "${args.default}" — not a remaining forge (${remaining.join(", ")}).`);
2220
+ newDefault = args.default;
2221
+ } else if (tty) {
2222
+ const answer = await promptSelect(`"${key}" is the default forge. Pick a new default:`, [...remaining, LEAVE_UNSET]);
2223
+ if (answer === null) return abort();
2224
+ if (answer !== LEAVE_UNSET) newDefault = answer;
2225
+ } else consola.warn(`Removing the default forge "${key}"; defaultForge now points at a missing forge. Pass --default to reassign it.`);
2226
+ consola.info(`Remove forge "${key}" from ${file}${newDefault ? ` (new default: "${newDefault}")` : ""}`);
2227
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort();
2228
+ if (await applyChange(file, (c) => {
2229
+ removeForge(c, key);
2230
+ if (newDefault) setDefaultForge(c, newDefault);
2231
+ }, () => consola.log(`Remove the "${key}" entry from \`forges\` in ${file}.`))) consola.success(`Removed forge "${key}" from ${file}`);
2232
+ else process.exitCode = 1;
2233
+ }
2234
+ });
2235
+ function fail(message) {
2236
+ consola.error(message);
2237
+ process.exitCode = 1;
2238
+ }
2239
+ function abort() {
2240
+ consola.info("Aborted — nothing changed.");
2241
+ }
2242
+ //#endregion
2243
+ //#region src/commands/forge/index.ts
2244
+ var forgeCommand = defineCommand({
2245
+ meta: {
2246
+ name: "forge",
2247
+ description: "Add, remove or edit forges in the config"
2248
+ },
2249
+ subCommands: {
2250
+ add: forgeAddCommand,
2251
+ remove: forgeRemoveCommand,
2252
+ edit: forgeEditCommand
2253
+ }
2254
+ });
2255
+ //#endregion
2256
+ //#region src/repos/import.ts
2257
+ async function listDirs(path) {
2258
+ try {
2259
+ return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
2260
+ } catch (error) {
2261
+ if (error.code === "ENOENT") return [];
2262
+ throw error;
2263
+ }
2264
+ }
2265
+ /**
2266
+ * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
2267
+ * Unlike `scanRepos`, this is config-free: every top-level directory is a
2268
+ * candidate server dir, and the names are discovered rather than configured.
2269
+ */
2270
+ async function discoverForgemapLayout(path) {
2271
+ const repos = [];
2272
+ for (const serverDir of await listDirs(path)) {
2273
+ const serverPath = join(path, serverDir);
2274
+ for (const owner of await listDirs(serverPath)) {
2275
+ const ownerPath = join(serverPath, owner);
2276
+ for (const repo of await listDirs(ownerPath)) repos.push({
2277
+ serverDir,
2278
+ owner,
2279
+ repo,
2280
+ localPath: join(ownerPath, repo)
2281
+ });
2282
+ }
2283
+ }
2284
+ return repos;
2285
+ }
2286
+ function forgeTypeForHost(host) {
2287
+ return host === "github.com" ? "github" : "git";
2288
+ }
2289
+ /**
2290
+ * Derive a `root` + one forge per server dir from the analyzed reports.
2291
+ * Host (and therefore type) come from the dominant origin host of the repos
2292
+ * under each server dir.
2293
+ */
2294
+ function deriveConfig(reports, path) {
2295
+ const forges = {};
2296
+ const counts = /* @__PURE__ */ new Map();
2297
+ const byServer = /* @__PURE__ */ new Map();
2298
+ for (const report of reports) {
2299
+ const list = byServer.get(report.repo.serverDir);
2300
+ if (list) list.push(report);
2301
+ else byServer.set(report.repo.serverDir, [report]);
2302
+ }
2303
+ for (const [serverDir, group] of byServer) {
2304
+ counts.set(serverDir, group.length);
2305
+ const hostTally = /* @__PURE__ */ new Map();
2306
+ for (const report of group) if (report.originHost) hostTally.set(report.originHost, (hostTally.get(report.originHost) ?? 0) + 1);
2307
+ const host = [...hostTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
2308
+ forges[serverDir] = {
2309
+ type: host ? forgeTypeForHost(host) : "git",
2310
+ host,
2311
+ dir: serverDir
2312
+ };
2313
+ }
2314
+ return {
1791
2315
  root: path,
1792
2316
  defaultForge: Object.keys(forges).slice().sort((a, b) => {
1793
2317
  const aGh = forges[a].type === "github" ? 1 : 0;
@@ -2419,7 +2943,7 @@ var infoCommand = defineCommand({
2419
2943
  async run({ args }) {
2420
2944
  const binary = resolveBinary(process.argv[1]);
2421
2945
  const info = {
2422
- version: "0.5.0",
2946
+ version: "0.6.0-dev-main.107-882ecab",
2423
2947
  build: detectBuild(binary.resolved),
2424
2948
  binary,
2425
2949
  node: process.version,
@@ -2433,88 +2957,175 @@ var infoCommand = defineCommand({
2433
2957
  }
2434
2958
  });
2435
2959
  //#endregion
2436
- //#region src/repos/match.ts
2960
+ //#region src/repos/filter.ts
2961
+ var FLAG = "--filter";
2437
2962
  /**
2438
- * The one Fuse configuration every fuzzy lookup shares — `search`, `pick`
2439
- * and the fuzzy slug fallback in `path`/`open`. Keeping it in one place is
2440
- * what makes a query rank identically no matter which command runs it.
2963
+ * Shared `--filter` option for the commands that enumerate repos
2964
+ * (`status`, `sync`, `list`), so the flag reads identically everywhere.
2441
2965
  */
2442
- var REPO_FUSE_OPTIONS = {
2443
- keys: [
2444
- "slug",
2445
- "owner",
2446
- "repo"
2447
- ],
2448
- threshold: .3,
2449
- ignoreLocation: true
2966
+ var filterArg = {
2967
+ type: "string",
2968
+ description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2450
2969
  };
2451
- function createRepoFuse(repos) {
2452
- return new Fuse(repos, REPO_FUSE_OPTIONS);
2453
- }
2454
- /** Fuzzy-match `query` against scanned repos, best match first. */
2455
- function matchRepos(repos, query, limit) {
2456
- return createRepoFuse(repos).search(query, limit ? { limit } : void 0).map((r) => r.item);
2457
- }
2458
- //#endregion
2459
- //#region src/repos/picker.ts
2460
2970
  /**
2461
- * Show the interactive repo picker and return the chosen local path
2462
- * (undefined when the user cancels).
2463
- *
2464
- * `$(forgemap pick)` / `$(forgemap path <q>)` captures stdout, so the
2465
- * interactive TUI must not go there. consola/clack writes the UI to stdout AND
2466
- * reads stdout.rows/columns for layout — but a captured stdout is a pipe (no
2467
- * rows → nothing renders). So for the duration of the prompt: route stdout
2468
- * writes to stderr (the real TTY) and borrow stderr's dimensions, then
2469
- * restore. stdout stays clean for the chosen path only.
2971
+ * Recover every `--filter` occurrence from the raw argv.
2470
2972
  *
2471
- * Callers must check {@link canPrompt} first — without a TTY on stdin there is
2472
- * nobody to answer.
2973
+ * citty (0.2.2) parses through `node:util` `parseArgs` and never sets
2974
+ * `multiple: true`, so Node keeps only the **last** value of a repeated option:
2975
+ * `--filter a --filter b` reaches `args.filter` as `'b'`, silently dropping
2976
+ * `a`. The raw argv is the only place the full list survives.
2473
2977
  */
2474
- async function promptRepoChoice(candidates) {
2475
- const out = process.stdout;
2476
- const realWrite = out.write;
2477
- const saved = {
2478
- rows: Object.getOwnPropertyDescriptor(out, "rows"),
2479
- columns: Object.getOwnPropertyDescriptor(out, "columns"),
2480
- isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2481
- };
2482
- const fake = (key, value) => {
2483
- Object.defineProperty(out, key, {
2484
- configurable: true,
2485
- value
2486
- });
2487
- };
2488
- const restore = (key) => {
2489
- if (saved[key]) Object.defineProperty(out, key, saved[key]);
2490
- else delete out[key];
2491
- };
2492
- out.write = process.stderr.write.bind(process.stderr);
2493
- fake("rows", process.stderr.rows ?? 24);
2494
- fake("columns", process.stderr.columns ?? 80);
2495
- fake("isTTY", true);
2496
- let choice;
2497
- try {
2498
- choice = await consola.prompt("Select a repo", {
2499
- type: "select",
2500
- options: candidates.map((r) => ({
2501
- label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2502
- value: r.localPath,
2503
- hint: r.localPath
2504
- }))
2505
- });
2506
- } finally {
2507
- out.write = realWrite;
2508
- restore("rows");
2509
- restore("columns");
2510
- restore("isTTY");
2978
+ function collectFilterArgs(rawArgs) {
2979
+ const values = [];
2980
+ for (let i = 0; i < rawArgs.length; i++) {
2981
+ const arg = rawArgs[i];
2982
+ if (arg === "--") break;
2983
+ if (arg === FLAG) {
2984
+ const next = rawArgs[i + 1];
2985
+ if (next !== void 0 && !next.startsWith("-")) {
2986
+ values.push(next);
2987
+ i++;
2988
+ }
2989
+ continue;
2990
+ }
2991
+ if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(9));
2511
2992
  }
2512
- return typeof choice === "string" && choice ? choice : void 0;
2993
+ return values;
2513
2994
  }
2514
- /** Whether an interactive prompt can be shown at all. */
2515
- function canPrompt() {
2516
- return Boolean(process.stdin.isTTY);
2995
+ /**
2996
+ * Normalize the shapes a filter value arrives in — absent, a single string, or
2997
+ * a list — into a list, dropping blanks (`--filter ''`).
2998
+ */
2999
+ function normalizeFilters(value) {
3000
+ if (value === void 0) return [];
3001
+ return (Array.isArray(value) ? value : [value]).map((v) => v.trim()).filter((v) => v.length > 0);
3002
+ }
3003
+ /**
3004
+ * The filter values for a run: the raw argv wins, since it is the only shape
3005
+ * that survives repetition. Fall back to the parsed value when the argv carries
3006
+ * no `--filter` at all, which is how the command is driven programmatically
3007
+ * (and in tests), where `rawArgs` may be empty.
3008
+ */
3009
+ function resolveFilters(rawArgs, value) {
3010
+ const fromRawArgs = collectFilterArgs(rawArgs);
3011
+ return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
2517
3012
  }
3013
+ /**
3014
+ * Keep the repos matching any of `filters` (OR-combined). A value matches when
3015
+ * it equals the repo's owner or its forge name, compared case-insensitively —
3016
+ * forge and owner names are case-preserving but not case-significant. An empty
3017
+ * filter list is a no-op, so an unused flag never narrows the output.
3018
+ */
3019
+ function filterRepos(repos, filters) {
3020
+ if (filters.length === 0) return repos;
3021
+ const wanted = new Set(filters.map((f) => f.toLowerCase()));
3022
+ return repos.filter((r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase()));
3023
+ }
3024
+ //#endregion
3025
+ //#region src/repos/match.ts
3026
+ /**
3027
+ * The one Fuse configuration every fuzzy lookup shares — `list`, `pick`
3028
+ * and the fuzzy slug fallback in `path`/`open`. Keeping it in one place is
3029
+ * what makes a query rank identically no matter which command runs it.
3030
+ */
3031
+ var REPO_FUSE_OPTIONS = {
3032
+ keys: [
3033
+ "slug",
3034
+ "owner",
3035
+ "repo"
3036
+ ],
3037
+ threshold: .3,
3038
+ ignoreLocation: true
3039
+ };
3040
+ function createRepoFuse(repos) {
3041
+ return new Fuse(repos, REPO_FUSE_OPTIONS);
3042
+ }
3043
+ /** Fuzzy-match `query` against scanned repos, best match first. */
3044
+ function matchRepos(repos, query, limit) {
3045
+ return createRepoFuse(repos).search(query, limit ? { limit } : void 0).map((r) => r.item);
3046
+ }
3047
+ //#endregion
3048
+ //#region src/commands/list.ts
3049
+ function renderTree$1(repos) {
3050
+ const byForge = /* @__PURE__ */ new Map();
3051
+ for (const r of repos) {
3052
+ let owners = byForge.get(r.forgeName);
3053
+ if (!owners) {
3054
+ owners = /* @__PURE__ */ new Map();
3055
+ byForge.set(r.forgeName, owners);
3056
+ }
3057
+ const list = owners.get(r.owner);
3058
+ if (list) list.push(r);
3059
+ else owners.set(r.owner, [r]);
3060
+ }
3061
+ return formatTree(Array.from(byForge, ([forge, owners]) => ({
3062
+ text: colors.bold(forge),
3063
+ children: Array.from(owners, ([owner, items]) => ({
3064
+ text: owner,
3065
+ children: items.map((r) => ({ text: `${colors.cyan(r.repo)} ${colors.dim(r.localPath)}` }))
3066
+ }))
3067
+ })));
3068
+ }
3069
+ var listCommand = defineCommand({
3070
+ meta: {
3071
+ name: "list",
3072
+ description: "List cloned repos; with a query, fuzzy-match by owner/repo and print matches"
3073
+ },
3074
+ args: {
3075
+ query: {
3076
+ type: "positional",
3077
+ description: "Optional search term (matched fuzzily against <owner>/<repo>). Omit to list every repo.",
3078
+ required: false
3079
+ },
3080
+ format: {
3081
+ type: "string",
3082
+ description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
3083
+ default: "auto"
3084
+ },
3085
+ filter: filterArg,
3086
+ limit: {
3087
+ type: "string",
3088
+ description: "Maximum number of matches to print (default: unlimited)"
3089
+ },
3090
+ config: {
3091
+ type: "string",
3092
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3093
+ }
3094
+ },
3095
+ async run({ args, rawArgs }) {
3096
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
3097
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3098
+ const repos = filterRepos(await scanRepos({
3099
+ config: loaded.config,
3100
+ configDir
3101
+ }), resolveFilters(rawArgs, args.filter));
3102
+ const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
3103
+ const query = args.query ?? "";
3104
+ const items = matchRepos(repos, query, limit);
3105
+ const allowed = [
3106
+ "auto",
3107
+ "pretty",
3108
+ "path",
3109
+ "slug"
3110
+ ];
3111
+ if (!allowed.includes(args.format)) {
3112
+ consola.error(`Invalid --format value "${args.format}". Allowed: ${allowed.join(", ")}.`);
3113
+ process.exitCode = 1;
3114
+ return;
3115
+ }
3116
+ const requested = args.format;
3117
+ const format = requested === "auto" ? process.stdout.isTTY ? "pretty" : "path" : requested;
3118
+ if (items.length === 0) {
3119
+ if (format === "pretty") consola.info(query ? `No matches for "${query}".` : "No repos found.");
3120
+ return;
3121
+ }
3122
+ if (format === "pretty") {
3123
+ process.stdout.write(`${renderTree$1(items)}\n`);
3124
+ return;
3125
+ }
3126
+ for (const item of items) process.stdout.write(`${format === "slug" ? item.slug : item.localPath}\n`);
3127
+ }
3128
+ });
2518
3129
  //#endregion
2519
3130
  //#region src/slug/locate.ts
2520
3131
  /**
@@ -2712,7 +3323,7 @@ var pickCommand = defineCommand({
2712
3323
  return;
2713
3324
  }
2714
3325
  if (!canPrompt()) {
2715
- consola.error("pick requires an interactive terminal. Use `forgemap search` for non-interactive output.");
3326
+ consola.error("pick requires an interactive terminal. Use `forgemap list` for non-interactive output.");
2716
3327
  process.exitCode = 1;
2717
3328
  return;
2718
3329
  }
@@ -2721,152 +3332,6 @@ var pickCommand = defineCommand({
2721
3332
  }
2722
3333
  });
2723
3334
  //#endregion
2724
- //#region src/repos/filter.ts
2725
- var FLAG = "--filter";
2726
- /**
2727
- * Shared `--filter` option for the commands that enumerate repos
2728
- * (`status`, `sync`, `search`), so the flag reads identically everywhere.
2729
- */
2730
- var filterArg = {
2731
- type: "string",
2732
- description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2733
- };
2734
- /**
2735
- * Recover every `--filter` occurrence from the raw argv.
2736
- *
2737
- * citty (0.2.2) parses through `node:util` `parseArgs` and never sets
2738
- * `multiple: true`, so Node keeps only the **last** value of a repeated option:
2739
- * `--filter a --filter b` reaches `args.filter` as `'b'`, silently dropping
2740
- * `a`. The raw argv is the only place the full list survives.
2741
- */
2742
- function collectFilterArgs(rawArgs) {
2743
- const values = [];
2744
- for (let i = 0; i < rawArgs.length; i++) {
2745
- const arg = rawArgs[i];
2746
- if (arg === "--") break;
2747
- if (arg === FLAG) {
2748
- const next = rawArgs[i + 1];
2749
- if (next !== void 0 && !next.startsWith("-")) {
2750
- values.push(next);
2751
- i++;
2752
- }
2753
- continue;
2754
- }
2755
- if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(9));
2756
- }
2757
- return values;
2758
- }
2759
- /**
2760
- * Normalize the shapes a filter value arrives in — absent, a single string, or
2761
- * a list — into a list, dropping blanks (`--filter ''`).
2762
- */
2763
- function normalizeFilters(value) {
2764
- if (value === void 0) return [];
2765
- return (Array.isArray(value) ? value : [value]).map((v) => v.trim()).filter((v) => v.length > 0);
2766
- }
2767
- /**
2768
- * The filter values for a run: the raw argv wins, since it is the only shape
2769
- * that survives repetition. Fall back to the parsed value when the argv carries
2770
- * no `--filter` at all, which is how the command is driven programmatically
2771
- * (and in tests), where `rawArgs` may be empty.
2772
- */
2773
- function resolveFilters(rawArgs, value) {
2774
- const fromRawArgs = collectFilterArgs(rawArgs);
2775
- return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
2776
- }
2777
- /**
2778
- * Keep the repos matching any of `filters` (OR-combined). A value matches when
2779
- * it equals the repo's owner or its forge name, compared case-insensitively —
2780
- * forge and owner names are case-preserving but not case-significant. An empty
2781
- * filter list is a no-op, so an unused flag never narrows the output.
2782
- */
2783
- function filterRepos(repos, filters) {
2784
- if (filters.length === 0) return repos;
2785
- const wanted = new Set(filters.map((f) => f.toLowerCase()));
2786
- return repos.filter((r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase()));
2787
- }
2788
- //#endregion
2789
- //#region src/commands/search.ts
2790
- function renderTree$1(repos) {
2791
- const byForge = /* @__PURE__ */ new Map();
2792
- for (const r of repos) {
2793
- let owners = byForge.get(r.forgeName);
2794
- if (!owners) {
2795
- owners = /* @__PURE__ */ new Map();
2796
- byForge.set(r.forgeName, owners);
2797
- }
2798
- const list = owners.get(r.owner);
2799
- if (list) list.push(r);
2800
- else owners.set(r.owner, [r]);
2801
- }
2802
- return formatTree(Array.from(byForge, ([forge, owners]) => ({
2803
- text: colors.bold(forge),
2804
- children: Array.from(owners, ([owner, items]) => ({
2805
- text: owner,
2806
- children: items.map((r) => ({ text: `${colors.cyan(r.repo)} ${colors.dim(r.localPath)}` }))
2807
- }))
2808
- })));
2809
- }
2810
- var searchCommand = defineCommand({
2811
- meta: {
2812
- name: "search",
2813
- description: "Fuzzy-search cloned repos by owner/repo and print matching repos"
2814
- },
2815
- args: {
2816
- query: {
2817
- type: "positional",
2818
- description: "Search term (matched fuzzily against <owner>/<repo>)",
2819
- required: true
2820
- },
2821
- format: {
2822
- type: "string",
2823
- description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
2824
- default: "auto"
2825
- },
2826
- filter: filterArg,
2827
- limit: {
2828
- type: "string",
2829
- description: "Maximum number of matches to print (default: unlimited)"
2830
- },
2831
- config: {
2832
- type: "string",
2833
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2834
- }
2835
- },
2836
- async run({ args, rawArgs }) {
2837
- const loaded = await loadForgeMapConfig({ configFile: args.config });
2838
- const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2839
- const repos = filterRepos(await scanRepos({
2840
- config: loaded.config,
2841
- configDir
2842
- }), resolveFilters(rawArgs, args.filter));
2843
- const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
2844
- const items = matchRepos(repos, args.query, limit);
2845
- const allowed = [
2846
- "auto",
2847
- "pretty",
2848
- "path",
2849
- "slug"
2850
- ];
2851
- if (!allowed.includes(args.format)) {
2852
- consola.error(`Invalid --format value "${args.format}". Allowed: ${allowed.join(", ")}.`);
2853
- process.exitCode = 1;
2854
- return;
2855
- }
2856
- const requested = args.format;
2857
- const format = requested === "auto" ? process.stdout.isTTY ? "pretty" : "path" : requested;
2858
- if (items.length === 0) {
2859
- if (format === "pretty") consola.info(`No matches for "${args.query}".`);
2860
- return;
2861
- }
2862
- if (format === "pretty") {
2863
- process.stdout.write(`${renderTree$1(items)}\n`);
2864
- return;
2865
- }
2866
- for (const item of items) process.stdout.write(`${format === "slug" ? item.slug : item.localPath}\n`);
2867
- }
2868
- });
2869
- //#endregion
2870
3335
  //#region src/commands/shell-init.ts
2871
3336
  /** Append (idempotently) a loader that, plus completion, sets up the shell so
2872
3337
  * the user only has to re-source their rc file. */
@@ -2896,7 +3361,7 @@ ${name}() {
2896
3361
  target=$(command forgemap pick) || return $?
2897
3362
  else
2898
3363
  local matches
2899
- matches=$(command forgemap search "$1" --format path)
3364
+ matches=$(command forgemap list "$1" --format path)
2900
3365
  local count
2901
3366
  count=$(printf '%s' "$matches" | grep -c '^/' || true)
2902
3367
  if [ "$count" = "1" ]; then
@@ -2915,7 +3380,7 @@ ${name}() {
2915
3380
  }
2916
3381
  `;
2917
3382
  }
2918
- function renderFish(name) {
3383
+ function renderFish$1(name) {
2919
3384
  return `# forgemap shell integration — drop into your ~/.config/fish/config.fish:
2920
3385
  # forgemap shell-init fish | source
2921
3386
 
@@ -2926,7 +3391,7 @@ function ${name} --description "forgemap with cd interception"
2926
3391
  if test (count $argv) -eq 0
2927
3392
  set target (command forgemap pick); or return $status
2928
3393
  else
2929
- set matches (command forgemap search $argv[1] --format path)
3394
+ set matches (command forgemap list $argv[1] --format path)
2930
3395
  set count (count $matches)
2931
3396
  if test $count -eq 1
2932
3397
  set target $matches[1]
@@ -2978,7 +3443,7 @@ var shellInitCommand = defineCommand({
2978
3443
  await install(requested, name);
2979
3444
  return;
2980
3445
  }
2981
- const out = requested === "fish" ? renderFish(name) : renderPosix(name);
3446
+ const out = requested === "fish" ? renderFish$1(name) : renderPosix(name);
2982
3447
  process.stdout.write(out);
2983
3448
  }
2984
3449
  });
@@ -3326,12 +3791,297 @@ function severitySymbol(severity) {
3326
3791
  if (severity === "warn") return colors.yellow("!");
3327
3792
  return colors.red("✗");
3328
3793
  }
3794
+ var validateCommand = defineCommand({
3795
+ meta: {
3796
+ name: "validate",
3797
+ description: "Preflight: check the config schema, required CLI tools, and root directory"
3798
+ },
3799
+ args: {
3800
+ json: {
3801
+ type: "boolean",
3802
+ description: "Emit a machine-readable JSON report",
3803
+ default: false
3804
+ },
3805
+ config: {
3806
+ type: "string",
3807
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3808
+ }
3809
+ },
3810
+ async run({ args }) {
3811
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
3812
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3813
+ const checks = await runChecks(loaded.config, configDir);
3814
+ const ok = checks.every((c) => c.severity !== "fail");
3815
+ if (args.json) process.stdout.write(`${JSON.stringify({
3816
+ ok,
3817
+ checks
3818
+ }, null, 2)}\n`);
3819
+ else {
3820
+ for (const c of checks) process.stdout.write(`${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}\n`);
3821
+ process.stdout.write(`\n${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}\n`);
3822
+ }
3823
+ if (!ok) {
3824
+ process.exitCode = 1;
3825
+ return;
3826
+ }
3827
+ if (!loaded.configFile) consola.warn("No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one.");
3828
+ }
3829
+ });
3830
+ //#endregion
3831
+ //#region src/commands/completion.ts
3832
+ var SLUG_COMMANDS = [
3833
+ "clone",
3834
+ "cd",
3835
+ "path",
3836
+ "open",
3837
+ "list",
3838
+ "pick",
3839
+ "delete"
3840
+ ];
3841
+ var STATIC_FLAG_VALUES = {
3842
+ list: { "--format": [
3843
+ "auto",
3844
+ "pretty",
3845
+ "path",
3846
+ "slug"
3847
+ ] },
3848
+ status: { "--format": ["pretty", "json"] },
3849
+ import: {
3850
+ "--format": ["pretty", "json"],
3851
+ "--type": ["forgemap"]
3852
+ }
3853
+ };
3854
+ var SHELL_POSITIONAL = /* @__PURE__ */ new Set(["completion", "shell-init"]);
3855
+ /** Every forgemap command declares `args` as a plain object literal (or omits
3856
+ * it, like `config`), never a thunk — so no `Resolvable` unwrapping is needed. */
3857
+ function argsOf(cmd) {
3858
+ return cmd.args ?? {};
3859
+ }
3860
+ /** Flag names a command exposes, derived from its `defineCommand` args so a new
3861
+ * flag surfaces in completion automatically. Positionals are handled
3862
+ * separately; negatable booleans (those with a `negativeDescription`) also get
3863
+ * their `--no-<flag>` form. */
3864
+ function flagsOf(cmd) {
3865
+ const flags = [];
3866
+ for (const [name, def] of Object.entries(argsOf(cmd))) {
3867
+ if (def.type === "positional") continue;
3868
+ flags.push(`--${name}`);
3869
+ if (def.type === "boolean" && def.negativeDescription) flags.push(`--no-${name}`);
3870
+ }
3871
+ return flags;
3872
+ }
3873
+ /** The ordered subcommand registry, mirroring `rootCommand.subCommands` in
3874
+ * cli.ts. It is kept here rather than imported from cli.ts because that would
3875
+ * form a cycle (cli → completion → cli). Built lazily so the self-reference to
3876
+ * `completionCommand` resolves after the module finishes initializing. */
3877
+ function commandSpecs() {
3878
+ return [
3879
+ ["clone", cloneCommand],
3880
+ ["import", importCommand],
3881
+ ["cleanup", cleanupCommand],
3882
+ ["delete", deleteCommand],
3883
+ ["cd", cdCommand],
3884
+ ["path", pathCommand],
3885
+ ["open", openCommand],
3886
+ ["list", listCommand],
3887
+ ["pick", pickCommand],
3888
+ ["status", statusCommand],
3889
+ ["sync", syncCommand],
3890
+ ["validate", validateCommand],
3891
+ ["info", infoCommand],
3892
+ ["completion", completionCommand],
3893
+ ["shell-init", shellInitCommand],
3894
+ ["config", configCommand],
3895
+ ["forge", forgeCommand]
3896
+ ].map(([name, cmd]) => ({
3897
+ name,
3898
+ flags: flagsOf(cmd),
3899
+ flagValues: STATIC_FLAG_VALUES[name] ?? {},
3900
+ positionalValues: SHELL_POSITIONAL.has(name) ? [...SUPPORTED_SHELLS] : [],
3901
+ slugs: SLUG_COMMANDS.includes(name)
3902
+ }));
3903
+ }
3904
+ function flagValuePairs(specs) {
3905
+ return specs.flatMap((s) => Object.entries(s.flagValues).map(([flag, values]) => [
3906
+ s.name,
3907
+ flag,
3908
+ values
3909
+ ]));
3910
+ }
3911
+ function renderBash(specs) {
3912
+ const names = specs.map((s) => s.name).join(" ");
3913
+ const valueArms = flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) COMPREPLY=( $(compgen -W "${values.join(" ")}" -- "$cur") ); return ;;`).join("\n");
3914
+ const flagArms = specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) flags="${s.flags.join(" ")}" ;;`).join("\n");
3915
+ const slugCmds = specs.filter((s) => s.slugs).map((s) => s.name);
3916
+ return `# forgemap bash completion — drop into your ~/.bashrc:
3917
+ # eval "$(forgemap completion bash)"
3918
+ _forgemap_completion() {
3919
+ local cur prev cmd flags
3920
+ COMPREPLY=()
3921
+ cur="\${COMP_WORDS[COMP_CWORD]}"
3922
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
3923
+ cmd="\${COMP_WORDS[1]}"
3924
+
3925
+ if [ "$COMP_CWORD" = "1" ]; then
3926
+ COMPREPLY=( $(compgen -W "${names}" -- "$cur") )
3927
+ return
3928
+ fi
3929
+
3930
+ # Values for flags with a fixed set (e.g. --format), keyed by "<cmd>:<flag>".
3931
+ case "$cmd:$prev" in
3932
+ ${valueArms}
3933
+ esac
3934
+
3935
+ # Flag names for the current subcommand.
3936
+ if [[ "$cur" == -* ]]; then
3937
+ case "$cmd" in
3938
+ ${flagArms}
3939
+ esac
3940
+ COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
3941
+ return
3942
+ fi
3943
+
3944
+ # Positional values (repo slugs, or a shell name).
3945
+ case "$cmd" in
3946
+ ${[slugCmds.length > 0 ? ` ${slugCmds.join("|")})
3947
+ local slugs
3948
+ slugs=$(forgemap list --format slug 2>/dev/null)
3949
+ COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
3950
+ ;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) COMPREPLY=( $(compgen -W "${s.positionalValues.join(" ")}" -- "$cur") ) ;;`)].filter(Boolean).join("\n")}
3951
+ esac
3952
+ }
3953
+ complete -F _forgemap_completion forgemap
3954
+ `;
3955
+ }
3956
+ function renderZsh(specs) {
3957
+ return `# forgemap zsh completion — drop into your ~/.zshrc:
3958
+ # eval "$(forgemap completion zsh)"
3959
+ _forgemap() {
3960
+ local -a subcommands
3961
+ subcommands=(${specs.map((s) => `'${s.name}'`).join(" ")})
3962
+ local cmd="\${words[2]}"
3963
+ local prev="\${words[CURRENT-1]}"
3964
+ local cur="\${words[CURRENT]}"
3965
+
3966
+ if (( CURRENT == 2 )); then
3967
+ _describe 'forgemap subcommand' subcommands
3968
+ return
3969
+ fi
3970
+
3971
+ # Values for flags with a fixed set (e.g. --format), keyed by "<cmd>:<flag>".
3972
+ case "$cmd:$prev" in
3973
+ ${flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) compadd ${values.join(" ")}; return ;;`).join("\n")}
3974
+ esac
3975
+
3976
+ # Flag names for the current subcommand.
3977
+ if [[ "$cur" == -* ]]; then
3978
+ case "$cmd" in
3979
+ ${specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) compadd -- ${s.flags.join(" ")}; return ;;`).join("\n")}
3980
+ esac
3981
+ return
3982
+ fi
3983
+
3984
+ # Positional values (repo slugs, or a shell name).
3985
+ case "$cmd" in
3986
+ ${specs.filter((s) => s.slugs).map((s) => s.name).join("|")})
3987
+ local -a slugs
3988
+ slugs=("\${(@f)$(forgemap list --format slug 2>/dev/null)}")
3989
+ _describe 'slug' slugs
3990
+ ;;
3991
+ ${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) compadd ${s.positionalValues.join(" ")} ;;`).join("\n")}
3992
+ esac
3993
+ }
3994
+ compdef _forgemap forgemap
3995
+ `;
3996
+ }
3997
+ function renderFish(specs) {
3998
+ const names = specs.map((s) => s.name).join(" ");
3999
+ const flagLines = specs.flatMap((s) => s.flags.map((flag) => {
4000
+ const long = flag.replace(/^--/, "");
4001
+ const values = s.flagValues[flag];
4002
+ const valuePart = values ? ` -x -a '${values.join(" ")}'` : "";
4003
+ return `complete -c forgemap -n '__fish_seen_subcommand_from ${s.name}' -l ${long}${valuePart}`;
4004
+ })).join("\n");
4005
+ const shellCmds = specs.filter((s) => s.positionalValues.length > 0);
4006
+ return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
4007
+ # forgemap completion fish | source
4008
+
4009
+ # Subcommands (depth 1).
4010
+ complete -c forgemap -f -n '__fish_use_subcommand' -a '${names}'
4011
+
4012
+ # Flags per subcommand (with fixed value sets where applicable).
4013
+ ${flagLines}
4014
+
4015
+ # Shell flavor for completion / shell-init (depth 2).
4016
+ function __forgemap_needs_shell
4017
+ set -l tokens (commandline -opc)
4018
+ set -l shell_cmds ${shellCmds.map((s) => `"${s.name}"`).join(" ")}
4019
+ if test (count $tokens) -ge 2; and contains $tokens[2] $shell_cmds
4020
+ return 0
4021
+ end
4022
+ return 1
4023
+ end
4024
+ complete -c forgemap -f -n '__forgemap_needs_shell' -a '${shellCmds[0]?.positionalValues.join(" ") ?? ""}'
4025
+
4026
+ # Slugs (depth 2) for commands that take one.
4027
+ function __forgemap_needs_slug
4028
+ set -l tokens (commandline -opc)
4029
+ set -l slug_cmds ${specs.filter((s) => s.slugs).map((s) => `"${s.name}"`).join(" ")}
4030
+ if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
4031
+ return 0
4032
+ end
4033
+ return 1
4034
+ end
4035
+
4036
+ complete -c forgemap -f -n '__forgemap_needs_slug' \\
4037
+ -a '(forgemap list --format slug 2>/dev/null)'
4038
+ `;
4039
+ }
4040
+ var completionCommand = defineCommand({
4041
+ meta: {
4042
+ name: "completion",
4043
+ description: "Print a shell completion script. Source via `eval \"$(forgemap completion)\"`."
4044
+ },
4045
+ args: {
4046
+ shell: {
4047
+ type: "positional",
4048
+ description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
4049
+ required: false
4050
+ },
4051
+ install: {
4052
+ type: "boolean",
4053
+ description: "Append the completion loader to your shell's rc file (idempotent) instead of printing",
4054
+ default: false
4055
+ }
4056
+ },
4057
+ async run({ args }) {
4058
+ const requested = args.shell ?? detectShell();
4059
+ if (!SUPPORTED_SHELLS.includes(requested)) {
4060
+ consola.error(`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`);
4061
+ process.exitCode = 1;
4062
+ return;
4063
+ }
4064
+ if (args.install) {
4065
+ const { status, rcFile } = await installRcBlock(requested, "completion", [requested === "fish" ? "forgemap completion fish | source" : `eval "$(forgemap completion ${requested})"`]);
4066
+ if (status === "present") consola.info(`forgemap completion already present in ${rcFile}.`);
4067
+ else {
4068
+ const verb = status === "updated" ? "Updated" : "Added";
4069
+ consola.success(`${verb} forgemap completion in ${rcFile}.`);
4070
+ consola.info(`Run \`source ${rcFile}\` or restart your shell to activate it.`);
4071
+ }
4072
+ return;
4073
+ }
4074
+ const specs = commandSpecs();
4075
+ const out = requested === "fish" ? renderFish(specs) : requested === "zsh" ? renderZsh(specs) : renderBash(specs);
4076
+ process.stdout.write(out);
4077
+ }
4078
+ });
3329
4079
  //#endregion
3330
4080
  //#region src/bin/forgemap.ts
3331
4081
  runMain(defineCommand({
3332
4082
  meta: {
3333
4083
  name: "forgemap",
3334
- version: "0.5.0",
4084
+ version: "0.6.0-dev-main.107-882ecab",
3335
4085
  description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
3336
4086
  },
3337
4087
  subCommands: {
@@ -3342,50 +4092,16 @@ runMain(defineCommand({
3342
4092
  cd: cdCommand,
3343
4093
  path: pathCommand,
3344
4094
  open: openCommand,
3345
- search: searchCommand,
4095
+ list: listCommand,
3346
4096
  pick: pickCommand,
3347
4097
  status: statusCommand,
3348
4098
  sync: syncCommand,
3349
- validate: defineCommand({
3350
- meta: {
3351
- name: "validate",
3352
- description: "Preflight: check the config schema, required CLI tools, and root directory"
3353
- },
3354
- args: {
3355
- json: {
3356
- type: "boolean",
3357
- description: "Emit a machine-readable JSON report",
3358
- default: false
3359
- },
3360
- config: {
3361
- type: "string",
3362
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3363
- }
3364
- },
3365
- async run({ args }) {
3366
- const loaded = await loadForgeMapConfig({ configFile: args.config });
3367
- const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3368
- const checks = await runChecks(loaded.config, configDir);
3369
- const ok = checks.every((c) => c.severity !== "fail");
3370
- if (args.json) process.stdout.write(`${JSON.stringify({
3371
- ok,
3372
- checks
3373
- }, null, 2)}\n`);
3374
- else {
3375
- for (const c of checks) process.stdout.write(`${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}\n`);
3376
- process.stdout.write(`\n${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}\n`);
3377
- }
3378
- if (!ok) {
3379
- process.exitCode = 1;
3380
- return;
3381
- }
3382
- if (!loaded.configFile) consola.warn("No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one.");
3383
- }
3384
- }),
4099
+ validate: validateCommand,
3385
4100
  info: infoCommand,
3386
4101
  completion: completionCommand,
3387
4102
  "shell-init": shellInitCommand,
3388
- config: configCommand
4103
+ config: configCommand,
4104
+ forge: forgeCommand
3389
4105
  }
3390
4106
  }));
3391
4107
  //#endregion