From 1c2fef9b5a4d840128774fe40a7e1123765be326 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Wed, 11 Mar 2026 19:31:58 +0100 Subject: [PATCH] feat(cli): prune and resolve --- quartz/bootstrap-cli.mjs | 32 ++++++ quartz/cli/plugin-data.js | 2 + quartz/cli/plugin-git-handlers.js | 167 +++++++++++++++++++++++++++++ quartz/cli/templates/obsidian.yaml | 2 +- quartz/cli/templates/ttrpg.yaml | 2 +- 5 files changed, 203 insertions(+), 2 deletions(-) diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 85ad76444..a49d61856 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -21,6 +21,8 @@ import { handlePluginDisable, handlePluginConfig, handlePluginCheck, + handlePluginPrune, + handlePluginResolve, } from "./cli/plugin-git-handlers.js" import { CommonArgv, @@ -178,6 +180,36 @@ yargs(hideBin(process.argv)) .command("check", "Check for plugin updates", CommonArgv, async () => { await handlePluginCheck() }) + .command( + "prune", + "Remove installed plugins no longer referenced in config", + { + ...CommonArgv, + "dry-run": { + boolean: true, + default: false, + describe: "show what would be pruned without making changes", + }, + }, + async (argv) => { + await handlePluginPrune({ dryRun: argv.dryRun }) + }, + ) + .command( + "resolve", + "Install plugins from config that are not yet in the lockfile", + { + ...CommonArgv, + "dry-run": { + boolean: true, + default: false, + describe: "show what would be resolved without making changes", + }, + }, + async (argv) => { + await handlePluginResolve({ dryRun: argv.dryRun }) + }, + ) .demandCommand(0, "") }, async (argv) => { diff --git a/quartz/cli/plugin-data.js b/quartz/cli/plugin-data.js index 687c4029f..b1e92f772 100644 --- a/quartz/cli/plugin-data.js +++ b/quartz/cli/plugin-data.js @@ -15,6 +15,8 @@ const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugin function resolveConfigPath() { if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH + if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH + if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH return CONFIG_YAML_PATH } diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 678b519f0..2a31069c2 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -701,3 +701,170 @@ export async function handlePluginRestore() { 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-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 + const src = entry.source + return src.startsWith("github:") || src.startsWith("git+") || src.startsWith("https://") + }) + + 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 } = parseGitSource(entry.source) + const pluginDir = path.join(PLUGINS_DIR, name) + + if (fs.existsSync(pluginDir)) { + console.log(styleText("yellow", `⚠ ${name} directory already exists, updating lockfile`)) + const commit = getGitCommit(pluginDir) + lockfile.plugins[name] = { + source: entry.source, + resolved: url, + commit, + installedAt: new Date().toISOString(), + } + installed.push({ name, pluginDir }) + continue + } + + 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, + 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...")) + for (const { name, pluginDir } of installed) { + if (!buildPlugin(pluginDir, name)) { + failed++ + } else { + console.log(styleText("green", ` ✓ ${name} built`)) + } + } + 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")) +} diff --git a/quartz/cli/templates/obsidian.yaml b/quartz/cli/templates/obsidian.yaml index 9f9c03d19..3506be7a9 100644 --- a/quartz/cli/templates/obsidian.yaml +++ b/quartz/cli/templates/obsidian.yaml @@ -112,7 +112,7 @@ plugins: enabled: false order: 85 - source: github:quartz-community/hard-line-breaks - enabled: false + enabled: true order: 90 - source: github:quartz-community/ox-hugo enabled: false diff --git a/quartz/cli/templates/ttrpg.yaml b/quartz/cli/templates/ttrpg.yaml index 831fd79f3..cbf2394de 100644 --- a/quartz/cli/templates/ttrpg.yaml +++ b/quartz/cli/templates/ttrpg.yaml @@ -112,7 +112,7 @@ plugins: enabled: false order: 85 - source: github:quartz-community/hard-line-breaks - enabled: false + enabled: true order: 90 - source: github:quartz-community/ox-hugo enabled: false