From 13c2b5d909151a8ca61ad0e6bde0623a046be14b Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Fri, 22 May 2026 10:24:33 +0200 Subject: [PATCH 1/2] feat: install from lockfile --- README.md | 12 ++-- src/cli/index.ts | 34 +++++++++ src/cli/parse-args.ts | 4 ++ src/cli/types.ts | 1 + src/commands/sync.ts | 88 +++++++++++++++++++++-- src/types/sync.ts | 1 + tests/cli-parse.test.js | 17 +++++ tests/install.test.js | 154 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 tests/install.test.js diff --git a/README.md b/README.md index 9d1560a..895b072 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ npx docs-cache add github:owner/repo#main # Sync and lock npx docs-cache sync +npx docs-cache install npx docs-cache sync --frozen # Refresh tracked refs (write lock/materialized output) @@ -56,8 +57,9 @@ Use this flow to keep behavior predictable (similar to package manager manifest 1. Keep source intent in config (`ref: "main"`, `ref: "v1"`, or a commit SHA). 2. Run `npx docs-cache update ` (or `--all`) to refresh selected sources and lock data. -3. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data drifts. -4. Use `npx docs-cache pin ` only when you explicitly want to rewrite config refs to commit SHAs. +3. Use `npx docs-cache install` to restore cache/targets from `docs-lock.json` without rewriting the lock file. +4. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data drifts. +5. Use `npx docs-cache pin ` only when you explicitly want to rewrite config refs to commit SHAs. ## Configuration @@ -138,9 +140,9 @@ Use `postinstall` to ensure documentation is available locally immediately after ```json { - "scripts": { - "postinstall": "npx docs-cache sync --prune" - } + "scripts": { + "postinstall": "npx docs-cache install" + } } ``` diff --git a/src/cli/index.ts b/src/cli/index.ts index e5a0707..3be6d34 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ Commands: remove Remove sources from the config and targets pin Pin source refs to current commits update Refresh selected sources and lock data + install Install cache from lock data sync Synchronize cache with config status Show cache status clean Remove project cache @@ -267,6 +268,35 @@ const runStatus = async ( printStatus(status); }; +const runInstallCommand = async ( + parsed: Extract, +) => { + const options = parsed.options; + if (options.lockOnly) { + throw new Error("Install does not support --lock-only."); + } + const { printSyncPlan, runSync } = await import("#commands/sync"); + const sourceFilter = parsed.ids.length > 0 ? parsed.ids : undefined; + const plan = await runSync({ + configPath: options.config, + cacheDirOverride: options.cacheDir, + json: options.json, + lockOnly: false, + offline: options.offline, + failOnMiss: options.failOnMiss, + install: true, + sourceFilter, + timeoutMs: options.timeoutMs, + verbose: options.verbose, + concurrency: options.concurrency, + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + return; + } + printSyncPlan(plan); +}; + const runClean = async (parsed: Extract) => { const options = parsed.options; const { cleanCache } = await import("#commands/clean"); @@ -420,6 +450,9 @@ const runCommand = async (parsed: CliCommand) => { case "update": await runUpdate(parsed); return; + case "install": + await runInstallCommand(parsed); + return; case "status": await runStatus(parsed); return; @@ -475,6 +508,7 @@ export async function main(): Promise { parsed.command !== "remove" && parsed.command !== "pin" && parsed.command !== "update" && + parsed.command !== "install" && parsed.command !== "sync" && parsed.positionals.length > 0 ) { diff --git a/src/cli/parse-args.ts b/src/cli/parse-args.ts index c67992b..df03184 100644 --- a/src/cli/parse-args.ts +++ b/src/cli/parse-args.ts @@ -9,6 +9,7 @@ const COMMANDS = [ "remove", "pin", "update", + "install", "sync", "status", "clean", @@ -328,6 +329,8 @@ const buildParsedCommand = ( return { command: "pin", ids: positionals, options }; case "update": return { command: "update", ids: positionals, options }; + case "install": + return { command: "install", ids: positionals, options }; case "sync": return { command: "sync", ids: positionals, options }; case "status": @@ -378,6 +381,7 @@ export const parseArgs = (argv = process.argv): ParsedArgs => { cli.command("remove ", "Remove sources from the config and targets"); cli.command("pin [id...]", "Pin source refs to current commit"); cli.command("update [id...]", "Refresh selected sources and lock data"); + cli.command("install [id...]", "Install cache from lock data"); cli.command("sync [id...]", "Synchronize cache with config"); cli.command("status", "Show cache status"); cli.command("clean", "Remove project cache"); diff --git a/src/cli/types.ts b/src/cli/types.ts index 56b9954..9770e05 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -26,6 +26,7 @@ export type CliCommand = | { command: "remove"; ids: string[]; options: CliOptions } | { command: "pin"; ids: string[]; options: CliOptions } | { command: "update"; ids: string[]; options: CliOptions } + | { command: "install"; ids: string[]; options: CliOptions } | { command: "sync"; ids: string[]; options: CliOptions } | { command: "status"; options: CliOptions } | { command: "clean"; options: CliOptions } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index ab611ad..4ac3bad 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -156,6 +156,15 @@ export const getSyncPlan = async ( filteredSources.map(async (source) => { const lockEntry = lockData?.sources?.[source.id]; const rulesSha256 = computeRulesSha(source, defaults); + if (options.install) { + return buildInstallResult({ + source, + lockEntry, + defaults, + resolvedCacheDir, + rulesSha256, + }); + } if (options.offline) { return buildOfflineResult({ source, @@ -356,6 +365,32 @@ const buildOfflineResult = async (params: { }; }; +const buildInstallResult = async (params: { + source: DocsCacheResolvedSource; + lockEntry: DocsCacheLock["sources"][string] | undefined; + defaults: DocsCacheDefaults; + resolvedCacheDir: string; + rulesSha256: string; +}): Promise => { + const { source, lockEntry, defaults, resolvedCacheDir, rulesSha256 } = params; + const docsPresent = await hasDocs(resolvedCacheDir, source.id); + const resolvedCommit = lockEntry?.resolvedCommit ?? "missing"; + const base = buildSyncResultBase({ + source, + lockEntry, + defaults, + resolvedCommit, + rulesSha256, + }); + if (!lockEntry) { + return { ...base, status: "missing" }; + } + if (lockEntry.rulesSha256 !== rulesSha256) { + return { ...base, status: "changed" }; + } + return { ...base, status: docsPresent ? "up-to-date" : "changed" }; +}; + const buildOnlineResult = async (params: { source: DocsCacheResolvedSource; lockEntry: DocsCacheLock["sources"][string] | undefined; @@ -734,6 +769,38 @@ const reportVerifyFailures = ( } }; +const assertInstallLock = (plan: SyncPlan) => { + if (!plan.lockData) { + throw new Error( + "Install requires docs-lock.json. Run docs-cache sync first.", + ); + } + const missing = plan.sources.filter( + (source) => !plan.lockData?.sources[source.id], + ); + if (missing.length > 0) { + throw new Error( + `Install failed: lock is missing source(s): ${missing + .map((source) => source.id) + .join( + ", ", + )}. Run docs-cache update or docs-cache sync to refresh the lock.`, + ); + } + const changed = plan.results.filter( + (result) => result.lockRulesSha256 !== result.rulesSha256, + ); + if (changed.length > 0) { + throw new Error( + `Install failed: lock is out of date for source(s): ${changed + .map((result) => result.id) + .join( + ", ", + )}. Run docs-cache update or docs-cache sync to refresh the lock.`, + ); + } +}; + const finalizeSync = async (params: { plan: SyncPlan; previous: Awaited> | null; @@ -743,8 +810,15 @@ const finalizeSync = async (params: { warningCount: number; }) => { const { plan, previous, reporter, options, startTime, warningCount } = params; - const lock = await buildLock(plan, previous); - await writeLock(plan.lockPath, lock); + const lock = options.install ? previous : await buildLock(plan, previous); + if (!lock) { + throw new Error( + "Install requires docs-lock.json. Run docs-cache sync first.", + ); + } + if (!options.install) { + await writeLock(plan.lockPath, lock); + } const { totalBytes, totalFiles } = summarizePlan(plan); if (reporter) { const summary = `${symbols.info} ${formatBytes(totalBytes)} ยท ${totalFiles} files`; @@ -806,8 +880,8 @@ const createJobRunner = (params: { const fetch = await runFetch({ sourceId: source.id, - repo: source.repo, - ref: source.ref, + repo: options.install ? (lockEntry?.repo ?? source.repo) : source.repo, + ref: options.install ? (lockEntry?.ref ?? source.ref) : source.ref, resolvedCommit: result.resolvedCommit, cacheDir: plan.cacheDir, include: source.include ?? defaults.include, @@ -855,6 +929,9 @@ const createJobRunner = (params: { }; export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { + if (options.install && options.lockOnly) { + throw new Error("Install does not support lockOnly."); + } const startTime = process.hrtime.bigint(); let warningCount = 0; const plan = await getSyncPlan(options, deps); @@ -865,6 +942,9 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { !options.json && !isSilentMode() && process.stdout.isTTY && !isTestRunner; const reporter = useLiveOutput ? new TaskReporter() : null; const previous = plan.lockData; + if (options.install) { + assertInstallLock(plan); + } const requiredMissing = plan.results.filter((result) => { const source = plan.sources.find((entry) => entry.id === result.id); return result.status === "missing" && (source?.required ?? true); diff --git a/src/types/sync.ts b/src/types/sync.ts index 00beeb4..a806e14 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -6,6 +6,7 @@ export type SyncOptions = { offline: boolean; failOnMiss: boolean; frozen?: boolean; + install?: boolean; verbose?: boolean; concurrency?: number; sourceFilter?: string[]; diff --git a/tests/cli-parse.test.js b/tests/cli-parse.test.js index 3343c4d..c93b603 100644 --- a/tests/cli-parse.test.js +++ b/tests/cli-parse.test.js @@ -232,6 +232,23 @@ test("parseArgs handles sync source filters and frozen", async (t) => { assert.equal(result.options.frozen, true); }); +test("parseArgs accepts install source filters", async (t) => { + const module = await loadCliModule(); + if (!module) { + t.skip("CLI not built yet"); + return; + } + const result = module.parseArgs([ + "node", + "docs-cache", + "install", + "source-a", + ]); + + assert.equal(result.command, "install"); + assert.deepEqual(result.positionals, ["source-a"]); +}); + test("parseArgs handles equals-form scoped flag on pin", async (t) => { const module = await loadCliModule(); if (!module) { diff --git a/tests/install.test.js b/tests/install.test.js new file mode 100644 index 0000000..c905b6b --- /dev/null +++ b/tests/install.test.js @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { DEFAULT_LOCK_FILENAME, runSync } from "../dist/api.mjs"; + +const exists = async (target) => { + try { + await access(target); + return true; + } catch { + return false; + } +}; + +const writeConfig = async (tmpRoot, extra = {}) => { + const configPath = path.join(tmpRoot, "docs.config.json"); + await writeFile( + configPath, + `${JSON.stringify( + { + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + ref: "main", + ...extra, + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ); + return configPath; +}; + +test("install materializes from lock without rewriting it", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-install-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = await writeConfig(tmpRoot, { targetDir: "./target" }); + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + + await runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: true, + offline: false, + failOnMiss: false, + }, + { + resolveRemoteCommit: async () => ({ + repo: "https://example.com/repo.git", + ref: "main", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + }, + ); + + const lockPath = path.join(tmpRoot, DEFAULT_LOCK_FILENAME); + const lockBefore = await readFile(lockPath, "utf8"); + let fetchCommit = null; + let materialized = false; + + await runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + install: true, + }, + { + resolveRemoteCommit: async () => { + throw new Error("install should not resolve remote refs"); + }, + fetchSource: async ({ resolvedCommit }) => { + fetchCommit = resolvedCommit; + return { repoDir, cleanup: async () => undefined }; + }, + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + materialized = true; + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + return { bytes: 5, fileCount: 1 }; + }, + }, + ); + + assert.equal(fetchCommit, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert.equal(materialized, true); + assert.equal(await exists(path.join(cacheDir, "local", "README.md")), true); + assert.equal(await exists(path.join(tmpRoot, "target", "README.md")), true); + assert.equal(await readFile(lockPath, "utf8"), lockBefore); +}); + +test("install fails when lock rules do not match config", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-install-rules-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const configPath = await writeConfig(tmpRoot, { include: ["docs/**/*.md"] }); + await writeFile( + path.join(tmpRoot, DEFAULT_LOCK_FILENAME), + `${JSON.stringify({ + version: 1, + toolVersion: "0.1.0", + sources: { + local: { + repo: "https://example.com/repo.git", + ref: "main", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + bytes: 0, + fileCount: 0, + manifestSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + rulesSha256: "stale", + }, + }, + })}\n`, + "utf8", + ); + + await assert.rejects( + () => + runSync({ + configPath, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + install: true, + }), + /Install failed: lock is out of date/i, + ); +}); From d021045a3b0084d882917db311b36335db16b861 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Fri, 22 May 2026 12:55:58 +0200 Subject: [PATCH 2/2] fix: address install review feedback --- README.md | 6 +++--- src/commands/sync.ts | 17 +++++++++++++++ tests/install.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 895b072..3fe4a8e 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,9 @@ Use `postinstall` to ensure documentation is available locally immediately after ```json { - "scripts": { - "postinstall": "npx docs-cache install" - } + "scripts": { + "postinstall": "npx docs-cache install" + } } ``` diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 4ac3bad..0364ae4 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -790,6 +790,23 @@ const assertInstallLock = (plan: SyncPlan) => { const changed = plan.results.filter( (result) => result.lockRulesSha256 !== result.rulesSha256, ); + const driftedSources = plan.sources.filter((source) => { + const lockEntry = plan.lockData?.sources[source.id]; + return lockEntry?.repo !== source.repo || lockEntry.ref !== source.ref; + }); + changed.push( + ...driftedSources + .filter((source) => !changed.some((result) => result.id === source.id)) + .map((source) => { + const result = plan.results.find((entry) => entry.id === source.id); + if (!result) { + throw new Error( + `Install failed: source ${source.id} is missing from plan.`, + ); + } + return result; + }), + ); if (changed.length > 0) { throw new Error( `Install failed: lock is out of date for source(s): ${changed diff --git a/tests/install.test.js b/tests/install.test.js index c905b6b..56e8060 100644 --- a/tests/install.test.js +++ b/tests/install.test.js @@ -100,7 +100,11 @@ test("install materializes from lock without rewriting it", async () => { `${JSON.stringify({ path: "README.md", size: 5 })}\n`, ); await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); - return { bytes: 5, fileCount: 1 }; + return { + bytes: 5, + fileCount: 1, + manifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }; }, }, ); @@ -152,3 +156,47 @@ test("install fails when lock rules do not match config", async () => { /Install failed: lock is out of date/i, ); }); + +test("install fails when lock repo or ref does not match config", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-install-drift-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const configPath = await writeConfig(tmpRoot); + + await runSync( + { + configPath, + json: false, + lockOnly: true, + offline: false, + failOnMiss: false, + }, + { + resolveRemoteCommit: async () => ({ + repo: "https://example.com/repo.git", + ref: "main", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + }, + ); + + await writeConfig(tmpRoot, { + repo: "https://example.com/other.git", + ref: "v1", + }); + + await assert.rejects( + () => + runSync({ + configPath, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + install: true, + }), + /Install failed: lock is out of date/i, + ); +});