@sous-io/sous 0.2.2 → 0.2.3
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 +1 -0
- package/docs/markdown/repositories-consuming.md +3 -1
- package/docs/markdown/repositories-variables.md +33 -14
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/compile.ts +4 -2
- package/src/lib/build-service.ts +52 -4
- package/src/lib/markdown-compiler.ts +20 -1
- package/src/lib/repos/recipe-targets.ts +9 -1
- package/src/lib/settings.ts +51 -3
- package/src/lib/vars/answers.ts +184 -0
- package/src/lib/vars/definition-source.ts +9 -0
- package/src/lib/vars/index.ts +1 -0
|
@@ -264,7 +264,9 @@ $ sous build
|
|
|
264
264
|
```
|
|
265
265
|
|
|
266
266
|
A variable with no answer in the committed `.sous/.env` and none in the environment renders empty
|
|
267
|
-
rather than stopping the build
|
|
267
|
+
rather than stopping the build, and the build prints a warning naming it before it compiles; run
|
|
268
|
+
`sous vars ask` from a terminal to fill it in. How an answer reaches a template is described in
|
|
269
|
+
[How a template reads an answer](repositories-variables.md#how-a-template-reads-an-answer).
|
|
268
270
|
|
|
269
271
|
## Control freshness and the store
|
|
270
272
|
|
|
@@ -297,20 +297,39 @@ nothing about it sensitive, committing it to `.sous/.env` is simpler: a fresh cl
|
|
|
297
297
|
|
|
298
298
|
## How a template reads an answer
|
|
299
299
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
300
|
+
A build lays the answers into the template scope itself. For every variable a subscribed recipe publishes,
|
|
301
|
+
the build walks the ladder above, takes the first value it finds, and adds it to the scope under the
|
|
302
|
+
variable's own name. So once `taskFileRoot` is answered, `{{ taskFileRoot }}` renders in the recipe's own
|
|
303
|
+
skills and in any template this project compiles, and `${taskFileRoot}` works in `_vars` and every other
|
|
304
|
+
config value. Nothing has to be mapped by hand.
|
|
305
|
+
|
|
306
|
+
The answers sit under your config, not over it. The scope a template renders with is assembled in this
|
|
307
|
+
order, each layer overriding the one before:
|
|
308
|
+
|
|
309
|
+
1. The auto-injected `sous*` variables.
|
|
310
|
+
2. The recipe answers, found through the ladder.
|
|
311
|
+
3. Your `_env` block.
|
|
312
|
+
4. Your `_vars` block.
|
|
313
|
+
|
|
314
|
+
So a project that already carries an answer in `_vars`, or maps one through `_env`, keeps rendering exactly
|
|
315
|
+
what it did; the answer in the env files is simply shadowed, and `sous vars list` still reports it.
|
|
316
|
+
|
|
317
|
+
When no rung answers, the definition's own `default` is what renders, because the description a publisher
|
|
318
|
+
writes promises what the default does. A required variable with no answer and no default renders as an
|
|
319
|
+
empty string, and the build says so before it compiles, naming each such variable, the recipe that asks for
|
|
320
|
+
it, and `sous vars ask` as the way to answer. The build still succeeds; an unanswered question is
|
|
321
|
+
something to tell you about, not a reason to refuse the rest of the project.
|
|
322
|
+
|
|
323
|
+
?> Two recipes may ask the same question. Their shared answer renders in both, and in your own templates.
|
|
324
|
+
When the recipe-scoped name gives one of them a different answer, that recipe's own files render its own
|
|
325
|
+
answer while everything else, your templates included, renders the first definition's; `sous vars show`
|
|
326
|
+
tells you which names are in play.
|
|
327
|
+
|
|
328
|
+
An answer is laid in exactly as it is stored: a path stays the string you typed, relative or absolute, and a
|
|
329
|
+
number stays text. A template that needs an absolute path from a relative answer composes one under another
|
|
330
|
+
name in `_vars`, for instance `taskFileDir: "${sousDir}/../${taskFileRoot}"`.
|
|
331
|
+
|
|
332
|
+
The `_env` block is still the way to reach any environment variable no recipe asks about.
|
|
314
333
|
|
|
315
334
|
## Where to go next
|
|
316
335
|
|
package/package.json
CHANGED
package/src/commands/compile.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { BaseCommand } from "../base-command.js";
|
|
|
3
3
|
import { CompilationService } from "../lib/markdown-compiler.js";
|
|
4
4
|
import { resolveCompilation, resolveRootScope } from "../lib/settings.js";
|
|
5
5
|
import {
|
|
6
|
+
resolveProjectAnswers,
|
|
6
7
|
resolveRecipeTargets,
|
|
7
8
|
resolveStateFilePath,
|
|
8
9
|
withRecipeTargets,
|
|
@@ -57,8 +58,9 @@ export default class Compile extends BaseCommand {
|
|
|
57
58
|
// The recipes this project subscribes to contribute compile targets
|
|
58
59
|
// alongside its own; both go through the same compiler.
|
|
59
60
|
const withRecipes = () => {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
61
|
+
const answers = resolveProjectAnswers(this.settings, this.configContext);
|
|
62
|
+
const scope = resolveRootScope(this.settings, this.configContext, { answers });
|
|
63
|
+
const recipes = resolveRecipeTargets(this.settings, scope, this.configContext, answers);
|
|
62
64
|
for (const notice of recipes.warnings) warning(notice);
|
|
63
65
|
return withRecipeTargets(
|
|
64
66
|
resolveCompilation(this.settings, scope),
|
package/src/lib/build-service.ts
CHANGED
|
@@ -11,6 +11,12 @@ import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.j
|
|
|
11
11
|
import { StateService } from "./state.js";
|
|
12
12
|
import { isProtectedPath } from "./state.js";
|
|
13
13
|
import { protectedRepoPaths } from "./repos/links.js";
|
|
14
|
+
import {
|
|
15
|
+
noRecipeAnswers,
|
|
16
|
+
resolveRecipeAnswers,
|
|
17
|
+
unansweredWarning,
|
|
18
|
+
type RecipeAnswers,
|
|
19
|
+
} from "./vars/answers.js";
|
|
14
20
|
import { log, warning } from "../utils/formatting.js";
|
|
15
21
|
|
|
16
22
|
export type BuildOptions = {
|
|
@@ -50,26 +56,65 @@ const NO_RECIPE_TARGETS: RecipeTargets = {
|
|
|
50
56
|
warnings: [],
|
|
51
57
|
};
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The answers to every recipe variable in play for the project the context
|
|
61
|
+
* describes, resolved once so a build can render with them, hand them to every
|
|
62
|
+
* scope it builds, and report what is still unanswered. Empty when the caller
|
|
63
|
+
* gave no config context.
|
|
64
|
+
*
|
|
65
|
+
* @param settings - The merged project config.
|
|
66
|
+
* @param configContext - Where the active config was discovered.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveProjectAnswers(
|
|
69
|
+
settings: Settings,
|
|
70
|
+
configContext?: ConfigContext
|
|
71
|
+
): RecipeAnswers {
|
|
72
|
+
if (configContext === undefined) return noRecipeAnswers();
|
|
73
|
+
return resolveRecipeAnswers({ settings, sousDir: configContext.sousDir });
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
/**
|
|
54
77
|
* The compile targets a project's subscribed recipes contribute, for the project
|
|
55
78
|
* the options describe. Empty when the caller gave no config context, which is
|
|
56
79
|
* the case only in tests that build a settings object by hand.
|
|
57
80
|
*
|
|
81
|
+
* Each recipe's files render with that recipe's own view of the answers, and a
|
|
82
|
+
* required variable nothing answered is reported through `warnings`, so every
|
|
83
|
+
* caller that prints those tells the user what the build could not fill in.
|
|
84
|
+
*
|
|
58
85
|
* @param settings - The merged project config.
|
|
59
86
|
* @param rootScope - The resolved settings scope, for `${var}` in destinations.
|
|
60
87
|
* @param configContext - Where the active config was discovered.
|
|
88
|
+
* @param answers - The project's recipe answers, when the caller resolved them
|
|
89
|
+
* already; resolved here otherwise.
|
|
61
90
|
*/
|
|
62
91
|
export function resolveRecipeTargets(
|
|
63
92
|
settings: Settings,
|
|
64
93
|
rootScope: Record<string, string>,
|
|
65
|
-
configContext?: ConfigContext
|
|
94
|
+
configContext?: ConfigContext,
|
|
95
|
+
answers?: RecipeAnswers
|
|
66
96
|
): RecipeTargets {
|
|
67
97
|
if (configContext === undefined) return NO_RECIPE_TARGETS;
|
|
68
|
-
|
|
98
|
+
const resolved = answers ?? resolveProjectAnswers(settings, configContext);
|
|
99
|
+
const scopes = new Map<string, Record<string, string>>();
|
|
100
|
+
|
|
101
|
+
const recipes = buildRecipeTargets({
|
|
69
102
|
sousDir: configContext.sousDir,
|
|
70
103
|
settings,
|
|
71
104
|
scope: rootScope,
|
|
105
|
+
scopeFor: (recipe) => {
|
|
106
|
+
let scope = scopes.get(recipe.key);
|
|
107
|
+
if (scope === undefined) {
|
|
108
|
+
scope = resolveRootScope(settings, configContext, { answers: resolved, recipe: recipe.key });
|
|
109
|
+
scopes.set(recipe.key, scope);
|
|
110
|
+
}
|
|
111
|
+
return scope;
|
|
112
|
+
},
|
|
72
113
|
});
|
|
114
|
+
|
|
115
|
+
const missing = unansweredWarning(resolved, rootScope);
|
|
116
|
+
if (missing !== undefined) recipes.warnings.push(missing);
|
|
117
|
+
return recipes;
|
|
73
118
|
}
|
|
74
119
|
|
|
75
120
|
/**
|
|
@@ -232,7 +277,10 @@ export class BuildService {
|
|
|
232
277
|
* Returns true if all steps succeeded.
|
|
233
278
|
*/
|
|
234
279
|
async build(settings: Settings, options: BuildOptions = {}): Promise<boolean> {
|
|
235
|
-
|
|
280
|
+
// The recipe answers are resolved once here and handed to every scope the
|
|
281
|
+
// build assembles, so the lockfile and the manifests are read one time.
|
|
282
|
+
const answers = resolveProjectAnswers(settings, options.configContext);
|
|
283
|
+
const rootScope = resolveRootScope(settings, options.configContext, { answers });
|
|
236
284
|
const namespaceResolver = resolveNamespaceResolver(settings, options);
|
|
237
285
|
const protectedPaths = protectedPathsFor(options);
|
|
238
286
|
|
|
@@ -260,7 +308,7 @@ export class BuildService {
|
|
|
260
308
|
// targets alongside its own, so a recipe's files are compiled by exactly the
|
|
261
309
|
// same machinery as everything else, and are pruned and cleared by it too.
|
|
262
310
|
if (!options.noCompile) {
|
|
263
|
-
const recipes = resolveRecipeTargets(settings, rootScope, options.configContext);
|
|
311
|
+
const recipes = resolveRecipeTargets(settings, rootScope, options.configContext, answers);
|
|
264
312
|
for (const notice of recipes.warnings) warning(notice);
|
|
265
313
|
|
|
266
314
|
const config = withRecipeTargets(
|
|
@@ -129,6 +129,17 @@ export function inferGlobBase(pattern: string): string {
|
|
|
129
129
|
return joined || "/";
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A stable text form of an output's variable scope, for the source hash of a
|
|
134
|
+
* rendered output. Keys are sorted so two scopes holding the same values hash
|
|
135
|
+
* the same whatever order they were assembled in.
|
|
136
|
+
*
|
|
137
|
+
* @param vars - The variable scope an output renders with.
|
|
138
|
+
*/
|
|
139
|
+
export function stableVarsFingerprint(vars: Record<string, string>): string {
|
|
140
|
+
return JSON.stringify(Object.keys(vars).sort().map((key) => [key, vars[key]]));
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
export class CompilationService {
|
|
133
144
|
private strict: boolean;
|
|
134
145
|
private rebuild: boolean;
|
|
@@ -447,7 +458,7 @@ ${taskFileContents}
|
|
|
447
458
|
}
|
|
448
459
|
|
|
449
460
|
// Compute source hash once per target from the assembled content
|
|
450
|
-
const
|
|
461
|
+
const contentHash = hashContent(content);
|
|
451
462
|
|
|
452
463
|
let allSucceeded = true;
|
|
453
464
|
|
|
@@ -462,6 +473,14 @@ ${taskFileContents}
|
|
|
462
473
|
if (resolvedDest === undefined) continue;
|
|
463
474
|
destFile = resolvedDest;
|
|
464
475
|
|
|
476
|
+
// A rendered output depends on its variables as much as on its source: a
|
|
477
|
+
// changed answer or `_vars` value with the same template must re-render,
|
|
478
|
+
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
479
|
+
// verbatim copy hashes its content alone.
|
|
480
|
+
const srcHash = isTpl && output.vars
|
|
481
|
+
? hashContent(`${content}\n${stableVarsFingerprint(output.vars)}`)
|
|
482
|
+
: contentHash;
|
|
483
|
+
|
|
465
484
|
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
466
485
|
const existingEntry = state.files.find(f => f.dest === destFile);
|
|
467
486
|
if (
|
|
@@ -52,6 +52,12 @@ export type RecipeTargetOptions = {
|
|
|
52
52
|
settings: Settings;
|
|
53
53
|
/** The resolved settings scope, used to substitute `${var}` in destinations. */
|
|
54
54
|
scope?: VarScope;
|
|
55
|
+
/**
|
|
56
|
+
* The scope one recipe's own files render with. A recipe's answers to its own
|
|
57
|
+
* questions are laid over the project scope there, so a recipe sees its own
|
|
58
|
+
* answer even when another recipe asks the same name. Defaults to `scope`.
|
|
59
|
+
*/
|
|
60
|
+
scopeFor?: (recipe: LockedRecipeLocation) => VarScope;
|
|
55
61
|
/** The environment to read; decides where the store is. */
|
|
56
62
|
env?: NodeJS.ProcessEnv;
|
|
57
63
|
/** The locked recipes, when the caller has already located them. */
|
|
@@ -167,6 +173,8 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
|
|
|
167
173
|
for (const destination of kindDestinations) destinations.add(destination);
|
|
168
174
|
if (recipe.linked) watchDirs.add(recipe.dir);
|
|
169
175
|
|
|
176
|
+
const recipeScope = options.scopeFor?.(recipe) ?? options.scope ?? {};
|
|
177
|
+
|
|
170
178
|
const ignore = (content.exclude ?? []).map((pattern) =>
|
|
171
179
|
path.join(recipe.dir, pattern)
|
|
172
180
|
);
|
|
@@ -182,7 +190,7 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
|
|
|
182
190
|
globBase,
|
|
183
191
|
outputs: kindDestinations.map((destination) => ({
|
|
184
192
|
destinationDir: destination,
|
|
185
|
-
vars:
|
|
193
|
+
vars: recipeScope,
|
|
186
194
|
})),
|
|
187
195
|
});
|
|
188
196
|
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -18,6 +18,11 @@ import { resolveSousHome } from "./sous-home.js";
|
|
|
18
18
|
import { validateSettings } from "./config-schema.js";
|
|
19
19
|
import { applyRepoDefaults } from "./repos/defaults.js";
|
|
20
20
|
import type { RecipeConfigLayer } from "./repos/recipe-config-layers.js";
|
|
21
|
+
import {
|
|
22
|
+
answersForRecipe,
|
|
23
|
+
resolveRecipeAnswers,
|
|
24
|
+
type RecipeAnswers,
|
|
25
|
+
} from "./vars/answers.js";
|
|
21
26
|
import { warning } from "../utils/formatting.js";
|
|
22
27
|
|
|
23
28
|
// Re-exported for backwards compatibility: ConfigError moved to ./errors.ts so
|
|
@@ -757,20 +762,63 @@ export function resolveEnvScope(settings: Settings, context?: ConfigContext): Va
|
|
|
757
762
|
return scope;
|
|
758
763
|
}
|
|
759
764
|
|
|
765
|
+
/** What else a root scope may be built from; see resolveRootScope. */
|
|
766
|
+
export type RootScopeOptions = {
|
|
767
|
+
/**
|
|
768
|
+
* The recipe answers already resolved for this project. When omitted and a
|
|
769
|
+
* config context is given, they are resolved here; pass them when a caller
|
|
770
|
+
* has them already, so the lockfile and the manifests are read once.
|
|
771
|
+
*/
|
|
772
|
+
answers?: RecipeAnswers;
|
|
773
|
+
/**
|
|
774
|
+
* The recipe whose own files this scope renders, as `namespace/recipe`. Its
|
|
775
|
+
* own answers are laid over the merged view, so a recipe sees the answer to
|
|
776
|
+
* its own question even when another recipe asks the same name.
|
|
777
|
+
*/
|
|
778
|
+
recipe?: string;
|
|
779
|
+
};
|
|
780
|
+
|
|
760
781
|
/**
|
|
761
782
|
* Resolves the root-level _vars from a Settings object into a scope.
|
|
762
|
-
* Chains: auto-vars → env scope → root _vars.
|
|
783
|
+
* Chains: auto-vars → recipe answers → env scope → root _vars.
|
|
784
|
+
*
|
|
785
|
+
* The recipe answers are the values the project's env files and shell hold for
|
|
786
|
+
* every variable its subscribed recipes publish, found through the ladder in
|
|
787
|
+
* `vars/ladder.ts`. They sit under `_env` and `_vars`, so an explicit config
|
|
788
|
+
* value always wins, and they are present only when a config context says which
|
|
789
|
+
* project this is; a settings object built by hand in a test has none.
|
|
763
790
|
*
|
|
764
791
|
* @param settings - The root settings object.
|
|
765
792
|
* @param context - The discovered config location (optional in tests).
|
|
793
|
+
* @param options - Answers already resolved, or the recipe the scope is for.
|
|
766
794
|
*/
|
|
767
|
-
export function resolveRootScope(
|
|
795
|
+
export function resolveRootScope(
|
|
796
|
+
settings: Settings,
|
|
797
|
+
context?: ConfigContext,
|
|
798
|
+
options: RootScopeOptions = {}
|
|
799
|
+
): VarScope {
|
|
768
800
|
const autoVars = buildAutoVars(context);
|
|
801
|
+
const answers = resolveAnswerLayer(settings, context, options);
|
|
769
802
|
const envScope = resolveEnvScope(settings, context);
|
|
770
|
-
const baseScope = { ...autoVars, ...envScope };
|
|
803
|
+
const baseScope = { ...autoVars, ...answers, ...envScope };
|
|
771
804
|
return resolveScope(settings._vars ?? {}, baseScope);
|
|
772
805
|
}
|
|
773
806
|
|
|
807
|
+
/**
|
|
808
|
+
* The recipe-answer layer of a root scope: the merged view, or a recipe's own
|
|
809
|
+
* view of it. Empty without a config context.
|
|
810
|
+
*/
|
|
811
|
+
function resolveAnswerLayer(
|
|
812
|
+
settings: Settings,
|
|
813
|
+
context: ConfigContext | undefined,
|
|
814
|
+
options: RootScopeOptions
|
|
815
|
+
): VarScope {
|
|
816
|
+
if (context === undefined) return {};
|
|
817
|
+
const answers =
|
|
818
|
+
options.answers ?? resolveRecipeAnswers({ settings, sousDir: context.sousDir });
|
|
819
|
+
return options.recipe === undefined ? answers.merged : answersForRecipe(answers, options.recipe);
|
|
820
|
+
}
|
|
821
|
+
|
|
774
822
|
/**
|
|
775
823
|
* Built-in `@include` aliases, always available and reserved (their names begin
|
|
776
824
|
* with `~` so user `_aliases` can never shadow them). Add new entries here as
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answers a build renders with.
|
|
3
|
+
*
|
|
4
|
+
* A recipe publishes variable DEFINITIONS; the project's env files and shell
|
|
5
|
+
* hold the ANSWERS; the ladder (`ladder.ts`) says which answer a definition
|
|
6
|
+
* gets. This module is the one place that turns all of that into the variable
|
|
7
|
+
* scope a template renders from, so a `{{ variable }}` in a recipe's own skill
|
|
8
|
+
* sees the answer to its own question without the project mapping the name by
|
|
9
|
+
* hand through `_env`.
|
|
10
|
+
*
|
|
11
|
+
* Three views come out of one walk over the definitions:
|
|
12
|
+
*
|
|
13
|
+
* - `merged`, what the project's own templates render: one value per
|
|
14
|
+
* variable name. Two recipes may publish the same name (the shared rung of
|
|
15
|
+
* the ladder exists for exactly that), so the first definition in lockfile
|
|
16
|
+
* order wins the merged view, and a project that wants something else says
|
|
17
|
+
* so in `_vars`, which sits above every answer.
|
|
18
|
+
* - `byRecipe`, what a recipe's own files render: the answers resolved for
|
|
19
|
+
* that recipe's definitions, which the recipe-scoped rung of the ladder can
|
|
20
|
+
* make different from the merged view.
|
|
21
|
+
* - `unanswered`, every required definition that no rung and no default
|
|
22
|
+
* answered. A build reports those and carries on, because a missing answer
|
|
23
|
+
* is something to tell the user about, not a reason to refuse to build the
|
|
24
|
+
* rest of the project.
|
|
25
|
+
*
|
|
26
|
+
* A definition's `default` counts as an answer of last resort: the
|
|
27
|
+
* description a publisher writes promises what the default does, and a
|
|
28
|
+
* template rendering an empty string instead would break that promise.
|
|
29
|
+
*
|
|
30
|
+
* Values are laid in exactly as the ladder found them. Nothing here resolves a
|
|
31
|
+
* path or coerces a number; the answer is the string the user stored, the same
|
|
32
|
+
* string an `_env` mapping would deliver.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Settings, VarScope } from "../settings.js";
|
|
36
|
+
import { BULLET } from "../../utils/formatting.js";
|
|
37
|
+
import {
|
|
38
|
+
ProjectDefinitionSource,
|
|
39
|
+
definingRecipeKey,
|
|
40
|
+
type DefinedVariable,
|
|
41
|
+
} from "./definition-source.js";
|
|
42
|
+
import { loadLadderContext, resolveVariable, type LadderContext } from "./ladder.js";
|
|
43
|
+
|
|
44
|
+
/** The answers in play for a project, in the three views a build needs. */
|
|
45
|
+
export interface RecipeAnswers {
|
|
46
|
+
/** One value per variable name; the first definition in lockfile order wins a name. */
|
|
47
|
+
merged: VarScope;
|
|
48
|
+
/** The answers resolved for each recipe's own definitions, keyed `namespace/recipe`. */
|
|
49
|
+
byRecipe: Map<string, VarScope>;
|
|
50
|
+
/** Every required definition that nothing answered and that has no default. */
|
|
51
|
+
unanswered: DefinedVariable[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How the answers are resolved. */
|
|
55
|
+
export interface RecipeAnswerOptions {
|
|
56
|
+
/** The merged project config, read for its `varMappings` block. */
|
|
57
|
+
settings: Settings;
|
|
58
|
+
/** The project's `.sous/` directory, which holds the lockfile and both env files. */
|
|
59
|
+
sousDir: string;
|
|
60
|
+
/**
|
|
61
|
+
* The definitions to answer. Defaults to every definition the project's
|
|
62
|
+
* lockfile pins; tests hand in a fixed list.
|
|
63
|
+
*/
|
|
64
|
+
definitions?: DefinedVariable[];
|
|
65
|
+
/**
|
|
66
|
+
* The environment the ladder treats as the shell. A build passes nothing and
|
|
67
|
+
* gets `process.env`, which already holds every env-file value in precedence
|
|
68
|
+
* order (the files are loaded first-writer-wins, shell first), so the value
|
|
69
|
+
* that comes back is the right one; only the layer it is attributed to would
|
|
70
|
+
* differ, and a build does not report that.
|
|
71
|
+
*/
|
|
72
|
+
shellEnv?: NodeJS.ProcessEnv;
|
|
73
|
+
/** The environment that decides where the store is; defaults to `process.env`. */
|
|
74
|
+
env?: NodeJS.ProcessEnv;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The empty result, for a project that locks nothing. */
|
|
78
|
+
export function noRecipeAnswers(): RecipeAnswers {
|
|
79
|
+
return { merged: {}, byRecipe: new Map(), unanswered: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolves every definition in play through the ladder and lays the answers
|
|
84
|
+
* out in the three views a build renders and reports with.
|
|
85
|
+
*
|
|
86
|
+
* @param options - The project's config, its `.sous/` directory, and optionally
|
|
87
|
+
* the definitions and environment to use instead of the real ones.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveRecipeAnswers(options: RecipeAnswerOptions): RecipeAnswers {
|
|
90
|
+
const definitions =
|
|
91
|
+
options.definitions ??
|
|
92
|
+
new ProjectDefinitionSource(options.settings, options.sousDir, options.env).loadSync();
|
|
93
|
+
if (definitions.length === 0) return noRecipeAnswers();
|
|
94
|
+
|
|
95
|
+
const context: LadderContext = loadLadderContext({
|
|
96
|
+
sousDir: options.sousDir,
|
|
97
|
+
settings: options.settings,
|
|
98
|
+
...(options.shellEnv === undefined ? {} : { shellEnv: options.shellEnv }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = noRecipeAnswers();
|
|
102
|
+
|
|
103
|
+
for (const defined of definitions) {
|
|
104
|
+
const value = answerFor(defined, context);
|
|
105
|
+
const name = defined.definition.name;
|
|
106
|
+
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
if (defined.definition.required) result.unanswered.push(defined);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!(name in result.merged)) result.merged[name] = value;
|
|
113
|
+
|
|
114
|
+
const recipeKey = definingRecipeKey(defined.recipe);
|
|
115
|
+
const own = result.byRecipe.get(recipeKey) ?? {};
|
|
116
|
+
own[name] = value;
|
|
117
|
+
result.byRecipe.set(recipeKey, own);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The answer one definition renders with: what the ladder found, else the
|
|
125
|
+
* definition's own default, else nothing.
|
|
126
|
+
*
|
|
127
|
+
* @param defined - The definition and the recipe that published it.
|
|
128
|
+
* @param context - The environment layers and mapping records.
|
|
129
|
+
*/
|
|
130
|
+
function answerFor(defined: DefinedVariable, context: LadderContext): string | undefined {
|
|
131
|
+
const resolved = resolveVariable(defined, context);
|
|
132
|
+
if (resolved !== undefined) return resolved.value;
|
|
133
|
+
const fallback = defined.definition.default;
|
|
134
|
+
return fallback === undefined ? undefined : String(fallback);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The scope a recipe's own files render with: the merged view, with that
|
|
139
|
+
* recipe's own answers laid over it.
|
|
140
|
+
*
|
|
141
|
+
* @param answers - The resolved answers.
|
|
142
|
+
* @param recipeKey - The recipe, as `namespace/recipe`.
|
|
143
|
+
*/
|
|
144
|
+
export function answersForRecipe(answers: RecipeAnswers, recipeKey: string): VarScope {
|
|
145
|
+
return { ...answers.merged, ...(answers.byRecipe.get(recipeKey) ?? {}) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The one warning a build prints when required variables are unanswered: every
|
|
150
|
+
* variable named with the recipe that asks for it, and the command that answers
|
|
151
|
+
* them. Undefined when nothing is missing.
|
|
152
|
+
*
|
|
153
|
+
* A variable the project's config defines itself, in `_vars` or through
|
|
154
|
+
* `_env`, is not missing: the template renders that value, whatever the ladder
|
|
155
|
+
* found. So the check is made against the scope the templates actually render
|
|
156
|
+
* with, not against the ladder alone.
|
|
157
|
+
*
|
|
158
|
+
* @param answers - The resolved answers.
|
|
159
|
+
* @param renderScope - The scope the project's templates render with.
|
|
160
|
+
*/
|
|
161
|
+
export function unansweredWarning(
|
|
162
|
+
answers: RecipeAnswers,
|
|
163
|
+
renderScope: VarScope = {}
|
|
164
|
+
): string | undefined {
|
|
165
|
+
const missing = answers.unanswered.filter(
|
|
166
|
+
(defined) => renderScope[defined.definition.name] === undefined
|
|
167
|
+
);
|
|
168
|
+
if (missing.length === 0) return undefined;
|
|
169
|
+
|
|
170
|
+
const lines = missing.map(
|
|
171
|
+
(defined) =>
|
|
172
|
+
` ${BULLET} ${defined.definition.name} (asked by ${definingRecipeKey(defined.recipe)})`
|
|
173
|
+
);
|
|
174
|
+
const count = missing.length;
|
|
175
|
+
const noun = count === 1 ? "variable" : "variables";
|
|
176
|
+
const pronoun = count === 1 ? "it" : "them";
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
`${count} required recipe ${noun} ${count === 1 ? "has" : "have"} no answer, so the ` +
|
|
180
|
+
`templates that use ${pronoun} render an empty value:\n` +
|
|
181
|
+
`${lines.join("\n")}\n` +
|
|
182
|
+
`Run 'sous vars ask' to answer ${pronoun}.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -111,6 +111,15 @@ export class ProjectDefinitionSource implements VariableDefinitionSource {
|
|
|
111
111
|
|
|
112
112
|
/** Every variable published by every recipe this project's lockfile pins. */
|
|
113
113
|
async load(): Promise<DefinedVariable[]> {
|
|
114
|
+
return this.loadSync();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The same list, read synchronously. The lockfile and the manifests are
|
|
119
|
+
* ordinary files, and the settings scope a build renders with is assembled
|
|
120
|
+
* synchronously, so the build path needs this form.
|
|
121
|
+
*/
|
|
122
|
+
loadSync(): DefinedVariable[] {
|
|
114
123
|
void this.settings;
|
|
115
124
|
|
|
116
125
|
const defined: DefinedVariable[] = [];
|