@syver_august/vowp 0.0.0-stage → 0.2.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 +115 -2
- package/bin/vowp.js +52 -0
- package/lib/download.js +145 -0
- package/lib/install.js +155 -0
- package/lib/tar.js +157 -0
- package/lib/targets.js +64 -0
- package/package.json +48 -4
- package/scripts/postinstall.js +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syver-August Meyer
|
|
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
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vowp (npm)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The npm package for **VOWP** — a local-first supervisor for AI coding agents.
|
|
4
|
+
It installs the prebuilt `vowp` binary for your platform; Rust is not
|
|
5
|
+
required.
|
|
6
|
+
|
|
7
|
+
> **Status: early release (0.2.0).** The v0.2 command surface is complete, but
|
|
8
|
+
> VOWP is young and interfaces may still change. The source, documentation and
|
|
9
|
+
> issue tracker live at [github.com/syver777/vowp](https://github.com/syver777/vowp).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g @syver_august/vowp
|
|
15
|
+
vowp --version
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or run it without a global install:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx vowp --version
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Node.js 18 or newer. `npm install` runs a postinstall step that
|
|
25
|
+
downloads the archive for the current platform and verifies it before use.
|
|
26
|
+
|
|
27
|
+
## Supported platforms
|
|
28
|
+
|
|
29
|
+
| Platform | Architecture | Release target |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Linux (glibc) | x86_64 | `x86_64-unknown-linux-gnu` |
|
|
32
|
+
| Linux (glibc) | arm64 | `aarch64-unknown-linux-gnu` |
|
|
33
|
+
| macOS | Intel | `x86_64-apple-darwin` |
|
|
34
|
+
| macOS | Apple silicon | `aarch64-apple-darwin` |
|
|
35
|
+
|
|
36
|
+
The Linux archives are built on Ubuntu 22.04 against glibc 2.35; older glibc
|
|
37
|
+
and musl systems such as Alpine are not supported. The macOS archives are not
|
|
38
|
+
code-signed or notarized. Windows is not supported. On any other platform the
|
|
39
|
+
postinstall step stops with an error that names the platform and suggests
|
|
40
|
+
building from source.
|
|
41
|
+
|
|
42
|
+
## How the binary is installed and verified
|
|
43
|
+
|
|
44
|
+
The package version is the release version. For version `X.Y.Z` the postinstall
|
|
45
|
+
step downloads exactly two files:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
https://github.com/syver777/vowp/releases/download/vX.Y.Z/vowp-X.Y.Z-<target>.tar.gz
|
|
49
|
+
https://github.com/syver777/vowp/releases/download/vX.Y.Z/vowp-X.Y.Z-<target>.tar.gz.sha256
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Both are built by the repository's release workflow. The step:
|
|
53
|
+
|
|
54
|
+
1. computes the SHA-256 of the downloaded archive;
|
|
55
|
+
2. compares it with the hash in the `.sha256` file;
|
|
56
|
+
3. fails without installing anything when the hashes differ;
|
|
57
|
+
4. extracts only `vowp-X.Y.Z-<target>/vowp` from the archive, ignoring other
|
|
58
|
+
entries and rejecting absolute or `..` paths;
|
|
59
|
+
5. installs it as `vendor/vowp` inside the package and marks it executable.
|
|
60
|
+
|
|
61
|
+
`bin/vowp.js` then runs that binary with the arguments and standard
|
|
62
|
+
input/output/error inherited from the caller, and exits with the binary's exit
|
|
63
|
+
code. The install step makes no other network calls, and the package has no
|
|
64
|
+
runtime dependencies beyond Node.js built-ins.
|
|
65
|
+
|
|
66
|
+
## Skip the download
|
|
67
|
+
|
|
68
|
+
Set `VOWP_SKIP_DOWNLOAD=1` to install the package without downloading the
|
|
69
|
+
binary:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
VOWP_SKIP_DOWNLOAD=1 npm install -g @syver_august/vowp
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The launcher will then report that the binary is missing until you reinstall
|
|
76
|
+
or run `npm rebuild -g vowp` (global install) / `npm rebuild vowp` (project
|
|
77
|
+
install) without the variable.
|
|
78
|
+
|
|
79
|
+
## Mirrors
|
|
80
|
+
|
|
81
|
+
Point the download at a mirror with `VOWP_RELEASE_BASE_URL`. The mirror must
|
|
82
|
+
serve the same release layout, with the version directory and the archive plus
|
|
83
|
+
its `.sha256` file:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
VOWP_RELEASE_BASE_URL=https://mirror.example/vowp/releases/download \
|
|
87
|
+
npm install -g @syver_august/vowp
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That example resolves to
|
|
91
|
+
`https://mirror.example/vowp/releases/download/vX.Y.Z/vowp-X.Y.Z-<target>.tar.gz`.
|
|
92
|
+
HTTPS is required; plain HTTP is accepted only for loopback hosts, which is
|
|
93
|
+
useful for a local test server. The checksum is always verified, so a mirror
|
|
94
|
+
cannot substitute a different binary without changing the `.sha256` file too.
|
|
95
|
+
|
|
96
|
+
## Troubleshooting
|
|
97
|
+
|
|
98
|
+
Re-run the download after an interrupted install. For a global install:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
npm rebuild -g vowp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For a package installed in a project:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
npm rebuild vowp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If your npm configuration sets `ignore-scripts=true`, lifecycle scripts are
|
|
111
|
+
disabled for that install; enable them or run `scripts/postinstall.js` from the
|
|
112
|
+
installed package directory.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT. See [LICENSE](https://github.com/syver777/vowp/blob/main/LICENSE).
|
package/bin/vowp.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const { targetFor, unsupportedMessage } = require("../lib/targets");
|
|
10
|
+
|
|
11
|
+
const binary = path.join(__dirname, "..", "vendor", "vowp");
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(binary)) {
|
|
14
|
+
const target = targetFor(process.platform, process.arch);
|
|
15
|
+
let message = `vowp: the prebuilt binary is not installed at ${binary}.\n`;
|
|
16
|
+
if (target) {
|
|
17
|
+
message += `Target: ${process.platform}/${process.arch} (${target}).\n`;
|
|
18
|
+
} else {
|
|
19
|
+
message += `${unsupportedMessage(process.platform, process.arch)}\n`;
|
|
20
|
+
}
|
|
21
|
+
message +=
|
|
22
|
+
"Run `npm rebuild -g vowp` after a global install, or reinstall the package,\n" +
|
|
23
|
+
"to download it. If you installed with VOWP_SKIP_DOWNLOAD=1, run the\n" +
|
|
24
|
+
"install again without it.\n";
|
|
25
|
+
process.stderr.write(message);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
30
|
+
|
|
31
|
+
child.on("error", (error) => {
|
|
32
|
+
process.stderr.write(`vowp: cannot run ${binary}: ${error.message}\n`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
37
|
+
process.on(signal, () => {
|
|
38
|
+
try {
|
|
39
|
+
child.kill(signal);
|
|
40
|
+
} catch {
|
|
41
|
+
// The child has already exited; the exit handler sets the final status.
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
child.on("exit", (code, signal) => {
|
|
47
|
+
if (signal) {
|
|
48
|
+
const number = os.constants.signals[signal];
|
|
49
|
+
process.exit(number ? 128 + number : 1);
|
|
50
|
+
}
|
|
51
|
+
process.exit(code === null ? 1 : code);
|
|
52
|
+
});
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const https = require("node:https");
|
|
5
|
+
const zlib = require("node:zlib");
|
|
6
|
+
|
|
7
|
+
const MAX_REDIRECTS = 5;
|
|
8
|
+
const TIMEOUT_MS = 30_000;
|
|
9
|
+
const USER_AGENT = "vowp-npm";
|
|
10
|
+
|
|
11
|
+
function isLoopback(hostname) {
|
|
12
|
+
return (
|
|
13
|
+
hostname === "localhost" ||
|
|
14
|
+
hostname === "127.0.0.1" ||
|
|
15
|
+
hostname === "::1" ||
|
|
16
|
+
hostname === "[::1]"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function checkUrl(value) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`invalid release URL: ${value}`);
|
|
26
|
+
}
|
|
27
|
+
if (url.username || url.password) {
|
|
28
|
+
throw new Error("invalid release URL: credentials are not allowed");
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol === "https:") return url;
|
|
31
|
+
if (url.protocol === "http:" && isLoopback(url.hostname)) return url;
|
|
32
|
+
throw new Error(
|
|
33
|
+
`refusing an insecure release URL: ${value}; use https:// ` +
|
|
34
|
+
"(http:// is allowed only for localhost)",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Download a URL into memory, following redirects.
|
|
40
|
+
*
|
|
41
|
+
* HTTPS is required except for loopback hosts, so tests and local mirrors can
|
|
42
|
+
* use a plain HTTP server. A redirect may not downgrade to an insecure host.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} value absolute URL
|
|
45
|
+
* @param {number} maxBytes maximum accepted body size
|
|
46
|
+
* @returns {Promise<Buffer>}
|
|
47
|
+
*/
|
|
48
|
+
function getBuffer(value, maxBytes) {
|
|
49
|
+
return request(value, maxBytes, MAX_REDIRECTS);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function request(value, maxBytes, redirectsLeft) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = checkUrl(value);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
reject(error);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const client = url.protocol === "https:" ? https : http;
|
|
63
|
+
const req = client.get(
|
|
64
|
+
url,
|
|
65
|
+
{ headers: { "user-agent": USER_AGENT, accept: "*/*" } },
|
|
66
|
+
(response) => {
|
|
67
|
+
const status = response.statusCode || 0;
|
|
68
|
+
const location = response.headers.location;
|
|
69
|
+
|
|
70
|
+
if (status >= 300 && status < 400 && location) {
|
|
71
|
+
response.resume();
|
|
72
|
+
if (redirectsLeft <= 0) {
|
|
73
|
+
reject(new Error(`download failed: too many redirects for ${value}`));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let next;
|
|
77
|
+
try {
|
|
78
|
+
next = new URL(location, url).toString();
|
|
79
|
+
} catch {
|
|
80
|
+
reject(new Error(`download failed: invalid redirect from ${value}`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
request(next, maxBytes, redirectsLeft - 1).then(resolve, reject);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (status !== 200) {
|
|
88
|
+
response.resume();
|
|
89
|
+
reject(new Error(`download failed: ${value} returned HTTP ${status}`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const encoding = String(response.headers["content-encoding"] || "")
|
|
94
|
+
.trim()
|
|
95
|
+
.toLowerCase();
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let total = 0;
|
|
98
|
+
|
|
99
|
+
response.on("data", (chunk) => {
|
|
100
|
+
total += chunk.length;
|
|
101
|
+
if (total > maxBytes) {
|
|
102
|
+
response.destroy(
|
|
103
|
+
new Error(`download is larger than ${maxBytes} bytes: ${value}`),
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
chunks.push(chunk);
|
|
108
|
+
});
|
|
109
|
+
response.on("error", (error) => {
|
|
110
|
+
reject(new Error(`download failed: ${value}: ${error.message}`));
|
|
111
|
+
});
|
|
112
|
+
response.on("end", () => {
|
|
113
|
+
let body = Buffer.concat(chunks);
|
|
114
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
115
|
+
try {
|
|
116
|
+
body = zlib.gunzipSync(body, { maxOutputLength: maxBytes });
|
|
117
|
+
} catch {
|
|
118
|
+
reject(new Error(`download failed: cannot decompress ${value}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
} else if (encoding !== "" && encoding !== "identity") {
|
|
122
|
+
reject(
|
|
123
|
+
new Error(
|
|
124
|
+
`download failed: unsupported content encoding '${encoding}' for ${value}`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
resolve(body);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
req.setTimeout(TIMEOUT_MS, () => {
|
|
135
|
+
req.destroy(
|
|
136
|
+
new Error(`download timed out after ${TIMEOUT_MS / 1000}s: ${value}`),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
req.on("error", (error) => {
|
|
140
|
+
reject(new Error(`download failed: ${value}: ${error.message}`));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { getBuffer, checkUrl };
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const fsp = require("node:fs/promises");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
|
|
8
|
+
const { getBuffer } = require("./download");
|
|
9
|
+
const { extractFile } = require("./tar");
|
|
10
|
+
const { isMusl, targetFor, unsupportedMessage } = require("./targets");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RELEASE_BASE_URL =
|
|
13
|
+
"https://github.com/syver777/vowp/releases/download";
|
|
14
|
+
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
|
15
|
+
const MAX_CHECKSUM_BYTES = 64 * 1024;
|
|
16
|
+
const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
17
|
+
|
|
18
|
+
function readPackage(packageDir) {
|
|
19
|
+
const file = path.join(packageDir, "package.json");
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new Error(`cannot read ${file}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function releaseBaseUrl(env) {
|
|
28
|
+
const value = env.VOWP_RELEASE_BASE_URL || DEFAULT_RELEASE_BASE_URL;
|
|
29
|
+
return value.replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hashesEqual(left, right) {
|
|
33
|
+
const a = Buffer.from(left, "hex");
|
|
34
|
+
const b = Buffer.from(right, "hex");
|
|
35
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseChecksum(text, archiveName) {
|
|
39
|
+
const lines = text
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line !== "");
|
|
43
|
+
const hashes = [];
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const match = /^([0-9a-fA-F]{64})(?:[ \t]+\*?(.+))?$/.exec(line);
|
|
46
|
+
if (!match) continue;
|
|
47
|
+
const file = match[2] ? path.basename(match[2].trim()) : null;
|
|
48
|
+
if (file !== null && file !== archiveName) continue;
|
|
49
|
+
hashes.push(match[1].toLowerCase());
|
|
50
|
+
}
|
|
51
|
+
if (hashes.length !== 1) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`invalid checksum file for ${archiveName}: expected one SHA256 line`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return hashes[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Download, verify and install the prebuilt `vowp` binary into the package's
|
|
61
|
+
* `vendor/` directory.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @param {string} [options.packageDir] package root (default: this module's parent)
|
|
65
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
66
|
+
* @param {string} [options.platform]
|
|
67
|
+
* @param {string} [options.arch]
|
|
68
|
+
* @param {(message: string) => void} [options.log]
|
|
69
|
+
* @returns {Promise<object>} a summary with `skipped: true` when the download was skipped
|
|
70
|
+
*/
|
|
71
|
+
async function install(options = {}) {
|
|
72
|
+
const env = options.env || process.env;
|
|
73
|
+
const platform = options.platform || process.platform;
|
|
74
|
+
const arch = options.arch || process.arch;
|
|
75
|
+
const packageDir = options.packageDir || path.resolve(__dirname, "..");
|
|
76
|
+
const log = options.log || ((message) => process.stdout.write(`vowp: ${message}\n`));
|
|
77
|
+
|
|
78
|
+
if (env.VOWP_SKIP_DOWNLOAD === "1") {
|
|
79
|
+
log("skipping the prebuilt binary download (VOWP_SKIP_DOWNLOAD=1)");
|
|
80
|
+
return { skipped: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (platform === "linux" && isMusl(platform)) {
|
|
84
|
+
throw new Error(unsupportedMessage(platform, arch, { musl: true }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const target = targetFor(platform, arch);
|
|
88
|
+
if (!target) {
|
|
89
|
+
throw new Error(unsupportedMessage(platform, arch));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pkg = readPackage(packageDir);
|
|
93
|
+
const version = options.version || pkg.version;
|
|
94
|
+
if (typeof version !== "string" || !VERSION_PATTERN.test(version)) {
|
|
95
|
+
throw new Error(`invalid package version: ${String(version)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base = releaseBaseUrl(env);
|
|
99
|
+
const archiveName = `vowp-${version}-${target}.tar.gz`;
|
|
100
|
+
const archiveUrl = `${base}/v${version}/${archiveName}`;
|
|
101
|
+
const checksumUrl = `${archiveUrl}.sha256`;
|
|
102
|
+
const archiveRoot = `vowp-${version}-${target}`;
|
|
103
|
+
const binaryEntry = `${archiveRoot}/vowp`;
|
|
104
|
+
|
|
105
|
+
log(`downloading ${checksumUrl}`);
|
|
106
|
+
const checksumText = (
|
|
107
|
+
await getBuffer(checksumUrl, MAX_CHECKSUM_BYTES)
|
|
108
|
+
).toString("utf8");
|
|
109
|
+
const expected = parseChecksum(checksumText, archiveName);
|
|
110
|
+
|
|
111
|
+
log(`downloading ${archiveUrl}`);
|
|
112
|
+
const archive = await getBuffer(archiveUrl, MAX_ARCHIVE_BYTES);
|
|
113
|
+
const actual = crypto.createHash("sha256").update(archive).digest("hex");
|
|
114
|
+
if (!hashesEqual(actual, expected)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`SHA256 mismatch for ${archiveName} (expected ${expected}, got ${actual})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
log(`verified SHA256 ${actual}`);
|
|
120
|
+
|
|
121
|
+
let file;
|
|
122
|
+
try {
|
|
123
|
+
file = extractFile(archive, binaryEntry);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error(`cannot extract ${archiveName}: ${error.message}`);
|
|
126
|
+
}
|
|
127
|
+
const vendorDir = path.join(packageDir, "vendor");
|
|
128
|
+
const binaryPath = path.join(vendorDir, "vowp");
|
|
129
|
+
const temporary = path.join(
|
|
130
|
+
vendorDir,
|
|
131
|
+
`.vowp-${process.pid}-${Date.now().toString(36)}`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await fsp.mkdir(vendorDir, { recursive: true });
|
|
135
|
+
try {
|
|
136
|
+
await fsp.writeFile(temporary, file.data, { mode: 0o755 });
|
|
137
|
+
await fsp.chmod(temporary, 0o755);
|
|
138
|
+
await fsp.rename(temporary, binaryPath);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await fsp.rm(temporary, { force: true }).catch(() => {});
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
await fsp.chmod(binaryPath, 0o755);
|
|
144
|
+
|
|
145
|
+
log(`installed ${binaryPath}`);
|
|
146
|
+
return { skipped: false, version, target, sha256: actual, binary: binaryPath };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
DEFAULT_RELEASE_BASE_URL,
|
|
151
|
+
install,
|
|
152
|
+
parseChecksum,
|
|
153
|
+
readPackage,
|
|
154
|
+
releaseBaseUrl,
|
|
155
|
+
};
|
package/lib/tar.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const zlib = require("node:zlib");
|
|
4
|
+
|
|
5
|
+
const BLOCK_SIZE = 512;
|
|
6
|
+
const MAX_ENTRY_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
const MAX_TAR_BYTES = 512 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function readString(buffer, offset, length) {
|
|
10
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
11
|
+
const end = slice.indexOf(0);
|
|
12
|
+
return (end === -1 ? slice : slice.subarray(0, end)).toString("utf8");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readOctal(buffer, offset, length, what) {
|
|
16
|
+
const text = readString(buffer, offset, length).trim();
|
|
17
|
+
if (text === "") return 0;
|
|
18
|
+
if (!/^[0-7]+$/.test(text)) {
|
|
19
|
+
throw new Error(`invalid tar archive: ${what} is not octal`);
|
|
20
|
+
}
|
|
21
|
+
return Number.parseInt(text, 8);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function verifyChecksum(header) {
|
|
25
|
+
const stored = readOctal(header, 148, 8, "header checksum");
|
|
26
|
+
let sum = 0;
|
|
27
|
+
for (let i = 0; i < BLOCK_SIZE; i += 1) {
|
|
28
|
+
sum += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
29
|
+
}
|
|
30
|
+
if (stored !== sum) {
|
|
31
|
+
throw new Error("invalid tar archive: header checksum mismatch");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isZeroBlock(block) {
|
|
36
|
+
for (const byte of block) {
|
|
37
|
+
if (byte !== 0) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse a POSIX pax extended header ("<length> <key>=<value>\n" records). The
|
|
43
|
+
// release archives only use the path and size records, but values are kept so
|
|
44
|
+
// the caller can apply the ones it understands.
|
|
45
|
+
function parsePax(data) {
|
|
46
|
+
const entries = {};
|
|
47
|
+
let offset = 0;
|
|
48
|
+
while (offset < data.length) {
|
|
49
|
+
const space = data.indexOf(0x20, offset);
|
|
50
|
+
if (space === -1) break;
|
|
51
|
+
const lengthText = data.subarray(offset, space).toString("ascii");
|
|
52
|
+
if (!/^[0-9]+$/.test(lengthText)) {
|
|
53
|
+
throw new Error("invalid tar archive: bad pax header length");
|
|
54
|
+
}
|
|
55
|
+
const length = Number.parseInt(lengthText, 10);
|
|
56
|
+
if (length <= 0 || offset + length > data.length) {
|
|
57
|
+
throw new Error("invalid tar archive: bad pax header record");
|
|
58
|
+
}
|
|
59
|
+
const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
|
|
60
|
+
const equals = record.indexOf("=");
|
|
61
|
+
if (equals !== -1) {
|
|
62
|
+
entries[record.slice(0, equals)] = record.slice(equals + 1);
|
|
63
|
+
}
|
|
64
|
+
offset += length;
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safePath(name) {
|
|
70
|
+
if (name.startsWith("/")) {
|
|
71
|
+
throw new Error(`invalid tar archive: absolute path ${JSON.stringify(name)}`);
|
|
72
|
+
}
|
|
73
|
+
const parts = name.split("/").filter((part) => part !== "" && part !== ".");
|
|
74
|
+
if (parts.some((part) => part === "..")) {
|
|
75
|
+
throw new Error(`invalid tar archive: unsafe path ${JSON.stringify(name)}`);
|
|
76
|
+
}
|
|
77
|
+
if (parts.length === 0) {
|
|
78
|
+
throw new Error("invalid tar archive: empty entry name");
|
|
79
|
+
}
|
|
80
|
+
return parts.join("/");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function joinName(prefix, name) {
|
|
84
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract one regular file from a gzipped tar archive.
|
|
89
|
+
*
|
|
90
|
+
* Only the requested entry is returned; nothing is written to disk. The
|
|
91
|
+
* archive is validated (header checksums and safe entry names) even for entries
|
|
92
|
+
* that are not the requested one.
|
|
93
|
+
*
|
|
94
|
+
* @param {Buffer} archive gzipped tar bytes
|
|
95
|
+
* @param {string} wantedPath normalized path of the file to return
|
|
96
|
+
* @returns {{data: Buffer, mode: number}}
|
|
97
|
+
*/
|
|
98
|
+
function extractFile(archive, wantedPath) {
|
|
99
|
+
let tar;
|
|
100
|
+
try {
|
|
101
|
+
tar = zlib.gunzipSync(archive, { maxOutputLength: MAX_TAR_BYTES });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`invalid tar archive: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
let offset = 0;
|
|
106
|
+
let pendingName = null;
|
|
107
|
+
let pendingPax = null;
|
|
108
|
+
|
|
109
|
+
while (offset + BLOCK_SIZE <= tar.length) {
|
|
110
|
+
const header = tar.subarray(offset, offset + BLOCK_SIZE);
|
|
111
|
+
if (isZeroBlock(header)) break;
|
|
112
|
+
verifyChecksum(header);
|
|
113
|
+
|
|
114
|
+
const size = readOctal(header, 124, 12, "entry size");
|
|
115
|
+
if (size > MAX_ENTRY_BYTES) {
|
|
116
|
+
throw new Error(`invalid tar archive: entry is larger than ${MAX_ENTRY_BYTES} bytes`);
|
|
117
|
+
}
|
|
118
|
+
const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]);
|
|
119
|
+
const dataStart = offset + BLOCK_SIZE;
|
|
120
|
+
const dataEnd = dataStart + size;
|
|
121
|
+
if (dataEnd > tar.length) {
|
|
122
|
+
throw new Error("invalid tar archive: truncated entry");
|
|
123
|
+
}
|
|
124
|
+
const data = tar.subarray(dataStart, dataEnd);
|
|
125
|
+
|
|
126
|
+
if (type === "L") {
|
|
127
|
+
pendingName = readString(data, 0, data.length);
|
|
128
|
+
} else if (type === "x") {
|
|
129
|
+
pendingPax = parsePax(data);
|
|
130
|
+
} else if (type === "g") {
|
|
131
|
+
// Global pax attributes do not affect the entries we need.
|
|
132
|
+
} else {
|
|
133
|
+
const paxPath = pendingPax && pendingPax.path ? pendingPax.path : null;
|
|
134
|
+
const rawName = paxPath || pendingName || joinName(
|
|
135
|
+
readString(header, 345, 155),
|
|
136
|
+
readString(header, 0, 100),
|
|
137
|
+
);
|
|
138
|
+
const name = safePath(rawName);
|
|
139
|
+
if (name === wantedPath) {
|
|
140
|
+
if (type !== "0") {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`invalid tar archive: ${wantedPath} is not a regular file`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return { data: Buffer.from(data), mode: readOctal(header, 100, 8, "mode") };
|
|
146
|
+
}
|
|
147
|
+
pendingName = null;
|
|
148
|
+
pendingPax = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
offset = dataStart + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
throw new Error(`invalid tar archive: it does not contain ${wantedPath}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { extractFile };
|
package/lib/targets.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The four targets that .github/workflows/release.yml builds. The keys are
|
|
4
|
+
// Node's process.platform and process.arch values; the values are the Rust
|
|
5
|
+
// target triples used in the release archive names.
|
|
6
|
+
const TARGETS = Object.freeze({
|
|
7
|
+
"linux-x64": "x86_64-unknown-linux-gnu",
|
|
8
|
+
"linux-arm64": "aarch64-unknown-linux-gnu",
|
|
9
|
+
"darwin-x64": "x86_64-apple-darwin",
|
|
10
|
+
"darwin-arm64": "aarch64-apple-darwin",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const SUPPORTED = Object.freeze(
|
|
14
|
+
Object.entries(TARGETS).map(([id, target]) => {
|
|
15
|
+
const [platform, arch] = id.split("-");
|
|
16
|
+
return Object.freeze({ platform, arch, target });
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return the release target triple for a Node platform/arch pair, or null when
|
|
22
|
+
* the pair has no prebuilt archive.
|
|
23
|
+
*/
|
|
24
|
+
function targetFor(platform, arch) {
|
|
25
|
+
return TARGETS[`${platform}-${arch}`] || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when Node is running against musl libc. The Linux release archives are
|
|
30
|
+
* linked against glibc, so musl systems have no compatible archive.
|
|
31
|
+
*/
|
|
32
|
+
function isMusl(platform = process.platform) {
|
|
33
|
+
if (platform !== "linux") return false;
|
|
34
|
+
if (!process.report || typeof process.report.getReport !== "function") return false;
|
|
35
|
+
try {
|
|
36
|
+
const report = process.report.getReport();
|
|
37
|
+
return Boolean(report && report.header && !report.header.glibcVersionRuntime);
|
|
38
|
+
} catch {
|
|
39
|
+
// Without a report there is no reliable libc answer; assume the common
|
|
40
|
+
// glibc case and let the launcher report an exec failure if it is wrong.
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build the error text used for platforms without a prebuilt archive. */
|
|
46
|
+
function unsupportedMessage(platform, arch, options = {}) {
|
|
47
|
+
const id = `${platform}/${arch}`;
|
|
48
|
+
if (options.musl) {
|
|
49
|
+
return (
|
|
50
|
+
`unsupported platform: ${id} uses musl libc, but the Linux release ` +
|
|
51
|
+
`archives are built against glibc (Ubuntu 22.04, glibc 2.35). ` +
|
|
52
|
+
`Build from source with Cargo instead: https://github.com/syver777/vowp`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const supported = SUPPORTED.map(
|
|
56
|
+
({ platform: p, arch: a, target }) => `${p}/${a} (${target})`,
|
|
57
|
+
).join(", ");
|
|
58
|
+
return (
|
|
59
|
+
`unsupported platform: ${id}; supported platforms: ${supported}. ` +
|
|
60
|
+
`Build from source with Cargo instead: https://github.com/syver777/vowp`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { TARGETS, SUPPORTED, targetFor, isMusl, unsupportedMessage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syver_august/vowp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local-first supervisor for AI coding agents",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"agents",
|
|
8
|
+
"agent-orchestration",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"opencode",
|
|
11
|
+
"supervisor",
|
|
12
|
+
"cli",
|
|
13
|
+
"tmux"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/syver777/vowp#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/syver777/vowp/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/syver777/vowp.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Syver-August Meyer",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"os": [
|
|
29
|
+
"linux",
|
|
30
|
+
"darwin"
|
|
31
|
+
],
|
|
32
|
+
"cpu": [
|
|
33
|
+
"x64",
|
|
34
|
+
"arm64"
|
|
35
|
+
],
|
|
36
|
+
"bin": {
|
|
37
|
+
"vowp": "bin/vowp.js"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"bin/",
|
|
41
|
+
"lib/",
|
|
42
|
+
"scripts/postinstall.js",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"postinstall": "node scripts/postinstall.js",
|
|
48
|
+
"test": "node --test test/*.test.js"
|
|
49
|
+
}
|
|
50
|
+
}
|