diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js index 9d01e7f53..5662a077a 100644 --- a/test/test-parse-source-urls.js +++ b/test/test-parse-source-urls.js @@ -212,6 +212,31 @@ 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/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`); diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 8a5ea8863..77f3fe6ed 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -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. @@ -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; @@ -132,6 +142,51 @@ 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; + + return { + type: 'url', + cloneUrl: trimmed, + 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 @@ -357,6 +412,18 @@ class CustomModuleManager { return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules'); } + /** + * Convert a stable cache key into filesystem-safe path segments. + * Keep parseSource().cacheKey human-readable while avoiding invalid + * characters such as ":" from custom SSH ports on Windows. + * @param {string} cacheKey - Parsed cache key + * @returns {string} Filesystem path for the cached clone + */ + _getRepoCacheDir(cacheKey) { + const safeSegments = cacheKey.split('/').map((segment) => segment.replaceAll(':', '__port_')); + return path.join(this.getCacheDir(), ...safeSegments); + } + /** * Clone a custom module repository to cache. * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.). @@ -371,8 +438,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; @@ -630,7 +696,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;