mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-09-19 08:11:52 +08:00
c23f23400d
* feat: streamline core to a 5-skill kernel with standalone skill modules
Core installs 14 -> 5 catalog-visible skills; atoms exit to standalone
modules; installer gains real dependency resolution; zero npm deps.
- Merge bmad-editorial-review-prose/-structure into bmad-editorial-review
(structure models JIT-loaded, new customize.toml)
- Merge bmad-review-adversarial-general/-edge-case-hunter/-verification-gap
into bmad-review as selectable lenses; hidden husk-forwarders remain at
the old IDs (no catalog rows) so gds/loop/os-utils keep working
- Move bmad-brainstorming, bmad-party-mode, bmad-forge-idea out of core to
src/standalone-skills/ as single-skill modules; add bmad-analysis bundle
module (curated dependency list over the atoms)
- Move bmad-spec into bmm (2-plan-workflows)
- Modernize bmad-advanced-elicitation: uv run, customize.toml, methods
pick offloaded to scripts/pick_methods.py (with tests)
- Delete bmad-index-docs, bmad-shard-doc (removes the tree's only external
npm dependency), and the four deprecation shims (bmad-create-prd,
bmad-edit-prd, bmad-validate-prd, bmad-create-architecture); all added
to removals.txt
- Installer: activate the dependencies field (recursive union into
selectedModules, cycle-guarded, warn on unknown), config-driven picker
visibility; core stays force-installed
- bmm module.yaml declares deps on the three atoms
- Docs updated across all locales; new reference/standalone-skills.md;
shard-doc how-tos removed
* Restore original critical wording lost in the review/editorial merges
The merges into bmad-review and bmad-editorial-review were meant to keep
the source skills' critical wording behind progressive disclosure, not
paraphrase it away. Restore what was lost:
- lens-adversarial: clueless-weasel framing, extreme-skepticism wording,
the at-least-ten-issues quota, and zero-findings-is-suspicious (the
merge had inverted this to zero-is-valid)
- bmad-review SKILL: zero-findings stance is now per-lens
- lens-edge-case: mandatory exact-order step enforcement
- lens-verification-gap: exact 'No verification gaps found.' clean line
- editorial-review: full Human/LLM reader principles restored to new
references/reader-principles.md; structure-pass HIGH-VALUE DENSITY
role, front-load-value, anti-patterns, pacing check, and length_target
assessment; prose-pass role sentence, analyze-style-first step, and
merge-overlapping-fixes rule; output summary block and min-3-words HALT
* feat(installer): promote bmad-analysis bundle to src/bmad-analysis-skills
Move the bmad-analysis bundle module out of src/standalone-skills/ into its
own src/bmad-analysis-skills root, teach the installer to resolve it there
(getModulePath, official-modules listing, isBuiltInModule helper), and
update the marketplace manifest and standalone-skills docs to match.
* refactor(bmad-review): rename edge-case lens to edge-case-hunter
Rename the lens code and reference file (lens-edge-case.md ->
lens-edge-case-hunter.md), add explicit when = "always" to the shipped
lenses, and tighten the lens-selection wording in SKILL.md.
* feat(bmad-editorial-review): configurable style guide + analysis-driven rework
Apply the workflow-builder analysis recommendations:
- Make the baseline style guide configurable: style_guide in customize.toml
now IS the baseline (default "Microsoft Writing Style Guide") instead of
an empty override slot; SKILL.md no longer hardcodes the guide.
- Inline reader-principles.md into SKILL.md and delete the reference (it
loaded on every run and was half-duplicated inline).
- Complete the customization surface: activation_steps_prepend/append,
persistent_facts (project-context glob), on_complete, and a
review_output_path scalar split out of output_preferences; add a
file:-load fallback convention.
- Ground word metrics: new scripts/word_metrics.py (stdlib, PEP 723, tests)
emits total/per-section word counts so impact estimates and the reduction
summary use exact numbers.
- Cross-pass dedup: prose pass skips CUT-tagged passages and re-attaches
fixes in MERGE'd ones; output ranks by impact with a long-tail rollup.
- Polish: HALT threshold replaced with plain outcome, duplicate LLM-reader
bullets merged, all-caps lowered, literal Overview heading added.
* fix(installer): stop cache-refresh git commands from escaping to the parent repo
Two compounding bugs let a pre-commit test run shallow-fetch and hard-reset
the developer's own repository:
1. Git spawns in custom-module-manager and external-manager inherited the
hook environment. Git exports GIT_DIR (absolute, in worktree checkouts)
into pre-commit hooks; a child git then targets the hook's repo regardless
of cwd, and treats its cwd — the module cache dir — as the work tree. The
cache refresh's 'git fetch --depth 1' + 'git reset --hard origin/main'
therefore shallowed the shared .bare and moved the checked-out branch.
New git-env.js strips repo-targeting GIT_* vars from every git spawn in
both managers, including calls that previously inherited process.env
implicitly.
2. Test suite 51 (quickUpdate dependency expansion) ran the real
CustomModuleManager lookup, which scans ~/.bmad/cache/custom-modules and
network-refreshes every cached clone — real user state. The suite now
stubs findModuleSourceByCode.
Verified by rerunning the suite with GIT_DIR pointed at the repo and a PATH
shim blocking fetch/reset/clone: 432 passing, zero blocked calls.
* De-scope standalone-skills mechanism: atoms return to core, shims reinstated
Shrink the PR to its heart — the skill merges — and defer the module
mechanics to a follow-up where all skills become module-driven:
- bmad-brainstorming, bmad-party-mode, bmad-forge-idea move back to
src/core-skills/ as ordinary core skills with their catalog rows
restored; src/standalone-skills/ and the bmad-analysis bundle module
are removed
- Installer reverted to main: standalone discovery, hidden-module
filtering, dependency resolution, manifest changes (test suites 49-51
removed with the code); the cache-refresh git fix is retained
- The four bmm deprecation shims (create/edit/validate-prd,
create-architecture) are reinstated so enterprise installs that
invoke the old IDs or carry _bmad/custom overrides keep working;
descriptions trimmed to the short husk style; their removals.txt
entries dropped (removal rides the v7 cut as their frontmatter
promises)
- marketplace.json keeps the five plugin entries with atom paths
pointing at src/core-skills/
- Docs (en/cs/fr/vi/zh) reframe the three skills as core thinking
skills; standalone-skills.md reference page removed
* Fix all findings from the max-effort adversarial review
Correctness:
- Finish the edge-case -> edge-case-hunter lens rename at every caller:
the forwarder husk, the code-review/dev-auto/quick-dev review layers,
the renderer test assertion, and the stale example path in
bmad-review/SKILL.md
- marketplace.json: ship the five core kernel skills with
bmad-method-lifecycle so its skills' bmad-review/bmad-editorial-review/
bmad-help/bmad-advanced-elicitation invocations resolve in a
marketplace install
- pick_methods.py / word_metrics.py: force UTF-8 stdout (Windows locale
code pages crashed on the catalog's arrows and CJK headings)
- word_metrics.py: pair fences CommonMark-style so 4-backtick fences can
embed 3-backtick examples without corrupting sections; count CJK
characters as words
- pick_methods.py: validate --extra entries are JSON objects (was an
uncaught AttributeError); read catalogs with utf-8-sig (BOM'd CSVs
silently blanked every num)
- bmad-spec: activation now resolves {output_folder} (which the
Workspace uses) instead of the unused {planning_artifacts}; drop the
stale core-only-installs comment
- Editorial husks: pin the legacy output contracts (three-column table /
Document Summary report and exact empty-state lines) like the review
husks do
- PRD shims: advertise the real bmad-prd customize keys
(validation_checklist_template, prd_output_path, run_folder_pattern,
finalize_reviewers) instead of three that don't exist
- bmad-prd: add the forwarded-activation clause its shims rely on
(ported from bmad-architecture)
- Locale workflow-maps (fr/cs/vi/zh): add the bmad-spec Phase-2 row the
English map gained, which every locale's core-tools note points at
- git-env.js: also strip GIT_CONFIG_PARAMETERS and the
GIT_CONFIG_COUNT/KEY_n/VALUE_n family; pass gitEnv() to the three npm
install spawns whose transitive git calls inherited hook vars
Consistency:
- brain.py --extra overlay now replaces-by-name like pick_methods.py
(same customize.toml additional_* semantics across sibling skills),
with a regression test
* Fix prettier formatting in marketplace.json
* Apply valid CodeRabbit findings
- brain.py: catch malformed --extra overlays (bad JSON, non-array root,
non-object entries) into the clean error path instead of a raw
traceback, with a regression test; read catalogs and overlays with
utf-8-sig; normalize ALL CSV fields (required ones were unstripped and
could arrive as None from short rows)
- brain-selector: clamp the random-technique count to what the pool can
supply so the Total badge matches the actual draw (template +
regenerated assets/brain-selector.html)
- bmad-editorial-review: word_metrics command now uses the explicit
{skill-root}/ prefix
- resolve_party.py / resolve_personas.py: custom member overrides now
start from the installed entry, so omitted fields (icon, title,
description, module, team) survive; non-string member tokens land in
unresolved instead of raising TypeError; party's member loop gains the
isinstance guards its personas twin already had
- bmad-brainstorming: fix the SKILL.md claim that headless is the only
context for self-generated ideas (autonomous mode is interactive);
autonomous mode honors user-supplied techniques before self-selecting
- Docs: drop duplicate 'only' in the spec template; align zh-cn
forge-idea's bmad-review description with the English wording
* Create only the output folder at install time
bmm no longer pre-creates planning_artifacts, implementation_artifacts,
and project_knowledge — the last of which put an empty docs/ at every
project root. Skills create those lazily on first write. core now
declares {output_folder} in its directories block, which was previously
created only as a side effect of the artifact folders nesting under it.
925 lines
35 KiB
JavaScript
925 lines
35 KiB
JavaScript
const fs = require('../fs-native');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execSync } = require('node:child_process');
|
|
const prompts = require('../prompts');
|
|
const { gitEnv } = require('./git-env');
|
|
|
|
function quoteCustomRef(ref) {
|
|
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
|
|
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
|
|
}
|
|
return `"${ref}"`;
|
|
}
|
|
|
|
function isLocalSourcePath(input) {
|
|
return (
|
|
input.startsWith('/') ||
|
|
input.startsWith('./') ||
|
|
input.startsWith('../') ||
|
|
input.startsWith('.\\') ||
|
|
input.startsWith('..\\') ||
|
|
input.startsWith('~') ||
|
|
path.win32.isAbsolute(input)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Manages custom modules installed from user-provided sources.
|
|
* 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 {
|
|
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
|
|
static _resolutionCache = new Map();
|
|
/** @type {Set<string>} Repo roots refreshed in the current process (dedupe quick-update fetches). */
|
|
static _refreshedRepoPaths = new Set();
|
|
/** @type {Map<string, Promise<void>>} In-flight refresh operations keyed by repo path. */
|
|
static _refreshInFlight = new Map();
|
|
|
|
// ─── Source Parsing ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a user-provided source input into a structured descriptor.
|
|
* Accepts local file paths, HTTPS Git URLs, HTTP Git URLs, and SSH Git URLs.
|
|
* For HTTPS/HTTP URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
|
|
* The original protocol (http or https) is preserved in the returned cloneUrl.
|
|
*
|
|
* @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 trimmedRaw = input.trim();
|
|
if (!trimmedRaw) {
|
|
return {
|
|
type: null,
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Source is required',
|
|
};
|
|
}
|
|
|
|
// Extract optional @<tag-or-branch> suffix from the end of the input.
|
|
// Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash.
|
|
// Raw commit SHAs are NOT supported here — `git clone --branch` can't take
|
|
// them; use --pin at the module level or check out the SHA manually.
|
|
// Only strip when the tail looks like a ref, so we don't disturb
|
|
// URLs without a version spec or the SSH protocol's `git@host:...` prefix.
|
|
let trimmed = trimmedRaw;
|
|
let versionSuffix = null;
|
|
const lastAt = trimmedRaw.lastIndexOf('@');
|
|
// Skip if @ is part of git@github.com:... (first char cannot be stripped as version)
|
|
// and skip if @ appears before the path rather than after a ref-shaped tail.
|
|
if (lastAt > 0) {
|
|
const candidate = trimmedRaw.slice(lastAt + 1);
|
|
const before = trimmedRaw.slice(0, lastAt);
|
|
// candidate must be ref-shaped and must not itself look like a URL / SSH host
|
|
if (/^[\w.\-+/]+$/.test(candidate) && !candidate.includes(':')) {
|
|
// Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
|
|
// in that case. Require that the @ comes after the host/path, not inside the auth segment.
|
|
// Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
|
|
const beforeLooksLikeRepo = isLocalSourcePath(before) || /^https?:\/\//i.test(before) || /^git@[^:]+:.+/.test(before);
|
|
if (beforeLooksLikeRepo) {
|
|
versionSuffix = candidate;
|
|
trimmed = before;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local path detection: POSIX, Windows, relative, or home-relative.
|
|
if (isLocalSourcePath(trimmed)) {
|
|
if (versionSuffix) {
|
|
return {
|
|
type: 'local',
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Local paths do not support @version suffixes',
|
|
};
|
|
}
|
|
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,
|
|
version: versionSuffix || null,
|
|
rawInput: trimmedRaw,
|
|
cacheKey: `${host}/${owner}/${repo}`,
|
|
displayName: `${owner}/${repo}`,
|
|
isValid: true,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
// HTTPS/HTTP URL: generic handling for any Git host.
|
|
// We avoid host-specific parsing — `git clone` will accept whatever URL the
|
|
// user provides. We only need to (a) separate an optional browser-style
|
|
// subdir suffix from the clone URL, (b) extract any embedded ref
|
|
// (branch/tag) from deep-path URLs, and (c) derive a cache key / display
|
|
// name from the path. The original protocol (http or https) is preserved.
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
let url;
|
|
try {
|
|
url = new URL(trimmed);
|
|
} catch {
|
|
url = null;
|
|
}
|
|
|
|
if (url && url.host) {
|
|
const host = url.host;
|
|
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
let subdir = null;
|
|
let urlRef = null; // branch/tag/commit extracted from deep-path URLs
|
|
|
|
// Detect browser-style deep-path patterns that embed a ref
|
|
// (branch/tag/commit) and optional subdirectory. These appear
|
|
// across many hosts:
|
|
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
|
|
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
|
|
// Gitea /<repo>/src/<ref>[/<subdir>]
|
|
// Gitea /<repo>/src/(branch|commit|tag)/<ref>[/<subdir>]
|
|
// Group 1 = repo path prefix, Group 2 = ref, Group 3 = subdir (optional).
|
|
const deepPathPatterns = [
|
|
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
|
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
|
];
|
|
for (const pattern of deepPathPatterns) {
|
|
const match = repoPath.match(pattern);
|
|
if (match) {
|
|
repoPath = match[1];
|
|
if (match[2]) urlRef = match[2];
|
|
if (match[3]) {
|
|
const cleaned = match[3].replace(/\/+$/, '');
|
|
if (cleaned) subdir = cleaned;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Some hosts use ?path=/subdir on browse links to point at a file or
|
|
// directory. Honor it when no deep-path marker matched above.
|
|
if (!subdir) {
|
|
const pathParam = url.searchParams.get('path');
|
|
if (pathParam) {
|
|
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
if (cleaned) subdir = cleaned;
|
|
}
|
|
}
|
|
|
|
// Strip a single trailing .git for a stable cacheKey/displayName.
|
|
const repoPathClean = repoPath.replace(/\.git$/i, '');
|
|
if (!repoPathClean) {
|
|
return {
|
|
type: null,
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Not a valid Git URL or local path',
|
|
};
|
|
}
|
|
|
|
const cloneUrl = `${url.protocol}//${host}/${repoPathClean}`;
|
|
const cacheKey = `${host}/${repoPathClean}`;
|
|
|
|
// Display name: prefer "<owner>/<repo>" using the last two meaningful
|
|
// path segments.
|
|
const segments = repoPathClean.split('/').filter(Boolean);
|
|
const repoSeg = segments.at(-1);
|
|
const ownerSeg = segments.at(-2);
|
|
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
|
|
|
|
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
|
const version = versionSuffix || urlRef || null;
|
|
|
|
return {
|
|
type: 'url',
|
|
cloneUrl,
|
|
subdir,
|
|
localPath: null,
|
|
version,
|
|
rawInput: trimmedRaw,
|
|
cacheKey,
|
|
displayName,
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ─── 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;
|
|
try {
|
|
return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Discover modules from pre-read marketplace.json data.
|
|
* @param {Object} marketplaceData - Parsed marketplace.json content
|
|
* @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
|
|
* @returns {Array<Object>} Normalized plugin list
|
|
*/
|
|
async discoverModules(marketplaceData, sourceUrl) {
|
|
const plugins = marketplaceData?.plugins;
|
|
|
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
throw new Error('marketplace.json contains no plugins');
|
|
}
|
|
|
|
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 ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the cache directory for custom modules.
|
|
* @returns {string} Path to the custom modules cache directory
|
|
*/
|
|
getCacheDir() {
|
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
|
}
|
|
|
|
/**
|
|
* Clone a custom module repository to cache.
|
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
|
|
* @param {string} sourceInput - Git URL (HTTPS, HTTP, or SSH)
|
|
* @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(sourceInput, options = {}) {
|
|
const parsed = this.parseSource(sourceInput);
|
|
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 repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
|
|
const silent = options.silent || false;
|
|
const displayName = parsed.displayName;
|
|
|
|
// Pin override: --pin CODE=TAG resolved at module-selection time overrides
|
|
// any @version suffix present in the URL.
|
|
const effectiveVersion = options.pinOverride || parsed.version || null;
|
|
|
|
await fs.ensureDir(path.dirname(repoCacheDir));
|
|
|
|
const createSpinner = async () => {
|
|
if (silent) {
|
|
return { start() {}, stop() {}, error() {} };
|
|
}
|
|
return await prompts.spinner();
|
|
};
|
|
|
|
// If an existing cache exists but was cloned at a different version, re-clone.
|
|
// Tracked via .bmad-source.json's recorded version.
|
|
if (await fs.pathExists(repoCacheDir)) {
|
|
let cachedVersion = null;
|
|
try {
|
|
const existing = await fs.readJson(path.join(repoCacheDir, '.bmad-source.json'));
|
|
cachedVersion = existing?.version || null;
|
|
} catch {
|
|
// no metadata; treat as mismatched to be safe if a version was requested
|
|
}
|
|
if ((effectiveVersion || null) !== (cachedVersion || null)) {
|
|
await fs.remove(repoCacheDir);
|
|
}
|
|
}
|
|
|
|
if (await fs.pathExists(repoCacheDir)) {
|
|
// Update existing clone (same version as before)
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Updating ${displayName}...`);
|
|
try {
|
|
execSync('git fetch origin --depth 1', {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }),
|
|
});
|
|
if (effectiveVersion) {
|
|
// Fetch the ref as either a tag or a branch — `origin <ref>` works
|
|
// for both, whereas `origin tag <ref>` fails for branch refs parsed
|
|
// out of /tree/<branch>/... URLs.
|
|
execSync(`git fetch --depth 1 origin ${quoteCustomRef(effectiveVersion)} --no-tags`, {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }),
|
|
});
|
|
execSync(`git checkout --quiet FETCH_HEAD`, {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv(),
|
|
});
|
|
} else {
|
|
// Resolve the default branch (origin/HEAD) and fetch it explicitly.
|
|
// With shallow clones, `origin/HEAD` is stale and `git reset --hard
|
|
// origin/HEAD` never picks up new commits on the default branch.
|
|
let defaultBranch = 'main';
|
|
try {
|
|
defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD --short', {
|
|
cwd: repoCacheDir,
|
|
stdio: 'pipe',
|
|
env: gitEnv(),
|
|
})
|
|
.toString()
|
|
.trim()
|
|
.replace('origin/', '');
|
|
} catch {
|
|
// Fallback if origin/HEAD is not set
|
|
}
|
|
execSync(`git fetch --depth 1 origin ${quoteCustomRef(defaultBranch)}`, {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }),
|
|
});
|
|
execSync(`git reset --hard origin/${quoteCustomRef(defaultBranch)}`, {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv(),
|
|
});
|
|
}
|
|
fetchSpinner.stop(`Updated ${displayName}`);
|
|
} catch {
|
|
// Fetch failed against an existing cache — most often the remote is
|
|
// unreachable (network down, repo deleted/moved, auth revoked).
|
|
// Preserve the previous clone so re-deploy still works from cached
|
|
// content; surface a warning so the user knows the cache is stale.
|
|
fetchSpinner.error(`Could not refresh ${displayName} — keeping cached copy`);
|
|
await prompts.log.warn(`Custom module ${displayName} was not refreshed (remote unreachable). Using cached copy.`);
|
|
}
|
|
}
|
|
|
|
if (!(await fs.pathExists(repoCacheDir))) {
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Cloning ${displayName}${effectiveVersion ? ` @ ${effectiveVersion}` : ''}...`);
|
|
try {
|
|
if (effectiveVersion) {
|
|
execSync(`git clone --depth 1 --branch ${quoteCustomRef(effectiveVersion)} "${parsed.cloneUrl}" "${repoCacheDir}"`, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }),
|
|
});
|
|
} else {
|
|
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }),
|
|
});
|
|
}
|
|
fetchSpinner.stop(`Cloned ${displayName}`);
|
|
} catch (error_) {
|
|
fetchSpinner.error(`Failed to clone ${displayName}`);
|
|
const refSuffix = effectiveVersion ? `@${effectiveVersion}` : '';
|
|
throw new Error(`Failed to clone ${parsed.cloneUrl}${refSuffix}: ${error_.message}`);
|
|
}
|
|
}
|
|
|
|
// Record the resolved SHA for the manifest writer.
|
|
let resolvedSha = null;
|
|
try {
|
|
resolvedSha = execSync('git rev-parse HEAD', { cwd: repoCacheDir, stdio: 'pipe', env: gitEnv() }).toString().trim();
|
|
} catch {
|
|
// swallow — a non-git repo (local path) wouldn't reach here anyway
|
|
}
|
|
// Best-effort: capture the remote default branch name so channel marker
|
|
// metadata for "next" reflects the actual tracked ref (not always "main").
|
|
let defaultRef = 'main';
|
|
if (!effectiveVersion) {
|
|
try {
|
|
const symbolic = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
|
|
cwd: repoCacheDir,
|
|
stdio: 'pipe',
|
|
env: gitEnv(),
|
|
})
|
|
.toString()
|
|
.trim();
|
|
if (symbolic.startsWith('origin/')) {
|
|
defaultRef = symbolic.slice('origin/'.length) || defaultRef;
|
|
}
|
|
} catch {
|
|
// Fallback to previous marker value when symbolic ref is unavailable.
|
|
try {
|
|
const existingMarker = await fs.readJson(path.join(repoCacheDir, '.bmad-channel.json'));
|
|
if (existingMarker?.channel === 'next' && typeof existingMarker.version === 'string' && existingMarker.version.trim()) {
|
|
defaultRef = existingMarker.version.trim();
|
|
}
|
|
} catch {
|
|
// Keep default fallback.
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
version: effectiveVersion || null,
|
|
rawInput: parsed.rawInput || sourceInput,
|
|
sha: resolvedSha,
|
|
clonedAt: new Date().toISOString(),
|
|
});
|
|
// Keep a channel marker in custom cache too so update paths that rely on
|
|
// channel metadata (same as official-module cache) can treat this clone as
|
|
// refreshable. URL + no explicit ref => next, explicit ref => pinned.
|
|
await fs.writeJson(path.join(repoCacheDir, '.bmad-channel.json'), {
|
|
channel: effectiveVersion ? 'pinned' : 'next',
|
|
version: effectiveVersion || defaultRef,
|
|
sha: resolvedSha,
|
|
writtenAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Install dependencies if package.json exists (skip during browsing/analysis)
|
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
|
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
|
|
const installSpinner = await createSpinner();
|
|
installSpinner.start(`Installing dependencies for ${displayName}...`);
|
|
try {
|
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 120_000,
|
|
env: gitEnv(), // npm shells out to git for git-URL deps; keep hook GIT_* vars away from it
|
|
});
|
|
installSpinner.stop(`Installed dependencies for ${displayName}`);
|
|
} catch (error_) {
|
|
installSpinner.error(`Failed to install dependencies for ${displayName}`);
|
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
|
}
|
|
}
|
|
|
|
return repoCacheDir;
|
|
}
|
|
|
|
// ─── Plugin Resolution ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a plugin to determine installation strategy and module registration files.
|
|
* Results are cached in _resolutionCache keyed by module code.
|
|
* @param {string} repoPath - Absolute path to the cloned repository or local directory
|
|
* @param {Object} plugin - Raw plugin object from marketplace.json
|
|
* @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
|
|
*/
|
|
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
|
|
const { PluginResolver } = require('./plugin-resolver');
|
|
const resolver = new PluginResolver();
|
|
const resolved = await resolver.resolve(repoPath, plugin);
|
|
|
|
// Read clone metadata (written by cloneRepo) so we can pick up the
|
|
// resolved git ref + SHA for manifest recording.
|
|
let cloneMetadata = null;
|
|
if (sourceUrl) {
|
|
try {
|
|
cloneMetadata = await fs.readJson(path.join(repoPath, '.bmad-source.json'));
|
|
} catch {
|
|
// no metadata — local-source or legacy cache
|
|
}
|
|
}
|
|
|
|
// Stamp source info onto each resolved module for manifest tracking
|
|
for (const mod of resolved) {
|
|
if (sourceUrl) mod.repoUrl = sourceUrl;
|
|
if (localPath) mod.localPath = localPath;
|
|
if (cloneMetadata) {
|
|
mod.cloneRef = cloneMetadata.version || null;
|
|
mod.cloneSha = cloneMetadata.sha || null;
|
|
mod.rawInput = cloneMetadata.rawInput || null;
|
|
}
|
|
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Get a cached resolution result by module code.
|
|
* @param {string} moduleCode - Module code to look up
|
|
* @returns {Object|null} ResolvedModule or null if not cached
|
|
*/
|
|
getResolution(moduleCode) {
|
|
return CustomModuleManager._resolutionCache.get(moduleCode) || null;
|
|
}
|
|
|
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Find the module source path within a cached or local source directory.
|
|
* @param {string} sourceInput - Git URL or local path (used to locate cached clone)
|
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
|
* @returns {string|null} Path to directory containing module.yaml
|
|
*/
|
|
async findModuleSource(sourceInput, pluginSource) {
|
|
const parsed = this.parseSource(sourceInput);
|
|
if (!parsed.isValid) 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")
|
|
if (pluginSource) {
|
|
const sourcePath = path.join(baseDir, pluginSource);
|
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
|
if (await fs.pathExists(moduleYaml)) {
|
|
return sourcePath;
|
|
}
|
|
}
|
|
|
|
// Fallback: search skills/ and src/ directories
|
|
for (const dir of ['skills', 'src']) {
|
|
const rootCandidate = path.join(baseDir, dir, 'module.yaml');
|
|
if (await fs.pathExists(rootCandidate)) {
|
|
return path.dirname(rootCandidate);
|
|
}
|
|
const dirPath = path.join(baseDir, dir);
|
|
if (await fs.pathExists(dirPath)) {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
|
if (await fs.pathExists(subCandidate)) {
|
|
return path.dirname(subCandidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check base directory root
|
|
const rootCandidate = path.join(baseDir, 'module.yaml');
|
|
if (await fs.pathExists(rootCandidate)) {
|
|
return baseDir;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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 {Object} [options] - Options
|
|
* @returns {string|null} Path to the module source or null
|
|
*/
|
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
|
// Check resolution cache first (populated by resolvePlugin)
|
|
const resolved = CustomModuleManager._resolutionCache.get(moduleCode);
|
|
if (resolved) {
|
|
// For strategies 1-2: the common parent or setup skill's parent has the module files
|
|
if (resolved.moduleYamlPath) {
|
|
return path.dirname(resolved.moduleYamlPath);
|
|
}
|
|
// For strategy 5 (synthesized): return the first skill's parent as a reference path
|
|
if (resolved.skillPaths && resolved.skillPaths.length > 0) {
|
|
return path.dirname(resolved.skillPaths[0]);
|
|
}
|
|
}
|
|
|
|
const cacheDir = this.getCacheDir();
|
|
if (!(await fs.pathExists(cacheDir))) return null;
|
|
|
|
// Search through all cached repo roots
|
|
try {
|
|
const { PluginResolver } = require('./plugin-resolver');
|
|
const resolver = new PluginResolver();
|
|
const repoRoots = await this._findCacheRepoRoots(cacheDir);
|
|
|
|
for (const { repoPath, metadata } of repoRoots) {
|
|
// Quick-update path: refresh URL-backed cached repos before reading
|
|
// files from them so re-deploy uses latest commits for `next` and
|
|
// the pinned ref for `pinned`.
|
|
if (options.bmadDir && metadata?.rawInput) {
|
|
await this._refreshRepoCacheOnce(repoPath, metadata);
|
|
}
|
|
|
|
// Check marketplace.json for matching module code
|
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
|
if (!(await fs.pathExists(marketplacePath))) continue;
|
|
|
|
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) {
|
|
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 resolvedMods = await resolver.resolve(repoPath, plugin);
|
|
for (const mod of resolvedMods) {
|
|
if (mod.code === moduleCode) {
|
|
// Use metadata for URL reconstruction instead of deriving from path
|
|
mod.repoUrl = metadata?.cloneUrl || null;
|
|
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
|
|
}
|
|
}
|
|
} catch {
|
|
// Cache doesn't exist or is inaccessible
|
|
}
|
|
|
|
// Fallback: check manifest for localPath (local-source modules not in cache)
|
|
return this._findLocalSourceFromManifest(moduleCode, options);
|
|
}
|
|
|
|
/**
|
|
* Refresh one cached repo at most once per process with in-flight dedupe.
|
|
* Prevents concurrent quick-update callers from racing the same cache path.
|
|
* @param {string} repoPath - Absolute cache repo path
|
|
* @param {Object} metadata - Parsed .bmad-source.json metadata
|
|
*/
|
|
async _refreshRepoCacheOnce(repoPath, metadata) {
|
|
if (CustomModuleManager._refreshedRepoPaths.has(repoPath)) return;
|
|
|
|
const existing = CustomModuleManager._refreshInFlight.get(repoPath);
|
|
if (existing) {
|
|
await existing;
|
|
return;
|
|
}
|
|
|
|
const refreshPromise = (async () => {
|
|
try {
|
|
await this.cloneRepo(metadata.rawInput, {
|
|
silent: true,
|
|
pinOverride: metadata.version || undefined,
|
|
});
|
|
CustomModuleManager._refreshedRepoPaths.add(repoPath);
|
|
} catch (error_) {
|
|
// cloneRepo only throws here for unrecoverable cases (no cache present
|
|
// and a fresh clone failed, or an unexpected internal error). The
|
|
// common "remote unreachable but cache exists" case is handled inside
|
|
// cloneRepo, which preserves the clone and returns normally. Reaching
|
|
// this catch means we have no usable cache — surface a warning so the
|
|
// failure isn't silent.
|
|
await prompts.log.warn(`Refresh of cached custom module at ${path.basename(repoPath)} failed: ${error_?.message || error_}`);
|
|
} finally {
|
|
CustomModuleManager._refreshInFlight.delete(repoPath);
|
|
}
|
|
})();
|
|
|
|
CustomModuleManager._refreshInFlight.set(repoPath, refreshPromise);
|
|
await refreshPromise;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
} 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 ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
|
* @param {Object} plugin - Plugin object from marketplace.json
|
|
* @param {string|null} sourceUrl - Source URL (null for local paths)
|
|
* @param {Object} data - Full marketplace.json data
|
|
* @returns {Object} Normalized module info
|
|
*/
|
|
_normalizeCustomModule(plugin, sourceUrl, data) {
|
|
return {
|
|
code: plugin.name,
|
|
name: plugin.name,
|
|
displayName: plugin.name,
|
|
description: plugin.description || '',
|
|
version: plugin.version || null,
|
|
author: plugin.author || data.owner || '',
|
|
url: sourceUrl || null,
|
|
source: plugin.source || null,
|
|
skills: plugin.skills || [],
|
|
rawPlugin: plugin,
|
|
type: 'custom',
|
|
trustTier: 'unverified',
|
|
builtIn: false,
|
|
isExternal: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { CustomModuleManager };
|