/ src / plugin-manifest.ts
plugin-manifest.ts
  1  /**
  2   * Plugin manifest: reads and validates opencli-plugin.json files.
  3   *
  4   * Supports two modes:
  5   * 1. Single plugin: repo root IS the plugin directory.
  6   * 2. Monorepo: repo contains multiple plugins declared in `plugins` field.
  7   */
  8  
  9  import * as fs from 'node:fs';
 10  import * as path from 'node:path';
 11  import { PKG_VERSION } from './version.js';
 12  
 13  // ── Types ───────────────────────────────────────────────────────────────────
 14  
 15  export interface SubPluginEntry {
 16    /** Relative path from repo root to the sub-plugin directory. */
 17    path: string;
 18    version?: string;
 19    description?: string;
 20    /** Semver range for opencli compatibility (overrides top-level). */
 21    opencli?: string;
 22    /** When true, this sub-plugin is skipped during install. */
 23    disabled?: boolean;
 24  }
 25  
 26  export interface PluginManifest {
 27    /** Plugin name (single-plugin mode). */
 28    name?: string;
 29    /** Semantic version of the plugin (single-plugin mode). */
 30    version?: string;
 31    /** Semver range for opencli compatibility, e.g. ">=1.0.0". */
 32    opencli?: string;
 33    /** Human-readable description. */
 34    description?: string;
 35    /** Monorepo sub-plugins. Key = logical plugin name. */
 36    plugins?: Record<string, SubPluginEntry>;
 37  }
 38  
 39  export const MANIFEST_FILENAME = 'opencli-plugin.json';
 40  
 41  // ── Read / Validate ─────────────────────────────────────────────────────────
 42  
 43  /**
 44   * Read and parse opencli-plugin.json from a directory.
 45   * Returns null if the file does not exist or is unparseable.
 46   */
 47  export function readPluginManifest(dir: string): PluginManifest | null {
 48    const manifestPath = path.join(dir, MANIFEST_FILENAME);
 49    try {
 50      const raw = fs.readFileSync(manifestPath, 'utf-8');
 51      const parsed = JSON.parse(raw);
 52      if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
 53        return null;
 54      }
 55      return parsed as PluginManifest;
 56    } catch {
 57      return null;
 58    }
 59  }
 60  
 61  /** Returns true when the manifest declares a monorepo (has `plugins` field). */
 62  export function isMonorepo(manifest: PluginManifest): boolean {
 63    return (
 64      manifest.plugins !== undefined &&
 65      manifest.plugins !== null &&
 66      typeof manifest.plugins === 'object' &&
 67      Object.keys(manifest.plugins).length > 0
 68    );
 69  }
 70  
 71  /**
 72   * Get the list of enabled sub-plugins from a monorepo manifest.
 73   * Returns entries sorted by key name.
 74   */
 75  export function getEnabledPlugins(
 76    manifest: PluginManifest,
 77  ): Array<{ name: string; entry: SubPluginEntry }> {
 78    if (!manifest.plugins) return [];
 79    return Object.entries(manifest.plugins)
 80      .filter(([, entry]) => !entry.disabled)
 81      .map(([name, entry]) => ({ name, entry }))
 82      .sort((a, b) => a.name.localeCompare(b.name));
 83  }
 84  
 85  // ── Version compatibility ───────────────────────────────────────────────────
 86  
 87  /**
 88   * Check if the current opencli version satisfies a semver range string.
 89   *
 90   * Supports a simplified subset of semver ranges:
 91   *   ">=1.0.0"   – greater than or equal
 92   *   "<=1.5.0"   – less than or equal
 93   *   ">1.0.0"    – strictly greater
 94   *   "<2.0.0"    – strictly less
 95   *   "^1.2.0"    – compatible (>=1.2.0 and <2.0.0)
 96   *   "~1.2.0"    – patch-level (>=1.2.0 and <1.3.0)
 97   *   "1.2.0"     – exact match
 98   *   ">=1.0.0 <2.0.0" – multiple constraints (space-separated, all must match)
 99   *
100   * Returns true if compatible, false if not, and true for empty/undefined
101   * ranges (no constraint = always compatible).
102   */
103  export function checkCompatibility(range: string | undefined): boolean {
104    if (!range || range.trim() === '') return true;
105    return satisfiesRange(PKG_VERSION, range);
106  }
107  
108  /** Parse a version string ("1.2.3") into [major, minor, patch]. */
109  export function parseVersion(version: string): [number, number, number] | null {
110    const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
111    if (!match) return null;
112    return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
113  }
114  
115  /** Compare two version tuples: -1 if a<b, 0 if equal, 1 if a>b. */
116  function compareVersions(
117    a: [number, number, number],
118    b: [number, number, number],
119  ): -1 | 0 | 1 {
120    for (let i = 0; i < 3; i++) {
121      if (a[i] < b[i]) return -1;
122      if (a[i] > b[i]) return 1;
123    }
124    return 0;
125  }
126  
127  /** Check if a version satisfies a single constraint like ">=1.2.0". */
128  function satisfiesSingleConstraint(
129    version: [number, number, number],
130    constraint: string,
131  ): boolean {
132    const trimmed = constraint.trim();
133    if (!trimmed) return true;
134  
135    // ^1.2.0 → >=1.2.0 <2.0.0
136    if (trimmed.startsWith('^')) {
137      const target = parseVersion(trimmed.slice(1));
138      if (!target) return true;
139      const upper: [number, number, number] = [target[0] + 1, 0, 0];
140      return compareVersions(version, target) >= 0 && compareVersions(version, upper) < 0;
141    }
142  
143    // ~1.2.0 → >=1.2.0 <1.3.0
144    if (trimmed.startsWith('~')) {
145      const target = parseVersion(trimmed.slice(1));
146      if (!target) return true;
147      const upper: [number, number, number] = [target[0], target[1] + 1, 0];
148      return compareVersions(version, target) >= 0 && compareVersions(version, upper) < 0;
149    }
150  
151    // >=, <=, >, <, =
152    if (trimmed.startsWith('>=')) {
153      const target = parseVersion(trimmed.slice(2));
154      if (!target) return true;
155      return compareVersions(version, target) >= 0;
156    }
157    if (trimmed.startsWith('<=')) {
158      const target = parseVersion(trimmed.slice(2));
159      if (!target) return true;
160      return compareVersions(version, target) <= 0;
161    }
162    if (trimmed.startsWith('>')) {
163      const target = parseVersion(trimmed.slice(1));
164      if (!target) return true;
165      return compareVersions(version, target) > 0;
166    }
167    if (trimmed.startsWith('<')) {
168      const target = parseVersion(trimmed.slice(1));
169      if (!target) return true;
170      return compareVersions(version, target) < 0;
171    }
172    if (trimmed.startsWith('=')) {
173      const target = parseVersion(trimmed.slice(1));
174      if (!target) return true;
175      return compareVersions(version, target) === 0;
176    }
177  
178    // Exact match
179    const target = parseVersion(trimmed);
180    if (!target) return true;
181    return compareVersions(version, target) === 0;
182  }
183  
184  /**
185   * Check if a version string satisfies a range expression.
186   * Space-separated constraints are ANDed together.
187   */
188  export function satisfiesRange(versionStr: string, range: string): boolean {
189    const version = parseVersion(versionStr);
190    if (!version) return true; // Can't parse our own version → assume ok
191  
192    // Split on whitespace for multi-constraint ranges (e.g. ">=1.0.0 <2.0.0")
193    const constraints = range.trim().split(/\s+/);
194    return constraints.every((c) => satisfiesSingleConstraint(version, c));
195  }
196  
197  // ── Exports for testing ─────────────────────────────────────────────────────
198  
199  export {
200    readPluginManifest as _readPluginManifest,
201    isMonorepo as _isMonorepo,
202    getEnabledPlugins as _getEnabledPlugins,
203    checkCompatibility as _checkCompatibility,
204    parseVersion as _parseVersion,
205    satisfiesRange as _satisfiesRange,
206  };