#!/usr/bin/env node /** * trek-plugin pack [dir] [--out plugin.zip] [--json] * * Turns a built plugin directory into the exact plugin.zip the TREK installer * expects, and prints its sha256 + byte size — the two values authors otherwise * compute by hand for the registry entry. Validates the manifest first, refuses * native binaries (same rule as the installer), and enforces the same size * limits, so a local pack that succeeds installs cleanly. */ import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { makeZip, type ZipInput } from '../zip.js'; import { validatePluginDir } from './validate.js'; const MAX_TOTAL = 50 * 1024 * 1024; const MAX_FILE = 25 * 1024 * 1024; const MAX_ENTRIES = 4000; // Files kept at the archive root; everything the installer needs plus light docs. const ROOT_FILES = ['trek-plugin.json', 'README.md', 'LICENSE', 'LICENSE.md', 'package.json']; // Directories walked recursively into the archive. const DIRS = ['server', 'client']; // Never shipped: dep trees, VCS, source maps, TypeScript sources, the output zip. const SKIP_DIR = new Set(['node_modules', '.git']); const NATIVE_RE = /\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; export interface PackResult { artifact: string; sha256: string; size: number; files: string[]; } function walk(base: string, rel: string, out: ZipInput[]): void { for (const entry of fs.readdirSync(path.join(base, rel), { withFileTypes: true })) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { if (SKIP_DIR.has(entry.name)) continue; walk(base, childRel, out); } else if (entry.isFile()) { if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue; out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); } // symlinks and other non-regular entries are skipped (never shipped) } } export function packPluginDir(dir: string, outPath: string): PackResult { const report = validatePluginDir(dir); if (!report.ok) throw new Error('plugin is not valid:\n - ' + report.errors.join('\n - ')); const outAbs = path.resolve(outPath); const files: ZipInput[] = []; for (const f of ROOT_FILES) { const p = path.join(dir, f); if (fs.existsSync(p) && fs.statSync(p).isFile()) files.push({ name: f, data: fs.readFileSync(p) }); } for (const d of DIRS) { if (fs.existsSync(path.join(dir, d))) walk(dir, d, files); } // Drop an existing output zip if it happens to sit in the tree. const filtered = files.filter((f) => path.resolve(dir, f.name) !== outAbs); // Same gates the installer enforces, surfaced locally. let total = 0; for (const f of filtered) { if (NATIVE_RE.test(f.name)) throw new Error(`native binaries are not allowed: ${f.name}`); if (f.data.length > MAX_FILE) throw new Error(`file too large (>25MB): ${f.name}`); total += f.data.length; } if (total > MAX_TOTAL) throw new Error('archive exceeds the 50MB limit'); if (filtered.length > MAX_ENTRIES) throw new Error('too many files (>4000)'); if (!filtered.some((f) => f.name === 'trek-plugin.json')) throw new Error('trek-plugin.json must be at the plugin root'); const buf = makeZip(filtered); fs.writeFileSync(outAbs, buf); return { artifact: outAbs, sha256: createHash('sha256').update(buf).digest('hex'), size: buf.length, files: filtered.map((f) => f.name).sort(), }; }