@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.
Files changed (52) hide show
  1. package/docs/markdown/_sidebar.md +2 -0
  2. package/docs/markdown/commands.md +10 -6
  3. package/docs/markdown/repositories-authoring.md +19 -11
  4. package/docs/markdown/repositories-consuming.md +34 -13
  5. package/docs/markdown/repositories-file-formats.md +88 -13
  6. package/docs/markdown/repositories-providers.md +8 -6
  7. package/docs/markdown/repositories-troubleshooting.md +6 -0
  8. package/docs/markdown/repositories-variables.md +13 -3
  9. package/docs/markdown/repositories.md +3 -3
  10. package/package.json +1 -1
  11. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  12. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  13. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  14. package/src/commands/lock/rebuild.ts +7 -6
  15. package/src/commands/recipe/show.ts +163 -64
  16. package/src/commands/repo/contribute.ts +1 -10
  17. package/src/commands/repo/gc.ts +3 -2
  18. package/src/commands/repo/release.ts +20 -3
  19. package/src/commands/repo/search.ts +2 -1
  20. package/src/commands/vars/ask.ts +32 -10
  21. package/src/lib/refs/find.ts +202 -21
  22. package/src/lib/refs/index.ts +2 -0
  23. package/src/lib/refs/parse.ts +844 -0
  24. package/src/lib/refs/scopes.ts +34 -0
  25. package/src/lib/refs/settle.ts +219 -0
  26. package/src/lib/repos/catalog.ts +87 -80
  27. package/src/lib/repos/declarations.ts +177 -0
  28. package/src/lib/repos/formats/common.ts +14 -0
  29. package/src/lib/repos/formats/index-file.ts +42 -2
  30. package/src/lib/repos/formats/lockfile.ts +5 -3
  31. package/src/lib/repos/formats/recipe-manifest.ts +16 -212
  32. package/src/lib/repos/formats/variable-definition.ts +268 -0
  33. package/src/lib/repos/index.ts +0 -1
  34. package/src/lib/repos/lock-service.ts +3 -2
  35. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  36. package/src/lib/repos/locked-recipes.ts +2 -2
  37. package/src/lib/repos/namespace-resolver.ts +40 -47
  38. package/src/lib/repos/providers/base.ts +30 -0
  39. package/src/lib/repos/providers/github.ts +22 -0
  40. package/src/lib/repos/providers/gitlab.ts +68 -0
  41. package/src/lib/repos/providers/provider.ts +96 -0
  42. package/src/lib/repos/release/index-builder.ts +221 -47
  43. package/src/lib/repos/release/index.ts +1 -0
  44. package/src/lib/repos/release/plan.ts +8 -6
  45. package/src/lib/repos/release/settle.ts +237 -0
  46. package/src/lib/repos/release/tags.ts +3 -7
  47. package/src/lib/repos/resolver.ts +251 -53
  48. package/src/lib/repos/subscription-service.ts +355 -49
  49. package/src/lib/repos/update-plan.ts +3 -2
  50. package/src/lib/vars/mappings.ts +17 -30
  51. package/src/lib/vars/question-plan.ts +44 -21
  52. 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 = key.slice(0, key.indexOf("/"));
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 = ["subscribes", "depends"] as const;
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 = (typeof LOCK_KINDS)[number];
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, in one of two spellings:
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
- * A short name such as `sous-recipes:` is a consuming project's own label, so
24
- * it never appears in a published manifest; see `parseDependencyRef`.
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 { parseDependencyRef } from "../ref.js";
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`, in either of the two spellings a
46
- * manifest may use: a bare ref naming a recipe in this same repository, or a
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
- parseDependencyRef(value);
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.