discovery.ts
1 /** 2 * CLI discovery: finds JS CLI definitions and registers them. 3 * 4 * Supports two modes: 5 * 1. FAST PATH (manifest): If a pre-compiled cli-manifest.json exists, 6 * registers commands instantly. JS modules are loaded lazily only 7 * when their command is executed. 8 * 2. FALLBACK (filesystem scan): Traditional runtime discovery for development. 9 */ 10 11 import * as fs from 'node:fs'; 12 import * as os from 'node:os'; 13 import * as path from 'node:path'; 14 import { fileURLToPath, pathToFileURL } from 'node:url'; 15 import { type InternalCliCommand, Strategy, registerCommand } from './registry.js'; 16 import { getErrorMessage } from './errors.js'; 17 import { log } from './logger.js'; 18 import type { ManifestEntry } from './build-manifest.js'; 19 import { findPackageRoot, getCliManifestPath } from './package-paths.js'; 20 21 /** User runtime directory: ~/.opencli */ 22 export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli'); 23 /** User CLIs directory: ~/.opencli/clis */ 24 export const USER_CLIS_DIR = path.join(USER_OPENCLI_DIR, 'clis'); 25 /** Plugins directory: ~/.opencli/plugins/ */ 26 export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins'); 27 /** Matches files that register commands via cli() or lifecycle hooks */ 28 const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; 29 30 function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy { 31 if (!rawStrategy) return fallback; 32 const key = rawStrategy.toUpperCase() as keyof typeof Strategy; 33 return Strategy[key] ?? fallback; 34 } 35 36 const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); 37 38 /** 39 * Ensure ~/.opencli/node_modules/@jackwener/opencli symlink exists so that 40 * user CLIs in ~/.opencli/clis/ can `import { cli } from '@jackwener/opencli/registry'`. 41 * 42 * This is the sole resolution mechanism — adapters use package exports 43 * (e.g. `@jackwener/opencli/registry`, `@jackwener/opencli/errors`) and 44 * Node.js resolves them through this symlink. 45 */ 46 export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DIR): Promise<void> { 47 await fs.promises.mkdir(baseDir, { recursive: true }); 48 49 // package.json for ESM resolution in ~/.opencli/ 50 const pkgJsonPath = path.join(baseDir, 'package.json'); 51 const pkgJsonContent = `${JSON.stringify({ name: 'opencli-user-runtime', private: true, type: 'module' }, null, 2)}\n`; 52 try { 53 const existing = await fs.promises.readFile(pkgJsonPath, 'utf-8'); 54 if (existing !== pkgJsonContent) await fs.promises.writeFile(pkgJsonPath, pkgJsonContent, 'utf-8'); 55 } catch { 56 await fs.promises.writeFile(pkgJsonPath, pkgJsonContent, 'utf-8'); 57 } 58 59 // Create node_modules/@jackwener/opencli symlink pointing to the installed package root. 60 const opencliRoot = PACKAGE_ROOT; 61 const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener'); 62 const symlinkPath = path.join(symlinkDir, 'opencli'); 63 try { 64 let needsUpdate = true; 65 try { 66 const existing = await fs.promises.readlink(symlinkPath); 67 if (existing === opencliRoot) needsUpdate = false; 68 } catch { /* doesn't exist */ } 69 if (needsUpdate) { 70 await fs.promises.mkdir(symlinkDir, { recursive: true }); 71 try { await fs.promises.rm(symlinkPath, { recursive: true, force: true }); } catch { /* doesn't exist */ } 72 const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; 73 await fs.promises.symlink(opencliRoot, symlinkPath, symlinkType); 74 } 75 } catch (err) { 76 log.warn(`Could not create symlink at ${symlinkPath}: ${getErrorMessage(err)}`); 77 } 78 } 79 80 /** 81 * Ensure the user adapters directory exists. 82 * 83 * With smart sync, ~/.opencli/clis/ only holds files that differ from the 84 * package baseline (upstream-synced cache + autofix output + user overrides). 85 * Built-in adapters are loaded directly from the installed package. 86 */ 87 export async function ensureUserAdapters(): Promise<void> { 88 await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true }); 89 } 90 91 /** 92 * Discover and register CLI commands. 93 * Uses pre-compiled manifest when available for instant startup. 94 */ 95 export async function discoverClis(...dirs: string[]): Promise<void> { 96 // Fast path: try manifest first (production / post-build) 97 for (const dir of dirs) { 98 const manifestPath = getCliManifestPath(dir); 99 try { 100 await fs.promises.access(manifestPath); 101 const loaded = await loadFromManifest(manifestPath, dir); 102 if (loaded) continue; // Skip filesystem scan only when manifest is usable 103 } catch { 104 // Fall through to filesystem scan 105 } 106 await discoverClisFromFs(dir); 107 } 108 } 109 110 /** 111 * Fast-path: register commands from pre-compiled manifest. 112 * TS modules are deferred — loaded lazily on first execution. 113 */ 114 async function loadFromManifest(manifestPath: string, clisDir: string): Promise<boolean> { 115 try { 116 const raw = await fs.promises.readFile(manifestPath, 'utf-8'); 117 const manifest = JSON.parse(raw) as ManifestEntry[]; 118 for (const entry of manifest) { 119 if (!entry.modulePath) continue; 120 const modulePath = path.resolve(clisDir, entry.modulePath); 121 const cmd: InternalCliCommand = { 122 site: entry.site, 123 name: entry.name, 124 aliases: entry.aliases, 125 description: entry.description ?? '', 126 domain: entry.domain, 127 strategy: parseStrategy(entry.strategy), 128 browser: entry.browser, 129 args: entry.args ?? [], 130 columns: entry.columns, 131 pipeline: entry.pipeline, 132 timeoutSeconds: entry.timeout, 133 source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath, 134 deprecated: entry.deprecated, 135 replacedBy: entry.replacedBy, 136 navigateBefore: entry.navigateBefore, 137 _lazy: true, 138 _modulePath: modulePath, 139 }; 140 // normalizeCommand inside registerCommand handles strategy → browser/navigateBefore 141 registerCommand(cmd); 142 } 143 return true; 144 } catch (err) { 145 log.warn(`Failed to load manifest ${manifestPath}: ${getErrorMessage(err)}`); 146 return false; 147 } 148 } 149 150 /** 151 * Fallback: traditional filesystem scan (used during development with tsx). 152 */ 153 async function discoverClisFromFs(dir: string): Promise<void> { 154 try { await fs.promises.access(dir); } catch { return; } 155 const entries = await fs.promises.readdir(dir, { withFileTypes: true }); 156 157 const sitePromises = entries 158 .filter(entry => entry.isDirectory()) 159 .map(async (entry) => { 160 const site = entry.name; 161 const siteDir = path.join(dir, site); 162 const files = await fs.promises.readdir(siteDir); 163 await Promise.all(files.map(async (file) => { 164 const filePath = path.join(siteDir, file); 165 if (file.endsWith('.yaml') || file.endsWith('.yml')) { 166 log.warn(`Ignoring YAML adapter ${filePath} — YAML format is no longer supported. Convert to JavaScript using cli() from '@jackwener/opencli/registry'.`); 167 return; 168 } 169 if (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) { 170 log.warn(`Ignoring TypeScript adapter ${filePath} — .ts adapters are no longer loaded. Rename to .js or convert to JavaScript.`); 171 return; 172 } 173 if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js')) { 174 if (!(await isCliModule(filePath))) return; 175 await import(pathToFileURL(filePath).href).catch((err) => { 176 log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`); 177 }); 178 } 179 })); 180 }); 181 await Promise.all(sitePromises); 182 } 183 184 /** 185 * Discover and register plugins from ~/.opencli/plugins/. 186 * Each subdirectory is treated as a plugin (site = directory name). 187 * Files inside are scanned flat (no nested site subdirs). 188 */ 189 export async function discoverPlugins(): Promise<void> { 190 try { await fs.promises.access(PLUGINS_DIR); } catch { return; } 191 const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true }); 192 await Promise.all(entries.map(async (entry) => { 193 const pluginDir = path.join(PLUGINS_DIR, entry.name); 194 if (!(await isDiscoverablePluginDir(entry, pluginDir))) return; 195 await discoverPluginDir(pluginDir, entry.name); 196 })); 197 } 198 199 /** 200 * Flat scan: read ts/js files directly in a plugin directory. 201 * Unlike discoverClisFromFs, this does NOT expect nested site subdirectories. 202 */ 203 async function discoverPluginDir(dir: string, site: string): Promise<void> { 204 const files = await fs.promises.readdir(dir); 205 const fileSet = new Set(files); 206 await Promise.all(files.map(async (file) => { 207 const filePath = path.join(dir, file); 208 if (file.endsWith('.yaml') || file.endsWith('.yml')) { 209 log.warn(`Ignoring YAML plugin ${filePath} — YAML format is no longer supported. Convert to JavaScript using cli() from '@jackwener/opencli/registry'.`); 210 return; 211 } 212 if (file.endsWith('.js') && !file.endsWith('.d.js')) { 213 if (!(await isCliModule(filePath))) return; 214 await import(pathToFileURL(filePath).href).catch((err) => { 215 log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`); 216 }); 217 } else if ( 218 file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') 219 ) { 220 const jsFile = file.replace(/\.ts$/, '.js'); 221 // Prefer compiled .js — skip the .ts source file 222 if (fileSet.has(jsFile)) return; 223 // No compiled .js found — cannot import raw .ts in production Node.js. 224 // This typically means esbuild transpilation failed during plugin install. 225 log.warn( 226 `Plugin ${site}/${file}: no compiled .js found. ` + 227 `Run "opencli plugin update ${site}" to re-transpile, or install esbuild.` 228 ); 229 } 230 })); 231 } 232 233 async function isCliModule(filePath: string): Promise<boolean> { 234 try { 235 const source = await fs.promises.readFile(filePath, 'utf-8'); 236 return PLUGIN_MODULE_PATTERN.test(source); 237 } catch (err) { 238 log.warn(`Failed to inspect module ${filePath}: ${getErrorMessage(err)}`); 239 return false; 240 } 241 } 242 243 async function isDiscoverablePluginDir(entry: fs.Dirent, pluginDir: string): Promise<boolean> { 244 if (entry.isDirectory()) return true; 245 if (!entry.isSymbolicLink()) return false; 246 247 try { 248 return (await fs.promises.stat(pluginDir)).isDirectory(); 249 } catch (err) { 250 const code = (err as NodeJS.ErrnoException).code; 251 if (code !== 'ENOENT' && code !== 'ENOTDIR') { 252 log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`); 253 } 254 return false; 255 } 256 }