From 658261aef5980a50d491eb45e312b63a4808a703 Mon Sep 17 00:00:00 2001 From: Tankatronic Date: Wed, 15 Apr 2026 12:13:49 -0700 Subject: [PATCH 1/4] Implement Azure DevOps URL parsing for repositories Added support for Azure DevOps URL parsing, including both modern and legacy formats, to extract relevant repository information. --- .../modules/custom-module-manager.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 482c4dc43..9d95b9101 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -73,6 +73,56 @@ class CustomModuleManager { }; } + // Azure DevOps URL: https://dev.azure.com/{org}/{project}/_git/{repo} + // Also supports legacy: https://{org}.visualstudio.com/{project}/_git/{repo} + const adoModernMatch = trimmed.match( + /^https?:\/\/(dev\.azure\.com)\/([^/]+)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?(\/.*)?$/, + ); + const adoLegacyMatch = + !adoModernMatch && + trimmed.match( + /^https?:\/\/([^/]+\.visualstudio\.com)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?(\/.*)?$/, + ); + const adoMatch = adoModernMatch || adoLegacyMatch; + if (adoMatch) { + let host, org, project, repo, remainder; + if (adoModernMatch) { + [, host, org, project, repo, remainder] = adoModernMatch; + } else { + // Legacy: org is in the hostname, path is /{project}/_git/{repo} + [, host, project, repo, remainder] = adoLegacyMatch; + org = null; + } + + const cloneUrl = adoModernMatch + ? `https://${host}/${org}/${project}/_git/${repo}` + : `https://${host}/${project}/_git/${repo}`; + let subdir = null; + + if (remainder) { + // Azure DevOps uses ?path=/subdir or /path/subdir patterns + const subdirMatch = remainder.match(/^\/(.+)$/); + if (subdirMatch) { + subdir = subdirMatch[1].replace(/\/$/, ''); + } + } + + const cacheKey = adoModernMatch + ? `${host}/${org}/${project}/${repo}` + : `${host}/${project}/${repo}`; + + return { + type: 'url', + cloneUrl, + subdir, + localPath: null, + cacheKey, + displayName: `${project}/${repo}`, + isValid: true, + error: null, + }; + } + // HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git] const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/); if (httpsMatch) { From e7bfb46191540abf1599b223355c250e6842fc9e Mon Sep 17 00:00:00 2001 From: Tankatronic Date: Wed, 15 Apr 2026 12:14:33 -0700 Subject: [PATCH 2/4] Add Azure DevOps URL parsing tests This test file verifies the functionality of the CustomModuleManager's parseSource method for Azure DevOps Git URLs, including both dev.azure.com and legacy visualstudio.com formats. It includes various assertions to check the validity and expected outputs for different URL formats. --- test/test-azure-devops-url-parsing.js | 159 ++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 test/test-azure-devops-url-parsing.js diff --git a/test/test-azure-devops-url-parsing.js b/test/test-azure-devops-url-parsing.js new file mode 100644 index 000000000..c7b3a6818 --- /dev/null +++ b/test/test-azure-devops-url-parsing.js @@ -0,0 +1,159 @@ +/** + * Azure DevOps URL Parsing Tests + * + * Verifies that CustomModuleManager.parseSource() correctly handles + * Azure DevOps Git URLs (dev.azure.com and legacy visualstudio.com). + * + * Fixes: https://github.com/bmad-code-org/BMAD-METHOD/issues/2268 + * Usage: node test/test-azure-devops-url-parsing.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(); + +// ─── Azure DevOps: dev.azure.com ──────────────────────────────────────────── + +console.log(`\n${colors.cyan}Azure DevOps URL parsing (dev.azure.com)${colors.reset}\n`); + +{ + const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module'); + assert(result.isValid === true, 'dev.azure.com basic URL is valid'); + assert(result.type === 'url', 'dev.azure.com type is url'); + assert( + result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', + 'dev.azure.com cloneUrl preserves full _git path', + `Got: ${result.cloneUrl}`, + ); + assert(result.subdir === null, 'dev.azure.com basic URL has no subdir'); + assert( + result.cacheKey === 'dev.azure.com/myorg/MyProject/my-module', + 'dev.azure.com cacheKey includes org/project/repo', + `Got: ${result.cacheKey}`, + ); + assert( + result.displayName === 'MyProject/my-module', + 'dev.azure.com displayName is project/repo', + `Got: ${result.displayName}`, + ); +} + +{ + const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git'); + assert(result.isValid === true, 'dev.azure.com URL with .git suffix is valid'); + assert( + result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', + 'dev.azure.com .git suffix stripped from cloneUrl', + `Got: ${result.cloneUrl}`, + ); +} + +{ + const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module/path/to/subdir'); + assert(result.isValid === true, 'dev.azure.com URL with subdir path is valid'); + assert( + result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', + 'dev.azure.com subdir URL cloneUrl excludes subdir', + `Got: ${result.cloneUrl}`, + ); + assert( + result.subdir === 'path/to/subdir', + 'dev.azure.com subdir correctly extracted', + `Got: ${result.subdir}`, + ); +} + +// ─── Azure DevOps: legacy visualstudio.com ────────────────────────────────── + +console.log(`\n${colors.cyan}Azure DevOps URL parsing (visualstudio.com)${colors.reset}\n`); + +{ + const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module'); + assert(result.isValid === true, 'visualstudio.com basic URL is valid'); + assert(result.type === 'url', 'visualstudio.com type is url'); + assert( + result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module', + 'visualstudio.com cloneUrl preserves full _git path', + `Got: ${result.cloneUrl}`, + ); + assert(result.subdir === null, 'visualstudio.com basic URL has no subdir'); + assert( + result.cacheKey === 'myorg.visualstudio.com/MyProject/my-module', + 'visualstudio.com cacheKey is host/project/repo', + `Got: ${result.cacheKey}`, + ); +} + +// ─── Non-Azure URLs still work ────────────────────────────────────────────── + +console.log(`\n${colors.cyan}Non-Azure HTTPS 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}`, + ); +} + +// ─── Summary ──────────────────────────────────────────────────────────────── + +console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`); +process.exit(failed > 0 ? 1 : 0); From 5b74cde45b397acf0e377f7f745cd4441fa51e0f Mon Sep 17 00:00:00 2001 From: Tankatronic Date: Wed, 15 Apr 2026 12:06:27 -0700 Subject: [PATCH 3/4] fix: handle Azure DevOps _git URLs in custom module source parser --- tools/installer/modules/custom-module-manager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 9d95b9101..9901dc253 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -73,7 +73,7 @@ class CustomModuleManager { }; } - // Azure DevOps URL: https://dev.azure.com/{org}/{project}/_git/{repo} + // Azure DevOps URL: https://dev.azure.com/{org}/{project}/_git/{repo} // Also supports legacy: https://{org}.visualstudio.com/{project}/_git/{repo} const adoModernMatch = trimmed.match( /^https?:\/\/(dev\.azure\.com)\/([^/]+)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?(\/.*)?$/, @@ -122,7 +122,7 @@ class CustomModuleManager { error: null, }; } - + // HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git] const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/); if (httpsMatch) { From d19de20c7d13df6f9716674b364b70b74183ba6e Mon Sep 17 00:00:00 2001 From: Tankatronic Date: Mon, 20 Apr 2026 09:49:42 -0700 Subject: [PATCH 4/4] remove ado specifics, apply general pattern --- package-lock.json | 42 +--- test/test-azure-devops-url-parsing.js | 159 ------------- test/test-parse-source-urls.js | 210 ++++++++++++++++++ .../modules/custom-module-manager.js | 151 +++++++------ 4 files changed, 292 insertions(+), 270 deletions(-) delete mode 100644 test/test-azure-devops-url-parsing.js create mode 100644 test/test-parse-source-urls.js diff --git a/package-lock.json b/package-lock.json index bfd60ee1e..d547eff9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "chalk": "^4.1.2", "commander": "^14.0.0", "csv-parse": "^6.1.0", - "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", "js-yaml": "^4.1.0", @@ -25,8 +24,8 @@ "yaml": "^2.7.0" }, "bin": { - "bmad": "tools/bmad-npx-wrapper.js", - "bmad-method": "tools/bmad-npx-wrapper.js" + "bmad": "tools/installer/bmad-cli.js", + "bmad-method": "tools/installer/bmad-cli.js" }, "devDependencies": { "@astrojs/sitemap": "^3.6.0", @@ -46,6 +45,7 @@ "prettier": "^3.7.4", "prettier-plugin-packagejson": "^2.5.19", "sharp": "^0.33.5", + "unist-util-visit": "^5.1.0", "yaml-eslint-parser": "^1.2.3", "yaml-lint": "^1.7.0" }, @@ -6975,20 +6975,6 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7227,6 +7213,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/h3": { @@ -9066,18 +9053,6 @@ "dev": true, "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": { "version": "0.16.28", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", @@ -13607,15 +13582,6 @@ "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": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", diff --git a/test/test-azure-devops-url-parsing.js b/test/test-azure-devops-url-parsing.js deleted file mode 100644 index c7b3a6818..000000000 --- a/test/test-azure-devops-url-parsing.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Azure DevOps URL Parsing Tests - * - * Verifies that CustomModuleManager.parseSource() correctly handles - * Azure DevOps Git URLs (dev.azure.com and legacy visualstudio.com). - * - * Fixes: https://github.com/bmad-code-org/BMAD-METHOD/issues/2268 - * Usage: node test/test-azure-devops-url-parsing.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(); - -// ─── Azure DevOps: dev.azure.com ──────────────────────────────────────────── - -console.log(`\n${colors.cyan}Azure DevOps URL parsing (dev.azure.com)${colors.reset}\n`); - -{ - const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module'); - assert(result.isValid === true, 'dev.azure.com basic URL is valid'); - assert(result.type === 'url', 'dev.azure.com type is url'); - assert( - result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', - 'dev.azure.com cloneUrl preserves full _git path', - `Got: ${result.cloneUrl}`, - ); - assert(result.subdir === null, 'dev.azure.com basic URL has no subdir'); - assert( - result.cacheKey === 'dev.azure.com/myorg/MyProject/my-module', - 'dev.azure.com cacheKey includes org/project/repo', - `Got: ${result.cacheKey}`, - ); - assert( - result.displayName === 'MyProject/my-module', - 'dev.azure.com displayName is project/repo', - `Got: ${result.displayName}`, - ); -} - -{ - const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git'); - assert(result.isValid === true, 'dev.azure.com URL with .git suffix is valid'); - assert( - result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', - 'dev.azure.com .git suffix stripped from cloneUrl', - `Got: ${result.cloneUrl}`, - ); -} - -{ - const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module/path/to/subdir'); - assert(result.isValid === true, 'dev.azure.com URL with subdir path is valid'); - assert( - result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module', - 'dev.azure.com subdir URL cloneUrl excludes subdir', - `Got: ${result.cloneUrl}`, - ); - assert( - result.subdir === 'path/to/subdir', - 'dev.azure.com subdir correctly extracted', - `Got: ${result.subdir}`, - ); -} - -// ─── Azure DevOps: legacy visualstudio.com ────────────────────────────────── - -console.log(`\n${colors.cyan}Azure DevOps URL parsing (visualstudio.com)${colors.reset}\n`); - -{ - const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module'); - assert(result.isValid === true, 'visualstudio.com basic URL is valid'); - assert(result.type === 'url', 'visualstudio.com type is url'); - assert( - result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module', - 'visualstudio.com cloneUrl preserves full _git path', - `Got: ${result.cloneUrl}`, - ); - assert(result.subdir === null, 'visualstudio.com basic URL has no subdir'); - assert( - result.cacheKey === 'myorg.visualstudio.com/MyProject/my-module', - 'visualstudio.com cacheKey is host/project/repo', - `Got: ${result.cacheKey}`, - ); -} - -// ─── Non-Azure URLs still work ────────────────────────────────────────────── - -console.log(`\n${colors.cyan}Non-Azure HTTPS 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}`, - ); -} - -// ─── Summary ──────────────────────────────────────────────────────────────── - -console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`); -process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js new file mode 100644 index 000000000..4e902ede3 --- /dev/null +++ b/test/test-parse-source-urls.js @@ -0,0 +1,210 @@ +/** + * 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 ////. + // 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}`); +} + +// ─── 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/ 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/ 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/ 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); diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 9901dc253..4f02a4a6f 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -73,90 +73,95 @@ class CustomModuleManager { }; } - // Azure DevOps URL: https://dev.azure.com/{org}/{project}/_git/{repo} - // Also supports legacy: https://{org}.visualstudio.com/{project}/_git/{repo} - const adoModernMatch = trimmed.match( - /^https?:\/\/(dev\.azure\.com)\/([^/]+)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?(\/.*)?$/, - ); - const adoLegacyMatch = - !adoModernMatch && - trimmed.match( - /^https?:\/\/([^/]+\.visualstudio\.com)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?(\/.*)?$/, - ); - const adoMatch = adoModernMatch || adoLegacyMatch; - if (adoMatch) { - let host, org, project, repo, remainder; - if (adoModernMatch) { - [, host, org, project, repo, remainder] = adoModernMatch; - } else { - // Legacy: org is in the hostname, path is /{project}/_git/{repo} - [, host, project, repo, remainder] = adoLegacyMatch; - org = null; + // HTTPS 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 + // subdir suffix from the clone URL, and (b) derive a cache key / display + // name from the path. + if (/^https?:\/\//i.test(trimmed)) { + let url; + try { + url = new URL(trimmed); + } catch { + url = null; } - const cloneUrl = adoModernMatch - ? `https://${host}/${org}/${project}/_git/${repo}` - : `https://${host}/${project}/_git/${repo}`; - let subdir = null; + if (url && url.host) { + const host = url.host; + let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); + let subdir = null; - if (remainder) { - // Azure DevOps uses ?path=/subdir or /path/subdir patterns - const subdirMatch = remainder.match(/^\/(.+)$/); - if (subdirMatch) { - subdir = subdirMatch[1].replace(/\/$/, ''); - } - } - - const cacheKey = adoModernMatch - ? `${host}/${org}/${project}/${repo}` - : `${host}/${project}/${repo}`; - - return { - type: 'url', - cloneUrl, - subdir, - localPath: null, - cacheKey, - displayName: `${project}/${repo}`, - isValid: true, - error: null, - }; - } - - // HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git] - const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/); - if (httpsMatch) { - const [, host, owner, repo, remainder] = httpsMatch; - const cloneUrl = `https://${host}/${owner}/${repo}`; - let subdir = null; - - if (remainder) { - // Extract subdir from deep path patterns used by various Git hosts + // Detect browser-style deep-path patterns that embed a subdirectory + // after a ref (branch/tag/commit). These appear across many hosts: + // GitHub //tree|blob// + // GitLab //-/tree|blob// + // Gitea //src// + // Gitea //src/(branch|commit|tag)// + // Group 1 = repo path prefix, Group 2 = subdir. + // Trailing subdir is optional: a URL like //tree/ with no + // further path still identifies a repo at a ref (no subdir). const deepPathPatterns = [ - /^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path - /^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree) - /^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path + /^(.+?)\/(?:-\/)?(?:tree|blob)\/[^/]+(?:\/(.+))?$/, + /^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?[^/]+(?:\/(.+))?$/, ]; - for (const pattern of deepPathPatterns) { - const match = remainder.match(pattern); + const match = repoPath.match(pattern); 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; } } - } - return { - type: 'url', - cloneUrl, - subdir, - localPath: null, - cacheKey: `${host}/${owner}/${repo}`, - displayName: `${owner}/${repo}`, - isValid: true, - error: null, - }; + // 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 "/" 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 { + type: 'url', + cloneUrl, + subdir, + localPath: null, + cacheKey, + displayName, + isValid: true, + error: null, + }; + } } return {