@link-assistant/hive-mind 1.58.0 → 1.59.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.
@@ -342,7 +342,9 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
342
342
  // ([].every(fn) returns true for any fn).
343
343
  if (allChecks.length === 0) {
344
344
  if (verbose) {
345
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks yet - treating as pending`);
345
+ // Issue #1712: Reword for clarity empty check-runs API response, not "no CI configured".
346
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${sha}`;
347
+ console.log(`[VERBOSE] /merge: PR #${prNumber} HEAD ${commitUrl} has no check-runs/statuses registered yet — treating as pending`);
346
348
  }
347
349
  return {
348
350
  status: 'pending',
@@ -1070,6 +1072,8 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1070
1072
  const statuses = JSON.parse(statusJson.trim() || '[]');
1071
1073
 
1072
1074
  // Build detailed checks list
1075
+ // Issue #1712: Include html_url so the user-facing waiting message can include
1076
+ // a clickable link for each pending/failing check.
1073
1077
  const allChecks = [
1074
1078
  ...checkRuns.map(check => ({
1075
1079
  name: check.name,
@@ -1077,6 +1081,7 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1077
1081
  conclusion: check.conclusion, // success, failure, cancelled, timed_out, skipped, neutral, action_required, stale, null
1078
1082
  type: 'check_run',
1079
1083
  id: check.id,
1084
+ html_url: check.html_url || check.details_url || null,
1080
1085
  })),
1081
1086
  ...statuses.map(status => ({
1082
1087
  name: status.context,
@@ -1084,13 +1089,20 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1084
1089
  conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
1085
1090
  type: 'status',
1086
1091
  id: null,
1092
+ html_url: status.target_url || null,
1087
1093
  })),
1088
1094
  ];
1089
1095
 
1090
1096
  // No checks yet
1091
1097
  if (allChecks.length === 0) {
1092
1098
  if (verbose) {
1093
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks yet - treating as no_checks`);
1099
+ // Issue #1712: Reword to avoid "no CI checks" sounding like "no CI configured".
1100
+ // We are inspecting the GitHub `/commits/{sha}/check-runs` and `/commits/{sha}/status` endpoints;
1101
+ // an empty result means no check-runs/statuses are registered yet for this commit, NOT that the
1102
+ // repository has no CI/CD workflows. The caller (`getMergeBlockers`) decides between race
1103
+ // condition vs. "no CI configured" based on additional API calls.
1104
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${sha}`;
1105
+ console.log(`[VERBOSE] /merge: PR #${prNumber} HEAD ${commitUrl} has no check-runs or commit statuses registered yet (status=no_checks; race vs. no-CI distinction is decided downstream)`);
1094
1106
  }
1095
1107
  return {
1096
1108
  status: 'no_checks',
@@ -1213,9 +1225,14 @@ export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
1213
1225
  .map(run => ({ id: run.id, status: run.status, conclusion: run.conclusion, name: run.name, html_url: run.html_url, path: run.path }));
1214
1226
 
1215
1227
  if (verbose) {
1216
- console.log(`[VERBOSE] /merge: Found ${runs.length} workflow runs for SHA ${sha.substring(0, 7)}`);
1228
+ // Issue #1712: Include the commit URL (not just SHA) and the workflow run html_url
1229
+ // so the user can click through to GitHub instead of pasting IDs into a URL by hand.
1230
+ // The run html_url already encodes the run ID, so we don't repeat it as a separate field.
1231
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${sha}`;
1232
+ console.log(`[VERBOSE] /merge: Found ${runs.length} workflow run(s) for ${commitUrl}`);
1217
1233
  for (const run of runs) {
1218
- console.log(`[VERBOSE] /merge: - ${run.name} (${run.id}): status=${run.status}, conclusion=${run.conclusion}`);
1234
+ const concPart = run.conclusion ? `/${run.conclusion}` : '';
1235
+ console.log(`[VERBOSE] /merge: - ${run.name} [${run.status}${concPart}]: ${run.html_url}`);
1219
1236
  }
1220
1237
  }
1221
1238
 
@@ -1333,8 +1350,8 @@ export { getCommitDate, checkPreviousPRCommitsHadCI, checkWorkflowsHavePRTrigger
1333
1350
  import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1334
1351
  export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
1335
1352
 
1336
- import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas } from './github-merge-repo-actions.lib.mjs'; // Issue #1503
1337
- export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas };
1353
+ import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas, getActivePRWorkflowRuns } from './github-merge-repo-actions.lib.mjs'; // Issue #1503, #1712
1354
+ export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas, getActivePRWorkflowRuns };
1338
1355
 
1339
1356
  export default {
1340
1357
  READY_LABEL,
@@ -1376,4 +1393,5 @@ export default {
1376
1393
  getAllActiveRepoRuns,
1377
1394
  waitForAllRepoActions,
1378
1395
  checkCIConsensus, // Issue #1503
1396
+ getActivePRWorkflowRuns, // Issue #1712: list active runs across ALL PR commits
1379
1397
  };
package/src/lino.lib.mjs CHANGED
@@ -2,7 +2,9 @@ if (typeof use === 'undefined') {
2
2
  globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
3
3
  }
4
4
 
5
- const linoModule = await use('links-notation');
5
+ // Issue #1710: hosted CI npm-install flake — retry once on a corrupt install.
6
+ const { useWithRetry } = await import('./use-with-retry.lib.mjs');
7
+ const linoModule = await useWithRetry(globalThis.use, 'links-notation');
6
8
  const LinoParser = linoModule.Parser || linoModule.default?.Parser;
7
9
 
8
10
  const fs = await import('fs');
@@ -36,10 +36,15 @@ if (typeof globalThis.use === 'undefined') {
36
36
  }
37
37
  }
38
38
 
39
- const getenvModule = await use('getenv');
39
+ // Issue #1710 / #1712: use-m occasionally hands back a truncated/corrupt global
40
+ // package on hosted CI (npm install -g flake — manifests as ERR_INVALID_PACKAGE_CONFIG,
41
+ // "Failed to resolve the path", or SyntaxError mid-import). useWithRetry deletes
42
+ // the broken install dir and re-fetches.
43
+ const { useWithRetry } = await import('./use-with-retry.lib.mjs');
44
+ const getenvModule = await useWithRetry(globalThis.use, 'getenv');
40
45
  // Node 24 CJS/ESM interop may return the whole module object instead of the function directly
41
46
  const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
42
- const linoModule = await use('links-notation');
47
+ const linoModule = await useWithRetry(globalThis.use, 'links-notation');
43
48
  const LinoParser = linoModule.Parser || linoModule.default?.Parser;
44
49
 
45
50
  /**
@@ -33,7 +33,43 @@ const { reportError } = sentryLib;
33
33
 
34
34
  // Import GitHub merge functions
35
35
  const githubMergeLib = await import('./github-merge.lib.mjs');
36
- const { checkPRMergeable, checkForBillingLimitError, getDetailedCIStatus, getWorkflowRunsForSha, getWorkflowRunJobsCount, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI } = githubMergeLib;
36
+ const { checkPRMergeable, checkForBillingLimitError, getDetailedCIStatus, getWorkflowRunsForSha, getWorkflowRunJobsCount, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI, getActivePRWorkflowRuns } = githubMergeLib;
37
+
38
+ /**
39
+ * Issue #1712: Plain-English meaning of GitHub Actions / check-run statuses, so the
40
+ * verbose log explains itself instead of forcing the user to look up GitHub docs.
41
+ * Returns the same status string suffixed with a parenthetical hint, e.g.
42
+ * "in_progress (currently executing)". Unknown statuses are returned unchanged.
43
+ */
44
+ const STATUS_HINTS = {
45
+ in_progress: 'currently executing',
46
+ queued: 'waiting for a runner',
47
+ pending: 'waiting to start',
48
+ waiting: 'blocked on a deployment / approval gate',
49
+ requested: 'requested but not yet picked up',
50
+ completed: 'finished',
51
+ };
52
+ const CONCLUSION_HINTS = {
53
+ success: 'passed',
54
+ failure: 'failed',
55
+ cancelled: 'cancelled (will be re-triggered if applicable)',
56
+ timed_out: 'timed out',
57
+ skipped: 'skipped (e.g. paths-ignore matched)',
58
+ neutral: 'neutral / informational',
59
+ action_required: 'manual approval required',
60
+ stale: 'stale — superseded by a newer run',
61
+ startup_failure: 'workflow failed to start (likely invalid YAML)',
62
+ };
63
+ const explainStatus = (status, conclusion) => {
64
+ const statusPart = status ? `${status}${STATUS_HINTS[status] ? ` (${STATUS_HINTS[status]})` : ''}` : 'unknown';
65
+ if (!conclusion) return statusPart;
66
+ const concPart = `${conclusion}${CONCLUSION_HINTS[conclusion] ? ` (${CONCLUSION_HINTS[conclusion]})` : ''}`;
67
+ return `${statusPart} → ${concPart}`;
68
+ };
69
+ const formatRunLine = run => {
70
+ const status = run.conclusion ? `${run.status}/${run.conclusion}` : run.status;
71
+ return `${run.name} [${status}] — ${run.html_url}`;
72
+ };
37
73
 
38
74
  // Issue #1625: Import centralized session-ending markers so the duplicate-
39
75
  // search scope for checkForExistingComment() stays in lock-step with the
@@ -343,13 +379,38 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
343
379
  }
344
380
 
345
381
  // Some workflow runs are still in progress or produced results — genuine race condition
382
+ // Issue #1712: User-facing blocker `details` carry the run URL + status so the
383
+ // top-level "⏳ Waiting for CI:" line is self-explanatory. Verbose listing is
384
+ // produced by `getWorkflowRunsForSha(..., verbose=true)` above — do NOT print the
385
+ // same run list twice. Here we only emit the one-line summary that explains
386
+ // *why* we're still waiting (race vs. real run).
387
+ const commitUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits/${ciStatus.sha}`;
346
388
  if (verbose) {
347
- await log(`[VERBOSE] /merge: PR #${prNumber} has no CI check-runs yet, but ${workflowRuns.length} workflow run(s) were triggered for SHA ${ciStatus.sha.substring(0, 7)} - genuine race condition (waiting for check-runs to appear)`);
389
+ await log(`[VERBOSE] /merge: ${workflowRuns.length} workflow run(s) registered for PR #${prNumber} HEAD ${commitUrl} waiting for them to publish check-runs (race condition between workflow_run and check_runs APIs, typically ~30–120 s)`);
348
390
  }
391
+
392
+ // Also surface any active workflow runs on OLDER PR commits, so the user's view of
393
+ // the GitHub Actions tab (which shows yellow dots for every commit) reconciles
394
+ // with the log. These are NOT blockers — GitHub's concurrency group cancels them
395
+ // when a new commit is pushed — but listing them stops the user from worrying that
396
+ // the watcher is missing them.
397
+ const activeAcrossCommits = await getActivePRWorkflowRuns(owner, repo, prNumber, ciStatus.sha, verbose, getWorkflowRunsForSha);
398
+ if (verbose && activeAcrossCommits.otherActive > 0) {
399
+ await log(`[VERBOSE] /merge: ${activeAcrossCommits.otherActive} additional active workflow run(s) on older commits of PR #${prNumber} (these are not blockers — GitHub's concurrency group will cancel them):`);
400
+ for (const group of activeAcrossCommits.groups) {
401
+ if (group.isHead) continue;
402
+ const olderCommitUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits/${group.sha}`;
403
+ await log(`[VERBOSE] /merge: on ${olderCommitUrl}:`);
404
+ for (const run of group.runs) {
405
+ await log(`[VERBOSE] /merge: - ${formatRunLine(run)} — ${explainStatus(run.status, run.conclusion)}`);
406
+ }
407
+ }
408
+ }
409
+
349
410
  blockers.push({
350
411
  type: 'ci_pending',
351
- message: `CI/CD checks have not started yet (${workflowRuns.length} workflow run(s) triggered, waiting for check-runs to appear)`,
352
- details: workflowRuns.map(r => r.name),
412
+ message: `Waiting for ${workflowRuns.length} workflow run(s) on HEAD ${commitUrl} to publish check-runs`,
413
+ details: workflowRuns.map(formatRunLine),
353
414
  });
354
415
  } else {
355
416
  // No workflow runs for this SHA — but this could be a race condition!
@@ -519,11 +580,27 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
519
580
  }
520
581
  } else if (ciStatus.status === 'pending') {
521
582
  // CI is still running or queued - wait for completion
522
- const pendingNames = [...ciStatus.pendingChecks, ...ciStatus.queuedChecks].map(c => c.name);
583
+ const pendingChecks = [...ciStatus.pendingChecks, ...ciStatus.queuedChecks];
584
+ const pendingDetails = pendingChecks.map(c => {
585
+ const statusPart = c.status ? ` [${c.status}]` : '';
586
+ const urlPart = c.html_url ? ` — ${c.html_url}` : '';
587
+ return `${c.name}${statusPart}${urlPart}`;
588
+ });
589
+ if (verbose) {
590
+ // Issue #1712: One concise line + a per-check entry that includes a plain-English
591
+ // explanation of the status. We do NOT also call getWorkflowRunsForSha here — the
592
+ // detailed CI status already covers the same data via check-runs.
593
+ const commitUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits/${ciStatus.sha}`;
594
+ await log(`[VERBOSE] /merge: ${pendingChecks.length} check-run(s) still running/queued on PR #${prNumber} HEAD ${commitUrl}:`);
595
+ for (const c of pendingChecks) {
596
+ const url = c.html_url ? ` — ${c.html_url}` : '';
597
+ await log(`[VERBOSE] /merge: - ${c.name}: ${explainStatus(c.status, c.conclusion)}${url}`);
598
+ }
599
+ }
523
600
  blockers.push({
524
601
  type: 'ci_pending',
525
602
  message: 'CI/CD checks are still running or queued',
526
- details: pendingNames,
603
+ details: pendingDetails,
527
604
  });
528
605
  } else if (ciStatus.status === 'cancelled') {
529
606
  // All non-passed checks are cancelled or stale (no genuine failures)
@@ -540,10 +617,15 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
540
617
  } else {
541
618
  // These need to be re-triggered, NOT treated as AI-fixable failures
542
619
  const cancelledOrStaleChecks = [...ciStatus.cancelledChecks, ...(ciStatus.staleChecks || [])];
620
+ const cancelledDetails = cancelledOrStaleChecks.map(c => {
621
+ const concPart = c.conclusion ? ` [${c.conclusion}]` : '';
622
+ const urlPart = c.html_url ? ` — ${c.html_url}` : '';
623
+ return `${c.name}${concPart}${urlPart}`;
624
+ });
543
625
  blockers.push({
544
626
  type: 'ci_cancelled',
545
627
  message: 'CI/CD checks were cancelled or became stale',
546
- details: cancelledOrStaleChecks.map(c => c.name),
628
+ details: cancelledDetails,
547
629
  sha: ciStatus.sha,
548
630
  });
549
631
  }
@@ -111,6 +111,11 @@ export const watchUntilMergeable = async params => {
111
111
  await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
112
112
  await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
113
113
  await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
114
+ // Issue #1708: Surface that --auto-input-until-mergeable streamed feedback
115
+ // into the prior session, so any restart triggered here is a fallback.
116
+ if (argv.autoInputUntilMergeable) {
117
+ await log(formatAligned('', 'Streaming-first:', '--auto-input-until-mergeable was active; this loop is the fallback', 2));
118
+ }
114
119
  await log('');
115
120
  await log('Press Ctrl+C to stop watching manually');
116
121
  await log('');
@@ -898,10 +903,30 @@ No further AI sessions will be started automatically for this run. Please review
898
903
  const cancelledOnly = blockers.every(b => b.type === 'ci_cancelled' || b.type === 'ci_pending');
899
904
  const cancelledBlocker = blockers.find(b => b.type === 'ci_cancelled');
900
905
 
906
+ // Issue #1712: When `details` contain URLs (which they now always do for ci_pending /
907
+ // ci_cancelled blockers), comma-joining them produces an unreadable single-line wall
908
+ // of text. Render the first detail inline (with the message as the header) and any
909
+ // additional details on their own indented lines. Each detail is already
910
+ // self-explanatory: "<name> [<status>] — <url>".
911
+ const renderBlocker = (icon, header, blocker) => {
912
+ if (!blocker.details || blocker.details.length === 0) {
913
+ return log(formatAligned(icon, header, blocker.message, 2));
914
+ }
915
+ if (blocker.details.length === 1) {
916
+ return log(formatAligned(icon, header, blocker.details[0], 2));
917
+ }
918
+ return (async () => {
919
+ await log(formatAligned(icon, header, blocker.message, 2));
920
+ for (const detail of blocker.details) {
921
+ await log(formatAligned('', '', detail, 4));
922
+ }
923
+ })();
924
+ };
925
+
901
926
  if (cancelledOnly && cancelledBlocker) {
902
- await log(formatAligned('🔄', 'Waiting for re-triggered CI:', cancelledBlocker.details.join(', '), 2));
927
+ await renderBlocker('🔄', 'Waiting for re-triggered CI:', cancelledBlocker);
903
928
  } else if (pendingBlocker) {
904
- await log(formatAligned('⏳', 'Waiting for CI:', pendingBlocker.details.length > 0 ? pendingBlocker.details.join(', ') : pendingBlocker.message, 2));
929
+ await renderBlocker('⏳', 'Waiting for CI:', pendingBlocker);
905
930
  } else {
906
931
  await log(formatAligned('⏳', 'Waiting for:', blockers.map(b => b.message).join(', '), 2));
907
932
  }
@@ -198,6 +198,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
198
198
  description: 'Auto-restart until PR becomes mergeable (no iteration limit). Restarts on new comments from non-bot users, CI failures, merge conflicts, or other issues. Does NOT auto-merge.',
199
199
  default: true,
200
200
  },
201
+ // Issue #1708: Stage 1 introduces this flag inert — it parses, appears in
202
+ // --help, and is read by validateAutoInputUntilMergeable below, but does not
203
+ // change the runtime loop yet. Stages 2-6 will wire it into watchUntilMergeable
204
+ // and the bidirectional NDJSON pipe (see docs/case-studies/issue-1708/).
205
+ 'auto-input-until-mergeable': {
206
+ type: 'boolean',
207
+ description: '[EXPERIMENTAL] Extend a single AI tool session as long as possible by streaming new input (uncommitted changes, CI/CD failures, PR/issue comments, issue title/body updates) directly into the running session, instead of restarting it. Implies --accept-incomming-comments-as-input and --queue-comments-to-input by default (comments are deferred until the AI finishes the current step and is waiting for input). Existing auto-restart/auto-resume loops remain enabled as a fallback, but the goal is to keep them dormant. The full streaming-aware watchUntilMergeable replacement and per-tool wiring is staged in subsequent PRs (see docs/case-studies/issue-1708/). Falls back gracefully on non-Claude tools and on streaming errors. Disabled by default.',
208
+ default: false,
209
+ },
201
210
  'wait-for-all-actions-in-repository-before-mergeable': {
202
211
  type: 'boolean',
203
212
  description: 'Wait for ALL active GitHub Actions workflow runs in the entire repository to complete before declaring PR mergeable. When enabled, blocks merge if ANY CI/CD run in the repository is active, regardless of branch — this is a strict safety mode for repositories with cross-branch CI/CD coupling. Disabled by default.',
@@ -377,6 +386,26 @@ export const SOLVE_OPTION_DEFINITIONS = {
377
386
  description: '[EXPERIMENTAL] Convenience flag that enables --interactive-mode, --accept-incomming-comments-as-input and --exclude-all-own-incomming-comments-from-input together. Only supported for --tool claude.',
378
387
  default: false,
379
388
  },
389
+ // Issue #1708: Comment delivery mode for --accept-incomming-comments-as-input.
390
+ // --stream-comments-to-input: forward comments immediately as they arrive
391
+ // (the default for --accept-incomming-comments-as-input on its own; matches
392
+ // the existing #817 behavior of pushing comments to Claude as soon as
393
+ // pollIncomingComments sees them).
394
+ // --queue-comments-to-input: hold comments until the AI signals it is idle
395
+ // (waiting for input), then flush the queue. Used by
396
+ // --auto-input-until-mergeable so the model finishes the current step
397
+ // before getting interrupted with new instructions.
398
+ // The two flags are mutually exclusive; if both are set, queue mode wins.
399
+ 'stream-comments-to-input': {
400
+ type: 'boolean',
401
+ description: '[EXPERIMENTAL] When --accept-incomming-comments-as-input is enabled, forward each new PR/issue comment to the AI immediately as it arrives (real-time streaming). This is the default behavior for --accept-incomming-comments-as-input on its own. Mutually exclusive with --queue-comments-to-input; queue mode wins if both are set. Only supported for --tool claude.',
402
+ default: false,
403
+ },
404
+ 'queue-comments-to-input': {
405
+ type: 'boolean',
406
+ description: '[EXPERIMENTAL] When --accept-incomming-comments-as-input is enabled, queue new PR/issue comments and only flush them once the AI signals it is idle (waiting for input). This is the default mode implied by --auto-input-until-mergeable so the AI completes the current step before being interrupted with new instructions. Mutually exclusive with --stream-comments-to-input; queue mode wins if both are set. Only supported for --tool claude.',
407
+ default: false,
408
+ },
380
409
  'prompt-explore-sub-agent': {
381
410
  type: 'boolean',
382
411
  description: 'Encourage AI to use Explore-style sub-agent workflow for codebase exploration. Supported for --tool claude and --tool codex.',
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Retry wrapper for `use-m` package loading.
5
+ *
6
+ * Issue #1710: Hosted CI runners occasionally hand back a truncated or
7
+ * partially-installed global package after `npm install -g <pkg>`. Three
8
+ * surface symptoms have been observed:
9
+ *
10
+ * 1. `import` throws a SyntaxError ("Unexpected end of input") wrapped
11
+ * in use-m's `Failed to import module from '<path>'.` — the file on
12
+ * disk is cut off mid-line.
13
+ * 2. use-m throws `Failed to resolve the path to '<pkg>' from '<dir>'`
14
+ * — the install completed without error but the package tree is
15
+ * missing files that the `main`/`exports` entry depends on.
16
+ * 3. Node throws `Invalid package config <dir>/package.json.` with
17
+ * `code: 'ERR_INVALID_PACKAGE_CONFIG'` — the package.json itself
18
+ * is corrupt/truncated and cannot even be parsed (issue #1712).
19
+ *
20
+ * The recovery is identical for all three: delete the broken alias install
21
+ * directory and ask use-m to re-fetch. A clean reinstall almost always
22
+ * succeeds. This helper centralises that retry so every call site picks
23
+ * it up.
24
+ */
25
+
26
+ /**
27
+ * @param {(specifier: string) => Promise<unknown>} use - the use-m loader.
28
+ * @param {string} specifier - the npm specifier to load (e.g. `'getenv'`).
29
+ * @param {object} [options]
30
+ * @param {number} [options.attempts=3] - total attempts including the first try.
31
+ * @param {(path: string) => Promise<void>} [options.cleanup] - injectable cleanup
32
+ * for the corrupted install directory (defaults to recursive `rm`).
33
+ * @returns {Promise<unknown>} the module returned by use-m.
34
+ */
35
+ export const useWithRetry = async (use, specifier, options = {}) => {
36
+ const attempts = options.attempts ?? 3;
37
+ const cleanup = options.cleanup ?? defaultCleanup;
38
+ let lastError;
39
+ for (let attempt = 1; attempt <= attempts; attempt++) {
40
+ try {
41
+ return await use(specifier);
42
+ } catch (error) {
43
+ lastError = error;
44
+ if (attempt === attempts || !isCorruptInstallError(error)) {
45
+ throw error;
46
+ }
47
+ const corruptedPath = extractCorruptedFilePath(error);
48
+ if (corruptedPath) {
49
+ try {
50
+ // Two failure modes:
51
+ // * "Failed to import module from '<file>'" — corruptedPath is a file
52
+ // inside the use-m alias dir (e.g. /.../getenv-v-latest/index.js).
53
+ // * "Failed to resolve the path to 'pkg' from '<dir>'" — corruptedPath
54
+ // is the alias dir itself (e.g. /.../links-notation-v-latest).
55
+ // For files, walk up to the alias dir; otherwise remove the dir as-is.
56
+ const { dirname } = await import('node:path');
57
+ const target = corruptedPath.endsWith('-v-latest') || /-v-\d/.test(corruptedPath) ? corruptedPath : dirname(corruptedPath);
58
+ await cleanup(target);
59
+ } catch {
60
+ // Best-effort cleanup; fall through to retry regardless.
61
+ }
62
+ }
63
+ }
64
+ }
65
+ // Unreachable — the loop either returns or throws.
66
+ throw lastError;
67
+ };
68
+
69
+ export const isCorruptInstallError = error => {
70
+ const cause = error?.cause;
71
+ if (cause instanceof SyntaxError) return true;
72
+ const causeMessage = typeof cause?.message === 'string' ? cause.message : '';
73
+ if (/Unexpected end of input|Unexpected token/.test(causeMessage)) return true;
74
+ // Mode 3 (issue #1712): package.json itself is corrupt — Node refuses to
75
+ // even parse it and throws ERR_INVALID_PACKAGE_CONFIG before use-m's own
76
+ // resolve/import logic gets a chance to run.
77
+ if (error?.code === 'ERR_INVALID_PACKAGE_CONFIG') return true;
78
+ if (cause?.code === 'ERR_INVALID_PACKAGE_CONFIG') return true;
79
+ // Mode 2 (also seen on hosted CI): npm install completes but the package
80
+ // tree is incomplete, so use-m can't resolve the entry point.
81
+ const message = typeof error?.message === 'string' ? error.message : '';
82
+ if (/^Failed to resolve the path to /.test(message)) return true;
83
+ // Fallback string match for ERR_INVALID_PACKAGE_CONFIG (in case the error
84
+ // bubbles through use-m without preserving the `code` property).
85
+ return /^Invalid package config /.test(message);
86
+ };
87
+
88
+ export const extractCorruptedFilePath = error => {
89
+ const message = typeof error?.message === 'string' ? error.message : '';
90
+ const importMatch = message.match(/Failed to import module from '([^']+)'/);
91
+ if (importMatch) return importMatch[1];
92
+ // For "Failed to resolve the path to 'pkg' from '<dir>'" the second path
93
+ // is already the alias install directory — return it directly so callers
94
+ // can clean it up (cleanup() handles both files and directories).
95
+ const resolveMatch = message.match(/Failed to resolve the path to '[^']+' from '([^']+)'/);
96
+ if (resolveMatch) return resolveMatch[1];
97
+ // Mode 3 (issue #1712): "Invalid package config <dir>/package.json." —
98
+ // extract the package.json path so the caller's cleanup() walks up to
99
+ // the alias dir.
100
+ const invalidConfigMatch = message.match(/Invalid package config (\S+?package\.json)/);
101
+ return invalidConfigMatch ? invalidConfigMatch[1] : null;
102
+ };
103
+
104
+ const defaultCleanup = async path => {
105
+ const { rm } = await import('node:fs/promises');
106
+ await rm(path, { recursive: true, force: true });
107
+ };