@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.
Files changed (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Browsing what a project trusts.
3
+ *
4
+ * Every trusted repository publishes an index, and the project's lockfile
5
+ * records what it currently pins. Put those two together and a project can be
6
+ * asked four questions without downloading anything: which namespaces exist,
7
+ * what is in one of them, which recipes exist, and everything about one recipe.
8
+ * This module answers all four.
9
+ *
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.
16
+ *
17
+ * Refs resolve the way `sous subscribe` resolves them: `namespace`,
18
+ * `namespace/recipe`, and either of those qualified with `repo:`. A one-word ref
19
+ * is searched across every trusted repository, and a word that means more than
20
+ * one thing is an error listing what it could have meant.
21
+ */
22
+
23
+ import semver from "semver";
24
+ import { ConfigError } from "../errors.js";
25
+ import type { IndexDependency, IndexFile } from "./formats/index-file.js";
26
+ import type { Lockfile } from "./formats/lockfile.js";
27
+ import type {
28
+ ContentKind,
29
+ RecipeManifest,
30
+ VariableDefinition,
31
+ } from "./formats/recipe-manifest.js";
32
+ import { dependencyRefKey, parseDependencyRef, parseRef, type ParsedRef } from "./ref.js";
33
+ import { bareName } from "../vars/names.js";
34
+ import {
35
+ describeReference,
36
+ findNamespace,
37
+ findRecipe,
38
+ referenceReposFromIndexes,
39
+ type ReferenceContext,
40
+ type ReferenceMatch,
41
+ } from "../refs/index.js";
42
+
43
+ // --- What the catalog reads ---------------------------------------------------------------------
44
+
45
+ /** One trusted repository whose index has been fetched. */
46
+ export type CatalogRepo = {
47
+ /** The short name this project calls it. */
48
+ name: string;
49
+ /** Where it lives, as the project's config records it. */
50
+ url?: string;
51
+ /** Its cached index. */
52
+ index: IndexFile;
53
+ };
54
+
55
+ /** Where one published recipe's files would be read from. */
56
+ export type RecipeLocation = {
57
+ /** The recipe key, `namespace/recipe`. */
58
+ key: string;
59
+ namespace: string;
60
+ name: string;
61
+ /** The short name of the repository publishing it. */
62
+ repo: string;
63
+ /** The version being described. */
64
+ version: string;
65
+ /** The recipe folder, relative to the repository root. */
66
+ path: string;
67
+ };
68
+
69
+ /** Everything the catalog functions read. */
70
+ export type CatalogInputs = {
71
+ /**
72
+ * The trusted repositories with a readable index, in the order a one-word ref
73
+ * searches them: the built-in repository first, then the ones the config
74
+ * names, in config order.
75
+ */
76
+ repos: CatalogRepo[];
77
+ /** The project's lockfile, which is where a pinned version comes from. */
78
+ lock: Lockfile;
79
+ /** The ref keys the project subscribes to: namespaces, and `namespace/recipe`. */
80
+ subscriptions: string[];
81
+ /**
82
+ * Reads one published recipe's manifest, when its files are on this machine.
83
+ * Returning undefined means "not available", and the recipe is described from
84
+ * its index alone.
85
+ */
86
+ readManifest?: (recipe: RecipeLocation) => RecipeManifest | undefined;
87
+ /** Where one content kind's files land in this project, when anywhere does. */
88
+ destinationsFor?: (kind: ContentKind) => string[];
89
+ };
90
+
91
+ // --- What the catalog answers -------------------------------------------------------------------
92
+
93
+ /** How much of a namespace a project subscribes to. */
94
+ export type NamespaceCoverage = "whole namespace" | "some recipes" | "none";
95
+
96
+ /** One namespace, as the listing shows it. */
97
+ export type NamespaceListing = {
98
+ /** The short name of the repository publishing it. */
99
+ repo: string;
100
+ /** The namespace name. */
101
+ namespace: string;
102
+ /** The namespace's one-paragraph summary, when its index carries one. */
103
+ description?: string;
104
+ /** How many recipes the repository publishes in it. */
105
+ recipeCount: number;
106
+ /** How much of it this project subscribes to. */
107
+ subscribed: NamespaceCoverage;
108
+ };
109
+
110
+ /** One recipe, as a listing shows it. */
111
+ export type RecipeListing = {
112
+ /** The recipe key, `namespace/recipe`. */
113
+ key: string;
114
+ namespace: string;
115
+ name: string;
116
+ /** The short name of the repository publishing it. */
117
+ repo: string;
118
+ /** The highest published version, prereleases considered only when nothing else is published. */
119
+ latest?: string;
120
+ /** The version this project's lockfile pins, when it pins one. */
121
+ pinned?: string;
122
+ /** True when the project subscribes to this recipe, or to the whole namespace holding it. */
123
+ subscribed: boolean;
124
+ /** The recipe's one-paragraph summary, when its index carries one. */
125
+ description?: string;
126
+ };
127
+
128
+ /** One namespace in full: the namespace itself, and every recipe in it. */
129
+ export type NamespaceDetail = {
130
+ repo: string;
131
+ /** Where the repository lives, as the project's config records it. */
132
+ repoUrl?: string;
133
+ namespace: string;
134
+ description?: string;
135
+ subscribed: NamespaceCoverage;
136
+ /** Every recipe the repository publishes in the namespace, by key. */
137
+ recipes: RecipeListing[];
138
+ };
139
+
140
+ /** What one published version is to this project. */
141
+ export type VersionStatus = "latest" | "pinned" | "latest and pinned" | "other";
142
+
143
+ /** One published version of one recipe. */
144
+ export type RecipeVersionListing = {
145
+ /** The exact version. */
146
+ version: string;
147
+ /** What the version is to this project. */
148
+ status: VersionStatus;
149
+ /** True when the index marks it a prerelease. */
150
+ prerelease: boolean;
151
+ /** When it was released, when the index records it. */
152
+ releasedAt?: string;
153
+ };
154
+
155
+ /** One dependency of the version being described. */
156
+ export type RecipeDependencyListing = {
157
+ /** The recipe key the dependency names, `namespace/recipe`. */
158
+ key: string;
159
+ /** The dependency exactly as the recipe's manifest wrote it, when it could be read. */
160
+ declared?: string;
161
+ /** Whether the manifest declared it a build dependency or a co-subscription. */
162
+ kind?: "depends" | "subscribes";
163
+ /** The exact version the release resolved it to, when the index records one. */
164
+ resolvedVersion?: string;
165
+ /** The range the index recorded instead, when it could not resolve an exact version. */
166
+ resolvedRange?: string;
167
+ /** The canonical identity of the repository publishing it, for a cross-repository dependency. */
168
+ repo?: string;
169
+ };
170
+
171
+ /** One variable a recipe asks about. */
172
+ export type RecipeVariableListing = {
173
+ /** The variable's name, as the manifest declares it. */
174
+ name: string;
175
+ /** The type of answer it takes. */
176
+ type: string;
177
+ /** The environment variable an answer is stored under. */
178
+ env: string;
179
+ /** True when an answer is required before the recipe is usable. */
180
+ required: boolean;
181
+ /** True when the answer is a secret, which sous never prints. */
182
+ secret: boolean;
183
+ /** The one-line question it asks. */
184
+ prompt: string;
185
+ };
186
+
187
+ /** One content kind a recipe contributes, and where its files would land. */
188
+ export type RecipeContentListing = {
189
+ /** The content kind, as the manifest declares it. */
190
+ kind: ContentKind;
191
+ /** The glob patterns the manifest includes. */
192
+ include: string[];
193
+ /** Every directory in this project the files would be written into. */
194
+ destinations: string[];
195
+ };
196
+
197
+ /** One recipe in full. */
198
+ export type RecipeDetail = {
199
+ /** The recipe key, `namespace/recipe`. */
200
+ key: string;
201
+ namespace: string;
202
+ name: string;
203
+ /** The short name of the repository publishing it. */
204
+ repo: string;
205
+ /** Where that repository lives, as the project's config records it. */
206
+ repoUrl?: string;
207
+ /** The recipe's one-paragraph summary, when its index carries one. */
208
+ description?: string;
209
+ /** The recipe folder, relative to the repository root. */
210
+ path: string;
211
+ /** The highest published version. */
212
+ latest?: string;
213
+ /** The version this project's lockfile pins, when it pins one. */
214
+ pinned?: string;
215
+ /** True when the project subscribes to this recipe, or to the whole namespace holding it. */
216
+ subscribed: boolean;
217
+ /**
218
+ * The version everything else here describes: the pinned one when the project
219
+ * pins one, and the latest published one otherwise.
220
+ */
221
+ describing?: string;
222
+ /** Every published version, newest first. */
223
+ versions: RecipeVersionListing[];
224
+ /** What the described version depends on, by key. */
225
+ dependencies: RecipeDependencyListing[];
226
+ /** The variables the recipe declares, in manifest order. */
227
+ variables: RecipeVariableListing[];
228
+ /** What the recipe contributes, and where each kind's files land. */
229
+ contents: RecipeContentListing[];
230
+ /**
231
+ * True when the recipe's own manifest could be read. When it is false, the
232
+ * variables, the declared dependencies and the contents are unknown rather
233
+ * than empty, because the recipe's files are not on this machine.
234
+ */
235
+ manifestRead: boolean;
236
+ };
237
+
238
+ // --- Listings -----------------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Every namespace every trusted repository publishes, sorted by namespace and
242
+ * then by repository, so two repositories publishing the same namespace name sit
243
+ * beside each other.
244
+ *
245
+ * @param inputs - The cached indexes, the lockfile and the project's subscriptions.
246
+ */
247
+ export function listNamespaces(inputs: CatalogInputs): NamespaceListing[] {
248
+ const subscriptions = new Set(inputs.subscriptions);
249
+ const listings: NamespaceListing[] = [];
250
+
251
+ for (const repo of inputs.repos) {
252
+ for (const [namespace, declared] of Object.entries(repo.index.namespaces)) {
253
+ listings.push({
254
+ repo: repo.name,
255
+ namespace,
256
+ ...(declared.description === undefined ? {} : { description: declared.description }),
257
+ recipeCount: recipeKeysIn(repo.index, namespace).length,
258
+ subscribed: coverageOf(namespace, repo.index, subscriptions),
259
+ });
260
+ }
261
+ }
262
+
263
+ return listings.sort(byNamespaceThenRepo);
264
+ }
265
+
266
+ /**
267
+ * Every recipe every trusted repository publishes, sorted by key and then by
268
+ * repository.
269
+ *
270
+ * @param inputs - The cached indexes, the lockfile and the project's subscriptions.
271
+ */
272
+ export function listRecipes(inputs: CatalogInputs): RecipeListing[] {
273
+ const subscriptions = new Set(inputs.subscriptions);
274
+ const listings: RecipeListing[] = [];
275
+
276
+ for (const repo of inputs.repos) {
277
+ for (const key of Object.keys(repo.index.recipes)) {
278
+ listings.push(recipeListing(key, repo, inputs.lock, subscriptions));
279
+ }
280
+ }
281
+
282
+ return listings.sort((left, right) =>
283
+ left.key === right.key
284
+ ? compare(left.repo, right.repo)
285
+ : compare(left.key, right.key)
286
+ );
287
+ }
288
+
289
+ /**
290
+ * One namespace in full: what the repository says about it, and every recipe it
291
+ * publishes in it.
292
+ *
293
+ * @param inputs - The cached indexes, the lockfile and the project's subscriptions.
294
+ * @param ref - The namespace, optionally qualified with `repo:`.
295
+ */
296
+ export function describeNamespace(inputs: CatalogInputs, ref: string): NamespaceDetail {
297
+ const found = resolveNamespaceRef(inputs, ref);
298
+ const subscriptions = new Set(inputs.subscriptions);
299
+ const declared = found.repo.index.namespaces[found.namespace]!;
300
+
301
+ return {
302
+ repo: found.repo.name,
303
+ ...(found.repo.url === undefined ? {} : { repoUrl: found.repo.url }),
304
+ namespace: found.namespace,
305
+ ...(declared.description === undefined ? {} : { description: declared.description }),
306
+ subscribed: coverageOf(found.namespace, found.repo.index, subscriptions),
307
+ recipes: recipeKeysIn(found.repo.index, found.namespace).map((key) =>
308
+ recipeListing(key, found.repo, inputs.lock, subscriptions)
309
+ ),
310
+ };
311
+ }
312
+
313
+ /**
314
+ * One recipe in full: its identity, every version it publishes, what it depends
315
+ * on, what it asks about, and where its files would land in this project.
316
+ *
317
+ * @param inputs - The cached indexes, the lockfile, the subscriptions and the manifest reader.
318
+ * @param ref - The recipe, as `namespace/recipe`, a bare recipe name, or either qualified with `repo:`.
319
+ */
320
+ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail {
321
+ const found = resolveRecipeRef(inputs, ref);
322
+ const subscriptions = new Set(inputs.subscriptions);
323
+ const listing = recipeListing(found.key, found.repo, inputs.lock, subscriptions);
324
+ const entry = found.repo.index.recipes[found.key]!;
325
+
326
+ const describing = listing.pinned ?? listing.latest;
327
+ const published = entry.versions[describing ?? ""];
328
+
329
+ const manifest =
330
+ describing === undefined || inputs.readManifest === undefined
331
+ ? undefined
332
+ : inputs.readManifest({
333
+ key: found.key,
334
+ namespace: found.namespace,
335
+ name: found.name,
336
+ repo: found.repo.name,
337
+ version: describing,
338
+ path: entry.path,
339
+ });
340
+
341
+ return {
342
+ ...listing,
343
+ ...(found.repo.url === undefined ? {} : { repoUrl: found.repo.url }),
344
+ path: entry.path,
345
+ ...(describing === undefined ? {} : { describing }),
346
+ versions: versionListings(entry.versions, listing.latest, listing.pinned),
347
+ dependencies: dependencyListings(published?.dependencies, manifest),
348
+ variables: (manifest?.variables ?? []).map(variableListing),
349
+ contents: (manifest?.contents ?? []).map((content) => ({
350
+ kind: content.kind,
351
+ include: [...content.include],
352
+ destinations: inputs.destinationsFor?.(content.kind) ?? [],
353
+ })),
354
+ manifestRead: manifest !== undefined,
355
+ };
356
+ }
357
+
358
+ // --- Resolving a ref ----------------------------------------------------------------------------
359
+
360
+ /** One namespace a ref resolved to. */
361
+ export type ResolvedNamespace = {
362
+ /** The repository publishing it. */
363
+ repo: CatalogRepo;
364
+ /** The namespace name. */
365
+ namespace: string;
366
+ };
367
+
368
+ /** One recipe a ref resolved to. */
369
+ export type ResolvedRecipeRef = {
370
+ /** The repository publishing it. */
371
+ repo: CatalogRepo;
372
+ /** The recipe key, `namespace/recipe`. */
373
+ key: string;
374
+ namespace: string;
375
+ name: string;
376
+ };
377
+
378
+ /**
379
+ * The namespace a ref names. A one-word ref is searched across every trusted
380
+ * repository; a word that names a namespace in two of them is an error listing
381
+ * both.
382
+ *
383
+ * @param inputs - The cached indexes.
384
+ * @param ref - The namespace, optionally qualified with `repo:`.
385
+ */
386
+ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): ResolvedNamespace {
387
+ const parsed = parseRef(ref);
388
+
389
+ if (parsed.recipe !== undefined) {
390
+ throw new ConfigError(
391
+ `'${ref}' names the recipe '${parsed.recipe}', not a namespace.\n` +
392
+ ` The namespace it belongs to is '${parsed.namespace}'.`
393
+ );
394
+ }
395
+
396
+ const matches = findNamespace(refSearchString(parsed), referenceContext(inputs));
397
+
398
+ if (matches.length === 1) {
399
+ return { repo: repoNamed(inputs, matches[0]!.repo!), namespace: matches[0]!.namespace! };
400
+ }
401
+
402
+ if (matches.length > 1) throw ambiguousError(ref, "namespace", matches);
403
+
404
+ const asRecipe = findRecipe(refSearchString(parsed), referenceContext(inputs));
405
+ if (parsed.repo === undefined && asRecipe.length > 0) {
406
+ throw new ConfigError(
407
+ `No repository this project trusts publishes a namespace called ` +
408
+ `'${parsed.namespace}'.\n` +
409
+ ` It is the name of a recipe:\n` +
410
+ asRecipe.map((candidate) => ` ${describeReference(candidate)}`).join("\n")
411
+ );
412
+ }
413
+
414
+ throw unknownError(inputs, ref, "namespace", parsed.repo);
415
+ }
416
+
417
+ /**
418
+ * The recipe a ref names. A two-segment ref names it exactly; a one-word ref is
419
+ * searched as a recipe name across every trusted repository, and a name two of
420
+ * them publish is an error listing both.
421
+ *
422
+ * @param inputs - The cached indexes.
423
+ * @param ref - The recipe, as `namespace/recipe`, a bare recipe name, or either qualified with `repo:`.
424
+ */
425
+ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRecipeRef {
426
+ const parsed = parseRef(ref);
427
+ const search = refSearchString(parsed);
428
+ const context = referenceContext(inputs);
429
+
430
+ const matches = findRecipe(search, context);
431
+
432
+ if (matches.length === 1) {
433
+ const match = matches[0]!;
434
+ return {
435
+ repo: repoNamed(inputs, match.repo!),
436
+ key: `${match.namespace}/${match.recipe}`,
437
+ namespace: match.namespace!,
438
+ name: match.recipe!,
439
+ };
440
+ }
441
+
442
+ if (matches.length > 1) throw ambiguousError(ref, "recipe", matches);
443
+
444
+ const asNamespace = findNamespace(search, context);
445
+ if (asNamespace.length > 0) {
446
+ throw new ConfigError(
447
+ `No repository this project trusts publishes a recipe called ` +
448
+ `'${parsed.namespace}'.\n` +
449
+ ` It is the name of a namespace:\n` +
450
+ asNamespace.map((candidate) => ` ${describeReference(candidate)}`).join("\n")
451
+ );
452
+ }
453
+
454
+ throw unknownError(inputs, ref, "recipe", parsed.repo);
455
+ }
456
+
457
+ // --- The pieces ---------------------------------------------------------------------------------
458
+
459
+ /** Every recipe key one index publishes in one namespace, sorted. */
460
+ function recipeKeysIn(index: IndexFile, namespace: string): string[] {
461
+ return Object.keys(index.recipes)
462
+ .filter((key) => key.slice(0, key.indexOf("/")) === namespace)
463
+ .sort();
464
+ }
465
+
466
+ /**
467
+ * How much of one namespace a project subscribes to: the whole namespace when it
468
+ * subscribes to the namespace itself, some recipes when it subscribes to at
469
+ * least one recipe in it, and none otherwise.
470
+ *
471
+ * @param namespace - The namespace name.
472
+ * @param index - The index publishing it.
473
+ * @param subscriptions - The ref keys the project subscribes to.
474
+ */
475
+ function coverageOf(
476
+ namespace: string,
477
+ index: IndexFile,
478
+ subscriptions: Set<string>
479
+ ): NamespaceCoverage {
480
+ if (subscriptions.has(namespace)) return "whole namespace";
481
+ const some = recipeKeysIn(index, namespace).some((key) => subscriptions.has(key));
482
+ return some ? "some recipes" : "none";
483
+ }
484
+
485
+ /**
486
+ * One recipe's listing row: what the index publishes, what the lockfile pins,
487
+ * and whether the project subscribes to it.
488
+ *
489
+ * @param key - The recipe key.
490
+ * @param repo - The repository publishing it.
491
+ * @param lock - The project's lockfile.
492
+ * @param subscriptions - The ref keys the project subscribes to.
493
+ */
494
+ function recipeListing(
495
+ key: string,
496
+ repo: CatalogRepo,
497
+ lock: Lockfile,
498
+ subscriptions: Set<string>
499
+ ): RecipeListing {
500
+ const entry = repo.index.recipes[key]!;
501
+ const namespace = key.slice(0, key.indexOf("/"));
502
+ const name = key.slice(namespace.length + 1);
503
+ const latest = latestVersion(Object.keys(entry.versions), entry.versions);
504
+
505
+ // A locked entry pins one recipe from one repository. Two repositories can
506
+ // publish the same key, so the row only claims the pin when the lockfile says
507
+ // the recipe came from this repository.
508
+ const locked = lock.recipes[key];
509
+ const pinned = locked !== undefined && locked.repo === repo.name ? locked.version : undefined;
510
+
511
+ return {
512
+ key,
513
+ namespace,
514
+ name,
515
+ repo: repo.name,
516
+ ...(latest === undefined ? {} : { latest }),
517
+ ...(pinned === undefined ? {} : { pinned }),
518
+ subscribed: subscriptions.has(key) || subscriptions.has(namespace),
519
+ ...(entry.description === undefined ? {} : { description: entry.description }),
520
+ };
521
+ }
522
+
523
+ /**
524
+ * The highest published version. Prereleases are considered only when a recipe
525
+ * has published nothing else, so a repository mid-prerelease still reports a
526
+ * latest version rather than none.
527
+ *
528
+ * @param versions - Every published version string.
529
+ * @param published - The index entries those versions carry.
530
+ */
531
+ function latestVersion(
532
+ versions: string[],
533
+ published: Record<string, { prerelease: boolean }>
534
+ ): string | undefined {
535
+ const stable = versions.filter((version) => published[version]?.prerelease !== true);
536
+ const pool = stable.length > 0 ? stable : versions;
537
+ return semver.maxSatisfying(pool, "*", { includePrerelease: true }) ?? undefined;
538
+ }
539
+
540
+ /**
541
+ * Every published version, newest first, each labeled with what it is to this
542
+ * project.
543
+ *
544
+ * @param versions - The index's version entries.
545
+ * @param latest - The highest published version.
546
+ * @param pinned - The version the lockfile pins, when it pins one.
547
+ */
548
+ function versionListings(
549
+ versions: Record<string, { prerelease: boolean; releasedAt?: string }>,
550
+ latest: string | undefined,
551
+ pinned: string | undefined
552
+ ): RecipeVersionListing[] {
553
+ return Object.keys(versions)
554
+ .sort((left, right) => semver.rcompare(left, right, { loose: true }))
555
+ .map((version) => {
556
+ const entry = versions[version]!;
557
+ const isLatest = version === latest;
558
+ const isPinned = version === pinned;
559
+ const status: VersionStatus =
560
+ isLatest && isPinned
561
+ ? "latest and pinned"
562
+ : isLatest
563
+ ? "latest"
564
+ : isPinned
565
+ ? "pinned"
566
+ : "other";
567
+ return {
568
+ version,
569
+ status,
570
+ prerelease: entry.prerelease,
571
+ ...(entry.releasedAt === undefined ? {} : { releasedAt: entry.releasedAt }),
572
+ };
573
+ });
574
+ }
575
+
576
+ /**
577
+ * What one version depends on, from both sides: the dependencies the recipe's
578
+ * manifest declares, and the versions the release resolved them to in the
579
+ * index. A dependency that appears on only one side is still a row, because the
580
+ * two disagreeing is exactly what somebody reading this needs to see.
581
+ *
582
+ * @param resolved - The index's record of what the version was released against.
583
+ * @param manifest - The recipe's manifest, when its files could be read.
584
+ */
585
+ function dependencyListings(
586
+ resolved: Record<string, IndexDependency> | undefined,
587
+ manifest: RecipeManifest | undefined
588
+ ): RecipeDependencyListing[] {
589
+ const rows = new Map<string, RecipeDependencyListing>();
590
+
591
+ for (const [key, entry] of Object.entries(resolved ?? {})) {
592
+ rows.set(key, {
593
+ key,
594
+ ...(entry.version === undefined ? {} : { resolvedVersion: entry.version }),
595
+ ...(entry.range === undefined ? {} : { resolvedRange: entry.range }),
596
+ ...(entry.repo === undefined ? {} : { repo: entry.repo }),
597
+ });
598
+ }
599
+
600
+ for (const [kind, declared] of [
601
+ ["depends", manifest?.depends ?? []],
602
+ ["subscribes", manifest?.subscribes ?? []],
603
+ ] as Array<["depends" | "subscribes", string[]]>) {
604
+ for (const written of declared) {
605
+ let key: string;
606
+ try {
607
+ key = dependencyRefKey(parseDependencyRef(written));
608
+ } catch {
609
+ // A manifest sous cannot parse is still worth showing; it is listed
610
+ // under what it was written as, so the reader sees the bad entry.
611
+ key = written;
612
+ }
613
+ rows.set(key, { ...(rows.get(key) ?? { key }), declared: written, kind });
614
+ }
615
+ }
616
+
617
+ return [...rows.values()].sort((left, right) => compare(left.key, right.key));
618
+ }
619
+
620
+ /**
621
+ * One variable row: what it is called, what kind of answer it takes, and the
622
+ * environment variable an answer is stored under.
623
+ *
624
+ * @param definition - The variable definition from the recipe's manifest.
625
+ */
626
+ function variableListing(definition: VariableDefinition): RecipeVariableListing {
627
+ return {
628
+ name: definition.name,
629
+ type: definition.type,
630
+ // The manifest may bind an existing environment variable; otherwise the
631
+ // answer is stored under the name sous derives from the variable's own.
632
+ env: bareName(definition),
633
+ required: definition.required,
634
+ secret: definition.secret,
635
+ prompt: definition.prompt,
636
+ };
637
+ }
638
+
639
+ /**
640
+ * The repository a match named. Every match comes from this catalog's own
641
+ * repositories, so the lookup always finds one.
642
+ *
643
+ * @param inputs - The cached indexes.
644
+ * @param name - The repository's short name.
645
+ */
646
+ function repoNamed(inputs: CatalogInputs, name: string): CatalogRepo {
647
+ return inputs.repos.find((repo) => repo.name === name)!;
648
+ }
649
+
650
+ /** This catalog's repositories, in the shape a reference searches. */
651
+ function referenceContext(inputs: CatalogInputs): ReferenceContext {
652
+ return {
653
+ repos: referenceReposFromIndexes(
654
+ inputs.repos.map((repo) => repo.name),
655
+ new Map(inputs.repos.map((repo) => [repo.name, repo.index])),
656
+ Object.fromEntries(inputs.repos.map((repo) => [repo.name, repo.url]))
657
+ ),
658
+ };
659
+ }
660
+
661
+ /**
662
+ * A parsed ref written back out as a reference, without its version range: the
663
+ * range says which version to use, never which thing is meant.
664
+ *
665
+ * @param parsed - The parsed ref.
666
+ */
667
+ function refSearchString(parsed: ParsedRef): string {
668
+ const path = parsed.recipe === undefined ? parsed.namespace : `${parsed.namespace}/${parsed.recipe}`;
669
+ return parsed.repo === undefined ? path : `${parsed.repo}:${path}`;
670
+ }
671
+
672
+ /** The error a ref that could have meant several things raises. */
673
+ function ambiguousError(
674
+ ref: string,
675
+ what: "namespace" | "recipe",
676
+ candidates: ReferenceMatch[]
677
+ ): ConfigError {
678
+ return new ConfigError(
679
+ `'${ref}' names a ${what} in more than one repository this project trusts:\n` +
680
+ candidates.map((candidate) => ` ${describeReference(candidate)}`).join("\n") +
681
+ `\n Name the repository as well, as 'repository:${ref}', to say which one you mean.`
682
+ );
683
+ }
684
+
685
+ /** The error a ref that matched nothing raises, naming what was searched. */
686
+ function unknownError(
687
+ inputs: CatalogInputs,
688
+ ref: string,
689
+ what: "namespace" | "recipe",
690
+ qualifier: string | undefined
691
+ ): ConfigError {
692
+ if (qualifier !== undefined && !inputs.repos.some((repo) => repo.name === qualifier)) {
693
+ return new ConfigError(
694
+ `This project trusts no repository called '${qualifier}', so '${ref}' could not ` +
695
+ `be looked up.\n` +
696
+ ` Repositories with an index sous has read: ${describeRepos(inputs)}.`
697
+ );
698
+ }
699
+
700
+ return new ConfigError(
701
+ `No repository this project trusts publishes a ${what} called '${ref}'.\n` +
702
+ ` Repositories with an index sous has read: ${describeRepos(inputs)}.`
703
+ );
704
+ }
705
+
706
+ /** The repositories that were searched, in plain language. */
707
+ function describeRepos(inputs: CatalogInputs): string {
708
+ if (inputs.repos.length === 0) return "none";
709
+ return inputs.repos.map((repo) => repo.name).join(", ");
710
+ }
711
+
712
+ /** Sorts namespace listings by namespace, then by repository. */
713
+ function byNamespaceThenRepo(left: NamespaceListing, right: NamespaceListing): number {
714
+ return left.namespace === right.namespace
715
+ ? compare(left.repo, right.repo)
716
+ : compare(left.namespace, right.namespace);
717
+ }
718
+
719
+ /** Bytewise string ordering, so a listing sorts the same on every machine. */
720
+ function compare(left: string, right: string): number {
721
+ return left < right ? -1 : left > right ? 1 : 0;
722
+ }