Compare commits
4 Commits
44f7572167
...
c5f7de38e2
| Author | SHA1 | Date |
|---|---|---|
|
|
c5f7de38e2 | |
|
|
16ff771941 | |
|
|
ea557079f0 | |
|
|
87d194b93e |
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
{
|
||||
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) ────────────────────────
|
||||
|
||||
console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);
|
||||
|
|
|
|||
|
|
@ -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,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.
|
||||
// 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 +415,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 +442,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 +700,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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue