Ctrl K
ComunidadGuías…

plugin-install.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// @ts-check
// plugin-install.mjs — clone/scaffold/validate community plugins. Lives at repo
// ROOT (not under plugins/) because it uses node:child_process to run git, which
// test-all's plugin deny-list forbids inside plugins/. Imported by plugins.mjs.
//
// Security (the clone-time-RCE class): git is spawned via execFileSync with an
// ARRAY argv (never a shell string), the URL is strict-allowlisted to
// https://github.com/<owner>/<repo>, alternate transports are disabled
// (protocol.ext/file allow=never), and the EXACT commit SHA is fetched + checked
// out (a moved tag or force-push cannot substitute different code).

import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, rmSync, cpSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { validateManifest } from './plugins/_engine.mjs';
import { hashPluginTree } from './plugins/_lock.mjs';
import { auditPlugin } from './plugin-audit.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const GITHUB_URL_RE = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?(?:\.git)?$/;
const NAME_RE = /^career-ops-plugin-([a-z0-9][a-z0-9-]*)$/;
const SHA_RE = /^[0-9a-f]{40}$/;
const MIN_FILES = ['manifest.json', 'index.mjs', 'README.md', 'LICENSE'];

/** Normalize `owner/repo` | full URL into a validated github URL + the plugin id. */
export function parseRepoArg(arg) {
  let url = arg;
  if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(arg)) url = `https://github.com/${arg}`;
  url = url.replace(/\.git$/, '');
  if (!GITHUB_URL_RE.test(url)) throw new Error(`refusing non-GitHub/unsafe repo URL: ${arg} (expected https://github.com/<owner>/<repo>)`);
  const repoName = url.split('/').pop() || '';
  const m = NAME_RE.exec(repoName);
  if (!m) throw new Error(`repo must be named "career-ops-plugin-<name>" (got "${repoName}")`);
  return { url, id: m[1] };
}

/** Clone the EXACT pinned SHA into a fresh temp dir. Returns the temp dir path. */
export function safeClone(url, sha) {
  if (!SHA_RE.test(sha || '')) throw new Error(`a 40-hex commit --sha is required (got ${JSON.stringify(sha)})`);
  const dir = mkdtempSync(path.join(tmpdir(), 'co-plugin-'));
  const git = (...args) => execFileSync('git', ['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', ...args], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 120_000 });
  try {
    git('-C', dir, 'init', '-q');
    git('-C', dir, 'remote', 'add', 'origin', '--', url);
    git('-C', dir, 'fetch', '--depth', '1', '--no-tags', '-q', 'origin', sha);
    git('-C', dir, 'checkout', '-q', 'FETCH_HEAD');
    rmSync(path.join(dir, '.git'), { recursive: true, force: true }); // drop VCS metadata (and any hooks)
    return dir;
  } catch (err) {
    rmSync(dir, { recursive: true, force: true });
    throw new Error(`clone of ${url}@${sha.slice(0, 10)} failed — ${err.stderr ? String(err.stderr).slice(0, 200) : err.message}`);
  }
}

/** Check the minimum file set + a valid manifest whose id matches `expectId`. */
export function validateInstall(dir, expectId) {
  const problems = [];
  for (const f of MIN_FILES) if (!existsSync(path.join(dir, f))) problems.push(`missing required file: ${f}`);
  if (problems.length) return { ok: false, problems, manifest: null };
  let parsed;
  try { parsed = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8')); }
  catch (e) { return { ok: false, problems: [`manifest.json invalid JSON: ${e.message}`], manifest: null }; }
  // validateManifest wants the dir to BE the plugin dir + the basename to equal id.
  const tmpNamed = path.join(path.dirname(dir), expectId);
  if (dir !== tmpNamed) { try { renameSync(dir, tmpNamed); dir = tmpNamed; } catch { /* validate in place using expectId */ } }
  const manifest = validateManifest(parsed, dir, expectId);
  if (!manifest) return { ok: false, problems: ['manifest failed validation (see ⚠️ above)'], manifest: null, dir };
  const audit = auditPlugin(dir);
  if (!audit.ok) return { ok: false, problems: audit.findings.map(f => `${f.file}: ${f.issue}`), manifest, dir };
  return { ok: true, problems: [], manifest, dir };
}

/**
 * Install a community plugin from a github repo at a pinned SHA into
 * plugins.local/<id>. Returns { id, manifest, integrity, dir } WITHOUT enabling
 * it (the caller runs the consent gate). Throws on any validation failure.
 */
export function installFromRepo(root, { url, sha }) {
  const { url: safeUrl, id } = parseRepoArg(url);
  const dest = path.join(root, 'plugins.local', id);
  if (existsSync(dest)) throw new Error(`plugins.local/${id} already exists — \`node plugins.mjs remove ${id}\` first`);
  let cloned = safeClone(safeUrl, sha);
  let result;
  try { result = validateInstall(cloned, id); }
  catch (e) { rmSync(cloned, { recursive: true, force: true }); throw e; }
  if (!result.ok) {
    rmSync(result.dir || cloned, { recursive: true, force: true });
    throw new Error(`plugin rejected:\n  - ${result.problems.join('\n  - ')}`);
  }
  mkdirSync(path.join(root, 'plugins.local'), { recursive: true });
  cpSync(result.dir, dest, { recursive: true });
  rmSync(result.dir, { recursive: true, force: true });
  const tree = hashPluginTree(dest);
  return { id, manifest: { ...result.manifest, dir: dest }, integrity: tree.integrity, files: tree.files, repo: safeUrl, sha };
}

/**
 * Clone + statically validate a registry entry WITHOUT installing it (used by
 * the registry-validate CI). Executes NO plugin code — manifest is parsed, the
 * audit is static. Returns problems (empty = clean).
 * @returns {string[]}
 */
export function auditRegistryEntry(url, sha, expectId) {
  let parsed;
  try { parsed = parseRepoArg(url); } catch (e) { return [e.message]; }
  if (expectId && parsed.id !== expectId) return [`repo "${url}" → id "${parsed.id}" but registry id is "${expectId}"`];
  let dir;
  try { dir = safeClone(parsed.url, sha); } catch (e) { return [e.message]; }
  let result;
  try { result = validateInstall(dir, parsed.id); }
  catch (e) { rmSync(dir, { recursive: true, force: true }); return [e.message]; }
  try { rmSync(result.dir || dir, { recursive: true, force: true }); } catch { /* best-effort */ }
  return result.ok ? [] : result.problems;
}

/** Scaffold a new local plugin from plugins/_template/. */
export function scaffoldNew(root, name) {
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`plugin name must match [a-z0-9-] (got "${name}")`);
  const tpl = path.join(root, 'plugins', '_template');
  if (!existsSync(tpl)) throw new Error('plugins/_template/ not found');
  const dest = path.join(root, 'plugins.local', name);
  if (existsSync(dest)) throw new Error(`plugins.local/${name} already exists`);
  mkdirSync(path.join(root, 'plugins.local'), { recursive: true });
  cpSync(tpl, dest, { recursive: true });
  // The template ships its skill as skill.md.template so skill catalogs that
  // index any SKILL.md with frontmatter do not list the placeholder as a real
  // skill; the scaffolded plugin gets the skill.md its manifest points at.
  const skillTpl = path.join(dest, 'skill.md.template');
  if (existsSync(skillTpl)) renameSync(skillTpl, path.join(dest, 'skill.md'));
  // Substitute {{NAME}} placeholders in shipped text files.
  const sub = (p) => { if (existsSync(p)) writeFileSync(p, readFileSync(p, 'utf8').replaceAll('{{NAME}}', name), 'utf8'); };
  for (const f of readdirSync(dest, { withFileTypes: true })) {
    if (f.isFile()) sub(path.join(dest, f.name));
  }
  if (existsSync(path.join(dest, 'test'))) for (const f of readdirSync(path.join(dest, 'test'))) sub(path.join(dest, 'test', f));
  return dest;
}

// ── CLI flags + help ────────────────────────────────────────────────
//
// This file is a library first: plugins.mjs owns the user-facing `new`/`add`
// commands and calls the exports above. But docs/SCRIPTS.md lists `node
// plugin-install.mjs` as a runnable command, so it is reachable directly — and
// every direct invocation printed nothing and exited 0, `--help` and a typo'd
// flag included. That is the silent-success shape lib/cli-flags.mjs exists to
// end (#2775): nothing told you the install/scaffold commands live elsewhere.
//
// KNOWN_FLAGS is exactly --help/-h because that is every flag this file parses.
// --sha and --confirm are plugins.mjs's flags, not this script's, so they are
// pointed at rather than claimed here.

const KNOWN_FLAGS = ['--help', '-h'];

const USAGE = `Usage:
  node plugin-install.mjs --help|-h   # print this usage block and exit

plugin-install.mjs takes no flags or arguments of its own — it is the
clone/scaffold/validate engine behind plugins.mjs, which owns the CLI:

  node plugins.mjs new <name>
      # scaffold plugins.local/<name> from plugins/_template/

  node plugins.mjs add <name|owner/repo> [--sha <commit>] [--confirm]
      # clone a pinned commit into plugins.local/<id>, validate it, and print
      # its capability card (--confirm also enables it)

Install constraints enforced here: the repo must be named
career-ops-plugin-<name>, live at https://github.com/<owner>/<repo>, and be
pinned to a 40-hex commit SHA — a registry entry supplies the SHA, anything
else requires --sha. See docs/PLUGINS.md.`;

if (isMainModule(import.meta.url)) {
  const args = process.argv.slice(2);

  // Unrecognized flags exit 1 naming the flag; --help/-h print USAGE and exit 0.
  validateFlags(args, KNOWN_FLAGS, USAGE);

  // Anything left is a non-flag operand (a repo, a plugin name) aimed at a
  // command this file does not have. Exiting 0 in silence is how you end up
  // believing a plugin was installed, so say where the command lives instead.
  if (args.length > 0) {
    console.error(`Error: plugin-install.mjs takes no arguments (got: ${args.join(' ')}). Its commands live on plugins.mjs.`);
    console.error(USAGE);
    process.exit(1);
  }

  // Bare `node plugin-install.mjs`, the form docs/SCRIPTS.md documents: there is
  // no work to do here, so print where the work is rather than nothing at all.
  console.log(USAGE);
}