@wirecat/cli-base 0.65.4 → 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 +43 -0
- package/README.md +4 -0
- package/dist/credentials.d.ts +10 -6
- package/dist/credentials.js +23 -14
- 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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/docs/standards/security.md +29 -15
- package/package.json +9 -1
|
@@ -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;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { installerOf } from "../installer.js";
|
|
4
|
+
import { PROGRAM_PREFIX } from "./names.js";
|
|
5
|
+
import { exists, fileState, SHIM_FORMAT, shimFiles, writeShim } from "./shim.js";
|
|
6
|
+
const posix = (path) => path.replaceAll("\\", "/");
|
|
7
|
+
/** pnpm's global packages live under PNPM_HOME, whatever the folder is called; a project's own `.pnpm` does not. */
|
|
8
|
+
const pnpmHome = (path, env) => {
|
|
9
|
+
const home = env.PNPM_HOME;
|
|
10
|
+
return home && path.startsWith(`${posix(home).replace(/\/+$/, "")}/`) ? home : undefined;
|
|
11
|
+
};
|
|
12
|
+
/** Where each package manager puts the commands of a global install, read from where the package itself lives. */
|
|
13
|
+
const layoutFolders = (script, env) => {
|
|
14
|
+
const path = posix(script);
|
|
15
|
+
const home = pnpmHome(path, env);
|
|
16
|
+
// pnpm 12 puts global commands in $PNPM_HOME/bin, earlier versions in $PNPM_HOME itself.
|
|
17
|
+
if (home)
|
|
18
|
+
return [join(home, "bin"), home];
|
|
19
|
+
switch (installerOf(script)) {
|
|
20
|
+
case "bun":
|
|
21
|
+
return [`${path.slice(0, path.indexOf("/install/global/node_modules/"))}/bin`];
|
|
22
|
+
case "npm": {
|
|
23
|
+
const root = path.slice(0, path.indexOf("/node_modules/"));
|
|
24
|
+
// `<prefix>/lib/node_modules` with `<prefix>/bin` on Linux and macOS; `<prefix>/node_modules` beside the
|
|
25
|
+
// commands on Windows.
|
|
26
|
+
return [basename(root) === "lib" ? join(dirname(root), "bin") : root];
|
|
27
|
+
}
|
|
28
|
+
default:
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The global bin folder the running tool was installed into, or `undefined` for npx, a checkout or a project's own
|
|
34
|
+
* `node_modules/.bin`. At most four `stat` calls, so it can run on every start.
|
|
35
|
+
*/
|
|
36
|
+
export const binFolder = ({ name, argv1, script, env, platform }) => {
|
|
37
|
+
if (!["npm", "pnpm", "bun"].includes(installerOf(script)) && !pnpmHome(posix(script), env))
|
|
38
|
+
return undefined;
|
|
39
|
+
const extensions = platform === "win32" ? [".cmd", ".exe"] : [""];
|
|
40
|
+
const candidates = [argv1 ? dirname(argv1) : undefined, ...layoutFolders(script, env)];
|
|
41
|
+
for (const dir of new Set(candidates)) {
|
|
42
|
+
if (dir === undefined || basename(dir) === ".bin")
|
|
43
|
+
continue;
|
|
44
|
+
for (const extension of extensions) {
|
|
45
|
+
const program = `${PROGRAM_PREFIX}${name}${extension}`;
|
|
46
|
+
if (exists(join(dir, program)))
|
|
47
|
+
return { dir, program };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether the program in `dir` still starts something. bun on Windows leaves a tool's `.exe` behind when the tool is
|
|
54
|
+
* uninstalled; the `.bunx` beside it names the script, as UTF-16 text up to a `"`, and that script is gone.
|
|
55
|
+
*/
|
|
56
|
+
export const installed = (dir, program) => {
|
|
57
|
+
if (!exists(join(dir, program)))
|
|
58
|
+
return false;
|
|
59
|
+
if (!program.toLowerCase().endsWith(".exe"))
|
|
60
|
+
return true;
|
|
61
|
+
let script;
|
|
62
|
+
try {
|
|
63
|
+
script =
|
|
64
|
+
readFileSync(join(dir, `${program.slice(0, -4)}.bunx`))
|
|
65
|
+
.toString("utf16le")
|
|
66
|
+
.split('"')[0] ?? "";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (script === "" || exists(resolve(dir, script)))
|
|
72
|
+
return true;
|
|
73
|
+
// The relative path did not resolve; bun's default layout puts the packages in <BUN_INSTALL>/install/global.
|
|
74
|
+
const at = posix(script).lastIndexOf("/install/global/node_modules/");
|
|
75
|
+
return at < 0 || exists(join(dirname(dir), posix(script).slice(at + 1)));
|
|
76
|
+
};
|
|
77
|
+
const NAME = "wcat";
|
|
78
|
+
/**
|
|
79
|
+
* Makes sure the `wcat` launcher in `dir` starts an installed tool. It never prints and never throws: a tool calls it
|
|
80
|
+
* on every start, and a launcher it cannot write must not fail the tool's own command.
|
|
81
|
+
*/
|
|
82
|
+
export const ensureLauncher = ({ dir, program, platform, }) => {
|
|
83
|
+
try {
|
|
84
|
+
const mine = shimFiles("launcher", { dir, name: NAME, target: program, platform });
|
|
85
|
+
const states = mine.map((file) => fileState(file.path));
|
|
86
|
+
if (states.some((one) => one.state === "foreign"))
|
|
87
|
+
return "foreign";
|
|
88
|
+
const ours = states.flatMap((one) => (one.state === "ours" ? [one] : []));
|
|
89
|
+
if (ours.some(({ mark }) => mark.format > SHIM_FORMAT))
|
|
90
|
+
return "newer";
|
|
91
|
+
if (ours.some(({ mark }) => mark.kind !== "launcher"))
|
|
92
|
+
return "foreign";
|
|
93
|
+
// Keep the tool it starts while that tool is installed, so two tools never take turns rewriting it.
|
|
94
|
+
const current = ours.find(({ mark }) => installed(dir, mark.target))?.mark.target;
|
|
95
|
+
const wanted = shimFiles("launcher", { dir, name: NAME, target: current ?? program, platform });
|
|
96
|
+
const stale = wanted.filter((file, at) => {
|
|
97
|
+
const state = states[at];
|
|
98
|
+
return state?.state !== "ours" || state.text !== file.text;
|
|
99
|
+
});
|
|
100
|
+
if (stale.length === 0)
|
|
101
|
+
return "kept";
|
|
102
|
+
for (const file of stale)
|
|
103
|
+
writeShim(file);
|
|
104
|
+
return "written";
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return "failed";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** WireCat's own tools: `wcat --help` names each one that is missing with the command that installs it. */
|
|
2
|
+
export declare const FIRST_PARTY_TOOLS: readonly {
|
|
3
|
+
name: string;
|
|
4
|
+
package?: string;
|
|
5
|
+
}[];
|
|
6
|
+
/** `wcat` answers these itself, so a `wcat-<name>` program can never take one over. */
|
|
7
|
+
export declare const BUILT_IN_COMMANDS: readonly ["help", "version", "shortcuts", "doctor", "update", "upgrade", "app"];
|
|
8
|
+
/** Names a third-party CLI must not take: WireCat's tools, the built-in commands and `wcat` itself. */
|
|
9
|
+
export declare const RESERVED_NAMES: readonly string[];
|
|
10
|
+
/** A tool name `wcat` runs: lower case, as a command word and a file name on every system. */
|
|
11
|
+
export declare const TOOL_NAME: RegExp;
|
|
12
|
+
export declare const PROGRAM_PREFIX = "wcat-";
|
|
13
|
+
/** The first argument `wcat-<name> --wcat-describe` answers with its JSON description. */
|
|
14
|
+
export declare const DESCRIBE_FLAG = "--wcat-describe";
|
|
15
|
+
/** The first argument the `wcat` launcher passes, so the tool runs `wcat` instead of its own program. */
|
|
16
|
+
export declare const DISPATCH_FLAG = "--wcat-dispatch";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** WireCat's own tools: `wcat --help` names each one that is missing with the command that installs it. */
|
|
2
|
+
export const FIRST_PARTY_TOOLS = [
|
|
3
|
+
{ name: "tg", package: "@wirecat/tg-cli" },
|
|
4
|
+
{ name: "max", package: "@wirecat/max-cli" },
|
|
5
|
+
{ name: "wa", package: "@wirecat/whatsapp-cli" },
|
|
6
|
+
{ name: "zm", package: "@wirecat/zoom-cli" },
|
|
7
|
+
{ name: "drive", package: "@wirecat/drive-cli" },
|
|
8
|
+
{ name: "memo", package: "@wirecat/cli-memo" },
|
|
9
|
+
{ name: "mail", package: "@wirecat/mail-cli" },
|
|
10
|
+
// memo reads email today; the name is held for a tool of its own.
|
|
11
|
+
{ name: "email" },
|
|
12
|
+
];
|
|
13
|
+
/** `wcat` answers these itself, so a `wcat-<name>` program can never take one over. */
|
|
14
|
+
export const BUILT_IN_COMMANDS = ["help", "version", "shortcuts", "doctor", "update", "upgrade", "app"];
|
|
15
|
+
/** Names a third-party CLI must not take: WireCat's tools, the built-in commands and `wcat` itself. */
|
|
16
|
+
export const RESERVED_NAMES = [
|
|
17
|
+
...FIRST_PARTY_TOOLS.map((tool) => tool.name),
|
|
18
|
+
...BUILT_IN_COMMANDS,
|
|
19
|
+
"wcat",
|
|
20
|
+
];
|
|
21
|
+
/** A tool name `wcat` runs: lower case, as a command word and a file name on every system. */
|
|
22
|
+
export const TOOL_NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
23
|
+
export const PROGRAM_PREFIX = "wcat-";
|
|
24
|
+
/** The first argument `wcat-<name> --wcat-describe` answers with its JSON description. */
|
|
25
|
+
export const DESCRIBE_FLAG = "--wcat-describe";
|
|
26
|
+
/** The first argument the `wcat` launcher passes, so the tool runs `wcat` instead of its own program. */
|
|
27
|
+
export const DISPATCH_FLAG = "--wcat-dispatch";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The system `wcat` runs on and the variables it searches; tests pass another platform's. */
|
|
2
|
+
export interface SearchEnv {
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
platform: NodeJS.Platform;
|
|
5
|
+
}
|
|
6
|
+
/** Each folder once: a folder listed twice is one program, not two. */
|
|
7
|
+
export declare const pathEntries: ({ env, platform }: SearchEnv) => string[];
|
|
8
|
+
/** The endings Windows runs without being told, `.cmd` before `.ps1` when PATHEXT says so; none elsewhere. */
|
|
9
|
+
export declare const programExtensions: ({ env, platform }: SearchEnv) => string[];
|
|
10
|
+
/** Every `command` on PATH, in PATH order, one file per folder: the first ending Windows would run. */
|
|
11
|
+
export declare const findOnPath: (command: string, search: SearchEnv) => string[];
|
|
12
|
+
/** Every `wcat-<name>` program on PATH by name, each with its files in PATH order. */
|
|
13
|
+
export declare const findTools: (search: SearchEnv) => Map<string, string[]>;
|
|
14
|
+
/** Names `wcat` answers itself; a `wcat-<name>` program with one of them never runs through `wcat`. */
|
|
15
|
+
export declare const isBuiltIn: (name: string) => boolean;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BUILT_IN_COMMANDS, PROGRAM_PREFIX, TOOL_NAME } from "./names.js";
|
|
4
|
+
const variable = (env, name, platform) => platform === "win32" ? Object.entries(env).find(([key]) => key.toUpperCase() === name)?.[1] : env[name];
|
|
5
|
+
/** Each folder once: a folder listed twice is one program, not two. */
|
|
6
|
+
export const pathEntries = ({ env, platform }) => {
|
|
7
|
+
const raw = variable(env, "PATH", platform) ?? "";
|
|
8
|
+
const entries = raw.split(platform === "win32" ? ";" : ":").map((entry) => entry.replace(/^"(.*)"$/, "$1"));
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
return entries.filter((entry) => {
|
|
11
|
+
const key = platform === "win32" ? entry.toLowerCase().replace(/[\\/]+$/, "") : entry.replace(/\/+$/, "");
|
|
12
|
+
if (entry === "" || seen.has(key))
|
|
13
|
+
return false;
|
|
14
|
+
seen.add(key);
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
/** The endings Windows runs without being told, `.cmd` before `.ps1` when PATHEXT says so; none elsewhere. */
|
|
19
|
+
export const programExtensions = ({ env, platform }) => platform === "win32"
|
|
20
|
+
? (variable(env, "PATHEXT", platform) ?? ".COM;.EXE;.BAT;.CMD")
|
|
21
|
+
.split(";")
|
|
22
|
+
.filter((ext) => /^\.[a-z0-9]+$/i.test(ext))
|
|
23
|
+
.map((ext) => ext.toLowerCase())
|
|
24
|
+
: [""];
|
|
25
|
+
const isProgram = (path, platform) => {
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
return stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** Every `command` on PATH, in PATH order, one file per folder: the first ending Windows would run. */
|
|
35
|
+
export const findOnPath = (command, search) => {
|
|
36
|
+
const extensions = programExtensions(search);
|
|
37
|
+
return pathEntries(search).flatMap((dir) => {
|
|
38
|
+
for (const extension of extensions) {
|
|
39
|
+
const path = join(dir, command + extension);
|
|
40
|
+
if (isProgram(path, search.platform))
|
|
41
|
+
return [path];
|
|
42
|
+
}
|
|
43
|
+
return [];
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
/** Every `wcat-<name>` program on PATH by name, each with its files in PATH order. */
|
|
47
|
+
export const findTools = (search) => {
|
|
48
|
+
const extensions = programExtensions(search);
|
|
49
|
+
const tools = new Map();
|
|
50
|
+
for (const dir of pathEntries(search)) {
|
|
51
|
+
let files;
|
|
52
|
+
try {
|
|
53
|
+
files = readdirSync(dir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const here = new Map();
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
if (!(search.platform === "win32" ? file.toLowerCase() : file).startsWith(PROGRAM_PREFIX))
|
|
61
|
+
continue;
|
|
62
|
+
const extension = extensions.find((ext) => ext !== "" && file.toLowerCase().endsWith(ext)) ?? "";
|
|
63
|
+
if (search.platform === "win32" && extension === "")
|
|
64
|
+
continue;
|
|
65
|
+
const base = search.platform === "win32" ? file.toLowerCase() : file;
|
|
66
|
+
const name = base.slice(PROGRAM_PREFIX.length, base.length - extension.length);
|
|
67
|
+
if (!TOOL_NAME.test(name))
|
|
68
|
+
continue;
|
|
69
|
+
const best = here.get(name);
|
|
70
|
+
const rank = (path) => extensions.findIndex((ext) => path.toLowerCase().endsWith(ext));
|
|
71
|
+
const path = join(dir, file);
|
|
72
|
+
if (best !== undefined && rank(best) <= rank(path))
|
|
73
|
+
continue;
|
|
74
|
+
if (isProgram(path, search.platform))
|
|
75
|
+
here.set(name, path);
|
|
76
|
+
}
|
|
77
|
+
for (const [name, path] of here)
|
|
78
|
+
tools.set(name, [...(tools.get(name) ?? []), path]);
|
|
79
|
+
}
|
|
80
|
+
return new Map([...tools].sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
81
|
+
};
|
|
82
|
+
/** Names `wcat` answers itself; a `wcat-<name>` program with one of them never runs through `wcat`. */
|
|
83
|
+
export const isBuiltIn = (name) => name === "wcat" || BUILT_IN_COMMANDS.includes(name);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type WcatTool } from "./describe.js";
|
|
2
|
+
/**
|
|
3
|
+
* Asks one program to describe itself. Any program can sit on PATH under a `wcat-` name, so it gets no input, a time
|
|
4
|
+
* limit and a cap on what it may print, and its failure only means "no description".
|
|
5
|
+
*/
|
|
6
|
+
export declare const probeDescribe: (path: string, { env, timeoutMs }: {
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
}) => Promise<Omit<WcatTool, "command"> | undefined>;
|
|
10
|
+
/** cli-base's own version, read only when `wcat --version` asks; `unknown` from a bundle that left the file behind. */
|
|
11
|
+
export declare const baseVersion: () => string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import crossSpawn from "cross-spawn";
|
|
3
|
+
import { parseDescribe } from "./describe.js";
|
|
4
|
+
import { DESCRIBE_FLAG } from "./names.js";
|
|
5
|
+
const MAX_ANSWER_BYTES = 64 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Asks one program to describe itself. Any program can sit on PATH under a `wcat-` name, so it gets no input, a time
|
|
8
|
+
* limit and a cap on what it may print, and its failure only means "no description".
|
|
9
|
+
*/
|
|
10
|
+
export const probeDescribe = (path, { env, timeoutMs }) => new Promise((resolve) => {
|
|
11
|
+
let child;
|
|
12
|
+
try {
|
|
13
|
+
child = crossSpawn(path, [DESCRIBE_FLAG], { stdio: ["ignore", "pipe", "ignore"], env, windowsHide: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
resolve(undefined);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
let settled = false;
|
|
22
|
+
// On Windows the child is cmd.exe and node is its child: killing cmd.exe leaves node holding the pipe, so "close"
|
|
23
|
+
// could wait for it. Giving up never waits for "close".
|
|
24
|
+
const settle = (answer) => {
|
|
25
|
+
if (settled)
|
|
26
|
+
return;
|
|
27
|
+
settled = true;
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
resolve(answer);
|
|
30
|
+
};
|
|
31
|
+
const giveUp = () => {
|
|
32
|
+
child.kill();
|
|
33
|
+
child.stdout?.destroy();
|
|
34
|
+
child.unref();
|
|
35
|
+
settle(undefined);
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(giveUp, timeoutMs);
|
|
38
|
+
child.stdout?.on("data", (chunk) => {
|
|
39
|
+
size += chunk.length;
|
|
40
|
+
if (size > MAX_ANSWER_BYTES)
|
|
41
|
+
giveUp();
|
|
42
|
+
else
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
});
|
|
45
|
+
child.once("error", () => settle(undefined));
|
|
46
|
+
child.once("close", (code) => settle(code === 0 ? parseDescribe(Buffer.concat(chunks).toString("utf8")) : undefined));
|
|
47
|
+
});
|
|
48
|
+
/** cli-base's own version, read only when `wcat --version` asks; `unknown` from a bundle that left the file behind. */
|
|
49
|
+
export const baseVersion = () => {
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
|
|
52
|
+
const { name, version } = JSON.parse(raw);
|
|
53
|
+
return name === "@wirecat/cli-base" && typeof version === "string" ? version : "unknown";
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return "unknown";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small files cli-base writes into a bin folder: the `wcat` launcher and the short-name shortcuts. Each starts a
|
|
3
|
+
* program in the same folder by its stable name (`wcat-tg`), never a file inside a package: pnpm keeps packages in
|
|
4
|
+
* folders named after their version, so a link into one breaks on the first upgrade.
|
|
5
|
+
*
|
|
6
|
+
* Every file carries a mark line, so cli-base can tell its own files from anyone else's and never touches the rest.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SHIM_FORMAT = 1;
|
|
9
|
+
export type ShimKind = "launcher" | "shortcut";
|
|
10
|
+
export interface ShimMark {
|
|
11
|
+
format: number;
|
|
12
|
+
kind: ShimKind;
|
|
13
|
+
/** The program it starts, a file name in the same folder: `wcat-tg`, `wcat-tg.cmd`. */
|
|
14
|
+
target: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ShimFile {
|
|
17
|
+
path: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The files of one shim: `<name>` on Linux and macOS; `<name>.cmd` and `<name>.ps1` on Windows, the pair npm writes
|
|
22
|
+
* for its own commands. The POSIX file names the folder in full, so a link to it from elsewhere still works; the
|
|
23
|
+
* Windows pair finds the folder from where it is, as npm's own wrappers do.
|
|
24
|
+
*/
|
|
25
|
+
export declare const shimFiles: (kind: ShimKind, { dir, name, target, platform }: {
|
|
26
|
+
dir: string;
|
|
27
|
+
name: string;
|
|
28
|
+
target: string;
|
|
29
|
+
platform: NodeJS.Platform;
|
|
30
|
+
}) => ShimFile[];
|
|
31
|
+
export declare const parseShim: (text: string) => ShimMark | undefined;
|
|
32
|
+
export type FileState = {
|
|
33
|
+
state: "missing";
|
|
34
|
+
} | {
|
|
35
|
+
state: "ours";
|
|
36
|
+
mark: ShimMark;
|
|
37
|
+
text: string;
|
|
38
|
+
} | {
|
|
39
|
+
state: "foreign";
|
|
40
|
+
};
|
|
41
|
+
/** What sits at `path`: nothing, one of our shims, or a file or link that belongs to someone else. */
|
|
42
|
+
export declare const fileState: (path: string) => FileState;
|
|
43
|
+
export declare const exists: (path: string) => boolean;
|
|
44
|
+
/** Written beside the target and renamed over it, so two tools starting at once never leave half a file. */
|
|
45
|
+
export declare const writeShim: ({ path, text }: ShimFile) => void;
|