@kaynooo/cli-doctor 0.7.0 → 0.8.0
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 +9 -2
- package/dist/cli-bin.js +378 -25
- package/dist/cli.js +375 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,16 +10,23 @@ doctor -q # only the failing checks, nothing when all pass
|
|
|
10
10
|
doctor --only tea --only bun # just these checks (a package such as cli-forgejo, tea, hooks, bun, completions)
|
|
11
11
|
doctor --fix --dry-run # what --fix would run
|
|
12
12
|
doctor --fix # run the fixes, then check again
|
|
13
|
+
doctor --install # pick the packages to add from a checklist
|
|
14
|
+
doctor --install forgejo check hooks # or name them: a package, one of its bins, hooks, all
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
| Check | Passes when | Fix |
|
|
16
18
|
| --- | --- | --- |
|
|
17
19
|
| each `@kaynooo/cli-*` package | it is installed globally, its bins are on the PATH and it is no older than the version `bun info` serves | `bun install -g <package>@<latest>`, and one line installing every outdated package at once |
|
|
18
|
-
| `
|
|
20
|
+
| `needs` | the system tools the installed packages need are on the PATH: glab for `gl`, docker for `dc`, `dlog`, `dx` and `sf` | install them by hand, doctor never installs a system package |
|
|
21
|
+
| `tea` | `ft` is installed, tea is installed, logged in to git.kaynooo.fr and `tea api version` answers, since `ft` goes through it | `tea login add --url https://git.kaynooo.fr --oauth` |
|
|
19
22
|
| `hooks` | `~/.claude/settings.json` wires the hooks one way: the `cli-hooks@kaynooo-cli` plugin enabled, or the `cli` mod listed in `CLAUDE_CODE_PLUGIN_DIRS`; both at once fails, every call being checked twice | `claude plugin install cli-hooks --marketplace ...`, or `claude plugin disable` for the doubled one |
|
|
20
23
|
| `bun` | Bun satisfies the `engines.bun` range of every installed package | `bun upgrade` |
|
|
21
24
|
|
|
22
|
-
A registry that does not answer leaves a package passing, with a note. Exit codes: 0 every check passed, 1 one failed, 2 a usage error.
|
|
25
|
+
Only the packages that are installed are checked: a package left out is no failure, and the last line counts them. A registry that does not answer leaves a package passing, with a note. Exit codes: 0 every check passed, 1 one failed, 2 a usage error.
|
|
26
|
+
|
|
27
|
+
## --install
|
|
28
|
+
|
|
29
|
+
`--install` adds packages: the names given (a package such as `forgejo`, one of its bins such as `ft`, `hooks` for the Claude Code plugin, or `all`), else a checklist on a terminal that shows each package's bins, what it does and what it needs. Off a terminal, names are required. Before installing, each package is checked against its needs: a missing required tool (tea for `ft`, glab for `gl`, docker for `dc`) leaves the package out unless `--force`, and `hooks` installs `guard` first. The installs run in one `bun install -g`, then the plugin, then the checks and fixes of what was installed. `--list` prints the choices and `--dry-run` what would run. Exits 1 when a package was left out.
|
|
23
30
|
|
|
24
31
|
## --fix
|
|
25
32
|
|
package/dist/cli-bin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli-bin.ts
|
|
5
|
-
import
|
|
5
|
+
import process15 from "process";
|
|
6
6
|
// ../kit/src/args.ts
|
|
7
7
|
import process5 from "process";
|
|
8
8
|
import { parseArgs } from "util";
|
|
@@ -274,6 +274,11 @@ function le(i = {}) {
|
|
|
274
274
|
}
|
|
275
275
|
var t = le();
|
|
276
276
|
|
|
277
|
+
// ../kit/src/theme.ts
|
|
278
|
+
var STYLES = new Set(["bold", "dim", "italic", "underline", "inverse", "strikethrough", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey", "blackBright", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright"]);
|
|
279
|
+
var MAX_FILE_BYTES = 64 * 1024;
|
|
280
|
+
var warned = new Set;
|
|
281
|
+
|
|
277
282
|
// ../kit/src/style.ts
|
|
278
283
|
function takesControlCodes(stream, env = process2.env) {
|
|
279
284
|
return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
|
|
@@ -281,14 +286,14 @@ function takesControlCodes(stream, env = process2.env) {
|
|
|
281
286
|
function paint(stream, env = process2.env) {
|
|
282
287
|
return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
|
|
283
288
|
}
|
|
284
|
-
var
|
|
285
|
-
passed:
|
|
286
|
-
failed:
|
|
287
|
-
running:
|
|
288
|
-
queued:
|
|
289
|
-
skipped:
|
|
289
|
+
var STATUS_WORDS = {
|
|
290
|
+
passed: ["passed", "success", "succeeded", "ok"],
|
|
291
|
+
failed: ["failed", "failure", "error", "errored"],
|
|
292
|
+
running: ["running", "in_progress", "started"],
|
|
293
|
+
queued: ["queued", "waiting", "pending", "created", "blocked", "scheduled"],
|
|
294
|
+
skipped: ["skipped", "cancelled", "canceled", "manual"]
|
|
290
295
|
};
|
|
291
|
-
var STATUS_OF = new Map(Object.entries(
|
|
296
|
+
var STATUS_OF = new Map(Object.entries(STATUS_WORDS).flatMap(([status, words]) => words.map((word) => [word, status])));
|
|
292
297
|
|
|
293
298
|
// ../kit/src/format.ts
|
|
294
299
|
function upTo(text, separator) {
|
|
@@ -303,7 +308,7 @@ var ESCAPE = String.fromCharCode(27);
|
|
|
303
308
|
function textWidth(text) {
|
|
304
309
|
return Bun.stringWidth(text);
|
|
305
310
|
}
|
|
306
|
-
function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
311
|
+
function table(rows, { cellWidth, header = false, align = [], fit, stream = process3.stdout } = {}) {
|
|
307
312
|
const cells = cellWidth === undefined ? rows : rows.map((row) => row.map((cell) => truncate(cell.replaceAll(LINE_BREAKS, " "), cellWidth)));
|
|
308
313
|
const widths = [];
|
|
309
314
|
for (const row of cells) {
|
|
@@ -311,7 +316,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
|
311
316
|
widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
|
|
312
317
|
}
|
|
313
318
|
const shown = fit === undefined ? cells : narrow(cells, widths, fit);
|
|
314
|
-
const colors = paint(
|
|
319
|
+
const colors = paint(stream);
|
|
315
320
|
const lines = shown.map((row) => row.map((cell, index) => {
|
|
316
321
|
const width = widths[index] ?? 0;
|
|
317
322
|
const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
|
|
@@ -319,7 +324,8 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
|
319
324
|
return `${gap}${cell}`;
|
|
320
325
|
return index === row.length - 1 ? cell : `${cell}${gap}`;
|
|
321
326
|
}).join(" ").trimEnd());
|
|
322
|
-
|
|
327
|
+
const rule = header && takesControlCodes(stream) && lines.length > 1 ? [colors.dim("\u2500".repeat(Math.max(...lines.map(textWidth))))] : [];
|
|
328
|
+
return lines.flatMap((line, index) => header && index === 0 ? [colors.bold(line), ...rule] : [line]).join(`
|
|
323
329
|
`);
|
|
324
330
|
}
|
|
325
331
|
function narrow(rows, widths, limit) {
|
|
@@ -446,6 +452,10 @@ class Exit extends Error {
|
|
|
446
452
|
this.hint = hint;
|
|
447
453
|
}
|
|
448
454
|
}
|
|
455
|
+
function fail(message, code = 2, hint) {
|
|
456
|
+
throw new Exit(message, code, hint);
|
|
457
|
+
}
|
|
458
|
+
|
|
449
459
|
class UsageError extends Exit {
|
|
450
460
|
constructor(message, hint) {
|
|
451
461
|
super(message, 2, hint);
|
|
@@ -1159,6 +1169,41 @@ import process10 from "process";
|
|
|
1159
1169
|
// src/checks.ts
|
|
1160
1170
|
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
1161
1171
|
import { basename, join as join3 } from "path";
|
|
1172
|
+
|
|
1173
|
+
// src/catalog.ts
|
|
1174
|
+
var HOOKS_REQUIRES = "@kaynooo/cli-guard";
|
|
1175
|
+
var ABOUT = {
|
|
1176
|
+
"@kaynooo/cli-base": "run a command at a ref in a throwaway worktree, and prove a test fails before a change",
|
|
1177
|
+
"@kaynooo/cli-changelog": "release notes from conventional commits, published as a Forgejo release",
|
|
1178
|
+
"@kaynooo/cli-check": "the repo's check, lint and test steps, its fixers, error lines from logs and a recipe picker",
|
|
1179
|
+
"@kaynooo/cli-code": "source read by spans, TODO comments and the files worth splitting",
|
|
1180
|
+
"@kaynooo/cli-db": "read-only SQL against Postgres, MySQL and MariaDB, credentials never shown",
|
|
1181
|
+
"@kaynooo/cli-dev": "passwords, JWTs, timestamps, UUIDs, base64 and file hashes",
|
|
1182
|
+
"@kaynooo/cli-docker": "docker compose on the stack found here, service health, shells and Symfony commands",
|
|
1183
|
+
"@kaynooo/cli-doctor": "this install check, and the list of every bin",
|
|
1184
|
+
"@kaynooo/cli-edit": "replace text in files, writing nothing unless every match count is right",
|
|
1185
|
+
"@kaynooo/cli-env": ".env files listed and edited with secrets masked, and a secrets filter for pipes",
|
|
1186
|
+
"@kaynooo/cli-forgejo": "Forgejo CI runs, logs, releases and issue checklists",
|
|
1187
|
+
"@kaynooo/cli-git": "repository state in one line, commits of exact paths, a branch picker",
|
|
1188
|
+
"@kaynooo/cli-gitlab": "GitLab pipelines and their failed tests",
|
|
1189
|
+
"@kaynooo/cli-guard": "the safety rules a Claude Code Bash call is held to",
|
|
1190
|
+
"@kaynooo/cli-out": "shortens a noisy command's output, keeping the errors",
|
|
1191
|
+
"@kaynooo/cli-pick": "a fuzzy selector for pipes",
|
|
1192
|
+
"@kaynooo/cli-pkg": "vets npm packages before they are added, and bumps a lib in its consumers",
|
|
1193
|
+
"@kaynooo/cli-probe": "a site's HTTP version, redirects, timings and TLS",
|
|
1194
|
+
"@kaynooo/cli-proc": "what a process is, who listens on a port, leftover dev processes",
|
|
1195
|
+
"@kaynooo/cli-stack": "a stable port per service name, and a compose release audit",
|
|
1196
|
+
"@kaynooo/cli-view": "markdown, source and git diffs in the colours of the other bins",
|
|
1197
|
+
"@kaynooo/cli-wait": "wait for a line, a port, a URL or a process; a machine-wide slot for heavy jobs",
|
|
1198
|
+
"@kaynooo/cli-workspace": "the repositories under a root needing attention, reclaimable folders and known vulnerabilities"
|
|
1199
|
+
};
|
|
1200
|
+
var NEEDS = {
|
|
1201
|
+
"@kaynooo/cli-forgejo": [{ bin: "tea", why: "ft goes through it", how: "https://gitea.com/gitea/tea/releases" }],
|
|
1202
|
+
"@kaynooo/cli-gitlab": [{ bin: "glab", why: "gl goes through it", how: "https://gitlab.com/gitlab-org/cli" }],
|
|
1203
|
+
"@kaynooo/cli-docker": [{ bin: "docker", why: "dc, dlog, dx and sf run docker compose", how: "https://docs.docker.com/engine/install/" }]
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// src/checks.ts
|
|
1162
1207
|
var TOOLS = {
|
|
1163
1208
|
"@kaynooo/cli-base": ["base", "proof"],
|
|
1164
1209
|
"@kaynooo/cli-changelog": ["changelog"],
|
|
@@ -1210,6 +1255,9 @@ function firstLine(text) {
|
|
|
1210
1255
|
function installed(host, name) {
|
|
1211
1256
|
return readJson(join3(host.globalModules, name, "package.json"));
|
|
1212
1257
|
}
|
|
1258
|
+
function isInstalled(host, name) {
|
|
1259
|
+
return installed(host, name)?.version !== undefined;
|
|
1260
|
+
}
|
|
1213
1261
|
function ownBin(host, name, bin) {
|
|
1214
1262
|
const declared = installed(host, name)?.bin;
|
|
1215
1263
|
const file = typeof declared === "string" ? declared : declared?.[bin];
|
|
@@ -1248,10 +1296,16 @@ async function checkTea(host) {
|
|
|
1248
1296
|
return { check: "tea", ok: false, detail: `${forge} did not answer: ${firstLine(version.stderr || version.stdout)}`, fix: `tea login edit ${login.name} --token <token>, or check the network` };
|
|
1249
1297
|
return { check: "tea", ok: true, detail: `logged in to ${forge} as ${login.name}, Forgejo ${served}` };
|
|
1250
1298
|
}
|
|
1251
|
-
function
|
|
1299
|
+
function hooksWiring(host) {
|
|
1252
1300
|
const settings = readJson(join3(host.home, ".claude", "settings.json")) ?? {};
|
|
1253
1301
|
const plugin = settings.enabledPlugins?.[PLUGIN] === true;
|
|
1254
1302
|
const mod = (settings.env?.CLAUDE_CODE_PLUGIN_DIRS ?? "").split(":").map((dir) => dir.replace(LEADING_TILDE, host.home)).find((dir) => basename(dir) === "cli" && existsSync(join3(dir, "hooks", "hooks.json")));
|
|
1303
|
+
return { plugin, mod };
|
|
1304
|
+
}
|
|
1305
|
+
function checkHooks(host) {
|
|
1306
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1307
|
+
if ((plugin || mod !== undefined) && !isInstalled(host, HOOKS_REQUIRES))
|
|
1308
|
+
return { check: "hooks", ok: false, detail: `wired, but ${HOOKS_REQUIRES} is not installed, so every hook finds no guard`, fix: `bun install -g ${HOOKS_REQUIRES}` };
|
|
1255
1309
|
if (plugin && mod !== undefined)
|
|
1256
1310
|
return { check: "hooks", ok: false, detail: `both the ${PLUGIN} plugin and the mod in ${mod} run guard, so every call is checked twice`, fix: `claude plugin disable ${PLUGIN}` };
|
|
1257
1311
|
if (plugin)
|
|
@@ -1276,19 +1330,29 @@ function checkCompletions(host) {
|
|
|
1276
1330
|
return { check: "completions", ok: false, detail: `no fish completion in ${dir} for ${missing.join(", ")}`, fix: "cli completions fish --install" };
|
|
1277
1331
|
return { check: "completions", ok: true, detail: dir };
|
|
1278
1332
|
}
|
|
1333
|
+
function checkNeeds(host) {
|
|
1334
|
+
const missing = Object.entries(NEEDS).flatMap(([name, needs]) => isInstalled(host, name) ? needs.filter((need) => need.bin !== "tea" && host.which(need.bin) === null) : []);
|
|
1335
|
+
if (missing.length > 0)
|
|
1336
|
+
return { check: "needs", ok: false, detail: `missing ${missing.map((need) => `${need.bin} (${need.why})`).join(", ")}`, fix: `install ${missing.map((need) => `${need.bin}: ${need.how}`).join("; ")}` };
|
|
1337
|
+
return { check: "needs", ok: true, detail: "every tool the installed packages need is on the PATH" };
|
|
1338
|
+
}
|
|
1279
1339
|
var toolCheck = (name) => name.slice(name.indexOf("/") + 1);
|
|
1280
|
-
var OTHER_CHECKS = ["tea", "hooks", "bun", "completions"];
|
|
1340
|
+
var OTHER_CHECKS = ["tea", "hooks", "needs", "bun", "completions"];
|
|
1341
|
+
var FORGEJO = "@kaynooo/cli-forgejo";
|
|
1281
1342
|
var CHECK_NAMES = [...Object.keys(TOOLS).map(toolCheck), ...OTHER_CHECKS];
|
|
1282
1343
|
async function runChecks(host, only = []) {
|
|
1283
1344
|
const wanted = (check) => only.length === 0 || only.includes(check);
|
|
1284
|
-
const entries = Object.entries(TOOLS).filter(([name]) => wanted(toolCheck(name)));
|
|
1345
|
+
const entries = Object.entries(TOOLS).filter(([name]) => only.length === 0 ? isInstalled(host, name) : wanted(toolCheck(name)));
|
|
1346
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1347
|
+
const hooks = only.length > 0 || plugin || mod !== undefined || isInstalled(host, HOOKS_REQUIRES);
|
|
1285
1348
|
const tools = [];
|
|
1286
1349
|
for (let at = 0;at < entries.length; at += INFO_AT_ONCE)
|
|
1287
1350
|
tools.push(...await Promise.all(entries.slice(at, at + INFO_AT_ONCE).map(async ([name, bins]) => checkTool(host, name, bins))));
|
|
1288
1351
|
return [
|
|
1289
1352
|
...tools,
|
|
1290
|
-
...wanted("tea") ? [await checkTea(host)] : [],
|
|
1291
|
-
...wanted("hooks") ? [checkHooks(host)] : [],
|
|
1353
|
+
...wanted("tea") && (only.length > 0 || isInstalled(host, FORGEJO)) ? [await checkTea(host)] : [],
|
|
1354
|
+
...wanted("hooks") && hooks ? [checkHooks(host)] : [],
|
|
1355
|
+
...wanted("needs") ? [checkNeeds(host)] : [],
|
|
1292
1356
|
...wanted("bun") ? [checkBun(host)] : [],
|
|
1293
1357
|
...wanted("completions") ? [checkCompletions(host)] : []
|
|
1294
1358
|
];
|
|
@@ -1354,7 +1418,7 @@ function missingCompletions(host, dir) {
|
|
|
1354
1418
|
}
|
|
1355
1419
|
|
|
1356
1420
|
// src/doctor.ts
|
|
1357
|
-
import
|
|
1421
|
+
import process14 from "process";
|
|
1358
1422
|
|
|
1359
1423
|
// src/fix.ts
|
|
1360
1424
|
var INSTALL = /^bun install -g (\S+)$/;
|
|
@@ -1415,11 +1479,286 @@ function hostHere() {
|
|
|
1415
1479
|
};
|
|
1416
1480
|
}
|
|
1417
1481
|
|
|
1482
|
+
// src/install.ts
|
|
1483
|
+
import process13 from "process";
|
|
1484
|
+
|
|
1485
|
+
// ../kit/src/prompt.ts
|
|
1486
|
+
import process12 from "process";
|
|
1487
|
+
var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
|
|
1488
|
+
var CONTROL = /^[\x00-\x1F\x7F]/;
|
|
1489
|
+
var WORDS = /\s+/;
|
|
1490
|
+
var MAX_ROWS = 10;
|
|
1491
|
+
var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
|
|
1492
|
+
function readKeys(input) {
|
|
1493
|
+
return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
|
|
1494
|
+
}
|
|
1495
|
+
function flip(checked, value) {
|
|
1496
|
+
const next = new Set(checked);
|
|
1497
|
+
if (!next.delete(value))
|
|
1498
|
+
next.add(value);
|
|
1499
|
+
return next;
|
|
1500
|
+
}
|
|
1501
|
+
async function checklist(question, choices, { initial = new Set, min = 0, toggle = flip, ...settings } = {}) {
|
|
1502
|
+
const list = new List(choices, 0);
|
|
1503
|
+
let checked = initial;
|
|
1504
|
+
let invalid;
|
|
1505
|
+
const accepted = await ask(question, settings, (colors, state, rows) => state === "asking" ? [asking(colors, question, list.query, "type to filter, space toggles, enter accepts"), ...list.rows(colors, rows, (choice) => checked.has(choice.value) ? `${colors.cyan("\u25C9")} ` : `${colors.dim("\u25EF")} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]] : [answer(colors, question, state, choices.filter((choice) => checked.has(choice.value)).map((choice) => choice.label).join(", ") || "none")], (key) => {
|
|
1506
|
+
if (key === "cancel")
|
|
1507
|
+
return key;
|
|
1508
|
+
invalid = undefined;
|
|
1509
|
+
if (key === "enter") {
|
|
1510
|
+
invalid = checked.size < min ? `pick at least ${min}` : undefined;
|
|
1511
|
+
return invalid === undefined ? "enter" : undefined;
|
|
1512
|
+
}
|
|
1513
|
+
const current = list.current();
|
|
1514
|
+
if (typeof key === "object" && key.text === " ") {
|
|
1515
|
+
if (current !== undefined)
|
|
1516
|
+
checked = toggle(checked, current.value);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
list.press(key);
|
|
1520
|
+
return;
|
|
1521
|
+
});
|
|
1522
|
+
return accepted ? checked : undefined;
|
|
1523
|
+
}
|
|
1524
|
+
class List {
|
|
1525
|
+
choices;
|
|
1526
|
+
query = "";
|
|
1527
|
+
matches;
|
|
1528
|
+
cursor;
|
|
1529
|
+
top = 0;
|
|
1530
|
+
width;
|
|
1531
|
+
constructor(choices, initial) {
|
|
1532
|
+
this.choices = choices;
|
|
1533
|
+
this.matches = choices;
|
|
1534
|
+
this.cursor = Math.min(Math.max(initial, 0), choices.length - 1);
|
|
1535
|
+
this.width = Math.max(0, ...choices.map((choice) => choice.label.length));
|
|
1536
|
+
}
|
|
1537
|
+
current() {
|
|
1538
|
+
return this.matches[this.cursor];
|
|
1539
|
+
}
|
|
1540
|
+
press(key) {
|
|
1541
|
+
if (key === "up" || key === "down") {
|
|
1542
|
+
if (this.matches.length > 0)
|
|
1543
|
+
this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
if (key === "erase")
|
|
1547
|
+
this.filter([...this.query].slice(0, -1).join(""));
|
|
1548
|
+
else if (typeof key === "object")
|
|
1549
|
+
this.filter(this.query + key.text);
|
|
1550
|
+
}
|
|
1551
|
+
filter(query) {
|
|
1552
|
+
const words = query.toLowerCase().split(WORDS).filter((word) => word !== "");
|
|
1553
|
+
const current = this.current();
|
|
1554
|
+
this.query = query;
|
|
1555
|
+
this.matches = this.choices.filter(({ label, hint }) => words.every((word) => `${label} ${hint}`.toLowerCase().includes(word)));
|
|
1556
|
+
this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0);
|
|
1557
|
+
this.top = 0;
|
|
1558
|
+
}
|
|
1559
|
+
rows(colors, { columns, rows }, marker) {
|
|
1560
|
+
if (this.matches.length === 0)
|
|
1561
|
+
return [colors.dim(" no match")];
|
|
1562
|
+
const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length));
|
|
1563
|
+
this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor);
|
|
1564
|
+
const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
|
|
1565
|
+
const here = this.top + index === this.cursor;
|
|
1566
|
+
const mark = marker(choice);
|
|
1567
|
+
const head = `${here ? "\u276F" : " "} ${mark}`;
|
|
1568
|
+
const label = choice.label.padEnd(this.width);
|
|
1569
|
+
const room = columns - 5 - (mark === "" ? 0 : 2) - this.width;
|
|
1570
|
+
return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`;
|
|
1571
|
+
});
|
|
1572
|
+
const below = this.matches.length - this.top - height;
|
|
1573
|
+
return [...this.top > 0 ? [colors.dim(` \u2026 ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` \u2026 ${below} more`)] : []];
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function asking(colors, question, typed, keys) {
|
|
1577
|
+
return `${colors.cyan("?")} ${question} ${typed === "" ? colors.dim(`(${keys})`) : typed}`;
|
|
1578
|
+
}
|
|
1579
|
+
function answer(colors, question, state, picked) {
|
|
1580
|
+
return state === "enter" ? `${colors.green("\u2713")} ${question} ${colors.cyan(picked)}` : `${colors.red("\u2717")} ${question} ${colors.dim("cancelled")}`;
|
|
1581
|
+
}
|
|
1582
|
+
async function ask(question, { hint, terminal = process12 }, render, press) {
|
|
1583
|
+
const { stdin, stderr } = terminal;
|
|
1584
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
1585
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint);
|
|
1586
|
+
const colors = paint(stderr);
|
|
1587
|
+
let drawn = 0;
|
|
1588
|
+
let outcome;
|
|
1589
|
+
const draw = (state) => {
|
|
1590
|
+
const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
|
|
1591
|
+
stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
|
|
1592
|
+
`)}`);
|
|
1593
|
+
drawn = frame.length;
|
|
1594
|
+
};
|
|
1595
|
+
try {
|
|
1596
|
+
stdin.setRawMode(true);
|
|
1597
|
+
stdin.resume();
|
|
1598
|
+
stderr.write("\x1B[?25l");
|
|
1599
|
+
draw("asking");
|
|
1600
|
+
return await new Promise((resolve) => {
|
|
1601
|
+
function onData(data) {
|
|
1602
|
+
for (const key of readKeys(data.toString())) {
|
|
1603
|
+
const pressed = press(key);
|
|
1604
|
+
if (pressed !== undefined) {
|
|
1605
|
+
settle(pressed);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
draw("asking");
|
|
1610
|
+
}
|
|
1611
|
+
function onEnd() {
|
|
1612
|
+
settle("cancel");
|
|
1613
|
+
}
|
|
1614
|
+
function settle(settled) {
|
|
1615
|
+
outcome = settled;
|
|
1616
|
+
stdin.off("data", onData);
|
|
1617
|
+
stdin.off("end", onEnd);
|
|
1618
|
+
resolve(settled === "enter");
|
|
1619
|
+
}
|
|
1620
|
+
stdin.on("data", onData);
|
|
1621
|
+
stdin.on("end", onEnd);
|
|
1622
|
+
});
|
|
1623
|
+
} finally {
|
|
1624
|
+
draw(outcome ?? "cancel");
|
|
1625
|
+
stderr.write(`
|
|
1626
|
+
\x1B[?25h`);
|
|
1627
|
+
stdin.setRawMode(false);
|
|
1628
|
+
stdin.pause();
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/install.ts
|
|
1633
|
+
var HOOKS = "hooks";
|
|
1634
|
+
var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
|
|
1635
|
+
var CLI_PREFIX = /^cli-/;
|
|
1636
|
+
var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
|
|
1637
|
+
function candidates2(host) {
|
|
1638
|
+
const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
|
|
1639
|
+
const needs = (NEEDS[name] ?? []).map((need) => need.bin);
|
|
1640
|
+
return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
|
|
1641
|
+
});
|
|
1642
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1643
|
+
if (plugin || mod !== undefined)
|
|
1644
|
+
return packages;
|
|
1645
|
+
return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
|
|
1646
|
+
}
|
|
1647
|
+
function choose(pool, names) {
|
|
1648
|
+
const chosen = new Set;
|
|
1649
|
+
for (const name of names) {
|
|
1650
|
+
if (name === "all") {
|
|
1651
|
+
for (const each of pool)
|
|
1652
|
+
chosen.add(each);
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const found = pool.find((each) => each.names.includes(name));
|
|
1656
|
+
if (found !== undefined)
|
|
1657
|
+
chosen.add(found);
|
|
1658
|
+
}
|
|
1659
|
+
return [...chosen];
|
|
1660
|
+
}
|
|
1661
|
+
function plan(host, picked, force) {
|
|
1662
|
+
const blocked = [];
|
|
1663
|
+
const warnings = [];
|
|
1664
|
+
const packages = [];
|
|
1665
|
+
for (const name of new Set(picked.flatMap((each) => each.packages))) {
|
|
1666
|
+
const lacking = (NEEDS[name] ?? []).filter((need) => host.which(need.bin) === null);
|
|
1667
|
+
if (lacking.length > 0 && !force) {
|
|
1668
|
+
for (const need of lacking)
|
|
1669
|
+
blocked.push({ package: shortName(name), need: need.bin, why: need.why, how: need.how });
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
for (const need of lacking)
|
|
1673
|
+
warnings.push({ package: shortName(name), need: need.bin, why: need.why });
|
|
1674
|
+
packages.push(name);
|
|
1675
|
+
}
|
|
1676
|
+
const guardBlocked = blocked.some((each) => `@kaynooo/cli-${each.package}` === HOOKS_REQUIRES);
|
|
1677
|
+
return { packages, plugin: picked.some((each) => each.id === HOOKS) && !guardBlocked, blocked, warnings };
|
|
1678
|
+
}
|
|
1679
|
+
function checksFor(packages, plugin) {
|
|
1680
|
+
return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
|
|
1681
|
+
}
|
|
1682
|
+
async function ask2(pool) {
|
|
1683
|
+
if (process13.stdin.isTTY !== true || process13.stderr.isTTY !== true)
|
|
1684
|
+
badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
|
|
1685
|
+
const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
|
|
1686
|
+
const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
|
|
1687
|
+
return picked === undefined ? [] : [...picked];
|
|
1688
|
+
}
|
|
1689
|
+
async function install(host, options, recheck) {
|
|
1690
|
+
const pool = candidates2(host);
|
|
1691
|
+
const { dim, green, red, yellow } = paint(process13.stdout);
|
|
1692
|
+
if (options.list) {
|
|
1693
|
+
for (const each of pool)
|
|
1694
|
+
console.log(`${each.label.padEnd(12)} ${dim(each.hint)}`);
|
|
1695
|
+
if (pool.length === 0)
|
|
1696
|
+
console.log("everything is installed");
|
|
1697
|
+
return 0;
|
|
1698
|
+
}
|
|
1699
|
+
if (pool.length === 0) {
|
|
1700
|
+
console.log("everything is installed");
|
|
1701
|
+
return 0;
|
|
1702
|
+
}
|
|
1703
|
+
const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
|
|
1704
|
+
if (unknown.length > 0)
|
|
1705
|
+
badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
|
|
1706
|
+
const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
|
|
1707
|
+
const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
|
|
1708
|
+
for (const name of skipped)
|
|
1709
|
+
console.log(`${dim("already")} ${name} is installed`);
|
|
1710
|
+
if (picked.length === 0) {
|
|
1711
|
+
if (skipped.length === 0)
|
|
1712
|
+
console.log("nothing picked");
|
|
1713
|
+
return 0;
|
|
1714
|
+
}
|
|
1715
|
+
const planned = plan(host, picked, options.force);
|
|
1716
|
+
for (const each of planned.blocked)
|
|
1717
|
+
console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
|
|
1718
|
+
for (const each of planned.warnings)
|
|
1719
|
+
console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
|
|
1720
|
+
if (planned.packages.length === 0 && !planned.plugin)
|
|
1721
|
+
return 1;
|
|
1722
|
+
const command = ["bun", "install", "-g", ...planned.packages];
|
|
1723
|
+
if (planned.packages.length > 0)
|
|
1724
|
+
console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
|
|
1725
|
+
if (planned.plugin)
|
|
1726
|
+
console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
|
|
1727
|
+
if (options.dryRun)
|
|
1728
|
+
return planned.blocked.length > 0 ? 1 : 0;
|
|
1729
|
+
if (planned.packages.length > 0) {
|
|
1730
|
+
const ran = await host.run(command);
|
|
1731
|
+
if (ran.code !== 0) {
|
|
1732
|
+
console.error(`${red("failed")} bun install -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
|
|
1733
|
+
return 1;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (planned.plugin) {
|
|
1737
|
+
if (host.which("claude") === null) {
|
|
1738
|
+
console.log(`${dim("not run")} claude is not on the PATH: ${PLUGIN_INSTALL.join(" ")}`);
|
|
1739
|
+
} else {
|
|
1740
|
+
const ran = await host.run(PLUGIN_INSTALL);
|
|
1741
|
+
if (ran.code !== 0)
|
|
1742
|
+
console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
console.log(`
|
|
1746
|
+
after the install:`);
|
|
1747
|
+
const code = await recheck(checksFor(planned.packages, planned.plugin));
|
|
1748
|
+
return planned.blocked.length > 0 ? 1 : code;
|
|
1749
|
+
}
|
|
1750
|
+
function candidatesAll(host) {
|
|
1751
|
+
const open = candidates2(host);
|
|
1752
|
+
const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
|
|
1753
|
+
return [...open, ...done];
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1418
1756
|
// src/doctor.ts
|
|
1419
|
-
var USAGE = `doctor [--only <check>] [--quiet] [--fix [--dry-run]] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it
|
|
1757
|
+
var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
|
|
1420
1758
|
|
|
1421
|
-
Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it; the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both; Bun at least the version the tools ask for; fish completions for every bin.
|
|
1759
|
+
Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it when ft is installed; the system tools the installed packages need (glab, docker); the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both, with guard installed; Bun at least the version the tools ask for; fish completions for every bin. A package not installed is no failure, the last line counts them.
|
|
1422
1760
|
--fix runs the printed fix: lines it may (global installs of the packages, the completions, the hooks plugin), the installs in one call, then checks again; a line that installs a system package or only describes a step is printed as not run. --dry-run shows what --fix would run.
|
|
1761
|
+
--install adds packages: the names given (a package such as forgejo, one of its bins such as ft, hooks for the Claude Code plugin, or all), else a checklist on a terminal. Each is checked against what it needs first: a missing required tool (tea for ft, glab for gl, docker for dc) leaves that package out unless --force, and hooks brings guard along. doctor never installs a system package. Then it checks and fixes what it installed. --list prints the choices, --dry-run what would be installed.
|
|
1423
1762
|
--quiet prints only the failing checks, nothing when all pass. --only limits the run to the checks named, e.g. --only tea --only bun.`;
|
|
1424
1763
|
var EXITS = [[0, "every check passes (after --fix, the ones it fixed too)"], [1, "a check fails"], [2, "a usage error"]];
|
|
1425
1764
|
var INSTALL2 = "bun install -g ";
|
|
@@ -1428,12 +1767,15 @@ var OPTIONS = {
|
|
|
1428
1767
|
only: { type: "string", multiple: true, choices: CHECK_NAMES, description: "run only this check (a package such as cli-forgejo, tea, hooks, bun or completions), repeatable" },
|
|
1429
1768
|
quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
|
|
1430
1769
|
fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
|
|
1431
|
-
"dry-run": { type: "boolean", description: "with --fix, show what would run without running it (alone, it implies --fix)" }
|
|
1770
|
+
"dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
|
|
1771
|
+
install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
|
|
1772
|
+
list: { type: "boolean", description: "with --install, print the choices" },
|
|
1773
|
+
force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
|
|
1432
1774
|
};
|
|
1433
1775
|
function print(results) {
|
|
1434
1776
|
if (results.length === 0)
|
|
1435
1777
|
return;
|
|
1436
|
-
const { green, red, dim } = paint(
|
|
1778
|
+
const { green, red, dim } = paint(process14.stdout);
|
|
1437
1779
|
const width = Math.max(...results.map((result) => result.check.length));
|
|
1438
1780
|
for (const { check, ok, detail, fix } of results)
|
|
1439
1781
|
console.log(`${ok ? green("ok ") : red("FAIL")} ${check.padEnd(width)} ${detail}${fix === undefined ? "" : ` ${dim("fix:")} ${fix}`}`);
|
|
@@ -1443,14 +1785,21 @@ function print(results) {
|
|
|
1443
1785
|
${dim("fix them all:")} ${INSTALL2}${installs.join(" ")}`);
|
|
1444
1786
|
}
|
|
1445
1787
|
function printOutcomes(outcomes, log) {
|
|
1446
|
-
const { green, red, dim } = paint(
|
|
1788
|
+
const { green, red, dim } = paint(process14.stdout);
|
|
1447
1789
|
const marks = { ran: green("ran "), failed: red("failed "), "dry-run": dim("would "), "not run": dim("not run") };
|
|
1448
1790
|
for (const { command, status, detail } of outcomes)
|
|
1449
1791
|
log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
|
|
1450
1792
|
}
|
|
1451
1793
|
async function main(args, host = hostHere()) {
|
|
1452
|
-
const { values } = parse(args, USAGE, OPTIONS, { positionals: [], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"]] });
|
|
1794
|
+
const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
|
|
1453
1795
|
const only = values.only ?? [];
|
|
1796
|
+
if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
|
|
1797
|
+
badUsage("names, --list and --force go with --install");
|
|
1798
|
+
if (values.install === true) {
|
|
1799
|
+
if (values.fix === true || only.length > 0 || values.json === true || values.quiet === true)
|
|
1800
|
+
badUsage("--install does not combine with --fix, --only, --json or --quiet");
|
|
1801
|
+
return install(host, { names: positionals, list: values.list === true, dryRun: values["dry-run"] === true, force: values.force === true }, async (checks) => main(["--fix", ...checks.flatMap((check) => ["--only", check])], host));
|
|
1802
|
+
}
|
|
1454
1803
|
const dry = values["dry-run"] === true;
|
|
1455
1804
|
const json = values.json === true;
|
|
1456
1805
|
let results = await runChecks(host, only);
|
|
@@ -1473,6 +1822,10 @@ after the fixes:`);
|
|
|
1473
1822
|
printJson(shown);
|
|
1474
1823
|
else
|
|
1475
1824
|
print(shown);
|
|
1825
|
+
const absent = Object.keys(TOOLS).filter((name) => !isInstalled(host, name)).length;
|
|
1826
|
+
if (!json && only.length === 0 && absent > 0)
|
|
1827
|
+
console.log(`
|
|
1828
|
+
${paint(process14.stdout).dim(`${absent} packages not installed: doctor --install`)}`);
|
|
1476
1829
|
return results.every((result) => result.ok) ? 0 : 1;
|
|
1477
1830
|
}
|
|
1478
1831
|
|
|
@@ -1501,7 +1854,7 @@ var LIST_OPTIONS = {
|
|
|
1501
1854
|
json: jsonOption("[{bin, package, installed, synopsis?, summary?, replaces?, examples?}]")
|
|
1502
1855
|
};
|
|
1503
1856
|
function print2(listed) {
|
|
1504
|
-
const { cyan, dim } = paint(
|
|
1857
|
+
const { cyan, dim } = paint(process15.stdout);
|
|
1505
1858
|
const width = Math.max(...listed.map((each) => each.bin.length));
|
|
1506
1859
|
for (const each of listed) {
|
|
1507
1860
|
const what = !each.installed ? dim(`not installed: bun install -g ${each.package}`) : `${each.summary ?? ""}${each.replaces === undefined ? "" : dim(`; instead of ${each.replaces}`)}`;
|
|
@@ -1584,7 +1937,7 @@ ${table(summary.usageErrors.map((each) => [`${each.bin}${each.command === undefi
|
|
|
1584
1937
|
return 0;
|
|
1585
1938
|
}
|
|
1586
1939
|
if (import.meta.main) {
|
|
1587
|
-
|
|
1940
|
+
process15.exitCode = await runCli({
|
|
1588
1941
|
name: "cli",
|
|
1589
1942
|
about: "the bins of the @kaynooo/cli-* packages, described by their own help",
|
|
1590
1943
|
replaces: "running each bin's --help to find the one that fits",
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
-
import
|
|
5
|
+
import process15 from "process";
|
|
6
6
|
// ../kit/src/args.ts
|
|
7
7
|
import process5 from "process";
|
|
8
8
|
import { parseArgs } from "util";
|
|
@@ -274,6 +274,11 @@ function le(i = {}) {
|
|
|
274
274
|
}
|
|
275
275
|
var t = le();
|
|
276
276
|
|
|
277
|
+
// ../kit/src/theme.ts
|
|
278
|
+
var STYLES = new Set(["bold", "dim", "italic", "underline", "inverse", "strikethrough", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey", "blackBright", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright"]);
|
|
279
|
+
var MAX_FILE_BYTES = 64 * 1024;
|
|
280
|
+
var warned = new Set;
|
|
281
|
+
|
|
277
282
|
// ../kit/src/style.ts
|
|
278
283
|
function takesControlCodes(stream, env = process2.env) {
|
|
279
284
|
return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
|
|
@@ -281,14 +286,14 @@ function takesControlCodes(stream, env = process2.env) {
|
|
|
281
286
|
function paint(stream, env = process2.env) {
|
|
282
287
|
return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
|
|
283
288
|
}
|
|
284
|
-
var
|
|
285
|
-
passed:
|
|
286
|
-
failed:
|
|
287
|
-
running:
|
|
288
|
-
queued:
|
|
289
|
-
skipped:
|
|
289
|
+
var STATUS_WORDS = {
|
|
290
|
+
passed: ["passed", "success", "succeeded", "ok"],
|
|
291
|
+
failed: ["failed", "failure", "error", "errored"],
|
|
292
|
+
running: ["running", "in_progress", "started"],
|
|
293
|
+
queued: ["queued", "waiting", "pending", "created", "blocked", "scheduled"],
|
|
294
|
+
skipped: ["skipped", "cancelled", "canceled", "manual"]
|
|
290
295
|
};
|
|
291
|
-
var STATUS_OF = new Map(Object.entries(
|
|
296
|
+
var STATUS_OF = new Map(Object.entries(STATUS_WORDS).flatMap(([status, words]) => words.map((word) => [word, status])));
|
|
292
297
|
|
|
293
298
|
// ../kit/src/format.ts
|
|
294
299
|
function upTo(text, separator) {
|
|
@@ -303,7 +308,7 @@ var ESCAPE = String.fromCharCode(27);
|
|
|
303
308
|
function textWidth(text) {
|
|
304
309
|
return Bun.stringWidth(text);
|
|
305
310
|
}
|
|
306
|
-
function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
311
|
+
function table(rows, { cellWidth, header = false, align = [], fit, stream = process3.stdout } = {}) {
|
|
307
312
|
const cells = cellWidth === undefined ? rows : rows.map((row) => row.map((cell) => truncate(cell.replaceAll(LINE_BREAKS, " "), cellWidth)));
|
|
308
313
|
const widths = [];
|
|
309
314
|
for (const row of cells) {
|
|
@@ -311,7 +316,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
|
311
316
|
widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
|
|
312
317
|
}
|
|
313
318
|
const shown = fit === undefined ? cells : narrow(cells, widths, fit);
|
|
314
|
-
const colors = paint(
|
|
319
|
+
const colors = paint(stream);
|
|
315
320
|
const lines = shown.map((row) => row.map((cell, index) => {
|
|
316
321
|
const width = widths[index] ?? 0;
|
|
317
322
|
const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
|
|
@@ -319,7 +324,8 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
|
|
|
319
324
|
return `${gap}${cell}`;
|
|
320
325
|
return index === row.length - 1 ? cell : `${cell}${gap}`;
|
|
321
326
|
}).join(" ").trimEnd());
|
|
322
|
-
|
|
327
|
+
const rule = header && takesControlCodes(stream) && lines.length > 1 ? [colors.dim("\u2500".repeat(Math.max(...lines.map(textWidth))))] : [];
|
|
328
|
+
return lines.flatMap((line, index) => header && index === 0 ? [colors.bold(line), ...rule] : [line]).join(`
|
|
323
329
|
`);
|
|
324
330
|
}
|
|
325
331
|
function narrow(rows, widths, limit) {
|
|
@@ -446,6 +452,10 @@ class Exit extends Error {
|
|
|
446
452
|
this.hint = hint;
|
|
447
453
|
}
|
|
448
454
|
}
|
|
455
|
+
function fail(message, code = 2, hint) {
|
|
456
|
+
throw new Exit(message, code, hint);
|
|
457
|
+
}
|
|
458
|
+
|
|
449
459
|
class UsageError extends Exit {
|
|
450
460
|
constructor(message, hint) {
|
|
451
461
|
super(message, 2, hint);
|
|
@@ -1152,12 +1162,45 @@ var {Glob } = globalThis.Bun;
|
|
|
1152
1162
|
var SESSION_FILES = new Glob("*.json");
|
|
1153
1163
|
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
1154
1164
|
// src/doctor.ts
|
|
1155
|
-
import
|
|
1165
|
+
import process14 from "process";
|
|
1156
1166
|
|
|
1157
1167
|
// src/checks.ts
|
|
1158
1168
|
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
1159
1169
|
import { basename, join as join4 } from "path";
|
|
1160
1170
|
|
|
1171
|
+
// src/catalog.ts
|
|
1172
|
+
var HOOKS_REQUIRES = "@kaynooo/cli-guard";
|
|
1173
|
+
var ABOUT = {
|
|
1174
|
+
"@kaynooo/cli-base": "run a command at a ref in a throwaway worktree, and prove a test fails before a change",
|
|
1175
|
+
"@kaynooo/cli-changelog": "release notes from conventional commits, published as a Forgejo release",
|
|
1176
|
+
"@kaynooo/cli-check": "the repo's check, lint and test steps, its fixers, error lines from logs and a recipe picker",
|
|
1177
|
+
"@kaynooo/cli-code": "source read by spans, TODO comments and the files worth splitting",
|
|
1178
|
+
"@kaynooo/cli-db": "read-only SQL against Postgres, MySQL and MariaDB, credentials never shown",
|
|
1179
|
+
"@kaynooo/cli-dev": "passwords, JWTs, timestamps, UUIDs, base64 and file hashes",
|
|
1180
|
+
"@kaynooo/cli-docker": "docker compose on the stack found here, service health, shells and Symfony commands",
|
|
1181
|
+
"@kaynooo/cli-doctor": "this install check, and the list of every bin",
|
|
1182
|
+
"@kaynooo/cli-edit": "replace text in files, writing nothing unless every match count is right",
|
|
1183
|
+
"@kaynooo/cli-env": ".env files listed and edited with secrets masked, and a secrets filter for pipes",
|
|
1184
|
+
"@kaynooo/cli-forgejo": "Forgejo CI runs, logs, releases and issue checklists",
|
|
1185
|
+
"@kaynooo/cli-git": "repository state in one line, commits of exact paths, a branch picker",
|
|
1186
|
+
"@kaynooo/cli-gitlab": "GitLab pipelines and their failed tests",
|
|
1187
|
+
"@kaynooo/cli-guard": "the safety rules a Claude Code Bash call is held to",
|
|
1188
|
+
"@kaynooo/cli-out": "shortens a noisy command's output, keeping the errors",
|
|
1189
|
+
"@kaynooo/cli-pick": "a fuzzy selector for pipes",
|
|
1190
|
+
"@kaynooo/cli-pkg": "vets npm packages before they are added, and bumps a lib in its consumers",
|
|
1191
|
+
"@kaynooo/cli-probe": "a site's HTTP version, redirects, timings and TLS",
|
|
1192
|
+
"@kaynooo/cli-proc": "what a process is, who listens on a port, leftover dev processes",
|
|
1193
|
+
"@kaynooo/cli-stack": "a stable port per service name, and a compose release audit",
|
|
1194
|
+
"@kaynooo/cli-view": "markdown, source and git diffs in the colours of the other bins",
|
|
1195
|
+
"@kaynooo/cli-wait": "wait for a line, a port, a URL or a process; a machine-wide slot for heavy jobs",
|
|
1196
|
+
"@kaynooo/cli-workspace": "the repositories under a root needing attention, reclaimable folders and known vulnerabilities"
|
|
1197
|
+
};
|
|
1198
|
+
var NEEDS = {
|
|
1199
|
+
"@kaynooo/cli-forgejo": [{ bin: "tea", why: "ft goes through it", how: "https://gitea.com/gitea/tea/releases" }],
|
|
1200
|
+
"@kaynooo/cli-gitlab": [{ bin: "glab", why: "gl goes through it", how: "https://gitlab.com/gitlab-org/cli" }],
|
|
1201
|
+
"@kaynooo/cli-docker": [{ bin: "docker", why: "dc, dlog, dx and sf run docker compose", how: "https://docs.docker.com/engine/install/" }]
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1161
1204
|
// src/completions.ts
|
|
1162
1205
|
import { existsSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync } from "fs";
|
|
1163
1206
|
import { join as join3 } from "path";
|
|
@@ -1272,6 +1315,9 @@ function firstLine(text) {
|
|
|
1272
1315
|
function installed(host, name) {
|
|
1273
1316
|
return readJson(join4(host.globalModules, name, "package.json"));
|
|
1274
1317
|
}
|
|
1318
|
+
function isInstalled(host, name) {
|
|
1319
|
+
return installed(host, name)?.version !== undefined;
|
|
1320
|
+
}
|
|
1275
1321
|
function ownBin(host, name, bin) {
|
|
1276
1322
|
const declared = installed(host, name)?.bin;
|
|
1277
1323
|
const file = typeof declared === "string" ? declared : declared?.[bin];
|
|
@@ -1310,10 +1356,16 @@ async function checkTea(host) {
|
|
|
1310
1356
|
return { check: "tea", ok: false, detail: `${forge} did not answer: ${firstLine(version.stderr || version.stdout)}`, fix: `tea login edit ${login.name} --token <token>, or check the network` };
|
|
1311
1357
|
return { check: "tea", ok: true, detail: `logged in to ${forge} as ${login.name}, Forgejo ${served}` };
|
|
1312
1358
|
}
|
|
1313
|
-
function
|
|
1359
|
+
function hooksWiring(host) {
|
|
1314
1360
|
const settings = readJson(join4(host.home, ".claude", "settings.json")) ?? {};
|
|
1315
1361
|
const plugin = settings.enabledPlugins?.[PLUGIN] === true;
|
|
1316
1362
|
const mod = (settings.env?.CLAUDE_CODE_PLUGIN_DIRS ?? "").split(":").map((dir) => dir.replace(LEADING_TILDE, host.home)).find((dir) => basename(dir) === "cli" && existsSync2(join4(dir, "hooks", "hooks.json")));
|
|
1363
|
+
return { plugin, mod };
|
|
1364
|
+
}
|
|
1365
|
+
function checkHooks(host) {
|
|
1366
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1367
|
+
if ((plugin || mod !== undefined) && !isInstalled(host, HOOKS_REQUIRES))
|
|
1368
|
+
return { check: "hooks", ok: false, detail: `wired, but ${HOOKS_REQUIRES} is not installed, so every hook finds no guard`, fix: `bun install -g ${HOOKS_REQUIRES}` };
|
|
1317
1369
|
if (plugin && mod !== undefined)
|
|
1318
1370
|
return { check: "hooks", ok: false, detail: `both the ${PLUGIN} plugin and the mod in ${mod} run guard, so every call is checked twice`, fix: `claude plugin disable ${PLUGIN}` };
|
|
1319
1371
|
if (plugin)
|
|
@@ -1338,19 +1390,29 @@ function checkCompletions(host) {
|
|
|
1338
1390
|
return { check: "completions", ok: false, detail: `no fish completion in ${dir} for ${missing.join(", ")}`, fix: "cli completions fish --install" };
|
|
1339
1391
|
return { check: "completions", ok: true, detail: dir };
|
|
1340
1392
|
}
|
|
1393
|
+
function checkNeeds(host) {
|
|
1394
|
+
const missing = Object.entries(NEEDS).flatMap(([name, needs]) => isInstalled(host, name) ? needs.filter((need) => need.bin !== "tea" && host.which(need.bin) === null) : []);
|
|
1395
|
+
if (missing.length > 0)
|
|
1396
|
+
return { check: "needs", ok: false, detail: `missing ${missing.map((need) => `${need.bin} (${need.why})`).join(", ")}`, fix: `install ${missing.map((need) => `${need.bin}: ${need.how}`).join("; ")}` };
|
|
1397
|
+
return { check: "needs", ok: true, detail: "every tool the installed packages need is on the PATH" };
|
|
1398
|
+
}
|
|
1341
1399
|
var toolCheck = (name) => name.slice(name.indexOf("/") + 1);
|
|
1342
|
-
var OTHER_CHECKS = ["tea", "hooks", "bun", "completions"];
|
|
1400
|
+
var OTHER_CHECKS = ["tea", "hooks", "needs", "bun", "completions"];
|
|
1401
|
+
var FORGEJO = "@kaynooo/cli-forgejo";
|
|
1343
1402
|
var CHECK_NAMES = [...Object.keys(TOOLS).map(toolCheck), ...OTHER_CHECKS];
|
|
1344
1403
|
async function runChecks(host, only = []) {
|
|
1345
1404
|
const wanted = (check) => only.length === 0 || only.includes(check);
|
|
1346
|
-
const entries = Object.entries(TOOLS).filter(([name]) => wanted(toolCheck(name)));
|
|
1405
|
+
const entries = Object.entries(TOOLS).filter(([name]) => only.length === 0 ? isInstalled(host, name) : wanted(toolCheck(name)));
|
|
1406
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1407
|
+
const hooks = only.length > 0 || plugin || mod !== undefined || isInstalled(host, HOOKS_REQUIRES);
|
|
1347
1408
|
const tools = [];
|
|
1348
1409
|
for (let at = 0;at < entries.length; at += INFO_AT_ONCE)
|
|
1349
1410
|
tools.push(...await Promise.all(entries.slice(at, at + INFO_AT_ONCE).map(async ([name, bins]) => checkTool(host, name, bins))));
|
|
1350
1411
|
return [
|
|
1351
1412
|
...tools,
|
|
1352
|
-
...wanted("tea") ? [await checkTea(host)] : [],
|
|
1353
|
-
...wanted("hooks") ? [checkHooks(host)] : [],
|
|
1413
|
+
...wanted("tea") && (only.length > 0 || isInstalled(host, FORGEJO)) ? [await checkTea(host)] : [],
|
|
1414
|
+
...wanted("hooks") && hooks ? [checkHooks(host)] : [],
|
|
1415
|
+
...wanted("needs") ? [checkNeeds(host)] : [],
|
|
1354
1416
|
...wanted("bun") ? [checkBun(host)] : [],
|
|
1355
1417
|
...wanted("completions") ? [checkCompletions(host)] : []
|
|
1356
1418
|
];
|
|
@@ -1415,11 +1477,286 @@ function hostHere() {
|
|
|
1415
1477
|
};
|
|
1416
1478
|
}
|
|
1417
1479
|
|
|
1480
|
+
// src/install.ts
|
|
1481
|
+
import process13 from "process";
|
|
1482
|
+
|
|
1483
|
+
// ../kit/src/prompt.ts
|
|
1484
|
+
import process12 from "process";
|
|
1485
|
+
var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
|
|
1486
|
+
var CONTROL = /^[\x00-\x1F\x7F]/;
|
|
1487
|
+
var WORDS = /\s+/;
|
|
1488
|
+
var MAX_ROWS = 10;
|
|
1489
|
+
var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
|
|
1490
|
+
function readKeys(input) {
|
|
1491
|
+
return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
|
|
1492
|
+
}
|
|
1493
|
+
function flip(checked, value) {
|
|
1494
|
+
const next = new Set(checked);
|
|
1495
|
+
if (!next.delete(value))
|
|
1496
|
+
next.add(value);
|
|
1497
|
+
return next;
|
|
1498
|
+
}
|
|
1499
|
+
async function checklist(question, choices, { initial = new Set, min = 0, toggle = flip, ...settings } = {}) {
|
|
1500
|
+
const list = new List(choices, 0);
|
|
1501
|
+
let checked = initial;
|
|
1502
|
+
let invalid;
|
|
1503
|
+
const accepted = await ask(question, settings, (colors, state, rows) => state === "asking" ? [asking(colors, question, list.query, "type to filter, space toggles, enter accepts"), ...list.rows(colors, rows, (choice) => checked.has(choice.value) ? `${colors.cyan("\u25C9")} ` : `${colors.dim("\u25EF")} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]] : [answer(colors, question, state, choices.filter((choice) => checked.has(choice.value)).map((choice) => choice.label).join(", ") || "none")], (key) => {
|
|
1504
|
+
if (key === "cancel")
|
|
1505
|
+
return key;
|
|
1506
|
+
invalid = undefined;
|
|
1507
|
+
if (key === "enter") {
|
|
1508
|
+
invalid = checked.size < min ? `pick at least ${min}` : undefined;
|
|
1509
|
+
return invalid === undefined ? "enter" : undefined;
|
|
1510
|
+
}
|
|
1511
|
+
const current = list.current();
|
|
1512
|
+
if (typeof key === "object" && key.text === " ") {
|
|
1513
|
+
if (current !== undefined)
|
|
1514
|
+
checked = toggle(checked, current.value);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
list.press(key);
|
|
1518
|
+
return;
|
|
1519
|
+
});
|
|
1520
|
+
return accepted ? checked : undefined;
|
|
1521
|
+
}
|
|
1522
|
+
class List {
|
|
1523
|
+
choices;
|
|
1524
|
+
query = "";
|
|
1525
|
+
matches;
|
|
1526
|
+
cursor;
|
|
1527
|
+
top = 0;
|
|
1528
|
+
width;
|
|
1529
|
+
constructor(choices, initial) {
|
|
1530
|
+
this.choices = choices;
|
|
1531
|
+
this.matches = choices;
|
|
1532
|
+
this.cursor = Math.min(Math.max(initial, 0), choices.length - 1);
|
|
1533
|
+
this.width = Math.max(0, ...choices.map((choice) => choice.label.length));
|
|
1534
|
+
}
|
|
1535
|
+
current() {
|
|
1536
|
+
return this.matches[this.cursor];
|
|
1537
|
+
}
|
|
1538
|
+
press(key) {
|
|
1539
|
+
if (key === "up" || key === "down") {
|
|
1540
|
+
if (this.matches.length > 0)
|
|
1541
|
+
this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (key === "erase")
|
|
1545
|
+
this.filter([...this.query].slice(0, -1).join(""));
|
|
1546
|
+
else if (typeof key === "object")
|
|
1547
|
+
this.filter(this.query + key.text);
|
|
1548
|
+
}
|
|
1549
|
+
filter(query) {
|
|
1550
|
+
const words = query.toLowerCase().split(WORDS).filter((word) => word !== "");
|
|
1551
|
+
const current = this.current();
|
|
1552
|
+
this.query = query;
|
|
1553
|
+
this.matches = this.choices.filter(({ label, hint }) => words.every((word) => `${label} ${hint}`.toLowerCase().includes(word)));
|
|
1554
|
+
this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0);
|
|
1555
|
+
this.top = 0;
|
|
1556
|
+
}
|
|
1557
|
+
rows(colors, { columns, rows }, marker) {
|
|
1558
|
+
if (this.matches.length === 0)
|
|
1559
|
+
return [colors.dim(" no match")];
|
|
1560
|
+
const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length));
|
|
1561
|
+
this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor);
|
|
1562
|
+
const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
|
|
1563
|
+
const here = this.top + index === this.cursor;
|
|
1564
|
+
const mark = marker(choice);
|
|
1565
|
+
const head = `${here ? "\u276F" : " "} ${mark}`;
|
|
1566
|
+
const label = choice.label.padEnd(this.width);
|
|
1567
|
+
const room = columns - 5 - (mark === "" ? 0 : 2) - this.width;
|
|
1568
|
+
return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`;
|
|
1569
|
+
});
|
|
1570
|
+
const below = this.matches.length - this.top - height;
|
|
1571
|
+
return [...this.top > 0 ? [colors.dim(` \u2026 ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` \u2026 ${below} more`)] : []];
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
function asking(colors, question, typed, keys) {
|
|
1575
|
+
return `${colors.cyan("?")} ${question} ${typed === "" ? colors.dim(`(${keys})`) : typed}`;
|
|
1576
|
+
}
|
|
1577
|
+
function answer(colors, question, state, picked) {
|
|
1578
|
+
return state === "enter" ? `${colors.green("\u2713")} ${question} ${colors.cyan(picked)}` : `${colors.red("\u2717")} ${question} ${colors.dim("cancelled")}`;
|
|
1579
|
+
}
|
|
1580
|
+
async function ask(question, { hint, terminal = process12 }, render, press) {
|
|
1581
|
+
const { stdin, stderr } = terminal;
|
|
1582
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
1583
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint);
|
|
1584
|
+
const colors = paint(stderr);
|
|
1585
|
+
let drawn = 0;
|
|
1586
|
+
let outcome;
|
|
1587
|
+
const draw = (state) => {
|
|
1588
|
+
const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
|
|
1589
|
+
stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
|
|
1590
|
+
`)}`);
|
|
1591
|
+
drawn = frame.length;
|
|
1592
|
+
};
|
|
1593
|
+
try {
|
|
1594
|
+
stdin.setRawMode(true);
|
|
1595
|
+
stdin.resume();
|
|
1596
|
+
stderr.write("\x1B[?25l");
|
|
1597
|
+
draw("asking");
|
|
1598
|
+
return await new Promise((resolve) => {
|
|
1599
|
+
function onData(data) {
|
|
1600
|
+
for (const key of readKeys(data.toString())) {
|
|
1601
|
+
const pressed = press(key);
|
|
1602
|
+
if (pressed !== undefined) {
|
|
1603
|
+
settle(pressed);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
draw("asking");
|
|
1608
|
+
}
|
|
1609
|
+
function onEnd() {
|
|
1610
|
+
settle("cancel");
|
|
1611
|
+
}
|
|
1612
|
+
function settle(settled) {
|
|
1613
|
+
outcome = settled;
|
|
1614
|
+
stdin.off("data", onData);
|
|
1615
|
+
stdin.off("end", onEnd);
|
|
1616
|
+
resolve(settled === "enter");
|
|
1617
|
+
}
|
|
1618
|
+
stdin.on("data", onData);
|
|
1619
|
+
stdin.on("end", onEnd);
|
|
1620
|
+
});
|
|
1621
|
+
} finally {
|
|
1622
|
+
draw(outcome ?? "cancel");
|
|
1623
|
+
stderr.write(`
|
|
1624
|
+
\x1B[?25h`);
|
|
1625
|
+
stdin.setRawMode(false);
|
|
1626
|
+
stdin.pause();
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/install.ts
|
|
1631
|
+
var HOOKS = "hooks";
|
|
1632
|
+
var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
|
|
1633
|
+
var CLI_PREFIX = /^cli-/;
|
|
1634
|
+
var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
|
|
1635
|
+
function candidates2(host) {
|
|
1636
|
+
const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
|
|
1637
|
+
const needs = (NEEDS[name] ?? []).map((need) => need.bin);
|
|
1638
|
+
return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
|
|
1639
|
+
});
|
|
1640
|
+
const { plugin, mod } = hooksWiring(host);
|
|
1641
|
+
if (plugin || mod !== undefined)
|
|
1642
|
+
return packages;
|
|
1643
|
+
return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
|
|
1644
|
+
}
|
|
1645
|
+
function choose(pool, names) {
|
|
1646
|
+
const chosen = new Set;
|
|
1647
|
+
for (const name of names) {
|
|
1648
|
+
if (name === "all") {
|
|
1649
|
+
for (const each of pool)
|
|
1650
|
+
chosen.add(each);
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
const found = pool.find((each) => each.names.includes(name));
|
|
1654
|
+
if (found !== undefined)
|
|
1655
|
+
chosen.add(found);
|
|
1656
|
+
}
|
|
1657
|
+
return [...chosen];
|
|
1658
|
+
}
|
|
1659
|
+
function plan(host, picked, force) {
|
|
1660
|
+
const blocked = [];
|
|
1661
|
+
const warnings = [];
|
|
1662
|
+
const packages = [];
|
|
1663
|
+
for (const name of new Set(picked.flatMap((each) => each.packages))) {
|
|
1664
|
+
const lacking = (NEEDS[name] ?? []).filter((need) => host.which(need.bin) === null);
|
|
1665
|
+
if (lacking.length > 0 && !force) {
|
|
1666
|
+
for (const need of lacking)
|
|
1667
|
+
blocked.push({ package: shortName(name), need: need.bin, why: need.why, how: need.how });
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
for (const need of lacking)
|
|
1671
|
+
warnings.push({ package: shortName(name), need: need.bin, why: need.why });
|
|
1672
|
+
packages.push(name);
|
|
1673
|
+
}
|
|
1674
|
+
const guardBlocked = blocked.some((each) => `@kaynooo/cli-${each.package}` === HOOKS_REQUIRES);
|
|
1675
|
+
return { packages, plugin: picked.some((each) => each.id === HOOKS) && !guardBlocked, blocked, warnings };
|
|
1676
|
+
}
|
|
1677
|
+
function checksFor(packages, plugin) {
|
|
1678
|
+
return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
|
|
1679
|
+
}
|
|
1680
|
+
async function ask2(pool) {
|
|
1681
|
+
if (process13.stdin.isTTY !== true || process13.stderr.isTTY !== true)
|
|
1682
|
+
badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
|
|
1683
|
+
const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
|
|
1684
|
+
const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
|
|
1685
|
+
return picked === undefined ? [] : [...picked];
|
|
1686
|
+
}
|
|
1687
|
+
async function install(host, options, recheck) {
|
|
1688
|
+
const pool = candidates2(host);
|
|
1689
|
+
const { dim, green, red, yellow } = paint(process13.stdout);
|
|
1690
|
+
if (options.list) {
|
|
1691
|
+
for (const each of pool)
|
|
1692
|
+
console.log(`${each.label.padEnd(12)} ${dim(each.hint)}`);
|
|
1693
|
+
if (pool.length === 0)
|
|
1694
|
+
console.log("everything is installed");
|
|
1695
|
+
return 0;
|
|
1696
|
+
}
|
|
1697
|
+
if (pool.length === 0) {
|
|
1698
|
+
console.log("everything is installed");
|
|
1699
|
+
return 0;
|
|
1700
|
+
}
|
|
1701
|
+
const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
|
|
1702
|
+
if (unknown.length > 0)
|
|
1703
|
+
badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
|
|
1704
|
+
const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
|
|
1705
|
+
const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
|
|
1706
|
+
for (const name of skipped)
|
|
1707
|
+
console.log(`${dim("already")} ${name} is installed`);
|
|
1708
|
+
if (picked.length === 0) {
|
|
1709
|
+
if (skipped.length === 0)
|
|
1710
|
+
console.log("nothing picked");
|
|
1711
|
+
return 0;
|
|
1712
|
+
}
|
|
1713
|
+
const planned = plan(host, picked, options.force);
|
|
1714
|
+
for (const each of planned.blocked)
|
|
1715
|
+
console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
|
|
1716
|
+
for (const each of planned.warnings)
|
|
1717
|
+
console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
|
|
1718
|
+
if (planned.packages.length === 0 && !planned.plugin)
|
|
1719
|
+
return 1;
|
|
1720
|
+
const command = ["bun", "install", "-g", ...planned.packages];
|
|
1721
|
+
if (planned.packages.length > 0)
|
|
1722
|
+
console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
|
|
1723
|
+
if (planned.plugin)
|
|
1724
|
+
console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
|
|
1725
|
+
if (options.dryRun)
|
|
1726
|
+
return planned.blocked.length > 0 ? 1 : 0;
|
|
1727
|
+
if (planned.packages.length > 0) {
|
|
1728
|
+
const ran = await host.run(command);
|
|
1729
|
+
if (ran.code !== 0) {
|
|
1730
|
+
console.error(`${red("failed")} bun install -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
|
|
1731
|
+
return 1;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (planned.plugin) {
|
|
1735
|
+
if (host.which("claude") === null) {
|
|
1736
|
+
console.log(`${dim("not run")} claude is not on the PATH: ${PLUGIN_INSTALL.join(" ")}`);
|
|
1737
|
+
} else {
|
|
1738
|
+
const ran = await host.run(PLUGIN_INSTALL);
|
|
1739
|
+
if (ran.code !== 0)
|
|
1740
|
+
console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
console.log(`
|
|
1744
|
+
after the install:`);
|
|
1745
|
+
const code = await recheck(checksFor(planned.packages, planned.plugin));
|
|
1746
|
+
return planned.blocked.length > 0 ? 1 : code;
|
|
1747
|
+
}
|
|
1748
|
+
function candidatesAll(host) {
|
|
1749
|
+
const open = candidates2(host);
|
|
1750
|
+
const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
|
|
1751
|
+
return [...open, ...done];
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1418
1754
|
// src/doctor.ts
|
|
1419
|
-
var USAGE = `doctor [--only <check>] [--quiet] [--fix [--dry-run]] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it
|
|
1755
|
+
var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
|
|
1420
1756
|
|
|
1421
|
-
Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it; the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both; Bun at least the version the tools ask for; fish completions for every bin.
|
|
1757
|
+
Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it when ft is installed; the system tools the installed packages need (glab, docker); the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both, with guard installed; Bun at least the version the tools ask for; fish completions for every bin. A package not installed is no failure, the last line counts them.
|
|
1422
1758
|
--fix runs the printed fix: lines it may (global installs of the packages, the completions, the hooks plugin), the installs in one call, then checks again; a line that installs a system package or only describes a step is printed as not run. --dry-run shows what --fix would run.
|
|
1759
|
+
--install adds packages: the names given (a package such as forgejo, one of its bins such as ft, hooks for the Claude Code plugin, or all), else a checklist on a terminal. Each is checked against what it needs first: a missing required tool (tea for ft, glab for gl, docker for dc) leaves that package out unless --force, and hooks brings guard along. doctor never installs a system package. Then it checks and fixes what it installed. --list prints the choices, --dry-run what would be installed.
|
|
1423
1760
|
--quiet prints only the failing checks, nothing when all pass. --only limits the run to the checks named, e.g. --only tea --only bun.`;
|
|
1424
1761
|
var EXITS = [[0, "every check passes (after --fix, the ones it fixed too)"], [1, "a check fails"], [2, "a usage error"]];
|
|
1425
1762
|
var INSTALL2 = "bun install -g ";
|
|
@@ -1428,12 +1765,15 @@ var OPTIONS = {
|
|
|
1428
1765
|
only: { type: "string", multiple: true, choices: CHECK_NAMES, description: "run only this check (a package such as cli-forgejo, tea, hooks, bun or completions), repeatable" },
|
|
1429
1766
|
quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
|
|
1430
1767
|
fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
|
|
1431
|
-
"dry-run": { type: "boolean", description: "with --fix, show what would run without running it (alone, it implies --fix)" }
|
|
1768
|
+
"dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
|
|
1769
|
+
install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
|
|
1770
|
+
list: { type: "boolean", description: "with --install, print the choices" },
|
|
1771
|
+
force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
|
|
1432
1772
|
};
|
|
1433
1773
|
function print(results) {
|
|
1434
1774
|
if (results.length === 0)
|
|
1435
1775
|
return;
|
|
1436
|
-
const { green, red, dim } = paint(
|
|
1776
|
+
const { green, red, dim } = paint(process14.stdout);
|
|
1437
1777
|
const width = Math.max(...results.map((result) => result.check.length));
|
|
1438
1778
|
for (const { check, ok, detail, fix } of results)
|
|
1439
1779
|
console.log(`${ok ? green("ok ") : red("FAIL")} ${check.padEnd(width)} ${detail}${fix === undefined ? "" : ` ${dim("fix:")} ${fix}`}`);
|
|
@@ -1443,14 +1783,21 @@ function print(results) {
|
|
|
1443
1783
|
${dim("fix them all:")} ${INSTALL2}${installs.join(" ")}`);
|
|
1444
1784
|
}
|
|
1445
1785
|
function printOutcomes(outcomes, log) {
|
|
1446
|
-
const { green, red, dim } = paint(
|
|
1786
|
+
const { green, red, dim } = paint(process14.stdout);
|
|
1447
1787
|
const marks = { ran: green("ran "), failed: red("failed "), "dry-run": dim("would "), "not run": dim("not run") };
|
|
1448
1788
|
for (const { command, status, detail } of outcomes)
|
|
1449
1789
|
log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
|
|
1450
1790
|
}
|
|
1451
1791
|
async function main(args, host = hostHere()) {
|
|
1452
|
-
const { values } = parse(args, USAGE, OPTIONS, { positionals: [], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"]] });
|
|
1792
|
+
const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
|
|
1453
1793
|
const only = values.only ?? [];
|
|
1794
|
+
if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
|
|
1795
|
+
badUsage("names, --list and --force go with --install");
|
|
1796
|
+
if (values.install === true) {
|
|
1797
|
+
if (values.fix === true || only.length > 0 || values.json === true || values.quiet === true)
|
|
1798
|
+
badUsage("--install does not combine with --fix, --only, --json or --quiet");
|
|
1799
|
+
return install(host, { names: positionals, list: values.list === true, dryRun: values["dry-run"] === true, force: values.force === true }, async (checks) => main(["--fix", ...checks.flatMap((check) => ["--only", check])], host));
|
|
1800
|
+
}
|
|
1454
1801
|
const dry = values["dry-run"] === true;
|
|
1455
1802
|
const json = values.json === true;
|
|
1456
1803
|
let results = await runChecks(host, only);
|
|
@@ -1473,8 +1820,12 @@ after the fixes:`);
|
|
|
1473
1820
|
printJson(shown);
|
|
1474
1821
|
else
|
|
1475
1822
|
print(shown);
|
|
1823
|
+
const absent = Object.keys(TOOLS).filter((name) => !isInstalled(host, name)).length;
|
|
1824
|
+
if (!json && only.length === 0 && absent > 0)
|
|
1825
|
+
console.log(`
|
|
1826
|
+
${paint(process14.stdout).dim(`${absent} packages not installed: doctor --install`)}`);
|
|
1476
1827
|
return results.every((result) => result.ok) ? 0 : 1;
|
|
1477
1828
|
}
|
|
1478
1829
|
|
|
1479
1830
|
// src/cli.ts
|
|
1480
|
-
|
|
1831
|
+
process15.exitCode = await runMain({ name: "doctor", run: async (args) => main(args) });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-doctor",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "doctor: checks the @kaynooo/cli-* install of a host: each bin's version against the registry, tea's login to Forgejo, the Claude Code hooks and the Bun floor; cli list: every bin in one line, read from its own help.",
|
|
6
6
|
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|