mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-09-19 08:11:52 +08:00
3d824d4c0f
* feat(installer): channel-based version resolution for external modules Adds stable/next/pinned channel resolution so external/community modules install at released git tags by default instead of tracking main HEAD. Manifest now records channel, resolved version, and SHA per module for reproducible installs. CLI flags: --channel, --all-stable, --all-next, --next=CODE (repeatable), --pin CODE=TAG (repeatable). Precedence: pin > next > channel > registry default > stable. --yes accepts patch/minor upgrades but refuses majors. Interactive "Ready to install (all stable)?" gate with a per-module picker (stable/next/pin) when declined. Re-install prompts classify tag diffs as patch/minor/major with semver-class-dependent defaults. Legacy version:null manifests get a one-time migration prompt. Custom modules gain an optional @<ref> URL suffix for pinning (https, ssh, /tree/<ref>/subdir forms supported; local paths rejected). Community modules honor --next/--pin overrides with a curator-bypass warning; default path still enforces the approved SHA. Quick-update now reads the manifest's recorded channel per module so pinned installs don't silently roll forward. * feat(installer): interactive channel switch, upgrade refusal, unified docs Builds on the channel-resolution foundation. The installer now lets users flip a module between stable, next, and pinned after install — either interactively via a "Review channel assignments?" gate, or by flag. Quick and modify re-installs classify stable upgrades; under non-interactive flows, patches and minors apply automatically but majors are refused with a pointer to --pin. Fallback behavior for GitHub rate-limit / network failures is now cache- aware: re-installs reuse the recorded ref silently; fresh installs abort with actionable guidance (set GITHUB_TOKEN or use --next/--pin). Bundled modules (core, bmm) warn when targeted by --pin or --next so users aren't left wondering why the flag had no effect. Install summary labels no longer mangle "main" into "vmain"; next-channel entries render as "main @ <short-sha>" instead. Bundled modules are now correctly skipped from all channel prompts and tag-API lookups. Docs consolidated into a single how-to. install-bmad.md now covers the interactive flow, the channel model (stable/next/pinned plus the npm dist-tag axis for core/bmm), the re-install upgrade prompts, the full flag reference, copy-paste recipes, and troubleshooting. The old non-interactive-installation.md is reduced to a redirect stub. * fix(installer): review fixes + unit tests for channel resolution - ui.js: import parseGitHubRepo; fixes ReferenceError in the interactive channel picker's stable-tag pre-resolve path. - community-manager: pinned modules now fetch+checkout the pin tag on cache refresh instead of resetting to origin/HEAD (was silently drifting to main on re-install). - channel-plan: parseChannelOptions returns acceptBypass so --yes auto-confirms the curator-bypass prompt; headless --next/--pin installs of community modules no longer hang. - community-manager: simplify recordedVersion (dead ternary branch). - custom-module-manager: drop "or sha" from the @<ref> comment (git clone --branch rejects raw SHAs); update-path fetches origin <ref> so /tree/<branch>/ URLs work too. - install-bmad.md: rename "Headless / CI installs" to "Headless CI installs" so the stub's #headless-ci-installs anchor resolves. - test/test-installer-channels.js: 83 unit tests for channel-plan and channel-resolver pure modules; wired into npm test as test:channels. * fix(installer): address CodeRabbit review findings - ui.js: skip stable-channel upgrade classification when the user has already declared intent via --pin/--next=/--channel or the review gate. Prevents the decline / major-refused / fetch-error branches from silently overwriting an explicit pin with prev.version. - external-manager.js: short-circuit cloneExternalModule when the requested plan matches an existing in-process resolution and the cache is valid. Avoids redundant resolveChannel() + git fetch on every same-plan lookup in a single install. - installer.js: fall back to CommunityModuleManager.getResolution() when no external resolution exists, so community module result rows carry newChannel/newSha instead of null under --next/--pin. - installer.js: don't label a module as "no change" when its version string is 'main'/'HEAD' — the SHA may have moved and preVersions doesn't track the prior SHA. Show "(refreshed)" instead. - official-modules.js: match versionInfo.version to the manifest's cloneRef || (hasGitClone ? 'main' : version) expression so summary lines report the cloned ref for git-backed custom installs. - install-bmad.md: clarify that sha is only written for git-backed modules and that rerunning the same --modules on another machine does not reproduce stable-channel installs — convert recorded tags into explicit --pin flags for cross-machine reproducibility.
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,
|
|
};
|