diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 2c9cc59a1..17bed97ee 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -20,6 +20,7 @@ const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { WorkflowCommandGenerator } = require('../tools/cli/installers/lib/ide/shared/workflow-command-generator'); const { TaskToolCommandGenerator } = require('../tools/cli/installers/lib/ide/shared/task-tool-command-generator'); +const { ConfigDrivenIdeSetup } = require('../tools/cli/installers/lib/ide/_config-driven'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); const { CodexSetup } = require('../tools/cli/installers/lib/ide/codex'); const { ModuleManager } = require('../tools/cli/installers/lib/modules/manager'); @@ -744,6 +745,117 @@ internal: true console.log(''); + // ============================================================ + // Test 19: Empty Target Artifact Filter Guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 19: Empty Artifact Target Guard${colors.reset}\n`); + + try { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-empty-target-')); + const projectDir = path.join(tmpRoot, 'project'); + const bmadDir = path.join(tmpRoot, BMAD_FOLDER_NAME); + await fs.ensureDir(projectDir); + await fs.ensureDir(bmadDir); + + const setup = new ConfigDrivenIdeSetup('test', { + name: 'Test IDE', + preferred: false, + installer: { target_dir: '.test', template_type: 'default' }, + }); + + const result = await setup.installToTarget( + projectDir, + bmadDir, + { target_dir: '.test', template_type: 'default', artifact_types: [] }, + { silent: true }, + ); + + assert( + result.success && + result.results.agents === 0 && + result.results.workflows === 0 && + result.results.tasks === 0 && + result.results.tools === 0, + 'Installer short-circuits explicit empty artifact target', + ); + + assert(!(await fs.pathExists(path.join(projectDir, '.test'))), 'Installer does not create output directory for empty artifact target'); + + await fs.remove(tmpRoot); + } catch (error) { + assert(false, 'Empty artifact target guard runs', error.message); + } + + console.log(''); + + // ============================================================ + // Test 20: Split Template Extension Override Guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 20: Split Template Extension Guard${colors.reset}\n`); + + try { + const setup = new ConfigDrivenIdeSetup('test', { + name: 'Test IDE', + preferred: false, + installer: { target_dir: '.test', template_type: 'default' }, + }); + + setup.loadSplitTemplates = async () => 'template-content'; + const loaded = await setup.loadTemplateWithMetadata('default', 'workflow', { + header_template: 'header.md', + extension: 'toml', + }); + + assert(loaded.extension === '.toml', 'Split template loader preserves configured extension override'); + } catch (error) { + assert(false, 'Split template extension guard runs', error.message); + } + + console.log(''); + + // ============================================================ + // Test 21: Workflow Path Normalization Guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 21: Workflow Path Normalization Guard${colors.reset}\n`); + + try { + const generator = new WorkflowCommandGenerator(); + generator.loadWorkflowManifest = async () => [ + { + name: 'create-story', + description: 'Create Story', + module: 'bmm', + path: String.raw`C:\repo\src\bmm\workflows\4-implementation\create-story\workflow.md`, + }, + { + name: 'validate-workflow', + description: 'Validate Workflow', + module: 'core', + path: String.raw`C:\repo\_bmad\core\workflows\validate-workflow\workflow.md`, + }, + ]; + generator.generateCommandContent = async () => 'content'; + + const { artifacts } = await generator.collectWorkflowArtifacts('/tmp'); + const createStory = artifacts.find((artifact) => artifact.name === 'create-story'); + const validateWorkflow = artifacts.find((artifact) => artifact.name === 'validate-workflow'); + + assert( + createStory?.workflowPath === 'bmm/workflows/4-implementation/create-story/workflow.md', + 'Workflow artifact path normalizes Windows src path to module-relative path', + createStory?.workflowPath, + ); + assert( + validateWorkflow?.workflowPath === 'core/workflows/validate-workflow/workflow.md', + 'Workflow artifact path strips _bmad prefix after separator normalization', + validateWorkflow?.workflowPath, + ); + } catch (error) { + assert(false, 'Workflow path normalization guard runs', error.message); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 1d22d8a1c..0f363f2e2 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -66,6 +66,12 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir, template_type, artifact_types } = config; + + // Skip explicitly empty targets to avoid creating empty command directories. + if (Array.isArray(artifact_types) && artifact_types.length === 0) { + return { success: true, results: { agents: 0, workflows: 0, tasks: 0, tools: 0 } }; + } + const targetPath = path.join(projectDir, target_dir); await this.ensureDir(targetPath); @@ -250,12 +256,13 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { // Check for separate header/body templates if (header_template || body_template) { const template = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template); - return { template, extension: '.md' }; + return { template, extension: this.normalizeExtension(config.extension) }; } // Load combined template with extension detection + const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType; for (const extension of supportedExtensions) { - const templateName = `${templateType}-${artifactType}${extension}`; + const templateName = `${templateBaseName}${extension}`; const templatePath = path.join(__dirname, 'templates', 'combined', templateName); if (await fs.pathExists(templatePath)) { return { @@ -329,6 +336,19 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { return `${header}\n${body}`; } + normalizeExtension(extension) { + if (!extension) { + return '.md'; + } + + const trimmed = String(extension).trim(); + if (trimmed === '') { + return '.md'; + } + + return trimmed.startsWith('.') ? trimmed : `.${trimmed}`; + } + /** * Get default minimal template * @param {string} artifactType - Artifact type 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 29b8e7117..8ce9ae3e9 100644 --- a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js @@ -68,7 +68,8 @@ 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; + 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/')) { @@ -76,6 +77,11 @@ class WorkflowCommandGenerator { if (parts.length > 1) { workflowRelPath = parts.slice(1).join('/'); } + } else if (workflowRelPath.includes('/src/')) { + const match = workflowRelPath.match(/\/src\/([^/]+)\/(.+)/); + if (match) { + workflowRelPath = `${match[1]}/${match[2]}`; + } } artifacts.push({ type: 'workflow-command',