diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3399c31..aebc814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,11 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/api.ts b/src/api.ts index d87a397..bae2317 100644 --- a/src/api.ts +++ b/src/api.ts @@ -8,4 +8,5 @@ export { pruneCache } from "./prune"; export { removeSources } from "./remove"; export { resolveRepoInput } from "./resolve-repo"; export { printSyncPlan, runSync } from "./sync"; +export { applyTargetDir } from "./targets"; export { verifyCache } from "./verify"; diff --git a/src/materialize.ts b/src/materialize.ts index dc375c7..533aadc 100644 --- a/src/materialize.ts +++ b/src/materialize.ts @@ -101,6 +101,29 @@ export const materializeSource = async (params: MaterializeParams) => { const tempDir = await mkdtemp( path.join(params.cacheDir, `.tmp-${params.sourceId}-`), ); + let manifestStreamRef: ReturnType | null = null; + const closeManifestStream = async () => { + const stream = manifestStreamRef; + if (!stream || stream.closed || stream.destroyed) { + return; + } + await new Promise((resolve) => { + const cleanup = () => { + stream.off("close", onClose); + stream.off("error", onError); + resolve(); + }; + const onClose = () => cleanup(); + const onError = () => cleanup(); + stream.once("close", onClose); + stream.once("error", onError); + try { + stream.end(); + } catch { + cleanup(); + } + }); + }; try { const files = await fg(params.include, { @@ -132,6 +155,7 @@ export const materializeSource = async (params: MaterializeParams) => { const manifestStream = createWriteStream(manifestPath, { encoding: "utf8", }); + manifestStreamRef = manifestStream; const manifestHash = createHash("sha256"); const writeManifestLine = async (line: string) => { return new Promise((resolve, reject) => { @@ -267,6 +291,11 @@ export const materializeSource = async (params: MaterializeParams) => { manifestSha256, }; } catch (error) { + try { + await closeManifestStream(); + } catch { + // Ignore cleanup errors to preserve root cause. + } await rm(tempDir, { recursive: true, force: true }); throw error; } diff --git a/src/sync.ts b/src/sync.ts index ee040df..0947e74 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -268,6 +268,7 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { sourceDir: path.join(plan.cacheDir, source.id), targetDir: resolvedTarget, mode: source.targetMode ?? defaults.targetMode, + explicitTargetMode: source.targetMode !== undefined, }); }), ); @@ -352,6 +353,7 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { sourceDir: path.join(plan.cacheDir, source.id), targetDir: resolvedTarget, mode: source.targetMode ?? defaults.targetMode, + explicitTargetMode: source.targetMode !== undefined, }); } result.bytes = stats.bytes; diff --git a/src/targets.ts b/src/targets.ts index 62687d0..30ffaa9 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -1,28 +1,61 @@ import { cp, mkdir, rm, symlink } from "node:fs/promises"; import path from "node:path"; +type TargetDeps = { + cp: typeof cp; + mkdir: typeof mkdir; + rm: typeof rm; + symlink: typeof symlink; + stderr: NodeJS.WritableStream; +}; + type TargetParams = { sourceDir: string; targetDir: string; mode?: "symlink" | "copy"; + explicitTargetMode?: boolean; + deps?: TargetDeps; }; -const removeTarget = async (targetDir: string) => { - await rm(targetDir, { recursive: true, force: true }); +const removeTarget = async (targetDir: string, deps: TargetDeps) => { + await deps.rm(targetDir, { recursive: true, force: true }); }; export const applyTargetDir = async (params: TargetParams) => { + const deps = params.deps ?? { + cp, + mkdir, + rm, + symlink, + stderr: process.stderr, + }; const parentDir = path.dirname(params.targetDir); - await mkdir(parentDir, { recursive: true }); - await removeTarget(params.targetDir); + await deps.mkdir(parentDir, { recursive: true }); + await removeTarget(params.targetDir, deps); const defaultMode = process.platform === "win32" ? "copy" : "symlink"; const mode = params.mode ?? defaultMode; if (mode === "copy") { - await cp(params.sourceDir, params.targetDir, { recursive: true }); + await deps.cp(params.sourceDir, params.targetDir, { recursive: true }); return; } const type = process.platform === "win32" ? "junction" : "dir"; - await symlink(params.sourceDir, params.targetDir, type); + try { + await deps.symlink(params.sourceDir, params.targetDir, type); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const fallbackCodes = new Set(["EPERM", "EACCES", "ENOTSUP", "EINVAL"]); + if (code && fallbackCodes.has(code)) { + if (params.explicitTargetMode) { + const message = error instanceof Error ? error.message : String(error); + deps.stderr.write( + `Warning: Failed to create symlink at ${params.targetDir}. Falling back to copy. ${message}\n`, + ); + } + await deps.cp(params.sourceDir, params.targetDir, { recursive: true }); + return; + } + throw error; + } }; diff --git a/tests/edge-cases-validation.test.js b/tests/edge-cases-validation.test.js index f2ff1d1..d2236f0 100644 --- a/tests/edge-cases-validation.test.js +++ b/tests/edge-cases-validation.test.js @@ -114,6 +114,14 @@ test("targetDir with Windows-style path is allowed", async () => { ], }); + if (process.platform === "win32") { + await assert.rejects( + () => loadConfig(configPath), + /targetDir.*escapes project directory/i, + ); + return; + } + const { sources } = await loadConfig(configPath); assert.equal(sources[0].targetDir, "C:\\Users\\test\\docs"); }); diff --git a/tests/sync-targets.test.js b/tests/sync-targets.test.js index 1ab1c90..d905525 100644 --- a/tests/sync-targets.test.js +++ b/tests/sync-targets.test.js @@ -72,6 +72,10 @@ test("sync applies targetDir with copy mode", async () => { }); test("sync applies targetDir with symlink mode", async () => { + if (process.platform === "win32") { + return; + } + const tmpRoot = path.join( tmpdir(), `docs-cache-target-link-${Date.now().toString(36)}`, diff --git a/tests/targets.test.js b/tests/targets.test.js new file mode 100644 index 0000000..597bc7e --- /dev/null +++ b/tests/targets.test.js @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { applyTargetDir } from "../dist/api.mjs"; + +test("applyTargetDir warns and falls back to copy when symlink fails", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-target-fallback-${Date.now().toString(36)}`, + ); + const sourceDir = path.join(tmpRoot, "source"); + const targetDir = path.join(tmpRoot, "target"); + + await mkdir(sourceDir, { recursive: true }); + await writeFile(path.join(sourceDir, "README.md"), "hello", "utf8"); + + let stderr = ""; + await applyTargetDir({ + sourceDir, + targetDir, + mode: "symlink", + explicitTargetMode: true, + deps: { + cp, + mkdir, + rm, + symlink: async () => { + const error = new Error("symlink blocked"); + error.code = "EPERM"; + throw error; + }, + stderr: { + write: (chunk) => { + stderr += String(chunk); + return true; + }, + }, + }, + }); + + const data = await readFile(path.join(targetDir, "README.md"), "utf8"); + assert.equal(data, "hello"); + assert.match(stderr, /Warning: Failed to create symlink/i); +});