Compare commits

..

5 Commits

Author SHA1 Message Date
Brian d0f2518d5f
Merge 7302f350b5 into 3ba51e1bac 2026-04-09 14:01:32 +00:00
Brian Madison 7302f350b5 fix(installer): resolve custom modules from disk cache on quick update
When the resolution cache is empty (fresh CLI process, e.g. quick
update), findModuleSourceByCode only matched plugin.name against the
module code. This failed for modules like "sam" and "dw" where the
code comes from module.yaml inside a setup/standalone skill, not from
the plugin name in marketplace.json.

Now runs the PluginResolver on cached repos when the direct name match
fails, finding the correct module source and re-populating the cache
for the install pipeline.
2026-04-09 09:01:28 -05:00
Brian Madison a7f469690a fix(installer): manifest-generator overwrites custom module version/repoUrl
ManifestGenerator rebuilds the entire manifest via getModuleVersionInfo
for every module. For custom modules, this returned null for version and
repoUrl because it only checked _readMarketplaceVersion (which searches
for marketplace.json on disk) and hardcoded repoUrl to null. Now checks
the resolution cache first to get the correct version and repo URL.
2026-04-09 08:58:31 -05:00
Brian Madison ffe84a9f17 fix(installer): pass version and repoUrl to manifest for custom plugins
installFromResolution was passing empty strings for version and repoUrl,
which the manifest stores as null. Now threads the repo URL from ui.js
through resolvePlugin into each ResolvedModule, and passes the plugin
version and URL to the manifest correctly.
2026-04-09 08:51:57 -05:00
Brian Madison 489067fdda fix(installer): address PR review findings for plugin resolver
- Guard against path traversal in plugin-resolver.js: skill paths from
  unverified marketplace.json are now constrained to the repo root using
  path.resolve() + startsWith check
- Skip npm install during browsing phase: cloneRepo() accepts
  skipInstall option, used in ui.js before user confirms selection,
  preventing arbitrary lifecycle script execution from untrusted repos
- Add createModuleDirectories() call to installFromResolution() so
  modules with declarative directory config are fully set up
- Fix ESLint: use replaceAll instead of replace with global regex
2026-04-09 08:44:17 -05:00
5 changed files with 57 additions and 18 deletions

View File

@ -835,14 +835,15 @@ class Manifest {
// Check if this is a custom module (from user-provided URL)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const resolved = customMgr.getResolution(moduleName);
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
if (customSource || resolved) {
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
return {
version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
repoUrl: resolved?.repoUrl || null,
};
}

View File

@ -104,6 +104,7 @@ class CustomModuleManager {
* @param {string} repoUrl - GitHub repository URL
* @param {Object} [options] - Clone options
* @param {boolean} [options.silent] - Suppress spinner output
* @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
* @returns {string} Path to the cloned repository
*/
async cloneRepo(repoUrl, options = {}) {
@ -159,9 +160,9 @@ class CustomModuleManager {
}
}
// Install dependencies if package.json exists
// Install dependencies if package.json exists (skip during browsing/analysis)
const packageJsonPath = path.join(repoCacheDir, 'package.json');
if (await fs.pathExists(packageJsonPath)) {
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
try {
@ -187,15 +188,17 @@ class CustomModuleManager {
* Results are cached in _resolutionCache keyed by module code.
* @param {string} repoPath - Absolute path to the cloned repository
* @param {Object} plugin - Raw plugin object from marketplace.json
* @param {string} [repoUrl] - Original GitHub URL for manifest tracking
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
*/
async resolvePlugin(repoPath, plugin) {
async resolvePlugin(repoPath, plugin, repoUrl) {
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const resolved = await resolver.resolve(repoPath, plugin);
// Cache each resolved module by its code for lookup during install
// Stamp repo URL onto each resolved module for manifest tracking
for (const mod of resolved) {
if (repoUrl) mod.repoUrl = repoUrl;
CustomModuleManager._resolutionCache.set(mod.code, mod);
}
@ -288,6 +291,8 @@ class CustomModuleManager {
// Search through all custom repo caches
try {
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
for (const ownerEntry of owners) {
if (!ownerEntry.isDirectory()) continue;
@ -303,14 +308,37 @@ class CustomModuleManager {
try {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
for (const plugin of data.plugins || []) {
// Direct name match (legacy behavior)
if (plugin.name === moduleCode) {
// Found the module - find its source
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
const moduleYaml = path.join(sourcePath, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
return sourcePath;
}
}
// Resolve plugin to check if any module.yaml code matches
if (plugin.skills && plugin.skills.length > 0) {
try {
const resolved = await resolver.resolve(repoPath, plugin);
for (const mod of resolved) {
if (mod.code === moduleCode) {
// Derive repo URL from cache path for manifest tracking
const repoUrl = `https://github.com/${ownerEntry.name}/${repoEntry.name}`;
mod.repoUrl = repoUrl;
CustomModuleManager._resolutionCache.set(mod.code, mod);
if (mod.moduleYamlPath) {
return path.dirname(mod.moduleYamlPath);
}
if (mod.skillPaths && mod.skillPaths.length > 0) {
return path.dirname(mod.skillPaths[0]);
}
}
}
} catch {
// Skip unresolvable plugins
}
}
}
} catch {
// Skip malformed marketplace.json

View File

@ -326,15 +326,20 @@ class OfficialModules {
if (fileTrackingCallback) fileTrackingCallback(helpTarget);
}
// Create directories declared in module.yaml (strategies 1-4 may have these)
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(resolved.code, bmadDir, options);
}
// Update manifest
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
await manifestObj.addModule(bmadDir, resolved.code, {
version: resolved.version || '',
source: `custom:${resolved.pluginName}`,
npmPackage: '',
repoUrl: '',
version: resolved.version || null,
source: 'custom',
npmPackage: null,
repoUrl: resolved.repoUrl || null,
});
return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };

View File

@ -33,11 +33,16 @@ class PluginResolver {
return [];
}
// Resolve skill paths to absolute and filter out non-existent
// Resolve skill paths to absolute, constrain to repo root, filter non-existent
const repoRoot = path.resolve(repoPath);
const skillPaths = [];
for (const rel of skillRelPaths) {
const normalized = rel.replace(/^\.\//, '');
const abs = path.join(repoPath, normalized);
const abs = path.resolve(repoPath, normalized);
// Guard against path traversal (.. segments, absolute paths in marketplace.json)
if (!abs.startsWith(repoRoot + path.sep) && abs !== repoRoot) {
continue;
}
if (await fs.pathExists(abs)) {
skillPaths.push(abs);
}
@ -384,7 +389,7 @@ class PluginResolver {
_escapeCSVField(value) {
if (!value) return '';
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`;
return `"${value.replaceAll('"', '""')}"`;
}
return value;
}

View File

@ -863,11 +863,11 @@ class UI {
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);
// Clone the repo so we can resolve plugin structures
// Clone the repo so we can resolve plugin structures (skip npm install until user confirms)
s.start('Cloning repository...');
let repoPath;
try {
repoPath = await customMgr.cloneRepo(url.trim());
repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true });
s.stop('Repository cloned');
} catch (cloneError) {
s.error('Failed to clone repository');
@ -881,7 +881,7 @@ class UI {
const allResolved = [];
for (const plugin of plugins) {
try {
const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin);
const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim());
if (resolved.length > 0) {
allResolved.push(...resolved);
} else {