@@ -4,6 +4,13 @@ const path = require('node:path');
44const { execSync } = require ( 'node:child_process' ) ;
55const prompts = require ( '../prompts' ) ;
66
7+ function quoteCustomRef ( ref ) {
8+ if ( typeof ref !== 'string' || ! / ^ [ \w . \- + / ] + $ / . test ( ref ) ) {
9+ throw new Error ( `Unsafe ref name: ${ JSON . stringify ( ref ) } ` ) ;
10+ }
11+ return `"${ ref } "` ;
12+ }
13+
714/**
815 * Manages custom modules installed from user-provided sources.
916 * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@@ -38,8 +45,8 @@ class CustomModuleManager {
3845 } ;
3946 }
4047
41- const trimmed = input . trim ( ) ;
42- if ( ! trimmed ) {
48+ const trimmedRaw = input . trim ( ) ;
49+ if ( ! trimmedRaw ) {
4350 return {
4451 type : null ,
4552 cloneUrl : null ,
@@ -52,8 +59,53 @@ class CustomModuleManager {
5259 } ;
5360 }
5461
62+ // Extract optional @<tag-or-branch> suffix from the end of the input.
63+ // Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash.
64+ // Raw commit SHAs are NOT supported here — `git clone --branch` can't take
65+ // them; use --pin at the module level or check out the SHA manually.
66+ // Only strip when the tail looks like a ref, so we don't disturb
67+ // URLs without a version spec or the SSH protocol's `git@host:...` prefix.
68+ let trimmed = trimmedRaw ;
69+ let versionSuffix = null ;
70+ const lastAt = trimmedRaw . lastIndexOf ( '@' ) ;
71+ // Skip if @ is part of git@github .com:... (first char cannot be stripped as version)
72+ // and skip if @ appears before the path rather than after a ref-shaped tail.
73+ if ( lastAt > 0 ) {
74+ const candidate = trimmedRaw . slice ( lastAt + 1 ) ;
75+ const before = trimmedRaw . slice ( 0 , lastAt ) ;
76+ // candidate must be ref-shaped and must not itself look like a URL / SSH host
77+ if ( / ^ [ \w . \- + / ] + $ / . test ( candidate ) && ! candidate . includes ( ':' ) ) {
78+ // Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
79+ // in that case. Require that the @ comes after the host/path, not inside the auth segment.
80+ // Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
81+ const beforeLooksLikeRepo =
82+ before . startsWith ( '/' ) ||
83+ before . startsWith ( './' ) ||
84+ before . startsWith ( '../' ) ||
85+ before . startsWith ( '~' ) ||
86+ / ^ h t t p s ? : \/ \/ / i. test ( before ) ||
87+ / ^ g i t @ [ ^ : ] + : .+ / . test ( before ) ;
88+ if ( beforeLooksLikeRepo ) {
89+ versionSuffix = candidate ;
90+ trimmed = before ;
91+ }
92+ }
93+ }
94+
5595 // Local path detection: starts with /, ./, ../, or ~
5696 if ( trimmed . startsWith ( '/' ) || trimmed . startsWith ( './' ) || trimmed . startsWith ( '../' ) || trimmed . startsWith ( '~' ) ) {
97+ if ( versionSuffix ) {
98+ return {
99+ type : 'local' ,
100+ cloneUrl : null ,
101+ subdir : null ,
102+ localPath : null ,
103+ cacheKey : null ,
104+ displayName : null ,
105+ isValid : false ,
106+ error : 'Local paths do not support @version suffixes' ,
107+ } ;
108+ }
57109 return this . _parseLocalPath ( trimmed ) ;
58110 }
59111
@@ -66,6 +118,8 @@ class CustomModuleManager {
66118 cloneUrl : trimmed ,
67119 subdir : null ,
68120 localPath : null ,
121+ version : versionSuffix || null ,
122+ rawInput : trimmedRaw ,
69123 cacheKey : `${ host } /${ owner } /${ repo } ` ,
70124 displayName : `${ owner } /${ repo } ` ,
71125 isValid : true ,
@@ -79,29 +133,47 @@ class CustomModuleManager {
79133 const [ , host , owner , repo , remainder ] = httpsMatch ;
80134 const cloneUrl = `https://${ host } /${ owner } /${ repo } ` ;
81135 let subdir = null ;
136+ let urlRef = null ; // branch/tag extracted from /tree/<ref>/subdir
82137
83138 if ( remainder ) {
84139 // Extract subdir from deep path patterns used by various Git hosts
85140 const deepPathPatterns = [
86- / ^ \/ (?: - \/ ) ? t r e e \/ [ ^ / ] + \/ ( .+ ) $ / , // GitHub /tree/branch/path , GitLab /-/tree/branch/path
87- / ^ \/ (?: - \/ ) ? b l o b \/ [ ^ / ] + \/ ( .+ ) $ / , // /blob/branch/path (treat same as tree)
88- / ^ \/ s r c \/ [ ^ / ] + \/ ( .+ ) $ / , // Gitea/Forgejo /src/branch/path
141+ { regex : / ^ \/ (?: - \/ ) ? t r e e \/ ( [ ^ / ] + ) \/ ( .+ ) $ / , refIdx : 1 , pathIdx : 2 } , // GitHub , GitLab
142+ { regex : / ^ \/ (?: - \/ ) ? b l o b \/ ( [ ^ / ] + ) \/ ( .+ ) $ / , refIdx : 1 , pathIdx : 2 } ,
143+ { regex : / ^ \/ s r c \/ ( [ ^ / ] + ) \/ ( .+ ) $ / , refIdx : 1 , pathIdx : 2 } , // Gitea/Forgejo
89144 ] ;
145+ // Also match `/tree/<ref>` with no subdir
146+ const refOnlyPatterns = [ / ^ \/ (?: - \/ ) ? t r e e \/ ( [ ^ / ] + ?) \/ ? $ / , / ^ \/ (?: - \/ ) ? b l o b \/ ( [ ^ / ] + ?) \/ ? $ / , / ^ \/ s r c \/ ( [ ^ / ] + ?) \/ ? $ / ] ;
90147
91- for ( const pattern of deepPathPatterns ) {
92- const match = remainder . match ( pattern ) ;
148+ for ( const p of deepPathPatterns ) {
149+ const match = remainder . match ( p . regex ) ;
93150 if ( match ) {
94- subdir = match [ 1 ] . replace ( / \/ $ / , '' ) ; // strip trailing slash
151+ urlRef = match [ p . refIdx ] ;
152+ subdir = match [ p . pathIdx ] . replace ( / \/ $ / , '' ) ;
95153 break ;
96154 }
97155 }
156+ if ( ! subdir ) {
157+ for ( const r of refOnlyPatterns ) {
158+ const match = remainder . match ( r ) ;
159+ if ( match ) {
160+ urlRef = match [ 1 ] ;
161+ break ;
162+ }
163+ }
164+ }
98165 }
99166
167+ // Precedence: explicit @version suffix > URL /tree/<ref> path segment.
168+ const version = versionSuffix || urlRef || null ;
169+
100170 return {
101171 type : 'url' ,
102172 cloneUrl,
103173 subdir,
104174 localPath : null ,
175+ version,
176+ rawInput : trimmedRaw ,
105177 cacheKey : `${ host } /${ owner } /${ repo } ` ,
106178 displayName : `${ owner } /${ repo } ` ,
107179 isValid : true ,
@@ -255,6 +327,10 @@ class CustomModuleManager {
255327 const silent = options . silent || false ;
256328 const displayName = parsed . displayName ;
257329
330+ // Pin override: --pin CODE=TAG resolved at module-selection time overrides
331+ // any @version suffix present in the URL.
332+ const effectiveVersion = options . pinOverride || parsed . version || null ;
333+
258334 await fs . ensureDir ( path . dirname ( repoCacheDir ) ) ;
259335
260336 const createSpinner = async ( ) => {
@@ -264,8 +340,23 @@ class CustomModuleManager {
264340 return await prompts . spinner ( ) ;
265341 } ;
266342
343+ // If an existing cache exists but was cloned at a different version, re-clone.
344+ // Tracked via .bmad-source.json's recorded version.
345+ if ( await fs . pathExists ( repoCacheDir ) ) {
346+ let cachedVersion = null ;
347+ try {
348+ const existing = await fs . readJson ( path . join ( repoCacheDir , '.bmad-source.json' ) ) ;
349+ cachedVersion = existing ?. version || null ;
350+ } catch {
351+ // no metadata; treat as mismatched to be safe if a version was requested
352+ }
353+ if ( ( effectiveVersion || null ) !== ( cachedVersion || null ) ) {
354+ await fs . remove ( repoCacheDir ) ;
355+ }
356+ }
357+
267358 if ( await fs . pathExists ( repoCacheDir ) ) {
268- // Update existing clone
359+ // Update existing clone (same version as before)
269360 const fetchSpinner = await createSpinner ( ) ;
270361 fetchSpinner . start ( `Updating ${ displayName } ...` ) ;
271362 try {
@@ -274,10 +365,42 @@ class CustomModuleManager {
274365 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
275366 env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
276367 } ) ;
277- execSync ( 'git reset --hard origin/HEAD' , {
278- cwd : repoCacheDir ,
279- stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
280- } ) ;
368+ if ( effectiveVersion ) {
369+ // Fetch the ref as either a tag or a branch — `origin <ref>` works
370+ // for both, whereas `origin tag <ref>` fails for branch refs parsed
371+ // out of /tree/<branch>/... URLs.
372+ execSync ( `git fetch --depth 1 origin ${ quoteCustomRef ( effectiveVersion ) } --no-tags` , {
373+ cwd : repoCacheDir ,
374+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
375+ env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
376+ } ) ;
377+ execSync ( `git checkout --quiet FETCH_HEAD` , {
378+ cwd : repoCacheDir ,
379+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
380+ } ) ;
381+ } else {
382+ // Resolve the default branch (origin/HEAD) and fetch it explicitly.
383+ // With shallow clones, `origin/HEAD` is stale and `git reset --hard
384+ // origin/HEAD` never picks up new commits on the default branch.
385+ let defaultBranch = 'main' ;
386+ try {
387+ defaultBranch = execSync ( 'git symbolic-ref refs/remotes/origin/HEAD --short' , {
388+ cwd : repoCacheDir ,
389+ stdio : 'pipe' ,
390+ } ) . toString ( ) . trim ( ) . replace ( 'origin/' , '' ) ;
391+ } catch {
392+ // Fallback if origin/HEAD is not set
393+ }
394+ execSync ( `git fetch --depth 1 origin ${ quoteCustomRef ( defaultBranch ) } ` , {
395+ cwd : repoCacheDir ,
396+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
397+ env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
398+ } ) ;
399+ execSync ( `git reset --hard origin/${ defaultBranch } ` , {
400+ cwd : repoCacheDir ,
401+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
402+ } ) ;
403+ }
281404 fetchSpinner . stop ( `Updated ${ displayName } ` ) ;
282405 } catch {
283406 fetchSpinner . error ( `Update failed, re-downloading ${ displayName } ` ) ;
@@ -287,25 +410,44 @@ class CustomModuleManager {
287410
288411 if ( ! ( await fs . pathExists ( repoCacheDir ) ) ) {
289412 const fetchSpinner = await createSpinner ( ) ;
290- fetchSpinner . start ( `Cloning ${ displayName } ...` ) ;
413+ fetchSpinner . start ( `Cloning ${ displayName } ${ effectiveVersion ? ` @ ${ effectiveVersion } ` : '' } ...` ) ;
291414 try {
292- execSync ( `git clone --depth 1 "${ parsed . cloneUrl } " "${ repoCacheDir } "` , {
293- stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
294- env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
295- } ) ;
415+ if ( effectiveVersion ) {
416+ execSync ( `git clone --depth 1 --branch ${ quoteCustomRef ( effectiveVersion ) } "${ parsed . cloneUrl } " "${ repoCacheDir } "` , {
417+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
418+ env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
419+ } ) ;
420+ } else {
421+ execSync ( `git clone --depth 1 "${ parsed . cloneUrl } " "${ repoCacheDir } "` , {
422+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
423+ env : { ...process . env , GIT_TERMINAL_PROMPT : '0' } ,
424+ } ) ;
425+ }
296426 fetchSpinner . stop ( `Cloned ${ displayName } ` ) ;
297427 } catch ( error_ ) {
298428 fetchSpinner . error ( `Failed to clone ${ displayName } ` ) ;
299- throw new Error ( `Failed to clone ${ parsed . cloneUrl } : ${ error_ . message } ` ) ;
429+ const refSuffix = effectiveVersion ? `@${ effectiveVersion } ` : '' ;
430+ throw new Error ( `Failed to clone ${ parsed . cloneUrl } ${ refSuffix } : ${ error_ . message } ` ) ;
300431 }
301432 }
302433
434+ // Record the resolved SHA for the manifest writer.
435+ let resolvedSha = null ;
436+ try {
437+ resolvedSha = execSync ( 'git rev-parse HEAD' , { cwd : repoCacheDir , stdio : 'pipe' } ) . toString ( ) . trim ( ) ;
438+ } catch {
439+ // swallow — a non-git repo (local path) wouldn't reach here anyway
440+ }
441+
303442 // Write source metadata for later URL reconstruction
304443 const metadataPath = path . join ( repoCacheDir , '.bmad-source.json' ) ;
305444 await fs . writeJson ( metadataPath , {
306445 cloneUrl : parsed . cloneUrl ,
307446 cacheKey : parsed . cacheKey ,
308447 displayName : parsed . displayName ,
448+ version : effectiveVersion || null ,
449+ rawInput : parsed . rawInput || sourceInput ,
450+ sha : resolvedSha ,
309451 clonedAt : new Date ( ) . toISOString ( ) ,
310452 } ) ;
311453
@@ -346,10 +488,26 @@ class CustomModuleManager {
346488 const resolver = new PluginResolver ( ) ;
347489 const resolved = await resolver . resolve ( repoPath , plugin ) ;
348490
491+ // Read clone metadata (written by cloneRepo) so we can pick up the
492+ // resolved git ref + SHA for manifest recording.
493+ let cloneMetadata = null ;
494+ if ( sourceUrl ) {
495+ try {
496+ cloneMetadata = await fs . readJson ( path . join ( repoPath , '.bmad-source.json' ) ) ;
497+ } catch {
498+ // no metadata — local-source or legacy cache
499+ }
500+ }
501+
349502 // Stamp source info onto each resolved module for manifest tracking
350503 for ( const mod of resolved ) {
351504 if ( sourceUrl ) mod . repoUrl = sourceUrl ;
352505 if ( localPath ) mod . localPath = localPath ;
506+ if ( cloneMetadata ) {
507+ mod . cloneRef = cloneMetadata . version || null ;
508+ mod . cloneSha = cloneMetadata . sha || null ;
509+ mod . rawInput = cloneMetadata . rawInput || null ;
510+ }
353511 CustomModuleManager . _resolutionCache . set ( mod . code , mod ) ;
354512 }
355513
0 commit comments