242 lines
8.2 KiB
JavaScript
242 lines
8.2 KiB
JavaScript
const https = require('node:https');
|
|
const semver = require('semver');
|
|
|
|
/**
|
|
* Channel resolver for external and community modules.
|
|
*
|
|
* A "channel" is the resolution strategy that 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: main branch HEAD
|
|
* - pinned: an explicit user-supplied tag
|
|
*
|
|
* This module is pure (no prompts, no git, no filesystem). It only talks to
|
|
* the GitHub tags API and performs semver math. Clone logic lives in the
|
|
* module managers that call resolveChannel().
|
|
*/
|
|
|
|
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' => string[] sorted desc } of pure-semver tags.
|
|
const tagCache = new Map();
|
|
|
|
/**
|
|
* Parse a GitHub repo URL into { owner, repo }. Returns null if the URL is
|
|
* not a GitHub URL the resolver can handle.
|
|
*/
|
|
function parseGitHubRepo(url) {
|
|
if (!url || typeof url !== 'string') return null;
|
|
const trimmed = url
|
|
.trim()
|
|
.replace(/\.git$/, '')
|
|
.replace(/\/$/, '');
|
|
|
|
// https://github.com/owner/repo
|
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i);
|
|
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
|
|
// git@github.com:owner/repo
|
|
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 semver string, or null if the tag
|
|
* is not valid semver or is a prerelease (contains -alpha/-beta/-rc/etc.).
|
|
*/
|
|
function normalizeStableTag(tagName) {
|
|
if (typeof tagName !== 'string') return null;
|
|
const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName;
|
|
const valid = semver.valid(stripped);
|
|
if (!valid) return null;
|
|
// Exclude prereleases. semver.prerelease returns null for pure releases.
|
|
if (semver.prerelease(valid)) return null;
|
|
return valid;
|
|
}
|
|
|
|
/**
|
|
* Fetch pure-semver tags (highest first) from a GitHub repo.
|
|
* Cached per-process per owner/repo.
|
|
*
|
|
* @returns {Promise<Array<{tag: string, version: string}>>}
|
|
* tag is the original ref name (e.g. "v1.7.0"), version is the cleaned
|
|
* semver (e.g. "1.7.0").
|
|
*/
|
|
async function fetchStableTags(owner, repo, { timeout } = {}) {
|
|
const cacheKey = `${owner}/${repo}`;
|
|
if (tagCache.has(cacheKey)) return tagCache.get(cacheKey);
|
|
|
|
// GitHub returns up to 100 tags per page; one page is plenty for our modules.
|
|
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) => semver.rcompare(a.version, b.version));
|
|
|
|
tagCache.set(cacheKey, stable);
|
|
return stable;
|
|
}
|
|
|
|
/**
|
|
* Resolve a channel plan for a single module into a git-clonable ref.
|
|
*
|
|
* @param {Object} args
|
|
* @param {'stable'|'next'|'pinned'} args.channel
|
|
* @param {string} [args.pin] - Required when channel === 'pinned'
|
|
* @param {string} args.repoUrl - Module's git URL (for tag lookup)
|
|
* @returns {Promise<{channel, ref, version}>} where
|
|
* ref: the git ref to pass to `git clone --branch`, or null for HEAD (next)
|
|
* version: the resolved version string (tag name for stable/pinned, 'main' for next)
|
|
*
|
|
* Throws on:
|
|
* - pinned without a pin value
|
|
* - stable with no GitHub repo parseable from the URL (pass through to caller to fall back)
|
|
*
|
|
* Falls back to next-channel semantics and sets resolvedFallback=true when
|
|
* stable resolution turns up no tags.
|
|
*/
|
|
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) {
|
|
// No GitHub URL — caller must handle by falling back to next.
|
|
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' };
|
|
}
|
|
|
|
try {
|
|
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 };
|
|
} catch (error) {
|
|
// Propagate the error; callers decide whether to fall back or abort.
|
|
error.message = `Failed to resolve stable channel for ${parsed.owner}/${parsed.repo}: ${error.message}`;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw new Error(`resolveChannel: unknown channel '${channel}'`);
|
|
}
|
|
|
|
/**
|
|
* Verify that a specific tag exists in a GitHub repo. Used to validate
|
|
* --pin values before the user sits through a long clone that then fails.
|
|
*/
|
|
async function tagExists(owner, repo, tagName, { timeout } = {}) {
|
|
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tagName)}`;
|
|
try {
|
|
await fetchJson(url, { timeout });
|
|
return true;
|
|
} catch (error) {
|
|
if (error.statusCode === 404) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classify the semver delta between two versions.
|
|
* - 'none' → same version (or downgrade; treated same)
|
|
* - 'patch' → same major.minor, higher patch
|
|
* - 'minor' → same major, higher minor
|
|
* - 'major' → different major
|
|
* - 'unknown' → either version is not valid semver; caller should treat as major
|
|
*/
|
|
function classifyUpgrade(currentVersion, newVersion) {
|
|
const current = semver.valid(semver.coerce(currentVersion));
|
|
const next = semver.valid(semver.coerce(newVersion));
|
|
if (!current || !next) return 'unknown';
|
|
if (semver.lte(next, current)) return 'none';
|
|
const diff = semver.diff(current, next);
|
|
if (diff === 'patch') return 'patch';
|
|
if (diff === 'minor' || diff === 'preminor') return 'minor';
|
|
if (diff === 'major' || diff === 'premajor') return 'major';
|
|
// prepatch, prerelease — treat conservatively as minor (prereleases shouldn't
|
|
// normally surface here since stable channel filters them out).
|
|
return 'minor';
|
|
}
|
|
|
|
/**
|
|
* Build the GitHub release notes URL for a resolved tag.
|
|
* Returns null if the repo URL isn't a GitHub URL.
|
|
*/
|
|
function releaseNotesUrl(repoUrl, tag) {
|
|
const parsed = parseGitHubRepo(repoUrl);
|
|
if (!parsed || !tag) return null;
|
|
return `https://github.com/${parsed.owner}/${parsed.repo}/releases/tag/${encodeURIComponent(tag)}`;
|
|
}
|
|
|
|
/**
|
|
* Test-only: clear the per-process tag cache.
|
|
*/
|
|
function _clearTagCache() {
|
|
tagCache.clear();
|
|
}
|
|
|
|
module.exports = {
|
|
parseGitHubRepo,
|
|
fetchStableTags,
|
|
resolveChannel,
|
|
tagExists,
|
|
classifyUpgrade,
|
|
releaseNotesUrl,
|
|
normalizeStableTag,
|
|
_clearTagCache,
|
|
};
|