feat: centralize native dep installation across plugins

Replace per-plugin npm install with a single aggregated npm install
in the quartz root. Plugins declaring requiresInstall in their quartz
manifest now have their peerDependencies collected and installed
together, letting npm resolve compatible versions across plugins.
This commit is contained in:
saberzero1 2026-03-17 22:32:47 +01:00
parent 13a25cbfaf
commit 51b05e79e2
No known key found for this signature in database
3 changed files with 148 additions and 35 deletions

12
package-lock.json generated
View File

@ -93,9 +93,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -544,9 +544,9 @@
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"

View File

@ -16,6 +16,7 @@ import {
import {
parsePluginSource,
installPlugin,
installNativeDeps,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
@ -236,12 +237,31 @@ export async function loadQuartzConfig(
const enabledEntries = json.plugins.filter((e) => e.enabled)
const manifests = new Map<string, PluginManifest>()
// Ensure all plugins are installed and collect manifests
// Ensure all plugins are installed and collect native deps
const allNativeDeps = new Map<string, Map<string, string>>()
for (const entry of enabledEntries) {
try {
const gitSpec = parsePluginSource(entry.source)
await installPlugin(gitSpec, { verbose: false })
const result = await installPlugin(gitSpec, { verbose: false })
if (result.nativeDeps.size > 0) {
allNativeDeps.set(gitSpec.name, result.nativeDeps)
}
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to install plugin: ${styleText("yellow", entry.source)}\n` +
` ${err instanceof Error ? err.message : String(err)}`,
)
}
}
if (allNativeDeps.size > 0) {
installNativeDeps(allNativeDeps, { verbose: false })
}
// Collect manifests (requires native deps to be installed first)
for (const entry of enabledEntries) {
try {
const manifest = await getManifest(entry.source)
if (manifest) {
manifests.set(entry.source, manifest)
@ -249,7 +269,7 @@ export async function loadQuartzConfig(
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to install plugin: ${styleText("yellow", entry.source)}\n` +
` Failed to load manifest: ${styleText("yellow", entry.source)}\n` +
` ${err instanceof Error ? err.message : String(err)}`,
)
}

View File

@ -118,43 +118,130 @@ function extractRepoName(url: string): string {
return match ? match[1] : "unknown"
}
async function installPluginDepsIfNeeded(
pluginDir: string,
pluginName: string,
options: { verbose?: boolean },
): Promise<void> {
/**
* Collect native (peer) dependencies from a plugin that declares requiresInstall.
*/
function collectNativeDeps(pluginDir: string): Map<string, string> {
const result = new Map<string, string>()
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return
if (!fs.existsSync(pkgPath)) return result
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const manifest = pkg.quartz ?? pkg.manifest ?? {}
if (!manifest.requiresInstall) return
if (!manifest.requiresInstall) return result
const peerDeps: Record<string, string> = pkg.peerDependencies ?? {}
for (const [name, range] of Object.entries(peerDeps)) {
// Skip shared externals that Quartz already provides
if (SHARED_EXTERNALS.some((prefix) => name.startsWith(prefix)) || name === "vfile") {
continue
}
result.set(name, range)
}
} catch {
// ignore parse errors
}
return result
}
/**
* Install all collected native dependencies into the Quartz root with a single
* `npm install --no-save`. Lets npm resolve compatible versions across plugins.
*/
export function installNativeDeps(
nativeDeps: Map<string, Map<string, string>>,
options: { verbose?: boolean },
): void {
const merged = new Map<string, Map<string, string>>()
for (const [pluginName, deps] of nativeDeps) {
for (const [pkg, range] of deps) {
if (!merged.has(pkg)) {
merged.set(pkg, new Map())
}
merged.get(pkg)!.set(pluginName, range)
}
}
if (merged.size === 0) return
const installArgs: string[] = []
for (const [pkg, pluginRanges] of merged) {
const ranges = [...pluginRanges.values()]
const uniqueRanges = [...new Set(ranges)]
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.`,
const sources = [...pluginRanges.entries()]
.map(([plugin, range]) => `${plugin} (${range})`)
.join(", ")
console.log(
styleText("cyan", ``),
`Native dep ${styleText("bold", pkg)} required by: ${sources}`,
)
}
if (uniqueRanges.length === 1) {
installArgs.push(`${pkg}@${JSON.stringify(uniqueRanges[0])}`)
} else {
if (options.verbose) {
console.warn(
styleText("yellow", ``),
`Multiple version ranges for ${pkg}: ${uniqueRanges.join(", ")}. npm will attempt to resolve a compatible version.`,
)
}
// Use first range; npm will fail if truly incompatible
installArgs.push(`${pkg}@${JSON.stringify(uniqueRanges[0])}`)
}
}
if (installArgs.length === 0) return
if (options.verbose) {
console.log(
styleText("cyan", ``),
`Installing ${installArgs.length} native package(s) into Quartz root...`,
)
}
try {
execSync(`npm install --no-save ${installArgs.join(" ")}`, {
cwd: process.cwd(),
stdio: options.verbose ? "inherit" : "pipe",
timeout: 120_000,
})
if (options.verbose) {
console.log(
styleText("green", ``),
`Installed native dependencies: ${[...merged.keys()].join(", ")}`,
)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(
styleText("red", ``),
`Failed to install native dependencies. This may indicate incompatible version ranges across plugins.\n` +
` Packages: ${[...merged.keys()].join(", ")}\n` +
` Error: ${message}`,
)
throw new Error(`Native dependency installation failed: ${message}`)
}
}
interface PluginInstallResult {
pluginDir: string
nativeDeps: Map<string, string>
}
/**
* Install a plugin from a Git repository, or symlink a local plugin.
* Returns the plugin directory and any native dependencies it requires.
*/
export async function installPlugin(
spec: GitPluginSpec,
options: { verbose?: boolean; force?: boolean } = {},
): Promise<string> {
): Promise<PluginInstallResult> {
const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name)
// Local source: symlink instead of clone
@ -171,7 +258,7 @@ export async function installPlugin(
if (options.verbose) {
console.log(styleText("cyan", ``), `Plugin ${spec.name} already linked`)
}
return pluginDir
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
}
} catch {
// stat failed, recreate
@ -204,7 +291,7 @@ export async function installPlugin(
console.log(styleText("green", ``), `Linked ${spec.name}`)
}
return pluginDir
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
}
// Git source: clone
@ -216,7 +303,7 @@ export async function installPlugin(
if (options.verbose) {
console.log(styleText("cyan", ``), `Plugin ${spec.name} already installed`)
}
return pluginDir
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
} catch {
// If git operations fail, re-clone
}
@ -244,13 +331,11 @@ export async function installPlugin(
noCheckout: false,
})
await installPluginDepsIfNeeded(pluginDir, spec.name, options)
if (options.verbose) {
console.log(styleText("green", ``), `Installed ${spec.name}`)
}
return pluginDir
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
}
/**
@ -261,18 +346,26 @@ export async function installPlugins(
options: { verbose?: boolean; force?: boolean } = {},
): Promise<Map<string, string>> {
const installed = new Map<string, string>()
const allNativeDeps = new Map<string, Map<string, string>>()
for (const source of sources) {
try {
const spec = typeof source === "string" ? parsePluginSource(source) : source
const pluginDir = await installPlugin(spec, options)
installed.set(spec.name, pluginDir)
const result = await installPlugin(spec, options)
installed.set(spec.name, result.pluginDir)
if (result.nativeDeps.size > 0) {
allNativeDeps.set(spec.name, result.nativeDeps)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(styleText("red", ``), `Failed to install plugin: ${message}`)
}
}
if (allNativeDeps.size > 0) {
installNativeDeps(allNativeDeps, options)
}
await regeneratePluginIndex(options)
return installed