@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.
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/repositories-authoring.md +12 -8
- package/docs/markdown/repositories-consuming.md +8 -1
- package/docs/markdown/repositories-file-formats.md +53 -8
- package/docs/markdown/repositories-providers.md +8 -6
- package/docs/markdown/repositories-troubleshooting.md +6 -0
- package/docs/markdown/repositories-variables.md +9 -2
- package/docs/markdown/repositories.md +3 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/lock/rebuild.ts +2 -2
- package/src/commands/repo/contribute.ts +1 -10
- package/src/commands/repo/gc.ts +3 -2
- package/src/commands/repo/release.ts +20 -3
- package/src/commands/repo/search.ts +2 -1
- package/src/commands/vars/ask.ts +32 -10
- package/src/lib/refs/find.ts +202 -21
- package/src/lib/refs/index.ts +2 -0
- package/src/lib/refs/parse.ts +844 -0
- package/src/lib/refs/scopes.ts +34 -0
- package/src/lib/refs/settle.ts +219 -0
- package/src/lib/repos/catalog.ts +44 -25
- package/src/lib/repos/formats/index-file.ts +2 -1
- package/src/lib/repos/formats/recipe-manifest.ts +10 -8
- package/src/lib/repos/index.ts +0 -1
- package/src/lib/repos/lock-service.ts +3 -2
- package/src/lib/repos/locked-namespace-resolver.ts +21 -15
- package/src/lib/repos/locked-recipes.ts +2 -2
- package/src/lib/repos/namespace-resolver.ts +40 -47
- package/src/lib/repos/providers/base.ts +30 -0
- package/src/lib/repos/providers/github.ts +22 -0
- package/src/lib/repos/providers/gitlab.ts +68 -0
- package/src/lib/repos/providers/provider.ts +96 -0
- package/src/lib/repos/release/index-builder.ts +55 -17
- package/src/lib/repos/release/index.ts +1 -0
- package/src/lib/repos/release/plan.ts +8 -6
- package/src/lib/repos/release/settle.ts +237 -0
- package/src/lib/repos/release/tags.ts +3 -7
- package/src/lib/repos/resolver.ts +198 -41
- package/src/lib/repos/subscription-service.ts +262 -38
- package/src/lib/vars/mappings.ts +17 -30
- package/src/lib/repos/ref.ts +0 -513
package/src/lib/refs/scopes.ts
CHANGED
|
@@ -23,6 +23,40 @@ export enum SousScope {
|
|
|
23
23
|
EnvVarName = "envVarName",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Where a ref was written. `parseRef` reads every form wherever a ref comes
|
|
28
|
+
* from, and the place decides which of those forms it allows: a refused form
|
|
29
|
+
* is an error saying what to write there instead.
|
|
30
|
+
*/
|
|
31
|
+
export enum RefSource {
|
|
32
|
+
/**
|
|
33
|
+
* A ref typed on the command line. Every form is allowed: a bare name, a
|
|
34
|
+
* namespace and recipe, a `repo:` qualifier, a version range, a
|
|
35
|
+
* provider-scheme locator, an HTTPS, SSH or scheme-less URL, and a browser
|
|
36
|
+
* URL copied from a host's file view. Names may be written in any case.
|
|
37
|
+
*/
|
|
38
|
+
CommandLine = "commandLine",
|
|
39
|
+
/**
|
|
40
|
+
* A subscription key in a config layer. Only the stored form is allowed:
|
|
41
|
+
* `namespace` or `namespace/recipe`, lowercase. The repository a
|
|
42
|
+
* subscription resolves into is recorded in the lockfile, and its range in
|
|
43
|
+
* the entry's own `range` field.
|
|
44
|
+
*/
|
|
45
|
+
Config = "config",
|
|
46
|
+
/**
|
|
47
|
+
* An entry of a recipe manifest's `depends` or `subscribes` list. A bare ref
|
|
48
|
+
* names a recipe in the same repository; every locator and URL form names
|
|
49
|
+
* one in another repository. A `repo:` qualifier is refused, because it is
|
|
50
|
+
* one project's private name for a repository, and so is a local path.
|
|
51
|
+
*/
|
|
52
|
+
Manifest = "manifest",
|
|
53
|
+
/**
|
|
54
|
+
* A key sous wrote itself: the lockfile, the index and the store. Only the
|
|
55
|
+
* canonical `namespace` or `namespace/recipe` is allowed.
|
|
56
|
+
*/
|
|
57
|
+
Lockfile = "lockfile",
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Every scope, in the order matches of equal specificity are listed in. It is
|
|
28
62
|
* the order the one-word ref search has always used (a whole namespace before
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settling what a ref means, once the index that can say is at hand.
|
|
3
|
+
*
|
|
4
|
+
* `parseRef` returns every reading of a written ref. Some readings are settled
|
|
5
|
+
* the moment they are read (`workflow/alpha`), and some need the index of the
|
|
6
|
+
* repository they point at: a browser URL names a folder, and only the `path`
|
|
7
|
+
* each recipe's index entry records says which recipe that folder is; a GitLab
|
|
8
|
+
* URL with nested groups can be read as several repositories, and only their
|
|
9
|
+
* indexes say which one publishes what was named.
|
|
10
|
+
*
|
|
11
|
+
* Names are matched the same way everywhere: the exact spelling first, then
|
|
12
|
+
* ignoring case. A name that still matches several things is a question for
|
|
13
|
+
* `pickReference`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
isBrowsedReading,
|
|
18
|
+
isNamedReading,
|
|
19
|
+
namespaceOfKey,
|
|
20
|
+
parseRef,
|
|
21
|
+
refKey,
|
|
22
|
+
splitRecipeKey,
|
|
23
|
+
type ParsedRef,
|
|
24
|
+
type RefReading,
|
|
25
|
+
} from "./parse.js";
|
|
26
|
+
import { RefSource } from "./scopes.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The names that match a written one: those spelled exactly as written, or,
|
|
30
|
+
* when there is none, those that differ only in case.
|
|
31
|
+
*
|
|
32
|
+
* matchNames("Workflow", ["workflow", "tooling"]); // -> ["workflow"]
|
|
33
|
+
* matchNames("workflow", ["workflow", "Workflow"]); // -> ["workflow"]
|
|
34
|
+
*
|
|
35
|
+
* @param written - The name as written.
|
|
36
|
+
* @param names - The names it could mean.
|
|
37
|
+
*/
|
|
38
|
+
export function matchNames(written: string, names: Iterable<string>): string[] {
|
|
39
|
+
const all = [...names];
|
|
40
|
+
const exact = all.filter((name) => name === written);
|
|
41
|
+
if (exact.length > 0) return exact;
|
|
42
|
+
const folded = written.toLowerCase();
|
|
43
|
+
return all.filter((name) => name.toLowerCase() === folded);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What settling needs to know about one repository's index. */
|
|
47
|
+
export type SettleIndex = {
|
|
48
|
+
/** Every namespace the index publishes. */
|
|
49
|
+
namespaces: Iterable<string>;
|
|
50
|
+
/** Every recipe it publishes, keyed `namespace/recipe`, with the folder it lives in. */
|
|
51
|
+
recipes: Record<string, { path: string }>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a reading names in one repository's index, spelled the way the index
|
|
56
|
+
* spells it. A named reading that the index does not publish settles to
|
|
57
|
+
* nothing; a browsed path settles through the recipe paths.
|
|
58
|
+
*
|
|
59
|
+
* settleInIndex({ namespace: "Workflow", recipe: "alpha" }, index);
|
|
60
|
+
* // -> [{ namespace: "workflow", recipe: "alpha" }]
|
|
61
|
+
*
|
|
62
|
+
* @param reading - One reading of a written ref.
|
|
63
|
+
* @param index - The index of the repository the reading points at.
|
|
64
|
+
*/
|
|
65
|
+
export function settleInIndex(reading: RefReading, index: SettleIndex): ParsedRef[] {
|
|
66
|
+
const carried = {
|
|
67
|
+
...(reading.location === undefined ? {} : { location: reading.location }),
|
|
68
|
+
...("range" in reading && reading.range !== undefined ? { range: reading.range } : {}),
|
|
69
|
+
...("repo" in reading && reading.repo !== undefined ? { repo: reading.repo } : {}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (isBrowsedReading(reading)) {
|
|
73
|
+
return settleBrowsed(reading.browsed, index).map((settled) => ({ ...settled, ...carried }));
|
|
74
|
+
}
|
|
75
|
+
if (!isNamedReading(reading)) return [];
|
|
76
|
+
|
|
77
|
+
if (reading.recipe === undefined) {
|
|
78
|
+
return matchNames(reading.namespace, index.namespaces).map((namespace) => ({
|
|
79
|
+
namespace,
|
|
80
|
+
...carried,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return matchNames(refKey(reading), Object.keys(index.recipes)).map((key) => {
|
|
85
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
86
|
+
return { namespace, recipe: name, ...carried };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a browser path names, through the folder each recipe's index entry
|
|
92
|
+
* records. The path still carries the branch in front (which may itself hold
|
|
93
|
+
* slashes), so every split is tried: the folder of a recipe, or anything inside
|
|
94
|
+
* it, names that recipe; a folder holding recipes of exactly one namespace
|
|
95
|
+
* names that namespace.
|
|
96
|
+
*
|
|
97
|
+
* settleBrowsed("main/recipes/workflow/alpha/SKILL.md", index);
|
|
98
|
+
* // -> [{ namespace: "workflow", recipe: "alpha" }]
|
|
99
|
+
*
|
|
100
|
+
* @param browsed - The path after `tree/` or `blob/`, branch included.
|
|
101
|
+
* @param index - The repository's index.
|
|
102
|
+
*/
|
|
103
|
+
export function settleBrowsed(browsed: string, index: SettleIndex): ParsedRef[] {
|
|
104
|
+
const segments = browsed.split("/").filter((segment) => segment.length > 0);
|
|
105
|
+
const recipes = Object.entries(index.recipes).map(([key, entry]) => ({
|
|
106
|
+
key,
|
|
107
|
+
path: entry.path.replace(/^\.?\/+|\/+$/g, ""),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const found = new Map<string, ParsedRef>();
|
|
111
|
+
|
|
112
|
+
// A recipe's folder, or a file inside it. The deepest recipe wins, so a
|
|
113
|
+
// recipe nested inside another's folder is named by its own path.
|
|
114
|
+
for (let start = 1; start < segments.length; start++) {
|
|
115
|
+
const folder = segments.slice(start).join("/");
|
|
116
|
+
const holding = recipes
|
|
117
|
+
.filter((recipe) => folder === recipe.path || folder.startsWith(`${recipe.path}/`))
|
|
118
|
+
.sort((left, right) => right.path.length - left.path.length);
|
|
119
|
+
const deepest = holding[0];
|
|
120
|
+
if (deepest !== undefined) {
|
|
121
|
+
const { namespace, name } = splitRecipeKey(deepest.key);
|
|
122
|
+
found.set(deepest.key, { namespace, recipe: name });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (found.size > 0) return [...found.values()];
|
|
126
|
+
|
|
127
|
+
// A folder above recipes: a namespace, when everything under it is one.
|
|
128
|
+
for (let start = 1; start < segments.length; start++) {
|
|
129
|
+
const folder = segments.slice(start).join("/");
|
|
130
|
+
const under = recipes.filter((recipe) => recipe.path.startsWith(`${folder}/`));
|
|
131
|
+
const namespaces = new Set(under.map((recipe) => namespaceOfKey(recipe.key)));
|
|
132
|
+
if (namespaces.size === 1) {
|
|
133
|
+
const namespace = [...namespaces][0]!;
|
|
134
|
+
found.set(namespace, { namespace });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...found.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a repository's index recorded about one dependency, as far as settling needs. */
|
|
141
|
+
export type RecordedDependencies = Record<string, { repo?: string } | undefined>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The readings of a manifest dependency that the declaring recipe's index
|
|
145
|
+
* confirms. A release settles a dependency that reads more than one way (a
|
|
146
|
+
* GitLab URL with nested groups, a browser URL) and records the repository it
|
|
147
|
+
* settled on beside each key it resolved; a consumer reads that record and never
|
|
148
|
+
* probes. A dependency that reads one way needs no record and comes back as it
|
|
149
|
+
* is.
|
|
150
|
+
*
|
|
151
|
+
* A named reading is confirmed by a record under its own key (or, for a whole
|
|
152
|
+
* namespace, under a key inside it) carrying its repository; a browsed reading
|
|
153
|
+
* by any record carrying its repository.
|
|
154
|
+
*
|
|
155
|
+
* @param readings - Every reading of the dependency, from `parseRef`.
|
|
156
|
+
* @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
|
|
157
|
+
* @returns The confirmed readings: one is the answer; none or several leave it unsettled.
|
|
158
|
+
*/
|
|
159
|
+
export function confirmedReadings(
|
|
160
|
+
readings: RefReading[],
|
|
161
|
+
recorded: RecordedDependencies | undefined
|
|
162
|
+
): RefReading[] {
|
|
163
|
+
if (readings.length === 1) return readings;
|
|
164
|
+
const records = Object.entries(recorded ?? {});
|
|
165
|
+
|
|
166
|
+
return readings.filter((reading) => {
|
|
167
|
+
const identity = reading.location?.identity;
|
|
168
|
+
if (identity === undefined) return false;
|
|
169
|
+
return records.some(([key, record]) => {
|
|
170
|
+
if (record?.repo !== identity) return false;
|
|
171
|
+
if (isBrowsedReading(reading)) return true;
|
|
172
|
+
if (!isNamedReading(reading)) return false;
|
|
173
|
+
return reading.recipe === undefined
|
|
174
|
+
? key.startsWith(`${reading.namespace}/`)
|
|
175
|
+
: key === refKey(reading);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What `settleDependency` may consult, beyond the dependency itself. */
|
|
181
|
+
export type SettleDependencyOptions = {
|
|
182
|
+
/** What the declaring version's index entry records about its dependencies. */
|
|
183
|
+
recorded?: RecordedDependencies;
|
|
184
|
+
/**
|
|
185
|
+
* Whether a reading is already known to be right from what is on this
|
|
186
|
+
* machine: its repository is trusted and its cached index publishes what it
|
|
187
|
+
* names. Used only when the index recorded nothing, and it never fetches.
|
|
188
|
+
*/
|
|
189
|
+
known?: (reading: RefReading) => boolean;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The one reading a manifest dependency settles on, or undefined when it reads
|
|
194
|
+
* more than one way and nothing at hand says which: the declaring recipe's
|
|
195
|
+
* index recorded no answer, and what this machine already knows does not pick
|
|
196
|
+
* exactly one.
|
|
197
|
+
*
|
|
198
|
+
* settleDependency("workflow/alpha");
|
|
199
|
+
* // -> { namespace: "workflow", recipe: "alpha" }
|
|
200
|
+
*
|
|
201
|
+
* @param written - The dependency as the manifest wrote it.
|
|
202
|
+
* @param options - The index's record, and what is known locally.
|
|
203
|
+
*/
|
|
204
|
+
export function settleDependency(
|
|
205
|
+
written: string,
|
|
206
|
+
options: SettleDependencyOptions = {}
|
|
207
|
+
): RefReading | undefined {
|
|
208
|
+
const readings = parseRef(written, RefSource.Manifest);
|
|
209
|
+
if (readings.length === 1) return readings[0];
|
|
210
|
+
|
|
211
|
+
const confirmed = confirmedReadings(readings, options.recorded);
|
|
212
|
+
if (confirmed.length === 1) return confirmed[0];
|
|
213
|
+
|
|
214
|
+
if (options.known !== undefined) {
|
|
215
|
+
const known = readings.filter(options.known);
|
|
216
|
+
if (known.length === 1) return known[0];
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
package/src/lib/repos/catalog.ts
CHANGED
|
@@ -29,7 +29,15 @@ import type {
|
|
|
29
29
|
RecipeManifest,
|
|
30
30
|
VariableDefinition,
|
|
31
31
|
} from "./formats/recipe-manifest.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
isNamedReading,
|
|
34
|
+
namespaceOfKey,
|
|
35
|
+
parseRef,
|
|
36
|
+
refKey,
|
|
37
|
+
splitRecipeKey,
|
|
38
|
+
type ParsedRef,
|
|
39
|
+
} from "../refs/parse.js";
|
|
40
|
+
import { settleDependency } from "../refs/settle.js";
|
|
33
41
|
import { bareName } from "../vars/names.js";
|
|
34
42
|
import {
|
|
35
43
|
describeReference,
|
|
@@ -390,7 +398,7 @@ export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
|
|
|
390
398
|
([key]) => inputs.lock.recipes[key]?.repo === repo.name
|
|
391
399
|
)
|
|
392
400
|
);
|
|
393
|
-
const held = new Set(Object.keys(recipes).map(
|
|
401
|
+
const held = new Set(Object.keys(recipes).map(namespaceOfKey));
|
|
394
402
|
const namespaces = Object.fromEntries(
|
|
395
403
|
Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
|
|
396
404
|
);
|
|
@@ -468,16 +476,16 @@ export type ResolvedRecipeRef = {
|
|
|
468
476
|
* @param ref - The namespace, optionally qualified with `repo:`.
|
|
469
477
|
*/
|
|
470
478
|
export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): ResolvedNamespace {
|
|
471
|
-
const parsed =
|
|
479
|
+
const { parsed, search } = readCommandLineRef(ref);
|
|
472
480
|
|
|
473
|
-
if (parsed
|
|
481
|
+
if (parsed?.recipe !== undefined) {
|
|
474
482
|
throw new ConfigError(
|
|
475
483
|
`'${ref}' names the recipe '${parsed.recipe}', not a namespace.\n` +
|
|
476
484
|
` The namespace it belongs to is '${parsed.namespace}'.`
|
|
477
485
|
);
|
|
478
486
|
}
|
|
479
487
|
|
|
480
|
-
const matches = findNamespace(
|
|
488
|
+
const matches = findNamespace(search, referenceContext(inputs));
|
|
481
489
|
|
|
482
490
|
if (matches.length === 1) {
|
|
483
491
|
return { repo: repoNamed(inputs, matches[0]!.repo!), namespace: matches[0]!.namespace! };
|
|
@@ -485,8 +493,8 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
|
|
|
485
493
|
|
|
486
494
|
if (matches.length > 1) throw ambiguousError(ref, "namespace", matches);
|
|
487
495
|
|
|
488
|
-
const asRecipe = findRecipe(
|
|
489
|
-
if (parsed.repo === undefined && asRecipe.length > 0) {
|
|
496
|
+
const asRecipe = findRecipe(search, referenceContext(inputs));
|
|
497
|
+
if (parsed !== undefined && parsed.repo === undefined && asRecipe.length > 0) {
|
|
490
498
|
throw new ConfigError(
|
|
491
499
|
`No repository this project trusts publishes a namespace called ` +
|
|
492
500
|
`'${parsed.namespace}'.\n` +
|
|
@@ -495,7 +503,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
|
|
|
495
503
|
);
|
|
496
504
|
}
|
|
497
505
|
|
|
498
|
-
throw unknownError(inputs, ref, "namespace", parsed
|
|
506
|
+
throw unknownError(inputs, ref, "namespace", parsed?.repo);
|
|
499
507
|
}
|
|
500
508
|
|
|
501
509
|
/**
|
|
@@ -507,8 +515,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
|
|
|
507
515
|
* @param ref - The recipe, as `namespace/recipe`, a bare recipe name, or either qualified with `repo:`.
|
|
508
516
|
*/
|
|
509
517
|
export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRecipeRef {
|
|
510
|
-
const parsed =
|
|
511
|
-
const search = refSearchString(parsed);
|
|
518
|
+
const { parsed, search } = readCommandLineRef(ref);
|
|
512
519
|
const context = referenceContext(inputs);
|
|
513
520
|
|
|
514
521
|
const matches = findRecipe(search, context);
|
|
@@ -529,13 +536,13 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
|
|
|
529
536
|
if (asNamespace.length > 0) {
|
|
530
537
|
throw new ConfigError(
|
|
531
538
|
`No repository this project trusts publishes a recipe called ` +
|
|
532
|
-
`'${parsed
|
|
539
|
+
`'${parsed?.namespace ?? ref}'.\n` +
|
|
533
540
|
` It is the name of a namespace:\n` +
|
|
534
541
|
asNamespace.map((candidate) => ` ${describeReference(candidate)}`).join("\n")
|
|
535
542
|
);
|
|
536
543
|
}
|
|
537
544
|
|
|
538
|
-
throw unknownError(inputs, ref, "recipe", parsed
|
|
545
|
+
throw unknownError(inputs, ref, "recipe", parsed?.repo);
|
|
539
546
|
}
|
|
540
547
|
|
|
541
548
|
// --- The pieces ---------------------------------------------------------------------------------
|
|
@@ -543,7 +550,7 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
|
|
|
543
550
|
/** Every recipe key one index publishes in one namespace, sorted. */
|
|
544
551
|
function recipeKeysIn(index: IndexFile, namespace: string): string[] {
|
|
545
552
|
return Object.keys(index.recipes)
|
|
546
|
-
.filter((key) =>
|
|
553
|
+
.filter((key) => namespaceOfKey(key) === namespace)
|
|
547
554
|
.sort();
|
|
548
555
|
}
|
|
549
556
|
|
|
@@ -583,8 +590,7 @@ function recipeListing(
|
|
|
583
590
|
): RecipeListing {
|
|
584
591
|
const lock = inputs.lock;
|
|
585
592
|
const entry = repo.index.recipes[key]!;
|
|
586
|
-
const namespace =
|
|
587
|
-
const name = key.slice(namespace.length + 1);
|
|
593
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
588
594
|
const latest = latestVersion(Object.keys(entry.versions), entry.versions);
|
|
589
595
|
|
|
590
596
|
// A locked entry pins one recipe from one repository. Two repositories can
|
|
@@ -689,12 +695,17 @@ function dependencyListings(
|
|
|
689
695
|
["subscribes", manifest?.subscribes ?? []],
|
|
690
696
|
] as Array<["depends" | "subscribes", string[]]>) {
|
|
691
697
|
for (const written of declared) {
|
|
692
|
-
|
|
698
|
+
// A dependency that reads more than one way is listed under the key the
|
|
699
|
+
// index recorded for it. One sous cannot parse or settle is still worth
|
|
700
|
+
// showing; it is listed under what it was written as, so the reader sees
|
|
701
|
+
// the entry as it stands.
|
|
702
|
+
let key = written;
|
|
693
703
|
try {
|
|
694
|
-
|
|
704
|
+
const reading = settleDependency(written, {
|
|
705
|
+
...(resolved === undefined ? {} : { recorded: resolved }),
|
|
706
|
+
});
|
|
707
|
+
if (reading !== undefined && isNamedReading(reading)) key = refKey(reading);
|
|
695
708
|
} catch {
|
|
696
|
-
// A manifest sous cannot parse is still worth showing; it is listed
|
|
697
|
-
// under what it was written as, so the reader sees the bad entry.
|
|
698
709
|
key = written;
|
|
699
710
|
}
|
|
700
711
|
rows.set(key, { ...(rows.get(key) ?? { key }), declared: written, kind });
|
|
@@ -746,14 +757,22 @@ function referenceContext(inputs: CatalogInputs): ReferenceContext {
|
|
|
746
757
|
}
|
|
747
758
|
|
|
748
759
|
/**
|
|
749
|
-
* A
|
|
750
|
-
*
|
|
760
|
+
* A ref from the command line, checked by the one parser, and the spelling to
|
|
761
|
+
* search for: a short ref written back without its version range (the range
|
|
762
|
+
* says which version to use, never which thing is meant), or a location
|
|
763
|
+
* exactly as written, which the search settles through the repository's index.
|
|
751
764
|
*
|
|
752
|
-
* @param
|
|
765
|
+
* @param ref - The ref as typed.
|
|
766
|
+
* @returns The short reading, when the ref was short, and the search term.
|
|
753
767
|
*/
|
|
754
|
-
function
|
|
755
|
-
const
|
|
756
|
-
|
|
768
|
+
function readCommandLineRef(ref: string): { parsed?: ParsedRef; search: string } {
|
|
769
|
+
const readings = parseRef(ref);
|
|
770
|
+
const first = readings[0]!;
|
|
771
|
+
if (first.location !== undefined || !isNamedReading(first)) return { search: ref.trim() };
|
|
772
|
+
return {
|
|
773
|
+
parsed: first,
|
|
774
|
+
search: first.repo === undefined ? refKey(first) : `${first.repo}:${refKey(first)}`,
|
|
775
|
+
};
|
|
757
776
|
}
|
|
758
777
|
|
|
759
778
|
/** The error a ref that could have meant several things raises. */
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
semverVersionSchema,
|
|
32
32
|
stableJsonStringify,
|
|
33
33
|
} from "./common.js";
|
|
34
|
+
import { namespaceOfKey } from "../../refs/parse.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* One dependency of one published version, as the release resolved it.
|
|
@@ -142,7 +143,7 @@ export const indexFileSchema = forwardCompatibleObject({
|
|
|
142
143
|
// A recipe whose namespace is not declared could never be resolved, so a
|
|
143
144
|
// release that produced one is broken; say which recipe and which namespace.
|
|
144
145
|
for (const key of Object.keys(index.recipes)) {
|
|
145
|
-
const namespace =
|
|
146
|
+
const namespace = namespaceOfKey(key);
|
|
146
147
|
if (!Object.hasOwn(index.namespaces, namespace)) {
|
|
147
148
|
ctx.addIssue({
|
|
148
149
|
code: "custom",
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
* its files DO enter the output. A curated bundle is simply a recipe made
|
|
16
16
|
* mostly of `subscribes` entries.
|
|
17
17
|
*
|
|
18
|
-
* Both lists name their targets BY LOCATION
|
|
18
|
+
* Both lists name their targets BY LOCATION:
|
|
19
19
|
*
|
|
20
20
|
* workflow/sat a sibling in this repository
|
|
21
21
|
* github://sous-io/sous-recipes/workflow/sat a recipe in another one
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Every other location form works too (an HTTPS or SSH URL, a browser URL); a
|
|
24
|
+
* short name such as `sous-recipes:` is a consuming project's own label, so it
|
|
25
|
+
* never appears in a published manifest. See `parseRef` with
|
|
26
|
+
* `RefSource.Manifest`.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import { z } from "zod";
|
|
@@ -37,20 +39,20 @@ import {
|
|
|
37
39
|
submissionsSchema,
|
|
38
40
|
variableNameSchema,
|
|
39
41
|
} from "./common.js";
|
|
40
|
-
import {
|
|
42
|
+
import { parseRef } from "../../refs/parse.js";
|
|
43
|
+
import { RefSource } from "../../refs/scopes.js";
|
|
41
44
|
|
|
42
45
|
// --- Dependency refs ----------------------------------------------------------------------------
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
|
-
* A dependency in `depends` or `subscribes
|
|
46
|
-
*
|
|
47
|
-
* locator URL naming one in another repository. Parsed with the real parser so
|
|
48
|
+
* A dependency in `depends` or `subscribes`: a bare ref naming a recipe in this
|
|
49
|
+
* same repository, or a location naming one in another repository. Parsed with the real parser so
|
|
48
50
|
* a manifest and the rest of sous never disagree about what a dependency means;
|
|
49
51
|
* the parser's message is carried through as the zod issue message.
|
|
50
52
|
*/
|
|
51
53
|
const dependencyRefSchema = z.string().superRefine((value, ctx) => {
|
|
52
54
|
try {
|
|
53
|
-
|
|
55
|
+
parseRef(value, RefSource.Manifest);
|
|
54
56
|
} catch (error) {
|
|
55
57
|
ctx.addIssue({ code: "custom", message: (error as Error).message });
|
|
56
58
|
}
|
package/src/lib/repos/index.ts
CHANGED
|
@@ -18,7 +18,6 @@ export * from "./formats/lockfile.js";
|
|
|
18
18
|
export * from "./formats/store-entry.js";
|
|
19
19
|
export * from "./formats/links-map.js";
|
|
20
20
|
export * from "./load-manifest.js";
|
|
21
|
-
export * from "./ref.js";
|
|
22
21
|
export * from "./identity.js";
|
|
23
22
|
|
|
24
23
|
// Phase 2a: the machine-wide recipe store, under the user-level sous directory.
|
|
@@ -34,6 +34,7 @@ import type { ResolvedRecipe } from "./resolver.js";
|
|
|
34
34
|
import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
|
|
35
35
|
import { builtInProviders, requireProvider } from "./providers/index.js";
|
|
36
36
|
import { repoIdentity } from "./identity.js";
|
|
37
|
+
import { splitRecipeKey } from "../refs/parse.js";
|
|
37
38
|
import type { ProviderOptions, RepoProvider } from "./providers/provider.js";
|
|
38
39
|
|
|
39
40
|
/** What the lockfile needs to know about a repository. */
|
|
@@ -382,11 +383,11 @@ export class LockService {
|
|
|
382
383
|
const report: RestoreReport = { restored: [], alreadyPresent: [] };
|
|
383
384
|
|
|
384
385
|
for (const [key, entry] of Object.entries(lock.recipes)) {
|
|
385
|
-
const namespace =
|
|
386
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
386
387
|
const storeKey: StoreKey = {
|
|
387
388
|
identity: lock.repos[entry.repo]!.identity,
|
|
388
389
|
namespace,
|
|
389
|
-
name
|
|
390
|
+
name,
|
|
390
391
|
version: entry.version,
|
|
391
392
|
};
|
|
392
393
|
|
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
* Phase 4 defined the `NamespaceResolver` contract and shipped an in-memory
|
|
5
5
|
* implementation for tests. This is the implementation backed by the project's
|
|
6
6
|
* actual state: the lockfile says which recipes are in play and at what version,
|
|
7
|
-
* the links map and the store say where each one's files are, and
|
|
8
|
-
*
|
|
7
|
+
* the links map and the store say where each one's files are, and the
|
|
8
|
+
* lockfile's holder lists say what each recipe is allowed to address.
|
|
9
9
|
*
|
|
10
10
|
* The scoping rule is the whole point, and it is enforced by handing
|
|
11
11
|
* `StaticNamespaceResolver` the right two maps:
|
|
12
12
|
*
|
|
13
13
|
* - a file inside a recipe may address only that recipe's own declared
|
|
14
|
-
* dependencies (`depends` plus `subscribes`), at their pinned versions
|
|
14
|
+
* dependencies (`depends` plus `subscribes`), at their pinned versions. The
|
|
15
|
+
* lockfile records which keys each recipe holds, however its manifest wrote
|
|
16
|
+
* them (a sibling ref, a locator, a URL copied from the browser), so that
|
|
17
|
+
* record is what decides it;
|
|
15
18
|
* - a file in the project's own templates may address every recipe the
|
|
16
19
|
* lockfile pins, whatever holds it: the project, a set it subscribes to,
|
|
17
20
|
* or another recipe's `depends`.
|
|
@@ -28,16 +31,16 @@
|
|
|
28
31
|
import semver from "semver";
|
|
29
32
|
import type { Settings } from "../settings.js";
|
|
30
33
|
import { resolveStoreRoot } from "../sous-home.js";
|
|
34
|
+
import { RefSource } from "../refs/scopes.js";
|
|
35
|
+
import { namespaceOfKey, parseShortRef, refKey } from "../refs/parse.js";
|
|
31
36
|
import {
|
|
32
37
|
StaticNamespaceResolver,
|
|
33
|
-
normalizeRef,
|
|
34
38
|
type DroppedRecipe,
|
|
35
39
|
type NamespaceResolver,
|
|
36
40
|
} from "./namespace-resolver.js";
|
|
37
41
|
import {
|
|
38
42
|
listLockedRecipes,
|
|
39
43
|
readProjectLockfile,
|
|
40
|
-
readRecipeManifestIn,
|
|
41
44
|
type LockedRecipeLocation,
|
|
42
45
|
} from "./locked-recipes.js";
|
|
43
46
|
import { createIndexCache } from "./providers/index.js";
|
|
@@ -84,14 +87,11 @@ export function createProjectNamespaceResolver(
|
|
|
84
87
|
for (const recipe of locked) {
|
|
85
88
|
recipes[recipe.key] = recipe.dir;
|
|
86
89
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const declared = [...(manifest.depends ?? []), ...(manifest.subscribes ?? [])];
|
|
94
|
-
if (declared.length > 0) dependencies[recipe.key] = declared;
|
|
90
|
+
// Every recipe holding this one declared it, in `depends` or `subscribes`,
|
|
91
|
+
// and the lockfile records the key that declaration resolved to.
|
|
92
|
+
for (const holder of recipe.requestedBy) {
|
|
93
|
+
(dependencies[holder] ??= []).push(recipe.key);
|
|
94
|
+
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// No `projectScope`: a project template may address every recipe the
|
|
@@ -126,10 +126,16 @@ export function explainUnpinnedRecipe(
|
|
|
126
126
|
recipe: string,
|
|
127
127
|
options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
|
|
128
128
|
): DroppedRecipe | undefined {
|
|
129
|
-
const namespace =
|
|
129
|
+
const namespace = namespaceOfKey(recipe);
|
|
130
130
|
for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
|
|
131
131
|
if (entry?.enabled !== false) continue;
|
|
132
|
-
|
|
132
|
+
let ref: string;
|
|
133
|
+
try {
|
|
134
|
+
ref = refKey(parseShortRef(key, RefSource.Config));
|
|
135
|
+
} catch {
|
|
136
|
+
// The config schema already refuses a key that is not a ref.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
133
139
|
if (ref === recipe || ref === namespace) {
|
|
134
140
|
return { by: "disabled-subscription", subscription: key };
|
|
135
141
|
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./load-manifest.js";
|
|
34
34
|
import { readEffectiveLinks } from "./links.js";
|
|
35
35
|
import { identitySegments } from "./identity.js";
|
|
36
|
+
import { splitRecipeKey } from "../refs/parse.js";
|
|
36
37
|
import { PROJECT_HOLDER } from "./formats/lockfile.js";
|
|
37
38
|
|
|
38
39
|
/** One locked recipe, together with the directory its files are read from. */
|
|
@@ -164,8 +165,7 @@ export function listLockedRecipes(
|
|
|
164
165
|
|
|
165
166
|
for (const key of keys.sort()) {
|
|
166
167
|
const entry = lock.recipes[key]!;
|
|
167
|
-
const namespace =
|
|
168
|
-
const name = key.slice(namespace.length + 1);
|
|
168
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
169
169
|
|
|
170
170
|
let dir: string | undefined;
|
|
171
171
|
let linked = false;
|