@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/scripts/install.js +195 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liaisonio/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Liaison Cloud CLI — manage connectors, entries, and applications from the command line",
5
5
  "keywords": [
6
6
  "liaison",
@@ -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
- // Followed-redirect HTTP GET that streams to a file.
77
- function downloadToFile(targetUrl, outPath) {
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 file = fs.createWriteStream(outPath);
80
- function get(u, depth) {
81
- if (depth > 5) {
82
- reject(new Error(`too many redirects fetching ${targetUrl}`));
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
- https
86
- .get(u, { headers: { 'User-Agent': 'liaison-cli-installer' } }, (res) => {
87
- if (
88
- res.statusCode &&
89
- res.statusCode >= 300 &&
90
- res.statusCode < 400 &&
91
- res.headers.location
92
- ) {
93
- res.resume();
94
- get(res.headers.location, depth + 1);
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
- function downloadToString(targetUrl) {
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
- function get(u, depth) {
115
- if (depth > 5) {
116
- reject(new Error(`too many redirects fetching ${targetUrl}`));
117
- return;
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
- .get(u, { headers: { 'User-Agent': 'liaison-cli-installer' } }, (res) => {
121
- if (
122
- res.statusCode &&
123
- res.statusCode >= 300 &&
124
- res.statusCode < 400 &&
125
- res.headers.location
126
- ) {
127
- res.resume();
128
- get(res.headers.location, depth + 1);
129
- return;
130
- }
131
- if (res.statusCode !== 200) {
132
- reject(new Error(`HTTP ${res.statusCode} for ${u}`));
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
- get(targetUrl, 0);
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
  });