@jpillora/take 0.8.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 +318 -0
- package/package.json +15 -0
- package/take.ts +472 -0
package/README.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# take
|
|
2
|
+
|
|
3
|
+
A minimal CLI library for building TypeScript-based command-line tools. Works with Deno, Node.js (with type stripping), and Bun.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
deno add jsr:@jpillora/take
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Write file `dev.ts`
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
#!/usr/bin/env -S deno run --allow-env
|
|
17
|
+
// or execute with bun, or with node
|
|
18
|
+
|
|
19
|
+
import { Command, Register } from "@jpillora/take";
|
|
20
|
+
|
|
21
|
+
Register(
|
|
22
|
+
Command({
|
|
23
|
+
name: "greet",
|
|
24
|
+
description: "Say hello",
|
|
25
|
+
flags: {},
|
|
26
|
+
run() {
|
|
27
|
+
console.log("Hello, world!");
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. Make it executable `chmod +x dev.ts`
|
|
34
|
+
|
|
35
|
+
3. Run it:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
./dev.ts --help
|
|
39
|
+
|
|
40
|
+
dev.ts <command> --help
|
|
41
|
+
|
|
42
|
+
commands:
|
|
43
|
+
• greet - Say hello
|
|
44
|
+
|
|
45
|
+
./dev.ts greet
|
|
46
|
+
Hello, world!
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
### String Flag
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
Command({
|
|
55
|
+
name: "greet",
|
|
56
|
+
description: "Greet someone by name",
|
|
57
|
+
flags: {
|
|
58
|
+
name: {
|
|
59
|
+
initial: "world",
|
|
60
|
+
description: "Name to greet",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
run({ flags }) {
|
|
64
|
+
// typescript infers flag types from initial:
|
|
65
|
+
// (property) name: string
|
|
66
|
+
console.log(`Hello, ${flags.name}!`);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
$ ./dev.ts greet --name Alice
|
|
73
|
+
Hello, Alice!
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Number Flag
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
Command({
|
|
80
|
+
name: "repeat",
|
|
81
|
+
description: "Repeat a message N times",
|
|
82
|
+
flags: {
|
|
83
|
+
count: {
|
|
84
|
+
initial: 3,
|
|
85
|
+
description: "Number of repetitions",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
run({ flags }) {
|
|
89
|
+
for (let i = 0; i < flags.count; i++) {
|
|
90
|
+
console.log("Hello!");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
$ ./dev.ts repeat --count 5
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Boolean Flag
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
Command({
|
|
104
|
+
name: "build",
|
|
105
|
+
description: "Build the project",
|
|
106
|
+
flags: {
|
|
107
|
+
minify: {
|
|
108
|
+
initial: false,
|
|
109
|
+
description: "Minify the output",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
run({ flags }) {
|
|
113
|
+
if (flags.minify) {
|
|
114
|
+
console.log("Building with minification...");
|
|
115
|
+
} else {
|
|
116
|
+
console.log("Building...");
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
$ ./dev.ts build --minify
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Environment Variable Fallback
|
|
127
|
+
|
|
128
|
+
Flags can read from environment variables when not provided on the command line.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
Command({
|
|
132
|
+
name: "deploy",
|
|
133
|
+
description: "Deploy the application",
|
|
134
|
+
flags: {
|
|
135
|
+
token: {
|
|
136
|
+
initial: "",
|
|
137
|
+
description: "API token",
|
|
138
|
+
env: "DEPLOY_TOKEN",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
run({ flags }) {
|
|
142
|
+
if (!flags.token) {
|
|
143
|
+
throw "token is required";
|
|
144
|
+
}
|
|
145
|
+
console.log("Deploying with token...");
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
$ DEPLOY_TOKEN=secret ./dev.ts deploy
|
|
152
|
+
# or
|
|
153
|
+
$ ./dev.ts deploy --token secret
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Additional Help Text
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
Command({
|
|
160
|
+
name: "migrate",
|
|
161
|
+
description: "Run database migrations",
|
|
162
|
+
help: `
|
|
163
|
+
This command runs pending database migrations.
|
|
164
|
+
Make sure your DATABASE_URL is set correctly.
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
./dev.ts migrate --dry-run
|
|
168
|
+
./dev.ts migrate --target 5`,
|
|
169
|
+
flags: {
|
|
170
|
+
dryRun: {
|
|
171
|
+
initial: false,
|
|
172
|
+
description: "Preview changes without applying",
|
|
173
|
+
},
|
|
174
|
+
target: {
|
|
175
|
+
initial: 0,
|
|
176
|
+
description: "Target migration version (0 = latest)",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
run({ flags }) {
|
|
180
|
+
// migration logic
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Positional Arguments
|
|
186
|
+
|
|
187
|
+
Non-flag arguments are available in `args`.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
Command({
|
|
191
|
+
name: "copy",
|
|
192
|
+
description: "Copy files to destination",
|
|
193
|
+
flags: {},
|
|
194
|
+
run({ args }) {
|
|
195
|
+
const [source, dest] = args;
|
|
196
|
+
if (!source || !dest) {
|
|
197
|
+
throw "usage: copy <source> <dest>";
|
|
198
|
+
}
|
|
199
|
+
console.log(`Copying ${source} to ${dest}`);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
$ ./dev.ts copy file.txt backup/
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Subcommands
|
|
209
|
+
|
|
210
|
+
Use spaces in the name to create nested commands.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
await Register(
|
|
214
|
+
Command({
|
|
215
|
+
name: "db migrate",
|
|
216
|
+
description: "Run database migrations",
|
|
217
|
+
flags: {},
|
|
218
|
+
run() {
|
|
219
|
+
console.log("Running migrations...");
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
Command({
|
|
223
|
+
name: "db seed",
|
|
224
|
+
description: "Seed the database",
|
|
225
|
+
flags: {},
|
|
226
|
+
run() {
|
|
227
|
+
console.log("Seeding database...");
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
$ ./dev.ts db migrate
|
|
235
|
+
$ ./dev.ts db seed
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Calling Other Commands
|
|
239
|
+
|
|
240
|
+
Use `cmd` to invoke other registered commands programmatically.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
Command({
|
|
244
|
+
name: "all",
|
|
245
|
+
description: "Run build, test, and deploy",
|
|
246
|
+
flags: {},
|
|
247
|
+
async run({ cmd }) {
|
|
248
|
+
await cmd("build", "--minify");
|
|
249
|
+
await cmd("test");
|
|
250
|
+
await cmd("deploy");
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Show Help Programmatically
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
Command({
|
|
259
|
+
name: "process",
|
|
260
|
+
description: "Process input files",
|
|
261
|
+
flags: {},
|
|
262
|
+
run({ args, help }) {
|
|
263
|
+
if (args.length === 0) {
|
|
264
|
+
help("no input files provided");
|
|
265
|
+
}
|
|
266
|
+
// process files...
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Timer Utility
|
|
272
|
+
|
|
273
|
+
Measure execution time with the built-in timer.
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
import { Command, Register, timer } from "@jpillora/take";
|
|
277
|
+
|
|
278
|
+
Command({
|
|
279
|
+
name: "slow",
|
|
280
|
+
description: "A slow operation",
|
|
281
|
+
flags: {},
|
|
282
|
+
run() {
|
|
283
|
+
const t = timer();
|
|
284
|
+
// ... do work ...
|
|
285
|
+
console.log(`Completed in ${t}`); // "Completed in 1.23sec"
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Spawn Utility
|
|
291
|
+
|
|
292
|
+
Run external commands with a Promise-based wrapper.
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { Command, Register, spawn } from "@jpillora/take";
|
|
296
|
+
|
|
297
|
+
Command({
|
|
298
|
+
name: "lint",
|
|
299
|
+
description: "Run the linter",
|
|
300
|
+
flags: {
|
|
301
|
+
fix: {
|
|
302
|
+
initial: false,
|
|
303
|
+
description: "Auto-fix issues",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
async run({ flags }) {
|
|
307
|
+
await spawn({
|
|
308
|
+
program: "deno",
|
|
309
|
+
args: flags.fix ? ["lint", "--fix"] : ["lint"],
|
|
310
|
+
stdio: "inherit",
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jpillora/take",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "A minimal CLI library for building TypeScript-based command-line tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./take.ts",
|
|
7
|
+
"exports": "./take.ts",
|
|
8
|
+
"files": ["take.ts"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/jpillora/take"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["cli", "typescript", "deno", "bun", "node"]
|
|
15
|
+
}
|
package/take.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Take is a mini-CLI library for building typescript-based command-line tools
|
|
2
|
+
// Works with Deno, Node.js (with type stripping), and Bun
|
|
3
|
+
// deno-lint-ignore-file no-explicit-any
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
spawn as nodeSpawn,
|
|
9
|
+
SpawnOptions as nodeSpawnOptions,
|
|
10
|
+
} from "node:child_process";
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
|
|
13
|
+
export type Flag = {
|
|
14
|
+
initial: number | string | boolean | Date;
|
|
15
|
+
description: string;
|
|
16
|
+
env?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Flags = Record<string, Flag>;
|
|
20
|
+
|
|
21
|
+
export type FlagValues<T extends Flags> = {
|
|
22
|
+
[P in keyof T]: T[P]["initial"] extends number ? number
|
|
23
|
+
: T[P]["initial"] extends string ? string
|
|
24
|
+
: T[P]["initial"] extends boolean ? boolean
|
|
25
|
+
: T[P]["initial"] extends Date ? Date
|
|
26
|
+
: never;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type TakeCommand<F extends Flags = Flags> = {
|
|
30
|
+
name: string;
|
|
31
|
+
names?: string[];
|
|
32
|
+
description: string;
|
|
33
|
+
help?: string;
|
|
34
|
+
flags: F;
|
|
35
|
+
run: (input: CommandInput<F>) => void | Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function newFlags<F extends Flags>(flags: F): F {
|
|
39
|
+
return flags;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NewCommand<F extends Flags = Flags> = TakeCommand<F> & {
|
|
43
|
+
// always null, but can be type-referenced
|
|
44
|
+
flagValues: FlagValues<F>;
|
|
45
|
+
input: CommandInput<F>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CommandInput<F extends Flags> = {
|
|
49
|
+
flags: FlagValues<F>;
|
|
50
|
+
args: string[];
|
|
51
|
+
cmd: (...args: string[]) => Promise<void>;
|
|
52
|
+
cmdName: string;
|
|
53
|
+
help: (msg?: string) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function help(str: string) {
|
|
57
|
+
throw str;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const exit = (...args: any[]) => {
|
|
61
|
+
console.log(...args);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type namedFlag = { name: string } & Flag;
|
|
66
|
+
|
|
67
|
+
type namedFlags = namedFlag[];
|
|
68
|
+
|
|
69
|
+
function namedFlags(record: Flags): namedFlags {
|
|
70
|
+
return Object.entries(record).map((kv) => ({
|
|
71
|
+
name: kv[0],
|
|
72
|
+
...kv[1],
|
|
73
|
+
})).toSorted(
|
|
74
|
+
(na, nb) => na.name < nb.name ? -1 : 1,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function convert(val: any, type: string) {
|
|
79
|
+
switch (type) {
|
|
80
|
+
case "string":
|
|
81
|
+
return `${val}`;
|
|
82
|
+
case "number": {
|
|
83
|
+
const f = parseFloat(val);
|
|
84
|
+
if (isNaN(f)) {
|
|
85
|
+
throw `expected a number, got: ${val}`;
|
|
86
|
+
}
|
|
87
|
+
return f;
|
|
88
|
+
}
|
|
89
|
+
case "boolean":
|
|
90
|
+
return Boolean(val);
|
|
91
|
+
default:
|
|
92
|
+
throw `unknown type: ${type}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// helper async node spawn
|
|
97
|
+
export type SpawnOptions =
|
|
98
|
+
& { program: string; args?: string[] }
|
|
99
|
+
& nodeSpawnOptions;
|
|
100
|
+
|
|
101
|
+
export async function spawn(options: SpawnOptions): Promise<number> {
|
|
102
|
+
return await new Promise<number>((resolve, reject) => {
|
|
103
|
+
const child = nodeSpawn(options.program, options.args ?? [], options);
|
|
104
|
+
child.on("close", (code) => {
|
|
105
|
+
if (code === 0) {
|
|
106
|
+
resolve(0);
|
|
107
|
+
} else {
|
|
108
|
+
reject(code);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// helper for measuring time
|
|
115
|
+
export const timer: () => () => string = (() => {
|
|
116
|
+
const scale: [n: number, s: string][] = [
|
|
117
|
+
[1000, "ms"],
|
|
118
|
+
[60, "sec"],
|
|
119
|
+
[60, "min"],
|
|
120
|
+
[24, "hr"],
|
|
121
|
+
];
|
|
122
|
+
const fmt = (v: number) => {
|
|
123
|
+
for (const s of scale) {
|
|
124
|
+
const n = s[0], u = s[1];
|
|
125
|
+
if (v < n) {
|
|
126
|
+
return `${v.toFixed(2)}${u}${v == 1 || u.endsWith("s") ? "" : "s"}`;
|
|
127
|
+
}
|
|
128
|
+
v /= n;
|
|
129
|
+
}
|
|
130
|
+
throw `??`;
|
|
131
|
+
};
|
|
132
|
+
return () => {
|
|
133
|
+
const t0 = performance.now();
|
|
134
|
+
const stop = () => {
|
|
135
|
+
const t1 = performance.now();
|
|
136
|
+
return fmt(t1 - t0);
|
|
137
|
+
};
|
|
138
|
+
stop.toString = stop; // 🧙♂️you may omit the brackets
|
|
139
|
+
return stop;
|
|
140
|
+
};
|
|
141
|
+
})();
|
|
142
|
+
|
|
143
|
+
// Load .env file if it exists
|
|
144
|
+
async function loadEnvFile(path: string): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
const content = await readFile(path, "utf-8");
|
|
147
|
+
for (const line of content.split("\n")) {
|
|
148
|
+
const trimmed = line.trim();
|
|
149
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
150
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
151
|
+
if (match) {
|
|
152
|
+
const key = match[1].trim();
|
|
153
|
+
let value = match[2].trim();
|
|
154
|
+
// Remove quotes if present
|
|
155
|
+
if (
|
|
156
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
157
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
158
|
+
) {
|
|
159
|
+
value = value.slice(1, -1);
|
|
160
|
+
}
|
|
161
|
+
process.env[key] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function Register(...commands: NewCommand<any>[]) {
|
|
171
|
+
// Load .env by default
|
|
172
|
+
await loadEnvFile(".env");
|
|
173
|
+
// Get script name from argv[1]
|
|
174
|
+
const scriptName = basename(process.argv[1] || "cli");
|
|
175
|
+
// validate commands
|
|
176
|
+
if (!Array.isArray(commands)) {
|
|
177
|
+
exit(`CLI(commands) must be an array`);
|
|
178
|
+
}
|
|
179
|
+
if (commands.length === 0) {
|
|
180
|
+
exit(`CLI(commands) must have at least 1 command`);
|
|
181
|
+
}
|
|
182
|
+
const names = new Set();
|
|
183
|
+
for (const c of commands) {
|
|
184
|
+
// validate name
|
|
185
|
+
if (typeof c.name !== "string") {
|
|
186
|
+
exit(`all CLI(commands) must have a "name"`);
|
|
187
|
+
}
|
|
188
|
+
// transform name
|
|
189
|
+
// list out "sub-command" names
|
|
190
|
+
c.names = c.name.trim().split(/\s+/g);
|
|
191
|
+
c.name = c.names.join(" "); // tidy name
|
|
192
|
+
// duplicate check
|
|
193
|
+
if (names.has(c.name)) {
|
|
194
|
+
exit(`duplicate command name: ${c.name}`);
|
|
195
|
+
}
|
|
196
|
+
names.add(c.name);
|
|
197
|
+
// validate function
|
|
198
|
+
if (typeof c.run !== "function") {
|
|
199
|
+
exit(`command "${c.name}" must have a "run" function`);
|
|
200
|
+
}
|
|
201
|
+
// validate flags
|
|
202
|
+
if (!c.flags) {
|
|
203
|
+
exit(`command "${c.name}" must have "flags", you can use {}`);
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(c.flags)) {
|
|
206
|
+
exit(`command "${c.name}" flags must be an object, not an array`);
|
|
207
|
+
}
|
|
208
|
+
for (const [name, flag] of Object.entries(c.flags) as [string, Flag][]) {
|
|
209
|
+
if (!flag.description) {
|
|
210
|
+
exit(`command "${c.name}" flag "${name}" must have a "description"`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
commands.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
215
|
+
// helper for joining 2 columns of text
|
|
216
|
+
type Table = { left: string; right: string }[];
|
|
217
|
+
const joinColumns = (table: Table) => {
|
|
218
|
+
const max = table.reduce((m, { left }) => Math.max(m, left.length), 0);
|
|
219
|
+
return table
|
|
220
|
+
.map((item) => {
|
|
221
|
+
if (!item.right) return item.left;
|
|
222
|
+
const pad = " ".repeat(max - item.left.length);
|
|
223
|
+
return `${item.left}${pad} ${item.right}`;
|
|
224
|
+
})
|
|
225
|
+
.join("\n");
|
|
226
|
+
};
|
|
227
|
+
// recursive help text builder for the command tree
|
|
228
|
+
function help(msg?: string) {
|
|
229
|
+
// build command list
|
|
230
|
+
const content = joinColumns(
|
|
231
|
+
commands.filter((cmd) => {
|
|
232
|
+
return cmd.name !== "debug" || process.env.DEBUG === "1";
|
|
233
|
+
}).map((cmd) => ({
|
|
234
|
+
left: ` • ${cmd.name}`,
|
|
235
|
+
right: cmd.description ? `- ${cmd.description}` : "",
|
|
236
|
+
})),
|
|
237
|
+
);
|
|
238
|
+
// print result
|
|
239
|
+
console.log(
|
|
240
|
+
`\n${scriptName} <command> --help\n\n` + "commands:\n" + content + "\n",
|
|
241
|
+
);
|
|
242
|
+
if (msg) {
|
|
243
|
+
console.log("ERROR:", msg);
|
|
244
|
+
console.log("");
|
|
245
|
+
}
|
|
246
|
+
process.exit(msg ? 1 : 0);
|
|
247
|
+
}
|
|
248
|
+
// help text builder for a given command
|
|
249
|
+
function helpFor(cmd: TakeCommand, flagSpecs: namedFlags, msg?: string) {
|
|
250
|
+
// build flag help
|
|
251
|
+
const shorts = new Set();
|
|
252
|
+
const short = (name: string) => {
|
|
253
|
+
const l = name[0];
|
|
254
|
+
if (shorts.has(l)) return "";
|
|
255
|
+
shorts.add(l);
|
|
256
|
+
return `, -${l}`;
|
|
257
|
+
};
|
|
258
|
+
const extras = (flag: Flag) => {
|
|
259
|
+
const { env, initial } = flag;
|
|
260
|
+
const out = [];
|
|
261
|
+
if (env) out.push(`env=${env}`);
|
|
262
|
+
if (initial) out.push(`default=${initial}`);
|
|
263
|
+
return out.length ? ` (${out.join(" ")})` : "";
|
|
264
|
+
};
|
|
265
|
+
const content = joinColumns(
|
|
266
|
+
flagSpecs.map((flag) => ({
|
|
267
|
+
left: ` --${flag.name}${short(flag.name)}${
|
|
268
|
+
typeof flag.initial === "boolean" ? "" : ` <${typeof flag.initial}>`
|
|
269
|
+
}`,
|
|
270
|
+
right: ` ${flag.description || ""}${extras(flag)}`,
|
|
271
|
+
})),
|
|
272
|
+
);
|
|
273
|
+
// print result
|
|
274
|
+
console.log(
|
|
275
|
+
`\n${scriptName} ` +
|
|
276
|
+
cmd.name +
|
|
277
|
+
" <flags>\n\n" +
|
|
278
|
+
"description:\n" +
|
|
279
|
+
cmd.description +
|
|
280
|
+
(cmd.help ? ("\n\nhelp:\n" + cmd.help) : "") +
|
|
281
|
+
"\n\n" +
|
|
282
|
+
"flags:\n" +
|
|
283
|
+
content +
|
|
284
|
+
"\n",
|
|
285
|
+
);
|
|
286
|
+
if (msg) {
|
|
287
|
+
console.log("ERROR:", msg);
|
|
288
|
+
console.log("");
|
|
289
|
+
}
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
// run 1 command from the command tree.
|
|
293
|
+
// this function can be called from other commands
|
|
294
|
+
async function cmd(...args: string[]) {
|
|
295
|
+
if (args.length === 0) {
|
|
296
|
+
help();
|
|
297
|
+
}
|
|
298
|
+
// stage 1, find command, split args into names/rest
|
|
299
|
+
let match = null;
|
|
300
|
+
let rest: string[] = [];
|
|
301
|
+
let names = null;
|
|
302
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
303
|
+
names = args.slice(0, i + 1);
|
|
304
|
+
rest = args.slice(i + 1);
|
|
305
|
+
const name = names.join(" ");
|
|
306
|
+
match = commands.find((c) => c.name === name);
|
|
307
|
+
if (match) {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!match) {
|
|
312
|
+
return help(`no matched command: ${args.join(" ")}`);
|
|
313
|
+
}
|
|
314
|
+
// convert command flags into a list
|
|
315
|
+
const flagSpecs = namedFlags(match.flags);
|
|
316
|
+
// always add --help
|
|
317
|
+
flagSpecs.push({
|
|
318
|
+
name: "help",
|
|
319
|
+
initial: false,
|
|
320
|
+
description: "show help",
|
|
321
|
+
});
|
|
322
|
+
// stage 2, init flags, parse rest of args
|
|
323
|
+
const cmdHelp = helpFor.bind(null, match, flagSpecs);
|
|
324
|
+
let nextFlag: namedFlag | null = null;
|
|
325
|
+
// traverse rest arguments, sorting into either flags or cmd-args
|
|
326
|
+
const flagVals: Record<string, any> = {};
|
|
327
|
+
const cmdArgs = [];
|
|
328
|
+
for (const arg of rest) {
|
|
329
|
+
// arg is flag-value
|
|
330
|
+
if (nextFlag) {
|
|
331
|
+
flagVals[nextFlag.name] = convert(arg, typeof nextFlag.initial);
|
|
332
|
+
nextFlag = null;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// arg is command-flag
|
|
336
|
+
const m = /^-(-?)(\S+)$/.exec(arg);
|
|
337
|
+
if (m) {
|
|
338
|
+
const long = Boolean(m[1]);
|
|
339
|
+
const name = m[2];
|
|
340
|
+
const fs = flagSpecs.find((f) => {
|
|
341
|
+
if (!long) {
|
|
342
|
+
const letters = name.split("");
|
|
343
|
+
return letters.includes(f.name[0]);
|
|
344
|
+
}
|
|
345
|
+
return f.name === name;
|
|
346
|
+
});
|
|
347
|
+
if (!fs) {
|
|
348
|
+
return cmdHelp(`unknown flag "${name}"`);
|
|
349
|
+
}
|
|
350
|
+
if (typeof fs.initial === "boolean") {
|
|
351
|
+
flagVals[fs.name] = true;
|
|
352
|
+
} else {
|
|
353
|
+
nextFlag = fs; // collect String/Number flags
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// arg is command-arg
|
|
358
|
+
cmdArgs.push(arg);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// missing flag value
|
|
362
|
+
if (nextFlag) {
|
|
363
|
+
cmdHelp(`missing value for flag: ${nextFlag.name}`);
|
|
364
|
+
}
|
|
365
|
+
// help requested
|
|
366
|
+
if (flagVals.help) {
|
|
367
|
+
cmdHelp();
|
|
368
|
+
}
|
|
369
|
+
// set default values
|
|
370
|
+
for (const flag of flagSpecs) {
|
|
371
|
+
const { name, env, initial } = flag;
|
|
372
|
+
if (!env || name in flagVals) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const val = process.env[env];
|
|
376
|
+
if (val) {
|
|
377
|
+
flagVals[name] = convert(val, typeof flag.initial);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (initial) {
|
|
381
|
+
flagVals[name] = initial;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// exec targets 'run' function
|
|
385
|
+
const t = timer();
|
|
386
|
+
try {
|
|
387
|
+
await match.run({
|
|
388
|
+
flags: flagVals as FlagValues<typeof match.flags>,
|
|
389
|
+
args: cmdArgs,
|
|
390
|
+
cmd,
|
|
391
|
+
cmdName: match.name,
|
|
392
|
+
help: cmdHelp,
|
|
393
|
+
});
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (typeof err === "string") {
|
|
396
|
+
cmdHelp(err);
|
|
397
|
+
}
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
console.log(`${scriptName} "${match.name}" ran in ${t}`);
|
|
401
|
+
}
|
|
402
|
+
// "root" command
|
|
403
|
+
// process.argv: [node, script, ...args]
|
|
404
|
+
const args = process.argv.slice(2);
|
|
405
|
+
// help intercept
|
|
406
|
+
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
|
407
|
+
help();
|
|
408
|
+
}
|
|
409
|
+
// normal command execution
|
|
410
|
+
try {
|
|
411
|
+
await cmd(...args);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (typeof err === "string") {
|
|
414
|
+
help(err);
|
|
415
|
+
}
|
|
416
|
+
// parse and display error
|
|
417
|
+
const eo = err as Record<string, any>;
|
|
418
|
+
if (eo && typeof eo === "object") {
|
|
419
|
+
// handle command output
|
|
420
|
+
if (typeof eo.code === "number") {
|
|
421
|
+
process.exit(eo.code);
|
|
422
|
+
}
|
|
423
|
+
// handle js error
|
|
424
|
+
const msg = err ? eo.stack || eo.message : `${err}`;
|
|
425
|
+
if (/exit with (\d+)/.test(msg)) {
|
|
426
|
+
process.exit(parseInt(RegExp.$1, 10));
|
|
427
|
+
}
|
|
428
|
+
console.log("ERROR: " + msg + "\n");
|
|
429
|
+
}
|
|
430
|
+
// caught error -> exit 1
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function Command<F extends Flags>(
|
|
436
|
+
command: TakeCommand<F>,
|
|
437
|
+
): NewCommand<F> {
|
|
438
|
+
return { ...command, flagValues: (null as any), input: (null as any) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// deno-lint-ignore no-constant-condition
|
|
442
|
+
if (42 < 7) {
|
|
443
|
+
// TYPE CHECK
|
|
444
|
+
Register(
|
|
445
|
+
Command({
|
|
446
|
+
name: "foo",
|
|
447
|
+
description: "...",
|
|
448
|
+
flags: {
|
|
449
|
+
zip: {
|
|
450
|
+
initial: 42,
|
|
451
|
+
description: "this is a test",
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
run(input) {
|
|
455
|
+
input.flags.zip; // (property) zip: number
|
|
456
|
+
},
|
|
457
|
+
}),
|
|
458
|
+
Command({
|
|
459
|
+
name: "bar",
|
|
460
|
+
description: "test command",
|
|
461
|
+
flags: {
|
|
462
|
+
zop: {
|
|
463
|
+
initial: "hello",
|
|
464
|
+
description: "string flag",
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
run(input) {
|
|
468
|
+
console.log("zop:", input.flags.zop); // (property) zop: string
|
|
469
|
+
},
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
}
|