@jonaskahn/relo 0.1.0-beta.17.d00e9a2a → 0.1.0-beta.20.d7e7f1ef

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
@@ -1,97 +1,59 @@
1
1
  <div align="center">
2
- <img src="assets/logo.png" alt="Relo" width="96">
2
+ <img src="https://raw.githubusercontent.com/jonaskahn/relo/main/assets/logo.svg" alt="Relo" width="96">
3
3
 
4
4
  <h2>Relo</h2>
5
5
 
6
6
  <code>▚▚▚▚▚▚▚ Light • Fast • Reliable ▚▚▚▚▚▚▚</code>
7
7
 
8
- </div>
9
-
10
- > One local proxy that lets Codex, Claude Code, Cursor and Grok, etc. Talk to any model, any provider with no config changes.
11
-
12
- ## Installation
13
-
14
- ```console
15
- $ npm install -g @jonaskahn/relo
16
- $ relo daemon start
17
- ```
18
-
19
- ## What you get
20
-
21
- This package installs the `relo` binary and puts a `relo` command on your
22
- PATH. It is the daemon with its tray icon, downloaded from the GitHub release
23
- and verified against the release checksums.
24
-
25
- `relo` runs headless where there is no desktop session — a container or a
26
- server — and shows its tray icon where there is one. The installers
27
- (`Relo-Setup-*.exe` on Windows, the `Relo-Setup-*.deb` / `Relo-Setup-*.rpm`
28
- packages on Linux, and `Relo.app` on macOS) install the same binary.
29
-
30
- Every install provides the `relo` command, so they are never installed
31
- together: if another relo is already on your PATH, this install refuses and
32
- tells you where it found it. Removing that install first lets this package
33
- take over.
34
-
35
- Supported hosts: macOS, Linux, and Windows on both Intel and Apple Silicon (or
36
- their ARM equivalents). State lives in `$RELO_HOME`, `~/.relo` by default, so
37
- an npm install and a `.deb` install share the same configuration.
8
+ <a href="https://www.npmjs.com/package/@jonaskahn/relo"><img src="https://img.shields.io/npm/v/@jonaskahn/relo?label=npm%40latest" alt="npm latest"></a>
9
+ <a href="https://www.npmjs.com/package/@jonaskahn/relo?activeTab=versions"><img src="https://img.shields.io/npm/v/@jonaskahn/relo/beta" alt="npm beta"></a>
38
10
 
39
- ## Updates
11
+ <p><i>Let your harness agents talk to any model, any provider with no config changes.</i></p>
40
12
 
41
- The postinstall stops a running relo before replacing the binary: it asks the
42
- old build to drain, ends the processes of this home that still hold a
43
- configured port, and checks the ports are free before it writes the new one. A
44
- port another program holds fails the install rather than killing that program:
45
-
46
- ```
47
- relo: install failed -- something else is listening on port 10101
48
- ```
13
+ </div>
49
14
 
50
- Free the port, or point `$RELO_HOME/config.toml` at another one, and install
51
- again. The data is never touched by an update.
15
+ ---
52
16
 
53
- ## Uninstalling
17
+ ## Install
54
18
 
55
19
  ```console
56
- $ npm uninstall -g @jonaskahn/relo
20
+ $ npm i -g @jonaskahn/relo # latest stable (Node 22+)
21
+ $ npm i -g @jonaskahn/relo@beta # fresh from main
57
22
  ```
58
23
 
59
- This keeps your data in `$RELO_HOME`: the preuninstall script stops the daemon,
60
- frees its ports, and turns off start-at-login. To remove the data as well, use
61
- the daemon itself, which also removes the program:
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.
28
+
29
+ ## Start
62
30
 
63
31
  ```console
64
- $ relo uninstall --wipe
32
+ $ relo daemon start # console at http://127.0.0.1:10101
65
33
  ```
66
34
 
67
- Without a flag, `relo uninstall` asks whether the data goes.
35
+ One port serves everything: the console, the management API, and the data
36
+ plane (`/inference` speaks OpenAI, `/inference-claude` speaks Anthropic).
68
37
 
69
- ## Install options
38
+ ## Update
70
39
 
71
- The installer downloads the daemon binary from a GitHub release: the latest
72
- one by default, or the tag `--relo-version` names when the installer is run
73
- by hand:
40
+ The installer stops the running daemon, swaps the binary, and checks the port
41
+ is free. Your data is never touched. If another program holds the port, the
42
+ install fails instead of killing it:
74
43
 
75
- ```console
76
- $ node "$(npm root -g)/relo/lib/install.mjs" --relo-version 0.2.0
44
+ ```
45
+ relo: install failed -- something else is listening on port 10101
77
46
  ```
78
47
 
79
- ## Troubleshooting
80
-
81
- An install that printed a platform or checksum error left nothing behind, so
82
- fix the cause and run the installer again:
48
+ ## Uninstall
83
49
 
84
50
  ```console
85
- $ node "$(npm root -g)/relo/lib/install.mjs"
51
+ $ npm uninstall -g @jonaskahn/relo # keeps your data
52
+ $ relo uninstall --wipe # removes data too
86
53
  ```
87
54
 
88
- Downloads are verified against `checksums.txt` from the release. A mismatch
89
- fails the install rather than writing an executable.
90
-
91
55
  ## Links
92
56
 
93
- - [Source](https://github.com/jonaskahn/relo)
94
- - [Releases](https://github.com/jonaskahn/relo/releases)
95
- - [Documentation](https://github.com/jonaskahn/relo#readme)
57
+ [Source](https://github.com/jonaskahn/relo) · [Releases](https://github.com/jonaskahn/relo/releases) · [Docs](https://github.com/jonaskahn/relo#readme)
96
58
 
97
59
  AGPL-3.0-only
@@ -0,0 +1,72 @@
1
+ // Reads the daemon binary out of a release tarball. Prereleases publish one
2
+ // versioned archive per platform instead of the raw binary the stable
3
+ // releases carry, so the installer falls back to this when the manifest
4
+ // lists no raw binary.
5
+ //
6
+ // Pure node:zlib plus a minimal tar walk, no dependency and no system tar,
7
+ // so the postinstall behaves the same on every host the package supports.
8
+ import { gunzipSync } from 'node:zlib';
9
+
10
+ function text(bytes) {
11
+ return Buffer.from(bytes).toString('utf8').replace(/\0.*$/s, '').trim();
12
+ }
13
+
14
+ function entryName(header) {
15
+ const name = text(header.subarray(0, 100));
16
+ const prefix = text(header.subarray(345, 500));
17
+ const joined = prefix ? `${prefix}/${name}` : name;
18
+ return joined.replace(/^\.\//, '');
19
+ }
20
+
21
+ function entrySize(header) {
22
+ const size = Number.parseInt(text(header.subarray(124, 136)), 8);
23
+ if (!Number.isSafeInteger(size) || size < 0) {
24
+ throw new Error('relo: the release archive has a corrupt entry size');
25
+ }
26
+ return size;
27
+ }
28
+
29
+ function safeName(name) {
30
+ if (!name || name.startsWith('/') || name.startsWith('\\')) return false;
31
+ return !name.split(/[\\/]/).includes('..');
32
+ }
33
+
34
+ // extractBinary returns the bytes of the single archive member whose file
35
+ // name is in names. Pax headers and directories are skipped; anything else
36
+ // that is not that one regular file fails the install instead of writing a
37
+ // wrong executable into the package.
38
+ export function extractBinary(tarball, { names }) {
39
+ let tar;
40
+ try {
41
+ tar = gunzipSync(tarball);
42
+ } catch {
43
+ throw new Error('relo: the release archive is not valid gzip');
44
+ }
45
+ const found = [];
46
+ let offset = 0;
47
+ while (offset + 512 <= tar.length) {
48
+ const header = tar.subarray(offset, offset + 512);
49
+ if (header.every((byte) => byte === 0)) break;
50
+ const name = entryName(header);
51
+ const size = entrySize(header);
52
+ const end = offset + 512 + size;
53
+ if (end > tar.length) {
54
+ throw new Error(`relo: the release archive truncates ${name || 'an entry'}`);
55
+ }
56
+ const type = String.fromCharCode(header[156]);
57
+ if ((type === '0' || type === '\0') && names.includes(name.split('/').pop())) {
58
+ if (!safeName(name)) {
59
+ throw new Error(`relo: the release archive hides an unsafe path (${name})`);
60
+ }
61
+ found.push(Buffer.from(tar.subarray(offset + 512, end)));
62
+ }
63
+ offset += 512 + Math.ceil(size / 512) * 512;
64
+ }
65
+ if (found.length === 0) {
66
+ throw new Error(`relo: the release archive holds no ${names.join(' or ')}`);
67
+ }
68
+ if (found.length > 1) {
69
+ throw new Error('relo: the release archive holds more than one binary');
70
+ }
71
+ return found[0];
72
+ }
package/lib/daemon.mjs CHANGED
@@ -16,19 +16,18 @@ export function reloHome(env = process.env) {
16
16
  return env.RELO_HOME?.trim() || join(homedir(), '.relo');
17
17
  }
18
18
 
19
- const DEFAULTS = { port: 10101, openai: 10201, anthropic: 10202, gemini: 10203 };
19
+ const DEFAULTS = { port: 10101 };
20
20
 
21
- // servicePorts lists the ports a home opens: the management port plus one per
22
- // client protocol. A protocol whose port is 0 is not opened.
21
+ // servicePorts lists the port a home opens: the single listener's port. A
22
+ // zero port asks for an ephemeral one, which no scan can name.
23
23
  export function servicePorts(configText = '') {
24
- const found = {};
24
+ let found;
25
25
  for (const line of configText.split('\n')) {
26
- const match = line.match(/^\s*(port|openai|anthropic|gemini)\s*=\s*(\d+)/);
27
- if (match) found[match[1]] ??= Number(match[2]);
26
+ const match = line.match(/^\s*port\s*=\s*(\d+)/);
27
+ if (match) found ??= Number(match[1]);
28
28
  }
29
- return ['port', 'openai', 'anthropic', 'gemini']
30
- .map((key) => found[key] ?? DEFAULTS[key])
31
- .filter((port) => port !== 0);
29
+ const port = found ?? DEFAULTS.port;
30
+ return port === 0 ? [] : [port];
32
31
  }
33
32
 
34
33
  // runtimePid reads the pid a daemon published, or null when no usable file
package/lib/install.mjs CHANGED
@@ -22,7 +22,8 @@ import { askStart, quiesce, reloHome } from './daemon.mjs';
22
22
  import { findConflictingRelo } from './conflict.mjs';
23
23
  import { fetchBytes } from './download.mjs';
24
24
  import { target } from './platform.mjs';
25
- import { assets, checksumsUrl, prereleaseTag, tagFromArgs, url } from './release.mjs';
25
+ import { assets, checksumsUrl, prereleaseTag, selectBundle, tagFromArgs, url } from './release.mjs';
26
+ import { extractBinary } from './archive.mjs';
26
27
 
27
28
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
29
  const binaryDir = resolve(packageRoot, 'bin');
@@ -42,10 +43,16 @@ async function fromRelease(tag) {
42
43
  const { version } = assets(tag);
43
44
 
44
45
  // One manifest lists every asset in the release, so a single fetch
45
- // verifies the binary before anything executable is written.
46
- const manifest = await fetchBytes(checksumsUrl(tag), 'checksums.txt');
47
- const body = await fetchBytes(url(asset.name, tag), asset.name);
48
- expect(parse(manifest), asset.name, body);
46
+ // verifies the binary before anything executable is written. A release
47
+ // carrying the raw binary uses it; one carrying only the versioned
48
+ // tarballs falls back to this host's archive, and the binary inside is
49
+ // what the launcher beside it already expects.
50
+ const manifest = parse(await fetchBytes(checksumsUrl(tag), 'checksums.txt'));
51
+ const pick = selectBundle(manifest, asset);
52
+ const downloaded = await fetchBytes(url(pick.name, tag), pick.name);
53
+ expect(manifest, pick.name, downloaded);
54
+ const inner = asset.os === 'windows' ? 'relo.exe' : 'relo';
55
+ const body = pick.kind === 'raw' ? downloaded : extractBinary(downloaded, { names: [inner] });
49
56
  const destination = resolve(binaryDir, asset.name);
50
57
  // Quiesced first: on windows the rename below fails over a running exe, and
51
58
  // the new daemon cannot bind a port the old one still serves.
package/lib/release.mjs CHANGED
@@ -53,3 +53,30 @@ export function checksumsUrl(tag = null) {
53
53
  export function url(name, tag = null) {
54
54
  return `${assets(tag).base}/${name}`;
55
55
  }
56
+
57
+ // selectBundle names the asset one host downloads from a release manifest:
58
+ // the raw binary when the release carries it, else this host's versioned
59
+ // tarball. It throws naming both when neither is listed, so a failed
60
+ // install says what is missing instead of what a 404 means.
61
+ export function selectBundle(entries, { os, arch, name }) {
62
+ if (entries.has(name)) return { kind: 'raw', name };
63
+ const bundle = tarballFor(entries, { os, arch });
64
+ if (bundle) return { kind: 'tarball', name: bundle };
65
+ throw new Error(
66
+ `no ${name} or versioned tarball in the release. ` +
67
+ 'No release may be published yet; rerun the installer with --relo-version <tag> for a published tag, or without it for the latest release.'
68
+ );
69
+ }
70
+
71
+ // tarballFor finds the versioned archive a release publishes for one host
72
+ // inside its own checksums manifest. Releases ship one tarball per
73
+ // platform, and the version is read out of the manifest entry itself, so
74
+ // this resolves both a pinned tag and the unpinned latest release without
75
+ // a second fetch.
76
+ export function tarballFor(entries, { os, arch }) {
77
+ const match = [...entries.keys()].find((name) => {
78
+ const parts = new RegExp(`^relo-(.+)-${os}-${arch}\\.tar\\.gz$`).exec(name);
79
+ return parts && version(parts[1]);
80
+ });
81
+ return match ?? null;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jonaskahn/relo",
3
- "version": "0.1.0-beta.17.d00e9a2a",
3
+ "version": "0.1.0-beta.20.d7e7f1ef",
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",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "files": [
42
42
  "bin/relo.mjs",
43
+ "lib/archive.mjs",
43
44
  "lib/checksums.mjs",
44
45
  "lib/conflict.mjs",
45
46
  "lib/daemon.mjs",