@sous-io/sous 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +408 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +73 -9
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +619 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +413 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/bin/xcv +0 -5
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The machine-wide recipe store: one immutable directory per recipe version,
|
|
3
|
+
* verified against its content hash.
|
|
4
|
+
*
|
|
5
|
+
* Layout (decision 11), rooted at `$SOUS_HOME/cache`. The first part is the
|
|
6
|
+
* repository's CANONICAL IDENTITY (`github.com/sous-io/sous-recipes`), which is
|
|
7
|
+
* several directories deep, because the store is shared by every project on the
|
|
8
|
+
* machine and a project's short name for a repository is its own private label:
|
|
9
|
+
*
|
|
10
|
+
* <root>/<repository identity>/<namespace>/<name>/<version>/
|
|
11
|
+
* .sous.entry.json the marker describing this entry
|
|
12
|
+
* ... the recipe's files, exactly as fetched
|
|
13
|
+
*
|
|
14
|
+
* The store is disposable by design: everything in it is re-fetchable from the
|
|
15
|
+
* pins in a project's lockfile, which is what lets `gc()` evict entries without
|
|
16
|
+
* asking anyone. Content is never edited in place and is never symlinked into a
|
|
17
|
+
* project; a build reads inputs from here and renders or copies its outputs.
|
|
18
|
+
*
|
|
19
|
+
* Writes are atomic. `put()` assembles the entry in a temporary directory that
|
|
20
|
+
* is a SIBLING of its final home (same filesystem, so the rename cannot fail
|
|
21
|
+
* across devices), hashes it, verifies it, and only then renames it into place.
|
|
22
|
+
* A crash therefore leaves either the old entry or the new one, never a
|
|
23
|
+
* half-copied tree.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { randomBytes } from "node:crypto";
|
|
29
|
+
import { ConfigError } from "../../errors.js";
|
|
30
|
+
import { INDEX_CACHE_DIRNAME, STORE_ENTRY_FILENAME } from "../formats/common.js";
|
|
31
|
+
import {
|
|
32
|
+
parseStoreEntry,
|
|
33
|
+
stringifyStoreEntry,
|
|
34
|
+
type StoreEntry,
|
|
35
|
+
} from "../formats/store-entry.js";
|
|
36
|
+
import { resolveStoreRoot, type EnvLike } from "../../sous-home.js";
|
|
37
|
+
import { ensureStoreRootDirectory } from "../../../utils/sous-directory.js";
|
|
38
|
+
import { hashDirectory, hashesEqual } from "./hash.js";
|
|
39
|
+
import { identitySegments } from "../identity.js";
|
|
40
|
+
import type {
|
|
41
|
+
RecipeStoreLike,
|
|
42
|
+
StoreGcOptions,
|
|
43
|
+
StoreGcReport,
|
|
44
|
+
StoreHit,
|
|
45
|
+
StoreKey,
|
|
46
|
+
} from "./contract.js";
|
|
47
|
+
|
|
48
|
+
/** Prefix of the temporary directories `put()` and `remove()` use. */
|
|
49
|
+
const TEMP_PREFIX = ".sous-tmp-";
|
|
50
|
+
|
|
51
|
+
/** Directory name never copied into the store. */
|
|
52
|
+
const GIT_DIR_NAME = ".git";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How far below the store root a listing walk will look for an entry marker. A
|
|
56
|
+
* hosted repository's entry sits six levels down (host, owner, name, namespace,
|
|
57
|
+
* recipe, version) and a nested group path adds a few more; this is the guard
|
|
58
|
+
* that stops a walk rather than a limit anyone is meant to reach.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_STORE_DEPTH = 12;
|
|
61
|
+
|
|
62
|
+
/** Options accepted when constructing a store. */
|
|
63
|
+
export type RecipeStoreOptions = {
|
|
64
|
+
/** Absolute path to the store root; every entry lives under it. */
|
|
65
|
+
root: string;
|
|
66
|
+
/**
|
|
67
|
+
* Called with a complete, plain-language sentence whenever the store repairs
|
|
68
|
+
* itself (a corrupted entry removed, an unreadable marker skipped). The store
|
|
69
|
+
* writes nothing to the console itself, so a command can route these through
|
|
70
|
+
* the shared formatting helpers.
|
|
71
|
+
*/
|
|
72
|
+
onWarning?: (message: string) => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders a store key the way error and warning messages name it:
|
|
77
|
+
* `<identity>:namespace/name@version`.
|
|
78
|
+
*
|
|
79
|
+
* @param key - The key to describe.
|
|
80
|
+
*/
|
|
81
|
+
export function formatStoreKey(key: StoreKey): string {
|
|
82
|
+
return `${key.identity}:${key.namespace}/${key.name}@${key.version}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The key an entry is filed under, read back out of its own marker. The marker
|
|
87
|
+
* records the repository's identity under `repo`, because that is what it is.
|
|
88
|
+
*
|
|
89
|
+
* @param entry - A validated store entry marker.
|
|
90
|
+
*/
|
|
91
|
+
export function storeKeyOf(entry: StoreEntry): StoreKey {
|
|
92
|
+
return {
|
|
93
|
+
identity: entry.repo,
|
|
94
|
+
namespace: entry.namespace,
|
|
95
|
+
name: entry.name,
|
|
96
|
+
version: entry.version,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rejects a key component that could escape the store root. Callers pass
|
|
102
|
+
* validated names in practice; this is the belt that makes a bug in a provider
|
|
103
|
+
* unable to write outside the cache.
|
|
104
|
+
*
|
|
105
|
+
* @param value - The component to check.
|
|
106
|
+
* @param label - What the component is, for the error message.
|
|
107
|
+
*/
|
|
108
|
+
function assertSafeSegment(value: string, label: string): void {
|
|
109
|
+
if (
|
|
110
|
+
value.length === 0 ||
|
|
111
|
+
value === "." ||
|
|
112
|
+
value === ".." ||
|
|
113
|
+
value.includes("/") ||
|
|
114
|
+
value.includes("\\") ||
|
|
115
|
+
value.includes("\0")
|
|
116
|
+
) {
|
|
117
|
+
throw new ConfigError(
|
|
118
|
+
`Invalid store key: the ${label} '${value}' is not a usable directory name.\n` +
|
|
119
|
+
` Every part of a store key (the repository identity's segments, the namespace, ` +
|
|
120
|
+
`the recipe name and the version) must be a single usable path segment.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when a directory entry is one of the store's own temporary directories. */
|
|
126
|
+
function isTempName(name: string): boolean {
|
|
127
|
+
return name.startsWith(TEMP_PREFIX);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads one marker file. Returns the validated entry, `undefined` when there is
|
|
132
|
+
* no marker there at all, and the literal `"unreadable"` when there is one that
|
|
133
|
+
* does not parse, which a caller reports rather than silently ignoring.
|
|
134
|
+
*
|
|
135
|
+
* @param markerPath - Absolute path of the marker file.
|
|
136
|
+
*/
|
|
137
|
+
async function readMarkerFile(
|
|
138
|
+
markerPath: string
|
|
139
|
+
): Promise<StoreEntry | "unreadable" | undefined> {
|
|
140
|
+
let raw: string;
|
|
141
|
+
try {
|
|
142
|
+
raw = await fs.readFile(markerPath, "utf8");
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
return parseStoreEntry(JSON.parse(raw), markerPath);
|
|
149
|
+
} catch {
|
|
150
|
+
return "unreadable";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An ISO 8601 timestamp with an offset, the shape every marker field uses. */
|
|
155
|
+
function nowIso(): string {
|
|
156
|
+
return new Date().toISOString();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Removes a directory tree, ignoring the case where it is already gone. */
|
|
160
|
+
async function removeTree(target: string): Promise<void> {
|
|
161
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Copies a directory tree, skipping `.git` and any stale store marker, and
|
|
166
|
+
* returns the total number of content bytes copied.
|
|
167
|
+
*
|
|
168
|
+
* Symlinks are skipped, and so is anything under one. The store holds real files
|
|
169
|
+
* that the repository itself published, and the content hash skips links for the
|
|
170
|
+
* same reason, so a copied tree hashes the same as its source on any machine.
|
|
171
|
+
*
|
|
172
|
+
* @param source - The directory to copy from.
|
|
173
|
+
* @param destination - The directory to copy into; created if missing.
|
|
174
|
+
*/
|
|
175
|
+
async function copyTree(source: string, destination: string): Promise<number> {
|
|
176
|
+
await fs.mkdir(destination, { recursive: true });
|
|
177
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
178
|
+
let bytes = 0;
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (entry.name === GIT_DIR_NAME) continue;
|
|
182
|
+
if (entry.name === STORE_ENTRY_FILENAME) continue;
|
|
183
|
+
|
|
184
|
+
// A symlink is skipped whole, exactly as the hash skips it, so what lands in
|
|
185
|
+
// the store is what the hash was computed over. Following one would copy
|
|
186
|
+
// bytes the repository does not own and make the entry differ per machine.
|
|
187
|
+
if (entry.isSymbolicLink()) continue;
|
|
188
|
+
|
|
189
|
+
const from = path.join(source, entry.name);
|
|
190
|
+
const to = path.join(destination, entry.name);
|
|
191
|
+
|
|
192
|
+
if (entry.isDirectory()) {
|
|
193
|
+
bytes += await copyTree(from, to);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!entry.isFile()) continue;
|
|
197
|
+
|
|
198
|
+
let stats;
|
|
199
|
+
try {
|
|
200
|
+
stats = await fs.stat(from);
|
|
201
|
+
} catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await fs.copyFile(from, to);
|
|
206
|
+
await fs.chmod(to, stats.mode & 0o777);
|
|
207
|
+
bytes += stats.size;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return bytes;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The machine-wide recipe store. Construct one with an explicit root, or use
|
|
215
|
+
* `RecipeStore.fromEnv()` to take the root from `$SOUS_HOME/cache`.
|
|
216
|
+
*/
|
|
217
|
+
export class RecipeStore implements RecipeStoreLike {
|
|
218
|
+
/** The absolute root directory of this store. */
|
|
219
|
+
readonly root: string;
|
|
220
|
+
|
|
221
|
+
/** Where self-repair notices go; a no-op when the caller supplied none. */
|
|
222
|
+
private readonly onWarning: (message: string) => void;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param options - The store root, and an optional warning sink.
|
|
226
|
+
*/
|
|
227
|
+
constructor(options: RecipeStoreOptions) {
|
|
228
|
+
this.root = path.normalize(path.resolve(options.root));
|
|
229
|
+
this.onWarning = options.onWarning ?? (() => {});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Builds a store rooted at the user-level cache directory. `SOUS_HOME` is
|
|
234
|
+
* file-settable, so this reads the environment at call time; construct the
|
|
235
|
+
* store after the env files have loaded.
|
|
236
|
+
*
|
|
237
|
+
* @param env - The environment to read; defaults to the live `process.env`.
|
|
238
|
+
* @param options - An optional warning sink.
|
|
239
|
+
*/
|
|
240
|
+
static fromEnv(
|
|
241
|
+
env: EnvLike = process.env,
|
|
242
|
+
options: Omit<RecipeStoreOptions, "root"> = {}
|
|
243
|
+
): RecipeStore {
|
|
244
|
+
return new RecipeStore({ root: resolveStoreRoot(env), ...options });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The directory an entry lives in, whether or not it exists.
|
|
249
|
+
*
|
|
250
|
+
* @param key - The recipe version to locate.
|
|
251
|
+
*/
|
|
252
|
+
entryDir(key: StoreKey): string {
|
|
253
|
+
const identity = identitySegments(key.identity);
|
|
254
|
+
if (identity.length < 2) {
|
|
255
|
+
throw new ConfigError(
|
|
256
|
+
`Invalid store key: '${key.identity}' is not a repository identity.\n` +
|
|
257
|
+
` An identity is the repository's host followed by the path it lives at, as in ` +
|
|
258
|
+
`'github.com/sous-io/sous-recipes'.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
for (const segment of identity) assertSafeSegment(segment, "repository identity");
|
|
262
|
+
assertSafeSegment(key.namespace, "namespace");
|
|
263
|
+
assertSafeSegment(key.name, "recipe name");
|
|
264
|
+
assertSafeSegment(key.version, "version");
|
|
265
|
+
return path.join(this.root, ...identity, key.namespace, key.name, key.version);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The marker path for an entry. */
|
|
269
|
+
private markerPath(key: StoreKey): string {
|
|
270
|
+
return path.join(this.entryDir(key), STORE_ENTRY_FILENAME);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Copies `sourceDir` into the store, hashes it, verifies it against
|
|
275
|
+
* `expectedHash` when one is given, writes the marker and renames the result
|
|
276
|
+
* into place atomically.
|
|
277
|
+
*
|
|
278
|
+
* Replacing an entry that is already present is allowed only when the new
|
|
279
|
+
* content hashes the same as the old; a differing hash for the same version
|
|
280
|
+
* means the upstream content changed under a published version, which is a
|
|
281
|
+
* hard error rather than something to silently overwrite.
|
|
282
|
+
*
|
|
283
|
+
* @param key - The recipe version being stored.
|
|
284
|
+
* @param sourceDir - The directory holding the fetched files.
|
|
285
|
+
* @param expectedHash - The hash the caller (a lockfile pin, or a repo index)
|
|
286
|
+
* requires the content to have.
|
|
287
|
+
*/
|
|
288
|
+
async put(key: StoreKey, sourceDir: string, expectedHash?: string): Promise<StoreEntry> {
|
|
289
|
+
const target = this.entryDir(key);
|
|
290
|
+
const parent = path.dirname(target);
|
|
291
|
+
// The store root explains itself the first time anything is written into it.
|
|
292
|
+
ensureStoreRootDirectory(this.root);
|
|
293
|
+
await fs.mkdir(parent, { recursive: true });
|
|
294
|
+
|
|
295
|
+
const stagingDir = path.join(
|
|
296
|
+
parent,
|
|
297
|
+
`${TEMP_PREFIX}${key.version}-${randomBytes(6).toString("hex")}`
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const sizeBytes = await copyTree(sourceDir, stagingDir);
|
|
302
|
+
const hash = await hashDirectory(stagingDir);
|
|
303
|
+
|
|
304
|
+
if (expectedHash !== undefined && !hashesEqual(hash, expectedHash)) {
|
|
305
|
+
throw new ConfigError(
|
|
306
|
+
`The content of ${formatStoreKey(key)} does not match the hash it is pinned to.\n` +
|
|
307
|
+
` Expected: ${expectedHash}\n` +
|
|
308
|
+
` Actual: ${hash}\n` +
|
|
309
|
+
` Nothing was written to the store. Either the upstream files changed under a ` +
|
|
310
|
+
`published version, or the download was corrupted.`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const existing = await this.readMarker(key);
|
|
315
|
+
if (existing !== undefined && !hashesEqual(existing.hash, hash)) {
|
|
316
|
+
throw new ConfigError(
|
|
317
|
+
`The store already holds ${formatStoreKey(key)} with different content.\n` +
|
|
318
|
+
` Stored: ${existing.hash}\n` +
|
|
319
|
+
` Incoming: ${hash}\n` +
|
|
320
|
+
` A published version is immutable, so sous will not overwrite it. Remove the ` +
|
|
321
|
+
`entry deliberately if the upstream version was genuinely republished.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const entry: StoreEntry = {
|
|
326
|
+
formatVersion: 1,
|
|
327
|
+
repo: key.identity,
|
|
328
|
+
namespace: key.namespace,
|
|
329
|
+
name: key.name,
|
|
330
|
+
version: key.version,
|
|
331
|
+
hash,
|
|
332
|
+
fetchedAt: existing?.fetchedAt ?? nowIso(),
|
|
333
|
+
lastAccessAt: nowIso(),
|
|
334
|
+
sizeBytes,
|
|
335
|
+
};
|
|
336
|
+
await fs.writeFile(
|
|
337
|
+
path.join(stagingDir, STORE_ENTRY_FILENAME),
|
|
338
|
+
stringifyStoreEntry(entry),
|
|
339
|
+
"utf8"
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await this.swapIntoPlace(stagingDir, target);
|
|
343
|
+
return entry;
|
|
344
|
+
} finally {
|
|
345
|
+
await removeTree(stagingDir);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Renames a staged directory over the entry's final location. When something
|
|
351
|
+
* is already there it is moved aside first and deleted afterwards, so the
|
|
352
|
+
* window in which neither tree is in place is a single rename wide.
|
|
353
|
+
*
|
|
354
|
+
* @param stagingDir - The fully assembled entry.
|
|
355
|
+
* @param target - Where the entry belongs.
|
|
356
|
+
*/
|
|
357
|
+
private async swapIntoPlace(stagingDir: string, target: string): Promise<void> {
|
|
358
|
+
const displaced = path.join(
|
|
359
|
+
path.dirname(target),
|
|
360
|
+
`${TEMP_PREFIX}old-${randomBytes(6).toString("hex")}`
|
|
361
|
+
);
|
|
362
|
+
let displacedSomething = false;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await fs.rename(target, displaced);
|
|
366
|
+
displacedSomething = true;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
369
|
+
if (code !== "ENOENT") throw error;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await fs.rename(stagingDir, target);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (displacedSomething) await fs.rename(displaced, target);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (displacedSomething) await removeTree(displaced);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Reads and validates an entry's marker, returning undefined when the entry
|
|
384
|
+
* is absent. An unreadable or invalid marker is reported through the warning
|
|
385
|
+
* sink and treated as absent, so a damaged store heals on the next fetch.
|
|
386
|
+
*
|
|
387
|
+
* @param key - The entry to read.
|
|
388
|
+
*/
|
|
389
|
+
private async readMarker(key: StoreKey): Promise<StoreEntry | undefined> {
|
|
390
|
+
const markerPath = this.markerPath(key);
|
|
391
|
+
const marker = await readMarkerFile(markerPath);
|
|
392
|
+
|
|
393
|
+
if (marker === "unreadable") {
|
|
394
|
+
this.onWarning(
|
|
395
|
+
`The store entry marker at ${markerPath} could not be read, so the cached copy of ` +
|
|
396
|
+
`${formatStoreKey(key)} was ignored. It will be fetched again.`
|
|
397
|
+
);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
return marker;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Returns the entry when it is present and its content still hashes to what
|
|
405
|
+
* the marker records, touching its last-access time so collection sees the
|
|
406
|
+
* use. An entry whose content no longer verifies is removed and reported as
|
|
407
|
+
* absent, with a warning.
|
|
408
|
+
*
|
|
409
|
+
* @param key - The recipe version to look up.
|
|
410
|
+
*/
|
|
411
|
+
async get(key: StoreKey): Promise<StoreHit | undefined> {
|
|
412
|
+
const entry = await this.readMarker(key);
|
|
413
|
+
if (entry === undefined) return undefined;
|
|
414
|
+
|
|
415
|
+
const dir = this.entryDir(key);
|
|
416
|
+
const actual = await hashDirectory(dir);
|
|
417
|
+
if (!hashesEqual(actual, entry.hash)) {
|
|
418
|
+
await this.remove(key);
|
|
419
|
+
this.onWarning(
|
|
420
|
+
`The cached copy of ${formatStoreKey(key)} did not match its recorded content hash, ` +
|
|
421
|
+
`so it was removed from the store and will be fetched again.`
|
|
422
|
+
);
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const touched: StoreEntry = { ...entry, lastAccessAt: nowIso() };
|
|
427
|
+
await this.writeMarker(key, touched);
|
|
428
|
+
return { dir, entry: touched };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Writes a marker back over an existing entry. Only the marker changes;
|
|
433
|
+
* content is never touched, and the marker is excluded from the content hash,
|
|
434
|
+
* so touching an entry cannot invalidate it.
|
|
435
|
+
*
|
|
436
|
+
* @param key - The entry to write the marker for.
|
|
437
|
+
* @param entry - The marker contents.
|
|
438
|
+
*/
|
|
439
|
+
private async writeMarker(key: StoreKey, entry: StoreEntry): Promise<void> {
|
|
440
|
+
await fs.writeFile(this.markerPath(key), stringifyStoreEntry(entry), "utf8");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* True when an entry with a readable marker is present. This is a cheap
|
|
445
|
+
* presence check and does NOT verify the content hash; use `get()` when the
|
|
446
|
+
* answer has to be trustworthy.
|
|
447
|
+
*
|
|
448
|
+
* @param key - The recipe version to check for.
|
|
449
|
+
*/
|
|
450
|
+
async has(key: StoreKey): Promise<boolean> {
|
|
451
|
+
return (await this.readMarker(key)) !== undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Removes an entry and any directories its removal leaves empty, so the store
|
|
456
|
+
* does not accumulate an empty skeleton of every repo it ever held.
|
|
457
|
+
*
|
|
458
|
+
* @param key - The recipe version to remove.
|
|
459
|
+
*/
|
|
460
|
+
async remove(key: StoreKey): Promise<void> {
|
|
461
|
+
const target = this.entryDir(key);
|
|
462
|
+
await removeTree(target);
|
|
463
|
+
await this.pruneEmptyParents(path.dirname(target));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Deletes empty directories from `dir` upwards, stopping at the store root
|
|
468
|
+
* (which is left in place even when empty).
|
|
469
|
+
*
|
|
470
|
+
* @param dir - The deepest directory to consider.
|
|
471
|
+
*/
|
|
472
|
+
private async pruneEmptyParents(dir: string): Promise<void> {
|
|
473
|
+
let current = dir;
|
|
474
|
+
while (current.startsWith(this.root + path.sep)) {
|
|
475
|
+
try {
|
|
476
|
+
const remaining = await fs.readdir(current);
|
|
477
|
+
if (remaining.length > 0) return;
|
|
478
|
+
await fs.rmdir(current);
|
|
479
|
+
} catch {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
current = path.dirname(current);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Every entry in the store, read from the markers. Directories without a
|
|
488
|
+
* readable marker are skipped, so a partially removed tree never breaks a
|
|
489
|
+
* listing.
|
|
490
|
+
*/
|
|
491
|
+
async list(): Promise<StoreEntry[]> {
|
|
492
|
+
const entries: StoreEntry[] = [];
|
|
493
|
+
await this.collectEntries(this.root, 0, entries);
|
|
494
|
+
return entries;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Walks the store looking for entry markers. A repository identity is several
|
|
499
|
+
* directories deep and a self-hosted group path may be deeper still, so the
|
|
500
|
+
* depth is not fixed: any directory holding a readable marker IS an entry, and
|
|
501
|
+
* nothing below it is walked.
|
|
502
|
+
*
|
|
503
|
+
* @param dir - The directory to look in.
|
|
504
|
+
* @param depth - How far below the store root that directory is.
|
|
505
|
+
* @param into - The entries found so far, added to in place.
|
|
506
|
+
*/
|
|
507
|
+
private async collectEntries(
|
|
508
|
+
dir: string,
|
|
509
|
+
depth: number,
|
|
510
|
+
into: StoreEntry[]
|
|
511
|
+
): Promise<void> {
|
|
512
|
+
// Nothing sensible is ever this deep, and a loop through a symlinked
|
|
513
|
+
// directory would otherwise never end.
|
|
514
|
+
if (depth > MAX_STORE_DEPTH) return;
|
|
515
|
+
|
|
516
|
+
for (const child of await this.childDirectories(dir)) {
|
|
517
|
+
// The index cache is a sibling of the entries, inside the same root.
|
|
518
|
+
if (depth === 0 && child === INDEX_CACHE_DIRNAME) continue;
|
|
519
|
+
|
|
520
|
+
const childDir = path.join(dir, child);
|
|
521
|
+
const marker = await readMarkerFile(path.join(childDir, STORE_ENTRY_FILENAME));
|
|
522
|
+
if (marker === "unreadable") {
|
|
523
|
+
this.onWarning(
|
|
524
|
+
`The store entry marker at ${path.join(childDir, STORE_ENTRY_FILENAME)} could ` +
|
|
525
|
+
`not be read, so that cached copy was left out of the listing. It will be ` +
|
|
526
|
+
`fetched again when something needs it.`
|
|
527
|
+
);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (marker !== undefined) {
|
|
531
|
+
into.push(marker);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
await this.collectEntries(childDir, depth + 1, into);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Directory names directly inside `dir`, with the store's own temporary
|
|
540
|
+
* directories filtered out. A missing directory lists as empty.
|
|
541
|
+
*
|
|
542
|
+
* @param dir - The directory to read.
|
|
543
|
+
*/
|
|
544
|
+
private async childDirectories(dir: string): Promise<string[]> {
|
|
545
|
+
let found;
|
|
546
|
+
try {
|
|
547
|
+
found = await fs.readdir(dir, { withFileTypes: true });
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
return found
|
|
552
|
+
.filter((entry) => entry.isDirectory() && !isTempName(entry.name))
|
|
553
|
+
.map((entry) => entry.name);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Evicts least-recently-used entries until the store fits inside `maxBytes`.
|
|
558
|
+
* Entries listed in `keep` (everything a lockfile still pins) are never
|
|
559
|
+
* evicted, even when that leaves the store over its cap; a cache that is too
|
|
560
|
+
* small is a nuisance, while evicting a pinned entry breaks a build.
|
|
561
|
+
*
|
|
562
|
+
* @param options - The size cap, the keys to protect, and whether to only report.
|
|
563
|
+
*/
|
|
564
|
+
async gc(options: StoreGcOptions): Promise<StoreGcReport> {
|
|
565
|
+
const entries = await this.list();
|
|
566
|
+
const bytesBefore = entries.reduce((total, entry) => total + entry.sizeBytes, 0);
|
|
567
|
+
|
|
568
|
+
const protectedKeys = new Set(
|
|
569
|
+
(options.keep ?? []).map((key) => formatStoreKey(key))
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
// Least recently used first; ties break on the key so a pass is deterministic.
|
|
573
|
+
const candidates = [...entries].sort((a, b) => {
|
|
574
|
+
if (a.lastAccessAt !== b.lastAccessAt) {
|
|
575
|
+
return a.lastAccessAt < b.lastAccessAt ? -1 : 1;
|
|
576
|
+
}
|
|
577
|
+
return formatStoreKey(storeKeyOf(a)) < formatStoreKey(storeKeyOf(b)) ? -1 : 1;
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const evicted: StoreEntry[] = [];
|
|
581
|
+
const kept: StoreEntry[] = [];
|
|
582
|
+
let bytesAfter = bytesBefore;
|
|
583
|
+
|
|
584
|
+
for (const entry of candidates) {
|
|
585
|
+
if (
|
|
586
|
+
bytesAfter <= options.maxBytes ||
|
|
587
|
+
protectedKeys.has(formatStoreKey(storeKeyOf(entry)))
|
|
588
|
+
) {
|
|
589
|
+
kept.push(entry);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (options.dryRun !== true) await this.remove(storeKeyOf(entry));
|
|
593
|
+
evicted.push(entry);
|
|
594
|
+
bytesAfter -= entry.sizeBytes;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { evicted, kept, bytesBefore, bytesAfter };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store's tunable numbers, read from the optional top-level `store:` block
|
|
3
|
+
* of a project's config.
|
|
4
|
+
*
|
|
5
|
+
* Every value sous ships is a DEFAULT, not an assumption: the schema
|
|
6
|
+
* (`config-schema.ts`) makes each field optional and this module supplies the
|
|
7
|
+
* fallback, so a project can change any of them without having to restate the
|
|
8
|
+
* others.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Settings } from "../../settings.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default size cap for the whole store: one gigabyte. Past this, `gc()` evicts
|
|
15
|
+
* least-recently-used entries that no lockfile pins.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_STORE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default freshness window for a fetched repo index: five minutes. A non-watch
|
|
21
|
+
* build re-checks upstream only once this window has lapsed, and a failed check
|
|
22
|
+
* never breaks a build; the last good answer stands.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_FRESHNESS_SECONDS = 300;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default interval at which watch mode polls upstream for a newer in-range
|
|
28
|
+
* version: five minutes.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_WATCH_POLL_SECONDS = 300;
|
|
31
|
+
|
|
32
|
+
/** The store's settings, with every default applied. */
|
|
33
|
+
export type ResolvedStoreSettings = {
|
|
34
|
+
/** Size cap for the whole store, in bytes. */
|
|
35
|
+
maxBytes: number;
|
|
36
|
+
/** How long a fetched index stays fresh, in seconds. */
|
|
37
|
+
freshnessSeconds: number;
|
|
38
|
+
/** How often watch mode polls upstream, in seconds. */
|
|
39
|
+
watchPollSeconds: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves the store settings for a project: the `store:` block if it has one,
|
|
44
|
+
* with sous's defaults filling every field it leaves out.
|
|
45
|
+
*
|
|
46
|
+
* The values are plain numbers rather than `${var}` strings, so no variable
|
|
47
|
+
* resolution is involved and this can be called with the raw merged settings.
|
|
48
|
+
*
|
|
49
|
+
* @param settings - The merged, validated project config.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveStoreSettings(settings: Settings): ResolvedStoreSettings {
|
|
52
|
+
const store = settings.store ?? {};
|
|
53
|
+
return {
|
|
54
|
+
maxBytes: store.maxBytes ?? DEFAULT_STORE_MAX_BYTES,
|
|
55
|
+
freshnessSeconds: store.freshnessSeconds ?? DEFAULT_FRESHNESS_SECONDS,
|
|
56
|
+
watchPollSeconds: store.watchPollSeconds ?? DEFAULT_WATCH_POLL_SECONDS,
|
|
57
|
+
};
|
|
58
|
+
}
|