build-manifest.ts
1 #!/usr/bin/env node 2 /** 3 * Build-time CLI manifest compiler. 4 * 5 * Scans all JS CLI definitions in clis/ and pre-compiles them into a single 6 * manifest.json for instant cold-start registration. 7 * 8 * Usage: npx tsx src/build-manifest.ts 9 * Output: cli-manifest.json next to clis/ 10 */ 11 12 import * as fs from 'node:fs'; 13 import * as path from 'node:path'; 14 import { fileURLToPath, pathToFileURL } from 'node:url'; 15 import { getErrorMessage } from './errors.js'; 16 import { fullName, getRegistry, type CliCommand } from './registry.js'; 17 import { findPackageRoot, getCliManifestPath } from './package-paths.js'; 18 19 const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); 20 const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis'); 21 // Write manifest next to clis/ so both dev and installed runtime can find it. 22 const OUTPUT = getCliManifestPath(CLIS_DIR); 23 24 export interface ManifestEntry { 25 site: string; 26 name: string; 27 aliases?: string[]; 28 description: string; 29 domain?: string; 30 strategy: string; 31 browser: boolean; 32 args: Array<{ 33 name: string; 34 type?: string; 35 default?: unknown; 36 required?: boolean; 37 valueRequired?: boolean; 38 positional?: boolean; 39 help?: string; 40 choices?: string[]; 41 }>; 42 columns?: string[]; 43 pipeline?: Record<string, unknown>[]; 44 timeout?: number; 45 deprecated?: boolean | string; 46 replacedBy?: string; 47 type: 'js'; 48 /** Relative path from clis/ dir, e.g. 'bilibili/search.js' */ 49 modulePath?: string; 50 /** Relative path to the source file from clis/ dir (e.g. 'site/cmd.js') */ 51 sourceFile?: string; 52 /** Pre-navigation control — see CliCommand.navigateBefore */ 53 navigateBefore?: boolean | string; 54 } 55 56 import { isRecord } from './utils.js'; 57 58 const CLI_MODULE_PATTERN = /\bcli\s*\(/; 59 60 function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] { 61 return args.map(arg => ({ 62 name: arg.name, 63 type: arg.type ?? 'str', 64 default: arg.default, 65 required: !!arg.required, 66 valueRequired: !!arg.valueRequired || undefined, 67 positional: arg.positional || undefined, 68 help: arg.help ?? '', 69 choices: arg.choices, 70 })); 71 } 72 73 function toModulePath(filePath: string, site: string): string { 74 const baseName = path.basename(filePath, path.extname(filePath)); 75 return `${site}/${baseName}.js`; 76 } 77 78 function isCliCommandValue(value: unknown, site: string): value is CliCommand { 79 return isRecord(value) 80 && typeof value.site === 'string' 81 && value.site === site 82 && typeof value.name === 'string' 83 && Array.isArray(value.args); 84 } 85 86 function toManifestEntry(cmd: CliCommand, modulePath: string, sourceFile?: string): ManifestEntry { 87 return { 88 site: cmd.site, 89 name: cmd.name, 90 aliases: cmd.aliases, 91 description: cmd.description ?? '', 92 domain: cmd.domain, 93 strategy: (cmd.strategy ?? 'public').toString().toLowerCase(), 94 browser: cmd.browser ?? true, 95 args: toManifestArgs(cmd.args), 96 columns: cmd.columns, 97 timeout: cmd.timeoutSeconds, 98 deprecated: cmd.deprecated, 99 replacedBy: cmd.replacedBy, 100 type: 'js', 101 modulePath, 102 sourceFile, 103 navigateBefore: cmd.navigateBefore, 104 }; 105 } 106 107 export async function loadManifestEntries( 108 filePath: string, 109 site: string, 110 importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref), 111 ): Promise<ManifestEntry[]> { 112 try { 113 const src = fs.readFileSync(filePath, 'utf-8'); 114 115 // Helper/test modules should not appear as CLI commands in the manifest. 116 if (!CLI_MODULE_PATTERN.test(src)) return []; 117 118 const modulePath = toModulePath(filePath, site); 119 const registry = getRegistry(); 120 const before = new Map(registry.entries()); 121 const mod = await importer(pathToFileURL(filePath).href); 122 123 const exportedCommands = Object.values(isRecord(mod) ? mod : {}) 124 .filter(value => isCliCommandValue(value, site)); 125 126 const runtimeCommands = exportedCommands.length > 0 127 ? exportedCommands 128 : [...registry.entries()] 129 .filter(([key, cmd]) => { 130 if (cmd.site !== site) return false; 131 const previous = before.get(key); 132 return !previous || previous !== cmd; 133 }) 134 .map(([, cmd]) => cmd); 135 136 // Resolve sourceFile relative to clis/. 137 const sourceRelative = path.relative(CLIS_DIR, filePath); 138 139 const seen = new Set<string>(); 140 return runtimeCommands 141 .filter((cmd) => { 142 const key = fullName(cmd); 143 if (seen.has(key)) return false; 144 seen.add(key); 145 return true; 146 }) 147 .sort((a, b) => a.name.localeCompare(b.name)) 148 .map(cmd => toManifestEntry(cmd, modulePath, sourceRelative)); 149 } catch (err) { 150 // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry. 151 process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`); 152 return []; 153 } 154 } 155 156 export async function buildManifest(): Promise<ManifestEntry[]> { 157 const manifest = new Map<string, ManifestEntry>(); 158 159 // Scan JS adapters directly from clis/. 160 // Adapters are now JS-first — no compilation step needed. 161 if (fs.existsSync(CLIS_DIR)) { 162 for (const site of fs.readdirSync(CLIS_DIR)) { 163 const siteDir = path.join(CLIS_DIR, site); 164 if (!fs.statSync(siteDir).isDirectory()) continue; 165 for (const file of fs.readdirSync(siteDir)) { 166 if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') { 167 const filePath = path.join(siteDir, file); 168 const entries = await loadManifestEntries(filePath, site); 169 for (const entry of entries) { 170 const key = `${entry.site}/${entry.name}`; 171 manifest.set(key, entry); 172 } 173 } 174 } 175 } 176 } 177 178 return [...manifest.values()].sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name)); 179 } 180 181 async function main(): Promise<void> { 182 const manifest = await buildManifest(); 183 fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); 184 fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2)); 185 186 console.log(`✅ Manifest compiled: ${manifest.length} entries → ${OUTPUT}`); 187 188 // Restore executable permissions on bin entries. 189 // tsc does not preserve the +x bit, so after a clean rebuild the CLI 190 // entry-point loses its executable permission, causing "Permission denied". 191 // See: https://github.com/jackwener/opencli/issues/446 192 if (process.platform !== 'win32') { 193 const projectRoot = PACKAGE_ROOT; 194 const pkgPath = path.resolve(projectRoot, 'package.json'); 195 try { 196 const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); 197 const bins: Record<string, string> = typeof pkg.bin === 'string' 198 ? { [pkg.name ?? 'cli']: pkg.bin } 199 : pkg.bin ?? {}; 200 for (const binPath of Object.values(bins)) { 201 const abs = path.resolve(projectRoot, binPath); 202 if (fs.existsSync(abs)) { 203 fs.chmodSync(abs, 0o755); 204 console.log(`✅ Restored executable permission: ${binPath}`); 205 } 206 } 207 } catch { 208 // Best-effort; never break the build for a permission fix. 209 } 210 } 211 } 212 213 const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; 214 if (entrypoint === import.meta.url) { 215 void main(); 216 }