feat: add runtime plugin externals validation and native dep install

- validatePluginExternals() scans plugin dist/ for unbundled imports
  and warns when non-allowlisted externals are detected
- installPluginDepsIfNeeded() runs npm install for plugins with
  quartz.requiresInstall flag (for native deps like sharp)
- Added requiresInstall field to PluginManifest type
This commit is contained in:
saberzero1 2026-03-17 19:46:42 +01:00
parent a932000421
commit d792d8ebf9
No known key found for this signature in database
3 changed files with 146 additions and 0 deletions

View File

@ -1,5 +1,6 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { execSync } from "child_process"
import git from "isomorphic-git" import git from "isomorphic-git"
import http from "isomorphic-git/http/node" import http from "isomorphic-git/http/node"
import { styleText } from "util" import { styleText } from "util"
@ -117,6 +118,36 @@ function extractRepoName(url: string): string {
return match ? match[1] : "unknown" return match ? match[1] : "unknown"
} }
async function installPluginDepsIfNeeded(
pluginDir: string,
pluginName: string,
options: { verbose?: boolean },
): Promise<void> {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const manifest = pkg.quartz ?? pkg.manifest ?? {}
if (!manifest.requiresInstall) return
if (options.verbose) {
console.log(styleText("cyan", ``), `Installing native dependencies for ${pluginName}...`)
}
execSync("npm install --omit=dev --ignore-scripts=false", {
cwd: pluginDir,
stdio: options.verbose ? "inherit" : "pipe",
timeout: 60_000,
})
} catch {
console.warn(
styleText("yellow", ``),
`Failed to install dependencies for ${pluginName}. Native features may not work.`,
)
}
}
/** /**
* Install a plugin from a Git repository, or symlink a local plugin. * Install a plugin from a Git repository, or symlink a local plugin.
*/ */
@ -213,6 +244,8 @@ export async function installPlugin(
noCheckout: false, noCheckout: false,
}) })
await installPluginDepsIfNeeded(pluginDir, spec.name, options)
if (options.verbose) { if (options.verbose) {
console.log(styleText("green", ``), `Installed ${spec.name}`) console.log(styleText("green", ``), `Installed ${spec.name}`)
} }
@ -415,6 +448,114 @@ export function cleanPlugins(): void {
} }
} }
const NODE_BUILTINS = new Set([
"assert",
"buffer",
"child_process",
"cluster",
"console",
"constants",
"crypto",
"dgram",
"dns",
"domain",
"events",
"fs",
"http",
"http2",
"https",
"inspector",
"module",
"net",
"os",
"path",
"perf_hooks",
"process",
"punycode",
"querystring",
"readline",
"repl",
"stream",
"string_decoder",
"sys",
"timers",
"tls",
"trace_events",
"tty",
"url",
"util",
"v8",
"vm",
"wasi",
"worker_threads",
"zlib",
])
const SHARED_EXTERNALS = ["@quartz-community/", "preact", "@jackyzha0/quartz", "vfile"]
function isAllowedExternal(specifier: string, pluginPeerDeps: string[]): boolean {
if (specifier.startsWith("node:")) return true
const bare = specifier.split("/")[0]
if (NODE_BUILTINS.has(bare)) return true
if (SHARED_EXTERNALS.some((prefix) => specifier.startsWith(prefix))) return true
if (pluginPeerDeps.some((dep) => specifier === dep || specifier.startsWith(dep + "/"))) {
return true
}
return false
}
export function validatePluginExternals(
pluginName: string,
entryPoint: string,
options?: { verbose?: boolean },
): string[] {
try {
const content = fs.readFileSync(entryPoint, "utf-8")
let peerDeps: string[] = []
const pluginDir = path.dirname(entryPoint).replace(/\/dist$/, "")
const pkgPath = path.join(pluginDir, "package.json")
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
peerDeps = Object.keys(pkg.peerDependencies ?? {})
} catch {
// ignore parse errors
}
}
const importPattern =
/^\s*(?:import\s+.*\s+from|export\s+.*\s+from)\s+["']([^"'./][^"']*)["']/gm
const unexpected: string[] = []
for (const match of content.matchAll(importPattern)) {
const specifier = match[1]
if (!isAllowedExternal(specifier, peerDeps)) {
unexpected.push(specifier)
}
}
const unique = [...new Set(unexpected)]
if (unique.length > 0 && options?.verbose) {
console.warn(
styleText("yellow", ``) +
` Plugin ${styleText("cyan", pluginName)} has unbundled external imports that may fail at runtime:\n` +
unique.map((s) => ` - ${s}`).join("\n") +
`\n These packages are not provided by Quartz. The plugin should bundle them into dist/.`,
)
}
return unique
} catch {
return []
}
}
export async function regeneratePluginIndex(options: { verbose?: boolean } = {}): Promise<void> { export async function regeneratePluginIndex(options: { verbose?: boolean } = {}): Promise<void> {
if (!fs.existsSync(PLUGINS_CACHE_DIR)) { if (!fs.existsSync(PLUGINS_CACHE_DIR)) {
return return

View File

@ -20,6 +20,7 @@ import {
getPluginEntryPoint, getPluginEntryPoint,
toFileUrl, toFileUrl,
isLocalSource, isLocalSource,
validatePluginExternals,
} from "./gitLoader" } from "./gitLoader"
const MINIMUM_QUARTZ_VERSION = "4.5.0" const MINIMUM_QUARTZ_VERSION = "4.5.0"
@ -191,6 +192,8 @@ async function resolveSinglePlugin(
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
const importedManifest: PluginManifest | null = module.manifest ?? null const importedManifest: PluginManifest | null = module.manifest ?? null
validatePluginExternals(gitSpec.name, entryPoint, { verbose: options.verbose })
manifest = importedManifest ?? {} manifest = importedManifest ?? {}
const categoryOrCategories = manifest.category ?? detectPluginType(module) const categoryOrCategories = manifest.category ?? detectPluginType(module)

View File

@ -65,6 +65,8 @@ export interface PluginManifest {
components?: Record<string, ComponentManifest & ComponentLayoutDefaults> components?: Record<string, ComponentManifest & ComponentLayoutDefaults>
/** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */ /** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */
frames?: Record<string, { exportName: string }> frames?: Record<string, { exportName: string }>
/** Whether the plugin requires `npm install` after cloning (e.g. for native dependencies like sharp). */
requiresInstall?: boolean
} }
/** /**