termcraft 0.1.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/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/banner/index.d.mts +7 -0
- package/dist/banner/index.mjs +15 -0
- package/dist/index.d.mts +108 -0
- package/dist/index.mjs +500 -0
- package/dist/prompts/index.d.mts +2 -0
- package/dist/prompts/index.mjs +2 -0
- package/dist/utils/color.d.mts +2 -0
- package/dist/utils/color.mjs +2 -0
- package/package.json +89 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mythie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# termcraft
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Mythie/termcraft/actions/workflows/ci.yml)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Build type-safe CLIs with a single function call. Parsing, help, subcommands, and lifecycle hooks, all inferred from your definition.
|
|
7
|
+
|
|
8
|
+
> **Note:** termcraft is in early development (v0.1.0). The API may change between minor versions until v1.0.0.
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bun add termcraft
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineCommand, parseAsString, parseAsBoolean, runMain } from "termcraft";
|
|
18
|
+
|
|
19
|
+
const command = defineCommand({
|
|
20
|
+
meta: { name: "greet", version: "1.0.0" },
|
|
21
|
+
args: {
|
|
22
|
+
name: parseAsString.positional(0).withDescription("Name to greet."),
|
|
23
|
+
loud: parseAsBoolean.withDefault(false).withDescription("Print in uppercase."),
|
|
24
|
+
},
|
|
25
|
+
run: ({ args }) => {
|
|
26
|
+
const line = `Hello ${args.name}!`;
|
|
27
|
+
console.log(args.loud ? line.toUpperCase() : line);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await runMain(command);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
$ bun run greet.ts world --loud
|
|
36
|
+
HELLO WORLD!
|
|
37
|
+
|
|
38
|
+
$ bun run greet.ts --help
|
|
39
|
+
greet
|
|
40
|
+
|
|
41
|
+
USAGE
|
|
42
|
+
greet <name> [options]
|
|
43
|
+
|
|
44
|
+
ARGUMENTS
|
|
45
|
+
<name> Name to greet.
|
|
46
|
+
|
|
47
|
+
OPTIONS
|
|
48
|
+
--loud Print in uppercase. (default: false)
|
|
49
|
+
-h, --help Show help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Why termcraft?
|
|
53
|
+
|
|
54
|
+
- **Bun-native** Designed for Bun from the ground up, no Node baggage.
|
|
55
|
+
- **Fully type-safe** Argument types are inferred from your definition. `args.port` is `number`, not `any`. `.withDefault()` and `.withRequired()` narrow the type from `T | null` to `T`.
|
|
56
|
+
- **Lifecycle hooks** `setup` / `run` / `cleanup` / `onError` with guaranteed cleanup, even on SIGINT. No other lightweight CLI framework handles resource teardown this well.
|
|
57
|
+
- **Flag-or-prompt pattern** Combine CLI flags with interactive prompts: `args.name ?? await text({ message: "Name?" })`. Ship CLIs that work both interactively and in scripts.
|
|
58
|
+
- **Batteries included** Prompts, colors, and banners as sub-path imports. One `bun add`, no juggling packages.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Declarative commands** Define CLIs with `defineCommand()`.
|
|
63
|
+
- **Type-safe parsers** `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsEnum`.
|
|
64
|
+
- **Fluent API** `.withAlias()`, `.withDefault()`, `.withRequired()`, `.withDescription()`, `.positional()`, `.variadic()`.
|
|
65
|
+
- **Subcommand routing** Nested commands with lazy-loaded imports via `() => import()`.
|
|
66
|
+
- **Auto-generated help** `--help` / `-h` with aligned columns, usage lines, and examples.
|
|
67
|
+
- **Signal handling** Graceful SIGINT (exit 130) / SIGTERM (exit 143) with cleanup.
|
|
68
|
+
- **Interactive prompts** via `termcraft/prompts` ([`@clack/prompts`](https://github.com/bombshell-dev/clack)).
|
|
69
|
+
- **Terminal colors** via `termcraft/colors` ([`picocolors`](https://github.com/alexeyraspopov/picocolors)).
|
|
70
|
+
- **ASCII banners** via `termcraft/banner` ([`figlet`](https://github.com/patorjk/figlet.js)).
|
|
71
|
+
- **Custom parsers** from parse/serialize functions or any Zod-compatible schema.
|
|
72
|
+
|
|
73
|
+
## Argument Parsers
|
|
74
|
+
|
|
75
|
+
Parsers start as optional (`T | null`). Chain methods to refine:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { parseAsString, parseAsInteger, parseAsEnum } from "termcraft";
|
|
79
|
+
|
|
80
|
+
parseAsString.positional(0) // string | null
|
|
81
|
+
parseAsString.positional(0).withRequired() // string (non-null)
|
|
82
|
+
parseAsInteger.withAlias("c").withDefault(1) // number (non-null)
|
|
83
|
+
parseAsString.withRequired().withDescription("Output file.") // string (non-null)
|
|
84
|
+
parseAsEnum(["stable", "beta", "canary"] as const) // "stable" | "beta" | "canary" | null
|
|
85
|
+
parseAsString.positional(0).variadic() // string[] (collects remaining)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Built-in Parsers
|
|
89
|
+
|
|
90
|
+
| Parser | Type | Description |
|
|
91
|
+
|--------|------|-------------|
|
|
92
|
+
| `parseAsString` | `string` | Pass-through string |
|
|
93
|
+
| `parseAsInteger` | `number` | Whole numbers only |
|
|
94
|
+
| `parseAsFloat` | `number` | Any valid number |
|
|
95
|
+
| `parseAsBoolean` | `boolean` | `true/yes/1` or `false/no/0` |
|
|
96
|
+
| `parseAsEnum(values)` | `values[number]` | One of the provided values |
|
|
97
|
+
|
|
98
|
+
### Custom Parsers
|
|
99
|
+
|
|
100
|
+
Create parsers from functions or any object with `safeParse` (e.g., Zod):
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createParser } from "termcraft";
|
|
104
|
+
|
|
105
|
+
// From parse/serialize functions
|
|
106
|
+
const parseAsDate = createParser({
|
|
107
|
+
parse: (value: string) => {
|
|
108
|
+
const date = new Date(value);
|
|
109
|
+
return isNaN(date.getTime()) ? null : date;
|
|
110
|
+
},
|
|
111
|
+
serialize: (value: Date) => value.toISOString(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// From a Zod schema (requires zod as a peer dependency)
|
|
115
|
+
import { z } from "zod";
|
|
116
|
+
const parseAsPort = createParser(z.coerce.number().int().min(1).max(65535));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Subcommands
|
|
120
|
+
|
|
121
|
+
Nest commands with the `commands` field. Supports deeply nested routing and lazy-loaded imports:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { defineCommand, parseAsInteger, runMain } from "termcraft";
|
|
125
|
+
|
|
126
|
+
const command = defineCommand({
|
|
127
|
+
meta: { name: "tasks", version: "1.0.0" },
|
|
128
|
+
commands: {
|
|
129
|
+
list: defineCommand({
|
|
130
|
+
meta: { name: "list", description: "List tasks." },
|
|
131
|
+
args: {
|
|
132
|
+
limit: parseAsInteger.withDefault(10).withDescription("Max tasks."),
|
|
133
|
+
},
|
|
134
|
+
run: ({ args }) => console.log(`Listing ${args.limit} tasks`),
|
|
135
|
+
}),
|
|
136
|
+
// Lazy-loaded subcommand
|
|
137
|
+
deploy: () => import("./commands/deploy"),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await runMain(command);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
bun run cli.ts list --limit 5
|
|
146
|
+
bun run cli.ts deploy
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Lifecycle Hooks
|
|
150
|
+
|
|
151
|
+
Commands support `setup`, `run`, `cleanup`, and `onError` hooks. Cleanup is guaranteed to run even if `setup` or `run` throws:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const command = defineCommand({
|
|
155
|
+
meta: { name: "release", version: "1.0.0" },
|
|
156
|
+
args: {
|
|
157
|
+
tag: parseAsString.withRequired().withDescription("Version tag."),
|
|
158
|
+
},
|
|
159
|
+
setup: async ({ args }) => {
|
|
160
|
+
console.log(`Preparing release ${args.tag}`);
|
|
161
|
+
},
|
|
162
|
+
run: ({ args }) => {
|
|
163
|
+
console.log(`Publishing ${args.tag}`);
|
|
164
|
+
},
|
|
165
|
+
cleanup: () => {
|
|
166
|
+
console.log("Cleaning up temporary files");
|
|
167
|
+
},
|
|
168
|
+
onError: (error) => {
|
|
169
|
+
console.error(`Release failed: ${error}`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Interactive Prompts
|
|
176
|
+
|
|
177
|
+
Use `termcraft/prompts` for interactive input. The flag-or-prompt pattern lets your CLI work both interactively and in CI:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { defineCommand, parseAsString, runMain } from "termcraft";
|
|
181
|
+
import { text, select, intro, outro } from "termcraft/prompts";
|
|
182
|
+
|
|
183
|
+
const command = defineCommand({
|
|
184
|
+
meta: { name: "init", version: "1.0.0" },
|
|
185
|
+
args: {
|
|
186
|
+
name: parseAsString.withDescription("Project name."),
|
|
187
|
+
},
|
|
188
|
+
run: async ({ args }) => {
|
|
189
|
+
intro("Project Setup");
|
|
190
|
+
|
|
191
|
+
// Use CLI flag if provided, prompt otherwise
|
|
192
|
+
const name = args.name ?? await text({ message: "Project name?" });
|
|
193
|
+
|
|
194
|
+
const template = await select({
|
|
195
|
+
message: "Template?",
|
|
196
|
+
options: [
|
|
197
|
+
{ value: "web", label: "Web App" },
|
|
198
|
+
{ value: "api", label: "API Server" },
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
outro(`Created ${name} with ${template} template.`);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await runMain(command);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Colors and Banners
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { colors } from "termcraft/colors";
|
|
213
|
+
import { banner } from "termcraft/banner";
|
|
214
|
+
|
|
215
|
+
console.log(await banner("my-app"));
|
|
216
|
+
console.log(colors.green("Success!"));
|
|
217
|
+
console.log(colors.bold(colors.red("Error:")), "something went wrong");
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Error Handling
|
|
221
|
+
|
|
222
|
+
termcraft exports error classes for programmatic error handling:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { TermcraftError, MissingArgError, InvalidValueError } from "termcraft";
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- `MissingArgError` is thrown when a required argument or option is missing.
|
|
229
|
+
- `InvalidValueError` is thrown when a value doesn't match the parser's expected type.
|
|
230
|
+
- `TermcraftError` is the base class for all termcraft errors.
|
|
231
|
+
|
|
232
|
+
Signal handling is automatic: SIGINT exits with code 130, SIGTERM with 143. Cleanup hooks always run before exit.
|
|
233
|
+
|
|
234
|
+
## Documentation
|
|
235
|
+
|
|
236
|
+
Full guides and API reference in [`docs/`](./docs/):
|
|
237
|
+
|
|
238
|
+
- [Getting Started](./docs/getting-started.md)
|
|
239
|
+
- [Defining Commands](./docs/defining-commands.md)
|
|
240
|
+
- [Argument Parsers](./docs/argument-parsers.md)
|
|
241
|
+
- [Custom Parsers](./docs/custom-parsers.md)
|
|
242
|
+
- [Subcommands](./docs/subcommands.md)
|
|
243
|
+
- [Lifecycle Hooks](./docs/lifecycle-hooks.md)
|
|
244
|
+
- [Prompts, Colors, and Banners](./docs/prompts.md)
|
|
245
|
+
- [Error Handling](./docs/error-handling.md)
|
|
246
|
+
- [API Reference](./docs/api-reference.md)
|
|
247
|
+
|
|
248
|
+
## Examples
|
|
249
|
+
|
|
250
|
+
See [`examples/`](./examples/) for complete working CLIs:
|
|
251
|
+
|
|
252
|
+
- **[greet.ts](./examples/greet.ts)** positional args, flags, aliases
|
|
253
|
+
- **[init.ts](./examples/init.ts)** interactive prompts with flag-or-prompt pattern
|
|
254
|
+
- **[release.ts](./examples/release.ts)** lifecycle hooks, banners, error handling
|
|
255
|
+
- **[tasks.ts](./examples/tasks.ts)** subcommands and nested commands
|
|
256
|
+
- **[workspace.ts](./examples/workspace.ts)** full app-like CLI with grouped subcommands
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
bun run examples/greet.ts world --loud
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
bun install # install dependencies
|
|
266
|
+
bun test # run tests
|
|
267
|
+
bun run lint # check for lint/format issues
|
|
268
|
+
bun run lint:fix # auto-fix lint/format issues
|
|
269
|
+
bun run typecheck # run TypeScript type checking
|
|
270
|
+
bun run check # lint + typecheck
|
|
271
|
+
bun run build # build dist/ for publishing
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/banner/index.d.ts
|
|
2
|
+
interface BannerOptions {
|
|
3
|
+
font?: "1Row" | "3-D" | "3D Diagonal" | "3D-ASCII" | "3x5" | "4Max" | "5 Line Oblique" | "Standard" | "Ghost" | "Big" | "Block" | "Bubble" | "Digital" | "Ivrit" | "Mini" | "Script" | "Shadow" | "Slant" | "Small" | "Speed" | "Tinker-Toy" | (string & {});
|
|
4
|
+
}
|
|
5
|
+
declare function banner(text: string, options?: BannerOptions): Promise<string>;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { BannerOptions, banner };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import figlet from "figlet";
|
|
2
|
+
//#region src/banner/index.ts
|
|
3
|
+
function banner(text, options) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
figlet.text(text, options, (error, result) => {
|
|
6
|
+
if (error) {
|
|
7
|
+
reject(error);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
resolve(result ?? "");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { banner };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//#region src/parsers/types.d.ts
|
|
2
|
+
type ParserFunctions<T> = {
|
|
3
|
+
parse: (val: string) => T | null;
|
|
4
|
+
serialize: (val: T) => string;
|
|
5
|
+
};
|
|
6
|
+
type ParserMeta = {
|
|
7
|
+
alias?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
defaultValue?: unknown;
|
|
10
|
+
isRequired: boolean;
|
|
11
|
+
isPositional: boolean;
|
|
12
|
+
positionalIndex?: number;
|
|
13
|
+
isVariadic: boolean;
|
|
14
|
+
typeName?: string;
|
|
15
|
+
isBooleanFlag: boolean;
|
|
16
|
+
};
|
|
17
|
+
type Parser<T, D extends boolean = false, V extends boolean = false> = {
|
|
18
|
+
_meta: ParserMeta;
|
|
19
|
+
parse: (val: string) => T | null;
|
|
20
|
+
serialize: (val: T) => string;
|
|
21
|
+
withAlias: (alias: string) => Parser<T, D, V>;
|
|
22
|
+
withDescription: (description: string) => Parser<T, D, V>;
|
|
23
|
+
withRequired: () => Parser<T, true, V>;
|
|
24
|
+
withDefault: (defaultValue: T) => Parser<T, true, V>;
|
|
25
|
+
positional: (index?: number) => Parser<T, true, V>;
|
|
26
|
+
variadic: () => Parser<T, D, true>;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/command/types.d.ts
|
|
30
|
+
type CommandMeta = {
|
|
31
|
+
name: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
};
|
|
35
|
+
type ArgsRecord = Record<string, Parser<any, boolean, boolean>>;
|
|
36
|
+
type CommandContext<T extends ArgsRecord> = {
|
|
37
|
+
args: InferArgs<T>;
|
|
38
|
+
};
|
|
39
|
+
type HelpConfig = {
|
|
40
|
+
header?: string;
|
|
41
|
+
examples?: string[];
|
|
42
|
+
render?: (command: CommandDef<any>, meta: CommandMeta) => string;
|
|
43
|
+
};
|
|
44
|
+
type InferArgs<T extends ArgsRecord> = { [K in keyof T]: T[K] extends Parser<infer V, infer D, infer IsVariadic> ? IsVariadic extends true ? V[] : D extends true ? V : V | null : never };
|
|
45
|
+
type LazyCommandRef = {
|
|
46
|
+
description?: string;
|
|
47
|
+
load: () => Promise<{
|
|
48
|
+
default: CommandDef<any>;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
type CommandRef = CommandDef<any> | (() => Promise<{
|
|
52
|
+
default: CommandDef<any>;
|
|
53
|
+
}>) | LazyCommandRef;
|
|
54
|
+
type CommandDef<T extends ArgsRecord> = {
|
|
55
|
+
meta: CommandMeta;
|
|
56
|
+
args?: T;
|
|
57
|
+
commands?: Record<string, CommandRef>;
|
|
58
|
+
help?: HelpConfig;
|
|
59
|
+
setup?: (context: CommandContext<T>) => void | Promise<void>;
|
|
60
|
+
run?: (context: CommandContext<T>) => unknown | Promise<unknown>;
|
|
61
|
+
cleanup?: (context: CommandContext<T>) => void | Promise<void>;
|
|
62
|
+
onError?: (error: unknown) => void | Promise<void>;
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/command/define.d.ts
|
|
66
|
+
declare const defineCommand: <T extends ArgsRecord = ArgsRecord>(definition: CommandDef<T>) => CommandDef<T>;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/command/errors.d.ts
|
|
69
|
+
declare class TermcraftError extends Error {
|
|
70
|
+
constructor(message: string);
|
|
71
|
+
}
|
|
72
|
+
declare class MissingArgError extends TermcraftError {
|
|
73
|
+
constructor(name: string, isPositional: boolean);
|
|
74
|
+
}
|
|
75
|
+
declare class InvalidValueError extends TermcraftError {
|
|
76
|
+
constructor(name: string, value: string, typeName: string, isPositional: boolean);
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/command/help.d.ts
|
|
80
|
+
declare const renderHelp: <T extends ArgsRecord>(command: CommandDef<T>) => string;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/command/run.d.ts
|
|
83
|
+
declare const runCommand: <T extends ArgsRecord>(command: CommandDef<T>, options: {
|
|
84
|
+
argv: string[];
|
|
85
|
+
}) => Promise<void>;
|
|
86
|
+
declare const routeAndRun: <T extends ArgsRecord>(command: CommandDef<T>, argv: string[]) => Promise<void>;
|
|
87
|
+
declare const runMain: <T extends ArgsRecord>(command: CommandDef<T>) => Promise<void>;
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/parsers/built-in.d.ts
|
|
90
|
+
declare const parseAsString: Parser<string, false>;
|
|
91
|
+
declare const parseAsInteger: Parser<number, false>;
|
|
92
|
+
declare const parseAsFloat: Parser<number, false>;
|
|
93
|
+
declare const parseAsBoolean: Parser<boolean, false>;
|
|
94
|
+
declare const parseAsEnum: <const TValues extends readonly [string, ...string[]]>(values: TValues) => Parser<TValues[number], false>;
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/parsers/create-parser.d.ts
|
|
97
|
+
type ZodLikeSchema<T = unknown> = {
|
|
98
|
+
safeParse: (value: unknown) => {
|
|
99
|
+
success: true;
|
|
100
|
+
data: T;
|
|
101
|
+
} | {
|
|
102
|
+
success: false;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
declare function createParser<T>(functions: ParserFunctions<T>, meta?: Partial<ParserMeta>): Parser<T, false>;
|
|
106
|
+
declare function createParser<T>(schema: ZodLikeSchema<T>, meta?: Partial<ParserMeta>): Parser<T, false>;
|
|
107
|
+
//#endregion
|
|
108
|
+
export { type ArgsRecord, type CommandContext, type CommandDef, type CommandMeta, type CommandRef, type HelpConfig, type InferArgs, InvalidValueError, type LazyCommandRef, MissingArgError, type Parser, type ParserFunctions, type ParserMeta, TermcraftError, createParser, defineCommand, parseAsBoolean, parseAsEnum, parseAsFloat, parseAsInteger, parseAsString, renderHelp, routeAndRun, runCommand, runMain };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { colors } from "./utils/color.mjs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
//#region src/command/utils.ts
|
|
4
|
+
const getOrderedPositionals = (args) => {
|
|
5
|
+
return Object.entries(args).filter(([, parser]) => parser._meta.isPositional).map(([name, parser], order) => ({
|
|
6
|
+
name,
|
|
7
|
+
parser,
|
|
8
|
+
order,
|
|
9
|
+
index: parser._meta.positionalIndex ?? Number.MAX_SAFE_INTEGER
|
|
10
|
+
})).sort((left, right) => {
|
|
11
|
+
if (left.index !== right.index) return left.index - right.index;
|
|
12
|
+
return left.order - right.order;
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/command/define.ts
|
|
17
|
+
const validateArgs = (args) => {
|
|
18
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
19
|
+
for (const [name, parser] of Object.entries(args)) {
|
|
20
|
+
const { alias } = parser._meta;
|
|
21
|
+
if (alias) {
|
|
22
|
+
const existing = aliases.get(alias);
|
|
23
|
+
if (existing) throw new Error(`Duplicate alias "${alias}" for arguments "${existing}" and "${name}".`);
|
|
24
|
+
aliases.set(alias, name);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const explicitIndices = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const [name, parser] of Object.entries(args)) {
|
|
29
|
+
const positionalIndex = parser._meta.isPositional ? parser._meta.positionalIndex : void 0;
|
|
30
|
+
if (positionalIndex === void 0) continue;
|
|
31
|
+
const existing = explicitIndices.get(positionalIndex);
|
|
32
|
+
if (existing) throw new Error(`Duplicate positional index ${positionalIndex} for arguments "${existing}" and "${name}".`);
|
|
33
|
+
explicitIndices.set(positionalIndex, name);
|
|
34
|
+
}
|
|
35
|
+
const orderedPositionals = getOrderedPositionals(args);
|
|
36
|
+
const variadics = orderedPositionals.filter(({ parser }) => parser._meta.isVariadic);
|
|
37
|
+
if (variadics.length > 1) throw new Error("Only one variadic positional argument is allowed.");
|
|
38
|
+
const variadic = variadics[0];
|
|
39
|
+
if (variadic && orderedPositionals[orderedPositionals.length - 1]?.name !== variadic.name) throw new Error(`Variadic positional argument "${variadic.name}" must be the last positional argument.`);
|
|
40
|
+
};
|
|
41
|
+
const defineCommand = (definition) => {
|
|
42
|
+
if (definition.args) validateArgs(definition.args);
|
|
43
|
+
return definition;
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/command/errors.ts
|
|
47
|
+
var TermcraftError = class extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = new.target.name;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var MissingArgError = class extends TermcraftError {
|
|
54
|
+
constructor(name, isPositional) {
|
|
55
|
+
super(isPositional ? `Missing required argument: ${name}` : `Missing required option: --${name}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var InvalidValueError = class extends TermcraftError {
|
|
59
|
+
constructor(name, value, typeName, isPositional) {
|
|
60
|
+
super(`Invalid value for ${isPositional ? name : `--${name}`}: ${JSON.stringify(value)} (expected ${typeName})`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/command/help.ts
|
|
65
|
+
const isPositionalRequired = (parser) => parser._meta.defaultValue === void 0;
|
|
66
|
+
const isNamedOptionRequired = (parser) => {
|
|
67
|
+
return parser._meta.isRequired && parser._meta.defaultValue === void 0;
|
|
68
|
+
};
|
|
69
|
+
const formatPositionalUsage = (name, parser) => {
|
|
70
|
+
const displayName = parser._meta.isVariadic ? `${name}...` : name;
|
|
71
|
+
return isPositionalRequired(parser) ? `<${displayName}>` : `[${displayName}]`;
|
|
72
|
+
};
|
|
73
|
+
const formatOptionUsage = (name, parser) => {
|
|
74
|
+
return `--${name}${parser._meta.isBooleanFlag ? "" : ` <${parser._meta.typeName ?? "value"}>`}`;
|
|
75
|
+
};
|
|
76
|
+
const formatDefaultValue = (parser) => {
|
|
77
|
+
if (parser._meta.defaultValue === void 0) return null;
|
|
78
|
+
return parser.serialize(parser._meta.defaultValue);
|
|
79
|
+
};
|
|
80
|
+
const formatOptionLabel = (name, parser) => {
|
|
81
|
+
return `${parser._meta.alias ? `-${parser._meta.alias}, ` : ""}--${name}${parser._meta.isBooleanFlag ? "" : ` <${parser._meta.typeName ?? "value"}>`}`;
|
|
82
|
+
};
|
|
83
|
+
const formatOptionDescription = (parser) => {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (parser._meta.description) parts.push(parser._meta.description);
|
|
86
|
+
const defaultValue = formatDefaultValue(parser);
|
|
87
|
+
if (defaultValue !== null) parts.push(`(default: ${defaultValue})`);
|
|
88
|
+
return parts.join(" ");
|
|
89
|
+
};
|
|
90
|
+
const isLazyCommandRef$1 = (ref) => typeof ref === "object" && "load" in ref;
|
|
91
|
+
const formatCommandDescription = (commandRef) => {
|
|
92
|
+
if (typeof commandRef === "function") return "";
|
|
93
|
+
if (isLazyCommandRef$1(commandRef)) return commandRef.description ?? "";
|
|
94
|
+
return commandRef.meta.description ?? "";
|
|
95
|
+
};
|
|
96
|
+
const renderSection = (title, lines) => {
|
|
97
|
+
if (lines.length === 0) return [];
|
|
98
|
+
return [colors.bold(colors.cyan(title)), ...lines];
|
|
99
|
+
};
|
|
100
|
+
const renderHelp = (command) => {
|
|
101
|
+
if (command.help?.render) return command.help.render(command, command.meta);
|
|
102
|
+
const args = command.args ?? {};
|
|
103
|
+
const argEntries = Object.entries(args);
|
|
104
|
+
const positionals = getOrderedPositionals(args);
|
|
105
|
+
const options = argEntries.filter(([, parser]) => !parser._meta.isPositional);
|
|
106
|
+
const requiredOptions = options.filter(([, parser]) => isNamedOptionRequired(parser));
|
|
107
|
+
const optionalOptions = options.filter(([, parser]) => !isNamedOptionRequired(parser));
|
|
108
|
+
const commandEntries = Object.entries(command.commands ?? {});
|
|
109
|
+
const sections = [];
|
|
110
|
+
const header = command.help?.header ?? colors.bold(command.meta.name);
|
|
111
|
+
sections.push(header);
|
|
112
|
+
if (command.meta.description) sections.push(command.meta.description);
|
|
113
|
+
const usage = [command.meta.name];
|
|
114
|
+
if (commandEntries.length > 0) usage.push("<command>");
|
|
115
|
+
usage.push(...requiredOptions.map(([name, parser]) => formatOptionUsage(name, parser)));
|
|
116
|
+
usage.push(...positionals.map(({ name, parser }) => formatPositionalUsage(name, parser)));
|
|
117
|
+
if (optionalOptions.length > 0) usage.push("[options]");
|
|
118
|
+
sections.push("", ...renderSection("USAGE", [` ${usage.join(" ")}`]));
|
|
119
|
+
if (positionals.length > 0) {
|
|
120
|
+
const labels = positionals.map(({ name, parser }) => formatPositionalUsage(name, parser));
|
|
121
|
+
const maxLabelWidth = Math.max(...labels.map((l) => l.length));
|
|
122
|
+
sections.push("", ...renderSection("ARGUMENTS", positionals.map(({ parser }, i) => {
|
|
123
|
+
const description = formatOptionDescription(parser);
|
|
124
|
+
const label = labels[i];
|
|
125
|
+
return description ? ` ${label.padEnd(maxLabelWidth)} ${description}` : ` ${label}`;
|
|
126
|
+
})));
|
|
127
|
+
}
|
|
128
|
+
{
|
|
129
|
+
const labels = options.map(([name, parser]) => formatOptionLabel(name, parser));
|
|
130
|
+
const implicitLabels = ["-h, --help"];
|
|
131
|
+
if (command.meta.version) implicitLabels.push("-v, --version");
|
|
132
|
+
const maxLabelWidth = Math.max(...labels.map((l) => l.length), ...implicitLabels.map((l) => l.length));
|
|
133
|
+
const optionLines = options.map(([, parser], i) => {
|
|
134
|
+
const description = formatOptionDescription(parser);
|
|
135
|
+
const label = labels[i];
|
|
136
|
+
return description ? ` ${label.padEnd(maxLabelWidth)} ${description}` : ` ${label}`;
|
|
137
|
+
});
|
|
138
|
+
const implicitFlags = [` ${"-h, --help".padEnd(maxLabelWidth)} Show help`];
|
|
139
|
+
if (command.meta.version) implicitFlags.push(` ${"-v, --version".padEnd(maxLabelWidth)} Show version`);
|
|
140
|
+
sections.push("", ...renderSection("OPTIONS", [...optionLines, ...implicitFlags]));
|
|
141
|
+
}
|
|
142
|
+
if (commandEntries.length > 0) {
|
|
143
|
+
const names = commandEntries.map(([name]) => name);
|
|
144
|
+
const maxNameWidth = Math.max(...names.map((n) => n.length));
|
|
145
|
+
sections.push("", ...renderSection("COMMANDS", commandEntries.map(([name, commandRef]) => {
|
|
146
|
+
const description = formatCommandDescription(commandRef);
|
|
147
|
+
return description ? ` ${name.padEnd(maxNameWidth)} ${description}` : ` ${name}`;
|
|
148
|
+
})));
|
|
149
|
+
}
|
|
150
|
+
if (command.help?.examples?.length) sections.push("", ...renderSection("EXAMPLES", command.help.examples.map((example) => ` ${colors.dim(example)}`)));
|
|
151
|
+
return sections.join("\n");
|
|
152
|
+
};
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/command/resolve-args.ts
|
|
155
|
+
const parseValue = (name, parser, rawValue) => {
|
|
156
|
+
const parsed = parser.parse(rawValue);
|
|
157
|
+
if (parsed === null) throw new InvalidValueError(name, rawValue, parser._meta.typeName ?? "value", parser._meta.isPositional);
|
|
158
|
+
return parsed;
|
|
159
|
+
};
|
|
160
|
+
const resolveArgs = (args, argv) => {
|
|
161
|
+
const namedEntries = Object.entries(args).filter(([, parser]) => !parser._meta.isPositional);
|
|
162
|
+
const positionals = getOrderedPositionals(args);
|
|
163
|
+
const options = Object.fromEntries(namedEntries.map(([name, parser]) => {
|
|
164
|
+
return [name, {
|
|
165
|
+
type: parser._meta.isBooleanFlag ? "boolean" : "string",
|
|
166
|
+
...parser._meta.alias ? { short: parser._meta.alias } : {}
|
|
167
|
+
}];
|
|
168
|
+
}));
|
|
169
|
+
let parsedArgs;
|
|
170
|
+
try {
|
|
171
|
+
parsedArgs = parseArgs({
|
|
172
|
+
args: argv,
|
|
173
|
+
options,
|
|
174
|
+
allowPositionals: true,
|
|
175
|
+
strict: true
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof Error) {
|
|
179
|
+
const missingValue = error.message.match(/^Option '(?:-[^,]+, )?--([^ ]+) <value>' argument missing$/);
|
|
180
|
+
if (missingValue) throw new MissingArgError(missingValue[1], false);
|
|
181
|
+
throw new TermcraftError(error.message);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const resolved = {};
|
|
186
|
+
for (const [name, parser] of namedEntries) {
|
|
187
|
+
const rawValue = parsedArgs.values[name];
|
|
188
|
+
if (rawValue === void 0) {
|
|
189
|
+
if (parser._meta.defaultValue !== void 0) {
|
|
190
|
+
resolved[name] = parser._meta.defaultValue;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (parser._meta.isRequired) throw new MissingArgError(name, false);
|
|
194
|
+
resolved[name] = null;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
resolved[name] = parseValue(name, parser, String(rawValue));
|
|
198
|
+
}
|
|
199
|
+
let positionalOffset = 0;
|
|
200
|
+
for (const { name, parser } of positionals) {
|
|
201
|
+
if (parser._meta.isVariadic) {
|
|
202
|
+
const remaining = parsedArgs.positionals.slice(positionalOffset);
|
|
203
|
+
if (remaining.length === 0) {
|
|
204
|
+
if (parser._meta.defaultValue !== void 0) {
|
|
205
|
+
resolved[name] = parser._meta.defaultValue;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
throw new MissingArgError(name, true);
|
|
209
|
+
}
|
|
210
|
+
resolved[name] = remaining.map((value) => parseValue(name, parser, value));
|
|
211
|
+
positionalOffset = parsedArgs.positionals.length;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const rawValue = parsedArgs.positionals[positionalOffset];
|
|
215
|
+
if (rawValue === void 0) {
|
|
216
|
+
if (parser._meta.defaultValue !== void 0) {
|
|
217
|
+
resolved[name] = parser._meta.defaultValue;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
throw new MissingArgError(name, true);
|
|
221
|
+
}
|
|
222
|
+
resolved[name] = parseValue(name, parser, rawValue);
|
|
223
|
+
positionalOffset += 1;
|
|
224
|
+
}
|
|
225
|
+
const unexpectedArg = parsedArgs.positionals[positionalOffset];
|
|
226
|
+
if (unexpectedArg !== void 0) throw new TermcraftError(`Unexpected argument: ${unexpectedArg}`);
|
|
227
|
+
return resolved;
|
|
228
|
+
};
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/command/run.ts
|
|
231
|
+
var SignalExitError = class extends Error {
|
|
232
|
+
constructor(code) {
|
|
233
|
+
super(`Process interrupted with exit code ${code}`);
|
|
234
|
+
this.code = code;
|
|
235
|
+
this.name = "SignalExitError";
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
let activeSignal = null;
|
|
239
|
+
const formatError = (error) => {
|
|
240
|
+
if (error instanceof Error) return error.message;
|
|
241
|
+
return String(error);
|
|
242
|
+
};
|
|
243
|
+
const throwIfSignaled = () => {
|
|
244
|
+
const error = activeSignal?.error;
|
|
245
|
+
if (error) throw error;
|
|
246
|
+
};
|
|
247
|
+
const awaitWithSignal = async (value) => {
|
|
248
|
+
const signal = activeSignal;
|
|
249
|
+
if (!signal) return await value;
|
|
250
|
+
return await Promise.race([Promise.resolve(value), signal.promise.then((error) => {
|
|
251
|
+
throw error;
|
|
252
|
+
})]);
|
|
253
|
+
};
|
|
254
|
+
const isLazyCommandRef = (ref) => typeof ref === "object" && "load" in ref;
|
|
255
|
+
const resolveCommandRef = async (commandRef) => {
|
|
256
|
+
if (typeof commandRef === "function") {
|
|
257
|
+
const module = await awaitWithSignal(commandRef());
|
|
258
|
+
throwIfSignaled();
|
|
259
|
+
return module.default;
|
|
260
|
+
}
|
|
261
|
+
if (isLazyCommandRef(commandRef)) {
|
|
262
|
+
const module = await awaitWithSignal(commandRef.load());
|
|
263
|
+
throwIfSignaled();
|
|
264
|
+
return module.default;
|
|
265
|
+
}
|
|
266
|
+
throwIfSignaled();
|
|
267
|
+
return commandRef;
|
|
268
|
+
};
|
|
269
|
+
const hasRequiredPositionalsForEmptyArgvHelp = (command) => {
|
|
270
|
+
const args = command.args;
|
|
271
|
+
if (!args) return false;
|
|
272
|
+
return Object.values(args).some((parser) => parser._meta.isPositional && parser._meta.defaultValue === void 0);
|
|
273
|
+
};
|
|
274
|
+
const runCommand = async (command, options) => {
|
|
275
|
+
const context = { args: command.args ? resolveArgs(command.args, options.argv) : {} };
|
|
276
|
+
let error;
|
|
277
|
+
try {
|
|
278
|
+
throwIfSignaled();
|
|
279
|
+
await awaitWithSignal(command.setup?.(context));
|
|
280
|
+
throwIfSignaled();
|
|
281
|
+
await awaitWithSignal(command.run?.(context));
|
|
282
|
+
} catch (caughtError) {
|
|
283
|
+
error = caughtError;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await command.cleanup?.(context);
|
|
287
|
+
throwIfSignaled();
|
|
288
|
+
} catch (caughtError) {
|
|
289
|
+
if (caughtError instanceof SignalExitError || error === void 0) error = caughtError;
|
|
290
|
+
}
|
|
291
|
+
if (error === void 0) return;
|
|
292
|
+
if (error instanceof SignalExitError) throw error;
|
|
293
|
+
if (command.onError) {
|
|
294
|
+
await command.onError(error);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
};
|
|
299
|
+
const routeAndRun = async (command, argv) => {
|
|
300
|
+
throwIfSignaled();
|
|
301
|
+
const [subcommandName, ...rest] = argv;
|
|
302
|
+
if (subcommandName && command.commands?.[subcommandName]) {
|
|
303
|
+
const subcommand = await resolveCommandRef(command.commands[subcommandName]);
|
|
304
|
+
throwIfSignaled();
|
|
305
|
+
await routeAndRun(subcommand, rest);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (Boolean(command.commands && Object.keys(command.commands).length > 0) && !subcommandName || !subcommandName && hasRequiredPositionalsForEmptyArgvHelp(command)) {
|
|
309
|
+
throwIfSignaled();
|
|
310
|
+
console.log(renderHelp(command));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const dashDashIndex = argv.indexOf("--");
|
|
314
|
+
const flagsArgv = dashDashIndex === -1 ? argv : argv.slice(0, dashDashIndex);
|
|
315
|
+
if (flagsArgv.includes("--help") || flagsArgv.includes("-h")) {
|
|
316
|
+
throwIfSignaled();
|
|
317
|
+
console.log(renderHelp(command));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (flagsArgv.includes("--version") || flagsArgv.includes("-v")) {
|
|
321
|
+
throwIfSignaled();
|
|
322
|
+
console.log(command.meta.version ?? "");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
throwIfSignaled();
|
|
326
|
+
await runCommand(command, { argv });
|
|
327
|
+
};
|
|
328
|
+
const runMain = async (command) => {
|
|
329
|
+
const argv = process.argv.slice(2);
|
|
330
|
+
let exitCode = null;
|
|
331
|
+
let stderrOutput = null;
|
|
332
|
+
const onSigint = () => {
|
|
333
|
+
const signal = activeSignal;
|
|
334
|
+
if (signal && !signal.error) {
|
|
335
|
+
const error = new SignalExitError(130);
|
|
336
|
+
signal.error = error;
|
|
337
|
+
signal.resolve(error);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const onSigterm = () => {
|
|
341
|
+
const signal = activeSignal;
|
|
342
|
+
if (signal && !signal.error) {
|
|
343
|
+
const error = new SignalExitError(143);
|
|
344
|
+
signal.error = error;
|
|
345
|
+
signal.resolve(error);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const previousSignal = activeSignal;
|
|
349
|
+
activeSignal = {
|
|
350
|
+
...Promise.withResolvers(),
|
|
351
|
+
error: null
|
|
352
|
+
};
|
|
353
|
+
process.on("SIGINT", onSigint);
|
|
354
|
+
process.on("SIGTERM", onSigterm);
|
|
355
|
+
try {
|
|
356
|
+
await routeAndRun(command, argv);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error instanceof SignalExitError) exitCode = error.code;
|
|
359
|
+
else {
|
|
360
|
+
stderrOutput = `${formatError(error)}\n`;
|
|
361
|
+
exitCode = 1;
|
|
362
|
+
}
|
|
363
|
+
} finally {
|
|
364
|
+
process.off("SIGINT", onSigint);
|
|
365
|
+
process.off("SIGTERM", onSigterm);
|
|
366
|
+
activeSignal = previousSignal;
|
|
367
|
+
}
|
|
368
|
+
if (stderrOutput) process.stderr.write(stderrOutput);
|
|
369
|
+
if (exitCode !== null) process.exit(exitCode);
|
|
370
|
+
};
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/parsers/create-parser.ts
|
|
373
|
+
const createParserMeta = (overrides = {}) => ({
|
|
374
|
+
isRequired: false,
|
|
375
|
+
isPositional: false,
|
|
376
|
+
isVariadic: false,
|
|
377
|
+
isBooleanFlag: false,
|
|
378
|
+
...overrides
|
|
379
|
+
});
|
|
380
|
+
const buildParser = (config) => {
|
|
381
|
+
const meta = createParserMeta(config.meta);
|
|
382
|
+
return {
|
|
383
|
+
_meta: meta,
|
|
384
|
+
parse: config.parse,
|
|
385
|
+
serialize: config.serialize,
|
|
386
|
+
withAlias: (alias) => buildParser({
|
|
387
|
+
...config,
|
|
388
|
+
meta: {
|
|
389
|
+
...meta,
|
|
390
|
+
alias
|
|
391
|
+
}
|
|
392
|
+
}),
|
|
393
|
+
withDescription: (description) => buildParser({
|
|
394
|
+
...config,
|
|
395
|
+
meta: {
|
|
396
|
+
...meta,
|
|
397
|
+
description
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
400
|
+
withRequired: () => buildParser({
|
|
401
|
+
parse: config.parse,
|
|
402
|
+
serialize: config.serialize,
|
|
403
|
+
meta: {
|
|
404
|
+
...meta,
|
|
405
|
+
isRequired: true
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
withDefault: (defaultValue) => buildParser({
|
|
409
|
+
parse: config.parse,
|
|
410
|
+
serialize: config.serialize,
|
|
411
|
+
meta: {
|
|
412
|
+
...meta,
|
|
413
|
+
defaultValue
|
|
414
|
+
}
|
|
415
|
+
}),
|
|
416
|
+
positional: (index) => buildParser({
|
|
417
|
+
parse: config.parse,
|
|
418
|
+
serialize: config.serialize,
|
|
419
|
+
meta: {
|
|
420
|
+
...meta,
|
|
421
|
+
isPositional: true,
|
|
422
|
+
positionalIndex: index
|
|
423
|
+
}
|
|
424
|
+
}),
|
|
425
|
+
variadic: () => buildParser({
|
|
426
|
+
...config,
|
|
427
|
+
meta: {
|
|
428
|
+
...meta,
|
|
429
|
+
isVariadic: true
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
};
|
|
433
|
+
};
|
|
434
|
+
function createParser(input, meta) {
|
|
435
|
+
if (typeof input === "object" && "parse" in input && "serialize" in input) {
|
|
436
|
+
const functions = input;
|
|
437
|
+
return buildParser({
|
|
438
|
+
parse: functions.parse,
|
|
439
|
+
serialize: functions.serialize,
|
|
440
|
+
meta
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const schema = input;
|
|
444
|
+
return buildParser({
|
|
445
|
+
parse: (value) => {
|
|
446
|
+
const result = schema.safeParse(value);
|
|
447
|
+
return result.success ? result.data : null;
|
|
448
|
+
},
|
|
449
|
+
serialize: (value) => String(value),
|
|
450
|
+
meta
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region src/parsers/built-in.ts
|
|
455
|
+
const parseAsString = createParser({
|
|
456
|
+
parse: (value) => value,
|
|
457
|
+
serialize: (value) => value
|
|
458
|
+
}, { typeName: "string" });
|
|
459
|
+
const parseAsInteger = createParser({
|
|
460
|
+
parse: (value) => {
|
|
461
|
+
if (value.trim() === "") return null;
|
|
462
|
+
const parsed = Number(value);
|
|
463
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
464
|
+
},
|
|
465
|
+
serialize: (value) => value.toString()
|
|
466
|
+
}, { typeName: "integer" });
|
|
467
|
+
const parseAsFloat = createParser({
|
|
468
|
+
parse: (value) => {
|
|
469
|
+
if (value.trim() === "") return null;
|
|
470
|
+
const parsed = Number(value);
|
|
471
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
472
|
+
},
|
|
473
|
+
serialize: (value) => value.toString()
|
|
474
|
+
}, { typeName: "number" });
|
|
475
|
+
const parseAsBoolean = createParser({
|
|
476
|
+
parse: (value) => {
|
|
477
|
+
const normalized = value.trim().toLowerCase();
|
|
478
|
+
if ([
|
|
479
|
+
"true",
|
|
480
|
+
"yes",
|
|
481
|
+
"1"
|
|
482
|
+
].includes(normalized)) return true;
|
|
483
|
+
if ([
|
|
484
|
+
"false",
|
|
485
|
+
"no",
|
|
486
|
+
"0"
|
|
487
|
+
].includes(normalized)) return false;
|
|
488
|
+
return null;
|
|
489
|
+
},
|
|
490
|
+
serialize: (value) => value.toString()
|
|
491
|
+
}, {
|
|
492
|
+
typeName: "boolean",
|
|
493
|
+
isBooleanFlag: true
|
|
494
|
+
});
|
|
495
|
+
const parseAsEnum = (values) => createParser({
|
|
496
|
+
parse: (value) => values.includes(value) ? value : null,
|
|
497
|
+
serialize: (value) => value
|
|
498
|
+
}, { typeName: values.join("|") });
|
|
499
|
+
//#endregion
|
|
500
|
+
export { InvalidValueError, MissingArgError, TermcraftError, createParser, defineCommand, parseAsBoolean, parseAsEnum, parseAsFloat, parseAsInteger, parseAsString, renderHelp, routeAndRun, runCommand, runMain };
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "termcraft",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A type-safe CLI framework for Bun with declarative argument parsing, subcommands, and lifecycle hooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mythie",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Mythie/termcraft.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Mythie/termcraft",
|
|
13
|
+
"bugs": "https://github.com/Mythie/termcraft/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"cli",
|
|
16
|
+
"command-line",
|
|
17
|
+
"framework",
|
|
18
|
+
"bun",
|
|
19
|
+
"typescript",
|
|
20
|
+
"parser",
|
|
21
|
+
"arguments"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"import": "./dist/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./prompts": {
|
|
37
|
+
"types": "./dist/prompts/index.d.mts",
|
|
38
|
+
"import": "./dist/prompts/index.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./banner": {
|
|
41
|
+
"types": "./dist/banner/index.d.mts",
|
|
42
|
+
"import": "./dist/banner/index.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./colors": {
|
|
45
|
+
"types": "./dist/utils/color.d.mts",
|
|
46
|
+
"import": "./dist/utils/color.mjs"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"main": "./dist/index.mjs",
|
|
50
|
+
"types": "./dist/index.d.mts",
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"lint": "biome check .",
|
|
54
|
+
"lint:fix": "biome check --write .",
|
|
55
|
+
"format": "biome format --write .",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "bun test",
|
|
58
|
+
"check": "biome check . && tsc --noEmit",
|
|
59
|
+
"prepublishOnly": "bun run check && bun run build",
|
|
60
|
+
"prepare": "husky || true"
|
|
61
|
+
},
|
|
62
|
+
"lint-staged": {
|
|
63
|
+
"*.{ts,tsx,js,jsx,json,css,md}": [
|
|
64
|
+
"biome check --write --no-errors-on-unmatched"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"zod": "^4.0.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"zod": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"@clack/prompts": "^1.1.0",
|
|
77
|
+
"figlet": "^1.11.0",
|
|
78
|
+
"picocolors": "^1.1.1"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@biomejs/biome": "^2.4.7",
|
|
82
|
+
"@types/bun": "latest",
|
|
83
|
+
"@types/figlet": "^1.7.0",
|
|
84
|
+
"husky": "^9.1.7",
|
|
85
|
+
"lint-staged": "^16.3.4",
|
|
86
|
+
"tsdown": "^0.21.2",
|
|
87
|
+
"typescript": "latest"
|
|
88
|
+
}
|
|
89
|
+
}
|