@sous-io/sous 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,2678 @@
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 } from "./formats/recipe-manifest.js";
60
+ import { formatRef, parseRef, refKey, type ParsedRef } from "./ref.js";
61
+ import {
62
+ SousScope,
63
+ describeReference,
64
+ findReference,
65
+ pickReference,
66
+ referenceReposFromIndexes,
67
+ referenceToRef,
68
+ type ReferenceMatch,
69
+ } from "../refs/index.js";
70
+ import { describeIndexSearch } from "./ref-search.js";
71
+ import {
72
+ PROJECT_REQUESTER,
73
+ resolveRefs,
74
+ type RefRequest,
75
+ type ResolvedRecipe,
76
+ type ResolverRepo,
77
+ } from "./resolver.js";
78
+ import {
79
+ LockService,
80
+ type LockDiff,
81
+ type LockRepoInput,
82
+ type RestoreReport,
83
+ } from "./lock-service.js";
84
+ import { TrustService, USER_ADDED_BY, type TrustedRepo } from "./trust.js";
85
+ import {
86
+ SUBSCRIPTIONS_LAYER_FILENAME,
87
+ readManagedLayer,
88
+ removeManagedLayer,
89
+ updateManagedLayer,
90
+ } from "./managed-layer.js";
91
+ import {
92
+ builtInProviders,
93
+ createIndexCache,
94
+ requireProvider,
95
+ type IndexCache,
96
+ type ProviderOptions,
97
+ type ProviderId,
98
+ type RepoProvider,
99
+ } from "./providers/index.js";
100
+ import { normalizeRepoUrl } from "./providers/provider.js";
101
+ import {
102
+ assertLocalRepoDirectory,
103
+ looksLikeLocalPath,
104
+ resolveRepoArgument,
105
+ } from "./providers/local.js";
106
+ import { RecipeStore } from "./store/recipe-store.js";
107
+ import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
108
+ import { resolveStoreSettings } from "./store/settings.js";
109
+ import {
110
+ effectiveRangeForHolders,
111
+ findNewerInRange,
112
+ recordUpstreamCheck,
113
+ shouldCheckUpstream,
114
+ } from "./freshness.js";
115
+ import { REPO_NAME_PATTERN } from "./formats/patterns.js";
116
+ import {
117
+ linkedPathFor,
118
+ readGlobalLinks,
119
+ readProjectLinks,
120
+ writeProjectLinks,
121
+ } from "./links.js";
122
+ import {
123
+ keysHeldBySubscription,
124
+ listLockedRecipes,
125
+ mapLinkedRecipes,
126
+ readRecipeManifestIn,
127
+ } from "./locked-recipes.js";
128
+ import { resolveStoreRoot } from "../sous-home.js";
129
+ import { seedCoreRecipe, type SeedCoreRecipeReport } from "./seed.js";
130
+ import { enabledRepos, enabledSubscriptions, isBuiltInEntry } from "./defaults.js";
131
+ import {
132
+ CORE_RECIPE_KEY,
133
+ OFFICIAL_REPO_IDENTITY,
134
+ packagedCoreRecipeDir,
135
+ } from "./core-recipe.js";
136
+ import { repoIdentity } from "./identity.js";
137
+ import { buildRecipeTargets } from "./recipe-targets.js";
138
+ import { resolveOutputPath } from "../markdown-compiler.js";
139
+ import { hashDirectory } from "./store/hash.js";
140
+
141
+ // --- Options and reports ------------------------------------------------------------------------
142
+
143
+ /** How the subscription service is built. Every collaborator is injectable. */
144
+ export type SubscriptionServiceOptions = {
145
+ /** The project's `.sous/` directory: lockfile, links map and env files. */
146
+ sousDir: string;
147
+ /** The project's `conf.d/` directory. Defaults to `<sousDir>/conf.d`. */
148
+ confDir?: string;
149
+ /** The merged project config. */
150
+ settings: Settings;
151
+ /** The environment to read; decides where the store is. Defaults to `process.env`. */
152
+ env?: NodeJS.ProcessEnv;
153
+ /**
154
+ * The real shell environment, snapshotted before the `.sous/` env files were
155
+ * injected. Used only to tell a shell-supplied answer from a file-supplied one.
156
+ */
157
+ shellEnv?: NodeJS.ProcessEnv;
158
+ /** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
159
+ interactive?: boolean;
160
+ /** The recipe store. Defaults to the machine-wide one. */
161
+ store?: RecipeStoreLike;
162
+ /** The index cache. Defaults to one rooted at the store. */
163
+ indexCache?: IndexCache;
164
+ /** The trust layer. Defaults to one bound to this project. */
165
+ trust?: TrustService;
166
+ /** The lockfile service. Defaults to one bound to this project. */
167
+ lock?: LockService;
168
+ /** The providers to choose from. Defaults to the built-ins. */
169
+ providers?: RepoProvider[];
170
+ /** Options handed to every provider call. */
171
+ providerOptions?: ProviderOptions;
172
+ /** Where warnings go. Defaults to the console warning banner. */
173
+ warn?: (message: string) => void;
174
+ /** Where the plan and the resolution notices go. Defaults to the console. */
175
+ write?: (message: string) => void;
176
+ /** How a yes or no question is asked. Injected in tests. */
177
+ ask?: (message: string) => Promise<boolean>;
178
+ /** How a choice between candidate refs is asked. Injected in tests. */
179
+ choose?: (
180
+ message: string,
181
+ candidates: ReferenceMatch[]
182
+ ) => Promise<ReferenceMatch>;
183
+ /** The clock, so a recorded timestamp is predictable in tests. */
184
+ now?: () => Date;
185
+ };
186
+
187
+ /** What `addRepo` is asked to do. */
188
+ export type AddRepoOptions = {
189
+ /** The repository's URL, or an absolute path for one on this machine. */
190
+ url: string;
191
+ /** The short name refs will use. Defaults to the last segment of the URL. */
192
+ name?: string;
193
+ /** The provider that handles it, when the URL does not give it away. */
194
+ provider?: ProviderId;
195
+ /** Acknowledge trust without being asked, for a run with no terminal. */
196
+ trust?: boolean;
197
+ /** Work out what would happen and report it, writing and fetching nothing. */
198
+ dryRun?: boolean;
199
+ };
200
+
201
+ /** What `addRepo` did. */
202
+ export type AddRepoOutcome = {
203
+ /** The short name the repository was recorded under. */
204
+ name: string;
205
+ /** Where it lives, as recorded. */
206
+ url: string;
207
+ /** The provider that handles it. */
208
+ provider: ProviderId;
209
+ /** True when the project already trusted this exact repository. */
210
+ alreadyTrusted: boolean;
211
+ /** Every namespace its index declares, sorted. */
212
+ namespaces: string[];
213
+ /** How many recipes its index publishes. */
214
+ recipeCount: number;
215
+ /** True when nothing was written, because this was a dry run. */
216
+ dryRun: boolean;
217
+ };
218
+
219
+ /** What `subscribe` is asked to do. */
220
+ export type SubscribeOptions = {
221
+ /** The ref to subscribe to: a namespace, or `namespace/recipe`, with an optional range. */
222
+ ref: string;
223
+ /** Let prerelease versions take part in range matching. */
224
+ prerelease?: boolean;
225
+ /** Prefer a newer in-range version over the locked one on every build. */
226
+ alwaysPull?: boolean;
227
+ /** Acknowledge trust for every repository this command adds, without being asked. */
228
+ trust?: boolean;
229
+ /** Accept the subscribe confirmation without being asked. */
230
+ yes?: boolean;
231
+ /** Take the first candidate when a one-word ref matched several things. */
232
+ acceptFirst?: boolean;
233
+ /**
234
+ * Answers supplied ahead of the questions, which are validated and stored
235
+ * before anything is asked. See `lib/vars/preanswers.ts`.
236
+ */
237
+ answers?: ProvidedAnswer[];
238
+ /** Work out what would happen and report it, writing and fetching nothing. */
239
+ dryRun?: boolean;
240
+ };
241
+
242
+ /** What `subscribe` did. */
243
+ export type SubscribeOutcome = {
244
+ /** The ref that was installed, fully qualified, in its canonical written form. */
245
+ ref: string;
246
+ /** The ref as it was written, when a one-word ref had to be resolved first. */
247
+ resolvedFrom?: string;
248
+ /** The key the subscription was recorded under. */
249
+ key: string;
250
+ /** Every recipe version the resolution settled on. */
251
+ resolved: ResolvedRecipe[];
252
+ /** Repositories that had to be trusted along the way. */
253
+ trusted: string[];
254
+ /** What changed in the lockfile. */
255
+ diff: LockDiff;
256
+ /** What the variable questions produced, when they were asked. */
257
+ answers?: AskReport;
258
+ /**
259
+ * Every question these recipes would ask, and where each answer would go.
260
+ * Reported by a dry run, which is how a caller with no terminal finds out
261
+ * what to supply with `--answer`.
262
+ */
263
+ questions?: PlannedVariable[];
264
+ /**
265
+ * Recipes a dry run could not describe, because their files are not on this
266
+ * machine and a dry run downloads nothing. Their questions are unknown until
267
+ * they are installed.
268
+ */
269
+ unreadable?: string[];
270
+ /** Dependency cycles the resolver noticed, reported rather than treated as fatal. */
271
+ cycles: string[][];
272
+ /** True when nothing was written, because this was a dry run. */
273
+ dryRun: boolean;
274
+ };
275
+
276
+ /** What `unsubscribe` is asked to do. */
277
+ export type UnsubscribeOptions = {
278
+ /** The ref to unsubscribe from. */
279
+ ref: string;
280
+ /** Work out what would happen and report it, writing nothing. */
281
+ dryRun?: boolean;
282
+ };
283
+
284
+ /** What `unsubscribe` did. */
285
+ export type UnsubscribeOutcome = {
286
+ /** The key the subscription was recorded under. */
287
+ key: string;
288
+ /** What changed in the lockfile. */
289
+ diff: LockDiff;
290
+ /** Recipes that stayed because something else still holds them, with who holds them. */
291
+ stayed: Array<{ key: string; heldBy: string[] }>;
292
+ /**
293
+ * True when the subscription was one sous provides itself, so it was switched
294
+ * off with an `enabled: false` entry rather than deleted. The entry sous
295
+ * provides comes back on every run; only a recorded opt-out outlives it.
296
+ */
297
+ optedOut: boolean;
298
+ /** True when nothing was written, because this was a dry run. */
299
+ dryRun: boolean;
300
+ };
301
+
302
+ /** What `removeRepo` is asked to do. */
303
+ export type RemoveRepoOptions = {
304
+ /** The repository's short name, as the project records it. */
305
+ name: string;
306
+ /** Accept the removal without being asked. */
307
+ yes?: boolean;
308
+ /** Work out what would happen and report it, writing nothing. */
309
+ dryRun?: boolean;
310
+ /**
311
+ * The resolved settings scope, used to work out which output files the
312
+ * removed recipes wrote. Without it a `recipeOutputs` destination holding a
313
+ * `${var}` cannot be resolved, and the file list is left out of the report.
314
+ */
315
+ scope?: VarScope;
316
+ };
317
+
318
+ /** What `removeRepo` did, or would do on a dry run. */
319
+ export type RemoveRepoOutcome = {
320
+ /** The repository's short name. */
321
+ name: string;
322
+ /** Where it lives, as the entry recorded it. */
323
+ url: string;
324
+ /**
325
+ * True when the repository is one sous provides itself, so it was switched
326
+ * off with an `enabled: false` entry rather than deleted. The entry sous
327
+ * provides comes back on every run; only a recorded opt-out outlives it.
328
+ */
329
+ optedOut: boolean;
330
+ /** The subscriptions that were removed because they resolve into it. */
331
+ subscriptions: string[];
332
+ /**
333
+ * Subscriptions that resolve into it but are written in the project's own
334
+ * config, which sous never edits. They stay, and are named so the person
335
+ * removing the repository knows to deal with them.
336
+ */
337
+ keptSubscriptions: string[];
338
+ /** The locked recipes that were released, because nothing else held them. */
339
+ removedRecipes: string[];
340
+ /** Recipes that stayed because something else still holds them, with who holds them. */
341
+ stayed: Array<{ key: string; heldBy: string[] }>;
342
+ /** The output files the removed recipes compiled, which the next build prunes. */
343
+ outputs: string[];
344
+ /** What changed in the lockfile. */
345
+ diff: LockDiff;
346
+ /** The linked checkout that pointed at the repository, when there was one. */
347
+ linkedPath?: string;
348
+ /**
349
+ * True when that link is the machine-wide one, which other projects on this
350
+ * machine share, so it was left exactly as it was.
351
+ */
352
+ linkIsGlobal: boolean;
353
+ /** True when nothing was written, because this was a dry run. */
354
+ dryRun: boolean;
355
+ };
356
+
357
+ /** One row of the subscription listing: what a project subscribes to, and why. */
358
+ export type SubscriptionListing = {
359
+ /** The ref key: a bare namespace, or `namespace/recipe`. */
360
+ key: string;
361
+ /** The version range the subscription resolves within, when one was written. */
362
+ range: string | undefined;
363
+ /** False when the entry is switched off with `enabled: false`. */
364
+ enabled: boolean;
365
+ /** What the entry recorded about who wanted it, when it recorded anything. */
366
+ addedBy: string | undefined;
367
+ /** Every recipe the lockfile pins because of this subscription, sorted by key. */
368
+ pinned: Array<{ key: string; version: string }>;
369
+ };
370
+
371
+ /** What bringing the lockfile in line with the declared subscriptions produced. */
372
+ export type SubscriptionSyncReport = {
373
+ /** The subscriptions that had to be resolved, by ref key. */
374
+ resolved: string[];
375
+ /** Recipes the lockfile did not pin before and pins now. */
376
+ added: Array<{ key: string; version: string }>;
377
+ /** Recipes whose pinned version moved to satisfy a subscription's range. */
378
+ moved: Array<{ key: string; from: string; to: string }>;
379
+ /** Subscriptions that could not be resolved, each with a plain-language reason. */
380
+ failed: Array<{ key: string; reason: string }>;
381
+ };
382
+
383
+ /** What an upstream check found. */
384
+ export type UpstreamCheckReport = {
385
+ /** Repositories that were actually asked. */
386
+ checked: string[];
387
+ /** Recipes moved to a newer in-range version. */
388
+ updated: Array<{ key: string; from: string; to: string }>;
389
+ /** Repositories whose check failed; the last good answer still stands. */
390
+ failed: Array<{ repo: string; reason: string }>;
391
+ };
392
+
393
+ // --- The service --------------------------------------------------------------------------------
394
+
395
+ /** Adds repositories, subscribes to recipes, and keeps the store and lockfile honest. */
396
+ export class SubscriptionService {
397
+ private readonly sousDir: string;
398
+
399
+ private readonly confDir: string;
400
+
401
+ private readonly settings: Settings;
402
+
403
+ private readonly env: NodeJS.ProcessEnv;
404
+
405
+ private readonly shellEnv: NodeJS.ProcessEnv;
406
+
407
+ private readonly interactive: boolean;
408
+
409
+ private readonly providers: RepoProvider[];
410
+
411
+ private readonly providerOptions: ProviderOptions;
412
+
413
+ private readonly warn: (message: string) => void;
414
+
415
+ private readonly write: (message: string) => void;
416
+
417
+ private readonly ask: (message: string) => Promise<boolean>;
418
+
419
+ private readonly choose: (
420
+ message: string,
421
+ candidates: ReferenceMatch[]
422
+ ) => Promise<ReferenceMatch>;
423
+
424
+ private readonly now: () => Date;
425
+
426
+ private readonly storeInstance: RecipeStoreLike;
427
+
428
+ private readonly indexCache: IndexCache;
429
+
430
+ private readonly trust: TrustService;
431
+
432
+ private readonly lock: LockService;
433
+
434
+ /**
435
+ * Canonical identities already worked out, keyed by the provider and URL they
436
+ * came from. Canonicalizing is pure, so it is worth doing once per run.
437
+ */
438
+ private readonly identityCache = new Map<string, string>();
439
+
440
+ /**
441
+ * What seeding the packaged core recipe did, once it has been done. Seeding is
442
+ * idempotent but not free (it verifies the store entry against its content
443
+ * hash), so one service instance does it at most once.
444
+ */
445
+ private seedReport: SeedCoreRecipeReport | undefined;
446
+
447
+ /**
448
+ * Where each locked recipe's files are, keyed by recipe key, once it has been
449
+ * looked up. Deriving the range a `depends`-held recipe may move within asks
450
+ * for this once per lockfile entry, and the answer does not change during a
451
+ * command.
452
+ */
453
+ private lockedDirectories: Record<string, string> | undefined;
454
+
455
+ /**
456
+ * Every trusted repository's index, once it has been loaded. Working out what
457
+ * a one-word ref meant and describing what a subscription will do both read
458
+ * it, within one command, and an index does not change mid-command.
459
+ */
460
+ private indexesSnapshot: Map<string, IndexFile> | undefined;
461
+
462
+ /**
463
+ * @param options - The project's directories, its config, and any collaborator to override.
464
+ */
465
+ constructor(options: SubscriptionServiceOptions) {
466
+ this.sousDir = options.sousDir;
467
+ this.confDir = options.confDir ?? path.join(options.sousDir, CONFD_DIR_NAME);
468
+ this.settings = options.settings;
469
+ this.env = options.env ?? process.env;
470
+ this.shellEnv = options.shellEnv ?? this.env;
471
+ this.interactive = options.interactive ?? isInteractive();
472
+ this.providers = options.providers ?? builtInProviders();
473
+ this.providerOptions = options.providerOptions ?? {};
474
+ this.warn = options.warn ?? warning;
475
+ this.write = options.write ?? ((message: string) => log(message));
476
+ this.ask = options.ask ?? ((message: string) => askYesNo(message));
477
+ this.choose =
478
+ options.choose ??
479
+ ((message, candidates) =>
480
+ askChoice(
481
+ message,
482
+ candidates.map((candidate) => ({
483
+ name: describeReference(candidate),
484
+ value: candidate,
485
+ }))
486
+ ));
487
+ this.now = options.now ?? (() => new Date());
488
+
489
+ this.storeInstance =
490
+ options.store ??
491
+ new RecipeStore({ root: resolveStoreRoot(this.env), onWarning: this.warn });
492
+ this.indexCache =
493
+ options.indexCache ??
494
+ createIndexCache({
495
+ storeRoot: this.storeInstance.root,
496
+ resolveProvider: (url, providerId) =>
497
+ requireProvider(url, providerId, this.providers),
498
+ providerOptions: this.providerOptions,
499
+ warn: this.warn,
500
+ now: this.now,
501
+ });
502
+ this.trust =
503
+ options.trust ??
504
+ new TrustService({
505
+ sousDir: this.sousDir,
506
+ confDir: this.confDir,
507
+ settings: this.settings,
508
+ interactive: this.interactive,
509
+ // The trust question is one of this service's questions, so it is asked
510
+ // and printed through the same seams as the rest of them.
511
+ ask: this.ask,
512
+ write: this.write,
513
+ now: this.now,
514
+ });
515
+ this.lock = options.lock ?? new LockService(this.sousDir);
516
+ }
517
+
518
+ /** The recipe store this service fills and reads. */
519
+ get store(): RecipeStoreLike {
520
+ return this.storeInstance;
521
+ }
522
+
523
+ /** The lockfile service this project uses. */
524
+ get lockService(): LockService {
525
+ return this.lock;
526
+ }
527
+
528
+ /** The index cache, for a command that wants to read a cached index without fetching. */
529
+ get indexes(): IndexCache {
530
+ return this.indexCache;
531
+ }
532
+
533
+ /**
534
+ * The cached index of one added repository, or undefined when nothing has
535
+ * been fetched from it yet. Nothing is downloaded. Callers name the
536
+ * repository the way the project does, by its short name; the cache itself is
537
+ * keyed by identity, and this is what translates between the two.
538
+ *
539
+ * @param name - The repository's short name.
540
+ */
541
+ cachedIndex(name: string): IndexFile | undefined {
542
+ const identity = this.identityForRepo(name);
543
+ return identity === undefined ? undefined : this.indexCache.readCached(identity);
544
+ }
545
+
546
+ // --- Repository identity ----------------------------------------------------------------------
547
+
548
+ /**
549
+ * The canonical identity of a repository at a URL: what the machine-wide
550
+ * store and the index cache file it under. Canonicalizing runs a provider's
551
+ * URL parser, so the answers are remembered for the life of the service.
552
+ *
553
+ * @param url - Where the repository lives.
554
+ * @param providerId - The provider the repository entry names, when it names one.
555
+ */
556
+ private identityOf(url: string, providerId?: string): string {
557
+ const cacheKey = `${providerId ?? ""}|${url}`;
558
+ const known = this.identityCache.get(cacheKey);
559
+ if (known !== undefined) return known;
560
+
561
+ const provider = requireProvider(url, providerId, this.providers);
562
+ const identity = repoIdentity(provider.canonicalize(url));
563
+ this.identityCache.set(cacheKey, identity);
564
+ return identity;
565
+ }
566
+
567
+ /**
568
+ * The canonical identity of an added repository, by the short name this
569
+ * project calls it. The lockfile is consulted second, so a repository that
570
+ * has been removed from the config can still be located while its recipes are
571
+ * being cleaned up.
572
+ *
573
+ * @param name - The repository's short name.
574
+ * @param lock - The lockfile, when the caller has already read it.
575
+ */
576
+ identityForRepo(name: string, lock?: Lockfile): string | undefined {
577
+ const entry = this.currentRepos()[name];
578
+ if (entry?.url !== undefined) return this.identityOf(entry.url, entry.provider);
579
+
580
+ const locked = (lock ?? this.lock.read()).repos[name];
581
+ if (locked === undefined) return undefined;
582
+ return locked.identity;
583
+ }
584
+
585
+ // --- Adding a repository ----------------------------------------------------------------------
586
+
587
+ /**
588
+ * Adds a repository, which is the same thing as trusting it, and then fetches
589
+ * exactly one file from it: its index. Nothing is downloaded before the trust
590
+ * question is answered.
591
+ *
592
+ * A path is normalized before anything else happens: `~` is expanded and a
593
+ * relative path is resolved against the working directory, so what gets
594
+ * stored is always absolute (a repository on this machine is machine-specific
595
+ * whichever way it was typed). A path that is not a repository is reported as
596
+ * a path mistake, naming what was typed and where sous looked, rather than as
597
+ * a provider that could not be found.
598
+ *
599
+ * @param options - The URL, an optional short name and provider, and the trust flag.
600
+ */
601
+ async addRepo(options: AddRepoOptions): Promise<AddRepoOutcome> {
602
+ const typed = options.url.trim();
603
+ const url = resolveRepoArgument(typed);
604
+ if (looksLikeLocalPath(typed)) assertLocalRepoDirectory(typed, url);
605
+ const provider = requireProvider(url, options.provider, this.providers);
606
+ const canonical = provider.canonicalize(url);
607
+ const name = options.name ?? canonical.name;
608
+
609
+ if (!REPO_NAME_PATTERN.test(name)) {
610
+ throw new ConfigError(
611
+ `'${name}' is not a usable short name for a repository.\n` +
612
+ ` A short name is lowercase kebab-case: a letter, then letters, digits or ` +
613
+ `hyphens. It is what refs use as the 'repo:' qualifier.\n` +
614
+ ` Choose one with '--name', for example ` +
615
+ `'sous repo add ${url} --name my-recipes'.`
616
+ );
617
+ }
618
+
619
+ const existing = this.currentRepos()[name];
620
+ const alreadyTrusted =
621
+ existing !== undefined && normalizeRepoUrl(existing.url) === normalizeRepoUrl(url);
622
+
623
+ if (existing !== undefined && !alreadyTrusted) {
624
+ throw new ConfigError(
625
+ `This project already has a repository called '${name}', and it is a different one.\n` +
626
+ ` Already added: ${existing.url}\n` +
627
+ ` Being added: ${url}\n` +
628
+ ` Give this one a name of its own with '--name', for example ` +
629
+ `'sous repo add ${url} --name ${name}-2'.`
630
+ );
631
+ }
632
+
633
+ if (options.dryRun === true) {
634
+ return {
635
+ name,
636
+ url,
637
+ provider: provider.id,
638
+ alreadyTrusted,
639
+ namespaces: [],
640
+ recipeCount: 0,
641
+ dryRun: true,
642
+ };
643
+ }
644
+
645
+ // Trust first, fetch second. This is the last gate before a repository's
646
+ // recipes can put files (and scripts) on this machine.
647
+ if (!alreadyTrusted) {
648
+ await this.trust.confirmTrust(
649
+ [
650
+ {
651
+ name,
652
+ url,
653
+ requiredBy: [{ ref: name, requestedBy: PROJECT_REQUESTER }],
654
+ },
655
+ ],
656
+ {
657
+ interactive: this.interactive,
658
+ ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
659
+ }
660
+ );
661
+ }
662
+
663
+ // Written again even when confirmTrust already wrote it, so the entry records
664
+ // that a person added this repository deliberately rather than a dependency
665
+ // having dragged it in.
666
+ this.trust.addRepo({
667
+ name,
668
+ url,
669
+ ...(options.provider === undefined ? {} : { provider: options.provider }),
670
+ addedBy: USER_ADDED_BY,
671
+ });
672
+
673
+ const lookup = await this.indexCache.getIndex(this.identityOf(url, provider.id), {
674
+ url,
675
+ label: name,
676
+ ...(options.provider === undefined ? {} : { provider: options.provider }),
677
+ force: true,
678
+ });
679
+
680
+ return {
681
+ name,
682
+ url,
683
+ provider: provider.id,
684
+ alreadyTrusted,
685
+ namespaces: Object.keys(lookup.index.namespaces).sort(),
686
+ recipeCount: Object.keys(lookup.index.recipes).length,
687
+ dryRun: false,
688
+ };
689
+ }
690
+
691
+ // --- Subscribing ------------------------------------------------------------------------------
692
+
693
+ /**
694
+ * Subscribes the project to a namespace or a recipe: resolves the whole
695
+ * dependency closure, trusts whatever new repositories that turns up, fetches
696
+ * every resolved version into the store, writes the lockfile and the managed
697
+ * subscriptions layer, and finally asks for the variables the new recipes
698
+ * publish.
699
+ *
700
+ * @param options - The ref, the prerelease and always-pull flags, and the trust flag.
701
+ */
702
+ async subscribe(options: SubscribeOptions): Promise<SubscribeOutcome> {
703
+ const written = parseRef(options.ref);
704
+ const dryRun = options.dryRun === true;
705
+
706
+ // A one-word ref is a guess at a name, and the guess is settled here, from
707
+ // the cached indexes alone. Everything after this point works with a fully
708
+ // qualified ref, so what is confirmed is exactly what is installed.
709
+ const parsed = await this.resolveBareRef(written, options);
710
+ const key = refKey(parsed);
711
+
712
+ // The last gate before anything is fetched or written: what this will do to
713
+ // the project, in plain sentences, and a question.
714
+ await this.confirmSubscription(parsed, options);
715
+
716
+ const { resolved, trusted, cycles } = await this.resolveClosure(parsed, options);
717
+
718
+ const before = this.lock.read();
719
+ const after = this.lock.applyResolution(before, resolved, this.lockRepoInputs());
720
+ const diff = this.lock.diff(before, after);
721
+
722
+ const resolvedFrom =
723
+ formatRef(written) === formatRef(parsed) ? {} : { resolvedFrom: formatRef(written) };
724
+
725
+ if (dryRun) {
726
+ // The questions are planned even here, so `--dry-run` is the command an
727
+ // agent runs to find out what a subscription will want to know. Answers
728
+ // supplied with it are validated and reported, and nothing is written.
729
+ //
730
+ // What can be described is settled from the files actually on this
731
+ // machine, not from what the resolver happened to reach: a recipe already
732
+ // in the store, or read from a linked checkout, has its questions listed
733
+ // even when something else in the closure is still missing.
734
+ const defined = this.definedVariables(resolved);
735
+ const unreadable = this.unreadableRecipes(resolved);
736
+ const context = this.ladderContext();
737
+ const supplied = applyProvidedAnswers(defined, options.answers ?? [], context, {
738
+ sousDir: this.sousDir,
739
+ confDir: this.confDir,
740
+ interactive: this.interactive,
741
+ dryRun: true,
742
+ });
743
+
744
+ return {
745
+ ref: formatRef(parsed),
746
+ ...resolvedFrom,
747
+ key,
748
+ resolved,
749
+ trusted,
750
+ diff,
751
+ cycles,
752
+ questions: planQuestions(defined, context, { sousDir: this.sousDir }),
753
+ ...(unreadable.length === 0 ? {} : { unreadable }),
754
+ ...(supplied.stored.length === 0
755
+ ? {}
756
+ : { answers: { answered: supplied.stored, inherited: [], skipped: [] } }),
757
+ dryRun: true,
758
+ };
759
+ }
760
+
761
+ // Supplied answers are checked before anything is written, so an answer
762
+ // that does not fit, or a name nothing declares, fails the run rather than
763
+ // leaving a subscription behind with its questions unanswered.
764
+ validateProvidedAnswers(this.definedVariables(resolved), options.answers ?? []);
765
+
766
+ for (const recipe of resolved) await this.ensureStored(recipe);
767
+
768
+ this.lock.write(after);
769
+ this.writeSubscriptionEntry(key, parsed, options);
770
+
771
+ const answers = await this.askVariables(resolved, options.answers ?? []);
772
+
773
+ return {
774
+ ref: formatRef(parsed),
775
+ ...resolvedFrom,
776
+ key,
777
+ resolved,
778
+ trusted,
779
+ diff,
780
+ answers,
781
+ cycles,
782
+ dryRun: false,
783
+ };
784
+ }
785
+
786
+ // --- Working out what a one-word ref meant -----------------------------------------------------
787
+
788
+ /**
789
+ * Settles what a ref names, reading nothing but the cached indexes.
790
+ *
791
+ * A ref with two segments already says what it names and is handed back
792
+ * untouched. A ref with one segment is resolved through the shared reference
793
+ * module (`src/lib/refs/`), over the namespace and recipe scopes only:
794
+ * nothing found is an error naming what was searched, one match is used and
795
+ * reported, and several are chosen between. `--accept-first` takes the first
796
+ * match in the documented order; a run that cannot ask fails and says so.
797
+ *
798
+ * @param written - The ref exactly as the user wrote it.
799
+ * @param options - The accept-first flag.
800
+ */
801
+ private async resolveBareRef(
802
+ written: ParsedRef,
803
+ options: SubscribeOptions
804
+ ): Promise<ParsedRef> {
805
+ if (written.recipe !== undefined) return written;
806
+
807
+ const repoOrder = this.repoSearchOrder(written.repo);
808
+ const indexes = await this.loadIndexes(repoOrder);
809
+ if (written.repo === undefined) this.indexesSnapshot = indexes;
810
+
811
+ const context = { repos: referenceReposFromIndexes(repoOrder, indexes) };
812
+ const matches = findReference(
813
+ written.namespace,
814
+ [SousScope.Namespace, SousScope.Recipe],
815
+ context
816
+ );
817
+
818
+ if (matches.length === 0) {
819
+ throw new ConfigError(
820
+ [
821
+ `Nothing called '${written.namespace}' was found: no namespace has that name, ` +
822
+ `and no recipe does either.`,
823
+ ...describeIndexSearch({ name: written.namespace, repoOrder, indexes }),
824
+ ` Run 'sous repo search ${written.namespace}' to look for something like it, or ` +
825
+ `'sous repo add <url>' to add the repository that publishes it.`,
826
+ ].join("\n")
827
+ );
828
+ }
829
+
830
+ const chosen = await pickReference(matches, {
831
+ search: written.namespace,
832
+ interactive: this.interactive,
833
+ ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
834
+ write: (message: string) => this.write(message),
835
+ choose: (message, offered) => this.choose(message, offered),
836
+ });
837
+
838
+ return referenceToRef(chosen, written);
839
+ }
840
+
841
+ /**
842
+ * The repositories a one-word ref is searched in, in the order their
843
+ * candidates are listed: the built-in repository first, then the ones the
844
+ * config names, in the order the config names them.
845
+ *
846
+ * @param only - A repository qualifier from the ref, which narrows the search to it.
847
+ */
848
+ private repoSearchOrder(only?: string): string[] {
849
+ const repos = this.currentRepos();
850
+ const names = Object.keys(repos);
851
+
852
+ if (only !== undefined) {
853
+ if (!Object.hasOwn(repos, only)) {
854
+ throw new ConfigError(
855
+ `This project does not trust a repository called '${only}'.\n` +
856
+ (names.length > 0
857
+ ? ` It trusts: ${names.join(", ")}.`
858
+ : ` It trusts none yet.`) +
859
+ `\n Add it with 'sous repo add <url> --name ${only}'.`
860
+ );
861
+ }
862
+ return [only];
863
+ }
864
+
865
+ const builtIn = names.filter((name) => isBuiltInEntry(repos[name]));
866
+ return [...builtIn, ...names.filter((name) => !builtIn.includes(name))];
867
+ }
868
+
869
+ /**
870
+ * Every trusted repository's index, loaded once per command. Whatever a
871
+ * one-word ref already loaded is reused, so confirming a subscription costs
872
+ * no extra lookups.
873
+ */
874
+ private async indexSnapshot(): Promise<Map<string, IndexFile>> {
875
+ if (this.indexesSnapshot === undefined) {
876
+ this.indexesSnapshot = await this.loadIndexes(this.repoSearchOrder());
877
+ }
878
+ return this.indexesSnapshot;
879
+ }
880
+
881
+ // --- The subscribe confirmation ----------------------------------------------------------------
882
+
883
+ /**
884
+ * Says what subscribing will do to this project, and asks whether to go on.
885
+ *
886
+ * This runs before anything is fetched or written, so a "no" costs nothing:
887
+ * the only thing read to get here is the cached index of each trusted
888
+ * repository. `--yes` skips the question, and a dry run states the plan and
889
+ * never asks, because a dry run has nothing to decline.
890
+ *
891
+ * @param parsed - The fully qualified ref being subscribed to.
892
+ * @param options - The yes and dry-run flags.
893
+ */
894
+ private async confirmSubscription(
895
+ parsed: ParsedRef,
896
+ options: SubscribeOptions
897
+ ): Promise<void> {
898
+ const indexes = await this.indexSnapshot();
899
+ const plan = await this.subscriptionPlan(parsed, options, indexes);
900
+ for (const line of plan) this.write(line === "" ? "" : indent(line));
901
+
902
+ if (options.dryRun === true || options.yes === true) return;
903
+
904
+ if (!this.interactive) {
905
+ throw nonInteractiveError({
906
+ prompt: `whether to go ahead with subscribing to '${formatRef(parsed)}'`,
907
+ remedy:
908
+ "pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
909
+ "the plan above without being asked.",
910
+ });
911
+ }
912
+
913
+ const proceed = await this.ask("Proceed?");
914
+ if (!proceed) {
915
+ throw new ConfigError(
916
+ `Nothing was written: the subscription to '${formatRef(parsed)}' was declined.\n` +
917
+ ` Nothing was downloaded, no lockfile entry was made, and this project's ` +
918
+ `config is exactly as it was.`
919
+ );
920
+ }
921
+ }
922
+
923
+ /**
924
+ * The plan itself: what will be compiled, what can run, what will be asked,
925
+ * and what will be fetched, in plain sentences.
926
+ *
927
+ * @param parsed - The fully qualified ref being subscribed to.
928
+ * @param options - The prerelease flag, for the dependency peek.
929
+ */
930
+ private async subscriptionPlan(
931
+ parsed: ParsedRef,
932
+ options: SubscribeOptions,
933
+ indexes: Map<string, IndexFile>
934
+ ): Promise<string[]> {
935
+ const target = formatRef(parsed);
936
+ const width = wrapColumns() - 4;
937
+ const lines: string[] = [""];
938
+
939
+ /** One sentence of the plan, wrapped and in the warning color. */
940
+ const sentence = (text: string): string[] =>
941
+ wrapText(text, width).map((line) => palette.warning(line));
942
+
943
+ /** One bullet of the plan, wrapped so its continuation hangs under the text. */
944
+ const bullet = (text: string): string[] =>
945
+ wrapText(`${BULLET} ${text}`, width, { hangingIndent: 2 }).map((line) =>
946
+ palette.warning(line)
947
+ );
948
+
949
+ if (parsed.recipe === undefined) {
950
+ const published = this.namespaceRecipes(parsed, indexes);
951
+ lines.push(
952
+ ...sentence(
953
+ `Subscribing to '${target}' subscribes this project to the whole ` +
954
+ `namespace '${parsed.namespace}', which means ${palette.highlight(
955
+ "every recipe in it, including ones published later"
956
+ )}.`
957
+ )
958
+ );
959
+ if (published.length > 0) {
960
+ lines.push(
961
+ ...sentence(`It publishes ${published.length} today: ${published.join(", ")}.`)
962
+ );
963
+ }
964
+ } else {
965
+ lines.push(
966
+ ...sentence(
967
+ `Subscribing to '${target}' installs the recipe '${parsed.recipe}' from ` +
968
+ `the namespace '${parsed.namespace}'.`
969
+ )
970
+ );
971
+ }
972
+
973
+ lines.push("");
974
+ lines.push(...sentence("Here is what that does:"));
975
+ lines.push("");
976
+ lines.push(
977
+ ...bullet(
978
+ `The files it ships are compiled into this project on the next build, ` +
979
+ `which writes them into this project's agent directories.`
980
+ )
981
+ );
982
+ lines.push(
983
+ ...bullet(
984
+ `Any scripts it ships ${palette.highlight(
985
+ "can be run on this machine"
986
+ )} when an agent uses them. Sous does not run them itself, and it cannot ` +
987
+ `vouch for what they do.`
988
+ )
989
+ );
990
+ lines.push(
991
+ ...bullet(
992
+ `The variables it publishes are asked about at the end of this command, ` +
993
+ `and the answers are written into this project's env files.`
994
+ )
995
+ );
996
+ lines.push(
997
+ ...bullet(
998
+ `Its dependencies are fetched and pinned in this project's lockfile, at ` +
999
+ `the exact versions resolved now.`
1000
+ )
1001
+ );
1002
+
1003
+ const untrusted = await this.knownUntrustedDependencyRepos(parsed, options, indexes);
1004
+ if (untrusted.length > 0) {
1005
+ lines.push(
1006
+ ...bullet(
1007
+ `Some of what it needs lives in repositories this project ${palette.highlight(
1008
+ "does not trust yet"
1009
+ )}: ${untrusted.join(", ")}. You are asked about each one by name before ` +
1010
+ `anything is fetched from it.`
1011
+ )
1012
+ );
1013
+ } else {
1014
+ lines.push(
1015
+ ...bullet(
1016
+ `If a dependency turns out to live in a repository this project does ` +
1017
+ `not trust, sous stops and asks about that repository by name before ` +
1018
+ `fetching anything from it.`
1019
+ )
1020
+ );
1021
+ }
1022
+
1023
+ lines.push("");
1024
+ return lines;
1025
+ }
1026
+
1027
+ /**
1028
+ * The recipes a namespace publishes today, as `namespace/recipe` keys.
1029
+ *
1030
+ * @param parsed - The fully qualified namespace ref.
1031
+ */
1032
+ private namespaceRecipes(parsed: ParsedRef, indexes: Map<string, IndexFile>): string[] {
1033
+ const index = parsed.repo === undefined ? undefined : indexes.get(parsed.repo);
1034
+ if (index === undefined) return [];
1035
+ return Object.keys(index.recipes)
1036
+ .filter((key) => key.startsWith(`${parsed.namespace}/`))
1037
+ .sort();
1038
+ }
1039
+
1040
+ /**
1041
+ * Repositories a dependency needs that this project does not trust, as far as
1042
+ * anything already on disk knows.
1043
+ *
1044
+ * A manifest is the only thing that names a dependency, and a manifest that
1045
+ * has never been fetched cannot be read without fetching, which is exactly
1046
+ * what the confirmation exists to gate. So this reads what the store already
1047
+ * holds, and says nothing when it holds nothing; the trust ceremony during
1048
+ * resolution is still where the real answer comes from.
1049
+ *
1050
+ * @param parsed - The fully qualified ref being subscribed to.
1051
+ * @param options - The prerelease flag.
1052
+ */
1053
+ private async knownUntrustedDependencyRepos(
1054
+ parsed: ParsedRef,
1055
+ options: SubscribeOptions,
1056
+ indexes: Map<string, IndexFile>
1057
+ ): Promise<string[]> {
1058
+ try {
1059
+ const result = await resolveRefs(
1060
+ [
1061
+ {
1062
+ ref: parsed,
1063
+ requestedBy: PROJECT_REQUESTER,
1064
+ kind: "subscribes",
1065
+ ...(options.prerelease === true ? { prerelease: true } : {}),
1066
+ },
1067
+ ],
1068
+ {
1069
+ indexes,
1070
+ repos: this.resolverRepos(),
1071
+ // Reads only what is already on disk: the confirmation must not fetch.
1072
+ loadManifest: (recipe) => this.loadRecipeManifest(recipe, true),
1073
+ ...(options.prerelease === true ? { prerelease: true } : {}),
1074
+ }
1075
+ );
1076
+ return result.missingRepos.map((missing) => `'${missing.name}'`).sort();
1077
+ } catch {
1078
+ // The plan is a courtesy; a peek that fails must never stop a subscription
1079
+ // that resolution itself would have completed.
1080
+ return [];
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Resolves a ref and everything beneath it, running the trust round as often
1086
+ * as resolution keeps turning up repositories the project has not added.
1087
+ *
1088
+ * IN PRACTICE THIS RUNS AT MOST ONE TRUST ROUND TODAY, and the loop is the
1089
+ * shape rather than the behavior. A recipe names the repository it depends on
1090
+ * by short name only, so nothing in a manifest carries a URL and every missing
1091
+ * repository comes back under `needUrl`, which throws below. The loop earns
1092
+ * its keep the moment any source of URLs exists (a repository hint block, or
1093
+ * the lockfile of a project restoring someone else's commit); until then, read
1094
+ * it as "one round, then either resolution succeeds or the person is told what
1095
+ * to add".
1096
+ *
1097
+ * @param parsed - The ref being subscribed to.
1098
+ * @param options - The prerelease and trust flags.
1099
+ */
1100
+ private async resolveClosure(
1101
+ parsed: ParsedRef,
1102
+ options: SubscribeOptions
1103
+ ): Promise<{
1104
+ resolved: ResolvedRecipe[];
1105
+ trusted: string[];
1106
+ cycles: string[][];
1107
+ unreadable: string[];
1108
+ }> {
1109
+ const trusted: string[] = [];
1110
+
1111
+ for (;;) {
1112
+ const repos = this.resolverRepos();
1113
+ const indexes = await this.loadIndexes(Object.keys(repos));
1114
+
1115
+ const result = await resolveRefs(
1116
+ [
1117
+ {
1118
+ ref: parsed,
1119
+ requestedBy: PROJECT_REQUESTER,
1120
+ kind: "subscribes",
1121
+ ...(options.prerelease === true ? { prerelease: true } : {}),
1122
+ },
1123
+ ],
1124
+ {
1125
+ indexes,
1126
+ repos,
1127
+ loadManifest: (recipe) => this.loadRecipeManifest(recipe, options.dryRun === true),
1128
+ ...(options.prerelease === true ? { prerelease: true } : {}),
1129
+ }
1130
+ );
1131
+
1132
+ if (result.missingRepos.length === 0) {
1133
+ // A dry run refuses to download, so a recipe this machine does not hold
1134
+ // yet has no manifest to read. That is not a failure here: the plan
1135
+ // describes everything it could read and names what it could not.
1136
+ if (result.missingManifests.length > 0 && options.dryRun !== true) {
1137
+ throw new ConfigError(
1138
+ `Sous could not read the manifest of ` +
1139
+ `${result.missingManifests.map((entry) => `'${entry}'`).join(", ")}.\n` +
1140
+ ` Every recipe carries a manifest, so this one is either damaged upstream ` +
1141
+ `or could not be downloaded. Nothing was written.`
1142
+ );
1143
+ }
1144
+ return {
1145
+ resolved: result.resolved,
1146
+ trusted,
1147
+ cycles: result.cycles,
1148
+ unreadable: result.missingManifests,
1149
+ };
1150
+ }
1151
+
1152
+ const outcome = await this.trust.confirmTrust(result.missingRepos, {
1153
+ interactive: this.interactive,
1154
+ ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
1155
+ });
1156
+
1157
+ if (outcome.needUrl.length > 0) {
1158
+ const lines = [
1159
+ outcome.needUrl.length === 1
1160
+ ? `Sous does not know where the repository '${outcome.needUrl[0]}' lives, so it ` +
1161
+ `cannot add it for you.`
1162
+ : `Sous does not know where these repositories live, so it cannot add them for ` +
1163
+ `you: ${outcome.needUrl.map((entry) => `'${entry}'`).join(", ")}.`,
1164
+ " A recipe names the repository it depends on by its short name only; the URL " +
1165
+ "has to come from you.",
1166
+ " Add each one with its URL, then run this command again:",
1167
+ "",
1168
+ ];
1169
+ for (const name of outcome.needUrl) {
1170
+ lines.push(` sous repo add <url> --name ${name}`);
1171
+ }
1172
+ throw new ConfigError(lines.join("\n"));
1173
+ }
1174
+
1175
+ trusted.push(...outcome.added);
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Removes one subscription and everything that was only there because of it.
1181
+ * Removal is refcounted: a recipe another subscription (or another recipe)
1182
+ * still holds stays exactly where it is, and is reported as having stayed.
1183
+ *
1184
+ * @param options - The ref to unsubscribe from.
1185
+ */
1186
+ async unsubscribe(options: UnsubscribeOptions): Promise<UnsubscribeOutcome> {
1187
+ const parsed = parseRef(options.ref);
1188
+ const key = refKey(parsed);
1189
+ const dryRun = options.dryRun === true;
1190
+
1191
+ const managed = this.readSubscriptionEntries();
1192
+ const configured = enabledSubscriptions(this.settings);
1193
+ const before = this.lock.read();
1194
+ const held = this.keysHeldBySubscription(before, key);
1195
+
1196
+ // An entry the managed layer already switched off is not a subscription any
1197
+ // more, so removing it again is the "you do not subscribe to this" case
1198
+ // rather than a deletion that would quietly switch it back on.
1199
+ const alreadyOff = managed[key]?.enabled === false;
1200
+ const removable = Object.hasOwn(managed, key) && !alreadyOff;
1201
+ // Sous provides the `core` subscription itself, so there is no entry to
1202
+ // delete. Removing it means recording an opt-out that outlives the default.
1203
+ const builtIn = !removable && isBuiltInEntry(configured[key]);
1204
+
1205
+ if (!removable && !builtIn && !Object.hasOwn(configured, key) && held.length === 0) {
1206
+ const known = [
1207
+ ...new Set([
1208
+ ...Object.entries(managed)
1209
+ .filter(([, entry]) => entry?.enabled !== false)
1210
+ .map(([entryKey]) => entryKey),
1211
+ ...Object.keys(configured),
1212
+ ]),
1213
+ ].sort();
1214
+ throw new ConfigError(
1215
+ `This project does not subscribe to '${key}'.\n` +
1216
+ (known.length > 0
1217
+ ? ` It subscribes to: ${known.join(", ")}.`
1218
+ : ` It has no subscriptions yet.`)
1219
+ );
1220
+ }
1221
+
1222
+ if (!removable && !builtIn && Object.hasOwn(configured, key)) {
1223
+ throw new ConfigError(
1224
+ `The subscription to '${key}' is written in this project's own config, not in the ` +
1225
+ `layer sous manages.\n` +
1226
+ ` Remove its entry from the 'subscriptions' block of your config file; sous ` +
1227
+ `never edits a config file you wrote.`
1228
+ );
1229
+ }
1230
+
1231
+ let after = before;
1232
+ for (const heldKey of held) after = this.dropProjectHold(after, heldKey);
1233
+ const diff = this.lock.diff(before, after);
1234
+
1235
+ const stayed = held
1236
+ .filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
1237
+ .map((heldKey) => ({
1238
+ key: heldKey,
1239
+ heldBy: [...after.recipes[heldKey]!.requestedBy],
1240
+ }));
1241
+
1242
+ if (!dryRun) {
1243
+ this.lock.write(after);
1244
+
1245
+ const remaining = { ...managed };
1246
+ if (builtIn) remaining[key] = { enabled: false };
1247
+ else delete remaining[key];
1248
+
1249
+ if (Object.keys(remaining).length === 0) {
1250
+ removeManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
1251
+ confDir: this.confDir,
1252
+ });
1253
+ } else {
1254
+ // A subscription sous provides itself has no entry to delete, so the
1255
+ // opt-out is written as the entry instead; anything else is removed.
1256
+ updateManagedLayer(
1257
+ this.sousDir,
1258
+ SUBSCRIPTIONS_LAYER_FILENAME,
1259
+ [
1260
+ {
1261
+ path: ["subscriptions", key],
1262
+ value: builtIn ? { enabled: false } : undefined,
1263
+ },
1264
+ ],
1265
+ { confDir: this.confDir }
1266
+ );
1267
+ }
1268
+ }
1269
+
1270
+ return { key, diff, stayed, optedOut: builtIn, dryRun };
1271
+ }
1272
+
1273
+ // --- Withdrawing trust from a repository -------------------------------------------------------
1274
+
1275
+ /**
1276
+ * Stops trusting a repository: every subscription that resolves into it is
1277
+ * removed through the same refcounted path `unsubscribe` uses, the link that
1278
+ * pointed at it is dropped, and its entry leaves the managed repositories
1279
+ * layer.
1280
+ *
1281
+ * Informed consent, never prevention: everything that will go is printed
1282
+ * first, in plain sentences, and then one question is asked. `--yes` skips the
1283
+ * question and a dry run never asks, because a dry run has nothing to decline.
1284
+ *
1285
+ * The repository sous provides itself has no entry to delete, so removing it
1286
+ * records `enabled: false` instead; the default comes back on every run and
1287
+ * only a recorded opt-out outlives it.
1288
+ *
1289
+ * @param options - The repository's short name, and the yes and dry-run flags.
1290
+ */
1291
+ async removeRepo(options: RemoveRepoOptions): Promise<RemoveRepoOutcome> {
1292
+ const name = options.name;
1293
+ const dryRun = options.dryRun === true;
1294
+
1295
+ const trusted = this.trust.listTrusted();
1296
+ const entry = trusted[name];
1297
+ if (entry === undefined) {
1298
+ const known = Object.keys(trusted).sort();
1299
+ throw new ConfigError(
1300
+ `This project does not trust a repository called '${name}'.\n` +
1301
+ (known.length > 0
1302
+ ? ` It trusts: ${known.join(", ")}.`
1303
+ : ` It trusts no repositories yet.`)
1304
+ );
1305
+ }
1306
+
1307
+ const managed = this.trust.listManaged();
1308
+ const builtIn = !Object.hasOwn(managed, name) && isBuiltInEntry(entry);
1309
+ if (!Object.hasOwn(managed, name) && !builtIn) {
1310
+ throw new ConfigError(
1311
+ `The repository '${name}' is written in this project's own config, not in the ` +
1312
+ `layer sous manages.\n` +
1313
+ ` Remove its entry from the 'repos' block of your config file; sous never ` +
1314
+ `edits a config file you wrote.`
1315
+ );
1316
+ }
1317
+
1318
+ const before = this.lock.read();
1319
+ const { removable, kept } = this.subscriptionsInto(name, before);
1320
+
1321
+ // What the removal would leave behind, worked out before anything is
1322
+ // written, so the plan below describes exactly what is about to happen.
1323
+ let after = before;
1324
+ const held: string[] = [];
1325
+ for (const key of removable) {
1326
+ for (const heldKey of keysHeldBySubscription(before, key)) {
1327
+ held.push(heldKey);
1328
+ after = this.dropProjectHold(after, heldKey);
1329
+ }
1330
+ }
1331
+ const diff = this.lock.diff(before, after);
1332
+ const removedRecipes = diff.removed.map((change) => change.key);
1333
+ const stayed = [...new Set(held)]
1334
+ .filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
1335
+ .sort()
1336
+ .map((heldKey) => ({ key: heldKey, heldBy: [...after.recipes[heldKey]!.requestedBy] }));
1337
+
1338
+ const outputs = this.outputsOf(removedRecipes, options.scope);
1339
+ const projectLink = readProjectLinks(this.sousDir).links[name];
1340
+ const globalLink = readGlobalLinks(this.env).links[name];
1341
+ const link = projectLink ?? globalLink;
1342
+
1343
+ const outcome: RemoveRepoOutcome = {
1344
+ name,
1345
+ url: entry.url,
1346
+ optedOut: builtIn,
1347
+ subscriptions: removable,
1348
+ keptSubscriptions: kept,
1349
+ removedRecipes,
1350
+ stayed,
1351
+ outputs,
1352
+ diff,
1353
+ ...(link === undefined ? {} : { linkedPath: link.path }),
1354
+ linkIsGlobal: projectLink === undefined && globalLink !== undefined,
1355
+ dryRun,
1356
+ };
1357
+
1358
+ for (const line of this.removalPlan(outcome)) {
1359
+ this.write(line === "" ? "" : indent(line));
1360
+ }
1361
+
1362
+ if (!dryRun && options.yes !== true) {
1363
+ if (!this.interactive) {
1364
+ throw nonInteractiveError({
1365
+ prompt: `whether to stop trusting the repository '${name}'`,
1366
+ remedy:
1367
+ "pass '--yes' (spelled '-y', '--force' or '-f' if you prefer) to accept the " +
1368
+ "plan above without being asked.",
1369
+ });
1370
+ }
1371
+
1372
+ const proceed = await this.ask("Stop trusting it?");
1373
+ if (!proceed) {
1374
+ throw new ConfigError(
1375
+ `Nothing was written: the repository '${name}' is still trusted.\n` +
1376
+ ` No subscription was removed, no lockfile entry was changed, and this ` +
1377
+ `project's config is exactly as it was.`
1378
+ );
1379
+ }
1380
+ }
1381
+
1382
+ if (dryRun) return outcome;
1383
+
1384
+ // Each subscription goes through the ordinary refcounted removal, so a
1385
+ // recipe another subscription still holds is kept exactly as it would be
1386
+ // had the subscription been removed on its own.
1387
+ for (const key of removable) await this.unsubscribe({ ref: key });
1388
+
1389
+ if (projectLink !== undefined) {
1390
+ const map = readProjectLinks(this.sousDir);
1391
+ delete map.links[name];
1392
+ writeProjectLinks(this.sousDir, map);
1393
+ }
1394
+
1395
+ if (builtIn) this.trust.disableRepo(name);
1396
+ else this.trust.removeRepo(name);
1397
+
1398
+ return outcome;
1399
+ }
1400
+
1401
+ /**
1402
+ * The subscriptions that resolve into one repository, split by whether sous
1403
+ * may remove them. A subscription counts when the lockfile pins one of its
1404
+ * recipes to that repository, or when its ref names the repository outright
1405
+ * with a `repo:` qualifier.
1406
+ *
1407
+ * @param name - The repository's short name.
1408
+ * @param lock - The lockfile as it stands.
1409
+ */
1410
+ private subscriptionsInto(
1411
+ name: string,
1412
+ lock: Lockfile
1413
+ ): { removable: string[]; kept: string[] } {
1414
+ const fromRepo = new Set(
1415
+ Object.entries(lock.recipes)
1416
+ .filter(([, recipe]) => recipe.repo === name)
1417
+ .map(([key]) => key)
1418
+ );
1419
+
1420
+ const subscriptions = this.allSubscriptions();
1421
+ const managed = this.readSubscriptionEntries();
1422
+ const removable: string[] = [];
1423
+ const kept: string[] = [];
1424
+
1425
+ for (const key of Object.keys(subscriptions).sort()) {
1426
+ let qualifier: string | undefined;
1427
+ try {
1428
+ qualifier = parseRef(key).repo;
1429
+ } catch {
1430
+ // A ref that does not parse cannot name this repository, and reporting
1431
+ // it here would bury the removal under an unrelated complaint.
1432
+ continue;
1433
+ }
1434
+
1435
+ const touches =
1436
+ qualifier === name ||
1437
+ keysHeldBySubscription(lock, key).some((heldKey) => fromRepo.has(heldKey));
1438
+ if (!touches) continue;
1439
+
1440
+ const sousMayRemove =
1441
+ Object.hasOwn(managed, key) || isBuiltInEntry(subscriptions[key]);
1442
+ if (sousMayRemove) removable.push(key);
1443
+ else kept.push(key);
1444
+ }
1445
+
1446
+ return { removable, kept };
1447
+ }
1448
+
1449
+ /**
1450
+ * The output files a set of locked recipes compiled, which the next build
1451
+ * prunes once they are gone.
1452
+ *
1453
+ * Worked out from the recipes' own compile targets rather than from the build
1454
+ * state file, because the state file records what sous wrote without
1455
+ * recording which recipe wrote it. A destination that cannot be resolved
1456
+ * (a `${var}` with no value in the scope given) leaves the list empty rather
1457
+ * than stopping a removal; the build itself reports that properly.
1458
+ *
1459
+ * @param keys - The recipe keys that are going away.
1460
+ * @param scope - The resolved settings scope, for `${var}` in destinations.
1461
+ */
1462
+ private outputsOf(keys: string[], scope?: VarScope): string[] {
1463
+ if (keys.length === 0) return [];
1464
+
1465
+ const going = new Set(keys);
1466
+ const locked = listLockedRecipes({ sousDir: this.sousDir, env: this.env }).filter(
1467
+ (recipe) => going.has(recipe.key)
1468
+ );
1469
+ if (locked.length === 0) return [];
1470
+
1471
+ try {
1472
+ const { targets } = buildRecipeTargets({
1473
+ sousDir: this.sousDir,
1474
+ settings: this.settings,
1475
+ ...(scope === undefined ? {} : { scope }),
1476
+ env: this.env,
1477
+ locked,
1478
+ });
1479
+
1480
+ const files = new Set<string>();
1481
+ for (const target of targets) {
1482
+ for (const output of target.outputs) {
1483
+ const resolved = resolveOutputPath(target, output);
1484
+ if (resolved !== undefined) files.add(resolved);
1485
+ }
1486
+ }
1487
+ return [...files].sort();
1488
+ } catch {
1489
+ return [];
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * What stopping trusting a repository will do to this project, in plain
1495
+ * sentences: the entry itself, the subscriptions that go with it, the recipes
1496
+ * they hold, the files the next build prunes, and the link that points at it.
1497
+ *
1498
+ * @param outcome - Everything the removal worked out.
1499
+ */
1500
+ private removalPlan(outcome: RemoveRepoOutcome): string[] {
1501
+ const lines: string[] = [""];
1502
+
1503
+ lines.push(
1504
+ outcome.optedOut
1505
+ ? `The repository '${outcome.name}' at ${outcome.url} is one sous provides ` +
1506
+ `itself, so it cannot be deleted: it is switched off instead, by recording ` +
1507
+ `'${outcome.name}: { enabled: false }' in this project's repositories layer.`
1508
+ : `The entry for '${outcome.name}' at ${outcome.url} is removed from this ` +
1509
+ `project's repositories layer, so sous stops reading anything from it.`
1510
+ );
1511
+
1512
+ lines.push("");
1513
+ lines.push("Here is what goes with it:");
1514
+ lines.push("");
1515
+
1516
+ if (outcome.subscriptions.length > 0) {
1517
+ lines.push(
1518
+ outcome.subscriptions.length === 1
1519
+ ? ` One subscription resolves into it and is removed: ` +
1520
+ `${outcome.subscriptions[0]}.`
1521
+ : ` ${outcome.subscriptions.length} subscriptions resolve into it and are ` +
1522
+ `removed: ${outcome.subscriptions.join(", ")}.`
1523
+ );
1524
+ } else {
1525
+ lines.push(" Nothing this project subscribes to resolves into it.");
1526
+ }
1527
+
1528
+ if (outcome.removedRecipes.length > 0) {
1529
+ lines.push(
1530
+ ` ${outcome.removedRecipes.length === 1 ? "One locked recipe is" : `${outcome.removedRecipes.length} locked recipes are`} ` +
1531
+ `held only through those subscriptions, and ${
1532
+ outcome.removedRecipes.length === 1 ? "it leaves" : "they leave"
1533
+ } the lockfile: ${outcome.removedRecipes.join(", ")}.`
1534
+ );
1535
+ }
1536
+
1537
+ for (const stayed of outcome.stayed) {
1538
+ lines.push(
1539
+ ` The recipe '${stayed.key}' stays, because ${stayed.heldBy.join(", ")} still ` +
1540
+ `holds it.`
1541
+ );
1542
+ }
1543
+
1544
+ if (outcome.outputs.length > 0) {
1545
+ lines.push(
1546
+ ` ${outcome.outputs.length === 1 ? "One file those recipes compiled is" : `${outcome.outputs.length} files those recipes compiled are`} ` +
1547
+ `pruned by the build that follows:`
1548
+ );
1549
+ for (const file of outcome.outputs) lines.push(` ${file}`);
1550
+ }
1551
+
1552
+ if (outcome.linkedPath !== undefined) {
1553
+ lines.push(
1554
+ outcome.linkIsGlobal
1555
+ ? ` A machine-wide link points this repository at the checkout at ` +
1556
+ `${outcome.linkedPath}. That map is shared by every project on this ` +
1557
+ `machine, so it is left exactly as it is, and the checkout stays on disk.`
1558
+ : ` This project links the repository to the checkout at ` +
1559
+ `${outcome.linkedPath}. The link is removed, and the checkout stays on ` +
1560
+ `disk exactly as it is.`
1561
+ );
1562
+ }
1563
+
1564
+ for (const key of outcome.keptSubscriptions) {
1565
+ lines.push(
1566
+ ` The subscription to '${key}' resolves into it and is written in this ` +
1567
+ `project's own config, which sous never edits, so it stays. It resolves ` +
1568
+ `against nothing once the repository is gone.`
1569
+ );
1570
+ }
1571
+
1572
+ lines.push("");
1573
+ return lines;
1574
+ }
1575
+
1576
+ // --- Restoring and upstream checks -------------------------------------------------------------
1577
+
1578
+ /**
1579
+ * True when the lockfile pins something the store does not hold, which is the
1580
+ * state of a fresh clone. A linked repository is read from its checkout and is
1581
+ * never restored.
1582
+ */
1583
+ needsRestore(): boolean {
1584
+ return listLockedRecipes({ sousDir: this.sousDir, env: this.env }).some(
1585
+ (recipe) => !recipe.linked && !recipe.present
1586
+ );
1587
+ }
1588
+
1589
+ /**
1590
+ * Puts the core recipe that ships inside the sous package into the store,
1591
+ * writes a stand-in index for the official repository when nothing real has
1592
+ * ever been fetched, and tells the index cache about the packaged version so
1593
+ * it resolves even against a real index that has not published it yet. This
1594
+ * runs before anything else a build does, because it is what lets a project
1595
+ * resolve the `core` namespace at all, with or without a network.
1596
+ *
1597
+ * Idempotent, offline, and never fatal: a failure comes back in the report as
1598
+ * a sentence to warn about.
1599
+ */
1600
+ async seedCore(): Promise<SeedCoreRecipeReport> {
1601
+ if (this.seedReport !== undefined) return this.seedReport;
1602
+ this.seedReport = await seedCoreRecipe({
1603
+ store: this.storeInstance,
1604
+ sousVersion: SOUS_VERSION,
1605
+ now: this.now,
1606
+ // Teaching the index cache what the package holds is what lets the seeded
1607
+ // version resolve on a machine whose cached index is the real one and does
1608
+ // not publish that version yet.
1609
+ indexCache: this.indexCache,
1610
+ warn: this.warn,
1611
+ });
1612
+ return this.seedReport;
1613
+ }
1614
+
1615
+ /**
1616
+ * Brings the lockfile in line with the subscriptions the config declares.
1617
+ *
1618
+ * A subscription is normally written by `sous subscribe`, which locks it on
1619
+ * the spot. Two cases leave one declared but unlocked, and both have to work
1620
+ * without anyone typing a command: a subscription hand-written into the config
1621
+ * (or arriving with a colleague's commit), and the built-in `core`
1622
+ * subscription, whose range is the running sous version and therefore changes
1623
+ * every time sous is upgraded.
1624
+ *
1625
+ * So a subscription is resolved here when the lockfile pins nothing for it, or
1626
+ * pins something its range no longer allows. Everything else is left exactly
1627
+ * as the lockfile has it; a build never re-decides a version it already has.
1628
+ *
1629
+ * Nothing here is fatal and nothing here prompts. A build must not stop
1630
+ * because a repository is unreachable, and it must never block on a question,
1631
+ * so a subscription that cannot be resolved comes back in the report as a
1632
+ * sentence to warn about.
1633
+ */
1634
+ async ensureSubscriptionsLocked(): Promise<SubscriptionSyncReport> {
1635
+ const report: SubscriptionSyncReport = {
1636
+ resolved: [],
1637
+ added: [],
1638
+ moved: [],
1639
+ failed: [],
1640
+ };
1641
+
1642
+ const subscriptions = this.allSubscriptions();
1643
+ const before = this.lock.read();
1644
+
1645
+ const pending: Array<{ key: string; request: RefRequest }> = [];
1646
+ for (const key of Object.keys(subscriptions).sort()) {
1647
+ const entry = subscriptions[key]!;
1648
+ if (this.subscriptionIsLocked(key, entry, before)) continue;
1649
+
1650
+ let parsed: ParsedRef;
1651
+ try {
1652
+ parsed = parseRef(key);
1653
+ } catch (error) {
1654
+ report.failed.push({ key, reason: describeError(error) });
1655
+ continue;
1656
+ }
1657
+
1658
+ pending.push({
1659
+ key,
1660
+ request: {
1661
+ ref: {
1662
+ ...parsed,
1663
+ ...(entry.range === undefined ? {} : { range: entry.range }),
1664
+ },
1665
+ requestedBy: PROJECT_REQUESTER,
1666
+ kind: "subscribes",
1667
+ ...(entry.prerelease === true ? { prerelease: true } : {}),
1668
+ },
1669
+ });
1670
+ }
1671
+
1672
+ if (pending.length === 0) return report;
1673
+
1674
+ const repos = this.resolverRepos();
1675
+ const indexes = await this.loadIndexes(Object.keys(repos), { lock: before });
1676
+
1677
+ // Each subscription is resolved on its own. Resolving them together would be
1678
+ // one call, but the resolver raises on the first ref it cannot settle, and a
1679
+ // project should not lose four subscriptions because one of them names a
1680
+ // recipe that no longer exists. What the separate resolutions produce is
1681
+ // merged back together below, so a recipe two subscriptions both depend on
1682
+ // still records both of them as holders.
1683
+ const stored = new Map<string, ResolvedRecipe>();
1684
+ for (const { key, request } of pending) {
1685
+ let result;
1686
+ try {
1687
+ result = await resolveRefs([request], {
1688
+ indexes,
1689
+ repos,
1690
+ loadManifest: (recipe) => this.loadRecipeManifest(recipe, false),
1691
+ });
1692
+ } catch (error) {
1693
+ report.failed.push({ key, reason: describeError(error) });
1694
+ continue;
1695
+ }
1696
+
1697
+ // A repository something needs but the project has not added is a trust
1698
+ // decision, and a build is the wrong moment to ask for one. Say which
1699
+ // command grants it, and leave this subscription alone.
1700
+ if (result.missingRepos.length > 0) {
1701
+ const names = result.missingRepos.map((missing) => missing.name);
1702
+ report.failed.push({
1703
+ key,
1704
+ reason:
1705
+ `Sous has not been told where ${
1706
+ names.length === 1
1707
+ ? `the repository '${names[0]}' lives`
1708
+ : `these repositories live: ${names.map((n) => `'${n}'`).join(", ")}`
1709
+ }, so it could not be resolved.\n` +
1710
+ names.map((name) => ` sous repo add <url> --name ${name}`).join("\n"),
1711
+ });
1712
+ continue;
1713
+ }
1714
+
1715
+ let failed = false;
1716
+ const settled: ResolvedRecipe[] = [];
1717
+ for (const recipe of result.resolved) {
1718
+ try {
1719
+ await this.ensureStored(recipe);
1720
+ settled.push(recipe);
1721
+ } catch (error) {
1722
+ report.failed.push({ key, reason: describeError(error) });
1723
+ failed = true;
1724
+ break;
1725
+ }
1726
+ }
1727
+
1728
+ if (failed) continue;
1729
+ report.resolved.push(key);
1730
+
1731
+ for (const recipe of settled) {
1732
+ const already = stored.get(recipe.key);
1733
+ if (already === undefined) {
1734
+ stored.set(recipe.key, recipe);
1735
+ continue;
1736
+ }
1737
+
1738
+ if (already.version !== recipe.version) {
1739
+ report.failed.push({
1740
+ key: recipe.key,
1741
+ reason:
1742
+ `Two of this project's subscriptions want different versions of ` +
1743
+ `'${recipe.key}': ${already.version} and ${recipe.version}. Sous kept ` +
1744
+ `${already.version}.\n` +
1745
+ ` Subscribe to '${recipe.key}' directly, with the range you want, so ` +
1746
+ `there is one answer.`,
1747
+ });
1748
+ continue;
1749
+ }
1750
+
1751
+ stored.set(recipe.key, mergeHolders(already, recipe));
1752
+ }
1753
+ }
1754
+
1755
+ if (stored.size === 0) return report;
1756
+
1757
+ const settledRecipes = [...stored.values()];
1758
+ const after = this.lock.applyResolution(before, settledRecipes, this.lockRepoInputs());
1759
+ for (const recipe of settledRecipes) {
1760
+ const previous = before.recipes[recipe.key];
1761
+ if (previous === undefined) {
1762
+ report.added.push({ key: recipe.key, version: recipe.version });
1763
+ } else if (previous.version !== recipe.version) {
1764
+ report.moved.push({
1765
+ key: recipe.key,
1766
+ from: previous.version,
1767
+ to: recipe.version,
1768
+ });
1769
+ }
1770
+ }
1771
+
1772
+ this.lock.write(after);
1773
+ return report;
1774
+ }
1775
+
1776
+ /**
1777
+ * True when the lockfile already pins everything one subscription asks for, at
1778
+ * a version its range still allows.
1779
+ *
1780
+ * @param key - The subscription's ref key: a namespace, or `namespace/recipe`.
1781
+ * @param entry - The subscription entry, which carries the range.
1782
+ * @param lock - The lockfile as it stands.
1783
+ */
1784
+ private subscriptionIsLocked(
1785
+ key: string,
1786
+ entry: SubscriptionEntry,
1787
+ lock: Lockfile
1788
+ ): boolean {
1789
+ const matches = key.includes("/")
1790
+ ? lock.recipes[key] === undefined
1791
+ ? []
1792
+ : [lock.recipes[key]!]
1793
+ : Object.entries(lock.recipes)
1794
+ .filter(([lockedKey]) => lockedKey.startsWith(`${key}/`))
1795
+ .map(([, locked]) => locked);
1796
+
1797
+ if (matches.length === 0) return false;
1798
+
1799
+ const range = entry.range;
1800
+ const includePrerelease = entry.prerelease === true;
1801
+
1802
+ return matches.every((locked) => {
1803
+ if (!locked.requestedBy.includes(PROJECT_HOLDER)) return false;
1804
+ if (range === undefined || range === "*") return true;
1805
+ return semver.satisfies(locked.version, range, { includePrerelease });
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Makes the store hold exactly what the lockfile pins. Nothing here decides a
1811
+ * version and nothing here asks a question; that is what makes a fresh clone
1812
+ * reproducible.
1813
+ */
1814
+ async restore(): Promise<RestoreReport> {
1815
+ await this.seedCore();
1816
+ const lock = this.lock.read();
1817
+ if (Object.keys(lock.recipes).length === 0) {
1818
+ return { restored: [], alreadyPresent: [] };
1819
+ }
1820
+
1821
+ const indexes = await this.loadIndexes(Object.keys(lock.repos), { lock });
1822
+ return this.lock.restore(lock, {
1823
+ store: this.storeInstance,
1824
+ indexes,
1825
+ providers: this.providers,
1826
+ providerOptions: this.providerOptions,
1827
+ });
1828
+ }
1829
+
1830
+ /**
1831
+ * Looks upstream for the repositories that prefer a newer in-range version,
1832
+ * and moves the lockfile to it when there is one. A failed check never breaks
1833
+ * a build: it is warned about and the last good answer stands.
1834
+ *
1835
+ * @param options - Whether to check regardless of the freshness window, and which window to use.
1836
+ */
1837
+ async checkUpstream(
1838
+ options: { force?: boolean; freshnessSeconds?: number } = {}
1839
+ ): Promise<UpstreamCheckReport> {
1840
+ const report: UpstreamCheckReport = { checked: [], updated: [], failed: [] };
1841
+ const lock = this.lock.read();
1842
+ if (Object.keys(lock.recipes).length === 0) return report;
1843
+
1844
+ const storeSettings = resolveStoreSettings(this.settings);
1845
+ const freshnessSeconds = options.freshnessSeconds ?? storeSettings.freshnessSeconds;
1846
+ const repos = this.currentRepos();
1847
+ const subscriptions = this.allSubscriptions();
1848
+
1849
+ let changed = false;
1850
+ const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
1851
+
1852
+ for (const repoName of Object.keys(lock.repos).sort()) {
1853
+ if (!this.prefersNewer(repoName, repos[repoName], lock, subscriptions)) continue;
1854
+
1855
+ const identity = this.identityForRepo(repoName, lock);
1856
+ if (identity === undefined) continue;
1857
+
1858
+ const meta = this.indexCache.readMeta(identity);
1859
+ const due = shouldCheckUpstream({
1860
+ ...(meta?.lastCheckedAt === undefined ? {} : { lastCheckedAt: meta.lastCheckedAt }),
1861
+ freshnessSeconds,
1862
+ alwaysPull: true,
1863
+ ...(options.force === undefined ? {} : { force: options.force }),
1864
+ now: this.now(),
1865
+ });
1866
+ if (!due) continue;
1867
+
1868
+ report.checked.push(repoName);
1869
+
1870
+ let index: IndexFile;
1871
+ try {
1872
+ const url = repos[repoName]?.url ?? lock.repos[repoName]!.url;
1873
+ const providerId = repos[repoName]?.provider;
1874
+ index = (
1875
+ await this.indexCache.getIndex(identity, {
1876
+ url,
1877
+ label: repoName,
1878
+ ...(providerId === undefined ? {} : { provider: providerId }),
1879
+ force: true,
1880
+ })
1881
+ ).index;
1882
+ } catch (error) {
1883
+ report.failed.push({ repo: repoName, reason: describeError(error) });
1884
+ // Recorded even though it failed, so an unreachable host is not retried
1885
+ // on every single build.
1886
+ recordUpstreamCheck(this.indexCache, identity, this.now());
1887
+ continue;
1888
+ }
1889
+
1890
+ recordUpstreamCheck(this.indexCache, identity, this.now());
1891
+
1892
+ for (const [key, entry] of Object.entries(recipes)) {
1893
+ if (entry.repo !== repoName) continue;
1894
+ const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
1895
+
1896
+ // Always-pull re-resolves WITHIN what was declared; it never widens it.
1897
+ // A recipe held only through another recipe's `depends` has no
1898
+ // subscription to read a range from, and treating that as "any version"
1899
+ // would move it straight past the constraint the dependency declared.
1900
+ const range = this.effectiveRangeFor(key, entry, subscriptions);
1901
+ if (range === undefined) continue;
1902
+
1903
+ const newer = findNewerInRange({
1904
+ index,
1905
+ key,
1906
+ lockedVersion: entry.version,
1907
+ ...(range === "*" ? {} : { range }),
1908
+ ...(subscription?.prerelease === undefined
1909
+ ? {}
1910
+ : { prerelease: subscription.prerelease }),
1911
+ });
1912
+ if (newer === undefined) continue;
1913
+
1914
+ const published = index.recipes[key]!.versions[newer.to]!;
1915
+ try {
1916
+ await this.fetchIntoStore({
1917
+ identity,
1918
+ key,
1919
+ version: newer.to,
1920
+ hash: published.hash,
1921
+ tag: published.tag,
1922
+ recipePath: index.recipes[key]!.path,
1923
+ url: repos[repoName]?.url ?? lock.repos[repoName]!.url,
1924
+ providerId: repos[repoName]?.provider,
1925
+ });
1926
+ } catch (error) {
1927
+ report.failed.push({ repo: repoName, reason: describeError(error) });
1928
+ continue;
1929
+ }
1930
+
1931
+ recipes[key] = { ...entry, version: newer.to, hash: published.hash };
1932
+ report.updated.push(newer);
1933
+ changed = true;
1934
+ }
1935
+ }
1936
+
1937
+ if (changed) this.lock.write({ ...lock, recipes });
1938
+ return report;
1939
+ }
1940
+
1941
+ /**
1942
+ * Everything a build needs done before it compiles: seed the packaged core
1943
+ * recipe, restore whatever else the store is missing, then look upstream for
1944
+ * the repositories that want a newer version. All three are quiet when there
1945
+ * is nothing to do.
1946
+ *
1947
+ * @param options - Whether to force the upstream check, and which freshness window to use.
1948
+ */
1949
+ async prepareForBuild(
1950
+ options: { force?: boolean; freshnessSeconds?: number } = {}
1951
+ ): Promise<{
1952
+ seed: SeedCoreRecipeReport;
1953
+ subscriptions: SubscriptionSyncReport;
1954
+ restored: RestoreReport | undefined;
1955
+ upstream: UpstreamCheckReport;
1956
+ }> {
1957
+ const seed = await this.seedCore();
1958
+ const subscriptions = await this.ensureSubscriptionsLocked();
1959
+ let restored: RestoreReport | undefined;
1960
+ if (this.needsRestore()) restored = await this.restore();
1961
+ const upstream = await this.checkUpstream(options);
1962
+ return { seed, subscriptions, restored, upstream };
1963
+ }
1964
+
1965
+ // --- Reading the project's state ---------------------------------------------------------------
1966
+
1967
+ /**
1968
+ * Every repository this project trusts, merging the config it was loaded with
1969
+ * and the managed layer as it stands on disk right now. The layer is re-read
1970
+ * because a trust round in this same process may have just written to it.
1971
+ */
1972
+ currentRepos(): Record<string, TrustedRepo> {
1973
+ return {
1974
+ ...(enabledRepos(this.settings) as Record<string, TrustedRepo>),
1975
+ ...this.trust.listManaged(),
1976
+ };
1977
+ }
1978
+
1979
+ /**
1980
+ * Every subscription in force, from the config and from the managed layer.
1981
+ *
1982
+ * The managed layer is re-read rather than taken from the loaded config,
1983
+ * because a command in this same process may have just written to it; an entry
1984
+ * it switches off is dropped here, so removing a subscription sous provides
1985
+ * itself really does stop it being locked and built.
1986
+ */
1987
+ allSubscriptions(): Record<string, SubscriptionEntry> {
1988
+ const merged: Record<string, SubscriptionEntry> = {
1989
+ ...(enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>),
1990
+ ...this.readSubscriptionEntries(),
1991
+ };
1992
+
1993
+ for (const [key, entry] of Object.entries(merged)) {
1994
+ if (entry?.enabled === false) delete merged[key];
1995
+ }
1996
+ return merged;
1997
+ }
1998
+
1999
+ /**
2000
+ * Every subscription this project declares, switched-off ones included, with
2001
+ * the versions the lockfile pins because of each. Switched-off entries are
2002
+ * kept because an opt-out is part of what a project subscribes to, and hiding
2003
+ * it would make `sous subscription list` disagree with the config.
2004
+ *
2005
+ * Reads only what is already on disk, so the listing is safe offline.
2006
+ */
2007
+ listSubscriptions(): SubscriptionListing[] {
2008
+ const entries: Record<string, SubscriptionEntry> = {
2009
+ ...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
2010
+ ...this.readSubscriptionEntries(),
2011
+ };
2012
+
2013
+ const lock = this.lock.read();
2014
+
2015
+ return Object.keys(entries)
2016
+ .sort()
2017
+ .map((key) => {
2018
+ const entry = entries[key]!;
2019
+ return {
2020
+ key,
2021
+ range: entry.range,
2022
+ enabled: entry.enabled !== false,
2023
+ addedBy: entry.addedBy,
2024
+ pinned: keysHeldBySubscription(lock, key).map((heldKey) => ({
2025
+ key: heldKey,
2026
+ version: lock.recipes[heldKey]!.version,
2027
+ })),
2028
+ };
2029
+ });
2030
+ }
2031
+
2032
+ /** The trusted repositories in the shape the resolver reads. */
2033
+ private resolverRepos(): Record<string, ResolverRepo> {
2034
+ const repos: Record<string, ResolverRepo> = {};
2035
+ for (const [name, entry] of Object.entries(this.currentRepos())) {
2036
+ repos[name] = {
2037
+ url: entry.url,
2038
+ identity: this.identityOf(entry.url, entry.provider),
2039
+ ...(entry.provider === undefined ? {} : { provider: entry.provider }),
2040
+ ...(entry.alwaysPull === undefined ? {} : { alwaysPull: entry.alwaysPull }),
2041
+ };
2042
+ }
2043
+ return repos;
2044
+ }
2045
+
2046
+ /** The repository records the lockfile writes, keyed by short name. */
2047
+ private lockRepoInputs(): Record<string, LockRepoInput> {
2048
+ const inputs: Record<string, LockRepoInput> = {};
2049
+ for (const [name, entry] of Object.entries(this.currentRepos())) {
2050
+ inputs[name] = {
2051
+ url: entry.url,
2052
+ identity: this.identityOf(entry.url, entry.provider),
2053
+ ...(entry.provider === undefined ? {} : { provider: entry.provider }),
2054
+ };
2055
+ }
2056
+ return inputs;
2057
+ }
2058
+
2059
+ /**
2060
+ * The cached (or freshly fetched) index of each named repository. A repository
2061
+ * whose index cannot be obtained at all is warned about and left out, so one
2062
+ * unreachable host never blocks work on the others.
2063
+ *
2064
+ * @param names - The repositories to load indexes for.
2065
+ * @param options - A lockfile to fall back to for a repository's URL.
2066
+ */
2067
+ async loadIndexes(
2068
+ names: string[],
2069
+ options: { lock?: Lockfile; force?: boolean } = {}
2070
+ ): Promise<Map<string, IndexFile>> {
2071
+ const repos = this.currentRepos();
2072
+ const indexes = new Map<string, IndexFile>();
2073
+ const freshnessSeconds = resolveStoreSettings(this.settings).freshnessSeconds;
2074
+
2075
+ for (const name of names) {
2076
+ const url = repos[name]?.url ?? options.lock?.repos[name]?.url;
2077
+ if (url === undefined) continue;
2078
+
2079
+ try {
2080
+ const identity =
2081
+ this.identityForRepo(name, options.lock) ??
2082
+ this.identityOf(url, repos[name]?.provider);
2083
+ const lookup = await this.indexCache.getIndex(identity, {
2084
+ url,
2085
+ label: name,
2086
+ ...(repos[name]?.provider === undefined
2087
+ ? {}
2088
+ : { provider: repos[name]!.provider! }),
2089
+ maxAgeSeconds: freshnessSeconds,
2090
+ ...(options.force === undefined ? {} : { force: options.force }),
2091
+ });
2092
+ indexes.set(name, lookup.index);
2093
+ } catch (error) {
2094
+ this.warn(
2095
+ `Sous could not read the index of the repository '${name}', so nothing in it ` +
2096
+ `can be resolved right now.\n${describeError(error)}`
2097
+ );
2098
+ }
2099
+ }
2100
+
2101
+ return indexes;
2102
+ }
2103
+
2104
+ // --- The store ---------------------------------------------------------------------------------
2105
+
2106
+ /**
2107
+ * The directory a resolved recipe's files are read from: a linked working copy
2108
+ * when the repository is linked, otherwise its store entry.
2109
+ *
2110
+ * @param recipe - The resolved recipe.
2111
+ */
2112
+ private recipeDirectory(recipe: ResolvedRecipe): string {
2113
+ const checkout = linkedPathFor(recipe.repo, this.sousDir, this.env);
2114
+ if (checkout !== undefined) {
2115
+ const linked = mapLinkedRecipes(checkout)[recipe.key];
2116
+ if (linked !== undefined) return linked;
2117
+ }
2118
+ return this.storeInstance.entryDir(storeKeyFor(recipe));
2119
+ }
2120
+
2121
+ /**
2122
+ * Makes sure a resolved recipe's files are in the store, fetching them when
2123
+ * they are not. A linked repository is read from its checkout and is never
2124
+ * fetched.
2125
+ *
2126
+ * @param recipe - The resolved recipe.
2127
+ */
2128
+ private async ensureStored(recipe: ResolvedRecipe): Promise<void> {
2129
+ if (linkedPathFor(recipe.repo, this.sousDir, this.env) !== undefined) return;
2130
+
2131
+ const key = storeKeyFor(recipe);
2132
+ const hit = await this.storeInstance.get(key);
2133
+ if (hit !== undefined && hit.entry.hash === recipe.hash) return;
2134
+
2135
+ // The store refuses to overwrite an entry whose content differs, because a
2136
+ // published version is immutable and one that changed underneath a project
2137
+ // is worth refusing loudly. There is exactly one entry that is not a
2138
+ // published version: the packaged core recipe sous seeds so a project can
2139
+ // build before it has ever reached the network. That copy is a stand-in for
2140
+ // the published one, not a rival to it, so when the two disagree at the same
2141
+ // version it steps aside and the published copy is fetched over it.
2142
+ if (hit !== undefined && (await this.isSeededCoreEntry(key, hit.entry.hash))) {
2143
+ await this.storeInstance.remove(key);
2144
+ }
2145
+
2146
+ const repos = this.currentRepos();
2147
+ const url = repos[recipe.repo]?.url;
2148
+ if (url === undefined) {
2149
+ throw new ConfigError(
2150
+ `Sous cannot fetch '${recipe.key}' because it no longer knows where the ` +
2151
+ `repository '${recipe.repo}' lives.\n` +
2152
+ ` Add it with 'sous repo add <url> --name ${recipe.repo}'.`
2153
+ );
2154
+ }
2155
+
2156
+ await this.fetchIntoStore({
2157
+ identity: recipe.identity,
2158
+ key: recipe.key,
2159
+ version: recipe.version,
2160
+ hash: recipe.hash,
2161
+ tag: recipe.tag,
2162
+ recipePath: recipe.path,
2163
+ url,
2164
+ providerId: repos[recipe.repo]?.provider,
2165
+ });
2166
+ }
2167
+
2168
+ /**
2169
+ * True when a store entry is the packaged core recipe that seeding put there,
2170
+ * rather than anything fetched from a repository.
2171
+ *
2172
+ * The test is deliberately exact: the entry has to be the core recipe in the
2173
+ * official repository, AND its content has to hash to what this installation's
2174
+ * package holds. An entry that was genuinely fetched, or a seeded entry from a
2175
+ * different installation, is left alone.
2176
+ *
2177
+ * @param key - The store key being written to.
2178
+ * @param storedHash - The hash the entry currently holds.
2179
+ */
2180
+ private async isSeededCoreEntry(key: StoreKey, storedHash: string): Promise<boolean> {
2181
+ if (key.identity !== OFFICIAL_REPO_IDENTITY) return false;
2182
+ if (`${key.namespace}/${key.name}` !== CORE_RECIPE_KEY) return false;
2183
+
2184
+ try {
2185
+ return (await hashDirectory(packagedCoreRecipeDir())) === storedHash;
2186
+ } catch {
2187
+ // No packaged recipe to compare against means nothing here was seeded.
2188
+ return false;
2189
+ }
2190
+ }
2191
+
2192
+ /**
2193
+ * Fetches one recipe version into the store, verifying it against the hash the
2194
+ * index publishes. The download lands in a temporary directory beside the
2195
+ * store, so a failed fetch never leaves a half-written entry behind.
2196
+ *
2197
+ * @param request - Which version to fetch, from where, and at which tag.
2198
+ */
2199
+ private async fetchIntoStore(request: {
2200
+ identity: string;
2201
+ key: string;
2202
+ version: string;
2203
+ hash: string;
2204
+ tag: string;
2205
+ recipePath: string;
2206
+ url: string;
2207
+ providerId?: string;
2208
+ }): Promise<void> {
2209
+ const provider = requireProvider(request.url, request.providerId, this.providers);
2210
+ const canonical = provider.canonicalize(request.url);
2211
+
2212
+ const namespace = request.key.slice(0, request.key.indexOf("/"));
2213
+ const key: StoreKey = {
2214
+ identity: request.identity,
2215
+ namespace,
2216
+ name: request.key.slice(namespace.length + 1),
2217
+ version: request.version,
2218
+ };
2219
+
2220
+ ensureStoreRootDirectory(this.storeInstance.root);
2221
+ const workDir = await fsp.mkdtemp(path.join(this.storeInstance.root, ".sous-fetch-"));
2222
+ const fetchDir = path.join(workDir, key.name);
2223
+ try {
2224
+ await provider.fetchRecipeTree(
2225
+ canonical,
2226
+ request.recipePath,
2227
+ request.tag,
2228
+ fetchDir,
2229
+ this.providerOptions
2230
+ );
2231
+ await this.storeInstance.put(key, fetchDir, request.hash);
2232
+ } finally {
2233
+ await fsp.rm(workDir, { recursive: true, force: true });
2234
+ }
2235
+ }
2236
+
2237
+ /**
2238
+ * The manifest loader the resolver walks the dependency closure with. It
2239
+ * fetches the recipe when the store does not hold it, which is the seam where
2240
+ * "resolve" turns into "download"; a dry run refuses to fetch and reads only
2241
+ * what is already there.
2242
+ *
2243
+ * @param recipe - The resolved recipe whose manifest is wanted.
2244
+ * @param dryRun - When true, read what is on disk and download nothing.
2245
+ */
2246
+ private async loadRecipeManifest(
2247
+ recipe: ResolvedRecipe,
2248
+ dryRun: boolean
2249
+ ): Promise<RecipeManifest | undefined> {
2250
+ const directory = this.recipeDirectory(recipe);
2251
+ const existing = readRecipeManifestIn(directory);
2252
+ if (existing !== undefined) return existing;
2253
+ if (dryRun) return undefined;
2254
+
2255
+ await this.ensureStored(recipe);
2256
+ return readRecipeManifestIn(this.recipeDirectory(recipe));
2257
+ }
2258
+
2259
+ // --- The managed subscriptions layer -----------------------------------------------------------
2260
+
2261
+ /** Every subscription written in the managed layer, keyed by ref key. */
2262
+ private readSubscriptionEntries(): Record<string, SubscriptionEntry> {
2263
+ const layer = readManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
2264
+ confDir: this.confDir,
2265
+ });
2266
+ const entries = layer["subscriptions"];
2267
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return {};
2268
+ return entries as Record<string, SubscriptionEntry>;
2269
+ }
2270
+
2271
+ /**
2272
+ * Records one subscription in the managed layer, editing only that entry.
2273
+ *
2274
+ * @param key - The ref key the subscription is recorded under.
2275
+ * @param parsed - The ref as parsed, for its version range.
2276
+ * @param options - The prerelease and always-pull flags.
2277
+ */
2278
+ private writeSubscriptionEntry(
2279
+ key: string,
2280
+ parsed: ParsedRef,
2281
+ options: SubscribeOptions
2282
+ ): void {
2283
+ const entry: SubscriptionEntry = {
2284
+ ...(parsed.range === undefined ? {} : { range: parsed.range }),
2285
+ ...(options.prerelease === true ? { prerelease: true } : {}),
2286
+ ...(options.alwaysPull === true ? { alwaysPull: true } : {}),
2287
+ addedAt: this.now().toISOString(),
2288
+ addedBy: USER_ADDED_BY,
2289
+ };
2290
+
2291
+ updateManagedLayer(
2292
+ this.sousDir,
2293
+ SUBSCRIPTIONS_LAYER_FILENAME,
2294
+ [{ path: ["subscriptions", key], value: entry }],
2295
+ { confDir: this.confDir }
2296
+ );
2297
+ }
2298
+
2299
+ // --- Lockfile bookkeeping ----------------------------------------------------------------------
2300
+
2301
+ /**
2302
+ * The lockfile keys one subscription holds directly. The rule itself lives in
2303
+ * `keysHeldBySubscription` (`locked-recipes.ts`), beside the other readers of
2304
+ * the lockfile's holder lists.
2305
+ *
2306
+ * @param lock - The lockfile as it stands.
2307
+ * @param key - The subscription's ref key.
2308
+ */
2309
+ private keysHeldBySubscription(lock: Lockfile, key: string): string[] {
2310
+ return keysHeldBySubscription(lock, key);
2311
+ }
2312
+
2313
+ /**
2314
+ * Drops the project's own hold on one recipe. When something else still holds
2315
+ * it the entry stays; when nothing does, the entry goes and everything it
2316
+ * pulled in is reconsidered, which is what makes removal refcounted.
2317
+ *
2318
+ * @param lock - The lockfile as it stands.
2319
+ * @param key - The recipe key to release.
2320
+ */
2321
+ private dropProjectHold(lock: Lockfile, key: string): Lockfile {
2322
+ const entry = lock.recipes[key];
2323
+ if (entry === undefined) return lock;
2324
+
2325
+ const kept = entry.requestedBy.filter((holder) => holder !== PROJECT_HOLDER);
2326
+ const recipes = { ...lock.recipes };
2327
+
2328
+ if (kept.length > 0) {
2329
+ recipes[key] = { ...entry, requestedBy: kept };
2330
+ return { ...lock, recipes };
2331
+ }
2332
+
2333
+ delete recipes[key];
2334
+ return this.lock.removeHolder({ ...lock, recipes }, key);
2335
+ }
2336
+
2337
+ /**
2338
+ * True when a repository prefers a newer in-range version over the locked one,
2339
+ * whether the flag is on the repository itself or on any subscription that
2340
+ * resolves inside it.
2341
+ *
2342
+ * @param repoName - The repository's short name.
2343
+ * @param repo - Its config entry, when it still has one.
2344
+ * @param lock - The lockfile, for which recipes came from it.
2345
+ * @param subscriptions - Every subscription, from the config and the managed layer.
2346
+ */
2347
+ private prefersNewer(
2348
+ repoName: string,
2349
+ repo: TrustedRepo | undefined,
2350
+ lock: Lockfile,
2351
+ subscriptions: Record<string, SubscriptionEntry>
2352
+ ): boolean {
2353
+ if (repo?.alwaysPull === true) return true;
2354
+
2355
+ for (const [key, entry] of Object.entries(subscriptions)) {
2356
+ if (entry.alwaysPull !== true) continue;
2357
+ const keys = this.keysHeldBySubscription(lock, key);
2358
+ if (keys.some((held) => lock.recipes[held]!.repo === repoName)) return true;
2359
+ }
2360
+
2361
+ return false;
2362
+ }
2363
+
2364
+ /**
2365
+ * The version range an always-pull check may move one locked recipe within,
2366
+ * or undefined when sous cannot tell and therefore must not move it.
2367
+ *
2368
+ * The rule itself lives in `effectiveRangeForHolders`; this supplies it with
2369
+ * the two lookups it needs, one reading the project's subscriptions and one
2370
+ * reading the manifest of each holding recipe.
2371
+ *
2372
+ * @param key - The locked recipe key, `namespace/recipe`.
2373
+ * @param entry - Its lockfile entry, for its holders.
2374
+ * @param subscriptions - Every subscription, from the config and the managed layer.
2375
+ */
2376
+ private effectiveRangeFor(
2377
+ key: string,
2378
+ entry: LockedRecipe,
2379
+ subscriptions: Record<string, SubscriptionEntry>
2380
+ ): string | undefined {
2381
+ const namespace = key.split("/")[0]!;
2382
+ return effectiveRangeForHolders(key, entry.requestedBy, {
2383
+ subscriptionRange: (held) => {
2384
+ // A subscription the config no longer declares is not a hold sous can
2385
+ // read a range from; leave the entry alone rather than guessing.
2386
+ const subscription = subscriptions[held] ?? subscriptions[namespace];
2387
+ return subscription === undefined ? undefined : (subscription.range ?? "*");
2388
+ },
2389
+ dependencyRange: (holder, held) =>
2390
+ this.declaredDependencyRange(holder, held, namespace),
2391
+ });
2392
+ }
2393
+
2394
+ /**
2395
+ * The range one recipe's manifest declares for a dependency, or undefined when
2396
+ * its manifest cannot be read or no longer names that dependency.
2397
+ *
2398
+ * @param holder - The holding recipe's key, `namespace/recipe`.
2399
+ * @param key - The held recipe's key.
2400
+ * @param namespace - The held recipe's namespace, since a `depends` entry may
2401
+ * name a whole namespace rather than one recipe.
2402
+ */
2403
+ private declaredDependencyRange(
2404
+ holder: string,
2405
+ key: string,
2406
+ namespace: string
2407
+ ): string | undefined {
2408
+ const directory = this.lockedRecipeDirectories()[holder];
2409
+ if (directory === undefined) return undefined;
2410
+
2411
+ let manifest;
2412
+ try {
2413
+ manifest = readRecipeManifestIn(directory);
2414
+ } catch {
2415
+ return undefined;
2416
+ }
2417
+ if (manifest === undefined) return undefined;
2418
+
2419
+ for (const dependency of manifest.depends ?? []) {
2420
+ let parsed: ParsedRef;
2421
+ try {
2422
+ parsed = parseRef(dependency);
2423
+ } catch {
2424
+ continue;
2425
+ }
2426
+ const dependencyKey = refKey(parsed);
2427
+ if (dependencyKey !== key && dependencyKey !== namespace) continue;
2428
+ return parsed.range ?? "*";
2429
+ }
2430
+
2431
+ return undefined;
2432
+ }
2433
+
2434
+ /**
2435
+ * Where each locked recipe's files are, keyed by recipe key. Read once per
2436
+ * command, because an always-pull check asks for the same answer for every
2437
+ * entry in the lockfile.
2438
+ */
2439
+ private lockedRecipeDirectories(): Record<string, string> {
2440
+ if (this.lockedDirectories === undefined) {
2441
+ this.lockedDirectories = {};
2442
+ for (const located of listLockedRecipes({ sousDir: this.sousDir, env: this.env })) {
2443
+ if (located.present) this.lockedDirectories[located.key] = located.dir;
2444
+ }
2445
+ }
2446
+ return this.lockedDirectories;
2447
+ }
2448
+
2449
+ // --- Variables ----------------------------------------------------------------------------------
2450
+
2451
+ /**
2452
+ * Every variable definition the resolved closure publishes, attributed to the
2453
+ * recipe that declared it and to the chain that pulled it in.
2454
+ *
2455
+ * A recipe whose files are not on this machine contributes nothing rather
2456
+ * than failing, which is what lets a dry run describe as much of the closure
2457
+ * as it can without downloading any of it.
2458
+ *
2459
+ * @param resolved - The recipe versions the resolution settled on.
2460
+ */
2461
+ private definedVariables(resolved: ResolvedRecipe[]): DefinedVariable[] {
2462
+ const defined: DefinedVariable[] = [];
2463
+ const byKey = new Map(resolved.map((recipe) => [recipe.key, recipe]));
2464
+ const repos = this.currentRepos();
2465
+
2466
+ /** One resolved recipe, described the way the variables layer shows it. */
2467
+ const describe = (recipe: ResolvedRecipe): DefiningRecipe => {
2468
+ const url = repos[recipe.repo]?.url;
2469
+ return {
2470
+ repo: recipe.repo,
2471
+ namespace: recipe.namespace,
2472
+ name: recipe.name,
2473
+ version: recipe.version,
2474
+ path: recipe.path,
2475
+ dir: this.recipeDirectory(recipe),
2476
+ ...(url === undefined ? {} : { url }),
2477
+ };
2478
+ };
2479
+
2480
+ /** How a recipe came to be here: the subscribed recipe first, then each holder. */
2481
+ const chainFor = (recipe: ResolvedRecipe): ResolvedRecipe[] => {
2482
+ const chain = [recipe];
2483
+ const seen = new Set([recipe.key]);
2484
+ let current = recipe;
2485
+
2486
+ while (!current.requestedBy.includes(PROJECT_HOLDER)) {
2487
+ const holderKey = current.requestedBy.find(
2488
+ (holder) => holder !== PROJECT_HOLDER && byKey.has(holder) && !seen.has(holder)
2489
+ );
2490
+ if (holderKey === undefined) break;
2491
+ current = byKey.get(holderKey)!;
2492
+ seen.add(holderKey);
2493
+ chain.unshift(current);
2494
+ }
2495
+
2496
+ return chain;
2497
+ };
2498
+
2499
+ // The subscribed recipe's own questions come first, then each dependency in
2500
+ // the order the closure reached it, so the run reads the way it happened.
2501
+ const ordered = [...resolved].sort((left, right) => {
2502
+ const depth = chainFor(left).length - chainFor(right).length;
2503
+ return depth !== 0 ? depth : left.key.localeCompare(right.key);
2504
+ });
2505
+
2506
+ for (const recipe of ordered) {
2507
+ const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
2508
+ if (manifest === undefined) continue;
2509
+
2510
+ const publisher = describe(recipe);
2511
+ const requiredBy = chainFor(recipe).map(describe);
2512
+ for (const definition of manifest.variables ?? []) {
2513
+ defined.push({ definition, recipe: publisher, requiredBy });
2514
+ }
2515
+ }
2516
+
2517
+ return defined;
2518
+ }
2519
+
2520
+ /**
2521
+ * The recipes in a resolution whose manifests cannot be read from this
2522
+ * machine, because neither the store nor a linked checkout holds their files
2523
+ * yet. A dry run downloads nothing, so this is exactly the set whose
2524
+ * questions it cannot describe; everything else is described in full.
2525
+ *
2526
+ * @param resolved - The recipe versions the resolution settled on.
2527
+ */
2528
+ private unreadableRecipes(resolved: ResolvedRecipe[]): string[] {
2529
+ return resolved
2530
+ .filter((recipe) => readRecipeManifestIn(this.recipeDirectory(recipe)) === undefined)
2531
+ .map((recipe) => recipe.key)
2532
+ .sort();
2533
+ }
2534
+
2535
+ /** The environment layers and mapping records this project resolves against. */
2536
+ private ladderContext(): LadderContext {
2537
+ return loadLadderContext({
2538
+ sousDir: this.sousDir,
2539
+ settings: this.settings,
2540
+ shellEnv: this.shellEnv,
2541
+ });
2542
+ }
2543
+
2544
+ /**
2545
+ * Asks for the variables the newly resolved recipes publish, keeping and
2546
+ * reporting whatever answers were already in scope. Answers supplied ahead of
2547
+ * the questions are validated and stored first, so only what is left over is
2548
+ * asked for. A run with no terminal fails naming the exact environment
2549
+ * variables that would answer each remaining question, which is what the
2550
+ * variables layer does everywhere.
2551
+ *
2552
+ * @param resolved - The recipe versions the resolution settled on.
2553
+ * @param provided - Answers supplied ahead of the questions.
2554
+ */
2555
+ private async askVariables(
2556
+ resolved: ResolvedRecipe[],
2557
+ provided: ProvidedAnswer[]
2558
+ ): Promise<AskReport | undefined> {
2559
+ const defined = this.definedVariables(resolved);
2560
+ if (defined.length === 0 && provided.length === 0) return undefined;
2561
+
2562
+ const context = this.ladderContext();
2563
+ const options = {
2564
+ sousDir: this.sousDir,
2565
+ confDir: this.confDir,
2566
+ interactive: this.interactive,
2567
+ };
2568
+
2569
+ // A supplied answer naming a variable nothing declares fails here, before
2570
+ // any question is asked and before anything is stored.
2571
+ const supplied = applyProvidedAnswers(defined, provided, context, options);
2572
+
2573
+ const report = await askForMissing(defined, context, {
2574
+ ...options,
2575
+ skip: supplied.keys,
2576
+ });
2577
+ report.answered.unshift(...supplied.stored);
2578
+ return report;
2579
+ }
2580
+ }
2581
+
2582
+ // --- Helpers ------------------------------------------------------------------------------------
2583
+
2584
+ /**
2585
+ * Builds the subscription service for a running command, from what every
2586
+ * command already has: where its config was discovered, the merged settings,
2587
+ * and the shell environment as it was before the `.sous/` env files were loaded.
2588
+ *
2589
+ * @param options - The discovered config context, the settings, and the shell environment.
2590
+ */
2591
+ export function subscriptionServiceFor(options: {
2592
+ /** Where the active config was found. */
2593
+ configContext: ConfigContext;
2594
+ /** The merged project config. */
2595
+ settings: Settings;
2596
+ /** The shell environment as it was before the env files were injected. */
2597
+ shellEnv?: NodeJS.ProcessEnv;
2598
+ /** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
2599
+ interactive?: boolean;
2600
+ }): SubscriptionService {
2601
+ return new SubscriptionService({
2602
+ sousDir: options.configContext.sousDir,
2603
+ ...(options.configContext.confDir === undefined
2604
+ ? {}
2605
+ : { confDir: options.configContext.confDir }),
2606
+ settings: options.settings,
2607
+ ...(options.shellEnv === undefined ? {} : { shellEnv: options.shellEnv }),
2608
+ ...(options.interactive === undefined ? {} : { interactive: options.interactive }),
2609
+ });
2610
+ }
2611
+
2612
+ /** One subscription entry, as it is written into the managed layer. */
2613
+ export type SubscriptionEntry = {
2614
+ /**
2615
+ * Whether the subscription takes part in anything. Defaults to true. Sous
2616
+ * writes `false` when a subscription it provides itself is removed, because
2617
+ * the default comes back on every run and only a recorded opt-out outlives it.
2618
+ */
2619
+ enabled?: boolean;
2620
+ /** The semantic version range to resolve within. */
2621
+ range?: string;
2622
+ /** Whether prerelease versions take part in range matching. */
2623
+ prerelease?: boolean;
2624
+ /** Whether a newer in-range version is preferred over the locked one. */
2625
+ alwaysPull?: boolean;
2626
+ /** When the subscription was added. */
2627
+ addedAt?: string;
2628
+ /** Who required it: "user", or the ref of the recipe that co-subscribed it. */
2629
+ addedBy?: string;
2630
+ };
2631
+
2632
+ /**
2633
+ * Combines two resolutions of the SAME recipe version into one, so the lockfile
2634
+ * records every holder rather than only the last resolution's.
2635
+ *
2636
+ * This matters for removal: a recipe several subscriptions depend on has to
2637
+ * survive unsubscribing from one of them, and the lockfile's refcounting is what
2638
+ * decides that. A recipe anyone holds as a co-subscription is a co-subscription;
2639
+ * it is only a build dependency while nothing subscribes to it.
2640
+ *
2641
+ * @param left - The resolution already recorded.
2642
+ * @param right - The resolution to fold into it.
2643
+ */
2644
+ function mergeHolders(left: ResolvedRecipe, right: ResolvedRecipe): ResolvedRecipe {
2645
+ const requestedBy = [...new Set([...left.requestedBy, ...right.requestedBy])].sort();
2646
+
2647
+ const ranges = [...left.ranges];
2648
+ for (const entry of right.ranges) {
2649
+ const known = ranges.some(
2650
+ (seen) => seen.range === entry.range && seen.requestedBy === entry.requestedBy
2651
+ );
2652
+ if (!known) ranges.push(entry);
2653
+ }
2654
+
2655
+ return {
2656
+ ...left,
2657
+ requestedBy,
2658
+ ranges,
2659
+ kind:
2660
+ left.kind === "subscribes" || right.kind === "subscribes" ? "subscribes" : "depends",
2661
+ };
2662
+ }
2663
+
2664
+ /** The store key a resolved recipe is filed under. */
2665
+ function storeKeyFor(recipe: ResolvedRecipe): StoreKey {
2666
+ return {
2667
+ identity: recipe.identity,
2668
+ namespace: recipe.namespace,
2669
+ name: recipe.name,
2670
+ version: recipe.version,
2671
+ };
2672
+ }
2673
+
2674
+ /** The message of an error, whichever kind it turned out to be. */
2675
+ function describeError(error: unknown): string {
2676
+ if (isConfigError(error)) return (error as ConfigError).message;
2677
+ return error instanceof Error ? error.message : String(error);
2678
+ }