fix(installer): support ssh custom-source ports

This commit is contained in:
Loic Duong 2026-06-18 14:49:05 +07:00
parent 9d5739d992
commit 44f7572167
2 changed files with 96 additions and 4 deletions

View File

@ -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`);

View File

@ -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,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;
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 +439,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 +697,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;