@sous-io/sous 0.2.27 → 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
@@ -1,513 +0,0 @@
1
- /**
2
- * The ref parser.
3
- *
4
- * A "ref" is how everything in the Repositories system names a namespace or a
5
- * recipe: on the command line, in a project's subscriptions, and in a recipe
6
- * manifest's `depends` and `subscribes` lists. The grammar is deliberately
7
- * small and prefix-free:
8
- *
9
- * ref := [ repo ":" ] namespace [ "/" recipe [ "@" range ] ]
10
- *
11
- * workflow a whole namespace
12
- * workflow/task-files one recipe, any version
13
- * workflow/task-files@^1.2.0 one recipe, constrained
14
- * sous-recipes:workflow/task-files the same recipe in a named repo
15
- *
16
- * The repo qualifier is only needed when the same ref resolves in more than one
17
- * added repo; refs otherwise resolve across every added repo's cached index.
18
- * A version range applies to a recipe, never to a namespace, because namespaces
19
- * are not versioned.
20
- */
21
-
22
- import semver from "semver";
23
- import { ConfigError } from "../errors.js";
24
- import {
25
- NAMESPACE_NAME_PATTERN,
26
- RECIPE_NAME_PATTERN,
27
- REPO_NAME_PATTERN,
28
- } from "./formats/patterns.js";
29
-
30
- /** A parsed ref. `recipe` is absent for a namespace ref; `range` needs a recipe. */
31
- export type ParsedRef = {
32
- /** The repo short name from a `repo:` qualifier, when one was given. */
33
- repo?: string;
34
- /** The namespace. Always present. */
35
- namespace: string;
36
- /** The recipe name, when the ref names a recipe rather than a whole namespace. */
37
- recipe?: string;
38
- /** The semantic version range, when one was given. Only legal with a recipe. */
39
- range?: string;
40
- };
41
-
42
- /** The one-line reminder appended to every ref error. */
43
- const SYNTAX_HELP =
44
- "A ref is written as 'namespace', 'namespace/recipe', 'namespace/recipe@<range>' or " +
45
- "'repo:namespace/recipe@<range>'.";
46
-
47
- /** Builds a ConfigError that quotes the offending input and shows the grammar. */
48
- function refError(input: string, problem: string): ConfigError {
49
- return new ConfigError(`Invalid ref '${input}': ${problem}\n ${SYNTAX_HELP}`);
50
- }
51
-
52
- /**
53
- * Parses a ref string into its parts, throwing a ConfigError that quotes the
54
- * input and shows the grammar when it does not fit.
55
- *
56
- * @param input - The ref as written by a user or a manifest.
57
- */
58
- export function parseRef(input: string): ParsedRef {
59
- if (typeof input !== "string") {
60
- throw refError(String(input), "a ref must be a string.");
61
- }
62
-
63
- const trimmed = input.trim();
64
- if (trimmed.length === 0) {
65
- throw refError(input, "a ref must not be empty.");
66
- }
67
-
68
- if (trimmed.startsWith("@")) {
69
- throw refError(
70
- input,
71
- "refs take no '@' prefix. The '@' character introduces a version range only, " +
72
- "as in 'workflow/task-files@^1.2.0'."
73
- );
74
- }
75
- if (trimmed.startsWith("~")) {
76
- throw refError(
77
- input,
78
- "refs take no '~' prefix. The '~' sigil belongs to template include lines " +
79
- "('@~workflow/file.md'); a ref itself is written without it."
80
- );
81
- }
82
-
83
- // Split the version range off first, so the rest is pure path and qualifier.
84
- let body = trimmed;
85
- let range: string | undefined;
86
- const atIndex = body.indexOf("@");
87
- if (atIndex !== -1) {
88
- if (body.indexOf("@", atIndex + 1) !== -1) {
89
- throw refError(input, "a ref may carry at most one '@' version range.");
90
- }
91
- range = body.slice(atIndex + 1).trim();
92
- body = body.slice(0, atIndex);
93
- if (range.length === 0) {
94
- throw refError(input, "the '@' is not followed by a version range.");
95
- }
96
- }
97
-
98
- // Then the repo qualifier.
99
- let repo: string | undefined;
100
- const colonIndex = body.indexOf(":");
101
- if (colonIndex !== -1) {
102
- if (body.indexOf(":", colonIndex + 1) !== -1) {
103
- throw refError(input, "a ref may carry at most one 'repo:' qualifier.");
104
- }
105
- repo = body.slice(0, colonIndex);
106
- body = body.slice(colonIndex + 1);
107
- if (repo.length === 0) {
108
- throw refError(input, "the repo qualifier before ':' is empty.");
109
- }
110
- if (!REPO_NAME_PATTERN.test(repo)) {
111
- throw refError(
112
- input,
113
- `the repo qualifier '${repo}' must be lowercase kebab-case: a letter, then ` +
114
- "letters, digits or hyphens."
115
- );
116
- }
117
- }
118
-
119
- // What is left is the namespace, optionally followed by a recipe.
120
- const segments = body.split("/");
121
- if (segments.length > 2) {
122
- throw refError(
123
- input,
124
- "a ref has at most two path segments, a namespace and a recipe."
125
- );
126
- }
127
-
128
- const [namespace, recipe] = segments;
129
- if (namespace === undefined || namespace.length === 0) {
130
- throw refError(input, "the namespace is empty.");
131
- }
132
- if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
133
- throw refError(
134
- input,
135
- `the namespace '${namespace}' must be lowercase kebab-case: a letter, then ` +
136
- "letters, digits or hyphens."
137
- );
138
- }
139
-
140
- if (recipe !== undefined) {
141
- if (recipe.length === 0) {
142
- throw refError(input, "the recipe name after '/' is empty.");
143
- }
144
- if (!RECIPE_NAME_PATTERN.test(recipe)) {
145
- throw refError(
146
- input,
147
- `the recipe name '${recipe}' must be lowercase kebab-case: a letter, then ` +
148
- "letters, digits or hyphens."
149
- );
150
- }
151
- }
152
-
153
- if (range !== undefined) {
154
- if (recipe === undefined) {
155
- throw refError(
156
- input,
157
- "a version range applies to a recipe, and namespaces are not versioned. " +
158
- "Name a recipe, as in 'workflow/task-files@^1.2.0'."
159
- );
160
- }
161
- if (semver.validRange(range) === null) {
162
- throw refError(
163
- input,
164
- `'${range}' is not a version range. Ranges follow npm's rules, such as ` +
165
- "'^1.2.0', '~2.1', '>=1.0.0 <2.0.0' or '*'."
166
- );
167
- }
168
- }
169
-
170
- const parsed: ParsedRef = { namespace };
171
- if (repo !== undefined) parsed.repo = repo;
172
- if (recipe !== undefined) parsed.recipe = recipe;
173
- if (range !== undefined) parsed.range = range;
174
- return parsed;
175
- }
176
-
177
- /**
178
- * Parses a ref, returning undefined instead of throwing. Use this where a bad
179
- * ref is reported through another mechanism, such as a zod issue.
180
- *
181
- * @param input - The ref as written.
182
- */
183
- export function tryParseRef(input: string): ParsedRef | undefined {
184
- try {
185
- return parseRef(input);
186
- } catch {
187
- return undefined;
188
- }
189
- }
190
-
191
- /**
192
- * True when the input parses as a ref.
193
- *
194
- * @param input - The ref as written.
195
- */
196
- export function isValidRef(input: string): boolean {
197
- return tryParseRef(input) !== undefined;
198
- }
199
-
200
- /**
201
- * Renders a parsed ref back into its canonical written form. Round-trips with
202
- * parseRef, apart from surrounding whitespace.
203
- *
204
- * @param parsed - The ref parts.
205
- */
206
- export function formatRef(parsed: ParsedRef): string {
207
- const qualifier = parsed.repo === undefined ? "" : `${parsed.repo}:`;
208
- const recipe = parsed.recipe === undefined ? "" : `/${parsed.recipe}`;
209
- const range = parsed.range === undefined ? "" : `@${parsed.range}`;
210
- return `${qualifier}${parsed.namespace}${recipe}${range}`;
211
- }
212
-
213
- /**
214
- * The ref's identity, with the repo qualifier and the version range dropped:
215
- * `namespace` for a namespace ref, `namespace/recipe` for a recipe ref. This is
216
- * the key everything else is stored under (subscriptions, the index, the
217
- * lockfile), so the same recipe is never recorded twice under two spellings.
218
- *
219
- * @param parsed - The ref parts.
220
- */
221
- export function refKey(parsed: ParsedRef): string {
222
- return parsed.recipe === undefined
223
- ? parsed.namespace
224
- : `${parsed.namespace}/${parsed.recipe}`;
225
- }
226
-
227
- /** True when the ref names a whole namespace rather than a single recipe. */
228
- export function isNamespaceRef(parsed: ParsedRef): boolean {
229
- return parsed.recipe === undefined;
230
- }
231
-
232
- // --- Dependency refs ----------------------------------------------------------------------------
233
-
234
- /**
235
- * The default host of each provider that can appear as a dependency locator's
236
- * scheme. A locator whose repository path does not begin with a host segment
237
- * (a segment carrying a dot) means the provider's own public host.
238
- *
239
- * The values match the `GITHUB_HOST` and `GITLAB_HOST` constants the providers
240
- * themselves use; they are repeated here because the ref parser deliberately
241
- * imports nothing from the provider layer, which is built on top of it.
242
- */
243
- export const DEPENDENCY_PROVIDER_HOSTS: Readonly<Record<string, string>> = {
244
- github: "github.com",
245
- gitlab: "gitlab.com",
246
- };
247
-
248
- /**
249
- * A dependency as a recipe manifest writes it, in either of the two spellings
250
- * `depends` and `subscribes` accept.
251
- *
252
- * A SIBLING is a recipe in the same repository, written as a bare ref; a REMOTE
253
- * is a recipe in another repository, written as a locator URL whose scheme is
254
- * the provider's identifier.
255
- */
256
- export type DependencyRef = {
257
- /** Whether the target lives in this repository or in another one. */
258
- kind: "sibling" | "remote";
259
- /** The provider identifier the locator's scheme named. Remote refs only. */
260
- provider?: string;
261
- /** The host the repository lives on, explicit or the provider's default. */
262
- host?: string;
263
- /** The repository's path on that host, such as `sous-io/sous-recipes`. */
264
- repoPath?: string;
265
- /** The repository's canonical identity, `<host>/<repoPath>`. Remote refs only. */
266
- canonicalRepo?: string;
267
- /** The namespace the target recipe belongs to. */
268
- namespace: string;
269
- /**
270
- * The recipe's name. A sibling ref may name a whole namespace and leave this
271
- * out; a locator URL always names a recipe, because its last two path
272
- * segments are by rule the namespace and the recipe.
273
- */
274
- recipe?: string;
275
- /** The version range, when one was written. */
276
- range?: string;
277
- };
278
-
279
- /** The one-line reminder appended to every dependency ref error. */
280
- const DEPENDENCY_SYNTAX_HELP =
281
- "A dependency is written either as a bare ref naming a recipe in this same repository " +
282
- "('workflow/task-files', optionally with a range such as 'workflow/task-files@^1.1'), " +
283
- "or as a locator URL naming a recipe in another repository " +
284
- "('github://sous-io/sous-recipes/workflow/task-files@^1.1').";
285
-
286
- /** Builds a ConfigError that quotes the offending dependency and shows the grammar. */
287
- function dependencyError(input: string, problem: string): ConfigError {
288
- return new ConfigError(
289
- `Invalid dependency '${input}': ${problem}\n ${DEPENDENCY_SYNTAX_HELP}`
290
- );
291
- }
292
-
293
- /** The scheme a locator URL leads with, when it leads with one. */
294
- const LOCATOR_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i;
295
-
296
- /**
297
- * Parses one entry of a recipe manifest's `depends` or `subscribes` list.
298
- *
299
- * parseDependencyRef("workflow/task-files");
300
- * // -> { kind: "sibling", namespace: "workflow", recipe: "task-files" }
301
- *
302
- * parseDependencyRef("github://sous-io/sous-recipes/workflow/sat@^1.1");
303
- * // -> { kind: "remote", provider: "github", host: "github.com",
304
- * // repoPath: "sous-io/sous-recipes",
305
- * // canonicalRepo: "github.com/sous-io/sous-recipes",
306
- * // namespace: "workflow", recipe: "sat", range: "^1.1" }
307
- *
308
- * @param input - The dependency exactly as the manifest wrote it.
309
- */
310
- export function parseDependencyRef(input: string): DependencyRef {
311
- if (typeof input !== "string") {
312
- throw dependencyError(String(input), "a dependency must be a string.");
313
- }
314
-
315
- const trimmed = input.trim();
316
- if (trimmed.length === 0) {
317
- throw dependencyError(input, "a dependency must not be empty.");
318
- }
319
-
320
- const locator = LOCATOR_SCHEME_PATTERN.exec(trimmed);
321
- if (locator !== null) return parseRemoteDependency(input, locator[1]!, locator[2]!);
322
-
323
- // Anything without a scheme is a sibling, so it follows the ordinary ref
324
- // grammar; the one thing a manifest may no longer write is the consumer-side
325
- // 'repo:' qualifier, which names a short name only that project knows.
326
- if (trimmed.includes(":")) {
327
- throw dependencyError(
328
- input,
329
- "a 'repo:' qualifier names a short name that only the consuming project knows, so " +
330
- "it cannot appear in a published manifest. Name the other repository by its " +
331
- "location instead, as in 'github://owner/repository/namespace/recipe'."
332
- );
333
- }
334
-
335
- const parsed = parseRef(trimmed);
336
- const sibling: DependencyRef = { kind: "sibling", namespace: parsed.namespace };
337
- if (parsed.recipe !== undefined) sibling.recipe = parsed.recipe;
338
- if (parsed.range !== undefined) sibling.range = parsed.range;
339
- return sibling;
340
- }
341
-
342
- /**
343
- * Parses the locator form, whose scheme is the provider's identifier.
344
- *
345
- * The path is read from the RIGHT: the last two segments are always the
346
- * namespace and the recipe, because they are the recipe's published identity
347
- * and never a filesystem path. Everything before them is the repository, whose
348
- * first segment is the host when it carries a dot and otherwise the provider's
349
- * own public host.
350
- *
351
- * @param input - The dependency as written, for error messages.
352
- * @param scheme - The scheme, which names the provider.
353
- * @param rest - Everything after the `://`.
354
- */
355
- function parseRemoteDependency(
356
- input: string,
357
- scheme: string,
358
- rest: string
359
- ): DependencyRef {
360
- const provider = scheme.toLowerCase();
361
-
362
- if (provider === "local") {
363
- throw dependencyError(
364
- input,
365
- "a local repository is a consumer's convenience, not a published location, so a " +
366
- "manifest cannot depend on one. Publish the recipe and depend on it by its " +
367
- "published location."
368
- );
369
- }
370
-
371
- const defaultHost = DEPENDENCY_PROVIDER_HOSTS[provider];
372
- if (defaultHost === undefined) {
373
- const known = Object.keys(DEPENDENCY_PROVIDER_HOSTS).sort().join(", ");
374
- throw dependencyError(
375
- input,
376
- `'${provider}' is not a provider sous can fetch from. The scheme of a locator URL ` +
377
- `is the provider's own identifier; sous ships these: ${known}.`
378
- );
379
- }
380
-
381
- let body = rest.trim();
382
- let range: string | undefined;
383
- const atIndex = body.indexOf("@");
384
- if (atIndex !== -1) {
385
- if (body.indexOf("@", atIndex + 1) !== -1) {
386
- throw dependencyError(input, "a dependency may carry at most one '@' version range.");
387
- }
388
- range = body.slice(atIndex + 1).trim();
389
- body = body.slice(0, atIndex);
390
- if (range.length === 0) {
391
- throw dependencyError(input, "the '@' is not followed by a version range.");
392
- }
393
- if (semver.validRange(range) === null) {
394
- throw dependencyError(
395
- input,
396
- `'${range}' is not a version range. Ranges follow npm's rules, such as '^1.2.0', ` +
397
- "'~2.1', '>=1.0.0 <2.0.0' or '*'."
398
- );
399
- }
400
- }
401
-
402
- const segments = body.split("/").filter((segment) => segment.length > 0);
403
-
404
- // The recipe is two segments and a repository is at least an owner and a
405
- // name, so four is the shortest locator that can mean anything.
406
- if (segments.length < 4) {
407
- throw dependencyError(
408
- input,
409
- "a locator URL names a repository and then the recipe inside it, as in " +
410
- "'github://owner/repository/namespace/recipe'. The last two segments are always " +
411
- "the namespace and the recipe."
412
- );
413
- }
414
-
415
- const recipe = segments.pop()!;
416
- const namespace = segments.pop()!;
417
-
418
- if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
419
- throw dependencyError(
420
- input,
421
- `the namespace '${namespace}' must be lowercase kebab-case: a letter, then letters, ` +
422
- "digits or hyphens."
423
- );
424
- }
425
- if (!RECIPE_NAME_PATTERN.test(recipe)) {
426
- throw dependencyError(
427
- input,
428
- `the recipe name '${recipe}' must be lowercase kebab-case: a letter, then letters, ` +
429
- "digits or hyphens."
430
- );
431
- }
432
-
433
- const first = segments[0]!;
434
- const hasHost = first.includes(".");
435
- const host = (hasHost ? first : defaultHost).toLowerCase();
436
- const pathSegments = hasHost ? segments.slice(1) : segments;
437
-
438
- if (pathSegments.length < 2) {
439
- const what =
440
- pathSegments.length === 0 ? "nothing" : `only '${pathSegments.join("/")}'`;
441
- throw dependencyError(
442
- input,
443
- `the host '${host}' is followed by ${what}, and a repository is named by an owner ` +
444
- "and a repository name, as in " +
445
- "'gitlab://gitlab.example.com/group/subgroup/project/namespace/recipe'."
446
- );
447
- }
448
-
449
- const repoPath = pathSegments.join("/").replace(/\.git$/i, "");
450
-
451
- return {
452
- kind: "remote",
453
- provider,
454
- host,
455
- repoPath,
456
- canonicalRepo: `${host}/${repoPath}`.toLowerCase(),
457
- namespace,
458
- recipe,
459
- ...(range === undefined ? {} : { range }),
460
- };
461
- }
462
-
463
- /**
464
- * The recipe key a dependency names: `namespace/recipe`, or the bare namespace
465
- * when a sibling ref named a whole namespace. This is what the index, the
466
- * lockfile and the store all file a recipe under.
467
- *
468
- * @param parsed - The parsed dependency.
469
- */
470
- export function dependencyRefKey(parsed: DependencyRef): string {
471
- return parsed.recipe === undefined
472
- ? parsed.namespace
473
- : `${parsed.namespace}/${parsed.recipe}`;
474
- }
475
-
476
- /**
477
- * Renders a parsed dependency back into the form a manifest writes. Round-trips
478
- * with parseDependencyRef, apart from surrounding whitespace and a host that was
479
- * left implicit.
480
- *
481
- * @param parsed - The parsed dependency.
482
- */
483
- export function formatDependencyRef(parsed: DependencyRef): string {
484
- const key = dependencyRefKey(parsed);
485
- const range = parsed.range === undefined ? "" : `@${parsed.range}`;
486
- if (parsed.kind === "sibling") return `${key}${range}`;
487
- return `${parsed.provider}://${parsed.host}/${parsed.repoPath}/${key}${range}`;
488
- }
489
-
490
- /**
491
- * Parses a dependency, returning undefined instead of throwing. Use this where
492
- * a bad dependency is reported through another mechanism, such as a zod issue.
493
- *
494
- * @param input - The dependency as written.
495
- */
496
- export function tryParseDependencyRef(input: string): DependencyRef | undefined {
497
- try {
498
- return parseDependencyRef(input);
499
- } catch {
500
- return undefined;
501
- }
502
- }
503
-
504
- /**
505
- * The HTTPS location a remote dependency points at, which is what `sous repo
506
- * add` would be given for it.
507
- *
508
- * @param parsed - A parsed remote dependency.
509
- */
510
- export function dependencyRepoUrl(parsed: DependencyRef): string | undefined {
511
- if (parsed.kind !== "remote") return undefined;
512
- return `https://${parsed.host}/${parsed.repoPath}`;
513
- }