agileflow 2.87.0 → 2.89.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/CHANGELOG.md +10 -0
- package/lib/file-cache.js +358 -0
- package/lib/progress.js +331 -0
- package/lib/validate.js +281 -1
- package/lib/yaml-utils.js +122 -0
- package/package.json +1 -3
- package/scripts/agileflow-welcome.js +19 -33
- package/scripts/damage-control-bash.js +2 -59
- package/scripts/damage-control-edit.js +4 -69
- package/scripts/damage-control-write.js +4 -69
- package/scripts/lib/damage-control-utils.js +153 -0
- package/scripts/obtain-context.js +4 -5
- package/tools/cli/installers/core/installer.js +32 -2
- package/tools/cli/installers/ide/_base-ide.js +143 -19
- package/tools/cli/installers/ide/claude-code.js +14 -51
- package/tools/cli/installers/ide/cursor.js +4 -40
- package/tools/cli/installers/ide/windsurf.js +6 -40
- package/tools/cli/lib/content-injector.js +37 -0
- package/tools/cli/lib/ide-errors.js +233 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.89.0] - 2026-01-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Security hardening, LRU file caching, and comprehensive test coverage
|
|
14
|
+
|
|
15
|
+
## [2.88.0] - 2026-01-13
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Security and code quality improvements from EP-0012 ideation
|
|
19
|
+
|
|
10
20
|
## [2.87.0] - 2026-01-13
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Cache - LRU Cache for frequently read JSON files
|
|
3
|
+
*
|
|
4
|
+
* Optimizes performance by caching frequently accessed JSON files
|
|
5
|
+
* with configurable TTL (Time To Live).
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - LRU (Least Recently Used) eviction when max size reached
|
|
9
|
+
* - TTL-based automatic expiration
|
|
10
|
+
* - Separate caches for different data types
|
|
11
|
+
* - Thread-safe for single-process Node.js usage
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* LRU Cache implementation with TTL support
|
|
19
|
+
*/
|
|
20
|
+
class LRUCache {
|
|
21
|
+
/**
|
|
22
|
+
* Create a new LRU Cache
|
|
23
|
+
* @param {Object} options
|
|
24
|
+
* @param {number} [options.maxSize=100] - Maximum number of entries
|
|
25
|
+
* @param {number} [options.ttlMs=30000] - Time to live in milliseconds (default 30s)
|
|
26
|
+
*/
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.maxSize = options.maxSize || 100;
|
|
29
|
+
this.ttlMs = options.ttlMs || 30000;
|
|
30
|
+
this.cache = new Map();
|
|
31
|
+
this.stats = {
|
|
32
|
+
hits: 0,
|
|
33
|
+
misses: 0,
|
|
34
|
+
evictions: 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get a value from cache
|
|
40
|
+
* @param {string} key - Cache key
|
|
41
|
+
* @returns {*} Cached value or undefined if not found/expired
|
|
42
|
+
*/
|
|
43
|
+
get(key) {
|
|
44
|
+
const entry = this.cache.get(key);
|
|
45
|
+
|
|
46
|
+
if (!entry) {
|
|
47
|
+
this.stats.misses++;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check if expired
|
|
52
|
+
if (Date.now() > entry.expiresAt) {
|
|
53
|
+
this.cache.delete(key);
|
|
54
|
+
this.stats.misses++;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Move to end (most recently used)
|
|
59
|
+
this.cache.delete(key);
|
|
60
|
+
this.cache.set(key, entry);
|
|
61
|
+
|
|
62
|
+
this.stats.hits++;
|
|
63
|
+
return entry.value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set a value in cache
|
|
68
|
+
* @param {string} key - Cache key
|
|
69
|
+
* @param {*} value - Value to cache
|
|
70
|
+
* @param {number} [ttlMs] - Optional custom TTL for this entry
|
|
71
|
+
*/
|
|
72
|
+
set(key, value, ttlMs = this.ttlMs) {
|
|
73
|
+
// Remove existing entry if present
|
|
74
|
+
if (this.cache.has(key)) {
|
|
75
|
+
this.cache.delete(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Evict oldest entries if at capacity
|
|
79
|
+
while (this.cache.size >= this.maxSize) {
|
|
80
|
+
const oldestKey = this.cache.keys().next().value;
|
|
81
|
+
this.cache.delete(oldestKey);
|
|
82
|
+
this.stats.evictions++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Add new entry
|
|
86
|
+
this.cache.set(key, {
|
|
87
|
+
value,
|
|
88
|
+
expiresAt: Date.now() + ttlMs,
|
|
89
|
+
cachedAt: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if key exists and is not expired
|
|
95
|
+
* @param {string} key - Cache key
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
has(key) {
|
|
99
|
+
const entry = this.cache.get(key);
|
|
100
|
+
if (!entry) return false;
|
|
101
|
+
if (Date.now() > entry.expiresAt) {
|
|
102
|
+
this.cache.delete(key);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove a key from cache
|
|
110
|
+
* @param {string} key - Cache key
|
|
111
|
+
* @returns {boolean} True if key was removed
|
|
112
|
+
*/
|
|
113
|
+
delete(key) {
|
|
114
|
+
return this.cache.delete(key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Clear all entries
|
|
119
|
+
*/
|
|
120
|
+
clear() {
|
|
121
|
+
this.cache.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get cache statistics
|
|
126
|
+
* @returns {Object} Stats object
|
|
127
|
+
*/
|
|
128
|
+
getStats() {
|
|
129
|
+
return {
|
|
130
|
+
...this.stats,
|
|
131
|
+
size: this.cache.size,
|
|
132
|
+
maxSize: this.maxSize,
|
|
133
|
+
hitRate: this.stats.hits + this.stats.misses > 0
|
|
134
|
+
? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(1) + '%'
|
|
135
|
+
: '0%',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get number of entries in cache
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
get size() {
|
|
144
|
+
return this.cache.size;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// File Cache Singleton
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
// Global cache instance (persists across requires in same process)
|
|
153
|
+
const fileCache = new LRUCache({
|
|
154
|
+
maxSize: 50,
|
|
155
|
+
ttlMs: 30000, // 30 seconds
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read and cache a JSON file
|
|
160
|
+
* @param {string} filePath - Absolute path to JSON file
|
|
161
|
+
* @param {Object} [options]
|
|
162
|
+
* @param {boolean} [options.force=false] - Skip cache and force read
|
|
163
|
+
* @param {number} [options.ttlMs] - Custom TTL for this file
|
|
164
|
+
* @returns {Object|null} Parsed JSON or null if error
|
|
165
|
+
*/
|
|
166
|
+
function readJSONCached(filePath, options = {}) {
|
|
167
|
+
const { force = false, ttlMs } = options;
|
|
168
|
+
const cacheKey = `json:${filePath}`;
|
|
169
|
+
|
|
170
|
+
// Check cache first (unless force reload)
|
|
171
|
+
if (!force) {
|
|
172
|
+
const cached = fileCache.get(cacheKey);
|
|
173
|
+
if (cached !== undefined) {
|
|
174
|
+
return cached;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Read from disk
|
|
179
|
+
try {
|
|
180
|
+
if (!fs.existsSync(filePath)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
184
|
+
const data = JSON.parse(content);
|
|
185
|
+
|
|
186
|
+
// Cache the result
|
|
187
|
+
fileCache.set(cacheKey, data, ttlMs);
|
|
188
|
+
|
|
189
|
+
return data;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Cache null to avoid repeated failed reads
|
|
192
|
+
fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read and cache a text file
|
|
199
|
+
* @param {string} filePath - Absolute path to file
|
|
200
|
+
* @param {Object} [options]
|
|
201
|
+
* @param {boolean} [options.force=false] - Skip cache and force read
|
|
202
|
+
* @param {number} [options.ttlMs] - Custom TTL for this file
|
|
203
|
+
* @returns {string|null} File content or null if error
|
|
204
|
+
*/
|
|
205
|
+
function readFileCached(filePath, options = {}) {
|
|
206
|
+
const { force = false, ttlMs } = options;
|
|
207
|
+
const cacheKey = `file:${filePath}`;
|
|
208
|
+
|
|
209
|
+
// Check cache first (unless force reload)
|
|
210
|
+
if (!force) {
|
|
211
|
+
const cached = fileCache.get(cacheKey);
|
|
212
|
+
if (cached !== undefined) {
|
|
213
|
+
return cached;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Read from disk
|
|
218
|
+
try {
|
|
219
|
+
if (!fs.existsSync(filePath)) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
223
|
+
|
|
224
|
+
// Cache the result
|
|
225
|
+
fileCache.set(cacheKey, content, ttlMs);
|
|
226
|
+
|
|
227
|
+
return content;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// Cache null to avoid repeated failed reads
|
|
230
|
+
fileCache.set(cacheKey, null, 5000); // 5s TTL for errors
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Invalidate cache for a specific file
|
|
237
|
+
* Call this after writing to a cached file
|
|
238
|
+
* @param {string} filePath - Absolute path to file
|
|
239
|
+
*/
|
|
240
|
+
function invalidate(filePath) {
|
|
241
|
+
fileCache.delete(`json:${filePath}`);
|
|
242
|
+
fileCache.delete(`file:${filePath}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Invalidate cache for all files in a directory
|
|
247
|
+
* @param {string} dirPath - Directory path
|
|
248
|
+
*/
|
|
249
|
+
function invalidateDir(dirPath) {
|
|
250
|
+
const normalizedDir = path.normalize(dirPath);
|
|
251
|
+
for (const key of fileCache.cache.keys()) {
|
|
252
|
+
const keyPath = key.replace(/^(json|file):/, '');
|
|
253
|
+
if (keyPath.startsWith(normalizedDir)) {
|
|
254
|
+
fileCache.delete(key);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Clear entire cache
|
|
261
|
+
*/
|
|
262
|
+
function clearCache() {
|
|
263
|
+
fileCache.clear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get cache statistics
|
|
268
|
+
* @returns {Object} Cache stats
|
|
269
|
+
*/
|
|
270
|
+
function getCacheStats() {
|
|
271
|
+
return fileCache.getStats();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// =============================================================================
|
|
275
|
+
// Convenience Methods for Common Files
|
|
276
|
+
// =============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read status.json with caching
|
|
280
|
+
* @param {string} rootDir - Project root directory
|
|
281
|
+
* @param {Object} [options]
|
|
282
|
+
* @returns {Object|null}
|
|
283
|
+
*/
|
|
284
|
+
function readStatus(rootDir, options = {}) {
|
|
285
|
+
const filePath = path.join(rootDir, 'docs', '09-agents', 'status.json');
|
|
286
|
+
return readJSONCached(filePath, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Read session-state.json with caching
|
|
291
|
+
* @param {string} rootDir - Project root directory
|
|
292
|
+
* @param {Object} [options]
|
|
293
|
+
* @returns {Object|null}
|
|
294
|
+
*/
|
|
295
|
+
function readSessionState(rootDir, options = {}) {
|
|
296
|
+
const filePath = path.join(rootDir, 'docs', '09-agents', 'session-state.json');
|
|
297
|
+
return readJSONCached(filePath, options);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read agileflow-metadata.json with caching
|
|
302
|
+
* @param {string} rootDir - Project root directory
|
|
303
|
+
* @param {Object} [options]
|
|
304
|
+
* @returns {Object|null}
|
|
305
|
+
*/
|
|
306
|
+
function readMetadata(rootDir, options = {}) {
|
|
307
|
+
const filePath = path.join(rootDir, 'docs', '00-meta', 'agileflow-metadata.json');
|
|
308
|
+
return readJSONCached(filePath, options);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Read registry.json with caching
|
|
313
|
+
* @param {string} rootDir - Project root directory
|
|
314
|
+
* @param {Object} [options]
|
|
315
|
+
* @returns {Object|null}
|
|
316
|
+
*/
|
|
317
|
+
function readRegistry(rootDir, options = {}) {
|
|
318
|
+
const filePath = path.join(rootDir, '.agileflow', 'sessions', 'registry.json');
|
|
319
|
+
return readJSONCached(filePath, options);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Batch read multiple common files
|
|
324
|
+
* More efficient than reading each individually
|
|
325
|
+
* @param {string} rootDir - Project root directory
|
|
326
|
+
* @param {Object} [options]
|
|
327
|
+
* @returns {Object} Object with status, sessionState, metadata, registry
|
|
328
|
+
*/
|
|
329
|
+
function readProjectFiles(rootDir, options = {}) {
|
|
330
|
+
return {
|
|
331
|
+
status: readStatus(rootDir, options),
|
|
332
|
+
sessionState: readSessionState(rootDir, options),
|
|
333
|
+
metadata: readMetadata(rootDir, options),
|
|
334
|
+
registry: readRegistry(rootDir, options),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = {
|
|
339
|
+
// Core LRU Cache class (for custom usage)
|
|
340
|
+
LRUCache,
|
|
341
|
+
|
|
342
|
+
// File reading with caching
|
|
343
|
+
readJSONCached,
|
|
344
|
+
readFileCached,
|
|
345
|
+
|
|
346
|
+
// Cache management
|
|
347
|
+
invalidate,
|
|
348
|
+
invalidateDir,
|
|
349
|
+
clearCache,
|
|
350
|
+
getCacheStats,
|
|
351
|
+
|
|
352
|
+
// Convenience methods for common files
|
|
353
|
+
readStatus,
|
|
354
|
+
readSessionState,
|
|
355
|
+
readMetadata,
|
|
356
|
+
readRegistry,
|
|
357
|
+
readProjectFiles,
|
|
358
|
+
};
|
package/lib/progress.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* progress.js - Progress Indicators for Long-Running Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides animated spinners and micro-progress indicators that meet
|
|
5
|
+
* the Doherty Threshold (<400ms feels instant).
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Animated braille spinner
|
|
9
|
+
* - Micro-progress with current/total counts
|
|
10
|
+
* - TTY detection (no animation in non-TTY)
|
|
11
|
+
* - Minimal overhead for fast operations
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { c } = require('./colors');
|
|
15
|
+
|
|
16
|
+
// Braille spinner characters (smooth animation)
|
|
17
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
18
|
+
|
|
19
|
+
// Doherty Threshold - operations faster than this feel instant
|
|
20
|
+
const DOHERTY_THRESHOLD_MS = 400;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Progress spinner for long-running operations.
|
|
24
|
+
* Automatically handles TTY detection and cleanup.
|
|
25
|
+
*/
|
|
26
|
+
class Spinner {
|
|
27
|
+
/**
|
|
28
|
+
* Create a new spinner
|
|
29
|
+
* @param {string} message - Initial message to display
|
|
30
|
+
* @param {Object} [options={}] - Spinner options
|
|
31
|
+
* @param {number} [options.interval=80] - Animation interval in ms
|
|
32
|
+
* @param {boolean} [options.enabled=true] - Whether spinner is enabled
|
|
33
|
+
*/
|
|
34
|
+
constructor(message, options = {}) {
|
|
35
|
+
this.message = message;
|
|
36
|
+
this.interval = options.interval || 80;
|
|
37
|
+
this.enabled = options.enabled !== false && process.stdout.isTTY;
|
|
38
|
+
this.frameIndex = 0;
|
|
39
|
+
this.timer = null;
|
|
40
|
+
this.startTime = null;
|
|
41
|
+
this.current = 0;
|
|
42
|
+
this.total = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start the spinner animation
|
|
47
|
+
* @returns {Spinner} this for chaining
|
|
48
|
+
*/
|
|
49
|
+
start() {
|
|
50
|
+
if (!this.enabled) {
|
|
51
|
+
// In non-TTY, just print the message once
|
|
52
|
+
console.log(`${c.dim}${this.message}${c.reset}`);
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.startTime = Date.now();
|
|
57
|
+
this.render();
|
|
58
|
+
this.timer = setInterval(() => {
|
|
59
|
+
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
60
|
+
this.render();
|
|
61
|
+
}, this.interval);
|
|
62
|
+
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Update the spinner message
|
|
68
|
+
* @param {string} message - New message
|
|
69
|
+
* @returns {Spinner} this for chaining
|
|
70
|
+
*/
|
|
71
|
+
update(message) {
|
|
72
|
+
this.message = message;
|
|
73
|
+
if (this.enabled && this.timer) {
|
|
74
|
+
this.render();
|
|
75
|
+
}
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Update micro-progress (current/total)
|
|
81
|
+
* @param {number} current - Current item number
|
|
82
|
+
* @param {number} total - Total items
|
|
83
|
+
* @param {string} [action] - Action being performed (e.g., 'Archiving')
|
|
84
|
+
* @returns {Spinner} this for chaining
|
|
85
|
+
*/
|
|
86
|
+
progress(current, total, action = null) {
|
|
87
|
+
this.current = current;
|
|
88
|
+
this.total = total;
|
|
89
|
+
if (action) {
|
|
90
|
+
this.message = `${action}... ${current}/${total}`;
|
|
91
|
+
} else {
|
|
92
|
+
this.message = `${this.message.split('...')[0]}... ${current}/${total}`;
|
|
93
|
+
}
|
|
94
|
+
if (this.enabled && this.timer) {
|
|
95
|
+
this.render();
|
|
96
|
+
}
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render the current spinner frame
|
|
102
|
+
* @private
|
|
103
|
+
*/
|
|
104
|
+
render() {
|
|
105
|
+
if (!this.enabled) return;
|
|
106
|
+
|
|
107
|
+
const frame = SPINNER_FRAMES[this.frameIndex];
|
|
108
|
+
const line = `${c.cyan}${frame}${c.reset} ${this.message}`;
|
|
109
|
+
|
|
110
|
+
// Clear line and write new content
|
|
111
|
+
process.stdout.clearLine(0);
|
|
112
|
+
process.stdout.cursorTo(0);
|
|
113
|
+
process.stdout.write(line);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stop the spinner with a success message
|
|
118
|
+
* @param {string} [message] - Success message (defaults to original message)
|
|
119
|
+
* @returns {Spinner} this for chaining
|
|
120
|
+
*/
|
|
121
|
+
succeed(message = null) {
|
|
122
|
+
return this.stop('✓', message || this.message, c.green);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stop the spinner with a failure message
|
|
127
|
+
* @param {string} [message] - Failure message (defaults to original message)
|
|
128
|
+
* @returns {Spinner} this for chaining
|
|
129
|
+
*/
|
|
130
|
+
fail(message = null) {
|
|
131
|
+
return this.stop('✗', message || this.message, c.red);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stop the spinner with a warning message
|
|
136
|
+
* @param {string} [message] - Warning message (defaults to original message)
|
|
137
|
+
* @returns {Spinner} this for chaining
|
|
138
|
+
*/
|
|
139
|
+
warn(message = null) {
|
|
140
|
+
return this.stop('⚠', message || this.message, c.yellow);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stop the spinner with an info message
|
|
145
|
+
* @param {string} [message] - Info message (defaults to original message)
|
|
146
|
+
* @returns {Spinner} this for chaining
|
|
147
|
+
*/
|
|
148
|
+
info(message = null) {
|
|
149
|
+
return this.stop('ℹ', message || this.message, c.blue);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stop the spinner with a custom symbol
|
|
154
|
+
* @param {string} symbol - Symbol to display
|
|
155
|
+
* @param {string} message - Final message
|
|
156
|
+
* @param {string} [color=''] - ANSI color code
|
|
157
|
+
* @returns {Spinner} this for chaining
|
|
158
|
+
*/
|
|
159
|
+
stop(symbol, message, color = '') {
|
|
160
|
+
if (this.timer) {
|
|
161
|
+
clearInterval(this.timer);
|
|
162
|
+
this.timer = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (this.enabled) {
|
|
166
|
+
process.stdout.clearLine(0);
|
|
167
|
+
process.stdout.cursorTo(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const elapsed = this.startTime ? Date.now() - this.startTime : 0;
|
|
171
|
+
const suffix = elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
|
|
172
|
+
|
|
173
|
+
console.log(`${color}${symbol}${c.reset} ${message}${suffix}`);
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if operation was fast (under Doherty Threshold)
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
wasFast() {
|
|
182
|
+
return this.startTime && (Date.now() - this.startTime) < DOHERTY_THRESHOLD_MS;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format duration for display
|
|
188
|
+
* @param {number} ms - Duration in milliseconds
|
|
189
|
+
* @returns {string} Formatted duration
|
|
190
|
+
*/
|
|
191
|
+
function formatDuration(ms) {
|
|
192
|
+
if (ms < 1000) {
|
|
193
|
+
return `${ms}ms`;
|
|
194
|
+
}
|
|
195
|
+
const seconds = (ms / 1000).toFixed(1);
|
|
196
|
+
return `${seconds}s`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create and start a new spinner
|
|
201
|
+
* @param {string} message - Message to display
|
|
202
|
+
* @param {Object} [options={}] - Spinner options
|
|
203
|
+
* @returns {Spinner} Started spinner instance
|
|
204
|
+
*/
|
|
205
|
+
function createSpinner(message, options = {}) {
|
|
206
|
+
return new Spinner(message, options).start();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run an async operation with a spinner
|
|
211
|
+
* @param {string} message - Message to display
|
|
212
|
+
* @param {Function} fn - Async function to execute
|
|
213
|
+
* @param {Object} [options={}] - Spinner options
|
|
214
|
+
* @returns {Promise<any>} Result of the async function
|
|
215
|
+
*/
|
|
216
|
+
async function withSpinner(message, fn, options = {}) {
|
|
217
|
+
const spinner = createSpinner(message, options);
|
|
218
|
+
try {
|
|
219
|
+
const result = await fn(spinner);
|
|
220
|
+
spinner.succeed();
|
|
221
|
+
return result;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
spinner.fail(error.message);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Progress bar for operations with known total
|
|
230
|
+
*/
|
|
231
|
+
class ProgressBar {
|
|
232
|
+
/**
|
|
233
|
+
* Create a new progress bar
|
|
234
|
+
* @param {string} label - Label to display
|
|
235
|
+
* @param {number} total - Total items
|
|
236
|
+
* @param {Object} [options={}] - Bar options
|
|
237
|
+
* @param {number} [options.width=30] - Bar width in characters
|
|
238
|
+
*/
|
|
239
|
+
constructor(label, total, options = {}) {
|
|
240
|
+
this.label = label;
|
|
241
|
+
this.total = total;
|
|
242
|
+
this.current = 0;
|
|
243
|
+
this.width = options.width || 30;
|
|
244
|
+
this.enabled = process.stdout.isTTY;
|
|
245
|
+
this.startTime = Date.now();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Update progress
|
|
250
|
+
* @param {number} current - Current item number
|
|
251
|
+
* @param {string} [item] - Current item being processed
|
|
252
|
+
* @returns {ProgressBar} this for chaining
|
|
253
|
+
*/
|
|
254
|
+
update(current, item = null) {
|
|
255
|
+
this.current = current;
|
|
256
|
+
|
|
257
|
+
if (!this.enabled) {
|
|
258
|
+
// Non-TTY: print every 10% or on completion
|
|
259
|
+
const percent = Math.floor((current / this.total) * 100);
|
|
260
|
+
if (percent % 10 === 0 || current === this.total) {
|
|
261
|
+
console.log(`${this.label}: ${percent}% (${current}/${this.total})`);
|
|
262
|
+
}
|
|
263
|
+
return this;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const percent = this.total > 0 ? (current / this.total) : 0;
|
|
267
|
+
const filled = Math.round(this.width * percent);
|
|
268
|
+
const empty = this.width - filled;
|
|
269
|
+
|
|
270
|
+
const bar = `${c.green}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
271
|
+
const percentStr = `${Math.round(percent * 100)}%`.padStart(4);
|
|
272
|
+
const countStr = `${current}/${this.total}`;
|
|
273
|
+
const itemStr = item ? ` ${c.dim}${item}${c.reset}` : '';
|
|
274
|
+
|
|
275
|
+
process.stdout.clearLine(0);
|
|
276
|
+
process.stdout.cursorTo(0);
|
|
277
|
+
process.stdout.write(`${this.label} ${bar} ${percentStr} (${countStr})${itemStr}`);
|
|
278
|
+
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Increment progress by 1
|
|
284
|
+
* @param {string} [item] - Current item being processed
|
|
285
|
+
* @returns {ProgressBar} this for chaining
|
|
286
|
+
*/
|
|
287
|
+
increment(item = null) {
|
|
288
|
+
return this.update(this.current + 1, item);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Complete the progress bar
|
|
293
|
+
* @param {string} [message] - Completion message
|
|
294
|
+
* @returns {ProgressBar} this for chaining
|
|
295
|
+
*/
|
|
296
|
+
complete(message = null) {
|
|
297
|
+
if (this.enabled) {
|
|
298
|
+
process.stdout.clearLine(0);
|
|
299
|
+
process.stdout.cursorTo(0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const elapsed = Date.now() - this.startTime;
|
|
303
|
+
const suffix = elapsed > DOHERTY_THRESHOLD_MS ? ` ${c.dim}(${formatDuration(elapsed)})${c.reset}` : '';
|
|
304
|
+
const msg = message || `${this.label} complete`;
|
|
305
|
+
|
|
306
|
+
console.log(`${c.green}✓${c.reset} ${msg} (${this.total} items)${suffix}`);
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Create a progress bar
|
|
313
|
+
* @param {string} label - Label to display
|
|
314
|
+
* @param {number} total - Total items
|
|
315
|
+
* @param {Object} [options={}] - Bar options
|
|
316
|
+
* @returns {ProgressBar}
|
|
317
|
+
*/
|
|
318
|
+
function createProgressBar(label, total, options = {}) {
|
|
319
|
+
return new ProgressBar(label, total, options);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
Spinner,
|
|
324
|
+
ProgressBar,
|
|
325
|
+
createSpinner,
|
|
326
|
+
createProgressBar,
|
|
327
|
+
withSpinner,
|
|
328
|
+
formatDuration,
|
|
329
|
+
DOHERTY_THRESHOLD_MS,
|
|
330
|
+
SPINNER_FRAMES,
|
|
331
|
+
};
|