feat(installer): universal source support for custom modules
Replace GitHub-only custom module installation with support for any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths. - Add parseSource() universal input parser (local paths, SSH, HTTPS with deep path/subdir extraction for GitHub, GitLab, Gitea) - Add resolveSource() coordinator: parse -> clone if URL -> detect discovery vs direct mode (marketplace.json present or not) - Clone-first approach eliminates host-specific raw URL fetching - 3-level cache structure (host/owner/repo) with .bmad-source.json metadata for URL reconstruction - Local paths install directly without caching; localPath persisted in manifest for quick-update source lookup - Direct mode scans target directory for SKILL.md when no marketplace.json - Fix version display bug where walk-up found parent repo marketplace.json and reported wrong version for custom modules
This commit is contained in:
parent
7302f350b5
commit
eec011ae9a
|
|
@ -591,11 +591,16 @@ class Installer {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get display name from source module.yaml; version from marketplace.json
|
// Get display name from source module.yaml; version from resolution cache or marketplace.json
|
||||||
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
||||||
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
||||||
const displayName = moduleInfo?.name || moduleName;
|
const displayName = moduleInfo?.name || moduleName;
|
||||||
const version = sourcePath ? await this._getMarketplaceVersion(sourcePath) : '';
|
|
||||||
|
// Prefer version from resolution cache (accurate for custom/local modules),
|
||||||
|
// fall back to marketplace.json walk-up for official modules
|
||||||
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
|
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
|
||||||
|
const version = cachedResolution?.version || (sourcePath ? await this._getMarketplaceVersion(sourcePath) : '');
|
||||||
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1189,7 +1194,7 @@ class Installer {
|
||||||
const customMgr = new CustomModuleManager();
|
const customMgr = new CustomModuleManager();
|
||||||
for (const moduleId of installedModules) {
|
for (const moduleId of installedModules) {
|
||||||
if (!availableModules.some((m) => m.id === moduleId)) {
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
|
||||||
if (customSource) {
|
if (customSource) {
|
||||||
availableModules.push({
|
availableModules.push({
|
||||||
id: moduleId,
|
id: moduleId,
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ class ManifestGenerator {
|
||||||
// Get existing install date if available
|
// Get existing install date if available
|
||||||
const existing = existingModulesMap.get(moduleName);
|
const existing = existingModulesMap.get(moduleName);
|
||||||
|
|
||||||
updatedModules.push({
|
const moduleEntry = {
|
||||||
name: moduleName,
|
name: moduleName,
|
||||||
version: versionInfo.version,
|
version: versionInfo.version,
|
||||||
installDate: existing?.installDate || new Date().toISOString(),
|
installDate: existing?.installDate || new Date().toISOString(),
|
||||||
|
|
@ -420,7 +420,9 @@ class ManifestGenerator {
|
||||||
source: versionInfo.source,
|
source: versionInfo.source,
|
||||||
npmPackage: versionInfo.npmPackage,
|
npmPackage: versionInfo.npmPackage,
|
||||||
repoUrl: versionInfo.repoUrl,
|
repoUrl: versionInfo.repoUrl,
|
||||||
});
|
};
|
||||||
|
if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
|
||||||
|
updatedModules.push(moduleEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
|
|
||||||
|
|
@ -181,10 +181,10 @@ class Manifest {
|
||||||
|
|
||||||
// Handle adding a new module with version info
|
// Handle adding a new module with version info
|
||||||
if (updates.addModule) {
|
if (updates.addModule) {
|
||||||
const { name, version, source, npmPackage, repoUrl } = updates.addModule;
|
const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
|
||||||
const existing = manifest.modules.find((m) => m.name === name);
|
const existing = manifest.modules.find((m) => m.name === name);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
manifest.modules.push({
|
const entry = {
|
||||||
name,
|
name,
|
||||||
version: version || null,
|
version: version || null,
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
|
|
@ -192,7 +192,9 @@ class Manifest {
|
||||||
source: source || 'external',
|
source: source || 'external',
|
||||||
npmPackage: npmPackage || null,
|
npmPackage: npmPackage || null,
|
||||||
repoUrl: repoUrl || null,
|
repoUrl: repoUrl || null,
|
||||||
});
|
};
|
||||||
|
if (localPath) entry.localPath = localPath;
|
||||||
|
manifest.modules.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,7 +282,7 @@ class Manifest {
|
||||||
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
// Module doesn't exist, add it
|
// Module doesn't exist, add it
|
||||||
manifest.modules.push({
|
const entry = {
|
||||||
name: moduleName,
|
name: moduleName,
|
||||||
version: options.version || null,
|
version: options.version || null,
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
|
|
@ -288,7 +290,9 @@ class Manifest {
|
||||||
source: options.source || 'unknown',
|
source: options.source || 'unknown',
|
||||||
npmPackage: options.npmPackage || null,
|
npmPackage: options.npmPackage || null,
|
||||||
repoUrl: options.repoUrl || null,
|
repoUrl: options.repoUrl || null,
|
||||||
});
|
};
|
||||||
|
if (options.localPath) entry.localPath = options.localPath;
|
||||||
|
manifest.modules.push(entry);
|
||||||
} else {
|
} else {
|
||||||
// Module exists, update its version info
|
// Module exists, update its version info
|
||||||
const existing = manifest.modules[existingIndex];
|
const existing = manifest.modules[existingIndex];
|
||||||
|
|
@ -298,6 +302,7 @@ class Manifest {
|
||||||
source: options.source || existing.source,
|
source: options.source || existing.source,
|
||||||
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
||||||
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
||||||
|
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
|
||||||
lastUpdated: new Date().toISOString(),
|
lastUpdated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -832,11 +837,11 @@ class Manifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a custom module (from user-provided URL)
|
// Check if this is a custom module (from user-provided URL or local path)
|
||||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
const customMgr = new CustomModuleManager();
|
const customMgr = new CustomModuleManager();
|
||||||
const resolved = customMgr.getResolution(moduleName);
|
const resolved = customMgr.getResolution(moduleName);
|
||||||
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
|
||||||
if (customSource || resolved) {
|
if (customSource || resolved) {
|
||||||
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
|
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
|
||||||
return {
|
return {
|
||||||
|
|
@ -844,6 +849,7 @@ class Manifest {
|
||||||
source: 'custom',
|
source: 'custom',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: resolved?.repoUrl || null,
|
repoUrl: resolved?.repoUrl || null,
|
||||||
|
localPath: resolved?.localPath || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,90 +3,206 @@ const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
const { RegistryClient } = require('./registry-client');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages custom modules installed from user-provided GitHub URLs.
|
* Manages custom modules installed from user-provided sources.
|
||||||
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
|
||||||
|
* Validates input, clones repos, reads .claude-plugin/marketplace.json, resolves plugins.
|
||||||
*/
|
*/
|
||||||
class CustomModuleManager {
|
class CustomModuleManager {
|
||||||
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
|
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
|
||||||
static _resolutionCache = new Map();
|
static _resolutionCache = new Map();
|
||||||
|
|
||||||
constructor() {
|
// ─── Source Parsing ───────────────────────────────────────────────────────
|
||||||
this._client = new RegistryClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── URL Validation ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Parse a user-provided source input into a structured descriptor.
|
||||||
|
* Accepts local file paths, HTTPS Git URLs, and SSH Git URLs.
|
||||||
|
* For HTTPS URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
|
||||||
|
*
|
||||||
|
* @param {string} input - URL or local file path
|
||||||
|
* @returns {Object} Parsed source descriptor:
|
||||||
|
* { type: 'url'|'local', cloneUrl, subdir, localPath, cacheKey, displayName, isValid, error }
|
||||||
|
*/
|
||||||
|
parseSource(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return { type: null, cloneUrl: null, subdir: null, localPath: null, cacheKey: null, displayName: null, isValid: false, error: 'Source is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { type: null, cloneUrl: null, subdir: null, localPath: null, cacheKey: null, displayName: null, isValid: false, error: 'Source is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local path detection: starts with /, ./, ../, or ~
|
||||||
|
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
|
||||||
|
return this._parseLocalPath(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH URL: git@host:owner/repo.git
|
||||||
|
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
const [, host, owner, repo] = sshMatch;
|
||||||
|
return {
|
||||||
|
type: 'url',
|
||||||
|
cloneUrl: trimmed,
|
||||||
|
subdir: null,
|
||||||
|
localPath: null,
|
||||||
|
cacheKey: `${host}/${owner}/${repo}`,
|
||||||
|
displayName: `${owner}/${repo}`,
|
||||||
|
isValid: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git]
|
||||||
|
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
|
||||||
|
if (httpsMatch) {
|
||||||
|
const [, host, owner, repo, remainder] = httpsMatch;
|
||||||
|
const cloneUrl = `https://${host}/${owner}/${repo}`;
|
||||||
|
let subdir = null;
|
||||||
|
|
||||||
|
if (remainder) {
|
||||||
|
// Extract subdir from deep path patterns used by various Git hosts
|
||||||
|
const deepPathPatterns = [
|
||||||
|
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
|
||||||
|
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
|
||||||
|
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of deepPathPatterns) {
|
||||||
|
const match = remainder.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
subdir = match[1].replace(/\/$/, ''); // strip trailing slash
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'url',
|
||||||
|
cloneUrl,
|
||||||
|
subdir,
|
||||||
|
localPath: null,
|
||||||
|
cacheKey: `${host}/${owner}/${repo}`,
|
||||||
|
displayName: `${owner}/${repo}`,
|
||||||
|
isValid: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: null, cloneUrl: null, subdir: null, localPath: null, cacheKey: null, displayName: null, isValid: false, error: 'Not a valid Git URL or local path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a local filesystem path.
|
||||||
|
* @param {string} rawPath - Path string (may contain ~ for home)
|
||||||
|
* @returns {Object} Parsed source descriptor
|
||||||
|
*/
|
||||||
|
_parseLocalPath(rawPath) {
|
||||||
|
const expanded = rawPath.startsWith('~') ? path.join(os.homedir(), rawPath.slice(1)) : rawPath;
|
||||||
|
const resolved = path.resolve(expanded);
|
||||||
|
|
||||||
|
if (!fs.pathExistsSync(resolved)) {
|
||||||
|
return { type: 'local', cloneUrl: null, subdir: null, localPath: resolved, cacheKey: null, displayName: path.basename(resolved), isValid: false, error: `Path does not exist: ${resolved}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'local',
|
||||||
|
cloneUrl: null,
|
||||||
|
subdir: null,
|
||||||
|
localPath: resolved,
|
||||||
|
cacheKey: null,
|
||||||
|
displayName: path.basename(resolved),
|
||||||
|
isValid: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use parseSource() instead. Kept for backward compatibility.
|
||||||
* Parse and validate a GitHub repository URL.
|
* Parse and validate a GitHub repository URL.
|
||||||
* Supports HTTPS and SSH formats.
|
|
||||||
* @param {string} url - GitHub URL to validate
|
* @param {string} url - GitHub URL to validate
|
||||||
* @returns {Object} { owner, repo, isValid, error }
|
* @returns {Object} { owner, repo, isValid, error }
|
||||||
*/
|
*/
|
||||||
validateGitHubUrl(url) {
|
validateGitHubUrl(url) {
|
||||||
if (!url || typeof url !== 'string') {
|
const parsed = this.parseSource(url);
|
||||||
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
if (!parsed.isValid) {
|
||||||
|
return { owner: null, repo: null, isValid: false, error: parsed.error };
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'local') {
|
||||||
const trimmed = url.trim();
|
|
||||||
|
|
||||||
// HTTPS format: https://github.com/owner/repo[.git]
|
|
||||||
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
||||||
if (httpsMatch) {
|
|
||||||
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSH format: git@github.com:owner/repo.git
|
|
||||||
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
||||||
if (sshMatch) {
|
|
||||||
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
||||||
}
|
}
|
||||||
|
// Extract owner/repo from cacheKey (host/owner/repo)
|
||||||
|
const parts = parsed.cacheKey.split('/');
|
||||||
|
return { owner: parts[1] || null, repo: parts[2] || null, isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marketplace JSON ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read .claude-plugin/marketplace.json from a local directory.
|
||||||
|
* @param {string} dirPath - Directory to read from
|
||||||
|
* @returns {Object|null} Parsed marketplace.json or null if not found
|
||||||
|
*/
|
||||||
|
async readMarketplaceJsonFromDisk(dirPath) {
|
||||||
|
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
||||||
|
if (!(await fs.pathExists(marketplacePath))) return null;
|
||||||
|
return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Discovery ────────────────────────────────────────────────────────────
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
* Discover modules from pre-read marketplace.json data.
|
||||||
* @param {string} repoUrl - GitHub repository URL
|
* @param {Object} marketplaceData - Parsed marketplace.json content
|
||||||
* @returns {Object} Parsed marketplace.json content
|
* @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
|
||||||
*/
|
|
||||||
async fetchMarketplaceJson(repoUrl) {
|
|
||||||
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
|
||||||
if (!isValid) throw new Error(error);
|
|
||||||
|
|
||||||
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this._client.fetchJson(rawUrl);
|
|
||||||
} catch (error_) {
|
|
||||||
if (error_.message.includes('404')) {
|
|
||||||
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
|
||||||
}
|
|
||||||
if (error_.message.includes('403')) {
|
|
||||||
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover modules from a GitHub repository's marketplace.json.
|
|
||||||
* @param {string} repoUrl - GitHub repository URL
|
|
||||||
* @returns {Array<Object>} Normalized plugin list
|
* @returns {Array<Object>} Normalized plugin list
|
||||||
*/
|
*/
|
||||||
async discoverModules(repoUrl) {
|
async discoverModules(marketplaceData, sourceUrl) {
|
||||||
const data = await this.fetchMarketplaceJson(repoUrl);
|
const plugins = marketplaceData?.plugins;
|
||||||
const plugins = data?.plugins;
|
|
||||||
|
|
||||||
if (!Array.isArray(plugins) || plugins.length === 0) {
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
||||||
throw new Error('marketplace.json contains no plugins');
|
throw new Error('marketplace.json contains no plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, sourceUrl, marketplaceData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Resolution ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level coordinator: parse input, clone if URL, determine discovery vs direct mode.
|
||||||
|
* @param {string} input - URL or local path
|
||||||
|
* @param {Object} [options] - Options passed to cloneRepo
|
||||||
|
* @returns {Object} { parsed, rootDir, repoPath, sourceUrl, marketplace, mode: 'discovery'|'direct' }
|
||||||
|
*/
|
||||||
|
async resolveSource(input, options = {}) {
|
||||||
|
const parsed = this.parseSource(input);
|
||||||
|
if (!parsed.isValid) throw new Error(parsed.error);
|
||||||
|
|
||||||
|
let rootDir;
|
||||||
|
let repoPath;
|
||||||
|
let sourceUrl;
|
||||||
|
|
||||||
|
if (parsed.type === 'local') {
|
||||||
|
rootDir = parsed.localPath;
|
||||||
|
repoPath = null;
|
||||||
|
sourceUrl = null;
|
||||||
|
} else {
|
||||||
|
repoPath = await this.cloneRepo(input, options);
|
||||||
|
sourceUrl = parsed.cloneUrl;
|
||||||
|
rootDir = parsed.subdir ? path.join(repoPath, parsed.subdir) : repoPath;
|
||||||
|
|
||||||
|
if (parsed.subdir && !(await fs.pathExists(rootDir))) {
|
||||||
|
throw new Error(`Subdirectory '${parsed.subdir}' not found in cloned repository`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplace = await this.readMarketplaceJsonFromDisk(rootDir);
|
||||||
|
const mode = marketplace ? 'discovery' : 'direct';
|
||||||
|
|
||||||
|
return { parsed, rootDir, repoPath, sourceUrl, marketplace, mode };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Clone ────────────────────────────────────────────────────────────────
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -101,21 +217,24 @@ class CustomModuleManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a custom module repository to cache.
|
* Clone a custom module repository to cache.
|
||||||
* @param {string} repoUrl - GitHub repository URL
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
|
||||||
|
* @param {string} sourceInput - Git URL (HTTPS or SSH)
|
||||||
* @param {Object} [options] - Clone options
|
* @param {Object} [options] - Clone options
|
||||||
* @param {boolean} [options.silent] - Suppress spinner output
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
* @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
|
* @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
|
||||||
* @returns {string} Path to the cloned repository
|
* @returns {string} Path to the cloned repository
|
||||||
*/
|
*/
|
||||||
async cloneRepo(repoUrl, options = {}) {
|
async cloneRepo(sourceInput, options = {}) {
|
||||||
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
const parsed = this.parseSource(sourceInput);
|
||||||
if (!isValid) throw new Error(error);
|
if (!parsed.isValid) throw new Error(parsed.error);
|
||||||
|
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
|
||||||
|
|
||||||
const cacheDir = this.getCacheDir();
|
const cacheDir = this.getCacheDir();
|
||||||
const repoCacheDir = path.join(cacheDir, owner, repo);
|
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
|
||||||
const silent = options.silent || false;
|
const silent = options.silent || false;
|
||||||
|
const displayName = parsed.displayName;
|
||||||
|
|
||||||
await fs.ensureDir(path.join(cacheDir, owner));
|
await fs.ensureDir(path.dirname(repoCacheDir));
|
||||||
|
|
||||||
const createSpinner = async () => {
|
const createSpinner = async () => {
|
||||||
if (silent) {
|
if (silent) {
|
||||||
|
|
@ -127,7 +246,7 @@ class CustomModuleManager {
|
||||||
if (await fs.pathExists(repoCacheDir)) {
|
if (await fs.pathExists(repoCacheDir)) {
|
||||||
// Update existing clone
|
// Update existing clone
|
||||||
const fetchSpinner = await createSpinner();
|
const fetchSpinner = await createSpinner();
|
||||||
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
fetchSpinner.start(`Updating ${displayName}...`);
|
||||||
try {
|
try {
|
||||||
execSync('git fetch origin --depth 1', {
|
execSync('git fetch origin --depth 1', {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
|
|
@ -138,42 +257,51 @@ class CustomModuleManager {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
fetchSpinner.stop(`Updated ${displayName}`);
|
||||||
} catch {
|
} catch {
|
||||||
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
|
||||||
await fs.remove(repoCacheDir);
|
await fs.remove(repoCacheDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.pathExists(repoCacheDir))) {
|
if (!(await fs.pathExists(repoCacheDir))) {
|
||||||
const fetchSpinner = await createSpinner();
|
const fetchSpinner = await createSpinner();
|
||||||
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
fetchSpinner.start(`Cloning ${displayName}...`);
|
||||||
try {
|
try {
|
||||||
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
});
|
});
|
||||||
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
fetchSpinner.stop(`Cloned ${displayName}`);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
fetchSpinner.error(`Failed to clone ${displayName}`);
|
||||||
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write source metadata for later URL reconstruction
|
||||||
|
const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
|
||||||
|
await fs.writeJson(metadataPath, {
|
||||||
|
cloneUrl: parsed.cloneUrl,
|
||||||
|
cacheKey: parsed.cacheKey,
|
||||||
|
displayName: parsed.displayName,
|
||||||
|
clonedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
// Install dependencies if package.json exists (skip during browsing/analysis)
|
// Install dependencies if package.json exists (skip during browsing/analysis)
|
||||||
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
||||||
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
|
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
|
||||||
const installSpinner = await createSpinner();
|
const installSpinner = await createSpinner();
|
||||||
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
installSpinner.start(`Installing dependencies for ${displayName}...`);
|
||||||
try {
|
try {
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
installSpinner.stop(`Installed dependencies for ${displayName}`);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
installSpinner.error(`Failed to install dependencies for ${displayName}`);
|
||||||
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -186,19 +314,21 @@ class CustomModuleManager {
|
||||||
/**
|
/**
|
||||||
* Resolve a plugin to determine installation strategy and module registration files.
|
* Resolve a plugin to determine installation strategy and module registration files.
|
||||||
* Results are cached in _resolutionCache keyed by module code.
|
* Results are cached in _resolutionCache keyed by module code.
|
||||||
* @param {string} repoPath - Absolute path to the cloned repository
|
* @param {string} repoPath - Absolute path to the cloned repository or local directory
|
||||||
* @param {Object} plugin - Raw plugin object from marketplace.json
|
* @param {Object} plugin - Raw plugin object from marketplace.json
|
||||||
* @param {string} [repoUrl] - Original GitHub URL for manifest tracking
|
* @param {string} [sourceUrl] - Original URL for manifest tracking (null for local)
|
||||||
|
* @param {string} [localPath] - Local source path for manifest tracking (null for URLs)
|
||||||
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
|
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
|
||||||
*/
|
*/
|
||||||
async resolvePlugin(repoPath, plugin, repoUrl) {
|
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
|
||||||
const { PluginResolver } = require('./plugin-resolver');
|
const { PluginResolver } = require('./plugin-resolver');
|
||||||
const resolver = new PluginResolver();
|
const resolver = new PluginResolver();
|
||||||
const resolved = await resolver.resolve(repoPath, plugin);
|
const resolved = await resolver.resolve(repoPath, plugin);
|
||||||
|
|
||||||
// Stamp repo URL onto each resolved module for manifest tracking
|
// Stamp source info onto each resolved module for manifest tracking
|
||||||
for (const mod of resolved) {
|
for (const mod of resolved) {
|
||||||
if (repoUrl) mod.repoUrl = repoUrl;
|
if (sourceUrl) mod.repoUrl = sourceUrl;
|
||||||
|
if (localPath) mod.localPath = localPath;
|
||||||
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,20 +347,27 @@ class CustomModuleManager {
|
||||||
// ─── Source Finding ───────────────────────────────────────────────────────
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the module source path within a cloned custom repo.
|
* Find the module source path within a cached or local source directory.
|
||||||
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
* @param {string} sourceInput - Git URL or local path (used to locate cached clone)
|
||||||
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
||||||
* @returns {string|null} Path to directory containing module.yaml
|
* @returns {string|null} Path to directory containing module.yaml
|
||||||
*/
|
*/
|
||||||
async findModuleSource(repoUrl, pluginSource) {
|
async findModuleSource(sourceInput, pluginSource) {
|
||||||
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
const parsed = this.parseSource(sourceInput);
|
||||||
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
if (!parsed.isValid) return null;
|
||||||
|
|
||||||
if (!(await fs.pathExists(repoCacheDir))) return null;
|
let baseDir;
|
||||||
|
if (parsed.type === 'local') {
|
||||||
|
baseDir = parsed.localPath;
|
||||||
|
} else {
|
||||||
|
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(baseDir))) return null;
|
||||||
|
|
||||||
// Try plugin source path first (e.g., "./src/pro-skills")
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
||||||
if (pluginSource) {
|
if (pluginSource) {
|
||||||
const sourcePath = path.join(repoCacheDir, pluginSource);
|
const sourcePath = path.join(baseDir, pluginSource);
|
||||||
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
if (await fs.pathExists(moduleYaml)) {
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
return sourcePath;
|
return sourcePath;
|
||||||
|
|
@ -239,11 +376,11 @@ class CustomModuleManager {
|
||||||
|
|
||||||
// Fallback: search skills/ and src/ directories
|
// Fallback: search skills/ and src/ directories
|
||||||
for (const dir of ['skills', 'src']) {
|
for (const dir of ['skills', 'src']) {
|
||||||
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
const rootCandidate = path.join(baseDir, dir, 'module.yaml');
|
||||||
if (await fs.pathExists(rootCandidate)) {
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
return path.dirname(rootCandidate);
|
return path.dirname(rootCandidate);
|
||||||
}
|
}
|
||||||
const dirPath = path.join(repoCacheDir, dir);
|
const dirPath = path.join(baseDir, dir);
|
||||||
if (await fs.pathExists(dirPath)) {
|
if (await fs.pathExists(dirPath)) {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -257,10 +394,10 @@ class CustomModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check repo root
|
// Check base directory root
|
||||||
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
const rootCandidate = path.join(baseDir, 'module.yaml');
|
||||||
if (await fs.pathExists(rootCandidate)) {
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
return repoCacheDir;
|
return baseDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -268,6 +405,8 @@ class CustomModuleManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find module source by module code, searching the custom cache.
|
* Find module source by module code, searching the custom cache.
|
||||||
|
* Handles both new 3-level cache structure (host/owner/repo) and
|
||||||
|
* legacy 2-level structure (owner/repo).
|
||||||
* @param {string} moduleCode - Module code to search for
|
* @param {string} moduleCode - Module code to search for
|
||||||
* @param {Object} [options] - Options
|
* @param {Object} [options] - Options
|
||||||
* @returns {string|null} Path to the module source or null
|
* @returns {string|null} Path to the module source or null
|
||||||
|
|
@ -289,22 +428,17 @@ class CustomModuleManager {
|
||||||
const cacheDir = this.getCacheDir();
|
const cacheDir = this.getCacheDir();
|
||||||
if (!(await fs.pathExists(cacheDir))) return null;
|
if (!(await fs.pathExists(cacheDir))) return null;
|
||||||
|
|
||||||
// Search through all custom repo caches
|
// Search through all cached repo roots
|
||||||
try {
|
try {
|
||||||
const { PluginResolver } = require('./plugin-resolver');
|
const { PluginResolver } = require('./plugin-resolver');
|
||||||
const resolver = new PluginResolver();
|
const resolver = new PluginResolver();
|
||||||
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
const repoRoots = await this._findCacheRepoRoots(cacheDir);
|
||||||
for (const ownerEntry of owners) {
|
|
||||||
if (!ownerEntry.isDirectory()) continue;
|
|
||||||
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
|
||||||
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
|
||||||
for (const repoEntry of repos) {
|
|
||||||
if (!repoEntry.isDirectory()) continue;
|
|
||||||
const repoPath = path.join(ownerPath, repoEntry.name);
|
|
||||||
|
|
||||||
|
for (const { repoPath, metadata } of repoRoots) {
|
||||||
// Check marketplace.json for matching module code
|
// Check marketplace.json for matching module code
|
||||||
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
if (await fs.pathExists(marketplacePath)) {
|
if (!(await fs.pathExists(marketplacePath))) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
for (const plugin of data.plugins || []) {
|
for (const plugin of data.plugins || []) {
|
||||||
|
|
@ -320,12 +454,11 @@ class CustomModuleManager {
|
||||||
// Resolve plugin to check if any module.yaml code matches
|
// Resolve plugin to check if any module.yaml code matches
|
||||||
if (plugin.skills && plugin.skills.length > 0) {
|
if (plugin.skills && plugin.skills.length > 0) {
|
||||||
try {
|
try {
|
||||||
const resolved = await resolver.resolve(repoPath, plugin);
|
const resolvedMods = await resolver.resolve(repoPath, plugin);
|
||||||
for (const mod of resolved) {
|
for (const mod of resolvedMods) {
|
||||||
if (mod.code === moduleCode) {
|
if (mod.code === moduleCode) {
|
||||||
// Derive repo URL from cache path for manifest tracking
|
// Use metadata for URL reconstruction instead of deriving from path
|
||||||
const repoUrl = `https://github.com/${ownerEntry.name}/${repoEntry.name}`;
|
mod.repoUrl = metadata?.cloneUrl || null;
|
||||||
mod.repoUrl = repoUrl;
|
|
||||||
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
||||||
if (mod.moduleYamlPath) {
|
if (mod.moduleYamlPath) {
|
||||||
return path.dirname(mod.moduleYamlPath);
|
return path.dirname(mod.moduleYamlPath);
|
||||||
|
|
@ -344,13 +477,91 @@ class CustomModuleManager {
|
||||||
// Skip malformed marketplace.json
|
// Skip malformed marketplace.json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Cache doesn't exist or is inaccessible
|
// Cache doesn't exist or is inaccessible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: check manifest for localPath (local-source modules not in cache)
|
||||||
|
return this._findLocalSourceFromManifest(moduleCode, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the installation manifest for a localPath entry for this module.
|
||||||
|
* Used as fallback when the module was installed from a local source (no cache entry).
|
||||||
|
* Returns the path only if it still exists on disk; never removes installed files.
|
||||||
|
* @param {string} moduleCode - Module code to search for
|
||||||
|
* @param {Object} [options] - Options (must include bmadDir or will search common locations)
|
||||||
|
* @returns {string|null} Path to the local module source or null
|
||||||
|
*/
|
||||||
|
async _findLocalSourceFromManifest(moduleCode, options = {}) {
|
||||||
|
try {
|
||||||
|
const { Manifest } = require('../core/manifest');
|
||||||
|
const manifestObj = new Manifest();
|
||||||
|
|
||||||
|
// Try to find bmadDir from options or common locations
|
||||||
|
const bmadDir = options.bmadDir;
|
||||||
|
if (!bmadDir) return null;
|
||||||
|
|
||||||
|
const manifestData = await manifestObj.read(bmadDir);
|
||||||
|
if (!manifestData?.modulesDetailed) return null;
|
||||||
|
|
||||||
|
const moduleEntry = manifestData.modulesDetailed.find((m) => m.name === moduleCode);
|
||||||
|
if (!moduleEntry?.localPath) return null;
|
||||||
|
|
||||||
|
// Only return the path if it still exists (source not removed)
|
||||||
|
if (await fs.pathExists(moduleEntry.localPath)) {
|
||||||
|
return moduleEntry.localPath;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively find repo root directories within the cache.
|
||||||
|
* A repo root is identified by containing .bmad-source.json (new) or .claude-plugin/ (legacy).
|
||||||
|
* Handles both 3-level (host/owner/repo) and legacy 2-level (owner/repo) cache layouts.
|
||||||
|
* @param {string} dir - Directory to search
|
||||||
|
* @param {number} [depth=0] - Current recursion depth
|
||||||
|
* @param {number} [maxDepth=4] - Maximum recursion depth
|
||||||
|
* @returns {Promise<Array<{repoPath: string, metadata: Object|null}>>}
|
||||||
|
*/
|
||||||
|
async _findCacheRepoRoots(dir, depth = 0, maxDepth = 4) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Check if this directory is a repo root
|
||||||
|
const metadataPath = path.join(dir, '.bmad-source.json');
|
||||||
|
const claudePluginDir = path.join(dir, '.claude-plugin');
|
||||||
|
|
||||||
|
if (await fs.pathExists(metadataPath)) {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||||
|
results.push({ repoPath: dir, metadata });
|
||||||
|
} catch {
|
||||||
|
results.push({ repoPath: dir, metadata: null });
|
||||||
|
}
|
||||||
|
return results; // Don't recurse into repo contents
|
||||||
|
}
|
||||||
|
if (await fs.pathExists(claudePluginDir)) {
|
||||||
|
results.push({ repoPath: dir, metadata: null });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subdirectories
|
||||||
|
if (depth >= maxDepth) return results;
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||||
|
const subResults = await this._findCacheRepoRoots(path.join(dir, entry.name), depth + 1, maxDepth);
|
||||||
|
results.push(...subResults);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory not readable
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normalization ────────────────────────────────────────────────────────
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
@ -358,11 +569,11 @@ class CustomModuleManager {
|
||||||
/**
|
/**
|
||||||
* Normalize a plugin from marketplace.json to a consistent shape.
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
||||||
* @param {Object} plugin - Plugin object from marketplace.json
|
* @param {Object} plugin - Plugin object from marketplace.json
|
||||||
* @param {string} repoUrl - Source repository URL
|
* @param {string|null} sourceUrl - Source URL (null for local paths)
|
||||||
* @param {Object} data - Full marketplace.json data
|
* @param {Object} data - Full marketplace.json data
|
||||||
* @returns {Object} Normalized module info
|
* @returns {Object} Normalized module info
|
||||||
*/
|
*/
|
||||||
_normalizeCustomModule(plugin, repoUrl, data) {
|
_normalizeCustomModule(plugin, sourceUrl, data) {
|
||||||
return {
|
return {
|
||||||
code: plugin.name,
|
code: plugin.name,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
|
|
@ -370,7 +581,7 @@ class CustomModuleManager {
|
||||||
description: plugin.description || '',
|
description: plugin.description || '',
|
||||||
version: plugin.version || null,
|
version: plugin.version || null,
|
||||||
author: plugin.author || data.owner || '',
|
author: plugin.author || data.owner || '',
|
||||||
url: repoUrl,
|
url: sourceUrl || null,
|
||||||
source: plugin.source || null,
|
source: plugin.source || null,
|
||||||
skills: plugin.skills || [],
|
skills: plugin.skills || [],
|
||||||
rawPlugin: plugin,
|
rawPlugin: plugin,
|
||||||
|
|
|
||||||
|
|
@ -818,13 +818,13 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to install modules from custom GitHub URLs.
|
* Prompt user to install modules from custom sources (Git URLs or local paths).
|
||||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
* @returns {Array} Selected custom module code strings
|
* @returns {Array} Selected custom module code strings
|
||||||
*/
|
*/
|
||||||
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
||||||
const addCustom = await prompts.confirm({
|
const addCustom = await prompts.confirm({
|
||||||
message: 'Would you like to install from a custom GitHub URL?',
|
message: 'Would you like to install from a custom source (Git URL or local path)?',
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
if (!addCustom) return [];
|
if (!addCustom) return [];
|
||||||
|
|
@ -835,53 +835,59 @@ class UI {
|
||||||
|
|
||||||
let addMore = true;
|
let addMore = true;
|
||||||
while (addMore) {
|
while (addMore) {
|
||||||
const url = await prompts.text({
|
const sourceInput = await prompts.text({
|
||||||
message: 'GitHub repository URL:',
|
message: 'Git URL or local path:',
|
||||||
placeholder: 'https://github.com/owner/repo',
|
placeholder: 'https://github.com/owner/repo or /path/to/module',
|
||||||
validate: (input) => {
|
validate: (input) => {
|
||||||
if (!input || input.trim() === '') return 'URL is required';
|
if (!input || input.trim() === '') return 'Source is required';
|
||||||
const result = customMgr.validateGitHubUrl(input.trim());
|
const result = customMgr.parseSource(input.trim());
|
||||||
return result.isValid ? undefined : result.error;
|
return result.isValid ? undefined : result.error;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const s = await prompts.spinner();
|
const s = await prompts.spinner();
|
||||||
s.start('Fetching module info...');
|
s.start('Resolving source...');
|
||||||
|
|
||||||
let plugins;
|
let sourceResult;
|
||||||
try {
|
try {
|
||||||
plugins = await customMgr.discoverModules(url.trim());
|
sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
|
||||||
s.stop('Module info loaded');
|
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
s.error('Failed to load module info');
|
s.error('Failed to resolve source');
|
||||||
await prompts.log.error(` ${error.message}`);
|
await prompts.log.error(` ${error.message}`);
|
||||||
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceResult.parsed.type === 'local') {
|
||||||
|
await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).');
|
||||||
|
} else {
|
||||||
await prompts.log.warn(
|
await prompts.log.warn(
|
||||||
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
'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 (skip npm install until user confirms)
|
// Resolve plugins based on discovery mode vs direct mode
|
||||||
s.start('Cloning repository...');
|
s.start('Analyzing plugin structure...');
|
||||||
let repoPath;
|
const allResolved = [];
|
||||||
|
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
|
||||||
|
|
||||||
|
if (sourceResult.mode === 'discovery') {
|
||||||
|
// Discovery mode: marketplace.json found, list available plugins
|
||||||
|
let plugins;
|
||||||
try {
|
try {
|
||||||
repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true });
|
plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
|
||||||
s.stop('Repository cloned');
|
} catch (discoverError) {
|
||||||
} catch (cloneError) {
|
s.error('Failed to discover modules');
|
||||||
s.error('Failed to clone repository');
|
await prompts.log.error(` ${discoverError.message}`);
|
||||||
await prompts.log.error(` ${cloneError.message}`);
|
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
||||||
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve each plugin to determine installable modules
|
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
|
||||||
s.start('Analyzing plugin structure...');
|
|
||||||
const allResolved = [];
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
try {
|
try {
|
||||||
const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim());
|
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
|
||||||
if (resolved.length > 0) {
|
if (resolved.length > 0) {
|
||||||
allResolved.push(...resolved);
|
allResolved.push(...resolved);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -900,11 +906,46 @@ class UI {
|
||||||
await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
|
await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode: no marketplace.json, scan directory for skills and resolve
|
||||||
|
const directPlugin = {
|
||||||
|
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||||
|
source: '.',
|
||||||
|
skills: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scan for SKILL.md directories to populate skills array
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
||||||
|
if (await fs.pathExists(skillMd)) {
|
||||||
|
directPlugin.skills.push(entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (scanError) {
|
||||||
|
s.error('Failed to scan directory');
|
||||||
|
await prompts.log.error(` ${scanError.message}`);
|
||||||
|
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directPlugin.skills.length > 0) {
|
||||||
|
try {
|
||||||
|
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
||||||
|
allResolved.push(...resolved);
|
||||||
|
} catch (resolveError) {
|
||||||
|
await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
|
s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
|
||||||
|
|
||||||
if (allResolved.length === 0) {
|
if (allResolved.length === 0) {
|
||||||
await prompts.log.warn('No installable modules found in this repository.');
|
await prompts.log.warn('No installable modules found in this source.');
|
||||||
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -945,7 +986,7 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
addMore = await prompts.confirm({
|
addMore = await prompts.confirm({
|
||||||
message: 'Add another custom module URL?',
|
message: 'Add another custom source?',
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue