@sous-io/sous 0.2.37 → 1.0.2

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 (244) hide show
  1. package/README.md +14 -256
  2. package/dist/bin.js +11 -0
  3. package/dist/sous.js +1094 -0
  4. package/docs/markdown/README.md +5 -4
  5. package/docs/markdown/_sidebar.md +21 -1
  6. package/docs/markdown/commands.md +294 -431
  7. package/docs/markdown/config-discovery.md +25 -14
  8. package/docs/markdown/config-inspection.md +53 -31
  9. package/docs/markdown/config-layers.md +168 -66
  10. package/docs/markdown/config-variables.md +44 -11
  11. package/docs/markdown/configuration.md +104 -17
  12. package/docs/markdown/design-principles.md +4 -1
  13. package/docs/markdown/repositories-authoring.md +30 -0
  14. package/docs/markdown/repositories-consuming.md +72 -14
  15. package/docs/markdown/repositories-file-formats.md +31 -13
  16. package/docs/markdown/repositories-providers.md +48 -57
  17. package/docs/markdown/repositories-quickstart.md +3 -2
  18. package/docs/markdown/repositories-troubleshooting.md +27 -1
  19. package/docs/markdown/repositories-variables.md +6 -1
  20. package/docs/markdown/repositories.md +18 -8
  21. package/package.json +48 -107
  22. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
  23. package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
  24. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
  25. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
  26. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
  27. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  28. package/sous.config.schema.json +351 -132
  29. package/bin/run.js +0 -51
  30. package/src/base-command.ts +0 -457
  31. package/src/commands/build.ts +0 -181
  32. package/src/commands/clear.ts +0 -79
  33. package/src/commands/compile.ts +0 -155
  34. package/src/commands/config/get.ts +0 -110
  35. package/src/commands/config/show.ts +0 -32
  36. package/src/commands/config/validate.ts +0 -53
  37. package/src/commands/help.ts +0 -46
  38. package/src/commands/init.ts +0 -289
  39. package/src/commands/launch.ts +0 -139
  40. package/src/commands/lock/rebuild.ts +0 -239
  41. package/src/commands/lock/show.ts +0 -115
  42. package/src/commands/namespace/list.ts +0 -138
  43. package/src/commands/namespace/show.ts +0 -130
  44. package/src/commands/prune.ts +0 -43
  45. package/src/commands/recipe/list.ts +0 -112
  46. package/src/commands/recipe/show.ts +0 -439
  47. package/src/commands/repo/add.ts +0 -145
  48. package/src/commands/repo/contribute.ts +0 -604
  49. package/src/commands/repo/gc.ts +0 -173
  50. package/src/commands/repo/init.ts +0 -136
  51. package/src/commands/repo/link.ts +0 -899
  52. package/src/commands/repo/list.ts +0 -236
  53. package/src/commands/repo/release.ts +0 -685
  54. package/src/commands/repo/remove.ts +0 -194
  55. package/src/commands/repo/search.ts +0 -243
  56. package/src/commands/repo/submit.ts +0 -341
  57. package/src/commands/repo/unlink.ts +0 -461
  58. package/src/commands/subscription/add.ts +0 -286
  59. package/src/commands/subscription/list.ts +0 -208
  60. package/src/commands/subscription/remove.ts +0 -182
  61. package/src/commands/subscription/update.ts +0 -216
  62. package/src/commands/update.ts +0 -178
  63. package/src/commands/vars/ask.ts +0 -417
  64. package/src/commands/vars/index.ts +0 -79
  65. package/src/commands/vars/list.ts +0 -67
  66. package/src/commands/vars/show.ts +0 -77
  67. package/src/config-command.ts +0 -30
  68. package/src/lib/build-preparation.ts +0 -128
  69. package/src/lib/build-service.ts +0 -580
  70. package/src/lib/config-discovery.ts +0 -378
  71. package/src/lib/config-inspect.ts +0 -145
  72. package/src/lib/config-kernel.mjs +0 -379
  73. package/src/lib/config-schema.ts +0 -401
  74. package/src/lib/env-file.ts +0 -328
  75. package/src/lib/env-local.ts +0 -212
  76. package/src/lib/errors.ts +0 -32
  77. package/src/lib/glob-pattern.ts +0 -24
  78. package/src/lib/include-resolver.ts +0 -523
  79. package/src/lib/interactive.ts +0 -165
  80. package/src/lib/markdown-compiler.ts +0 -947
  81. package/src/lib/package-info.ts +0 -25
  82. package/src/lib/pid-service.ts +0 -99
  83. package/src/lib/project-install.d.mts +0 -44
  84. package/src/lib/project-install.mjs +0 -234
  85. package/src/lib/project-scaffold/index.ts +0 -332
  86. package/src/lib/project-scaffold/templates.ts +0 -240
  87. package/src/lib/repos/catalog-display.ts +0 -215
  88. package/src/lib/repos/catalog-inputs.ts +0 -288
  89. package/src/lib/repos/catalog.ts +0 -842
  90. package/src/lib/repos/contribute.ts +0 -452
  91. package/src/lib/repos/core-recipe.ts +0 -105
  92. package/src/lib/repos/declarations.ts +0 -187
  93. package/src/lib/repos/defaults.ts +0 -175
  94. package/src/lib/repos/formats/common.ts +0 -440
  95. package/src/lib/repos/formats/index-file.ts +0 -253
  96. package/src/lib/repos/formats/links-map.ts +0 -98
  97. package/src/lib/repos/formats/lockfile.ts +0 -169
  98. package/src/lib/repos/formats/patterns.ts +0 -57
  99. package/src/lib/repos/formats/recipe-manifest.ts +0 -205
  100. package/src/lib/repos/formats/repo-manifest.ts +0 -97
  101. package/src/lib/repos/formats/store-entry.ts +0 -84
  102. package/src/lib/repos/formats/variable-definition.ts +0 -268
  103. package/src/lib/repos/freshness.ts +0 -264
  104. package/src/lib/repos/git-clone.ts +0 -850
  105. package/src/lib/repos/identity.ts +0 -89
  106. package/src/lib/repos/index.ts +0 -57
  107. package/src/lib/repos/links.ts +0 -356
  108. package/src/lib/repos/load-manifest.ts +0 -237
  109. package/src/lib/repos/lock-service.ts +0 -454
  110. package/src/lib/repos/locked-namespace-resolver.ts +0 -232
  111. package/src/lib/repos/locked-recipe-lookup.ts +0 -123
  112. package/src/lib/repos/locked-recipes.ts +0 -254
  113. package/src/lib/repos/managed-layer.ts +0 -422
  114. package/src/lib/repos/memories-hash-name.ts +0 -33
  115. package/src/lib/repos/namespace-resolver.ts +0 -527
  116. package/src/lib/repos/providers/base.ts +0 -268
  117. package/src/lib/repos/providers/git.ts +0 -245
  118. package/src/lib/repos/providers/github.ts +0 -589
  119. package/src/lib/repos/providers/gitlab.ts +0 -332
  120. package/src/lib/repos/providers/http.ts +0 -128
  121. package/src/lib/repos/providers/index-cache.ts +0 -421
  122. package/src/lib/repos/providers/index.ts +0 -106
  123. package/src/lib/repos/providers/local.ts +0 -410
  124. package/src/lib/repos/providers/provider.ts +0 -615
  125. package/src/lib/repos/recipe-config-layers.ts +0 -316
  126. package/src/lib/repos/recipe-key-matcher.ts +0 -62
  127. package/src/lib/repos/recipe-memories.ts +0 -178
  128. package/src/lib/repos/recipe-targets.ts +0 -236
  129. package/src/lib/repos/ref-search.ts +0 -46
  130. package/src/lib/repos/release/bump.ts +0 -161
  131. package/src/lib/repos/release/changelog.ts +0 -461
  132. package/src/lib/repos/release/git-state.ts +0 -460
  133. package/src/lib/repos/release/index-builder.ts +0 -1108
  134. package/src/lib/repos/release/index.ts +0 -19
  135. package/src/lib/repos/release/plan.ts +0 -521
  136. package/src/lib/repos/release/settle.ts +0 -154
  137. package/src/lib/repos/release/submissions.ts +0 -214
  138. package/src/lib/repos/release/submit-checkout.ts +0 -290
  139. package/src/lib/repos/release/submit-questions.ts +0 -153
  140. package/src/lib/repos/release/submit-service.ts +0 -898
  141. package/src/lib/repos/release/tags.ts +0 -239
  142. package/src/lib/repos/release/validate.ts +0 -463
  143. package/src/lib/repos/resolver.ts +0 -1028
  144. package/src/lib/repos/scaffold/index.ts +0 -245
  145. package/src/lib/repos/scaffold/templates.ts +0 -517
  146. package/src/lib/repos/seed.ts +0 -473
  147. package/src/lib/repos/store/contract.ts +0 -64
  148. package/src/lib/repos/store/hash.ts +0 -191
  149. package/src/lib/repos/store/recipe-store.ts +0 -599
  150. package/src/lib/repos/store/settings.ts +0 -58
  151. package/src/lib/repos/subscription-service.ts +0 -3859
  152. package/src/lib/repos/trust.ts +0 -447
  153. package/src/lib/repos/update-plan.ts +0 -235
  154. package/src/lib/self-update/index.ts +0 -14
  155. package/src/lib/self-update/installs.ts +0 -413
  156. package/src/lib/self-update/managers.ts +0 -126
  157. package/src/lib/self-update/plan.ts +0 -315
  158. package/src/lib/self-update/registry.ts +0 -99
  159. package/src/lib/self-update/run.ts +0 -506
  160. package/src/lib/self-update/versions.ts +0 -243
  161. package/src/lib/settings.ts +0 -1107
  162. package/src/lib/sous-home.ts +0 -104
  163. package/src/lib/state.ts +0 -167
  164. package/src/lib/vars/answers.ts +0 -192
  165. package/src/lib/vars/ask.ts +0 -1152
  166. package/src/lib/vars/definition-source.ts +0 -261
  167. package/src/lib/vars/display.ts +0 -231
  168. package/src/lib/vars/index.ts +0 -20
  169. package/src/lib/vars/ladder.ts +0 -282
  170. package/src/lib/vars/mappings.ts +0 -260
  171. package/src/lib/vars/names.ts +0 -94
  172. package/src/lib/vars/preanswers.ts +0 -395
  173. package/src/lib/vars/question-plan.ts +0 -241
  174. package/src/lib/vars/report.ts +0 -228
  175. package/src/lib/vars/safe-regex.ts +0 -235
  176. package/src/lib/vars/secrets.ts +0 -131
  177. package/src/lib/vars/validate.ts +0 -312
  178. package/src/lib/version-report.ts +0 -90
  179. package/src/lib/watch-loop.ts +0 -148
  180. package/src/lib/watch-service.ts +0 -116
  181. package/src/services/ref-resolver/container.ts +0 -107
  182. package/src/services/ref-resolver/dependency.ts +0 -60
  183. package/src/services/ref-resolver/format.ts +0 -197
  184. package/src/services/ref-resolver/glob.ts +0 -115
  185. package/src/services/ref-resolver/hash-names.ts +0 -155
  186. package/src/services/ref-resolver/index.ts +0 -55
  187. package/src/services/ref-resolver/injectable.ts +0 -36
  188. package/src/services/ref-resolver/location.ts +0 -62
  189. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
  190. package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
  191. package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
  192. package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
  193. package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
  194. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
  195. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
  196. package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
  197. package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
  198. package/src/services/ref-resolver/named-refs.ts +0 -144
  199. package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
  200. package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
  201. package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
  202. package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
  203. package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
  204. package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
  205. package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
  206. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
  207. package/src/services/ref-resolver/parts.ts +0 -115
  208. package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
  209. package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
  210. package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
  211. package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
  212. package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
  213. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
  214. package/src/services/ref-resolver/ref-helpers.ts +0 -62
  215. package/src/services/ref-resolver/ref-picker-service.ts +0 -204
  216. package/src/services/ref-resolver/ref-report.ts +0 -84
  217. package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
  218. package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
  219. package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
  220. package/src/services/ref-resolver/source.ts +0 -48
  221. package/src/services/ref-resolver/tokens.ts +0 -25
  222. package/src/services/ref-resolver/types.ts +0 -155
  223. package/src/templating/filters/bullet-list.ts +0 -9
  224. package/src/templating/filters/index.ts +0 -8
  225. package/src/templating/init-liquid-engine.ts +0 -139
  226. package/src/templating/lib/glob-files.ts +0 -74
  227. package/src/templating/lib/import-export.ts +0 -32
  228. package/src/templating/lib/secret-scope.ts +0 -41
  229. package/src/templating/lib/tag-args.ts +0 -19
  230. package/src/templating/tags/exportScalarVarsJs.ts +0 -46
  231. package/src/templating/tags/getFiles.ts +0 -89
  232. package/src/templating/tags/index.ts +0 -14
  233. package/src/templating/tags/listFiles.ts +0 -54
  234. package/src/templating/tags/showVars.ts +0 -28
  235. package/src/utils/choice-prompt.ts +0 -143
  236. package/src/utils/command-errors.ts +0 -186
  237. package/src/utils/command-help.ts +0 -45
  238. package/src/utils/confirm-prompt.ts +0 -110
  239. package/src/utils/flags.ts +0 -177
  240. package/src/utils/formatting.ts +0 -849
  241. package/src/utils/prompts.ts +0 -53
  242. package/src/utils/sous-directory.ts +0 -245
  243. package/src/utils/table.ts +0 -603
  244. package/src/utils/value-prompt.ts +0 -119
@@ -1,1107 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
- import { createRequire } from "node:module";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { globSync } from "glob";
7
- import { inferGlobBase, type CompilationConfig, type CompilationTarget, type ResolvedRuntimeContext } from "./markdown-compiler.js";
8
- import { buildAliasMap, resolveAliasPrefix, type AliasMap } from "./include-resolver.js";
9
- import { toGlobPattern } from "./glob-pattern.js";
10
- import {
11
- CONFD_DIR_NAME,
12
- ENV_DEFAULTS_NAME,
13
- ENV_LOCAL_NAME,
14
- SOUS_DIR_NAME,
15
- type DiscoveredConfig,
16
- } from "./config-discovery.js";
17
- import { ConfigError } from "./errors.js";
18
- import { sharedHashNames } from "../services/ref-resolver/index.js";
19
- import { resolveSousHome } from "./sous-home.js";
20
- import { validateSettings } from "./config-schema.js";
21
- import { applyRepoDefaults } from "./repos/defaults.js";
22
- import type { RecipeConfigLayer } from "./repos/recipe-config-layers.js";
23
- import {
24
- answersForRecipe,
25
- resolveRecipeAnswers,
26
- type RecipeAnswers,
27
- } from "./vars/answers.js";
28
- import { warning } from "../utils/formatting.js";
29
-
30
- // Re-exported for backwards compatibility: ConfigError moved to ./errors.ts so
31
- // config-discovery.ts can throw it without importing this module (cycle).
32
- export { ConfigError, isConfigError } from "./errors.js";
33
-
34
- // CLI_ROOT and SOUS_VERSION live in their own module so that modules this one
35
- // imports can read them without importing this one back. Re-exported here under
36
- // the names everything already uses.
37
- import { CLI_ROOT, SOUS_VERSION } from "./package-info.js";
38
- export { CLI_ROOT, SOUS_VERSION };
39
-
40
- // --- Variable scope ------------------------------------------------------------------------------
41
-
42
- /** A flat map of resolved variable names to their string values. */
43
- export type VarScope = Record<string, string>;
44
-
45
- // --- Raw settings types (matching settings.local.js shape) --------------------------------------
46
-
47
- type RawOutput = {
48
- _if?: Record<string, { eq: string }>;
49
- _vars?: Record<string, string>;
50
- destinationFile?: string;
51
- destinationDir?: string;
52
- };
53
-
54
- type RawRuntimeContext = {
55
- /** ${var} path to the git repo root used for branch detection. */
56
- gitRoot: string;
57
- /** ${var} path where the generated session-context file is written. */
58
- outputPath: string;
59
- /** ${var} root directory for task files; branch name is appended to locate the active file. */
60
- taskFileRoot: string;
61
- /** Regex pattern string; branches matching this pattern trigger task file lookup. */
62
- branchPattern?: string;
63
- };
64
-
65
- type RawTarget = {
66
- _vars?: Record<string, string>;
67
- /** Point-to-point source file path. Exactly one of entryPoint or entryGlob must be set. */
68
- entryPoint?: string;
69
- /** Glob pattern expanding to multiple source files. Exactly one of entryPoint or entryGlob must be set. */
70
- entryGlob?: string;
71
- /**
72
- * Explicit base directory for computing relative output paths when using destinationDir.
73
- * Only meaningful for entryGlob targets. When omitted, inferred from the glob pattern.
74
- */
75
- globBase?: string;
76
- /**
77
- * When true, the compiler generates a runtime session context file (branch name, task file)
78
- * alongside the entry point before compilation. Only meaningful for AGENTS-style entry points.
79
- */
80
- generateRuntimeContext?: boolean;
81
- outputs: RawOutput[];
82
- };
83
-
84
- type RawProjectCompilation = {
85
- _vars?: Record<string, string>;
86
- includeSourceComments?: boolean;
87
- targets: RawTarget[];
88
- };
89
-
90
- /**
91
- * One trusted repository, keyed in `Settings.repos` by the short name refs use
92
- * in their `repo:` qualifier. Adding a repo is what trusts it.
93
- */
94
- export type RepoEntry = {
95
- /** Where the repository lives. */
96
- url: string;
97
- /**
98
- * Whether the repository takes part in anything. Defaults to true; setting it
99
- * to false is how a project opts out of a repository sous provides itself,
100
- * without deleting an entry it does not own.
101
- */
102
- enabled?: boolean;
103
- /**
104
- * Which provider handles it. Inferred from the URL when omitted. `local` is a
105
- * repository on this machine, for local development and tests.
106
- */
107
- provider?: "github" | "gitlab" | "local";
108
- /** Install a newer in-range version whenever one exists, rather than holding the lock. */
109
- alwaysPull?: boolean;
110
- /** When the repo was added. */
111
- addedAt?: string;
112
- /** Who required it: "user", or the ref of the recipe whose dependency pulled it in. */
113
- addedBy?: string;
114
- };
115
-
116
- /**
117
- * One subscription, keyed in `Settings.subscriptions` by a ref key: a bare
118
- * namespace, or `namespace/recipe`.
119
- */
120
- export type SubscriptionEntry = {
121
- /**
122
- * Whether the subscription takes part in anything. Defaults to true; setting
123
- * it to false is how a project opts out of the `core` namespace sous
124
- * subscribes it to.
125
- */
126
- enabled?: boolean;
127
- /** The semantic version range to resolve within. Defaults to "*". */
128
- range?: string;
129
- /** Let prerelease versions take part in range matching. */
130
- prerelease?: boolean;
131
- /** Per-subscription form of the repo-level always-pull flag. */
132
- alwaysPull?: boolean;
133
- /** When the subscription was added. */
134
- addedAt?: string;
135
- /** Who required it: "user", or the ref of the recipe that co-subscribed it. */
136
- addedBy?: string;
137
- };
138
-
139
- /**
140
- * Knobs for the machine-wide recipe store. Defaults are applied by the store
141
- * itself, not here; see config-schema.ts for the values sous ships.
142
- */
143
- type StoreConfig = {
144
- /** Size cap, past which least-recently-used entries are collected. */
145
- maxBytes?: number;
146
- /** How long a fetched index stays fresh before sous re-checks upstream. */
147
- freshnessSeconds?: number;
148
- /** How often watch mode polls upstream. */
149
- watchPollSeconds?: number;
150
- };
151
-
152
- /**
153
- * What a project does with each content kind its subscribed recipes publish.
154
- *
155
- * `skills.outputs` lists the destination directories for recipe skills, each
156
- * `${var}` substituted; the default is `<project root>/.claude/skills`, the
157
- * project root being the parent of `.sous/`. `memories.first` and
158
- * `memories.exclude` are lists of globs, or `/.../` regular expressions, over
159
- * `namespace/recipe` keys.
160
- */
161
- type RecipesConfig = {
162
- skills?: {
163
- /** Where recipe skill bundles are written. */
164
- outputs?: string[];
165
- };
166
- memories?: {
167
- /** Recipes whose memories come first. */
168
- first?: string[];
169
- /** Recipes whose memories are left out. */
170
- exclude?: string[];
171
- };
172
- };
173
-
174
- /** Configuration for a launchable tool (e.g. claude, codex). */
175
- type ToolConfig = {
176
- /** The executable command to run. */
177
- command: string;
178
- /** Arguments passed before the prompt file argument. */
179
- args?: string[];
180
- /**
181
- * Path to a file whose contents are appended as the final argument to the command.
182
- * Path is resolved through the project's var scope.
183
- */
184
- promptFile?: string;
185
- };
186
-
187
- export type Settings = {
188
- /** Config schema version. Optional; when present must be 1 (validated at load). */
189
- version?: number;
190
- _env?: Record<string, string>;
191
- /**
192
- * Config variables. A few names are read by Sous itself:
193
- * `stateFilePath` overrides where the build state file is written (see
194
- * StateService.getFilePath), and `pidFilePath` does the same for the watcher
195
- * PID file. Both resolve through the settings scope.
196
- */
197
- _vars?: Record<string, string>;
198
- _aliases?: Record<string, string | string[]>;
199
- /** Optional display name for the configured project. */
200
- name?: string;
201
- compilation?: RawProjectCompilation;
202
- runtimeContext?: RawRuntimeContext;
203
- tools?: Record<string, ToolConfig>;
204
- /** Trusted repositories, keyed by the short name refs use. */
205
- repos?: Record<string, RepoEntry>;
206
- /** Subscriptions, keyed by ref key (`namespace` or `namespace/recipe`). */
207
- subscriptions?: Record<string, SubscriptionEntry>;
208
- /** Knobs for the machine-wide recipe store. */
209
- store?: StoreConfig;
210
- /**
211
- * What the project does with each content kind its subscribed recipes publish.
212
- */
213
- recipes?: RecipesConfig;
214
- /**
215
- * Variable mapping records, keyed by environment variable name, each bound to
216
- * one recipe variable written as `namespace/recipe/variableName` with an
217
- * optional `repo:` qualifier. The top rung of the answer resolution ladder.
218
- */
219
- varMappings?: Record<string, string>;
220
- };
221
-
222
- // --- Loader -------------------------------------------------------------------------------------
223
-
224
- /** Options accepted by loadSettings / loadSettingsWithLayers. */
225
- export type LoadSettingsOptions = {
226
- /**
227
- * When true, the kernel returns one cumulative-config snapshot per top-level
228
- * layer (the provenance seam behind `sous config get --layers`).
229
- */
230
- trace?: boolean;
231
- };
232
-
233
- /** One trace-mode snapshot: the cumulative config AFTER `path` was merged. */
234
- export type SettingsLayer = {
235
- path: string;
236
- config: unknown;
237
- };
238
-
239
- /** Absolute path to the config kernel subprocess entry (plain .mjs, ships in src/). */
240
- const CONFIG_KERNEL_PATH = path.join(CLI_ROOT, "src", "lib", "config-kernel.mjs");
241
-
242
- /**
243
- * Loads settings from a discovered config (primary file + conf.d layers) and
244
- * returns the merged result together with any trace-mode layer snapshots.
245
- *
246
- * Every layer — .js, .mjs, .json and .yaml alike — is loaded by ONE kernel
247
- * subprocess (src/lib/config-kernel.mjs), which parses/imports each file in
248
- * order, JSON-forces it, deep-merges it into a live cumulative config, runs
249
- * `configure(currentConfig, builder)` exports, and serialises the final JSON
250
- * once. Uniform kernel semantics beat the spawn cost, so there is no
251
- * parent-side shortcut for plain JSON.
252
- *
253
- * Two spawn attempts are made, in this order:
254
- *
255
- * 1. Plain Node, no loader. This is what a normal ESM config needs, and it is
256
- * the only thing that works for a `.sous/sous.config.js` sitting in a repo
257
- * whose package.json has no `"type": "module"` (under the tsx loader such a
258
- * file is treated as CJS and dies with ERR_REQUIRE_CYCLE_MODULE).
259
- * 2. The tsx loader, so a config may use TypeScript syntax and extensionless
260
- * relative imports. (The kernel itself is plain .mjs and runs under both.)
261
- *
262
- * The subprocess (rather than a direct `import()`) avoids the require(esm) cycle
263
- * that tsx triggers in the parent process. Because every layer is round-tripped
264
- * through JSON, functions, RegExp, Date and undefined values are dropped.
265
- *
266
- * @param source - The full DiscoveredConfig, or a bare config file path. A bare
267
- * string means exactly that one file: no conf.d scan is performed (back-compat
268
- * for tests and direct callers).
269
- * @param options - `{ trace }` — see LoadSettingsOptions.
270
- */
271
- /* c8 ignore next 75 */
272
- export async function loadSettingsWithLayers(
273
- source: DiscoveredConfig | string,
274
- options: LoadSettingsOptions = {}
275
- ): Promise<{ settings: Settings; layers: SettingsLayer[] }> {
276
- let configPath: string;
277
- let sousDir: string;
278
- let confDir: string;
279
- let layerPaths: string[];
280
- let recipeLayers: RecipeConfigLayer[];
281
-
282
- if (typeof source === "string") {
283
- configPath = path.resolve(source);
284
- sousDir = path.dirname(configPath);
285
- confDir = path.join(sousDir, CONFD_DIR_NAME);
286
- layerPaths = [configPath];
287
- recipeLayers = [];
288
- } else {
289
- ({ configPath, sousDir, confDir, layerPaths } = source);
290
- recipeLayers = source.recipeLayers ?? [];
291
- }
292
-
293
- if (!fs.existsSync(configPath)) {
294
- throw new Error(`Settings file not found: ${configPath}`);
295
- }
296
-
297
- // A recipe layer is handed to the kernel as content, not as a path, because
298
- // it has already been read and filtered down to the keys a recipe may set
299
- // (see `repos/recipe-config-layers.ts`). The kernel never opens the file, so
300
- // there is no second, unfiltered reading of it.
301
- const recipeLayerByPath = new Map(recipeLayers.map((layer) => [layer.path, layer]));
302
- const sources = layerPaths.map((layerPath) => {
303
- const recipeLayer = recipeLayerByPath.get(layerPath);
304
- if (recipeLayer === undefined) return layerPath;
305
- return { path: recipeLayer.path, config: recipeLayer.config };
306
- });
307
-
308
- const kernelInput = JSON.stringify({
309
- sources,
310
- context: {
311
- sousDir,
312
- confDir,
313
- sousRootPath: CLI_ROOT,
314
- sousVersion: SOUS_VERSION,
315
- configPath,
316
- },
317
- trace: options.trace === true,
318
- });
319
-
320
- // Resolve tsx via module resolution so this works when npm hoists the
321
- // dependency (local install, npx) as well as when it nests it (global,
322
- // repo clone).
323
- let tsxPath: string;
324
- try {
325
- tsxPath = createRequire(import.meta.url).resolve("tsx/esm");
326
- } catch {
327
- tsxPath = path.resolve(CLI_ROOT, "node_modules/tsx/dist/esm/index.cjs");
328
- }
329
-
330
- const attempts: { label: string; args: string[] }[] = [
331
- { label: "node", args: [CONFIG_KERNEL_PATH] },
332
- // `--import` takes a module specifier, so the path goes as a file:// URL:
333
- // a bare Windows path reads as a URL whose scheme is the drive letter.
334
- { label: "tsx", args: ["--import", pathToFileURL(tsxPath).href, CONFIG_KERNEL_PATH] },
335
- ];
336
-
337
- const failures: { label: string; message: string }[] = [];
338
-
339
- for (const attempt of attempts) {
340
- const result = spawnSync(process.execPath, attempt.args, {
341
- input: kernelInput,
342
- encoding: "utf8",
343
- });
344
-
345
- if (result.status === 0) {
346
- let parsed: { config: unknown; layers?: SettingsLayer[] };
347
- try {
348
- parsed = JSON.parse(result.stdout) as { config: unknown; layers?: SettingsLayer[] };
349
- } catch {
350
- throw new Error(
351
- `Config at ${configPath} did not produce valid JSON. Every layer must resolve ` +
352
- `to a plain object (a \`config\`/default export, or a \`configure\` function).\n` +
353
- ` Got: ${result.stdout.slice(0, 300)}`
354
- );
355
- }
356
- // assertFlatConfig runs FIRST: its multi-project migration message is more
357
- // actionable than a generic unknown-key error. validateSettings then checks
358
- // the MERGED config against the zod schema (version, strict keys, shapes).
359
- const flat = assertFlatConfig(parsed.config, configPath);
360
- // The built-in repository and the implicit `core` subscription are added
361
- // UNDER whatever the layers produced, and BEFORE validation, so that the
362
- // shortest opt-out a project can write (`{ enabled: false }`) is a
363
- // complete, valid entry once the built-in fields are underneath it. See
364
- // `repos/defaults.ts`.
365
- return {
366
- settings: validateSettings(applyRepoDefaults(flat), configPath),
367
- layers: parsed.layers ?? [],
368
- };
369
- }
370
-
371
- failures.push({ label: attempt.label, message: result.stderr?.trim() || "unknown error" });
372
- }
373
-
374
- // Kernel-side errors (bad JSON/YAML, old schema, configure() throw, cycle,
375
- // bad builder var, non-object layer) are raised inside the .mjs kernel, which
376
- // runs identically under both the `node` and `tsx` attempts — so both stderrs
377
- // are the same. Collapse identical messages so a single config error is not
378
- // printed twice as if it were two distinct failures.
379
- //
380
- // The tsx attempt re-imports an ESM `.js` config that the `node` attempt
381
- // already loaded and reported a real error for; on the tsx attempt this
382
- // surfaces as a spurious "Cannot require() ES Module … in a cycle"
383
- // (ERR_REQUIRE_CYCLE_MODULE) that is never a real user-config problem. Drop it
384
- // whenever another attempt produced a genuine error, so the real message is
385
- // not buried under an unrelated require(esm)-cycle warning.
386
- const isTsxCycleArtifact = (message: string): boolean =>
387
- /ERR_REQUIRE_CYCLE_MODULE/.test(message) ||
388
- /Cannot require\(\) ES Module .* in a cycle/.test(message);
389
- const meaningfulFailures = failures.filter((f) => !isTsxCycleArtifact(f.message));
390
- const effectiveFailures = meaningfulFailures.length > 0 ? meaningfulFailures : failures;
391
-
392
- const uniqueMessages = [...new Set(effectiveFailures.map((f) => f.message))];
393
- const detail =
394
- uniqueMessages.length === 1
395
- ? ` ${uniqueMessages[0]}`
396
- : effectiveFailures.map((f) => ` [via ${f.label}] ${f.message}`).join("\n\n");
397
-
398
- throw new ConfigError(`Failed to load config from ${configPath}\n${detail}`);
399
- }
400
-
401
- /**
402
- * Loads the merged settings for a discovered config (or a bare config file
403
- * path). Thin wrapper over loadSettingsWithLayers for callers that do not need
404
- * the trace-mode layer snapshots.
405
- */
406
- export async function loadSettings(
407
- source: DiscoveredConfig | string,
408
- options: LoadSettingsOptions = {}
409
- ): Promise<Settings> {
410
- const { settings } = await loadSettingsWithLayers(source, options);
411
- return settings;
412
- }
413
-
414
- /**
415
- * Rejects configs written in the removed multi-project schema. One config now
416
- * describes exactly one project; the fields that used to live inside a
417
- * `projects.<key>` entry sit at the top level instead.
418
- */
419
- function assertFlatConfig(raw: unknown, configPath: string): Settings {
420
- if (
421
- raw !== null &&
422
- typeof raw === "object" &&
423
- ("projects" in raw || "defaultProject" in raw)
424
- ) {
425
- throw new ConfigError(
426
- `Config at ${configPath} uses the removed multi-project schema ` +
427
- `('projects' / 'defaultProject').\n` +
428
- ` A sous config now describes exactly one project. To migrate:\n` +
429
- ` 1. Move your single project's fields (name, _vars, _aliases, compilation,\n` +
430
- ` runtimeContext, tools) to the top level of the config.\n` +
431
- ` 2. Delete the 'projects' and 'defaultProject' keys.\n` +
432
- ` A config with several projects must be split into one config per project.`
433
- );
434
- }
435
- return raw as Settings;
436
- }
437
-
438
- // --- Variable Resolution -------------------------------------------------------------------------
439
-
440
- /**
441
- * Substitutes ${varName} references in a string using the provided scope.
442
- * Unknown variable references are left as-is.
443
- */
444
- export function substituteVars(str: string, scope: VarScope): string {
445
- return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
446
- }
447
-
448
- /** Returns the names of every `${var}` reference left unresolved in a string. */
449
- export function findUnresolvedVars(str: string): string[] {
450
- return [...new Set([...str.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1]))];
451
- }
452
-
453
- /**
454
- * Collapses `.` and `..` segments in an already-substituted absolute path.
455
- *
456
- * Output destinations must be normalized at resolution time, not left as written.
457
- * A config that names a directory as `${sousDir}/..` (the natural way to reach the
458
- * repo root from a discovered `.sous/`) otherwise produces a `destinationDir` of
459
- * `/repo/.sous/../.claude/skills`, while the file paths Sous actually writes go
460
- * through `path.join` and come out as `/repo/.claude/skills/...`. Prune compares
461
- * tracked destinations against `destinationDir` by string prefix, so the two forms
462
- * never match and prune deletes every file compile had just written.
463
- *
464
- * Relative values are left alone: they are resolved later against a context this
465
- * function does not have.
466
- *
467
- * @param value - A substituted path from the config.
468
- * @returns The normalized path, or `value` unchanged when it is not absolute.
469
- */
470
- export function normalizeConfigPath(value: string): string {
471
- return path.isAbsolute(value) ? path.normalize(value) : value;
472
- }
473
-
474
- /**
475
- * Substitutes `${varName}` references and throws when any reference cannot be
476
- * resolved from the scope. Used for every value Sous acts on (entry points,
477
- * destinations, prompt files, runtime-context paths) so a typo'd or missing
478
- * variable fails loudly instead of silently producing a literal `${var}` path.
479
- *
480
- * @param str - The raw value from the config.
481
- * @param scope - The resolved variable scope.
482
- * @param context - Where the value came from, e.g.
483
- * `compilation.targets[0].entryPoint`. Named in the error.
484
- * @returns The fully substituted string.
485
- * @throws When one or more `${var}` references are unresolved.
486
- */
487
- export function substituteVarsStrict(str: string, scope: VarScope, context: string): string {
488
- const result = substituteVars(str, scope);
489
- const unresolved = findUnresolvedVars(result);
490
-
491
- if (unresolved.length > 0) {
492
- const names = unresolved.map((n) => `\${${n}}`).join(", ");
493
- const plural = unresolved.length === 1 ? "variable" : "variables";
494
- const available = Object.keys(scope).sort();
495
- throw new ConfigError(
496
- `Unresolved ${plural} ${names} in ${context}\n` +
497
- ` raw value: ${str}\n` +
498
- ` Define ${unresolved.length === 1 ? "it" : "them"} in a _vars block, or map ` +
499
- `${unresolved.length === 1 ? "it" : "them"} from the environment via the top-level ` +
500
- `_env block (values can come from .sous/.env.local or .sous/.env).\n` +
501
- ` Variables in scope here: ${available.length > 0 ? available.join(", ") : "(none)"}`
502
- );
503
- }
504
-
505
- return result;
506
- }
507
-
508
- /**
509
- * Resolves a _vars block into a new scope with a FIXPOINT loop:
510
- *
511
- * 1. Start from the inherited scope. Every block entry begins unresolved.
512
- * 2. Each round, substitute every still-unresolved entry against the current
513
- * scope (inherited vars + entries already finalized this pass). An entry
514
- * whose `${refs}` all resolve is finalized and added to the scope.
515
- * 3. Repeat until a round finalizes nothing.
516
- *
517
- * Because each round re-scans every unresolved entry, declaration order does not
518
- * matter: `{ file: "${root}/x", root: "/data" }` resolves as readily as the
519
- * reverse. When progress stops with entries still unresolved, that is a hard
520
- * error (see buildUnresolvedScopeError): a `${ref}` that never resolves — a typo,
521
- * a cycle, or a name defined nowhere — is a config mistake, not a literal to be
522
- * passed through silently.
523
- */
524
- export function resolveScope(block: Record<string, string>, inherited: VarScope): VarScope {
525
- const blockKeys = Object.keys(block);
526
-
527
- // Warn if user defines vars in the reserved 'sous*' namespace
528
- for (const key of blockKeys) {
529
- if (key.startsWith("sous")) {
530
- console.warn(`Warning: variable '${key}' uses the reserved 'sous*' namespace and may conflict with auto-injected variables.`);
531
- }
532
- }
533
-
534
- const scope: VarScope = { ...inherited };
535
- const unresolved = new Set(blockKeys);
536
-
537
- let progressed = true;
538
- while (progressed && unresolved.size > 0) {
539
- progressed = false;
540
- for (const key of unresolved) {
541
- const substituted = substituteVars(block[key], scope);
542
- if (findUnresolvedVars(substituted).length === 0) {
543
- scope[key] = substituted;
544
- unresolved.delete(key);
545
- progressed = true;
546
- }
547
- }
548
- }
549
-
550
- if (unresolved.size > 0) {
551
- throw buildUnresolvedScopeError(block, scope, unresolved);
552
- }
553
-
554
- return scope;
555
- }
556
-
557
- /**
558
- * Builds the ConfigError thrown when resolveScope's fixpoint stops with entries
559
- * still unresolved. The message names each unresolved entry and the exact
560
- * `${names}` it still needs, then separates the two failure modes: reference
561
- * CYCLES (entries that depend on each other, no starting point) and UNDEFINED
562
- * references (names defined nowhere). It closes with the variables that ARE in
563
- * scope, so a typo is obvious at a glance.
564
- *
565
- * @param block - The raw _vars block being resolved.
566
- * @param scope - The working scope (inherited vars + every entry that DID
567
- * resolve); its keys are the "in scope" list.
568
- * @param unresolved - The block keys that never resolved.
569
- */
570
- function buildUnresolvedScopeError(
571
- block: Record<string, string>,
572
- scope: VarScope,
573
- unresolved: Set<string>
574
- ): ConfigError {
575
- // For each unresolved entry, the ${names} still missing after fixpoint. Every
576
- // such name is either another unresolved block key (an intra-block edge) or a
577
- // name defined nowhere (resolvable block keys and inherited vars are already
578
- // in `scope`, so they never appear here).
579
- const needs = new Map<string, string[]>();
580
- for (const key of unresolved) {
581
- needs.set(key, findUnresolvedVars(substituteVars(block[key], scope)));
582
- }
583
-
584
- // Dependency graph among unresolved entries: u -> v when u still needs the
585
- // still-unresolved block key v. SCCs of this graph are the reference cycles.
586
- const edges = new Map<string, string[]>();
587
- for (const key of unresolved) {
588
- edges.set(key, (needs.get(key) ?? []).filter((n) => unresolved.has(n)));
589
- }
590
- const cycles = findCycles([...unresolved], edges);
591
-
592
- // Undefined references: needed names that are not block keys at all.
593
- const undefinedRefs = new Map<string, string[]>();
594
- for (const key of unresolved) {
595
- for (const name of needs.get(key) ?? []) {
596
- if (!unresolved.has(name)) {
597
- const referrers = undefinedRefs.get(name) ?? [];
598
- referrers.push(key);
599
- undefinedRefs.set(name, referrers);
600
- }
601
- }
602
- }
603
-
604
- const lines: string[] = [
605
- "Unresolved variables after fixpoint resolution of a _vars block:",
606
- ];
607
- for (const key of [...unresolved].sort()) {
608
- const names = (needs.get(key) ?? []).map((n) => `\${${n}}`).join(", ");
609
- lines.push(` - ${key} still needs ${names || "(nothing resolvable)"}`);
610
- }
611
-
612
- if (cycles.length > 0) {
613
- lines.push("");
614
- lines.push("Reference cycles (these variables reference each other):");
615
- for (const cycle of cycles) {
616
- const sorted = [...cycle].sort();
617
- lines.push(` - ${[...sorted, sorted[0]].join(" -> ")}`);
618
- }
619
- }
620
-
621
- if (undefinedRefs.size > 0) {
622
- lines.push("");
623
- lines.push("Undefined references (names defined nowhere — no _vars, _env, or auto-var):");
624
- for (const name of [...undefinedRefs.keys()].sort()) {
625
- const referrers = [...new Set(undefinedRefs.get(name) ?? [])].sort();
626
- lines.push(` - \${${name}} (needed by ${referrers.join(", ")})`);
627
- }
628
- }
629
-
630
- const inScope = Object.keys(scope).sort();
631
- lines.push("");
632
- lines.push(`Variables in scope here: ${inScope.length > 0 ? inScope.join(", ") : "(none)"}`);
633
-
634
- return new ConfigError(lines.join("\n"));
635
- }
636
-
637
- /**
638
- * Finds reference cycles in a directed graph via Tarjan's strongly-connected-
639
- * component algorithm. A cycle is an SCC with more than one member, or a single
640
- * node that references itself. Returns each cycle as its member list.
641
- */
642
- function findCycles(nodes: string[], edges: Map<string, string[]>): string[][] {
643
- const index = new Map<string, number>();
644
- const low = new Map<string, number>();
645
- const onStack = new Set<string>();
646
- const stack: string[] = [];
647
- const sccs: string[][] = [];
648
- let counter = 0;
649
-
650
- function strongconnect(v: string): void {
651
- index.set(v, counter);
652
- low.set(v, counter);
653
- counter++;
654
- stack.push(v);
655
- onStack.add(v);
656
-
657
- for (const w of edges.get(v) ?? []) {
658
- if (!index.has(w)) {
659
- strongconnect(w);
660
- low.set(v, Math.min(low.get(v)!, low.get(w)!));
661
- } else if (onStack.has(w)) {
662
- low.set(v, Math.min(low.get(v)!, index.get(w)!));
663
- }
664
- }
665
-
666
- if (low.get(v) === index.get(v)) {
667
- const component: string[] = [];
668
- let w: string;
669
- do {
670
- w = stack.pop()!;
671
- onStack.delete(w);
672
- component.push(w);
673
- } while (w !== v);
674
- sccs.push(component);
675
- }
676
- }
677
-
678
- for (const v of nodes) {
679
- if (!index.has(v)) strongconnect(v);
680
- }
681
-
682
- return sccs.filter(
683
- (component) =>
684
- component.length > 1 || (edges.get(component[0]) ?? []).includes(component[0])
685
- );
686
- }
687
-
688
- /**
689
- * Where the active config came from. Threaded through variable resolution so
690
- * configs can reference their own `.sous/` directory and so error messages can
691
- * point at the right `.env.local` / `.env`.
692
- */
693
- export type ConfigContext = {
694
- /** Absolute path to the `.sous/` directory holding the config. */
695
- sousDir: string;
696
- /** Absolute path to the primary config file itself. */
697
- configPath: string;
698
- /** Absolute path to the `conf.d/` drop-in directory (may not exist). */
699
- confDir?: string;
700
- /** Ordered absolute paths of every loaded config layer (primary first). */
701
- layerPaths?: string[];
702
- };
703
-
704
- /**
705
- * Builds the auto-injected variable scope. These vars are always available
706
- * and injected first, before _env and _vars.
707
- * The 'sous*' namespace is reserved — warns if user defines a var starting with 'sous'.
708
- *
709
- * `sousHome` is resolved from `process.env` on every call rather than captured
710
- * once, because `SOUS_HOME` is file-settable: `.sous/.env.local` and
711
- * `.sous/.env` are loaded into `process.env` before settings resolve.
712
- *
713
- * @param context - The discovered config location. When supplied, adds
714
- * `sousDir` and `sousConfigPath` (plus `sousConfDir` when known) so configs
715
- * can build paths relative to their own `.sous/` directory.
716
- */
717
- export function buildAutoVars(context?: ConfigContext): VarScope {
718
- return {
719
- sousRootPath: CLI_ROOT,
720
- sousVersion: SOUS_VERSION,
721
- sousHome: resolveSousHome(),
722
- ...(context !== undefined && {
723
- sousDir: context.sousDir,
724
- sousConfigPath: context.configPath,
725
- }),
726
- ...(context?.confDir !== undefined && {
727
- sousConfDir: context.confDir,
728
- }),
729
- };
730
- }
731
-
732
- /**
733
- * Resolves the top-level _env block into a VarScope.
734
- * Each entry maps a config var name (key) to an environment variable name (value).
735
- * Throws a clear error if any referenced env var is not set.
736
- * Only called on the root Settings object — _env is top-level only.
737
- *
738
- * @param settings - The root settings object.
739
- * @param context - The discovered config location, used to name the
740
- * `.sous/.env.local` and `.sous/.env` files in the error message.
741
- */
742
- export function resolveEnvScope(settings: Settings, context?: ConfigContext): VarScope {
743
- const env = settings._env ?? {};
744
- const scope: VarScope = {};
745
-
746
- for (const [varName, envVarName] of Object.entries(env)) {
747
- const value = process.env[envVarName];
748
- if (value === undefined) {
749
- const envLocalPath = context
750
- ? path.join(context.sousDir, ENV_LOCAL_NAME)
751
- : `${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}`;
752
- const envDefaultsPath = context
753
- ? path.join(context.sousDir, ENV_DEFAULTS_NAME)
754
- : `${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}`;
755
- throw new ConfigError(
756
- `_env resolution failed: environment variable '${envVarName}' (mapped to config ` +
757
- `var '${varName}') is not set.\n` +
758
- ` Define it in ${envLocalPath} as:\n` +
759
- ` ${envVarName}=<value>\n` +
760
- ` ...or, if the value is shared by the whole team and is not a secret, in ` +
761
- `${envDefaultsPath} (committed).\n` +
762
- ` ...or export it in your shell before running sous.`
763
- );
764
- }
765
- scope[varName] = value;
766
- }
767
-
768
- return scope;
769
- }
770
-
771
- /** What else a root scope may be built from; see resolveRootScope. */
772
- export type RootScopeOptions = {
773
- /**
774
- * The recipe answers already resolved for this project. When omitted and a
775
- * config context is given, they are resolved here; pass them when a caller
776
- * has them already, so the lockfile and the manifests are read once.
777
- */
778
- answers?: RecipeAnswers;
779
- /**
780
- * The recipe whose own files this scope renders, as `namespace/recipe`. Its
781
- * own answers are laid over the merged view, so a recipe sees the answer to
782
- * its own question even when another recipe asks the same name.
783
- */
784
- recipe?: string;
785
- };
786
-
787
- /**
788
- * Resolves the root-level _vars from a Settings object into a scope.
789
- * Chains: auto-vars → recipe answers → env scope → root _vars.
790
- *
791
- * The recipe answers are the values the project's env files and shell hold for
792
- * every variable its subscribed recipes publish, found through the ladder in
793
- * `vars/ladder.ts`. They sit under `_env` and `_vars`, so an explicit config
794
- * value always wins, and they are present only when a config context says which
795
- * project this is; a settings object built by hand in a test has none.
796
- *
797
- * @param settings - The root settings object.
798
- * @param context - The discovered config location (optional in tests).
799
- * @param options - Answers already resolved, or the recipe the scope is for.
800
- */
801
- export function resolveRootScope(
802
- settings: Settings,
803
- context?: ConfigContext,
804
- options: RootScopeOptions = {}
805
- ): VarScope {
806
- const autoVars = buildAutoVars(context);
807
- const answers = resolveAnswerLayer(settings, context, options);
808
- const envScope = resolveEnvScope(settings, context);
809
- const baseScope = { ...autoVars, ...answers, ...envScope };
810
- return resolveScope(settings._vars ?? {}, baseScope);
811
- }
812
-
813
- /**
814
- * The recipe-answer layer of a root scope: the merged view, or a recipe's own
815
- * view of it. Empty without a config context.
816
- */
817
- function resolveAnswerLayer(
818
- settings: Settings,
819
- context: ConfigContext | undefined,
820
- options: RootScopeOptions
821
- ): VarScope {
822
- if (context === undefined) return {};
823
- const answers =
824
- options.answers ?? resolveRecipeAnswers({ settings, sousDir: context.sousDir });
825
- return options.recipe === undefined ? answers.merged : answersForRecipe(answers, options.recipe);
826
- }
827
-
828
- /**
829
- * Built-in `@include` aliases: every `#` name the registry holds
830
- * (`sharedHashNames`), each standing for its directories in this project. They
831
- * are reserved (user `_aliases` may not start with `#`), so a user alias can
832
- * never shadow one.
833
- *
834
- * - `#project` is the consuming project's root (`projectRoot`).
835
- *
836
- * Files that used to be reached through a built-in alias pointing inside the
837
- * sous package are published as recipes now, and a recipe's files are addressed
838
- * by its namespace (`@~workflow/task-files/_partials/resume-task.md`),
839
- * resolved against what the project has pinned. A `~` reference is NOT an
840
- * alias: it is resolved separately, after the alias map has been tried; see
841
- * `locked-namespace-resolver.ts`.
842
- */
843
- export function buildBuiltInAliases(scope: VarScope): AliasMap {
844
- return sharedHashNames().aliasMap(scope as Record<string, string>);
845
- }
846
-
847
- /**
848
- * Resolve the full `@include` alias map: built-ins, then the config's
849
- * `_aliases` block (user entries prepend, so they are tried first and fall
850
- * through to built-in bases of the same name). User alias names starting
851
- * with `~` or `#` are a ConfigError (reserved).
852
- *
853
- * @param settings - The loaded settings (for the `_aliases` block).
854
- * @param scope - The resolved settings scope (for ${var} substitution + projectRoot).
855
- */
856
- export function resolveAliases(settings: Settings, scope: VarScope): AliasMap {
857
- return buildAliasMap({
858
- builtIns: buildBuiltInAliases(scope),
859
- userAliases: [settings._aliases],
860
- scope,
861
- onError: (message) => {
862
- throw new ConfigError(message);
863
- },
864
- });
865
- }
866
-
867
- /** A resolved tool configuration with promptFile path substituted. */
868
- export type ResolvedToolConfig = {
869
- command: string;
870
- args?: string[];
871
- promptFile?: string;
872
- };
873
-
874
- /**
875
- * Resolves the config's tools block, substituting vars in promptFile paths.
876
- * Returns an empty object if no tools are configured.
877
- *
878
- * @param settings - The loaded settings.
879
- * @param scope - The resolved settings scope (from resolveRootScope).
880
- */
881
- export function resolveTools(
882
- settings: Settings,
883
- scope: VarScope = {}
884
- ): Record<string, ResolvedToolConfig> {
885
- if (!settings.tools) return {};
886
-
887
- return Object.fromEntries(
888
- Object.entries(settings.tools).map(([name, tool]) => [
889
- name,
890
- {
891
- command: tool.command,
892
- ...(tool.args !== undefined && { args: tool.args }),
893
- ...(tool.promptFile !== undefined && {
894
- promptFile: substituteVarsStrict(
895
- tool.promptFile,
896
- scope,
897
- `tools.${name}.promptFile`
898
- ),
899
- }),
900
- },
901
- ])
902
- );
903
- }
904
-
905
- // --- Compilation Resolution ----------------------------------------------------------------------
906
-
907
- /**
908
- * Resolves a RawRuntimeContext into a ResolvedRuntimeContext by substituting
909
- * ${var} references and converting branchPattern from string to RegExp.
910
- */
911
- function resolveRuntimeContext(
912
- raw: RawRuntimeContext,
913
- scope: VarScope,
914
- context: string
915
- ): ResolvedRuntimeContext {
916
- return {
917
- gitRoot: substituteVarsStrict(raw.gitRoot, scope, `${context}.gitRoot`),
918
- outputPath: substituteVarsStrict(raw.outputPath, scope, `${context}.outputPath`),
919
- taskFileRoot: substituteVarsStrict(raw.taskFileRoot, scope, `${context}.taskFileRoot`),
920
- branchPattern: new RegExp(raw.branchPattern ?? "PT-"),
921
- };
922
- }
923
-
924
- /**
925
- * Resolves the config's compilation block into the shape the compiler expects.
926
- * Walks the config tree resolving _vars at each level (settings → compilation → target → output).
927
- * Pass scope from resolveRootScope(settings) to thread the settings vars down.
928
- * Returns null if the config has no compilation block.
929
- */
930
- export function resolveCompilation(
931
- settings: Settings,
932
- scope: VarScope = {}
933
- ): CompilationConfig | null {
934
- if (!settings.compilation) return null;
935
-
936
- const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
937
- const aliases = resolveAliases(settings, scope);
938
-
939
- return {
940
- includeSourceComments: settings.compilation.includeSourceComments,
941
- aliases,
942
- includeScope: scope,
943
- targets: settings.compilation.targets.flatMap((target, targetIndex): CompilationTarget[] => {
944
- const targetScope = resolveScope(target._vars ?? {}, compilationScope);
945
- const where = `compilation.targets[${targetIndex}]`;
946
- const hasSingle = target.entryPoint !== undefined;
947
- const hasGlob = target.entryGlob !== undefined;
948
-
949
- if (hasSingle && hasGlob) {
950
- throw new Error("Target cannot have both entryPoint and entryGlob");
951
- }
952
- if (!hasSingle && !hasGlob) {
953
- throw new Error("Target must have either entryPoint or entryGlob");
954
- }
955
-
956
- /**
957
- * Resolves the outputs array for a target, filtering by _if conditions and substituting vars.
958
- */
959
- function resolveOutputs(scope: VarScope) {
960
- return target.outputs
961
- .filter(output => {
962
- if (!output._if) return true;
963
- const outputScope = resolveScope(output._vars ?? {}, scope);
964
- return Object.entries(output._if).every(([varName, condition]) => {
965
- const val = outputScope[varName] ?? scope[varName] ?? compilationScope[varName];
966
- return val === condition.eq;
967
- });
968
- })
969
- .map((output, outputIndex) => {
970
- const outputScope = resolveScope(output._vars ?? {}, scope);
971
- const outputWhere = `${where}.outputs[${outputIndex}]`;
972
- return {
973
- ...(output.destinationFile !== undefined && {
974
- destinationFile: normalizeConfigPath(
975
- substituteVarsStrict(
976
- output.destinationFile,
977
- outputScope,
978
- `${outputWhere}.destinationFile`
979
- )
980
- ),
981
- }),
982
- ...(output.destinationDir !== undefined && {
983
- destinationDir: normalizeConfigPath(
984
- substituteVarsStrict(
985
- output.destinationDir,
986
- outputScope,
987
- `${outputWhere}.destinationDir`
988
- )
989
- ),
990
- }),
991
- vars: outputScope,
992
- };
993
- });
994
- }
995
-
996
- if (hasSingle) {
997
- const runtimeContext =
998
- target.generateRuntimeContext && settings.runtimeContext
999
- ? resolveRuntimeContext(settings.runtimeContext, scope, "runtimeContext")
1000
- : undefined;
1001
- return [{
1002
- rootInputPath: normalizeConfigPath(
1003
- substituteVarsStrict(target.entryPoint!, targetScope, `${where}.entryPoint`)
1004
- ),
1005
- ...(runtimeContext !== undefined && { runtimeContext }),
1006
- outputs: resolveOutputs(targetScope),
1007
- }];
1008
- }
1009
-
1010
- /* c8 ignore start */
1011
- // Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs.
1012
- // A leading alias (`#project/skills/**`) expands to one candidate pattern per alias
1013
- // base; the first base that matches any files wins, mirroring the first-existing-wins
1014
- // rule of @include resolution.
1015
- const rawPattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
1016
- const patternCandidates = resolveAliasPrefix(rawPattern, aliases).map(toGlobPattern);
1017
- let pattern = patternCandidates[0];
1018
- let matchedFiles: string[] = [];
1019
- for (const candidate of patternCandidates) {
1020
- const matches = globSync(candidate, { absolute: true })
1021
- .filter(filePath => fs.statSync(filePath).isFile());
1022
- if (matches.length > 0) {
1023
- pattern = candidate;
1024
- matchedFiles = matches;
1025
- break;
1026
- }
1027
- }
1028
-
1029
- if (matchedFiles.length === 0) {
1030
- warning(`Glob pattern matched no files:\n${patternCandidates.join("\n")}`);
1031
- }
1032
-
1033
- const globBase = normalizeConfigPath(
1034
- target.globBase
1035
- ? substituteVarsStrict(target.globBase, targetScope, `${where}.globBase`)
1036
- : inferGlobBase(pattern)
1037
- );
1038
- return matchedFiles.map(filePath => ({
1039
- rootInputPath: filePath,
1040
- globBase,
1041
- outputs: resolveOutputs(targetScope),
1042
- }));
1043
- /* c8 ignore stop */
1044
- }),
1045
- };
1046
- }
1047
-
1048
- /** Resolved watch configuration for a project. */
1049
- export type WatchConfig = {
1050
- /** Exact file paths — watched directly by chokidar. Trigger partial rebuilds. */
1051
- files: string[];
1052
- /**
1053
- * Glob patterns — chokidar watches their base directories; incoming events are
1054
- * filtered against these patterns before triggering a partial rebuild.
1055
- */
1056
- globs: string[];
1057
- /**
1058
- * Additional paths (files or directories) that trigger a full rebuild when changed.
1059
- * Used for the settings file, templating directory, and config imports.
1060
- */
1061
- fullRebuildPaths?: string[];
1062
- };
1063
-
1064
- /**
1065
- * Returns the watch configuration for the config's compilation targets.
1066
- *
1067
- * - entryPoint targets → exact file path in `files`.
1068
- * - entryGlob targets → resolved glob string in `globs`.
1069
- *
1070
- * Alias prefixes in entryGlob patterns are expanded (every base of the alias
1071
- * is watched, matching compile's fall-through resolution).
1072
- */
1073
- export function resolveWatchConfig(settings: Settings, scope: VarScope = {}): WatchConfig {
1074
- if (!settings.compilation) return { files: [], globs: [] };
1075
-
1076
- const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
1077
- const aliases = resolveAliases(settings, scope);
1078
- const files: string[] = [];
1079
- const globs: string[] = [];
1080
-
1081
- for (const [targetIndex, target] of settings.compilation.targets.entries()) {
1082
- const targetScope = resolveScope(target._vars ?? {}, compilationScope);
1083
- const where = `compilation.targets[${targetIndex}]`;
1084
-
1085
- if (target.entryPoint) {
1086
- // Normalize to match the compilation path (rootInputPath) and, more
1087
- // importantly, chokidar's change events: chokidar reports the OS-normalized
1088
- // path, so an unnormalized `${sousDir}/../prompts/A.md` entry would never
1089
- // string-match the `/prompts/A.md` event and the partial rebuild would never fire.
1090
- files.push(
1091
- normalizeConfigPath(
1092
- substituteVarsStrict(target.entryPoint, targetScope, `${where}.entryPoint`)
1093
- )
1094
- );
1095
- }
1096
-
1097
- if (target.entryGlob) {
1098
- const pattern = substituteVarsStrict(target.entryGlob, targetScope, `${where}.entryGlob`);
1099
- globs.push(...resolveAliasPrefix(pattern, aliases).map(normalizeConfigPath));
1100
- }
1101
- }
1102
-
1103
- return {
1104
- files: [...new Set(files)],
1105
- globs: [...new Set(globs)],
1106
- };
1107
- }