Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions test/test-parse-source-urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,48 @@ console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.r
assert(result.cloneUrl === 'git@github.com:owner/repo.git', 'SSH cloneUrl unchanged', `Got: ${result.cloneUrl}`);
}

{
const result = manager.parseSource('ssh://git@host:2222/path/repo.git');
assert(result.isValid === true, 'SSH protocol URL with custom port is valid');
assert(result.cloneUrl === 'ssh://git@host:2222/path/repo.git', 'SSH protocol custom-port cloneUrl unchanged', `Got: ${result.cloneUrl}`);
assert(result.cacheKey === 'host:2222/path/repo', 'SSH protocol custom-port cacheKey includes port and path', `Got: ${result.cacheKey}`);
assert(result.displayName === 'path/repo', 'SSH protocol custom-port displayName uses last two segments', `Got: ${result.displayName}`);
}

{
const result = manager.parseSource('ssh://git@host:2222/path/repo.git?foo=bar#readme');
assert(result.isValid === true, 'SSH protocol URL with query and hash is valid');
assert(result.cloneUrl === 'ssh://git@host:2222/path/repo.git', 'SSH protocol cloneUrl drops query and hash', `Got: ${result.cloneUrl}`);
assert(result.cacheKey === 'host:2222/path/repo', 'SSH protocol query/hash cacheKey ignores query and hash', `Got: ${result.cacheKey}`);
}

{
const result = manager.parseSource('ssh://git%40corp@host:2222/path/repo.git?foo=bar#readme');
assert(result.isValid === true, 'SSH protocol URL with encoded username is valid');
assert(
result.cloneUrl === 'ssh://git%40corp@host:2222/path/repo.git',
'SSH protocol cloneUrl preserves encoded username',
`Got: ${result.cloneUrl}`,
);
}

{
const result = manager.parseSource('ssh://git@host/owner/repo.git');
assert(result.isValid === true, 'SSH protocol URL without custom port remains valid');
assert(result.cacheKey === 'host/owner/repo', 'SSH protocol no-port cacheKey excludes port', `Got: ${result.cacheKey}`);
}

{
const result = manager.parseSource('ssh://git@host:2222/owner/repo.git@v1.2.3');
assert(result.isValid === true, 'SSH protocol URL with custom port and @version is valid');
assert(
result.cloneUrl === 'ssh://git@host:2222/owner/repo.git',
'SSH protocol @version cloneUrl strips suffix',
`Got: ${result.cloneUrl}`,
);
assert(result.version === 'v1.2.3', 'SSH protocol @version suffix extracted', `Got: ${result.version}`);
}

// ─── Generic URL handling (any host, any path depth) ────────────────────────

console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);
Expand Down
78 changes: 74 additions & 4 deletions tools/installer/modules/custom-module-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ function quoteCustomRef(ref) {
return `"${ref}"`;
}

function urlHasRepoPath(value) {
try {
const url = new URL(value);
return Boolean(url.host && url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''));
} catch {
return false;
}
}

/**
* Manages custom modules installed from user-provided sources.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
Expand Down Expand Up @@ -88,7 +97,8 @@ class CustomModuleManager {
before.startsWith('./') ||
before.startsWith('../') ||
before.startsWith('~') ||
/^https?:\/\//i.test(before) ||
(/^https?:\/\//i.test(before) && urlHasRepoPath(before)) ||
(/^ssh:\/\//i.test(before) && urlHasRepoPath(before)) ||
/^git@[^:]+:.+/.test(before);
if (beforeLooksLikeRepo) {
versionSuffix = candidate;
Expand Down Expand Up @@ -132,6 +142,54 @@ class CustomModuleManager {
};
}

// SSH protocol URL: ssh://git@host[:port]/owner/repo.git
if (/^ssh:\/\//i.test(trimmed)) {
let url;
try {
url = new URL(trimmed);
} catch {
url = null;
}

if (url && url.host) {
const repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
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 segments = repoPathClean.split('/').filter(Boolean);
const repoSeg = segments.at(-1);
const ownerSeg = segments.at(-2);
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
url.search = '';
url.hash = '';
const cloneUrl = url.toString();

return {
type: 'url',
cloneUrl,
subdir: null,
localPath: null,
version: versionSuffix || null,
rawInput: trimmedRaw,
cacheKey: `${url.host}/${repoPathClean}`,
displayName,
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
Expand Down Expand Up @@ -357,6 +415,19 @@ class CustomModuleManager {
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
}

/**
* Convert a stable cache key into filesystem-safe path segments.
* Preserve the historical on-disk layout except on Windows, where ":" from
* custom SSH ports is invalid inside a path segment.
* @param {string} cacheKey - Parsed cache key
* @returns {string} Filesystem path for the cached clone
*/
_getRepoCacheDir(cacheKey) {
const segments = cacheKey.split('/');
const safeSegments = process.platform === 'win32' ? segments.map((segment) => segment.replaceAll(':', '__port_')) : segments;
Comment thread
loicduong marked this conversation as resolved.
return path.join(this.getCacheDir(), ...safeSegments);
}

/**
* Clone a custom module repository to cache.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
Expand All @@ -371,8 +442,7 @@ class CustomModuleManager {
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 repoCacheDir = this._getRepoCacheDir(parsed.cacheKey);
const silent = options.silent || false;
const displayName = parsed.displayName;

Expand Down Expand Up @@ -630,7 +700,7 @@ class CustomModuleManager {
if (parsed.type === 'local') {
baseDir = parsed.localPath;
} else {
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
baseDir = this._getRepoCacheDir(parsed.cacheKey);
}

if (!(await fs.pathExists(baseDir))) return null;
Expand Down
Loading