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.
Files changed (3) hide show
  1. package/lib/bootstrap.js +393 -82
  2. package/lib/ui.js +15 -1
  3. 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
- reject(new BootstrapError(`too many redirects while fetching ${url}`));
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
- reject(new BootstrapError(`invalid checksum URL ${url}: ${error.message}`));
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
- const request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
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
- resolve(fetchChecksumText(new URL(response.headers.location, url).toString(), budget - 1));
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
- reject(new BootstrapError(`fetching ${url} failed with HTTP ${status}`));
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
- request.destroy(new BootstrapError(`checksum file ${url} is unexpectedly large`));
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", () => resolve(Buffer.concat(chunks).toString("utf8")));
233
- response.on("error", (responseError) => reject(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
- request.destroy(new BootstrapError(`fetching ${url} timed out`));
359
+ fail(new BootstrapError(`fetching ${url} timed out`));
237
360
  });
238
- request.on("error", (requestError) => reject(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
- // Returns true when a checksum was present and matched, false when none is
244
- // published (pre-2.2.7 releases predate the sidecar), and throws on mismatch:
245
- // corrupted or tampered bytes must never reach pip.
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
- process.stderr.write(
252
- `[iterate-harness] warning: no SHA256 checksum published for ${url} ` +
253
- `(${error.message}); skipping integrity check\n`
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
- process.stderr.write(
260
- `[iterate-harness] warning: unreadable SHA256 checksum for ${url}; skipping integrity check\n`
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(version));
431
+ return path.join(homeDir, TARBALL_CACHE_DIR_NAME, wheelAssetName(safeVersion));
293
432
  }
294
- return path.join(homeDir, TARBALL_CACHE_DIR_NAME, `iterate-harness-${version}${ext}`);
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 new BootstrapError(
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
- reject(new BootstrapError(`too many redirects while downloading ${url}`));
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
- reject(new BootstrapError(`invalid download URL ${url}: ${error.message}`));
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
- const request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
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
- resolve(downloadFile(nextUrl, dest, budget - 1));
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
- reject(new BootstrapError(`downloading ${url} failed with HTTP ${status}`));
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
- reject(closeError);
677
+ fail(closeError);
456
678
  return;
457
679
  }
458
- resolve(dest);
680
+ finish(dest);
459
681
  });
460
682
  });
461
683
  file.on("error", (fileError) => {
462
684
  file.destroy();
463
- reject(fileError);
685
+ fail(fileError);
464
686
  });
465
687
  });
466
688
  request.on("timeout", () => {
467
- request.destroy(new BootstrapError(`downloading ${url} timed out`));
689
+ fail(new BootstrapError(`downloading ${url} timed out`));
468
690
  });
469
- request.on("error", (requestError) => reject(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 nodeDownload = (deps && deps.nodeDownload) || downloadFile;
501
- const curlDownloadFn = (deps && deps.curlDownload) || curlDownload;
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 runStepFn = options.runStepFn || runStep;
548
- const downloader = options.downloader || downloadTarballTo;
549
- const checksumFetcher = options.checksumFetcher || fetchChecksumText;
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
- runStepFn,
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
- runStepFn(python, pipInstallArgs(candidate));
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
- const recreateVenv = fs.existsSync(venvDir) && !fs.existsSync(activateMarker);
626
- if (recreateVenv) {
627
- fs.rmSync(venvDir, { recursive: true, force: true });
628
- }
629
- if (!fs.existsSync(activateMarker)) {
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
- await installHarness({
637
- python: executable.python,
638
- candidates,
639
- homeDir,
640
- version,
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
- const child = spawn(target, args, {
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
- if (typeof timer.unref === "function") timer.unref();
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.2.7",
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": ">=16"
20
+ "node": ">=18"
21
21
  },
22
22
  "files": [
23
23
  "bin",