@jonaskahn/relo 0.0.0-stage → 0.1.0-beta.15.f766ed9a
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 +96 -2
- package/bin/relo.mjs +42 -0
- package/lib/checksums.mjs +31 -0
- package/lib/conflict.mjs +68 -0
- package/lib/daemon.mjs +280 -0
- package/lib/download.mjs +52 -0
- package/lib/install.mjs +90 -0
- package/lib/platform.mjs +24 -0
- package/lib/release.mjs +55 -0
- package/lib/uninstall.mjs +31 -0
- package/package.json +61 -5
package/README.md
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Relo" width="96">
|
|
2
3
|
|
|
3
|
-
|
|
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
|
+
}
|
package/lib/conflict.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/download.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/platform.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/release.mjs
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@jonaskahn/relo",
|
|
3
|
+
"version": "0.1.0-beta.15.f766ed9a",
|
|
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
|
+
}
|