diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index aa9ab2b..eba7f99 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -4,35 +4,22 @@ import { hideBin } from "yargs/helpers" import { handleBuild, handleCreate, - handleUpdate, handleUpgrade, handleRestore, handleSync, } from "./cli/handlers.js" import { handleMigrate } from "./cli/migrate-handler.js" import { - handlePluginInstall as handleGitPluginInstall, + handlePluginInstallUnified, handlePluginAdd, handlePluginRemove, - handlePluginUpdate, - handlePluginRestore, handlePluginList, handlePluginEnable, handlePluginDisable, handlePluginConfig, - handlePluginCheck, handlePluginPrune, - handlePluginResolve, } from "./cli/plugin-git-handlers.js" -import { - CommonArgv, - BuildArgv, - CreateArgv, - SyncArgv, - PluginInstallArgv, - PluginUninstallArgv, - PluginSearchArgv, -} from "./cli/args.js" +import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js" import { version } from "./cli/constants.js" async function launchTui() { @@ -83,14 +70,6 @@ yargs(hideBin(process.argv)) .command("create", "Initialize Quartz", CreateArgv, async (argv) => { await handleCreate(argv) }) - .command( - "update [names..]", - "Update installed plugins to latest version", - CommonArgv, - async (argv) => { - await handleUpdate(argv) - }, - ) .command("upgrade", "Upgrade Quartz to the latest version", CommonArgv, async (argv) => { await handleUpgrade(argv) }) @@ -118,115 +97,162 @@ yargs(hideBin(process.argv)) "plugin [subcommand]", "Manage Quartz plugins", (yargs) => { - return yargs - .command("install", "Install plugins from quartz.lock.json", CommonArgv, async () => { - await handleGitPluginInstall() - }) - .command( - "add ", - "Add plugins from Git repositories", - { - ...CommonArgv, - name: { - string: true, - alias: ["as"], - describe: "Override the plugin name (for resolving conflicts with duplicate names)", + return ( + yargs + .command( + "install [names..]", + "Install plugins from lockfile or config", + { + ...CommonArgv, + "from-config": { + boolean: true, + default: false, + describe: "install plugins referenced in quartz.config.yaml instead of lockfile", + }, + latest: { + boolean: true, + default: false, + describe: "fetch latest version from remote instead of pinned lockfile commit", + }, + clean: { + boolean: true, + default: false, + describe: "skip plugins whose directory already exists", + }, + "dry-run": { + boolean: true, + default: false, + describe: "show what would happen without making changes", + }, }, - subdir: { - string: true, - describe: "Subdirectory within the repository containing the plugin", + async (argv) => { + await handlePluginInstallUnified({ + names: argv.names?.length ? argv.names : undefined, + fromConfig: argv.fromConfig, + latest: argv.latest, + clean: argv.clean, + dryRun: argv.dryRun, + }) }, - }, - async (argv) => { - await handlePluginAdd(argv.repos, { name: argv.name, subdir: argv.subdir }) - }, - ) - .command("remove ", "Remove installed plugins", CommonArgv, async (argv) => { - await handlePluginRemove(argv.names) - }) - .command( - "update [names..]", - "Update installed plugins to latest version", - CommonArgv, - async (argv) => { - await handlePluginUpdate(argv.names) - }, - ) - .command("list", "List all installed plugins", CommonArgv, async () => { - await handlePluginList() - }) - .command( - "restore", - "Restore plugins from lockfile (exact versions)", - CommonArgv, - async () => { - await handlePluginRestore() - }, - ) - .command( - "enable ", - "Enable plugins in quartz.config.yaml", - CommonArgv, - async (argv) => { - await handlePluginEnable(argv.names) - }, - ) - .command( - "disable ", - "Disable plugins in quartz.config.yaml", - CommonArgv, - async (argv) => { - await handlePluginDisable(argv.names) - }, - ) - .command( - "config ", - "View or set plugin configuration", - { - ...CommonArgv, - set: { - string: true, - describe: "Set a config value (key=value)", + ) + .command( + "add ", + "Add plugins from Git repositories", + { + ...CommonArgv, + name: { + string: true, + alias: ["as"], + describe: "Override the plugin name (for resolving conflicts with duplicate names)", + }, + subdir: { + string: true, + describe: "Subdirectory within the repository containing the plugin", + }, }, - }, - async (argv) => { - await handlePluginConfig(argv.name, { set: argv.set }) - }, - ) - .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 handlePluginAdd(argv.repos, { + name: argv.name, + subdir: argv.subdir, + }) }, - }, - 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", + ) + .command("remove ", "Remove installed plugins", CommonArgv, async (argv) => { + await handlePluginRemove(argv.names) + }) + .command("list", "List all installed plugins", CommonArgv, async () => { + await handlePluginList() + }) + .command( + "enable ", + "Enable plugins in quartz.config.yaml", + CommonArgv, + async (argv) => { + await handlePluginEnable(argv.names) }, - }, - async (argv) => { - await handlePluginResolve({ dryRun: argv.dryRun }) - }, - ) - .demandCommand(0, "") + ) + .command( + "disable ", + "Disable plugins in quartz.config.yaml", + CommonArgv, + async (argv) => { + await handlePluginDisable(argv.names) + }, + ) + .command( + "config ", + "View or set plugin configuration", + { + ...CommonArgv, + set: { + string: true, + describe: "Set a config value (key=value)", + }, + }, + async (argv) => { + await handlePluginConfig(argv.name, { set: argv.set }) + }, + ) + .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 }) + }, + ) + // Hidden deprecated aliases + .command("restore", false, CommonArgv, async () => { + console.log( + "\x1b[33m⚠ 'plugin restore' is deprecated. Use 'plugin install --clean' instead.\x1b[0m", + ) + await handlePluginInstallUnified({ clean: true }) + }) + .command("update [names..]", false, CommonArgv, async (argv) => { + console.log( + "\x1b[33m⚠ 'plugin update' is deprecated. Use 'plugin install --latest' instead.\x1b[0m", + ) + await handlePluginInstallUnified({ + names: argv.names?.length ? argv.names : undefined, + latest: true, + }) + }) + .command("check", false, CommonArgv, async () => { + console.log( + "\x1b[33m⚠ 'plugin check' is deprecated. Use 'plugin install --latest --dry-run' instead.\x1b[0m", + ) + await handlePluginInstallUnified({ latest: true, dryRun: true }) + }) + .command( + "resolve", + false, + { + ...CommonArgv, + "dry-run": { + boolean: true, + default: false, + describe: "show what would be resolved without making changes", + }, + }, + async (argv) => { + console.log( + "\x1b[33m⚠ 'plugin resolve' is deprecated. Use 'plugin install --from-config' instead.\x1b[0m", + ) + await handlePluginInstallUnified({ + fromConfig: true, + dryRun: argv.dryRun, + }) + }, + ) + .demandCommand(0, "") + ) }, async (argv) => { if (!argv._.includes("plugin") || argv._.length > 1) return diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 1b20b00..24e9080 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -26,7 +26,6 @@ import { import { handlePluginRestore, handlePluginCheck, - handlePluginUpdate, handlePluginResolve, } from "./plugin-git-handlers.js" import { @@ -682,16 +681,6 @@ export async function handleUpgrade(argv) { console.log(styleText("green", "Done!")) } -/** - * Handles `npx quartz update` - * Shortcut for `npx quartz plugin update` — updates all installed plugins. - * @param {*} argv arguments for `update` - */ -export async function handleUpdate(argv) { - console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`) - await handlePluginUpdate(argv.names) -} - /** * Handles `npx quartz restore` * @param {*} argv arguments for `restore` diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index d551ef4..4816f11 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -274,29 +274,591 @@ async function regeneratePluginIndex() { fs.writeFileSync(indexPath, indexContent) } -export async function handlePluginInstall() { - const lockfile = readLockfile() +export async function handlePluginInstallUnified({ + names, + fromConfig = false, + latest = false, + clean = false, + dryRun = false, +} = {}) { + if (clean && latest) { + console.log(styleText("red", "✗ --clean and --latest cannot be used together")) + return + } - if (!lockfile) { + const pluginsJson = readPluginsJson() + let lockfile = readLockfile() + + if (!fromConfig && !lockfile) { console.log( - styleText("yellow", "⚠ No quartz.lock.json found. Run 'npx quartz plugin add ' first."), + styleText( + "yellow", + "⚠ No quartz.lock.json found. Run 'npx quartz plugin add ' 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) + + console.log(styleText("bold", "Checking for plugin updates...\n")) + + const results = [] + for (const [name, entry] of Object.entries(lockfile.plugins)) { + if (nameFilter && !nameFilter.has(name)) continue + const displayName = nameOverrides.get(name) ?? name + + if (entry.commit === "local") { + results.push({ + name: displayName, + 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: displayName, + installed: entry.commit.slice(0, 7), + latest: latestCommit.slice(0, 7), + status: isCurrent ? "up to date" : "update available", + }) + } catch { + results.push({ + name: displayName, + 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, + )}`, + ) + } + 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 + + 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 }) + fs.symlinkSync(resolvedPath, pluginDir, "dir") + 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 if (subdir) { + console.log(styleText("cyan", `→ Cloning ${name} from ${url} (subdir: ${subdir})...`)) + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + const commit = cloneWithSubdir({ 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}...`)) + + 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 }) + 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 = 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() + } + + if (orphans.length > 0) { + console.log() + for (const name of orphans) { + const pluginDir = path.join(PLUGINS_DIR, name) + if (fs.existsSync(pluginDir)) { + fs.rmSync(pluginDir, { recursive: true }) + } + delete lockfile.plugins[name] + lockfileChanged = true + console.log(styleText("yellow", `✗ Removed ${name} (not in config)`)) + } + 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, + ) + + 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 }) + 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 { + 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 }) + cloneWithSubdir({ 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}` : "" + execSync(`git clone --depth 1${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`)) + } + return + } + + if (latest) { + const pluginsToUpdate = nameFilter ? Array.from(nameFilter) : Object.keys(lockfile.plugins) + const updatedPlugins = [] + let lockfileChanged = false + + 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 + } + + try { + console.log(styleText("cyan", `→ Updating ${name}...`)) + + if (entry.subdir) { + fs.rmSync(pluginDir, { recursive: true }) + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + const newCommit = cloneWithSubdir({ + 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" + 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 }) + 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}`)) + } + } + + 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() + } + + 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 = [] - for (const [name, entry] of Object.entries(lockfile.plugins)) { + for (const [name, entry] of entries) { const pluginDir = path.join(PLUGINS_DIR, name) - // Local plugin: ensure symlink exists if (entry.commit === "local") { try { if (fs.existsSync(pluginDir)) { @@ -306,7 +868,6 @@ export async function handlePluginInstall() { installed++ continue } - // Wrong target or not a symlink — remove and re-link if (stat.isSymbolicLink()) fs.unlinkSync(pluginDir) else fs.rmSync(pluginDir, { recursive: true }) } @@ -421,6 +982,10 @@ export async function handlePluginInstall() { } } +export async function handlePluginInstall() { + return handlePluginInstallUnified() +} + export async function handlePluginAdd( sources, { name: nameOverride, subdir: subdirOverride } = {}, @@ -729,178 +1294,11 @@ export async function handlePluginConfig(name, options = {}) { } export async function handlePluginCheck() { - 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", "Checking for plugin updates...\n")) - - const results = [] - for (const [name, entry] of Object.entries(lockfile.plugins)) { - const displayName = nameOverrides.get(name) ?? name - - if (entry.commit === "local") { - results.push({ - name: displayName, - 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: displayName, - installed: entry.commit.slice(0, 7), - latest: latestCommit.slice(0, 7), - status: isCurrent ? "up to date" : "update available", - }) - } catch { - results.push({ - name: displayName, - 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, - )}`, - ) - } + return handlePluginInstallUnified({ latest: true, dryRun: true }) } export async function handlePluginUpdate(names) { - const lockfile = readLockfile() - if (!lockfile) { - console.log(styleText("yellow", "⚠ No plugins installed")) - return - } - - const pluginsJson = readPluginsJson() - const pluginsToUpdate = names - ? names.map((n) => resolveLockfileName(n, lockfile, pluginsJson)) - : 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}...`)) - - if (entry.subdir) { - fs.rmSync(pluginDir, { recursive: true }) - fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - const newCommit = cloneWithSubdir({ - 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() - 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" - 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")) + return handlePluginInstallUnified({ names, latest: true }) } export async function handlePluginList() { @@ -960,107 +1358,7 @@ export async function handlePluginList() { } 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 ' 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 { - 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 }) - cloneWithSubdir({ 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}` : "" - execSync(`git clone --depth 1${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`)) - } + return handlePluginInstallUnified({ clean: true }) } export async function handlePluginPrune({ dryRun = false } = {}) { @@ -1119,191 +1417,5 @@ export async function handlePluginPrune({ dryRun = false } = {}) { } 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) - 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) - ) - }) - - 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)} — ${formatSource(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, 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 }) - 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 }) - continue - } - - if (local) { - // Local path: symlink - 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 }) - fs.symlinkSync(resolvedPath, pluginDir, "dir") - lockfile.plugins[name] = { - source: entry.source, - resolved: resolvedPath, - commit: "local", - ...(subdir && { subdir }), - installedAt: new Date().toISOString(), - } - installed.push({ name, pluginDir }) - console.log(styleText("green", `✓ Linked ${name} (local)`)) - } else if (subdir) { - console.log(styleText("cyan", `→ Cloning ${name} from ${url} (subdir: ${subdir})...`)) - fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - const commit = cloneWithSubdir({ url, ref, subdir, pluginDir }) - lockfile.plugins[name] = { - source: entry.source, - resolved: url, - commit, - ...(ref && { ref }), - subdir, - installedAt: new Date().toISOString(), - } - installed.push({ name, pluginDir }) - console.log( - styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)} (subdir: ${subdir})`), - ) - } 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 ${formatSource(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() - } - - const configNames = new Set(pluginsJson.plugins.map((entry) => extractPluginName(entry.source))) - const orphans = Object.keys(lockfile.plugins).filter((name) => !configNames.has(name)) - if (orphans.length > 0) { - console.log() - for (const name of orphans) { - const pluginDir = path.join(PLUGINS_DIR, name) - if (fs.existsSync(pluginDir)) { - fs.rmSync(pluginDir, { recursive: true }) - } - delete lockfile.plugins[name] - console.log(styleText("yellow", `✗ Removed ${name} (not in config)`)) - } - 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")) + return handlePluginInstallUnified({ fromConfig: true, dryRun }) }