mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
199 lines
6.5 KiB
JavaScript
199 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Build-time CLI manifest compiler.
|
|
*
|
|
* Scans all YAML/TS CLI definitions and pre-compiles them into a single
|
|
* manifest.json for instant cold-start registration (no runtime YAML parsing).
|
|
*
|
|
* Usage: npx tsx src/build-manifest.ts
|
|
* Output: dist/cli-manifest.json
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import yaml from 'js-yaml';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
|
const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
|
|
|
|
interface ManifestEntry {
|
|
site: string;
|
|
name: string;
|
|
description: string;
|
|
domain?: string;
|
|
strategy: string;
|
|
browser: boolean;
|
|
args: Array<{
|
|
name: string;
|
|
type?: string;
|
|
default?: any;
|
|
required?: boolean;
|
|
help?: string;
|
|
choices?: string[];
|
|
}>;
|
|
columns?: string[];
|
|
pipeline?: any[];
|
|
timeout?: number;
|
|
/** 'yaml' or 'ts' — determines how executeCommand loads the handler */
|
|
type: 'yaml' | 'ts';
|
|
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
|
|
modulePath?: string;
|
|
}
|
|
|
|
function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
const def = yaml.load(raw) as any;
|
|
if (!def || typeof def !== 'object') return null;
|
|
|
|
const strategyStr = def.strategy ?? (def.browser === false ? 'public' : 'cookie');
|
|
const strategy = strategyStr.toUpperCase();
|
|
const browser = def.browser ?? (strategy !== 'PUBLIC');
|
|
|
|
const args: ManifestEntry['args'] = [];
|
|
if (def.args && typeof def.args === 'object') {
|
|
for (const [argName, argDef] of Object.entries(def.args as Record<string, any>)) {
|
|
args.push({
|
|
name: argName,
|
|
type: argDef?.type ?? 'str',
|
|
default: argDef?.default,
|
|
required: argDef?.required ?? false,
|
|
help: argDef?.description ?? argDef?.help ?? '',
|
|
choices: argDef?.choices,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
site: def.site ?? site,
|
|
name: def.name ?? path.basename(filePath, path.extname(filePath)),
|
|
description: def.description ?? '',
|
|
domain: def.domain,
|
|
strategy: strategy.toLowerCase(),
|
|
browser,
|
|
args,
|
|
columns: def.columns,
|
|
pipeline: def.pipeline,
|
|
timeout: def.timeout,
|
|
type: 'yaml',
|
|
};
|
|
} catch (err: any) {
|
|
process.stderr.write(`Warning: failed to parse ${filePath}: ${err.message}\n`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function scanTs(filePath: string, site: string): ManifestEntry {
|
|
// TS adapters self-register via cli() at import time.
|
|
// We statically parse the source to extract metadata for the manifest stub.
|
|
const baseName = path.basename(filePath, path.extname(filePath));
|
|
const relativePath = `${site}/${baseName}.js`;
|
|
|
|
const entry: ManifestEntry = {
|
|
site,
|
|
name: baseName,
|
|
description: '',
|
|
strategy: 'cookie',
|
|
browser: true,
|
|
args: [],
|
|
type: 'ts',
|
|
modulePath: relativePath,
|
|
};
|
|
|
|
try {
|
|
const src = fs.readFileSync(filePath, 'utf-8');
|
|
|
|
// Extract description
|
|
const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
if (descMatch) entry.description = descMatch[1];
|
|
|
|
// Extract domain
|
|
const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
if (domainMatch) entry.domain = domainMatch[1];
|
|
|
|
// Extract strategy
|
|
const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
|
|
if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
|
|
|
|
// Extract browser: false (some adapters bypass browser entirely)
|
|
const browserMatch = src.match(/browser\s*:\s*(true|false)/);
|
|
if (browserMatch) entry.browser = browserMatch[1] === 'true';
|
|
|
|
// Extract columns
|
|
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
|
|
if (colMatch) {
|
|
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
|
|
}
|
|
|
|
// Extract args array items: { name: '...', ... }
|
|
const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
|
|
if (argsBlockMatch) {
|
|
const argsBlock = argsBlockMatch[1];
|
|
const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
|
|
let m;
|
|
while ((m = argRegex.exec(argsBlock)) !== null) {
|
|
const argName = m[1];
|
|
const body = m[2];
|
|
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
|
|
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
|
|
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
|
|
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
|
|
let defaultVal: any = undefined;
|
|
if (defaultMatch) {
|
|
const raw = defaultMatch[1].trim();
|
|
if (raw === 'true') defaultVal = true;
|
|
else if (raw === 'false') defaultVal = false;
|
|
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
|
|
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
|
|
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
|
|
}
|
|
|
|
entry.args.push({
|
|
name: argName,
|
|
type: typeMatch?.[1] ?? 'str',
|
|
default: defaultVal,
|
|
required: requiredMatch?.[1] === 'true',
|
|
help: helpMatch?.[1] ?? '',
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// If parsing fails, fall back to empty metadata — module will self-register at runtime
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
// Main
|
|
const manifest: ManifestEntry[] = [];
|
|
|
|
if (fs.existsSync(CLIS_DIR)) {
|
|
for (const site of fs.readdirSync(CLIS_DIR)) {
|
|
const siteDir = path.join(CLIS_DIR, site);
|
|
if (!fs.statSync(siteDir).isDirectory()) continue;
|
|
for (const file of fs.readdirSync(siteDir)) {
|
|
const filePath = path.join(siteDir, file);
|
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
const entry = scanYaml(filePath, site);
|
|
if (entry) manifest.push(entry);
|
|
} else if (
|
|
(file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
|
|
(file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
|
|
) {
|
|
manifest.push(scanTs(filePath, site));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
|
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
|
|
|
|
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
|
|
const tsCount = manifest.filter(e => e.type === 'ts').length;
|
|
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
|