@sous-io/sous 0.1.1 → 0.2.1

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 (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +409 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +625 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +415 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * Asking the questions a project's variable definitions imply.
3
+ *
4
+ * A definition is inert. A question is asked only when a subscribed recipe
5
+ * needs a variable and nothing in scope answers it, or when the answer that IS
6
+ * in scope no longer fits the definition. An answer that is already there is
7
+ * offered back with its scope and source shown and is never asked about again.
8
+ *
9
+ * Answers are stored in the project's own env files: `.sous/.env` for the
10
+ * shared ones, which are committed and shared with the team, and
11
+ * `.sous/.env.local` for machine-specific values and every secret, which is
12
+ * gitignored. The writer preserves the rest of the file exactly.
13
+ *
14
+ * Nothing here prompts unless it was told it may. A non-interactive run with an
15
+ * unanswered required variable fails, naming the exact environment variables
16
+ * that would satisfy it, most specific first, which is the message a continuous
17
+ * integration log needs to be useful.
18
+ */
19
+
20
+ import path from "node:path";
21
+ import { confirm, input as input_, select } from "@inquirer/prompts";
22
+ import { color } from "@oclif/color";
23
+ import { ENV_DEFAULTS_NAME, ENV_LOCAL_NAME } from "../config-discovery.js";
24
+ import { updateEnvFile } from "../env-file.js";
25
+ import { ConfigError } from "../errors.js";
26
+ import {
27
+ blankLine,
28
+ blankLines,
29
+ formatVariable,
30
+ indent,
31
+ keysHelpTip,
32
+ log,
33
+ palette,
34
+ paragraph,
35
+ showVariables,
36
+ VARIABLE_INDENT,
37
+ warning,
38
+ wrapColumns,
39
+ wrapText,
40
+ } from "../../utils/formatting.js";
41
+ import { choicePrompt } from "../../utils/choice-prompt.js";
42
+ import { confirmPrompt } from "../../utils/confirm-prompt.js";
43
+ import { valuePrompt } from "../../utils/value-prompt.js";
44
+ import { ENV_VAR_NAME_PATTERN } from "../repos/formats/patterns.js";
45
+ import { variableReferenceKey } from "../refs/find.js";
46
+ import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
47
+ import {
48
+ definedVariableKey,
49
+ definingRecipeKey,
50
+ type DefinedVariable,
51
+ } from "./definition-source.js";
52
+ import {
53
+ BASIC_FACT_LABELS,
54
+ displayValue,
55
+ renderFacts,
56
+ selectFacts,
57
+ variableFacts,
58
+ } from "./display.js";
59
+ import {
60
+ describeSource,
61
+ diagnoseVariable,
62
+ lookupEnvName,
63
+ variableCandidates,
64
+ recordAnswerInContext,
65
+ RUNG_LABELS,
66
+ type LadderCandidate,
67
+ type LadderContext,
68
+ type ResolvedVariable,
69
+ } from "./ladder.js";
70
+ import {
71
+ VAR_MAPPINGS_LAYER_FILENAME,
72
+ formatMappingTarget,
73
+ mappingTargetFor,
74
+ writeMappingRecord,
75
+ } from "./mappings.js";
76
+ import {
77
+ bareName,
78
+ namespaceScopedName,
79
+ recipeScopedName,
80
+ sharedName,
81
+ } from "./names.js";
82
+ import { validateAnswer } from "./validate.js";
83
+
84
+ /** Which env file an answer was written to. */
85
+ export type AnswerFile = typeof ENV_LOCAL_NAME | typeof ENV_DEFAULTS_NAME;
86
+
87
+ /** One answer that was collected and stored. */
88
+ export interface AnsweredVariable {
89
+ /** The variable that was answered. */
90
+ defined: DefinedVariable;
91
+ /** The environment variable the answer was stored under. */
92
+ envName: string;
93
+ /** Which env file it went into. */
94
+ file: AnswerFile;
95
+ /** The absolute path of that file. */
96
+ filePath: string;
97
+ /** The stored value. */
98
+ value: string;
99
+ /** Whether the entry was rewritten in place or added at the end. */
100
+ outcome: "updated" | "appended" | "not written";
101
+ /** The mapping record written alongside the answer, when one was needed. */
102
+ mapping?: { envName: string; target: string; filePath: string };
103
+ /**
104
+ * The answer this one replaced, when there was a different one already in
105
+ * scope. Only a supplied answer (`--answer`) replaces anything without being
106
+ * asked first, so the report says plainly when one did.
107
+ */
108
+ replaced?: string;
109
+ /**
110
+ * An environment variable in the real shell environment that answers this
111
+ * variable and therefore outranks the file this answer was written to. The
112
+ * report names it, because the stored answer does nothing until it is unset.
113
+ */
114
+ shadowedBy?: string;
115
+ }
116
+
117
+ /** One variable that already had a valid answer. */
118
+ export interface InheritedVariable {
119
+ /** The variable that was already answered. */
120
+ defined: DefinedVariable;
121
+ /** The answer and exactly where it came from. */
122
+ resolved: ResolvedVariable;
123
+ }
124
+
125
+ /** One variable that was left alone, and why. */
126
+ export interface SkippedVariable {
127
+ /** The variable that was not answered. */
128
+ defined: DefinedVariable;
129
+ /** A plain-language reason, shown in the report. */
130
+ reason: string;
131
+ }
132
+
133
+ /** What one run of `askForMissing` did. */
134
+ export interface AskReport {
135
+ /** Answers collected in this run. */
136
+ answered: AnsweredVariable[];
137
+ /** Answers that were already in scope and still fit. */
138
+ inherited: InheritedVariable[];
139
+ /** Variables that were deliberately left unanswered. */
140
+ skipped: SkippedVariable[];
141
+ }
142
+
143
+ /** How `askForMissing` should behave. */
144
+ export interface AskOptions {
145
+ /** The project's `.sous/` directory, which holds both env files. */
146
+ sousDir: string;
147
+ /** The project's `conf.d/` directory, where a mapping record is written. */
148
+ confDir: string;
149
+ /** Whether questions may be asked. A non-interactive run fails instead. */
150
+ interactive: boolean;
151
+ /**
152
+ * Limit the run to these variables. Each entry is a variable's bare name, its
153
+ * `namespace/recipe.name` key, or its fully qualified
154
+ * `repository:namespace/recipe.name` reference. A command that resolved a
155
+ * reference through `src/lib/refs/` passes the fully qualified form, which is
156
+ * the only spelling that cannot mean two variables at once.
157
+ */
158
+ only?: string[];
159
+ /**
160
+ * Variables an answer was supplied for ahead of the run, by
161
+ * `namespace/recipe.name` key. They are neither asked about nor reported
162
+ * here; the caller that stored them reports them itself.
163
+ */
164
+ skip?: string[];
165
+ /** Ask again even when a valid answer is already in scope. */
166
+ reask?: boolean;
167
+ /** Work out what would happen and report it, without writing anything. */
168
+ dryRun?: boolean;
169
+ }
170
+
171
+ /** True when the definition's answer belongs in the gitignored local file. */
172
+ export function answerFileFor(definition: VariableDefinition): AnswerFile {
173
+ return definition.secret || definition.scope === "local"
174
+ ? ENV_LOCAL_NAME
175
+ : ENV_DEFAULTS_NAME;
176
+ }
177
+
178
+ /**
179
+ * True when `only` names this variable, by bare name, by its
180
+ * `namespace/recipe.name` key, or by its fully qualified reference.
181
+ */
182
+ function isNamed(defined: DefinedVariable, only: string[] | undefined): boolean {
183
+ if (only === undefined) return false;
184
+ const key = definedVariableKey(defined);
185
+ const qualified = variableReferenceKey(defined);
186
+ return only.some(
187
+ (name) => name === defined.definition.name || name === key || name === qualified
188
+ );
189
+ }
190
+
191
+ /** The generated header comment written above a newly stored answer. */
192
+ export function answerHeader(defined: DefinedVariable): string[] {
193
+ const recipe = definingRecipeKey(defined.recipe);
194
+ return [
195
+ `Set by sous for ${recipe}: ${defined.definition.prompt}`,
196
+ defined.definition.description,
197
+ "Edit freely; sous only rewrites the value line.",
198
+ ];
199
+ }
200
+
201
+ /**
202
+ * The message a non-interactive run fails with: one block per unanswered
203
+ * variable, naming every environment variable that would satisfy it, most
204
+ * specific first.
205
+ */
206
+ function buildNonInteractiveError(
207
+ pending: { defined: DefinedVariable; candidates: LadderCandidate[]; current?: string }[]
208
+ ): ConfigError {
209
+ const lines: string[] = [
210
+ pending.length === 1
211
+ ? "One variable still needs an answer, and there is no terminal to ask on."
212
+ : `${pending.length} variables still need answers, and there is no terminal to ask on.`,
213
+ "",
214
+ "Set one of the environment variables listed under each variable, or run",
215
+ "'sous vars ask' from a terminal.",
216
+ ];
217
+
218
+ for (const entry of pending) {
219
+ lines.push("");
220
+ lines.push(
221
+ ` ${entry.defined.definition.name} (${definingRecipeKey(entry.defined.recipe)}): ` +
222
+ entry.defined.definition.prompt
223
+ );
224
+ if (entry.current !== undefined) {
225
+ lines.push(
226
+ ` The value currently in scope does not fit this variable: ${entry.current}`
227
+ );
228
+ }
229
+ for (const candidate of entry.candidates) {
230
+ lines.push(` ${candidate.envName} (${RUNG_LABELS[candidate.rung]})`);
231
+ }
232
+ }
233
+
234
+ return new ConfigError(lines.join("\n"));
235
+ }
236
+
237
+ /** Where one answer will be stored: which env file, and under what name. */
238
+ export interface StoragePlan {
239
+ /** The env file the answer goes into. */
240
+ file: AnswerFile;
241
+ /** The environment variable name the answer is stored under. */
242
+ envName: string;
243
+ }
244
+
245
+ /** One question sous is going to ask, and the value Enter alone would accept. */
246
+ interface PlannedQuestion {
247
+ /** The variable being asked about. */
248
+ defined: DefinedVariable;
249
+ /** The value offered as the default, when there is one. */
250
+ suggestion?: string;
251
+ }
252
+
253
+ /** The questions one recipe contributes, in the order they will be asked. */
254
+ interface QuestionGroup {
255
+ /** The recipe's `namespace/recipe` key. */
256
+ key: string;
257
+ /** Whether the project subscribed to this recipe itself. */
258
+ direct: boolean;
259
+ /** The questions, in declaration order. */
260
+ questions: PlannedQuestion[];
261
+ }
262
+
263
+ /** "1 answer" or "4 answers", so no count is ever printed with the wrong noun. */
264
+ function answerCount(count: number): string {
265
+ return count === 1 ? "1 answer" : `${count} answers`;
266
+ }
267
+
268
+ /**
269
+ * The single sentence printed before any question when the run spans more than
270
+ * one recipe, so the size of what is about to be asked is known up front. A run
271
+ * covering one recipe needs no lead-in; its own opening line says everything.
272
+ *
273
+ * @param groups - Each recipe, how many questions it contributes, and whether the project subscribed to it directly.
274
+ * @returns The lead-in, or undefined when there is only one recipe.
275
+ *
276
+ * @example
277
+ * askLeadIn([
278
+ * { key: "workflow/task-files", count: 4, direct: true },
279
+ * { key: "workflow/sub-agent-delegation", count: 2, direct: false },
280
+ * ]);
281
+ * // -> "workflow/task-files needs 4 answers, and workflow/sub-agent-delegation, which it depends on, needs 2."
282
+ */
283
+ export function askLeadIn(
284
+ groups: Array<{ key: string; count: number; direct: boolean }>
285
+ ): string | undefined {
286
+ if (groups.length < 2) return undefined;
287
+
288
+ const parts = groups.map((group, index) => {
289
+ const relation = index === 0 || group.direct ? "" : ", which it depends on,";
290
+ const needs = index === 0 ? answerCount(group.count) : String(group.count);
291
+ return `${group.key}${relation} needs ${needs}`;
292
+ });
293
+
294
+ const last = parts.pop()!;
295
+ return `${[...parts, `and ${last}`].join(", ")}.`;
296
+ }
297
+
298
+ /**
299
+ * The opening line of one recipe's questions, printed once before the first of
300
+ * them.
301
+ *
302
+ * @param key - The recipe's `namespace/recipe` key.
303
+ * @param count - How many questions the recipe contributes.
304
+ *
305
+ * @example
306
+ * recipeOpeningLine("workflow/task-files", 4);
307
+ * // -> "workflow/task-files needs 4 answers before it can be used."
308
+ */
309
+ export function recipeOpeningLine(key: string, count: number): string {
310
+ return `${key} needs ${answerCount(count)} before it can be used.`;
311
+ }
312
+
313
+ /** Everything the basic view of one question draws. */
314
+ export interface BasicViewInput {
315
+ /** The variable being asked about. */
316
+ defined: DefinedVariable;
317
+ /** This question's place in its recipe's run, counting from one. */
318
+ index: number;
319
+ /** How many questions that recipe contributes. */
320
+ total: number;
321
+ /** Where the answer is going, as it stands right now. */
322
+ plan: StoragePlan;
323
+ /** The value Enter alone would accept, when there is one. */
324
+ suggestion?: string;
325
+ /** The column to wrap the description at. */
326
+ width?: number;
327
+ }
328
+
329
+ /**
330
+ * The basic view of one question: the header, the publisher's description
331
+ * wrapped to the terminal, and the facts a person needs before typing an
332
+ * answer. The facts are the same labeled block the advanced view draws,
333
+ * narrowed to four labels, so the two views always read and line up the same
334
+ * way. The keys are named by the legend the prompt itself draws underneath.
335
+ *
336
+ * @param input - The question, its place in the run, and where the answer is going.
337
+ * @param storagePath - The absolute path of the env file the answer goes into.
338
+ * @returns The lines to print, without indentation.
339
+ */
340
+ export function basicViewLines(input: BasicViewInput, storagePath: string): string[] {
341
+ const { defined, index, total, plan } = input;
342
+ const { definition } = defined;
343
+ const width = input.width ?? wrapColumns();
344
+
345
+ const facts = selectFacts(
346
+ variableFacts({ defined, storagePath, storedAs: plan.envName }),
347
+ BASIC_FACT_LABELS
348
+ );
349
+
350
+ // The keys are named by the legend the question itself draws underneath the
351
+ // input line, in the style the stock prompts use, so nothing is repeated here.
352
+ return [
353
+ color.bold(`Question ${index} of ${total}: ${color.cyan(definition.name)}`),
354
+ "",
355
+ ...wrapText(definition.description, width - 2),
356
+ "",
357
+ ...renderFacts(facts, width),
358
+ ];
359
+ }
360
+
361
+ /**
362
+ * The key legend one question draws under its input line, written the way the
363
+ * stock `@inquirer/select` prompt writes its own: the key, what it does beside
364
+ * it, pairs separated by a bullet. Tab always opens the advanced view; what the
365
+ * other keys do depends on the kind of question, so a question picked from a
366
+ * list names the arrow keys rather than talking about typing a default.
367
+ *
368
+ * @param definition - The variable being asked about.
369
+ * @param suggestion - The value Enter alone would accept, when there is one.
370
+ * @returns The legend line, colored.
371
+ *
372
+ * @example
373
+ * questionHint({ type: "enum", ... });
374
+ * // -> "↑↓ navigate • ⏎ select • ⇥ advanced"
375
+ */
376
+ export function questionHint(
377
+ definition: VariableDefinition,
378
+ suggestion?: string
379
+ ): string {
380
+ const advanced: [string, string] = ["⇥", "advanced"];
381
+
382
+ if (definition.type === "enum") {
383
+ return keysHelpTip([["↑↓", "navigate"], ["⏎", "select"], advanced]);
384
+ }
385
+ if (definition.type === "boolean") {
386
+ return keysHelpTip([["y/n", "answer"], ["⏎", "accept default"], advanced]);
387
+ }
388
+ return suggestion === undefined || suggestion === ""
389
+ ? keysHelpTip([advanced])
390
+ : keysHelpTip([["⏎", "accept default"], advanced]);
391
+ }
392
+
393
+ /**
394
+ * The advanced view of one question: the same header and description, then
395
+ * every fact about the variable, laid out by the renderer `sous vars show`
396
+ * uses, so the vocabulary never drifts between the two.
397
+ *
398
+ * @param input - The question, its place in the run, and where the answer is going.
399
+ * @param storagePath - The absolute path of the env file the answer goes into.
400
+ * @returns The lines to print, without indentation.
401
+ */
402
+ export function advancedViewLines(input: BasicViewInput, storagePath: string): string[] {
403
+ const { defined, index, total } = input;
404
+ const width = input.width ?? wrapColumns();
405
+
406
+ return [
407
+ color.bold(palette.warning("[Advanced Variable Settings]")),
408
+ "",
409
+ color.bold(`Question ${index} of ${total}: ${color.cyan(defined.definition.name)}`),
410
+ "",
411
+ ...wrapText(defined.definition.description, width - 2),
412
+ "",
413
+ ...renderFacts(
414
+ variableFacts({ defined, storagePath, storedAs: input.plan.envName }),
415
+ width
416
+ ),
417
+ ];
418
+ }
419
+
420
+ /**
421
+ * Prints a block of lines indented under the question. Two blank lines open it,
422
+ * so a question header always has room above it and never reads as the tail of
423
+ * whatever was printed before, and one closes it.
424
+ */
425
+ function printBlock(lines: string[]): void {
426
+ blankLines(2);
427
+ for (const line of lines) log(line === "" ? " " : indent(line));
428
+ blankLine();
429
+ }
430
+
431
+ /**
432
+ * The value the name picker returns when the name is to be typed by hand. It is
433
+ * lowercase, so it can never collide with an environment variable name, which
434
+ * is always upper snake case.
435
+ */
436
+ export const ANOTHER_NAME = "another-name";
437
+
438
+ /**
439
+ * Every environment variable name the ladder would look this variable up
440
+ * under, in ladder order, as a pick list. Choosing one of these keeps the
441
+ * answer findable with no mapping record at all.
442
+ *
443
+ * @param defined - The variable and the recipe that published it.
444
+ */
445
+ export function nameChoices(
446
+ defined: DefinedVariable
447
+ ): Array<{ name: string; value: string }> {
448
+ const { namespace, name: recipe } = defined.recipe;
449
+ const variable = defined.definition.name;
450
+
451
+ const rungs: Array<[string, string]> = [
452
+ ["recipe scope", recipeScopedName(namespace, recipe, variable)],
453
+ ["namespace scope", namespaceScopedName(namespace, variable)],
454
+ ["shared scope", sharedName(variable)],
455
+ ["declared name", bareName(defined.definition)],
456
+ ];
457
+
458
+ const seen = new Set<string>();
459
+ const choices: Array<{ name: string; value: string }> = [];
460
+ for (const [label, envName] of rungs) {
461
+ if (seen.has(envName)) continue;
462
+ seen.add(envName);
463
+ choices.push({ name: `${envName} (${label})`, value: envName });
464
+ }
465
+
466
+ choices.push({ name: "Another name, which you type yourself", value: ANOTHER_NAME });
467
+ return choices;
468
+ }
469
+
470
+ /**
471
+ * The two env files an answer can go into, described by what each one means for
472
+ * the team rather than by its name alone.
473
+ */
474
+ export function fileChoices(): Array<{ name: string; value: AnswerFile }> {
475
+ return [
476
+ {
477
+ name: `${ENV_DEFAULTS_NAME} (committed, shared with everyone on the project)`,
478
+ value: ENV_DEFAULTS_NAME,
479
+ },
480
+ {
481
+ name: `${ENV_LOCAL_NAME} (gitignored, this machine only)`,
482
+ value: ENV_LOCAL_NAME,
483
+ },
484
+ ];
485
+ }
486
+
487
+ /**
488
+ * The warning shown when a value the definition marked secret or local is about
489
+ * to be pointed at the committed env file. Sous does not prevent it; it says
490
+ * plainly what happens and asks.
491
+ *
492
+ * @param defined - The variable being answered.
493
+ */
494
+ export function committedFileWarning(defined: DefinedVariable): string {
495
+ const why = defined.definition.secret
496
+ ? "declared this variable a secret"
497
+ : "declared this variable machine-specific";
498
+ return (
499
+ `${definingRecipeKey(defined.recipe)} ${why}, and ${ENV_DEFAULTS_NAME} is committed ` +
500
+ `to git. An answer stored there enters your project's git history, is pushed with ` +
501
+ `every clone, and is visible to everyone who can read the repository.`
502
+ );
503
+ }
504
+
505
+ /**
506
+ * The advanced view and its menu. It runs until the person answering returns to
507
+ * the value question, and hands back where the answer should be stored: the
508
+ * plan it was given when nothing was changed or the changes were discarded, and
509
+ * the edited plan when they were saved.
510
+ *
511
+ * @param input - The question, its place in the run, and the plan as it stands.
512
+ * @param options - Where the env files live, and whether questions may be asked.
513
+ * @returns The storage plan to use for this answer.
514
+ */
515
+ async function runAdvancedView(
516
+ input: BasicViewInput,
517
+ options: AskOptions
518
+ ): Promise<StoragePlan> {
519
+ /**
520
+ * The stock `select` prompt draws its own legend and cannot be given padding
521
+ * underneath, so the padding every sous question keeps is written after it
522
+ * answers instead.
523
+ */
524
+ const padded = async <T>(answer: Promise<T>): Promise<T> => {
525
+ const value = await answer;
526
+ blankLine();
527
+ return value;
528
+ };
529
+
530
+ const original = input.plan;
531
+ let working: StoragePlan = { ...original };
532
+
533
+ for (;;) {
534
+ printBlock(
535
+ advancedViewLines(
536
+ { ...input, plan: working },
537
+ path.join(options.sousDir, working.file)
538
+ )
539
+ );
540
+
541
+ const changed = working.file !== original.file || working.envName !== original.envName;
542
+ const choices = [
543
+ changed
544
+ ? { name: "Save changes and return to value entry", value: "save" }
545
+ : { name: "Return to value entry", value: "return" },
546
+ ...(changed
547
+ ? [{ name: "Discard changes and return to value entry", value: "discard" }]
548
+ : []),
549
+ { name: "Change the storage file", value: "file" },
550
+ { name: "Change the stored variable name", value: "name" },
551
+ ];
552
+
553
+ const action = await padded(
554
+ select({ message: "What would you like to do?", choices })
555
+ );
556
+
557
+ if (action === "return" || action === "save") return working;
558
+ if (action === "discard") return original;
559
+
560
+ if (action === "file") {
561
+ const file = await padded(
562
+ select({
563
+ message: "Which file should this answer be stored in?",
564
+ choices: fileChoices(),
565
+ default: working.file,
566
+ })
567
+ );
568
+
569
+ if (file === ENV_DEFAULTS_NAME && answerFileFor(input.defined.definition) === ENV_LOCAL_NAME) {
570
+ warning(committedFileWarning(input.defined));
571
+ const accepted = await padded(
572
+ confirm({
573
+ message: `Store this answer in ${ENV_DEFAULTS_NAME} anyway?`,
574
+ default: false,
575
+ })
576
+ );
577
+ if (!accepted) continue;
578
+ }
579
+
580
+ working = { ...working, file };
581
+ continue;
582
+ }
583
+
584
+ const picked = await padded(
585
+ select({
586
+ message: "Which environment variable should hold this answer?",
587
+ choices: nameChoices(input.defined),
588
+ default: working.envName,
589
+ })
590
+ );
591
+
592
+ if (picked !== ANOTHER_NAME) {
593
+ working = { ...working, envName: picked };
594
+ continue;
595
+ }
596
+
597
+ const typed = await padded(
598
+ input_({
599
+ message: "What should the environment variable be called?",
600
+ default: working.envName,
601
+ validate: (value: string) =>
602
+ ENV_VAR_NAME_PATTERN.test(value.trim())
603
+ ? true
604
+ : "An environment variable name is upper snake case: a letter or underscore, then letters, digits or underscores.",
605
+ })
606
+ );
607
+ working = { ...working, envName: typed.trim() };
608
+ }
609
+ }
610
+
611
+ /** The words a stored boolean may be written with that all mean yes. */
612
+ const TRUE_WORDS = ["true", "yes", "y", "on", "1"];
613
+
614
+ /**
615
+ * Asks the one question the variable's type calls for: a list to pick from for
616
+ * an enum, a yes or no for a boolean, and typing for everything else. All three
617
+ * prompts answer the same way, so Tab reaches the advanced view from every kind
618
+ * of question and the caller has one return path to handle.
619
+ *
620
+ * @param definition - The variable being asked about.
621
+ * @param suggestion - The value Enter alone would accept, when there is one.
622
+ * @param validate - Checks a typed answer; the other two kinds cannot be wrong.
623
+ * @returns The answer as text, or a request for the advanced view.
624
+ */
625
+ async function askByType(
626
+ definition: VariableDefinition,
627
+ suggestion: string | undefined,
628
+ validate: (value: string) => true | string
629
+ ): Promise<{ kind: "value"; value: string } | { kind: "advanced" }> {
630
+ // The legend goes under the input line, where the stock prompts draw theirs.
631
+ const hint = questionHint(definition, suggestion);
632
+
633
+ if (definition.type === "enum") {
634
+ const enumOptions = definition.validate?.enum ?? [];
635
+ return choicePrompt({
636
+ message: definition.prompt,
637
+ choices: enumOptions.map((option) => ({ name: option, value: option })),
638
+ hint,
639
+ ...(suggestion !== undefined && enumOptions.includes(suggestion)
640
+ ? { default: suggestion }
641
+ : {}),
642
+ });
643
+ }
644
+
645
+ if (definition.type === "boolean") {
646
+ const current = suggestion ?? String(definition.default ?? "");
647
+ const answered = await confirmPrompt({
648
+ message: definition.prompt,
649
+ hint,
650
+ default: TRUE_WORDS.includes(current.toLowerCase()),
651
+ });
652
+ return answered.kind === "advanced"
653
+ ? answered
654
+ : { kind: "value", value: String(answered.value) };
655
+ }
656
+
657
+ return valuePrompt({
658
+ message: definition.prompt,
659
+ validate,
660
+ hint,
661
+ ...(suggestion === undefined ? {} : { default: suggestion }),
662
+ ...(definition.secret ? { mask: true } : {}),
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Asks one question and hands back the answer together with where it should be
668
+ * stored. Tab opens the advanced view; returning from it prints the basic view
669
+ * again, with whatever the advanced view changed, and asks once more.
670
+ *
671
+ * @param question - The variable and the value Enter alone would accept.
672
+ * @param place - This question's place in its recipe's run.
673
+ * @param options - Where the env files live.
674
+ * @returns The answer as text, and the storage plan it should be written with.
675
+ */
676
+ async function askOneQuestion(
677
+ question: PlannedQuestion,
678
+ place: { index: number; total: number },
679
+ options: AskOptions
680
+ ): Promise<{ answer: string; plan: StoragePlan }> {
681
+ const { defined } = question;
682
+ const { definition } = defined;
683
+
684
+ let plan: StoragePlan = {
685
+ file: answerFileFor(definition),
686
+ envName: bareName(definition),
687
+ };
688
+
689
+ const validate = (value: string): true | string => {
690
+ const result = validateAnswer(definition, value, {
691
+ recipe: definingRecipeKey(defined.recipe),
692
+ });
693
+ return result.ok ? true : result.message;
694
+ };
695
+
696
+ for (;;) {
697
+ const view: BasicViewInput = {
698
+ defined,
699
+ index: place.index,
700
+ total: place.total,
701
+ plan,
702
+ ...(question.suggestion === undefined ? {} : { suggestion: question.suggestion }),
703
+ };
704
+
705
+ printBlock(basicViewLines(view, path.join(options.sousDir, plan.file)));
706
+
707
+ // Every kind of question ends the same way: an answer, or a request for the
708
+ // advanced view, which is shown and then hands back here to ask again.
709
+ const result = await askByType(definition, question.suggestion, validate);
710
+
711
+ if (result.kind === "value") return { answer: result.value, plan };
712
+
713
+ plan = await runAdvancedView(view, options);
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Groups the questions by the recipe that published them, keeping the order
719
+ * they arrived in: the subscribed recipe first, then each dependency in closure
720
+ * order.
721
+ *
722
+ * @param questions - Every question that will be asked, in order.
723
+ */
724
+ function groupQuestions(questions: PlannedQuestion[]): QuestionGroup[] {
725
+ const groups = new Map<string, QuestionGroup>();
726
+
727
+ for (const question of questions) {
728
+ const key = definingRecipeKey(question.defined.recipe);
729
+ const chain = question.defined.requiredBy;
730
+ const existing = groups.get(key);
731
+ if (existing === undefined) {
732
+ groups.set(key, {
733
+ key,
734
+ direct: chain === undefined || chain.length <= 1,
735
+ questions: [question],
736
+ });
737
+ } else {
738
+ existing.questions.push(question);
739
+ }
740
+ }
741
+
742
+ return [...groups.values()];
743
+ }
744
+
745
+ /**
746
+ * Walks every definition, keeps the answers that already fit, asks for the ones
747
+ * that do not, and stores what it collects in the project's env files.
748
+ *
749
+ * Everything is decided before the first question is printed, so the run can
750
+ * say how many answers each recipe needs before it asks for any of them. In a
751
+ * subscribe, this runs strictly after the dependency closure has resolved and
752
+ * every new repository has been trusted; a question is never interleaved with a
753
+ * trust decision.
754
+ *
755
+ * @param defined - Every variable definition in play.
756
+ * @param context - The environment layers and mapping records to resolve against.
757
+ * @param options - Where to write, whether questions may be asked, and what to limit to.
758
+ * @returns What was answered, inherited and skipped.
759
+ */
760
+ export async function askForMissing(
761
+ defined: DefinedVariable[],
762
+ context: LadderContext,
763
+ options: AskOptions
764
+ ): Promise<AskReport> {
765
+ const report: AskReport = { answered: [], inherited: [], skipped: [] };
766
+ const pending: { defined: DefinedVariable; candidates: LadderCandidate[]; current?: string }[] =
767
+ [];
768
+ const questions: PlannedQuestion[] = [];
769
+ const deferred: DefinedVariable[] = [];
770
+ /** Names an earlier question in this same run will write. */
771
+ const plannedNames = new Set<string>();
772
+
773
+ for (const entry of defined) {
774
+ if (options.only !== undefined && !isNamed(entry, options.only)) continue;
775
+ if (options.skip?.includes(definedVariableKey(entry)) === true) continue;
776
+
777
+ const { definition } = entry;
778
+ const named = options.only !== undefined;
779
+ const diagnosis = diagnoseVariable(entry, context);
780
+ const existing = diagnosis.resolved;
781
+ const validity =
782
+ existing === undefined
783
+ ? undefined
784
+ : validateAnswer(definition, existing.value, {
785
+ recipe: definingRecipeKey(entry.recipe),
786
+ });
787
+
788
+ if (existing !== undefined && validity?.ok === true && options.reask !== true && !named) {
789
+ report.inherited.push({ defined: entry, resolved: existing });
790
+ continue;
791
+ }
792
+
793
+ // A question earlier in this run is about to answer one of the names this
794
+ // variable resolves under, so it is inherited rather than asked; which name
795
+ // answered is settled once the answer is really stored.
796
+ if (
797
+ existing === undefined &&
798
+ options.reask !== true &&
799
+ !named &&
800
+ diagnosis.candidates.some((candidate) => plannedNames.has(candidate.envName))
801
+ ) {
802
+ deferred.push(entry);
803
+ continue;
804
+ }
805
+
806
+ const invalid = existing !== undefined && validity?.ok === false;
807
+ const mustAsk =
808
+ invalid || (existing === undefined && definition.required) || options.reask === true || named;
809
+
810
+ if (!mustAsk) {
811
+ report.skipped.push({
812
+ defined: entry,
813
+ reason: "no answer yet, and this variable is optional",
814
+ });
815
+ continue;
816
+ }
817
+
818
+ if (!options.interactive) {
819
+ pending.push({
820
+ defined: entry,
821
+ candidates: diagnosis.candidates,
822
+ ...(invalid && validity?.ok === false ? { current: validity.message } : {}),
823
+ });
824
+ continue;
825
+ }
826
+
827
+ const suggestion =
828
+ existing !== undefined && validity?.ok === true
829
+ ? existing.value
830
+ : definition.default !== undefined
831
+ ? String(definition.default)
832
+ : undefined;
833
+
834
+ plannedNames.add(bareName(definition));
835
+ questions.push({ defined: entry, ...(suggestion === undefined ? {} : { suggestion }) });
836
+ }
837
+
838
+ if (pending.length > 0) throw buildNonInteractiveError(pending);
839
+
840
+ const groups = groupQuestions(questions);
841
+ const leadIn = askLeadIn(
842
+ groups.map((group) => ({
843
+ key: group.key,
844
+ count: group.questions.length,
845
+ direct: group.direct,
846
+ }))
847
+ );
848
+ if (leadIn !== undefined) {
849
+ blankLine();
850
+ paragraph(leadIn, { color: palette.warning });
851
+ }
852
+
853
+ for (const group of groups) {
854
+ blankLine();
855
+ paragraph(recipeOpeningLine(group.key, group.questions.length), {
856
+ color: palette.warning,
857
+ });
858
+
859
+ for (const [position, question] of group.questions.entries()) {
860
+ await runQuestion(
861
+ question,
862
+ { index: position + 1, total: group.questions.length },
863
+ context,
864
+ options,
865
+ report
866
+ );
867
+ }
868
+ }
869
+
870
+ // A variable another answer was expected to cover: if it really is answered
871
+ // now, it is inherited; if it is not, it is asked on its own.
872
+ for (const entry of deferred) {
873
+ const diagnosis = diagnoseVariable(entry, context);
874
+ const validity =
875
+ diagnosis.resolved === undefined
876
+ ? undefined
877
+ : validateAnswer(entry.definition, diagnosis.resolved.value, {
878
+ recipe: definingRecipeKey(entry.recipe),
879
+ });
880
+
881
+ if (diagnosis.resolved !== undefined && validity?.ok === true) {
882
+ report.inherited.push({ defined: entry, resolved: diagnosis.resolved });
883
+ continue;
884
+ }
885
+
886
+ blankLine();
887
+ paragraph(recipeOpeningLine(definingRecipeKey(entry.recipe), 1), {
888
+ color: palette.warning,
889
+ });
890
+ await runQuestion(
891
+ { defined: entry },
892
+ { index: 1, total: 1 },
893
+ context,
894
+ options,
895
+ report
896
+ );
897
+ }
898
+
899
+ return report;
900
+ }
901
+
902
+ /**
903
+ * Asks one question, stores the answer, and prints the two lines that say what
904
+ * was stored and where.
905
+ *
906
+ * @param question - The variable and the value Enter alone would accept.
907
+ * @param place - This question's place in its recipe's run.
908
+ * @param context - The environment layers, updated as answers are stored.
909
+ * @param options - Where to write, and whether this is a dry run.
910
+ * @param report - The report to record the outcome in.
911
+ */
912
+ async function runQuestion(
913
+ question: PlannedQuestion,
914
+ place: { index: number; total: number },
915
+ context: LadderContext,
916
+ options: AskOptions,
917
+ report: AskReport
918
+ ): Promise<void> {
919
+ const { answer, plan } = await askOneQuestion(question, place, options);
920
+ const validated = validateAnswer(question.defined.definition, answer, {
921
+ recipe: definingRecipeKey(question.defined.recipe),
922
+ });
923
+
924
+ if (!validated.ok) {
925
+ report.skipped.push({ defined: question.defined, reason: validated.message });
926
+ return;
927
+ }
928
+
929
+ const stored = await storeAnswer(
930
+ question.defined,
931
+ String(validated.value),
932
+ context,
933
+ options,
934
+ plan
935
+ );
936
+ report.answered.push(stored);
937
+
938
+ blankLine();
939
+ showVariables([
940
+ {
941
+ label: "Answer",
942
+ value: `${stored.envName}=${displayValue(
943
+ stored.value,
944
+ question.defined.definition.secret
945
+ )}`,
946
+ },
947
+ {
948
+ label: options.dryRun === true ? "Would be saved to" : "Saved to",
949
+ value: stored.filePath,
950
+ },
951
+ ]);
952
+ }
953
+
954
+ /**
955
+ * Stores one answer, in the env file and under the name the plan asks for (the
956
+ * definition's own choices when there is no plan). A name the resolution ladder
957
+ * would never look at, which is what "another name" in the advanced view
958
+ * produces, gets a mapping record so the answer is still found; and when the
959
+ * name the definition asks for is already bound to something that does not fit,
960
+ * a mapping is offered rather than an overwrite.
961
+ *
962
+ * @param defined - The variable being answered.
963
+ * @param value - The validated answer, in its stored form.
964
+ * @param context - The environment layers, updated so later lookups see the answer.
965
+ * @param options - Where to write, and whether this is a dry run.
966
+ * @param plan - Where the answer should go, when the advanced view settled it.
967
+ */
968
+ async function storeAnswer(
969
+ defined: DefinedVariable,
970
+ value: string,
971
+ context: LadderContext,
972
+ options: AskOptions,
973
+ plan?: StoragePlan
974
+ ): Promise<AnsweredVariable> {
975
+ const { definition } = defined;
976
+ const file = plan?.file ?? answerFileFor(definition);
977
+ const filePath = path.join(options.sousDir, file);
978
+
979
+ let envName = plan?.envName ?? bareName(definition);
980
+ let mapping: AnsweredVariable["mapping"];
981
+
982
+ // A chosen name the ladder never looks at needs a record binding it to this
983
+ // variable, or the answer would be written and then never found again.
984
+ const reachable = variableCandidates(defined, context).some(
985
+ (candidate) => candidate.envName === envName
986
+ );
987
+ if (!reachable) {
988
+ const target = formatMappingTarget(mappingTargetFor(defined));
989
+ const mappingPath =
990
+ options.dryRun === true
991
+ ? path.join(options.confDir, VAR_MAPPINGS_LAYER_FILENAME)
992
+ : writeMappingRecord(options.confDir, envName, target);
993
+ mapping = { envName, target, filePath: mappingPath };
994
+ context.mappings = { ...context.mappings, [envName]: target };
995
+ }
996
+
997
+ const occupant = lookupEnvName(envName, context);
998
+ const conflicts =
999
+ mapping === undefined &&
1000
+ occupant !== undefined &&
1001
+ validateAnswer(definition, occupant.value, { recipe: definingRecipeKey(defined.recipe) }).ok ===
1002
+ false;
1003
+
1004
+ if (conflicts) {
1005
+ const scopedName = recipeScopedName(
1006
+ defined.recipe.namespace,
1007
+ defined.recipe.name,
1008
+ definition.name
1009
+ );
1010
+ const target = formatMappingTarget(mappingTargetFor(defined));
1011
+
1012
+ const useMapping = options.interactive
1013
+ ? (await select({
1014
+ message:
1015
+ `${envName} already holds a value that does not fit ${definition.name}. ` +
1016
+ "Where should this answer go?",
1017
+ choices: [
1018
+ { name: `${scopedName}, with a mapping record (recommended)`, value: true },
1019
+ { name: `${envName}, replacing what is there`, value: false },
1020
+ ],
1021
+ })) === true
1022
+ : true;
1023
+
1024
+ if (useMapping) {
1025
+ envName = scopedName;
1026
+ const mappingPath = options.dryRun === true
1027
+ ? path.join(options.confDir, VAR_MAPPINGS_LAYER_FILENAME)
1028
+ : writeMappingRecord(options.confDir, scopedName, target);
1029
+ mapping = { envName: scopedName, target, filePath: mappingPath };
1030
+ context.mappings = { ...context.mappings, [scopedName]: target };
1031
+ }
1032
+ }
1033
+
1034
+ const outcome =
1035
+ options.dryRun === true ? "not written" : updateEnvFile(filePath, envName, value, {
1036
+ header: answerHeader(defined),
1037
+ });
1038
+
1039
+ if (options.dryRun !== true) {
1040
+ recordAnswerInContext(context, file, envName, value);
1041
+ }
1042
+
1043
+ return { defined, envName, file, filePath, value, outcome, ...(mapping ? { mapping } : {}) };
1044
+ }
1045
+
1046
+ /**
1047
+ * Renders an ask report as the lines a command prints: what was answered, what
1048
+ * was inherited (always shown, with its scope and source, so an answer that
1049
+ * came from somewhere else is never a surprise), and what was left alone.
1050
+ *
1051
+ * @param report - The report to render.
1052
+ * @param dryRun - When true, the wording says what WOULD have been written.
1053
+ */
1054
+ export function formatAskReport(report: AskReport, dryRun = false): string[] {
1055
+ const lines: string[] = [];
1056
+ const width = wrapColumns() - 2;
1057
+
1058
+ /** The variable name every entry in this report is labeled with. */
1059
+ const labelWidth = Math.max(
1060
+ 0,
1061
+ ...[...report.inherited, ...report.answered, ...report.skipped].map(
1062
+ (entry) => entry.defined.definition.name.length
1063
+ )
1064
+ );
1065
+
1066
+ /** One entry of the report, laid out by the shared key and value renderer. */
1067
+ const entryLines = (
1068
+ label: string,
1069
+ value: string,
1070
+ detail?: string
1071
+ ): string[] =>
1072
+ formatVariable(
1073
+ { label, value, ...(detail === undefined ? {} : { detail }) },
1074
+ { labelWidth, width }
1075
+ );
1076
+
1077
+ /** A note about the entry above it, hanging under the value column. */
1078
+ const noteLines = (text: string): string[] =>
1079
+ wrapText(text, width - VARIABLE_INDENT - labelWidth - 2).map(
1080
+ (line) => `${" ".repeat(VARIABLE_INDENT + labelWidth + 2)}${palette.muted(line)}`
1081
+ );
1082
+
1083
+ if (report.inherited.length > 0) {
1084
+ lines.push("Answers already in scope:");
1085
+ for (const entry of report.inherited) {
1086
+ const shown = displayValue(entry.resolved.value, entry.defined.definition.secret);
1087
+ lines.push(
1088
+ ...entryLines(
1089
+ entry.defined.definition.name,
1090
+ shown,
1091
+ `from ${describeSource(entry.resolved.source)}`
1092
+ )
1093
+ );
1094
+ }
1095
+ lines.push("");
1096
+ }
1097
+
1098
+ if (report.answered.length > 0) {
1099
+ lines.push(dryRun ? "Answers that would be stored:" : "Answers stored:");
1100
+ for (const entry of report.answered) {
1101
+ const shown = displayValue(entry.value, entry.defined.definition.secret);
1102
+ lines.push(
1103
+ ...entryLines(
1104
+ entry.defined.definition.name,
1105
+ shown,
1106
+ `${entry.envName} in ${entry.file}`
1107
+ )
1108
+ );
1109
+ if (entry.mapping !== undefined) {
1110
+ lines.push(...noteLines(`mapped to ${entry.mapping.target} in the conf.d layer`));
1111
+ }
1112
+ if (entry.replaced !== undefined) {
1113
+ const previous = displayValue(entry.replaced, entry.defined.definition.secret);
1114
+ lines.push(
1115
+ ...noteLines(
1116
+ dryRun
1117
+ ? `replacing the answer already there: ${previous}`
1118
+ : `replaced the answer already there: ${previous}`
1119
+ )
1120
+ );
1121
+ }
1122
+ if (entry.shadowedBy !== undefined) {
1123
+ lines.push(
1124
+ ...noteLines(
1125
+ `${entry.shadowedBy} is set in your shell environment and answers this ` +
1126
+ `variable first; unset it for the stored answer to take effect`
1127
+ )
1128
+ );
1129
+ }
1130
+ }
1131
+ lines.push("");
1132
+ }
1133
+
1134
+ if (report.skipped.length > 0) {
1135
+ lines.push("Left unanswered:");
1136
+ for (const entry of report.skipped) {
1137
+ lines.push(...entryLines(entry.defined.definition.name, entry.reason));
1138
+ }
1139
+ lines.push("");
1140
+ }
1141
+
1142
+ if (lines.length === 0) {
1143
+ lines.push(
1144
+ ...wrapText(
1145
+ "Every variable in play already has an answer that fits its definition.",
1146
+ width
1147
+ )
1148
+ );
1149
+ }
1150
+
1151
+ return lines;
1152
+ }