bmad-method 6.3.1-next.20 → 6.3.1-next.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ const https = require('node:https');
2
+ const semver = require('semver');
3
+
4
+ /**
5
+ * Channel resolver for external and community modules.
6
+ *
7
+ * A "channel" is the resolution strategy that decides which ref of a module
8
+ * to clone when no explicit version is supplied:
9
+ * - stable: highest pure-semver git tag (excludes -alpha/-beta/-rc)
10
+ * - next: main branch HEAD
11
+ * - pinned: an explicit user-supplied tag
12
+ *
13
+ * This module is pure (no prompts, no git, no filesystem). It only talks to
14
+ * the GitHub tags API and performs semver math. Clone logic lives in the
15
+ * module managers that call resolveChannel().
16
+ */
17
+
18
+ const GITHUB_API_BASE = 'https://api.github.com';
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+ const USER_AGENT = 'bmad-method-installer';
21
+
22
+ // Per-process cache: { 'owner/repo' => string[] sorted desc } of pure-semver tags.
23
+ const tagCache = new Map();
24
+
25
+ /**
26
+ * Parse a GitHub repo URL into { owner, repo }. Returns null if the URL is
27
+ * not a GitHub URL the resolver can handle.
28
+ */
29
+ function parseGitHubRepo(url) {
30
+ if (!url || typeof url !== 'string') return null;
31
+ const trimmed = url
32
+ .trim()
33
+ .replace(/\.git$/, '')
34
+ .replace(/\/$/, '');
35
+
36
+ // https://github.com/owner/repo
37
+ const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i);
38
+ if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
39
+
40
+ // git@github.com:owner/repo
41
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
42
+ if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
43
+
44
+ return null;
45
+ }
46
+
47
+ function fetchJson(url, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
48
+ const headers = {
49
+ 'User-Agent': USER_AGENT,
50
+ Accept: 'application/vnd.github+json',
51
+ 'X-GitHub-Api-Version': '2022-11-28',
52
+ };
53
+ if (process.env.GITHUB_TOKEN) {
54
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
55
+ }
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const req = https.get(url, { headers, timeout }, (res) => {
59
+ let body = '';
60
+ res.on('data', (chunk) => (body += chunk));
61
+ res.on('end', () => {
62
+ if (res.statusCode < 200 || res.statusCode >= 300) {
63
+ const err = new Error(`GitHub API ${res.statusCode} for ${url}: ${body.slice(0, 200)}`);
64
+ err.statusCode = res.statusCode;
65
+ return reject(err);
66
+ }
67
+ try {
68
+ resolve(JSON.parse(body));
69
+ } catch (error) {
70
+ reject(new Error(`Failed to parse GitHub response: ${error.message}`));
71
+ }
72
+ });
73
+ });
74
+ req.on('error', reject);
75
+ req.on('timeout', () => {
76
+ req.destroy();
77
+ reject(new Error(`GitHub API request timed out: ${url}`));
78
+ });
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Strip a leading 'v' and return a valid semver string, or null if the tag
84
+ * is not valid semver or is a prerelease (contains -alpha/-beta/-rc/etc.).
85
+ */
86
+ function normalizeStableTag(tagName) {
87
+ if (typeof tagName !== 'string') return null;
88
+ const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName;
89
+ const valid = semver.valid(stripped);
90
+ if (!valid) return null;
91
+ // Exclude prereleases. semver.prerelease returns null for pure releases.
92
+ if (semver.prerelease(valid)) return null;
93
+ return valid;
94
+ }
95
+
96
+ /**
97
+ * Fetch pure-semver tags (highest first) from a GitHub repo.
98
+ * Cached per-process per owner/repo.
99
+ *
100
+ * @returns {Promise<Array<{tag: string, version: string}>>}
101
+ * tag is the original ref name (e.g. "v1.7.0"), version is the cleaned
102
+ * semver (e.g. "1.7.0").
103
+ */
104
+ async function fetchStableTags(owner, repo, { timeout } = {}) {
105
+ const cacheKey = `${owner}/${repo}`;
106
+ if (tagCache.has(cacheKey)) return tagCache.get(cacheKey);
107
+
108
+ // GitHub returns up to 100 tags per page; one page is plenty for our modules.
109
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/tags?per_page=100`;
110
+ const raw = await fetchJson(url, { timeout });
111
+ if (!Array.isArray(raw)) {
112
+ throw new TypeError(`Unexpected response from ${url}`);
113
+ }
114
+
115
+ const stable = [];
116
+ for (const entry of raw) {
117
+ const version = normalizeStableTag(entry?.name);
118
+ if (version) stable.push({ tag: entry.name, version });
119
+ }
120
+ stable.sort((a, b) => semver.rcompare(a.version, b.version));
121
+
122
+ tagCache.set(cacheKey, stable);
123
+ return stable;
124
+ }
125
+
126
+ /**
127
+ * Resolve a channel plan for a single module into a git-clonable ref.
128
+ *
129
+ * @param {Object} args
130
+ * @param {'stable'|'next'|'pinned'} args.channel
131
+ * @param {string} [args.pin] - Required when channel === 'pinned'
132
+ * @param {string} args.repoUrl - Module's git URL (for tag lookup)
133
+ * @returns {Promise<{channel, ref, version}>} where
134
+ * ref: the git ref to pass to `git clone --branch`, or null for HEAD (next)
135
+ * version: the resolved version string (tag name for stable/pinned, 'main' for next)
136
+ *
137
+ * Throws on:
138
+ * - pinned without a pin value
139
+ * - stable with no GitHub repo parseable from the URL (pass through to caller to fall back)
140
+ *
141
+ * Falls back to next-channel semantics and sets resolvedFallback=true when
142
+ * stable resolution turns up no tags.
143
+ */
144
+ async function resolveChannel({ channel, pin, repoUrl, timeout }) {
145
+ if (channel === 'pinned') {
146
+ if (!pin) throw new Error('resolveChannel: pinned channel requires a pin value');
147
+ return { channel: 'pinned', ref: pin, version: pin, resolvedFallback: false };
148
+ }
149
+
150
+ if (channel === 'next') {
151
+ return { channel: 'next', ref: null, version: 'main', resolvedFallback: false };
152
+ }
153
+
154
+ if (channel === 'stable') {
155
+ const parsed = parseGitHubRepo(repoUrl);
156
+ if (!parsed) {
157
+ // No GitHub URL — caller must handle by falling back to next.
158
+ return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' };
159
+ }
160
+
161
+ try {
162
+ const tags = await fetchStableTags(parsed.owner, parsed.repo, { timeout });
163
+ if (tags.length === 0) {
164
+ return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'no-stable-tags' };
165
+ }
166
+ const top = tags[0];
167
+ return { channel: 'stable', ref: top.tag, version: top.tag, resolvedFallback: false };
168
+ } catch (error) {
169
+ // Propagate the error; callers decide whether to fall back or abort.
170
+ error.message = `Failed to resolve stable channel for ${parsed.owner}/${parsed.repo}: ${error.message}`;
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ throw new Error(`resolveChannel: unknown channel '${channel}'`);
176
+ }
177
+
178
+ /**
179
+ * Verify that a specific tag exists in a GitHub repo. Used to validate
180
+ * --pin values before the user sits through a long clone that then fails.
181
+ */
182
+ async function tagExists(owner, repo, tagName, { timeout } = {}) {
183
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tagName)}`;
184
+ try {
185
+ await fetchJson(url, { timeout });
186
+ return true;
187
+ } catch (error) {
188
+ if (error.statusCode === 404) return false;
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Classify the semver delta between two versions.
195
+ * - 'none' → same version (or downgrade; treated same)
196
+ * - 'patch' → same major.minor, higher patch
197
+ * - 'minor' → same major, higher minor
198
+ * - 'major' → different major
199
+ * - 'unknown' → either version is not valid semver; caller should treat as major
200
+ */
201
+ function classifyUpgrade(currentVersion, newVersion) {
202
+ const current = semver.valid(semver.coerce(currentVersion));
203
+ const next = semver.valid(semver.coerce(newVersion));
204
+ if (!current || !next) return 'unknown';
205
+ if (semver.lte(next, current)) return 'none';
206
+ const diff = semver.diff(current, next);
207
+ if (diff === 'patch') return 'patch';
208
+ if (diff === 'minor' || diff === 'preminor') return 'minor';
209
+ if (diff === 'major' || diff === 'premajor') return 'major';
210
+ // prepatch, prerelease — treat conservatively as minor (prereleases shouldn't
211
+ // normally surface here since stable channel filters them out).
212
+ return 'minor';
213
+ }
214
+
215
+ /**
216
+ * Build the GitHub release notes URL for a resolved tag.
217
+ * Returns null if the repo URL isn't a GitHub URL.
218
+ */
219
+ function releaseNotesUrl(repoUrl, tag) {
220
+ const parsed = parseGitHubRepo(repoUrl);
221
+ if (!parsed || !tag) return null;
222
+ return `https://github.com/${parsed.owner}/${parsed.repo}/releases/tag/${encodeURIComponent(tag)}`;
223
+ }
224
+
225
+ /**
226
+ * Test-only: clear the per-process tag cache.
227
+ */
228
+ function _clearTagCache() {
229
+ tagCache.clear();
230
+ }
231
+
232
+ module.exports = {
233
+ parseGitHubRepo,
234
+ fetchStableTags,
235
+ resolveChannel,
236
+ tagExists,
237
+ classifyUpgrade,
238
+ releaseNotesUrl,
239
+ normalizeStableTag,
240
+ _clearTagCache,
241
+ };
@@ -4,6 +4,8 @@ const path = require('node:path');
4
4
  const { execSync } = require('node:child_process');
5
5
  const prompts = require('../prompts');
6
6
  const { RegistryClient } = require('./registry-client');
7
+ const { decideChannelForModule } = require('./channel-plan');
8
+ const { parseGitHubRepo, tagExists } = require('./channel-resolver');
7
9
 
8
10
  const MARKETPLACE_OWNER = 'bmad-code-org';
9
11
  const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
@@ -15,13 +17,29 @@ const MARKETPLACE_REF = 'main';
15
17
  * Returns empty results when the registry is unreachable.
16
18
  * Community modules are pinned to approved SHA when set; uses HEAD otherwise.
17
19
  */
20
+ function quoteShellRef(ref) {
21
+ if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
22
+ throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
23
+ }
24
+ return `"${ref}"`;
25
+ }
26
+
18
27
  class CommunityModuleManager {
28
+ // moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator }
29
+ // Shared across all instances; the manifest writer often uses a fresh instance.
30
+ static _resolutions = new Map();
31
+
19
32
  constructor() {
20
33
  this._client = new RegistryClient();
21
34
  this._cachedIndex = null;
22
35
  this._cachedCategories = null;
23
36
  }
24
37
 
38
+ /** Get the most recent channel resolution for a community module. */
39
+ getResolution(moduleCode) {
40
+ return CommunityModuleManager._resolutions.get(moduleCode) || null;
41
+ }
42
+
25
43
  // ─── Data Loading ──────────────────────────────────────────────────────────
26
44
 
27
45
  /**
@@ -196,12 +214,49 @@ class CommunityModuleManager {
196
214
  return await prompts.spinner();
197
215
  };
198
216
 
199
- const sha = moduleInfo.approvedSha;
217
+ // ─── Resolve channel plan ──────────────────────────────────────────────
218
+ // Default community behavior (stable channel) honors the curator's
219
+ // approved SHA. --next=CODE and --pin CODE=TAG override the curator; we
220
+ // warn the user before bypassing the approved version.
221
+ const planEntry = decideChannelForModule({
222
+ code: moduleCode,
223
+ channelOptions: options.channelOptions,
224
+ registryDefault: 'stable',
225
+ });
226
+
227
+ const approvedSha = moduleInfo.approvedSha;
228
+ const approvedTag = moduleInfo.approvedTag;
229
+
230
+ let bypassedCurator = false;
231
+ if (planEntry.channel !== 'stable') {
232
+ bypassedCurator = true;
233
+ if (!silent) {
234
+ const approvedLabel = approvedTag || approvedSha || 'curator-approved version';
235
+ await prompts.log.warn(
236
+ `WARNING: Installing '${moduleCode}' from ${
237
+ planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD'
238
+ } bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`,
239
+ );
240
+ if (!options.channelOptions?.acceptBypass) {
241
+ const proceed = await prompts.confirm({
242
+ message: `Continue installing '${moduleCode}' with curator bypass?`,
243
+ default: false,
244
+ });
245
+ if (!proceed) {
246
+ throw new Error(`Install of community module '${moduleCode}' cancelled by user.`);
247
+ }
248
+ }
249
+ }
250
+ }
251
+
200
252
  let needsDependencyInstall = false;
201
253
  let wasNewClone = false;
202
254
 
203
255
  if (await fs.pathExists(moduleCacheDir)) {
204
- // Already cloned - update to latest HEAD
256
+ // Already cloned refresh to the correct ref for the resolved channel.
257
+ // A pinned install must not reset to origin/HEAD (it would silently drift
258
+ // to main on every re-install). Stable + approvedSha is handled below
259
+ // by the curator-SHA checkout logic.
205
260
  const fetchSpinner = await createSpinner();
206
261
  fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
207
262
  try {
@@ -211,10 +266,24 @@ class CommunityModuleManager {
211
266
  stdio: ['ignore', 'pipe', 'pipe'],
212
267
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
213
268
  });
214
- execSync('git reset --hard origin/HEAD', {
215
- cwd: moduleCacheDir,
216
- stdio: ['ignore', 'pipe', 'pipe'],
217
- });
269
+ if (planEntry.channel === 'pinned') {
270
+ // Fetch the pin tag specifically and check it out.
271
+ execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, {
272
+ cwd: moduleCacheDir,
273
+ stdio: ['ignore', 'pipe', 'pipe'],
274
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
275
+ });
276
+ execSync('git checkout --quiet FETCH_HEAD', {
277
+ cwd: moduleCacheDir,
278
+ stdio: ['ignore', 'pipe', 'pipe'],
279
+ });
280
+ } else {
281
+ // stable (approvedSha path re-checks out below) and next: track main.
282
+ execSync('git reset --hard origin/HEAD', {
283
+ cwd: moduleCacheDir,
284
+ stdio: ['ignore', 'pipe', 'pipe'],
285
+ });
286
+ }
218
287
  const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
219
288
  if (currentRef !== newRef) needsDependencyInstall = true;
220
289
  fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
@@ -231,10 +300,17 @@ class CommunityModuleManager {
231
300
  const fetchSpinner = await createSpinner();
232
301
  fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
233
302
  try {
234
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
235
- stdio: ['ignore', 'pipe', 'pipe'],
236
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
237
- });
303
+ if (planEntry.channel === 'pinned') {
304
+ execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
305
+ stdio: ['ignore', 'pipe', 'pipe'],
306
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
307
+ });
308
+ } else {
309
+ execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
310
+ stdio: ['ignore', 'pipe', 'pipe'],
311
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
312
+ });
313
+ }
238
314
  fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
239
315
  needsDependencyInstall = true;
240
316
  } catch (error) {
@@ -243,18 +319,19 @@ class CommunityModuleManager {
243
319
  }
244
320
  }
245
321
 
246
- // If pinned to a specific SHA, check out that exact commit.
247
- // Refuse to install if the approved SHA cannot be reached - security requirement.
248
- if (sha) {
322
+ // ─── Check out the resolved ref per channel ──────────────────────────
323
+ if (planEntry.channel === 'stable' && approvedSha) {
324
+ // Default path: pin to the curator-approved SHA. Refuse install if the SHA
325
+ // is unreachable (tag may have been deleted or rewritten) — security requirement.
249
326
  const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
250
- if (headSha !== sha) {
327
+ if (headSha !== approvedSha) {
251
328
  try {
252
- execSync(`git fetch --depth 1 origin ${sha}`, {
329
+ execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
253
330
  cwd: moduleCacheDir,
254
331
  stdio: ['ignore', 'pipe', 'pipe'],
255
332
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
256
333
  });
257
- execSync(`git checkout ${sha}`, {
334
+ execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
258
335
  cwd: moduleCacheDir,
259
336
  stdio: ['ignore', 'pipe', 'pipe'],
260
337
  });
@@ -262,12 +339,37 @@ class CommunityModuleManager {
262
339
  } catch {
263
340
  await fs.remove(moduleCacheDir);
264
341
  throw new Error(
265
- `Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
266
- `Installation refused for security. The module registry entry may need updating.`,
342
+ `Community module '${moduleCode}' could not be pinned to its approved commit (${approvedSha}). ` +
343
+ `Installation refused for security. The module registry entry may need updating, ` +
344
+ `or use --next=${moduleCode} / --pin ${moduleCode}=<tag> to explicitly bypass.`,
267
345
  );
268
346
  }
269
347
  }
348
+ } else if (planEntry.channel === 'stable' && !approvedSha) {
349
+ // Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior).
350
+ if (!silent) {
351
+ await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`);
352
+ }
353
+ } else if (planEntry.channel === 'pinned') {
354
+ // We cloned the tag directly above (via --branch), but ensure HEAD matches.
355
+ // No additional checkout needed.
270
356
  }
357
+ // else: 'next' channel — already at origin/HEAD from the fetch/reset above.
358
+
359
+ // Record the resolution so the manifest writer can pick up channel/version/sha.
360
+ const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
361
+ const recordedVersion =
362
+ planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7);
363
+ CommunityModuleManager._resolutions.set(moduleCode, {
364
+ channel: planEntry.channel,
365
+ version: recordedVersion,
366
+ sha: installedSha,
367
+ registryApprovedTag: approvedTag || null,
368
+ registryApprovedSha: approvedSha || null,
369
+ repoUrl: moduleInfo.url,
370
+ bypassedCurator,
371
+ planSource: planEntry.source,
372
+ });
271
373
 
272
374
  // Install dependencies if needed
273
375
  const packageJsonPath = path.join(moduleCacheDir, 'package.json');