@sous-io/sous 0.2.28 → 0.2.30
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 +2 -0
- package/docs/markdown/commands.md +10 -6
- package/docs/markdown/repositories-authoring.md +19 -11
- package/docs/markdown/repositories-consuming.md +34 -13
- package/docs/markdown/repositories-file-formats.md +88 -13
- package/docs/markdown/repositories-providers.md +8 -6
- package/docs/markdown/repositories-troubleshooting.md +6 -0
- package/docs/markdown/repositories-variables.md +13 -3
- 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/skills/about-sous-repos/SKILL.tpl.md +7 -4
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/lock/rebuild.ts +7 -6
- package/src/commands/recipe/show.ts +163 -64
- 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 +87 -80
- package/src/lib/repos/declarations.ts +177 -0
- package/src/lib/repos/formats/common.ts +14 -0
- package/src/lib/repos/formats/index-file.ts +42 -2
- package/src/lib/repos/formats/lockfile.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +16 -212
- package/src/lib/repos/formats/variable-definition.ts +268 -0
- 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 +221 -47
- 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 +251 -53
- package/src/lib/repos/subscription-service.ts +355 -49
- package/src/lib/repos/update-plan.ts +3 -2
- package/src/lib/vars/mappings.ts +17 -30
- package/src/lib/vars/question-plan.ts +44 -21
- package/src/lib/repos/ref.ts +0 -513
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How one recipe brings in another: which entry of its manifest names it, and
|
|
3
|
+
* whether that entry is a co-subscription or a build dependency.
|
|
4
|
+
*
|
|
5
|
+
* A manifest lists what it brings in under `depends` and `subscribes`, and one
|
|
6
|
+
* recipe can be covered by more than one entry: `subscribes: [workflow]` covers
|
|
7
|
+
* every recipe of the `workflow` namespace, and `depends: [workflow/task-files]`
|
|
8
|
+
* names one of them again. Three places need one answer per recipe, and they
|
|
9
|
+
* must agree: a release recording the answer in the index, `sous recipe show`
|
|
10
|
+
* describing a recipe whose manifest it can read, and the resolver walking a
|
|
11
|
+
* recipe whose manifest it cannot. This module is that one answer, and its
|
|
12
|
+
* reverse.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DependencyKind } from "./formats/common.js";
|
|
16
|
+
import type { IndexVersion } from "./formats/index-file.js";
|
|
17
|
+
import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../refs/parse.js";
|
|
18
|
+
import { RefSource } from "../refs/scopes.js";
|
|
19
|
+
import { settleDependency, type RecordedDependencies } from "../refs/settle.js";
|
|
20
|
+
|
|
21
|
+
/** A manifest's two dependency lists, each entry exactly as it was written. */
|
|
22
|
+
export type DependencyLists = {
|
|
23
|
+
/** Build dependencies: fetched and addressable, never in the project's output. */
|
|
24
|
+
depends: string[];
|
|
25
|
+
/** Co-subscriptions: their files land and their questions are asked. */
|
|
26
|
+
subscribes: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** How one recipe is brought in by the manifest that declares it. */
|
|
30
|
+
export type DependencyDeclaration = {
|
|
31
|
+
/**
|
|
32
|
+
* The manifest entry that brings it in, as written. An entry naming the
|
|
33
|
+
* recipe wins over a namespace entry that also covers it; between two
|
|
34
|
+
* entries naming it, the later one (in `depends`, then `subscribes` order)
|
|
35
|
+
* wins, and between two namespace entries, the first.
|
|
36
|
+
*/
|
|
37
|
+
declared: string;
|
|
38
|
+
/** `subscribes` when any entry covering the recipe is a co-subscription. */
|
|
39
|
+
kind: DependencyKind;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A declaration being gathered, entry by entry. */
|
|
43
|
+
export type GatheredDeclaration = DependencyDeclaration & {
|
|
44
|
+
/** True when an entry named the recipe itself rather than its namespace. */
|
|
45
|
+
named: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** One manifest entry covering a recipe. */
|
|
49
|
+
export type CoveringEntry = {
|
|
50
|
+
/** The entry as written. */
|
|
51
|
+
written: string;
|
|
52
|
+
/** The list it sits in. */
|
|
53
|
+
kind: DependencyKind;
|
|
54
|
+
/** True when it names the recipe itself rather than a namespace holding it. */
|
|
55
|
+
named: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Folds one more manifest entry covering a recipe into what is known about the
|
|
60
|
+
* recipe's declaration, by the rule `DependencyDeclaration` states. Every
|
|
61
|
+
* reader applies this one function, entry by entry in manifest order
|
|
62
|
+
* (`depends` first, then `subscribes`).
|
|
63
|
+
*
|
|
64
|
+
* foldDeclaration(undefined, { written: "workflow", kind: "subscribes", named: false })
|
|
65
|
+
* // -> { declared: "workflow", kind: "subscribes", named: false }
|
|
66
|
+
*
|
|
67
|
+
* @param before - What the earlier entries said, when any covered the recipe.
|
|
68
|
+
* @param entry - The next entry covering it.
|
|
69
|
+
*/
|
|
70
|
+
export function foldDeclaration(
|
|
71
|
+
before: GatheredDeclaration | undefined,
|
|
72
|
+
entry: CoveringEntry
|
|
73
|
+
): GatheredDeclaration {
|
|
74
|
+
const keepBefore = before !== undefined && !entry.named;
|
|
75
|
+
return {
|
|
76
|
+
declared: keepBefore ? before.declared : entry.written.trim(),
|
|
77
|
+
kind: before?.kind === "subscribes" || entry.kind === "subscribes" ? "subscribes" : "depends",
|
|
78
|
+
named: entry.named || before?.named === true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The declaration that brings one recipe in, or undefined when no entry of the
|
|
84
|
+
* lists covers it. An entry that reads more than one way is settled from what
|
|
85
|
+
* the version's index entry recorded, and one that cannot be settled, or does
|
|
86
|
+
* not parse, covers nothing.
|
|
87
|
+
*
|
|
88
|
+
* declarationFor({ depends: ["workflow/a"], subscribes: ["workflow"] }, "workflow/a")
|
|
89
|
+
* // -> { declared: "workflow/a", kind: "subscribes" }
|
|
90
|
+
*
|
|
91
|
+
* @param lists - The declaring recipe's `depends` and `subscribes` lists.
|
|
92
|
+
* @param key - The recipe being asked about, `namespace/recipe`.
|
|
93
|
+
* @param recorded - What the declaring version's index entry records, for settling.
|
|
94
|
+
*/
|
|
95
|
+
export function declarationFor(
|
|
96
|
+
lists: Partial<DependencyLists>,
|
|
97
|
+
key: string,
|
|
98
|
+
recorded?: RecordedDependencies
|
|
99
|
+
): DependencyDeclaration | undefined {
|
|
100
|
+
let gathered: GatheredDeclaration | undefined;
|
|
101
|
+
|
|
102
|
+
for (const [kind, entries] of [
|
|
103
|
+
["depends", lists.depends ?? []],
|
|
104
|
+
["subscribes", lists.subscribes ?? []],
|
|
105
|
+
] as Array<[DependencyKind, string[]]>) {
|
|
106
|
+
for (const written of entries) {
|
|
107
|
+
let reading;
|
|
108
|
+
try {
|
|
109
|
+
reading = settleDependency(written, recorded === undefined ? {} : { recorded });
|
|
110
|
+
} catch {
|
|
111
|
+
// An entry that does not parse is reported wherever the manifest is
|
|
112
|
+
// validated; here it simply covers nothing.
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (reading === undefined || !isNamedReading(reading)) continue;
|
|
116
|
+
|
|
117
|
+
const named = reading.recipe !== undefined;
|
|
118
|
+
const covers = named ? refKey(reading) === key : namespaceOfKey(key) === reading.namespace;
|
|
119
|
+
if (covers) gathered = foldDeclaration(gathered, { written, kind, named });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return gathered === undefined ? undefined : { declared: gathered.declared, kind: gathered.kind };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The manifest lists a version's index entry records, or undefined when it
|
|
128
|
+
* records neither (a version recorded before the index described recipes).
|
|
129
|
+
* That is what lets the resolver walk a recipe whose files are not on this
|
|
130
|
+
* machine exactly as it would walk its manifest.
|
|
131
|
+
*
|
|
132
|
+
* indexDependencyLists({ subscribes: ["workflow"], depends: [] , ... })
|
|
133
|
+
* // -> { depends: [], subscribes: ["workflow"] }
|
|
134
|
+
*
|
|
135
|
+
* @param version - The version's index entry.
|
|
136
|
+
*/
|
|
137
|
+
export function indexDependencyLists(
|
|
138
|
+
version: Pick<IndexVersion, "depends" | "subscribes">
|
|
139
|
+
): DependencyLists | undefined {
|
|
140
|
+
if (version.depends === undefined && version.subscribes === undefined) return undefined;
|
|
141
|
+
return { depends: [...(version.depends ?? [])], subscribes: [...(version.subscribes ?? [])] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Says how a declaration brings a recipe in, the way a person would: a whole
|
|
146
|
+
* namespace is named as one, and anything else is shown as it was written.
|
|
147
|
+
*
|
|
148
|
+
* describeDeclaration("workflow") // -> "the whole 'workflow' namespace"
|
|
149
|
+
* describeDeclaration("workflow/task-files") // -> "workflow/task-files"
|
|
150
|
+
*
|
|
151
|
+
* @param declared - The manifest entry, as written.
|
|
152
|
+
*/
|
|
153
|
+
export function describeDeclaration(declared: string): string {
|
|
154
|
+
try {
|
|
155
|
+
const readings = parseRef(declared, RefSource.Manifest);
|
|
156
|
+
const only = readings[0]!;
|
|
157
|
+
if (readings.length === 1 && isNamedReading(only) && only.recipe === undefined) {
|
|
158
|
+
return only.location === undefined
|
|
159
|
+
? `the whole '${only.namespace}' namespace`
|
|
160
|
+
: `the whole '${only.namespace}' namespace of ${only.location.identity}`;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Shown as it was written; the reader sees the bad entry.
|
|
164
|
+
}
|
|
165
|
+
return declared;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Plain-language wording for a dependency kind.
|
|
170
|
+
*
|
|
171
|
+
* @param kind - The kind, when it is known.
|
|
172
|
+
*/
|
|
173
|
+
export function describeDependencyKind(kind: DependencyKind | undefined): string {
|
|
174
|
+
if (kind === "depends") return "build dependency";
|
|
175
|
+
if (kind === "subscribes") return "co-subscription";
|
|
176
|
+
return "not recorded";
|
|
177
|
+
}
|
|
@@ -161,6 +161,20 @@ export const recipeKeySchema = z
|
|
|
161
161
|
"(for example 'workflow/task-files')"
|
|
162
162
|
);
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The two ways one recipe can bring in another, named after the manifest lists
|
|
166
|
+
* that declare them: `subscribes` is a co-subscription, whose files land in the
|
|
167
|
+
* project and whose questions are asked; `depends` is a build dependency, a
|
|
168
|
+
* library whose files never reach the project's output.
|
|
169
|
+
*/
|
|
170
|
+
export const DEPENDENCY_KINDS = ["subscribes", "depends"] as const;
|
|
171
|
+
|
|
172
|
+
/** A co-subscription or a build dependency. */
|
|
173
|
+
export const dependencyKindSchema = z.enum(DEPENDENCY_KINDS);
|
|
174
|
+
|
|
175
|
+
/** A co-subscription or a build dependency. */
|
|
176
|
+
export type DependencyKind = (typeof DEPENDENCY_KINDS)[number];
|
|
177
|
+
|
|
164
178
|
// --- Value primitives ---------------------------------------------------------------------------
|
|
165
179
|
|
|
166
180
|
/** An exact semantic version, as published by a recipe. */
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import {
|
|
20
20
|
contentHashSchema,
|
|
21
|
+
dependencyKindSchema,
|
|
21
22
|
formatVersionSchema,
|
|
22
23
|
forwardCompatibleObject,
|
|
23
24
|
isoTimestampSchema,
|
|
@@ -31,6 +32,8 @@ import {
|
|
|
31
32
|
semverVersionSchema,
|
|
32
33
|
stableJsonStringify,
|
|
33
34
|
} from "./common.js";
|
|
35
|
+
import { publishedVariableDefinitionSchema } from "./variable-definition.js";
|
|
36
|
+
import { namespaceOfKey } from "../../refs/parse.js";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* One dependency of one published version, as the release resolved it.
|
|
@@ -54,6 +57,22 @@ export const indexDependencySchema = forwardCompatibleObject({
|
|
|
54
57
|
* this same repository.
|
|
55
58
|
*/
|
|
56
59
|
repo: repoIdentitySchema.optional(),
|
|
60
|
+
/**
|
|
61
|
+
* The entry of the version's manifest that brings this dependency in, exactly
|
|
62
|
+
* as it was written: a recipe (`workflow/task-files`, with its range when it
|
|
63
|
+
* has one), a whole namespace of this repository (`workflow`), or a locator
|
|
64
|
+
* naming a recipe in another repository. When several entries cover the same
|
|
65
|
+
* recipe, the one naming it wins over a namespace. Absent on an entry recorded
|
|
66
|
+
* before sous described recipes in the index (ADR 0010).
|
|
67
|
+
*/
|
|
68
|
+
declared: z.string().min(1, "must not be empty").optional(),
|
|
69
|
+
/**
|
|
70
|
+
* Whether that brings it in as a co-subscription (`subscribes`: its files
|
|
71
|
+
* land in the project and its questions are asked) or as a build dependency
|
|
72
|
+
* (`depends`: a library only); `subscribes` when any entry covering it is a
|
|
73
|
+
* co-subscription. Recorded alongside `declared`.
|
|
74
|
+
*/
|
|
75
|
+
kind: dependencyKindSchema.optional(),
|
|
57
76
|
}).refine((entry) => entry.version !== undefined || entry.range !== undefined, {
|
|
58
77
|
message:
|
|
59
78
|
"must record either the exact version this dependency resolved to or the range " +
|
|
@@ -92,9 +111,30 @@ export const indexVersionSchema = forwardCompatibleObject({
|
|
|
92
111
|
* published version means one thing forever.
|
|
93
112
|
*
|
|
94
113
|
* The field is additive: an index written before it existed still parses, and
|
|
95
|
-
* a consumer that finds no entry falls back to the manifest's ranges.
|
|
114
|
+
* a consumer that finds no entry falls back to the manifest's ranges. A
|
|
115
|
+
* version described in full (ADR 0010) carries this field even when it is
|
|
116
|
+
* empty, so "depends on nothing" and "not recorded" read differently.
|
|
96
117
|
*/
|
|
97
118
|
dependencies: z.record(recipeKeySchema, indexDependencySchema).optional(),
|
|
119
|
+
/**
|
|
120
|
+
* The variable definitions this version's manifest publishes, in manifest
|
|
121
|
+
* order: the questions subscribing to it asks. An empty list means it asks
|
|
122
|
+
* none; an absent field means the release that recorded the version did not
|
|
123
|
+
* record them (a release made before ADR 0010), and only the recipe's own
|
|
124
|
+
* files can say.
|
|
125
|
+
*/
|
|
126
|
+
variables: z.array(publishedVariableDefinitionSchema).optional(),
|
|
127
|
+
/**
|
|
128
|
+
* The version's manifest `depends` and `subscribes` lists, exactly as
|
|
129
|
+
* written, so a consumer walks a recipe whose files it has not fetched the
|
|
130
|
+
* same way it walks its manifest: a namespace still expands to what the
|
|
131
|
+
* namespace holds, including a namespace of another repository, which no
|
|
132
|
+
* key of `dependencies` can record. Written, even when empty, beside
|
|
133
|
+
* `variables`; absent on a version recorded before ADR 0010. Kept as plain
|
|
134
|
+
* strings, so a ref form a later sous accepts never breaks this reader.
|
|
135
|
+
*/
|
|
136
|
+
depends: z.array(z.string().min(1, "must not be empty")).optional(),
|
|
137
|
+
subscribes: z.array(z.string().min(1, "must not be empty")).optional(),
|
|
98
138
|
});
|
|
99
139
|
|
|
100
140
|
/** One recipe, with every version the repo publishes of it. */
|
|
@@ -142,7 +182,7 @@ export const indexFileSchema = forwardCompatibleObject({
|
|
|
142
182
|
// A recipe whose namespace is not declared could never be resolved, so a
|
|
143
183
|
// release that produced one is broken; say which recipe and which namespace.
|
|
144
184
|
for (const key of Object.keys(index.recipes)) {
|
|
145
|
-
const namespace =
|
|
185
|
+
const namespace = namespaceOfKey(key);
|
|
146
186
|
if (!Object.hasOwn(index.namespaces, namespace)) {
|
|
147
187
|
ctx.addIssue({
|
|
148
188
|
code: "custom",
|
|
@@ -30,13 +30,15 @@ import {
|
|
|
30
30
|
repoUrlSchema,
|
|
31
31
|
semverVersionSchema,
|
|
32
32
|
stableJsonStringify,
|
|
33
|
+
DEPENDENCY_KINDS,
|
|
34
|
+
type DependencyKind,
|
|
33
35
|
} from "./common.js";
|
|
34
36
|
|
|
35
37
|
/** The literal `requestedBy` holder meaning "the project subscribed to this directly". */
|
|
36
38
|
export const PROJECT_HOLDER = "project";
|
|
37
39
|
|
|
38
|
-
/** How a locked recipe entered the project. */
|
|
39
|
-
export const LOCK_KINDS =
|
|
40
|
+
/** How a locked recipe entered the project: the dependency kinds every format shares. */
|
|
41
|
+
export const LOCK_KINDS = DEPENDENCY_KINDS;
|
|
40
42
|
|
|
41
43
|
/** One repo the project resolves against. */
|
|
42
44
|
export const lockedRepoSchema = z
|
|
@@ -138,7 +140,7 @@ export type LockedRepo = z.infer<typeof lockedRepoSchema>;
|
|
|
138
140
|
export type LockedRecipe = z.infer<typeof lockedRecipeSchema>;
|
|
139
141
|
|
|
140
142
|
/** How a locked recipe entered the project. */
|
|
141
|
-
export type LockKind =
|
|
143
|
+
export type LockKind = DependencyKind;
|
|
142
144
|
|
|
143
145
|
/**
|
|
144
146
|
* Validates a parsed lockfile, throwing a ConfigError that names the file and
|
|
@@ -15,18 +15,19 @@
|
|
|
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";
|
|
28
30
|
import {
|
|
29
|
-
envVarNameSchema,
|
|
30
31
|
extensibleObject,
|
|
31
32
|
formatVersionSchema,
|
|
32
33
|
namespaceNameSchema,
|
|
@@ -35,22 +36,27 @@ import {
|
|
|
35
36
|
relativePathSchema,
|
|
36
37
|
semverVersionSchema,
|
|
37
38
|
submissionsSchema,
|
|
38
|
-
variableNameSchema,
|
|
39
39
|
} from "./common.js";
|
|
40
|
-
import {
|
|
40
|
+
import { parseRef } from "../../refs/parse.js";
|
|
41
|
+
import { RefSource } from "../../refs/scopes.js";
|
|
42
|
+
import { variableDefinitionSchema } from "./variable-definition.js";
|
|
43
|
+
|
|
44
|
+
// Variable definitions live in their own module, so the index can read them
|
|
45
|
+
// without importing the ref parser this manifest needs; they are re-exported
|
|
46
|
+
// here because a definition is part of the manifest.
|
|
47
|
+
export * from "./variable-definition.js";
|
|
41
48
|
|
|
42
49
|
// --- Dependency refs ----------------------------------------------------------------------------
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
|
-
* A dependency in `depends` or `subscribes
|
|
46
|
-
*
|
|
47
|
-
* locator URL naming one in another repository. Parsed with the real parser so
|
|
52
|
+
* A dependency in `depends` or `subscribes`: a bare ref naming a recipe in this
|
|
53
|
+
* same repository, or a location naming one in another repository. Parsed with the real parser so
|
|
48
54
|
* a manifest and the rest of sous never disagree about what a dependency means;
|
|
49
55
|
* the parser's message is carried through as the zod issue message.
|
|
50
56
|
*/
|
|
51
57
|
const dependencyRefSchema = z.string().superRefine((value, ctx) => {
|
|
52
58
|
try {
|
|
53
|
-
|
|
59
|
+
parseRef(value, RefSource.Manifest);
|
|
54
60
|
} catch (error) {
|
|
55
61
|
ctx.addIssue({ code: "custom", message: (error as Error).message });
|
|
56
62
|
}
|
|
@@ -95,196 +101,6 @@ export const recipeContentSchema = extensibleObject({
|
|
|
95
101
|
exclude: z.array(relativePathSchema("an exclude pattern", true)).optional(),
|
|
96
102
|
});
|
|
97
103
|
|
|
98
|
-
// --- Variable definitions -----------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
/** The value types a recipe may declare for a variable. */
|
|
101
|
-
export const VARIABLE_TYPES = [
|
|
102
|
-
"string",
|
|
103
|
-
"number",
|
|
104
|
-
"boolean",
|
|
105
|
-
"enum",
|
|
106
|
-
"path",
|
|
107
|
-
"url",
|
|
108
|
-
] as const;
|
|
109
|
-
|
|
110
|
-
/** Which env file an answer is written to. */
|
|
111
|
-
export const VARIABLE_SCOPES = ["shared", "local"] as const;
|
|
112
|
-
|
|
113
|
-
/** Constraints checked against an answer before it is accepted or stored. */
|
|
114
|
-
export const variableValidationSchema = extensibleObject({
|
|
115
|
-
/** A regular expression the answer must match, as a string. */
|
|
116
|
-
pattern: z
|
|
117
|
-
.string()
|
|
118
|
-
.refine(
|
|
119
|
-
(value) => {
|
|
120
|
-
try {
|
|
121
|
-
new RegExp(value);
|
|
122
|
-
return true;
|
|
123
|
-
} catch {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
{ message: "must be a valid regular expression" }
|
|
128
|
-
)
|
|
129
|
-
.optional(),
|
|
130
|
-
/** Minimum length of a string answer. */
|
|
131
|
-
minLength: z.number().int().min(0).optional(),
|
|
132
|
-
/** Maximum length of a string answer. */
|
|
133
|
-
maxLength: z.number().int().min(0).optional(),
|
|
134
|
-
/** Minimum value of a numeric answer. */
|
|
135
|
-
min: z.number().optional(),
|
|
136
|
-
/** Maximum value of a numeric answer. */
|
|
137
|
-
max: z.number().optional(),
|
|
138
|
-
/** The allowed answers. Required when the variable's type is 'enum'. */
|
|
139
|
-
enum: z.array(z.string()).min(1, "must list at least one option").optional(),
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* A published variable definition: a specification, not a value. Definitions
|
|
144
|
-
* are inert; a question is asked only when a subscribed recipe needs the
|
|
145
|
-
* variable and no valid answer is already in scope.
|
|
146
|
-
*/
|
|
147
|
-
export const variableDefinitionSchema = extensibleObject({
|
|
148
|
-
/** The variable's camelCase name, as templates refer to it. */
|
|
149
|
-
name: variableNameSchema,
|
|
150
|
-
/**
|
|
151
|
-
* The environment variable an answer binds to. Authors may name an existing
|
|
152
|
-
* variable such as GITHUB_TOKEN to reuse a value already in the environment.
|
|
153
|
-
* When omitted, `sous repo release` derives an upper snake case default from
|
|
154
|
-
* the name; the runtime never derives one.
|
|
155
|
-
*/
|
|
156
|
-
env: envVarNameSchema.optional(),
|
|
157
|
-
/** The answer's type, which decides how it is validated and prompted for. */
|
|
158
|
-
type: z.enum(VARIABLE_TYPES),
|
|
159
|
-
/** The one-line question shown when the variable is asked. */
|
|
160
|
-
prompt: z.string().min(1, "must not be empty"),
|
|
161
|
-
/**
|
|
162
|
-
* The paragraph that explains the variable: what it is for, what a good
|
|
163
|
-
* answer looks like, and what changes when it is set. Shown above the
|
|
164
|
-
* question when sous asks, and by `sous vars <name>`. Required, because a
|
|
165
|
-
* consumer reading the question has no other way to learn what a publisher
|
|
166
|
-
* meant by it.
|
|
167
|
-
*/
|
|
168
|
-
description: z
|
|
169
|
-
.string({
|
|
170
|
-
error:
|
|
171
|
-
"is required: every published variable must explain itself in a sentence or " +
|
|
172
|
-
"two. The description is shown above the question when sous asks, and by " +
|
|
173
|
-
"'sous vars <name>'",
|
|
174
|
-
})
|
|
175
|
-
.min(1, "must not be empty"),
|
|
176
|
-
/**
|
|
177
|
-
* A realistic sample answer. It is documentation and prompt copy only; sous
|
|
178
|
-
* never stores it, never offers it as the answer, and never falls back to it.
|
|
179
|
-
* Use `default` for a value a project should actually start with.
|
|
180
|
-
*/
|
|
181
|
-
example: z.union([z.string(), z.number(), z.boolean()], {
|
|
182
|
-
error:
|
|
183
|
-
"is required: every published variable must show what a real answer looks like. " +
|
|
184
|
-
"The example is shown with the question and by 'sous vars <name>'; it is " +
|
|
185
|
-
"documentation only and is never stored as the answer (use 'default' for that)",
|
|
186
|
-
}),
|
|
187
|
-
/** The value offered when the question is asked with nothing else in scope. */
|
|
188
|
-
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
189
|
-
/** Whether an answer is needed for a build to run. Defaults to true. */
|
|
190
|
-
required: z.boolean().default(true),
|
|
191
|
-
/**
|
|
192
|
-
* Whether the answer is a secret. A secret is always written to the
|
|
193
|
-
* gitignored `.sous/.env.local`, never to the committed `.sous/.env`.
|
|
194
|
-
*/
|
|
195
|
-
secret: z.boolean().default(false),
|
|
196
|
-
/**
|
|
197
|
-
* Which env file the answer is written to: 'shared' is `.sous/.env`, which is
|
|
198
|
-
* committed and shared with the team; 'local' is `.sous/.env.local`, which is
|
|
199
|
-
* gitignored and machine-specific. Defaults to 'shared'.
|
|
200
|
-
*/
|
|
201
|
-
scope: z.enum(VARIABLE_SCOPES).default("shared"),
|
|
202
|
-
/** Constraints checked against an answer. */
|
|
203
|
-
validate: variableValidationSchema.optional(),
|
|
204
|
-
})
|
|
205
|
-
.superRefine((definition, ctx) => {
|
|
206
|
-
const { type, validate } = definition;
|
|
207
|
-
|
|
208
|
-
if (type === "enum" && (validate?.enum === undefined || validate.enum.length === 0)) {
|
|
209
|
-
ctx.addIssue({
|
|
210
|
-
code: "custom",
|
|
211
|
-
path: ["validate", "enum"],
|
|
212
|
-
message:
|
|
213
|
-
"a variable of type 'enum' must list its options under 'validate.enum'",
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (validate !== undefined) {
|
|
218
|
-
const { minLength, maxLength, min, max } = validate;
|
|
219
|
-
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
220
|
-
ctx.addIssue({
|
|
221
|
-
code: "custom",
|
|
222
|
-
path: ["validate", "maxLength"],
|
|
223
|
-
message: "must not be smaller than 'validate.minLength'",
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
if (min !== undefined && max !== undefined && min > max) {
|
|
227
|
-
ctx.addIssue({
|
|
228
|
-
code: "custom",
|
|
229
|
-
path: ["validate", "max"],
|
|
230
|
-
message: "must not be smaller than 'validate.min'",
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// A secret written to the committed env file would leak, so the two fields
|
|
236
|
-
// are not allowed to contradict each other.
|
|
237
|
-
if (definition.secret && definition.scope === "shared") {
|
|
238
|
-
ctx.addIssue({
|
|
239
|
-
code: "custom",
|
|
240
|
-
path: ["scope"],
|
|
241
|
-
message:
|
|
242
|
-
"a secret variable is stored in the gitignored '.sous/.env.local', so its " +
|
|
243
|
-
"scope must be 'local' (or left unset)",
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// `default` and `example` are both literal values written by the publisher,
|
|
248
|
-
// so both have to fit the variable they describe; an example that could
|
|
249
|
-
// never be a valid answer is worse than no example at all.
|
|
250
|
-
checkDeclaredValue(definition.default, "default");
|
|
251
|
-
checkDeclaredValue(definition.example, "example");
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Checks one publisher-written literal against the declared type and the
|
|
255
|
-
* 'validate.enum' options.
|
|
256
|
-
*
|
|
257
|
-
* @param value - The literal to check, or undefined when it was omitted.
|
|
258
|
-
* @param field - The field name, used as the issue path.
|
|
259
|
-
*/
|
|
260
|
-
function checkDeclaredValue(
|
|
261
|
-
value: string | number | boolean | undefined,
|
|
262
|
-
field: "default" | "example"
|
|
263
|
-
): void {
|
|
264
|
-
if (value === undefined) return;
|
|
265
|
-
|
|
266
|
-
const expected =
|
|
267
|
-
type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
|
|
268
|
-
if (typeof value !== expected) {
|
|
269
|
-
ctx.addIssue({
|
|
270
|
-
code: "custom",
|
|
271
|
-
path: [field],
|
|
272
|
-
message: `must be a ${expected}, to match the declared type '${type}'`,
|
|
273
|
-
});
|
|
274
|
-
} else if (
|
|
275
|
-
type === "enum" &&
|
|
276
|
-
validate?.enum !== undefined &&
|
|
277
|
-
!validate.enum.includes(String(value))
|
|
278
|
-
) {
|
|
279
|
-
ctx.addIssue({
|
|
280
|
-
code: "custom",
|
|
281
|
-
path: [field],
|
|
282
|
-
message: "must be one of the options listed under 'validate.enum'",
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
104
|
// --- The manifest -------------------------------------------------------------------------------
|
|
289
105
|
|
|
290
106
|
/** The recipe manifest schema. */
|
|
@@ -362,21 +178,9 @@ export type RecipeManifest = z.infer<typeof recipeManifestSchema>;
|
|
|
362
178
|
/** One validated content group from a recipe manifest. */
|
|
363
179
|
export type RecipeContent = z.infer<typeof recipeContentSchema>;
|
|
364
180
|
|
|
365
|
-
/** One validated variable definition from a recipe manifest. */
|
|
366
|
-
export type VariableDefinition = z.infer<typeof variableDefinitionSchema>;
|
|
367
|
-
|
|
368
|
-
/** The constraints attached to a variable definition. */
|
|
369
|
-
export type VariableValidation = z.infer<typeof variableValidationSchema>;
|
|
370
|
-
|
|
371
181
|
/** What a content group contributes. */
|
|
372
182
|
export type ContentKind = (typeof CONTENT_KINDS)[number];
|
|
373
183
|
|
|
374
|
-
/** The value type a variable definition declares. */
|
|
375
|
-
export type VariableType = (typeof VARIABLE_TYPES)[number];
|
|
376
|
-
|
|
377
|
-
/** Which env file an answer is written to. */
|
|
378
|
-
export type VariableScope = (typeof VARIABLE_SCOPES)[number];
|
|
379
|
-
|
|
380
184
|
/**
|
|
381
185
|
* Validates a parsed recipe manifest, throwing a ConfigError that names the
|
|
382
186
|
* file and the path of every bad field.
|