diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index a49d618..aa9ab2b 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -122,9 +122,25 @@ yargs(hideBin(process.argv)) .command("install", "Install plugins from quartz.lock.json", CommonArgv, async () => { await handleGitPluginInstall() }) - .command("add ", "Add plugins from Git repositories", CommonArgv, async (argv) => { - await handlePluginAdd(argv.repos) - }) + .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 handlePluginAdd(argv.repos, { name: argv.name, subdir: argv.subdir }) + }, + ) .command("remove ", "Remove installed plugins", CommonArgv, async (argv) => { await handlePluginRemove(argv.names) }) diff --git a/quartz/cli/plugin-data.js b/quartz/cli/plugin-data.js index e5cdfec..b8e1958 100644 --- a/quartz/cli/plugin-data.js +++ b/quartz/cli/plugin-data.js @@ -400,6 +400,76 @@ export function createConfigFromTemplate(templateName) { return rest } +/** + * Resolves a user-facing plugin name (which may be an overridden name from config) + * to the corresponding lockfile key (the original name at install time). + * + * This bridges the naming identity split between config YAML (which supports + * source.name overrides) and the lockfile/disk (which are keyed by the original name). + * + * @param {string} name - The name the user provided (may be overridden or original) + * @param {object|null} lockfile - The parsed lockfile + * @param {object|null} pluginsJson - The parsed config YAML + * @returns {string} The lockfile key that corresponds to this plugin + */ +export function resolveLockfileName(name, lockfile, pluginsJson) { + // Direct match — no resolution needed + if (lockfile?.plugins?.[name]) return name + + // Check if any config entry with this overridden name maps to a different lockfile key + if (pluginsJson?.plugins) { + const configEntry = pluginsJson.plugins.find( + (e) => extractPluginName(e.source) === name || formatSource(e.source) === name, + ) + if (configEntry) { + const url = getSourceUrl(configEntry.source) + for (const [key, lock] of Object.entries(lockfile?.plugins ?? {})) { + if ( + lock.source === url || + lock.source === formatSource(configEntry.source) || + lock.resolved === url + ) { + return key + } + } + } + } + + return name +} + +/** + * Builds a map from lockfile keys to their overridden display names from config. + * Returns entries only where the overridden name differs from the lockfile key. + * + * @param {object|null} lockfile - The parsed lockfile + * @param {object|null} pluginsJson - The parsed config YAML + * @returns {Map} Map of lockfileKey → overriddenName + */ +export function getNameOverrides(lockfile, pluginsJson) { + const overrides = new Map() + if (!lockfile?.plugins || !pluginsJson?.plugins) return overrides + + for (const entry of pluginsJson.plugins) { + const configName = extractPluginName(entry.source) + const url = getSourceUrl(entry.source) + + for (const [lockKey, lock] of Object.entries(lockfile.plugins)) { + if (lockKey === configName) break // no override, names match + if ( + lock.source === url || + lock.source === formatSource(entry.source) || + lock.resolved === url + ) { + overrides.set(lockKey, configName) + break + } + } + } + + return overrides +} + export const PLUGINS_JSON_PATH = CONFIG_YAML_PATH export const DEFAULT_PLUGINS_JSON_PATH = DEFAULT_CONFIG_YAML_PATH export { LOCKFILE_PATH, PLUGINS_DIR } diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 5360562..78e1f18 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -17,6 +17,8 @@ import { isLocalSource, getSourceUrl, formatSource, + resolveLockfileName, + getNameOverrides, } from "./plugin-data.js" const INTERNAL_EXPORTS = new Set(["manifest", "default"]) @@ -419,7 +421,19 @@ export async function handlePluginInstall() { } } -export async function handlePluginAdd(sources) { +export async function handlePluginAdd( + sources, + { name: nameOverride, subdir: subdirOverride } = {}, +) { + 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 + } + let lockfile = readLockfile() if (!lockfile) { lockfile = { version: "1.0.0", plugins: {} } @@ -433,9 +447,21 @@ export async function handlePluginAdd(sources) { for (const source of sources) { try { - const { name, url, ref, local, subdir } = parseGitSource(source) + 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 @@ -458,7 +484,7 @@ export async function handlePluginAdd(sources) { ...(subdir && { subdir }), installedAt: new Date().toISOString(), } - addedPlugins.push({ name, pluginDir, source }) + addedPlugins.push({ name, pluginDir, source, configSource }) console.log(styleText("green", `✓ Added ${name} (local symlink)`)) } else if (subdir) { console.log(styleText("cyan", `→ Adding ${name} from ${url} (subdir: ${subdir})...`)) @@ -472,7 +498,7 @@ export async function handlePluginAdd(sources) { subdir, installedAt: new Date().toISOString(), } - addedPlugins.push({ name, pluginDir, source }) + 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}...`)) @@ -494,7 +520,7 @@ export async function handlePluginAdd(sources) { installedAt: new Date().toISOString(), } - addedPlugins.push({ name, pluginDir, source }) + addedPlugins.push({ name, pluginDir, source, configSource }) console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) } } catch (error) { @@ -517,10 +543,10 @@ export async function handlePluginAdd(sources) { writeLockfile(lockfile) const pluginsJson = readPluginsJson() if (pluginsJson?.plugins) { - for (const { pluginDir, source } of addedPlugins) { + for (const { pluginDir, source, configSource } of addedPlugins) { const manifest = readManifestFromPackageJson(pluginDir) const newEntry = { - source, + source: configSource ?? source, enabled: manifest?.defaultEnabled ?? true, options: manifest?.defaultOptions ?? {}, order: manifest?.defaultOrder ?? 50, @@ -553,23 +579,28 @@ export async function handlePluginRemove(names) { return } + const pluginsJson = readPluginsJson() let removed = false + const resolvedNames = [] for (const name of names) { - const pluginDir = path.join(PLUGINS_DIR, name) + const lockKey = resolveLockfileName(name, lockfile, pluginsJson) + resolvedNames.push(lockKey) + const pluginDir = path.join(PLUGINS_DIR, lockKey) - if (!lockfile.plugins[name] && !fs.existsSync(pluginDir)) { + if (!lockfile.plugins[lockKey] && !fs.existsSync(pluginDir)) { console.log(styleText("yellow", `⚠ ${name} is not installed`)) continue } - console.log(styleText("cyan", `→ Removing ${name}...`)) + 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[name] - console.log(styleText("green", `✓ Removed ${name}`)) + delete lockfile.plugins[lockKey] + console.log(styleText("green", `✓ Removed ${displayName}`)) removed = true } @@ -578,12 +609,12 @@ export async function handlePluginRemove(names) { } writeLockfile(lockfile) - const pluginsJson = readPluginsJson() if (pluginsJson?.plugins) { pluginsJson.plugins = pluginsJson.plugins.filter( (plugin) => !names.includes(extractPluginName(plugin.source)) && - !names.includes(formatSource(plugin.source)), + !names.includes(formatSource(plugin.source)) && + !resolvedNames.includes(extractPluginName(plugin.source)), ) writePluginsJson(pluginsJson) } @@ -704,14 +735,18 @@ export async function handlePluginCheck() { 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)) { - // Local plugins: show "local" status, skip git checks + const displayName = nameOverrides.get(name) ?? name + if (entry.commit === "local") { results.push({ - name, + name: displayName, installed: "local", latest: "—", status: "local", @@ -729,14 +764,14 @@ export async function handlePluginCheck() { const isCurrent = latestCommit === entry.commit results.push({ - name, + name: displayName, installed: entry.commit.slice(0, 7), latest: latestCommit.slice(0, 7), status: isCurrent ? "up to date" : "update available", }) } catch { results.push({ - name, + name: displayName, installed: entry.commit.slice(0, 7), latest: "?", status: "check failed", @@ -772,7 +807,10 @@ export async function handlePluginUpdate(names) { return } - const pluginsToUpdate = names || Object.keys(lockfile.plugins) + const pluginsJson = readPluginsJson() + const pluginsToUpdate = names + ? names.map((n) => resolveLockfileName(n, lockfile, pluginsJson)) + : Object.keys(lockfile.plugins) const updatedPlugins = [] for (const name of pluginsToUpdate) { @@ -870,18 +908,24 @@ export async function handlePluginList() { 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 - // Local plugins: special display if (entry.commit === "local") { const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink() const status = isLinked ? styleText("green", "✓") : styleText("red", "✗") - console.log(` ${status} ${styleText("bold", name)}`) + console.log(` ${status} ${styleText("bold", displayLabel)}`) console.log(` Source: ${formatSource(entry.source)}`) console.log(` Type: local symlink`) console.log(` Target: ${entry.resolved}`) @@ -902,7 +946,7 @@ export async function handlePluginList() { : styleText("yellow", "⚡") : styleText("red", "✗") - console.log(` ${status} ${styleText("bold", name)}`) + 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) {