@sous-io/sous 0.2.15 → 0.2.17

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 (39) hide show
  1. package/docs/markdown/commands.md +63 -16
  2. package/docs/markdown/repositories-authoring.md +52 -11
  3. package/docs/markdown/repositories-consuming.md +32 -1
  4. package/docs/markdown/repositories-file-formats.md +20 -0
  5. package/docs/markdown/repositories-providers.md +20 -10
  6. package/package.json +1 -1
  7. package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
  8. package/src/commands/namespace/list.ts +42 -21
  9. package/src/commands/namespace/show.ts +32 -12
  10. package/src/commands/recipe/list.ts +40 -23
  11. package/src/commands/recipe/show.ts +28 -6
  12. package/src/commands/repo/list.ts +67 -10
  13. package/src/commands/repo/release.ts +41 -0
  14. package/src/commands/repo/search.ts +68 -15
  15. package/src/commands/repo/submit.ts +245 -35
  16. package/src/commands/subscription/list.ts +98 -19
  17. package/src/lib/build-preparation.ts +44 -1
  18. package/src/lib/repos/catalog-display.ts +101 -2
  19. package/src/lib/repos/catalog-inputs.ts +145 -17
  20. package/src/lib/repos/catalog.ts +92 -5
  21. package/src/lib/repos/formats/common.ts +20 -0
  22. package/src/lib/repos/formats/recipe-manifest.ts +7 -0
  23. package/src/lib/repos/formats/repo-manifest.ts +8 -0
  24. package/src/lib/repos/freshness.ts +56 -0
  25. package/src/lib/repos/providers/base.ts +33 -1
  26. package/src/lib/repos/providers/github.ts +276 -3
  27. package/src/lib/repos/providers/gitlab.ts +1 -0
  28. package/src/lib/repos/providers/http.ts +7 -2
  29. package/src/lib/repos/providers/index-cache.ts +56 -17
  30. package/src/lib/repos/providers/provider.ts +121 -3
  31. package/src/lib/repos/release/changelog.ts +448 -0
  32. package/src/lib/repos/release/git-state.ts +101 -15
  33. package/src/lib/repos/release/index.ts +2 -0
  34. package/src/lib/repos/release/submissions.ts +214 -0
  35. package/src/lib/repos/release/submit-checkout.ts +271 -0
  36. package/src/lib/repos/release/submit-questions.ts +153 -0
  37. package/src/lib/repos/release/submit-service.ts +581 -174
  38. package/src/lib/repos/subscription-service.ts +138 -9
  39. package/src/utils/flags.ts +24 -0
@@ -2,20 +2,31 @@
2
2
  * `sous subscription list`.
3
3
  *
4
4
  * Prints every subscription this project declares: the ref, the version range
5
- * it resolves within, the versions its lockfile currently pins, where the
6
- * subscription came from, and whether it is switched on. Entries switched off
7
- * with `enabled: false` are listed too, because an opt-out is part of what a
8
- * project declares.
5
+ * it resolves within, the versions its lockfile currently pins, the latest
6
+ * version each of those recipes has published, where the subscription came
7
+ * from, and whether it is switched on. Entries switched off with
8
+ * `enabled: false` are listed too, because an opt-out is part of what a project
9
+ * declares.
9
10
  *
10
- * It reads only the config and the lockfile, so it is safe offline and never
11
- * downloads anything.
11
+ * By default it reads only the config, the lockfile and the cached indexes, so
12
+ * it is safe offline and never downloads anything. `--latest` reads the latest
13
+ * versions from upstream instead, saving nothing, and `--installed` narrows the
14
+ * listing to the subscriptions the lockfile pins something for.
12
15
  */
13
16
 
14
17
  import { BaseCommand } from "../../base-command.js";
15
18
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
16
19
  import { BUILT_IN_ADDED_BY } from "../../lib/repos/defaults.js";
17
20
  import { USER_ADDED_BY } from "../../lib/repos/trust.js";
21
+ import { loadCatalogContext } from "../../lib/repos/catalog-inputs.js";
22
+ import { listRecipes } from "../../lib/repos/catalog.js";
23
+ import {
24
+ describeIndexSource,
25
+ pinnedCell,
26
+ printBrowsingNotes,
27
+ } from "../../lib/repos/catalog-display.js";
18
28
  import { renderTable, type TableColumn } from "../../utils/table.js";
29
+ import { browsingFlags } from "../../utils/flags.js";
19
30
  import {
20
31
  blankLine,
21
32
  footer,
@@ -39,6 +50,7 @@ const COLUMNS: TableColumn[] = [
39
50
  { key: "key", header: "Subscription", kind: "path", overflow: "truncate", minWidth: 12 },
40
51
  { key: "range", header: "Range", overflow: "truncate", minWidth: 7 },
41
52
  { key: "pinned", header: "Pinned version", flex: 1, minWidth: 14 },
53
+ { key: "latest", header: "Latest version", overflow: "wrap", priority: "medium", minWidth: 14 },
42
54
  { key: "origin", header: "Origin", priority: "medium" },
43
55
  { key: "enabled", header: "Enabled", priority: "medium" },
44
56
  ];
@@ -52,16 +64,21 @@ export default class SubscriptionList extends BaseCommand {
52
64
  */
53
65
  static aliases = ["subscriptions:list"];
54
66
 
55
- static examples = ["<%= config.bin %> subscription list"];
67
+ static examples = [
68
+ "<%= config.bin %> subscription list",
69
+ "<%= config.bin %> subscription list --installed --latest",
70
+ ];
56
71
 
57
- static flags = { ...BaseCommand.baseFlags };
72
+ static flags = { ...BaseCommand.baseFlags, ...browsingFlags() };
58
73
 
59
74
  async run(): Promise<void> {
60
- await this.parse(SubscriptionList);
75
+ const { flags } = await this.parse(SubscriptionList);
61
76
 
62
77
  showCommandVars({
63
78
  Project: this.projectLabel,
64
79
  Config: this.configContext.configPath,
80
+ Reading: describeIndexSource(flags.latest),
81
+ ...(flags.installed ? { Showing: "only what this project has installed" } : {}),
65
82
  });
66
83
 
67
84
  heading("Subscriptions");
@@ -72,28 +89,69 @@ export default class SubscriptionList extends BaseCommand {
72
89
  shellEnv: this.shellEnv,
73
90
  });
74
91
 
75
- const listings = service.listSubscriptions();
92
+ const listings = service
93
+ .listSubscriptions()
94
+ .filter((entry) => !flags.installed || entry.pinned.length > 0);
76
95
 
77
96
  blankLine();
78
97
 
79
98
  if (listings.length === 0) {
80
- paragraph("This project subscribes to nothing yet.");
99
+ paragraph(
100
+ flags.installed
101
+ ? "No subscription of this project has installed anything yet."
102
+ : "This project subscribes to nothing yet."
103
+ );
81
104
  footer();
82
105
  return;
83
106
  }
84
107
 
85
- const rows = listings.map((entry) => ({
86
- key: entry.key,
87
- range: entry.range ?? "any version",
88
- pinned: describePinned(entry.pinned, entry.enabled),
89
- origin: describeOrigin(entry.addedBy),
90
- enabled: entry.enabled ? "yes" : "no",
91
- }));
108
+ // The latest version of each pinned recipe, from the repository the
109
+ // lockfile says it came from; the linked checkout, when there is one.
110
+ const { inputs, notChecked } = await loadCatalogContext({
111
+ service,
112
+ sousDir: this.configContext.sousDir,
113
+ settings: this.settings,
114
+ latest: flags.latest,
115
+ });
116
+ const published = new Map(
117
+ listRecipes(inputs).map((recipe) => [`${recipe.repo}:${recipe.key}`, recipe])
118
+ );
119
+ const lock = inputs.lock;
120
+
121
+ let anyLinked = false;
122
+ const rows = listings.map((entry) => {
123
+ const held = entry.pinned.map((pin) => {
124
+ const recipe = published.get(`${lock.recipes[pin.key]?.repo}:${pin.key}`);
125
+ if (recipe?.linkedPath !== undefined) anyLinked = true;
126
+ return { ...pin, recipe };
127
+ });
128
+ return {
129
+ key: entry.key,
130
+ range: entry.range ?? "any version",
131
+ pinned:
132
+ held.length === 0
133
+ ? describePinned([], entry.enabled)
134
+ : held
135
+ .map((pin) => `${pin.key} ${pinnedCell(pin.version, pin.recipe?.linkedPath)}`)
136
+ .join(", "),
137
+ latest: describeLatest(held.map((pin) => ({ key: pin.key, latest: pin.recipe?.latest }))),
138
+ origin: describeOrigin(entry.addedBy),
139
+ enabled: entry.enabled ? "yes" : "no",
140
+ };
141
+ });
142
+
143
+ const columns = flags.installed
144
+ ? COLUMNS.map((column) =>
145
+ column.key === "pinned" ? { ...column, header: "Installed version" } : column
146
+ )
147
+ : COLUMNS;
92
148
 
93
- for (const line of renderTable(COLUMNS, rows, { indent: INDENT })) {
149
+ for (const line of renderTable(columns, rows, { indent: INDENT })) {
94
150
  log(indent(line, INDENT));
95
151
  }
96
152
 
153
+ printBrowsingNotes({ notChecked, anyLinked });
154
+
97
155
  footer();
98
156
  }
99
157
  }
@@ -117,6 +175,27 @@ export function describePinned(
117
175
  return enabled ? "pinned on first build" : "not pinned";
118
176
  }
119
177
 
178
+ /**
179
+ * The latest published version of each recipe a subscription holds. One held
180
+ * recipe is the usual case, and it shows the version alone; several (a
181
+ * namespace subscription) are each named with the version beside them. A
182
+ * recipe no readable index publishes says so; a subscription holding nothing
183
+ * yet has no latest version to show.
184
+ *
185
+ * describeLatest([{ key: "workflow/task-files", latest: "1.2.0" }])
186
+ * // -> "1.2.0"
187
+ *
188
+ * @param held - Each held recipe, with its latest version when an index names one.
189
+ */
190
+ export function describeLatest(held: Array<{ key: string; latest: string | undefined }>): string {
191
+ if (held.length === 0) return "";
192
+ const unknown = "not in any index sous has read";
193
+ if (held.length === 1) return held[0]!.latest ?? unknown;
194
+ return held
195
+ .map((entry) => `${entry.key} ${entry.latest ?? unknown}`)
196
+ .join(", ");
197
+ }
198
+
120
199
  /**
121
200
  * Plain-language wording for a subscription entry's `addedBy` field.
122
201
  *
@@ -7,7 +7,16 @@
7
7
  */
8
8
 
9
9
  import type { SubscriptionService } from "./repos/subscription-service.js";
10
- import { blankLine, footer, heading, paragraph, warning } from "../utils/formatting.js";
10
+ import {
11
+ blankLine,
12
+ footer,
13
+ heading,
14
+ note,
15
+ paragraph,
16
+ section,
17
+ showVariables,
18
+ warning,
19
+ } from "../utils/formatting.js";
11
20
 
12
21
  /**
13
22
  * Gets this project's recipes ready to compile: restores whatever the store is
@@ -83,4 +92,38 @@ export async function prepareRepositoriesForBuild(
83
92
  }
84
93
 
85
94
  if (needsRestore) footer();
95
+
96
+ reportNewerVersions(upstream.newer);
97
+ }
98
+
99
+ /**
100
+ * States which recipes this project uses have a newer version within the range
101
+ * declared for them, as a fact: one line per recipe, the newer version beside the
102
+ * pinned one. Nothing moved, and the closing sentence says so. Prints nothing
103
+ * when there is nothing newer.
104
+ *
105
+ * @param newer - What the upstream check found.
106
+ */
107
+ export function reportNewerVersions(
108
+ newer: Array<{ key: string; from: string; to: string }>
109
+ ): void {
110
+ if (newer.length === 0) return;
111
+
112
+ section("Newer versions published");
113
+ showVariables(
114
+ newer.map((entry) => ({
115
+ label: entry.key,
116
+ value: entry.to,
117
+ detail: `this project pins ${entry.from}`,
118
+ }))
119
+ );
120
+ blankLine();
121
+ note(
122
+ newer.length === 1
123
+ ? "This version is within the range declared for the recipe. No pin was changed, " +
124
+ "so this build uses the pinned version."
125
+ : "Each version is within the range declared for its recipe. No pin was changed, " +
126
+ "so this build uses the pinned versions."
127
+ );
128
+ footer();
86
129
  }
@@ -10,7 +10,14 @@
10
10
  */
11
11
 
12
12
  import { renderFacts, type LabeledFact } from "../vars/display.js";
13
- import { log, wrapColumns } from "../../utils/formatting.js";
13
+ import {
14
+ blankLine,
15
+ log,
16
+ note,
17
+ palette,
18
+ paragraph,
19
+ wrapColumns,
20
+ } from "../../utils/formatting.js";
14
21
  import type { TableColumn } from "../../utils/table.js";
15
22
  import type {
16
23
  NamespaceCoverage,
@@ -42,6 +49,19 @@ export const RECIPE_COLUMNS: TableColumn[] = [
42
49
  },
43
50
  ];
44
51
 
52
+ /**
53
+ * The recipe columns for one run. Narrowed to what the project has installed,
54
+ * the pinned column is the installed version, and is headed that way.
55
+ *
56
+ * @param options - Whether the listing is narrowed to installed recipes.
57
+ */
58
+ export function recipeColumns(options: { installed?: boolean } = {}): TableColumn[] {
59
+ if (options.installed !== true) return RECIPE_COLUMNS;
60
+ return RECIPE_COLUMNS.map((column) =>
61
+ column.key === "pinned" ? { ...column, header: "Installed" } : column
62
+ );
63
+ }
64
+
45
65
  /** One rendered recipe row, in the shape `RECIPE_COLUMNS` reads. */
46
66
  export type RecipeRow = {
47
67
  key: string;
@@ -64,12 +84,91 @@ export function recipeRows(listings: RecipeListing[]): RecipeRow[] {
64
84
  key: entry.key,
65
85
  repo: entry.repo,
66
86
  latest: entry.latest ?? "none published",
67
- pinned: entry.pinned ?? "",
87
+ pinned: pinnedCell(entry.pinned, entry.linkedPath),
68
88
  subscribed: entry.subscribed ? "yes" : "no",
69
89
  description: entry.description ?? "no description published",
70
90
  }));
71
91
  }
72
92
 
93
+ /**
94
+ * The pinned version as a cell: the version alone, or the version marked
95
+ * `linked` in muted grey when builds read the recipe from a linked checkout.
96
+ *
97
+ * @param pinned - The version the lockfile pins, when it pins one.
98
+ * @param linkedPath - The linked checkout, when the repository is linked.
99
+ */
100
+ export function pinnedCell(pinned: string | undefined, linkedPath: string | undefined): string {
101
+ if (pinned === undefined) return "";
102
+ return linkedPath === undefined ? pinned : `${pinned} ${palette.muted("linked")}`;
103
+ }
104
+
105
+ /** What a listing says about the linked recipes it marked. */
106
+ export const LINKED_NOTE =
107
+ "A recipe marked linked is pinned at the version shown, but builds currently read it " +
108
+ "from the linked checkout of its repository.";
109
+
110
+ /** What a repository listing says when an installed repository is linked. */
111
+ export const LINKED_REPO_NOTE =
112
+ "A recipe installed from a linked repository is pinned at the version shown, but " +
113
+ "builds currently read it from the linked checkout.";
114
+
115
+ /**
116
+ * Where a browsing command read the indexes from, in the words its opening
117
+ * block shows.
118
+ *
119
+ * @param latest - Whether the latest was asked for.
120
+ */
121
+ export function describeIndexSource(latest: boolean): string {
122
+ return latest ? "each repository, upstream" : "the cached indexes";
123
+ }
124
+
125
+ /** What a browsing command could not read, and whether any row is linked. */
126
+ export type BrowsingNotes = {
127
+ /** Trusted repositories with no index at all. */
128
+ notFetched?: string[];
129
+ /** Repositories upstream could not answer for, shown from the cache. */
130
+ notChecked?: string[];
131
+ /** True when some row was marked linked. */
132
+ anyLinked?: boolean;
133
+ };
134
+
135
+ /**
136
+ * Prints what a browsing command's rows could not say themselves: repositories
137
+ * that were not listed at all, repositories shown from the cache because
138
+ * upstream could not be reached, and what `linked` means when a row carries
139
+ * it. Prints nothing when there is nothing to say.
140
+ *
141
+ * @param notes - What the command could not read, and whether a row was linked.
142
+ */
143
+ export function printBrowsingNotes(notes: BrowsingNotes): void {
144
+ const lines: Array<{ text: string; kind: "fact" | "note" }> = [];
145
+
146
+ if ((notes.notFetched ?? []).length > 0) {
147
+ lines.push({
148
+ kind: "fact",
149
+ text:
150
+ `These repositories are trusted and their index has not been fetched yet, so ` +
151
+ `nothing in them is listed: ${notes.notFetched!.join(", ")}.`,
152
+ });
153
+ }
154
+ if ((notes.notChecked ?? []).length > 0) {
155
+ lines.push({
156
+ kind: "fact",
157
+ text:
158
+ `These repositories could not be reached, so they are shown from the cache and ` +
159
+ `were not checked: ${notes.notChecked!.join(", ")}.`,
160
+ });
161
+ }
162
+ if (notes.anyLinked === true) lines.push({ kind: "note", text: LINKED_NOTE });
163
+
164
+ if (lines.length === 0) return;
165
+ blankLine();
166
+ for (const line of lines) {
167
+ if (line.kind === "note") note(line.text);
168
+ else paragraph(line.text);
169
+ }
170
+ }
171
+
73
172
  /**
74
173
  * Plain-language wording for how much of a namespace a project subscribes to.
75
174
  *
@@ -8,15 +8,18 @@
8
8
  * is pinned, the links map and the store for a recipe's own files, and
9
9
  * `recipeOutputs` for where a content kind lands.
10
10
  *
11
- * Nothing here downloads anything. A repository whose index has never been
12
- * fetched is left out of the catalog and named separately, so a browsing command
13
- * is safe offline.
11
+ * By default nothing here downloads anything: a repository whose index has
12
+ * never been fetched is left out of the catalog and named separately, so a
13
+ * browsing command is safe offline. Asked for the latest, it reads each index
14
+ * from upstream instead and writes none of it to the cache; a repository that
15
+ * cannot be reached is read from the cache and named as not checked.
14
16
  */
15
17
 
16
18
  import type { Settings, VarScope } from "../settings.js";
17
19
  import type { SubscriptionService } from "./subscription-service.js";
18
20
  import type { CatalogInputs, CatalogRepo } from "./catalog.js";
19
- import { linkedPathFor } from "./links.js";
21
+ import type { IndexFile } from "./formats/index-file.js";
22
+ import { linkedPathFor, readEffectiveLinks } from "./links.js";
20
23
  import { mapLinkedRecipes, readRecipeManifestIn } from "./locked-recipes.js";
21
24
  import { WRITABLE_CONTENT_KINDS, destinationsFor } from "./recipe-targets.js";
22
25
  import type { WritableContentKind } from "./recipe-targets.js";
@@ -37,8 +40,94 @@ export type CatalogInputsOptions = {
37
40
  scope?: VarScope;
38
41
  /** The environment to read; decides where the store and the links map are. */
39
42
  env?: NodeJS.ProcessEnv;
43
+ /**
44
+ * The indexes to read, when the caller has already gathered them (with
45
+ * `readTrustedIndexes`, say, to read upstream). The cached indexes otherwise.
46
+ */
47
+ indexes?: TrustedIndexes;
48
+ };
49
+
50
+ /** Where to read the trusted repositories' indexes from. */
51
+ export type TrustedIndexesOptions = {
52
+ /** Read each index from upstream rather than the cache, writing none of it. */
53
+ latest?: boolean;
40
54
  };
41
55
 
56
+ /** One trusted repository's index, and where it was read from. */
57
+ export type TrustedIndex = {
58
+ /** The repository's short name. */
59
+ name: string;
60
+ /** Where it lives, as the project's config records it. */
61
+ url?: string;
62
+ /** The index that was read. */
63
+ index: IndexFile;
64
+ /** Whether it came from upstream just now or from the cache. */
65
+ source: "upstream" | "cache";
66
+ };
67
+
68
+ /** Every trusted repository's index sous could read, and what it could not. */
69
+ export type TrustedIndexes = {
70
+ /** The indexes, by repository short name, sorted. */
71
+ repos: TrustedIndex[];
72
+ /**
73
+ * Trusted repositories with no index at all: never fetched, and (when the
74
+ * latest was asked for) not reachable either. Sorted.
75
+ */
76
+ notFetched: string[];
77
+ /**
78
+ * Repositories the latest was asked for that could not be reached, and were
79
+ * read from the cache instead. Sorted; always empty when reading the cache.
80
+ */
81
+ notChecked: string[];
82
+ };
83
+
84
+ /**
85
+ * Reads the index of every repository this project trusts. From the cache by
86
+ * default, which downloads nothing. With `latest`, from upstream, all at once,
87
+ * and nothing fetched is written to the cache: only a command that resolves
88
+ * versions changes what the cache holds. A repository upstream cannot answer
89
+ * for is read from the cache and named in `notChecked`.
90
+ *
91
+ * @param service - The subscription service for this project.
92
+ * @param options - Whether to read upstream.
93
+ */
94
+ export async function readTrustedIndexes(
95
+ service: SubscriptionService,
96
+ options: TrustedIndexesOptions = {}
97
+ ): Promise<TrustedIndexes> {
98
+ if (options.latest !== true) return cachedTrustedIndexes(service);
99
+
100
+ const trusted = service.currentRepos();
101
+ const names = Object.keys(trusted).sort();
102
+ const answers = await Promise.allSettled(names.map((name) => service.upstreamIndex(name)));
103
+
104
+ const result: TrustedIndexes = { repos: [], notFetched: [], notChecked: [] };
105
+ names.forEach((name, position) => {
106
+ const url = trusted[name]?.url;
107
+ const answer = answers[position]!;
108
+ if (answer.status === "fulfilled") {
109
+ result.repos.push({
110
+ name,
111
+ ...(url === undefined ? {} : { url }),
112
+ index: answer.value,
113
+ source: "upstream",
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Upstream could not answer, so the cached copy stands in and says so. A
119
+ // repository with no cached copy either is named once, as never fetched.
120
+ const cached = service.cachedIndex(name);
121
+ if (cached === undefined) {
122
+ result.notFetched.push(name);
123
+ return;
124
+ }
125
+ result.notChecked.push(name);
126
+ result.repos.push({ name, ...(url === undefined ? {} : { url }), index: cached, source: "cache" });
127
+ });
128
+ return result;
129
+ }
130
+
42
131
  /** The catalog's inputs, plus what could not be read. */
43
132
  export type CatalogContext = {
44
133
  /** What the catalog functions read. */
@@ -48,6 +137,11 @@ export type CatalogContext = {
48
137
  * could be listed. Sorted.
49
138
  */
50
139
  notFetched: string[];
140
+ /**
141
+ * Repositories the latest was asked for that could not be reached, listed
142
+ * from the cache instead. Sorted.
143
+ */
144
+ notChecked: string[];
51
145
  };
52
146
 
53
147
  /**
@@ -60,25 +154,23 @@ export type CatalogContext = {
60
154
  export function catalogContextFor(options: CatalogInputsOptions): CatalogContext {
61
155
  const { service } = options;
62
156
  const env = options.env ?? process.env;
157
+ const indexes = options.indexes ?? cachedTrustedIndexes(service);
63
158
 
64
- const repos: CatalogRepo[] = [];
65
- const notFetched: string[] = [];
159
+ const repos: CatalogRepo[] = indexes.repos.map((entry) => ({
160
+ name: entry.name,
161
+ ...(entry.url === undefined ? {} : { url: entry.url }),
162
+ index: entry.index,
163
+ }));
66
164
 
67
- const trusted = service.currentRepos();
68
- for (const name of Object.keys(trusted).sort()) {
69
- const index = service.cachedIndex(name);
70
- if (index === undefined) {
71
- notFetched.push(name);
72
- continue;
73
- }
74
- const url = trusted[name]?.url;
75
- repos.push({ name, ...(url === undefined ? {} : { url }), index });
76
- }
165
+ const links = readEffectiveLinks(options.sousDir, env);
166
+ const linked: Record<string, string> = {};
167
+ for (const [name, link] of Object.entries(links)) linked[name] = link.path;
77
168
 
78
169
  const inputs: CatalogInputs = {
79
170
  repos,
80
171
  lock: service.lockService.read(),
81
172
  subscriptions: Object.keys(service.allSubscriptions()).sort(),
173
+ linked,
82
174
  readManifest: (recipe) => {
83
175
  const directory = recipeFilesDirectory({
84
176
  service,
@@ -105,7 +197,43 @@ export function catalogContextFor(options: CatalogInputsOptions): CatalogContext
105
197
  },
106
198
  };
107
199
 
108
- return { inputs, notFetched };
200
+ return { inputs, notFetched: indexes.notFetched, notChecked: indexes.notChecked };
201
+ }
202
+
203
+ /**
204
+ * The catalog's inputs, reading the indexes the way the options say: from the
205
+ * cache, or with `latest` from upstream without writing to the cache.
206
+ *
207
+ * @param options - The subscription service, the project's directory and config,
208
+ * and whether to read upstream.
209
+ */
210
+ export async function loadCatalogContext(
211
+ options: Omit<CatalogInputsOptions, "indexes"> & TrustedIndexesOptions
212
+ ): Promise<CatalogContext> {
213
+ const indexes = await readTrustedIndexes(options.service, {
214
+ ...(options.latest === undefined ? {} : { latest: options.latest }),
215
+ });
216
+ return catalogContextFor({ ...options, indexes });
217
+ }
218
+
219
+ /**
220
+ * Every trusted repository's cached index, read synchronously.
221
+ *
222
+ * @param service - The subscription service for this project.
223
+ */
224
+ function cachedTrustedIndexes(service: SubscriptionService): TrustedIndexes {
225
+ const trusted = service.currentRepos();
226
+ const result: TrustedIndexes = { repos: [], notFetched: [], notChecked: [] };
227
+ for (const name of Object.keys(trusted).sort()) {
228
+ const index = service.cachedIndex(name);
229
+ if (index === undefined) {
230
+ result.notFetched.push(name);
231
+ continue;
232
+ }
233
+ const url = trusted[name]?.url;
234
+ result.repos.push({ name, ...(url === undefined ? {} : { url }), index, source: "cache" });
235
+ }
236
+ return result;
109
237
  }
110
238
 
111
239
  /** Which recipe, at which version, in which of this project's repositories. */