/
superded
/
void
Обзор
Документация
Войти
/
superded
/
void
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
Безопасность
readme
quartz/cli/plugin-git-handlers.js
1 759 строк
59 KB
superded42
Initial commit
04 июл 2026, 05:05
Не верифицирован
04 июл 2026, 05:05
af1cbe5
Код
Авторство
О чём код?
import fs from "fs" import path from "path" import os from "os" import { 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, getSourceUrl, formatSource, resolveLockfileName, getNameOverrides, } from "./plugin-data.js" import { symlinkOrCopySync } from "./helpers.js" const INTERNAL_EXPORTS = new Set(["manifest", "default"]) const execAsync = promisify(execCb) async function cloneWithSubdirAsync({ url, ref, subdir, pluginDir }) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quartz-plugin-")) try { if (ref) { await execAsync(`git clone --depth 1 --branch ${ref} "${url}" "${tmpDir}"`) } else { await execAsync(`git clone --depth 1 "${url}" "${tmpDir}"`) } const subdirPath = path.join(tmpDir, subdir) if (!fs.existsSync(subdirPath)) { throw new Error(`Subdirectory "${subdir}" not found in cloned repository`) } fs.cpSync(subdirPath, pluginDir, { recursive: true }) const { stdout } = await execAsync("git rev-parse HEAD", { cwd: tmpDir }) return stdout.trim() } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) } } async function buildPluginAsync(pluginDir, name) { if (hasPrebuiltDist(pluginDir)) { console.log(styleText("green", ` ✓ ${name}: using pre-built dist/`)) linkPeerPlugins(pluginDir) return true } 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 } /** * Check whether a plugin's .gitignore excludes dist/. * When dist/ is gitignored, the plugin cannot ship pre-built output in version * control (e.g. because it uses tree-shaking) and must always be built locally. */ function isDistGitignored(pluginDir) { 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 hasPrebuiltDist(pluginDir) { const distDir = path.join(pluginDir, "dist") return fs.existsSync(distDir) && !isDistGitignored(pluginDir) } function needsBuild(pluginDir) { if (isDistGitignored(pluginDir)) return true 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 trySymlink(target, linkPath) { symlinkOrCopySync(target, linkPath) } 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 ?? {} 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) trySymlink(target, peerNodeModulesPath) 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) trySymlink(target, peerNodeModulesPath) } } /** * 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 } const PLUGIN_TYPE_PATTERN = /Quartz(?:Emitter|Transformer|Filter|PageType)Plugin|QuartzComponentConstructor|\(.*\)\s*=>\s*QuartzComponent\b/ function resolveOriginalName(exportName, dtsContent) { const aliasPattern = new RegExp(`(\\w+)\\s+as\\s+${exportName}\\b`) const match = dtsContent.match(aliasPattern) return match ? match[1] : exportName } function isOverridableExport(name, dtsContent) { const declName = resolveOriginalName(name, dtsContent) const declPattern = new RegExp(`declare\\s+const\\s+${declName}\\s*:\\s*(.+?)(?:;|$)`, "m") const match = dtsContent.match(declPattern) if (!match) return false return PLUGIN_TYPE_PATTERN.test(match[1]) } 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 pluginDirs = fs.readdirSync(PLUGINS_DIR).filter((name) => { const pluginPath = path.join(PLUGINS_DIR, name) return fs.statSync(pluginPath).isDirectory() }) // Phase 1: Collect all exports per plugin, detect conflicts const pluginExports = new Map() const nameCount = new Map() for (const pluginName of pluginDirs) { 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) const named = exportedNames.filter((e) => !e.startsWith("type ")) const types = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5)) const overridable = named.filter((n) => isOverridableExport(n, dtsContent)) const passthrough = named.filter((n) => !isOverridableExport(n, dtsContent)) if (overridable.length > 0 || passthrough.length > 0 || types.length > 0) { pluginExports.set(pluginName, { overridable, passthrough, types }) for (const n of [...overridable, ...passthrough]) { nameCount.set(n, (nameCount.get(n) ?? 0) + 1) } } } // Phase 2: Generate index with registry import, plugin map, and conditional top-level exports const lines = [] lines.push(`import { componentRegistry } from "../../quartz/components/registry"`) lines.push("") // Type re-exports for (const [pluginName, { types }] of pluginExports) { if (types.length > 0) { lines.push(`export type { ${types.join(", ")} } from "./${pluginName}"`) } } // Direct re-exports for non-overridable values (constants, utility functions, etc.) for (const [pluginName, { passthrough }] of pluginExports) { if (passthrough.length === 0) continue const unique = passthrough.filter((n) => (nameCount.get(n) ?? 0) === 1) if (unique.length > 0) { lines.push(`export { ${unique.join(", ")} } from "./${pluginName}"`) } } lines.push("") // Generate the plugins map with override wrappers (overridable exports only) lines.push( `export const plugins: Record<string, Record<string, (...args: unknown[]) => void>> = {`, ) for (const [pluginName, { overridable }] of pluginExports) { if (overridable.length === 0) continue const escapedName = pluginName.replace(/"/g, '\\"') lines.push(` "${escapedName}": {`) for (const n of overridable) { lines.push( ` ${n}: (...args: unknown[]) => { componentRegistry.setOptionOverrides("${escapedName}", args[0] as Record<string, unknown>); },`, ) } lines.push(` },`) } lines.push(`}`) lines.push("") // Top-level exports for overridable names: alias to the plugins map wrapper for (const [pluginName, { overridable }] of pluginExports) { if (overridable.length === 0) continue const unique = overridable.filter((n) => (nameCount.get(n) ?? 0) === 1) const conflicting = overridable.filter((n) => (nameCount.get(n) ?? 0) > 1) if (unique.length > 0) { const escapedName = pluginName.replace(/"/g, '\\"') for (const n of unique) { lines.push(`export const ${n} = plugins["${escapedName}"].${n}`) } } if (conflicting.length > 0) { for (const n of conflicting) { console.warn( styleText("yellow", `⚠`), `Export "${n}" conflicts across plugins — use plugins["${pluginName}"].${n} in quartz.ts`, ) } } } lines.push("") const indexContent = lines.join("\n") const indexPath = path.join(PLUGINS_DIR, "index.ts") fs.writeFileSync(indexPath, indexContent) } export async function handlePluginInstallUnified({ names, fromConfig = false, latest = false, clean = false, dryRun = false, concurrency: concurrencyOption, } = {}) { if (clean && latest) { console.log(styleText("red", "✗ --clean and --latest cannot be used together")) return } const resolvedConcurrency = Math.max(1, concurrencyOption ?? os.cpus().length) const pluginsJson = readPluginsJson() let lockfile = readLockfile() if (!fromConfig && !lockfile) { console.log( styleText("yellow", "⚠ No quartz.lock.json found. Run 'npx quartz plugin add <repo>' first."), ) return } const resolvedNames = names ? names.map((name) => resolveLockfileName(name, lockfile ?? { version: "1.0.0", plugins: {} }, pluginsJson), ) : null const nameFilter = resolvedNames ? new Set(resolvedNames) : null if (dryRun && latest) { if (!lockfile || Object.keys(lockfile.plugins).length === 0) { console.log(styleText("gray", "No plugins installed")) return } const nameOverrides = getNameOverrides(lockfile, pluginsJson) const rows = Object.entries(lockfile.plugins) .filter(([name]) => !nameFilter || nameFilter.has(name)) .map(([name, entry]) => ({ name, entry, displayName: nameOverrides.get(name) ?? name, })) const isTTY = process.stdout.isTTY const nameWidth = Math.max(6, ...rows.map((row) => row.displayName.length)) + 2 const header = `${"Plugin".padEnd(nameWidth)}${"Installed".padEnd(12)}${"Latest".padEnd(12)}Status` const renderRow = ({ displayName }, installed, latest, statusLabel) => `${displayName.padEnd(nameWidth)}${installed.padEnd(12)}${latest.padEnd(12)}${statusLabel}` const updateRow = (index, installed, latest, statusLabel) => { if (!isTTY) return const offset = rows.length - index process.stdout.write( `\x1b[${offset}A\x1b[2K\r${renderRow(rows[index], installed, latest, statusLabel)}\x1b[${offset}B`, ) } if (isTTY) { console.log(styleText("bold", "Checking for plugin updates...\n")) console.log(styleText("bold", header)) console.log("─".repeat(header.length)) for (const row of rows) { if (row.entry.commit === "local") { console.log(renderRow(row, "local", "—", styleText("green", "local"))) continue } console.log(renderRow(row, row.entry.commit.slice(0, 7), "—", styleText("cyan", "⋯"))) } } const promises = rows.map((row, index) => { if (row.entry.commit === "local") { return Promise.resolve({ index, installed: "local", latest: "—", status: "local", }) } const lsRemoteRef = row.entry.ref ? `refs/heads/${row.entry.ref}` : "HEAD" return execAsync(`git ls-remote "${row.entry.resolved}" ${lsRemoteRef}`) .then(({ stdout }) => { const latestCommit = stdout.split("\t")[0].trim() const isCurrent = latestCommit === row.entry.commit const installed = row.entry.commit.slice(0, 7) const latest = latestCommit.slice(0, 7) const statusLabel = isCurrent ? styleText("green", "up to date") : styleText("yellow", "update available") updateRow(index, installed, latest, statusLabel) return { index, installed, latest, status: isCurrent ? "up to date" : "update available", } }) .catch(() => { const installed = row.entry.commit.slice(0, 7) const latest = "?" const statusLabel = styleText("red", "check failed") updateRow(index, installed, latest, statusLabel) return { index, installed, latest, status: "check failed", } }) }) const results = await Promise.all(promises) if (!isTTY) { console.log(styleText("bold", "Checking for plugin updates...\n")) console.log(styleText("bold", header)) console.log("─".repeat(header.length)) for (const { index, installed, latest, status } of results) { const color = status === "up to date" || status === "local" ? "green" : status === "check failed" ? "red" : "yellow" console.log(renderRow(rows[index], installed, latest, styleText(color, status))) } } return } if (fromConfig) { if (!pluginsJson?.plugins || pluginsJson.plugins.length === 0) { console.log(styleText("gray", "No plugins configured")) return } if (!lockfile) { lockfile = { version: "1.0.0", plugins: {} } } if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }) } const configNames = new Set(pluginsJson.plugins.map((entry) => extractPluginName(entry.source))) const orphans = Object.keys(lockfile.plugins).filter((name) => !configNames.has(name)) const missing = pluginsJson.plugins .filter((entry) => { const name = extractPluginName(entry.source) const pluginDir = path.join(PLUGINS_DIR, name) if (lockfile.plugins[name] && fs.existsSync(pluginDir)) return false const src = getSourceUrl(entry.source) return ( src.startsWith("github:") || src.startsWith("git+") || src.startsWith("https://") || isLocalSource(src) ) }) .filter((entry) => { if (!nameFilter) return true const name = extractPluginName(entry.source) return nameFilter.has(name) }) if (missing.length === 0) { console.log(styleText("green", "✓ All configured plugins are already installed")) if (dryRun) { if (orphans.length > 0) { console.log() console.log(`Found ${orphans.length} orphaned plugin(s) in lockfile:\n`) for (const name of orphans) { console.log(` ${styleText("yellow", name)} — in lockfile but not in config`) } console.log() console.log( styleText("cyan", "Dry run — no changes made. Re-run without --dry-run to resolve."), ) } return } if (orphans.length === 0) { return } } if (missing.length > 0) { 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)} — ${formatSource(entry.source)}`) } console.log() if (dryRun) { if (orphans.length > 0) { console.log(`Found ${orphans.length} orphaned plugin(s) in lockfile:\n`) for (const name of orphans) { console.log(` ${styleText("yellow", name)} — in lockfile but not in config`) } console.log() } console.log( styleText("cyan", "Dry run — no changes made. Re-run without --dry-run to resolve."), ) return } } const installed = [] let failed = 0 let lockfileChanged = false // Handle existing dirs and local symlinks (fast), collect remote clones const remoteEntries = [] for (const entry of missing) { try { const { name, url, ref, local, subdir } = 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", ...(subdir && { subdir }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) lockfileChanged = true 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 }), ...(subdir && { subdir }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) lockfileChanged = true continue } if (local) { let resolvedPath = path.resolve(url) if (subdir) resolvedPath = path.join(resolvedPath, subdir) 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 }) symlinkOrCopySync(resolvedPath, pluginDir) lockfile.plugins[name] = { source: entry.source, resolved: resolvedPath, commit: "local", ...(subdir && { subdir }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) lockfileChanged = true console.log(styleText("green", `✓ Linked ${name} (local)`)) } else { remoteEntries.push({ entry, name, url, ref, subdir, pluginDir }) } } catch (error) { console.log(styleText("red", `✗ Failed to resolve ${formatSource(entry.source)}: ${error}`)) failed++ } } // Clone remote plugins in parallel if (remoteEntries.length > 0) { const concurrency = resolvedConcurrency await runParallel( remoteEntries, concurrency, async ({ entry, name, url, ref, subdir, pluginDir }) => { try { if (subdir) { console.log(styleText("cyan", `→ Cloning ${name} from ${url} (subdir: ${subdir})...`)) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) const commit = await cloneWithSubdirAsync({ url, ref, subdir, pluginDir }) lockfile.plugins[name] = { source: entry.source, resolved: url, commit, ...(ref && { ref }), subdir, installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) lockfileChanged = true console.log( styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)} (subdir: ${subdir})`), ) } else { console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`)) const branchArg = ref ? ` --branch ${ref}` : "" await execAsync(`git clone --depth 1${branchArg} "${url}" "${pluginDir}"`) const { stdout } = await execAsync("git rev-parse HEAD", { cwd: pluginDir }) const commit = stdout.trim() lockfile.plugins[name] = { source: entry.source, resolved: url, commit, ...(ref && { ref }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) lockfileChanged = true console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`)) } } catch (error) { console.log( styleText("red", `✗ Failed to resolve ${formatSource(entry.source)}: ${error}`), ) failed++ } }, ) } if (installed.length > 0) { console.log() console.log(styleText("cyan", "→ Building plugins...")) const concurrency = resolvedConcurrency 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() } if (orphans.length > 0) { console.log() let removedOrphans = false for (const name of orphans) { const entry = lockfile.plugins[name] if (entry?.commit === "local") { console.log( styleText( "yellow", `⚠ ${name} is a local plugin not in config — skipping (remove manually with 'plugin remove')`, ), ) continue } const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { fs.rmSync(pluginDir, { recursive: true }) } delete lockfile.plugins[name] lockfileChanged = true removedOrphans = true console.log(styleText("yellow", `✗ Removed ${name} (not in config)`)) } if (removedOrphans) { await regeneratePluginIndex() } } if (lockfileChanged) { 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")) } else if (failed > 0) { console.log() console.log(styleText("yellow", `⚠ Resolved ${installed.length} plugin(s), ${failed} failed`)) } return } if (dryRun) { const entries = Object.entries(lockfile.plugins).filter(([name]) => nameFilter ? nameFilter.has(name) : true, ) if (entries.length === 0) { console.log(styleText("gray", "No plugins installed")) return } console.log(styleText("cyan", "→ Dry run: plugins to install from lockfile...")) for (const [name, entry] of entries) { const sourceLabel = entry.source ? formatSource(entry.source) : entry.resolved const commitLabel = entry.commit === "local" ? "local" : entry.commit.slice(0, 7) console.log(` ${styleText("yellow", name)} — ${sourceLabel} (${commitLabel})`) } return } if (clean) { console.log(styleText("cyan", "→ Restoring plugins from lockfile...")) console.log() if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }) } let installed = 0 let failed = 0 const restoredPlugins = [] const entries = Object.entries(lockfile.plugins).filter(([name]) => nameFilter ? nameFilter.has(name) : true, ) // Handle local symlinks and collect remote plugins to clone const remotePlugins = [] for (const [name, entry] of entries) { const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { console.log(styleText("yellow", `⚠ ${name}: directory exists, skipping`)) continue } 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 }) symlinkOrCopySync(entry.resolved, pluginDir) 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 } remotePlugins.push({ name, entry, pluginDir }) } // Clone remote plugins in parallel if (remotePlugins.length > 0) { const concurrency = resolvedConcurrency await runParallel(remotePlugins, concurrency, async ({ name, entry, pluginDir }) => { try { if (entry.subdir) { console.log( styleText( "cyan", `→ ${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)} (subdir: ${entry.subdir})...`, ), ) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) await cloneWithSubdirAsync({ url: entry.resolved, ref: entry.ref, subdir: entry.subdir, pluginDir, }) } else { console.log( styleText( "cyan", `→ ${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)}...`, ), ) const branchArg = entry.ref ? ` --branch ${entry.ref}` : "" await execAsync(`git clone --depth 1${branchArg} "${entry.resolved}" "${pluginDir}"`) await execAsync(`git checkout ${entry.commit}`, { cwd: pluginDir }) } 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 = resolvedConcurrency 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`)) } return } if (latest) { const pluginsToUpdate = nameFilter ? Array.from(nameFilter) : Object.keys(lockfile.plugins) const updatedPlugins = [] let lockfileChanged = false // Phase 1: Validate and categorize plugins (fast, sequential) const validPlugins = [] 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 } if (entry.commit === "local") { console.log(styleText("cyan", `→ Rebuilding local plugin ${name}...`)) updatedPlugins.push({ name, pluginDir }) continue } validPlugins.push({ name, pluginDir, entry }) } // Phase 2: Fetch/update plugins in parallel if (validPlugins.length > 0) { const concurrency = resolvedConcurrency await runParallel(validPlugins, concurrency, async ({ name, pluginDir, entry }) => { try { console.log(styleText("cyan", `→ Updating ${name}...`)) if (entry.subdir) { fs.rmSync(pluginDir, { recursive: true }) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) const newCommit = await cloneWithSubdirAsync({ url: entry.resolved, ref: entry.ref, subdir: entry.subdir, pluginDir, }) if (needsBuild(pluginDir)) { updatedPlugins.push({ name, pluginDir }) } if (newCommit !== entry.commit) { entry.commit = newCommit entry.installedAt = new Date().toISOString() lockfileChanged = true console.log( styleText( "green", `✓ Updated ${name} to ${newCommit.slice(0, 7)} (subdir: ${entry.subdir})`, ), ) } else { console.log(styleText("gray", `✓ ${name} rebuilt (subdir: ${entry.subdir})`)) } } else { const fetchRef = entry.ref || "" const resetTarget = entry.ref ? `origin/${entry.ref}` : "origin/HEAD" await execAsync(`git fetch --depth 1 origin${fetchRef ? " " + fetchRef : ""}`, { cwd: pluginDir, }) await execAsync(`git reset --hard ${resetTarget}`, { cwd: pluginDir }) const { stdout } = await execAsync("git rev-parse HEAD", { cwd: pluginDir }) const newCommit = stdout.trim() if (newCommit !== entry.commit) { entry.commit = newCommit entry.installedAt = new Date().toISOString() updatedPlugins.push({ name, pluginDir }) lockfileChanged = true 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}`)) } }) } // Phase 3: Build updated plugins in parallel if (updatedPlugins.length > 0) { console.log() console.log(styleText("cyan", "→ Rebuilding updated plugins...")) const concurrency = resolvedConcurrency 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() } if (lockfileChanged) { writeLockfile(lockfile) console.log() console.log(styleText("gray", "Updated quartz.lock.json")) } return } if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }) } const entries = Object.entries(lockfile.plugins).filter(([name]) => nameFilter ? nameFilter.has(name) : true, ) if (entries.length === 0) { console.log(styleText("gray", "No plugins installed")) return } console.log(styleText("cyan", "→ Installing plugins from lockfile...")) let installed = 0 let failed = 0 const pluginsToBuild = [] // Handle local plugins and collect entries needing git operations const gitEntries = [] for (const [name, entry] of entries) { const pluginDir = path.join(PLUGINS_DIR, name) 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 } 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 }) symlinkOrCopySync(entry.resolved, pluginDir) 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)) { if (entry.subdir) { if (!needsBuild(pluginDir)) { console.log( styleText("gray", ` ✓ ${name}@${entry.commit.slice(0, 7)} already installed (subdir)`), ) installed++ continue } pluginsToBuild.push({ name, pluginDir }) installed++ } else { 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) { gitEntries.push({ name, entry, pluginDir, action: "update" }) } else { pluginsToBuild.push({ name, pluginDir }) installed++ } } } else { gitEntries.push({ name, entry, pluginDir, action: "clone" }) } } // Run git fetch/clone operations in parallel if (gitEntries.length > 0) { const concurrency = resolvedConcurrency await runParallel(gitEntries, concurrency, async ({ name, entry, pluginDir, action }) => { try { if (action === "update") { console.log(styleText("cyan", ` → ${name}: updating to ${entry.commit.slice(0, 7)}...`)) const fetchRef = entry.ref ? ` ${entry.ref}` : "" await execAsync(`git fetch --depth 1 origin${fetchRef}`, { cwd: pluginDir }) await execAsync(`git reset --hard ${entry.commit}`, { cwd: pluginDir }) pluginsToBuild.push({ name, pluginDir }) installed++ } else { if (entry.subdir) { console.log(styleText("cyan", ` → ${name}: cloning (subdir: ${entry.subdir})...`)) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) await cloneWithSubdirAsync({ url: entry.resolved, ref: entry.ref, subdir: entry.subdir, pluginDir, }) } else { console.log(styleText("cyan", ` → ${name}: cloning...`)) const branchArg = entry.ref ? ` --branch ${entry.ref}` : "" await execAsync(`git clone --depth 1${branchArg} "${entry.resolved}" "${pluginDir}"`) if (entry.commit !== "unknown") { await execAsync(`git fetch --depth 1 origin ${entry.commit}`, { cwd: pluginDir }) await execAsync(`git checkout ${entry.commit}`, { cwd: pluginDir }) } } console.log(styleText("green", ` ✓ ${name}@${entry.commit.slice(0, 7)}`)) pluginsToBuild.push({ name, pluginDir }) installed++ } } catch { console.log( styleText("red", ` ✗ ${name}: failed to ${action === "update" ? "update" : "clone"}`), ) failed++ } }) } if (pluginsToBuild.length > 0) { console.log() console.log(styleText("cyan", "→ Building plugins...")) const concurrency = resolvedConcurrency 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 handlePluginInstall() { return handlePluginInstallUnified() } export async function handlePluginAdd( sources, { name: nameOverride, subdir: subdirOverride, concurrency: concurrencyOption } = {}, ) { if (nameOverride && sources.length > 1) { console.log(styleText("red", "✗ --name/--as can only be used when adding a single plugin")) return } if (subdirOverride && sources.length > 1) { console.log(styleText("red", "✗ --subdir can only be used when adding a single plugin")) return } const resolvedConcurrency = Math.max(1, concurrencyOption ?? os.cpus().length) let lockfile = readLockfile() if (!lockfile) { lockfile = { version: "1.0.0", plugins: {} } } if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }) } const addedPlugins = [] // Handle local plugins and collect remote sources to clone const remoteSources = [] for (const source of sources) { try { const parsed = parseGitSource(source) const name = nameOverride ?? parsed.name const url = parsed.url const ref = parsed.ref const local = parsed.local const subdir = subdirOverride ?? parsed.subdir const pluginDir = path.join(PLUGINS_DIR, name) let configSource = undefined if (nameOverride || subdirOverride) { configSource = { repo: source } if (nameOverride) configSource.name = nameOverride if (subdirOverride) configSource.subdir = subdirOverride } if (fs.existsSync(pluginDir)) { console.log(styleText("yellow", `⚠ ${name} already exists. Use 'update' to refresh.`)) continue } if (local) { let resolvedPath = path.resolve(url) if (subdir) resolvedPath = path.join(resolvedPath, subdir) 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 }) symlinkOrCopySync(resolvedPath, pluginDir) lockfile.plugins[name] = { source, resolved: resolvedPath, commit: "local", ...(subdir && { subdir }), installedAt: new Date().toISOString(), } addedPlugins.push({ name, pluginDir, source, configSource }) console.log(styleText("green", `✓ Added ${name} (local symlink)`)) } else { remoteSources.push({ source, name, url, ref, subdir, pluginDir, configSource }) } } catch (error) { console.log(styleText("red", `✗ Failed to add ${formatSource(source)}: ${error}`)) } } // Clone remote plugins in parallel if (remoteSources.length > 0) { const concurrency = resolvedConcurrency await runParallel( remoteSources, concurrency, async ({ source, name, url, ref, subdir, pluginDir, configSource }) => { try { if (subdir) { console.log(styleText("cyan", `→ Adding ${name} from ${url} (subdir: ${subdir})...`)) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) const commit = await cloneWithSubdirAsync({ url, ref, subdir, pluginDir }) lockfile.plugins[name] = { source, resolved: url, commit, ...(ref && { ref }), subdir, installedAt: new Date().toISOString(), } addedPlugins.push({ name, pluginDir, source, configSource }) console.log( styleText("green", `✓ Added ${name}@${commit.slice(0, 7)} (subdir: ${subdir})`), ) } else { console.log(styleText("cyan", `→ Adding ${name} from ${url}...`)) const branchArg = ref ? ` --branch ${ref}` : "" await execAsync(`git clone --depth 1${branchArg} "${url}" "${pluginDir}"`) const { stdout } = await execAsync("git rev-parse HEAD", { cwd: pluginDir }) const commit = stdout.trim() lockfile.plugins[name] = { source, resolved: url, commit, ...(ref && { ref }), installedAt: new Date().toISOString(), } addedPlugins.push({ name, pluginDir, source, configSource }) console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) } } catch (error) { console.log(styleText("red", `✗ Failed to add ${formatSource(source)}: ${error}`)) } }, ) } if (addedPlugins.length > 0) { console.log() console.log(styleText("cyan", "→ Building plugins...")) const concurrency = resolvedConcurrency 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, configSource } of addedPlugins) { const manifest = readManifestFromPackageJson(pluginDir) const newEntry = { source: configSource ?? source, enabled: manifest?.defaultEnabled ?? true, options: manifest?.defaultOptions ?? {}, order: manifest?.defaultOrder ?? 50, } if (manifest?.components) { const layoutPositions = new Set(["left", "right", "beforeBody", "afterBody"]) const firstComponentKey = Object.keys(manifest.components)[0] const comp = manifest.components[firstComponentKey] if (comp?.defaultPosition && layoutPositions.has(comp.defaultPosition)) { newEntry.layout = { position: comp.defaultPosition, priority: comp.defaultPriority ?? 50, } } } 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 } const pluginsJson = readPluginsJson() let removed = false const resolvedNames = [] for (const name of names) { const lockKey = resolveLockfileName(name, lockfile, pluginsJson) resolvedNames.push(lockKey) const pluginDir = path.join(PLUGINS_DIR, lockKey) if (!lockfile.plugins[lockKey] && !fs.existsSync(pluginDir)) { console.log(styleText("yellow", `⚠ ${name} is not installed`)) continue } const displayName = lockKey !== name ? `${name} (${lockKey})` : name console.log(styleText("cyan", `→ Removing ${displayName}...`)) if (fs.existsSync(pluginDir)) { fs.rmSync(pluginDir, { recursive: true }) } delete lockfile.plugins[lockKey] console.log(styleText("green", `✓ Removed ${displayName}`)) removed = true } if (removed) { await regeneratePluginIndex() } writeLockfile(lockfile) if (pluginsJson?.plugins) { pluginsJson.plugins = pluginsJson.plugins.filter( (plugin) => !names.includes(extractPluginName(plugin.source)) && !names.includes(formatSource(plugin.source)) && !resolvedNames.includes(extractPluginName(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 || formatSource(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 || formatSource(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 || formatSource(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: ${formatSource(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() { return handlePluginInstallUnified({ latest: true, dryRun: true }) } export async function handlePluginUpdate(names) { return handlePluginInstallUnified({ names, latest: true }) } export async function handlePluginList() { const lockfile = readLockfile() if (!lockfile || Object.keys(lockfile.plugins).length === 0) { console.log(styleText("gray", "No plugins installed")) return } const pluginsJson = readPluginsJson() const nameOverrides = getNameOverrides(lockfile, pluginsJson) 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) const overriddenName = nameOverrides.get(name) const displayLabel = overriddenName ? `${overriddenName} ${styleText("gray", `(dir: ${name})`)}` : name if (entry.commit === "local") { const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink() const status = isLinked ? styleText("green", "✓") : styleText("red", "✗") console.log(` ${status} ${styleText("bold", displayLabel)}`) console.log(` Source: ${formatSource(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", displayLabel)}`) console.log(` Source: ${formatSource(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 handlePluginStatus() { const lockfile = readLockfile() if (!lockfile || Object.keys(lockfile.plugins).length === 0) { console.log(styleText("gray", "No plugins installed")) return } const pluginsJson = readPluginsJson() const nameOverrides = getNameOverrides(lockfile, pluginsJson) const enabledByName = new Map( (pluginsJson?.plugins ?? []).map((entry) => [ extractPluginName(entry.source), entry.enabled !== false, ]), ) const rows = Object.entries(lockfile.plugins).map(([name, entry]) => { const pluginDir = path.join(PLUGINS_DIR, name) const exists = fs.existsSync(pluginDir) const displayName = nameOverrides.get(name) ?? name const sourceLabel = formatSource(entry.source) const commitLabel = entry.commit === "local" ? "local" : `@${entry.commit.slice(0, 7)}` const enabled = enabledByName.get(name) ?? false return { name, entry, exists, displayName, sourceLabel, commitLabel, enabled } }) const nameWidth = Math.max(8, ...rows.map((row) => row.displayName.length)) + 2 const sourceWidth = Math.max(8, ...rows.map((row) => row.sourceLabel.length)) + 2 const commitWidth = Math.max(6, ...rows.map((row) => row.commitLabel.length)) + 2 const enabledWidth = Math.max("enabled".length, "disabled".length) + 2 const updateWidth = Math.max( "— local".length, "⋯".length, "✓ up to date".length, "↑ update available".length, "✗ check failed".length, ) + 2 const formatRow = (row, updateLabel, updateText) => { const statusIcon = row.exists ? styleText("green", "✓") : styleText("red", "✗") const enabledText = row.enabled ? "enabled" : "disabled" const enabledLabel = row.enabled ? styleText("green", enabledText) : styleText("gray", enabledText) const enabledColumn = `${enabledLabel}${" ".repeat(enabledWidth - enabledText.length)}` const updateColumn = `${updateLabel}${" ".repeat(Math.max(0, updateWidth - updateText.length))}` return ` ${statusIcon} ${row.displayName.padEnd(nameWidth)}${row.sourceLabel.padEnd( sourceWidth, )}${row.commitLabel.padEnd(commitWidth)}${enabledColumn}${updateColumn}` } const updateDisplay = (status) => { switch (status) { case "local": return { text: "— local", label: styleText("gray", "— local") } case "up_to_date": return { text: "✓ up to date", label: styleText("green", "✓ up to date") } case "update_available": return { text: "↑ update available", label: styleText("yellow", "↑ update available") } case "failed": return { text: "✗ check failed", label: styleText("red", "✗ check failed") } default: return { text: "⋯", label: styleText("cyan", "⋯") } } } const isTTY = process.stdout.isTTY const updateLine = (index, updateLabel, updateText) => { if (!isTTY) return const offset = rows.length - index process.stdout.write( `\x1b[${offset}A\x1b[2K\r${formatRow(rows[index], updateLabel, updateText)}\x1b[${offset}B`, ) } if (isTTY) { console.log(styleText("bold", "Installed Plugins:")) console.log() for (const row of rows) { const display = row.entry.commit === "local" ? updateDisplay("local") : updateDisplay("checking") console.log(formatRow(row, display.label, display.text)) } } const promises = rows.map((row, index) => { if (row.entry.commit === "local") { return Promise.resolve({ index, status: "local", name: row.displayName, }) } const lsRemoteRef = row.entry.ref ? `refs/heads/${row.entry.ref}` : "HEAD" return execAsync(`git ls-remote "${row.entry.resolved}" ${lsRemoteRef}`) .then(({ stdout }) => { const latestCommit = stdout.split("\t")[0].trim() const status = latestCommit === row.entry.commit ? "up_to_date" : "update_available" const display = updateDisplay(status) updateLine(index, display.label, display.text) return { index, status, name: row.displayName } }) .catch(() => { const display = updateDisplay("failed") updateLine(index, display.label, display.text) return { index, status: "failed", name: row.displayName } }) }) const results = await Promise.all(promises) const updatesAvailable = results .filter((result) => result.status === "update_available") .map((result) => result.name) const failedChecks = results .filter((result) => result.status === "failed") .map((result) => result.name) if (!isTTY) { console.log(styleText("bold", "Installed Plugins:")) console.log() for (const result of results) { const row = rows[result.index] const display = updateDisplay(result.status) console.log(formatRow(row, display.label, display.text)) } } if (updatesAvailable.length === 0 && failedChecks.length === 0) { console.log(styleText("green", "\n✓ All plugins up to date")) return } if (updatesAvailable.length > 0) { console.log(styleText("yellow", `\nUpdates available: ${updatesAvailable.join(", ")}`)) console.log(styleText("gray", "Run 'npx quartz plugin install --latest' to update.")) } if (failedChecks.length > 0) { console.log(styleText("red", `\nChecks failed: ${failedChecks.join(", ")}`)) } } export async function handlePluginRestore() { return handlePluginInstallUnified({ clean: true }) } 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 } = {}) { return handlePluginInstallUnified({ fromConfig: true, dryRun }) }