Skip to content

Commit f13465a

Browse files
authored
feat: install from lockfile (#48)
* feat: install from lockfile * fix: address install review feedback
1 parent 2df07b4 commit f13465a

8 files changed

Lines changed: 365 additions & 7 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ npx docs-cache add github:owner/repo#main
3232

3333
# Sync and lock
3434
npx docs-cache sync
35+
npx docs-cache install
3536
npx docs-cache sync --frozen
3637

3738
# Refresh tracked refs (write lock/materialized output)
@@ -56,8 +57,9 @@ Use this flow to keep behavior predictable (similar to package manager manifest
5657

5758
1. Keep source intent in config (`ref: "main"`, `ref: "v1"`, or a commit SHA).
5859
2. Run `npx docs-cache update <id...>` (or `--all`) to refresh selected sources and lock data.
59-
3. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data drifts.
60-
4. Use `npx docs-cache pin <id...>` only when you explicitly want to rewrite config refs to commit SHAs.
60+
3. Use `npx docs-cache install` to restore cache/targets from `docs-lock.json` without rewriting the lock file.
61+
4. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data drifts.
62+
5. Use `npx docs-cache pin <id...>` only when you explicitly want to rewrite config refs to commit SHAs.
6163

6264
## Configuration
6365

@@ -139,7 +141,7 @@ Use `postinstall` to ensure documentation is available locally immediately after
139141
```json
140142
{
141143
"scripts": {
142-
"postinstall": "npx docs-cache sync --prune"
144+
"postinstall": "npx docs-cache install"
143145
}
144146
}
145147
```

src/cli/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Commands:
1616
remove Remove sources from the config and targets
1717
pin Pin source refs to current commits
1818
update Refresh selected sources and lock data
19+
install Install cache from lock data
1920
sync Synchronize cache with config
2021
status Show cache status
2122
clean Remove project cache
@@ -267,6 +268,35 @@ const runStatus = async (
267268
printStatus(status);
268269
};
269270

271+
const runInstallCommand = async (
272+
parsed: Extract<CliCommand, { command: "install" }>,
273+
) => {
274+
const options = parsed.options;
275+
if (options.lockOnly) {
276+
throw new Error("Install does not support --lock-only.");
277+
}
278+
const { printSyncPlan, runSync } = await import("#commands/sync");
279+
const sourceFilter = parsed.ids.length > 0 ? parsed.ids : undefined;
280+
const plan = await runSync({
281+
configPath: options.config,
282+
cacheDirOverride: options.cacheDir,
283+
json: options.json,
284+
lockOnly: false,
285+
offline: options.offline,
286+
failOnMiss: options.failOnMiss,
287+
install: true,
288+
sourceFilter,
289+
timeoutMs: options.timeoutMs,
290+
verbose: options.verbose,
291+
concurrency: options.concurrency,
292+
});
293+
if (options.json) {
294+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
295+
return;
296+
}
297+
printSyncPlan(plan);
298+
};
299+
270300
const runClean = async (parsed: Extract<CliCommand, { command: "clean" }>) => {
271301
const options = parsed.options;
272302
const { cleanCache } = await import("#commands/clean");
@@ -420,6 +450,9 @@ const runCommand = async (parsed: CliCommand) => {
420450
case "update":
421451
await runUpdate(parsed);
422452
return;
453+
case "install":
454+
await runInstallCommand(parsed);
455+
return;
423456
case "status":
424457
await runStatus(parsed);
425458
return;
@@ -475,6 +508,7 @@ export async function main(): Promise<void> {
475508
parsed.command !== "remove" &&
476509
parsed.command !== "pin" &&
477510
parsed.command !== "update" &&
511+
parsed.command !== "install" &&
478512
parsed.command !== "sync" &&
479513
parsed.positionals.length > 0
480514
) {

src/cli/parse-args.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const COMMANDS = [
99
"remove",
1010
"pin",
1111
"update",
12+
"install",
1213
"sync",
1314
"status",
1415
"clean",
@@ -328,6 +329,8 @@ const buildParsedCommand = (
328329
return { command: "pin", ids: positionals, options };
329330
case "update":
330331
return { command: "update", ids: positionals, options };
332+
case "install":
333+
return { command: "install", ids: positionals, options };
331334
case "sync":
332335
return { command: "sync", ids: positionals, options };
333336
case "status":
@@ -378,6 +381,7 @@ export const parseArgs = (argv = process.argv): ParsedArgs => {
378381
cli.command("remove <id...>", "Remove sources from the config and targets");
379382
cli.command("pin [id...]", "Pin source refs to current commit");
380383
cli.command("update [id...]", "Refresh selected sources and lock data");
384+
cli.command("install [id...]", "Install cache from lock data");
381385
cli.command("sync [id...]", "Synchronize cache with config");
382386
cli.command("status", "Show cache status");
383387
cli.command("clean", "Remove project cache");

src/cli/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type CliCommand =
2626
| { command: "remove"; ids: string[]; options: CliOptions }
2727
| { command: "pin"; ids: string[]; options: CliOptions }
2828
| { command: "update"; ids: string[]; options: CliOptions }
29+
| { command: "install"; ids: string[]; options: CliOptions }
2930
| { command: "sync"; ids: string[]; options: CliOptions }
3031
| { command: "status"; options: CliOptions }
3132
| { command: "clean"; options: CliOptions }

src/commands/sync.ts

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ export const getSyncPlan = async (
156156
filteredSources.map(async (source) => {
157157
const lockEntry = lockData?.sources?.[source.id];
158158
const rulesSha256 = computeRulesSha(source, defaults);
159+
if (options.install) {
160+
return buildInstallResult({
161+
source,
162+
lockEntry,
163+
defaults,
164+
resolvedCacheDir,
165+
rulesSha256,
166+
});
167+
}
159168
if (options.offline) {
160169
return buildOfflineResult({
161170
source,
@@ -356,6 +365,32 @@ const buildOfflineResult = async (params: {
356365
};
357366
};
358367

368+
const buildInstallResult = async (params: {
369+
source: DocsCacheResolvedSource;
370+
lockEntry: DocsCacheLock["sources"][string] | undefined;
371+
defaults: DocsCacheDefaults;
372+
resolvedCacheDir: string;
373+
rulesSha256: string;
374+
}): Promise<SyncResult> => {
375+
const { source, lockEntry, defaults, resolvedCacheDir, rulesSha256 } = params;
376+
const docsPresent = await hasDocs(resolvedCacheDir, source.id);
377+
const resolvedCommit = lockEntry?.resolvedCommit ?? "missing";
378+
const base = buildSyncResultBase({
379+
source,
380+
lockEntry,
381+
defaults,
382+
resolvedCommit,
383+
rulesSha256,
384+
});
385+
if (!lockEntry) {
386+
return { ...base, status: "missing" };
387+
}
388+
if (lockEntry.rulesSha256 !== rulesSha256) {
389+
return { ...base, status: "changed" };
390+
}
391+
return { ...base, status: docsPresent ? "up-to-date" : "changed" };
392+
};
393+
359394
const buildOnlineResult = async (params: {
360395
source: DocsCacheResolvedSource;
361396
lockEntry: DocsCacheLock["sources"][string] | undefined;
@@ -734,6 +769,55 @@ const reportVerifyFailures = (
734769
}
735770
};
736771

772+
const assertInstallLock = (plan: SyncPlan) => {
773+
if (!plan.lockData) {
774+
throw new Error(
775+
"Install requires docs-lock.json. Run docs-cache sync first.",
776+
);
777+
}
778+
const missing = plan.sources.filter(
779+
(source) => !plan.lockData?.sources[source.id],
780+
);
781+
if (missing.length > 0) {
782+
throw new Error(
783+
`Install failed: lock is missing source(s): ${missing
784+
.map((source) => source.id)
785+
.join(
786+
", ",
787+
)}. Run docs-cache update or docs-cache sync to refresh the lock.`,
788+
);
789+
}
790+
const changed = plan.results.filter(
791+
(result) => result.lockRulesSha256 !== result.rulesSha256,
792+
);
793+
const driftedSources = plan.sources.filter((source) => {
794+
const lockEntry = plan.lockData?.sources[source.id];
795+
return lockEntry?.repo !== source.repo || lockEntry.ref !== source.ref;
796+
});
797+
changed.push(
798+
...driftedSources
799+
.filter((source) => !changed.some((result) => result.id === source.id))
800+
.map((source) => {
801+
const result = plan.results.find((entry) => entry.id === source.id);
802+
if (!result) {
803+
throw new Error(
804+
`Install failed: source ${source.id} is missing from plan.`,
805+
);
806+
}
807+
return result;
808+
}),
809+
);
810+
if (changed.length > 0) {
811+
throw new Error(
812+
`Install failed: lock is out of date for source(s): ${changed
813+
.map((result) => result.id)
814+
.join(
815+
", ",
816+
)}. Run docs-cache update or docs-cache sync to refresh the lock.`,
817+
);
818+
}
819+
};
820+
737821
const finalizeSync = async (params: {
738822
plan: SyncPlan;
739823
previous: Awaited<ReturnType<typeof readLock>> | null;
@@ -743,8 +827,15 @@ const finalizeSync = async (params: {
743827
warningCount: number;
744828
}) => {
745829
const { plan, previous, reporter, options, startTime, warningCount } = params;
746-
const lock = await buildLock(plan, previous);
747-
await writeLock(plan.lockPath, lock);
830+
const lock = options.install ? previous : await buildLock(plan, previous);
831+
if (!lock) {
832+
throw new Error(
833+
"Install requires docs-lock.json. Run docs-cache sync first.",
834+
);
835+
}
836+
if (!options.install) {
837+
await writeLock(plan.lockPath, lock);
838+
}
748839
const { totalBytes, totalFiles } = summarizePlan(plan);
749840
if (reporter) {
750841
const summary = `${symbols.info} ${formatBytes(totalBytes)} · ${totalFiles} files`;
@@ -806,8 +897,8 @@ const createJobRunner = (params: {
806897

807898
const fetch = await runFetch({
808899
sourceId: source.id,
809-
repo: source.repo,
810-
ref: source.ref,
900+
repo: options.install ? (lockEntry?.repo ?? source.repo) : source.repo,
901+
ref: options.install ? (lockEntry?.ref ?? source.ref) : source.ref,
811902
resolvedCommit: result.resolvedCommit,
812903
cacheDir: plan.cacheDir,
813904
include: source.include ?? defaults.include,
@@ -855,6 +946,9 @@ const createJobRunner = (params: {
855946
};
856947

857948
export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => {
949+
if (options.install && options.lockOnly) {
950+
throw new Error("Install does not support lockOnly.");
951+
}
858952
const startTime = process.hrtime.bigint();
859953
let warningCount = 0;
860954
const plan = await getSyncPlan(options, deps);
@@ -865,6 +959,9 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => {
865959
!options.json && !isSilentMode() && process.stdout.isTTY && !isTestRunner;
866960
const reporter = useLiveOutput ? new TaskReporter() : null;
867961
const previous = plan.lockData;
962+
if (options.install) {
963+
assertInstallLock(plan);
964+
}
868965
const requiredMissing = plan.results.filter((result) => {
869966
const source = plan.sources.find((entry) => entry.id === result.id);
870967
return result.status === "missing" && (source?.required ?? true);

src/types/sync.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type SyncOptions = {
66
offline: boolean;
77
failOnMiss: boolean;
88
frozen?: boolean;
9+
install?: boolean;
910
verbose?: boolean;
1011
concurrency?: number;
1112
sourceFilter?: string[];

tests/cli-parse.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,23 @@ test("parseArgs handles sync source filters and frozen", async (t) => {
232232
assert.equal(result.options.frozen, true);
233233
});
234234

235+
test("parseArgs accepts install source filters", async (t) => {
236+
const module = await loadCliModule();
237+
if (!module) {
238+
t.skip("CLI not built yet");
239+
return;
240+
}
241+
const result = module.parseArgs([
242+
"node",
243+
"docs-cache",
244+
"install",
245+
"source-a",
246+
]);
247+
248+
assert.equal(result.command, "install");
249+
assert.deepEqual(result.positionals, ["source-a"]);
250+
});
251+
235252
test("parseArgs handles equals-form scoped flag on pin", async (t) => {
236253
const module = await loadCliModule();
237254
if (!module) {

0 commit comments

Comments
 (0)