@jpillora/take 0.8.1 → 0.8.5

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.
Files changed (4) hide show
  1. package/package.json +4 -4
  2. package/take.mjs +408 -0
  3. package/README.md +0 -318
  4. package/take.ts +0 -472
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@jpillora/take",
3
- "version": "0.8.1",
3
+ "version": "0.8.5",
4
4
  "description": "A minimal CLI library for building TypeScript-based command-line tools",
5
5
  "type": "module",
6
- "main": "./take.ts",
7
- "exports": "./take.ts",
8
- "files": ["take.ts"],
6
+ "main": "./take.mjs",
7
+ "exports": "./take.mjs",
8
+ "files": ["take.mjs"],
9
9
  "license": "MIT",
10
10
  "repository": {
11
11
  "type": "git",
package/take.mjs ADDED
@@ -0,0 +1,408 @@
1
+ "use strict";
2
+ // Take is a mini-CLI library for building typescript-based command-line tools
3
+ // Works with Deno, Node.js (with type stripping), and Bun
4
+ // deno-lint-ignore-file no-explicit-any
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.timer = void 0;
10
+ exports.newFlags = newFlags;
11
+ exports.help = help;
12
+ exports.spawn = spawn;
13
+ exports.Register = Register;
14
+ exports.Command = Command;
15
+ const promises_1 = require("node:fs/promises");
16
+ const node_path_1 = require("node:path");
17
+ const node_child_process_1 = require("node:child_process");
18
+ const node_process_1 = __importDefault(require("node:process"));
19
+ function newFlags(flags) {
20
+ return flags;
21
+ }
22
+ function help(str) {
23
+ throw str;
24
+ }
25
+ const exit = (...args) => {
26
+ console.log(...args);
27
+ node_process_1.default.exit(1);
28
+ };
29
+ function namedFlags(record) {
30
+ return Object.entries(record).map((kv) => ({
31
+ name: kv[0],
32
+ ...kv[1],
33
+ })).toSorted((na, nb) => na.name < nb.name ? -1 : 1);
34
+ }
35
+ function convert(val, type) {
36
+ switch (type) {
37
+ case "string":
38
+ return `${val}`;
39
+ case "number": {
40
+ const f = parseFloat(val);
41
+ if (isNaN(f)) {
42
+ throw `expected a number, got: ${val}`;
43
+ }
44
+ return f;
45
+ }
46
+ case "boolean":
47
+ return Boolean(val);
48
+ default:
49
+ throw `unknown type: ${type}`;
50
+ }
51
+ }
52
+ async function spawn(options) {
53
+ return await new Promise((resolve, reject) => {
54
+ const child = (0, node_child_process_1.spawn)(options.program, options.args ?? [], options);
55
+ child.on("close", (code) => {
56
+ if (code === 0) {
57
+ resolve(0);
58
+ }
59
+ else {
60
+ reject(code);
61
+ }
62
+ });
63
+ });
64
+ }
65
+ // helper for measuring time
66
+ exports.timer = (() => {
67
+ const scale = [
68
+ [1000, "ms"],
69
+ [60, "sec"],
70
+ [60, "min"],
71
+ [24, "hr"],
72
+ ];
73
+ const fmt = (v) => {
74
+ for (const s of scale) {
75
+ const n = s[0], u = s[1];
76
+ if (v < n) {
77
+ return `${v.toFixed(2)}${u}${v == 1 || u.endsWith("s") ? "" : "s"}`;
78
+ }
79
+ v /= n;
80
+ }
81
+ throw `??`;
82
+ };
83
+ return () => {
84
+ const t0 = performance.now();
85
+ const stop = () => {
86
+ const t1 = performance.now();
87
+ return fmt(t1 - t0);
88
+ };
89
+ stop.toString = stop; // 🧙‍♂️you may omit the brackets
90
+ return stop;
91
+ };
92
+ })();
93
+ // Load .env file if it exists
94
+ async function loadEnvFile(path) {
95
+ try {
96
+ const content = await (0, promises_1.readFile)(path, "utf-8");
97
+ for (const line of content.split("\n")) {
98
+ const trimmed = line.trim();
99
+ if (!trimmed || trimmed.startsWith("#"))
100
+ continue;
101
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
102
+ if (match) {
103
+ const key = match[1].trim();
104
+ let value = match[2].trim();
105
+ // Remove quotes if present
106
+ if ((value.startsWith('"') && value.endsWith('"')) ||
107
+ (value.startsWith("'") && value.endsWith("'"))) {
108
+ value = value.slice(1, -1);
109
+ }
110
+ node_process_1.default.env[key] = value;
111
+ }
112
+ }
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ async function Register(...commands) {
120
+ // Load .env by default
121
+ await loadEnvFile(".env");
122
+ // Get script name from argv[1]
123
+ const scriptName = (0, node_path_1.basename)(node_process_1.default.argv[1] || "cli");
124
+ // validate commands
125
+ if (!Array.isArray(commands)) {
126
+ exit(`CLI(commands) must be an array`);
127
+ }
128
+ if (commands.length === 0) {
129
+ exit(`CLI(commands) must have at least 1 command`);
130
+ }
131
+ const names = new Set();
132
+ for (const c of commands) {
133
+ // validate name
134
+ if (typeof c.name !== "string") {
135
+ exit(`all CLI(commands) must have a "name"`);
136
+ }
137
+ // transform name
138
+ // list out "sub-command" names
139
+ c.names = c.name.trim().split(/\s+/g);
140
+ c.name = c.names.join(" "); // tidy name
141
+ // duplicate check
142
+ if (names.has(c.name)) {
143
+ exit(`duplicate command name: ${c.name}`);
144
+ }
145
+ names.add(c.name);
146
+ // validate function
147
+ if (typeof c.run !== "function") {
148
+ exit(`command "${c.name}" must have a "run" function`);
149
+ }
150
+ // validate flags
151
+ if (!c.flags) {
152
+ exit(`command "${c.name}" must have "flags", you can use {}`);
153
+ }
154
+ if (Array.isArray(c.flags)) {
155
+ exit(`command "${c.name}" flags must be an object, not an array`);
156
+ }
157
+ for (const [name, flag] of Object.entries(c.flags)) {
158
+ if (!flag.description) {
159
+ exit(`command "${c.name}" flag "${name}" must have a "description"`);
160
+ }
161
+ }
162
+ }
163
+ commands.sort((a, b) => (a.name < b.name ? -1 : 1));
164
+ const joinColumns = (table) => {
165
+ const max = table.reduce((m, { left }) => Math.max(m, left.length), 0);
166
+ return table
167
+ .map((item) => {
168
+ if (!item.right)
169
+ return item.left;
170
+ const pad = " ".repeat(max - item.left.length);
171
+ return `${item.left}${pad} ${item.right}`;
172
+ })
173
+ .join("\n");
174
+ };
175
+ // recursive help text builder for the command tree
176
+ function help(msg) {
177
+ // build command list
178
+ const content = joinColumns(commands.filter((cmd) => {
179
+ return cmd.name !== "debug" || node_process_1.default.env.DEBUG === "1";
180
+ }).map((cmd) => ({
181
+ left: ` • ${cmd.name}`,
182
+ right: cmd.description ? `- ${cmd.description}` : "",
183
+ })));
184
+ // print result
185
+ console.log(`\n${scriptName} <command> --help\n\n` + "commands:\n" + content + "\n");
186
+ if (msg) {
187
+ console.log("ERROR:", msg);
188
+ console.log("");
189
+ }
190
+ node_process_1.default.exit(msg ? 1 : 0);
191
+ }
192
+ // help text builder for a given command
193
+ function helpFor(cmd, flagSpecs, msg) {
194
+ // build flag help
195
+ const shorts = new Set();
196
+ const short = (name) => {
197
+ const l = name[0];
198
+ if (shorts.has(l))
199
+ return "";
200
+ shorts.add(l);
201
+ return `, -${l}`;
202
+ };
203
+ const extras = (flag) => {
204
+ const { env, initial } = flag;
205
+ const out = [];
206
+ if (env)
207
+ out.push(`env=${env}`);
208
+ if (initial)
209
+ out.push(`default=${initial}`);
210
+ return out.length ? ` (${out.join(" ")})` : "";
211
+ };
212
+ const content = joinColumns(flagSpecs.map((flag) => ({
213
+ left: ` --${flag.name}${short(flag.name)}${typeof flag.initial === "boolean" ? "" : ` <${typeof flag.initial}>`}`,
214
+ right: ` ${flag.description || ""}${extras(flag)}`,
215
+ })));
216
+ // print result
217
+ console.log(`\n${scriptName} ` +
218
+ cmd.name +
219
+ " <flags>\n\n" +
220
+ "description:\n" +
221
+ cmd.description +
222
+ (cmd.help ? ("\n\nhelp:\n" + cmd.help) : "") +
223
+ "\n\n" +
224
+ "flags:\n" +
225
+ content +
226
+ "\n");
227
+ if (msg) {
228
+ console.log("ERROR:", msg);
229
+ console.log("");
230
+ }
231
+ node_process_1.default.exit(0);
232
+ }
233
+ // run 1 command from the command tree.
234
+ // this function can be called from other commands
235
+ async function cmd(...args) {
236
+ if (args.length === 0) {
237
+ help();
238
+ }
239
+ // stage 1, find command, split args into names/rest
240
+ let match = null;
241
+ let rest = [];
242
+ let names = null;
243
+ for (let i = args.length - 1; i >= 0; i--) {
244
+ names = args.slice(0, i + 1);
245
+ rest = args.slice(i + 1);
246
+ const name = names.join(" ");
247
+ match = commands.find((c) => c.name === name);
248
+ if (match) {
249
+ break;
250
+ }
251
+ }
252
+ if (!match) {
253
+ return help(`no matched command: ${args.join(" ")}`);
254
+ }
255
+ // convert command flags into a list
256
+ const flagSpecs = namedFlags(match.flags);
257
+ // always add --help
258
+ flagSpecs.push({
259
+ name: "help",
260
+ initial: false,
261
+ description: "show help",
262
+ });
263
+ // stage 2, init flags, parse rest of args
264
+ const cmdHelp = helpFor.bind(null, match, flagSpecs);
265
+ let nextFlag = null;
266
+ // traverse rest arguments, sorting into either flags or cmd-args
267
+ const flagVals = {};
268
+ const cmdArgs = [];
269
+ for (const arg of rest) {
270
+ // arg is flag-value
271
+ if (nextFlag) {
272
+ flagVals[nextFlag.name] = convert(arg, typeof nextFlag.initial);
273
+ nextFlag = null;
274
+ continue;
275
+ }
276
+ // arg is command-flag
277
+ const m = /^-(-?)(\S+)$/.exec(arg);
278
+ if (m) {
279
+ const long = Boolean(m[1]);
280
+ const name = m[2];
281
+ const fs = flagSpecs.find((f) => {
282
+ if (!long) {
283
+ const letters = name.split("");
284
+ return letters.includes(f.name[0]);
285
+ }
286
+ return f.name === name;
287
+ });
288
+ if (!fs) {
289
+ return cmdHelp(`unknown flag "${name}"`);
290
+ }
291
+ if (typeof fs.initial === "boolean") {
292
+ flagVals[fs.name] = true;
293
+ }
294
+ else {
295
+ nextFlag = fs; // collect String/Number flags
296
+ }
297
+ continue;
298
+ }
299
+ // arg is command-arg
300
+ cmdArgs.push(arg);
301
+ continue;
302
+ }
303
+ // missing flag value
304
+ if (nextFlag) {
305
+ cmdHelp(`missing value for flag: ${nextFlag.name}`);
306
+ }
307
+ // help requested
308
+ if (flagVals.help) {
309
+ cmdHelp();
310
+ }
311
+ // set default values
312
+ for (const flag of flagSpecs) {
313
+ const { name, env, initial } = flag;
314
+ if (name in flagVals) {
315
+ continue;
316
+ }
317
+ if (env && node_process_1.default.env[env]) {
318
+ flagVals[name] = convert(node_process_1.default.env[env], typeof initial);
319
+ continue;
320
+ }
321
+ if (initial !== undefined) {
322
+ flagVals[name] = initial;
323
+ }
324
+ }
325
+ // exec targets 'run' function
326
+ const t = (0, exports.timer)();
327
+ try {
328
+ await match.run({
329
+ flags: flagVals,
330
+ args: cmdArgs,
331
+ cmd,
332
+ cmdName: match.name,
333
+ help: cmdHelp,
334
+ });
335
+ }
336
+ catch (err) {
337
+ if (typeof err === "string") {
338
+ cmdHelp(err);
339
+ }
340
+ throw err;
341
+ }
342
+ console.log(`${scriptName} "${match.name}" ran in ${t}`);
343
+ }
344
+ // "root" command
345
+ // process.argv: [node, script, ...args]
346
+ const args = node_process_1.default.argv.slice(2);
347
+ // help intercept
348
+ if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
349
+ help();
350
+ }
351
+ // normal command execution
352
+ try {
353
+ await cmd(...args);
354
+ }
355
+ catch (err) {
356
+ if (typeof err === "string") {
357
+ help(err);
358
+ }
359
+ // parse and display error
360
+ const eo = err;
361
+ if (eo && typeof eo === "object") {
362
+ // handle command output
363
+ if (typeof eo.code === "number") {
364
+ node_process_1.default.exit(eo.code);
365
+ }
366
+ // handle js error
367
+ const msg = err ? eo.stack || eo.message : `${err}`;
368
+ if (/exit with (\d+)/.test(msg)) {
369
+ node_process_1.default.exit(parseInt(RegExp.$1, 10));
370
+ }
371
+ console.log("ERROR: " + msg + "\n");
372
+ }
373
+ // caught error -> exit 1
374
+ node_process_1.default.exit(1);
375
+ }
376
+ }
377
+ function Command(command) {
378
+ return { ...command, flagValues: null, input: null };
379
+ }
380
+ // deno-lint-ignore no-constant-condition
381
+ if (42 < 7) {
382
+ // TYPE CHECK
383
+ Register(Command({
384
+ name: "foo",
385
+ description: "...",
386
+ flags: {
387
+ zip: {
388
+ initial: 42,
389
+ description: "this is a test",
390
+ },
391
+ },
392
+ run(input) {
393
+ input.flags.zip; // (property) zip: number
394
+ },
395
+ }), Command({
396
+ name: "bar",
397
+ description: "test command",
398
+ flags: {
399
+ zop: {
400
+ initial: "hello",
401
+ description: "string flag",
402
+ },
403
+ },
404
+ run(input) {
405
+ console.log("zop:", input.flags.zop); // (property) zop: string
406
+ },
407
+ }));
408
+ }
package/README.md DELETED
@@ -1,318 +0,0 @@
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/take.ts DELETED
@@ -1,472 +0,0 @@
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
- }