This commit is contained in:
Tankatronic 2026-04-22 09:15:01 -07:00 committed by GitHub
commit 8b009ab51c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 381 additions and 65 deletions

42
package-lock.json generated
View File

@ -15,7 +15,6 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"commander": "^14.0.0", "commander": "^14.0.0",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"fs-extra": "^11.3.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -25,8 +24,8 @@
"yaml": "^2.7.0" "yaml": "^2.7.0"
}, },
"bin": { "bin": {
"bmad": "tools/bmad-npx-wrapper.js", "bmad": "tools/installer/bmad-cli.js",
"bmad-method": "tools/bmad-npx-wrapper.js" "bmad-method": "tools/installer/bmad-cli.js"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.6.0",
@ -46,6 +45,7 @@
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-packagejson": "^2.5.19", "prettier-plugin-packagejson": "^2.5.19",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"unist-util-visit": "^5.1.0",
"yaml-eslint-parser": "^1.2.3", "yaml-eslint-parser": "^1.2.3",
"yaml-lint": "^1.7.0" "yaml-lint": "^1.7.0"
}, },
@ -6975,20 +6975,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/fs-extra": {
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -7227,6 +7213,7 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/h3": { "node_modules/h3": {
@ -9066,18 +9053,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/katex": { "node_modules/katex": {
"version": "0.16.28", "version": "0.16.28",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz",
@ -13607,15 +13582,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",

View File

@ -39,11 +39,12 @@
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
"lint:md": "markdownlint-cli2 \"**/*.md\"", "lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills", "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check", "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run lint && npm run lint:md && npm run format:check",
"test:install": "node test/test-installation-components.js", "test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js", "test:refs": "node test/test-file-refs-csv.js",
"test:urls": "node test/test-parse-source-urls.js",
"validate:refs": "node tools/validate-file-refs.js --strict", "validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict" "validate:skills": "node tools/validate-skills.js --strict"
}, },

View File

@ -0,0 +1,294 @@
/**
* parseSource() URL parsing tests
*
* Verifies that CustomModuleManager.parseSource() correctly handles Git URLs
* across arbitrary hosts and path shapes (deep paths, nested groups, browse
* links, repo names containing dots, etc.) using host-agnostic rules.
*
* Usage: node test/test-parse-source-urls.js
*/
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
// ANSI colors
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, errorMessage = '') {
if (condition) {
console.log(`${colors.green}${colors.reset} ${testName}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${testName}`);
if (errorMessage) {
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
}
failed++;
}
}
const manager = new CustomModuleManager();
// ─── Deep path shapes (4+ segments) ─────────────────────────────────────────
console.log(`\n${colors.cyan}Deep path shapes${colors.reset}\n`);
{
// Hosts that expose the repo at a nested path like /<org>/<project>/<marker>/<repo>.
// The parser must preserve the full path (no stripping of intermediate segments).
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module');
assert(result.isValid === true, 'nested-path URL is valid');
assert(result.type === 'url', 'nested-path type is url');
assert(
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
'nested-path cloneUrl preserves full path',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === null, 'nested-path URL has no subdir');
assert(
result.cacheKey === 'git.example.com/myorg/MyProject/_git/my-module',
'nested-path cacheKey includes full repo path',
`Got: ${result.cacheKey}`,
);
assert(result.displayName === '_git/my-module', 'nested-path displayName uses last two segments', `Got: ${result.displayName}`);
}
{
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module.git');
assert(result.isValid === true, 'nested-path URL with .git suffix is valid');
assert(
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
'nested-path .git suffix stripped from cloneUrl',
`Got: ${result.cloneUrl}`,
);
}
{
// Browse links that use ?path=/... to point at a subdirectory.
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module?path=/path/to/subdir');
assert(result.isValid === true, 'URL with ?path= is valid');
assert(
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
'?path= cloneUrl excludes subdir',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === 'path/to/subdir', '?path= subdir correctly extracted', `Got: ${result.subdir}`);
}
// ─── Azure DevOps URLs (Issue #2268) ────────────────────────────────────────
console.log(`\n${colors.cyan}Azure DevOps URLs (Issue #2268)${colors.reset}\n`);
{
// Modern dev.azure.com format — the exact URL from the bug report.
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module');
assert(result.isValid === true, 'ADO modern URL is valid');
assert(result.type === 'url', 'ADO modern type is url');
assert(
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
'ADO modern cloneUrl preserves full _git path',
`Got: ${result.cloneUrl}`,
);
assert(
result.cacheKey === 'dev.azure.com/myorg/MyProject/_git/my-module',
'ADO modern cacheKey includes full path',
`Got: ${result.cacheKey}`,
);
assert(result.subdir === null, 'ADO modern URL has no subdir');
}
{
// Modern format with .git suffix
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git');
assert(result.isValid === true, 'ADO modern .git suffix is valid');
assert(
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
'ADO modern .git suffix stripped from cloneUrl',
`Got: ${result.cloneUrl}`,
);
}
{
// Modern format with ?path= subdir (browse link)
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills');
assert(result.isValid === true, 'ADO modern ?path= is valid');
assert(
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
'ADO modern ?path= cloneUrl excludes subdir',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === 'src/skills', 'ADO modern ?path= subdir extracted', `Got: ${result.subdir}`);
}
{
// Legacy visualstudio.com format
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module');
assert(result.isValid === true, 'ADO legacy URL is valid');
assert(
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
'ADO legacy cloneUrl preserves full path',
`Got: ${result.cloneUrl}`,
);
assert(
result.cacheKey === 'myorg.visualstudio.com/MyProject/_git/my-module',
'ADO legacy cacheKey includes full path',
`Got: ${result.cacheKey}`,
);
}
{
// Legacy format with .git suffix
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module.git');
assert(result.isValid === true, 'ADO legacy .git suffix is valid');
assert(
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
'ADO legacy .git suffix stripped from cloneUrl',
`Got: ${result.cloneUrl}`,
);
}
{
// Legacy format with ?path= subdir
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module?path=/src');
assert(result.isValid === true, 'ADO legacy ?path= is valid');
assert(
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
'ADO legacy ?path= cloneUrl excludes subdir',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === 'src', 'ADO legacy ?path= subdir extracted', `Got: ${result.subdir}`);
}
// ─── Subdomain hosts ────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}Subdomain hosts${colors.reset}\n`);
{
const result = manager.parseSource('https://myorg.example.com/MyProject/_git/my-module');
assert(result.isValid === true, 'subdomain URL is valid');
assert(result.type === 'url', 'subdomain type is url');
assert(
result.cloneUrl === 'https://myorg.example.com/MyProject/_git/my-module',
'subdomain cloneUrl preserves full path',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === null, 'subdomain URL has no subdir');
assert(
result.cacheKey === 'myorg.example.com/MyProject/_git/my-module',
'subdomain cacheKey includes full repo path',
`Got: ${result.cacheKey}`,
);
}
// ─── Simple owner/repo URLs (regression) ────────────────────────────────────
console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.reset}\n`);
{
const result = manager.parseSource('https://github.com/owner/repo');
assert(result.isValid === true, 'GitHub basic URL still valid');
assert(result.cloneUrl === 'https://github.com/owner/repo', 'GitHub cloneUrl unchanged', `Got: ${result.cloneUrl}`);
assert(result.cacheKey === 'github.com/owner/repo', 'GitHub cacheKey unchanged', `Got: ${result.cacheKey}`);
}
{
const result = manager.parseSource('https://github.com/owner/repo/tree/main/subdir');
assert(result.isValid === true, 'GitHub URL with tree path still valid');
assert(result.cloneUrl === 'https://github.com/owner/repo', 'GitHub tree URL cloneUrl correct', `Got: ${result.cloneUrl}`);
assert(result.subdir === 'subdir', 'GitHub tree subdir still extracted', `Got: ${result.subdir}`);
}
{
const result = manager.parseSource('git@github.com:owner/repo.git');
assert(result.isValid === true, 'SSH URL still valid');
assert(result.cloneUrl === 'git@github.com:owner/repo.git', 'SSH cloneUrl unchanged', `Got: ${result.cloneUrl}`);
}
// ─── Generic URL handling (any host, any path depth) ────────────────────────
console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);
{
// GitLab nested groups — the old 2-segment regex would have failed this.
const result = manager.parseSource('https://gitlab.com/group/subgroup/repo');
assert(result.isValid === true, 'GitLab nested-group URL is valid');
assert(
result.cloneUrl === 'https://gitlab.com/group/subgroup/repo',
'GitLab nested-group cloneUrl preserves full path',
`Got: ${result.cloneUrl}`,
);
assert(
result.cacheKey === 'gitlab.com/group/subgroup/repo',
'GitLab nested-group cacheKey includes full path',
`Got: ${result.cacheKey}`,
);
assert(result.displayName === 'subgroup/repo', 'GitLab nested-group displayName uses last two segments', `Got: ${result.displayName}`);
}
{
const result = manager.parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module');
assert(result.isValid === true, 'GitLab nested-group tree URL is valid');
assert(
result.cloneUrl === 'https://gitlab.com/group/subgroup/repo',
'GitLab nested-group tree cloneUrl excludes subdir',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === 'src/module', 'GitLab nested-group tree subdir extracted', `Got: ${result.subdir}`);
}
{
// Self-hosted host with a repo name containing dots — the old regex
// explicitly excluded dots from the repo segment.
const result = manager.parseSource('https://git.example.com/owner/my.repo.name');
assert(result.isValid === true, 'repo name with dots is valid');
assert(
result.cloneUrl === 'https://git.example.com/owner/my.repo.name',
'repo name with dots preserved in cloneUrl',
`Got: ${result.cloneUrl}`,
);
assert(result.displayName === 'owner/my.repo.name', 'repo name with dots preserved in displayName', `Got: ${result.displayName}`);
}
{
// Browser URL pointing at a ref with NO trailing subdir must still strip
// the /tree/<ref> segment from the clone URL.
const result = manager.parseSource('https://github.com/owner/repo/tree/main');
assert(result.isValid === true, 'tree URL without subdir is valid');
assert(
result.cloneUrl === 'https://github.com/owner/repo',
'tree URL without subdir strips ref from cloneUrl',
`Got: ${result.cloneUrl}`,
);
assert(result.subdir === null, 'tree URL without subdir yields null subdir', `Got: ${result.subdir}`);
assert(result.displayName === 'owner/repo', 'tree URL without subdir displayName is owner/repo', `Got: ${result.displayName}`);
}
{
// Same shape for GitLab's /-/tree form and Gitea's /src/branch form.
const gitlab = manager.parseSource('https://gitlab.com/group/repo/-/tree/main');
assert(
gitlab.cloneUrl === 'https://gitlab.com/group/repo' && gitlab.subdir === null,
'GitLab /-/tree/<ref> without subdir strips ref',
`Got: ${gitlab.cloneUrl} subdir=${gitlab.subdir}`,
);
const gitea = manager.parseSource('https://gitea.example.com/owner/repo/src/branch/main');
assert(
gitea.cloneUrl === 'https://gitea.example.com/owner/repo' && gitea.subdir === null,
'Gitea /src/branch/<ref> without subdir strips ref',
`Got: ${gitea.cloneUrl} subdir=${gitea.subdir}`,
);
}
// ─── Summary ────────────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`);
process.exit(failed > 0 ? 1 : 0);

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,