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.
- package/package.json +3 -2
- package/tools/installer/commands/install.js +13 -0
- package/tools/installer/core/config.js +4 -1
- package/tools/installer/core/installer.js +96 -9
- package/tools/installer/core/manifest-generator.js +16 -1
- package/tools/installer/core/manifest.js +28 -3
- package/tools/installer/modules/channel-plan.js +203 -0
- package/tools/installer/modules/channel-resolver.js +241 -0
- package/tools/installer/modules/community-manager.js +120 -18
- package/tools/installer/modules/custom-module-manager.js +160 -19
- package/tools/installer/modules/external-manager.js +231 -29
- package/tools/installer/modules/official-modules.js +56 -10
- package/tools/installer/modules/registry-fallback.yaml +8 -0
- package/tools/installer/ui.js +407 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
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 !==
|
|
327
|
+
if (headSha !== approvedSha) {
|
|
251
328
|
try {
|
|
252
|
-
execSync(`git fetch --depth 1 origin ${
|
|
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 ${
|
|
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 (${
|
|
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');
|