iterate-harness 2.5.0 → 2.7.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 (2) hide show
  1. package/lib/bootstrap.js +243 -41
  2. package/package.json +1 -1
package/lib/bootstrap.js CHANGED
@@ -59,6 +59,20 @@ const DOWNLOAD_TOTAL_TIMEOUT_MS = 60000;
59
59
  const BOOTSTRAP_TIMEOUT_MS = 900000;
60
60
  const CURL_MAX_TIME_SECONDS = 240;
61
61
 
62
+ // A `python --version` probe or a `python -m venv` step must never hang the
63
+ // wrapper forever (broken py-launcher on Windows, wedged NFS home, ...).
64
+ const PYTHON_PROBE_TIMEOUT_MS = 30000;
65
+ const STEP_TIMEOUT_MS = 600000;
66
+
67
+ // Advisory lock serializing concurrent bootstraps (parallel `ih` runs, npm
68
+ // postinstall racing the first invocation): two installers writing the same
69
+ // venv corrupt it. A live bootstrap is capped at BOOTSTRAP_TIMEOUT_MS, so a
70
+ // lock untouched for longer belongs to a crashed/killed installer.
71
+ const LOCK_FILE_NAME = "bootstrap.lock";
72
+ const LOCK_STALE_MS = BOOTSTRAP_TIMEOUT_MS + 5 * 60 * 1000;
73
+ const LOCK_POLL_INTERVAL_MS = 400;
74
+ const LOCK_ACQUIRE_TIMEOUT_MS = BOOTSTRAP_TIMEOUT_MS;
75
+
62
76
  // Integrity: every release publishes a `<artifact>.sha256` sidecar next to the
63
77
  // artifact. Any bytes this wrapper downloads itself are hashed and compared
64
78
  // against that sidecar before pip is allowed to install them.
@@ -171,6 +185,23 @@ function assertHttpsInstallSource(url, allowHttp) {
171
185
  }
172
186
  }
173
187
 
188
+ // Resolve a `Location` header against the URL it came from and enforce the
189
+ // plain-HTTP gate on the result. Two failure modes are guarded here:
190
+ // - a malformed Location (e.g. `http://[::`) used to throw an uncaught
191
+ // TypeError inside the response callback and crash the whole wrapper;
192
+ // - an https→http redirect silently bypassed the --allow-http gate that the
193
+ // original install URL was checked against.
194
+ function resolveRedirectUrl(location, fromUrl, allowHttp) {
195
+ let next;
196
+ try {
197
+ next = new URL(String(location), String(fromUrl)).toString();
198
+ } catch (error) {
199
+ throw new BootstrapError(`invalid redirect from ${fromUrl}: ${error.message}`);
200
+ }
201
+ assertHttpsInstallSource(next, allowHttp);
202
+ return next;
203
+ }
204
+
174
205
  // ---------------------------------------------------------------------------
175
206
  // Pure helpers (unit-tested in test/bootstrap.test.js)
176
207
  // ---------------------------------------------------------------------------
@@ -272,7 +303,7 @@ function sha256File(filePath) {
272
303
  }
273
304
 
274
305
  // Small text fetch with the same redirect/timeout discipline as downloadFile.
275
- function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal) {
306
+ function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal, allowHttp) {
276
307
  const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
277
308
  const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
278
309
  return new Promise((resolve, reject) => {
@@ -321,19 +352,23 @@ function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal) {
321
352
  fail(new BootstrapError(`invalid checksum URL ${url}: ${error.message}`));
322
353
  return;
323
354
  }
355
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
356
+ fail(new BootstrapError(`unsupported checksum protocol ${parsed.protocol} in ${url}`));
357
+ return;
358
+ }
324
359
  const client = parsed.protocol === "http:" ? http : https;
325
360
  request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
326
361
  const status = response.statusCode || 0;
327
362
  if (status >= 300 && status < 400 && response.headers.location) {
328
363
  response.resume();
329
- finish(
330
- fetchChecksumText(
331
- new URL(response.headers.location, url).toString(),
332
- budget - 1,
333
- totalTimeout,
334
- signal
335
- )
336
- );
364
+ let nextUrl;
365
+ try {
366
+ nextUrl = resolveRedirectUrl(response.headers.location, url, allowHttp);
367
+ } catch (redirectError) {
368
+ fail(redirectError);
369
+ return;
370
+ }
371
+ finish(fetchChecksumText(nextUrl, budget - 1, totalTimeout, signal, allowHttp));
337
372
  return;
338
373
  }
339
374
  if (status < 200 || status >= 300) {
@@ -467,15 +502,24 @@ function runtimeHomeDir(env) {
467
502
  return path.join(os.homedir(), RUNTIME_DIR_NAME);
468
503
  }
469
504
 
470
- function detectPython(env) {
505
+ function detectPython(env, options) {
506
+ const probeTimeoutMs =
507
+ options && options.probeTimeoutMs !== undefined
508
+ ? options.probeTimeoutMs
509
+ : PYTHON_PROBE_TIMEOUT_MS;
471
510
  const errors = [];
472
511
  for (const candidate of pythonCandidates(process.platform, env || process.env)) {
473
512
  const probe = spawnSync(candidate.command, [...candidate.preArgs, "--version"], {
474
513
  encoding: "utf8",
475
514
  windowsHide: true,
515
+ timeout: probeTimeoutMs,
476
516
  });
477
517
  if (probe.error) {
478
- errors.push(`${candidate.command}: not found`);
518
+ errors.push(
519
+ probe.error.code === "ETIMEDOUT"
520
+ ? `${candidate.command}: probing --version timed out after ${Math.round(probeTimeoutMs / 1000)}s`
521
+ : `${candidate.command}: not found`
522
+ );
479
523
  continue;
480
524
  }
481
525
  const stdout = `${probe.stdout || ""}${probe.stderr || ""}`;
@@ -509,19 +553,34 @@ function detectPython(env) {
509
553
  const CERT_VERIFY_FAILURE_MARKER = "CERTIFICATE_VERIFY_FAILED";
510
554
 
511
555
  function runStep(command, args, options) {
556
+ const timeoutMs =
557
+ options && options.timeout !== undefined ? options.timeout : STEP_TIMEOUT_MS;
512
558
  const result = spawnSync(command, args, {
513
559
  stdio: ["ignore", "pipe", "pipe"],
514
560
  windowsHide: true,
515
561
  maxBuffer: 16 * 1024 * 1024,
562
+ timeout: timeoutMs,
516
563
  ...options,
517
564
  });
518
565
  const capturedOutput = `${result.stdout || ""}${result.stderr || ""}`;
519
566
  if (capturedOutput) {
520
567
  process.stderr.write(capturedOutput);
521
568
  }
569
+ if (result.error && result.error.code === "ETIMEDOUT") {
570
+ throw new BootstrapError(
571
+ `${command} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`
572
+ );
573
+ }
522
574
  if (result.error) {
523
575
  throw new BootstrapError(`failed to run ${command}: ${result.error.message}`);
524
576
  }
577
+ if (result.status === null) {
578
+ // Killed by a signal (not a numeric exit): "exited with code null" would
579
+ // send users hunting for a nonexistent exit code.
580
+ throw new BootstrapError(
581
+ `${command} ${args.join(" ")} was terminated by signal ${result.signal || "unknown"}`
582
+ );
583
+ }
525
584
  if (result.status !== 0) {
526
585
  if (capturedOutput.includes(CERT_VERIFY_FAILURE_MARKER)) {
527
586
  throw certVerifyFailure(command, args, result.status);
@@ -552,8 +611,10 @@ function runPipStepAsync(python, args, signal) {
552
611
  }
553
612
  process.stderr.write(text);
554
613
  };
555
- child.stdout.on("data", forwardOutput);
556
- child.stderr.on("data", forwardOutput);
614
+ // On some spawn failures the stdio streams are null; the 'error' handler
615
+ // below still reports the failure, but attaching must not throw first.
616
+ if (child.stdout) child.stdout.on("data", forwardOutput);
617
+ if (child.stderr) child.stderr.on("data", forwardOutput);
557
618
  const fail = (error) => {
558
619
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
559
620
  if (!child.killed) child.kill();
@@ -606,11 +667,12 @@ function installCandidates(version, env) {
606
667
  // Artifact download fallback (Node-side TLS — survives broken pip trust stores)
607
668
  // ---------------------------------------------------------------------------
608
669
 
609
- function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
670
+ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal, allowHttp) {
610
671
  const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
611
672
  const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
612
673
  return new Promise((resolve, reject) => {
613
674
  let request = null;
675
+ let file = null;
614
676
  let timer = null;
615
677
  let onAbort = null;
616
678
  let settled = false;
@@ -626,7 +688,12 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
626
688
  settled = true;
627
689
  if (timer) clearTimeout(timer);
628
690
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
691
+ // Tear down the in-flight file stream (a timed-out/aborted download
692
+ // would otherwise leak the fd) and never leave a truncated artifact
693
+ // behind that a later run could mistake for a complete one.
694
+ if (file) file.destroy();
629
695
  if (request) request.destroy(error);
696
+ try { fs.rmSync(dest, { force: true }); } catch { /* best-effort */ }
630
697
  reject(error);
631
698
  };
632
699
  timer = setTimeout(() => {
@@ -655,13 +722,23 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
655
722
  fail(new BootstrapError(`invalid download URL ${url}: ${error.message}`));
656
723
  return;
657
724
  }
725
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
726
+ fail(new BootstrapError(`unsupported download protocol ${parsed.protocol} in ${url}`));
727
+ return;
728
+ }
658
729
  const client = parsed.protocol === "http:" ? http : https;
659
730
  request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
660
731
  const status = response.statusCode || 0;
661
732
  if (status >= 300 && status < 400 && response.headers.location) {
662
733
  response.resume();
663
- const nextUrl = new URL(response.headers.location, url).toString();
664
- finish(downloadFile(nextUrl, dest, budget - 1, totalTimeout, signal));
734
+ let nextUrl;
735
+ try {
736
+ nextUrl = resolveRedirectUrl(response.headers.location, url, allowHttp);
737
+ } catch (redirectError) {
738
+ fail(redirectError);
739
+ return;
740
+ }
741
+ finish(downloadFile(nextUrl, dest, budget - 1, totalTimeout, signal, allowHttp));
665
742
  return;
666
743
  }
667
744
  if (status < 200 || status >= 300) {
@@ -669,8 +746,13 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
669
746
  fail(new BootstrapError(`downloading ${url} failed with HTTP ${status}`));
670
747
  return;
671
748
  }
672
- const file = fs.createWriteStream(dest);
749
+ file = fs.createWriteStream(dest);
673
750
  response.pipe(file);
751
+ // A socket reset mid-body emits on the response, not the file: without
752
+ // this handler the download would stall until the total timeout fired.
753
+ response.on("error", (responseError) => {
754
+ fail(responseError);
755
+ });
674
756
  file.on("finish", () => {
675
757
  file.close((closeError) => {
676
758
  if (closeError) {
@@ -681,7 +763,6 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
681
763
  });
682
764
  });
683
765
  file.on("error", (fileError) => {
684
- file.destroy();
685
766
  fail(fileError);
686
767
  });
687
768
  });
@@ -692,12 +773,17 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
692
773
  });
693
774
  }
694
775
 
695
- function curlDownload(url, dest, spawnFn) {
776
+ // `--proto-redir` pins the protocols curl may follow a redirect to: by
777
+ // default only https, so a 302 to plain http cannot bypass the --allow-http
778
+ // gate the original URL was checked against (curl -L would happily fetch it).
779
+ function curlDownload(url, dest, spawnFn, allowHttp) {
696
780
  const runner = spawnFn || spawnSync;
697
781
  const result = runner("curl", [
698
782
  "-fsSL",
699
783
  "--max-time",
700
784
  String(CURL_MAX_TIME_SECONDS),
785
+ "--proto-redir",
786
+ allowHttp ? "=https,http" : "=https",
701
787
  "-o",
702
788
  dest,
703
789
  url,
@@ -720,10 +806,18 @@ function curlDownload(url, dest, spawnFn) {
720
806
 
721
807
  async function downloadTarballTo(url, dest, deps) {
722
808
  const signal = deps && deps.signal;
809
+ const allowHttp = Boolean(deps && deps.allowHttp);
723
810
  const nodeDownload =
724
811
  (deps && deps.nodeDownload) ||
725
812
  ((downloadUrl, downloadDest) =>
726
- downloadFile(downloadUrl, downloadDest, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal));
813
+ downloadFile(
814
+ downloadUrl,
815
+ downloadDest,
816
+ undefined,
817
+ DOWNLOAD_TOTAL_TIMEOUT_MS,
818
+ signal,
819
+ allowHttp
820
+ ));
727
821
  const curlDownloadFn = deps && deps.curlDownload ? deps.curlDownload : curlDownload;
728
822
  fs.mkdirSync(path.dirname(dest), { recursive: true });
729
823
  const untrack = trackDownload(dest);
@@ -736,7 +830,7 @@ async function downloadTarballTo(url, dest, deps) {
736
830
  {
737
831
  name: "curl",
738
832
  run: async () => {
739
- const outcome = curlDownloadFn(url, dest);
833
+ const outcome = curlDownloadFn(url, dest, undefined, allowHttp);
740
834
  if (!outcome.ok) {
741
835
  throw outcome.error;
742
836
  }
@@ -785,10 +879,10 @@ async function installHarness(options) {
785
879
  const runPipStep = options.runStepFn || runPipStepAsync;
786
880
  const downloader = options.downloader
787
881
  ? options.downloader
788
- : (url, dest) => downloadTarballTo(url, dest, { signal });
882
+ : (url, dest) => downloadTarballTo(url, dest, { signal, allowHttp });
789
883
  const checksumFetcher = options.checksumFetcher
790
884
  ? options.checksumFetcher
791
- : (url) => fetchChecksumText(url, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal);
885
+ : (url) => fetchChecksumText(url, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal, allowHttp);
792
886
 
793
887
  if (!candidates || candidates.length === 0) {
794
888
  throw new BootstrapError("no install candidate URLs were provided");
@@ -854,22 +948,98 @@ async function installRemoteArtifact(
854
948
  signal
855
949
  ) {
856
950
  assertHttpsInstallSource(url, allowHttp);
951
+ let primaryError;
857
952
  try {
858
953
  await runStepFn(python, pipInstallArgs(url), signal);
859
954
  return;
860
- } catch (primaryError) {
955
+ } catch (error) {
956
+ primaryError = error;
861
957
  process.stderr.write(
862
958
  `[iterate-harness] pip install from ${url} failed; retrying via direct download ...\n`
863
959
  );
864
960
  }
865
961
  const cachePath = downloadCachePath(homeDir, version, artifactExtensionFor(url));
866
- await downloader(url, cachePath);
867
- // These are bytes we fetched ourselves, so we own their integrity: check the
868
- // published SHA256 sidecar before handing the file to pip.
869
- await verifyDownloadedArtifact(url, cachePath, checksumFetcher, noVerify);
962
+ try {
963
+ await downloader(url, cachePath);
964
+ // These are bytes we fetched ourselves, so we own their integrity: check
965
+ // the published SHA256 sidecar before handing the file to pip.
966
+ await verifyDownloadedArtifact(url, cachePath, checksumFetcher, noVerify);
967
+ } catch (downloadError) {
968
+ // Keep the primary pip failure in the message: "all install candidates
969
+ // failed" is useless if it only reports the fallback's error.
970
+ throw new BootstrapError(
971
+ `pip install from ${url} failed (${primaryError.message}); ` +
972
+ `the direct-download fallback failed too: ${downloadError.message}`
973
+ );
974
+ }
870
975
  await runStepFn(python, pipInstallArgs(cachePath), signal);
871
976
  }
872
977
 
978
+ // Serialize concurrent bootstraps with an atomically-created lock file
979
+ // (`open(..., "wx")` is O_EXCL — no check-then-create race). A lock whose mtime
980
+ // is older than LOCK_STALE_MS belongs to a crashed installer and is reaped;
981
+ // a live waiter polls until the holder releases or LOCK_ACQUIRE_TIMEOUT_MS
982
+ // elapses. The release callback only deletes a lock still bearing OUR token,
983
+ // so it can never free a lock that was stale-reaped and re-taken meanwhile.
984
+ async function acquireBootstrapLock(homeDir, options) {
985
+ const opts = options || {};
986
+ const pollIntervalMs =
987
+ opts.pollIntervalMs !== undefined ? opts.pollIntervalMs : LOCK_POLL_INTERVAL_MS;
988
+ const acquireTimeoutMs =
989
+ opts.acquireTimeoutMs !== undefined ? opts.acquireTimeoutMs : LOCK_ACQUIRE_TIMEOUT_MS;
990
+ const staleMs = opts.staleMs !== undefined ? opts.staleMs : LOCK_STALE_MS;
991
+ fs.mkdirSync(homeDir, { recursive: true });
992
+ const lockPath = path.join(homeDir, LOCK_FILE_NAME);
993
+ const token = `${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
994
+ const deadline = Date.now() + acquireTimeoutMs;
995
+ for (;;) {
996
+ try {
997
+ const fd = fs.openSync(lockPath, "wx");
998
+ try {
999
+ fs.writeSync(fd, token);
1000
+ } finally {
1001
+ fs.closeSync(fd);
1002
+ }
1003
+ return () => {
1004
+ try {
1005
+ if (fs.readFileSync(lockPath, "utf8") === token) {
1006
+ fs.rmSync(lockPath, { force: true });
1007
+ }
1008
+ } catch {
1009
+ /* best-effort: a leftover stale lock is reaped by the next waiter */
1010
+ }
1011
+ };
1012
+ } catch (error) {
1013
+ if (error.code !== "EEXIST") {
1014
+ throw new BootstrapError(`cannot create bootstrap lock ${lockPath}: ${error.message}`);
1015
+ }
1016
+ }
1017
+ try {
1018
+ const stat = fs.statSync(lockPath);
1019
+ if (Date.now() - stat.mtimeMs > staleMs) {
1020
+ process.stderr.write(
1021
+ `[iterate-harness] removing stale bootstrap lock ${lockPath}\n`
1022
+ );
1023
+ fs.rmSync(lockPath, { force: true });
1024
+ continue;
1025
+ }
1026
+ } catch (statError) {
1027
+ if (statError.code === "ENOENT") {
1028
+ // Holder released between our open and the stat — retry immediately.
1029
+ continue;
1030
+ }
1031
+ throw new BootstrapError(`cannot stat bootstrap lock ${lockPath}: ${statError.message}`);
1032
+ }
1033
+ if (Date.now() > deadline) {
1034
+ throw new BootstrapError(
1035
+ `timed out waiting for the bootstrap lock ${lockPath}; ` +
1036
+ "if no other `ih`/npm install is running, remove that file and retry"
1037
+ );
1038
+ }
1039
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1040
+ }
1041
+ }
1042
+
873
1043
  async function bootstrap(homeDir, version, env, options) {
874
1044
  const opts = options || {};
875
1045
  fs.mkdirSync(homeDir, { recursive: true });
@@ -946,9 +1116,17 @@ async function ensureRuntime(env, options) {
946
1116
  ? fs.readFileSync(stampPath, "utf8")
947
1117
  : "";
948
1118
 
949
- if (!skipInstall && needsBootstrap(stampContent, version)) {
1119
+ // Bootstrap is needed when the stamp does not match THIS wrapper's version
1120
+ // OR when the venv's ih entry point is gone even though the stamp survived
1121
+ // (user deleted/corrupted the venv) — self-heal instead of demanding
1122
+ // `rm -rf` on every run.
1123
+ const stampOutdated = needsBootstrap(stampContent, version);
1124
+ const ihMissing = !fs.existsSync(executable.ih);
1125
+
1126
+ if (!skipInstall && (stampOutdated || ihMissing)) {
950
1127
  if (opts.interactive) {
951
- // Interactive install wizard: ask the user before downloading.
1128
+ // Interactive install wizard: ask the user before downloading. Asked
1129
+ // before taking the bootstrap lock so a waiting user never holds it.
952
1130
  const confirmed = await ui.askYesNo(
953
1131
  `Install iterate-harness v${version} into ${homeDir}?`,
954
1132
  true
@@ -958,18 +1136,36 @@ async function ensureRuntime(env, options) {
958
1136
  "Skipped installing iterate-harness. Re-run `ih` whenever you are ready."
959
1137
  );
960
1138
  }
961
- ui.frameSection("Installing", [
962
- `\x1b[36m◆\x1b[0m Version: v${version}`,
963
- `\x1b[36m◆\x1b[0m Runtime: ${homeDir}`,
964
- ]);
965
1139
  }
966
- await bootstrap(homeDir, version, environment, opts);
967
- if (opts.interactive) {
968
- ui.frameSection("Done", [
969
- `\x1b[32m✓\x1b[0m iterate-harness v${version} installed`,
970
- ` Run \x1b[36mih --help\x1b[0m to see all commands.`,
971
- ` Try \x1b[36mih status\x1b[0m or \x1b[36mih iterate --help\x1b[0m to get started.`,
972
- ]);
1140
+ // Concurrent `ih` runs (or postinstall racing the first run) must not
1141
+ // bootstrap into the same venv at once — serialize on the lock file and
1142
+ // re-check the stamp afterwards (another process may have finished while
1143
+ // we waited).
1144
+ const releaseLock = await acquireBootstrapLock(homeDir);
1145
+ try {
1146
+ const currentStamp = fs.existsSync(stampPath)
1147
+ ? fs.readFileSync(stampPath, "utf8")
1148
+ : "";
1149
+ if (!needsBootstrap(currentStamp, version) && fs.existsSync(executable.ih)) {
1150
+ ui.info(`iterate-harness ${version} was installed by another process; reusing it.`);
1151
+ } else {
1152
+ if (opts.interactive) {
1153
+ ui.frameSection("Installing", [
1154
+ `\x1b[36m◆\x1b[0m Version: v${version}`,
1155
+ `\x1b[36m◆\x1b[0m Runtime: ${homeDir}`,
1156
+ ]);
1157
+ }
1158
+ await bootstrap(homeDir, version, environment, opts);
1159
+ if (opts.interactive) {
1160
+ ui.frameSection("Done", [
1161
+ `\x1b[32m✓\x1b[0m iterate-harness v${version} installed`,
1162
+ ` Run \x1b[36mih --help\x1b[0m to see all commands.`,
1163
+ ` Try \x1b[36mih status\x1b[0m or \x1b[36mih iterate --help\x1b[0m to get started.`,
1164
+ ]);
1165
+ }
1166
+ }
1167
+ } finally {
1168
+ releaseLock();
973
1169
  }
974
1170
  }
975
1171
 
@@ -1082,6 +1278,7 @@ module.exports = {
1082
1278
  BootstrapError,
1083
1279
  CancelledError,
1084
1280
  DEFAULT_ARTIFACT_EXT,
1281
+ LOCK_FILE_NAME,
1085
1282
  MAX_DOWNLOAD_REDIRECTS,
1086
1283
  MIN_PYTHON_MAJOR,
1087
1284
  MIN_PYTHON_MINOR,
@@ -1089,8 +1286,11 @@ module.exports = {
1089
1286
  HOME_ENV_VAR,
1090
1287
  INSTALL_URL_ENV_VAR,
1091
1288
  SKIP_INSTALL_ENV_VAR,
1289
+ STEP_TIMEOUT_MS,
1290
+ PYTHON_PROBE_TIMEOUT_MS,
1092
1291
  WHEEL_ASSET_SUFFIX,
1093
1292
  CHECKSUM_ASSET_SUFFIX,
1293
+ acquireBootstrapLock,
1094
1294
  artifactExtensionFor,
1095
1295
  checksumAssetUrl,
1096
1296
  detectPython,
@@ -1100,6 +1300,8 @@ module.exports = {
1100
1300
  downloadTarballTo,
1101
1301
  fetchChecksumText,
1102
1302
  parseChecksum,
1303
+ resolveRedirectUrl,
1304
+ runStep,
1103
1305
  sha256File,
1104
1306
  verifyDownloadedArtifact,
1105
1307
  ensureRuntime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-harness",
3
- "version": "2.5.0",
3
+ "version": "2.7.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",