#!/usr/bin/env node // Builds the FIRST-PARTY plugins that ship with this fork (vnc/plugins/*) and // stages them where the running server can install them. // // Why this exists // --------------- // `vnc/plugins/smime` is audited SOURCE, not a prebuilt drop (upstream's own // smime.zip was deliberately distrusted). Nothing built it in any real build, // so the S/MIME policy toggle was on while no plugin existed. This script is // the missing build step; `lib/admin/bundled-plugins.ts` is the matching // install step that runs at server startup. // // Output layout (gitignored, produced not committed): // // vnc/plugins/build//manifest.json // vnc/plugins/build// e.g. index.js // // That directory is copied into the container image (Dockerfile) and into // .next/standalone (scripts/assemble-standalone.mjs), so every distribution // path - container, Electron, local dev - boots with the same artifact. // // Each plugin keeps its OWN package.json + lockfile so its (crypto) deps stay // pinned by the audit, rather than being restated in the app's root manifest. import { execFileSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const pluginsDir = path.join(rootDir, "vnc", "plugins"); const outDir = path.join(pluginsDir, "build"); // Mirrors MAX_PLUGIN_SIZE in lib/plugin-types.ts - the cap the admin upload // route enforces. A first-party plugin must live inside the same budget. const MAX_BUNDLE_BYTES = 5 * 1024 * 1024; const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const npm = process.platform === "win32" ? "npm.cmd" : "npm"; function run(cmd, args, cwd) { execFileSync(cmd, args, { cwd, stdio: "inherit" }); } function listPluginDirs() { if (!existsSync(pluginsDir)) return []; return readdirSync(pluginsDir) .filter((name) => name !== "build" && !name.startsWith(".")) .map((name) => path.join(pluginsDir, name)) .filter((dir) => statSync(dir).isDirectory()) .filter((dir) => existsSync(path.join(dir, "manifest.json"))); } function buildOne(pluginDir) { const manifestPath = path.join(pluginDir, "manifest.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); const id = manifest.id; if (typeof id !== "string" || !ID_RE.test(id)) { throw new Error(`${manifestPath}: invalid plugin id ${JSON.stringify(id)}`); } const entrypoint = typeof manifest.entrypoint === "string" ? manifest.entrypoint : "index.js"; if (entrypoint.includes("/") || entrypoint.includes("\\")) { throw new Error(`${manifestPath}: entrypoint must be a bare filename`); } const pkgPath = path.join(pluginDir, "package.json"); if (!existsSync(pkgPath)) { throw new Error(`${pluginDir}: no package.json, cannot build`); } const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); if (!pkg.scripts?.build) { throw new Error(`${pkgPath}: no "build" script`); } // Install the plugin's own deps only when they're missing. Keeps repeat // builds (and `npm run dev`) fast - the actual esbuild step is ~20ms. if (!existsSync(path.join(pluginDir, "node_modules"))) { const hasLock = existsSync(path.join(pluginDir, "package-lock.json")); console.log(`[build-plugins] installing deps for ${id}`); run(npm, hasLock ? ["ci"] : ["install", "--no-audit", "--no-fund"], pluginDir); } console.log(`[build-plugins] building ${id}`); run(npm, ["run", "build"], pluginDir); const built = path.join(pluginDir, "dist", entrypoint); if (!existsSync(built)) { throw new Error(`${id}: build produced no ${path.relative(rootDir, built)}`); } const size = statSync(built).size; if (size > MAX_BUNDLE_BYTES) { throw new Error( `${id}: bundle is ${(size / 1024 / 1024).toFixed(2)} MB, over the ` + `${MAX_BUNDLE_BYTES / 1024 / 1024} MB plugin limit`, ); } const stageDir = path.join(outDir, id); rmSync(stageDir, { recursive: true, force: true }); mkdirSync(stageDir, { recursive: true }); cpSync(manifestPath, path.join(stageDir, "manifest.json")); cpSync(built, path.join(stageDir, entrypoint)); console.log( `[build-plugins] staged ${id} v${manifest.version} ` + `(${(size / 1024).toFixed(0)} KB) -> ${path.relative(rootDir, stageDir)}`, ); } const dirs = listPluginDirs(); if (dirs.length === 0) { console.log("[build-plugins] no first-party plugins found under vnc/plugins"); process.exit(0); } // Rebuild the staging root from scratch so a plugin removed from the tree does // not linger as a stale bundle that the server would happily keep installing. rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); for (const dir of dirs) buildOne(dir); console.log(`[build-plugins] done (${dirs.length} plugin(s))`);