@sous-io/sous 0.2.28 → 0.2.30

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.
Files changed (52) hide show
  1. package/docs/markdown/_sidebar.md +2 -0
  2. package/docs/markdown/commands.md +10 -6
  3. package/docs/markdown/repositories-authoring.md +19 -11
  4. package/docs/markdown/repositories-consuming.md +34 -13
  5. package/docs/markdown/repositories-file-formats.md +88 -13
  6. package/docs/markdown/repositories-providers.md +8 -6
  7. package/docs/markdown/repositories-troubleshooting.md +6 -0
  8. package/docs/markdown/repositories-variables.md +13 -3
  9. package/docs/markdown/repositories.md +3 -3
  10. package/package.json +1 -1
  11. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  12. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  13. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  14. package/src/commands/lock/rebuild.ts +7 -6
  15. package/src/commands/recipe/show.ts +163 -64
  16. package/src/commands/repo/contribute.ts +1 -10
  17. package/src/commands/repo/gc.ts +3 -2
  18. package/src/commands/repo/release.ts +20 -3
  19. package/src/commands/repo/search.ts +2 -1
  20. package/src/commands/vars/ask.ts +32 -10
  21. package/src/lib/refs/find.ts +202 -21
  22. package/src/lib/refs/index.ts +2 -0
  23. package/src/lib/refs/parse.ts +844 -0
  24. package/src/lib/refs/scopes.ts +34 -0
  25. package/src/lib/refs/settle.ts +219 -0
  26. package/src/lib/repos/catalog.ts +87 -80
  27. package/src/lib/repos/declarations.ts +177 -0
  28. package/src/lib/repos/formats/common.ts +14 -0
  29. package/src/lib/repos/formats/index-file.ts +42 -2
  30. package/src/lib/repos/formats/lockfile.ts +5 -3
  31. package/src/lib/repos/formats/recipe-manifest.ts +16 -212
  32. package/src/lib/repos/formats/variable-definition.ts +268 -0
  33. package/src/lib/repos/index.ts +0 -1
  34. package/src/lib/repos/lock-service.ts +3 -2
  35. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  36. package/src/lib/repos/locked-recipes.ts +2 -2
  37. package/src/lib/repos/namespace-resolver.ts +40 -47
  38. package/src/lib/repos/providers/base.ts +30 -0
  39. package/src/lib/repos/providers/github.ts +22 -0
  40. package/src/lib/repos/providers/gitlab.ts +68 -0
  41. package/src/lib/repos/providers/provider.ts +96 -0
  42. package/src/lib/repos/release/index-builder.ts +221 -47
  43. package/src/lib/repos/release/index.ts +1 -0
  44. package/src/lib/repos/release/plan.ts +8 -6
  45. package/src/lib/repos/release/settle.ts +237 -0
  46. package/src/lib/repos/release/tags.ts +3 -7
  47. package/src/lib/repos/resolver.ts +251 -53
  48. package/src/lib/repos/subscription-service.ts +355 -49
  49. package/src/lib/repos/update-plan.ts +3 -2
  50. package/src/lib/vars/mappings.ts +17 -30
  51. package/src/lib/vars/question-plan.ts +44 -21
  52. package/src/lib/repos/ref.ts +0 -513
@@ -24,7 +24,8 @@
24
24
  import path from "node:path";
25
25
  import semver from "semver";
26
26
  import { hashDirectory } from "../store/hash.js";
27
- import { parseDependencyRef } from "../ref.js";
27
+ import { isNamedReading, parseRef, splitRecipeKey } from "../../refs/parse.js";
28
+ import { RefSource } from "../../refs/scopes.js";
28
29
  import type { RunOptions } from "../providers/git.js";
29
30
  import { listRecipeTags, tagFor, withTaggedTree, type RecipeTag } from "./tags.js";
30
31
  import { nextVersion, type BumpLevel } from "./bump.js";
@@ -343,13 +344,14 @@ export function siblingKeysOf(validation: RepoValidation, key: string): string[]
343
344
 
344
345
  const keys: string[] = [];
345
346
  for (const written of declared) {
346
- let parsed;
347
+ let readings;
347
348
  try {
348
- parsed = parseDependencyRef(written);
349
+ readings = parseRef(written, RefSource.Manifest);
349
350
  } catch {
350
351
  continue;
351
352
  }
352
- if (parsed.kind !== "sibling") continue;
353
+ const parsed = readings[0]!;
354
+ if (!isNamedReading(parsed) || parsed.location !== undefined) continue;
353
355
 
354
356
  if (parsed.recipe !== undefined) {
355
357
  keys.push(`${parsed.namespace}/${parsed.recipe}`);
@@ -502,8 +504,8 @@ function coveredByScope(recipe: ValidatedRecipe, scope: ReleaseScope): boolean {
502
504
 
503
505
  /** The tag that publishes one version of one recipe key. */
504
506
  function tagOf(key: string, version: string): string {
505
- const slash = key.indexOf("/");
506
- return tagFor(key.slice(0, slash), key.slice(slash + 1), version);
507
+ const { namespace, name } = splitRecipeKey(key);
508
+ return tagFor(namespace, name, version);
507
509
  }
508
510
 
509
511
  /** Renders an absolute path as a repository-relative one, with forward slashes. */
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Settling, at release time, the dependencies that read 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:
6
+ *
7
+ * - a GitLab URL with nested groups, which does not say where the project path
8
+ * ends (`gitlab.com/a/b/c/d` may be project `a/b` with namespace `c` and
9
+ * recipe `d`, or project `a/b/c` with namespace `d`);
10
+ * - a browser URL copied from a host's file view, which names a folder whose
11
+ * recipe only that repository's index can say.
12
+ *
13
+ * `sous repo release` settles each of them once, by fetching the index of every
14
+ * candidate repository through the provider layer and keeping the reading whose
15
+ * index publishes what was named. What it settled on is recorded in the index
16
+ * beside the dependency's key, so a consumer reads the answer and never probes.
17
+ *
18
+ * A network failure fails the release: a release never falls through to the
19
+ * next reading, because the reading it fell through to could be the wrong one.
20
+ * Two readings that both publish what is named are a genuine tie, and an error
21
+ * naming the spellings that read one way (the `/*` form for the namespace).
22
+ */
23
+
24
+ import { parseIndexFile, type IndexFile } from "../formats/index-file.js";
25
+ import { INDEX_FILENAME } from "../formats/common.js";
26
+ import {
27
+ builtInProviders,
28
+ providerById,
29
+ type ProviderOptions,
30
+ type RepoProvider,
31
+ } from "../providers/index.js";
32
+ import {
33
+ formatRef,
34
+ isBrowsedReading,
35
+ isNamedReading,
36
+ parseRef,
37
+ refKey,
38
+ type ParsedRef,
39
+ type RefLocation,
40
+ type RefReading,
41
+ } from "../../refs/parse.js";
42
+ import { settleInIndex } from "../../refs/settle.js";
43
+ import { RefSource } from "../../refs/scopes.js";
44
+ import { describeError, type RepoValidation, type ValidationProblem } from "./validate.js";
45
+
46
+ /** What one dependency settled on. */
47
+ export type SettledDependency = {
48
+ /** The canonical identity of the repository publishing it. */
49
+ identity: string;
50
+ /**
51
+ * The recipe keys it reaches in that repository: one for a recipe, and every
52
+ * recipe the namespace publishes for a namespace.
53
+ */
54
+ keys: string[];
55
+ /** The range the manifest wrote, when it wrote one. */
56
+ range?: string;
57
+ };
58
+
59
+ /** What settling found. */
60
+ export type SettleResult = {
61
+ /** Each settled dependency, keyed by the dependency exactly as written (trimmed). */
62
+ settled: Map<string, SettledDependency>;
63
+ /** Everything that could not be settled; every one of them is an error. */
64
+ problems: ValidationProblem[];
65
+ };
66
+
67
+ /** How settling reaches the network. */
68
+ export type SettleOptions = {
69
+ /** The providers to fetch through. Defaults to the built-ins. */
70
+ providers?: RepoProvider[];
71
+ /** Testing seams handed to every index fetch. */
72
+ providerOptions?: ProviderOptions;
73
+ };
74
+
75
+ /**
76
+ * True when a dependency's readings need an index to settle: it reads more than
77
+ * one way, or it names a browser path.
78
+ *
79
+ * @param readings - Every reading of the dependency.
80
+ */
81
+ export function needsSettling(readings: RefReading[]): boolean {
82
+ return readings.length > 1 || readings.some(isBrowsedReading);
83
+ }
84
+
85
+ /**
86
+ * Settles every dependency in a repository that reads more than one way.
87
+ * Dependencies that read one way are left alone, and nothing is fetched for
88
+ * them, so a repository that uses none of these forms releases offline as
89
+ * before.
90
+ *
91
+ * @param validation - The validated repository.
92
+ * @param options - The providers, and testing seams.
93
+ */
94
+ export async function settleDependencyLocations(
95
+ validation: RepoValidation,
96
+ options: SettleOptions = {}
97
+ ): Promise<SettleResult> {
98
+ const providers = options.providers ?? builtInProviders();
99
+ const settled = new Map<string, SettledDependency>();
100
+ const problems: ValidationProblem[] = [];
101
+ const indexes = new Map<string, Promise<IndexFile>>();
102
+
103
+ const indexOf = (location: RefLocation): Promise<IndexFile> => {
104
+ let pending = indexes.get(location.identity);
105
+ if (pending === undefined) {
106
+ pending = fetchIndexAt(location, providers, options.providerOptions ?? {});
107
+ indexes.set(location.identity, pending);
108
+ }
109
+ return pending;
110
+ };
111
+
112
+ for (const recipe of validation.recipes) {
113
+ const declared = [...(recipe.manifest.depends ?? []), ...(recipe.manifest.subscribes ?? [])];
114
+ for (const written of declared) {
115
+ const trimmed = written.trim();
116
+ if (settled.has(trimmed)) continue;
117
+
118
+ let readings: RefReading[];
119
+ try {
120
+ readings = parseRef(trimmed, RefSource.Manifest, providers);
121
+ } catch {
122
+ // Validation already reported a dependency that does not parse.
123
+ continue;
124
+ }
125
+ if (!needsSettling(readings)) continue;
126
+
127
+ const where = `${recipe.path} ('${trimmed}')`;
128
+ try {
129
+ settled.set(trimmed, await settleOne(trimmed, readings, indexOf, providers));
130
+ } catch (error) {
131
+ problems.push({ level: "error", where, message: describeError(error) });
132
+ }
133
+ }
134
+ }
135
+
136
+ return { settled, problems };
137
+ }
138
+
139
+ /**
140
+ * Settles one dependency: fetches the index behind each reading, and keeps the
141
+ * one reading that publishes what it names.
142
+ *
143
+ * @param written - The dependency as written.
144
+ * @param readings - Every reading of it.
145
+ * @param indexOf - Fetches (once) the index at a location.
146
+ * @param providers - The providers, for formatting.
147
+ */
148
+ async function settleOne(
149
+ written: string,
150
+ readings: RefReading[],
151
+ indexOf: (location: RefLocation) => Promise<IndexFile>,
152
+ providers: RepoProvider[]
153
+ ): Promise<SettledDependency> {
154
+ const found: Array<{ reading: RefReading; settled: ParsedRef[]; index: IndexFile }> = [];
155
+
156
+ for (const reading of readings) {
157
+ const location = reading.location;
158
+ /* c8 ignore next */
159
+ if (location === undefined) continue;
160
+
161
+ let index: IndexFile;
162
+ try {
163
+ index = await indexOf(location);
164
+ } catch (error) {
165
+ throw new Error(
166
+ `the dependency reads ${readings.length === 1 ? "as a folder in" : "as something in"} ` +
167
+ `the repository at ${location.url}, and its index could not be read, so the release ` +
168
+ `cannot settle what it means. A release never guesses past an index it could not ` +
169
+ `read.\n ${describeError(error)}`
170
+ );
171
+ }
172
+
173
+ const settled = settleInIndex(reading, {
174
+ namespaces: Object.keys(index.namespaces),
175
+ recipes: index.recipes,
176
+ });
177
+ if (settled.length > 0) found.push({ reading, settled, index });
178
+ }
179
+
180
+ if (found.length === 0) {
181
+ throw new Error(
182
+ `no repository it could name publishes what it names. It was read as:\n` +
183
+ readings.map((reading) => ` ${formatRef(reading, providers)}`).join("\n")
184
+ );
185
+ }
186
+
187
+ const choices = found.flatMap((entry) => entry.settled);
188
+ if (choices.length > 1) {
189
+ throw new Error(
190
+ `it names more than one thing, and each of these publishes it:\n` +
191
+ choices.map((choice) => ` ${formatRef(choice, providers)}`).join("\n") +
192
+ `\n Write the one you mean. A namespace is written with '/*' after it, as in ` +
193
+ `'${spelledOut(choices.find((choice) => choice.recipe === undefined) ?? choices[0]!, providers)}', ` +
194
+ `and a recipe as its canonical locator above.`
195
+ );
196
+ }
197
+
198
+ const { index } = found[0]!;
199
+ const choice = choices[0]!;
200
+ const keys =
201
+ choice.recipe === undefined
202
+ ? Object.keys(index.recipes)
203
+ .filter((key) => key.startsWith(`${choice.namespace}/`))
204
+ .sort()
205
+ : [refKey(choice)];
206
+ const range = "range" in found[0]!.reading ? found[0]!.reading.range : undefined;
207
+ return {
208
+ identity: choice.location!.identity,
209
+ keys,
210
+ ...(range === undefined ? {} : { range }),
211
+ };
212
+ }
213
+
214
+ /** A reading spelled so it reads one way: a namespace with `/*` after it. */
215
+ function spelledOut(reading: ParsedRef, providers: RepoProvider[]): string {
216
+ const written = formatRef(reading, providers);
217
+ return reading.recipe === undefined && isNamedReading(reading) ? `${written}/*` : written;
218
+ }
219
+
220
+ /**
221
+ * Fetches and validates the index at a location, through its provider.
222
+ *
223
+ * @param location - Where the repository lives.
224
+ * @param providers - The providers to fetch through.
225
+ * @param options - Testing seams.
226
+ */
227
+ async function fetchIndexAt(
228
+ location: RefLocation,
229
+ providers: RepoProvider[],
230
+ options: ProviderOptions
231
+ ): Promise<IndexFile> {
232
+ const provider = providerById(location.provider, providers);
233
+ /* c8 ignore next */
234
+ if (provider === undefined) throw new Error(`sous has no '${location.provider}' provider.`);
235
+ const fetched = await provider.fetchIndex(provider.canonicalize(location.url), options);
236
+ return parseIndexFile(JSON.parse(fetched.text), `${location.url}/${INDEX_FILENAME}`);
237
+ }
@@ -16,6 +16,7 @@ import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { runGit, type RunOptions } from "../providers/git.js";
18
18
  import { RECIPE_KEY_PATTERN } from "../formats/patterns.js";
19
+ import { splitRecipeKey } from "../../refs/parse.js";
19
20
 
20
21
  /** One release tag, taken apart into the recipe it publishes. */
21
22
  export type RecipeTag = {
@@ -60,13 +61,8 @@ export function parseRecipeTag(tag: string): RecipeTag | undefined {
60
61
  const version = tag.slice(at + 1);
61
62
  if (!RECIPE_KEY_PATTERN.test(key)) return undefined;
62
63
 
63
- const slash = key.indexOf("/");
64
- return {
65
- tag,
66
- namespace: key.slice(0, slash),
67
- name: key.slice(slash + 1),
68
- version,
69
- };
64
+ const { namespace, name } = splitRecipeKey(key);
65
+ return { tag, namespace, name, version };
70
66
  }
71
67
 
72
68
  /**