@mutmutco/installer-face 0.1.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 +77 -0
- package/dist/conformance.d.ts +21 -0
- package/dist/face.d.ts +64 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +454 -0
- package/dist/products.d.ts +12 -0
- package/dist/shell.d.ts +20 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @mutmutco/installer-face
|
|
2
|
+
|
|
3
|
+
The MM Terminal Line, once, for every surface that talks to a human.
|
|
4
|
+
|
|
5
|
+
`docs/Guides/installer-guidebook.md` keeps the WHY. This package is the WHAT, and
|
|
6
|
+
`assertFaceConformance` is the enforcement each repo runs against its own surfaces.
|
|
7
|
+
|
|
8
|
+
**Scope** (owner ruling, 2026-09-20): the served one-liner scripts, the npm install path, `install`,
|
|
9
|
+
`update`, bootstrap scripts and doctor receipts. A repo consumes this package. It does not keep a
|
|
10
|
+
copy — and a "fallback" copy is just another copy.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
Jerv-Hub wrote this renderer, MM-Strategy copied it, and the copy immediately found the original
|
|
15
|
+
breaking its own two-space rule. Four copies are four drifts waiting, and prose cannot stop drift;
|
|
16
|
+
only shared code and a shared guard can.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createFace, assertFaceConformance } from '@mutmutco/installer-face';
|
|
22
|
+
|
|
23
|
+
const face = createFace({
|
|
24
|
+
product: 'jerv-hub',
|
|
25
|
+
color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb'),
|
|
26
|
+
columns: process.stdout.columns,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for (const line of face.welcome()) console.log(line);
|
|
30
|
+
console.log(face.step('Registering MCP server', 2));
|
|
31
|
+
console.log(face.relay(childOutput)); // another program's output, inside the rail
|
|
32
|
+
for (const line of face.receipt([...])) console.log(line);
|
|
33
|
+
console.log(face.signOff());
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Colour is a DECISION the caller passes in, never sniffed here: the same face is used by processes
|
|
37
|
+
whose stdout is a pipe, and a face drawn into a pipe is the bug this package exists to prevent.
|
|
38
|
+
|
|
39
|
+
### Served one-liners
|
|
40
|
+
|
|
41
|
+
A `curl … | sh` or `irm … | iex` script cannot import npm, so the package emits the block instead:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { renderShellFace, renderPowerShellFace } from '@mutmutco/installer-face';
|
|
45
|
+
writeFileSync('install.sh', header + renderShellFace('jerv-hub') + body);
|
|
46
|
+
writeFileSync('install.ps1', header + renderPowerShellFace('jerv-hub') + body);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The fragments carry the traps that already shipped as bugs: the stdout TTY gate (never stdin), the
|
|
50
|
+
UTF-8 console encoding set before the first glyph, glyphs by code point so Windows PowerShell 5.1
|
|
51
|
+
cannot mojibake them, a rule built by repetition rather than `tr` (byte-oriented, emits mojibake),
|
|
52
|
+
and receipt widths measured in columns rather than `${#var}` bytes.
|
|
53
|
+
|
|
54
|
+
### The guard
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { assertFaceConformance, assertScriptConformance } from '@mutmutco/installer-face';
|
|
58
|
+
|
|
59
|
+
assertFaceConformance(renderedLines, { product: 'jerv-hub', columns: 100 });
|
|
60
|
+
assertFaceConformance(pipedLines, { product: 'jerv-hub', tty: false }); // plain lane: no glyphs, no colour
|
|
61
|
+
assertScriptConformance(readFileSync('install.ps1', 'utf8'), { kind: 'ps1' });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It fails on every fault that reached a user on 2026-09-19/20: clack's corners, a single space after
|
|
65
|
+
a glyph, two left edges, foreign output inside the face, a path in a step line, a ragged receipt, a
|
|
66
|
+
reworded greeting, a face drawn into a pipe, a top-level `exit` in a served `.ps1`, and a literal
|
|
67
|
+
glyph in a file `irm` will decode by code page.
|
|
68
|
+
|
|
69
|
+
## The product table is the review gate
|
|
70
|
+
|
|
71
|
+
`PRODUCTS` holds display name, accent and warm line per product. A new product is a pull request
|
|
72
|
+
against this package — never a string typed into a repo.
|
|
73
|
+
|
|
74
|
+
## Rendering, not reading
|
|
75
|
+
|
|
76
|
+
Every fault this package guards was invisible in code review and obvious in one rendered run. The
|
|
77
|
+
tests execute the shell fragment and measure the drawn columns rather than matching source text.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ConformanceOptions {
|
|
2
|
+
product: string;
|
|
3
|
+
/** The width the surface was rendered at; lines may not exceed it. */
|
|
4
|
+
columns?: number;
|
|
5
|
+
/** False asserts the PLAIN lane: no colour, and no glyphs or frames at all. */
|
|
6
|
+
tty?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Assert a rendered surface against the contract. `lines` is what the surface actually printed,
|
|
10
|
+
* in order, colour included.
|
|
11
|
+
*/
|
|
12
|
+
export declare function assertFaceConformance(lines: readonly string[], options: ConformanceOptions): void;
|
|
13
|
+
export interface ScriptConformanceOptions {
|
|
14
|
+
kind: 'sh' | 'ps1';
|
|
15
|
+
product?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Assert a SERVED script — the thing `curl … | sh` and `irm … | iex` actually execute. These are
|
|
19
|
+
* source-level rules no rendered run can catch.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertScriptConformance(script: string, options: ScriptConformanceOptions): void;
|
package/dist/face.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ProductIdentity } from './products.js';
|
|
2
|
+
/** The guidebook glyph set. Nothing outside this may appear in a face. */
|
|
3
|
+
export declare const GLYPH: Readonly<{
|
|
4
|
+
diamond: "◆";
|
|
5
|
+
hollow: "◇";
|
|
6
|
+
bar: "│";
|
|
7
|
+
check: "✔";
|
|
8
|
+
cross: "✖";
|
|
9
|
+
dot: "●";
|
|
10
|
+
boxTop: "╭";
|
|
11
|
+
boxTopEnd: "╮";
|
|
12
|
+
boxBottom: "╰";
|
|
13
|
+
boxBottomEnd: "╯";
|
|
14
|
+
rule: "─";
|
|
15
|
+
}>;
|
|
16
|
+
/** MM Terminal Line tokens, as SGR parameters. */
|
|
17
|
+
export declare const PALETTE: Readonly<{
|
|
18
|
+
green: "38;2;31;209;138";
|
|
19
|
+
ink: "38;2;237;237;237";
|
|
20
|
+
muted: "38;2;150;150;150";
|
|
21
|
+
line: "38;2;42;42;42";
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Where a step's TITLE starts, counted from the bar: bar, two spaces, glyph, two spaces.
|
|
25
|
+
*
|
|
26
|
+
* Two spaces after the glyph, never one — the check is drawn double-width in several terminals, so a
|
|
27
|
+
* single space reads as no gap at all ("no spacing next to checkmarks", Jervaise, 2026-09-19).
|
|
28
|
+
* Wrapped continuations and relayed output share this column, which is what gives a run ONE left
|
|
29
|
+
* edge instead of three.
|
|
30
|
+
*/
|
|
31
|
+
export declare const TITLE_COLUMN = 6;
|
|
32
|
+
/** Visible width: what the terminal draws, with colour removed. Counts CODE POINTS, never bytes. */
|
|
33
|
+
export declare function visibleWidth(text: string): number;
|
|
34
|
+
/** Strip every SGR sequence — the plain lane a pipe, a log or NO_COLOR must receive. */
|
|
35
|
+
export declare function stripColor(text: string): string;
|
|
36
|
+
/** The guidebook's width cap: no emitted line exceeds the terminal width, 100 by default. */
|
|
37
|
+
export declare function faceWidth(columns?: number): number;
|
|
38
|
+
/** Break at word boundaries, hard-slicing a token longer than the line itself. */
|
|
39
|
+
export declare function wrapWords(text: string, width: number): string[];
|
|
40
|
+
export type StepKind = 'ok' | 'fail' | 'note';
|
|
41
|
+
export interface Face {
|
|
42
|
+
readonly identity: ProductIdentity;
|
|
43
|
+
readonly width: number;
|
|
44
|
+
/** `◆ <product> — Mutatis Mutandis` and the product's own warm line. */
|
|
45
|
+
welcome(): string[];
|
|
46
|
+
/** One durable line for one finished step: glyph, title, and a measured time when truthful. */
|
|
47
|
+
step(title: string, seconds?: number | null, kind?: StepKind): string;
|
|
48
|
+
/** Another program's output, kept inside the face at the step-title column. */
|
|
49
|
+
relay(text: string): string;
|
|
50
|
+
/** The closing box: what changed, and one suggested command. */
|
|
51
|
+
receipt(lines: string[] | string): string[];
|
|
52
|
+
/** The sign-off, in the product accent. */
|
|
53
|
+
signOff(): string;
|
|
54
|
+
/** A refusal that reads as a face line rather than a bare error string. */
|
|
55
|
+
refusal(message: string): string;
|
|
56
|
+
paint(sgr: string, text: string): string;
|
|
57
|
+
}
|
|
58
|
+
export interface FaceOptions {
|
|
59
|
+
product: string;
|
|
60
|
+
/** False emits no escape codes at all, so a piped or logged run is byte-stable plain text. */
|
|
61
|
+
color?: boolean;
|
|
62
|
+
columns?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare function createFace({ product, color, columns }: FaceOptions): Face;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createFace, faceWidth, stripColor, visibleWidth, wrapWords, GLYPH, PALETTE, TITLE_COLUMN } from './face.js';
|
|
2
|
+
export type { Face, FaceOptions, StepKind } from './face.js';
|
|
3
|
+
export { PRODUCTS, identityFor } from './products.js';
|
|
4
|
+
export type { ProductIdentity } from './products.js';
|
|
5
|
+
export { renderShellFace, renderPowerShellFace } from './shell.js';
|
|
6
|
+
export { assertFaceConformance, assertScriptConformance } from './conformance.js';
|
|
7
|
+
export type { ConformanceOptions, ScriptConformanceOptions } from './conformance.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// src/products.ts
|
|
2
|
+
var PRODUCTS = Object.freeze({
|
|
3
|
+
"mm-strategy": Object.freeze({
|
|
4
|
+
name: "MM Strategy",
|
|
5
|
+
accent: "38;2;249;115;22",
|
|
6
|
+
warm: "Welcome. Let's set up MM Strategy \u2014 about a minute."
|
|
7
|
+
}),
|
|
8
|
+
"mmi-hub": Object.freeze({
|
|
9
|
+
name: "mmi-hub",
|
|
10
|
+
accent: "38;2;125;211;252",
|
|
11
|
+
warm: "Welcome back. Checking your surfaces\u2026"
|
|
12
|
+
}),
|
|
13
|
+
"jerv-hub": Object.freeze({
|
|
14
|
+
name: "jerv-hub",
|
|
15
|
+
accent: "38;2;248;113;113",
|
|
16
|
+
warm: "Welcome back. Checking your surfaces\u2026"
|
|
17
|
+
}),
|
|
18
|
+
jervcode: Object.freeze({
|
|
19
|
+
name: "JervCode",
|
|
20
|
+
accent: "38;2;192;132;252",
|
|
21
|
+
warm: "Welcome back. Keeping JervCode current\u2026"
|
|
22
|
+
})
|
|
23
|
+
});
|
|
24
|
+
function identityFor(product) {
|
|
25
|
+
const identity = PRODUCTS[product];
|
|
26
|
+
if (!identity) {
|
|
27
|
+
throw new Error(`unknown product ${JSON.stringify(product)} \u2014 add it to installer/face/src/products.ts rather than composing a greeting locally (known: ${Object.keys(PRODUCTS).join(", ")})`);
|
|
28
|
+
}
|
|
29
|
+
return identity;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/face.ts
|
|
33
|
+
var GLYPH = Object.freeze({
|
|
34
|
+
diamond: "\u25C6",
|
|
35
|
+
hollow: "\u25C7",
|
|
36
|
+
bar: "\u2502",
|
|
37
|
+
check: "\u2714",
|
|
38
|
+
cross: "\u2716",
|
|
39
|
+
dot: "\u25CF",
|
|
40
|
+
boxTop: "\u256D",
|
|
41
|
+
boxTopEnd: "\u256E",
|
|
42
|
+
boxBottom: "\u2570",
|
|
43
|
+
boxBottomEnd: "\u256F",
|
|
44
|
+
rule: "\u2500"
|
|
45
|
+
});
|
|
46
|
+
var PALETTE = Object.freeze({
|
|
47
|
+
green: "38;2;31;209;138",
|
|
48
|
+
ink: "38;2;237;237;237",
|
|
49
|
+
muted: "38;2;150;150;150",
|
|
50
|
+
line: "38;2;42;42;42"
|
|
51
|
+
});
|
|
52
|
+
var TITLE_COLUMN = 6;
|
|
53
|
+
var ANSI = /\u001b\[[0-9;]*m/g;
|
|
54
|
+
function visibleWidth(text) {
|
|
55
|
+
return [...String(text).replace(ANSI, "")].length;
|
|
56
|
+
}
|
|
57
|
+
function stripColor(text) {
|
|
58
|
+
return String(text).replace(ANSI, "");
|
|
59
|
+
}
|
|
60
|
+
function faceWidth(columns) {
|
|
61
|
+
const raw = Number(columns);
|
|
62
|
+
return Math.max(40, Math.min(100, Number.isFinite(raw) && raw > 0 ? raw : 100));
|
|
63
|
+
}
|
|
64
|
+
function wrapWords(text, width) {
|
|
65
|
+
const limit = Math.max(8, Math.floor(width));
|
|
66
|
+
const lines = [];
|
|
67
|
+
let line = "";
|
|
68
|
+
for (const word of String(text).split(/\s+/u).filter(Boolean)) {
|
|
69
|
+
let token = word;
|
|
70
|
+
while (visibleWidth(token) > limit) {
|
|
71
|
+
if (line) {
|
|
72
|
+
lines.push(line);
|
|
73
|
+
line = "";
|
|
74
|
+
}
|
|
75
|
+
lines.push([...token].slice(0, limit).join(""));
|
|
76
|
+
token = [...token].slice(limit).join("");
|
|
77
|
+
}
|
|
78
|
+
if (!line) line = token;
|
|
79
|
+
else if (visibleWidth(line) + 1 + visibleWidth(token) <= limit) line += ` ${token}`;
|
|
80
|
+
else {
|
|
81
|
+
lines.push(line);
|
|
82
|
+
line = token;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (line) lines.push(line);
|
|
86
|
+
return lines.length ? lines : [""];
|
|
87
|
+
}
|
|
88
|
+
function createFace({ product, color = false, columns }) {
|
|
89
|
+
const identity = identityFor(product);
|
|
90
|
+
const width = faceWidth(columns);
|
|
91
|
+
const paint = (sgr, text) => color ? `\x1B[${sgr}m${text}\x1B[0m` : String(text);
|
|
92
|
+
const bar = () => paint(PALETTE.muted, GLYPH.bar);
|
|
93
|
+
const indent = " ".repeat(TITLE_COLUMN - 1);
|
|
94
|
+
const welcome = () => [
|
|
95
|
+
`${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
|
|
96
|
+
bar(),
|
|
97
|
+
`${bar()} ${identity.warm}`,
|
|
98
|
+
bar()
|
|
99
|
+
];
|
|
100
|
+
const step = (title, seconds = null, kind = "ok") => {
|
|
101
|
+
const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.hollow) : paint(PALETTE.green, GLYPH.check);
|
|
102
|
+
const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
|
|
103
|
+
const column = Math.min(44, Math.max(0, width - 8));
|
|
104
|
+
const reserved = time ? 6 : 0;
|
|
105
|
+
const [first, ...rest] = wrapWords(title, width - TITLE_COLUMN - reserved);
|
|
106
|
+
const head = `${bar()} ${glyph} ${first}`;
|
|
107
|
+
const pad = Math.max(1, column - visibleWidth(head));
|
|
108
|
+
return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
|
|
109
|
+
};
|
|
110
|
+
const relay = (text) => String(text).split("\n").map((line) => line.trim() === "" ? bar() : `${bar()}${indent}${line}`).join("\n");
|
|
111
|
+
const receipt = (lines) => {
|
|
112
|
+
const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
|
|
113
|
+
const line = String(raw);
|
|
114
|
+
if (visibleWidth(line) <= width - 6) return [line];
|
|
115
|
+
const lead = /^\s*/u.exec(line)?.[0] ?? "";
|
|
116
|
+
return wrapWords(line, width - 6 - lead.length).map((part) => `${lead}${part}`);
|
|
117
|
+
});
|
|
118
|
+
const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
|
|
119
|
+
const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
|
|
120
|
+
const frame = (left, right) => `${paint(identity.accent, left)}${rule}${paint(identity.accent, right)}`;
|
|
121
|
+
return [
|
|
122
|
+
frame(GLYPH.boxTop, GLYPH.boxTopEnd),
|
|
123
|
+
...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${line}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
|
|
124
|
+
frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
|
|
125
|
+
];
|
|
126
|
+
};
|
|
127
|
+
const signOff = () => `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
|
|
128
|
+
const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
|
|
129
|
+
return { identity, width, welcome, step, relay, receipt, signOff, refusal, paint };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/shell.ts
|
|
133
|
+
var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
|
|
134
|
+
function renderShellFace(product) {
|
|
135
|
+
const identity = identityFor(product);
|
|
136
|
+
return `# >>> installer-face:begin (generated by @mutmutco/installer-face \u2014 do not edit by hand)
|
|
137
|
+
# MMI-Hub docs/Guides/installer-guidebook.md, face A: branded welcome, one durable line per finished
|
|
138
|
+
# step with a measured time, relayed output kept inside the face, and a closing receipt box.
|
|
139
|
+
FACE_COLOR=0
|
|
140
|
+
if [ -t 1 ] && [ -z "\${NO_COLOR:-}" ] && [ "\${TERM:-}" != 'dumb' ]; then
|
|
141
|
+
FACE_COLOR=1
|
|
142
|
+
fi
|
|
143
|
+
FACE_NAME='${identity.name}'
|
|
144
|
+
FACE_ACCENT='${identity.accent}'
|
|
145
|
+
FACE_WARM='${identity.warm.replace(/'/gu, "'\\''")}'
|
|
146
|
+
FACE_MUTED='${PALETTE.muted}'
|
|
147
|
+
FACE_GREEN='${PALETTE.green}'
|
|
148
|
+
|
|
149
|
+
# paint <sgr> <text> -> the text, coloured only when colour is allowed.
|
|
150
|
+
paint() {
|
|
151
|
+
if [ "$FACE_COLOR" = '1' ]; then
|
|
152
|
+
printf '\\033[%sm%s\\033[0m' "$1" "$2"
|
|
153
|
+
else
|
|
154
|
+
printf '%s' "$2"
|
|
155
|
+
fi
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
face_welcome() {
|
|
159
|
+
printf '%s %s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME")" '\u2014 Mutatis Mutandis'
|
|
160
|
+
printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
|
|
161
|
+
printf '%s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$FACE_WARM"
|
|
162
|
+
printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# face_step <title> <start epoch seconds> \u2014 one durable line for a finished step, measured.
|
|
166
|
+
face_step() {
|
|
167
|
+
face_secs=$(( $(date +%s) - $2 ))
|
|
168
|
+
[ "$face_secs" -ge 0 ] || face_secs=0
|
|
169
|
+
face_title="$1"
|
|
170
|
+
if [ \${#face_title} -gt 38 ]; then
|
|
171
|
+
face_title="$(printf '%.18s' "$face_title")\u2026$(printf '%s' "$face_title" | tail -c 19)"
|
|
172
|
+
face_pad=0
|
|
173
|
+
else
|
|
174
|
+
face_pad=$(( 38 - \${#face_title} ))
|
|
175
|
+
fi
|
|
176
|
+
printf '%s %s %s%*s%s\\n' \\
|
|
177
|
+
"$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_GREEN" '\u2714')" "$face_title" "$face_pad" '' "$(paint "$FACE_MUTED" "\${face_secs}s")"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# face_relay \u2014 read another program's output and keep it inside the face, at the step-title column.
|
|
181
|
+
face_relay() {
|
|
182
|
+
while IFS= read -r face_l; do
|
|
183
|
+
if [ -n "$face_l" ]; then
|
|
184
|
+
printf '%s${RELAY_INDENT}%s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$face_l"
|
|
185
|
+
else
|
|
186
|
+
printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
|
|
187
|
+
fi
|
|
188
|
+
done
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
face_rule() {
|
|
192
|
+
face_r=''
|
|
193
|
+
face_i=0
|
|
194
|
+
while [ "$face_i" -lt "$1" ]; do
|
|
195
|
+
face_r="$face_r\u2500"
|
|
196
|
+
face_i=$((face_i + 1))
|
|
197
|
+
done
|
|
198
|
+
printf '%s' "$face_r"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# face_receipt <headline> <next command> <location> \u2014 the closing box.
|
|
202
|
+
face_receipt() {
|
|
203
|
+
face_text1="$1"
|
|
204
|
+
face_text2=" Installed into $3"
|
|
205
|
+
face_text3=" Check health any time: $2"
|
|
206
|
+
face_w1=$(( \${#face_text1} + 3 ))
|
|
207
|
+
face_w=$face_w1
|
|
208
|
+
[ \${#face_text2} -gt "$face_w" ] && face_w=\${#face_text2}
|
|
209
|
+
[ \${#face_text3} -gt "$face_w" ] && face_w=\${#face_text3}
|
|
210
|
+
face_bar="$(paint "$FACE_ACCENT" '\u2502')"
|
|
211
|
+
face_line="$(face_rule $((face_w + 4)))"
|
|
212
|
+
printf '%s\\n' "$(paint "$FACE_ACCENT" "\u256D\${face_line}\u256E")"
|
|
213
|
+
printf '%s %s %*s%s\\n' "$face_bar" "$(paint "$FACE_GREEN" '\u2714') $face_text1" "$((face_w - face_w1))" '' "$face_bar"
|
|
214
|
+
printf '%s %s%*s %s\\n' "$face_bar" "$face_text2" "$((face_w - \${#face_text2}))" '' "$face_bar"
|
|
215
|
+
printf '%s %s%*s %s\\n' "$face_bar" "$face_text3" "$((face_w - \${#face_text3}))" '' "$face_bar"
|
|
216
|
+
printf '%s\\n' "$(paint "$FACE_ACCENT" "\u2570\${face_line}\u256F")"
|
|
217
|
+
printf '%s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME \xB7 Mutatis Mutandis")"
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
face_refusal() {
|
|
221
|
+
printf '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1" >&2
|
|
222
|
+
}
|
|
223
|
+
# <<< installer-face:end
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
function renderPowerShellFace(product) {
|
|
227
|
+
const identity = identityFor(product);
|
|
228
|
+
return `# >>> installer-face:begin (generated by @mutmutco/installer-face \u2014 do not edit by hand)
|
|
229
|
+
# MMI-Hub docs/Guides/installer-guidebook.md, face A. Glyphs are emitted by code point because this
|
|
230
|
+
# file is decoded by the console code page when \`irm\` pipes it to \`iex\`.
|
|
231
|
+
$FaceDiamond = [char] 0x25C6
|
|
232
|
+
$FaceHollow = [char] 0x25C7
|
|
233
|
+
$FaceBar = [char] 0x2502
|
|
234
|
+
$FaceCheck = [char] 0x2714
|
|
235
|
+
$FaceCross = [char] 0x2716
|
|
236
|
+
$FaceDash = [char] 0x2014
|
|
237
|
+
|
|
238
|
+
$FaceName = '${identity.name}'
|
|
239
|
+
$FaceAccent = '${identity.accent}'
|
|
240
|
+
$FaceWarm = '${identity.warm.replace(/'/gu, "''").replace(/\u2026/gu, "' + [char] 0x2026 + '")}'
|
|
241
|
+
$FaceMuted = '${PALETTE.muted}'
|
|
242
|
+
$FaceGreen = '${PALETTE.green}'
|
|
243
|
+
|
|
244
|
+
$FaceColor = $false
|
|
245
|
+
if ([string]::IsNullOrEmpty($env:NO_COLOR) -and -not [Console]::IsOutputRedirected) {
|
|
246
|
+
if ((-not [string]::IsNullOrEmpty($env:WT_SESSION)) -or $PSVersionTable.PSVersion.Major -ge 6) {
|
|
247
|
+
$FaceColor = $true
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
# A console that refuses UTF-8 still gets the glyphs its code page can draw.
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function Write-Paint {
|
|
258
|
+
param([string] $Sgr, [string] $Text)
|
|
259
|
+
if ($FaceColor) {
|
|
260
|
+
return ([char] 27) + '[' + $Sgr + 'm' + $Text + ([char] 27) + '[0m'
|
|
261
|
+
}
|
|
262
|
+
return $Text
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function Write-FaceWelcome {
|
|
266
|
+
Write-Host ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent $FaceName) + ' ' + $FaceDash + ' Mutatis Mutandis')
|
|
267
|
+
Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
|
|
268
|
+
Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + $FaceWarm)
|
|
269
|
+
Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# A title longer than the step column is shortened in the middle: a path's start and tail identify it.
|
|
273
|
+
function Get-FaceTitle {
|
|
274
|
+
param([string] $Title, [int] $Width = 38)
|
|
275
|
+
if ($Title.Length -le $Width) {
|
|
276
|
+
return $Title
|
|
277
|
+
}
|
|
278
|
+
$head = [Math]::Floor(($Width - 1) / 2)
|
|
279
|
+
$tail = $Width - 1 - $head
|
|
280
|
+
return $Title.Substring(0, $head) + [char] 0x2026 + $Title.Substring($Title.Length - $tail)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function Write-FaceStep {
|
|
284
|
+
param([string] $Title, [DateTime] $Started)
|
|
285
|
+
$seconds = [Math]::Max(0, [int] ([DateTime]::UtcNow - $Started).TotalSeconds)
|
|
286
|
+
Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceGreen ([string] $FaceCheck)) + ' ' + (Get-FaceTitle $Title).PadRight(38) + ' ' + (Write-Paint $FaceMuted ($seconds.ToString() + 's')))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Another program's output, kept inside the face at the step-title column.
|
|
290
|
+
function Write-FaceRelay {
|
|
291
|
+
param([string] $Line)
|
|
292
|
+
if ([string]::IsNullOrWhiteSpace($Line)) {
|
|
293
|
+
Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + '${RELAY_INDENT}' + $Line)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function Write-FaceReceipt {
|
|
300
|
+
param([string] $Location, [string] $Next)
|
|
301
|
+
$first = [string] $FaceCheck + ' ' + $FaceName + ' is ready.'
|
|
302
|
+
$second = ' Installed into ' + $Location
|
|
303
|
+
$third = ' Check health any time: ' + $Next
|
|
304
|
+
$inner = ($first.Length, $second.Length, $third.Length | Measure-Object -Maximum).Maximum
|
|
305
|
+
$rule = [string]::new([char] 0x2500, $inner + 4)
|
|
306
|
+
Write-Host (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
|
|
307
|
+
foreach ($text in @($first, $second, $third)) {
|
|
308
|
+
Write-Host ((Write-Paint $FaceAccent ([string] $FaceBar)) + ' ' + $text.PadRight($inner) + ' ' + (Write-Paint $FaceAccent ([string] $FaceBar)))
|
|
309
|
+
}
|
|
310
|
+
Write-Host (Write-Paint $FaceAccent ([string] [char] 0x2570 + $rule + [char] 0x256F))
|
|
311
|
+
Write-Host ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent ($FaceName + ' ' + [char] 0x00B7 + ' Mutatis Mutandis')))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function Write-FaceRefusal {
|
|
315
|
+
param([string] $Message)
|
|
316
|
+
[Console]::Error.WriteLine((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
|
|
317
|
+
}
|
|
318
|
+
# <<< installer-face:end
|
|
319
|
+
`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/conformance.ts
|
|
323
|
+
var ALLOWED = new Set(Object.values(GLYPH));
|
|
324
|
+
var FORBIDDEN = /* @__PURE__ */ new Map([
|
|
325
|
+
["\u2713", "U+2713 is not in the guidebook glyph set; the check is U+2714"],
|
|
326
|
+
["\u2717", "U+2717 is not in the guidebook glyph set; the cross is U+2716"],
|
|
327
|
+
["\u21BB", "U+21BB is not in the guidebook glyph set; a retry uses the dot U+25CF"],
|
|
328
|
+
["\u250C", "U+250C is clack's intro corner; the welcome opens with the branded diamond"],
|
|
329
|
+
["\u251C", "U+251C is clack's note frame; a receipt box pairs U+256D/U+256E over U+2570/U+256F"],
|
|
330
|
+
["\u2514", "U+2514 is clack's outro corner; the sign-off carries the diamond"]
|
|
331
|
+
]);
|
|
332
|
+
var EMOJI = /[\u{1f300}-\u{1faff}\u{2600}-\u{27bf}]/u;
|
|
333
|
+
function fail(rule, detail) {
|
|
334
|
+
throw new Error(`installer face: ${rule} \u2014 ${detail}`);
|
|
335
|
+
}
|
|
336
|
+
function assertFaceConformance(lines, options) {
|
|
337
|
+
const identity = identityFor(options.product);
|
|
338
|
+
const width = options.columns ?? 100;
|
|
339
|
+
const rendered = lines.flatMap((line) => String(line).split("\n"));
|
|
340
|
+
const plain = rendered.map(stripColor);
|
|
341
|
+
if (rendered.length === 0) fail("R7 render before you ship", "no rendered lines were given to the guard");
|
|
342
|
+
if (options.tty === false) {
|
|
343
|
+
for (const line of plain) {
|
|
344
|
+
const glyph = [...line].find((char) => ALLOWED.has(char));
|
|
345
|
+
if (glyph) fail("non-TTY lane is plain", `a piped run emitted ${JSON.stringify(glyph)} in ${JSON.stringify(line)}`);
|
|
346
|
+
}
|
|
347
|
+
for (const line of rendered) {
|
|
348
|
+
if (line.includes("\x1B[")) fail("non-TTY lane is plain", `a piped run emitted colour in ${JSON.stringify(line)}`);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
for (const line of plain) {
|
|
353
|
+
if (/[\u25c6\u25c7\u2714\u2716\u25cf] (?! )/u.test(line)) {
|
|
354
|
+
fail("R4 two spaces after a glyph", `a single space follows the glyph in ${JSON.stringify(line)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const line of plain) {
|
|
358
|
+
for (const [glyph, why] of FORBIDDEN) {
|
|
359
|
+
if (line.includes(glyph)) fail("glyph set", `${why} (in ${JSON.stringify(line)})`);
|
|
360
|
+
}
|
|
361
|
+
const emoji = [...line].find((char) => EMOJI.test(char) && !ALLOWED.has(char));
|
|
362
|
+
if (emoji) fail("glyph set", `emoji ${JSON.stringify(emoji)} in ${JSON.stringify(line)}`);
|
|
363
|
+
}
|
|
364
|
+
const top = plain.findIndex((line) => line.startsWith(GLYPH.boxTop));
|
|
365
|
+
if (top !== -1) {
|
|
366
|
+
const bottom = plain.findIndex((line, index) => index > top && line.startsWith(GLYPH.boxBottom));
|
|
367
|
+
if (bottom === -1) fail("R5 receipt frame", "the receipt box opens and never closes");
|
|
368
|
+
if (!plain[top].endsWith(GLYPH.boxTopEnd)) fail("R5 receipt frame", `the top row does not pair its corners: ${JSON.stringify(plain[top])}`);
|
|
369
|
+
if (!plain[bottom].endsWith(GLYPH.boxBottomEnd)) fail("R5 receipt frame", `the bottom row does not pair its corners: ${JSON.stringify(plain[bottom])}`);
|
|
370
|
+
const widths = new Set(plain.slice(top, bottom + 1).map(visibleWidth));
|
|
371
|
+
if (widths.size !== 1) fail("R5 receipt frame", `the box is ragged: widths ${[...widths].join(", ")}`);
|
|
372
|
+
}
|
|
373
|
+
const inReceipt = (index) => top !== -1 && index >= top && index <= plain.findIndex((line, at) => at > top && line.startsWith(GLYPH.boxBottom));
|
|
374
|
+
const welcomeEnd = (plain[0] ?? "").startsWith(GLYPH.diamond) ? 4 : 0;
|
|
375
|
+
for (const [index, line] of plain.entries()) {
|
|
376
|
+
if (index < welcomeEnd || inReceipt(index)) continue;
|
|
377
|
+
if (!line.startsWith(GLYPH.bar) || line.trim() === GLYPH.bar) continue;
|
|
378
|
+
const after = [...line.slice(1)];
|
|
379
|
+
const at = after.findIndex((char) => char !== " ");
|
|
380
|
+
const edge = at + 2;
|
|
381
|
+
if (edge === 4 && ALLOWED.has(after[at])) continue;
|
|
382
|
+
if (edge !== TITLE_COLUMN + 1) {
|
|
383
|
+
fail("R1 one rail, one left edge", `text starts at column ${edge} without a glyph there; a line that carries no glyph belongs at the title column (${TITLE_COLUMN + 1}): ${JSON.stringify(line)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const line of plain) {
|
|
387
|
+
if (/\(node:\d+\)|DeprecationWarning|ExperimentalWarning/u.test(line)) {
|
|
388
|
+
fail("R2 no foreign output in the face", `a Node warning reached the face: ${JSON.stringify(line)} \u2014 fix the spawn, or run the child with --no-deprecation`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const receiptStart = top === -1 ? plain.length : top;
|
|
392
|
+
for (const line of plain.slice(0, receiptStart)) {
|
|
393
|
+
if (/[\u2714\u2716\u25cf\u25c7]/u.test(line) && /([A-Za-z]:\\|\/(?:home|Users|opt|usr)\/)/u.test(line)) {
|
|
394
|
+
fail("R3 paths live in the receipt", `a step line carries a path: ${JSON.stringify(line)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const line of plain) {
|
|
398
|
+
if (visibleWidth(line) > width) {
|
|
399
|
+
fail("width cap", `a line is ${visibleWidth(line)} columns wide, cap ${width}: ${JSON.stringify(line)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const head = plain[0] ?? "";
|
|
403
|
+
if (head.startsWith(GLYPH.diamond)) {
|
|
404
|
+
const expected = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
|
|
405
|
+
if (head !== expected) fail("welcome is the product table's", `expected ${JSON.stringify(expected)}, got ${JSON.stringify(head)}`);
|
|
406
|
+
const warm = plain.slice(1, 4).find((line) => line.includes(identity.warm));
|
|
407
|
+
if (!warm) fail("welcome is the product table's", `the warm line for ${options.product} is missing or reworded`);
|
|
408
|
+
}
|
|
409
|
+
const painted = rendered.join("\n");
|
|
410
|
+
if (painted.includes("\x1B[")) {
|
|
411
|
+
if (!painted.includes(identity.accent) && !painted.includes(PALETTE.muted)) {
|
|
412
|
+
fail("accent", `${options.product} rendered colour that is neither its accent nor a neutral token`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function assertScriptConformance(script, options) {
|
|
417
|
+
const text = String(script);
|
|
418
|
+
if (text.includes("%%")) fail("served script", "an unrendered placeholder survived");
|
|
419
|
+
if (options.kind === "ps1") {
|
|
420
|
+
const topLevelExit = text.split(/\r?\n/u).filter((line) => /^\s*exit\b/u.test(line));
|
|
421
|
+
if (topLevelExit.length > 0) {
|
|
422
|
+
fail("served script", "a top-level `exit` closes the user's terminal under `irm | iex`; report the outcome through $LASTEXITCODE");
|
|
423
|
+
}
|
|
424
|
+
for (const glyph of ALLOWED) {
|
|
425
|
+
if (text.includes(glyph)) {
|
|
426
|
+
fail("served script", `the glyph ${JSON.stringify(glyph)} is written literally; emit it by code point ([char] 0x25C6) or PowerShell 5.1 renders mojibake`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (!/IsOutputRedirected/u.test(text)) fail("served script", "colour must drop when output is redirected");
|
|
430
|
+
if (!/OutputEncoding/u.test(text)) fail("served script", "set [Console]::OutputEncoding to UTF-8 before writing or relaying any glyph");
|
|
431
|
+
} else {
|
|
432
|
+
if (!/\[ -t 1 \]/u.test(text)) fail("served script", "colour must be gated on stdout being a TTY (`[ -t 1 ]`), never on stdin");
|
|
433
|
+
if (/\btr\s+'\s'\s+'\u2500'/u.test(text)) {
|
|
434
|
+
fail("served script", "a rule cannot be built with tr: it is byte-oriented and substitutes one byte of the three-byte character");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
|
|
438
|
+
}
|
|
439
|
+
export {
|
|
440
|
+
GLYPH,
|
|
441
|
+
PALETTE,
|
|
442
|
+
PRODUCTS,
|
|
443
|
+
TITLE_COLUMN,
|
|
444
|
+
assertFaceConformance,
|
|
445
|
+
assertScriptConformance,
|
|
446
|
+
createFace,
|
|
447
|
+
faceWidth,
|
|
448
|
+
identityFor,
|
|
449
|
+
renderPowerShellFace,
|
|
450
|
+
renderShellFace,
|
|
451
|
+
stripColor,
|
|
452
|
+
visibleWidth,
|
|
453
|
+
wrapWords
|
|
454
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ProductIdentity {
|
|
2
|
+
/** The display name exactly as the guidebook writes it — `jerv-hub`, not `Jerv Hub`. */
|
|
3
|
+
readonly name: string;
|
|
4
|
+
/** Product accent as SGR parameters (truecolor). Callers degrade by dropping colour entirely. */
|
|
5
|
+
readonly accent: string;
|
|
6
|
+
/** The warm line under the welcome. Verbatim from the guidebook; never composed at a call site. */
|
|
7
|
+
readonly warm: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const PRODUCTS: Readonly<Record<string, ProductIdentity>>;
|
|
10
|
+
/** The identity for a product key. An unknown key is a caller bug, and failing here is cheaper than
|
|
11
|
+
* shipping an installer that greets a user as the wrong product. */
|
|
12
|
+
export declare function identityFor(product: string): ProductIdentity;
|
package/dist/shell.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The POSIX sh face block.
|
|
3
|
+
*
|
|
4
|
+
* Two traps are baked in rather than left to the caller, because both shipped as bugs first:
|
|
5
|
+
* - `${#var}` counts BYTES in a POSIX shell, so a string containing the check measures three
|
|
6
|
+
* columns too wide and tears the receipt frame open. Everything measured here is ASCII and the
|
|
7
|
+
* glyph is accounted for by hand.
|
|
8
|
+
* - a rule cannot be built with `tr ' ' '─'`: tr is byte-oriented, substitutes ONE byte of the
|
|
9
|
+
* three-byte character and emits mojibake. It is built by repetition.
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderShellFace(product: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The Windows PowerShell 5.1 face block.
|
|
14
|
+
*
|
|
15
|
+
* Every glyph is emitted BY CODE POINT, never as a literal character: `irm` hands the file to `iex`
|
|
16
|
+
* as text decoded by the console's own code page, and Windows PowerShell 5.1 renders literal UTF-8
|
|
17
|
+
* glyphs as mojibake. Colour is opt-in to terminals that speak VT and is dropped when output is
|
|
18
|
+
* redirected, so a logged run stays byte-stable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function renderPowerShellFace(product: string): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mutmutco/installer-face",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"esbuild": "^0.28.1",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"vitest": "^4.1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|