mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
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:
parent
a932000421
commit
d792d8ebf9
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user