@@ -93,6 +93,66 @@ function hasPluginFile(packagePath: string): boolean {
9393 existsSync ( join ( packagePath , 'c8ctl-plugin.ts' ) ) ;
9494}
9595
96+ /**
97+ * Check if a package is a valid c8ctl plugin
98+ */
99+ function isValidPlugin ( pkgPath : string ) : boolean {
100+ const pkgJsonPath = join ( pkgPath , 'package.json' ) ;
101+ if ( ! existsSync ( pkgJsonPath ) ) return false ;
102+
103+ try {
104+ const pkgJson = JSON . parse ( readFileSync ( pkgJsonPath , 'utf-8' ) ) ;
105+ return hasPluginFile ( pkgPath ) && pkgJson . keywords ?. includes ( 'c8ctl' ) ;
106+ } catch {
107+ return false ;
108+ }
109+ }
110+
111+ /**
112+ * Get package name from a valid plugin directory
113+ */
114+ function getPackageName ( pkgPath : string ) : string | null {
115+ const pkgJsonPath = join ( pkgPath , 'package.json' ) ;
116+ try {
117+ const pkgJson = JSON . parse ( readFileSync ( pkgJsonPath , 'utf-8' ) ) ;
118+ return pkgJson . name ;
119+ } catch {
120+ return null ;
121+ }
122+ }
123+
124+ /**
125+ * Scan directory entries for c8ctl plugins
126+ */
127+ function scanForPlugin ( nodeModulesPath : string , entries : string [ ] ) : string | null {
128+ for ( const entry of entries . filter ( e => ! e . startsWith ( '.' ) ) ) {
129+ const pkgPath = entry . startsWith ( '@' )
130+ ? null // Scoped packages handled separately
131+ : join ( nodeModulesPath , entry ) ;
132+
133+ if ( pkgPath && isValidPlugin ( pkgPath ) ) {
134+ return getPackageName ( pkgPath ) ;
135+ }
136+
137+ // Handle scoped packages
138+ if ( entry . startsWith ( '@' ) ) {
139+ const scopePath = join ( nodeModulesPath , entry ) ;
140+ try {
141+ const scopedPackages = readdirSync ( scopePath ) ;
142+ for ( const scopedPkg of scopedPackages ) {
143+ const pkgPath = join ( scopePath , scopedPkg ) ;
144+ if ( isValidPlugin ( pkgPath ) ) {
145+ return getPackageName ( pkgPath ) ;
146+ }
147+ }
148+ } catch {
149+ // Skip scoped packages that can't be read
150+ }
151+ }
152+ }
153+ return null ;
154+ }
155+
96156/**
97157 * Extract package name from URL or installed package
98158 * Tries to read package.json from installed package, falls back to URL parsing
@@ -103,37 +163,8 @@ function extractPackageNameFromUrl(url: string, pluginsDir: string): string {
103163 const nodeModulesPath = join ( pluginsDir , 'node_modules' ) ;
104164 if ( existsSync ( nodeModulesPath ) ) {
105165 const entries = readdirSync ( nodeModulesPath ) ;
106- for ( const entry of entries ) {
107- if ( entry . startsWith ( '.' ) ) continue ;
108-
109- if ( entry . startsWith ( '@' ) ) {
110- // Scoped package
111- const scopePath = join ( nodeModulesPath , entry ) ;
112- const scopedPackages = readdirSync ( scopePath ) ;
113- for ( const scopedPkg of scopedPackages ) {
114- const pkgPath = join ( scopePath , scopedPkg ) ;
115- const pkgJsonPath = join ( pkgPath , 'package.json' ) ;
116- if ( existsSync ( pkgJsonPath ) ) {
117- const pkgJson = JSON . parse ( readFileSync ( pkgJsonPath , 'utf-8' ) ) ;
118- // Check if this package has c8ctl-plugin file
119- if ( hasPluginFile ( pkgPath ) && pkgJson . keywords ?. includes ( 'c8ctl' ) ) {
120- return pkgJson . name ;
121- }
122- }
123- }
124- } else {
125- // Regular package
126- const pkgPath = join ( nodeModulesPath , entry ) ;
127- const pkgJsonPath = join ( pkgPath , 'package.json' ) ;
128- if ( existsSync ( pkgJsonPath ) ) {
129- const pkgJson = JSON . parse ( readFileSync ( pkgJsonPath , 'utf-8' ) ) ;
130- // Check if this package has c8ctl-plugin file
131- if ( hasPluginFile ( pkgPath ) && pkgJson . keywords ?. includes ( 'c8ctl' ) ) {
132- return pkgJson . name ;
133- }
134- }
135- }
136- }
166+ const foundName = scanForPlugin ( nodeModulesPath , entries ) ;
167+ if ( foundName ) return foundName ;
137168 }
138169 } catch ( error ) {
139170 // Fall through to URL-based name extraction
@@ -180,6 +211,57 @@ export async function unloadPlugin(packageName: string): Promise<void> {
180211 }
181212}
182213
214+ /**
215+ * Scan a directory entry for c8ctl plugins and add to the set
216+ */
217+ function addPluginIfFound ( entry : string , nodeModulesPath : string , installedPlugins : Set < string > ) : void {
218+ if ( entry . startsWith ( '.' ) ) return ;
219+
220+ if ( entry . startsWith ( '@' ) ) {
221+ // Scoped package - scan subdirectories
222+ const scopePath = join ( nodeModulesPath , entry ) ;
223+ try {
224+ readdirSync ( scopePath )
225+ . filter ( pkg => ! pkg . startsWith ( '.' ) )
226+ . forEach ( scopedPkg => {
227+ const packageNameWithScope = `${ entry } /${ scopedPkg } ` ;
228+ const packagePath = join ( nodeModulesPath , entry , scopedPkg ) ;
229+ if ( hasPluginFile ( packagePath ) ) {
230+ installedPlugins . add ( packageNameWithScope ) ;
231+ }
232+ } ) ;
233+ } catch {
234+ // Skip packages that can't be read
235+ }
236+ } else {
237+ // Regular package
238+ const packagePath = join ( nodeModulesPath , entry ) ;
239+ if ( hasPluginFile ( packagePath ) ) {
240+ installedPlugins . add ( entry ) ;
241+ }
242+ }
243+ }
244+
245+ /**
246+ * Scan node_modules for installed plugins
247+ */
248+ function scanInstalledPlugins ( nodeModulesPath : string ) : Set < string > {
249+ const installedPlugins = new Set < string > ( ) ;
250+
251+ if ( ! existsSync ( nodeModulesPath ) ) {
252+ return installedPlugins ;
253+ }
254+
255+ try {
256+ const entries = readdirSync ( nodeModulesPath ) ;
257+ entries . forEach ( entry => addPluginIfFound ( entry , nodeModulesPath , installedPlugins ) ) ;
258+ } catch ( error ) {
259+ getLogger ( ) . debug ( 'Error scanning global plugins directory:' , error ) ;
260+ }
261+
262+ return installedPlugins ;
263+ }
264+
183265/**
184266 * List installed plugins
185267 */
@@ -193,44 +275,7 @@ export function listPlugins(): void {
193275 // Check global plugins directory
194276 const pluginsDir = ensurePluginsDir ( ) ;
195277 const nodeModulesPath = join ( pluginsDir , 'node_modules' ) ;
196- let installedPlugins : Set < string > = new Set ( ) ;
197-
198- if ( existsSync ( nodeModulesPath ) ) {
199- // Scan for installed plugins in global directory
200- try {
201- const entries = readdirSync ( nodeModulesPath ) ;
202- for ( const entry of entries ) {
203- if ( entry . startsWith ( '.' ) ) continue ;
204-
205- if ( entry . startsWith ( '@' ) ) {
206- // Scoped package - scan subdirectories
207- const scopePath = join ( nodeModulesPath , entry ) ;
208- try {
209- const scopedPackages = readdirSync ( scopePath ) ;
210- for ( const scopedPkg of scopedPackages ) {
211- if ( ! scopedPkg . startsWith ( '.' ) ) {
212- const packageNameWithScope = `${ entry } /${ scopedPkg } ` ;
213- const packagePath = join ( nodeModulesPath , entry , scopedPkg ) ;
214- if ( hasPluginFile ( packagePath ) ) {
215- installedPlugins . add ( packageNameWithScope ) ;
216- }
217- }
218- }
219- } catch {
220- // Skip packages that can't be read
221- }
222- } else {
223- // Regular package
224- const packagePath = join ( nodeModulesPath , entry ) ;
225- if ( hasPluginFile ( packagePath ) ) {
226- installedPlugins . add ( entry ) ;
227- }
228- }
229- }
230- } catch ( error ) {
231- logger . debug ( 'Error scanning global plugins directory:' , error ) ;
232- }
233- }
278+ const installedPlugins = scanInstalledPlugins ( nodeModulesPath ) ;
234279
235280 // Build unified list with status
236281 const plugins : Array < { Name : string , Status : string , Source : string , 'Installed At' : string } > = [ ] ;
0 commit comments