docdex 0.1.11 → 0.2.2

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/install.js CHANGED
@@ -2,18 +2,158 @@
2
2
  "use strict";
3
3
 
4
4
  const fs = require("node:fs");
5
+ const http = require("node:http");
5
6
  const https = require("node:https");
6
7
  const os = require("node:os");
7
8
  const path = require("node:path");
8
9
  const { pipeline } = require("node:stream/promises");
9
- const tar = require("tar");
10
+ const crypto = require("node:crypto");
10
11
 
11
12
  const pkg = require("../package.json");
12
- const { artifactName, detectPlatformKey } = require("./platform");
13
+ const {
14
+ artifactName,
15
+ assetPatternForPlatformKey,
16
+ detectPlatformKey,
17
+ resolvePlatformPolicy,
18
+ targetTripleForPlatformKey,
19
+ UnsupportedPlatformError
20
+ } = require("./platform");
21
+ const { ManifestResolutionError, resolveCanonicalAssetForTargetTriple } = require("./release_manifest");
22
+ const { runPostInstallSetup } = require("./postinstall_setup");
13
23
 
14
24
  const MAX_REDIRECTS = 5;
15
25
  const USER_AGENT = "docdex-installer";
16
26
  const PLACEHOLDER_REPO_TOKEN = /OWNER|REPO/i;
27
+ const MAX_MANIFEST_BYTES = 1024 * 1024; // 1 MiB cap for safety
28
+ const INVALID_JSON_ERROR = "invalid JSON";
29
+ const INSTALL_METADATA_SCHEMA_VERSION = 1;
30
+ const INSTALL_METADATA_FILENAME = "docdexd-install.json";
31
+ const LEGACY_STAGING_SUFFIX = ".__docdexd_install_staging";
32
+ const LEGACY_BACKUP_SUFFIX = ".__docdexd_install_backup";
33
+ const LEGACY_INCOMING_SUFFIX = ".incoming";
34
+ const LEGACY_BACKUP_SIMPLE_SUFFIX = ".backup";
35
+ const INSTALLER_EVENT_PREFIX = "[docdex] event ";
36
+ const DEFAULT_INTEGRITY_CONFIG = Object.freeze({
37
+ metadataSources: ["manifest", "checksums", "sidecar"],
38
+ missingPolicy: "fallback"
39
+ });
40
+
41
+ const EXIT_CODE_BY_ERROR_CODE = Object.freeze({
42
+ DOCDEX_INSTALLER_CONFIG: 2,
43
+ DOCDEX_UNSUPPORTED_PLATFORM: 3,
44
+ DOCDEX_MANIFEST_MALFORMED: 10,
45
+ DOCDEX_TARGET_TRIPLE_INVALID: 11,
46
+ DOCDEX_ASSET_NO_MATCH: 12,
47
+ DOCDEX_ASSET_MULTI_MATCH: 13,
48
+ DOCDEX_ASSET_MALFORMED: 14,
49
+ DOCDEX_CHECKSUM_UNUSABLE: 24,
50
+ DOCDEX_DOWNLOAD_FAILED: 20,
51
+ DOCDEX_ASSET_MISSING: 21,
52
+ DOCDEX_INTEGRITY_MISMATCH: 22,
53
+ DOCDEX_ARCHIVE_INVALID: 23
54
+ });
55
+
56
+ function withBaseDetails(details) {
57
+ return {
58
+ targetTriple: null,
59
+ manifestVersion: null,
60
+ assetName: null,
61
+ ...(details || {})
62
+ };
63
+ }
64
+
65
+ class InstallerConfigError extends Error {
66
+ /**
67
+ * @param {string} message
68
+ * @param {object} [details]
69
+ */
70
+ constructor(message, details) {
71
+ super(message);
72
+ this.name = "InstallerConfigError";
73
+ this.code = "DOCDEX_INSTALLER_CONFIG";
74
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
75
+ this.details = withBaseDetails(details);
76
+ }
77
+ }
78
+
79
+ class MissingArtifactError extends Error {
80
+ /**
81
+ * @param {object} details
82
+ */
83
+ constructor(details) {
84
+ super("Missing release artifact for detected platform");
85
+ this.name = "MissingArtifactError";
86
+ this.code = "DOCDEX_ASSET_MISSING";
87
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
88
+ this.details = withBaseDetails(details);
89
+ }
90
+ }
91
+
92
+ class DownloadError extends Error {
93
+ /**
94
+ * @param {string} message
95
+ * @param {object} details
96
+ * @param {Error} [cause]
97
+ */
98
+ constructor(message, details, cause) {
99
+ super(message, cause ? { cause } : undefined);
100
+ this.name = "DownloadError";
101
+ this.code = "DOCDEX_DOWNLOAD_FAILED";
102
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
103
+ this.details = withBaseDetails(details);
104
+ }
105
+ }
106
+
107
+ class IntegrityMismatchError extends Error {
108
+ /**
109
+ * @param {string} archiveName
110
+ * @param {string} expectedSha256
111
+ * @param {string} actualSha256
112
+ * @param {object} [details]
113
+ */
114
+ constructor(archiveName, expectedSha256, actualSha256, details) {
115
+ super(
116
+ `Integrity check failed for ${archiveName}: expected sha256=${expectedSha256} got sha256=${actualSha256}`
117
+ );
118
+ this.name = "IntegrityMismatchError";
119
+ this.code = "DOCDEX_INTEGRITY_MISMATCH";
120
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
121
+ this.details = withBaseDetails({
122
+ ...details,
123
+ assetName: archiveName,
124
+ expectedSha256,
125
+ actualSha256
126
+ });
127
+ }
128
+ }
129
+
130
+ class ArchiveInvalidError extends Error {
131
+ /**
132
+ * @param {string} message
133
+ * @param {object} details
134
+ */
135
+ constructor(message, details) {
136
+ super(message);
137
+ this.name = "ArchiveInvalidError";
138
+ this.code = "DOCDEX_ARCHIVE_INVALID";
139
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
140
+ this.details = withBaseDetails(details);
141
+ }
142
+ }
143
+
144
+ class ChecksumResolutionError extends Error {
145
+ /**
146
+ * @param {string} message
147
+ * @param {object} [details]
148
+ */
149
+ constructor(message, details) {
150
+ super(message);
151
+ this.name = "ChecksumResolutionError";
152
+ this.code = "DOCDEX_CHECKSUM_UNUSABLE";
153
+ this.exitCode = EXIT_CODE_BY_ERROR_CODE[this.code];
154
+ this.details = withBaseDetails(details);
155
+ }
156
+ }
17
157
 
18
158
  function parseRepoSlug() {
19
159
  const envRepo = process.env.DOCDEX_DOWNLOAD_REPO;
@@ -26,7 +166,10 @@ function parseRepoSlug() {
26
166
  return match[1];
27
167
  }
28
168
 
29
- throw new Error("Set DOCDEX_DOWNLOAD_REPO env var or update package.json repository.url to owner/repo");
169
+ throw new InstallerConfigError(
170
+ "Set DOCDEX_DOWNLOAD_REPO env var or update package.json repository.url to owner/repo",
171
+ { repoSlug: null }
172
+ );
30
173
  }
31
174
 
32
175
  function getDownloadBase(repoSlug) {
@@ -38,7 +181,9 @@ function getVersion() {
38
181
  const version = (envVersion || pkg.version || "").replace(/^v/, "");
39
182
 
40
183
  if (!version) {
41
- throw new Error("Missing package version; set DOCDEX_VERSION or package.json version");
184
+ throw new InstallerConfigError("Missing package version; set DOCDEX_VERSION or package.json version", {
185
+ version: null
186
+ });
42
187
  }
43
188
 
44
189
  return version;
@@ -51,22 +196,91 @@ function requestOptions() {
51
196
  return { headers };
52
197
  }
53
198
 
199
+ function selectHttpClient(url) {
200
+ try {
201
+ const protocol = new URL(url).protocol;
202
+ if (protocol === "http:") return http;
203
+ if (protocol === "https:") return https;
204
+ } catch {
205
+ // Fall through to default.
206
+ }
207
+ return https;
208
+ }
209
+
210
+ function resolveRedirectUrl(location, baseUrl) {
211
+ try {
212
+ return new URL(location, baseUrl).toString();
213
+ } catch {
214
+ return location;
215
+ }
216
+ }
217
+
218
+ function downloadText(url, redirects = 0) {
219
+ if (redirects > MAX_REDIRECTS) {
220
+ throw new Error(`Too many redirects while fetching ${url}`);
221
+ }
222
+
223
+ return new Promise((resolve, reject) => {
224
+ const client = selectHttpClient(url);
225
+ client
226
+ .get(url, requestOptions(), (res) => {
227
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
228
+ res.resume();
229
+ const redirectUrl = resolveRedirectUrl(res.headers.location, url);
230
+ return downloadText(redirectUrl, redirects + 1).then(resolve, reject);
231
+ }
232
+
233
+ if (res.statusCode !== 200) {
234
+ res.resume();
235
+ const err = new Error(`Download failed (${res.statusCode}) from ${url}`);
236
+ err.statusCode = res.statusCode;
237
+ err.url = url;
238
+ return reject(err);
239
+ }
240
+
241
+ const chunks = [];
242
+ let total = 0;
243
+ res.on("data", (chunk) => {
244
+ total += chunk.length;
245
+ if (total > MAX_MANIFEST_BYTES) {
246
+ const err = new Error(`Response too large while fetching ${url} (>${MAX_MANIFEST_BYTES} bytes)`);
247
+ err.code = "DOCDEX_DOWNLOAD_TOO_LARGE";
248
+ err.url = url;
249
+ err.maxBytes = MAX_MANIFEST_BYTES;
250
+ err.actualBytes = total;
251
+ res.destroy(err);
252
+ return;
253
+ }
254
+ chunks.push(chunk);
255
+ });
256
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
257
+ res.on("error", reject);
258
+ })
259
+ .on("error", reject);
260
+ });
261
+ }
262
+
54
263
  function download(url, dest, redirects = 0) {
55
264
  if (redirects > MAX_REDIRECTS) {
56
265
  throw new Error(`Too many redirects while fetching ${url}`);
57
266
  }
58
267
 
59
268
  return new Promise((resolve, reject) => {
60
- https
269
+ const client = selectHttpClient(url);
270
+ client
61
271
  .get(url, requestOptions(), (res) => {
62
272
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
63
273
  res.resume();
64
- return download(res.headers.location, dest, redirects + 1).then(resolve, reject);
274
+ const redirectUrl = resolveRedirectUrl(res.headers.location, url);
275
+ return download(redirectUrl, dest, redirects + 1).then(resolve, reject);
65
276
  }
66
277
 
67
278
  if (res.statusCode !== 200) {
68
279
  res.resume();
69
- return reject(new Error(`Download failed (${res.statusCode}) from ${url}`));
280
+ const err = new Error(`Download failed (${res.statusCode}) from ${url}`);
281
+ err.statusCode = res.statusCode;
282
+ err.url = url;
283
+ return reject(err);
70
284
  }
71
285
 
72
286
  const file = fs.createWriteStream(dest);
@@ -77,35 +291,1733 @@ function download(url, dest, redirects = 0) {
77
291
  }
78
292
 
79
293
  async function extractTarball(archivePath, targetDir) {
294
+ // Lazy import so unit tests can load this module without installing optional npm deps.
295
+ const tar = require("tar");
80
296
  await fs.promises.mkdir(targetDir, { recursive: true });
81
297
  await tar.x({ file: archivePath, cwd: targetDir, gzip: true });
82
298
  }
83
299
 
300
+ async function sha256File(filePath) {
301
+ return new Promise((resolve, reject) => {
302
+ const hash = crypto.createHash("sha256");
303
+ const stream = fs.createReadStream(filePath);
304
+ stream.on("data", (chunk) => hash.update(chunk));
305
+ stream.on("error", reject);
306
+ stream.on("end", () => resolve(hash.digest("hex")));
307
+ });
308
+ }
309
+
310
+ function installMetadataPath(distDir, pathModule = path) {
311
+ return pathModule.join(distDir, INSTALL_METADATA_FILENAME);
312
+ }
313
+
314
+ function nowIso() {
315
+ return new Date().toISOString();
316
+ }
317
+
318
+ async function readJsonFileIfPossible({ fsModule, filePath }) {
319
+ if (!fsModule?.promises?.readFile) {
320
+ return { value: null, error: "readFile_unavailable", errorCode: "READFILE_UNAVAILABLE" };
321
+ }
322
+ try {
323
+ const raw = await fsModule.promises.readFile(filePath, "utf8");
324
+ try {
325
+ return { value: JSON.parse(raw), error: null, errorCode: null };
326
+ } catch (err) {
327
+ return { value: null, error: err?.message || String(err), errorCode: "INVALID_JSON" };
328
+ }
329
+ } catch (err) {
330
+ return {
331
+ value: null,
332
+ error: err?.message || String(err),
333
+ errorCode: typeof err?.code === "string" && err.code ? err.code : "READ_ERROR"
334
+ };
335
+ }
336
+ }
337
+
338
+ async function writeJsonFileAtomic({ fsModule, pathModule, filePath, value }) {
339
+ const dir = pathModule.dirname(filePath);
340
+ await fsModule.promises.mkdir(dir, { recursive: true });
341
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
342
+ const payload = `${JSON.stringify(value, null, 2)}\n`;
343
+ await fsModule.promises.writeFile(tmp, payload, "utf8");
344
+ await fsModule.promises.rename(tmp, filePath);
345
+ }
346
+
347
+ function isValidInstallMetadata(meta) {
348
+ if (!meta || typeof meta !== "object") return false;
349
+ if (meta.schemaVersion !== INSTALL_METADATA_SCHEMA_VERSION) return false;
350
+ if (typeof meta.version !== "string" || !meta.version) return false;
351
+ if (typeof meta.platformKey !== "string" || !meta.platformKey) return false;
352
+ if (!meta.binary || typeof meta.binary !== "object") return false;
353
+ if (typeof meta.binary.sha256 !== "string" || meta.binary.sha256.length !== 64) return false;
354
+ return true;
355
+ }
356
+
357
+ function normalizeSha256Hex(value) {
358
+ if (typeof value !== "string") return null;
359
+ const trimmed = value.trim().toLowerCase();
360
+ if (!/^[0-9a-f]{64}$/.test(trimmed)) return null;
361
+ return trimmed;
362
+ }
363
+
364
+ function emitInstallerEvent(logger, payload) {
365
+ if (!logger || typeof logger.log !== "function") return;
366
+ try {
367
+ logger.log(`${INSTALLER_EVENT_PREFIX}${JSON.stringify(payload)}`);
368
+ } catch {
369
+ // Ignore telemetry failures to avoid blocking installs.
370
+ }
371
+ }
372
+
373
+ function buildInstallNonce() {
374
+ const rand = Math.random().toString(16).slice(2, 10);
375
+ return `${Date.now()}.${process.pid}.${rand}`;
376
+ }
377
+
378
+ function stagingDownloadDir({ distBaseDir, platformKey, pathModule }) {
379
+ return pathModule.join(distBaseDir, ".staging", platformKey);
380
+ }
381
+
382
+ function stageDirName({ distDir, nonce }) {
383
+ return `${distDir}.stage.${nonce}`;
384
+ }
385
+
386
+ function backupDirName({ distDir, nonce }) {
387
+ return `${distDir}.backup.${nonce}`;
388
+ }
389
+
390
+ function failedDirName({ distDir, nonce }) {
391
+ return `${distDir}.failed.${nonce}`;
392
+ }
393
+
394
+ async function removeDirSafe(fsModule, dirPath) {
395
+ if (!dirPath) return false;
396
+ try {
397
+ await fsModule.promises.rm(dirPath, { recursive: true, force: true });
398
+ return true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }
403
+
404
+ async function listDirEntriesSafe(fsModule, dirPath) {
405
+ if (!fsModule?.promises?.readdir) return [];
406
+ try {
407
+ return await fsModule.promises.readdir(dirPath, { withFileTypes: true });
408
+ } catch {
409
+ return [];
410
+ }
411
+ }
412
+
413
+ async function cleanupInstallArtifacts({
414
+ fsModule,
415
+ pathModule,
416
+ distBaseDir,
417
+ distDir,
418
+ platformKey,
419
+ stagingDir,
420
+ backupDir,
421
+ preserveBackups = false
422
+ }) {
423
+ const paths = new Set();
424
+ const cleaned = [];
425
+
426
+ if (stagingDir) paths.add(stagingDir);
427
+ if (backupDir && !preserveBackups) paths.add(backupDir);
428
+
429
+ if (distDir) {
430
+ paths.add(`${distDir}${LEGACY_STAGING_SUFFIX}`);
431
+ if (!preserveBackups) paths.add(`${distDir}${LEGACY_BACKUP_SUFFIX}`);
432
+ paths.add(`${distDir}${LEGACY_INCOMING_SUFFIX}`);
433
+ if (!preserveBackups) paths.add(`${distDir}${LEGACY_BACKUP_SIMPLE_SUFFIX}`);
434
+ }
435
+
436
+ if (distBaseDir && platformKey) {
437
+ const entries = await listDirEntriesSafe(fsModule, distBaseDir);
438
+ for (const entry of entries) {
439
+ if (!entry.isDirectory()) continue;
440
+ const name = entry.name;
441
+ if (
442
+ name.startsWith(`${platformKey}.stage.`) ||
443
+ name.startsWith(`${platformKey}.staging.`) ||
444
+ name.startsWith(`${platformKey}.staging-`) ||
445
+ (!preserveBackups && name.startsWith(`${platformKey}.backup.`)) ||
446
+ name.startsWith(`${platformKey}.failed.`)
447
+ ) {
448
+ paths.add(pathModule.join(distBaseDir, name));
449
+ }
450
+ }
451
+ }
452
+
453
+ for (const pathToRemove of paths) {
454
+ if (await removeDirSafe(fsModule, pathToRemove)) cleaned.push(pathToRemove);
455
+ }
456
+
457
+ if (distBaseDir && platformKey) {
458
+ const downloadRoot = stagingDownloadDir({ distBaseDir, platformKey, pathModule });
459
+ if (await removeDirSafe(fsModule, downloadRoot)) cleaned.push(downloadRoot);
460
+ const stagingRoot = pathModule.join(distBaseDir, ".staging");
461
+ if (fsModule?.promises?.rmdir) {
462
+ await fsModule.promises.rmdir(stagingRoot).catch(() => {});
463
+ }
464
+ }
465
+
466
+ return cleaned;
467
+ }
468
+
469
+ async function selectLatestCandidate(fsModule, candidates) {
470
+ let latest = candidates[0] || null;
471
+ let latestMtime = -1;
472
+ for (const candidate of candidates) {
473
+ try {
474
+ const stat = await fsModule.promises.stat(candidate.path);
475
+ const mtime = typeof stat.mtimeMs === "number" ? stat.mtimeMs : stat.mtime?.getTime?.() ?? 0;
476
+ if (mtime > latestMtime) {
477
+ latestMtime = mtime;
478
+ latest = candidate;
479
+ }
480
+ } catch {
481
+ continue;
482
+ }
483
+ }
484
+ return latest;
485
+ }
486
+
487
+ async function recoverInterruptedInstall({ fsModule, pathModule, distDir, isWin32, logger }) {
488
+ const existsSync = typeof fsModule?.existsSync === "function" ? fsModule.existsSync.bind(fsModule) : null;
489
+ const canStat = typeof fsModule?.promises?.stat === "function";
490
+ const distBaseDir = pathModule.dirname(distDir);
491
+ const platformKey = pathModule.basename(distDir);
492
+
493
+ const backups = [];
494
+ const stages = [];
495
+ const failed = [];
496
+
497
+ const addIfExists = async (list, candidatePath, label) => {
498
+ if (!canStat) return;
499
+ try {
500
+ const stat = await fsModule.promises.stat(candidatePath);
501
+ if (stat.isDirectory()) list.push({ path: candidatePath, label });
502
+ } catch {
503
+ // ignore missing paths
504
+ }
505
+ };
506
+
507
+ await addIfExists(backups, `${distDir}${LEGACY_BACKUP_SUFFIX}`, "legacy_backup_suffix");
508
+ await addIfExists(backups, `${distDir}${LEGACY_BACKUP_SIMPLE_SUFFIX}`, "legacy_backup_simple");
509
+ await addIfExists(stages, `${distDir}${LEGACY_STAGING_SUFFIX}`, "legacy_staging_suffix");
510
+ await addIfExists(stages, `${distDir}${LEGACY_INCOMING_SUFFIX}`, "legacy_incoming");
511
+
512
+ const entries = await listDirEntriesSafe(fsModule, distBaseDir);
513
+ for (const entry of entries) {
514
+ if (!entry.isDirectory()) continue;
515
+ const name = entry.name;
516
+ const fullPath = pathModule.join(distBaseDir, name);
517
+ if (name.startsWith(`${platformKey}.backup.`)) {
518
+ backups.push({ path: fullPath, label: "backup" });
519
+ } else if (
520
+ name.startsWith(`${platformKey}.stage.`) ||
521
+ name.startsWith(`${platformKey}.staging.`) ||
522
+ name.startsWith(`${platformKey}.staging-`)
523
+ ) {
524
+ stages.push({ path: fullPath, label: "stage" });
525
+ } else if (name.startsWith(`${platformKey}.failed.`)) {
526
+ failed.push({ path: fullPath, label: "failed" });
527
+ }
528
+ }
529
+
530
+ const distDirExists = existsSync ? existsSync(distDir) : false;
531
+ let recoveredFrom = null;
532
+ let action = "not_needed";
533
+ let recoveryError = null;
534
+
535
+ if (!distDirExists && backups.length) {
536
+ const candidate = await selectLatestCandidate(fsModule, backups);
537
+ if (candidate) {
538
+ try {
539
+ await fsModule.promises.rename(candidate.path, distDir);
540
+ recoveredFrom = candidate.path;
541
+ action = "recovered";
542
+ } catch (err) {
543
+ recoveryError = err;
544
+ action = "recovery_failed";
545
+ }
546
+ }
547
+ }
548
+
549
+ const cleaned = [];
550
+
551
+ if (distDirExists || action === "recovered") {
552
+ for (const backup of backups) {
553
+ if (backup.path === recoveredFrom) continue;
554
+ if (await removeDirSafe(fsModule, backup.path)) cleaned.push(backup.path);
555
+ }
556
+ }
557
+
558
+ for (const stage of stages) {
559
+ if (await removeDirSafe(fsModule, stage.path)) cleaned.push(stage.path);
560
+ }
561
+ for (const entry of failed) {
562
+ if (await removeDirSafe(fsModule, entry.path)) cleaned.push(entry.path);
563
+ }
564
+
565
+ if (logger && typeof logger.log === "function" && action === "recovered") {
566
+ logger.log(`[docdex] recovered previous install from ${recoveredFrom}`);
567
+ }
568
+
569
+ return {
570
+ action,
571
+ recoveredFrom,
572
+ cleaned,
573
+ error: recoveryError,
574
+ isWin32
575
+ };
576
+ }
577
+
578
+ function resolveIntegrityConfig(integrityConfigFn) {
579
+ const raw = typeof integrityConfigFn === "function" ? integrityConfigFn() : null;
580
+ const configuredSources = Array.isArray(raw?.metadataSources) ? raw.metadataSources : null;
581
+ const metadataSources = (configuredSources && configuredSources.length
582
+ ? configuredSources
583
+ : DEFAULT_INTEGRITY_CONFIG.metadataSources
584
+ ).map((entry) => String(entry).toLowerCase());
585
+ const allowed = new Set(["manifest", "checksums", "sidecar"]);
586
+ const filteredSources = metadataSources.filter((entry) => allowed.has(entry));
587
+ const missingPolicy = raw?.missingPolicy === "abort" ? "abort" : DEFAULT_INTEGRITY_CONFIG.missingPolicy;
588
+
589
+ return {
590
+ metadataSources: filteredSources.length ? filteredSources : DEFAULT_INTEGRITY_CONFIG.metadataSources.slice(),
591
+ missingPolicy
592
+ };
593
+ }
594
+
595
+ function integrityUnverifiable(reason, { expectedSha256, actualSha256, expectedSource, error } = {}) {
596
+ return {
597
+ status: "unverifiable",
598
+ reason,
599
+ expectedSha256: expectedSha256 ?? null,
600
+ actualSha256: actualSha256 ?? null,
601
+ expectedSource: expectedSource ?? null,
602
+ error: error ?? null
603
+ };
604
+ }
605
+
606
+ function integrityMismatch({ expectedSha256, actualSha256, expectedSource }) {
607
+ return {
608
+ status: "mismatch",
609
+ reason: "hash_mismatch",
610
+ expectedSha256: expectedSha256 ?? null,
611
+ actualSha256: actualSha256 ?? null,
612
+ expectedSource: expectedSource ?? null,
613
+ error: null
614
+ };
615
+ }
616
+
617
+ function integrityVerified({ expectedSha256, actualSha256, expectedSource }) {
618
+ return {
619
+ status: "verified_ok",
620
+ reason: "hash_match",
621
+ expectedSha256: expectedSha256 ?? null,
622
+ actualSha256: actualSha256 ?? null,
623
+ expectedSource: expectedSource ?? null,
624
+ error: null
625
+ };
626
+ }
627
+
628
+ async function verifyInstalledDocdexdIntegrity({
629
+ fsModule,
630
+ sha256FileFn,
631
+ binaryPath,
632
+ expectedBinarySha256,
633
+ installedMetadata,
634
+ installedMetadataStatus,
635
+ installedMetadataStatusReason
636
+ }) {
637
+ const existsSync = typeof fsModule?.existsSync === "function" ? fsModule.existsSync.bind(fsModule) : null;
638
+ if (!existsSync) {
639
+ return integrityUnverifiable("fs_unavailable", { expectedSource: null, error: "existsSync_unavailable" });
640
+ }
641
+
642
+ if (!existsSync(binaryPath)) {
643
+ return integrityUnverifiable("missing_file", { expectedSource: null });
644
+ }
645
+
646
+ const expectedFromRelease = expectedBinarySha256 != null ? normalizeSha256Hex(expectedBinarySha256) : null;
647
+ if (expectedBinarySha256 != null && !expectedFromRelease) {
648
+ return integrityUnverifiable("expected_hash_invalid", { expectedSource: "release" });
649
+ }
650
+
651
+ const expectedFromMetadata =
652
+ installedMetadataStatus === "valid" ? normalizeSha256Hex(installedMetadata?.binary?.sha256) : null;
653
+
654
+ const expectedSha256 = expectedFromRelease || expectedFromMetadata;
655
+ const expectedSource = expectedFromRelease ? "release" : expectedFromMetadata ? "metadata" : null;
656
+
657
+ if (!expectedSha256) {
658
+ if (installedMetadataStatus && installedMetadataStatus !== "valid") {
659
+ const reason =
660
+ typeof installedMetadataStatusReason === "string" && installedMetadataStatusReason
661
+ ? installedMetadataStatusReason
662
+ : "metadata_missing";
663
+ return integrityUnverifiable(reason, { expectedSource });
664
+ }
665
+ return integrityUnverifiable("expected_hash_unavailable", { expectedSource });
666
+ }
667
+
668
+ try {
669
+ const actualSha256 = normalizeSha256Hex(await sha256FileFn(binaryPath));
670
+ if (!actualSha256) {
671
+ return integrityUnverifiable("hash_unreadable", { expectedSha256, expectedSource });
672
+ }
673
+ if (actualSha256 !== expectedSha256) {
674
+ return integrityMismatch({ expectedSha256, actualSha256, expectedSource });
675
+ }
676
+ return integrityVerified({ expectedSha256, actualSha256, expectedSource });
677
+ } catch (err) {
678
+ return integrityUnverifiable("unreadable", {
679
+ expectedSha256,
680
+ expectedSource,
681
+ error: err?.message || String(err)
682
+ });
683
+ }
684
+ }
685
+
686
+ /**
687
+ * @param {object} args
688
+ * @param {string} args.expectedVersion
689
+ * @param {{binarySha256: (string|null)}} args.expectedIntegrityMaterial
690
+ * @param {object} args.discoveredInstalledState
691
+ * @param {object} args.integrityResult
692
+ * @returns {{outcome: string, reason: string}}
693
+ */
694
+ function decideInstallAction({
695
+ expectedVersion,
696
+ expectedIntegrityMaterial,
697
+ discoveredInstalledState,
698
+ integrityResult
699
+ }) {
700
+ if (!discoveredInstalledState?.binaryPresent) return { outcome: "update", reason: "binary_missing" };
701
+
702
+ if (discoveredInstalledState.metadataStatus !== "valid") {
703
+ return {
704
+ outcome: "reinstall_unknown",
705
+ reason: discoveredInstalledState.metadataStatusReason || "metadata_invalid"
706
+ };
707
+ }
708
+
709
+ if (discoveredInstalledState.platformMismatch) {
710
+ return { outcome: "reinstall_unknown", reason: "platform_mismatch" };
711
+ }
712
+
713
+ if (discoveredInstalledState.installedVersion !== expectedVersion) {
714
+ return { outcome: "update", reason: "version_mismatch" };
715
+ }
716
+
717
+ const expectedBinarySha256 = normalizeSha256Hex(expectedIntegrityMaterial?.binarySha256);
718
+ if (!expectedBinarySha256) {
719
+ return { outcome: "reinstall_unknown", reason: "expected_integrity_missing" };
720
+ }
721
+
722
+ if (integrityResult?.status === "mismatch") {
723
+ return { outcome: "repair", reason: "binary_integrity_mismatch" };
724
+ }
725
+
726
+ if (integrityResult?.status === "verified_ok") {
727
+ return { outcome: "no-op", reason: "verified" };
728
+ }
729
+
730
+ return { outcome: "reinstall_unknown", reason: "integrity_unverifiable" };
731
+ }
732
+
733
+ async function discoverInstalledState({ fsModule, pathModule, distDir, platformKey, isWin32 }) {
734
+ const binaryPath = pathModule.join(distDir, isWin32 ? "docdexd.exe" : "docdexd");
735
+ const metadataPath = installMetadataPath(distDir, pathModule);
736
+
737
+ const existsSync = typeof fsModule?.existsSync === "function" ? fsModule.existsSync.bind(fsModule) : null;
738
+ if (!existsSync) {
739
+ return {
740
+ binaryPath,
741
+ metadataPath,
742
+ binaryPresent: false,
743
+ installedVersion: null,
744
+ metadata: null,
745
+ metadataStatus: "unavailable",
746
+ metadataStatusReason: "existsSync_unavailable",
747
+ platformMismatch: false
748
+ };
749
+ }
750
+
751
+ if (!existsSync(binaryPath)) {
752
+ return {
753
+ binaryPath,
754
+ metadataPath,
755
+ binaryPresent: false,
756
+ installedVersion: null,
757
+ metadata: null,
758
+ metadataStatus: "missing",
759
+ metadataStatusReason: "binary_missing",
760
+ platformMismatch: false
761
+ };
762
+ }
763
+
764
+ const metaResult = await readJsonFileIfPossible({ fsModule, filePath: metadataPath });
765
+ const meta = metaResult.value;
766
+ if (!isValidInstallMetadata(meta)) {
767
+ return {
768
+ binaryPath,
769
+ metadataPath,
770
+ binaryPresent: true,
771
+ installedVersion: typeof meta?.version === "string" ? meta.version : null,
772
+ metadata: null,
773
+ metadataStatus:
774
+ metaResult.errorCode === "ENOENT"
775
+ ? "missing"
776
+ : metaResult.errorCode
777
+ ? "unreadable"
778
+ : "invalid",
779
+ metadataStatusReason:
780
+ metaResult.errorCode === "ENOENT"
781
+ ? "metadata_missing"
782
+ : metaResult.errorCode
783
+ ? "metadata_unreadable"
784
+ : "metadata_invalid",
785
+ platformMismatch: false
786
+ };
787
+ }
788
+
789
+ return {
790
+ binaryPath,
791
+ metadataPath,
792
+ binaryPresent: true,
793
+ installedVersion: meta.version,
794
+ metadata: meta,
795
+ metadataStatus: "valid",
796
+ metadataStatusReason: null,
797
+ platformMismatch: meta.platformKey !== platformKey
798
+ };
799
+ }
800
+
801
+ async function verifyInstalledBinaryIntegrity({ sha256FileFn, binaryPath, expectedBinarySha256 }) {
802
+ const expected = normalizeSha256Hex(expectedBinarySha256);
803
+ if (!expected) {
804
+ return integrityUnverifiable("expected_hash_unavailable", {
805
+ expectedSha256: null,
806
+ actualSha256: null,
807
+ expectedSource: null,
808
+ error: "expected_missing"
809
+ });
810
+ }
811
+
812
+ try {
813
+ const actual = normalizeSha256Hex(await sha256FileFn(binaryPath));
814
+ if (!actual) {
815
+ return integrityUnverifiable("hash_unreadable", {
816
+ expectedSha256: expected,
817
+ actualSha256: null,
818
+ expectedSource: null,
819
+ error: "actual_invalid"
820
+ });
821
+ }
822
+ if (actual !== expected) {
823
+ return integrityMismatch({ expectedSha256: expected, actualSha256: actual, expectedSource: null });
824
+ }
825
+ return integrityVerified({ expectedSha256: expected, actualSha256: actual, expectedSource: null });
826
+ } catch (err) {
827
+ return integrityUnverifiable("unreadable", {
828
+ expectedSha256: expected,
829
+ actualSha256: null,
830
+ expectedSource: null,
831
+ error: err?.message || String(err)
832
+ });
833
+ }
834
+ }
835
+
836
+ async function determineLocalInstallerOutcome({
837
+ fsModule,
838
+ pathModule,
839
+ distDir,
840
+ platformKey,
841
+ expectedVersion,
842
+ isWin32,
843
+ sha256FileFn = sha256File,
844
+ expectedBinarySha256 = null
845
+ }) {
846
+ const discoveredInstalledState = await discoverInstalledState({
847
+ fsModule,
848
+ pathModule,
849
+ distDir,
850
+ platformKey,
851
+ isWin32
852
+ });
853
+
854
+ const expectedIntegrityMaterial = {
855
+ binarySha256: normalizeSha256Hex(expectedBinarySha256)
856
+ ? expectedBinarySha256
857
+ : discoveredInstalledState.metadataStatus === "valid"
858
+ ? discoveredInstalledState.metadata.binary.sha256
859
+ : null
860
+ };
861
+
862
+ const shouldVerifyIntegrity =
863
+ discoveredInstalledState.binaryPresent &&
864
+ !discoveredInstalledState.platformMismatch &&
865
+ discoveredInstalledState.installedVersion === expectedVersion &&
866
+ (normalizeSha256Hex(expectedBinarySha256) || discoveredInstalledState.metadataStatus === "valid");
867
+
868
+ const integrityResult = shouldVerifyIntegrity
869
+ ? await verifyInstalledDocdexdIntegrity({
870
+ fsModule,
871
+ sha256FileFn,
872
+ binaryPath: discoveredInstalledState.binaryPath,
873
+ expectedBinarySha256: expectedBinarySha256,
874
+ installedMetadata: discoveredInstalledState.metadata,
875
+ installedMetadataStatus: discoveredInstalledState.metadataStatus,
876
+ installedMetadataStatusReason: discoveredInstalledState.metadataStatusReason
877
+ })
878
+ : null;
879
+
880
+ const decision = decideInstallAction({
881
+ expectedVersion,
882
+ expectedIntegrityMaterial,
883
+ discoveredInstalledState,
884
+ integrityResult
885
+ });
886
+
887
+ const installedVersion =
888
+ typeof discoveredInstalledState.installedVersion === "string" ? discoveredInstalledState.installedVersion : null;
889
+
890
+ return {
891
+ outcome: decision.outcome,
892
+ reason: decision.reason,
893
+ binaryPath: discoveredInstalledState.binaryPath,
894
+ metadataPath: discoveredInstalledState.metadataPath,
895
+ installedVersion,
896
+ integrityResult
897
+ };
898
+ }
899
+
900
+ function parseSha256File(text, expectedFilename) {
901
+ const lines = String(text).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
902
+ for (const line of lines) {
903
+ // Typical format: "<hex> <filename>"
904
+ const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
905
+ if (!match) continue;
906
+ const hash = match[1].toLowerCase();
907
+ const filename = match[2].trim();
908
+ if (!expectedFilename || filename === expectedFilename) return hash;
909
+ }
910
+ return null;
911
+ }
912
+
913
+ function checksumCandidateNames() {
914
+ const envNames = process.env.DOCDEX_CHECKSUMS_NAMES || process.env.DOCDEX_CHECKSUMS_NAME;
915
+ if (envNames) {
916
+ return envNames
917
+ .split(",")
918
+ .map((s) => s.trim())
919
+ .filter(Boolean);
920
+ }
921
+
922
+ // Documented fallback (ops-01-us-08): SHA256SUMS from the same GitHub Release.
923
+ return ["SHA256SUMS", "SHA256SUMS.txt"];
924
+ }
925
+
926
+ function manifestCandidateNames() {
927
+ const envNames = process.env.DOCDEX_MANIFEST_NAMES || process.env.DOCDEX_MANIFEST_NAME;
928
+ if (envNames) {
929
+ return envNames
930
+ .split(",")
931
+ .map((s) => s.trim())
932
+ .filter(Boolean);
933
+ }
934
+
935
+ // Assumption (documented by code): release attaches one of these filenames.
936
+ return [
937
+ "docdex-release-manifest.json",
938
+ // Legacy/compat candidates:
939
+ "docdexd-manifest.json",
940
+ "docdex-manifest.json",
941
+ "manifest.json"
942
+ ];
943
+ }
944
+
945
+ async function tryResolveSha256ViaChecksumFiles({
946
+ repoSlug,
947
+ version,
948
+ archive,
949
+ downloadTextFn = downloadText,
950
+ getDownloadBaseFn = getDownloadBase,
951
+ checksumCandidateNamesFn = checksumCandidateNames
952
+ }) {
953
+ const base = getDownloadBaseFn(repoSlug);
954
+ const candidates = checksumCandidateNamesFn();
955
+ const errors = [];
956
+ const events = [];
957
+
958
+ for (const name of candidates) {
959
+ const url = `${base}/v${version}/${name}`;
960
+ try {
961
+ const text = await downloadTextFn(url);
962
+ const parsed = parseSha256File(text, archive);
963
+ if (parsed) {
964
+ return { checksumName: name, checksumUrl: url, sha256: parsed, errors, events, attempted: true };
965
+ }
966
+
967
+ const message = `Checksum file (${name}) is missing an entry for ${archive}`;
968
+ errors.push(`[DOCDEX_CHECKSUM_ENTRY_MISSING] ${message}`);
969
+ events.push({ code: "DOCDEX_CHECKSUM_ENTRY_MISSING", message, details: { checksumName: name, url, archive } });
970
+ continue;
971
+ } catch (e) {
972
+ // 404 => missing candidate; try next.
973
+ if (e && typeof e.statusCode === "number" && e.statusCode === 404) {
974
+ events.push({
975
+ code: "DOCDEX_CHECKSUM_NOT_FOUND",
976
+ message: `Checksum candidate not found (${name})`,
977
+ details: { checksumName: name, url, archive, statusCode: 404 }
978
+ });
979
+ continue;
980
+ }
981
+
982
+ if (e && e.code === "DOCDEX_DOWNLOAD_TOO_LARGE") {
983
+ const message = `Checksum file too large (${name}): exceeded ${e.maxBytes} bytes`;
984
+ errors.push(`[DOCDEX_CHECKSUM_TOO_LARGE] ${message}`);
985
+ events.push({
986
+ code: "DOCDEX_CHECKSUM_TOO_LARGE",
987
+ message,
988
+ details: { checksumName: name, url, archive, maxBytes: e.maxBytes, actualBytes: e.actualBytes }
989
+ });
990
+ continue;
991
+ }
992
+
993
+ const message = `Failed to fetch checksum file (${name}): ${e.message}`;
994
+ errors.push(`[DOCDEX_CHECKSUM_FETCH_FAILED] ${message}`);
995
+ events.push({
996
+ code: "DOCDEX_CHECKSUM_FETCH_FAILED",
997
+ message,
998
+ details: {
999
+ checksumName: name,
1000
+ url,
1001
+ archive,
1002
+ statusCode: typeof e?.statusCode === "number" ? e.statusCode : null
1003
+ }
1004
+ });
1005
+ continue;
1006
+ }
1007
+ }
1008
+
1009
+ return { checksumName: null, checksumUrl: null, sha256: null, errors, events, attempted: true, candidates };
1010
+ }
1011
+
1012
+ async function tryResolveAssetViaManifest({
1013
+ repoSlug,
1014
+ version,
1015
+ targetTriple,
1016
+ downloadTextFn = downloadText,
1017
+ getDownloadBaseFn = getDownloadBase,
1018
+ manifestCandidateNamesFn = manifestCandidateNames
1019
+ }) {
1020
+ const base = getDownloadBaseFn(repoSlug);
1021
+ const errors = [];
1022
+ const events = [];
1023
+ const candidates = manifestCandidateNamesFn();
1024
+
1025
+ for (const name of candidates) {
1026
+ const url = `${base}/v${version}/${name}`;
1027
+ try {
1028
+ const text = await downloadTextFn(url);
1029
+ let manifest;
1030
+ try {
1031
+ manifest = JSON.parse(text);
1032
+ } catch (e) {
1033
+ const message = `Malformed manifest (${name}): ${INVALID_JSON_ERROR}`;
1034
+ errors.push(`[DOCDEX_MANIFEST_JSON_INVALID] ${message}`);
1035
+ events.push({
1036
+ code: "DOCDEX_MANIFEST_JSON_INVALID",
1037
+ message,
1038
+ details: { manifestName: name, url, targetTriple }
1039
+ });
1040
+ continue;
1041
+ }
1042
+
1043
+ // If a manifest exists but doesn't support the current triple, fail deterministically.
1044
+ try {
1045
+ return {
1046
+ manifestName: name,
1047
+ resolved: resolveCanonicalAssetForTargetTriple(manifest, targetTriple),
1048
+ errors,
1049
+ events,
1050
+ attempted: true
1051
+ };
1052
+ } catch (e) {
1053
+ if (e instanceof ManifestResolutionError) {
1054
+ // Fail closed when the manifest is present but resolution is unsupported or ambiguous.
1055
+ if (e.code === "DOCDEX_ASSET_NO_MATCH" || e.code === "DOCDEX_ASSET_MULTI_MATCH") {
1056
+ e.message = `Manifest ${name}: ${e.message}`;
1057
+ e.details = {
1058
+ ...withBaseDetails(e.details),
1059
+ manifestName: name,
1060
+ manifestUrl: url,
1061
+ fallbackAttempted: false,
1062
+ fallbackReason: "manifest_present_but_unusable"
1063
+ };
1064
+ throw e;
1065
+ }
1066
+
1067
+ // Missing keys / invalid shape / missing sha256: treat as malformed and deterministically fall back.
1068
+ const message = `Manifest unusable (${name}): ${e.code} ${e.message}`;
1069
+ errors.push(`[DOCDEX_MANIFEST_UNUSABLE] ${message}`);
1070
+ events.push({
1071
+ code: "DOCDEX_MANIFEST_UNUSABLE",
1072
+ message,
1073
+ details: {
1074
+ manifestName: name,
1075
+ url,
1076
+ targetTriple,
1077
+ manifestErrorCode: e.code,
1078
+ manifestErrorMessage: e.message
1079
+ }
1080
+ });
1081
+ continue;
1082
+ }
1083
+ throw e;
1084
+ }
1085
+ } catch (e) {
1086
+ if (e instanceof ManifestResolutionError) throw e;
1087
+ // 404 => "missing manifest" candidate; try next. Anything else is recorded and we still try next.
1088
+ if (e && typeof e.statusCode === "number" && e.statusCode === 404) {
1089
+ events.push({
1090
+ code: "DOCDEX_MANIFEST_NOT_FOUND",
1091
+ message: `Manifest candidate not found (${name})`,
1092
+ details: { manifestName: name, url, targetTriple, statusCode: 404 }
1093
+ });
1094
+ continue;
1095
+ }
1096
+
1097
+ if (e && e.code === "DOCDEX_DOWNLOAD_TOO_LARGE") {
1098
+ const message = `Manifest too large (${name}): exceeded ${e.maxBytes} bytes`;
1099
+ errors.push(`[DOCDEX_MANIFEST_TOO_LARGE] ${message}`);
1100
+ events.push({
1101
+ code: "DOCDEX_MANIFEST_TOO_LARGE",
1102
+ message,
1103
+ details: { manifestName: name, url, targetTriple, maxBytes: e.maxBytes, actualBytes: e.actualBytes }
1104
+ });
1105
+ continue;
1106
+ }
1107
+
1108
+ const message = `Failed to fetch manifest (${name}): ${e.message}`;
1109
+ errors.push(`[DOCDEX_MANIFEST_FETCH_FAILED] ${message}`);
1110
+ events.push({
1111
+ code: "DOCDEX_MANIFEST_FETCH_FAILED",
1112
+ message,
1113
+ details: {
1114
+ manifestName: name,
1115
+ url,
1116
+ targetTriple,
1117
+ statusCode: typeof e?.statusCode === "number" ? e.statusCode : null
1118
+ }
1119
+ });
1120
+ continue;
1121
+ }
1122
+ }
1123
+
1124
+ if (candidates.length) {
1125
+ events.push({
1126
+ code: "DOCDEX_FALLBACK_USED",
1127
+ message: "No usable manifest candidate; falling back to deterministic asset naming",
1128
+ details: { targetTriple, manifestCandidates: candidates.slice() }
1129
+ });
1130
+ }
1131
+
1132
+ return { manifestName: null, resolved: null, errors, events, attempted: true };
1133
+ }
1134
+
1135
+ async function resolveInstallerDownloadPlan({
1136
+ repoSlug,
1137
+ version,
1138
+ platformKey,
1139
+ targetTriple,
1140
+ logger = console,
1141
+ downloadTextFn = downloadText,
1142
+ artifactNameFn = artifactName,
1143
+ getDownloadBaseFn = getDownloadBase,
1144
+ manifestCandidateNamesFn = manifestCandidateNames,
1145
+ checksumCandidateNamesFn = checksumCandidateNames,
1146
+ integrityConfigFn
1147
+ }) {
1148
+ let archive = null;
1149
+ let expectedSha256 = null;
1150
+ let source = "fallback";
1151
+ let integrity = null;
1152
+
1153
+ const integrityConfig = resolveIntegrityConfig(integrityConfigFn);
1154
+ const attemptedSources = [];
1155
+
1156
+ let manifestAttempt = { resolved: null, errors: [], manifestName: null };
1157
+
1158
+ if (integrityConfig.metadataSources.includes("manifest")) {
1159
+ attemptedSources.push("manifest");
1160
+ try {
1161
+ manifestAttempt = await tryResolveAssetViaManifest({
1162
+ repoSlug,
1163
+ version,
1164
+ targetTriple,
1165
+ downloadTextFn,
1166
+ getDownloadBaseFn,
1167
+ manifestCandidateNamesFn
1168
+ });
1169
+ } catch (err) {
1170
+ if (err instanceof ManifestResolutionError) {
1171
+ const expectedAsset = artifactNameFn(platformKey);
1172
+ err.details = {
1173
+ ...withBaseDetails(err.details),
1174
+ platformKey,
1175
+ expectedAsset,
1176
+ expectedAssetPattern: assetPatternForPlatformKey(platformKey, { exampleAssetName: expectedAsset })
1177
+ };
1178
+ }
1179
+ throw err;
1180
+ }
1181
+
1182
+ if (manifestAttempt.resolved) {
1183
+ archive = manifestAttempt.resolved.asset.name;
1184
+ expectedSha256 = manifestAttempt.resolved.integrity.sha256;
1185
+ source = `manifest:${manifestAttempt.manifestName}`;
1186
+ integrity = { metadataSource: "manifest", metadataName: manifestAttempt.manifestName };
1187
+ } else if (manifestAttempt.errors && manifestAttempt.errors.length) {
1188
+ logger.warn(`[docdex] Manifest unavailable; falling back. Details: ${manifestAttempt.errors.join(" | ")}`);
1189
+ } else {
1190
+ logger.log("[docdex] No manifest found; falling back to deterministic asset naming.");
1191
+ }
1192
+
1193
+ if (!manifestAttempt.resolved && integrityConfig.missingPolicy === "abort") {
1194
+ const manifestCandidates = manifestCandidateNamesFn();
1195
+ const checksumCandidates = checksumCandidateNamesFn();
1196
+ throw new ChecksumResolutionError(
1197
+ `Missing SHA-256 integrity metadata for ${artifactNameFn(platformKey)} (manifest fetch aborted)`,
1198
+ {
1199
+ platformKey,
1200
+ targetTriple,
1201
+ version,
1202
+ repoSlug,
1203
+ assetName: artifactNameFn(platformKey),
1204
+ source: "fallback",
1205
+ manifestName: manifestAttempt?.manifestName ?? null,
1206
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1207
+ fallbackAttempted: true,
1208
+ fallbackReason: manifestAttempt?.errors?.length ? "manifest_unavailable" : "manifest_not_found",
1209
+ checksumCandidates,
1210
+ checksumErrors: null,
1211
+ checksumEvents: null,
1212
+ integrityMissingPolicy: integrityConfig.missingPolicy,
1213
+ integrityAttemptedSources: attemptedSources.slice()
1214
+ }
1215
+ );
1216
+ }
1217
+ }
1218
+
1219
+ if (!archive) {
1220
+ archive = artifactNameFn(platformKey);
1221
+ }
1222
+
1223
+ let checksumAttempt = null;
1224
+ if (!expectedSha256 && integrityConfig.metadataSources.includes("checksums")) {
1225
+ attemptedSources.push("checksums");
1226
+ checksumAttempt = await tryResolveSha256ViaChecksumFiles({
1227
+ repoSlug,
1228
+ version,
1229
+ archive,
1230
+ downloadTextFn,
1231
+ getDownloadBaseFn,
1232
+ checksumCandidateNamesFn
1233
+ });
1234
+ if (checksumAttempt.sha256) {
1235
+ expectedSha256 = checksumAttempt.sha256;
1236
+ integrity = {
1237
+ metadataSource: "checksums",
1238
+ metadataName: checksumAttempt.checksumName
1239
+ };
1240
+ } else if (integrityConfig.missingPolicy === "abort") {
1241
+ const checksumCandidates = checksumCandidateNamesFn();
1242
+ throw new ChecksumResolutionError(
1243
+ `Missing SHA-256 integrity metadata for ${archive} (checksums fetch aborted)`,
1244
+ {
1245
+ platformKey,
1246
+ targetTriple,
1247
+ version,
1248
+ repoSlug,
1249
+ assetName: archive,
1250
+ source: "fallback",
1251
+ manifestName: manifestAttempt?.manifestName ?? null,
1252
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1253
+ fallbackAttempted: true,
1254
+ fallbackReason: manifestAttempt?.errors?.length ? "manifest_unavailable" : "manifest_not_found",
1255
+ checksumCandidates,
1256
+ checksumErrors: checksumAttempt?.errors ?? null,
1257
+ checksumEvents: checksumAttempt?.events ?? null,
1258
+ integrityMissingPolicy: integrityConfig.missingPolicy,
1259
+ integrityAttemptedSources: attemptedSources.slice()
1260
+ }
1261
+ );
1262
+ }
1263
+ }
1264
+
1265
+ if (!expectedSha256 && integrityConfig.metadataSources.includes("sidecar")) {
1266
+ attemptedSources.push("sidecar");
1267
+ const shaUrl = `${getDownloadBaseFn(repoSlug)}/v${version}/${archive}.sha256`;
1268
+ try {
1269
+ const shaText = await downloadTextFn(shaUrl);
1270
+ expectedSha256 = parseSha256File(shaText, archive);
1271
+ if (expectedSha256) {
1272
+ integrity = { metadataSource: "sidecar", metadataName: `${archive}.sha256` };
1273
+ }
1274
+ } catch {
1275
+ expectedSha256 = null;
1276
+ }
1277
+ }
1278
+
1279
+ if (!expectedSha256) {
1280
+ const manifestCandidates = manifestCandidateNamesFn();
1281
+ const checksumCandidates = checksumCandidateNamesFn();
1282
+ throw new ChecksumResolutionError(
1283
+ `Missing SHA-256 integrity metadata for ${archive} (tried manifest ${manifestCandidates.join(
1284
+ ", "
1285
+ )} and checksums ${checksumCandidates.join(", ")})`,
1286
+ {
1287
+ platformKey,
1288
+ targetTriple,
1289
+ version,
1290
+ repoSlug,
1291
+ assetName: archive,
1292
+ source: "fallback",
1293
+ manifestName: manifestAttempt?.manifestName ?? null,
1294
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1295
+ fallbackAttempted: true,
1296
+ fallbackReason: manifestAttempt?.errors?.length ? "manifest_unavailable" : "manifest_not_found",
1297
+ checksumCandidates,
1298
+ checksumErrors: checksumAttempt?.errors ?? null,
1299
+ checksumEvents: checksumAttempt?.events ?? null,
1300
+ integrityMissingPolicy: integrityConfig.missingPolicy,
1301
+ integrityAttemptedSources: attemptedSources.slice()
1302
+ }
1303
+ );
1304
+ }
1305
+
1306
+ return {
1307
+ archive,
1308
+ expectedSha256,
1309
+ source,
1310
+ integrity,
1311
+ manifestAttempt: { ...manifestAttempt, fallbackAttempted: !manifestAttempt.resolved }
1312
+ };
1313
+ }
1314
+
1315
+ async function verifyDownloadedFileIntegrity({
1316
+ filePath,
1317
+ expectedSha256,
1318
+ archiveName,
1319
+ sha256FileFn = sha256File,
1320
+ details
1321
+ }) {
1322
+ if (!expectedSha256) return null;
1323
+ const actual = await sha256FileFn(filePath);
1324
+ if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
1325
+ throw new IntegrityMismatchError(archiveName, expectedSha256, actual, details);
1326
+ }
1327
+ return actual;
1328
+ }
1329
+
1330
+ async function runInstaller(options) {
1331
+ const opts = options || {};
1332
+ const logger = opts.logger || console;
1333
+
1334
+ const detectPlatformKeyFn = opts.detectPlatformKeyFn || detectPlatformKey;
1335
+ const targetTripleForPlatformKeyFn = opts.targetTripleForPlatformKeyFn || targetTripleForPlatformKey;
1336
+ const getVersionFn = opts.getVersionFn || getVersion;
1337
+ const parseRepoSlugFn = opts.parseRepoSlugFn || parseRepoSlug;
1338
+ const resolveInstallerDownloadPlanFn = opts.resolveInstallerDownloadPlanFn || resolveInstallerDownloadPlan;
1339
+ const getDownloadBaseFn = opts.getDownloadBaseFn || getDownloadBase;
1340
+ const downloadFn = opts.downloadFn || download;
1341
+ const verifyDownloadedFileIntegrityFn = opts.verifyDownloadedFileIntegrityFn || verifyDownloadedFileIntegrity;
1342
+ const extractTarballFn = opts.extractTarballFn || extractTarball;
1343
+ const fsModule = opts.fsModule || fs;
1344
+ const pathModule = opts.pathModule || path;
1345
+ const osModule = opts.osModule || os;
1346
+ const artifactNameFn = opts.artifactNameFn || artifactName;
1347
+ const assetPatternForPlatformKeyFn = opts.assetPatternForPlatformKeyFn || assetPatternForPlatformKey;
1348
+ const sha256FileFn = opts.sha256FileFn || sha256File;
1349
+ const writeJsonFileAtomicFn = opts.writeJsonFileAtomicFn || writeJsonFileAtomic;
1350
+ const restartFn = opts.restartFn;
1351
+
1352
+ const detectedPlatform = opts.platform || process.platform;
1353
+ const detectedArch = opts.arch || process.arch;
1354
+
1355
+ const resolvePlatformPolicyFn =
1356
+ opts.resolvePlatformPolicyFn ||
1357
+ (opts.detectPlatformKeyFn || opts.targetTripleForPlatformKeyFn
1358
+ ? () => {
1359
+ const platformKey = detectPlatformKeyFn();
1360
+ const targetTriple = targetTripleForPlatformKeyFn(platformKey);
1361
+ const expectedAssetName = artifactNameFn(platformKey);
1362
+ const expectedAssetPattern = assetPatternForPlatformKeyFn(platformKey, {
1363
+ exampleAssetName: expectedAssetName
1364
+ });
1365
+ return {
1366
+ detected: { platform: detectedPlatform, arch: detectedArch },
1367
+ platformKey,
1368
+ targetTriple,
1369
+ expectedAssetName,
1370
+ expectedAssetPattern
1371
+ };
1372
+ }
1373
+ : resolvePlatformPolicy);
1374
+
1375
+ const platformPolicy = resolvePlatformPolicyFn({
1376
+ platform: detectedPlatform,
1377
+ arch: detectedArch,
1378
+ env: opts.env,
1379
+ report: opts.report,
1380
+ execPath: opts.execPath
1381
+ });
1382
+
1383
+ const platformKey = platformPolicy.platformKey;
1384
+ const targetTriple = platformPolicy.targetTriple;
1385
+ const version = getVersionFn();
1386
+ const distBaseDir = opts.distBaseDir || pathModule.join(__dirname, "..", "dist");
1387
+ const distDir = pathModule.join(distBaseDir, platformKey);
1388
+ const isWin32 = detectedPlatform === "win32";
1389
+
1390
+ const existsSync = typeof fsModule?.existsSync === "function" ? fsModule.existsSync.bind(fsModule) : null;
1391
+
1392
+ const preflight = await recoverInterruptedInstall({ fsModule, pathModule, distDir, isWin32, logger });
1393
+
1394
+ const local = await determineLocalInstallerOutcome({
1395
+ fsModule,
1396
+ pathModule,
1397
+ distDir,
1398
+ platformKey,
1399
+ expectedVersion: version,
1400
+ isWin32,
1401
+ sha256FileFn
1402
+ });
1403
+
1404
+ const priorRunnable = existsSync ? existsSync(local.binaryPath) : false;
1405
+
1406
+ if (local.outcome === "no-op") {
1407
+ logger.log("[docdex] Install outcome: no-op");
1408
+ await cleanupInstallArtifacts({
1409
+ fsModule,
1410
+ pathModule,
1411
+ distBaseDir,
1412
+ distDir,
1413
+ platformKey,
1414
+ stagingDir: null,
1415
+ backupDir: null
1416
+ });
1417
+ emitInstallerEvent(logger, {
1418
+ code: "DOCDEX_INSTALL_OUTCOME",
1419
+ details: {
1420
+ outcome: local.outcome,
1421
+ outcomeCode: "noop",
1422
+ reason: local.reason,
1423
+ downloadAttempted: false
1424
+ }
1425
+ });
1426
+ return {
1427
+ binaryPath: local.binaryPath,
1428
+ outcome: local.outcome,
1429
+ outcomeCode: "noop",
1430
+ integrityResult: local.integrityResult
1431
+ };
1432
+ }
1433
+
1434
+ const repoSlug = parseRepoSlugFn();
1435
+
1436
+ const { archive, expectedSha256, source, manifestAttempt } = await resolveInstallerDownloadPlanFn({
1437
+ repoSlug,
1438
+ version,
1439
+ platformKey,
1440
+ targetTriple,
1441
+ logger,
1442
+ integrityConfigFn: opts.integrityConfigFn
1443
+ });
1444
+
1445
+ const downloadUrl = `${getDownloadBaseFn(repoSlug)}/v${version}/${archive}`;
1446
+ const nonce = buildInstallNonce();
1447
+ const stagingDir = stageDirName({ distDir, nonce });
1448
+ const backupDir = backupDirName({ distDir, nonce });
1449
+ const failedDir = failedDirName({ distDir, nonce });
1450
+ const tmpDir = opts.tmpDir || null;
1451
+ let tmpFile = null;
1452
+ if (tmpDir) {
1453
+ tmpFile = pathModule.join(tmpDir, `${archive}.${process.pid}.tgz`);
1454
+ } else {
1455
+ const downloadRoot = stagingDownloadDir({ distBaseDir, platformKey, pathModule });
1456
+ await fsModule.promises.mkdir(downloadRoot, { recursive: true });
1457
+ tmpFile = pathModule.join(downloadRoot, `.docdex-download-staging-${nonce}.tgz`);
1458
+ }
1459
+
1460
+ const installAttempt = {
1461
+ stagingDir,
1462
+ backupDir,
1463
+ failedDir,
1464
+ tmpDownloadPath: tmpFile,
1465
+ distDir,
1466
+ archive,
1467
+ version
1468
+ };
1469
+
1470
+ const installSafety = {
1471
+ status: "in_progress",
1472
+ preflightRecovery:
1473
+ preflight?.action === "recovered"
1474
+ ? "recovered backup"
1475
+ : preflight?.action === "recovery_failed"
1476
+ ? "recovery failed"
1477
+ : "not needed",
1478
+ priorRunnable: { before: priorRunnable, after: null },
1479
+ rollback: "not needed",
1480
+ cleanup: "none"
1481
+ };
1482
+
1483
+ logger.log(`[docdex] Fetching ${archive} for ${platformKey} (${targetTriple}) via ${source}...`);
1484
+ let downloadAttempted = false;
1485
+ let backupMoved = false;
1486
+ let promoted = false;
1487
+ let extractAttempted = false;
1488
+ try {
1489
+ try {
1490
+ downloadAttempted = true;
1491
+ await downloadFn(downloadUrl, tmpFile);
1492
+ } catch (err) {
1493
+ if (err && typeof err.statusCode === "number" && err.statusCode === 404) {
1494
+ const fallbackReason = manifestAttempt?.errors?.length ? "manifest_unavailable" : "manifest_not_found";
1495
+ throw new MissingArtifactError({
1496
+ detected: { os: detectedPlatform, arch: detectedArch },
1497
+ platformKey,
1498
+ targetTriple,
1499
+ assetName: archive,
1500
+ source,
1501
+ manifestName: manifestAttempt?.manifestName ?? null,
1502
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1503
+ fallbackAttempted: source === "fallback",
1504
+ fallbackReason,
1505
+ version,
1506
+ repoSlug,
1507
+ downloadUrl,
1508
+ expectedAsset: archive,
1509
+ expectedAssetPattern: assetPatternForPlatformKeyFn(platformKey, { exampleAssetName: archive }),
1510
+ note: "This usually means the GitHub release assets are missing or the npm version is out of sync with the release."
1511
+ });
1512
+ }
1513
+ throw new DownloadError(
1514
+ `Download failed for ${archive}`,
1515
+ {
1516
+ platformKey,
1517
+ targetTriple,
1518
+ version,
1519
+ repoSlug,
1520
+ assetName: archive,
1521
+ downloadUrl,
1522
+ source,
1523
+ manifestName: manifestAttempt?.manifestName ?? null,
1524
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1525
+ fallbackAttempted: source === "fallback",
1526
+ statusCode: typeof err?.statusCode === "number" ? err.statusCode : null
1527
+ },
1528
+ err
1529
+ );
1530
+ }
1531
+
1532
+ try {
1533
+ await verifyDownloadedFileIntegrityFn({
1534
+ filePath: tmpFile,
1535
+ expectedSha256,
1536
+ archiveName: archive,
1537
+ details: {
1538
+ platformKey,
1539
+ targetTriple,
1540
+ version,
1541
+ repoSlug,
1542
+ downloadUrl,
1543
+ source,
1544
+ manifestName: manifestAttempt?.manifestName ?? null,
1545
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1546
+ fallbackAttempted: source === "fallback"
1547
+ }
1548
+ });
1549
+ } catch (err) {
1550
+ if (err instanceof IntegrityMismatchError) {
1551
+ emitInstallerEvent(logger, {
1552
+ code: "DOCDEX_INSTALL_INTEGRITY_ARCHIVE",
1553
+ details: {
1554
+ status: "mismatch",
1555
+ expectedSha256: err.details?.expectedSha256 ?? null,
1556
+ actualSha256: err.details?.actualSha256 ?? null
1557
+ }
1558
+ });
1559
+ }
1560
+ throw err;
1561
+ }
1562
+
1563
+ extractAttempted = true;
1564
+ await extractTarballFn(tmpFile, stagingDir);
1565
+
1566
+ const stagedBinaryPath = pathModule.join(stagingDir, isWin32 ? "docdexd.exe" : "docdexd");
1567
+ if (existsSync && !existsSync(stagedBinaryPath)) {
1568
+ throw new ArchiveInvalidError(`Downloaded archive missing binary at ${stagedBinaryPath}`, {
1569
+ platformKey,
1570
+ targetTriple,
1571
+ version,
1572
+ repoSlug,
1573
+ assetName: archive,
1574
+ downloadUrl,
1575
+ source,
1576
+ manifestName: manifestAttempt?.manifestName ?? null,
1577
+ manifestVersion: manifestAttempt?.resolved?.manifestVersion ?? null,
1578
+ fallbackAttempted: source === "fallback",
1579
+ binaryPath: stagedBinaryPath
1580
+ });
1581
+ }
1582
+
1583
+ await fsModule.promises.chmod(stagedBinaryPath, 0o755);
1584
+
1585
+ if (existsSync && existsSync(distDir)) {
1586
+ await fsModule.promises.rm(backupDir, { recursive: true, force: true }).catch(() => {});
1587
+ await fsModule.promises.rename(distDir, backupDir);
1588
+ backupMoved = true;
1589
+ }
1590
+
1591
+ await fsModule.promises.rename(stagingDir, distDir);
1592
+ promoted = true;
1593
+
1594
+ if (typeof restartFn === "function") {
1595
+ await restartFn();
1596
+ }
1597
+
1598
+ const binaryPath = pathModule.join(distDir, isWin32 ? "docdexd.exe" : "docdexd");
1599
+ const binarySha256 = await sha256FileFn(binaryPath);
1600
+ const metadata = {
1601
+ schemaVersion: INSTALL_METADATA_SCHEMA_VERSION,
1602
+ installedAt: nowIso(),
1603
+ version,
1604
+ repoSlug,
1605
+ platformKey,
1606
+ targetTriple,
1607
+ binary: {
1608
+ filename: isWin32 ? "docdexd.exe" : "docdexd",
1609
+ sha256: binarySha256
1610
+ },
1611
+ archive: {
1612
+ name: archive,
1613
+ sha256: expectedSha256 || null,
1614
+ source,
1615
+ downloadUrl
1616
+ }
1617
+ };
1618
+ await writeJsonFileAtomicFn({
1619
+ fsModule,
1620
+ pathModule,
1621
+ filePath: installMetadataPath(distDir, pathModule),
1622
+ value: metadata
1623
+ });
1624
+
1625
+ if (backupMoved) {
1626
+ await removeDirSafe(fsModule, backupDir);
1627
+ }
1628
+ await cleanupInstallArtifacts({
1629
+ fsModule,
1630
+ pathModule,
1631
+ distBaseDir,
1632
+ distDir,
1633
+ platformKey,
1634
+ stagingDir: null,
1635
+ backupDir: null
1636
+ });
1637
+
1638
+ logger.log(`[docdex] Install outcome: ${local.outcome}`);
1639
+ const outcomeCode = local.outcome === "repair" ? "repair" : "replace";
1640
+ emitInstallerEvent(logger, {
1641
+ code: "DOCDEX_INSTALL_OUTCOME",
1642
+ details: {
1643
+ outcome: local.outcome,
1644
+ outcomeCode,
1645
+ reason: local.reason,
1646
+ downloadAttempted
1647
+ }
1648
+ });
1649
+ return { binaryPath, outcome: local.outcome, outcomeCode };
1650
+ } catch (err) {
1651
+ let rollbackStatus = "not needed";
1652
+ let rollbackSucceeded = false;
1653
+ try {
1654
+ if (backupMoved && existsSync && existsSync(backupDir)) {
1655
+ if (existsSync(distDir)) {
1656
+ await fsModule.promises.rm(distDir, { recursive: true, force: true });
1657
+ }
1658
+ await fsModule.promises.rename(backupDir, distDir);
1659
+ rollbackStatus = "restored previous installation";
1660
+ rollbackSucceeded = true;
1661
+ } else if (promoted && !backupMoved) {
1662
+ if (existsSync && existsSync(distDir)) {
1663
+ await fsModule.promises.rm(distDir, { recursive: true, force: true });
1664
+ }
1665
+ rollbackStatus = "removed partial installation";
1666
+ }
1667
+ } catch {
1668
+ rollbackStatus = "failed";
1669
+ }
1670
+
1671
+ let cleaned = [];
1672
+ if (extractAttempted || backupMoved || promoted) {
1673
+ await removeDirSafe(fsModule, stagingDir);
1674
+ await removeDirSafe(fsModule, failedDir);
1675
+ cleaned = await cleanupInstallArtifacts({
1676
+ fsModule,
1677
+ pathModule,
1678
+ distBaseDir,
1679
+ distDir,
1680
+ platformKey,
1681
+ stagingDir: null,
1682
+ backupDir: null,
1683
+ preserveBackups: !rollbackSucceeded && rollbackStatus === "failed"
1684
+ });
1685
+ }
1686
+
1687
+ installSafety.rollback = rollbackStatus;
1688
+ if (rollbackStatus === "restored previous installation") {
1689
+ installSafety.status = "rolled_back";
1690
+ } else if (rollbackStatus === "removed partial installation") {
1691
+ installSafety.status = "partial_removed";
1692
+ } else if (rollbackStatus === "not needed") {
1693
+ installSafety.status = "no_rollback_needed";
1694
+ } else {
1695
+ installSafety.status = "failed";
1696
+ }
1697
+ installSafety.cleanup = cleaned.length ? `removed ${cleaned.length} artifacts` : "none";
1698
+ installSafety.priorRunnable.after = existsSync
1699
+ ? existsSync(pathModule.join(distDir, isWin32 ? "docdexd.exe" : "docdexd"))
1700
+ : null;
1701
+
1702
+ if (err && typeof err === "object") {
1703
+ err.details = {
1704
+ ...withBaseDetails(err.details),
1705
+ installAttempt,
1706
+ installSafety
1707
+ };
1708
+ }
1709
+ throw err;
1710
+ } finally {
1711
+ await fsModule.promises.rm(tmpFile, { force: true }).catch(() => {});
1712
+ }
1713
+ }
1714
+
84
1715
  async function main() {
85
- const platformKey = detectPlatformKey();
86
- const version = getVersion();
87
- const repoSlug = parseRepoSlug();
88
- const archive = artifactName(platformKey);
89
- const downloadUrl = `${getDownloadBase(repoSlug)}/v${version}/${archive}`;
90
- const distDir = path.join(__dirname, "..", "dist", platformKey);
91
- const tmpFile = path.join(os.tmpdir(), `${archive}.${process.pid}.tgz`);
1716
+ const result = await runInstaller();
1717
+ try {
1718
+ await runPostInstallSetup({ binaryPath: result?.binaryPath });
1719
+ } catch (err) {
1720
+ console.warn(`[docdex] postinstall setup skipped: ${err?.message || err}`);
1721
+ }
1722
+ }
1723
+
1724
+ function appendInstallSafetyLines(lines, err) {
1725
+ const safety = err?.details?.installSafety;
1726
+ if (!safety) return lines;
1727
+
1728
+ const priorBefore = safety.priorRunnable?.before;
1729
+ const priorAfter = safety.priorRunnable?.after;
1730
+
1731
+ lines.push(`[docdex] Install safety status: ${safety.status || "unknown"}`);
1732
+ if (safety.preflightRecovery) {
1733
+ lines.push(`[docdex] Preflight recovery: ${safety.preflightRecovery}`);
1734
+ }
1735
+ if (safety.rollback) {
1736
+ lines.push(`[docdex] Rollback: ${safety.rollback}`);
1737
+ }
1738
+ if (priorBefore != null) {
1739
+ lines.push(`[docdex] Prior docdexd runnable at start: ${priorBefore ? "yes" : "no"}`);
1740
+ }
1741
+ if (priorAfter != null) {
1742
+ lines.push(`[docdex] Prior docdexd runnable after failure: ${priorAfter ? "yes" : "no"}`);
1743
+ }
1744
+ if (safety.cleanup) {
1745
+ lines.push(`[docdex] Cleanup: ${safety.cleanup}`);
1746
+ }
1747
+ return lines;
1748
+ }
1749
+
1750
+ function describeFatalError(err) {
1751
+ const fallbackAttempted =
1752
+ err && typeof err.details?.fallbackAttempted === "boolean" ? err.details.fallbackAttempted : null;
1753
+
1754
+ if (err instanceof UnsupportedPlatformError) {
1755
+ const detected = `${err.details.platform}/${err.details.arch}`;
1756
+ const supportedKeys = (err.details.supportedPlatformKeys || []).join(", ");
1757
+ const supportedTriples = (err.details.supportedTargetTriples || []).join(", ");
1758
+ const libc = err.details?.libc ? String(err.details.libc) : null;
1759
+ const candidatePlatformKey =
1760
+ typeof err.details?.candidatePlatformKey === "string" ? err.details.candidatePlatformKey : null;
1761
+ const candidateTargetTriple =
1762
+ typeof err.details?.candidateTargetTriple === "string" ? err.details.candidateTargetTriple : null;
1763
+ const unpublished = err.details?.reason === "target_not_published";
1764
+ const candidateAssetPattern = candidatePlatformKey ? assetPatternForPlatformKey(candidatePlatformKey) : null;
1765
+
1766
+ return {
1767
+ code: err.code,
1768
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1769
+ details: withBaseDetails(err.details),
1770
+ lines: appendInstallSafetyLines(
1771
+ [
1772
+ `[docdex] install failed: unsupported platform (${detected})`,
1773
+ `[docdex] error code: ${err.code}`,
1774
+ "[docdex] No download was attempted for this platform.",
1775
+ libc ? `[docdex] Detected libc: ${libc}` : null,
1776
+ candidatePlatformKey ? `[docdex] Platform key: ${candidatePlatformKey}` : null,
1777
+ candidateTargetTriple ? `[docdex] Target triple: ${candidateTargetTriple}` : null,
1778
+ candidateAssetPattern ? `[docdex] Asset naming pattern: ${candidateAssetPattern}` : null,
1779
+ unpublished ? "[docdex] Note: this platform is recognized but no published binary is available yet." : null,
1780
+ supportedKeys ? `[docdex] Supported platforms: ${supportedKeys}` : null,
1781
+ supportedTriples ? `[docdex] Supported target triples: ${supportedTriples}` : null,
1782
+ "[docdex] Next steps:",
1783
+ "[docdex] - Use a supported platform (see list above).",
1784
+ "[docdex] - Or build from source (requires Rust): `cargo build --release --locked`.",
1785
+ "[docdex] - If you are on Linux and unsure of libc, set `DOCDEX_LIBC=gnu` or `DOCDEX_LIBC=musl`."
1786
+ ].filter(Boolean),
1787
+ err
1788
+ )
1789
+ };
1790
+ }
1791
+
1792
+ if (err instanceof InstallerConfigError) {
1793
+ return {
1794
+ code: err.code,
1795
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1796
+ details: withBaseDetails(err.details),
1797
+ lines: appendInstallSafetyLines(
1798
+ [
1799
+ `[docdex] install failed: ${err.message}`,
1800
+ `[docdex] error code: ${err.code}`,
1801
+ "[docdex] Next steps:",
1802
+ "[docdex] - Ensure you are installing a published npm package version (not a local folder missing metadata).",
1803
+ "[docdex] - If installing from a fork, set `DOCDEX_DOWNLOAD_REPO=<owner/repo>` to the repo that hosts the release assets."
1804
+ ],
1805
+ err
1806
+ )
1807
+ };
1808
+ }
1809
+
1810
+ if (err instanceof MissingArtifactError) {
1811
+ const detected = err.details?.detected ? `${err.details.detected.os}/${err.details.detected.arch}` : null;
1812
+ const platformKey = typeof err.details?.platformKey === "string" ? err.details.platformKey : null;
1813
+ const expectedAsset =
1814
+ typeof err.details?.expectedAsset === "string" && err.details.expectedAsset.trim()
1815
+ ? err.details.expectedAsset.trim()
1816
+ : typeof err.details?.assetName === "string" && err.details.assetName.trim()
1817
+ ? err.details.assetName.trim()
1818
+ : null;
1819
+ const expectedAssetPattern =
1820
+ typeof err.details?.expectedAssetPattern === "string" && err.details.expectedAssetPattern.trim()
1821
+ ? err.details.expectedAssetPattern.trim()
1822
+ : assetPatternForPlatformKey(platformKey, { exampleAssetName: expectedAsset || undefined });
1823
+ return {
1824
+ code: err.code,
1825
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1826
+ details: withBaseDetails(err.details),
1827
+ lines: appendInstallSafetyLines(
1828
+ [
1829
+ "[docdex] install failed: missing artifact/version sync issue (release asset not found)",
1830
+ `[docdex] error code: ${err.code}`,
1831
+ detected ? `[docdex] Detected platform: ${detected}` : null,
1832
+ err.details?.platformKey ? `[docdex] Platform key: ${err.details.platformKey}` : null,
1833
+ err.details?.targetTriple ? `[docdex] Expected target triple: ${err.details.targetTriple}` : null,
1834
+ err.details?.manifestName ? `[docdex] Manifest name: ${err.details.manifestName}` : null,
1835
+ err.details?.manifestVersion != null ? `[docdex] Manifest version: ${err.details.manifestVersion}` : null,
1836
+ fallbackAttempted != null ? `[docdex] Fallback attempted: ${fallbackAttempted}` : null,
1837
+ err.details?.fallbackReason ? `[docdex] Fallback reason: ${err.details.fallbackReason}` : null,
1838
+ err.details?.version ? `[docdex] Version: v${err.details.version}` : null,
1839
+ err.details?.repoSlug ? `[docdex] Download repo: ${err.details.repoSlug}` : null,
1840
+ err.details?.expectedAsset ? `[docdex] Expected asset: ${err.details.expectedAsset}` : null,
1841
+ expectedAssetPattern ? `[docdex] Asset naming pattern: ${expectedAssetPattern}` : null,
1842
+ err.details?.downloadUrl ? `[docdex] URL tried: ${err.details.downloadUrl}` : null,
1843
+ err.details?.note ? `[docdex] Note: ${err.details.note}` : null,
1844
+ "[docdex] Next steps:",
1845
+ "[docdex] - Confirm the GitHub Release for this version contains the expected asset for your target.",
1846
+ "[docdex] - If installing from a fork, set `DOCDEX_DOWNLOAD_REPO=<owner/repo>` to the repo that hosts the assets.",
1847
+ "[docdex] - Workaround: install a version with matching assets, or build from source (`cargo build --release --locked`)."
1848
+ ].filter(Boolean),
1849
+ err
1850
+ )
1851
+ };
1852
+ }
92
1853
 
93
- console.log(`[docdex] Fetching ${archive} for ${platformKey}...`);
94
- await fs.promises.rm(distDir, { recursive: true, force: true });
95
- await download(downloadUrl, tmpFile);
96
- await extractTarball(tmpFile, distDir);
1854
+ if (err instanceof ChecksumResolutionError) {
1855
+ const checksumCandidates = Array.isArray(err.details?.checksumCandidates) ? err.details.checksumCandidates : [];
1856
+ return {
1857
+ code: err.code,
1858
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1859
+ details: withBaseDetails(err.details),
1860
+ lines: appendInstallSafetyLines(
1861
+ [
1862
+ `[docdex] install failed: ${err.message}`,
1863
+ `[docdex] error code: ${err.code}`,
1864
+ err.details?.assetName ? `[docdex] Asset: ${err.details.assetName}` : null,
1865
+ err.details?.targetTriple ? `[docdex] Expected target triple: ${err.details.targetTriple}` : null,
1866
+ err.details?.manifestName ? `[docdex] Manifest name: ${err.details.manifestName}` : null,
1867
+ err.details?.manifestVersion != null ? `[docdex] Manifest version: ${err.details.manifestVersion}` : null,
1868
+ checksumCandidates.length
1869
+ ? `[docdex] Checksum candidates tried: ${checksumCandidates.join(", ")}`
1870
+ : null,
1871
+ err.details?.fallbackReason ? `[docdex] Fallback reason: ${err.details.fallbackReason}` : null,
1872
+ "[docdex] Next steps:",
1873
+ "[docdex] - Ensure the GitHub Release includes `docdex-release-manifest.json` or `SHA256SUMS` with a line for this asset.",
1874
+ "[docdex] - If installing from a fork, set `DOCDEX_DOWNLOAD_REPO=<owner/repo>` to the repo that hosts the release assets.",
1875
+ "[docdex] - If you cannot publish checksums, build from source (`cargo build --release --locked`)."
1876
+ ].filter(Boolean),
1877
+ err
1878
+ )
1879
+ };
1880
+ }
1881
+
1882
+ if (err instanceof DownloadError) {
1883
+ return {
1884
+ code: err.code,
1885
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1886
+ details: withBaseDetails(err.details),
1887
+ lines: appendInstallSafetyLines(
1888
+ [
1889
+ `[docdex] install failed: ${err.message}`,
1890
+ `[docdex] error code: ${err.code}`,
1891
+ err.details?.downloadUrl ? `[docdex] URL tried: ${err.details.downloadUrl}` : null,
1892
+ err.details?.statusCode != null ? `[docdex] HTTP status: ${err.details.statusCode}` : null,
1893
+ err.cause?.message ? `[docdex] Cause: ${err.cause.message}` : null
1894
+ ].filter(Boolean),
1895
+ err
1896
+ )
1897
+ };
1898
+ }
1899
+
1900
+ if (err instanceof IntegrityMismatchError) {
1901
+ const expectedSha256 = typeof err.details?.expectedSha256 === "string" ? err.details.expectedSha256 : null;
1902
+ const actualSha256 = typeof err.details?.actualSha256 === "string" ? err.details.actualSha256 : null;
1903
+ return {
1904
+ code: err.code,
1905
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1906
+ details: withBaseDetails(err.details),
1907
+ lines: appendInstallSafetyLines(
1908
+ [
1909
+ `[docdex] install failed: ${err.message}`,
1910
+ `[docdex] error code: ${err.code}`,
1911
+ err.details?.assetName ? `[docdex] Asset: ${err.details.assetName}` : null,
1912
+ err.details?.downloadUrl ? `[docdex] URL tried: ${err.details.downloadUrl}` : null,
1913
+ expectedSha256 ? `[docdex] Expected sha256: ${expectedSha256}` : null,
1914
+ actualSha256 ? `[docdex] Actual sha256: ${actualSha256}` : null,
1915
+ err.details?.source ? `[docdex] Source: ${err.details.source}` : null,
1916
+ err.details?.manifestName ? `[docdex] Manifest name: ${err.details.manifestName}` : null,
1917
+ err.details?.manifestVersion != null ? `[docdex] Manifest version: ${err.details.manifestVersion}` : null,
1918
+ fallbackAttempted != null ? `[docdex] Fallback attempted: ${fallbackAttempted}` : null,
1919
+ "[docdex] Next steps:",
1920
+ "[docdex] - Re-run the install; transient network/caching issues can corrupt downloads.",
1921
+ "[docdex] - Ensure you are installing from the intended repo/version (DOCDEX_DOWNLOAD_REPO, DOCDEX_VERSION).",
1922
+ "[docdex] - If behind a proxy or cache, bypass it; integrity mismatches can indicate tampering.",
1923
+ "[docdex] - If it still fails, build from source (`cargo build --release --locked`)."
1924
+ ].filter(Boolean),
1925
+ err
1926
+ )
1927
+ };
1928
+ }
97
1929
 
98
- const binaryPath = path.join(distDir, process.platform === "win32" ? "docdexd.exe" : "docdexd");
99
- if (!fs.existsSync(binaryPath)) {
100
- throw new Error(`Downloaded archive missing binary at ${binaryPath}`);
1930
+ if (err instanceof ArchiveInvalidError) {
1931
+ return {
1932
+ code: err.code,
1933
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1934
+ details: withBaseDetails(err.details),
1935
+ lines: appendInstallSafetyLines(
1936
+ [
1937
+ `[docdex] install failed: ${err.message}`,
1938
+ `[docdex] error code: ${err.code}`,
1939
+ err.details?.binaryPath ? `[docdex] Expected binary path: ${err.details.binaryPath}` : null
1940
+ ].filter(Boolean),
1941
+ err
1942
+ )
1943
+ };
101
1944
  }
102
1945
 
103
- await fs.promises.chmod(binaryPath, 0o755).catch(() => {});
104
- await fs.promises.rm(tmpFile, { force: true });
105
- console.log(`[docdex] Installed binary to ${binaryPath}`);
1946
+ if (err instanceof ManifestResolutionError) {
1947
+ const platformKey = typeof err.details?.platformKey === "string" ? err.details.platformKey : null;
1948
+ const expectedAssetPattern =
1949
+ typeof err.details?.expectedAssetPattern === "string"
1950
+ ? err.details.expectedAssetPattern
1951
+ : platformKey
1952
+ ? assetPatternForPlatformKey(platformKey)
1953
+ : assetPatternForPlatformKey(null);
1954
+
1955
+ const lines =
1956
+ err.code === "DOCDEX_ASSET_NO_MATCH"
1957
+ ? [
1958
+ "[docdex] install failed: missing artifact/version sync issue (manifest has no asset for this target)",
1959
+ `[docdex] error code: ${err.code}`,
1960
+ err.details?.targetTriple ? `[docdex] Expected target triple: ${err.details.targetTriple}` : null,
1961
+ `[docdex] Asset naming pattern: ${expectedAssetPattern}`,
1962
+ `[docdex] Details: ${err.message}`
1963
+ ].filter(Boolean)
1964
+ : [`[docdex] install failed: ${err.message}`, `[docdex] error code: ${err.code}`];
1965
+
1966
+ if (fallbackAttempted === false) {
1967
+ lines.push("[docdex] Fallback was not attempted because a manifest was present but unusable.");
1968
+ }
1969
+ if (Array.isArray(err.details?.supported) && err.details.supported.length) {
1970
+ lines.push(`[docdex] supported targets: ${err.details.supported.join(", ")}`);
1971
+ }
1972
+ if (Array.isArray(err.details?.matches) && err.details.matches.length) {
1973
+ lines.push(`[docdex] matched assets: ${err.details.matches.join(", ")}`);
1974
+ }
1975
+ return {
1976
+ code: err.code,
1977
+ exitCode: err.exitCode || EXIT_CODE_BY_ERROR_CODE[err.code] || 1,
1978
+ details: withBaseDetails(err.details),
1979
+ lines: appendInstallSafetyLines(lines, err)
1980
+ };
1981
+ }
1982
+
1983
+ const code = (err && typeof err.code === "string" && err.code) || "DOCDEX_INSTALL_FAILED";
1984
+ return {
1985
+ code,
1986
+ exitCode: (err && typeof err.exitCode === "number" && err.exitCode) || EXIT_CODE_BY_ERROR_CODE[code] || 1,
1987
+ details: withBaseDetails(err && err.details),
1988
+ lines: appendInstallSafetyLines(
1989
+ [`[docdex] install failed: ${err?.message || "unknown error"}`, `[docdex] error code: ${code}`],
1990
+ err
1991
+ )
1992
+ };
106
1993
  }
107
1994
 
108
- main().catch((err) => {
109
- console.error(`[docdex] install failed: ${err.message}`);
110
- process.exit(1);
111
- });
1995
+ function handleFatal(err) {
1996
+ const report = describeFatalError(err);
1997
+ for (const line of report.lines) console.error(line);
1998
+ process.exit(report.exitCode || 1);
1999
+ }
2000
+
2001
+ if (require.main === module) {
2002
+ main().catch(handleFatal);
2003
+ }
2004
+
2005
+ module.exports = {
2006
+ checksumCandidateNames,
2007
+ manifestCandidateNames,
2008
+ tryResolveAssetViaManifest,
2009
+ tryResolveSha256ViaChecksumFiles,
2010
+ resolveInstallerDownloadPlan,
2011
+ parseSha256File,
2012
+ sha256File,
2013
+ recoverInterruptedInstall,
2014
+ verifyInstalledDocdexdIntegrity,
2015
+ decideInstallAction,
2016
+ determineLocalInstallerOutcome,
2017
+ verifyDownloadedFileIntegrity,
2018
+ MissingArtifactError,
2019
+ ChecksumResolutionError,
2020
+ runInstaller,
2021
+ describeFatalError,
2022
+ handleFatal
2023
+ };