forgemap 0.4.1-dev.67-4a4bf9d → 0.4.1-dev.69-291072b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,7 +83,8 @@ forgemap pick kirch # picker pre-filtered by fuzzy query
83
83
  ### Open the folder in the OS file manager
84
84
 
85
85
  ```bash
86
- forgemap open kirchDev/laravel-pbac
86
+ forgemap open kirchDev/laravel-pbac # exact slug
87
+ forgemap open laravel # fuzzy single match → same folder
87
88
  ```
88
89
 
89
90
  - **WSL** → launches `explorer.exe` against `\\wsl$\<distro>\…`, Explorer opens the folder
@@ -99,8 +100,13 @@ forgemap sync --forge work --query api # restrict scope
99
100
 
100
101
  forgemap status # tree: branch / dirty / ahead↑ / behind↓ / last commit
101
102
  forgemap status --format json # structured for jq + scripts
103
+
104
+ # --filter keeps only matching owners/forges. Repeatable, OR-combined.
105
+ forgemap status --format json --filter kirchDev --filter TitusKirch
102
106
  ```
103
107
 
108
+ `--filter` works on `status`, `sync` and `search`. It matches an **owner** or a **forge name** exactly (case-insensitive) — unlike `--query`, which is fuzzy.
109
+
104
110
  All tree output (`status`, `search`, `import`) groups as `forge → owner → repo`. Network operations (`sync`, `import`, `cleanup`) run with a hard timeout and non-interactive SSH, so an unreachable host can never wedge a run.
105
111
 
106
112
  ### Adopt an existing layout — `import`
@@ -672,6 +672,9 @@ const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
672
672
  function stripGitSuffix(repo) {
673
673
  return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
674
674
  }
675
+ function looksLikeSlug(input) {
676
+ return input.trim().includes("/");
677
+ }
675
678
  function parseSlug(input) {
676
679
  const trimmed = input.trim();
677
680
  if (!trimmed) {
@@ -2050,6 +2053,111 @@ const importCommand = defineCommand({
2050
2053
  });
2051
2054
  }
2052
2055
  });
2056
+ const REPO_FUSE_OPTIONS = {
2057
+ keys: ["slug", "owner", "repo"],
2058
+ threshold: 0.3,
2059
+ ignoreLocation: true
2060
+ };
2061
+ function createRepoFuse(repos) {
2062
+ return new Fuse(repos, REPO_FUSE_OPTIONS);
2063
+ }
2064
+ function matchRepos(repos, query, limit) {
2065
+ const fuse = createRepoFuse(repos);
2066
+ const results = fuse.search(query, limit ? { limit } : void 0);
2067
+ return results.map((r) => r.item);
2068
+ }
2069
+ async function promptRepoChoice(candidates) {
2070
+ const out = process.stdout;
2071
+ const realWrite = out.write;
2072
+ const saved = {
2073
+ rows: Object.getOwnPropertyDescriptor(out, "rows"),
2074
+ columns: Object.getOwnPropertyDescriptor(out, "columns"),
2075
+ isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2076
+ };
2077
+ const fake = (key, value) => {
2078
+ Object.defineProperty(out, key, { configurable: true, value });
2079
+ };
2080
+ const restore = (key) => {
2081
+ if (saved[key]) Object.defineProperty(out, key, saved[key]);
2082
+ else delete out[key];
2083
+ };
2084
+ out.write = process.stderr.write.bind(process.stderr);
2085
+ fake("rows", process.stderr.rows ?? 24);
2086
+ fake("columns", process.stderr.columns ?? 80);
2087
+ fake("isTTY", true);
2088
+ let choice;
2089
+ try {
2090
+ choice = await consola.prompt("Select a repo", {
2091
+ type: "select",
2092
+ options: candidates.map((r) => ({
2093
+ label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2094
+ value: r.localPath,
2095
+ hint: r.localPath
2096
+ }))
2097
+ });
2098
+ } finally {
2099
+ out.write = realWrite;
2100
+ restore("rows");
2101
+ restore("columns");
2102
+ restore("isTTY");
2103
+ }
2104
+ return typeof choice === "string" && choice ? choice : void 0;
2105
+ }
2106
+ function canPrompt() {
2107
+ return Boolean(process.stdin.isTTY);
2108
+ }
2109
+ async function locateRepo(input, options) {
2110
+ const { config, configDir } = options;
2111
+ if (!input.trim() || looksLikeSlug(input)) {
2112
+ const resolved = resolveSlug(parseSlug(input), { config, configDir });
2113
+ return { kind: "slug", localPath: resolved.localPath };
2114
+ }
2115
+ const repos = options.repos ?? await scanRepos({ config, configDir });
2116
+ const candidates = matchRepos(repos, input);
2117
+ if (candidates.length === 0) return { kind: "none", query: input };
2118
+ if (candidates.length === 1) {
2119
+ return {
2120
+ kind: "match",
2121
+ localPath: candidates[0].localPath,
2122
+ repo: candidates[0]
2123
+ };
2124
+ }
2125
+ return { kind: "ambiguous", query: input, candidates };
2126
+ }
2127
+ async function resolveRepoPath(input, options) {
2128
+ const outcome = await locateRepo(input, options);
2129
+ switch (outcome.kind) {
2130
+ case "slug":
2131
+ case "match":
2132
+ return outcome.localPath;
2133
+ case "none":
2134
+ consola.error(`No cloned repo matches "${outcome.query}".`);
2135
+ process.stderr.write(
2136
+ `${colors.dim("Pass an explicit <owner>/<repo> for a repo that is not cloned yet.")}
2137
+ `
2138
+ );
2139
+ return null;
2140
+ case "ambiguous": {
2141
+ if (canPrompt()) {
2142
+ return await promptRepoChoice(outcome.candidates) ?? null;
2143
+ }
2144
+ consola.error(
2145
+ `"${outcome.query}" matches ${outcome.candidates.length} cloned repos:`
2146
+ );
2147
+ for (const c of outcome.candidates) {
2148
+ process.stderr.write(
2149
+ ` ${colors.cyan(`${c.forgeName}:${c.slug}`)} ${colors.dim(c.localPath)}
2150
+ `
2151
+ );
2152
+ }
2153
+ process.stderr.write(
2154
+ `${colors.dim("Narrow the query, pass an explicit <owner>/<repo>, or run `forgemap pick` to choose interactively.")}
2155
+ `
2156
+ );
2157
+ return null;
2158
+ }
2159
+ }
2160
+ }
2053
2161
  function platformOpen(localPath) {
2054
2162
  const distro = process.env.WSL_DISTRO_NAME;
2055
2163
  if (distro) {
@@ -2069,7 +2177,7 @@ const openCommand = defineCommand({
2069
2177
  args: {
2070
2178
  slug: {
2071
2179
  type: "positional",
2072
- description: "owner/repo, forge:owner/repo, or full URL",
2180
+ description: "owner/repo, forge:owner/repo, full URL, or a fuzzy query matched against cloned repos",
2073
2181
  required: true
2074
2182
  },
2075
2183
  config: {
@@ -2079,14 +2187,17 @@ const openCommand = defineCommand({
2079
2187
  },
2080
2188
  async run({ args }) {
2081
2189
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2082
- const parsed = parseSlug(args.slug);
2083
2190
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2084
- const resolved = resolveSlug(parsed, {
2191
+ const localPath = await resolveRepoPath(args.slug, {
2085
2192
  config: loaded.config,
2086
2193
  configDir
2087
2194
  });
2088
- const { cmd, args: cmdArgs } = platformOpen(resolved.localPath);
2089
- consola.info(`Opening ${resolved.localPath}`);
2195
+ if (!localPath) {
2196
+ process.exitCode = 1;
2197
+ return;
2198
+ }
2199
+ const { cmd, args: cmdArgs } = platformOpen(localPath);
2200
+ consola.info(`Opening ${localPath}`);
2090
2201
  const child = spawn(cmd, cmdArgs, {
2091
2202
  stdio: "ignore",
2092
2203
  detached: true
@@ -2113,7 +2224,7 @@ const pathCommand = defineCommand({
2113
2224
  args: {
2114
2225
  slug: {
2115
2226
  type: "positional",
2116
- description: "owner/repo, forge:owner/repo, or full URL",
2227
+ description: "owner/repo, forge:owner/repo, full URL, or a fuzzy query matched against cloned repos",
2117
2228
  required: true
2118
2229
  },
2119
2230
  config: {
@@ -2123,13 +2234,16 @@ const pathCommand = defineCommand({
2123
2234
  },
2124
2235
  async run({ args }) {
2125
2236
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2126
- const parsed = parseSlug(args.slug);
2127
2237
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2128
- const resolved = resolveSlug(parsed, {
2238
+ const localPath = await resolveRepoPath(args.slug, {
2129
2239
  config: loaded.config,
2130
2240
  configDir
2131
2241
  });
2132
- process.stdout.write(`${resolved.localPath}
2242
+ if (!localPath) {
2243
+ process.exitCode = 1;
2244
+ return;
2245
+ }
2246
+ process.stdout.write(`${localPath}
2133
2247
  `);
2134
2248
  }
2135
2249
  });
@@ -2153,17 +2267,7 @@ const pickCommand = defineCommand({
2153
2267
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2154
2268
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2155
2269
  const all = await scanRepos({ config: loaded.config, configDir });
2156
- let candidates;
2157
- if (args.query) {
2158
- const fuse = new Fuse(all, {
2159
- keys: ["slug", "owner", "repo"],
2160
- threshold: 0.3,
2161
- ignoreLocation: true
2162
- });
2163
- candidates = fuse.search(args.query).map((r) => r.item);
2164
- } else {
2165
- candidates = all;
2166
- }
2270
+ const candidates = args.query ? matchRepos(all, args.query) : all;
2167
2271
  if (candidates.length === 0) {
2168
2272
  consola.error(
2169
2273
  args.query ? `No repos match "${args.query}".` : "No repos found under the configured root."
@@ -2176,53 +2280,58 @@ const pickCommand = defineCommand({
2176
2280
  `);
2177
2281
  return;
2178
2282
  }
2179
- if (!process.stdin.isTTY) {
2283
+ if (!canPrompt()) {
2180
2284
  consola.error(
2181
2285
  "pick requires an interactive terminal. Use `forgemap search` for non-interactive output."
2182
2286
  );
2183
2287
  process.exitCode = 1;
2184
2288
  return;
2185
2289
  }
2186
- const out = process.stdout;
2187
- const realWrite = out.write;
2188
- const saved = {
2189
- rows: Object.getOwnPropertyDescriptor(out, "rows"),
2190
- columns: Object.getOwnPropertyDescriptor(out, "columns"),
2191
- isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2192
- };
2193
- const fake = (key, value) => {
2194
- Object.defineProperty(out, key, { configurable: true, value });
2195
- };
2196
- const restore = (key) => {
2197
- if (saved[key]) Object.defineProperty(out, key, saved[key]);
2198
- else delete out[key];
2199
- };
2200
- out.write = process.stderr.write.bind(process.stderr);
2201
- fake("rows", process.stderr.rows ?? 24);
2202
- fake("columns", process.stderr.columns ?? 80);
2203
- fake("isTTY", true);
2204
- let choice;
2205
- try {
2206
- choice = await consola.prompt("Select a repo", {
2207
- type: "select",
2208
- options: candidates.map((r) => ({
2209
- label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2210
- value: r.localPath,
2211
- hint: r.localPath
2212
- }))
2213
- });
2214
- } finally {
2215
- out.write = realWrite;
2216
- restore("rows");
2217
- restore("columns");
2218
- restore("isTTY");
2219
- }
2220
- if (typeof choice === "string" && choice) {
2221
- realWrite.call(out, `${choice}
2290
+ const choice = await promptRepoChoice(candidates);
2291
+ if (choice) {
2292
+ process.stdout.write(`${choice}
2222
2293
  `);
2223
2294
  }
2224
2295
  }
2225
2296
  });
2297
+ const FLAG = "--filter";
2298
+ const filterArg = {
2299
+ type: "string",
2300
+ description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2301
+ };
2302
+ function collectFilterArgs(rawArgs) {
2303
+ const values = [];
2304
+ for (let i = 0; i < rawArgs.length; i++) {
2305
+ const arg = rawArgs[i];
2306
+ if (arg === "--") break;
2307
+ if (arg === FLAG) {
2308
+ const next = rawArgs[i + 1];
2309
+ if (next !== void 0 && !next.startsWith("-")) {
2310
+ values.push(next);
2311
+ i++;
2312
+ }
2313
+ continue;
2314
+ }
2315
+ if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(FLAG.length + 1));
2316
+ }
2317
+ return values;
2318
+ }
2319
+ function normalizeFilters(value) {
2320
+ if (value === void 0) return [];
2321
+ const values = Array.isArray(value) ? value : [value];
2322
+ return values.map((v) => v.trim()).filter((v) => v.length > 0);
2323
+ }
2324
+ function resolveFilters(rawArgs, value) {
2325
+ const fromRawArgs = collectFilterArgs(rawArgs);
2326
+ return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
2327
+ }
2328
+ function filterRepos(repos, filters) {
2329
+ if (filters.length === 0) return repos;
2330
+ const wanted = new Set(filters.map((f) => f.toLowerCase()));
2331
+ return repos.filter(
2332
+ (r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase())
2333
+ );
2334
+ }
2226
2335
  function renderTree$1(repos) {
2227
2336
  const byForge = /* @__PURE__ */ new Map();
2228
2337
  for (const r of repos) {
@@ -2263,6 +2372,7 @@ const searchCommand = defineCommand({
2263
2372
  description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
2264
2373
  default: "auto"
2265
2374
  },
2375
+ filter: filterArg,
2266
2376
  limit: {
2267
2377
  type: "string",
2268
2378
  description: "Maximum number of matches to print (default: unlimited)"
@@ -2272,19 +2382,13 @@ const searchCommand = defineCommand({
2272
2382
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2273
2383
  }
2274
2384
  },
2275
- async run({ args }) {
2385
+ async run({ args, rawArgs }) {
2276
2386
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2277
2387
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2278
- const repos = await scanRepos({ config: loaded.config, configDir });
2279
- const fuse = new Fuse(repos, {
2280
- keys: ["slug", "owner", "repo"],
2281
- threshold: 0.3,
2282
- ignoreLocation: true,
2283
- includeScore: true
2284
- });
2388
+ const scanned = await scanRepos({ config: loaded.config, configDir });
2389
+ const repos = filterRepos(scanned, resolveFilters(rawArgs, args.filter));
2285
2390
  const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
2286
- const results = fuse.search(args.query, limit ? { limit } : void 0);
2287
- const items = results.map((r) => r.item);
2391
+ const items = matchRepos(repos, args.query, limit);
2288
2392
  const allowed = ["auto", "pretty", "path", "slug"];
2289
2393
  if (!allowed.includes(args.format)) {
2290
2394
  consola.error(
@@ -2495,6 +2599,7 @@ const statusCommand = defineCommand({
2495
2599
  type: "string",
2496
2600
  description: "Restrict to a single forge alias"
2497
2601
  },
2602
+ filter: filterArg,
2498
2603
  query: {
2499
2604
  type: "string",
2500
2605
  description: "Fuzzy filter against <owner>/<repo>"
@@ -2509,7 +2614,7 @@ const statusCommand = defineCommand({
2509
2614
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2510
2615
  }
2511
2616
  },
2512
- async run({ args }) {
2617
+ async run({ args, rawArgs }) {
2513
2618
  if (!ALLOWED_FORMATS.includes(args.format)) {
2514
2619
  consola.error(
2515
2620
  `Invalid --format value "${args.format}". Allowed: ${ALLOWED_FORMATS.join(", ")}.`
@@ -2527,6 +2632,7 @@ const statusCommand = defineCommand({
2527
2632
  if (args.forge) {
2528
2633
  repos = repos.filter((r) => r.forgeName === args.forge);
2529
2634
  }
2635
+ repos = filterRepos(repos, resolveFilters(rawArgs, args.filter));
2530
2636
  if (args.query) {
2531
2637
  const fuse = new Fuse(repos, {
2532
2638
  keys: ["slug", "owner", "repo"],
@@ -2608,6 +2714,7 @@ const syncCommand = defineCommand({
2608
2714
  type: "string",
2609
2715
  description: "Restrict to a single forge alias"
2610
2716
  },
2717
+ filter: filterArg,
2611
2718
  query: {
2612
2719
  type: "string",
2613
2720
  description: "Fuzzy filter against <owner>/<repo>"
@@ -2622,7 +2729,7 @@ const syncCommand = defineCommand({
2622
2729
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2623
2730
  }
2624
2731
  },
2625
- async run({ args }) {
2732
+ async run({ args, rawArgs }) {
2626
2733
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2627
2734
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2628
2735
  let repos = await scanReposCached({
@@ -2633,6 +2740,7 @@ const syncCommand = defineCommand({
2633
2740
  if (args.forge) {
2634
2741
  repos = repos.filter((r) => r.forgeName === args.forge);
2635
2742
  }
2743
+ repos = filterRepos(repos, resolveFilters(rawArgs, args.filter));
2636
2744
  if (args.query) {
2637
2745
  const fuse = new Fuse(repos, {
2638
2746
  keys: ["slug", "owner", "repo"],