plugin-install.mjs
Rama main · réplica de lectura del repositorio original.
#!/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);
}