knodin 0.7.6 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +41 -12
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +2181 -108
  4. package/dist/bin/launcher.js +25 -3
  5. package/dist/src/agent-integration.js +304 -0
  6. package/dist/src/artifact-refresh.js +82 -0
  7. package/dist/src/cli-args.js +292 -0
  8. package/dist/src/cli-model.js +384 -0
  9. package/dist/src/codeflow-replay.js +81 -0
  10. package/dist/src/compact-structural.js +96 -0
  11. package/dist/src/compare.js +39 -0
  12. package/dist/src/competitive-cold-mcp.js +40 -0
  13. package/dist/src/competitive-constraints.js +21 -0
  14. package/dist/src/competitive-manifest.js +411 -0
  15. package/dist/src/competitive-measurement.js +183 -0
  16. package/dist/src/competitive-runner.js +487 -0
  17. package/dist/src/competitive-sandbox.js +108 -0
  18. package/dist/src/context-export.js +423 -0
  19. package/dist/src/context.js +102 -0
  20. package/dist/src/deterministic-random.js +34 -0
  21. package/dist/src/diagnostics-write-helper.js +473 -0
  22. package/dist/src/diagnostics.js +1476 -0
  23. package/dist/src/docs-sections.js +142 -0
  24. package/dist/src/doctor.js +382 -0
  25. package/dist/src/engine/ann-hnsw.js +261 -0
  26. package/dist/src/engine/embeddings.js +193 -0
  27. package/dist/src/engine/file-walker.js +49 -0
  28. package/dist/src/engine/git-history.js +289 -0
  29. package/dist/src/engine/index.js +15094 -0
  30. package/dist/src/engine/perf.js +115 -0
  31. package/dist/src/engine/prune.js +112 -0
  32. package/dist/src/engine/sarif-import.js +341 -0
  33. package/dist/src/engine/scip-import.js +423 -0
  34. package/dist/src/engine/source-policy.js +85 -0
  35. package/dist/src/engine/sqlite.js +71 -0
  36. package/dist/src/engine/state-paths.js +175 -0
  37. package/dist/src/engine/symbol-delete.js +58 -0
  38. package/dist/src/execution-profile.js +208 -0
  39. package/dist/src/failure-diagnosis.js +655 -0
  40. package/dist/src/fleet.js +7 -0
  41. package/dist/src/git-executable.js +31 -0
  42. package/dist/src/graph-layout.js +173 -0
  43. package/dist/src/graph-query-health.js +115 -0
  44. package/dist/src/hook-manager-integration.js +156 -0
  45. package/dist/src/index-activity.js +126 -0
  46. package/dist/src/init-progress-worker.js +70 -2
  47. package/dist/src/init-progress.js +155 -0
  48. package/dist/src/init.js +1295 -0
  49. package/dist/src/lifecycle-health.js +282 -0
  50. package/dist/src/lsp-readonly.js +217 -0
  51. package/dist/src/mcp-graph-worker.js +69 -0
  52. package/dist/src/mcp-reliability.js +154 -0
  53. package/dist/src/mcp-worker-supervisor.js +350 -0
  54. package/dist/src/mirror.js +290 -0
  55. package/dist/src/node-runtime.js +157 -0
  56. package/dist/src/output-compression.js +630 -0
  57. package/dist/src/output-telemetry.js +368 -0
  58. package/dist/src/pr-triage.js +638 -0
  59. package/dist/src/progress-worker-runtime.js +46 -0
  60. package/dist/src/progressive-evidence.js +477 -0
  61. package/dist/src/pure-compression-cli.js +102 -0
  62. package/dist/src/relationship-adapters.js +377 -0
  63. package/dist/src/release-attestation.js +533 -0
  64. package/dist/src/release-preflight.js +513 -0
  65. package/dist/src/repair-lease.js +85 -0
  66. package/dist/src/repair-progress-worker.js +83 -2
  67. package/dist/src/repair-progress.js +262 -0
  68. package/dist/src/repository-init-process.js +177 -0
  69. package/dist/src/repository-management.js +1261 -0
  70. package/dist/src/resource-reachability.js +456 -0
  71. package/dist/src/response-budget.js +200 -0
  72. package/dist/src/server.js +217 -0
  73. package/dist/src/structural-fast-path.js +344 -0
  74. package/dist/src/structural-snapshot.js +37 -0
  75. package/dist/src/system-config.js +638 -0
  76. package/dist/src/terminal-help.js +83 -0
  77. package/dist/src/tools/knodin-tools.js +1645 -0
  78. package/dist/src/update-ceremony.js +162 -0
  79. package/dist/src/update-policy.js +944 -0
  80. package/dist/src/update-trust.js +504 -0
  81. package/dist/src/version.js +13 -0
  82. package/dist/src/visualization.js +515 -0
  83. package/dist/src/wait-for-fresh.js +98 -0
  84. package/dist/src/worktree-lifecycle.js +234 -0
  85. package/docs/BEHAVIORAL-CONTRACT.md +114 -0
  86. package/docs/CLI.md +30 -1
  87. package/docs/COMPARISON.md +413 -0
  88. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  89. package/docs/CONTAINED-EXECUTION.md +77 -0
  90. package/docs/DEMO.md +49 -0
  91. package/docs/DIAGNOSTICS.md +80 -0
  92. package/docs/GIT-HISTORY-REVIEW.md +39 -0
  93. package/docs/HANDOFF.md +180 -0
  94. package/docs/INSTALLATION.md +21 -18
  95. package/docs/MCP.md +64 -8
  96. package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
  97. package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
  98. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  99. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  100. package/docs/SCIP-IMPORT.md +62 -0
  101. package/docs/SIGNED-UPDATES.md +151 -0
  102. package/docs/TELEMETRY.md +46 -0
  103. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  104. package/docs/assets/knodin-favicon.svg +4 -0
  105. package/docs/releases/0.3.0.md +46 -0
  106. package/docs/releases/0.4.0.md +68 -0
  107. package/docs/releases/0.4.1.md +28 -0
  108. package/docs/releases/0.4.2.md +27 -0
  109. package/docs/releases/0.4.3.md +23 -0
  110. package/docs/releases/0.5.0.md +29 -0
  111. package/docs/releases/0.5.1.md +17 -0
  112. package/docs/releases/0.6.0.md +18 -0
  113. package/docs/releases/0.7.0.md +24 -0
  114. package/docs/releases/0.7.1.md +21 -0
  115. package/docs/releases/0.7.2.md +21 -0
  116. package/docs/releases/0.7.3.md +23 -0
  117. package/docs/releases/0.7.4.md +17 -0
  118. package/docs/releases/0.7.5.md +20 -0
  119. package/docs/releases/0.8.0.md +74 -0
  120. package/docs/releases/0.8.2.md +34 -0
  121. package/docs/releases/0.8.3.md +47 -0
  122. package/package.json +139 -4
  123. package/roadmap/competitive-roadmap.md +3896 -0
  124. package/schemas/release-attestation-v1.schema.json +210 -0
  125. package/schemas/support-bundle-v2.schema.json +212 -0
  126. package/dist/chunks/chunk-DMQAGX77.js +0 -654
  127. package/dist/chunks/chunk-F4Z3Z766.js +0 -4
  128. package/dist/chunks/chunk-SIJAQVSX.js +0 -3
  129. package/dist/chunks/chunk-X6M4HUUE.js +0 -2
  130. package/dist/chunks/chunk-YPRMY2LP.js +0 -8
  131. package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
  132. package/dist/chunks/server-7EDF4CBY.js +0 -14
  133. package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
  134. package/docs/releases/0.7.6.md +0 -25
@@ -0,0 +1,290 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { mirrorEntryPath, mirrorSourcePath, mirrorStagingRoot, mirrorStatePath, readMirrorRegistry, writeMirrorRegistry, } from "./engine/state-paths.js";
5
+ import { gitExecutable } from "./git-executable.js";
6
+ import { repositoryIdentity } from "./repository-management.js";
7
+ export class MirrorError extends Error {
8
+ kind;
9
+ constructor(kind, message) {
10
+ super(message);
11
+ this.name = "MirrorError";
12
+ this.kind = kind;
13
+ }
14
+ }
15
+ /** Marker dropped at a mirror's root so it cannot be mistaken for a checkout. */
16
+ const MIRROR_README = `This directory is a knodin mirror: a read-only shallow clone of a remote
17
+ repository, kept only so knodin can answer graph questions about it.
18
+
19
+ This is not a working copy.
20
+ Local edits here are DISCARDED on the next \`knodin remote refresh\`.
21
+ Clone the repository yourself if you intend to change it.
22
+ `;
23
+ /**
24
+ * Strips credentials from a clone URL so nothing secret reaches the registry.
25
+ *
26
+ * The password always goes. The username goes only for HTTP(S), where it is
27
+ * routinely a token or half of a credential pair.
28
+ *
29
+ * For other schemes the username is structural, not secret: `ssh://git@host/x`
30
+ * means the SSH account, and clearing it yields `ssh://host/x`, which is not the
31
+ * same URL and cannot be used to re-add the mirror. The registry value is shown
32
+ * by `knodin remote list`, so a user copying it back would get a URL that does
33
+ * not work — the listing has to stay actionable, not merely safe.
34
+ */
35
+ export function sanitizeCloneUrl(url) {
36
+ try {
37
+ const parsed = new URL(url);
38
+ parsed.password = "";
39
+ if (parsed.protocol === "http:" || parsed.protocol === "https:")
40
+ parsed.username = "";
41
+ return parsed.toString();
42
+ }
43
+ catch {
44
+ // scp-style (git@host:org/repo.git) carries no secret to strip.
45
+ return url;
46
+ }
47
+ }
48
+ /**
49
+ * Classifies a failed git invocation.
50
+ *
51
+ * GitHub and GitHub Enterprise deliberately answer 404 for a private repository
52
+ * the caller cannot see, so "missing" and "forbidden" are indistinguishable over
53
+ * the wire. They are therefore reported as one kind, and the message never
54
+ * claims the repository does not exist — that would turn an auth problem into a
55
+ * false statement about the remote.
56
+ */
57
+ export function classifyGitFailure(stderr, signal) {
58
+ if (signal)
59
+ return "interrupted";
60
+ const text = stderr.toLowerCase();
61
+ if (/no space left on device|disk quota|write error|out of memory/.test(text))
62
+ return "disk";
63
+ if (/could not resolve host|network is unreachable|connection timed out|failed to connect|temporary failure in name resolution|operation timed out/.test(text))
64
+ return "network";
65
+ if (/repository not found|not found|does not exist|authentication failed|permission denied|access denied|could not read username|terminal prompts disabled|403|401/.test(text))
66
+ return "not-found-or-forbidden";
67
+ return "unknown";
68
+ }
69
+ /**
70
+ * Removes embedded credentials from text before it is shown or logged.
71
+ *
72
+ * Git is necessarily invoked with the *raw* clone URL, and it echoes that URL
73
+ * back in its own error text — `fatal: repository 'https://user:token@host/x'
74
+ * not found`. Everything we surface interpolates that stderr, so a token in the
75
+ * URL would land in terminal output, and from there into scrollback, CI logs and
76
+ * support bundles. Only the registry copy was being sanitized.
77
+ *
78
+ * Two passes: an exact substitution of the raw URL when we have it, and a
79
+ * generic `//user:secret@` scrub for anything git spells differently (trailing
80
+ * `.git/`, percent-encoding, a redirect target we never passed in).
81
+ *
82
+ * The username class also excludes `:`, which is what makes this linear. An
83
+ * earlier form used `[^/@\s]*:[^/@\s]*@` and I argued it was safe because
84
+ * neither class can span the `@` that terminates them — true, and beside the
85
+ * point: neither class excluded `:` either, so a run of colons could be split
86
+ * between them in as many ways as there were colons. Measured on `//` followed
87
+ * by `a:` repeated, with no `@` to succeed on: 3.7 / 18.8 / 65.9 / 226.8 ms at
88
+ * 4k / 8k / 16k / 32k chars — quadratic. Excluding `:` from the first class
89
+ * fixes the split at the first colon; flat at 0.03 ms across the same inputs.
90
+ *
91
+ * Third time today that a regex I reasoned was linear was not, so: the guard on
92
+ * the credential-scrubbing path is itself measured, not argued.
93
+ */
94
+ const EMBEDDED_CREDENTIAL = /\/\/[^/@\s:]*:[^/@\s]*@/g;
95
+ /**
96
+ * Username-only userinfo on HTTP(S), e.g. `https://<token>@host/x`.
97
+ *
98
+ * Raised in review on #59: the pair form above misses a token passed as the
99
+ * username alone, which is a normal way to authenticate over HTTPS, so the
100
+ * scrub would have left it intact unless it happened to match `rawUrl` exactly.
101
+ *
102
+ * Restricted to `http:`/`https:` on purpose, and it is the same distinction
103
+ * `sanitizeCloneUrl` makes: over SSH the username is structural (`ssh://git@…`
104
+ * names the account), so blanket-redacting `//user@` would mangle SSH URLs in
105
+ * error text while protecting nothing. A *password* is secret under any scheme
106
+ * and is still caught by the pair pattern.
107
+ *
108
+ * Linear: the class excludes `/`, `@`, `:` and whitespace, so it cannot span
109
+ * either the `@` that terminates it or the `//` that begins the next attempt.
110
+ * Verified by measurement rather than by that argument — see the spec.
111
+ */
112
+ const HTTP_USERINFO = /(https?:\/\/)[^/@\s:]*@/gi;
113
+ export function redactSecrets(text, rawUrl) {
114
+ let out = text;
115
+ if (rawUrl?.includes("@"))
116
+ out = out.split(rawUrl).join(sanitizeCloneUrl(rawUrl));
117
+ out = out.replace(HTTP_USERINFO, "$1***@");
118
+ return out.replace(EMBEDDED_CREDENTIAL, "//***:***@");
119
+ }
120
+ /** Human-facing message for a failure kind, naming the identity actually used. */
121
+ function failureMessage(kind, url, stderr, rawUrl) {
122
+ const detail = redactSecrets(stderr, rawUrl).trim().split("\n").slice(-3).join(" ").slice(0, 400);
123
+ switch (kind) {
124
+ case "not-found-or-forbidden": {
125
+ // Git's own wording asserts non-existence ("repository ... does not
126
+ // exist", "Repository not found"), which is precisely the claim that
127
+ // cannot be supported: the server answers identically whether the
128
+ // repository is absent or merely invisible to these credentials.
129
+ // Passing that text through would launder a guess into a finding.
130
+ const neutral = detail
131
+ .replace(/does ?n[o']t exist/gi, "was not accessible")
132
+ .replace(/repository not found/gi, "repository not accessible");
133
+ return `knodin remote: ${url} was not found, or is not accessible with the credentials in use. Git reports both cases identically, so this is NOT evidence the repository is missing — retry with an account that has access (\`gh-as <account>\`) or an SSH remote. git reported: ${neutral}`;
134
+ }
135
+ case "network":
136
+ return `knodin remote: could not reach ${url}. ${detail}`;
137
+ case "disk":
138
+ return `knodin remote: ran out of disk while acquiring ${url}. ${detail}`;
139
+ case "interrupted":
140
+ return `knodin remote: acquiring ${url} was interrupted before it completed. ${detail}`;
141
+ default:
142
+ return `knodin remote: failed to acquire ${url}. ${detail}`;
143
+ }
144
+ }
145
+ /**
146
+ * Runs git, throwing a classified MirrorError on failure.
147
+ *
148
+ * `url` is the sanitized form used in messages. `rawUrl` is the credentialed
149
+ * form actually handed to git, supplied only so its secret can be scrubbed back
150
+ * out of git's own error text — never for display.
151
+ */
152
+ function runGit(args, url, cwd, rawUrl) {
153
+ const result = childProcess.spawnSync(gitExecutable(), args, {
154
+ cwd,
155
+ encoding: "utf8",
156
+ env: {
157
+ ...process.env,
158
+ // Never block on an interactive credential prompt: a hang is worse than
159
+ // a clean, classified failure.
160
+ GIT_TERMINAL_PROMPT: "0",
161
+ },
162
+ });
163
+ if (result.error)
164
+ throw new MirrorError("unknown", `${redactSecrets(result.error.message, rawUrl)} (${url})`);
165
+ if (result.status !== 0) {
166
+ const kind = classifyGitFailure(result.stderr ?? "", result.signal);
167
+ throw new MirrorError(kind, failureMessage(kind, url, result.stderr ?? "", rawUrl));
168
+ }
169
+ return result.stdout ?? "";
170
+ }
171
+ /** Recursively removes a directory, ignoring absence. */
172
+ function discard(target) {
173
+ fs.rmSync(target, { recursive: true, force: true });
174
+ }
175
+ /**
176
+ * Acquires a mirror of `url`.
177
+ *
178
+ * The clone lands in a staging directory and is renamed into place only once it
179
+ * has completed, so an interrupted acquisition can never leave a partial mirror
180
+ * that later reads as a complete one — the same failure shape as an empty graph
181
+ * that reads as "no results".
182
+ */
183
+ export function addMirror(url) {
184
+ const sanitized = sanitizeCloneUrl(url);
185
+ const staging = path.join(mirrorStagingRoot(), `pending-${process.pid}`);
186
+ fs.mkdirSync(mirrorStagingRoot(), { recursive: true });
187
+ discard(staging);
188
+ try {
189
+ // The raw `url` goes to git because it may carry the credentials that make
190
+ // the clone work; `sanitized` is what any message may show, and passing the
191
+ // raw form as the last argument lets its secret be scrubbed from git's own
192
+ // error text rather than echoed back to the terminal.
193
+ runGit(["clone", "--depth", "1", "--single-branch", url, staging], sanitized, undefined, url);
194
+ // Git records the clone URL verbatim in `.git/config`, so a token embedded
195
+ // in `url` would persist on disk inside the mirror — in a directory whose
196
+ // path `knodin remote list` prints — and be reused implicitly by every
197
+ // later `refresh`. Sanitizing the registry copy while leaving the real
198
+ // secret in the clone's config would be sanitization in name only.
199
+ //
200
+ // The cost is deliberate: `refresh` can no longer authenticate from a
201
+ // token baked into the URL, and falls back to whatever git would use
202
+ // anyway — an SSH key or a credential helper. When that is not configured,
203
+ // refresh fails with the existing not-found-or-forbidden message, which
204
+ // already points at `gh-as` and SSH remotes. An honest auth failure is
205
+ // better than a credential quietly living in ~/.knodin.
206
+ if (sanitized !== url)
207
+ runGit(["remote", "set-url", "origin", sanitized], sanitized, staging);
208
+ const identity = repositoryIdentity(staging);
209
+ const entry = mirrorEntryPath(identity);
210
+ const source = mirrorSourcePath(identity);
211
+ const existing = readMirrorRegistry().mirrors.find((m) => m.identity === identity);
212
+ if (existing && fs.existsSync(source)) {
213
+ discard(staging);
214
+ return { record: existing, alreadyPresent: true };
215
+ }
216
+ const sha = runGit(["rev-parse", "HEAD"], sanitized, staging).trim();
217
+ fs.mkdirSync(entry, { recursive: true });
218
+ fs.mkdirSync(mirrorStatePath(identity), { recursive: true });
219
+ discard(source);
220
+ fs.renameSync(staging, source);
221
+ fs.writeFileSync(path.join(entry, "README"), MIRROR_README);
222
+ const record = {
223
+ identity,
224
+ url: sanitized,
225
+ path: source,
226
+ fetchedAt: new Date().toISOString(),
227
+ sha,
228
+ readOnly: true,
229
+ };
230
+ const registry = readMirrorRegistry();
231
+ writeMirrorRegistry({
232
+ version: 1,
233
+ mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), record],
234
+ });
235
+ return { record, alreadyPresent: false };
236
+ }
237
+ finally {
238
+ discard(staging);
239
+ }
240
+ }
241
+ /**
242
+ * All registered mirrors, newest fetch first.
243
+ *
244
+ * Byte order, not `localeCompare`. `fetchedAt` is ISO 8601, which sorts
245
+ * correctly lexicographically, and locale collation can treat punctuation as
246
+ * variable-weight — so the `-`, `:` and `T` separators are not guaranteed to be
247
+ * compared the same way on every host. Same reasoning as `comparePaths`: this
248
+ * output should not depend on the machine it ran on.
249
+ */
250
+ export function listMirrors() {
251
+ return [...readMirrorRegistry().mirrors].sort((left, right) => {
252
+ if (left.fetchedAt > right.fetchedAt)
253
+ return -1;
254
+ return left.fetchedAt < right.fetchedAt ? 1 : 0;
255
+ });
256
+ }
257
+ /** Removes a mirror's clone, its graph, and its registry entry. */
258
+ export function removeMirror(identity) {
259
+ const registry = readMirrorRegistry();
260
+ if (!registry.mirrors.some((mirror) => mirror.identity === identity))
261
+ return false;
262
+ discard(mirrorEntryPath(identity));
263
+ writeMirrorRegistry({
264
+ version: 1,
265
+ mirrors: registry.mirrors.filter((m) => m.identity !== identity),
266
+ });
267
+ return true;
268
+ }
269
+ /**
270
+ * Fetches the mirror's branch again and hard-resets the clone onto it. The graph
271
+ * is left alone; callers re-index. Returns the record with its new sha.
272
+ */
273
+ export function refreshMirror(identity) {
274
+ const registry = readMirrorRegistry();
275
+ const record = registry.mirrors.find((m) => m.identity === identity);
276
+ if (!record)
277
+ throw new MirrorError("unknown", `knodin remote: no mirror named ${identity}`);
278
+ const source = record.path;
279
+ if (!fs.existsSync(source))
280
+ throw new MirrorError("unknown", `knodin remote: mirror ${identity} is registered but its clone is missing; remove and re-add it`);
281
+ runGit(["fetch", "--depth", "1", "origin"], record.url, source);
282
+ runGit(["reset", "--hard", "FETCH_HEAD"], record.url, source);
283
+ const sha = runGit(["rev-parse", "HEAD"], record.url, source).trim();
284
+ const updated = { ...record, sha, fetchedAt: new Date().toISOString() };
285
+ writeMirrorRegistry({
286
+ version: 1,
287
+ mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), updated],
288
+ });
289
+ return updated;
290
+ }
@@ -0,0 +1,157 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { compareBytesDescending } from "./compare.js";
6
+ const MINIMUM_NODE_MAJOR = 24;
7
+ function nodeMajor(version) {
8
+ const match = version?.trim().match(/^v?(\d+)(?:\.|$)/);
9
+ if (!match)
10
+ return undefined;
11
+ const major = Number(match[1]);
12
+ return Number.isSafeInteger(major) ? major : undefined;
13
+ }
14
+ function isSupported(version) {
15
+ return (nodeMajor(version) ?? 0) >= MINIMUM_NODE_MAJOR;
16
+ }
17
+ function sortVersionsDescending(left, right) {
18
+ const leftParts = left.replace(/^v/, "").split(".").map(Number);
19
+ const rightParts = right.replace(/^v/, "").split(".").map(Number);
20
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
21
+ const difference = (rightParts[index] ?? 0) - (leftParts[index] ?? 0);
22
+ if (difference !== 0)
23
+ return difference;
24
+ }
25
+ // Byte order: this tie-break decides which runtime the handoff selects, so it
26
+ // must not vary with the host locale. Descending, matching the numeric
27
+ // comparison above.
28
+ return compareBytesDescending(left, right);
29
+ }
30
+ function addPathCandidates(options, executableName, add) {
31
+ for (const directory of (options.env.PATH ?? "").split(options.pathApi.delimiter)) {
32
+ if (directory && options.pathApi.isAbsolute(directory))
33
+ add(options.pathApi.join(directory, executableName));
34
+ }
35
+ }
36
+ function defaultFnmRoot(options, appDataRoot) {
37
+ if (options.platform === "darwin")
38
+ return options.pathApi.join(options.homeDirectory, "Library", "Application Support", "fnm");
39
+ if (options.platform === "win32" && appDataRoot)
40
+ return options.pathApi.join(appDataRoot, "fnm");
41
+ return options.pathApi.join(options.homeDirectory, ".local", "share", "fnm");
42
+ }
43
+ function addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned) {
44
+ if (options.platform === "darwin") {
45
+ add("/opt/homebrew/opt/node@24/bin/node");
46
+ add("/opt/homebrew/opt/node/bin/node");
47
+ add("/usr/local/opt/node@24/bin/node");
48
+ add("/usr/local/opt/node/bin/node");
49
+ }
50
+ if (options.platform === "win32") {
51
+ addVersioned(absoluteEnvRoot(options.env.NVM_HOME), []);
52
+ const programFilesRoot = absoluteEnvRoot(options.env.ProgramFiles);
53
+ add(programFilesRoot
54
+ ? options.pathApi.join(programFilesRoot, "nodejs", executableName)
55
+ : undefined);
56
+ }
57
+ }
58
+ function runtimeCandidates(options) {
59
+ const { env, homeDirectory, pathApi, platform } = options;
60
+ const executableName = platform === "win32" ? "node.exe" : "node";
61
+ const candidates = [];
62
+ const seen = new Set([options.currentExecutable]);
63
+ const add = (candidate) => {
64
+ if (!candidate || seen.has(candidate))
65
+ return;
66
+ seen.add(candidate);
67
+ candidates.push(candidate);
68
+ };
69
+ const addVersioned = (root, suffix) => {
70
+ if (!root || !pathApi.isAbsolute(root))
71
+ return;
72
+ for (const version of options.listDirectories(root).sort(sortVersionsDescending))
73
+ add(pathApi.join(root, version, ...suffix, executableName));
74
+ };
75
+ if (env.KNODIN_NODE_RUNTIME && !pathApi.isAbsolute(env.KNODIN_NODE_RUNTIME))
76
+ throw new Error("KNODIN_NODE_RUNTIME must be an absolute path to a Node.js executable.");
77
+ add(env.KNODIN_NODE_RUNTIME);
78
+ addPathCandidates(options, executableName, add);
79
+ const absoluteEnvRoot = (value) => value && pathApi.isAbsolute(value) ? value : undefined;
80
+ const miseRoot = absoluteEnvRoot(env.MISE_DATA_DIR) ?? pathApi.join(homeDirectory, ".local", "share", "mise");
81
+ const managerBinaryDirectory = platform === "win32" ? [] : ["bin"];
82
+ addVersioned(pathApi.join(miseRoot, "installs", "node"), managerBinaryDirectory);
83
+ addVersioned(pathApi.join(homeDirectory, ".mise", "installs", "node"), managerBinaryDirectory);
84
+ const nvmRoot = absoluteEnvRoot(env.NVM_DIR);
85
+ addVersioned(nvmRoot ? pathApi.join(nvmRoot, "versions", "node") : undefined, managerBinaryDirectory);
86
+ addVersioned(pathApi.join(homeDirectory, ".nvm", "versions", "node"), managerBinaryDirectory);
87
+ const appDataRoot = absoluteEnvRoot(env.APPDATA);
88
+ const fnmDefaultRoot = defaultFnmRoot(options, appDataRoot);
89
+ const fnmRoot = absoluteEnvRoot(env.FNM_DIR);
90
+ addVersioned(fnmRoot ? pathApi.join(fnmRoot, "node-versions") : undefined, platform === "win32" ? ["installation"] : ["installation", "bin"]);
91
+ addVersioned(pathApi.join(fnmDefaultRoot, "node-versions"), platform === "win32" ? ["installation"] : ["installation", "bin"]);
92
+ const asdfRoot = absoluteEnvRoot(env.ASDF_DATA_DIR) ?? pathApi.join(homeDirectory, ".asdf");
93
+ addVersioned(pathApi.join(asdfRoot, "installs", "nodejs"), managerBinaryDirectory);
94
+ const voltaRoot = absoluteEnvRoot(env.VOLTA_HOME) ?? pathApi.join(homeDirectory, ".volta");
95
+ addVersioned(pathApi.join(voltaRoot, "tools", "image", "node"), managerBinaryDirectory);
96
+ addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned);
97
+ return candidates;
98
+ }
99
+ export function handoffToSupportedNodeRuntime(options) {
100
+ if (isSupported(options.currentVersion))
101
+ return { kind: "current" };
102
+ const explicitRuntime = options.env.KNODIN_NODE_RUNTIME;
103
+ if (explicitRuntime === options.currentExecutable)
104
+ throw new Error(`KNODIN_NODE_RUNTIME points to the active Node.js ${options.currentVersion} executable; Node.js 24 or newer is required.`);
105
+ for (const candidate of runtimeCandidates(options)) {
106
+ const version = options.probe(candidate);
107
+ if (!isSupported(version)) {
108
+ if (candidate === explicitRuntime) {
109
+ throw new Error(`KNODIN_NODE_RUNTIME points to ${candidate}, but it reports ${version ?? "no usable Node.js version"}; Node.js 24 or newer is required.`);
110
+ }
111
+ continue;
112
+ }
113
+ const result = options.spawn(candidate, options.launcherArguments, {
114
+ env: options.env,
115
+ stdio: "inherit",
116
+ });
117
+ if (result.error)
118
+ throw new Error(`Unable to launch Knodin with ${candidate}: ${result.error.message}`);
119
+ if (result.status === null && result.signal === null)
120
+ throw new Error(`Knodin's Node.js 24+ runtime at ${candidate} exited without a status.`);
121
+ return { kind: "relaunched", status: result.status, signal: result.signal };
122
+ }
123
+ throw new Error(`Knodin requires Node.js 24 or newer, but the active runtime is ${options.currentVersion}. Install Node.js 24+ with Homebrew or your version manager, or set KNODIN_NODE_RUNTIME=/absolute/path/to/node. Your project may remain pinned to Node.js 20.`);
124
+ }
125
+ function listDirectories(root) {
126
+ try {
127
+ return fs
128
+ .readdirSync(root, { withFileTypes: true })
129
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
130
+ .map((entry) => entry.name);
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ }
136
+ function probeNodeVersion(candidate) {
137
+ const result = spawnSync(candidate, ["-p", "process.versions.node"], {
138
+ encoding: "utf8",
139
+ stdio: ["ignore", "pipe", "ignore"],
140
+ timeout: 2_000,
141
+ });
142
+ return result.status === 0 ? result.stdout.trim() : undefined;
143
+ }
144
+ export function handoffCurrentProcessToSupportedNodeRuntime() {
145
+ return handoffToSupportedNodeRuntime({
146
+ currentExecutable: process.execPath,
147
+ currentVersion: process.versions.node,
148
+ env: process.env,
149
+ homeDirectory: os.homedir(),
150
+ platform: process.platform,
151
+ launcherArguments: process.argv.slice(1),
152
+ probe: probeNodeVersion,
153
+ spawn: (candidate, args, options) => spawnSync(candidate, args, options),
154
+ pathApi: path,
155
+ listDirectories,
156
+ });
157
+ }