@wirecat/cli-base 0.65.5 → 0.66.1
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 +34 -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/query/parser.js +1 -1
- 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
|
@@ -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;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DISPATCH_FLAG } from "./names.js";
|
|
4
|
+
/**
|
|
5
|
+
* The small files cli-base writes into a bin folder: the `wcat` launcher and the short-name shortcuts. Each starts a
|
|
6
|
+
* program in the same folder by its stable name (`wcat-tg`), never a file inside a package: pnpm keeps packages in
|
|
7
|
+
* folders named after their version, so a link into one breaks on the first upgrade.
|
|
8
|
+
*
|
|
9
|
+
* Every file carries a mark line, so cli-base can tell its own files from anyone else's and never touches the rest.
|
|
10
|
+
*/
|
|
11
|
+
export const SHIM_FORMAT = 1;
|
|
12
|
+
const MARK = /^(?:#|REM) wirecat-shim (\d+) (launcher|shortcut) (\S+)\r?$/m;
|
|
13
|
+
const NOTE = {
|
|
14
|
+
launcher: "Written by @wirecat/cli-base. Any WireCat tool rewrites it when it breaks; it is safe to delete.",
|
|
15
|
+
shortcut: "Made by `wcat shortcuts add`; `wcat shortcuts remove` takes it out.",
|
|
16
|
+
};
|
|
17
|
+
const shellQuote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
18
|
+
/**
|
|
19
|
+
* The files of one shim: `<name>` on Linux and macOS; `<name>.cmd` and `<name>.ps1` on Windows, the pair npm writes
|
|
20
|
+
* for its own commands. The POSIX file names the folder in full, so a link to it from elsewhere still works; the
|
|
21
|
+
* Windows pair finds the folder from where it is, as npm's own wrappers do.
|
|
22
|
+
*/
|
|
23
|
+
export const shimFiles = (kind, { dir, name, target, platform }) => {
|
|
24
|
+
const mark = `wirecat-shim ${SHIM_FORMAT} ${kind} ${target}`;
|
|
25
|
+
const extra = kind === "launcher" ? ` ${DISPATCH_FLAG}` : "";
|
|
26
|
+
if (platform !== "win32")
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
path: join(dir, name),
|
|
30
|
+
text: `#!/bin/sh\n# ${mark}\n# ${NOTE[kind]}\nexec ${shellQuote(join(dir, target))}${extra} "$@"\n`,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
path: join(dir, `${name}.cmd`),
|
|
36
|
+
text: ["@ECHO off", `REM ${mark}`, `REM ${NOTE[kind]}`, `"%~dp0${target}"${extra} %*`, ""].join("\r\n"),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
path: join(dir, `${name}.ps1`),
|
|
40
|
+
text: [
|
|
41
|
+
`# ${mark}`,
|
|
42
|
+
`# ${NOTE[kind]}`,
|
|
43
|
+
// PowerShell hands a .cmd its arguments unquoted, so `a&b` would run `b`; npm and pnpm write a .ps1 beside
|
|
44
|
+
// their .cmd, and it is started instead whenever it is there.
|
|
45
|
+
`$stem = Join-Path $PSScriptRoot "${target.replace(/\.(cmd|exe|bat)$/i, "")}"`,
|
|
46
|
+
`$target = if (Test-Path -LiteralPath "$stem.ps1") { "$stem.ps1" } else { Join-Path $PSScriptRoot "${target}" }`,
|
|
47
|
+
"if ($MyInvocation.ExpectingInput) {",
|
|
48
|
+
` $input | & $target${extra} @args`,
|
|
49
|
+
"} else {",
|
|
50
|
+
` & $target${extra} @args`,
|
|
51
|
+
"}",
|
|
52
|
+
"exit $LASTEXITCODE",
|
|
53
|
+
"",
|
|
54
|
+
].join("\r\n"),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
};
|
|
58
|
+
export const parseShim = (text) => {
|
|
59
|
+
const found = MARK.exec(text.split("\n", 3).join("\n"));
|
|
60
|
+
if (!found)
|
|
61
|
+
return undefined;
|
|
62
|
+
return { format: Number(found[1]), kind: found[2], target: found[3] };
|
|
63
|
+
};
|
|
64
|
+
/** A shim file is a few hundred bytes; anything larger is not one of ours and is not read. */
|
|
65
|
+
const MAX_SHIM_BYTES = 4096;
|
|
66
|
+
/** What sits at `path`: nothing, one of our shims, or a file or link that belongs to someone else. */
|
|
67
|
+
export const fileState = (path) => {
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = lstatSync(path);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return error.code === "ENOENT" ? { state: "missing" } : { state: "foreign" };
|
|
74
|
+
}
|
|
75
|
+
if (!stat.isFile() || stat.size > MAX_SHIM_BYTES)
|
|
76
|
+
return { state: "foreign" };
|
|
77
|
+
try {
|
|
78
|
+
const text = readFileSync(path, "utf8");
|
|
79
|
+
const mark = parseShim(text);
|
|
80
|
+
return mark ? { state: "ours", mark, text } : { state: "foreign" };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { state: "foreign" };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
export const exists = (path) => {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(path).isFile();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
/** Written beside the target and renamed over it, so two tools starting at once never leave half a file. */
|
|
95
|
+
export const writeShim = ({ path, text }) => {
|
|
96
|
+
const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(temporary, text, { mode: 0o755 });
|
|
99
|
+
renameSync(temporary, path);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
rmSync(temporary, { force: true });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SearchEnv } from "./path.js";
|
|
2
|
+
/**
|
|
3
|
+
* - `made`: the short name is a shortcut cli-base made, and `remove` may take it out.
|
|
4
|
+
* - `free`: no program on PATH has the name, so `add` may make it.
|
|
5
|
+
* - `package`: the tool's package installs the short name itself; the package manager owns it.
|
|
6
|
+
* - `taken`: another program has the name; it is never touched.
|
|
7
|
+
*/
|
|
8
|
+
export type ShortcutStatus = "made" | "free" | "package" | "taken";
|
|
9
|
+
export interface Shortcut {
|
|
10
|
+
/** The short name, which is also the tool's name: `tg`. */
|
|
11
|
+
name: string;
|
|
12
|
+
status: ShortcutStatus;
|
|
13
|
+
/** The file that has the name, for every status but `free`. */
|
|
14
|
+
path?: string;
|
|
15
|
+
/** The `wcat-<name>` program the shortcut starts. */
|
|
16
|
+
program: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ShortcutChange extends Shortcut {
|
|
19
|
+
changed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Every installed `wcat-<name>` program by name, the first on PATH for each, built-in names left out. */
|
|
22
|
+
export declare const installedPrograms: (search: SearchEnv) => Map<string, string[]>;
|
|
23
|
+
/** The short name of each installed tool, or of the tools named: made, free, installed by the package or taken. */
|
|
24
|
+
export declare const listShortcuts: (search: SearchEnv, names?: readonly string[]) => Shortcut[];
|
|
25
|
+
/**
|
|
26
|
+
* Makes a shortcut for each free name, in the folder of the program it starts. Only a name no program on PATH has is
|
|
27
|
+
* made, so a shortcut never hides another program and never sits in front of one.
|
|
28
|
+
*/
|
|
29
|
+
export declare const addShortcuts: (search: SearchEnv, names?: readonly string[]) => ShortcutChange[];
|
|
30
|
+
/** Removes the shortcuts cli-base made; a file it did not make stays, whatever its name. */
|
|
31
|
+
export declare const removeShortcuts: (search: SearchEnv, names?: readonly string[]) => ShortcutChange[];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, realpathSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { findOnPath, findTools, isBuiltIn } from "./path.js";
|
|
5
|
+
import { fileState, shimFiles, writeShim } from "./shim.js";
|
|
6
|
+
const WIRECAT_PACKAGE = /node_modules[\\/]+@wirecat[\\/]/;
|
|
7
|
+
/** A link into an `@wirecat/*` package, or a package manager's wrapper that starts one (npm on Windows, pnpm). */
|
|
8
|
+
const isWirecatPackage = (path) => {
|
|
9
|
+
try {
|
|
10
|
+
if (WIRECAT_PACKAGE.test(realpathSync(path)))
|
|
11
|
+
return true;
|
|
12
|
+
const buffer = Buffer.alloc(16 * 1024);
|
|
13
|
+
const fd = openSync(path, "r");
|
|
14
|
+
try {
|
|
15
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
16
|
+
return WIRECAT_PACKAGE.test(buffer.subarray(0, read).toString("latin1"));
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const ourShortcut = (path) => {
|
|
27
|
+
const state = fileState(path);
|
|
28
|
+
return state.state === "ours" && state.mark.kind === "shortcut";
|
|
29
|
+
};
|
|
30
|
+
const statusOf = (name, program, search) => {
|
|
31
|
+
const files = shimFiles("shortcut", {
|
|
32
|
+
dir: dirname(program),
|
|
33
|
+
name,
|
|
34
|
+
target: basename(program),
|
|
35
|
+
platform: search.platform,
|
|
36
|
+
});
|
|
37
|
+
const named = findOnPath(name, search);
|
|
38
|
+
const made = [...files.map((file) => file.path), ...named].find(ourShortcut);
|
|
39
|
+
if (made)
|
|
40
|
+
return { name, status: "made", path: made, program };
|
|
41
|
+
const first = named[0];
|
|
42
|
+
if (first === undefined)
|
|
43
|
+
return { name, status: "free", program };
|
|
44
|
+
return { name, status: isWirecatPackage(first) ? "package" : "taken", path: first, program };
|
|
45
|
+
};
|
|
46
|
+
/** A bin folder only an administrator may change is the usual cause, and the person can act on it. */
|
|
47
|
+
const changeFile = (path, change) => {
|
|
48
|
+
try {
|
|
49
|
+
change();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new CliError("permission_error", `could not change ${path}: ${error.code ?? error}`, {
|
|
53
|
+
reason: "access.denied",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** Every installed `wcat-<name>` program by name, the first on PATH for each, built-in names left out. */
|
|
58
|
+
export const installedPrograms = (search) => new Map([...findTools(search)].filter(([name]) => !isBuiltIn(name)));
|
|
59
|
+
const chosen = (names, search) => {
|
|
60
|
+
const installed = installedPrograms(search);
|
|
61
|
+
if (names === undefined || names.length === 0)
|
|
62
|
+
return [...installed].map(([name, paths]) => [name, paths[0]]);
|
|
63
|
+
const missing = names.filter((name) => !installed.has(name));
|
|
64
|
+
if (missing.length > 0)
|
|
65
|
+
throw new CliError("not_found", `not installed: ${missing.map((name) => `wcat-${name}`).join(", ")}`, {
|
|
66
|
+
reason: "tool.not-installed",
|
|
67
|
+
tool: missing[0],
|
|
68
|
+
installed: [...installed.keys()],
|
|
69
|
+
});
|
|
70
|
+
return [...new Set(names)].map((name) => [name, installed.get(name)[0]]);
|
|
71
|
+
};
|
|
72
|
+
/** The short name of each installed tool, or of the tools named: made, free, installed by the package or taken. */
|
|
73
|
+
export const listShortcuts = (search, names) => chosen(names, search).map(([name, program]) => statusOf(name, program, search));
|
|
74
|
+
/**
|
|
75
|
+
* Makes a shortcut for each free name, in the folder of the program it starts. Only a name no program on PATH has is
|
|
76
|
+
* made, so a shortcut never hides another program and never sits in front of one.
|
|
77
|
+
*/
|
|
78
|
+
export const addShortcuts = (search, names) => listShortcuts(search, names).map((shortcut) => {
|
|
79
|
+
if (shortcut.status !== "free")
|
|
80
|
+
return { ...shortcut, changed: false };
|
|
81
|
+
const files = shimFiles("shortcut", {
|
|
82
|
+
dir: dirname(shortcut.program),
|
|
83
|
+
name: shortcut.name,
|
|
84
|
+
target: basename(shortcut.program),
|
|
85
|
+
platform: search.platform,
|
|
86
|
+
});
|
|
87
|
+
for (const file of files)
|
|
88
|
+
changeFile(file.path, () => writeShim(file));
|
|
89
|
+
return { ...shortcut, status: "made", path: files[0]?.path, changed: true };
|
|
90
|
+
});
|
|
91
|
+
/** Removes the shortcuts cli-base made; a file it did not make stays, whatever its name. */
|
|
92
|
+
export const removeShortcuts = (search, names) => listShortcuts(search, names).map((shortcut) => {
|
|
93
|
+
if (shortcut.status !== "made")
|
|
94
|
+
return { ...shortcut, changed: false };
|
|
95
|
+
const files = shimFiles("shortcut", {
|
|
96
|
+
dir: dirname(shortcut.path),
|
|
97
|
+
name: shortcut.name,
|
|
98
|
+
target: basename(shortcut.program),
|
|
99
|
+
platform: search.platform,
|
|
100
|
+
});
|
|
101
|
+
for (const file of files)
|
|
102
|
+
if (ourShortcut(file.path))
|
|
103
|
+
changeFile(file.path, () => rmSync(file.path, { force: true }));
|
|
104
|
+
return { ...statusOf(shortcut.name, shortcut.program, search), changed: true };
|
|
105
|
+
});
|