curiouspub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/bin/curious.js +98 -0
- package/checksums.json +8 -0
- package/install.js +974 -0
- package/lib/platform.js +83 -0
- package/loopback-hosts.json +4 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Curious Pub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# curiouspub
|
|
2
|
+
|
|
3
|
+
The npm wrapper for [`curious`](https://github.com/curiouspub/cli) — the
|
|
4
|
+
command line tool for [curious.pub](https://curious.pub).
|
|
5
|
+
|
|
6
|
+
**Your agent builds. You approve. It ships.**
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
npx curiouspub deploy
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The package name is `curiouspub`; the command it installs is `curious`.
|
|
13
|
+
They are different names on purpose — `npx curiouspub` is the
|
|
14
|
+
run-without-installing form, and `curious` is what you type once it is
|
|
15
|
+
installed.
|
|
16
|
+
|
|
17
|
+
## What the install does
|
|
18
|
+
|
|
19
|
+
This package contains no binary. Its postinstall script downloads the
|
|
20
|
+
release build for your platform, checks it, and puts it beside itself.
|
|
21
|
+
|
|
22
|
+
Supported: macOS, Linux and Windows, on x64 and arm64. Anything else
|
|
23
|
+
fails the install with a message saying so, rather than succeeding and
|
|
24
|
+
leaving you with a command that is not there.
|
|
25
|
+
|
|
26
|
+
Node 22 or newer. The install script checks that before it does
|
|
27
|
+
anything else, because a version check that runs after the download has
|
|
28
|
+
let the thing it was guarding already happen.
|
|
29
|
+
|
|
30
|
+
## How the download is checked, and what that is worth
|
|
31
|
+
|
|
32
|
+
A postinstall script that downloads and runs a binary is, mechanically,
|
|
33
|
+
what a malicious package does. The difference is entirely in what
|
|
34
|
+
vouches for the bytes, so it is worth stating exactly:
|
|
35
|
+
|
|
36
|
+
- **The digest is inside this package.** `checksums.json` is written
|
|
37
|
+
when the package is published, from the same run that built the
|
|
38
|
+
binaries, and the download is refused unless it matches. So a release
|
|
39
|
+
asset **replaced after publication** fails the check — the digest is
|
|
40
|
+
vouched for by the registry, not by the host serving the download.
|
|
41
|
+
- **That is the whole claim.** It assumes this npm package is itself
|
|
42
|
+
authentic, and it says nothing about a compromised publisher or a
|
|
43
|
+
compromised release run: a run that builds a binary and its checksum
|
|
44
|
+
together will vouch for its own output. That residual is not closable
|
|
45
|
+
by anything a dependency-free install script can do, and this package
|
|
46
|
+
does not pretend otherwise.
|
|
47
|
+
- **A missing digest is refused too**, and it is not the same thing as a
|
|
48
|
+
mismatch: it means the package was built wrong, and it stops before
|
|
49
|
+
anything is downloaded.
|
|
50
|
+
- **The release is also signed** over its checksum file, keylessly, by
|
|
51
|
+
the workflow that produced it. That signature is there for an auditor
|
|
52
|
+
with the verifying tool. This script does not check it and does not
|
|
53
|
+
claim to.
|
|
54
|
+
- **npm provenance** ties the published package to the workflow that
|
|
55
|
+
built it. That is checkable on the registry page.
|
|
56
|
+
|
|
57
|
+
## Zero dependencies, and why
|
|
58
|
+
|
|
59
|
+
The install script uses Node's standard library and nothing else. That
|
|
60
|
+
is part of the argument rather than a preference: a reader can audit the
|
|
61
|
+
whole of `install.js` in one sitting, which is the only real answer to
|
|
62
|
+
"why should I let this run on my machine".
|
|
63
|
+
|
|
64
|
+
The same reasoning is why the proxy support is a hand-written tunnel
|
|
65
|
+
rather than a package.
|
|
66
|
+
|
|
67
|
+
## Behind a proxy
|
|
68
|
+
|
|
69
|
+
`npm_config_https_proxy` and `npm_config_proxy` are read first, then
|
|
70
|
+
`HTTPS_PROXY` and `https_proxy`; `npm_config_no_proxy` and `NO_PROXY`
|
|
71
|
+
are honoured, including the `*` and leading-dot forms.
|
|
72
|
+
|
|
73
|
+
A bypass entry names **one host**, and the leading dot is what widens
|
|
74
|
+
it: `example.com` covers that host alone, while `.example.com` covers it
|
|
75
|
+
and everything under it. An entry may carry a port, and then it matches
|
|
76
|
+
only that port. The distinction decides between a tunnel and a direct
|
|
77
|
+
connection, so it errs towards the proxy: an entry that matched more
|
|
78
|
+
hosts than it names would send the download direct on a machine whose
|
|
79
|
+
policy says tunnel.
|
|
80
|
+
|
|
81
|
+
If a proxy is configured and the tunnel fails, **the install fails and
|
|
82
|
+
names the proxy**. It never quietly connects directly instead: on a
|
|
83
|
+
machine where direct access is blocked that would be a bypass nobody
|
|
84
|
+
asked for.
|
|
85
|
+
|
|
86
|
+
If your proxy inspects TLS, point `NODE_EXTRA_CA_CERTS` at your
|
|
87
|
+
organisation's certificate authority file. That is the supported route,
|
|
88
|
+
and it is the only one — nothing in this package can turn certificate
|
|
89
|
+
verification off.
|
|
90
|
+
|
|
91
|
+
## If the install did not run
|
|
92
|
+
|
|
93
|
+
Installing with `--ignore-scripts` skips the download, and the command
|
|
94
|
+
then says so rather than failing with a stack trace. Install again
|
|
95
|
+
without it, or take a binary from the
|
|
96
|
+
[releases page](https://github.com/curiouspub/cli/releases).
|
|
97
|
+
|
|
98
|
+
## No telemetry
|
|
99
|
+
|
|
100
|
+
None. No analytics, no phone-home, no auto-update, no lifecycle script
|
|
101
|
+
other than the postinstall. The tool talks to the API you point it at
|
|
102
|
+
and to nothing else.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/bin/curious.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The command `curious` becomes once this package is installed. It runs
|
|
5
|
+
// the binary the postinstall downloaded and reports what happened.
|
|
6
|
+
//
|
|
7
|
+
// ON WINDOWS THIS IS THE SECOND OF THREE LAYERS, which is worth knowing
|
|
8
|
+
// before debugging why an argument arrived quoted: npm writes its own
|
|
9
|
+
// .cmd and .ps1 wrappers, those invoke node on this file, and this file
|
|
10
|
+
// spawns curious.exe. The shebang above matters only on Unix.
|
|
11
|
+
//
|
|
12
|
+
// IT FORWARDS NO SIGNALS, and that is a decision rather than an
|
|
13
|
+
// omission. On a POSIX terminal Ctrl-C goes to the foreground process
|
|
14
|
+
// GROUP, which the child has already joined, so forwarding delivers it
|
|
15
|
+
// twice. On Windows the signal argument to kill is ignored and the
|
|
16
|
+
// process is destroyed outright, so "forwarding an interrupt" there is
|
|
17
|
+
// a hard kill wearing a polite name — while a console interrupt already
|
|
18
|
+
// reaches the child as a console event. Both platforms already do the
|
|
19
|
+
// right thing.
|
|
20
|
+
//
|
|
21
|
+
// What it owes instead is the exit story below.
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { spawn } = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
const pkg = require('../package.json');
|
|
29
|
+
const { binaryName } = require('../lib/platform');
|
|
30
|
+
|
|
31
|
+
const root = path.join(__dirname, '..');
|
|
32
|
+
|
|
33
|
+
// The SAME helper the install script renamed the download with. Two
|
|
34
|
+
// expressions for one name is how a package installs successfully and
|
|
35
|
+
// then cannot find what it installed.
|
|
36
|
+
const binary = path.join(root, binaryName(process.platform));
|
|
37
|
+
|
|
38
|
+
// refuse says what is wrong and what to do about it, and never throws.
|
|
39
|
+
// A stack trace here would describe this file to somebody who wants to
|
|
40
|
+
// know why their command did not run.
|
|
41
|
+
function refuse(what) {
|
|
42
|
+
console.error(
|
|
43
|
+
`\ncurious is not installed properly: ${what}\n\n` +
|
|
44
|
+
'The binary is downloaded by this package\'s postinstall script, so this\n' +
|
|
45
|
+
'usually means the install ran with --ignore-scripts.\n\n' +
|
|
46
|
+
'Install again without it:\n\n' +
|
|
47
|
+
' npm install --global curiouspub\n\n' +
|
|
48
|
+
'or take a binary from https://github.com/curiouspub/cli/releases\n');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// THE MARKER IS CHECKED BEFORE THE BINARY IS RUN, and the version it
|
|
53
|
+
// carries is compared rather than merely present. A failed upgrade in a
|
|
54
|
+
// directory that already held an older release leaves that release's
|
|
55
|
+
// binary sitting under the right name; without this the shim would run
|
|
56
|
+
// it and the person would be using a version they did not install.
|
|
57
|
+
let marker = null;
|
|
58
|
+
try {
|
|
59
|
+
marker = JSON.parse(fs.readFileSync(path.join(root, 'installed.json'), 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
marker = null;
|
|
62
|
+
}
|
|
63
|
+
if (!marker || marker.version !== pkg.version) {
|
|
64
|
+
refuse(marker
|
|
65
|
+
? `the binary beside it was installed for version ${marker.version}, ` +
|
|
66
|
+
`and this is ${pkg.version}`
|
|
67
|
+
: 'the postinstall step has not run');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// stdio: 'inherit' IS the forwarding. Input, output and errors are the
|
|
71
|
+
// child's own file descriptors, so nothing here reads or rewrites what
|
|
72
|
+
// passes through — which matters for a tool whose output somebody
|
|
73
|
+
// redirects to a file.
|
|
74
|
+
//
|
|
75
|
+
// shell: false keeps argv exactly as it arrived. A command interpreter
|
|
76
|
+
// would re-parse it, and a project directory with a space in its name
|
|
77
|
+
// would reach the binary as two arguments.
|
|
78
|
+
const child = spawn(binary, process.argv.slice(2), { stdio: 'inherit', shell: false });
|
|
79
|
+
|
|
80
|
+
child.on('error', (err) => {
|
|
81
|
+
if (err.code === 'ENOENT') {
|
|
82
|
+
refuse('the downloaded binary is not there');
|
|
83
|
+
}
|
|
84
|
+
refuse(`the binary could not be started (${err.code || err.message})`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// A process that died of a signal has no exit code. The shell's own
|
|
88
|
+
// convention for that is 128 plus the signal number, and reporting a
|
|
89
|
+
// bare 1 instead loses the difference between "it failed" and "somebody
|
|
90
|
+
// interrupted it".
|
|
91
|
+
child.on('exit', (code, signal) => {
|
|
92
|
+
if (code !== null) {
|
|
93
|
+
process.exitCode = code;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const number = os.constants.signals[signal];
|
|
97
|
+
process.exitCode = number ? 128 + number : 1;
|
|
98
|
+
});
|
package/checksums.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"curious_0.1.0_darwin_amd64.gz": "db1a437b7bafb2422dea4bc8e91babc56d36923614970f90f22f0c7fcc3d1768",
|
|
3
|
+
"curious_0.1.0_darwin_arm64.gz": "af5e75e70603e13f174c8d5a48bba3569f485d83c12736c90b57197dcaf7e00a",
|
|
4
|
+
"curious_0.1.0_linux_amd64.gz": "150d4dd647e815ea18a428a6839aa99092a9e914558f74b1a021c75f8c0523bd",
|
|
5
|
+
"curious_0.1.0_linux_arm64.gz": "0ceaf92b4c4bf0d039676365a91fa358db6af90a1a0cb837dbc472b767d6206b",
|
|
6
|
+
"curious_0.1.0_windows_amd64.gz": "1bdf422f69bf093d519010285818cf5deb9f197ba8efb52cba52093b950d3277",
|
|
7
|
+
"curious_0.1.0_windows_arm64.gz": "d883fa36ae384c9f75d14e77b62e845c72b919c353d2cd73255d3c4b22a95144"
|
|
8
|
+
}
|
package/install.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Fetch the curious binary this machine can run, check it against a
|
|
5
|
+
// digest that shipped inside this package, and put it in place.
|
|
6
|
+
//
|
|
7
|
+
// WHAT VOUCHES FOR WHAT, because a script that downloads and executes a
|
|
8
|
+
// binary is mechanically what a malicious package does, and the whole
|
|
9
|
+
// difference is here:
|
|
10
|
+
//
|
|
11
|
+
// - Transport is TLS to the release host. That says the bytes arrived
|
|
12
|
+
// unaltered from whoever answered.
|
|
13
|
+
// - The SHA-256 comes from checksums.json, which is inside the npm
|
|
14
|
+
// package you already installed. It is written when the package is
|
|
15
|
+
// published, from the same run that built the binaries. So a
|
|
16
|
+
// release asset REPLACED AFTER PUBLICATION fails this check,
|
|
17
|
+
// because the digest is vouched for by the registry rather than by
|
|
18
|
+
// the host serving the download.
|
|
19
|
+
// - That is the whole claim, and it is narrower than it may sound. It
|
|
20
|
+
// assumes this npm package is itself authentic. It says nothing
|
|
21
|
+
// about a compromised publisher or a compromised release run: a run
|
|
22
|
+
// that builds a binary and its checksum together will happily vouch
|
|
23
|
+
// for its own output. That residual is not closable by anything a
|
|
24
|
+
// dependency-free install script can do.
|
|
25
|
+
// - The release is also signed, keylessly, over its checksum file.
|
|
26
|
+
// That signature is for an auditor with the verifying tool. This
|
|
27
|
+
// script does not check it and does not pretend to.
|
|
28
|
+
//
|
|
29
|
+
// NO DEPENDENCIES, and that is part of the argument rather than a
|
|
30
|
+
// preference. Everything here is Node's standard library, so a reader
|
|
31
|
+
// can audit the whole thing in one sitting — which is the only real
|
|
32
|
+
// answer to "why should I let this run on my machine". It is also why
|
|
33
|
+
// the proxy support further down is thirty lines of tunnel rather than
|
|
34
|
+
// a package: there is no built-in proxy support to switch on, so the
|
|
35
|
+
// choice was a dependency tree inside a postinstall or this.
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const http = require('node:http');
|
|
40
|
+
const https = require('node:https');
|
|
41
|
+
const net = require('node:net');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const tls = require('node:tls');
|
|
44
|
+
const zlib = require('node:zlib');
|
|
45
|
+
|
|
46
|
+
const pkg = require('./package.json');
|
|
47
|
+
const platform = require('./lib/platform');
|
|
48
|
+
const loopback = require('./loopback-hosts.json');
|
|
49
|
+
|
|
50
|
+
// Where the release lives. CURIOUS_RELEASE_BASE_URL replaces it, which
|
|
51
|
+
// is what makes this script testable against a server on this machine.
|
|
52
|
+
//
|
|
53
|
+
// EXPOSING THAT OVERRIDE IS SAFE BECAUSE OF WHAT SITS BEHIND IT: the
|
|
54
|
+
// digest is embedded in this package, so pointing the download
|
|
55
|
+
// somewhere else can only ever produce a MISMATCH. There is no origin
|
|
56
|
+
// an attacker can name that makes a substituted binary verify.
|
|
57
|
+
const DEFAULT_RELEASE_BASE = 'https://github.com/curiouspub/cli/releases/download';
|
|
58
|
+
|
|
59
|
+
// Thirty seconds for one whole attempt, redirects included.
|
|
60
|
+
//
|
|
61
|
+
// IT IS A DEADLINE PER ATTEMPT AND NOT PER HOP: a chain of redirects
|
|
62
|
+
// would otherwise multiply a bound nobody chose. It is not a total
|
|
63
|
+
// across attempts either — a retry gets a fresh one, because the thing
|
|
64
|
+
// being bounded is "this fetch is not going to work", and the second
|
|
65
|
+
// fetch is a different fetch.
|
|
66
|
+
//
|
|
67
|
+
// The number is chosen for what it bounds HERE, and deliberately not
|
|
68
|
+
// carried from anywhere else in this project. The asset is a few
|
|
69
|
+
// megabytes; thirty seconds covers it down to roughly a megabit per
|
|
70
|
+
// second, which is the slowest link on which installing a development
|
|
71
|
+
// tool is a reasonable thing to be doing.
|
|
72
|
+
const ATTEMPT_TIMEOUT_MS = 30_000;
|
|
73
|
+
|
|
74
|
+
// Three attempts, and therefore TWO waits — three attempts have two
|
|
75
|
+
// gaps between them, and a third backoff term would never be reached.
|
|
76
|
+
//
|
|
77
|
+
// A second, and then two. It is sized for a delivery-network blip or a
|
|
78
|
+
// failover, which is what a storage host that has just failed is likely
|
|
79
|
+
// to be doing. The quarter-second pause this project uses before
|
|
80
|
+
// re-asking an endpoint that answers in milliseconds is the number NOT
|
|
81
|
+
// to reuse here: right for smoothing a stalled small request, absurd as
|
|
82
|
+
// a pause before re-asking a file server.
|
|
83
|
+
//
|
|
84
|
+
// Worst case is therefore ninety-three seconds. That is derived, not a
|
|
85
|
+
// third constant, and nothing here compares against it.
|
|
86
|
+
const ATTEMPTS = 3;
|
|
87
|
+
const BACKOFF_MS = [1_000, 2_000];
|
|
88
|
+
|
|
89
|
+
// How many redirects one attempt will follow. The release host hands
|
|
90
|
+
// downloads to a storage host, which is one hop; five leaves room for a
|
|
91
|
+
// chain nobody planned while still making a loop terminate, which is
|
|
92
|
+
// the only thing a bound is really for.
|
|
93
|
+
const MAX_REDIRECTS = 5;
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------
|
|
96
|
+
// Stopping.
|
|
97
|
+
// ---------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// Refused carries a message already written for a person. Everything
|
|
100
|
+
// that decides to stop throws one, and exactly one place prints it, so
|
|
101
|
+
// there is a single answer to "what does a failed install look like".
|
|
102
|
+
class Refused extends Error {}
|
|
103
|
+
|
|
104
|
+
// Retryable is a failure worth asking about again. The difference from
|
|
105
|
+
// Refused decides whether the two waits happen at all: a 404 will say
|
|
106
|
+
// the same thing in a second's time, and a reset connection may not.
|
|
107
|
+
class Retryable extends Error {}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------
|
|
110
|
+
// Step 0: the Node floor, before anything else and before any network.
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
// THE DECLARED FLOOR IS THE ONLY FLOOR. It is read out of this
|
|
114
|
+
// package's own engines field rather than written again here, so there
|
|
115
|
+
// is one number and a change to it moves the check with it.
|
|
116
|
+
//
|
|
117
|
+
// engines ALONE IS NOT A FLOOR: npm only warns about it unless the
|
|
118
|
+
// person installing has asked for strictness, so the check has to
|
|
119
|
+
// happen here. And it has to happen FIRST — a version check that runs
|
|
120
|
+
// after the download has let the thing it was guarding already happen.
|
|
121
|
+
function requireNodeFloor() {
|
|
122
|
+
const declared = (pkg.engines && pkg.engines.node) || '';
|
|
123
|
+
const stated = /^>=\s*(\d+)/.exec(declared);
|
|
124
|
+
if (!stated) {
|
|
125
|
+
throw new Refused(
|
|
126
|
+
`This package declares a Node requirement of "${declared}", which this\n` +
|
|
127
|
+
'installer cannot read. That means the package was built wrong rather\n' +
|
|
128
|
+
'than anything about your machine. Please report it.');
|
|
129
|
+
}
|
|
130
|
+
const floor = Number(stated[1]);
|
|
131
|
+
const found = process.versions.node;
|
|
132
|
+
if (Number(found.split('.')[0]) < floor) {
|
|
133
|
+
throw new Refused(
|
|
134
|
+
`curious needs Node ${declared} and this is Node ${found}.\n\n` +
|
|
135
|
+
`Upgrade Node to ${floor} or newer and install again. Nothing has been\n` +
|
|
136
|
+
'downloaded.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------
|
|
141
|
+
// Step 1: what to fetch, and what its digest must be.
|
|
142
|
+
// ---------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function resolveTarget() {
|
|
145
|
+
const asset = platform.assetName(pkg.version, process.platform, process.arch);
|
|
146
|
+
if (!asset) {
|
|
147
|
+
throw new Refused(platform.unsupportedMessage(process.platform, process.arch));
|
|
148
|
+
}
|
|
149
|
+
return { asset, binary: platform.binaryName(process.platform) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// expectedDigest looks the asset up BEFORE any network call.
|
|
153
|
+
//
|
|
154
|
+
// AN ABSENT DIGEST IS NOT A MISMATCH, and it is not the same branch.
|
|
155
|
+
// A table with no entry for this platform means the package was built
|
|
156
|
+
// wrong — a stale file, a generator that emitted different names — and
|
|
157
|
+
// the shape everyone writes by accident, "if there is a digest and it
|
|
158
|
+
// differs, refuse", installs whatever arrived. So a missing entry stops
|
|
159
|
+
// here, naming the key it wanted, before anything is fetched.
|
|
160
|
+
function expectedDigest(asset) {
|
|
161
|
+
const file = path.join(__dirname, 'checksums.json');
|
|
162
|
+
let table;
|
|
163
|
+
try {
|
|
164
|
+
table = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
165
|
+
} catch (err) {
|
|
166
|
+
throw new Refused(
|
|
167
|
+
`The checksum table that ships with this package could not be read:\n\n ${err.message}\n\n` +
|
|
168
|
+
'That is a fault in the package rather than anything about your\n' +
|
|
169
|
+
'machine. Please report it.');
|
|
170
|
+
}
|
|
171
|
+
const digest = table[asset];
|
|
172
|
+
if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
173
|
+
throw new Refused(
|
|
174
|
+
`This package carries no checksum for ${asset}.\n\n` +
|
|
175
|
+
'Every supported platform is meant to have one, so this package was\n' +
|
|
176
|
+
'built wrong and curious will not install a binary it cannot check.\n' +
|
|
177
|
+
'Please report it.');
|
|
178
|
+
}
|
|
179
|
+
return digest;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------
|
|
183
|
+
// The scheme rule. ONE rule, applied to the first address and to every
|
|
184
|
+
// hop after it.
|
|
185
|
+
//
|
|
186
|
+
// It is one rule rather than two on purpose. "https everywhere, except
|
|
187
|
+
// the first address may be loopback http" makes a loopback server that
|
|
188
|
+
// issues a redirect abort on its own redirect — which is the ordinary
|
|
189
|
+
// way anybody would exercise redirects on their own machine.
|
|
190
|
+
// ---------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
// A host is loopback if it is in the set this project keeps in one
|
|
193
|
+
// place, in loopback-hosts.json, which the Go client's own copy is held
|
|
194
|
+
// equal to by a test.
|
|
195
|
+
//
|
|
196
|
+
// THE BRACKETS COME OFF FIRST. A URL parser reports an address literal
|
|
197
|
+
// with its brackets on and the set holds the address without them, so
|
|
198
|
+
// without this an origin the rule means to allow is refused for a
|
|
199
|
+
// reason nobody could see.
|
|
200
|
+
function unbracket(hostname) {
|
|
201
|
+
return hostname.startsWith('[') && hostname.endsWith(']')
|
|
202
|
+
? hostname.slice(1, -1)
|
|
203
|
+
: hostname;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isLoopback(hostname) {
|
|
207
|
+
return loopback.hosts.includes(unbracket(hostname).toLowerCase());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function checkScheme(url, where) {
|
|
211
|
+
if (url.protocol === 'https:') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (url.protocol === 'http:' && isLoopback(url.hostname)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
throw new Refused(
|
|
218
|
+
`${where} is ${url.protocol}//${url.host}, and curious will only download\n` +
|
|
219
|
+
'over https — or over plain http to this machine, which is there for\n' +
|
|
220
|
+
'local development.\n\n' +
|
|
221
|
+
'Nothing has been downloaded.');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function releaseBase() {
|
|
225
|
+
const override = process.env.CURIOUS_RELEASE_BASE_URL;
|
|
226
|
+
if (!override) {
|
|
227
|
+
return DEFAULT_RELEASE_BASE;
|
|
228
|
+
}
|
|
229
|
+
let url;
|
|
230
|
+
try {
|
|
231
|
+
url = new URL(override);
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Refused(
|
|
234
|
+
'CURIOUS_RELEASE_BASE_URL is not a URL. Unset it to use the real release.');
|
|
235
|
+
}
|
|
236
|
+
checkScheme(url, 'The release origin CURIOUS_RELEASE_BASE_URL names');
|
|
237
|
+
return override.replace(/\/+$/, '');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The tag keeps its leading v and the asset name does not. They are the
|
|
241
|
+
// same version in two spellings inside one address, and getting it
|
|
242
|
+
// wrong is a 404 nobody can explain.
|
|
243
|
+
function downloadURL(asset) {
|
|
244
|
+
return `${releaseBase()}/v${pkg.version}/${asset}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------
|
|
248
|
+
// Step 3: the download.
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------
|
|
252
|
+
// Proxies.
|
|
253
|
+
//
|
|
254
|
+
// Node's https client ignores every proxy variable there is: there is
|
|
255
|
+
// no built-in support to switch on, so the choice was a dependency
|
|
256
|
+
// inside a postinstall or the thirty lines below. Given that this
|
|
257
|
+
// script's whole security argument is that a reader can audit it in one
|
|
258
|
+
// sitting, thirty auditable lines beat a dependency tree.
|
|
259
|
+
// ---------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// NPM'S OWN SETTINGS COME FIRST. Somebody who configured npm expects
|
|
262
|
+
// that to be honoured, and npm resolves its own configuration ahead of
|
|
263
|
+
// the ambient environment.
|
|
264
|
+
const PROXY_VARIABLES = [
|
|
265
|
+
'npm_config_https_proxy', 'npm_config_proxy', 'HTTPS_PROXY', 'https_proxy',
|
|
266
|
+
];
|
|
267
|
+
const NO_PROXY_VARIABLES = ['npm_config_no_proxy', 'NO_PROXY', 'no_proxy'];
|
|
268
|
+
|
|
269
|
+
function firstSet(names) {
|
|
270
|
+
for (const name of names) {
|
|
271
|
+
const value = process.env[name];
|
|
272
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
273
|
+
return { name, value: value.trim() };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function defaultPort(url) {
|
|
280
|
+
return url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// splitEntry reads one NO_PROXY entry into a host and an optional port,
|
|
284
|
+
// keeping an address literal's colons out of the port's way.
|
|
285
|
+
function splitEntry(entry) {
|
|
286
|
+
if (entry.startsWith('[')) {
|
|
287
|
+
const close = entry.indexOf(']');
|
|
288
|
+
return {
|
|
289
|
+
host: entry.slice(1, close),
|
|
290
|
+
port: entry.slice(close + 1).replace(/^:/, ''),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const colon = entry.indexOf(':');
|
|
294
|
+
if (colon >= 0 && entry.indexOf(':', colon + 1) < 0) {
|
|
295
|
+
return { host: entry.slice(0, colon), port: entry.slice(colon + 1) };
|
|
296
|
+
}
|
|
297
|
+
return { host: entry, port: '' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Host and domain matching, with the three forms people actually write:
|
|
301
|
+
// a bare host, a leading dot for "and everything under it", and the
|
|
302
|
+
// wildcard that turns proxying off altogether.
|
|
303
|
+
function bypassed(url) {
|
|
304
|
+
const rule = firstSet(NO_PROXY_VARIABLES);
|
|
305
|
+
if (!rule) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
const host = unbracket(url.hostname).toLowerCase();
|
|
309
|
+
const port = defaultPort(url);
|
|
310
|
+
for (const raw of rule.value.split(',')) {
|
|
311
|
+
const entry = raw.trim().toLowerCase();
|
|
312
|
+
if (!entry) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (entry === '*') {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const parsed = splitEntry(entry);
|
|
319
|
+
if (parsed.port && parsed.port !== port) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// THE DOT IS THE WHOLE DIFFERENCE BETWEEN TWO OF THE THREE FORMS,
|
|
323
|
+
// and taking it off before the comparison collapses them: every
|
|
324
|
+
// entry becomes a domain-wide one, so a bare example.com bypasses
|
|
325
|
+
// evil.example.com as well.
|
|
326
|
+
//
|
|
327
|
+
// THAT ERROR RUNS IN THE DANGEROUS DIRECTION. It turns the proxy
|
|
328
|
+
// OFF for hosts nobody exempted, so on a machine whose egress is
|
|
329
|
+
// controlled the download goes direct where the policy said tunnel
|
|
330
|
+
// — the same thing this script refuses to do when a tunnel fails,
|
|
331
|
+
// arriving from the other side: not falling back to direct, but
|
|
332
|
+
// never choosing the proxy at all.
|
|
333
|
+
const under = parsed.host.startsWith('.');
|
|
334
|
+
const wanted = under ? parsed.host.slice(1) : parsed.host;
|
|
335
|
+
if (host === wanted || (under && host.endsWith(`.${wanted}`))) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// maskProxy renders a proxy address for a message. The password is the
|
|
343
|
+
// half that must never be printed; the username stays, because a person
|
|
344
|
+
// reading a failure needs to recognise which setting is in play.
|
|
345
|
+
function maskProxy(url) {
|
|
346
|
+
const shown = new URL(url.toString());
|
|
347
|
+
if (shown.password) {
|
|
348
|
+
shown.password = '***';
|
|
349
|
+
}
|
|
350
|
+
return shown.toString();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function proxyFailure(proxy, why) {
|
|
354
|
+
return (
|
|
355
|
+
`curious is set up to reach the release through a proxy, and ${why}.\n\n` +
|
|
356
|
+
` ${proxy.name}=${maskProxy(proxy.url)}\n\n` +
|
|
357
|
+
'curious will not connect directly instead. On a machine where direct\n' +
|
|
358
|
+
'access is blocked that would be a bypass nobody asked for, and where it\n' +
|
|
359
|
+
'is not, it would hide the real problem behind a slower one.\n\n' +
|
|
360
|
+
'Nothing was downloaded.');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// proxyFor decides, for THIS address, whether a proxy applies. It is
|
|
364
|
+
// asked again on every redirect: a redirect that crosses hosts leaves
|
|
365
|
+
// the old tunnel pointing at the wrong origin.
|
|
366
|
+
//
|
|
367
|
+
// A PERMITTED PLAIN-HTTP ADDRESS IS A PLAIN REQUEST. Tunnelling is for
|
|
368
|
+
// https, and the only http this script will touch at all is loopback.
|
|
369
|
+
function proxyFor(url) {
|
|
370
|
+
if (url.protocol !== 'https:') {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const configured = firstSet(PROXY_VARIABLES);
|
|
374
|
+
if (!configured) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = new URL(configured.value);
|
|
380
|
+
} catch {
|
|
381
|
+
throw new Refused(
|
|
382
|
+
`The proxy address in ${configured.name} is not a URL.\n\n` +
|
|
383
|
+
'Fix it or unset it; curious will not ignore a proxy that was\n' +
|
|
384
|
+
'configured on purpose.');
|
|
385
|
+
}
|
|
386
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
387
|
+
// REFUSED RATHER THAN TREATED AS HTTP. Guessing at a scheme this
|
|
388
|
+
// script does not speak would dial the proxy with the wrong
|
|
389
|
+
// protocol and report whatever came back as a network fault.
|
|
390
|
+
throw new Refused(
|
|
391
|
+
`The proxy address in ${configured.name} uses the ` +
|
|
392
|
+
`${parsed.protocol.replace(':', '')} scheme, which curious does not speak.\n\n` +
|
|
393
|
+
` ${configured.name}=${maskProxy(parsed)}\n\n` +
|
|
394
|
+
'curious tunnels through http and https proxies only. Nothing was\n' +
|
|
395
|
+
'downloaded.');
|
|
396
|
+
}
|
|
397
|
+
if (bypassed(url)) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
return { name: configured.name, url: parsed };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// proxyAuthorization renders the credentials an address carries into the
|
|
404
|
+
// header the proxy expects.
|
|
405
|
+
//
|
|
406
|
+
// A CREDENTIAL THAT CANNOT BE READ IS THE PROXY'S PROBLEM AND NOT THE
|
|
407
|
+
// NETWORK'S. Percent-decoding throws on a half-written escape — a
|
|
408
|
+
// mistyped password, an address edited by hand — and a throw nobody
|
|
409
|
+
// classified is a fault worth trying again, so a person with a typo in
|
|
410
|
+
// their proxy password was told three times over to check their
|
|
411
|
+
// connection, with the proxy named nowhere in it. This package's own
|
|
412
|
+
// rule is that a configured proxy is never quietly ignored and that a
|
|
413
|
+
// failure says which variable was read; an unreadable password is that
|
|
414
|
+
// rule's case exactly.
|
|
415
|
+
//
|
|
416
|
+
// The value is never in the message. maskProxy keeps the username,
|
|
417
|
+
// because a person reading a failure needs to recognise which setting
|
|
418
|
+
// is in play, and replaces the half that must not be printed.
|
|
419
|
+
function proxyAuthorization(proxy) {
|
|
420
|
+
let user;
|
|
421
|
+
let secret;
|
|
422
|
+
try {
|
|
423
|
+
user = decodeURIComponent(proxy.url.username);
|
|
424
|
+
secret = decodeURIComponent(proxy.url.password);
|
|
425
|
+
} catch {
|
|
426
|
+
throw new Refused(proxyFailure(proxy,
|
|
427
|
+
'the credentials in that address cannot be read — a percent sign begins\n' +
|
|
428
|
+
'an escape, and one in there never finishes one'));
|
|
429
|
+
}
|
|
430
|
+
return `Basic ${Buffer.from(`${user}:${secret}`).toString('base64')}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// openTunnel asks the proxy to join us to the target and hands back the
|
|
434
|
+
// raw socket. THE PORT COMES FROM THE ADDRESS rather than a hard-coded
|
|
435
|
+
// 443, and the address literal keeps its brackets here — a CONNECT line
|
|
436
|
+
// is a host and a port, and an unbracketed literal is neither.
|
|
437
|
+
function openTunnel(proxy, target, signal, sockets) {
|
|
438
|
+
return new Promise((resolve, reject) => {
|
|
439
|
+
const module = proxy.url.protocol === 'https:' ? https : http;
|
|
440
|
+
const authority = `${target.hostname}:${defaultPort(target)}`;
|
|
441
|
+
const headers = { host: authority };
|
|
442
|
+
if (proxy.url.username) {
|
|
443
|
+
headers['proxy-authorization'] = proxyAuthorization(proxy);
|
|
444
|
+
}
|
|
445
|
+
// A proxy may itself be reached at an address literal and speak TLS,
|
|
446
|
+
// which is the same identity check one connection earlier.
|
|
447
|
+
const request = module.request(withIdentity({
|
|
448
|
+
host: unbracket(proxy.url.hostname),
|
|
449
|
+
port: defaultPort(proxy.url),
|
|
450
|
+
method: 'CONNECT',
|
|
451
|
+
path: authority,
|
|
452
|
+
headers,
|
|
453
|
+
signal,
|
|
454
|
+
}, unbracket(proxy.url.hostname)));
|
|
455
|
+
request.on('socket', (socket) => sockets.add(socket));
|
|
456
|
+
|
|
457
|
+
// THE STATUS IS CHECKED. Without this a refusal surfaces as a
|
|
458
|
+
// bewildering TLS error about a handshake that never happened,
|
|
459
|
+
// because the bytes of an HTTP error page are not a server hello.
|
|
460
|
+
request.on('connect', (response, socket, head) => {
|
|
461
|
+
sockets.add(socket);
|
|
462
|
+
if (response.statusCode !== 200) {
|
|
463
|
+
socket.destroy();
|
|
464
|
+
reject(new Refused(proxyFailure(proxy,
|
|
465
|
+
`it answered ${response.statusCode} to the request to open a tunnel`)));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (head && head.length) {
|
|
469
|
+
// Bytes the proxy handed back with the response belong to the
|
|
470
|
+
// tunnel, and dropping them corrupts the first record of the
|
|
471
|
+
// TLS handshake.
|
|
472
|
+
socket.unshift(head);
|
|
473
|
+
}
|
|
474
|
+
resolve(socket);
|
|
475
|
+
});
|
|
476
|
+
request.on('response', (response) => {
|
|
477
|
+
discard(response);
|
|
478
|
+
reject(new Refused(proxyFailure(proxy,
|
|
479
|
+
`it answered ${response.statusCode} to the request to open a tunnel`)));
|
|
480
|
+
});
|
|
481
|
+
request.on('error', (err) => reject(new Refused(proxyFailure(proxy,
|
|
482
|
+
`the connection to it failed (${err.code || err.message})`))));
|
|
483
|
+
request.end();
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ADDRESS LITERALS AND THE PLATFORM'S IDENTITY CHECK.
|
|
488
|
+
//
|
|
489
|
+
// A host may be an address rather than a name, and a certificate says
|
|
490
|
+
// so with an IP entry rather than a DNS one. Between Node v22.23.2 and
|
|
491
|
+
// v24.20.0 the platform cannot make that comparison for an IPv6
|
|
492
|
+
// literal: checkServerIdentity normalises the host through IDNA before
|
|
493
|
+
// deciding whether it is an address (v22.23.2 lib/tls.js:408 and :430),
|
|
494
|
+
// and domainToASCII of an IPv6 literal is the empty string — so the
|
|
495
|
+
// certificate's address entries are never consulted, the literal is
|
|
496
|
+
// compared against the DNS names instead, and that question always
|
|
497
|
+
// answers no. Fixed upstream in v24.20.0 (lib/tls.js:434-441, whose
|
|
498
|
+
// comment says exactly this). The v22 line still carries it at
|
|
499
|
+
// v22.23.2, and v22 is this package's declared floor.
|
|
500
|
+
//
|
|
501
|
+
// Measured on one machine over five builds — v22.22.3 accepts,
|
|
502
|
+
// v22.23.2 refuses, v24.19.0 refuses, v24.20.0 accepts, v26.3.0
|
|
503
|
+
// accepts — and printed from inside both CI legs that failed on it.
|
|
504
|
+
//
|
|
505
|
+
// WHICH REAL PATHS PUT AN ADDRESS THERE, since a mechanism in a script
|
|
506
|
+
// somebody is meant to audit has to be reachable by a user and not only
|
|
507
|
+
// by a row. Three, and none of them needs a test harness:
|
|
508
|
+
//
|
|
509
|
+
// 1. CURIOUS_RELEASE_BASE_URL. releaseBase() sends it to checkScheme
|
|
510
|
+
// above, which returns on `https:` and asks nothing about the
|
|
511
|
+
// host — so https://[2001:db8::1]/dl is an accepted release
|
|
512
|
+
// origin, and it reaches TLS at sendGet or, behind a proxy, at
|
|
513
|
+
// secureThrough.
|
|
514
|
+
// 2. THE PROXY VARIABLES — npm_config_https_proxy, npm_config_proxy,
|
|
515
|
+
// HTTPS_PROXY, https_proxy. proxyFor() permits exactly the http
|
|
516
|
+
// and https schemes and likewise asks nothing about the host, so
|
|
517
|
+
// an https proxy given by address reaches TLS at openTunnel. A
|
|
518
|
+
// proxy named by IP rather than by name is ordinary in a corporate
|
|
519
|
+
// network, which makes this the likeliest of the three.
|
|
520
|
+
// 3. A REDIRECT, which is not the user's choice at all. The download
|
|
521
|
+
// host may answer with a Location the loop resolves and follows,
|
|
522
|
+
// and the per-hop checkScheme permits any https host.
|
|
523
|
+
//
|
|
524
|
+
// THE REPAIR CANNOT ACCEPT ANYTHING THE PLATFORM SHOULD REFUSE. It
|
|
525
|
+
// runs only after the platform has already refused, only when the name
|
|
526
|
+
// under check is itself an address, and it hands the comparison to
|
|
527
|
+
// OpenSSL's own iPAddress matching rather than doing string work here
|
|
528
|
+
// — the same comparison the fixed platform makes. On a version without
|
|
529
|
+
// the defect it never runs, because there is no refusal to reconsider.
|
|
530
|
+
function addressIdentity(host) {
|
|
531
|
+
if (!net.isIP(host)) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
return (name, cert) => {
|
|
535
|
+
const refusal = tls.checkServerIdentity(name, cert);
|
|
536
|
+
if (!refusal || !net.isIP(name) || !cert || !cert.raw) {
|
|
537
|
+
return refusal;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
return new crypto.X509Certificate(cert.raw).checkIP(name) ? undefined : refusal;
|
|
541
|
+
} catch {
|
|
542
|
+
// A certificate this platform will not parse is not a reason to
|
|
543
|
+
// overrule it. The refusal stands.
|
|
544
|
+
return refusal;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// withIdentity sets the option only when there is one to set. Node
|
|
550
|
+
// installs its default by spreading a defaults object, so a key that is
|
|
551
|
+
// present and undefined REPLACES the default with nothing and the
|
|
552
|
+
// handshake throws where it should have checked.
|
|
553
|
+
function withIdentity(options, host) {
|
|
554
|
+
const identity = addressIdentity(host);
|
|
555
|
+
if (identity) {
|
|
556
|
+
options.checkServerIdentity = identity;
|
|
557
|
+
}
|
|
558
|
+
return options;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// secureThrough wraps a tunnel in TLS for the real target.
|
|
562
|
+
//
|
|
563
|
+
// THE NAME IS NOT OPTIONAL AND IT IS NOT ALWAYS A NAME. Without a
|
|
564
|
+
// server name the handshake reaches a host that serves many and gets
|
|
565
|
+
// the wrong certificate, which fails in a way that looks like a network
|
|
566
|
+
// fault. But the standard library REFUSES an address literal as a
|
|
567
|
+
// server name — measured, not assumed — because the specification does
|
|
568
|
+
// not permit one, so a literal is passed as the host to check against
|
|
569
|
+
// instead, unbracketed, which is the spelling a certificate carries.
|
|
570
|
+
function secureThrough(tunnel, target, sockets) {
|
|
571
|
+
return new Promise((resolve, reject) => {
|
|
572
|
+
const host = unbracket(target.hostname);
|
|
573
|
+
const options = withIdentity({ socket: tunnel, host }, host);
|
|
574
|
+
if (!net.isIP(host)) {
|
|
575
|
+
options.servername = host;
|
|
576
|
+
}
|
|
577
|
+
const secure = tls.connect(options);
|
|
578
|
+
sockets.add(secure);
|
|
579
|
+
secure.once('secureConnect', () => resolve(secure));
|
|
580
|
+
secure.once('error', reject);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// sendGet issues one request and hands back the response headers. The
|
|
585
|
+
// body is left unread so the caller can decide whether to keep it.
|
|
586
|
+
//
|
|
587
|
+
// A TUNNELLED REQUEST NEEDS AN AGENT rather than a bare option: a
|
|
588
|
+
// client request with an agent asks the agent for its socket, and
|
|
589
|
+
// options.createConnection is only consulted when there is no agent at
|
|
590
|
+
// all — which `agent: false` does not produce, since it quietly makes a
|
|
591
|
+
// fresh one.
|
|
592
|
+
function sendGet(url, signal, sockets, socket) {
|
|
593
|
+
return new Promise((resolve, reject) => {
|
|
594
|
+
const module = url.protocol === 'https:' ? https : http;
|
|
595
|
+
const options = {
|
|
596
|
+
method: 'GET',
|
|
597
|
+
signal,
|
|
598
|
+
headers: { 'user-agent': `curiouspub/${pkg.version}` },
|
|
599
|
+
};
|
|
600
|
+
if (socket) {
|
|
601
|
+
const agent = new https.Agent({ keepAlive: false, maxSockets: 1 });
|
|
602
|
+
agent.createConnection = () => socket;
|
|
603
|
+
options.agent = agent;
|
|
604
|
+
} else if (url.protocol === 'https:') {
|
|
605
|
+
// The direct route reaches TLS here rather than in secureThrough,
|
|
606
|
+
// and an address literal is as legal in the base URL as a name.
|
|
607
|
+
withIdentity(options, unbracket(url.hostname));
|
|
608
|
+
}
|
|
609
|
+
const request = module.request(url, options);
|
|
610
|
+
request.on('socket', (s) => sockets.add(s));
|
|
611
|
+
request.on('response', resolve);
|
|
612
|
+
request.on('error', reject);
|
|
613
|
+
request.end();
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// discard ends a response whose body this script is not going to read.
|
|
618
|
+
//
|
|
619
|
+
// DRAINING IS NOT CLOSING, and the difference is the whole point. A
|
|
620
|
+
// stream set flowing and thrown away is still a stream being read: a
|
|
621
|
+
// host that answers a redirect, or an error, with a body that never
|
|
622
|
+
// ends goes on sending it — down a socket nobody is going to look at —
|
|
623
|
+
// for as long as this process lives, while the download carries on at
|
|
624
|
+
// the next address. Destroying takes the connection with it, which is
|
|
625
|
+
// the only thing that actually stops a sender.
|
|
626
|
+
//
|
|
627
|
+
// It is called for every answer whose body is not wanted, on both
|
|
628
|
+
// paths. The tunnelled path had a socket of its own to destroy and the
|
|
629
|
+
// direct one had nothing, which is exactly the sort of difference that
|
|
630
|
+
// survives review because only half of it is visible in any one place.
|
|
631
|
+
function discard(response) {
|
|
632
|
+
response.destroy();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// readBody collects a response, hashing as it goes, into a temp file in
|
|
636
|
+
// THIS directory.
|
|
637
|
+
//
|
|
638
|
+
// THE TEMP FILE IS HERE, AND UNIQUELY NAMED, for two separate reasons:
|
|
639
|
+
// a rename across devices fails, and two installs running at once are
|
|
640
|
+
// ordinary. It is closed before anything renames it, because on Windows
|
|
641
|
+
// renaming a file something still holds open fails outright — as does
|
|
642
|
+
// renaming one an antivirus scanner has not finished with.
|
|
643
|
+
function readBody(response) {
|
|
644
|
+
return new Promise((resolve, reject) => {
|
|
645
|
+
const file = path.join(
|
|
646
|
+
__dirname, `.curious-download-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
647
|
+
const hash = crypto.createHash('sha256');
|
|
648
|
+
const out = fs.createWriteStream(file);
|
|
649
|
+
let bytes = 0;
|
|
650
|
+
|
|
651
|
+
// ONE OUTCOME, and cleanup that waits for the handle to go.
|
|
652
|
+
//
|
|
653
|
+
// Destroying the stream and removing the file in the next statement
|
|
654
|
+
// is a race with the close. On Windows, removing a file something
|
|
655
|
+
// still holds open fails outright with EPERM — and this runs inside
|
|
656
|
+
// an event handler, so the throw does not become a failed install,
|
|
657
|
+
// it escapes as an unexpected error and leaves BOTH the partial
|
|
658
|
+
// temp file and a stack trace where the honest message should be.
|
|
659
|
+
// That is precisely the "nothing left behind" promise failing, on
|
|
660
|
+
// the one platform nobody writing this can try it on.
|
|
661
|
+
//
|
|
662
|
+
// So: wait for the stream's own close, then remove. And the flag,
|
|
663
|
+
// because more than one thing can fail at once — a reset connection
|
|
664
|
+
// ends the response and the write stream both — and the second
|
|
665
|
+
// arrival must not turn a settled failure into a resolve, nor
|
|
666
|
+
// report a different error than the first one.
|
|
667
|
+
let settled = false;
|
|
668
|
+
|
|
669
|
+
const fail = (err) => {
|
|
670
|
+
if (settled) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
settled = true;
|
|
674
|
+
const reason = err instanceof Retryable ? err : new Retryable(err.message);
|
|
675
|
+
const discardFile = () => {
|
|
676
|
+
try {
|
|
677
|
+
fs.rmSync(file, { force: true });
|
|
678
|
+
} catch {
|
|
679
|
+
// A temp file that refuses to go is not a reason to replace
|
|
680
|
+
// the real failure with a different one. The install still
|
|
681
|
+
// stops, and it stops saying why it stopped.
|
|
682
|
+
}
|
|
683
|
+
reject(reason);
|
|
684
|
+
};
|
|
685
|
+
if (out.closed) {
|
|
686
|
+
discardFile();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
out.once('close', discardFile);
|
|
690
|
+
out.destroy();
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
response.on('data', (chunk) => {
|
|
694
|
+
hash.update(chunk);
|
|
695
|
+
bytes += chunk.length;
|
|
696
|
+
});
|
|
697
|
+
response.on('error', fail);
|
|
698
|
+
out.on('error', fail);
|
|
699
|
+
response.pipe(out);
|
|
700
|
+
out.on('close', () => {
|
|
701
|
+
if (settled) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!response.complete) {
|
|
705
|
+
fail(new Retryable('the connection closed before the whole file arrived'));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
settled = true;
|
|
709
|
+
resolve({ file, digest: hash.digest('hex'), bytes });
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// statusError decides whether an answer is worth asking again for.
|
|
715
|
+
function statusError(status, url) {
|
|
716
|
+
const where = `${url.host}${url.pathname}`;
|
|
717
|
+
if (status >= 500) {
|
|
718
|
+
return new Retryable(`the release host answered ${status} for ${where}`);
|
|
719
|
+
}
|
|
720
|
+
return new Refused(
|
|
721
|
+
`The release host answered ${status} for ${where}.\n\n` +
|
|
722
|
+
(status === 404
|
|
723
|
+
? 'That version of curious has no build for this platform, which usually\n' +
|
|
724
|
+
'means this package and the release have got out of step. Please report it.'
|
|
725
|
+
: 'Nothing was downloaded.'));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The certificate failures whose codes carry no CERT in the name, so
|
|
729
|
+
// the shape rule in translate cannot see them.
|
|
730
|
+
//
|
|
731
|
+
// UNABLE_TO_VERIFY_LEAF_SIGNATURE is what a host that serves its own
|
|
732
|
+
// certificate and nothing beside it produces — the commonest
|
|
733
|
+
// arrangement there is, and exactly what a TLS-inspecting proxy looks
|
|
734
|
+
// like from here. Unclassified it read as a fault worth trying again,
|
|
735
|
+
// so the one failure with a specific way out of it got three attempts
|
|
736
|
+
// and then a message about the network.
|
|
737
|
+
//
|
|
738
|
+
// EVERY ENTRY IS A CODE THIS PACKAGE'S OWN SUITE PRODUCES, from a
|
|
739
|
+
// fixture, rather than a list copied out of the library's headers: a
|
|
740
|
+
// code nobody has seen is a branch nobody has run.
|
|
741
|
+
const CERTIFICATE_CODES = new Set([
|
|
742
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
743
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
744
|
+
]);
|
|
745
|
+
|
|
746
|
+
// translate turns whatever the transport produced into something a
|
|
747
|
+
// person can read, and leaves anything already decided alone.
|
|
748
|
+
//
|
|
749
|
+
// THE CERTIFICATE FAMILY IS CALLED OUT BY NAME because the honest way
|
|
750
|
+
// through it is an environment variable most people have never heard
|
|
751
|
+
// of — and because an install that fails behind an inspecting proxy
|
|
752
|
+
// with no route forward is exactly how somebody ends up reaching for a
|
|
753
|
+
// flag that turns verification off.
|
|
754
|
+
function translate(err, signal) {
|
|
755
|
+
if (err instanceof Refused || err instanceof Retryable) {
|
|
756
|
+
return err;
|
|
757
|
+
}
|
|
758
|
+
if (!(err instanceof Error)) {
|
|
759
|
+
return new Retryable(String(err));
|
|
760
|
+
}
|
|
761
|
+
if (signal.aborted && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
762
|
+
return new Retryable(`nothing arrived within ${ATTEMPT_TIMEOUT_MS / 1000} seconds`);
|
|
763
|
+
}
|
|
764
|
+
// OBSERVED SHAPES ONLY. The codes matched here were produced by real
|
|
765
|
+
// failures against real servers while this was written; keying on a
|
|
766
|
+
// code nobody has seen is how a branch that never runs gets written.
|
|
767
|
+
if (typeof err.code === 'string' &&
|
|
768
|
+
(CERTIFICATE_CODES.has(err.code) ||
|
|
769
|
+
err.code.includes('CERT') || err.code.startsWith('ERR_TLS'))) {
|
|
770
|
+
return new Refused(
|
|
771
|
+
'The TLS certificate the download host presented could not be verified:\n\n' +
|
|
772
|
+
// THE CODE IS PRINTED BESIDE THE SENTENCE. It is the half a person
|
|
773
|
+
// can search for and the half a row can assert on: a message can
|
|
774
|
+
// be reworded by a platform upgrade, and a row that matched only
|
|
775
|
+
// the word "certificate" would pass against a different failure
|
|
776
|
+
// entirely.
|
|
777
|
+
` ${err.code}: ${err.message}\n\n` +
|
|
778
|
+
'If you are behind a proxy that inspects TLS, point NODE_EXTRA_CA_CERTS\n' +
|
|
779
|
+
'at your organisation\'s certificate authority file and install again.\n' +
|
|
780
|
+
'curious will not skip this check.');
|
|
781
|
+
}
|
|
782
|
+
return new Retryable(err.message);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// attemptFetch is ONE attempt: everything from the first request to the
|
|
786
|
+
// bytes on disk, under a single deadline.
|
|
787
|
+
async function attemptFetch(startURL) {
|
|
788
|
+
const controller = new AbortController();
|
|
789
|
+
const sockets = new Set();
|
|
790
|
+
const timer = setTimeout(() => {
|
|
791
|
+
controller.abort();
|
|
792
|
+
// ABORTING THE REQUEST IS NOT ENOUGH ON ITS OWN. A timeout that
|
|
793
|
+
// only stops waiting leaves the attempt's socket open, and a
|
|
794
|
+
// tunnelled socket is not owned by any request at all.
|
|
795
|
+
for (const socket of sockets) {
|
|
796
|
+
socket.destroy();
|
|
797
|
+
}
|
|
798
|
+
}, ATTEMPT_TIMEOUT_MS);
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
let url = new URL(startURL);
|
|
802
|
+
for (let hop = 0; ; hop += 1) {
|
|
803
|
+
checkScheme(url, hop === 0 ? 'The download address' : 'The download was redirected to what');
|
|
804
|
+
|
|
805
|
+
// THE PROXY DECISION IS MADE AGAIN FOR EVERY HOP. A redirect that
|
|
806
|
+
// crosses hosts leaves the previous tunnel serving the wrong
|
|
807
|
+
// origin, and a host the rules exempt may sit at the other end of
|
|
808
|
+
// one that does not.
|
|
809
|
+
const proxy = proxyFor(url);
|
|
810
|
+
const tunnel = proxy
|
|
811
|
+
? await secureThrough(await openTunnel(proxy, url, controller.signal, sockets), url, sockets)
|
|
812
|
+
: null;
|
|
813
|
+
const response = await sendGet(url, controller.signal, sockets, tunnel);
|
|
814
|
+
const status = response.statusCode;
|
|
815
|
+
|
|
816
|
+
if (status >= 300 && status < 400) {
|
|
817
|
+
discard(response);
|
|
818
|
+
if (tunnel) {
|
|
819
|
+
tunnel.destroy();
|
|
820
|
+
}
|
|
821
|
+
if (hop >= MAX_REDIRECTS) {
|
|
822
|
+
throw new Refused(
|
|
823
|
+
`The download was redirected more than ${MAX_REDIRECTS} times, which\n` +
|
|
824
|
+
'means it is going in a circle. Nothing was downloaded.');
|
|
825
|
+
}
|
|
826
|
+
const location = response.headers.location;
|
|
827
|
+
if (!location) {
|
|
828
|
+
throw new Refused(
|
|
829
|
+
`The release host answered ${status} — a redirect — and said nothing\n` +
|
|
830
|
+
'about where to. Nothing was downloaded, and this is not something\n' +
|
|
831
|
+
'trying again will fix. Please report it.');
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
// Resolved against the address that sent it, so a bare path
|
|
835
|
+
// is a redirect within the same host rather than a failure.
|
|
836
|
+
url = new URL(location, url);
|
|
837
|
+
} catch {
|
|
838
|
+
throw new Refused(
|
|
839
|
+
`The release host redirected the download to "${location}", which is\n` +
|
|
840
|
+
'not an address curious can follow. Nothing was downloaded.');
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (status !== 200) {
|
|
846
|
+
discard(response);
|
|
847
|
+
throw statusError(status, url);
|
|
848
|
+
}
|
|
849
|
+
return await readBody(response);
|
|
850
|
+
}
|
|
851
|
+
} catch (err) {
|
|
852
|
+
throw translate(err, controller.signal);
|
|
853
|
+
} finally {
|
|
854
|
+
clearTimeout(timer);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function sleep(ms) {
|
|
859
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// download runs the attempts. A failure that is not worth repeating
|
|
863
|
+
// stops immediately; the rest wait and try again.
|
|
864
|
+
async function download(url) {
|
|
865
|
+
let last;
|
|
866
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
|
|
867
|
+
try {
|
|
868
|
+
return await attemptFetch(url);
|
|
869
|
+
} catch (err) {
|
|
870
|
+
if (!(err instanceof Retryable)) {
|
|
871
|
+
throw err;
|
|
872
|
+
}
|
|
873
|
+
last = err;
|
|
874
|
+
if (attempt < ATTEMPTS) {
|
|
875
|
+
await sleep(BACKOFF_MS[attempt - 1]);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
throw new Refused(
|
|
880
|
+
`curious could not download the binary after ${ATTEMPTS} attempts.\n\n` +
|
|
881
|
+
`The last one ended because ${last.message}.\n\n` +
|
|
882
|
+
'Check your connection and install again. Nothing was left behind.');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ---------------------------------------------------------------------
|
|
886
|
+
// Steps 4 to 7: check it, unpack it, put it in place.
|
|
887
|
+
// ---------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
function verify(downloaded, expected) {
|
|
890
|
+
if (downloaded.digest === expected) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
fs.rmSync(downloaded.file, { force: true });
|
|
894
|
+
throw new Refused(
|
|
895
|
+
'The binary that arrived is not the one this package expected.\n\n' +
|
|
896
|
+
` expected ${expected}\n` +
|
|
897
|
+
` received ${downloaded.digest}\n\n` +
|
|
898
|
+
'That is either a corrupted download or a tampered release asset, and\n' +
|
|
899
|
+
'both deserve a stop rather than an install. Nothing has been written.\n' +
|
|
900
|
+
'Please report it.');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// place decompresses the VERIFIED bytes into a second temp file, closes
|
|
904
|
+
// it, makes it executable, and renames it over whatever was there.
|
|
905
|
+
//
|
|
906
|
+
// THE ORDER MATTERS. Renaming the compressed file into place and
|
|
907
|
+
// decompressing afterwards would leave a window in which the binary's
|
|
908
|
+
// name holds something that is not a binary.
|
|
909
|
+
function place(downloaded, binary) {
|
|
910
|
+
const target = path.join(__dirname, binary);
|
|
911
|
+
const staged = path.join(
|
|
912
|
+
__dirname, `.curious-staged-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
913
|
+
try {
|
|
914
|
+
const bytes = zlib.gunzipSync(fs.readFileSync(downloaded.file));
|
|
915
|
+
fs.writeFileSync(staged, bytes);
|
|
916
|
+
// 0755 rather than 0777: whoever installed it owns it, everyone
|
|
917
|
+
// else may run it. A no-op on Windows, which has no such bits.
|
|
918
|
+
fs.chmodSync(staged, 0o755);
|
|
919
|
+
fs.renameSync(staged, target);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
fs.rmSync(staged, { force: true });
|
|
922
|
+
fs.rmSync(downloaded.file, { force: true });
|
|
923
|
+
throw new Refused(
|
|
924
|
+
`The downloaded binary could not be unpacked into place:\n\n ${err.message}\n\n` +
|
|
925
|
+
'Check that the directory this package was installed into is writable.');
|
|
926
|
+
}
|
|
927
|
+
fs.rmSync(downloaded.file, { force: true });
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The marker is how the shim knows the binary beside it belongs to THIS
|
|
931
|
+
// version of the package.
|
|
932
|
+
//
|
|
933
|
+
// IT IS REMOVED BEFORE ANY WORK AND WRITTEN AFTER ALL OF IT, so a
|
|
934
|
+
// failed upgrade leaves nothing claiming to be current. Without it a
|
|
935
|
+
// half-finished install in a reused directory leaves the previous
|
|
936
|
+
// release's binary sitting there under the right name, and the shim
|
|
937
|
+
// happily runs it.
|
|
938
|
+
const MARKER = 'installed.json';
|
|
939
|
+
|
|
940
|
+
function clearMarker() {
|
|
941
|
+
fs.rmSync(path.join(__dirname, MARKER), { force: true });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function writeMarker(binary) {
|
|
945
|
+
fs.writeFileSync(
|
|
946
|
+
path.join(__dirname, MARKER),
|
|
947
|
+
JSON.stringify({ version: pkg.version, binary }, null, 2) + '\n');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
async function main() {
|
|
953
|
+
requireNodeFloor();
|
|
954
|
+
const target = resolveTarget();
|
|
955
|
+
const expected = expectedDigest(target.asset);
|
|
956
|
+
clearMarker();
|
|
957
|
+
const downloaded = await download(downloadURL(target.asset));
|
|
958
|
+
verify(downloaded, expected);
|
|
959
|
+
place(downloaded, target.binary);
|
|
960
|
+
writeMarker(target.binary);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// THE ONE PLACE A FAILURE IS PRINTED, and it never prints a stack
|
|
964
|
+
// trace: a postinstall failure is read by somebody who typed one
|
|
965
|
+
// command and expected it to work, and a trace tells them about this
|
|
966
|
+
// file rather than about what to do next.
|
|
967
|
+
main().catch((err) => {
|
|
968
|
+
const message = err instanceof Refused
|
|
969
|
+
? err.message
|
|
970
|
+
: `Something went wrong that this installer did not expect:\n\n ${err && err.message}\n\n` +
|
|
971
|
+
'Please report it.';
|
|
972
|
+
console.error(`\ncurious could not be installed.\n\n${message}\n`);
|
|
973
|
+
process.exit(1);
|
|
974
|
+
});
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What Node calls this machine, and what the release calls the file
|
|
4
|
+
// built for it. The two vocabularies differ in two places and agree
|
|
5
|
+
// everywhere else, which is the whole reason this file exists: a
|
|
6
|
+
// mapping written inline at each call site is a mapping that can
|
|
7
|
+
// disagree with itself.
|
|
8
|
+
//
|
|
9
|
+
// ONE HELPER, TWO CONSUMERS, and that is load bearing rather than tidy.
|
|
10
|
+
// The install script renames the downloaded binary to binaryName(); the
|
|
11
|
+
// shim spawns binaryName(). If those were two expressions, the day one
|
|
12
|
+
// of them learned about Windows and the other did not would be the day
|
|
13
|
+
// the package installed successfully and then could not run.
|
|
14
|
+
|
|
15
|
+
// SUPPORTED is the promise this package makes, written out as pairs
|
|
16
|
+
// rather than as two lists crossed together. A cross product says
|
|
17
|
+
// "every combination", which is a claim about combinations nobody
|
|
18
|
+
// built: the release matrix is what decides this, and it is a list of
|
|
19
|
+
// pairs there too.
|
|
20
|
+
const SUPPORTED = [
|
|
21
|
+
{ platform: 'darwin', arch: 'x64' },
|
|
22
|
+
{ platform: 'darwin', arch: 'arm64' },
|
|
23
|
+
{ platform: 'linux', arch: 'x64' },
|
|
24
|
+
{ platform: 'linux', arch: 'arm64' },
|
|
25
|
+
{ platform: 'win32', arch: 'x64' },
|
|
26
|
+
{ platform: 'win32', arch: 'arm64' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The two spellings that differ. Node says win32 for an operating
|
|
30
|
+
// system that has not been 32-bit in twenty years, and x64 for the
|
|
31
|
+
// architecture the build tool calls amd64. Everything else is the same
|
|
32
|
+
// word in both vocabularies, so only the differences are written down —
|
|
33
|
+
// a full table would invite somebody to "correct" an identity mapping
|
|
34
|
+
// and change nothing, or to add a row the release does not build.
|
|
35
|
+
const OS_NAMES = { win32: 'windows' };
|
|
36
|
+
const ARCH_NAMES = { x64: 'amd64' };
|
|
37
|
+
|
|
38
|
+
function isSupported(platform, arch) {
|
|
39
|
+
return SUPPORTED.some((p) => p.platform === platform && p.arch === arch);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// assetName is the name of the single-member gzip the release publishes
|
|
43
|
+
// for this machine, or null when there is none.
|
|
44
|
+
//
|
|
45
|
+
// NULL RATHER THAN A GUESS. A name composed for an unsupported pair
|
|
46
|
+
// would be a request the release host answers with a 404 several
|
|
47
|
+
// seconds later, and the reader would be looking at a network error
|
|
48
|
+
// rather than at the sentence saying their machine is not built for.
|
|
49
|
+
function assetName(version, platform, arch) {
|
|
50
|
+
if (!isSupported(platform, arch)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const os = OS_NAMES[platform] || platform;
|
|
54
|
+
const cpu = ARCH_NAMES[arch] || arch;
|
|
55
|
+
return `curious_${version}_${os}_${cpu}.gz`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// binaryName is what the installed file is called once it is in place.
|
|
59
|
+
//
|
|
60
|
+
// NOTHING HANDS THIS TO US. The archive is a plain gzip, and gunzipping
|
|
61
|
+
// bytes ignores the member name the gzip header may or may not carry —
|
|
62
|
+
// so the destination is derived here, from the platform, or the install
|
|
63
|
+
// succeeds on Windows and leaves a file the shim will never find.
|
|
64
|
+
function binaryName(platform) {
|
|
65
|
+
return platform === 'win32' ? 'curious.exe' : 'curious';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// unsupportedMessage is the refusal. It names what was found, what is
|
|
69
|
+
// supported, and the two ways out, because a person reading it has
|
|
70
|
+
// already run the command that was supposed to work.
|
|
71
|
+
function unsupportedMessage(platform, arch) {
|
|
72
|
+
const supported = SUPPORTED.map((p) => ` ${p.platform}/${p.arch}`).join('\n');
|
|
73
|
+
return (
|
|
74
|
+
`curious has no prebuilt binary for ${platform}/${arch}.\n\n` +
|
|
75
|
+
`Supported:\n${supported}\n\n` +
|
|
76
|
+
'Two ways forward:\n' +
|
|
77
|
+
' - download a binary from the releases page at\n' +
|
|
78
|
+
' https://github.com/curiouspub/cli/releases\n' +
|
|
79
|
+
' - build it yourself: go install github.com/curiouspub/cli/cmd/curious@latest'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { SUPPORTED, assetName, binaryName, unsupportedMessage };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hosts": ["localhost", "127.0.0.1", "::1"],
|
|
3
|
+
"why": "The exact set of hosts this project's clients may reach over plaintext http, and nothing wider. Every other host must arrive over https. It is an exact set rather than a range: no other address in 127.0.0.0/8 is in it. This file is the one place the set is written; the Go client's own copy is held equal to it by a test, so the two cannot drift into being two different rules with one name."
|
|
4
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "curiouspub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pack an Astro project, upload it, and stream the build. Installs the curious CLI for your platform.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://curious.pub",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/curiouspub/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "commonjs",
|
|
12
|
+
"bin": {
|
|
13
|
+
"curious": "bin/curious.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22",
|
|
17
|
+
"npm": ">=10"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"acorn": "8.18.0"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"lib/",
|
|
25
|
+
"install.js",
|
|
26
|
+
"checksums.json",
|
|
27
|
+
"loopback-hosts.json",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"postinstall": "node install.js",
|
|
33
|
+
"test": "node --test test/"
|
|
34
|
+
}
|
|
35
|
+
}
|