@f5xc-salesdemos/pi-utils 14.0.3
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/package.json +60 -0
- package/src/abortable.ts +85 -0
- package/src/async.ts +50 -0
- package/src/cli.ts +432 -0
- package/src/color.ts +204 -0
- package/src/dirs.ts +425 -0
- package/src/env.ts +84 -0
- package/src/format.ts +106 -0
- package/src/frontmatter.ts +118 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/hook-fetch.ts +30 -0
- package/src/index.ts +47 -0
- package/src/json.ts +10 -0
- package/src/logger.ts +204 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/peek-file.ts +114 -0
- package/src/postmortem.ts +197 -0
- package/src/procmgr.ts +326 -0
- package/src/prompt.ts +401 -0
- package/src/ptree.ts +386 -0
- package/src/ring.ts +169 -0
- package/src/snowflake.ts +136 -0
- package/src/stream.ts +316 -0
- package/src/temp.ts +77 -0
- package/src/type-guards.ts +11 -0
- package/src/which.ts +230 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@f5xc-salesdemos/pi-utils",
|
|
4
|
+
"version": "14.0.3",
|
|
5
|
+
"description": "Shared utilities for pi packages",
|
|
6
|
+
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
|
+
"author": "Can Boluk",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/f5xc-salesdemos/xcsh.git",
|
|
12
|
+
"directory": "packages/utils"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/f5xc-salesdemos/xcsh/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"utilities",
|
|
19
|
+
"cli",
|
|
20
|
+
"logging",
|
|
21
|
+
"streams"
|
|
22
|
+
],
|
|
23
|
+
"main": "./src/index.ts",
|
|
24
|
+
"types": "./src/index.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "biome check . && bun run check:types",
|
|
27
|
+
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
28
|
+
"lint": "biome lint .",
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"fix": "biome check --write --unsafe .",
|
|
31
|
+
"fmt": "biome format --write ."
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"beautiful-mermaid": "^1.1",
|
|
35
|
+
"handlebars": "^4.7.9",
|
|
36
|
+
"winston": "^3.19",
|
|
37
|
+
"winston-daily-rotate-file": "^5.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "^1.3",
|
|
41
|
+
"@f5xc-salesdemos/pi-natives": "14.0.1"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.3.7"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"src"
|
|
48
|
+
],
|
|
49
|
+
"exports": {
|
|
50
|
+
".": {
|
|
51
|
+
"types": "./src/index.ts",
|
|
52
|
+
"import": "./src/index.ts"
|
|
53
|
+
},
|
|
54
|
+
"./*": {
|
|
55
|
+
"types": "./src/*.ts",
|
|
56
|
+
"import": "./src/*.ts"
|
|
57
|
+
},
|
|
58
|
+
"./*.js": "./src/*.ts"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/abortable.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
export class AbortError extends Error {
|
|
4
|
+
constructor(signal: AbortSignal) {
|
|
5
|
+
assert(signal.aborted, "Abort signal must be aborted");
|
|
6
|
+
|
|
7
|
+
const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled";
|
|
8
|
+
super(`Aborted: ${message}`, { cause: signal.reason });
|
|
9
|
+
this.name = "AbortError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sleep for a given number of milliseconds, respecting abort signal.
|
|
15
|
+
*
|
|
16
|
+
* Uses setTimeout (not Bun.sleep) so that vitest fake timers can intercept it in tests.
|
|
17
|
+
*/
|
|
18
|
+
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
19
|
+
return untilAborted(signal, () => {
|
|
20
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
21
|
+
setTimeout(resolve, ms);
|
|
22
|
+
return promise;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates an abortable stream from a given stream and signal.
|
|
28
|
+
*
|
|
29
|
+
* @param stream - The stream to make abortable
|
|
30
|
+
* @param signal - The signal to abort the stream
|
|
31
|
+
* @returns The abortable stream
|
|
32
|
+
*/
|
|
33
|
+
export function createAbortableStream<T>(stream: ReadableStream<T>, signal?: AbortSignal): ReadableStream<T> {
|
|
34
|
+
if (!signal) return stream;
|
|
35
|
+
return stream.pipeThrough(new TransformStream<T, T>(), { signal });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
|
|
40
|
+
* execution, the promise is rejected with a standard error.
|
|
41
|
+
*
|
|
42
|
+
* @param signal - Optional AbortSignal to cancel the operation
|
|
43
|
+
* @param pr - Function returning a promise to run
|
|
44
|
+
* @returns Promise resolving as `pr` would, or rejecting on abort
|
|
45
|
+
*/
|
|
46
|
+
export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
|
|
47
|
+
if (!signal) return pr();
|
|
48
|
+
if (signal.aborted) return Promise.reject(new AbortError(signal));
|
|
49
|
+
|
|
50
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
51
|
+
const onAbort = () => reject(new AbortError(signal));
|
|
52
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
53
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
54
|
+
|
|
55
|
+
void (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const out = await pr();
|
|
58
|
+
resolve(out);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
reject(err);
|
|
61
|
+
} finally {
|
|
62
|
+
cleanup();
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Memoizes a function with no arguments, calling it once and caching the result.
|
|
71
|
+
*
|
|
72
|
+
* @param fn - Function to be called once
|
|
73
|
+
* @returns A function that returns the cached result of `fn`
|
|
74
|
+
*/
|
|
75
|
+
export function once<T>(fn: () => T): () => T {
|
|
76
|
+
let store = undefined as { value: T } | undefined;
|
|
77
|
+
return () => {
|
|
78
|
+
if (store) {
|
|
79
|
+
return store.value;
|
|
80
|
+
}
|
|
81
|
+
const value = fn();
|
|
82
|
+
store = { value };
|
|
83
|
+
return value;
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/async.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a promise with a timeout and optional abort signal.
|
|
3
|
+
* Rejects with the given message if the timeout fires first.
|
|
4
|
+
* Cleans up all listeners on settlement.
|
|
5
|
+
*/
|
|
6
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string, signal?: AbortSignal): Promise<T> {
|
|
7
|
+
if (signal?.aborted) {
|
|
8
|
+
const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
|
|
9
|
+
return Promise.reject(reason);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { promise: wrapped, resolve, reject } = Promise.withResolvers<T>();
|
|
13
|
+
let settled = false;
|
|
14
|
+
const timeoutId = setTimeout(() => {
|
|
15
|
+
if (settled) return;
|
|
16
|
+
settled = true;
|
|
17
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
18
|
+
reject(new Error(message));
|
|
19
|
+
}, ms);
|
|
20
|
+
|
|
21
|
+
const onAbort = () => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true;
|
|
24
|
+
clearTimeout(timeoutId);
|
|
25
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (signal) {
|
|
29
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
promise.then(
|
|
33
|
+
value => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timeoutId);
|
|
37
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
38
|
+
resolve(value);
|
|
39
|
+
},
|
|
40
|
+
err => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timeoutId);
|
|
44
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
45
|
+
reject(err);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return wrapped;
|
|
50
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal CLI framework — drop-in replacement for the subset of @oclif/core
|
|
3
|
+
* actually used by the coding agent. Provides `Command`, `Args`, `Flags`,
|
|
4
|
+
* and a `run()` entry point with explicit command registration.
|
|
5
|
+
*
|
|
6
|
+
* Design goals:
|
|
7
|
+
* - Zero dependencies beyond node builtins
|
|
8
|
+
* - No filesystem scanning, no manifest files, no plugin loading
|
|
9
|
+
* - Lazy command imports (only the invoked command is loaded)
|
|
10
|
+
* - Typed `this.parse()` output matching oclif's API shape
|
|
11
|
+
*/
|
|
12
|
+
import { parseArgs as nodeParseArgs } from "node:util";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Flag & Arg descriptors
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "string" | "boolean" | "integer"> {
|
|
19
|
+
kind: K;
|
|
20
|
+
description?: string;
|
|
21
|
+
char?: string;
|
|
22
|
+
default?: unknown;
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
options?: readonly string[];
|
|
25
|
+
required?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ArgDescriptor {
|
|
29
|
+
kind: "string";
|
|
30
|
+
description?: string;
|
|
31
|
+
required?: boolean;
|
|
32
|
+
multiple?: boolean;
|
|
33
|
+
options?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface FlagInput {
|
|
37
|
+
description?: string;
|
|
38
|
+
char?: string;
|
|
39
|
+
default?: unknown;
|
|
40
|
+
multiple?: boolean;
|
|
41
|
+
options?: readonly string[];
|
|
42
|
+
required?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ArgInput {
|
|
46
|
+
description?: string;
|
|
47
|
+
required?: boolean;
|
|
48
|
+
multiple?: boolean;
|
|
49
|
+
options?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Builders that match the `Flags.*()` / `Args.*()` API from oclif. */
|
|
53
|
+
export const Flags = {
|
|
54
|
+
string<T extends FlagInput>(opts?: T): FlagDescriptor<"string"> & T {
|
|
55
|
+
return { kind: "string" as const, ...opts } as FlagDescriptor<"string"> & T;
|
|
56
|
+
},
|
|
57
|
+
boolean<T extends FlagInput>(opts?: T): FlagDescriptor<"boolean"> & T {
|
|
58
|
+
return { kind: "boolean" as const, ...opts } as FlagDescriptor<"boolean"> & T;
|
|
59
|
+
},
|
|
60
|
+
integer<T extends FlagInput & { default?: number }>(opts?: T): FlagDescriptor<"integer"> & T {
|
|
61
|
+
return { kind: "integer" as const, ...opts } as FlagDescriptor<"integer"> & T;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Args = {
|
|
66
|
+
string<T extends ArgInput>(opts?: T): ArgDescriptor & T {
|
|
67
|
+
return { kind: "string" as const, ...opts } as ArgDescriptor & T;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Parse result types — mirrors oclif's typed output from this.parse()
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
type FlagValue<D extends FlagDescriptor> = D["kind"] extends "boolean"
|
|
76
|
+
? D extends { default: boolean }
|
|
77
|
+
? boolean
|
|
78
|
+
: boolean | undefined
|
|
79
|
+
: D["kind"] extends "integer"
|
|
80
|
+
? D extends { default: number }
|
|
81
|
+
? number
|
|
82
|
+
: number | undefined
|
|
83
|
+
: D extends { multiple: true }
|
|
84
|
+
? string[] | undefined
|
|
85
|
+
: string | undefined;
|
|
86
|
+
|
|
87
|
+
type ArgValue<D extends ArgDescriptor> = D extends { multiple: true } ? string[] | undefined : string | undefined;
|
|
88
|
+
|
|
89
|
+
type FlagValues<T extends Record<string, FlagDescriptor>> = { [K in keyof T]: FlagValue<T[K]> };
|
|
90
|
+
type ArgValues<T extends Record<string, ArgDescriptor>> = { [K in keyof T]: ArgValue<T[K]> };
|
|
91
|
+
|
|
92
|
+
export interface ParseOutput<
|
|
93
|
+
F extends Record<string, FlagDescriptor> = Record<string, FlagDescriptor>,
|
|
94
|
+
A extends Record<string, ArgDescriptor> = Record<string, ArgDescriptor>,
|
|
95
|
+
> {
|
|
96
|
+
flags: FlagValues<F>;
|
|
97
|
+
args: ArgValues<A>;
|
|
98
|
+
argv: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Command base class
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface CommandCtor {
|
|
106
|
+
new (argv: string[], config: CliConfig): Command;
|
|
107
|
+
description?: string;
|
|
108
|
+
hidden?: boolean;
|
|
109
|
+
strict?: boolean;
|
|
110
|
+
aliases?: string[];
|
|
111
|
+
examples?: string[];
|
|
112
|
+
flags?: Record<string, FlagDescriptor>;
|
|
113
|
+
args?: Record<string, ArgDescriptor>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Configuration passed to every command instance and help renderers. */
|
|
117
|
+
export interface CliConfig {
|
|
118
|
+
bin: string;
|
|
119
|
+
version: string;
|
|
120
|
+
/** All registered commands keyed by their canonical name. */
|
|
121
|
+
commands: Map<string, CommandCtor>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Minimal Command base matching the oclif surface we use. */
|
|
125
|
+
export abstract class Command {
|
|
126
|
+
argv: string[];
|
|
127
|
+
config: CliConfig;
|
|
128
|
+
|
|
129
|
+
constructor(argv: string[], config: CliConfig) {
|
|
130
|
+
this.argv = argv;
|
|
131
|
+
this.config = config;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
abstract run(): Promise<void>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse argv against the static `flags` and `args` declared on the
|
|
138
|
+
* concrete command class. Returns a typed `{ flags, args, argv }` object.
|
|
139
|
+
*/
|
|
140
|
+
async parse<C extends CommandCtor>(
|
|
141
|
+
_Cmd: C,
|
|
142
|
+
): Promise<
|
|
143
|
+
ParseOutput<
|
|
144
|
+
NonNullable<C["flags"]> extends Record<string, FlagDescriptor>
|
|
145
|
+
? NonNullable<C["flags"]>
|
|
146
|
+
: Record<string, FlagDescriptor>,
|
|
147
|
+
NonNullable<C["args"]> extends Record<string, ArgDescriptor>
|
|
148
|
+
? NonNullable<C["args"]>
|
|
149
|
+
: Record<string, ArgDescriptor>
|
|
150
|
+
>
|
|
151
|
+
> {
|
|
152
|
+
const Cmd = _Cmd as CommandCtor;
|
|
153
|
+
const flagDefs = (Cmd.flags ?? {}) as Record<string, FlagDescriptor>;
|
|
154
|
+
const argDefs = (Cmd.args ?? {}) as Record<string, ArgDescriptor>;
|
|
155
|
+
const strict = Cmd.strict !== false;
|
|
156
|
+
|
|
157
|
+
// Build node:util parseArgs options from flag descriptors
|
|
158
|
+
const options: Record<
|
|
159
|
+
string,
|
|
160
|
+
{ type: "string" | "boolean"; short?: string; multiple?: boolean; default?: string | boolean }
|
|
161
|
+
> = {};
|
|
162
|
+
for (const [name, desc] of Object.entries(flagDefs)) {
|
|
163
|
+
const opt: (typeof options)[string] = {
|
|
164
|
+
type: desc.kind === "boolean" ? "boolean" : "string",
|
|
165
|
+
};
|
|
166
|
+
if (desc.char) opt.short = desc.char;
|
|
167
|
+
if (desc.multiple) opt.multiple = true;
|
|
168
|
+
if (desc.default !== undefined) {
|
|
169
|
+
opt.default = desc.kind === "boolean" ? Boolean(desc.default) : String(desc.default);
|
|
170
|
+
}
|
|
171
|
+
options[name] = opt;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// strict=false when command declares args (positionals must pass through)
|
|
175
|
+
// or when the command itself opts out
|
|
176
|
+
const { values: rawValues, positionals } = nodeParseArgs({
|
|
177
|
+
args: this.argv,
|
|
178
|
+
options,
|
|
179
|
+
allowPositionals: true,
|
|
180
|
+
strict,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Convert raw values to proper types and validate
|
|
184
|
+
const flags: Record<string, unknown> = {};
|
|
185
|
+
for (const [name, desc] of Object.entries(flagDefs)) {
|
|
186
|
+
const raw = rawValues[name];
|
|
187
|
+
if (desc.kind === "integer") {
|
|
188
|
+
if (raw === undefined || typeof raw === "boolean") {
|
|
189
|
+
flags[name] = desc.default ?? undefined;
|
|
190
|
+
} else {
|
|
191
|
+
const n = Number.parseInt(raw as string, 10);
|
|
192
|
+
if (Number.isNaN(n)) {
|
|
193
|
+
throw new Error(`Expected integer for --${name}, got "${raw}"`);
|
|
194
|
+
}
|
|
195
|
+
flags[name] = n;
|
|
196
|
+
}
|
|
197
|
+
} else if (desc.kind === "boolean") {
|
|
198
|
+
flags[name] =
|
|
199
|
+
raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined;
|
|
200
|
+
} else {
|
|
201
|
+
// string
|
|
202
|
+
const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined);
|
|
203
|
+
// Validate options constraint
|
|
204
|
+
if (val !== undefined && desc.options && !Array.isArray(val)) {
|
|
205
|
+
if (!desc.options.includes(val as string)) {
|
|
206
|
+
throw new Error(`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
flags[name] = val;
|
|
210
|
+
}
|
|
211
|
+
// Validate required
|
|
212
|
+
if (desc.required && flags[name] === undefined) {
|
|
213
|
+
throw new Error(`Missing required flag: --${name}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Map positionals to named args in declaration order and validate
|
|
218
|
+
const args: Record<string, unknown> = {};
|
|
219
|
+
let posIdx = 0;
|
|
220
|
+
for (const [argName, desc] of Object.entries(argDefs)) {
|
|
221
|
+
if (desc.multiple) {
|
|
222
|
+
const val = positionals.slice(posIdx);
|
|
223
|
+
args[argName] = val.length > 0 ? val : undefined;
|
|
224
|
+
posIdx = positionals.length;
|
|
225
|
+
} else {
|
|
226
|
+
const val = positionals[posIdx];
|
|
227
|
+
args[argName] = val;
|
|
228
|
+
posIdx++;
|
|
229
|
+
}
|
|
230
|
+
// Validate required
|
|
231
|
+
if (desc.required && args[argName] === undefined) {
|
|
232
|
+
throw new Error(`Missing required argument: ${argName}`);
|
|
233
|
+
}
|
|
234
|
+
// Validate options constraint
|
|
235
|
+
const argVal = args[argName];
|
|
236
|
+
if (argVal !== undefined && desc.options && typeof argVal === "string") {
|
|
237
|
+
if (!desc.options.includes(argVal)) {
|
|
238
|
+
throw new Error(`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { flags, args, argv: positionals } as never;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Help rendering
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** Render full root help: header, default command details, subcommand list. */
|
|
252
|
+
export function renderRootHelp(config: CliConfig): void {
|
|
253
|
+
const { bin, version, commands } = config;
|
|
254
|
+
const lines: string[] = [];
|
|
255
|
+
lines.push(`${bin} v${version}\n`);
|
|
256
|
+
lines.push("USAGE");
|
|
257
|
+
lines.push(` $ ${bin} [COMMAND]\n`);
|
|
258
|
+
|
|
259
|
+
// Show the default command's flags/args/examples inline.
|
|
260
|
+
// The default command is the one marked hidden (it's the implicit entry point).
|
|
261
|
+
const defaultCmd = [...commands.values()].find(C => C.hidden);
|
|
262
|
+
if (defaultCmd) {
|
|
263
|
+
renderCommandBody(lines, defaultCmd);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// List visible subcommands
|
|
267
|
+
const visible = [...commands.entries()].filter(([, C]) => !C.hidden);
|
|
268
|
+
if (visible.length > 0) {
|
|
269
|
+
lines.push("COMMANDS");
|
|
270
|
+
const maxLen = Math.max(...visible.map(([n]) => n.length));
|
|
271
|
+
for (const [name, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
272
|
+
lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`);
|
|
273
|
+
}
|
|
274
|
+
lines.push("");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
process.stdout.write(lines.join("\n"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Render help for a single command. */
|
|
281
|
+
export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void {
|
|
282
|
+
const lines: string[] = [];
|
|
283
|
+
if (Cmd.description) lines.push(`${Cmd.description}\n`);
|
|
284
|
+
lines.push("USAGE");
|
|
285
|
+
const argNames = Object.keys(Cmd.args ?? {});
|
|
286
|
+
const argStr = argNames.length > 0 ? ` ${argNames.map(n => `[${n.toUpperCase()}]`).join(" ")}` : "";
|
|
287
|
+
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
288
|
+
lines.push(` $ ${bin} ${id}${argStr}${hasFlags ? " [FLAGS]" : ""}\n`);
|
|
289
|
+
renderCommandBody(lines, Cmd);
|
|
290
|
+
process.stdout.write(lines.join("\n"));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
|
|
294
|
+
const argDefs = Cmd.args ?? {};
|
|
295
|
+
const flagDefs = Cmd.flags ?? {};
|
|
296
|
+
|
|
297
|
+
// Arguments
|
|
298
|
+
const argEntries = Object.entries(argDefs);
|
|
299
|
+
if (argEntries.length > 0) {
|
|
300
|
+
lines.push("ARGUMENTS");
|
|
301
|
+
const maxLen = Math.max(...argEntries.map(([n]) => n.length));
|
|
302
|
+
for (const [name, desc] of argEntries) {
|
|
303
|
+
const parts = [name.toUpperCase().padEnd(maxLen + 2)];
|
|
304
|
+
if (desc.description) parts.push(desc.description);
|
|
305
|
+
if (desc.options) parts.push(`(${[...desc.options].join("|")})`);
|
|
306
|
+
lines.push(` ${parts.join(" ")}`);
|
|
307
|
+
}
|
|
308
|
+
lines.push("");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Flags
|
|
312
|
+
const flagEntries = Object.entries(flagDefs);
|
|
313
|
+
if (flagEntries.length > 0) {
|
|
314
|
+
lines.push("FLAGS");
|
|
315
|
+
const formatted: [string, string][] = [];
|
|
316
|
+
for (const [name, desc] of flagEntries) {
|
|
317
|
+
const charPart = desc.char ? `-${desc.char}, ` : " ";
|
|
318
|
+
const namePart = `--${name}`;
|
|
319
|
+
const typePart = desc.kind === "boolean" ? "" : desc.kind === "integer" ? "=<int>" : "=<value>";
|
|
320
|
+
formatted.push([` ${charPart}${namePart}${typePart}`, desc.description ?? ""]);
|
|
321
|
+
}
|
|
322
|
+
const maxLeft = Math.max(...formatted.map(([l]) => l.length));
|
|
323
|
+
for (const [left, right] of formatted) {
|
|
324
|
+
lines.push(`${left.padEnd(maxLeft + 2)}${right}`);
|
|
325
|
+
}
|
|
326
|
+
lines.push("");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Examples
|
|
330
|
+
if (Cmd.examples && Cmd.examples.length > 0) {
|
|
331
|
+
lines.push("EXAMPLES");
|
|
332
|
+
for (const ex of Cmd.examples) {
|
|
333
|
+
for (const line of ex.split("\n")) {
|
|
334
|
+
lines.push(` ${line}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
lines.push("");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// CLI entry point
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/** A lazily-loaded command: canonical name, loader, and optional aliases. */
|
|
346
|
+
export interface CommandEntry {
|
|
347
|
+
name: string;
|
|
348
|
+
load: () => Promise<CommandCtor>;
|
|
349
|
+
aliases?: string[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface RunOptions {
|
|
353
|
+
bin: string;
|
|
354
|
+
version: string;
|
|
355
|
+
argv: string[];
|
|
356
|
+
commands: CommandEntry[];
|
|
357
|
+
/** Custom help renderer. Receives fully-populated config. */
|
|
358
|
+
help?: (config: CliConfig) => Promise<void> | void;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Find a command entry by exact name or alias. */
|
|
362
|
+
function findEntry(commands: CommandEntry[], id: string): CommandEntry | undefined {
|
|
363
|
+
return commands.find(e => e.name === id) ?? commands.find(e => e.aliases?.includes(id));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Main entry point — replaces `run()` from @oclif/core.
|
|
368
|
+
*
|
|
369
|
+
* Each command is explicitly registered with a lazy loader.
|
|
370
|
+
* No filesystem scanning, no plugin system, no package.json reading.
|
|
371
|
+
*/
|
|
372
|
+
export async function run(opts: RunOptions): Promise<void> {
|
|
373
|
+
const { bin, version, argv } = opts;
|
|
374
|
+
|
|
375
|
+
const commandId = argv[0] ?? "";
|
|
376
|
+
const commandArgv = argv.slice(1);
|
|
377
|
+
|
|
378
|
+
// Top-level help
|
|
379
|
+
if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") {
|
|
380
|
+
const config = await loadAllCommands(opts);
|
|
381
|
+
if (opts.help) {
|
|
382
|
+
await opts.help(config);
|
|
383
|
+
} else {
|
|
384
|
+
renderRootHelp(config);
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Version
|
|
390
|
+
if (commandId === "--version" || commandId === "-v") {
|
|
391
|
+
process.stdout.write(`${bin}/${version}\n`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Per-command help
|
|
396
|
+
if (commandArgv.includes("--help") || commandArgv.includes("-h")) {
|
|
397
|
+
const config = await loadAllCommands(opts);
|
|
398
|
+
// Resolve aliases for help too
|
|
399
|
+
const entry = findEntry(opts.commands, commandId);
|
|
400
|
+
const Cmd = entry ? config.commands.get(entry.name) : undefined;
|
|
401
|
+
if (Cmd) {
|
|
402
|
+
renderCommandHelp(bin, entry!.name, Cmd);
|
|
403
|
+
} else {
|
|
404
|
+
process.stderr.write(`Unknown command: ${commandId}\n`);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Find command by name or alias
|
|
410
|
+
const entry = findEntry(opts.commands, commandId);
|
|
411
|
+
|
|
412
|
+
if (!entry) {
|
|
413
|
+
process.stderr.write(`Error: command ${commandId} not found\n`);
|
|
414
|
+
process.exitCode = 1;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const Cmd = await entry.load();
|
|
419
|
+
const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
|
|
420
|
+
const instance = new Cmd(commandArgv, config);
|
|
421
|
+
await instance.run();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Resolve all command loaders for help/alias display. */
|
|
425
|
+
async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
|
|
426
|
+
const commands = new Map<string, CommandCtor>();
|
|
427
|
+
const loaded = await Promise.all(opts.commands.map(async e => [e.name, await e.load()] as const));
|
|
428
|
+
for (const [name, Cmd] of loaded) {
|
|
429
|
+
commands.set(name, Cmd);
|
|
430
|
+
}
|
|
431
|
+
return { bin: opts.bin, version: opts.version, commands };
|
|
432
|
+
}
|