@jonaskahn/relo 0.0.0-stage → 0.1.0-beta.13.91cd0d14

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,3 +1,97 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <img src="assets/logo.png" alt="Relo" width="96">
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <h2>Relo</h2>
5
+
6
+ <code>▚▚▚▚▚▚▚ Light • Fast • Reliable ▚▚▚▚▚▚▚</code>
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.
38
+
39
+ ## Updates
40
+
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
+ ```
49
+
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.
52
+
53
+ ## Uninstalling
54
+
55
+ ```console
56
+ $ npm uninstall -g @jonaskahn/relo
57
+ ```
58
+
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:
62
+
63
+ ```console
64
+ $ relo uninstall --wipe
65
+ ```
66
+
67
+ Without a flag, `relo uninstall` asks whether the data goes.
68
+
69
+ ## Install options
70
+
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:
74
+
75
+ ```console
76
+ $ node "$(npm root -g)/relo/lib/install.mjs" --relo-version 0.2.0
77
+ ```
78
+
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:
83
+
84
+ ```console
85
+ $ node "$(npm root -g)/relo/lib/install.mjs"
86
+ ```
87
+
88
+ Downloads are verified against `checksums.txt` from the release. A mismatch
89
+ fails the install rather than writing an executable.
90
+
91
+ ## Links
92
+
93
+ - [Source](https://github.com/jonaskahn/relo)
94
+ - [Releases](https://github.com/jonaskahn/relo/releases)
95
+ - [Documentation](https://github.com/jonaskahn/relo#readme)
96
+
97
+ AGPL-3.0-only
package/bin/relo.mjs ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Entry point npm links as `relo`. It execs the platform binary the postinstall
3
+ // placed beside it, passing through arguments, streams, and exit status.
4
+ import { spawn } from 'node:child_process';
5
+ import { accessSync, constants } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { target } from '../lib/platform.mjs';
10
+
11
+ const binary = resolve(dirname(fileURLToPath(import.meta.url)), target().name);
12
+
13
+ try {
14
+ accessSync(binary, constants.X_OK);
15
+ } catch {
16
+ console.error(
17
+ 'relo: this install has no daemon binary. Reinstall without --ignore-scripts, or run\n' +
18
+ ' node node_modules/relo/lib/install.mjs'
19
+ );
20
+ process.exit(1);
21
+ }
22
+
23
+ const child = spawn(binary, process.argv.slice(2), { stdio: 'inherit' });
24
+
25
+ // A terminal interrupt already reaches the child through the process group.
26
+ // These cover a service manager or a shell that signals only the launcher.
27
+ if (process.platform !== 'win32') {
28
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
29
+ process.on(signal, () => child.kill(signal));
30
+ }
31
+ }
32
+
33
+ child.on('error', (error) => {
34
+ console.error(`relo: could not start ${binary}: ${error.message}`);
35
+ process.exit(1);
36
+ });
37
+
38
+ child.on('exit', (code, signal) => {
39
+ // A signalled child reports no code; pass the shell convention through so a
40
+ // caller waiting on relo still sees a failure.
41
+ process.exit(code ?? (signal === 'SIGINT' ? 130 : 1));
42
+ });
@@ -0,0 +1,31 @@
1
+ // Verifies a downloaded asset against the digest the release publishes beside
2
+ // it, so a tampered or truncated download fails the install instead of being
3
+ // written into the package as an executable.
4
+ import { createHash } from 'node:crypto';
5
+
6
+ // digestOf is the lowercase hex sha256 of a buffer.
7
+ export function digestOf(body) {
8
+ return createHash('sha256').update(body).digest('hex');
9
+ }
10
+
11
+ // parse reads the `sha256 name` lines of a sha256sum file into a lookup. It
12
+ // accepts the two-column form both shasum and sha256sum emit, with one or two
13
+ // spaces and the optional leading asterisk binary mode adds.
14
+ export function parse(manifest) {
15
+ const entries = new Map();
16
+ for (const line of manifest.toString('utf8').split('\n')) {
17
+ const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line);
18
+ if (match) entries.set(match[2], match[1]);
19
+ }
20
+ return entries;
21
+ }
22
+
23
+ // expect finds the digest for one asset and checks the body against it.
24
+ export function expect(entries, name, body) {
25
+ const expected = entries.get(name);
26
+ if (!expected) throw new Error(`${name} is not listed in the release checksums`);
27
+ const actual = digestOf(body);
28
+ if (actual !== expected) {
29
+ throw new Error(`${name} does not match its published sha256. expected ${expected}, got ${actual}`);
30
+ }
31
+ }
@@ -0,0 +1,68 @@
1
+ // Finds another Relo command on PATH. The binary is one product now: a daemon
2
+ // with its tray icon, built the same way for the npm package and the
3
+ // installers. Two installs would still fight over the name, the home
4
+ // directory, and the ports, so the install refuses when a command this
5
+ // package did not install answers PATH.
6
+ import { accessSync, constants, realpathSync } from 'node:fs';
7
+ import { delimiter, join, resolve, sep } from 'node:path';
8
+
9
+ // candidates lists the relo commands a PATH resolves, in order. Windows
10
+ // installs the app's command as relo.cmd, a shim that starts the GUI
11
+ // relo.exe, and npm's bin as its own shim; both spellings are candidates
12
+ // there, while POSIX installs the bare command.
13
+ export function candidates({ pathValue = '', nodePlatform = 'linux', name = 'relo' } = {}) {
14
+ const executables = nodePlatform === 'win32' ? [`${name}.exe`, `${name}.cmd`] : [name];
15
+ const found = [];
16
+ for (const dir of pathValue.split(delimiter).filter(Boolean)) {
17
+ for (const file of executables) {
18
+ const candidate = join(dir, file);
19
+ try {
20
+ accessSync(candidate, constants.X_OK);
21
+ found.push(candidate);
22
+ } catch {
23
+ // Not there, or not executable: keep looking.
24
+ }
25
+ }
26
+ }
27
+ return found;
28
+ }
29
+
30
+ // findConflictingRelo returns the path of the first relo command on PATH
31
+ // this package did not install, or null. Commands inside selfDir are this
32
+ // package's own install and are skipped, so a reinstall is not a conflict.
33
+ // selfDir, PATH, and the platform are parameters so the search can be tested
34
+ // against fixture directories.
35
+ export function findConflictingRelo({
36
+ env = process.env,
37
+ nodePlatform = process.platform,
38
+ selfDir = null
39
+ } = {}) {
40
+ const pathValue = env.PATH ?? env.Path ?? '';
41
+ for (const candidate of candidates({ pathValue, nodePlatform })) {
42
+ if (selfDir && insideDir(candidate, selfDir)) {
43
+ continue;
44
+ }
45
+ return candidate;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // insideDir reports whether candidate resolves inside dir, following a link
51
+ // the way the npm bin shim points into the installed package. Both sides
52
+ // resolve, because either path may itself sit under a link. A path that
53
+ // resolves nowhere compares by its raw form instead.
54
+ function insideDir(candidate, dir) {
55
+ const prefix = existingPath(dir) + sep;
56
+ const resolved = existingPath(candidate);
57
+ return resolved === prefix.slice(0, -1) || resolved.startsWith(prefix);
58
+ }
59
+
60
+ // existingPath resolves a path through any links, or returns its raw form
61
+ // when nothing is there to resolve.
62
+ function existingPath(path) {
63
+ try {
64
+ return realpathSync(path);
65
+ } catch {
66
+ return resolve(path);
67
+ }
68
+ }
package/lib/daemon.mjs ADDED
@@ -0,0 +1,280 @@
1
+ // Quiesces the daemon running from one home before its binary is replaced, and
2
+ // offers to start the new one: a graceful `daemon stop --force` first, then
3
+ // signals against this home's processes only, so a process that merely reused a
4
+ // port is never touched.
5
+ //
6
+ // The config scan reads key names instead of parsing TOML, so a key this
7
+ // scan cannot see keeps its default.
8
+ import { execFileSync, spawnSync } from 'node:child_process';
9
+ import { createInterface } from 'node:readline/promises';
10
+ import { createReadStream, createWriteStream, existsSync, readFileSync, rmSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ // reloHome is the state directory the daemon serves: $RELO_HOME, else ~/.relo.
15
+ export function reloHome(env = process.env) {
16
+ return env.RELO_HOME?.trim() || join(homedir(), '.relo');
17
+ }
18
+
19
+ const DEFAULTS = { port: 10101, openai: 10201, anthropic: 10202, gemini: 10203 };
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.
23
+ export function servicePorts(configText = '') {
24
+ const found = {};
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]);
28
+ }
29
+ return ['port', 'openai', 'anthropic', 'gemini']
30
+ .map((key) => found[key] ?? DEFAULTS[key])
31
+ .filter((port) => port !== 0);
32
+ }
33
+
34
+ // runtimePid reads the pid a daemon published, or null when no usable file
35
+ // is there.
36
+ export function runtimePid(text) {
37
+ const match = text.match(/"pid"\s*:\s*(\d+)/);
38
+ return match ? Number(match[1]) : null;
39
+ }
40
+
41
+ // parseLsof reads `lsof -nP -iTCP:<port> -sTCP:LISTEN -t` output: one pid
42
+ // per line.
43
+ export function parseLsof(text) {
44
+ return text
45
+ .split('\n')
46
+ .map((line) => line.trim())
47
+ .filter((line) => /^\d+$/.test(line))
48
+ .map(Number);
49
+ }
50
+
51
+ // parseSs reads `ss -ltnp` output, collecting every pid= on a line that names
52
+ // the port.
53
+ export function parseSs(text, port) {
54
+ const names = new RegExp(`:${port}(?!\\d)`);
55
+ const pids = [];
56
+ for (const line of text.split('\n')) {
57
+ if (!names.test(line)) continue;
58
+ for (const match of line.matchAll(/pid=(\d+)/g)) pids.push(Number(match[1]));
59
+ }
60
+ return pids;
61
+ }
62
+
63
+ // parseNetstat reads `netstat -ano -p tcp` output (windows), pairing a
64
+ // LISTENING local port with its owning pid.
65
+ export function parseNetstat(text, port) {
66
+ const pids = [];
67
+ for (const line of text.split('\n')) {
68
+ const match = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
69
+ if (match && Number(match[1]) === port) pids.push(Number(match[2]));
70
+ }
71
+ return pids;
72
+ }
73
+
74
+ // isReloImage reports whether an executable name is the relo binary, so a
75
+ // signal never reaches a process that merely reused the port.
76
+ export function isReloImage(comm) {
77
+ const base = comm.trim().split(/[\\/]/).pop().toLowerCase();
78
+ return base === 'relo' || base === 'relo.exe';
79
+ }
80
+
81
+ // servesHome reports whether a relo process belongs to this home. The pid
82
+ // the home published always does; otherwise the command line must name
83
+ // --home. A relo started without --home serves the default home.
84
+ export function servesHome({ pid, owner, args, home, defaultHome }) {
85
+ if (owner && pid === owner) return true;
86
+ if (args.includes(`--home ${home}`) || args.includes(`--home=${home}`)) return true;
87
+ if (!/--home(?:=|\s)/.test(args)) return home === defaultHome;
88
+ return false;
89
+ }
90
+
91
+ // listeningPids lists the processes holding a port. An unavailable or empty
92
+ // lookup reads as no listeners, so a missing lsof never fails the install.
93
+ function listeningPids(port) {
94
+ try {
95
+ if (process.platform === 'win32') {
96
+ const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], {
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'pipe', 'ignore']
99
+ });
100
+ return parseNetstat(out, port);
101
+ }
102
+ if (process.platform === 'darwin') {
103
+ const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], {
104
+ encoding: 'utf8',
105
+ stdio: ['ignore', 'pipe', 'ignore']
106
+ });
107
+ return parseLsof(out);
108
+ }
109
+ try {
110
+ const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], {
111
+ encoding: 'utf8',
112
+ stdio: ['ignore', 'pipe', 'ignore']
113
+ });
114
+ return parseLsof(out);
115
+ } catch {
116
+ const out = execFileSync('ss', ['-ltnp'], {
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore']
119
+ });
120
+ return parseSs(out, port);
121
+ }
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ // processInfo describes a pid, or null when it is already gone. The command
128
+ // line comes from powershell on windows, which can fail; an empty line then
129
+ // falls back to the owner and default-home rules in servesHome.
130
+ function processInfo(pid) {
131
+ try {
132
+ if (process.platform === 'win32') {
133
+ const listing = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
134
+ encoding: 'utf8',
135
+ stdio: ['ignore', 'pipe', 'ignore']
136
+ }).trim();
137
+ const comm = listing.match(/^"([^"]+)"/)?.[1] ?? '';
138
+ let args = '';
139
+ try {
140
+ args = execFileSync(
141
+ 'powershell',
142
+ ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`],
143
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
144
+ ).trim();
145
+ } catch {}
146
+ return { comm, args };
147
+ }
148
+ const comm = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
149
+ encoding: 'utf8',
150
+ stdio: ['ignore', 'pipe', 'ignore']
151
+ }).trim();
152
+ const args = execFileSync('ps', ['-p', String(pid), '-o', 'args='], {
153
+ encoding: 'utf8',
154
+ stdio: ['ignore', 'pipe', 'ignore']
155
+ }).trim();
156
+ return { comm, args };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function alive(pid) {
163
+ try {
164
+ process.kill(pid, 0);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function signal(pid, name) {
172
+ try {
173
+ process.kill(pid, name);
174
+ } catch {}
175
+ }
176
+
177
+ // sleep blocks without a dependency, so the poll below stays synchronous.
178
+ function sleep(ms) {
179
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
180
+ }
181
+
182
+ // quiesce leaves no Relo running from one home before its binary is replaced:
183
+ // a graceful `daemon stop --force` through the already-installed binary, then
184
+ // signals against this home's processes only, then a check that the configured
185
+ // ports are free. Reports whether a daemon was there at all.
186
+ //
187
+ // A port another program holds fails the install. The daemon that would have
188
+ // served it is gone either way, so replacing its binary now would leave a new
189
+ // daemon that cannot bind -- and an automatic `npm install` must never kill a
190
+ // process that is not Relo.
191
+ export function quiesce(home, { binary } = {}) {
192
+ const configPath = join(home, 'config.toml');
193
+ const ports = servicePorts(existsSync(configPath) ? readFileSync(configPath, 'utf8') : '');
194
+ const runtimePath = join(home, 'runtime.json');
195
+ const defaultHome = join(homedir(), '.relo');
196
+ let owner = 0;
197
+ if (existsSync(runtimePath)) {
198
+ try {
199
+ owner = runtimePid(readFileSync(runtimePath, 'utf8')) ?? 0;
200
+ } catch {
201
+ owner = 0;
202
+ }
203
+ const info = owner ? processInfo(owner) : null;
204
+ if (!info || !isReloImage(info.comm)) owner = 0;
205
+ }
206
+ const candidates = new Set();
207
+ if (owner) candidates.add(owner);
208
+ for (const port of ports) {
209
+ for (const pid of listeningPids(port)) candidates.add(pid);
210
+ }
211
+ const victims = [...candidates].filter((pid) => {
212
+ const info = processInfo(pid);
213
+ return info && isReloImage(info.comm) && servesHome({ pid, owner, args: info.args, home, defaultHome });
214
+ });
215
+ const stopped = victims.length > 0;
216
+
217
+ if (binary && existsSync(binary)) {
218
+ // --force frees the ports too; a build that predates it rejects the
219
+ // flag, and the signals below cover that build.
220
+ spawnSync(binary, ['--home', home, 'daemon', 'stop', '--force'], { stdio: 'ignore' });
221
+ }
222
+
223
+ for (const pid of victims.filter(alive)) signal(pid, 'SIGTERM');
224
+ // A daemon gets ten seconds to close its listeners before the kill it
225
+ // cannot catch. On windows both signals terminate.
226
+ const deadline = Date.now() + 10_000;
227
+ let live = victims.filter(alive);
228
+ while (live.length > 0 && Date.now() < deadline) {
229
+ sleep(1000);
230
+ live = live.filter(alive);
231
+ }
232
+ for (const pid of live) signal(pid, 'SIGKILL');
233
+ rmSync(runtimePath, { force: true });
234
+
235
+ for (const port of ports) {
236
+ const holders = listeningPids(port);
237
+ if (holders.length > 0) {
238
+ throw new Error(
239
+ `relo: install failed -- something else is listening on port ${port}\n` +
240
+ ' free the port, or point relo at another one, and install again'
241
+ );
242
+ }
243
+ }
244
+ return { stopped };
245
+ }
246
+
247
+ // askStart prompts on a terminal and reports 'yes', 'no', or 'unattended'
248
+ // when no terminal can be asked on. The answer never comes from stdin: under
249
+ // a piped install stdin is not the user.
250
+ export async function askStart() {
251
+ if (process.platform === 'win32') {
252
+ if (!process.stdin.isTTY) return 'unattended';
253
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
254
+ try {
255
+ const answer = await terminal.question('start relo now? [Y/n] ');
256
+ return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
257
+ } finally {
258
+ terminal.close();
259
+ }
260
+ }
261
+ let input;
262
+ try {
263
+ input = createReadStream('/dev/tty');
264
+ await new Promise((resolve, reject) => {
265
+ input.once('open', resolve);
266
+ input.once('error', reject);
267
+ });
268
+ } catch {
269
+ return 'unattended';
270
+ }
271
+ const terminal = createInterface({ input, output: createWriteStream('/dev/tty') });
272
+ try {
273
+ const answer = await terminal.question('start relo now? [Y/n] ');
274
+ return /^[nN]/.test(answer.trim()) ? 'no' : 'yes';
275
+ } catch {
276
+ return 'unattended';
277
+ } finally {
278
+ terminal.close();
279
+ }
280
+ }
@@ -0,0 +1,52 @@
1
+ // Fetches a URL with a few spaced attempts. Kept out of install.mjs so the
2
+ // retry and error rules can be tested without running a download.
3
+ //
4
+ // A permanent client error fails fast: retrying a missing asset only burns
5
+ // three waits before printing the same dead end. Network errors, throttling,
6
+ // and 5xx keep the retries, since GitHub answers a cold CDN edge with an
7
+ // error often enough that a single attempt reads as a broken install.
8
+ const ATTEMPTS = 3;
9
+
10
+ // failFast reports whether another attempt would hit the same wall. 408
11
+ // timed out and 429 was throttled, so both are worth retrying; every other
12
+ // 4xx names something the next attempt will hit too.
13
+ function failFast(status) {
14
+ if (status === 408 || status === 429 || status >= 500) return false;
15
+ return status >= 400 && status < 500;
16
+ }
17
+
18
+ // fetchBytes returns the body of a URL. fetchImpl and sleep take arguments
19
+ // so tests can fake the network and skip the waits.
20
+ export async function fetchBytes(
21
+ target,
22
+ what,
23
+ { fetchImpl = fetch, sleep = (ms) => new Promise((done) => setTimeout(done, ms)) } = {}
24
+ ) {
25
+ let last;
26
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
27
+ try {
28
+ const response = await fetchImpl(target, { redirect: 'follow' });
29
+ if (!response.ok) {
30
+ if (failFast(response.status)) {
31
+ throw Object.assign(
32
+ new Error(
33
+ `no ${what} in the release (HTTP ${response.status}). ` +
34
+ 'No release may be published yet; rerun the installer with --relo-version <tag> for a published tag, or without it for the latest release.'
35
+ ),
36
+ { permanent: true }
37
+ );
38
+ }
39
+ throw new Error(`${response.status} ${response.statusText}`);
40
+ }
41
+ return Buffer.from(await response.arrayBuffer());
42
+ } catch (error) {
43
+ if (error?.permanent) throw error;
44
+ last = error;
45
+ if (attempt < ATTEMPTS) {
46
+ console.warn(`relo: ${what} failed (${error.message}), retrying`);
47
+ await sleep(attempt * 1000);
48
+ }
49
+ }
50
+ }
51
+ throw new Error(`could not download ${what} from ${target}: ${last.message}`);
52
+ }
@@ -0,0 +1,90 @@
1
+ // Puts the binary for this host inside the installed package. The launcher
2
+ // in bin/ execs it, so the daemon and its tray run as their own process and
3
+ // do not need node at run time. The install refuses a machine where another
4
+ // Relo already owns the relo command, whether an installer or an older
5
+ // package put it there: two installs would fight over the name, the home
6
+ // directory, and the ports. A relo running from this home is quiesced before
7
+ // its binary is replaced -- its daemon is stopped and the ports it served
8
+ // are freed -- and the install asks whether to start the new one.
9
+ //
10
+ // The binary always comes from a GitHub release: the one --relo-version names,
11
+ // the one this package was published as when that is a prerelease, or the
12
+ // latest release. Rerun this file by hand with the flag to install a specific
13
+ // release.
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
17
+ import { dirname, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { expect, parse } from './checksums.mjs';
21
+ import { askStart, quiesce, reloHome } from './daemon.mjs';
22
+ import { findConflictingRelo } from './conflict.mjs';
23
+ import { fetchBytes } from './download.mjs';
24
+ import { target } from './platform.mjs';
25
+ import { assets, checksumsUrl, prereleaseTag, tagFromArgs, url } from './release.mjs';
26
+
27
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
+ const binaryDir = resolve(packageRoot, 'bin');
29
+ const home = reloHome();
30
+ const publishedVersion = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')).version;
31
+
32
+ // writeAtomic lands the binary in one rename, so an interrupted install leaves
33
+ // no truncated executable behind for the launcher to find.
34
+ async function writeAtomic(destination, body) {
35
+ const staging = `${destination}.download`;
36
+ await writeFile(staging, body, { mode: 0o755 });
37
+ await rename(staging, destination);
38
+ }
39
+
40
+ async function fromRelease(tag) {
41
+ const asset = target();
42
+ const { version } = assets(tag);
43
+
44
+ // 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);
49
+ const destination = resolve(binaryDir, asset.name);
50
+ // Quiesced first: on windows the rename below fails over a running exe, and
51
+ // the new daemon cannot bind a port the old one still serves.
52
+ const { stopped } = quiesce(home, { binary: existsSync(destination) ? destination : undefined });
53
+ await mkdir(binaryDir, { recursive: true });
54
+ await writeAtomic(destination, body);
55
+ console.log(`relo: installed the ${version ? `v${version} ` : ''}daemon binary for ${asset.os}-${asset.arch}`);
56
+ console.log(stopped ? 'relo: stopped the running daemon' : 'relo: not running');
57
+ return destination;
58
+ }
59
+
60
+ try {
61
+ // One binary answers the relo command now, whether it came from this
62
+ // package or an installer, so a command this package did not install
63
+ // stops this one: it already provides the name, and a second Relo would
64
+ // fight it for the home directory and the ports. A previous install from
65
+ // this package is simply replaced below.
66
+ const conflict = findConflictingRelo({ selfDir: binaryDir });
67
+ if (conflict) {
68
+ throw new Error(
69
+ `another Relo install provides the relo command at ${conflict}\n` +
70
+ ' remove that install first to install this package, or keep using that install instead'
71
+ );
72
+ }
73
+ const destination = await fromRelease(tagFromArgs(process.argv.slice(2)) ?? prereleaseTag(publishedVersion));
74
+ const answer = await askStart();
75
+ if (answer === 'yes') {
76
+ const started = spawnSync(destination, ['daemon', 'start'], { stdio: 'inherit' });
77
+ if (started.status !== 0) {
78
+ console.warn('relo: could not start the daemon; run relo daemon start to try again');
79
+ }
80
+ } else {
81
+ console.log('relo: start it later with: relo daemon start');
82
+ }
83
+ } catch (error) {
84
+ // Remove only the staging file this install wrote. Removing the directory
85
+ // would take bin/relo.mjs with it, leaving a package whose `relo` command
86
+ // no longer exists.
87
+ await rm(resolve(binaryDir, `${target().name}.download`), { force: true }).catch(() => {});
88
+ console.error(`relo: install failed\n${error.message}`);
89
+ process.exitCode = 1;
90
+ }
@@ -0,0 +1,24 @@
1
+ // Maps the host onto a release asset name. The assets are the ones
2
+ // make build-all writes, so this translates Node's platform and arch into the
3
+ // Go names the release job already publishes rather than keeping a second
4
+ // table of its own.
5
+ import { arch, platform } from 'node:os';
6
+
7
+ const GOOS = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
8
+ const GOARCH = { arm64: 'arm64', x64: 'amd64' };
9
+
10
+ const SUPPORTED = 'darwin and linux on amd64 and arm64, windows on amd64 and arm64.';
11
+
12
+ // target describes the daemon binary a host installs. The platform and arch
13
+ // default to this machine and take arguments so every host the package claims
14
+ // to support can be tested from one machine. It throws with a message naming
15
+ // the host when the release has no asset for it, so a failed install says
16
+ // which platform is missing instead of what a 404 means.
17
+ export function target({ platform: nodePlatform = platform(), arch: nodeArch = arch() } = {}) {
18
+ const os = GOOS[nodePlatform];
19
+ const cpu = GOARCH[nodeArch];
20
+ if (!os || !cpu) {
21
+ throw new Error(`relo publishes no binary for ${nodePlatform}-${nodeArch}. Supported: ${SUPPORTED}`);
22
+ }
23
+ return { os, arch: cpu, name: `relo-${os}-${cpu}${os === 'windows' ? '.exe' : ''}` };
24
+ }
@@ -0,0 +1,55 @@
1
+ // Where the daemon binaries live and how they are named. Kept out of
2
+ // install.mjs so the rules can be tested without running a download.
3
+
4
+ // REPO is the GitHub repository the release job uploads to.
5
+ export const REPO = 'https://github.com/jonaskahn/relo';
6
+
7
+ // version normalizes a release tag, accepting both `v0.1.0` and `0.1.0`.
8
+ export function version(tag) {
9
+ return tag?.trim().replace(/^v/, '') || null;
10
+ }
11
+
12
+ // tagFromArgs reads the release to install from the command line. The
13
+ // installer takes no other argument; an unknown one is ignored so a bare
14
+ // postinstall keeps installing the latest release.
15
+ export function tagFromArgs(argv = []) {
16
+ for (let i = 0; i < argv.length; i += 1) {
17
+ const arg = argv[i];
18
+ if (arg.startsWith('--relo-version=')) return version(arg.slice('--relo-version='.length));
19
+ if (arg === '--relo-version' && i + 1 < argv.length) return version(argv[i + 1]);
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // prereleaseTag pins an install to the version this package was published as,
25
+ // and only when that version is a prerelease. A beta published under the beta
26
+ // dist-tag would otherwise resolve "latest" and install the stable binary,
27
+ // which is not the build its caller asked for. A stable package keeps
28
+ // following the latest release, so re-publishing an old stable version still
29
+ // tracks the channel instead of pinning itself.
30
+ export function prereleaseTag(published) {
31
+ const pinned = version(published);
32
+ return pinned && /^\d+\.\d+\.\d+-/.test(pinned) ? pinned : null;
33
+ }
34
+
35
+ // assets resolves the download directory for one release. Without a tag it
36
+ // returns the latest-release directory, which is how a release is always
37
+ // found when no pin is given.
38
+ export function assets(tag = null) {
39
+ const pinned = version(tag);
40
+ return {
41
+ base: pinned ? `${REPO}/releases/download/v${pinned}` : `${REPO}/releases/latest/download`,
42
+ version: pinned
43
+ };
44
+ }
45
+
46
+ // checksums lists every asset in a release with its sha256, so one fetch
47
+ // covers the single binary this host needs.
48
+ export function checksumsUrl(tag = null) {
49
+ return `${assets(tag).base}/checksums.txt`;
50
+ }
51
+
52
+ // url is the asset one host installs.
53
+ export function url(name, tag = null) {
54
+ return `${assets(tag).base}/${name}`;
55
+ }
@@ -0,0 +1,31 @@
1
+ // Runs before npm removes this package: the daemon is stopped, its ports are
2
+ // freed, and autostart is unregistered. The data is never touched here, because
3
+ // an npm removal keeps it -- `relo uninstall` is the way to wipe it.
4
+ //
5
+ // The work goes through the installed binary, which is the one thing that
6
+ // knows the configured ports and the login items. Without it the same quiesce
7
+ // runs in node, so a removal that lost the binary still frees what it can.
8
+ //
9
+ // Best effort by design: npm skips this with --ignore-scripts, and an
10
+ // uninstall must not fail over it.
11
+ import { existsSync } from 'node:fs';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ import { quiesce, reloHome } from './daemon.mjs';
17
+ import { target } from './platform.mjs';
18
+
19
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const binary = resolve(packageRoot, 'bin', target().name);
21
+ const home = reloHome();
22
+
23
+ try {
24
+ if (existsSync(binary)) {
25
+ spawnSync(binary, ['--home', home, 'uninstall', '--prepare'], { stdio: 'inherit' });
26
+ } else {
27
+ quiesce(home);
28
+ }
29
+ } catch (error) {
30
+ console.warn(`relo: could not stop the running daemon; ${error.message}`);
31
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
- "name": "@jonaskahn/relo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@jonaskahn/relo",
3
+ "version": "0.1.0-beta.13.91cd0d14",
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
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "homepage": "https://github.com/jonaskahn/relo#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jonaskahn/relo.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/jonaskahn/relo/issues"
14
+ },
15
+ "keywords": [
16
+ "llm",
17
+ "proxy",
18
+ "gateway",
19
+ "openai",
20
+ "anthropic",
21
+ "codex",
22
+ "claude-code",
23
+ "cursor",
24
+ "cli"
25
+ ],
26
+ "bin": {
27
+ "relo": "bin/relo.mjs"
28
+ },
29
+ "os": [
30
+ "darwin",
31
+ "linux",
32
+ "win32"
33
+ ],
34
+ "cpu": [
35
+ "x64",
36
+ "arm64"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "files": [
42
+ "bin/relo.mjs",
43
+ "lib/checksums.mjs",
44
+ "lib/conflict.mjs",
45
+ "lib/daemon.mjs",
46
+ "lib/download.mjs",
47
+ "lib/install.mjs",
48
+ "lib/platform.mjs",
49
+ "lib/release.mjs",
50
+ "lib/uninstall.mjs",
51
+ "README.md"
52
+ ],
53
+ "scripts": {
54
+ "postinstall": "node lib/install.mjs",
55
+ "preuninstall": "node lib/uninstall.mjs",
56
+ "test": "node --test"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org"
61
+ }
62
+ }