@sous-io/sous 0.2.29 → 0.2.30

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.
@@ -28,3 +28,4 @@
28
28
  - [0007: Frozen index entries and tolerant readers](adrs/0007-frozen-index-dependencies.md)
29
29
  - [0008: One ref parser, told where the ref came from](adrs/0008-one-ref-parser.md)
30
30
  - [0009: One whole build, everywhere](adrs/0009-whole-build-everywhere.md)
31
+ - [0010: The index describes each version's dependencies and questions](adrs/0010-index-describes-recipes.md)
@@ -347,8 +347,9 @@ optionally carrying an `@<range>` and a `repo:` qualifier; see
347
347
  ### `sous subscription add REF`
348
348
  Subscribes this project to a recipe, or to a whole namespace of them, then builds the project so the recipe's
349
349
  files are on disk when the command returns. Also spelled `sous subscribe`. Takes `-y, --yes`, `--no-build`, and
350
- `--dry-run`, which also prints the questions of every recipe this machine already holds; a recipe not held here
351
- yet is named instead, because a dry run downloads nothing.
350
+ `--dry-run`, which also prints every question the recipes would ask. A dry run downloads nothing, so a recipe
351
+ not on this machine is described from its repository's index; one whose index entry predates the release that
352
+ began recording questions there is named instead.
352
353
 
353
354
  - `--prerelease`: let prerelease versions take part in range matching.
354
355
  - `--always-pull`: install a newer in-range version whenever one exists, rather than holding the lock.
@@ -409,10 +410,13 @@ Lists the recipes the trusted repositories publish, across every namespace, with
409
410
  `namespace show` prints. Example: `sous recipe list --installed --latest`
410
411
 
411
412
  ### `sous recipe show REF`
412
- Describes one recipe completely: its repository and location, every published version, its dependencies as
413
- declared and as resolved at release time, the questions it asks with the environment variable each answer is
414
- stored under, and the directories its files are written into. `REF` is `namespace/recipe`, a recipe name alone,
415
- or either with a `repository:` qualifier. Example: `sous recipe show sous-recipes:core/about-sous`
413
+ Describes one recipe completely: its repository and location, every published version, its dependencies (the
414
+ manifest entry bringing each one in, such as a whole namespace, whether it is a co-subscription or a build
415
+ dependency, and the version it was released against), every recipe a subscription to it would install, every
416
+ question that would ask, laid out as `sous subscription add --dry-run` lays them out, and the directories its
417
+ files are written into. Everything but the files comes from the index, so a recipe nothing has installed yet
418
+ is described in full, offline. `REF` is `namespace/recipe`, a recipe name alone, or either with a
419
+ `repository:` qualifier. Example: `sous recipe show omakase/house`
416
420
 
417
421
  ## lock
418
422
 
@@ -243,9 +243,13 @@ this release's scope** is fine: the release depends on the last published versio
243
243
  ```
244
244
 
245
245
  Each version's resolved dependencies are written into the index, so a consumer installing that version gets what
246
- it was published with rather than ranges re-resolved months later. A published version never changes: its content
247
- hash and its dependencies are carried forward exactly as published, and a disagreement is an error telling you
248
- to bump rather than republish ([what is checked](repositories-file-formats.md#sousindexjson)). A version counts
246
+ it was published with rather than ranges re-resolved months later. Each dependency also records the manifest
247
+ entry that brought it in and whether it is a co-subscription or a build dependency, and each version records the
248
+ variable definitions its manifest publishes, so `sous recipe show` and `sous subscription add --dry-run` can say
249
+ what a recipe installs and asks before anything is fetched. A published version never changes: its content
250
+ hash, its dependencies and its variable definitions are carried forward exactly as published, and a
251
+ disagreement is an error telling you to bump rather than republish
252
+ ([what is checked](repositories-file-formats.md#sousindexjson)). A version counts
249
253
  as published once its tag exists, and the tags are the backstop, so a tagged version missing from the index is
250
254
  rebuilt from it, dependencies included, whenever the index is regenerated, which only happens on a run that
251
255
  publishes something. A bump edits the manifest in place, so comments, field order and layout survive; a folded
@@ -65,22 +65,35 @@ The cache can be behind what a repository has published since. Every one of thes
65
65
  `sous recipe list --installed --latest` is the out-of-date view: every installed recipe, with
66
66
  upstream's newest version beside the installed one.
67
67
 
68
- Read `sous recipe show` before subscribing: every published version, what it depends on (as
69
- declared, beside the version the index resolved it to), and its questions and files once it has
70
- them here.
68
+ Read `sous recipe show` before subscribing. It works offline, from the index alone: every published
69
+ version, what the version depends on (the manifest entry that brings each dependency in, whether it
70
+ is a co-subscription whose files land here or a build dependency, and the version it was released
71
+ against), every recipe a subscription would install, and every question that would ask. Only the
72
+ files it publishes wait until it is installed. A preset set, a recipe that only bundles others,
73
+ reads like this:
71
74
 
72
75
  ```term
73
- $ sous recipe show workflow/qa-variables
74
- Latest version : 0.1.0
76
+ $ sous recipe show omakase/house
77
+ Latest version : 1.0.0
75
78
  Pinned version : this project pins none
76
79
  Subscribed : no
77
- Dependency Declared as Resolved to Repository Kind
78
- ------------------ ------------------------------ ----------- --------------- ----------------
79
- workflow/qa-helper not declared in the manifest 0.1.0 this repository unknown
80
- The recipe's own files are not on this machine, so the questions it asks and the files it
81
- publishes are not known here. Subscribing to it fetches them.
80
+ Dependency Declared as Resolved to Repository Kind
81
+ --------------- ------------------------------ ----------- --------------- ----------------
82
+ tools/lint tools/lint 1.0.0 this repository build dependency
83
+ workflow/notes the whole 'workflow' namespace 1.0.0 this repository co-subscription
84
+ workflow/review the whole 'workflow' namespace 1.0.0 this repository co-subscription
85
+ ➔ What subscribing asks you:
86
+ These recipes ask 3 questions, 3 of which nothing answers yet.
87
+ workflow/notes asks 1 question:
88
+ notesDir
89
+ about : Where the notes skill keeps its notes.
90
+ stored-as : SOUS_VAR_NOTES_DIR
91
+ answered : no, and this recipe requires an answer
82
92
  ```
83
93
 
94
+ ?> A recipe released before sous recorded questions in the index is described from its files
95
+ instead, so its questions are listed once it is installed, and named as unknown until then.
96
+
84
97
  ## Subscribe to a recipe
85
98
 
86
99
  ```bash
@@ -206,8 +219,9 @@ stored value nothing reads. Names are camelCase, as the recipe declares them; th
206
219
  `namespace/recipe.name` key works too when two recipes publish the same name, and everything after
207
220
  the first `=` is the answer. `--answers-file answers.yaml` reads a YAML or JSON file of them.
208
221
 
209
- ?> A dry run downloads nothing, so a recipe your machine does not hold yet has no manifest to read
210
- and its questions cannot be listed; the run still succeeds and names them.
222
+ ?> A dry run downloads nothing. A recipe your machine does not hold yet is described from the
223
+ questions its repository's index records; one released before sous recorded questions there cannot
224
+ be, so the run still succeeds and names it.
211
225
 
212
226
  ## See what you have
213
227
 
@@ -201,8 +201,16 @@ fetches only this file; nothing more is downloaded until a project subscribes.
201
201
  "namespaces": { "quality": { "description": "Recipes that exercise the edges." } },
202
202
  "recipes": { "quality/qa-remote-dep": { "path": "recipes/quality/qa-remote-dep",
203
203
  "versions": { "0.1.0": {
204
- "dependencies": { "workflow/qa-helper": { "version": "0.1.0" },
205
- "workflow/sub-agent-delegation": { "range": "^1.0", "repo": "github.com/sous-io/sous-recipes" } },
204
+ "dependencies": {
205
+ "workflow/qa-helper": { "version": "0.1.0", "declared": "workflow", "kind": "subscribes" },
206
+ "workflow/sub-agent-delegation": { "range": "^1.0", "repo": "github.com/sous-io/sous-recipes",
207
+ "declared": "github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0",
208
+ "kind": "depends" } },
209
+ "depends": [ "github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0" ],
210
+ "subscribes": [ "workflow" ],
211
+ "variables": [ { "name": "qaNotesDir", "type": "path", "prompt": "Where should notes go?",
212
+ "description": "The directory QA notes are written to.", "example": "docs/qa",
213
+ "default": ".sous/qa-notes", "required": true, "secret": false, "scope": "shared" } ],
206
214
  "hash": "sha256-46e75442aeb368116c8830ad382707759f1ecd03974c4b5d42a5fabffce0324f",
207
215
  "prerelease": false, "releasedAt": "2026-09-12T07:06:33.236Z",
208
216
  "tag": "quality/qa-remote-dep@0.1.0"
@@ -216,12 +224,30 @@ fetches only this file; nothing more is downloaded until a project subscribes.
216
224
  | `recipes` | top level | Keyed `namespace/recipe`; a recipe whose namespace is not declared is an error. Each entry carries `path` (the recipe folder), `description`, and `versions`, keyed by exact version |
217
225
  | `hash`, `tag` | per version | The content hash verified after every fetch, and the tag, which must be exactly `namespace/recipe@version` so a version can never point at a branch |
218
226
  | `prerelease`, `releasedAt`, `seeded` | per version | Whether ranges skip it unless a subscription opts in, when it was released, and whether it is the copy sous folds in from its own package |
227
+ | `dependencies` | per version | What the version was released against, keyed `namespace/recipe`; see below |
228
+ | `variables` | per version | The version's variable definitions, exactly as its manifest publishes them (see [Variable definitions](#variable-definitions)), with `required`, `secret` and `scope` filled in; `[]` when it asks nothing |
229
+ | `depends`, `subscribes` | per version | The version's manifest lists, exactly as written; `[]` when empty |
219
230
 
220
231
  Dependencies are resolved at release time and keyed `namespace/recipe`. An entry carries `version` (a
221
232
  sibling, resolved exactly) or `range` plus `repo` (the identity of the repository publishing it, whose own
222
233
  index resolves the range); at least one is required. Installing a version installs these rather than
223
234
  re-resolving the manifest's ranges.
224
235
 
236
+ **Each version describes itself.** So that a recipe can be described before anything is fetched, every
237
+ version a release records carries more than its dependencies. Each dependency records `declared`, the
238
+ manifest entry that brings it in exactly as written (a recipe such as `workflow/qa-helper@^1.0`, a whole
239
+ namespace such as `workflow`, or a locator), and `kind`: `subscribes` for a co-subscription, whose files
240
+ land and whose questions are asked, or `depends` for a build dependency. When several entries cover one
241
+ recipe, an entry naming it wins over a namespace, and it is a co-subscription when any entry covering it is
242
+ one. The version records `variables`, its manifest's definitions, and `depends` and `subscribes`, its
243
+ manifest's lists as written; the lists are what a consumer walks, because a namespace of another repository
244
+ has no recipe key to record it under. All of them are written even when empty (`"dependencies": {}`,
245
+ `"variables": []`), so "none" reads differently from "not recorded". From these, `sous recipe show` and
246
+ `sous subscription add --dry-run` list everything a subscription installs and every question it asks, and
247
+ the resolver walks a recipe whose files are not on the machine from its index entry just as it would walk
248
+ its manifest. A version recorded before a release wrote these fields keeps its entry as it is; its questions
249
+ are known only once its files are fetched, and the dry run names it as such.
250
+
225
251
  **A published version's entry is frozen.** Its dependencies are resolved once, when the version is first
226
252
  recorded, and every later release carries the entry forward exactly as published, like its hash; releasing
227
253
  other recipes, adding a recipe to a namespace it subscribes to, or raising a sibling it names changes nothing
@@ -234,9 +260,13 @@ against the repository as it stood at that tag, then frozen the same way. An ent
234
260
  recorded dependencies carries none, stays that way, and installs by resolving its manifest's ranges.
235
261
 
236
262
  **Fields this sous does not know are kept, not refused.** A later sous may add fields to the index, at any
237
- level. This one reads such an index, validates every field it defines as strictly as before, and ignores the
238
- rest; a release it runs carries a published version's entry forward with those fields intact. A sous older
239
- than the one that began tolerating unknown fields refuses the whole index once a repository publishes one.
263
+ level, recorded variable definitions included. This one reads such an index, validates every field it
264
+ defines as strictly as before, and ignores the rest; a release it runs carries a published version's entry
265
+ forward with those fields intact.
266
+
267
+ !> A sous older than 0.2.26, the first to tolerate unknown fields, refuses the whole index of any
268
+ repository released with `declared`, `kind`, `variables`, `depends` or `subscribes` in it, the official
269
+ one included. A project pinned to such a sous fails to read that index until it moves to 0.2.26 or later.
240
270
 
241
271
  **The `seeded` field.** Sous ships the `core` recipe in its own npm package, so a project can build before
242
272
  reaching the network. That copy is folded into the official repository's index in memory and resolved like
@@ -12,7 +12,10 @@ a separate, ceremony-free system; see [Variables](config-variables.md). The two
12
12
 
13
13
  A definition ships inside the recipe that needs it, in its manifest's `variables:` array, so it versions,
14
14
  resolves, pins and trusts like the rest of the recipe; every field it can carry is listed in
15
- [Variable definitions](repositories-file-formats.md#variable-definitions).
15
+ [Variable definitions](repositories-file-formats.md#variable-definitions). `sous repo release` also copies
16
+ each version's definitions into the repository's index, which is how `sous recipe show` and
17
+ `sous subscription add --dry-run` list a recipe's questions before its files are on your machine. The index
18
+ holds definitions only, never an answer; answers come from the ladder below and nowhere else.
16
19
 
17
20
  A definition is in play when your lockfile pins the recipe that declares it, whether you subscribed to that
18
21
  recipe or a dependency pulled it in. A recipe the store does not hold yet contributes nothing rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,10 +65,13 @@ contribute: https://github.com/harbor-labs/team-recipes/blob/main/CONTRIBUTING.m
65
65
  A recipe's version lives in its manifest, which is the source of truth. A version is **published**
66
66
  once a git tag shaped `namespace/recipe@version` exists for it (for example
67
67
  `review/checklist@1.2.0`). The index records each published version with its content hash, its
68
- tag and the exact versions of its dependencies at release time; for the length of one release
69
- commit it also records the version each manifest declares, whose tag that release cuts next. A
70
- published version never changes: new files need a new version, and a release refuses to publish
71
- different content under a version already tagged. A lost index is rebuilt from the tags.
68
+ tag, the exact versions of its dependencies at release time (each with the manifest entry that
69
+ brought it in and whether it is a co-subscription or a build dependency) and the variable
70
+ definitions it publishes, which is how a project lists what a recipe installs and asks before
71
+ fetching it; for the length of one release commit it also records the version each manifest
72
+ declares, whose tag that release cuts next. A published version never changes: new files need a
73
+ new version, and a release refuses to publish different content under a version already tagged. A
74
+ lost index is rebuilt from the tags.
72
75
 
73
76
  ## Releasing
74
77
 
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.29
14
+ version: 0.2.30
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -150,7 +150,8 @@ export default class LockRebuild extends BaseCommand {
150
150
  repos,
151
151
  // A recipe's own manifest is what names its dependencies, and it is read
152
152
  // from the files this machine already holds. Nothing is fetched here, so a
153
- // recipe that is not in the store yet comes back under `missingManifests`.
153
+ // recipe that is not in the store yet is walked from what its index entry
154
+ // records, and comes back under `missingManifests` when that is nothing.
154
155
  loadManifest: (recipe) => {
155
156
  const directory = recipeFilesDirectory({
156
157
  service,
@@ -208,9 +209,9 @@ export default class LockRebuild extends BaseCommand {
208
209
  if (result.missingManifests.length > 0) {
209
210
  blankLine();
210
211
  paragraph(
211
- `The files of these recipes are not on this machine, so their own ` +
212
- `dependencies could not be read and are not in the rebuilt lockfile: ` +
213
- `${result.missingManifests.join(", ")}.`
212
+ `The files of these recipes are not on this machine, and their repository's ` +
213
+ `index does not record what they depend on, so their own dependencies are ` +
214
+ `not in the rebuilt lockfile: ${result.missingManifests.join(", ")}.`
214
215
  );
215
216
  }
216
217
 
@@ -2,27 +2,41 @@
2
2
  * `sous recipe show <ref>`.
3
3
  *
4
4
  * Shows one recipe in full: where it is published, every version it publishes,
5
- * what the version this project would use depends on, the questions it asks,
6
- * and where its files land in this project. It reads only what sous already has
7
- * on disk: the repository's cached index, the project's lockfile, and the
8
- * recipe's own files when they are in the store or a linked working copy.
9
- * `--latest` reads the repository's index from upstream instead, saving
10
- * nothing, and `--installed` looks the ref up among installed recipes only.
5
+ * what the version this project would use depends on and how each dependency is
6
+ * declared, everything subscribing to it would install, every question that
7
+ * would ask, and where its files land in this project. It reads only what sous
8
+ * already has on disk: the repository's cached index, the project's lockfile,
9
+ * and the recipe's own files when they are in the store or a linked working
10
+ * copy. The index describes each version's dependencies and questions, so a
11
+ * recipe nothing has installed is described as fully as one in the store, its
12
+ * files excepted. `--latest` reads the repository's index from upstream
13
+ * instead, saving nothing, and `--installed` looks the ref up among installed
14
+ * recipes only.
11
15
  */
12
16
 
13
17
  import { Args } from "@oclif/core";
18
+ import semver from "semver";
14
19
  import { BaseCommand } from "../../base-command.js";
15
20
  import { resolveRootScope } from "../../lib/settings.js";
16
- import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
21
+ import {
22
+ subscriptionServiceFor,
23
+ type SubscriptionPreview,
24
+ type SubscriptionService,
25
+ } from "../../lib/repos/subscription-service.js";
17
26
  import { loadCatalogContext } from "../../lib/repos/catalog-inputs.js";
18
27
  import {
19
28
  describeInstalled,
20
29
  describeRecipe,
30
+ type CatalogInputs,
21
31
  type RecipeContentListing,
22
32
  type RecipeDependencyListing,
23
- type RecipeVariableListing,
33
+ type RecipeDetail,
24
34
  type RecipeVersionListing,
25
35
  } from "../../lib/repos/catalog.js";
36
+ import { PROJECT_REQUESTER } from "../../lib/repos/resolver.js";
37
+ import { describeDeclaration, describeDependencyKind } from "../../lib/repos/declarations.js";
38
+ import { formatQuestionPlan } from "../../lib/vars/question-plan.js";
39
+ import { describeError } from "../../lib/repos/release/validate.js";
26
40
  import {
27
41
  INDENT,
28
42
  describeIndexSource,
@@ -61,14 +75,19 @@ const DEPENDENCY_COLUMNS: TableColumn[] = [
61
75
  { key: "kind", header: "Kind", overflow: "wrap", priority: "medium", minWidth: 16 },
62
76
  ];
63
77
 
64
- /** The columns the variable listing shows. */
65
- const VARIABLE_COLUMNS: TableColumn[] = [
66
- { key: "name", header: "Variable", overflow: "truncate", minWidth: 10 },
67
- { key: "type", header: "Type", priority: "medium" },
68
- { key: "env", header: "Environment variable", overflow: "truncate", minWidth: 12 },
69
- { key: "required", header: "Required", priority: "medium" },
70
- { key: "secret", header: "Secret", priority: "low" },
71
- { key: "prompt", header: "What it asks", overflow: "wrap", flex: 1, priority: "low", minWidth: 16 },
78
+ /** The columns the listing of everything a subscription installs shows. */
79
+ const INSTALLS_COLUMNS: TableColumn[] = [
80
+ { key: "key", header: "Recipe", kind: "path", overflow: "truncate", minWidth: 12 },
81
+ { key: "version", header: "Version", overflow: "truncate", minWidth: 7 },
82
+ { key: "kind", header: "Kind", overflow: "wrap", minWidth: 16 },
83
+ {
84
+ key: "neededBy",
85
+ header: "Needed by",
86
+ overflow: "wrap",
87
+ flex: 1,
88
+ priority: "medium",
89
+ minWidth: 12,
90
+ },
72
91
  ];
73
92
 
74
93
  /** The columns the content listing shows. */
@@ -86,7 +105,8 @@ const CONTENT_COLUMNS: TableColumn[] = [
86
105
  ];
87
106
 
88
107
  export default class RecipeShow extends BaseCommand {
89
- static description = "Show one recipe: its versions, dependencies, variables and files";
108
+ static description =
109
+ "Show one recipe: its versions, dependencies, what subscribing installs and asks, and its files";
90
110
 
91
111
  /**
92
112
  * The other spelling of the topic. It lives under a hidden topic, so it is
@@ -165,24 +185,76 @@ export default class RecipeShow extends BaseCommand {
165
185
  printBrowsingNotes({ notChecked: notChecked.filter((name) => name === detail.repo) });
166
186
 
167
187
  this.printVersions(detail.versions);
168
- this.printDependencies(detail.dependencies, detail.manifestRead);
188
+ this.printDependencies(
189
+ detail.dependencies,
190
+ detail.manifestRead || detail.dependenciesRecorded
191
+ );
169
192
 
170
- if (!detail.manifestRead) {
193
+ const preview = await this.preview(service, inputs, detail);
194
+ if (typeof preview === "string") {
195
+ blankLine();
196
+ paragraph(preview);
197
+ } else {
198
+ this.printInstalls(preview);
199
+ this.printQuestions(preview);
200
+ }
201
+
202
+ if (detail.manifestRead) {
203
+ this.printContents(detail.contents);
204
+ } else {
205
+ blankLine();
206
+ subheading("Where its files land in this project");
171
207
  blankLine();
172
208
  paragraph(
173
- "The recipe's own files are not on this machine, so the questions it asks and " +
174
- "the files it publishes are not known here. Subscribing to it fetches them."
209
+ "The recipe's own files are not on this machine, so the files it publishes are " +
210
+ "not known here. Subscribing to it fetches them."
175
211
  );
176
- footer();
177
- return;
178
212
  }
179
213
 
180
- this.printVariables(detail.variables);
181
- this.printContents(detail.contents);
182
-
183
214
  footer();
184
215
  }
185
216
 
217
+ /**
218
+ * Works out what subscribing to the described version would install and ask,
219
+ * from the same indexes the rest of the page reads, fetching nothing. The
220
+ * answer is a sentence instead when it cannot be worked out.
221
+ *
222
+ * @param service - The project's subscription service.
223
+ * @param inputs - The catalog's inputs, whose indexes the preview resolves against.
224
+ * @param detail - The recipe being described.
225
+ */
226
+ private async preview(
227
+ service: SubscriptionService,
228
+ inputs: CatalogInputs,
229
+ detail: RecipeDetail
230
+ ): Promise<SubscriptionPreview | string> {
231
+ if (detail.describing === undefined) {
232
+ return (
233
+ "This recipe publishes no version a subscription would install, so nothing " +
234
+ "is described below."
235
+ );
236
+ }
237
+ try {
238
+ return await service.previewSubscription(
239
+ {
240
+ repo: detail.repo,
241
+ namespace: detail.namespace,
242
+ recipe: detail.name,
243
+ range: detail.describing,
244
+ },
245
+ {
246
+ indexes: new Map(inputs.repos.map((repo) => [repo.name, repo.index])),
247
+ ...(semver.prerelease(detail.describing) === null ? {} : { prerelease: true }),
248
+ }
249
+ );
250
+ } catch (error) {
251
+ return (
252
+ `Sous could not work out what subscribing to version ${detail.describing} ` +
253
+ `would install. ${describeError(error)}`
254
+ );
255
+ }
256
+ }
257
+
186
258
  /**
187
259
  * Every published version, newest first, with what each one is to this
188
260
  * project.
@@ -207,24 +279,21 @@ export default class RecipeShow extends BaseCommand {
207
279
  }
208
280
 
209
281
  /**
210
- * What the described version depends on, from both sides: what the recipe's
211
- * manifest declares, and what its repository's index resolved that to when it
212
- * was released.
282
+ * What the described version depends on: the manifest entry that brings each
283
+ * dependency in and whether it is a co-subscription or a build dependency,
284
+ * and what its repository's index resolved it to when it was released.
213
285
  *
214
286
  * @param dependencies - The dependencies the catalog listed.
215
- * @param manifestRead - Whether the recipe's own manifest could be read.
287
+ * @param known - Whether the manifest or the index says what the version declares.
216
288
  */
217
- private printDependencies(
218
- dependencies: RecipeDependencyListing[],
219
- manifestRead: boolean
220
- ): void {
289
+ private printDependencies(dependencies: RecipeDependencyListing[], known: boolean): void {
221
290
  blankLine();
222
291
  subheading("What it depends on");
223
292
  blankLine();
224
293
 
225
294
  if (dependencies.length === 0) {
226
295
  paragraph(
227
- manifestRead
296
+ known
228
297
  ? "This recipe depends on nothing else."
229
298
  : "The repository's index records no dependencies for this version."
230
299
  );
@@ -233,7 +302,10 @@ export default class RecipeShow extends BaseCommand {
233
302
 
234
303
  const rows = dependencies.map((entry) => ({
235
304
  key: entry.key,
236
- declared: entry.declared ?? "not declared in the manifest",
305
+ declared:
306
+ entry.declared === undefined
307
+ ? "not recorded in the index"
308
+ : describeDeclaration(entry.declared),
237
309
  resolved:
238
310
  entry.resolvedVersion ??
239
311
  (entry.resolvedRange === undefined
@@ -249,32 +321,69 @@ export default class RecipeShow extends BaseCommand {
249
321
  }
250
322
 
251
323
  /**
252
- * The questions the recipe asks, and the environment variable each answer is
253
- * stored under.
324
+ * Every recipe a subscription to the described version would install, each
325
+ * with whether its files land in the project and what brings it in, and the
326
+ * repositories it would need that the project does not trust yet.
254
327
  *
255
- * @param variables - The variables the catalog listed.
328
+ * @param preview - What subscribing would install and ask.
256
329
  */
257
- private printVariables(variables: RecipeVariableListing[]): void {
330
+ private printInstalls(preview: SubscriptionPreview): void {
258
331
  blankLine();
259
- subheading("What it asks you");
332
+ subheading("What subscribing installs");
260
333
  blankLine();
261
334
 
262
- if (variables.length === 0) {
263
- paragraph("This recipe asks no questions.");
264
- return;
335
+ const rows = preview.resolved.map((recipe) => {
336
+ const subscribed = recipe.requestedBy.includes(PROJECT_REQUESTER);
337
+ const holders = recipe.requestedBy.filter((holder) => holder !== PROJECT_REQUESTER);
338
+ return {
339
+ key: recipe.key,
340
+ version: recipe.version,
341
+ kind: subscribed ? "the subscription itself" : describeDependencyKind(recipe.kind),
342
+ neededBy: holders.length === 0 ? "this subscription" : holders.join(", "),
343
+ };
344
+ });
345
+
346
+ for (const line of renderTable(INSTALLS_COLUMNS, rows, { indent: INDENT })) {
347
+ log(indent(line, INDENT));
265
348
  }
266
349
 
267
- const rows = variables.map((entry) => ({
268
- name: entry.name,
269
- type: entry.type,
270
- env: entry.env,
271
- required: entry.required ? "yes" : "no",
272
- secret: entry.secret ? "yes" : "no",
273
- prompt: entry.prompt,
274
- }));
350
+ if (preview.missingRepos.length > 0) {
351
+ blankLine();
352
+ paragraph(
353
+ `Subscribing also needs ${
354
+ preview.missingRepos.length === 1 ? "a repository" : "repositories"
355
+ } this project does not trust yet, so what ${
356
+ preview.missingRepos.length === 1 ? "it publishes" : "they publish"
357
+ } is not listed above: ` +
358
+ preview.missingRepos
359
+ .map(
360
+ (missing) =>
361
+ `${missing.name}${missing.url === undefined ? "" : ` (${missing.url})`}, ` +
362
+ `needed by ${missing.requiredBy.map((entry) => entry.requestedBy).join(", ")}`
363
+ )
364
+ .join("; ") +
365
+ "."
366
+ );
367
+ }
368
+ }
275
369
 
276
- for (const line of renderTable(VARIABLE_COLUMNS, rows, { indent: INDENT })) {
277
- log(indent(line, INDENT));
370
+ /**
371
+ * Every question a subscription to the described version would ask, laid out
372
+ * exactly as `sous subscription add --dry-run` lays them out, without the
373
+ * flags that answer them ahead of time.
374
+ *
375
+ * @param preview - What subscribing would install and ask.
376
+ */
377
+ private printQuestions(preview: SubscriptionPreview): void {
378
+ blankLine();
379
+ subheading("What subscribing asks you");
380
+ blankLine();
381
+
382
+ for (const line of formatQuestionPlan(preview.questions, {
383
+ unreadable: preview.unreadable,
384
+ answerHints: false,
385
+ })) {
386
+ log(line === "" ? "" : indent(line));
278
387
  }
279
388
  }
280
389
 
@@ -311,13 +420,3 @@ export default class RecipeShow extends BaseCommand {
311
420
  }
312
421
  }
313
422
 
314
- /**
315
- * Plain-language wording for how a dependency was declared.
316
- *
317
- * @param kind - What the manifest declared it as, when the manifest was read.
318
- */
319
- function describeDependencyKind(kind: "depends" | "subscribes" | undefined): string {
320
- if (kind === "depends") return "build dependency";
321
- if (kind === "subscribes") return "co-subscription";
322
- return "unknown";
323
- }