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/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
  *
@@ -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: `node:readline` and printed
14
- * lines. No setRawMode, no keypress handler, no cursor control, no redraw. That
15
- * is what makes this work in a dumb terminal, over ssh, and inside an editor's
16
- * integrated console, and it is a decision rather than a default.
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({ input, output, interactive = false, retries = 3 }) {
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((choice, index) => ` ${index + 1}. ${choice.label}`),
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((choice, index) => ` ${index + 1}. ${choice.label}`),
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
- const readline = createInterface({ input, output });
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
  *