@liaisonio/cli 0.2.0 → 0.2.2
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/package.json +1 -1
- package/scripts/install.js +195 -60
package/package.json
CHANGED
package/scripts/install.js
CHANGED
|
@@ -11,21 +11,35 @@
|
|
|
11
11
|
// - The user is on an unsupported platform (we exit 0 + warn, NOT fail,
|
|
12
12
|
// so npm install doesn't break for transitive deps)
|
|
13
13
|
//
|
|
14
|
+
// Honours HTTPS_PROXY / HTTP_PROXY env vars for users behind corporate proxies
|
|
15
|
+
// (Node's built-in https.get does NOT honour these out of the box — we handle
|
|
16
|
+
// it manually via a CONNECT tunnel). Retries once on transient network errors.
|
|
17
|
+
//
|
|
14
18
|
// Network failures DO fail the install — silently shipping a broken package
|
|
15
19
|
// is worse than a clear error the user can retry.
|
|
16
20
|
|
|
17
21
|
'use strict';
|
|
18
22
|
|
|
19
23
|
const https = require('https');
|
|
24
|
+
const http = require('http');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
const tls = require('tls');
|
|
20
27
|
const fs = require('fs');
|
|
21
28
|
const path = require('path');
|
|
22
29
|
const crypto = require('crypto');
|
|
30
|
+
const { URL } = require('url');
|
|
23
31
|
|
|
24
32
|
const pkg = require('../package.json');
|
|
25
33
|
const VERSION = `v${pkg.version}`;
|
|
26
34
|
const REPO = 'liaisonio/cli';
|
|
27
35
|
const RELEASE_BASE = `https://github.com/${REPO}/releases/download/${VERSION}`;
|
|
28
36
|
|
|
37
|
+
// How long to wait for a single HTTP round-trip before giving up.
|
|
38
|
+
const SOCKET_TIMEOUT_MS = 30_000;
|
|
39
|
+
// How many times to retry a download after a transient error. Retries are
|
|
40
|
+
// linear-backoff with 2s then 5s gaps.
|
|
41
|
+
const RETRY_DELAYS_MS = [2000, 5000];
|
|
42
|
+
|
|
29
43
|
// process.platform-process.arch → release asset GOOS-GOARCH suffix.
|
|
30
44
|
const PLATFORMS = {
|
|
31
45
|
'darwin-arm64': { os: 'darwin', arch: 'arm64', ext: '' },
|
|
@@ -73,74 +87,186 @@ const destPath = path.join(vendorDir, `liaison${platform.ext}`);
|
|
|
73
87
|
|
|
74
88
|
fs.mkdirSync(vendorDir, { recursive: true });
|
|
75
89
|
|
|
76
|
-
//
|
|
77
|
-
|
|
90
|
+
// ─── proxy support ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// getProxy reads HTTPS_PROXY / HTTP_PROXY / https_proxy / http_proxy, in the
|
|
93
|
+
// order npm / curl do. Returns a parsed URL or null.
|
|
94
|
+
function getProxy() {
|
|
95
|
+
const raw =
|
|
96
|
+
process.env.HTTPS_PROXY ||
|
|
97
|
+
process.env.https_proxy ||
|
|
98
|
+
process.env.HTTP_PROXY ||
|
|
99
|
+
process.env.http_proxy;
|
|
100
|
+
if (!raw) return null;
|
|
101
|
+
try {
|
|
102
|
+
return new URL(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
warn(`ignoring invalid proxy URL: ${raw}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// tunnelThroughProxy opens a TCP connection to the proxy, issues an HTTP
|
|
110
|
+
// CONNECT to the target, and upgrades the socket to TLS. Returns a Promise
|
|
111
|
+
// resolving to an established TLSSocket that can be handed to https.request
|
|
112
|
+
// as `createConnection`. Zero external dependencies.
|
|
113
|
+
function tunnelThroughProxy(proxy, targetHost, targetPort) {
|
|
78
114
|
return new Promise((resolve, reject) => {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
115
|
+
const proxyPort = proxy.port || (proxy.protocol === 'https:' ? 443 : 80);
|
|
116
|
+
const authHeader = proxy.username
|
|
117
|
+
? `Proxy-Authorization: Basic ${Buffer.from(
|
|
118
|
+
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`,
|
|
119
|
+
).toString('base64')}\r\n`
|
|
120
|
+
: '';
|
|
121
|
+
|
|
122
|
+
const proxySocket = net.connect(proxyPort, proxy.hostname, () => {
|
|
123
|
+
proxySocket.write(
|
|
124
|
+
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
|
|
125
|
+
`Host: ${targetHost}:${targetPort}\r\n` +
|
|
126
|
+
authHeader +
|
|
127
|
+
`\r\n`,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let buf = Buffer.alloc(0);
|
|
132
|
+
const onData = (chunk) => {
|
|
133
|
+
buf = Buffer.concat([buf, chunk]);
|
|
134
|
+
const end = buf.indexOf('\r\n\r\n');
|
|
135
|
+
if (end === -1) return;
|
|
136
|
+
const head = buf.slice(0, end).toString('utf8');
|
|
137
|
+
const status = head.split(' ')[1];
|
|
138
|
+
proxySocket.removeListener('data', onData);
|
|
139
|
+
if (status !== '200') {
|
|
140
|
+
reject(new Error(`proxy CONNECT failed: ${head.split('\r\n')[0]}`));
|
|
141
|
+
proxySocket.end();
|
|
83
142
|
return;
|
|
84
143
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (res.statusCode !== 200) {
|
|
98
|
-
reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
|
99
|
-
res.resume();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
res.pipe(file);
|
|
103
|
-
file.on('finish', () => file.close(() => resolve()));
|
|
104
|
-
file.on('error', reject);
|
|
105
|
-
})
|
|
106
|
-
.on('error', reject);
|
|
107
|
-
}
|
|
108
|
-
get(targetUrl, 0);
|
|
144
|
+
// Upgrade to TLS — the CONNECT tunnel is now a raw TCP pipe to target.
|
|
145
|
+
const tlsSocket = tls.connect({
|
|
146
|
+
socket: proxySocket,
|
|
147
|
+
servername: targetHost,
|
|
148
|
+
});
|
|
149
|
+
tlsSocket.once('secureConnect', () => resolve(tlsSocket));
|
|
150
|
+
tlsSocket.once('error', reject);
|
|
151
|
+
};
|
|
152
|
+
proxySocket.on('data', onData);
|
|
153
|
+
proxySocket.once('error', reject);
|
|
109
154
|
});
|
|
110
155
|
}
|
|
111
156
|
|
|
112
|
-
|
|
157
|
+
// httpsGetRaw makes a single HTTPS GET request. If an HTTPS_PROXY is set, it
|
|
158
|
+
// tunnels through it via CONNECT; otherwise it uses stock https.request.
|
|
159
|
+
// Returns a Promise<IncomingMessage>. Caller is responsible for consuming or
|
|
160
|
+
// discarding the body.
|
|
161
|
+
function httpsGetRaw(targetUrl) {
|
|
113
162
|
return new Promise((resolve, reject) => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
const u = new URL(targetUrl);
|
|
164
|
+
const proxy = getProxy();
|
|
165
|
+
|
|
166
|
+
const reqOptions = {
|
|
167
|
+
host: u.hostname,
|
|
168
|
+
port: u.port || 443,
|
|
169
|
+
path: u.pathname + u.search,
|
|
170
|
+
method: 'GET',
|
|
171
|
+
headers: {
|
|
172
|
+
'User-Agent': 'liaison-cli-installer',
|
|
173
|
+
Host: u.host,
|
|
174
|
+
},
|
|
175
|
+
timeout: SOCKET_TIMEOUT_MS,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const runRequest = (agentSocket) => {
|
|
179
|
+
if (agentSocket) {
|
|
180
|
+
reqOptions.createConnection = () => agentSocket;
|
|
118
181
|
}
|
|
119
|
-
https
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
res.resume();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
let body = '';
|
|
137
|
-
res.setEncoding('utf8');
|
|
138
|
-
res.on('data', (chunk) => (body += chunk));
|
|
139
|
-
res.on('end', () => resolve(body));
|
|
140
|
-
})
|
|
141
|
-
.on('error', reject);
|
|
182
|
+
const req = https.request(reqOptions, resolve);
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
req.on('timeout', () => {
|
|
185
|
+
req.destroy(new Error(`timeout after ${SOCKET_TIMEOUT_MS}ms`));
|
|
186
|
+
});
|
|
187
|
+
req.end();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (proxy) {
|
|
191
|
+
tunnelThroughProxy(proxy, u.hostname, u.port || 443)
|
|
192
|
+
.then(runRequest)
|
|
193
|
+
.catch(reject);
|
|
194
|
+
} else {
|
|
195
|
+
runRequest(null);
|
|
142
196
|
}
|
|
143
|
-
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Follow HTTP redirects up to maxRedirects, returning the final IncomingMessage
|
|
201
|
+
// that actually holds the body. Non-2xx status codes at the end of the chain
|
|
202
|
+
// are returned as-is; caller decides whether to treat them as an error.
|
|
203
|
+
async function httpsGet(targetUrl, maxRedirects = 5) {
|
|
204
|
+
let current = targetUrl;
|
|
205
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
206
|
+
const res = await httpsGetRaw(current);
|
|
207
|
+
if (
|
|
208
|
+
res.statusCode >= 300 &&
|
|
209
|
+
res.statusCode < 400 &&
|
|
210
|
+
res.headers.location
|
|
211
|
+
) {
|
|
212
|
+
res.resume();
|
|
213
|
+
current = res.headers.location;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
return res;
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`too many redirects fetching ${targetUrl}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// withRetry wraps an async function so it's tried a few times with backoff
|
|
222
|
+
// on network errors. Does NOT retry on 4xx/5xx from the server — those are
|
|
223
|
+
// deterministic and a retry will just hit the same error.
|
|
224
|
+
async function withRetry(label, fn) {
|
|
225
|
+
let lastErr;
|
|
226
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
227
|
+
try {
|
|
228
|
+
return await fn();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
lastErr = err;
|
|
231
|
+
if (attempt < RETRY_DELAYS_MS.length) {
|
|
232
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
233
|
+
warn(`${label}: ${err.message}, retrying in ${delay}ms...`);
|
|
234
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw lastErr;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function downloadToFile(targetUrl, outPath) {
|
|
243
|
+
return withRetry(`download ${path.basename(outPath)}`, async () => {
|
|
244
|
+
const res = await httpsGet(targetUrl);
|
|
245
|
+
if (res.statusCode !== 200) {
|
|
246
|
+
res.resume();
|
|
247
|
+
throw new Error(`HTTP ${res.statusCode} for ${targetUrl}`);
|
|
248
|
+
}
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
const file = fs.createWriteStream(outPath);
|
|
251
|
+
res.pipe(file);
|
|
252
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
253
|
+
file.on('error', reject);
|
|
254
|
+
res.on('error', reject);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function downloadToString(targetUrl) {
|
|
260
|
+
return withRetry(`fetch ${path.basename(new URL(targetUrl).pathname)}`, async () => {
|
|
261
|
+
const res = await httpsGet(targetUrl);
|
|
262
|
+
if (res.statusCode !== 200) {
|
|
263
|
+
res.resume();
|
|
264
|
+
throw new Error(`HTTP ${res.statusCode} for ${targetUrl}`);
|
|
265
|
+
}
|
|
266
|
+
let body = '';
|
|
267
|
+
res.setEncoding('utf8');
|
|
268
|
+
for await (const chunk of res) body += chunk;
|
|
269
|
+
return body;
|
|
144
270
|
});
|
|
145
271
|
}
|
|
146
272
|
|
|
@@ -151,6 +277,11 @@ function sha256(filepath) {
|
|
|
151
277
|
}
|
|
152
278
|
|
|
153
279
|
async function main() {
|
|
280
|
+
const proxy = getProxy();
|
|
281
|
+
if (proxy) {
|
|
282
|
+
log(`using proxy ${proxy.protocol}//${proxy.hostname}:${proxy.port || ''}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
154
285
|
log(`fetching ${url}`);
|
|
155
286
|
await downloadToFile(url, destPath);
|
|
156
287
|
fs.chmodSync(destPath, 0o755);
|
|
@@ -174,6 +305,10 @@ async function main() {
|
|
|
174
305
|
log(`installed ${filename} (sha256 ok)`);
|
|
175
306
|
}
|
|
176
307
|
|
|
308
|
+
// Suppress the unused `http` import warning — kept for future use if we
|
|
309
|
+
// ever need HTTP-not-HTTPS downloads (CI staging).
|
|
310
|
+
void http;
|
|
311
|
+
|
|
177
312
|
main().catch((err) => {
|
|
178
|
-
die(`download failed: ${err.message}
|
|
313
|
+
die(`download failed: ${err.message}. You can retry with \`npm rebuild @liaisonio/cli\`, or set HTTPS_PROXY if you're behind a corporate proxy.`);
|
|
179
314
|
});
|