@@ -11,6 +11,15 @@ function quoteCustomRef(ref) {
1111 return `"${ ref } "` ;
1212}
1313
14+ function urlHasRepoPath ( value ) {
15+ try {
16+ const url = new URL ( value ) ;
17+ return Boolean ( url . host && url . pathname . replace ( / ^ \/ + / , '' ) . replace ( / \/ + $ / , '' ) ) ;
18+ } catch {
19+ return false ;
20+ }
21+ }
22+
1423/**
1524 * Manages custom modules installed from user-provided sources.
1625 * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@@ -88,7 +97,8 @@ class CustomModuleManager {
8897 before . startsWith ( './' ) ||
8998 before . startsWith ( '../' ) ||
9099 before . startsWith ( '~' ) ||
91- / ^ h t t p s ? : \/ \/ / i. test ( before ) ||
100+ ( / ^ h t t p s ? : \/ \/ / i. test ( before ) && urlHasRepoPath ( before ) ) ||
101+ ( / ^ s s h : \/ \/ / i. test ( before ) && urlHasRepoPath ( before ) ) ||
92102 / ^ g i t @ [ ^ : ] + : .+ / . test ( before ) ;
93103 if ( beforeLooksLikeRepo ) {
94104 versionSuffix = candidate ;
@@ -132,6 +142,51 @@ class CustomModuleManager {
132142 } ;
133143 }
134144
145+ // SSH protocol URL: ssh://git@host [:port]/owner/repo.git
146+ if ( / ^ s s h : \/ \/ / i. test ( trimmed ) ) {
147+ let url ;
148+ try {
149+ url = new URL ( trimmed ) ;
150+ } catch {
151+ url = null ;
152+ }
153+
154+ if ( url && url . host ) {
155+ const repoPath = url . pathname . replace ( / ^ \/ + / , '' ) . replace ( / \/ + $ / , '' ) ;
156+ const repoPathClean = repoPath . replace ( / \. g i t $ / i, '' ) ;
157+ if ( ! repoPathClean ) {
158+ return {
159+ type : null ,
160+ cloneUrl : null ,
161+ subdir : null ,
162+ localPath : null ,
163+ cacheKey : null ,
164+ displayName : null ,
165+ isValid : false ,
166+ error : 'Not a valid Git URL or local path' ,
167+ } ;
168+ }
169+
170+ const segments = repoPathClean . split ( '/' ) . filter ( Boolean ) ;
171+ const repoSeg = segments . at ( - 1 ) ;
172+ const ownerSeg = segments . at ( - 2 ) ;
173+ const displayName = ownerSeg ? `${ ownerSeg } /${ repoSeg } ` : repoSeg ;
174+
175+ return {
176+ type : 'url' ,
177+ cloneUrl : trimmed ,
178+ subdir : null ,
179+ localPath : null ,
180+ version : versionSuffix || null ,
181+ rawInput : trimmedRaw ,
182+ cacheKey : `${ url . host } /${ repoPathClean } ` ,
183+ displayName,
184+ isValid : true ,
185+ error : null ,
186+ } ;
187+ }
188+ }
189+
135190 // HTTPS/HTTP URL: generic handling for any Git host.
136191 // We avoid host-specific parsing — `git clone` will accept whatever URL the
137192 // user provides. We only need to (a) separate an optional browser-style
@@ -357,6 +412,18 @@ class CustomModuleManager {
357412 return path . join ( os . homedir ( ) , '.bmad' , 'cache' , 'custom-modules' ) ;
358413 }
359414
415+ /**
416+ * Convert a stable cache key into filesystem-safe path segments.
417+ * Keep parseSource().cacheKey human-readable while avoiding invalid
418+ * characters such as ":" from custom SSH ports on Windows.
419+ * @param {string } cacheKey - Parsed cache key
420+ * @returns {string } Filesystem path for the cached clone
421+ */
422+ _getRepoCacheDir ( cacheKey ) {
423+ const safeSegments = cacheKey . split ( '/' ) . map ( ( segment ) => segment . replaceAll ( ':' , '__port_' ) ) ;
424+ return path . join ( this . getCacheDir ( ) , ...safeSegments ) ;
425+ }
426+
360427 /**
361428 * Clone a custom module repository to cache.
362429 * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
@@ -371,8 +438,7 @@ class CustomModuleManager {
371438 if ( ! parsed . isValid ) throw new Error ( parsed . error ) ;
372439 if ( parsed . type === 'local' ) throw new Error ( 'cloneRepo does not accept local paths' ) ;
373440
374- const cacheDir = this . getCacheDir ( ) ;
375- const repoCacheDir = path . join ( cacheDir , ...parsed . cacheKey . split ( '/' ) ) ;
441+ const repoCacheDir = this . _getRepoCacheDir ( parsed . cacheKey ) ;
376442 const silent = options . silent || false ;
377443 const displayName = parsed . displayName ;
378444
@@ -630,7 +696,7 @@ class CustomModuleManager {
630696 if ( parsed . type === 'local' ) {
631697 baseDir = parsed . localPath ;
632698 } else {
633- baseDir = path . join ( this . getCacheDir ( ) , ... parsed . cacheKey . split ( '/' ) ) ;
699+ baseDir = this . _getRepoCacheDir ( parsed . cacheKey ) ;
634700 }
635701
636702 if ( ! ( await fs . pathExists ( baseDir ) ) ) return null ;
0 commit comments