@jonaskahn/relo 0.1.4-beta.27.8d9c8af7 → 0.1.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/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
- ```console
20
- $ npm i -g @jonaskahn/relo # latest stable (Node 22+)
21
- $ npm i -g @jonaskahn/relo@beta # fresh from main
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. State
25
- lives in `~/.relo` (`$RELO_HOME`), shared with every other installer. Never
26
- installed twice: if another Relo is on your PATH, this one refuses and tells
27
- you where it found it.
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
- ```console
32
- $ relo daemon start # console at http://127.0.0.1:10101
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
- relo: install failed -- something else is listening on port 10101
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
- ```console
51
- $ npm uninstall -g @jonaskahn/relo # keeps your data
52
- $ relo uninstall --wipe # removes data too
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 { createReadStream, createWriteStream, existsSync, readFileSync, rmSync } from 'node:fs';
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 the relo binary, so a
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
- return base === 'relo' || base === 'relo.exe';
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 || !isReloImage(info.comm)) owner = 0;
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 && isReloImage(info.comm) && servesHome({ pid, owner, args: info.args, home, defaultHome });
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
- `relo: install failed -- something else is listening on port ${port}\n` +
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
- // askStart prompts on a terminal and reports 'yes', 'no', or 'unattended'
247
- // when no terminal can be asked on. The answer never comes from stdin: under
248
- // a piped install stdin is not the user.
249
- export async function askStart() {
250
- if (process.platform === 'win32') {
251
- if (!process.stdin.isTTY) return 'unattended';
252
- const terminal = createInterface({ input: process.stdin, output: process.stdout });
253
- try {
254
- const answer = await terminal.question('start relo now? [Y/n] ');
255
- return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
256
- } finally {
257
- terminal.close();
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
- let input;
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
- input = createReadStream('/dev/tty');
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 'unattended';
350
+ return null;
269
351
  }
270
- const terminal = createInterface({ input, output: createWriteStream('/dev/tty') });
352
+ let input;
271
353
  try {
272
- const answer = await terminal.question('start relo now? [Y/n] ');
273
- return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
354
+ input = new ReadStream(fd);
274
355
  } catch {
275
- return 'unattended';
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
- terminal.close();
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
- { fetchImpl = fetch, sleep = (ms) => new Promise((done) => setTimeout(done, ms)) } = {}
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 Buffer.from(await response.arrayBuffer());
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
- console.warn(`relo: ${what} failed (${error.message}), retrying`);
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
- async function fromRelease(tag) {
42
- const asset = target();
43
- const { version } = assets(tag);
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(await fetchBytes(checksumsUrl(tag), 'checksums.txt'));
103
+ const manifest = parse(checksums);
51
104
  const pick = selectBundle(manifest, asset);
52
- const downloaded = await fetchBytes(url(pick.name, tag), pick.name);
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
- const body = pick.kind === 'raw' ? downloaded : extractBinary(downloaded, { names: [inner] });
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
- console.log(`relo: installed the ${version ? `v${version} ` : ''}daemon binary for ${asset.os}-${asset.arch}`);
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
- const destination = await fromRelease(tagFromArgs(process.argv.slice(2)) ?? prereleaseTag(publishedVersion));
96
- const answer = await askStart();
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
- const started = spawnSync(destination, ['daemon', 'start'], { stdio: 'inherit' });
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
- console.warn('relo: could not start the daemon; run relo daemon start to try again');
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
- console.log('relo: start it later with: relo daemon start');
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
- console.error(`relo: install failed\n${error.message}`);
222
+ clear();
223
+ closeScreen();
224
+ failed('INSTALL FAILED', error.message);
111
225
  process.exitCode = 1;
112
226
  }
@@ -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.27.8d9c8af7",
3
+ "version": "0.1.4",
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"