From 87d194b93eb8bd4a6165a964477b9251a2b5946b Mon Sep 17 00:00:00 2001 From: Loic Duong Date: Thu, 18 Jun 2026 14:49:05 +0700 Subject: [PATCH 1/4] fix(installer): support ssh custom-source ports --- test/test-parse-source-urls.js | 25 +++++++ .../modules/custom-module-manager.js | 74 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) 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; From ea557079f0d20ff6437eb9d0eac2b8197403f193 Mon Sep 17 00:00:00 2001 From: Loic Duong Date: Fri, 19 Jun 2026 10:16:03 +0700 Subject: [PATCH 2/4] fix(installer): preserve posix custom-source cache paths --- tools/installer/modules/custom-module-manager.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 77f3fe6ed..5c47d0d8f 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -414,13 +414,14 @@ class CustomModuleManager { /** * 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. + * 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 safeSegments = cacheKey.split('/').map((segment) => segment.replaceAll(':', '__port_')); + const segments = cacheKey.split('/'); + const safeSegments = process.platform === 'win32' ? segments.map((segment) => segment.replaceAll(':', '__port_')) : segments; return path.join(this.getCacheDir(), ...safeSegments); } From 16ff771941e4578e18089c306ca25c2539039c8c Mon Sep 17 00:00:00 2001 From: Loic Duong Date: Fri, 19 Jun 2026 10:17:24 +0700 Subject: [PATCH 3/4] fix(installer): normalize ssh custom-source clone urls --- test/test-parse-source-urls.js | 7 +++++++ tools/installer/modules/custom-module-manager.js | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js index 5662a077a..98dec45aa 100644 --- a/test/test-parse-source-urls.js +++ b/test/test-parse-source-urls.js @@ -220,6 +220,13 @@ console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.r 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@host/owner/repo.git'); assert(result.isValid === true, 'SSH protocol URL without custom port remains valid'); diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 5c47d0d8f..6a4b68be6 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -171,10 +171,12 @@ class CustomModuleManager { const repoSeg = segments.at(-1); const ownerSeg = segments.at(-2); const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg; + const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ''}@` : ''; + const cloneUrl = `${url.protocol}//${auth}${url.host}/${repoPath}`; return { type: 'url', - cloneUrl: trimmed, + cloneUrl, subdir: null, localPath: null, version: versionSuffix || null, From c5f7de38e2cec9f4af2a932d975597416cd4f603 Mon Sep 17 00:00:00 2001 From: Loic Duong Date: Fri, 19 Jun 2026 10:27:58 +0700 Subject: [PATCH 4/4] fix(installer): preserve encoded ssh auth --- test/test-parse-source-urls.js | 10 ++++++++++ tools/installer/modules/custom-module-manager.js | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js index 98dec45aa..c453fd136 100644 --- a/test/test-parse-source-urls.js +++ b/test/test-parse-source-urls.js @@ -227,6 +227,16 @@ console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.r 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'); diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 6a4b68be6..f0488ca49 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -171,8 +171,9 @@ class CustomModuleManager { const repoSeg = segments.at(-1); const ownerSeg = segments.at(-2); const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg; - const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ''}@` : ''; - const cloneUrl = `${url.protocol}//${auth}${url.host}/${repoPath}`; + url.search = ''; + url.hash = ''; + const cloneUrl = url.toString(); return { type: 'url',