@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,395 @@
1
+ /**
2
+ * Answering a recipe's questions before it asks them.
3
+ *
4
+ * A person subscribing at a terminal answers each question as it comes. A
5
+ * script, a continuous integration job or an agent has no terminal and knows
6
+ * every answer already, so it supplies them up front: `--answer name=value`,
7
+ * repeated, or a file of the same pairs. Both `sous subscription add` and
8
+ * `sous vars ask` take them, and both hand them here, so the rules are written
9
+ * once.
10
+ *
11
+ * The rules are deliberately unforgiving, because a supplied answer is never
12
+ * seen by a human before it is stored:
13
+ *
14
+ * - Every answer is validated against its definition BEFORE anything is
15
+ * written, so a run either stores all of them or none of them.
16
+ * - A name no recipe declares fails the run and lists every variable the
17
+ * closure does declare, so a typo can never become a stored value under a
18
+ * name nothing reads.
19
+ * - An answer for a variable that already has one replaces it, where it
20
+ * already lives, and the report says so.
21
+ */
22
+
23
+ import path from "node:path";
24
+ import { ConfigError } from "../errors.js";
25
+ import { updateEnvFile } from "../env-file.js";
26
+ import { loadManifestFile } from "../repos/load-manifest.js";
27
+ import {
28
+ answerFileFor,
29
+ answerHeader,
30
+ type AnsweredVariable,
31
+ type AskOptions,
32
+ type StoragePlan,
33
+ } from "./ask.js";
34
+ import {
35
+ definedVariableKey,
36
+ definingRecipeKey,
37
+ type DefinedVariable,
38
+ } from "./definition-source.js";
39
+ import { displayValue } from "./display.js";
40
+ import {
41
+ recordAnswerInContext,
42
+ resolveVariable,
43
+ type LadderContext,
44
+ } from "./ladder.js";
45
+ import { bareName } from "./names.js";
46
+ import { validateAnswer } from "./validate.js";
47
+
48
+ /** How an answer supplied on the command line is written. */
49
+ export const ANSWER_FLAG_FORM = "--answer <name>=<value>";
50
+
51
+ /** The sentence appended to every error about a supplied answer. */
52
+ export const ANSWER_NAME_HELP =
53
+ "A name is spelled exactly as the recipe declares it, in camelCase; the full " +
54
+ "'namespace/recipe.name' key works too.";
55
+
56
+ /** One answer supplied ahead of the questions. */
57
+ export interface ProvidedAnswer {
58
+ /** The variable's declared name, or its `namespace/recipe.name` key. */
59
+ name: string;
60
+ /** The answer, exactly as it was written. */
61
+ value: string;
62
+ /** Where it came from, named in every error: the flag, or the file's path. */
63
+ from: string;
64
+ }
65
+
66
+ /**
67
+ * Splits one `name=value` pair. The split is on the FIRST `=` only, so a value
68
+ * may contain as many more as it likes (a connection string, a query, a base64
69
+ * blob).
70
+ *
71
+ * @param entry - The pair as written on the command line.
72
+ * @param from - Where it came from, named in the error.
73
+ *
74
+ * @example
75
+ * parseAnswerPair("apiUrl=https://x.test/?a=1&b=2");
76
+ * // -> { name: "apiUrl", value: "https://x.test/?a=1&b=2", from: "--answer" }
77
+ */
78
+ export function parseAnswerPair(entry: string, from = ANSWER_FLAG_FORM): ProvidedAnswer {
79
+ const at = entry.indexOf("=");
80
+ const name = at === -1 ? "" : entry.slice(0, at).trim();
81
+
82
+ if (at === -1 || name.length === 0) {
83
+ throw new ConfigError(
84
+ `'${entry}' is not an answer sous can read.\n` +
85
+ ` Write one as '${ANSWER_FLAG_FORM}', for example ` +
86
+ `'--answer apiUrl=https://api.example.com'.\n` +
87
+ ` Everything after the first '=' is the answer, so a value may contain more of them.`
88
+ );
89
+ }
90
+
91
+ return { name, value: entry.slice(at + 1), from };
92
+ }
93
+
94
+ /**
95
+ * Reads a file of answers: a YAML or JSON map of the same `name: value` pairs.
96
+ * It goes through the manifest loader, so the JSON dialect is the permissive
97
+ * one and a file can carry comments explaining its answers.
98
+ *
99
+ * @param filePath - Absolute path to the answers file.
100
+ */
101
+ export function loadAnswersFile(filePath: string): ProvidedAnswer[] {
102
+ const raw = loadManifestFile(filePath);
103
+
104
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
105
+ throw new ConfigError(
106
+ `The answers file at ${filePath} is not a map of answers.\n` +
107
+ ` It holds one entry per variable, written as 'name: value'.`
108
+ );
109
+ }
110
+
111
+ const answers: ProvidedAnswer[] = [];
112
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
113
+ if (typeof value === "object" && value !== null) {
114
+ throw new ConfigError(
115
+ `The answer for '${name}' in ${filePath} is a list or a map, and an answer is a ` +
116
+ `single value.\n` +
117
+ ` Answers are stored in environment files, which hold text; write the answer ` +
118
+ `as a string, a number or a boolean.`
119
+ );
120
+ }
121
+ if (value === null || value === undefined) {
122
+ throw new ConfigError(
123
+ `The answer for '${name}' in ${filePath} is empty.\n` +
124
+ ` Write the value the variable should be given, or leave the entry out ` +
125
+ `entirely so sous asks for it.`
126
+ );
127
+ }
128
+ answers.push({ name: name.trim(), value: String(value), from: filePath });
129
+ }
130
+
131
+ return answers;
132
+ }
133
+
134
+ /** The two ways a caller supplies answers ahead of the questions. */
135
+ export interface ProvidedAnswerInputs {
136
+ /** Every `--answer name=value` pair, in the order they were written. */
137
+ answer?: string[];
138
+ /** A file of `name: value` pairs, absolute or relative to `cwd`. */
139
+ answersFile?: string;
140
+ /** Where a relative answers file is resolved from. Defaults to the real cwd. */
141
+ cwd?: string;
142
+ }
143
+
144
+ /**
145
+ * Collects every supplied answer into one list, with the file read first and
146
+ * the flags laid over it, so `--answer` wins over the file for the same name.
147
+ * A name given twice in the same place keeps the last one written, which is how
148
+ * every other repeated flag behaves.
149
+ *
150
+ * @param inputs - The flag values, and where a relative file path resolves from.
151
+ */
152
+ export function collectProvidedAnswers(inputs: ProvidedAnswerInputs): ProvidedAnswer[] {
153
+ const collected = new Map<string, ProvidedAnswer>();
154
+
155
+ if (inputs.answersFile !== undefined) {
156
+ const filePath = path.resolve(inputs.cwd ?? process.cwd(), inputs.answersFile);
157
+ for (const answer of loadAnswersFile(filePath)) collected.set(answer.name, answer);
158
+ }
159
+
160
+ for (const entry of inputs.answer ?? []) {
161
+ const answer = parseAnswerPair(entry);
162
+ collected.set(answer.name, answer);
163
+ }
164
+
165
+ return [...collected.values()];
166
+ }
167
+
168
+ /** True when a supplied name names this variable, by declared name or by key. */
169
+ function matchesName(defined: DefinedVariable, name: string): boolean {
170
+ return defined.definition.name === name || definedVariableKey(defined) === name;
171
+ }
172
+
173
+ /**
174
+ * The error a name nothing declares fails with: every variable the closure DOES
175
+ * declare, grouped by the recipe that published it, so the caller can see the
176
+ * spelling they meant rather than guessing at it again.
177
+ *
178
+ * @param answer - The supplied answer whose name matched nothing.
179
+ * @param defined - Every variable definition in play.
180
+ */
181
+ export function unknownAnswerError(
182
+ answer: ProvidedAnswer,
183
+ defined: DefinedVariable[]
184
+ ): ConfigError {
185
+ const lines = [
186
+ `Nothing in this project defines a variable called '${answer.name}', so the answer ` +
187
+ `given with ${answer.from} cannot be stored.`,
188
+ ];
189
+
190
+ if (defined.length === 0) {
191
+ lines.push(
192
+ " No recipe this project subscribes to defines any variables yet, so there is " +
193
+ "nothing to answer."
194
+ );
195
+ return new ConfigError(lines.join("\n"));
196
+ }
197
+
198
+ const groups = new Map<string, string[]>();
199
+ for (const entry of defined) {
200
+ const key = definingRecipeKey(entry.recipe);
201
+ const names = groups.get(key) ?? [];
202
+ if (!names.includes(entry.definition.name)) names.push(entry.definition.name);
203
+ groups.set(key, names);
204
+ }
205
+
206
+ lines.push("", " These are the variables in play, and the recipes that declare them:", "");
207
+ for (const [key, names] of groups) {
208
+ lines.push(` ${key}`);
209
+ for (const name of names) lines.push(` ${name}`);
210
+ }
211
+ lines.push("", ` ${ANSWER_NAME_HELP}`);
212
+
213
+ return new ConfigError(lines.join("\n"));
214
+ }
215
+
216
+ /**
217
+ * The error a supplied answer that does not fit its definition fails with: the
218
+ * variable, the constraint it violated, and the publisher's own example of a
219
+ * real answer. A secret's value is never echoed back.
220
+ *
221
+ * @param answer - The supplied answer.
222
+ * @param defined - The definition it was checked against.
223
+ * @param message - The plain-language reason from `validateAnswer`.
224
+ */
225
+ export function invalidAnswerError(
226
+ answer: ProvidedAnswer,
227
+ defined: DefinedVariable,
228
+ message: string
229
+ ): ConfigError {
230
+ const { definition } = defined;
231
+ return new ConfigError(
232
+ `The answer given for '${answer.name}' does not fit the definition ` +
233
+ `${definingRecipeKey(defined.recipe)} publishes.\n` +
234
+ ` ${message}\n` +
235
+ ` For example: ${String(definition.example)}\n` +
236
+ ` The answer given with ${answer.from} was: ` +
237
+ `${displayValue(answer.value, definition.secret)}\n` +
238
+ ` Nothing was written; fix the answer and run the command again.`
239
+ );
240
+ }
241
+
242
+ /** What one run of `applyProvidedAnswers` did. */
243
+ export interface AppliedAnswers {
244
+ /** Every answer that was stored, in the shape the ask report prints. */
245
+ stored: AnsweredVariable[];
246
+ /**
247
+ * The keys of every variable these answers settled. `askForMissing` skips
248
+ * them, so a supplied answer is never asked about as well as stored.
249
+ */
250
+ keys: string[];
251
+ }
252
+
253
+ /**
254
+ * Where a supplied answer goes. A variable with no answer yet is stored exactly
255
+ * where the interactive flow would store it: under its declared name, in the
256
+ * file its scope asks for. A variable that ALREADY has an answer in one of the
257
+ * project's env files is overwritten where that answer lives, because storing
258
+ * the new value somewhere less specific would leave the old one winning and the
259
+ * caller asking why nothing changed.
260
+ *
261
+ * @param defined - The variable being answered.
262
+ * @param context - The environment layers, as they stand.
263
+ */
264
+ export function preAnswerPlan(
265
+ defined: DefinedVariable,
266
+ context: LadderContext
267
+ ): { plan: StoragePlan; replaced?: string; shadowedBy?: string } {
268
+ const fallback: StoragePlan = {
269
+ file: answerFileFor(defined.definition),
270
+ envName: bareName(defined.definition),
271
+ };
272
+ const existing = resolveVariable(defined, context);
273
+
274
+ if (existing === undefined) return { plan: fallback };
275
+
276
+ // A value the shell environment supplies is not sous's to rewrite, and it
277
+ // outranks both env files for as long as it is set; the answer is stored
278
+ // where it belongs and the report says what is covering it.
279
+ if (existing.source.file === "shell") {
280
+ return { plan: fallback, replaced: existing.value, shadowedBy: existing.source.envName };
281
+ }
282
+
283
+ return {
284
+ plan: { file: existing.source.file, envName: existing.source.envName },
285
+ replaced: existing.value,
286
+ };
287
+ }
288
+
289
+ /** One supplied answer, matched to a definition and coerced to its stored form. */
290
+ export interface ApplicableAnswer {
291
+ /** The answer as it was supplied. */
292
+ answer: ProvidedAnswer;
293
+ /** The definition it answers. */
294
+ target: DefinedVariable;
295
+ /** The validated answer, in the form an env file holds. */
296
+ value: string;
297
+ }
298
+
299
+ /**
300
+ * Matches every supplied answer to the definitions it answers and checks it
301
+ * against each of them, without writing anything.
302
+ *
303
+ * A command calls this early, before it installs or writes anything at all, so
304
+ * a bad answer fails the run cleanly rather than halfway through it. One name
305
+ * can answer more than one definition, because two recipes may declare the same
306
+ * variable; every one of them has to accept the value.
307
+ *
308
+ * @param defined - Every variable definition in play.
309
+ * @param provided - The answers supplied ahead of the questions.
310
+ * @returns One entry per definition each answer applies to.
311
+ */
312
+ export function validateProvidedAnswers(
313
+ defined: DefinedVariable[],
314
+ provided: ProvidedAnswer[]
315
+ ): ApplicableAnswer[] {
316
+ const applicable: ApplicableAnswer[] = [];
317
+
318
+ for (const answer of provided) {
319
+ const targets = defined.filter((entry) => matchesName(entry, answer.name));
320
+ if (targets.length === 0) throw unknownAnswerError(answer, defined);
321
+
322
+ for (const target of targets) {
323
+ const validated = validateAnswer(target.definition, answer.value, {
324
+ recipe: definingRecipeKey(target.recipe),
325
+ });
326
+ if (!validated.ok) throw invalidAnswerError(answer, target, validated.message);
327
+ applicable.push({ answer, target, value: String(validated.value) });
328
+ }
329
+ }
330
+
331
+ return applicable;
332
+ }
333
+
334
+ /**
335
+ * Validates every supplied answer, then stores them all.
336
+ *
337
+ * Validation happens for all of them first, so a run with one bad answer in it
338
+ * writes nothing at all rather than half of what it was given. A dry run
339
+ * validates and reports without writing, but still records the answers in the
340
+ * context it was handed, so the rest of the run plans as though they were
341
+ * stored.
342
+ *
343
+ * @param defined - Every variable definition in play.
344
+ * @param provided - The answers supplied ahead of the questions.
345
+ * @param context - The environment layers, updated as answers are stored.
346
+ * @param options - Where to write, and whether this is a dry run.
347
+ * @returns What was stored, and which variables no longer need asking.
348
+ */
349
+ export function applyProvidedAnswers(
350
+ defined: DefinedVariable[],
351
+ provided: ProvidedAnswer[],
352
+ context: LadderContext,
353
+ options: AskOptions
354
+ ): AppliedAnswers {
355
+ const applicable = validateProvidedAnswers(defined, provided);
356
+
357
+ const stored: AnsweredVariable[] = [];
358
+ const keys: string[] = [];
359
+ /** Env entries this run has already written, so one file line is written once. */
360
+ const written = new Set<string>();
361
+
362
+ for (const entry of applicable) {
363
+ keys.push(definedVariableKey(entry.target));
364
+
365
+ const { plan, replaced, shadowedBy } = preAnswerPlan(entry.target, context);
366
+ const filePath = path.join(options.sousDir, plan.file);
367
+ const slot = `${plan.file}:${plan.envName}`;
368
+ if (written.has(slot)) continue;
369
+ written.add(slot);
370
+
371
+ const outcome =
372
+ options.dryRun === true
373
+ ? ("not written" as const)
374
+ : updateEnvFile(filePath, plan.envName, entry.value, {
375
+ header: answerHeader(entry.target),
376
+ });
377
+
378
+ // Recorded even on a dry run: everything after this point should plan as
379
+ // though the answer were already stored.
380
+ recordAnswerInContext(context, plan.file, plan.envName, entry.value);
381
+
382
+ stored.push({
383
+ defined: entry.target,
384
+ envName: plan.envName,
385
+ file: plan.file,
386
+ filePath,
387
+ value: entry.value,
388
+ outcome,
389
+ ...(replaced === undefined || replaced === entry.value ? {} : { replaced }),
390
+ ...(shadowedBy === undefined ? {} : { shadowedBy }),
391
+ });
392
+ }
393
+
394
+ return { stored, keys };
395
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The questions a subscription is going to ask, written out before it asks any
3
+ * of them.
4
+ *
5
+ * A dry run is how a caller with no terminal finds out what a subscription
6
+ * wants: every variable the closure declares, what each one is for, where its
7
+ * answer will be stored, and whether something already answers it. With that in
8
+ * hand the whole subscription can be done in one more command, every answer
9
+ * supplied with `--answer`.
10
+ *
11
+ * The facts are laid out by the renderer `sous vars show` uses, so the labels
12
+ * and the vocabulary are the same wherever a variable is described.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { color } from "@oclif/color";
17
+ import { palette, wrapColumns, wrapText } from "../../utils/formatting.js";
18
+ import { answerFileFor, type AnswerFile } from "./ask.js";
19
+ import { definingRecipeKey, type DefinedVariable } from "./definition-source.js";
20
+ import { renderFacts, type LabeledFact } from "./display.js";
21
+ import { describeSource, resolveVariable, type LadderContext } from "./ladder.js";
22
+ import { bareName } from "./names.js";
23
+ import { validateAnswer } from "./validate.js";
24
+
25
+ /** One question the closure would ask, and what would happen to its answer. */
26
+ export interface PlannedVariable {
27
+ /** The variable and the recipe that published it. */
28
+ defined: DefinedVariable;
29
+ /** The environment variable the answer would be stored under. */
30
+ storedAs: string;
31
+ /** The env file it would be stored in. */
32
+ file: AnswerFile;
33
+ /** That file's absolute path. */
34
+ filePath: string;
35
+ /** True when something already answers this variable, and the answer fits. */
36
+ answered: boolean;
37
+ /** Where the existing answer came from, in plain language. */
38
+ answeredFrom?: string;
39
+ }
40
+
41
+ /** What `planQuestions` needs beyond the definitions themselves. */
42
+ export interface QuestionPlanOptions {
43
+ /** The project's `.sous/` directory, which holds both env files. */
44
+ sousDir: string;
45
+ }
46
+
47
+ /**
48
+ * The first sentence of a description, which is what a plan shows; the rest is
49
+ * in `sous vars show <name>`.
50
+ *
51
+ * @param text - The publisher's description.
52
+ */
53
+ export function firstSentence(text: string): string {
54
+ const match = /^.*?[.!?](?=\s|$)/s.exec(text.trim());
55
+ return (match?.[0] ?? text.trim()).replace(/\s+/g, " ");
56
+ }
57
+
58
+ /**
59
+ * Works out, for every variable in play, where its answer would go and whether
60
+ * anything answers it already.
61
+ *
62
+ * @param defined - Every variable definition the closure declares.
63
+ * @param context - The environment layers and mapping records to resolve against.
64
+ * @param options - Where the project's env files live.
65
+ */
66
+ export function planQuestions(
67
+ defined: DefinedVariable[],
68
+ context: LadderContext,
69
+ options: QuestionPlanOptions
70
+ ): PlannedVariable[] {
71
+ return defined.map((entry) => {
72
+ const file = answerFileFor(entry.definition);
73
+ const existing = resolveVariable(entry, context);
74
+ const answered =
75
+ existing !== undefined &&
76
+ validateAnswer(entry.definition, existing.value, {
77
+ recipe: definingRecipeKey(entry.recipe),
78
+ }).ok;
79
+
80
+ return {
81
+ defined: entry,
82
+ storedAs: answered ? existing!.source.envName : bareName(entry.definition),
83
+ file,
84
+ filePath: path.join(options.sousDir, file),
85
+ answered,
86
+ ...(answered ? { answeredFrom: describeSource(existing!.source) } : {}),
87
+ };
88
+ });
89
+ }
90
+
91
+ /** "1 question" or "3 questions", so no count is printed with the wrong noun. */
92
+ function questionCount(count: number): string {
93
+ return count === 1 ? "1 question" : `${count} questions`;
94
+ }
95
+
96
+ /** The labeled facts one planned question shows. */
97
+ export function plannedVariableFacts(planned: PlannedVariable): LabeledFact[] {
98
+ const { definition } = planned.defined;
99
+
100
+ return [
101
+ { label: "about", lines: [firstSentence(definition.description)] },
102
+ { label: "example", lines: [String(definition.example)] },
103
+ { label: "stored-as", lines: [planned.storedAs] },
104
+ { label: "storage-path", lines: [planned.filePath] },
105
+ {
106
+ label: "answered",
107
+ lines: [
108
+ planned.answered
109
+ ? `yes, from ${planned.answeredFrom}`
110
+ : definition.required
111
+ ? "no, and this recipe requires an answer"
112
+ : "no, and an answer is optional",
113
+ ],
114
+ },
115
+ { label: "answer-with", lines: [`--answer ${definition.name}=<value>`] },
116
+ ];
117
+ }
118
+
119
+ /** What `formatQuestionPlan` needs beyond the questions themselves. */
120
+ export interface QuestionPlanFormatOptions {
121
+ /** The column to wrap descriptions at. */
122
+ width?: number;
123
+ /**
124
+ * Recipes in the closure whose files are not on this machine, so their
125
+ * manifests could not be read. They are named in the plan rather than
126
+ * silently left out of it.
127
+ */
128
+ unreadable?: string[];
129
+ }
130
+
131
+ /**
132
+ * The one line that names the recipes a dry run could not read. A dry run
133
+ * downloads nothing, so a recipe this machine does not hold yet has no manifest
134
+ * to read; saying so by name is more useful than leaving it out of the plan.
135
+ *
136
+ * @param unreadable - The recipe keys whose manifests could not be read.
137
+ */
138
+ function unreadableLine(unreadable: string[]): string {
139
+ return (
140
+ `Not on this machine yet, so their questions cannot be listed: ` +
141
+ `${unreadable.join(", ")}. A dry run downloads nothing; run this command ` +
142
+ `again without '--dry-run' to install them and be asked.`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The whole plan as lines to print: one block per recipe, one labeled fact
148
+ * sheet per variable, a line naming any recipe that could not be read, and a
149
+ * closing sentence naming how to answer them all ahead of time.
150
+ *
151
+ * @param planned - Every question the closure would ask.
152
+ * @param options - The wrap column, and any recipes that could not be read.
153
+ * @returns The lines to print, without indentation.
154
+ */
155
+ export function formatQuestionPlan(
156
+ planned: PlannedVariable[],
157
+ options: QuestionPlanFormatOptions = {}
158
+ ): string[] {
159
+ const unreadable = options.unreadable ?? [];
160
+
161
+ const columns = options.width ?? wrapColumns();
162
+ /** Wraps one sentence to the width the caller's indentation leaves for it. */
163
+ const sentence = (text: string, paint = (line: string): string => line): string[] =>
164
+ wrapText(text, columns - 2).map(paint);
165
+
166
+ if (planned.length === 0) {
167
+ return unreadable.length === 0
168
+ ? sentence("None of these recipes ask any questions, so nothing needs answering.")
169
+ : sentence(unreadableLine(unreadable), palette.note);
170
+ }
171
+
172
+ const unanswered = planned.filter((entry) => !entry.answered).length;
173
+ // When part of the closure could not be read, the count below describes only
174
+ // the part that could, and the sentence says so rather than overstating it.
175
+ const subject =
176
+ unreadable.length === 0 ? "These recipes ask" : "The recipes sous could read ask";
177
+
178
+ const lines: string[] = sentence(
179
+ unanswered === 0
180
+ ? `${subject} ${questionCount(planned.length)}, and everything they ask ` +
181
+ `is already answered.`
182
+ : `${subject} ${questionCount(planned.length)}, ` +
183
+ `${unanswered} of which nothing answers yet.`
184
+ );
185
+
186
+ const groups = new Map<string, PlannedVariable[]>();
187
+ for (const entry of planned) {
188
+ const key = definingRecipeKey(entry.defined.recipe);
189
+ groups.set(key, [...(groups.get(key) ?? []), entry]);
190
+ }
191
+
192
+ for (const [key, entries] of groups) {
193
+ lines.push("", `${key} asks ${questionCount(entries.length)}:`);
194
+ for (const entry of entries) {
195
+ lines.push("", ` ${color.cyan(entry.defined.definition.name)}`);
196
+ // The facts block indents itself, so the plan adds nothing on top of it.
197
+ lines.push(...renderFacts(plannedVariableFacts(entry), columns - 2));
198
+ }
199
+ }
200
+
201
+ if (unreadable.length > 0) {
202
+ lines.push("", ...sentence(unreadableLine(unreadable), palette.note));
203
+ }
204
+
205
+ if (unanswered > 0) {
206
+ lines.push(
207
+ "",
208
+ ...sentence(
209
+ "Answer them all ahead of time by running this command again without " +
210
+ "'--dry-run', with one '--answer <name>=<value>' for each, or with " +
211
+ "'--answers-file <path>'.",
212
+ palette.note
213
+ )
214
+ );
215
+ }
216
+
217
+ return lines;
218
+ }