agileflow 2.89.3 → 2.90.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/placeholder-registry.js +617 -0
  3. package/lib/smart-json-file.js +228 -1
  4. package/lib/table-formatter.js +519 -0
  5. package/lib/transient-status.js +374 -0
  6. package/lib/ui-manager.js +612 -0
  7. package/lib/validate-args.js +213 -0
  8. package/lib/validate-names.js +143 -0
  9. package/lib/validate-paths.js +434 -0
  10. package/lib/validate.js +37 -737
  11. package/package.json +3 -1
  12. package/scripts/check-update.js +17 -3
  13. package/scripts/lib/sessionRegistry.js +678 -0
  14. package/scripts/session-manager.js +77 -10
  15. package/scripts/tui/App.js +151 -0
  16. package/scripts/tui/index.js +31 -0
  17. package/scripts/tui/lib/crashRecovery.js +304 -0
  18. package/scripts/tui/lib/eventStream.js +309 -0
  19. package/scripts/tui/lib/keyboard.js +261 -0
  20. package/scripts/tui/lib/loopControl.js +371 -0
  21. package/scripts/tui/panels/OutputPanel.js +242 -0
  22. package/scripts/tui/panels/SessionPanel.js +170 -0
  23. package/scripts/tui/panels/TracePanel.js +298 -0
  24. package/scripts/tui/simple-tui.js +390 -0
  25. package/tools/cli/commands/config.js +7 -31
  26. package/tools/cli/commands/doctor.js +28 -39
  27. package/tools/cli/commands/list.js +47 -35
  28. package/tools/cli/commands/status.js +20 -38
  29. package/tools/cli/commands/tui.js +59 -0
  30. package/tools/cli/commands/uninstall.js +12 -39
  31. package/tools/cli/installers/core/installer.js +13 -0
  32. package/tools/cli/lib/command-context.js +382 -0
  33. package/tools/cli/lib/config-manager.js +394 -0
  34. package/tools/cli/lib/ide-registry.js +186 -0
  35. package/tools/cli/lib/npm-utils.js +17 -3
  36. package/tools/cli/lib/self-update.js +148 -0
  37. package/tools/cli/lib/validation-middleware.js +491 -0
@@ -42,6 +42,101 @@ function debugLog(operation, details) {
42
42
  }
43
43
  }
44
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:
73
+ 'File is world-writable (mode: ' +
74
+ permissions.toString(8) +
75
+ '). Security risk - others can modify.',
76
+ };
77
+ }
78
+
79
+ if (worldRead) {
80
+ return {
81
+ ok: false,
82
+ warning:
83
+ 'File is world-readable (mode: ' +
84
+ permissions.toString(8) +
85
+ '). May expose sensitive config.',
86
+ };
87
+ }
88
+
89
+ if (groupWrite) {
90
+ return {
91
+ ok: false,
92
+ warning:
93
+ 'File is group-writable (mode: ' +
94
+ permissions.toString(8) +
95
+ '). Consider restricting to 0600.',
96
+ };
97
+ }
98
+
99
+ if (groupRead) {
100
+ return {
101
+ ok: false,
102
+ warning:
103
+ 'File is group-readable (mode: ' +
104
+ permissions.toString(8) +
105
+ '). Consider restricting to 0600.',
106
+ };
107
+ }
108
+
109
+ return { ok: true };
110
+ }
111
+
112
+ /**
113
+ * Set secure permissions on a file (0o600 - owner only)
114
+ * @param {string} filePath - Path to the file
115
+ * @returns {{ok: boolean, error?: Error}}
116
+ */
117
+ function setSecurePermissions(filePath) {
118
+ // Skip on Windows
119
+ if (process.platform === 'win32') {
120
+ return { ok: true };
121
+ }
122
+
123
+ try {
124
+ fs.chmodSync(filePath, SECURE_FILE_MODE);
125
+ debugLog('setSecurePermissions', { filePath, mode: SECURE_FILE_MODE.toString(8) });
126
+ return { ok: true };
127
+ } catch (err) {
128
+ const error = createTypedError(
129
+ `Failed to set secure permissions on ${filePath}: ${err.message}`,
130
+ 'EPERM',
131
+ {
132
+ cause: err,
133
+ context: { filePath, mode: SECURE_FILE_MODE },
134
+ }
135
+ );
136
+ return { ok: false, error };
137
+ }
138
+ }
139
+
45
140
  /**
46
141
  * Generate a unique temporary file path
47
142
  * @param {string} filePath - Original file path
@@ -78,6 +173,8 @@ class SmartJsonFile {
78
173
  * @param {number} [options.spaces=2] - JSON indentation spaces
79
174
  * @param {Function} [options.schema] - Optional validation function (throws on invalid)
80
175
  * @param {*} [options.defaultValue] - Default value if file doesn't exist
176
+ * @param {boolean} [options.secureMode=false] - Enforce 0o600 permissions on write
177
+ * @param {boolean} [options.warnInsecure=false] - Warn if file has insecure permissions on read
81
178
  */
82
179
  constructor(filePath, options = {}) {
83
180
  if (!filePath || typeof filePath !== 'string') {
@@ -99,10 +196,12 @@ class SmartJsonFile {
99
196
  this.spaces = options.spaces ?? 2;
100
197
  this.schema = options.schema ?? null;
101
198
  this.defaultValue = options.defaultValue;
199
+ this.secureMode = options.secureMode ?? false;
200
+ this.warnInsecure = options.warnInsecure ?? false;
102
201
 
103
202
  debugLog('constructor', {
104
203
  filePath,
105
- options: { retries: this.retries, backoff: this.backoff },
204
+ options: { retries: this.retries, backoff: this.backoff, secureMode: this.secureMode },
106
205
  });
107
206
  }
108
207
 
@@ -133,6 +232,21 @@ class SmartJsonFile {
133
232
  // Read file
134
233
  const content = fs.readFileSync(this.filePath, 'utf8');
135
234
 
235
+ // Security: Check file permissions if warnInsecure is enabled
236
+ if (this.warnInsecure) {
237
+ try {
238
+ const stats = fs.statSync(this.filePath);
239
+ const permCheck = checkFilePermissions(stats.mode);
240
+ if (!permCheck.ok) {
241
+ debugLog('read', { security: 'insecure permissions', warning: permCheck.warning });
242
+ // Log warning to stderr (non-blocking)
243
+ console.error(`[Security Warning] ${this.filePath}: ${permCheck.warning}`);
244
+ }
245
+ } catch (statErr) {
246
+ debugLog('read', { security: 'could not check permissions', error: statErr.message });
247
+ }
248
+ }
249
+
136
250
  // Parse JSON
137
251
  let data;
138
252
  try {
@@ -242,6 +356,16 @@ class SmartJsonFile {
242
356
 
243
357
  // Atomic rename
244
358
  fs.renameSync(tempPath, this.filePath);
359
+
360
+ // Security: Set secure permissions if secureMode is enabled
361
+ if (this.secureMode) {
362
+ const permResult = setSecurePermissions(this.filePath);
363
+ if (!permResult.ok) {
364
+ debugLog('write', { status: 'warning', security: 'failed to set secure permissions' });
365
+ // Don't fail the write, just warn
366
+ }
367
+ }
368
+
245
369
  debugLog('write', { status: 'success' });
246
370
 
247
371
  return { ok: true };
@@ -424,6 +548,17 @@ class SmartJsonFile {
424
548
  fs.writeFileSync(tempPath, content, 'utf8');
425
549
  fs.renameSync(tempPath, this.filePath);
426
550
 
551
+ // Security: Set secure permissions if secureMode is enabled
552
+ if (this.secureMode) {
553
+ const permResult = setSecurePermissions(this.filePath);
554
+ if (!permResult.ok) {
555
+ debugLog('writeSync', {
556
+ status: 'warning',
557
+ security: 'failed to set secure permissions',
558
+ });
559
+ }
560
+ }
561
+
427
562
  return { ok: true };
428
563
  } catch (err) {
429
564
  // Clean up temp file
@@ -446,4 +581,96 @@ class SmartJsonFile {
446
581
  }
447
582
  }
448
583
 
584
+ // Default max age for temp files (24 hours in milliseconds)
585
+ const DEFAULT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
586
+
587
+ /**
588
+ * Clean up orphaned temp files in a directory
589
+ *
590
+ * Removes files matching the temp file pattern that are older than maxAge.
591
+ * Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
592
+ *
593
+ * @param {string} directory - Directory to clean
594
+ * @param {Object} [options={}] - Cleanup options
595
+ * @param {number} [options.maxAgeMs=86400000] - Max age in ms (default: 24 hours)
596
+ * @param {boolean} [options.dryRun=false] - Don't delete, just report
597
+ * @returns {{ok: boolean, cleaned: string[], errors: string[]}}
598
+ */
599
+ function cleanupTempFiles(directory, options = {}) {
600
+ const { maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS, dryRun = false } = options;
601
+
602
+ const cleaned = [];
603
+ const errors = [];
604
+
605
+ try {
606
+ if (!fs.existsSync(directory)) {
607
+ return { ok: true, cleaned, errors };
608
+ }
609
+
610
+ const now = Date.now();
611
+ const entries = fs.readdirSync(directory);
612
+
613
+ // Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
614
+ const tempFilePattern = /^\.[^.]+\.\d+\.[a-z0-9]+\.[^.]+\.tmp$/;
615
+
616
+ for (const entry of entries) {
617
+ // Check if it matches temp file pattern
618
+ if (!tempFilePattern.test(entry)) continue;
619
+
620
+ const filePath = path.join(directory, entry);
621
+
622
+ try {
623
+ const stat = fs.statSync(filePath);
624
+
625
+ // Skip if not a file
626
+ if (!stat.isFile()) continue;
627
+
628
+ // Check age
629
+ const age = now - stat.mtimeMs;
630
+ if (age < maxAgeMs) continue;
631
+
632
+ // Delete the temp file
633
+ if (!dryRun) {
634
+ fs.unlinkSync(filePath);
635
+ }
636
+
637
+ cleaned.push(filePath);
638
+ debugLog('cleanupTempFiles', {
639
+ action: dryRun ? 'would delete' : 'deleted',
640
+ filePath,
641
+ ageHours: Math.round(age / 3600000),
642
+ });
643
+ } catch (err) {
644
+ errors.push(`${filePath}: ${err.message}`);
645
+ debugLog('cleanupTempFiles', { action: 'error', filePath, error: err.message });
646
+ }
647
+ }
648
+
649
+ return { ok: errors.length === 0, cleaned, errors };
650
+ } catch (err) {
651
+ errors.push(`Directory read error: ${err.message}`);
652
+ return { ok: false, cleaned, errors };
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Clean up temp files in the directory of a specific JSON file
658
+ *
659
+ * @param {string} filePath - Path to the JSON file
660
+ * @param {Object} [options={}] - Cleanup options
661
+ * @returns {{ok: boolean, cleaned: string[], errors: string[]}}
662
+ */
663
+ function cleanupTempFilesFor(filePath, options = {}) {
664
+ const directory = path.dirname(filePath);
665
+ return cleanupTempFiles(directory, options);
666
+ }
667
+
449
668
  module.exports = SmartJsonFile;
669
+
670
+ // Export helper functions for external use
671
+ module.exports.SECURE_FILE_MODE = SECURE_FILE_MODE;
672
+ module.exports.checkFilePermissions = checkFilePermissions;
673
+ module.exports.setSecurePermissions = setSecurePermissions;
674
+ module.exports.cleanupTempFiles = cleanupTempFiles;
675
+ module.exports.cleanupTempFilesFor = cleanupTempFilesFor;
676
+ module.exports.DEFAULT_TEMP_MAX_AGE_MS = DEFAULT_TEMP_MAX_AGE_MS;