Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <id...>` (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 <id...>` 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 <id...>` only when you explicitly want to rewrite config refs to commit SHAs.

## Configuration

Expand Down Expand Up @@ -139,7 +141,7 @@ Use `postinstall` to ensure documentation is available locally immediately after
```json
{
"scripts": {
"postinstall": "npx docs-cache sync --prune"
"postinstall": "npx docs-cache install"
}
}
```
Expand Down
34 changes: 34 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,6 +268,35 @@ const runStatus = async (
printStatus(status);
};

const runInstallCommand = async (
parsed: Extract<CliCommand, { command: "install" }>,
) => {
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<CliCommand, { command: "clean" }>) => {
const options = parsed.options;
const { cleanCache } = await import("#commands/clean");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -475,6 +508,7 @@ export async function main(): Promise<void> {
parsed.command !== "remove" &&
parsed.command !== "pin" &&
parsed.command !== "update" &&
parsed.command !== "install" &&
parsed.command !== "sync" &&
parsed.positionals.length > 0
) {
Expand Down
4 changes: 4 additions & 0 deletions src/cli/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const COMMANDS = [
"remove",
"pin",
"update",
"install",
"sync",
"status",
"clean",
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -378,6 +381,7 @@ export const parseArgs = (argv = process.argv): ParsedArgs => {
cli.command("remove <id...>", "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");
Expand Down
1 change: 1 addition & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
105 changes: 101 additions & 4 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SyncResult> => {
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;
Expand Down Expand Up @@ -734,6 +769,55 @@ 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,
);
const driftedSources = plan.sources.filter((source) => {
const lockEntry = plan.lockData?.sources[source.id];
return lockEntry?.repo !== source.repo || lockEntry.ref !== source.ref;
});
Comment on lines +793 to +796
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compare drift against the effective ref, not the raw source field.

Line 795 uses source.ref directly, but this file falls back to plan.defaults.ref elsewhere. If a source inherits the default ref, this will report a matching lock as drifted and make docs-cache install fail on a valid lock.

Suggested fix
 const driftedSources = plan.sources.filter((source) => {
 	const lockEntry = plan.lockData?.sources[source.id];
-	return lockEntry?.repo !== source.repo || lockEntry.ref !== source.ref;
+	if (!lockEntry) {
+		return false;
+	}
+	const expectedRef = source.ref ?? plan.defaults.ref;
+	return lockEntry.repo !== source.repo || lockEntry.ref !== expectedRef;
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/sync.ts` around lines 793 - 796, The drift detection in
driftedSources uses source.ref directly which misses the fallback to
plan.defaults.ref used elsewhere; update the filter in driftedSources to compute
the effective ref (e.g., effectiveRef = source.ref ?? plan.defaults?.ref or
source.ref || plan.defaults?.ref) and compare lockEntry.ref against that
effectiveRef (keep the existing repo comparison with source.repo), so inherited
default refs are treated as matching the lock.

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
.map((result) => result.id)
.join(
", ",
)}. Run docs-cache update or docs-cache sync to refresh the lock.`,
Comment thread
fbosch marked this conversation as resolved.
);
}
};

const finalizeSync = async (params: {
plan: SyncPlan;
previous: Awaited<ReturnType<typeof readLock>> | null;
Expand All @@ -743,8 +827,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`;
Expand Down Expand Up @@ -806,8 +897,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,
Expand Down Expand Up @@ -855,6 +946,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);
Expand All @@ -865,6 +959,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);
Expand Down
1 change: 1 addition & 0 deletions src/types/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type SyncOptions = {
offline: boolean;
failOnMiss: boolean;
frozen?: boolean;
install?: boolean;
verbose?: boolean;
concurrency?: number;
sourceFilter?: string[];
Expand Down
17 changes: 17 additions & 0 deletions tests/cli-parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading