@sous-io/sous 0.1.1 → 0.2.0

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 +408 -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 +619 -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 +413 -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,282 @@
1
+ /**
2
+ * The resolution ladder: how sous decides whether a variable already has an
3
+ * answer, and which environment variable supplied it.
4
+ *
5
+ * Five rungs, most specific first:
6
+ *
7
+ * 1. mapping a record binding an arbitrary name to this exact variable
8
+ * 2. recipe SOUS_VAR_<NAMESPACE>_<RECIPE>_<VARIABLE>
9
+ * 3. namespace SOUS_VAR_<NAMESPACE>_<VARIABLE>
10
+ * 4. shared SOUS_VAR_<VARIABLE>
11
+ * 5. bare the definition's own `env` name, or the shared form
12
+ *
13
+ * Within a rung the real shell environment wins, then `.sous/.env.local`, then
14
+ * `.sous/.env`; the same order the env file loader uses, so what the ladder
15
+ * reports is what a build actually sees.
16
+ *
17
+ * The three sources are read separately rather than off `process.env`, because
18
+ * by the time a command runs the env files have already been injected into the
19
+ * process environment and the distinction would be lost.
20
+ */
21
+
22
+ import path from "node:path";
23
+ import { ENV_DEFAULTS_NAME, ENV_LOCAL_NAME } from "../config-discovery.js";
24
+ import { readEnvFileMap } from "../env-local.js";
25
+ import type { Settings } from "../settings.js";
26
+ import type { DefinedVariable } from "./definition-source.js";
27
+ import { mappedNamesFor } from "./mappings.js";
28
+ import {
29
+ bareName,
30
+ namespaceScopedName,
31
+ recipeScopedName,
32
+ sharedName,
33
+ } from "./names.js";
34
+
35
+ /** The rungs of the ladder, most specific first. */
36
+ export const LADDER_RUNGS = ["mapping", "recipe", "namespace", "shared", "bare"] as const;
37
+
38
+ /** One rung of the ladder. */
39
+ export type LadderRung = (typeof LADDER_RUNGS)[number];
40
+
41
+ /** Plain-language names for each rung, used in output. */
42
+ export const RUNG_LABELS: Record<LadderRung, string> = {
43
+ mapping: "mapping record",
44
+ recipe: "recipe scope",
45
+ namespace: "namespace scope",
46
+ shared: "shared scope",
47
+ bare: "declared name",
48
+ };
49
+
50
+ /** Where a value was found: the real environment, or one of the two env files. */
51
+ export type EnvSourceFile = "shell" | typeof ENV_LOCAL_NAME | typeof ENV_DEFAULTS_NAME;
52
+
53
+ /** Plain-language names for each place a value can come from. */
54
+ export const SOURCE_LABELS: Record<string, string> = {
55
+ shell: "the shell environment",
56
+ [ENV_LOCAL_NAME]: `the ${ENV_LOCAL_NAME} file`,
57
+ [ENV_DEFAULTS_NAME]: `the ${ENV_DEFAULTS_NAME} file`,
58
+ };
59
+
60
+ /** One environment variable name the ladder will look up, and why. */
61
+ export interface LadderCandidate {
62
+ /** Which rung generated (or recorded) the name. */
63
+ rung: LadderRung;
64
+ /** The environment variable name. */
65
+ envName: string;
66
+ }
67
+
68
+ /** Where a resolved value came from. */
69
+ export interface VariableSource extends LadderCandidate {
70
+ /** Which of the three layers held the value. */
71
+ file: EnvSourceFile;
72
+ }
73
+
74
+ /** A resolved answer: the value, and exactly where it came from. */
75
+ export interface ResolvedVariable {
76
+ /** The value as stored, before validation or coercion. */
77
+ value: string;
78
+ /** Which name, on which rung, in which layer, supplied it. */
79
+ source: VariableSource;
80
+ }
81
+
82
+ /**
83
+ * The three separately parsed environment layers plus the mapping records, all
84
+ * the ladder needs to answer a lookup.
85
+ */
86
+ export interface LadderContext {
87
+ /** The real shell environment, captured before the env files were injected. */
88
+ shellEnv: Record<string, string>;
89
+ /** The parsed `.sous/.env.local` file. */
90
+ localEnv: Record<string, string>;
91
+ /** The parsed `.sous/.env` file. */
92
+ sharedEnv: Record<string, string>;
93
+ /** The merged `varMappings` block: environment variable name to target. */
94
+ mappings: Record<string, string>;
95
+ }
96
+
97
+ /** Drops undefined entries from a process environment, keeping the strings. */
98
+ function sanitizeEnv(env: NodeJS.ProcessEnv): Record<string, string> {
99
+ const out: Record<string, string> = {};
100
+ for (const [key, value] of Object.entries(env)) {
101
+ if (typeof value === "string") out[key] = value;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** What `loadLadderContext` needs in order to read the layers. */
107
+ export interface LadderContextOptions {
108
+ /** The project's `.sous/` directory, which holds both env files. */
109
+ sousDir: string;
110
+ /** The merged config, read for its `varMappings` block. */
111
+ settings?: Settings;
112
+ /**
113
+ * The real shell environment. Commands pass the snapshot BaseCommand takes
114
+ * before it injects the env files; without it the file-supplied values would
115
+ * be reported as though the shell had set them.
116
+ */
117
+ shellEnv?: NodeJS.ProcessEnv;
118
+ }
119
+
120
+ /**
121
+ * Reads the two env files and the mapping records into a ladder context.
122
+ *
123
+ * @param options - Where to read from; see LadderContextOptions.
124
+ */
125
+ export function loadLadderContext(options: LadderContextOptions): LadderContext {
126
+ const { sousDir, settings, shellEnv = process.env } = options;
127
+ return {
128
+ shellEnv: sanitizeEnv(shellEnv),
129
+ localEnv: readEnvFileMap(path.join(sousDir, ENV_LOCAL_NAME)),
130
+ sharedEnv: readEnvFileMap(path.join(sousDir, ENV_DEFAULTS_NAME)),
131
+ mappings: settings?.varMappings ?? {},
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Every environment variable name that could answer this variable, most
137
+ * specific rung first. Duplicate names are dropped, keeping the most specific
138
+ * occurrence, so a definition whose `env` field repeats a generated name is
139
+ * reported once.
140
+ *
141
+ * @param defined - The definition and the recipe that published it.
142
+ * @param context - The environment layers and mapping records.
143
+ */
144
+ export function variableCandidates(
145
+ defined: DefinedVariable,
146
+ context: LadderContext
147
+ ): LadderCandidate[] {
148
+ const { namespace, name: recipe } = defined.recipe;
149
+ const variable = defined.definition.name;
150
+
151
+ const ordered: LadderCandidate[] = [
152
+ ...mappedNamesFor(context.mappings, defined).map((envName) => ({
153
+ rung: "mapping" as const,
154
+ envName,
155
+ })),
156
+ { rung: "recipe", envName: recipeScopedName(namespace, recipe, variable) },
157
+ { rung: "namespace", envName: namespaceScopedName(namespace, variable) },
158
+ { rung: "shared", envName: sharedName(variable) },
159
+ { rung: "bare", envName: bareName(defined.definition) },
160
+ ];
161
+
162
+ const seen = new Set<string>();
163
+ return ordered.filter((candidate) => {
164
+ if (seen.has(candidate.envName)) return false;
165
+ seen.add(candidate.envName);
166
+ return true;
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Looks one environment variable name up across the three layers, in
172
+ * precedence order.
173
+ *
174
+ * @param envName - The name to look up.
175
+ * @param context - The environment layers.
176
+ * @returns The value and the layer that held it, or undefined when nothing did.
177
+ */
178
+ export function lookupEnvName(
179
+ envName: string,
180
+ context: LadderContext
181
+ ): { value: string; file: EnvSourceFile } | undefined {
182
+ const shell = context.shellEnv[envName];
183
+ if (shell !== undefined) return { value: shell, file: "shell" };
184
+
185
+ const local = context.localEnv[envName];
186
+ if (local !== undefined) return { value: local, file: ENV_LOCAL_NAME };
187
+
188
+ const shared = context.sharedEnv[envName];
189
+ if (shared !== undefined) return { value: shared, file: ENV_DEFAULTS_NAME };
190
+
191
+ return undefined;
192
+ }
193
+
194
+ /**
195
+ * Walks the ladder for one variable and returns the first answer it finds.
196
+ *
197
+ * @param defined - The definition and the recipe that published it.
198
+ * @param context - The environment layers and mapping records.
199
+ * @returns The value and its source, or undefined when no rung answered.
200
+ */
201
+ export function resolveVariable(
202
+ defined: DefinedVariable,
203
+ context: LadderContext
204
+ ): ResolvedVariable | undefined {
205
+ for (const candidate of variableCandidates(defined, context)) {
206
+ const hit = lookupEnvName(candidate.envName, context);
207
+ if (hit !== undefined) {
208
+ return {
209
+ value: hit.value,
210
+ source: { rung: candidate.rung, envName: candidate.envName, file: hit.file },
211
+ };
212
+ }
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ /** A resolution plus the full candidate list, for diagnostics and `sous vars`. */
218
+ export interface VariableDiagnosis {
219
+ /** The winning answer, when a rung produced one. */
220
+ resolved?: ResolvedVariable;
221
+ /** Every name the ladder tried, most specific first. */
222
+ candidates: LadderCandidate[];
223
+ }
224
+
225
+ /**
226
+ * Resolves a variable and reports every candidate name it considered, which is
227
+ * what `sous vars <name>` prints and what a non-interactive failure message
228
+ * lists.
229
+ *
230
+ * @param defined - The definition and the recipe that published it.
231
+ * @param context - The environment layers and mapping records.
232
+ */
233
+ export function diagnoseVariable(
234
+ defined: DefinedVariable,
235
+ context: LadderContext
236
+ ): VariableDiagnosis {
237
+ const candidates = variableCandidates(defined, context);
238
+ for (const candidate of candidates) {
239
+ const hit = lookupEnvName(candidate.envName, context);
240
+ if (hit !== undefined) {
241
+ return {
242
+ resolved: {
243
+ value: hit.value,
244
+ source: { rung: candidate.rung, envName: candidate.envName, file: hit.file },
245
+ },
246
+ candidates,
247
+ };
248
+ }
249
+ }
250
+ return { candidates };
251
+ }
252
+
253
+ /**
254
+ * Records a value in the context so later lookups in the same run see it, as
255
+ * they would on the next run once the file is on disk.
256
+ *
257
+ * @param context - The context to update.
258
+ * @param file - Which layer the value was written to.
259
+ * @param envName - The environment variable name.
260
+ * @param value - The value that was stored.
261
+ */
262
+ export function recordAnswerInContext(
263
+ context: LadderContext,
264
+ file: EnvSourceFile,
265
+ envName: string,
266
+ value: string
267
+ ): void {
268
+ if (file === "shell") context.shellEnv[envName] = value;
269
+ else if (file === ENV_LOCAL_NAME) context.localEnv[envName] = value;
270
+ else context.sharedEnv[envName] = value;
271
+ }
272
+
273
+ /**
274
+ * A one-line, plain-language description of where a value came from, such as
275
+ * "the shared scope name SOUS_VAR_API_URL, from the .env file".
276
+ *
277
+ * @param source - The resolved source.
278
+ */
279
+ export function describeSource(source: VariableSource): string {
280
+ const where = SOURCE_LABELS[source.file] ?? source.file;
281
+ return `the ${RUNG_LABELS[source.rung]} name ${source.envName}, from ${where}`;
282
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Mapping records: the universal conflict resolver for variable answers.
3
+ *
4
+ * A mapping record binds one environment variable, of any name at all, to one
5
+ * fully qualified variable:
6
+ *
7
+ * SOME_VAR -> sous-recipes:misc/stuff/apiUrl
8
+ *
9
+ * It is the top rung of the resolution ladder, and it exists because generated
10
+ * names can collide: two recipes may both declare `apiUrl`, or an author may
11
+ * bind an existing name such as `GITHUB_TOKEN` that something else already
12
+ * uses. Rather than inventing a name grammar that sous would have to parse back
13
+ * into scopes, a record simply states the binding.
14
+ *
15
+ * Records live under the top-level `varMappings` config key. sous writes the
16
+ * ones it creates into the machine-written `conf.d/520-var-mappings.jsonc`
17
+ * layer; a user may also hand-write `varMappings` in the primary config, and
18
+ * the layers merge like anything else.
19
+ */
20
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { parse as parseJsonc, type ParseError } from "jsonc-parser";
24
+ import { ConfigError } from "../errors.js";
25
+ import {
26
+ managedLayerHeader,
27
+ updateManagedLayer,
28
+ } from "../repos/managed-layer.js";
29
+ import {
30
+ NAMESPACE_NAME_PATTERN,
31
+ RECIPE_NAME_PATTERN,
32
+ REPO_NAME_PATTERN,
33
+ VARIABLE_NAME_PATTERN,
34
+ } from "../repos/formats/patterns.js";
35
+ import type { DefinedVariable } from "./definition-source.js";
36
+
37
+ /** File name of the machine-written mapping record layer. */
38
+ export const VAR_MAPPINGS_LAYER_FILENAME = "520-var-mappings.jsonc";
39
+
40
+ /** What the mapping record layer holds, written into its header comment. */
41
+ export const VAR_MAPPINGS_DESCRIPTION = [
42
+ "Each entry under 'varMappings' binds an environment variable to one recipe",
43
+ "variable, so an answer can be stored under a name of your choosing when the",
44
+ "usual names are taken. The 'sous vars ask' command writes them; run",
45
+ "'sous vars' to see which name answered what.",
46
+ ];
47
+
48
+ /** The header comment sous writes at the top of the mapping record layer. */
49
+ export const VAR_MAPPINGS_COMMENT = managedLayerHeader(
50
+ VAR_MAPPINGS_LAYER_FILENAME,
51
+ VAR_MAPPINGS_DESCRIPTION
52
+ );
53
+
54
+ /** A mapping record's target: one variable, named in full. */
55
+ export interface MappingTarget {
56
+ /** The repository's short name, when the record names one. */
57
+ repo?: string;
58
+ /** The recipe's namespace. */
59
+ namespace: string;
60
+ /** The recipe's name. */
61
+ recipe: string;
62
+ /** The variable's camelCase name. */
63
+ variable: string;
64
+ }
65
+
66
+ /** The one-line reminder appended to every mapping error. */
67
+ const TARGET_HELP =
68
+ "A mapping target is written as 'namespace/recipe/variableName', optionally " +
69
+ "qualified with a repository as 'repo:namespace/recipe/variableName'.";
70
+
71
+ /**
72
+ * Parses a mapping target string into its parts, throwing a ConfigError that
73
+ * quotes the input and shows the grammar when it does not fit.
74
+ *
75
+ * @param input - The target as written in the config.
76
+ */
77
+ export function parseMappingTarget(input: string): MappingTarget {
78
+ const fail = (problem: string): never => {
79
+ throw new ConfigError(
80
+ `Invalid variable mapping target '${input}': ${problem}\n ${TARGET_HELP}`
81
+ );
82
+ };
83
+
84
+ const trimmed = typeof input === "string" ? input.trim() : "";
85
+ if (trimmed.length === 0) fail("a target must not be empty.");
86
+
87
+ let body = trimmed;
88
+ let repo: string | undefined;
89
+
90
+ const colon = body.indexOf(":");
91
+ if (colon !== -1) {
92
+ repo = body.slice(0, colon);
93
+ body = body.slice(colon + 1);
94
+ if (!REPO_NAME_PATTERN.test(repo)) {
95
+ fail(
96
+ `the repository qualifier '${repo}' must be lowercase kebab-case: a letter, ` +
97
+ "then letters, digits or hyphens."
98
+ );
99
+ }
100
+ }
101
+
102
+ const segments = body.split("/");
103
+ if (segments.length !== 3) {
104
+ fail("a target names a namespace, a recipe and a variable, joined by slashes.");
105
+ }
106
+
107
+ const [namespace, recipe, variable] = segments as [string, string, string];
108
+ if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
109
+ fail(`the namespace '${namespace}' must be lowercase kebab-case.`);
110
+ }
111
+ if (!RECIPE_NAME_PATTERN.test(recipe)) {
112
+ fail(`the recipe name '${recipe}' must be lowercase kebab-case.`);
113
+ }
114
+ if (!VARIABLE_NAME_PATTERN.test(variable)) {
115
+ fail(
116
+ `the variable name '${variable}' must be camelCase: a lowercase letter, then ` +
117
+ "letters or digits."
118
+ );
119
+ }
120
+
121
+ const parsed: MappingTarget = { namespace, recipe, variable };
122
+ if (repo !== undefined) parsed.repo = repo;
123
+ return parsed;
124
+ }
125
+
126
+ /**
127
+ * Renders a mapping target back into its written form, which round-trips with
128
+ * `parseMappingTarget`.
129
+ *
130
+ * @param target - The target parts.
131
+ */
132
+ export function formatMappingTarget(target: MappingTarget): string {
133
+ const qualifier = target.repo === undefined ? "" : `${target.repo}:`;
134
+ return `${qualifier}${target.namespace}/${target.recipe}/${target.variable}`;
135
+ }
136
+
137
+ /**
138
+ * The fully qualified target for one defined variable, repository qualifier
139
+ * included, which is what a new record is written with.
140
+ *
141
+ * @param defined - The definition and the recipe that published it.
142
+ */
143
+ export function mappingTargetFor(defined: DefinedVariable): MappingTarget {
144
+ return {
145
+ repo: defined.recipe.repo,
146
+ namespace: defined.recipe.namespace,
147
+ recipe: defined.recipe.name,
148
+ variable: defined.definition.name,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * True when a mapping record's target names this variable. A record without a
154
+ * repository qualifier matches the variable in any repository; one with a
155
+ * qualifier must name the same repository.
156
+ *
157
+ * @param target - The record's parsed target.
158
+ * @param defined - The definition and the recipe that published it.
159
+ */
160
+ export function mappingMatches(target: MappingTarget, defined: DefinedVariable): boolean {
161
+ if (target.repo !== undefined && target.repo !== defined.recipe.repo) return false;
162
+ return (
163
+ target.namespace === defined.recipe.namespace &&
164
+ target.recipe === defined.recipe.name &&
165
+ target.variable === defined.definition.name
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Every environment variable name bound to this variable by a mapping record,
171
+ * sorted so the order never depends on config layer order.
172
+ *
173
+ * @param mappings - The merged `varMappings` block.
174
+ * @param defined - The definition and the recipe that published it.
175
+ */
176
+ export function mappedNamesFor(
177
+ mappings: Record<string, string>,
178
+ defined: DefinedVariable
179
+ ): string[] {
180
+ const names: string[] = [];
181
+ for (const [envName, rawTarget] of Object.entries(mappings)) {
182
+ if (mappingMatches(parseMappingTarget(rawTarget), defined)) names.push(envName);
183
+ }
184
+ return names.sort();
185
+ }
186
+
187
+ /**
188
+ * Records a mapping in the machine-written `conf.d/520-var-mappings.jsonc`
189
+ * layer, editing only that one entry's bytes so any comments, key order and
190
+ * formatting already in the file survive.
191
+ *
192
+ * The edit stages to a temporary name and renames over the layer, like every
193
+ * other file sous writes for a machine. A layer truncated by an interrupt would
194
+ * fail to parse and break every later command until someone deleted it by hand;
195
+ * a rename either happens or does not, so the previous layer survives.
196
+ *
197
+ * @param confDir - The project's `conf.d/` directory (`configContext.confDir`).
198
+ * @param envName - The environment variable the answer is stored under.
199
+ * @param target - The variable the name is bound to.
200
+ * @returns The path of the layer file that was written.
201
+ */
202
+ export function writeMappingRecord(
203
+ confDir: string,
204
+ envName: string,
205
+ target: MappingTarget | string
206
+ ): string {
207
+ const rendered = typeof target === "string" ? target : formatMappingTarget(target);
208
+ // Parse before writing, so a bad target is refused rather than persisted.
209
+ parseMappingTarget(rendered);
210
+
211
+ return updateManagedLayer(
212
+ path.dirname(confDir),
213
+ VAR_MAPPINGS_LAYER_FILENAME,
214
+ [{ path: ["varMappings", envName], value: rendered }],
215
+ { confDir, header: VAR_MAPPINGS_COMMENT }
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Reads the records already in the mapping layer file, returning an empty
221
+ * object when the file is missing. A layer still under the old
222
+ * `520-var-mappings.json` name is read as a fallback; the next write migrates
223
+ * it.
224
+ *
225
+ * @param filePath - Path to the mapping record layer file.
226
+ */
227
+ export function readMappingRecords(filePath: string): Record<string, string> {
228
+ let readPath = filePath;
229
+ if (!fs.existsSync(readPath)) {
230
+ const legacy = filePath.endsWith(".jsonc") ? filePath.slice(0, -1) : undefined;
231
+ if (legacy === undefined || !fs.existsSync(legacy)) return {};
232
+ readPath = legacy;
233
+ }
234
+
235
+ const errors: ParseError[] = [];
236
+ const parsed = parseJsonc(fs.readFileSync(readPath, "utf8"), errors, {
237
+ allowTrailingComma: true,
238
+ disallowComments: false,
239
+ });
240
+ if (errors.length > 0) {
241
+ throw new ConfigError(
242
+ `Could not read the variable mapping records at ${readPath}:\n` +
243
+ ` The file is not valid JSON with comments.\n` +
244
+ ` This file is written by sous; deleting it removes every mapping record.`
245
+ );
246
+ }
247
+
248
+ const block =
249
+ parsed !== null && typeof parsed === "object"
250
+ ? (parsed as { varMappings?: unknown }).varMappings
251
+ : undefined;
252
+ if (block === undefined) return {};
253
+ if (block === null || typeof block !== "object" || Array.isArray(block)) {
254
+ throw new ConfigError(
255
+ `The variable mapping records at ${readPath} are malformed: 'varMappings' must ` +
256
+ `be an object of environment variable names to targets.`
257
+ );
258
+ }
259
+
260
+ const records: Record<string, string> = {};
261
+ for (const [key, value] of Object.entries(block as Record<string, unknown>)) {
262
+ if (typeof value === "string") records[key] = value;
263
+ }
264
+ return records;
265
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Environment variable names for recipe variable answers.
3
+ *
4
+ * Every name here is GENERATED and then LOOKED UP. Nothing in sous ever parses
5
+ * a name back into the scope that produced it: `_` is both the delimiter and a
6
+ * legal identifier character, so `SOUS_VAR_MISC_STUFF_API_URL` could be split
7
+ * in several places and no parse would be trustworthy. When a generated name
8
+ * collides with something else, a mapping record (see `mappings.ts`) binds an
9
+ * arbitrary name to one fully qualified variable instead.
10
+ */
11
+
12
+ import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
13
+
14
+ /** The prefix every generated answer name carries. */
15
+ export const ENV_PREFIX = "SOUS_VAR_";
16
+
17
+ /**
18
+ * Converts a camelCase or kebab-case identifier to upper snake case.
19
+ *
20
+ * @param name - The identifier, such as `apiBaseUrl` or `task-files`.
21
+ * @returns The upper snake case form, such as `API_BASE_URL` or `TASK_FILES`.
22
+ */
23
+ export function toUpperSnake(name: string): string {
24
+ return name
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
26
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
27
+ .replace(/[-\s.]+/g, "_")
28
+ .replace(/_+/g, "_")
29
+ .replace(/^_|_$/g, "")
30
+ .toUpperCase();
31
+ }
32
+
33
+ /**
34
+ * The default environment variable name for a variable, derived from its
35
+ * camelCase name: `apiUrl` becomes `SOUS_VAR_API_URL`. This is the same string
36
+ * as the shared rung of the resolution ladder.
37
+ *
38
+ * @param variableName - The variable's camelCase name.
39
+ */
40
+ export function deriveEnvName(variableName: string): string {
41
+ return `${ENV_PREFIX}${toUpperSnake(variableName)}`;
42
+ }
43
+
44
+ /**
45
+ * The recipe-scoped name: the most specific generated rung, naming both the
46
+ * namespace and the recipe. `misc` plus `stuff` plus `apiUrl` becomes
47
+ * `SOUS_VAR_MISC_STUFF_API_URL`.
48
+ *
49
+ * @param namespace - The recipe's namespace.
50
+ * @param recipe - The recipe's name.
51
+ * @param variableName - The variable's camelCase name.
52
+ */
53
+ export function recipeScopedName(
54
+ namespace: string,
55
+ recipe: string,
56
+ variableName: string
57
+ ): string {
58
+ return `${ENV_PREFIX}${toUpperSnake(namespace)}_${toUpperSnake(recipe)}_${toUpperSnake(
59
+ variableName
60
+ )}`;
61
+ }
62
+
63
+ /**
64
+ * The namespace-scoped name, which answers the same variable for every recipe
65
+ * in one namespace. `misc` plus `apiUrl` becomes `SOUS_VAR_MISC_API_URL`.
66
+ *
67
+ * @param namespace - The recipe's namespace.
68
+ * @param variableName - The variable's camelCase name.
69
+ */
70
+ export function namespaceScopedName(namespace: string, variableName: string): string {
71
+ return `${ENV_PREFIX}${toUpperSnake(namespace)}_${toUpperSnake(variableName)}`;
72
+ }
73
+
74
+ /**
75
+ * The shared name, which answers a variable of this name for every recipe that
76
+ * declares one. `apiUrl` becomes `SOUS_VAR_API_URL`.
77
+ *
78
+ * @param variableName - The variable's camelCase name.
79
+ */
80
+ export function sharedName(variableName: string): string {
81
+ return deriveEnvName(variableName);
82
+ }
83
+
84
+ /**
85
+ * The bare declared name: the definition's own `env` field when its author set
86
+ * one (so a recipe can bind an existing variable such as `GITHUB_TOKEN`), and
87
+ * the shared form otherwise. This is also the name a new answer is written
88
+ * under.
89
+ *
90
+ * @param definition - The variable definition.
91
+ */
92
+ export function bareName(definition: VariableDefinition): string {
93
+ return definition.env ?? sharedName(definition.name);
94
+ }