@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.
Files changed (37) hide show
  1. package/README.md +12 -0
  2. package/docs/markdown/README.md +52 -0
  3. package/docs/markdown/_sidebar.md +2 -0
  4. package/docs/markdown/commands.md +31 -6
  5. package/docs/markdown/repositories-authoring.md +7 -3
  6. package/docs/markdown/repositories-consuming.md +26 -12
  7. package/docs/markdown/repositories-file-formats.md +35 -5
  8. package/docs/markdown/repositories-variables.md +4 -1
  9. package/package.json +2 -1
  10. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/base-command.ts +75 -2
  13. package/src/commands/lock/rebuild.ts +5 -4
  14. package/src/commands/recipe/show.ts +163 -64
  15. package/src/commands/update.ts +178 -0
  16. package/src/lib/project-install.d.mts +4 -0
  17. package/src/lib/project-install.mjs +46 -8
  18. package/src/lib/repos/catalog.ts +43 -55
  19. package/src/lib/repos/declarations.ts +177 -0
  20. package/src/lib/repos/formats/common.ts +14 -0
  21. package/src/lib/repos/formats/index-file.ts +40 -1
  22. package/src/lib/repos/formats/lockfile.ts +5 -3
  23. package/src/lib/repos/formats/recipe-manifest.ts +6 -204
  24. package/src/lib/repos/formats/variable-definition.ts +268 -0
  25. package/src/lib/repos/providers/http.ts +37 -16
  26. package/src/lib/repos/release/index-builder.ts +168 -32
  27. package/src/lib/repos/resolver.ts +53 -12
  28. package/src/lib/repos/subscription-service.ts +93 -11
  29. package/src/lib/repos/update-plan.ts +3 -2
  30. package/src/lib/self-update/index.ts +14 -0
  31. package/src/lib/self-update/installs.ts +412 -0
  32. package/src/lib/self-update/managers.ts +126 -0
  33. package/src/lib/self-update/plan.ts +315 -0
  34. package/src/lib/self-update/registry.ts +99 -0
  35. package/src/lib/self-update/run.ts +506 -0
  36. package/src/lib/self-update/versions.ts +243 -0
  37. 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 a 404 or a 401 usually means for a sous repository.
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 and error label.
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
- if (response.status === 404) {
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