@muthuishere/routsi 0.1.0
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/LICENSE +21 -0
- package/README.md +158 -0
- package/npm/launcher.js +33 -0
- package/package.json +36 -0
- package/scripts/postinstall.js +251 -0
- package/scripts/postinstall.test.js +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muthukumaran Navaneethakrishnan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# routsi
|
|
2
|
+
|
|
3
|
+
One OpenAI-compatible endpoint that routes each request to the right **model or
|
|
4
|
+
agent** — API models (OpenRouter, OpenAI, DeepSeek, …) *and* local agent CLIs
|
|
5
|
+
(Devin, Codex, Copilot, Claude Code) — with per-task routing, per-conversation
|
|
6
|
+
stickiness, a live dashboard, and metrics. A single Go binary; point any OpenAI SDK
|
|
7
|
+
at it.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
task build # -> bin/routsi
|
|
13
|
+
cp models.yaml ~/.config/routsi/ # or keep ./models.yaml
|
|
14
|
+
routsi serve # listens on :8080 by default
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Install via npm
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install -g @muthuishere/routsi
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`postinstall` downloads the prebuilt `routsi` binary for your OS/arch from [GitHub
|
|
24
|
+
Releases](https://github.com/muthuishere/routsi/releases) — no Go toolchain needed.
|
|
25
|
+
See [`docs/adr/006-npm-distribution.md`](docs/adr/006-npm-distribution.md) for how it
|
|
26
|
+
works.
|
|
27
|
+
|
|
28
|
+
Point any OpenAI client at `http://localhost:8080/v1`. Then:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
curl localhost:8080/v1/chat/completions -H 'content-type: application/json' \
|
|
32
|
+
-d '{"model":"auto","messages":[{"role":"user","content":"hello"}]}'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **`model: "auto"`** — routsi classifies the task and picks a tier.
|
|
36
|
+
- **`model: "dynamic-1"`** — a named group you define (low/medium/high members).
|
|
37
|
+
- **`model: "openrouter/anthropic/claude-haiku-4.5"`** — a concrete model, no routing.
|
|
38
|
+
- **`model: "devin/opus"`** — an agent as a model.
|
|
39
|
+
|
|
40
|
+
### Forwarding to an agent model (devin/…)
|
|
41
|
+
|
|
42
|
+
Agent CLIs expose their inner models as `<agent>/<model>` catalog entries — from
|
|
43
|
+
`variants:` or discovery (`GET /v1/models` lists them all). Just name one:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
curl localhost:8080/v1/chat/completions -H 'content-type: application/json' \
|
|
47
|
+
-H 'X-Conversation-Id: my-thread-1' \
|
|
48
|
+
-d '{"model":"devin/claude-opus-4.8","messages":[{"role":"user","content":"refactor plan?"}]}'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`devin/claude-opus-4.8` runs `devin --model claude-opus-4.8`; the conversation id maps
|
|
52
|
+
to a real Devin session (`-r` resume on later turns). Bare `devin` uses the default
|
|
53
|
+
model. Same pattern for `codex/…`, `copilot/…`, `claude/…` (one-shot). A dynamic
|
|
54
|
+
group level can point at an agent too: `high: devin/claude-opus-4.8`.
|
|
55
|
+
|
|
56
|
+
## Auth (token or mTLS)
|
|
57
|
+
|
|
58
|
+
Generate tokens with the built-in CLI:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
routsi token -n 2 # prints rtk_... tokens + setup instructions
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For mTLS, generate the whole trust setup (CA + server + client certs) in one command:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
routsi certs # -> ~/.config/routsi/certs/ (keys 0600)
|
|
68
|
+
routsi certs -host myhost.example # extra server SANs
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
auth:
|
|
73
|
+
tokens_env: ROUTSI_TOKENS # export ROUTSI_TOKENS="tok1,tok2" — enables bearer auth
|
|
74
|
+
tls:
|
|
75
|
+
cert: server.crt # cert+key = HTTPS
|
|
76
|
+
key: server.key
|
|
77
|
+
client_ca: clients-ca.crt # + client_ca = mTLS (client certs required)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With tokens on: `/v1/*`, `/stats`, `/metrics` need `Authorization: Bearer <tok>`
|
|
81
|
+
(OpenAI SDKs send this automatically as the api_key); dashboard: `/?token=tok`.
|
|
82
|
+
`/health` stays open. Token values live in env vars, never in the config file.
|
|
83
|
+
|
|
84
|
+
**Secrets via `.env`:** on startup routsi layers env vars, highest priority first:
|
|
85
|
+
process env > `-env`/`ROUTSI_ENV_FILE` > `./.env` > `~/.config/routsi/.env`. A file
|
|
86
|
+
never overrides a higher source and values are never logged — so provider keys and
|
|
87
|
+
`ROUTSI_TOKENS` can live in whichever `.env` suits you instead of your shell profile.
|
|
88
|
+
Keep the file `chmod 600` and out of version control.
|
|
89
|
+
|
|
90
|
+
The chosen model is returned in the `X-Selected-Model` header. Every response
|
|
91
|
+
carries a `usage` block.
|
|
92
|
+
|
|
93
|
+
## Pull-workers: let anyone plug in their agent
|
|
94
|
+
|
|
95
|
+
Instead of routsi hosting an agent CLI, a **remote worker** can join and answer — run
|
|
96
|
+
your own opencode/codex/claude anywhere, register a queue, answer questions the proxy
|
|
97
|
+
routes to it. Credentials never leave your machine; the worker is outbound-only.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
# on the worker machine (any machine with your agent logged in):
|
|
101
|
+
routsi worker run --proxy https://your-proxy:8080 --queue my-agent \
|
|
102
|
+
--agent 'codex exec --skip-git-repo-check -' # reads the question on stdin, prints the answer
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Now `{"model":"my-agent"}` routes to that worker. It's a normal model — shows in
|
|
106
|
+
`/v1/models`, `GET /v1/workers` (live status: state, last-seen, served, errors), and the
|
|
107
|
+
dashboard Workers panel. If no worker is live, requests fast-fail with `503`. No worker
|
|
108
|
+
auth in v1 (reserved `workers.auth` config placeholder). `routsi worker scaffold` prints
|
|
109
|
+
an editable curl-only version.
|
|
110
|
+
|
|
111
|
+
Install the worker as an **agent skill** so any agent session can become a worker:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
routsi install --skills # into ~/.claude/skills and ~/.codex/skills
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Run it as a service (watchdog)
|
|
118
|
+
|
|
119
|
+
Keeps routsi up across crashes and logins — launchd on macOS, systemd-user on Linux:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
routsi install # install + start (restart-on-failure, start-at-login)
|
|
123
|
+
routsi status # is it running?
|
|
124
|
+
routsi uninstall # stop + remove
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
No root; everything lives under your home dir. macOS logs: `~/Library/Logs/routsi.log`.
|
|
128
|
+
|
|
129
|
+
## Dashboard & metrics
|
|
130
|
+
|
|
131
|
+
- **`http://localhost:8080/`** — live dashboard (requests, tokens, latency, routing
|
|
132
|
+
split, escalations), auto-refreshing, self-contained.
|
|
133
|
+
- **`/stats`** — JSON snapshot.
|
|
134
|
+
- **`/metrics`** — Prometheus text (scrape it).
|
|
135
|
+
|
|
136
|
+
## Config (`models.yaml`)
|
|
137
|
+
|
|
138
|
+
See the commented [`models.yaml`](models.yaml). Highlights:
|
|
139
|
+
|
|
140
|
+
- `type: forward` — any OpenAI-compatible upstream (raw byte passthrough).
|
|
141
|
+
- `type: devin|codex|copilot|claude` — local agent CLIs (must be installed + logged in).
|
|
142
|
+
- `type: dynamic` — a virtual model with `levels: {low, medium, high}`.
|
|
143
|
+
- `variants:` / `discover_models: true` — expand one entry into many models. Forwards
|
|
144
|
+
fetch upstream `GET /models`; Devin is probed live; codex/copilot/claude read
|
|
145
|
+
`~/.config/routsi/known-models.json` (editable).
|
|
146
|
+
|
|
147
|
+
## How it differs from LiteLLM / OpenRouter / other gateways
|
|
148
|
+
|
|
149
|
+
Gateways route between *providers serving the same model* (load-balance, fallback).
|
|
150
|
+
routsi routes between *fundamentally different answerers* — API models **and full
|
|
151
|
+
agents** — behind one OpenAI face, choosing by task, sticky per conversation. It's a
|
|
152
|
+
single dependency-light binary you run yourself, not a platform.
|
|
153
|
+
|
|
154
|
+
## Design notes
|
|
155
|
+
|
|
156
|
+
Routing/conversation decisions are grounded in `docs/research/`. Not yet built:
|
|
157
|
+
inbound auth, tool-call passthrough on enveloped paths, escalation memory handoff,
|
|
158
|
+
compaction. See `CLAUDE.md` for current state.
|
package/npm/launcher.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Thin launcher installed as the `routsi` bin entry. Execs the native binary
|
|
5
|
+
// downloaded by scripts/postinstall.js into npm/bin/, passing argv/stdio/exit
|
|
6
|
+
// code straight through. Kept separate from the Go build's own `bin/routsi`
|
|
7
|
+
// output so `task build` (repo checkout) and this npm package never collide.
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const binName = process.platform === 'win32' ? 'routsi.exe' : 'routsi';
|
|
14
|
+
const binPath = path.join(__dirname, 'bin', binName);
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(binPath)) {
|
|
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
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = spawnSync(binPath, process.argv.slice(2), { stdio: 'inherit' });
|
|
27
|
+
|
|
28
|
+
if (result.error) {
|
|
29
|
+
console.error('routsi: failed to launch native binary: ' + result.error.message);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@muthuishere/routsi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenAI-compatible dynamic LLM router — a single Go binary, installed via npm as a prebuilt native binary",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"routsi": "npm/launcher.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"npm",
|
|
11
|
+
"scripts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"postinstall": "node scripts/postinstall.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=16"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/muthuishere/routsi.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://muthuishere.github.io/routsi",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/muthuishere/routsi/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"llm",
|
|
31
|
+
"router",
|
|
32
|
+
"openai",
|
|
33
|
+
"proxy",
|
|
34
|
+
"ai"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Zero-dependency postinstall: downloads the prebuilt routsi binary for this
|
|
5
|
+
// OS/arch from GitHub Releases and drops it at npm/bin/routsi[.exe].
|
|
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
|
+
// Each release also carries a checksums.txt (sha256sum output) covering all assets.
|
|
13
|
+
|
|
14
|
+
const https = require('https');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const zlib = require('zlib');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const REPO = 'muthuishere/routsi';
|
|
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
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const url = releaseUrl(version, asset);
|
|
211
|
+
console.log(`routsi postinstall: downloading ${asset} from ${url}`);
|
|
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 };
|
|
@@ -0,0 +1,33 @@
|
|
|
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');
|