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