@opencrs/cli 0.0.0-stage → 0.1.0-rc.3
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/.gitignore +2 -0
- package/LICENSE +202 -0
- package/LICENSE-SPEC +396 -0
- package/README.md +138 -2
- package/binary-install.js +348 -0
- package/binary.js +124 -0
- package/completions/_opencrs +156 -0
- package/completions/_opencrs.ps1 +94 -0
- package/completions/opencrs.bash +209 -0
- package/completions/opencrs.fish +57 -0
- package/install.js +4 -0
- package/man/opencrs.1 +312 -0
- package/npm-shrinkwrap.json +52 -0
- package/package.json +127 -3
- package/run-opencrs.js +4 -0
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenCRS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Open Creative Requirements Specification: one machine-readable file, on a publisher's own domain, that says what creative needs in order to work on each of its digital screens.
|
|
4
|
+
|
|
5
|
+
This repository holds the [0.1 specification](OpenCRS%200.1%20Specification.md), its JSON Schemas, worked examples, and a reference validator written in Rust, with a Go package that runs the validator's WebAssembly build.
|
|
6
|
+
|
|
7
|
+
| Path | What's in it |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `schema/0.1/` | JSON Schemas (Draft 2020-12): `index`, `screens`, `creative`, `report`, `rule-pack`, plus `common` definitions |
|
|
10
|
+
| `examples/harbourline/` | The spec's complete example (verbatim), an index, a creative manifest with two PNGs, and the reports the validator writes for them |
|
|
11
|
+
| `crates/opencrs/` | Rust library: schema validation, inheritance resolution, file probing, checks, reports, and discovery and trust (fetching indexes and documents, delegation, partner merging) |
|
|
12
|
+
| `rulepacks/` | Regulatory rule packs (`<name>@<version>.json`), embedded in the validator |
|
|
13
|
+
| `conformance/` | The conformance suite: test cases any validator can run to show it meets Core, Layout or Full |
|
|
14
|
+
| `crates/opencrs-cli/` | The `opencrs` command-line tool |
|
|
15
|
+
| `crates/opencrs-wasm/` | The validator compiled to WebAssembly, for the online checker at [opencrs.dev/check](https://opencrs.dev/check/) |
|
|
16
|
+
| `bindings/` | Python and npm packages over the Rust library |
|
|
17
|
+
| `js/` | Loader, conformance adapter and smoke test for the WebAssembly build |
|
|
18
|
+
| `opencrs.go` | The Go package (`opencrs.dev/go`): the validator's WebAssembly build (`opencrs.wasm`, embedded) behind a Go API |
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
brew install opencrs-org/tap/opencrs # macOS and Linux
|
|
24
|
+
scoop bucket add opencrs https://github.com/OpenCRS-Org/scoop-bucket # Windows
|
|
25
|
+
scoop install opencrs
|
|
26
|
+
npx @opencrs/cli --help # anywhere with Node.js
|
|
27
|
+
docker run --rm -v "$PWD:/work" ghcr.io/opencrs-org/opencrs --help # container
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or the installer script, which puts `opencrs` in `~/.cargo/bin`:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/OpenCRS-Org/opencrs/releases/latest/download/opencrs-cli-installer.sh | sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each [release](https://github.com/OpenCRS-Org/opencrs/releases) also has archives for macOS, Linux (glibc and static musl) and Windows, with checksums and build attestations.
|
|
37
|
+
|
|
38
|
+
## The validator
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
cargo build --release -p opencrs-cli # target/release/opencrs: a single binary; no runtime needed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run `opencrs --help` for the commands and `opencrs COMMAND --help` for a command's options. The single-dash spellings of earlier versions (`-spec`, `-json`, `-allow-local-http`) still work.
|
|
45
|
+
|
|
46
|
+
A man page (`man/opencrs.1`) and shell completions (`completions/`) are generated from the command definition and included in release archives; the Homebrew formula installs them. `opencrs completion bash|zsh|fish|powershell` prints a completion script. After changing the command line, run `cargo xtask generate` and commit the result; a test fails if they are out of date.
|
|
47
|
+
|
|
48
|
+
**Check documents against the schema** (add `--allow-local-http` to test an index that points at `http://localhost`). For a `screens.json`, this also reports screens that name a missing format or venue (`CRS-REFERENCE`) and rules that inheritance has made impossible to meet (`CRS-RULE-CONFLICT`).
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
opencrs schema examples/harbourline/retail.screens.json examples/harbourline/creative.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Check creative against a publisher's requirements**
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
opencrs check --spec examples/harbourline/retail.screens.json examples/harbourline/creative.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
lunch-portrait.png → harbourline.example/retail/portrait-totem-75: FAIL
|
|
62
|
+
warning CRS-LEGIBILITY Legal text is 18 px; 16 px minimum at 2 m, ratio 150. Passes with little margin [HC-L1-001]
|
|
63
|
+
warning CRS-LEGIBILITY Headline text is 92 px; 77 px minimum at 10 m, ratio 150. Passes with little margin [HC-L1-002]
|
|
64
|
+
error CRS-LEGIBILITY Legal text is 18 px; 23 px minimum at 3 m, ratio 150 [HC-L1-002]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The same file passes on one totem and fails on its twin, because the second is seen from further away. That is the point of describing screens as people see them.
|
|
68
|
+
|
|
69
|
+
The validator reads each file's type, pixel size, byte size and SHA-256 (and duration, for MP4) from the file itself. Values the manifest declares are used only when the file isn't available (`--no-files`). Use `--json` to get a JSON array of reports, each in the spec's report format, and `--spec` more than once to check against several publishers. Exit status is 1 if any report fails.
|
|
70
|
+
|
|
71
|
+
**Discover a publisher's documents and check which to trust**
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
opencrs fetch harbourline.example # read the index, fetch every document, follow delegates
|
|
75
|
+
opencrs fetch https://specs.cms-a.example/harbourline/airports.screens.json # does its publisher vouch for this exact URL?
|
|
76
|
+
opencrs fetch --token harbourline.example=TOKEN --output specs harbourline.example # include partner documents and save
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each document is marked `trusted`, `expired`, `rejected`, `skipped` (a partner document without credentials) or `failed`, with the reason. `--output` saves trusted documents byte-for-byte as served, ready for `opencrs check -spec`. When partner documents are included, it also writes a `merged.screens.json` and lists any conflicts. Use `--allow-local-http` to test an index on `http://localhost` before deploying it.
|
|
80
|
+
|
|
81
|
+
`fetch` refuses to connect to loopback, private, link-local, shared (CGNAT), multicast, documentation and other internal addresses, checked after DNS resolution and again on every redirect, so an index can't aim it at a machine's own services or its network. `-allow-local-http` is the opt-in for local testing: it allows `http://` and connections to `localhost`, `127.0.0.1` and `[::1]` only. `fetch` connects directly and ignores `HTTP(S)_PROXY`.
|
|
82
|
+
|
|
83
|
+
Limits: each document is at most 50 MiB (the spec's limit), an index may list at most 1,000 documents and 1,000 delegates, and one run reads at most 512 MiB in all; documents past that are marked `failed`.
|
|
84
|
+
|
|
85
|
+
**See what a screen actually requires after inheritance**
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
opencrs resolve examples/harbourline/retail.screens.json harbourline.example/retail/screen/HC-L1-002
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This prints the merged format, rules and derived values: minimum key and legal cap heights in mm and px, and the word limit.
|
|
92
|
+
|
|
93
|
+
**Checks implemented:** `CRS-PIXELS`, `CRS-MEDIA-TYPE`, `CRS-FILE-SIZE`, `CRS-DURATION`, `CRS-SAFE-AREA`, `CRS-BLOCKED-REGION`, `CRS-LEGIBILITY`, `CRS-MAX-WORDS`, `CRS-REQUIRED-ELEMENT`, `CRS-CATEGORY`, `CRS-MOTION` and `CRS-SPEC-STALE`. That covers all three conformance levels (Core, Layout and Full). Not yet implemented: segment checks for composite surfaces, and audio, codec and colour-space checks.
|
|
94
|
+
|
|
95
|
+
**Regulatory rule packs** are built into the validator (and the browser build) from `rulepacks/`, so a screen that references one, such as `"rule_packs": ["au-nsw-transport-corridor@2017-11"]`, is checked against it with no extra flags. Creative rules give `CRS-MOTION-PROHIBITED`, `CRS-TRAFFIC-DEVICE` (always needs visual review), `CRS-GLANCE-TEXT` and `CRS-SEQUENCING`; playback rules (dwell, transition, failure state, luminance, school zones) appear as `CRS-PLAYBACK` info lines chosen for the screen's speed and lighting zone, and never fail creative. A pack the validator doesn't know is reported `CRS-RULE-PACK` not checked. Packs for NSW, Queensland, WA, SA, Victoria and Tasmania are each checked against the state's own guidelines or planning rules, with page or section citations; all are `draft` except Tasmania's, which stays `unverified` until amendments made after its June 2024 edition are checked. Each pack's `notes` say what it leaves out. The NT and the ACT have no territory-wide rule for digital signs, so they have no pack. Validate a pack with `opencrs schema rulepacks/<pack>.json`.
|
|
96
|
+
|
|
97
|
+
**Run the conformance suite**
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
opencrs conformance # the Rust validator, at the Full level
|
|
101
|
+
opencrs conformance --cmd ./my-adapter.sh # any other validator, through an adapter
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [`conformance/README.md`](conformance/README.md) for the case format, the matching rules and how to write an adapter.
|
|
105
|
+
|
|
106
|
+
## The browser validator
|
|
107
|
+
|
|
108
|
+
`crates/opencrs-wasm` compiles the validator to WebAssembly. The online checker at [opencrs.dev/check](https://opencrs.dev/check/) runs it in the browser, so creative files are never uploaded; its page lives in the site's repository, OpenCRS-Org/opencrs.dev.
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
scripts/build-wasm.sh /tmp/wasm # needs binaryen's wasm-opt; writes opencrs.wasm and opencrs.js
|
|
112
|
+
node js/smoke.mjs /tmp/wasm # the module must behave like the CLI
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The committed `opencrs.wasm` at the repository root is the build of these sources and is what the Go package embeds. rustc's output differs from one host to another, so it is built in a pinned linux/amd64 container (Rust 1.95.0, binaryen 133) with `scripts/build-wasm-docker.sh OUT_DIR` (needs Docker), and CI rebuilds it there and compares byte for byte. A build with `scripts/build-wasm.sh` on your own machine is for testing; commit the container's.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
cargo test --workspace # the Rust library and CLI
|
|
121
|
+
go test -race . # the Go package
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The tests use the spec's own numbers as acceptance criteria: the worked legibility example (46 px and 16 px), text just below a minimum failing rather than rounding up to pass, restrictions that a lower level can't loosen, the 8-word limit from a 4-second dwell, larger text on the further totem, and every JSON example in the spec passing its schema (`crates/opencrs/tests/spec_examples.rs`), with the complete example identical to the copy in `examples/`.
|
|
125
|
+
|
|
126
|
+
The validator used to be written in Go. The last commit that had it is `6784da9`; `crates/opencrs-cli/tests/differential/README.md` explains how to build it and compare it with the Rust CLI.
|
|
127
|
+
|
|
128
|
+
## Licence
|
|
129
|
+
|
|
130
|
+
- **The specification** (`OpenCRS 0.1 Specification.md`): [CC BY 4.0](LICENSE-SPEC).
|
|
131
|
+
- **Everything else**, including the JSON Schemas, examples and code: [Apache 2.0](LICENSE).
|
|
132
|
+
|
|
133
|
+
Anyone can implement OpenCRS, including competitors, as the spec's governance section intends.
|
|
134
|
+
|
|
135
|
+
To contribute, see [CONTRIBUTING.md](CONTRIBUTING.md): commits are signed off under the [Developer Certificate of Origin](DCO). To report a security problem, see [SECURITY.md](SECURITY.md).
|
|
136
|
+
|
|
137
|
+
The Go module path is `opencrs.dev/go`, a vanity import path: `opencrs.dev` tells the `go` tool that the code lives at `github.com/OpenCRS-Org/opencrs`, so hosting can move without breaking imports. Add it with `go get opencrs.dev/go`.
|
|
138
|
+
|
|
139
|
+
**Checking a creative package from someone else.** Every host (the CLI, the npm and Python bindings, and the Go package's `CheckDir`) reads only the files the manifest names, refuses names with `..` or an absolute path, follows symlinks only while they stay inside the package directory, and probes only regular files. In Go, pass `root.FS()` from `os.OpenRoot(dir)` rather than `os.DirFS(dir)`, so the opens themselves are confined too. Two limits remain: a hard link inside the package can't be told from an ordinary file by any host, so unpack untrusted archives without hard links (most extractors have an option; Linux's `protected_hardlinks` also blocks links to other users' files); and a concurrent writer to the directory could swap in a symlink between the check and the open, so check a copy no one else can write to.
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createWriteStream,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
rmSync,
|
|
7
|
+
} = require("fs");
|
|
8
|
+
const { join, sep } = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
const { tmpdir } = require("os");
|
|
11
|
+
|
|
12
|
+
const https = require("node:https");
|
|
13
|
+
const http = require("node:http");
|
|
14
|
+
|
|
15
|
+
const tmpDir = tmpdir();
|
|
16
|
+
|
|
17
|
+
const error = (msg) => {
|
|
18
|
+
console.error(msg);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getProxyForUrl(urlString) {
|
|
23
|
+
const url = new URL(urlString);
|
|
24
|
+
const isHttps = url.protocol === "https:";
|
|
25
|
+
|
|
26
|
+
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
|
|
27
|
+
if (noProxy === "*") return null;
|
|
28
|
+
if (noProxy) {
|
|
29
|
+
const hostname = url.hostname.toLowerCase();
|
|
30
|
+
const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase());
|
|
31
|
+
for (const entry of noProxyList) {
|
|
32
|
+
if (hostname === entry || hostname.endsWith("." + entry)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const proxyEnv = isHttps
|
|
39
|
+
? process.env.HTTPS_PROXY || process.env.https_proxy
|
|
40
|
+
: process.env.HTTP_PROXY || process.env.http_proxy;
|
|
41
|
+
|
|
42
|
+
if (!proxyEnv) return null;
|
|
43
|
+
|
|
44
|
+
const proxyUrl = new URL(proxyEnv);
|
|
45
|
+
|
|
46
|
+
let auth = null;
|
|
47
|
+
if (proxyUrl.username || proxyUrl.password) {
|
|
48
|
+
auth = `${proxyUrl.username}:${proxyUrl.password}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
hostname: proxyUrl.hostname,
|
|
53
|
+
port: proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80),
|
|
54
|
+
auth: auth,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function connectThroughProxy(proxy, target) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const headers = {};
|
|
61
|
+
if (proxy.auth) {
|
|
62
|
+
headers["Proxy-Authorization"] =
|
|
63
|
+
"Basic " + Buffer.from(proxy.auth).toString("base64");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const connectReq = http.request({
|
|
67
|
+
hostname: proxy.hostname,
|
|
68
|
+
port: proxy.port,
|
|
69
|
+
method: "CONNECT",
|
|
70
|
+
path: `${target.hostname}:${target.port || 443}`,
|
|
71
|
+
headers,
|
|
72
|
+
});
|
|
73
|
+
connectReq.on("connect", (res, socket) => {
|
|
74
|
+
if (res.statusCode === 200) {
|
|
75
|
+
resolve(socket);
|
|
76
|
+
} else {
|
|
77
|
+
reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
connectReq.on("error", reject);
|
|
81
|
+
connectReq.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function download(urlString, maxRedirects) {
|
|
86
|
+
if (maxRedirects === undefined) maxRedirects = 5;
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (maxRedirects < 0) {
|
|
89
|
+
return reject(new Error("Too many redirects"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parsed = new URL(urlString);
|
|
93
|
+
const isHttps = parsed.protocol === "https:";
|
|
94
|
+
const mod = isHttps ? https : http;
|
|
95
|
+
const proxy = getProxyForUrl(urlString);
|
|
96
|
+
|
|
97
|
+
const doRequest = (extraOptions) => {
|
|
98
|
+
const options = Object.assign(
|
|
99
|
+
{
|
|
100
|
+
hostname: parsed.hostname,
|
|
101
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
102
|
+
path: parsed.pathname + parsed.search,
|
|
103
|
+
method: "GET",
|
|
104
|
+
headers: { "User-Agent": "cargo-dist-npm-installer" },
|
|
105
|
+
},
|
|
106
|
+
extraOptions || {},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (proxy && !isHttps) {
|
|
110
|
+
// HTTP through HTTP proxy: request the full URL via the proxy
|
|
111
|
+
options.hostname = proxy.hostname;
|
|
112
|
+
options.port = proxy.port;
|
|
113
|
+
options.path = urlString;
|
|
114
|
+
if (proxy.auth) {
|
|
115
|
+
options.headers["Proxy-Authorization"] =
|
|
116
|
+
"Basic " + Buffer.from(proxy.auth).toString("base64");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const req = mod.request(options, (res) => {
|
|
121
|
+
if (
|
|
122
|
+
res.statusCode >= 300 &&
|
|
123
|
+
res.statusCode < 400 &&
|
|
124
|
+
res.headers.location
|
|
125
|
+
) {
|
|
126
|
+
res.resume();
|
|
127
|
+
const nextUrl = new URL(res.headers.location, urlString).toString();
|
|
128
|
+
return download(nextUrl, maxRedirects - 1).then(resolve, reject);
|
|
129
|
+
}
|
|
130
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
131
|
+
res.resume();
|
|
132
|
+
return reject(new Error(`HTTP ${res.statusCode} from ${urlString}`));
|
|
133
|
+
}
|
|
134
|
+
resolve(res);
|
|
135
|
+
});
|
|
136
|
+
req.on("error", reject);
|
|
137
|
+
req.end();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (proxy && isHttps) {
|
|
141
|
+
connectThroughProxy(proxy, parsed).then(
|
|
142
|
+
(socket) => doRequest({ socket, agent: false }),
|
|
143
|
+
reject,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
doRequest();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class Package {
|
|
152
|
+
constructor(platform, name, url, filename, zipExt, binaries) {
|
|
153
|
+
let errors = [];
|
|
154
|
+
if (typeof url !== "string") {
|
|
155
|
+
errors.push("url must be a string");
|
|
156
|
+
} else {
|
|
157
|
+
try {
|
|
158
|
+
new URL(url);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
errors.push(e);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (name && typeof name !== "string") {
|
|
164
|
+
errors.push("package name must be a string");
|
|
165
|
+
}
|
|
166
|
+
if (!name) {
|
|
167
|
+
errors.push("You must specify the name of your package");
|
|
168
|
+
}
|
|
169
|
+
if (binaries && typeof binaries !== "object") {
|
|
170
|
+
errors.push("binaries must be a string => string map");
|
|
171
|
+
}
|
|
172
|
+
if (!binaries) {
|
|
173
|
+
errors.push("You must specify the binaries in the package");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (errors.length > 0) {
|
|
177
|
+
let errorMsg =
|
|
178
|
+
"One or more of the parameters you passed to the Binary constructor are invalid:\n";
|
|
179
|
+
errors.forEach((error) => {
|
|
180
|
+
errorMsg += error;
|
|
181
|
+
});
|
|
182
|
+
errorMsg +=
|
|
183
|
+
'\n\nCorrect usage: new Package("my-binary", "https://example.com/binary/download.tar.gz", {"my-binary": "my-binary"})';
|
|
184
|
+
error(errorMsg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.platform = platform;
|
|
188
|
+
this.url = url;
|
|
189
|
+
this.name = name;
|
|
190
|
+
this.filename = filename;
|
|
191
|
+
this.zipExt = zipExt;
|
|
192
|
+
this.installDirectory = join(__dirname, "node_modules", ".bin_real");
|
|
193
|
+
this.binaries = binaries;
|
|
194
|
+
|
|
195
|
+
if (!existsSync(this.installDirectory)) {
|
|
196
|
+
mkdirSync(this.installDirectory, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
exists() {
|
|
201
|
+
for (const binaryName in this.binaries) {
|
|
202
|
+
const binRelPath = this.binaries[binaryName];
|
|
203
|
+
const binPath = join(this.installDirectory, binRelPath);
|
|
204
|
+
if (!existsSync(binPath)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
install(suppressLogs = false) {
|
|
212
|
+
if (this.exists()) {
|
|
213
|
+
if (!suppressLogs) {
|
|
214
|
+
console.error(
|
|
215
|
+
`${this.name} is already installed, skipping installation.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
rmSync(this.installDirectory, { recursive: true, force: true });
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore - directory may not exist
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
mkdirSync(this.installDirectory, { recursive: true });
|
|
228
|
+
|
|
229
|
+
if (!suppressLogs) {
|
|
230
|
+
console.error(`Downloading release from ${this.url}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return download(this.url)
|
|
234
|
+
.then((res) => {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
mkdtemp(`${tmpDir}${sep}`, (err, directory) => {
|
|
237
|
+
if (err) return reject(err);
|
|
238
|
+
let tempFile = join(directory, this.filename);
|
|
239
|
+
const sink = res.pipe(createWriteStream(tempFile));
|
|
240
|
+
sink.on("error", (err) => reject(err));
|
|
241
|
+
sink.on("close", () => {
|
|
242
|
+
if (/\.tar\.*/.test(this.zipExt)) {
|
|
243
|
+
const result = spawnSync("tar", [
|
|
244
|
+
"xf",
|
|
245
|
+
tempFile,
|
|
246
|
+
// The tarballs are stored with a leading directory
|
|
247
|
+
// component; we strip one component in the
|
|
248
|
+
// shell installers too.
|
|
249
|
+
"--strip-components",
|
|
250
|
+
"1",
|
|
251
|
+
"-C",
|
|
252
|
+
this.installDirectory,
|
|
253
|
+
]);
|
|
254
|
+
if (result.status == 0) {
|
|
255
|
+
resolve();
|
|
256
|
+
} else if (result.error) {
|
|
257
|
+
reject(result.error);
|
|
258
|
+
} else {
|
|
259
|
+
reject(
|
|
260
|
+
new Error(
|
|
261
|
+
`An error occurred untarring the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
} else if (this.zipExt == ".zip") {
|
|
266
|
+
let result;
|
|
267
|
+
if (this.platform.artifactName.includes("windows")) {
|
|
268
|
+
// Windows does not have "unzip" by default on many installations, instead
|
|
269
|
+
// we use Expand-Archive from powershell
|
|
270
|
+
result = spawnSync("powershell.exe", [
|
|
271
|
+
"-NoProfile",
|
|
272
|
+
"-NonInteractive",
|
|
273
|
+
"-Command",
|
|
274
|
+
`& {
|
|
275
|
+
param([string]$LiteralPath, [string]$DestinationPath)
|
|
276
|
+
Expand-Archive -LiteralPath $LiteralPath -DestinationPath $DestinationPath -Force
|
|
277
|
+
}`,
|
|
278
|
+
tempFile,
|
|
279
|
+
this.installDirectory,
|
|
280
|
+
]);
|
|
281
|
+
} else {
|
|
282
|
+
result = spawnSync("unzip", [
|
|
283
|
+
"-q",
|
|
284
|
+
tempFile,
|
|
285
|
+
"-d",
|
|
286
|
+
this.installDirectory,
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status == 0) {
|
|
291
|
+
resolve();
|
|
292
|
+
} else if (result.error) {
|
|
293
|
+
reject(result.error);
|
|
294
|
+
} else {
|
|
295
|
+
reject(
|
|
296
|
+
new Error(
|
|
297
|
+
`An error occurred unzipping the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
reject(
|
|
303
|
+
new Error(`Unrecognized file extension: ${this.zipExt}`),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
})
|
|
310
|
+
.then(() => {
|
|
311
|
+
if (!suppressLogs) {
|
|
312
|
+
console.error(`${this.name} has been installed!`);
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
.catch((e) => {
|
|
316
|
+
error(`Error fetching release: ${e.message}`);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
run(binaryName) {
|
|
321
|
+
const promise = !this.exists() ? this.install(true) : Promise.resolve();
|
|
322
|
+
|
|
323
|
+
promise
|
|
324
|
+
.then(() => {
|
|
325
|
+
const [, , ...args] = process.argv;
|
|
326
|
+
|
|
327
|
+
const options = { cwd: process.cwd(), stdio: "inherit" };
|
|
328
|
+
|
|
329
|
+
const binRelPath = this.binaries[binaryName];
|
|
330
|
+
if (!binRelPath) {
|
|
331
|
+
error(`${binaryName} is not a known binary in ${this.name}`);
|
|
332
|
+
}
|
|
333
|
+
const binPath = join(this.installDirectory, binRelPath);
|
|
334
|
+
const result = spawnSync(binPath, args, options);
|
|
335
|
+
|
|
336
|
+
if (result.error) {
|
|
337
|
+
error(result.error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
process.exit(result.status);
|
|
341
|
+
})
|
|
342
|
+
.catch((e) => {
|
|
343
|
+
error(e.message);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports.Package = Package;
|
package/binary.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { Package } = require("./binary-install");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const libc = require("detect-libc");
|
|
4
|
+
|
|
5
|
+
const error = (msg) => {
|
|
6
|
+
console.error(msg);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
name,
|
|
12
|
+
artifactDownloadUrls,
|
|
13
|
+
supportedPlatforms,
|
|
14
|
+
glibcMinimum,
|
|
15
|
+
} = require("./package.json");
|
|
16
|
+
|
|
17
|
+
// FIXME: implement NPM installer handling of fallback download URLs
|
|
18
|
+
const artifactDownloadUrl = artifactDownloadUrls[0];
|
|
19
|
+
const builderGlibcMajorVersion = glibcMinimum.major;
|
|
20
|
+
const builderGlibcMinorVersion = glibcMinimum.series;
|
|
21
|
+
|
|
22
|
+
const getPlatform = () => {
|
|
23
|
+
const rawOsType = os.type();
|
|
24
|
+
const rawArchitecture = os.arch();
|
|
25
|
+
|
|
26
|
+
// We want to use rust-style target triples as the canonical key
|
|
27
|
+
// for a platform, so translate the "os" library's concepts into rust ones
|
|
28
|
+
let osType = "";
|
|
29
|
+
switch (rawOsType) {
|
|
30
|
+
case "Windows_NT":
|
|
31
|
+
osType = "pc-windows-msvc";
|
|
32
|
+
break;
|
|
33
|
+
case "Darwin":
|
|
34
|
+
osType = "apple-darwin";
|
|
35
|
+
break;
|
|
36
|
+
case "Linux":
|
|
37
|
+
osType = "unknown-linux-gnu";
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let arch = "";
|
|
42
|
+
switch (rawArchitecture) {
|
|
43
|
+
case "x64":
|
|
44
|
+
arch = "x86_64";
|
|
45
|
+
break;
|
|
46
|
+
case "arm64":
|
|
47
|
+
arch = "aarch64";
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (rawOsType === "Linux") {
|
|
52
|
+
if (libc.familySync() == "musl") {
|
|
53
|
+
osType = "unknown-linux-musl-dynamic";
|
|
54
|
+
} else if (libc.isNonGlibcLinuxSync()) {
|
|
55
|
+
console.warn(
|
|
56
|
+
"Your libc is neither glibc nor musl; trying static musl binary instead",
|
|
57
|
+
);
|
|
58
|
+
osType = "unknown-linux-musl-static";
|
|
59
|
+
} else {
|
|
60
|
+
let libcVersion = libc.versionSync();
|
|
61
|
+
let splitLibcVersion = libcVersion.split(".");
|
|
62
|
+
let libcMajorVersion = splitLibcVersion[0];
|
|
63
|
+
let libcMinorVersion = splitLibcVersion[1];
|
|
64
|
+
if (
|
|
65
|
+
libcMajorVersion != builderGlibcMajorVersion ||
|
|
66
|
+
libcMinorVersion < builderGlibcMinorVersion
|
|
67
|
+
) {
|
|
68
|
+
// We can't run the glibc binaries, but we can run the static musl ones
|
|
69
|
+
// if they exist
|
|
70
|
+
console.warn(
|
|
71
|
+
"Your glibc isn't compatible; trying static musl binary instead",
|
|
72
|
+
);
|
|
73
|
+
osType = "unknown-linux-musl-static";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Assume the above succeeded and build a target triple to look things up with.
|
|
79
|
+
// If any of it failed, this lookup will fail and we'll handle it like normal.
|
|
80
|
+
let targetTriple = `${arch}-${osType}`;
|
|
81
|
+
let platform = supportedPlatforms[targetTriple];
|
|
82
|
+
|
|
83
|
+
if (!platform) {
|
|
84
|
+
error(
|
|
85
|
+
`Platform with type "${rawOsType}" and architecture "${rawArchitecture}" is not supported by ${name}.\nYour system must be one of the following:\n\n${Object.keys(
|
|
86
|
+
supportedPlatforms,
|
|
87
|
+
).join(",")}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return platform;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const getPackage = () => {
|
|
95
|
+
const platform = getPlatform();
|
|
96
|
+
const url = `${artifactDownloadUrl}/${platform.artifactName}`;
|
|
97
|
+
let filename = platform.artifactName;
|
|
98
|
+
let ext = platform.zipExt;
|
|
99
|
+
let binary = new Package(platform, name, url, filename, ext, platform.bins);
|
|
100
|
+
|
|
101
|
+
return binary;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const install = (suppressLogs) => {
|
|
105
|
+
if (!artifactDownloadUrl || artifactDownloadUrl.length === 0) {
|
|
106
|
+
console.warn("in demo mode, not installing binaries");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const pkg = getPackage();
|
|
110
|
+
|
|
111
|
+
return pkg.install(suppressLogs);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const run = (binaryName) => {
|
|
115
|
+
const pkg = getPackage();
|
|
116
|
+
|
|
117
|
+
pkg.run(binaryName);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
install,
|
|
122
|
+
run,
|
|
123
|
+
getPackage,
|
|
124
|
+
};
|