@sous-io/sous 0.1.1 → 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 +115 -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 +72 -8
- 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/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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeding the store with the core recipe that ships inside the package.
|
|
3
|
+
*
|
|
4
|
+
* Every project is subscribed to the `core` namespace, and a project must be
|
|
5
|
+
* able to build with no network at all, including the very first time sous runs
|
|
6
|
+
* on a machine. That is what this module is for: it copies the packaged core
|
|
7
|
+
* recipe into the machine-wide store and, when nothing has ever been fetched
|
|
8
|
+
* from the official repository, writes a small stand-in index so the resolver
|
|
9
|
+
* can find the recipe it has just seeded.
|
|
10
|
+
*
|
|
11
|
+
* Three properties matter, and each one is deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - IDEMPOTENT. Seeding runs before every build. When the store already holds
|
|
14
|
+
* a verifying entry at this version, nothing is copied and nothing is
|
|
15
|
+
* written.
|
|
16
|
+
* - OFFLINE. Nothing here touches the network, and nothing here fails a build.
|
|
17
|
+
* A store that cannot be written is reported and the build carries on
|
|
18
|
+
* without core, which is far better than refusing to run.
|
|
19
|
+
* - REPLACEABLE. The stand-in index is written only when the cache holds no
|
|
20
|
+
* real index for the official repository. It carries a note saying sous
|
|
21
|
+
* wrote it, so a later run recognizes its own placeholder and is willing to
|
|
22
|
+
* replace it; the first successful fetch overwrites it with the real thing.
|
|
23
|
+
*
|
|
24
|
+
* The stand-in alone is not enough, and that is what `coreIndexOverlay` is for.
|
|
25
|
+
* A machine that has already fetched the real index keeps it, quite rightly, and
|
|
26
|
+
* that index publishes whatever versions of the core recipe the repository has
|
|
27
|
+
* released. Upgrade sous and the version it asks for is, for a while, one the
|
|
28
|
+
* repository has not published yet: the real index wins over the stand-in, the
|
|
29
|
+
* resolver finds nothing satisfying the range, and the project silently loses
|
|
30
|
+
* its core skills until the release pipeline catches up. So the packaged version
|
|
31
|
+
* is folded into the index IN MEMORY whenever it is missing, carrying the hash
|
|
32
|
+
* of the entry that was just seeded. The cached file is never touched, so it
|
|
33
|
+
* stays an honest record of what upstream served, and the moment upstream does
|
|
34
|
+
* publish that version its own entry is what gets used.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import semver from "semver";
|
|
40
|
+
import {
|
|
41
|
+
parseIndexFile,
|
|
42
|
+
stringifyIndexFile,
|
|
43
|
+
type IndexFile,
|
|
44
|
+
} from "./formats/index-file.js";
|
|
45
|
+
import {
|
|
46
|
+
INDEX_CACHE_DIRNAME,
|
|
47
|
+
INDEX_SIDECAR_SUFFIX,
|
|
48
|
+
type IndexOverlay,
|
|
49
|
+
} from "./providers/index-cache.js";
|
|
50
|
+
import { warning } from "../../utils/formatting.js";
|
|
51
|
+
import { identitySegments } from "./identity.js";
|
|
52
|
+
import { ensureIndexCacheDirectory } from "../../utils/sous-directory.js";
|
|
53
|
+
import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
|
|
54
|
+
import {
|
|
55
|
+
CORE_NAMESPACE,
|
|
56
|
+
CORE_RECIPE_KEY,
|
|
57
|
+
CORE_RECIPE_NAME,
|
|
58
|
+
CORE_RECIPE_PATH,
|
|
59
|
+
OFFICIAL_REPO_IDENTITY,
|
|
60
|
+
OFFICIAL_REPO_NAME,
|
|
61
|
+
packagedCoreRecipeDir,
|
|
62
|
+
readPackagedCoreManifest,
|
|
63
|
+
} from "./core-recipe.js";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The note the seed index carries in its `$comment` field. A cached index
|
|
67
|
+
* carrying this exact text is sous's own placeholder rather than something a
|
|
68
|
+
* repository published, so a later seed may overwrite it.
|
|
69
|
+
*/
|
|
70
|
+
export const SEED_INDEX_COMMENT =
|
|
71
|
+
"Written by sous itself from the core recipe inside the installed package, so a " +
|
|
72
|
+
"project can build before it has ever reached the network. The first successful " +
|
|
73
|
+
"fetch of the real index replaces this file.";
|
|
74
|
+
|
|
75
|
+
/** The description the seed index gives the core namespace. */
|
|
76
|
+
const CORE_NAMESPACE_DESCRIPTION =
|
|
77
|
+
"Skills that teach an agent about sous itself: what sous manages, how its " +
|
|
78
|
+
"configuration works, how templates render, and how skills are written. " +
|
|
79
|
+
"Auto-subscribed in every project, with opt-out-only semantics.";
|
|
80
|
+
|
|
81
|
+
/** What the seed is asked to do. */
|
|
82
|
+
export type SeedCoreRecipeOptions = {
|
|
83
|
+
/** The store to seed. */
|
|
84
|
+
store: RecipeStoreLike;
|
|
85
|
+
/** The store's root directory, which is where the index cache lives. */
|
|
86
|
+
storeRoot?: string;
|
|
87
|
+
/** The version to seed, which is by rule the running sous version. */
|
|
88
|
+
sousVersion: string;
|
|
89
|
+
/** The installed package's root directory. Defaults to the running CLI's own. */
|
|
90
|
+
packageRoot?: string;
|
|
91
|
+
/** The clock, so a written timestamp is predictable in tests. */
|
|
92
|
+
now?: () => Date;
|
|
93
|
+
/**
|
|
94
|
+
* The index cache to teach about the packaged version, once the seed knows
|
|
95
|
+
* its hash. Without this the seeded recipe is resolvable only on a machine
|
|
96
|
+
* whose cached index is sous's own stand-in.
|
|
97
|
+
*/
|
|
98
|
+
indexCache?: IndexOverlayTarget;
|
|
99
|
+
/** Where warnings go. Defaults to the console warning banner. */
|
|
100
|
+
warn?: (message: string) => void;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The part of the index cache the seed uses: somewhere to install what it has
|
|
105
|
+
* learned. Named as a small structural type so the seed does not depend on the
|
|
106
|
+
* cache's implementation.
|
|
107
|
+
*/
|
|
108
|
+
export type IndexOverlayTarget = {
|
|
109
|
+
setOverlay(overlay: IndexOverlay | undefined): void;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** What the seed did. */
|
|
113
|
+
export type SeedCoreRecipeReport = {
|
|
114
|
+
/** True when the recipe's files were copied into the store on this run. */
|
|
115
|
+
seeded: boolean;
|
|
116
|
+
/** True when the store already held a verifying entry, so nothing was copied. */
|
|
117
|
+
alreadyPresent: boolean;
|
|
118
|
+
/** True when a stand-in index was written for the official repository. */
|
|
119
|
+
wroteIndex: boolean;
|
|
120
|
+
/** The version that was seeded. */
|
|
121
|
+
version: string;
|
|
122
|
+
/** The content hash of the seeded entry, when there is one. */
|
|
123
|
+
hash?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Why the seed did nothing, when it could not run. A complete sentence, meant
|
|
126
|
+
* to be shown as a warning; the seed never throws.
|
|
127
|
+
*/
|
|
128
|
+
skippedBecause?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Copies the packaged core recipe into the store and, when nothing real is
|
|
133
|
+
* cached, writes a stand-in index for the official repository so the resolver
|
|
134
|
+
* can see what was just seeded.
|
|
135
|
+
*
|
|
136
|
+
* @param options - The store to seed, the version to seed it at, and the clock.
|
|
137
|
+
*/
|
|
138
|
+
export async function seedCoreRecipe(
|
|
139
|
+
options: SeedCoreRecipeOptions
|
|
140
|
+
): Promise<SeedCoreRecipeReport> {
|
|
141
|
+
const version = options.sousVersion;
|
|
142
|
+
const now = options.now ?? (() => new Date());
|
|
143
|
+
const storeRoot = options.storeRoot ?? options.store.root;
|
|
144
|
+
|
|
145
|
+
const key: StoreKey = {
|
|
146
|
+
identity: OFFICIAL_REPO_IDENTITY,
|
|
147
|
+
namespace: CORE_NAMESPACE,
|
|
148
|
+
name: CORE_RECIPE_NAME,
|
|
149
|
+
version,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const report: SeedCoreRecipeReport = {
|
|
153
|
+
seeded: false,
|
|
154
|
+
alreadyPresent: false,
|
|
155
|
+
wroteIndex: false,
|
|
156
|
+
version,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const existing = await options.store.get(key);
|
|
161
|
+
|
|
162
|
+
if (existing !== undefined) {
|
|
163
|
+
report.alreadyPresent = true;
|
|
164
|
+
report.hash = existing.entry.hash;
|
|
165
|
+
} else {
|
|
166
|
+
const source = packagedCoreRecipeDir(options.packageRoot);
|
|
167
|
+
const entry = await options.store.put(key, source);
|
|
168
|
+
report.seeded = true;
|
|
169
|
+
report.hash = entry.hash;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The cache is taught before the stand-in is considered, because the two
|
|
173
|
+
// answer different halves of the same problem: the stand-in covers a machine
|
|
174
|
+
// that has never fetched anything, and the overlay covers one that has.
|
|
175
|
+
options.indexCache?.setOverlay(
|
|
176
|
+
coreIndexOverlay({
|
|
177
|
+
version,
|
|
178
|
+
hash: report.hash!,
|
|
179
|
+
...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
|
|
180
|
+
...(options.warn === undefined ? {} : { warn: options.warn }),
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
report.wroteIndex = writeSeedIndex({
|
|
185
|
+
storeRoot,
|
|
186
|
+
version,
|
|
187
|
+
hash: report.hash!,
|
|
188
|
+
now: now(),
|
|
189
|
+
...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Seeding is a convenience, not a precondition: a read-only store or a
|
|
193
|
+
// damaged package must not stop a build that may not even use recipes.
|
|
194
|
+
report.skippedBecause =
|
|
195
|
+
`Sous could not seed the core recipe it ships with, so the skills in the ` +
|
|
196
|
+
`'core' namespace are unavailable until this is fixed.\n` +
|
|
197
|
+
`${error instanceof Error ? error.message : String(error)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return report;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** What the overlay needs to know about the version sous ships. */
|
|
204
|
+
export type CoreIndexOverlayOptions = {
|
|
205
|
+
/** The packaged version, which is by rule the running sous version. */
|
|
206
|
+
version: string;
|
|
207
|
+
/** The content hash of the entry the seed put in the store. */
|
|
208
|
+
hash: string;
|
|
209
|
+
/** The installed package's root directory. Defaults to the running CLI's own. */
|
|
210
|
+
packageRoot?: string;
|
|
211
|
+
/** Where the one warning this can produce goes. Defaults to the console banner. */
|
|
212
|
+
warn?: (message: string) => void;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Builds the overlay that makes the packaged core recipe resolvable whatever
|
|
217
|
+
* the official repository has published so far.
|
|
218
|
+
*
|
|
219
|
+
* Three cases, and the order matters:
|
|
220
|
+
*
|
|
221
|
+
* - The index already publishes this version with this hash: nothing to add,
|
|
222
|
+
* and the index is returned untouched. This is the ordinary case once the
|
|
223
|
+
* release pipeline has caught up.
|
|
224
|
+
* - The index publishes this version with a DIFFERENT hash: upstream wins, and
|
|
225
|
+
* sous says so once. The two are built from the same bytes, so this should
|
|
226
|
+
* not happen; when it does, the version a repository published is the one a
|
|
227
|
+
* lockfile should be able to pin on any machine, seeded or not.
|
|
228
|
+
* - The index does not publish this version at all: it is added, carrying the
|
|
229
|
+
* hash of the entry the seed just wrote and marked `seeded` so a listing can
|
|
230
|
+
* say it came packaged with sous.
|
|
231
|
+
*
|
|
232
|
+
* Nothing here writes anything. The returned index is a copy; the one passed in
|
|
233
|
+
* is left exactly as it was read.
|
|
234
|
+
*
|
|
235
|
+
* @param options - The packaged version, its hash, and where a warning goes.
|
|
236
|
+
*/
|
|
237
|
+
export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay {
|
|
238
|
+
let warned = false;
|
|
239
|
+
|
|
240
|
+
return (identity: string, index: IndexFile): IndexFile => {
|
|
241
|
+
if (identity !== OFFICIAL_REPO_IDENTITY) return index;
|
|
242
|
+
|
|
243
|
+
const published = index.recipes[CORE_RECIPE_KEY]?.versions[options.version];
|
|
244
|
+
|
|
245
|
+
if (published !== undefined) {
|
|
246
|
+
if (published.hash !== options.hash && !warned) {
|
|
247
|
+
warned = true;
|
|
248
|
+
(options.warn ?? warning)(
|
|
249
|
+
`The repository '${OFFICIAL_REPO_NAME}' publishes version ${options.version} of ` +
|
|
250
|
+
`'${CORE_RECIPE_KEY}' with different contents from the copy inside this ` +
|
|
251
|
+
`installation of sous, so sous is using the published one.\n` +
|
|
252
|
+
` Published: ${published.hash}\n` +
|
|
253
|
+
` Packaged: ${options.hash}\n` +
|
|
254
|
+
` Reinstalling sous will bring the two back into line.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return index;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const recipe = index.recipes[CORE_RECIPE_KEY];
|
|
261
|
+
const description = recipe?.description ?? packagedCoreDescription(options.packageRoot);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
...index,
|
|
265
|
+
namespaces: {
|
|
266
|
+
...index.namespaces,
|
|
267
|
+
[CORE_NAMESPACE]: index.namespaces[CORE_NAMESPACE] ?? {
|
|
268
|
+
description: CORE_NAMESPACE_DESCRIPTION,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
recipes: {
|
|
272
|
+
...index.recipes,
|
|
273
|
+
[CORE_RECIPE_KEY]: {
|
|
274
|
+
path: recipe?.path ?? CORE_RECIPE_PATH,
|
|
275
|
+
...(description === undefined ? {} : { description }),
|
|
276
|
+
versions: {
|
|
277
|
+
...recipe?.versions,
|
|
278
|
+
[options.version]: {
|
|
279
|
+
hash: options.hash,
|
|
280
|
+
tag: `${CORE_RECIPE_KEY}@${options.version}`,
|
|
281
|
+
prerelease: semver.prerelease(options.version) !== null,
|
|
282
|
+
seeded: true,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The packaged recipe's one-paragraph summary, or undefined when the manifest
|
|
293
|
+
* cannot be read. A description is decoration; losing it must not cost a project
|
|
294
|
+
* its core skills.
|
|
295
|
+
*
|
|
296
|
+
* @param packageRoot - The installed package's root directory.
|
|
297
|
+
*/
|
|
298
|
+
function packagedCoreDescription(packageRoot?: string): string | undefined {
|
|
299
|
+
try {
|
|
300
|
+
return readPackagedCoreManifest(packageRoot).description;
|
|
301
|
+
} catch {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Writes the stand-in index for the official repository, unless the cache
|
|
308
|
+
* already holds one that a repository actually published.
|
|
309
|
+
*
|
|
310
|
+
* Returns true when a file was written.
|
|
311
|
+
*
|
|
312
|
+
* @param input - Where the cache is, and what the seeded entry looks like.
|
|
313
|
+
*/
|
|
314
|
+
function writeSeedIndex(input: {
|
|
315
|
+
storeRoot: string;
|
|
316
|
+
version: string;
|
|
317
|
+
hash: string;
|
|
318
|
+
now: Date;
|
|
319
|
+
packageRoot?: string;
|
|
320
|
+
}): boolean {
|
|
321
|
+
// The cache files an index under the repository's identity, which is several
|
|
322
|
+
// directories deep; the seed writes to exactly the same place a real fetch
|
|
323
|
+
// would, so the first successful fetch replaces this copy rather than
|
|
324
|
+
// sitting beside it.
|
|
325
|
+
const segments = identitySegments(OFFICIAL_REPO_IDENTITY);
|
|
326
|
+
const last = segments.pop()!;
|
|
327
|
+
const directory = path.join(input.storeRoot, INDEX_CACHE_DIRNAME, ...segments);
|
|
328
|
+
const indexPath = path.join(directory, `${last}.json`);
|
|
329
|
+
const sidecarPath = path.join(directory, `${last}${INDEX_SIDECAR_SUFFIX}`);
|
|
330
|
+
|
|
331
|
+
if (!isReplaceableIndex(indexPath, input.version)) return false;
|
|
332
|
+
|
|
333
|
+
const manifest = readPackagedCoreManifest(input.packageRoot);
|
|
334
|
+
const timestamp = input.now.toISOString();
|
|
335
|
+
|
|
336
|
+
const index: IndexFile = {
|
|
337
|
+
$comment: SEED_INDEX_COMMENT,
|
|
338
|
+
formatVersion: 1,
|
|
339
|
+
name: OFFICIAL_REPO_NAME,
|
|
340
|
+
generatedAt: timestamp,
|
|
341
|
+
generator: input.version,
|
|
342
|
+
namespaces: {
|
|
343
|
+
[CORE_NAMESPACE]: { description: CORE_NAMESPACE_DESCRIPTION },
|
|
344
|
+
},
|
|
345
|
+
recipes: {
|
|
346
|
+
[CORE_RECIPE_KEY]: {
|
|
347
|
+
path: CORE_RECIPE_PATH,
|
|
348
|
+
...(manifest.description === undefined
|
|
349
|
+
? {}
|
|
350
|
+
: { description: manifest.description }),
|
|
351
|
+
versions: {
|
|
352
|
+
[input.version]: {
|
|
353
|
+
hash: input.hash,
|
|
354
|
+
tag: `${CORE_RECIPE_KEY}@${input.version}`,
|
|
355
|
+
prerelease: semver.prerelease(input.version) !== null,
|
|
356
|
+
releasedAt: timestamp,
|
|
357
|
+
seeded: true,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// No sidecar is written, deliberately. The sidecar is what says "this copy was
|
|
365
|
+
// fetched at such a time", and this copy was not fetched at all. Without one,
|
|
366
|
+
// sous treats the stand-in as infinitely old and tries upstream on the very
|
|
367
|
+
// next command: the first run with a network gets the real index immediately
|
|
368
|
+
// rather than waiting out a freshness window it never earned. When there is no
|
|
369
|
+
// network the fetch fails, the stand-in is used, and sous says so, which is the
|
|
370
|
+
// same last-good behavior every other repository gets.
|
|
371
|
+
//
|
|
372
|
+
// Any sidecar left over from an earlier fetch is removed for the same reason.
|
|
373
|
+
ensureIndexCacheDirectory(path.join(input.storeRoot, INDEX_CACHE_DIRNAME));
|
|
374
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
375
|
+
fs.writeFileSync(indexPath, stringifyIndexFile(index), "utf8");
|
|
376
|
+
fs.rmSync(sidecarPath, { force: true });
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* True when the seed may write over whatever is at `indexPath`.
|
|
382
|
+
*
|
|
383
|
+
* Three cases, in order:
|
|
384
|
+
*
|
|
385
|
+
* - Nothing cached, or a cached file that no longer parses: write. A damaged
|
|
386
|
+
* copy is worth less than a stand-in that works.
|
|
387
|
+
* - A stand-in sous wrote itself that does not name the version being seeded:
|
|
388
|
+
* write. This is how a machine that upgrades sous while offline ends up with
|
|
389
|
+
* an index naming the new version.
|
|
390
|
+
* - Anything else, including sous's own stand-in that already names this
|
|
391
|
+
* version: leave it alone. Rewriting it on every build would reset the
|
|
392
|
+
* freshness clock, and sous would then never look upstream for the real one.
|
|
393
|
+
*
|
|
394
|
+
* @param indexPath - Where the cached index for the official repository lives.
|
|
395
|
+
* @param version - The version being seeded.
|
|
396
|
+
*/
|
|
397
|
+
function isReplaceableIndex(indexPath: string, version: string): boolean {
|
|
398
|
+
let text: string;
|
|
399
|
+
try {
|
|
400
|
+
text = fs.readFileSync(indexPath, "utf8");
|
|
401
|
+
} catch {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let cached: IndexFile;
|
|
406
|
+
try {
|
|
407
|
+
cached = parseIndexFile(JSON.parse(text), indexPath);
|
|
408
|
+
} catch {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (cached.$comment !== SEED_INDEX_COMMENT) return false;
|
|
413
|
+
return cached.recipes[CORE_RECIPE_KEY]?.versions[version] === undefined;
|
|
414
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between the recipe store (Phase 2a) and everything that fills or reads it
|
|
3
|
+
* (providers, resolver, lockfile restore, the build). Both sides are written against this
|
|
4
|
+
* file; keep it dependency-free so either side can compile without the other.
|
|
5
|
+
*/
|
|
6
|
+
import type { StoreEntry } from "../formats/store-entry.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Identifies one recipe version inside one repository.
|
|
10
|
+
*
|
|
11
|
+
* The store is MACHINE-WIDE, so it is keyed by the repository's canonical
|
|
12
|
+
* identity rather than by a project's short name for it: two projects that call
|
|
13
|
+
* the same repository different things still share one cached copy, and two
|
|
14
|
+
* projects that use the same short name for different repositories never
|
|
15
|
+
* collide.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoreKey {
|
|
18
|
+
/** The repository's canonical identity, such as `github.com/sous-io/sous-recipes`. */
|
|
19
|
+
identity: string;
|
|
20
|
+
namespace: string;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a successful lookup returns: where the files are and the verified marker. */
|
|
26
|
+
export interface StoreHit {
|
|
27
|
+
dir: string;
|
|
28
|
+
entry: StoreEntry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Options for a garbage-collection pass. */
|
|
32
|
+
export interface StoreGcOptions {
|
|
33
|
+
/** Upper bound for the whole store in bytes; entries are evicted least-recently-used first. */
|
|
34
|
+
maxBytes: number;
|
|
35
|
+
/** Keys that must survive no matter what (everything a lockfile still pins). */
|
|
36
|
+
keep?: StoreKey[];
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StoreGcReport {
|
|
41
|
+
evicted: StoreEntry[];
|
|
42
|
+
kept: StoreEntry[];
|
|
43
|
+
bytesBefore: number;
|
|
44
|
+
bytesAfter: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The recipe store: one immutable folder per recipe version, verified against its content hash. */
|
|
48
|
+
export interface RecipeStoreLike {
|
|
49
|
+
/** The absolute root directory of this store. */
|
|
50
|
+
readonly root: string;
|
|
51
|
+
/** The directory an entry lives in (whether or not it exists). */
|
|
52
|
+
entryDir(key: StoreKey): string;
|
|
53
|
+
/** Copies `sourceDir` into the store, hashes it, verifies against `expectedHash` when given, writes the marker. */
|
|
54
|
+
put(key: StoreKey, sourceDir: string, expectedHash?: string): Promise<StoreEntry>;
|
|
55
|
+
/** Returns the entry if present and its hash still verifies; touches last access. Undefined when absent. */
|
|
56
|
+
get(key: StoreKey): Promise<StoreHit | undefined>;
|
|
57
|
+
has(key: StoreKey): Promise<boolean>;
|
|
58
|
+
remove(key: StoreKey): Promise<void>;
|
|
59
|
+
list(): Promise<StoreEntry[]>;
|
|
60
|
+
gc(options: StoreGcOptions): Promise<StoreGcReport>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Computes the canonical `sha256-<hex>` content hash of a directory tree. */
|
|
64
|
+
export type DirectoryHasher = (dir: string) => Promise<string>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical content hash of a recipe folder.
|
|
3
|
+
*
|
|
4
|
+
* Decision 12 of the Repositories design specifies "SHA-256 over a canonical
|
|
5
|
+
* tar of the recipe folder". This module computes exactly that idea WITHOUT a
|
|
6
|
+
* tar dependency: instead of serializing a real tar archive, it feeds the hash
|
|
7
|
+
* a canonical byte stream built from the same information a tar entry would
|
|
8
|
+
* carry that we actually care about (the path and the file's bytes), in a
|
|
9
|
+
* canonical order. Tar's variable header fields (mode, owner, group, mtime,
|
|
10
|
+
* block padding, format variant) are deliberately excluded, so the same tree
|
|
11
|
+
* hashes the same after a copy, a clone or an archive round-trip on a different
|
|
12
|
+
* machine.
|
|
13
|
+
*
|
|
14
|
+
* The stream, per file, is:
|
|
15
|
+
*
|
|
16
|
+
* <relative path (posix separators)> NUL <byte length> NUL <bytes> NUL
|
|
17
|
+
*
|
|
18
|
+
* Files are visited in bytewise order of their relative paths. `.git` and the
|
|
19
|
+
* store's own `.sous.entry.json` marker are skipped, so an entry's hash is
|
|
20
|
+
* independent of the marker that records it. Empty directories contribute
|
|
21
|
+
* nothing, since they carry no content a recipe can use.
|
|
22
|
+
*
|
|
23
|
+
* SYMLINKS ARE SKIPPED ENTIRELY, and so is anything under one. A hash has to
|
|
24
|
+
* mean the same thing on the publisher's machine and the consumer's, and a link
|
|
25
|
+
* points at bytes the repository does not own: following it made the hash depend
|
|
26
|
+
* on whatever happened to be at the target, so the same published version hashed
|
|
27
|
+
* differently on two machines and failed its own pin on every install. A recipe
|
|
28
|
+
* that needs a file ships the file; `sous repo release` refuses to publish a
|
|
29
|
+
* recipe folder containing a link.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import fs from "node:fs/promises";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { STORE_ENTRY_FILENAME } from "../formats/common.js";
|
|
36
|
+
|
|
37
|
+
/** Directory name never included in a content hash. */
|
|
38
|
+
const GIT_DIR_NAME = ".git";
|
|
39
|
+
|
|
40
|
+
/** The separator byte between the fields of one file's canonical record. */
|
|
41
|
+
const FIELD_SEPARATOR = Buffer.from([0]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Collects every hashable file under `dir`, as paths relative to `dir` and
|
|
45
|
+
* always with posix separators, so a hash computed on Windows matches one
|
|
46
|
+
* computed on Linux.
|
|
47
|
+
*
|
|
48
|
+
* @param dir - The directory to walk.
|
|
49
|
+
* @param prefix - The relative path of `dir` within the tree being hashed.
|
|
50
|
+
*/
|
|
51
|
+
async function collectFiles(dir: string, prefix = ""): Promise<string[]> {
|
|
52
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
53
|
+
const found: string[] = [];
|
|
54
|
+
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.name === GIT_DIR_NAME) continue;
|
|
57
|
+
if (entry.name === STORE_ENTRY_FILENAME) continue;
|
|
58
|
+
|
|
59
|
+
// `withFileTypes` reports the entry itself, not what it points at, so a
|
|
60
|
+
// symlink is recognised here and skipped whole. Nothing stats through it,
|
|
61
|
+
// which is the point: a hash may only depend on bytes the repository owns.
|
|
62
|
+
if (entry.isSymbolicLink()) continue;
|
|
63
|
+
|
|
64
|
+
const relative = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
|
|
65
|
+
const absolute = path.join(dir, entry.name);
|
|
66
|
+
|
|
67
|
+
if (entry.isDirectory()) found.push(...(await collectFiles(absolute, relative)));
|
|
68
|
+
else if (entry.isFile()) found.push(relative);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Orders two relative paths bytewise, so the ordering never depends on a locale. */
|
|
75
|
+
function bytewiseCompare(a: string, b: string): number {
|
|
76
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Computes the canonical content hash of a directory tree, returned as
|
|
81
|
+
* `sha256-` followed by 64 lowercase hexadecimal characters (the shape
|
|
82
|
+
* `contentHashSchema` validates).
|
|
83
|
+
*
|
|
84
|
+
* @param dir - Absolute path to the directory to hash.
|
|
85
|
+
*/
|
|
86
|
+
export async function hashDirectory(dir: string): Promise<string> {
|
|
87
|
+
const root = path.resolve(dir);
|
|
88
|
+
const files = (await collectFiles(root)).sort(bytewiseCompare);
|
|
89
|
+
const hash = createHash("sha256");
|
|
90
|
+
|
|
91
|
+
for (const relative of files) {
|
|
92
|
+
const bytes = await fs.readFile(path.join(root, ...relative.split("/")));
|
|
93
|
+
hash.update(Buffer.from(relative, "utf8"));
|
|
94
|
+
hash.update(FIELD_SEPARATOR);
|
|
95
|
+
hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
|
|
96
|
+
hash.update(FIELD_SEPARATOR);
|
|
97
|
+
hash.update(bytes);
|
|
98
|
+
hash.update(FIELD_SEPARATOR);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return `sha256-${hash.digest("hex")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compares two content hashes. Both are canonical lowercase strings, so this is
|
|
106
|
+
* an exact comparison; it exists so callers read as intent rather than as
|
|
107
|
+
* string equality, and so a future multi-algorithm form has one place to land.
|
|
108
|
+
*
|
|
109
|
+
* @param a - The first hash.
|
|
110
|
+
* @param b - The second hash.
|
|
111
|
+
*/
|
|
112
|
+
export function hashesEqual(a: string, b: string): boolean {
|
|
113
|
+
return a === b;
|
|
114
|
+
}
|