iterate-harness 2.2.7 → 2.3.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/lib/bootstrap.js +393 -82
- package/lib/ui.js +15 -1
- package/package.json +2 -2
package/lib/bootstrap.js
CHANGED
|
@@ -51,6 +51,12 @@ const TARBALL_CACHE_DIR_NAME = "cache";
|
|
|
51
51
|
|
|
52
52
|
const MAX_DOWNLOAD_REDIRECTS = 5;
|
|
53
53
|
const DOWNLOAD_TIMEOUT_MS = 120000;
|
|
54
|
+
// Hard wall-clock cap per single self-download (a stale-but-connected registry
|
|
55
|
+
// that never dribbles more bytes must not be able to hang the bootstrap).
|
|
56
|
+
const DOWNLOAD_TOTAL_TIMEOUT_MS = 60000;
|
|
57
|
+
// Hard cap for the whole install/bootstrap, whatever pip or the download
|
|
58
|
+
// fallbacks are doing.
|
|
59
|
+
const BOOTSTRAP_TIMEOUT_MS = 900000;
|
|
54
60
|
const CURL_MAX_TIME_SECONDS = 240;
|
|
55
61
|
|
|
56
62
|
// Integrity: every release publishes a `<artifact>.sha256` sidecar next to the
|
|
@@ -64,6 +70,12 @@ const HOME_ENV_VAR = "ITERATE_HARNESS_NPM_HOME";
|
|
|
64
70
|
const INSTALL_URL_ENV_VAR = "ITERATE_HARNESS_INSTALL_URL";
|
|
65
71
|
const SKIP_INSTALL_ENV_VAR = "ITERATE_HARNESS_SKIP_INSTALL";
|
|
66
72
|
|
|
73
|
+
// Flags understood by the wrapper itself (stripped before argv is forwarded to
|
|
74
|
+
// the Python `ih` child). Security-relevant escapes: verification gate and the
|
|
75
|
+
// plain-HTTP source ban can only be lifted explicitly.
|
|
76
|
+
const NO_VERIFY_FLAG = "--no-verify";
|
|
77
|
+
const ALLOW_HTTP_FLAG = "--allow-http";
|
|
78
|
+
|
|
67
79
|
const HARNESS_GITHUB_REPO = "jingzhao-l/iterate-harness";
|
|
68
80
|
const HARNESS_RELEASE_DOWNLOAD_URL = `https://github.com/${HARNESS_GITHUB_REPO}/releases/download`;
|
|
69
81
|
const HARNESS_REPO_ARCHIVE_URL = `https://github.com/${HARNESS_GITHUB_REPO}/archive/refs/tags`;
|
|
@@ -91,6 +103,74 @@ class BootstrapError extends Error {}
|
|
|
91
103
|
// graceful "nothing to do" exit (code 0), never as a failure.
|
|
92
104
|
class CancelledError extends Error {}
|
|
93
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Process-lifetime helpers for cleanup on SIGINT/SIGTERM during bootstrap
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
// In-flight self-downloaded artifacts. If the process is signalled (Ctrl-C,
|
|
111
|
+
// SIGTERM) mid-download, these partial files are removed instead of cluttering
|
|
112
|
+
// the cache with a corrupt artifact that would only fail verification later.
|
|
113
|
+
const activeDownloadFiles = new Set();
|
|
114
|
+
|
|
115
|
+
function trackDownload(dest) {
|
|
116
|
+
activeDownloadFiles.add(dest);
|
|
117
|
+
return () => { activeDownloadFiles.delete(dest); };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cleanupActiveDownloads() {
|
|
121
|
+
for (const dest of activeDownloadFiles) {
|
|
122
|
+
try { fs.rmSync(dest, { force: true }); } catch { /* best-effort */ }
|
|
123
|
+
}
|
|
124
|
+
activeDownloadFiles.clear();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// In-flight pip/python step children (async pip runner). A signal arriving
|
|
128
|
+
// while the wrapper bootstraps kills these too so they never keep running
|
|
129
|
+
// orphaned after the wrapper exits.
|
|
130
|
+
const activeStepChildren = new Set();
|
|
131
|
+
|
|
132
|
+
// AbortController wrapper: fires after `timeoutMs` with a clear
|
|
133
|
+
// BootstrapError. Call `.clear()` when the work finishes normally.
|
|
134
|
+
function abortable(timeoutMs, message) {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(
|
|
137
|
+
() => controller.abort(new BootstrapError(message)),
|
|
138
|
+
timeoutMs
|
|
139
|
+
);
|
|
140
|
+
return { signal: controller.signal, clear: () => clearTimeout(timer) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The shared TLS-trust-store guidance shown when pip exits with
|
|
144
|
+
// CERTIFICATE_VERIFY_FAILED.
|
|
145
|
+
function certVerifyFailure(command, args, code) {
|
|
146
|
+
return new BootstrapError(
|
|
147
|
+
[
|
|
148
|
+
`${command} ${args.join(" ")} exited with code ${code}`,
|
|
149
|
+
"",
|
|
150
|
+
"The download failed TLS certificate verification. This is a local",
|
|
151
|
+
"Python/pip trust-store issue, not a harness problem. Common fixes:",
|
|
152
|
+
" - macOS python.org installs: run",
|
|
153
|
+
" /Applications/Python 3.1x/Install Certificates.command",
|
|
154
|
+
" - Point pip at a CA bundle: export PIP_CERT=$(python3 -m certifi)",
|
|
155
|
+
` - Or use another interpreter: ${PYTHON_ENV_VAR}=/opt/homebrew/bin/python3.12 npm i -g iterate-harness`,
|
|
156
|
+
].join("\n")
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Plain-HTTP install sources are trivially MITM-able; the wrapper refuses
|
|
161
|
+
// them unless the user explicitly opts in.
|
|
162
|
+
function assertHttpsInstallSource(url, allowHttp) {
|
|
163
|
+
const scheme = String(
|
|
164
|
+
(/^([a-z][a-z0-9+.-]*):/i.exec(String(url || "")) || [])[1] || ""
|
|
165
|
+
).toLowerCase();
|
|
166
|
+
if (scheme === "http" && !allowHttp) {
|
|
167
|
+
throw new BootstrapError(
|
|
168
|
+
`refusing plain-HTTP install source ${url}; ` +
|
|
169
|
+
"use an https:// URL or pass --allow-http to permit plain HTTP"
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
94
174
|
// ---------------------------------------------------------------------------
|
|
95
175
|
// Pure helpers (unit-tested in test/bootstrap.test.js)
|
|
96
176
|
// ---------------------------------------------------------------------------
|
|
@@ -192,31 +272,73 @@ function sha256File(filePath) {
|
|
|
192
272
|
}
|
|
193
273
|
|
|
194
274
|
// Small text fetch with the same redirect/timeout discipline as downloadFile.
|
|
195
|
-
function fetchChecksumText(url, redirectBudget) {
|
|
275
|
+
function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal) {
|
|
196
276
|
const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
|
|
277
|
+
const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
|
|
197
278
|
return new Promise((resolve, reject) => {
|
|
279
|
+
let request = null;
|
|
280
|
+
let timer = null;
|
|
281
|
+
let onAbort = null;
|
|
282
|
+
let settled = false;
|
|
283
|
+
const finish = (value) => {
|
|
284
|
+
if (settled) return;
|
|
285
|
+
settled = true;
|
|
286
|
+
if (timer) clearTimeout(timer);
|
|
287
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
288
|
+
resolve(value);
|
|
289
|
+
};
|
|
290
|
+
const fail = (error) => {
|
|
291
|
+
if (settled) return;
|
|
292
|
+
settled = true;
|
|
293
|
+
if (timer) clearTimeout(timer);
|
|
294
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
295
|
+
if (request) request.destroy(error);
|
|
296
|
+
reject(error);
|
|
297
|
+
};
|
|
298
|
+
timer = setTimeout(() => {
|
|
299
|
+
fail(
|
|
300
|
+
new BootstrapError(`fetching ${url} timed out after ${Math.round(totalTimeout / 1000)}s`)
|
|
301
|
+
);
|
|
302
|
+
}, totalTimeout);
|
|
303
|
+
if (signal) {
|
|
304
|
+
onAbort = () => {
|
|
305
|
+
fail(
|
|
306
|
+
signal.reason instanceof Error
|
|
307
|
+
? signal.reason
|
|
308
|
+
: new BootstrapError(`fetching ${url} aborted`)
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
312
|
+
}
|
|
198
313
|
if (budget < 0) {
|
|
199
|
-
|
|
314
|
+
fail(new BootstrapError(`too many redirects while fetching ${url}`));
|
|
200
315
|
return;
|
|
201
316
|
}
|
|
202
317
|
let parsed;
|
|
203
318
|
try {
|
|
204
319
|
parsed = new URL(url);
|
|
205
320
|
} catch (error) {
|
|
206
|
-
|
|
321
|
+
fail(new BootstrapError(`invalid checksum URL ${url}: ${error.message}`));
|
|
207
322
|
return;
|
|
208
323
|
}
|
|
209
324
|
const client = parsed.protocol === "http:" ? http : https;
|
|
210
|
-
|
|
325
|
+
request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
|
|
211
326
|
const status = response.statusCode || 0;
|
|
212
327
|
if (status >= 300 && status < 400 && response.headers.location) {
|
|
213
328
|
response.resume();
|
|
214
|
-
|
|
329
|
+
finish(
|
|
330
|
+
fetchChecksumText(
|
|
331
|
+
new URL(response.headers.location, url).toString(),
|
|
332
|
+
budget - 1,
|
|
333
|
+
totalTimeout,
|
|
334
|
+
signal
|
|
335
|
+
)
|
|
336
|
+
);
|
|
215
337
|
return;
|
|
216
338
|
}
|
|
217
339
|
if (status < 200 || status >= 300) {
|
|
218
340
|
response.resume();
|
|
219
|
-
|
|
341
|
+
fail(new BootstrapError(`fetching ${url} failed with HTTP ${status}`));
|
|
220
342
|
return;
|
|
221
343
|
}
|
|
222
344
|
const chunks = [];
|
|
@@ -224,42 +346,48 @@ function fetchChecksumText(url, redirectBudget) {
|
|
|
224
346
|
response.on("data", (chunk) => {
|
|
225
347
|
size += chunk.length;
|
|
226
348
|
if (size > MAX_CHECKSUM_BYTES) {
|
|
227
|
-
|
|
349
|
+
fail(new BootstrapError(`checksum file ${url} is unexpectedly large`));
|
|
350
|
+
if (request) request.destroy();
|
|
228
351
|
return;
|
|
229
352
|
}
|
|
230
353
|
chunks.push(chunk);
|
|
231
354
|
});
|
|
232
|
-
response.on("end", () =>
|
|
233
|
-
response.on("error", (responseError) =>
|
|
355
|
+
response.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
356
|
+
response.on("error", (responseError) => fail(responseError));
|
|
234
357
|
});
|
|
235
358
|
request.on("timeout", () => {
|
|
236
|
-
|
|
359
|
+
fail(new BootstrapError(`fetching ${url} timed out`));
|
|
237
360
|
});
|
|
238
|
-
request.on("error", (requestError) =>
|
|
361
|
+
request.on("error", (requestError) => fail(requestError));
|
|
239
362
|
});
|
|
240
363
|
}
|
|
241
364
|
|
|
242
365
|
// Verify a self-downloaded artifact against its published `.sha256` sidecar.
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
async function verifyDownloadedArtifact(url, filePath, fetchChecksum) {
|
|
366
|
+
// Fail-CLOSED: any error fetching the sidecar, any missing/malformed digest, or
|
|
367
|
+
// any mismatch deletes the artifact and aborts before pip can install it. The
|
|
368
|
+
// only way past the gate is the explicit `--no-verify` opt-out.
|
|
369
|
+
async function verifyDownloadedArtifact(url, filePath, fetchChecksum, noVerify) {
|
|
370
|
+
if (noVerify) {
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`[iterate-harness] skipping SHA256 verification for ${url} (--no-verify)\n`
|
|
373
|
+
);
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
247
376
|
let content;
|
|
248
377
|
try {
|
|
249
378
|
content = await fetchChecksum(checksumAssetUrl(url));
|
|
250
379
|
} catch (error) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
380
|
+
fs.rmSync(filePath, { force: true });
|
|
381
|
+
throw new BootstrapError(
|
|
382
|
+
`cannot verify ${url}: failed to fetch its SHA256 sidecar (${error.message}); refusing to install`
|
|
254
383
|
);
|
|
255
|
-
return false;
|
|
256
384
|
}
|
|
257
385
|
const expected = parseChecksum(content);
|
|
258
386
|
if (!expected) {
|
|
259
|
-
|
|
260
|
-
|
|
387
|
+
fs.rmSync(filePath, { force: true });
|
|
388
|
+
throw new BootstrapError(
|
|
389
|
+
`cannot verify ${url}: missing or malformed SHA256 digest in the published sidecar; refusing to install`
|
|
261
390
|
);
|
|
262
|
-
return false;
|
|
263
391
|
}
|
|
264
392
|
const actual = sha256File(filePath);
|
|
265
393
|
if (actual !== expected) {
|
|
@@ -283,15 +411,26 @@ function artifactExtensionFor(url) {
|
|
|
283
411
|
|
|
284
412
|
function downloadCachePath(homeDir, version, extension) {
|
|
285
413
|
const ext = extension || DEFAULT_ARTIFACT_EXT;
|
|
414
|
+
// The version token ends up in the on-disk filename, so it must stay a single
|
|
415
|
+
// safe basename inside the cache dir — a hostile version/URL must never be
|
|
416
|
+
// able to traverse (`..`, `/`) out of the cache.
|
|
417
|
+
const safeVersion = path
|
|
418
|
+
.basename(String(version || ""))
|
|
419
|
+
.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
420
|
+
if (!safeVersion || safeVersion === "." || safeVersion === "..") {
|
|
421
|
+
throw new BootstrapError(
|
|
422
|
+
`cannot build a safe cache filename for version "${version}"`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
286
425
|
if (ext === WHEEL_ARTIFACT_EXT) {
|
|
287
426
|
// A locally-cached wheel must keep a valid PEP 427 filename (e.g.
|
|
288
427
|
// `iterate_harness-1.12.6-py3-none-any.whl`). pip refuses any `*.whl`
|
|
289
428
|
// whose name lacks the `{python}-{abi}-{platform}` tags, so the fallback
|
|
290
429
|
// cache file reuses the real release asset name rather than a bare
|
|
291
430
|
// `iterate-harness-{version}.whl`.
|
|
292
|
-
return path.join(homeDir, TARBALL_CACHE_DIR_NAME, wheelAssetName(
|
|
431
|
+
return path.join(homeDir, TARBALL_CACHE_DIR_NAME, wheelAssetName(safeVersion));
|
|
293
432
|
}
|
|
294
|
-
return path.join(homeDir, TARBALL_CACHE_DIR_NAME, `iterate-harness-${
|
|
433
|
+
return path.join(homeDir, TARBALL_CACHE_DIR_NAME, `iterate-harness-${safeVersion}${ext}`);
|
|
295
434
|
}
|
|
296
435
|
|
|
297
436
|
function pipInstallArgs(target) {
|
|
@@ -385,23 +524,71 @@ function runStep(command, args, options) {
|
|
|
385
524
|
}
|
|
386
525
|
if (result.status !== 0) {
|
|
387
526
|
if (capturedOutput.includes(CERT_VERIFY_FAILURE_MARKER)) {
|
|
388
|
-
throw
|
|
389
|
-
[
|
|
390
|
-
`${command} ${args.join(" ")} exited with code ${result.status}`,
|
|
391
|
-
"",
|
|
392
|
-
"The download failed TLS certificate verification. This is a local",
|
|
393
|
-
"Python/pip trust-store issue, not a harness problem. Common fixes:",
|
|
394
|
-
" - macOS python.org installs: run",
|
|
395
|
-
" /Applications/Python 3.1x/Install Certificates.command",
|
|
396
|
-
" - Point pip at a CA bundle: export PIP_CERT=$(python3 -m certifi)",
|
|
397
|
-
` - Or use another interpreter: ${PYTHON_ENV_VAR}=/opt/homebrew/bin/python3.12 npm i -g iterate-harness`,
|
|
398
|
-
].join("\n")
|
|
399
|
-
);
|
|
527
|
+
throw certVerifyFailure(command, args, result.status);
|
|
400
528
|
}
|
|
401
529
|
throw new BootstrapError(`${command} ${args.join(" ")} exited with code ${result.status}`);
|
|
402
530
|
}
|
|
403
531
|
}
|
|
404
532
|
|
|
533
|
+
// Async pip runner used for install steps: unlike the synchronous runStep it
|
|
534
|
+
// keeps the event loop live, so the bootstrap deadline can abort a pip that
|
|
535
|
+
// hangs on a dead registry (spawnSync would block the timer forever) and kill
|
|
536
|
+
// the stuck child instead of orphaning it.
|
|
537
|
+
function runPipStepAsync(python, args, signal) {
|
|
538
|
+
return new Promise((resolve, reject) => {
|
|
539
|
+
let onAbort = null;
|
|
540
|
+
const child = spawn(python, args, {
|
|
541
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
542
|
+
windowsHide: true,
|
|
543
|
+
});
|
|
544
|
+
// Registered so a SIGINT/SIGTERM arriving during bootstrap can kill the
|
|
545
|
+
// stuck pip child (see forwardSignal) instead of orphaning it.
|
|
546
|
+
activeStepChildren.add(child);
|
|
547
|
+
let capturedOutput = "";
|
|
548
|
+
const forwardOutput = (chunk) => {
|
|
549
|
+
const text = chunk.toString("utf8");
|
|
550
|
+
if (capturedOutput.length < 16 * 1024 * 1024) {
|
|
551
|
+
capturedOutput += text;
|
|
552
|
+
}
|
|
553
|
+
process.stderr.write(text);
|
|
554
|
+
};
|
|
555
|
+
child.stdout.on("data", forwardOutput);
|
|
556
|
+
child.stderr.on("data", forwardOutput);
|
|
557
|
+
const fail = (error) => {
|
|
558
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
559
|
+
if (!child.killed) child.kill();
|
|
560
|
+
reject(error);
|
|
561
|
+
};
|
|
562
|
+
if (signal) {
|
|
563
|
+
onAbort = () => {
|
|
564
|
+
fail(
|
|
565
|
+
signal.reason instanceof Error
|
|
566
|
+
? signal.reason
|
|
567
|
+
: new BootstrapError(`pip install of ${args[args.length - 1]} aborted`)
|
|
568
|
+
);
|
|
569
|
+
};
|
|
570
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
571
|
+
}
|
|
572
|
+
child.on("error", (error) => {
|
|
573
|
+
activeStepChildren.delete(child);
|
|
574
|
+
fail(new BootstrapError(`failed to run ${python}: ${error.message}`));
|
|
575
|
+
});
|
|
576
|
+
child.on("close", (code) => {
|
|
577
|
+
activeStepChildren.delete(child);
|
|
578
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
579
|
+
if (code !== 0) {
|
|
580
|
+
if (capturedOutput.includes(CERT_VERIFY_FAILURE_MARKER)) {
|
|
581
|
+
fail(certVerifyFailure(python, args, code));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
fail(new BootstrapError(`${python} ${args.join(" ")} exited with code ${code}`));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
resolve();
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
405
592
|
// Ordered install candidates, best-effort first:
|
|
406
593
|
// 1. PyPI spec — pip installs via the user's mirror (stable, no GitHub).
|
|
407
594
|
// 2. Pre-built wheel on the GitHub release (needs GitHub, but no source build).
|
|
@@ -419,32 +606,67 @@ function installCandidates(version, env) {
|
|
|
419
606
|
// Artifact download fallback (Node-side TLS — survives broken pip trust stores)
|
|
420
607
|
// ---------------------------------------------------------------------------
|
|
421
608
|
|
|
422
|
-
function downloadFile(url, dest, redirectBudget) {
|
|
609
|
+
function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
423
610
|
const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
|
|
611
|
+
const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
|
|
424
612
|
return new Promise((resolve, reject) => {
|
|
613
|
+
let request = null;
|
|
614
|
+
let timer = null;
|
|
615
|
+
let onAbort = null;
|
|
616
|
+
let settled = false;
|
|
617
|
+
const finish = (value) => {
|
|
618
|
+
if (settled) return;
|
|
619
|
+
settled = true;
|
|
620
|
+
if (timer) clearTimeout(timer);
|
|
621
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
622
|
+
resolve(value);
|
|
623
|
+
};
|
|
624
|
+
const fail = (error) => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
if (timer) clearTimeout(timer);
|
|
628
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
629
|
+
if (request) request.destroy(error);
|
|
630
|
+
reject(error);
|
|
631
|
+
};
|
|
632
|
+
timer = setTimeout(() => {
|
|
633
|
+
fail(
|
|
634
|
+
new BootstrapError(`downloading ${url} timed out after ${Math.round(totalTimeout / 1000)}s`)
|
|
635
|
+
);
|
|
636
|
+
}, totalTimeout);
|
|
637
|
+
if (signal) {
|
|
638
|
+
onAbort = () => {
|
|
639
|
+
fail(
|
|
640
|
+
signal.reason instanceof Error
|
|
641
|
+
? signal.reason
|
|
642
|
+
: new BootstrapError(`downloading ${url} aborted`)
|
|
643
|
+
);
|
|
644
|
+
};
|
|
645
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
646
|
+
}
|
|
425
647
|
if (budget < 0) {
|
|
426
|
-
|
|
648
|
+
fail(new BootstrapError(`too many redirects while downloading ${url}`));
|
|
427
649
|
return;
|
|
428
650
|
}
|
|
429
651
|
let parsed;
|
|
430
652
|
try {
|
|
431
653
|
parsed = new URL(url);
|
|
432
654
|
} catch (error) {
|
|
433
|
-
|
|
655
|
+
fail(new BootstrapError(`invalid download URL ${url}: ${error.message}`));
|
|
434
656
|
return;
|
|
435
657
|
}
|
|
436
658
|
const client = parsed.protocol === "http:" ? http : https;
|
|
437
|
-
|
|
659
|
+
request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
|
|
438
660
|
const status = response.statusCode || 0;
|
|
439
661
|
if (status >= 300 && status < 400 && response.headers.location) {
|
|
440
662
|
response.resume();
|
|
441
663
|
const nextUrl = new URL(response.headers.location, url).toString();
|
|
442
|
-
|
|
664
|
+
finish(downloadFile(nextUrl, dest, budget - 1, totalTimeout, signal));
|
|
443
665
|
return;
|
|
444
666
|
}
|
|
445
667
|
if (status < 200 || status >= 300) {
|
|
446
668
|
response.resume();
|
|
447
|
-
|
|
669
|
+
fail(new BootstrapError(`downloading ${url} failed with HTTP ${status}`));
|
|
448
670
|
return;
|
|
449
671
|
}
|
|
450
672
|
const file = fs.createWriteStream(dest);
|
|
@@ -452,21 +674,21 @@ function downloadFile(url, dest, redirectBudget) {
|
|
|
452
674
|
file.on("finish", () => {
|
|
453
675
|
file.close((closeError) => {
|
|
454
676
|
if (closeError) {
|
|
455
|
-
|
|
677
|
+
fail(closeError);
|
|
456
678
|
return;
|
|
457
679
|
}
|
|
458
|
-
|
|
680
|
+
finish(dest);
|
|
459
681
|
});
|
|
460
682
|
});
|
|
461
683
|
file.on("error", (fileError) => {
|
|
462
684
|
file.destroy();
|
|
463
|
-
|
|
685
|
+
fail(fileError);
|
|
464
686
|
});
|
|
465
687
|
});
|
|
466
688
|
request.on("timeout", () => {
|
|
467
|
-
|
|
689
|
+
fail(new BootstrapError(`downloading ${url} timed out`));
|
|
468
690
|
});
|
|
469
|
-
request.on("error", (requestError) =>
|
|
691
|
+
request.on("error", (requestError) => fail(requestError));
|
|
470
692
|
});
|
|
471
693
|
}
|
|
472
694
|
|
|
@@ -497,9 +719,14 @@ function curlDownload(url, dest, spawnFn) {
|
|
|
497
719
|
}
|
|
498
720
|
|
|
499
721
|
async function downloadTarballTo(url, dest, deps) {
|
|
500
|
-
const
|
|
501
|
-
const
|
|
722
|
+
const signal = deps && deps.signal;
|
|
723
|
+
const nodeDownload =
|
|
724
|
+
(deps && deps.nodeDownload) ||
|
|
725
|
+
((downloadUrl, downloadDest) =>
|
|
726
|
+
downloadFile(downloadUrl, downloadDest, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal));
|
|
727
|
+
const curlDownloadFn = deps && deps.curlDownload ? deps.curlDownload : curlDownload;
|
|
502
728
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
729
|
+
const untrack = trackDownload(dest);
|
|
503
730
|
|
|
504
731
|
const attempts = [
|
|
505
732
|
{
|
|
@@ -520,8 +747,15 @@ async function downloadTarballTo(url, dest, deps) {
|
|
|
520
747
|
|
|
521
748
|
const failures = [];
|
|
522
749
|
for (const attempt of attempts) {
|
|
750
|
+
if (signal && signal.aborted) {
|
|
751
|
+
untrack();
|
|
752
|
+
throw signal.reason instanceof Error
|
|
753
|
+
? signal.reason
|
|
754
|
+
: new BootstrapError("bootstrap aborted");
|
|
755
|
+
}
|
|
523
756
|
try {
|
|
524
757
|
await attempt.run();
|
|
758
|
+
untrack();
|
|
525
759
|
return dest;
|
|
526
760
|
} catch (error) {
|
|
527
761
|
failures.push(`${attempt.name}: ${error.message}`);
|
|
@@ -531,6 +765,7 @@ async function downloadTarballTo(url, dest, deps) {
|
|
|
531
765
|
fs.rmSync(dest, { force: true });
|
|
532
766
|
}
|
|
533
767
|
}
|
|
768
|
+
untrack();
|
|
534
769
|
throw new BootstrapError(`direct download of ${url} failed (${failures.join("; ")})`);
|
|
535
770
|
}
|
|
536
771
|
|
|
@@ -544,9 +779,16 @@ async function installHarness(options) {
|
|
|
544
779
|
const homeDir = options.homeDir;
|
|
545
780
|
const version = options.version;
|
|
546
781
|
const candidates = options.candidates;
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
const
|
|
782
|
+
const signal = options.signal;
|
|
783
|
+
const noVerify = Boolean(options.noVerify);
|
|
784
|
+
const allowHttp = Boolean(options.allowHttp);
|
|
785
|
+
const runPipStep = options.runStepFn || runPipStepAsync;
|
|
786
|
+
const downloader = options.downloader
|
|
787
|
+
? options.downloader
|
|
788
|
+
: (url, dest) => downloadTarballTo(url, dest, { signal });
|
|
789
|
+
const checksumFetcher = options.checksumFetcher
|
|
790
|
+
? options.checksumFetcher
|
|
791
|
+
: (url) => fetchChecksumText(url, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal);
|
|
550
792
|
|
|
551
793
|
if (!candidates || candidates.length === 0) {
|
|
552
794
|
throw new BootstrapError("no install candidate URLs were provided");
|
|
@@ -554,26 +796,38 @@ async function installHarness(options) {
|
|
|
554
796
|
|
|
555
797
|
const failures = [];
|
|
556
798
|
for (const candidate of candidates) {
|
|
799
|
+
if (signal && signal.aborted) {
|
|
800
|
+
throw signal.reason instanceof Error
|
|
801
|
+
? signal.reason
|
|
802
|
+
: new BootstrapError("bootstrap aborted");
|
|
803
|
+
}
|
|
557
804
|
try {
|
|
558
805
|
if (isRemoteHttpUrl(candidate)) {
|
|
559
806
|
// HTTP(S) candidate: `pip` first, then node/curl download + local pip
|
|
560
807
|
// (survives broken Python TLS trust stores on macOS python.org builds).
|
|
808
|
+
assertHttpsInstallSource(candidate, allowHttp);
|
|
561
809
|
await installRemoteArtifact(
|
|
562
810
|
python,
|
|
563
811
|
candidate,
|
|
564
812
|
homeDir,
|
|
565
813
|
version,
|
|
566
|
-
|
|
814
|
+
runPipStep,
|
|
567
815
|
downloader,
|
|
568
|
-
checksumFetcher
|
|
816
|
+
checksumFetcher,
|
|
817
|
+
noVerify,
|
|
818
|
+
allowHttp,
|
|
819
|
+
signal
|
|
569
820
|
);
|
|
570
821
|
} else {
|
|
571
822
|
// Non-URL candidate (e.g. `iterate-harness==1.12.9` resolved by pip
|
|
572
823
|
// against the user's mirror): no artifact to download, just pip it.
|
|
573
|
-
|
|
824
|
+
await runPipStep(python, pipInstallArgs(candidate), signal);
|
|
574
825
|
}
|
|
575
826
|
return;
|
|
576
827
|
} catch (error) {
|
|
828
|
+
if (signal && signal.aborted) {
|
|
829
|
+
throw signal.reason instanceof Error ? signal.reason : error;
|
|
830
|
+
}
|
|
577
831
|
failures.push(`${candidate}: ${error.message}`);
|
|
578
832
|
process.stderr.write(
|
|
579
833
|
`[iterate-harness] install from "${candidate}" failed (${error.message}); ` +
|
|
@@ -594,10 +848,14 @@ async function installRemoteArtifact(
|
|
|
594
848
|
version,
|
|
595
849
|
runStepFn,
|
|
596
850
|
downloader,
|
|
597
|
-
checksumFetcher
|
|
851
|
+
checksumFetcher,
|
|
852
|
+
noVerify,
|
|
853
|
+
allowHttp,
|
|
854
|
+
signal
|
|
598
855
|
) {
|
|
856
|
+
assertHttpsInstallSource(url, allowHttp);
|
|
599
857
|
try {
|
|
600
|
-
runStepFn(python, pipInstallArgs(url));
|
|
858
|
+
await runStepFn(python, pipInstallArgs(url), signal);
|
|
601
859
|
return;
|
|
602
860
|
} catch (primaryError) {
|
|
603
861
|
process.stderr.write(
|
|
@@ -608,11 +866,12 @@ async function installRemoteArtifact(
|
|
|
608
866
|
await downloader(url, cachePath);
|
|
609
867
|
// These are bytes we fetched ourselves, so we own their integrity: check the
|
|
610
868
|
// published SHA256 sidecar before handing the file to pip.
|
|
611
|
-
await verifyDownloadedArtifact(url, cachePath, checksumFetcher);
|
|
612
|
-
runStepFn(python, pipInstallArgs(cachePath));
|
|
869
|
+
await verifyDownloadedArtifact(url, cachePath, checksumFetcher, noVerify);
|
|
870
|
+
await runStepFn(python, pipInstallArgs(cachePath), signal);
|
|
613
871
|
}
|
|
614
872
|
|
|
615
|
-
async function bootstrap(homeDir, version, env) {
|
|
873
|
+
async function bootstrap(homeDir, version, env, options) {
|
|
874
|
+
const opts = options || {};
|
|
616
875
|
fs.mkdirSync(homeDir, { recursive: true });
|
|
617
876
|
|
|
618
877
|
const venvDir = path.join(homeDir, VENV_DIR_NAME);
|
|
@@ -622,23 +881,49 @@ async function bootstrap(homeDir, version, env) {
|
|
|
622
881
|
: path.join(venvDir, "bin", "activate");
|
|
623
882
|
|
|
624
883
|
const interpreter = detectPython(env);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
884
|
+
// A venv is only reusable when the activate marker AND the interpreter both
|
|
885
|
+
// resolve; anything else (missing marker, stale/corrupt/partial venv) is
|
|
886
|
+
// rebuilt from scratch rather than silently reused.
|
|
887
|
+
const venvUsable =
|
|
888
|
+
fs.existsSync(venvDir) &&
|
|
889
|
+
fs.existsSync(activateMarker) &&
|
|
890
|
+
fs.existsSync(executable.python);
|
|
891
|
+
if (!venvUsable) {
|
|
892
|
+
if (fs.existsSync(venvDir)) {
|
|
893
|
+
ui.step(`Removing broken virtualenv at ${venvDir}`);
|
|
894
|
+
fs.rmSync(venvDir, { recursive: true, force: true });
|
|
895
|
+
}
|
|
630
896
|
ui.step(`Creating virtualenv at ${venvDir}`);
|
|
631
897
|
runStep(interpreter.command, [...interpreter.preArgs, "-m", "venv", venvDir]);
|
|
898
|
+
if (!fs.existsSync(executable.python)) {
|
|
899
|
+
throw new BootstrapError(
|
|
900
|
+
`virtualenv creation failed: no interpreter at ${executable.python}`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
632
903
|
}
|
|
633
904
|
|
|
634
905
|
const candidates = installCandidates(version, env);
|
|
635
906
|
ui.step(`Installing iterate-harness ${version}`);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
907
|
+
// Overall bootstrap cap: a hung index must abort the install instead of
|
|
908
|
+
// hanging the wrapper forever.
|
|
909
|
+
const cap = abortable(
|
|
910
|
+
BOOTSTRAP_TIMEOUT_MS,
|
|
911
|
+
`bootstrap timed out after ${Math.round(BOOTSTRAP_TIMEOUT_MS / 60000)} minutes; ` +
|
|
912
|
+
"the install hung or stalled — check your network access to the index and retry"
|
|
913
|
+
);
|
|
914
|
+
try {
|
|
915
|
+
await installHarness({
|
|
916
|
+
python: executable.python,
|
|
917
|
+
candidates,
|
|
918
|
+
homeDir,
|
|
919
|
+
version,
|
|
920
|
+
noVerify: opts.noVerify,
|
|
921
|
+
allowHttp: opts.allowHttp,
|
|
922
|
+
signal: cap.signal,
|
|
923
|
+
});
|
|
924
|
+
} finally {
|
|
925
|
+
cap.clear();
|
|
926
|
+
}
|
|
642
927
|
|
|
643
928
|
const stampPath = path.join(homeDir, STAMP_FILE_NAME);
|
|
644
929
|
fs.writeFileSync(stampPath, `${version}\n`, "utf8");
|
|
@@ -678,7 +963,7 @@ async function ensureRuntime(env, options) {
|
|
|
678
963
|
`\x1b[36m◆\x1b[0m Runtime: ${homeDir}`,
|
|
679
964
|
]);
|
|
680
965
|
}
|
|
681
|
-
await bootstrap(homeDir, version, environment);
|
|
966
|
+
await bootstrap(homeDir, version, environment, opts);
|
|
682
967
|
if (opts.interactive) {
|
|
683
968
|
ui.frameSection("Done", [
|
|
684
969
|
`\x1b[32m✓\x1b[0m iterate-harness v${version} installed`,
|
|
@@ -723,33 +1008,59 @@ async function runHarness(args, env) {
|
|
|
723
1008
|
// when stderr is not a TTY (e.g. piped output), so `ih --version | jq` stays clean.
|
|
724
1009
|
ui.printBanner();
|
|
725
1010
|
|
|
1011
|
+
const cliArgs = Array.isArray(args) ? args : [];
|
|
1012
|
+
const forwardedArgs = cliArgs.filter(
|
|
1013
|
+
(arg) => arg !== NO_VERIFY_FLAG && arg !== ALLOW_HTTP_FLAG
|
|
1014
|
+
);
|
|
1015
|
+
const options = {
|
|
1016
|
+
noVerify: cliArgs.includes(NO_VERIFY_FLAG),
|
|
1017
|
+
allowHttp: cliArgs.includes(ALLOW_HTTP_FLAG),
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
// Signal handling spans the whole run: during bootstrap a Ctrl-C/SIGTERM
|
|
1021
|
+
// must clean up in-flight downloads and not orphan a pip/python child; after
|
|
1022
|
+
// bootstrap it is forwarded to the delegated `ih` child exactly as before.
|
|
1023
|
+
let child = null;
|
|
1024
|
+
const forwardSignal = (signal) => {
|
|
1025
|
+
cleanupActiveDownloads();
|
|
1026
|
+
const stepChildren = [...activeStepChildren];
|
|
1027
|
+
for (const stepChild of stepChildren) {
|
|
1028
|
+
if (!stepChild.killed) stepChild.kill(signal);
|
|
1029
|
+
}
|
|
1030
|
+
if (child && !child.killed) {
|
|
1031
|
+
child.kill(signal);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// No managed child yet (still bootstrapping): abort ourselves with the
|
|
1035
|
+
// conventional exit code for the signal.
|
|
1036
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
1037
|
+
};
|
|
1038
|
+
process.on("SIGINT", forwardSignal);
|
|
1039
|
+
process.on("SIGTERM", forwardSignal);
|
|
1040
|
+
|
|
726
1041
|
let target;
|
|
727
1042
|
try {
|
|
728
|
-
target = await ensureRuntime(env, { interactive: interactiveSession() });
|
|
1043
|
+
target = await ensureRuntime(env, { interactive: interactiveSession(), ...options });
|
|
729
1044
|
} catch (error) {
|
|
730
1045
|
if (error instanceof CancelledError) {
|
|
731
1046
|
ui.info(error.message);
|
|
1047
|
+
process.removeListener("SIGINT", forwardSignal);
|
|
1048
|
+
process.removeListener("SIGTERM", forwardSignal);
|
|
732
1049
|
process.exit(0);
|
|
733
1050
|
return;
|
|
734
1051
|
}
|
|
1052
|
+
process.removeListener("SIGINT", forwardSignal);
|
|
1053
|
+
process.removeListener("SIGTERM", forwardSignal);
|
|
735
1054
|
reportBootstrapFailure(error);
|
|
736
1055
|
process.exit(1);
|
|
737
1056
|
return;
|
|
738
1057
|
}
|
|
739
|
-
|
|
1058
|
+
child = spawn(target, forwardedArgs, {
|
|
740
1059
|
stdio: "inherit",
|
|
741
1060
|
windowsHide: false,
|
|
742
1061
|
env: env || process.env,
|
|
743
1062
|
});
|
|
744
1063
|
|
|
745
|
-
const forwardSignal = (signal) => {
|
|
746
|
-
if (!child.killed) {
|
|
747
|
-
child.kill(signal);
|
|
748
|
-
}
|
|
749
|
-
};
|
|
750
|
-
process.on("SIGINT", forwardSignal);
|
|
751
|
-
process.on("SIGTERM", forwardSignal);
|
|
752
|
-
|
|
753
1064
|
child.on("error", (error) => {
|
|
754
1065
|
process.stderr.write(`[iterate-harness] failed to launch ih: ${error.message}\n`);
|
|
755
1066
|
process.exit(1);
|
package/lib/ui.js
CHANGED
|
@@ -110,7 +110,21 @@ function askYesNo(question, defaultNo = false, options = {}) {
|
|
|
110
110
|
);
|
|
111
111
|
finish(defaultNo);
|
|
112
112
|
}, timeoutMs);
|
|
113
|
-
|
|
113
|
+
// Only unref the timer when the real terminal is in play. A TTY or
|
|
114
|
+
// piped stdin keeps the event loop alive through its OS file descriptor,
|
|
115
|
+
// so the unref'd timeout cannot be dropped before it fires (and it never
|
|
116
|
+
// blocks process exit once the prompt has settled). Injected streams
|
|
117
|
+
// (tests, embedded hosts) hold NO descriptor, so an unref'd timer can be
|
|
118
|
+
// skipped on an otherwise-empty loop — leaving the call pending forever
|
|
119
|
+
// instead of settling on the default. Keep the timer referenced there so
|
|
120
|
+
// the bounded-wait guarantee actually holds.
|
|
121
|
+
if (
|
|
122
|
+
typeof timer.unref === "function" &&
|
|
123
|
+
input === process.stdin &&
|
|
124
|
+
Boolean(input.isTTY)
|
|
125
|
+
) {
|
|
126
|
+
timer.unref();
|
|
127
|
+
}
|
|
114
128
|
}
|
|
115
129
|
const hint = defaultNo ? "[y/N]" : "[Y/n]";
|
|
116
130
|
rl.question(`\x1b[36m◆\x1b[0m ${question} ${hint} `, (answer) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "npm distribution wrapper for iterate-harness — installs the Python harness (ih CLI) into a managed venv on install and exposes it as a global command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"iterate-harness": "bin/iterate-harness.js"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=18"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin",
|