@sous-io/sous 0.2.15 → 0.2.16

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.
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Shows every repository this project trusts, what it publishes, and whether it
5
5
  * is currently being read from a working copy instead of a published version.
6
- * It reads only what sous already has on disk: a repository whose index has
7
- * never been fetched says so in its own row rather than triggering a download,
8
- * so the command is safe to run offline.
6
+ * By default it reads only what sous already has on disk: a repository whose
7
+ * index has never been fetched says so in its own row rather than triggering a
8
+ * download, so the command is safe to run offline. `--latest` reads each index
9
+ * from upstream instead, saving nothing, and `--installed` narrows the listing
10
+ * to the repositories the lockfile pins a recipe from, naming each one under
11
+ * its row.
9
12
  */
10
13
 
11
14
  import { Flags } from "@oclif/core";
@@ -15,14 +18,24 @@ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js"
15
18
  import { readEffectiveLinks } from "../../lib/repos/links.js";
16
19
  import { BUILT_IN_ADDED_BY } from "../../lib/repos/defaults.js";
17
20
  import { requireProvider } from "../../lib/repos/providers/index.js";
21
+ import { readTrustedIndexes } from "../../lib/repos/catalog-inputs.js";
22
+ import {
23
+ LINKED_REPO_NOTE,
24
+ describeIndexSource,
25
+ printBrowsingNotes,
26
+ } from "../../lib/repos/catalog-display.js";
18
27
  import { renderTable, type TableColumn } from "../../utils/table.js";
28
+ import { browsingFlags } from "../../utils/flags.js";
19
29
  import {
20
30
  footer,
21
31
  indent,
22
32
  log,
33
+ note,
23
34
  paragraph,
24
35
  section,
25
36
  showCommandVars,
37
+ wrapColumns,
38
+ wrapText,
26
39
  } from "../../utils/formatting.js";
27
40
 
28
41
  /** How far every line of this command's output is indented. */
@@ -71,10 +84,12 @@ export default class RepoList extends BaseCommand {
71
84
  static examples = [
72
85
  "<%= config.bin %> repo list",
73
86
  "<%= config.bin %> repo list --verbose",
87
+ "<%= config.bin %> repo list --installed --latest",
74
88
  ];
75
89
 
76
90
  static flags = {
77
91
  ...BaseCommand.baseFlags,
92
+ ...browsingFlags(),
78
93
  verbose: Flags.boolean({
79
94
  description: "Show the namespaces each repository publishes, under its row",
80
95
  default: false,
@@ -87,9 +102,15 @@ export default class RepoList extends BaseCommand {
87
102
  showCommandVars({
88
103
  Project: this.projectLabel,
89
104
  Config: this.configContext.configPath,
105
+ Reading: describeIndexSource(flags.latest),
106
+ ...(flags.installed ? { Showing: "only what this project has installed" } : {}),
90
107
  });
91
108
 
92
- section("Repositories this project trusts");
109
+ section(
110
+ flags.installed
111
+ ? "Repositories this project has installed recipes from"
112
+ : "Repositories this project trusts"
113
+ );
93
114
 
94
115
  const service = subscriptionServiceFor({
95
116
  configContext: this.configContext,
@@ -98,7 +119,20 @@ export default class RepoList extends BaseCommand {
98
119
  });
99
120
 
100
121
  const repos = service.currentRepos();
101
- const names = Object.keys(repos).sort();
122
+ const lock = service.lockService.read();
123
+ const names = Object.keys(repos)
124
+ .sort()
125
+ .filter(
126
+ (name) =>
127
+ !flags.installed ||
128
+ Object.values(lock.recipes).some((locked) => locked.repo === name)
129
+ );
130
+
131
+ if (names.length === 0 && flags.installed && Object.keys(repos).length > 0) {
132
+ paragraph("This project has installed no recipe from the repositories it trusts.");
133
+ footer();
134
+ return;
135
+ }
102
136
 
103
137
  if (names.length === 0) {
104
138
  paragraph(
@@ -110,10 +144,12 @@ export default class RepoList extends BaseCommand {
110
144
  }
111
145
 
112
146
  const links = readEffectiveLinks(this.configContext.sousDir);
147
+ const indexes = await readTrustedIndexes(service, { latest: flags.latest });
148
+ const indexFor = new Map(indexes.repos.map((entry) => [entry.name, entry.index]));
113
149
 
114
150
  const rows = names.map((name) => {
115
151
  const entry = repos[name]!;
116
- const index = service.cachedIndex(name);
152
+ const index = indexFor.get(name);
117
153
  const namespaces =
118
154
  index === undefined ? "not fetched" : Object.keys(index.namespaces).sort().join(", ");
119
155
  // A repository whose index has never been fetched says so in the cell
@@ -130,19 +166,40 @@ export default class RepoList extends BaseCommand {
130
166
  namespaces: namespaces.length > 0 ? namespaces : "none",
131
167
  recipes,
132
168
  linked: links[name] === undefined ? "no" : `yes: ${links[name]!.path}`,
169
+ installed: Object.entries(lock.recipes)
170
+ .filter(([, locked]) => locked.repo === name)
171
+ .map(([key, locked]) => `${key} ${locked.version}`)
172
+ .sort()
173
+ .join(", "),
133
174
  };
134
175
  });
135
176
 
136
177
  for (const line of renderTable(COLUMNS, rows, {
137
178
  indent: INDENT,
138
- rowNote: flags.verbose
139
- ? (row) => color.gray(indent(`Namespaces: ${row.namespaces}`, INDENT))
140
- : undefined,
179
+ rowNote: (row) => {
180
+ const notes: string[] = [];
181
+ if (flags.verbose) notes.push(`Namespaces: ${row.namespaces}`);
182
+ if (flags.installed) notes.push(`Installed: ${row.installed}`);
183
+ // Each note wraps under itself, inside the two indents it is printed at.
184
+ const width = Math.max(20, wrapColumns() - INDENT * 2);
185
+ return notes.length === 0
186
+ ? undefined
187
+ : notes
188
+ .flatMap((text) => wrapText(text, width))
189
+ .map((line) => color.gray(indent(line, INDENT)))
190
+ .join("\n");
191
+ },
141
192
  })) {
142
193
  log(indent(line, INDENT));
143
194
  }
144
195
 
145
- // Everything the table can say, the table says; nothing goes under it.
196
+ // The table says everything about each repository; what sits under it is
197
+ // only what the rows could not: a repository upstream could not answer for,
198
+ // and what a linked row means for the installed recipes it names.
199
+ printBrowsingNotes({ notChecked: indexes.notChecked.filter((name) => names.includes(name)) });
200
+ if (flags.installed && names.some((name) => links[name] !== undefined)) {
201
+ note(LINKED_REPO_NOTE);
202
+ }
146
203
  footer();
147
204
  }
148
205
  }
@@ -2,21 +2,32 @@
2
2
  * `sous repo search <text>`.
3
3
  *
4
4
  * Searches the cached index of every repository this project trusts: recipe
5
- * names, namespace names and descriptions. It reads only what is already on
6
- * disk, so it works offline and never downloads anything; a repository whose
7
- * index has not been fetched yet is named at the end rather than silently left
8
- * out of the results.
5
+ * names, namespace names and descriptions. By default it reads only what is
6
+ * already on disk, so it works offline and never downloads anything; a
7
+ * repository whose index has not been fetched yet is named at the end rather
8
+ * than silently left out of the results. `--latest` searches each index as
9
+ * upstream serves it, saving nothing, and `--installed` searches only the
10
+ * recipes the lockfile pins, showing the installed version.
9
11
  */
10
12
 
11
13
  import { Args, Flags } from "@oclif/core";
12
14
  import { BaseCommand } from "../../base-command.js";
13
15
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
16
+ import { readTrustedIndexes } from "../../lib/repos/catalog-inputs.js";
17
+ import {
18
+ LINKED_NOTE,
19
+ describeIndexSource,
20
+ pinnedCell,
21
+ } from "../../lib/repos/catalog-display.js";
22
+ import { readEffectiveLinks } from "../../lib/repos/links.js";
14
23
  import { renderTable, type TableColumn } from "../../utils/table.js";
24
+ import { browsingFlags } from "../../utils/flags.js";
15
25
  import {
16
26
  blankLine,
17
27
  footer,
18
28
  indent,
19
29
  log,
30
+ note,
20
31
  paragraph,
21
32
  section,
22
33
  showCommandVars,
@@ -45,12 +56,24 @@ const COLUMNS: TableColumn[] = [
45
56
  },
46
57
  ];
47
58
 
59
+ /** The column an `--installed` search adds: the version the lockfile pins. */
60
+ const INSTALLED_COLUMN: TableColumn = {
61
+ key: "installed",
62
+ header: "Installed",
63
+ overflow: "truncate",
64
+ minWidth: 9,
65
+ };
66
+
48
67
  /** One recipe that matched, ready to be shown. */
49
68
  type Match = {
50
69
  repo: string;
51
70
  key: string;
52
71
  description: string;
53
72
  versions: string[];
73
+ /** The version the lockfile pins, when it pins this recipe from this repository. */
74
+ installed?: string;
75
+ /** The linked checkout builds read it from, when it is installed and its repository is linked. */
76
+ linkedPath?: string;
54
77
  };
55
78
 
56
79
  export default class RepoSearch extends BaseCommand {
@@ -67,6 +90,7 @@ export default class RepoSearch extends BaseCommand {
67
90
  static examples = [
68
91
  "<%= config.bin %> repo search task",
69
92
  "<%= config.bin %> repo search browser --limit 50",
93
+ "<%= config.bin %> repo search task --installed --latest",
70
94
  ];
71
95
 
72
96
  static args = {
@@ -78,6 +102,7 @@ export default class RepoSearch extends BaseCommand {
78
102
 
79
103
  static flags = {
80
104
  ...BaseCommand.baseFlags,
105
+ ...browsingFlags(),
81
106
  limit: Flags.integer({
82
107
  description: "How many matches to show",
83
108
  default: 25,
@@ -92,6 +117,8 @@ export default class RepoSearch extends BaseCommand {
92
117
  Project: this.projectLabel,
93
118
  Config: this.configContext.configPath,
94
119
  Searching: args.text,
120
+ Reading: describeIndexSource(flags.latest),
121
+ ...(flags.installed ? { Showing: "only what this project has installed" } : {}),
95
122
  });
96
123
 
97
124
  section("Recipes matching your search");
@@ -104,16 +131,17 @@ export default class RepoSearch extends BaseCommand {
104
131
 
105
132
  const repos = Object.keys(service.currentRepos()).sort();
106
133
  const matches: Match[] = [];
107
- const notFetched: string[] = [];
108
-
109
- for (const repo of repos) {
110
- const index = service.cachedIndex(repo);
111
- if (index === undefined) {
112
- notFetched.push(repo);
113
- continue;
114
- }
134
+ const indexes = await readTrustedIndexes(service, { latest: flags.latest });
135
+ const { notFetched, notChecked } = indexes;
136
+ const lock = service.lockService.read();
137
+ const links = readEffectiveLinks(this.configContext.sousDir);
115
138
 
139
+ for (const { name: repo, index } of indexes.repos) {
116
140
  for (const [key, recipe] of Object.entries(index.recipes)) {
141
+ const locked = lock.recipes[key];
142
+ const installed = locked?.repo === repo ? locked.version : undefined;
143
+ if (flags.installed && installed === undefined) continue;
144
+
117
145
  const namespace = key.slice(0, key.indexOf("/"));
118
146
  const haystack = [
119
147
  key,
@@ -129,6 +157,10 @@ export default class RepoSearch extends BaseCommand {
129
157
  key,
130
158
  description: recipe.description ?? "",
131
159
  versions: Object.keys(recipe.versions).sort(),
160
+ ...(installed === undefined ? {} : { installed }),
161
+ ...(installed === undefined || links[repo] === undefined
162
+ ? {}
163
+ : { linkedPath: links[repo]!.path }),
132
164
  });
133
165
  }
134
166
  }
@@ -148,7 +180,9 @@ export default class RepoSearch extends BaseCommand {
148
180
  repos.length === 0
149
181
  ? "This project trusts no repositories yet, so there is nothing to search. " +
150
182
  "Add one with 'sous repo add <url>'."
151
- : `Nothing in the repositories this project trusts matches '${args.text}'.`
183
+ : flags.installed
184
+ ? `Nothing this project has installed matches '${args.text}'.`
185
+ : `Nothing in the repositories this project trusts matches '${args.text}'.`
152
186
  );
153
187
  } else {
154
188
  const shown = matches.slice(0, flags.limit);
@@ -156,11 +190,18 @@ export default class RepoSearch extends BaseCommand {
156
190
  key: match.key,
157
191
  repo: match.repo,
158
192
  versions: match.versions.join(", "),
193
+ installed: pinnedCell(match.installed, match.linkedPath),
159
194
  description:
160
195
  match.description.length > 0 ? match.description : "no description published",
161
196
  }));
162
197
 
163
- for (const line of renderTable(COLUMNS, rows, { indent: INDENT })) {
198
+ // Narrowed to what is installed, the installed version sits right after
199
+ // the recipe, ahead of every version the repository publishes.
200
+ const columns = flags.installed
201
+ ? [COLUMNS[0]!, INSTALLED_COLUMN, ...COLUMNS.slice(1)]
202
+ : COLUMNS;
203
+
204
+ for (const line of renderTable(columns, rows, { indent: INDENT })) {
164
205
  log(indent(line, INDENT));
165
206
  }
166
207
 
@@ -179,9 +220,21 @@ export default class RepoSearch extends BaseCommand {
179
220
  );
180
221
  }
181
222
 
223
+ if (notChecked.length > 0) {
224
+ summary.push(
225
+ `These repositories could not be reached, so they were searched as the cache ` +
226
+ `has them and were not checked: ${notChecked.join(", ")}.`
227
+ );
228
+ }
229
+
182
230
  if (summary.length > 0) {
183
231
  blankLine();
184
- for (const line of summary) log(indent(line));
232
+ for (const line of summary) paragraph(line);
233
+ }
234
+
235
+ if (matches.slice(0, flags.limit).some((match) => match.linkedPath !== undefined)) {
236
+ blankLine();
237
+ note(LINKED_NOTE);
185
238
  }
186
239
 
187
240
  footer();
@@ -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
  *