@microsoft/m365-copilot-eval 1.0.1-preview.1 → 1.1.0-preview.1
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 +61 -17
- package/README.md +11 -18
- package/package.json +4 -5
- package/src/clients/cli/requirements.txt +1 -1
- package/src/clients/node-js/bin/runevals.js +86 -59
- package/src/clients/node-js/config/default.js +1 -1
- package/src/clients/node-js/lib/progress.js +677 -0
- package/src/clients/node-js/lib/python-runtime.js +93 -19
- package/src/clients/node-js/lib/venv-manager.js +155 -17
- package/TERMS.txt +0 -65
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress display module for CLI initialization
|
|
3
|
+
* Provides real-time progress indication with support for:
|
|
4
|
+
* - Interactive TTY terminals (in-place updates, spinners, progress bars)
|
|
5
|
+
* - CI/CD environments (timestamped line-based output)
|
|
6
|
+
* - Verbose mode (detailed sub-step information)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as readline from 'readline';
|
|
10
|
+
import { setInterval, clearInterval } from 'timers';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialization phases configuration
|
|
14
|
+
*/
|
|
15
|
+
export const PHASES = [
|
|
16
|
+
{
|
|
17
|
+
id: 'download',
|
|
18
|
+
name: 'Downloading Python runtime',
|
|
19
|
+
order: 1,
|
|
20
|
+
progressType: 'determinate',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'extract',
|
|
24
|
+
name: 'Extracting Python runtime',
|
|
25
|
+
order: 2,
|
|
26
|
+
progressType: 'indeterminate',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'venv',
|
|
30
|
+
name: 'Creating virtual environment',
|
|
31
|
+
order: 3,
|
|
32
|
+
progressType: 'indeterminate',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'deps',
|
|
36
|
+
name: 'Installing dependencies',
|
|
37
|
+
order: 4,
|
|
38
|
+
progressType: 'determinate',
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Spinner frames for indeterminate progress (Unicode Braille)
|
|
44
|
+
*/
|
|
45
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
46
|
+
const SPINNER_INTERVAL = 80; // ms
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default options for ProgressReporter
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_OPTIONS = {
|
|
52
|
+
verbose: false,
|
|
53
|
+
quiet: false,
|
|
54
|
+
forceInteractive: false,
|
|
55
|
+
forceNonInteractive: false,
|
|
56
|
+
ciOutputInterval: 30000, // 30 seconds
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Detect if running in an interactive terminal
|
|
61
|
+
* @returns {boolean} True if interactive TTY, false for CI/CD or piped output
|
|
62
|
+
*/
|
|
63
|
+
export function isInteractiveTerminal() {
|
|
64
|
+
// Not a TTY (pipes, redirects)
|
|
65
|
+
if (!process.stdout.isTTY) return false;
|
|
66
|
+
|
|
67
|
+
// Common CI environment variables
|
|
68
|
+
if (process.env.CI) return false;
|
|
69
|
+
if (process.env.GITHUB_ACTIONS) return false;
|
|
70
|
+
if (process.env.JENKINS_URL) return false;
|
|
71
|
+
if (process.env.GITLAB_CI) return false;
|
|
72
|
+
if (process.env.TF_BUILD) return false; // Azure Pipelines
|
|
73
|
+
if (process.env.CIRCLECI) return false;
|
|
74
|
+
if (process.env.TRAVIS) return false;
|
|
75
|
+
if (process.env.BUILDKITE) return false;
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Format bytes to human-readable string
|
|
82
|
+
* @param {number} bytes - Number of bytes
|
|
83
|
+
* @returns {string} Formatted string (e.g., "45.2 MB")
|
|
84
|
+
*/
|
|
85
|
+
export function formatBytes(bytes) {
|
|
86
|
+
if (bytes === 0) return '0 B';
|
|
87
|
+
if (bytes < 0) return '0 B';
|
|
88
|
+
|
|
89
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
90
|
+
const k = 1024;
|
|
91
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
92
|
+
const index = Math.min(i, units.length - 1);
|
|
93
|
+
|
|
94
|
+
return `${(bytes / Math.pow(k, index)).toFixed(index > 0 ? 1 : 0)} ${units[index]}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format duration in milliseconds to human-readable string
|
|
99
|
+
* @param {number} ms - Duration in milliseconds
|
|
100
|
+
* @returns {string} Formatted string (e.g., "2m 30s")
|
|
101
|
+
*/
|
|
102
|
+
export function formatDuration(ms) {
|
|
103
|
+
if (ms < 0) ms = 0;
|
|
104
|
+
|
|
105
|
+
const seconds = Math.floor(ms / 1000);
|
|
106
|
+
const minutes = Math.floor(seconds / 60);
|
|
107
|
+
const remainingSeconds = seconds % 60;
|
|
108
|
+
|
|
109
|
+
if (minutes > 0) {
|
|
110
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
111
|
+
}
|
|
112
|
+
return `${seconds}s`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Calculate estimated time remaining
|
|
117
|
+
* @param {number} current - Current progress value
|
|
118
|
+
* @param {number} total - Total progress value
|
|
119
|
+
* @param {number} elapsedMs - Elapsed time in milliseconds
|
|
120
|
+
* @returns {number|null} Estimated remaining time in ms, or null if not calculable
|
|
121
|
+
*/
|
|
122
|
+
export function calculateETA(current, total, elapsedMs) {
|
|
123
|
+
if (current <= 0 || total <= 0 || elapsedMs <= 0) return null;
|
|
124
|
+
|
|
125
|
+
const percentage = (current / total) * 100;
|
|
126
|
+
// Only calculate when we have enough progress data (> 5%)
|
|
127
|
+
if (percentage < 5) return null;
|
|
128
|
+
|
|
129
|
+
const rate = current / elapsedMs; // units per ms
|
|
130
|
+
const remaining = total - current;
|
|
131
|
+
return Math.round(remaining / rate);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render ASCII progress bar
|
|
136
|
+
* @param {number} percentage - Progress percentage (0-100)
|
|
137
|
+
* @param {number} width - Bar width in characters
|
|
138
|
+
* @returns {string} Progress bar string (e.g., "[████████░░░░]")
|
|
139
|
+
*/
|
|
140
|
+
export function renderProgressBar(percentage, width = 12) {
|
|
141
|
+
const clamped = Math.max(0, Math.min(100, percentage));
|
|
142
|
+
const filled = Math.round((clamped / 100) * width);
|
|
143
|
+
const empty = width - filled;
|
|
144
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Spinner class for indeterminate progress
|
|
149
|
+
*/
|
|
150
|
+
export class Spinner {
|
|
151
|
+
constructor(label = '') {
|
|
152
|
+
this.label = label;
|
|
153
|
+
this.frameIndex = 0;
|
|
154
|
+
this.interval = null;
|
|
155
|
+
this.isRunning = false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start the spinner animation
|
|
160
|
+
* @param {Function} renderFn - Function to call with each frame
|
|
161
|
+
*/
|
|
162
|
+
start(renderFn) {
|
|
163
|
+
if (this.isRunning) return;
|
|
164
|
+
this.isRunning = true;
|
|
165
|
+
|
|
166
|
+
this.interval = setInterval(() => {
|
|
167
|
+
const frame = SPINNER_FRAMES[this.frameIndex++ % SPINNER_FRAMES.length];
|
|
168
|
+
if (renderFn) {
|
|
169
|
+
renderFn(frame, this.label);
|
|
170
|
+
}
|
|
171
|
+
}, SPINNER_INTERVAL);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stop the spinner animation
|
|
176
|
+
*/
|
|
177
|
+
stop() {
|
|
178
|
+
if (this.interval) {
|
|
179
|
+
clearInterval(this.interval);
|
|
180
|
+
this.interval = null;
|
|
181
|
+
}
|
|
182
|
+
this.isRunning = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Update the spinner label
|
|
187
|
+
* @param {string} label - New label text
|
|
188
|
+
*/
|
|
189
|
+
setLabel(label) {
|
|
190
|
+
this.label = label;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Progress Reporter class for managing initialization progress display
|
|
196
|
+
*/
|
|
197
|
+
export class ProgressReporter {
|
|
198
|
+
/**
|
|
199
|
+
* @param {Object} options - Configuration options
|
|
200
|
+
* @param {boolean} options.verbose - Show detailed sub-step information
|
|
201
|
+
* @param {boolean} options.quiet - Suppress all progress output
|
|
202
|
+
* @param {boolean} options.forceInteractive - Force interactive mode (for testing)
|
|
203
|
+
* @param {boolean} options.forceNonInteractive - Force CI mode (for testing)
|
|
204
|
+
* @param {number} options.ciOutputInterval - Interval (ms) between CI output lines
|
|
205
|
+
*/
|
|
206
|
+
constructor(options = {}) {
|
|
207
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
208
|
+
this.isInteractive = this._determineInteractiveMode();
|
|
209
|
+
this.startTime = Date.now();
|
|
210
|
+
this.phaseStartTime = null;
|
|
211
|
+
this.currentPhase = null;
|
|
212
|
+
this.phaseStatuses = new Map();
|
|
213
|
+
this.spinner = null;
|
|
214
|
+
this.lastCIOutput = 0;
|
|
215
|
+
|
|
216
|
+
// Initialize phase statuses
|
|
217
|
+
for (const phase of PHASES) {
|
|
218
|
+
this.phaseStatuses.set(phase.id, 'pending');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Determine if we should use interactive mode
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
_determineInteractiveMode() {
|
|
227
|
+
if (this.options.forceInteractive) return true;
|
|
228
|
+
if (this.options.forceNonInteractive) return false;
|
|
229
|
+
return isInteractiveTerminal();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get phase info by ID
|
|
234
|
+
* @param {string} phaseId - Phase identifier
|
|
235
|
+
* @returns {Object|undefined} Phase configuration
|
|
236
|
+
*/
|
|
237
|
+
_getPhase(phaseId) {
|
|
238
|
+
return PHASES.find((p) => p.id === phaseId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Format phase prefix for display
|
|
243
|
+
* @param {string} phaseId - Phase identifier
|
|
244
|
+
* @returns {string} Formatted prefix (e.g., "[Step 1/4]")
|
|
245
|
+
*/
|
|
246
|
+
formatPhasePrefix(phaseId) {
|
|
247
|
+
const phase = this._getPhase(phaseId);
|
|
248
|
+
if (!phase) return '';
|
|
249
|
+
return `[Step ${phase.order}/${PHASES.length}]`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Update the current line in place (TTY) or print new line (CI)
|
|
254
|
+
* @param {string} text - Text to display
|
|
255
|
+
*/
|
|
256
|
+
updateLine(text) {
|
|
257
|
+
if (this.options.quiet) return;
|
|
258
|
+
|
|
259
|
+
if (this.isInteractive) {
|
|
260
|
+
// Truncate text to terminal width
|
|
261
|
+
const maxWidth = (process.stdout.columns || 80) - 1;
|
|
262
|
+
let displayText = text;
|
|
263
|
+
if (text.length > maxWidth) {
|
|
264
|
+
displayText = text.substring(0, maxWidth - 3) + '...';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Simple in-place update: carriage return, clear, write
|
|
268
|
+
// Note: Dynamic terminal resize may cause brief artifacts
|
|
269
|
+
process.stdout.write('\r');
|
|
270
|
+
readline.clearLine(process.stdout, 0);
|
|
271
|
+
process.stdout.write(displayText);
|
|
272
|
+
} else {
|
|
273
|
+
// CI mode: print with timestamp
|
|
274
|
+
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
275
|
+
console.log(`[${elapsed}] ${text}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Print a new line (both TTY and CI)
|
|
281
|
+
* @param {string} text - Text to display
|
|
282
|
+
*/
|
|
283
|
+
printLine(text) {
|
|
284
|
+
if (this.options.quiet) return;
|
|
285
|
+
|
|
286
|
+
if (this.isInteractive) {
|
|
287
|
+
// Truncate text to terminal width to prevent wrapping
|
|
288
|
+
const maxWidth = (process.stdout.columns || 80) - 1;
|
|
289
|
+
let displayText = text;
|
|
290
|
+
if (text.length > maxWidth) {
|
|
291
|
+
displayText = text.substring(0, maxWidth - 3) + '...';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Clear current line, write text, and move to next line
|
|
295
|
+
readline.cursorTo(process.stdout, 0);
|
|
296
|
+
readline.clearLine(process.stdout, 0);
|
|
297
|
+
process.stdout.write(displayText);
|
|
298
|
+
process.stdout.write('\n');
|
|
299
|
+
} else {
|
|
300
|
+
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
301
|
+
console.log(`[${elapsed}] ${text}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Start a new phase
|
|
307
|
+
* @param {string} phaseId - Phase identifier
|
|
308
|
+
*/
|
|
309
|
+
startPhase(phaseId) {
|
|
310
|
+
const phase = this._getPhase(phaseId);
|
|
311
|
+
if (!phase) return;
|
|
312
|
+
|
|
313
|
+
// Stop any existing spinner
|
|
314
|
+
if (this.spinner) {
|
|
315
|
+
this.spinner.stop();
|
|
316
|
+
this.spinner = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.currentPhase = phaseId;
|
|
320
|
+
this.phaseStartTime = Date.now();
|
|
321
|
+
this.phaseStatuses.set(phaseId, 'active');
|
|
322
|
+
|
|
323
|
+
const prefix = this.formatPhasePrefix(phaseId);
|
|
324
|
+
|
|
325
|
+
if (phase.progressType === 'indeterminate' && this.isInteractive) {
|
|
326
|
+
// Start spinner for indeterminate phases
|
|
327
|
+
this.spinner = new Spinner(phase.name);
|
|
328
|
+
this.spinner.start((frame, label) => {
|
|
329
|
+
this.updateLine(`${prefix} ${label} ${frame}`);
|
|
330
|
+
});
|
|
331
|
+
} else if (!this.isInteractive) {
|
|
332
|
+
// CI mode: print phase start
|
|
333
|
+
this.printLine(`${prefix} ${phase.name}...`);
|
|
334
|
+
this.lastCIOutput = Date.now();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Update progress for the current phase
|
|
340
|
+
* @param {Object} update - Progress update
|
|
341
|
+
* @param {string} update.phaseId - Phase identifier
|
|
342
|
+
* @param {number} [update.current] - Current progress value
|
|
343
|
+
* @param {number} [update.total] - Total progress value
|
|
344
|
+
* @param {string} [update.message] - Optional status message
|
|
345
|
+
*/
|
|
346
|
+
updateProgress(update) {
|
|
347
|
+
if (this.options.quiet) return;
|
|
348
|
+
|
|
349
|
+
const { phaseId, current, total, message } = update;
|
|
350
|
+
const phase = this._getPhase(phaseId);
|
|
351
|
+
if (!phase) return;
|
|
352
|
+
|
|
353
|
+
const prefix = this.formatPhasePrefix(phaseId);
|
|
354
|
+
const elapsed = Date.now() - (this.phaseStartTime || this.startTime);
|
|
355
|
+
|
|
356
|
+
// Status-specific handling for deps phase (must come before generic checks)
|
|
357
|
+
if (
|
|
358
|
+
phase.progressType === 'determinate' &&
|
|
359
|
+
update.status === 'downloading' &&
|
|
360
|
+
current !== undefined &&
|
|
361
|
+
total !== undefined
|
|
362
|
+
) {
|
|
363
|
+
// Downloading phase with known total (from collecting count)
|
|
364
|
+
// Guard against division by zero
|
|
365
|
+
if (total === 0) {
|
|
366
|
+
// Fall back to indeterminate spinner if total is invalid
|
|
367
|
+
if (this.isInteractive) {
|
|
368
|
+
if (!this.spinner || !this.spinner.isRunning) {
|
|
369
|
+
this.spinner = new Spinner('Downloading packages');
|
|
370
|
+
this.spinner.start((frame, label) => {
|
|
371
|
+
this.updateLine(`${prefix} ${frame} ${label}`);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const percentage = Math.round((current / total) * 100);
|
|
379
|
+
const progressBar = renderProgressBar(percentage);
|
|
380
|
+
const countStr = ` (${current}/${total})`;
|
|
381
|
+
|
|
382
|
+
const displayText = `${prefix} Downloading packages ${progressBar} ${percentage}%${countStr}`;
|
|
383
|
+
|
|
384
|
+
if (this.isInteractive) {
|
|
385
|
+
// Stop spinner if running, switch to progress bar
|
|
386
|
+
if (this.spinner && this.spinner.isRunning) {
|
|
387
|
+
this.spinner.stop();
|
|
388
|
+
this.spinner = null;
|
|
389
|
+
}
|
|
390
|
+
this.updateLine(displayText);
|
|
391
|
+
} else {
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
if (now - this.lastCIOutput >= this.options.ciOutputInterval) {
|
|
394
|
+
this.printLine(
|
|
395
|
+
`${prefix} Downloading packages... ${percentage}%${countStr}`
|
|
396
|
+
);
|
|
397
|
+
this.lastCIOutput = now;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else if (
|
|
401
|
+
phase.progressType === 'determinate' &&
|
|
402
|
+
update.status === 'installing'
|
|
403
|
+
) {
|
|
404
|
+
// Installing phase - show spinner with "Installing packages..." and elapsed time
|
|
405
|
+
const statusLabel = 'Installing packages';
|
|
406
|
+
|
|
407
|
+
if (this.isInteractive) {
|
|
408
|
+
if (!this.spinner || !this.spinner.isRunning) {
|
|
409
|
+
this.spinner = new Spinner(statusLabel);
|
|
410
|
+
this.spinner.start((frame, label) => {
|
|
411
|
+
const currentElapsed = formatDuration(
|
|
412
|
+
Date.now() - (this.phaseStartTime || this.startTime)
|
|
413
|
+
);
|
|
414
|
+
this.updateLine(`${prefix} ${label} [${currentElapsed}] ${frame}`);
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
this.spinner.setLabel(statusLabel);
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
if (now - this.lastCIOutput >= this.options.ciOutputInterval) {
|
|
422
|
+
const elapsedStr = formatDuration(elapsed);
|
|
423
|
+
this.printLine(`${prefix} ${statusLabel}... [${elapsedStr}]`);
|
|
424
|
+
this.lastCIOutput = now;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else if (
|
|
428
|
+
phase.progressType === 'determinate' &&
|
|
429
|
+
current !== undefined &&
|
|
430
|
+
total !== undefined &&
|
|
431
|
+
total > 0
|
|
432
|
+
) {
|
|
433
|
+
// Generic determinate progress with known total (e.g., Python runtime download)
|
|
434
|
+
const rawPercentage = (current / total) * 100;
|
|
435
|
+
const percentage = Math.max(0, Math.min(100, Math.round(rawPercentage)));
|
|
436
|
+
const progressBar = renderProgressBar(percentage);
|
|
437
|
+
|
|
438
|
+
// Format size display (for downloads) - compact format for 80-column terminals
|
|
439
|
+
let sizeStr = '';
|
|
440
|
+
if (total > 1000) {
|
|
441
|
+
// Likely bytes - use shared unit for compactness
|
|
442
|
+
const currentBytes = formatBytes(current);
|
|
443
|
+
const totalBytes = formatBytes(total);
|
|
444
|
+
// Extract unit from total (e.g., "50.0 MB" -> "MB")
|
|
445
|
+
const unit = totalBytes.split(' ')[1];
|
|
446
|
+
const currentVal = currentBytes.split(' ')[0];
|
|
447
|
+
const totalVal = totalBytes.split(' ')[0];
|
|
448
|
+
sizeStr = ` (${currentVal}/${totalVal} ${unit})`;
|
|
449
|
+
} else {
|
|
450
|
+
// Likely count (packages)
|
|
451
|
+
sizeStr = ` (${current}/${total})`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const displayText = `${prefix} ${phase.name} ${progressBar} ${percentage}%${sizeStr}`;
|
|
455
|
+
|
|
456
|
+
if (this.isInteractive) {
|
|
457
|
+
// Stop spinner if running (e.g., when deps transitions from installing spinner to determinate progress)
|
|
458
|
+
if (this.spinner && this.spinner.isRunning) {
|
|
459
|
+
this.spinner.stop();
|
|
460
|
+
this.spinner = null;
|
|
461
|
+
}
|
|
462
|
+
this.updateLine(displayText);
|
|
463
|
+
} else {
|
|
464
|
+
// CI mode: output periodically
|
|
465
|
+
const now = Date.now();
|
|
466
|
+
if (now - this.lastCIOutput >= this.options.ciOutputInterval) {
|
|
467
|
+
this.printLine(`${prefix} ${phase.name}... ${percentage}%${sizeStr}`);
|
|
468
|
+
this.lastCIOutput = now;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Verbose mode: show additional details
|
|
473
|
+
if (this.options.verbose && message) {
|
|
474
|
+
if (this.isInteractive) {
|
|
475
|
+
console.log(`\n → ${message}`);
|
|
476
|
+
} else {
|
|
477
|
+
console.log(` → ${message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} else if (
|
|
481
|
+
phase.progressType === 'determinate' &&
|
|
482
|
+
current !== undefined &&
|
|
483
|
+
total === undefined
|
|
484
|
+
) {
|
|
485
|
+
// Progress with count but unknown total (e.g., pip collecting dependencies)
|
|
486
|
+
// Show spinner with count, status, and elapsed time
|
|
487
|
+
const status = update.status || 'collecting';
|
|
488
|
+
let statusLabel = phase.name;
|
|
489
|
+
let countStr = ` (${current} packages)`;
|
|
490
|
+
const elapsedStr = ` [${formatDuration(elapsed)}]`;
|
|
491
|
+
|
|
492
|
+
// Customize display based on pip phase
|
|
493
|
+
if (status === 'collecting') {
|
|
494
|
+
statusLabel = 'Collecting dependencies';
|
|
495
|
+
countStr = ` (${current} packages)`;
|
|
496
|
+
} else if (status === 'cached') {
|
|
497
|
+
statusLabel = 'Using cached packages';
|
|
498
|
+
countStr = ` (${current} cached)`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (this.isInteractive) {
|
|
502
|
+
// Use spinner with elapsed time to show activity
|
|
503
|
+
if (this.spinner && this.spinner.isRunning) {
|
|
504
|
+
this.spinner.setLabel(`${statusLabel}${countStr}${elapsedStr}`);
|
|
505
|
+
} else {
|
|
506
|
+
// Start spinner for count-based progress
|
|
507
|
+
if (!this.spinner) {
|
|
508
|
+
this.spinner = new Spinner(
|
|
509
|
+
`${statusLabel}${countStr}${elapsedStr}`
|
|
510
|
+
);
|
|
511
|
+
this.spinner.start((frame, label) => {
|
|
512
|
+
// Update elapsed time on each frame
|
|
513
|
+
const currentElapsed = formatDuration(
|
|
514
|
+
Date.now() - (this.phaseStartTime || this.startTime)
|
|
515
|
+
);
|
|
516
|
+
const baseLabel = label.replace(/\s*\[\d+m?\s*\d*s\]$/, ''); // Remove old elapsed
|
|
517
|
+
this.updateLine(
|
|
518
|
+
`${prefix} ${baseLabel} [${currentElapsed}] ${frame}`
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
this.spinner.setLabel(`${statusLabel}${countStr}${elapsedStr}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
// CI mode: output periodically
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
if (now - this.lastCIOutput >= this.options.ciOutputInterval) {
|
|
529
|
+
this.printLine(`${prefix} ${statusLabel}...${countStr}${elapsedStr}`);
|
|
530
|
+
this.lastCIOutput = now;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Verbose mode: show package name
|
|
535
|
+
if (this.options.verbose && message) {
|
|
536
|
+
if (this.isInteractive) {
|
|
537
|
+
readline.clearLine(process.stdout, 0);
|
|
538
|
+
readline.cursorTo(process.stdout, 0);
|
|
539
|
+
console.log(` → ${message}`);
|
|
540
|
+
} else {
|
|
541
|
+
console.log(` → ${message}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} else if (phase.progressType === 'indeterminate') {
|
|
545
|
+
// For indeterminate phases, spinner handles display in interactive mode
|
|
546
|
+
// In CI mode, output periodically
|
|
547
|
+
if (!this.isInteractive) {
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
if (now - this.lastCIOutput >= this.options.ciOutputInterval) {
|
|
550
|
+
this.printLine(`${prefix} ${phase.name}...`);
|
|
551
|
+
this.lastCIOutput = now;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Verbose mode: show message if provided
|
|
556
|
+
if (this.options.verbose && message) {
|
|
557
|
+
if (this.isInteractive) {
|
|
558
|
+
// Clear spinner line and print message
|
|
559
|
+
readline.clearLine(process.stdout, 0);
|
|
560
|
+
readline.cursorTo(process.stdout, 0);
|
|
561
|
+
console.log(` → ${message}`);
|
|
562
|
+
} else {
|
|
563
|
+
console.log(` → ${message}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Mark a phase as complete
|
|
571
|
+
* @param {string} phaseId - Phase identifier
|
|
572
|
+
*/
|
|
573
|
+
completePhase(phaseId) {
|
|
574
|
+
const phase = this._getPhase(phaseId);
|
|
575
|
+
if (!phase) return;
|
|
576
|
+
|
|
577
|
+
// Stop spinner if running
|
|
578
|
+
if (this.spinner) {
|
|
579
|
+
this.spinner.stop();
|
|
580
|
+
this.spinner = null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
this.phaseStatuses.set(phaseId, 'completed');
|
|
584
|
+
const prefix = this.formatPhasePrefix(phaseId);
|
|
585
|
+
|
|
586
|
+
this.printLine(`${prefix} ${phase.name} ✓`);
|
|
587
|
+
this.currentPhase = null;
|
|
588
|
+
this.phaseStartTime = null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Mark a phase as failed
|
|
593
|
+
* @param {string} phaseId - Phase identifier
|
|
594
|
+
* @param {Error} error - The error that occurred
|
|
595
|
+
*/
|
|
596
|
+
failPhase(phaseId, error) {
|
|
597
|
+
const phase = this._getPhase(phaseId);
|
|
598
|
+
if (!phase) return;
|
|
599
|
+
|
|
600
|
+
// Stop spinner if running
|
|
601
|
+
if (this.spinner) {
|
|
602
|
+
this.spinner.stop();
|
|
603
|
+
this.spinner = null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
this.phaseStatuses.set(phaseId, 'failed');
|
|
607
|
+
|
|
608
|
+
// Clear current line and display error
|
|
609
|
+
if (this.isInteractive) {
|
|
610
|
+
readline.clearLine(process.stdout, 0);
|
|
611
|
+
readline.cursorTo(process.stdout, 0);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.log(`\n❌ Failed: ${phase.name}`);
|
|
615
|
+
console.log(`\nError: ${error.message}`);
|
|
616
|
+
console.log(`\nSuggested actions:`);
|
|
617
|
+
console.log(` • Check your internet connection`);
|
|
618
|
+
console.log(` • If behind a proxy, set HTTP_PROXY/HTTPS_PROXY`);
|
|
619
|
+
console.log(` • Run with --verbose for detailed output`);
|
|
620
|
+
|
|
621
|
+
this.currentPhase = null;
|
|
622
|
+
this.phaseStartTime = null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Mark a phase as skipped (cache hit)
|
|
627
|
+
* @param {string} phaseId - Phase identifier
|
|
628
|
+
*/
|
|
629
|
+
skipPhase(phaseId) {
|
|
630
|
+
this.phaseStatuses.set(phaseId, 'skipped');
|
|
631
|
+
// Silent skip per FR-007 - no output
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Display final completion summary
|
|
636
|
+
*/
|
|
637
|
+
complete() {
|
|
638
|
+
if (this.options.quiet) return;
|
|
639
|
+
|
|
640
|
+
// Stop any running spinner
|
|
641
|
+
if (this.spinner) {
|
|
642
|
+
this.spinner.stop();
|
|
643
|
+
this.spinner = null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const totalTime = formatDuration(Date.now() - this.startTime);
|
|
647
|
+
this.printLine(`\n✓ Initialization complete (${totalTime})`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Create a progress callback function for use with initialization functions
|
|
652
|
+
* @returns {Function} Callback function accepting progress updates
|
|
653
|
+
*/
|
|
654
|
+
createCallback() {
|
|
655
|
+
return (update) => {
|
|
656
|
+
switch (update.type) {
|
|
657
|
+
case 'start':
|
|
658
|
+
this.startPhase(update.phaseId);
|
|
659
|
+
break;
|
|
660
|
+
case 'progress':
|
|
661
|
+
this.updateProgress(update);
|
|
662
|
+
break;
|
|
663
|
+
case 'complete':
|
|
664
|
+
this.completePhase(update.phaseId);
|
|
665
|
+
break;
|
|
666
|
+
case 'error':
|
|
667
|
+
this.failPhase(update.phaseId, update.error);
|
|
668
|
+
break;
|
|
669
|
+
case 'skip':
|
|
670
|
+
this.skipPhase(update.phaseId);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export default ProgressReporter;
|