diff --git a/test/test-installation-components.js b/test/test-installation-components.js index c2680fe83..9ad0ee1b5 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -862,6 +862,56 @@ internal: true console.log(''); + // ============================================================ + // Test 22: Custom BMAD Folder Workflow Path Guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 22: Custom BMAD Folder Workflow Path Guard${colors.reset}\n`); + + try { + const generator = new WorkflowCommandGenerator('mybmad'); + generator.loadWorkflowManifest = async () => [ + { + name: 'sprint-planning', + description: 'Sprint Planning', + module: 'bmm', + path: '/tmp/project/mybmad/bmm/workflows/4-implementation/sprint-planning/workflow.md', + }, + { + name: 'create-story', + description: 'Create Story', + module: 'bmm', + path: 'mybmad/bmm/workflows/4-implementation/create-story/workflow.md', + }, + ]; + generator.generateCommandContent = async () => 'content'; + + const { artifacts } = await generator.collectWorkflowArtifacts('/tmp'); + const sprintPlanning = artifacts.find((artifact) => artifact.name === 'sprint-planning'); + const createStory = artifacts.find((artifact) => artifact.name === 'create-story'); + + assert( + sprintPlanning?.workflowPath === 'bmm/workflows/4-implementation/sprint-planning/workflow.md', + 'Custom folder absolute workflow path strips configured BMAD folder prefix', + sprintPlanning?.workflowPath, + ); + assert( + createStory?.workflowPath === 'bmm/workflows/4-implementation/create-story/workflow.md', + 'Custom folder relative workflow path strips configured BMAD folder prefix', + createStory?.workflowPath, + ); + + const installedPath = generator.mapSourcePathToInstalled('/tmp/project/mybmad/core/tasks/workflow.md'); + assert( + installedPath === 'mybmad/core/tasks/workflow.md', + 'Installed workflow path mapping handles absolute paths containing custom BMAD folder', + installedPath, + ); + } catch (error) { + assert(false, 'Custom BMAD folder workflow path guard runs', error.message); + } + + console.log(''); + for (const tmpRoot of tmpRoots) { await fs.remove(tmpRoot).catch(() => {}); } diff --git a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js index 75151e167..75c1b2601 100644 --- a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js @@ -4,6 +4,10 @@ const csv = require('csv-parse/sync'); const prompts = require('../../../../lib/prompts'); const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils'); +function escapeRegex(value) { + return String(value).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +} + /** * Generates command files for each workflow in the manifest */ @@ -67,22 +71,7 @@ class WorkflowCommandGenerator { for (const workflow of allWorkflows) { const commandContent = await this.generateCommandContent(workflow, bmadDir); - // Calculate the relative workflow path (e.g., bmm/workflows/4-implementation/sprint-planning/workflow.md) - let workflowRelPath = workflow.path || ''; - workflowRelPath = workflowRelPath.replaceAll('\\', '/'); - // Remove _bmad/ prefix if present to get relative path from project root - // Handle both absolute paths (/path/to/_bmad/...) and relative paths (_bmad/...) - if (workflowRelPath.includes('_bmad/')) { - const parts = workflowRelPath.split(/_bmad\//); - if (parts.length > 1) { - workflowRelPath = parts.at(-1); - } - } else if (workflowRelPath.includes('/src/') || workflowRelPath.startsWith('src/')) { - const match = workflowRelPath.match(/(?:^|\/)src\/([^/]+)\/(.+)/); - if (match) { - workflowRelPath = `${match[1]}/${match[2]}`; - } - } + const workflowRelPath = this.mapSourcePathToModuleRelative(workflow.path); artifacts.push({ type: 'workflow-command', name: workflow.name, @@ -213,6 +202,29 @@ When running any workflow: return this.mapSourcePathToInstalled(workflowPath, true); } + mapSourcePathToModuleRelative(sourcePath) { + const mapped = this.mapSourcePathToInstalled(sourcePath, false); + if (!mapped) { + return mapped; + } + + const normalized = String(mapped).replaceAll('\\', '/'); + + // Typical installed path -> strip BMAD root prefix for templates that prepend it. + if (normalized.startsWith(`${this.bmadFolderName}/`)) { + return normalized.slice(`${this.bmadFolderName}/`.length); + } + + // Absolute path containing the configured BMAD root folder. + const folderPattern = new RegExp(`(?:^|\\/)${escapeRegex(this.bmadFolderName)}\\/(.+)`); + const folderMatch = normalized.match(folderPattern); + if (folderMatch) { + return folderMatch[1]; + } + + return normalized; + } + mapSourcePathToInstalled(sourcePath, includeProjectRootPrefix = false) { if (!sourcePath) { return sourcePath; @@ -232,6 +244,15 @@ When running any workflow: return includeProjectRootPrefix ? `{project-root}/${mapped}` : mapped; } + // Handle absolute paths that already include the configured BMAD folder + // (e.g., /tmp/project/mybmad/bmm/workflows/...). + const folderPattern = new RegExp(`(?:^|\\/)${escapeRegex(this.bmadFolderName)}\\/(.+)`); + const folderMatch = normalized.match(folderPattern); + if (folderMatch) { + const mapped = `${this.bmadFolderName}/${folderMatch[1]}`; + return includeProjectRootPrefix ? `{project-root}/${mapped}` : mapped; + } + if (normalized.startsWith(`${this.bmadFolderName}/`)) { return includeProjectRootPrefix ? `{project-root}/${normalized}` : normalized; }