This commit is contained in:
Loic Duong 2026-06-19 03:28:23 +00:00 committed by GitHub
commit 581b2c284c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 4 deletions

View File

@ -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}`); 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) ──────────────────────── // ─── Generic URL handling (any host, any path depth) ────────────────────────
console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`); console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);

View File

@ -11,6 +11,15 @@ function quoteCustomRef(ref) {
return `"${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. * Manages custom modules installed from user-provided sources.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths. * 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('../') || 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); /^git@[^:]+:.+/.test(before);
if (beforeLooksLikeRepo) { if (beforeLooksLikeRepo) {
versionSuffix = candidate; versionSuffix = candidate;
@ -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. // HTTPS/HTTP URL: generic handling for any Git host.
// We avoid host-specific parsing — `git clone` will accept whatever URL the // We avoid host-specific parsing — `git clone` will accept whatever URL the
// user provides. We only need to (a) separate an optional browser-style // user provides. We only need to (a) separate an optional browser-style
@ -357,6 +415,19 @@ class CustomModuleManager {
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules'); 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. * Clone a custom module repository to cache.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.). * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
@ -371,8 +442,7 @@ class CustomModuleManager {
if (!parsed.isValid) throw new Error(parsed.error); if (!parsed.isValid) throw new Error(parsed.error);
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths'); if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
const cacheDir = this.getCacheDir(); const repoCacheDir = this._getRepoCacheDir(parsed.cacheKey);
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
const silent = options.silent || false; const silent = options.silent || false;
const displayName = parsed.displayName; const displayName = parsed.displayName;
@ -630,7 +700,7 @@ class CustomModuleManager {
if (parsed.type === 'local') { if (parsed.type === 'local') {
baseDir = parsed.localPath; baseDir = parsed.localPath;
} else { } else {
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/')); baseDir = this._getRepoCacheDir(parsed.cacheKey);
} }
if (!(await fs.pathExists(baseDir))) return null; if (!(await fs.pathExists(baseDir))) return null;