Merge c5f7de38e2 into 9d5739d992
This commit is contained in:
commit
581b2c284c
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue