@baseworks/cli 0.2.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/dist/args.d.ts +30 -0
- package/dist/args.js +10 -0
- package/dist/chunk-5U3CFW6F.js +26 -0
- package/dist/chunk-BEHHVEGL.js +17 -0
- package/dist/chunk-C2YZKMJ7.js +185 -0
- package/dist/chunk-H4M2NPA2.js +46 -0
- package/dist/chunk-HJFKWDPX.js +40 -0
- package/dist/chunk-LQJTQ52B.js +98 -0
- package/dist/chunk-TSVJD4R6.js +51 -0
- package/dist/chunk-XQGLG3X3.js +54 -0
- package/dist/chunk-ZD7NOFZL.js +59 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +8 -0
- package/dist/config-loader.d.ts +41 -0
- package/dist/config-loader.js +10 -0
- package/dist/context.d.ts +45 -0
- package/dist/context.js +6 -0
- package/dist/display.d.ts +82 -0
- package/dist/display.js +32 -0
- package/dist/fmt.d.ts +19 -0
- package/dist/fmt.js +17 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +78 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/middleware.js +11 -0
- package/dist/plugin.d.ts +99 -0
- package/dist/plugin.js +0 -0
- package/dist/runner.d.ts +53 -0
- package/dist/runner.js +10 -0
- package/dist/tree.d.ts +31 -0
- package/dist/tree.js +7 -0
- package/package.json +34 -0
- package/src/args.ts +77 -0
- package/src/client.ts +80 -0
- package/src/config-loader.ts +68 -0
- package/src/context.ts +124 -0
- package/src/display.ts +287 -0
- package/src/fmt.ts +59 -0
- package/src/index.ts +34 -0
- package/src/middleware.ts +80 -0
- package/src/plugin.ts +120 -0
- package/src/runner.ts +164 -0
- package/src/testing.ts +125 -0
- package/src/tree.ts +55 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @baseworks/cli — shared CLI primitives for Node.js CLIs.
|
|
2
|
+
|
|
3
|
+
// Display
|
|
4
|
+
export { clr, table, kv, success, warn, fatal, prompt, printOrJson, printOutput, outputOption } from './display.js';
|
|
5
|
+
export type { ColDef, OutputFormat, TableOpts } from './display.js';
|
|
6
|
+
|
|
7
|
+
// Format helpers
|
|
8
|
+
export { fmtDate, fmtRole, fmtStatus, fmtBool, fmtCount, fmtId } from './fmt.js';
|
|
9
|
+
|
|
10
|
+
// Tree display
|
|
11
|
+
export { tree } from './tree.js';
|
|
12
|
+
export type { TreeNode } from './tree.js';
|
|
13
|
+
|
|
14
|
+
// Arg / flag parsing
|
|
15
|
+
export { parseFlags, isJsonMode, getFlag } from './args.js';
|
|
16
|
+
export type { ParsedArgs } from './args.js';
|
|
17
|
+
|
|
18
|
+
// Context manager
|
|
19
|
+
export { createContextManager } from './context.js';
|
|
20
|
+
export type { ContextManager, BaseConfig } from './context.js';
|
|
21
|
+
|
|
22
|
+
// Config field resolvers (env var names are project-specific — pass as params)
|
|
23
|
+
export { resolveToken, resolveApiBase, resolveField } from './config-loader.js';
|
|
24
|
+
export type { ResolveOptions, ResolveBaseOptions } from './config-loader.js';
|
|
25
|
+
|
|
26
|
+
// HTTP client
|
|
27
|
+
export { createApiClient } from './client.js';
|
|
28
|
+
export type { ApiClient, ApiError } from './client.js';
|
|
29
|
+
|
|
30
|
+
// Plugin system
|
|
31
|
+
export { createCliRunner } from './runner.js';
|
|
32
|
+
export type { CliRunner, CliRunnerConfig } from './runner.js';
|
|
33
|
+
export type { CliPlugin, CliCommand, CliMiddlewareFn, CommandDeps, CliHooks } from './plugin.js';
|
|
34
|
+
export { requiresToken, requiresContext, confirmDestructive } from './middleware.js';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in CLI middleware.
|
|
3
|
+
*
|
|
4
|
+
* Each function returns a CliMiddlewareFn — pass the result to
|
|
5
|
+
* CliCommand.middleware[], or let the runner apply it via the
|
|
6
|
+
* requiresToken / requiresContext / confirm shorthand properties.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fatal, prompt, warn } from './display.js';
|
|
10
|
+
import type { CliMiddlewareFn } from './plugin.js';
|
|
11
|
+
|
|
12
|
+
// ─── requiresToken ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ensures deps.token is set before the command runs.
|
|
16
|
+
* The runner pre-populates deps.token from getToken(); this middleware
|
|
17
|
+
* just guards and gives a clear error message if it's missing.
|
|
18
|
+
*
|
|
19
|
+
* Applied automatically when CliCommand.requiresToken !== false.
|
|
20
|
+
*/
|
|
21
|
+
export function requiresToken(): CliMiddlewareFn {
|
|
22
|
+
return async (deps, next) => {
|
|
23
|
+
if (!deps.token) {
|
|
24
|
+
fatal('No API token found. Run: login');
|
|
25
|
+
}
|
|
26
|
+
await next();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── requiresContext ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ensures an active org/project context is set before the command runs.
|
|
34
|
+
* Sets deps.context to the result of ctx.getContext().
|
|
35
|
+
*
|
|
36
|
+
* Applied automatically when CliCommand.requiresContext === true.
|
|
37
|
+
*/
|
|
38
|
+
export function requiresContext(): CliMiddlewareFn {
|
|
39
|
+
return async (deps, next) => {
|
|
40
|
+
const ctx = deps.ctx.getContext();
|
|
41
|
+
if (!ctx) {
|
|
42
|
+
fatal('No active context. Set one with: use <org>');
|
|
43
|
+
}
|
|
44
|
+
deps.context = ctx;
|
|
45
|
+
await next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── confirmDestructive ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Prompts the user with a y/N confirmation before continuing.
|
|
53
|
+
* In non-TTY / CI environments (stdin not interactive), auto-aborts
|
|
54
|
+
* unless the --yes / -y flag is present.
|
|
55
|
+
*
|
|
56
|
+
* Applied automatically when CliCommand.confirm is set.
|
|
57
|
+
*/
|
|
58
|
+
export function confirmDestructive(message: string): CliMiddlewareFn {
|
|
59
|
+
return async (deps, next) => {
|
|
60
|
+
// --yes / -y bypasses the prompt (CI-friendly)
|
|
61
|
+
if (deps.flags['yes'] || deps.flags['y']) {
|
|
62
|
+
await next();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isTTY = Boolean(process.stdin.isTTY);
|
|
67
|
+
if (!isTTY) {
|
|
68
|
+
fatal(`Destructive operation requires confirmation. Re-run with --yes to proceed.\n ${message}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
warn(message);
|
|
72
|
+
const answer = await prompt('Continue? (y/N) ');
|
|
73
|
+
if (answer.toLowerCase() !== 'y') {
|
|
74
|
+
console.log('Aborted.');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await next();
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI plugin system — types.
|
|
3
|
+
*
|
|
4
|
+
* A CliPlugin groups related commands. Each command declares its middleware
|
|
5
|
+
* requirements declaratively; the runner builds and executes the chain.
|
|
6
|
+
*
|
|
7
|
+
* argv → [requiresToken] → [requiresContext] → [confirmDestructive] → [custom…] → fn
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiClient } from './client.js';
|
|
11
|
+
import type { ContextManager } from './context.js';
|
|
12
|
+
|
|
13
|
+
// ─── Deps — shared context passed through every middleware and command ─────────
|
|
14
|
+
|
|
15
|
+
export interface CommandDeps {
|
|
16
|
+
/** Full argv slice (after command name). */
|
|
17
|
+
argv: string[];
|
|
18
|
+
/** Positional args (non-flag). */
|
|
19
|
+
args: string[];
|
|
20
|
+
/** Parsed flags. */
|
|
21
|
+
flags: Record<string, string | boolean>;
|
|
22
|
+
/** Authenticated HTTP client. */
|
|
23
|
+
http: ApiClient;
|
|
24
|
+
/** Config / context manager for this app. */
|
|
25
|
+
ctx: ContextManager<Record<string, string | undefined>>;
|
|
26
|
+
/** Frontend app URL (for browser-based flows like login). */
|
|
27
|
+
appBase: string;
|
|
28
|
+
/**
|
|
29
|
+
* Set by requiresToken middleware.
|
|
30
|
+
* Guaranteed non-null inside commands that declare requiresToken (default).
|
|
31
|
+
*/
|
|
32
|
+
token?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Set by requiresContext middleware.
|
|
35
|
+
* Guaranteed non-null inside commands that declare requiresContext: true.
|
|
36
|
+
*/
|
|
37
|
+
context?: Record<string, string | undefined>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A middleware function. Call next() to continue the chain.
|
|
44
|
+
* Throw (or call fatal()) to abort.
|
|
45
|
+
*/
|
|
46
|
+
export type CliMiddlewareFn = (
|
|
47
|
+
deps: CommandDeps,
|
|
48
|
+
next: () => Promise<void>,
|
|
49
|
+
) => Promise<void>;
|
|
50
|
+
|
|
51
|
+
// ─── Command ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface CliCommand {
|
|
54
|
+
/** The command handler — runs after all middleware. */
|
|
55
|
+
fn: (deps: CommandDeps) => Promise<void> | void;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ensure a token is present before running.
|
|
59
|
+
* Default: true. Set to false for commands like `login` or `help`.
|
|
60
|
+
*/
|
|
61
|
+
requiresToken?: boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensure org/project context is set (ctx.getContext() non-null).
|
|
65
|
+
* Default: false.
|
|
66
|
+
*/
|
|
67
|
+
requiresContext?: boolean;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Show a "are you sure?" confirmation prompt before running.
|
|
71
|
+
* Supply the message to display. No prompt if undefined.
|
|
72
|
+
*/
|
|
73
|
+
confirm?: string;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Additional middleware to run after the built-in guards,
|
|
77
|
+
* in declaration order, before fn.
|
|
78
|
+
*/
|
|
79
|
+
middleware?: CliMiddlewareFn[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface CliPlugin {
|
|
85
|
+
/** Map of command name → CliCommand. */
|
|
86
|
+
commands: Record<string, CliCommand>;
|
|
87
|
+
/** Short aliases → canonical command name. */
|
|
88
|
+
aliases?: Record<string, string>;
|
|
89
|
+
/** Help text section contributed by this plugin (shown by dtab --help). */
|
|
90
|
+
helpText?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Per-subcommand help strings, shown when --help appears after a subcommand.
|
|
93
|
+
* Key format: "command sub" e.g. "account tokens create"
|
|
94
|
+
* The runner prints this when deps.flags['help'] is true.
|
|
95
|
+
*/
|
|
96
|
+
subHelp?: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export interface CliHooks {
|
|
102
|
+
/**
|
|
103
|
+
* Fires before the middleware chain + command fn.
|
|
104
|
+
* Useful for telemetry, logging, debug output.
|
|
105
|
+
*/
|
|
106
|
+
beforeCommand?: (name: string, deps: CommandDeps) => Promise<void> | void;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fires after successful command completion.
|
|
110
|
+
* durationMs = wall-clock time including middleware.
|
|
111
|
+
*/
|
|
112
|
+
afterCommand?: (name: string, deps: CommandDeps, durationMs: number) => Promise<void> | void;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fires when any unhandled error escapes the command fn or its middleware.
|
|
116
|
+
* If you handle the error here, do NOT re-throw — the runner will not fatal().
|
|
117
|
+
* If you don't register this hook, the runner calls fatal(err.message).
|
|
118
|
+
*/
|
|
119
|
+
onError?: (name: string, error: Error, deps: CommandDeps) => Promise<void> | void;
|
|
120
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI runner — plugin registration, middleware chain execution, lifecycle hooks.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const runner = createCliRunner({
|
|
6
|
+
* ctx,
|
|
7
|
+
* appBase: 'https://myapp.com',
|
|
8
|
+
* getToken: () => resolveToken(ctx.loadConfig(), { env: 'MY_TOKEN' }),
|
|
9
|
+
* getApiBase: () => resolveApiBase(ctx.loadConfig(), { env: 'MY_API_URL', default: 'https://api.myapp.com' }),
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* runner.register(accountCliPlugin({ ... }));
|
|
13
|
+
* runner.register(orgCliPlugin());
|
|
14
|
+
* runner.register(myAppPlugin);
|
|
15
|
+
*
|
|
16
|
+
* runner.hooks({
|
|
17
|
+
* onError: (name, err) => { ... }, // optional telemetry
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* await runner.run(process.argv.slice(2));
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseFlags } from './args.js';
|
|
24
|
+
import { createApiClient } from './client.js';
|
|
25
|
+
import { fatal } from './display.js';
|
|
26
|
+
import { requiresToken as requiresTokenMw, requiresContext as requiresContextMw, confirmDestructive } from './middleware.js';
|
|
27
|
+
import type { ContextManager } from './context.js';
|
|
28
|
+
import type { CliPlugin, CliCommand, CliHooks, CommandDeps, CliMiddlewareFn } from './plugin.js';
|
|
29
|
+
|
|
30
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export interface CliRunnerConfig {
|
|
33
|
+
/** Context manager for this application. */
|
|
34
|
+
ctx: ContextManager<Record<string, string | undefined>>;
|
|
35
|
+
/** Frontend app base URL — used by browser-based flows (e.g. login). */
|
|
36
|
+
appBase: string;
|
|
37
|
+
/**
|
|
38
|
+
* Returns the active API token, or undefined if not set.
|
|
39
|
+
* Called fresh on every request so config changes are picked up immediately.
|
|
40
|
+
*/
|
|
41
|
+
getToken: () => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the API base URL.
|
|
44
|
+
* Called fresh on every request.
|
|
45
|
+
*/
|
|
46
|
+
getApiBase: () => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Runner ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface CliRunner {
|
|
52
|
+
/** Register a plugin — merges its commands and aliases. */
|
|
53
|
+
register(plugin: CliPlugin): void;
|
|
54
|
+
/** Register lifecycle hooks (additive — multiple calls merge). */
|
|
55
|
+
hooks(h: Partial<CliHooks>): void;
|
|
56
|
+
/** Execute the CLI with the given argv (typically process.argv.slice(2)). */
|
|
57
|
+
run(argv: string[]): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createCliRunner(config: CliRunnerConfig): CliRunner {
|
|
61
|
+
const allCommands: Record<string, CliCommand> = {};
|
|
62
|
+
const allAliases: Record<string, string> = {};
|
|
63
|
+
const helpSections: string[] = [];
|
|
64
|
+
let activeHooks: Partial<CliHooks> = {};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
register(plugin: CliPlugin) {
|
|
68
|
+
for (const [name, cmd] of Object.entries(plugin.commands)) {
|
|
69
|
+
allCommands[name] = cmd;
|
|
70
|
+
}
|
|
71
|
+
for (const [alias, target] of Object.entries(plugin.aliases ?? {})) {
|
|
72
|
+
allAliases[alias] = target;
|
|
73
|
+
}
|
|
74
|
+
if (plugin.helpText) helpSections.push(plugin.helpText);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
hooks(h: Partial<CliHooks>) {
|
|
78
|
+
activeHooks = { ...activeHooks, ...h };
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async run(argv: string[]) {
|
|
82
|
+
const { args: topArgs, flags: topFlags } = parseFlags(argv);
|
|
83
|
+
|
|
84
|
+
// Version / help short-circuits
|
|
85
|
+
if (topFlags['version'] || topFlags['v']) {
|
|
86
|
+
// Version is app-specific — let the command handle it, or noop here.
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rawCmd = topArgs[0];
|
|
91
|
+
// Show global help only when: no command given, OR --help is the very first token
|
|
92
|
+
// (e.g. `dtab --help`). When --help appears AFTER a command token
|
|
93
|
+
// (e.g. `dtab account tokens create --help`) pass it through to the command fn.
|
|
94
|
+
const globalHelp = !rawCmd || argv[0] === '--help' || argv[0] === '-h';
|
|
95
|
+
if (globalHelp) {
|
|
96
|
+
console.log(helpSections.join('\n'));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cmdName = allAliases[rawCmd] ?? rawCmd;
|
|
101
|
+
const cmd = allCommands[cmdName];
|
|
102
|
+
if (!cmd) {
|
|
103
|
+
fatal(`Unknown command: ${rawCmd}. Run with --help for usage.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Slice argv to get everything after the command name token
|
|
107
|
+
const rawIdx = argv.indexOf(rawCmd);
|
|
108
|
+
const restArgv = argv.slice(rawIdx + 1);
|
|
109
|
+
const { args, flags } = parseFlags(restArgv);
|
|
110
|
+
|
|
111
|
+
// Build deps — token pre-populated so requiresToken middleware can guard
|
|
112
|
+
const deps: CommandDeps = {
|
|
113
|
+
argv: restArgv,
|
|
114
|
+
args,
|
|
115
|
+
flags,
|
|
116
|
+
http: createApiClient(config.getToken, config.getApiBase),
|
|
117
|
+
ctx: config.ctx,
|
|
118
|
+
appBase: config.appBase,
|
|
119
|
+
token: config.getToken(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Lifecycle: beforeCommand
|
|
123
|
+
await activeHooks.beforeCommand?.(cmdName, deps);
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await executeCommand(cmd, deps);
|
|
128
|
+
await activeHooks.afterCommand?.(cmdName, deps, Date.now() - t0);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
131
|
+
if (activeHooks.onError) {
|
|
132
|
+
await activeHooks.onError(cmdName, err, deps);
|
|
133
|
+
} else {
|
|
134
|
+
fatal(err.message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Chain execution ─────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function executeCommand(cmd: CliCommand, deps: CommandDeps): Promise<void> {
|
|
144
|
+
const chain: CliMiddlewareFn[] = [];
|
|
145
|
+
|
|
146
|
+
// Built-in guards from shorthand properties
|
|
147
|
+
if (cmd.requiresToken !== false) chain.push(requiresTokenMw());
|
|
148
|
+
if (cmd.requiresContext) chain.push(requiresContextMw());
|
|
149
|
+
if (cmd.confirm) chain.push(confirmDestructive(cmd.confirm));
|
|
150
|
+
|
|
151
|
+
// Custom middleware declared on the command
|
|
152
|
+
if (cmd.middleware?.length) chain.push(...cmd.middleware);
|
|
153
|
+
|
|
154
|
+
// Execute chain then fn
|
|
155
|
+
let i = 0;
|
|
156
|
+
const next = async (): Promise<void> => {
|
|
157
|
+
if (i < chain.length) {
|
|
158
|
+
await chain[i++]!(deps, next);
|
|
159
|
+
} else {
|
|
160
|
+
await cmd.fn(deps);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await next();
|
|
164
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @baseworks/cli/testing
|
|
3
|
+
*
|
|
4
|
+
* Shared test utilities for CLI packages.
|
|
5
|
+
* Import in test files only — do not use in production code.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { runCommand, mockGet, mockPost, server } from '@baseworks/cli/testing'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Command } from 'commander'
|
|
12
|
+
|
|
13
|
+
// ─── runCommand ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface RunResult {
|
|
16
|
+
stdout: string
|
|
17
|
+
stderr: string
|
|
18
|
+
exitCode: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs a Commander program in-process, capturing stdout/stderr.
|
|
23
|
+
* Prevents process.exit from actually exiting.
|
|
24
|
+
*
|
|
25
|
+
* @param program - A Commander Command (root program)
|
|
26
|
+
* @param args - User args, e.g. ['org', 'create', '--name', 'Foo']
|
|
27
|
+
*/
|
|
28
|
+
export async function runCommand(program: Command, args: string[]): Promise<RunResult> {
|
|
29
|
+
const stdoutChunks: string[] = []
|
|
30
|
+
const stderrChunks: string[] = []
|
|
31
|
+
|
|
32
|
+
const origWrite = process.stdout.write.bind(process.stdout)
|
|
33
|
+
const origErr = process.stderr.write.bind(process.stderr)
|
|
34
|
+
const origLog = console.log
|
|
35
|
+
const origError = console.error
|
|
36
|
+
const origExit = process.exit.bind(process)
|
|
37
|
+
|
|
38
|
+
let exitCode = 0
|
|
39
|
+
|
|
40
|
+
process.stdout.write = ((c: unknown) => { stdoutChunks.push(String(c)); return true }) as typeof process.stdout.write
|
|
41
|
+
process.stderr.write = ((c: unknown) => { stderrChunks.push(String(c)); return true }) as typeof process.stderr.write
|
|
42
|
+
console.log = (...a: unknown[]) => stdoutChunks.push(a.join(' ') + '\n')
|
|
43
|
+
console.error = (...a: unknown[]) => stderrChunks.push(a.join(' ') + '\n')
|
|
44
|
+
;(process as NodeJS.Process & { exit: (c?: number) => never }).exit = (code?: number) => {
|
|
45
|
+
exitCode = code ?? 0
|
|
46
|
+
throw new Error(`__exit__${code ?? 0}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await program.parseAsync(args, { from: 'user' })
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof Error && !err.message.startsWith('__exit__')) {
|
|
53
|
+
if ((err as NodeJS.ErrnoException).code?.startsWith('commander.')) {
|
|
54
|
+
exitCode = 0
|
|
55
|
+
} else {
|
|
56
|
+
stderrChunks.push(err.message + '\n')
|
|
57
|
+
exitCode = exitCode || 1
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
process.stdout.write = origWrite
|
|
62
|
+
process.stderr.write = origErr
|
|
63
|
+
console.log = origLog
|
|
64
|
+
console.error = origError
|
|
65
|
+
;(process as NodeJS.Process & { exit: (c?: number) => never }).exit = origExit
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join(''), exitCode }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── MSW mock helpers ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
import { http, HttpResponse, type HttpHandler } from 'msw'
|
|
74
|
+
import { setupServer } from 'msw/node'
|
|
75
|
+
|
|
76
|
+
export { http, HttpResponse }
|
|
77
|
+
|
|
78
|
+
export const server = setupServer()
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create mock helpers bound to a specific base URL.
|
|
82
|
+
* Usage:
|
|
83
|
+
* const { mockGet, mockPost, ... } = createMocks('http://flect.test')
|
|
84
|
+
*/
|
|
85
|
+
export function createMocks(base: string) {
|
|
86
|
+
return {
|
|
87
|
+
mockGet(path: string, body: unknown, status = 200) {
|
|
88
|
+
server.use(http.get(`${base}${path}`, () => HttpResponse.json(body, { status })))
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
mockPost(path: string, body: unknown, status = 201) {
|
|
92
|
+
server.use(http.post(`${base}${path}`, () => HttpResponse.json(body, { status })))
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
mockPatch(path: string, body: unknown, status = 200) {
|
|
96
|
+
server.use(http.patch(`${base}${path}`, () => HttpResponse.json(body, { status })))
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
mockDelete(path: string, status = 204) {
|
|
100
|
+
server.use(http.delete(`${base}${path}`, () => new HttpResponse(null, { status })))
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
mockPostCapture(path: string, responseBody: unknown, status = 201) {
|
|
104
|
+
const captured: { body?: unknown } = {}
|
|
105
|
+
server.use(http.post(`${base}${path}`, async ({ request }) => {
|
|
106
|
+
captured.body = await request.json()
|
|
107
|
+
return HttpResponse.json(responseBody, { status })
|
|
108
|
+
}))
|
|
109
|
+
return captured
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
mockPatchCapture(path: string, responseBody: unknown, status = 200) {
|
|
113
|
+
const captured: { body?: unknown } = {}
|
|
114
|
+
server.use(http.patch(`${base}${path}`, async ({ request }) => {
|
|
115
|
+
captured.body = await request.json()
|
|
116
|
+
return HttpResponse.json(responseBody, { status })
|
|
117
|
+
}))
|
|
118
|
+
return captured
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
use(...handlers: HttpHandler[]) {
|
|
122
|
+
server.use(...handlers)
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical tree display for CLI output.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* tree([
|
|
6
|
+
* {
|
|
7
|
+
* label: 'taskflow', badge: '●', meta: 'org',
|
|
8
|
+
* children: [
|
|
9
|
+
* {
|
|
10
|
+
* label: 'platform', meta: 'workspace',
|
|
11
|
+
* children: [
|
|
12
|
+
* { label: 'taskflow', badge: '🌿', meta: 'project · production, staging' },
|
|
13
|
+
* ],
|
|
14
|
+
* },
|
|
15
|
+
* ],
|
|
16
|
+
* },
|
|
17
|
+
* ]);
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { clr } from './display.js';
|
|
21
|
+
|
|
22
|
+
export interface TreeNode {
|
|
23
|
+
/** Primary label — printed bold. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Optional badge/icon before the label. */
|
|
26
|
+
badge?: string;
|
|
27
|
+
/** Dimmed metadata shown after the label. */
|
|
28
|
+
meta?: string;
|
|
29
|
+
/** Child nodes. */
|
|
30
|
+
children?: TreeNode[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function tree(nodes: TreeNode[], prefix = ''): void {
|
|
34
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
35
|
+
const node = nodes[i]!;
|
|
36
|
+
const last = i === nodes.length - 1;
|
|
37
|
+
const isRoot = prefix === '';
|
|
38
|
+
const connector = isRoot ? '' : (last ? '└── ' : '├── ');
|
|
39
|
+
// Root nodes use ' '/'│ ' as childPfx so children are always indented
|
|
40
|
+
const childPfx = isRoot
|
|
41
|
+
? (last ? ' ' : '│ ')
|
|
42
|
+
: prefix + (last ? ' ' : '│ ');
|
|
43
|
+
|
|
44
|
+
const badge = node.badge ? node.badge + ' ' : '';
|
|
45
|
+
const meta = node.meta ? ` ${clr.dim}${node.meta}${clr.reset}` : '';
|
|
46
|
+
|
|
47
|
+
console.log(
|
|
48
|
+
`${clr.dim}${prefix}${connector}${clr.reset}${badge}${clr.bold}${node.label}${clr.reset}${meta}`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (node.children?.length) {
|
|
52
|
+
tree(node.children, childPfx);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|