@sous-io/sous 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +409 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +625 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +415 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /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 {
|
|
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
|
|
18
|
-
* paths — `{% render "@~
|
|
19
|
-
* `{% render "@${var}/z.md" %}`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
if (
|
|
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
|
|
62
|
-
* `@${var}/...` paths in
|
|
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({
|