electrobun 2.0.1-beta.7 → 2.0.1

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.
@@ -0,0 +1,1363 @@
1
+ "use strict";
2
+
3
+ // Resolve the exact Hutch release paired with this electrobun npm version.
4
+ // The single npm package stays platform-neutral: each Electrobun GitHub
5
+ // Release carries the four Hutch archives, and this shim verifies and caches
6
+ // the host archive on first use. A compatible machine-wide launcher remains
7
+ // a fallback, but Hutch before 0.22 cannot honor the paired default variables.
8
+
9
+ const { execFileSync, spawnSync } = require("node:child_process");
10
+ const { createHash, randomBytes } = require("node:crypto");
11
+ const {
12
+ accessSync,
13
+ chmodSync,
14
+ constants: fsConstants,
15
+ existsSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } = require("node:fs");
25
+ const { get } = require("node:https");
26
+ const { homedir, tmpdir } = require("node:os");
27
+ const path = require("node:path");
28
+
29
+ // Stamped by push-version.js from package/hutch.config.ts.
30
+ const PAIRED_HUTCH_VERSION = "0.24.3";
31
+ const ELECTROBUN_VERSION = require("../package.json").version;
32
+
33
+ const MINIMUM_DEFAULTS_HUTCH_VERSION = "0.22.0";
34
+ const HUTCH_ARTIFACT_INDEX_FILENAME = "hutch-artifacts.json";
35
+ const HUTCH_ARTIFACT_INDEX_SCHEMA_VERSION = 1;
36
+ const CACHE_MANIFEST_FILENAME = ".electrobun-cache.json";
37
+ const CACHE_MANIFEST_SCHEMA_VERSION = 1;
38
+ const CACHE_LOCK_OWNER_FILENAME = "owner.json";
39
+ const CACHE_LOCK_RELEASED_FILENAME = "released.json";
40
+ const CACHE_LOCK_ORPHAN_FILENAME = "orphaned.json";
41
+ const defaultInstallerBaseUrl = "https://hutch.blackboard.sh/hutch";
42
+ const defaultReleasesBaseUrl =
43
+ "https://github.com/blackboardsh/electrobun/releases/download";
44
+ const maxInstallerBytes = 1024 * 1024;
45
+ const maxArtifactIndexBytes = 1024 * 1024;
46
+ const maxArchiveBytes = 64 * 1024 * 1024;
47
+ const maxLauncherBytes = 16 * 1024 * 1024;
48
+ const maxEngineBytes = 64 * 1024 * 1024;
49
+ const maxReleaseMetadataBytes = 1024 * 1024;
50
+ const maxExtractedBytes = 80 * 1024 * 1024;
51
+ const maxRedirects = 5;
52
+ const downloadTimeoutMs = 30_000;
53
+ const hutchVersionProbeTimeoutMs = 15_000;
54
+ const cacheLockTimeoutMs = 15_000;
55
+ const cacheLockPollMs = 25;
56
+ const cacheLockOrphanGraceMs = 60_000;
57
+ const cacheRenameRetries = 4;
58
+ const cacheRenameRetryMs = 25;
59
+ const cacheLockWaiter = new Int32Array(new SharedArrayBuffer(4));
60
+ const cacheSleep = (milliseconds) =>
61
+ Atomics.wait(cacheLockWaiter, 0, 0, milliseconds);
62
+ const strictSemver =
63
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
64
+
65
+ const releasedPlatforms = {
66
+ "darwin-arm64": "macos-arm64",
67
+ "linux-arm64": "linux-arm64",
68
+ "linux-x64": "linux-x64",
69
+ "win32-x64": "windows-x64",
70
+ };
71
+
72
+ function normalizeHutchChannel(value) {
73
+ if (value === "stable") return "production";
74
+ if (value === "production" || value === "canary") return value;
75
+ return null;
76
+ }
77
+
78
+ function hutchChannel(environment) {
79
+ for (const key of ["ELECTROBUN_HUTCH_CHANNEL", "HUTCH_ACTIVE_CHANNEL"]) {
80
+ const selected = normalizeHutchChannel(environment[key]);
81
+ if (selected) return selected;
82
+ }
83
+ return "production";
84
+ }
85
+
86
+ function environmentFlagEnabled(environment, name) {
87
+ const value = environment[name];
88
+ return (
89
+ value === "1" ||
90
+ (typeof value === "string" &&
91
+ ["true", "yes"].includes(value.toLowerCase()))
92
+ );
93
+ }
94
+
95
+ function hutchPlatformKey(platform, arch) {
96
+ return releasedPlatforms[`${platform}-${arch}`] ?? null;
97
+ }
98
+
99
+ function pathApiForPlatform(platform) {
100
+ return platform === "win32" ? path.win32 : path.posix;
101
+ }
102
+
103
+ function hutchHomePath(environment, platform, userHome) {
104
+ const pathApi = pathApiForPlatform(platform);
105
+ return (
106
+ environment.HUTCH_HOME ||
107
+ environment.DASH_HOME ||
108
+ pathApi.join(userHome, ".hutch")
109
+ );
110
+ }
111
+
112
+ function globalHutchBinaryPath(channel, environment, platform, userHome) {
113
+ const pathApi = pathApiForPlatform(platform);
114
+ const command = channel === "canary" ? "hutch-canary" : "hutch";
115
+ return pathApi.join(
116
+ hutchHomePath(environment, platform, userHome),
117
+ "bin",
118
+ `${command}${platform === "win32" ? ".exe" : ""}`,
119
+ );
120
+ }
121
+
122
+ function downloadedHutchRoot(environment, platform, userHome, platformKey) {
123
+ const pathApi = pathApiForPlatform(platform);
124
+ return pathApi.join(
125
+ hutchHomePath(environment, platform, userHome),
126
+ "npm",
127
+ "electrobun",
128
+ ELECTROBUN_VERSION,
129
+ platformKey,
130
+ );
131
+ }
132
+
133
+ function hutchBinaryInRoot(root, platform) {
134
+ return path.join(root, "bin", platform === "win32" ? "hutch.exe" : "hutch");
135
+ }
136
+
137
+ function hutchEngineInRoot(root, platform) {
138
+ return path.join(
139
+ root,
140
+ "bin",
141
+ platform === "win32" ? "hutch-engine.exe" : "hutch-engine",
142
+ );
143
+ }
144
+
145
+ function validatedHttpsUrl(value, label) {
146
+ let url;
147
+ try {
148
+ url = new URL(value);
149
+ } catch {
150
+ throw new Error(`${label} is not a valid URL`);
151
+ }
152
+ const localHttp =
153
+ url.protocol === "http:" &&
154
+ (url.hostname === "127.0.0.1" || url.hostname === "localhost");
155
+ if (url.protocol !== "https:" && !localHttp) {
156
+ throw new Error(`${label} must use HTTPS`);
157
+ }
158
+ return url;
159
+ }
160
+
161
+ function releasesBaseUrl(environment) {
162
+ return validatedHttpsUrl(
163
+ environment.ELECTROBUN_RELEASES_BASE_URL ?? defaultReleasesBaseUrl,
164
+ "Electrobun releases base URL",
165
+ ).href.replace(/\/+$/, "");
166
+ }
167
+
168
+ function download(url, options = {}, redirects = 0) {
169
+ if (redirects > maxRedirects) {
170
+ return Promise.reject(new Error("too many download redirects"));
171
+ }
172
+ let target;
173
+ try {
174
+ target = validatedHttpsUrl(url, options.label ?? "download URL");
175
+ } catch (error) {
176
+ return Promise.reject(error);
177
+ }
178
+ const maximum = options.maxBytes ?? maxArchiveBytes;
179
+
180
+ return new Promise((resolve, reject) => {
181
+ const requestGet = options.requestGet ?? get;
182
+ const request = requestGet(target, (response) => {
183
+ if (
184
+ response.statusCode >= 300 &&
185
+ response.statusCode < 400 &&
186
+ response.headers.location
187
+ ) {
188
+ response.resume();
189
+ let redirected;
190
+ try {
191
+ redirected = new URL(response.headers.location, target);
192
+ } catch {
193
+ reject(new Error("invalid download redirect URL"));
194
+ return;
195
+ }
196
+ resolve(download(redirected.href, options, redirects + 1));
197
+ return;
198
+ }
199
+
200
+ if (response.statusCode !== 200) {
201
+ response.resume();
202
+ reject(
203
+ new Error(
204
+ `${options.label ?? "download"} returned HTTP ${response.statusCode ?? "unknown"}`,
205
+ ),
206
+ );
207
+ return;
208
+ }
209
+
210
+ const declared = Number(response.headers["content-length"]);
211
+ if (Number.isFinite(declared) && declared > maximum) {
212
+ response.resume();
213
+ reject(new Error(`${options.label ?? "download"} exceeded its size limit`));
214
+ return;
215
+ }
216
+
217
+ const chunks = [];
218
+ let size = 0;
219
+ response.on("data", (chunk) => {
220
+ size += chunk.length;
221
+ if (size > maximum) {
222
+ request.destroy(
223
+ new Error(`${options.label ?? "download"} exceeded its size limit`),
224
+ );
225
+ return;
226
+ }
227
+ chunks.push(chunk);
228
+ });
229
+ response.on("end", () => resolve(Buffer.concat(chunks)));
230
+ response.on("error", reject);
231
+ });
232
+ request.setTimeout(downloadTimeoutMs, () => {
233
+ request.destroy(new Error(`${options.label ?? "download"} timed out`));
234
+ });
235
+ request.on("error", reject);
236
+ });
237
+ }
238
+
239
+ function checkedSpawn(command, args, options) {
240
+ const result = spawnSync(command, args, options);
241
+ if (result.error) throw result.error;
242
+ if (result.status !== 0) {
243
+ throw new Error(`${command} exited with status ${result.status ?? "unknown"}`);
244
+ }
245
+ }
246
+
247
+ async function installHutch({ channel, environment, platform }) {
248
+ const temporary = mkdtempSync(path.join(tmpdir(), "electrobun-hutch-installer-"));
249
+ try {
250
+ if (platform === "win32") {
251
+ const installer = path.join(temporary, "install.ps1");
252
+ writeFileSync(
253
+ installer,
254
+ await download(`${defaultInstallerBaseUrl}/install.ps1`, {
255
+ label: "Hutch installer",
256
+ maxBytes: maxInstallerBytes,
257
+ }),
258
+ );
259
+ checkedSpawn(
260
+ "powershell.exe",
261
+ [
262
+ "-NoProfile",
263
+ "-NonInteractive",
264
+ "-ExecutionPolicy",
265
+ "Bypass",
266
+ "-File",
267
+ installer,
268
+ "-Channel",
269
+ channel,
270
+ ],
271
+ { env: environment, stdio: "inherit" },
272
+ );
273
+ } else {
274
+ const installer = path.join(temporary, "install.sh");
275
+ writeFileSync(
276
+ installer,
277
+ await download(`${defaultInstallerBaseUrl}/install.sh`, {
278
+ label: "Hutch installer",
279
+ maxBytes: maxInstallerBytes,
280
+ }),
281
+ { mode: 0o700 },
282
+ );
283
+ checkedSpawn("sh", [installer, "--channel", channel], {
284
+ env: environment,
285
+ stdio: "inherit",
286
+ });
287
+ }
288
+ } finally {
289
+ rmSync(temporary, { force: true, recursive: true });
290
+ }
291
+ }
292
+
293
+ function parseVersion(value) {
294
+ const match = typeof value === "string" ? value.match(strictSemver) : null;
295
+ if (!match) return null;
296
+ return {
297
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
298
+ prerelease: match[4] ?? null,
299
+ version: value,
300
+ };
301
+ }
302
+
303
+ function hutchBinaryVersion(
304
+ binary,
305
+ environment,
306
+ spawn = spawnSync,
307
+ sharedTemporaryDirectory = tmpdir(),
308
+ ) {
309
+ // Hutch discovers hutch.config.* through cwd ancestors. Running from the
310
+ // shared temp directory can therefore inherit an unrelated /tmp config;
311
+ // the filesystem root is outside that project-discovery subtree.
312
+ const neutralCwd = path.parse(path.resolve(sharedTemporaryDirectory)).root;
313
+ const result = spawn(binary, ["--version"], {
314
+ cwd: neutralCwd,
315
+ encoding: "utf8",
316
+ env: {
317
+ ...environment,
318
+ HUTCH_DEFAULT_CLI: PAIRED_HUTCH_VERSION,
319
+ HUTCH_DEFAULT_ELECTROBUN: ELECTROBUN_VERSION,
320
+ PWD: neutralCwd,
321
+ },
322
+ timeout: hutchVersionProbeTimeoutMs,
323
+ windowsHide: true,
324
+ });
325
+ if (result.error || result.status !== 0) return null;
326
+ const version = result.stdout?.trim();
327
+ return parseVersion(version) ? version : null;
328
+ }
329
+
330
+ function compatibleFallback(binary, environment, versionReader = hutchBinaryVersion) {
331
+ const version = versionReader(binary, environment);
332
+ return {
333
+ binary,
334
+ compatible: version === PAIRED_HUTCH_VERSION,
335
+ version,
336
+ };
337
+ }
338
+
339
+ function incompatibleFallbackError(label, fallback) {
340
+ const found = fallback.version
341
+ ? `selected Hutch ${fallback.version}`
342
+ : "could not select a Hutch release";
343
+ return new Error(
344
+ `${label} ${found}; it must honor HUTCH_DEFAULT_CLI and select paired Hutch ${PAIRED_HUTCH_VERSION} (launchers before ${MINIMUM_DEFAULTS_HUTCH_VERSION} ignore this default)`,
345
+ );
346
+ }
347
+
348
+ function object(value, label) {
349
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
350
+ throw new Error(`${label} must be an object`);
351
+ }
352
+ return value;
353
+ }
354
+
355
+ function validateArtifactIndex(bytes, baseUrl, platformKey) {
356
+ let index;
357
+ try {
358
+ index = JSON.parse(bytes.toString("utf8"));
359
+ } catch (error) {
360
+ throw new Error(`Hutch artifact index is invalid JSON: ${error.message}`);
361
+ }
362
+ object(index, "Hutch artifact index");
363
+ if (index.schemaVersion !== HUTCH_ARTIFACT_INDEX_SCHEMA_VERSION) {
364
+ throw new Error("unsupported Hutch artifact index schema");
365
+ }
366
+ const product = object(index.product, "Hutch artifact index product");
367
+ if (product.name !== "electrobun" || product.version !== ELECTROBUN_VERSION) {
368
+ throw new Error("Hutch artifact index Electrobun identity does not match");
369
+ }
370
+ const hutch = object(index.hutch, "Hutch artifact index release");
371
+ if (hutch.version !== PAIRED_HUTCH_VERSION) {
372
+ throw new Error("Hutch artifact index paired version does not match");
373
+ }
374
+ const platforms = object(index.platforms, "Hutch artifact index platforms");
375
+ const expectedPlatforms = [...new Set(Object.values(releasedPlatforms))].sort();
376
+ if (JSON.stringify(Object.keys(platforms).sort()) !== JSON.stringify(expectedPlatforms)) {
377
+ throw new Error("Hutch artifact index platform matrix is incomplete");
378
+ }
379
+ const archive = object(
380
+ object(platforms[platformKey], `${platformKey} artifact`).archive,
381
+ `${platformKey} archive`,
382
+ );
383
+ const filename = `electrobun-hutch-${platformKey}.tar.gz`;
384
+ const expectedUrl = `${baseUrl}/v${ELECTROBUN_VERSION}/${filename}`;
385
+ if (archive.url !== expectedUrl) {
386
+ throw new Error(`${platformKey} archive URL does not match the Electrobun release`);
387
+ }
388
+ if (!Number.isSafeInteger(archive.size) || archive.size < 1 || archive.size > maxArchiveBytes) {
389
+ throw new Error(`${platformKey} archive size is invalid`);
390
+ }
391
+ if (!/^[0-9a-f]{64}$/.test(archive.sha256)) {
392
+ throw new Error(`${platformKey} archive SHA-256 is invalid`);
393
+ }
394
+ return { filename, ...archive };
395
+ }
396
+
397
+ function expectedArchiveEntries(
398
+ platformKey,
399
+ platform,
400
+ expectedHutchVersion = PAIRED_HUTCH_VERSION,
401
+ ) {
402
+ const root = `hutch-v${expectedHutchVersion}-${platformKey}`;
403
+ const extension = platform === "win32" ? ".exe" : "";
404
+ const memberLimits = new Map([
405
+ [`${root}/bin/hutch${extension}`, maxLauncherBytes],
406
+ [`${root}/bin/hutch-engine${extension}`, maxEngineBytes],
407
+ [`${root}/hutch-release.json`, maxReleaseMetadataBytes],
408
+ ]);
409
+ return {
410
+ files: new Set(memberLimits.keys()),
411
+ memberLimits,
412
+ root,
413
+ };
414
+ }
415
+
416
+ function validateArchiveEntries(
417
+ archivePath,
418
+ platformKey,
419
+ platform,
420
+ environment,
421
+ execute = execFileSync,
422
+ expectedHutchVersion = PAIRED_HUTCH_VERSION,
423
+ tarExecutable,
424
+ ) {
425
+ const tar = tarExecutable ?? tarCommand(platform, environment);
426
+ const archiveDirectory = path.dirname(archivePath);
427
+ const archiveFilename = path.basename(archivePath);
428
+ // Windows bsdtar can corrupt non-ACP absolute argv paths. Node passes cwd
429
+ // through the native wide-character API, so keep every tar argv path ASCII.
430
+ const listing = execute(tar, ["-tzf", archiveFilename], {
431
+ cwd: archiveDirectory,
432
+ encoding: "utf8",
433
+ maxBuffer: 1024 * 1024,
434
+ });
435
+ const verbose = execute(tar, ["-tvzf", archiveFilename], {
436
+ cwd: archiveDirectory,
437
+ encoding: "utf8",
438
+ maxBuffer: 1024 * 1024,
439
+ });
440
+ const names = listing.trim().split(/\r?\n/).filter(Boolean);
441
+ const details = verbose.trim().split(/\r?\n/).filter(Boolean);
442
+ if (names.length !== details.length) {
443
+ throw new Error("Hutch archive listing is inconsistent");
444
+ }
445
+ const expected = expectedArchiveEntries(
446
+ platformKey,
447
+ platform,
448
+ expectedHutchVersion,
449
+ );
450
+ const seenFiles = new Set();
451
+ for (let index = 0; index < names.length; index += 1) {
452
+ const raw = names[index];
453
+ if (raw.includes("\\") || raw.includes("\0") || path.posix.isAbsolute(raw)) {
454
+ throw new Error("Hutch archive contains an unsafe path");
455
+ }
456
+ const name = path.posix.normalize(raw.replace(/\/+$/, ""));
457
+ if (
458
+ name === ".." ||
459
+ name.startsWith("../") ||
460
+ (name !== expected.root && !name.startsWith(`${expected.root}/`))
461
+ ) {
462
+ throw new Error("Hutch archive contains an unsafe path");
463
+ }
464
+ const type = details[index][0];
465
+ if (expected.files.has(name)) {
466
+ if (type !== "-") throw new Error("Hutch archive executable is not a regular file");
467
+ if (seenFiles.has(name)) throw new Error("Hutch archive contains duplicate files");
468
+ seenFiles.add(name);
469
+ } else if (
470
+ (name === expected.root || name === `${expected.root}/bin`) &&
471
+ type === "d"
472
+ ) {
473
+ continue;
474
+ } else {
475
+ throw new Error(`Hutch archive contains an unexpected entry: ${raw}`);
476
+ }
477
+ }
478
+ if (seenFiles.size !== expected.files.size) {
479
+ throw new Error("Hutch archive is missing required files");
480
+ }
481
+
482
+ // Avoid locale-dependent parsing of `tar -tv` sizes. Ask tar to stream each
483
+ // already allowlisted regular member to stdout under a strict maxBuffer;
484
+ // extraction can therefore never write an unbounded decompression bomb.
485
+ let extractedBytes = 0;
486
+ for (const [member, maximum] of expected.memberLimits) {
487
+ let contents;
488
+ try {
489
+ contents = execute(tar, ["-xOzf", archiveFilename, member], {
490
+ cwd: archiveDirectory,
491
+ encoding: null,
492
+ maxBuffer: maximum,
493
+ });
494
+ } catch (error) {
495
+ throw new Error(
496
+ `Hutch archive member ${member} is unreadable or exceeds its size limit: ${error.message}`,
497
+ );
498
+ }
499
+ const size = Buffer.isBuffer(contents)
500
+ ? contents.length
501
+ : Buffer.byteLength(contents ?? "");
502
+ if (size < 1 || size > maximum) {
503
+ throw new Error(`Hutch archive member ${member} exceeds its size limit`);
504
+ }
505
+ extractedBytes += size;
506
+ if (extractedBytes > maxExtractedBytes) {
507
+ throw new Error("Hutch archive exceeds its total extracted-size limit");
508
+ }
509
+ }
510
+ return expected.root;
511
+ }
512
+
513
+ function cacheMemberLimits(platform) {
514
+ const extension = platform === "win32" ? ".exe" : "";
515
+ return new Map([
516
+ [`bin/hutch${extension}`, maxLauncherBytes],
517
+ [`bin/hutch-engine${extension}`, maxEngineBytes],
518
+ ["hutch-release.json", maxReleaseMetadataBytes],
519
+ ]);
520
+ }
521
+
522
+ function sha256File(file) {
523
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
524
+ }
525
+
526
+ function pathEntryExists(file) {
527
+ try {
528
+ lstatSync(file);
529
+ return true;
530
+ } catch (error) {
531
+ if (error.code === "ENOENT") return false;
532
+ throw error;
533
+ }
534
+ }
535
+
536
+ function writeCacheManifest(
537
+ root,
538
+ platformKey,
539
+ platform,
540
+ expectedHutchVersion,
541
+ archive,
542
+ ) {
543
+ const files = {};
544
+ for (const [relative, maximum] of cacheMemberLimits(platform)) {
545
+ const file = path.join(root, ...relative.split("/"));
546
+ const stat = lstatSync(file);
547
+ if (!stat.isFile() || stat.size < 1 || stat.size > maximum) {
548
+ throw new Error(`extracted Hutch cache member ${relative} is invalid`);
549
+ }
550
+ files[relative] = {
551
+ size: stat.size,
552
+ sha256: sha256File(file),
553
+ ...(platform !== "win32" && relative.startsWith("bin/")
554
+ ? { mode: stat.mode & 0o777 }
555
+ : {}),
556
+ };
557
+ }
558
+ writeFileSync(
559
+ path.join(root, CACHE_MANIFEST_FILENAME),
560
+ `${JSON.stringify({
561
+ schemaVersion: CACHE_MANIFEST_SCHEMA_VERSION,
562
+ electrobunVersion: ELECTROBUN_VERSION,
563
+ hutchVersion: expectedHutchVersion,
564
+ platform: platformKey,
565
+ archiveSha256: createHash("sha256").update(archive).digest("hex"),
566
+ files,
567
+ })}\n`,
568
+ { flag: "wx", mode: 0o444 },
569
+ );
570
+ }
571
+
572
+ function validateCachedHutch(
573
+ root,
574
+ platformKey,
575
+ platform,
576
+ expectedHutchVersion = PAIRED_HUTCH_VERSION,
577
+ requireCacheManifest = true,
578
+ ) {
579
+ try {
580
+ const members = cacheMemberLimits(platform);
581
+ const memberPaths = new Map(
582
+ [...members.keys()].map((relative) => [
583
+ relative,
584
+ path.join(root, ...relative.split("/")),
585
+ ]),
586
+ );
587
+ const metadataPath = memberPaths.get("hutch-release.json");
588
+ const binary = hutchBinaryInRoot(root, platform);
589
+ const engine = hutchEngineInRoot(root, platform);
590
+ const realRoot = realpathSync(root);
591
+ for (const [relative, candidate] of memberPaths) {
592
+ const stat = lstatSync(candidate);
593
+ const maximum = members.get(relative);
594
+ if (!stat.isFile() || stat.size < 1 || stat.size > maximum) return null;
595
+ const resolved = realpathSync(candidate);
596
+ if (!resolved.startsWith(`${realRoot}${path.sep}`)) return null;
597
+ }
598
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
599
+ const extension = platform === "win32" ? ".exe" : "";
600
+ if (
601
+ metadata.schema !== 1 ||
602
+ metadata.kind !== "archive" ||
603
+ metadata.product !== "hutch" ||
604
+ metadata.version !== expectedHutchVersion ||
605
+ metadata.platform !== platformKey ||
606
+ metadata.launcher !== `bin/hutch${extension}` ||
607
+ metadata.executable !== `bin/hutch-engine${extension}`
608
+ ) {
609
+ return null;
610
+ }
611
+ if (!requireCacheManifest) return binary;
612
+
613
+ if (platform !== "win32") {
614
+ for (const executable of [binary, engine]) {
615
+ try {
616
+ accessSync(executable, fsConstants.X_OK);
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+ }
622
+ const manifestPath = path.join(root, CACHE_MANIFEST_FILENAME);
623
+ const manifestStat = lstatSync(manifestPath);
624
+ if (
625
+ !manifestStat.isFile() ||
626
+ manifestStat.size < 1 ||
627
+ manifestStat.size > maxReleaseMetadataBytes ||
628
+ (platform !== "win32" && (manifestStat.mode & 0o022) !== 0)
629
+ ) {
630
+ return null;
631
+ }
632
+ const resolvedManifest = realpathSync(manifestPath);
633
+ if (!resolvedManifest.startsWith(`${realRoot}${path.sep}`)) return null;
634
+ const cache = object(
635
+ JSON.parse(readFileSync(manifestPath, "utf8")),
636
+ "Hutch cache manifest",
637
+ );
638
+ if (
639
+ cache.schemaVersion !== CACHE_MANIFEST_SCHEMA_VERSION ||
640
+ cache.electrobunVersion !== ELECTROBUN_VERSION ||
641
+ cache.hutchVersion !== expectedHutchVersion ||
642
+ cache.platform !== platformKey ||
643
+ !/^[0-9a-f]{64}$/.test(cache.archiveSha256)
644
+ ) {
645
+ return null;
646
+ }
647
+ const cachedFiles = object(cache.files, "Hutch cache manifest files");
648
+ const expectedFiles = [...members.keys()].sort();
649
+ if (
650
+ JSON.stringify(Object.keys(cachedFiles).sort()) !==
651
+ JSON.stringify(expectedFiles)
652
+ ) {
653
+ return null;
654
+ }
655
+ for (const relative of expectedFiles) {
656
+ const descriptor = object(
657
+ cachedFiles[relative],
658
+ `Hutch cache member ${relative}`,
659
+ );
660
+ const file = memberPaths.get(relative);
661
+ const stat = lstatSync(file);
662
+ const executableModeIsValid =
663
+ platform === "win32" ||
664
+ !relative.startsWith("bin/") ||
665
+ (Number.isSafeInteger(descriptor.mode) &&
666
+ descriptor.mode >= 0 &&
667
+ descriptor.mode <= 0o777 &&
668
+ (descriptor.mode & 0o022) === 0 &&
669
+ (stat.mode & 0o777) === descriptor.mode);
670
+ if (
671
+ descriptor.size !== stat.size ||
672
+ !/^[0-9a-f]{64}$/.test(descriptor.sha256) ||
673
+ descriptor.sha256 !== sha256File(file) ||
674
+ !executableModeIsValid
675
+ ) {
676
+ return null;
677
+ }
678
+ }
679
+ return binary;
680
+ } catch {
681
+ return null;
682
+ }
683
+ }
684
+
685
+ function tarCommand(platform, environment) {
686
+ return platform === "win32"
687
+ ? path.win32.join(environment.SystemRoot || "C:\\Windows", "System32", "tar.exe")
688
+ : "tar";
689
+ }
690
+
691
+ function readCacheLockRecord(lock, filename) {
692
+ try {
693
+ const record = object(
694
+ JSON.parse(readFileSync(path.join(lock, filename), "utf8")),
695
+ "Hutch cache lock record",
696
+ );
697
+ if (
698
+ record.schemaVersion !== 1 ||
699
+ !Number.isSafeInteger(record.pid) ||
700
+ record.pid < 1 ||
701
+ !Number.isSafeInteger(record.createdAt) ||
702
+ record.createdAt < 0 ||
703
+ typeof record.token !== "string" ||
704
+ !/^[0-9a-f]{32}$/.test(record.token)
705
+ ) {
706
+ return null;
707
+ }
708
+ return record;
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ function processIsAlive(pid) {
715
+ try {
716
+ process.kill(pid, 0);
717
+ return true;
718
+ } catch (error) {
719
+ return error.code !== "ESRCH";
720
+ }
721
+ }
722
+
723
+ function reclaimableCacheLock(lock, now, isProcessAlive) {
724
+ let stat;
725
+ try {
726
+ stat = lstatSync(lock);
727
+ } catch (error) {
728
+ if (error.code === "ENOENT") return null;
729
+ throw error;
730
+ }
731
+ const owner = readCacheLockRecord(lock, CACHE_LOCK_OWNER_FILENAME);
732
+ if (owner) {
733
+ const released = readCacheLockRecord(lock, CACHE_LOCK_RELEASED_FILENAME);
734
+ if (released?.token === owner.token) {
735
+ return { kind: "owner", token: owner.token };
736
+ }
737
+ if (!isProcessAlive(owner.pid)) {
738
+ return { kind: "owner", token: owner.token };
739
+ }
740
+ return null;
741
+ }
742
+ const recordedOrphan = readCacheLockRecord(lock, CACHE_LOCK_ORPHAN_FILENAME);
743
+ if (recordedOrphan) {
744
+ return { kind: "orphan", token: recordedOrphan.token };
745
+ }
746
+ if (now() - stat.mtimeMs < cacheLockOrphanGraceMs) return null;
747
+ const orphanFingerprint = () => ({
748
+ kind: "orphan",
749
+ token: createHash("sha256")
750
+ .update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`)
751
+ .digest("hex")
752
+ .slice(0, 32),
753
+ });
754
+ if (pathEntryExists(path.join(lock, CACHE_LOCK_ORPHAN_FILENAME))) {
755
+ // A crash can leave a partial marker. The old directory is already
756
+ // non-empty, so a stat-derived deterministic token still provides the same
757
+ // ABA fence as a valid marker without deleting or replacing it in place.
758
+ return orphanFingerprint();
759
+ }
760
+ const orphan = {
761
+ schemaVersion: 1,
762
+ pid: process.pid,
763
+ createdAt: now(),
764
+ token: randomBytes(16).toString("hex"),
765
+ };
766
+ try {
767
+ writeFileSync(
768
+ path.join(lock, CACHE_LOCK_ORPHAN_FILENAME),
769
+ `${JSON.stringify(orphan)}\n`,
770
+ { flag: "wx", mode: 0o444 },
771
+ );
772
+ } catch (error) {
773
+ if (error.code !== "EEXIST") {
774
+ if (error.code === "ENOENT") return null;
775
+ throw error;
776
+ }
777
+ }
778
+ // A legitimate creator that was paused between mkdir and owner publication
779
+ // always wins over the orphan marker.
780
+ const lateOwner = readCacheLockRecord(lock, CACHE_LOCK_OWNER_FILENAME);
781
+ if (lateOwner) {
782
+ if (!isProcessAlive(lateOwner.pid)) {
783
+ return { kind: "owner", token: lateOwner.token };
784
+ }
785
+ return null;
786
+ }
787
+ const marked = readCacheLockRecord(lock, CACHE_LOCK_ORPHAN_FILENAME);
788
+ return marked ? { kind: "orphan", token: marked.token } : orphanFingerprint();
789
+ }
790
+
791
+ function sameReclaimCandidate(left, right) {
792
+ if (!left || !right || left.kind !== right.kind) return false;
793
+ return left.token === right.token;
794
+ }
795
+
796
+ function tryReclaimCacheLock({
797
+ candidate,
798
+ isProcessAlive,
799
+ lock,
800
+ now,
801
+ rename,
802
+ }) {
803
+ // Re-read immediately before the atomic move. In particular, never reclaim
804
+ // a lock whose recorded owner is still alive, regardless of its age.
805
+ if (
806
+ !sameReclaimCandidate(
807
+ candidate,
808
+ reclaimableCacheLock(lock, now, isProcessAlive),
809
+ )
810
+ ) {
811
+ return false;
812
+ }
813
+ // The candidate-stable tombstone is intentionally retained. It is a fence:
814
+ // every waiter that previously observed this stale token renames to the same
815
+ // non-empty destination, so only one can win and no delayed waiter can move a
816
+ // replacement owner's fixed lock (the classic reclaim ABA race).
817
+ const tombstone = `${lock}.reclaimed-${candidate.token}`;
818
+ try {
819
+ mkdirSync(tombstone);
820
+ } catch (error) {
821
+ if (error.code !== "EEXIST") throw error;
822
+ }
823
+ const quarantine = path.join(tombstone, "stale-lock");
824
+ try {
825
+ rename(lock, quarantine);
826
+ } catch (error) {
827
+ if (["EEXIST", "ENOENT", "ENOTEMPTY"].includes(error.code)) return false;
828
+ if (error.code === "EPERM" && pathEntryExists(quarantine)) return false;
829
+ if (["EACCES", "EBUSY", "EPERM"].includes(error.code)) return false;
830
+ throw error;
831
+ }
832
+ return true;
833
+ }
834
+
835
+ function acquireCacheLock({
836
+ expectedHutchVersion,
837
+ isProcessAlive = processIsAlive,
838
+ makeDirectory = mkdirSync,
839
+ now = Date.now,
840
+ platform,
841
+ platformKey,
842
+ remove = rmSync,
843
+ rename = renameSync,
844
+ root,
845
+ sleep = cacheSleep,
846
+ timeoutMs = cacheLockTimeoutMs,
847
+ }) {
848
+ const lock = `${root}.install-lock`;
849
+ const deadline = now() + timeoutMs;
850
+ const owner = {
851
+ schemaVersion: 1,
852
+ pid: process.pid,
853
+ createdAt: now(),
854
+ token: randomBytes(16).toString("hex"),
855
+ };
856
+ const claim = `${lock}.claim-${process.pid}-${owner.token}`;
857
+ makeDirectory(claim);
858
+ try {
859
+ writeFileSync(
860
+ path.join(claim, CACHE_LOCK_OWNER_FILENAME),
861
+ `${JSON.stringify(owner)}\n`,
862
+ { flag: "wx", mode: 0o444 },
863
+ );
864
+ } catch (error) {
865
+ remove(claim, { force: true, recursive: true });
866
+ throw error;
867
+ }
868
+
869
+ try {
870
+ while (true) {
871
+ if (!pathEntryExists(lock)) {
872
+ try {
873
+ rename(claim, lock);
874
+ break;
875
+ } catch (error) {
876
+ if (!pathEntryExists(lock)) {
877
+ if (
878
+ ["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"].includes(
879
+ error.code,
880
+ )
881
+ ) {
882
+ // A competing owner may publish and release entirely before this
883
+ // catch runs. The prepared claim is still ours, so retry it.
884
+ if (now() >= deadline) {
885
+ throw new Error(`timed out waiting for Hutch cache lock ${lock}`);
886
+ }
887
+ sleep(cacheLockPollMs);
888
+ continue;
889
+ }
890
+ throw error;
891
+ }
892
+ }
893
+ }
894
+ const candidate = reclaimableCacheLock(lock, now, isProcessAlive);
895
+ if (
896
+ candidate &&
897
+ tryReclaimCacheLock({
898
+ candidate,
899
+ isProcessAlive,
900
+ lock,
901
+ now,
902
+ rename,
903
+ })
904
+ ) {
905
+ continue;
906
+ }
907
+ if (now() >= deadline) {
908
+ throw new Error(`timed out waiting for Hutch cache lock ${lock}`);
909
+ }
910
+ sleep(cacheLockPollMs);
911
+ }
912
+ } catch (error) {
913
+ remove(claim, { force: true, recursive: true });
914
+ throw error;
915
+ }
916
+
917
+ let released = false;
918
+ return {
919
+ cached: null,
920
+ release() {
921
+ if (released) return;
922
+ released = true;
923
+ const current = readCacheLockRecord(lock, CACHE_LOCK_OWNER_FILENAME);
924
+ if (current?.token !== owner.token) return;
925
+ const quarantine = `${lock}.released-${owner.token}`;
926
+ try {
927
+ rename(lock, quarantine);
928
+ } catch {
929
+ // Never delete the fixed path after a failed move: it may already name a
930
+ // replacement owner. Mark only our still-current token as released.
931
+ const unchanged = readCacheLockRecord(lock, CACHE_LOCK_OWNER_FILENAME);
932
+ if (unchanged?.token === owner.token) {
933
+ try {
934
+ writeFileSync(
935
+ path.join(lock, CACHE_LOCK_RELEASED_FILENAME),
936
+ `${JSON.stringify(owner)}\n`,
937
+ { flag: "wx", mode: 0o444 },
938
+ );
939
+ } catch {
940
+ // A later waiter can retry once the owner exits.
941
+ }
942
+ }
943
+ return;
944
+ }
945
+ try {
946
+ remove(quarantine, { force: true, recursive: true });
947
+ } catch {
948
+ // The fixed path is already free; this owner-unique path is harmless.
949
+ }
950
+ },
951
+ };
952
+ }
953
+
954
+ function installDownloadedArchive({
955
+ archive,
956
+ cacheLock = acquireCacheLock,
957
+ cleanup = rmSync,
958
+ environment,
959
+ execute = execFileSync,
960
+ expectedHutchVersion = PAIRED_HUTCH_VERSION,
961
+ makeLockDirectory = mkdirSync,
962
+ platform,
963
+ platformKey,
964
+ rename = renameSync,
965
+ remove = rmSync,
966
+ root,
967
+ sleep = cacheSleep,
968
+ tarExecutable,
969
+ }) {
970
+ const parent = path.dirname(root);
971
+ mkdirSync(parent, { recursive: true });
972
+ const temporary = mkdtempSync(path.join(parent, `.install-${platformKey}-`));
973
+ try {
974
+ const archivePath = path.join(temporary, "hutch.tar.gz");
975
+ writeFileSync(archivePath, archive, { flag: "wx" });
976
+ const extractedName = validateArchiveEntries(
977
+ archivePath,
978
+ platformKey,
979
+ platform,
980
+ environment,
981
+ execute,
982
+ expectedHutchVersion,
983
+ tarExecutable,
984
+ );
985
+ execute(tarExecutable ?? tarCommand(platform, environment), ["-xzf", "hutch.tar.gz"], {
986
+ cwd: temporary,
987
+ stdio: "pipe",
988
+ });
989
+ const extracted = path.join(temporary, extractedName);
990
+ let binary = validateCachedHutch(
991
+ extracted,
992
+ platformKey,
993
+ platform,
994
+ expectedHutchVersion,
995
+ false,
996
+ );
997
+ if (!binary) throw new Error("extracted Hutch archive identity is invalid");
998
+ if (platform !== "win32") {
999
+ chmodSync(binary, 0o755);
1000
+ chmodSync(hutchEngineInRoot(extracted, platform), 0o755);
1001
+ }
1002
+ writeCacheManifest(
1003
+ extracted,
1004
+ platformKey,
1005
+ platform,
1006
+ expectedHutchVersion,
1007
+ archive,
1008
+ );
1009
+ binary = validateCachedHutch(
1010
+ extracted,
1011
+ platformKey,
1012
+ platform,
1013
+ expectedHutchVersion,
1014
+ );
1015
+ if (!binary) throw new Error("sealed Hutch cache is invalid");
1016
+
1017
+ const lock = cacheLock({
1018
+ expectedHutchVersion,
1019
+ makeDirectory: makeLockDirectory,
1020
+ platform,
1021
+ platformKey,
1022
+ remove,
1023
+ rename,
1024
+ root,
1025
+ });
1026
+ if (lock.cached) return lock.cached;
1027
+ let quarantine = null;
1028
+ let publishedExtracted = false;
1029
+ let committed = false;
1030
+ try {
1031
+ const existing = validateCachedHutch(
1032
+ root,
1033
+ platformKey,
1034
+ platform,
1035
+ expectedHutchVersion,
1036
+ );
1037
+ if (existing) return existing;
1038
+
1039
+ if (pathEntryExists(root)) {
1040
+ // This second validation happens while holding the per-root lock and
1041
+ // immediately before quarantine, so a valid winner is never moved.
1042
+ const beforeQuarantine = validateCachedHutch(
1043
+ root,
1044
+ platformKey,
1045
+ platform,
1046
+ expectedHutchVersion,
1047
+ );
1048
+ if (beforeQuarantine) return beforeQuarantine;
1049
+ const invalidQuarantine = `${root}.invalid-${process.pid}-${randomBytes(8).toString("hex")}`;
1050
+ for (let attempt = 0; attempt <= cacheRenameRetries; attempt += 1) {
1051
+ const validNow = validateCachedHutch(
1052
+ root,
1053
+ platformKey,
1054
+ platform,
1055
+ expectedHutchVersion,
1056
+ );
1057
+ if (validNow) return validNow;
1058
+ try {
1059
+ rename(root, invalidQuarantine);
1060
+ quarantine = invalidQuarantine;
1061
+ break;
1062
+ } catch (error) {
1063
+ const recovered = validateCachedHutch(
1064
+ root,
1065
+ platformKey,
1066
+ platform,
1067
+ expectedHutchVersion,
1068
+ );
1069
+ if (recovered) return recovered;
1070
+ if (error.code === "ENOENT" && !pathEntryExists(root)) break;
1071
+ if (
1072
+ ["EACCES", "EBUSY", "EPERM"].includes(error.code) &&
1073
+ attempt < cacheRenameRetries
1074
+ ) {
1075
+ sleep(cacheRenameRetryMs);
1076
+ continue;
1077
+ }
1078
+ throw error;
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ let raced = null;
1084
+ for (let attempt = 0; attempt <= cacheRenameRetries; attempt += 1) {
1085
+ try {
1086
+ rename(extracted, root);
1087
+ publishedExtracted = true;
1088
+ break;
1089
+ } catch (error) {
1090
+ raced = validateCachedHutch(
1091
+ root,
1092
+ platformKey,
1093
+ platform,
1094
+ expectedHutchVersion,
1095
+ );
1096
+ if (raced) break;
1097
+ if (
1098
+ ["EACCES", "EBUSY", "EPERM"].includes(error.code) &&
1099
+ attempt < cacheRenameRetries
1100
+ ) {
1101
+ sleep(cacheRenameRetryMs);
1102
+ continue;
1103
+ }
1104
+ throw error;
1105
+ }
1106
+ }
1107
+ if (raced) {
1108
+ committed = true;
1109
+ if (quarantine) {
1110
+ try {
1111
+ remove(quarantine, { force: true, recursive: true });
1112
+ } catch {
1113
+ // The valid winner is committed; quarantine cleanup is best-effort.
1114
+ }
1115
+ quarantine = null;
1116
+ }
1117
+ return raced;
1118
+ }
1119
+ const installed = validateCachedHutch(
1120
+ root,
1121
+ platformKey,
1122
+ platform,
1123
+ expectedHutchVersion,
1124
+ );
1125
+ if (!installed) throw new Error("installed Hutch cache is invalid");
1126
+ // From this point the cache is a complete immutable unit. Other resolver
1127
+ // processes may observe it, so later cleanup must never roll it back.
1128
+ committed = true;
1129
+ if (quarantine) {
1130
+ try {
1131
+ remove(quarantine, { force: true, recursive: true });
1132
+ } catch {
1133
+ // Cache publication succeeded; an invalid quarantine is best-effort.
1134
+ }
1135
+ quarantine = null;
1136
+ }
1137
+ return installed;
1138
+ } catch (error) {
1139
+ if (publishedExtracted && !committed) {
1140
+ remove(root, { force: true, recursive: true });
1141
+ }
1142
+ if (quarantine) {
1143
+ remove(quarantine, { force: true, recursive: true });
1144
+ }
1145
+ throw error;
1146
+ } finally {
1147
+ lock.release();
1148
+ }
1149
+ } finally {
1150
+ try {
1151
+ cleanup(temporary, {
1152
+ force: true,
1153
+ maxRetries: cacheRenameRetries,
1154
+ recursive: true,
1155
+ retryDelay: cacheRenameRetryMs,
1156
+ });
1157
+ } catch {
1158
+ // A committed cache must never be reported as failed because Windows AV
1159
+ // temporarily holds the private extraction directory. Old temp cleanup is
1160
+ // deliberately a separate, lower-priority maintenance concern.
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ async function downloadPairedHutch(options) {
1166
+ const baseUrl = releasesBaseUrl(options.environment);
1167
+ const releaseUrl = `${baseUrl}/v${ELECTROBUN_VERSION}`;
1168
+ const fetchBytes = options.download ?? download;
1169
+ const index = await fetchBytes(`${releaseUrl}/${HUTCH_ARTIFACT_INDEX_FILENAME}`, {
1170
+ label: "Hutch artifact index",
1171
+ maxBytes: maxArtifactIndexBytes,
1172
+ });
1173
+ const descriptor = validateArtifactIndex(index, baseUrl, options.platformKey);
1174
+ console.error(
1175
+ `Downloading Hutch ${PAIRED_HUTCH_VERSION} for Electrobun ${ELECTROBUN_VERSION} (${options.platformKey})...`,
1176
+ );
1177
+ const archive = await fetchBytes(descriptor.url, {
1178
+ label: `${options.platformKey} Hutch archive`,
1179
+ maxBytes: maxArchiveBytes,
1180
+ });
1181
+ if (archive.length !== descriptor.size) {
1182
+ throw new Error(
1183
+ `downloaded Hutch archive size ${archive.length} does not match ${descriptor.size}`,
1184
+ );
1185
+ }
1186
+ const digest = createHash("sha256").update(archive).digest("hex");
1187
+ if (digest !== descriptor.sha256) {
1188
+ throw new Error("downloaded Hutch archive SHA-256 does not match the release index");
1189
+ }
1190
+ return installDownloadedArchive({
1191
+ ...options,
1192
+ archive,
1193
+ expectedHutchVersion: PAIRED_HUTCH_VERSION,
1194
+ });
1195
+ }
1196
+
1197
+ async function ensureCompatibleGlobal(options) {
1198
+ const channel = hutchChannel(options.environment);
1199
+ const global = globalHutchBinaryPath(
1200
+ channel,
1201
+ options.environment,
1202
+ options.platform,
1203
+ options.userHome,
1204
+ );
1205
+ const versionReader = options.hutchBinaryVersion ?? hutchBinaryVersion;
1206
+ if (options.fileExists(global)) {
1207
+ const existing = compatibleFallback(global, options.environment, versionReader);
1208
+ if (existing.compatible) return global;
1209
+ if (environmentFlagEnabled(options.environment, "DASH_RELEASE_OFFLINE")) {
1210
+ throw incompatibleFallbackError("the installed global Hutch", existing);
1211
+ }
1212
+ }
1213
+ if (environmentFlagEnabled(options.environment, "DASH_RELEASE_OFFLINE")) {
1214
+ throw new Error(
1215
+ `Hutch is not installed at ${global}; DASH_RELEASE_OFFLINE prevents downloading it`,
1216
+ );
1217
+ }
1218
+ console.error(
1219
+ `Electrobun projects use Hutch; installing the latest ${channel} release...`,
1220
+ );
1221
+ await options.install({
1222
+ channel,
1223
+ environment: options.environment,
1224
+ platform: options.platform,
1225
+ });
1226
+ if (!options.fileExists(global)) throw new Error(`Hutch was not installed at ${global}`);
1227
+ const installed = compatibleFallback(global, options.environment, versionReader);
1228
+ if (!installed.compatible) {
1229
+ throw incompatibleFallbackError("the installed global Hutch", installed);
1230
+ }
1231
+ return global;
1232
+ }
1233
+
1234
+ async function resolveHutchBinary(options = {}) {
1235
+ const environment = options.environment ?? process.env;
1236
+ const platform = options.platform ?? process.platform;
1237
+ const arch = options.arch ?? process.arch;
1238
+ const userHome = options.userHome ?? homedir();
1239
+ const fileExists = options.existsSync ?? existsSync;
1240
+ const install = options.installHutch ?? installHutch;
1241
+ const versionReader = options.hutchBinaryVersion ?? hutchBinaryVersion;
1242
+ const common = {
1243
+ ...options,
1244
+ environment,
1245
+ fileExists,
1246
+ install,
1247
+ platform,
1248
+ userHome,
1249
+ };
1250
+
1251
+ if (environment.ELECTROBUN_HUTCH_BINARY) {
1252
+ const configured = environment.ELECTROBUN_HUTCH_BINARY;
1253
+ if (!fileExists(configured)) {
1254
+ throw new Error(`ELECTROBUN_HUTCH_BINARY does not exist: ${configured}`);
1255
+ }
1256
+ const explicit = compatibleFallback(configured, environment, versionReader);
1257
+ if (!explicit.compatible) {
1258
+ throw incompatibleFallbackError("ELECTROBUN_HUTCH_BINARY", explicit);
1259
+ }
1260
+ return configured;
1261
+ }
1262
+
1263
+ if (options.ensureGlobalHutch) await ensureCompatibleGlobal(common);
1264
+
1265
+ const platformKey = hutchPlatformKey(platform, arch);
1266
+ if (platformKey) {
1267
+ const root =
1268
+ options.cacheRoot ??
1269
+ downloadedHutchRoot(environment, platform, userHome, platformKey);
1270
+ const cached = validateCachedHutch(root, platformKey, platform);
1271
+ if (cached) return cached;
1272
+
1273
+ if (!environmentFlagEnabled(environment, "DASH_RELEASE_OFFLINE")) {
1274
+ try {
1275
+ return await downloadPairedHutch({
1276
+ ...common,
1277
+ platformKey,
1278
+ root,
1279
+ });
1280
+ } catch (error) {
1281
+ common.assetError = error;
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ const channel = hutchChannel(environment);
1287
+ const global = globalHutchBinaryPath(channel, environment, platform, userHome);
1288
+ if (fileExists(global)) {
1289
+ const fallback = compatibleFallback(global, environment, versionReader);
1290
+ if (fallback.compatible) {
1291
+ if (common.assetError) {
1292
+ console.error(
1293
+ `Electrobun could not acquire its paired Hutch asset (${common.assetError.message}); using compatible global Hutch ${fallback.version}.`,
1294
+ );
1295
+ }
1296
+ return global;
1297
+ }
1298
+ if (environmentFlagEnabled(environment, "DASH_RELEASE_OFFLINE")) {
1299
+ throw incompatibleFallbackError("the installed global Hutch", fallback);
1300
+ }
1301
+ }
1302
+
1303
+ if (environmentFlagEnabled(environment, "DASH_RELEASE_OFFLINE")) {
1304
+ const cache = platformKey ? "paired Hutch is not in the npm cache" : "platform is unsupported";
1305
+ throw new Error(`${cache}; DASH_RELEASE_OFFLINE prevents downloading it`);
1306
+ }
1307
+
1308
+ try {
1309
+ return await ensureCompatibleGlobal(common);
1310
+ } catch (fallbackError) {
1311
+ if (common.assetError) {
1312
+ throw new Error(
1313
+ `could not acquire paired Hutch: ${common.assetError.message}; global fallback failed: ${fallbackError.message}`,
1314
+ );
1315
+ }
1316
+ throw fallbackError;
1317
+ }
1318
+ }
1319
+
1320
+ function environmentWithPairedDefaults(environment) {
1321
+ const enriched = { ...environment };
1322
+ if (!enriched.HUTCH_DEFAULT_CLI) {
1323
+ enriched.HUTCH_DEFAULT_CLI = PAIRED_HUTCH_VERSION;
1324
+ }
1325
+ if (!enriched.HUTCH_DEFAULT_ELECTROBUN) {
1326
+ enriched.HUTCH_DEFAULT_ELECTROBUN = ELECTROBUN_VERSION;
1327
+ }
1328
+ return enriched;
1329
+ }
1330
+
1331
+ function runHutch({ binary, args, environment }) {
1332
+ const result = spawnSync(binary, args, {
1333
+ env: environmentWithPairedDefaults(environment),
1334
+ stdio: "inherit",
1335
+ });
1336
+ if (result.error) throw result.error;
1337
+ if (result.status !== null) return result.status;
1338
+ if (result.signal === "SIGINT") return 130;
1339
+ if (result.signal === "SIGTERM") return 143;
1340
+ return 1;
1341
+ }
1342
+
1343
+ module.exports = {
1344
+ CACHE_MANIFEST_FILENAME,
1345
+ CACHE_LOCK_OWNER_FILENAME,
1346
+ ELECTROBUN_VERSION,
1347
+ HUTCH_ARTIFACT_INDEX_FILENAME,
1348
+ MINIMUM_DEFAULTS_HUTCH_VERSION,
1349
+ PAIRED_HUTCH_VERSION,
1350
+ acquireCacheLock,
1351
+ download,
1352
+ downloadedHutchRoot,
1353
+ environmentWithPairedDefaults,
1354
+ globalHutchBinaryPath,
1355
+ hutchChannel,
1356
+ hutchBinaryVersion,
1357
+ hutchPlatformKey,
1358
+ installDownloadedArchive,
1359
+ resolveHutchBinary,
1360
+ runHutch,
1361
+ validateArtifactIndex,
1362
+ validateCachedHutch,
1363
+ };