@sous-io/sous 0.2.29 → 0.2.31
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/README.md +12 -0
- package/docs/markdown/README.md +52 -0
- package/docs/markdown/_sidebar.md +2 -0
- package/docs/markdown/commands.md +31 -6
- package/docs/markdown/repositories-authoring.md +7 -3
- package/docs/markdown/repositories-consuming.md +26 -12
- package/docs/markdown/repositories-file-formats.md +35 -5
- package/docs/markdown/repositories-variables.md +4 -1
- package/package.json +2 -1
- 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/base-command.ts +75 -2
- package/src/commands/lock/rebuild.ts +5 -4
- package/src/commands/recipe/show.ts +163 -64
- package/src/commands/update.ts +178 -0
- package/src/lib/project-install.d.mts +4 -0
- package/src/lib/project-install.mjs +46 -8
- package/src/lib/repos/catalog.ts +43 -55
- 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 +40 -1
- package/src/lib/repos/formats/lockfile.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +6 -204
- package/src/lib/repos/formats/variable-definition.ts +268 -0
- package/src/lib/repos/providers/http.ts +37 -16
- package/src/lib/repos/release/index-builder.ts +168 -32
- package/src/lib/repos/resolver.ts +53 -12
- package/src/lib/repos/subscription-service.ts +93 -11
- package/src/lib/repos/update-plan.ts +3 -2
- package/src/lib/self-update/index.ts +14 -0
- package/src/lib/self-update/installs.ts +412 -0
- package/src/lib/self-update/managers.ts +126 -0
- package/src/lib/self-update/plan.ts +315 -0
- package/src/lib/self-update/registry.ts +99 -0
- package/src/lib/self-update/run.ts +506 -0
- package/src/lib/self-update/versions.ts +243 -0
- package/src/lib/vars/question-plan.ts +44 -21
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
|
|
29
29
|
import { z } from "zod";
|
|
30
30
|
import {
|
|
31
|
-
envVarNameSchema,
|
|
32
31
|
extensibleObject,
|
|
33
32
|
formatVersionSchema,
|
|
34
33
|
namespaceNameSchema,
|
|
@@ -37,10 +36,15 @@ import {
|
|
|
37
36
|
relativePathSchema,
|
|
38
37
|
semverVersionSchema,
|
|
39
38
|
submissionsSchema,
|
|
40
|
-
variableNameSchema,
|
|
41
39
|
} from "./common.js";
|
|
42
40
|
import { parseRef } from "../../refs/parse.js";
|
|
43
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";
|
|
44
48
|
|
|
45
49
|
// --- Dependency refs ----------------------------------------------------------------------------
|
|
46
50
|
|
|
@@ -97,196 +101,6 @@ export const recipeContentSchema = extensibleObject({
|
|
|
97
101
|
exclude: z.array(relativePathSchema("an exclude pattern", true)).optional(),
|
|
98
102
|
});
|
|
99
103
|
|
|
100
|
-
// --- Variable definitions -----------------------------------------------------------------------
|
|
101
|
-
|
|
102
|
-
/** The value types a recipe may declare for a variable. */
|
|
103
|
-
export const VARIABLE_TYPES = [
|
|
104
|
-
"string",
|
|
105
|
-
"number",
|
|
106
|
-
"boolean",
|
|
107
|
-
"enum",
|
|
108
|
-
"path",
|
|
109
|
-
"url",
|
|
110
|
-
] as const;
|
|
111
|
-
|
|
112
|
-
/** Which env file an answer is written to. */
|
|
113
|
-
export const VARIABLE_SCOPES = ["shared", "local"] as const;
|
|
114
|
-
|
|
115
|
-
/** Constraints checked against an answer before it is accepted or stored. */
|
|
116
|
-
export const variableValidationSchema = extensibleObject({
|
|
117
|
-
/** A regular expression the answer must match, as a string. */
|
|
118
|
-
pattern: z
|
|
119
|
-
.string()
|
|
120
|
-
.refine(
|
|
121
|
-
(value) => {
|
|
122
|
-
try {
|
|
123
|
-
new RegExp(value);
|
|
124
|
-
return true;
|
|
125
|
-
} catch {
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
{ message: "must be a valid regular expression" }
|
|
130
|
-
)
|
|
131
|
-
.optional(),
|
|
132
|
-
/** Minimum length of a string answer. */
|
|
133
|
-
minLength: z.number().int().min(0).optional(),
|
|
134
|
-
/** Maximum length of a string answer. */
|
|
135
|
-
maxLength: z.number().int().min(0).optional(),
|
|
136
|
-
/** Minimum value of a numeric answer. */
|
|
137
|
-
min: z.number().optional(),
|
|
138
|
-
/** Maximum value of a numeric answer. */
|
|
139
|
-
max: z.number().optional(),
|
|
140
|
-
/** The allowed answers. Required when the variable's type is 'enum'. */
|
|
141
|
-
enum: z.array(z.string()).min(1, "must list at least one option").optional(),
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* A published variable definition: a specification, not a value. Definitions
|
|
146
|
-
* are inert; a question is asked only when a subscribed recipe needs the
|
|
147
|
-
* variable and no valid answer is already in scope.
|
|
148
|
-
*/
|
|
149
|
-
export const variableDefinitionSchema = extensibleObject({
|
|
150
|
-
/** The variable's camelCase name, as templates refer to it. */
|
|
151
|
-
name: variableNameSchema,
|
|
152
|
-
/**
|
|
153
|
-
* The environment variable an answer binds to. Authors may name an existing
|
|
154
|
-
* variable such as GITHUB_TOKEN to reuse a value already in the environment.
|
|
155
|
-
* When omitted, `sous repo release` derives an upper snake case default from
|
|
156
|
-
* the name; the runtime never derives one.
|
|
157
|
-
*/
|
|
158
|
-
env: envVarNameSchema.optional(),
|
|
159
|
-
/** The answer's type, which decides how it is validated and prompted for. */
|
|
160
|
-
type: z.enum(VARIABLE_TYPES),
|
|
161
|
-
/** The one-line question shown when the variable is asked. */
|
|
162
|
-
prompt: z.string().min(1, "must not be empty"),
|
|
163
|
-
/**
|
|
164
|
-
* The paragraph that explains the variable: what it is for, what a good
|
|
165
|
-
* answer looks like, and what changes when it is set. Shown above the
|
|
166
|
-
* question when sous asks, and by `sous vars <name>`. Required, because a
|
|
167
|
-
* consumer reading the question has no other way to learn what a publisher
|
|
168
|
-
* meant by it.
|
|
169
|
-
*/
|
|
170
|
-
description: z
|
|
171
|
-
.string({
|
|
172
|
-
error:
|
|
173
|
-
"is required: every published variable must explain itself in a sentence or " +
|
|
174
|
-
"two. The description is shown above the question when sous asks, and by " +
|
|
175
|
-
"'sous vars <name>'",
|
|
176
|
-
})
|
|
177
|
-
.min(1, "must not be empty"),
|
|
178
|
-
/**
|
|
179
|
-
* A realistic sample answer. It is documentation and prompt copy only; sous
|
|
180
|
-
* never stores it, never offers it as the answer, and never falls back to it.
|
|
181
|
-
* Use `default` for a value a project should actually start with.
|
|
182
|
-
*/
|
|
183
|
-
example: z.union([z.string(), z.number(), z.boolean()], {
|
|
184
|
-
error:
|
|
185
|
-
"is required: every published variable must show what a real answer looks like. " +
|
|
186
|
-
"The example is shown with the question and by 'sous vars <name>'; it is " +
|
|
187
|
-
"documentation only and is never stored as the answer (use 'default' for that)",
|
|
188
|
-
}),
|
|
189
|
-
/** The value offered when the question is asked with nothing else in scope. */
|
|
190
|
-
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
191
|
-
/** Whether an answer is needed for a build to run. Defaults to true. */
|
|
192
|
-
required: z.boolean().default(true),
|
|
193
|
-
/**
|
|
194
|
-
* Whether the answer is a secret. A secret is always written to the
|
|
195
|
-
* gitignored `.sous/.env.local`, never to the committed `.sous/.env`.
|
|
196
|
-
*/
|
|
197
|
-
secret: z.boolean().default(false),
|
|
198
|
-
/**
|
|
199
|
-
* Which env file the answer is written to: 'shared' is `.sous/.env`, which is
|
|
200
|
-
* committed and shared with the team; 'local' is `.sous/.env.local`, which is
|
|
201
|
-
* gitignored and machine-specific. Defaults to 'shared'.
|
|
202
|
-
*/
|
|
203
|
-
scope: z.enum(VARIABLE_SCOPES).default("shared"),
|
|
204
|
-
/** Constraints checked against an answer. */
|
|
205
|
-
validate: variableValidationSchema.optional(),
|
|
206
|
-
})
|
|
207
|
-
.superRefine((definition, ctx) => {
|
|
208
|
-
const { type, validate } = definition;
|
|
209
|
-
|
|
210
|
-
if (type === "enum" && (validate?.enum === undefined || validate.enum.length === 0)) {
|
|
211
|
-
ctx.addIssue({
|
|
212
|
-
code: "custom",
|
|
213
|
-
path: ["validate", "enum"],
|
|
214
|
-
message:
|
|
215
|
-
"a variable of type 'enum' must list its options under 'validate.enum'",
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (validate !== undefined) {
|
|
220
|
-
const { minLength, maxLength, min, max } = validate;
|
|
221
|
-
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
222
|
-
ctx.addIssue({
|
|
223
|
-
code: "custom",
|
|
224
|
-
path: ["validate", "maxLength"],
|
|
225
|
-
message: "must not be smaller than 'validate.minLength'",
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
if (min !== undefined && max !== undefined && min > max) {
|
|
229
|
-
ctx.addIssue({
|
|
230
|
-
code: "custom",
|
|
231
|
-
path: ["validate", "max"],
|
|
232
|
-
message: "must not be smaller than 'validate.min'",
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// A secret written to the committed env file would leak, so the two fields
|
|
238
|
-
// are not allowed to contradict each other.
|
|
239
|
-
if (definition.secret && definition.scope === "shared") {
|
|
240
|
-
ctx.addIssue({
|
|
241
|
-
code: "custom",
|
|
242
|
-
path: ["scope"],
|
|
243
|
-
message:
|
|
244
|
-
"a secret variable is stored in the gitignored '.sous/.env.local', so its " +
|
|
245
|
-
"scope must be 'local' (or left unset)",
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// `default` and `example` are both literal values written by the publisher,
|
|
250
|
-
// so both have to fit the variable they describe; an example that could
|
|
251
|
-
// never be a valid answer is worse than no example at all.
|
|
252
|
-
checkDeclaredValue(definition.default, "default");
|
|
253
|
-
checkDeclaredValue(definition.example, "example");
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Checks one publisher-written literal against the declared type and the
|
|
257
|
-
* 'validate.enum' options.
|
|
258
|
-
*
|
|
259
|
-
* @param value - The literal to check, or undefined when it was omitted.
|
|
260
|
-
* @param field - The field name, used as the issue path.
|
|
261
|
-
*/
|
|
262
|
-
function checkDeclaredValue(
|
|
263
|
-
value: string | number | boolean | undefined,
|
|
264
|
-
field: "default" | "example"
|
|
265
|
-
): void {
|
|
266
|
-
if (value === undefined) return;
|
|
267
|
-
|
|
268
|
-
const expected =
|
|
269
|
-
type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
|
|
270
|
-
if (typeof value !== expected) {
|
|
271
|
-
ctx.addIssue({
|
|
272
|
-
code: "custom",
|
|
273
|
-
path: [field],
|
|
274
|
-
message: `must be a ${expected}, to match the declared type '${type}'`,
|
|
275
|
-
});
|
|
276
|
-
} else if (
|
|
277
|
-
type === "enum" &&
|
|
278
|
-
validate?.enum !== undefined &&
|
|
279
|
-
!validate.enum.includes(String(value))
|
|
280
|
-
) {
|
|
281
|
-
ctx.addIssue({
|
|
282
|
-
code: "custom",
|
|
283
|
-
path: [field],
|
|
284
|
-
message: "must be one of the options listed under 'validate.enum'",
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
|
|
290
104
|
// --- The manifest -------------------------------------------------------------------------------
|
|
291
105
|
|
|
292
106
|
/** The recipe manifest schema. */
|
|
@@ -364,21 +178,9 @@ export type RecipeManifest = z.infer<typeof recipeManifestSchema>;
|
|
|
364
178
|
/** One validated content group from a recipe manifest. */
|
|
365
179
|
export type RecipeContent = z.infer<typeof recipeContentSchema>;
|
|
366
180
|
|
|
367
|
-
/** One validated variable definition from a recipe manifest. */
|
|
368
|
-
export type VariableDefinition = z.infer<typeof variableDefinitionSchema>;
|
|
369
|
-
|
|
370
|
-
/** The constraints attached to a variable definition. */
|
|
371
|
-
export type VariableValidation = z.infer<typeof variableValidationSchema>;
|
|
372
|
-
|
|
373
181
|
/** What a content group contributes. */
|
|
374
182
|
export type ContentKind = (typeof CONTENT_KINDS)[number];
|
|
375
183
|
|
|
376
|
-
/** The value type a variable definition declares. */
|
|
377
|
-
export type VariableType = (typeof VARIABLE_TYPES)[number];
|
|
378
|
-
|
|
379
|
-
/** Which env file an answer is written to. */
|
|
380
|
-
export type VariableScope = (typeof VARIABLE_SCOPES)[number];
|
|
381
|
-
|
|
382
184
|
/**
|
|
383
185
|
* Validates a parsed recipe manifest, throwing a ConfigError that names the
|
|
384
186
|
* file and the path of every bad field.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A recipe's variable definitions: what each variable is called, what shape an
|
|
3
|
+
* answer takes, and what question to ask. A recipe manifest declares them
|
|
4
|
+
* (`recipe-manifest.ts` re-exports everything here), and `sous repo release`
|
|
5
|
+
* copies each version's definitions into the index, so the same fields and the
|
|
6
|
+
* same rules read both copies.
|
|
7
|
+
*
|
|
8
|
+
* This module imports nothing but the shared primitives, because the index
|
|
9
|
+
* reads it, and the index sits below the ref parser the manifest needs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import {
|
|
14
|
+
envVarNameSchema,
|
|
15
|
+
extensibleObject,
|
|
16
|
+
forwardCompatibleObject,
|
|
17
|
+
variableNameSchema,
|
|
18
|
+
} from "./common.js";
|
|
19
|
+
|
|
20
|
+
// --- Variable definitions -----------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** The value types a recipe may declare for a variable. */
|
|
23
|
+
export const VARIABLE_TYPES = [
|
|
24
|
+
"string",
|
|
25
|
+
"number",
|
|
26
|
+
"boolean",
|
|
27
|
+
"enum",
|
|
28
|
+
"path",
|
|
29
|
+
"url",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/** Which env file an answer is written to. */
|
|
33
|
+
export const VARIABLE_SCOPES = ["shared", "local"] as const;
|
|
34
|
+
|
|
35
|
+
/** Constraints checked against an answer before it is accepted or stored. */
|
|
36
|
+
const variableValidationShape = {
|
|
37
|
+
/** A regular expression the answer must match, as a string. */
|
|
38
|
+
pattern: z
|
|
39
|
+
.string()
|
|
40
|
+
.refine(
|
|
41
|
+
(value) => {
|
|
42
|
+
try {
|
|
43
|
+
new RegExp(value);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{ message: "must be a valid regular expression" }
|
|
50
|
+
)
|
|
51
|
+
.optional(),
|
|
52
|
+
/** Minimum length of a string answer. */
|
|
53
|
+
minLength: z.number().int().min(0).optional(),
|
|
54
|
+
/** Maximum length of a string answer. */
|
|
55
|
+
maxLength: z.number().int().min(0).optional(),
|
|
56
|
+
/** Minimum value of a numeric answer. */
|
|
57
|
+
min: z.number().optional(),
|
|
58
|
+
/** Maximum value of a numeric answer. */
|
|
59
|
+
max: z.number().optional(),
|
|
60
|
+
/** The allowed answers. Required when the variable's type is 'enum'. */
|
|
61
|
+
enum: z.array(z.string()).min(1, "must list at least one option").optional(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Constraints checked against an answer before it is accepted or stored. */
|
|
65
|
+
export const variableValidationSchema = extensibleObject(variableValidationShape);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The fields of a variable definition, shared by the two schemas that read
|
|
69
|
+
* one: the hand-written manifest, and the copy a release records in the index.
|
|
70
|
+
*
|
|
71
|
+
* @param validate - The schema for the `validate` block, strict or forward compatible.
|
|
72
|
+
*/
|
|
73
|
+
function variableDefinitionShape<Validate extends z.ZodType>(validate: Validate) {
|
|
74
|
+
return {
|
|
75
|
+
/** The variable's camelCase name, as templates refer to it. */
|
|
76
|
+
name: variableNameSchema,
|
|
77
|
+
/**
|
|
78
|
+
* The environment variable an answer binds to. Authors may name an existing
|
|
79
|
+
* variable such as GITHUB_TOKEN to reuse a value already in the environment.
|
|
80
|
+
* When omitted, `sous repo release` derives an upper snake case default from
|
|
81
|
+
* the name; the runtime never derives one.
|
|
82
|
+
*/
|
|
83
|
+
env: envVarNameSchema.optional(),
|
|
84
|
+
/** The answer's type, which decides how it is validated and prompted for. */
|
|
85
|
+
type: z.enum(VARIABLE_TYPES),
|
|
86
|
+
/** The one-line question shown when the variable is asked. */
|
|
87
|
+
prompt: z.string().min(1, "must not be empty"),
|
|
88
|
+
/**
|
|
89
|
+
* The paragraph that explains the variable: what it is for, what a good
|
|
90
|
+
* answer looks like, and what changes when it is set. Shown above the
|
|
91
|
+
* question when sous asks, and by `sous vars <name>`. Required, because a
|
|
92
|
+
* consumer reading the question has no other way to learn what a publisher
|
|
93
|
+
* meant by it.
|
|
94
|
+
*/
|
|
95
|
+
description: z
|
|
96
|
+
.string({
|
|
97
|
+
error:
|
|
98
|
+
"is required: every published variable must explain itself in a sentence or " +
|
|
99
|
+
"two. The description is shown above the question when sous asks, and by " +
|
|
100
|
+
"'sous vars <name>'",
|
|
101
|
+
})
|
|
102
|
+
.min(1, "must not be empty"),
|
|
103
|
+
/**
|
|
104
|
+
* A realistic sample answer. It is documentation and prompt copy only; sous
|
|
105
|
+
* never stores it, never offers it as the answer, and never falls back to it.
|
|
106
|
+
* Use `default` for a value a project should actually start with.
|
|
107
|
+
*/
|
|
108
|
+
example: z.union([z.string(), z.number(), z.boolean()], {
|
|
109
|
+
error:
|
|
110
|
+
"is required: every published variable must show what a real answer looks like. " +
|
|
111
|
+
"The example is shown with the question and by 'sous vars <name>'; it is " +
|
|
112
|
+
"documentation only and is never stored as the answer (use 'default' for that)",
|
|
113
|
+
}),
|
|
114
|
+
/** The value offered when the question is asked with nothing else in scope. */
|
|
115
|
+
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
116
|
+
/** Whether an answer is needed for a build to run. Defaults to true. */
|
|
117
|
+
required: z.boolean().default(true),
|
|
118
|
+
/**
|
|
119
|
+
* Whether the answer is a secret. A secret is always written to the
|
|
120
|
+
* gitignored `.sous/.env.local`, never to the committed `.sous/.env`.
|
|
121
|
+
*/
|
|
122
|
+
secret: z.boolean().default(false),
|
|
123
|
+
/**
|
|
124
|
+
* Which env file the answer is written to: 'shared' is `.sous/.env`, which is
|
|
125
|
+
* committed and shared with the team; 'local' is `.sous/.env.local`, which is
|
|
126
|
+
* gitignored and machine-specific. Defaults to 'shared'.
|
|
127
|
+
*/
|
|
128
|
+
scope: z.enum(VARIABLE_SCOPES).default("shared"),
|
|
129
|
+
/** Constraints checked against an answer. */
|
|
130
|
+
validate: validate.optional(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What `checkVariableDefinition` reads from a definition. */
|
|
135
|
+
type CheckedDefinition = {
|
|
136
|
+
type: VariableType;
|
|
137
|
+
secret: boolean;
|
|
138
|
+
scope: VariableScope;
|
|
139
|
+
default?: string | number | boolean | undefined;
|
|
140
|
+
example: string | number | boolean;
|
|
141
|
+
validate?:
|
|
142
|
+
| { minLength?: number; maxLength?: number; min?: number; max?: number; enum?: string[] }
|
|
143
|
+
| undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The rules a variable definition obeys beyond the shape of each field, shared
|
|
148
|
+
* by both schemas so a definition the index records is held to exactly the rules
|
|
149
|
+
* its manifest was.
|
|
150
|
+
*
|
|
151
|
+
* @param definition - The definition, already parsed field by field.
|
|
152
|
+
* @param ctx - The refinement context issues are added to.
|
|
153
|
+
*/
|
|
154
|
+
function checkVariableDefinition(definition: CheckedDefinition, ctx: z.RefinementCtx): void {
|
|
155
|
+
const { type, validate } = definition;
|
|
156
|
+
|
|
157
|
+
if (type === "enum" && (validate?.enum === undefined || validate.enum.length === 0)) {
|
|
158
|
+
ctx.addIssue({
|
|
159
|
+
code: "custom",
|
|
160
|
+
path: ["validate", "enum"],
|
|
161
|
+
message:
|
|
162
|
+
"a variable of type 'enum' must list its options under 'validate.enum'",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (validate !== undefined) {
|
|
167
|
+
const { minLength, maxLength, min, max } = validate;
|
|
168
|
+
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: "custom",
|
|
171
|
+
path: ["validate", "maxLength"],
|
|
172
|
+
message: "must not be smaller than 'validate.minLength'",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: "custom",
|
|
178
|
+
path: ["validate", "max"],
|
|
179
|
+
message: "must not be smaller than 'validate.min'",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A secret written to the committed env file would leak, so the two fields
|
|
185
|
+
// are not allowed to contradict each other.
|
|
186
|
+
if (definition.secret && definition.scope === "shared") {
|
|
187
|
+
ctx.addIssue({
|
|
188
|
+
code: "custom",
|
|
189
|
+
path: ["scope"],
|
|
190
|
+
message:
|
|
191
|
+
"a secret variable is stored in the gitignored '.sous/.env.local', so its " +
|
|
192
|
+
"scope must be 'local' (or left unset)",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `default` and `example` are both literal values written by the publisher,
|
|
197
|
+
// so both have to fit the variable they describe; an example that could
|
|
198
|
+
// never be a valid answer is worse than no example at all.
|
|
199
|
+
checkDeclaredValue(definition.default, "default");
|
|
200
|
+
checkDeclaredValue(definition.example, "example");
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Checks one publisher-written literal against the declared type and the
|
|
204
|
+
* 'validate.enum' options.
|
|
205
|
+
*
|
|
206
|
+
* @param value - The literal to check, or undefined when it was omitted.
|
|
207
|
+
* @param field - The field name, used as the issue path.
|
|
208
|
+
*/
|
|
209
|
+
function checkDeclaredValue(
|
|
210
|
+
value: string | number | boolean | undefined,
|
|
211
|
+
field: "default" | "example"
|
|
212
|
+
): void {
|
|
213
|
+
if (value === undefined) return;
|
|
214
|
+
|
|
215
|
+
const expected =
|
|
216
|
+
type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
|
|
217
|
+
if (typeof value !== expected) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: "custom",
|
|
220
|
+
path: [field],
|
|
221
|
+
message: `must be a ${expected}, to match the declared type '${type}'`,
|
|
222
|
+
});
|
|
223
|
+
} else if (
|
|
224
|
+
type === "enum" &&
|
|
225
|
+
validate?.enum !== undefined &&
|
|
226
|
+
!validate.enum.includes(String(value))
|
|
227
|
+
) {
|
|
228
|
+
ctx.addIssue({
|
|
229
|
+
code: "custom",
|
|
230
|
+
path: [field],
|
|
231
|
+
message: "must be one of the options listed under 'validate.enum'",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A published variable definition: a specification, not a value. Definitions
|
|
239
|
+
* are inert; a question is asked only when a subscribed recipe needs the
|
|
240
|
+
* variable and no valid answer is already in scope.
|
|
241
|
+
*/
|
|
242
|
+
export const variableDefinitionSchema = extensibleObject(
|
|
243
|
+
variableDefinitionShape(variableValidationSchema)
|
|
244
|
+
).superRefine(checkVariableDefinition);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A variable definition as a release records it in the index, so a consumer
|
|
248
|
+
* can list a recipe's questions before its files are fetched. It is the
|
|
249
|
+
* manifest's definition, field for field and rule for rule; the one difference
|
|
250
|
+
* is that a field this sous does not define is kept rather than refused,
|
|
251
|
+
* because the index is read by every sous a project happens to pin, and a
|
|
252
|
+
* later one may add a field.
|
|
253
|
+
*/
|
|
254
|
+
export const publishedVariableDefinitionSchema = forwardCompatibleObject(
|
|
255
|
+
variableDefinitionShape(forwardCompatibleObject(variableValidationShape))
|
|
256
|
+
).superRefine(checkVariableDefinition);
|
|
257
|
+
|
|
258
|
+
/** One validated variable definition from a recipe manifest. */
|
|
259
|
+
export type VariableDefinition = z.infer<typeof variableDefinitionSchema>;
|
|
260
|
+
|
|
261
|
+
/** The constraints attached to a variable definition. */
|
|
262
|
+
export type VariableValidation = z.infer<typeof variableValidationSchema>;
|
|
263
|
+
|
|
264
|
+
/** The value type a variable definition declares. */
|
|
265
|
+
export type VariableType = (typeof VARIABLE_TYPES)[number];
|
|
266
|
+
|
|
267
|
+
/** Which env file an answer is written to. */
|
|
268
|
+
export type VariableScope = (typeof VARIABLE_SCOPES)[number];
|
|
@@ -38,15 +38,48 @@ export type FetchTextOptions = {
|
|
|
38
38
|
label?: string;
|
|
39
39
|
/** Cancels the request, for a caller that will not wait past a deadline. */
|
|
40
40
|
signal?: AbortSignal;
|
|
41
|
+
/** The `Accept` header to send. Defaults to `application/json`. */
|
|
42
|
+
accept?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The lines that explain a failed status to the reader. Defaults to the
|
|
45
|
+
* hints about a sous repository (a missing index, a private repository), so
|
|
46
|
+
* a caller fetching anything else names its own.
|
|
47
|
+
*/
|
|
48
|
+
statusHints?: (status: number) => string[];
|
|
41
49
|
};
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What a failed status usually means when the URL names a file in a sous
|
|
53
|
+
* repository: the default for `FetchTextOptions.statusHints`.
|
|
54
|
+
*
|
|
55
|
+
* @param status - The HTTP status the server answered with.
|
|
56
|
+
*/
|
|
57
|
+
function repositoryStatusHints(status: number): string[] {
|
|
58
|
+
if (status === 404) {
|
|
59
|
+
return [
|
|
60
|
+
" Either the repository publishes no sous index yet, or the URL names a " +
|
|
61
|
+
"repository that does not exist.",
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
if (status === 401 || status === 403) {
|
|
65
|
+
return [
|
|
66
|
+
" The repository is private or the request was not authorized. Sous uses a " +
|
|
67
|
+
"token from the environment, or from the provider's command line tool when " +
|
|
68
|
+
"one is installed and signed in.",
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
/**
|
|
44
75
|
* GETs a URL and returns its body as text. Any non-2xx response, or a transport
|
|
45
76
|
* failure, becomes a ConfigError that names the URL and the status, and says
|
|
46
|
-
* plainly what
|
|
77
|
+
* plainly what the status usually means (for a sous repository, unless the
|
|
78
|
+
* caller passes `statusHints`).
|
|
47
79
|
*
|
|
48
80
|
* @param url - The absolute HTTPS URL to fetch.
|
|
49
|
-
* @param options - Bearer token, fetch implementation
|
|
81
|
+
* @param options - Bearer token, fetch implementation, error label, `Accept`
|
|
82
|
+
* header and status hints.
|
|
50
83
|
*/
|
|
51
84
|
export async function fetchText(
|
|
52
85
|
url: string,
|
|
@@ -62,7 +95,7 @@ export async function fetchText(
|
|
|
62
95
|
);
|
|
63
96
|
}
|
|
64
97
|
|
|
65
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
98
|
+
const headers: Record<string, string> = { Accept: options.accept ?? "application/json" };
|
|
66
99
|
if (options.token !== undefined && options.token.length > 0) {
|
|
67
100
|
headers["Authorization"] = `Bearer ${options.token}`;
|
|
68
101
|
}
|
|
@@ -85,19 +118,7 @@ export async function fetchText(
|
|
|
85
118
|
`Sous could not fetch the ${label} from ${url}.`,
|
|
86
119
|
` The server answered ${response.status} ${response.statusText}.`,
|
|
87
120
|
];
|
|
88
|
-
|
|
89
|
-
lines.push(
|
|
90
|
-
" Either the repository publishes no sous index yet, or the URL names a " +
|
|
91
|
-
"repository that does not exist."
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
if (response.status === 401 || response.status === 403) {
|
|
95
|
-
lines.push(
|
|
96
|
-
" The repository is private or the request was not authorized. Sous uses a " +
|
|
97
|
-
"token from the environment, or from the provider's command line tool when " +
|
|
98
|
-
"one is installed and signed in."
|
|
99
|
-
);
|
|
100
|
-
}
|
|
121
|
+
lines.push(...(options.statusHints ?? repositoryStatusHints)(response.status));
|
|
101
122
|
throw new ConfigError(lines.join("\n"));
|
|
102
123
|
}
|
|
103
124
|
|