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/AGENTS.md +1 -1
- package/README.md +14 -24
- package/context/coding-standards.md +19 -0
- package/package.json +2 -2
- package/skills/complete-feature/SKILL.md +2 -2
- package/src/cli.mjs +781 -82
- package/src/install.mjs +30 -2
- package/src/kit.mjs +16 -0
- package/src/progress.mjs +144 -0
- package/src/theme.mjs +362 -0
package/src/install.mjs
CHANGED
|
@@ -65,15 +65,28 @@ export function planInstall(kitRoot, targetRoot, { force = false } = {}) {
|
|
|
65
65
|
* itself alongside everything that did succeed instead of aborting the run
|
|
66
66
|
* halfway with no summary. Entries marked `skip` are never opened.
|
|
67
67
|
*
|
|
68
|
+
* `onProgress` is called once per plan item, after that item has resolved, with
|
|
69
|
+
* the item and whether it succeeded. It exists so a caller can report real
|
|
70
|
+
* progress without this function knowing what a progress bar is — there is no
|
|
71
|
+
* timer here, no rate, and no percentage, because a completion event is the
|
|
72
|
+
* only thing this layer actually knows. Omit it and nothing changes: the
|
|
73
|
+
* callback is the only difference between this and the version that had no
|
|
74
|
+
* parameter at all.
|
|
75
|
+
*
|
|
76
|
+
* A failed write reports `ok: false` rather than being skipped silently. A
|
|
77
|
+
* caller that counted only calls, and not outcomes, could otherwise render a
|
|
78
|
+
* complete bar over a partly failed install.
|
|
79
|
+
*
|
|
68
80
|
* @returns {{written: number, skipped: number, overwritten: number,
|
|
69
81
|
* errors: {relativePath: string, message: string}[]}}
|
|
70
82
|
*/
|
|
71
|
-
export function applyPlan(plan, { dryRun = false } = {}) {
|
|
83
|
+
export function applyPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
72
84
|
const result = { written: 0, skipped: 0, overwritten: 0, errors: [] };
|
|
73
85
|
|
|
74
86
|
for (const item of plan) {
|
|
75
87
|
if (item.status === "skip") {
|
|
76
88
|
result.skipped += 1;
|
|
89
|
+
onProgress?.({ item, ok: true });
|
|
77
90
|
continue;
|
|
78
91
|
}
|
|
79
92
|
|
|
@@ -86,12 +99,14 @@ export function applyPlan(plan, { dryRun = false } = {}) {
|
|
|
86
99
|
relativePath: item.relativePath,
|
|
87
100
|
message: error.message,
|
|
88
101
|
});
|
|
102
|
+
onProgress?.({ item, ok: false });
|
|
89
103
|
continue;
|
|
90
104
|
}
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
if (item.status === "overwrite") result.overwritten += 1;
|
|
94
108
|
else result.written += 1;
|
|
109
|
+
onProgress?.({ item, ok: true });
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
return result;
|
|
@@ -205,26 +220,37 @@ function actionFor(state, force) {
|
|
|
205
220
|
* Conflicts and orphans are outcomes, not errors: nothing went wrong, and the
|
|
206
221
|
* files they name are the ones this tool successfully left alone.
|
|
207
222
|
*
|
|
223
|
+
* `onProgress` behaves exactly as it does in `applyPlan`, and for the same
|
|
224
|
+
* reason: one call per plan item, after it resolves, carrying the item and
|
|
225
|
+
* whether it succeeded. Conflicts, orphans, and up-to-date adapters all report
|
|
226
|
+
* `ok: true` — nothing went wrong in any of those cases, and each one is an
|
|
227
|
+
* enumerated unit of a plan the user is watching being carried out. Only an
|
|
228
|
+
* unreadable file and a failed write report `ok: false`.
|
|
229
|
+
*
|
|
208
230
|
* @returns {{generated: number, replaced: number, unchanged: number,
|
|
209
231
|
* conflicts: string[], orphans: string[],
|
|
210
232
|
* errors: {relativePath: string, message: string}[]}}
|
|
211
233
|
*/
|
|
212
|
-
export function applyAdapterPlan(plan, { dryRun = false } = {}) {
|
|
234
|
+
export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
213
235
|
const result = { generated: 0, replaced: 0, unchanged: 0, conflicts: [], orphans: [], errors: [] };
|
|
214
236
|
|
|
215
237
|
for (const item of plan) {
|
|
216
238
|
switch (item.action) {
|
|
217
239
|
case "up-to-date":
|
|
218
240
|
result.unchanged += 1;
|
|
241
|
+
onProgress?.({ item, ok: true });
|
|
219
242
|
continue;
|
|
220
243
|
case "conflict":
|
|
221
244
|
result.conflicts.push(item.relativePath);
|
|
245
|
+
onProgress?.({ item, ok: true });
|
|
222
246
|
continue;
|
|
223
247
|
case "orphan":
|
|
224
248
|
result.orphans.push(item.relativePath);
|
|
249
|
+
onProgress?.({ item, ok: true });
|
|
225
250
|
continue;
|
|
226
251
|
case "unreadable":
|
|
227
252
|
result.errors.push({ relativePath: item.relativePath, message: item.message });
|
|
253
|
+
onProgress?.({ item, ok: false });
|
|
228
254
|
continue;
|
|
229
255
|
default:
|
|
230
256
|
break;
|
|
@@ -236,12 +262,14 @@ export function applyAdapterPlan(plan, { dryRun = false } = {}) {
|
|
|
236
262
|
writeFileSync(item.destination, item.contents, "utf8");
|
|
237
263
|
} catch (error) {
|
|
238
264
|
result.errors.push({ relativePath: item.relativePath, message: error.message });
|
|
265
|
+
onProgress?.({ item, ok: false });
|
|
239
266
|
continue;
|
|
240
267
|
}
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
if (item.action === "replace") result.replaced += 1;
|
|
244
271
|
else result.generated += 1;
|
|
272
|
+
onProgress?.({ item, ok: true });
|
|
245
273
|
}
|
|
246
274
|
|
|
247
275
|
return result;
|
package/src/kit.mjs
CHANGED
|
@@ -51,6 +51,22 @@ export function isExcluded(basename) {
|
|
|
51
51
|
|
|
52
52
|
const PACKAGE_ROOT = resolve(HERE, "..");
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* This installer's own version, for the identity block to state.
|
|
56
|
+
*
|
|
57
|
+
* Read from the package manifest for the same reason the copy list is: the
|
|
58
|
+
* number has one home, and a constant declared here would be a second one that
|
|
59
|
+
* `npm version` does not know to update. Read once at module load, because a
|
|
60
|
+
* file that is part of the running package cannot change underneath a single
|
|
61
|
+
* run, and because a failure to read it should surface at import rather than
|
|
62
|
+
* halfway through a report.
|
|
63
|
+
*
|
|
64
|
+
* Not exported through `findKitRoot`'s resolution: the manifest sits beside
|
|
65
|
+
* this source in both layouts, published and checkout alike, so there is
|
|
66
|
+
* nothing to search for.
|
|
67
|
+
*/
|
|
68
|
+
export const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
69
|
+
|
|
54
70
|
/**
|
|
55
71
|
* Locate the kit directories this tool should copy from.
|
|
56
72
|
*
|
package/src/progress.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install phase's progress bar: one counter, one format function, one write.
|
|
3
|
+
*
|
|
4
|
+
* Not a renderer framework, and deliberately not extensible. It holds three
|
|
5
|
+
* numbers and knows how to draw one line from them.
|
|
6
|
+
*
|
|
7
|
+
* **What makes this bar honest**, since a progress bar is the easiest thing in
|
|
8
|
+
* a CLI to fake and the prototype rejected an earlier version of it as theatre:
|
|
9
|
+
*
|
|
10
|
+
* - **The denominator is a plan, not an estimate.** `planInstall` and
|
|
11
|
+
* `planAdapters` both return complete lists before a single byte is written,
|
|
12
|
+
* so the total is a count of enumerated work rather than a guess that gets
|
|
13
|
+
* corrected as it goes.
|
|
14
|
+
* - **The numerator is completions.** It moves when `applyPlan` or
|
|
15
|
+
* `applyAdapterPlan` finishes a unit and tells us so. There is no timer in
|
|
16
|
+
* this file, no interval, no rate, and nothing that advances because time
|
|
17
|
+
* passed. That is also why no throttle is needed: the bar repaints at most
|
|
18
|
+
* once per completed unit, and the unit count is bounded by the plan.
|
|
19
|
+
* - **A run that finishes instantly is a correct run.** If the work takes 30ms
|
|
20
|
+
* the bar reaches 100% in 30ms and the phase ends. Nothing sleeps or paces
|
|
21
|
+
* itself so the effect can be admired.
|
|
22
|
+
* - **Nothing is displayed that has not been earned.** Both the fill and the
|
|
23
|
+
* percentage floor rather than round, so neither can show a complete bar
|
|
24
|
+
* while a unit is still outstanding.
|
|
25
|
+
* - **A failure cannot produce a clean 100%.** A failed unit advances nothing;
|
|
26
|
+
* it increments a separate counter that the bar renders explicitly. An
|
|
27
|
+
* install that lost two files ends visibly short with those two named, which
|
|
28
|
+
* is the one thing the summary underneath must never be contradicted about.
|
|
29
|
+
*
|
|
30
|
+
* **What it deliberately cannot do.** There is no hide-cursor sequence here and
|
|
31
|
+
* no way to write one: `theme.line` exposes exactly two escapes, return to
|
|
32
|
+
* column zero and clear the line, and neither of them is stateful. A renderer
|
|
33
|
+
* with nothing to restore cannot leave a terminal broken, so this file has no
|
|
34
|
+
* signal handler, no exit hook, and no try/finally — an interrupted run leaves
|
|
35
|
+
* a visible cursor on a partly drawn bar, which is the accepted cost and the
|
|
36
|
+
* reason the guarantee holds without any machinery defending it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How many cells the bar occupies.
|
|
41
|
+
*
|
|
42
|
+
* A constant, not a function of the terminal width. Deriving it from the
|
|
43
|
+
* terminal would mean measuring, would make the bar jump on a resize, and would
|
|
44
|
+
* make its rendering depend on a value this process has no reliable way to
|
|
45
|
+
* track. A fixed bar is stable in scrollback and identical in every transcript.
|
|
46
|
+
*/
|
|
47
|
+
const BAR_CELLS = 24;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the progress treatment for one run.
|
|
51
|
+
*
|
|
52
|
+
* Three behaviours, chosen from the theme and never from a call site:
|
|
53
|
+
*
|
|
54
|
+
* - `contract` — nothing at all. No bar, no milestone, no byte. This tier is a
|
|
55
|
+
* promise made to scripts, and a repaint written into a pipe would produce a
|
|
56
|
+
* log file containing a transcript of an animation.
|
|
57
|
+
* - `plain` — milestones, no bar. The phase still says what it finished; it
|
|
58
|
+
* simply does not repaint a line, because `dynamic` is false.
|
|
59
|
+
* - `expressive` — milestones and the bar.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {object} options.theme - the run's theme, from `createTheme`.
|
|
63
|
+
* @param {number} options.total - units the plans enumerated. Zero disables the
|
|
64
|
+
* bar, which is what a dry run and an empty plan both want.
|
|
65
|
+
* @param {(text: string) => void} options.out - where to write.
|
|
66
|
+
*/
|
|
67
|
+
export function createProgress({ theme, total = 0, out = () => {} } = {}) {
|
|
68
|
+
const speaks = theme.tier !== "contract";
|
|
69
|
+
const repaints = speaks && theme.dynamic && total > 0;
|
|
70
|
+
|
|
71
|
+
let done = 0;
|
|
72
|
+
let failed = 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The bar, as one line, from the three numbers above.
|
|
76
|
+
*
|
|
77
|
+
* Floor rather than round on both the fill and the percentage. Rounding would
|
|
78
|
+
* let 35 of 36 units display a full bar at 100%, which is a claim about work
|
|
79
|
+
* that has not happened — the exact defect this whole design exists to avoid,
|
|
80
|
+
* and one that would only ever be visible on the last unit of a slow run.
|
|
81
|
+
*/
|
|
82
|
+
const bar = () => {
|
|
83
|
+
const filled = Math.floor((done / total) * BAR_CELLS);
|
|
84
|
+
const track =
|
|
85
|
+
theme.glyph.barFull.repeat(filled) + theme.glyph.barEmpty.repeat(BAR_CELLS - filled);
|
|
86
|
+
const percent = String(Math.floor((done / total) * 100)).padStart(3);
|
|
87
|
+
|
|
88
|
+
// Painted `warn` the moment anything has failed, so the bar's own colour
|
|
89
|
+
// stops agreeing with a summary that is about to report an error. Colour is
|
|
90
|
+
// not the only carrier: the count and its glyph are spelled out beside it.
|
|
91
|
+
const tail = failed > 0 ? ` ${theme.warn(`${theme.glyph.warn} ${failed} failed`)}` : "";
|
|
92
|
+
return ` ${failed > 0 ? theme.warn(track) : theme.ok(track)} ${percent}%${tail}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const repaint = () => {
|
|
96
|
+
if (repaints) out(theme.line.start() + theme.line.clear() + bar());
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
/** One unit resolved. The only thing that may move this bar. */
|
|
101
|
+
advance({ ok = true } = {}) {
|
|
102
|
+
if (ok) done += 1;
|
|
103
|
+
else failed += 1;
|
|
104
|
+
repaint();
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A phase finished. Printed above the bar, which is then redrawn.
|
|
109
|
+
*
|
|
110
|
+
* The clear-then-print-then-repaint order is what lets a milestone scroll
|
|
111
|
+
* into the transcript while the bar stays on the last line, using only the
|
|
112
|
+
* two escapes the theme exposes. Moving the cursor up to keep the bar in
|
|
113
|
+
* place would need sequences that do not exist here on purpose.
|
|
114
|
+
*/
|
|
115
|
+
milestone(text) {
|
|
116
|
+
if (!speaks) return;
|
|
117
|
+
if (repaints) out(theme.line.start() + theme.line.clear());
|
|
118
|
+
out(`${text}\n`);
|
|
119
|
+
repaint();
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* End the phase, leaving the completed bar on screen.
|
|
124
|
+
*
|
|
125
|
+
* One newline, so the bar becomes part of the transcript rather than
|
|
126
|
+
* something the summary overwrites. Someone pasting the run into an issue
|
|
127
|
+
* keeps the evidence that the phase ran.
|
|
128
|
+
*/
|
|
129
|
+
finish() {
|
|
130
|
+
if (repaints) out("\n");
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/** Exposed for tests and for a caller that wants to assert its own totals. */
|
|
134
|
+
get completed() {
|
|
135
|
+
return done;
|
|
136
|
+
},
|
|
137
|
+
get failures() {
|
|
138
|
+
return failed;
|
|
139
|
+
},
|
|
140
|
+
get total() {
|
|
141
|
+
return total;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/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
|
+
}
|