fix(cli): extract core-config-defaults module and centralize fallback logic

- Extract getDefaultCoreConfig, applyDefaultCoreConfig, and
  isMissingOrUnresolvedCoreConfigValue into core-config-defaults.js
- Memoize YAML parsing with clearable cache for testing
- Use prompts.log.warn instead of console.warn for error logging
- Guard process.cwd() with try/catch in --yes path
- Centralize handler.js placeholder replacement via shared defaults
- Add tests for backfill logic and custom handler shared defaults
This commit is contained in:
Jonah Schulte 2026-03-25 00:31:01 -04:00
parent 1571a3cff4
commit 8927e207e8
3 changed files with 141 additions and 55 deletions

View File

@ -15,6 +15,7 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { ConfigCollector } = require('../tools/cli/installers/lib/core/config-collector'); const { ConfigCollector } = require('../tools/cli/installers/lib/core/config-collector');
const { applyDefaultCoreConfig, clearCoreConfigDefaultsCache, getDefaultCoreConfig } = require('../tools/cli/lib/core-config-defaults');
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes'); const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
@ -2028,6 +2029,38 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 34: Core Config Default Backfill
// ============================================================
console.log(`${colors.yellow}Test Suite 34: Core Config Default Backfill${colors.reset}\n`);
try {
clearCoreConfigDefaultsCache();
const defaults = await getDefaultCoreConfig();
const normalized = applyDefaultCoreConfig(
{
user_name: '{user_name}',
communication_language: 'Spanish',
document_output_language: '',
output_folder: '{output_folder}',
},
defaults,
);
assert(normalized.appliedDefaults === true, 'Core config backfill reports when defaults were applied');
assert(normalized.coreConfig.user_name === defaults.user_name, 'Core config backfill replaces unresolved user_name placeholder');
assert(normalized.coreConfig.communication_language === 'Spanish', 'Core config backfill preserves existing valid values');
assert(
normalized.coreConfig.document_output_language === defaults.document_output_language,
'Core config backfill replaces blank document output language',
);
assert(normalized.coreConfig.output_folder === defaults.output_folder, 'Core config backfill replaces unresolved output_folder');
} catch (error) {
assert(false, 'Core config default backfill test succeeds', error.message);
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -0,0 +1,89 @@
const os = require('node:os');
const fs = require('fs-extra');
const yaml = require('yaml');
const prompts = require('./prompts');
const { getModulePath } = require('./project-root');
let cachedCoreConfigDefaults = null;
function getFallbackUsername() {
let safeUsername;
try {
safeUsername = os.userInfo().username;
} catch {
safeUsername = process.env.USER || process.env.USERNAME || 'User';
}
if (typeof safeUsername !== 'string' || safeUsername.trim() === '') {
return 'User';
}
const normalizedUsername = safeUsername.trim();
return normalizedUsername.charAt(0).toUpperCase() + normalizedUsername.slice(1);
}
function normalizeDefaultString(value, fallback) {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback;
}
function isMissingOrUnresolvedCoreConfigValue(value) {
return value == null || (typeof value === 'string' && (value.trim() === '' || /^\{[^}]+\}$/.test(value.trim())));
}
function applyDefaultCoreConfig(coreConfig = {}, defaults = {}) {
const normalizedConfig = { ...coreConfig };
let appliedDefaults = false;
for (const [key, value] of Object.entries(defaults)) {
if (isMissingOrUnresolvedCoreConfigValue(normalizedConfig[key])) {
normalizedConfig[key] = value;
appliedDefaults = true;
}
}
return { coreConfig: normalizedConfig, appliedDefaults };
}
async function getDefaultCoreConfig() {
if (cachedCoreConfigDefaults) {
return { ...cachedCoreConfigDefaults };
}
const fallbackDefaults = {
user_name: getFallbackUsername(),
communication_language: 'English',
document_output_language: 'English',
output_folder: '_bmad-output',
};
try {
const moduleYamlPath = getModulePath('core', 'module.yaml');
const moduleConfig = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8')) || {};
cachedCoreConfigDefaults = {
user_name: normalizeDefaultString(moduleConfig.user_name?.default, fallbackDefaults.user_name),
communication_language: normalizeDefaultString(moduleConfig.communication_language?.default, fallbackDefaults.communication_language),
document_output_language: normalizeDefaultString(
moduleConfig.document_output_language?.default,
fallbackDefaults.document_output_language,
),
output_folder: normalizeDefaultString(moduleConfig.output_folder?.default, fallbackDefaults.output_folder),
};
} catch (error) {
await prompts.log.warn(`Failed to load module.yaml, falling back to defaults: ${error.message}`);
cachedCoreConfigDefaults = fallbackDefaults;
}
return { ...cachedCoreConfigDefaults };
}
function clearCoreConfigDefaultsCache() {
cachedCoreConfigDefaults = null;
}
module.exports = {
applyDefaultCoreConfig,
clearCoreConfigDefaultsCache,
getDefaultCoreConfig,
isMissingOrUnresolvedCoreConfigValue,
};

View File

@ -2,6 +2,7 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { applyDefaultCoreConfig, getDefaultCoreConfig: loadDefaultCoreConfig } = require('./core-config-defaults');
const { CustomHandler } = require('../installers/lib/custom/handler'); const { CustomHandler } = require('../installers/lib/custom/handler');
const { ExternalModuleManager } = require('../installers/lib/modules/external-manager'); const { ExternalModuleManager } = require('../installers/lib/modules/external-manager');
const prompts = require('./prompts'); const prompts = require('./prompts');
@ -49,7 +50,13 @@ class UI {
await prompts.log.info(`Using directory from command-line: ${confirmedDirectory}`); await prompts.log.info(`Using directory from command-line: ${confirmedDirectory}`);
} else if (options.yes) { } else if (options.yes) {
// Default to current directory when --yes flag is set // Default to current directory when --yes flag is set
const cwd = process.cwd(); let cwd;
try {
cwd = process.cwd();
} catch (error) {
await prompts.log.error(`Failed to resolve current directory (--yes flag): ${error.message}`);
throw new Error(`Unable to determine current directory: ${error.message}`);
}
const validation = this.validateDirectorySync(cwd); const validation = this.validateDirectorySync(cwd);
if (validation) { if (validation) {
throw new Error(`Invalid current directory: ${validation}`); throw new Error(`Invalid current directory: ${validation}`);
@ -836,39 +843,8 @@ class UI {
* Get default core config values by reading from src/core/module.yaml * Get default core config values by reading from src/core/module.yaml
* @returns {Object} Default core config with user_name, communication_language, document_output_language, output_folder * @returns {Object} Default core config with user_name, communication_language, document_output_language, output_folder
*/ */
getDefaultCoreConfig() { async getDefaultCoreConfig() {
const { getModulePath } = require('./project-root'); return loadDefaultCoreConfig();
const yaml = require('yaml');
let safeUsername;
try {
safeUsername = os.userInfo().username;
} catch {
safeUsername = process.env.USER || process.env.USERNAME || 'User';
}
const osUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
const norm = (value, fallback) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback);
// Read defaults from core module.yaml (single source of truth)
try {
const moduleYamlPath = path.join(getModulePath('core'), 'module.yaml');
const moduleConfig = yaml.parse(fs.readFileSync(moduleYamlPath, 'utf8'));
return {
user_name: norm(moduleConfig.user_name?.default, osUsername),
communication_language: norm(moduleConfig.communication_language?.default, 'English'),
document_output_language: norm(moduleConfig.document_output_language?.default, 'English'),
output_folder: norm(moduleConfig.output_folder?.default, '_bmad-output'),
};
} catch (error) {
console.warn(`Failed to load module.yaml, falling back to defaults: ${error.message}`);
return {
user_name: osUsername,
communication_language: 'English',
document_output_language: 'English',
output_folder: '_bmad-output',
};
}
} }
/** /**
@ -915,32 +891,20 @@ class UI {
) { ) {
await configCollector.collectModuleConfig('core', directory, false, true); await configCollector.collectModuleConfig('core', directory, false, true);
} else if (options.yes) { } else if (options.yes) {
// Fill in defaults for any fields not provided via command-line or existing config const defaults = await this.getDefaultCoreConfig();
const isMissingOrUnresolved = (v) => v == null || (typeof v === 'string' && (v.trim() === '' || /^\{[^}]+\}$/.test(v.trim()))); const normalizedConfig = applyDefaultCoreConfig(configCollector.collectedConfig.core, defaults);
configCollector.collectedConfig.core = normalizedConfig.coreConfig;
const defaults = this.getDefaultCoreConfig(); if (normalizedConfig.appliedDefaults) {
for (const [key, value] of Object.entries(defaults)) { await prompts.log.info('Using default configuration (--yes flag)');
if (isMissingOrUnresolved(configCollector.collectedConfig.core[key])) {
configCollector.collectedConfig.core[key] = value;
}
} }
} }
} else if (options.yes) { } else if (options.yes) {
// Use all defaults when --yes flag is set, merging with any existing config
await configCollector.loadExistingConfig(directory); await configCollector.loadExistingConfig(directory);
const existingConfig = configCollector.collectedConfig.core || {}; const existingConfig = configCollector.collectedConfig.core || {};
const defaults = this.getDefaultCoreConfig(); const defaults = await this.getDefaultCoreConfig();
configCollector.collectedConfig.core = { ...defaults, ...existingConfig }; const normalizedConfig = applyDefaultCoreConfig(existingConfig, defaults);
configCollector.collectedConfig.core = normalizedConfig.coreConfig;
// Clean up any unresolved placeholder tokens from existing config if (normalizedConfig.appliedDefaults) {
const isMissingOrUnresolved = (v) => v == null || (typeof v === 'string' && (v.trim() === '' || /^\{[^}]+\}$/.test(v.trim())));
for (const [key, value] of Object.entries(configCollector.collectedConfig.core)) {
if (isMissingOrUnresolved(value)) {
configCollector.collectedConfig.core[key] = defaults[key];
}
}
if (Object.keys(existingConfig).length === 0) {
await prompts.log.info('Using default configuration (--yes flag)'); await prompts.log.info('Using default configuration (--yes flag)');
} }
} else { } else {