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.
- package/README.md +62 -46
- package/dist/bin/forgemap.mjs +808 -54
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +2 -1
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
}
|