create-pathfinder 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +34 -27
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +2 -2
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +816 -89
- package/src/install.mjs +30 -2
- package/src/kit.mjs +16 -0
- package/src/progress.mjs +144 -0
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +590 -0
package/src/install.mjs
CHANGED
|
@@ -65,15 +65,28 @@ export function planInstall(kitRoot, targetRoot, { force = false } = {}) {
|
|
|
65
65
|
* itself alongside everything that did succeed instead of aborting the run
|
|
66
66
|
* halfway with no summary. Entries marked `skip` are never opened.
|
|
67
67
|
*
|
|
68
|
+
* `onProgress` is called once per plan item, after that item has resolved, with
|
|
69
|
+
* the item and whether it succeeded. It exists so a caller can report real
|
|
70
|
+
* progress without this function knowing what a progress bar is — there is no
|
|
71
|
+
* timer here, no rate, and no percentage, because a completion event is the
|
|
72
|
+
* only thing this layer actually knows. Omit it and nothing changes: the
|
|
73
|
+
* callback is the only difference between this and the version that had no
|
|
74
|
+
* parameter at all.
|
|
75
|
+
*
|
|
76
|
+
* A failed write reports `ok: false` rather than being skipped silently. A
|
|
77
|
+
* caller that counted only calls, and not outcomes, could otherwise render a
|
|
78
|
+
* complete bar over a partly failed install.
|
|
79
|
+
*
|
|
68
80
|
* @returns {{written: number, skipped: number, overwritten: number,
|
|
69
81
|
* errors: {relativePath: string, message: string}[]}}
|
|
70
82
|
*/
|
|
71
|
-
export function applyPlan(plan, { dryRun = false } = {}) {
|
|
83
|
+
export function applyPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
72
84
|
const result = { written: 0, skipped: 0, overwritten: 0, errors: [] };
|
|
73
85
|
|
|
74
86
|
for (const item of plan) {
|
|
75
87
|
if (item.status === "skip") {
|
|
76
88
|
result.skipped += 1;
|
|
89
|
+
onProgress?.({ item, ok: true });
|
|
77
90
|
continue;
|
|
78
91
|
}
|
|
79
92
|
|
|
@@ -86,12 +99,14 @@ export function applyPlan(plan, { dryRun = false } = {}) {
|
|
|
86
99
|
relativePath: item.relativePath,
|
|
87
100
|
message: error.message,
|
|
88
101
|
});
|
|
102
|
+
onProgress?.({ item, ok: false });
|
|
89
103
|
continue;
|
|
90
104
|
}
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
if (item.status === "overwrite") result.overwritten += 1;
|
|
94
108
|
else result.written += 1;
|
|
109
|
+
onProgress?.({ item, ok: true });
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
return result;
|
|
@@ -205,26 +220,37 @@ function actionFor(state, force) {
|
|
|
205
220
|
* Conflicts and orphans are outcomes, not errors: nothing went wrong, and the
|
|
206
221
|
* files they name are the ones this tool successfully left alone.
|
|
207
222
|
*
|
|
223
|
+
* `onProgress` behaves exactly as it does in `applyPlan`, and for the same
|
|
224
|
+
* reason: one call per plan item, after it resolves, carrying the item and
|
|
225
|
+
* whether it succeeded. Conflicts, orphans, and up-to-date adapters all report
|
|
226
|
+
* `ok: true` — nothing went wrong in any of those cases, and each one is an
|
|
227
|
+
* enumerated unit of a plan the user is watching being carried out. Only an
|
|
228
|
+
* unreadable file and a failed write report `ok: false`.
|
|
229
|
+
*
|
|
208
230
|
* @returns {{generated: number, replaced: number, unchanged: number,
|
|
209
231
|
* conflicts: string[], orphans: string[],
|
|
210
232
|
* errors: {relativePath: string, message: string}[]}}
|
|
211
233
|
*/
|
|
212
|
-
export function applyAdapterPlan(plan, { dryRun = false } = {}) {
|
|
234
|
+
export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
213
235
|
const result = { generated: 0, replaced: 0, unchanged: 0, conflicts: [], orphans: [], errors: [] };
|
|
214
236
|
|
|
215
237
|
for (const item of plan) {
|
|
216
238
|
switch (item.action) {
|
|
217
239
|
case "up-to-date":
|
|
218
240
|
result.unchanged += 1;
|
|
241
|
+
onProgress?.({ item, ok: true });
|
|
219
242
|
continue;
|
|
220
243
|
case "conflict":
|
|
221
244
|
result.conflicts.push(item.relativePath);
|
|
245
|
+
onProgress?.({ item, ok: true });
|
|
222
246
|
continue;
|
|
223
247
|
case "orphan":
|
|
224
248
|
result.orphans.push(item.relativePath);
|
|
249
|
+
onProgress?.({ item, ok: true });
|
|
225
250
|
continue;
|
|
226
251
|
case "unreadable":
|
|
227
252
|
result.errors.push({ relativePath: item.relativePath, message: item.message });
|
|
253
|
+
onProgress?.({ item, ok: false });
|
|
228
254
|
continue;
|
|
229
255
|
default:
|
|
230
256
|
break;
|
|
@@ -236,12 +262,14 @@ export function applyAdapterPlan(plan, { dryRun = false } = {}) {
|
|
|
236
262
|
writeFileSync(item.destination, item.contents, "utf8");
|
|
237
263
|
} catch (error) {
|
|
238
264
|
result.errors.push({ relativePath: item.relativePath, message: error.message });
|
|
265
|
+
onProgress?.({ item, ok: false });
|
|
239
266
|
continue;
|
|
240
267
|
}
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
if (item.action === "replace") result.replaced += 1;
|
|
244
271
|
else result.generated += 1;
|
|
272
|
+
onProgress?.({ item, ok: true });
|
|
245
273
|
}
|
|
246
274
|
|
|
247
275
|
return result;
|
package/src/kit.mjs
CHANGED
|
@@ -51,6 +51,22 @@ export function isExcluded(basename) {
|
|
|
51
51
|
|
|
52
52
|
const PACKAGE_ROOT = resolve(HERE, "..");
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* This installer's own version, for the identity block to state.
|
|
56
|
+
*
|
|
57
|
+
* Read from the package manifest for the same reason the copy list is: the
|
|
58
|
+
* number has one home, and a constant declared here would be a second one that
|
|
59
|
+
* `npm version` does not know to update. Read once at module load, because a
|
|
60
|
+
* file that is part of the running package cannot change underneath a single
|
|
61
|
+
* run, and because a failure to read it should surface at import rather than
|
|
62
|
+
* halfway through a report.
|
|
63
|
+
*
|
|
64
|
+
* Not exported through `findKitRoot`'s resolution: the manifest sits beside
|
|
65
|
+
* this source in both layouts, published and checkout alike, so there is
|
|
66
|
+
* nothing to search for.
|
|
67
|
+
*/
|
|
68
|
+
export const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
69
|
+
|
|
54
70
|
/**
|
|
55
71
|
* Locate the kit directories this tool should copy from.
|
|
56
72
|
*
|
package/src/progress.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install phase's progress bar: one counter, one format function, one write.
|
|
3
|
+
*
|
|
4
|
+
* Not a renderer framework, and deliberately not extensible. It holds three
|
|
5
|
+
* numbers and knows how to draw one line from them.
|
|
6
|
+
*
|
|
7
|
+
* **What makes this bar honest**, since a progress bar is the easiest thing in
|
|
8
|
+
* a CLI to fake and the prototype rejected an earlier version of it as theatre:
|
|
9
|
+
*
|
|
10
|
+
* - **The denominator is a plan, not an estimate.** `planInstall` and
|
|
11
|
+
* `planAdapters` both return complete lists before a single byte is written,
|
|
12
|
+
* so the total is a count of enumerated work rather than a guess that gets
|
|
13
|
+
* corrected as it goes.
|
|
14
|
+
* - **The numerator is completions.** It moves when `applyPlan` or
|
|
15
|
+
* `applyAdapterPlan` finishes a unit and tells us so. There is no timer in
|
|
16
|
+
* this file, no interval, no rate, and nothing that advances because time
|
|
17
|
+
* passed. That is also why no throttle is needed: the bar repaints at most
|
|
18
|
+
* once per completed unit, and the unit count is bounded by the plan.
|
|
19
|
+
* - **A run that finishes instantly is a correct run.** If the work takes 30ms
|
|
20
|
+
* the bar reaches 100% in 30ms and the phase ends. Nothing sleeps or paces
|
|
21
|
+
* itself so the effect can be admired.
|
|
22
|
+
* - **Nothing is displayed that has not been earned.** Both the fill and the
|
|
23
|
+
* percentage floor rather than round, so neither can show a complete bar
|
|
24
|
+
* while a unit is still outstanding.
|
|
25
|
+
* - **A failure cannot produce a clean 100%.** A failed unit advances nothing;
|
|
26
|
+
* it increments a separate counter that the bar renders explicitly. An
|
|
27
|
+
* install that lost two files ends visibly short with those two named, which
|
|
28
|
+
* is the one thing the summary underneath must never be contradicted about.
|
|
29
|
+
*
|
|
30
|
+
* **What it deliberately cannot do.** There is no hide-cursor sequence here and
|
|
31
|
+
* no way to write one: `theme.line` exposes exactly two escapes, return to
|
|
32
|
+
* column zero and clear the line, and neither of them is stateful. A renderer
|
|
33
|
+
* with nothing to restore cannot leave a terminal broken, so this file has no
|
|
34
|
+
* signal handler, no exit hook, and no try/finally — an interrupted run leaves
|
|
35
|
+
* a visible cursor on a partly drawn bar, which is the accepted cost and the
|
|
36
|
+
* reason the guarantee holds without any machinery defending it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How many cells the bar occupies.
|
|
41
|
+
*
|
|
42
|
+
* A constant, not a function of the terminal width. Deriving it from the
|
|
43
|
+
* terminal would mean measuring, would make the bar jump on a resize, and would
|
|
44
|
+
* make its rendering depend on a value this process has no reliable way to
|
|
45
|
+
* track. A fixed bar is stable in scrollback and identical in every transcript.
|
|
46
|
+
*/
|
|
47
|
+
const BAR_CELLS = 24;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the progress treatment for one run.
|
|
51
|
+
*
|
|
52
|
+
* Three behaviours, chosen from the theme and never from a call site:
|
|
53
|
+
*
|
|
54
|
+
* - `contract` — nothing at all. No bar, no milestone, no byte. This tier is a
|
|
55
|
+
* promise made to scripts, and a repaint written into a pipe would produce a
|
|
56
|
+
* log file containing a transcript of an animation.
|
|
57
|
+
* - `plain` — milestones, no bar. The phase still says what it finished; it
|
|
58
|
+
* simply does not repaint a line, because `dynamic` is false.
|
|
59
|
+
* - `expressive` — milestones and the bar.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {object} options.theme - the run's theme, from `createTheme`.
|
|
63
|
+
* @param {number} options.total - units the plans enumerated. Zero disables the
|
|
64
|
+
* bar, which is what a dry run and an empty plan both want.
|
|
65
|
+
* @param {(text: string) => void} options.out - where to write.
|
|
66
|
+
*/
|
|
67
|
+
export function createProgress({ theme, total = 0, out = () => {} } = {}) {
|
|
68
|
+
const speaks = theme.tier !== "contract";
|
|
69
|
+
const repaints = speaks && theme.dynamic && total > 0;
|
|
70
|
+
|
|
71
|
+
let done = 0;
|
|
72
|
+
let failed = 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The bar, as one line, from the three numbers above.
|
|
76
|
+
*
|
|
77
|
+
* Floor rather than round on both the fill and the percentage. Rounding would
|
|
78
|
+
* let 35 of 36 units display a full bar at 100%, which is a claim about work
|
|
79
|
+
* that has not happened — the exact defect this whole design exists to avoid,
|
|
80
|
+
* and one that would only ever be visible on the last unit of a slow run.
|
|
81
|
+
*/
|
|
82
|
+
const bar = () => {
|
|
83
|
+
const filled = Math.floor((done / total) * BAR_CELLS);
|
|
84
|
+
const track =
|
|
85
|
+
theme.glyph.barFull.repeat(filled) + theme.glyph.barEmpty.repeat(BAR_CELLS - filled);
|
|
86
|
+
const percent = String(Math.floor((done / total) * 100)).padStart(3);
|
|
87
|
+
|
|
88
|
+
// Painted `warn` the moment anything has failed, so the bar's own colour
|
|
89
|
+
// stops agreeing with a summary that is about to report an error. Colour is
|
|
90
|
+
// not the only carrier: the count and its glyph are spelled out beside it.
|
|
91
|
+
const tail = failed > 0 ? ` ${theme.warn(`${theme.glyph.warn} ${failed} failed`)}` : "";
|
|
92
|
+
return ` ${failed > 0 ? theme.warn(track) : theme.ok(track)} ${percent}%${tail}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const repaint = () => {
|
|
96
|
+
if (repaints) out(theme.line.start() + theme.line.clear() + bar());
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
/** One unit resolved. The only thing that may move this bar. */
|
|
101
|
+
advance({ ok = true } = {}) {
|
|
102
|
+
if (ok) done += 1;
|
|
103
|
+
else failed += 1;
|
|
104
|
+
repaint();
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A phase finished. Printed above the bar, which is then redrawn.
|
|
109
|
+
*
|
|
110
|
+
* The clear-then-print-then-repaint order is what lets a milestone scroll
|
|
111
|
+
* into the transcript while the bar stays on the last line, using only the
|
|
112
|
+
* two escapes the theme exposes. Moving the cursor up to keep the bar in
|
|
113
|
+
* place would need sequences that do not exist here on purpose.
|
|
114
|
+
*/
|
|
115
|
+
milestone(text) {
|
|
116
|
+
if (!speaks) return;
|
|
117
|
+
if (repaints) out(theme.line.start() + theme.line.clear());
|
|
118
|
+
out(`${text}\n`);
|
|
119
|
+
repaint();
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* End the phase, leaving the completed bar on screen.
|
|
124
|
+
*
|
|
125
|
+
* One newline, so the bar becomes part of the transcript rather than
|
|
126
|
+
* something the summary overwrites. Someone pasting the run into an issue
|
|
127
|
+
* keeps the evidence that the phase ran.
|
|
128
|
+
*/
|
|
129
|
+
finish() {
|
|
130
|
+
if (repaints) out("\n");
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/** Exposed for tests and for a caller that wants to assert its own totals. */
|
|
134
|
+
get completed() {
|
|
135
|
+
return done;
|
|
136
|
+
},
|
|
137
|
+
get failures() {
|
|
138
|
+
return failed;
|
|
139
|
+
},
|
|
140
|
+
get total() {
|
|
141
|
+
return total;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/prompt.mjs
CHANGED
|
@@ -10,10 +10,31 @@
|
|
|
10
10
|
* nothing must also print nothing, and a guard bug that degrades silently would
|
|
11
11
|
* show up as a script that hangs on someone's CI runner months later.
|
|
12
12
|
*
|
|
13
|
-
* The second is the decided interaction model
|
|
14
|
-
* lines. No setRawMode, no keypress handler, no cursor control, no redraw
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The second is the decided interaction model. ~~`node:readline` and printed
|
|
14
|
+
* lines. No setRawMode, no keypress handler, no cursor control, no redraw.~~
|
|
15
|
+
* **Superseded in Feature 23**, in two different ways, and the sentence was
|
|
16
|
+
* partly wrong when it was written:
|
|
17
|
+
*
|
|
18
|
+
* - **"No setRawMode" was never true of the process**, only of this file.
|
|
19
|
+
* `node:readline` turns raw mode on itself the moment its input is a TTY, and
|
|
20
|
+
* turns it off again on `close()` — verified across five Node majors during
|
|
21
|
+
* the spike. What is true, and is now the load-bearing statement, is that
|
|
22
|
+
* **readline owns raw mode and Pathfinder never touches it.** The distinction
|
|
23
|
+
* matters: the first is a claim about terminal state that a reader could check
|
|
24
|
+
* and find false, and the second is the reason no interrupt can leave a
|
|
25
|
+
* terminal broken.
|
|
26
|
+
* - **There is now a keypress handler and a redraw**, in `select.mjs`, borrowed
|
|
27
|
+
* from readline and given back. Which is a change of interaction, not of
|
|
28
|
+
* ownership.
|
|
29
|
+
*
|
|
30
|
+
* What has *not* changed is the reason the original sentence existed. The
|
|
31
|
+
* line-based path still works in a dumb terminal, over a pipe, and on a CI
|
|
32
|
+
* runner, and it is still here — every function below keeps its numbered/`y n`
|
|
33
|
+
* implementation intact and reaches for it whenever `theme.selection` is false.
|
|
34
|
+
* That is a supported way to use this tool, reachable deliberately with
|
|
35
|
+
* `PATHFINDER_PROMPT=classic`, and not a fallback anyone should feel they have
|
|
36
|
+
* been demoted to. `text()` never had anything to gain from a keypress loop and
|
|
37
|
+
* is untouched.
|
|
17
38
|
*
|
|
18
39
|
* Answers are bounded. Unparseable input is re-prompted a fixed number of times
|
|
19
40
|
* and then gives up, so an input stream that will never produce a `y` cannot
|
|
@@ -24,14 +45,25 @@
|
|
|
24
45
|
|
|
25
46
|
import { createInterface } from "node:readline";
|
|
26
47
|
|
|
48
|
+
import { alignmentWidth, optionRow, select } from "./select.mjs";
|
|
49
|
+
import { createTheme } from "./theme.mjs";
|
|
50
|
+
|
|
27
51
|
const YES = new Set(["y", "yes"]);
|
|
28
52
|
const NO = new Set(["n", "no"]);
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
55
|
* A question-asker bound to one pair of streams.
|
|
32
56
|
*
|
|
57
|
+
* `theme` decides *how* a question is asked and nothing else. Every function
|
|
58
|
+
* below returns the same values through either path, which is what lets the
|
|
59
|
+
* choice be made here instead of at four call sites — and what lets the whole
|
|
60
|
+
* existing test suite drive the classic path unchanged by simply not supplying
|
|
61
|
+
* one. The default theme knows about no terminal at all, so it answers no to
|
|
62
|
+
* `selection`, which is the conservative answer and the right one for a
|
|
63
|
+
* prompter built from streams nobody has described.
|
|
64
|
+
*
|
|
33
65
|
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream,
|
|
34
|
-
* interactive?: boolean, retries?: number}} options
|
|
66
|
+
* interactive?: boolean, retries?: number, theme?: object}} options
|
|
35
67
|
* @returns {{interactive: boolean,
|
|
36
68
|
* confirm: (question: string, options?: {defaultAnswer?: boolean}) => Promise<boolean|null>,
|
|
37
69
|
* chooseMany: (question: string, config?: object) => Promise<unknown[]|null>,
|
|
@@ -39,7 +71,13 @@ const NO = new Set(["n", "no"]);
|
|
|
39
71
|
* text: (question: string) => Promise<string|null>,
|
|
40
72
|
* close: () => void}}
|
|
41
73
|
*/
|
|
42
|
-
export function createPrompter({
|
|
74
|
+
export function createPrompter({
|
|
75
|
+
input,
|
|
76
|
+
output,
|
|
77
|
+
interactive = false,
|
|
78
|
+
retries = 3,
|
|
79
|
+
theme = createTheme(),
|
|
80
|
+
}) {
|
|
43
81
|
let reader = null;
|
|
44
82
|
|
|
45
83
|
// Created on first use, not here. A run that never reaches a question — a
|
|
@@ -47,10 +85,23 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
47
85
|
// reader to stdin, because attaching one resumes the stream and a process
|
|
48
86
|
// holding an open stdin does not exit on its own.
|
|
49
87
|
function ensureReader() {
|
|
50
|
-
if (reader === null) reader = createReader({ input, output });
|
|
88
|
+
if (reader === null) reader = createReader({ input, output, theme });
|
|
51
89
|
return reader;
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Ask one question with the arrow keys.
|
|
94
|
+
*
|
|
95
|
+
* The Interface is handed over rather than a pair of streams, because the
|
|
96
|
+
* selector's whole contract is that it borrows from an open readline and
|
|
97
|
+
* gives it back. Nothing here consults `input.isTTY`: the decision was made
|
|
98
|
+
* once, in the theme, and a module that re-derived it could disagree with the
|
|
99
|
+
* one place that is allowed to have an opinion — and could not be tested over
|
|
100
|
+
* a pipe at all.
|
|
101
|
+
*/
|
|
102
|
+
const ask = (question, config) =>
|
|
103
|
+
select({ readline: ensureReader().readline, theme, question, ...config });
|
|
104
|
+
|
|
54
105
|
return {
|
|
55
106
|
interactive,
|
|
56
107
|
|
|
@@ -59,6 +110,20 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
59
110
|
throw new Error(`refusing to ask "${question}": this prompter is not interactive`);
|
|
60
111
|
}
|
|
61
112
|
|
|
113
|
+
// Two rows rather than a typed letter. `y` and `n` still work and are
|
|
114
|
+
// deliberately not printed: the presented interaction is the one the hint
|
|
115
|
+
// line describes, and an accelerator that has to be advertised is a second
|
|
116
|
+
// thing to learn rather than a shortcut for people who already know it.
|
|
117
|
+
if (theme.selection) {
|
|
118
|
+
return ask(question, {
|
|
119
|
+
options: [
|
|
120
|
+
{ label: "Yes", value: true, key: "y" },
|
|
121
|
+
{ label: "No", value: false, key: "n" },
|
|
122
|
+
],
|
|
123
|
+
initial: [defaultAnswer],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
62
127
|
const suffix = defaultAnswer ? "[Y/n]" : "[y/N]";
|
|
63
128
|
|
|
64
129
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
@@ -103,12 +168,28 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
103
168
|
}
|
|
104
169
|
if (choices.length === 0) return [];
|
|
105
170
|
|
|
171
|
+
// A checkbox list, which is what this question always was. Note what does
|
|
172
|
+
// *not* move: the caller still supplies `{label, value}` and still reads
|
|
173
|
+
// back the chosen values in list order, so no decision about what the
|
|
174
|
+
// options mean has crossed into this file.
|
|
175
|
+
if (theme.selection) {
|
|
176
|
+
return ask(question, { options: choices, multi: true, initial: defaultSelection });
|
|
177
|
+
}
|
|
178
|
+
|
|
106
179
|
const defaults = choices.filter((choice) => defaultSelection.includes(choice.value));
|
|
107
180
|
const defaultNumbers = defaults.map((choice) => choices.indexOf(choice) + 1);
|
|
108
181
|
|
|
182
|
+
// The same option grammar the selector uses, inside a numbered list
|
|
183
|
+
// instead of a repainted block. An option that names the path it writes
|
|
184
|
+
// to is not a decoration the keyboard path earned — it is the answer to
|
|
185
|
+
// "what does checking this box do", and both paths owe it.
|
|
186
|
+
const labelWidth = alignmentWidth(choices, theme);
|
|
187
|
+
|
|
109
188
|
const header = [
|
|
110
189
|
`? ${question}`,
|
|
111
|
-
...choices.map(
|
|
190
|
+
...choices.map(
|
|
191
|
+
(choice, index) => ` ${index + 1}. ${optionRow({ theme, option: choice, labelWidth })}`,
|
|
192
|
+
),
|
|
112
193
|
defaultNumbers.length > 0
|
|
113
194
|
? ` Numbers, comma-separated. Enter for the detected default [${defaultNumbers.join(",")}], or 0 for none.`
|
|
114
195
|
: " Numbers, comma-separated. Enter or 0 for none.",
|
|
@@ -158,12 +239,22 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
158
239
|
if (choices.length === 0) return null;
|
|
159
240
|
|
|
160
241
|
const fallback = choices.find((choice) => choice.value === defaultValue) ?? choices[0];
|
|
242
|
+
|
|
243
|
+
// The default becomes the highlighted row rather than a number in a
|
|
244
|
+
// sentence, so taking it still costs one keystroke.
|
|
245
|
+
if (theme.selection) {
|
|
246
|
+
return ask(question, { options: choices, initial: [fallback.value] });
|
|
247
|
+
}
|
|
248
|
+
|
|
161
249
|
const defaultNumber = choices.indexOf(fallback) + 1;
|
|
250
|
+
const labelWidth = alignmentWidth(choices, theme);
|
|
162
251
|
|
|
163
252
|
output.write(
|
|
164
253
|
[
|
|
165
254
|
`? ${question}`,
|
|
166
|
-
...choices.map(
|
|
255
|
+
...choices.map(
|
|
256
|
+
(choice, index) => ` ${index + 1}. ${optionRow({ theme, option: choice, labelWidth })}`,
|
|
257
|
+
),
|
|
167
258
|
` A number, or Enter for [${defaultNumber}].`,
|
|
168
259
|
"",
|
|
169
260
|
].join("\n"),
|
|
@@ -266,8 +357,16 @@ export function nonInteractivePrompter() {
|
|
|
266
357
|
* with a closed stdin must fall through to the caller's decision about an
|
|
267
358
|
* unanswered question, not hang.
|
|
268
359
|
*/
|
|
269
|
-
function createReader({ input, output }) {
|
|
270
|
-
|
|
360
|
+
function createReader({ input, output, theme }) {
|
|
361
|
+
// `terminal` is forced only where the selector will run, and only ever from
|
|
362
|
+
// false to true. On a real terminal readline works this out for itself and
|
|
363
|
+
// the flag changes nothing; over a pipe it is what makes the keypress decoder
|
|
364
|
+
// exist at all, which is the difference between a selector that can be tested
|
|
365
|
+
// and one that can only be tried. It is never forced *off*, so no classic run
|
|
366
|
+
// has its line editing altered by this.
|
|
367
|
+
const readline = createInterface(
|
|
368
|
+
theme?.selection ? { input, output, terminal: true } : { input, output },
|
|
369
|
+
);
|
|
271
370
|
const delivered = [];
|
|
272
371
|
const waiting = [];
|
|
273
372
|
let closed = false;
|
|
@@ -284,6 +383,12 @@ function createReader({ input, output }) {
|
|
|
284
383
|
});
|
|
285
384
|
|
|
286
385
|
return {
|
|
386
|
+
// The Interface itself, for the one caller that needs the *owner* rather
|
|
387
|
+
// than a line: the selector borrows this object's listeners and hands them
|
|
388
|
+
// back. Exposed rather than re-created, because a second Interface over the
|
|
389
|
+
// same stdin would be a second thing turning raw mode on and off.
|
|
390
|
+
readline,
|
|
391
|
+
|
|
287
392
|
/**
|
|
288
393
|
* Print `text` and resolve with the next line, or null at end of input.
|
|
289
394
|
*
|