@sous-io/sous 0.2.14 → 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.
@@ -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. */
@@ -78,6 +78,11 @@ export type CatalogInputs = {
78
78
  lock: Lockfile;
79
79
  /** The ref keys the project subscribes to: namespaces, and `namespace/recipe`. */
80
80
  subscriptions: string[];
81
+ /**
82
+ * The repositories read from a linked working copy instead of the store, by
83
+ * short name, each with the checkout's path.
84
+ */
85
+ linked?: Record<string, string>;
81
86
  /**
82
87
  * Reads one published recipe's manifest, when its files are on this machine.
83
88
  * Returning undefined means "not available", and the recipe is described from
@@ -119,6 +124,11 @@ export type RecipeListing = {
119
124
  latest?: string;
120
125
  /** The version this project's lockfile pins, when it pins one. */
121
126
  pinned?: string;
127
+ /**
128
+ * The linked checkout builds read this recipe from instead of the pinned
129
+ * version, when the project pins it and its repository is linked.
130
+ */
131
+ linkedPath?: string;
122
132
  /** True when the project subscribes to this recipe, or to the whole namespace holding it. */
123
133
  subscribed: boolean;
124
134
  /** The recipe's one-paragraph summary, when its index carries one. */
@@ -212,6 +222,11 @@ export type RecipeDetail = {
212
222
  latest?: string;
213
223
  /** The version this project's lockfile pins, when it pins one. */
214
224
  pinned?: string;
225
+ /**
226
+ * The linked checkout builds read this recipe from instead of the pinned
227
+ * version, when the project pins it and its repository is linked.
228
+ */
229
+ linkedPath?: string;
215
230
  /** True when the project subscribes to this recipe, or to the whole namespace holding it. */
216
231
  subscribed: boolean;
217
232
  /**
@@ -275,7 +290,7 @@ export function listRecipes(inputs: CatalogInputs): RecipeListing[] {
275
290
 
276
291
  for (const repo of inputs.repos) {
277
292
  for (const key of Object.keys(repo.index.recipes)) {
278
- listings.push(recipeListing(key, repo, inputs.lock, subscriptions));
293
+ listings.push(recipeListing(key, repo, inputs, subscriptions));
279
294
  }
280
295
  }
281
296
 
@@ -305,7 +320,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
305
320
  ...(declared.description === undefined ? {} : { description: declared.description }),
306
321
  subscribed: coverageOf(found.namespace, found.repo.index, subscriptions),
307
322
  recipes: recipeKeysIn(found.repo.index, found.namespace).map((key) =>
308
- recipeListing(key, found.repo, inputs.lock, subscriptions)
323
+ recipeListing(key, found.repo, inputs, subscriptions)
309
324
  ),
310
325
  };
311
326
  }
@@ -320,7 +335,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
320
335
  export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail {
321
336
  const found = resolveRecipeRef(inputs, ref);
322
337
  const subscriptions = new Set(inputs.subscriptions);
323
- const listing = recipeListing(found.key, found.repo, inputs.lock, subscriptions);
338
+ const listing = recipeListing(found.key, found.repo, inputs, subscriptions);
324
339
  const entry = found.repo.index.recipes[found.key]!;
325
340
 
326
341
  const describing = listing.pinned ?? listing.latest;
@@ -355,6 +370,75 @@ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail
355
370
  };
356
371
  }
357
372
 
373
+ /**
374
+ * The same inputs, narrowed to what this project has installed: each
375
+ * repository's index keeps only the recipes the lockfile pins from that
376
+ * repository, and only the namespaces holding one of them. Every listing and
377
+ * every ref lookup over the result therefore sees only installed recipes. A
378
+ * recipe's published version history is kept whole, so the installed version
379
+ * still sits among the others.
380
+ *
381
+ * narrowToInstalled(inputs)
382
+ * // -> the index of "r" holds "a/x" only, when the lockfile pins "a/x" from "r"
383
+ *
384
+ * @param inputs - The catalog's inputs.
385
+ */
386
+ export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
387
+ const repos = inputs.repos.map((repo) => {
388
+ const recipes = Object.fromEntries(
389
+ Object.entries(repo.index.recipes).filter(
390
+ ([key]) => inputs.lock.recipes[key]?.repo === repo.name
391
+ )
392
+ );
393
+ const held = new Set(Object.keys(recipes).map((key) => key.slice(0, key.indexOf("/"))));
394
+ const namespaces = Object.fromEntries(
395
+ Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
396
+ );
397
+ return { ...repo, index: { ...repo.index, recipes, namespaces } };
398
+ });
399
+ return { ...inputs, repos };
400
+ }
401
+
402
+ /**
403
+ * Looks a ref up among the installed recipes only, so an ambiguity between an
404
+ * installed recipe and one the project does not use settles itself. A ref that
405
+ * names something published but not installed is an error saying exactly that,
406
+ * rather than the "no repository publishes" error the narrowed lookup alone
407
+ * would raise.
408
+ *
409
+ * describeInstalled(inputs, "workflow/task-files", describeRecipe, "recipe")
410
+ * // -> the recipe, when the lockfile pins it; otherwise an error saying the
411
+ * // project has not installed it
412
+ *
413
+ * @param inputs - The catalog's inputs, not yet narrowed.
414
+ * @param ref - The ref being looked up.
415
+ * @param describe - The lookup to run: `describeNamespace` or `describeRecipe`.
416
+ * @param what - What the ref names, for the error.
417
+ */
418
+ export function describeInstalled<T>(
419
+ inputs: CatalogInputs,
420
+ ref: string,
421
+ describe: (inputs: CatalogInputs, ref: string) => T,
422
+ what: "namespace" | "recipe"
423
+ ): T {
424
+ try {
425
+ return describe(narrowToInstalled(inputs), ref);
426
+ } catch {
427
+ // The full lookup either explains the ref better (ambiguous, unknown) or
428
+ // proves it is published and simply not installed.
429
+ describe(inputs, ref);
430
+ throw what === "namespace"
431
+ ? new ConfigError(
432
+ `This project has installed no recipe from the namespace '${ref}', so there is ` +
433
+ `nothing to show with '--installed'.`
434
+ )
435
+ : new ConfigError(
436
+ `This project has not installed the recipe '${ref}', so there is nothing to ` +
437
+ `show with '--installed'.`
438
+ );
439
+ }
440
+ }
441
+
358
442
  // --- Resolving a ref ----------------------------------------------------------------------------
359
443
 
360
444
  /** One namespace a ref resolved to. */
@@ -488,15 +572,16 @@ function coverageOf(
488
572
  *
489
573
  * @param key - The recipe key.
490
574
  * @param repo - The repository publishing it.
491
- * @param lock - The project's lockfile.
575
+ * @param inputs - The lockfile and the linked repositories.
492
576
  * @param subscriptions - The ref keys the project subscribes to.
493
577
  */
494
578
  function recipeListing(
495
579
  key: string,
496
580
  repo: CatalogRepo,
497
- lock: Lockfile,
581
+ inputs: Pick<CatalogInputs, "lock" | "linked">,
498
582
  subscriptions: Set<string>
499
583
  ): RecipeListing {
584
+ const lock = inputs.lock;
500
585
  const entry = repo.index.recipes[key]!;
501
586
  const namespace = key.slice(0, key.indexOf("/"));
502
587
  const name = key.slice(namespace.length + 1);
@@ -507,6 +592,7 @@ function recipeListing(
507
592
  // the recipe came from this repository.
508
593
  const locked = lock.recipes[key];
509
594
  const pinned = locked !== undefined && locked.repo === repo.name ? locked.version : undefined;
595
+ const linkedPath = pinned === undefined ? undefined : inputs.linked?.[repo.name];
510
596
 
511
597
  return {
512
598
  key,
@@ -515,6 +601,7 @@ function recipeListing(
515
601
  repo: repo.name,
516
602
  ...(latest === undefined ? {} : { latest }),
517
603
  ...(pinned === undefined ? {} : { pinned }),
604
+ ...(linkedPath === undefined ? {} : { linkedPath }),
518
605
  subscribed: subscriptions.has(key) || subscriptions.has(namespace),
519
606
  ...(entry.description === undefined ? {} : { description: entry.description }),
520
607
  };
@@ -206,3 +206,59 @@ export function effectiveRangeForHolders(
206
206
  const combined = constraints.join(" ");
207
207
  return semver.validRange(combined) === null ? undefined : combined;
208
208
  }
209
+
210
+ /**
211
+ * How long a build waits for a repository that it is only checking so it can
212
+ * say whether a newer version exists: three seconds. Past that the check is
213
+ * abandoned, quietly, and the build goes on with what the cache already knew.
214
+ */
215
+ export const NEWER_VERSION_CHECK_TIMEOUT_MS = 3000;
216
+
217
+ /**
218
+ * Runs a piece of upstream work with a deadline. The work is handed an abort
219
+ * signal that fires at the deadline, so a request that honors it is cancelled
220
+ * rather than left to keep the process alive; either way the returned promise
221
+ * rejects at the deadline, and the timer never holds the process open itself.
222
+ *
223
+ * await withDeadline((signal) => fetchSomething(signal), 3000);
224
+ * // -> the result, or an error saying the repository did not answer in time
225
+ *
226
+ * @param work - The work to run, given the signal that cancels it.
227
+ * @param milliseconds - How long to wait.
228
+ */
229
+ export async function withDeadline<T>(
230
+ work: (signal: AbortSignal) => Promise<T>,
231
+ milliseconds: number
232
+ ): Promise<T> {
233
+ const controller = new AbortController();
234
+ let timer: ReturnType<typeof setTimeout> | undefined;
235
+
236
+ const deadline = new Promise<never>((_, reject) => {
237
+ timer = setTimeout(() => {
238
+ controller.abort();
239
+ reject(
240
+ new Error(
241
+ `The repository did not answer within ${formatSeconds(milliseconds)}, so sous ` +
242
+ `stopped waiting for it.`
243
+ )
244
+ );
245
+ }, milliseconds);
246
+ timer.unref?.();
247
+ });
248
+
249
+ try {
250
+ return await Promise.race([work(controller.signal), deadline]);
251
+ } finally {
252
+ if (timer !== undefined) clearTimeout(timer);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * A duration in plain words, such as "3 seconds" or "1 second".
258
+ *
259
+ * @param milliseconds - The duration.
260
+ */
261
+ function formatSeconds(milliseconds: number): string {
262
+ const seconds = Math.round((milliseconds / 1000) * 10) / 10;
263
+ return seconds === 1 ? "1 second" : `${seconds} seconds`;
264
+ }