fix(installer): handle deep-path URLs in custom module source parser

Rewrite parseSource() from host-specific regex to generic URL-based
parser so Azure DevOps _git paths and other multi-segment repo URLs
are preserved in cloneUrl and cacheKey.

Closes #2268
This commit is contained in:
Justin Loveless 2026-04-22 09:03:56 -07:00
parent 914c4edd6b
commit 4e5c930318
1 changed files with 80 additions and 25 deletions

View File

@ -73,41 +73,96 @@ class CustomModuleManager {
}; };
} }
// HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git] // HTTPS URL: generic handling for any Git host.
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/); // We avoid host-specific parsing — `git clone` will accept whatever URL the
if (httpsMatch) { // user provides. We only need to (a) separate an optional browser-style
const [, host, owner, repo, remainder] = httpsMatch; // subdir suffix from the clone URL, and (b) derive a cache key / display
const cloneUrl = `https://${host}/${owner}/${repo}`; // name from the path.
if (/^https?:\/\//i.test(trimmed)) {
let url;
try {
url = new URL(trimmed);
} catch {
url = null;
}
if (url && url.host) {
const host = url.host;
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
let subdir = null; let subdir = null;
if (remainder) { // Detect browser-style deep-path patterns that embed a subdirectory
// Extract subdir from deep path patterns used by various Git hosts // after a ref (branch/tag/commit). These appear across many hosts:
// GitHub /<repo>/tree|blob/<ref>/<subdir>
// GitLab /<repo>/-/tree|blob/<ref>/<subdir>
// Gitea /<repo>/src/<ref>/<subdir>
// Gitea /<repo>/src/(branch|commit|tag)/<ref>/<subdir>
// Group 1 = repo path prefix, Group 2 = subdir.
// Trailing subdir is optional: a URL like /<repo>/tree/<ref> with no
// further path still identifies a repo at a ref (no subdir).
const deepPathPatterns = [ const deepPathPatterns = [
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path /^(.+?)\/(?:-\/)?(?:tree|blob)\/[^/]+(?:\/(.+))?$/,
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree) /^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?[^/]+(?:\/(.+))?$/,
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
]; ];
for (const pattern of deepPathPatterns) { for (const pattern of deepPathPatterns) {
const match = remainder.match(pattern); const match = repoPath.match(pattern);
if (match) { if (match) {
subdir = match[1].replace(/\/$/, ''); // strip trailing slash repoPath = match[1];
if (match[2]) {
const cleaned = match[2].replace(/\/+$/, '');
if (cleaned) subdir = cleaned;
}
break; break;
} }
} }
// Some hosts use ?path=/subdir on browse links to point at a file or
// directory. Honor it when no deep-path marker matched above.
if (!subdir) {
const pathParam = url.searchParams.get('path');
if (pathParam) {
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
if (cleaned) subdir = cleaned;
} }
}
// Strip a single trailing .git for a stable cacheKey/displayName.
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 cloneUrl = `${url.protocol}//${host}/${repoPathClean}`;
const cacheKey = `${host}/${repoPathClean}`;
// Display name: prefer "<owner>/<repo>" using the last two meaningful
// path segments.
const segments = repoPathClean.split('/').filter(Boolean);
const repoSeg = segments.at(-1);
const ownerSeg = segments.at(-2);
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
return { return {
type: 'url', type: 'url',
cloneUrl, cloneUrl,
subdir, subdir,
localPath: null, localPath: null,
cacheKey: `${host}/${owner}/${repo}`, cacheKey,
displayName: `${owner}/${repo}`, displayName,
isValid: true, isValid: true,
error: null, error: null,
}; };
} }
}
return { return {
type: null, type: null,