Skip to content

feat(installer): community module browser and custom URL support#2229

Merged
bmadcode merged 6 commits intomainfrom
custom-support
Apr 8, 2026
Merged

feat(installer): community module browser and custom URL support#2229
bmadcode merged 6 commits intomainfrom
custom-support

Conversation

@bmadcode
Copy link
Copy Markdown
Collaborator

@bmadcode bmadcode commented Apr 8, 2026

Summary

  • Three-tier module selection: official modules (unchanged), community modules (category drill-down with featured/search), and custom GitHub URL
  • Community modules fetched from marketplace community-index.yaml, pinned to approved_sha when set (refuses install if SHA unreachable), uses HEAD when no SHA (trusted contributors)
  • Custom URL modules discovered via .claude-plugin/marketplace.json with unverified warning
  • Shared RegistryClient utility for HTTP fetch/YAML/JSON parsing
  • findModuleSource chain extended with community and custom fallthrough
  • Manifest detects community and custom source types

New files

  • tools/installer/modules/registry-client.js - Shared HTTPS fetch
  • tools/installer/modules/community-manager.js - Community catalog, categories, SHA-pinned cloning
  • tools/installer/modules/custom-module-manager.js - GitHub URL validation, marketplace.json discovery

Test plan

  • All 196 existing tests pass
  • Lint, format, markdown lint all pass
  • New modules load and instantiate correctly
  • Community manager handles unreachable registry (returns empty)
  • End-to-end: install with community module selected (requires marketplace community-index.yaml on main - merged in feat: community module catalog and promoted fields bmad-plugins-marketplace#6)
  • End-to-end: install with custom GitHub URL

Three-tier module selection: official, community (category drill-down
with featured/search), and custom GitHub URL.

- Add RegistryClient shared fetch utility
- Add CommunityModuleManager with SHA-pinned cloning (refuses install
  if approved SHA cannot be reached; uses HEAD when no SHA set)
- Add CustomModuleManager for arbitrary GitHub repo installation
- Extend findModuleSource chain with community and custom fallthrough
- Extend manifest to detect community and custom source types
- Add Config.customModulesMeta for custom module metadata
@augmentcode
Copy link
Copy Markdown

augmentcode bot commented Apr 8, 2026

🤖 Augment PR Summary

Summary: Adds a three-tier module selection/install flow to the installer, expanding beyond official modules.

Changes:

  • Extends module discovery to include community modules from the marketplace registry and custom modules from user-provided GitHub URLs
  • Adds a shared RegistryClient for HTTP fetch + YAML/JSON parsing
  • Introduces CommunityModuleManager to load a community catalog, support category/featured/search browsing, and clone repos (optionally SHA-pinned)
  • Introduces CustomModuleManager to validate GitHub URLs, discover modules via .claude-plugin/marketplace.json, and clone to a local cache
  • Updates OfficialModules.findModuleSource to fall through to community and then custom module sources
  • Updates manifest source detection to classify modules as built-in/external/community/custom/unknown
  • Refactors the UI module picker into separate official/community/custom phases

Technical Notes: Community index + categories are pulled from the marketplace repo; custom modules are treated as unverified and are pre-cloned to cache for the install pipeline.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 8 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

const customSelected = await this._addCustomUrlModules(installedModuleIds);

// Merge all selections
return [...officialSelected, ...communitySelected, ...customSelected];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/ui.js:581 — selectAllModules() concatenates the three selections without de-duplicating module codes, so the same code can appear multiple times and be processed twice downstream.
This could lead to repeated installs/config prompts if a module ID overlaps across tiers.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

/**
* Prompt user to install modules from custom GitHub URLs.
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected custom module objects with metadata
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/ui.js:804 — The JSDoc for _addCustomUrlModules() says it returns “custom module objects with metadata”, but the function returns an array of module code strings.
This mismatch can mislead callers and future maintenance.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

* Manages community modules from the BMad marketplace registry.
* Fetches community-index.yaml and categories.yaml from GitHub.
* Returns empty results when the registry is unreachable.
* Community modules are pinned to approved tags (not HEAD).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/modules/community-manager.js:16 — The class doc says community modules are pinned to approved tags “(not HEAD)”, but cloneModule() updates to origin/HEAD whenever approvedSha is not set.
This comment/code mismatch can lead to incorrect assumptions about what gets installed.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

return path.dirname(rootCandidate);
}

return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/modules/community-manager.js:336 — findModuleSource() returns a directory derived from moduleDefinition even when the configured file doesn’t exist, which can cause later steps to assume a valid module.yaml path.
Returning a non-existent source path here could turn a registry/config error into a harder-to-debug install failure.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

});
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/modules/community-manager.js:280 — If dependency installation fails, the error is logged but the install continues, potentially leaving a cached module in a broken/half-installed state.
Consider whether this should fail the module install step so downstream code doesn’t run against missing deps.

Severity: medium

Other Locations
  • tools/installer/modules/custom-module-manager.js:172

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const trimmed = url.trim();

// HTTPS format: https://github.com/owner/repo[.git]
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/modules/custom-module-manager.js:33 — validateGitHubUrl() rejects common GitHub URL variants like a trailing slash (.../owner/repo/) and repo names containing dots, which may block legitimate repos.
This can make the custom URL flow fail unexpectedly for users.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/core/manifest.js:840 — In the custom-module block, customSource is computed but not used, so _readMarketplaceVersion() is typically called with moduleSourcePath=null and falls back to the external-module cache (likely returning null).
That means custom modules may always show version: null in manifests even when the cached repo has a .claude-plugin/marketplace.json.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tools/installer/core/manifest.js:845 — For custom modules, repoUrl is always recorded as null, which makes it hard to trace provenance and can be ambiguous if multiple cached custom repos expose the same plugin name.
Consider capturing the source URL from the custom cache/marketplace metadata for the manifest.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

This pull request extends the installer system with support for community and custom modules. New module managers are introduced to fetch and manage community modules from a GitHub registry and custom modules from user-provided GitHub URLs. The manifest resolution is updated to discover and resolve these sources. The module selection UI is reworked into a three-phase pipeline allowing users to select from official, community, and custom modules. HTTP fetching is centralized via a new RegistryClient, which ExternalModuleManager now delegates to. Config is updated to track custom modules metadata.

Changes

Cohort / File(s) Summary
Core Configuration
tools/installer/core/config.js
Constructor now accepts customModulesMeta parameter (frozen array, defaults to []); Config.build() forwards user input accordingly.
HTTP Client & External Manager
tools/installer/modules/registry-client.js, tools/installer/modules/external-manager.js
New RegistryClient class added for shared HTTPS fetching with timeout, redirect, and YAML/JSON parsing support. ExternalModuleManager refactored to delegate HTTP calls to RegistryClient instead of internal _fetch implementation.
Module Managers (Community & Custom)
tools/installer/modules/community-manager.js, tools/installer/modules/custom-module-manager.js
New CommunityModuleManager fetches and caches GitHub-hosted community modules, provides listing/search/category filtering, and clones modules to user cache with dependency installation and optional SHA verification. New CustomModuleManager validates GitHub URLs, fetches .claude-plugin/marketplace.json, maintains per-repo cache, and discovers module sources via heuristic paths.
Module Resolution & Manifest
tools/installer/core/manifest.js, tools/installer/modules/official-modules.js
Manifest.getModuleVersionInfo() now checks community and custom sources after official modules, returning appropriate source metadata. OfficialModules.findModuleSource() dynamically loads and queries both managers for source discovery fallback.
UI Module Selection
tools/installer/ui.js
selectAllModules() refactored into three-phase pipeline: official-module selection, community-module browsing (with category drill-down and search), and custom-URL addition (with validation, plugin discovery, and cloning). New helper methods handle each phase with merged result accumulation.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant UI
    participant OfficialModules
    participant CommunityModuleManager
    participant CustomModuleManager
    participant RegistryClient
    participant GitHub

    User->>UI: Start module selection
    
    rect rgba(100, 150, 200, 0.5)
    Note over UI,OfficialModules: Phase 1: Select Official Modules
    UI->>OfficialModules: _selectOfficialModules()
    OfficialModules-->>UI: [official module selections]
    end
    
    rect rgba(150, 100, 200, 0.5)
    Note over UI,CommunityModuleManager: Phase 2: Browse Community Modules
    UI->>CommunityModuleManager: loadCategories()
    CommunityModuleManager->>RegistryClient: fetchYaml(community-index)
    RegistryClient->>GitHub: GET community registry
    GitHub-->>RegistryClient: YAML content
    RegistryClient-->>CommunityModuleManager: parsed index
    CommunityModuleManager-->>UI: categories list
    
    loop Category browsing
        User->>UI: Select category
        UI->>CommunityModuleManager: listByCategory(slug)
        CommunityModuleManager-->>UI: [modules in category]
        User->>UI: Select modules
    end
    UI-->>UI: [community module selections]
    end
    
    rect rgba(200, 150, 100, 0.5)
    Note over UI,CustomModuleManager: Phase 3: Add Custom GitHub URLs
    loop URL entry loop
        User->>UI: Enter GitHub URL
        UI->>CustomModuleManager: Fetch repo plugins
        CustomModuleManager->>RegistryClient: fetchJson(.claude-plugin/marketplace.json)
        RegistryClient->>GitHub: GET marketplace.json
        GitHub-->>RegistryClient: JSON content
        RegistryClient-->>CustomModuleManager: plugin definitions
        CustomModuleManager->>GitHub: Clone/update repo
        GitHub-->>CustomModuleManager: local cache
        CustomModuleManager-->>UI: [discovered plugins]
        User->>UI: Confirm plugin selection
        UI-->>UI: [custom module selections]
    end
    end
    
    UI-->>User: [merged all selections]
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • alexeyv
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding a community module browser and custom URL support to the installer.
Description check ✅ Passed The description is well-structured and directly addresses the changeset, detailing the three-tier selection system, new files, community and custom module features, and test status.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch custom-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tools/installer/ui.js (1)

618-625: ⚠️ Potential issue | 🟠 Major

Let users skip official modules in phase 1.

required: true forces at least one official selection before the community/custom phases even start. That makes community-only and custom-only installs impossible on a fresh project. If you still want a guard, enforce it after all three phases are complete.

💡 Proposed fix
-      required: true,
+      required: false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/ui.js` around lines 618 - 625, The
prompts.autocompleteMultiselect call (assigned to selected) currently sets
required: true which forces at least one official module selection and prevents
community-only or custom-only installs; change this by removing or setting
required to false on the prompts.autocompleteMultiselect options and, if you
need a guard, perform validation after all three phases (official, community,
custom) are completed rather than blocking at this step so a user can skip
official modules during phase 1.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tools/installer/core/manifest.js`:
- Around line 835-846: The custom-module branch currently finds customSource via
CustomModuleManager but still calls _readMarketplaceVersion(moduleName,
moduleSourcePath), which can be null and causes version lookup to use the wrong
cache; change the call to pass the discovered customSource (or its path/URL
value) into _readMarketplaceVersion so the helper reads version metadata from
the custom module location. Update the block around
CustomModuleManager/customMgr/customSource and the return that sets version to
use the value returned by _readMarketplaceVersion when invoked with customSource
instead of moduleSourcePath.

In `@tools/installer/modules/community-manager.js`:
- Around line 181-183: Validate and sanitize registry-derived fields before
using them: ensure moduleCode contains only allowed characters (e.g.,
alphanumerics, hyphen, underscore) and does not contain path separators or
traversal sequences, then build moduleCacheDir by resolving
path.join(this.getCacheDir(), moduleCode) and verifying the resolved path starts
with the cacheDir prefix; validate moduleInfo.url is a well-formed git URL (or
matches an allowed host pattern) and approvedSha matches a safe hex commit-ish
pattern before using them; replace execSync shell invocations that interpolate
url/sha with execFileSync or spawnSync using argument arrays (e.g., ['git',
'clone', moduleInfo.url, moduleCacheDir'] and ['git', 'checkout', approvedSha'])
to avoid shell injection, and apply the same validation and execFileSync
refactor to the other occurrences around lines 229-255 (the git clone/checkout
flows).
- Around line 303-307: The code currently uses path.join(cloneDir,
moduleInfo.moduleDefinition) and returns path.dirname(configuredPath) without
ensuring the joined path is inside the cloned repo; resolve the joined path
(e.g., with path.resolve) and verify the resolved configuredPath is still under
cloneDir (e.g., compare path.relative(cloneDir, resolvedPath) does not start
with '..' or use startsWith after normalizing) before using or returning it; if
the check fails, treat it as not found and continue. Apply the same fix for the
other occurrence referenced (around line 336) that uses
moduleInfo.moduleDefinition and returning path.dirname.

In `@tools/installer/modules/custom-module-manager.js`:
- Around line 25-45: The validateGitHubUrl function is too permissive and allows
traversal/injection vectors; update validateGitHubUrl to parse the URL
structurally (use URL parsing for https and explicit parsing for git@ SSH) and
strictly validate that owner and repo match a safe whitelist regex (e.g. only
alphanumerics, hyphen, underscore and dot: /^[A-Za-z0-9._-]+$/), explicitly
reject '.' and '..', trim any .git suffix from repo, and return isValid=false
with an error for invalid values; then update call sites that use
path.join(cacheDir, owner, repo) (references around the code using path.join at
lines ~110-112) to assume owner/repo are sanitized, and replace execSync("git
clone ...") uses (references around ~148-155) with a non-shell invocation (e.g.
child_process.execFile or spawn with args) to pass "git" and ["clone", repoUrl,
targetPath"] as separate arguments to avoid shell injection.

In `@tools/installer/ui.js`:
- Around line 833-865: The code currently pushes only plugin.code into
selectedModules, losing source metadata and risking collisions; change the
selection step (inside the loop that iterates plugins after cloning) to push an
object that includes code, repoUrl (use the url.trim() used earlier), and source
(e.g., 'custom' or a value consistent with customModulesMeta) instead of a
string; ensure the rest of the flow that consumes selectedModules is updated to
expect objects (or populate the new customModulesMeta field accordingly) so
downstream resolution uses the stored repoUrl/source rather than scanning the
cache by code.
- Around line 647-651: The current prompt handler returns [] when the user
declines "browseCommunity", which drops installed community/custom modules;
update the flow to return the existing installedModuleIds if present instead of
an empty array—i.e., where the code sets const browseCommunity and then does if
(!browseCommunity) return [], change that branch to return installedModuleIds ||
[] (or otherwise merge/retain installedModuleIds) so installed community/custom
modules are preserved in modify/update mode; apply the same change to the
duplicate block referencing browseCommunity around the other occurrence (lines
~807-811).

---

Outside diff comments:
In `@tools/installer/ui.js`:
- Around line 618-625: The prompts.autocompleteMultiselect call (assigned to
selected) currently sets required: true which forces at least one official
module selection and prevents community-only or custom-only installs; change
this by removing or setting required to false on the
prompts.autocompleteMultiselect options and, if you need a guard, perform
validation after all three phases (official, community, custom) are completed
rather than blocking at this step so a user can skip official modules during
phase 1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5bb4b141-8316-4876-b6cf-caadddfa84d7

📥 Commits

Reviewing files that changed from the base of the PR and between 5e038a8 and 7c58c0b.

📒 Files selected for processing (8)
  • tools/installer/core/config.js
  • tools/installer/core/manifest.js
  • tools/installer/modules/community-manager.js
  • tools/installer/modules/custom-module-manager.js
  • tools/installer/modules/external-manager.js
  • tools/installer/modules/official-modules.js
  • tools/installer/modules/registry-client.js
  • tools/installer/ui.js

Comment on lines +835 to +846
// Check if this is a custom module (from user-provided URL)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return {
version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use customSource when loading the custom module version.

This branch already found the module in the custom cache, but _readMarketplaceVersion() still receives moduleSourcePath, which is usually null in status/update flows. That makes the helper fall back to the external-module cache and custom modules lose their version metadata.

💡 Proposed fix
-      const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
+      const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath || customSource);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if this is a custom module (from user-provided URL)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return {
version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
};
// Check if this is a custom module (from user-provided URL)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath || customSource);
return {
version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/core/manifest.js` around lines 835 - 846, The custom-module
branch currently finds customSource via CustomModuleManager but still calls
_readMarketplaceVersion(moduleName, moduleSourcePath), which can be null and
causes version lookup to use the wrong cache; change the call to pass the
discovered customSource (or its path/URL value) into _readMarketplaceVersion so
the helper reads version metadata from the custom module location. Update the
block around CustomModuleManager/customMgr/customSource and the return that sets
version to use the value returned by _readMarketplaceVersion when invoked with
customSource instead of moduleSourcePath.

Comment on lines +181 to +183
const cacheDir = this.getCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Validate registry fields before using them in paths and shell commands.

moduleCode, moduleInfo.url, and approvedSha all come from remote marketplace data here. moduleCode is used to build a local path, and url/sha are interpolated into execSync(...); a malformed registry entry can escape the cache directory or break out of the git command line locally. Validate these fields first, and call git with argv arrays (execFileSync/spawnSync) instead of shell strings.

Also applies to: 229-255

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/modules/community-manager.js` around lines 181 - 183,
Validate and sanitize registry-derived fields before using them: ensure
moduleCode contains only allowed characters (e.g., alphanumerics, hyphen,
underscore) and does not contain path separators or traversal sequences, then
build moduleCacheDir by resolving path.join(this.getCacheDir(), moduleCode) and
verifying the resolved path starts with the cacheDir prefix; validate
moduleInfo.url is a well-formed git URL (or matches an allowed host pattern) and
approvedSha matches a safe hex commit-ish pattern before using them; replace
execSync shell invocations that interpolate url/sha with execFileSync or
spawnSync using argument arrays (e.g., ['git', 'clone', moduleInfo.url,
moduleCacheDir'] and ['git', 'checkout', approvedSha']) to avoid shell
injection, and apply the same validation and execFileSync refactor to the other
occurrences around lines 229-255 (the git clone/checkout flows).

Comment on lines +303 to +307
if (moduleInfo.moduleDefinition) {
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
if (await fs.pathExists(configuredPath)) {
return path.dirname(configuredPath);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep moduleDefinition inside the cloned repo.

moduleInfo.moduleDefinition is registry-controlled, and path.join(cloneDir, moduleInfo.moduleDefinition) will walk out of cloneDir when it contains ... That lets a marketplace entry redirect installation to some other local directory that happens to contain a module.yaml. Resolve the path and verify it still sits under cloneDir before using or returning it.

Also applies to: 336-336

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/modules/community-manager.js` around lines 303 - 307, The
code currently uses path.join(cloneDir, moduleInfo.moduleDefinition) and returns
path.dirname(configuredPath) without ensuring the joined path is inside the
cloned repo; resolve the joined path (e.g., with path.resolve) and verify the
resolved configuredPath is still under cloneDir (e.g., compare
path.relative(cloneDir, resolvedPath) does not start with '..' or use startsWith
after normalizing) before using or returning it; if the check fails, treat it as
not found and continue. Apply the same fix for the other occurrence referenced
(around line 336) that uses moduleInfo.moduleDefinition and returning
path.dirname.

Comment on lines +25 to +45
validateGitHubUrl(url) {
if (!url || typeof url !== 'string') {
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
}

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)' };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

validateGitHubUrl() is too permissive for later path/shell use.

These regexes currently accept values like .., quotes, spaces, and query fragments in the owner/repo segments, and those values are later reused in path.join(cacheDir, owner, repo) and execSync("git clone ..."). That is a traversal/injection hazard, and it also rejects valid dotted repo names. Parse the URL structurally, reject ./.. and non-GitHub-safe characters, and stop invoking git through a shell.

Also applies to: 110-112, 148-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/modules/custom-module-manager.js` around lines 25 - 45, The
validateGitHubUrl function is too permissive and allows traversal/injection
vectors; update validateGitHubUrl to parse the URL structurally (use URL parsing
for https and explicit parsing for git@ SSH) and strictly validate that owner
and repo match a safe whitelist regex (e.g. only alphanumerics, hyphen,
underscore and dot: /^[A-Za-z0-9._-]+$/), explicitly reject '.' and '..', trim
any .git suffix from repo, and return isValid=false with an error for invalid
values; then update call sites that use path.join(cacheDir, owner, repo)
(references around the code using path.join at lines ~110-112) to assume
owner/repo are sanitized, and replace execSync("git clone ...") uses (references
around ~148-155) with a non-shell invocation (e.g. child_process.execFile or
spawn with args) to pass "git" and ["clone", repoUrl, targetPath"] as separate
arguments to avoid shell injection.

Comment on lines +647 to +651
const browseCommunity = await prompts.confirm({
message: 'Would you like to browse community modules?',
default: false,
});
if (!browseCommunity) return [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Skipping these prompts drops installed community/custom modules from modify flows.

In update mode installedModuleIds is already available, but answering “no” here returns []. Official modules keep their installed state through phase 1 initialValues; community/custom modules do not, so they fall out of the desired module list unless the user re-enters these flows.

Also applies to: 807-811

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/ui.js` around lines 647 - 651, The current prompt handler
returns [] when the user declines "browseCommunity", which drops installed
community/custom modules; update the flow to return the existing
installedModuleIds if present instead of an empty array—i.e., where the code
sets const browseCommunity and then does if (!browseCommunity) return [], change
that branch to return installedModuleIds || [] (or otherwise merge/retain
installedModuleIds) so installed community/custom modules are preserved in
modify/update mode; apply the same change to the duplicate block referencing
browseCommunity around the other occurrence (lines ~807-811).

Comment on lines +833 to +865
const plugins = await customMgr.discoverModules(url.trim());
s.stop('Module info loaded');

await prompts.log.warn(
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);

for (const plugin of plugins) {
const versionStr = plugin.version ? ` v${plugin.version}` : '';
await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
}

const confirmInstall = await prompts.confirm({
message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
default: false,
});

if (confirmInstall) {
// Pre-clone the repo so it's cached for the install pipeline
s.start('Cloning repository...');
try {
await customMgr.cloneRepo(url.trim());
s.stop('Repository cloned');
} catch (cloneError) {
s.error('Failed to clone repository');
await prompts.log.error(` ${cloneError.message}`);
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
continue;
}

for (const plugin of plugins) {
selectedModules.push(plugin.code);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep custom selection metadata, not just plugin.code.

This flow flattens each discovered plugin down to its code. Downstream resolution then has to scan the entire custom cache by code and take the first match, so two repos with the same plugin name — or a collision with an official/community code — can resolve to the wrong source. Persist { code, repoUrl, source } here; the new customModulesMeta field looks intended for that.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/ui.js` around lines 833 - 865, The code currently pushes only
plugin.code into selectedModules, losing source metadata and risking collisions;
change the selection step (inside the loop that iterates plugins after cloning)
to push an object that includes code, repoUrl (use the url.trim() used earlier),
and source (e.g., 'custom' or a value consistent with customModulesMeta) instead
of a string; ensure the rest of the flow that consumes selectedModules is
updated to expect objects (or populate the new customModulesMeta field
accordingly) so downstream resolution uses the stored repoUrl/source rather than
scanning the cache by code.

bmadcode added 5 commits April 8, 2026 00:20
- Remove redundant CommunityModuleManager instantiation in UI display
- Remove dead customModulesMeta field from Config (never populated)
- Add 35 unit tests for CustomModuleManager and CommunityModuleManager
  pure functions: URL validation, normalization, search, featured, categories
When a user does "Modify Installation" and declines to browse community
modules, previously installed community/custom modules are now auto-kept.
If the user does browse, their selections are trusted (they can deselect).

Also fix stale docs: class doc for SHA pinning, JSDoc return type.
Quick update now checks community registry and custom cache so installed
community/custom modules are updated instead of skipped.
When quick update encounters new config fields (e.g., from a newly
supported community module), use schema defaults silently instead of
prompting the user. Quick update should be non-interactive.
…e cases

Cover SHA normalization (set vs null/trusted), listByCategory,
getModuleByCode, and URL validation edge cases (HTTP, trailing slash,
SSH without .git). Total: 243 tests.
@bmadcode bmadcode merged commit b744408 into main Apr 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant