@mutmutco/installer-face 0.1.0 → 0.2.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/README.md +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +129 -1
- package/dist/rows.d.ts +22 -0
- package/dist/spinner.d.ts +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,33 @@ UTF-8 console encoding set before the first glyph, glyphs by code point so Windo
|
|
|
51
51
|
cannot mojibake them, a rule built by repetition rather than `tr` (byte-oriented, emits mojibake),
|
|
52
52
|
and receipt widths measured in columns rather than `${#var}` bytes.
|
|
53
53
|
|
|
54
|
+
### Transient status
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createSpinner } from '@mutmutco/installer-face';
|
|
58
|
+
|
|
59
|
+
const spinner = createSpinner(face, { animate: Boolean(process.stderr.isTTY && !process.env.NO_COLOR) });
|
|
60
|
+
spinner.start('Downloading the launcher');
|
|
61
|
+
spinner.say('Downloading the launcher (12 MB)');
|
|
62
|
+
spinner.stop(); // ALWAYS before the durable line
|
|
63
|
+
console.log(face.step('Downloaded the launcher', 12));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Status goes to stderr, results to stdout, so a relayed or redirected run never receives frames. The
|
|
67
|
+
frame occupies the step line's glyph column and is rendered through `face.step(text, null, 'note')`
|
|
68
|
+
rather than composed, so it inherits `TITLE_COLUMN` and the width cap, and the durable line replaces
|
|
69
|
+
it exactly. Off a TTY it writes nothing at all — no frames, no escape codes, no cleared lines. The
|
|
70
|
+
interval is `unref`'d, so a throw before `stop()` cannot hold a process open.
|
|
71
|
+
|
|
72
|
+
clack cannot provide this: its `spinner().stop()` always prints a line carrying its own glyph, which
|
|
73
|
+
is one of the three defects that made us draw the face ourselves.
|
|
74
|
+
|
|
75
|
+
Served scripts get the same lane from the generated fragments: `face_status` / `face_status_clear`
|
|
76
|
+
in sh (gated on `[ -t 2 ]`), and `Write-FaceStatus` / `Clear-FaceStatus` in PowerShell, with frames
|
|
77
|
+
emitted BY CODE POINT because `irm` hands the file to `iex` decoded by the console code page. Both
|
|
78
|
+
advance the frame per CALL rather than on a timer: drive them from the work — a byte count, a retry,
|
|
79
|
+
a phase change.
|
|
80
|
+
|
|
54
81
|
### The guard
|
|
55
82
|
|
|
56
83
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,9 @@ export type { Face, FaceOptions, StepKind } from './face.js';
|
|
|
3
3
|
export { PRODUCTS, identityFor } from './products.js';
|
|
4
4
|
export type { ProductIdentity } from './products.js';
|
|
5
5
|
export { renderShellFace, renderPowerShellFace } from './shell.js';
|
|
6
|
+
export { renderRows } from './rows.js';
|
|
7
|
+
export type { Row } from './rows.js';
|
|
8
|
+
export { createSpinner, SPINNER_FRAMES } from './spinner.js';
|
|
9
|
+
export type { Spinner, SpinnerOptions } from './spinner.js';
|
|
6
10
|
export { assertFaceConformance, assertScriptConformance } from './conformance.js';
|
|
7
11
|
export type { ConformanceOptions, ScriptConformanceOptions } from './conformance.js';
|
package/dist/index.js
CHANGED
|
@@ -192,7 +192,7 @@ face_rule() {
|
|
|
192
192
|
face_r=''
|
|
193
193
|
face_i=0
|
|
194
194
|
while [ "$face_i" -lt "$1" ]; do
|
|
195
|
-
face_r="
|
|
195
|
+
face_r="\${face_r}\u2500"
|
|
196
196
|
face_i=$((face_i + 1))
|
|
197
197
|
done
|
|
198
198
|
printf '%s' "$face_r"
|
|
@@ -220,6 +220,29 @@ face_receipt() {
|
|
|
220
220
|
face_refusal() {
|
|
221
221
|
printf '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1" >&2
|
|
222
222
|
}
|
|
223
|
+
|
|
224
|
+
# face_status <text> \u2014 transient status, redrawn in place on STDERR (0.2.0). A served script has no
|
|
225
|
+
# timer and no background job worth the complexity, so the frame advances on each CALL: drive it from
|
|
226
|
+
# the work (a byte count, a retry, a phase change) rather than from a clock. Gated on stderr being a
|
|
227
|
+
# TTY, so a piped or logged run receives nothing and stays byte-stable.
|
|
228
|
+
FACE_FRAME=0
|
|
229
|
+
face_status() {
|
|
230
|
+
[ -t 2 ] || return 0
|
|
231
|
+
FACE_FRAME=$(( (FACE_FRAME + 1) % 4 ))
|
|
232
|
+
case "$FACE_FRAME" in
|
|
233
|
+
0) face_f='\u25D2' ;;
|
|
234
|
+
1) face_f='\u25D0' ;;
|
|
235
|
+
2) face_f='\u25D3' ;;
|
|
236
|
+
*) face_f='\u25D1' ;;
|
|
237
|
+
esac
|
|
238
|
+
printf '\\r\\033[2K%s %s %s' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_MUTED" "$face_f")" "$1" >&2
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Always call before writing the durable line, or the finished step lands on a half-drawn frame.
|
|
242
|
+
face_status_clear() {
|
|
243
|
+
[ -t 2 ] || return 0
|
|
244
|
+
printf '\\r\\033[2K' >&2
|
|
245
|
+
}
|
|
223
246
|
# <<< installer-face:end
|
|
224
247
|
`;
|
|
225
248
|
}
|
|
@@ -315,10 +338,110 @@ function Write-FaceRefusal {
|
|
|
315
338
|
param([string] $Message)
|
|
316
339
|
[Console]::Error.WriteLine((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
|
|
317
340
|
}
|
|
341
|
+
|
|
342
|
+
# Transient status, redrawn in place on STDERR (0.2.0). Frames are emitted BY CODE POINT for the same
|
|
343
|
+
# reason the glyphs are: \`irm\` hands this file to \`iex\` decoded by the console code page, so a
|
|
344
|
+
# literal UTF-8 frame arrives as mojibake on Windows PowerShell 5.1. The frame advances per CALL \u2014
|
|
345
|
+
# drive it from the work, not from a timer.
|
|
346
|
+
$FaceFrames = @([char] 0x25D2, [char] 0x25D0, [char] 0x25D3, [char] 0x25D1)
|
|
347
|
+
$FaceFrame = 0
|
|
348
|
+
function Write-FaceStatus {
|
|
349
|
+
param([string] $Text)
|
|
350
|
+
if ([Console]::IsErrorRedirected) {
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
$script:FaceFrame = ($script:FaceFrame + 1) % $FaceFrames.Length
|
|
354
|
+
$line = (Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceMuted ([string] $FaceFrames[$script:FaceFrame])) + ' ' + $Text
|
|
355
|
+
[Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K' + $line)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Always call before writing the durable line.
|
|
359
|
+
function Clear-FaceStatus {
|
|
360
|
+
if ([Console]::IsErrorRedirected) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
[Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K')
|
|
364
|
+
}
|
|
318
365
|
# <<< installer-face:end
|
|
319
366
|
`;
|
|
320
367
|
}
|
|
321
368
|
|
|
369
|
+
// src/rows.ts
|
|
370
|
+
var SEPARATOR = "\xB7";
|
|
371
|
+
var subjectOf = (row) => row.aspect ? `${row.surface} ${row.aspect}` : row.surface;
|
|
372
|
+
var groupKey = (row) => `${subjectOf(row)}\0${row.state}\0${row.mark ?? ""}\0${row.note ?? ""}`;
|
|
373
|
+
function renderRows(face, rows) {
|
|
374
|
+
if (rows.length === 0) return [];
|
|
375
|
+
const groups = /* @__PURE__ */ new Map();
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const key = groupKey(row);
|
|
378
|
+
const found = groups.get(key);
|
|
379
|
+
if (found) {
|
|
380
|
+
if (row.instance) found.instances.push(row.instance);
|
|
381
|
+
} else {
|
|
382
|
+
groups.set(key, { row, instances: row.instance ? [row.instance] : [] });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const subjects = [...groups.values()].map(({ row, instances }) => instances.length > 1 ? subjectOf(row) : [subjectOf(row), instances[0]].filter(Boolean).join(` ${SEPARATOR} `));
|
|
386
|
+
const column = Math.min(Math.max(...subjects.map(visibleWidth)), Math.max(12, face.width - 24));
|
|
387
|
+
const lines = [];
|
|
388
|
+
for (const [index, { row, instances }] of [...groups.values()].entries()) {
|
|
389
|
+
const subject = subjects[index];
|
|
390
|
+
const tail = instances.length > 1 ? `${row.state} ${SEPARATOR} ${instances.join(", ")}` : row.state;
|
|
391
|
+
const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail}`;
|
|
392
|
+
lines.push(...face.relay(body).split("\n"));
|
|
393
|
+
if (row.note) {
|
|
394
|
+
for (const part of wrapWords(row.note, Math.max(16, face.width - column - 10))) {
|
|
395
|
+
lines.push(...face.relay(`${" ".repeat(column)} ${part}`).split("\n"));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return lines;
|
|
400
|
+
}
|
|
401
|
+
function markGlyph(face, kind) {
|
|
402
|
+
const glyph = kind === "fail" ? GLYPH.cross : kind === "note" ? GLYPH.hollow : GLYPH.check;
|
|
403
|
+
return face.paint(kind === "fail" ? face.identity.accent : kind === "note" ? "38;2;150;150;150" : "38;2;31;209;138", glyph);
|
|
404
|
+
}
|
|
405
|
+
function clip(text, width) {
|
|
406
|
+
if (visibleWidth(text) <= width) return text;
|
|
407
|
+
return `\u2026${[...text].slice(-(width - 1)).join("")}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/spinner.ts
|
|
411
|
+
var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
|
|
412
|
+
function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
|
|
413
|
+
let timer = null;
|
|
414
|
+
let text = "";
|
|
415
|
+
let frame = 0;
|
|
416
|
+
const clear = () => {
|
|
417
|
+
if (animate) stream.write("\r\x1B[2K");
|
|
418
|
+
};
|
|
419
|
+
const draw = () => {
|
|
420
|
+
if (!animate) return;
|
|
421
|
+
const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
|
|
422
|
+
stream.write(`\r\x1B[2K${line}`);
|
|
423
|
+
frame += 1;
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
start(next) {
|
|
427
|
+
text = next;
|
|
428
|
+
frame = 0;
|
|
429
|
+
draw();
|
|
430
|
+
if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
|
|
431
|
+
},
|
|
432
|
+
say(next) {
|
|
433
|
+
text = next;
|
|
434
|
+
draw();
|
|
435
|
+
},
|
|
436
|
+
stop() {
|
|
437
|
+
if (timer) clearInterval(timer);
|
|
438
|
+
timer = null;
|
|
439
|
+
clear();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
var SPINNER_FRAMES = Object.freeze([...FRAMES]);
|
|
444
|
+
|
|
322
445
|
// src/conformance.ts
|
|
323
446
|
var ALLOWED = new Set(Object.values(GLYPH));
|
|
324
447
|
var FORBIDDEN = /* @__PURE__ */ new Map([
|
|
@@ -433,6 +556,8 @@ function assertScriptConformance(script, options) {
|
|
|
433
556
|
if (/\btr\s+'\s'\s+'\u2500'/u.test(text)) {
|
|
434
557
|
fail("served script", "a rule cannot be built with tr: it is byte-oriented and substitutes one byte of the three-byte character");
|
|
435
558
|
}
|
|
559
|
+
const unbraced = text.match(/\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]/u);
|
|
560
|
+
if (unbraced) fail("served script", `an unbraced $variable abuts a non-ASCII byte and macOS sh reads it as one variable name: ${JSON.stringify(unbraced[0])} \u2014 brace the expansion`);
|
|
436
561
|
}
|
|
437
562
|
if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
|
|
438
563
|
}
|
|
@@ -440,13 +565,16 @@ export {
|
|
|
440
565
|
GLYPH,
|
|
441
566
|
PALETTE,
|
|
442
567
|
PRODUCTS,
|
|
568
|
+
SPINNER_FRAMES,
|
|
443
569
|
TITLE_COLUMN,
|
|
444
570
|
assertFaceConformance,
|
|
445
571
|
assertScriptConformance,
|
|
446
572
|
createFace,
|
|
573
|
+
createSpinner,
|
|
447
574
|
faceWidth,
|
|
448
575
|
identityFor,
|
|
449
576
|
renderPowerShellFace,
|
|
577
|
+
renderRows,
|
|
450
578
|
renderShellFace,
|
|
451
579
|
stripColor,
|
|
452
580
|
visibleWidth,
|
package/dist/rows.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Face, StepKind } from './face.js';
|
|
2
|
+
export interface Row {
|
|
3
|
+
/** The thing being reported: `Hermes desktop`, `Claude Code`, `jerv-cli`. */
|
|
4
|
+
surface: string;
|
|
5
|
+
/** What about it: `skill`, `MCP`, `panel`. Omitted when the surface is the whole subject. */
|
|
6
|
+
aspect?: string;
|
|
7
|
+
/** Which copy: a Hermes profile, a host account. Omitted for machine-level work. */
|
|
8
|
+
instance?: string;
|
|
9
|
+
/** What happened: `registered`, `written`, `installed`, `2/3 profiles`. */
|
|
10
|
+
state: string;
|
|
11
|
+
/** Optional glyph for a health verdict; omit for a plain convergence row. */
|
|
12
|
+
mark?: StepKind;
|
|
13
|
+
/** A continuation hanging under this row, with no subject of its own. */
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render a batch of rows as durable lines, aligned in one column the package computes.
|
|
18
|
+
*
|
|
19
|
+
* Batch in, lines out: the column cannot be right for a row rendered alone, which is exactly how
|
|
20
|
+
* every hand-built version drifted.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderRows(face: Face, rows: readonly Row[]): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Face } from './face.js';
|
|
2
|
+
export interface Spinner {
|
|
3
|
+
/** Begin animating against this title. Safe to call again to restart. */
|
|
4
|
+
start(text: string): void;
|
|
5
|
+
/** Change the title without restarting the animation. */
|
|
6
|
+
say(text: string): void;
|
|
7
|
+
/** Stop and CLEAR the line. Always call before writing the durable step line. */
|
|
8
|
+
stop(): void;
|
|
9
|
+
}
|
|
10
|
+
export interface SpinnerOptions {
|
|
11
|
+
/** False writes nothing at all: no frames, no escape codes, no cleared lines. */
|
|
12
|
+
animate: boolean;
|
|
13
|
+
/** Defaults to stderr. Results belong on stdout; status never does. */
|
|
14
|
+
stream?: NodeJS.WritableStream;
|
|
15
|
+
/** Frame interval in ms. */
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A spinner that occupies the STEP LINE's glyph column, so the durable line that replaces it lands
|
|
20
|
+
* at the same place — one left edge, not two. It renders through `face.step(text, null, 'note')`
|
|
21
|
+
* rather than composing a line of its own, which means it inherits TITLE_COLUMN and cannot drift
|
|
22
|
+
* from it when the face changes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSpinner(face: Face, { animate, stream, intervalMs }: SpinnerOptions): Spinner;
|
|
25
|
+
/** The frames, exported so a conformance check can recognise transient output. */
|
|
26
|
+
export declare const SPINNER_FRAMES: readonly string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/installer-face",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The MM Terminal Line installer face: one renderer, the canonical product table, shell/PowerShell fragments for served one-liners, and the drift guard every surface runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|