@jonaskahn/relo 0.1.0-beta.19.453b59ee → 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/lib/archive.mjs +72 -0
- package/lib/install.mjs +12 -5
- package/lib/release.mjs +27 -0
- package/package.json +2 -1
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/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",
|