@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/args.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight arg/flag parser for Node CLI binaries.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* --flag → { flag: true }
|
|
6
|
+
* --flag value → { flag: 'value' }
|
|
7
|
+
* --flag=value → { flag: 'value' }
|
|
8
|
+
* positional args → args[]
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const { args, flags } = parseFlags(process.argv.slice(2));
|
|
12
|
+
* if (flags.json) { ... }
|
|
13
|
+
* const [ws, proj] = args;
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ParsedArgs {
|
|
17
|
+
/** Positional (non-flag) arguments in order. */
|
|
18
|
+
args: string[];
|
|
19
|
+
/** Parsed flags. Values are string if the flag had a value, true if bare. */
|
|
20
|
+
flags: Record<string, string | boolean>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFlags(argv: string[]): ParsedArgs {
|
|
24
|
+
const args: string[] = [];
|
|
25
|
+
const flags: Record<string, string | boolean> = {};
|
|
26
|
+
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < argv.length) {
|
|
29
|
+
const token = argv[i]!;
|
|
30
|
+
|
|
31
|
+
if (token.startsWith('--')) {
|
|
32
|
+
const eqIdx = token.indexOf('=');
|
|
33
|
+
|
|
34
|
+
if (eqIdx !== -1) {
|
|
35
|
+
// --flag=value
|
|
36
|
+
const key = token.slice(2, eqIdx);
|
|
37
|
+
const val = token.slice(eqIdx + 1);
|
|
38
|
+
flags[key] = val;
|
|
39
|
+
} else {
|
|
40
|
+
const key = token.slice(2);
|
|
41
|
+
const next = argv[i + 1];
|
|
42
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
43
|
+
// --flag value
|
|
44
|
+
flags[key] = next;
|
|
45
|
+
i++;
|
|
46
|
+
} else {
|
|
47
|
+
// bare --flag
|
|
48
|
+
flags[key] = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else if (token.startsWith('-') && token.length === 2) {
|
|
52
|
+
// Short flag: -j → json shorthand
|
|
53
|
+
flags[token.slice(1)] = true;
|
|
54
|
+
} else {
|
|
55
|
+
args.push(token);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { args, flags };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Returns true if --json or -j flag is present in process.argv. */
|
|
65
|
+
export function isJsonMode(argv = process.argv): boolean {
|
|
66
|
+
return argv.includes('--json') || argv.includes('-j');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract a named flag value from argv.
|
|
71
|
+
* getFlag(process.argv, '--public-key') → string | undefined
|
|
72
|
+
*/
|
|
73
|
+
export function getFlag(argv: string[], flag: string): string | undefined {
|
|
74
|
+
const i = argv.indexOf(flag);
|
|
75
|
+
if (i === -1) return undefined;
|
|
76
|
+
return argv[i + 1];
|
|
77
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic HTTP client factory for Node CLI binaries.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { get, post, patch, del } = createApiClient(
|
|
6
|
+
* () => cfg.token ?? process.env.MY_TOKEN,
|
|
7
|
+
* () => cfg.apiBase ?? 'https://api.example.com',
|
|
8
|
+
* );
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ApiClient {
|
|
12
|
+
api<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
|
13
|
+
get<T>(path: string, headers?: Record<string, string>): Promise<T>;
|
|
14
|
+
post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>;
|
|
15
|
+
patch<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>;
|
|
16
|
+
del<T>(path: string, headers?: Record<string, string>): Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ApiError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
public readonly status: number,
|
|
22
|
+
public readonly code: string,
|
|
23
|
+
message: string,
|
|
24
|
+
) { super(message); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createApiClient(
|
|
28
|
+
getToken: () => string | undefined,
|
|
29
|
+
getApiBase: () => string,
|
|
30
|
+
getExtraHeaders?: () => Record<string, string>,
|
|
31
|
+
): ApiClient {
|
|
32
|
+
async function api<T>(
|
|
33
|
+
method: string,
|
|
34
|
+
path: string,
|
|
35
|
+
body?: unknown,
|
|
36
|
+
extra: Record<string, string> = {},
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const token = getToken();
|
|
39
|
+
const base = getApiBase().replace(/\/$/, '');
|
|
40
|
+
|
|
41
|
+
const headers: Record<string, string> = {
|
|
42
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
43
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
44
|
+
...(getExtraHeaders ? getExtraHeaders() : {}),
|
|
45
|
+
...extra, // per-call headers override defaults
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const res = await fetch(`${base}${path}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers,
|
|
51
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (res.status === 204) return null as T;
|
|
55
|
+
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
let data: unknown;
|
|
58
|
+
try { data = JSON.parse(text); } catch {
|
|
59
|
+
if (!res.ok) throw new ApiError(res.status, 'parse_error', `${res.status}: ${text}`);
|
|
60
|
+
return text as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const err = data as { error?: { code?: string; message?: string } };
|
|
65
|
+
const code = err?.error?.code ?? 'unknown';
|
|
66
|
+
const msg = err?.error?.message ?? res.statusText;
|
|
67
|
+
throw new ApiError(res.status, code, `${res.status} ${code}: ${msg}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return data as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
api,
|
|
75
|
+
get: <T>(path: string, h?: Record<string, string>) => api<T>('GET', path, undefined, h),
|
|
76
|
+
post: <T>(path: string, body: unknown, h?: Record<string, string>) => api<T>('POST', path, body, h),
|
|
77
|
+
patch: <T>(path: string, body: unknown, h?: Record<string, string>) => api<T>('PATCH', path, body, h),
|
|
78
|
+
del: <T>(path: string, h?: Record<string, string>) => api<T>('DELETE', path, undefined, h),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed helpers for reading standard fields from a BaseConfig.
|
|
3
|
+
*
|
|
4
|
+
* Env var names are passed as parameters — this module has no knowledge of
|
|
5
|
+
* project-specific env vars (DTAB_TOKEN, ORBSEAL_TOKEN, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const cfg = ctx.loadConfig();
|
|
9
|
+
* const token = resolveToken(cfg, { env: 'DTAB_TOKEN' });
|
|
10
|
+
* const apiBase = resolveApiBase(cfg, { env: 'DTAB_API_URL', default: 'https://api.driptab.app' });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BaseConfig } from './context.js';
|
|
14
|
+
|
|
15
|
+
// ─── Options ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ResolveOptions {
|
|
18
|
+
/** Name of the environment variable to fall back to (e.g. 'DTAB_TOKEN'). */
|
|
19
|
+
env?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ResolveBaseOptions extends ResolveOptions {
|
|
23
|
+
/** Default value if neither config nor env has a value. */
|
|
24
|
+
default?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read `token` from config, falling back to the named env var.
|
|
31
|
+
* Returns undefined if neither is set.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveToken(
|
|
34
|
+
cfg: BaseConfig,
|
|
35
|
+
opts: ResolveOptions = {},
|
|
36
|
+
): string | undefined {
|
|
37
|
+
return (cfg.token as string | undefined)
|
|
38
|
+
?? (opts.env ? process.env[opts.env] : undefined);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read `apiBase` from config, falling back to the named env var,
|
|
43
|
+
* then to opts.default. Strips trailing slash.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveApiBase(
|
|
46
|
+
cfg: BaseConfig,
|
|
47
|
+
opts: ResolveBaseOptions = {},
|
|
48
|
+
): string {
|
|
49
|
+
const raw = (cfg.apiBase as string | undefined)
|
|
50
|
+
?? (opts.env ? process.env[opts.env] : undefined)
|
|
51
|
+
?? opts.default
|
|
52
|
+
?? '';
|
|
53
|
+
return raw.replace(/\/$/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read an arbitrary string field from config by key,
|
|
58
|
+
* falling back to the named env var.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveField(
|
|
61
|
+
cfg: BaseConfig,
|
|
62
|
+
key: string,
|
|
63
|
+
opts: ResolveOptions & { default?: string } = {},
|
|
64
|
+
): string | undefined {
|
|
65
|
+
return (cfg[key] as string | undefined)
|
|
66
|
+
?? (opts.env ? process.env[opts.env] : undefined)
|
|
67
|
+
?? opts.default;
|
|
68
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic CLI context manager.
|
|
3
|
+
*
|
|
4
|
+
* Read resolution order (first found wins):
|
|
5
|
+
* 1. {cwd}/.{appName}/config.json — project-local
|
|
6
|
+
* 2. {home}/.{appName}/config.json — global fallback
|
|
7
|
+
*
|
|
8
|
+
* Write always targets {cwd}/.{appName}/config.json — so `dtab login`
|
|
9
|
+
* creates a project-local config wherever you run the command.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const ctx = createContextManager<DriptabContext>('dtab');
|
|
13
|
+
* ctx.setContext({ org: 'acme', project: 'billing' });
|
|
14
|
+
* const { org } = ctx.getContext() ?? {};
|
|
15
|
+
* console.log(ctx.configPath); // shows which file will be written
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
19
|
+
import { homedir } from 'os';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface BaseConfig {
|
|
25
|
+
token?: string;
|
|
26
|
+
apiBase?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContextManager<TContext extends Record<string, string | undefined>> {
|
|
31
|
+
/** Read the full config file (local first, then global). */
|
|
32
|
+
loadConfig(): BaseConfig & { context?: TContext };
|
|
33
|
+
|
|
34
|
+
/** Write the full config file to the active path (local if present, else global). */
|
|
35
|
+
saveConfig(cfg: BaseConfig & { context?: TContext }): void;
|
|
36
|
+
|
|
37
|
+
/** Return the stored context, or undefined if none set. */
|
|
38
|
+
getContext(): TContext | undefined;
|
|
39
|
+
|
|
40
|
+
/** Merge partial context into the stored context and persist. */
|
|
41
|
+
setContext(parts: Partial<TContext>): void;
|
|
42
|
+
|
|
43
|
+
/** Remove the stored context (keeps token/apiBase). */
|
|
44
|
+
clearContext(): void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Active config file path — whichever was found (local or global).
|
|
48
|
+
* Recalculated on each access so it reflects the current working directory.
|
|
49
|
+
*/
|
|
50
|
+
readonly configPath: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export function createContextManager<TContext extends Record<string, string | undefined>>(
|
|
56
|
+
appName: string,
|
|
57
|
+
): ContextManager<TContext> {
|
|
58
|
+
const globalDir = join(homedir(), `.${appName}`);
|
|
59
|
+
const globalPath = join(globalDir, 'config.json');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the active config path.
|
|
63
|
+
*
|
|
64
|
+
* Local ({cwd}/.{appName}/config.json) wins when:
|
|
65
|
+
* - The config.json itself exists, OR
|
|
66
|
+
* - The .{appName}/ directory exists (project is initialised but not yet logged in).
|
|
67
|
+
*
|
|
68
|
+
* Otherwise falls back to the global (~/.{appName}/config.json).
|
|
69
|
+
*/
|
|
70
|
+
function resolveActivePath(): { cfgPath: string; cfgDir: string; isLocal: boolean } {
|
|
71
|
+
const localDir = join(process.cwd(), `.${appName}`);
|
|
72
|
+
const localPath = join(localDir, 'config.json');
|
|
73
|
+
const isLocal = existsSync(localPath) || existsSync(localDir);
|
|
74
|
+
return isLocal
|
|
75
|
+
? { cfgPath: localPath, cfgDir: localDir, isLocal: true }
|
|
76
|
+
: { cfgPath: globalPath, cfgDir: globalDir, isLocal: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read path — local dir or file present → local, else global. */
|
|
80
|
+
function resolveReadPath(): string {
|
|
81
|
+
return resolveActivePath().cfgPath;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Write path — same logic as read so reads and writes are always in sync. */
|
|
85
|
+
function resolveWritePath(): { cfgPath: string; cfgDir: string } {
|
|
86
|
+
const { cfgPath, cfgDir } = resolveActivePath();
|
|
87
|
+
return { cfgPath, cfgDir };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadConfig(): BaseConfig & { context?: TContext } {
|
|
91
|
+
const cfgPath = resolveReadPath();
|
|
92
|
+
if (!existsSync(cfgPath)) return {};
|
|
93
|
+
try { return JSON.parse(readFileSync(cfgPath, 'utf8')) as BaseConfig & { context?: TContext }; }
|
|
94
|
+
catch { return {}; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveConfig(cfg: BaseConfig & { context?: TContext }): void {
|
|
98
|
+
const { cfgPath, cfgDir } = resolveWritePath();
|
|
99
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
100
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
get configPath() { return resolveWritePath().cfgPath; },
|
|
105
|
+
loadConfig,
|
|
106
|
+
saveConfig,
|
|
107
|
+
|
|
108
|
+
getContext(): TContext | undefined {
|
|
109
|
+
return loadConfig().context;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
setContext(parts: Partial<TContext>): void {
|
|
113
|
+
const cfg = loadConfig();
|
|
114
|
+
cfg.context = { ...(cfg.context ?? {}), ...parts } as TContext;
|
|
115
|
+
saveConfig(cfg);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
clearContext(): void {
|
|
119
|
+
const cfg = loadConfig();
|
|
120
|
+
delete cfg.context;
|
|
121
|
+
saveConfig(cfg);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/display.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal display primitives. Node.js only — TTY-aware ANSI colours.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
|
|
7
|
+
// ─── Colors ───────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const isTTY = typeof process !== 'undefined' && process.stdout?.isTTY;
|
|
10
|
+
|
|
11
|
+
export const clr = {
|
|
12
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
13
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
14
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
15
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
16
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
17
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
18
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ─── Display helpers ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Truncate a UUID or long ID to first `n` chars + `…`.
|
|
25
|
+
* Short IDs (no dash at pos 8) are returned as-is.
|
|
26
|
+
*/
|
|
27
|
+
export function truncateId(id: string, n = 8): string {
|
|
28
|
+
if (!id || id.length <= n + 1) return id;
|
|
29
|
+
const isUuid = /^[0-9a-f]{8}-/i.test(id);
|
|
30
|
+
return isUuid ? id.slice(0, n) + '…' : id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Colorize a status string for terminal display. */
|
|
34
|
+
export function statusColor(status: string): string {
|
|
35
|
+
const s = status.toLowerCase();
|
|
36
|
+
if (['active', 'succeeded', 'paid', 'completed'].includes(s))
|
|
37
|
+
return `${clr.green}${status}${clr.reset}`;
|
|
38
|
+
if (['canceled', 'cancelled', 'failed', 'expired', 'revoked'].includes(s))
|
|
39
|
+
return `${clr.dim}${status}${clr.reset}`;
|
|
40
|
+
if (['trialing', 'past_due', 'pending', 'draft'].includes(s))
|
|
41
|
+
return `${clr.yellow}${status}${clr.reset}`;
|
|
42
|
+
return status;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Print a dim summary line below a table. */
|
|
46
|
+
export function summary(text: string): void {
|
|
47
|
+
console.log(` ${clr.dim}${text}${clr.reset}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Table ────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export type OutputFormat = 'table' | 'wide' | 'json' | 'yaml' | 'code';
|
|
53
|
+
|
|
54
|
+
export interface ColDef<T> {
|
|
55
|
+
key: keyof T;
|
|
56
|
+
label: string;
|
|
57
|
+
fmt?: (value: T[keyof T], row: T) => string;
|
|
58
|
+
/** If true, column is only shown in -o wide mode. */
|
|
59
|
+
wide?: boolean;
|
|
60
|
+
/** @deprecated use wide instead */
|
|
61
|
+
detail?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function visibleLength(str: string): number {
|
|
65
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function padEnd(str: string, width: number): string {
|
|
69
|
+
return str + ' '.repeat(Math.max(0, width - visibleLength(str)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TableOpts {
|
|
73
|
+
/**
|
|
74
|
+
* Message shown when rows is empty. Use a hint to guide the user.
|
|
75
|
+
* Default: "(none)"
|
|
76
|
+
*/
|
|
77
|
+
emptyHint?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function table<T extends Record<string, unknown>>(
|
|
81
|
+
rows: T[],
|
|
82
|
+
cols: ColDef<T>[],
|
|
83
|
+
opts: TableOpts & { wide?: boolean } = {},
|
|
84
|
+
): void {
|
|
85
|
+
const isWide = opts.wide
|
|
86
|
+
?? (process.argv.includes('--detail') || process.argv.includes('-d')
|
|
87
|
+
|| process.argv.includes('-o') && process.argv[process.argv.indexOf('-o') + 1] === 'wide'
|
|
88
|
+
|| process.argv.includes('--output') && process.argv[process.argv.indexOf('--output') + 1] === 'wide');
|
|
89
|
+
|
|
90
|
+
const visibleCols = cols.filter(c => !c.wide && !c.detail || isWide);
|
|
91
|
+
|
|
92
|
+
if (!rows.length) {
|
|
93
|
+
const msg = opts.emptyHint ?? '(none)';
|
|
94
|
+
console.log(` ${clr.dim}${msg}${clr.reset}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const widths = visibleCols.map((col, i) => {
|
|
99
|
+
const labelLen = col.label.length;
|
|
100
|
+
const maxVal = Math.max(...rows.map(r => visibleLength(String(col.fmt ? col.fmt(r[col.key], r) : (r[col.key] ?? '—')))));
|
|
101
|
+
return Math.max(labelLen, maxVal, i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log('');
|
|
105
|
+
const header = visibleCols.map((col, i) =>
|
|
106
|
+
padEnd(`${clr.bold}${clr.dim}${col.label}${clr.reset}`, widths[i] ?? col.label.length),
|
|
107
|
+
).join(' ');
|
|
108
|
+
console.log(' ' + header);
|
|
109
|
+
console.log(' ' + widths.map(w => '─'.repeat(w)).join(' '));
|
|
110
|
+
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const line = visibleCols.map((col, i) => {
|
|
113
|
+
const val = col.fmt ? col.fmt(row[col.key], row) : (row[col.key] ?? '—');
|
|
114
|
+
return padEnd(String(val), widths[i] ?? 0);
|
|
115
|
+
}).join(' ');
|
|
116
|
+
console.log(' ' + line);
|
|
117
|
+
}
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Key-value ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export function kv(pairs: [string, string][]): void {
|
|
124
|
+
const w = Math.max(...pairs.map(([k]) => k.length));
|
|
125
|
+
for (const [k, v] of pairs) {
|
|
126
|
+
console.log(` ${clr.dim}${k.padEnd(w)}${clr.reset} ${v}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Status messages ──────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export function success(msg: string): void {
|
|
133
|
+
console.log(`${clr.green}✓${clr.reset} ${msg}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function warn(msg: string): void {
|
|
137
|
+
console.warn(`${clr.yellow}⚠${clr.reset} ${msg}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function fatal(msg: string): never {
|
|
141
|
+
console.error(`${clr.red}error:${clr.reset} ${msg}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
throw new Error(msg); // unreachable — satisfies TS never
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Prompt ───────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function prompt(question: string): Promise<string> {
|
|
149
|
+
return new Promise(resolve => {
|
|
150
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
151
|
+
rl.question(question, (ans: string) => { rl.close(); resolve(ans.trim()); });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Subcommand help ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Lookup and print subcommand help, return true if help was printed.
|
|
159
|
+
* Use at the top of each command fn:
|
|
160
|
+
*
|
|
161
|
+
* if (helpFor('account tokens', deps.args, deps.flags, HELP)) return;
|
|
162
|
+
*
|
|
163
|
+
* helpMap keys are subcommand paths WITHOUT the top-level command name,
|
|
164
|
+
* e.g. { 'tokens create': '...', 'tokens list': '...' }
|
|
165
|
+
*/
|
|
166
|
+
export function helpFor(
|
|
167
|
+
cmdName: string,
|
|
168
|
+
args: string[],
|
|
169
|
+
flags: Record<string, string | boolean>,
|
|
170
|
+
helpMap: Record<string, string>,
|
|
171
|
+
): boolean {
|
|
172
|
+
if (!flags['help'] && !flags['h']) return false;
|
|
173
|
+
// Build lookup key from positional args (sub sub-sub …)
|
|
174
|
+
const key = args.join(' ').trim();
|
|
175
|
+
const text = (key && helpMap[key]) ?? helpMap[''] ?? null;
|
|
176
|
+
if (text) {
|
|
177
|
+
console.log(text);
|
|
178
|
+
} else {
|
|
179
|
+
// Print all known subcommands for this command
|
|
180
|
+
const lines = Object.entries(helpMap)
|
|
181
|
+
.filter(([k]) => k !== '')
|
|
182
|
+
.map(([k, v]) => ` ${cmdName} ${k.padEnd(28)}${v.split('\n')[0]}`);
|
|
183
|
+
console.log(`\n${clr.bold}${cmdName}${clr.reset}\n\n${lines.join('\n')}\n`);
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── JSON / text dual-mode output ────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @deprecated Use printOutput instead.
|
|
192
|
+
* Kept for backward compatibility with legacy CliPlugin commands.
|
|
193
|
+
*/
|
|
194
|
+
export function printOrJson<T>(data: T, textFn: () => void, argv = process.argv): void {
|
|
195
|
+
if (argv.includes('--json') || argv.includes('-j')) {
|
|
196
|
+
console.log(JSON.stringify(data, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
textFn();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Universal output formatter for Commander-based commands.
|
|
204
|
+
*
|
|
205
|
+
* @param data Raw data to output (array or object)
|
|
206
|
+
* @param format Output format from -o/--output option
|
|
207
|
+
* @param renderFn Called for table/wide; receives wide=true for -o wide
|
|
208
|
+
* @param codeKey Key (or fn) used for -o code output. Falls back to short_id → id.
|
|
209
|
+
*
|
|
210
|
+
* Usage:
|
|
211
|
+
* printOutput(plans, opts.output, (wide) => table(plans, cols, { wide }), 'code');
|
|
212
|
+
*/
|
|
213
|
+
export function printOutput<T extends Record<string, unknown>>(
|
|
214
|
+
data: T | T[],
|
|
215
|
+
format: string | undefined,
|
|
216
|
+
renderFn: (wide: boolean) => void,
|
|
217
|
+
codeKey?: string | ((item: T) => string),
|
|
218
|
+
): void {
|
|
219
|
+
const fmt = (format ?? 'table') as OutputFormat;
|
|
220
|
+
|
|
221
|
+
switch (fmt) {
|
|
222
|
+
case 'json':
|
|
223
|
+
console.log(JSON.stringify(data, null, 2));
|
|
224
|
+
break;
|
|
225
|
+
|
|
226
|
+
case 'yaml': {
|
|
227
|
+
const toYaml = (v: unknown, indent = 0): string => {
|
|
228
|
+
const pad = ' '.repeat(indent);
|
|
229
|
+
if (v === null || v === undefined) return 'null';
|
|
230
|
+
if (typeof v === 'string') return /[:\n#{}[\],&*?|<>=!%@`]/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
|
|
231
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
232
|
+
if (Array.isArray(v)) {
|
|
233
|
+
if (v.length === 0) return '[]';
|
|
234
|
+
return v.map(item => `${pad}- ${toYaml(item, indent + 2).trimStart()}`).join('\n');
|
|
235
|
+
}
|
|
236
|
+
if (typeof v === 'object') {
|
|
237
|
+
return Object.entries(v as Record<string, unknown>)
|
|
238
|
+
.map(([k, val]) => {
|
|
239
|
+
const rendered = toYaml(val, indent + 2);
|
|
240
|
+
return typeof val === 'object' && val !== null && !Array.isArray(val)
|
|
241
|
+
? `${pad}${k}:\n${rendered}`
|
|
242
|
+
: `${pad}${k}: ${rendered}`;
|
|
243
|
+
}).join('\n');
|
|
244
|
+
}
|
|
245
|
+
return String(v);
|
|
246
|
+
};
|
|
247
|
+
console.log(toYaml(data));
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case 'code': {
|
|
252
|
+
const items = Array.isArray(data) ? data : [data];
|
|
253
|
+
for (const item of items) {
|
|
254
|
+
let code: string | undefined;
|
|
255
|
+
if (typeof codeKey === 'function') {
|
|
256
|
+
code = codeKey(item);
|
|
257
|
+
} else if (codeKey && item[codeKey] != null) {
|
|
258
|
+
code = String(item[codeKey]);
|
|
259
|
+
} else {
|
|
260
|
+
// fallback: slug → code → short_id → id
|
|
261
|
+
code = String(item['slug'] ?? item['code'] ?? item['short_id'] ?? item['id'] ?? '');
|
|
262
|
+
}
|
|
263
|
+
if (code) console.log(code);
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'wide':
|
|
269
|
+
renderFn(true);
|
|
270
|
+
break;
|
|
271
|
+
|
|
272
|
+
case 'table':
|
|
273
|
+
default:
|
|
274
|
+
renderFn(false);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Returns the Commander output option definition string.
|
|
281
|
+
* Use with .addOption() or .option():
|
|
282
|
+
*
|
|
283
|
+
* cmd.option(...outputOption())
|
|
284
|
+
*/
|
|
285
|
+
export function outputOption(): [string, string, string] {
|
|
286
|
+
return ['-o, --output <format>', 'Output format: table|wide|json|yaml|code', 'table'];
|
|
287
|
+
}
|
package/src/fmt.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display formatters for common field types.
|
|
3
|
+
* All return ANSI-colored strings safe to pass to table/kv.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { clr } from './display.js';
|
|
7
|
+
|
|
8
|
+
// ─── Date ─────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a date value for terminal display.
|
|
12
|
+
* Accepts:
|
|
13
|
+
* - Unix ms timestamp (number)
|
|
14
|
+
* - ISO 8601 string
|
|
15
|
+
* - null / undefined → "—"
|
|
16
|
+
*/
|
|
17
|
+
export function fmtDate(val: string | number | null | undefined): string {
|
|
18
|
+
if (val === null || val === undefined) return `${clr.dim}—${clr.reset}`;
|
|
19
|
+
const d = typeof val === 'number' ? new Date(val) : new Date(val);
|
|
20
|
+
if (isNaN(d.getTime())) return `${clr.dim}—${clr.reset}`;
|
|
21
|
+
return `${clr.dim}${d.toISOString().slice(0, 10)}${clr.reset}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Role ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function fmtRole(role: string | null | undefined): string {
|
|
27
|
+
if (role === 'owner') return `${clr.cyan}owner${clr.reset}`;
|
|
28
|
+
if (role === 'admin') return `${clr.yellow}admin${clr.reset}`;
|
|
29
|
+
if (role === 'member') return 'member';
|
|
30
|
+
return `${clr.dim}${role ?? '—'}${clr.reset}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function fmtStatus(revokedAt: string | null | undefined): string {
|
|
36
|
+
return revokedAt
|
|
37
|
+
? `${clr.dim}revoked${clr.reset}`
|
|
38
|
+
: `${clr.green}active${clr.reset}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Boolean ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function fmtBool(val: boolean | number | null | undefined): string {
|
|
44
|
+
return val ? `${clr.green}yes${clr.reset}` : `${clr.dim}no${clr.reset}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Count (zero is dimmed) ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function fmtCount(n: number | null | undefined): string {
|
|
50
|
+
if (!n) return `${clr.dim}0${clr.reset}`;
|
|
51
|
+
return String(n);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── ID (dimmed, truncated) ───────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function fmtId(id: string | null | undefined): string {
|
|
57
|
+
if (!id) return `${clr.dim}—${clr.reset}`;
|
|
58
|
+
return `${clr.dim}${id}${clr.reset}`;
|
|
59
|
+
}
|