Skip to content
Merged
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 thread
coderabbitai[bot] marked this conversation as resolved.
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