iterate-harness 2.6.0 → 2.8.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 +165 -22
  2. package/lib/ui.js +22 -8
  3. package/package.json +1 -1
package/lib/bootstrap.js CHANGED
@@ -50,7 +50,11 @@ const STAMP_FILE_NAME = "version.stamp";
50
50
  const TARBALL_CACHE_DIR_NAME = "cache";
51
51
 
52
52
  const MAX_DOWNLOAD_REDIRECTS = 5;
53
- const DOWNLOAD_TIMEOUT_MS = 120000;
53
+ // Per-request *inactivity* timeout: no bytes on the socket for this long means
54
+ // the connection has died. It must stay below DOWNLOAD_TOTAL_TIMEOUT_MS or the
55
+ // wall-clock cap always wins and the `request.on("timeout")` branch below can
56
+ // never fire.
57
+ const DOWNLOAD_TIMEOUT_MS = 30000;
54
58
  // Hard wall-clock cap per single self-download (a stale-but-connected registry
55
59
  // that never dribbles more bytes must not be able to hang the bootstrap).
56
60
  const DOWNLOAD_TOTAL_TIMEOUT_MS = 60000;
@@ -277,8 +281,67 @@ function venvExecutablePaths(venvDir, platform) {
277
281
  };
278
282
  }
279
283
 
280
- function needsBootstrap(stampContent, expectedVersion) {
281
- return String(stampContent || "").trim() !== String(expectedVersion || "").trim();
284
+ // The inputs that determine what a runtime *is*. Recording them in the stamp
285
+ // lets the wrapper rebuild when the user flips the lever that built the venv,
286
+ // instead of silently reusing a runtime they did not ask for.
287
+ function runtimeInputs(version, environment) {
288
+ const env = environment || process.env;
289
+ return {
290
+ version: String(version || ""),
291
+ pythonEnv: String(env[PYTHON_ENV_VAR] || ""),
292
+ source: String((installCandidates(version, env) || [])[0] || ""),
293
+ };
294
+ }
295
+
296
+ function formatStamp(inputs) {
297
+ return JSON.stringify(inputs);
298
+ }
299
+
300
+ function parseStamp(stampContent) {
301
+ // Stamps written by this wrapper are a JSON object (version + the inputs
302
+ // they were built from). Stamps written by an older wrapper are a bare
303
+ // version string, and are marked `legacy`: they cannot prove which
304
+ // interpreter or install source built the runtime they describe.
305
+ const text = String(stampContent || "").trim();
306
+ if (!text) return null;
307
+ if (text[0] === "{") {
308
+ try {
309
+ const parsed = JSON.parse(text);
310
+ if (parsed && typeof parsed.version === "string") {
311
+ return {
312
+ version: parsed.version,
313
+ pythonEnv: String(parsed.pythonEnv || ""),
314
+ source: String(parsed.source || ""),
315
+ legacy: false,
316
+ };
317
+ }
318
+ } catch {
319
+ /* fall through to the legacy single-string shape */
320
+ }
321
+ }
322
+ return { version: text, pythonEnv: "", source: "", legacy: true };
323
+ }
324
+
325
+ // Bootstrap is needed when the stamp's version differs from THIS wrapper's, or
326
+ // when the interpreter override / install source changed since the stamp was
327
+ // written. Reusing a runtime built with a different Python — or installed from
328
+ // a different URL — is worse than one extra reinstall: the user sees an
329
+ // `ih --version` that disagrees with the wrapper that launched it, and nothing
330
+ // explains why.
331
+ //
332
+ // A legacy bare-version stamp proves nothing about inputs, so it rebuilds once
333
+ // and is rewritten with inputs. That costs nothing in practice: any upgrade
334
+ // already changes the version and forces a reinstall, so the only extra
335
+ // reinstall is for a stamp whose version already matches this wrapper.
336
+ function needsBootstrap(stampContent, expectedVersion, expectedInputs) {
337
+ const stamp = parseStamp(stampContent);
338
+ if (!stamp || stamp.version !== String(expectedVersion || "").trim()) return true;
339
+ if (stamp.legacy) return true;
340
+ if (!expectedInputs) return false;
341
+ return (
342
+ String(stamp.pythonEnv || "") !== String(expectedInputs.pythonEnv || "") ||
343
+ String(stamp.source || "") !== String(expectedInputs.source || "")
344
+ );
282
345
  }
283
346
 
284
347
  function isRemoteHttpUrl(target) {
@@ -476,6 +539,8 @@ function pipInstallArgs(target) {
476
539
  // Runtime resolution
477
540
  // ---------------------------------------------------------------------------
478
541
 
542
+ const UNKNOWN_VERSION = "0.0.0-unknown";
543
+
479
544
  function packageVersion() {
480
545
  const manifestPath = path.resolve(__dirname, "..", "package.json");
481
546
  try {
@@ -485,13 +550,18 @@ function packageVersion() {
485
550
  }
486
551
  } catch (error) {
487
552
  // A corrupted/missing manifest must not crash every `ih` invocation with an
488
- // unhandled exception; fall back to an unknown version so the wrapper can
489
- // still run the Python harness and surface the real error later if any.
553
+ // unhandled exception. Return UNKNOWN_VERSION and let the caller decide:
554
+ // an existing runtime is reused, and a fresh install is refused rather
555
+ // than attempted against a version that cannot exist.
490
556
  process.stderr.write(
491
557
  `[iterate-harness] warning: cannot read ${manifestPath}: ${error.message}\n`
492
558
  );
493
559
  }
494
- return "0.0.0-unknown";
560
+ return UNKNOWN_VERSION;
561
+ }
562
+
563
+ function isUnknownVersion(version) {
564
+ return version === UNKNOWN_VERSION;
495
565
  }
496
566
 
497
567
  function runtimeHomeDir(env) {
@@ -1096,7 +1166,15 @@ async function bootstrap(homeDir, version, env, options) {
1096
1166
  }
1097
1167
 
1098
1168
  const stampPath = path.join(homeDir, STAMP_FILE_NAME);
1099
- fs.writeFileSync(stampPath, `${version}\n`, "utf8");
1169
+ // The stamp records what the runtime was built FROM, not just which version:
1170
+ // a venv created with `ITERATE_HARNESS_PYTHON` set, or from an
1171
+ // `ITERATE_HARNESS_INSTALL_URL` override, must not be silently reused once
1172
+ // that override disappears or changes.
1173
+ fs.writeFileSync(
1174
+ stampPath,
1175
+ `${formatStamp(runtimeInputs(version, env))}\n`,
1176
+ "utf8"
1177
+ );
1100
1178
  ui.success(`iterate-harness ${version} installed`);
1101
1179
  }
1102
1180
 
@@ -1116,11 +1194,34 @@ async function ensureRuntime(env, options) {
1116
1194
  ? fs.readFileSync(stampPath, "utf8")
1117
1195
  : "";
1118
1196
 
1197
+ // A version we cannot read cannot be installed: `iterate-harness==0.0.0-unknown`
1198
+ // does not exist, so attempting a bootstrap here would fail every run and
1199
+ // re-download nothing. Reuse the runtime that is already there, or refuse
1200
+ // with an actionable error instead of a doomed install.
1201
+ if (isUnknownVersion(version)) {
1202
+ if (fs.existsSync(executable.ih)) {
1203
+ ui.warning(
1204
+ `wrapper version is unreadable; reusing the existing runtime at ${homeDir}`
1205
+ );
1206
+ return executable.ih;
1207
+ }
1208
+ throw new BootstrapError(
1209
+ [
1210
+ "cannot determine the wrapper version (npm/package.json is missing or unreadable),",
1211
+ "and no runtime is installed yet — nothing to run.",
1212
+ "Reinstall the package (`npm i -g iterate-harness`) or restore its package.json.",
1213
+ ].join(" ")
1214
+ );
1215
+ }
1216
+
1119
1217
  // Bootstrap is needed when the stamp does not match THIS wrapper's version
1120
1218
  // OR when the venv's ih entry point is gone even though the stamp survived
1121
1219
  // (user deleted/corrupted the venv) — self-heal instead of demanding
1122
- // `rm -rf` on every run.
1123
- const stampOutdated = needsBootstrap(stampContent, version);
1220
+ // `rm -rf` on every run. The stamp also carries the interpreter/install
1221
+ // source it was built with, so flipping ITERATE_HARNESS_PYTHON or
1222
+ // ITERATE_HARNESS_INSTALL_URL triggers a rebuild instead of a silent reuse.
1223
+ const inputs = runtimeInputs(version, environment);
1224
+ const stampOutdated = needsBootstrap(stampContent, version, inputs);
1124
1225
  const ihMissing = !fs.existsSync(executable.ih);
1125
1226
 
1126
1227
  if (!skipInstall && (stampOutdated || ihMissing)) {
@@ -1146,7 +1247,7 @@ async function ensureRuntime(env, options) {
1146
1247
  const currentStamp = fs.existsSync(stampPath)
1147
1248
  ? fs.readFileSync(stampPath, "utf8")
1148
1249
  : "";
1149
- if (!needsBootstrap(currentStamp, version) && fs.existsSync(executable.ih)) {
1250
+ if (!needsBootstrap(currentStamp, version, inputs) && fs.existsSync(executable.ih)) {
1150
1251
  ui.info(`iterate-harness ${version} was installed by another process; reusing it.`);
1151
1252
  } else {
1152
1253
  if (opts.interactive) {
@@ -1187,16 +1288,53 @@ async function ensureRuntime(env, options) {
1187
1288
  // Delegation to the real ih executable
1188
1289
  // ---------------------------------------------------------------------------
1189
1290
 
1291
+ // Every error path funnels through here, including the top-level promise
1292
+ // rejection handler in the bin scripts. A stderr write can itself fail
1293
+ // (closed/redirected console — the classic Windows EPIPE/EBADF case, or a
1294
+ // destroyed stream), and throwing from inside the error handler would turn a
1295
+ // handled failure into an unhandled rejection with a raw stack trace. Never
1296
+ // let the reporting path blow up.
1297
+ function safeWriteTo(stream, text) {
1298
+ try {
1299
+ stream.write(text);
1300
+ } catch {
1301
+ /* best-effort: nothing else can report this failure */
1302
+ }
1303
+ }
1304
+
1190
1305
  function reportBootstrapFailure(error) {
1191
1306
  const message = error instanceof BootstrapError ? error.message : (error && error.stack) || error;
1192
- process.stderr.write(`[iterate-harness] ${message}\n`);
1307
+ safeWriteTo(process.stderr, `[iterate-harness] ${message}\n`);
1193
1308
  }
1194
1309
 
1195
1310
  // The install wizard only makes sense when a human can actually answer: both
1196
1311
  // stdin and stderr must be TTYs. Piped/CI/`< /dev/null` runs bootstrap
1197
1312
  // non-interactively so they never block on a prompt that nobody can see.
1198
- function interactiveSession() {
1199
- return Boolean(process.stdin.isTTY && process.stderr.isTTY);
1313
+ // `streams` is injectable for tests, since `process.stdin`/`process.stderr` are
1314
+ // getter-only and cannot be reassigned.
1315
+ function interactiveSession(streams) {
1316
+ const s = streams || {};
1317
+ const stdin = s.stdin === undefined ? process.stdin : s.stdin;
1318
+ const stderr = s.stderr === undefined ? process.stderr : s.stderr;
1319
+ return Boolean(stdin.isTTY && stderr.isTTY);
1320
+ }
1321
+
1322
+ // Split the CLI argv into what the wrapper consumes and what it forwards to
1323
+ // the delegated `ih` child. The wrapper flags belong to the wrapper ONLY when
1324
+ // they appear before a `--` separator: anything after `--` is the child's, so
1325
+ // `ih sub -- --no-verify` must forward that flag instead of swallowing it.
1326
+ function splitHarnessArgs(args) {
1327
+ const cliArgs = Array.isArray(args) ? args : [];
1328
+ const separator = cliArgs.indexOf("--");
1329
+ const wrapperSlice = separator === -1 ? cliArgs : cliArgs.slice(0, separator);
1330
+ return {
1331
+ forwardedArgs: [
1332
+ ...wrapperSlice.filter((arg) => arg !== NO_VERIFY_FLAG && arg !== ALLOW_HTTP_FLAG),
1333
+ ...(separator === -1 ? [] : cliArgs.slice(separator)),
1334
+ ],
1335
+ noVerify: wrapperSlice.includes(NO_VERIFY_FLAG),
1336
+ allowHttp: wrapperSlice.includes(ALLOW_HTTP_FLAG),
1337
+ };
1200
1338
  }
1201
1339
 
1202
1340
  async function runHarness(args, env) {
@@ -1204,14 +1342,8 @@ async function runHarness(args, env) {
1204
1342
  // when stderr is not a TTY (e.g. piped output), so `ih --version | jq` stays clean.
1205
1343
  ui.printBanner();
1206
1344
 
1207
- const cliArgs = Array.isArray(args) ? args : [];
1208
- const forwardedArgs = cliArgs.filter(
1209
- (arg) => arg !== NO_VERIFY_FLAG && arg !== ALLOW_HTTP_FLAG
1210
- );
1211
- const options = {
1212
- noVerify: cliArgs.includes(NO_VERIFY_FLAG),
1213
- allowHttp: cliArgs.includes(ALLOW_HTTP_FLAG),
1214
- };
1345
+ const { forwardedArgs, noVerify, allowHttp } = splitHarnessArgs(args);
1346
+ const options = { noVerify, allowHttp };
1215
1347
 
1216
1348
  // Signal handling spans the whole run: during bootstrap a Ctrl-C/SIGTERM
1217
1349
  // must clean up in-flight downloads and not orphan a pip/python child; after
@@ -1258,7 +1390,12 @@ async function runHarness(args, env) {
1258
1390
  });
1259
1391
 
1260
1392
  child.on("error", (error) => {
1261
- process.stderr.write(`[iterate-harness] failed to launch ih: ${error.message}\n`);
1393
+ // Same guard as reportBootstrapFailure: a failing stderr write here would
1394
+ // turn a launch failure into an uncaught exception instead of a clean exit.
1395
+ safeWriteTo(
1396
+ process.stderr,
1397
+ `[iterate-harness] failed to launch ih: ${error.message}\n`
1398
+ );
1262
1399
  process.exit(1);
1263
1400
  });
1264
1401
  child.on("close", (code, signal) => {
@@ -1311,15 +1448,21 @@ module.exports = {
1311
1448
  interactiveSession,
1312
1449
  isRemoteHttpUrl,
1313
1450
  isSupportedPython,
1451
+ isUnknownVersion,
1314
1452
  needsBootstrap,
1315
1453
  packageVersion,
1316
1454
  parsePythonVersion,
1455
+ parseStamp,
1317
1456
  pipInstallArgs,
1318
1457
  pypiInstallSpec,
1319
1458
  pythonCandidates,
1320
1459
  releaseTarballUrl,
1321
1460
  reportBootstrapFailure,
1322
1461
  runHarness,
1462
+ runtimeInputs,
1463
+ splitHarnessArgs,
1464
+ formatStamp,
1465
+ UNKNOWN_VERSION,
1323
1466
  venvExecutablePaths,
1324
1467
  wheelAssetName,
1325
1468
  wheelAssetUrl,
package/lib/ui.js CHANGED
@@ -81,8 +81,8 @@ function frameSection(title, lines) {
81
81
  }
82
82
 
83
83
  // Safety net for unattended runs: if stdin is closed (EOF) or the user never
84
- // answers, fall back to the default instead of hanging forever. Override per
85
- // call with `options.timeoutMs` (0 disables the timeout entirely).
84
+ // answers, fall back to the *stated* default instead of hanging forever.
85
+ // Override per call with `options.timeoutMs` (0 disables the timeout entirely).
86
86
  const DEFAULT_PROMPT_TIMEOUT_MS = 120000;
87
87
 
88
88
  function askYesNo(question, defaultNo = false, options = {}) {
@@ -94,6 +94,13 @@ function askYesNo(question, defaultNo = false, options = {}) {
94
94
  let settled = false;
95
95
  let timer = null;
96
96
  const rl = readline.createInterface({ input, output });
97
+ // The default the prompt DISPLAYS is the answer that must be taken when
98
+ // nobody answers: `defaultNo=true` renders "[y/N]", so Enter, an EOF'd
99
+ // stdin, an unrecognised reply, and a timeout all mean "No". Resolving
100
+ // `defaultNo` verbatim here inverted that — it rendered "[y/N]" while
101
+ // auto-answering "yes", so an unattended first run confirmed a download
102
+ // of hundreds of megabytes that the user never agreed to.
103
+ const fallback = !defaultNo;
97
104
  const finish = (value) => {
98
105
  if (settled) return;
99
106
  settled = true;
@@ -102,13 +109,20 @@ function askYesNo(question, defaultNo = false, options = {}) {
102
109
  resolve(value);
103
110
  };
104
111
  // readline emits "close" on EOF (piped/`< /dev/null` stdin) — never hang.
105
- rl.on("close", () => finish(defaultNo));
112
+ rl.on("close", () => finish(fallback));
106
113
  if (timeoutMs > 0) {
107
114
  timer = setTimeout(() => {
108
- process.stderr.write(
109
- `\x1b[2m no response after ${Math.round(timeoutMs / 1000)}s; using default\x1b[0m\n`
110
- );
111
- finish(defaultNo);
115
+ // A failing stderr write inside this timer would surface as an uncaught
116
+ // exception rather than settling the prompt, so swallow it.
117
+ try {
118
+ output.write(
119
+ `\x1b[2m no response after ${Math.round(timeoutMs / 1000)}s; ` +
120
+ `using default ${defaultNo ? "(no)" : "(yes)"}\x1b[0m\n`
121
+ );
122
+ } catch {
123
+ /* best-effort: the prompt still settles on the default */
124
+ }
125
+ finish(fallback);
112
126
  }, timeoutMs);
113
127
  // Only unref the timer when the real terminal is in play. A TTY or
114
128
  // piped stdin keeps the event loop alive through its OS file descriptor,
@@ -131,7 +145,7 @@ function askYesNo(question, defaultNo = false, options = {}) {
131
145
  const a = String(answer).trim().toLowerCase();
132
146
  if (a === "y" || a === "yes") finish(true);
133
147
  else if (a === "n" || a === "no") finish(false);
134
- else finish(defaultNo);
148
+ else finish(fallback);
135
149
  });
136
150
  });
137
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-harness",
3
- "version": "2.6.0",
3
+ "version": "2.8.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",