@sous-io/sous 0.2.32 → 0.2.34
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 +7 -6
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -4
- package/docs/markdown/configuration.md +28 -4
- package/docs/markdown/repositories-authoring.md +22 -2
- package/docs/markdown/repositories-consuming.md +40 -11
- package/docs/markdown/repositories-file-formats.md +28 -13
- package/docs/markdown/repositories-quickstart.md +1 -1
- package/docs/markdown/repositories-variables.md +16 -0
- package/docs/markdown/repositories.md +8 -4
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +27 -15
- package/src/commands/lock/rebuild.ts +3 -6
- package/src/commands/namespace/show.ts +2 -2
- package/src/commands/recipe/show.ts +26 -9
- package/src/commands/repo/contribute.ts +32 -23
- package/src/commands/repo/gc.ts +1 -1
- package/src/commands/repo/remove.ts +1 -1
- package/src/commands/repo/search.ts +1 -1
- package/src/commands/vars/ask.ts +75 -54
- package/src/lib/build-service.ts +93 -15
- package/src/lib/config-schema.ts +59 -19
- package/src/lib/include-resolver.ts +307 -69
- package/src/lib/markdown-compiler.ts +289 -82
- package/src/lib/project-scaffold/templates.ts +19 -11
- package/src/lib/repos/catalog-inputs.ts +1 -1
- package/src/lib/repos/catalog.ts +98 -72
- package/src/lib/repos/declarations.ts +22 -12
- package/src/lib/repos/formats/index-file.ts +1 -1
- package/src/lib/repos/formats/recipe-manifest.ts +3 -4
- package/src/lib/repos/lock-service.ts +1 -1
- package/src/lib/repos/locked-namespace-resolver.ts +14 -6
- package/src/lib/repos/locked-recipe-lookup.ts +123 -0
- package/src/lib/repos/locked-recipes.ts +1 -1
- package/src/lib/repos/memories-hash-name.ts +33 -0
- package/src/lib/repos/namespace-resolver.ts +198 -119
- package/src/lib/repos/recipe-config-layers.ts +29 -1
- package/src/lib/repos/recipe-key-matcher.ts +62 -0
- package/src/lib/repos/recipe-memories.ts +175 -0
- package/src/lib/repos/recipe-targets.ts +32 -28
- package/src/lib/repos/ref-search.ts +1 -1
- package/src/lib/repos/release/index-builder.ts +23 -15
- package/src/lib/repos/release/plan.ts +14 -7
- package/src/lib/repos/release/settle.ts +38 -121
- package/src/lib/repos/release/submit-checkout.ts +42 -23
- package/src/lib/repos/release/tags.ts +1 -1
- package/src/lib/repos/resolver.ts +90 -72
- package/src/lib/repos/subscription-service.ts +192 -170
- package/src/lib/settings.ts +37 -33
- package/src/lib/vars/answers.ts +9 -1
- package/src/lib/vars/ask.ts +3 -3
- package/src/lib/vars/display.ts +1 -3
- package/src/lib/vars/index.ts +1 -0
- package/src/lib/vars/mappings.ts +17 -9
- package/src/lib/vars/secrets.ts +131 -0
- package/src/services/ref-resolver/container.ts +107 -0
- package/src/services/ref-resolver/dependency.ts +60 -0
- package/src/services/ref-resolver/format.ts +197 -0
- package/src/services/ref-resolver/glob.ts +115 -0
- package/src/services/ref-resolver/hash-names.ts +155 -0
- package/src/services/ref-resolver/index.ts +55 -0
- package/src/services/ref-resolver/injectable.ts +36 -0
- package/src/services/ref-resolver/location.ts +62 -0
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
- package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
- package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
- package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
- package/src/services/ref-resolver/named-refs.ts +144 -0
- package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
- package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
- package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
- package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
- package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
- package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
- package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
- package/src/services/ref-resolver/parts.ts +115 -0
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
- package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
- package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
- package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
- package/src/services/ref-resolver/ref-helpers.ts +62 -0
- package/src/services/ref-resolver/ref-picker-service.ts +204 -0
- package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
- package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
- package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
- package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
- package/src/services/ref-resolver/source.ts +48 -0
- package/src/services/ref-resolver/tokens.ts +25 -0
- package/src/services/ref-resolver/types.ts +155 -0
- package/src/templating/init-liquid-engine.ts +33 -18
- package/src/templating/lib/secret-scope.ts +41 -0
- package/src/templating/tags/exportScalarVarsJs.ts +5 -2
- package/src/templating/tags/showVars.ts +8 -2
- package/src/lib/refs/find.ts +0 -770
- package/src/lib/refs/index.ts +0 -14
- package/src/lib/refs/parse.ts +0 -844
- package/src/lib/refs/pick.ts +0 -147
- package/src/lib/refs/scopes.ts +0 -95
- package/src/lib/refs/settle.ts +0 -219
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SousRef } from "../types.js";
|
|
2
|
+
import { CatalogMatcher, type CatalogRepo } from "./catalog-matcher.js";
|
|
3
|
+
import type { RefLookup, RefMatch } from "./ref-lookup.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Answers repositories, namespaces and recipes from a catalog a caller has
|
|
7
|
+
* already built (which repositories it knows, and what each publishes). It
|
|
8
|
+
* reads nothing, fetches nothing and never fails.
|
|
9
|
+
*/
|
|
10
|
+
export class CatalogLookup implements RefLookup {
|
|
11
|
+
private readonly matcher: CatalogMatcher;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param repos - The repositories to search, in the order their matches are listed.
|
|
15
|
+
*/
|
|
16
|
+
constructor(repos: CatalogRepo[]) {
|
|
17
|
+
this.matcher = new CatalogMatcher(repos);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async find(candidate: SousRef): Promise<RefMatch[]> {
|
|
21
|
+
return this.matcher.match(candidate);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching repositories, namespaces and recipes against a catalog of what is
|
|
3
|
+
* published. Every lookup that knows repositories (cached indexes, fetched
|
|
4
|
+
* indexes, the definitions in play) builds a catalog and asks this.
|
|
5
|
+
*
|
|
6
|
+
* Names are matched the same way everywhere: the exact spelling first, then
|
|
7
|
+
* ignoring case, and a name may be a glob pattern. A match is returned in the
|
|
8
|
+
* spelling the catalog publishes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { IndexFile } from "../../../lib/repos/formats/index-file.js";
|
|
12
|
+
import { matchName, type NameMatch } from "../glob.js";
|
|
13
|
+
import { refKey } from "../format.js";
|
|
14
|
+
import type { NamespaceRef, RecipeRef, RefLocation, RepoRef, SousRef } from "../types.js";
|
|
15
|
+
import type { RefMatch } from "./ref-lookup.js";
|
|
16
|
+
|
|
17
|
+
/** One repository, and what it publishes. */
|
|
18
|
+
export type CatalogRepo = {
|
|
19
|
+
/** The short name this project calls it, when it has one. */
|
|
20
|
+
name?: string;
|
|
21
|
+
/** Where it lives, which is how a ref written as a location finds it. */
|
|
22
|
+
location?: RefLocation;
|
|
23
|
+
/** Every namespace it publishes. */
|
|
24
|
+
namespaces: Array<{ name: string; description?: string }>;
|
|
25
|
+
/** Every recipe it publishes. */
|
|
26
|
+
recipes: Array<{ namespace: string; name: string; description?: string; path?: string }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The catalog entry for one repository's index.
|
|
31
|
+
*
|
|
32
|
+
* @param index - The repository's index.
|
|
33
|
+
* @param repo - What the project knows of the repository: its short name and where it lives.
|
|
34
|
+
*/
|
|
35
|
+
export function catalogRepoOfIndex(
|
|
36
|
+
index: IndexFile,
|
|
37
|
+
repo: { name?: string; location?: RefLocation } = {}
|
|
38
|
+
): CatalogRepo {
|
|
39
|
+
return {
|
|
40
|
+
...(repo.name === undefined ? {} : { name: repo.name }),
|
|
41
|
+
...(repo.location === undefined ? {} : { location: repo.location }),
|
|
42
|
+
namespaces: Object.entries(index.namespaces).map(([namespace, declared]) => ({
|
|
43
|
+
name: namespace,
|
|
44
|
+
...(declared?.description === undefined ? {} : { description: declared.description }),
|
|
45
|
+
})),
|
|
46
|
+
recipes: Object.entries(index.recipes).map(([key, recipe]) => {
|
|
47
|
+
const slash = key.indexOf("/");
|
|
48
|
+
return {
|
|
49
|
+
namespace: key.slice(0, slash),
|
|
50
|
+
name: key.slice(slash + 1),
|
|
51
|
+
path: recipe.path,
|
|
52
|
+
...(recipe.description === undefined ? {} : { description: recipe.description }),
|
|
53
|
+
};
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The weaker of two matches: one folded part makes the whole thing folded. */
|
|
59
|
+
function both(left: NameMatch, right: NameMatch): NameMatch {
|
|
60
|
+
if (left === undefined || right === undefined) return undefined;
|
|
61
|
+
return left === "folded" || right === "folded" ? "folded" : "exact";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Matches refs against a catalog, repository by repository, in catalog order. */
|
|
65
|
+
export class CatalogMatcher {
|
|
66
|
+
/**
|
|
67
|
+
* @param repos - The repositories to search, in the order their matches are listed.
|
|
68
|
+
*/
|
|
69
|
+
constructor(private readonly repos: CatalogRepo[]) {}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every repository, namespace or recipe in the catalog a candidate names.
|
|
73
|
+
* Candidates of any other kind match nothing.
|
|
74
|
+
*
|
|
75
|
+
* @param candidate - One reading of a written ref.
|
|
76
|
+
*/
|
|
77
|
+
match(candidate: SousRef): RefMatch[] {
|
|
78
|
+
const found: Array<RefMatch & { repoIndex: number }> = [];
|
|
79
|
+
|
|
80
|
+
this.repos.forEach((repo, repoIndex) => {
|
|
81
|
+
const add = (ref: SousRef, spelling: NameMatch): void => {
|
|
82
|
+
if (spelling === undefined) return;
|
|
83
|
+
found.push({ ref, exactSpelling: spelling === "exact", repoIndex });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
switch (candidate.kind) {
|
|
87
|
+
case "repo": {
|
|
88
|
+
if (candidate.browsed !== undefined) {
|
|
89
|
+
for (const settled of this.settleBrowsed(candidate, repo)) add(settled, "exact");
|
|
90
|
+
} else {
|
|
91
|
+
add(...this.matchRepo(candidate, repo));
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "namespace": {
|
|
96
|
+
const qualifier = this.qualifies(candidate.repo, repo);
|
|
97
|
+
for (const namespace of repo.namespaces) {
|
|
98
|
+
const spelling = both(qualifier, matchName(candidate.name, namespace.name));
|
|
99
|
+
if (spelling === undefined) continue;
|
|
100
|
+
add(
|
|
101
|
+
this.carry(candidate, {
|
|
102
|
+
kind: "namespace",
|
|
103
|
+
name: namespace.name,
|
|
104
|
+
repo: this.knownRepo(repo),
|
|
105
|
+
...(namespace.description === undefined ? {} : { description: namespace.description }),
|
|
106
|
+
}),
|
|
107
|
+
spelling
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "recipe": {
|
|
113
|
+
const qualifier = this.qualifies(candidate.namespace?.repo ?? candidate.repo, repo);
|
|
114
|
+
for (const recipe of repo.recipes) {
|
|
115
|
+
const inNamespace =
|
|
116
|
+
candidate.namespace === undefined
|
|
117
|
+
? "exact"
|
|
118
|
+
: matchName(candidate.namespace.name, recipe.namespace);
|
|
119
|
+
const spelling = both(
|
|
120
|
+
both(qualifier, inNamespace),
|
|
121
|
+
matchName(candidate.name, recipe.name)
|
|
122
|
+
);
|
|
123
|
+
if (spelling === undefined) continue;
|
|
124
|
+
add(this.carry(candidate, this.knownRecipe(repo, recipe)), spelling);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return found
|
|
134
|
+
.sort((left, right) =>
|
|
135
|
+
left.repoIndex !== right.repoIndex
|
|
136
|
+
? left.repoIndex - right.repoIndex
|
|
137
|
+
: refKey(left.ref) < refKey(right.ref)
|
|
138
|
+
? -1
|
|
139
|
+
: refKey(left.ref) > refKey(right.ref)
|
|
140
|
+
? 1
|
|
141
|
+
: 0
|
|
142
|
+
)
|
|
143
|
+
.map(({ ref, exactSpelling }) => ({ ref, exactSpelling }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How a repository qualifier matches one catalog repository (no qualifier matches all). */
|
|
147
|
+
private qualifies(qualifier: RepoRef | undefined, repo: CatalogRepo): NameMatch {
|
|
148
|
+
if (qualifier === undefined) return "exact";
|
|
149
|
+
let result: NameMatch = "exact";
|
|
150
|
+
if (qualifier.name !== undefined) {
|
|
151
|
+
if (repo.name === undefined) return undefined;
|
|
152
|
+
result = matchName(qualifier.name, repo.name);
|
|
153
|
+
if (result === undefined) return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (qualifier.location !== undefined) {
|
|
156
|
+
if (repo.location?.identity !== qualifier.location.identity) return undefined;
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The repository ref a catalog repository is known as. */
|
|
162
|
+
private knownRepo(repo: CatalogRepo, revision?: string): RepoRef {
|
|
163
|
+
return {
|
|
164
|
+
kind: "repo",
|
|
165
|
+
...(repo.name === undefined ? {} : { name: repo.name }),
|
|
166
|
+
...(repo.location === undefined ? {} : { location: repo.location }),
|
|
167
|
+
...(revision === undefined ? {} : { revision }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The recipe ref a catalog recipe is known as, parents and all. */
|
|
172
|
+
private knownRecipe(repo: CatalogRepo, recipe: CatalogRepo["recipes"][number], revision?: string): RecipeRef {
|
|
173
|
+
const namespace: NamespaceRef = {
|
|
174
|
+
kind: "namespace",
|
|
175
|
+
name: recipe.namespace,
|
|
176
|
+
repo: this.knownRepo(repo, revision),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
kind: "recipe",
|
|
180
|
+
name: recipe.name,
|
|
181
|
+
namespace,
|
|
182
|
+
...(recipe.description === undefined ? {} : { description: recipe.description }),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a candidate carries over to the known ref it matched: its range and query values. */
|
|
187
|
+
private carry(candidate: SousRef, known: SousRef): SousRef {
|
|
188
|
+
return {
|
|
189
|
+
...known,
|
|
190
|
+
...(candidate.kind === "recipe" && candidate.range !== undefined
|
|
191
|
+
? { range: candidate.range }
|
|
192
|
+
: {}),
|
|
193
|
+
...(candidate.vars === undefined ? {} : { vars: candidate.vars }),
|
|
194
|
+
} as SousRef;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A repository ref, named by short name or by location. */
|
|
198
|
+
private matchRepo(candidate: RepoRef, repo: CatalogRepo): [SousRef, NameMatch] {
|
|
199
|
+
const known = this.knownRepo(repo);
|
|
200
|
+
if (candidate.name === undefined && candidate.location === undefined) return [known, undefined];
|
|
201
|
+
return [
|
|
202
|
+
{ ...known, ...(candidate.vars === undefined ? {} : { vars: candidate.vars }) },
|
|
203
|
+
this.qualifies(candidate, repo),
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* What a browser path names in one repository, through the folder each
|
|
209
|
+
* recipe lives in. The path still carries the branch in front of it (which
|
|
210
|
+
* may itself hold slashes), so every split is tried: the folder of a recipe,
|
|
211
|
+
* or anything inside it, names that recipe; a folder holding recipes of
|
|
212
|
+
* exactly one namespace names that namespace.
|
|
213
|
+
*
|
|
214
|
+
* settleBrowsed("main/recipes/workflow/alpha/SKILL.md")
|
|
215
|
+
* // -> the recipe workflow/alpha, with the revision "main"
|
|
216
|
+
*/
|
|
217
|
+
private settleBrowsed(candidate: RepoRef, repo: CatalogRepo): SousRef[] {
|
|
218
|
+
if (candidate.location === undefined || repo.location?.identity !== candidate.location.identity) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const segments = (candidate.browsed ?? "").split("/").filter((segment) => segment.length > 0);
|
|
222
|
+
const recipes = repo.recipes
|
|
223
|
+
.map((recipe) => ({ recipe, path: (recipe.path ?? "").replace(/^\.?\/+|\/+$/g, "") }))
|
|
224
|
+
.filter((entry) => entry.path.length > 0);
|
|
225
|
+
|
|
226
|
+
const range = candidate.range === undefined ? {} : { range: candidate.range };
|
|
227
|
+
const found = new Map<string, SousRef>();
|
|
228
|
+
|
|
229
|
+
// A recipe's folder, or a file inside it. The deepest recipe wins, so a
|
|
230
|
+
// recipe nested inside another's folder is named by its own path.
|
|
231
|
+
for (let start = 1; start < segments.length; start++) {
|
|
232
|
+
const folder = segments.slice(start).join("/");
|
|
233
|
+
const holding = recipes
|
|
234
|
+
.filter((entry) => folder === entry.path || folder.startsWith(`${entry.path}/`))
|
|
235
|
+
.sort((left, right) => right.path.length - left.path.length);
|
|
236
|
+
const deepest = holding[0];
|
|
237
|
+
if (deepest === undefined) continue;
|
|
238
|
+
const revision = segments.slice(0, start).join("/");
|
|
239
|
+
found.set(`${deepest.recipe.namespace}/${deepest.recipe.name}`, {
|
|
240
|
+
...this.knownRecipe(repo, deepest.recipe, revision),
|
|
241
|
+
...range,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (found.size > 0) return [...found.values()];
|
|
245
|
+
|
|
246
|
+
// A folder above recipes: a namespace, when everything under it is one.
|
|
247
|
+
for (let start = 1; start < segments.length; start++) {
|
|
248
|
+
const folder = segments.slice(start).join("/");
|
|
249
|
+
const under = recipes.filter((entry) => entry.path.startsWith(`${folder}/`));
|
|
250
|
+
const namespaces = new Set(under.map((entry) => entry.recipe.namespace));
|
|
251
|
+
if (namespaces.size !== 1) continue;
|
|
252
|
+
const name = [...namespaces][0]!;
|
|
253
|
+
found.set(name, {
|
|
254
|
+
kind: "namespace",
|
|
255
|
+
name,
|
|
256
|
+
repo: this.knownRepo(repo, segments.slice(0, start).join("/")),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return [...found.values()];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SousRef } from "../types.js";
|
|
2
|
+
import type { RefLookup, RefMatch } from "./ref-lookup.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asks several lookups in order. The first one that answers a candidate with a
|
|
6
|
+
* non-empty list wins and the rest are never asked, so a cheap, authoritative
|
|
7
|
+
* source (a release's record) can sit in front of a broad one (every cached
|
|
8
|
+
* index). A lookup that throws stops the chain: a failure is never skipped.
|
|
9
|
+
*/
|
|
10
|
+
export class ChainedLookup implements RefLookup {
|
|
11
|
+
private readonly lookups: RefLookup[];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param lookups - The lookups to ask, most authoritative first.
|
|
15
|
+
*/
|
|
16
|
+
constructor(...lookups: RefLookup[]) {
|
|
17
|
+
this.lookups = lookups;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async find(candidate: SousRef): Promise<RefMatch[]> {
|
|
21
|
+
for (const lookup of this.lookups) {
|
|
22
|
+
const matches = await lookup.find(candidate);
|
|
23
|
+
if (matches.length > 0) return matches;
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DefinedVariable } from "../../../lib/vars/definition-source.js";
|
|
2
|
+
import { variableCandidates, type LadderContext } from "../../../lib/vars/ladder.js";
|
|
3
|
+
import { bareName } from "../../../lib/vars/names.js";
|
|
4
|
+
import type { SousRef, VariableRef } from "../types.js";
|
|
5
|
+
import type { RefLookup, RefMatch } from "./ref-lookup.js";
|
|
6
|
+
import { variableRefOf } from "./variable-lookup.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Answers an environment variable name with the variables it answers.
|
|
10
|
+
*
|
|
11
|
+
* A name is held whole and compared in its exact spelling only, because a
|
|
12
|
+
* variable (`apiUrl`) and an environment variable name can differ only in case
|
|
13
|
+
* and the spelling typed is the one meant. The names searched are the one each
|
|
14
|
+
* definition declares (a recipe may bind an existing variable such as
|
|
15
|
+
* `GITHUB_TOKEN`) plus every generated name on the resolution ladder that is
|
|
16
|
+
* actually in use in `.sous/.env` or `.sous/.env.local`; generated names are
|
|
17
|
+
* only searched when in use, because every variable generates several and a
|
|
18
|
+
* project would otherwise be told that a name nothing has ever set names one of
|
|
19
|
+
* its variables.
|
|
20
|
+
*/
|
|
21
|
+
export class EnvVarLookup implements RefLookup {
|
|
22
|
+
private readonly byName = new Map<string, VariableRef[]>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param variables - The variable definitions in play.
|
|
26
|
+
* @param ladder - The environment layers, which decide which generated names are in use.
|
|
27
|
+
*/
|
|
28
|
+
constructor(variables: DefinedVariable[], ladder?: LadderContext) {
|
|
29
|
+
const inUse =
|
|
30
|
+
ladder === undefined
|
|
31
|
+
? new Set<string>()
|
|
32
|
+
: new Set([...Object.keys(ladder.localEnv), ...Object.keys(ladder.sharedEnv)]);
|
|
33
|
+
|
|
34
|
+
for (const defined of variables) {
|
|
35
|
+
const names = new Set<string>([bareName(defined.definition)]);
|
|
36
|
+
if (ladder !== undefined) {
|
|
37
|
+
for (const candidate of variableCandidates(defined, ladder)) {
|
|
38
|
+
if (inUse.has(candidate.envName)) names.add(candidate.envName);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const answered = this.byName.get(name) ?? [];
|
|
43
|
+
answered.push(variableRefOf(defined));
|
|
44
|
+
this.byName.set(name, answered);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async find(candidate: SousRef): Promise<RefMatch[]> {
|
|
50
|
+
if (candidate.kind !== "envVar") return [];
|
|
51
|
+
const variables = this.byName.get(candidate.name);
|
|
52
|
+
if (variables === undefined) return [];
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
ref: {
|
|
56
|
+
kind: "envVar",
|
|
57
|
+
name: candidate.name,
|
|
58
|
+
variables,
|
|
59
|
+
...(candidate.vars === undefined ? {} : { vars: candidate.vars }),
|
|
60
|
+
},
|
|
61
|
+
exactSpelling: true,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settling, at release time, a dependency that reads more than one way.
|
|
3
|
+
*
|
|
4
|
+
* Most dependencies say exactly what they mean: `workflow/alpha`, or
|
|
5
|
+
* `github://owner/repo/workflow/alpha`. Two kinds do not: a GitLab URL with
|
|
6
|
+
* nested groups, which does not say where the project path ends, and a browser
|
|
7
|
+
* URL copied from a host's file view, which names a folder whose recipe only
|
|
8
|
+
* that repository's index can say.
|
|
9
|
+
*
|
|
10
|
+
* `sous repo release` settles each of them once, by fetching the index of every
|
|
11
|
+
* candidate repository through the provider layer and keeping the reading whose
|
|
12
|
+
* index publishes what was named. A network failure fails the release: a
|
|
13
|
+
* release never falls through to the next reading, because the reading it fell
|
|
14
|
+
* through to could be the wrong one. Two readings that both publish what is
|
|
15
|
+
* named are a genuine tie, and an error naming the spelling that reads one way.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ConfigError } from "../../../lib/errors.js";
|
|
19
|
+
import { INDEX_FILENAME } from "../../../lib/repos/formats/common.js";
|
|
20
|
+
import { parseIndexFile, type IndexFile } from "../../../lib/repos/formats/index-file.js";
|
|
21
|
+
import {
|
|
22
|
+
builtInProviders,
|
|
23
|
+
providerById,
|
|
24
|
+
type ProviderOptions,
|
|
25
|
+
type RepoProvider,
|
|
26
|
+
} from "../../../lib/repos/providers/index.js";
|
|
27
|
+
import { formatRef } from "../format.js";
|
|
28
|
+
import { locationOf } from "../parts.js";
|
|
29
|
+
import type { RefLocation, SousRef } from "../types.js";
|
|
30
|
+
import { CatalogMatcher, catalogRepoOfIndex } from "./catalog-matcher.js";
|
|
31
|
+
import type { RefLookup, RefMatch } from "./ref-lookup.js";
|
|
32
|
+
|
|
33
|
+
/** Fetches the index of the repository at a location. */
|
|
34
|
+
export type IndexFetcher = (location: RefLocation) => Promise<IndexFile>;
|
|
35
|
+
|
|
36
|
+
/** What a `FetchedIndexLookup` may be given. */
|
|
37
|
+
export type FetchedIndexLookupOptions = {
|
|
38
|
+
/** The providers to fetch through. Defaults to the built-ins. */
|
|
39
|
+
providers?: RepoProvider[];
|
|
40
|
+
/** Testing seams handed to every index fetch by the default fetcher. */
|
|
41
|
+
providerOptions?: ProviderOptions;
|
|
42
|
+
/** Replaces the default fetcher, which goes through the provider layer. */
|
|
43
|
+
fetchIndex?: IndexFetcher;
|
|
44
|
+
/** What the ref being settled is called in an error, such as "dependency". Defaults to "ref". */
|
|
45
|
+
subject?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A failure, in a sentence. */
|
|
49
|
+
function describe(error: unknown): string {
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Answers a located candidate by fetching the index of the repository it names
|
|
55
|
+
* (once per repository) and matching the candidate against it. A candidate
|
|
56
|
+
* with no location names no repository to fetch from and matches nothing.
|
|
57
|
+
*/
|
|
58
|
+
export class FetchedIndexLookup implements RefLookup {
|
|
59
|
+
private readonly providers: RepoProvider[];
|
|
60
|
+
private readonly fetcher: IndexFetcher;
|
|
61
|
+
private readonly indexes = new Map<string, Promise<IndexFile>>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param options - The providers and testing seams, or a replacement fetcher.
|
|
65
|
+
*/
|
|
66
|
+
constructor(private readonly options: FetchedIndexLookupOptions = {}) {
|
|
67
|
+
this.providers = options.providers ?? builtInProviders();
|
|
68
|
+
this.fetcher = options.fetchIndex ?? ((location) => this.fetchThroughProvider(location));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async find(candidate: SousRef): Promise<RefMatch[]> {
|
|
72
|
+
const location = locationOf(candidate);
|
|
73
|
+
if (location === undefined) return [];
|
|
74
|
+
|
|
75
|
+
let index: IndexFile;
|
|
76
|
+
try {
|
|
77
|
+
index = await this.indexAt(location);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const subject = this.options.subject ?? "ref";
|
|
80
|
+
const folder = candidate.kind === "repo" && candidate.browsed !== undefined;
|
|
81
|
+
throw new ConfigError(
|
|
82
|
+
`the ${subject} reads ${folder ? "as a folder in" : "as something in"} the repository ` +
|
|
83
|
+
`at ${location.url}, and its index could not be read, so the release cannot settle ` +
|
|
84
|
+
`what it means. A release never guesses past an index it could not read.\n` +
|
|
85
|
+
` ${describe(error)}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return new CatalogMatcher([catalogRepoOfIndex(index, { location })]).match(candidate);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one reading of a written ref that the fetched indexes confirm: every
|
|
94
|
+
* reading is looked up, and exactly one may publish what it names.
|
|
95
|
+
*
|
|
96
|
+
* @param candidates - Every reading of the written ref.
|
|
97
|
+
* @returns The one match.
|
|
98
|
+
* @throws A ConfigError when no repository publishes it, when several do (naming the
|
|
99
|
+
* `/*` spelling that reads one way), or when an index could not be fetched.
|
|
100
|
+
*/
|
|
101
|
+
async settle(candidates: SousRef[]): Promise<RefMatch> {
|
|
102
|
+
const choices: RefMatch[] = [];
|
|
103
|
+
for (const candidate of candidates) choices.push(...(await this.find(candidate)));
|
|
104
|
+
|
|
105
|
+
if (choices.length === 0) {
|
|
106
|
+
throw new ConfigError(
|
|
107
|
+
`no repository it could name publishes what it names. It was read as:\n` +
|
|
108
|
+
candidates.map((candidate) => ` ${formatRef(candidate, this.providers)}`).join("\n")
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (choices.length > 1) {
|
|
112
|
+
const namespace = choices.find((choice) => choice.ref.kind === "namespace") ?? choices[0]!;
|
|
113
|
+
const written = formatRef(namespace.ref, this.providers);
|
|
114
|
+
throw new ConfigError(
|
|
115
|
+
`it names more than one thing, and each of these publishes it:\n` +
|
|
116
|
+
choices.map((choice) => ` ${formatRef(choice.ref, this.providers)}`).join("\n") +
|
|
117
|
+
`\n Write the one you mean. A namespace is written with '/*' after it, as in ` +
|
|
118
|
+
`'${namespace.ref.kind === "namespace" ? `${written}/*` : written}', and a recipe as ` +
|
|
119
|
+
`its canonical locator above.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return choices[0]!;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The index at a location, fetched once per repository.
|
|
127
|
+
*
|
|
128
|
+
* @param location - Where the repository lives.
|
|
129
|
+
*/
|
|
130
|
+
indexAt(location: RefLocation): Promise<IndexFile> {
|
|
131
|
+
let pending = this.indexes.get(location.identity);
|
|
132
|
+
if (pending === undefined) {
|
|
133
|
+
pending = this.fetcher(location);
|
|
134
|
+
this.indexes.set(location.identity, pending);
|
|
135
|
+
}
|
|
136
|
+
return pending;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fetches and validates the index at a location, through its provider. */
|
|
140
|
+
private async fetchThroughProvider(location: RefLocation): Promise<IndexFile> {
|
|
141
|
+
const provider = providerById(location.provider, this.providers);
|
|
142
|
+
/* c8 ignore next */
|
|
143
|
+
if (provider === undefined) throw new Error(`sous has no '${location.provider}' provider.`);
|
|
144
|
+
const fetched = await provider.fetchIndex(
|
|
145
|
+
provider.canonicalize(location.url),
|
|
146
|
+
this.options.providerOptions ?? {}
|
|
147
|
+
);
|
|
148
|
+
return parseIndexFile(JSON.parse(fetched.text), `${location.url}/${INDEX_FILENAME}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { locationOf } from "../parts.js";
|
|
2
|
+
import type { SousRef } from "../types.js";
|
|
3
|
+
import type { RefLookup, RefMatch } from "./ref-lookup.js";
|
|
4
|
+
|
|
5
|
+
/** What a repository's index recorded about one dependency, as far as settling needs. */
|
|
6
|
+
export type RecordedDependencies = Record<string, { repo?: string } | undefined>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether what a version's index entry recorded confirms a candidate reading
|
|
10
|
+
* of one of its dependencies. This is the rule `RecordedDependencyLookup`
|
|
11
|
+
* answers with, for callers that settle a dependency without waiting.
|
|
12
|
+
*
|
|
13
|
+
* @param candidate - One reading of the dependency as the manifest wrote it.
|
|
14
|
+
* @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
|
|
15
|
+
*/
|
|
16
|
+
export function isConfirmedByRecord(
|
|
17
|
+
candidate: SousRef,
|
|
18
|
+
recorded: RecordedDependencies | undefined
|
|
19
|
+
): boolean {
|
|
20
|
+
const identity = locationOf(candidate)?.identity;
|
|
21
|
+
const records = Object.entries(recorded ?? {}).filter(([, record]) =>
|
|
22
|
+
identity === undefined ? record?.repo === undefined : record?.repo === identity
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
switch (candidate.kind) {
|
|
26
|
+
case "repo":
|
|
27
|
+
return candidate.browsed !== undefined && identity !== undefined && records.length > 0;
|
|
28
|
+
case "namespace":
|
|
29
|
+
return records.some(([key]) => key.startsWith(`${candidate.name}/`));
|
|
30
|
+
case "recipe":
|
|
31
|
+
return (
|
|
32
|
+
candidate.namespace !== undefined &&
|
|
33
|
+
records.some(([key]) => key === `${candidate.namespace!.name}/${candidate.name}`)
|
|
34
|
+
);
|
|
35
|
+
default:
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Answers from what a published version's index entry recorded about its
|
|
42
|
+
* dependencies, and never searches.
|
|
43
|
+
*
|
|
44
|
+
* A release settles a dependency that reads more than one way (a GitLab URL
|
|
45
|
+
* with nested groups, a browser URL) and records the repository it settled on
|
|
46
|
+
* beside each key it resolved; a consumer reads that record and never probes.
|
|
47
|
+
* A candidate is confirmed when a record says so: a namespace by a record
|
|
48
|
+
* under a key inside it, a recipe by a record under its own key, a browser
|
|
49
|
+
* path by any record carrying its repository. A candidate that names no
|
|
50
|
+
* location is confirmed by a record that names none, which is a sibling in the
|
|
51
|
+
* declaring recipe's own repository.
|
|
52
|
+
*/
|
|
53
|
+
export class RecordedDependencyLookup implements RefLookup {
|
|
54
|
+
/**
|
|
55
|
+
* @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
|
|
56
|
+
*/
|
|
57
|
+
constructor(private readonly recorded: RecordedDependencies | undefined) {}
|
|
58
|
+
|
|
59
|
+
async find(candidate: SousRef): Promise<RefMatch[]> {
|
|
60
|
+
return isConfirmedByRecord(candidate, this.recorded) ? [{ ref: candidate, exactSpelling: true }] : [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lookups: what exists, as a queryable object rather than a list.
|
|
3
|
+
*
|
|
4
|
+
* A caller that knows what exists (the cached indexes, a release's recorded
|
|
5
|
+
* dependencies, the variable definitions in play) hands the resolver a lookup,
|
|
6
|
+
* and the candidate readings narrow to the ones that are actually available.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SousRef } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/** One thing a candidate reading turned out to name. */
|
|
12
|
+
export type RefMatch = {
|
|
13
|
+
/**
|
|
14
|
+
* The known ref, spelled the way it is published (not the way it was
|
|
15
|
+
* typed), with every parent it has.
|
|
16
|
+
*/
|
|
17
|
+
ref: SousRef;
|
|
18
|
+
/**
|
|
19
|
+
* True when every part of the candidate matched in exactly the case it was
|
|
20
|
+
* written. A lookup returns case-insensitive matches too; the resolver keeps
|
|
21
|
+
* them only when nothing matched exactly.
|
|
22
|
+
*/
|
|
23
|
+
exactSpelling: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Answers what a candidate reading names.
|
|
28
|
+
*
|
|
29
|
+
* `find` returns an empty array when nothing known matches the candidate, and
|
|
30
|
+
* throws when the lookup could not find out (an index that could not be
|
|
31
|
+
* fetched): not finding an answer and failing to look are different things.
|
|
32
|
+
*/
|
|
33
|
+
export interface RefLookup {
|
|
34
|
+
/**
|
|
35
|
+
* Every known ref a candidate names.
|
|
36
|
+
*
|
|
37
|
+
* @param candidate - One reading of a written ref.
|
|
38
|
+
*/
|
|
39
|
+
find(candidate: SousRef): Promise<RefMatch[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A lookup that can also answer without waiting, for a caller whose own
|
|
44
|
+
* interface is synchronous (a template engine resolving a path). It answers
|
|
45
|
+
* exactly what `find` answers.
|
|
46
|
+
*/
|
|
47
|
+
export interface SyncRefLookup extends RefLookup {
|
|
48
|
+
/**
|
|
49
|
+
* Every known ref a candidate names, at once.
|
|
50
|
+
*
|
|
51
|
+
* @param candidate - One reading of a written ref.
|
|
52
|
+
*/
|
|
53
|
+
findSync(candidate: SousRef): RefMatch[];
|
|
54
|
+
}
|