@sous-io/sous 0.1.0 → 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 (206) hide show
  1. package/README.md +121 -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 +73 -9
  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/bin/xcv +0 -5
  166. package/shared-prompts/_partials/resume-task.md +0 -51
  167. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  168. package/shared-prompts/_partials/update-task-file.md +0 -52
  169. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  189. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  190. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  191. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  192. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  193. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  194. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  195. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  196. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  197. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  198. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  199. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  200. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  201. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  202. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  203. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  204. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  206. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Validating an answer against the definition that asked for it.
3
+ *
4
+ * A recipe declares a small, deliberately boring constraint vocabulary (a type,
5
+ * plus `pattern`, `minLength`, `maxLength`, `min`, `max` and `enum`), all of it
6
+ * expressible in plain JSON so a manifest never has to carry code. This module
7
+ * turns that vocabulary into a zod schema and reports a failure in the same
8
+ * plain language the question was asked in, naming the constraint that was
9
+ * violated rather than dumping a schema error.
10
+ *
11
+ * Answers are stored as text in env files, so validation always starts from a
12
+ * string and hands back the coerced value.
13
+ *
14
+ * A `pattern` comes from a recipe, which is code from somewhere else, so it is
15
+ * never run on this thread without a limit; see `safe-regex.ts`. A pattern that
16
+ * exceeds its budget fails validation, and the failure says so plainly rather
17
+ * than telling the person that their answer is wrong.
18
+ */
19
+
20
+ import { z } from "zod";
21
+ import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
22
+ import { DEFAULT_PATTERN_BUDGET_MS, matchWithBudget } from "./safe-regex.js";
23
+
24
+ /** The value an answer coerces to, once it has been validated. */
25
+ export type AnswerValue = string | number | boolean;
26
+
27
+ /** What the caller knows about where a definition came from, for messages. */
28
+ export interface ValidationContext {
29
+ /**
30
+ * The recipe that published the definition, named the way it should read in a
31
+ * message (for example 'acme/web-app'). Omitted when the caller does not know
32
+ * it, in which case messages describe it in words instead.
33
+ */
34
+ recipe?: string;
35
+ /** How long a declared pattern may run, in milliseconds. */
36
+ patternBudgetMs?: number;
37
+ }
38
+
39
+ /** A validated answer, or the reason it was refused. */
40
+ export type AnswerValidation =
41
+ | { ok: true; value: AnswerValue }
42
+ | { ok: false; message: string };
43
+
44
+ /** The strings accepted as a true answer for a boolean variable. */
45
+ const TRUE_WORDS = new Set(["true", "yes", "y", "on", "1"]);
46
+
47
+ /** The strings accepted as a false answer for a boolean variable. */
48
+ const FALSE_WORDS = new Set(["false", "no", "n", "off", "0"]);
49
+
50
+ /**
51
+ * Builds the zod schema for one definition. The schema's input is the trimmed
52
+ * answer text and its output is the coerced value, so callers get the number or
53
+ * boolean a `number` or `boolean` variable promised.
54
+ *
55
+ * @param definition - The variable definition to build a schema for.
56
+ * @param context - What is known about the recipe, for the pattern message.
57
+ */
58
+ export function validationSchemaFor(
59
+ definition: VariableDefinition,
60
+ context: ValidationContext = {}
61
+ ): z.ZodType<AnswerValue> {
62
+ const rules = definition.validate;
63
+
64
+ switch (definition.type) {
65
+ case "number": {
66
+ const schema = z
67
+ .string()
68
+ .refine((value) => value.length > 0 && Number.isFinite(Number(value)), {
69
+ message: "must be a number",
70
+ })
71
+ .transform((value) => Number(value))
72
+ .pipe(numberRules());
73
+ return schema as unknown as z.ZodType<AnswerValue>;
74
+ }
75
+
76
+ case "boolean": {
77
+ const schema = z
78
+ .string()
79
+ .refine((value) => TRUE_WORDS.has(value.toLowerCase()) || FALSE_WORDS.has(value.toLowerCase()), {
80
+ message: "must be 'true' or 'false'",
81
+ })
82
+ .transform((value) => TRUE_WORDS.has(value.toLowerCase()));
83
+ return schema as unknown as z.ZodType<AnswerValue>;
84
+ }
85
+
86
+ case "enum": {
87
+ const options = rules?.enum ?? [];
88
+ const schema = z.string().refine((value) => options.includes(value), {
89
+ message: `must be one of: ${options.join(", ")}`,
90
+ });
91
+ return stringRules(schema) as unknown as z.ZodType<AnswerValue>;
92
+ }
93
+
94
+ case "url": {
95
+ const schema = z.string().refine(
96
+ (value) => {
97
+ try {
98
+ const parsed = new URL(value);
99
+ return parsed.protocol.length > 1;
100
+ } catch {
101
+ return false;
102
+ }
103
+ },
104
+ { message: "must be a URL, including its scheme (for example 'https://example.com')" }
105
+ );
106
+ return stringRules(schema) as unknown as z.ZodType<AnswerValue>;
107
+ }
108
+
109
+ case "path": {
110
+ const schema = z.string().refine((value) => !value.includes("\0"), {
111
+ message: "must be a filesystem path, with no null characters in it",
112
+ });
113
+ return stringRules(schema) as unknown as z.ZodType<AnswerValue>;
114
+ }
115
+
116
+ case "string":
117
+ default:
118
+ return stringRules(z.string()) as unknown as z.ZodType<AnswerValue>;
119
+ }
120
+
121
+ /** Applies the numeric constraints to a number schema. */
122
+ function numberRules(): z.ZodType<number, number> {
123
+ let schema = z.number();
124
+ if (rules?.min !== undefined) schema = schema.min(rules.min, `must be at least ${rules.min}`);
125
+ if (rules?.max !== undefined) schema = schema.max(rules.max, `must be at most ${rules.max}`);
126
+ return schema;
127
+ }
128
+
129
+ /** Applies the string constraints, including the required-means-non-empty rule. */
130
+ function stringRules(base: z.ZodType<string>): z.ZodType<string> {
131
+ let schema: z.ZodType<string> = base;
132
+
133
+ if (definition.required) {
134
+ schema = schema.refine((value) => value.length > 0, { message: "must not be empty" });
135
+ }
136
+ if (rules?.minLength !== undefined) {
137
+ schema = schema.refine((value) => value.length >= rules.minLength!, {
138
+ message: `must be at least ${rules.minLength} characters long`,
139
+ });
140
+ }
141
+ if (rules?.maxLength !== undefined) {
142
+ schema = schema.refine((value) => value.length <= rules.maxLength!, {
143
+ message: `must be at most ${rules.maxLength} characters long`,
144
+ });
145
+ }
146
+ if (rules?.pattern !== undefined) {
147
+ const pattern = rules.pattern;
148
+ const budget = context.patternBudgetMs ?? DEFAULT_PATTERN_BUDGET_MS;
149
+ schema = schema.superRefine((value, ctx) => {
150
+ const outcome = matchWithBudget(pattern, value, budget);
151
+ if (outcome === "match") return;
152
+ ctx.addIssue({
153
+ code: "custom",
154
+ message:
155
+ outcome === "timeout"
156
+ ? patternTooSlowMessage(pattern, budget, context.recipe)
157
+ : `must match the pattern ${pattern}`,
158
+ });
159
+ });
160
+ }
161
+
162
+ return schema;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The failure for a pattern that ran out of time. It names the pattern and the
168
+ * recipe that published it, and says plainly that the pattern is the problem;
169
+ * the person answering has no way to write an answer that a runaway pattern
170
+ * would finish on.
171
+ *
172
+ * @param pattern - The regular expression source the recipe declared.
173
+ * @param budgetMs - The budget it exceeded, in milliseconds.
174
+ * @param recipe - The recipe that published it, when the caller knows it.
175
+ */
176
+ function patternTooSlowMessage(
177
+ pattern: string,
178
+ budgetMs: number,
179
+ recipe?: string
180
+ ): string {
181
+ const publisher = recipe === undefined ? "the recipe that defines it" : `the recipe ${recipe}`;
182
+ return (
183
+ `could not be checked: the pattern ${pattern}, published by ${publisher}, ` +
184
+ `took longer than ${budgetMs} milliseconds to run, so sous stopped waiting ` +
185
+ "for it. The pattern is too slow to run, and the answer was not the problem; " +
186
+ "this needs to be reported to whoever publishes the recipe"
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Validates one answer against its definition.
192
+ *
193
+ * @param definition - The variable definition the answer is for.
194
+ * @param raw - The answer as text, as typed or as read from an env file.
195
+ * @param context - What is known about the recipe, for the pattern message.
196
+ * @returns The coerced value, or a plain-language message naming what is wrong.
197
+ */
198
+ export function validateAnswer(
199
+ definition: VariableDefinition,
200
+ raw: string,
201
+ context: ValidationContext = {}
202
+ ): AnswerValidation {
203
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
204
+
205
+ if (trimmed.length === 0 && !definition.required) {
206
+ return { ok: true, value: "" };
207
+ }
208
+
209
+ const result = validationSchemaFor(definition, context).safeParse(trimmed);
210
+ if (result.success) return { ok: true, value: result.data };
211
+
212
+ const first = result.error.issues[0];
213
+ const reason = first?.message ?? "is not valid";
214
+ return { ok: false, message: `${definition.name} ${reason}.` };
215
+ }
216
+
217
+ /**
218
+ * The form an answer is STORED in. Env files hold text, so a coerced number or
219
+ * boolean goes back to its canonical string.
220
+ *
221
+ * @param value - The coerced answer.
222
+ */
223
+ export function storedForm(value: AnswerValue): string {
224
+ return typeof value === "boolean" ? String(value) : String(value);
225
+ }
226
+
227
+ /**
228
+ * A short, plain-language summary of a definition's constraints, shown beside
229
+ * the question and by `sous vars <name>`. Returns an empty array when the
230
+ * definition constrains nothing beyond its type.
231
+ *
232
+ * @param definition - The variable definition.
233
+ */
234
+ export function constraintHints(definition: VariableDefinition): string[] {
235
+ const hints: string[] = [];
236
+ const rules = definition.validate;
237
+
238
+ hints.push(`type: ${definition.type}`);
239
+ if (definition.type === "enum" && rules?.enum !== undefined) {
240
+ hints.push(`one of: ${rules.enum.join(", ")}`);
241
+ }
242
+ if (rules?.minLength !== undefined) hints.push(`at least ${rules.minLength} characters`);
243
+ if (rules?.maxLength !== undefined) hints.push(`at most ${rules.maxLength} characters`);
244
+ if (rules?.min !== undefined) hints.push(`no less than ${rules.min}`);
245
+ if (rules?.max !== undefined) hints.push(`no more than ${rules.max}`);
246
+ if (rules?.pattern !== undefined) hints.push(`matching ${rules.pattern}`);
247
+ if (!definition.required) hints.push("optional");
248
+
249
+ return hints;
250
+ }
251
+
252
+ /**
253
+ * A character count with the noun in the right number.
254
+ *
255
+ * @param count - How many characters.
256
+ */
257
+ function characters(count: number): string {
258
+ return count === 1 ? "1 character" : `${count} characters`;
259
+ }
260
+
261
+ /**
262
+ * One sentence per constraint, in plain words with the raw form the manifest
263
+ * declared in parentheses, so a reader can both understand the rule and find it
264
+ * in the recipe that published it. This is what the advanced view and
265
+ * `sous vars show` list under `@constraints`.
266
+ *
267
+ * @param definition - The variable definition.
268
+ * @returns One sentence per constraint, type first.
269
+ *
270
+ * @example
271
+ * constraintBullets(definition);
272
+ * // -> ["must be a value of the type url (type: url)", "must match the pattern /^https/ (pattern: ^https)"]
273
+ */
274
+ export function constraintBullets(definition: VariableDefinition): string[] {
275
+ const rules = definition.validate;
276
+ const options = rules?.enum;
277
+
278
+ // The type sentence never puts an article in front of the type name, because
279
+ // the article would have to change with the name ('a path', but 'an enum').
280
+ // An enum is described by the options it allows, which says more than the
281
+ // word 'enum' does and reads as one sentence rather than two.
282
+ const bullets: string[] =
283
+ definition.type === "enum" && options !== undefined
284
+ ? [`must be one of: ${options.join(", ")} (type: enum)`]
285
+ : [`must be a value of the type ${definition.type} (type: ${definition.type})`];
286
+
287
+ if (options !== undefined && definition.type !== "enum") {
288
+ bullets.push(`must be one of: ${options.join(", ")} (enum: ${options.join(", ")})`);
289
+ }
290
+ if (rules?.minLength !== undefined) {
291
+ bullets.push(
292
+ `must be at least ${characters(rules.minLength)} long (minLength: ${rules.minLength})`
293
+ );
294
+ }
295
+ if (rules?.maxLength !== undefined) {
296
+ bullets.push(
297
+ `must be at most ${characters(rules.maxLength)} long (maxLength: ${rules.maxLength})`
298
+ );
299
+ }
300
+ if (rules?.min !== undefined) {
301
+ bullets.push(`must be no less than ${rules.min} (min: ${rules.min})`);
302
+ }
303
+ if (rules?.max !== undefined) {
304
+ bullets.push(`must be no more than ${rules.max} (max: ${rules.max})`);
305
+ }
306
+ if (rules?.pattern !== undefined) {
307
+ bullets.push(`must match the pattern /${rules.pattern}/ (pattern: ${rules.pattern})`);
308
+ }
309
+ if (!definition.required) bullets.push("an answer is optional");
310
+
311
+ return bullets;
312
+ }
@@ -0,0 +1,148 @@
1
+ import path from "node:path";
2
+ import {
3
+ CLI_ROOT,
4
+ resolveRootScope,
5
+ resolveWatchConfig,
6
+ type ConfigContext,
7
+ type Settings,
8
+ type WatchConfig,
9
+ } from "./settings.js";
10
+ import type { WatchHandle, WatchService } from "./watch-service.js";
11
+ import { readEffectiveLinks } from "./repos/links.js";
12
+ import { log } from "../utils/formatting.js";
13
+
14
+ /**
15
+ * Builds a WatchConfig from current settings, injecting the primary config
16
+ * file, the conf.d/ drop-in DIRECTORY, the templating directory and every linked
17
+ * repository's working copy into fullRebuildPaths. Watching a directory (not
18
+ * each file inside it) covers files appearing, changing, or disappearing at
19
+ * runtime, since full-rebuild matching is exact-or-directory-prefix.
20
+ *
21
+ * A linked repository is watched because that is the whole point of a link: the
22
+ * recipes are being edited right now, in that checkout, and a watch that ignored
23
+ * them would make the link useless.
24
+ *
25
+ * Shared by `build --watch` and `compile --watch` so both react to config,
26
+ * template and linked-recipe edits identically.
27
+ */
28
+ export function buildReloadWatchConfig(
29
+ settings: Settings,
30
+ configContext: ConfigContext
31
+ ): WatchConfig {
32
+ const rootScope = resolveRootScope(settings, configContext);
33
+ const config = resolveWatchConfig(settings, rootScope);
34
+ const links = readEffectiveLinks(configContext.sousDir);
35
+ const reloadPaths = [
36
+ configContext.configPath,
37
+ configContext.confDir,
38
+ path.join(CLI_ROOT, "src", "templating"),
39
+ ...Object.values(links).map((link) => link.path),
40
+ ].filter((p): p is string => typeof p === "string");
41
+ config.fullRebuildPaths = [...(config.fullRebuildPaths ?? []), ...reloadPaths];
42
+ return config;
43
+ }
44
+
45
+ /** Options for {@link startConfigReloadWatch}. */
46
+ export type ConfigReloadWatchOptions = {
47
+ /** The watcher factory used to (re)start chokidar. */
48
+ watchService: WatchService;
49
+ /**
50
+ * Produces a fresh WatchConfig from the CURRENT settings each time a watcher
51
+ * is (re)started; typically `() => buildReloadWatchConfig(this.settings, this.configContext)`.
52
+ */
53
+ buildWatchConfig: () => WatchConfig;
54
+ /**
55
+ * Performs the actual work (build/compile) using the command's CURRENT
56
+ * settings. Called for partial rebuilds (with the changed file) and, after a
57
+ * successful reload, for full rebuilds (no argument). Owns its own
58
+ * heading/footer output.
59
+ */
60
+ rebuild: (changedFile?: string) => Promise<void>;
61
+ /**
62
+ * Re-runs discovery + settings load and commits the result onto the command,
63
+ * but only if it loads cleanly (last-good semantics). Throws on failure.
64
+ */
65
+ reloadConfig: () => Promise<void>;
66
+ };
67
+
68
+ /** The running watch loop; exposes the live handle and a manual full-rebuild trigger. */
69
+ export type ConfigReloadWatchController = {
70
+ /** Mutable reference to the live watcher handle (swapped on every restart). */
71
+ handle: { current: WatchHandle | null };
72
+ /** Triggers a full rebuild immediately, bypassing the debounce (e.g. a keypress). */
73
+ triggerFullRebuild: (reason: string) => Promise<void>;
74
+ };
75
+
76
+ /**
77
+ * Runs the shared watch loop used by `build --watch` and `compile --watch`.
78
+ *
79
+ * - Partial events (a watched source file changed) call `rebuild(filePath)`.
80
+ * - Full events (config file, conf.d directory, or templating dir changed) stop
81
+ * the watcher, reload settings via `reloadConfig`, rebuild, and restart the
82
+ * watcher. A failed reload is reported without wedging the session: the
83
+ * last-good config stays in place and the watcher restarts so the next edit
84
+ * can recover.
85
+ *
86
+ * A single `isRebuilding` guard serialises overlapping triggers.
87
+ */
88
+ export function startConfigReloadWatch(
89
+ options: ConfigReloadWatchOptions
90
+ ): ConfigReloadWatchController {
91
+ const { watchService, buildWatchConfig, rebuild, reloadConfig } = options;
92
+
93
+ let isRebuilding = false;
94
+ const handle: { current: WatchHandle | null } = { current: null };
95
+
96
+ const startWatcher = () => {
97
+ const watchConfig = buildWatchConfig();
98
+ handle.current = watchService.watch(watchConfig, async (event) => {
99
+ if (event.type === "partial") {
100
+ if (isRebuilding) return;
101
+ isRebuilding = true;
102
+ log(`\nChange detected: ${event.filePath}`);
103
+ await rebuild(event.filePath);
104
+ isRebuilding = false;
105
+ } else {
106
+ // Full rebuild: stop current watcher, reload settings, restart
107
+ if (isRebuilding) return;
108
+ isRebuilding = true;
109
+ log(`\nConfig changed (${event.filePath}), reloading settings and restarting watcher...`);
110
+ await handle.current!.stop();
111
+
112
+ try {
113
+ // Re-run discovery: conf.d layer files can appear or disappear while
114
+ // watching, so the ordered layer list must be rebuilt (and the
115
+ // duplicate-baseName check re-run) before reloading settings. Only
116
+ // committed by reloadConfig once it loads cleanly.
117
+ await reloadConfig();
118
+ await rebuild();
119
+ } catch (error) {
120
+ // A broken config edit (bad JSON/JS, colliding conf.d baseNames,
121
+ // configure() throw, etc.) must not wedge the session: report it,
122
+ // keep the last-good config, and fall through to restart the watcher
123
+ // so the next edit can recover.
124
+ log(
125
+ `\nConfig reload failed; keeping the last-good config. Fix the config and save again to retry.\n ${
126
+ error instanceof Error ? error.message : String(error)
127
+ }`
128
+ );
129
+ } finally {
130
+ isRebuilding = false;
131
+ startWatcher();
132
+ }
133
+ }
134
+ });
135
+ };
136
+
137
+ startWatcher();
138
+
139
+ const triggerFullRebuild = async (reason: string) => {
140
+ if (isRebuilding) return;
141
+ isRebuilding = true;
142
+ log(`\n${reason}`);
143
+ await rebuild();
144
+ isRebuilding = false;
145
+ };
146
+
147
+ return { handle, triggerFullRebuild };
148
+ }
@@ -3,7 +3,11 @@ import path from "node:path";
3
3
  import { Liquid, type FS } from "liquidjs";
4
4
  import filterRegistrars from "./filters/index.js";
5
5
  import tagRegistrars from "./tags/index.js";
6
- import { resolveIncludeCandidates, type AliasMap } from "../lib/include-resolver.js";
6
+ import { resolveInclude, type AliasMap } from "../lib/include-resolver.js";
7
+ import {
8
+ formatNamespaceProblem,
9
+ type NamespaceResolver,
10
+ } from "../lib/repos/namespace-resolver.js";
7
11
 
8
12
  /** Options for alias-aware `{% render %}` path resolution. */
9
13
  export type EngineAliasOptions = {
@@ -11,33 +15,70 @@ export type EngineAliasOptions = {
11
15
  aliases?: AliasMap;
12
16
  /** Variable scope for `${var}` substitution in render paths. */
13
17
  scope?: Record<string, string>;
18
+ /** Resolver consulted for a `~namespace` first segment in a render path. */
19
+ namespaceResolver?: NamespaceResolver;
20
+ /**
21
+ * Absolute path of the template being rendered, handed to the namespace
22
+ * resolver for scoping. Nested partials fall back to the directory LiquidJS
23
+ * resolves them against, which is enough to locate the owning recipe.
24
+ */
25
+ fromFile?: string;
14
26
  };
15
27
 
16
28
  /**
17
- * A node-backed LiquidJS FS that additionally understands `@`-prefixed render
18
- * paths — `{% render "@~sous-shared/x.md" %}`, `{% render "@docs/y.md" %}`, or
19
- * `{% render "@${var}/z.md" %}` — resolving them through the same alias/var/
20
- * relative candidate logic as `@include`. Non-`@` paths use standard root-based
21
- * resolution.
29
+ * A node-backed LiquidJS FS that additionally understands alias and namespace
30
+ * render paths — `{% render "@~project/x.md" %}`, `{% render "@docs/y.md" %}`,
31
+ * `{% render "@${var}/z.md" %}` and `{% render "~workflow/task-files/x.md" %}` —
32
+ * resolving them through the same alias/namespace/var/relative candidate logic
33
+ * as `@include`. The leading `@` is optional for a `~`-sigil path, since `~`
34
+ * already marks the path as symbolic rather than relative. Every other path
35
+ * uses standard root-based resolution.
22
36
  *
23
- * @param opts - Alias map and variable scope.
37
+ * @param opts - Alias map, variable scope, namespace resolver and including file.
24
38
  * @returns A LiquidJS FS implementation.
25
39
  */
26
40
  function createAliasFS(opts: EngineAliasOptions): FS {
27
41
  const aliases = opts.aliases ?? {};
28
42
  const scope = opts.scope ?? {};
29
43
 
30
- /** Resolve an `@`-path to its first existing candidate, or the first candidate. */
31
- const resolveAt = (file: string, dir: string): string | null => {
32
- if (!file.startsWith("@")) return null;
33
- const candidates = resolveIncludeCandidates(file.slice(1), { aliases, scope, baseDir: dir });
34
- return candidates.find((c) => fs.existsSync(c)) ?? candidates[0] ?? null;
44
+ /**
45
+ * Resolve an `@`-path or `~`-path to its first existing candidate, or the
46
+ * first candidate. Returns null for ordinary paths so the caller falls back
47
+ * to root-based resolution. Throws when a `~namespace` reference resolved to
48
+ * nothing and the resolver explained why, so the reason reaches the user
49
+ * instead of a bare "file not found".
50
+ */
51
+ const resolveSymbolic = (file: string, dir: string): string | null => {
52
+ const isAt = file.startsWith("@");
53
+ if (!isAt && !file.startsWith("~")) return null;
54
+
55
+ const rawPath = isAt ? file.slice(1) : file;
56
+ const { candidates, namespaceIssue } = resolveInclude(rawPath, {
57
+ aliases,
58
+ scope,
59
+ baseDir: dir,
60
+ namespaceResolver: opts.namespaceResolver,
61
+ fromFile: opts.fromFile ?? dir,
62
+ });
63
+
64
+ const existing = candidates.find((c) => fs.existsSync(c));
65
+ if (existing) return existing;
66
+
67
+ if (namespaceIssue) {
68
+ throw new Error(
69
+ `Cannot render "${file}"\n${formatNamespaceProblem(namespaceIssue)}\n tried:\n${candidates
70
+ .map((c) => ` - ${c}`)
71
+ .join("\n")}`
72
+ );
73
+ }
74
+
75
+ return candidates[0] ?? null;
35
76
  };
36
77
 
37
78
  return {
38
79
  resolve(dir: string, file: string, ext: string): string {
39
- const at = resolveAt(file, dir);
40
- if (at) return at;
80
+ const symbolic = resolveSymbolic(file, dir);
81
+ if (symbolic) return symbolic;
41
82
  // Standard resolution: join against the root dir, applying ext if missing.
42
83
  const joined = path.resolve(dir, file);
43
84
  if (ext && !path.extname(joined)) return joined + ext;
@@ -58,8 +99,9 @@ function createAliasFS(opts: EngineAliasOptions): FS {
58
99
  *
59
100
  * @param roots - Filesystem root paths searched (in order) when resolving
60
101
  * `{% render %}` partials (relative paths resolve against these).
61
- * @param aliasOpts - Optional alias map + scope enabling `@alias/...` and
62
- * `@${var}/...` paths in `{% render %}` (parity with `@include`).
102
+ * @param aliasOpts - Optional alias map, variable scope and namespace resolver,
103
+ * enabling `@alias/...`, `@${var}/...` and `~namespace/...` paths in
104
+ * `{% render %}` (parity with `@include`).
63
105
  */
64
106
  export function createLiquidEngine(roots: string[], aliasOpts: EngineAliasOptions = {}): Liquid {
65
107
  const engine = new Liquid({