@sous-io/sous 0.2.37 → 1.0.2

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 (244) hide show
  1. package/README.md +14 -256
  2. package/dist/bin.js +11 -0
  3. package/dist/sous.js +1094 -0
  4. package/docs/markdown/README.md +5 -4
  5. package/docs/markdown/_sidebar.md +21 -1
  6. package/docs/markdown/commands.md +294 -431
  7. package/docs/markdown/config-discovery.md +25 -14
  8. package/docs/markdown/config-inspection.md +53 -31
  9. package/docs/markdown/config-layers.md +168 -66
  10. package/docs/markdown/config-variables.md +44 -11
  11. package/docs/markdown/configuration.md +104 -17
  12. package/docs/markdown/design-principles.md +4 -1
  13. package/docs/markdown/repositories-authoring.md +30 -0
  14. package/docs/markdown/repositories-consuming.md +72 -14
  15. package/docs/markdown/repositories-file-formats.md +31 -13
  16. package/docs/markdown/repositories-providers.md +48 -57
  17. package/docs/markdown/repositories-quickstart.md +3 -2
  18. package/docs/markdown/repositories-troubleshooting.md +27 -1
  19. package/docs/markdown/repositories-variables.md +6 -1
  20. package/docs/markdown/repositories.md +18 -8
  21. package/package.json +48 -107
  22. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
  23. package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
  24. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
  25. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
  26. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
  27. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  28. package/sous.config.schema.json +351 -132
  29. package/bin/run.js +0 -51
  30. package/src/base-command.ts +0 -457
  31. package/src/commands/build.ts +0 -181
  32. package/src/commands/clear.ts +0 -79
  33. package/src/commands/compile.ts +0 -155
  34. package/src/commands/config/get.ts +0 -110
  35. package/src/commands/config/show.ts +0 -32
  36. package/src/commands/config/validate.ts +0 -53
  37. package/src/commands/help.ts +0 -46
  38. package/src/commands/init.ts +0 -289
  39. package/src/commands/launch.ts +0 -139
  40. package/src/commands/lock/rebuild.ts +0 -239
  41. package/src/commands/lock/show.ts +0 -115
  42. package/src/commands/namespace/list.ts +0 -138
  43. package/src/commands/namespace/show.ts +0 -130
  44. package/src/commands/prune.ts +0 -43
  45. package/src/commands/recipe/list.ts +0 -112
  46. package/src/commands/recipe/show.ts +0 -439
  47. package/src/commands/repo/add.ts +0 -145
  48. package/src/commands/repo/contribute.ts +0 -604
  49. package/src/commands/repo/gc.ts +0 -173
  50. package/src/commands/repo/init.ts +0 -136
  51. package/src/commands/repo/link.ts +0 -899
  52. package/src/commands/repo/list.ts +0 -236
  53. package/src/commands/repo/release.ts +0 -685
  54. package/src/commands/repo/remove.ts +0 -194
  55. package/src/commands/repo/search.ts +0 -243
  56. package/src/commands/repo/submit.ts +0 -341
  57. package/src/commands/repo/unlink.ts +0 -461
  58. package/src/commands/subscription/add.ts +0 -286
  59. package/src/commands/subscription/list.ts +0 -208
  60. package/src/commands/subscription/remove.ts +0 -182
  61. package/src/commands/subscription/update.ts +0 -216
  62. package/src/commands/update.ts +0 -178
  63. package/src/commands/vars/ask.ts +0 -417
  64. package/src/commands/vars/index.ts +0 -79
  65. package/src/commands/vars/list.ts +0 -67
  66. package/src/commands/vars/show.ts +0 -77
  67. package/src/config-command.ts +0 -30
  68. package/src/lib/build-preparation.ts +0 -128
  69. package/src/lib/build-service.ts +0 -580
  70. package/src/lib/config-discovery.ts +0 -378
  71. package/src/lib/config-inspect.ts +0 -145
  72. package/src/lib/config-kernel.mjs +0 -379
  73. package/src/lib/config-schema.ts +0 -401
  74. package/src/lib/env-file.ts +0 -328
  75. package/src/lib/env-local.ts +0 -212
  76. package/src/lib/errors.ts +0 -32
  77. package/src/lib/glob-pattern.ts +0 -24
  78. package/src/lib/include-resolver.ts +0 -523
  79. package/src/lib/interactive.ts +0 -165
  80. package/src/lib/markdown-compiler.ts +0 -947
  81. package/src/lib/package-info.ts +0 -25
  82. package/src/lib/pid-service.ts +0 -99
  83. package/src/lib/project-install.d.mts +0 -44
  84. package/src/lib/project-install.mjs +0 -234
  85. package/src/lib/project-scaffold/index.ts +0 -332
  86. package/src/lib/project-scaffold/templates.ts +0 -240
  87. package/src/lib/repos/catalog-display.ts +0 -215
  88. package/src/lib/repos/catalog-inputs.ts +0 -288
  89. package/src/lib/repos/catalog.ts +0 -842
  90. package/src/lib/repos/contribute.ts +0 -452
  91. package/src/lib/repos/core-recipe.ts +0 -105
  92. package/src/lib/repos/declarations.ts +0 -187
  93. package/src/lib/repos/defaults.ts +0 -175
  94. package/src/lib/repos/formats/common.ts +0 -440
  95. package/src/lib/repos/formats/index-file.ts +0 -253
  96. package/src/lib/repos/formats/links-map.ts +0 -98
  97. package/src/lib/repos/formats/lockfile.ts +0 -169
  98. package/src/lib/repos/formats/patterns.ts +0 -57
  99. package/src/lib/repos/formats/recipe-manifest.ts +0 -205
  100. package/src/lib/repos/formats/repo-manifest.ts +0 -97
  101. package/src/lib/repos/formats/store-entry.ts +0 -84
  102. package/src/lib/repos/formats/variable-definition.ts +0 -268
  103. package/src/lib/repos/freshness.ts +0 -264
  104. package/src/lib/repos/git-clone.ts +0 -850
  105. package/src/lib/repos/identity.ts +0 -89
  106. package/src/lib/repos/index.ts +0 -57
  107. package/src/lib/repos/links.ts +0 -356
  108. package/src/lib/repos/load-manifest.ts +0 -237
  109. package/src/lib/repos/lock-service.ts +0 -454
  110. package/src/lib/repos/locked-namespace-resolver.ts +0 -232
  111. package/src/lib/repos/locked-recipe-lookup.ts +0 -123
  112. package/src/lib/repos/locked-recipes.ts +0 -254
  113. package/src/lib/repos/managed-layer.ts +0 -422
  114. package/src/lib/repos/memories-hash-name.ts +0 -33
  115. package/src/lib/repos/namespace-resolver.ts +0 -527
  116. package/src/lib/repos/providers/base.ts +0 -268
  117. package/src/lib/repos/providers/git.ts +0 -245
  118. package/src/lib/repos/providers/github.ts +0 -589
  119. package/src/lib/repos/providers/gitlab.ts +0 -332
  120. package/src/lib/repos/providers/http.ts +0 -128
  121. package/src/lib/repos/providers/index-cache.ts +0 -421
  122. package/src/lib/repos/providers/index.ts +0 -106
  123. package/src/lib/repos/providers/local.ts +0 -410
  124. package/src/lib/repos/providers/provider.ts +0 -615
  125. package/src/lib/repos/recipe-config-layers.ts +0 -316
  126. package/src/lib/repos/recipe-key-matcher.ts +0 -62
  127. package/src/lib/repos/recipe-memories.ts +0 -178
  128. package/src/lib/repos/recipe-targets.ts +0 -236
  129. package/src/lib/repos/ref-search.ts +0 -46
  130. package/src/lib/repos/release/bump.ts +0 -161
  131. package/src/lib/repos/release/changelog.ts +0 -461
  132. package/src/lib/repos/release/git-state.ts +0 -460
  133. package/src/lib/repos/release/index-builder.ts +0 -1108
  134. package/src/lib/repos/release/index.ts +0 -19
  135. package/src/lib/repos/release/plan.ts +0 -521
  136. package/src/lib/repos/release/settle.ts +0 -154
  137. package/src/lib/repos/release/submissions.ts +0 -214
  138. package/src/lib/repos/release/submit-checkout.ts +0 -290
  139. package/src/lib/repos/release/submit-questions.ts +0 -153
  140. package/src/lib/repos/release/submit-service.ts +0 -898
  141. package/src/lib/repos/release/tags.ts +0 -239
  142. package/src/lib/repos/release/validate.ts +0 -463
  143. package/src/lib/repos/resolver.ts +0 -1028
  144. package/src/lib/repos/scaffold/index.ts +0 -245
  145. package/src/lib/repos/scaffold/templates.ts +0 -517
  146. package/src/lib/repos/seed.ts +0 -473
  147. package/src/lib/repos/store/contract.ts +0 -64
  148. package/src/lib/repos/store/hash.ts +0 -191
  149. package/src/lib/repos/store/recipe-store.ts +0 -599
  150. package/src/lib/repos/store/settings.ts +0 -58
  151. package/src/lib/repos/subscription-service.ts +0 -3859
  152. package/src/lib/repos/trust.ts +0 -447
  153. package/src/lib/repos/update-plan.ts +0 -235
  154. package/src/lib/self-update/index.ts +0 -14
  155. package/src/lib/self-update/installs.ts +0 -413
  156. package/src/lib/self-update/managers.ts +0 -126
  157. package/src/lib/self-update/plan.ts +0 -315
  158. package/src/lib/self-update/registry.ts +0 -99
  159. package/src/lib/self-update/run.ts +0 -506
  160. package/src/lib/self-update/versions.ts +0 -243
  161. package/src/lib/settings.ts +0 -1107
  162. package/src/lib/sous-home.ts +0 -104
  163. package/src/lib/state.ts +0 -167
  164. package/src/lib/vars/answers.ts +0 -192
  165. package/src/lib/vars/ask.ts +0 -1152
  166. package/src/lib/vars/definition-source.ts +0 -261
  167. package/src/lib/vars/display.ts +0 -231
  168. package/src/lib/vars/index.ts +0 -20
  169. package/src/lib/vars/ladder.ts +0 -282
  170. package/src/lib/vars/mappings.ts +0 -260
  171. package/src/lib/vars/names.ts +0 -94
  172. package/src/lib/vars/preanswers.ts +0 -395
  173. package/src/lib/vars/question-plan.ts +0 -241
  174. package/src/lib/vars/report.ts +0 -228
  175. package/src/lib/vars/safe-regex.ts +0 -235
  176. package/src/lib/vars/secrets.ts +0 -131
  177. package/src/lib/vars/validate.ts +0 -312
  178. package/src/lib/version-report.ts +0 -90
  179. package/src/lib/watch-loop.ts +0 -148
  180. package/src/lib/watch-service.ts +0 -116
  181. package/src/services/ref-resolver/container.ts +0 -107
  182. package/src/services/ref-resolver/dependency.ts +0 -60
  183. package/src/services/ref-resolver/format.ts +0 -197
  184. package/src/services/ref-resolver/glob.ts +0 -115
  185. package/src/services/ref-resolver/hash-names.ts +0 -155
  186. package/src/services/ref-resolver/index.ts +0 -55
  187. package/src/services/ref-resolver/injectable.ts +0 -36
  188. package/src/services/ref-resolver/location.ts +0 -62
  189. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
  190. package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
  191. package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
  192. package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
  193. package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
  194. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
  195. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
  196. package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
  197. package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
  198. package/src/services/ref-resolver/named-refs.ts +0 -144
  199. package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
  200. package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
  201. package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
  202. package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
  203. package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
  204. package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
  205. package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
  206. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
  207. package/src/services/ref-resolver/parts.ts +0 -115
  208. package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
  209. package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
  210. package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
  211. package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
  212. package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
  213. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
  214. package/src/services/ref-resolver/ref-helpers.ts +0 -62
  215. package/src/services/ref-resolver/ref-picker-service.ts +0 -204
  216. package/src/services/ref-resolver/ref-report.ts +0 -84
  217. package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
  218. package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
  219. package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
  220. package/src/services/ref-resolver/source.ts +0 -48
  221. package/src/services/ref-resolver/tokens.ts +0 -25
  222. package/src/services/ref-resolver/types.ts +0 -155
  223. package/src/templating/filters/bullet-list.ts +0 -9
  224. package/src/templating/filters/index.ts +0 -8
  225. package/src/templating/init-liquid-engine.ts +0 -139
  226. package/src/templating/lib/glob-files.ts +0 -74
  227. package/src/templating/lib/import-export.ts +0 -32
  228. package/src/templating/lib/secret-scope.ts +0 -41
  229. package/src/templating/lib/tag-args.ts +0 -19
  230. package/src/templating/tags/exportScalarVarsJs.ts +0 -46
  231. package/src/templating/tags/getFiles.ts +0 -89
  232. package/src/templating/tags/index.ts +0 -14
  233. package/src/templating/tags/listFiles.ts +0 -54
  234. package/src/templating/tags/showVars.ts +0 -28
  235. package/src/utils/choice-prompt.ts +0 -143
  236. package/src/utils/command-errors.ts +0 -186
  237. package/src/utils/command-help.ts +0 -45
  238. package/src/utils/confirm-prompt.ts +0 -110
  239. package/src/utils/flags.ts +0 -177
  240. package/src/utils/formatting.ts +0 -849
  241. package/src/utils/prompts.ts +0 -53
  242. package/src/utils/sous-directory.ts +0 -245
  243. package/src/utils/table.ts +0 -603
  244. package/src/utils/value-prompt.ts +0 -119
@@ -1,239 +0,0 @@
1
- /**
2
- * Git tags as the cheap enumeration path for published recipe versions.
3
- *
4
- * Recipe metadata is the source of truth for versions; a tag shaped
5
- * `namespace/recipe@1.2.3` is a convenience ref that points at the commit the
6
- * version was published from. The two must agree, and this module is what makes
7
- * that checkable: it names tags, lists them, reads a file back out of one, and
8
- * materializes a tagged recipe folder so its content can be hashed.
9
- *
10
- * Every function takes the injectable command runner, so a test drives the whole
11
- * surface against a repository made with `git init` and never reaches a network.
12
- */
13
-
14
- import fs from "node:fs/promises";
15
- import os from "node:os";
16
- import path from "node:path";
17
- import { runGit, type RunOptions } from "../providers/git.js";
18
- import { RECIPE_KEY_PATTERN } from "../formats/patterns.js";
19
- import { splitRecipeKey } from "../../../services/ref-resolver/index.js";
20
-
21
- /** One release tag, taken apart into the recipe it publishes. */
22
- export type RecipeTag = {
23
- /** The tag exactly as git holds it, `namespace/recipe@version`. */
24
- tag: string;
25
- /** The recipe's namespace. */
26
- namespace: string;
27
- /** The recipe's name. */
28
- name: string;
29
- /** The exact version the tag publishes. */
30
- version: string;
31
- };
32
-
33
- /** The recipe key (`namespace/recipe`) a tag belongs to. */
34
- export function recipeTagKey(tag: RecipeTag): string {
35
- return `${tag.namespace}/${tag.name}`;
36
- }
37
-
38
- /**
39
- * The tag that publishes one version of one recipe.
40
- *
41
- * @param namespace - The recipe's namespace.
42
- * @param name - The recipe's name.
43
- * @param version - The exact version being published.
44
- */
45
- export function tagFor(namespace: string, name: string, version: string): string {
46
- return `${namespace}/${name}@${version}`;
47
- }
48
-
49
- /**
50
- * Takes a tag apart, or returns undefined when it is not a recipe release tag.
51
- * A repository may carry tags of its own shape (`v1.2.0`, say), and those are
52
- * simply not ours.
53
- *
54
- * @param tag - The tag name as git holds it.
55
- */
56
- export function parseRecipeTag(tag: string): RecipeTag | undefined {
57
- const at = tag.lastIndexOf("@");
58
- if (at <= 0 || at === tag.length - 1) return undefined;
59
-
60
- const key = tag.slice(0, at);
61
- const version = tag.slice(at + 1);
62
- if (!RECIPE_KEY_PATTERN.test(key)) return undefined;
63
-
64
- const { namespace, name } = splitRecipeKey(key);
65
- return { tag, namespace, name, version };
66
- }
67
-
68
- /**
69
- * Every recipe release tag in a repository, in the order git lists them. Tags
70
- * that are not shaped like a recipe release are left out.
71
- *
72
- * @param rootDir - The repository's root directory.
73
- * @param options - The command runner to use.
74
- */
75
- export async function listRecipeTags(
76
- rootDir: string,
77
- options: RunOptions = {}
78
- ): Promise<RecipeTag[]> {
79
- const output = await runGit(["tag", "--list", "*@*"], {
80
- cwd: rootDir,
81
- run: options.run,
82
- });
83
- if (output.length === 0) return [];
84
-
85
- const tags: RecipeTag[] = [];
86
- for (const line of output.split("\n")) {
87
- const parsed = parseRecipeTag(line.trim());
88
- if (parsed !== undefined) tags.push(parsed);
89
- }
90
- return tags;
91
- }
92
-
93
- /**
94
- * The contents of one file as it stood at a tag, or undefined when the tag does
95
- * not carry that file.
96
- *
97
- * @param rootDir - The repository's root directory.
98
- * @param tag - The tag to read at.
99
- * @param relativePath - The file's path relative to the repository root.
100
- * @param options - The command runner to use.
101
- */
102
- export async function readFileAtTag(
103
- rootDir: string,
104
- tag: string,
105
- relativePath: string,
106
- options: RunOptions = {}
107
- ): Promise<string | undefined> {
108
- try {
109
- return await runGit(["show", `${tag}:${toPosix(relativePath)}`], {
110
- cwd: rootDir,
111
- run: options.run,
112
- });
113
- } catch {
114
- return undefined;
115
- }
116
- }
117
-
118
- /**
119
- * When the commit a tag points at was made, as an ISO 8601 timestamp, or
120
- * undefined when git cannot say.
121
- *
122
- * @param rootDir - The repository's root directory.
123
- * @param tag - The tag to date.
124
- * @param options - The command runner to use.
125
- */
126
- export async function tagCommitDate(
127
- rootDir: string,
128
- tag: string,
129
- options: RunOptions = {}
130
- ): Promise<string | undefined> {
131
- let raw: string;
132
- try {
133
- raw = await runGit(["log", "-1", "--format=%cI", tag], {
134
- cwd: rootDir,
135
- run: options.run,
136
- });
137
- } catch {
138
- return undefined;
139
- }
140
-
141
- const parsed = new Date(raw.trim());
142
- if (Number.isNaN(parsed.getTime())) return undefined;
143
- return parsed.toISOString();
144
- }
145
-
146
- /**
147
- * Checks out the tree at a tag into a temporary directory, hands the caller the
148
- * path of one folder inside it, and cleans up afterwards.
149
- *
150
- * A linked worktree is used rather than `git archive` piped into a tar reader:
151
- * the command runner captures output as text, so an archive's bytes could not
152
- * survive the trip, while a worktree puts the real files on disk where they can
153
- * be read and hashed exactly as a fetched copy would be.
154
- *
155
- * @param rootDir - The repository's root directory.
156
- * @param tag - The tag to check out.
157
- * @param subPath - The folder inside the tree to hand back, relative to the root.
158
- * @param use - Called with the absolute path of that folder.
159
- * @param options - The command runner to use.
160
- */
161
- export async function withTaggedTree<T>(
162
- rootDir: string,
163
- tag: string,
164
- subPath: string,
165
- use: (dir: string) => Promise<T>,
166
- options: RunOptions = {}
167
- ): Promise<T> {
168
- const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sous-release-"));
169
- const checkout = path.join(workRoot, "tree");
170
-
171
- try {
172
- await runGit(["worktree", "add", "--detach", "--quiet", checkout, tag], {
173
- cwd: rootDir,
174
- run: options.run,
175
- });
176
- return await use(path.join(checkout, ...toPosix(subPath).split("/")));
177
- } finally {
178
- // Remove the worktree through git first, so its administrative record goes
179
- // with it; a plain delete would leave the repository listing a worktree
180
- // that is no longer there.
181
- try {
182
- await runGit(["worktree", "remove", "--force", checkout], {
183
- cwd: rootDir,
184
- run: options.run,
185
- });
186
- } catch {
187
- // The worktree was never created, or git already dropped it. Either way
188
- // the temporary directory below is what actually needs removing.
189
- }
190
- await fs.rm(workRoot, { recursive: true, force: true });
191
- }
192
- }
193
-
194
- /**
195
- * Creates an annotated tag on HEAD.
196
- *
197
- * @param rootDir - The repository's root directory.
198
- * @param tag - The tag to create.
199
- * @param message - The tag's annotation message.
200
- * @param options - The command runner to use.
201
- */
202
- export async function createAnnotatedTag(
203
- rootDir: string,
204
- tag: string,
205
- message: string,
206
- options: RunOptions = {}
207
- ): Promise<void> {
208
- await runGit(["tag", "--annotate", tag, "--message", message], {
209
- cwd: rootDir,
210
- run: options.run,
211
- });
212
- }
213
-
214
- /**
215
- * Pushes exactly the named tags to a remote, and nothing else. Sous never
216
- * pushes a branch as a side effect of tagging.
217
- *
218
- * @param rootDir - The repository's root directory.
219
- * @param remote - The remote to push to, normally `origin`.
220
- * @param tags - The tags to push.
221
- * @param options - The command runner to use.
222
- */
223
- export async function pushTags(
224
- rootDir: string,
225
- remote: string,
226
- tags: ReadonlyArray<string>,
227
- options: RunOptions = {}
228
- ): Promise<void> {
229
- if (tags.length === 0) return;
230
- await runGit(["push", remote, ...tags.map((tag) => `refs/tags/${tag}`)], {
231
- cwd: rootDir,
232
- run: options.run,
233
- });
234
- }
235
-
236
- /** Rewrites a path with forward slashes, which is what git speaks everywhere. */
237
- function toPosix(value: string): string {
238
- return value.split(path.sep).join("/");
239
- }
@@ -1,463 +0,0 @@
1
- /**
2
- * Publish-side validation for a recipe repository.
3
- *
4
- * `sous repo release` and `sous repo submit` both refuse to do anything until
5
- * the repository they are standing in describes itself consistently: every
6
- * folder the repo manifest lists exists and holds a recipe manifest, every
7
- * recipe belongs to a declared namespace, no two recipes share a key, and no
8
- * two variable definitions quietly claim the same environment variable.
9
- *
10
- * Nothing here runs git or touches the network; it reads files and reports.
11
- * Problems are collected rather than thrown, so one run tells an author
12
- * everything that is wrong instead of only the first thing.
13
- */
14
-
15
- import fs from "node:fs";
16
- import path from "node:path";
17
- import { ConfigError } from "../../errors.js";
18
- import { MANIFEST_EXTENSIONS, REPO_MANIFEST_BASENAME } from "../formats/common.js";
19
- import {
20
- parseRecipeManifest,
21
- recipeManifestKey,
22
- type RecipeManifest,
23
- } from "../formats/recipe-manifest.js";
24
- import { parseRepoManifest, type RepoManifest } from "../formats/repo-manifest.js";
25
- import {
26
- findRecipeManifest,
27
- findRepoManifest,
28
- loadManifestFile,
29
- requireRepoManifest,
30
- } from "../load-manifest.js";
31
- import { bareName } from "../../vars/names.js";
32
-
33
- /** How serious a validation finding is. An error stops a release; a warning does not. */
34
- export type ProblemLevel = "error" | "warning";
35
-
36
- /** One validation finding, with enough location to act on it. */
37
- export type ValidationProblem = {
38
- /** Whether this stops a release or only deserves saying out loud. */
39
- level: ProblemLevel;
40
- /** Where it was found: a repo-relative file path, with a field path when there is one. */
41
- where: string;
42
- /** What is wrong, in plain language, and what to do about it. */
43
- message: string;
44
- };
45
-
46
- /** One recipe folder that was found, read and validated. */
47
- export type ValidatedRecipe = {
48
- /** The recipe folder's path relative to the repository root, as the repo manifest lists it. */
49
- path: string;
50
- /** Absolute path to the recipe folder. */
51
- dir: string;
52
- /** Absolute path to the recipe manifest inside that folder. */
53
- manifestPath: string;
54
- /** The validated recipe manifest. */
55
- manifest: RecipeManifest;
56
- /** The recipe's key, `namespace/name`. */
57
- key: string;
58
- /** The manifest exactly as it was parsed, before the schema dropped its `x-` keys. */
59
- raw: unknown;
60
- };
61
-
62
- /** Everything one pass over a repository found. */
63
- export type RepoValidation = {
64
- /** Absolute path to the repository root. */
65
- rootDir: string;
66
- /** Absolute path to the repo manifest at that root. */
67
- manifestPath: string;
68
- /** The validated repo manifest. */
69
- manifest: RepoManifest;
70
- /** Every recipe that could be read, in the order the repo manifest lists them. */
71
- recipes: ValidatedRecipe[];
72
- /** Everything found wrong, errors and warnings together. */
73
- problems: ValidationProblem[];
74
- };
75
-
76
- /**
77
- * Environment variable names a recipe should not claim without saying it meant
78
- * to: the ones an operating system, a shell or a well-known tool already owns.
79
- * Claiming one is legal and occasionally correct (binding an existing
80
- * `GITHUB_TOKEN` is the motivating case), so this is a warning, silenced by
81
- * setting `x-intentional: true` on the definition.
82
- */
83
- export const WELL_KNOWN_ENV_NAMES: readonly string[] = [
84
- "PATH",
85
- "HOME",
86
- "USER",
87
- "SHELL",
88
- "GITHUB_TOKEN",
89
- "GITLAB_TOKEN",
90
- "NPM_TOKEN",
91
- ];
92
-
93
- /**
94
- * Prefixes treated the same way as the names above. `SOUS_VAR_` is deliberately
95
- * exempt: every name sous derives for an answer starts with it, so warning on
96
- * it would fire on every well-behaved definition in every repository.
97
- */
98
- export const WELL_KNOWN_ENV_PREFIXES: readonly string[] = ["AWS_", "SOUS_"];
99
-
100
- /** The extension key that silences the well-known environment name warning. */
101
- export const INTENTIONAL_EXTENSION_KEY = "x-intentional";
102
-
103
- /**
104
- * Walks up from a directory to the repository root: the first directory at or
105
- * above it holding a repo manifest.
106
- *
107
- * @param startDir - Where to start looking, normally the working directory.
108
- * @returns The absolute path of the repository root.
109
- */
110
- export function findRepoRoot(startDir: string): string {
111
- let current = path.resolve(startDir);
112
-
113
- for (;;) {
114
- if (findRepoManifest(current) !== undefined) return current;
115
- const parent = path.dirname(current);
116
- if (parent === current) break;
117
- current = parent;
118
- }
119
-
120
- throw new ConfigError(
121
- `${path.resolve(startDir)} is not inside a sous recipe repository.\n` +
122
- ` A recipe repository has a '${REPO_MANIFEST_BASENAME}${MANIFEST_EXTENSIONS[0]}' file at ` +
123
- `its root, and sous looked in that directory and in every directory above it.\n` +
124
- ` Run the command from inside a repository, or create one with 'sous repo init'.`
125
- );
126
- }
127
-
128
- /**
129
- * Reads and checks a whole recipe repository.
130
- *
131
- * The repo manifest itself must parse; there is nothing to report against when
132
- * it does not, so that failure is thrown as a ConfigError. Everything below it
133
- * is collected into `problems`, so one run surfaces every fault at once.
134
- *
135
- * @param rootDir - Absolute path to the repository root.
136
- */
137
- export function validateRepo(rootDir: string): RepoValidation {
138
- const manifestPath = requireRepoManifest(rootDir);
139
- const manifest = parseRepoManifest(loadManifestFile(manifestPath), manifestPath);
140
-
141
- const problems: ValidationProblem[] = [];
142
- const recipes: ValidatedRecipe[] = [];
143
- const seenKeys = new Map<string, string>();
144
- const manifestName = path.basename(manifestPath);
145
-
146
- for (const recipePath of manifest.recipes) {
147
- const dir = path.join(rootDir, recipePath);
148
- const found = readRecipe(rootDir, recipePath, dir, manifestName, problems);
149
- if (found === undefined) continue;
150
-
151
- if (!Object.hasOwn(manifest.namespaces, found.manifest.namespace)) {
152
- problems.push({
153
- level: "error",
154
- where: `${relative(rootDir, found.manifestPath)} namespace`,
155
- message:
156
- `the recipe is in the namespace '${found.manifest.namespace}', which ` +
157
- `${manifestName} does not declare under 'namespaces'. Declare the namespace, or ` +
158
- `move the recipe into one that exists.`,
159
- });
160
- }
161
-
162
- const firstPath = seenKeys.get(found.key);
163
- if (firstPath !== undefined) {
164
- problems.push({
165
- level: "error",
166
- where: relative(rootDir, found.manifestPath),
167
- message:
168
- `the recipe '${found.key}' is also published by ${firstPath}. A namespace and a ` +
169
- `name together name exactly one recipe, so rename one of the two.`,
170
- });
171
- } else {
172
- seenKeys.set(found.key, relative(rootDir, found.manifestPath));
173
- }
174
-
175
- problems.push(...checkSymlinks(recipePath, dir));
176
-
177
- recipes.push(found);
178
- }
179
-
180
- problems.push(...checkVariableEnvNames(rootDir, recipes));
181
-
182
- return { rootDir, manifestPath, manifest, recipes, problems };
183
- }
184
-
185
- /** True when any problem in the list is an error. */
186
- export function hasErrors(problems: ReadonlyArray<ValidationProblem>): boolean {
187
- return problems.some((problem) => problem.level === "error");
188
- }
189
-
190
- /** The errors in a problem list, in the order they were found. */
191
- export function errorsIn(problems: ReadonlyArray<ValidationProblem>): ValidationProblem[] {
192
- return problems.filter((problem) => problem.level === "error");
193
- }
194
-
195
- /** The warnings in a problem list, in the order they were found. */
196
- export function warningsIn(
197
- problems: ReadonlyArray<ValidationProblem>
198
- ): ValidationProblem[] {
199
- return problems.filter((problem) => problem.level === "warning");
200
- }
201
-
202
- /**
203
- * The reason an environment variable name deserves a warning, or undefined when
204
- * it is an ordinary name. Exported so a caller can explain the rule without
205
- * repeating the list.
206
- *
207
- * @param envName - The environment variable name a definition claims.
208
- */
209
- export function wellKnownEnvReason(envName: string): string | undefined {
210
- if (WELL_KNOWN_ENV_NAMES.includes(envName)) {
211
- return `'${envName}' is a well-known name that the system or another tool already uses`;
212
- }
213
- if (envName.startsWith("SOUS_VAR_")) return undefined;
214
- for (const prefix of WELL_KNOWN_ENV_PREFIXES) {
215
- if (envName.startsWith(prefix)) {
216
- return (
217
- `'${envName}' starts with '${prefix}', a prefix reserved by the system or by ` +
218
- `another tool`
219
- );
220
- }
221
- }
222
- return undefined;
223
- }
224
-
225
- // --- Internals ----------------------------------------------------------------------------------
226
-
227
- /**
228
- * The message a thrown value should be reported with. A ConfigError already
229
- * carries plain-language wording, and every other Error at least carries a
230
- * message; anything else is rendered as it stands.
231
- *
232
- * @param error - The value that was thrown.
233
- */
234
- export function describeError(error: unknown): string {
235
- return error instanceof Error ? error.message : String(error);
236
- }
237
-
238
- /** Renders an absolute path as a repository-relative one, with forward slashes. */
239
- function relative(rootDir: string, target: string): string {
240
- return path.relative(rootDir, target).split(path.sep).join("/");
241
- }
242
-
243
- /**
244
- * Reads one recipe folder. Returns undefined, having recorded a problem, when
245
- * the folder is missing or its manifest cannot be read.
246
- */
247
- function readRecipe(
248
- rootDir: string,
249
- recipePath: string,
250
- dir: string,
251
- manifestName: string,
252
- problems: ValidationProblem[]
253
- ): ValidatedRecipe | undefined {
254
- if (!isDirectory(dir)) {
255
- problems.push({
256
- level: "error",
257
- where: recipePath,
258
- message:
259
- `${manifestName} lists this recipe folder, but there is no directory there. Create ` +
260
- `it, or remove the path from the 'recipes' list.`,
261
- });
262
- return undefined;
263
- }
264
-
265
- let manifestPath: string | undefined;
266
- try {
267
- manifestPath = findRecipeManifest(dir);
268
- } catch (error) {
269
- problems.push({
270
- level: "error",
271
- where: recipePath,
272
- message: describeError(error),
273
- });
274
- return undefined;
275
- }
276
-
277
- if (manifestPath === undefined) {
278
- problems.push({
279
- level: "error",
280
- where: recipePath,
281
- message:
282
- "there is no recipe manifest in this folder. Every folder listed under 'recipes' " +
283
- "holds one 'sous.recipe.yaml'.",
284
- });
285
- return undefined;
286
- }
287
-
288
- let raw: unknown;
289
- let manifest: RecipeManifest;
290
- try {
291
- raw = loadManifestFile(manifestPath);
292
- manifest = parseRecipeManifest(raw, manifestPath);
293
- } catch (error) {
294
- problems.push({
295
- level: "error",
296
- where: relative(rootDir, manifestPath),
297
- message: describeError(error),
298
- });
299
- return undefined;
300
- }
301
-
302
- return {
303
- path: recipePath,
304
- dir,
305
- manifestPath,
306
- manifest,
307
- key: recipeManifestKey(manifest),
308
- raw,
309
- };
310
- }
311
-
312
- /** One definition's claim on an environment variable name. */
313
- type EnvClaim = {
314
- envName: string;
315
- variableName: string;
316
- where: string;
317
- };
318
-
319
- /**
320
- * Checks every variable definition in the repository against every other one.
321
- *
322
- * Two definitions of the SAME variable name may share an environment variable:
323
- * that is the shared rung of the resolution ladder doing its job, and it is how
324
- * one answer serves every recipe that asks the same question. Two definitions
325
- * of DIFFERENT variable names sharing one name is a genuine collision, because
326
- * a single answer would silently satisfy both.
327
- */
328
- function checkVariableEnvNames(
329
- rootDir: string,
330
- recipes: ReadonlyArray<ValidatedRecipe>
331
- ): ValidationProblem[] {
332
- const problems: ValidationProblem[] = [];
333
- const byEnvName = new Map<string, EnvClaim>();
334
- const byVariableName = new Map<string, EnvClaim>();
335
-
336
- for (const recipe of recipes) {
337
- const definitions = recipe.manifest.variables ?? [];
338
- definitions.forEach((definition, index) => {
339
- const envName = bareName(definition);
340
- const where =
341
- `${relative(rootDir, recipe.manifestPath)} variables[${index}] ` +
342
- `('${definition.name}')`;
343
- const claim: EnvClaim = { envName, variableName: definition.name, where };
344
-
345
- const byEnv = byEnvName.get(envName);
346
- if (byEnv !== undefined && byEnv.variableName !== definition.name) {
347
- problems.push({
348
- level: "error",
349
- where,
350
- message:
351
- `claims the environment variable '${envName}', which ${byEnv.where} already ` +
352
- `claims for the different variable '${byEnv.variableName}'. Two definitions ` +
353
- `cannot share one environment variable; set 'env' explicitly on one of them.`,
354
- });
355
- } else if (byEnv === undefined) {
356
- byEnvName.set(envName, claim);
357
- }
358
-
359
- const byName = byVariableName.get(definition.name);
360
- if (byName !== undefined && byName.envName !== envName) {
361
- problems.push({
362
- level: "warning",
363
- where,
364
- message:
365
- `binds to '${envName}', while ${byName.where} binds the same variable name to ` +
366
- `'${byName.envName}'. One answer will not serve both; give them one 'env' value ` +
367
- `if they are meant to be the same question.`,
368
- });
369
- } else if (byName === undefined) {
370
- byVariableName.set(definition.name, claim);
371
- }
372
-
373
- const reason = wellKnownEnvReason(envName);
374
- if (reason !== undefined && !isIntentional(recipe.raw, index)) {
375
- problems.push({
376
- level: "warning",
377
- where,
378
- message:
379
- `${reason}. An answer written there is visible to every process this project ` +
380
- `starts. If that is what you meant, set '${INTENTIONAL_EXTENSION_KEY}: true' on ` +
381
- `the definition to say so.`,
382
- });
383
- }
384
- });
385
- }
386
-
387
- return problems;
388
- }
389
-
390
- /**
391
- * True when the raw manifest marks the variable definition at `index` as an
392
- * intentional claim on a well-known name.
393
- *
394
- * The flag is read from the RAW manifest rather than from the validated one
395
- * because the recipe manifest schema accepts and then drops every key in the
396
- * reserved `x-` extension namespace, so it never reaches the parsed object.
397
- */
398
- function isIntentional(raw: unknown, index: number): boolean {
399
- if (typeof raw !== "object" || raw === null) return false;
400
- const variables = (raw as Record<string, unknown>).variables;
401
- if (!Array.isArray(variables)) return false;
402
- const entry = variables[index];
403
- if (typeof entry !== "object" || entry === null) return false;
404
- return (entry as Record<string, unknown>)[INTENTIONAL_EXTENSION_KEY] === true;
405
- }
406
-
407
- /** True when the path exists and is a directory. */
408
- function isDirectory(candidate: string): boolean {
409
- try {
410
- return fs.statSync(candidate).isDirectory();
411
- } catch {
412
- return false;
413
- }
414
- }
415
-
416
- /**
417
- * Reports every symbolic link inside a recipe folder as an error.
418
- *
419
- * A published version's content hash is computed over the recipe folder, and a
420
- * link points at bytes the repository does not own. Sous skips links when it
421
- * hashes and when it copies into the store, so a linked file is simply not part
422
- * of what a consumer receives; publishing one would ship a recipe that is
423
- * missing a file it expects. The fix is always the same, so it is refused here
424
- * rather than surfacing later as a puzzling absence.
425
- *
426
- * @param recipePath - The recipe folder's path relative to the repository root.
427
- * @param dir - The recipe folder's absolute path.
428
- */
429
- function checkSymlinks(recipePath: string, dir: string): ValidationProblem[] {
430
- const problems: ValidationProblem[] = [];
431
-
432
- const walk = (current: string, prefix: string): void => {
433
- let entries;
434
- try {
435
- entries = fs.readdirSync(current, { withFileTypes: true });
436
- } catch {
437
- return;
438
- }
439
-
440
- for (const entry of entries) {
441
- if (entry.name === ".git") continue;
442
- const relativePath = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
443
-
444
- if (entry.isSymbolicLink()) {
445
- problems.push({
446
- level: "error",
447
- where: `${recipePath}/${relativePath}`,
448
- message:
449
- "this is a symbolic link, and a recipe may not publish one. A published " +
450
- "version's content hash covers the recipe folder itself, so sous neither " +
451
- "hashes nor installs what a link points at; the file would simply be missing " +
452
- "for everyone who installs the recipe. Replace the link with the file itself.",
453
- });
454
- continue;
455
- }
456
-
457
- if (entry.isDirectory()) walk(path.join(current, entry.name), relativePath);
458
- }
459
- };
460
-
461
- walk(dir, "");
462
- return problems;
463
- }