projectstore-claude 0.29.2 → 0.29.4
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 +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -4,63 +4,194 @@
|
|
|
4
4
|
// One question decides everything here: is this a person at a terminal? Then
|
|
5
5
|
// colour and in-place step lines. Otherwise — a pipe, an agent's tool call,
|
|
6
6
|
// CI — the same layout as plain text, so a reader that is not a terminal loses
|
|
7
|
-
// nothing but the escapes (install spec, contract 18).
|
|
7
|
+
// nothing but the escapes (install spec, contract 18). The layout itself —
|
|
8
|
+
// the glyph table, the colour roles and the primitives every screen is built
|
|
9
|
+
// from — is the presentation spec's (*Terminal presentation of the
|
|
10
|
+
// projectstore CLI: layout, glyphs, colour roles, questions and live lines*,
|
|
11
|
+
// contracts 2–6 and 8).
|
|
8
12
|
//
|
|
9
|
-
// caps(stream, env) → { color, live, width, ascii }
|
|
10
|
-
// painter(caps) → paint(style, text)
|
|
11
|
-
// icon(caps, name) →
|
|
13
|
+
// caps(stream, env) → { color, live, width, columns, ascii }
|
|
14
|
+
// painter(caps) → paint(style or role, text)
|
|
15
|
+
// icon(caps, name) → a glyph or separator from GLYPHS
|
|
16
|
+
// glyphText(caps, text) → the bin's own prose, its glyphs in caps' column
|
|
12
17
|
// duration(ms) → "0.4s", "12s", "1m 03s"
|
|
13
18
|
// wrap(text, width, indent) → prose broken at spaces, hanging indent
|
|
14
|
-
//
|
|
19
|
+
// badge(caps, head, parts) → a screen's first line
|
|
20
|
+
// heading(caps, title, counts) → "Plan — 3 to add · 5 unchanged"
|
|
21
|
+
// rows(caps, list, opts) → aligned glyph · action · target · note lines
|
|
22
|
+
// rowLines(caps, list, opts) → the same, one array of lines per row
|
|
23
|
+
// PLAIN → the caps of a reader that is not a terminal
|
|
24
|
+
// rule(caps, length) → a horizontal rule
|
|
25
|
+
// kv(caps, pairs, opts) → aligned key–value lines
|
|
26
|
+
// commandBlock(caps, cmds) → commands on their own lines (contract 6)
|
|
27
|
+
// stepReporter(stream, caps) → { start(label), end(ok, note, opts), finish(label, t0, opts), abort() }
|
|
28
|
+
// liveLine(stream, caps, label) → { end(ok, doneLabel, note), abort() }
|
|
15
29
|
// askLine(question, in, out) → the answer, or null on end of input
|
|
30
|
+
// askApply(n, opts) → contract 9's question; true applies
|
|
31
|
+
// railAvailable(stdin, caps) → true when the question rail may be drawn
|
|
32
|
+
// openRail(opts) → { select, multi, confirm, path, answered, close, cancelled }
|
|
33
|
+
// CANCELLED, BACK → what a rail question resolves to on Ctrl+C or end of input, and on Esc
|
|
34
|
+
// drainInput(stdin, opts) → discards what was typed ahead, before a line-mode question
|
|
16
35
|
|
|
17
|
-
import { createInterface } from "node:readline
|
|
36
|
+
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
18
37
|
|
|
19
38
|
// FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
|
|
20
39
|
// "2", "3" or "true" turn colour on even without a terminal, anything else
|
|
21
40
|
// ("0", "false") turns it off. Otherwise NO_COLOR (https://no-color.org) turns
|
|
22
41
|
// colour off when set to anything non-empty, and TERM=dumb means a terminal
|
|
23
42
|
// that understands no escapes.
|
|
43
|
+
//
|
|
44
|
+
// Glyphs are a switch of their own (presentation spec contract 2): ASCII
|
|
45
|
+
// under TERM=dumb or PROJECTSTORE_ASCII set non-empty, Unicode otherwise, in
|
|
46
|
+
// every mode. PROJECTSTORE_ASCII changes glyphs only — colour, live lines and
|
|
47
|
+
// questions follow the stream — so a terminal that draws ambiguous-width
|
|
48
|
+
// characters wide still gets aligned rows, in colour.
|
|
24
49
|
export function caps(stream = process.stdout, env = process.env) {
|
|
25
50
|
const tty = Boolean(stream && stream.isTTY);
|
|
26
51
|
const dumb = env.TERM === "dumb";
|
|
27
52
|
const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
|
|
28
|
-
const color =
|
|
53
|
+
const color =
|
|
54
|
+
env.FORCE_COLOR !== undefined
|
|
55
|
+
? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR))
|
|
56
|
+
: tty && !dumb && !off;
|
|
29
57
|
const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
|
|
58
|
+
const ascii = dumb || (env.PROJECTSTORE_ASCII !== undefined && env.PROJECTSTORE_ASCII !== "");
|
|
30
59
|
// `width` lays out prose (never below 40); `columns` is the terminal's
|
|
31
60
|
// real width, which a live line must not reach.
|
|
32
|
-
return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii
|
|
61
|
+
return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii };
|
|
33
62
|
}
|
|
34
63
|
|
|
64
|
+
// The sixteen ANSI colours, bold and dim — never 256-colour or truecolor, so
|
|
65
|
+
// the reader's terminal theme decides the shades, light or dark.
|
|
35
66
|
const SGR = {
|
|
36
|
-
bold: [1, 22],
|
|
37
|
-
|
|
67
|
+
bold: [1, 22],
|
|
68
|
+
dim: [2, 22],
|
|
69
|
+
red: [31, 39],
|
|
70
|
+
green: [32, 39],
|
|
71
|
+
yellow: [33, 39],
|
|
72
|
+
blue: [34, 39],
|
|
73
|
+
magenta: [35, 39],
|
|
74
|
+
cyan: [36, 39],
|
|
75
|
+
gray: [90, 39],
|
|
38
76
|
};
|
|
39
77
|
|
|
78
|
+
// The colour roles (presentation spec contract 3), each one style above.
|
|
79
|
+
// Colour never carries meaning alone: whatever wears a role also has a glyph
|
|
80
|
+
// or a word that says the same.
|
|
81
|
+
// action the active question, a command to run, a verb, a fetch
|
|
82
|
+
// done ✓, an added row's glyph, a zero-issue count
|
|
83
|
+
// attention ▲, a channel switch, a refusal, a warning count
|
|
84
|
+
// error ✕, a failed step, an issue count
|
|
85
|
+
// explanation notes, reasons, consent lines, the rail, the project path
|
|
86
|
+
// emphasis headings, a question's title, a finding's title
|
|
87
|
+
export const ROLES = Object.freeze({
|
|
88
|
+
action: "cyan",
|
|
89
|
+
done: "green",
|
|
90
|
+
attention: "yellow",
|
|
91
|
+
error: "red",
|
|
92
|
+
explanation: "gray",
|
|
93
|
+
emphasis: "bold",
|
|
94
|
+
});
|
|
95
|
+
|
|
40
96
|
// Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
|
|
41
|
-
// the package promises >=20.0.0.
|
|
97
|
+
// the package promises >=20.0.0. `style` is a role or a style name; anything
|
|
98
|
+
// else, like a null role, paints nothing.
|
|
42
99
|
export function painter(c) {
|
|
43
100
|
return (style, text) => {
|
|
44
101
|
const s = String(text);
|
|
45
|
-
|
|
46
|
-
return
|
|
102
|
+
const sgr = SGR[ROLES[style] || style];
|
|
103
|
+
if (!c.color || !sgr || !s) return s;
|
|
104
|
+
return `\x1b[${sgr[0]}m${s}\x1b[${sgr[1]}m`;
|
|
47
105
|
};
|
|
48
106
|
}
|
|
49
107
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
108
|
+
// The one glyph table (presentation spec contract 4): every glyph and
|
|
109
|
+
// separator a renderer composes, as [Unicode, ASCII]. The ASCII column is
|
|
110
|
+
// used when caps().ascii says so. Changes from 0.29.2: fail ✗ → ✕ (FAIL →
|
|
111
|
+
// x); remove and cleanup ✕ → − (x → -); refuse and note ! → ▲.
|
|
112
|
+
export const GLYPHS = Object.freeze({
|
|
113
|
+
ok: Object.freeze(["✓", "ok"]),
|
|
114
|
+
add: Object.freeze(["+", "+"]),
|
|
115
|
+
fail: Object.freeze(["✕", "x"]),
|
|
116
|
+
update: Object.freeze(["↻", "~"]),
|
|
117
|
+
warning: Object.freeze(["▲", "!"]),
|
|
118
|
+
switch: Object.freeze(["⇄", "<>"]),
|
|
119
|
+
unchanged: Object.freeze(["·", "."]),
|
|
120
|
+
remove: Object.freeze(["−", "-"]),
|
|
121
|
+
fetch: Object.freeze(["↓", "v"]),
|
|
122
|
+
running: Object.freeze(["…", "..."]),
|
|
123
|
+
ask: Object.freeze(["◆", "*"]),
|
|
124
|
+
answered: Object.freeze(["◇", "o"]),
|
|
125
|
+
choiceOn: Object.freeze(["●", "(*)"]),
|
|
126
|
+
choiceOff: Object.freeze(["○", "( )"]),
|
|
127
|
+
checkOn: Object.freeze(["◼", "[x]"]),
|
|
128
|
+
checkOff: Object.freeze(["◻", "[ ]"]),
|
|
129
|
+
rail: Object.freeze(["│", "|"]),
|
|
130
|
+
railTop: Object.freeze(["┌", "+"]),
|
|
131
|
+
railEnd: Object.freeze(["└", "+"]),
|
|
132
|
+
rule: Object.freeze(["─", "-"]),
|
|
133
|
+
progressDone: Object.freeze(["━", "#"]),
|
|
134
|
+
progressLeft: Object.freeze(["╺", "-"]),
|
|
135
|
+
dash: Object.freeze(["—", "-"]),
|
|
136
|
+
dot: Object.freeze(["·", "."]),
|
|
137
|
+
// A move or a state change in a step's text: `a → b` (decided 2026-10-10,
|
|
138
|
+
// added to the spec's table at the story's close-out).
|
|
139
|
+
transition: Object.freeze(["→", "->"]),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// The other names a glyph answers to: the spec's shared rows (fail / issue,
|
|
143
|
+
// warning / refusal / note, unchanged / skipped) and the plan's own action
|
|
144
|
+
// names, so a caller says what happened rather than which shape to draw.
|
|
145
|
+
export const GLYPH_ALIASES = Object.freeze({
|
|
146
|
+
issue: "fail",
|
|
147
|
+
refuse: "warning",
|
|
148
|
+
refusal: "warning",
|
|
149
|
+
note: "warning",
|
|
150
|
+
skip: "unchanged",
|
|
151
|
+
skipped: "unchanged",
|
|
152
|
+
create: "add",
|
|
153
|
+
migrate: "update",
|
|
154
|
+
refresh: "update",
|
|
155
|
+
cleanup: "remove",
|
|
156
|
+
prune: "remove",
|
|
157
|
+
// doctor's info finding: not a warning, so not ▲ — the two would differ by
|
|
158
|
+
// colour alone (decided 2026-10-11 with doctor's report).
|
|
159
|
+
info: "dot",
|
|
160
|
+
// The question rail's checklist cursor: the row it is on is marked by a
|
|
161
|
+
// glyph, not by colour alone (contract 3).
|
|
162
|
+
pointer: "transition",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The spinner (presentation spec contract 4): braille frames, one every 80 ms.
|
|
166
|
+
// ASCII glyphs get none — a live line there shows "..." and the elapsed time.
|
|
167
|
+
export const SPINNER = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
|
|
168
|
+
const SPIN_MS = 80;
|
|
169
|
+
// Nothing is drawn before this: a fetch from a warm npm cache ends in about
|
|
170
|
+
// half a second, and a spinner that flashes for one frame is noise.
|
|
171
|
+
const QUIET_MS = 300;
|
|
172
|
+
// A name the table does not know draws the update glyph, as 0.29.2's did: a
|
|
173
|
+
// plan row is never left without a mark.
|
|
55
174
|
export function icon(c, name) {
|
|
56
|
-
const pair =
|
|
57
|
-
return c.ascii ? pair[1] : pair[0];
|
|
175
|
+
const pair = GLYPHS[name] || GLYPHS[GLYPH_ALIASES[name]] || GLYPHS.update;
|
|
176
|
+
return c && c.ascii ? pair[1] : pair[0];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Prose the bin keeps in its own words — help's summaries and options, a
|
|
180
|
+
// usage form's `<phrase…>` — with each of the table's glyphs in it drawn from
|
|
181
|
+
// the column caps() picks: under ASCII glyphs — reads -, … reads ..., ·
|
|
182
|
+
// reads . and → reads ->. Text from the vault, a manifest or a finding never
|
|
183
|
+
// goes through it; that prints as it is (presentation spec, Testing).
|
|
184
|
+
const TO_ASCII = new Map(Object.values(GLYPHS).map(([u, a]) => [u, a]));
|
|
185
|
+
export function glyphText(c, text) {
|
|
186
|
+
const s = String(text);
|
|
187
|
+
return c && c.ascii ? [...s].map((ch) => TO_ASCII.get(ch) ?? ch).join("") : s;
|
|
58
188
|
}
|
|
59
189
|
|
|
60
190
|
export function duration(ms) {
|
|
61
191
|
if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
62
192
|
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
63
|
-
const m = Math.floor(ms / 60_000),
|
|
193
|
+
const m = Math.floor(ms / 60_000),
|
|
194
|
+
s = Math.round((ms % 60_000) / 1000);
|
|
64
195
|
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
65
196
|
}
|
|
66
197
|
|
|
@@ -77,73 +208,917 @@ export function wrap(text, width, indent = "") {
|
|
|
77
208
|
const lines = [];
|
|
78
209
|
let line = "";
|
|
79
210
|
for (const word of String(text).split(" ")) {
|
|
80
|
-
if (line && plain(line).length + 1 + plain(word).length > room) {
|
|
81
|
-
|
|
211
|
+
if (line && plain(line).length + 1 + plain(word).length > room) {
|
|
212
|
+
lines.push(line);
|
|
213
|
+
line = word;
|
|
214
|
+
} else line = line ? `${line} ${word}` : word;
|
|
82
215
|
}
|
|
83
216
|
lines.push(line);
|
|
84
217
|
return lines.map((l, k) => (k ? indent + l : l)).join("\n");
|
|
85
218
|
}
|
|
86
219
|
|
|
87
|
-
//
|
|
88
|
-
// cursor is on, so a label that wrapped would leave its first half
|
|
89
|
-
|
|
220
|
+
// An in-flight frame must fit one physical row: "\r\x1b[2K" clears only the
|
|
221
|
+
// row the cursor is on, so a label that wrapped would leave its first half
|
|
222
|
+
// behind. The cut is marked with the table's running glyph — "...", room
|
|
223
|
+
// reserved for all three, under ASCII glyphs.
|
|
224
|
+
const fit = (c, text, room) => {
|
|
225
|
+
if (text.length <= room) return text;
|
|
226
|
+
const mark = icon(c, "running");
|
|
227
|
+
return text.slice(0, Math.max(1, room - mark.length)) + mark;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// The columns a line may fill: one short of the terminal — a line that fills
|
|
231
|
+
// the last column leaves some terminals waiting to wrap — and never past 100.
|
|
232
|
+
const rowWidth = (c) => Math.min(c.width, c.columns || c.width, 100) - 1;
|
|
233
|
+
|
|
234
|
+
// One row of a step line: the mark, the label, and the right-hand note
|
|
235
|
+
// aligned to the row's end. Only an in-flight frame (`cut`) is cut to the
|
|
236
|
+
// row, and only on a live terminal (presentation spec contract 8): the
|
|
237
|
+
// finished line that replaces it carries the whole target, and the terminal
|
|
238
|
+
// wraps it where it must.
|
|
239
|
+
function rowFor(c, indent) {
|
|
240
|
+
const width = rowWidth(c);
|
|
241
|
+
return (mark, label, right, { cut = false } = {}) => {
|
|
242
|
+
const room = width - indent - plain(mark).length - 1 - plain(right).length - 1;
|
|
243
|
+
const left = `${" ".repeat(indent)}${mark} ${cut && c.live ? fit(c, label, room) : label}`;
|
|
244
|
+
const gap = Math.max(1, width - plain(left).length - plain(right).length);
|
|
245
|
+
return left + " ".repeat(gap) + right;
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Layout primitives (presentation spec contracts 5–7) ─────────────────
|
|
250
|
+
//
|
|
251
|
+
// Pure: each returns text for its caller to print. Cells are measured on
|
|
252
|
+
// plain() width, so a painted cell and an unpainted one align alike.
|
|
253
|
+
|
|
254
|
+
const separated = (c) => ` ${icon(c, "dot")} `;
|
|
255
|
+
|
|
256
|
+
// A screen's first line: the head in the emphasis role, then its parts, each
|
|
257
|
+
// after the dot separator — `projectstore install · Claude Code, Codex`.
|
|
258
|
+
export function badge(c, head, parts = []) {
|
|
259
|
+
const shown = parts.filter((x) => x !== null && x !== undefined && x !== "");
|
|
260
|
+
return [painter(c)("emphasis", head), ...shown].join(separated(c));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A heading: the title in the emphasis role and, when there are any, its
|
|
264
|
+
// counts after the dash separator, joined by the dot separator. A count is a
|
|
265
|
+
// string or [text, role] — `Plan — 3 to add · 1 to update · 5 unchanged`,
|
|
266
|
+
// each number in its role's colour.
|
|
267
|
+
export function heading(c, title, counts = []) {
|
|
268
|
+
const paint = painter(c);
|
|
269
|
+
const parts = counts.filter(Boolean).map((x) => (Array.isArray(x) ? paint(x[1], x[0]) : String(x)));
|
|
270
|
+
const head = paint("emphasis", title);
|
|
271
|
+
return parts.length ? `${head} ${icon(c, "dash")} ${parts.join(separated(c))}` : head;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Aligned rows (contract 5): glyph · action word · target · note. Each column
|
|
275
|
+
// is as wide as its widest cell in the block — the target column over the
|
|
276
|
+
// rows that carry a note, since nothing else aligns after it — and an empty
|
|
277
|
+
// column takes no room. Nothing is boxed and nothing is cut: a target is
|
|
278
|
+
// never truncated or broken. On a live terminal a row that would pass the
|
|
279
|
+
// width moves its note to the next line, indented to the target column, where
|
|
280
|
+
// a long note wraps; anywhere else each row stays one line, whole, for a
|
|
281
|
+
// reader that greps. A row is { glyph, role, action, target, note }: `glyph`
|
|
282
|
+
// a name in GLYPHS or an alias, `role` the colour of the glyph and the action
|
|
283
|
+
// word; the note is in the explanation role. rowLines() gives each row's
|
|
284
|
+
// lines on their own, for a caller that prints more beneath each row.
|
|
285
|
+
// A row with `prose: true` has words for a target, not a path (status's story
|
|
286
|
+
// titles): on a live terminal one wider than the row wraps at its spaces,
|
|
287
|
+
// with the target column as its hanging indent, and a moved note goes beneath
|
|
288
|
+
// it at that column. Any other target is never wrapped.
|
|
289
|
+
export function rows(c, list, opts = {}) {
|
|
290
|
+
return rowLines(c, list, opts).flat();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function rowLines(c, list, { indent = 2 } = {}) {
|
|
294
|
+
const paint = painter(c);
|
|
295
|
+
const cells = list.map((r) => ({
|
|
296
|
+
glyph: r.glyph ? icon(c, r.glyph) : "",
|
|
297
|
+
role: r.role || null,
|
|
298
|
+
action: String(r.action ?? ""),
|
|
299
|
+
target: String(r.target ?? ""),
|
|
300
|
+
note: String(r.note ?? ""),
|
|
301
|
+
prose: Boolean(r.prose),
|
|
302
|
+
}));
|
|
303
|
+
const widest = (xs) => Math.max(0, ...xs.map((s) => plain(s).length));
|
|
304
|
+
const gw = widest(cells.map((r) => r.glyph));
|
|
305
|
+
const aw = widest(cells.map((r) => r.action));
|
|
306
|
+
const tw = widest(cells.filter((r) => r.note).map((r) => r.target));
|
|
307
|
+
const width = c.live ? rowWidth(c) : 0;
|
|
308
|
+
const cell = (text, w, role) => paint(role, text) + " ".repeat(w - plain(text).length);
|
|
309
|
+
const out = [];
|
|
310
|
+
for (const r of cells) {
|
|
311
|
+
let lead = " ".repeat(indent);
|
|
312
|
+
if (gw) lead += cell(r.glyph, gw, r.role) + " ";
|
|
313
|
+
if (aw) lead += cell(r.action, aw, r.role) + " ";
|
|
314
|
+
const col = plain(lead).length;
|
|
315
|
+
const pad = " ".repeat(col);
|
|
316
|
+
const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target)
|
|
317
|
+
.split("\n")
|
|
318
|
+
.map((l) => l.trimEnd());
|
|
319
|
+
if (!r.note) {
|
|
320
|
+
out.push(alone);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const line = `${lead}${cell(r.target, tw, null)} ${paint("explanation", r.note)}`;
|
|
324
|
+
if (!width || plain(line).length <= width) {
|
|
325
|
+
out.push([line]);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
out.push([
|
|
329
|
+
...alone,
|
|
330
|
+
...wrap(r.note, width, pad)
|
|
331
|
+
.split("\n")
|
|
332
|
+
.map((l, k) => pad + paint("explanation", k ? l.slice(col) : l)),
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The capabilities of a reader that is not a terminal and asked for nothing:
|
|
339
|
+
// the renderers' default, so a caller that passes no caps gets plain text.
|
|
340
|
+
export const PLAIN = Object.freeze({ color: false, live: false, width: 100, columns: 100, ascii: false });
|
|
341
|
+
|
|
342
|
+
// A horizontal rule in the explanation role, as wide as a row may be.
|
|
343
|
+
export function rule(c, length = rowWidth(c)) {
|
|
344
|
+
return painter(c)("explanation", icon(c, "rule").repeat(Math.max(0, length)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Key–value lines (contracts 8 and 12): each key in the explanation role,
|
|
348
|
+
// padded to the widest key, then two spaces and the value as it is. A key
|
|
349
|
+
// with no value is printed alone, with nothing after it.
|
|
350
|
+
export function kv(c, pairs, { indent = 2 } = {}) {
|
|
351
|
+
const paint = painter(c);
|
|
352
|
+
const kw = Math.max(0, ...pairs.map(([k]) => plain(k).length));
|
|
353
|
+
return pairs.map(([k, v]) => {
|
|
354
|
+
const value = v === null || v === undefined ? "" : String(v);
|
|
355
|
+
const key = `${" ".repeat(indent)}${paint("explanation", k)}`;
|
|
356
|
+
return value ? `${key}${" ".repeat(kw - plain(k).length)} ${value}` : key;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Commands the renderer composes (contract 6): a blank line, then each
|
|
361
|
+
// command on a line of its own in the action role, with nothing after it —
|
|
362
|
+
// no punctuation, no prose — so a copy takes exactly the command. Never
|
|
363
|
+
// wrapped: a command broken in two cannot be pasted back.
|
|
364
|
+
export function commandBlock(c, commands, { indent = 2 } = {}) {
|
|
365
|
+
const paint = painter(c);
|
|
366
|
+
return ["", ...commands.map((cmd) => `${" ".repeat(indent)}${paint("action", cmd)}`)];
|
|
367
|
+
}
|
|
90
368
|
|
|
91
369
|
// One line per step. On a live terminal the line appears when the step starts
|
|
92
|
-
// (
|
|
93
|
-
// it ends; anything else gets the finished
|
|
370
|
+
// (marked "…", cut to one row) and is rewritten in place with the result, the
|
|
371
|
+
// whole label and the duration when it ends; anything else gets the finished
|
|
372
|
+
// line only. Steps run synchronously
|
|
94
373
|
// (spawnSync), so nothing animates in between — the "…" is the honest state.
|
|
95
|
-
|
|
374
|
+
// `end` may name the finished line in other words than the running one
|
|
375
|
+
// (`label`: "adding …" while it runs, "added …" once it has); `now` is the
|
|
376
|
+
// test's clock. `finish` is the finished line of work whose own steps printed
|
|
377
|
+
// beneath its first line (a registration, a layout move): nothing of it is in
|
|
378
|
+
// flight, so its line is written on a line of its own, timed from `t0`.
|
|
379
|
+
export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2, now = Date.now } = {}) {
|
|
96
380
|
const paint = painter(c);
|
|
97
381
|
let open = null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
382
|
+
const line = rowFor(c, indent);
|
|
383
|
+
const finished = (ok, note, kept, label, ms, over, warn = false) => {
|
|
384
|
+
// A step that ran and left its target in place is neither ✓ nor ✕; one
|
|
385
|
+
// that failed without stopping the run (setup's git init) is ▲.
|
|
386
|
+
const mark = !ok
|
|
387
|
+
? paint("error", icon(c, "fail"))
|
|
388
|
+
: warn
|
|
389
|
+
? paint("attention", icon(c, "warning"))
|
|
390
|
+
: kept
|
|
391
|
+
? paint("explanation", icon(c, "skip"))
|
|
392
|
+
: paint("done", icon(c, "ok"));
|
|
393
|
+
const right = paint("explanation", [note, duration(ms)].filter(Boolean).join(" "));
|
|
394
|
+
stream.write((over && c.live ? "\r\x1b[2K" : "") + line(mark, label, right) + "\n");
|
|
106
395
|
};
|
|
107
396
|
const self = {
|
|
108
397
|
start(label) {
|
|
109
398
|
// A step started while another is still open (a host command run by a
|
|
110
399
|
// rollback) closes the open line where it stands rather than overwrite it.
|
|
111
400
|
if (open && c.live) stream.write("\n");
|
|
112
|
-
open = { label, t0:
|
|
113
|
-
if (c.live) stream.write(line(paint("
|
|
401
|
+
open = { label, t0: now() };
|
|
402
|
+
if (c.live) stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
|
|
114
403
|
},
|
|
115
|
-
end(ok = true, note = "", { kept = false } = {}) {
|
|
404
|
+
end(ok = true, note = "", { kept = false, label = null, warn = false } = {}) {
|
|
116
405
|
if (!open) return;
|
|
117
|
-
|
|
118
|
-
// A step that ran and left its target in place is neither ✓ nor ✗.
|
|
119
|
-
const mark = !ok ? paint("red", icon(c, "fail")) : kept ? paint("gray", icon(c, "skip")) : paint("green", icon(c, "ok"));
|
|
120
|
-
const right = paint("gray", [note, duration(ms)].filter(Boolean).join(" "));
|
|
121
|
-
const text = line(mark, open.label, right);
|
|
122
|
-
stream.write((c.live ? "\r\x1b[2K" : "") + text + "\n");
|
|
406
|
+
finished(ok, note, kept, label ?? open.label, now() - open.t0, true, warn);
|
|
123
407
|
open = null;
|
|
124
408
|
},
|
|
409
|
+
finish(label, t0, { ok = true, note = "" } = {}) {
|
|
410
|
+
finished(ok, note, false, label, now() - t0, false);
|
|
411
|
+
},
|
|
125
412
|
// An exception left a step open: end its line as failed, so whatever is
|
|
126
413
|
// printed next starts on a line of its own.
|
|
127
|
-
abort() {
|
|
414
|
+
abort() {
|
|
415
|
+
if (open) self.end(false);
|
|
416
|
+
},
|
|
128
417
|
};
|
|
129
418
|
return self;
|
|
130
419
|
}
|
|
131
420
|
|
|
421
|
+
// A line for work that runs asynchronously, so it can animate (presentation
|
|
422
|
+
// spec contract 8, its asynchronous part; built for the shell fetch of
|
|
423
|
+
// install and upgrade, reused by an asynchronous APPLY). It starts on
|
|
424
|
+
// creation. On a live terminal nothing shows for the first 300 ms; then the
|
|
425
|
+
// spinner in the action role, the label and the elapsed time, redrawn in
|
|
426
|
+
// place every 80 ms and cut to one row. Under ASCII glyphs the frames are
|
|
427
|
+
// "..." with the elapsed time. PROJECTSTORE_NO_ANIMATION, set non-empty, draws
|
|
428
|
+
// the "…" line at once, as stepReporter does, and then only the finished one.
|
|
429
|
+
// On a pipe there is one finished line and no escape. `end` replaces the line
|
|
430
|
+
// with ✓ or the fail glyph, the whole past-tense label — never cut, wrapped by
|
|
431
|
+
// the terminal — and the duration. No byte
|
|
432
|
+
// bar: npm gives a child no byte counts, and a bar is shown only when the size
|
|
433
|
+
// is known. The timers are unref'd and cleared by both `end` and `abort`, so
|
|
434
|
+
// a forgotten line never holds the process; the cursor is never hidden, so no
|
|
435
|
+
// exit path has anything to restore. `now` and `timers` are the test's clock.
|
|
436
|
+
export function liveLine(
|
|
437
|
+
stream = process.stdout,
|
|
438
|
+
c = caps(stream),
|
|
439
|
+
label = "",
|
|
440
|
+
{ env = process.env, now = Date.now, timers = globalThis, indent = 2 } = {},
|
|
441
|
+
) {
|
|
442
|
+
const paint = painter(c);
|
|
443
|
+
const line = rowFor(c, indent);
|
|
444
|
+
const t0 = now();
|
|
445
|
+
const still = Boolean(env.PROJECTSTORE_NO_ANIMATION);
|
|
446
|
+
let drawn = false,
|
|
447
|
+
frame = 0,
|
|
448
|
+
wait = null,
|
|
449
|
+
tick = null,
|
|
450
|
+
settled = false;
|
|
451
|
+
const unref = (h) => {
|
|
452
|
+
try {
|
|
453
|
+
h?.unref?.();
|
|
454
|
+
} catch {}
|
|
455
|
+
return h;
|
|
456
|
+
};
|
|
457
|
+
const stop = () => {
|
|
458
|
+
if (wait !== null) {
|
|
459
|
+
timers.clearTimeout(wait);
|
|
460
|
+
wait = null;
|
|
461
|
+
}
|
|
462
|
+
if (tick !== null) {
|
|
463
|
+
timers.clearInterval(tick);
|
|
464
|
+
tick = null;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const draw = () => {
|
|
468
|
+
const mark = c.ascii ? icon(c, "running") : paint("action", SPINNER[frame++ % SPINNER.length]);
|
|
469
|
+
stream.write("\r\x1b[2K" + line(mark, label, paint("explanation", duration(now() - t0)), { cut: true }));
|
|
470
|
+
drawn = true;
|
|
471
|
+
};
|
|
472
|
+
if (c.live && still) {
|
|
473
|
+
stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
|
|
474
|
+
drawn = true;
|
|
475
|
+
} else if (c.live) {
|
|
476
|
+
wait = unref(
|
|
477
|
+
timers.setTimeout(() => {
|
|
478
|
+
wait = null;
|
|
479
|
+
draw();
|
|
480
|
+
tick = unref(timers.setInterval(draw, SPIN_MS));
|
|
481
|
+
}, QUIET_MS),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
end(ok = true, doneLabel = label, note = "") {
|
|
486
|
+
if (settled) return;
|
|
487
|
+
settled = true;
|
|
488
|
+
stop();
|
|
489
|
+
const mark = ok ? paint("done", icon(c, "ok")) : paint("error", icon(c, "fail"));
|
|
490
|
+
const right = paint("explanation", [note, duration(now() - t0)].filter(Boolean).join(" "));
|
|
491
|
+
stream.write((c.live && drawn ? "\r\x1b[2K" : "") + line(mark, doneLabel, right) + "\n");
|
|
492
|
+
},
|
|
493
|
+
// An exception mid-work: the timers go and the drawn row is cleared, so
|
|
494
|
+
// whatever is printed next starts clean.
|
|
495
|
+
abort() {
|
|
496
|
+
if (settled) return;
|
|
497
|
+
settled = true;
|
|
498
|
+
stop();
|
|
499
|
+
if (c.live && drawn) stream.write("\r\x1b[2K");
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
132
504
|
// One line of input, read the way a shell's own prompt is: in the terminal's
|
|
133
505
|
// line mode, so readline writes nothing but the question (no cursor escapes),
|
|
134
506
|
// the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
|
|
135
507
|
// anything is written). End of input — Ctrl+D, a closed pipe — is null, which
|
|
136
508
|
// every caller reads as no; it never leaves the question pending (an unsettled
|
|
137
|
-
// promise is exit 13 with a warning). The
|
|
138
|
-
//
|
|
509
|
+
// promise is exit 13 with a warning). The question is node:readline's callback
|
|
510
|
+
// form: readline/promises is experimental before Node 22.17 and 24, below the
|
|
511
|
+
// engines floor. Its callback never runs on close, so the close handler
|
|
512
|
+
// settles it.
|
|
139
513
|
export async function askLine(question, input, output) {
|
|
140
514
|
const rl = createInterface({ input, output, terminal: false });
|
|
141
515
|
return await new Promise((settle) => {
|
|
142
516
|
let done = false;
|
|
143
|
-
const finish = (answer) => {
|
|
517
|
+
const finish = (answer) => {
|
|
518
|
+
if (done) return;
|
|
519
|
+
done = true;
|
|
520
|
+
settle(answer);
|
|
521
|
+
rl.close();
|
|
522
|
+
};
|
|
144
523
|
// End of input echoes no newline: end the question's line, so whatever is
|
|
145
524
|
// printed next ("Nothing written.") starts on a line of its own.
|
|
146
|
-
rl.on("close", () => {
|
|
147
|
-
|
|
525
|
+
rl.on("close", () => {
|
|
526
|
+
if (!done) output.write("\n");
|
|
527
|
+
finish(null);
|
|
528
|
+
});
|
|
529
|
+
rl.question(question, (a) => finish(a));
|
|
148
530
|
});
|
|
149
531
|
}
|
|
532
|
+
|
|
533
|
+
// The install spec's contract 9 question, for every verb that asks it — the
|
|
534
|
+
// install family and `scaffold --write` at a terminal. Its bytes and its
|
|
535
|
+
// answer rule live here once, so two verbs cannot drift into two questions:
|
|
536
|
+
// `◆ Apply N changes? [Y/n] ` (`* Apply…` with ASCII glyphs), the question
|
|
537
|
+
// glyph in the action role (presentation spec contract 8); Enter, y or yes
|
|
538
|
+
// (any case) applies; anything else is a no, and so is end of input. Only
|
|
539
|
+
// the look is the presentation spec's: the line is still read by askLine, in
|
|
540
|
+
// line mode. Whether to ask at all is the caller's (install-harness.mjs's
|
|
541
|
+
// isInteractive). `caps` is the asking stream's; without one the question is
|
|
542
|
+
// unpainted, in Unicode. `ask` stands in for the terminal in tests and means
|
|
543
|
+
// "this is one"; it gets the question and returns the answer.
|
|
544
|
+
export async function askApply(
|
|
545
|
+
n,
|
|
546
|
+
{ stdin = null, stdout = null, ask = null, caps: c = { color: false, ascii: false } } = {},
|
|
547
|
+
) {
|
|
548
|
+
const paint = painter(c);
|
|
549
|
+
const question = `${paint("action", icon(c, "ask"))} ${paint("emphasis", `Apply ${n === 1 ? "1 change" : `${n} changes`}?`)} ${paint("explanation", "[Y/n]")} `;
|
|
550
|
+
const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
|
|
551
|
+
if (answer === null || answer === undefined) return false;
|
|
552
|
+
return /^(y(es)?)?$/i.test(String(answer).trim());
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── The question rail (presentation spec contract 9; built by the story
|
|
556
|
+
// "projectstore setup asks, shows one plan and installs every harness it
|
|
557
|
+
// finds, with the vault", its rule 2) ──────────────────────────────────────
|
|
558
|
+
//
|
|
559
|
+
// A run that asks more than one question draws them down one left margin:
|
|
560
|
+
// `┌` and the badge at the top, `◆` on the question being asked, `◇` and its
|
|
561
|
+
// answer on one line once it is answered, `└` at the end. Four kinds —
|
|
562
|
+
// select (one of n), multi (a checklist), confirm and path — each with its
|
|
563
|
+
// default, which Enter takes. The confirm that follows the rail is not one of
|
|
564
|
+
// them: it stays askApply's line-mode question, so every verb has one consent
|
|
565
|
+
// gate (contract 8).
|
|
566
|
+
//
|
|
567
|
+
// Keys come in raw mode, through node:readline's keypress decoder: ↑/↓ move,
|
|
568
|
+
// space toggles a checklist row, Enter confirms; Esc — reported after
|
|
569
|
+
// readline's escape timeout, since a bare Esc begins every arrow sequence —
|
|
570
|
+
// goes back from a path question; Ctrl+C, a byte in raw mode rather than a
|
|
571
|
+
// signal, and end of input cancel. Every other key is ignored.
|
|
572
|
+
|
|
573
|
+
// What a question resolves to when it is cancelled (Ctrl+C, or stdin ended)
|
|
574
|
+
// and when Esc leaves a path question. Once cancelled, a rail stays so: each
|
|
575
|
+
// later question resolves to CANCELLED at once and draws nothing.
|
|
576
|
+
export const CANCELLED = Symbol("projectstore.rail.cancelled");
|
|
577
|
+
export const BACK = Symbol("projectstore.rail.back");
|
|
578
|
+
|
|
579
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
580
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
581
|
+
// readline's own default: how long a lone Esc waits for the rest of a sequence.
|
|
582
|
+
const ESCAPE_MS = 500;
|
|
583
|
+
// End of input, as the key queue carries it, behind the keys typed before it.
|
|
584
|
+
const EOF = Object.freeze({ name: "end of input" });
|
|
585
|
+
|
|
586
|
+
// Whether a rail may be drawn: stdin is a terminal that can be put in raw
|
|
587
|
+
// mode, and the caps (stdout's) are a live terminal — not a pipe, not
|
|
588
|
+
// TERM=dumb. Without a rail the caller runs as without a terminal: no
|
|
589
|
+
// question is asked (the setup story's rule 2 amends contract 9 so). Whether
|
|
590
|
+
// the run is interactive at all — CI, --json, a host session — is the
|
|
591
|
+
// caller's question (install-harness.mjs's isInteractive), asked before this.
|
|
592
|
+
export function railAvailable(stdin, c) {
|
|
593
|
+
return Boolean(stdin && stdin.isTTY && typeof stdin.setRawMode === "function" && c && c.live);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// A choice as the rail draws and answers it: a string is its own value.
|
|
597
|
+
const choiceOf = (x) =>
|
|
598
|
+
typeof x === "object" && x !== null
|
|
599
|
+
? {
|
|
600
|
+
value: x.value ?? x.label,
|
|
601
|
+
label: String(x.label ?? x.value),
|
|
602
|
+
reason: x.reason ? String(x.reason) : "",
|
|
603
|
+
answer: String(x.answer ?? x.label ?? x.value),
|
|
604
|
+
on: Boolean(x.on),
|
|
605
|
+
}
|
|
606
|
+
: { value: x, label: String(x), reason: "", answer: String(x), on: false };
|
|
607
|
+
|
|
608
|
+
const isEnter = (k) => k.name === "return" || k.name === "enter";
|
|
609
|
+
const printable = (k) =>
|
|
610
|
+
typeof k.str === "string" && k.str !== "" && !k.ctrl && !k.meta && !/[\x00-\x1f\x7f]/.test(k.str);
|
|
611
|
+
|
|
612
|
+
// Open a rail on a terminal: draws its top line and holds the terminal for its
|
|
613
|
+
// questions until close(). Everything the run prints while the rail is open
|
|
614
|
+
// goes through it, since a redraw counts the rows it drew.
|
|
615
|
+
//
|
|
616
|
+
// rail.select(title, choices, { default, note }) → the chosen value
|
|
617
|
+
// rail.multi(title, choices, { note, min = 1 }) → the ticked values, in order
|
|
618
|
+
// rail.confirm(title, { default = true, yes, no, yesReason, noReason, note }) → true or false
|
|
619
|
+
// rail.path(title, { default, check, note, back = true }) → the path typed, or the default
|
|
620
|
+
// rail.answered(title, text) → draws a question a flag answered
|
|
621
|
+
// rail.close(note) → `└`, then the terminal is the caller's
|
|
622
|
+
//
|
|
623
|
+
// A choice is a string, or { value, label, reason, answer, on }: `reason` is
|
|
624
|
+
// shown beside the label (beneath it when the row would pass the width),
|
|
625
|
+
// `answer` is the answered line's text (the label by default), `on` pre-ticks
|
|
626
|
+
// a checklist row. `default` is the value Enter takes. A path's `check(value)`
|
|
627
|
+
// returns a reason to refuse it — shown under the question, which stays open —
|
|
628
|
+
// or nothing to accept it. Each question also resolves to CANCELLED; a path
|
|
629
|
+
// resolves to BACK on Esc, and then its own lines and the answered line
|
|
630
|
+
// before it are erased, so the caller asks that question again where it was.
|
|
631
|
+
//
|
|
632
|
+
// One keypress queue per rail: keys typed ahead of a question — in the same
|
|
633
|
+
// chunk as the Enter that answered the one before, or while the run worked
|
|
634
|
+
// between two — wait for it. Raw mode is on only while a question is asked:
|
|
635
|
+
// each question turns it off and shows the cursor in a `finally`, so a throw
|
|
636
|
+
// restores the terminal as an answer does, and stdin is paused between
|
|
637
|
+
// questions, so an open rail never holds the process. A one-time `exit`
|
|
638
|
+
// handler and a SIGTERM / SIGHUP / SIGINT handler restore it too; the signal handler
|
|
639
|
+
// then removes the rail's handlers and raises the signal again, so the
|
|
640
|
+
// process ends as the signal would have ended it. `proc` is the process those
|
|
641
|
+
// handlers are added to, a test's emitter in tests. Never process.exit: a
|
|
642
|
+
// cancel is an answer, and the caller returns 130 (contract 9).
|
|
643
|
+
export function openRail({
|
|
644
|
+
stdin = process.stdin,
|
|
645
|
+
stdout = process.stdout,
|
|
646
|
+
caps: c = caps(stdout),
|
|
647
|
+
proc = process,
|
|
648
|
+
head = "projectstore",
|
|
649
|
+
parts = [],
|
|
650
|
+
escapeMs = ESCAPE_MS,
|
|
651
|
+
} = {}) {
|
|
652
|
+
if (!railAvailable(stdin, c))
|
|
653
|
+
throw new Error("no question rail: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
|
|
654
|
+
const paint = painter(c);
|
|
655
|
+
const bar = paint("explanation", icon(c, "rail"));
|
|
656
|
+
const sep = ` ${icon(c, "dot")} `;
|
|
657
|
+
const write = (s) => stdout.write(s);
|
|
658
|
+
// The terminal's width now, for the layout and for counting the rows a line
|
|
659
|
+
// fills: a line wider than the terminal fills more than one, and a redraw
|
|
660
|
+
// that miscounted them would leave half a frame behind.
|
|
661
|
+
const columns = () => (Number(stdout.columns) > 0 ? Number(stdout.columns) : c.columns || 100);
|
|
662
|
+
const room = () => Math.max(20, Math.min(columns(), 100) - 1);
|
|
663
|
+
const rowsOf = (lines) => {
|
|
664
|
+
const w = columns();
|
|
665
|
+
return lines.reduce((n, l) => n + Math.max(1, Math.ceil([...plain(l)].length / w)), 0);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// ── The keys ──
|
|
669
|
+
const queue = [];
|
|
670
|
+
let waiter = null;
|
|
671
|
+
const wake = () => {
|
|
672
|
+
if (waiter) {
|
|
673
|
+
const w = waiter;
|
|
674
|
+
waiter = null;
|
|
675
|
+
w();
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
const onKey = (str, key) => {
|
|
679
|
+
queue.push({ ...(key || {}), str });
|
|
680
|
+
wake();
|
|
681
|
+
};
|
|
682
|
+
const onEnd = () => {
|
|
683
|
+
queue.push(EOF);
|
|
684
|
+
wake();
|
|
685
|
+
};
|
|
686
|
+
const next = () =>
|
|
687
|
+
queue.length
|
|
688
|
+
? Promise.resolve(queue.shift())
|
|
689
|
+
: new Promise((settle) => {
|
|
690
|
+
waiter = () => settle(queue.shift());
|
|
691
|
+
});
|
|
692
|
+
emitKeypressEvents(stdin, { escapeCodeTimeout: escapeMs });
|
|
693
|
+
stdin.on("keypress", onKey);
|
|
694
|
+
stdin.on("end", onEnd);
|
|
695
|
+
stdin.on("error", onEnd);
|
|
696
|
+
if (stdin.readableEnded) queue.push(EOF);
|
|
697
|
+
// Listening resumed the stream; it reads only while a question is asked.
|
|
698
|
+
stdin.pause();
|
|
699
|
+
|
|
700
|
+
// ── The restore, on every path ──
|
|
701
|
+
// `live`: the question being asked, or null between questions.
|
|
702
|
+
let closed = false,
|
|
703
|
+
cancelled = false,
|
|
704
|
+
live = null;
|
|
705
|
+
const restore = () => {
|
|
706
|
+
try {
|
|
707
|
+
stdin.setRawMode(false);
|
|
708
|
+
} catch {}
|
|
709
|
+
try {
|
|
710
|
+
write(SHOW_CURSOR);
|
|
711
|
+
} catch {}
|
|
712
|
+
};
|
|
713
|
+
const onExit = () => restore();
|
|
714
|
+
const onSignal = {};
|
|
715
|
+
const release = () => {
|
|
716
|
+
proc.removeListener("exit", onExit);
|
|
717
|
+
for (const [sig, f] of Object.entries(onSignal)) proc.removeListener(sig, f);
|
|
718
|
+
};
|
|
719
|
+
// SIGINT too: Ctrl+C typed during a question is a byte, but `kill -INT`
|
|
720
|
+
// from outside is a signal, and its default would leave the cursor hidden.
|
|
721
|
+
for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
|
|
722
|
+
onSignal[sig] = () => {
|
|
723
|
+
restore();
|
|
724
|
+
if (live)
|
|
725
|
+
try {
|
|
726
|
+
write("\n");
|
|
727
|
+
} catch {}
|
|
728
|
+
release();
|
|
729
|
+
try {
|
|
730
|
+
proc.kill(proc.pid, sig);
|
|
731
|
+
} catch {}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
proc.once("exit", onExit);
|
|
735
|
+
for (const [sig, f] of Object.entries(onSignal)) proc.on(sig, f);
|
|
736
|
+
|
|
737
|
+
// ── Drawing ──
|
|
738
|
+
// `shown`: the rows of the question on screen, written with no newline
|
|
739
|
+
// after it, so the cursor is on its last row. `last`: the rows of the line
|
|
740
|
+
// answered before it, which a path's Esc takes back.
|
|
741
|
+
let shown = 0,
|
|
742
|
+
last = 0;
|
|
743
|
+
const erase = () => {
|
|
744
|
+
if (!shown) return;
|
|
745
|
+
write(`\r${shown > 1 ? `\x1b[${shown - 1}A` : ""}\x1b[J`);
|
|
746
|
+
shown = 0;
|
|
747
|
+
};
|
|
748
|
+
const frame = (lines) => {
|
|
749
|
+
erase();
|
|
750
|
+
write(lines.join("\n"));
|
|
751
|
+
shown = rowsOf(lines);
|
|
752
|
+
};
|
|
753
|
+
const item = (lines) => {
|
|
754
|
+
erase();
|
|
755
|
+
write(`${lines.join("\n")}\n`);
|
|
756
|
+
last = rowsOf(lines);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const titled = (title) =>
|
|
760
|
+
wrap(String(title), room() - 2)
|
|
761
|
+
.split("\n")
|
|
762
|
+
.map((t, k) => `${k ? bar : paint("action", icon(c, "ask"))} ${paint("emphasis", t)}`);
|
|
763
|
+
const prose = (text, role = "explanation") =>
|
|
764
|
+
wrap(String(text), room() - 2)
|
|
765
|
+
.split("\n")
|
|
766
|
+
.map((t) => `${bar} ${paint(role, t)}`);
|
|
767
|
+
const warning = (text) => prose(`${icon(c, "warning")} ${text}`, "attention");
|
|
768
|
+
// The key hint: one line, broken only between its keys on a terminal too
|
|
769
|
+
// narrow for it, each separator ending the line it closes.
|
|
770
|
+
const hint = (keys) => {
|
|
771
|
+
const parts = keys.filter(Boolean).map((k, i, all) => (i < all.length - 1 ? `${k}${sep.trimEnd()}` : k));
|
|
772
|
+
const lines = [];
|
|
773
|
+
for (const p of parts) {
|
|
774
|
+
if (lines.length && plain(`${lines.at(-1)} ${p}`).length <= room() - 2) lines[lines.length - 1] += ` ${p}`;
|
|
775
|
+
else lines.push(p);
|
|
776
|
+
}
|
|
777
|
+
return lines.map((t) => `${bar} ${paint("explanation", t)}`);
|
|
778
|
+
};
|
|
779
|
+
const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") => [
|
|
780
|
+
bar,
|
|
781
|
+
`${paint(role, icon(c, glyph))} ${title}${text ? ` ${paint(answerRole, text)}` : ""}`,
|
|
782
|
+
];
|
|
783
|
+
// Choice rows: the marks (one plain width), the label, and the reason in a
|
|
784
|
+
// column after the widest label that keeps its reason beside it. A row that
|
|
785
|
+
// would pass the width puts its reason beneath the label, wrapped, and is
|
|
786
|
+
// then left out of the column — so one wide label does not push every
|
|
787
|
+
// other reason down. A label is never cut.
|
|
788
|
+
const choiceLines = (marks, list) => {
|
|
789
|
+
const lead = (i) => `${bar} ${marks[i]} `;
|
|
790
|
+
const widest = (xs) => Math.max(0, ...xs.map((x) => plain(x.label).length));
|
|
791
|
+
const fits = (x, i, w) => plain(lead(i)).length + w + 2 + plain(x.reason).length <= room();
|
|
792
|
+
let beside = list.map((x, i) => Boolean(x.reason) && fits(x, i, plain(x.label).length));
|
|
793
|
+
for (;;) {
|
|
794
|
+
const w = widest(list.filter((_, i) => beside[i]));
|
|
795
|
+
const still = beside.map((b, i) => b && fits(list[i], i, w));
|
|
796
|
+
if (still.every((b, i) => b === beside[i])) break;
|
|
797
|
+
beside = still;
|
|
798
|
+
}
|
|
799
|
+
const lw = widest(list.filter((_, i) => beside[i]));
|
|
800
|
+
const out = [];
|
|
801
|
+
list.forEach((x, i) => {
|
|
802
|
+
if (beside[i]) {
|
|
803
|
+
out.push(`${lead(i)}${x.label}${" ".repeat(lw - plain(x.label).length)} ${paint("explanation", x.reason)}`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
out.push(lead(i) + x.label);
|
|
807
|
+
if (!x.reason) return;
|
|
808
|
+
const col = plain(lead(i)).length;
|
|
809
|
+
for (const t of wrap(x.reason, room() - col).split("\n"))
|
|
810
|
+
out.push(`${bar} ${" ".repeat(col - 2)}${paint("explanation", t)}`);
|
|
811
|
+
});
|
|
812
|
+
return out;
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// One question: raw mode and a hidden cursor while it is asked (a path
|
|
816
|
+
// shows the cursor, at the end of what is typed), its frame redrawn on each
|
|
817
|
+
// key that changes it, collapsed to one answered line when it is answered.
|
|
818
|
+
async function ask(q) {
|
|
819
|
+
if (closed) throw new Error("the question rail is closed");
|
|
820
|
+
if (live) throw new Error("the question rail is already asking");
|
|
821
|
+
if (cancelled) return CANCELLED;
|
|
822
|
+
live = q;
|
|
823
|
+
let settled = false;
|
|
824
|
+
const draw = () => frame(q.draw());
|
|
825
|
+
try {
|
|
826
|
+
stdin.setRawMode(true);
|
|
827
|
+
if (!q.cursor) write(HIDE_CURSOR);
|
|
828
|
+
stdin.resume();
|
|
829
|
+
draw();
|
|
830
|
+
for (;;) {
|
|
831
|
+
const k = await next();
|
|
832
|
+
// close() cancelled this question while it waited: it drew the line.
|
|
833
|
+
if (closed) {
|
|
834
|
+
settled = true;
|
|
835
|
+
return CANCELLED;
|
|
836
|
+
}
|
|
837
|
+
if (k === EOF || (k.ctrl && k.name === "c")) {
|
|
838
|
+
cancelled = true;
|
|
839
|
+
item(answeredLine(q.title, "cancelled", "warning", "attention", "attention"));
|
|
840
|
+
settled = true;
|
|
841
|
+
return CANCELLED;
|
|
842
|
+
}
|
|
843
|
+
const r = await q.key(k);
|
|
844
|
+
if (closed) {
|
|
845
|
+
settled = true;
|
|
846
|
+
return CANCELLED;
|
|
847
|
+
}
|
|
848
|
+
if (r === "redraw") {
|
|
849
|
+
draw();
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (!r) continue;
|
|
853
|
+
if (r.back) {
|
|
854
|
+
erase();
|
|
855
|
+
if (last) {
|
|
856
|
+
write(`\x1b[${last}A\x1b[J`);
|
|
857
|
+
last = 0;
|
|
858
|
+
}
|
|
859
|
+
settled = true;
|
|
860
|
+
return BACK;
|
|
861
|
+
}
|
|
862
|
+
item(answeredLine(q.title, r.answer));
|
|
863
|
+
settled = true;
|
|
864
|
+
return r.value;
|
|
865
|
+
}
|
|
866
|
+
} finally {
|
|
867
|
+
live = null;
|
|
868
|
+
// A throw left the frame drawn: end its line, so what follows starts on its own.
|
|
869
|
+
if (!settled && shown) {
|
|
870
|
+
try {
|
|
871
|
+
write("\n");
|
|
872
|
+
} catch {}
|
|
873
|
+
shown = 0;
|
|
874
|
+
}
|
|
875
|
+
restore();
|
|
876
|
+
try {
|
|
877
|
+
stdin.pause();
|
|
878
|
+
} catch {}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const choose = (title, list, at, note, keys) => {
|
|
883
|
+
if (!list.length) throw new TypeError(`${title}: no choices`);
|
|
884
|
+
return ask({
|
|
885
|
+
title,
|
|
886
|
+
draw: () => [
|
|
887
|
+
bar,
|
|
888
|
+
...titled(title),
|
|
889
|
+
...(note ? prose(note) : []),
|
|
890
|
+
...choiceLines(
|
|
891
|
+
list.map((_, i) =>
|
|
892
|
+
i === at ? paint("action", icon(c, "choiceOn")) : paint("explanation", icon(c, "choiceOff")),
|
|
893
|
+
),
|
|
894
|
+
list,
|
|
895
|
+
),
|
|
896
|
+
...hint(keys),
|
|
897
|
+
],
|
|
898
|
+
key: (k) => {
|
|
899
|
+
if (k.name === "up") {
|
|
900
|
+
at = (at + list.length - 1) % list.length;
|
|
901
|
+
return "redraw";
|
|
902
|
+
}
|
|
903
|
+
if (k.name === "down") {
|
|
904
|
+
at = (at + 1) % list.length;
|
|
905
|
+
return "redraw";
|
|
906
|
+
}
|
|
907
|
+
if (isEnter(k)) return { value: list[at].value, answer: list[at].answer };
|
|
908
|
+
return null;
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
};
|
|
912
|
+
const defaultAt = (list, value) =>
|
|
913
|
+
Math.max(
|
|
914
|
+
0,
|
|
915
|
+
list.findIndex((x) => Object.is(x.value, value)),
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
// The top: `┌` and the badge — `┌ projectstore setup` — in place of the
|
|
919
|
+
// badge line a screen without a rail starts with.
|
|
920
|
+
write(`${paint("explanation", icon(c, "railTop"))} ${badge(c, head, parts)}\n`);
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
get cancelled() {
|
|
924
|
+
return cancelled;
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
select(title, choices, { default: value, note = "" } = {}) {
|
|
928
|
+
const list = choices.map(choiceOf);
|
|
929
|
+
return choose(title, list, defaultAt(list, value), note, ["up/down move", "enter choose", "ctrl+c cancel"]);
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
// The row under the cursor carries the transition mark, so the cursor is
|
|
933
|
+
// seen without colour (contract 3); a ticked row's box is ◼, an empty one ◻.
|
|
934
|
+
multi(title, choices, { note = "", min = 1 } = {}) {
|
|
935
|
+
const list = choices.map(choiceOf);
|
|
936
|
+
if (!list.length) throw new TypeError(`${title}: no choices`);
|
|
937
|
+
// More than the list holds could never be ticked: the question would never close.
|
|
938
|
+
const need = Math.min(Math.max(0, Number(min) || 0), list.length);
|
|
939
|
+
const on = list.map((x) => x.on);
|
|
940
|
+
const pointer = icon(c, "pointer");
|
|
941
|
+
let at = 0,
|
|
942
|
+
refused = "";
|
|
943
|
+
return ask({
|
|
944
|
+
title,
|
|
945
|
+
draw: () => [
|
|
946
|
+
bar,
|
|
947
|
+
...titled(title),
|
|
948
|
+
...(note ? prose(note) : []),
|
|
949
|
+
...choiceLines(
|
|
950
|
+
list.map(
|
|
951
|
+
(_, i) =>
|
|
952
|
+
`${i === at ? paint("action", pointer) : " ".repeat(pointer.length)} ${paint(on[i] ? "action" : "explanation", icon(c, on[i] ? "checkOn" : "checkOff"))}`,
|
|
953
|
+
),
|
|
954
|
+
list,
|
|
955
|
+
),
|
|
956
|
+
...(refused ? warning(refused) : []),
|
|
957
|
+
...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"]),
|
|
958
|
+
],
|
|
959
|
+
key: (k) => {
|
|
960
|
+
if (k.name === "up") {
|
|
961
|
+
at = (at + list.length - 1) % list.length;
|
|
962
|
+
return "redraw";
|
|
963
|
+
}
|
|
964
|
+
if (k.name === "down") {
|
|
965
|
+
at = (at + 1) % list.length;
|
|
966
|
+
return "redraw";
|
|
967
|
+
}
|
|
968
|
+
if (k.name === "space") {
|
|
969
|
+
on[at] = !on[at];
|
|
970
|
+
refused = "";
|
|
971
|
+
return "redraw";
|
|
972
|
+
}
|
|
973
|
+
if (!isEnter(k)) return null;
|
|
974
|
+
const picked = list.filter((_, i) => on[i]);
|
|
975
|
+
if (picked.length < need) {
|
|
976
|
+
refused = need === 1 ? "tick at least one with space" : `tick at least ${need} with space`;
|
|
977
|
+
return "redraw";
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
value: picked.map((x) => x.value),
|
|
981
|
+
answer: picked.length ? picked.map((x) => x.answer).join(", ") : "none",
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
confirm(
|
|
988
|
+
title,
|
|
989
|
+
{ default: yes = true, yes: yesLabel = "Yes", no: noLabel = "No", yesReason = "", noReason = "", note = "" } = {},
|
|
990
|
+
) {
|
|
991
|
+
const list = [
|
|
992
|
+
choiceOf({ value: true, label: yesLabel, reason: yesReason }),
|
|
993
|
+
choiceOf({ value: false, label: noLabel, reason: noReason }),
|
|
994
|
+
];
|
|
995
|
+
return choose(title, list, yes ? 0 : 1, note, ["up/down move", "enter choose", "ctrl+c cancel"]);
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
// What is typed is drawn on the last line, the cursor after it. The
|
|
999
|
+
// default has a line of its own, in words — a placeholder told from typed
|
|
1000
|
+
// text by its colour alone would not be told apart under NO_COLOR — and
|
|
1001
|
+
// Enter with nothing typed takes it. Backspace takes back a character;
|
|
1002
|
+
// Esc goes back.
|
|
1003
|
+
path(title, { default: fallback = "", check = null, note = "", back = true } = {}) {
|
|
1004
|
+
let value = "",
|
|
1005
|
+
refused = "";
|
|
1006
|
+
return ask({
|
|
1007
|
+
title,
|
|
1008
|
+
cursor: true,
|
|
1009
|
+
draw: () => [
|
|
1010
|
+
bar,
|
|
1011
|
+
...titled(title),
|
|
1012
|
+
...(note ? prose(note) : []),
|
|
1013
|
+
...(refused ? warning(refused) : []),
|
|
1014
|
+
...(fallback ? prose(`default ${fallback}`) : []),
|
|
1015
|
+
...hint(["enter confirm", back ? "esc back" : null, "ctrl+c cancel"]),
|
|
1016
|
+
`${bar} ${value}`,
|
|
1017
|
+
],
|
|
1018
|
+
key: async (k) => {
|
|
1019
|
+
if (k.name === "escape") return back ? { back: true } : null;
|
|
1020
|
+
if (isEnter(k)) {
|
|
1021
|
+
const chosen = value.trim() || String(fallback);
|
|
1022
|
+
if (!chosen) {
|
|
1023
|
+
refused = "type a path";
|
|
1024
|
+
return "redraw";
|
|
1025
|
+
}
|
|
1026
|
+
const why = check ? await check(chosen) : null;
|
|
1027
|
+
if (why) {
|
|
1028
|
+
refused = String(why);
|
|
1029
|
+
return "redraw";
|
|
1030
|
+
}
|
|
1031
|
+
return { value: chosen, answer: chosen };
|
|
1032
|
+
}
|
|
1033
|
+
if (k.name === "backspace") {
|
|
1034
|
+
if (!value) return null;
|
|
1035
|
+
value = [...value].slice(0, -1).join("");
|
|
1036
|
+
refused = "";
|
|
1037
|
+
return "redraw";
|
|
1038
|
+
}
|
|
1039
|
+
if (!printable(k)) return null;
|
|
1040
|
+
value += k.str;
|
|
1041
|
+
refused = "";
|
|
1042
|
+
return "redraw";
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
// A question a flag answered, or one the run shows rather than asks: drawn
|
|
1048
|
+
// as an answered one, so the rail always shows the whole set of answers.
|
|
1049
|
+
// After a cancel it draws nothing: the run is stopping, and an answer
|
|
1050
|
+
// drawn under the cancelled question would read as one given.
|
|
1051
|
+
answered(title, text) {
|
|
1052
|
+
if (closed) throw new Error("the question rail is closed");
|
|
1053
|
+
if (cancelled) return;
|
|
1054
|
+
item(answeredLine(title, text));
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
// `└` (and a closing note beside it), then the terminal is the caller's
|
|
1058
|
+
// again: stdin paused and its listeners gone, so the line-mode confirm
|
|
1059
|
+
// (askApply → askLine) reads the next line. Keys the decoder already took
|
|
1060
|
+
// and no question asked for are dropped with the queue. Keys typed after
|
|
1061
|
+
// this, while stdin is paused and the terminal back in line mode, wait in
|
|
1062
|
+
// the terminal's own buffer and reach the next askLine: draining them
|
|
1063
|
+
// before the confirm is the caller's.
|
|
1064
|
+
//
|
|
1065
|
+
// Closed while a question is still asked, it cancels that question
|
|
1066
|
+
// first: its cancelled line drawn and the terminal restored here, then
|
|
1067
|
+
// the question woken, so it resolves to CANCELLED and its `finally` runs.
|
|
1068
|
+
close(note = "") {
|
|
1069
|
+
if (closed) return;
|
|
1070
|
+
closed = true;
|
|
1071
|
+
if (live) {
|
|
1072
|
+
cancelled = true;
|
|
1073
|
+
item(answeredLine(live.title, "cancelled", "warning", "attention", "attention"));
|
|
1074
|
+
restore();
|
|
1075
|
+
}
|
|
1076
|
+
erase();
|
|
1077
|
+
write(`${paint("explanation", icon(c, "railEnd"))}${note ? ` ${note}` : ""}\n`);
|
|
1078
|
+
stdin.removeListener("keypress", onKey);
|
|
1079
|
+
stdin.removeListener("end", onEnd);
|
|
1080
|
+
stdin.removeListener("error", onEnd);
|
|
1081
|
+
queue.length = 0;
|
|
1082
|
+
const woken = waiter;
|
|
1083
|
+
waiter = null;
|
|
1084
|
+
try {
|
|
1085
|
+
stdin.pause();
|
|
1086
|
+
} catch {}
|
|
1087
|
+
release();
|
|
1088
|
+
if (woken) woken();
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// What was typed ahead of a line-mode question, discarded (the setup story,
|
|
1094
|
+
// rule 2: the rail's raw mode ends at `└`, and the confirm after it is
|
|
1095
|
+
// askLine's). Keys typed while the run fetched, read the registry and drew
|
|
1096
|
+
// PLAN wait in the terminal's buffer — an Enter double-tapped on the last rail
|
|
1097
|
+
// question among them — and the next askLine would take them as the answer
|
|
1098
|
+
// to a question nobody saw. So: raw mode on (the terminal hands over what it
|
|
1099
|
+
// holds, line or not), the stream resumed for `ticks` turns of the event loop,
|
|
1100
|
+
// then paused, raw mode off, and whatever arrived dropped. Returns the bytes
|
|
1101
|
+
// dropped. A stream that cannot be put in raw mode is left alone (no rail ran
|
|
1102
|
+
// on it). `wait` is the test's turn of the loop.
|
|
1103
|
+
export async function drainInput(stdin, { ticks = 2, wait = () => new Promise(setImmediate) } = {}) {
|
|
1104
|
+
if (!stdin || typeof stdin.setRawMode !== "function") return 0;
|
|
1105
|
+
let dropped = 0;
|
|
1106
|
+
const sink = (chunk) => {
|
|
1107
|
+
dropped += Buffer.byteLength(chunk);
|
|
1108
|
+
};
|
|
1109
|
+
try {
|
|
1110
|
+
stdin.setRawMode(true);
|
|
1111
|
+
stdin.on("data", sink);
|
|
1112
|
+
stdin.resume();
|
|
1113
|
+
for (let k = 0; k < ticks; k++) await wait();
|
|
1114
|
+
} finally {
|
|
1115
|
+
stdin.removeListener("data", sink);
|
|
1116
|
+
try {
|
|
1117
|
+
stdin.pause();
|
|
1118
|
+
} catch {}
|
|
1119
|
+
try {
|
|
1120
|
+
stdin.setRawMode(false);
|
|
1121
|
+
} catch {}
|
|
1122
|
+
}
|
|
1123
|
+
return dropped;
|
|
1124
|
+
}
|