@wirecat/cli-base 0.65.5 → 0.66.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/CHANGELOG.md +26 -0
- package/README.md +4 -0
- package/dist/dev/main.d.ts +5 -0
- package/dist/dev/main.js +31 -16
- package/dist/dev/wcat-lint.d.ts +15 -0
- package/dist/dev/wcat-lint.js +42 -0
- package/dist/installer.d.ts +7 -0
- package/dist/installer.js +17 -0
- package/dist/standards/errors.js +6 -0
- package/dist/update/index.d.ts +2 -7
- package/dist/update/index.js +1 -17
- package/dist/wcat/describe.d.ts +22 -0
- package/dist/wcat/describe.js +31 -0
- package/dist/wcat/dispatch.d.ts +43 -0
- package/dist/wcat/dispatch.js +184 -0
- package/dist/wcat/index.d.ts +16 -0
- package/dist/wcat/index.js +16 -0
- package/dist/wcat/launcher.d.ts +40 -0
- package/dist/wcat/launcher.js +109 -0
- package/dist/wcat/names.d.ts +16 -0
- package/dist/wcat/names.js +27 -0
- package/dist/wcat/path.d.ts +15 -0
- package/dist/wcat/path.js +83 -0
- package/dist/wcat/probe.d.ts +11 -0
- package/dist/wcat/probe.js +58 -0
- package/dist/wcat/shim.d.ts +45 -0
- package/dist/wcat/shim.js +104 -0
- package/dist/wcat/shortcuts.d.ts +31 -0
- package/dist/wcat/shortcuts.js +105 -0
- package/dist/wcat/start.d.ts +38 -0
- package/dist/wcat/start.js +45 -0
- package/docs/standards/README.md +1 -1
- package/docs/standards/commands.md +20 -1
- package/docs/standards/new-cli.md +26 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
|
|
|
8
8
|
Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
|
|
9
9
|
[`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
|
|
10
10
|
|
|
11
|
+
## 0.66.0 — 31.10.2026
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`wcat`, one command that runs every tool: `wcat tg …` runs `wcat-tg …`** (`@wirecat/cli-base/wcat`). Another
|
|
16
|
+
program named `tg` or `drive` can sit earlier on PATH, and an agent or a script then runs it without any sign; a
|
|
17
|
+
`wcat-<name>` name is the tool's alone. Any `wcat-<name>` program on PATH joins, ours or not. `wcat --help` lists
|
|
18
|
+
what is installed with each tool's description, the WireCat tools that are not, with their install commands, and
|
|
19
|
+
warns when two programs share a name; `--json` prints the list. Built-in names (`help`, `version`, `shortcuts`,
|
|
20
|
+
`doctor`, `update`, `upgrade`, `app`) always belong to `wcat`. An unknown tool is `not_found` with the new reason
|
|
21
|
+
`tool.not-installed`. Nothing changes for a CLI until it calls `wcatStart`.
|
|
22
|
+
- **`wcatStart(tool)` from `@wirecat/cli-base/wcat/start`, the first call in a CLI's bin.** It answers
|
|
23
|
+
`--wcat-describe` with the tool's name, version, description and docs page, runs `wcat` when the `wcat` launcher
|
|
24
|
+
starts the tool, and on any other start makes sure the `wcat` launcher in the tool's global bin folder works. The
|
|
25
|
+
launcher is a small marked script that starts an installed `wcat-<tool>` by name (a `.cmd` and `.ps1` pair on
|
|
26
|
+
Windows), never a link into a package folder, which pnpm renames on every upgrade. A file named `wcat` that
|
|
27
|
+
cli-base did not write is never touched. When the launcher works, the check costs a few `stat` calls; it loads
|
|
28
|
+
only Node built-ins. Call it before `startCli`, and set `process.exitCode` to the `code` it returns.
|
|
29
|
+
- **`wcat shortcuts`, and `listShortcuts`, `addShortcuts` and `removeShortcuts` for a program.** A tool's short name,
|
|
30
|
+
such as `tg`, is made only when no program on PATH has it, and only shortcuts cli-base made are removed. A short
|
|
31
|
+
name the tool's package installs itself shows as `package` and is left to the package manager.
|
|
32
|
+
- **`RESERVED_NAMES` and a `cli-dev lint` warning for joining `wcat`** ([WC-CMD-29](docs/standards/commands.md#wc-cmd-29--answer---wcat-describe-and-take-no-name-wirecat-reserves)).
|
|
33
|
+
The lint warns when a `"bin"` key does not start with `wcat-`, when a package outside `@wirecat/` takes a reserved
|
|
34
|
+
name, and, with `--cli`, when the built CLI does not answer `--wcat-describe`. The warnings do not fail the run:
|
|
35
|
+
every CLI still installs its short name today.
|
|
36
|
+
|
|
11
37
|
## 0.65.5 — 31.10.2026
|
|
12
38
|
|
|
13
39
|
### Added
|
package/README.md
CHANGED
|
@@ -187,6 +187,10 @@ operation and never falls back to `any`. 64-bit integers stay exact.
|
|
|
187
187
|
- **Update checks that never update unasked.** Which package manager installed the CLI, the command that updates it,
|
|
188
188
|
and a daily notice only when a person is at a terminal. A tool holding someone's credentials does not change itself.
|
|
189
189
|
- **Release checks** every CLI shares: changelog shape, dead links, package contents, versions in step.
|
|
190
|
+
- **`wcat`, one command for every tool.** `wcat tg …` runs `wcat-tg …`, like `git foo` runs `git-foo`, and any
|
|
191
|
+
`wcat-<name>` program on PATH joins. `wcat --help` lists what is installed with each tool's description;
|
|
192
|
+
`wcat shortcuts add` gives a tool its short name only where no other program has it. Each tool keeps the `wcat`
|
|
193
|
+
launcher working on start, at the cost of a few `stat` calls.
|
|
190
194
|
|
|
191
195
|
## Engineering
|
|
192
196
|
|
package/dist/dev/main.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
1
2
|
import { type RunLogProbe } from "./lint.js";
|
|
2
3
|
export interface DevIo {
|
|
3
4
|
cwd: string;
|
|
@@ -12,4 +13,8 @@ export interface DevIo {
|
|
|
12
13
|
* `XDG_STATE_HOME` would point it at the owner's real runs.
|
|
13
14
|
*/
|
|
14
15
|
export declare const probeRunLog: (cwd: string, bin: string, environment?: NodeJS.ProcessEnv) => RunLogProbe;
|
|
16
|
+
type HomeCall = (args: string[]) => ReturnType<typeof spawn.sync>;
|
|
17
|
+
/** Runs the built CLI in a new empty home with only PATH from this environment, and removes the home after. */
|
|
18
|
+
export declare const inEmptyHome: <T>(cwd: string, bin: string, environment: NodeJS.ProcessEnv, body: (call: HomeCall) => T) => T;
|
|
15
19
|
export declare const main: (argv: readonly string[], io: DevIo) => Promise<number>;
|
|
20
|
+
export {};
|
package/dist/dev/main.js
CHANGED
|
@@ -25,6 +25,7 @@ import { baselineOf, judge, lintCommands, RUN_LOG_COMMANDS, walk } from "./lint.
|
|
|
25
25
|
import { nextVersion } from "./next-version.js";
|
|
26
26
|
import { slowTests } from "./slow-tests.js";
|
|
27
27
|
import { matrixPage, matrixRows, parseArgvLog } from "./test-matrix.js";
|
|
28
|
+
import { binNameWarnings, describeWarnings } from "./wcat-lint.js";
|
|
28
29
|
const USAGE = `usage: cli-dev <command> [options]
|
|
29
30
|
|
|
30
31
|
slow-tests [report] [count] the slowest tests and files (report: coverage/tests.json, count: 20)
|
|
@@ -257,7 +258,25 @@ const PROBE_FLAG = "--cli-dev-run-log-probe";
|
|
|
257
258
|
* kept. The CLI sees a new empty home and nothing else from this environment: a stray `*_STATE_DIR` or
|
|
258
259
|
* `XDG_STATE_HOME` would point it at the owner's real runs.
|
|
259
260
|
*/
|
|
260
|
-
export const probeRunLog = (cwd, bin, environment = process.env) => {
|
|
261
|
+
export const probeRunLog = (cwd, bin, environment = process.env) => inEmptyHome(cwd, bin, environment, (call) => {
|
|
262
|
+
const failed = call([PROBE_FLAG]);
|
|
263
|
+
if (failed.status === 0)
|
|
264
|
+
return { kept: false, detail: `\`${PROBE_FLAG}\` did not fail, so nothing could be kept` };
|
|
265
|
+
const listed = call(["runs", "list", "--json"]);
|
|
266
|
+
if (listed.status !== 0)
|
|
267
|
+
return { kept: false, detail: `\`runs list --json\` exited ${listed.status ?? listed.error?.message}` };
|
|
268
|
+
let items;
|
|
269
|
+
try {
|
|
270
|
+
items = JSON.parse(String(listed.stdout)).items;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return { kept: false, detail: "`runs list --json` did not print JSON" };
|
|
274
|
+
}
|
|
275
|
+
const kept = Array.isArray(items) && items.some((item) => item?.status === "failed");
|
|
276
|
+
return kept ? { kept } : { kept, detail: `\`runs list --json\` shows no failed run after \`${PROBE_FLAG}\`` };
|
|
277
|
+
});
|
|
278
|
+
/** Runs the built CLI in a new empty home with only PATH from this environment, and removes the home after. */
|
|
279
|
+
export const inEmptyHome = (cwd, bin, environment, body) => {
|
|
261
280
|
const home = mkdtempSync(join(tmpdir(), "cli-dev-run-log-"));
|
|
262
281
|
const env = {
|
|
263
282
|
PATH: environment.PATH ?? "",
|
|
@@ -277,21 +296,7 @@ export const probeRunLog = (cwd, bin, environment = process.env) => {
|
|
|
277
296
|
maxBuffer: 16 * 1024 * 1024,
|
|
278
297
|
});
|
|
279
298
|
try {
|
|
280
|
-
|
|
281
|
-
if (failed.status === 0)
|
|
282
|
-
return { kept: false, detail: `\`${PROBE_FLAG}\` did not fail, so nothing could be kept` };
|
|
283
|
-
const listed = call(["runs", "list", "--json"]);
|
|
284
|
-
if (listed.status !== 0)
|
|
285
|
-
return { kept: false, detail: `\`runs list --json\` exited ${listed.status ?? listed.error?.message}` };
|
|
286
|
-
let items;
|
|
287
|
-
try {
|
|
288
|
-
items = JSON.parse(String(listed.stdout)).items;
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return { kept: false, detail: "`runs list --json` did not print JSON" };
|
|
292
|
-
}
|
|
293
|
-
const kept = Array.isArray(items) && items.some((item) => item?.status === "failed");
|
|
294
|
-
return kept ? { kept } : { kept, detail: `\`runs list --json\` shows no failed run after \`${PROBE_FLAG}\`` };
|
|
299
|
+
return body(call);
|
|
295
300
|
}
|
|
296
301
|
finally {
|
|
297
302
|
rmSync(home, { recursive: true, force: true });
|
|
@@ -406,8 +411,18 @@ const lintRun = (args, io) => {
|
|
|
406
411
|
if (verdict.overBudget)
|
|
407
412
|
problems.push(`${overrides.length} overrides; the budget is ${budget}`);
|
|
408
413
|
io.err(`lint: ${violations.length} violations — ${verdict.baselined} in the baseline, ${verdict.overridden} overridden (budget ${budget}), ${verdict.fresh.length} new`);
|
|
414
|
+
for (const warning of wcatWarnings(io.cwd, values.cli))
|
|
415
|
+
io.err(`warning ${warning.rule} ${warning.target}: ${warning.message}`);
|
|
409
416
|
return report(io, problems, "lint: ok");
|
|
410
417
|
};
|
|
418
|
+
const wcatWarnings = (cwd, cli) => {
|
|
419
|
+
const pkgPath = join(cwd, "package.json");
|
|
420
|
+
const pkg = existsSync(pkgPath) ? readJson(pkgPath) : {};
|
|
421
|
+
return [
|
|
422
|
+
...binNameWarnings(pkg),
|
|
423
|
+
...(cli ? describeWarnings(inEmptyHome(cwd, cli, process.env, (call) => call(["--wcat-describe"]))) : []),
|
|
424
|
+
];
|
|
425
|
+
};
|
|
411
426
|
const byCli = (pairs, flag) => new Map((pairs ?? []).map((pair) => {
|
|
412
427
|
const [cli, path] = pair.split(/=(.*)/s);
|
|
413
428
|
if (!cli || !path)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface WcatWarning {
|
|
2
|
+
rule: "wcat-bin-name" | "wcat-describe";
|
|
3
|
+
target: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
/** Each command is `wcat-<name>`, and a package outside `@wirecat/` takes no reserved name. */
|
|
7
|
+
export declare const binNameWarnings: (pkg: {
|
|
8
|
+
name?: unknown;
|
|
9
|
+
bin?: unknown;
|
|
10
|
+
}) => WcatWarning[];
|
|
11
|
+
/** The built CLI answers `--wcat-describe` with its JSON description. */
|
|
12
|
+
export declare const describeWarnings: (answer: {
|
|
13
|
+
status: number | null;
|
|
14
|
+
stdout: unknown;
|
|
15
|
+
}) => WcatWarning[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cli-dev lint`'s checks for joining `wcat` (WC-CMD-29). They warn and never fail the run: every CLI still installs
|
|
3
|
+
* its short name until it moves to `wcat-<name>`, and the warning becomes a failure once they have.
|
|
4
|
+
*/
|
|
5
|
+
import { parseDescribe } from "../wcat/describe.js";
|
|
6
|
+
import { DESCRIBE_FLAG, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME } from "../wcat/names.js";
|
|
7
|
+
const WIRECAT_SCOPE = "@wirecat/";
|
|
8
|
+
/** The commands a `package.json` installs: its `"bin"` keys, or the package's own name for a `"bin"` string. */
|
|
9
|
+
const binNames = (pkg) => {
|
|
10
|
+
if (typeof pkg.bin === "string")
|
|
11
|
+
return typeof pkg.name === "string" ? [pkg.name.replace(/^@[^/]+\//, "")] : [];
|
|
12
|
+
return typeof pkg.bin === "object" && pkg.bin !== null ? Object.keys(pkg.bin) : [];
|
|
13
|
+
};
|
|
14
|
+
/** Each command is `wcat-<name>`, and a package outside `@wirecat/` takes no reserved name. */
|
|
15
|
+
export const binNameWarnings = (pkg) => binNames(pkg).flatMap((command) => {
|
|
16
|
+
const target = `package.json "bin" ${command}`;
|
|
17
|
+
if (!command.startsWith(PROGRAM_PREFIX))
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
rule: "wcat-bin-name",
|
|
21
|
+
target,
|
|
22
|
+
message: `install the command as ${PROGRAM_PREFIX}${command}, so no other program on PATH has its name`,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
const name = command.slice(PROGRAM_PREFIX.length);
|
|
26
|
+
if (!TOOL_NAME.test(name))
|
|
27
|
+
return [{ rule: "wcat-bin-name", target, message: "the name after wcat- is lower-case letters, digits and -" }];
|
|
28
|
+
const ours = typeof pkg.name === "string" && pkg.name.startsWith(WIRECAT_SCOPE);
|
|
29
|
+
if (!ours && RESERVED_NAMES.includes(name))
|
|
30
|
+
return [{ rule: "wcat-bin-name", target, message: `${name} is reserved for WireCat's own tools or wcat itself` }];
|
|
31
|
+
return [];
|
|
32
|
+
});
|
|
33
|
+
/** The built CLI answers `--wcat-describe` with its JSON description. */
|
|
34
|
+
export const describeWarnings = (answer) => answer.status === 0 && parseDescribe(String(answer.stdout))
|
|
35
|
+
? []
|
|
36
|
+
: [
|
|
37
|
+
{
|
|
38
|
+
rule: "wcat-describe",
|
|
39
|
+
target: DESCRIBE_FLAG,
|
|
40
|
+
message: `the CLI does not answer ${DESCRIBE_FLAG}: call wcatStart from @wirecat/cli-base/wcat/start first in its bin`,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type Installer = "pnpm" | "npm" | "bun" | "npx" | "checkout" | "unknown";
|
|
2
|
+
/**
|
|
3
|
+
* Read from where the running file really lives — `realpath` of the script, not `which`: the
|
|
4
|
+
* command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
|
|
5
|
+
* on 2026-09-24 by installing into throwaway prefixes.
|
|
6
|
+
*/
|
|
7
|
+
export declare const installerOf: (scriptPath: string) => Installer;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read from where the running file really lives — `realpath` of the script, not `which`: the
|
|
3
|
+
* command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
|
|
4
|
+
* on 2026-09-24 by installing into throwaway prefixes.
|
|
5
|
+
*/
|
|
6
|
+
export const installerOf = (scriptPath) => {
|
|
7
|
+
const path = scriptPath.replaceAll("\\", "/");
|
|
8
|
+
if (path.includes("/_npx/"))
|
|
9
|
+
return "npx";
|
|
10
|
+
if (path.includes("/install/global/node_modules/"))
|
|
11
|
+
return "bun";
|
|
12
|
+
if (/\/\.?pnpm\//.test(path))
|
|
13
|
+
return "pnpm";
|
|
14
|
+
if (path.includes("/node_modules/"))
|
|
15
|
+
return "npm";
|
|
16
|
+
return path.includes("/dist/") ? "checkout" : "unknown";
|
|
17
|
+
};
|
package/dist/standards/errors.js
CHANGED
|
@@ -61,6 +61,12 @@ export const BASE_REASONS = defineReasons([
|
|
|
61
61
|
{ reason: "auth.failed", code: "authentication_error", message: "the sign-in did not work", details: [] },
|
|
62
62
|
{ reason: "access.denied", code: "permission_error", message: "this account may not do that", details: [] },
|
|
63
63
|
{ reason: "item.not-found", code: "not_found", message: "nothing found by that name", details: [] },
|
|
64
|
+
{
|
|
65
|
+
reason: "tool.not-installed",
|
|
66
|
+
code: "not_found",
|
|
67
|
+
message: "no wcat-<name> program by that name is on PATH",
|
|
68
|
+
details: ["tool", "installed"],
|
|
69
|
+
},
|
|
64
70
|
{
|
|
65
71
|
reason: "confirmation.required",
|
|
66
72
|
code: "confirmation_required",
|
package/dist/update/index.d.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
export { executableEnvironment, executableOnPath } from "../executable.js";
|
|
2
2
|
import * as v from "valibot";
|
|
3
3
|
import type { FetchLike } from "../http/index.js";
|
|
4
|
-
export type Installer
|
|
5
|
-
|
|
6
|
-
* Read from where the running file really lives — `realpath` of the script, not `which`: the
|
|
7
|
-
* command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
|
|
8
|
-
* on 2026-09-24 by installing into throwaway prefixes.
|
|
9
|
-
*/
|
|
10
|
-
export declare const installerOf: (scriptPath: string) => Installer;
|
|
4
|
+
export { type Installer, installerOf } from "../installer.js";
|
|
5
|
+
import type { Installer } from "../installer.js";
|
|
11
6
|
/** The argv that updates a global install, or `undefined` where there is nothing to run. */
|
|
12
7
|
export declare const updateCommand: (installer: Installer, packageName: string) => string[] | undefined;
|
|
13
8
|
/** Plain `x.y.z` only: a pre-release is never offered, so it never counts as newer. */
|
package/dist/update/index.js
CHANGED
|
@@ -12,23 +12,7 @@ import { executableEnvironment, executableOnPath } from "../executable.js";
|
|
|
12
12
|
export { executableEnvironment, executableOnPath } from "../executable.js";
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
import { loadConfigFile, saveConfigFile } from "../config.js";
|
|
15
|
-
|
|
16
|
-
* Read from where the running file really lives — `realpath` of the script, not `which`: the
|
|
17
|
-
* command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
|
|
18
|
-
* on 2026-09-24 by installing into throwaway prefixes.
|
|
19
|
-
*/
|
|
20
|
-
export const installerOf = (scriptPath) => {
|
|
21
|
-
const path = scriptPath.replaceAll("\\", "/");
|
|
22
|
-
if (path.includes("/_npx/"))
|
|
23
|
-
return "npx";
|
|
24
|
-
if (path.includes("/install/global/node_modules/"))
|
|
25
|
-
return "bun";
|
|
26
|
-
if (/\/\.?pnpm\//.test(path))
|
|
27
|
-
return "pnpm";
|
|
28
|
-
if (path.includes("/node_modules/"))
|
|
29
|
-
return "npm";
|
|
30
|
-
return path.includes("/dist/") ? "checkout" : "unknown";
|
|
31
|
-
};
|
|
15
|
+
export { installerOf } from "../installer.js";
|
|
32
16
|
/** The argv that updates a global install, or `undefined` where there is nothing to run. */
|
|
33
17
|
export const updateCommand = (installer, packageName) => {
|
|
34
18
|
const target = `${packageName}@latest`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** What a tool says about itself to `wcat`, from `wcat-<name> --wcat-describe` or the tool's own metadata. */
|
|
2
|
+
export interface WcatTool {
|
|
3
|
+
/** The name in `wcat <name>`, and the short command: `tg`. */
|
|
4
|
+
command: string;
|
|
5
|
+
version: string;
|
|
6
|
+
/** One line: what the tool reads or does. */
|
|
7
|
+
description: string;
|
|
8
|
+
/** The tool's documentation page. */
|
|
9
|
+
docs?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Format of the `--wcat-describe` answer; a later format adds keys and keeps these. */
|
|
12
|
+
export declare const DESCRIBE_FORMAT = 1;
|
|
13
|
+
export interface DescribeAnswer {
|
|
14
|
+
wcat_describe: number;
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
description: string;
|
|
18
|
+
docs_url?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const describeAnswer: (tool: WcatTool) => DescribeAnswer;
|
|
21
|
+
/** A `--wcat-describe` answer, or `undefined` for anything else: the program is listed either way. */
|
|
22
|
+
export declare const parseDescribe: (text: string) => Omit<WcatTool, "command"> | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { singleLine } from "../sanitize.js";
|
|
2
|
+
/** Format of the `--wcat-describe` answer; a later format adds keys and keeps these. */
|
|
3
|
+
export const DESCRIBE_FORMAT = 1;
|
|
4
|
+
export const describeAnswer = (tool) => ({
|
|
5
|
+
wcat_describe: DESCRIBE_FORMAT,
|
|
6
|
+
name: tool.command,
|
|
7
|
+
version: tool.version,
|
|
8
|
+
description: tool.description,
|
|
9
|
+
...(tool.docs ? { docs_url: tool.docs } : {}),
|
|
10
|
+
});
|
|
11
|
+
const MAX_TEXT = 200;
|
|
12
|
+
const clean = (value) => typeof value === "string" && value.trim() !== "" ? singleLine(value.trim()).slice(0, MAX_TEXT) : undefined;
|
|
13
|
+
/** A `--wcat-describe` answer, or `undefined` for anything else: the program is listed either way. */
|
|
14
|
+
export const parseDescribe = (text) => {
|
|
15
|
+
let answer;
|
|
16
|
+
try {
|
|
17
|
+
answer = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (typeof answer !== "object" || answer === null || !("wcat_describe" in answer))
|
|
23
|
+
return undefined;
|
|
24
|
+
const { version, description, docs_url: docs } = answer;
|
|
25
|
+
const url = clean(docs);
|
|
26
|
+
return {
|
|
27
|
+
version: clean(version) ?? "",
|
|
28
|
+
description: clean(description) ?? "",
|
|
29
|
+
...(url && /^https?:\/\//.test(url) ? { docs: url } : {}),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WcatTool } from "./describe.js";
|
|
2
|
+
import { type SearchEnv } from "./path.js";
|
|
3
|
+
interface Output {
|
|
4
|
+
write: (text: string) => unknown;
|
|
5
|
+
isTTY?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface WcatContext extends SearchEnv {
|
|
8
|
+
stdout: Output;
|
|
9
|
+
stderr: Output;
|
|
10
|
+
/** The tool running the dispatcher: described from its metadata, without starting it again. */
|
|
11
|
+
self?: WcatTool;
|
|
12
|
+
/** How long one `--wcat-describe` may take; 2 s when left out. */
|
|
13
|
+
describeTimeoutMs?: number;
|
|
14
|
+
/** Where SIGTERM and SIGHUP arrive, to pass on to the tool; `process` when left out. */
|
|
15
|
+
signals?: Pick<NodeJS.Process, "on" | "off">;
|
|
16
|
+
}
|
|
17
|
+
export interface ListedTool {
|
|
18
|
+
name: string;
|
|
19
|
+
installed: boolean;
|
|
20
|
+
/** The program `wcat <name>` starts. */
|
|
21
|
+
path?: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
docs_url?: string;
|
|
25
|
+
/** Other programs with the same name, later on PATH: `wcat` never runs them. */
|
|
26
|
+
shadowed?: string[];
|
|
27
|
+
package?: string;
|
|
28
|
+
install?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Every `wcat-<name>` on PATH with what it says about itself, then WireCat's own tools that are not installed. */
|
|
31
|
+
export declare const listTools: (context: WcatContext) => Promise<ListedTool[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Starts `wcat-<name>` with the rest of the words, the same streams and environment, and answers its exit code.
|
|
34
|
+
* SIGTERM and SIGHUP are passed on. Ctrl-C reaches the tool from the terminal by itself, so SIGINT is only kept from
|
|
35
|
+
* ending `wcat` before the tool does.
|
|
36
|
+
*/
|
|
37
|
+
export declare const runTool: (path: string, args: readonly string[], context: WcatContext) => Promise<number>;
|
|
38
|
+
/**
|
|
39
|
+
* `wcat` itself: the words after `wcat`. Built-in commands come first, so a `wcat-<name>` program can never take
|
|
40
|
+
* one over; anything else names a tool, and the rest of the line is that tool's.
|
|
41
|
+
*/
|
|
42
|
+
export declare const runWcat: (args: readonly string[], context: WcatContext) => Promise<number>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:os";
|
|
3
|
+
import crossSpawn from "cross-spawn";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
import { BUILT_IN_COMMANDS, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, TOOL_NAME } from "./names.js";
|
|
6
|
+
import { findOnPath } from "./path.js";
|
|
7
|
+
import { baseVersion, probeDescribe } from "./probe.js";
|
|
8
|
+
import { addShortcuts, installedPrograms, listShortcuts, removeShortcuts } from "./shortcuts.js";
|
|
9
|
+
const DEFAULT_DESCRIBE_TIMEOUT_MS = 2000;
|
|
10
|
+
/** Every `wcat-<name>` on PATH with what it says about itself, then WireCat's own tools that are not installed. */
|
|
11
|
+
export const listTools = async (context) => {
|
|
12
|
+
const installed = installedPrograms(context);
|
|
13
|
+
const timeoutMs = context.describeTimeoutMs ?? DEFAULT_DESCRIBE_TIMEOUT_MS;
|
|
14
|
+
const found = await Promise.all([...installed].map(async ([name, paths]) => {
|
|
15
|
+
const [path, ...shadowed] = paths;
|
|
16
|
+
const own = context.self?.command === name ? context.self : undefined;
|
|
17
|
+
const described = own ?? (await probeDescribe(path, { env: context.env, timeoutMs }));
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
installed: true,
|
|
21
|
+
path,
|
|
22
|
+
...(described?.version ? { version: described.version } : {}),
|
|
23
|
+
...(described?.description ? { description: described.description } : {}),
|
|
24
|
+
...(described?.docs ? { docs_url: described.docs } : {}),
|
|
25
|
+
...(shadowed.length > 0 ? { shadowed } : {}),
|
|
26
|
+
};
|
|
27
|
+
}));
|
|
28
|
+
const missing = FIRST_PARTY_TOOLS.filter((tool) => tool.package && !installed.has(tool.name)).map((tool) => ({
|
|
29
|
+
name: tool.name,
|
|
30
|
+
installed: false,
|
|
31
|
+
package: tool.package,
|
|
32
|
+
install: `npm install -g ${tool.package}`,
|
|
33
|
+
}));
|
|
34
|
+
return [...found, ...missing];
|
|
35
|
+
};
|
|
36
|
+
const envelope = (items, started) => ({
|
|
37
|
+
items,
|
|
38
|
+
page: { mode: "page", limit: Math.max(items.length, 1), has_more: false },
|
|
39
|
+
meta: { run_id: randomUUID(), duration_ms: Math.max(0, Math.round(performance.now() - started)), complete: true },
|
|
40
|
+
});
|
|
41
|
+
const column = (rows) => {
|
|
42
|
+
const widths = rows[0]?.map((_, at) => rows.reduce((wide, row) => Math.max(wide, (row[at] ?? "").length), 0)) ?? [];
|
|
43
|
+
return rows.map((row) => ` ${row.map((cell, at) => cell.padEnd(widths[at] ?? 0)).join(" ")}`.trimEnd());
|
|
44
|
+
};
|
|
45
|
+
const USAGE = [
|
|
46
|
+
"wcat <tool> [arguments] run a tool: wcat tg --help",
|
|
47
|
+
"wcat shortcuts [add|remove] [tool...] short names, such as tg, for installed tools",
|
|
48
|
+
"wcat --json the list below as JSON",
|
|
49
|
+
];
|
|
50
|
+
const listText = (tools) => {
|
|
51
|
+
const installed = tools.filter((tool) => tool.installed);
|
|
52
|
+
const missing = tools.filter((tool) => !tool.installed);
|
|
53
|
+
return [
|
|
54
|
+
"wcat runs WireCat tools, and any other wcat-<name> program on PATH.",
|
|
55
|
+
"",
|
|
56
|
+
...USAGE,
|
|
57
|
+
"",
|
|
58
|
+
installed.length > 0 ? "Installed:" : "Installed: none",
|
|
59
|
+
...column(installed.map((tool) => [tool.name, tool.version ?? "", tool.description ?? "(no description)"])),
|
|
60
|
+
...(missing.length > 0
|
|
61
|
+
? ["", "Not installed:", ...column(missing.map((tool) => [tool.name, tool.install ?? ""]))]
|
|
62
|
+
: []),
|
|
63
|
+
"",
|
|
64
|
+
].join("\n");
|
|
65
|
+
};
|
|
66
|
+
const shadowWarnings = (tools) => tools
|
|
67
|
+
.filter((tool) => tool.shadowed)
|
|
68
|
+
.map((tool) => `wcat: ${(tool.shadowed?.length ?? 0) + 1} programs are named ${PROGRAM_PREFIX}${tool.name}; wcat runs the first:\n${[tool.path, ...(tool.shadowed ?? [])].map((path) => ` ${path}`).join("\n")}\n`)
|
|
69
|
+
.join("");
|
|
70
|
+
const shortcutText = (shortcuts) => {
|
|
71
|
+
if (shortcuts.length === 0)
|
|
72
|
+
return "No wcat-<name> programs are installed.\n";
|
|
73
|
+
const said = (shortcut) => {
|
|
74
|
+
switch (shortcut.status) {
|
|
75
|
+
case "made":
|
|
76
|
+
return `made: ${shortcut.path}`;
|
|
77
|
+
case "free":
|
|
78
|
+
return shortcut.changed ? "removed" : "free: wcat shortcuts add makes it";
|
|
79
|
+
case "package":
|
|
80
|
+
return `installed by the package: ${shortcut.path}`;
|
|
81
|
+
case "taken":
|
|
82
|
+
return `taken by ${shortcut.path}`;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return `${column(shortcuts.map((shortcut) => [shortcut.name, said(shortcut)])).join("\n")}\n`;
|
|
86
|
+
};
|
|
87
|
+
const signalNumber = (signal) => constants.signals[signal] ?? 1;
|
|
88
|
+
/**
|
|
89
|
+
* Starts `wcat-<name>` with the rest of the words, the same streams and environment, and answers its exit code.
|
|
90
|
+
* SIGTERM and SIGHUP are passed on. Ctrl-C reaches the tool from the terminal by itself, so SIGINT is only kept from
|
|
91
|
+
* ending `wcat` before the tool does.
|
|
92
|
+
*/
|
|
93
|
+
export const runTool = (path, args, context) => new Promise((resolve, reject) => {
|
|
94
|
+
const signals = context.signals ?? process;
|
|
95
|
+
const child = crossSpawn(path, [...args], { stdio: "inherit", env: context.env });
|
|
96
|
+
const pass = (signal) => () => child.kill(signal);
|
|
97
|
+
const term = pass("SIGTERM");
|
|
98
|
+
const hup = pass("SIGHUP");
|
|
99
|
+
const ignore = () => { };
|
|
100
|
+
signals.on("SIGTERM", term).on("SIGHUP", hup).on("SIGINT", ignore);
|
|
101
|
+
const done = () => signals.off("SIGTERM", term).off("SIGHUP", hup).off("SIGINT", ignore);
|
|
102
|
+
child.once("error", (error) => {
|
|
103
|
+
done();
|
|
104
|
+
reject(new CliError("configuration_error", `could not start ${path}: ${error.message}`));
|
|
105
|
+
});
|
|
106
|
+
child.once("exit", (code, signal) => {
|
|
107
|
+
done();
|
|
108
|
+
resolve(code ?? (signal ? 128 + signalNumber(signal) : 1));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const fail = async (error, json, context) => {
|
|
112
|
+
const { errorObjectOf, exitCodeOfError, renderError } = await import("../failure.js");
|
|
113
|
+
const object = errorObjectOf(error);
|
|
114
|
+
context.stderr.write(`${renderError(object, { cli: "wcat", interactive: !json })}\n`);
|
|
115
|
+
return exitCodeOfError(object);
|
|
116
|
+
};
|
|
117
|
+
const shortcuts = async (args, json, context) => {
|
|
118
|
+
const [action, ...names] = args;
|
|
119
|
+
const started = performance.now();
|
|
120
|
+
const result = action === "add"
|
|
121
|
+
? addShortcuts(context, names)
|
|
122
|
+
: action === "remove"
|
|
123
|
+
? removeShortcuts(context, names)
|
|
124
|
+
: action === undefined || action === "list"
|
|
125
|
+
? listShortcuts(context, names)
|
|
126
|
+
: undefined;
|
|
127
|
+
if (result === undefined)
|
|
128
|
+
throw new CliError("validation_error", `wcat shortcuts takes list, add or remove, not "${action}"`, {
|
|
129
|
+
reason: "input.invalid",
|
|
130
|
+
});
|
|
131
|
+
context.stdout.write(json ? `${JSON.stringify(envelope(result, started))}\n` : shortcutText(result));
|
|
132
|
+
return 0;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* `wcat` itself: the words after `wcat`. Built-in commands come first, so a `wcat-<name>` program can never take
|
|
136
|
+
* one over; anything else names a tool, and the rest of the line is that tool's.
|
|
137
|
+
*/
|
|
138
|
+
export const runWcat = async (args, context) => {
|
|
139
|
+
const [first, ...rest] = args;
|
|
140
|
+
// A tool's own --json is the tool's; wcat reads it only for its own answers and for a tool it cannot find.
|
|
141
|
+
const json = args.includes("--json");
|
|
142
|
+
const words = args.filter((word) => word !== "--json");
|
|
143
|
+
const started = performance.now();
|
|
144
|
+
try {
|
|
145
|
+
if (first === "--version" || first === "-V" || first === "version") {
|
|
146
|
+
const tools = await listTools(context);
|
|
147
|
+
const by = context.self ? `, run by ${PROGRAM_PREFIX}${context.self.command} ${context.self.version}` : "";
|
|
148
|
+
context.stdout.write(`wcat ${baseVersion()} (@wirecat/cli-base${by})\n\n${listText(tools)}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (words.length === 0 || first === "--help" || first === "-h" || first === "help") {
|
|
152
|
+
const tools = await listTools(context);
|
|
153
|
+
if (json)
|
|
154
|
+
context.stdout.write(`${JSON.stringify(envelope(tools, started))}\n`);
|
|
155
|
+
else {
|
|
156
|
+
context.stderr.write(shadowWarnings(tools));
|
|
157
|
+
context.stdout.write(listText(tools));
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
if (first === "shortcuts")
|
|
162
|
+
return await shortcuts(words.slice(1), json, context);
|
|
163
|
+
if (BUILT_IN_COMMANDS.includes(first))
|
|
164
|
+
throw new CliError("validation_error", `wcat ${first} is reserved for a later version of wcat`, {
|
|
165
|
+
reason: "input.invalid",
|
|
166
|
+
});
|
|
167
|
+
if (first?.startsWith("-"))
|
|
168
|
+
throw new CliError("validation_error", `wcat does not take ${first} before a tool's name`, {
|
|
169
|
+
reason: "input.invalid",
|
|
170
|
+
});
|
|
171
|
+
const name = first;
|
|
172
|
+
const [path, ...shadowed] = TOOL_NAME.test(name) ? findOnPath(`${PROGRAM_PREFIX}${name}`, context) : [];
|
|
173
|
+
if (path === undefined) {
|
|
174
|
+
const installed = [...installedPrograms(context).keys()];
|
|
175
|
+
throw new CliError("not_found", `no ${PROGRAM_PREFIX}${name} on PATH; installed: ${installed.length > 0 ? installed.join(", ") : "none"}`, { reason: "tool.not-installed", tool: name, installed });
|
|
176
|
+
}
|
|
177
|
+
if (shadowed.length > 0 && context.stderr.isTTY)
|
|
178
|
+
context.stderr.write(shadowWarnings([{ name, installed: true, path, shadowed }]));
|
|
179
|
+
return await runTool(path, rest, context);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return fail(error, json, context);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wcat`, the umbrella command: `wcat tg …` runs `wcat-tg …`, as `git foo` runs `git-foo`. Its code is here, so every
|
|
3
|
+
* CLI on cli-base carries a copy; the `wcat` file on PATH is a small launcher that starts an installed tool in
|
|
4
|
+
* dispatch mode, and each tool repairs it on start.
|
|
5
|
+
*
|
|
6
|
+
* A CLI's bin imports `wcatStart` from `@wirecat/cli-base/wcat/start`, which loads only Node built-ins; this entry
|
|
7
|
+
* point is the whole library, for WireCat Desktop and for tests.
|
|
8
|
+
*/
|
|
9
|
+
export { type DescribeAnswer, describeAnswer, parseDescribe, type WcatTool } from "./describe.js";
|
|
10
|
+
export { type ListedTool, listTools, runWcat, type WcatContext } from "./dispatch.js";
|
|
11
|
+
export { type BinFolder, binFolder, ensureLauncher, type LauncherState } from "./launcher.js";
|
|
12
|
+
export { BUILT_IN_COMMANDS, DESCRIBE_FLAG, DISPATCH_FLAG, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME, } from "./names.js";
|
|
13
|
+
export { findOnPath, findTools, type SearchEnv } from "./path.js";
|
|
14
|
+
export { parseShim, SHIM_FORMAT, type ShimKind, shimFiles } from "./shim.js";
|
|
15
|
+
export { addShortcuts, installedPrograms, listShortcuts, removeShortcuts, type Shortcut, type ShortcutChange, type ShortcutStatus, } from "./shortcuts.js";
|
|
16
|
+
export { type WcatStart, type WcatStartIo, wcatStart } from "./start.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wcat`, the umbrella command: `wcat tg …` runs `wcat-tg …`, as `git foo` runs `git-foo`. Its code is here, so every
|
|
3
|
+
* CLI on cli-base carries a copy; the `wcat` file on PATH is a small launcher that starts an installed tool in
|
|
4
|
+
* dispatch mode, and each tool repairs it on start.
|
|
5
|
+
*
|
|
6
|
+
* A CLI's bin imports `wcatStart` from `@wirecat/cli-base/wcat/start`, which loads only Node built-ins; this entry
|
|
7
|
+
* point is the whole library, for WireCat Desktop and for tests.
|
|
8
|
+
*/
|
|
9
|
+
export { describeAnswer, parseDescribe } from "./describe.js";
|
|
10
|
+
export { listTools, runWcat } from "./dispatch.js";
|
|
11
|
+
export { binFolder, ensureLauncher } from "./launcher.js";
|
|
12
|
+
export { BUILT_IN_COMMANDS, DESCRIBE_FLAG, DISPATCH_FLAG, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME, } from "./names.js";
|
|
13
|
+
export { findOnPath, findTools } from "./path.js";
|
|
14
|
+
export { parseShim, SHIM_FORMAT, shimFiles } from "./shim.js";
|
|
15
|
+
export { addShortcuts, installedPrograms, listShortcuts, removeShortcuts, } from "./shortcuts.js";
|
|
16
|
+
export { wcatStart } from "./start.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface BinFolderInput {
|
|
2
|
+
/** The tool's name in `wcat <name>`: `tg`. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** `process.argv[1]`: the command as it was started, which is the bin folder's own link where npm makes one. */
|
|
5
|
+
argv1?: string;
|
|
6
|
+
/** A file of the installed package, links resolved: where it lives says which package manager put it there. */
|
|
7
|
+
script: string;
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
platform: NodeJS.Platform;
|
|
10
|
+
}
|
|
11
|
+
export interface BinFolder {
|
|
12
|
+
dir: string;
|
|
13
|
+
/** The tool's own command in it, `wcat-tg` or `wcat-tg.cmd`. */
|
|
14
|
+
program: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The global bin folder the running tool was installed into, or `undefined` for npx, a checkout or a project's own
|
|
18
|
+
* `node_modules/.bin`. At most four `stat` calls, so it can run on every start.
|
|
19
|
+
*/
|
|
20
|
+
export declare const binFolder: ({ name, argv1, script, env, platform }: BinFolderInput) => BinFolder | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the program in `dir` still starts something. bun on Windows leaves a tool's `.exe` behind when the tool is
|
|
23
|
+
* uninstalled; the `.bunx` beside it names the script, as UTF-16 text up to a `"`, and that script is gone.
|
|
24
|
+
*/
|
|
25
|
+
export declare const installed: (dir: string, program: string) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* - `kept`: the launcher is there and starts a tool that is installed.
|
|
28
|
+
* - `written`: it was missing, broken or in an older format, and now starts the running tool.
|
|
29
|
+
* - `foreign`: a `wcat` that cli-base did not write is there, and stays untouched.
|
|
30
|
+
* - `newer`: a newer cli-base wrote it; this one leaves it alone.
|
|
31
|
+
* - `failed`: it could not be written, such as a bin folder only an administrator may change.
|
|
32
|
+
*/
|
|
33
|
+
export type LauncherState = "kept" | "written" | "foreign" | "newer" | "failed";
|
|
34
|
+
/**
|
|
35
|
+
* Makes sure the `wcat` launcher in `dir` starts an installed tool. It never prints and never throws: a tool calls it
|
|
36
|
+
* on every start, and a launcher it cannot write must not fail the tool's own command.
|
|
37
|
+
*/
|
|
38
|
+
export declare const ensureLauncher: ({ dir, program, platform, }: BinFolder & {
|
|
39
|
+
platform: NodeJS.Platform;
|
|
40
|
+
}) => LauncherState;
|