@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,1152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking the questions a project's variable definitions imply.
|
|
3
|
+
*
|
|
4
|
+
* A definition is inert. A question is asked only when a subscribed recipe
|
|
5
|
+
* needs a variable and nothing in scope answers it, or when the answer that IS
|
|
6
|
+
* in scope no longer fits the definition. An answer that is already there is
|
|
7
|
+
* offered back with its scope and source shown and is never asked about again.
|
|
8
|
+
*
|
|
9
|
+
* Answers are stored in the project's own env files: `.sous/.env` for the
|
|
10
|
+
* shared ones, which are committed and shared with the team, and
|
|
11
|
+
* `.sous/.env.local` for machine-specific values and every secret, which is
|
|
12
|
+
* gitignored. The writer preserves the rest of the file exactly.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here prompts unless it was told it may. A non-interactive run with an
|
|
15
|
+
* unanswered required variable fails, naming the exact environment variables
|
|
16
|
+
* that would satisfy it, most specific first, which is the message a continuous
|
|
17
|
+
* integration log needs to be useful.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { confirm, input as input_, select } from "@inquirer/prompts";
|
|
22
|
+
import { color } from "@oclif/color";
|
|
23
|
+
import { ENV_DEFAULTS_NAME, ENV_LOCAL_NAME } from "../config-discovery.js";
|
|
24
|
+
import { updateEnvFile } from "../env-file.js";
|
|
25
|
+
import { ConfigError } from "../errors.js";
|
|
26
|
+
import {
|
|
27
|
+
blankLine,
|
|
28
|
+
blankLines,
|
|
29
|
+
formatVariable,
|
|
30
|
+
indent,
|
|
31
|
+
keysHelpTip,
|
|
32
|
+
log,
|
|
33
|
+
palette,
|
|
34
|
+
paragraph,
|
|
35
|
+
showVariables,
|
|
36
|
+
VARIABLE_INDENT,
|
|
37
|
+
warning,
|
|
38
|
+
wrapColumns,
|
|
39
|
+
wrapText,
|
|
40
|
+
} from "../../utils/formatting.js";
|
|
41
|
+
import { choicePrompt } from "../../utils/choice-prompt.js";
|
|
42
|
+
import { confirmPrompt } from "../../utils/confirm-prompt.js";
|
|
43
|
+
import { valuePrompt } from "../../utils/value-prompt.js";
|
|
44
|
+
import { ENV_VAR_NAME_PATTERN } from "../repos/formats/patterns.js";
|
|
45
|
+
import { variableReferenceKey } from "../refs/find.js";
|
|
46
|
+
import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
|
|
47
|
+
import {
|
|
48
|
+
definedVariableKey,
|
|
49
|
+
definingRecipeKey,
|
|
50
|
+
type DefinedVariable,
|
|
51
|
+
} from "./definition-source.js";
|
|
52
|
+
import {
|
|
53
|
+
BASIC_FACT_LABELS,
|
|
54
|
+
displayValue,
|
|
55
|
+
renderFacts,
|
|
56
|
+
selectFacts,
|
|
57
|
+
variableFacts,
|
|
58
|
+
} from "./display.js";
|
|
59
|
+
import {
|
|
60
|
+
describeSource,
|
|
61
|
+
diagnoseVariable,
|
|
62
|
+
lookupEnvName,
|
|
63
|
+
variableCandidates,
|
|
64
|
+
recordAnswerInContext,
|
|
65
|
+
RUNG_LABELS,
|
|
66
|
+
type LadderCandidate,
|
|
67
|
+
type LadderContext,
|
|
68
|
+
type ResolvedVariable,
|
|
69
|
+
} from "./ladder.js";
|
|
70
|
+
import {
|
|
71
|
+
VAR_MAPPINGS_LAYER_FILENAME,
|
|
72
|
+
formatMappingTarget,
|
|
73
|
+
mappingTargetFor,
|
|
74
|
+
writeMappingRecord,
|
|
75
|
+
} from "./mappings.js";
|
|
76
|
+
import {
|
|
77
|
+
bareName,
|
|
78
|
+
namespaceScopedName,
|
|
79
|
+
recipeScopedName,
|
|
80
|
+
sharedName,
|
|
81
|
+
} from "./names.js";
|
|
82
|
+
import { validateAnswer } from "./validate.js";
|
|
83
|
+
|
|
84
|
+
/** Which env file an answer was written to. */
|
|
85
|
+
export type AnswerFile = typeof ENV_LOCAL_NAME | typeof ENV_DEFAULTS_NAME;
|
|
86
|
+
|
|
87
|
+
/** One answer that was collected and stored. */
|
|
88
|
+
export interface AnsweredVariable {
|
|
89
|
+
/** The variable that was answered. */
|
|
90
|
+
defined: DefinedVariable;
|
|
91
|
+
/** The environment variable the answer was stored under. */
|
|
92
|
+
envName: string;
|
|
93
|
+
/** Which env file it went into. */
|
|
94
|
+
file: AnswerFile;
|
|
95
|
+
/** The absolute path of that file. */
|
|
96
|
+
filePath: string;
|
|
97
|
+
/** The stored value. */
|
|
98
|
+
value: string;
|
|
99
|
+
/** Whether the entry was rewritten in place or added at the end. */
|
|
100
|
+
outcome: "updated" | "appended" | "not written";
|
|
101
|
+
/** The mapping record written alongside the answer, when one was needed. */
|
|
102
|
+
mapping?: { envName: string; target: string; filePath: string };
|
|
103
|
+
/**
|
|
104
|
+
* The answer this one replaced, when there was a different one already in
|
|
105
|
+
* scope. Only a supplied answer (`--answer`) replaces anything without being
|
|
106
|
+
* asked first, so the report says plainly when one did.
|
|
107
|
+
*/
|
|
108
|
+
replaced?: string;
|
|
109
|
+
/**
|
|
110
|
+
* An environment variable in the real shell environment that answers this
|
|
111
|
+
* variable and therefore outranks the file this answer was written to. The
|
|
112
|
+
* report names it, because the stored answer does nothing until it is unset.
|
|
113
|
+
*/
|
|
114
|
+
shadowedBy?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One variable that already had a valid answer. */
|
|
118
|
+
export interface InheritedVariable {
|
|
119
|
+
/** The variable that was already answered. */
|
|
120
|
+
defined: DefinedVariable;
|
|
121
|
+
/** The answer and exactly where it came from. */
|
|
122
|
+
resolved: ResolvedVariable;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One variable that was left alone, and why. */
|
|
126
|
+
export interface SkippedVariable {
|
|
127
|
+
/** The variable that was not answered. */
|
|
128
|
+
defined: DefinedVariable;
|
|
129
|
+
/** A plain-language reason, shown in the report. */
|
|
130
|
+
reason: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What one run of `askForMissing` did. */
|
|
134
|
+
export interface AskReport {
|
|
135
|
+
/** Answers collected in this run. */
|
|
136
|
+
answered: AnsweredVariable[];
|
|
137
|
+
/** Answers that were already in scope and still fit. */
|
|
138
|
+
inherited: InheritedVariable[];
|
|
139
|
+
/** Variables that were deliberately left unanswered. */
|
|
140
|
+
skipped: SkippedVariable[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** How `askForMissing` should behave. */
|
|
144
|
+
export interface AskOptions {
|
|
145
|
+
/** The project's `.sous/` directory, which holds both env files. */
|
|
146
|
+
sousDir: string;
|
|
147
|
+
/** The project's `conf.d/` directory, where a mapping record is written. */
|
|
148
|
+
confDir: string;
|
|
149
|
+
/** Whether questions may be asked. A non-interactive run fails instead. */
|
|
150
|
+
interactive: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Limit the run to these variables. Each entry is a variable's bare name, its
|
|
153
|
+
* `namespace/recipe.name` key, or its fully qualified
|
|
154
|
+
* `repository:namespace/recipe.name` reference. A command that resolved a
|
|
155
|
+
* reference through `src/lib/refs/` passes the fully qualified form, which is
|
|
156
|
+
* the only spelling that cannot mean two variables at once.
|
|
157
|
+
*/
|
|
158
|
+
only?: string[];
|
|
159
|
+
/**
|
|
160
|
+
* Variables an answer was supplied for ahead of the run, by
|
|
161
|
+
* `namespace/recipe.name` key. They are neither asked about nor reported
|
|
162
|
+
* here; the caller that stored them reports them itself.
|
|
163
|
+
*/
|
|
164
|
+
skip?: string[];
|
|
165
|
+
/** Ask again even when a valid answer is already in scope. */
|
|
166
|
+
reask?: boolean;
|
|
167
|
+
/** Work out what would happen and report it, without writing anything. */
|
|
168
|
+
dryRun?: boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when the definition's answer belongs in the gitignored local file. */
|
|
172
|
+
export function answerFileFor(definition: VariableDefinition): AnswerFile {
|
|
173
|
+
return definition.secret || definition.scope === "local"
|
|
174
|
+
? ENV_LOCAL_NAME
|
|
175
|
+
: ENV_DEFAULTS_NAME;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when `only` names this variable, by bare name, by its
|
|
180
|
+
* `namespace/recipe.name` key, or by its fully qualified reference.
|
|
181
|
+
*/
|
|
182
|
+
function isNamed(defined: DefinedVariable, only: string[] | undefined): boolean {
|
|
183
|
+
if (only === undefined) return false;
|
|
184
|
+
const key = definedVariableKey(defined);
|
|
185
|
+
const qualified = variableReferenceKey(defined);
|
|
186
|
+
return only.some(
|
|
187
|
+
(name) => name === defined.definition.name || name === key || name === qualified
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The generated header comment written above a newly stored answer. */
|
|
192
|
+
export function answerHeader(defined: DefinedVariable): string[] {
|
|
193
|
+
const recipe = definingRecipeKey(defined.recipe);
|
|
194
|
+
return [
|
|
195
|
+
`Set by sous for ${recipe}: ${defined.definition.prompt}`,
|
|
196
|
+
defined.definition.description,
|
|
197
|
+
"Edit freely; sous only rewrites the value line.",
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The message a non-interactive run fails with: one block per unanswered
|
|
203
|
+
* variable, naming every environment variable that would satisfy it, most
|
|
204
|
+
* specific first.
|
|
205
|
+
*/
|
|
206
|
+
function buildNonInteractiveError(
|
|
207
|
+
pending: { defined: DefinedVariable; candidates: LadderCandidate[]; current?: string }[]
|
|
208
|
+
): ConfigError {
|
|
209
|
+
const lines: string[] = [
|
|
210
|
+
pending.length === 1
|
|
211
|
+
? "One variable still needs an answer, and there is no terminal to ask on."
|
|
212
|
+
: `${pending.length} variables still need answers, and there is no terminal to ask on.`,
|
|
213
|
+
"",
|
|
214
|
+
"Set one of the environment variables listed under each variable, or run",
|
|
215
|
+
"'sous vars ask' from a terminal.",
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
for (const entry of pending) {
|
|
219
|
+
lines.push("");
|
|
220
|
+
lines.push(
|
|
221
|
+
` ${entry.defined.definition.name} (${definingRecipeKey(entry.defined.recipe)}): ` +
|
|
222
|
+
entry.defined.definition.prompt
|
|
223
|
+
);
|
|
224
|
+
if (entry.current !== undefined) {
|
|
225
|
+
lines.push(
|
|
226
|
+
` The value currently in scope does not fit this variable: ${entry.current}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
for (const candidate of entry.candidates) {
|
|
230
|
+
lines.push(` ${candidate.envName} (${RUNG_LABELS[candidate.rung]})`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return new ConfigError(lines.join("\n"));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Where one answer will be stored: which env file, and under what name. */
|
|
238
|
+
export interface StoragePlan {
|
|
239
|
+
/** The env file the answer goes into. */
|
|
240
|
+
file: AnswerFile;
|
|
241
|
+
/** The environment variable name the answer is stored under. */
|
|
242
|
+
envName: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One question sous is going to ask, and the value Enter alone would accept. */
|
|
246
|
+
interface PlannedQuestion {
|
|
247
|
+
/** The variable being asked about. */
|
|
248
|
+
defined: DefinedVariable;
|
|
249
|
+
/** The value offered as the default, when there is one. */
|
|
250
|
+
suggestion?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The questions one recipe contributes, in the order they will be asked. */
|
|
254
|
+
interface QuestionGroup {
|
|
255
|
+
/** The recipe's `namespace/recipe` key. */
|
|
256
|
+
key: string;
|
|
257
|
+
/** Whether the project subscribed to this recipe itself. */
|
|
258
|
+
direct: boolean;
|
|
259
|
+
/** The questions, in declaration order. */
|
|
260
|
+
questions: PlannedQuestion[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** "1 answer" or "4 answers", so no count is ever printed with the wrong noun. */
|
|
264
|
+
function answerCount(count: number): string {
|
|
265
|
+
return count === 1 ? "1 answer" : `${count} answers`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The single sentence printed before any question when the run spans more than
|
|
270
|
+
* one recipe, so the size of what is about to be asked is known up front. A run
|
|
271
|
+
* covering one recipe needs no lead-in; its own opening line says everything.
|
|
272
|
+
*
|
|
273
|
+
* @param groups - Each recipe, how many questions it contributes, and whether the project subscribed to it directly.
|
|
274
|
+
* @returns The lead-in, or undefined when there is only one recipe.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* askLeadIn([
|
|
278
|
+
* { key: "workflow/task-files", count: 4, direct: true },
|
|
279
|
+
* { key: "workflow/sub-agent-delegation", count: 2, direct: false },
|
|
280
|
+
* ]);
|
|
281
|
+
* // -> "workflow/task-files needs 4 answers, and workflow/sub-agent-delegation, which it depends on, needs 2."
|
|
282
|
+
*/
|
|
283
|
+
export function askLeadIn(
|
|
284
|
+
groups: Array<{ key: string; count: number; direct: boolean }>
|
|
285
|
+
): string | undefined {
|
|
286
|
+
if (groups.length < 2) return undefined;
|
|
287
|
+
|
|
288
|
+
const parts = groups.map((group, index) => {
|
|
289
|
+
const relation = index === 0 || group.direct ? "" : ", which it depends on,";
|
|
290
|
+
const needs = index === 0 ? answerCount(group.count) : String(group.count);
|
|
291
|
+
return `${group.key}${relation} needs ${needs}`;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const last = parts.pop()!;
|
|
295
|
+
return `${[...parts, `and ${last}`].join(", ")}.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The opening line of one recipe's questions, printed once before the first of
|
|
300
|
+
* them.
|
|
301
|
+
*
|
|
302
|
+
* @param key - The recipe's `namespace/recipe` key.
|
|
303
|
+
* @param count - How many questions the recipe contributes.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* recipeOpeningLine("workflow/task-files", 4);
|
|
307
|
+
* // -> "workflow/task-files needs 4 answers before it can be used."
|
|
308
|
+
*/
|
|
309
|
+
export function recipeOpeningLine(key: string, count: number): string {
|
|
310
|
+
return `${key} needs ${answerCount(count)} before it can be used.`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Everything the basic view of one question draws. */
|
|
314
|
+
export interface BasicViewInput {
|
|
315
|
+
/** The variable being asked about. */
|
|
316
|
+
defined: DefinedVariable;
|
|
317
|
+
/** This question's place in its recipe's run, counting from one. */
|
|
318
|
+
index: number;
|
|
319
|
+
/** How many questions that recipe contributes. */
|
|
320
|
+
total: number;
|
|
321
|
+
/** Where the answer is going, as it stands right now. */
|
|
322
|
+
plan: StoragePlan;
|
|
323
|
+
/** The value Enter alone would accept, when there is one. */
|
|
324
|
+
suggestion?: string;
|
|
325
|
+
/** The column to wrap the description at. */
|
|
326
|
+
width?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The basic view of one question: the header, the publisher's description
|
|
331
|
+
* wrapped to the terminal, and the facts a person needs before typing an
|
|
332
|
+
* answer. The facts are the same labeled block the advanced view draws,
|
|
333
|
+
* narrowed to four labels, so the two views always read and line up the same
|
|
334
|
+
* way. The keys are named by the legend the prompt itself draws underneath.
|
|
335
|
+
*
|
|
336
|
+
* @param input - The question, its place in the run, and where the answer is going.
|
|
337
|
+
* @param storagePath - The absolute path of the env file the answer goes into.
|
|
338
|
+
* @returns The lines to print, without indentation.
|
|
339
|
+
*/
|
|
340
|
+
export function basicViewLines(input: BasicViewInput, storagePath: string): string[] {
|
|
341
|
+
const { defined, index, total, plan } = input;
|
|
342
|
+
const { definition } = defined;
|
|
343
|
+
const width = input.width ?? wrapColumns();
|
|
344
|
+
|
|
345
|
+
const facts = selectFacts(
|
|
346
|
+
variableFacts({ defined, storagePath, storedAs: plan.envName }),
|
|
347
|
+
BASIC_FACT_LABELS
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// The keys are named by the legend the question itself draws underneath the
|
|
351
|
+
// input line, in the style the stock prompts use, so nothing is repeated here.
|
|
352
|
+
return [
|
|
353
|
+
color.bold(`Question ${index} of ${total}: ${color.cyan(definition.name)}`),
|
|
354
|
+
"",
|
|
355
|
+
...wrapText(definition.description, width - 2),
|
|
356
|
+
"",
|
|
357
|
+
...renderFacts(facts, width),
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The key legend one question draws under its input line, written the way the
|
|
363
|
+
* stock `@inquirer/select` prompt writes its own: the key, what it does beside
|
|
364
|
+
* it, pairs separated by a bullet. Tab always opens the advanced view; what the
|
|
365
|
+
* other keys do depends on the kind of question, so a question picked from a
|
|
366
|
+
* list names the arrow keys rather than talking about typing a default.
|
|
367
|
+
*
|
|
368
|
+
* @param definition - The variable being asked about.
|
|
369
|
+
* @param suggestion - The value Enter alone would accept, when there is one.
|
|
370
|
+
* @returns The legend line, colored.
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* questionHint({ type: "enum", ... });
|
|
374
|
+
* // -> "↑↓ navigate • ⏎ select • ⇥ advanced"
|
|
375
|
+
*/
|
|
376
|
+
export function questionHint(
|
|
377
|
+
definition: VariableDefinition,
|
|
378
|
+
suggestion?: string
|
|
379
|
+
): string {
|
|
380
|
+
const advanced: [string, string] = ["⇥", "advanced"];
|
|
381
|
+
|
|
382
|
+
if (definition.type === "enum") {
|
|
383
|
+
return keysHelpTip([["↑↓", "navigate"], ["⏎", "select"], advanced]);
|
|
384
|
+
}
|
|
385
|
+
if (definition.type === "boolean") {
|
|
386
|
+
return keysHelpTip([["y/n", "answer"], ["⏎", "accept default"], advanced]);
|
|
387
|
+
}
|
|
388
|
+
return suggestion === undefined || suggestion === ""
|
|
389
|
+
? keysHelpTip([advanced])
|
|
390
|
+
: keysHelpTip([["⏎", "accept default"], advanced]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The advanced view of one question: the same header and description, then
|
|
395
|
+
* every fact about the variable, laid out by the renderer `sous vars show`
|
|
396
|
+
* uses, so the vocabulary never drifts between the two.
|
|
397
|
+
*
|
|
398
|
+
* @param input - The question, its place in the run, and where the answer is going.
|
|
399
|
+
* @param storagePath - The absolute path of the env file the answer goes into.
|
|
400
|
+
* @returns The lines to print, without indentation.
|
|
401
|
+
*/
|
|
402
|
+
export function advancedViewLines(input: BasicViewInput, storagePath: string): string[] {
|
|
403
|
+
const { defined, index, total } = input;
|
|
404
|
+
const width = input.width ?? wrapColumns();
|
|
405
|
+
|
|
406
|
+
return [
|
|
407
|
+
color.bold(palette.warning("[Advanced Variable Settings]")),
|
|
408
|
+
"",
|
|
409
|
+
color.bold(`Question ${index} of ${total}: ${color.cyan(defined.definition.name)}`),
|
|
410
|
+
"",
|
|
411
|
+
...wrapText(defined.definition.description, width - 2),
|
|
412
|
+
"",
|
|
413
|
+
...renderFacts(
|
|
414
|
+
variableFacts({ defined, storagePath, storedAs: input.plan.envName }),
|
|
415
|
+
width
|
|
416
|
+
),
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Prints a block of lines indented under the question. Two blank lines open it,
|
|
422
|
+
* so a question header always has room above it and never reads as the tail of
|
|
423
|
+
* whatever was printed before, and one closes it.
|
|
424
|
+
*/
|
|
425
|
+
function printBlock(lines: string[]): void {
|
|
426
|
+
blankLines(2);
|
|
427
|
+
for (const line of lines) log(line === "" ? " " : indent(line));
|
|
428
|
+
blankLine();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The value the name picker returns when the name is to be typed by hand. It is
|
|
433
|
+
* lowercase, so it can never collide with an environment variable name, which
|
|
434
|
+
* is always upper snake case.
|
|
435
|
+
*/
|
|
436
|
+
export const ANOTHER_NAME = "another-name";
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Every environment variable name the ladder would look this variable up
|
|
440
|
+
* under, in ladder order, as a pick list. Choosing one of these keeps the
|
|
441
|
+
* answer findable with no mapping record at all.
|
|
442
|
+
*
|
|
443
|
+
* @param defined - The variable and the recipe that published it.
|
|
444
|
+
*/
|
|
445
|
+
export function nameChoices(
|
|
446
|
+
defined: DefinedVariable
|
|
447
|
+
): Array<{ name: string; value: string }> {
|
|
448
|
+
const { namespace, name: recipe } = defined.recipe;
|
|
449
|
+
const variable = defined.definition.name;
|
|
450
|
+
|
|
451
|
+
const rungs: Array<[string, string]> = [
|
|
452
|
+
["recipe scope", recipeScopedName(namespace, recipe, variable)],
|
|
453
|
+
["namespace scope", namespaceScopedName(namespace, variable)],
|
|
454
|
+
["shared scope", sharedName(variable)],
|
|
455
|
+
["declared name", bareName(defined.definition)],
|
|
456
|
+
];
|
|
457
|
+
|
|
458
|
+
const seen = new Set<string>();
|
|
459
|
+
const choices: Array<{ name: string; value: string }> = [];
|
|
460
|
+
for (const [label, envName] of rungs) {
|
|
461
|
+
if (seen.has(envName)) continue;
|
|
462
|
+
seen.add(envName);
|
|
463
|
+
choices.push({ name: `${envName} (${label})`, value: envName });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
choices.push({ name: "Another name, which you type yourself", value: ANOTHER_NAME });
|
|
467
|
+
return choices;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The two env files an answer can go into, described by what each one means for
|
|
472
|
+
* the team rather than by its name alone.
|
|
473
|
+
*/
|
|
474
|
+
export function fileChoices(): Array<{ name: string; value: AnswerFile }> {
|
|
475
|
+
return [
|
|
476
|
+
{
|
|
477
|
+
name: `${ENV_DEFAULTS_NAME} (committed, shared with everyone on the project)`,
|
|
478
|
+
value: ENV_DEFAULTS_NAME,
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: `${ENV_LOCAL_NAME} (gitignored, this machine only)`,
|
|
482
|
+
value: ENV_LOCAL_NAME,
|
|
483
|
+
},
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The warning shown when a value the definition marked secret or local is about
|
|
489
|
+
* to be pointed at the committed env file. Sous does not prevent it; it says
|
|
490
|
+
* plainly what happens and asks.
|
|
491
|
+
*
|
|
492
|
+
* @param defined - The variable being answered.
|
|
493
|
+
*/
|
|
494
|
+
export function committedFileWarning(defined: DefinedVariable): string {
|
|
495
|
+
const why = defined.definition.secret
|
|
496
|
+
? "declared this variable a secret"
|
|
497
|
+
: "declared this variable machine-specific";
|
|
498
|
+
return (
|
|
499
|
+
`${definingRecipeKey(defined.recipe)} ${why}, and ${ENV_DEFAULTS_NAME} is committed ` +
|
|
500
|
+
`to git. An answer stored there enters your project's git history, is pushed with ` +
|
|
501
|
+
`every clone, and is visible to everyone who can read the repository.`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The advanced view and its menu. It runs until the person answering returns to
|
|
507
|
+
* the value question, and hands back where the answer should be stored: the
|
|
508
|
+
* plan it was given when nothing was changed or the changes were discarded, and
|
|
509
|
+
* the edited plan when they were saved.
|
|
510
|
+
*
|
|
511
|
+
* @param input - The question, its place in the run, and the plan as it stands.
|
|
512
|
+
* @param options - Where the env files live, and whether questions may be asked.
|
|
513
|
+
* @returns The storage plan to use for this answer.
|
|
514
|
+
*/
|
|
515
|
+
async function runAdvancedView(
|
|
516
|
+
input: BasicViewInput,
|
|
517
|
+
options: AskOptions
|
|
518
|
+
): Promise<StoragePlan> {
|
|
519
|
+
/**
|
|
520
|
+
* The stock `select` prompt draws its own legend and cannot be given padding
|
|
521
|
+
* underneath, so the padding every sous question keeps is written after it
|
|
522
|
+
* answers instead.
|
|
523
|
+
*/
|
|
524
|
+
const padded = async <T>(answer: Promise<T>): Promise<T> => {
|
|
525
|
+
const value = await answer;
|
|
526
|
+
blankLine();
|
|
527
|
+
return value;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const original = input.plan;
|
|
531
|
+
let working: StoragePlan = { ...original };
|
|
532
|
+
|
|
533
|
+
for (;;) {
|
|
534
|
+
printBlock(
|
|
535
|
+
advancedViewLines(
|
|
536
|
+
{ ...input, plan: working },
|
|
537
|
+
path.join(options.sousDir, working.file)
|
|
538
|
+
)
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const changed = working.file !== original.file || working.envName !== original.envName;
|
|
542
|
+
const choices = [
|
|
543
|
+
changed
|
|
544
|
+
? { name: "Save changes and return to value entry", value: "save" }
|
|
545
|
+
: { name: "Return to value entry", value: "return" },
|
|
546
|
+
...(changed
|
|
547
|
+
? [{ name: "Discard changes and return to value entry", value: "discard" }]
|
|
548
|
+
: []),
|
|
549
|
+
{ name: "Change the storage file", value: "file" },
|
|
550
|
+
{ name: "Change the stored variable name", value: "name" },
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const action = await padded(
|
|
554
|
+
select({ message: "What would you like to do?", choices })
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
if (action === "return" || action === "save") return working;
|
|
558
|
+
if (action === "discard") return original;
|
|
559
|
+
|
|
560
|
+
if (action === "file") {
|
|
561
|
+
const file = await padded(
|
|
562
|
+
select({
|
|
563
|
+
message: "Which file should this answer be stored in?",
|
|
564
|
+
choices: fileChoices(),
|
|
565
|
+
default: working.file,
|
|
566
|
+
})
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
if (file === ENV_DEFAULTS_NAME && answerFileFor(input.defined.definition) === ENV_LOCAL_NAME) {
|
|
570
|
+
warning(committedFileWarning(input.defined));
|
|
571
|
+
const accepted = await padded(
|
|
572
|
+
confirm({
|
|
573
|
+
message: `Store this answer in ${ENV_DEFAULTS_NAME} anyway?`,
|
|
574
|
+
default: false,
|
|
575
|
+
})
|
|
576
|
+
);
|
|
577
|
+
if (!accepted) continue;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
working = { ...working, file };
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const picked = await padded(
|
|
585
|
+
select({
|
|
586
|
+
message: "Which environment variable should hold this answer?",
|
|
587
|
+
choices: nameChoices(input.defined),
|
|
588
|
+
default: working.envName,
|
|
589
|
+
})
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
if (picked !== ANOTHER_NAME) {
|
|
593
|
+
working = { ...working, envName: picked };
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const typed = await padded(
|
|
598
|
+
input_({
|
|
599
|
+
message: "What should the environment variable be called?",
|
|
600
|
+
default: working.envName,
|
|
601
|
+
validate: (value: string) =>
|
|
602
|
+
ENV_VAR_NAME_PATTERN.test(value.trim())
|
|
603
|
+
? true
|
|
604
|
+
: "An environment variable name is upper snake case: a letter or underscore, then letters, digits or underscores.",
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
working = { ...working, envName: typed.trim() };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The words a stored boolean may be written with that all mean yes. */
|
|
612
|
+
const TRUE_WORDS = ["true", "yes", "y", "on", "1"];
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Asks the one question the variable's type calls for: a list to pick from for
|
|
616
|
+
* an enum, a yes or no for a boolean, and typing for everything else. All three
|
|
617
|
+
* prompts answer the same way, so Tab reaches the advanced view from every kind
|
|
618
|
+
* of question and the caller has one return path to handle.
|
|
619
|
+
*
|
|
620
|
+
* @param definition - The variable being asked about.
|
|
621
|
+
* @param suggestion - The value Enter alone would accept, when there is one.
|
|
622
|
+
* @param validate - Checks a typed answer; the other two kinds cannot be wrong.
|
|
623
|
+
* @returns The answer as text, or a request for the advanced view.
|
|
624
|
+
*/
|
|
625
|
+
async function askByType(
|
|
626
|
+
definition: VariableDefinition,
|
|
627
|
+
suggestion: string | undefined,
|
|
628
|
+
validate: (value: string) => true | string
|
|
629
|
+
): Promise<{ kind: "value"; value: string } | { kind: "advanced" }> {
|
|
630
|
+
// The legend goes under the input line, where the stock prompts draw theirs.
|
|
631
|
+
const hint = questionHint(definition, suggestion);
|
|
632
|
+
|
|
633
|
+
if (definition.type === "enum") {
|
|
634
|
+
const enumOptions = definition.validate?.enum ?? [];
|
|
635
|
+
return choicePrompt({
|
|
636
|
+
message: definition.prompt,
|
|
637
|
+
choices: enumOptions.map((option) => ({ name: option, value: option })),
|
|
638
|
+
hint,
|
|
639
|
+
...(suggestion !== undefined && enumOptions.includes(suggestion)
|
|
640
|
+
? { default: suggestion }
|
|
641
|
+
: {}),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (definition.type === "boolean") {
|
|
646
|
+
const current = suggestion ?? String(definition.default ?? "");
|
|
647
|
+
const answered = await confirmPrompt({
|
|
648
|
+
message: definition.prompt,
|
|
649
|
+
hint,
|
|
650
|
+
default: TRUE_WORDS.includes(current.toLowerCase()),
|
|
651
|
+
});
|
|
652
|
+
return answered.kind === "advanced"
|
|
653
|
+
? answered
|
|
654
|
+
: { kind: "value", value: String(answered.value) };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return valuePrompt({
|
|
658
|
+
message: definition.prompt,
|
|
659
|
+
validate,
|
|
660
|
+
hint,
|
|
661
|
+
...(suggestion === undefined ? {} : { default: suggestion }),
|
|
662
|
+
...(definition.secret ? { mask: true } : {}),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Asks one question and hands back the answer together with where it should be
|
|
668
|
+
* stored. Tab opens the advanced view; returning from it prints the basic view
|
|
669
|
+
* again, with whatever the advanced view changed, and asks once more.
|
|
670
|
+
*
|
|
671
|
+
* @param question - The variable and the value Enter alone would accept.
|
|
672
|
+
* @param place - This question's place in its recipe's run.
|
|
673
|
+
* @param options - Where the env files live.
|
|
674
|
+
* @returns The answer as text, and the storage plan it should be written with.
|
|
675
|
+
*/
|
|
676
|
+
async function askOneQuestion(
|
|
677
|
+
question: PlannedQuestion,
|
|
678
|
+
place: { index: number; total: number },
|
|
679
|
+
options: AskOptions
|
|
680
|
+
): Promise<{ answer: string; plan: StoragePlan }> {
|
|
681
|
+
const { defined } = question;
|
|
682
|
+
const { definition } = defined;
|
|
683
|
+
|
|
684
|
+
let plan: StoragePlan = {
|
|
685
|
+
file: answerFileFor(definition),
|
|
686
|
+
envName: bareName(definition),
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const validate = (value: string): true | string => {
|
|
690
|
+
const result = validateAnswer(definition, value, {
|
|
691
|
+
recipe: definingRecipeKey(defined.recipe),
|
|
692
|
+
});
|
|
693
|
+
return result.ok ? true : result.message;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
for (;;) {
|
|
697
|
+
const view: BasicViewInput = {
|
|
698
|
+
defined,
|
|
699
|
+
index: place.index,
|
|
700
|
+
total: place.total,
|
|
701
|
+
plan,
|
|
702
|
+
...(question.suggestion === undefined ? {} : { suggestion: question.suggestion }),
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
printBlock(basicViewLines(view, path.join(options.sousDir, plan.file)));
|
|
706
|
+
|
|
707
|
+
// Every kind of question ends the same way: an answer, or a request for the
|
|
708
|
+
// advanced view, which is shown and then hands back here to ask again.
|
|
709
|
+
const result = await askByType(definition, question.suggestion, validate);
|
|
710
|
+
|
|
711
|
+
if (result.kind === "value") return { answer: result.value, plan };
|
|
712
|
+
|
|
713
|
+
plan = await runAdvancedView(view, options);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Groups the questions by the recipe that published them, keeping the order
|
|
719
|
+
* they arrived in: the subscribed recipe first, then each dependency in closure
|
|
720
|
+
* order.
|
|
721
|
+
*
|
|
722
|
+
* @param questions - Every question that will be asked, in order.
|
|
723
|
+
*/
|
|
724
|
+
function groupQuestions(questions: PlannedQuestion[]): QuestionGroup[] {
|
|
725
|
+
const groups = new Map<string, QuestionGroup>();
|
|
726
|
+
|
|
727
|
+
for (const question of questions) {
|
|
728
|
+
const key = definingRecipeKey(question.defined.recipe);
|
|
729
|
+
const chain = question.defined.requiredBy;
|
|
730
|
+
const existing = groups.get(key);
|
|
731
|
+
if (existing === undefined) {
|
|
732
|
+
groups.set(key, {
|
|
733
|
+
key,
|
|
734
|
+
direct: chain === undefined || chain.length <= 1,
|
|
735
|
+
questions: [question],
|
|
736
|
+
});
|
|
737
|
+
} else {
|
|
738
|
+
existing.questions.push(question);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return [...groups.values()];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Walks every definition, keeps the answers that already fit, asks for the ones
|
|
747
|
+
* that do not, and stores what it collects in the project's env files.
|
|
748
|
+
*
|
|
749
|
+
* Everything is decided before the first question is printed, so the run can
|
|
750
|
+
* say how many answers each recipe needs before it asks for any of them. In a
|
|
751
|
+
* subscribe, this runs strictly after the dependency closure has resolved and
|
|
752
|
+
* every new repository has been trusted; a question is never interleaved with a
|
|
753
|
+
* trust decision.
|
|
754
|
+
*
|
|
755
|
+
* @param defined - Every variable definition in play.
|
|
756
|
+
* @param context - The environment layers and mapping records to resolve against.
|
|
757
|
+
* @param options - Where to write, whether questions may be asked, and what to limit to.
|
|
758
|
+
* @returns What was answered, inherited and skipped.
|
|
759
|
+
*/
|
|
760
|
+
export async function askForMissing(
|
|
761
|
+
defined: DefinedVariable[],
|
|
762
|
+
context: LadderContext,
|
|
763
|
+
options: AskOptions
|
|
764
|
+
): Promise<AskReport> {
|
|
765
|
+
const report: AskReport = { answered: [], inherited: [], skipped: [] };
|
|
766
|
+
const pending: { defined: DefinedVariable; candidates: LadderCandidate[]; current?: string }[] =
|
|
767
|
+
[];
|
|
768
|
+
const questions: PlannedQuestion[] = [];
|
|
769
|
+
const deferred: DefinedVariable[] = [];
|
|
770
|
+
/** Names an earlier question in this same run will write. */
|
|
771
|
+
const plannedNames = new Set<string>();
|
|
772
|
+
|
|
773
|
+
for (const entry of defined) {
|
|
774
|
+
if (options.only !== undefined && !isNamed(entry, options.only)) continue;
|
|
775
|
+
if (options.skip?.includes(definedVariableKey(entry)) === true) continue;
|
|
776
|
+
|
|
777
|
+
const { definition } = entry;
|
|
778
|
+
const named = options.only !== undefined;
|
|
779
|
+
const diagnosis = diagnoseVariable(entry, context);
|
|
780
|
+
const existing = diagnosis.resolved;
|
|
781
|
+
const validity =
|
|
782
|
+
existing === undefined
|
|
783
|
+
? undefined
|
|
784
|
+
: validateAnswer(definition, existing.value, {
|
|
785
|
+
recipe: definingRecipeKey(entry.recipe),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
if (existing !== undefined && validity?.ok === true && options.reask !== true && !named) {
|
|
789
|
+
report.inherited.push({ defined: entry, resolved: existing });
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// A question earlier in this run is about to answer one of the names this
|
|
794
|
+
// variable resolves under, so it is inherited rather than asked; which name
|
|
795
|
+
// answered is settled once the answer is really stored.
|
|
796
|
+
if (
|
|
797
|
+
existing === undefined &&
|
|
798
|
+
options.reask !== true &&
|
|
799
|
+
!named &&
|
|
800
|
+
diagnosis.candidates.some((candidate) => plannedNames.has(candidate.envName))
|
|
801
|
+
) {
|
|
802
|
+
deferred.push(entry);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const invalid = existing !== undefined && validity?.ok === false;
|
|
807
|
+
const mustAsk =
|
|
808
|
+
invalid || (existing === undefined && definition.required) || options.reask === true || named;
|
|
809
|
+
|
|
810
|
+
if (!mustAsk) {
|
|
811
|
+
report.skipped.push({
|
|
812
|
+
defined: entry,
|
|
813
|
+
reason: "no answer yet, and this variable is optional",
|
|
814
|
+
});
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (!options.interactive) {
|
|
819
|
+
pending.push({
|
|
820
|
+
defined: entry,
|
|
821
|
+
candidates: diagnosis.candidates,
|
|
822
|
+
...(invalid && validity?.ok === false ? { current: validity.message } : {}),
|
|
823
|
+
});
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const suggestion =
|
|
828
|
+
existing !== undefined && validity?.ok === true
|
|
829
|
+
? existing.value
|
|
830
|
+
: definition.default !== undefined
|
|
831
|
+
? String(definition.default)
|
|
832
|
+
: undefined;
|
|
833
|
+
|
|
834
|
+
plannedNames.add(bareName(definition));
|
|
835
|
+
questions.push({ defined: entry, ...(suggestion === undefined ? {} : { suggestion }) });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (pending.length > 0) throw buildNonInteractiveError(pending);
|
|
839
|
+
|
|
840
|
+
const groups = groupQuestions(questions);
|
|
841
|
+
const leadIn = askLeadIn(
|
|
842
|
+
groups.map((group) => ({
|
|
843
|
+
key: group.key,
|
|
844
|
+
count: group.questions.length,
|
|
845
|
+
direct: group.direct,
|
|
846
|
+
}))
|
|
847
|
+
);
|
|
848
|
+
if (leadIn !== undefined) {
|
|
849
|
+
blankLine();
|
|
850
|
+
paragraph(leadIn, { color: palette.warning });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
for (const group of groups) {
|
|
854
|
+
blankLine();
|
|
855
|
+
paragraph(recipeOpeningLine(group.key, group.questions.length), {
|
|
856
|
+
color: palette.warning,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
for (const [position, question] of group.questions.entries()) {
|
|
860
|
+
await runQuestion(
|
|
861
|
+
question,
|
|
862
|
+
{ index: position + 1, total: group.questions.length },
|
|
863
|
+
context,
|
|
864
|
+
options,
|
|
865
|
+
report
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// A variable another answer was expected to cover: if it really is answered
|
|
871
|
+
// now, it is inherited; if it is not, it is asked on its own.
|
|
872
|
+
for (const entry of deferred) {
|
|
873
|
+
const diagnosis = diagnoseVariable(entry, context);
|
|
874
|
+
const validity =
|
|
875
|
+
diagnosis.resolved === undefined
|
|
876
|
+
? undefined
|
|
877
|
+
: validateAnswer(entry.definition, diagnosis.resolved.value, {
|
|
878
|
+
recipe: definingRecipeKey(entry.recipe),
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
if (diagnosis.resolved !== undefined && validity?.ok === true) {
|
|
882
|
+
report.inherited.push({ defined: entry, resolved: diagnosis.resolved });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
blankLine();
|
|
887
|
+
paragraph(recipeOpeningLine(definingRecipeKey(entry.recipe), 1), {
|
|
888
|
+
color: palette.warning,
|
|
889
|
+
});
|
|
890
|
+
await runQuestion(
|
|
891
|
+
{ defined: entry },
|
|
892
|
+
{ index: 1, total: 1 },
|
|
893
|
+
context,
|
|
894
|
+
options,
|
|
895
|
+
report
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return report;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Asks one question, stores the answer, and prints the two lines that say what
|
|
904
|
+
* was stored and where.
|
|
905
|
+
*
|
|
906
|
+
* @param question - The variable and the value Enter alone would accept.
|
|
907
|
+
* @param place - This question's place in its recipe's run.
|
|
908
|
+
* @param context - The environment layers, updated as answers are stored.
|
|
909
|
+
* @param options - Where to write, and whether this is a dry run.
|
|
910
|
+
* @param report - The report to record the outcome in.
|
|
911
|
+
*/
|
|
912
|
+
async function runQuestion(
|
|
913
|
+
question: PlannedQuestion,
|
|
914
|
+
place: { index: number; total: number },
|
|
915
|
+
context: LadderContext,
|
|
916
|
+
options: AskOptions,
|
|
917
|
+
report: AskReport
|
|
918
|
+
): Promise<void> {
|
|
919
|
+
const { answer, plan } = await askOneQuestion(question, place, options);
|
|
920
|
+
const validated = validateAnswer(question.defined.definition, answer, {
|
|
921
|
+
recipe: definingRecipeKey(question.defined.recipe),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (!validated.ok) {
|
|
925
|
+
report.skipped.push({ defined: question.defined, reason: validated.message });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const stored = await storeAnswer(
|
|
930
|
+
question.defined,
|
|
931
|
+
String(validated.value),
|
|
932
|
+
context,
|
|
933
|
+
options,
|
|
934
|
+
plan
|
|
935
|
+
);
|
|
936
|
+
report.answered.push(stored);
|
|
937
|
+
|
|
938
|
+
blankLine();
|
|
939
|
+
showVariables([
|
|
940
|
+
{
|
|
941
|
+
label: "Answer",
|
|
942
|
+
value: `${stored.envName}=${displayValue(
|
|
943
|
+
stored.value,
|
|
944
|
+
question.defined.definition.secret
|
|
945
|
+
)}`,
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
label: options.dryRun === true ? "Would be saved to" : "Saved to",
|
|
949
|
+
value: stored.filePath,
|
|
950
|
+
},
|
|
951
|
+
]);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Stores one answer, in the env file and under the name the plan asks for (the
|
|
956
|
+
* definition's own choices when there is no plan). A name the resolution ladder
|
|
957
|
+
* would never look at, which is what "another name" in the advanced view
|
|
958
|
+
* produces, gets a mapping record so the answer is still found; and when the
|
|
959
|
+
* name the definition asks for is already bound to something that does not fit,
|
|
960
|
+
* a mapping is offered rather than an overwrite.
|
|
961
|
+
*
|
|
962
|
+
* @param defined - The variable being answered.
|
|
963
|
+
* @param value - The validated answer, in its stored form.
|
|
964
|
+
* @param context - The environment layers, updated so later lookups see the answer.
|
|
965
|
+
* @param options - Where to write, and whether this is a dry run.
|
|
966
|
+
* @param plan - Where the answer should go, when the advanced view settled it.
|
|
967
|
+
*/
|
|
968
|
+
async function storeAnswer(
|
|
969
|
+
defined: DefinedVariable,
|
|
970
|
+
value: string,
|
|
971
|
+
context: LadderContext,
|
|
972
|
+
options: AskOptions,
|
|
973
|
+
plan?: StoragePlan
|
|
974
|
+
): Promise<AnsweredVariable> {
|
|
975
|
+
const { definition } = defined;
|
|
976
|
+
const file = plan?.file ?? answerFileFor(definition);
|
|
977
|
+
const filePath = path.join(options.sousDir, file);
|
|
978
|
+
|
|
979
|
+
let envName = plan?.envName ?? bareName(definition);
|
|
980
|
+
let mapping: AnsweredVariable["mapping"];
|
|
981
|
+
|
|
982
|
+
// A chosen name the ladder never looks at needs a record binding it to this
|
|
983
|
+
// variable, or the answer would be written and then never found again.
|
|
984
|
+
const reachable = variableCandidates(defined, context).some(
|
|
985
|
+
(candidate) => candidate.envName === envName
|
|
986
|
+
);
|
|
987
|
+
if (!reachable) {
|
|
988
|
+
const target = formatMappingTarget(mappingTargetFor(defined));
|
|
989
|
+
const mappingPath =
|
|
990
|
+
options.dryRun === true
|
|
991
|
+
? path.join(options.confDir, VAR_MAPPINGS_LAYER_FILENAME)
|
|
992
|
+
: writeMappingRecord(options.confDir, envName, target);
|
|
993
|
+
mapping = { envName, target, filePath: mappingPath };
|
|
994
|
+
context.mappings = { ...context.mappings, [envName]: target };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const occupant = lookupEnvName(envName, context);
|
|
998
|
+
const conflicts =
|
|
999
|
+
mapping === undefined &&
|
|
1000
|
+
occupant !== undefined &&
|
|
1001
|
+
validateAnswer(definition, occupant.value, { recipe: definingRecipeKey(defined.recipe) }).ok ===
|
|
1002
|
+
false;
|
|
1003
|
+
|
|
1004
|
+
if (conflicts) {
|
|
1005
|
+
const scopedName = recipeScopedName(
|
|
1006
|
+
defined.recipe.namespace,
|
|
1007
|
+
defined.recipe.name,
|
|
1008
|
+
definition.name
|
|
1009
|
+
);
|
|
1010
|
+
const target = formatMappingTarget(mappingTargetFor(defined));
|
|
1011
|
+
|
|
1012
|
+
const useMapping = options.interactive
|
|
1013
|
+
? (await select({
|
|
1014
|
+
message:
|
|
1015
|
+
`${envName} already holds a value that does not fit ${definition.name}. ` +
|
|
1016
|
+
"Where should this answer go?",
|
|
1017
|
+
choices: [
|
|
1018
|
+
{ name: `${scopedName}, with a mapping record (recommended)`, value: true },
|
|
1019
|
+
{ name: `${envName}, replacing what is there`, value: false },
|
|
1020
|
+
],
|
|
1021
|
+
})) === true
|
|
1022
|
+
: true;
|
|
1023
|
+
|
|
1024
|
+
if (useMapping) {
|
|
1025
|
+
envName = scopedName;
|
|
1026
|
+
const mappingPath = options.dryRun === true
|
|
1027
|
+
? path.join(options.confDir, VAR_MAPPINGS_LAYER_FILENAME)
|
|
1028
|
+
: writeMappingRecord(options.confDir, scopedName, target);
|
|
1029
|
+
mapping = { envName: scopedName, target, filePath: mappingPath };
|
|
1030
|
+
context.mappings = { ...context.mappings, [scopedName]: target };
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const outcome =
|
|
1035
|
+
options.dryRun === true ? "not written" : updateEnvFile(filePath, envName, value, {
|
|
1036
|
+
header: answerHeader(defined),
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
if (options.dryRun !== true) {
|
|
1040
|
+
recordAnswerInContext(context, file, envName, value);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return { defined, envName, file, filePath, value, outcome, ...(mapping ? { mapping } : {}) };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Renders an ask report as the lines a command prints: what was answered, what
|
|
1048
|
+
* was inherited (always shown, with its scope and source, so an answer that
|
|
1049
|
+
* came from somewhere else is never a surprise), and what was left alone.
|
|
1050
|
+
*
|
|
1051
|
+
* @param report - The report to render.
|
|
1052
|
+
* @param dryRun - When true, the wording says what WOULD have been written.
|
|
1053
|
+
*/
|
|
1054
|
+
export function formatAskReport(report: AskReport, dryRun = false): string[] {
|
|
1055
|
+
const lines: string[] = [];
|
|
1056
|
+
const width = wrapColumns() - 2;
|
|
1057
|
+
|
|
1058
|
+
/** The variable name every entry in this report is labeled with. */
|
|
1059
|
+
const labelWidth = Math.max(
|
|
1060
|
+
0,
|
|
1061
|
+
...[...report.inherited, ...report.answered, ...report.skipped].map(
|
|
1062
|
+
(entry) => entry.defined.definition.name.length
|
|
1063
|
+
)
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
/** One entry of the report, laid out by the shared key and value renderer. */
|
|
1067
|
+
const entryLines = (
|
|
1068
|
+
label: string,
|
|
1069
|
+
value: string,
|
|
1070
|
+
detail?: string
|
|
1071
|
+
): string[] =>
|
|
1072
|
+
formatVariable(
|
|
1073
|
+
{ label, value, ...(detail === undefined ? {} : { detail }) },
|
|
1074
|
+
{ labelWidth, width }
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
/** A note about the entry above it, hanging under the value column. */
|
|
1078
|
+
const noteLines = (text: string): string[] =>
|
|
1079
|
+
wrapText(text, width - VARIABLE_INDENT - labelWidth - 2).map(
|
|
1080
|
+
(line) => `${" ".repeat(VARIABLE_INDENT + labelWidth + 2)}${palette.muted(line)}`
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
if (report.inherited.length > 0) {
|
|
1084
|
+
lines.push("Answers already in scope:");
|
|
1085
|
+
for (const entry of report.inherited) {
|
|
1086
|
+
const shown = displayValue(entry.resolved.value, entry.defined.definition.secret);
|
|
1087
|
+
lines.push(
|
|
1088
|
+
...entryLines(
|
|
1089
|
+
entry.defined.definition.name,
|
|
1090
|
+
shown,
|
|
1091
|
+
`from ${describeSource(entry.resolved.source)}`
|
|
1092
|
+
)
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
lines.push("");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (report.answered.length > 0) {
|
|
1099
|
+
lines.push(dryRun ? "Answers that would be stored:" : "Answers stored:");
|
|
1100
|
+
for (const entry of report.answered) {
|
|
1101
|
+
const shown = displayValue(entry.value, entry.defined.definition.secret);
|
|
1102
|
+
lines.push(
|
|
1103
|
+
...entryLines(
|
|
1104
|
+
entry.defined.definition.name,
|
|
1105
|
+
shown,
|
|
1106
|
+
`${entry.envName} in ${entry.file}`
|
|
1107
|
+
)
|
|
1108
|
+
);
|
|
1109
|
+
if (entry.mapping !== undefined) {
|
|
1110
|
+
lines.push(...noteLines(`mapped to ${entry.mapping.target} in the conf.d layer`));
|
|
1111
|
+
}
|
|
1112
|
+
if (entry.replaced !== undefined) {
|
|
1113
|
+
const previous = displayValue(entry.replaced, entry.defined.definition.secret);
|
|
1114
|
+
lines.push(
|
|
1115
|
+
...noteLines(
|
|
1116
|
+
dryRun
|
|
1117
|
+
? `replacing the answer already there: ${previous}`
|
|
1118
|
+
: `replaced the answer already there: ${previous}`
|
|
1119
|
+
)
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (entry.shadowedBy !== undefined) {
|
|
1123
|
+
lines.push(
|
|
1124
|
+
...noteLines(
|
|
1125
|
+
`${entry.shadowedBy} is set in your shell environment and answers this ` +
|
|
1126
|
+
`variable first; unset it for the stored answer to take effect`
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
lines.push("");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (report.skipped.length > 0) {
|
|
1135
|
+
lines.push("Left unanswered:");
|
|
1136
|
+
for (const entry of report.skipped) {
|
|
1137
|
+
lines.push(...entryLines(entry.defined.definition.name, entry.reason));
|
|
1138
|
+
}
|
|
1139
|
+
lines.push("");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (lines.length === 0) {
|
|
1143
|
+
lines.push(
|
|
1144
|
+
...wrapText(
|
|
1145
|
+
"Every variable in play already has an answer that fits its definition.",
|
|
1146
|
+
width
|
|
1147
|
+
)
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return lines;
|
|
1152
|
+
}
|