@sous-io/sous 0.2.28 → 0.2.29

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 (43) hide show
  1. package/docs/markdown/_sidebar.md +1 -0
  2. package/docs/markdown/repositories-authoring.md +12 -8
  3. package/docs/markdown/repositories-consuming.md +8 -1
  4. package/docs/markdown/repositories-file-formats.md +53 -8
  5. package/docs/markdown/repositories-providers.md +8 -6
  6. package/docs/markdown/repositories-troubleshooting.md +6 -0
  7. package/docs/markdown/repositories-variables.md +9 -2
  8. package/docs/markdown/repositories.md +3 -3
  9. package/package.json +1 -1
  10. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/commands/lock/rebuild.ts +2 -2
  13. package/src/commands/repo/contribute.ts +1 -10
  14. package/src/commands/repo/gc.ts +3 -2
  15. package/src/commands/repo/release.ts +20 -3
  16. package/src/commands/repo/search.ts +2 -1
  17. package/src/commands/vars/ask.ts +32 -10
  18. package/src/lib/refs/find.ts +202 -21
  19. package/src/lib/refs/index.ts +2 -0
  20. package/src/lib/refs/parse.ts +844 -0
  21. package/src/lib/refs/scopes.ts +34 -0
  22. package/src/lib/refs/settle.ts +219 -0
  23. package/src/lib/repos/catalog.ts +44 -25
  24. package/src/lib/repos/formats/index-file.ts +2 -1
  25. package/src/lib/repos/formats/recipe-manifest.ts +10 -8
  26. package/src/lib/repos/index.ts +0 -1
  27. package/src/lib/repos/lock-service.ts +3 -2
  28. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  29. package/src/lib/repos/locked-recipes.ts +2 -2
  30. package/src/lib/repos/namespace-resolver.ts +40 -47
  31. package/src/lib/repos/providers/base.ts +30 -0
  32. package/src/lib/repos/providers/github.ts +22 -0
  33. package/src/lib/repos/providers/gitlab.ts +68 -0
  34. package/src/lib/repos/providers/provider.ts +96 -0
  35. package/src/lib/repos/release/index-builder.ts +55 -17
  36. package/src/lib/repos/release/index.ts +1 -0
  37. package/src/lib/repos/release/plan.ts +8 -6
  38. package/src/lib/repos/release/settle.ts +237 -0
  39. package/src/lib/repos/release/tags.ts +3 -7
  40. package/src/lib/repos/resolver.ts +198 -41
  41. package/src/lib/repos/subscription-service.ts +262 -38
  42. package/src/lib/vars/mappings.ts +17 -30
  43. package/src/lib/repos/ref.ts +0 -513
@@ -19,10 +19,14 @@
19
19
  * taskFileRoot the same variable, bare
20
20
  * SOUS_VAR_TASK_FILE_ROOT the environment variable answering it
21
21
  *
22
- * Matching is case-sensitive, because every identifier in sous is (namespaces
23
- * and recipes are lowercase kebab-case, variables are camelCase, environment
24
- * variable names are upper snake case), and a case-insensitive search would
25
- * report a variable and its own environment variable name as the same thing.
22
+ * A location names the same things: an HTTPS, SSH or scheme-less URL, a
23
+ * provider-scheme locator, or a URL copied from the browser, matched against
24
+ * the trusted repository at that location and settled through its index.
25
+ *
26
+ * Matching tries the exact spelling first, then ignores case; a name that still
27
+ * matches several things is a question for `pickReference`. The exact pass
28
+ * comes first because a variable (`apiUrl`) and an environment variable name
29
+ * can differ only in case, and the spelling typed is the one meant.
26
30
  *
27
31
  * The order matches come back in is part of the contract, because it is the
28
32
  * order they are offered in and the one `--accept-first` picks from. Matches
@@ -34,7 +38,17 @@
34
38
  */
35
39
 
36
40
  import type { IndexFile } from "../repos/formats/index-file.js";
37
- import type { ParsedRef } from "../repos/ref.js";
41
+ import { repoIdentity } from "../repos/identity.js";
42
+ import { requireProvider } from "../repos/providers/index.js";
43
+ import {
44
+ isRepositoryReading,
45
+ looksLikeLocation,
46
+ parseRef,
47
+ splitRecipeKey,
48
+ type ParsedRef,
49
+ type RefReading,
50
+ } from "./parse.js";
51
+ import { settleInIndex } from "./settle.js";
38
52
  import type { DefinedVariable } from "../vars/definition-source.js";
39
53
  import { variableCandidates, type LadderContext } from "../vars/ladder.js";
40
54
  import { bareName } from "../vars/names.js";
@@ -58,6 +72,8 @@ export type ReferenceRecipe = {
58
72
  name: string;
59
73
  /** Its one-paragraph summary, when the index carries one. */
60
74
  description?: string;
75
+ /** The folder it lives in, relative to the repository root, when the index says. */
76
+ path?: string;
61
77
  };
62
78
 
63
79
  /** One repository a reference could name, and what it publishes. */
@@ -66,6 +82,11 @@ export type ReferenceRepo = {
66
82
  name: string;
67
83
  /** Where it lives, as the project's config records it. */
68
84
  url?: string;
85
+ /**
86
+ * Its canonical identity, which is how a reference written as a location
87
+ * finds it whatever short name this project gave it.
88
+ */
89
+ identity?: string;
69
90
  /** Every namespace it publishes. */
70
91
  namespaces: ReferenceNamespace[];
71
92
  /** Every recipe it publishes. */
@@ -163,11 +184,40 @@ export function findReference(
163
184
  const term = search.trim();
164
185
  if (term.length === 0) return [];
165
186
 
187
+ if (looksLikeLocation(term)) return findLocated(term, wanted, context);
188
+
189
+ const exact = collectMatches(term, wanted, context, (one, other) => one === other);
190
+ if (exact.length > 0) return exact;
191
+ return collectMatches(
192
+ term,
193
+ wanted,
194
+ context,
195
+ (one, other) => one.toLowerCase() === other.toLowerCase()
196
+ );
197
+ }
198
+
199
+ /** How two spellings are compared in one pass of the search. */
200
+ type SameSpelling = (one: string, other: string) => boolean;
201
+
202
+ /**
203
+ * Every match of a term under one way of comparing spellings.
204
+ *
205
+ * @param term - The reference, trimmed.
206
+ * @param wanted - The kinds of thing the command accepts.
207
+ * @param context - What to search.
208
+ * @param same - Whether two spellings count as the same.
209
+ */
210
+ function collectMatches(
211
+ term: string,
212
+ wanted: Set<SousScope>,
213
+ context: ReferenceContext,
214
+ same: SameSpelling
215
+ ): ReferenceMatch[] {
166
216
  const matches: ReferenceMatch[] = [];
167
217
  const repoOrder = new Map((context.repos ?? []).map((repo, position) => [repo.name, position]));
168
218
 
169
219
  for (const repo of context.repos ?? []) {
170
- if (wanted.has(SousScope.Repository) && term === repo.name) {
220
+ if (wanted.has(SousScope.Repository) && same(term, repo.name)) {
171
221
  matches.push({
172
222
  scope: SousScope.Repository,
173
223
  key: repo.name,
@@ -181,7 +231,7 @@ export function findReference(
181
231
  if (wanted.has(SousScope.Namespace)) {
182
232
  for (const namespace of repo.namespaces) {
183
233
  const key = `${repo.name}:${namespace.name}`;
184
- const qualification = qualificationOf(term, [
234
+ const qualification = qualificationOf(term, same, [
185
235
  [key, Qualification.Full],
186
236
  [namespace.name, Qualification.Bare],
187
237
  ]);
@@ -201,7 +251,7 @@ export function findReference(
201
251
  if (wanted.has(SousScope.Recipe)) {
202
252
  for (const recipe of repo.recipes) {
203
253
  const key = `${repo.name}:${recipe.namespace}/${recipe.name}`;
204
- const qualification = qualificationOf(term, [
254
+ const qualification = qualificationOf(term, same, [
205
255
  [key, Qualification.Full],
206
256
  [`${recipe.namespace}/${recipe.name}`, Qualification.Partial],
207
257
  [`${repo.name}:${recipe.name}`, Qualification.Partial],
@@ -229,7 +279,7 @@ export function findReference(
229
279
  const variable = defined.definition.name;
230
280
 
231
281
  if (wanted.has(SousScope.VariableName)) {
232
- const qualification = qualificationOf(term, [
282
+ const qualification = qualificationOf(term, same, [
233
283
  [key, Qualification.Full],
234
284
  [`${namespace}/${recipe}.${variable}`, Qualification.Partial],
235
285
  [`${recipe}.${variable}`, Qualification.Partial],
@@ -252,16 +302,17 @@ export function findReference(
252
302
  }
253
303
  }
254
304
 
255
- if (wanted.has(SousScope.EnvVarName) && environmentNamesFor(defined, context).has(term)) {
305
+ const envName = [...environmentNamesFor(defined, context)].find((name) => same(term, name));
306
+ if (wanted.has(SousScope.EnvVarName) && envName !== undefined) {
256
307
  matches.push({
257
308
  scope: SousScope.EnvVarName,
258
309
  key,
259
- label: term,
310
+ label: envName,
260
311
  repo,
261
312
  namespace,
262
313
  recipe,
263
314
  variable,
264
- envName: term,
315
+ envName,
265
316
  qualification: Qualification.EnvName,
266
317
  detail: defined.definition.prompt,
267
318
  });
@@ -272,6 +323,96 @@ export function findReference(
272
323
  return sortMatches(matches, repoOrder);
273
324
  }
274
325
 
326
+ /**
327
+ * Every match of a reference written as a location. Each reading of it is
328
+ * looked for among the repositories at that location, whatever short name this
329
+ * project gave them, and settled through that repository's index: a named
330
+ * namespace or recipe exactly first and then ignoring case, a browser path
331
+ * through the folder each recipe lives in. A location that does not parse, or
332
+ * whose repository this project does not trust, names nothing here.
333
+ *
334
+ * @param term - The reference, trimmed.
335
+ * @param wanted - The kinds of thing the command accepts.
336
+ * @param context - What to search.
337
+ */
338
+ function findLocated(
339
+ term: string,
340
+ wanted: Set<SousScope>,
341
+ context: ReferenceContext
342
+ ): ReferenceMatch[] {
343
+ let readings: RefReading[];
344
+ try {
345
+ readings = parseRef(term);
346
+ } catch {
347
+ return [];
348
+ }
349
+
350
+ const matches: ReferenceMatch[] = [];
351
+ const repoOrder = new Map((context.repos ?? []).map((repo, position) => [repo.name, position]));
352
+
353
+ for (const reading of readings) {
354
+ for (const repo of context.repos ?? []) {
355
+ if (repo.identity === undefined || repo.identity !== reading.location?.identity) continue;
356
+
357
+ if (isRepositoryReading(reading)) {
358
+ if (wanted.has(SousScope.Repository)) {
359
+ matches.push({
360
+ scope: SousScope.Repository,
361
+ key: repo.name,
362
+ label: repo.name,
363
+ repo: repo.name,
364
+ qualification: Qualification.Full,
365
+ ...(repo.url === undefined ? {} : { detail: repo.url }),
366
+ });
367
+ }
368
+ continue;
369
+ }
370
+
371
+ const recipes: Record<string, { path: string }> = {};
372
+ for (const recipe of repo.recipes) {
373
+ recipes[`${recipe.namespace}/${recipe.name}`] = { path: recipe.path ?? "" };
374
+ }
375
+ const settled = settleInIndex(reading, {
376
+ namespaces: repo.namespaces.map((namespace) => namespace.name),
377
+ recipes,
378
+ });
379
+
380
+ for (const found of settled) {
381
+ if (found.recipe === undefined) {
382
+ if (!wanted.has(SousScope.Namespace)) continue;
383
+ const namespace = repo.namespaces.find((entry) => entry.name === found.namespace);
384
+ matches.push({
385
+ scope: SousScope.Namespace,
386
+ key: `${repo.name}:${found.namespace}`,
387
+ label: found.namespace,
388
+ repo: repo.name,
389
+ namespace: found.namespace,
390
+ qualification: Qualification.Full,
391
+ ...(namespace?.description === undefined ? {} : { detail: namespace.description }),
392
+ });
393
+ continue;
394
+ }
395
+ if (!wanted.has(SousScope.Recipe)) continue;
396
+ const recipe = repo.recipes.find(
397
+ (entry) => entry.namespace === found.namespace && entry.name === found.recipe
398
+ );
399
+ matches.push({
400
+ scope: SousScope.Recipe,
401
+ key: `${repo.name}:${found.namespace}/${found.recipe}`,
402
+ label: found.recipe,
403
+ repo: repo.name,
404
+ namespace: found.namespace,
405
+ recipe: found.recipe,
406
+ qualification: Qualification.Full,
407
+ ...(recipe?.description === undefined ? {} : { detail: recipe.description }),
408
+ });
409
+ }
410
+ }
411
+ }
412
+
413
+ return sortMatches(matches, repoOrder);
414
+ }
415
+
275
416
  /**
276
417
  * The repository a reference names.
277
418
  *
@@ -395,22 +536,43 @@ export function referenceReposFromIndexes(
395
536
 
396
537
  const recipes: ReferenceRecipe[] = [];
397
538
  for (const [key, recipe] of Object.entries(index.recipes)) {
398
- const slash = key.indexOf("/");
399
- if (slash === -1) continue;
539
+ const { namespace, name: recipeName } = splitRecipeKey(key);
400
540
  recipes.push({
401
- namespace: key.slice(0, slash),
402
- name: key.slice(slash + 1),
541
+ namespace,
542
+ name: recipeName,
543
+ path: recipe.path,
403
544
  ...(recipe.description === undefined ? {} : { description: recipe.description }),
404
545
  });
405
546
  }
406
547
 
407
548
  const url = urls[name];
408
- repos.push({ name, namespaces, recipes, ...(url === undefined ? {} : { url }) });
549
+ const identity = url === undefined ? undefined : identityOfUrl(url);
550
+ repos.push({
551
+ name,
552
+ namespaces,
553
+ recipes,
554
+ ...(url === undefined ? {} : { url }),
555
+ ...(identity === undefined ? {} : { identity }),
556
+ });
409
557
  }
410
558
 
411
559
  return repos;
412
560
  }
413
561
 
562
+ /**
563
+ * The canonical identity of a repository at a URL, or undefined when no
564
+ * provider recognizes it.
565
+ *
566
+ * @param url - Where the repository lives.
567
+ */
568
+ function identityOfUrl(url: string): string | undefined {
569
+ try {
570
+ return repoIdentity(requireProvider(url).canonicalize(url));
571
+ } catch {
572
+ return undefined;
573
+ }
574
+ }
575
+
414
576
  /**
415
577
  * The repositories, namespaces and recipes the variable definitions in play
416
578
  * belong to, derived from the definitions themselves.
@@ -421,21 +583,35 @@ export function referenceReposFromIndexes(
421
583
  * context from the definitions also means `sous vars ask --file` resolves
422
584
  * references exactly as a subscribed project does.
423
585
  *
586
+ * The trusted repositories, when given, lend each repository its identity and
587
+ * URL and each recipe its folder, so a reference written as a location (a URL,
588
+ * a locator, a URL copied from the browser) settles here exactly as it does in
589
+ * every other command.
590
+ *
424
591
  * @param variables - The variable definitions in play.
425
592
  * @param ladder - The environment layers, for environment variable names.
593
+ * @param trusted - The trusted repositories as a reference searches them.
426
594
  */
427
595
  export function referenceContextFromVariables(
428
596
  variables: DefinedVariable[],
429
- ladder?: LadderContext
597
+ ladder?: LadderContext,
598
+ trusted: readonly ReferenceRepo[] = []
430
599
  ): ReferenceContext {
431
600
  const repos = new Map<string, ReferenceRepo>();
432
601
 
433
602
  for (const defined of variables) {
434
603
  const { repo: repoName, namespace, name: recipe } = defined.recipe;
604
+ const known = trusted.find((entry) => entry.name === repoName);
435
605
 
436
606
  let repo = repos.get(repoName);
437
607
  if (repo === undefined) {
438
- repo = { name: repoName, namespaces: [], recipes: [] };
608
+ repo = {
609
+ name: repoName,
610
+ namespaces: [],
611
+ recipes: [],
612
+ ...(known?.url === undefined ? {} : { url: known.url }),
613
+ ...(known?.identity === undefined ? {} : { identity: known.identity }),
614
+ };
439
615
  repos.set(repoName, repo);
440
616
  }
441
617
 
@@ -443,7 +619,10 @@ export function referenceContextFromVariables(
443
619
  repo.namespaces.push({ name: namespace });
444
620
  }
445
621
  if (!repo.recipes.some((entry) => entry.namespace === namespace && entry.name === recipe)) {
446
- repo.recipes.push({ namespace, name: recipe });
622
+ const path = known?.recipes.find(
623
+ (entry) => entry.namespace === namespace && entry.name === recipe
624
+ )?.path;
625
+ repo.recipes.push({ namespace, name: recipe, ...(path === undefined ? {} : { path }) });
447
626
  }
448
627
  }
449
628
 
@@ -494,14 +673,16 @@ export function referenceToRef(match: ReferenceMatch, original: ParsedRef): Pars
494
673
  * the first one that matches wins.
495
674
  *
496
675
  * @param term - The search term.
676
+ * @param same - Whether two spellings count as the same.
497
677
  * @param spellings - Each spelling of this thing, with how qualified it is.
498
678
  */
499
679
  function qualificationOf(
500
680
  term: string,
681
+ same: SameSpelling,
501
682
  spellings: [string, Qualification][]
502
683
  ): Qualification | undefined {
503
684
  for (const [spelling, qualification] of spellings) {
504
- if (spelling === term) return qualification;
685
+ if (same(spelling, term)) return qualification;
505
686
  }
506
687
  return undefined;
507
688
  }
@@ -8,5 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from "./scopes.js";
11
+ export * from "./parse.js";
12
+ export * from "./settle.js";
11
13
  export * from "./find.js";
12
14
  export * from "./pick.js";