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