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 };