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 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
+ });
@@ -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
+ }