@jonaskahn/relo 0.1.0-beta.19.453b59ee → 0.1.0-beta.21.03fd1a51

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.
@@ -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
- 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.19.453b59ee",
3
+ "version": "0.1.0-beta.21.03fd1a51",
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",