@oclif/multi-stage-output 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Salesforce.com
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,23 @@
1
+ <img src="https://user-images.githubusercontent.com/449385/38243295-e0a47d58-372e-11e8-9bc0-8c02a6f4d2ac.png" width="260" height="73">
2
+
3
+ [![Version](https://img.shields.io/npm/v/@oclif/multi-stage-output.svg)](https://npmjs.org/package/@oclif/multi-stage-output)
4
+ [![Downloads/week](https://img.shields.io/npm/dw/@oclif/multi-stage-output.svg)](https://npmjs.org/package/@oclif/multi-stage-output)
5
+ [![License](https://img.shields.io/npm/l/@oclif/multi-stage-output.svg)](https://github.com/oclif/multi-stage-output/blob/main/LICENSE)
6
+
7
+ # Description
8
+
9
+ This is a framework for showing multi-stage output in the terminal. It's integrated with oclif's builtin [Performance](https://oclif.io/docs/performance/) capabilities so that perf metrics are automatically captured for each stage.
10
+
11
+ # Examples
12
+
13
+ You can see examples of how to use it in the [examples](./examples/) directory.
14
+
15
+ You can run any of these with the following:
16
+
17
+ ```
18
+ node --loader=ts-node/esm examples/basic.ts
19
+ ```
20
+
21
+ # Contributing
22
+
23
+ See the [contributing guide](./CONRTIBUTING.md).
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ export declare function Divider({ dividerChar, dividerColor, padding, title, titleColor, titlePadding, width, }: {
3
+ readonly title?: string;
4
+ readonly width?: number | 'full';
5
+ readonly padding?: number;
6
+ readonly titleColor?: string;
7
+ readonly titlePadding?: number;
8
+ readonly dividerChar?: string;
9
+ readonly dividerColor?: string;
10
+ }): React.ReactNode;
@@ -0,0 +1,22 @@
1
+ import { Box, Text } from 'ink';
2
+ import React from 'react';
3
+ const getSideDividerWidth = (width, titleWidth) => (width - titleWidth) / 2;
4
+ const getNumberOfCharsPerWidth = (char, width) => width / char.length;
5
+ const PAD = ' ';
6
+ export function Divider({ dividerChar = '─', dividerColor = 'dim', padding = 1, title = '', titleColor = 'white', titlePadding = 1, width = 50, }) {
7
+ const titleString = title ? `${PAD.repeat(titlePadding) + title + PAD.repeat(titlePadding)}` : '';
8
+ const titleWidth = titleString.length;
9
+ const terminalWidth = process.stdout.columns ?? 80;
10
+ const widthToUse = width === 'full' ? terminalWidth - titlePadding : width > terminalWidth ? terminalWidth : width;
11
+ const dividerWidth = getSideDividerWidth(widthToUse, titleWidth);
12
+ const numberOfCharsPerSide = getNumberOfCharsPerWidth(dividerChar, dividerWidth);
13
+ const dividerSideString = dividerChar.repeat(numberOfCharsPerSide);
14
+ const paddingString = PAD.repeat(padding);
15
+ return (React.createElement(Box, { flexDirection: "row" },
16
+ React.createElement(Text, null,
17
+ paddingString,
18
+ React.createElement(Text, { color: dividerColor }, dividerSideString),
19
+ React.createElement(Text, { color: titleColor }, titleString),
20
+ React.createElement(Text, { color: dividerColor }, dividerSideString),
21
+ paddingString)));
22
+ }
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import { StageTracker } from '../stage-tracker.js';
3
+ type Info<T extends Record<string, unknown>> = {
4
+ /**
5
+ * key-value: Display a key-value pair with a spinner.
6
+ * static-key-value: Display a key-value pair without a spinner.
7
+ * message: Display a message.
8
+ */
9
+ type: 'dynamic-key-value' | 'static-key-value' | 'message';
10
+ /**
11
+ * Color of the value.
12
+ */
13
+ color?: string;
14
+ /**
15
+ * Get the value to display. Takes the data property on the MultiStageComponent as an argument.
16
+ * Useful if you want to apply some logic (like rendering a link) to the data before displaying it.
17
+ *
18
+ * @param data The data property on the MultiStageComponent.
19
+ * @returns {string | undefined}
20
+ */
21
+ get: (data?: T) => string | undefined;
22
+ /**
23
+ * Whether the value should be bold.
24
+ */
25
+ bold?: boolean;
26
+ };
27
+ type KeyValuePair<T extends Record<string, unknown>> = Info<T> & {
28
+ /**
29
+ * Label of the key-value pair.
30
+ */
31
+ label: string;
32
+ type: 'dynamic-key-value' | 'static-key-value';
33
+ };
34
+ type SimpleMessage<T extends Record<string, unknown>> = Info<T> & {
35
+ type: 'message';
36
+ };
37
+ type StageInfoBlock<T extends Record<string, unknown>> = Array<(KeyValuePair<T> & {
38
+ stage: string;
39
+ }) | (SimpleMessage<T> & {
40
+ stage: string;
41
+ })>;
42
+ export type FormattedKeyValue = {
43
+ readonly color?: string;
44
+ readonly isBold?: boolean;
45
+ readonly label?: string;
46
+ readonly value: string | undefined;
47
+ readonly stage?: string;
48
+ readonly type: 'dynamic-key-value' | 'static-key-value' | 'message';
49
+ };
50
+ type MultiStageComponentOptions<T extends Record<string, unknown>> = {
51
+ /**
52
+ * Stages to render.
53
+ */
54
+ readonly stages: readonly string[] | string[];
55
+ /**
56
+ * Title to display at the top of the stages component.
57
+ */
58
+ readonly title: string;
59
+ /**
60
+ * Information to display at the bottom of the stages component.
61
+ */
62
+ readonly postStagesBlock?: Array<KeyValuePair<T> | SimpleMessage<T>>;
63
+ /**
64
+ * Information to display below the title but above the stages.
65
+ */
66
+ readonly preStagesBlock?: Array<KeyValuePair<T> | SimpleMessage<T>>;
67
+ /**
68
+ * Whether to show the total elapsed time. Defaults to true
69
+ */
70
+ readonly showElapsedTime?: boolean;
71
+ /**
72
+ * Whether to show the time spent on each stage. Defaults to true
73
+ */
74
+ readonly showStageTime?: boolean;
75
+ /**
76
+ * Information to display for a specific stage. Each object must have a stage property set.
77
+ */
78
+ readonly stageSpecificBlock?: StageInfoBlock<T>;
79
+ /**
80
+ * The unit to use for the timer. Defaults to 'ms'
81
+ */
82
+ readonly timerUnit?: 'ms' | 's';
83
+ /**
84
+ * Data to display in the stages component. This data will be passed to the get function in the info object.
85
+ */
86
+ readonly data?: Partial<T>;
87
+ /**
88
+ * Whether JSON output is enabled. Defaults to false.
89
+ *
90
+ * Pass in this.jsonEnabled() from the command class to determine if JSON output is enabled.
91
+ */
92
+ readonly jsonEnabled: boolean;
93
+ /**
94
+ * Whether to override the CI detection and force the component to render as if it's in a CI environment.
95
+ */
96
+ readonly isInCiOverride?: boolean;
97
+ };
98
+ type StagesProps = {
99
+ readonly error?: Error | undefined;
100
+ readonly postStagesBlock?: FormattedKeyValue[];
101
+ readonly preStagesBlock?: FormattedKeyValue[];
102
+ readonly stageSpecificBlock?: FormattedKeyValue[];
103
+ readonly title: string;
104
+ readonly hasElapsedTime?: boolean;
105
+ readonly hasStageTime?: boolean;
106
+ readonly timerUnit?: 'ms' | 's';
107
+ readonly stageTracker: StageTracker;
108
+ };
109
+ declare function SimpleMessage({ color, isBold, value }: FormattedKeyValue): React.ReactNode;
110
+ export declare function Stages({ error, hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit, title, }: StagesProps): React.ReactNode;
111
+ export declare class MultiStageOutput<T extends Record<string, unknown>> implements Disposable {
112
+ private ciInstance;
113
+ private data?;
114
+ private readonly hasElapsedTime?;
115
+ private readonly hasStageTime?;
116
+ private inkInstance;
117
+ private readonly isInCi;
118
+ private readonly postStagesBlock?;
119
+ private readonly preStagesBlock?;
120
+ private readonly stages;
121
+ private readonly stageSpecificBlock?;
122
+ private stageTracker;
123
+ private stopped;
124
+ private readonly timerUnit?;
125
+ private readonly title;
126
+ constructor({ data, isInCiOverride, jsonEnabled, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stageSpecificBlock, stages, timerUnit, title, }: MultiStageComponentOptions<T>);
127
+ goto(stage: string, data?: Partial<T>): void;
128
+ next(data?: Partial<T>): void;
129
+ stop(error?: Error): void;
130
+ [Symbol.dispose](): void;
131
+ updateData(data: Partial<T>): void;
132
+ private formatKeyValuePairs;
133
+ private update;
134
+ }
135
+ export {};
@@ -0,0 +1,312 @@
1
+ import { ux } from '@oclif/core/ux';
2
+ import { capitalCase } from 'change-case';
3
+ import { Box, Text, render } from 'ink';
4
+ import { env } from 'node:process';
5
+ import React from 'react';
6
+ import { icons, spinners } from '../design-elements.js';
7
+ import { StageTracker } from '../stage-tracker.js';
8
+ import { readableTime } from '../utils.js';
9
+ import { Divider } from './divider.js';
10
+ import { SpinnerOrError, SpinnerOrErrorOrChildren } from './spinner.js';
11
+ import { Timer } from './timer.js';
12
+ // Taken from https://github.com/sindresorhus/is-in-ci
13
+ const isInCi = env.CI !== '0' &&
14
+ env.CI !== 'false' &&
15
+ ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_')));
16
+ function StaticKeyValue({ color, isBold, label, value }) {
17
+ if (!value)
18
+ return;
19
+ return (React.createElement(Box, { key: label },
20
+ React.createElement(Text, { bold: isBold },
21
+ label,
22
+ ": "),
23
+ React.createElement(Text, { color: color }, value)));
24
+ }
25
+ function SimpleMessage({ color, isBold, value }) {
26
+ if (!value)
27
+ return;
28
+ return (React.createElement(Text, { bold: isBold, color: color }, value));
29
+ }
30
+ function Infos({ error, keyValuePairs, stage, }) {
31
+ return (keyValuePairs
32
+ // If stage is provided, only show info for that stage
33
+ // otherwise, show all infos that don't have a specified stage
34
+ .filter((kv) => (stage ? kv.stage === stage : !kv.stage))
35
+ .map((kv) => {
36
+ const key = `${kv.label}-${kv.value}`;
37
+ if (kv.type === 'message') {
38
+ return React.createElement(SimpleMessage, { key: key, ...kv });
39
+ }
40
+ if (kv.type === 'dynamic-key-value') {
41
+ return (React.createElement(SpinnerOrErrorOrChildren, { key: key, error: error, label: `${kv.label}: `, labelPosition: "left", type: spinners.info }, kv.value && (React.createElement(Text, { bold: kv.isBold, color: kv.color }, kv.value))));
42
+ }
43
+ if (kv.type === 'static-key-value') {
44
+ return React.createElement(StaticKeyValue, { key: key, ...kv });
45
+ }
46
+ return null;
47
+ }));
48
+ }
49
+ export function Stages({ error, hasElapsedTime = true, hasStageTime = true, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit = 'ms', title, }) {
50
+ return (React.createElement(Box, { flexDirection: "column", paddingTop: 1 },
51
+ React.createElement(Divider, { title: title }),
52
+ preStagesBlock && preStagesBlock.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingTop: 1 },
53
+ React.createElement(Infos, { error: error, keyValuePairs: preStagesBlock }))),
54
+ React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingTop: 1 }, [...stageTracker.entries()].map(([stage, status]) => (React.createElement(Box, { key: stage, flexDirection: "column" },
55
+ React.createElement(Box, null,
56
+ (status === 'current' || status === 'failed') && (React.createElement(SpinnerOrError, { error: error, label: capitalCase(stage), type: spinners.stage })),
57
+ status === 'skipped' && (React.createElement(Text, { color: "dim" },
58
+ icons.skipped,
59
+ " ",
60
+ capitalCase(stage),
61
+ " - Skipped")),
62
+ status === 'completed' && (React.createElement(Box, null,
63
+ React.createElement(Text, { color: "green" }, icons.completed),
64
+ React.createElement(Text, null, capitalCase(stage)))),
65
+ status === 'pending' && (React.createElement(Text, { color: "dim" },
66
+ icons.pending,
67
+ " ",
68
+ capitalCase(stage))),
69
+ status !== 'pending' && status !== 'skipped' && hasStageTime && (React.createElement(Box, null,
70
+ React.createElement(Text, null, " "),
71
+ React.createElement(Timer, { color: "dim", isStopped: status === 'completed', unit: timerUnit })))),
72
+ stageSpecificBlock && stageSpecificBlock.length > 0 && status !== 'pending' && status !== 'skipped' && (React.createElement(Box, { flexDirection: "column", marginLeft: 5 },
73
+ React.createElement(Infos, { error: error, keyValuePairs: stageSpecificBlock, stage: stage }))))))),
74
+ postStagesBlock && postStagesBlock.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingTop: 1 },
75
+ React.createElement(Infos, { error: error, keyValuePairs: postStagesBlock }))),
76
+ hasElapsedTime && (React.createElement(Box, { marginLeft: 1, paddingTop: 1 },
77
+ React.createElement(Text, null, "Elapsed Time: "),
78
+ React.createElement(Timer, { unit: timerUnit })))));
79
+ }
80
+ class CIMultiStageOutput {
81
+ data;
82
+ hasElapsedTime;
83
+ hasStageTime;
84
+ lastUpdateTime;
85
+ messageTimeout = Number.parseInt(env.SF_CI_MESSAGE_TIMEOUT ?? '5000', 10) ?? 5000;
86
+ postStagesBlock;
87
+ preStagesBlock;
88
+ seenStages = new Set();
89
+ stages;
90
+ stageSpecificBlock;
91
+ startTime;
92
+ startTimes = new Map();
93
+ timerUnit;
94
+ title;
95
+ constructor({ data, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stageSpecificBlock, stages, timerUnit, title, }) {
96
+ this.title = title;
97
+ this.stages = stages;
98
+ this.postStagesBlock = postStagesBlock;
99
+ this.preStagesBlock = preStagesBlock;
100
+ this.hasElapsedTime = showElapsedTime ?? true;
101
+ this.hasStageTime = showStageTime ?? true;
102
+ this.stageSpecificBlock = stageSpecificBlock;
103
+ this.timerUnit = timerUnit ?? 'ms';
104
+ this.data = data;
105
+ this.lastUpdateTime = Date.now();
106
+ ux.stdout(`───── ${this.title} ─────`);
107
+ ux.stdout('Steps:');
108
+ for (const stage of this.stages) {
109
+ ux.stdout(`${this.stages.indexOf(stage) + 1}. ${capitalCase(stage)}`);
110
+ }
111
+ ux.stdout();
112
+ if (this.hasElapsedTime) {
113
+ this.startTime = Date.now();
114
+ }
115
+ }
116
+ stop(stageTracker) {
117
+ this.update(stageTracker);
118
+ if (this.startTime) {
119
+ const elapsedTime = Date.now() - this.startTime;
120
+ ux.stdout();
121
+ const displayTime = readableTime(elapsedTime, this.timerUnit);
122
+ ux.stdout(`Elapsed time: ${displayTime}`);
123
+ ux.stdout();
124
+ }
125
+ this.printInfo(this.preStagesBlock);
126
+ this.printInfo(this.postStagesBlock);
127
+ }
128
+ update(stageTracker, data) {
129
+ this.data = { ...this.data, ...data };
130
+ for (const [stage, status] of stageTracker.entries()) {
131
+ // no need to re-render completed, failed, or skipped stages
132
+ if (this.seenStages.has(stage))
133
+ continue;
134
+ switch (status) {
135
+ case 'pending': {
136
+ // do nothing
137
+ break;
138
+ }
139
+ case 'current': {
140
+ if (Date.now() - this.lastUpdateTime < this.messageTimeout)
141
+ break;
142
+ this.lastUpdateTime = Date.now();
143
+ if (!this.startTimes.has(stage))
144
+ this.startTimes.set(stage, Date.now());
145
+ ux.stdout(`${icons.current} ${capitalCase(stage)}...`);
146
+ this.printInfo(this.preStagesBlock, 3);
147
+ this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
148
+ this.printInfo(this.postStagesBlock, 3);
149
+ break;
150
+ }
151
+ case 'failed':
152
+ case 'skipped':
153
+ case 'completed': {
154
+ this.seenStages.add(stage);
155
+ if (this.hasStageTime && status !== 'skipped') {
156
+ const startTime = this.startTimes.get(stage);
157
+ const elapsedTime = startTime ? Date.now() - startTime : 0;
158
+ const displayTime = readableTime(elapsedTime, this.timerUnit);
159
+ ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`);
160
+ this.printInfo(this.preStagesBlock, 3);
161
+ this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
162
+ this.printInfo(this.postStagesBlock, 3);
163
+ }
164
+ else if (status === 'skipped') {
165
+ ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`);
166
+ }
167
+ else {
168
+ ux.stdout(`${icons[status]} ${capitalCase(stage)}`);
169
+ this.printInfo(this.preStagesBlock, 3);
170
+ this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
171
+ this.printInfo(this.postStagesBlock, 3);
172
+ }
173
+ break;
174
+ }
175
+ default:
176
+ // do nothing
177
+ }
178
+ }
179
+ }
180
+ printInfo(infoBlock, indent = 0) {
181
+ const spaces = ' '.repeat(indent);
182
+ if (infoBlock?.length) {
183
+ for (const info of infoBlock) {
184
+ const formattedData = info.get ? info.get(this.data) : undefined;
185
+ if (!formattedData)
186
+ continue;
187
+ if (info.type === 'message') {
188
+ ux.stdout(`${spaces}${formattedData}`);
189
+ }
190
+ else {
191
+ ux.stdout(`${spaces}${info.label}: ${formattedData}`);
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ export class MultiStageOutput {
198
+ ciInstance;
199
+ data;
200
+ hasElapsedTime;
201
+ hasStageTime;
202
+ inkInstance;
203
+ isInCi;
204
+ postStagesBlock;
205
+ preStagesBlock;
206
+ stages;
207
+ stageSpecificBlock;
208
+ stageTracker;
209
+ stopped = false;
210
+ timerUnit;
211
+ title;
212
+ constructor({ data, isInCiOverride, jsonEnabled, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stageSpecificBlock, stages, timerUnit, title, }) {
213
+ this.data = data;
214
+ this.stages = stages;
215
+ this.title = title;
216
+ this.postStagesBlock = postStagesBlock;
217
+ this.preStagesBlock = preStagesBlock;
218
+ this.hasElapsedTime = showElapsedTime ?? true;
219
+ this.hasStageTime = showStageTime ?? true;
220
+ this.timerUnit = timerUnit ?? 'ms';
221
+ this.stageTracker = new StageTracker(stages);
222
+ this.stageSpecificBlock = stageSpecificBlock;
223
+ this.isInCi = isInCiOverride ?? isInCi;
224
+ if (jsonEnabled)
225
+ return;
226
+ if (this.isInCi) {
227
+ this.ciInstance = new CIMultiStageOutput({
228
+ data,
229
+ jsonEnabled,
230
+ postStagesBlock,
231
+ preStagesBlock,
232
+ showElapsedTime,
233
+ showStageTime,
234
+ stageSpecificBlock,
235
+ stages,
236
+ timerUnit,
237
+ title,
238
+ });
239
+ }
240
+ else {
241
+ this.inkInstance = render(React.createElement(Stages, { hasElapsedTime: this.hasElapsedTime, hasStageTime: this.hasStageTime, postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), stageTracker: this.stageTracker, timerUnit: this.timerUnit, title: this.title }));
242
+ }
243
+ }
244
+ goto(stage, data) {
245
+ if (this.stopped)
246
+ return;
247
+ // ignore non-existent stages
248
+ if (!this.stages.includes(stage))
249
+ return;
250
+ // prevent going to a previous stage
251
+ if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0]))
252
+ return;
253
+ this.update(stage, data);
254
+ }
255
+ next(data) {
256
+ if (this.stopped)
257
+ return;
258
+ const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1;
259
+ if (nextStageIndex < this.stages.length) {
260
+ this.update(this.stages[nextStageIndex], data);
261
+ }
262
+ }
263
+ stop(error) {
264
+ if (this.stopped)
265
+ return;
266
+ this.stopped = true;
267
+ this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], { hasError: Boolean(error), isStopping: true });
268
+ if (this.isInCi) {
269
+ this.ciInstance?.stop(this.stageTracker);
270
+ return;
271
+ }
272
+ if (error) {
273
+ this.inkInstance?.rerender(React.createElement(Stages, { error: error, hasElapsedTime: this.hasElapsedTime, hasStageTime: this.hasStageTime, postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), stageTracker: this.stageTracker, timerUnit: this.timerUnit, title: this.title }));
274
+ }
275
+ else {
276
+ this.inkInstance?.rerender(React.createElement(Stages, { hasElapsedTime: this.hasElapsedTime, hasStageTime: this.hasStageTime, postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), stageTracker: this.stageTracker, timerUnit: this.timerUnit, title: this.title }));
277
+ }
278
+ this.inkInstance?.unmount();
279
+ }
280
+ [Symbol.dispose]() {
281
+ this.inkInstance?.unmount();
282
+ }
283
+ updateData(data) {
284
+ if (this.stopped)
285
+ return;
286
+ this.data = { ...this.data, ...data };
287
+ this.update(this.stageTracker.current ?? this.stages[0], data);
288
+ }
289
+ formatKeyValuePairs(infoBlock) {
290
+ return (infoBlock?.map((info) => {
291
+ const formattedData = info.get ? info.get(this.data) : undefined;
292
+ return {
293
+ color: info.color,
294
+ isBold: info.bold,
295
+ type: info.type,
296
+ value: formattedData,
297
+ ...(info.type === 'message' ? {} : { label: info.label }),
298
+ ...('stage' in info ? { stage: info.stage } : {}),
299
+ };
300
+ }) ?? []);
301
+ }
302
+ update(stage, data) {
303
+ this.data = { ...this.data, ...data };
304
+ this.stageTracker.refresh(stage);
305
+ if (this.isInCi) {
306
+ this.ciInstance?.update(this.stageTracker, this.data);
307
+ }
308
+ else {
309
+ this.inkInstance?.rerender(React.createElement(Stages, { hasElapsedTime: this.hasElapsedTime, hasStageTime: this.hasStageTime, postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), stageTracker: this.stageTracker, timerUnit: this.timerUnit, title: this.title }));
310
+ }
311
+ }
312
+ }
@@ -0,0 +1,31 @@
1
+ import { type SpinnerName } from 'cli-spinners';
2
+ import React from 'react';
3
+ export type UseSpinnerProps = {
4
+ /**
5
+ * Type of a spinner.
6
+ * See [cli-spinners](https://github.com/sindresorhus/cli-spinners) for available spinners.
7
+ *
8
+ * @default dots
9
+ */
10
+ readonly type?: SpinnerName;
11
+ };
12
+ export type UseSpinnerResult = {
13
+ frame: string;
14
+ };
15
+ export declare function useSpinner({ type }: UseSpinnerProps): UseSpinnerResult;
16
+ export type SpinnerProps = UseSpinnerProps & {
17
+ /**
18
+ * Label to show near the spinner.
19
+ */
20
+ readonly label?: string;
21
+ readonly isBold?: boolean;
22
+ readonly labelPosition?: 'left' | 'right';
23
+ };
24
+ export declare function Spinner({ isBold, label, labelPosition, type }: SpinnerProps): React.ReactElement;
25
+ export declare function SpinnerOrError({ error, labelPosition, ...props }: SpinnerProps & {
26
+ readonly error?: Error;
27
+ }): React.ReactElement;
28
+ export declare function SpinnerOrErrorOrChildren({ children, error, ...props }: SpinnerProps & {
29
+ readonly children?: React.ReactNode;
30
+ readonly error?: Error;
31
+ }): React.ReactElement;
@@ -0,0 +1,59 @@
1
+ import spinners from 'cli-spinners';
2
+ import { Box, Text } from 'ink';
3
+ import React, { useEffect, useState } from 'react';
4
+ import { icons } from '../design-elements.js';
5
+ export function useSpinner({ type = 'dots' }) {
6
+ const [frame, setFrame] = useState(0);
7
+ const spinner = spinners[type];
8
+ useEffect(() => {
9
+ const timer = setInterval(() => {
10
+ setFrame((previousFrame) => {
11
+ const isLastFrame = previousFrame === spinner.frames.length - 1;
12
+ return isLastFrame ? 0 : previousFrame + 1;
13
+ });
14
+ }, spinner.interval);
15
+ return () => {
16
+ clearInterval(timer);
17
+ };
18
+ }, [spinner]);
19
+ return {
20
+ frame: spinner.frames[frame] ?? '',
21
+ };
22
+ }
23
+ export function Spinner({ isBold, label, labelPosition = 'right', type }) {
24
+ const { frame } = useSpinner({ type });
25
+ return (React.createElement(Box, null,
26
+ label && labelPosition === 'left' && React.createElement(Text, null,
27
+ label,
28
+ " "),
29
+ isBold ? (React.createElement(Text, { bold: true, color: "magenta" }, frame)) : (React.createElement(Text, { color: "magenta" }, frame)),
30
+ label && labelPosition === 'right' && React.createElement(Text, null,
31
+ " ",
32
+ label)));
33
+ }
34
+ export function SpinnerOrError({ error, labelPosition = 'right', ...props }) {
35
+ if (error) {
36
+ return (React.createElement(Box, null,
37
+ props.label && labelPosition === 'left' && React.createElement(Text, null,
38
+ props.label,
39
+ " "),
40
+ React.createElement(Text, { color: "red" }, icons.failed),
41
+ props.label && labelPosition === 'right' && React.createElement(Text, null,
42
+ " ",
43
+ props.label)));
44
+ }
45
+ return React.createElement(Spinner, { labelPosition: labelPosition, ...props });
46
+ }
47
+ export function SpinnerOrErrorOrChildren({ children, error, ...props }) {
48
+ if (children) {
49
+ return (React.createElement(Box, null,
50
+ props.label && props.labelPosition === 'left' && React.createElement(Text, null,
51
+ props.label,
52
+ " "),
53
+ children,
54
+ props.label && props.labelPosition === 'right' && React.createElement(Text, null,
55
+ " ",
56
+ props.label)));
57
+ }
58
+ return React.createElement(SpinnerOrError, { error: error, ...props });
59
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ export declare function Timer({ color, isStopped, unit, }: {
3
+ readonly color?: string;
4
+ readonly isStopped?: boolean;
5
+ readonly unit: 'ms' | 's';
6
+ }): React.ReactNode;
@@ -0,0 +1,22 @@
1
+ import { Text } from 'ink';
2
+ import React from 'react';
3
+ import { readableTime } from '../utils.js';
4
+ export function Timer({ color, isStopped, unit, }) {
5
+ const [time, setTime] = React.useState(0);
6
+ const [previousDate, setPreviousDate] = React.useState(Date.now());
7
+ React.useEffect(() => {
8
+ if (isStopped) {
9
+ setTime(time + (Date.now() - previousDate));
10
+ setPreviousDate(Date.now());
11
+ return () => { };
12
+ }
13
+ const intervalId = setInterval(() => {
14
+ setTime(time + (Date.now() - previousDate));
15
+ setPreviousDate(Date.now());
16
+ }, unit === 'ms' ? 1 : 1000);
17
+ return () => {
18
+ clearInterval(intervalId);
19
+ };
20
+ }, [time, isStopped, previousDate, unit]);
21
+ return React.createElement(Text, { color: color }, readableTime(time, unit));
22
+ }
@@ -0,0 +1,9 @@
1
+ import { type SpinnerName } from 'cli-spinners';
2
+ export declare const icons: {
3
+ completed: string;
4
+ current: string;
5
+ failed: string;
6
+ pending: string;
7
+ skipped: string;
8
+ };
9
+ export declare const spinners: Record<string, SpinnerName>;
@@ -0,0 +1,12 @@
1
+ import figures from 'figures';
2
+ export const icons = {
3
+ completed: figures.tick,
4
+ current: figures.play,
5
+ failed: figures.cross,
6
+ pending: figures.squareSmallFilled,
7
+ skipped: figures.circle,
8
+ };
9
+ export const spinners = {
10
+ info: process.platform === 'win32' ? 'line' : 'arc',
11
+ stage: process.platform === 'win32' ? 'line' : 'dots2',
12
+ };
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { MultiStageOutput } from './components/multi-stage-output.js';
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export { MultiStageOutput } from './components/multi-stage-output.js';
@@ -0,0 +1,12 @@
1
+ export type StageStatus = 'pending' | 'current' | 'completed' | 'skipped' | 'failed';
2
+ export declare class StageTracker extends Map<string, StageStatus> {
3
+ current: string | undefined;
4
+ private markers;
5
+ constructor(stages: readonly string[] | string[]);
6
+ refresh(nextStage: string, opts?: {
7
+ hasError?: boolean;
8
+ isStopping?: boolean;
9
+ }): void;
10
+ set(stage: string, status: StageStatus): this;
11
+ private stopMarker;
12
+ }
@@ -0,0 +1,63 @@
1
+ import { Performance } from '@oclif/core/performance';
2
+ export class StageTracker extends Map {
3
+ current;
4
+ markers = new Map();
5
+ constructor(stages) {
6
+ super(stages.map((stage) => [stage, 'pending']));
7
+ }
8
+ refresh(nextStage, opts) {
9
+ const stages = [...this.keys()];
10
+ for (const stage of stages) {
11
+ if (this.get(stage) === 'skipped')
12
+ continue;
13
+ if (this.get(stage) === 'failed')
14
+ continue;
15
+ // .stop() was called with an error => set the stage to failed
16
+ if (nextStage === stage && opts?.hasError) {
17
+ this.set(stage, 'failed');
18
+ this.stopMarker(stage);
19
+ continue;
20
+ }
21
+ // .stop() was called without an error => set the stage to completed
22
+ if (nextStage === stage && opts?.isStopping) {
23
+ this.set(stage, 'completed');
24
+ this.stopMarker(stage);
25
+ continue;
26
+ }
27
+ // set the current stage
28
+ if (nextStage === stage) {
29
+ this.set(stage, 'current');
30
+ // create a marker for the current stage if it doesn't exist
31
+ if (!this.markers.has(stage)) {
32
+ this.markers.set(stage, Performance.mark('MultiStageComponent', stage.replaceAll(' ', '-').toLowerCase()));
33
+ }
34
+ continue;
35
+ }
36
+ // any stage before the current stage should be marked as skipped if it's still pending
37
+ if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.get(stage) === 'pending') {
38
+ this.set(stage, 'skipped');
39
+ continue;
40
+ }
41
+ // any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet)
42
+ if (stages.indexOf(nextStage) > stages.indexOf(stage)) {
43
+ this.set(stage, 'completed');
44
+ this.stopMarker(stage);
45
+ continue;
46
+ }
47
+ // default to pending
48
+ this.set(stage, 'pending');
49
+ }
50
+ }
51
+ set(stage, status) {
52
+ if (status === 'current') {
53
+ this.current = stage;
54
+ }
55
+ return super.set(stage, status);
56
+ }
57
+ stopMarker(stage) {
58
+ const marker = this.markers.get(stage);
59
+ if (marker && !marker.stopped) {
60
+ marker.stop();
61
+ }
62
+ }
63
+ }
package/lib/utils.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function readableTime(time: number, granularity: 's' | 'ms', decimalPlaces?: number): string;
package/lib/utils.js ADDED
@@ -0,0 +1,31 @@
1
+ function truncate(value, decimals = 2) {
2
+ const remainder = value % 1;
3
+ // truncate remainder to specified decimals
4
+ const fractionalPart = remainder ? remainder.toString().split('.')[1].slice(0, decimals) : '0'.repeat(decimals);
5
+ const wholeNumberPart = Math.floor(value).toString();
6
+ return decimals ? `${wholeNumberPart}.${fractionalPart}` : wholeNumberPart;
7
+ }
8
+ export function readableTime(time, granularity, decimalPlaces = 2) {
9
+ if (granularity === 's' && time < 1000) {
10
+ return '< 1s';
11
+ }
12
+ const decimals = granularity === 'ms' ? decimalPlaces : 0;
13
+ // if time < 1000ms, return time in ms
14
+ if (time < 1000) {
15
+ return `${time}ms`;
16
+ }
17
+ // if time < 60s, return time in seconds
18
+ if (time < 60_000) {
19
+ return `${truncate(time / 1000, decimals)}s`;
20
+ }
21
+ // if time < 60m, return time in minutes and seconds
22
+ if (time < 3_600_000) {
23
+ const minutes = Math.floor(time / 60_000);
24
+ const seconds = truncate((time % 60_000) / 1000, decimals);
25
+ return `${minutes}m ${seconds}s`;
26
+ }
27
+ // if time >= 60m, return time in hours and minutes
28
+ const hours = Math.floor(time / 3_600_000);
29
+ const minutes = Math.floor((time % 3_600_000) / 60_000);
30
+ return `${hours}h ${minutes}m`;
31
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@oclif/multi-stage-output",
3
+ "description": "Terminal output for oclif commands with multiple stages",
4
+ "version": "0.1.0",
5
+ "author": "Salesforce",
6
+ "bugs": "https://github.com/oclif/multi-stage-output/issues",
7
+ "dependencies": {
8
+ "@oclif/core": "^4",
9
+ "change-case": "^5.4.4",
10
+ "cli-spinners": "^2",
11
+ "figures": "^6.1.0",
12
+ "ink": "^5.0.1",
13
+ "react": "^18.3.1",
14
+ "terminal-link": "^3.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@commitlint/config-conventional": "^19",
18
+ "@oclif/prettier-config": "^0.2.1",
19
+ "@types/chai": "^4.3.16",
20
+ "@types/mocha": "^10.0.7",
21
+ "@types/node": "^18",
22
+ "@types/react": "^18.3.3",
23
+ "@types/sinon": "^17.0.3",
24
+ "chai": "^4.5.0",
25
+ "commitlint": "^19",
26
+ "eslint": "^8.57.0",
27
+ "eslint-config-oclif": "^5.2.0",
28
+ "eslint-config-oclif-typescript": "^3.1.8",
29
+ "eslint-config-prettier": "^9.1.0",
30
+ "eslint-config-xo": "^0.45.0",
31
+ "eslint-config-xo-react": "^0.27.0",
32
+ "eslint-plugin-react": "^7.34.3",
33
+ "eslint-plugin-react-hooks": "^4.6.2",
34
+ "husky": "^9.1.3",
35
+ "ink-testing-library": "^4.0.0",
36
+ "lint-staged": "^15",
37
+ "mocha": "^10.7.0",
38
+ "prettier": "^3.3.3",
39
+ "shx": "^0.3.4",
40
+ "sinon": "^18",
41
+ "strip-ansi": "^7.1.0",
42
+ "ts-node": "^10.9.2",
43
+ "typescript": "^5"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ },
48
+ "files": [
49
+ "/lib"
50
+ ],
51
+ "homepage": "https://github.com/oclif/core",
52
+ "keywords": [
53
+ "oclif",
54
+ "cli",
55
+ "stages"
56
+ ],
57
+ "license": "MIT",
58
+ "exports": {
59
+ ".": "./lib/index.js"
60
+ },
61
+ "repository": "oclif/core",
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "scripts": {
66
+ "build": "shx rm -rf lib && tsc",
67
+ "compile": "tsc",
68
+ "format": "prettier --write \"+(src|test)/**/*.+(ts|js|json)\"",
69
+ "lint": "eslint . --ext .ts",
70
+ "posttest": "yarn lint",
71
+ "prepack": "yarn run build",
72
+ "prepare": "husky",
73
+ "test": "mocha --forbid-only \"test/**/*.test.+(ts|tsx)\" --parallel"
74
+ },
75
+ "types": "lib/index.d.ts",
76
+ "type": "module"
77
+ }