agileflow 2.88.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.
@@ -7,6 +7,14 @@
7
7
  const path = require('node:path');
8
8
  const fs = require('fs-extra');
9
9
  const chalk = require('chalk');
10
+ const {
11
+ IdeConfigNotFoundError,
12
+ CommandInstallationError,
13
+ FilePermissionError,
14
+ CleanupError,
15
+ ContentInjectionError,
16
+ withPermissionHandling,
17
+ } = require('../../lib/ide-errors');
10
18
 
11
19
  /**
12
20
  * Base class for IDE-specific setup
@@ -99,9 +107,74 @@ class BaseIdeSetup {
99
107
  throw new Error(`setup() must be implemented by ${this.name} handler`);
100
108
  }
101
109
 
110
+ /**
111
+ * Standard setup flow shared by most IDE installers.
112
+ * Handles cleanup, command/agent installation, and logging.
113
+ *
114
+ * @param {string} projectDir - Project directory
115
+ * @param {string} agileflowDir - AgileFlow installation directory
116
+ * @param {Object} config - Configuration options
117
+ * @param {string} config.targetSubdir - Target subdirectory name under configDir (e.g., 'commands', 'workflows')
118
+ * @param {string} config.agileflowFolder - AgileFlow folder name (e.g., 'agileflow', 'AgileFlow')
119
+ * @param {string} [config.commandLabel='commands'] - Label for commands in output (e.g., 'workflows')
120
+ * @param {string} [config.agentLabel='agents'] - Label for agents in output
121
+ * @returns {Promise<{success: boolean, commands: number, agents: number}>}
122
+ */
123
+ async setupStandard(projectDir, agileflowDir, config) {
124
+ const {
125
+ targetSubdir,
126
+ agileflowFolder,
127
+ commandLabel = 'commands',
128
+ agentLabel = 'agents',
129
+ } = config;
130
+
131
+ console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
132
+
133
+ // Clean up old installation first
134
+ await this.cleanup(projectDir);
135
+
136
+ // Create target directory (e.g., .cursor/commands/AgileFlow)
137
+ const ideDir = path.join(projectDir, this.configDir);
138
+ const targetDir = path.join(ideDir, targetSubdir);
139
+ const agileflowTargetDir = path.join(targetDir, agileflowFolder);
140
+
141
+ // Install commands using shared recursive method
142
+ const commandsSource = path.join(agileflowDir, 'commands');
143
+ const commandResult = await this.installCommandsRecursive(
144
+ commandsSource,
145
+ agileflowTargetDir,
146
+ agileflowDir,
147
+ true // Inject dynamic content
148
+ );
149
+
150
+ // Install agents as subdirectory
151
+ const agentsSource = path.join(agileflowDir, 'agents');
152
+ const agentsTargetDir = path.join(agileflowTargetDir, 'agents');
153
+ const agentResult = await this.installCommandsRecursive(
154
+ agentsSource,
155
+ agentsTargetDir,
156
+ agileflowDir,
157
+ false // No dynamic content for agents
158
+ );
159
+
160
+ console.log(chalk.green(` ✓ ${this.displayName} configured:`));
161
+ console.log(chalk.dim(` - ${commandResult.commands} ${commandLabel} installed`));
162
+ console.log(chalk.dim(` - ${agentResult.commands} ${agentLabel} installed`));
163
+ console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowTargetDir)}`));
164
+
165
+ return {
166
+ success: true,
167
+ commands: commandResult.commands,
168
+ agents: agentResult.commands,
169
+ ideDir,
170
+ agileflowTargetDir,
171
+ };
172
+ }
173
+
102
174
  /**
103
175
  * Cleanup IDE configuration
104
176
  * @param {string} projectDir - Project directory
177
+ * @throws {CleanupError} If cleanup fails
105
178
  */
106
179
  async cleanup(projectDir) {
107
180
  if (this.configDir) {
@@ -109,10 +182,25 @@ class BaseIdeSetup {
109
182
  for (const folderName of ['agileflow', 'AgileFlow']) {
110
183
  const agileflowPath = path.join(projectDir, this.configDir, 'commands', folderName);
111
184
  if (await fs.pathExists(agileflowPath)) {
112
- await fs.remove(agileflowPath);
113
- console.log(
114
- chalk.dim(` Removed old ${folderName} configuration from ${this.displayName}`)
115
- );
185
+ try {
186
+ await fs.remove(agileflowPath);
187
+ console.log(
188
+ chalk.dim(` Removed old ${folderName} configuration from ${this.displayName}`)
189
+ );
190
+ } catch (error) {
191
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
192
+ throw new CleanupError(
193
+ this.displayName,
194
+ agileflowPath,
195
+ `Permission denied: ${error.message}`
196
+ );
197
+ }
198
+ throw new CleanupError(
199
+ this.displayName,
200
+ agileflowPath,
201
+ error.message
202
+ );
203
+ }
116
204
  }
117
205
  }
118
206
  }
@@ -140,21 +228,27 @@ class BaseIdeSetup {
140
228
  }
141
229
 
142
230
  /**
143
- * Write a file
231
+ * Write a file with permission error handling
144
232
  * @param {string} filePath - File path
145
233
  * @param {string} content - File content
234
+ * @throws {FilePermissionError} If permission denied
146
235
  */
147
236
  async writeFile(filePath, content) {
148
- await fs.writeFile(filePath, content, 'utf8');
237
+ await withPermissionHandling(this.displayName, filePath, 'write', async () => {
238
+ await fs.writeFile(filePath, content, 'utf8');
239
+ });
149
240
  }
150
241
 
151
242
  /**
152
- * Read a file
243
+ * Read a file with permission error handling
153
244
  * @param {string} filePath - File path
154
245
  * @returns {Promise<string>} File content
246
+ * @throws {FilePermissionError} If permission denied
155
247
  */
156
248
  async readFile(filePath) {
157
- return fs.readFile(filePath, 'utf8');
249
+ return withPermissionHandling(this.displayName, filePath, 'read', async () => {
250
+ return fs.readFile(filePath, 'utf8');
251
+ });
158
252
  }
159
253
 
160
254
  /**
@@ -202,6 +296,8 @@ class BaseIdeSetup {
202
296
  * @param {string} agileflowDir - AgileFlow installation directory (for dynamic content)
203
297
  * @param {boolean} injectDynamic - Whether to inject dynamic content (only for top-level commands)
204
298
  * @returns {Promise<{commands: number, subdirs: number}>} Count of installed items
299
+ * @throws {CommandInstallationError} If command installation fails
300
+ * @throws {FilePermissionError} If permission denied
205
301
  */
206
302
  async installCommandsRecursive(sourceDir, targetDir, agileflowDir, injectDynamic = false) {
207
303
  let commandCount = 0;
@@ -211,7 +307,14 @@ class BaseIdeSetup {
211
307
  return { commands: 0, subdirs: 0 };
212
308
  }
213
309
 
214
- await this.ensureDir(targetDir);
310
+ try {
311
+ await this.ensureDir(targetDir);
312
+ } catch (error) {
313
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
314
+ throw new FilePermissionError(this.displayName, targetDir, 'write');
315
+ }
316
+ throw error;
317
+ }
215
318
 
216
319
  const entries = await fs.readdir(sourceDir, { withFileTypes: true });
217
320
 
@@ -220,19 +323,40 @@ class BaseIdeSetup {
220
323
  const targetPath = path.join(targetDir, entry.name);
221
324
 
222
325
  if (entry.isFile() && entry.name.endsWith('.md')) {
223
- // Read and process .md file
224
- let content = await this.readFile(sourcePath);
326
+ try {
327
+ // Read and process .md file
328
+ let content = await this.readFile(sourcePath);
225
329
 
226
- // Inject dynamic content if enabled (for top-level commands)
227
- if (injectDynamic) {
228
- content = this.injectDynamicContent(content, agileflowDir);
229
- }
330
+ // Inject dynamic content if enabled (for top-level commands)
331
+ if (injectDynamic) {
332
+ try {
333
+ content = this.injectDynamicContent(content, agileflowDir);
334
+ } catch (injectionError) {
335
+ throw new ContentInjectionError(
336
+ this.displayName,
337
+ sourcePath,
338
+ injectionError.message
339
+ );
340
+ }
341
+ }
230
342
 
231
- // Replace docs/ references with custom folder name
232
- content = this.replaceDocsReferences(content);
343
+ // Replace docs/ references with custom folder name
344
+ content = this.replaceDocsReferences(content);
233
345
 
234
- await this.writeFile(targetPath, content);
235
- commandCount++;
346
+ await this.writeFile(targetPath, content);
347
+ commandCount++;
348
+ } catch (error) {
349
+ // Re-throw typed errors as-is
350
+ if (error.name && error.name.includes('Error') && error.ideName) {
351
+ throw error;
352
+ }
353
+ throw new CommandInstallationError(
354
+ this.displayName,
355
+ entry.name,
356
+ error.message,
357
+ { sourcePath, targetPath }
358
+ );
359
+ }
236
360
  } else if (entry.isDirectory()) {
237
361
  // Recursively process subdirectory
238
362
  const subResult = await this.installCommandsRecursive(
@@ -26,68 +26,31 @@ class ClaudeCodeSetup extends BaseIdeSetup {
26
26
  * @param {Object} options - Setup options
27
27
  */
28
28
  async setup(projectDir, agileflowDir, options = {}) {
29
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
29
+ // Use standard setup for commands and agents
30
+ const result = await this.setupStandard(projectDir, agileflowDir, {
31
+ targetSubdir: this.commandsDir,
32
+ agileflowFolder: 'agileflow',
33
+ });
30
34
 
31
- // Clean up old installation first
32
- await this.cleanup(projectDir);
33
-
34
- // Create .claude/commands/agileflow directory
35
- const claudeDir = path.join(projectDir, this.configDir);
36
- const commandsDir = path.join(claudeDir, this.commandsDir);
37
- const agileflowCommandsDir = path.join(commandsDir, 'agileflow');
38
-
39
- await this.ensureDir(agileflowCommandsDir);
40
-
41
- // Recursively install all commands (including subdirectories like agents/, session/)
42
- const commandsSource = path.join(agileflowDir, 'commands');
43
- const commandResult = await this.installCommandsRecursive(
44
- commandsSource,
45
- agileflowCommandsDir,
46
- agileflowDir,
47
- true // Inject dynamic content for top-level commands
48
- );
49
-
50
- // Also install agents as slash commands (.claude/commands/agileflow/agents/)
35
+ const { ideDir, agileflowTargetDir } = result;
51
36
  const agentsSource = path.join(agileflowDir, 'agents');
52
- const agentsTargetDir = path.join(agileflowCommandsDir, 'agents');
53
- const agentResult = await this.installCommandsRecursive(
54
- agentsSource,
55
- agentsTargetDir,
56
- agileflowDir,
57
- false // No dynamic content for agents
58
- );
59
-
60
- // ALSO install agents as spawnable subagents (.claude/agents/agileflow/)
37
+
38
+ // Claude Code specific: Install agents as spawnable subagents (.claude/agents/agileflow/)
61
39
  // This allows Task tool to spawn them with subagent_type: "agileflow-ui"
62
- const spawnableAgentsDir = path.join(claudeDir, 'agents', 'agileflow');
40
+ const spawnableAgentsDir = path.join(ideDir, 'agents', 'agileflow');
63
41
  await this.installCommandsRecursive(agentsSource, spawnableAgentsDir, agileflowDir, false);
64
42
  console.log(chalk.dim(` - Spawnable agents: .claude/agents/agileflow/`));
65
43
 
66
- // Create skills directory for user-generated skills (.claude/skills/)
44
+ // Claude Code specific: Create skills directory for user-generated skills
67
45
  // AgileFlow no longer ships static skills - users generate them via /agileflow:skill:create
68
- const skillsTargetDir = path.join(claudeDir, 'skills');
46
+ const skillsTargetDir = path.join(ideDir, 'skills');
69
47
  await this.ensureDir(skillsTargetDir);
70
48
  console.log(chalk.dim(` - Skills directory: .claude/skills/ (for user-generated skills)`));
71
49
 
72
- // Setup damage control hooks
73
- await this.setupDamageControl(projectDir, agileflowDir, claudeDir, options);
74
-
75
- const totalCommands = commandResult.commands + agentResult.commands;
76
- const totalSubdirs =
77
- commandResult.subdirs + (agentResult.commands > 0 ? 1 : 0) + agentResult.subdirs;
78
-
79
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
80
- console.log(chalk.dim(` - ${totalCommands} commands installed`));
81
- if (totalSubdirs > 0) {
82
- console.log(chalk.dim(` - ${totalSubdirs} subdirectories`));
83
- }
84
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowCommandsDir)}`));
50
+ // Claude Code specific: Setup damage control hooks
51
+ await this.setupDamageControl(projectDir, agileflowDir, ideDir, options);
85
52
 
86
- return {
87
- success: true,
88
- commands: totalCommands,
89
- subdirs: totalSubdirs,
90
- };
53
+ return result;
91
54
  }
92
55
 
93
56
  /**
@@ -7,7 +7,6 @@
7
7
 
8
8
  const path = require('node:path');
9
9
  const fs = require('fs-extra');
10
- const chalk = require('chalk');
11
10
  const { BaseIdeSetup } = require('./_base-ide');
12
11
 
13
12
  /**
@@ -27,45 +26,10 @@ class CursorSetup extends BaseIdeSetup {
27
26
  * @param {Object} options - Setup options
28
27
  */
29
28
  async setup(projectDir, agileflowDir, options = {}) {
30
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
31
-
32
- // Clean up old installation first
33
- await this.cleanup(projectDir);
34
-
35
- // Create .cursor/commands/AgileFlow directory
36
- const cursorDir = path.join(projectDir, this.configDir);
37
- const commandsDir = path.join(cursorDir, this.commandsDir);
38
- const agileflowCommandsDir = path.join(commandsDir, 'AgileFlow');
39
-
40
- // Install commands using shared recursive method
41
- const commandsSource = path.join(agileflowDir, 'commands');
42
- const commandResult = await this.installCommandsRecursive(
43
- commandsSource,
44
- agileflowCommandsDir,
45
- agileflowDir,
46
- true // Inject dynamic content
47
- );
48
-
49
- // Install agents as subdirectory
50
- const agentsSource = path.join(agileflowDir, 'agents');
51
- const agentsTargetDir = path.join(agileflowCommandsDir, 'agents');
52
- const agentResult = await this.installCommandsRecursive(
53
- agentsSource,
54
- agentsTargetDir,
55
- agileflowDir,
56
- false // No dynamic content for agents
57
- );
58
-
59
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
60
- console.log(chalk.dim(` - ${commandResult.commands} commands installed`));
61
- console.log(chalk.dim(` - ${agentResult.commands} agents installed`));
62
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowCommandsDir)}`));
63
-
64
- return {
65
- success: true,
66
- commands: commandResult.commands,
67
- agents: agentResult.commands,
68
- };
29
+ return this.setupStandard(projectDir, agileflowDir, {
30
+ targetSubdir: this.commandsDir,
31
+ agileflowFolder: 'AgileFlow',
32
+ });
69
33
  }
70
34
 
71
35
  /**
@@ -7,7 +7,6 @@
7
7
 
8
8
  const path = require('node:path');
9
9
  const fs = require('fs-extra');
10
- const chalk = require('chalk');
11
10
  const { BaseIdeSetup } = require('./_base-ide');
12
11
 
13
12
  /**
@@ -27,45 +26,12 @@ class WindsurfSetup extends BaseIdeSetup {
27
26
  * @param {Object} options - Setup options
28
27
  */
29
28
  async setup(projectDir, agileflowDir, options = {}) {
30
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
31
-
32
- // Clean up old installation first
33
- await this.cleanup(projectDir);
34
-
35
- // Create .windsurf/workflows/agileflow directory
36
- const windsurfDir = path.join(projectDir, this.configDir);
37
- const workflowsDir = path.join(windsurfDir, this.workflowsDir);
38
- const agileflowWorkflowsDir = path.join(workflowsDir, 'agileflow');
39
-
40
- // Install commands using shared recursive method
41
- const commandsSource = path.join(agileflowDir, 'commands');
42
- const commandResult = await this.installCommandsRecursive(
43
- commandsSource,
44
- agileflowWorkflowsDir,
45
- agileflowDir,
46
- true // Inject dynamic content
47
- );
48
-
49
- // Install agents as subdirectory
50
- const agentsSource = path.join(agileflowDir, 'agents');
51
- const agentsTargetDir = path.join(agileflowWorkflowsDir, 'agents');
52
- const agentResult = await this.installCommandsRecursive(
53
- agentsSource,
54
- agentsTargetDir,
55
- agileflowDir,
56
- false // No dynamic content for agents
57
- );
58
-
59
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
60
- console.log(chalk.dim(` - ${commandResult.commands} workflows installed`));
61
- console.log(chalk.dim(` - ${agentResult.commands} agent workflows installed`));
62
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowWorkflowsDir)}`));
63
-
64
- return {
65
- success: true,
66
- commands: commandResult.commands,
67
- agents: agentResult.commands,
68
- };
29
+ return this.setupStandard(projectDir, agileflowDir, {
30
+ targetSubdir: this.workflowsDir,
31
+ agileflowFolder: 'agileflow',
32
+ commandLabel: 'workflows',
33
+ agentLabel: 'agent workflows',
34
+ });
69
35
  }
70
36
 
71
37
  /**
@@ -26,6 +26,7 @@ const path = require('path');
26
26
 
27
27
  // Use shared modules
28
28
  const { parseFrontmatter, normalizeTools } = require('../../../scripts/lib/frontmatter-parser');
29
+ const { validatePath } = require('../../../lib/validate');
29
30
  const {
30
31
  countCommands,
31
32
  countAgents,
@@ -37,6 +38,18 @@ const {
37
38
  // List Generation Functions
38
39
  // =============================================================================
39
40
 
41
+ /**
42
+ * Validate that a file path is within the expected directory.
43
+ * Prevents reading files outside the expected scope.
44
+ * @param {string} filePath - File path to validate
45
+ * @param {string} baseDir - Expected base directory
46
+ * @returns {boolean} True if path is safe
47
+ */
48
+ function isPathSafe(filePath, baseDir) {
49
+ const result = validatePath(filePath, baseDir, { allowSymlinks: true });
50
+ return result.ok;
51
+ }
52
+
40
53
  /**
41
54
  * Scan agents directory and generate formatted agent list
42
55
  * @param {string} agentsDir - Path to agents directory
@@ -50,6 +63,12 @@ function generateAgentList(agentsDir) {
50
63
 
51
64
  for (const file of files) {
52
65
  const filePath = path.join(agentsDir, file);
66
+
67
+ // Validate path before reading to prevent traversal via symlinks or malicious names
68
+ if (!isPathSafe(filePath, agentsDir)) {
69
+ continue;
70
+ }
71
+
53
72
  const content = fs.readFileSync(filePath, 'utf8');
54
73
  const frontmatter = parseFrontmatter(content);
55
74
 
@@ -94,6 +113,12 @@ function generateCommandList(commandsDir) {
94
113
  const mainFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
95
114
  for (const file of mainFiles) {
96
115
  const filePath = path.join(commandsDir, file);
116
+
117
+ // Validate path before reading
118
+ if (!isPathSafe(filePath, commandsDir)) {
119
+ continue;
120
+ }
121
+
97
122
  const content = fs.readFileSync(filePath, 'utf8');
98
123
  const frontmatter = parseFrontmatter(content);
99
124
  const cmdName = path.basename(file, '.md');
@@ -114,10 +139,22 @@ function generateCommandList(commandsDir) {
114
139
  for (const entry of entries) {
115
140
  if (entry.isDirectory()) {
116
141
  const subDir = path.join(commandsDir, entry.name);
142
+
143
+ // Validate subdirectory path
144
+ if (!isPathSafe(subDir, commandsDir)) {
145
+ continue;
146
+ }
147
+
117
148
  const subFiles = fs.readdirSync(subDir).filter(f => f.endsWith('.md'));
118
149
 
119
150
  for (const file of subFiles) {
120
151
  const filePath = path.join(subDir, file);
152
+
153
+ // Validate file path within subdirectory
154
+ if (!isPathSafe(filePath, commandsDir)) {
155
+ continue;
156
+ }
157
+
121
158
  const content = fs.readFileSync(filePath, 'utf8');
122
159
  const frontmatter = parseFrontmatter(content);
123
160
  const cmdName = `${entry.name}:${path.basename(file, '.md')}`;