Merge branch 'main' into chore/remove-barry-agent

This commit is contained in:
Alex Verkhovsky 2026-04-01 05:58:15 -07:00 committed by GitHub
commit 7e0c325e20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 349 additions and 117 deletions

View File

@ -14,7 +14,9 @@
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
const { Installer } = require('../tools/installer/core/installer');
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
const { OfficialModules } = require('../tools/installer/modules/official-modules');
const { IdeManager } = require('../tools/installer/ide/manager');
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
@ -126,6 +128,56 @@ async function createSkillCollisionFixture() {
return { root: fixtureRoot, bmadDir: fixtureDir };
}
async function createCustomModuleManifestFixture() {
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-manifest-'));
const bmadDir = path.join(fixtureRoot, '_bmad');
const configDir = path.join(bmadDir, '_config');
const moduleSourceDir = path.join(fixtureRoot, 'test-module-source');
await fs.ensureDir(configDir);
await fs.ensureDir(moduleSourceDir);
const minimalAgent = '<agent name="Test" title="T"><persona>p</persona></agent>';
await fs.ensureDir(path.join(bmadDir, 'core', 'agents'));
await fs.writeFile(path.join(bmadDir, 'core', 'agents', 'test.md'), minimalAgent);
await fs.ensureDir(path.join(bmadDir, 'test-module', 'agents'));
await fs.writeFile(path.join(bmadDir, 'test-module', 'agents', 'test.md'), minimalAgent);
await fs.writeFile(path.join(moduleSourceDir, 'module.yaml'), ['code: test-module', 'name: Test Module', ''].join('\n'));
await fs.writeFile(
path.join(configDir, 'manifest.yaml'),
[
'installation:',
' version: 6.2.2',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
'modules:',
' - name: core',
' version: 6.2.2',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
' source: built-in',
' npmPackage: null',
' repoUrl: null',
' - name: test-module',
' version: null',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
' source: custom',
' npmPackage: null',
' repoUrl: null',
'customModules:',
' - id: test-module',
' name: "Test Module"',
` sourcePath: ${JSON.stringify(moduleSourceDir)}`,
'ides:',
' - codex',
'',
].join('\n'),
);
return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml'), moduleSourceDir };
}
/**
* Test Suite
*/
@ -1713,6 +1765,107 @@ async function runTests() {
console.log('');
// ============================================================
// Suite 33: Main manifest preserves active customModules only
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
let customManifestFixture = null;
try {
customManifestFixture = await createCustomModuleManifestFixture();
const yaml = require('yaml');
const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
originalManifest.customModules.push({
id: 'removed-module',
name: 'Removed Module',
sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
});
await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
const generator33 = new ManifestGenerator();
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array');
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
assert(
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
'Main manifest preserves custom module sourcePath',
);
assert(
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
'Main manifest drops stale custom module entries',
);
} catch (error) {
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
} finally {
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
}
console.log('');
// ============================================================
// Suite 34: Quick update uses manifest-backed custom sources
// ============================================================
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
let quickUpdateFixture = null;
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
try {
quickUpdateFixture = await createCustomModuleManifestFixture();
const installer34 = new Installer();
installer34.externalModuleManager.hasModule = async () => false;
installer34.externalModuleManager.listAvailable = async () => [];
let capturedInstallConfig34 = null;
installer34.install = async (config) => {
capturedInstallConfig34 = config;
return { success: true };
};
OfficialModules.prototype.listAvailable = async function () {
return { modules: [], customModules: [] };
};
OfficialModules.prototype.loadExistingConfig = async function () {
this.collectedConfig = this.collectedConfig || {};
};
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
this.collectedConfig = this.collectedConfig || {};
if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {};
}
return false;
};
await installer34.quickUpdate({
directory: quickUpdateFixture.root,
skipPrompts: true,
});
const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
assert(
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
'Quick update uses preserved manifest sourcePath for custom modules',
);
} catch (error) {
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
} finally {
OfficialModules.prototype.listAvailable = originalListAvailable34;
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -1144,59 +1144,12 @@ class Installer {
const configuredIdes = existingInstall.ides;
const projectRoot = path.dirname(bmadDir);
// Get custom module sources: first from --custom-content (re-cache from source), then from cache
const customModuleSources = new Map();
if (config.customContent?.sources?.length > 0) {
for (const source of config.customContent.sources) {
if (source.id && source.path && (await fs.pathExists(source.path))) {
customModuleSources.set(source.id, {
id: source.id,
name: source.name || source.id,
sourcePath: source.path,
cached: false, // From CLI, will be re-cached
});
}
}
}
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name;
const cachedPath = path.join(cacheDir, moduleId);
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
if (!(await fs.pathExists(cachedPath))) {
continue;
}
if (!cachedModule.isDirectory()) {
continue;
}
// Skip if we already have this module from manifest
if (customModuleSources.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await this.externalModuleManager.hasModule(moduleId);
if (isExternal) {
continue;
}
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId,
sourcePath: cachedPath,
cached: true,
});
}
}
}
const customModuleSources = await this.customModules.assembleQuickUpdateSources(
config,
existingInstall,
bmadDir,
this.externalModuleManager,
);
// Get available modules (what we have source for)
const availableModulesData = await new OfficialModules().listAvailable();

View File

@ -377,10 +377,12 @@ class ManifestGenerator {
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
const installedModuleSet = new Set(this.modules);
// Read existing manifest to preserve install date
let existingInstallDate = null;
const existingModulesMap = new Map();
let existingCustomModules = [];
if (await fs.pathExists(manifestPath)) {
try {
@ -402,6 +404,12 @@ class ManifestGenerator {
}
}
}
if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) {
// We filter here so manifest regeneration preserves source metadata only for custom modules that
// are still installed. Without that, customModules can retain stale entries for modules that were removed.
existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id));
}
} catch {
// If we can't read existing manifest, continue with defaults
}
@ -437,6 +445,7 @@ class ManifestGenerator {
lastUpdated: new Date().toISOString(),
},
modules: updatedModules,
customModules: existingCustomModules,
ides: this.selectedIdes,
};

View File

@ -192,6 +192,111 @@ class CustomModules {
return this.paths;
}
/**
* Assemble quick-update source candidates before install() hands them to discoverPaths().
* This exists because discoverPaths() consumes already-prepared quick-update sources,
* while quickUpdate() still has to build that source map from manifest, explicit inputs,
* and cache conventions.
* Precedence: manifest-backed paths, explicit sources override them, then cached modules.
* @param {Object} config - Quick update configuration
* @param {Object} existingInstall - Existing installation snapshot
* @param {string} bmadDir - BMAD directory
* @param {Object} externalModuleManager - External module manager
* @returns {Promise<Map<string, Object>>} Map of custom module ID to source info
*/
async assembleQuickUpdateSources(config, existingInstall, bmadDir, externalModuleManager) {
const projectRoot = path.dirname(bmadDir);
const customModuleSources = new Map();
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
// Skip if no ID - can't reliably track or re-cache without it
if (!customModule?.id) continue;
let sourcePath = customModule.sourcePath;
if (sourcePath && sourcePath.startsWith('_config')) {
// Paths are relative to BMAD dir, but we want absolute paths for install
sourcePath = path.join(bmadDir, sourcePath);
} else if (!sourcePath && customModule.relativePath) {
// Fall back to relativePath
sourcePath = path.resolve(projectRoot, customModule.relativePath);
} else if (sourcePath && !path.isAbsolute(sourcePath)) {
// If we have a sourcePath but it's not absolute, resolve it relative to project root
sourcePath = path.resolve(projectRoot, sourcePath);
}
// If we still don't have a valid source path, skip this module
if (!sourcePath || !(await fs.pathExists(sourcePath))) {
continue;
}
customModuleSources.set(customModule.id, {
id: customModule.id,
name: customModule.name || customModule.id,
sourcePath,
relativePath: customModule.relativePath,
cached: false,
});
}
}
if (config.customContent?.sources?.length > 0) {
for (const source of config.customContent.sources) {
if (source.id && source.path) {
customModuleSources.set(source.id, {
id: source.id,
name: source.name || source.id,
sourcePath: source.path,
cached: false, // From CLI, will be re-cached
});
}
}
}
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (!(await fs.pathExists(cacheDir))) {
return customModuleSources;
}
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name;
const cachedPath = path.join(cacheDir, moduleId);
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
if (!(await fs.pathExists(cachedPath))) {
continue;
}
if (!cachedModule.isDirectory()) {
continue;
}
// Skip if we already have this module from manifest
if (customModuleSources.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await externalModuleManager.hasModule(moduleId);
if (isExternal) {
continue;
}
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId,
sourcePath: cachedPath,
cached: true,
});
}
}
return customModuleSources;
}
}
module.exports = { CustomModules };

View File

@ -50,12 +50,6 @@ export default defineConfig({
defaultLocale: 'root',
locales,
logo: {
light: './public/img/bmad-light.png',
dark: './public/img/bmad-dark.png',
alt: 'BMAD Method',
replacesTitle: true,
},
favicon: '/favicon.ico',
// Social links

View File

@ -12,16 +12,16 @@ const llmsFullUrl = `${getSiteUrl()}/llms-full.txt`;
.ai-banner {
width: 100%;
height: var(--ai-banner-height, 2.75rem);
background: #334155;
color: #cbd5e1;
background: #1a1a1a;
color: #a1a1a1;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-bottom: 1px solid rgba(140, 140, 255, 0.15);
border-bottom: 1px solid #262626;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-family: system-ui, sans-serif;
font-family: 'Inter', system-ui, sans-serif;
}
/* Truncate text on narrow screens */
@ -32,15 +32,16 @@ const llmsFullUrl = `${getSiteUrl()}/llms-full.txt`;
max-width: 100%;
}
.ai-banner a {
color: #B9B9FF;
color: #3b82f6;
text-decoration: none;
font-weight: 600;
}
.ai-banner a:hover {
color: #fafafa;
text-decoration: underline;
}
.ai-banner a:focus-visible {
outline: 2px solid #B9B9FF;
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 2px;
}

View File

@ -1,14 +1,15 @@
/**
* BMAD Method Documentation - Custom Styles for Starlight
* Electric Blue theme optimized for dark mode
* Dark theme matching bmadcode.com Ghost blog
*
* CSS Variable Mapping:
* Docusaurus Starlight
* --ifm-color-primary --sl-color-accent
* --ifm-background-color --sl-color-bg
* --ifm-font-color-base --sl-color-text
* Design tokens from Ghost theme:
* Background: #0a0a0a | Surface: #1a1a1a | Border: #262626
* Accent: #3b82f6 | Gold: #d4a853 | Text: #fafafa/#a1a1a1/#666666
*/
/* Google Fonts - match Ghost blog typography */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ============================================
COLOR PALETTE - Light Mode
============================================ */
@ -19,10 +20,10 @@
/* Full-width content - override Starlight's default 45rem/67.5rem */
--sl-content-width: 65rem;
/* Primary accent colors - purple to match Docusaurus */
--sl-color-accent-low: #e0e0ff;
--sl-color-accent: #5E5ED0;
--sl-color-accent-high: #3333CC;
/* Primary accent colors - blue to match Ghost blog */
--sl-color-accent-low: #dbeafe;
--sl-color-accent: #2563eb;
--sl-color-accent-high: #1d4ed8;
/* Text colors */
--sl-color-white: #1e293b;
@ -35,13 +36,14 @@
--sl-color-black: #f8fafc;
/* Font settings */
--sl-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
--sl-font: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
--sl-font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--sl-text-base: 1rem;
--sl-line-height: 1.7;
/* Code highlighting */
--sl-color-bg-inline-code: rgba(94, 94, 208, 0.1);
--sl-color-bg-inline-code: rgba(59, 130, 246, 0.08);
}
/* ============================================
@ -51,35 +53,49 @@
/* Full-width content - override Starlight's default */
--sl-content-width: 65rem;
/* Primary accent colors - purple to match Docusaurus */
--sl-color-accent-low: #2a2a5a;
--sl-color-accent: #8C8CFF;
--sl-color-accent-high: #B9B9FF;
/* Primary accent colors - blue to match Ghost blog */
--sl-color-accent-low: rgba(59, 130, 246, 0.12);
--sl-color-accent: #3b82f6;
--sl-color-accent-high: #60a5fa;
/* Background colors */
--sl-color-bg: #1b1b1d;
--sl-color-bg-nav: #1b1b1d;
--sl-color-bg-sidebar: #1b1b1d;
--sl-color-hairline-light: rgba(140, 140, 255, 0.1);
--sl-color-hairline: rgba(140, 140, 255, 0.15);
/* Background colors - match Ghost blog */
--sl-color-bg: #0a0a0a;
--sl-color-bg-nav: #0a0a0a;
--sl-color-bg-sidebar: #0a0a0a;
--sl-color-hairline-light: rgba(255, 255, 255, 0.06);
--sl-color-hairline: #262626;
/* Text colors */
--sl-color-white: #f8fafc;
/* Text colors - match Ghost blog */
--sl-color-white: #fafafa;
--sl-color-gray-1: #e2e8f0;
--sl-color-gray-2: #cbd5e1;
--sl-color-gray-2: #a1a1a1;
--sl-color-gray-3: #94a3b8;
--sl-color-gray-4: #64748b;
--sl-color-gray-4: #666666;
--sl-color-gray-5: #475569;
--sl-color-gray-6: #334155;
--sl-color-black: #1b1b1d;
--sl-color-gray-6: #262626;
--sl-color-black: #0a0a0a;
/* Code highlighting */
--sl-color-bg-inline-code: rgba(140, 140, 255, 0.15);
--sl-color-bg-inline-code: rgba(59, 130, 246, 0.15);
}
/* ============================================
TYPOGRAPHY
============================================ */
/* Space Grotesk for all headings - match Ghost blog */
.sl-markdown-content h1,
.sl-markdown-content h2,
.sl-markdown-content h3,
.sl-markdown-content h4,
.sl-markdown-content h5,
.sl-markdown-content h6,
.site-title,
starlight-toc h2 {
font-family: 'Space Grotesk', 'Inter', system-ui, sans-serif;
letter-spacing: -0.02em;
}
.sl-markdown-content h1 {
margin-bottom: 1.5rem;
}
@ -138,14 +154,14 @@
/* Active state - thin left accent bar */
.sidebar-content a[aria-current='page'] {
background-color: rgba(94, 94, 208, 0.08);
background-color: rgba(59, 130, 246, 0.08);
color: var(--sl-color-accent);
border-left-color: var(--sl-color-accent);
font-weight: 600;
}
:root[data-theme='dark'] .sidebar-content a[aria-current='page'] {
background-color: rgba(140, 140, 255, 0.1);
background-color: rgba(59, 130, 246, 0.1);
color: var(--sl-color-accent-high);
border-left-color: var(--sl-color-accent);
}
@ -232,7 +248,8 @@ header.header .header.sl-flex {
}
:root[data-theme='dark'] header.header {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
box-shadow: none;
border-bottom: 1px solid #262626;
}
.site-title {
@ -281,20 +298,20 @@ header.header .header.sl-flex {
.card:hover {
transform: translateY(-3px);
border-color: var(--sl-color-accent);
box-shadow: 0 8px 24px rgba(94, 94, 208, 0.15);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.15);
}
:root[data-theme='dark'] .card {
background: linear-gradient(145deg, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.8));
border-color: rgba(140, 140, 255, 0.2);
background: #1a1a1a;
border-color: #262626;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
:root[data-theme='dark'] .card:hover {
border-color: rgba(140, 140, 255, 0.5);
border-color: #3b82f6;
box-shadow:
0 8px 32px rgba(140, 140, 255, 0.2),
0 0 0 1px rgba(140, 140, 255, 0.1);
0 8px 32px rgba(59, 130, 246, 0.15),
0 0 0 1px rgba(59, 130, 246, 0.1);
}
/* Starlight card grid */
@ -313,11 +330,11 @@ header.header .header.sl-flex {
}
:root[data-theme='dark'] .sl-link-card {
border-color: rgba(140, 140, 255, 0.2);
border-color: #262626;
}
:root[data-theme='dark'] .sl-link-card:hover {
border-color: rgba(140, 140, 255, 0.5);
border-color: #3b82f6;
}
/* ============================================
@ -372,21 +389,21 @@ table {
}
:root[data-theme='dark'] table {
border-color: rgba(140, 140, 255, 0.1);
border-color: #262626;
}
:root[data-theme='dark'] table th {
background-color: rgba(140, 140, 255, 0.05);
background-color: rgba(59, 130, 246, 0.05);
}
:root[data-theme='dark'] table tr:nth-child(2n) {
background-color: rgba(140, 140, 255, 0.02);
background-color: rgba(255, 255, 255, 0.02);
}
/* Blockquotes */
blockquote {
border-left-color: var(--sl-color-accent);
background-color: rgba(94, 94, 208, 0.05);
background-color: rgba(59, 130, 246, 0.05);
border-radius: 0 8px 8px 0;
padding: 1rem 1.25rem;
}
@ -423,19 +440,19 @@ blockquote {
/* Note aside */
.starlight-aside--note {
background-color: rgba(94, 94, 208, 0.08);
background-color: rgba(59, 130, 246, 0.08);
}
.starlight-aside--note .starlight-aside__title {
color: #5C5CCC;
color: #2563eb;
}
:root[data-theme='dark'] .starlight-aside--note {
background-color: rgba(140, 140, 255, 0.12);
background-color: rgba(59, 130, 246, 0.12);
}
:root[data-theme='dark'] .starlight-aside--note .starlight-aside__title {
color: #8C8CFF;
color: #3b82f6;
}
/* Caution aside */
@ -512,7 +529,7 @@ blockquote {
ROADMAP STYLES
============================================ */
.roadmap-container {
--color-planned: #6366f1;
--color-planned: #3b82f6;
--color-in-progress: #10b981;
--color-exploring: #f59e0b;
--color-bg-card: rgba(255, 255, 255, 0.03);
@ -663,8 +680,8 @@ blockquote {
}
.roadmap-badge.planned {
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.roadmap-badge.exploring {
@ -735,7 +752,7 @@ blockquote {
.roadmap-future-card {
padding: 1.5rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(245, 158, 11, 0.05));
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(212, 168, 83, 0.05));
border: 1px solid var(--color-border);
transition: transform 0.2s ease;
display: flex;