diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index d8b35c0d1..493ff657c 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -109,6 +109,12 @@ jobs: - name: Test agent compilation components run: npm run test:install + - name: Test bmad-module skill (source parsing + channels) + run: npm run test:skill-source + + - name: Test bmad-module skill (end-to-end install/update/remove) + run: npm run test:skill + - name: Validate file references run: npm run validate:refs diff --git a/package.json b/package.json index 3f9e89f70..a633bf9ba 100644 --- a/package.json +++ b/package.json @@ -40,13 +40,15 @@ "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 vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", + "quality": "npm run vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:skill-source && npm run test:skill && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", - "test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:channels && npm run test:skill-source && npm run test:skill && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", "test:ide-sync": "node test/test-ide-sync.js", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", + "test:skill": "bash src/core-skills/bmad-module/tests/integration.test.sh", + "test:skill-source": "node test/test-bmad-module-source.mjs", "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/src/core-skills/bmad-module/README.md b/src/core-skills/bmad-module/README.md index a7da3ed84..f31915eb6 100644 --- a/src/core-skills/bmad-module/README.md +++ b/src/core-skills/bmad-module/README.md @@ -18,10 +18,12 @@ bmad-module remove [--purge] bmad-module list [--json] ``` -`` accepts `owner/repo`, a full git URL, or a local path. +`` accepts `owner/repo`, a full git URL (`https://…`, `git@…`, `ssh://`, `git://`), or a local path. A git source may carry an `@` suffix and may be a browser-style deep link (`/tree|blob/[/]`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`); `parseSource` in `lib/source.mjs` extracts the embedded ref and a repo subdirectory, mirroring the installer's `custom-module-manager.js`. ## Behavior notes +- **Source resolution & caching.** Git sources are cloned into a shared cache at `~/.bmad/cache/custom-modules////` (with `.bmad-source.json` / `.bmad-channel.json` metadata), the same cache the full installer uses; a matching ref is reused, otherwise the clone is fetched/refreshed, and a fetch failure keeps the stale copy so installs work offline. The install then copies the module root (the subdir, if the URL named one) out of the cache into a throwaway temp tree to stage from — the cache is never mutated. Local sources are copied straight to the temp tree. See `lib/cache.mjs`. +- **Channels.** `lib/channel-resolver.mjs` (a `node:`-only port of the installer's `channel-resolver.js`) resolves `--channel`: `pinned` → an explicit `--ref`/`@ref`; `stable` → the latest non-prerelease GitHub release tag (falls back to `next` when there are no tags, the URL isn't GitHub, or the tags API is unreachable); `next` (the default for a bare git source) → the default branch. `update` re-resolves the channel the module was installed with. - **Source of truth** for what was installed is `_bmad/_config/files-manifest.csv` (per-file hashes) and `_bmad/_config/skill-manifest.csv` (one row per shipped skill). `manifest.yaml` carries the source/version/sha tuple. - **`update`** refuses to overwrite locally-modified files (hash mismatch against the recorded hash). Move overrides into `_bmad/custom//` and retry. - **`remove`** without `--purge` preserves `_bmad/custom//` so a re-install picks the customizations back up. `--purge` deletes them. Remove also prunes the module's skills from every configured IDE. @@ -34,7 +36,8 @@ bmad-module list [--json] The skill itself is a thin verb router (`SKILL.md`). `scripts/bmad-module.mjs` is a zero-import launcher that guards the import graph (a missing/corrupt runtime file becomes a documented exit code, not a raw stack trace); the verb dispatcher lives in `scripts/cli.mjs` and all filesystem work happens in the `lib/` modules. These carry **no registry dependencies** — important because the installer copies the skill into the IDE skills directories (e.g. `.claude/skills/bmad-module/`) without `node_modules` and never runs `npm install` there: - `manifest.yaml` is read/written with a **vendored copy of the real `yaml` library** (`lib/vendor/yaml.mjs`, regenerated by `lib/vendor/build-vendor.mjs`) so it stays byte-identical to BMAD core's writer. -- `semver` validity/range checks use a small `node:`-only helper (`lib/semver-lite.mjs`). +- `semver` validity/range checks **and** the version comparison the stable-channel resolver needs (`prerelease`, `compare`, `rcompare`) use a small `node:`-only helper (`lib/semver-lite.mjs`) instead of the `semver` package. +- The shared clone cache (`lib/cache.mjs`) and channel resolution (`lib/channel-resolver.mjs`) use only `node:child_process` / `node:https` so the skill needs no dependencies after distribution. ## Exit codes @@ -43,3 +46,5 @@ See `SKILL.md` for the full table. The script's stderr always names the conditio ## Tests Integration tests live in `tests/integration.test.sh` and run end-to-end on a fresh BMAD install. Fixtures for negative cases (collisions, path traversal, reserved codes) are under `tests/fixtures/`; legacy-format fixtures (strategy-1 module files, a reserved code, and the synthesize fallback) are under `tests/fixtures/examples/legacy/`. + +The pure (no-network) install plumbing — `parseSource` URL/`@ref`/subdir parsing, `semver-lite`, and the `channel-resolver` helpers — is unit-tested in `test/test-bmad-module-source.mjs` (run via `npm run test:skill-source`, included in `npm test`), kept at parity with the installer's `test/test-parse-source-urls.js` and `test/test-installer-channels.js`. diff --git a/src/core-skills/bmad-module/SKILL.md b/src/core-skills/bmad-module/SKILL.md index bfe83a9fe..ac1c352cf 100644 --- a/src/core-skills/bmad-module/SKILL.md +++ b/src/core-skills/bmad-module/SKILL.md @@ -33,8 +33,8 @@ If the verb is ambiguous (e.g. the user says "manage modules"), ASK which verb t ### Step 2 — Parse the args -- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref `, `--channel `, `--set .=` (override a module config answer; repeatable), `--module `, `--dry-run`. Use `--module ` only when a legacy marketplace.json repo defines more than one module: the script exits 20 listing the available codes, then re-run picking one. First-party legacy modules whose codes are reserved (`gds`, `bmm`, …) install on the legacy path; the same reserved code in a current-spec `plugin.json` is still rejected (exit 21). -- **update:** the user supplies `` (the `_bmad//` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--channel `, `--set .=`. +- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. A source may carry an `@` suffix (`owner/repo@v1.2.3`) and a git URL may be a browser-style deep link (`https://github.com/owner/repo/tree//`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`): the script extracts the ref and a repo subdirectory automatically, so a module living in a monorepo subfolder installs directly. Optional flags: `--ref `, `--channel `, `--set .=` (override a module config answer; repeatable), `--module `, `--dry-run`. Channels: `pinned` clones an explicit `--ref`/`@ref`; `stable` resolves the latest non-prerelease GitHub release tag (falls back to the default branch when there are no tags / the host isn't GitHub / the tags API is unreachable); `next` (the default for a bare git source) tracks the default branch. Use `--module ` only when a legacy marketplace.json repo defines more than one module: the script exits 20 listing the available codes, then re-run picking one. First-party legacy modules whose codes are reserved (`gds`, `bmm`, …) install on the legacy path; the same reserved code in a current-spec `plugin.json` is still rejected (exit 21). +- **update:** the user supplies `` (the `_bmad//` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--channel `, `--set .=`. Without overrides, update re-resolves the channel the module was installed with — a `stable` module moves to the latest release tag, a `pinned` module stays put unless `--ref` moves it, and a `next` module re-pulls the default branch. - **remove:** the user supplies ``. Use `--purge` only if they explicitly say "also remove customizations" or "purge". - **list:** no args. Use `--json` if the user asks for machine-readable. @@ -97,4 +97,8 @@ User: "What modules do I have installed?" → Run `… list`. No confirmation ne User: "Update the devlog module to v0.5.0" → Confirm, then run `… update devlog --ref v0.5.0`. +User: "Install the studio module on the stable channel" → Confirm, then run `… install acme/acme-studio --channel stable` (resolves the latest release tag). + +User: "Install the linter that lives in the tools/ folder of this monorepo" → Confirm, then run `… install https://github.com/acme/monorepo/tree/main/tools/linter` (ref + subdir are parsed from the URL). + User: "Remove the mdlint module and wipe its customizations too" → Confirm, then run `… remove mdlint --purge`. diff --git a/src/core-skills/bmad-module/scripts/install.mjs b/src/core-skills/bmad-module/scripts/install.mjs index 53471fed7..696d9cf5e 100644 --- a/src/core-skills/bmad-module/scripts/install.mjs +++ b/src/core-skills/bmad-module/scripts/install.mjs @@ -3,6 +3,7 @@ import { EXIT, BmadModuleError } from './lib/exit.mjs'; import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs'; import fsp from 'node:fs/promises'; import { parseSource, materializeSource } from './lib/source.mjs'; +import { resolveChannel } from './lib/channel-resolver.mjs'; import { readAndValidateManifest, validateManifestObject, hasBmadPluginJson } from './lib/plugin-json.mjs'; import { resolveLegacyModule } from './lib/legacy-resolver.mjs'; import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs'; @@ -28,9 +29,11 @@ export async function runInstall(opts) { } await ensureConfigDir(bmadDir); - // §2. Normalize + materialize source. + // §2. Normalize source, resolve the channel/ref to a concrete clone target, + // then materialize (clone into the shared cache and copy out a working tree). const descriptor = parseSource(opts.source); - const materialized = await materializeSource(descriptor, { ref: opts.ref || null }); + const target = await resolveCloneTarget(descriptor, opts); + const materialized = await materializeSource(descriptor, { ref: target.ref }); try { // §3. Read + validate the manifest. New-spec modules carry a @@ -130,11 +133,14 @@ export async function runInstall(opts) { // §7. Register in manifests. await addModuleToManifest(bmadDir, code, { - version: manifest.bmad.moduleVersion || manifest.version, + // Git installs record the resolved channel version (tag for stable/pinned, + // 'main' for next) like the full installer; local installs keep the + // module's declared version since there is no clone ref. + version: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version, repoUrl: descriptor.kind === 'git' ? descriptor.url : null, sha: materialized.sha, ref: materialized.ref, - channel: opts.channel || (opts.ref ? 'pinned' : descriptor.kind === 'git' ? 'next' : null), + channel: target.channel, rawSource: descriptor.rawInput, moduleName: manifest.name, }); @@ -189,6 +195,54 @@ export async function runInstall(opts) { } } +// Resolve a parsed source + CLI flags into a concrete clone target: +// { ref, channel, version } +// ref — git ref to clone (null = default branch / local source) +// channel — manifest channel tag: 'stable' | 'pinned' | 'next' | null (local) +// version — manifest version string for git installs (tag for stable/pinned, +// 'main' for next); null for local (caller uses the module version). +// +// Mirrors the installer's channel semantics: an explicit --ref (or an @ref / +// /tree/ parsed from the source) pins; --channel stable resolves the latest +// non-prerelease GitHub tag, falling back to next (with a warning) when there are +// no tags, the URL isn't a GitHub repo, or the tags API is unreachable. +export async function resolveCloneTarget(descriptor, opts) { + if (descriptor.kind !== 'git') { + return { ref: null, channel: null, version: null }; + } + + const explicitRef = opts.ref ?? descriptor.ref ?? null; + let channel = opts.channel || (explicitRef ? 'pinned' : 'next'); + + if (channel === 'pinned') { + if (explicitRef) { + return { ref: explicitRef, channel: 'pinned', version: explicitRef }; + } else { + process.stderr.write(`[bmad-module] warning: --channel pinned needs a --ref; falling back to next.\n`); + channel = 'next'; + } + } + + if (channel === 'stable') { + try { + const r = await resolveChannel({ channel: 'stable', repoUrl: descriptor.url }); + if (r.resolvedFallback) { + process.stderr.write( + `[bmad-module] note: no stable release found for ${descriptor.displayName} (${r.reason}); tracking the default branch.\n`, + ); + return { ref: null, channel: 'next', version: 'main' }; + } + return { ref: r.ref, channel: 'stable', version: r.version }; + } catch (e) { + process.stderr.write(`[bmad-module] warning: could not resolve stable channel (${e.message}); tracking the default branch.\n`); + return { ref: null, channel: 'next', version: 'main' }; + } + } + + // next + return { ref: null, channel: 'next', version: 'main' }; +} + // Shared post-copy completion for install and update: install JS deps, generate // the central config + agent roster, create declared working directories, and // rebuild the merged help catalog. Mirrors what the full installer does for a diff --git a/src/core-skills/bmad-module/scripts/lib/cache.mjs b/src/core-skills/bmad-module/scripts/lib/cache.mjs new file mode 100644 index 000000000..e4b3c63ab --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/cache.mjs @@ -0,0 +1,161 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { EXIT, BmadModuleError } from './exit.mjs'; + +const execFileP = promisify(execFile); +const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; + +// Shared clone cache for community modules — mirrors +// tools/installer/modules/custom-module-manager.js (getCacheDir + cloneRepo) so +// a skill-driven install reuses the same on-disk cache the CLI installer +// maintains. node:-only (execFile, not execSync+fs-extra); npm deps are NOT +// installed here — the skill installs them in _bmad// after the copy. + +export function getCacheDir() { + return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules'); +} + +// A ref must be a tag/branch name git can take as a positional argument. Reject +// option-like values so a crafted `--upload-pack=…` ref can't reach git. +function assertSafeRef(ref) { + if (!/^[\w.+/][\w.\-+/]*$/.test(ref)) { + throw new BmadModuleError(EXIT.USAGE, `unsafe git ref: ${ref}`); + } + return ref; +} + +async function pathExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function readJsonSafe(p) { + try { + return JSON.parse(await fs.readFile(p, 'utf8')); + } catch { + return null; + } +} + +async function git(args, cwd) { + return execFileP('git', args, { cwd, env: GIT_ENV, timeout: 120_000 }); +} + +async function revParseHead(cwd) { + try { + const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd }); + return stdout.trim(); + } catch { + return null; + } +} + +async function defaultBranch(cwd) { + try { + const { stdout } = await execFileP('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd }); + return stdout.trim().replace(/^origin\//, '') || 'main'; + } catch { + return 'main'; + } +} + +// Ensure the repo behind `descriptor` is cloned/refreshed in the shared cache at +// the requested `ref` (a tag/branch, or null for the default branch). Returns +// { repoDir, sha, ref }. Reuses an existing clone when its recorded version +// matches; re-clones on a version change; on a fetch failure against an existing +// clone, keeps the stale copy and warns (so installs work offline). +export async function ensureCachedRepo(descriptor, ref = null) { + if (descriptor.kind !== 'git') throw new BmadModuleError(EXIT.USAGE, `ensureCachedRepo requires a git source`); + if (!descriptor.cacheKey) throw new BmadModuleError(EXIT.USAGE, `git source has no cacheKey: ${descriptor.rawInput}`); + const effectiveRef = ref; + if (effectiveRef) assertSafeRef(effectiveRef); + + const repoDir = path.join(getCacheDir(), ...descriptor.cacheKey.split('/')); + await fs.mkdir(path.dirname(repoDir), { recursive: true }); + + // Existing cache at a different version → re-clone from scratch. + if (await pathExists(repoDir)) { + const meta = await readJsonSafe(path.join(repoDir, '.bmad-source.json')); + const cachedVersion = meta?.version || null; + if (effectiveRef !== cachedVersion) { + await fs.rm(repoDir, { recursive: true, force: true }); + } + } + + if (await pathExists(repoDir)) { + // Refresh the existing clone (same version as before). + try { + await git(['fetch', 'origin', '--depth', '1'], repoDir); + if (effectiveRef) { + await git(['fetch', '--depth', '1', 'origin', effectiveRef, '--no-tags'], repoDir); + await git(['checkout', '--quiet', 'FETCH_HEAD'], repoDir); + } else { + const branch = await defaultBranch(repoDir); + assertSafeRef(branch); + await git(['fetch', '--depth', '1', 'origin', branch], repoDir); + await git(['reset', '--hard', `origin/${branch}`], repoDir); + } + } catch (e) { + // Remote unreachable — keep the cached copy so the install still works. + process.stderr.write( + `[bmad-module] warning: could not refresh ${descriptor.displayName} (${e.stderr || e.message}). Using cached copy.\n`, + ); + } + } else { + // Fresh clone. + const args = ['clone', '--depth', '1']; + if (effectiveRef) args.push('--branch', effectiveRef); + args.push(descriptor.url, repoDir); + try { + await git(args); + } catch (e) { + await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {}); + const refSuffix = effectiveRef ? `@${effectiveRef}` : ''; + throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed for ${descriptor.url}${refSuffix}: ${e.stderr || e.message}`); + } + } + + const sha = await revParseHead(repoDir); + const branchForMeta = effectiveRef ? null : await defaultBranch(repoDir); + const now = new Date().toISOString(); + await fs.writeFile( + path.join(repoDir, '.bmad-source.json'), + JSON.stringify( + { + cloneUrl: descriptor.url, + cacheKey: descriptor.cacheKey, + displayName: descriptor.displayName, + version: effectiveRef || null, + rawInput: descriptor.rawInput, + sha, + clonedAt: now, + }, + null, + 2, + ), + 'utf8', + ); + await fs.writeFile( + path.join(repoDir, '.bmad-channel.json'), + JSON.stringify( + { + channel: effectiveRef ? 'pinned' : 'next', + version: effectiveRef || branchForMeta || 'main', + sha, + writtenAt: now, + }, + null, + 2, + ), + 'utf8', + ); + + return { repoDir, sha, ref: effectiveRef }; +} diff --git a/src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs b/src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs new file mode 100644 index 000000000..ee37f0631 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs @@ -0,0 +1,135 @@ +import https from 'node:https'; +import { valid, prerelease, rcompare } from './semver-lite.mjs'; + +// Channel resolver for community modules — decides which ref of a module to +// clone when no explicit version is supplied: +// - stable: highest pure-semver git tag (excludes -alpha/-beta/-rc) +// - next: default-branch HEAD +// - pinned: an explicit user-supplied tag/branch +// +// node:-only port of tools/installer/modules/channel-resolver.js (which uses the +// `semver` package + node:https). Uses lib/semver-lite.mjs to stay registry-free +// — the skill ships without node_modules. Talks only to the GitHub tags API and +// does semver math; clone logic lives in source.mjs. + +const GITHUB_API_BASE = 'https://api.github.com'; +const DEFAULT_TIMEOUT_MS = 10_000; +const USER_AGENT = 'bmad-method-installer'; + +// Per-process cache: 'owner/repo' => [{ tag, version }] sorted newest-first. +const tagCache = new Map(); + +// Parse a GitHub repo URL into { owner, repo }, or null for non-GitHub URLs. +export function parseGitHubRepo(url) { + if (!url || typeof url !== 'string') return null; + const trimmed = url + .trim() + .replace(/\.git$/, '') + .replace(/\/$/, ''); + const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i); + if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] }; + const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/]+)$/i); + if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] }; + return null; +} + +function fetchJson(url, { timeout = DEFAULT_TIMEOUT_MS } = {}) { + const headers = { + 'User-Agent': USER_AGENT, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + return new Promise((resolve, reject) => { + const req = https.get(url, { headers, timeout }, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + const err = new Error(`GitHub API ${res.statusCode} for ${url}: ${body.slice(0, 200)}`); + err.statusCode = res.statusCode; + return reject(err); + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(new Error(`Failed to parse GitHub response: ${error.message}`)); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error(`GitHub API request timed out: ${url}`)); + }); + }); +} + +// Strip a leading 'v' and return a valid non-prerelease semver, else null. +export function normalizeStableTag(tagName) { + if (typeof tagName !== 'string') return null; + const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName; + const v = valid(stripped); + if (!v) return null; + if (prerelease(v)) return null; // exclude prereleases + return v; +} + +// Fetch pure-semver tags (highest first) from a GitHub repo, cached per-process. +// Returns [{ tag, version }]: tag is the original ref (e.g. "v1.7.0"), version +// the cleaned semver ("1.7.0"). +export async function fetchStableTags(owner, repo, { timeout } = {}) { + const cacheKey = `${owner}/${repo}`; + if (tagCache.has(cacheKey)) return tagCache.get(cacheKey); + + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/tags?per_page=100`; + const raw = await fetchJson(url, { timeout }); + if (!Array.isArray(raw)) throw new TypeError(`Unexpected response from ${url}`); + + const stable = []; + for (const entry of raw) { + const version = normalizeStableTag(entry?.name); + if (version) stable.push({ tag: entry.name, version }); + } + stable.sort((a, b) => rcompare(a.version, b.version)); + tagCache.set(cacheKey, stable); + return stable; +} + +// Resolve a channel into a git-clonable ref. +// ref: ref for `git clone --branch`, or null for default-branch HEAD (next) +// version: tag name for stable/pinned, 'main' for next +// Falls back to next-channel semantics with resolvedFallback=true when stable +// turns up no tags or the URL isn't a GitHub repo. Throws on a pinned channel +// with no pin, or on a GitHub API error during stable resolution. +export async function resolveChannel({ channel, pin, repoUrl, timeout }) { + if (channel === 'pinned') { + if (!pin) throw new Error('resolveChannel: pinned channel requires a pin value'); + return { channel: 'pinned', ref: pin, version: pin, resolvedFallback: false }; + } + + if (channel === 'next') { + return { channel: 'next', ref: null, version: 'main', resolvedFallback: false }; + } + + if (channel === 'stable') { + const parsed = parseGitHubRepo(repoUrl); + if (!parsed) { + return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' }; + } + const tags = await fetchStableTags(parsed.owner, parsed.repo, { timeout }); + if (tags.length === 0) { + return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'no-stable-tags' }; + } + const top = tags[0]; + return { channel: 'stable', ref: top.tag, version: top.tag, resolvedFallback: false }; + } + + throw new Error(`resolveChannel: unknown channel '${channel}'`); +} + +// Test-only: clear the per-process tag cache. +export function _clearTagCache() { + tagCache.clear(); +} diff --git a/src/core-skills/bmad-module/scripts/lib/semver-lite.mjs b/src/core-skills/bmad-module/scripts/lib/semver-lite.mjs index a4c7b07a6..996a405c1 100644 --- a/src/core-skills/bmad-module/scripts/lib/semver-lite.mjs +++ b/src/core-skills/bmad-module/scripts/lib/semver-lite.mjs @@ -27,6 +27,71 @@ export function valid(version) { return `${m[1]}.${m[2]}.${m[3]}${m[4] ? `-${m[4]}` : ''}`; } +/** + * Mirror of `semver.prerelease()`: returns the array of dot-separated + * prerelease identifiers (numeric ones coerced to Number) for a valid version, + * or null when the version is invalid or has no prerelease. Used by the channel + * resolver to exclude `-alpha`/`-rc` tags from the stable channel. + */ +export function prerelease(version) { + if (typeof version !== 'string') return null; + const m = SEMVER_RE.exec(version.trim()); + if (!m || !m[4]) return null; + return m[4].split('.').map((id) => (/^\d+$/.test(id) ? Number(id) : id)); +} + +// Numeric compare of the three core fields, then prerelease precedence per +// SemVer §11. Returns -1, 0, or 1. Both inputs must be valid semver. +function compareValid(a, b) { + const ma = SEMVER_RE.exec(a); + const mb = SEMVER_RE.exec(b); + for (let i = 1; i <= 3; i++) { + const d = Number(ma[i]) - Number(mb[i]); + if (d !== 0) return d < 0 ? -1 : 1; + } + // A version WITH a prerelease has lower precedence than one without. + const pa = ma[4]; + const pb = mb[4]; + if (!pa && !pb) return 0; + if (!pa) return 1; + if (!pb) return -1; + const ia = pa.split('.'); + const ib = pb.split('.'); + const len = Math.min(ia.length, ib.length); + for (let i = 0; i < len; i++) { + if (ia[i] === ib[i]) continue; + const na = /^\d+$/.test(ia[i]); + const nb = /^\d+$/.test(ib[i]); + if (na && nb) return Number(ia[i]) < Number(ib[i]) ? -1 : 1; + // Numeric identifiers always have lower precedence than alphanumeric ones. + if (na) return -1; + if (nb) return 1; + return ia[i] < ib[i] ? -1 : 1; + } + // A larger set of prerelease fields wins when all preceding are equal. + return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1; +} + +/** + * Mirror of `semver.compare()`: -1/0/1 for ab. Returns null when + * either side is not valid semver (semver throws; callers here treat unknown as + * "don't reorder"). + */ +export function compare(a, b) { + const va = valid(a); + const vb = valid(b); + if (va === null || vb === null) return null; + return compareValid(va, vb); +} + +/** + * Mirror of `semver.rcompare()`: reverse of compare(), for sorting newest-first. + */ +export function rcompare(a, b) { + const c = compare(a, b); + return c === null ? 0 : -c; +} + // ---- range grammar ------------------------------------------------------- // A "partial" is a 1–3 segment version where each segment may be a number or an // x-range wildcard (x/X/*), with optional prerelease/build on the full form. diff --git a/src/core-skills/bmad-module/scripts/lib/source.mjs b/src/core-skills/bmad-module/scripts/lib/source.mjs index 84cbe3942..772d1a5d9 100644 --- a/src/core-skills/bmad-module/scripts/lib/source.mjs +++ b/src/core-skills/bmad-module/scripts/lib/source.mjs @@ -1,107 +1,236 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; import { copyDir } from './fs-safe.mjs'; +import { ensureCachedRepo } from './cache.mjs'; import { EXIT, BmadModuleError } from './exit.mjs'; -const execFileP = promisify(execFile); - const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/; +// A `@` tail is ref-shaped (no `:` so we never eat the auth +// segment of `git@host:…`). Raw commit SHAs are not supported — `git clone +// --branch` can't take them; pass a tag/branch or check the SHA out manually. +const REF_TAIL_RE = /^[\w.\-+/]+$/; // Normalize a `` argument from the CLI into a descriptor: -// { kind: 'local' | 'git', path?, url?, displayName, rawInput } -// Accepts: -// - owner/repo → GitHub HTTPS -// - https://… or git@… URL → as given -// - file://path → local -// - relative or absolute path → local (if it exists on disk) +// { kind: 'local' | 'git', path?, url?, subdir, ref, cacheKey, displayName, rawInput } +// `ref` is a branch/tag extracted from an explicit `@` suffix or an +// embedded browser-URL path (`…/tree/`); `subdir` is a module location +// inside the repo extracted from a deep-path / `?path=` URL. Accepts: +// - owner/repo[@ref] → GitHub HTTPS +// - https://…, http://…, git@…, ssh://, git:// → as given (ref/subdir parsed) +// - file://path → local +// - relative or absolute path → local (if it exists on disk) +// Mirrors tools/installer/modules/custom-module-manager.js#parseSource, adapted +// to the skill's throw-on-invalid contract and node:-only deps; keeps the skill's +// `owner/repo` shorthand, which the installer (URL/marketplace-driven) lacks. export function parseSource(input) { if (typeof input !== 'string' || !input.trim()) { throw new BmadModuleError(EXIT.USAGE, `source is required`); } - const raw = input.trim(); + const rawInput = input.trim(); - if (raw.startsWith('file://')) { - const p = decodeURI(raw.slice('file://'.length)); - return { kind: 'local', path: path.resolve(p), displayName: p, rawInput: raw }; + // Split off an optional @ suffix, but only when the part before the `@` + // looks like a complete repo reference — so we don't disturb `git@host:…` or + // an `@` that's part of the path. + let body = rawInput; + let ref = null; + const lastAt = rawInput.lastIndexOf('@'); + if (lastAt > 0) { + const candidate = rawInput.slice(lastAt + 1); + const before = rawInput.slice(0, lastAt); + if (REF_TAIL_RE.test(candidate) && !candidate.includes(':')) { + const beforeLooksLikeRepo = + before.startsWith('/') || + before.startsWith('./') || + before.startsWith('../') || + before.startsWith('~') || + before.startsWith('file://') || + /^(?:https?|ssh|git):\/\//i.test(before) || + /^git@[^:]+:.+/.test(before) || + GH_SHORT_RE.test(before); + if (beforeLooksLikeRepo) { + ref = candidate; + body = before; + } + } } - if ( - raw.startsWith('https://') || - raw.startsWith('http://') || - raw.startsWith('git@') || - raw.startsWith('ssh://') || - raw.startsWith('git://') - ) { - return { kind: 'git', url: raw, displayName: raw, rawInput: raw }; + if (body.startsWith('file://')) { + if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`); + const p = decodeURI(body.slice('file://'.length)); + return localDescriptor(path.resolve(p), p, rawInput); } - if (GH_SHORT_RE.test(raw)) { - const url = `https://github.com/${raw}`; - return { kind: 'git', url, displayName: raw, rawInput: raw }; + // Local path: starts with /, ./, ../, or ~. + if (body.startsWith('/') || body.startsWith('./') || body.startsWith('../') || body.startsWith('~')) { + if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`); + const expanded = body.startsWith('~') ? path.join(os.homedir(), body.slice(1)) : body; + return localDescriptor(path.resolve(expanded), body, rawInput); } - // Treat anything else as a local path. - return { kind: 'local', path: path.resolve(raw), displayName: raw, rawInput: raw }; + // SSH: git@host:owner/repo[.git] + const sshMatch = body.match(/^git@([^:]+):(.+?)\/([^/.]+?)(?:\.git)?$/); + if (sshMatch) { + const [, host, owner, repo] = sshMatch; + return { + kind: 'git', + url: body, + subdir: null, + ref, + cacheKey: `${host}/${owner}/${repo}`, + displayName: `${owner}/${repo}`, + rawInput, + }; + } + + // HTTP(S) / ssh:// / git:// URLs — parse with the URL API so any host, nested + // group, dotted repo name, or browse-link shape is handled host-agnostically. + if (/^(?:https?|ssh|git):\/\//i.test(body)) { + return parseUrlDescriptor(body, ref, rawInput); + } + + // owner/repo shorthand → GitHub HTTPS. + if (GH_SHORT_RE.test(body)) { + return { + kind: 'git', + url: `https://github.com/${body}`, + subdir: null, + ref, + cacheKey: `github.com/${body}`, + displayName: body, + rawInput, + }; + } + + throw new BmadModuleError(EXIT.USAGE, `not a valid module source (owner/repo, git URL, or local path): ${rawInput}`); } +function localDescriptor(absPath, displayName, rawInput) { + return { kind: 'local', path: absPath, subdir: null, ref: null, cacheKey: null, displayName, rawInput }; +} + +// Browser-style deep paths that embed a ref (branch/tag/commit) and optional +// subdirectory, across hosts: +// GitHub //tree|blob/[/] +// GitLab //-/tree|blob/[/] +// Gitea //src/[branch|commit|tag/][/] +// Group 1 = repo path prefix, 2 = ref, 3 = subdir (optional). +const DEEP_PATH_PATTERNS = [ + /^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/, + /^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/, +]; + +function parseUrlDescriptor(body, refFromSuffix, rawInput) { + let url; + try { + url = new URL(body); + } catch { + url = null; + } + if (!url || !url.host) { + throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`); + } + + const host = url.host; + let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); + let subdir = null; + let urlRef = null; + + for (const pattern of DEEP_PATH_PATTERNS) { + const m = repoPath.match(pattern); + if (m) { + repoPath = m[1]; + if (m[2]) urlRef = m[2]; + if (m[3]) { + const cleaned = m[3].replace(/\/+$/, ''); + if (cleaned) subdir = cleaned; + } + break; + } + } + + // Some hosts use ?path=/subdir on browse links. + if (!subdir) { + const pathParam = url.searchParams.get('path'); + if (pathParam) { + const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, ''); + if (cleaned) subdir = cleaned; + } + } + + const repoPathClean = repoPath.replace(/\.git$/i, ''); + if (!repoPathClean) { + throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`); + } + + const segments = repoPathClean.split('/').filter(Boolean); + const displayName = segments.length >= 2 ? `${segments.at(-2)}/${segments.at(-1)}` : segments.at(-1); + + return { + kind: 'git', + url: `${url.protocol}//${host}/${repoPathClean}`, + subdir, + // Explicit @ref suffix wins over an embedded /tree/ path segment. + ref: refFromSuffix || urlRef || null, + cacheKey: `${host}/${repoPathClean}`, + displayName, + rawInput, + }; +} + +// Files that should never be staged into _bmad// from a source tree. +const STAGE_IGNORE = (rel) => + rel === '.git' || + rel.startsWith('.git/') || + rel === 'node_modules' || + rel.startsWith('node_modules/') || + rel === '.bmad-source.json' || + rel === '.bmad-channel.json'; + // Resolve a parsed descriptor into a usable source directory on disk. -// For local sources, copies into a fresh temp dir so installation can stage -// without touching the user's working tree. For git, shallow-clones into a -// temp dir (no shared cache for v1 — keeps install deterministic and avoids -// stale checkouts). // -// Returns { dir, sha, ref, cleanup } where `sha` and `ref` are null for -// local sources and `cleanup()` removes the temp dir. +// Always returns a throwaway temp working copy so the install pipeline can write +// into it (e.g. synthesized module.yaml for legacy strategy 5) and stage from it +// without mutating the user's tree or the shared clone cache. +// - local: copies the directory into the temp working copy. +// - git: ensures the repo is in the shared cache (~/.bmad/cache/custom-modules), +// then copies the module root (the subdir if the source URL named one) out of +// the cache into the temp working copy. +// +// Returns { dir, sha, ref, cleanup } where `sha`/`ref` are null for local +// sources and `cleanup()` removes the temp working copy (never the cache). export async function materializeSource(descriptor, opts = {}) { - const { ref = null } = opts; const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-')); + const dir = path.join(tmpRoot, 'src'); + const cleanup = () => fs.rm(tmpRoot, { recursive: true, force: true }); if (descriptor.kind === 'local') { const srcStat = await fs.stat(descriptor.path).catch(() => null); if (!srcStat || !srcStat.isDirectory()) { + await cleanup(); throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`); } - const dir = path.join(tmpRoot, 'src'); - await copyDir( - descriptor.path, - dir, - (rel) => rel === '.git' || rel.startsWith('.git/') || rel === 'node_modules' || rel.startsWith('node_modules/'), - ); - return { - dir, - sha: null, - ref: null, - cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), - }; + await copyDir(descriptor.path, dir, STAGE_IGNORE); + return { dir, sha: null, ref: null, cleanup }; } - // git - const dir = path.join(tmpRoot, 'src'); - const args = ['clone', '--depth', '1']; - if (ref) args.push('--branch', ref); - args.push(descriptor.url, dir); + // git — explicit --ref/resolved channel wins over a ref parsed from the source. + const ref = opts.ref ?? descriptor.ref ?? null; + let cached; try { - await execFileP('git', args, { timeout: 120_000 }); + cached = await ensureCachedRepo(descriptor, ref); } catch (e) { - await fs.rm(tmpRoot, { recursive: true, force: true }); - throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed: ${e.stderr || e.message}`); + await cleanup(); + throw e; } - let sha = null; - try { - const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: dir }); - sha = stdout.trim(); - } catch { - // sha unknown — non-fatal, manifest will show null + const moduleRoot = descriptor.subdir ? path.join(cached.repoDir, descriptor.subdir) : cached.repoDir; + const rootStat = await fs.stat(moduleRoot).catch(() => null); + if (!rootStat || !rootStat.isDirectory()) { + await cleanup(); + throw new BmadModuleError(EXIT.USAGE, `subdirectory "${descriptor.subdir}" not found in ${descriptor.displayName}`); } - return { - dir, - sha, - ref, - cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), - }; + await copyDir(moduleRoot, dir, STAGE_IGNORE); + return { dir, sha: cached.sha, ref: cached.ref, cleanup }; } diff --git a/src/core-skills/bmad-module/scripts/update.mjs b/src/core-skills/bmad-module/scripts/update.mjs index f7bb2d936..9c23ec876 100644 --- a/src/core-skills/bmad-module/scripts/update.mjs +++ b/src/core-skills/bmad-module/scripts/update.mjs @@ -16,7 +16,7 @@ import { readSkillCanonicalIdsForModule, } from './lib/manifest-ops.mjs'; import { distributeToIdes } from './lib/ide-sync.mjs'; -import { finishModuleInstall } from './install.mjs'; +import { finishModuleInstall, resolveCloneTarget } from './install.mjs'; // Update one installed module (or all when opts.all is true). v1 semantics: // - Re-resolves the original source (or new --ref) and re-clones. @@ -54,7 +54,14 @@ async function updateOne(bmadDir, projectDir, entry, opts) { throw new BmadModuleError(EXIT.BAD_MANIFEST, `module ${code} has no rawSource in manifest.yaml — cannot re-resolve`); } const descriptor = parseSource(entry.rawSource); - const materialized = await materializeSource(descriptor, { ref: opts.ref || entry.ref || null }); + // Re-resolve against the channel/ref the module was installed with, unless the + // CLI overrides either. A `stable` entry re-resolves to the latest tag; a + // pinned entry stays put unless `--ref` moves it. + const target = await resolveCloneTarget(descriptor, { + ref: opts.ref ?? entry.ref ?? null, + channel: opts.channel ?? entry.channel ?? null, + }); + const materialized = await materializeSource(descriptor, { ref: target.ref }); try { // No-op fast path. @@ -117,11 +124,11 @@ async function updateOne(bmadDir, projectDir, entry, opts) { await removeSkillManifestRows(bmadDir, code); await removeFilesManifestRows(bmadDir, code); await addModuleToManifest(bmadDir, code, { - version: manifest.bmad.moduleVersion || manifest.version, + version: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version, repoUrl: descriptor.kind === 'git' ? descriptor.url : null, sha: materialized.sha, - ref: opts.ref || entry.ref, - channel: opts.channel || (opts.ref ? 'pinned' : entry.channel || (descriptor.kind === 'git' ? 'next' : null)), + ref: materialized.ref, + channel: target.channel, rawSource: descriptor.rawInput, moduleName: manifest.name, }); diff --git a/test/test-bmad-module-source.mjs b/test/test-bmad-module-source.mjs new file mode 100644 index 000000000..9c0cdf16e --- /dev/null +++ b/test/test-bmad-module-source.mjs @@ -0,0 +1,155 @@ +/** + * bmad-module skill — parseSource / semver-lite / channel-resolver unit tests. + * + * Covers the skill's pure (no-network, no-filesystem) install plumbing so the + * @ref / deep-path-URL / subdir parsing, the registry-free semver math, and the + * channel resolver stay at parity with the canonical installer + * (tools/installer/modules/custom-module-manager.js + channel-resolver.js, whose + * own behavior is pinned by test/test-parse-source-urls.js and + * test/test-installer-channels.js). + * + * Usage: node test/test-bmad-module-source.mjs + */ + +import { parseSource } from '../src/core-skills/bmad-module/scripts/lib/source.mjs'; +import { valid, prerelease, compare, rcompare, validRange } from '../src/core-skills/bmad-module/scripts/lib/semver-lite.mjs'; +import { parseGitHubRepo, normalizeStableTag } from '../src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs'; + +const colors = { reset: '', green: '', red: '', cyan: '', dim: '' }; +let passed = 0; +let failed = 0; + +function assert(cond, name, detail = '') { + if (cond) { + console.log(`${colors.green}✓${colors.reset} ${name}`); + passed++; + } else { + console.log(`${colors.red}✗${colors.reset} ${name}`); + if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`); + failed++; + } +} +const eq = (got, want, name) => + assert(JSON.stringify(got) === JSON.stringify(want), name, `got ${JSON.stringify(got)} want ${JSON.stringify(want)}`); +function throws(fn, name) { + try { + fn(); + assert(false, name, 'expected a throw'); + } catch { + assert(true, name); + } +} + +// ─── parseSource ──────────────────────────────────────────────────────────── +console.log(`\n${colors.cyan}parseSource${colors.reset}\n`); + +{ + const r = parseSource('owner/repo'); + eq( + [r.kind, r.url, r.ref, r.subdir, r.cacheKey], + ['git', 'https://github.com/owner/repo', null, null, 'github.com/owner/repo'], + 'owner/repo shorthand → GitHub HTTPS', + ); +} +{ + const r = parseSource('acme/devlog@v1.2.3'); + eq([r.url, r.ref], ['https://github.com/acme/devlog', 'v1.2.3'], 'owner/repo@ref strips suffix'); +} +{ + const r = parseSource('https://github.com/owner/repo/tree/main/pkg/foo'); + eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', 'pkg/foo'], 'GitHub /tree//'); +} +{ + const r = parseSource('https://github.com/owner/repo/tree/main'); + eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', null], 'GitHub /tree/ without subdir strips ref'); +} +{ + const r = parseSource('https://github.com/owner/repo/blob/v2.0.0/src'); + eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'v2.0.0', 'src'], 'GitHub /blob//'); +} +{ + const r = parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module'); + eq([r.url, r.ref, r.subdir], ['https://gitlab.com/group/subgroup/repo', 'main', 'src/module'], 'GitLab nested-group /-/tree'); +} +{ + const r = parseSource('https://gitea.example.com/owner/repo/src/branch/main'); + eq([r.url, r.subdir], ['https://gitea.example.com/owner/repo', null], 'Gitea /src/branch/ without subdir strips ref'); +} +{ + const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills'); + eq( + [r.url, r.subdir, r.cacheKey], + ['https://dev.azure.com/myorg/MyProject/_git/my-module', 'src/skills', 'dev.azure.com/myorg/MyProject/_git/my-module'], + 'Azure DevOps ?path= + full-path cacheKey', + ); +} +{ + const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git'); + eq(r.url, 'https://dev.azure.com/myorg/MyProject/_git/my-module', 'trailing .git stripped from cloneUrl'); +} +{ + const r = parseSource('git@github.com:owner/repo.git'); + eq( + [r.kind, r.url, r.cacheKey, r.displayName], + ['git', 'git@github.com:owner/repo.git', 'github.com/owner/repo', 'owner/repo'], + 'SSH URL preserved, @ not consumed', + ); +} +{ + const r = parseSource('https://git.example.com/owner/my.repo.name'); + eq([r.url, r.displayName], ['https://git.example.com/owner/my.repo.name', 'owner/my.repo.name'], 'dotted repo name preserved'); +} +{ + const r = parseSource('https://git.example.com/myorg/MyProject/_git/my-module'); + eq( + [r.cacheKey, r.displayName], + ['git.example.com/myorg/MyProject/_git/my-module', '_git/my-module'], + 'nested path cacheKey + last-two-segment displayName', + ); +} +throws(() => parseSource('./local@v1'), 'local path + @ref throws'); +throws(() => parseSource(' '), 'empty source throws'); +throws(() => parseSource('not a source'), 'garbage source throws'); + +// ─── semver-lite ────────────────────────────────────────────────────────────── +console.log(`\n${colors.cyan}semver-lite${colors.reset}\n`); + +eq(valid('v1.2.3'), '1.2.3', 'valid() strips leading v'); +eq(valid('1.2'), null, 'valid() rejects partial'); +eq(prerelease('1.0.0-rc.1'), ['rc', 1], 'prerelease() parses identifiers'); +eq(prerelease('1.0.0'), null, 'prerelease() null for release'); +eq(compare('1.0.0-alpha', '1.0.0'), -1, 'prerelease < release'); +eq(compare('1.0.0-alpha.1', '1.0.0-alpha.beta'), -1, 'numeric id < alphanumeric id'); +eq(compare('1.2.0', '1.10.0'), -1, 'numeric (not lexical) field compare'); +eq(compare('2.0.0', '2.0.0'), 0, 'equal versions'); +eq(compare('bad', '1.0.0'), null, 'compare() null on invalid'); +{ + const tags = [ + { tag: 'v1.0.0', version: '1.0.0' }, + { tag: 'v1.7.0', version: '1.7.0' }, + { tag: 'v1.2.0', version: '1.2.0' }, + ]; + tags.sort((a, b) => rcompare(a.version, b.version)); + eq( + tags.map((t) => t.tag), + ['v1.7.0', 'v1.2.0', 'v1.0.0'], + 'rcompare() sorts newest-first', + ); +} +assert(validRange('>=6.0.0') !== null, 'validRange() accepts a real range'); + +// ─── channel-resolver (pure helpers) ────────────────────────────────────────── +console.log(`\n${colors.cyan}channel-resolver${colors.reset}\n`); + +eq(parseGitHubRepo('https://github.com/o/r/tree/main'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from deep URL'); +eq(parseGitHubRepo('git@github.com:o/r'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from SSH'); +eq(parseGitHubRepo('https://gitlab.com/o/r'), null, 'parseGitHubRepo null for non-GitHub'); +eq( + [normalizeStableTag('v1.7.0'), normalizeStableTag('1.0.0-rc.1'), normalizeStableTag('nope')], + ['1.7.0', null, null], + 'normalizeStableTag excludes prereleases/invalid', +); + +// ─── Summary ────────────────────────────────────────────────────────────────── +console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`); +process.exit(failed > 0 ? 1 : 0);