@jonaskahn/relo 0.1.4-beta.27.8d9c8af7 → 0.1.4-beta.29.0927baae
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 +47 -13
- package/lib/daemon.mjs +215 -31
- package/lib/download.mjs +35 -4
- package/lib/install.mjs +128 -14
- package/lib/progress.mjs +409 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -14,22 +14,49 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
+
## ▓ CONSOLE
|
|
18
|
+
|
|
19
|
+
<div align="center">
|
|
20
|
+
|
|
21
|
+
https://github.com/user-attachments/assets/cbda11c8-b60c-413b-8a08-03144dd06e7a
|
|
22
|
+
|
|
23
|
+
<sub>Demo: using Relo in Codex</sub>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
| | | |
|
|
27
|
+
|------------------------------------------------------------------------------|------------------------------------------------------------------------|---------------------------------------------------------------------|
|
|
28
|
+
| Dashboard | Connections | Groups |
|
|
29
|
+
| <img src="https://github.com/user-attachments/assets/ba44719e-f176-4d02-9e92-10cf7055a6e8" alt="Dashboard" width="320"> | <img src="https://github.com/user-attachments/assets/b27d9948-1f30-4aac-a0f5-d98109c5522e" alt="Connections" width="320"> | <img src="https://github.com/user-attachments/assets/29ce8e23-925f-4d22-b571-a49d8bc70841" alt="Groups" width="320"> |
|
|
30
|
+
| Agent Harness | Add agent | Usage |
|
|
31
|
+
| <img src="https://github.com/user-attachments/assets/3e5bfe37-ad48-4c12-a32a-f4901f8f982a" alt="Agent Harness" width="320"> | <img src="https://github.com/user-attachments/assets/f4730cb7-7177-40c0-8d48-905dfde29ba3" alt="Add agent" width="320"> | <img src="https://github.com/user-attachments/assets/42f329be-4d1b-408c-bc8e-87a1237eccf6" alt="Usage" width="320"> |
|
|
32
|
+
|
|
17
33
|
## Install
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
- Latest, Stable
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm i -g @jonaskahn/relo --foreground-scripts
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- Beta, Risk
|
|
42
|
+
```bash
|
|
43
|
+
npm i -g @jonaskahn/relo@beta --foreground-scripts
|
|
22
44
|
```
|
|
23
45
|
|
|
24
|
-
Installs the `relo` binary, verified against the release checksums.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
Installs the `relo` binary, verified against the release checksums. The
|
|
47
|
+
install downloads the ~13 MB daemon binary and prints each step, with a
|
|
48
|
+
download bar showing the rate and the time left. `--foreground-scripts` keeps
|
|
49
|
+
that output under the install: npm otherwise runs it in the background and
|
|
50
|
+
shows nothing until the command ends, which makes a slow download look stuck.
|
|
51
|
+
|
|
52
|
+
State lives in `~/.relo` (`$RELO_HOME`), shared with every other installer.
|
|
53
|
+
Never installed twice: if another Relo is on your PATH, this one refuses and
|
|
54
|
+
tells you where it found it.
|
|
28
55
|
|
|
29
56
|
## Start
|
|
30
57
|
|
|
31
|
-
```
|
|
32
|
-
|
|
58
|
+
```bash
|
|
59
|
+
relo daemon start # console at http://127.0.0.1:10101
|
|
33
60
|
```
|
|
34
61
|
|
|
35
62
|
One port serves everything: the console, the management API, and the data
|
|
@@ -42,16 +69,23 @@ is free. Your data is never touched. If another program holds the port, the
|
|
|
42
69
|
install fails instead of killing it:
|
|
43
70
|
|
|
44
71
|
```
|
|
45
|
-
|
|
72
|
+
✗ INSTALL FAILED
|
|
73
|
+
something else is listening on port 10101
|
|
74
|
+
free the port, or point relo at another one, and install again
|
|
46
75
|
```
|
|
47
76
|
|
|
48
77
|
## Uninstall
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
- Remove and Keep Data
|
|
80
|
+
```bash
|
|
81
|
+
npm uninstall -g @jonaskahn/relo
|
|
82
|
+
```
|
|
83
|
+
- Remove and Wipe Data
|
|
84
|
+
```bash
|
|
85
|
+
relo uninstall --wipe
|
|
53
86
|
```
|
|
54
87
|
|
|
88
|
+
|
|
55
89
|
## Links
|
|
56
90
|
|
|
57
91
|
[Source](https://github.com/jonaskahn/relo) · [Releases](https://github.com/jonaskahn/relo/releases) · [Docs](https://github.com/jonaskahn/relo#readme)
|
package/lib/daemon.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// scan cannot see keeps its default.
|
|
8
8
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
9
|
import { createInterface } from 'node:readline/promises';
|
|
10
|
-
import {
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { ReadStream } from 'node:tty';
|
|
13
14
|
|
|
14
15
|
// reloHome is the state directory the daemon serves: $RELO_HOME, else ~/.relo.
|
|
15
16
|
export function reloHome(env = process.env) {
|
|
@@ -70,11 +71,25 @@ export function parseNetstat(text, port) {
|
|
|
70
71
|
return pids;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
// isReloImage reports whether an executable name is
|
|
74
|
-
// signal never reaches a process that merely reused the port.
|
|
74
|
+
// isReloImage reports whether an executable name is a relo binary, so a
|
|
75
|
+
// signal never reaches a process that merely reused the port. The installers
|
|
76
|
+
// name the daemon `relo`, but the release assets and this package keep the
|
|
77
|
+
// platform in the name (`relo-darwin-arm64`, `relo-darwin-arm64-0.1.1`), so
|
|
78
|
+
// both spellings count.
|
|
75
79
|
export function isReloImage(comm) {
|
|
76
80
|
const base = comm.trim().split(/[\\/]/).pop().toLowerCase();
|
|
77
|
-
|
|
81
|
+
if (base === 'relo' || base === 'relo.exe') return true;
|
|
82
|
+
return /^relo-(?:darwin|linux|windows)-(?:amd64|arm64)(?:-[0-9][0-9a-z.+-]*)?(?:\.exe)?$/.test(base);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// isReloProcess reports whether a process is a relo binary. Linux truncates
|
|
86
|
+
// the command name at fifteen characters, which can cut `relo-linux-amd64`
|
|
87
|
+
// short, so the executable in the command line settles it when the name does
|
|
88
|
+
// not.
|
|
89
|
+
export function isReloProcess({ comm = '', args = '' } = {}) {
|
|
90
|
+
if (isReloImage(comm)) return true;
|
|
91
|
+
const executable = args.trim().split(/\s+/)[0] ?? '';
|
|
92
|
+
return executable !== '' && isReloImage(executable);
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
// servesHome reports whether a relo process belongs to this home. The pid
|
|
@@ -200,7 +215,7 @@ export function quiesce(home, { binary } = {}) {
|
|
|
200
215
|
owner = 0;
|
|
201
216
|
}
|
|
202
217
|
const info = owner ? processInfo(owner) : null;
|
|
203
|
-
if (!info || !
|
|
218
|
+
if (!info || !isReloProcess(info)) owner = 0;
|
|
204
219
|
}
|
|
205
220
|
const candidates = new Set();
|
|
206
221
|
if (owner) candidates.add(owner);
|
|
@@ -209,7 +224,7 @@ export function quiesce(home, { binary } = {}) {
|
|
|
209
224
|
}
|
|
210
225
|
const victims = [...candidates].filter((pid) => {
|
|
211
226
|
const info = processInfo(pid);
|
|
212
|
-
return info &&
|
|
227
|
+
return info && isReloProcess(info) && servesHome({ pid, owner, args: info.args, home, defaultHome });
|
|
213
228
|
});
|
|
214
229
|
const stopped = victims.length > 0;
|
|
215
230
|
|
|
@@ -235,7 +250,7 @@ export function quiesce(home, { binary } = {}) {
|
|
|
235
250
|
const holders = listeningPids(port);
|
|
236
251
|
if (holders.length > 0) {
|
|
237
252
|
throw new Error(
|
|
238
|
-
`
|
|
253
|
+
`something else is listening on port ${port}\n` +
|
|
239
254
|
' free the port, or point relo at another one, and install again'
|
|
240
255
|
);
|
|
241
256
|
}
|
|
@@ -243,37 +258,206 @@ export function quiesce(home, { binary } = {}) {
|
|
|
243
258
|
return { stopped };
|
|
244
259
|
}
|
|
245
260
|
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
// move steps a menu selection like a game menu: it wraps at both ends, so
|
|
262
|
+
// left from the first option lands on the last.
|
|
263
|
+
export function move(index, count, delta) {
|
|
264
|
+
if (count <= 0) return 0;
|
|
265
|
+
return (index + delta + count) % count;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// decodeKeys reads the keys a terminal chunk carries: 'left', 'right',
|
|
269
|
+
// 'enter', or 'interrupt'. pending holds an escape sequence the previous
|
|
270
|
+
// chunk ended in the middle of; the returned pending is carried into the
|
|
271
|
+
// next call.
|
|
272
|
+
//
|
|
273
|
+
// Arrows arrive in either form a terminal sends them, CSI (`ESC [ D`) and
|
|
274
|
+
// application-cursor SS3 (`ESC O D`). A modified arrow -- parameters before
|
|
275
|
+
// the final byte -- and every other sequence is consumed and ignored.
|
|
276
|
+
export function decodeKeys(pending = '', chunk = '') {
|
|
277
|
+
const text = pending + chunk;
|
|
278
|
+
const keys = [];
|
|
279
|
+
let at = 0;
|
|
280
|
+
while (at < text.length) {
|
|
281
|
+
if (text[at] === '\x1b') {
|
|
282
|
+
const end = escapeEnd(text, at);
|
|
283
|
+
if (end === 0) return { keys, pending: text.slice(at) };
|
|
284
|
+
// A lone Esc, or the Esc of an Alt chord, leaves nothing to act
|
|
285
|
+
// on. Dropping only the Esc lets the next byte read as its own
|
|
286
|
+
// key, so an Esc press never swallows a following Enter.
|
|
287
|
+
if (end === at + 1) {
|
|
288
|
+
at += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const sequence = text.slice(at, end);
|
|
292
|
+
if (sequence === '\x1b[C' || sequence === '\x1bOC') keys.push('right');
|
|
293
|
+
else if (sequence === '\x1b[D' || sequence === '\x1bOD') keys.push('left');
|
|
294
|
+
at = end;
|
|
295
|
+
continue;
|
|
258
296
|
}
|
|
297
|
+
const key = text[at];
|
|
298
|
+
if (key === '\r' || key === '\n') keys.push('enter');
|
|
299
|
+
else if (key === '\x03') keys.push('interrupt');
|
|
300
|
+
at += 1;
|
|
259
301
|
}
|
|
260
|
-
|
|
302
|
+
return { keys, pending: '' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// escapeEnd finds the byte after the escape sequence starting at `at`. Zero
|
|
306
|
+
// means the text ended before the sequence did, so the rest is a pending
|
|
307
|
+
// prefix for the next chunk.
|
|
308
|
+
function escapeEnd(text, at) {
|
|
309
|
+
const next = text[at + 1];
|
|
310
|
+
if (next === undefined) return 0;
|
|
311
|
+
if (next === '[') {
|
|
312
|
+
for (let cursor = at + 2; cursor < text.length; cursor += 1) {
|
|
313
|
+
const code = text.charCodeAt(cursor);
|
|
314
|
+
if (code >= 0x40 && code <= 0x7e) return cursor + 1;
|
|
315
|
+
// A control byte cannot sit inside a CSI: drop the Esc so the
|
|
316
|
+
// control byte still reads as itself.
|
|
317
|
+
if (code < 0x20) return at + 1;
|
|
318
|
+
}
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
if (next === 'O') {
|
|
322
|
+
const third = text[at + 2];
|
|
323
|
+
if (third === undefined) return 0;
|
|
324
|
+
return third.charCodeAt(0) < 0x20 ? at + 1 : at + 3;
|
|
325
|
+
}
|
|
326
|
+
// A bare Esc, or the Esc of an Alt chord.
|
|
327
|
+
if (next === '\x1b' || next.charCodeAt(0) < 0x20) return at + 1;
|
|
328
|
+
return at + 2;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// terminalInput opens the terminal for raw keys. POSIX reads the controlling
|
|
332
|
+
// tty, not stdin: under a piped install stdin is not the user. Windows has no
|
|
333
|
+
// /dev/tty, so a console stdin is the only source there.
|
|
334
|
+
function terminalInput() {
|
|
335
|
+
if (process.platform === 'win32') {
|
|
336
|
+
if (!process.stdin?.isTTY) return null;
|
|
337
|
+
return {
|
|
338
|
+
input: process.stdin,
|
|
339
|
+
close() {
|
|
340
|
+
// stdin stays open for the daemon spawn that follows.
|
|
341
|
+
process.stdin.pause();
|
|
342
|
+
process.stdin.unref();
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
let fd;
|
|
261
347
|
try {
|
|
262
|
-
|
|
263
|
-
await new Promise((resolve, reject) => {
|
|
264
|
-
input.once('open', resolve);
|
|
265
|
-
input.once('error', reject);
|
|
266
|
-
});
|
|
348
|
+
fd = openSync('/dev/tty', 'r+');
|
|
267
349
|
} catch {
|
|
268
|
-
return
|
|
350
|
+
return null;
|
|
269
351
|
}
|
|
270
|
-
|
|
352
|
+
let input;
|
|
271
353
|
try {
|
|
272
|
-
|
|
273
|
-
return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
|
|
354
|
+
input = new ReadStream(fd);
|
|
274
355
|
} catch {
|
|
275
|
-
|
|
356
|
+
// The stream owns the fd once it is built, but not when it is not.
|
|
357
|
+
closeSync(fd);
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
input,
|
|
362
|
+
close() {
|
|
363
|
+
input.destroy();
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// select shows labels as a left/right menu and resolves the chosen index, -1
|
|
369
|
+
// when the user interrupts, or null when there is no terminal to ask on.
|
|
370
|
+
// onDraw hears every move, so the caller can repaint its own screen.
|
|
371
|
+
export async function select(labels, { onDraw } = {}) {
|
|
372
|
+
const source = terminalInput();
|
|
373
|
+
if (!source) return null;
|
|
374
|
+
const { input } = source;
|
|
375
|
+
let at = 0;
|
|
376
|
+
let pending = '';
|
|
377
|
+
let settled = false;
|
|
378
|
+
let resolve;
|
|
379
|
+
const settle = (value) => {
|
|
380
|
+
if (settled) return;
|
|
381
|
+
settled = true;
|
|
382
|
+
resolve(value);
|
|
383
|
+
};
|
|
384
|
+
const onStop = () => settle(null);
|
|
385
|
+
const onData = (chunk) => {
|
|
386
|
+
if (settled) return;
|
|
387
|
+
const decoded = decodeKeys(pending, chunk.toString('utf8'));
|
|
388
|
+
pending = decoded.pending;
|
|
389
|
+
for (const key of decoded.keys) {
|
|
390
|
+
if (key === 'left' || key === 'right') {
|
|
391
|
+
at = move(at, labels.length, key === 'right' ? 1 : -1);
|
|
392
|
+
onDraw?.(at);
|
|
393
|
+
} else if (key === 'enter') {
|
|
394
|
+
settle(at);
|
|
395
|
+
return;
|
|
396
|
+
} else if (key === 'interrupt') {
|
|
397
|
+
settle(-1);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
try {
|
|
403
|
+
return await new Promise((done) => {
|
|
404
|
+
resolve = done;
|
|
405
|
+
// The error listener goes on before raw mode: a failed setRawMode
|
|
406
|
+
// emits rather than throws, and an unhandled emit would crash.
|
|
407
|
+
input.once('error', onStop);
|
|
408
|
+
input.once('end', onStop);
|
|
409
|
+
input.once('close', onStop);
|
|
410
|
+
try {
|
|
411
|
+
input.setRawMode(true);
|
|
412
|
+
} catch {
|
|
413
|
+
settle(null);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (settled) return;
|
|
417
|
+
onDraw?.(at);
|
|
418
|
+
input.on('data', onData);
|
|
419
|
+
});
|
|
276
420
|
} finally {
|
|
277
|
-
|
|
421
|
+
// Restore cooked mode before the error listener is removed, so a
|
|
422
|
+
// failed restore cannot surface as an unhandled error.
|
|
423
|
+
try {
|
|
424
|
+
input.setRawMode(false);
|
|
425
|
+
} catch {
|
|
426
|
+
// The terminal is gone; there is nothing left to restore.
|
|
427
|
+
}
|
|
428
|
+
input.removeListener('data', onData);
|
|
429
|
+
input.removeListener('error', onStop);
|
|
430
|
+
input.removeListener('end', onStop);
|
|
431
|
+
input.removeListener('close', onStop);
|
|
432
|
+
source.close();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// askStart asks the start question on a terminal and reports 'yes', 'no', or
|
|
437
|
+
// 'unattended' when no terminal can be asked on. The answer never comes from
|
|
438
|
+
// stdin: under a piped install stdin is not the user. onQuestion and onAnswer
|
|
439
|
+
// let the caller draw the question inside its own screen; onQuestion hears
|
|
440
|
+
// every move of the left/right menu, and the first label is Yes.
|
|
441
|
+
//
|
|
442
|
+
// A raw key stream is used rather than readline: a readline stream leaves tty
|
|
443
|
+
// handles open, and those keep the installer's process alive after the work
|
|
444
|
+
// is done, so npm waits on a postinstall that has already finished. select()
|
|
445
|
+
// tears its stream down instead. Ctrl-C means "not now", never a failed
|
|
446
|
+
// install: the binary is already in place.
|
|
447
|
+
export async function askStart({ labels = ['YES', 'NO'], onQuestion, onAnswer } = {}) {
|
|
448
|
+
if (process.platform === 'win32' && !onQuestion) {
|
|
449
|
+
if (!process.stdin.isTTY) return 'unattended';
|
|
450
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
451
|
+
try {
|
|
452
|
+
const answer = await terminal.question('START RELO NOW? [Y/n] ');
|
|
453
|
+
return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
|
|
454
|
+
} finally {
|
|
455
|
+
terminal.close();
|
|
456
|
+
}
|
|
278
457
|
}
|
|
458
|
+
const at = await select(labels, { onDraw: onQuestion });
|
|
459
|
+
if (at === null) return 'unattended';
|
|
460
|
+
if (at < 0) return 'no';
|
|
461
|
+
onAnswer?.(labels[at]);
|
|
462
|
+
return at === 0 ? 'yes' : 'no';
|
|
279
463
|
}
|
package/lib/download.mjs
CHANGED
|
@@ -16,11 +16,20 @@ function failFast(status) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// fetchBytes returns the body of a URL. fetchImpl and sleep take arguments
|
|
19
|
-
// so tests can fake the network and skip the waits.
|
|
19
|
+
// so tests can fake the network and skip the waits. onProgress is called with
|
|
20
|
+
// the bytes received so far, the total when the server sends a length, and
|
|
21
|
+
// the milliseconds since the body started arriving. onRetry is told about
|
|
22
|
+
// each attempt that will be retried, so the caller can show it where the user
|
|
23
|
+
// is watching.
|
|
20
24
|
export async function fetchBytes(
|
|
21
25
|
target,
|
|
22
26
|
what,
|
|
23
|
-
{
|
|
27
|
+
{
|
|
28
|
+
fetchImpl = fetch,
|
|
29
|
+
sleep = (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
30
|
+
onProgress,
|
|
31
|
+
onRetry = (message) => console.warn(`relo: ${message}`)
|
|
32
|
+
} = {}
|
|
24
33
|
) {
|
|
25
34
|
let last;
|
|
26
35
|
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
|
|
@@ -38,15 +47,37 @@ export async function fetchBytes(
|
|
|
38
47
|
}
|
|
39
48
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
40
49
|
}
|
|
41
|
-
return
|
|
50
|
+
return await readBody(response, onProgress);
|
|
42
51
|
} catch (error) {
|
|
43
52
|
if (error?.permanent) throw error;
|
|
44
53
|
last = error;
|
|
45
54
|
if (attempt < ATTEMPTS) {
|
|
46
|
-
|
|
55
|
+
onRetry(`${what} failed (${error.message}), retrying`);
|
|
47
56
|
await sleep(attempt * 1000);
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
throw new Error(`could not download ${what} from ${target}: ${last.message}`);
|
|
52
61
|
}
|
|
62
|
+
|
|
63
|
+
// readBody reads the response body, reporting the bytes as they arrive when
|
|
64
|
+
// the response is a stream. A response without a readable stream -- a test
|
|
65
|
+
// double, or an older runtime -- is read whole.
|
|
66
|
+
async function readBody(response, onProgress) {
|
|
67
|
+
if (!onProgress || !response.body?.getReader) {
|
|
68
|
+
return Buffer.from(await response.arrayBuffer());
|
|
69
|
+
}
|
|
70
|
+
const total = Number(response.headers?.get?.('content-length')) || 0;
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const reader = response.body.getReader();
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let received = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
chunks.push(Buffer.from(value));
|
|
79
|
+
received += value.length;
|
|
80
|
+
onProgress(received, total, Date.now() - started);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks);
|
|
83
|
+
}
|
package/lib/install.mjs
CHANGED
|
@@ -21,6 +21,24 @@ import { expect, parse } from './checksums.mjs';
|
|
|
21
21
|
import { askStart, quiesce, reloHome } from './daemon.mjs';
|
|
22
22
|
import { findConflictingRelo, findReloApp, findSetupInstall } from './conflict.mjs';
|
|
23
23
|
import { fetchBytes } from './download.mjs';
|
|
24
|
+
import {
|
|
25
|
+
bar,
|
|
26
|
+
bytes,
|
|
27
|
+
choices,
|
|
28
|
+
chosen,
|
|
29
|
+
clear,
|
|
30
|
+
closeScreen,
|
|
31
|
+
done,
|
|
32
|
+
duration,
|
|
33
|
+
failed,
|
|
34
|
+
interactive,
|
|
35
|
+
note,
|
|
36
|
+
openScreen,
|
|
37
|
+
progress,
|
|
38
|
+
separator,
|
|
39
|
+
step,
|
|
40
|
+
warn
|
|
41
|
+
} from './progress.mjs';
|
|
24
42
|
import { target } from './platform.mjs';
|
|
25
43
|
import { assets, checksumsUrl, prereleaseTag, selectBundle, tagFromArgs, url } from './release.mjs';
|
|
26
44
|
import { extractBinary } from './archive.mjs';
|
|
@@ -38,33 +56,102 @@ async function writeAtomic(destination, body) {
|
|
|
38
56
|
await rename(staging, destination);
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
// LOGO is the installer's block-letter mark. Six rows of shadow type give
|
|
60
|
+
// the title card its arcade weight.
|
|
61
|
+
const LOGO = [
|
|
62
|
+
'██████╗ ███████╗██╗ ██████╗',
|
|
63
|
+
'██╔══██╗██╔════╝██║ ██╔═══██╗',
|
|
64
|
+
'██████╔╝█████╗ ██║ ██║ ██║',
|
|
65
|
+
'██╔══██╗██╔══╝ ██║ ██║ ██║',
|
|
66
|
+
'██║ ██║███████╗███████╗╚██████╔╝',
|
|
67
|
+
'╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝'
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// label pads a step name to one column, so the values line up under it.
|
|
71
|
+
const label = (name) => name.padEnd(8);
|
|
72
|
+
|
|
73
|
+
// downloadStats renders the running download: the shimmering bar, the
|
|
74
|
+
// percentage, the bytes, the rate, and the time left.
|
|
75
|
+
function downloadStats(received, total, elapsed, tick) {
|
|
76
|
+
const seconds = Math.max(elapsed, 1) / 1000;
|
|
77
|
+
const rate = received / seconds;
|
|
78
|
+
const parts = [];
|
|
79
|
+
if (total > 0) parts.push(`${Math.floor((received / total) * 100)}%`);
|
|
80
|
+
parts.push(total > 0 ? `${bytes(received)}/${bytes(total)}` : bytes(received));
|
|
81
|
+
// A rate needs a moment to mean anything: the first chunk lands before
|
|
82
|
+
// any time has passed, which reads as an absurd speed and a wrong ETA.
|
|
83
|
+
if (elapsed >= 500) {
|
|
84
|
+
parts.push(`${bytes(rate)}/s`);
|
|
85
|
+
if (total > received && rate > 0) {
|
|
86
|
+
parts.push(`ETA ${Math.max(1, Math.ceil((total - received) / rate))}s`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const barText = total > 0 ? `[${bar(received / total, { width: 12, color: true, frame: tick })}] ` : '';
|
|
90
|
+
return `${barText}${parts.join(' ')}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fromRelease(tag, asset, startedAt) {
|
|
94
|
+
step(label('MANIFEST'));
|
|
95
|
+
const checksums = await fetchBytes(checksumsUrl(tag), 'checksums.txt', { onRetry: warn });
|
|
96
|
+
done(`${label('MANIFEST')} ${bytes(checksums.length)}`);
|
|
44
97
|
|
|
45
98
|
// One manifest lists every asset in the release, so a single fetch
|
|
46
99
|
// verifies the binary before anything executable is written. A release
|
|
47
100
|
// carrying the raw binary uses it; one carrying only the versioned
|
|
48
101
|
// tarballs falls back to this host's archive, and the binary inside is
|
|
49
102
|
// what the launcher beside it already expects.
|
|
50
|
-
const manifest = parse(
|
|
103
|
+
const manifest = parse(checksums);
|
|
51
104
|
const pick = selectBundle(manifest, asset);
|
|
52
|
-
|
|
105
|
+
step(label('DOWNLOAD'));
|
|
106
|
+
const downloadStart = Date.now();
|
|
107
|
+
let latest = { received: 0, total: 0 };
|
|
108
|
+
const downloaded = await fetchBytes(url(pick.name, tag), pick.name, {
|
|
109
|
+
onProgress: (received, total) => {
|
|
110
|
+
latest = { received, total };
|
|
111
|
+
progress(
|
|
112
|
+
(tick) => downloadStats(latest.received, latest.total, Date.now() - downloadStart, tick),
|
|
113
|
+
{ force: total > 0 && received === total }
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
onRetry: warn
|
|
117
|
+
});
|
|
118
|
+
done(`${label('DOWNLOAD')} ${bytes(downloaded.length)} in ${duration(Date.now() - downloadStart)}`);
|
|
119
|
+
|
|
53
120
|
expect(manifest, pick.name, downloaded);
|
|
121
|
+
done(`${label('CHECKSUM')} sha256 verified`);
|
|
122
|
+
|
|
54
123
|
const inner = asset.os === 'windows' ? 'relo.exe' : 'relo';
|
|
55
|
-
|
|
124
|
+
let body = downloaded;
|
|
125
|
+
if (pick.kind !== 'raw') {
|
|
126
|
+
step(label('EXTRACT'));
|
|
127
|
+
body = extractBinary(downloaded, { names: [inner] });
|
|
128
|
+
done(`${label('EXTRACT')} ${inner}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
56
131
|
const destination = resolve(binaryDir, asset.name);
|
|
57
132
|
// Quiesced first: on windows the rename below fails over a running exe, and
|
|
58
133
|
// the new daemon cannot bind a port the old one still serves.
|
|
134
|
+
step(label('DAEMON'));
|
|
59
135
|
const { stopped } = quiesce(home, { binary: existsSync(destination) ? destination : undefined });
|
|
136
|
+
done(`${label('DAEMON')} ${stopped ? 'stopped the running daemon' : 'not running'}`);
|
|
137
|
+
|
|
138
|
+
step(label('INSTALL'));
|
|
60
139
|
await mkdir(binaryDir, { recursive: true });
|
|
61
140
|
await writeAtomic(destination, body);
|
|
62
|
-
|
|
63
|
-
console.log(stopped ? 'relo: stopped the running daemon' : 'relo: not running');
|
|
141
|
+
done(`${label('INSTALL')} ${bytes(body.length)} · ${asset.os}-${asset.arch} · ${duration(Date.now() - startedAt)}`);
|
|
64
142
|
return destination;
|
|
65
143
|
}
|
|
66
144
|
|
|
67
145
|
try {
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
const tag = tagFromArgs(process.argv.slice(2)) ?? prereleaseTag(publishedVersion);
|
|
148
|
+
const asset = target();
|
|
149
|
+
const { version } = assets(tag);
|
|
150
|
+
await openScreen(LOGO, 'I N S T A L L E R', [
|
|
151
|
+
version ? `v${version}` : 'latest release',
|
|
152
|
+
`${asset.os} · ${asset.arch}`.toUpperCase()
|
|
153
|
+
]);
|
|
154
|
+
step(label('CONFLICT'));
|
|
68
155
|
// One binary answers the relo command now, whether it came from this
|
|
69
156
|
// package or an installer, so a command this package did not install
|
|
70
157
|
// stops this one: it already provides the name, and a second Relo would
|
|
@@ -92,21 +179,48 @@ try {
|
|
|
92
179
|
' remove that install first to install this package, or keep using that install instead'
|
|
93
180
|
);
|
|
94
181
|
}
|
|
95
|
-
|
|
96
|
-
|
|
182
|
+
done(`${label('CONFLICT')} no other relo install`);
|
|
183
|
+
|
|
184
|
+
const destination = await fromRelease(tag, asset, startedAt);
|
|
185
|
+
separator();
|
|
186
|
+
const question = 'START RELO NOW?';
|
|
187
|
+
const labels = ['YES', 'NO'];
|
|
188
|
+
const answer = interactive()
|
|
189
|
+
? await askStart({
|
|
190
|
+
labels,
|
|
191
|
+
onQuestion: (at) => choices(question, labels, at),
|
|
192
|
+
onAnswer: (value) => chosen(question, value)
|
|
193
|
+
})
|
|
194
|
+
: await askStart();
|
|
97
195
|
if (answer === 'yes') {
|
|
98
|
-
|
|
196
|
+
closeScreen();
|
|
197
|
+
// The daemon's error goes into the result block rather than through
|
|
198
|
+
// the screen: a start refused before it spawned anything names the
|
|
199
|
+
// cause on stderr, and that is the whole answer.
|
|
200
|
+
const started = spawnSync(destination, ['daemon', 'start'], {
|
|
201
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
202
|
+
encoding: 'utf8'
|
|
203
|
+
});
|
|
99
204
|
if (started.status !== 0) {
|
|
100
|
-
|
|
205
|
+
const detail = (started.stderr ?? '').trim() || started.error?.message || '';
|
|
206
|
+
failed(
|
|
207
|
+
'DAEMON DID NOT START',
|
|
208
|
+
detail
|
|
209
|
+
? `${detail}\n start it later: relo daemon start`
|
|
210
|
+
: 'start it later: relo daemon start'
|
|
211
|
+
);
|
|
101
212
|
}
|
|
102
213
|
} else {
|
|
103
|
-
|
|
214
|
+
note('start it later: relo daemon start');
|
|
215
|
+
closeScreen();
|
|
104
216
|
}
|
|
105
217
|
} catch (error) {
|
|
106
218
|
// Remove only the staging file this install wrote. Removing the directory
|
|
107
219
|
// would take bin/relo.mjs with it, leaving a package whose `relo` command
|
|
108
220
|
// no longer exists.
|
|
109
221
|
await rm(resolve(binaryDir, `${target().name}.download`), { force: true }).catch(() => {});
|
|
110
|
-
|
|
222
|
+
clear();
|
|
223
|
+
closeScreen();
|
|
224
|
+
failed('INSTALL FAILED', error.message);
|
|
111
225
|
process.exitCode = 1;
|
|
112
226
|
}
|
package/lib/progress.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Writes the install's progress where the user can watch it, styled as a
|
|
2
|
+
// Game Boy screen: the DMG palette, a title screen that reveals itself, and
|
|
3
|
+
// a menu of steps inside the same frame.
|
|
4
|
+
//
|
|
5
|
+
// npm runs a postinstall in the background by default, so everything the
|
|
6
|
+
// installer prints on stdout is captured and shown only when the script
|
|
7
|
+
// fails. The user's terminal is still there, so the progress goes straight
|
|
8
|
+
// to /dev/tty and stays visible while the daemon binary downloads. Without a
|
|
9
|
+
// terminal to write to -- windows, or a piped install -- the same lines go
|
|
10
|
+
// to the console, where npm shows them under --foreground-scripts.
|
|
11
|
+
import { openSync, writeSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
// The four shades of a Game Boy screen.
|
|
14
|
+
const DARK = [15, 56, 15];
|
|
15
|
+
const MID = [48, 98, 48];
|
|
16
|
+
const LIGHT = [139, 172, 15];
|
|
17
|
+
const BRIGHT = [155, 188, 15];
|
|
18
|
+
|
|
19
|
+
// The screen is one rectangle: the card, the steps, and the closing border
|
|
20
|
+
// all share this width, so every row ends on the same column.
|
|
21
|
+
const INNER = 76;
|
|
22
|
+
|
|
23
|
+
let handle;
|
|
24
|
+
let tried = false;
|
|
25
|
+
let active = null;
|
|
26
|
+
let frame = 0;
|
|
27
|
+
let ticker = null;
|
|
28
|
+
let renderer = null;
|
|
29
|
+
let framed = false;
|
|
30
|
+
let lastAt = 0;
|
|
31
|
+
let lastText = null;
|
|
32
|
+
|
|
33
|
+
// terminal is the file descriptor of the user's terminal, or null when there
|
|
34
|
+
// is none to write to.
|
|
35
|
+
function terminal() {
|
|
36
|
+
if (tried) return handle;
|
|
37
|
+
tried = true;
|
|
38
|
+
if (process.platform === 'win32') return null;
|
|
39
|
+
try {
|
|
40
|
+
handle = openSync('/dev/tty', 'a');
|
|
41
|
+
} catch {
|
|
42
|
+
// No controlling terminal: the console is the only place left.
|
|
43
|
+
handle = null;
|
|
44
|
+
}
|
|
45
|
+
return handle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// emit writes to the terminal and reports whether it landed there.
|
|
49
|
+
function emit(text) {
|
|
50
|
+
const fd = terminal();
|
|
51
|
+
if (fd === null) return false;
|
|
52
|
+
try {
|
|
53
|
+
writeSync(fd, text);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
// The terminal went away mid-install; the console still works.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// paint writes a line that will be rewritten in place. Windows has no
|
|
62
|
+
// /dev/tty here, so its console gets the line instead when it is a terminal.
|
|
63
|
+
function paint(text) {
|
|
64
|
+
if (emit(text)) return;
|
|
65
|
+
if (process.stdout?.isTTY) process.stdout.write(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// colorEnabled reports whether the output can carry color. NO_COLOR and a
|
|
69
|
+
// dumb terminal turn it off; FORCE_COLOR turns it on.
|
|
70
|
+
function colorEnabled() {
|
|
71
|
+
if (process.env.NO_COLOR || process.env.FORCE_COLOR === '0') return false;
|
|
72
|
+
if (process.env.FORCE_COLOR) return true;
|
|
73
|
+
if (process.env.TERM === 'dumb') return false;
|
|
74
|
+
return terminal() !== null || Boolean(process.stdout?.isTTY);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function truecolor() {
|
|
78
|
+
return /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// shade paints text in one of the screen's greens. A terminal that speaks
|
|
82
|
+
// 24-bit color gets the exact DMG shade; anything else gets the nearest
|
|
83
|
+
// basic green, and no color at all stays plain.
|
|
84
|
+
function shade(color, text) {
|
|
85
|
+
if (!colorEnabled()) return text;
|
|
86
|
+
if (truecolor()) {
|
|
87
|
+
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[0m`;
|
|
88
|
+
}
|
|
89
|
+
const code = color === BRIGHT ? '92' : color === LIGHT ? '32' : '2';
|
|
90
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const bright = (text) => shade(BRIGHT, text);
|
|
94
|
+
const light = (text) => shade(LIGHT, text);
|
|
95
|
+
const mid = (text) => shade(MID, text);
|
|
96
|
+
|
|
97
|
+
// width counts the columns a painted line occupies, ignoring its escapes.
|
|
98
|
+
function width(text) {
|
|
99
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// row wraps a line in the screen's walls and pads it to the full width, so
|
|
103
|
+
// every line of the screen is a rectangle of the same size.
|
|
104
|
+
function row(text) {
|
|
105
|
+
if (!framed) return text;
|
|
106
|
+
const pad = Math.max(0, INNER - width(text) - 2);
|
|
107
|
+
return `${bright('│')} ${text}${' '.repeat(pad)} ${bright('│')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// stopActivity ends the redraw timer, so a finished (or failed) download
|
|
111
|
+
// never leaves one behind.
|
|
112
|
+
function stopActivity() {
|
|
113
|
+
if (ticker !== null) {
|
|
114
|
+
clearInterval(ticker);
|
|
115
|
+
ticker = null;
|
|
116
|
+
}
|
|
117
|
+
renderer = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// clear wipes the line the progress bar drew on, so the next write -- a
|
|
121
|
+
// step, or an error -- starts on a clean line.
|
|
122
|
+
export function clear() {
|
|
123
|
+
stopActivity();
|
|
124
|
+
paint('\r\x1b[2K');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// rawLine clears the running line and prints one line exactly as given.
|
|
128
|
+
function rawLine(text) {
|
|
129
|
+
clear();
|
|
130
|
+
active = null;
|
|
131
|
+
lastText = null;
|
|
132
|
+
if (emit(`${text}\n`)) return;
|
|
133
|
+
console.log(text);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// line clears the running line and prints one line, framed when the screen
|
|
137
|
+
// is open.
|
|
138
|
+
function line(text) {
|
|
139
|
+
rawLine(row(text));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// pause breathes between the lines of the screen, so it draws itself the way
|
|
143
|
+
// a game's title screen comes up. The console gets no delay.
|
|
144
|
+
function pause(ms = 28) {
|
|
145
|
+
return terminal() === null ? Promise.resolve() : new Promise((done) => setTimeout(done, ms));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// openScreen draws the top of the screen: the logo centered, the label, the
|
|
149
|
+
// release details, and the rule the menu sits under. closeScreen draws the
|
|
150
|
+
// bottom when the install is over.
|
|
151
|
+
export async function openScreen(logo, label, details) {
|
|
152
|
+
const logoWidth = Math.max(...logo.map((text) => text.length));
|
|
153
|
+
const center = (text, paint) => {
|
|
154
|
+
const pad = Math.floor((INNER - text.length) / 2);
|
|
155
|
+
return paint(' '.repeat(pad) + text + ' '.repeat(INNER - text.length - pad));
|
|
156
|
+
};
|
|
157
|
+
const blank = () => `${bright('│')}${' '.repeat(INNER)}${bright('│')}`;
|
|
158
|
+
const card = (text, paint) => `${bright('│')}${center(text, paint)}${bright('│')}`;
|
|
159
|
+
|
|
160
|
+
framed = true;
|
|
161
|
+
rawLine(bright(`┌${'─'.repeat(INNER)}┐`));
|
|
162
|
+
await pause();
|
|
163
|
+
rawLine(blank());
|
|
164
|
+
await pause();
|
|
165
|
+
for (const text of logo) {
|
|
166
|
+
rawLine(card(text.padEnd(logoWidth), bright));
|
|
167
|
+
await pause();
|
|
168
|
+
}
|
|
169
|
+
rawLine(blank());
|
|
170
|
+
await pause();
|
|
171
|
+
rawLine(card(label, bright));
|
|
172
|
+
await pause();
|
|
173
|
+
rawLine(blank());
|
|
174
|
+
await pause();
|
|
175
|
+
for (const text of details) {
|
|
176
|
+
rawLine(card(text, light));
|
|
177
|
+
await pause();
|
|
178
|
+
}
|
|
179
|
+
rawLine(blank());
|
|
180
|
+
await pause();
|
|
181
|
+
rawLine(bright(`├${'─'.repeat(INNER)}┤`));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// separator draws the rule between two blocks of the screen.
|
|
185
|
+
export function separator() {
|
|
186
|
+
if (!framed) return;
|
|
187
|
+
rawLine(bright(`├${'─'.repeat(INNER)}┤`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// closeScreen draws the bottom border. It is safe to call when no screen is
|
|
191
|
+
// open, so the error path can close whatever was drawn.
|
|
192
|
+
export function closeScreen() {
|
|
193
|
+
if (!framed) return;
|
|
194
|
+
framed = false;
|
|
195
|
+
rawLine(bright(`└${'─'.repeat(INNER)}┘`));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// interactive reports whether there is a screen to draw and keys to read.
|
|
199
|
+
export function interactive() {
|
|
200
|
+
return terminal() !== null || Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// choices draws a question and its option buttons on the running line. The
|
|
204
|
+
// cursor stays there, so the next key redraws the same row.
|
|
205
|
+
export function choices(question, labels, at) {
|
|
206
|
+
active = null;
|
|
207
|
+
lastText = null;
|
|
208
|
+
clear();
|
|
209
|
+
paint(row(`${light(question)} ${buttons(labels, at)}`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// chosen replaces the question row with the option that was picked.
|
|
213
|
+
export function chosen(question, value) {
|
|
214
|
+
line(`${light('▶')} ${question} ${value}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// buttons renders the options as one row of keycaps. Every form of a label is
|
|
218
|
+
// the same width, so moving the selection never shifts the row.
|
|
219
|
+
export function buttons(labels, at) {
|
|
220
|
+
return labels
|
|
221
|
+
.map((label, index) => (index === at ? highlight(`▶ ${label} ◀`) : mid(`[ ${label} ]`)))
|
|
222
|
+
.join(' ');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// highlight paints text as the chosen menu item: bright background, dark text.
|
|
226
|
+
// A terminal without 24-bit color falls back to inverse video.
|
|
227
|
+
function highlight(text) {
|
|
228
|
+
if (!colorEnabled()) return text;
|
|
229
|
+
if (truecolor()) {
|
|
230
|
+
return `\x1b[48;2;${BRIGHT[0]};${BRIGHT[1]};${BRIGHT[2]}m\x1b[38;2;${DARK[0]};${DARK[1]};${DARK[2]}m${text}\x1b[0m`;
|
|
231
|
+
}
|
|
232
|
+
return `\x1b[7m${text}\x1b[0m`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// selected paints the running step the way a Game Boy highlights the chosen
|
|
236
|
+
// menu item: bright background, dark text. The cursor blinks and the dots
|
|
237
|
+
// cycle so the chip reads as busy.
|
|
238
|
+
function selected(label, tick = 0) {
|
|
239
|
+
const dots = '.'.repeat(tick % 4).padEnd(3);
|
|
240
|
+
const cursor = tick % 4 < 2 ? '▶' : '▷';
|
|
241
|
+
return highlight(` ${`${cursor} ${label}${dots}`} `);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// draw paints one running line, throttled so a fast download neither floods
|
|
245
|
+
// the terminal with frames nor skips a percentage.
|
|
246
|
+
function draw(text, force) {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const onTerminal = terminal() !== null;
|
|
249
|
+
if (!force) {
|
|
250
|
+
if (text === lastText) return;
|
|
251
|
+
if (onTerminal ? now - lastAt < 50 : now - lastAt < 1000) return;
|
|
252
|
+
}
|
|
253
|
+
lastText = text;
|
|
254
|
+
lastAt = now;
|
|
255
|
+
const rendered = row(`${selected(active ?? '', frame)} ${text}`);
|
|
256
|
+
if (onTerminal) {
|
|
257
|
+
emit(`\r\x1b[2K${rendered}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
console.log(rendered);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// step starts a step and leaves the cursor on its line. On the console the
|
|
264
|
+
// running line is printed by progress() instead, so a step that never
|
|
265
|
+
// reports progress leaves only its finished line behind.
|
|
266
|
+
export function step(text) {
|
|
267
|
+
active = text;
|
|
268
|
+
frame = 0;
|
|
269
|
+
clear();
|
|
270
|
+
lastText = null;
|
|
271
|
+
emit(row(selected(text, 0)));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// done ends the running step.
|
|
275
|
+
export function done(text) {
|
|
276
|
+
line(`${light('✓')} ${text}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// warn prints a step that did not go to plan but does not stop the install.
|
|
280
|
+
export function warn(text) {
|
|
281
|
+
line(`${bright('!')} ${text}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// note prints something the installer did not do, such as a command to run
|
|
285
|
+
// later.
|
|
286
|
+
export function note(text) {
|
|
287
|
+
line(`${mid('·')} ${mid(text)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// failed prints a failed result as the fourth block, below the closed screen.
|
|
291
|
+
// It goes to stderr, where npm shows it even when a postinstall's stdout was
|
|
292
|
+
// captured.
|
|
293
|
+
export function failed(title, message) {
|
|
294
|
+
console.error(errorBox(title, message));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// errorBox renders a failure result: a red-walled block the same width as the
|
|
298
|
+
// screen, the headline, and the message wrapped to fit. Exported so its shape
|
|
299
|
+
// can be tested without a terminal.
|
|
300
|
+
export function errorBox(title, message) {
|
|
301
|
+
const wall = red('│');
|
|
302
|
+
const rows = [red(`┌${'─'.repeat(INNER)}┐`)];
|
|
303
|
+
const add = (text, paint) => {
|
|
304
|
+
const pad = Math.max(0, INNER - width(text) - 2);
|
|
305
|
+
rows.push(`${wall} ${paint(text)}${' '.repeat(pad)} ${wall}`);
|
|
306
|
+
};
|
|
307
|
+
add(`✗ ${title}`, redBright);
|
|
308
|
+
add('', red);
|
|
309
|
+
for (const line of wrapText(message, INNER - 2)) add(line, red);
|
|
310
|
+
rows.push(red(`└${'─'.repeat(INNER)}┘`));
|
|
311
|
+
return rows.join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// errorColor follows the same switches as the screen but looks at stderr,
|
|
315
|
+
// the stream the failure block is printed on, so a captured log stays clean
|
|
316
|
+
// when stderr is not a terminal.
|
|
317
|
+
function errorColor() {
|
|
318
|
+
if (process.env.NO_COLOR || process.env.FORCE_COLOR === '0') return false;
|
|
319
|
+
if (process.env.FORCE_COLOR) return true;
|
|
320
|
+
if (process.env.TERM === 'dumb') return false;
|
|
321
|
+
return Boolean(process.stderr?.isTTY);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const red = (text) => (errorColor() ? `\x1b[31m${text}\x1b[0m` : text);
|
|
325
|
+
const redBright = (text) => (errorColor() ? `\x1b[1;31m${text}\x1b[0m` : text);
|
|
326
|
+
|
|
327
|
+
// wrapText breaks a message into lines no wider than width, keeping the
|
|
328
|
+
// two-space indent a hint line starts with. A word longer than a line -- a
|
|
329
|
+
// path in a daemon error, say -- is split so the box walls stay aligned.
|
|
330
|
+
function wrapText(text, width) {
|
|
331
|
+
const lines = [];
|
|
332
|
+
for (const paragraph of text.split('\n')) {
|
|
333
|
+
const indent = paragraph.match(/^\s*/)[0];
|
|
334
|
+
const room = Math.max(1, width - indent.length);
|
|
335
|
+
const words = paragraph.trim().split(/\s+/).filter(Boolean);
|
|
336
|
+
if (words.length === 0) {
|
|
337
|
+
lines.push('');
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
let line = '';
|
|
341
|
+
for (const word of words) {
|
|
342
|
+
let rest = word;
|
|
343
|
+
while (rest.length > room) {
|
|
344
|
+
if (line !== '') {
|
|
345
|
+
lines.push(indent + line);
|
|
346
|
+
line = '';
|
|
347
|
+
}
|
|
348
|
+
lines.push(indent + rest.slice(0, room));
|
|
349
|
+
rest = rest.slice(room);
|
|
350
|
+
}
|
|
351
|
+
if (rest === '') continue;
|
|
352
|
+
if (line !== '' && line.length + 1 + rest.length > room) {
|
|
353
|
+
lines.push(indent + line);
|
|
354
|
+
line = rest;
|
|
355
|
+
} else line = line === '' ? rest : `${line} ${rest}`;
|
|
356
|
+
}
|
|
357
|
+
if (line !== '') lines.push(indent + line);
|
|
358
|
+
}
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// progress starts a running line. render is called with a frame number and
|
|
363
|
+
// returns the text, so the line can keep moving -- the bar's shade, the
|
|
364
|
+
// blinking cursor -- even while no bytes arrive. A terminal redraws it at
|
|
365
|
+
// most twenty times a second; the console prints it once a second instead,
|
|
366
|
+
// since there the output is a log rather than a redrawn line. force draws
|
|
367
|
+
// the frame that ends a download.
|
|
368
|
+
export function progress(render, { force = false } = {}) {
|
|
369
|
+
renderer = render;
|
|
370
|
+
draw(render(frame), force);
|
|
371
|
+
if (ticker !== null || terminal() === null) return;
|
|
372
|
+
// Unreferenced: an animation must never be the reason an install hangs.
|
|
373
|
+
ticker = setInterval(() => {
|
|
374
|
+
if (renderer === null) return;
|
|
375
|
+
frame += 1;
|
|
376
|
+
draw(renderer(frame), true);
|
|
377
|
+
}, 90);
|
|
378
|
+
ticker.unref();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// bar renders a fraction of a whole as a fixed width block bar. Every filled
|
|
382
|
+
// cell is the same full block, so the bar stays a clean rectangle; a frame
|
|
383
|
+
// only moves the shade across those blocks, which keeps it moving even on a
|
|
384
|
+
// stalled connection. color dims the track.
|
|
385
|
+
export function bar(fraction, { width: cells = 24, color = false, frame: tick } = {}) {
|
|
386
|
+
const filled = Math.max(0, Math.min(cells, Math.round(fraction * cells)));
|
|
387
|
+
const track = '░'.repeat(cells - filled);
|
|
388
|
+
if (!color || !colorEnabled()) return `${'█'.repeat(filled)}${track}`;
|
|
389
|
+
let paint = '';
|
|
390
|
+
for (let index = 0; index < filled; index += 1) {
|
|
391
|
+
paint += tick !== undefined && (index + tick) % 6 > 3 ? light('█') : bright('█');
|
|
392
|
+
}
|
|
393
|
+
return `${paint}${mid(track)}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// bytes renders a byte count the way a person reads it.
|
|
397
|
+
export function bytes(count) {
|
|
398
|
+
if (count < 1024) return `${count} B`;
|
|
399
|
+
if (count < 1024 * 1024) return `${(count / 1024).toFixed(1)} KB`;
|
|
400
|
+
return `${(count / (1024 * 1024)).toFixed(1)} MB`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// duration renders an elapsed time the way a person reads it.
|
|
404
|
+
export function duration(ms) {
|
|
405
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
406
|
+
const seconds = ms / 1000;
|
|
407
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
408
|
+
return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
409
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jonaskahn/relo",
|
|
3
|
-
"version": "0.1.4-beta.
|
|
3
|
+
"version": "0.1.4-beta.29.0927baae",
|
|
4
4
|
"description": "One local proxy that lets Codex, Claude Code, Cursor and Grok, etc. Talk to any model, any provider with no config changes",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"lib/download.mjs",
|
|
48
48
|
"lib/install.mjs",
|
|
49
49
|
"lib/platform.mjs",
|
|
50
|
+
"lib/progress.mjs",
|
|
50
51
|
"lib/release.mjs",
|
|
51
52
|
"lib/uninstall.mjs",
|
|
52
53
|
"README.md"
|