agileflow 2.89.2 → 2.90.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/lib/content-sanitizer.js +463 -0
  4. package/lib/error-codes.js +544 -0
  5. package/lib/errors.js +336 -5
  6. package/lib/feedback.js +561 -0
  7. package/lib/path-resolver.js +396 -0
  8. package/lib/placeholder-registry.js +617 -0
  9. package/lib/session-registry.js +461 -0
  10. package/lib/smart-json-file.js +653 -0
  11. package/lib/table-formatter.js +504 -0
  12. package/lib/transient-status.js +374 -0
  13. package/lib/ui-manager.js +612 -0
  14. package/lib/validate-args.js +213 -0
  15. package/lib/validate-names.js +143 -0
  16. package/lib/validate-paths.js +434 -0
  17. package/lib/validate.js +38 -584
  18. package/package.json +4 -1
  19. package/scripts/agileflow-configure.js +40 -1440
  20. package/scripts/agileflow-welcome.js +2 -1
  21. package/scripts/check-update.js +16 -3
  22. package/scripts/lib/configure-detect.js +383 -0
  23. package/scripts/lib/configure-features.js +811 -0
  24. package/scripts/lib/configure-repair.js +314 -0
  25. package/scripts/lib/configure-utils.js +115 -0
  26. package/scripts/lib/frontmatter-parser.js +3 -3
  27. package/scripts/lib/sessionRegistry.js +682 -0
  28. package/scripts/obtain-context.js +417 -113
  29. package/scripts/ralph-loop.js +1 -1
  30. package/scripts/session-manager.js +77 -10
  31. package/scripts/tui/App.js +176 -0
  32. package/scripts/tui/index.js +75 -0
  33. package/scripts/tui/lib/crashRecovery.js +302 -0
  34. package/scripts/tui/lib/eventStream.js +316 -0
  35. package/scripts/tui/lib/keyboard.js +252 -0
  36. package/scripts/tui/lib/loopControl.js +371 -0
  37. package/scripts/tui/panels/OutputPanel.js +278 -0
  38. package/scripts/tui/panels/SessionPanel.js +178 -0
  39. package/scripts/tui/panels/TracePanel.js +333 -0
  40. package/src/core/commands/tui.md +91 -0
  41. package/tools/cli/commands/config.js +10 -33
  42. package/tools/cli/commands/doctor.js +48 -40
  43. package/tools/cli/commands/list.js +49 -37
  44. package/tools/cli/commands/status.js +13 -37
  45. package/tools/cli/commands/uninstall.js +12 -41
  46. package/tools/cli/installers/core/installer.js +75 -12
  47. package/tools/cli/installers/ide/_interface.js +238 -0
  48. package/tools/cli/installers/ide/codex.js +2 -2
  49. package/tools/cli/installers/ide/manager.js +15 -0
  50. package/tools/cli/lib/command-context.js +374 -0
  51. package/tools/cli/lib/config-manager.js +394 -0
  52. package/tools/cli/lib/content-injector.js +69 -16
  53. package/tools/cli/lib/ide-errors.js +163 -29
  54. package/tools/cli/lib/ide-registry.js +186 -0
  55. package/tools/cli/lib/npm-utils.js +16 -3
  56. package/tools/cli/lib/self-update.js +148 -0
  57. package/tools/cli/lib/validation-middleware.js +491 -0
@@ -0,0 +1,653 @@
1
+ /**
2
+ * smart-json-file.js - Safe JSON File Operations with Retry Logic
3
+ *
4
+ * Provides atomic writes, automatic retries, and error code integration
5
+ * for all JSON file operations in AgileFlow.
6
+ *
7
+ * Features:
8
+ * - Atomic writes (write to temp file, then rename)
9
+ * - Configurable retry logic with exponential backoff
10
+ * - Integration with error-codes.js for typed errors
11
+ * - Schema validation support
12
+ * - Automatic directory creation
13
+ *
14
+ * Usage:
15
+ * const SmartJsonFile = require('./smart-json-file');
16
+ *
17
+ * // Basic usage
18
+ * const file = new SmartJsonFile('/path/to/file.json');
19
+ * const data = await file.read();
20
+ * await file.write({ key: 'value' });
21
+ *
22
+ * // With options
23
+ * const file = new SmartJsonFile('/path/to/file.json', {
24
+ * retries: 3,
25
+ * backoff: 100,
26
+ * createDir: true,
27
+ * schema: mySchema
28
+ * });
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { createTypedError, getErrorCodeFromError, ErrorCodes } = require('./error-codes');
34
+
35
+ // Debug logging
36
+ const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
37
+
38
+ function debugLog(operation, details) {
39
+ if (DEBUG) {
40
+ const timestamp = new Date().toISOString();
41
+ console.error(`[${timestamp}] [SmartJsonFile] ${operation}:`, JSON.stringify(details));
42
+ }
43
+ }
44
+
45
+ // Security: Secure file permission mode for sensitive config files
46
+ // 0o600 = owner read/write only (no group or world access)
47
+ const SECURE_FILE_MODE = 0o600;
48
+
49
+ /**
50
+ * Check if file permissions are too permissive (security risk)
51
+ * @param {number} mode - File mode (from fs.statSync)
52
+ * @returns {{ok: boolean, warning?: string}} Check result
53
+ */
54
+ function checkFilePermissions(mode) {
55
+ // Skip permission checks on Windows (different permission model)
56
+ if (process.platform === 'win32') {
57
+ return { ok: true };
58
+ }
59
+
60
+ // Extract permission bits (last 9 bits)
61
+ const permissions = mode & 0o777;
62
+
63
+ // Check for group/world readable/writable (security risk)
64
+ const groupRead = permissions & 0o040;
65
+ const groupWrite = permissions & 0o020;
66
+ const worldRead = permissions & 0o004;
67
+ const worldWrite = permissions & 0o002;
68
+
69
+ if (worldWrite) {
70
+ return {
71
+ ok: false,
72
+ warning: 'File is world-writable (mode: ' + permissions.toString(8) + '). Security risk - others can modify.',
73
+ };
74
+ }
75
+
76
+ if (worldRead) {
77
+ return {
78
+ ok: false,
79
+ warning: 'File is world-readable (mode: ' + permissions.toString(8) + '). May expose sensitive config.',
80
+ };
81
+ }
82
+
83
+ if (groupWrite) {
84
+ return {
85
+ ok: false,
86
+ warning: 'File is group-writable (mode: ' + permissions.toString(8) + '). Consider restricting to 0600.',
87
+ };
88
+ }
89
+
90
+ if (groupRead) {
91
+ return {
92
+ ok: false,
93
+ warning: 'File is group-readable (mode: ' + permissions.toString(8) + '). Consider restricting to 0600.',
94
+ };
95
+ }
96
+
97
+ return { ok: true };
98
+ }
99
+
100
+ /**
101
+ * Set secure permissions on a file (0o600 - owner only)
102
+ * @param {string} filePath - Path to the file
103
+ * @returns {{ok: boolean, error?: Error}}
104
+ */
105
+ function setSecurePermissions(filePath) {
106
+ // Skip on Windows
107
+ if (process.platform === 'win32') {
108
+ return { ok: true };
109
+ }
110
+
111
+ try {
112
+ fs.chmodSync(filePath, SECURE_FILE_MODE);
113
+ debugLog('setSecurePermissions', { filePath, mode: SECURE_FILE_MODE.toString(8) });
114
+ return { ok: true };
115
+ } catch (err) {
116
+ const error = createTypedError(`Failed to set secure permissions on ${filePath}: ${err.message}`, 'EPERM', {
117
+ cause: err,
118
+ context: { filePath, mode: SECURE_FILE_MODE },
119
+ });
120
+ return { ok: false, error };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Generate a unique temporary file path
126
+ * @param {string} filePath - Original file path
127
+ * @returns {string} Temporary file path
128
+ */
129
+ function getTempPath(filePath) {
130
+ const dir = path.dirname(filePath);
131
+ const ext = path.extname(filePath);
132
+ const base = path.basename(filePath, ext);
133
+ const timestamp = Date.now();
134
+ const random = Math.random().toString(36).slice(2, 8);
135
+ return path.join(dir, `.${base}.${timestamp}.${random}${ext}.tmp`);
136
+ }
137
+
138
+ /**
139
+ * Sleep for a specified duration
140
+ * @param {number} ms - Milliseconds to sleep
141
+ * @returns {Promise<void>}
142
+ */
143
+ function sleep(ms) {
144
+ return new Promise(resolve => setTimeout(resolve, ms));
145
+ }
146
+
147
+ /**
148
+ * SmartJsonFile - Safe JSON file operations with retry logic
149
+ */
150
+ class SmartJsonFile {
151
+ /**
152
+ * @param {string} filePath - Absolute path to JSON file
153
+ * @param {Object} [options={}] - Configuration options
154
+ * @param {number} [options.retries=3] - Number of retry attempts
155
+ * @param {number} [options.backoff=100] - Initial backoff in ms (doubles each retry)
156
+ * @param {boolean} [options.createDir=true] - Create parent directory if missing
157
+ * @param {number} [options.spaces=2] - JSON indentation spaces
158
+ * @param {Function} [options.schema] - Optional validation function (throws on invalid)
159
+ * @param {*} [options.defaultValue] - Default value if file doesn't exist
160
+ * @param {boolean} [options.secureMode=false] - Enforce 0o600 permissions on write
161
+ * @param {boolean} [options.warnInsecure=false] - Warn if file has insecure permissions on read
162
+ */
163
+ constructor(filePath, options = {}) {
164
+ if (!filePath || typeof filePath !== 'string') {
165
+ throw createTypedError('filePath is required and must be a string', 'EINVAL', {
166
+ context: { provided: typeof filePath },
167
+ });
168
+ }
169
+
170
+ if (!path.isAbsolute(filePath)) {
171
+ throw createTypedError('filePath must be an absolute path', 'EINVAL', {
172
+ context: { provided: filePath },
173
+ });
174
+ }
175
+
176
+ this.filePath = filePath;
177
+ this.retries = options.retries ?? 3;
178
+ this.backoff = options.backoff ?? 100;
179
+ this.createDir = options.createDir ?? true;
180
+ this.spaces = options.spaces ?? 2;
181
+ this.schema = options.schema ?? null;
182
+ this.defaultValue = options.defaultValue;
183
+ this.secureMode = options.secureMode ?? false;
184
+ this.warnInsecure = options.warnInsecure ?? false;
185
+
186
+ debugLog('constructor', {
187
+ filePath,
188
+ options: { retries: this.retries, backoff: this.backoff, secureMode: this.secureMode },
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Read and parse JSON file
194
+ * @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
195
+ */
196
+ async read() {
197
+ let lastError = null;
198
+ let attempt = 0;
199
+
200
+ while (attempt <= this.retries) {
201
+ try {
202
+ debugLog('read', { filePath: this.filePath, attempt });
203
+
204
+ // Check if file exists
205
+ if (!fs.existsSync(this.filePath)) {
206
+ if (this.defaultValue !== undefined) {
207
+ debugLog('read', { status: 'using default value' });
208
+ return { ok: true, data: this.defaultValue };
209
+ }
210
+ const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
211
+ context: { filePath: this.filePath },
212
+ });
213
+ return { ok: false, error };
214
+ }
215
+
216
+ // Read file
217
+ const content = fs.readFileSync(this.filePath, 'utf8');
218
+
219
+ // Security: Check file permissions if warnInsecure is enabled
220
+ if (this.warnInsecure) {
221
+ try {
222
+ const stats = fs.statSync(this.filePath);
223
+ const permCheck = checkFilePermissions(stats.mode);
224
+ if (!permCheck.ok) {
225
+ debugLog('read', { security: 'insecure permissions', warning: permCheck.warning });
226
+ // Log warning to stderr (non-blocking)
227
+ console.error(`[Security Warning] ${this.filePath}: ${permCheck.warning}`);
228
+ }
229
+ } catch (statErr) {
230
+ debugLog('read', { security: 'could not check permissions', error: statErr.message });
231
+ }
232
+ }
233
+
234
+ // Parse JSON
235
+ let data;
236
+ try {
237
+ data = JSON.parse(content);
238
+ } catch (parseError) {
239
+ const error = createTypedError(
240
+ `Invalid JSON in ${this.filePath}: ${parseError.message}`,
241
+ 'EPARSE',
242
+ { cause: parseError, context: { filePath: this.filePath } }
243
+ );
244
+ return { ok: false, error };
245
+ }
246
+
247
+ // Validate schema if provided
248
+ if (this.schema) {
249
+ try {
250
+ this.schema(data);
251
+ } catch (schemaError) {
252
+ const error = createTypedError(
253
+ `Schema validation failed for ${this.filePath}: ${schemaError.message}`,
254
+ 'ESCHEMA',
255
+ { cause: schemaError, context: { filePath: this.filePath } }
256
+ );
257
+ return { ok: false, error };
258
+ }
259
+ }
260
+
261
+ debugLog('read', { status: 'success' });
262
+ return { ok: true, data };
263
+ } catch (err) {
264
+ lastError = err;
265
+ debugLog('read', { status: 'error', error: err.message, attempt });
266
+
267
+ // Don't retry for certain errors
268
+ const errorCode = getErrorCodeFromError(err);
269
+ if (errorCode.code === 'EACCES' || errorCode.code === 'EPERM') {
270
+ // Permission errors won't resolve with retries
271
+ const error = createTypedError(`Permission denied reading ${this.filePath}`, 'EACCES', {
272
+ cause: err,
273
+ context: { filePath: this.filePath },
274
+ });
275
+ return { ok: false, error };
276
+ }
277
+
278
+ // Wait before retrying
279
+ if (attempt < this.retries) {
280
+ const waitTime = this.backoff * Math.pow(2, attempt);
281
+ debugLog('read', { status: 'retrying', waitTime });
282
+ await sleep(waitTime);
283
+ }
284
+
285
+ attempt++;
286
+ }
287
+ }
288
+
289
+ // All retries exhausted
290
+ const error = createTypedError(
291
+ `Failed to read ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
292
+ 'EUNKNOWN',
293
+ { cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
294
+ );
295
+ return { ok: false, error };
296
+ }
297
+
298
+ /**
299
+ * Write data to JSON file atomically
300
+ * @param {any} data - Data to write
301
+ * @returns {Promise<{ok: boolean, error?: Error}>} Result object
302
+ */
303
+ async write(data) {
304
+ let lastError = null;
305
+ let attempt = 0;
306
+
307
+ // Validate schema before writing
308
+ if (this.schema) {
309
+ try {
310
+ this.schema(data);
311
+ } catch (schemaError) {
312
+ const error = createTypedError(
313
+ `Schema validation failed: ${schemaError.message}`,
314
+ 'ESCHEMA',
315
+ { cause: schemaError, context: { filePath: this.filePath } }
316
+ );
317
+ return { ok: false, error };
318
+ }
319
+ }
320
+
321
+ while (attempt <= this.retries) {
322
+ const tempPath = getTempPath(this.filePath);
323
+
324
+ try {
325
+ debugLog('write', { filePath: this.filePath, tempPath, attempt });
326
+
327
+ // Create parent directory if needed
328
+ const dir = path.dirname(this.filePath);
329
+ if (this.createDir && !fs.existsSync(dir)) {
330
+ fs.mkdirSync(dir, { recursive: true });
331
+ debugLog('write', { status: 'created directory', dir });
332
+ }
333
+
334
+ // Serialize data
335
+ const content = JSON.stringify(data, null, this.spaces) + '\n';
336
+
337
+ // Write to temp file
338
+ fs.writeFileSync(tempPath, content, 'utf8');
339
+ debugLog('write', { status: 'wrote temp file' });
340
+
341
+ // Atomic rename
342
+ fs.renameSync(tempPath, this.filePath);
343
+
344
+ // Security: Set secure permissions if secureMode is enabled
345
+ if (this.secureMode) {
346
+ const permResult = setSecurePermissions(this.filePath);
347
+ if (!permResult.ok) {
348
+ debugLog('write', { status: 'warning', security: 'failed to set secure permissions' });
349
+ // Don't fail the write, just warn
350
+ }
351
+ }
352
+
353
+ debugLog('write', { status: 'success' });
354
+
355
+ return { ok: true };
356
+ } catch (err) {
357
+ lastError = err;
358
+ debugLog('write', { status: 'error', error: err.message, attempt });
359
+
360
+ // Clean up temp file if it exists
361
+ try {
362
+ if (fs.existsSync(tempPath)) {
363
+ fs.unlinkSync(tempPath);
364
+ }
365
+ } catch {
366
+ // Ignore cleanup errors
367
+ }
368
+
369
+ // Don't retry for certain errors
370
+ const errorCode = getErrorCodeFromError(err);
371
+ if (
372
+ errorCode.code === 'EACCES' ||
373
+ errorCode.code === 'EPERM' ||
374
+ errorCode.code === 'EROFS'
375
+ ) {
376
+ const error = createTypedError(
377
+ `Permission denied writing ${this.filePath}`,
378
+ errorCode.code,
379
+ { cause: err, context: { filePath: this.filePath } }
380
+ );
381
+ return { ok: false, error };
382
+ }
383
+
384
+ // Wait before retrying
385
+ if (attempt < this.retries) {
386
+ const waitTime = this.backoff * Math.pow(2, attempt);
387
+ debugLog('write', { status: 'retrying', waitTime });
388
+ await sleep(waitTime);
389
+ }
390
+
391
+ attempt++;
392
+ }
393
+ }
394
+
395
+ // All retries exhausted
396
+ const error = createTypedError(
397
+ `Failed to write ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
398
+ 'EUNKNOWN',
399
+ { cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
400
+ );
401
+ return { ok: false, error };
402
+ }
403
+
404
+ /**
405
+ * Read, modify, and write atomically
406
+ * @param {Function} modifier - Function that takes current data and returns modified data
407
+ * @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
408
+ */
409
+ async modify(modifier) {
410
+ debugLog('modify', { filePath: this.filePath });
411
+
412
+ // Read current data
413
+ const readResult = await this.read();
414
+ if (!readResult.ok) {
415
+ // If file doesn't exist but we have a default value, use that
416
+ if (readResult.error?.errorCode === 'ENOENT' && this.defaultValue !== undefined) {
417
+ readResult.ok = true;
418
+ readResult.data = this.defaultValue;
419
+ delete readResult.error;
420
+ } else {
421
+ return readResult;
422
+ }
423
+ }
424
+
425
+ // Apply modifier
426
+ let newData;
427
+ try {
428
+ newData = await modifier(readResult.data);
429
+ } catch (modifyError) {
430
+ const error = createTypedError(`Modifier function failed: ${modifyError.message}`, 'EINVAL', {
431
+ cause: modifyError,
432
+ context: { filePath: this.filePath },
433
+ });
434
+ return { ok: false, error };
435
+ }
436
+
437
+ // Write modified data
438
+ const writeResult = await this.write(newData);
439
+ if (!writeResult.ok) {
440
+ return writeResult;
441
+ }
442
+
443
+ return { ok: true, data: newData };
444
+ }
445
+
446
+ /**
447
+ * Check if file exists
448
+ * @returns {boolean}
449
+ */
450
+ exists() {
451
+ return fs.existsSync(this.filePath);
452
+ }
453
+
454
+ /**
455
+ * Delete the file
456
+ * @returns {Promise<{ok: boolean, error?: Error}>}
457
+ */
458
+ async delete() {
459
+ try {
460
+ if (!fs.existsSync(this.filePath)) {
461
+ return { ok: true }; // Already doesn't exist
462
+ }
463
+
464
+ fs.unlinkSync(this.filePath);
465
+ debugLog('delete', { filePath: this.filePath, status: 'success' });
466
+ return { ok: true };
467
+ } catch (err) {
468
+ const errorCode = getErrorCodeFromError(err);
469
+ const error = createTypedError(
470
+ `Failed to delete ${this.filePath}: ${err.message}`,
471
+ errorCode.code,
472
+ { cause: err, context: { filePath: this.filePath } }
473
+ );
474
+ return { ok: false, error };
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Synchronous read - for cases where async isn't possible
480
+ * @returns {{ok: boolean, data?: any, error?: Error}}
481
+ */
482
+ readSync() {
483
+ try {
484
+ if (!fs.existsSync(this.filePath)) {
485
+ if (this.defaultValue !== undefined) {
486
+ return { ok: true, data: this.defaultValue };
487
+ }
488
+ const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
489
+ context: { filePath: this.filePath },
490
+ });
491
+ return { ok: false, error };
492
+ }
493
+
494
+ const content = fs.readFileSync(this.filePath, 'utf8');
495
+ const data = JSON.parse(content);
496
+
497
+ if (this.schema) {
498
+ this.schema(data);
499
+ }
500
+
501
+ return { ok: true, data };
502
+ } catch (err) {
503
+ const errorCode = getErrorCodeFromError(err);
504
+ const error = createTypedError(
505
+ `Failed to read ${this.filePath}: ${err.message}`,
506
+ errorCode.code,
507
+ { cause: err, context: { filePath: this.filePath } }
508
+ );
509
+ return { ok: false, error };
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Synchronous write - for cases where async isn't possible
515
+ * @param {any} data - Data to write
516
+ * @returns {{ok: boolean, error?: Error}}
517
+ */
518
+ writeSync(data) {
519
+ const tempPath = getTempPath(this.filePath);
520
+
521
+ try {
522
+ if (this.schema) {
523
+ this.schema(data);
524
+ }
525
+
526
+ const dir = path.dirname(this.filePath);
527
+ if (this.createDir && !fs.existsSync(dir)) {
528
+ fs.mkdirSync(dir, { recursive: true });
529
+ }
530
+
531
+ const content = JSON.stringify(data, null, this.spaces) + '\n';
532
+ fs.writeFileSync(tempPath, content, 'utf8');
533
+ fs.renameSync(tempPath, this.filePath);
534
+
535
+ // Security: Set secure permissions if secureMode is enabled
536
+ if (this.secureMode) {
537
+ const permResult = setSecurePermissions(this.filePath);
538
+ if (!permResult.ok) {
539
+ debugLog('writeSync', { status: 'warning', security: 'failed to set secure permissions' });
540
+ }
541
+ }
542
+
543
+ return { ok: true };
544
+ } catch (err) {
545
+ // Clean up temp file
546
+ try {
547
+ if (fs.existsSync(tempPath)) {
548
+ fs.unlinkSync(tempPath);
549
+ }
550
+ } catch {
551
+ // Ignore
552
+ }
553
+
554
+ const errorCode = getErrorCodeFromError(err);
555
+ const error = createTypedError(
556
+ `Failed to write ${this.filePath}: ${err.message}`,
557
+ errorCode.code,
558
+ { cause: err, context: { filePath: this.filePath } }
559
+ );
560
+ return { ok: false, error };
561
+ }
562
+ }
563
+ }
564
+
565
+ // Default max age for temp files (24 hours in milliseconds)
566
+ const DEFAULT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
567
+
568
+ /**
569
+ * Clean up orphaned temp files in a directory
570
+ *
571
+ * Removes files matching the temp file pattern that are older than maxAge.
572
+ * Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
573
+ *
574
+ * @param {string} directory - Directory to clean
575
+ * @param {Object} [options={}] - Cleanup options
576
+ * @param {number} [options.maxAgeMs=86400000] - Max age in ms (default: 24 hours)
577
+ * @param {boolean} [options.dryRun=false] - Don't delete, just report
578
+ * @returns {{ok: boolean, cleaned: string[], errors: string[]}}
579
+ */
580
+ function cleanupTempFiles(directory, options = {}) {
581
+ const { maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS, dryRun = false } = options;
582
+
583
+ const cleaned = [];
584
+ const errors = [];
585
+
586
+ try {
587
+ if (!fs.existsSync(directory)) {
588
+ return { ok: true, cleaned, errors };
589
+ }
590
+
591
+ const now = Date.now();
592
+ const entries = fs.readdirSync(directory);
593
+
594
+ // Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
595
+ const tempFilePattern = /^\.[^.]+\.\d+\.[a-z0-9]+\.[^.]+\.tmp$/;
596
+
597
+ for (const entry of entries) {
598
+ // Check if it matches temp file pattern
599
+ if (!tempFilePattern.test(entry)) continue;
600
+
601
+ const filePath = path.join(directory, entry);
602
+
603
+ try {
604
+ const stat = fs.statSync(filePath);
605
+
606
+ // Skip if not a file
607
+ if (!stat.isFile()) continue;
608
+
609
+ // Check age
610
+ const age = now - stat.mtimeMs;
611
+ if (age < maxAgeMs) continue;
612
+
613
+ // Delete the temp file
614
+ if (!dryRun) {
615
+ fs.unlinkSync(filePath);
616
+ }
617
+
618
+ cleaned.push(filePath);
619
+ debugLog('cleanupTempFiles', { action: dryRun ? 'would delete' : 'deleted', filePath, ageHours: Math.round(age / 3600000) });
620
+ } catch (err) {
621
+ errors.push(`${filePath}: ${err.message}`);
622
+ debugLog('cleanupTempFiles', { action: 'error', filePath, error: err.message });
623
+ }
624
+ }
625
+
626
+ return { ok: errors.length === 0, cleaned, errors };
627
+ } catch (err) {
628
+ errors.push(`Directory read error: ${err.message}`);
629
+ return { ok: false, cleaned, errors };
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Clean up temp files in the directory of a specific JSON file
635
+ *
636
+ * @param {string} filePath - Path to the JSON file
637
+ * @param {Object} [options={}] - Cleanup options
638
+ * @returns {{ok: boolean, cleaned: string[], errors: string[]}}
639
+ */
640
+ function cleanupTempFilesFor(filePath, options = {}) {
641
+ const directory = path.dirname(filePath);
642
+ return cleanupTempFiles(directory, options);
643
+ }
644
+
645
+ module.exports = SmartJsonFile;
646
+
647
+ // Export helper functions for external use
648
+ module.exports.SECURE_FILE_MODE = SECURE_FILE_MODE;
649
+ module.exports.checkFilePermissions = checkFilePermissions;
650
+ module.exports.setSecurePermissions = setSecurePermissions;
651
+ module.exports.cleanupTempFiles = cleanupTempFiles;
652
+ module.exports.cleanupTempFilesFor = cleanupTempFilesFor;
653
+ module.exports.DEFAULT_TEMP_MAX_AGE_MS = DEFAULT_TEMP_MAX_AGE_MS;