quartz/quartz/cli/plugin-git-handlers.js
saberzero1 341b8b3779
perf(plugins): parallelize builds, skip when dist/ exists, fix double-build
- Use --ignore-scripts during npm install to prevent duplicate builds
- Skip build entirely when dist/ directory already exists (pre-built plugins)
- Add buildPluginAsync() and runParallel() for concurrent plugin builds
- Convert all 5 build loops to parallel execution bounded by CPU count
- Prune devDependencies after build to avoid singleton duplication
2026-03-14 22:02:49 +01:00

1104 lines
35 KiB
JavaScript

import fs from "fs"
import path from "path"
import os from "os"
import { execSync, exec as execCb } from "child_process"
import { styleText, promisify } from "util"
import {
readPluginsJson,
writePluginsJson,
readLockfile,
writeLockfile,
extractPluginName,
readManifestFromPackageJson,
parseGitSource,
getGitCommit,
PLUGINS_DIR,
LOCKFILE_PATH,
isLocalSource,
} from "./plugin-data.js"
const INTERNAL_EXPORTS = new Set(["manifest", "default"])
const execAsync = promisify(execCb)
function buildPlugin(pluginDir, name) {
try {
const skipBuild = !needsBuild(pluginDir)
console.log(styleText("cyan", `${name}: installing dependencies...`))
execSync("npm install --ignore-scripts", { cwd: pluginDir, stdio: "ignore" })
if (!skipBuild) {
console.log(styleText("cyan", `${name}: building...`))
execSync("npm run build", { cwd: pluginDir, stdio: "ignore" })
}
// Remove devDependencies after build — they are no longer needed and their
// presence can cause duplicate-singleton issues when a plugin ships its own
// copy of a shared dependency (e.g. bases-page's ViewRegistry).
execSync("npm prune --omit=dev", { cwd: pluginDir, stdio: "ignore" })
// Symlink peerDependencies: @quartz-community/* peers resolve to sibling
// plugins, all other peers resolve to the host Quartz node_modules so that
// plugins share a single copy of packages like unified, vfile, etc.
linkPeerPlugins(pluginDir)
return true
} catch (error) {
console.log(styleText("red", `${name}: build failed`))
return false
}
}
async function buildPluginAsync(pluginDir, name) {
try {
const skipBuild = !needsBuild(pluginDir)
console.log(styleText("cyan", `${name}: installing dependencies...`))
await execAsync("npm install --ignore-scripts", { cwd: pluginDir })
if (!skipBuild) {
console.log(styleText("cyan", `${name}: building...`))
await execAsync("npm run build", { cwd: pluginDir })
}
await execAsync("npm prune --omit=dev", { cwd: pluginDir })
linkPeerPlugins(pluginDir)
return true
} catch (error) {
console.log(styleText("red", `${name}: build failed`))
return false
}
}
/**
* Run async tasks with bounded concurrency.
* @param {Array} items - Items to process
* @param {number} concurrency - Max parallel tasks
* @param {Function} fn - Async function to run per item
* @returns {Promise<Array>} Results in order
*/
async function runParallel(items, concurrency, fn) {
const results = new Array(items.length)
let nextIndex = 0
async function worker() {
while (nextIndex < items.length) {
const i = nextIndex++
results[i] = await fn(items[i], i)
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
await Promise.all(workers)
return results
}
function needsBuild(pluginDir) {
const distDir = path.join(pluginDir, "dist")
return !fs.existsSync(distDir)
}
/**
* After pruning devDependencies, peerDependencies may no longer be installed
* in the plugin's own node_modules. This function resolves them:
*
* 1. @quartz-community/* peers → symlink to the co-installed sibling plugin
* 2. All other peers → symlink to the host Quartz node_modules so plugins
* share a single copy of packages like unified, vfile, rehype-raw, etc.
*/
function linkPeerPlugins(pluginDir) {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const peers = pkg.peerDependencies ?? {}
// Locate the host Quartz node_modules (two levels up from .quartz/plugins/<name>)
const quartzRoot = path.resolve(pluginDir, "..", "..", "..")
const hostNodeModules = path.join(quartzRoot, "node_modules")
for (const peerName of Object.keys(peers)) {
// Check if this peer is already satisfied (e.g. installed as a regular dep)
const peerNodeModulesPath = path.join(pluginDir, "node_modules", ...peerName.split("/"))
if (fs.existsSync(peerNodeModulesPath)) continue
// Case 1: @quartz-community scoped packages → sibling plugin symlink
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
}
// Case 2: Other peers → resolve from host Quartz node_modules
const hostPeerPath = path.join(hostNodeModules, ...peerName.split("/"))
if (!fs.existsSync(hostPeerPath)) continue
// Ensure parent directory exists (for scoped packages like @napi-rs/simple-git)
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")
}
}
/**
* Search installed plugins for one whose package.json "name" matches the given
* npm package name (e.g. "@quartz-community/bases-page").
*/
function findPluginByPackageName(packageName) {
if (!fs.existsSync(PLUGINS_DIR)) return null
const plugins = fs.readdirSync(PLUGINS_DIR).filter((entry) => {
const entryPath = path.join(PLUGINS_DIR, entry)
return fs.statSync(entryPath).isDirectory()
})
for (const pluginDirName of plugins) {
const pkgPath = path.join(PLUGINS_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_DIR, pluginDirName)
}
} catch {}
}
return null
}
function parseExportsFromDts(content) {
const exports = []
const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g)
for (const match of exportMatches) {
const fromModule = match[2]
if (fromModule?.startsWith("@")) continue
const names = match[1]
.split(",")
.map((n) => n.trim())
.filter(Boolean)
for (const name of names) {
const cleanName = name.split(" as ").pop()?.trim() || name.trim()
if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) {
const finalName = cleanName.replace(/^type\s+/, "")
if (name.includes("type ")) {
exports.push(`type ${finalName}`)
} else {
exports.push(finalName)
}
}
}
}
return exports
}
async function regeneratePluginIndex() {
if (!fs.existsSync(PLUGINS_DIR)) return
const plugins = fs.readdirSync(PLUGINS_DIR).filter((name) => {
const pluginPath = path.join(PLUGINS_DIR, name)
return fs.statSync(pluginPath).isDirectory()
})
const exports = []
for (const pluginName of plugins) {
const pluginDir = path.join(PLUGINS_DIR, pluginName)
const distIndex = path.join(pluginDir, "dist", "index.d.ts")
if (!fs.existsSync(distIndex)) continue
const dtsContent = fs.readFileSync(distIndex, "utf-8")
const exportedNames = parseExportsFromDts(dtsContent)
if (exportedNames.length > 0) {
const namedExports = exportedNames.filter((e) => !e.startsWith("type "))
const typeExports = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5))
if (namedExports.length > 0) {
exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`)
}
if (typeExports.length > 0) {
exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`)
}
}
}
const indexContent = exports.join("\n") + "\n"
const indexPath = path.join(PLUGINS_DIR, "index.ts")
fs.writeFileSync(indexPath, indexContent)
}
export async function handlePluginInstall() {
const lockfile = readLockfile()
if (!lockfile) {
console.log(
styleText("yellow", "⚠ No quartz.lock.json found. Run 'npx quartz plugin add <repo>' first."),
)
return
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
console.log(styleText("cyan", "→ Installing plugins from lockfile..."))
let installed = 0
let failed = 0
const pluginsToBuild = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
// Local plugin: ensure symlink exists
if (entry.commit === "local") {
try {
if (fs.existsSync(pluginDir)) {
const stat = fs.lstatSync(pluginDir)
if (stat.isSymbolicLink() && fs.readlinkSync(pluginDir) === entry.resolved) {
console.log(styleText("gray", `${name} (local) already linked`))
installed++
continue
}
// Wrong target or not a symlink — remove and re-link
if (stat.isSymbolicLink()) fs.unlinkSync(pluginDir)
else fs.rmSync(pluginDir, { recursive: true })
}
if (!fs.existsSync(entry.resolved)) {
console.log(styleText("red", `${name}: local path missing: ${entry.resolved}`))
failed++
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
console.log(styleText("green", `${name} (local) linked`))
pluginsToBuild.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to link local path`))
failed++
}
continue
}
if (fs.existsSync(pluginDir)) {
try {
const currentCommit = getGitCommit(pluginDir)
if (currentCommit === entry.commit && !needsBuild(pluginDir)) {
console.log(
styleText("gray", `${name}@${entry.commit.slice(0, 7)} already installed`),
)
installed++
continue
}
if (currentCommit !== entry.commit) {
console.log(styleText("cyan", `${name}: updating to ${entry.commit.slice(0, 7)}...`))
const fetchRef = entry.ref ? ` ${entry.ref}` : ""
execSync(`git fetch --depth 1 origin${fetchRef}`, { cwd: pluginDir, stdio: "ignore" })
execSync(`git reset --hard ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" })
}
pluginsToBuild.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to update`))
failed++
}
} else {
try {
console.log(styleText("cyan", `${name}: cloning...`))
const branchArg = entry.ref ? ` --branch ${entry.ref}` : ""
execSync(`git clone --depth 1${branchArg} ${entry.resolved} ${pluginDir}`, {
stdio: "ignore",
})
if (entry.commit !== "unknown") {
execSync(`git fetch --depth 1 origin ${entry.commit}`, {
cwd: pluginDir,
stdio: "ignore",
})
execSync(`git checkout ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" })
}
console.log(styleText("green", `${name}@${entry.commit.slice(0, 7)}`))
pluginsToBuild.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to clone`))
failed++
}
}
}
if (pluginsToBuild.length > 0) {
console.log()
console.log(styleText("cyan", "→ Building plugins..."))
const concurrency = Math.max(1, os.cpus().length)
const results = await runParallel(pluginsToBuild, concurrency, async ({ name, pluginDir }) => {
const ok = await buildPluginAsync(pluginDir, name)
if (ok) console.log(styleText("green", `${name} built`))
return ok
})
for (const ok of results) {
if (!ok) { failed++; installed-- }
}
}
await regeneratePluginIndex()
console.log()
if (failed === 0) {
console.log(styleText("green", `✓ Installed ${installed} plugin(s)`))
} else {
console.log(styleText("yellow", `⚠ Installed ${installed} plugin(s), ${failed} failed`))
}
}
export async function handlePluginAdd(sources) {
let lockfile = readLockfile()
if (!lockfile) {
lockfile = { version: "1.0.0", plugins: {} }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
const addedPlugins = []
for (const source of sources) {
try {
const { name, url, ref, local } = parseGitSource(source)
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
console.log(styleText("yellow", `${name} already exists. Use 'update' to refresh.`))
continue
}
if (local) {
// Local path: create symlink instead of git clone
const resolvedPath = path.resolve(url)
if (!fs.existsSync(resolvedPath)) {
console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`))
continue
}
console.log(styleText("cyan", `→ Adding ${name} from local path ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
lockfile.plugins[name] = {
source,
resolved: resolvedPath,
commit: "local",
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
console.log(styleText("green", `✓ Added ${name} (local symlink)`))
} else {
console.log(styleText("cyan", `→ Adding ${name} from ${url}...`))
if (ref) {
execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" })
} else {
execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" })
}
const commit = getGitCommit(pluginDir)
lockfile.plugins[name] = {
source,
resolved: url,
commit,
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`))
}
} catch (error) {
console.log(styleText("red", `✗ Failed to add ${source}: ${error}`))
}
}
if (addedPlugins.length > 0) {
console.log()
console.log(styleText("cyan", "→ Building plugins..."))
const concurrency = Math.max(1, os.cpus().length)
await runParallel(addedPlugins, concurrency, async ({ name, pluginDir }) => {
const ok = await buildPluginAsync(pluginDir, name)
if (ok) console.log(styleText("green", `${name} built`))
return ok
})
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson()
if (pluginsJson?.plugins) {
for (const { pluginDir, source } of addedPlugins) {
const manifest = readManifestFromPackageJson(pluginDir)
const newEntry = {
source,
enabled: manifest?.defaultEnabled ?? true,
options: manifest?.defaultOptions ?? {},
order: manifest?.defaultOrder ?? 50,
}
if (manifest?.components) {
const firstComponentKey = Object.keys(manifest.components)[0]
const comp = manifest.components[firstComponentKey]
if (comp?.defaultPosition) {
newEntry.layout = {
position: comp.defaultPosition,
priority: comp.defaultPriority ?? 50,
display: "all",
}
}
}
pluginsJson.plugins.push(newEntry)
}
writePluginsJson(pluginsJson)
}
console.log()
console.log(styleText("gray", "Updated quartz.lock.json"))
}
export async function handlePluginRemove(names) {
const lockfile = readLockfile()
if (!lockfile) {
console.log(styleText("yellow", "⚠ No plugins installed"))
return
}
let removed = false
for (const name of names) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (!lockfile.plugins[name] && !fs.existsSync(pluginDir)) {
console.log(styleText("yellow", `${name} is not installed`))
continue
}
console.log(styleText("cyan", `→ Removing ${name}...`))
if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true })
}
delete lockfile.plugins[name]
console.log(styleText("green", `✓ Removed ${name}`))
removed = true
}
if (removed) {
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson()
if (pluginsJson?.plugins) {
pluginsJson.plugins = pluginsJson.plugins.filter(
(plugin) =>
!names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source),
)
writePluginsJson(pluginsJson)
}
console.log()
console.log(styleText("gray", "Updated quartz.lock.json"))
}
export async function handlePluginEnable(names) {
const json = readPluginsJson()
if (!json) {
console.log(styleText("red", "✗ No quartz.config.yaml found. Cannot enable plugins."))
return
}
for (const name of names) {
const entry = json.plugins.find(
(e) => extractPluginName(e.source) === name || e.source === name,
)
if (!entry) {
console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.config.yaml`))
continue
}
if (entry.enabled) {
console.log(styleText("gray", `${name} is already enabled`))
continue
}
entry.enabled = true
console.log(styleText("green", `✓ Enabled ${name}`))
}
writePluginsJson(json)
}
export async function handlePluginDisable(names) {
const json = readPluginsJson()
if (!json) {
console.log(styleText("red", "✗ No quartz.config.yaml found. Cannot disable plugins."))
return
}
for (const name of names) {
const entry = json.plugins.find(
(e) => extractPluginName(e.source) === name || e.source === name,
)
if (!entry) {
console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.config.yaml`))
continue
}
if (!entry.enabled) {
console.log(styleText("gray", `${name} is already disabled`))
continue
}
entry.enabled = false
console.log(styleText("green", `✓ Disabled ${name}`))
}
writePluginsJson(json)
}
export async function handlePluginConfig(name, options = {}) {
const json = readPluginsJson()
if (!json) {
console.log(styleText("red", "✗ No quartz.config.yaml found."))
return
}
const entry = json.plugins.find((e) => extractPluginName(e.source) === name || e.source === name)
if (!entry) {
console.log(styleText("red", `✗ Plugin "${name}" not found in quartz.config.yaml`))
return
}
if (options.set) {
const eqIndex = options.set.indexOf("=")
if (eqIndex === -1) {
console.log(styleText("red", "✗ Invalid format. Use: --set key=value"))
return
}
const key = options.set.slice(0, eqIndex)
let value = options.set.slice(eqIndex + 1)
try {
value = JSON.parse(value)
} catch {}
if (!entry.options) entry.options = {}
entry.options[key] = value
writePluginsJson(json)
console.log(styleText("green", `✓ Set ${name}.${key} = ${JSON.stringify(value)}`))
} else {
console.log(styleText("bold", `Plugin: ${name}`))
console.log(` Source: ${entry.source}`)
console.log(` Enabled: ${entry.enabled}`)
console.log(` Order: ${entry.order ?? 50}`)
if (entry.options && Object.keys(entry.options).length > 0) {
console.log(` Options:`)
for (const [k, v] of Object.entries(entry.options)) {
console.log(` ${k}: ${JSON.stringify(v)}`)
}
} else {
console.log(` Options: (none)`)
}
if (entry.layout) {
console.log(` Layout:`)
for (const [k, v] of Object.entries(entry.layout)) {
console.log(` ${k}: ${JSON.stringify(v)}`)
}
}
}
}
export async function handlePluginCheck() {
const lockfile = readLockfile()
if (!lockfile || Object.keys(lockfile.plugins).length === 0) {
console.log(styleText("gray", "No plugins installed"))
return
}
console.log(styleText("bold", "Checking for plugin updates...\n"))
const results = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
// Local plugins: show "local" status, skip git checks
if (entry.commit === "local") {
results.push({
name,
installed: "local",
latest: "—",
status: "local",
})
continue
}
try {
const lsRemoteRef = entry.ref ? `refs/heads/${entry.ref}` : "HEAD"
const latestCommit = execSync(`git ls-remote ${entry.resolved} ${lsRemoteRef}`, {
encoding: "utf-8",
})
.split("\t")[0]
.trim()
const isCurrent = latestCommit === entry.commit
results.push({
name,
installed: entry.commit.slice(0, 7),
latest: latestCommit.slice(0, 7),
status: isCurrent ? "up to date" : "update available",
})
} catch {
results.push({
name,
installed: entry.commit.slice(0, 7),
latest: "?",
status: "check failed",
})
}
}
const nameWidth = Math.max(6, ...results.map((r) => r.name.length)) + 2
const header = `${"Plugin".padEnd(nameWidth)}${"Installed".padEnd(12)}${"Latest".padEnd(12)}Status`
console.log(styleText("bold", header))
console.log("─".repeat(header.length))
for (const r of results) {
const color =
r.status === "up to date" || r.status === "local"
? "green"
: r.status === "check failed"
? "red"
: "yellow"
console.log(
`${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText(
color,
r.status,
)}`,
)
}
}
export async function handlePluginUpdate(names) {
const lockfile = readLockfile()
if (!lockfile) {
console.log(styleText("yellow", "⚠ No plugins installed"))
return
}
const pluginsToUpdate = names || Object.keys(lockfile.plugins)
const updatedPlugins = []
for (const name of pluginsToUpdate) {
const entry = lockfile.plugins[name]
if (!entry) {
console.log(styleText("yellow", `${name} is not installed`))
continue
}
const pluginDir = path.join(PLUGINS_DIR, name)
if (!fs.existsSync(pluginDir)) {
console.log(
styleText("yellow", `${name} directory missing. Run 'npx quartz plugin install'.`),
)
continue
}
// Local plugins: just rebuild, no git operations
if (entry.commit === "local") {
console.log(styleText("cyan", `→ Rebuilding local plugin ${name}...`))
updatedPlugins.push({ name, pluginDir })
continue
}
try {
console.log(styleText("cyan", `→ Updating ${name}...`))
const fetchRef = entry.ref || ""
const resetTarget = entry.ref ? `origin/${entry.ref}` : "origin/HEAD"
execSync(`git fetch --depth 1 origin${fetchRef ? " " + fetchRef : ""}`, {
cwd: pluginDir,
stdio: "ignore",
})
execSync(`git reset --hard ${resetTarget}`, { cwd: pluginDir, stdio: "ignore" })
const newCommit = getGitCommit(pluginDir)
if (newCommit !== entry.commit) {
entry.commit = newCommit
entry.installedAt = new Date().toISOString()
updatedPlugins.push({ name, pluginDir })
console.log(styleText("green", `✓ Updated ${name} to ${newCommit.slice(0, 7)}`))
} else {
console.log(styleText("gray", `${name} already up to date`))
}
} catch (error) {
console.log(styleText("red", `✗ Failed to update ${name}: ${error}`))
}
}
if (updatedPlugins.length > 0) {
console.log()
console.log(styleText("cyan", "→ Rebuilding updated plugins..."))
const concurrency = Math.max(1, os.cpus().length)
await runParallel(updatedPlugins, concurrency, async ({ name, pluginDir }) => {
const ok = await buildPluginAsync(pluginDir, name)
if (ok) console.log(styleText("green", `${name} rebuilt`))
return ok
})
await regeneratePluginIndex()
}
writeLockfile(lockfile)
console.log()
console.log(styleText("gray", "Updated quartz.lock.json"))
}
export async function handlePluginList() {
const lockfile = readLockfile()
if (!lockfile || Object.keys(lockfile.plugins).length === 0) {
console.log(styleText("gray", "No plugins installed"))
return
}
console.log(styleText("bold", "Installed Plugins:"))
console.log()
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
const exists = fs.existsSync(pluginDir)
// Local plugins: special display
if (entry.commit === "local") {
const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink()
const status = isLinked ? styleText("green", "✓") : styleText("red", "✗")
console.log(` ${status} ${styleText("bold", name)}`)
console.log(` Source: ${entry.source}`)
console.log(` Type: local symlink`)
console.log(` Target: ${entry.resolved}`)
console.log(` Installed: ${new Date(entry.installedAt).toLocaleDateString()}`)
console.log()
continue
}
let currentCommit = entry.commit
if (exists) {
currentCommit = getGitCommit(pluginDir)
}
const status = exists
? currentCommit === entry.commit
? styleText("green", "✓")
: styleText("yellow", "⚡")
: styleText("red", "✗")
console.log(` ${status} ${styleText("bold", name)}`)
console.log(` Source: ${entry.source}`)
console.log(` Commit: ${entry.commit.slice(0, 7)}`)
if (currentCommit !== entry.commit && exists) {
console.log(` Current: ${currentCommit.slice(0, 7)} (modified)`)
}
console.log(` Installed: ${new Date(entry.installedAt).toLocaleDateString()}`)
console.log()
}
}
export async function handlePluginRestore() {
const lockfile = readLockfile()
if (!lockfile) {
console.log(styleText("red", "✗ No quartz.lock.json found. Cannot restore."))
console.log()
console.log("Run 'npx quartz plugin add <repo>' to install plugins from scratch.")
return
}
console.log(styleText("cyan", "→ Restoring plugins from lockfile..."))
console.log()
const pluginsDir = path.join(process.cwd(), ".quartz", "plugins")
if (!fs.existsSync(pluginsDir)) {
fs.mkdirSync(pluginsDir, { recursive: true })
}
let installed = 0
let failed = 0
const restoredPlugins = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(pluginsDir, name)
if (fs.existsSync(pluginDir)) {
console.log(styleText("yellow", `${name}: directory exists, skipping`))
continue
}
// Local plugin: re-symlink
if (entry.commit === "local") {
try {
if (!fs.existsSync(entry.resolved)) {
console.log(styleText("red", `${name}: local path missing: ${entry.resolved}`))
failed++
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
console.log(styleText("green", `${name} restored (local symlink)`))
restoredPlugins.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to restore local symlink`))
failed++
}
continue
}
try {
console.log(
styleText("cyan", `${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)}...`),
)
const branchArg = entry.ref ? ` --branch ${entry.ref}` : ""
execSync(`git clone${branchArg} ${entry.resolved} ${pluginDir}`, { stdio: "ignore" })
execSync(`git checkout ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" })
console.log(styleText("green", `${name} restored`))
restoredPlugins.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to restore`))
failed++
}
}
if (restoredPlugins.length > 0) {
console.log()
console.log(styleText("cyan", "→ Building restored plugins..."))
const concurrency = Math.max(1, os.cpus().length)
const results = await runParallel(restoredPlugins, concurrency, async ({ name, pluginDir }) => {
const ok = await buildPluginAsync(pluginDir, name)
if (ok) console.log(styleText("green", `${name} built`))
return ok
})
for (const ok of results) {
if (!ok) { failed++; installed-- }
}
await regeneratePluginIndex()
}
console.log()
if (failed === 0) {
console.log(styleText("green", `✓ Restored ${installed} plugin(s)`))
} else {
console.log(styleText("yellow", `⚠ Restored ${installed} plugin(s), ${failed} failed`))
}
}
export async function handlePluginPrune({ dryRun = false } = {}) {
const lockfile = readLockfile()
if (!lockfile || Object.keys(lockfile.plugins).length === 0) {
console.log(styleText("gray", "No plugins installed"))
return
}
const pluginsJson = readPluginsJson()
const configuredNames = new Set(
(pluginsJson?.plugins ?? []).map((entry) => extractPluginName(entry.source)),
)
const orphans = Object.keys(lockfile.plugins).filter((name) => !configuredNames.has(name))
if (orphans.length === 0) {
console.log(styleText("green", "✓ No orphaned plugins found — nothing to prune"))
return
}
console.log(`Found ${orphans.length} orphaned plugin(s):\n`)
for (const name of orphans) {
console.log(` ${styleText("yellow", name)} — in lockfile but not in config`)
}
console.log()
if (dryRun) {
console.log(styleText("cyan", "Dry run — no changes made. Re-run without --dry-run to prune."))
return
}
let removed = 0
for (const name of orphans) {
const pluginDir = path.join(PLUGINS_DIR, name)
console.log(styleText("cyan", `→ Removing ${name}...`))
if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true })
}
delete lockfile.plugins[name]
console.log(styleText("green", `✓ Removed ${name}`))
removed++
}
if (removed > 0) {
await regeneratePluginIndex()
}
writeLockfile(lockfile)
console.log()
console.log(styleText("green", `✓ Pruned ${removed} plugin(s)`))
console.log(styleText("gray", "Updated quartz.lock.json"))
}
export async function handlePluginResolve({ dryRun = false } = {}) {
const pluginsJson = readPluginsJson()
if (!pluginsJson?.plugins || pluginsJson.plugins.length === 0) {
console.log(styleText("gray", "No plugins configured"))
return
}
let lockfile = readLockfile()
if (!lockfile) {
lockfile = { version: "1.0.0", plugins: {} }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
// Find config entries whose source is a git/local-resolvable URL and not yet in lockfile
const missing = pluginsJson.plugins.filter((entry) => {
const name = extractPluginName(entry.source)
if (lockfile.plugins[name]) return false
// Only attempt sources that parseGitSource can handle (git URLs + local paths)
const src = entry.source
return (
src.startsWith("github:") ||
src.startsWith("git+") ||
src.startsWith("https://") ||
isLocalSource(src)
)
})
if (missing.length === 0) {
console.log(styleText("green", "✓ All configured plugins are already installed"))
return
}
console.log(`Found ${missing.length} uninstalled plugin(s) in config:\n`)
for (const entry of missing) {
const name = extractPluginName(entry.source)
console.log(` ${styleText("yellow", name)}${entry.source}`)
}
console.log()
if (dryRun) {
console.log(
styleText("cyan", "Dry run — no changes made. Re-run without --dry-run to resolve."),
)
return
}
const installed = []
let failed = 0
for (const entry of missing) {
try {
const { name, url, ref, local } = parseGitSource(entry.source)
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
if (local) {
console.log(styleText("yellow", `${name} directory already exists, updating lockfile`))
lockfile.plugins[name] = {
source: entry.source,
resolved: url,
commit: "local",
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
continue
}
console.log(styleText("yellow", `${name} directory already exists, updating lockfile`))
const commit = getGitCommit(pluginDir)
lockfile.plugins[name] = {
source: entry.source,
resolved: url,
commit,
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
continue
}
if (local) {
// Local path: symlink
const resolvedPath = path.resolve(url)
if (!fs.existsSync(resolvedPath)) {
console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`))
failed++
continue
}
console.log(styleText("cyan", `→ Linking ${name} from ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
lockfile.plugins[name] = {
source: entry.source,
resolved: resolvedPath,
commit: "local",
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
console.log(styleText("green", `✓ Linked ${name} (local)`))
} else {
console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`))
if (ref) {
execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" })
} else {
execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" })
}
const commit = getGitCommit(pluginDir)
lockfile.plugins[name] = {
source: entry.source,
resolved: url,
commit,
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`))
}
} catch (error) {
console.log(styleText("red", `✗ Failed to resolve ${entry.source}: ${error}`))
failed++
}
}
if (installed.length > 0) {
console.log()
console.log(styleText("cyan", "→ Building plugins..."))
const concurrency = Math.max(1, os.cpus().length)
const results = await runParallel(installed, concurrency, async ({ name, pluginDir }) => {
const ok = await buildPluginAsync(pluginDir, name)
if (ok) console.log(styleText("green", `${name} built`))
return ok
})
for (const ok of results) {
if (!ok) failed++
}
await regeneratePluginIndex()
}
writeLockfile(lockfile)
console.log()
if (failed === 0) {
console.log(styleText("green", `✓ Resolved ${installed.length} plugin(s)`))
} else {
console.log(styleText("yellow", `⚠ Resolved ${installed.length} plugin(s), ${failed} failed`))
}
console.log(styleText("gray", "Updated quartz.lock.json"))
}