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.
- package/lib/bootstrap.js +165 -22
- package/lib/ui.js +22 -8
- 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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
489
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
|
1208
|
-
const
|
|
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
|
-
|
|
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.
|
|
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(
|
|
112
|
+
rl.on("close", () => finish(fallback));
|
|
106
113
|
if (timeoutMs > 0) {
|
|
107
114
|
timer = setTimeout(() => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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.
|
|
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",
|