create-pathfinder 1.5.0 → 1.6.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/theme.mjs ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Every capability decision and every decorated byte the CLI emits.
3
+ *
4
+ * One seam, so that a second consumer or a second kind of decoration has
5
+ * somewhere to attach. Before this module, `marks()` and `supportsUnicode()`
6
+ * sat beside the one report they served, and two non-ASCII characters had
7
+ * already escaped the ASCII fallback entirely — which is the argument for the
8
+ * seam, not an accident of that particular pair.
9
+ *
10
+ * Three properties hold, and each one exists to stop a class of call-site bug:
11
+ *
12
+ * - **Total functions.** `theme.ok("text")` returns the plain string when
13
+ * colour is off; the line primitives return the empty string when repainting
14
+ * is not allowed. A call site never asks about capability, so a call site can
15
+ * never forget to. A call site that seems to need a conditional means this
16
+ * module is missing a helper.
17
+ * - **Pure.** `createTheme` reads `{ env, platform, isTTY }` and nothing else —
18
+ * no `process`, no `node:tty`, no filesystem. `run()` is already handed all
19
+ * three, so there is no second source to disagree with. The tests build
20
+ * several themes per file, which a module-level singleton would prevent.
21
+ * - **Decoration, not drawing.** No timer, no loop, no frame, no buffer, no
22
+ * state that survives a call. The line primitives are two escape strings and
23
+ * a boolean. If this file grows a concept of drawing, it has overreached.
24
+ *
25
+ * Deliberately absent: cursor visibility control. A renderer that never hides
26
+ * the cursor has nothing to restore, so no signal handler and no interrupted
27
+ * run can leave a terminal broken. A cursor parked at the end of a progress bar
28
+ * is the accepted cost of that guarantee.
29
+ */
30
+
31
+ /**
32
+ * SGR codes, written out rather than depended on.
33
+ *
34
+ * Eight ANSI colours, bold, and dim. Every *severity* in this module is one of
35
+ * these and will stay one of these: they render identically everywhere, and a
36
+ * level that means "this went wrong" must never depend on a colour that some
37
+ * terminal renders as something else.
38
+ */
39
+ const SGR = Object.freeze({
40
+ reset: "\u001B[0m",
41
+ bold: "\u001B[1m",
42
+ dim: "\u001B[2m",
43
+ red: "\u001B[31m",
44
+ green: "\u001B[32m",
45
+ yellow: "\u001B[33m",
46
+ cyan: "\u001B[36m",
47
+ });
48
+
49
+ /**
50
+ * Blaze orange, `#E0611F`, in as many alphabets as terminals actually speak.
51
+ *
52
+ * This is the one colour in the CLI that is an *identity* rather than a level,
53
+ * and it is the only reason this module knows what a colour depth is. The
54
+ * severity paints are untouched by all of it, which is the containment that
55
+ * makes the extra depth affordable: a terminal that lies about its capability
56
+ * costs the brand its exact hue, and costs meaning nothing.
57
+ *
58
+ * Three renderings, best first:
59
+ *
60
+ * - **24-bit** - the real value, exactly. Nothing is approximated.
61
+ * - **256** - index 166, `#D75F00`. Chosen by computing the nearest cell of the
62
+ * 6x6x6 cube rather than by eye: `#E0611F` is (224, 97, 31), the cube's
63
+ * levels are 0/95/135/175/215/255, and (215, 95, 0) is nearest by squared
64
+ * distance at 1046 - well clear of the obvious rival 208 `#FF8700` at 3366.
65
+ * - **16** - bold yellow, and only here. This is the floor, never the
66
+ * preference: it is the only warm accent the eight ANSI values offer, so at
67
+ * this depth alone do the brand and `warn` share a hue. The collision is
68
+ * contained rather than waved away - at this depth the identity is carried by
69
+ * the mark's form, its letterspacing, and its position, and a warning is
70
+ * always additionally a glyph and a word. Closing that overlap is precisely
71
+ * what the two depths above are for.
72
+ */
73
+ const BRAND = Object.freeze({
74
+ 24: "\u001B[38;2;224;97;31m",
75
+ 8: "\u001B[38;5;166m",
76
+ 4: `${SGR.bold}${SGR.yellow}`,
77
+ });
78
+
79
+ /**
80
+ * The glyph table, in whichever alphabet this terminal can be trusted with.
81
+ *
82
+ * One table, so a finding and the action it leads to are marked the same way,
83
+ * and so that every character above U+007F the CLI prints has exactly one
84
+ * source. `ellipsis` and `dash` are here for the same reason the severity marks
85
+ * are: they were being printed unconditionally by call sites that had already
86
+ * been told the terminal only gets ASCII.
87
+ *
88
+ * `warn` is the one genuinely new choice. In ASCII it is `*` rather than the
89
+ * obvious `!`, because `!` already means `bad` — and a collision between the
90
+ * two would land precisely in the plain tier, where the glyph is doing the most
91
+ * work because there is no colour beside it.
92
+ *
93
+ * The second group is structural rather than severity: the marks a run's
94
+ * identity and phases are built from. `scan` and `box` are emoji in the
95
+ * decorated alphabet, which is a deliberate product decision and not a drift,
96
+ * and each has an ASCII counterpart that survives the fallback with its sense
97
+ * intact - a lens, and a crate.
98
+ *
99
+ * `barFull` and `barEmpty` are the progress bar's two cells. They only ever
100
+ * render in the expressive tier, since `dynamic` is false everywhere else and
101
+ * the bar draws nothing there - but they carry ASCII counterparts anyway, so
102
+ * that the alphabets stay the same size and the fallback stays a property of
103
+ * this table rather than a fact about who happens to call it.
104
+ *
105
+ * `rule` is both the stroke the Pathfinder mark is drawn from and the character
106
+ * any other horizontal device would use. `gutter` hangs a block together down
107
+ * its left edge. Both are drawn left to right from a fixed count and neither
108
+ * closes on the right, so no caller ever has to know the printed width of a
109
+ * decorated string. That is why there is no corner, no box, and no border
110
+ * character in this table — a closed box cannot be aligned without width maths
111
+ * that emoji defeat, which the prototype demonstrated by failing to close its
112
+ * own.
113
+ */
114
+ const GLYPHS = Object.freeze({
115
+ unicode: Object.freeze({
116
+ ok: "✓",
117
+ info: "·",
118
+ warn: "▲",
119
+ bad: "✗",
120
+ dash: "—",
121
+ ellipsis: "…",
122
+ scan: "🔍",
123
+ box: "📦",
124
+ clipboard: "📋",
125
+ party: "🎉",
126
+ rule: "━",
127
+ gutter: "│",
128
+ barFull: "█",
129
+ barEmpty: "░",
130
+ }),
131
+ ascii: Object.freeze({
132
+ ok: "+",
133
+ info: "-",
134
+ warn: "*",
135
+ bad: "!",
136
+ dash: "-",
137
+ ellipsis: "...",
138
+ scan: "(o)",
139
+ box: "(=)",
140
+ clipboard: "(:)",
141
+ party: "\\o/",
142
+ rule: "=",
143
+ gutter: "|",
144
+ barFull: "#",
145
+ barEmpty: ".",
146
+ }),
147
+ });
148
+
149
+ /**
150
+ * Can this terminal be trusted with the decorated glyphs?
151
+ *
152
+ * Answered from the environment rather than attempted and hoped for, and biased
153
+ * hard toward "no": an unanswerable environment gets ASCII, which is readable
154
+ * everywhere, while a wrong "yes" leaves mojibake in the first output a new
155
+ * user ever sees from this tool.
156
+ *
157
+ * Moved from `cli.mjs` unchanged. The rules are not revisited here — a rewrite
158
+ * would be a behaviour change wearing a refactor's clothes.
159
+ */
160
+ function detectUnicode(env, platform) {
161
+ if (platform === "win32") {
162
+ return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode";
163
+ }
164
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
165
+ return /utf-?8/i.test(locale);
166
+ }
167
+
168
+ /**
169
+ * May this run emit colour?
170
+ *
171
+ * The order of these checks is the whole answer, so it is written as a sequence
172
+ * of refusals ending in the default:
173
+ *
174
+ * 1. `FORCE_COLOR=0` is an explicit "no" and outranks everything, including a
175
+ * terminal that would otherwise qualify.
176
+ * 2. `NO_COLOR` disables on presence, whatever its value — that is the
177
+ * convention, and honouring the value would make `NO_COLOR=` a surprise.
178
+ * It outranks `FORCE_COLOR`, which promises only to override TTY detection.
179
+ * 3. `TERM=dumb` is the terminal telling us what it is.
180
+ * 4. `FORCE_COLOR` set to anything else turns colour on with no TTY, which is
181
+ * how a CI job that renders ANSI in its log viewer asks for it.
182
+ * 5. Otherwise: colour if this is a terminal.
183
+ */
184
+ function detectColor(env, isTTY) {
185
+ if (env.FORCE_COLOR === "0") return false;
186
+ if (env.NO_COLOR !== undefined) return false;
187
+ if (env.TERM === "dumb") return false;
188
+ if (env.FORCE_COLOR !== undefined) return true;
189
+ return isTTY;
190
+ }
191
+
192
+ /**
193
+ * How many colours this terminal has *said* it can render: 0, 4, 8, or 24 bits.
194
+ *
195
+ * A third axis beside colour and Unicode, and deliberately not a fourth tier.
196
+ * The tier answers "what kind of presentation is this", and every tier already
197
+ * works at every depth — so a depth is a refinement of one colour, never a
198
+ * different rendering. Exactly one consumer exists, `brand`, and if a severity
199
+ * ever reads this value something has gone wrong upstream.
200
+ *
201
+ * Answered only from what the environment volunteers. Nothing is probed, no
202
+ * escape sequence is written and read back, and no reply is waited for: a
203
+ * capability query is a round trip with a terminal that may never answer, and
204
+ * this module is not allowed to block or to hold state.
205
+ *
206
+ * The order is a sequence of claims, strongest first, ending in the floor:
207
+ *
208
+ * 1. `FORCE_COLOR` at 2 or 3 is someone stating a depth outright. This is the
209
+ * convention the ecosystem settled on, and it is also the only way to
210
+ * exercise the upper depths in a test without pretending to be a terminal.
211
+ * 2. `COLORTERM` of `truecolor` or `24bit` is the de-facto announcement, set by
212
+ * every terminal that means it.
213
+ * 3. A `TERM` ending in `-direct` is the terminfo spelling of the same claim.
214
+ * 4. A `TERM` containing `256color` is the older, narrower claim.
215
+ * 5. Otherwise 4 — the floor, and the answer for every terminal that said
216
+ * nothing.
217
+ *
218
+ * Biased toward under-claiming, exactly as `detectUnicode` is, but the stakes
219
+ * are far lower here and worth stating plainly: a wrong "yes" about Unicode
220
+ * leaves mojibake in someone's first impression, whereas a wrong "yes" here
221
+ * renders one wordmark in an unintended colour or, on a terminal that ignores
222
+ * the sequence entirely, in the default one. `COLORTERM` does leak across ssh,
223
+ * tmux, and sudo, so being wrong is realistic — it is simply cheap.
224
+ */
225
+ function detectColorDepth(env, color) {
226
+ if (!color) return 0;
227
+ if (env.FORCE_COLOR === "3") return 24;
228
+ if (env.FORCE_COLOR === "2") return 8;
229
+
230
+ const colorterm = env.COLORTERM || "";
231
+ if (/^(truecolor|24bit)$/i.test(colorterm)) return 24;
232
+
233
+ const term = env.TERM || "";
234
+ if (/-direct$/i.test(term)) return 24;
235
+ if (/256color/i.test(term)) return 8;
236
+
237
+ return 4;
238
+ }
239
+
240
+ /**
241
+ * Which presentation tier this run gets.
242
+ *
243
+ * Decided once, here, as a documented function of capability — the alternative
244
+ * is every call site guessing, and them disagreeing.
245
+ *
246
+ * - `contract` — not a terminal. A pipe, a redirect, a CI log. These bytes are
247
+ * a promise kept to scripts written against 1.4.1, so the tier is decided by
248
+ * the TTY alone and nothing else can promote a run into it or out of it.
249
+ * - `expressive` — a terminal that answered yes to both colour and Unicode.
250
+ * - `plain` — a terminal that did not. Not a degraded mode: it is a supported
251
+ * way to use this tool, and anything that reads correctly only in
252
+ * `expressive` is a defect.
253
+ *
254
+ * `FORCE_COLOR` with no TTY is the one combination worth stating outright: the
255
+ * tier is `contract` and colour is on. That is not a contradiction — the tier
256
+ * answers "is anyone watching this live", the capability answers "did they ask
257
+ * for colour", and someone setting `FORCE_COLOR` in a pipeline has answered the
258
+ * second question themselves.
259
+ */
260
+ function selectTier({ isTTY, color, unicode }) {
261
+ if (!isTTY) return "contract";
262
+ return color && unicode ? "expressive" : "plain";
263
+ }
264
+
265
+ /**
266
+ * Build a theme from what the process was able to observe about the outside
267
+ * world.
268
+ *
269
+ * @param {object} [options]
270
+ * @param {Record<string, string | undefined>} [options.env] - the environment,
271
+ * as `run()` received it.
272
+ * @param {string} [options.platform] - `process.platform`, as `run()` received
273
+ * it.
274
+ * @param {boolean} [options.isTTY] - whether stdout is a terminal.
275
+ * @returns {Readonly<object>} the theme
276
+ */
277
+ export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}) {
278
+ const unicode = detectUnicode(env, platform);
279
+ const color = detectColor(env, isTTY);
280
+ const colorDepth = detectColorDepth(env, color);
281
+ const tier = selectTier({ isTTY, color, unicode });
282
+
283
+ // May this run repaint a line it has already written? Only where someone is
284
+ // watching it happen. A pipe keeps every byte ever written to it, so a
285
+ // progress treatment that repaints into a log file produces a transcript of
286
+ // its own animation.
287
+ const dynamic = tier === "expressive";
288
+
289
+ /**
290
+ * Wrap `text` in an SGR pair, or hand it back untouched.
291
+ *
292
+ * The reset is unconditional rather than a matching "off" code, because a
293
+ * caller may nest and the cheap correct thing is to end every span the same
294
+ * way. Several codes may be given, which is how emphasis combines with a
295
+ * colour without a call site nesting two paints and emitting two resets.
296
+ */
297
+ const paint =
298
+ (...codes) =>
299
+ (text) =>
300
+ color ? `${codes.join("")}${text}${SGR.reset}` : `${text}`;
301
+
302
+ const glyph = unicode ? GLYPHS.unicode : GLYPHS.ascii;
303
+
304
+ return Object.freeze({
305
+ // What was decided, exposed for tests and for the one place that may
306
+ // legitimately branch: a caller choosing between whole presentations.
307
+ tier,
308
+ color,
309
+ unicode,
310
+ dynamic,
311
+
312
+ glyph,
313
+
314
+ // Severity, named for what it means and never for the colour it happens to
315
+ // use. Four levels: `warn` is new and nothing consumes it yet.
316
+ //
317
+ // Colour never carries meaning alone. Every one of these takes a string
318
+ // that already says something, and the call sites pair them with a glyph
319
+ // from the table above — so a `plain` terminal, a screen reader, and a
320
+ // colour-blind reader all lose the decoration and keep the message.
321
+ ok: paint(SGR.green),
322
+ info: paint(SGR.cyan),
323
+ warn: paint(SGR.yellow),
324
+ bad: paint(SGR.red),
325
+
326
+ // Emphasis, not severity. Kept separate so that "important" and "something
327
+ // went wrong" cannot be confused for one another at a call site.
328
+ bold: paint(SGR.bold),
329
+ dim: paint(SGR.dim),
330
+
331
+ // What depth the brand got, exposed for the same reason `tier` is: tests
332
+ // need to assert it, and a reviewer needs to be able to ask.
333
+ colorDepth,
334
+
335
+ // Identity, and the only place a colour is chosen to mean "Pathfinder"
336
+ // rather than to mean a level.
337
+ //
338
+ // Written against BRAND rather than through `paint`, because this is the
339
+ // one paint whose escape sequence is chosen at run time from what the
340
+ // terminal claimed. Everything else in this module has exactly one code for
341
+ // its whole life, and keeping those two facts in separate functions is what
342
+ // stops a future severity from quietly acquiring a depth.
343
+ //
344
+ // Note what is *not* here: any attempt to reproduce #E0611F on a terminal
345
+ // that did not say it could. At depth 4 the brand degrades to the warm
346
+ // accent the eight ANSI values offer and the mark's form carries the rest,
347
+ // which is the whole reason the mark is a drawing of the logo and not a
348
+ // coloured word.
349
+ brand: (text) => (colorDepth === 0 ? `${text}` : `${BRAND[colorDepth]}${text}${SGR.reset}`),
350
+
351
+ // The only escape sequences that are not colour, and the reason they live
352
+ // here: a progress renderer that hand-rolled its own would be a second
353
+ // place capable of writing bytes into a pipe. Exactly two — return to
354
+ // column zero, and clear the current line — and both are the empty string
355
+ // whenever repainting is not allowed, so a caller that never checks
356
+ // `dynamic` still emits nothing.
357
+ line: Object.freeze({
358
+ start: () => (dynamic ? "\r" : ""),
359
+ clear: () => (dynamic ? "\u001B[2K" : ""),
360
+ }),
361
+ });
362
+ }