Skip to content

Commit 87d194b

Browse files
committed
fix(installer): support ssh custom-source ports
1 parent 9d5739d commit 87d194b

2 files changed

Lines changed: 95 additions & 4 deletions

File tree

test/test-parse-source-urls.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,31 @@ console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.r
212212
assert(result.cloneUrl === 'git@github.com:owner/repo.git', 'SSH cloneUrl unchanged', `Got: ${result.cloneUrl}`);
213213
}
214214

215+
{
216+
const result = manager.parseSource('ssh://git@host:2222/path/repo.git');
217+
assert(result.isValid === true, 'SSH protocol URL with custom port is valid');
218+
assert(result.cloneUrl === 'ssh://git@host:2222/path/repo.git', 'SSH protocol custom-port cloneUrl unchanged', `Got: ${result.cloneUrl}`);
219+
assert(result.cacheKey === 'host:2222/path/repo', 'SSH protocol custom-port cacheKey includes port and path', `Got: ${result.cacheKey}`);
220+
assert(result.displayName === 'path/repo', 'SSH protocol custom-port displayName uses last two segments', `Got: ${result.displayName}`);
221+
}
222+
223+
{
224+
const result = manager.parseSource('ssh://git@host/owner/repo.git');
225+
assert(result.isValid === true, 'SSH protocol URL without custom port remains valid');
226+
assert(result.cacheKey === 'host/owner/repo', 'SSH protocol no-port cacheKey excludes port', `Got: ${result.cacheKey}`);
227+
}
228+
229+
{
230+
const result = manager.parseSource('ssh://git@host:2222/owner/repo.git@v1.2.3');
231+
assert(result.isValid === true, 'SSH protocol URL with custom port and @version is valid');
232+
assert(
233+
result.cloneUrl === 'ssh://git@host:2222/owner/repo.git',
234+
'SSH protocol @version cloneUrl strips suffix',
235+
`Got: ${result.cloneUrl}`,
236+
);
237+
assert(result.version === 'v1.2.3', 'SSH protocol @version suffix extracted', `Got: ${result.version}`);
238+
}
239+
215240
// ─── Generic URL handling (any host, any path depth) ────────────────────────
216241

217242
console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);

tools/installer/modules/custom-module-manager.js

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ function quoteCustomRef(ref) {
1111
return `"${ref}"`;
1212
}
1313

14+
function urlHasRepoPath(value) {
15+
try {
16+
const url = new URL(value);
17+
return Boolean(url.host && url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''));
18+
} catch {
19+
return false;
20+
}
21+
}
22+
1423
/**
1524
* Manages custom modules installed from user-provided sources.
1625
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@@ -88,7 +97,8 @@ class CustomModuleManager {
8897
before.startsWith('./') ||
8998
before.startsWith('../') ||
9099
before.startsWith('~') ||
91-
/^https?:\/\//i.test(before) ||
100+
(/^https?:\/\//i.test(before) && urlHasRepoPath(before)) ||
101+
(/^ssh:\/\//i.test(before) && urlHasRepoPath(before)) ||
92102
/^git@[^:]+:.+/.test(before);
93103
if (beforeLooksLikeRepo) {
94104
versionSuffix = candidate;
@@ -132,6 +142,51 @@ class CustomModuleManager {
132142
};
133143
}
134144

145+
// SSH protocol URL: ssh://git@host[:port]/owner/repo.git
146+
if (/^ssh:\/\//i.test(trimmed)) {
147+
let url;
148+
try {
149+
url = new URL(trimmed);
150+
} catch {
151+
url = null;
152+
}
153+
154+
if (url && url.host) {
155+
const repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
156+
const repoPathClean = repoPath.replace(/\.git$/i, '');
157+
if (!repoPathClean) {
158+
return {
159+
type: null,
160+
cloneUrl: null,
161+
subdir: null,
162+
localPath: null,
163+
cacheKey: null,
164+
displayName: null,
165+
isValid: false,
166+
error: 'Not a valid Git URL or local path',
167+
};
168+
}
169+
170+
const segments = repoPathClean.split('/').filter(Boolean);
171+
const repoSeg = segments.at(-1);
172+
const ownerSeg = segments.at(-2);
173+
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
174+
175+
return {
176+
type: 'url',
177+
cloneUrl: trimmed,
178+
subdir: null,
179+
localPath: null,
180+
version: versionSuffix || null,
181+
rawInput: trimmedRaw,
182+
cacheKey: `${url.host}/${repoPathClean}`,
183+
displayName,
184+
isValid: true,
185+
error: null,
186+
};
187+
}
188+
}
189+
135190
// HTTPS/HTTP URL: generic handling for any Git host.
136191
// We avoid host-specific parsing — `git clone` will accept whatever URL the
137192
// user provides. We only need to (a) separate an optional browser-style
@@ -357,6 +412,18 @@ class CustomModuleManager {
357412
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
358413
}
359414

415+
/**
416+
* Convert a stable cache key into filesystem-safe path segments.
417+
* Keep parseSource().cacheKey human-readable while avoiding invalid
418+
* characters such as ":" from custom SSH ports on Windows.
419+
* @param {string} cacheKey - Parsed cache key
420+
* @returns {string} Filesystem path for the cached clone
421+
*/
422+
_getRepoCacheDir(cacheKey) {
423+
const safeSegments = cacheKey.split('/').map((segment) => segment.replaceAll(':', '__port_'));
424+
return path.join(this.getCacheDir(), ...safeSegments);
425+
}
426+
360427
/**
361428
* Clone a custom module repository to cache.
362429
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
@@ -371,8 +438,7 @@ class CustomModuleManager {
371438
if (!parsed.isValid) throw new Error(parsed.error);
372439
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
373440

374-
const cacheDir = this.getCacheDir();
375-
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
441+
const repoCacheDir = this._getRepoCacheDir(parsed.cacheKey);
376442
const silent = options.silent || false;
377443
const displayName = parsed.displayName;
378444

@@ -630,7 +696,7 @@ class CustomModuleManager {
630696
if (parsed.type === 'local') {
631697
baseDir = parsed.localPath;
632698
} else {
633-
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
699+
baseDir = this._getRepoCacheDir(parsed.cacheKey);
634700
}
635701

636702
if (!(await fs.pathExists(baseDir))) return null;

0 commit comments

Comments
 (0)