From 7b85aa34647c9c070dae7057ffc81bc166f820a0 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Wed, 18 Mar 2026 01:12:38 +0100 Subject: [PATCH] fix(cli): extended options for plugin sources --- quartz/cli/plugin-data.js | 104 +++++++++---- quartz/cli/plugin-git-handlers.js | 237 +++++++++++++++++++++-------- quartz/cli/templates/obsidian.yaml | 5 +- quartz/cli/templates/ttrpg.yaml | 5 +- 4 files changed, 260 insertions(+), 91 deletions(-) diff --git a/quartz/cli/plugin-data.js b/quartz/cli/plugin-data.js index 8171295f9..e8617ca0b 100644 --- a/quartz/cli/plugin-data.js +++ b/quartz/cli/plugin-data.js @@ -83,32 +83,73 @@ export function writeLockfile(lockfile) { fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n") } +/** + * Normalizes a source value to a URL string. + * Source can be a plain string (e.g. "github:owner/repo") or an object + * with { name?, repo, subdir? } for installing from a subdirectory of a repo. + */ +export function getSourceUrl(source) { + if (typeof source === "string") return source + if (typeof source === "object" && source !== null && typeof source.repo === "string") { + return source.repo + } + throw new Error(`Invalid plugin source: ${JSON.stringify(source)}`) +} + +/** + * Returns the subdir from an object source, or undefined for string sources. + */ +export function getSourceSubdir(source) { + if (typeof source === "object" && source !== null && typeof source.subdir === "string") { + return source.subdir + } + return undefined +} + +/** + * Returns a display-friendly string for a source value. + */ +export function formatSource(source) { + if (typeof source === "string") return source + if (typeof source === "object" && source !== null) { + const parts = [source.repo] + if (source.subdir) parts.push(`(subdir: ${source.subdir})`) + return parts.join(" ") + } + return String(source) +} + export function isLocalSource(source) { - if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) { + const url = getSourceUrl(source) + if (url.startsWith("./") || url.startsWith("../") || url.startsWith("/")) { return true } // Windows absolute paths (e.g. C:\ or D:/) - if (/^[A-Za-z]:[\\/]/.test(source)) { + if (/^[A-Za-z]:[\\/]/.test(url)) { return true } return false } export function extractPluginName(source) { - if (isLocalSource(source)) { - return path.basename(source.replace(/[\/]+$/, "")) + if (typeof source === "object" && source !== null && typeof source.name === "string") { + return source.name } - if (source.startsWith("github:")) { - const withoutPrefix = source.replace("github:", "") + const url = getSourceUrl(source) + if (isLocalSource(url)) { + return path.basename(url.replace(/[\/]+$/, "")) + } + if (url.startsWith("github:")) { + const withoutPrefix = url.replace("github:", "") const [repoPath] = withoutPrefix.split("#") const parts = repoPath.split("/") return parts[parts.length - 1] } - if (source.startsWith("git+") || source.startsWith("https://")) { - const url = source.replace("git+", "") - const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) - return match?.[1] ?? source + if (url.startsWith("git+") || url.startsWith("https://")) { + const cleaned = url.replace("git+", "") + const match = cleaned.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) + return match?.[1] ?? url } - return source + return url } export function readManifestFromPackageJson(pluginDir) { @@ -123,28 +164,33 @@ export function readManifestFromPackageJson(pluginDir) { } export function parseGitSource(source) { - if (isLocalSource(source)) { - const resolved = path.resolve(source) - const name = path.basename(resolved) - return { name, url: resolved, ref: undefined, local: true } + const url = getSourceUrl(source) + const subdir = getSourceSubdir(source) + if (isLocalSource(url)) { + const resolved = path.resolve(url) + const name = typeof source === "object" && source.name ? source.name : path.basename(resolved) + return { name, url: resolved, ref: undefined, local: true, subdir } } - if (source.startsWith("github:")) { - const [repoPath, ref] = source.replace("github:", "").split("#") + if (url.startsWith("github:")) { + const [repoPath, ref] = url.replace("github:", "").split("#") const [owner, repo] = repoPath.split("/") - return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref } + const name = typeof source === "object" && source.name ? source.name : repo + return { name, url: `https://github.com/${owner}/${repo}.git`, ref, subdir } } - if (source.startsWith("git+")) { - const raw = source.replace("git+", "") - const [url, ref] = raw.split("#") - const name = path.basename(url, ".git") - return { name, url, ref } + if (url.startsWith("git+")) { + const raw = url.replace("git+", "") + const [parsed, ref] = raw.split("#") + const name = + typeof source === "object" && source.name ? source.name : path.basename(parsed, ".git") + return { name, url: parsed, ref, subdir } } - if (source.startsWith("https://")) { - const [url, ref] = source.split("#") - const name = path.basename(url, ".git") - return { name, url, ref } + if (url.startsWith("https://")) { + const [parsed, ref] = url.split("#") + const name = + typeof source === "object" && source.name ? source.name : path.basename(parsed, ".git") + return { name, url: parsed, ref, subdir } } - throw new Error(`Cannot parse plugin source: ${source}`) + throw new Error(`Cannot parse plugin source: ${formatSource(source)}`) } export function getGitCommit(pluginDir) { @@ -195,6 +241,8 @@ export function getEnrichedPlugins() { name, displayName: manifest?.displayName ?? name, source: entry.source, + sourceDisplay: formatSource(entry.source), + subdir: getSourceSubdir(entry.source) ?? locked?.subdir ?? undefined, enabled: entry.enabled ?? true, options: entry.options ?? {}, order: entry.order ?? 50, diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 23b837377..5360562c7 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -15,12 +15,33 @@ import { PLUGINS_DIR, LOCKFILE_PATH, isLocalSource, + getSourceUrl, + formatSource, } from "./plugin-data.js" const INTERNAL_EXPORTS = new Set(["manifest", "default"]) const execAsync = promisify(execCb) +function cloneWithSubdir({ url, ref, subdir, pluginDir }) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quartz-plugin-")) + try { + if (ref) { + execSync(`git clone --depth 1 --branch ${ref} "${url}" "${tmpDir}"`, { stdio: "ignore" }) + } else { + execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, { stdio: "ignore" }) + } + 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 }) + return getGitCommit(tmpDir) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + function buildPlugin(pluginDir, name) { try { const skipBuild = !needsBuild(pluginDir) @@ -306,19 +327,34 @@ export async function handlePluginInstall() { if (fs.existsSync(pluginDir)) { try { - const currentCommit = getGitCommit(pluginDir) - if (currentCommit === entry.commit && !needsBuild(pluginDir)) { - console.log( - styleText("gray", ` ✓ ${name}@${entry.commit.slice(0, 7)} already installed`), - ) - installed++ - continue - } - if (currentCommit !== entry.commit) { - console.log(styleText("cyan", ` → ${name}: updating to ${entry.commit.slice(0, 7)}...`)) - const fetchRef = entry.ref ? ` ${entry.ref}` : "" - execSync(`git fetch --depth 1 origin${fetchRef}`, { cwd: pluginDir, stdio: "ignore" }) - execSync(`git reset --hard ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" }) + if (entry.subdir) { + if (!needsBuild(pluginDir)) { + console.log( + styleText( + "gray", + ` ✓ ${name}@${entry.commit.slice(0, 7)} already installed (subdir)`, + ), + ) + installed++ + continue + } + } 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) { + console.log( + styleText("cyan", ` → ${name}: updating to ${entry.commit.slice(0, 7)}...`), + ) + const fetchRef = entry.ref ? ` ${entry.ref}` : "" + execSync(`git fetch --depth 1 origin${fetchRef}`, { cwd: pluginDir, stdio: "ignore" }) + execSync(`git reset --hard ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" }) + } } pluginsToBuild.push({ name, pluginDir }) installed++ @@ -328,17 +364,23 @@ export async function handlePluginInstall() { } } else { try { - console.log(styleText("cyan", ` → ${name}: cloning...`)) - const branchArg = entry.ref ? ` --branch ${entry.ref}` : "" - execSync(`git clone --depth 1${branchArg} "${entry.resolved}" "${pluginDir}"`, { - stdio: "ignore", - }) - if (entry.commit !== "unknown") { - execSync(`git fetch --depth 1 origin ${entry.commit}`, { - cwd: pluginDir, + if (entry.subdir) { + console.log(styleText("cyan", ` → ${name}: cloning (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...`)) + 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" }) + if (entry.commit !== "unknown") { + execSync(`git fetch --depth 1 origin ${entry.commit}`, { + cwd: pluginDir, + stdio: "ignore", + }) + execSync(`git checkout ${entry.commit}`, { cwd: pluginDir, stdio: "ignore" }) + } } console.log(styleText("green", ` ✓ ${name}@${entry.commit.slice(0, 7)}`)) pluginsToBuild.push({ name, pluginDir }) @@ -391,7 +433,7 @@ export async function handlePluginAdd(sources) { for (const source of sources) { try { - const { name, url, ref, local } = parseGitSource(source) + const { name, url, ref, local, subdir } = parseGitSource(source) const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { @@ -400,8 +442,8 @@ export async function handlePluginAdd(sources) { } if (local) { - // Local path: create symlink instead of git clone - const resolvedPath = path.resolve(url) + 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 @@ -413,10 +455,25 @@ export async function handlePluginAdd(sources) { source, resolved: resolvedPath, commit: "local", + ...(subdir && { subdir }), installedAt: new Date().toISOString(), } addedPlugins.push({ name, pluginDir, source }) console.log(styleText("green", `✓ Added ${name} (local symlink)`)) + } else if (subdir) { + console.log(styleText("cyan", `→ Adding ${name} from ${url} (subdir: ${subdir})...`)) + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + const commit = cloneWithSubdir({ url, ref, subdir, pluginDir }) + lockfile.plugins[name] = { + source, + resolved: url, + commit, + ...(ref && { ref }), + subdir, + installedAt: new Date().toISOString(), + } + addedPlugins.push({ name, pluginDir, source }) + console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)} (subdir: ${subdir})`)) } else { console.log(styleText("cyan", `→ Adding ${name} from ${url}...`)) @@ -441,7 +498,7 @@ export async function handlePluginAdd(sources) { console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) } } catch (error) { - console.log(styleText("red", `✗ Failed to add ${source}: ${error}`)) + console.log(styleText("red", `✗ Failed to add ${formatSource(source)}: ${error}`)) } } @@ -525,7 +582,8 @@ export async function handlePluginRemove(names) { if (pluginsJson?.plugins) { pluginsJson.plugins = pluginsJson.plugins.filter( (plugin) => - !names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source), + !names.includes(extractPluginName(plugin.source)) && + !names.includes(formatSource(plugin.source)), ) writePluginsJson(pluginsJson) } @@ -542,7 +600,7 @@ export async function handlePluginEnable(names) { for (const name of names) { const entry = json.plugins.find( - (e) => extractPluginName(e.source) === name || e.source === name, + (e) => extractPluginName(e.source) === name || formatSource(e.source) === name, ) if (!entry) { console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.config.yaml`)) @@ -568,7 +626,7 @@ export async function handlePluginDisable(names) { for (const name of names) { const entry = json.plugins.find( - (e) => extractPluginName(e.source) === name || e.source === name, + (e) => extractPluginName(e.source) === name || formatSource(e.source) === name, ) if (!entry) { console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.config.yaml`)) @@ -592,7 +650,9 @@ export async function handlePluginConfig(name, options = {}) { return } - const entry = json.plugins.find((e) => extractPluginName(e.source) === name || e.source === name) + 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 @@ -617,7 +677,7 @@ export async function handlePluginConfig(name, options = {}) { console.log(styleText("green", `✓ Set ${name}.${key} = ${JSON.stringify(value)}`)) } else { console.log(styleText("bold", `Plugin: ${name}`)) - console.log(` Source: ${entry.source}`) + 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) { @@ -739,22 +799,47 @@ export async function handlePluginUpdate(names) { try { console.log(styleText("cyan", `→ Updating ${name}...`)) - const fetchRef = entry.ref || "" - const resetTarget = entry.ref ? `origin/${entry.ref}` : "origin/HEAD" - execSync(`git fetch --depth 1 origin${fetchRef ? " " + fetchRef : ""}`, { - cwd: pluginDir, - stdio: "ignore", - }) - execSync(`git reset --hard ${resetTarget}`, { cwd: pluginDir, stdio: "ignore" }) - const newCommit = getGitCommit(pluginDir) - if (newCommit !== entry.commit) { - entry.commit = newCommit - entry.installedAt = new Date().toISOString() - updatedPlugins.push({ name, pluginDir }) - console.log(styleText("green", `✓ Updated ${name} to ${newCommit.slice(0, 7)}`)) + 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 (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)} (subdir: ${entry.subdir})`, + ), + ) + } else { + console.log(styleText("gray", `✓ ${name} already up to date`)) + } } else { - console.log(styleText("gray", `✓ ${name} already up to date`)) + 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}`)) @@ -797,7 +882,7 @@ export async function handlePluginList() { const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink() const status = isLinked ? styleText("green", "✓") : styleText("red", "✗") console.log(` ${status} ${styleText("bold", name)}`) - console.log(` Source: ${entry.source}`) + console.log(` Source: ${formatSource(entry.source)}`) console.log(` Type: local symlink`) console.log(` Target: ${entry.resolved}`) console.log(` Installed: ${new Date(entry.installedAt).toLocaleDateString()}`) @@ -818,7 +903,7 @@ export async function handlePluginList() { : styleText("red", "✗") console.log(` ${status} ${styleText("bold", name)}`) - console.log(` Source: ${entry.source}`) + 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)`) @@ -878,14 +963,25 @@ export async function handlePluginRestore() { } try { - 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" }) + 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++ @@ -997,8 +1093,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { const name = extractPluginName(entry.source) const pluginDir = path.join(PLUGINS_DIR, name) if (lockfile.plugins[name] && fs.existsSync(pluginDir)) return false - // Only attempt sources that parseGitSource can handle (git URLs + local paths) - const src = entry.source + const src = getSourceUrl(entry.source) return ( src.startsWith("github:") || src.startsWith("git+") || @@ -1015,7 +1110,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { 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(` ${styleText("yellow", name)} — ${formatSource(entry.source)}`) } console.log() @@ -1031,7 +1126,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { for (const entry of missing) { try { - const { name, url, ref, local } = parseGitSource(entry.source) + const { name, url, ref, local, subdir } = parseGitSource(entry.source) const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { @@ -1041,6 +1136,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { source: entry.source, resolved: url, commit: "local", + ...(subdir && { subdir }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) @@ -1053,6 +1149,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { resolved: url, commit, ...(ref && { ref }), + ...(subdir && { subdir }), installedAt: new Date().toISOString(), } installed.push({ name, pluginDir }) @@ -1061,7 +1158,8 @@ export async function handlePluginResolve({ dryRun = false } = {}) { if (local) { // Local path: symlink - const resolvedPath = path.resolve(url) + 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++ @@ -1074,10 +1172,27 @@ export async function handlePluginResolve({ dryRun = false } = {}) { 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}...`)) @@ -1102,7 +1217,7 @@ export async function handlePluginResolve({ dryRun = false } = {}) { console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`)) } } catch (error) { - console.log(styleText("red", `✗ Failed to resolve ${entry.source}: ${error}`)) + console.log(styleText("red", `✗ Failed to resolve ${formatSource(entry.source)}: ${error}`)) failed++ } } diff --git a/quartz/cli/templates/obsidian.yaml b/quartz/cli/templates/obsidian.yaml index 7231bf3cd..fe7f9d2cf 100644 --- a/quartz/cli/templates/obsidian.yaml +++ b/quartz/cli/templates/obsidian.yaml @@ -246,7 +246,10 @@ plugins: position: beforeBody priority: 15 display: all - - source: github:saberzero1/quartz-themes + - source: + name: quartz-themes + repo: github:saberzero1/quartz-themes + subdir: plugin enabled: true options: theme: default diff --git a/quartz/cli/templates/ttrpg.yaml b/quartz/cli/templates/ttrpg.yaml index 4fab86b7e..c2007ab69 100644 --- a/quartz/cli/templates/ttrpg.yaml +++ b/quartz/cli/templates/ttrpg.yaml @@ -249,7 +249,10 @@ plugins: # TTRPG-specific plugins - source: github:Requiae/quartz-leaflet-map-plugin enabled: true - - source: github:saberzero1/quartz-themes + - source: + name: quartz-themes + repo: github:saberzero1/quartz-themes + subdir: plugin enabled: true options: theme: its-theme