@sous-io/sous 0.2.25 → 0.2.27
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 +24 -8
- package/docs/markdown/repositories-authoring.md +5 -4
- package/docs/markdown/repositories-file-formats.md +16 -0
- package/docs/markdown/repositories.md +20 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +3 -2
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/build.ts +28 -43
- package/src/commands/compile.ts +2 -2
- package/src/commands/init.ts +9 -17
- package/src/commands/launch.ts +13 -7
- package/src/commands/prune.ts +13 -10
- package/src/commands/repo/remove.ts +7 -6
- package/src/commands/repo/unlink.ts +7 -6
- package/src/commands/subscription/add.ts +7 -6
- package/src/commands/subscription/remove.ts +7 -6
- package/src/commands/subscription/update.ts +7 -6
- package/src/lib/build-preparation.ts +3 -4
- package/src/lib/build-service.ts +60 -16
- package/src/lib/markdown-compiler.ts +75 -15
- package/src/lib/repos/formats/common.ts +18 -1
- package/src/lib/repos/formats/index-file.ts +79 -81
- package/src/lib/repos/locked-namespace-resolver.ts +132 -4
- package/src/lib/repos/locked-recipes.ts +0 -22
- package/src/lib/repos/namespace-resolver.ts +104 -19
- package/src/lib/repos/release/index-builder.ts +371 -80
package/src/lib/build-service.ts
CHANGED
|
@@ -10,14 +10,16 @@ import { CompilationService, resolveOutputPath } from "./markdown-compiler.js";
|
|
|
10
10
|
import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.js";
|
|
11
11
|
import { StateService } from "./state.js";
|
|
12
12
|
import { isProtectedPath } from "./state.js";
|
|
13
|
-
import { protectedRepoPaths } from "./repos/links.js";
|
|
13
|
+
import { describeLinkedRepos, protectedRepoPaths } from "./repos/links.js";
|
|
14
|
+
import { subscriptionServiceFor } from "./repos/subscription-service.js";
|
|
15
|
+
import { prepareRepositoriesForBuild } from "./build-preparation.js";
|
|
14
16
|
import {
|
|
15
17
|
noRecipeAnswers,
|
|
16
18
|
resolveRecipeAnswers,
|
|
17
19
|
unansweredWarning,
|
|
18
20
|
type RecipeAnswers,
|
|
19
21
|
} from "./vars/answers.js";
|
|
20
|
-
import { log, warning } from "../utils/formatting.js";
|
|
22
|
+
import { footer, heading, log, warning } from "../utils/formatting.js";
|
|
21
23
|
|
|
22
24
|
export type BuildOptions = {
|
|
23
25
|
strict?: boolean;
|
|
@@ -437,22 +439,64 @@ export class BuildService {
|
|
|
437
439
|
}
|
|
438
440
|
}
|
|
439
441
|
|
|
442
|
+
/** What {@link runProjectBuild} is given. */
|
|
443
|
+
export type ProjectBuildInput = {
|
|
444
|
+
/** The merged project config, reloaded after any change a command made. */
|
|
445
|
+
settings: Settings;
|
|
446
|
+
/** Where the active config was discovered. */
|
|
447
|
+
configContext: ConfigContext;
|
|
448
|
+
/** The shell environment as it was before the env files were injected. */
|
|
449
|
+
shellEnv?: NodeJS.ProcessEnv;
|
|
450
|
+
/** The build's own options; every one defaults to what a plain `sous build` uses. */
|
|
451
|
+
options?: Omit<BuildOptions, "configContext">;
|
|
452
|
+
/** The heading printed above the compile and prune step. Defaults to "Building". */
|
|
453
|
+
heading?: string;
|
|
454
|
+
};
|
|
455
|
+
|
|
440
456
|
/**
|
|
441
|
-
*
|
|
457
|
+
* The one build every command runs: `build`, `init`, `launch`, `prune`,
|
|
458
|
+
* `repo remove`, `repo unlink`, `subscription add`, `subscription remove` and
|
|
459
|
+
* `subscription update` all come through here, so a project is built the same
|
|
460
|
+
* way whichever of them the user typed.
|
|
442
461
|
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
462
|
+
* In order, it announces every linked repository, prepares the project's
|
|
463
|
+
* recipes (seeds the core recipe, locks new subscriptions, restores whatever
|
|
464
|
+
* the store is missing, checks upstream and reports newer versions), and then
|
|
465
|
+
* compiles and prunes. A dry run prepares nothing, because preparing writes the
|
|
466
|
+
* lockfile and the store. A partial rebuild in watch mode (one changed source
|
|
467
|
+
* file) prepares nothing either: the config did not change, so neither did the
|
|
468
|
+
* recipes it needs.
|
|
448
469
|
*
|
|
449
|
-
* @param
|
|
450
|
-
* @
|
|
451
|
-
* @returns True when compile and prune both succeeded.
|
|
470
|
+
* @param input - The project, the build options and the heading.
|
|
471
|
+
* @returns True when compile and prune both succeeded; false after any compile error.
|
|
452
472
|
*/
|
|
453
|
-
export async function
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
473
|
+
export async function runProjectBuild(input: ProjectBuildInput): Promise<boolean> {
|
|
474
|
+
const options = input.options ?? {};
|
|
475
|
+
const partial = options.changedFile !== undefined;
|
|
476
|
+
|
|
477
|
+
// A linked repository is read from a working copy instead of a published
|
|
478
|
+
// version, so it is announced on every build; a build that silently produced
|
|
479
|
+
// something different would be far worse than a noisy one.
|
|
480
|
+
if (!partial) {
|
|
481
|
+
const linked = describeLinkedRepos(input.configContext.sousDir);
|
|
482
|
+
if (linked.length > 0) warning(linked.join("\n"));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!options.dryRun && !partial) {
|
|
486
|
+
await prepareRepositoriesForBuild(
|
|
487
|
+
subscriptionServiceFor({
|
|
488
|
+
configContext: input.configContext,
|
|
489
|
+
settings: input.settings,
|
|
490
|
+
...(input.shellEnv === undefined ? {} : { shellEnv: input.shellEnv }),
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
heading(input.heading ?? "Building");
|
|
496
|
+
const succeeded = await new BuildService().build(input.settings, {
|
|
497
|
+
...options,
|
|
498
|
+
configContext: input.configContext,
|
|
499
|
+
});
|
|
500
|
+
footer();
|
|
501
|
+
return succeeded;
|
|
458
502
|
}
|
|
@@ -63,6 +63,11 @@ export type CompilationConfig = {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export type CompilationServiceOptions = {
|
|
66
|
+
/**
|
|
67
|
+
* Treat compile warnings as errors. An error always fails the compile; this
|
|
68
|
+
* only decides whether a warning (a `.tpl.` file copied without being
|
|
69
|
+
* rendered, a git branch that could not be read) does too.
|
|
70
|
+
*/
|
|
66
71
|
strict?: boolean;
|
|
67
72
|
rebuild?: boolean;
|
|
68
73
|
dryRun?: boolean;
|
|
@@ -184,14 +189,26 @@ export class CompilationService {
|
|
|
184
189
|
this.encoder = get_encoding("o200k_base");
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Records a compile error and prints it. The compile carries on, so every
|
|
194
|
+
* target is compiled and every error is listed; the output the error belongs
|
|
195
|
+
* to is not written, and the compile as a whole reports failure.
|
|
196
|
+
*/
|
|
188
197
|
private handleError(message: string): void {
|
|
189
198
|
this.errors.push(message);
|
|
190
199
|
displayError(message);
|
|
200
|
+
}
|
|
191
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Records a compile warning: printed as a warning, or, in strict mode,
|
|
204
|
+
* recorded as an error, with everything an error brings.
|
|
205
|
+
*/
|
|
206
|
+
private handleWarning(message: string): void {
|
|
192
207
|
if (this.strict) {
|
|
193
|
-
|
|
208
|
+
this.handleError(message);
|
|
209
|
+
return;
|
|
194
210
|
}
|
|
211
|
+
warning(message);
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
/**
|
|
@@ -383,7 +400,10 @@ export class CompilationService {
|
|
|
383
400
|
).trim();
|
|
384
401
|
} catch (error) {
|
|
385
402
|
const message = error instanceof Error ? error.message : String(error);
|
|
386
|
-
this.
|
|
403
|
+
this.handleWarning(
|
|
404
|
+
`Sous could not read the current git branch, so the runtime context says ` +
|
|
405
|
+
`"unknown": ${message}`
|
|
406
|
+
);
|
|
387
407
|
}
|
|
388
408
|
|
|
389
409
|
const runtimeHeader = `## Runtime Session Context
|
|
@@ -427,6 +447,21 @@ ${taskFileContents}
|
|
|
427
447
|
fs.writeFileSync(ctx.outputPath, `${runtimeHeader}${taskFileBlock}\n`, "utf8");
|
|
428
448
|
}
|
|
429
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Says that an output was not written because its target had an error, and
|
|
452
|
+
* what that leaves on disk.
|
|
453
|
+
*
|
|
454
|
+
* @param destFile - The output that was not written.
|
|
455
|
+
*/
|
|
456
|
+
private reportNotWritten(destFile: string): void {
|
|
457
|
+
const verb = this.dryRun ? "would not be written" : "not written";
|
|
458
|
+
log(
|
|
459
|
+
fs.existsSync(destFile)
|
|
460
|
+
? ` ✗ ${destFile} (${verb} because of an error; the previous copy stays)`
|
|
461
|
+
: ` ✗ ${destFile} (${verb} because of an error)`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
430
465
|
/** Compile a single target, writing to all of its outputs. */
|
|
431
466
|
private async compileTarget(
|
|
432
467
|
target: CompilationTarget,
|
|
@@ -445,6 +480,8 @@ ${taskFileContents}
|
|
|
445
480
|
? target.includeSourceComments
|
|
446
481
|
: this.includeSourceComments;
|
|
447
482
|
|
|
483
|
+
const errorsBefore = this.errors.length;
|
|
484
|
+
|
|
448
485
|
if (target.runtimeContext) {
|
|
449
486
|
this.generateRuntimeSessionContext(target);
|
|
450
487
|
}
|
|
@@ -457,6 +494,11 @@ ${taskFileContents}
|
|
|
457
494
|
return false;
|
|
458
495
|
}
|
|
459
496
|
|
|
497
|
+
// An include that failed left a hole in the assembled content (and in strict
|
|
498
|
+
// mode, a warning counts as an error), so none of this target's outputs may
|
|
499
|
+
// be written: the previous copy stays in place.
|
|
500
|
+
const assembled = this.errors.length === errorsBefore;
|
|
501
|
+
|
|
460
502
|
// Compute source hash once per target from the assembled content
|
|
461
503
|
const contentHash = hashContent(content);
|
|
462
504
|
|
|
@@ -473,6 +515,12 @@ ${taskFileContents}
|
|
|
473
515
|
if (resolvedDest === undefined) continue;
|
|
474
516
|
destFile = resolvedDest;
|
|
475
517
|
|
|
518
|
+
if (!assembled) {
|
|
519
|
+
this.reportNotWritten(destFile);
|
|
520
|
+
allSucceeded = false;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
476
524
|
// A rendered output depends on its variables as much as on its source: a
|
|
477
525
|
// changed answer or `_vars` value with the same template must re-render,
|
|
478
526
|
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
@@ -508,8 +556,15 @@ ${taskFileContents}
|
|
|
508
556
|
// directly, and it stays as a tripwire in case the resolver ever changes.
|
|
509
557
|
if (isTpl && !output.vars) {
|
|
510
558
|
this.unrenderedTemplates.push(`${target.rootInputPath} → ${destFile}`);
|
|
559
|
+
// In strict mode this warning is an error, so the output is not written.
|
|
560
|
+
if (this.strict) {
|
|
561
|
+
this.reportNotWritten(destFile);
|
|
562
|
+
allSucceeded = false;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
511
565
|
}
|
|
512
566
|
|
|
567
|
+
const errorsBeforeRender = this.errors.length;
|
|
513
568
|
const resolvedContent = (isTpl && output.vars)
|
|
514
569
|
? await this.renderContent(
|
|
515
570
|
content,
|
|
@@ -522,6 +577,14 @@ ${taskFileContents}
|
|
|
522
577
|
target.rootInputPath
|
|
523
578
|
)
|
|
524
579
|
: content;
|
|
580
|
+
|
|
581
|
+
// A template that failed to render is not written either.
|
|
582
|
+
if (this.errors.length > errorsBeforeRender) {
|
|
583
|
+
this.reportNotWritten(destFile);
|
|
584
|
+
allSucceeded = false;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
525
588
|
const fileContent = resolvedContent;
|
|
526
589
|
const outputDir = path.dirname(destFile);
|
|
527
590
|
|
|
@@ -582,8 +645,11 @@ ${taskFileContents}
|
|
|
582
645
|
}
|
|
583
646
|
|
|
584
647
|
/**
|
|
585
|
-
* Compile all targets from the given config.
|
|
586
|
-
*
|
|
648
|
+
* Compile all targets from the given config. Every target is compiled even
|
|
649
|
+
* after an error, so every error is listed; an output whose target had an
|
|
650
|
+
* error is not written and keeps its previous copy and its state entry.
|
|
651
|
+
*
|
|
652
|
+
* @returns True when nothing reported an error; false after any error.
|
|
587
653
|
*/
|
|
588
654
|
async compile(config: CompilationConfig, stateFilePath?: string): Promise<boolean> {
|
|
589
655
|
const stateService = new StateService();
|
|
@@ -632,15 +698,7 @@ ${taskFileContents}
|
|
|
632
698
|
`\nFix: add a '_vars' block to the output (an empty '_vars: {}' is enough to\n` +
|
|
633
699
|
`enable rendering), or rename the source so it does not contain '.tpl.'.`;
|
|
634
700
|
|
|
635
|
-
|
|
636
|
-
// Recorded as a failure rather than exiting here, so state still gets
|
|
637
|
-
// written and the caller decides the exit code.
|
|
638
|
-
this.errors.push(message);
|
|
639
|
-
displayError(message);
|
|
640
|
-
allSucceeded = false;
|
|
641
|
-
} else {
|
|
642
|
-
warning(message);
|
|
643
|
-
}
|
|
701
|
+
this.handleWarning(message);
|
|
644
702
|
}
|
|
645
703
|
|
|
646
704
|
if (this.errors.length > 0) {
|
|
@@ -665,7 +723,9 @@ ${taskFileContents}
|
|
|
665
723
|
await stateService.save(stateFilePath, state);
|
|
666
724
|
}
|
|
667
725
|
|
|
668
|
-
|
|
726
|
+
// Any error fails the compile, including one that belongs to no single
|
|
727
|
+
// target (a bad config option).
|
|
728
|
+
return allSucceeded && this.errors.length === 0;
|
|
669
729
|
} finally {
|
|
670
730
|
if (this.encoder) {
|
|
671
731
|
this.encoder.free();
|
|
@@ -307,12 +307,29 @@ function stripExtensionKeys(value: unknown): unknown {
|
|
|
307
307
|
* Builds a strict object schema for a HAND-WRITTEN format: unknown keys are
|
|
308
308
|
* rejected so typos surface immediately, except keys in the reserved `x-`
|
|
309
309
|
* extension namespace, which are accepted and ignored. Machine-written formats
|
|
310
|
-
* use
|
|
310
|
+
* use `z.strictObject` or `forwardCompatibleObject` instead; nothing writes
|
|
311
|
+
* extension keys into them.
|
|
311
312
|
*/
|
|
312
313
|
export function extensibleObject<Shape extends z.ZodRawShape>(shape: Shape) {
|
|
313
314
|
return z.preprocess(stripExtensionKeys, z.strictObject(shape));
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Builds an object schema for a machine-written format that other versions of
|
|
319
|
+
* sous publish and this one reads: the repo index. Every known key is validated
|
|
320
|
+
* exactly as strictly as `z.strictObject` would, and a key this version does not
|
|
321
|
+
* define is kept, unread, rather than refused.
|
|
322
|
+
*
|
|
323
|
+
* A later sous adds fields to the index, and a repository published by it is
|
|
324
|
+
* read by every older sous a project happens to pin; refusing the whole index
|
|
325
|
+
* over a field it cannot use would break those projects for nothing. The key is
|
|
326
|
+
* kept rather than dropped, so a release carrying a published version forward
|
|
327
|
+
* writes it back byte for byte, whichever sous runs the release.
|
|
328
|
+
*/
|
|
329
|
+
export function forwardCompatibleObject<Shape extends z.ZodRawShape>(shape: Shape) {
|
|
330
|
+
return z.looseObject(shape);
|
|
331
|
+
}
|
|
332
|
+
|
|
316
333
|
/**
|
|
317
334
|
* The `submissions` block a repo manifest and a recipe manifest may both carry:
|
|
318
335
|
* whether the recipes it covers take proposed changes, and where to send a
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Adding a repo fetches only this file. Nothing else is downloaded until a
|
|
11
11
|
* project subscribes to something inside it.
|
|
12
|
+
*
|
|
13
|
+
* Every object in it is a `forwardCompatibleObject`: a field a later sous adds is
|
|
14
|
+
* kept and ignored rather than refused, so an index published by a newer sous
|
|
15
|
+
* still reads here. The fields this version defines are validated in full.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { z } from "zod";
|
|
15
19
|
import {
|
|
16
20
|
contentHashSchema,
|
|
17
21
|
formatVersionSchema,
|
|
22
|
+
forwardCompatibleObject,
|
|
18
23
|
isoTimestampSchema,
|
|
19
24
|
namespaceNameSchema,
|
|
20
25
|
parseFormat,
|
|
@@ -38,30 +43,25 @@ import {
|
|
|
38
43
|
* belongs to that repository's own index, so this entry carries the range the
|
|
39
44
|
* manifest declared instead.
|
|
40
45
|
*/
|
|
41
|
-
export const indexDependencySchema =
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
message:
|
|
58
|
-
"must record either the exact version this dependency resolved to or the range " +
|
|
59
|
-
"the recipe declared",
|
|
60
|
-
}
|
|
61
|
-
);
|
|
46
|
+
export const indexDependencySchema = forwardCompatibleObject({
|
|
47
|
+
/** The exact version this dependency resolved to, when the release could resolve one. */
|
|
48
|
+
version: semverVersionSchema.optional(),
|
|
49
|
+
/** The range the manifest declared, recorded when no exact version could be resolved. */
|
|
50
|
+
range: semverRangeSchema.optional(),
|
|
51
|
+
/**
|
|
52
|
+
* The canonical identity of the repository publishing it
|
|
53
|
+
* (`github.com/sous-io/sous-recipes`). Omitted for a sibling, which lives in
|
|
54
|
+
* this same repository.
|
|
55
|
+
*/
|
|
56
|
+
repo: repoIdentitySchema.optional(),
|
|
57
|
+
}).refine((entry) => entry.version !== undefined || entry.range !== undefined, {
|
|
58
|
+
message:
|
|
59
|
+
"must record either the exact version this dependency resolved to or the range " +
|
|
60
|
+
"the recipe declared",
|
|
61
|
+
});
|
|
62
62
|
|
|
63
63
|
/** One published version of one recipe. */
|
|
64
|
-
export const indexVersionSchema =
|
|
64
|
+
export const indexVersionSchema = forwardCompatibleObject({
|
|
65
65
|
/** Content hash of the recipe folder at this version, verified after every fetch. */
|
|
66
66
|
hash: contentHashSchema,
|
|
67
67
|
/**
|
|
@@ -98,7 +98,7 @@ export const indexVersionSchema = z.strictObject({
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
/** One recipe, with every version the repo publishes of it. */
|
|
101
|
-
export const indexRecipeSchema =
|
|
101
|
+
export const indexRecipeSchema = forwardCompatibleObject({
|
|
102
102
|
/** The recipe folder, relative to the repo root. */
|
|
103
103
|
path: relativePathSchema("a recipe path"),
|
|
104
104
|
/** One-paragraph summary, copied from the recipe manifest at release time. */
|
|
@@ -112,71 +112,69 @@ export const indexRecipeSchema = z.strictObject({
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
/** One namespace declaration, copied from the repo manifest at release time. */
|
|
115
|
-
export const indexNamespaceSchema =
|
|
115
|
+
export const indexNamespaceSchema = forwardCompatibleObject({
|
|
116
116
|
description: z.string().optional(),
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
/** The repo index schema. */
|
|
120
|
-
export const indexFileSchema =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
`declare under 'namespaces'`,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
120
|
+
export const indexFileSchema = forwardCompatibleObject({
|
|
121
|
+
/**
|
|
122
|
+
* A plain-language note about where this copy of the index came from. JSON
|
|
123
|
+
* has no comment syntax and an index is machine-written, so this is the one
|
|
124
|
+
* place a writer can say something to whoever opens the file. Sous ignores
|
|
125
|
+
* the value everywhere except one place: the seed index it writes for its
|
|
126
|
+
* own built-in repository carries `SEED_INDEX_COMMENT`, which is how a
|
|
127
|
+
* later run recognizes its own placeholder and is willing to replace it.
|
|
128
|
+
*/
|
|
129
|
+
$comment: z.string().optional(),
|
|
130
|
+
formatVersion: formatVersionSchema,
|
|
131
|
+
/** The repo's suggested short name, copied from its manifest. */
|
|
132
|
+
name: repoNameSchema,
|
|
133
|
+
/** When this index was generated. */
|
|
134
|
+
generatedAt: isoTimestampSchema,
|
|
135
|
+
/** The version of sous that generated it. */
|
|
136
|
+
generator: semverVersionSchema,
|
|
137
|
+
/** Every namespace the repo publishes. */
|
|
138
|
+
namespaces: z.record(namespaceNameSchema, indexNamespaceSchema),
|
|
139
|
+
/** Every recipe the repo publishes, keyed `namespace/recipe`. */
|
|
140
|
+
recipes: z.record(recipeKeySchema, indexRecipeSchema),
|
|
141
|
+
}).superRefine((index, ctx) => {
|
|
142
|
+
// A recipe whose namespace is not declared could never be resolved, so a
|
|
143
|
+
// release that produced one is broken; say which recipe and which namespace.
|
|
144
|
+
for (const key of Object.keys(index.recipes)) {
|
|
145
|
+
const namespace = key.slice(0, key.indexOf("/"));
|
|
146
|
+
if (!Object.hasOwn(index.namespaces, namespace)) {
|
|
147
|
+
ctx.addIssue({
|
|
148
|
+
code: "custom",
|
|
149
|
+
path: ["recipes", key],
|
|
150
|
+
message:
|
|
151
|
+
`belongs to the namespace '${namespace}', which this index does not ` +
|
|
152
|
+
`declare under 'namespaces'`,
|
|
153
|
+
});
|
|
157
154
|
}
|
|
155
|
+
}
|
|
158
156
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
157
|
+
// A version's tag is what the provider fetches, so a tag that does not
|
|
158
|
+
// name this exact recipe and version is a version pointing somewhere else.
|
|
159
|
+
// Nothing on the consumer side could otherwise tell: an index publishing
|
|
160
|
+
// `1.0.0` with `tag: "main"` would hand `git clone --branch main` a moving
|
|
161
|
+
// target, whose content changes on every push and whose pinned hash then
|
|
162
|
+
// simply starts failing. Sous writes these tags itself, so requiring the
|
|
163
|
+
// shape it writes costs a correct index nothing.
|
|
164
|
+
for (const [key, recipe] of Object.entries(index.recipes)) {
|
|
165
|
+
for (const [version, published] of Object.entries(recipe.versions)) {
|
|
166
|
+
const expected = `${key}@${version}`;
|
|
167
|
+
if (published.tag === expected) continue;
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: "custom",
|
|
170
|
+
path: ["recipes", key, "versions", version, "tag"],
|
|
171
|
+
message:
|
|
172
|
+
`is '${published.tag}', but a published version's tag names the recipe and ` +
|
|
173
|
+
`the version it carries, so this one must be '${expected}'`,
|
|
174
|
+
});
|
|
178
175
|
}
|
|
179
|
-
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
180
178
|
|
|
181
179
|
/** A validated repo index. */
|
|
182
180
|
export type IndexFile = z.infer<typeof indexFileSchema>;
|