@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 +28 -66
- package/lib/archive.mjs +72 -0
- package/lib/daemon.mjs +8 -9
- package/lib/install.mjs +12 -5
- package/lib/release.mjs +27 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,97 +1,59 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="assets/logo.
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
<p><i>Let your harness agents talk to any model, any provider with no config changes.</i></p>
|
|
40
12
|
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
again. The data is never touched by an update.
|
|
15
|
+
---
|
|
52
16
|
|
|
53
|
-
##
|
|
17
|
+
## Install
|
|
54
18
|
|
|
55
19
|
```console
|
|
56
|
-
$ npm
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
32
|
+
$ relo daemon start # console at http://127.0.0.1:10101
|
|
65
33
|
```
|
|
66
34
|
|
|
67
|
-
|
|
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
|
-
##
|
|
38
|
+
## Update
|
|
70
39
|
|
|
71
|
-
The installer
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
```
|
|
76
|
-
|
|
44
|
+
```
|
|
45
|
+
relo: install failed -- something else is listening on port 10101
|
|
77
46
|
```
|
|
78
47
|
|
|
79
|
-
##
|
|
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
|
-
$
|
|
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
|
-
|
|
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
|
package/lib/archive.mjs
ADDED
|
@@ -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
|
|
19
|
+
const DEFAULTS = { port: 10101 };
|
|
20
20
|
|
|
21
|
-
// servicePorts lists the
|
|
22
|
-
//
|
|
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
|
-
|
|
24
|
+
let found;
|
|
25
25
|
for (const line of configText.split('\n')) {
|
|
26
|
-
const match = line.match(/^\s*
|
|
27
|
-
if (match) found
|
|
26
|
+
const match = line.match(/^\s*port\s*=\s*(\d+)/);
|
|
27
|
+
if (match) found ??= Number(match[1]);
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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",
|