@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,3859 +0,0 @@
1
- /**
2
- * The subscription service: the workflow the consumer commands drive.
3
- *
4
- * Everything underneath it is a single-purpose part (a provider, the index
5
- * cache, the resolver, the trust layer, the store, the lockfile). This is the
6
- * one place that puts them in the right order, and the order is the design:
7
- *
8
- * - Nothing is fetched from a repository before it is trusted, not even its
9
- * index. `addRepo` runs the trust ceremony first and fetches second.
10
- * - Resolution is iterative. Each round may turn up repositories a dependency
11
- * needs that the project has not added; those go through one consolidated
12
- * trust question and the round runs again.
13
- * - A subscription is not finished until its questions are answered, so
14
- * `subscribe` ends by asking for the variables its recipes publish.
15
- * - Restoring never decides anything: it fetches exactly what the lockfile
16
- * pins, which is what makes a fresh clone reproducible and prompt-free.
17
- *
18
- * Every collaborator is injectable, so a test can drive the whole workflow
19
- * against a local fixture repository without a network.
20
- */
21
-
22
- import fsp from "node:fs/promises";
23
- import path from "node:path";
24
- import semver from "semver";
25
- import { ConfigError, isConfigError } from "../errors.js";
26
- import { SOUS_VERSION, type ConfigContext, type Settings, type VarScope } from "../settings.js";
27
- import { CONFD_DIR_NAME } from "../config-discovery.js";
28
- import {
29
- BULLET,
30
- indent,
31
- log,
32
- palette,
33
- warning,
34
- wrapColumns,
35
- wrapText,
36
- } from "../../utils/formatting.js";
37
- import { askChoice, askYesNo } from "../../utils/prompts.js";
38
- import { ensureStoreRootDirectory } from "../../utils/sous-directory.js";
39
- import { isInteractive, nonInteractiveError } from "../interactive.js";
40
- import {
41
- applyProvidedAnswers,
42
- askForMissing,
43
- loadLadderContext,
44
- planQuestions,
45
- validateProvidedAnswers,
46
- type AskReport,
47
- type DefinedVariable,
48
- type DefiningRecipe,
49
- type LadderContext,
50
- type PlannedVariable,
51
- type ProvidedAnswer,
52
- } from "../vars/index.js";
53
- import type { IndexFile } from "./formats/index-file.js";
54
- import {
55
- PROJECT_HOLDER,
56
- type LockedRecipe,
57
- type Lockfile,
58
- } from "./formats/lockfile.js";
59
- import type { RecipeManifest, VariableDefinition } from "./formats/recipe-manifest.js";
60
- import {
61
- CachedIndexLookup,
62
- RefPickArguments,
63
- RefResolveArguments,
64
- RefSource,
65
- catalogRepoOfIndex,
66
- describeRef,
67
- formatRef,
68
- isNamedRef,
69
- locationFromUrl,
70
- locationOf,
71
- matchNames,
72
- namespaceOf,
73
- namespaceOfKey,
74
- parseNamedRef,
75
- rangeOf,
76
- repoOf,
77
- sharedRefPicker,
78
- sharedRefResolver,
79
- shortKey,
80
- splitRecipeKey,
81
- type CatalogRepo,
82
- type NamedRef,
83
- type RefKind,
84
- type SousRef,
85
- } from "../../services/ref-resolver/index.js";
86
- import { describeIndexSearch } from "./ref-search.js";
87
- import {
88
- PROJECT_REQUESTER,
89
- proposeRepoName,
90
- resolveRefs,
91
- type MissingRepo,
92
- type RefRequest,
93
- type ResolvedRecipe,
94
- type ResolverRepo,
95
- } from "./resolver.js";
96
- import {
97
- LockService,
98
- type LockDiff,
99
- type LockRepoInput,
100
- type RestoreReport,
101
- } from "./lock-service.js";
102
- import { TrustService, USER_ADDED_BY, type TrustedRepo } from "./trust.js";
103
- import {
104
- SUBSCRIPTIONS_LAYER_FILENAME,
105
- readManagedLayer,
106
- removeManagedLayer,
107
- updateManagedLayer,
108
- } from "./managed-layer.js";
109
- import {
110
- builtInProviders,
111
- createIndexCache,
112
- requireProvider,
113
- type IndexCache,
114
- type ProviderOptions,
115
- type ProviderId,
116
- type RepoProvider,
117
- } from "./providers/index.js";
118
- import { normalizeRepoUrl } from "./providers/provider.js";
119
- import {
120
- assertLocalRepoDirectory,
121
- looksLikeLocalPath,
122
- resolveRepoArgument,
123
- } from "./providers/local.js";
124
- import { RecipeStore } from "./store/recipe-store.js";
125
- import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
126
- import { resolveStoreSettings } from "./store/settings.js";
127
- import {
128
- NEWER_VERSION_CHECK_TIMEOUT_MS,
129
- effectiveRangeForHolders,
130
- findNewerInRange,
131
- recordUpstreamCheck,
132
- shouldCheckUpstream,
133
- type NewerVersion,
134
- withDeadline,
135
- } from "./freshness.js";
136
- import {
137
- describeUpdateScope,
138
- formatUpdatePlan,
139
- isEmptyUpdate,
140
- recipeInScope,
141
- type UpdateScope,
142
- } from "./update-plan.js";
143
- import { REPO_NAME_PATTERN } from "./formats/patterns.js";
144
- import {
145
- linkedPathFor,
146
- readGlobalLinks,
147
- readProjectLinks,
148
- writeProjectLinks,
149
- } from "./links.js";
150
- import {
151
- keysHeldBySubscription,
152
- lockedClosure,
153
- listLockedRecipes,
154
- mapLinkedRecipes,
155
- readRecipeManifestIn,
156
- } from "./locked-recipes.js";
157
- import { resolveStoreRoot } from "../sous-home.js";
158
- import {
159
- packagedCoreIndexOverlay,
160
- seedCoreRecipe,
161
- type SeedCoreRecipeReport,
162
- } from "./seed.js";
163
- import { enabledRepos, enabledSubscriptions, isBuiltInEntry } from "./defaults.js";
164
- import {
165
- CORE_NAMESPACE,
166
- CORE_RECIPE_KEY,
167
- OFFICIAL_REPO_IDENTITY,
168
- packagedCoreRecipeDir,
169
- } from "./core-recipe.js";
170
- import { repoIdentity } from "./identity.js";
171
- import { buildRecipeTargets } from "./recipe-targets.js";
172
- import { resolveOutputPath } from "../markdown-compiler.js";
173
- import { hashDirectory } from "./store/hash.js";
174
-
175
- // --- Options and reports ------------------------------------------------------------------------
176
-
177
- /** How the subscription service is built. Every collaborator is injectable. */
178
- export type SubscriptionServiceOptions = {
179
- /** The project's `.sous/` directory: lockfile, links map and env files. */
180
- sousDir: string;
181
- /** The project's `conf.d/` directory. Defaults to `<sousDir>/conf.d`. */
182
- confDir?: string;
183
- /** The merged project config. */
184
- settings: Settings;
185
- /** The environment to read; decides where the store is. Defaults to `process.env`. */
186
- env?: NodeJS.ProcessEnv;
187
- /**
188
- * The real shell environment, snapshotted before the `.sous/` env files were
189
- * injected. Used only to tell a shell-supplied answer from a file-supplied one.
190
- */
191
- shellEnv?: NodeJS.ProcessEnv;
192
- /** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
193
- interactive?: boolean;
194
- /** The recipe store. Defaults to the machine-wide one. */
195
- store?: RecipeStoreLike;
196
- /** The index cache. Defaults to one rooted at the store. */
197
- indexCache?: IndexCache;
198
- /** The trust layer. Defaults to one bound to this project. */
199
- trust?: TrustService;
200
- /** The lockfile service. Defaults to one bound to this project. */
201
- lock?: LockService;
202
- /** The providers to choose from. Defaults to the built-ins. */
203
- providers?: RepoProvider[];
204
- /** Options handed to every provider call. */
205
- providerOptions?: ProviderOptions;
206
- /** Where warnings go. Defaults to the console warning banner. */
207
- warn?: (message: string) => void;
208
- /** Where the plan and the resolution notices go. Defaults to the console. */
209
- write?: (message: string) => void;
210
- /** How a yes or no question is asked. Injected in tests. */
211
- ask?: (message: string) => Promise<boolean>;
212
- /** How a choice between candidate refs is asked. Injected in tests. */
213
- choose?: (message: string, candidates: SousRef[]) => Promise<SousRef>;
214
- /** The clock, so a recorded timestamp is predictable in tests. */
215
- now?: () => Date;
216
- };
217
-
218
- /** What `addRepo` is asked to do. */
219
- export type AddRepoOptions = {
220
- /** The repository's URL, or an absolute path for one on this machine. */
221
- url: string;
222
- /** The short name refs will use. Defaults to the last segment of the URL. */
223
- name?: string;
224
- /** The provider that handles it, when the URL does not give it away. */
225
- provider?: ProviderId;
226
- /** Acknowledge trust without being asked, for a run with no terminal. */
227
- trust?: boolean;
228
- /** Work out what would happen and report it, writing and fetching nothing. */
229
- dryRun?: boolean;
230
- };
231
-
232
- /** What `addRepo` did. */
233
- export type AddRepoOutcome = {
234
- /** The short name the repository was recorded under. */
235
- name: string;
236
- /** Where it lives, as recorded. */
237
- url: string;
238
- /** The provider that handles it. */
239
- provider: ProviderId;
240
- /** True when the project already trusted this exact repository. */
241
- alreadyTrusted: boolean;
242
- /** Every namespace its index declares, sorted. */
243
- namespaces: string[];
244
- /** How many recipes its index publishes. */
245
- recipeCount: number;
246
- /** True when nothing was written, because this was a dry run. */
247
- dryRun: boolean;
248
- };
249
-
250
- /** What `subscribe` is asked to do. */
251
- export type SubscribeOptions = {
252
- /** The ref to subscribe to: a namespace, or `namespace/recipe`, with an optional range. */
253
- ref: string;
254
- /** Let prerelease versions take part in range matching. */
255
- prerelease?: boolean;
256
- /** Prefer a newer in-range version over the locked one on every build. */
257
- alwaysPull?: boolean;
258
- /** Acknowledge trust for every repository this command adds, without being asked. */
259
- trust?: boolean;
260
- /** Accept the subscribe confirmation without being asked. */
261
- yes?: boolean;
262
- /** Take the first candidate when a one-word ref matched several things. */
263
- acceptFirst?: boolean;
264
- /**
265
- * Answers supplied ahead of the questions, which are validated and stored
266
- * before anything is asked. See `lib/vars/preanswers.ts`.
267
- */
268
- answers?: ProvidedAnswer[];
269
- /** Work out what would happen and report it, writing and fetching nothing. */
270
- dryRun?: boolean;
271
- };
272
-
273
- /** What `subscribe` did. */
274
- export type SubscribeOutcome = {
275
- /** The ref that was installed, fully qualified, in its canonical written form. */
276
- ref: string;
277
- /** The ref as it was written, when a one-word ref had to be resolved first. */
278
- resolvedFrom?: string;
279
- /** The key the subscription was recorded under. */
280
- key: string;
281
- /** Every recipe version the resolution settled on. */
282
- resolved: ResolvedRecipe[];
283
- /** Repositories that had to be trusted along the way. */
284
- trusted: string[];
285
- /** What changed in the lockfile. */
286
- diff: LockDiff;
287
- /** What the variable questions produced, when they were asked. */
288
- answers?: AskReport;
289
- /**
290
- * Every question these recipes would ask, and where each answer would go.
291
- * Reported by a dry run, which is how a caller with no terminal finds out
292
- * what to supply with `--answer`.
293
- */
294
- questions?: PlannedVariable[];
295
- /**
296
- * Recipes a dry run could not describe: their files are not on this machine,
297
- * a dry run downloads nothing, and their repository's index does not record
298
- * their questions. Their questions are unknown until they are installed.
299
- */
300
- unreadable?: string[];
301
- /** Dependency cycles the resolver noticed, reported rather than treated as fatal. */
302
- cycles: string[][];
303
- /** True when nothing was written, because this was a dry run. */
304
- dryRun: boolean;
305
- };
306
-
307
- /** What `previewSubscription` is asked about. */
308
- export type PreviewSubscriptionOptions = {
309
- /** The indexes to resolve against, keyed by short name; the cached ones by default. */
310
- indexes?: Map<string, IndexFile>;
311
- /** Whether prerelease versions may take part in range matching. */
312
- prerelease?: boolean;
313
- };
314
-
315
- /** What subscribing to a ref would install and ask, worked out without changing anything. */
316
- export type SubscriptionPreview = {
317
- /** Every recipe version the subscription would install, sorted by key. */
318
- resolved: ResolvedRecipe[];
319
- /** Repositories the closure needs that the project does not trust yet. */
320
- missingRepos: MissingRepo[];
321
- /** Every question the closure would ask, and where each answer would go. */
322
- questions: PlannedVariable[];
323
- /** Recipes whose questions cannot be listed; see `SubscribeOutcome.unreadable`. */
324
- unreadable: string[];
325
- /** Dependency cycles the resolver noticed. */
326
- cycles: string[][];
327
- };
328
-
329
- /** What `unsubscribe` is asked to do. */
330
- export type UnsubscribeOptions = {
331
- /** The ref to unsubscribe from. */
332
- ref: string;
333
- /** Work out what would happen and report it, writing nothing. */
334
- dryRun?: boolean;
335
- };
336
-
337
- /** What `unsubscribe` did. */
338
- export type UnsubscribeOutcome = {
339
- /** The key the subscription was recorded under. */
340
- key: string;
341
- /** What changed in the lockfile. */
342
- diff: LockDiff;
343
- /** Recipes that stayed because something else still holds them, with who holds them. */
344
- stayed: Array<{ key: string; heldBy: string[] }>;
345
- /**
346
- * True when the subscription was one sous provides itself, so it was switched
347
- * off with an `enabled: false` entry rather than deleted. The entry sous
348
- * provides comes back on every run; only a recorded opt-out outlives it.
349
- */
350
- optedOut: boolean;
351
- /** True when nothing was written, because this was a dry run. */
352
- dryRun: boolean;
353
- };
354
-
355
- /** What `removeRepo` is asked to do. */
356
- export type RemoveRepoOptions = {
357
- /** The repository's short name, as the project records it. */
358
- name: string;
359
- /** Accept the removal without being asked. */
360
- yes?: boolean;
361
- /** Work out what would happen and report it, writing nothing. */
362
- dryRun?: boolean;
363
- /**
364
- * The resolved settings scope, used to work out which output files the
365
- * removed recipes wrote. Without it a `recipes.skills.outputs` destination holding a
366
- * `${var}` cannot be resolved, and the file list is left out of the report.
367
- */
368
- scope?: VarScope;
369
- };
370
-
371
- /** What `removeRepo` did, or would do on a dry run. */
372
- export type RemoveRepoOutcome = {
373
- /** The repository's short name. */
374
- name: string;
375
- /** Where it lives, as the entry recorded it. */
376
- url: string;
377
- /**
378
- * True when the repository is one sous provides itself, so it was switched
379
- * off with an `enabled: false` entry rather than deleted. The entry sous
380
- * provides comes back on every run; only a recorded opt-out outlives it.
381
- */
382
- optedOut: boolean;
383
- /** The subscriptions that were removed because they resolve into it. */
384
- subscriptions: string[];
385
- /**
386
- * Subscriptions that resolve into it but are written in the project's own
387
- * config, which sous never edits. They stay, and are named so the person
388
- * removing the repository knows to deal with them.
389
- */
390
- keptSubscriptions: string[];
391
- /** The locked recipes that were released, because nothing else held them. */
392
- removedRecipes: string[];
393
- /** Recipes that stayed because something else still holds them, with who holds them. */
394
- stayed: Array<{ key: string; heldBy: string[] }>;
395
- /** The output files the removed recipes compiled, which the next build prunes. */
396
- outputs: string[];
397
- /** What changed in the lockfile. */
398
- diff: LockDiff;
399
- /** The linked checkout that pointed at the repository, when there was one. */
400
- linkedPath?: string;
401
- /**
402
- * True when that link is the machine-wide one, which other projects on this
403
- * machine share, so it was left exactly as it was.
404
- */
405
- linkIsGlobal: boolean;
406
- /** True when nothing was written, because this was a dry run. */
407
- dryRun: boolean;
408
- };
409
-
410
- /** One row of the subscription listing: what a project subscribes to, and why. */
411
- export type SubscriptionListing = {
412
- /** The ref key: a bare namespace, or `namespace/recipe`. */
413
- key: string;
414
- /** The version range the subscription resolves within, when one was written. */
415
- range: string | undefined;
416
- /** False when the entry is switched off with `enabled: false`. */
417
- enabled: boolean;
418
- /** What the entry recorded about who wanted it, when it recorded anything. */
419
- addedBy: string | undefined;
420
- /** Every recipe the lockfile pins because of this subscription, sorted by key. */
421
- pinned: Array<{ key: string; version: string }>;
422
- };
423
-
424
- /** What bringing the lockfile in line with the declared subscriptions produced. */
425
- export type SubscriptionSyncReport = {
426
- /** The subscriptions that had to be resolved, by ref key. */
427
- resolved: string[];
428
- /** Recipes the lockfile did not pin before and pins now. */
429
- added: Array<{ key: string; version: string }>;
430
- /** Recipes whose pinned version moved to satisfy a subscription's range. */
431
- moved: Array<{ key: string; from: string; to: string }>;
432
- /** Subscriptions that could not be resolved, each with a plain-language reason. */
433
- failed: Array<{ key: string; reason: string }>;
434
- };
435
-
436
- /** What an upstream check found. */
437
- export type UpstreamCheckReport = {
438
- /** Repositories that were actually asked. */
439
- checked: string[];
440
- /** Recipes moved to a newer in-range version. */
441
- updated: Array<{ key: string; from: string; to: string }>;
442
- /** Repositories whose check failed; the last good answer still stands. */
443
- failed: Array<{ repo: string; reason: string }>;
444
- /**
445
- * Locked recipes with a newer version inside the range their holders
446
- * declared, in a repository that does not prefer newer versions. Nothing
447
- * moved; this is only what a build tells the person running it.
448
- */
449
- newer: Array<{ key: string; repo: string; from: string; to: string }>;
450
- /**
451
- * Repositories sous looked at only to find newer versions, whose look failed
452
- * or ran out of time. Kept quiet: the cached index answered instead.
453
- */
454
- unchecked: Array<{ repo: string; reason: string }>;
455
- };
456
-
457
- /** What `update` is asked to do. */
458
- export type UpdateOptions = {
459
- /**
460
- * What to update: a repository, a namespace or a recipe, written at any level
461
- * of qualification and resolved through `src/services/ref-resolver/`. Left out, every
462
- * subscription is updated.
463
- */
464
- ref?: string;
465
- /**
466
- * A repository short name the caller has already settled, which narrows the
467
- * update exactly as a reference naming that repository would. `sous repo
468
- * unlink --update` passes this; it is never combined with `ref`.
469
- */
470
- repo?: string;
471
- /** Accept the plan, and trust any repository a newer version needs, without being asked. */
472
- yes?: boolean;
473
- /** Take the first candidate when the reference matched several things. */
474
- acceptFirst?: boolean;
475
- /** Answers supplied ahead of the questions the new versions ask. */
476
- answers?: ProvidedAnswer[];
477
- /** Work out and print the plan, writing nothing and downloading no recipe. */
478
- dryRun?: boolean;
479
- };
480
-
481
- /** What `update` did, or would do on a dry run. */
482
- export type UpdateOutcome = {
483
- /** What the update covered. */
484
- scope: UpdateScope;
485
- /** What changed in the lockfile. */
486
- diff: LockDiff;
487
- /** True when nothing needed moving, so nothing was asked and nothing written. */
488
- nothingToUpdate: boolean;
489
- /** Repositories whose index could not be fetched; their pins stayed. */
490
- unreachable: Array<{ repo: string; reason: string }>;
491
- /** Subscriptions that could not be resolved; their pins stayed. */
492
- failed: Array<{ key: string; reason: string }>;
493
- /** Linked repositories whose pins moved. */
494
- linked: string[];
495
- /** Repositories trusted along the way. */
496
- trusted: string[];
497
- /** What the variable questions produced, when any were asked. */
498
- answers?: AskReport;
499
- /** Dependency cycles the resolver noticed. */
500
- cycles: string[][];
501
- /** True when nothing was written, because this was a dry run. */
502
- dryRun: boolean;
503
- };
504
-
505
- /** What looking for newer published versions of one repository's pins found. */
506
- export type NewerVersionsReport =
507
- | {
508
- /** The index was fetched and compared. */
509
- checked: true;
510
- /** Every pin with a newer published version its range allows, by key. */
511
- newer: NewerVersion[];
512
- }
513
- | {
514
- /** The index could not be fetched in time, so nothing is known. */
515
- checked: false;
516
- /** Why not, as a sentence. */
517
- reason: string;
518
- };
519
-
520
- // --- The service --------------------------------------------------------------------------------
521
-
522
- /** Adds repositories, subscribes to recipes, and keeps the store and lockfile honest. */
523
- export class SubscriptionService {
524
- private readonly sousDir: string;
525
-
526
- private readonly confDir: string;
527
-
528
- private readonly settings: Settings;
529
-
530
- private readonly env: NodeJS.ProcessEnv;
531
-
532
- private readonly shellEnv: NodeJS.ProcessEnv;
533
-
534
- private readonly interactive: boolean;
535
-
536
- private readonly providers: RepoProvider[];
537
-
538
- private readonly providerOptions: ProviderOptions;
539
-
540
- private readonly warn: (message: string) => void;
541
-
542
- private readonly write: (message: string) => void;
543
-
544
- private readonly ask: (message: string) => Promise<boolean>;
545
-
546
- private readonly choose: (message: string, candidates: SousRef[]) => Promise<SousRef>;
547
-
548
- private readonly now: () => Date;
549
-
550
- private readonly storeInstance: RecipeStoreLike;
551
-
552
- private readonly indexCache: IndexCache;
553
-
554
- private readonly trust: TrustService;
555
-
556
- private readonly lock: LockService;
557
-
558
- /**
559
- * Canonical identities already worked out, keyed by the provider and URL they
560
- * came from. Canonicalizing is pure, so it is worth doing once per run.
561
- */
562
- private readonly identityCache = new Map<string, string>();
563
-
564
- /**
565
- * What seeding the packaged core recipe did, once it has been done. Seeding is
566
- * idempotent but not free (it verifies the store entry against its content
567
- * hash), so one service instance does it at most once.
568
- */
569
- private seedReport: SeedCoreRecipeReport | undefined;
570
-
571
- /**
572
- * Where each locked recipe's files are, keyed by recipe key, once it has been
573
- * looked up. Deriving the range a `depends`-held recipe may move within asks
574
- * for this once per lockfile entry, and the answer does not change during a
575
- * command.
576
- */
577
- private lockedDirectories: Record<string, string> | undefined;
578
-
579
- /**
580
- * Every trusted repository's index, once it has been loaded. Working out what
581
- * a one-word ref meant and describing what a subscription will do both read
582
- * it, within one command, and an index does not change mid-command.
583
- */
584
- private indexesSnapshot: Map<string, IndexFile> | undefined;
585
-
586
- /**
587
- * @param options - The project's directories, its config, and any collaborator to override.
588
- */
589
- constructor(options: SubscriptionServiceOptions) {
590
- this.sousDir = options.sousDir;
591
- this.confDir = options.confDir ?? path.join(options.sousDir, CONFD_DIR_NAME);
592
- this.settings = options.settings;
593
- this.env = options.env ?? process.env;
594
- this.shellEnv = options.shellEnv ?? this.env;
595
- this.interactive = options.interactive ?? isInteractive();
596
- this.providers = options.providers ?? builtInProviders();
597
- this.providerOptions = options.providerOptions ?? {};
598
- this.warn = options.warn ?? warning;
599
- this.write = options.write ?? ((message: string) => log(message));
600
- this.ask = options.ask ?? ((message: string) => askYesNo(message));
601
- this.choose =
602
- options.choose ??
603
- ((message, candidates) =>
604
- askChoice(
605
- message,
606
- candidates.map((candidate) => ({
607
- name: describeRef(candidate),
608
- value: candidate,
609
- }))
610
- ));
611
- this.now = options.now ?? (() => new Date());
612
-
613
- this.storeInstance =
614
- options.store ??
615
- new RecipeStore({ root: resolveStoreRoot(this.env), onWarning: this.warn });
616
- this.indexCache =
617
- options.indexCache ??
618
- createIndexCache({
619
- storeRoot: this.storeInstance.root,
620
- resolveProvider: (url, providerId) =>
621
- requireProvider(url, providerId, this.providers),
622
- providerOptions: this.providerOptions,
623
- warn: this.warn,
624
- now: this.now,
625
- // Every cache a command builds knows the packaged core version from the
626
- // start, so a command that never seeds (a lockfile rebuild, a browsing
627
- // command) resolves and lists exactly what a build does. Seeding
628
- // replaces this with an overlay carrying the seeded entry's own hash.
629
- overlay: packagedCoreIndexOverlay({ version: SOUS_VERSION, warn: this.warn }),
630
- });
631
- this.trust =
632
- options.trust ??
633
- new TrustService({
634
- sousDir: this.sousDir,
635
- confDir: this.confDir,
636
- settings: this.settings,
637
- interactive: this.interactive,
638
- // The trust question is one of this service's questions, so it is asked
639
- // and printed through the same seams as the rest of them.
640
- ask: this.ask,
641
- write: this.write,
642
- now: this.now,
643
- });
644
- this.lock = options.lock ?? new LockService(this.sousDir);
645
- }
646
-
647
- /** The recipe store this service fills and reads. */
648
- get store(): RecipeStoreLike {
649
- return this.storeInstance;
650
- }
651
-
652
- /** The lockfile service this project uses. */
653
- get lockService(): LockService {
654
- return this.lock;
655
- }
656
-
657
- /** The index cache, for a command that wants to read a cached index without fetching. */
658
- get indexes(): IndexCache {
659
- return this.indexCache;
660
- }
661
-
662
- /**
663
- * The cached index of one added repository, or undefined when nothing has
664
- * been fetched from it yet. Nothing is downloaded. Callers name the
665
- * repository the way the project does, by its short name; the cache itself is
666
- * keyed by identity, and this is what translates between the two.
667
- *
668
- * @param name - The repository's short name.
669
- */
670
- cachedIndex(name: string): IndexFile | undefined {
671
- const identity = this.identityForRepo(name);
672
- return identity === undefined ? undefined : this.indexCache.readCached(identity);
673
- }
674
-
675
- /**
676
- * Every trusted repository as a reference searches it, read from the cached
677
- * indexes alone: what it publishes, where each recipe lives, and its
678
- * canonical identity, so a reference written as a location finds it whatever
679
- * this project calls it. A repository whose index has never been fetched can
680
- * still be named, just not searched inside.
681
- */
682
- cachedReferenceRepos(): CatalogRepo[] {
683
- const repos = this.resolverRepos();
684
- return Object.keys(repos).map((name) => {
685
- const { url } = repos[name]!;
686
- const index = this.cachedIndex(name);
687
- const location = locationFromUrl(url, this.providers);
688
- const known = { name, ...(location === undefined ? {} : { location }) };
689
- return index === undefined
690
- ? { ...known, namespaces: [], recipes: [] }
691
- : catalogRepoOfIndex(index, known);
692
- });
693
- }
694
-
695
- // --- Repository identity ----------------------------------------------------------------------
696
-
697
- /**
698
- * The canonical identity of a repository at a URL: what the machine-wide
699
- * store and the index cache file it under. Canonicalizing runs a provider's
700
- * URL parser, so the answers are remembered for the life of the service.
701
- *
702
- * @param url - Where the repository lives.
703
- * @param providerId - The provider the repository entry names, when it names one.
704
- */
705
- private identityOf(url: string, providerId?: string): string {
706
- const cacheKey = `${providerId ?? ""}|${url}`;
707
- const known = this.identityCache.get(cacheKey);
708
- if (known !== undefined) return known;
709
-
710
- const provider = requireProvider(url, providerId, this.providers);
711
- const identity = repoIdentity(provider.canonicalize(url));
712
- this.identityCache.set(cacheKey, identity);
713
- return identity;
714
- }
715
-
716
- /**
717
- * The canonical identity of an added repository, by the short name this
718
- * project calls it. The lockfile is consulted second, so a repository that
719
- * has been removed from the config can still be located while its recipes are
720
- * being cleaned up.
721
- *
722
- * @param name - The repository's short name.
723
- * @param lock - The lockfile, when the caller has already read it.
724
- */
725
- identityForRepo(name: string, lock?: Lockfile): string | undefined {
726
- const entry = this.currentRepos()[name];
727
- if (entry?.url !== undefined) return this.identityOf(entry.url, entry.provider);
728
-
729
- const locked = (lock ?? this.lock.read()).repos[name];
730
- if (locked === undefined) return undefined;
731
- return locked.identity;
732
- }
733
-
734
- // --- Adding a repository ----------------------------------------------------------------------
735
-
736
- /**
737
- * Adds a repository, which is the same thing as trusting it, and then fetches
738
- * exactly one file from it: its index. Nothing is downloaded before the trust
739
- * question is answered.
740
- *
741
- * A path is normalized before anything else happens: `~` is expanded and a
742
- * relative path is resolved against the working directory, so what gets
743
- * stored is always absolute (a repository on this machine is machine-specific
744
- * whichever way it was typed). A path that is not a repository is reported as
745
- * a path mistake, naming what was typed and where sous looked, rather than as
746
- * a provider that could not be found.
747
- *
748
- * @param options - The URL, an optional short name and provider, and the trust flag.
749
- */
750
- async addRepo(options: AddRepoOptions): Promise<AddRepoOutcome> {
751
- const typed = options.url.trim();
752
- const url = resolveRepoArgument(typed);
753
- if (looksLikeLocalPath(typed)) assertLocalRepoDirectory(typed, url);
754
- const provider = requireProvider(url, options.provider, this.providers);
755
- const canonical = provider.canonicalize(url);
756
- const name = options.name ?? canonical.name;
757
-
758
- if (!REPO_NAME_PATTERN.test(name)) {
759
- throw new ConfigError(
760
- `'${name}' is not a usable short name for a repository.\n` +
761
- ` A short name is lowercase kebab-case: a letter, then letters, digits or ` +
762
- `hyphens. It is what refs use as the 'repo:' qualifier.\n` +
763
- ` Choose one with '--name', for example ` +
764
- `'sous repo add ${url} --name my-recipes'.`
765
- );
766
- }
767
-
768
- const existing = this.currentRepos()[name];
769
- const alreadyTrusted =
770
- existing !== undefined && normalizeRepoUrl(existing.url) === normalizeRepoUrl(url);
771
-
772
- if (existing !== undefined && !alreadyTrusted) {
773
- throw new ConfigError(
774
- `This project already has a repository called '${name}', and it is a different one.\n` +
775
- ` Already added: ${existing.url}\n` +
776
- ` Being added: ${url}\n` +
777
- ` Give this one a name of its own with '--name', for example ` +
778
- `'sous repo add ${url} --name ${name}-2'.`
779
- );
780
- }
781
-
782
- if (options.dryRun === true) {
783
- return {
784
- name,
785
- url,
786
- provider: provider.id,
787
- alreadyTrusted,
788
- namespaces: [],
789
- recipeCount: 0,
790
- dryRun: true,
791
- };
792
- }
793
-
794
- // Trust first, fetch second. This is the last gate before a repository's
795
- // recipes can put files (and scripts) on this machine.
796
- if (!alreadyTrusted) {
797
- await this.trust.confirmTrust(
798
- [
799
- {
800
- name,
801
- url,
802
- requiredBy: [{ ref: name, requestedBy: PROJECT_REQUESTER }],
803
- },
804
- ],
805
- {
806
- interactive: this.interactive,
807
- ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
808
- }
809
- );
810
- }
811
-
812
- // Written again even when confirmTrust already wrote it, so the entry records
813
- // that a person added this repository deliberately rather than a dependency
814
- // having dragged it in.
815
- this.trust.addRepo({
816
- name,
817
- url,
818
- ...(options.provider === undefined ? {} : { provider: options.provider }),
819
- addedBy: USER_ADDED_BY,
820
- });
821
-
822
- const lookup = await this.indexCache.getIndex(this.identityOf(url, provider.id), {
823
- url,
824
- label: name,
825
- ...(options.provider === undefined ? {} : { provider: options.provider }),
826
- force: true,
827
- });
828
-
829
- return {
830
- name,
831
- url,
832
- provider: provider.id,
833
- alreadyTrusted,
834
- namespaces: Object.keys(lookup.index.namespaces).sort(),
835
- recipeCount: Object.keys(lookup.index.recipes).length,
836
- dryRun: false,
837
- };
838
- }
839
-
840
- // --- Subscribing ------------------------------------------------------------------------------
841
-
842
- /**
843
- * Subscribes the project to a namespace or a recipe: resolves the whole
844
- * dependency closure, trusts whatever new repositories that turns up, fetches
845
- * every resolved version into the store, writes the lockfile and the managed
846
- * subscriptions layer, and finally asks for the variables the new recipes
847
- * publish.
848
- *
849
- * @param options - The ref, the prerelease and always-pull flags, and the trust flag.
850
- */
851
- async subscribe(options: SubscribeOptions): Promise<SubscribeOutcome> {
852
- const dryRun = options.dryRun === true;
853
-
854
- // What the ref names is settled here, from the cached indexes alone: a
855
- // one-word ref is a guess at a name, and a location is matched against the
856
- // repository at that location (trusted first, when it is not yet). Every
857
- // step after this works with a fully qualified ref, so what is confirmed
858
- // is exactly what is installed.
859
- const { parsed, trusted: trustedForRef } = await this.resolveWrittenRef(options.ref, options);
860
- const key = shortKey(parsed);
861
-
862
- // The last gate before anything is fetched or written: what this will do to
863
- // the project, in plain sentences, and a question.
864
- await this.confirmSubscription(parsed, options);
865
-
866
- const closure = await this.resolveClosure(parsed, options);
867
- const { resolved, cycles } = closure;
868
- const trusted = [...trustedForRef, ...closure.trusted];
869
-
870
- const before = this.lock.read();
871
- const after = this.lock.applyResolution(before, resolved, this.lockRepoInputs());
872
- const diff = this.lock.diff(before, after);
873
-
874
- const resolvedFrom =
875
- options.ref.trim() === formatRef(parsed) ? {} : { resolvedFrom: options.ref.trim() };
876
-
877
- if (dryRun) {
878
- // The questions are planned even here, so `--dry-run` is the command an
879
- // agent runs to find out what a subscription will want to know. Answers
880
- // supplied with it are validated and reported, and nothing is written.
881
- //
882
- // What can be described is settled from the files actually on this
883
- // machine, not from what the resolver happened to reach: a recipe already
884
- // in the store, or read from a linked checkout, has its questions listed
885
- // even when something else in the closure is still missing.
886
- const defined = this.definedVariables(resolved);
887
- const unreadable = this.unreadableRecipes(resolved);
888
- const context = this.ladderContext();
889
- const supplied = applyProvidedAnswers(defined, options.answers ?? [], context, {
890
- sousDir: this.sousDir,
891
- confDir: this.confDir,
892
- interactive: this.interactive,
893
- dryRun: true,
894
- });
895
-
896
- return {
897
- ref: formatRef(parsed),
898
- ...resolvedFrom,
899
- key,
900
- resolved,
901
- trusted,
902
- diff,
903
- cycles,
904
- questions: planQuestions(defined, context, { sousDir: this.sousDir }),
905
- ...(unreadable.length === 0 ? {} : { unreadable }),
906
- ...(supplied.stored.length === 0
907
- ? {}
908
- : { answers: { answered: supplied.stored, inherited: [], skipped: [] } }),
909
- dryRun: true,
910
- };
911
- }
912
-
913
- // Supplied answers are checked before anything is written, so an answer
914
- // that does not fit, or a name nothing declares, fails the run rather than
915
- // leaving a subscription behind with its questions unanswered.
916
- validateProvidedAnswers(this.definedVariables(resolved), options.answers ?? []);
917
-
918
- for (const recipe of resolved) await this.ensureStored(recipe);
919
-
920
- this.lock.write(after);
921
- this.writeSubscriptionEntry(key, parsed, options);
922
-
923
- const answers = await this.askVariables(resolved, options.answers ?? []);
924
-
925
- return {
926
- ref: formatRef(parsed),
927
- ...resolvedFrom,
928
- key,
929
- resolved,
930
- trusted,
931
- diff,
932
- answers,
933
- cycles,
934
- dryRun: false,
935
- };
936
- }
937
-
938
- // --- Working out what a one-word ref meant -----------------------------------------------------
939
-
940
- /**
941
- * Settles what a ref typed on the command line names, in any form it was
942
- * written: a short ref goes through `resolveBareRef`; a location (a URL, an
943
- * SSH remote, a provider-scheme locator, a browser URL) is matched against
944
- * the trusted repository at that location and settled through its index.
945
- * When no reading's repository is trusted yet, the trust ceremony runs for
946
- * it first, exactly as it runs for a dependency's repository, and nothing is
947
- * fetched from it before the answer.
948
- *
949
- * @param written - The ref exactly as the user wrote it.
950
- * @param options - The trust and accept-first flags.
951
- * @returns The fully qualified ref, and any repository trusted to settle it.
952
- */
953
- private async resolveWrittenRef(
954
- written: string,
955
- options: SubscribeOptions
956
- ): Promise<{ parsed: NamedRef; trusted: string[] }> {
957
- const { refs: readings } = sharedRefResolver().parse(written);
958
- const short = readings.find(
959
- (reading): reading is NamedRef => isNamedRef(reading) && locationOf(reading) === undefined
960
- );
961
- if (short !== undefined) {
962
- return { parsed: await this.resolveBareRef(short, options), trusted: [] };
963
- }
964
-
965
- const trusted = await this.trustLocation(written, readings, options);
966
- const chosen = await this.pickLocated(written, options.acceptFirst);
967
- return { parsed: chosen, trusted };
968
- }
969
-
970
- /**
971
- * The one namespace or recipe a location names among the trusted
972
- * repositories, settled through their cached indexes and chosen between the
973
- * way every reference is.
974
- *
975
- * @param written - The location as written.
976
- * @param acceptFirst - Whether `--accept-first` was passed.
977
- */
978
- private async pickLocated(written: string, acceptFirst?: boolean): Promise<NamedRef> {
979
- const repoOrder = this.repoSearchOrder();
980
- const indexes = await this.loadIndexes(repoOrder);
981
- this.indexesSnapshot = indexes;
982
-
983
- const matches = await this.findNamed(written, repoOrder, indexes);
984
-
985
- return this.pickNamed(matches, {
986
- search: written.trim(),
987
- ...(acceptFirst === undefined ? {} : { acceptFirst }),
988
- details: [
989
- ` The repository at that location is trusted, but its index publishes no namespace ` +
990
- `or recipe there.`,
991
- ` Run 'sous repo list --latest' to read what it publishes now.`,
992
- ],
993
- });
994
- }
995
-
996
- /**
997
- * What a ref names among the trusted repositories' cached indexes, of the
998
- * kinds given. Each repository carries the location it lives at, so a ref
999
- * written as a location finds it whatever this project calls it.
1000
- *
1001
- * @param input - The ref exactly as it was written.
1002
- * @param repoOrder - The repositories, in search order.
1003
- * @param indexes - Their indexes.
1004
- * @param kinds - The kinds of ref this step accepts.
1005
- */
1006
- private async findRefs(
1007
- input: string,
1008
- repoOrder: string[],
1009
- indexes: Map<string, IndexFile>,
1010
- kinds: readonly RefKind[]
1011
- ): Promise<SousRef[]> {
1012
- const repos = this.resolverRepos();
1013
- const urls: Record<string, string | undefined> = {};
1014
- for (const name of repoOrder) urls[name] = repos[name]?.url;
1015
- const lookup = new CachedIndexLookup(indexes, { order: repoOrder, urls });
1016
- const result = await sharedRefResolver().resolve(
1017
- new RefResolveArguments({ input, lookup, kinds, refusedIsEmpty: true })
1018
- );
1019
- return result.refs;
1020
- }
1021
-
1022
- /** What a ref names among the trusted repositories, as a namespace or a recipe. */
1023
- private async findNamed(
1024
- input: string,
1025
- repoOrder: string[],
1026
- indexes: Map<string, IndexFile>
1027
- ): Promise<NamedRef[]> {
1028
- return (await this.findRefs(input, repoOrder, indexes, ["namespace", "recipe"])) as NamedRef[];
1029
- }
1030
-
1031
- /**
1032
- * Settles on one of several refs, the way every command does: one proceeds,
1033
- * several are chosen between through this service's own prompt, and
1034
- * `--accept-first` takes the first.
1035
- *
1036
- * @param matches - What the ref could have meant, in listing order.
1037
- * @param args - What the shared picker is told, less this service's own seams.
1038
- */
1039
- private async pickRef(
1040
- matches: SousRef[],
1041
- args: Omit<ConstructorParameters<typeof RefPickArguments>[0], "interactive" | "write" | "choose">
1042
- ): Promise<SousRef> {
1043
- return sharedRefPicker().pick(
1044
- matches,
1045
- new RefPickArguments({
1046
- ...args,
1047
- interactive: this.interactive,
1048
- write: (message: string) => this.write(message),
1049
- choose: (message, offered) => this.choose(message, offered),
1050
- })
1051
- );
1052
- }
1053
-
1054
- /** `pickRef` for a step that only offers namespaces and recipes. */
1055
- private async pickNamed(
1056
- matches: NamedRef[],
1057
- args: Omit<ConstructorParameters<typeof RefPickArguments>[0], "interactive" | "write" | "choose">
1058
- ): Promise<NamedRef> {
1059
- return (await this.pickRef(matches, args)) as NamedRef;
1060
- }
1061
-
1062
- /**
1063
- * Makes sure the repository a located ref names is trusted: nothing happens
1064
- * when any reading's repository already is. Otherwise the one repository the
1065
- * location names goes through the trust ceremony; a location that reads as
1066
- * several repositories (a GitLab URL with nested groups) is first a question
1067
- * of which one, since nothing may be fetched from a repository to find out
1068
- * before it is trusted.
1069
- *
1070
- * @param written - The location as written.
1071
- * @param readings - Every reading of it.
1072
- * @param options - The trust and accept-first flags.
1073
- * @returns The short names of the repositories trusted here.
1074
- */
1075
- private async trustLocation(
1076
- written: string,
1077
- readings: SousRef[],
1078
- options: SubscribeOptions
1079
- ): Promise<string[]> {
1080
- const repos = this.resolverRepos();
1081
- const known = new Set(Object.values(repos).map((repo) => repo.identity));
1082
- const located = readings.filter((reading) => locationOf(reading) !== undefined);
1083
- if (located.some((reading) => known.has(locationOf(reading)!.identity))) return [];
1084
-
1085
- let chosen = located[0]!;
1086
- const identities = new Set(located.map((reading) => locationOf(reading)!.identity));
1087
- if (identities.size > 1) {
1088
- chosen = await this.pickRef(located, {
1089
- search: written.trim(),
1090
- ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
1091
- prompt: `'${written.trim()}' can be read as more than one repository. Which one?`,
1092
- });
1093
- }
1094
-
1095
- const location = locationOf(chosen)!;
1096
- const outcome = await this.trust.confirmTrust(
1097
- [
1098
- {
1099
- name: proposeRepoName(repos, location.identity),
1100
- url: location.url,
1101
- identity: location.identity,
1102
- provider: location.provider,
1103
- requiredBy: [{ ref: written.trim(), requestedBy: PROJECT_REQUESTER }],
1104
- },
1105
- ],
1106
- {
1107
- interactive: this.interactive,
1108
- ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
1109
- }
1110
- );
1111
- return outcome.added;
1112
- }
1113
-
1114
- /**
1115
- * Settles what a ref names, reading nothing but the cached indexes.
1116
- *
1117
- * A ref with two segments already says what it names and is handed back
1118
- * untouched. A ref with one segment is resolved through the ref resolver
1119
- * (`src/services/ref-resolver/`), over namespaces and recipes only:
1120
- * nothing found is an error naming what was searched, one match is used and
1121
- * reported, and several are chosen between. `--accept-first` takes the first
1122
- * match in the documented order; a run that cannot ask fails and says so.
1123
- *
1124
- * @param written - The ref exactly as the user wrote it.
1125
- * @param options - The accept-first flag.
1126
- */
1127
- private async resolveBareRef(
1128
- written: NamedRef,
1129
- options: SubscribeOptions
1130
- ): Promise<NamedRef> {
1131
- // A ref that names a recipe, spelled as it is published, says what it
1132
- // names already. One word, or a spelling in another case, is searched.
1133
- const writtenRepo = repoOf(written)?.name;
1134
- const key = shortKey(written);
1135
- const search = writtenRepo === undefined ? key : `${writtenRepo}:${key}`;
1136
- if (written.kind === "recipe" && key === key.toLowerCase()) {
1137
- return written;
1138
- }
1139
-
1140
- const qualifier =
1141
- writtenRepo === undefined
1142
- ? undefined
1143
- : (matchNames(writtenRepo, Object.keys(this.currentRepos()))[0] ?? writtenRepo);
1144
- const repoOrder = this.repoSearchOrder(qualifier);
1145
- const indexes = await this.loadIndexes(repoOrder);
1146
- if (writtenRepo === undefined) this.indexesSnapshot = indexes;
1147
-
1148
- const lookup = new CachedIndexLookup(indexes, { order: repoOrder });
1149
- const matches = (
1150
- await sharedRefResolver().resolve(
1151
- new RefResolveArguments({ input: search, lookup, kinds: ["namespace", "recipe"] })
1152
- )
1153
- ).refs as NamedRef[];
1154
-
1155
- if (matches.length === 0) {
1156
- throw new ConfigError(
1157
- [
1158
- `Nothing called '${key}' was found: no namespace has that name, ` +
1159
- `and no recipe does either.`,
1160
- ...describeIndexSearch({ name: key, repoOrder, indexes }),
1161
- ` Run 'sous repo search ${key}' to look for something like it, or ` +
1162
- `'sous repo add <url>' to add the repository that publishes it.`,
1163
- ].join("\n")
1164
- );
1165
- }
1166
-
1167
- const chosen = await this.pickNamed(matches, {
1168
- search,
1169
- ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
1170
- });
1171
-
1172
- // The range the ref was written with says which version to use, never
1173
- // which thing is meant, so it is carried over to whatever was chosen.
1174
- const range = rangeOf(written);
1175
- return chosen.kind === "recipe" && range !== undefined ? { ...chosen, range } : chosen;
1176
- }
1177
-
1178
- /**
1179
- * The repositories a one-word ref is searched in, in the order their
1180
- * candidates are listed: the built-in repository first, then the ones the
1181
- * config names, in the order the config names them.
1182
- *
1183
- * @param only - A repository qualifier from the ref, which narrows the search to it.
1184
- */
1185
- private repoSearchOrder(only?: string): string[] {
1186
- const repos = this.currentRepos();
1187
- const names = Object.keys(repos);
1188
-
1189
- if (only !== undefined) {
1190
- if (!Object.hasOwn(repos, only)) {
1191
- throw new ConfigError(
1192
- `This project does not trust a repository called '${only}'.\n` +
1193
- (names.length > 0
1194
- ? ` It trusts: ${names.join(", ")}.`
1195
- : ` It trusts none yet.`) +
1196
- `\n Add it with 'sous repo add <url> --name ${only}'.`
1197
- );
1198
- }
1199
- return [only];
1200
- }
1201
-
1202
- const builtIn = names.filter((name) => isBuiltInEntry(repos[name]));
1203
- return [...builtIn, ...names.filter((name) => !builtIn.includes(name))];
1204
- }
1205
-
1206
- /**
1207
- * Every trusted repository's index, loaded once per command. Whatever a
1208
- * one-word ref already loaded is reused, so confirming a subscription costs
1209
- * no extra lookups.
1210
- */
1211
- private async indexSnapshot(): Promise<Map<string, IndexFile>> {
1212
- if (this.indexesSnapshot === undefined) {
1213
- this.indexesSnapshot = await this.loadIndexes(this.repoSearchOrder());
1214
- }
1215
- return this.indexesSnapshot;
1216
- }
1217
-
1218
- // --- The subscribe confirmation ----------------------------------------------------------------
1219
-
1220
- /**
1221
- * Says what subscribing will do to this project, and asks whether to go on.
1222
- *
1223
- * This runs before anything is fetched or written, so a "no" costs nothing:
1224
- * the only thing read to get here is the cached index of each trusted
1225
- * repository. `--yes` skips the question, and a dry run states the plan and
1226
- * never asks, because a dry run has nothing to decline.
1227
- *
1228
- * @param parsed - The fully qualified ref being subscribed to.
1229
- * @param options - The yes and dry-run flags.
1230
- */
1231
- private async confirmSubscription(
1232
- parsed: NamedRef,
1233
- options: SubscribeOptions
1234
- ): Promise<void> {
1235
- const indexes = await this.indexSnapshot();
1236
- const plan = await this.subscriptionPlan(parsed, options, indexes);
1237
- for (const line of plan) this.write(line === "" ? "" : indent(line));
1238
-
1239
- if (options.dryRun === true || options.yes === true) return;
1240
-
1241
- if (!this.interactive) {
1242
- throw nonInteractiveError({
1243
- prompt: `whether to go ahead with subscribing to '${formatRef(parsed)}'`,
1244
- remedy:
1245
- "pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
1246
- "the plan above without being asked.",
1247
- });
1248
- }
1249
-
1250
- const proceed = await this.ask("Proceed?");
1251
- if (!proceed) {
1252
- throw new ConfigError(
1253
- `Nothing was written: the subscription to '${formatRef(parsed)}' was declined.\n` +
1254
- ` Nothing was downloaded, no lockfile entry was made, and this project's ` +
1255
- `config is exactly as it was.`
1256
- );
1257
- }
1258
- }
1259
-
1260
- /**
1261
- * The plan itself: what will be compiled, what can run, what will be asked,
1262
- * and what will be fetched, in plain sentences.
1263
- *
1264
- * @param parsed - The fully qualified ref being subscribed to.
1265
- * @param options - The prerelease flag, for the dependency peek.
1266
- */
1267
- private async subscriptionPlan(
1268
- parsed: NamedRef,
1269
- options: SubscribeOptions,
1270
- indexes: Map<string, IndexFile>
1271
- ): Promise<string[]> {
1272
- const target = formatRef(parsed);
1273
- const width = wrapColumns() - 4;
1274
- const lines: string[] = [""];
1275
-
1276
- /** One sentence of the plan, wrapped and in the warning color. */
1277
- const sentence = (text: string): string[] =>
1278
- wrapText(text, width).map((line) => palette.warning(line));
1279
-
1280
- /** One bullet of the plan, wrapped so its continuation hangs under the text. */
1281
- const bullet = (text: string): string[] =>
1282
- wrapText(`${BULLET} ${text}`, width, { hangingIndent: 2 }).map((line) =>
1283
- palette.warning(line)
1284
- );
1285
-
1286
- if (parsed.kind === "namespace") {
1287
- const published = this.namespaceRecipes(parsed, indexes);
1288
- lines.push(
1289
- ...sentence(
1290
- `Subscribing to '${target}' subscribes this project to the whole ` +
1291
- `namespace '${parsed.name}', which means ${palette.highlight(
1292
- "every recipe in it, including ones published later"
1293
- )}.`
1294
- )
1295
- );
1296
- if (published.length > 0) {
1297
- lines.push(
1298
- ...sentence(`It publishes ${published.length} today: ${published.join(", ")}.`)
1299
- );
1300
- }
1301
- } else {
1302
- lines.push(
1303
- ...sentence(
1304
- `Subscribing to '${target}' installs the recipe '${parsed.name}' from ` +
1305
- `the namespace '${namespaceOf(parsed)!.name}'.`
1306
- )
1307
- );
1308
- }
1309
-
1310
- lines.push("");
1311
- lines.push(...sentence("Here is what that does:"));
1312
- lines.push("");
1313
- lines.push(
1314
- ...bullet(
1315
- `The files it ships are compiled into this project on the next build, ` +
1316
- `which writes them into this project's agent directories.`
1317
- )
1318
- );
1319
- lines.push(
1320
- ...bullet(
1321
- `Any scripts it ships ${palette.highlight(
1322
- "can be run on this machine"
1323
- )} when an agent uses them. Sous does not run them itself, and it cannot ` +
1324
- `vouch for what they do.`
1325
- )
1326
- );
1327
- lines.push(
1328
- ...bullet(
1329
- `The variables it publishes are asked about at the end of this command, ` +
1330
- `and the answers are written into this project's env files.`
1331
- )
1332
- );
1333
- lines.push(
1334
- ...bullet(
1335
- `Its dependencies are fetched and pinned in this project's lockfile, at ` +
1336
- `the exact versions resolved now.`
1337
- )
1338
- );
1339
-
1340
- const untrusted = await this.knownUntrustedDependencyRepos(parsed, options, indexes);
1341
- if (untrusted.length > 0) {
1342
- lines.push(
1343
- ...bullet(
1344
- `Some of what it needs lives in repositories this project ${palette.highlight(
1345
- "does not trust yet"
1346
- )}: ${untrusted.join(", ")}. You are asked about each one by name before ` +
1347
- `anything is fetched from it.`
1348
- )
1349
- );
1350
- } else {
1351
- lines.push(
1352
- ...bullet(
1353
- `If a dependency turns out to live in a repository this project does ` +
1354
- `not trust, sous stops and asks about that repository by name before ` +
1355
- `fetching anything from it.`
1356
- )
1357
- );
1358
- }
1359
-
1360
- lines.push("");
1361
- return lines;
1362
- }
1363
-
1364
- /**
1365
- * The recipes a namespace publishes today, as `namespace/recipe` keys.
1366
- *
1367
- * @param parsed - The fully qualified namespace ref.
1368
- */
1369
- private namespaceRecipes(parsed: NamedRef, indexes: Map<string, IndexFile>): string[] {
1370
- const repo = repoOf(parsed)?.name;
1371
- const index = repo === undefined ? undefined : indexes.get(repo);
1372
- if (index === undefined) return [];
1373
- return Object.keys(index.recipes)
1374
- .filter((key) => key.startsWith(`${namespaceOf(parsed)!.name}/`))
1375
- .sort();
1376
- }
1377
-
1378
- /**
1379
- * Repositories a dependency needs that this project does not trust, as far as
1380
- * anything already on disk knows.
1381
- *
1382
- * A manifest is the only thing that names a dependency, and a manifest that
1383
- * has never been fetched cannot be read without fetching, which is exactly
1384
- * what the confirmation exists to gate. So this reads what the store already
1385
- * holds, and says nothing when it holds nothing; the trust ceremony during
1386
- * resolution is still where the real answer comes from.
1387
- *
1388
- * @param parsed - The fully qualified ref being subscribed to.
1389
- * @param options - The prerelease flag.
1390
- */
1391
- private async knownUntrustedDependencyRepos(
1392
- parsed: NamedRef,
1393
- options: SubscribeOptions,
1394
- indexes: Map<string, IndexFile>
1395
- ): Promise<string[]> {
1396
- try {
1397
- const result = await resolveRefs(
1398
- [
1399
- {
1400
- ref: parsed,
1401
- requestedBy: PROJECT_REQUESTER,
1402
- kind: "subscribes",
1403
- ...(options.prerelease === true ? { prerelease: true } : {}),
1404
- },
1405
- ],
1406
- {
1407
- indexes,
1408
- repos: this.resolverRepos(),
1409
- // Reads only what is already on disk: the confirmation must not fetch.
1410
- loadManifest: (recipe) => this.loadRecipeManifest(recipe, true),
1411
- ...(options.prerelease === true ? { prerelease: true } : {}),
1412
- }
1413
- );
1414
- return result.missingRepos.map((missing) => `'${missing.name}'`).sort();
1415
- } catch {
1416
- // The plan is a courtesy; a peek that fails must never stop a subscription
1417
- // that resolution itself would have completed.
1418
- return [];
1419
- }
1420
- }
1421
-
1422
- /**
1423
- * Resolves a ref and everything beneath it, running the trust round as often
1424
- * as resolution keeps turning up repositories the project has not added.
1425
- *
1426
- * IN PRACTICE THIS RUNS AT MOST ONE TRUST ROUND TODAY, and the loop is the
1427
- * shape rather than the behavior. A recipe names the repository it depends on
1428
- * by short name only, so nothing in a manifest carries a URL and every missing
1429
- * repository comes back under `needUrl`, which throws below. The loop earns
1430
- * its keep the moment any source of URLs exists (a repository hint block, or
1431
- * the lockfile of a project restoring someone else's commit); until then, read
1432
- * it as "one round, then either resolution succeeds or the person is told what
1433
- * to add".
1434
- *
1435
- * @param parsed - The ref being subscribed to.
1436
- * @param options - The prerelease and trust flags.
1437
- */
1438
- private async resolveClosure(
1439
- parsed: NamedRef,
1440
- options: SubscribeOptions
1441
- ): Promise<{
1442
- resolved: ResolvedRecipe[];
1443
- trusted: string[];
1444
- cycles: string[][];
1445
- unreadable: string[];
1446
- }> {
1447
- const trusted: string[] = [];
1448
-
1449
- for (;;) {
1450
- const repos = this.resolverRepos();
1451
- const indexes = await this.loadIndexes(Object.keys(repos));
1452
-
1453
- const result = await resolveRefs(
1454
- [
1455
- {
1456
- ref: parsed,
1457
- requestedBy: PROJECT_REQUESTER,
1458
- kind: "subscribes",
1459
- ...(options.prerelease === true ? { prerelease: true } : {}),
1460
- },
1461
- ],
1462
- {
1463
- indexes,
1464
- repos,
1465
- loadManifest: (recipe) => this.loadRecipeManifest(recipe, options.dryRun === true),
1466
- ...(options.prerelease === true ? { prerelease: true } : {}),
1467
- }
1468
- );
1469
-
1470
- if (result.missingRepos.length === 0) {
1471
- // A dry run refuses to download, so a recipe this machine does not hold
1472
- // yet has no manifest to read. That is not a failure here: the plan
1473
- // describes everything it could read and names what it could not.
1474
- if (result.missingManifests.length > 0 && options.dryRun !== true) {
1475
- throw new ConfigError(
1476
- `Sous could not read the manifest of ` +
1477
- `${result.missingManifests.map((entry) => `'${entry}'`).join(", ")}.\n` +
1478
- ` Every recipe carries a manifest, so this one is either damaged upstream ` +
1479
- `or could not be downloaded. Nothing was written.`
1480
- );
1481
- }
1482
- return {
1483
- resolved: result.resolved,
1484
- trusted,
1485
- cycles: result.cycles,
1486
- unreadable: result.missingManifests,
1487
- };
1488
- }
1489
-
1490
- const outcome = await this.trust.confirmTrust(result.missingRepos, {
1491
- interactive: this.interactive,
1492
- ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
1493
- });
1494
-
1495
- if (outcome.needUrl.length > 0) {
1496
- const lines = [
1497
- outcome.needUrl.length === 1
1498
- ? `Sous does not know where the repository '${outcome.needUrl[0]}' lives, so it ` +
1499
- `cannot add it for you.`
1500
- : `Sous does not know where these repositories live, so it cannot add them for ` +
1501
- `you: ${outcome.needUrl.map((entry) => `'${entry}'`).join(", ")}.`,
1502
- " A recipe names the repository it depends on by its short name only; the URL " +
1503
- "has to come from you.",
1504
- " Add each one with its URL, then run this command again:",
1505
- "",
1506
- ];
1507
- for (const name of outcome.needUrl) {
1508
- lines.push(` sous repo add <url> --name ${name}`);
1509
- }
1510
- throw new ConfigError(lines.join("\n"));
1511
- }
1512
-
1513
- trusted.push(...outcome.added);
1514
- }
1515
- }
1516
-
1517
- /**
1518
- * The subscription key a ref typed on the command line names. A short ref
1519
- * names its own key, matched against the keys in force exactly first and then
1520
- * ignoring case; a location is settled through the trusted repository at that
1521
- * location, the way `subscribe` settles one.
1522
- *
1523
- * @param written - The ref as typed.
1524
- * @param keys - The subscription keys in force.
1525
- */
1526
- private async subscriptionKeyFor(written: string, keys: string[]): Promise<string> {
1527
- const { refs: readings } = sharedRefResolver().parse(written);
1528
- const short = readings.find(
1529
- (reading): reading is NamedRef => isNamedRef(reading) && locationOf(reading) === undefined
1530
- );
1531
- if (short !== undefined) {
1532
- const key = shortKey(short);
1533
- return matchNames(key, new Set(keys))[0] ?? key;
1534
- }
1535
- const chosen = await this.pickLocated(written);
1536
- return shortKey(chosen);
1537
- }
1538
-
1539
- /**
1540
- * Removes one subscription and everything that was only there because of it.
1541
- * Removal is refcounted: a recipe another subscription (or another recipe)
1542
- * still holds stays exactly where it is, and is reported as having stayed.
1543
- *
1544
- * @param options - The ref to unsubscribe from.
1545
- */
1546
- async unsubscribe(options: UnsubscribeOptions): Promise<UnsubscribeOutcome> {
1547
- const dryRun = options.dryRun === true;
1548
-
1549
- const managed = this.readSubscriptionEntries();
1550
- const configured = enabledSubscriptions(this.settings);
1551
- const key = await this.subscriptionKeyFor(options.ref, [
1552
- ...Object.keys(managed),
1553
- ...Object.keys(configured),
1554
- ]);
1555
- const before = this.lock.read();
1556
- const held = this.keysHeldBySubscription(before, key);
1557
-
1558
- // An entry the managed layer already switched off is not a subscription any
1559
- // more, so removing it again is the "you do not subscribe to this" case
1560
- // rather than a deletion that would quietly switch it back on.
1561
- const alreadyOff = managed[key]?.enabled === false;
1562
- const removable = Object.hasOwn(managed, key) && !alreadyOff;
1563
- // Sous provides the `core` subscription itself, so there is no entry to
1564
- // delete. Removing it means recording an opt-out that outlives the default.
1565
- const builtIn = !removable && isBuiltInEntry(configured[key]);
1566
-
1567
- if (!removable && !builtIn && !Object.hasOwn(configured, key) && held.length === 0) {
1568
- const known = [
1569
- ...new Set([
1570
- ...Object.entries(managed)
1571
- .filter(([, entry]) => entry?.enabled !== false)
1572
- .map(([entryKey]) => entryKey),
1573
- ...Object.keys(configured),
1574
- ]),
1575
- ].sort();
1576
- throw new ConfigError(
1577
- `This project does not subscribe to '${key}'.\n` +
1578
- (known.length > 0
1579
- ? ` It subscribes to: ${known.join(", ")}.`
1580
- : ` It has no subscriptions yet.`)
1581
- );
1582
- }
1583
-
1584
- if (!removable && !builtIn && Object.hasOwn(configured, key)) {
1585
- throw new ConfigError(
1586
- `The subscription to '${key}' is written in this project's own config, not in the ` +
1587
- `layer sous manages.\n` +
1588
- ` Remove its entry from the 'subscriptions' block of your config file; sous ` +
1589
- `never edits a config file you wrote.`
1590
- );
1591
- }
1592
-
1593
- let after = before;
1594
- for (const heldKey of held) after = this.dropProjectHold(after, heldKey);
1595
- const diff = this.lock.diff(before, after);
1596
-
1597
- const stayed = held
1598
- .filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
1599
- .map((heldKey) => ({
1600
- key: heldKey,
1601
- heldBy: [...after.recipes[heldKey]!.requestedBy],
1602
- }));
1603
-
1604
- if (!dryRun) {
1605
- this.lock.write(after);
1606
-
1607
- const remaining = { ...managed };
1608
- if (builtIn) remaining[key] = { enabled: false };
1609
- else delete remaining[key];
1610
-
1611
- if (Object.keys(remaining).length === 0) {
1612
- removeManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
1613
- confDir: this.confDir,
1614
- });
1615
- } else {
1616
- // A subscription sous provides itself has no entry to delete, so the
1617
- // opt-out is written as the entry instead; anything else is removed.
1618
- updateManagedLayer(
1619
- this.sousDir,
1620
- SUBSCRIPTIONS_LAYER_FILENAME,
1621
- [
1622
- {
1623
- path: ["subscriptions", key],
1624
- value: builtIn ? { enabled: false } : undefined,
1625
- },
1626
- ],
1627
- { confDir: this.confDir }
1628
- );
1629
- }
1630
- }
1631
-
1632
- return { key, diff, stayed, optedOut: builtIn, dryRun };
1633
- }
1634
-
1635
- // --- Withdrawing trust from a repository -------------------------------------------------------
1636
-
1637
- /**
1638
- * Stops trusting a repository: every subscription that resolves into it is
1639
- * removed through the same refcounted path `unsubscribe` uses, the link that
1640
- * pointed at it is dropped, and its entry leaves the managed repositories
1641
- * layer.
1642
- *
1643
- * Informed consent, never prevention: everything that will go is printed
1644
- * first, in plain sentences, and then one question is asked. `--yes` skips the
1645
- * question and a dry run never asks, because a dry run has nothing to decline.
1646
- *
1647
- * The repository sous provides itself has no entry to delete, so removing it
1648
- * records `enabled: false` instead; the default comes back on every run and
1649
- * only a recorded opt-out outlives it.
1650
- *
1651
- * @param options - The repository's short name, and the yes and dry-run flags.
1652
- */
1653
- async removeRepo(options: RemoveRepoOptions): Promise<RemoveRepoOutcome> {
1654
- const name = options.name;
1655
- const dryRun = options.dryRun === true;
1656
-
1657
- const trusted = this.trust.listTrusted();
1658
- const entry = trusted[name];
1659
- if (entry === undefined) {
1660
- const known = Object.keys(trusted).sort();
1661
- throw new ConfigError(
1662
- `This project does not trust a repository called '${name}'.\n` +
1663
- (known.length > 0
1664
- ? ` It trusts: ${known.join(", ")}.`
1665
- : ` It trusts no repositories yet.`)
1666
- );
1667
- }
1668
-
1669
- const managed = this.trust.listManaged();
1670
- const builtIn = !Object.hasOwn(managed, name) && isBuiltInEntry(entry);
1671
- if (!Object.hasOwn(managed, name) && !builtIn) {
1672
- throw new ConfigError(
1673
- `The repository '${name}' is written in this project's own config, not in the ` +
1674
- `layer sous manages.\n` +
1675
- ` Remove its entry from the 'repos' block of your config file; sous never ` +
1676
- `edits a config file you wrote.`
1677
- );
1678
- }
1679
-
1680
- const before = this.lock.read();
1681
- const { removable, kept } = this.subscriptionsInto(name, before);
1682
-
1683
- // What the removal would leave behind, worked out before anything is
1684
- // written, so the plan below describes exactly what is about to happen.
1685
- let after = before;
1686
- const held: string[] = [];
1687
- for (const key of removable) {
1688
- for (const heldKey of keysHeldBySubscription(before, key)) {
1689
- held.push(heldKey);
1690
- after = this.dropProjectHold(after, heldKey);
1691
- }
1692
- }
1693
- const diff = this.lock.diff(before, after);
1694
- const removedRecipes = diff.removed.map((change) => change.key);
1695
- const stayed = [...new Set(held)]
1696
- .filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
1697
- .sort()
1698
- .map((heldKey) => ({ key: heldKey, heldBy: [...after.recipes[heldKey]!.requestedBy] }));
1699
-
1700
- const outputs = this.outputsOf(removedRecipes, options.scope);
1701
- const projectLink = readProjectLinks(this.sousDir).links[name];
1702
- const globalLink = readGlobalLinks(this.env).links[name];
1703
- const link = projectLink ?? globalLink;
1704
-
1705
- const outcome: RemoveRepoOutcome = {
1706
- name,
1707
- url: entry.url,
1708
- optedOut: builtIn,
1709
- subscriptions: removable,
1710
- keptSubscriptions: kept,
1711
- removedRecipes,
1712
- stayed,
1713
- outputs,
1714
- diff,
1715
- ...(link === undefined ? {} : { linkedPath: link.path }),
1716
- linkIsGlobal: projectLink === undefined && globalLink !== undefined,
1717
- dryRun,
1718
- };
1719
-
1720
- for (const line of this.removalPlan(outcome)) {
1721
- this.write(line === "" ? "" : indent(line));
1722
- }
1723
-
1724
- if (!dryRun && options.yes !== true) {
1725
- if (!this.interactive) {
1726
- throw nonInteractiveError({
1727
- prompt: `whether to stop trusting the repository '${name}'`,
1728
- remedy:
1729
- "pass '--yes' (spelled '-y', '--force' or '-f' if you prefer) to accept the " +
1730
- "plan above without being asked.",
1731
- });
1732
- }
1733
-
1734
- const proceed = await this.ask("Stop trusting it?");
1735
- if (!proceed) {
1736
- throw new ConfigError(
1737
- `Nothing was written: the repository '${name}' is still trusted.\n` +
1738
- ` No subscription was removed, no lockfile entry was changed, and this ` +
1739
- `project's config is exactly as it was.`
1740
- );
1741
- }
1742
- }
1743
-
1744
- if (dryRun) return outcome;
1745
-
1746
- // Each subscription goes through the ordinary refcounted removal, so a
1747
- // recipe another subscription still holds is kept exactly as it would be
1748
- // had the subscription been removed on its own.
1749
- for (const key of removable) await this.unsubscribe({ ref: key });
1750
-
1751
- if (projectLink !== undefined) {
1752
- const map = readProjectLinks(this.sousDir);
1753
- delete map.links[name];
1754
- writeProjectLinks(this.sousDir, map);
1755
- }
1756
-
1757
- if (builtIn) this.trust.disableRepo(name);
1758
- else this.trust.removeRepo(name);
1759
-
1760
- return outcome;
1761
- }
1762
-
1763
- /**
1764
- * The subscriptions that resolve into one repository, split by whether sous
1765
- * may remove them. A subscription counts when the lockfile pins one of its
1766
- * recipes to that repository, or when its ref names the repository outright
1767
- * with a `repo:` qualifier.
1768
- *
1769
- * @param name - The repository's short name.
1770
- * @param lock - The lockfile as it stands.
1771
- */
1772
- private subscriptionsInto(
1773
- name: string,
1774
- lock: Lockfile
1775
- ): { removable: string[]; kept: string[] } {
1776
- const fromRepo = new Set(
1777
- Object.entries(lock.recipes)
1778
- .filter(([, recipe]) => recipe.repo === name)
1779
- .map(([key]) => key)
1780
- );
1781
-
1782
- const subscriptions = this.allSubscriptions();
1783
- const managed = this.readSubscriptionEntries();
1784
- const removable: string[] = [];
1785
- const kept: string[] = [];
1786
-
1787
- for (const key of Object.keys(subscriptions).sort()) {
1788
- let qualifier: string | undefined;
1789
- try {
1790
- qualifier = repoOf(parseNamedRef(key, RefSource.Config))?.name;
1791
- } catch {
1792
- // A ref that does not parse cannot name this repository, and reporting
1793
- // it here would bury the removal under an unrelated complaint.
1794
- continue;
1795
- }
1796
-
1797
- const touches =
1798
- qualifier === name ||
1799
- keysHeldBySubscription(lock, key).some((heldKey) => fromRepo.has(heldKey));
1800
- if (!touches) continue;
1801
-
1802
- const sousMayRemove =
1803
- Object.hasOwn(managed, key) || isBuiltInEntry(subscriptions[key]);
1804
- if (sousMayRemove) removable.push(key);
1805
- else kept.push(key);
1806
- }
1807
-
1808
- return { removable, kept };
1809
- }
1810
-
1811
- /**
1812
- * The output files a set of locked recipes compiled, which the next build
1813
- * prunes once they are gone.
1814
- *
1815
- * Worked out from the recipes' own compile targets rather than from the build
1816
- * state file, because the state file records what sous wrote without
1817
- * recording which recipe wrote it. A destination that cannot be resolved
1818
- * (a `${var}` with no value in the scope given) leaves the list empty rather
1819
- * than stopping a removal; the build itself reports that properly.
1820
- *
1821
- * @param keys - The recipe keys that are going away.
1822
- * @param scope - The resolved settings scope, for `${var}` in destinations.
1823
- */
1824
- private outputsOf(keys: string[], scope?: VarScope): string[] {
1825
- if (keys.length === 0) return [];
1826
-
1827
- const going = new Set(keys);
1828
- const locked = listLockedRecipes({ sousDir: this.sousDir, env: this.env }).filter(
1829
- (recipe) => going.has(recipe.key)
1830
- );
1831
- if (locked.length === 0) return [];
1832
-
1833
- try {
1834
- const { targets } = buildRecipeTargets({
1835
- sousDir: this.sousDir,
1836
- settings: this.settings,
1837
- ...(scope === undefined ? {} : { scope }),
1838
- env: this.env,
1839
- locked,
1840
- });
1841
-
1842
- const files = new Set<string>();
1843
- for (const target of targets) {
1844
- for (const output of target.outputs) {
1845
- const resolved = resolveOutputPath(target, output);
1846
- if (resolved !== undefined) files.add(resolved);
1847
- }
1848
- }
1849
- return [...files].sort();
1850
- } catch {
1851
- return [];
1852
- }
1853
- }
1854
-
1855
- /**
1856
- * What stopping trusting a repository will do to this project, in plain
1857
- * sentences: the entry itself, the subscriptions that go with it, the recipes
1858
- * they hold, the files the next build prunes, and the link that points at it.
1859
- *
1860
- * @param outcome - Everything the removal worked out.
1861
- */
1862
- private removalPlan(outcome: RemoveRepoOutcome): string[] {
1863
- const lines: string[] = [""];
1864
-
1865
- lines.push(
1866
- outcome.optedOut
1867
- ? `The repository '${outcome.name}' at ${outcome.url} is one sous provides ` +
1868
- `itself, so it cannot be deleted: it is switched off instead, by recording ` +
1869
- `'${outcome.name}: { enabled: false }' in this project's repositories layer.`
1870
- : `The entry for '${outcome.name}' at ${outcome.url} is removed from this ` +
1871
- `project's repositories layer, so sous stops reading anything from it.`
1872
- );
1873
-
1874
- lines.push("");
1875
- lines.push("Here is what goes with it:");
1876
- lines.push("");
1877
-
1878
- if (outcome.subscriptions.length > 0) {
1879
- lines.push(
1880
- outcome.subscriptions.length === 1
1881
- ? ` One subscription resolves into it and is removed: ` +
1882
- `${outcome.subscriptions[0]}.`
1883
- : ` ${outcome.subscriptions.length} subscriptions resolve into it and are ` +
1884
- `removed: ${outcome.subscriptions.join(", ")}.`
1885
- );
1886
- } else {
1887
- lines.push(" Nothing this project subscribes to resolves into it.");
1888
- }
1889
-
1890
- if (outcome.removedRecipes.length > 0) {
1891
- lines.push(
1892
- ` ${outcome.removedRecipes.length === 1 ? "One locked recipe is" : `${outcome.removedRecipes.length} locked recipes are`} ` +
1893
- `held only through those subscriptions, and ${
1894
- outcome.removedRecipes.length === 1 ? "it leaves" : "they leave"
1895
- } the lockfile: ${outcome.removedRecipes.join(", ")}.`
1896
- );
1897
- }
1898
-
1899
- for (const stayed of outcome.stayed) {
1900
- lines.push(
1901
- ` The recipe '${stayed.key}' stays, because ${stayed.heldBy.join(", ")} still ` +
1902
- `holds it.`
1903
- );
1904
- }
1905
-
1906
- if (outcome.outputs.length > 0) {
1907
- lines.push(
1908
- ` ${outcome.outputs.length === 1 ? "One file those recipes compiled is" : `${outcome.outputs.length} files those recipes compiled are`} ` +
1909
- `pruned by the build that follows:`
1910
- );
1911
- for (const file of outcome.outputs) lines.push(` ${file}`);
1912
- }
1913
-
1914
- if (outcome.linkedPath !== undefined) {
1915
- lines.push(
1916
- outcome.linkIsGlobal
1917
- ? ` A machine-wide link points this repository at the checkout at ` +
1918
- `${outcome.linkedPath}. That map is shared by every project on this ` +
1919
- `machine, so it is left exactly as it is, and the checkout stays on disk.`
1920
- : ` This project links the repository to the checkout at ` +
1921
- `${outcome.linkedPath}. The link is removed, and the checkout stays on ` +
1922
- `disk exactly as it is.`
1923
- );
1924
- }
1925
-
1926
- for (const key of outcome.keptSubscriptions) {
1927
- lines.push(
1928
- ` The subscription to '${key}' resolves into it and is written in this ` +
1929
- `project's own config, which sous never edits, so it stays. It resolves ` +
1930
- `against nothing once the repository is gone.`
1931
- );
1932
- }
1933
-
1934
- lines.push("");
1935
- return lines;
1936
- }
1937
-
1938
- // --- Moving pins within their ranges -----------------------------------------------------------
1939
-
1940
- /**
1941
- * Moves the lockfile's pins to the newest published versions their ranges
1942
- * allow, and nothing else: the subscriptions themselves are never edited, and
1943
- * no range is ever widened.
1944
- *
1945
- * The order is the design, as it is for subscribing:
1946
- *
1947
- * - Every trusted repository's index is fetched fresh first. One that
1948
- * cannot be reached is reported, and its pins stay exactly where they are.
1949
- * - The reference, when there is one, is resolved through `src/services/ref-resolver/`
1950
- * against those indexes, and it narrows which pins may move. Everything
1951
- * outside it is held at its locked version for as long as its ranges
1952
- * allow, so dependencies move with the closure and nothing else does.
1953
- * - The whole change is printed as a plan and asked about once. With
1954
- * nothing to change, nothing is asked. `--yes` accepts it; a run with no
1955
- * terminal and no `--yes` fails naming the flag.
1956
- * - Any repository a newer version needs that the project does not trust
1957
- * goes through the usual trust question, and the plan is worked out again
1958
- * once it is trusted.
1959
- * - The lockfile is written, and then the questions the new versions ask
1960
- * are asked, exactly as `subscribe` asks them.
1961
- *
1962
- * A subscription sous provides itself (the `core` namespace) never moves: its
1963
- * range is exactly the running sous version. Switched-off subscriptions take
1964
- * no part. A linked repository's pins move like any other, and builds keep
1965
- * reading its checkout until it is unlinked.
1966
- *
1967
- * @param options - What to update, and the confirmation, answer and dry-run flags.
1968
- */
1969
- async update(options: UpdateOptions = {}): Promise<UpdateOutcome> {
1970
- const dryRun = options.dryRun === true;
1971
-
1972
- const repoNames = Object.keys(this.currentRepos()).sort();
1973
- const { indexes, unreachable } = await this.fetchFreshIndexes(repoNames);
1974
- const scope = await this.resolveUpdateScope(options, indexes);
1975
- const unreachableNames = new Set(unreachable.map((entry) => entry.repo));
1976
-
1977
- let plan = await this.planUpdate(scope, indexes, unreachableNames, dryRun);
1978
- const facts = {
1979
- scope,
1980
- unreachable,
1981
- builtIn: this.builtInSubscriptionsIn(scope),
1982
- switchedOff: this.switchedOffSubscriptionsIn(scope),
1983
- };
1984
-
1985
- const questions = planQuestions(this.definedVariables(plan.changed), this.ladderContext(), {
1986
- sousDir: this.sousDir,
1987
- }).filter((entry) => !entry.answered);
1988
-
1989
- for (const line of formatUpdatePlan({
1990
- ...facts,
1991
- diff: plan.diff,
1992
- missingRepos: plan.missingRepos,
1993
- questions,
1994
- unreadable: dryRun ? this.unreadableRecipes(plan.changed) : [],
1995
- linked: plan.linked,
1996
- failed: plan.failed,
1997
- })) {
1998
- this.write(line === "" ? "" : indent(line));
1999
- }
2000
-
2001
- const outcome = (trusted: string[], answers?: AskReport): UpdateOutcome => ({
2002
- scope,
2003
- diff: plan.diff,
2004
- nothingToUpdate: isEmptyUpdate(plan),
2005
- unreachable,
2006
- failed: plan.failed,
2007
- linked: plan.linked,
2008
- trusted,
2009
- ...(answers === undefined ? {} : { answers }),
2010
- cycles: plan.cycles,
2011
- dryRun,
2012
- });
2013
-
2014
- if (isEmptyUpdate(plan) || dryRun) return outcome([]);
2015
-
2016
- if (options.yes !== true) {
2017
- if (!this.interactive) {
2018
- throw nonInteractiveError({
2019
- prompt: `whether to update ${describeUpdateScope(scope)}`,
2020
- remedy:
2021
- "pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
2022
- "the plan above without being asked.",
2023
- });
2024
- }
2025
-
2026
- const proceed = await this.ask("Update the lockfile?");
2027
- if (!proceed) {
2028
- throw new ConfigError(
2029
- `Nothing was written: the update was declined.\n` +
2030
- ` The lockfile and this project's config are exactly as they were.`
2031
- );
2032
- }
2033
- }
2034
-
2035
- // A newer version that needs a repository this project does not trust is a
2036
- // trust decision, asked by name exactly as subscribing asks it. Once it is
2037
- // trusted, its index is read and the plan is worked out again, since the
2038
- // closure could not be walked past it before.
2039
- const trusted: string[] = [];
2040
- while (plan.missingRepos.length > 0) {
2041
- const decided = await this.trust.confirmTrust(plan.missingRepos, {
2042
- interactive: this.interactive,
2043
- ...(options.yes === undefined ? {} : { trustFlag: options.yes }),
2044
- });
2045
-
2046
- if (decided.needUrl.length > 0) {
2047
- throw new ConfigError(
2048
- [
2049
- `Sous does not know where ${
2050
- decided.needUrl.length === 1
2051
- ? `the repository '${decided.needUrl[0]}' lives`
2052
- : `these repositories live: ${decided.needUrl.map((n) => `'${n}'`).join(", ")}`
2053
- }, so it cannot add ${decided.needUrl.length === 1 ? "it" : "them"} for you.`,
2054
- " Nothing was written. Add each one with its URL, then run this command again:",
2055
- "",
2056
- ...decided.needUrl.map((name) => ` sous repo add <url> --name ${name}`),
2057
- ].join("\n")
2058
- );
2059
- }
2060
-
2061
- trusted.push(...decided.added);
2062
- const added = await this.fetchFreshIndexes(decided.added);
2063
- for (const [name, index] of added.indexes) indexes.set(name, index);
2064
- plan = await this.planUpdate(scope, indexes, unreachableNames, false);
2065
- }
2066
-
2067
- // Supplied answers are checked before anything is written, so one that does
2068
- // not fit fails the run rather than leaving the lockfile moved with its
2069
- // questions unanswered.
2070
- validateProvidedAnswers(this.definedVariables(plan.changed), options.answers ?? []);
2071
-
2072
- for (const recipe of plan.changed) await this.ensureStored(recipe);
2073
- this.lock.write(plan.after);
2074
-
2075
- const answers = await this.askVariables(plan.changed, options.answers ?? []);
2076
- return outcome(trusted, answers);
2077
- }
2078
-
2079
- /**
2080
- * Fetches the index of each named repository straight from upstream,
2081
- * ignoring the freshness window. A repository that cannot be reached is
2082
- * reported rather than raised, and the copy already cached (when there is
2083
- * one) stands in for it so its pins can still be held where they are.
2084
- *
2085
- * @param names - The repositories' short names.
2086
- */
2087
- private async fetchFreshIndexes(names: string[]): Promise<{
2088
- indexes: Map<string, IndexFile>;
2089
- unreachable: Array<{ repo: string; reason: string }>;
2090
- }> {
2091
- const repos = this.currentRepos();
2092
- const indexes = new Map<string, IndexFile>();
2093
- const unreachable: Array<{ repo: string; reason: string }> = [];
2094
-
2095
- for (const name of names) {
2096
- const entry = repos[name];
2097
- if (entry === undefined) continue;
2098
-
2099
- let identity: string;
2100
- try {
2101
- identity = this.identityOf(entry.url, entry.provider);
2102
- } catch (error) {
2103
- unreachable.push({ repo: name, reason: describeError(error) });
2104
- continue;
2105
- }
2106
-
2107
- try {
2108
- const lookup = await this.indexCache.refresh(identity, {
2109
- url: entry.url,
2110
- label: name,
2111
- ...(entry.provider === undefined ? {} : { provider: entry.provider }),
2112
- });
2113
- indexes.set(name, lookup.index);
2114
- } catch (error) {
2115
- unreachable.push({ repo: name, reason: describeError(error) });
2116
- const cached = this.indexCache.readCached(identity);
2117
- if (cached !== undefined) indexes.set(name, cached);
2118
- }
2119
- }
2120
-
2121
- return { indexes, unreachable };
2122
- }
2123
-
2124
- /**
2125
- * Settles what an update covers: a repository the caller named outright, the
2126
- * repository, namespace or recipe a reference names, or everything.
2127
- *
2128
- * @param options - The reference or repository, and the accept-first flag.
2129
- * @param indexes - The freshly fetched indexes, which the reference is resolved against.
2130
- */
2131
- private async resolveUpdateScope(
2132
- options: UpdateOptions,
2133
- indexes: Map<string, IndexFile>
2134
- ): Promise<UpdateScope> {
2135
- if (options.repo !== undefined) {
2136
- this.repoSearchOrder(options.repo);
2137
- return { kind: "repository", repo: options.repo };
2138
- }
2139
- if (options.ref === undefined) return { kind: "all" };
2140
-
2141
- const repoOrder = this.repoSearchOrder();
2142
- const repos = this.currentRepos();
2143
- const urls: Record<string, string | undefined> = {};
2144
- for (const name of repoOrder) urls[name] = repos[name]?.url;
2145
-
2146
- const matches = await this.findRefs(options.ref, repoOrder, indexes, [
2147
- "repo",
2148
- "namespace",
2149
- "recipe",
2150
- ]);
2151
-
2152
- const chosen = await this.pickRef(matches, {
2153
- search: options.ref,
2154
- ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
2155
- details: [
2156
- ` No trusted repository, namespace or recipe has that name.`,
2157
- ...describeIndexSearch({ name: options.ref, repoOrder, indexes }),
2158
- ],
2159
- });
2160
-
2161
- switch (chosen.kind) {
2162
- case "repo":
2163
- return { kind: "repository", repo: chosen.name! };
2164
- case "namespace":
2165
- return { kind: "namespace", repo: repoOf(chosen)!.name!, namespace: chosen.name };
2166
- default:
2167
- return {
2168
- kind: "recipe",
2169
- repo: repoOf(chosen)!.name!,
2170
- key: shortKey(chosen as NamedRef),
2171
- };
2172
- }
2173
- }
2174
-
2175
- /**
2176
- * Works out what an update would do, writing nothing.
2177
- *
2178
- * Every subscription the scope touches is resolved afresh against the indexes
2179
- * given, one at a time so one that cannot be resolved does not stop the
2180
- * others, with every pin outside the scope held where the lockfile has it.
2181
- * What the resolutions settle on is laid over the lockfile, and every hold the
2182
- * new closure no longer declares is released, refcounted, so a dependency a
2183
- * newer version dropped leaves the lockfile unless something else still holds
2184
- * it.
2185
- *
2186
- * @param scope - What may move.
2187
- * @param indexes - The indexes to resolve against.
2188
- * @param unreachable - Repositories whose pins must not move, because their
2189
- * index could not be fetched.
2190
- * @param dryRun - When true, read manifests only from disk and download nothing.
2191
- */
2192
- private async planUpdate(
2193
- scope: UpdateScope,
2194
- indexes: Map<string, IndexFile>,
2195
- unreachable: Set<string>,
2196
- dryRun: boolean
2197
- ): Promise<{
2198
- after: Lockfile;
2199
- diff: LockDiff;
2200
- changed: ResolvedRecipe[];
2201
- missingRepos: MissingRepo[];
2202
- failed: Array<{ key: string; reason: string }>;
2203
- linked: string[];
2204
- cycles: string[][];
2205
- }> {
2206
- const before = this.lock.read();
2207
- const subscriptions = this.allSubscriptions();
2208
- const repos = this.resolverRepos();
2209
-
2210
- /** True when the update may move this recipe. */
2211
- const movable = (key: string, repo: string): boolean => {
2212
- if (unreachable.has(repo)) return false;
2213
- if (key === CORE_RECIPE_KEY && this.isBuiltInSubscription(CORE_NAMESPACE)) {
2214
- return false;
2215
- }
2216
- return recipeInScope(scope, key, repo);
2217
- };
2218
-
2219
- // Everything the update may not move is held where the lockfile has it.
2220
- const keep: Record<string, string> = {};
2221
- for (const [key, entry] of Object.entries(before.recipes)) {
2222
- if (!movable(key, entry.repo)) keep[key] = entry.version;
2223
- }
2224
-
2225
- const targets = Object.keys(subscriptions)
2226
- .sort()
2227
- .filter((key) => !this.isBuiltInSubscription(key))
2228
- .filter((key) => this.subscriptionTouches(key, scope, before, movable));
2229
-
2230
- const merged = new Map<string, ResolvedRecipe>();
2231
- const resolvedSubscriptions: string[] = [];
2232
- const missing = new Map<string, MissingRepo>();
2233
- const failed: Array<{ key: string; reason: string }> = [];
2234
- const cycles: string[][] = [];
2235
-
2236
- for (const key of targets) {
2237
- const entry = subscriptions[key]!;
2238
- let request: RefRequest;
2239
- try {
2240
- request = {
2241
- ref: parseNamedRef(key, RefSource.Config),
2242
- ...(entry.range === undefined ? {} : { range: entry.range }),
2243
- requestedBy: PROJECT_REQUESTER,
2244
- kind: "subscribes",
2245
- ...(entry.prerelease === true ? { prerelease: true } : {}),
2246
- };
2247
- } catch (error) {
2248
- failed.push({ key, reason: describeError(error) });
2249
- continue;
2250
- }
2251
-
2252
- let result;
2253
- try {
2254
- result = await resolveRefs([request], {
2255
- indexes,
2256
- repos,
2257
- keep,
2258
- loadManifest: (recipe) => this.loadRecipeManifest(recipe, dryRun),
2259
- ...(entry.prerelease === true ? { prerelease: true } : {}),
2260
- });
2261
- } catch (error) {
2262
- failed.push({ key, reason: describeError(error) });
2263
- continue;
2264
- }
2265
-
2266
- cycles.push(...result.cycles);
2267
- for (const repo of result.missingRepos) {
2268
- const known = missing.get(repo.name);
2269
- if (known === undefined) missing.set(repo.name, { ...repo });
2270
- else known.requiredBy.push(...repo.requiredBy);
2271
- }
2272
-
2273
- let conflict = false;
2274
- for (const recipe of result.resolved) {
2275
- const already = merged.get(recipe.key);
2276
- if (already !== undefined && already.version !== recipe.version) {
2277
- failed.push({
2278
- key,
2279
- reason:
2280
- `It wants version ${recipe.version} of '${recipe.key}', and another ` +
2281
- `subscription wants ${already.version}.`,
2282
- });
2283
- conflict = true;
2284
- break;
2285
- }
2286
- }
2287
- if (conflict) continue;
2288
-
2289
- for (const recipe of result.resolved) {
2290
- const already = merged.get(recipe.key);
2291
- merged.set(recipe.key, already === undefined ? recipe : mergeHolders(already, recipe));
2292
- }
2293
- resolvedSubscriptions.push(key);
2294
- }
2295
-
2296
- const settled = [...merged.values()];
2297
- const applied = this.lock.applyResolution(before, settled, this.lockRepoInputs());
2298
- const others = Object.keys(subscriptions).filter(
2299
- (key) => !resolvedSubscriptions.includes(key)
2300
- );
2301
- const after = this.releaseDroppedHolds(applied, merged, resolvedSubscriptions, others);
2302
- const diff = this.lock.diff(before, after);
2303
-
2304
- const changed = settled.filter(
2305
- (recipe) => before.recipes[recipe.key]?.version !== recipe.version
2306
- );
2307
- const linked = [
2308
- ...new Set(
2309
- changed
2310
- .map((recipe) => recipe.repo)
2311
- .filter((repo) => linkedPathFor(repo, this.sousDir, this.env) !== undefined)
2312
- ),
2313
- ].sort();
2314
-
2315
- return {
2316
- after,
2317
- diff,
2318
- changed,
2319
- missingRepos: [...missing.values()].sort((left, right) =>
2320
- left.name < right.name ? -1 : 1
2321
- ),
2322
- failed,
2323
- linked,
2324
- cycles,
2325
- };
2326
- }
2327
-
2328
- /**
2329
- * True when a subscription reaches anything the update may move, which is
2330
- * what decides whether it is resolved again. A subscription with nothing
2331
- * locked yet counts when its own ref falls inside the scope.
2332
- *
2333
- * @param key - The subscription's ref key.
2334
- * @param scope - What the update covers.
2335
- * @param lock - The lockfile as it stands.
2336
- * @param movable - Whether one locked recipe may move.
2337
- */
2338
- private subscriptionTouches(
2339
- key: string,
2340
- scope: UpdateScope,
2341
- lock: Lockfile,
2342
- movable: (key: string, repo: string) => boolean
2343
- ): boolean {
2344
- if (scope.kind === "all") return true;
2345
-
2346
- let parsed: NamedRef;
2347
- try {
2348
- parsed = parseNamedRef(key, RefSource.Config);
2349
- } catch {
2350
- return false;
2351
- }
2352
- const parsedRepo = repoOf(parsed)?.name;
2353
-
2354
- const closure = lockedClosure(lock, keysHeldBySubscription(lock, shortKey(parsed)));
2355
- if (closure.some((held) => movable(held, lock.recipes[held]!.repo))) return true;
2356
-
2357
- // Nothing locked reaches the scope, but the subscription may still name it:
2358
- // a namespace subscription gains the recipes a namespace publishes later.
2359
- if (parsedRepo !== undefined && parsedRepo !== scope.repo) return false;
2360
- const covered = shortKey(parsed);
2361
- switch (scope.kind) {
2362
- case "repository":
2363
- return parsedRepo === scope.repo;
2364
- case "namespace":
2365
- return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
2366
- case "recipe":
2367
- return covered === scope.key || scope.key.startsWith(`${covered}/`);
2368
- }
2369
- }
2370
-
2371
- /**
2372
- * Releases every hold the new resolution no longer declares.
2373
- *
2374
- * Laying a resolution over the lockfile only ever adds holders, which is right
2375
- * for a subscription and wrong for an update: a newer version that no longer
2376
- * depends on something must stop holding it, and a namespace that stopped
2377
- * publishing a recipe must stop holding that one. A hold is released when the
2378
- * holder was resolved again and did not declare the recipe this time; an entry
2379
- * left with no holders goes, and so does whatever only it was holding.
2380
- *
2381
- * @param lock - The lockfile with the resolution laid over it.
2382
- * @param merged - What the resolutions settled on, by key.
2383
- * @param resolvedSubscriptions - The subscriptions that were resolved again.
2384
- * @param otherSubscriptions - Subscriptions that were not, whose holds stand.
2385
- */
2386
- private releaseDroppedHolds(
2387
- lock: Lockfile,
2388
- merged: Map<string, ResolvedRecipe>,
2389
- resolvedSubscriptions: string[],
2390
- otherSubscriptions: string[]
2391
- ): Lockfile {
2392
- const coverOf = (key: string): string | undefined => {
2393
- try {
2394
- return shortKey(parseNamedRef(key, RefSource.Config));
2395
- } catch {
2396
- return undefined;
2397
- }
2398
- };
2399
- const covers = (cover: string | undefined, key: string): boolean =>
2400
- cover !== undefined && (key === cover || key.startsWith(`${cover}/`));
2401
- const resolvedCovers = resolvedSubscriptions.map(coverOf);
2402
- const otherCovers = otherSubscriptions.map(coverOf);
2403
-
2404
- const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
2405
- const orphaned: string[] = [];
2406
-
2407
- for (const [key, entry] of Object.entries(lock.recipes)) {
2408
- const settled = merged.get(key);
2409
- const kept = entry.requestedBy.filter((holder) => {
2410
- if (holder === PROJECT_HOLDER) {
2411
- if (!resolvedCovers.some((cover) => covers(cover, key))) return true;
2412
- if (otherCovers.some((cover) => covers(cover, key))) return true;
2413
- return settled?.requestedBy.includes(PROJECT_HOLDER) === true;
2414
- }
2415
- if (!merged.has(holder)) return true;
2416
- return settled?.requestedBy.includes(holder) === true;
2417
- });
2418
-
2419
- if (kept.length === entry.requestedBy.length) continue;
2420
- if (kept.length === 0) {
2421
- delete recipes[key];
2422
- orphaned.push(key);
2423
- } else {
2424
- recipes[key] = { ...entry, requestedBy: kept };
2425
- }
2426
- }
2427
-
2428
- let result: Lockfile = { ...lock, recipes };
2429
- for (const key of orphaned) result = this.lock.removeHolder(result, key);
2430
- return result;
2431
- }
2432
-
2433
- /**
2434
- * True when a subscription is one sous provides itself, which an update never
2435
- * moves: its range is exactly the running sous version.
2436
- *
2437
- * @param key - The subscription's ref key.
2438
- */
2439
- private isBuiltInSubscription(key: string): boolean {
2440
- const configured = enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>;
2441
- return isBuiltInEntry(configured[key]);
2442
- }
2443
-
2444
- /**
2445
- * The subscriptions sous provides itself that an update of this scope would
2446
- * otherwise have covered, with the version each stays at, for the plan.
2447
- *
2448
- * @param scope - What the update covers.
2449
- */
2450
- private builtInSubscriptionsIn(
2451
- scope: UpdateScope
2452
- ): Array<{ key: string; version?: string }> {
2453
- const lock = this.lock.read();
2454
- return Object.keys(this.allSubscriptions())
2455
- .filter((key) => this.isBuiltInSubscription(key))
2456
- .filter((key) =>
2457
- keysHeldBySubscription(lock, key).some((held) =>
2458
- recipeInScope(scope, held, lock.recipes[held]!.repo)
2459
- )
2460
- )
2461
- .map((key) => {
2462
- const version = this.allSubscriptions()[key]?.range;
2463
- return { key, ...(version === undefined ? {} : { version }) };
2464
- });
2465
- }
2466
-
2467
- /**
2468
- * The switched-off subscriptions an update of this scope leaves alone.
2469
- *
2470
- * @param scope - What the update covers.
2471
- */
2472
- private switchedOffSubscriptionsIn(scope: UpdateScope): string[] {
2473
- const declared: Record<string, SubscriptionEntry> = {
2474
- ...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
2475
- ...this.readSubscriptionEntries(),
2476
- };
2477
-
2478
- return Object.keys(declared)
2479
- .filter((key) => declared[key]?.enabled === false)
2480
- .filter((key) => {
2481
- if (scope.kind === "all") return true;
2482
- let parsed: NamedRef;
2483
- try {
2484
- parsed = parseNamedRef(key, RefSource.Config);
2485
- } catch {
2486
- return false;
2487
- }
2488
- const covered = shortKey(parsed);
2489
- switch (scope.kind) {
2490
- case "repository":
2491
- return repoOf(parsed)?.name === scope.repo;
2492
- case "namespace":
2493
- return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
2494
- case "recipe":
2495
- return covered === scope.key || scope.key.startsWith(`${covered}/`);
2496
- }
2497
- })
2498
- .sort();
2499
- }
2500
-
2501
- /**
2502
- * Fetches one repository's index with a short timeout and says which of its
2503
- * pins have a newer published version their ranges allow. Nothing moves; this
2504
- * is the fact `sous repo unlink` reports. A fetch that fails or takes too long
2505
- * is an answer too: that sous could not check.
2506
- *
2507
- * @param name - The repository's short name.
2508
- * @param options - How long to wait for the index, in milliseconds.
2509
- */
2510
- async newerPublishedVersions(
2511
- name: string,
2512
- options: { timeoutMs?: number } = {}
2513
- ): Promise<NewerVersionsReport> {
2514
- const timeoutMs = options.timeoutMs ?? NEWER_VERSION_CHECK_TIMEOUT_MS;
2515
- const lock = this.lock.read();
2516
- const entry = this.currentRepos()[name];
2517
- const url = entry?.url ?? lock.repos[name]?.url;
2518
- const identity = this.identityForRepo(name, lock);
2519
- if (url === undefined || identity === undefined) {
2520
- return {
2521
- checked: false,
2522
- reason: `This project does not trust a repository called '${name}'.`,
2523
- };
2524
- }
2525
-
2526
- // The same deadline and the same comparison a build's newer-version report
2527
- // uses, so the two can never disagree about what counts as newer.
2528
- let index: IndexFile;
2529
- try {
2530
- index = (
2531
- await withDeadline(
2532
- (signal) =>
2533
- this.indexCache.refresh(identity, {
2534
- url,
2535
- label: name,
2536
- ...(entry?.provider === undefined ? {} : { provider: entry.provider }),
2537
- signal,
2538
- }),
2539
- timeoutMs
2540
- )
2541
- ).index;
2542
- } catch (error) {
2543
- return { checked: false, reason: describeError(error) };
2544
- } finally {
2545
- recordUpstreamCheck(this.indexCache, identity, this.now());
2546
- }
2547
-
2548
- return {
2549
- checked: true,
2550
- newer: this.newerInRange(index, name, lock, this.allSubscriptions()),
2551
- };
2552
- }
2553
-
2554
- /**
2555
- * Every recipe the lockfile pins from one repository that has a newer
2556
- * published version inside the range its holders declared, sorted by key.
2557
- * Ranges come from `effectiveRangeForHolders`, so nothing is ever reported
2558
- * past a constraint a dependency declared. A build's newer-version report and
2559
- * `sous repo unlink` both read it.
2560
- *
2561
- * @param index - The repository's index.
2562
- * @param repoName - The repository's short name.
2563
- * @param lock - The lockfile as it stands.
2564
- * @param subscriptions - Every subscription, for ranges and prerelease opt-ins.
2565
- */
2566
- private newerInRange(
2567
- index: IndexFile,
2568
- repoName: string,
2569
- lock: Lockfile,
2570
- subscriptions: Record<string, SubscriptionEntry>
2571
- ): NewerVersion[] {
2572
- const newer: NewerVersion[] = [];
2573
- for (const key of Object.keys(lock.recipes).sort()) {
2574
- const entry = lock.recipes[key]!;
2575
- if (entry.repo !== repoName) continue;
2576
- const range = this.effectiveRangeFor(key, entry, subscriptions);
2577
- if (range === undefined) continue;
2578
- const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2579
-
2580
- const found = findNewerInRange({
2581
- index,
2582
- key,
2583
- lockedVersion: entry.version,
2584
- ...(range === "*" ? {} : { range }),
2585
- ...(subscription?.prerelease === undefined
2586
- ? {}
2587
- : { prerelease: subscription.prerelease }),
2588
- });
2589
- if (found !== undefined) newer.push(found);
2590
- }
2591
- return newer;
2592
- }
2593
-
2594
- // --- Restoring and upstream checks -------------------------------------------------------------
2595
-
2596
- /**
2597
- * True when the lockfile pins something the store does not hold, which is the
2598
- * state of a fresh clone. A linked repository is read from its checkout and is
2599
- * never restored.
2600
- */
2601
- needsRestore(): boolean {
2602
- return listLockedRecipes({ sousDir: this.sousDir, env: this.env }).some(
2603
- (recipe) => !recipe.linked && !recipe.present
2604
- );
2605
- }
2606
-
2607
- /**
2608
- * Puts the core recipe that ships inside the sous package into the store,
2609
- * writes a stand-in index for the official repository when nothing real has
2610
- * ever been fetched, and tells the index cache about the packaged version so
2611
- * it resolves even against a real index that has not published it yet. This
2612
- * runs before anything else a build does, because it is what lets a project
2613
- * resolve the `core` namespace at all, with or without a network.
2614
- *
2615
- * Idempotent, offline, and never fatal: a failure comes back in the report as
2616
- * a sentence to warn about.
2617
- */
2618
- async seedCore(): Promise<SeedCoreRecipeReport> {
2619
- if (this.seedReport !== undefined) return this.seedReport;
2620
- this.seedReport = await seedCoreRecipe({
2621
- store: this.storeInstance,
2622
- sousVersion: SOUS_VERSION,
2623
- now: this.now,
2624
- // Teaching the index cache what the package holds is what lets the seeded
2625
- // version resolve on a machine whose cached index is the real one and does
2626
- // not publish that version yet.
2627
- indexCache: this.indexCache,
2628
- warn: this.warn,
2629
- });
2630
- return this.seedReport;
2631
- }
2632
-
2633
- /**
2634
- * Brings the lockfile in line with the subscriptions the config declares.
2635
- *
2636
- * A subscription is normally written by `sous subscribe`, which locks it on
2637
- * the spot. Two cases leave one declared but unlocked, and both have to work
2638
- * without anyone typing a command: a subscription hand-written into the config
2639
- * (or arriving with a colleague's commit), and the built-in `core`
2640
- * subscription, whose range is the running sous version and therefore changes
2641
- * every time sous is upgraded.
2642
- *
2643
- * So a subscription is resolved here when the lockfile pins nothing for it, or
2644
- * pins something its range no longer allows. Everything else is left exactly
2645
- * as the lockfile has it; a build never re-decides a version it already has.
2646
- *
2647
- * Nothing here is fatal and nothing here prompts. A build must not stop
2648
- * because a repository is unreachable, and it must never block on a question,
2649
- * so a subscription that cannot be resolved comes back in the report as a
2650
- * sentence to warn about.
2651
- */
2652
- async ensureSubscriptionsLocked(): Promise<SubscriptionSyncReport> {
2653
- const report: SubscriptionSyncReport = {
2654
- resolved: [],
2655
- added: [],
2656
- moved: [],
2657
- failed: [],
2658
- };
2659
-
2660
- const subscriptions = this.allSubscriptions();
2661
- const before = this.lock.read();
2662
-
2663
- const pending: Array<{ key: string; request: RefRequest }> = [];
2664
- for (const key of Object.keys(subscriptions).sort()) {
2665
- const entry = subscriptions[key]!;
2666
- if (this.subscriptionIsLocked(key, entry, before)) continue;
2667
-
2668
- let parsed: NamedRef;
2669
- try {
2670
- parsed = parseNamedRef(key, RefSource.Config);
2671
- } catch (error) {
2672
- report.failed.push({ key, reason: describeError(error) });
2673
- continue;
2674
- }
2675
-
2676
- pending.push({
2677
- key,
2678
- request: {
2679
- ref: parsed,
2680
- ...(entry.range === undefined ? {} : { range: entry.range }),
2681
- requestedBy: PROJECT_REQUESTER,
2682
- kind: "subscribes",
2683
- ...(entry.prerelease === true ? { prerelease: true } : {}),
2684
- },
2685
- });
2686
- }
2687
-
2688
- if (pending.length === 0) return report;
2689
-
2690
- const repos = this.resolverRepos();
2691
- const indexes = await this.loadIndexes(Object.keys(repos), { lock: before });
2692
-
2693
- // Each subscription is resolved on its own. Resolving them together would be
2694
- // one call, but the resolver raises on the first ref it cannot settle, and a
2695
- // project should not lose four subscriptions because one of them names a
2696
- // recipe that no longer exists. What the separate resolutions produce is
2697
- // merged back together below, so a recipe two subscriptions both depend on
2698
- // still records both of them as holders.
2699
- const stored = new Map<string, ResolvedRecipe>();
2700
- for (const { key, request } of pending) {
2701
- let result;
2702
- try {
2703
- result = await resolveRefs([request], {
2704
- indexes,
2705
- repos,
2706
- loadManifest: (recipe) => this.loadRecipeManifest(recipe, false),
2707
- });
2708
- } catch (error) {
2709
- report.failed.push({ key, reason: describeError(error) });
2710
- continue;
2711
- }
2712
-
2713
- // A repository something needs but the project has not added is a trust
2714
- // decision, and a build is the wrong moment to ask for one. Say which
2715
- // command grants it, and leave this subscription alone.
2716
- if (result.missingRepos.length > 0) {
2717
- const names = result.missingRepos.map((missing) => missing.name);
2718
- report.failed.push({
2719
- key,
2720
- reason:
2721
- `Sous has not been told where ${
2722
- names.length === 1
2723
- ? `the repository '${names[0]}' lives`
2724
- : `these repositories live: ${names.map((n) => `'${n}'`).join(", ")}`
2725
- }, so it could not be resolved.\n` +
2726
- names.map((name) => ` sous repo add <url> --name ${name}`).join("\n"),
2727
- });
2728
- continue;
2729
- }
2730
-
2731
- let failed = false;
2732
- const settled: ResolvedRecipe[] = [];
2733
- for (const recipe of result.resolved) {
2734
- try {
2735
- await this.ensureStored(recipe);
2736
- settled.push(recipe);
2737
- } catch (error) {
2738
- report.failed.push({ key, reason: describeError(error) });
2739
- failed = true;
2740
- break;
2741
- }
2742
- }
2743
-
2744
- if (failed) continue;
2745
- report.resolved.push(key);
2746
-
2747
- for (const recipe of settled) {
2748
- const already = stored.get(recipe.key);
2749
- if (already === undefined) {
2750
- stored.set(recipe.key, recipe);
2751
- continue;
2752
- }
2753
-
2754
- if (already.version !== recipe.version) {
2755
- report.failed.push({
2756
- key: recipe.key,
2757
- reason:
2758
- `Two of this project's subscriptions want different versions of ` +
2759
- `'${recipe.key}': ${already.version} and ${recipe.version}. Sous kept ` +
2760
- `${already.version}.\n` +
2761
- ` Subscribe to '${recipe.key}' directly, with the range you want, so ` +
2762
- `there is one answer.`,
2763
- });
2764
- continue;
2765
- }
2766
-
2767
- stored.set(recipe.key, mergeHolders(already, recipe));
2768
- }
2769
- }
2770
-
2771
- if (stored.size === 0) return report;
2772
-
2773
- const settledRecipes = [...stored.values()];
2774
- const after = this.lock.applyResolution(before, settledRecipes, this.lockRepoInputs());
2775
- for (const recipe of settledRecipes) {
2776
- const previous = before.recipes[recipe.key];
2777
- if (previous === undefined) {
2778
- report.added.push({ key: recipe.key, version: recipe.version });
2779
- } else if (previous.version !== recipe.version) {
2780
- report.moved.push({
2781
- key: recipe.key,
2782
- from: previous.version,
2783
- to: recipe.version,
2784
- });
2785
- }
2786
- }
2787
-
2788
- this.lock.write(after);
2789
- return report;
2790
- }
2791
-
2792
- /**
2793
- * True when the lockfile already pins everything one subscription asks for, at
2794
- * a version its range still allows.
2795
- *
2796
- * @param key - The subscription's ref key: a namespace, or `namespace/recipe`.
2797
- * @param entry - The subscription entry, which carries the range.
2798
- * @param lock - The lockfile as it stands.
2799
- */
2800
- private subscriptionIsLocked(
2801
- key: string,
2802
- entry: SubscriptionEntry,
2803
- lock: Lockfile
2804
- ): boolean {
2805
- const matches = key.includes("/")
2806
- ? lock.recipes[key] === undefined
2807
- ? []
2808
- : [lock.recipes[key]!]
2809
- : Object.entries(lock.recipes)
2810
- .filter(([lockedKey]) => lockedKey.startsWith(`${key}/`))
2811
- .map(([, locked]) => locked);
2812
-
2813
- if (matches.length === 0) return false;
2814
-
2815
- const range = entry.range;
2816
- const includePrerelease = entry.prerelease === true;
2817
-
2818
- return matches.every((locked) => {
2819
- if (!locked.requestedBy.includes(PROJECT_HOLDER)) return false;
2820
- if (range === undefined || range === "*") return true;
2821
- return semver.satisfies(locked.version, range, { includePrerelease });
2822
- });
2823
- }
2824
-
2825
- /**
2826
- * Makes the store hold exactly what the lockfile pins. Nothing here decides a
2827
- * version and nothing here asks a question; that is what makes a fresh clone
2828
- * reproducible.
2829
- */
2830
- async restore(): Promise<RestoreReport> {
2831
- await this.seedCore();
2832
- const lock = this.lock.read();
2833
- if (Object.keys(lock.recipes).length === 0) {
2834
- return { restored: [], alreadyPresent: [] };
2835
- }
2836
-
2837
- const indexes = await this.loadIndexes(Object.keys(lock.repos), { lock });
2838
- return this.lock.restore(lock, {
2839
- store: this.storeInstance,
2840
- indexes,
2841
- providers: this.providers,
2842
- providerOptions: this.providerOptions,
2843
- });
2844
- }
2845
-
2846
- /**
2847
- * Looks upstream for every repository the lockfile pins something from, on
2848
- * the freshness window. A repository that prefers a newer in-range version
2849
- * moves the lockfile to it; every other repository is only read, and a newer
2850
- * in-range version it publishes is reported in `newer` without moving any
2851
- * pin. A failed check never breaks a build: for a repository that prefers
2852
- * newer versions it is warned about, and for the others it is quiet, capped
2853
- * at a short deadline, and the cached index answers instead.
2854
- *
2855
- * @param options - Whether to check regardless of the freshness window, which
2856
- * window to use, and how long to wait for a repository checked only for the
2857
- * newer-version report.
2858
- */
2859
- async checkUpstream(
2860
- options: { force?: boolean; freshnessSeconds?: number; newerCheckTimeoutMs?: number } = {}
2861
- ): Promise<UpstreamCheckReport> {
2862
- const report: UpstreamCheckReport = {
2863
- checked: [],
2864
- updated: [],
2865
- failed: [],
2866
- newer: [],
2867
- unchecked: [],
2868
- };
2869
- const lock = this.lock.read();
2870
- if (Object.keys(lock.recipes).length === 0) return report;
2871
-
2872
- const storeSettings = resolveStoreSettings(this.settings);
2873
- const freshnessSeconds = options.freshnessSeconds ?? storeSettings.freshnessSeconds;
2874
- const repos = this.currentRepos();
2875
- const subscriptions = this.allSubscriptions();
2876
-
2877
- let changed = false;
2878
- const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
2879
-
2880
- for (const repoName of Object.keys(lock.repos).sort()) {
2881
- const identity = this.identityForRepo(repoName, lock);
2882
- if (identity === undefined) continue;
2883
-
2884
- const prefersNewer = this.prefersNewer(repoName, repos[repoName], lock, subscriptions);
2885
- const meta = this.indexCache.readMeta(identity);
2886
- const due = shouldCheckUpstream({
2887
- ...(meta?.lastCheckedAt === undefined ? {} : { lastCheckedAt: meta.lastCheckedAt }),
2888
- freshnessSeconds,
2889
- alwaysPull: prefersNewer,
2890
- ...(options.force === undefined ? {} : { force: options.force }),
2891
- now: this.now(),
2892
- });
2893
-
2894
- if (!prefersNewer) {
2895
- await this.reportNewerVersions({
2896
- repoName,
2897
- identity,
2898
- due,
2899
- lock,
2900
- subscriptions,
2901
- report,
2902
- timeoutMs: options.newerCheckTimeoutMs ?? NEWER_VERSION_CHECK_TIMEOUT_MS,
2903
- });
2904
- continue;
2905
- }
2906
-
2907
- if (!due) continue;
2908
-
2909
- report.checked.push(repoName);
2910
-
2911
- let index: IndexFile;
2912
- try {
2913
- const url = repos[repoName]?.url ?? lock.repos[repoName]!.url;
2914
- const providerId = repos[repoName]?.provider;
2915
- index = (
2916
- await this.indexCache.getIndex(identity, {
2917
- url,
2918
- label: repoName,
2919
- ...(providerId === undefined ? {} : { provider: providerId }),
2920
- force: true,
2921
- })
2922
- ).index;
2923
- } catch (error) {
2924
- report.failed.push({ repo: repoName, reason: describeError(error) });
2925
- // Recorded even though it failed, so an unreachable host is not retried
2926
- // on every single build.
2927
- recordUpstreamCheck(this.indexCache, identity, this.now());
2928
- continue;
2929
- }
2930
-
2931
- recordUpstreamCheck(this.indexCache, identity, this.now());
2932
-
2933
- for (const [key, entry] of Object.entries(recipes)) {
2934
- if (entry.repo !== repoName) continue;
2935
- const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2936
-
2937
- // Always-pull re-resolves WITHIN what was declared; it never widens it.
2938
- // A recipe held only through another recipe's `depends` has no
2939
- // subscription to read a range from, and treating that as "any version"
2940
- // would move it straight past the constraint the dependency declared.
2941
- const range = this.effectiveRangeFor(key, entry, subscriptions);
2942
- if (range === undefined) continue;
2943
-
2944
- const newer = findNewerInRange({
2945
- index,
2946
- key,
2947
- lockedVersion: entry.version,
2948
- ...(range === "*" ? {} : { range }),
2949
- ...(subscription?.prerelease === undefined
2950
- ? {}
2951
- : { prerelease: subscription.prerelease }),
2952
- });
2953
- if (newer === undefined) continue;
2954
-
2955
- const published = index.recipes[key]!.versions[newer.to]!;
2956
- try {
2957
- await this.fetchIntoStore({
2958
- identity,
2959
- key,
2960
- version: newer.to,
2961
- hash: published.hash,
2962
- tag: published.tag,
2963
- recipePath: index.recipes[key]!.path,
2964
- url: repos[repoName]?.url ?? lock.repos[repoName]!.url,
2965
- providerId: repos[repoName]?.provider,
2966
- });
2967
- } catch (error) {
2968
- report.failed.push({ repo: repoName, reason: describeError(error) });
2969
- continue;
2970
- }
2971
-
2972
- recipes[key] = { ...entry, version: newer.to, hash: published.hash };
2973
- report.updated.push(newer);
2974
- changed = true;
2975
- }
2976
- }
2977
-
2978
- if (changed) this.lock.write({ ...lock, recipes });
2979
- return report;
2980
- }
2981
-
2982
- /**
2983
- * The newer-version report for one repository that does not prefer newer
2984
- * versions: reads its index (from upstream when the freshness window has
2985
- * lapsed, within a short deadline, and from the cache otherwise) and records
2986
- * every locked recipe from it that has a newer version in range. Nothing
2987
- * moves, and nothing here is ever an error: a check that fails or runs out of
2988
- * time is noted in `unchecked` and the cached index answers instead.
2989
- *
2990
- * @param input - The repository, whether a check is due, and the report to fill.
2991
- */
2992
- private async reportNewerVersions(input: {
2993
- repoName: string;
2994
- identity: string;
2995
- due: boolean;
2996
- lock: Lockfile;
2997
- subscriptions: Record<string, SubscriptionEntry>;
2998
- report: UpstreamCheckReport;
2999
- timeoutMs: number;
3000
- }): Promise<void> {
3001
- const { repoName, identity, lock, report } = input;
3002
- let index: IndexFile | undefined;
3003
-
3004
- if (input.due) {
3005
- report.checked.push(repoName);
3006
- const repo = this.currentRepos()[repoName];
3007
- const url = repo?.url ?? lock.repos[repoName]!.url;
3008
- try {
3009
- index = (
3010
- await withDeadline(
3011
- (signal) =>
3012
- this.indexCache.refresh(identity, {
3013
- url,
3014
- label: repoName,
3015
- ...(repo?.provider === undefined ? {} : { provider: repo.provider }),
3016
- signal,
3017
- }),
3018
- input.timeoutMs
3019
- )
3020
- ).index;
3021
- } catch (error) {
3022
- report.unchecked.push({ repo: repoName, reason: describeError(error) });
3023
- }
3024
- // Recorded whatever happened, so an unreachable host costs one short wait
3025
- // per freshness window rather than one per build.
3026
- recordUpstreamCheck(this.indexCache, identity, this.now());
3027
- }
3028
-
3029
- index ??= this.indexCache.readCached(identity);
3030
- if (index === undefined) return;
3031
-
3032
- for (const newer of this.newerInRange(index, repoName, lock, input.subscriptions)) {
3033
- report.newer.push({ ...newer, repo: repoName });
3034
- }
3035
- }
3036
-
3037
- /**
3038
- * Reads one trusted repository's index from upstream, without writing it to
3039
- * the cache: what a browsing command asked to read the latest uses. A failure
3040
- * is raised, so the caller can fall back to the cached copy and say so.
3041
- *
3042
- * @param name - The repository's short name.
3043
- */
3044
- async upstreamIndex(name: string): Promise<IndexFile> {
3045
- const repo = this.currentRepos()[name];
3046
- if (repo === undefined) {
3047
- throw new ConfigError(`This project trusts no repository called '${name}'.`);
3048
- }
3049
- const identity = this.identityOf(repo.url, repo.provider);
3050
- return this.indexCache.fetchUpstream(identity, {
3051
- url: repo.url,
3052
- ...(repo.provider === undefined ? {} : { provider: repo.provider }),
3053
- });
3054
- }
3055
-
3056
- /**
3057
- * Everything a build needs done before it compiles: seed the packaged core
3058
- * recipe, restore whatever else the store is missing, then look upstream for
3059
- * the repositories that want a newer version. All three are quiet when there
3060
- * is nothing to do.
3061
- *
3062
- * @param options - Whether to force the upstream check, and which freshness window to use.
3063
- */
3064
- async prepareForBuild(
3065
- options: { force?: boolean; freshnessSeconds?: number } = {}
3066
- ): Promise<{
3067
- seed: SeedCoreRecipeReport;
3068
- subscriptions: SubscriptionSyncReport;
3069
- restored: RestoreReport | undefined;
3070
- upstream: UpstreamCheckReport;
3071
- }> {
3072
- const seed = await this.seedCore();
3073
- const subscriptions = await this.ensureSubscriptionsLocked();
3074
- let restored: RestoreReport | undefined;
3075
- if (this.needsRestore()) restored = await this.restore();
3076
- const upstream = await this.checkUpstream(options);
3077
- return { seed, subscriptions, restored, upstream };
3078
- }
3079
-
3080
- // --- Reading the project's state ---------------------------------------------------------------
3081
-
3082
- /**
3083
- * Every repository this project trusts, merging the config it was loaded with
3084
- * and the managed layer as it stands on disk right now. The layer is re-read
3085
- * because a trust round in this same process may have just written to it.
3086
- */
3087
- currentRepos(): Record<string, TrustedRepo> {
3088
- return {
3089
- ...(enabledRepos(this.settings) as Record<string, TrustedRepo>),
3090
- ...this.trust.listManaged(),
3091
- };
3092
- }
3093
-
3094
- /**
3095
- * Every subscription in force, from the config and from the managed layer.
3096
- *
3097
- * The managed layer is re-read rather than taken from the loaded config,
3098
- * because a command in this same process may have just written to it; an entry
3099
- * it switches off is dropped here, so removing a subscription sous provides
3100
- * itself really does stop it being locked and built.
3101
- */
3102
- allSubscriptions(): Record<string, SubscriptionEntry> {
3103
- const merged: Record<string, SubscriptionEntry> = {
3104
- ...(enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>),
3105
- ...this.readSubscriptionEntries(),
3106
- };
3107
-
3108
- for (const [key, entry] of Object.entries(merged)) {
3109
- if (entry?.enabled === false) delete merged[key];
3110
- }
3111
- return merged;
3112
- }
3113
-
3114
- /**
3115
- * Every subscription this project declares, switched-off ones included, with
3116
- * the versions the lockfile pins because of each. Switched-off entries are
3117
- * kept because an opt-out is part of what a project subscribes to, and hiding
3118
- * it would make `sous subscription list` disagree with the config.
3119
- *
3120
- * Reads only what is already on disk, so the listing is safe offline.
3121
- */
3122
- listSubscriptions(): SubscriptionListing[] {
3123
- const entries: Record<string, SubscriptionEntry> = {
3124
- ...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
3125
- ...this.readSubscriptionEntries(),
3126
- };
3127
-
3128
- const lock = this.lock.read();
3129
-
3130
- return Object.keys(entries)
3131
- .sort()
3132
- .map((key) => {
3133
- const entry = entries[key]!;
3134
- return {
3135
- key,
3136
- range: entry.range,
3137
- enabled: entry.enabled !== false,
3138
- addedBy: entry.addedBy,
3139
- pinned: keysHeldBySubscription(lock, key).map((heldKey) => ({
3140
- key: heldKey,
3141
- version: lock.recipes[heldKey]!.version,
3142
- })),
3143
- };
3144
- });
3145
- }
3146
-
3147
- /** The trusted repositories in the shape the resolver reads. */
3148
- private resolverRepos(): Record<string, ResolverRepo> {
3149
- const repos: Record<string, ResolverRepo> = {};
3150
- for (const [name, entry] of Object.entries(this.currentRepos())) {
3151
- repos[name] = {
3152
- url: entry.url,
3153
- identity: this.identityOf(entry.url, entry.provider),
3154
- ...(entry.provider === undefined ? {} : { provider: entry.provider }),
3155
- ...(entry.alwaysPull === undefined ? {} : { alwaysPull: entry.alwaysPull }),
3156
- };
3157
- }
3158
- return repos;
3159
- }
3160
-
3161
- /** The repository records the lockfile writes, keyed by short name. */
3162
- private lockRepoInputs(): Record<string, LockRepoInput> {
3163
- const inputs: Record<string, LockRepoInput> = {};
3164
- for (const [name, entry] of Object.entries(this.currentRepos())) {
3165
- inputs[name] = {
3166
- url: entry.url,
3167
- identity: this.identityOf(entry.url, entry.provider),
3168
- ...(entry.provider === undefined ? {} : { provider: entry.provider }),
3169
- };
3170
- }
3171
- return inputs;
3172
- }
3173
-
3174
- /**
3175
- * The cached (or freshly fetched) index of each named repository. A repository
3176
- * whose index cannot be obtained at all is warned about and left out, so one
3177
- * unreachable host never blocks work on the others.
3178
- *
3179
- * @param names - The repositories to load indexes for.
3180
- * @param options - A lockfile to fall back to for a repository's URL.
3181
- */
3182
- async loadIndexes(
3183
- names: string[],
3184
- options: { lock?: Lockfile; force?: boolean } = {}
3185
- ): Promise<Map<string, IndexFile>> {
3186
- const repos = this.currentRepos();
3187
- const indexes = new Map<string, IndexFile>();
3188
- const freshnessSeconds = resolveStoreSettings(this.settings).freshnessSeconds;
3189
-
3190
- for (const name of names) {
3191
- const url = repos[name]?.url ?? options.lock?.repos[name]?.url;
3192
- if (url === undefined) continue;
3193
-
3194
- try {
3195
- const identity =
3196
- this.identityForRepo(name, options.lock) ??
3197
- this.identityOf(url, repos[name]?.provider);
3198
- const lookup = await this.indexCache.getIndex(identity, {
3199
- url,
3200
- label: name,
3201
- ...(repos[name]?.provider === undefined
3202
- ? {}
3203
- : { provider: repos[name]!.provider! }),
3204
- maxAgeSeconds: freshnessSeconds,
3205
- ...(options.force === undefined ? {} : { force: options.force }),
3206
- });
3207
- indexes.set(name, lookup.index);
3208
- } catch (error) {
3209
- this.warn(
3210
- `Sous could not read the index of the repository '${name}', so nothing in it ` +
3211
- `can be resolved right now.\n${describeError(error)}`
3212
- );
3213
- }
3214
- }
3215
-
3216
- return indexes;
3217
- }
3218
-
3219
- // --- The store ---------------------------------------------------------------------------------
3220
-
3221
- /**
3222
- * The directory a resolved recipe's files are read from: a linked working copy
3223
- * when the repository is linked, otherwise its store entry.
3224
- *
3225
- * @param recipe - The resolved recipe.
3226
- */
3227
- private recipeDirectory(recipe: ResolvedRecipe): string {
3228
- const checkout = linkedPathFor(recipe.repo, this.sousDir, this.env);
3229
- if (checkout !== undefined) {
3230
- const linked = mapLinkedRecipes(checkout)[recipe.key];
3231
- if (linked !== undefined) return linked;
3232
- }
3233
- return this.storeInstance.entryDir(storeKeyFor(recipe));
3234
- }
3235
-
3236
- /**
3237
- * Makes sure a resolved recipe's files are in the store, fetching them when
3238
- * they are not. A linked repository is read from its checkout and is never
3239
- * fetched.
3240
- *
3241
- * @param recipe - The resolved recipe.
3242
- */
3243
- private async ensureStored(recipe: ResolvedRecipe): Promise<void> {
3244
- if (linkedPathFor(recipe.repo, this.sousDir, this.env) !== undefined) return;
3245
-
3246
- const key = storeKeyFor(recipe);
3247
- const hit = await this.storeInstance.get(key);
3248
- if (hit !== undefined && hit.entry.hash === recipe.hash) return;
3249
-
3250
- // The store refuses to overwrite an entry whose content differs, because a
3251
- // published version is immutable and one that changed underneath a project
3252
- // is worth refusing loudly. There is exactly one entry that is not a
3253
- // published version: the packaged core recipe sous seeds so a project can
3254
- // build before it has ever reached the network. That copy is a stand-in for
3255
- // the published one, not a rival to it, so when the two disagree at the same
3256
- // version it steps aside and the published copy is fetched over it.
3257
- if (hit !== undefined && (await this.isSeededCoreEntry(key, hit.entry.hash))) {
3258
- await this.storeInstance.remove(key);
3259
- }
3260
-
3261
- const repos = this.currentRepos();
3262
- const url = repos[recipe.repo]?.url;
3263
- if (url === undefined) {
3264
- throw new ConfigError(
3265
- `Sous cannot fetch '${recipe.key}' because it no longer knows where the ` +
3266
- `repository '${recipe.repo}' lives.\n` +
3267
- ` Add it with 'sous repo add <url> --name ${recipe.repo}'.`
3268
- );
3269
- }
3270
-
3271
- await this.fetchIntoStore({
3272
- identity: recipe.identity,
3273
- key: recipe.key,
3274
- version: recipe.version,
3275
- hash: recipe.hash,
3276
- tag: recipe.tag,
3277
- recipePath: recipe.path,
3278
- url,
3279
- providerId: repos[recipe.repo]?.provider,
3280
- });
3281
- }
3282
-
3283
- /**
3284
- * True when a store entry is the packaged core recipe that seeding put there,
3285
- * rather than anything fetched from a repository.
3286
- *
3287
- * The test is deliberately exact: the entry has to be the core recipe in the
3288
- * official repository, AND its content has to hash to what this installation's
3289
- * package holds. An entry that was genuinely fetched, or a seeded entry from a
3290
- * different installation, is left alone.
3291
- *
3292
- * @param key - The store key being written to.
3293
- * @param storedHash - The hash the entry currently holds.
3294
- */
3295
- private async isSeededCoreEntry(key: StoreKey, storedHash: string): Promise<boolean> {
3296
- if (key.identity !== OFFICIAL_REPO_IDENTITY) return false;
3297
- if (`${key.namespace}/${key.name}` !== CORE_RECIPE_KEY) return false;
3298
-
3299
- try {
3300
- return (await hashDirectory(packagedCoreRecipeDir())) === storedHash;
3301
- } catch {
3302
- // No packaged recipe to compare against means nothing here was seeded.
3303
- return false;
3304
- }
3305
- }
3306
-
3307
- /**
3308
- * Fetches one recipe version into the store, verifying it against the hash the
3309
- * index publishes. The download lands in a temporary directory beside the
3310
- * store, so a failed fetch never leaves a half-written entry behind.
3311
- *
3312
- * @param request - Which version to fetch, from where, and at which tag.
3313
- */
3314
- private async fetchIntoStore(request: {
3315
- identity: string;
3316
- key: string;
3317
- version: string;
3318
- hash: string;
3319
- tag: string;
3320
- recipePath: string;
3321
- url: string;
3322
- providerId?: string;
3323
- }): Promise<void> {
3324
- const provider = requireProvider(request.url, request.providerId, this.providers);
3325
- const canonical = provider.canonicalize(request.url);
3326
-
3327
- const { namespace, name } = splitRecipeKey(request.key);
3328
- const key: StoreKey = {
3329
- identity: request.identity,
3330
- namespace,
3331
- name,
3332
- version: request.version,
3333
- };
3334
-
3335
- ensureStoreRootDirectory(this.storeInstance.root);
3336
- const workDir = await fsp.mkdtemp(path.join(this.storeInstance.root, ".sous-fetch-"));
3337
- const fetchDir = path.join(workDir, key.name);
3338
- try {
3339
- await provider.fetchRecipeTree(
3340
- canonical,
3341
- request.recipePath,
3342
- request.tag,
3343
- fetchDir,
3344
- this.providerOptions
3345
- );
3346
- await this.storeInstance.put(key, fetchDir, request.hash);
3347
- } finally {
3348
- await fsp.rm(workDir, { recursive: true, force: true });
3349
- }
3350
- }
3351
-
3352
- /**
3353
- * The manifest loader the resolver walks the dependency closure with. It
3354
- * fetches the recipe when the store does not hold it, which is the seam where
3355
- * "resolve" turns into "download"; a dry run refuses to fetch and reads only
3356
- * what is already there.
3357
- *
3358
- * @param recipe - The resolved recipe whose manifest is wanted.
3359
- * @param dryRun - When true, read what is on disk and download nothing.
3360
- */
3361
- private async loadRecipeManifest(
3362
- recipe: ResolvedRecipe,
3363
- dryRun: boolean
3364
- ): Promise<RecipeManifest | undefined> {
3365
- const directory = this.recipeDirectory(recipe);
3366
- const existing = readRecipeManifestIn(directory);
3367
- if (existing !== undefined) return existing;
3368
- if (dryRun) return undefined;
3369
-
3370
- await this.ensureStored(recipe);
3371
- return readRecipeManifestIn(this.recipeDirectory(recipe));
3372
- }
3373
-
3374
- // --- The managed subscriptions layer -----------------------------------------------------------
3375
-
3376
- /** Every subscription written in the managed layer, keyed by ref key. */
3377
- private readSubscriptionEntries(): Record<string, SubscriptionEntry> {
3378
- const layer = readManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
3379
- confDir: this.confDir,
3380
- });
3381
- const entries = layer["subscriptions"];
3382
- if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return {};
3383
- return entries as Record<string, SubscriptionEntry>;
3384
- }
3385
-
3386
- /**
3387
- * Records one subscription in the managed layer, editing only that entry.
3388
- *
3389
- * @param key - The ref key the subscription is recorded under.
3390
- * @param parsed - The ref as parsed, for its version range.
3391
- * @param options - The prerelease and always-pull flags.
3392
- */
3393
- private writeSubscriptionEntry(
3394
- key: string,
3395
- parsed: NamedRef,
3396
- options: SubscribeOptions
3397
- ): void {
3398
- const range = rangeOf(parsed);
3399
- const entry: SubscriptionEntry = {
3400
- ...(range === undefined ? {} : { range }),
3401
- ...(options.prerelease === true ? { prerelease: true } : {}),
3402
- ...(options.alwaysPull === true ? { alwaysPull: true } : {}),
3403
- addedAt: this.now().toISOString(),
3404
- addedBy: USER_ADDED_BY,
3405
- };
3406
-
3407
- updateManagedLayer(
3408
- this.sousDir,
3409
- SUBSCRIPTIONS_LAYER_FILENAME,
3410
- [{ path: ["subscriptions", key], value: entry }],
3411
- { confDir: this.confDir }
3412
- );
3413
- }
3414
-
3415
- // --- Lockfile bookkeeping ----------------------------------------------------------------------
3416
-
3417
- /**
3418
- * The lockfile keys one subscription holds directly. The rule itself lives in
3419
- * `keysHeldBySubscription` (`locked-recipes.ts`), beside the other readers of
3420
- * the lockfile's holder lists.
3421
- *
3422
- * @param lock - The lockfile as it stands.
3423
- * @param key - The subscription's ref key.
3424
- */
3425
- private keysHeldBySubscription(lock: Lockfile, key: string): string[] {
3426
- return keysHeldBySubscription(lock, key);
3427
- }
3428
-
3429
- /**
3430
- * Drops the project's own hold on one recipe. When something else still holds
3431
- * it the entry stays; when nothing does, the entry goes and everything it
3432
- * pulled in is reconsidered, which is what makes removal refcounted.
3433
- *
3434
- * @param lock - The lockfile as it stands.
3435
- * @param key - The recipe key to release.
3436
- */
3437
- private dropProjectHold(lock: Lockfile, key: string): Lockfile {
3438
- const entry = lock.recipes[key];
3439
- if (entry === undefined) return lock;
3440
-
3441
- const kept = entry.requestedBy.filter((holder) => holder !== PROJECT_HOLDER);
3442
- const recipes = { ...lock.recipes };
3443
-
3444
- if (kept.length > 0) {
3445
- recipes[key] = { ...entry, requestedBy: kept };
3446
- return { ...lock, recipes };
3447
- }
3448
-
3449
- delete recipes[key];
3450
- return this.lock.removeHolder({ ...lock, recipes }, key);
3451
- }
3452
-
3453
- /**
3454
- * True when a repository prefers a newer in-range version over the locked one,
3455
- * whether the flag is on the repository itself or on any subscription that
3456
- * resolves inside it.
3457
- *
3458
- * @param repoName - The repository's short name.
3459
- * @param repo - Its config entry, when it still has one.
3460
- * @param lock - The lockfile, for which recipes came from it.
3461
- * @param subscriptions - Every subscription, from the config and the managed layer.
3462
- */
3463
- private prefersNewer(
3464
- repoName: string,
3465
- repo: TrustedRepo | undefined,
3466
- lock: Lockfile,
3467
- subscriptions: Record<string, SubscriptionEntry>
3468
- ): boolean {
3469
- if (repo?.alwaysPull === true) return true;
3470
-
3471
- for (const [key, entry] of Object.entries(subscriptions)) {
3472
- if (entry.alwaysPull !== true) continue;
3473
- const keys = this.keysHeldBySubscription(lock, key);
3474
- if (keys.some((held) => lock.recipes[held]!.repo === repoName)) return true;
3475
- }
3476
-
3477
- return false;
3478
- }
3479
-
3480
- /**
3481
- * The version range an always-pull check may move one locked recipe within,
3482
- * or undefined when sous cannot tell and therefore must not move it.
3483
- *
3484
- * The rule itself lives in `effectiveRangeForHolders`; this supplies it with
3485
- * the two lookups it needs, one reading the project's subscriptions and one
3486
- * reading the manifest of each holding recipe.
3487
- *
3488
- * @param key - The locked recipe key, `namespace/recipe`.
3489
- * @param entry - Its lockfile entry, for its holders.
3490
- * @param subscriptions - Every subscription, from the config and the managed layer.
3491
- */
3492
- private effectiveRangeFor(
3493
- key: string,
3494
- entry: LockedRecipe,
3495
- subscriptions: Record<string, SubscriptionEntry>
3496
- ): string | undefined {
3497
- const namespace = namespaceOfKey(key);
3498
- return effectiveRangeForHolders(key, entry.requestedBy, {
3499
- subscriptionRange: (held) => {
3500
- // A subscription the config no longer declares is not a hold sous can
3501
- // read a range from; leave the entry alone rather than guessing.
3502
- const subscription = subscriptions[held] ?? subscriptions[namespace];
3503
- return subscription === undefined ? undefined : (subscription.range ?? "*");
3504
- },
3505
- dependencyRange: (holder, held) =>
3506
- this.declaredDependencyRange(holder, held, namespace),
3507
- });
3508
- }
3509
-
3510
- /**
3511
- * The range one recipe's manifest declares for a dependency, or undefined when
3512
- * its manifest cannot be read or no longer names that dependency.
3513
- *
3514
- * @param holder - The holding recipe's key, `namespace/recipe`.
3515
- * @param key - The held recipe's key.
3516
- * @param namespace - The held recipe's namespace, since a `depends` entry may
3517
- * name a whole namespace rather than one recipe.
3518
- */
3519
- private declaredDependencyRange(
3520
- holder: string,
3521
- key: string,
3522
- namespace: string
3523
- ): string | undefined {
3524
- const directory = this.lockedRecipeDirectories()[holder];
3525
- if (directory === undefined) return undefined;
3526
-
3527
- let manifest;
3528
- try {
3529
- manifest = readRecipeManifestIn(directory);
3530
- } catch {
3531
- return undefined;
3532
- }
3533
- if (manifest === undefined) return undefined;
3534
-
3535
- for (const dependency of manifest.depends ?? []) {
3536
- // Every reading counts: a dependency that reads more than one way still
3537
- // declares its range for whichever reading the lockfile settled on.
3538
- let readings: SousRef[];
3539
- try {
3540
- readings = sharedRefResolver().parse(dependency, RefSource.Manifest).refs;
3541
- } catch {
3542
- continue;
3543
- }
3544
- for (const reading of readings) {
3545
- if (!isNamedRef(reading)) continue;
3546
- const dependencyKey = shortKey(reading);
3547
- if (dependencyKey !== key && dependencyKey !== namespace) continue;
3548
- return rangeOf(reading) ?? "*";
3549
- }
3550
- }
3551
-
3552
- return undefined;
3553
- }
3554
-
3555
- /**
3556
- * Where each locked recipe's files are, keyed by recipe key. Read once per
3557
- * command, because an always-pull check asks for the same answer for every
3558
- * entry in the lockfile.
3559
- */
3560
- private lockedRecipeDirectories(): Record<string, string> {
3561
- if (this.lockedDirectories === undefined) {
3562
- this.lockedDirectories = {};
3563
- for (const located of listLockedRecipes({ sousDir: this.sousDir, env: this.env })) {
3564
- if (located.present) this.lockedDirectories[located.key] = located.dir;
3565
- }
3566
- }
3567
- return this.lockedDirectories;
3568
- }
3569
-
3570
- // --- Variables ----------------------------------------------------------------------------------
3571
-
3572
- /**
3573
- * Every variable definition the resolved closure publishes, attributed to the
3574
- * recipe that declared it and to the chain that pulled it in.
3575
- *
3576
- * A recipe whose files are not on this machine is described from the
3577
- * definitions its index entry records, and contributes nothing rather than
3578
- * failing when the entry records none, which is what lets a dry run describe
3579
- * as much of the closure as it can without downloading any of it.
3580
- *
3581
- * @param resolved - The recipe versions the resolution settled on.
3582
- */
3583
- private definedVariables(resolved: ResolvedRecipe[]): DefinedVariable[] {
3584
- const defined: DefinedVariable[] = [];
3585
- const byKey = new Map(resolved.map((recipe) => [recipe.key, recipe]));
3586
- const repos = this.currentRepos();
3587
-
3588
- /** One resolved recipe, described the way the variables layer shows it. */
3589
- const describe = (recipe: ResolvedRecipe): DefiningRecipe => {
3590
- const url = repos[recipe.repo]?.url;
3591
- return {
3592
- repo: recipe.repo,
3593
- namespace: recipe.namespace,
3594
- name: recipe.name,
3595
- version: recipe.version,
3596
- path: recipe.path,
3597
- dir: this.recipeDirectory(recipe),
3598
- ...(url === undefined ? {} : { url }),
3599
- };
3600
- };
3601
-
3602
- /** How a recipe came to be here: the subscribed recipe first, then each holder. */
3603
- const chainFor = (recipe: ResolvedRecipe): ResolvedRecipe[] => {
3604
- const chain = [recipe];
3605
- const seen = new Set([recipe.key]);
3606
- let current = recipe;
3607
-
3608
- while (!current.requestedBy.includes(PROJECT_HOLDER)) {
3609
- const holderKey = current.requestedBy.find(
3610
- (holder) => holder !== PROJECT_HOLDER && byKey.has(holder) && !seen.has(holder)
3611
- );
3612
- if (holderKey === undefined) break;
3613
- current = byKey.get(holderKey)!;
3614
- seen.add(holderKey);
3615
- chain.unshift(current);
3616
- }
3617
-
3618
- return chain;
3619
- };
3620
-
3621
- // The subscribed recipe's own questions come first, then each dependency in
3622
- // the order the closure reached it, so the run reads the way it happened.
3623
- const ordered = [...resolved].sort((left, right) => {
3624
- const depth = chainFor(left).length - chainFor(right).length;
3625
- return depth !== 0 ? depth : left.key.localeCompare(right.key);
3626
- });
3627
-
3628
- for (const recipe of ordered) {
3629
- const variables = this.publishedVariables(recipe);
3630
- if (variables === undefined) continue;
3631
-
3632
- const publisher = describe(recipe);
3633
- const requiredBy = chainFor(recipe).map(describe);
3634
- for (const definition of variables) {
3635
- defined.push({ definition, recipe: publisher, requiredBy });
3636
- }
3637
- }
3638
-
3639
- return defined;
3640
- }
3641
-
3642
- /**
3643
- * The recipes in a resolution whose manifests cannot be read from this
3644
- * machine, because neither the store nor a linked checkout holds their files
3645
- * yet. A dry run downloads nothing, so this is exactly the set whose
3646
- * questions it cannot describe; everything else is described in full.
3647
- *
3648
- * @param resolved - The recipe versions the resolution settled on.
3649
- */
3650
- private unreadableRecipes(resolved: ResolvedRecipe[]): string[] {
3651
- return resolved
3652
- .filter((recipe) => this.publishedVariables(recipe) === undefined)
3653
- .map((recipe) => recipe.key)
3654
- .sort();
3655
- }
3656
-
3657
- /**
3658
- * The variable definitions one resolved recipe publishes: from its manifest
3659
- * when its files are on this machine (a linked checkout's working copy
3660
- * included), otherwise from its index entry, or undefined when neither can
3661
- * say.
3662
- *
3663
- * @param recipe - The resolved recipe.
3664
- */
3665
- private publishedVariables(recipe: ResolvedRecipe): VariableDefinition[] | undefined {
3666
- const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
3667
- if (manifest !== undefined) return manifest.variables ?? [];
3668
- return recipe.variables;
3669
- }
3670
-
3671
- /**
3672
- * Works out what subscribing to a ref would install and which questions it
3673
- * would ask, from the indexes and the files already on this machine. Nothing
3674
- * is fetched, trusted or written, which is what lets `sous recipe show`
3675
- * describe a recipe before anyone subscribes to it. A repository the closure
3676
- * needs and the project does not trust is reported, and its recipes are not
3677
- * resolved.
3678
- *
3679
- * @param parsed - The fully qualified ref, with the exact version to describe as its range.
3680
- * @param options - The indexes to read, and whether prereleases may match.
3681
- */
3682
- async previewSubscription(
3683
- parsed: NamedRef,
3684
- options: PreviewSubscriptionOptions = {}
3685
- ): Promise<SubscriptionPreview> {
3686
- const repos = this.resolverRepos();
3687
- const indexes = options.indexes ?? (await this.loadIndexes(Object.keys(repos)));
3688
- const result = await resolveRefs(
3689
- [
3690
- {
3691
- ref: parsed,
3692
- requestedBy: PROJECT_REQUESTER,
3693
- kind: "subscribes",
3694
- ...(options.prerelease === true ? { prerelease: true } : {}),
3695
- },
3696
- ],
3697
- {
3698
- indexes,
3699
- repos,
3700
- loadManifest: (recipe) => this.loadRecipeManifest(recipe, true),
3701
- ...(options.prerelease === true ? { prerelease: true } : {}),
3702
- }
3703
- );
3704
-
3705
- return {
3706
- resolved: result.resolved,
3707
- missingRepos: result.missingRepos,
3708
- questions: planQuestions(this.definedVariables(result.resolved), this.ladderContext(), {
3709
- sousDir: this.sousDir,
3710
- }),
3711
- unreadable: this.unreadableRecipes(result.resolved),
3712
- cycles: result.cycles,
3713
- };
3714
- }
3715
-
3716
- /** The environment layers and mapping records this project resolves against. */
3717
- private ladderContext(): LadderContext {
3718
- return loadLadderContext({
3719
- sousDir: this.sousDir,
3720
- settings: this.settings,
3721
- shellEnv: this.shellEnv,
3722
- });
3723
- }
3724
-
3725
- /**
3726
- * Asks for the variables the newly resolved recipes publish, keeping and
3727
- * reporting whatever answers were already in scope. Answers supplied ahead of
3728
- * the questions are validated and stored first, so only what is left over is
3729
- * asked for. A run with no terminal fails naming the exact environment
3730
- * variables that would answer each remaining question, which is what the
3731
- * variables layer does everywhere.
3732
- *
3733
- * @param resolved - The recipe versions the resolution settled on.
3734
- * @param provided - Answers supplied ahead of the questions.
3735
- */
3736
- private async askVariables(
3737
- resolved: ResolvedRecipe[],
3738
- provided: ProvidedAnswer[]
3739
- ): Promise<AskReport | undefined> {
3740
- const defined = this.definedVariables(resolved);
3741
- if (defined.length === 0 && provided.length === 0) return undefined;
3742
-
3743
- const context = this.ladderContext();
3744
- const options = {
3745
- sousDir: this.sousDir,
3746
- confDir: this.confDir,
3747
- interactive: this.interactive,
3748
- };
3749
-
3750
- // A supplied answer naming a variable nothing declares fails here, before
3751
- // any question is asked and before anything is stored.
3752
- const supplied = applyProvidedAnswers(defined, provided, context, options);
3753
-
3754
- const report = await askForMissing(defined, context, {
3755
- ...options,
3756
- skip: supplied.keys,
3757
- });
3758
- report.answered.unshift(...supplied.stored);
3759
- return report;
3760
- }
3761
- }
3762
-
3763
- // --- Helpers ------------------------------------------------------------------------------------
3764
-
3765
- /**
3766
- * Builds the subscription service for a running command, from what every
3767
- * command already has: where its config was discovered, the merged settings,
3768
- * and the shell environment as it was before the `.sous/` env files were loaded.
3769
- *
3770
- * @param options - The discovered config context, the settings, and the shell environment.
3771
- */
3772
- export function subscriptionServiceFor(options: {
3773
- /** Where the active config was found. */
3774
- configContext: ConfigContext;
3775
- /** The merged project config. */
3776
- settings: Settings;
3777
- /** The shell environment as it was before the env files were injected. */
3778
- shellEnv?: NodeJS.ProcessEnv;
3779
- /** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
3780
- interactive?: boolean;
3781
- }): SubscriptionService {
3782
- return new SubscriptionService({
3783
- sousDir: options.configContext.sousDir,
3784
- ...(options.configContext.confDir === undefined
3785
- ? {}
3786
- : { confDir: options.configContext.confDir }),
3787
- settings: options.settings,
3788
- ...(options.shellEnv === undefined ? {} : { shellEnv: options.shellEnv }),
3789
- ...(options.interactive === undefined ? {} : { interactive: options.interactive }),
3790
- });
3791
- }
3792
-
3793
- /** One subscription entry, as it is written into the managed layer. */
3794
- export type SubscriptionEntry = {
3795
- /**
3796
- * Whether the subscription takes part in anything. Defaults to true. Sous
3797
- * writes `false` when a subscription it provides itself is removed, because
3798
- * the default comes back on every run and only a recorded opt-out outlives it.
3799
- */
3800
- enabled?: boolean;
3801
- /** The semantic version range to resolve within. */
3802
- range?: string;
3803
- /** Whether prerelease versions take part in range matching. */
3804
- prerelease?: boolean;
3805
- /** Whether a newer in-range version is preferred over the locked one. */
3806
- alwaysPull?: boolean;
3807
- /** When the subscription was added. */
3808
- addedAt?: string;
3809
- /** Who required it: "user", or the ref of the recipe that co-subscribed it. */
3810
- addedBy?: string;
3811
- };
3812
-
3813
- /**
3814
- * Combines two resolutions of the SAME recipe version into one, so the lockfile
3815
- * records every holder rather than only the last resolution's.
3816
- *
3817
- * This matters for removal: a recipe several subscriptions depend on has to
3818
- * survive unsubscribing from one of them, and the lockfile's refcounting is what
3819
- * decides that. A recipe anyone holds as a co-subscription is a co-subscription;
3820
- * it is only a build dependency while nothing subscribes to it.
3821
- *
3822
- * @param left - The resolution already recorded.
3823
- * @param right - The resolution to fold into it.
3824
- */
3825
- function mergeHolders(left: ResolvedRecipe, right: ResolvedRecipe): ResolvedRecipe {
3826
- const requestedBy = [...new Set([...left.requestedBy, ...right.requestedBy])].sort();
3827
-
3828
- const ranges = [...left.ranges];
3829
- for (const entry of right.ranges) {
3830
- const known = ranges.some(
3831
- (seen) => seen.range === entry.range && seen.requestedBy === entry.requestedBy
3832
- );
3833
- if (!known) ranges.push(entry);
3834
- }
3835
-
3836
- return {
3837
- ...left,
3838
- requestedBy,
3839
- ranges,
3840
- kind:
3841
- left.kind === "subscribes" || right.kind === "subscribes" ? "subscribes" : "depends",
3842
- };
3843
- }
3844
-
3845
- /** The store key a resolved recipe is filed under. */
3846
- function storeKeyFor(recipe: ResolvedRecipe): StoreKey {
3847
- return {
3848
- identity: recipe.identity,
3849
- namespace: recipe.namespace,
3850
- name: recipe.name,
3851
- version: recipe.version,
3852
- };
3853
- }
3854
-
3855
- /** The message of an error, whichever kind it turned out to be. */
3856
- function describeError(error: unknown): string {
3857
- if (isConfigError(error)) return (error as ConfigError).message;
3858
- return error instanceof Error ? error.message : String(error);
3859
- }