@pokit/reporter-clack 0.0.1 → 0.0.2
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/package.json +7 -7
- package/src/adapter.ts +817 -0
- package/src/symbols.ts +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/reporter-clack",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Clack-based event reporter for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -26,9 +26,8 @@
|
|
|
26
26
|
"bugs": {
|
|
27
27
|
"url": "https://github.com/notation-dev/openpok/issues"
|
|
28
28
|
},
|
|
29
|
-
"main": "./
|
|
30
|
-
"
|
|
31
|
-
"types": "./src/index.ts",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
32
31
|
"exports": {
|
|
33
32
|
".": {
|
|
34
33
|
"bun": "./src/index.ts",
|
|
@@ -39,7 +38,8 @@
|
|
|
39
38
|
"files": [
|
|
40
39
|
"dist",
|
|
41
40
|
"README.md",
|
|
42
|
-
"LICENSE"
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"src"
|
|
43
43
|
],
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/bun": "latest",
|
|
53
|
-
"@pokit/core": "0.0.
|
|
53
|
+
"@pokit/core": "0.0.2"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@pokit/core": "0.0.
|
|
56
|
+
"@pokit/core": "0.0.2"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"bun": ">=1.0.0"
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clack Reporter Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the ReporterAdapter interface using @clack/prompts.
|
|
5
|
+
* Consumes CLI events from the EventBus and renders them to the terminal.
|
|
6
|
+
*
|
|
7
|
+
* Design principles:
|
|
8
|
+
* - Groups are visual containers with a bold title (intro) and completion indicator (outro)
|
|
9
|
+
* - Activities within sequential groups show as spinner items that complete with checkmarks
|
|
10
|
+
* - Parallel groups use a single spinner that tracks progress, showing completions as they finish
|
|
11
|
+
* - Logs during an active spinner will pause the spinner, show the log, then resume
|
|
12
|
+
* - Process output is never interleaved with spinners
|
|
13
|
+
*
|
|
14
|
+
* Rendering strategy:
|
|
15
|
+
* - group:start -> p.intro() with bold label (or plain text in plain mode)
|
|
16
|
+
* - group:end -> p.outro() with success indicator
|
|
17
|
+
* - activity:start (sequential) -> spinner.start() (or plain text indicator)
|
|
18
|
+
* - activity:start (parallel) -> track activity, update combined spinner
|
|
19
|
+
* - activity:success -> spinner.stop() with checkmark (code 0) or update combined spinner
|
|
20
|
+
* - activity:failure -> spinner.stop() with X (code 1)
|
|
21
|
+
* - activity:update -> spinner.message()
|
|
22
|
+
* - log -> pause spinner if active, p.log.*, resume spinner
|
|
23
|
+
*
|
|
24
|
+
* Plain mode (--plain or CI environment):
|
|
25
|
+
* - When unicode is disabled, uses ASCII symbols and bypasses clack's decorative output
|
|
26
|
+
* - When color is disabled (--no-color or NO_COLOR env), strips ANSI color codes
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as p from '@clack/prompts';
|
|
30
|
+
import pc from 'picocolors';
|
|
31
|
+
import type {
|
|
32
|
+
ReporterAdapter,
|
|
33
|
+
ReporterAdapterController,
|
|
34
|
+
EventBus,
|
|
35
|
+
CLIEvent,
|
|
36
|
+
ActivityId,
|
|
37
|
+
GroupId,
|
|
38
|
+
GroupLayout,
|
|
39
|
+
LogLevel,
|
|
40
|
+
OutputConfig,
|
|
41
|
+
} from '@pokit/core';
|
|
42
|
+
import { detectOutputConfig, CommandError } from '@pokit/core';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract error message and optional output from an error.
|
|
46
|
+
* If the error is a CommandError with output, includes that in the message.
|
|
47
|
+
*/
|
|
48
|
+
function formatErrorMessage(error: Error | string): string {
|
|
49
|
+
if (typeof error === 'string') {
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
if (error instanceof CommandError && error.output) {
|
|
53
|
+
return `${error.message}\n\n${error.output}`;
|
|
54
|
+
}
|
|
55
|
+
return error.message;
|
|
56
|
+
}
|
|
57
|
+
import { getSymbols, type SymbolSet } from './symbols';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Helper to conditionally apply color.
|
|
61
|
+
* In no-color mode, returns text unchanged.
|
|
62
|
+
*/
|
|
63
|
+
function colorize(text: string, colorFn: (s: string) => string, useColor: boolean): string {
|
|
64
|
+
return useColor ? colorFn(text) : text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write a line to stdout.
|
|
69
|
+
* Uses process.stdout.write to ensure proper capture in all environments.
|
|
70
|
+
* (Bun's console.log may not be captured by stdout interception)
|
|
71
|
+
*/
|
|
72
|
+
function writeLine(line: string): void {
|
|
73
|
+
process.stdout.write(line + '\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type SpinnerInstance = ReturnType<typeof p.spinner>;
|
|
77
|
+
|
|
78
|
+
type SpinnerEntry = {
|
|
79
|
+
spinner: SpinnerInstance;
|
|
80
|
+
label: string;
|
|
81
|
+
/** Current message being displayed (for restore after log) */
|
|
82
|
+
currentMessage: string;
|
|
83
|
+
/** Parent group ID for tracking failures */
|
|
84
|
+
parentGroupId?: GroupId;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type GroupEntry = {
|
|
88
|
+
label: string;
|
|
89
|
+
layout: GroupLayout;
|
|
90
|
+
/** Track if any activity in this group has failed */
|
|
91
|
+
hasFailure: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type ParallelActivity = {
|
|
95
|
+
label: string;
|
|
96
|
+
groupId: GroupId;
|
|
97
|
+
status: 'pending' | 'success' | 'failure';
|
|
98
|
+
error?: string;
|
|
99
|
+
/** Remediation steps for failed checks */
|
|
100
|
+
remediation?: string[];
|
|
101
|
+
/** Documentation URL for failed checks */
|
|
102
|
+
documentationUrl?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A log message that was buffered during an active spinner
|
|
107
|
+
*/
|
|
108
|
+
type BufferedLog = {
|
|
109
|
+
activityId: ActivityId;
|
|
110
|
+
level: LogLevel;
|
|
111
|
+
message: string;
|
|
112
|
+
timestamp: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Maximum number of logs to buffer per activity to prevent memory issues */
|
|
116
|
+
const MAX_BUFFERED_LOGS_PER_ACTIVITY = 100;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* State for tracking active spinners and groups
|
|
120
|
+
*/
|
|
121
|
+
type AdapterState = {
|
|
122
|
+
/** Active spinners for sequential activities */
|
|
123
|
+
spinners: Map<ActivityId, SpinnerEntry>;
|
|
124
|
+
/** Active groups with their layout info */
|
|
125
|
+
groups: Map<GroupId, GroupEntry>;
|
|
126
|
+
/** For parallel groups: track activities and use a single spinner */
|
|
127
|
+
parallelActivities: Map<ActivityId, ParallelActivity>;
|
|
128
|
+
/** The single spinner used for parallel group progress */
|
|
129
|
+
parallelSpinner: SpinnerEntry | null;
|
|
130
|
+
/** The group ID that owns the parallel spinner */
|
|
131
|
+
parallelSpinnerGroupId: GroupId | null;
|
|
132
|
+
/** When true, ignore all events (for fullscreen TUI takeover) */
|
|
133
|
+
suspended: boolean;
|
|
134
|
+
/** Activities that were suspended - we'll show completion for these on resume */
|
|
135
|
+
suspendedActivities: Map<ActivityId, { label: string }>;
|
|
136
|
+
/** Logs buffered during active spinners, to be flushed on activity completion */
|
|
137
|
+
bufferedLogs: BufferedLog[];
|
|
138
|
+
/** Verbose mode - when true, all logs are displayed immediately (no buffering) */
|
|
139
|
+
verbose: boolean;
|
|
140
|
+
/** Output configuration for color/unicode support */
|
|
141
|
+
outputConfig: OutputConfig;
|
|
142
|
+
/** Symbol set based on output config */
|
|
143
|
+
symbols: SymbolSet;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update the parallel spinner message based on current activity states
|
|
148
|
+
*/
|
|
149
|
+
function updateParallelSpinnerMessage(state: AdapterState): void {
|
|
150
|
+
if (!state.parallelSpinner) return;
|
|
151
|
+
|
|
152
|
+
const activities = Array.from(state.parallelActivities.values()).filter(
|
|
153
|
+
(a) => a.groupId === state.parallelSpinnerGroupId
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const pending = activities.filter((a) => a.status === 'pending');
|
|
157
|
+
const completed = activities.filter((a) => a.status !== 'pending');
|
|
158
|
+
|
|
159
|
+
if (pending.length === 0) {
|
|
160
|
+
// All done - this will be cleaned up by group:end
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const message =
|
|
165
|
+
pending.length === 1
|
|
166
|
+
? pending[0]!.label
|
|
167
|
+
: `Running ${pending.length} tasks (${completed.length}/${activities.length} done)`;
|
|
168
|
+
|
|
169
|
+
state.parallelSpinner.currentMessage = message;
|
|
170
|
+
state.parallelSpinner.spinner.message(message);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Display a log message.
|
|
175
|
+
* Uses clack's log functions in unicode mode, plain console.log in plain mode.
|
|
176
|
+
*
|
|
177
|
+
* @param level - The log level
|
|
178
|
+
* @param message - The message to display
|
|
179
|
+
* @param state - The adapter state (for output config)
|
|
180
|
+
* @param indented - Whether to indent the log (for buffered logs inside activity context)
|
|
181
|
+
*/
|
|
182
|
+
function displayLog(
|
|
183
|
+
level: LogLevel,
|
|
184
|
+
message: string,
|
|
185
|
+
state: AdapterState,
|
|
186
|
+
indented: boolean = false
|
|
187
|
+
): void {
|
|
188
|
+
const { outputConfig, symbols } = state;
|
|
189
|
+
|
|
190
|
+
// In plain mode (no unicode), use simple console output
|
|
191
|
+
if (!outputConfig.unicode) {
|
|
192
|
+
const prefix = indented ? `${symbols.groupLine} ` : '';
|
|
193
|
+
const levelPrefix = {
|
|
194
|
+
info: symbols.info,
|
|
195
|
+
warn: symbols.warning,
|
|
196
|
+
error: symbols.error,
|
|
197
|
+
success: symbols.success,
|
|
198
|
+
step: symbols.step,
|
|
199
|
+
}[level];
|
|
200
|
+
writeLine(`${prefix}${levelPrefix} ${message}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Unicode mode - use clack's decorative output
|
|
205
|
+
const prefix = indented ? '\u2502 ' : ''; // │ for indented logs
|
|
206
|
+
const formattedMessage = prefix + message;
|
|
207
|
+
|
|
208
|
+
switch (level) {
|
|
209
|
+
case 'info':
|
|
210
|
+
p.log.info(formattedMessage);
|
|
211
|
+
break;
|
|
212
|
+
case 'warn':
|
|
213
|
+
p.log.warn(formattedMessage);
|
|
214
|
+
break;
|
|
215
|
+
case 'error':
|
|
216
|
+
p.log.error(formattedMessage);
|
|
217
|
+
break;
|
|
218
|
+
case 'success':
|
|
219
|
+
p.log.success(formattedMessage);
|
|
220
|
+
break;
|
|
221
|
+
case 'step':
|
|
222
|
+
p.log.step(formattedMessage);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Flush buffered logs for a specific activity.
|
|
229
|
+
*
|
|
230
|
+
* @param state - The adapter state
|
|
231
|
+
* @param activityId - The activity ID whose logs should be flushed
|
|
232
|
+
*/
|
|
233
|
+
function flushLogsForActivity(state: AdapterState, activityId: ActivityId): void {
|
|
234
|
+
// Filter logs for this activity, sorted by timestamp
|
|
235
|
+
const activityLogs = state.bufferedLogs
|
|
236
|
+
.filter((log) => log.activityId === activityId)
|
|
237
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
238
|
+
|
|
239
|
+
// Display each buffered log with indentation
|
|
240
|
+
for (const log of activityLogs) {
|
|
241
|
+
displayLog(log.level, log.message, state, true);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Remove flushed logs from buffer
|
|
245
|
+
state.bufferedLogs = state.bufferedLogs.filter((log) => log.activityId !== activityId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Options for the reporter adapter
|
|
250
|
+
*/
|
|
251
|
+
export type ReporterAdapterOptions = {
|
|
252
|
+
/** When true, logs are displayed immediately instead of being buffered during spinners */
|
|
253
|
+
verbose?: boolean;
|
|
254
|
+
/** Output configuration (color, unicode, verbose settings) */
|
|
255
|
+
output?: OutputConfig;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Create a Clack-based ReporterAdapter
|
|
260
|
+
*
|
|
261
|
+
* @param options - Optional configuration for the adapter
|
|
262
|
+
*/
|
|
263
|
+
export function createReporterAdapter(options?: ReporterAdapterOptions): ReporterAdapter {
|
|
264
|
+
// Get output config from options, or use defaults
|
|
265
|
+
const outputConfig: OutputConfig = options?.output ?? {
|
|
266
|
+
color: true,
|
|
267
|
+
unicode: true,
|
|
268
|
+
verbose: options?.verbose ?? false,
|
|
269
|
+
};
|
|
270
|
+
// Verbose can be set via options.verbose for backwards compatibility
|
|
271
|
+
if (options?.verbose !== undefined) {
|
|
272
|
+
outputConfig.verbose = options.verbose;
|
|
273
|
+
}
|
|
274
|
+
const symbols = getSymbols(outputConfig);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
start(bus: EventBus): ReporterAdapterController {
|
|
278
|
+
const state: AdapterState = {
|
|
279
|
+
spinners: new Map(),
|
|
280
|
+
groups: new Map(),
|
|
281
|
+
parallelActivities: new Map(),
|
|
282
|
+
parallelSpinner: null,
|
|
283
|
+
parallelSpinnerGroupId: null,
|
|
284
|
+
suspended: false,
|
|
285
|
+
suspendedActivities: new Map(),
|
|
286
|
+
bufferedLogs: [],
|
|
287
|
+
verbose: outputConfig.verbose,
|
|
288
|
+
outputConfig,
|
|
289
|
+
symbols,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const handleEvent = (event: CLIEvent): void => {
|
|
293
|
+
switch (event.type) {
|
|
294
|
+
// Root lifecycle (app-level intro/outro)
|
|
295
|
+
case 'root:start':
|
|
296
|
+
case 'root:end':
|
|
297
|
+
// Handled by the router
|
|
298
|
+
break;
|
|
299
|
+
|
|
300
|
+
// Group lifecycle (command-level intro/outro)
|
|
301
|
+
case 'group:start': {
|
|
302
|
+
if (state.suspended) break;
|
|
303
|
+
|
|
304
|
+
state.groups.set(event.id, {
|
|
305
|
+
label: event.label,
|
|
306
|
+
layout: event.layout,
|
|
307
|
+
hasFailure: false,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// In plain mode, use simple bracket notation
|
|
311
|
+
if (!state.outputConfig.unicode) {
|
|
312
|
+
const label = colorize(event.label, pc.bold, state.outputConfig.color);
|
|
313
|
+
writeLine(`${state.symbols.groupStart}${label}${state.symbols.groupEnd}`);
|
|
314
|
+
} else {
|
|
315
|
+
p.intro(colorize(event.label, pc.bold, state.outputConfig.color));
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'group:end': {
|
|
321
|
+
if (state.suspended) break;
|
|
322
|
+
|
|
323
|
+
const group = state.groups.get(event.id);
|
|
324
|
+
state.groups.delete(event.id);
|
|
325
|
+
|
|
326
|
+
let hasFailures = group?.hasFailure ?? false;
|
|
327
|
+
// Collect errors with remediation info to print after the group closes
|
|
328
|
+
type DeferredError = {
|
|
329
|
+
error: string;
|
|
330
|
+
remediation?: string[];
|
|
331
|
+
documentationUrl?: string;
|
|
332
|
+
};
|
|
333
|
+
const deferredErrors: DeferredError[] = [];
|
|
334
|
+
|
|
335
|
+
// If this was a parallel group, show completion results and clean up
|
|
336
|
+
if (group?.layout === 'parallel') {
|
|
337
|
+
// Collect activities for this group
|
|
338
|
+
const activities = Array.from(state.parallelActivities.entries()).filter(
|
|
339
|
+
([, a]) => a.groupId === event.id
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Sort: successes first, then failures (so failures are more visible at end)
|
|
343
|
+
const sorted = activities.sort(([, a], [, b]) => {
|
|
344
|
+
if (a.status === 'success' && b.status !== 'success') return -1;
|
|
345
|
+
if (a.status !== 'success' && b.status === 'success') return 1;
|
|
346
|
+
return 0;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Stop the parallel spinner - use first success as the message
|
|
350
|
+
if (state.parallelSpinner && state.parallelSpinnerGroupId === event.id) {
|
|
351
|
+
const firstSuccess = sorted.find(([, a]) => a.status === 'success');
|
|
352
|
+
if (firstSuccess) {
|
|
353
|
+
// Show first success via spinner stop
|
|
354
|
+
state.parallelSpinner.spinner.stop(firstSuccess[1].label, 0);
|
|
355
|
+
// Flush buffered logs for this activity
|
|
356
|
+
flushLogsForActivity(state, firstSuccess[0]);
|
|
357
|
+
state.parallelActivities.delete(firstSuccess[0]);
|
|
358
|
+
} else {
|
|
359
|
+
// All failed - show first failure label (not error) via spinner stop
|
|
360
|
+
const firstFailure = sorted[0];
|
|
361
|
+
if (firstFailure) {
|
|
362
|
+
state.parallelSpinner.spinner.stop(firstFailure[1].label, 1);
|
|
363
|
+
hasFailures = true;
|
|
364
|
+
// Defer the error message with remediation to print after outro
|
|
365
|
+
if (firstFailure[1].error) {
|
|
366
|
+
deferredErrors.push({
|
|
367
|
+
error: firstFailure[1].error,
|
|
368
|
+
remediation: firstFailure[1].remediation,
|
|
369
|
+
documentationUrl: firstFailure[1].documentationUrl,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// Flush buffered logs for this activity
|
|
373
|
+
flushLogsForActivity(state, firstFailure[0]);
|
|
374
|
+
state.parallelActivities.delete(firstFailure[0]);
|
|
375
|
+
} else {
|
|
376
|
+
state.parallelSpinner.spinner.stop('Complete', 0);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
state.parallelSpinner = null;
|
|
380
|
+
state.parallelSpinnerGroupId = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Show remaining results (only labels inside group, defer errors)
|
|
384
|
+
for (const [activityId, activity] of state.parallelActivities) {
|
|
385
|
+
if (activity.groupId !== event.id) continue;
|
|
386
|
+
if (activity.status === 'success') {
|
|
387
|
+
if (!state.outputConfig.unicode) {
|
|
388
|
+
const prefix = colorize(
|
|
389
|
+
state.symbols.success,
|
|
390
|
+
pc.green,
|
|
391
|
+
state.outputConfig.color
|
|
392
|
+
);
|
|
393
|
+
writeLine(` ${prefix} ${activity.label}`);
|
|
394
|
+
} else {
|
|
395
|
+
p.log.success(activity.label);
|
|
396
|
+
}
|
|
397
|
+
} else if (activity.status === 'failure') {
|
|
398
|
+
hasFailures = true;
|
|
399
|
+
// Show label inside group, defer error message with remediation
|
|
400
|
+
if (!state.outputConfig.unicode) {
|
|
401
|
+
const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
|
|
402
|
+
writeLine(` ${prefix} ${activity.label}`);
|
|
403
|
+
} else {
|
|
404
|
+
p.log.error(activity.label);
|
|
405
|
+
}
|
|
406
|
+
if (activity.error) {
|
|
407
|
+
deferredErrors.push({
|
|
408
|
+
error: activity.error,
|
|
409
|
+
remediation: activity.remediation,
|
|
410
|
+
documentationUrl: activity.documentationUrl,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Flush any buffered logs for this parallel activity
|
|
415
|
+
flushLogsForActivity(state, activityId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Clean up all activities for this group
|
|
419
|
+
for (const [id, activity] of [...state.parallelActivities]) {
|
|
420
|
+
if (activity.groupId === event.id) {
|
|
421
|
+
state.parallelActivities.delete(id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Show appropriate outro based on whether there were failures
|
|
427
|
+
if (!state.outputConfig.unicode) {
|
|
428
|
+
// Plain mode - simple bracket notation
|
|
429
|
+
if (hasFailures) {
|
|
430
|
+
const failedText = colorize(state.symbols.failed, pc.red, state.outputConfig.color);
|
|
431
|
+
writeLine(`${state.symbols.groupStart}${failedText}${state.symbols.groupEnd}`);
|
|
432
|
+
} else {
|
|
433
|
+
const doneText = colorize(state.symbols.done, pc.green, state.outputConfig.color);
|
|
434
|
+
writeLine(`${state.symbols.groupStart}${doneText}${state.symbols.groupEnd}`);
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// Unicode mode - use clack's outro
|
|
438
|
+
if (hasFailures) {
|
|
439
|
+
p.outro(
|
|
440
|
+
colorize(`${state.symbols.failed} Failed`, pc.red, state.outputConfig.color)
|
|
441
|
+
);
|
|
442
|
+
} else {
|
|
443
|
+
p.outro(colorize(`${state.symbols.done} Done`, pc.green, state.outputConfig.color));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Print deferred error messages with remediation after the group closes
|
|
448
|
+
for (const deferred of deferredErrors) {
|
|
449
|
+
if (!state.outputConfig.unicode) {
|
|
450
|
+
writeLine(`${state.symbols.error} ${deferred.error}`);
|
|
451
|
+
} else {
|
|
452
|
+
p.log.error(deferred.error);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Display remediation steps if available
|
|
456
|
+
if (deferred.remediation && deferred.remediation.length > 0) {
|
|
457
|
+
writeLine('');
|
|
458
|
+
writeLine(' To fix:');
|
|
459
|
+
for (const step of deferred.remediation) {
|
|
460
|
+
writeLine(` - ${step}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Display documentation link if available
|
|
465
|
+
if (deferred.documentationUrl) {
|
|
466
|
+
writeLine('');
|
|
467
|
+
writeLine(` More info: ${deferred.documentationUrl}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Activity lifecycle (task-level spinner)
|
|
474
|
+
case 'activity:start': {
|
|
475
|
+
if (state.suspended) break;
|
|
476
|
+
|
|
477
|
+
// Find the parent group to determine layout
|
|
478
|
+
const parentGroup = event.parentId ? state.groups.get(event.parentId as GroupId) : null;
|
|
479
|
+
|
|
480
|
+
if (parentGroup?.layout === 'parallel') {
|
|
481
|
+
// Track this activity for the parallel group
|
|
482
|
+
state.parallelActivities.set(event.id, {
|
|
483
|
+
label: event.label,
|
|
484
|
+
groupId: event.parentId as GroupId,
|
|
485
|
+
status: 'pending',
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// In plain mode, just track activities - results shown at group:end
|
|
489
|
+
if (!state.outputConfig.unicode) {
|
|
490
|
+
if (!state.parallelSpinnerGroupId) {
|
|
491
|
+
state.parallelSpinnerGroupId = event.parentId as GroupId;
|
|
492
|
+
const activities = state.parallelActivities.size;
|
|
493
|
+
writeLine(` Running ${activities} task${activities > 1 ? 's' : ''}...`);
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
// Unicode mode: create or update the parallel spinner
|
|
497
|
+
if (!state.parallelSpinner) {
|
|
498
|
+
const spinner = p.spinner();
|
|
499
|
+
state.parallelSpinner = {
|
|
500
|
+
spinner,
|
|
501
|
+
label: event.label,
|
|
502
|
+
currentMessage: event.label,
|
|
503
|
+
};
|
|
504
|
+
state.parallelSpinnerGroupId = event.parentId as GroupId;
|
|
505
|
+
spinner.start(event.label);
|
|
506
|
+
}
|
|
507
|
+
updateParallelSpinnerMessage(state);
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
// Sequential activity
|
|
511
|
+
if (!state.outputConfig.unicode) {
|
|
512
|
+
// Plain mode: track activity without spinner - result shown on completion
|
|
513
|
+
state.spinners.set(event.id, {
|
|
514
|
+
spinner: null as unknown as SpinnerInstance, // Not used in plain mode
|
|
515
|
+
label: event.label,
|
|
516
|
+
currentMessage: event.label,
|
|
517
|
+
parentGroupId: event.parentId as GroupId | undefined,
|
|
518
|
+
});
|
|
519
|
+
} else {
|
|
520
|
+
// Unicode mode: create individual spinner
|
|
521
|
+
const spinner = p.spinner();
|
|
522
|
+
state.spinners.set(event.id, {
|
|
523
|
+
spinner,
|
|
524
|
+
label: event.label,
|
|
525
|
+
currentMessage: event.label,
|
|
526
|
+
parentGroupId: event.parentId as GroupId | undefined,
|
|
527
|
+
});
|
|
528
|
+
spinner.start(event.label);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
case 'activity:update': {
|
|
535
|
+
if (state.suspended) break;
|
|
536
|
+
|
|
537
|
+
// Check if this is a parallel activity
|
|
538
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
539
|
+
if (parallelActivity) {
|
|
540
|
+
// Update the activity label if message provided
|
|
541
|
+
if (event.payload.message) {
|
|
542
|
+
parallelActivity.label = event.payload.message;
|
|
543
|
+
if (state.outputConfig.unicode) {
|
|
544
|
+
updateParallelSpinnerMessage(state);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Sequential activity
|
|
551
|
+
const entry = state.spinners.get(event.id);
|
|
552
|
+
if (entry) {
|
|
553
|
+
const text =
|
|
554
|
+
event.payload.message ||
|
|
555
|
+
(event.payload.progress !== undefined ? `${event.payload.progress}%` : null);
|
|
556
|
+
if (text) {
|
|
557
|
+
entry.currentMessage = text;
|
|
558
|
+
// Only update spinner in unicode mode
|
|
559
|
+
if (state.outputConfig.unicode && entry.spinner) {
|
|
560
|
+
entry.spinner.message(text);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
case 'activity:success': {
|
|
568
|
+
// Check if this is a parallel activity
|
|
569
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
570
|
+
if (parallelActivity) {
|
|
571
|
+
parallelActivity.status = 'success';
|
|
572
|
+
if (state.outputConfig.unicode) {
|
|
573
|
+
updateParallelSpinnerMessage(state);
|
|
574
|
+
}
|
|
575
|
+
// Note: For parallel activities, logs will be flushed at group:end
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Sequential activity
|
|
580
|
+
const entry = state.spinners.get(event.id);
|
|
581
|
+
if (entry) {
|
|
582
|
+
if (!state.outputConfig.unicode) {
|
|
583
|
+
// Plain mode - print success line
|
|
584
|
+
const prefix = colorize(state.symbols.success, pc.green, state.outputConfig.color);
|
|
585
|
+
writeLine(` ${prefix} ${entry.label}`);
|
|
586
|
+
} else if (entry.spinner) {
|
|
587
|
+
// Unicode mode - stop spinner
|
|
588
|
+
entry.spinner.stop(entry.label, 0);
|
|
589
|
+
}
|
|
590
|
+
state.spinners.delete(event.id);
|
|
591
|
+
|
|
592
|
+
// Flush any buffered logs for this activity
|
|
593
|
+
flushLogsForActivity(state, event.id);
|
|
594
|
+
} else {
|
|
595
|
+
// Check if this was a suspended activity
|
|
596
|
+
const suspended = state.suspendedActivities.get(event.id);
|
|
597
|
+
if (suspended && !state.suspended) {
|
|
598
|
+
if (!state.outputConfig.unicode) {
|
|
599
|
+
const prefix = colorize(
|
|
600
|
+
state.symbols.success,
|
|
601
|
+
pc.green,
|
|
602
|
+
state.outputConfig.color
|
|
603
|
+
);
|
|
604
|
+
writeLine(` ${prefix} ${suspended.label}`);
|
|
605
|
+
} else {
|
|
606
|
+
p.log.success(suspended.label);
|
|
607
|
+
}
|
|
608
|
+
state.suspendedActivities.delete(event.id);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case 'activity:failure': {
|
|
615
|
+
const errorMessage = formatErrorMessage(event.error);
|
|
616
|
+
|
|
617
|
+
// Check if this is a parallel activity
|
|
618
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
619
|
+
if (parallelActivity) {
|
|
620
|
+
parallelActivity.status = 'failure';
|
|
621
|
+
parallelActivity.error = errorMessage;
|
|
622
|
+
parallelActivity.remediation = event.remediation;
|
|
623
|
+
parallelActivity.documentationUrl = event.documentationUrl;
|
|
624
|
+
// Mark the parent group as having failures
|
|
625
|
+
const parentGroup = state.groups.get(parallelActivity.groupId);
|
|
626
|
+
if (parentGroup) {
|
|
627
|
+
parentGroup.hasFailure = true;
|
|
628
|
+
}
|
|
629
|
+
if (state.outputConfig.unicode) {
|
|
630
|
+
updateParallelSpinnerMessage(state);
|
|
631
|
+
}
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Sequential activity
|
|
636
|
+
const entry = state.spinners.get(event.id);
|
|
637
|
+
if (entry) {
|
|
638
|
+
// Mark the parent group as having failures
|
|
639
|
+
if (entry.parentGroupId) {
|
|
640
|
+
const parentGroup = state.groups.get(entry.parentGroupId);
|
|
641
|
+
if (parentGroup) {
|
|
642
|
+
parentGroup.hasFailure = true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!state.outputConfig.unicode) {
|
|
647
|
+
// Plain mode - print error line
|
|
648
|
+
const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
|
|
649
|
+
writeLine(` ${prefix} ${errorMessage}`);
|
|
650
|
+
} else if (entry.spinner) {
|
|
651
|
+
// Unicode mode - stop spinner with error
|
|
652
|
+
entry.spinner.stop(errorMessage, 1);
|
|
653
|
+
}
|
|
654
|
+
state.spinners.delete(event.id);
|
|
655
|
+
|
|
656
|
+
// Display remediation steps if available
|
|
657
|
+
const linePrefix = state.outputConfig.unicode ? '\u2502' : state.symbols.groupLine;
|
|
658
|
+
if (event.remediation && event.remediation.length > 0) {
|
|
659
|
+
writeLine(linePrefix);
|
|
660
|
+
writeLine(`${linePrefix} To fix:`);
|
|
661
|
+
for (const step of event.remediation) {
|
|
662
|
+
writeLine(`${linePrefix} - ${step}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Display documentation link if available
|
|
667
|
+
if (event.documentationUrl) {
|
|
668
|
+
writeLine(linePrefix);
|
|
669
|
+
writeLine(`${linePrefix} More info: ${event.documentationUrl}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Flush any buffered logs for this activity
|
|
673
|
+
flushLogsForActivity(state, event.id);
|
|
674
|
+
} else {
|
|
675
|
+
// Check if this was a suspended activity
|
|
676
|
+
const suspended = state.suspendedActivities.get(event.id);
|
|
677
|
+
if (suspended && !state.suspended) {
|
|
678
|
+
if (!state.outputConfig.unicode) {
|
|
679
|
+
const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
|
|
680
|
+
writeLine(` ${prefix} ${suspended.label}: ${errorMessage}`);
|
|
681
|
+
} else {
|
|
682
|
+
p.log.error(`${suspended.label}: ${errorMessage}`);
|
|
683
|
+
}
|
|
684
|
+
state.suspendedActivities.delete(event.id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Log events
|
|
691
|
+
case 'log': {
|
|
692
|
+
if (state.suspended) break;
|
|
693
|
+
|
|
694
|
+
// Verbose mode: always display logs immediately
|
|
695
|
+
if (state.verbose) {
|
|
696
|
+
displayLog(event.level, event.message, state, false);
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const hasActiveSpinners = state.spinners.size > 0 || state.parallelSpinner !== null;
|
|
701
|
+
|
|
702
|
+
// Error logs interrupt spinners immediately (only in unicode mode)
|
|
703
|
+
if (
|
|
704
|
+
event.level === 'error' &&
|
|
705
|
+
hasActiveSpinners &&
|
|
706
|
+
event.activityId &&
|
|
707
|
+
state.outputConfig.unicode
|
|
708
|
+
) {
|
|
709
|
+
const spinner = state.spinners.get(event.activityId);
|
|
710
|
+
if (spinner && spinner.spinner) {
|
|
711
|
+
// Temporarily stop spinner, show error, resume
|
|
712
|
+
const currentMessage = spinner.currentMessage;
|
|
713
|
+
spinner.spinner.stop(currentMessage, 0);
|
|
714
|
+
displayLog(event.level, event.message, state, false);
|
|
715
|
+
spinner.spinner.start(currentMessage);
|
|
716
|
+
} else {
|
|
717
|
+
// No spinner for this activity, just display
|
|
718
|
+
displayLog(event.level, event.message, state, false);
|
|
719
|
+
}
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Buffer logs during active spinners
|
|
724
|
+
if (hasActiveSpinners && event.activityId) {
|
|
725
|
+
// Check if we've hit the buffer limit for this activity
|
|
726
|
+
const activityLogCount = state.bufferedLogs.filter(
|
|
727
|
+
(log) => log.activityId === event.activityId
|
|
728
|
+
).length;
|
|
729
|
+
|
|
730
|
+
if (activityLogCount < MAX_BUFFERED_LOGS_PER_ACTIVITY) {
|
|
731
|
+
state.bufferedLogs.push({
|
|
732
|
+
activityId: event.activityId,
|
|
733
|
+
level: event.level,
|
|
734
|
+
message: event.message,
|
|
735
|
+
timestamp: Date.now(),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
// If over limit, silently drop (prevent memory issues)
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// No active spinners - display immediately
|
|
743
|
+
displayLog(event.level, event.message, state, false);
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Reporter control events
|
|
748
|
+
case 'reporter:suspend': {
|
|
749
|
+
state.suspended = true;
|
|
750
|
+
// Stop all active spinners and track them for completion messages (only in unicode mode)
|
|
751
|
+
if (state.outputConfig.unicode) {
|
|
752
|
+
for (const [id, entry] of state.spinners) {
|
|
753
|
+
try {
|
|
754
|
+
if (entry.spinner) {
|
|
755
|
+
entry.spinner.stop(entry.label + '...', 0);
|
|
756
|
+
}
|
|
757
|
+
state.suspendedActivities.set(id, { label: entry.label });
|
|
758
|
+
} catch {
|
|
759
|
+
// Spinner may already be stopped
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
state.spinners.clear();
|
|
763
|
+
|
|
764
|
+
// Also stop parallel spinner
|
|
765
|
+
if (state.parallelSpinner) {
|
|
766
|
+
try {
|
|
767
|
+
state.parallelSpinner.spinner.stop('Paused...', 0);
|
|
768
|
+
} catch {
|
|
769
|
+
// Spinner may already be stopped
|
|
770
|
+
}
|
|
771
|
+
state.parallelSpinner = null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
case 'reporter:resume': {
|
|
778
|
+
state.suspended = false;
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
const unsubscribe = bus.on(handleEvent);
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
stop(): void {
|
|
788
|
+
// Stop all active spinners (only in unicode mode where spinners exist)
|
|
789
|
+
if (state.outputConfig.unicode) {
|
|
790
|
+
for (const entry of state.spinners.values()) {
|
|
791
|
+
try {
|
|
792
|
+
if (entry.spinner) {
|
|
793
|
+
entry.spinner.stop('Stopped', 1);
|
|
794
|
+
}
|
|
795
|
+
} catch {
|
|
796
|
+
// Spinner may already be stopped
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (state.parallelSpinner) {
|
|
800
|
+
try {
|
|
801
|
+
state.parallelSpinner.spinner.stop('Stopped', 1);
|
|
802
|
+
} catch {
|
|
803
|
+
// Spinner may already be stopped
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
state.spinners.clear();
|
|
808
|
+
state.groups.clear();
|
|
809
|
+
state.parallelActivities.clear();
|
|
810
|
+
state.parallelSpinner = null;
|
|
811
|
+
state.parallelSpinnerGroupId = null;
|
|
812
|
+
unsubscribe();
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
package/src/symbols.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol Sets for CLI Output
|
|
3
|
+
*
|
|
4
|
+
* Provides Unicode and ASCII symbol sets for terminal output.
|
|
5
|
+
* The appropriate set is selected based on OutputConfig.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OutputConfig } from '@pokit/core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Complete set of symbols used in CLI output
|
|
12
|
+
*/
|
|
13
|
+
export type SymbolSet = {
|
|
14
|
+
/** Success indicator (e.g., checkmark) */
|
|
15
|
+
success: string;
|
|
16
|
+
/** Error indicator (e.g., X mark) */
|
|
17
|
+
error: string;
|
|
18
|
+
/** Warning indicator */
|
|
19
|
+
warning: string;
|
|
20
|
+
/** Info indicator */
|
|
21
|
+
info: string;
|
|
22
|
+
/** Step/progress indicator */
|
|
23
|
+
step: string;
|
|
24
|
+
/** Group start (e.g., top-left corner) */
|
|
25
|
+
groupStart: string;
|
|
26
|
+
/** Group end (e.g., bottom-left corner) */
|
|
27
|
+
groupEnd: string;
|
|
28
|
+
/** Group line (e.g., vertical bar) */
|
|
29
|
+
groupLine: string;
|
|
30
|
+
/** Done message */
|
|
31
|
+
done: string;
|
|
32
|
+
/** Failed message */
|
|
33
|
+
failed: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unicode symbols for rich terminal output
|
|
38
|
+
* Used when unicode support is detected
|
|
39
|
+
*/
|
|
40
|
+
export const UNICODE_SYMBOLS: SymbolSet = {
|
|
41
|
+
success: '\u25C7', // ◇
|
|
42
|
+
error: '\u25A0', // ■
|
|
43
|
+
warning: '\u25B2', // ▲
|
|
44
|
+
info: '\u25CF', // ●
|
|
45
|
+
step: '\u25C7', // ◇
|
|
46
|
+
groupStart: '\u250C', // ┌
|
|
47
|
+
groupEnd: '\u2514', // └
|
|
48
|
+
groupLine: '\u2502', // │
|
|
49
|
+
done: '\u2714', // ✔
|
|
50
|
+
failed: '\u2718', // ✘
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ASCII symbols for plain text output
|
|
55
|
+
* Used in CI environments or when unicode is not supported
|
|
56
|
+
*/
|
|
57
|
+
export const ASCII_SYMBOLS: SymbolSet = {
|
|
58
|
+
success: '[OK]',
|
|
59
|
+
error: '[ERR]',
|
|
60
|
+
warning: '[WARN]',
|
|
61
|
+
info: '[INFO]',
|
|
62
|
+
step: '-',
|
|
63
|
+
groupStart: '[',
|
|
64
|
+
groupEnd: ']',
|
|
65
|
+
groupLine: '|',
|
|
66
|
+
done: 'Done',
|
|
67
|
+
failed: 'Failed',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the appropriate symbol set based on output configuration
|
|
72
|
+
*
|
|
73
|
+
* @param config - Output configuration
|
|
74
|
+
* @returns Symbol set to use
|
|
75
|
+
*/
|
|
76
|
+
export function getSymbols(config: OutputConfig): SymbolSet {
|
|
77
|
+
return config.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS;
|
|
78
|
+
}
|