fix(cli): resolve subdir plugins during build installs

This commit is contained in:
saberzero1 2026-03-18 02:39:45 +01:00
parent 166b9c87d8
commit f2c2a31292
No known key found for this signature in database
5 changed files with 203 additions and 49 deletions

View File

@ -6,12 +6,11 @@ import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadComponentsFromPackage( export async function loadComponentsFromPackage(
pluginName: string, pluginName: string,
manifest: PluginManifest | null, manifest: PluginManifest | null,
subdir?: string,
): Promise<void> { ): Promise<void> {
if (!manifest?.components) return if (!manifest?.components) return
try { try {
const componentsPath = getPluginSubpathEntry(pluginName, "./components", subdir) const componentsPath = getPluginSubpathEntry(pluginName, "./components")
let componentsModule: Record<string, unknown> let componentsModule: Record<string, unknown>
if (componentsPath) { if (componentsPath) {

View File

@ -189,7 +189,7 @@ function validateDependencies(
async function resolvePluginManifest(source: PluginSource): Promise<PluginManifest | null> { async function resolvePluginManifest(source: PluginSource): Promise<PluginManifest | null> {
try { try {
const gitSpec = parsePluginSource(source) const gitSpec = parsePluginSource(source)
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name)
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
return module.manifest ?? null return module.manifest ?? null
} catch { } catch {
@ -343,7 +343,7 @@ export async function loadQuartzConfig(
// Always import the main entry point for component-only plugins. // Always import the main entry point for component-only plugins.
// Some plugins (e.g. Bases view registrations) rely on side effects // Some plugins (e.g. Bases view registrations) rely on side effects
// in their index module to register functionality. // in their index module to register functionality.
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name)
try { try {
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
// If the module exports an init() function, call it with merged options // If the module exports an init() function, call it with merged options
@ -356,22 +356,22 @@ export async function loadQuartzConfig(
// Side-effect import failed — continue with manifest-based loading // Side-effect import failed — continue with manifest-based loading
} }
if (manifest?.components && Object.keys(manifest.components).length > 0) { if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest)
} }
if (manifest?.frames && Object.keys(manifest.frames).length > 0) { if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadFramesFromPackage(gitSpec.name, manifest)
} }
} else { } else {
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name)
try { try {
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
const detected = detectCategoryFromModule(module) const detected = detectCategoryFromModule(module)
if (detected) { if (detected) {
categoryMap[detected].push({ entry, manifest }) categoryMap[detected].push({ entry, manifest })
} else if (manifest?.components && Object.keys(manifest.components).length > 0) { } else if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest)
if (manifest?.frames && Object.keys(manifest.frames).length > 0) { if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadFramesFromPackage(gitSpec.name, manifest)
} }
} else { } else {
console.warn( console.warn(
@ -383,10 +383,10 @@ export async function loadQuartzConfig(
const hasComponents = manifest?.components && Object.keys(manifest.components).length > 0 const hasComponents = manifest?.components && Object.keys(manifest.components).length > 0
const hasFrames = manifest?.frames && Object.keys(manifest.frames).length > 0 const hasFrames = manifest?.frames && Object.keys(manifest.frames).length > 0
if (hasComponents) { if (hasComponents) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest)
} }
if (hasFrames) { if (hasFrames) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadFramesFromPackage(gitSpec.name, manifest)
} }
if (!hasComponents && !hasFrames) { if (!hasComponents && !hasFrames) {
console.warn( console.warn(
@ -423,13 +423,13 @@ export async function loadQuartzConfig(
for (const { entry, manifest } of items) { for (const { entry, manifest } of items) {
try { try {
const gitSpec = parsePluginSource(entry.source) const gitSpec = parsePluginSource(entry.source)
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name)
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
if (manifest?.components && Object.keys(manifest.components).length > 0) { if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest)
} }
if (manifest?.frames && Object.keys(manifest.frames).length > 0) { if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadFramesFromPackage(gitSpec.name, manifest)
} }
const factory = findFactory(module, expectedCategory) const factory = findFactory(module, expectedCategory)

View File

@ -6,12 +6,11 @@ import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadFramesFromPackage( export async function loadFramesFromPackage(
pluginName: string, pluginName: string,
manifest: PluginManifest | null, manifest: PluginManifest | null,
subdir?: string,
): Promise<void> { ): Promise<void> {
if (!manifest?.frames) return if (!manifest?.frames) return
try { try {
const framesPath = getPluginSubpathEntry(pluginName, "./frames", subdir) const framesPath = getPluginSubpathEntry(pluginName, "./frames")
let framesModule: Record<string, unknown> let framesModule: Record<string, unknown>
if (framesPath) { if (framesPath) {

View File

@ -76,8 +76,18 @@ export function parsePluginSource(source: PluginSource): GitPluginSpec {
return { name, repo: resolved, local: true, subdir } return { name, repo: resolved, local: true, subdir }
} }
const name = source.name ?? extractRepoName(url) // Expand shorthand formats in the repo field (e.g. "github:user/repo")
return { name, repo: url, ref: ref || undefined, subdir } // by recursing through the string-based parsing path, then overlay
// the object-level fields (subdir, ref, name) on top.
const expanded = parsePluginSource(url)
const name = source.name ?? expanded.name
return {
name,
repo: expanded.repo,
ref: ref || expanded.ref || undefined,
subdir,
local: expanded.local,
}
} }
// Handle local paths // Handle local paths
@ -248,6 +258,129 @@ export function installNativeDeps(
} }
} }
function isDistGitignored(pluginDir: string): boolean {
const gitignorePath = path.join(pluginDir, ".gitignore")
if (!fs.existsSync(gitignorePath)) return false
const lines = fs.readFileSync(gitignorePath, "utf-8").split("\n")
return lines.some((line) => {
const trimmed = line.trim()
return trimmed === "dist" || trimmed === "dist/" || trimmed === "/dist" || trimmed === "/dist/"
})
}
function needsBuild(pluginDir: string): boolean {
if (isDistGitignored(pluginDir)) return true
const distDir = path.join(pluginDir, "dist")
return !fs.existsSync(distDir)
}
function findPluginByPackageName(packageName: string): string | null {
if (!fs.existsSync(PLUGINS_CACHE_DIR)) return null
const plugins = fs.readdirSync(PLUGINS_CACHE_DIR).filter((entry) => {
const entryPath = path.join(PLUGINS_CACHE_DIR, entry)
return fs.statSync(entryPath).isDirectory()
})
for (const pluginDirName of plugins) {
const pkgPath = path.join(PLUGINS_CACHE_DIR, pluginDirName, "package.json")
if (!fs.existsSync(pkgPath)) continue
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
if (pkg.name === packageName) {
return path.join(PLUGINS_CACHE_DIR, pluginDirName)
}
} catch {}
}
return null
}
/**
* Symlink peer dependencies to the host Quartz node_modules so plugins
* share a single copy of packages like unified, vfile, preact, etc.
* @quartz-community/* peers resolve to co-installed sibling plugins instead.
*/
function linkPeerDependencies(pluginDir: string): void {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const peers: Record<string, string> = pkg.peerDependencies ?? {}
const quartzRoot = path.resolve(pluginDir, "..", "..", "..")
const hostNodeModules = path.join(quartzRoot, "node_modules")
for (const peerName of Object.keys(peers)) {
const peerNodeModulesPath = path.join(pluginDir, "node_modules", ...peerName.split("/"))
if (fs.existsSync(peerNodeModulesPath)) continue
if (peerName.startsWith("@quartz-community/")) {
const siblingPlugin = findPluginByPackageName(peerName)
if (!siblingPlugin) continue
const scopeDir = path.join(pluginDir, "node_modules", peerName.split("/")[0])
fs.mkdirSync(scopeDir, { recursive: true })
const target = path.relative(scopeDir, siblingPlugin)
fs.symlinkSync(target, peerNodeModulesPath, "dir")
continue
}
const hostPeerPath = path.join(hostNodeModules, ...peerName.split("/"))
if (!fs.existsSync(hostPeerPath)) continue
const parts = peerName.split("/")
if (parts.length > 1) {
const scopeDir = path.join(pluginDir, "node_modules", parts[0])
fs.mkdirSync(scopeDir, { recursive: true })
} else {
fs.mkdirSync(path.join(pluginDir, "node_modules"), { recursive: true })
}
const target = path.relative(path.dirname(peerNodeModulesPath), hostPeerPath)
fs.symlinkSync(target, peerNodeModulesPath, "dir")
}
}
function buildInstalledPlugin(pluginDir: string, name: string, verbose?: boolean): void {
try {
const shouldBuild = needsBuild(pluginDir)
if (verbose) {
console.log(styleText("cyan", ``), `${name}: installing dependencies...`)
}
execSync("npm install --ignore-scripts", {
cwd: pluginDir,
stdio: verbose ? "inherit" : "pipe",
timeout: 120_000,
})
if (shouldBuild) {
if (verbose) {
console.log(styleText("cyan", ``), `${name}: building...`)
}
execSync("npm run build", {
cwd: pluginDir,
stdio: verbose ? "inherit" : "pipe",
timeout: 120_000,
})
}
execSync("npm prune --omit=dev", {
cwd: pluginDir,
stdio: verbose ? "inherit" : "pipe",
timeout: 60_000,
})
linkPeerDependencies(pluginDir)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(styleText("red", ``), `${name}: post-install build failed: ${message}`)
throw new Error(`Failed to build plugin ${name}: ${message}`)
}
}
interface PluginInstallResult { interface PluginInstallResult {
pluginDir: string pluginDir: string
nativeDeps: Map<string, string> nativeDeps: Map<string, string>
@ -316,39 +449,66 @@ export async function installPlugin(
// Git source: clone // Git source: clone
// Check if already installed // Check if already installed
if (!options.force && fs.existsSync(pluginDir)) { if (!options.force && fs.existsSync(pluginDir)) {
// Check if it's a git repo by trying to resolve HEAD // For subdir installs, the .git directory is removed after extraction,
try { // so check for package.json instead. For full-repo installs, check git HEAD.
await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" }) if (spec.subdir) {
if (options.verbose) { const pkgPath = path.join(pluginDir, "package.json")
console.log(styleText("cyan", ``), `Plugin ${spec.name} already installed`) if (fs.existsSync(pkgPath)) {
if (options.verbose) {
console.log(styleText("cyan", ``), `Plugin ${spec.name} already installed`)
}
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
}
} else {
try {
await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" })
if (options.verbose) {
console.log(styleText("cyan", ``), `Plugin ${spec.name} already installed`)
}
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
} catch {
// If git operations fail, re-clone
} }
return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) }
} catch {
// If git operations fail, re-clone
} }
} }
// Clean up if force reinstall // Clean up if force reinstall or stale install
if (options.force && fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true }) fs.rmSync(pluginDir, { recursive: true })
} }
if (options.verbose) { if (options.verbose) {
const refSuffix = spec.ref ? `#${spec.ref}` : "" const refSuffix = spec.ref ? `#${spec.ref}` : ""
console.log(styleText("cyan", ``), `Cloning ${spec.name} from ${spec.repo}${refSuffix}...`) const subdirSuffix = spec.subdir ? ` (subdir: ${spec.subdir})` : ""
console.log(
styleText("cyan", ``),
`Cloning ${spec.name} from ${spec.repo}${refSuffix}${subdirSuffix}...`,
)
} }
// Clone the repository if (spec.subdir) {
await git.clone({ const tmpDir = pluginDir + ".__tmp__"
fs, if (fs.existsSync(tmpDir)) {
http, fs.rmSync(tmpDir, { recursive: true })
dir: pluginDir, }
url: spec.repo,
ref: spec.ref, const branchArg = spec.ref ? ` --branch ${spec.ref}` : ""
singleBranch: true, execSync(`git clone --depth 1${branchArg} "${spec.repo}" "${tmpDir}"`, { stdio: "pipe" })
depth: 1,
noCheckout: false, const subdirPath = path.join(tmpDir, spec.subdir)
}) if (!fs.existsSync(subdirPath)) {
fs.rmSync(tmpDir, { recursive: true })
throw new Error(`Subdirectory "${spec.subdir}" not found in repository ${spec.repo}`)
}
fs.renameSync(subdirPath, pluginDir)
fs.rmSync(tmpDir, { recursive: true })
} else {
const branchArg = spec.ref ? ` --branch ${spec.ref}` : ""
execSync(`git clone --depth 1${branchArg} "${spec.repo}" "${pluginDir}"`, { stdio: "pipe" })
}
buildInstalledPlugin(pluginDir, spec.name, options.verbose)
if (options.verbose) { if (options.verbose) {
console.log(styleText("green", ``), `Installed ${spec.name}`) console.log(styleText("green", ``), `Installed ${spec.name}`)
@ -408,9 +568,9 @@ export function isPluginInstalled(name: string): boolean {
* Get the entry point for a plugin. * Get the entry point for a plugin.
* Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues. * Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues.
*/ */
export function getPluginEntryPoint(name: string, subdir?: string): string { export function getPluginEntryPoint(name: string): string {
const pluginDir = getPluginDir(name) const pluginDir = getPluginDir(name)
const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir const searchDir = pluginDir
// Check package.json exports first (most reliable) // Check package.json exports first (most reliable)
const pkgJsonPath = path.join(searchDir, "package.json") const pkgJsonPath = path.join(searchDir, "package.json")
if (fs.existsSync(pkgJsonPath)) { if (fs.existsSync(pkgJsonPath)) {
@ -459,13 +619,9 @@ export function getPluginEntryPoint(name: string, subdir?: string): string {
* Resolve a subpath export for a plugin (e.g. "./components"). * Resolve a subpath export for a plugin (e.g. "./components").
* Uses package.json exports map, then falls back to dist/ directory structure. * Uses package.json exports map, then falls back to dist/ directory structure.
*/ */
export function getPluginSubpathEntry( export function getPluginSubpathEntry(name: string, subpath: string): string | null {
name: string,
subpath: string,
subdir?: string,
): string | null {
const pluginDir = getPluginDir(name) const pluginDir = getPluginDir(name)
const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir const searchDir = pluginDir
// Check package.json exports map // Check package.json exports map
const pkgJsonPath = path.join(searchDir, "package.json") const pkgJsonPath = path.join(searchDir, "package.json")

View File

@ -186,7 +186,7 @@ async function resolveSinglePlugin(
try { try {
const gitSpec = parsePluginSource(packageName) const gitSpec = parsePluginSource(packageName)
await installPlugin(gitSpec, { verbose: options.verbose }) await installPlugin(gitSpec, { verbose: options.verbose })
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name)
// Import the plugin // Import the plugin
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))