clap-ts 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/parser.js +5 -5
- package/dist/types.d.ts +14 -1
- package/package.json +17 -1
- package/src/__tests__/arg-options.test.ts +687 -0
- package/src/__tests__/argfile.test.ts +127 -0
- package/src/__tests__/clap-parity.test.ts +682 -0
- package/src/__tests__/command-options.test.ts +713 -0
- package/src/__tests__/completions.test.ts +423 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/deprecation.test.ts +104 -0
- package/src/__tests__/help.test.ts +312 -0
- package/src/__tests__/install.test.ts +120 -0
- package/src/__tests__/log.test.ts +189 -0
- package/src/__tests__/man.test.ts +135 -0
- package/src/__tests__/markdown.test.ts +114 -0
- package/src/__tests__/output.test.ts +249 -0
- package/src/__tests__/parser.test.ts +627 -0
- package/src/__tests__/plugins.test.ts +182 -0
- package/src/__tests__/progress.test.ts +221 -0
- package/src/__tests__/prompt.test.ts +265 -0
- package/src/__tests__/runner.test.ts +459 -0
- package/src/__tests__/spec.test.ts +107 -0
- package/src/__tests__/testing.test.ts +93 -0
- package/src/__tests__/validation.test.ts +267 -0
- package/src/argfile.ts +188 -0
- package/src/completions.ts +865 -0
- package/src/config.ts +184 -0
- package/src/help.ts +779 -0
- package/src/index.ts +58 -0
- package/src/install.ts +226 -0
- package/src/log.ts +225 -0
- package/src/man.ts +289 -0
- package/src/markdown.ts +210 -0
- package/src/output.ts +453 -0
- package/src/parser.ts +1240 -0
- package/src/plugins.ts +193 -0
- package/src/progress.ts +295 -0
- package/src/prompt.ts +388 -0
- package/src/runner.ts +769 -0
- package/src/spec.ts +197 -0
- package/src/testing.ts +159 -0
- package/src/types.ts +618 -0
- package/src/validation.ts +627 -0
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommands discovered from installed packages, the way git, eslint and kubectl
|
|
3
|
+
* grow: drop `my-tool-plugin-deploy` next to the CLI and `my-tool deploy` works.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { pluginSubCommands } from 'clap-ts/plugins';
|
|
7
|
+
*
|
|
8
|
+
* const main = defineCommand({
|
|
9
|
+
* meta: { name: 'my-tool' },
|
|
10
|
+
* lazySubCommands: pluginSubCommands('my-tool'),
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Pairs with `lazySubCommands`: discovery is a directory scan and each plugin is
|
|
15
|
+
* a module import, so neither happens until a token could be a subcommand.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import type { CommandDef } from './types.js';
|
|
22
|
+
|
|
23
|
+
/** A plugin package found on disk, before it has been loaded. */
|
|
24
|
+
export interface DiscoveredPlugin {
|
|
25
|
+
/** Subcommand name, the part after the prefix. */
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/** Full package name. */
|
|
28
|
+
readonly packageName: string;
|
|
29
|
+
/** Directory the package lives in. */
|
|
30
|
+
readonly dir: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PluginOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Package name prefix. Defaults to `<tool>-plugin-`, so `my-tool-plugin-deploy`
|
|
36
|
+
* becomes the `deploy` subcommand.
|
|
37
|
+
*/
|
|
38
|
+
readonly prefix?: string;
|
|
39
|
+
/** Directories to scan. Defaults to every `node_modules` up from `cwd`. */
|
|
40
|
+
readonly searchPaths?: readonly string[];
|
|
41
|
+
/** Where to start the walk (default `process.cwd()`). */
|
|
42
|
+
readonly cwd?: string;
|
|
43
|
+
/** Load a package and return its command. Defaults to a dynamic import of the package. */
|
|
44
|
+
readonly load?: (plugin: DiscoveredPlugin) => CommandDef<any> | undefined;
|
|
45
|
+
/** Called when a plugin fails to load. Defaults to rethrowing. */
|
|
46
|
+
readonly onError?: (plugin: DiscoveredPlugin, error: unknown) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every `node_modules` directory from `cwd` up to the filesystem root. */
|
|
50
|
+
export function nodeModulesPaths(cwd: string = process.cwd()): string[] {
|
|
51
|
+
const paths: string[] = [];
|
|
52
|
+
let dir = cwd;
|
|
53
|
+
for (;;) {
|
|
54
|
+
if (!dir.endsWith(`${'/'}node_modules`)) {
|
|
55
|
+
paths.push(join(dir, 'node_modules'));
|
|
56
|
+
}
|
|
57
|
+
const parent = join(dir, '..');
|
|
58
|
+
if (parent === dir) {
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find plugin packages without loading any of them.
|
|
67
|
+
*
|
|
68
|
+
* Scoped packages are handled: `@acme/my-tool-plugin-deploy` is found under its
|
|
69
|
+
* scope directory and reported as `deploy`. The nearest copy of a package wins,
|
|
70
|
+
* matching how resolution works.
|
|
71
|
+
*/
|
|
72
|
+
export function discoverPlugins(tool: string, opts?: PluginOptions): DiscoveredPlugin[] {
|
|
73
|
+
const prefix = opts?.prefix ?? `${tool}-plugin-`;
|
|
74
|
+
const roots = opts?.searchPaths ?? nodeModulesPaths(opts?.cwd);
|
|
75
|
+
|
|
76
|
+
const found = new Map<string, DiscoveredPlugin>();
|
|
77
|
+
|
|
78
|
+
const consider = (packageName: string, dir: string): void => {
|
|
79
|
+
const base = packageName.includes('/') ? packageName.slice(packageName.indexOf('/') + 1) : packageName;
|
|
80
|
+
if (!base.startsWith(prefix) || base.length === prefix.length) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const name = base.slice(prefix.length);
|
|
84
|
+
// The first root wins, so a local copy shadows one further up.
|
|
85
|
+
if (!found.has(name)) {
|
|
86
|
+
found.set(name, { name, packageName, dir });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
let entries: string[];
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(root);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.startsWith('.')) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (entry.startsWith('@')) {
|
|
103
|
+
let scoped: string[];
|
|
104
|
+
try {
|
|
105
|
+
scoped = readdirSync(join(root, entry));
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const inner of scoped) {
|
|
110
|
+
consider(`${entry}/${inner}`, join(root, entry, inner));
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
consider(entry, join(root, entry));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read a discovered package's own name and description, for help before it loads. */
|
|
122
|
+
function packageMeta(plugin: DiscoveredPlugin): { description?: string } {
|
|
123
|
+
const pkgPath = join(plugin.dir, 'package.json');
|
|
124
|
+
if (!existsSync(pkgPath)) {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { description?: string };
|
|
129
|
+
return pkg.description === undefined ? {} : { description: pkg.description };
|
|
130
|
+
} catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultLoad(plugin: DiscoveredPlugin): CommandDef<any> | undefined {
|
|
136
|
+
const require = createRequire(join(plugin.dir, 'package.json'));
|
|
137
|
+
const loaded = require(plugin.packageName) as
|
|
138
|
+
| CommandDef<any>
|
|
139
|
+
| { default?: CommandDef<any>; command?: CommandDef<any> };
|
|
140
|
+
|
|
141
|
+
if ('meta' in loaded && loaded.meta !== undefined) {
|
|
142
|
+
return loaded as CommandDef<any>;
|
|
143
|
+
}
|
|
144
|
+
const mod = loaded as { default?: CommandDef<any>; command?: CommandDef<any> };
|
|
145
|
+
return mod.default ?? mod.command;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A thunk for `lazySubCommands` that discovers and loads plugin packages.
|
|
150
|
+
*
|
|
151
|
+
* Nothing is scanned or imported until the thunk runs, which the parser only
|
|
152
|
+
* does when a token could be a subcommand.
|
|
153
|
+
*/
|
|
154
|
+
export function pluginSubCommands(
|
|
155
|
+
tool: string,
|
|
156
|
+
opts?: PluginOptions,
|
|
157
|
+
): () => Record<string, CommandDef<any>> {
|
|
158
|
+
return () => {
|
|
159
|
+
const load = opts?.load ?? defaultLoad;
|
|
160
|
+
const commands: Record<string, CommandDef<any>> = {};
|
|
161
|
+
|
|
162
|
+
for (const plugin of discoverPlugins(tool, opts)) {
|
|
163
|
+
let command: CommandDef<any> | undefined;
|
|
164
|
+
try {
|
|
165
|
+
command = load(plugin);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (opts?.onError === undefined) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`plugin '${plugin.packageName}' failed to load: ${
|
|
170
|
+
error instanceof Error ? error.message : String(error)
|
|
171
|
+
}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
opts.onError(plugin, error);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (command === undefined) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fall back to the package description so help says something useful
|
|
183
|
+
// even when the plugin did not set one.
|
|
184
|
+
const meta = command.meta.description === undefined ? packageMeta(plugin) : {};
|
|
185
|
+
commands[plugin.name] = {
|
|
186
|
+
...command,
|
|
187
|
+
meta: { ...command.meta, name: plugin.name, ...meta },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return commands;
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spinners and progress bars that know when nobody is watching.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { spinner, progressBar } from 'clap-ts/progress';
|
|
6
|
+
*
|
|
7
|
+
* const spin = spinner('Fetching');
|
|
8
|
+
* spin.start();
|
|
9
|
+
* await work();
|
|
10
|
+
* spin.succeed('Fetched 12 items');
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Both write to stderr and both go quiet when it is not a terminal, so piping a
|
|
14
|
+
* command's output never fills a log file with redraw escapes. `NO_COLOR` and
|
|
15
|
+
* `CI` are honoured the same way.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { styleText } from 'node:util';
|
|
19
|
+
import type { OutputSink } from './types.js';
|
|
20
|
+
import { displayWidth, truncate } from './output.js';
|
|
21
|
+
|
|
22
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
23
|
+
const ASCII_FRAMES = ['-', '\\', '|', '/'];
|
|
24
|
+
|
|
25
|
+
/** A stream that can also be interrogated about being a terminal. */
|
|
26
|
+
interface MaybeTty extends OutputSink {
|
|
27
|
+
isTTY?: boolean;
|
|
28
|
+
columns?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProgressOptions {
|
|
32
|
+
/** Where to draw (default process.stderr). */
|
|
33
|
+
readonly sink?: OutputSink;
|
|
34
|
+
/**
|
|
35
|
+
* Draw at all. Defaults to true only when the sink is a terminal and CI is
|
|
36
|
+
* unset, so redirected output stays clean.
|
|
37
|
+
*/
|
|
38
|
+
readonly enabled?: boolean;
|
|
39
|
+
/** Use ASCII frames instead of braille. */
|
|
40
|
+
readonly ascii?: boolean;
|
|
41
|
+
/** Milliseconds between spinner frames (default 80). */
|
|
42
|
+
readonly interval?: number;
|
|
43
|
+
/** Force colour on or off. */
|
|
44
|
+
readonly color?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Shortest gap between redraws, in milliseconds (default 16, about 60 a
|
|
47
|
+
* second). A loop that updates thousands of times a second would otherwise
|
|
48
|
+
* spend its time writing escape sequences nobody can read.
|
|
49
|
+
*/
|
|
50
|
+
readonly throttle?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
54
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
55
|
+
const CLEAR_LINE = '\r\x1b[2K';
|
|
56
|
+
|
|
57
|
+
/** Columns available to draw into. */
|
|
58
|
+
function widthOf(sink: MaybeTty): number {
|
|
59
|
+
return typeof sink.columns === 'number' && sink.columns > 0 ? sink.columns : 80;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveSink(opts?: ProgressOptions): MaybeTty {
|
|
63
|
+
return (opts?.sink ?? process.stderr) as MaybeTty;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldDraw(sink: MaybeTty, opts?: ProgressOptions): boolean {
|
|
67
|
+
if (opts?.enabled !== undefined) {
|
|
68
|
+
return opts.enabled;
|
|
69
|
+
}
|
|
70
|
+
// A pipe, a file or a CI log gets no redraws.
|
|
71
|
+
return sink.isTTY === true && process.env['CI'] === undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function paint(color: boolean, codes: Parameters<typeof styleText>[0], text: string): string {
|
|
75
|
+
return color ? styleText(codes, text, { validateStream: false }) : text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Spinner {
|
|
79
|
+
/** Begin animating. Safe to call twice. */
|
|
80
|
+
start(text?: string): Spinner;
|
|
81
|
+
/** Change the message without interrupting the animation. */
|
|
82
|
+
update(text: string): Spinner;
|
|
83
|
+
/** Stop and leave a green tick. */
|
|
84
|
+
succeed(text?: string): Spinner;
|
|
85
|
+
/** Stop and leave a red cross. */
|
|
86
|
+
fail(text?: string): Spinner;
|
|
87
|
+
/** Stop and leave a yellow warning mark. */
|
|
88
|
+
warn(text?: string): Spinner;
|
|
89
|
+
/** Stop and erase, leaving nothing behind. */
|
|
90
|
+
stop(): Spinner;
|
|
91
|
+
/** Whether this spinner draws anything at all. */
|
|
92
|
+
readonly enabled: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A spinner for work of unknown length.
|
|
97
|
+
*
|
|
98
|
+
* When drawing is off the terminating calls still print their message once, so
|
|
99
|
+
* a piped run reports what happened without any animation.
|
|
100
|
+
*/
|
|
101
|
+
export function spinner(initialText = '', opts?: ProgressOptions): Spinner {
|
|
102
|
+
const sink = resolveSink(opts);
|
|
103
|
+
const enabled = shouldDraw(sink, opts);
|
|
104
|
+
const frames = opts?.ascii === true ? ASCII_FRAMES : FRAMES;
|
|
105
|
+
const interval = opts?.interval ?? 80;
|
|
106
|
+
const color = opts?.color ?? styleText('red', 'x') !== 'x';
|
|
107
|
+
|
|
108
|
+
let text = initialText;
|
|
109
|
+
let frame = 0;
|
|
110
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
111
|
+
let cursorHidden = false;
|
|
112
|
+
|
|
113
|
+
const restoreCursor = (): void => {
|
|
114
|
+
if (cursorHidden) {
|
|
115
|
+
sink.write(SHOW_CURSOR);
|
|
116
|
+
cursorHidden = false;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const clear = (): void => {
|
|
121
|
+
sink.write(CLEAR_LINE);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const draw = (): void => {
|
|
125
|
+
clear();
|
|
126
|
+
// Two columns for the frame and its space; anything longer wraps, and a
|
|
127
|
+
// wrapped line cannot be erased by a single carriage return.
|
|
128
|
+
const room = widthOf(sink) - 2;
|
|
129
|
+
sink.write(`${paint(color, 'cyan', frames[frame]!)} ${truncate(text, room)}`);
|
|
130
|
+
frame = (frame + 1) % frames.length;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const finish = (mark: string, codes: Parameters<typeof styleText>[0], done?: string): Spinner => {
|
|
134
|
+
if (timer !== undefined) {
|
|
135
|
+
clearInterval(timer);
|
|
136
|
+
timer = undefined;
|
|
137
|
+
}
|
|
138
|
+
const message = done ?? text;
|
|
139
|
+
if (enabled) {
|
|
140
|
+
clear();
|
|
141
|
+
restoreCursor();
|
|
142
|
+
}
|
|
143
|
+
if (message.length > 0) {
|
|
144
|
+
sink.write(`${paint(color, codes, mark)} ${message}\n`);
|
|
145
|
+
}
|
|
146
|
+
return api;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const api: Spinner = {
|
|
150
|
+
enabled,
|
|
151
|
+
start(next) {
|
|
152
|
+
if (next !== undefined) {
|
|
153
|
+
text = next;
|
|
154
|
+
}
|
|
155
|
+
if (!enabled || timer !== undefined) {
|
|
156
|
+
return api;
|
|
157
|
+
}
|
|
158
|
+
// A visible cursor flickers over the animating frame.
|
|
159
|
+
sink.write(HIDE_CURSOR);
|
|
160
|
+
cursorHidden = true;
|
|
161
|
+
draw();
|
|
162
|
+
timer = setInterval(draw, interval);
|
|
163
|
+
// Never hold the event loop open for a spinner.
|
|
164
|
+
timer.unref?.();
|
|
165
|
+
return api;
|
|
166
|
+
},
|
|
167
|
+
update(next) {
|
|
168
|
+
text = next;
|
|
169
|
+
if (enabled && timer !== undefined) {
|
|
170
|
+
draw();
|
|
171
|
+
}
|
|
172
|
+
return api;
|
|
173
|
+
},
|
|
174
|
+
succeed: (done) => finish('✔', 'green', done),
|
|
175
|
+
fail: (done) => finish('✖', 'red', done),
|
|
176
|
+
warn: (done) => finish('⚠', 'yellow', done),
|
|
177
|
+
stop() {
|
|
178
|
+
if (timer !== undefined) {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
timer = undefined;
|
|
181
|
+
}
|
|
182
|
+
if (enabled) {
|
|
183
|
+
clear();
|
|
184
|
+
restoreCursor();
|
|
185
|
+
}
|
|
186
|
+
return api;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return api;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface ProgressBar {
|
|
194
|
+
/** Move to an absolute position. */
|
|
195
|
+
update(current: number, text?: string): ProgressBar;
|
|
196
|
+
/** Move forward by an amount (default 1). */
|
|
197
|
+
tick(by?: number, text?: string): ProgressBar;
|
|
198
|
+
/** Fill the bar and finish the line. */
|
|
199
|
+
finish(text?: string): ProgressBar;
|
|
200
|
+
/** Abandon the bar, erasing it. */
|
|
201
|
+
stop(): ProgressBar;
|
|
202
|
+
/** Render the current line without drawing it, for tests. */
|
|
203
|
+
render(): string;
|
|
204
|
+
readonly enabled: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ProgressBarOptions extends ProgressOptions {
|
|
208
|
+
/** Total the bar counts up to. */
|
|
209
|
+
readonly total: number;
|
|
210
|
+
/** Bar width in characters (default: a third of the terminal, 20 to 40). */
|
|
211
|
+
readonly width?: number;
|
|
212
|
+
/** Label shown before the bar. */
|
|
213
|
+
readonly text?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A determinate progress bar.
|
|
218
|
+
*
|
|
219
|
+
* `render` returns the line it would draw, which is what makes this testable
|
|
220
|
+
* without a terminal.
|
|
221
|
+
*/
|
|
222
|
+
export function progressBar(opts: ProgressBarOptions): ProgressBar {
|
|
223
|
+
const sink = resolveSink(opts);
|
|
224
|
+
const enabled = shouldDraw(sink, opts);
|
|
225
|
+
const color = opts.color ?? styleText('red', 'x') !== 'x';
|
|
226
|
+
const total = Math.max(1, opts.total);
|
|
227
|
+
const columns = widthOf(sink);
|
|
228
|
+
const width = opts.width ?? Math.min(40, Math.max(20, Math.floor(columns / 3)));
|
|
229
|
+
const throttle = opts.throttle ?? 16;
|
|
230
|
+
|
|
231
|
+
let current = 0;
|
|
232
|
+
let text = opts.text ?? '';
|
|
233
|
+
let lastDraw = 0;
|
|
234
|
+
|
|
235
|
+
const render = (): string => {
|
|
236
|
+
const ratio = Math.min(1, current / total);
|
|
237
|
+
const filled = Math.round(ratio * width);
|
|
238
|
+
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
239
|
+
const percent = `${String(Math.round(ratio * 100)).padStart(3)}%`;
|
|
240
|
+
const counts = `${String(current)}/${String(total)}`;
|
|
241
|
+
const line = `${bar} ${percent} ${counts}${text.length > 0 ? ` ${text}` : ''}`;
|
|
242
|
+
|
|
243
|
+
// Fit the terminal: a line that wraps cannot be erased by a carriage
|
|
244
|
+
// return, so the next redraw would stack instead of replacing.
|
|
245
|
+
const fitted = displayWidth(line) > columns ? truncate(line, columns) : line;
|
|
246
|
+
return color ? fitted.replace(bar, paint(color, 'cyan', bar)) : fitted;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const draw = (force: boolean): void => {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
// The final frame always lands; the ones in between can be skipped.
|
|
252
|
+
if (!force && throttle > 0 && now - lastDraw < throttle) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
lastDraw = now;
|
|
256
|
+
sink.write(`${CLEAR_LINE}${render()}`);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const api: ProgressBar = {
|
|
260
|
+
enabled,
|
|
261
|
+
render,
|
|
262
|
+
update(next, nextText) {
|
|
263
|
+
current = Math.max(0, Math.min(total, next));
|
|
264
|
+
if (nextText !== undefined) {
|
|
265
|
+
text = nextText;
|
|
266
|
+
}
|
|
267
|
+
if (enabled) {
|
|
268
|
+
draw(false);
|
|
269
|
+
}
|
|
270
|
+
return api;
|
|
271
|
+
},
|
|
272
|
+
tick: (by = 1, nextText) => api.update(current + by, nextText),
|
|
273
|
+
finish(done) {
|
|
274
|
+
current = total;
|
|
275
|
+
if (done !== undefined) {
|
|
276
|
+
text = done;
|
|
277
|
+
}
|
|
278
|
+
if (enabled) {
|
|
279
|
+
draw(true);
|
|
280
|
+
sink.write('\n');
|
|
281
|
+
} else if (text.length > 0) {
|
|
282
|
+
sink.write(`${text}\n`);
|
|
283
|
+
}
|
|
284
|
+
return api;
|
|
285
|
+
},
|
|
286
|
+
stop() {
|
|
287
|
+
if (enabled) {
|
|
288
|
+
sink.write(CLEAR_LINE);
|
|
289
|
+
}
|
|
290
|
+
return api;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return api;
|
|
295
|
+
}
|