diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 084014e..3bf762f 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -22,6 +22,7 @@ import { gitPull, popContentFolder, stashContentFolder, + symlinkOrCopy, } from "./helpers.js" import { handlePluginRestore, @@ -210,7 +211,7 @@ export async function handleCreate(argv) { preserveTimestamps: true, }) } else if (setupStrategy === "symlink") { - await fs.promises.symlink(originalFolder, contentFolder, "dir") + await symlinkOrCopy(originalFolder, contentFolder) } } else if (setupStrategy === "new") { await fs.promises.writeFile( diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js index 18c160b..7339e24 100644 --- a/quartz/cli/helpers.js +++ b/quartz/cli/helpers.js @@ -3,6 +3,7 @@ import { styleText } from "util" import { contentCacheFolder } from "./constants.js" import { spawnSync } from "child_process" import fs from "fs" +import path from "path" export function escapePath(fp) { return fp @@ -52,3 +53,57 @@ export async function popContentFolder(contentFolder) { }) await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) } + +/** + * Create a directory symlink with Windows fallback. + * + * On Windows, creating symlinks requires Developer Mode or admin privileges. + * When that fails (EPERM), we try a junction first (no elevation needed), + * then fall back to a recursive copy as a last resort. + * + * @param {string} target Symlink target (may be relative) + * @param {string} linkPath Path where the link is created + */ +export function symlinkOrCopySync(target, linkPath) { + try { + fs.symlinkSync(target, linkPath, "dir") + } catch (err) { + if (err.code === "EEXIST") return + if (err.code === "EPERM" && process.platform === "win32") { + try { + fs.symlinkSync(target, linkPath, "junction") + return + } catch { + const resolvedTarget = path.resolve(path.dirname(linkPath), target) + fs.cpSync(resolvedTarget, linkPath, { recursive: true }) + return + } + } + throw err + } +} + +/** + * Async version of {@link symlinkOrCopySync}. + * + * @param {string} target Symlink target (may be relative) + * @param {string} linkPath Path where the link is created + */ +export async function symlinkOrCopy(target, linkPath) { + try { + await fs.promises.symlink(target, linkPath, "dir") + } catch (err) { + if (err.code === "EEXIST") return + if (err.code === "EPERM" && process.platform === "win32") { + try { + await fs.promises.symlink(target, linkPath, "junction") + return + } catch { + const resolvedTarget = path.resolve(path.dirname(linkPath), target) + await fs.promises.cp(resolvedTarget, linkPath, { recursive: true }) + return + } + } + throw err + } +} diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 6ff7360..3a2278d 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -20,6 +20,7 @@ import { resolveLockfileName, getNameOverrides, } from "./plugin-data.js" +import { symlinkOrCopySync } from "./helpers.js" const INTERNAL_EXPORTS = new Set(["manifest", "default"]) @@ -128,12 +129,7 @@ function needsBuild(pluginDir) { * share a single copy of packages like unified, vfile, rehype-raw, etc. */ function trySymlink(target, linkPath) { - try { - fs.symlinkSync(target, linkPath, "dir") - } catch (err) { - if (err.code === "EEXIST") return - throw err - } + symlinkOrCopySync(target, linkPath) } function linkPeerPlugins(pluginDir) { @@ -621,7 +617,7 @@ export async function handlePluginInstallUnified({ } console.log(styleText("cyan", `→ Linking ${name} from ${resolvedPath}...`)) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - fs.symlinkSync(resolvedPath, pluginDir, "dir") + symlinkOrCopySync(resolvedPath, pluginDir) lockfile.plugins[name] = { source: entry.source, resolved: resolvedPath, @@ -810,7 +806,7 @@ export async function handlePluginInstallUnified({ continue } fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - fs.symlinkSync(entry.resolved, pluginDir, "dir") + symlinkOrCopySync(entry.resolved, pluginDir) console.log(styleText("green", `✓ ${name} restored (local symlink)`)) restoredPlugins.push({ name, pluginDir }) installed++ @@ -1045,7 +1041,7 @@ export async function handlePluginInstallUnified({ continue } fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - fs.symlinkSync(entry.resolved, pluginDir, "dir") + symlinkOrCopySync(entry.resolved, pluginDir) console.log(styleText("green", ` ✓ ${name} (local) linked`)) pluginsToBuild.push({ name, pluginDir }) installed++ @@ -1222,7 +1218,7 @@ export async function handlePluginAdd( } console.log(styleText("cyan", `→ Adding ${name} from local path ${resolvedPath}...`)) fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) - fs.symlinkSync(resolvedPath, pluginDir, "dir") + symlinkOrCopySync(resolvedPath, pluginDir) lockfile.plugins[name] = { source, resolved: resolvedPath,