@sous-io/sous 0.2.29 → 0.2.31

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 (37) hide show
  1. package/README.md +12 -0
  2. package/docs/markdown/README.md +52 -0
  3. package/docs/markdown/_sidebar.md +2 -0
  4. package/docs/markdown/commands.md +31 -6
  5. package/docs/markdown/repositories-authoring.md +7 -3
  6. package/docs/markdown/repositories-consuming.md +26 -12
  7. package/docs/markdown/repositories-file-formats.md +35 -5
  8. package/docs/markdown/repositories-variables.md +4 -1
  9. package/package.json +2 -1
  10. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/base-command.ts +75 -2
  13. package/src/commands/lock/rebuild.ts +5 -4
  14. package/src/commands/recipe/show.ts +163 -64
  15. package/src/commands/update.ts +178 -0
  16. package/src/lib/project-install.d.mts +4 -0
  17. package/src/lib/project-install.mjs +46 -8
  18. package/src/lib/repos/catalog.ts +43 -55
  19. package/src/lib/repos/declarations.ts +177 -0
  20. package/src/lib/repos/formats/common.ts +14 -0
  21. package/src/lib/repos/formats/index-file.ts +40 -1
  22. package/src/lib/repos/formats/lockfile.ts +5 -3
  23. package/src/lib/repos/formats/recipe-manifest.ts +6 -204
  24. package/src/lib/repos/formats/variable-definition.ts +268 -0
  25. package/src/lib/repos/providers/http.ts +37 -16
  26. package/src/lib/repos/release/index-builder.ts +168 -32
  27. package/src/lib/repos/resolver.ts +53 -12
  28. package/src/lib/repos/subscription-service.ts +93 -11
  29. package/src/lib/repos/update-plan.ts +3 -2
  30. package/src/lib/self-update/index.ts +14 -0
  31. package/src/lib/self-update/installs.ts +412 -0
  32. package/src/lib/self-update/managers.ts +126 -0
  33. package/src/lib/self-update/plan.ts +315 -0
  34. package/src/lib/self-update/registry.ts +99 -0
  35. package/src/lib/self-update/run.ts +506 -0
  36. package/src/lib/self-update/versions.ts +243 -0
  37. package/src/lib/vars/question-plan.ts +44 -21
@@ -8,11 +8,12 @@
8
8
  * This module answers all four.
9
9
  *
10
10
  * Everything here is a pure function over data the caller hands it: the cached
11
- * indexes, the lockfile, and the subscription keys the project declares. A
12
- * recipe's own manifest (its variables, its declared dependencies and the
13
- * content it contributes) lives inside the recipe's files rather than in the
14
- * index, so the caller supplies a reader for it; a recipe whose files are not on
15
- * this machine is described from its index alone rather than being an error.
11
+ * indexes, the lockfile, and the subscription keys the project declares. The
12
+ * content a recipe contributes is known only from its own manifest, inside the
13
+ * recipe's files, so the caller supplies a reader for it; a recipe whose files
14
+ * are not on this machine is described from its index alone rather than being
15
+ * an error, and an index recorded by a current release says how each of its
16
+ * dependencies was declared.
16
17
  *
17
18
  * Refs resolve the way `sous subscribe` resolves them: `namespace`,
18
19
  * `namespace/recipe`, and either of those qualified with `repo:`. A one-word ref
@@ -24,11 +25,7 @@ import semver from "semver";
24
25
  import { ConfigError } from "../errors.js";
25
26
  import type { IndexDependency, IndexFile } from "./formats/index-file.js";
26
27
  import type { Lockfile } from "./formats/lockfile.js";
27
- import type {
28
- ContentKind,
29
- RecipeManifest,
30
- VariableDefinition,
31
- } from "./formats/recipe-manifest.js";
28
+ import type { ContentKind, RecipeManifest } from "./formats/recipe-manifest.js";
32
29
  import {
33
30
  isNamedReading,
34
31
  namespaceOfKey,
@@ -38,7 +35,7 @@ import {
38
35
  type ParsedRef,
39
36
  } from "../refs/parse.js";
40
37
  import { settleDependency } from "../refs/settle.js";
41
- import { bareName } from "../vars/names.js";
38
+ import { declarationFor, type DependencyLists } from "./declarations.js";
42
39
  import {
43
40
  describeReference,
44
41
  findNamespace,
@@ -174,9 +171,13 @@ export type RecipeVersionListing = {
174
171
  export type RecipeDependencyListing = {
175
172
  /** The recipe key the dependency names, `namespace/recipe`. */
176
173
  key: string;
177
- /** The dependency exactly as the recipe's manifest wrote it, when it could be read. */
174
+ /**
175
+ * The manifest entry that brings it in, exactly as written (a recipe, a whole
176
+ * namespace, or a locator), from the index when it records one and from the
177
+ * manifest otherwise.
178
+ */
178
179
  declared?: string;
179
- /** Whether the manifest declared it a build dependency or a co-subscription. */
180
+ /** Whether that entry makes it a build dependency or a co-subscription. */
180
181
  kind?: "depends" | "subscribes";
181
182
  /** The exact version the release resolved it to, when the index records one. */
182
183
  resolvedVersion?: string;
@@ -186,22 +187,6 @@ export type RecipeDependencyListing = {
186
187
  repo?: string;
187
188
  };
188
189
 
189
- /** One variable a recipe asks about. */
190
- export type RecipeVariableListing = {
191
- /** The variable's name, as the manifest declares it. */
192
- name: string;
193
- /** The type of answer it takes. */
194
- type: string;
195
- /** The environment variable an answer is stored under. */
196
- env: string;
197
- /** True when an answer is required before the recipe is usable. */
198
- required: boolean;
199
- /** True when the answer is a secret, which sous never prints. */
200
- secret: boolean;
201
- /** The one-line question it asks. */
202
- prompt: string;
203
- };
204
-
205
190
  /** One content kind a recipe contributes, and where its files would land. */
206
191
  export type RecipeContentListing = {
207
192
  /** The content kind, as the manifest declares it. */
@@ -246,14 +231,18 @@ export type RecipeDetail = {
246
231
  versions: RecipeVersionListing[];
247
232
  /** What the described version depends on, by key. */
248
233
  dependencies: RecipeDependencyListing[];
249
- /** The variables the recipe declares, in manifest order. */
250
- variables: RecipeVariableListing[];
234
+ /**
235
+ * True when the index records the described version's dependencies, even as
236
+ * none at all, so an empty list means "depends on nothing" rather than "not
237
+ * recorded".
238
+ */
239
+ dependenciesRecorded: boolean;
251
240
  /** What the recipe contributes, and where each kind's files land. */
252
241
  contents: RecipeContentListing[];
253
242
  /**
254
243
  * True when the recipe's own manifest could be read. When it is false, the
255
- * variables, the declared dependencies and the contents are unknown rather
256
- * than empty, because the recipe's files are not on this machine.
244
+ * contents are unknown rather than empty, because the recipe's files are not
245
+ * on this machine.
257
246
  */
258
247
  manifestRead: boolean;
259
248
  };
@@ -368,7 +357,7 @@ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail
368
357
  ...(describing === undefined ? {} : { describing }),
369
358
  versions: versionListings(entry.versions, listing.latest, listing.pinned),
370
359
  dependencies: dependencyListings(published?.dependencies, manifest),
371
- variables: (manifest?.variables ?? []).map(variableListing),
360
+ dependenciesRecorded: published?.dependencies !== undefined,
372
361
  contents: (manifest?.contents ?? []).map((content) => ({
373
362
  kind: content.kind,
374
363
  include: [...content.include],
@@ -680,21 +669,39 @@ function dependencyListings(
680
669
  manifest: RecipeManifest | undefined
681
670
  ): RecipeDependencyListing[] {
682
671
  const rows = new Map<string, RecipeDependencyListing>();
672
+ const lists: DependencyLists | undefined =
673
+ manifest === undefined
674
+ ? undefined
675
+ : { depends: manifest.depends ?? [], subscribes: manifest.subscribes ?? [] };
683
676
 
684
677
  for (const [key, entry] of Object.entries(resolved ?? {})) {
678
+ // The index says how the release that published the version saw it. An
679
+ // entry recorded before the index said so is answered from the manifest,
680
+ // by the same rule the release applies.
681
+ const declaration =
682
+ entry.declared !== undefined && entry.kind !== undefined
683
+ ? { declared: entry.declared, kind: entry.kind }
684
+ : lists === undefined
685
+ ? undefined
686
+ : declarationFor(lists, key, resolved);
685
687
  rows.set(key, {
686
688
  key,
687
689
  ...(entry.version === undefined ? {} : { resolvedVersion: entry.version }),
688
690
  ...(entry.range === undefined ? {} : { resolvedRange: entry.range }),
689
691
  ...(entry.repo === undefined ? {} : { repo: entry.repo }),
692
+ ...(declaration ?? {}),
690
693
  });
691
694
  }
692
695
 
696
+ // A manifest entry that covers no row the index recorded is still shown, so a
697
+ // reader sees everything the manifest declares.
698
+ const covered = new Set([...rows.values()].map((row) => row.declared));
693
699
  for (const [kind, declared] of [
694
- ["depends", manifest?.depends ?? []],
695
- ["subscribes", manifest?.subscribes ?? []],
700
+ ["depends", lists?.depends ?? []],
701
+ ["subscribes", lists?.subscribes ?? []],
696
702
  ] as Array<["depends" | "subscribes", string[]]>) {
697
703
  for (const written of declared) {
704
+ if (covered.has(written.trim())) continue;
698
705
  // A dependency that reads more than one way is listed under the key the
699
706
  // index recorded for it. One sous cannot parse or settle is still worth
700
707
  // showing; it is listed under what it was written as, so the reader sees
@@ -715,25 +722,6 @@ function dependencyListings(
715
722
  return [...rows.values()].sort((left, right) => compare(left.key, right.key));
716
723
  }
717
724
 
718
- /**
719
- * One variable row: what it is called, what kind of answer it takes, and the
720
- * environment variable an answer is stored under.
721
- *
722
- * @param definition - The variable definition from the recipe's manifest.
723
- */
724
- function variableListing(definition: VariableDefinition): RecipeVariableListing {
725
- return {
726
- name: definition.name,
727
- type: definition.type,
728
- // The manifest may bind an existing environment variable; otherwise the
729
- // answer is stored under the name sous derives from the variable's own.
730
- env: bareName(definition),
731
- required: definition.required,
732
- secret: definition.secret,
733
- prompt: definition.prompt,
734
- };
735
- }
736
-
737
725
  /**
738
726
  * The repository a match named. Every match comes from this catalog's own
739
727
  * repositories, so the lookup always finds one.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * How one recipe brings in another: which entry of its manifest names it, and
3
+ * whether that entry is a co-subscription or a build dependency.
4
+ *
5
+ * A manifest lists what it brings in under `depends` and `subscribes`, and one
6
+ * recipe can be covered by more than one entry: `subscribes: [workflow]` covers
7
+ * every recipe of the `workflow` namespace, and `depends: [workflow/task-files]`
8
+ * names one of them again. Three places need one answer per recipe, and they
9
+ * must agree: a release recording the answer in the index, `sous recipe show`
10
+ * describing a recipe whose manifest it can read, and the resolver walking a
11
+ * recipe whose manifest it cannot. This module is that one answer, and its
12
+ * reverse.
13
+ */
14
+
15
+ import type { DependencyKind } from "./formats/common.js";
16
+ import type { IndexVersion } from "./formats/index-file.js";
17
+ import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../refs/parse.js";
18
+ import { RefSource } from "../refs/scopes.js";
19
+ import { settleDependency, type RecordedDependencies } from "../refs/settle.js";
20
+
21
+ /** A manifest's two dependency lists, each entry exactly as it was written. */
22
+ export type DependencyLists = {
23
+ /** Build dependencies: fetched and addressable, never in the project's output. */
24
+ depends: string[];
25
+ /** Co-subscriptions: their files land and their questions are asked. */
26
+ subscribes: string[];
27
+ };
28
+
29
+ /** How one recipe is brought in by the manifest that declares it. */
30
+ export type DependencyDeclaration = {
31
+ /**
32
+ * The manifest entry that brings it in, as written. An entry naming the
33
+ * recipe wins over a namespace entry that also covers it; between two
34
+ * entries naming it, the later one (in `depends`, then `subscribes` order)
35
+ * wins, and between two namespace entries, the first.
36
+ */
37
+ declared: string;
38
+ /** `subscribes` when any entry covering the recipe is a co-subscription. */
39
+ kind: DependencyKind;
40
+ };
41
+
42
+ /** A declaration being gathered, entry by entry. */
43
+ export type GatheredDeclaration = DependencyDeclaration & {
44
+ /** True when an entry named the recipe itself rather than its namespace. */
45
+ named: boolean;
46
+ };
47
+
48
+ /** One manifest entry covering a recipe. */
49
+ export type CoveringEntry = {
50
+ /** The entry as written. */
51
+ written: string;
52
+ /** The list it sits in. */
53
+ kind: DependencyKind;
54
+ /** True when it names the recipe itself rather than a namespace holding it. */
55
+ named: boolean;
56
+ };
57
+
58
+ /**
59
+ * Folds one more manifest entry covering a recipe into what is known about the
60
+ * recipe's declaration, by the rule `DependencyDeclaration` states. Every
61
+ * reader applies this one function, entry by entry in manifest order
62
+ * (`depends` first, then `subscribes`).
63
+ *
64
+ * foldDeclaration(undefined, { written: "workflow", kind: "subscribes", named: false })
65
+ * // -> { declared: "workflow", kind: "subscribes", named: false }
66
+ *
67
+ * @param before - What the earlier entries said, when any covered the recipe.
68
+ * @param entry - The next entry covering it.
69
+ */
70
+ export function foldDeclaration(
71
+ before: GatheredDeclaration | undefined,
72
+ entry: CoveringEntry
73
+ ): GatheredDeclaration {
74
+ const keepBefore = before !== undefined && !entry.named;
75
+ return {
76
+ declared: keepBefore ? before.declared : entry.written.trim(),
77
+ kind: before?.kind === "subscribes" || entry.kind === "subscribes" ? "subscribes" : "depends",
78
+ named: entry.named || before?.named === true,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * The declaration that brings one recipe in, or undefined when no entry of the
84
+ * lists covers it. An entry that reads more than one way is settled from what
85
+ * the version's index entry recorded, and one that cannot be settled, or does
86
+ * not parse, covers nothing.
87
+ *
88
+ * declarationFor({ depends: ["workflow/a"], subscribes: ["workflow"] }, "workflow/a")
89
+ * // -> { declared: "workflow/a", kind: "subscribes" }
90
+ *
91
+ * @param lists - The declaring recipe's `depends` and `subscribes` lists.
92
+ * @param key - The recipe being asked about, `namespace/recipe`.
93
+ * @param recorded - What the declaring version's index entry records, for settling.
94
+ */
95
+ export function declarationFor(
96
+ lists: Partial<DependencyLists>,
97
+ key: string,
98
+ recorded?: RecordedDependencies
99
+ ): DependencyDeclaration | undefined {
100
+ let gathered: GatheredDeclaration | undefined;
101
+
102
+ for (const [kind, entries] of [
103
+ ["depends", lists.depends ?? []],
104
+ ["subscribes", lists.subscribes ?? []],
105
+ ] as Array<[DependencyKind, string[]]>) {
106
+ for (const written of entries) {
107
+ let reading;
108
+ try {
109
+ reading = settleDependency(written, recorded === undefined ? {} : { recorded });
110
+ } catch {
111
+ // An entry that does not parse is reported wherever the manifest is
112
+ // validated; here it simply covers nothing.
113
+ continue;
114
+ }
115
+ if (reading === undefined || !isNamedReading(reading)) continue;
116
+
117
+ const named = reading.recipe !== undefined;
118
+ const covers = named ? refKey(reading) === key : namespaceOfKey(key) === reading.namespace;
119
+ if (covers) gathered = foldDeclaration(gathered, { written, kind, named });
120
+ }
121
+ }
122
+
123
+ return gathered === undefined ? undefined : { declared: gathered.declared, kind: gathered.kind };
124
+ }
125
+
126
+ /**
127
+ * The manifest lists a version's index entry records, or undefined when it
128
+ * records neither (a version recorded before the index described recipes).
129
+ * That is what lets the resolver walk a recipe whose files are not on this
130
+ * machine exactly as it would walk its manifest.
131
+ *
132
+ * indexDependencyLists({ subscribes: ["workflow"], depends: [] , ... })
133
+ * // -> { depends: [], subscribes: ["workflow"] }
134
+ *
135
+ * @param version - The version's index entry.
136
+ */
137
+ export function indexDependencyLists(
138
+ version: Pick<IndexVersion, "depends" | "subscribes">
139
+ ): DependencyLists | undefined {
140
+ if (version.depends === undefined && version.subscribes === undefined) return undefined;
141
+ return { depends: [...(version.depends ?? [])], subscribes: [...(version.subscribes ?? [])] };
142
+ }
143
+
144
+ /**
145
+ * Says how a declaration brings a recipe in, the way a person would: a whole
146
+ * namespace is named as one, and anything else is shown as it was written.
147
+ *
148
+ * describeDeclaration("workflow") // -> "the whole 'workflow' namespace"
149
+ * describeDeclaration("workflow/task-files") // -> "workflow/task-files"
150
+ *
151
+ * @param declared - The manifest entry, as written.
152
+ */
153
+ export function describeDeclaration(declared: string): string {
154
+ try {
155
+ const readings = parseRef(declared, RefSource.Manifest);
156
+ const only = readings[0]!;
157
+ if (readings.length === 1 && isNamedReading(only) && only.recipe === undefined) {
158
+ return only.location === undefined
159
+ ? `the whole '${only.namespace}' namespace`
160
+ : `the whole '${only.namespace}' namespace of ${only.location.identity}`;
161
+ }
162
+ } catch {
163
+ // Shown as it was written; the reader sees the bad entry.
164
+ }
165
+ return declared;
166
+ }
167
+
168
+ /**
169
+ * Plain-language wording for a dependency kind.
170
+ *
171
+ * @param kind - The kind, when it is known.
172
+ */
173
+ export function describeDependencyKind(kind: DependencyKind | undefined): string {
174
+ if (kind === "depends") return "build dependency";
175
+ if (kind === "subscribes") return "co-subscription";
176
+ return "not recorded";
177
+ }
@@ -161,6 +161,20 @@ export const recipeKeySchema = z
161
161
  "(for example 'workflow/task-files')"
162
162
  );
163
163
 
164
+ /**
165
+ * The two ways one recipe can bring in another, named after the manifest lists
166
+ * that declare them: `subscribes` is a co-subscription, whose files land in the
167
+ * project and whose questions are asked; `depends` is a build dependency, a
168
+ * library whose files never reach the project's output.
169
+ */
170
+ export const DEPENDENCY_KINDS = ["subscribes", "depends"] as const;
171
+
172
+ /** A co-subscription or a build dependency. */
173
+ export const dependencyKindSchema = z.enum(DEPENDENCY_KINDS);
174
+
175
+ /** A co-subscription or a build dependency. */
176
+ export type DependencyKind = (typeof DEPENDENCY_KINDS)[number];
177
+
164
178
  // --- Value primitives ---------------------------------------------------------------------------
165
179
 
166
180
  /** An exact semantic version, as published by a recipe. */
@@ -18,6 +18,7 @@
18
18
  import { z } from "zod";
19
19
  import {
20
20
  contentHashSchema,
21
+ dependencyKindSchema,
21
22
  formatVersionSchema,
22
23
  forwardCompatibleObject,
23
24
  isoTimestampSchema,
@@ -31,6 +32,7 @@ import {
31
32
  semverVersionSchema,
32
33
  stableJsonStringify,
33
34
  } from "./common.js";
35
+ import { publishedVariableDefinitionSchema } from "./variable-definition.js";
34
36
  import { namespaceOfKey } from "../../refs/parse.js";
35
37
 
36
38
  /**
@@ -55,6 +57,22 @@ export const indexDependencySchema = forwardCompatibleObject({
55
57
  * this same repository.
56
58
  */
57
59
  repo: repoIdentitySchema.optional(),
60
+ /**
61
+ * The entry of the version's manifest that brings this dependency in, exactly
62
+ * as it was written: a recipe (`workflow/task-files`, with its range when it
63
+ * has one), a whole namespace of this repository (`workflow`), or a locator
64
+ * naming a recipe in another repository. When several entries cover the same
65
+ * recipe, the one naming it wins over a namespace. Absent on an entry recorded
66
+ * before sous described recipes in the index (ADR 0010).
67
+ */
68
+ declared: z.string().min(1, "must not be empty").optional(),
69
+ /**
70
+ * Whether that brings it in as a co-subscription (`subscribes`: its files
71
+ * land in the project and its questions are asked) or as a build dependency
72
+ * (`depends`: a library only); `subscribes` when any entry covering it is a
73
+ * co-subscription. Recorded alongside `declared`.
74
+ */
75
+ kind: dependencyKindSchema.optional(),
58
76
  }).refine((entry) => entry.version !== undefined || entry.range !== undefined, {
59
77
  message:
60
78
  "must record either the exact version this dependency resolved to or the range " +
@@ -93,9 +111,30 @@ export const indexVersionSchema = forwardCompatibleObject({
93
111
  * published version means one thing forever.
94
112
  *
95
113
  * The field is additive: an index written before it existed still parses, and
96
- * a consumer that finds no entry falls back to the manifest's ranges.
114
+ * a consumer that finds no entry falls back to the manifest's ranges. A
115
+ * version described in full (ADR 0010) carries this field even when it is
116
+ * empty, so "depends on nothing" and "not recorded" read differently.
97
117
  */
98
118
  dependencies: z.record(recipeKeySchema, indexDependencySchema).optional(),
119
+ /**
120
+ * The variable definitions this version's manifest publishes, in manifest
121
+ * order: the questions subscribing to it asks. An empty list means it asks
122
+ * none; an absent field means the release that recorded the version did not
123
+ * record them (a release made before ADR 0010), and only the recipe's own
124
+ * files can say.
125
+ */
126
+ variables: z.array(publishedVariableDefinitionSchema).optional(),
127
+ /**
128
+ * The version's manifest `depends` and `subscribes` lists, exactly as
129
+ * written, so a consumer walks a recipe whose files it has not fetched the
130
+ * same way it walks its manifest: a namespace still expands to what the
131
+ * namespace holds, including a namespace of another repository, which no
132
+ * key of `dependencies` can record. Written, even when empty, beside
133
+ * `variables`; absent on a version recorded before ADR 0010. Kept as plain
134
+ * strings, so a ref form a later sous accepts never breaks this reader.
135
+ */
136
+ depends: z.array(z.string().min(1, "must not be empty")).optional(),
137
+ subscribes: z.array(z.string().min(1, "must not be empty")).optional(),
99
138
  });
100
139
 
101
140
  /** One recipe, with every version the repo publishes of it. */
@@ -30,13 +30,15 @@ import {
30
30
  repoUrlSchema,
31
31
  semverVersionSchema,
32
32
  stableJsonStringify,
33
+ DEPENDENCY_KINDS,
34
+ type DependencyKind,
33
35
  } from "./common.js";
34
36
 
35
37
  /** The literal `requestedBy` holder meaning "the project subscribed to this directly". */
36
38
  export const PROJECT_HOLDER = "project";
37
39
 
38
- /** How a locked recipe entered the project. */
39
- export const LOCK_KINDS = ["subscribes", "depends"] as const;
40
+ /** How a locked recipe entered the project: the dependency kinds every format shares. */
41
+ export const LOCK_KINDS = DEPENDENCY_KINDS;
40
42
 
41
43
  /** One repo the project resolves against. */
42
44
  export const lockedRepoSchema = z
@@ -138,7 +140,7 @@ export type LockedRepo = z.infer<typeof lockedRepoSchema>;
138
140
  export type LockedRecipe = z.infer<typeof lockedRecipeSchema>;
139
141
 
140
142
  /** How a locked recipe entered the project. */
141
- export type LockKind = (typeof LOCK_KINDS)[number];
143
+ export type LockKind = DependencyKind;
142
144
 
143
145
  /**
144
146
  * Validates a parsed lockfile, throwing a ConfigError that names the file and