/ src / discovery.ts
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  }