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,394 @@
1
+ /**
2
+ * AgileFlow CLI - Configuration Manager
3
+ *
4
+ * Centralized configuration management with schema validation,
5
+ * migration support, and consistent access patterns.
6
+ *
7
+ * Usage:
8
+ * const { ConfigManager } = require('./lib/config-manager');
9
+ * const config = await ConfigManager.load(projectDir);
10
+ * const userName = config.get('userName');
11
+ */
12
+
13
+ const path = require('path');
14
+ const fs = require('fs-extra');
15
+ const { safeLoad, safeDump } = require('../../../lib/yaml-utils');
16
+
17
+ /**
18
+ * Configuration schema definition
19
+ * @typedef {Object} ConfigSchema
20
+ * @property {string} type - Value type (string, array, boolean, number)
21
+ * @property {*} default - Default value
22
+ * @property {boolean} required - Whether the field is required
23
+ * @property {Function} [validate] - Custom validation function
24
+ */
25
+
26
+ /**
27
+ * Configuration schema for AgileFlow manifest
28
+ */
29
+ const CONFIG_SCHEMA = {
30
+ version: {
31
+ type: 'string',
32
+ default: '0.0.0',
33
+ required: true,
34
+ validate: v => /^\d+\.\d+\.\d+/.test(v),
35
+ },
36
+ userName: {
37
+ type: 'string',
38
+ default: 'Developer',
39
+ required: false,
40
+ },
41
+ ides: {
42
+ type: 'array',
43
+ default: ['claude-code'],
44
+ required: true,
45
+ validate: arr =>
46
+ Array.isArray(arr) &&
47
+ arr.every(ide => ['claude-code', 'cursor', 'windsurf', 'codex'].includes(ide)),
48
+ },
49
+ agileflowFolder: {
50
+ type: 'string',
51
+ default: '.agileflow',
52
+ required: true,
53
+ validate: v => typeof v === 'string' && v.length > 0 && !v.includes('..'),
54
+ },
55
+ docsFolder: {
56
+ type: 'string',
57
+ default: 'docs',
58
+ required: true,
59
+ validate: v => typeof v === 'string' && v.length > 0 && !v.includes('..'),
60
+ },
61
+ installedAt: {
62
+ type: 'string',
63
+ default: null,
64
+ required: false,
65
+ },
66
+ updatedAt: {
67
+ type: 'string',
68
+ default: null,
69
+ required: false,
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Valid configuration keys (for external reference)
75
+ */
76
+ const VALID_CONFIG_KEYS = Object.keys(CONFIG_SCHEMA);
77
+
78
+ /**
79
+ * User-editable configuration keys
80
+ */
81
+ const EDITABLE_CONFIG_KEYS = ['userName', 'ides', 'agileflowFolder', 'docsFolder'];
82
+
83
+ /**
84
+ * Configuration Manager class
85
+ */
86
+ class ConfigManager {
87
+ /**
88
+ * Create a new ConfigManager instance
89
+ * @param {Object} data - Configuration data
90
+ * @param {string} manifestPath - Path to manifest file
91
+ */
92
+ constructor(data = {}, manifestPath = null) {
93
+ this._data = { ...data };
94
+ this._manifestPath = manifestPath;
95
+ this._dirty = false;
96
+ }
97
+
98
+ /**
99
+ * Load configuration from a project directory
100
+ * @param {string} projectDir - Project directory
101
+ * @param {Object} options - Load options
102
+ * @param {string} [options.agileflowFolder='.agileflow'] - AgileFlow folder name
103
+ * @returns {Promise<ConfigManager>} ConfigManager instance
104
+ */
105
+ static async load(projectDir, options = {}) {
106
+ const agileflowFolder = options.agileflowFolder || '.agileflow';
107
+ const manifestPath = path.join(projectDir, agileflowFolder, '_cfg', 'manifest.yaml');
108
+
109
+ let data = {};
110
+
111
+ if (await fs.pathExists(manifestPath)) {
112
+ try {
113
+ const content = await fs.readFile(manifestPath, 'utf8');
114
+ const parsed = safeLoad(content);
115
+ // Normalize keys from snake_case to camelCase
116
+ data = ConfigManager._normalizeKeys(parsed);
117
+ } catch {
118
+ // If manifest is corrupted, use defaults
119
+ data = {};
120
+ }
121
+ }
122
+
123
+ // Apply defaults for missing fields
124
+ for (const [key, schema] of Object.entries(CONFIG_SCHEMA)) {
125
+ if (data[key] === undefined && schema.default !== null) {
126
+ data[key] = schema.default;
127
+ }
128
+ }
129
+
130
+ return new ConfigManager(data, manifestPath);
131
+ }
132
+
133
+ /**
134
+ * Normalize keys from snake_case to camelCase
135
+ * @param {Object} obj - Object with snake_case keys
136
+ * @returns {Object} Object with camelCase keys
137
+ */
138
+ static _normalizeKeys(obj) {
139
+ const keyMap = {
140
+ user_name: 'userName',
141
+ agileflow_folder: 'agileflowFolder',
142
+ docs_folder: 'docsFolder',
143
+ installed_at: 'installedAt',
144
+ updated_at: 'updatedAt',
145
+ };
146
+
147
+ const result = {};
148
+ for (const [key, value] of Object.entries(obj || {})) {
149
+ const normalizedKey = keyMap[key] || key;
150
+ result[normalizedKey] = value;
151
+ }
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Denormalize keys from camelCase to snake_case for storage
157
+ * @param {Object} obj - Object with camelCase keys
158
+ * @returns {Object} Object with snake_case keys
159
+ */
160
+ static _denormalizeKeys(obj) {
161
+ const keyMap = {
162
+ userName: 'user_name',
163
+ agileflowFolder: 'agileflow_folder',
164
+ docsFolder: 'docs_folder',
165
+ installedAt: 'installed_at',
166
+ updatedAt: 'updated_at',
167
+ };
168
+
169
+ const result = {};
170
+ for (const [key, value] of Object.entries(obj || {})) {
171
+ const denormalizedKey = keyMap[key] || key;
172
+ result[denormalizedKey] = value;
173
+ }
174
+ return result;
175
+ }
176
+
177
+ /**
178
+ * Get a configuration value
179
+ * @param {string} key - Configuration key
180
+ * @returns {*} Configuration value
181
+ */
182
+ get(key) {
183
+ const schema = CONFIG_SCHEMA[key];
184
+ if (!schema) {
185
+ return undefined;
186
+ }
187
+ return this._data[key] !== undefined ? this._data[key] : schema.default;
188
+ }
189
+
190
+ /**
191
+ * Set a configuration value
192
+ * @param {string} key - Configuration key
193
+ * @param {*} value - Configuration value
194
+ * @returns {{ok: boolean, error?: string}} Result
195
+ */
196
+ set(key, value) {
197
+ const schema = CONFIG_SCHEMA[key];
198
+
199
+ if (!schema) {
200
+ return { ok: false, error: `Unknown configuration key: ${key}` };
201
+ }
202
+
203
+ if (!EDITABLE_CONFIG_KEYS.includes(key)) {
204
+ return { ok: false, error: `Configuration key '${key}' is read-only` };
205
+ }
206
+
207
+ // Type validation
208
+ const typeError = this._validateType(key, value, schema);
209
+ if (typeError) {
210
+ return { ok: false, error: typeError };
211
+ }
212
+
213
+ // Custom validation
214
+ if (schema.validate && !schema.validate(value)) {
215
+ return { ok: false, error: `Invalid value for '${key}'` };
216
+ }
217
+
218
+ this._data[key] = value;
219
+ this._dirty = true;
220
+ return { ok: true };
221
+ }
222
+
223
+ /**
224
+ * Validate type of a value
225
+ * @param {string} key - Configuration key
226
+ * @param {*} value - Value to validate
227
+ * @param {ConfigSchema} schema - Schema definition
228
+ * @returns {string|null} Error message or null if valid
229
+ */
230
+ _validateType(key, value, schema) {
231
+ switch (schema.type) {
232
+ case 'string':
233
+ if (typeof value !== 'string') {
234
+ return `'${key}' must be a string`;
235
+ }
236
+ break;
237
+ case 'array':
238
+ if (!Array.isArray(value)) {
239
+ return `'${key}' must be an array`;
240
+ }
241
+ break;
242
+ case 'boolean':
243
+ if (typeof value !== 'boolean') {
244
+ return `'${key}' must be a boolean`;
245
+ }
246
+ break;
247
+ case 'number':
248
+ if (typeof value !== 'number') {
249
+ return `'${key}' must be a number`;
250
+ }
251
+ break;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * Validate all configuration values
258
+ * @returns {{ok: boolean, errors: string[]}} Validation result
259
+ */
260
+ validate() {
261
+ const errors = [];
262
+
263
+ for (const [key, schema] of Object.entries(CONFIG_SCHEMA)) {
264
+ const value = this._data[key];
265
+
266
+ // Check required fields
267
+ if (schema.required && (value === undefined || value === null)) {
268
+ errors.push(`Missing required field: ${key}`);
269
+ continue;
270
+ }
271
+
272
+ // Skip validation for undefined optional fields
273
+ if (value === undefined || value === null) {
274
+ continue;
275
+ }
276
+
277
+ // Type validation
278
+ const typeError = this._validateType(key, value, schema);
279
+ if (typeError) {
280
+ errors.push(typeError);
281
+ continue;
282
+ }
283
+
284
+ // Custom validation
285
+ if (schema.validate && !schema.validate(value)) {
286
+ errors.push(`Invalid value for '${key}'`);
287
+ }
288
+ }
289
+
290
+ return { ok: errors.length === 0, errors };
291
+ }
292
+
293
+ /**
294
+ * Save configuration to manifest file
295
+ * @returns {Promise<{ok: boolean, error?: string}>} Save result
296
+ */
297
+ async save() {
298
+ if (!this._manifestPath) {
299
+ return { ok: false, error: 'No manifest path set' };
300
+ }
301
+
302
+ try {
303
+ // Update timestamp
304
+ this._data.updatedAt = new Date().toISOString();
305
+
306
+ // Ensure directory exists
307
+ await fs.ensureDir(path.dirname(this._manifestPath));
308
+
309
+ // Denormalize keys for storage
310
+ const storageData = ConfigManager._denormalizeKeys(this._data);
311
+
312
+ // Write to file
313
+ await fs.writeFile(this._manifestPath, safeDump(storageData), 'utf8');
314
+
315
+ this._dirty = false;
316
+ return { ok: true };
317
+ } catch (err) {
318
+ return { ok: false, error: err.message };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Get all configuration data
324
+ * @returns {Object} All configuration data
325
+ */
326
+ getAll() {
327
+ const result = {};
328
+ for (const key of VALID_CONFIG_KEYS) {
329
+ result[key] = this.get(key);
330
+ }
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * Check if configuration has unsaved changes
336
+ * @returns {boolean}
337
+ */
338
+ isDirty() {
339
+ return this._dirty;
340
+ }
341
+
342
+ /**
343
+ * Get the manifest file path
344
+ * @returns {string|null}
345
+ */
346
+ getManifestPath() {
347
+ return this._manifestPath;
348
+ }
349
+
350
+ /**
351
+ * Migrate configuration from an older format
352
+ * @param {Object} oldData - Old configuration data
353
+ * @returns {{ok: boolean, migrated: string[]}} Migration result
354
+ */
355
+ migrate(oldData) {
356
+ const migrated = [];
357
+
358
+ // Migration: rename 'name' to 'userName'
359
+ if (oldData.name && !this._data.userName) {
360
+ this._data.userName = oldData.name;
361
+ migrated.push('name → userName');
362
+ this._dirty = true;
363
+ }
364
+
365
+ // Migration: normalize IDE names
366
+ if (this._data.ides) {
367
+ const normalizedIdes = this._data.ides.map(ide => ide.toLowerCase());
368
+ if (JSON.stringify(normalizedIdes) !== JSON.stringify(this._data.ides)) {
369
+ this._data.ides = normalizedIdes;
370
+ migrated.push('ides normalized to lowercase');
371
+ this._dirty = true;
372
+ }
373
+ }
374
+
375
+ // Migration: ensure agileflowFolder starts with dot
376
+ if (
377
+ this._data.agileflowFolder &&
378
+ !this._data.agileflowFolder.startsWith('.') &&
379
+ this._data.agileflowFolder !== 'agileflow'
380
+ ) {
381
+ // Only migrate if it looks like it should have a dot
382
+ // Don't migrate 'agileflow' to '.agileflow' automatically
383
+ }
384
+
385
+ return { ok: true, migrated };
386
+ }
387
+ }
388
+
389
+ module.exports = {
390
+ ConfigManager,
391
+ CONFIG_SCHEMA,
392
+ VALID_CONFIG_KEYS,
393
+ EDITABLE_CONFIG_KEYS,
394
+ };
@@ -33,6 +33,13 @@ const {
33
33
  countSkills,
34
34
  getCounts,
35
35
  } = require('../../../scripts/lib/counter');
36
+ const {
37
+ sanitize,
38
+ sanitizeAgentData,
39
+ sanitizeCommandData,
40
+ validatePlaceholderValue,
41
+ detectInjectionAttempt,
42
+ } = require('../../../lib/content-sanitizer');
36
43
 
37
44
  // =============================================================================
38
45
  // List Generation Functions
@@ -41,12 +48,14 @@ const {
41
48
  /**
42
49
  * Validate that a file path is within the expected directory.
43
50
  * Prevents reading files outside the expected scope.
51
+ * Security: Symlinks are NOT allowed to prevent escape attacks.
44
52
  * @param {string} filePath - File path to validate
45
53
  * @param {string} baseDir - Expected base directory
46
54
  * @returns {boolean} True if path is safe
47
55
  */
48
56
  function isPathSafe(filePath, baseDir) {
49
- const result = validatePath(filePath, baseDir, { allowSymlinks: true });
57
+ // Security hardening (US-0104): Symlinks disabled to prevent escape attacks
58
+ const result = validatePath(filePath, baseDir, { allowSymlinks: false });
50
59
  return result.ok;
51
60
  }
52
61
 
@@ -76,19 +85,32 @@ function generateAgentList(agentsDir) {
76
85
  continue;
77
86
  }
78
87
 
79
- agents.push({
88
+ // Sanitize agent data to prevent injection attacks
89
+ const rawAgent = {
80
90
  name: frontmatter.name || path.basename(file, '.md'),
81
91
  description: frontmatter.description || '',
82
92
  tools: normalizeTools(frontmatter.tools),
83
93
  model: frontmatter.model || 'haiku',
84
- });
94
+ };
95
+
96
+ const sanitizedAgent = sanitizeAgentData(rawAgent);
97
+
98
+ // Skip if sanitization produced invalid data
99
+ if (!sanitizedAgent.name || sanitizedAgent.name === 'unknown') {
100
+ continue;
101
+ }
102
+
103
+ agents.push(sanitizedAgent);
85
104
  }
86
105
 
87
106
  agents.sort((a, b) => a.name.localeCompare(b.name));
88
107
 
89
- let output = `**AVAILABLE AGENTS (${agents.length} total)**:\n\n`;
108
+ // Sanitize the count value
109
+ const safeCount = sanitize.count(agents.length);
110
+ let output = `**AVAILABLE AGENTS (${safeCount} total)**:\n\n`;
90
111
 
91
112
  agents.forEach((agent, index) => {
113
+ // All values are already sanitized by sanitizeAgentData
92
114
  output += `${index + 1}. **${agent.name}** (model: ${agent.model})\n`;
93
115
  output += ` - **Purpose**: ${agent.description}\n`;
94
116
  output += ` - **Tools**: ${agent.tools.join(', ')}\n`;
@@ -127,11 +149,19 @@ function generateCommandList(commandsDir) {
127
149
  continue;
128
150
  }
129
151
 
130
- commands.push({
152
+ // Sanitize command data to prevent injection attacks
153
+ const rawCommand = {
131
154
  name: cmdName,
132
155
  description: frontmatter.description || '',
133
156
  argumentHint: frontmatter['argument-hint'] || '',
134
- });
157
+ };
158
+
159
+ const sanitizedCommand = sanitizeCommandData(rawCommand);
160
+ if (!sanitizedCommand.name || sanitizedCommand.name === 'unknown') {
161
+ continue;
162
+ }
163
+
164
+ commands.push(sanitizedCommand);
135
165
  }
136
166
 
137
167
  // Scan subdirectories (e.g., session/)
@@ -163,20 +193,31 @@ function generateCommandList(commandsDir) {
163
193
  continue;
164
194
  }
165
195
 
166
- commands.push({
196
+ // Sanitize command data
197
+ const rawCommand = {
167
198
  name: cmdName,
168
199
  description: frontmatter.description || '',
169
200
  argumentHint: frontmatter['argument-hint'] || '',
170
- });
201
+ };
202
+
203
+ const sanitizedCommand = sanitizeCommandData(rawCommand);
204
+ if (!sanitizedCommand.name || sanitizedCommand.name === 'unknown') {
205
+ continue;
206
+ }
207
+
208
+ commands.push(sanitizedCommand);
171
209
  }
172
210
  }
173
211
  }
174
212
 
175
213
  commands.sort((a, b) => a.name.localeCompare(b.name));
176
214
 
177
- let output = `Available commands (${commands.length} total):\n`;
215
+ // Sanitize the count value
216
+ const safeCount = sanitize.count(commands.length);
217
+ let output = `Available commands (${safeCount} total):\n`;
178
218
 
179
219
  commands.forEach(cmd => {
220
+ // All values are already sanitized by sanitizeCommandData
180
221
  const argHint = cmd.argumentHint ? ` ${cmd.argumentHint}` : '';
181
222
  output += `- \`/agileflow:${cmd.name}${argHint}\` - ${cmd.description}\n`;
182
223
  });
@@ -208,16 +249,28 @@ function injectContent(content, context = {}) {
208
249
  counts = getCounts(coreDir);
209
250
  }
210
251
 
252
+ // Validate and sanitize all placeholder values before injection
253
+ const safeCommandCount = validatePlaceholderValue('COMMAND_COUNT', counts.commands).sanitized;
254
+ const safeAgentCount = validatePlaceholderValue('AGENT_COUNT', counts.agents).sanitized;
255
+ const safeSkillCount = validatePlaceholderValue('SKILL_COUNT', counts.skills).sanitized;
256
+ const safeVersion = validatePlaceholderValue('VERSION', version).sanitized;
257
+ const safeDate = validatePlaceholderValue('INSTALL_DATE', new Date()).sanitized;
258
+ const safeAgileflowFolder = validatePlaceholderValue(
259
+ 'agileflow_folder',
260
+ agileflowFolder
261
+ ).sanitized;
262
+
211
263
  // Replace count placeholders (both formats: {{X}} and <!-- {{X}} -->)
212
- result = result.replace(/\{\{COMMAND_COUNT\}\}/g, String(counts.commands));
213
- result = result.replace(/\{\{AGENT_COUNT\}\}/g, String(counts.agents));
214
- result = result.replace(/\{\{SKILL_COUNT\}\}/g, String(counts.skills));
264
+ result = result.replace(/\{\{COMMAND_COUNT\}\}/g, String(safeCommandCount));
265
+ result = result.replace(/\{\{AGENT_COUNT\}\}/g, String(safeAgentCount));
266
+ result = result.replace(/\{\{SKILL_COUNT\}\}/g, String(safeSkillCount));
215
267
 
216
268
  // Replace metadata placeholders
217
- result = result.replace(/\{\{VERSION\}\}/g, version);
218
- result = result.replace(/\{\{INSTALL_DATE\}\}/g, new Date().toISOString().split('T')[0]);
269
+ result = result.replace(/\{\{VERSION\}\}/g, safeVersion);
270
+ result = result.replace(/\{\{INSTALL_DATE\}\}/g, safeDate);
219
271
 
220
272
  // Replace list placeholders (only if core directory available)
273
+ // List generation already includes sanitization via sanitizeAgentData/sanitizeCommandData
221
274
  if (coreDir && fs.existsSync(coreDir)) {
222
275
  if (result.includes('{{AGENT_LIST}}')) {
223
276
  const agentList = generateAgentList(path.join(coreDir, 'agents'));
@@ -232,8 +285,8 @@ function injectContent(content, context = {}) {
232
285
  }
233
286
  }
234
287
 
235
- // Replace folder placeholders
236
- result = result.replace(/\{agileflow_folder\}/g, agileflowFolder);
288
+ // Replace folder placeholders with sanitized values
289
+ result = result.replace(/\{agileflow_folder\}/g, safeAgileflowFolder);
237
290
  result = result.replace(/\{project-root\}/g, '{project-root}'); // Keep as-is for runtime
238
291
 
239
292
  return result;