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/package.json b/package.json index a26398fdf..b77679c5b 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,12 @@ "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "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", - "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: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:skills": "node tools/validate-skills.js --strict" }, diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js new file mode 100644 index 000000000..9d01e7f53 --- /dev/null +++ b/test/test-parse-source-urls.js @@ -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 ////. + // 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/ 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);