@muthuishere/routsi 0.1.1 → 0.1.2
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 +6 -3
- package/npm/launcher.js +41 -24
- package/npm/resolve-binary.js +257 -0
- package/package.json +4 -3
- package/scripts/postinstall.js +24 -247
- package/scripts/postinstall.test.js +0 -33
package/README.md
CHANGED
|
@@ -20,9 +20,12 @@ routsi serve # listens on :8080 by default
|
|
|
20
20
|
npm install -g @muthuishere/routsi
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
`postinstall`
|
|
24
|
-
Releases](https://github.com/muthuishere/routsi/releases) — no Go
|
|
25
|
-
|
|
23
|
+
`postinstall` best-effort prefetches the prebuilt `routsi` binary for your OS/arch
|
|
24
|
+
from [GitHub Releases](https://github.com/muthuishere/routsi/releases) — no Go
|
|
25
|
+
toolchain needed. The launcher is self-healing: if postinstall didn't run (blocked
|
|
26
|
+
by `allow-scripts`, offline, whatever), the first `routsi` invocation fetches the
|
|
27
|
+
binary itself, so the command always works either way. See
|
|
28
|
+
[`docs/adr/006-npm-distribution.md`](docs/adr/006-npm-distribution.md) for how it
|
|
26
29
|
works.
|
|
27
30
|
|
|
28
31
|
Point any OpenAI client at `http://localhost:8080/v1`. Then:
|
package/npm/launcher.js
CHANGED
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
// Thin launcher installed as the `routsi` bin entry.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Thin launcher installed as the `routsi` bin entry. Guarantees the native
|
|
5
|
+
// binary is present (self-healing — downloads it on demand via
|
|
6
|
+
// npm/resolve-binary.js if postinstall didn't run or was blocked, e.g. by
|
|
7
|
+
// `allow-scripts`), then execs it, passing argv/stdio/exit code straight
|
|
8
|
+
// through. Kept separate from the Go build's own `bin/routsi` output so
|
|
9
|
+
// `task build` (repo checkout) and this npm package never collide.
|
|
8
10
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const { spawnSync } = require('child_process');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const { ensureBinary } = require('./resolve-binary');
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
(async function main() {
|
|
15
|
+
let binPath;
|
|
16
|
+
try {
|
|
17
|
+
binPath = await ensureBinary({ quiet: false });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('routsi: ' + (err && err.message ? err.message : String(err)));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
console.error(
|
|
18
|
-
`routsi: native binary not found at ${binPath}.\n` +
|
|
19
|
-
'The postinstall step may have failed or been skipped (offline install?).\n' +
|
|
20
|
-
'Try: npm install -g routsi --force\n' +
|
|
21
|
-
'Or download a binary manually from https://github.com/muthuishere/routsi/releases'
|
|
22
|
-
);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
24
|
+
const child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit' });
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const forwardSignal = (signal) => {
|
|
27
|
+
if (child.killed === false) {
|
|
28
|
+
try {
|
|
29
|
+
child.kill(signal);
|
|
30
|
+
} catch (_) {
|
|
31
|
+
// ignore — child may have already exited
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
process.on('SIGINT', forwardSignal);
|
|
36
|
+
process.on('SIGTERM', forwardSignal);
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
38
|
+
child.on('error', (err) => {
|
|
39
|
+
console.error('routsi: failed to launch native binary: ' + err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
32
42
|
|
|
33
|
-
|
|
43
|
+
child.on('exit', (code, signal) => {
|
|
44
|
+
if (signal) {
|
|
45
|
+
process.kill(process.pid, signal);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
process.exit(code === null ? 1 : code);
|
|
49
|
+
});
|
|
50
|
+
})();
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared, zero-dependency binary resolution/fetch logic for routsi's npm
|
|
4
|
+
// distribution (ADR 006). Used by both scripts/postinstall.js (best-effort
|
|
5
|
+
// prefetch) and npm/launcher.js (guarantees the binary before every run).
|
|
6
|
+
//
|
|
7
|
+
// Asset naming scheme (MUST match .github/workflows/release.yml):
|
|
8
|
+
// routsi_<os>_<arch>.tar.gz (darwin/linux)
|
|
9
|
+
// routsi_windows_<arch>.zip (windows)
|
|
10
|
+
// where <os> in {darwin, linux, windows}, <arch> in {amd64, arm64}.
|
|
11
|
+
// Release URL: https://github.com/muthuishere/routsi/releases/download/v<version>/<asset>
|
|
12
|
+
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const REPO = 'muthuishere/routsi';
|
|
21
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
22
|
+
const BIN_DIR = path.join(PKG_ROOT, 'npm', 'bin');
|
|
23
|
+
|
|
24
|
+
// platform: node's os.platform() value ('darwin'|'linux'|'win32'|...)
|
|
25
|
+
// arch: node's os.arch() value ('x64'|'arm64'|...)
|
|
26
|
+
function mapPlatform(platform) {
|
|
27
|
+
if (platform === 'darwin') return 'darwin';
|
|
28
|
+
if (platform === 'linux') return 'linux';
|
|
29
|
+
if (platform === 'win32') return 'windows';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mapArch(arch) {
|
|
34
|
+
if (arch === 'x64') return 'amd64';
|
|
35
|
+
if (arch === 'arm64') return 'arm64';
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure mapping function — unit-tested by scripts/postinstall.test.js.
|
|
40
|
+
function assetName(platform, arch, version) {
|
|
41
|
+
const os_ = mapPlatform(platform);
|
|
42
|
+
const arch_ = mapArch(arch);
|
|
43
|
+
if (!os_ || !arch_) {
|
|
44
|
+
throw new Error(`unsupported platform/arch: ${platform}/${arch}`);
|
|
45
|
+
}
|
|
46
|
+
const ext = os_ === 'windows' ? 'zip' : 'tar.gz';
|
|
47
|
+
return `routsi_${os_}_${arch_}.${ext}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function binaryName(platform) {
|
|
51
|
+
return mapPlatform(platform) === 'windows' ? 'routsi.exe' : 'routsi';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function binaryPath() {
|
|
55
|
+
return path.join(BIN_DIR, binaryName(process.platform));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function releaseUrl(version, asset) {
|
|
59
|
+
return `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// version() reads package.json's version, but allows an env override for
|
|
63
|
+
// testing against an already-released version before a new one is cut.
|
|
64
|
+
function version() {
|
|
65
|
+
if (process.env.ROUTSI_BINARY_VERSION) return process.env.ROUTSI_BINARY_VERSION;
|
|
66
|
+
const pkg = require(path.join(PKG_ROOT, 'package.json'));
|
|
67
|
+
return pkg.version;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isOffline() {
|
|
71
|
+
if (process.env.npm_config_offline === 'true') return true;
|
|
72
|
+
if (process.env.npm_config_prefer_offline === 'true' && process.env.ROUTSI_ALLOW_STALE) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// GET with a bounded number of redirect hops. Returns a Buffer via callback.
|
|
77
|
+
function fetchBuffer(url, redirectsLeft, cb) {
|
|
78
|
+
const req = https.get(url, { headers: { 'User-Agent': 'routsi-npm-launcher' } }, (res) => {
|
|
79
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
80
|
+
if (redirectsLeft <= 0) {
|
|
81
|
+
cb(new Error('too many redirects fetching ' + url));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
res.resume();
|
|
85
|
+
fetchBuffer(res.headers.location, redirectsLeft - 1, cb);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (res.statusCode !== 200) {
|
|
89
|
+
res.resume();
|
|
90
|
+
cb(new Error(`GET ${url} -> HTTP ${res.statusCode}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const chunks = [];
|
|
94
|
+
res.on('data', (c) => chunks.push(c));
|
|
95
|
+
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
|
96
|
+
res.on('error', cb);
|
|
97
|
+
});
|
|
98
|
+
req.on('error', cb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fetchBufferAsync(url) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
fetchBuffer(url, 5, (err, buf) => (err ? reject(err) : resolve(buf)));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Best-effort checksum verification. If checksums.txt can't be fetched (e.g.
|
|
108
|
+
// older release, network hiccup), we proceed without failing the install.
|
|
109
|
+
async function verifyChecksum(ver, assetBuf, asset) {
|
|
110
|
+
let checksumsBuf;
|
|
111
|
+
try {
|
|
112
|
+
checksumsBuf = await fetchBufferAsync(releaseUrl(ver, 'checksums.txt'));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn('routsi: could not fetch checksums.txt (' + err.message + '), skipping verification');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const text = checksumsBuf.toString('utf8');
|
|
118
|
+
const line = text.split('\n').find((l) => l.trim().endsWith(asset));
|
|
119
|
+
if (!line) {
|
|
120
|
+
console.warn('routsi: no checksum entry for ' + asset + ', skipping verification');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const expected = line.trim().split(/\s+/)[0].toLowerCase();
|
|
124
|
+
const actual = crypto.createHash('sha256').update(assetBuf).digest('hex');
|
|
125
|
+
if (expected !== actual) {
|
|
126
|
+
throw new Error(`checksum mismatch for ${asset}: expected ${expected}, got ${actual}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractTarGz(buf, destDir) {
|
|
131
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
132
|
+
const tmpTar = path.join(os.tmpdir(), `routsi-${Date.now()}.tar.gz`);
|
|
133
|
+
fs.writeFileSync(tmpTar, buf);
|
|
134
|
+
try {
|
|
135
|
+
execFileSync('tar', ['-xzf', tmpTar, '-C', destDir], { stdio: 'inherit' });
|
|
136
|
+
} finally {
|
|
137
|
+
fs.rmSync(tmpTar, { force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractZip(buf, destDir) {
|
|
142
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
143
|
+
const tmpZip = path.join(os.tmpdir(), `routsi-${Date.now()}.zip`);
|
|
144
|
+
fs.writeFileSync(tmpZip, buf);
|
|
145
|
+
try {
|
|
146
|
+
if (process.platform === 'win32') {
|
|
147
|
+
execFileSync('powershell.exe', [
|
|
148
|
+
'-NoProfile', '-Command',
|
|
149
|
+
`Expand-Archive -Force -Path "${tmpZip}" -DestinationPath "${destDir}"`,
|
|
150
|
+
], { stdio: 'inherit' });
|
|
151
|
+
} else {
|
|
152
|
+
execFileSync('unzip', ['-o', tmpZip, '-d', destDir], { stdio: 'inherit' });
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(tmpZip, { force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Locate the extracted binary — tarball/zip is flat (no top-level dir), but
|
|
160
|
+
// tolerate one either way.
|
|
161
|
+
function findExtractedBinary(destDir, wantName) {
|
|
162
|
+
const direct = path.join(destDir, wantName);
|
|
163
|
+
if (fs.existsSync(direct)) return direct;
|
|
164
|
+
const entries = fs.readdirSync(destDir, { withFileTypes: true });
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
if (e.isDirectory()) {
|
|
167
|
+
const nested = path.join(destDir, e.name, wantName);
|
|
168
|
+
if (fs.existsSync(nested)) return nested;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ensureBinary: returns the absolute path to a working npm/bin/routsi[.exe],
|
|
175
|
+
// downloading + extracting the release asset if it isn't already there.
|
|
176
|
+
// Never touches npm install lifecycle semantics — callers decide what to do
|
|
177
|
+
// with a thrown error.
|
|
178
|
+
async function ensureBinary(opts) {
|
|
179
|
+
opts = opts || {};
|
|
180
|
+
const quiet = !!opts.quiet;
|
|
181
|
+
const platform = process.platform;
|
|
182
|
+
const arch = process.arch;
|
|
183
|
+
const finalBinPath = binaryPath();
|
|
184
|
+
|
|
185
|
+
if (fs.existsSync(finalBinPath) && fs.statSync(finalBinPath).size > 0) {
|
|
186
|
+
return finalBinPath;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ver = version();
|
|
190
|
+
|
|
191
|
+
if (isOffline()) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
'offline mode requested (npm_config_offline) and no binary present at ' + finalBinPath
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let asset;
|
|
198
|
+
try {
|
|
199
|
+
asset = assetName(platform, arch, ver);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${err.message}. routsi ships prebuilt binaries for darwin/linux/windows on amd64/arm64.\n` +
|
|
203
|
+
`Build from source instead: https://github.com/${REPO}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!quiet) {
|
|
208
|
+
console.error(`routsi: fetching native binary v${ver} for ${platform}-${arch}...`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const url = releaseUrl(ver, asset);
|
|
212
|
+
let assetBuf;
|
|
213
|
+
try {
|
|
214
|
+
assetBuf = await fetchBufferAsync(url);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`failed to download ${url}: ${err.message}\n` +
|
|
218
|
+
`See release assets at https://github.com/${REPO}/releases — you can also\n` +
|
|
219
|
+
'download the right binary by hand and place it on your PATH.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await verifyChecksum(ver, assetBuf, asset);
|
|
224
|
+
|
|
225
|
+
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'routsi-extract-'));
|
|
226
|
+
try {
|
|
227
|
+
if (asset.endsWith('.zip')) {
|
|
228
|
+
extractZip(assetBuf, extractDir);
|
|
229
|
+
} else {
|
|
230
|
+
extractTarGz(assetBuf, extractDir);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const wantName = binaryName(platform);
|
|
234
|
+
const extracted = findExtractedBinary(extractDir, wantName);
|
|
235
|
+
if (!extracted) {
|
|
236
|
+
throw new Error(`downloaded archive ${asset} did not contain expected binary ${wantName}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
240
|
+
fs.copyFileSync(extracted, finalBinPath);
|
|
241
|
+
fs.chmodSync(finalBinPath, 0o755);
|
|
242
|
+
return finalBinPath;
|
|
243
|
+
} finally {
|
|
244
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
assetName,
|
|
250
|
+
mapPlatform,
|
|
251
|
+
mapArch,
|
|
252
|
+
binaryName,
|
|
253
|
+
binaryPath,
|
|
254
|
+
releaseUrl,
|
|
255
|
+
version,
|
|
256
|
+
ensureBinary,
|
|
257
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muthuishere/routsi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenAI-compatible dynamic LLM router — a single Go binary, installed via npm as a prebuilt native binary",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"routsi": "npm/launcher.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"npm",
|
|
11
|
-
"
|
|
10
|
+
"npm/resolve-binary.js",
|
|
11
|
+
"npm/launcher.js",
|
|
12
|
+
"scripts/postinstall.js",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,251 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const PKG_ROOT = path.join(__dirname, '..');
|
|
24
|
-
const BIN_DIR = path.join(PKG_ROOT, 'npm', 'bin');
|
|
25
|
-
|
|
26
|
-
function pkgVersion() {
|
|
27
|
-
const pkg = require(path.join(PKG_ROOT, 'package.json'));
|
|
28
|
-
return pkg.version;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Pure mapping function — unit-tested by scripts/postinstall.test.js.
|
|
32
|
-
// platform: node's os.platform() value ('darwin'|'linux'|'win32'|...)
|
|
33
|
-
// arch: node's os.arch() value ('x64'|'arm64'|...)
|
|
34
|
-
function mapPlatform(platform) {
|
|
35
|
-
if (platform === 'darwin') return 'darwin';
|
|
36
|
-
if (platform === 'linux') return 'linux';
|
|
37
|
-
if (platform === 'win32') return 'windows';
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function mapArch(arch) {
|
|
42
|
-
if (arch === 'x64') return 'amd64';
|
|
43
|
-
if (arch === 'arm64') return 'arm64';
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function assetName(platform, arch, version) {
|
|
48
|
-
const os_ = mapPlatform(platform);
|
|
49
|
-
const arch_ = mapArch(arch);
|
|
50
|
-
if (!os_ || !arch_) {
|
|
51
|
-
throw new Error(`unsupported platform/arch: ${platform}/${arch}`);
|
|
52
|
-
}
|
|
53
|
-
const ext = os_ === 'windows' ? 'zip' : 'tar.gz';
|
|
54
|
-
return `routsi_${os_}_${arch_}.${ext}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function binaryName(platform) {
|
|
58
|
-
return mapPlatform(platform) === 'windows' ? 'routsi.exe' : 'routsi';
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function releaseUrl(version, asset) {
|
|
62
|
-
return `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isOffline() {
|
|
66
|
-
if (process.env.npm_config_offline === 'true') return true;
|
|
67
|
-
if (process.env.npm_config_prefer_offline === 'true' && process.env.ROUTSI_ALLOW_STALE) return true;
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function fail(msg) {
|
|
72
|
-
console.error('\nroutsi postinstall: ' + msg);
|
|
73
|
-
console.error(
|
|
74
|
-
`See release assets at https://github.com/${REPO}/releases — you can also\n` +
|
|
75
|
-
'download the right binary by hand and place it on your PATH.\n'
|
|
76
|
-
);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function warnSkip(msg) {
|
|
81
|
-
console.warn('routsi postinstall: ' + msg + ' — skipping download.');
|
|
82
|
-
process.exit(0);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// GET with a bounded number of redirect hops. Returns a Buffer via callback.
|
|
86
|
-
function fetchBuffer(url, redirectsLeft, cb) {
|
|
87
|
-
const req = https.get(url, { headers: { 'User-Agent': 'routsi-npm-postinstall' } }, (res) => {
|
|
88
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
89
|
-
if (redirectsLeft <= 0) {
|
|
90
|
-
cb(new Error('too many redirects fetching ' + url));
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
res.resume();
|
|
94
|
-
fetchBuffer(res.headers.location, redirectsLeft - 1, cb);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (res.statusCode !== 200) {
|
|
98
|
-
res.resume();
|
|
99
|
-
cb(new Error(`GET ${url} -> HTTP ${res.statusCode}`));
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
const chunks = [];
|
|
103
|
-
res.on('data', (c) => chunks.push(c));
|
|
104
|
-
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
|
105
|
-
res.on('error', cb);
|
|
106
|
-
});
|
|
107
|
-
req.on('error', cb);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function fetchBufferAsync(url) {
|
|
111
|
-
return new Promise((resolve, reject) => {
|
|
112
|
-
fetchBuffer(url, 5, (err, buf) => (err ? reject(err) : resolve(buf)));
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Best-effort checksum verification. If checksums.txt can't be fetched (e.g.
|
|
117
|
-
// older release, network hiccup), we proceed without failing the install.
|
|
118
|
-
async function verifyChecksum(version, assetBuf, asset) {
|
|
119
|
-
let checksumsBuf;
|
|
120
|
-
try {
|
|
121
|
-
checksumsBuf = await fetchBufferAsync(releaseUrl(version, 'checksums.txt'));
|
|
122
|
-
} catch (err) {
|
|
123
|
-
console.warn('routsi postinstall: could not fetch checksums.txt (' + err.message + '), skipping verification');
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const text = checksumsBuf.toString('utf8');
|
|
127
|
-
const line = text.split('\n').find((l) => l.trim().endsWith(asset));
|
|
128
|
-
if (!line) {
|
|
129
|
-
console.warn('routsi postinstall: no checksum entry for ' + asset + ', skipping verification');
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const expected = line.trim().split(/\s+/)[0].toLowerCase();
|
|
133
|
-
const actual = crypto.createHash('sha256').update(assetBuf).digest('hex');
|
|
134
|
-
if (expected !== actual) {
|
|
135
|
-
fail(`checksum mismatch for ${asset}: expected ${expected}, got ${actual}`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function extractTarGz(buf, destDir) {
|
|
140
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
141
|
-
const tmpTar = path.join(os.tmpdir(), `routsi-${Date.now()}.tar.gz`);
|
|
142
|
-
fs.writeFileSync(tmpTar, buf);
|
|
143
|
-
try {
|
|
144
|
-
execFileSync('tar', ['-xzf', tmpTar, '-C', destDir], { stdio: 'inherit' });
|
|
145
|
-
} finally {
|
|
146
|
-
fs.rmSync(tmpTar, { force: true });
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function extractZip(buf, destDir) {
|
|
151
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
152
|
-
const tmpZip = path.join(os.tmpdir(), `routsi-${Date.now()}.zip`);
|
|
153
|
-
fs.writeFileSync(tmpZip, buf);
|
|
154
|
-
try {
|
|
155
|
-
if (process.platform === 'win32') {
|
|
156
|
-
execFileSync('powershell.exe', [
|
|
157
|
-
'-NoProfile', '-Command',
|
|
158
|
-
`Expand-Archive -Force -Path "${tmpZip}" -DestinationPath "${destDir}"`,
|
|
159
|
-
], { stdio: 'inherit' });
|
|
160
|
-
} else {
|
|
161
|
-
execFileSync('unzip', ['-o', tmpZip, '-d', destDir], { stdio: 'inherit' });
|
|
162
|
-
}
|
|
163
|
-
} finally {
|
|
164
|
-
fs.rmSync(tmpZip, { force: true });
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Locate the extracted binary — tarball/zip is flat (no top-level dir), but
|
|
169
|
-
// tolerate one either way.
|
|
170
|
-
function findExtractedBinary(destDir, wantName) {
|
|
171
|
-
const direct = path.join(destDir, wantName);
|
|
172
|
-
if (fs.existsSync(direct)) return direct;
|
|
173
|
-
const entries = fs.readdirSync(destDir, { withFileTypes: true });
|
|
174
|
-
for (const e of entries) {
|
|
175
|
-
if (e.isDirectory()) {
|
|
176
|
-
const nested = path.join(destDir, e.name, wantName);
|
|
177
|
-
if (fs.existsSync(nested)) return nested;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function main() {
|
|
184
|
-
const platform = process.platform;
|
|
185
|
-
const arch = process.arch;
|
|
186
|
-
const version = pkgVersion();
|
|
187
|
-
const finalBinPath = path.join(BIN_DIR, binaryName(platform));
|
|
188
|
-
|
|
189
|
-
if (fs.existsSync(finalBinPath) && fs.statSync(finalBinPath).size > 0) {
|
|
190
|
-
console.log('routsi postinstall: binary already present at ' + finalBinPath + ', skipping download');
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (isOffline()) {
|
|
195
|
-
warnSkip('offline mode requested (npm_config_offline)');
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
let asset;
|
|
200
|
-
try {
|
|
201
|
-
asset = assetName(platform, arch, version);
|
|
202
|
-
} catch (err) {
|
|
203
|
-
fail(
|
|
204
|
-
`${err.message}. routsi ships prebuilt binaries for darwin/linux/windows on amd64/arm64.\n` +
|
|
205
|
-
'Build from source instead: https://github.com/' + REPO
|
|
4
|
+
// Best-effort prefetch. This is NOT relied upon to guarantee the binary —
|
|
5
|
+
// npm/launcher.js does that on every `routsi` invocation (see
|
|
6
|
+
// npm/resolve-binary.js). This script only tries to warm the cache at
|
|
7
|
+
// install time so the first `routsi` run doesn't pay the download latency,
|
|
8
|
+
// and it must NEVER fail `npm install` — that would break under
|
|
9
|
+
// `--ignore-scripts`/`allow-scripts` blocking or a flaky network, both of
|
|
10
|
+
// which are fine: the launcher self-heals on first run regardless.
|
|
11
|
+
|
|
12
|
+
const { ensureBinary } = require('../npm/resolve-binary');
|
|
13
|
+
|
|
14
|
+
ensureBinary({ quiet: false })
|
|
15
|
+
.then((binPath) => {
|
|
16
|
+
console.log('routsi postinstall: binary ready at ' + binPath);
|
|
17
|
+
})
|
|
18
|
+
.catch((err) => {
|
|
19
|
+
console.warn(
|
|
20
|
+
'routsi postinstall: could not prefetch the native binary (' +
|
|
21
|
+
(err && err.message ? err.message : String(err)) +
|
|
22
|
+
').'
|
|
206
23
|
);
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
let assetBuf;
|
|
214
|
-
try {
|
|
215
|
-
assetBuf = await fetchBufferAsync(url);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
fail(`failed to download ${url}: ${err.message}`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
await verifyChecksum(version, assetBuf, asset);
|
|
222
|
-
|
|
223
|
-
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'routsi-extract-'));
|
|
224
|
-
try {
|
|
225
|
-
if (asset.endsWith('.zip')) {
|
|
226
|
-
extractZip(assetBuf, extractDir);
|
|
227
|
-
} else {
|
|
228
|
-
extractTarGz(assetBuf, extractDir);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const wantName = binaryName(platform);
|
|
232
|
-
const extracted = findExtractedBinary(extractDir, wantName);
|
|
233
|
-
if (!extracted) {
|
|
234
|
-
fail(`downloaded archive ${asset} did not contain expected binary ${wantName}`);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
239
|
-
fs.copyFileSync(extracted, finalBinPath);
|
|
240
|
-
fs.chmodSync(finalBinPath, 0o755);
|
|
241
|
-
console.log('routsi postinstall: installed binary at ' + finalBinPath);
|
|
242
|
-
} finally {
|
|
243
|
-
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (require.main === module) {
|
|
248
|
-
main().catch((err) => fail(err && err.message ? err.message : String(err)));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
module.exports = { assetName, mapPlatform, mapArch, binaryName, releaseUrl };
|
|
24
|
+
console.warn('routsi: it will be fetched automatically on first `routsi` run instead.');
|
|
25
|
+
})
|
|
26
|
+
.finally(() => {
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// Tiny assertion check for the pure platform->asset mapping in postinstall.js.
|
|
5
|
-
// Run with: node scripts/postinstall.test.js
|
|
6
|
-
|
|
7
|
-
const assert = require('assert');
|
|
8
|
-
const { assetName, binaryName, releaseUrl } = require('./postinstall.js');
|
|
9
|
-
|
|
10
|
-
function eq(actual, expected, label) {
|
|
11
|
-
assert.strictEqual(actual, expected, `${label}: expected ${expected}, got ${actual}`);
|
|
12
|
-
console.log(`ok - ${label}`);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
eq(assetName('darwin', 'arm64', '0.1.0'), 'routsi_darwin_arm64.tar.gz', 'darwin/arm64 asset name');
|
|
16
|
-
eq(assetName('darwin', 'x64', '0.1.0'), 'routsi_darwin_amd64.tar.gz', 'darwin/amd64 asset name');
|
|
17
|
-
eq(assetName('linux', 'x64', '0.1.0'), 'routsi_linux_amd64.tar.gz', 'linux/amd64 asset name');
|
|
18
|
-
eq(assetName('linux', 'arm64', '0.1.0'), 'routsi_linux_arm64.tar.gz', 'linux/arm64 asset name');
|
|
19
|
-
eq(assetName('win32', 'x64', '0.1.0'), 'routsi_windows_amd64.zip', 'windows/amd64 asset name');
|
|
20
|
-
|
|
21
|
-
eq(binaryName('darwin'), 'routsi', 'darwin binary name');
|
|
22
|
-
eq(binaryName('win32'), 'routsi.exe', 'windows binary name');
|
|
23
|
-
|
|
24
|
-
eq(
|
|
25
|
-
releaseUrl('0.1.0', 'routsi_linux_amd64.tar.gz'),
|
|
26
|
-
'https://github.com/muthuishere/routsi/releases/download/v0.1.0/routsi_linux_amd64.tar.gz',
|
|
27
|
-
'release URL construction'
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
assert.throws(() => assetName('sunos', 'x64', '0.1.0'), /unsupported platform\/arch/, 'unsupported platform throws');
|
|
31
|
-
assert.throws(() => assetName('linux', 'ia32', '0.1.0'), /unsupported platform\/arch/, 'unsupported arch throws');
|
|
32
|
-
|
|
33
|
-
console.log('\nall postinstall mapping checks passed');
|