@silvery/commander 0.2.0 → 0.4.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/README.md +105 -72
- package/package.json +7 -10
- package/src/colorize.ts +164 -0
- package/src/command.ts +117 -0
- package/src/index.ts +11 -168
- package/src/presets.ts +196 -0
- package/src/z.ts +45 -0
- package/src/typed.ts +0 -416
package/src/index.ts
CHANGED
|
@@ -1,171 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
export { Command
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
// Re-export typed CLI
|
|
6
|
-
export { TypedCommand, createCLI } from "./typed.ts"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Commander.js help colorization using ANSI escape codes.
|
|
10
|
-
*
|
|
11
|
-
* Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
|
|
12
|
-
* rather than regex post-processing. Works with @silvery/commander
|
|
13
|
-
* or plain commander — accepts a minimal CommandLike interface so Commander
|
|
14
|
-
* is a peer dependency, not a hard one.
|
|
15
|
-
*
|
|
16
|
-
* Zero dependencies — only raw ANSI escape codes.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* import { Command } from "@silvery/commander"
|
|
21
|
-
* import { colorizeHelp } from "@silvery/commander"
|
|
22
|
-
*
|
|
23
|
-
* const program = new Command("myapp").description("My CLI tool")
|
|
24
|
-
* colorizeHelp(program)
|
|
25
|
-
* ```
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
// Raw ANSI escape codes — no framework dependencies.
|
|
29
|
-
const RESET = "\x1b[0m"
|
|
30
|
-
const BOLD = "\x1b[1m"
|
|
31
|
-
const DIM = "\x1b[2m"
|
|
32
|
-
const CYAN = "\x1b[36m"
|
|
33
|
-
const GREEN = "\x1b[32m"
|
|
34
|
-
const YELLOW = "\x1b[33m"
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Check if color output should be enabled.
|
|
38
|
-
* Uses @silvery/ansi detectColor() if available, falls back to basic
|
|
39
|
-
* NO_COLOR/FORCE_COLOR/isTTY checks.
|
|
40
|
-
*/
|
|
41
|
-
let _shouldColorize: boolean | undefined
|
|
42
|
-
|
|
43
|
-
export function shouldColorize(): boolean {
|
|
44
|
-
if (_shouldColorize !== undefined) return _shouldColorize
|
|
45
|
-
|
|
46
|
-
// Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
|
|
47
|
-
try {
|
|
48
|
-
const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
|
|
49
|
-
_shouldColorize = detectColor(process.stdout) !== null
|
|
50
|
-
} catch {
|
|
51
|
-
// Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
|
|
52
|
-
if (process.env.NO_COLOR !== undefined) {
|
|
53
|
-
_shouldColorize = false
|
|
54
|
-
} else if (process.env.FORCE_COLOR !== undefined) {
|
|
55
|
-
_shouldColorize = true
|
|
56
|
-
} else {
|
|
57
|
-
_shouldColorize = process.stdout?.isTTY ?? true
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return _shouldColorize
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Wrap a string with ANSI codes, handling nested resets. */
|
|
65
|
-
function ansi(text: string, code: string): string {
|
|
66
|
-
return `${code}${text}${RESET}`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Minimal interface for Commander's Command — avoids requiring Commander
|
|
71
|
-
* as a direct dependency. Works with both `commander` and
|
|
72
|
-
* `@silvery/commander`.
|
|
73
|
-
*
|
|
74
|
-
* Uses permissive types to ensure structural compatibility with all
|
|
75
|
-
* Commander versions, overloads, and generic instantiations.
|
|
76
|
-
*/
|
|
77
|
-
export interface CommandLike {
|
|
78
|
-
// biome-ignore lint: permissive to match Commander's overloaded signatures
|
|
79
|
-
configureHelp(...args: any[]): any
|
|
80
|
-
// biome-ignore lint: permissive to match Commander's overloaded signatures
|
|
81
|
-
configureOutput(...args: any[]): any
|
|
82
|
-
// biome-ignore lint: permissive to match Commander's Command[] structurally
|
|
83
|
-
readonly commands: readonly any[]
|
|
84
|
-
}
|
|
1
|
+
// Enhanced Commander
|
|
2
|
+
export { Command } from "./command.ts"
|
|
3
|
+
export { colorizeHelp, shouldColorize, type ColorizeHelpOptions, type CommandLike } from "./colorize.ts"
|
|
85
4
|
|
|
86
|
-
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
commands?: string
|
|
90
|
-
/** ANSI code for --flags and -short options. Default: green */
|
|
91
|
-
flags?: string
|
|
92
|
-
/** ANSI code for description text. Default: dim */
|
|
93
|
-
description?: string
|
|
94
|
-
/** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
|
|
95
|
-
heading?: string
|
|
96
|
-
/** ANSI code for <required> and [optional] argument brackets. Default: yellow */
|
|
97
|
-
brackets?: string
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Apply colorized help output to a Commander.js program and all its subcommands.
|
|
102
|
-
*
|
|
103
|
-
* Uses Commander's built-in `configureHelp()` style hooks rather than
|
|
104
|
-
* post-processing the formatted string. This approach is robust against
|
|
105
|
-
* formatting changes in Commander and handles wrapping correctly.
|
|
106
|
-
*
|
|
107
|
-
* @param program - A Commander Command instance (or compatible object)
|
|
108
|
-
* @param options - Override default ANSI color codes for each element
|
|
109
|
-
*/
|
|
110
|
-
export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
|
|
111
|
-
const cmds = options?.commands ?? CYAN
|
|
112
|
-
const flags = options?.flags ?? GREEN
|
|
113
|
-
const desc = options?.description ?? DIM
|
|
114
|
-
const heading = options?.heading ?? BOLD
|
|
115
|
-
const brackets = options?.brackets ?? YELLOW
|
|
116
|
-
|
|
117
|
-
const helpConfig: Record<string, unknown> = {
|
|
118
|
-
// Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
|
|
119
|
-
styleTitle(str: string): string {
|
|
120
|
-
return ansi(str, heading)
|
|
121
|
-
},
|
|
122
|
-
|
|
123
|
-
// Command name in usage line and subcommand terms
|
|
124
|
-
styleCommandText(str: string): string {
|
|
125
|
-
return ansi(str, cmds)
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
// Option terms: "-v, --verbose", "--repo <path>", "[options]"
|
|
129
|
-
styleOptionText(str: string): string {
|
|
130
|
-
return ansi(str, flags)
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
// Subcommand names in the commands list
|
|
134
|
-
styleSubcommandText(str: string): string {
|
|
135
|
-
return ansi(str, cmds)
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
// Argument terms: "<file>", "[dir]"
|
|
139
|
-
styleArgumentText(str: string): string {
|
|
140
|
-
return ansi(str, brackets)
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
// Description text for options, subcommands, arguments
|
|
144
|
-
styleDescriptionText(str: string): string {
|
|
145
|
-
return ansi(str, desc)
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
// Command description (the main program description line) — keep normal
|
|
149
|
-
styleCommandDescription(str: string): string {
|
|
150
|
-
return str
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
program.configureHelp(helpConfig)
|
|
5
|
+
// Re-export Commander's other classes
|
|
6
|
+
export { Option, Argument, CommanderError, InvalidArgumentError, Help } from "commander"
|
|
7
|
+
export type { OptionValues } from "commander"
|
|
155
8
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
// Callers who want to respect NO_COLOR/FORCE_COLOR should check
|
|
161
|
-
// shouldColorize() before calling colorizeHelp().
|
|
162
|
-
program.configureOutput({
|
|
163
|
-
getOutHasColors: () => true,
|
|
164
|
-
getErrHasColors: () => true,
|
|
165
|
-
})
|
|
9
|
+
// Presets and Standard Schema type
|
|
10
|
+
export { int, uint, float, port, url, path, csv, json, bool, date, email, regex, intRange, oneOf } from "./presets.ts"
|
|
11
|
+
export type { Preset, StandardSchemaV1 } from "./presets.ts"
|
|
166
12
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
colorizeHelp(sub, options)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
13
|
+
// Tree-shakeable: only evaluated if user imports z
|
|
14
|
+
export { z } from "./z.ts"
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-built Standard Schema v1 presets for common CLI argument patterns.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies — validation is manual, no Zod/Valibot/ArkType required.
|
|
5
|
+
* Each preset implements Standard Schema v1 for interop with any schema library,
|
|
6
|
+
* plus standalone `.parse()` and `.safeParse()` convenience methods.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { createCLI, port, csv, int, url, oneOf } from "@silvery/commander"
|
|
11
|
+
*
|
|
12
|
+
* const cli = createCLI("deploy")
|
|
13
|
+
* .option("-p, --port <n>", "Port", port) // number (1-65535)
|
|
14
|
+
* .option("-r, --retries <n>", "Retries", int) // number (integer)
|
|
15
|
+
* .option("--tags <t>", "Tags", csv) // string[]
|
|
16
|
+
* .option("--callback <url>", "Callback", url) // string (validated URL)
|
|
17
|
+
* .option("-e, --env <e>", "Env", oneOf(["dev", "staging", "prod"]))
|
|
18
|
+
*
|
|
19
|
+
* // Standalone usage (outside Commander)
|
|
20
|
+
* port.parse("3000") // 3000
|
|
21
|
+
* port.safeParse("abc") // { success: false, issues: [{ message: "..." }] }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Standard Schema v1 interface — the universal schema interop protocol.
|
|
27
|
+
* Supports any schema library that implements Standard Schema (Zod >=3.24,
|
|
28
|
+
* Valibot >=1.0, ArkType >=2.0, etc.).
|
|
29
|
+
*
|
|
30
|
+
* Inlined to avoid any dependency on @standard-schema/spec.
|
|
31
|
+
* See: https://github.com/standard-schema/standard-schema
|
|
32
|
+
*/
|
|
33
|
+
export interface StandardSchemaV1<T = unknown> {
|
|
34
|
+
readonly "~standard": {
|
|
35
|
+
readonly version: 1
|
|
36
|
+
readonly vendor: string
|
|
37
|
+
readonly validate: (
|
|
38
|
+
value: unknown,
|
|
39
|
+
) => { value: T } | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<unknown> }> }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A Standard Schema v1 preset with standalone parse/safeParse methods. */
|
|
44
|
+
export interface Preset<T> extends StandardSchemaV1<T> {
|
|
45
|
+
/** Parse and validate a value, throwing on failure. */
|
|
46
|
+
parse(value: unknown): T
|
|
47
|
+
/** Parse and validate a value, returning a result object. */
|
|
48
|
+
safeParse(value: unknown): { success: true; value: T } | { success: false; issues: Array<{ message: string }> }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createPreset<T>(vendor: string, validate: (value: unknown) => T): Preset<T> {
|
|
52
|
+
const schema: Preset<T> = {
|
|
53
|
+
"~standard": {
|
|
54
|
+
version: 1,
|
|
55
|
+
vendor,
|
|
56
|
+
validate: (value) => {
|
|
57
|
+
try {
|
|
58
|
+
return { value: validate(value) }
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
return { issues: [{ message: e.message }] }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
parse(value: unknown): T {
|
|
65
|
+
const result = schema["~standard"].validate(value)
|
|
66
|
+
if ("issues" in result) throw new Error(result.issues[0]?.message ?? "Validation failed")
|
|
67
|
+
return result.value
|
|
68
|
+
},
|
|
69
|
+
safeParse(value: unknown) {
|
|
70
|
+
const result = schema["~standard"].validate(value)
|
|
71
|
+
if ("issues" in result) return { success: false as const, issues: [...result.issues] }
|
|
72
|
+
return { success: true as const, value: result.value }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
return schema
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const VENDOR = "@silvery/commander"
|
|
79
|
+
|
|
80
|
+
/** Integer (coerced from string). */
|
|
81
|
+
export const int = createPreset<number>(VENDOR, (v) => {
|
|
82
|
+
const s = String(v).trim()
|
|
83
|
+
if (s === "") throw new Error(`Expected integer, got "${v}"`)
|
|
84
|
+
const n = Number(s)
|
|
85
|
+
if (!Number.isInteger(n)) throw new Error(`Expected integer, got "${v}"`)
|
|
86
|
+
return n
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/** Unsigned integer (>= 0, coerced from string). */
|
|
90
|
+
export const uint = createPreset<number>(VENDOR, (v) => {
|
|
91
|
+
const s = String(v).trim()
|
|
92
|
+
if (s === "") throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
|
|
93
|
+
const n = Number(s)
|
|
94
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
|
|
95
|
+
return n
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/** Float (coerced from string). */
|
|
99
|
+
export const float = createPreset<number>(VENDOR, (v) => {
|
|
100
|
+
const s = String(v).trim()
|
|
101
|
+
if (s === "" || s === "NaN") throw new Error(`Expected number, got "${v}"`)
|
|
102
|
+
const n = Number(s)
|
|
103
|
+
if (Number.isNaN(n)) throw new Error(`Expected number, got "${v}"`)
|
|
104
|
+
return n
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/** Port number (1-65535). */
|
|
108
|
+
export const port = createPreset<number>(VENDOR, (v) => {
|
|
109
|
+
const n = Number(v)
|
|
110
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`Expected port (1-65535), got "${v}"`)
|
|
111
|
+
return n
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/** URL (validated via URL constructor). */
|
|
115
|
+
export const url = createPreset<string>(VENDOR, (v) => {
|
|
116
|
+
const s = String(v)
|
|
117
|
+
try {
|
|
118
|
+
new URL(s)
|
|
119
|
+
return s
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`Expected valid URL, got "${v}"`)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** File path (non-empty string). */
|
|
126
|
+
export const path = createPreset<string>(VENDOR, (v) => {
|
|
127
|
+
const s = String(v)
|
|
128
|
+
if (!s) throw new Error("Expected non-empty path")
|
|
129
|
+
return s
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** Comma-separated values to string[]. */
|
|
133
|
+
export const csv = createPreset<string[]>(VENDOR, (v) => {
|
|
134
|
+
return String(v)
|
|
135
|
+
.split(",")
|
|
136
|
+
.map((s) => s.trim())
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** JSON string to parsed value. */
|
|
141
|
+
export const json = createPreset<unknown>(VENDOR, (v) => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(String(v))
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error(`Expected valid JSON, got "${v}"`)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/** Boolean string ("true"/"false"/"1"/"0"/"yes"/"no"). */
|
|
150
|
+
export const bool = createPreset<boolean>(VENDOR, (v) => {
|
|
151
|
+
const s = String(v).toLowerCase()
|
|
152
|
+
if (["true", "1", "yes", "y"].includes(s)) return true
|
|
153
|
+
if (["false", "0", "no", "n"].includes(s)) return false
|
|
154
|
+
throw new Error(`Expected boolean (true/false/yes/no/1/0), got "${v}"`)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/** Date string to Date object. */
|
|
158
|
+
export const date = createPreset<Date>(VENDOR, (v) => {
|
|
159
|
+
const d = new Date(String(v))
|
|
160
|
+
if (isNaN(d.getTime())) throw new Error(`Expected valid date, got "${v}"`)
|
|
161
|
+
return d
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/** Email address (basic validation). */
|
|
165
|
+
export const email = createPreset<string>(VENDOR, (v) => {
|
|
166
|
+
const s = String(v)
|
|
167
|
+
if (!s.includes("@") || !s.includes(".")) throw new Error(`Expected email address, got "${v}"`)
|
|
168
|
+
return s
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/** Regex pattern string to RegExp. */
|
|
172
|
+
export const regex = createPreset<RegExp>(VENDOR, (v) => {
|
|
173
|
+
try {
|
|
174
|
+
return new RegExp(String(v))
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`Expected valid regex, got "${v}"`)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/** Integer with min/max bounds (factory). */
|
|
181
|
+
export function intRange(min: number, max: number): Preset<number> {
|
|
182
|
+
return createPreset<number>(VENDOR, (v) => {
|
|
183
|
+
const n = Number(v)
|
|
184
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`Expected integer ${min}-${max}, got "${v}"`)
|
|
185
|
+
return n
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Enum from a fixed set of string values (factory). */
|
|
190
|
+
export function oneOf<const T extends readonly string[]>(values: T): Preset<T[number]> {
|
|
191
|
+
return createPreset<T[number]>(VENDOR, (v) => {
|
|
192
|
+
const s = String(v)
|
|
193
|
+
if (!values.includes(s as any)) throw new Error(`Expected one of [${values.join(", ")}], got "${v}"`)
|
|
194
|
+
return s as T[number]
|
|
195
|
+
})
|
|
196
|
+
}
|
package/src/z.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended Zod object with CLI presets.
|
|
3
|
+
*
|
|
4
|
+
* Spreads all of Zod's exports and adds CLI-specific schemas.
|
|
5
|
+
* Tree-shakeable — only evaluated if user imports `z`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { z, Command } from "@silvery/commander"
|
|
10
|
+
*
|
|
11
|
+
* const program = new Command("deploy")
|
|
12
|
+
* .option("-p, --port <n>", "Port", z.port)
|
|
13
|
+
* .option("-e, --env <e>", "Environment", z.oneOf(["dev", "staging", "prod"]))
|
|
14
|
+
* .option("--tags <t>", "Tags", z.csv)
|
|
15
|
+
*
|
|
16
|
+
* program.parse()
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as zod from "zod"
|
|
21
|
+
|
|
22
|
+
export const z = {
|
|
23
|
+
...zod,
|
|
24
|
+
// CLI presets (built on Zod schemas)
|
|
25
|
+
port: zod.coerce.number().int().min(1).max(65535),
|
|
26
|
+
int: zod.coerce.number().int(),
|
|
27
|
+
uint: zod.coerce.number().int().min(0),
|
|
28
|
+
float: zod.coerce.number(),
|
|
29
|
+
csv: zod.string().transform((v: string) =>
|
|
30
|
+
v
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s: string) => s.trim())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
),
|
|
35
|
+
url: zod.string().url(),
|
|
36
|
+
path: zod.string().min(1),
|
|
37
|
+
email: zod.string().email(),
|
|
38
|
+
date: zod.coerce.date(),
|
|
39
|
+
json: zod.string().transform((v: string) => JSON.parse(v)),
|
|
40
|
+
bool: zod
|
|
41
|
+
.enum(["true", "false", "1", "0", "yes", "no", "y", "n"] as const)
|
|
42
|
+
.transform((v: string) => ["true", "1", "yes", "y"].includes(v)),
|
|
43
|
+
intRange: (min: number, max: number) => zod.coerce.number().int().min(min).max(max),
|
|
44
|
+
oneOf: <const T extends readonly [string, ...string[]]>(values: T) => zod.enum(values),
|
|
45
|
+
}
|