@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.
@@ -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
- * Compiles and prunes a project exactly the way `sous build` does with no flags.
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
- * Shared by the subscription commands, which rebuild the project the moment they
444
- * have changed what it subscribes to: a newly subscribed recipe's files appear,
445
- * and a removed one's files are pruned, without anyone having to remember a
446
- * second command. Every option is left at its default on purpose, because the
447
- * point is to run the ordinary build and nothing else.
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 settings - The merged project config, reloaded after the change.
450
- * @param configContext - Where the active config was discovered.
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 buildProjectOutputs(
454
- settings: Settings,
455
- configContext: ConfigContext
456
- ): Promise<boolean> {
457
- return new BuildService().build(settings, { configContext });
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
- /** Handle errors according to strict mode. */
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
- process.exit(1);
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.handleError(`Failed to resolve current git branch: ${message}`);
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
- * Returns true if all targets compiled successfully.
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
- if (this.strict) {
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
- return allSucceeded;
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 plain `z.strictObject` instead; nothing writes extension keys into them.
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 = z
42
- .strictObject({
43
- /** The exact version this dependency resolved to, when the release could resolve one. */
44
- version: semverVersionSchema.optional(),
45
- /** The range the manifest declared, recorded when no exact version could be resolved. */
46
- range: semverRangeSchema.optional(),
47
- /**
48
- * The canonical identity of the repository publishing it
49
- * (`github.com/sous-io/sous-recipes`). Omitted for a sibling, which lives in
50
- * this same repository.
51
- */
52
- repo: repoIdentitySchema.optional(),
53
- })
54
- .refine(
55
- (entry) => entry.version !== undefined || entry.range !== undefined,
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 = z.strictObject({
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 = z.strictObject({
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 = z.strictObject({
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 = z
121
- .strictObject({
122
- /**
123
- * A plain-language note about where this copy of the index came from. JSON
124
- * has no comment syntax and an index is machine-written, so this is the one
125
- * place a writer can say something to whoever opens the file. Sous ignores
126
- * the value everywhere except one place: the seed index it writes for its
127
- * own built-in repository carries `SEED_INDEX_COMMENT`, which is how a
128
- * later run recognizes its own placeholder and is willing to replace it.
129
- */
130
- $comment: z.string().optional(),
131
- formatVersion: formatVersionSchema,
132
- /** The repo's suggested short name, copied from its manifest. */
133
- name: repoNameSchema,
134
- /** When this index was generated. */
135
- generatedAt: isoTimestampSchema,
136
- /** The version of sous that generated it. */
137
- generator: semverVersionSchema,
138
- /** Every namespace the repo publishes. */
139
- namespaces: z.record(namespaceNameSchema, indexNamespaceSchema),
140
- /** Every recipe the repo publishes, keyed `namespace/recipe`. */
141
- recipes: z.record(recipeKeySchema, indexRecipeSchema),
142
- })
143
- .superRefine((index, ctx) => {
144
- // A recipe whose namespace is not declared could never be resolved, so a
145
- // release that produced one is broken; say which recipe and which namespace.
146
- for (const key of Object.keys(index.recipes)) {
147
- const namespace = key.slice(0, key.indexOf("/"));
148
- if (!Object.hasOwn(index.namespaces, namespace)) {
149
- ctx.addIssue({
150
- code: "custom",
151
- path: ["recipes", key],
152
- message:
153
- `belongs to the namespace '${namespace}', which this index does not ` +
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
- // A version's tag is what the provider fetches, so a tag that does not
160
- // name this exact recipe and version is a version pointing somewhere else.
161
- // Nothing on the consumer side could otherwise tell: an index publishing
162
- // `1.0.0` with `tag: "main"` would hand `git clone --branch main` a moving
163
- // target, whose content changes on every push and whose pinned hash then
164
- // simply starts failing. Sous writes these tags itself, so requiring the
165
- // shape it writes costs a correct index nothing.
166
- for (const [key, recipe] of Object.entries(index.recipes)) {
167
- for (const [version, published] of Object.entries(recipe.versions)) {
168
- const expected = `${key}@${version}`;
169
- if (published.tag === expected) continue;
170
- ctx.addIssue({
171
- code: "custom",
172
- path: ["recipes", key, "versions", version, "tag"],
173
- message:
174
- `is '${published.tag}', but a published version's tag names the recipe and ` +
175
- `the version it carries, so this one must be '${expected}'`,
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>;