@inthedark/wb 0.0.2 → 0.0.4
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/WorkbenchBootstrap.mjs +2 -2
- package/WorkbenchInstallPrompt.mjs +269 -0
- package/installer-cube.mjs +113 -0
- package/package.json +4 -3
- package/WorkbenchBootstrapPrompt.mjs +0 -71
package/WorkbenchBootstrap.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { DatabaseSync } from "node:sqlite";
|
|
12
|
-
import
|
|
12
|
+
import WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
|
|
13
13
|
import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
|
|
14
14
|
|
|
15
15
|
export default class WorkbenchBootstrap {
|
|
@@ -17,7 +17,7 @@ export default class WorkbenchBootstrap {
|
|
|
17
17
|
home = os.homedir(),
|
|
18
18
|
packageRoot = path.dirname(fileURLToPath(import.meta.url)),
|
|
19
19
|
environment = process.env,
|
|
20
|
-
prompt = new
|
|
20
|
+
prompt = new WorkbenchInstallPrompt(),
|
|
21
21
|
commands = new WorkbenchBootstrapCommand(),
|
|
22
22
|
write = text => process.stdout.write(text),
|
|
23
23
|
} = {}) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchInstallPrompt (default): owns dependency-free installer terminal interaction and the pinned cube header.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { INSTALLER_CUBE_ROWS, installerCubeWidth, renderInstallerCube } from "./installer-cube.mjs";
|
|
9
|
+
|
|
10
|
+
const WORDMARK = "w o r k b e n c h";
|
|
11
|
+
const FRAME_INTERVAL_MS = 80;
|
|
12
|
+
// Install log rows kept below a pinned header; shorter terminals skip pinning.
|
|
13
|
+
const LOG_ROWS = 8;
|
|
14
|
+
|
|
15
|
+
function scheduleFrames(tick) {
|
|
16
|
+
const timer = setInterval(tick, FRAME_INTERVAL_MS);
|
|
17
|
+
timer.unref?.();
|
|
18
|
+
return () => clearInterval(timer);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default class WorkbenchInstallPrompt {
|
|
22
|
+
constructor({
|
|
23
|
+
input = process.stdin,
|
|
24
|
+
output = process.stdout,
|
|
25
|
+
platform = process.platform,
|
|
26
|
+
cwd = process.cwd(),
|
|
27
|
+
home = os.homedir(),
|
|
28
|
+
schedule = scheduleFrames,
|
|
29
|
+
now = () => performance.now(),
|
|
30
|
+
signals = process,
|
|
31
|
+
} = {}) {
|
|
32
|
+
this.input = input;
|
|
33
|
+
this.output = output;
|
|
34
|
+
this.schedule = schedule;
|
|
35
|
+
this.now = now;
|
|
36
|
+
this.signals = signals;
|
|
37
|
+
this.paths = platform === "win32" ? path.win32 : path.posix;
|
|
38
|
+
this.windows = platform === "win32";
|
|
39
|
+
this.cwd = cwd;
|
|
40
|
+
this.home = home;
|
|
41
|
+
this.active = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
suffix(value) {
|
|
45
|
+
const name = this.paths.basename(value);
|
|
46
|
+
const comparable = this.windows ? name.toLowerCase() : name;
|
|
47
|
+
if (comparable === "wb" || comparable === "workbench") return "";
|
|
48
|
+
return `${value.endsWith(this.paths.sep) || (this.windows && value.endsWith("/")) ? "" : this.paths.sep}wb`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
destination(value) {
|
|
52
|
+
if (!value.trim()) throw new Error("Choose an installation directory.");
|
|
53
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error("Installation paths cannot contain control characters.");
|
|
54
|
+
const expanded = value === "~" ? this.home
|
|
55
|
+
: value.startsWith("~/") || (this.windows && value.startsWith("~\\"))
|
|
56
|
+
? this.paths.join(this.home, value.slice(2)) : value;
|
|
57
|
+
return this.paths.resolve(this.cwd, `${expanded}${this.suffix(expanded)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
location(label, initialValue) {
|
|
61
|
+
let characters = Array.from(initialValue);
|
|
62
|
+
let cursor = characters.length;
|
|
63
|
+
return this.interact(
|
|
64
|
+
() => {
|
|
65
|
+
const value = characters.join("");
|
|
66
|
+
const suffix = this.suffix(value);
|
|
67
|
+
// Keep a bounded one-line viewport so redraw never overwrites earlier setup output.
|
|
68
|
+
const available = Math.max(8, (this.output.columns || 80) - label.length - suffix.length - 4);
|
|
69
|
+
const start = Math.max(0, cursor - available + 1);
|
|
70
|
+
const visible = characters.slice(start, start + available).join("");
|
|
71
|
+
const prefix = `${label}: `;
|
|
72
|
+
this.output.write(`\r\u001b[2K${prefix}${visible}\u001b[2m${suffix}\u001b[22m`);
|
|
73
|
+
this.output.write(`\r\u001b[${prefix.length + characters.slice(start, cursor).join("").length + 1}G`);
|
|
74
|
+
},
|
|
75
|
+
(text, key, accept) => {
|
|
76
|
+
if (key.name === "return") {
|
|
77
|
+
accept(this.destination(characters.join("")));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (key.name === "left") cursor = Math.max(0, cursor - 1);
|
|
81
|
+
else if (key.name === "right") cursor = Math.min(characters.length, cursor + 1);
|
|
82
|
+
else if (key.name === "home" || (key.ctrl && key.name === "a")) cursor = 0;
|
|
83
|
+
else if (key.name === "end" || (key.ctrl && key.name === "e")) cursor = characters.length;
|
|
84
|
+
else if (key.name === "backspace" && cursor > 0) characters.splice(--cursor, 1);
|
|
85
|
+
else if (key.name === "delete") characters.splice(cursor, 1);
|
|
86
|
+
else if (text && !key.ctrl && !key.meta && !/[\u0000-\u001f\u007f]/u.test(text)) {
|
|
87
|
+
const added = Array.from(text);
|
|
88
|
+
characters.splice(cursor, 0, ...added);
|
|
89
|
+
cursor += added.length;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
choose(label, choices) {
|
|
96
|
+
if (!choices.length) return Promise.reject(new Error("A setup choice needs options."));
|
|
97
|
+
let selected = 0;
|
|
98
|
+
let rendered = false;
|
|
99
|
+
return this.interact(
|
|
100
|
+
() => {
|
|
101
|
+
if (rendered && choices.length > 1) this.output.write(`\r\u001b[${choices.length - 1}A`);
|
|
102
|
+
this.output.write(choices.map((choice, index) =>
|
|
103
|
+
`\r\u001b[2K${index === selected ? `> \u001b[7m${choice}\u001b[27m` : ` ${choice}`}`,
|
|
104
|
+
).join("\n"));
|
|
105
|
+
rendered = true;
|
|
106
|
+
},
|
|
107
|
+
(_text, key, accept) => {
|
|
108
|
+
if (key.name === "up") selected = (selected + choices.length - 1) % choices.length;
|
|
109
|
+
else if (key.name === "down" || key.name === "tab") selected = (selected + 1) % choices.length;
|
|
110
|
+
else if (key.name === "return") accept(choices[selected]);
|
|
111
|
+
},
|
|
112
|
+
label,
|
|
113
|
+
choices.length,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Cube height that fits above the wordmark, label and prompt without scrolling the screen; 0 for none. */
|
|
118
|
+
cubeRows(label, promptLines) {
|
|
119
|
+
const columns = this.output.columns || 80;
|
|
120
|
+
const rows = this.output.rows || 24;
|
|
121
|
+
const labelLines = label
|
|
122
|
+
? label.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(line.length / columns)), 0)
|
|
123
|
+
: 0;
|
|
124
|
+
// Wordmark, blank line, label, prompt. Scrolling would misplace every home-anchored redraw.
|
|
125
|
+
let cube = Math.min(INSTALLER_CUBE_ROWS.max, rows - 2 - labelLines - promptLines);
|
|
126
|
+
while (cube >= INSTALLER_CUBE_ROWS.min && installerCubeWidth(cube) > columns) cube--;
|
|
127
|
+
return cube >= INSTALLER_CUBE_ROWS.min ? cube : 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Animated cube frames, an in-place redraw and the centred wordmark for a header of `cubeRows` (0 for wordmark only). */
|
|
131
|
+
header(cubeRows) {
|
|
132
|
+
const started = this.now();
|
|
133
|
+
const frame = () => renderInstallerCube((this.now() - started) / 1000, cubeRows).join("\r\n");
|
|
134
|
+
return {
|
|
135
|
+
frame,
|
|
136
|
+
// Save and restore the cursor around each frame so prompts and output below never move.
|
|
137
|
+
draw: () => this.output.write(`\u001b7\u001b[H${frame()}\u001b8`),
|
|
138
|
+
wordmark: cubeRows
|
|
139
|
+
? WORDMARK.padStart(Math.floor((installerCubeWidth(cubeRows) + WORDMARK.length) / 2))
|
|
140
|
+
: WORDMARK,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Runs `task` with the animated header pinned above a scroll region holding its output. */
|
|
145
|
+
async withHeader(task) {
|
|
146
|
+
const cubeRows = this.output.isTTY && !this.active ? this.cubeRows("", LOG_ROWS) : 0;
|
|
147
|
+
if (!cubeRows) return await task();
|
|
148
|
+
const rows = this.output.rows || 24;
|
|
149
|
+
const top = cubeRows + 3;
|
|
150
|
+
const { frame, draw, wordmark } = this.header(cubeRows);
|
|
151
|
+
let pinned = true;
|
|
152
|
+
// Resetting the region homes the cursor; save/restore keeps it where the output left off.
|
|
153
|
+
const release = () => {
|
|
154
|
+
if (!pinned) return;
|
|
155
|
+
pinned = false;
|
|
156
|
+
this.output.write("\u001b7\u001b[r\u001b8");
|
|
157
|
+
};
|
|
158
|
+
// Default SIGINT exits without cleanup and would trap the shell inside the region.
|
|
159
|
+
const interrupt = () => {
|
|
160
|
+
release();
|
|
161
|
+
process.exit(130);
|
|
162
|
+
};
|
|
163
|
+
process.once("exit", release);
|
|
164
|
+
process.once("SIGINT", interrupt);
|
|
165
|
+
let stopAnimation = () => {};
|
|
166
|
+
try {
|
|
167
|
+
// Blank lines push earlier shell output into scrollback instead of erasing it.
|
|
168
|
+
this.output.write(`${"\r\n".repeat(rows)}\u001b[H${frame()}\r\n${wordmark}\r\n\u001b[${top};${rows}r\u001b[${top};1H`);
|
|
169
|
+
stopAnimation = this.schedule(() => { if (pinned) draw(); });
|
|
170
|
+
return await task();
|
|
171
|
+
} finally {
|
|
172
|
+
stopAnimation();
|
|
173
|
+
process.removeListener("exit", release);
|
|
174
|
+
process.removeListener("SIGINT", interrupt);
|
|
175
|
+
release();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interact(render, handle, label = "", promptLines = 1) {
|
|
180
|
+
if (!this.input.isTTY || !this.output.isTTY) {
|
|
181
|
+
return Promise.reject(new Error("Workbench setup needs an interactive terminal; no changes were accepted."));
|
|
182
|
+
}
|
|
183
|
+
if (this.active) return Promise.reject(new Error("Another setup prompt is active."));
|
|
184
|
+
this.active = true;
|
|
185
|
+
const wasRaw = this.input.isRaw;
|
|
186
|
+
// A fresh stdin is neither flowing nor paused; leaving it flowing would let this
|
|
187
|
+
// process keep reading the console and steal keys from later child prompts.
|
|
188
|
+
const wasFlowing = this.input.readableFlowing === true;
|
|
189
|
+
emitKeypressEvents(this.input);
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
let settled = false;
|
|
192
|
+
let screenActive = false;
|
|
193
|
+
let stopAnimation = null;
|
|
194
|
+
const restoreScreen = () => {
|
|
195
|
+
if (!screenActive) return;
|
|
196
|
+
screenActive = false;
|
|
197
|
+
this.output.write("\u001b[0m\u001b[?1049l");
|
|
198
|
+
};
|
|
199
|
+
const finish = (error, value) => {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
stopAnimation?.();
|
|
203
|
+
stopAnimation = null;
|
|
204
|
+
this.input.removeListener("keypress", onKey);
|
|
205
|
+
this.input.removeListener("end", onEnd);
|
|
206
|
+
this.input.removeListener("error", onError);
|
|
207
|
+
this.signals.removeListener("SIGINT", onEnd);
|
|
208
|
+
process.removeListener("exit", restoreScreen);
|
|
209
|
+
try {
|
|
210
|
+
this.input.setRawMode(Boolean(wasRaw));
|
|
211
|
+
if (!wasFlowing) this.input.pause();
|
|
212
|
+
} catch (cleanupError) {
|
|
213
|
+
error ??= cleanupError;
|
|
214
|
+
}
|
|
215
|
+
try { restoreScreen(); }
|
|
216
|
+
catch (cleanupError) { error ??= cleanupError; }
|
|
217
|
+
this.active = false;
|
|
218
|
+
if (error) reject(error);
|
|
219
|
+
else resolve(value);
|
|
220
|
+
};
|
|
221
|
+
const onEnd = () => finish(new DOMException("Setup cancelled.", "AbortError"));
|
|
222
|
+
const onError = (error) => finish(error);
|
|
223
|
+
const onKey = (text, key = {}) => {
|
|
224
|
+
if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
|
|
225
|
+
onEnd();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
handle(text, key, (value) => finish(null, value));
|
|
230
|
+
if (!settled) render();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
finish(error);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
this.input.once("end", onEnd);
|
|
236
|
+
this.input.once("error", onError);
|
|
237
|
+
this.signals.once("SIGINT", onEnd);
|
|
238
|
+
try {
|
|
239
|
+
this.input.setRawMode(true);
|
|
240
|
+
screenActive = true;
|
|
241
|
+
process.once("exit", restoreScreen);
|
|
242
|
+
const cubeRows = this.cubeRows(label, promptLines);
|
|
243
|
+
const { frame, draw, wordmark } = this.header(cubeRows);
|
|
244
|
+
this.output.write(`\u001b[?1049h\u001b[2J\u001b[H${cubeRows ? `${frame()}\r\n` : ""}${wordmark}\r\n\r\n${label ? `${label}\r\n` : ""}`);
|
|
245
|
+
render();
|
|
246
|
+
if (cubeRows) {
|
|
247
|
+
stopAnimation = this.schedule(() => {
|
|
248
|
+
if (settled) return;
|
|
249
|
+
try { draw(); }
|
|
250
|
+
catch (error) { finish(error); }
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
let bufferedInterrupt = false;
|
|
254
|
+
for (let chunk = this.input.read(); chunk !== null; chunk = this.input.read()) {
|
|
255
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
|
256
|
+
bufferedInterrupt ||= text.includes("\u0003") || text.includes("\u0004");
|
|
257
|
+
}
|
|
258
|
+
if (bufferedInterrupt) {
|
|
259
|
+
onEnd();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.input.on("keypress", onKey);
|
|
263
|
+
this.input.resume();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
finish(error);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - INSTALLER_CUBE_ROWS: largest and smallest cube heights worth drawing, in terminal rows.
|
|
4
|
+
* - installerCubeWidth: terminal columns a cube frame of the given height occupies.
|
|
5
|
+
* - renderInstallerCube: raycast one antialiased ASCII frame of the spinning installer cube.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const INSTALLER_CUBE_ROWS = Object.freeze({ max: 21, min: 11 });
|
|
9
|
+
|
|
10
|
+
// Terminal cells are roughly twice as tall as they are wide.
|
|
11
|
+
const CELL_ASPECT = 2.05;
|
|
12
|
+
const RAMP = " .:-=+*#%@";
|
|
13
|
+
const SAMPLES = 4;
|
|
14
|
+
const TILT = 0.35;
|
|
15
|
+
const SPIN = 0.8;
|
|
16
|
+
const START = 0.35;
|
|
17
|
+
const CAMERA = 5.2;
|
|
18
|
+
const FOCAL = 2.9;
|
|
19
|
+
const BORDER = 0.1;
|
|
20
|
+
const STROKE = 0.09;
|
|
21
|
+
const DOT = 0.12;
|
|
22
|
+
const TOP_FILL = 0.4;
|
|
23
|
+
|
|
24
|
+
// Face glyphs live in face space: u to the right and v up when the face is viewed from outside.
|
|
25
|
+
const GLYPHS = {
|
|
26
|
+
check: { strokes: [[[-0.45, 0], [-0.12, -0.38], [0.48, 0.42]]], dots: [] },
|
|
27
|
+
x: { strokes: [[[-0.42, -0.42], [0.42, 0.42]], [[-0.42, 0.42], [0.42, -0.42]]], dots: [] },
|
|
28
|
+
question: {
|
|
29
|
+
strokes: [[[-0.32, 0.22], [-0.22, 0.42], [0, 0.5], [0.22, 0.42], [0.32, 0.22], [0.22, 0.06], [0, -0.04], [0, -0.18]]],
|
|
30
|
+
dots: [[0, -0.42]],
|
|
31
|
+
},
|
|
32
|
+
dots: { strokes: [], dots: [[-0.42, 0], [0, 0], [0.42, 0]] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Indexed by axis then sign: [x-, x+], [y-, y+], [z-, z+]. Each basis satisfies u x v = normal.
|
|
36
|
+
const FACES = [
|
|
37
|
+
[{ u: [0, 0, 1], v: [0, 1, 0], glyph: GLYPHS.dots }, { u: [0, 0, -1], v: [0, 1, 0], glyph: GLYPHS.question }],
|
|
38
|
+
[{ u: [1, 0, 0], v: [0, 0, 1], glyph: null }, { u: [1, 0, 0], v: [0, 0, -1], glyph: null, fill: TOP_FILL }],
|
|
39
|
+
[{ u: [-1, 0, 0], v: [0, 1, 0], glyph: GLYPHS.x }, { u: [1, 0, 0], v: [0, 1, 0], glyph: GLYPHS.check }],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function installerCubeWidth(rows) {
|
|
43
|
+
return Math.round(rows * 2.2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function segmentDistance(px, py, [ax, ay], [bx, by]) {
|
|
47
|
+
const dx = bx - ax, dy = by - ay;
|
|
48
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
|
|
49
|
+
return Math.hypot(px - ax - t * dx, py - ay - t * dy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function glyphCovers(glyph, u, v) {
|
|
53
|
+
for (const stroke of glyph.strokes) {
|
|
54
|
+
for (let index = 1; index < stroke.length; index++) {
|
|
55
|
+
if (segmentDistance(u, v, stroke[index - 1], stroke[index]) < STROKE) return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return glyph.dots.some(([x, y]) => Math.hypot(u - x, v - y) < DOT);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Brightness 0..1 where a camera ray through (px, py) meets the cube, given world-to-object rotation. */
|
|
62
|
+
function shade(toObject, origin, px, py) {
|
|
63
|
+
const direction = toObject(px / FOCAL, py / FOCAL, -1);
|
|
64
|
+
let near = -Infinity, far = Infinity, axis = -1, side = 0;
|
|
65
|
+
for (let index = 0; index < 3; index++) {
|
|
66
|
+
let enter = (-1 - origin[index]) / direction[index];
|
|
67
|
+
let leave = (1 - origin[index]) / direction[index];
|
|
68
|
+
let entered = 0;
|
|
69
|
+
if (enter > leave) { [enter, leave] = [leave, enter]; entered = 1; }
|
|
70
|
+
if (enter > near) { near = enter; axis = index; side = entered; }
|
|
71
|
+
if (leave < far) far = leave;
|
|
72
|
+
}
|
|
73
|
+
if (near > far || far < 0) return 0;
|
|
74
|
+
const face = FACES[axis][side];
|
|
75
|
+
const x = origin[0] + direction[0] * near, y = origin[1] + direction[1] * near, z = origin[2] + direction[2] * near;
|
|
76
|
+
const u = x * face.u[0] + y * face.u[1] + z * face.u[2];
|
|
77
|
+
const v = x * face.v[0] + y * face.v[1] + z * face.v[2];
|
|
78
|
+
if (1 - Math.max(Math.abs(u), Math.abs(v)) < BORDER) return 1;
|
|
79
|
+
if (face.fill) return face.fill;
|
|
80
|
+
return face.glyph && glyphCovers(face.glyph, u, v) ? 0.95 : 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One frame: exactly `rows` strings of exactly installerCubeWidth(rows) printable ASCII characters. */
|
|
84
|
+
export function renderInstallerCube(seconds, rows = INSTALLER_CUBE_ROWS.max) {
|
|
85
|
+
const width = installerCubeWidth(rows);
|
|
86
|
+
const spin = START + seconds * SPIN;
|
|
87
|
+
const cs = Math.cos(spin), ss = Math.sin(spin), ct = Math.cos(TILT), st = Math.sin(TILT);
|
|
88
|
+
// Undo the tilt about X, then the spin about Y.
|
|
89
|
+
const toObject = (x, y, z) => {
|
|
90
|
+
const ty = y * ct + z * st, tz = -y * st + z * ct;
|
|
91
|
+
return [x * cs - tz * ss, ty, x * ss + tz * cs];
|
|
92
|
+
};
|
|
93
|
+
const origin = toObject(0, 0, CAMERA);
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (let row = 0; row < rows; row++) {
|
|
96
|
+
let line = "";
|
|
97
|
+
for (let column = 0; column < width; column++) {
|
|
98
|
+
let coverage = 0;
|
|
99
|
+
for (let j = 0; j < SAMPLES; j++) {
|
|
100
|
+
// Nudge the view up slightly so the cube sits centred rather than low in its box.
|
|
101
|
+
const py = -(row + (j + 0.5) / SAMPLES - rows / 2) / (rows / 2) - 0.12;
|
|
102
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
103
|
+
const px = (column + (i + 0.5) / SAMPLES - width / 2) / (rows * CELL_ASPECT / 2);
|
|
104
|
+
coverage += shade(toObject, origin, px, py);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const level = Math.pow(coverage / (SAMPLES * SAMPLES), 0.75);
|
|
108
|
+
line += RAMP[Math.min(RAMP.length - 1, Math.round(level * (RAMP.length - 1)))];
|
|
109
|
+
}
|
|
110
|
+
lines.push(line);
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inthedark/wb",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"node": ">=22.13.0"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"wb": "
|
|
17
|
+
"wb": "wb"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"wb",
|
|
27
27
|
"WorkbenchBootstrap.mjs",
|
|
28
28
|
"WorkbenchBootstrapCommand.mjs",
|
|
29
|
-
"
|
|
29
|
+
"WorkbenchInstallPrompt.mjs",
|
|
30
|
+
"installer-cube.mjs",
|
|
30
31
|
"index.mjs",
|
|
31
32
|
"index.d.ts",
|
|
32
33
|
"store-definitions.mjs",
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Exports:
|
|
3
|
-
* - WorkbenchBootstrapPrompt (default): owns the stable dependency-free consent and destination prompts used before cloning.
|
|
4
|
-
*/
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { createInterface } from "node:readline/promises";
|
|
8
|
-
|
|
9
|
-
export default class WorkbenchBootstrapPrompt {
|
|
10
|
-
constructor({
|
|
11
|
-
input = process.stdin,
|
|
12
|
-
output = process.stdout,
|
|
13
|
-
platform = process.platform,
|
|
14
|
-
cwd = process.cwd(),
|
|
15
|
-
home = os.homedir(),
|
|
16
|
-
ask,
|
|
17
|
-
} = {}) {
|
|
18
|
-
this.input = input;
|
|
19
|
-
this.output = output;
|
|
20
|
-
this.paths = platform === "win32" ? path.win32 : path.posix;
|
|
21
|
-
this.windows = platform === "win32";
|
|
22
|
-
this.cwd = cwd;
|
|
23
|
-
this.home = home;
|
|
24
|
-
this.ask = ask ?? (question => this.question(question));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
suffix(value) {
|
|
28
|
-
const name = this.paths.basename(value);
|
|
29
|
-
const comparable = this.windows ? name.toLowerCase() : name;
|
|
30
|
-
if (comparable === "wb" || comparable === "workbench") return "";
|
|
31
|
-
return `${value.endsWith(this.paths.sep) || (this.windows && value.endsWith("/")) ? "" : this.paths.sep}wb`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
destination(value) {
|
|
35
|
-
if (!value.trim()) throw new Error("Choose an installation directory.");
|
|
36
|
-
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error("Installation paths cannot contain control characters.");
|
|
37
|
-
const expanded = value === "~" ? this.home
|
|
38
|
-
: value.startsWith("~/") || (this.windows && value.startsWith("~\\"))
|
|
39
|
-
? this.paths.join(this.home, value.slice(2)) : value;
|
|
40
|
-
return this.paths.resolve(this.cwd, `${expanded}${this.suffix(expanded)}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async choose(label, choices) {
|
|
44
|
-
if (!choices.length) throw new Error("A setup choice needs options.");
|
|
45
|
-
const options = choices.map((choice, index) => `${index + 1}) ${choice}`).join(" ");
|
|
46
|
-
while (true) {
|
|
47
|
-
const answer = (await this.ask(`${label}\n${options}\nChoose [1]: `)).trim();
|
|
48
|
-
const selected = answer === "" ? 0 : Number(answer) - 1;
|
|
49
|
-
if (Number.isInteger(selected) && selected >= 0 && selected < choices.length) return choices[selected];
|
|
50
|
-
this.output.write(`Choose a number from 1 to ${choices.length}.\n`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async location(label, initialValue) {
|
|
55
|
-
const answer = await this.ask(`${label} [${initialValue}]: `);
|
|
56
|
-
return this.destination(answer.trim() || initialValue);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
withHeader(task) {
|
|
60
|
-
return task();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async question(text) {
|
|
64
|
-
if (!this.input.isTTY || !this.output.isTTY) {
|
|
65
|
-
throw new Error("Workbench setup needs an interactive terminal; no changes were accepted.");
|
|
66
|
-
}
|
|
67
|
-
const terminal = createInterface({ input: this.input, output: this.output });
|
|
68
|
-
try { return await terminal.question(text); }
|
|
69
|
-
finally { terminal.close(); }
|
|
70
|
-
}
|
|
71
|
-
}
|