bmad-method 6.3.1-next.8 → 6.4.0

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.
Files changed (141) hide show
  1. package/package.json +3 -2
  2. package/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +51 -36
  3. package/src/bmm-skills/1-analysis/bmad-agent-analyst/customize.toml +90 -0
  4. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md +50 -33
  5. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/customize.toml +81 -0
  6. package/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md +57 -1
  7. package/src/bmm-skills/1-analysis/bmad-document-project/customize.toml +41 -0
  8. package/src/bmm-skills/1-analysis/bmad-document-project/workflows/deep-dive-instructions.md +1 -0
  9. package/src/bmm-skills/1-analysis/bmad-document-project/workflows/full-scan-instructions.md +1 -0
  10. package/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md +48 -9
  11. package/src/bmm-skills/1-analysis/bmad-prfaq/customize.toml +41 -0
  12. package/src/bmm-skills/1-analysis/bmad-prfaq/references/verdict.md +4 -0
  13. package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +44 -9
  14. package/src/bmm-skills/1-analysis/bmad-product-brief/customize.toml +47 -0
  15. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/contextual-discovery.md +8 -7
  16. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/draft-and-review.md +6 -5
  17. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/finalize.md +4 -1
  18. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/guided-elicitation.md +3 -2
  19. package/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md +91 -1
  20. package/src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml +41 -0
  21. package/src/bmm-skills/1-analysis/research/bmad-domain-research/domain-steps/step-06-research-synthesis.md +6 -0
  22. package/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md +91 -1
  23. package/src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml +41 -0
  24. package/src/bmm-skills/1-analysis/research/bmad-market-research/steps/step-06-research-completion.md +6 -0
  25. package/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md +91 -1
  26. package/src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml +41 -0
  27. package/src/bmm-skills/1-analysis/research/bmad-technical-research/technical-steps/step-06-research-synthesis.md +6 -0
  28. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md +50 -35
  29. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/customize.toml +85 -0
  30. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md +50 -31
  31. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/customize.toml +60 -0
  32. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md +99 -1
  33. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml +41 -0
  34. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/steps-c/step-12-complete.md +6 -0
  35. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md +70 -1
  36. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml +41 -0
  37. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-14-complete.md +6 -0
  38. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md +97 -1
  39. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml +42 -0
  40. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +2 -0
  41. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md +99 -1
  42. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml +42 -0
  43. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-13-report-complete.md +1 -0
  44. package/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +50 -30
  45. package/src/bmm-skills/3-solutioning/bmad-agent-architect/customize.toml +65 -0
  46. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md +86 -1
  47. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml +41 -0
  48. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-06-final-assessment.md +6 -0
  49. package/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md +69 -1
  50. package/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml +41 -0
  51. package/src/bmm-skills/3-solutioning/bmad-create-architecture/steps/step-08-complete.md +6 -0
  52. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md +88 -1
  53. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml +41 -0
  54. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/steps/step-04-final-validation.md +6 -0
  55. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md +76 -1
  56. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml +41 -0
  57. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/steps/step-03-complete.md +6 -0
  58. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +48 -43
  59. package/src/bmm-skills/4-implementation/bmad-agent-dev/customize.toml +90 -0
  60. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +46 -7
  61. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml +41 -0
  62. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +6 -0
  63. package/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md +85 -1
  64. package/src/bmm-skills/4-implementation/bmad-code-review/customize.toml +41 -0
  65. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-04-present.md +6 -0
  66. package/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md +296 -1
  67. package/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml +41 -0
  68. package/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md +424 -1
  69. package/src/bmm-skills/4-implementation/bmad-create-story/customize.toml +41 -0
  70. package/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md +480 -1
  71. package/src/bmm-skills/4-implementation/bmad-dev-story/customize.toml +41 -0
  72. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md +171 -1
  73. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml +41 -0
  74. package/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +106 -1
  75. package/src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml +41 -0
  76. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md +6 -0
  77. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +6 -0
  78. package/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md +1507 -1
  79. package/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml +41 -0
  80. package/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md +294 -1
  81. package/src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml +41 -0
  82. package/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md +292 -1
  83. package/src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml +41 -0
  84. package/src/bmm-skills/module.yaml +49 -0
  85. package/src/core-skills/bmad-advanced-elicitation/SKILL.md +7 -1
  86. package/src/core-skills/bmad-customize/SKILL.md +111 -0
  87. package/src/core-skills/bmad-customize/scripts/list_customizable_skills.py +231 -0
  88. package/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py +249 -0
  89. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
  90. package/src/core-skills/bmad-party-mode/SKILL.md +13 -10
  91. package/src/core-skills/module-help.csv +1 -0
  92. package/src/core-skills/module.yaml +2 -0
  93. package/src/scripts/resolve_config.py +176 -0
  94. package/src/scripts/resolve_customization.py +230 -0
  95. package/tools/installer/commands/install.js +13 -0
  96. package/tools/installer/core/config.js +4 -1
  97. package/tools/installer/core/install-paths.js +11 -5
  98. package/tools/installer/core/installer.js +181 -94
  99. package/tools/installer/core/manifest-generator.js +339 -184
  100. package/tools/installer/core/manifest.js +86 -86
  101. package/tools/installer/fs-native.js +5 -0
  102. package/tools/installer/ide/platform-codes.yaml +6 -0
  103. package/tools/installer/modules/channel-plan.js +203 -0
  104. package/tools/installer/modules/channel-resolver.js +241 -0
  105. package/tools/installer/modules/community-manager.js +130 -23
  106. package/tools/installer/modules/custom-module-manager.js +160 -19
  107. package/tools/installer/modules/external-manager.js +235 -32
  108. package/tools/installer/modules/official-modules.js +58 -12
  109. package/tools/installer/modules/registry-client.js +139 -7
  110. package/tools/installer/modules/registry-fallback.yaml +8 -0
  111. package/tools/installer/modules/version-resolver.js +336 -0
  112. package/tools/installer/project-root.js +54 -0
  113. package/tools/installer/ui.js +561 -50
  114. package/tools/platform-codes.yaml +6 -0
  115. package/src/bmm-skills/1-analysis/bmad-agent-analyst/bmad-skill-manifest.yaml +0 -11
  116. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/bmad-skill-manifest.yaml +0 -11
  117. package/src/bmm-skills/1-analysis/bmad-document-project/workflow.md +0 -25
  118. package/src/bmm-skills/1-analysis/research/bmad-domain-research/workflow.md +0 -51
  119. package/src/bmm-skills/1-analysis/research/bmad-market-research/workflow.md +0 -51
  120. package/src/bmm-skills/1-analysis/research/bmad-technical-research/workflow.md +0 -52
  121. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/bmad-skill-manifest.yaml +0 -11
  122. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/bmad-skill-manifest.yaml +0 -11
  123. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/workflow.md +0 -61
  124. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/workflow.md +0 -35
  125. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/workflow.md +0 -62
  126. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/workflow.md +0 -61
  127. package/src/bmm-skills/3-solutioning/bmad-agent-architect/bmad-skill-manifest.yaml +0 -11
  128. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +0 -47
  129. package/src/bmm-skills/3-solutioning/bmad-create-architecture/workflow.md +0 -32
  130. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +0 -51
  131. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/workflow.md +0 -39
  132. package/src/bmm-skills/4-implementation/bmad-agent-dev/bmad-skill-manifest.yaml +0 -11
  133. package/src/bmm-skills/4-implementation/bmad-code-review/workflow.md +0 -55
  134. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +0 -267
  135. package/src/bmm-skills/4-implementation/bmad-create-story/workflow.md +0 -380
  136. package/src/bmm-skills/4-implementation/bmad-dev-story/workflow.md +0 -450
  137. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/workflow.md +0 -136
  138. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +0 -76
  139. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +0 -1479
  140. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +0 -263
  141. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +0 -261
@@ -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,10 +4,12 @@ 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
- const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
9
- const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
10
- const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
10
+ const MARKETPLACE_OWNER = 'bmad-code-org';
11
+ const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
12
+ const MARKETPLACE_REF = 'main';
11
13
 
12
14
  /**
13
15
  * Manages community modules from the BMad marketplace registry.
@@ -15,13 +17,29 @@ const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
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
  /**
@@ -33,7 +51,12 @@ class CommunityModuleManager {
33
51
  if (this._cachedIndex) return this._cachedIndex;
34
52
 
35
53
  try {
36
- const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
54
+ const config = await this._client.fetchGitHubYaml(
55
+ MARKETPLACE_OWNER,
56
+ MARKETPLACE_REPO,
57
+ 'registry/community-index.yaml',
58
+ MARKETPLACE_REF,
59
+ );
37
60
  if (config?.modules?.length) {
38
61
  this._cachedIndex = config;
39
62
  return config;
@@ -54,7 +77,7 @@ class CommunityModuleManager {
54
77
  if (this._cachedCategories) return this._cachedCategories;
55
78
 
56
79
  try {
57
- const config = await this._client.fetchYaml(CATEGORIES_URL);
80
+ const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
58
81
  if (config?.categories) {
59
82
  this._cachedCategories = config;
60
83
  return config;
@@ -191,12 +214,49 @@ class CommunityModuleManager {
191
214
  return await prompts.spinner();
192
215
  };
193
216
 
194
- 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
+
195
252
  let needsDependencyInstall = false;
196
253
  let wasNewClone = false;
197
254
 
198
255
  if (await fs.pathExists(moduleCacheDir)) {
199
- // 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.
200
260
  const fetchSpinner = await createSpinner();
201
261
  fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
202
262
  try {
@@ -206,10 +266,24 @@ class CommunityModuleManager {
206
266
  stdio: ['ignore', 'pipe', 'pipe'],
207
267
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
208
268
  });
209
- execSync('git reset --hard origin/HEAD', {
210
- cwd: moduleCacheDir,
211
- stdio: ['ignore', 'pipe', 'pipe'],
212
- });
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
+ }
213
287
  const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
214
288
  if (currentRef !== newRef) needsDependencyInstall = true;
215
289
  fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
@@ -226,10 +300,17 @@ class CommunityModuleManager {
226
300
  const fetchSpinner = await createSpinner();
227
301
  fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
228
302
  try {
229
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
230
- stdio: ['ignore', 'pipe', 'pipe'],
231
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
232
- });
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
+ }
233
314
  fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
234
315
  needsDependencyInstall = true;
235
316
  } catch (error) {
@@ -238,18 +319,19 @@ class CommunityModuleManager {
238
319
  }
239
320
  }
240
321
 
241
- // If pinned to a specific SHA, check out that exact commit.
242
- // Refuse to install if the approved SHA cannot be reached - security requirement.
243
- 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.
244
326
  const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
245
- if (headSha !== sha) {
327
+ if (headSha !== approvedSha) {
246
328
  try {
247
- execSync(`git fetch --depth 1 origin ${sha}`, {
329
+ execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
248
330
  cwd: moduleCacheDir,
249
331
  stdio: ['ignore', 'pipe', 'pipe'],
250
332
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
251
333
  });
252
- execSync(`git checkout ${sha}`, {
334
+ execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
253
335
  cwd: moduleCacheDir,
254
336
  stdio: ['ignore', 'pipe', 'pipe'],
255
337
  });
@@ -257,12 +339,37 @@ class CommunityModuleManager {
257
339
  } catch {
258
340
  await fs.remove(moduleCacheDir);
259
341
  throw new Error(
260
- `Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
261
- `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.`,
262
345
  );
263
346
  }
264
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.
265
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
+ });
266
373
 
267
374
  // Install dependencies if needed
268
375
  const packageJsonPath = path.join(moduleCacheDir, 'package.json');