oh-my-opencode 5.0.0-beta.21 → 5.0.0-beta.23

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 (151) hide show
  1. package/dist/cli/index.js +290 -288
  2. package/dist/cli-node/index.js +290 -288
  3. package/dist/index.js +1675 -1657
  4. package/dist/skills/debugging/SKILL.md +2 -0
  5. package/dist/skills/debugging/references/methodology/00-setup.md +12 -0
  6. package/dist/skills/debugging/references/methodology/02-investigate.md +21 -0
  7. package/dist/skills/debugging/references/runtimes/go.md +15 -1
  8. package/dist/skills/debugging/references/runtimes/native-binary.md +38 -0
  9. package/dist/skills/debugging/references/runtimes/node.md +25 -0
  10. package/dist/skills/debugging/references/runtimes/python.md +13 -0
  11. package/dist/skills/debugging/references/runtimes/rust.md +41 -0
  12. package/dist/skills/debugging/references/scripts/dap.mjs +267 -0
  13. package/dist/skills/debugging/references/scripts/dap.test.ts +86 -0
  14. package/dist/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  15. package/dist/skills/debugging/references/tools/dap.md +103 -0
  16. package/dist/skills/debugging/references/tools/frida.md +193 -0
  17. package/dist/tools/delegate-task/types.d.ts +3 -0
  18. package/dist/tui.js +131 -147
  19. package/package.json +18 -15
  20. package/packages/lsp-core/src/index.ts +1 -0
  21. package/packages/lsp-core/src/lsp/client-wrapper.ts +25 -5
  22. package/packages/lsp-core/src/lsp/client.ts +26 -0
  23. package/packages/lsp-core/src/lsp/connection.ts +16 -0
  24. package/packages/lsp-core/src/lsp/constants.ts +1 -0
  25. package/packages/lsp-core/src/lsp/format-document.test.ts +143 -0
  26. package/packages/lsp-core/src/lsp/format-document.ts +90 -0
  27. package/packages/lsp-core/src/lsp/manager-max-clients.test.ts +151 -0
  28. package/packages/lsp-core/src/lsp/manager.ts +34 -1
  29. package/packages/lsp-core/src/lsp/server-definitions.ts +29 -0
  30. package/packages/lsp-core/src/lsp/server-installation.test.ts +376 -0
  31. package/packages/lsp-core/src/lsp/server-installation.ts +200 -27
  32. package/packages/lsp-core/src/lsp/server-resolution-local-binary.test.ts +170 -0
  33. package/packages/lsp-core/src/lsp/server-resolution.ts +18 -5
  34. package/packages/lsp-core/src/lsp/transport.ts +9 -0
  35. package/packages/lsp-core/src/lsp/types.ts +8 -0
  36. package/packages/lsp-core/src/tool-surface.test.ts +13 -1
  37. package/packages/lsp-core/src/tools/definitions.ts +14 -0
  38. package/packages/lsp-core/src/tools/format.test.ts +56 -0
  39. package/packages/lsp-core/src/tools/format.ts +56 -0
  40. package/packages/lsp-core/src/tools/index.ts +1 -0
  41. package/packages/lsp-core/src/tools/types.ts +10 -0
  42. package/packages/lsp-daemon/dist/cli.js +432 -144
  43. package/packages/lsp-daemon/dist/client.d.ts +10 -0
  44. package/packages/lsp-daemon/dist/client.js +401 -107
  45. package/packages/lsp-daemon/dist/daemon-client.d.ts +1 -0
  46. package/packages/lsp-daemon/dist/daemon-client.js +3 -0
  47. package/packages/lsp-daemon/dist/index.js +395 -107
  48. package/packages/lsp-daemon/package.json +1 -1
  49. package/packages/lsp-tools-mcp/dist/cli.js +384 -99
  50. package/packages/lsp-tools-mcp/dist/lsp/manager.js +53 -0
  51. package/packages/lsp-tools-mcp/dist/mcp.js +384 -99
  52. package/packages/lsp-tools-mcp/dist/tools.js +385 -99
  53. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  54. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2 -2
  55. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  56. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  57. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +69 -35
  58. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +21 -2
  59. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  60. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  61. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  63. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  64. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  65. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  66. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
  67. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +404 -121
  68. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  69. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  70. package/packages/omo-codex/plugin/components/rules/dist/cli.js +3 -3
  71. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  72. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  73. package/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts +3 -3
  74. package/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts +4 -4
  75. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  76. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  77. package/packages/omo-codex/plugin/components/telemetry/dist/cli.js +30 -15
  78. package/packages/omo-codex/plugin/components/telemetry/dist/posthog.js +30 -15
  79. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  80. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  81. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  82. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  83. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
  84. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  85. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +5 -1
  86. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +2 -1
  87. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +11 -1
  88. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +22 -10
  89. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  90. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  91. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +5 -1
  92. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +13 -1
  93. package/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts +3 -5
  94. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +26 -2
  95. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  96. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  97. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  98. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  99. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  100. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  101. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  102. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  103. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  104. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  105. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  106. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  107. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  108. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  109. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  110. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/model-catalog.json +3 -3
  119. package/packages/omo-codex/plugin/package-lock.json +13 -13
  120. package/packages/omo-codex/plugin/package.json +1 -1
  121. package/packages/omo-codex/plugin/scripts/migrate-codex-config/catalog.mjs +3 -3
  122. package/packages/omo-codex/plugin/skills/debugging/SKILL.md +2 -0
  123. package/packages/omo-codex/plugin/skills/debugging/references/methodology/00-setup.md +12 -0
  124. package/packages/omo-codex/plugin/skills/debugging/references/methodology/02-investigate.md +21 -0
  125. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/go.md +15 -1
  126. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/native-binary.md +38 -0
  127. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/node.md +25 -0
  128. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/python.md +13 -0
  129. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/rust.md +41 -0
  130. package/packages/omo-codex/plugin/skills/debugging/references/scripts/dap.mjs +267 -0
  131. package/packages/omo-codex/plugin/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  132. package/packages/omo-codex/plugin/skills/debugging/references/tools/dap.md +103 -0
  133. package/packages/omo-codex/plugin/skills/debugging/references/tools/frida.md +193 -0
  134. package/packages/omo-codex/plugin/test/aggregate-model-catalog.test.mjs +1 -1
  135. package/packages/omo-codex/plugin/test/auto-update.test.mjs +2 -2
  136. package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +8 -8
  137. package/packages/omo-codex/scripts/install-config-reasoning.test.mjs +2 -2
  138. package/packages/omo-codex/scripts/install-dist/install-local.mjs +38 -23
  139. package/packages/shared-skills/skills/debugging/SKILL.md +2 -0
  140. package/packages/shared-skills/skills/debugging/references/methodology/00-setup.md +12 -0
  141. package/packages/shared-skills/skills/debugging/references/methodology/02-investigate.md +21 -0
  142. package/packages/shared-skills/skills/debugging/references/runtimes/go.md +15 -1
  143. package/packages/shared-skills/skills/debugging/references/runtimes/native-binary.md +38 -0
  144. package/packages/shared-skills/skills/debugging/references/runtimes/node.md +25 -0
  145. package/packages/shared-skills/skills/debugging/references/runtimes/python.md +13 -0
  146. package/packages/shared-skills/skills/debugging/references/runtimes/rust.md +41 -0
  147. package/packages/shared-skills/skills/debugging/references/scripts/dap.mjs +267 -0
  148. package/packages/shared-skills/skills/debugging/references/scripts/dap.test.ts +86 -0
  149. package/packages/shared-skills/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  150. package/packages/shared-skills/skills/debugging/references/tools/dap.md +103 -0
  151. package/packages/shared-skills/skills/debugging/references/tools/frida.md +193 -0
@@ -0,0 +1,376 @@
1
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+
6
+ import {
7
+ __resetServerBinaryResolutionCacheForTests,
8
+ __serverBinaryProbeCountForTests,
9
+ isServerInstalled,
10
+ resolveServerBinary,
11
+ } from "./server-installation.js";
12
+
13
+ const tempDirectories: string[] = [];
14
+ let previousPath: string | undefined;
15
+
16
+ function makeTempRoot(prefix: string): string {
17
+ const root = mkdtempSync(join(tmpdir(), prefix));
18
+ tempDirectories.push(root);
19
+ return root;
20
+ }
21
+
22
+ function writeExecutable(path: string, body = "#!/bin/sh\nexit 0\n"): string {
23
+ mkdirSync(join(path, ".."), { recursive: true });
24
+ writeFileSync(path, body);
25
+ chmodSync(path, 0o755);
26
+ return path;
27
+ }
28
+
29
+ beforeEach(() => {
30
+ previousPath = process.env["PATH"];
31
+ __resetServerBinaryResolutionCacheForTests();
32
+ });
33
+
34
+ afterEach(() => {
35
+ if (previousPath === undefined) delete process.env["PATH"];
36
+ else process.env["PATH"] = previousPath;
37
+ for (const directory of tempDirectories.splice(0)) {
38
+ rmSync(directory, { recursive: true, force: true });
39
+ }
40
+ __resetServerBinaryResolutionCacheForTests();
41
+ });
42
+
43
+ describe("resolveServerBinary node marker resolution", () => {
44
+ test("#given a package.json marker and a local node bin #when resolving with an empty PATH #then it returns the local absolute path", () => {
45
+ // given
46
+ const root = makeTempRoot("lsp-local-node-");
47
+ writeFileSync(join(root, "package.json"), "{}\n");
48
+ const binaryPath = writeExecutable(join(root, "node_modules", ".bin", "biome"));
49
+ process.env["PATH"] = join(root, "definitely-empty");
50
+
51
+ // when
52
+ const resolved = resolveServerBinary(["biome", "lsp-proxy", "--stdio"], root);
53
+
54
+ // then
55
+ expect(resolved).toBe(binaryPath);
56
+ });
57
+
58
+ test("#given a nested working directory #when resolving #then it walks up to the marker root", () => {
59
+ // given
60
+ const root = makeTempRoot("lsp-local-nested-");
61
+ writeFileSync(join(root, "package.json"), "{}\n");
62
+ const binaryPath = writeExecutable(join(root, "node_modules", ".bin", "biome"));
63
+ const nested = join(root, "packages", "deep", "src");
64
+ mkdirSync(nested, { recursive: true });
65
+ process.env["PATH"] = join(root, "definitely-empty");
66
+
67
+ // when
68
+ const resolved = resolveServerBinary(["biome", "lsp-proxy", "--stdio"], nested);
69
+
70
+ // then
71
+ expect(resolved).toBe(binaryPath);
72
+ });
73
+
74
+ test("#given a local bin without any project marker #when resolving #then the unmarked directory is not trusted", () => {
75
+ // given
76
+ const root = makeTempRoot("lsp-local-unmarked-");
77
+ writeExecutable(join(root, "node_modules", ".bin", "biome"));
78
+ process.env["PATH"] = join(root, "definitely-empty");
79
+
80
+ // when
81
+ const resolved = resolveServerBinary(["biome", "lsp-proxy", "--stdio"], root);
82
+
83
+ // then
84
+ expect(resolved).toBeNull();
85
+ });
86
+
87
+ test("#given a bun.lock marker #when resolving #then the node bin directory is probed", () => {
88
+ // given
89
+ const root = makeTempRoot("lsp-local-bunlock-");
90
+ writeFileSync(join(root, "bun.lock"), "");
91
+ const binaryPath = writeExecutable(join(root, "node_modules", ".bin", "biome"));
92
+ process.env["PATH"] = join(root, "definitely-empty");
93
+
94
+ // when / then
95
+ expect(resolveServerBinary(["biome"], root)).toBe(binaryPath);
96
+ });
97
+ });
98
+
99
+ describe("resolveServerBinary non-node ecosystems", () => {
100
+ test("#given a pyproject marker and a venv binary #when resolving #then it returns the venv path", () => {
101
+ // given
102
+ const root = makeTempRoot("lsp-local-python-");
103
+ writeFileSync(join(root, "pyproject.toml"), "[project]\n");
104
+ const binaryPath = writeExecutable(join(root, ".venv", "bin", "ruff"));
105
+ process.env["PATH"] = join(root, "definitely-empty");
106
+
107
+ // when / then
108
+ expect(resolveServerBinary(["ruff", "server"], root)).toBe(binaryPath);
109
+ });
110
+
111
+ test("#given a Gemfile marker and a bundled binary #when resolving #then it returns the vendored bundle path", () => {
112
+ // given
113
+ const root = makeTempRoot("lsp-local-ruby-");
114
+ writeFileSync(join(root, "Gemfile"), "source 'https://rubygems.org'\n");
115
+ const binaryPath = writeExecutable(join(root, "vendor", "bundle", "bin", "rubocop"));
116
+ process.env["PATH"] = join(root, "definitely-empty");
117
+
118
+ // when / then
119
+ expect(resolveServerBinary(["rubocop", "--lsp"], root)).toBe(binaryPath);
120
+ });
121
+
122
+ test("#given a go.mod marker and a project bin #when resolving #then it returns the project bin path", () => {
123
+ // given
124
+ const root = makeTempRoot("lsp-local-go-");
125
+ writeFileSync(join(root, "go.mod"), "module example.com/demo\n");
126
+ const binaryPath = writeExecutable(join(root, "bin", "gopls"));
127
+ process.env["PATH"] = join(root, "definitely-empty");
128
+
129
+ // when / then
130
+ expect(resolveServerBinary(["gopls"], root)).toBe(binaryPath);
131
+ });
132
+
133
+ test("#given a python marker without a matching venv binary #when resolving #then node bin dirs are not borrowed", () => {
134
+ // given
135
+ const root = makeTempRoot("lsp-local-python-miss-");
136
+ writeFileSync(join(root, "requirements.txt"), "ruff\n");
137
+ writeExecutable(join(root, "node_modules", ".bin", "ruff"));
138
+ process.env["PATH"] = join(root, "definitely-empty");
139
+
140
+ // when / then
141
+ expect(resolveServerBinary(["ruff", "server"], root)).toBeNull();
142
+ });
143
+ });
144
+
145
+ describe("resolveServerBinary probe ordering", () => {
146
+ test("#given a local binary and a PATH binary #when resolving #then the local binary wins", () => {
147
+ // given
148
+ const root = makeTempRoot("lsp-local-priority-");
149
+ writeFileSync(join(root, "package.json"), "{}\n");
150
+ const localBinary = writeExecutable(join(root, "node_modules", ".bin", "biome"));
151
+ const pathDir = makeTempRoot("lsp-path-priority-");
152
+ writeExecutable(join(pathDir, "biome"));
153
+ process.env["PATH"] = pathDir;
154
+
155
+ // when / then
156
+ expect(resolveServerBinary(["biome"], root)).toBe(localBinary);
157
+ });
158
+
159
+ test("#given only a PATH binary #when resolving #then the PATH entry is returned", () => {
160
+ // given
161
+ const root = makeTempRoot("lsp-path-fallback-root-");
162
+ writeFileSync(join(root, "package.json"), "{}\n");
163
+ const pathDir = makeTempRoot("lsp-path-fallback-");
164
+ const pathBinary = writeExecutable(join(pathDir, "biome"));
165
+ process.env["PATH"] = pathDir;
166
+
167
+ // when / then
168
+ expect(resolveServerBinary(["biome"], root)).toBe(pathBinary);
169
+ });
170
+
171
+ test("#given a path-shaped command #when resolving #then the command path itself is validated", () => {
172
+ // given
173
+ const root = makeTempRoot("lsp-explicit-path-");
174
+ const binaryPath = writeExecutable(join(root, "tools", "custom-ls"));
175
+ process.env["PATH"] = join(root, "definitely-empty");
176
+
177
+ // when / then
178
+ expect(resolveServerBinary([binaryPath, "--stdio"], root)).toBe(binaryPath);
179
+ expect(resolveServerBinary([join(root, "tools", "absent-ls")], root)).toBeNull();
180
+ });
181
+
182
+ test("#given no working directory #when resolving #then it falls back to PATH only", () => {
183
+ // given
184
+ const pathDir = makeTempRoot("lsp-no-cwd-");
185
+ const pathBinary = writeExecutable(join(pathDir, "biome"));
186
+ process.env["PATH"] = pathDir;
187
+
188
+ // when / then
189
+ expect(resolveServerBinary(["biome"])).toBe(pathBinary);
190
+ });
191
+
192
+ test("#given nothing anywhere #when resolving #then it returns null", () => {
193
+ // given
194
+ const root = makeTempRoot("lsp-unresolved-");
195
+ writeFileSync(join(root, "package.json"), "{}\n");
196
+ process.env["PATH"] = join(root, "definitely-empty");
197
+
198
+ // when / then
199
+ expect(resolveServerBinary(["biome"], root)).toBeNull();
200
+ expect(resolveServerBinary([], root)).toBeNull();
201
+ });
202
+ });
203
+
204
+ describe("resolveServerBinary containment", () => {
205
+ test("#given a marker only above the working directory root #when resolving with a stop boundary #then it does not escape the boundary", () => {
206
+ // given
207
+ const outer = makeTempRoot("lsp-boundary-outer-");
208
+ writeFileSync(join(outer, "package.json"), "{}\n");
209
+ writeExecutable(join(outer, "node_modules", ".bin", "biome"));
210
+ const inner = join(outer, "inner");
211
+ mkdirSync(inner, { recursive: true });
212
+ process.env["PATH"] = join(outer, "definitely-empty");
213
+
214
+ // when / then
215
+ expect(resolveServerBinary(["biome"], inner, { stopAt: inner })).toBeNull();
216
+ expect(resolveServerBinary(["biome"], inner)).toBe(join(outer, "node_modules", ".bin", "biome"));
217
+ });
218
+
219
+ test("#given a git repository boundary #when resolving #then the walk stops at the repository root", () => {
220
+ // given
221
+ const outer = makeTempRoot("lsp-git-boundary-");
222
+ writeFileSync(join(outer, "package.json"), "{}\n");
223
+ writeExecutable(join(outer, "node_modules", ".bin", "biome"));
224
+ const repo = join(outer, "repo");
225
+ mkdirSync(join(repo, ".git"), { recursive: true });
226
+ process.env["PATH"] = join(outer, "definitely-empty");
227
+
228
+ // when / then
229
+ expect(resolveServerBinary(["biome"], repo)).toBeNull();
230
+ });
231
+ });
232
+
233
+ describe("resolveServerBinary windows suffix probing", () => {
234
+ test("#given a windows platform and a .cmd shim #when resolving locally #then the suffixed binary is found", () => {
235
+ // given
236
+ const root = makeTempRoot("lsp-win-local-");
237
+ writeFileSync(join(root, "package.json"), "{}\n");
238
+ const binaryPath = writeExecutable(join(root, "node_modules", ".bin", "biome.cmd"));
239
+ process.env["PATH"] = join(root, "definitely-empty");
240
+
241
+ // when
242
+ const resolved = resolveServerBinary(["biome"], root, { platform: "win32", pathExt: ".COM;.EXE;.CMD" });
243
+
244
+ // then
245
+ expect(resolved).toBe(binaryPath);
246
+ });
247
+
248
+ test("#given a windows platform and a PATH .exe #when resolving #then the suffixed PATH entry is found", () => {
249
+ // given
250
+ const pathDir = makeTempRoot("lsp-win-path-");
251
+ const binaryPath = writeExecutable(join(pathDir, "biome.exe"));
252
+ process.env["PATH"] = pathDir;
253
+
254
+ // when / then
255
+ expect(resolveServerBinary(["biome"], undefined, { platform: "win32", pathExt: ".COM;.EXE" })).toBe(binaryPath);
256
+ });
257
+ });
258
+
259
+ describe("resolveServerBinary caching", () => {
260
+ test("#given a repeated lookup #when resolving twice #then the filesystem is probed only once", () => {
261
+ // given
262
+ const root = makeTempRoot("lsp-cache-hit-");
263
+ writeFileSync(join(root, "package.json"), "{}\n");
264
+ const binaryPath = writeExecutable(join(root, "node_modules", ".bin", "biome"));
265
+ process.env["PATH"] = join(root, "definitely-empty");
266
+
267
+ // when
268
+ const first = resolveServerBinary(["biome"], root);
269
+ const probesAfterFirst = __serverBinaryProbeCountForTests();
270
+ const second = resolveServerBinary(["biome"], root);
271
+ const probesAfterSecond = __serverBinaryProbeCountForTests();
272
+
273
+ // then
274
+ expect(first).toBe(binaryPath);
275
+ expect(second).toBe(binaryPath);
276
+ expect(probesAfterFirst).toBeGreaterThan(0);
277
+ expect(probesAfterSecond).toBe(probesAfterFirst);
278
+ });
279
+
280
+ test("#given a cached negative lookup #when resolving twice #then the miss is also cached", () => {
281
+ // given
282
+ const root = makeTempRoot("lsp-cache-miss-");
283
+ writeFileSync(join(root, "package.json"), "{}\n");
284
+ process.env["PATH"] = join(root, "definitely-empty");
285
+
286
+ // when
287
+ expect(resolveServerBinary(["biome"], root)).toBeNull();
288
+ const probesAfterFirst = __serverBinaryProbeCountForTests();
289
+ expect(resolveServerBinary(["biome"], root)).toBeNull();
290
+
291
+ // then
292
+ expect(__serverBinaryProbeCountForTests()).toBe(probesAfterFirst);
293
+ });
294
+
295
+ test("#given distinct working directories #when resolving the same command #then the cache keys stay separate", () => {
296
+ // given
297
+ const first = makeTempRoot("lsp-cache-key-a-");
298
+ writeFileSync(join(first, "package.json"), "{}\n");
299
+ const firstBinary = writeExecutable(join(first, "node_modules", ".bin", "biome"));
300
+ const second = makeTempRoot("lsp-cache-key-b-");
301
+ writeFileSync(join(second, "package.json"), "{}\n");
302
+ process.env["PATH"] = join(first, "definitely-empty");
303
+
304
+ // when / then
305
+ expect(resolveServerBinary(["biome"], first)).toBe(firstBinary);
306
+ expect(resolveServerBinary(["biome"], second)).toBeNull();
307
+ });
308
+ });
309
+
310
+ describe("isServerInstalled compatibility", () => {
311
+ test("#given a repo-local binary #when checking installation #then it reports installed", () => {
312
+ // given
313
+ const root = makeTempRoot("lsp-compat-local-");
314
+ writeFileSync(join(root, "package.json"), "{}\n");
315
+ writeExecutable(join(root, "node_modules", ".bin", "biome"));
316
+ process.env["PATH"] = join(root, "definitely-empty");
317
+
318
+ // when / then
319
+ expect(isServerInstalled(["biome", "lsp-proxy"], root)).toBe(true);
320
+ });
321
+
322
+ test("#given a PATH binary and no working directory #when checking installation #then it reports installed", () => {
323
+ // given
324
+ const pathDir = makeTempRoot("lsp-compat-path-");
325
+ writeExecutable(join(pathDir, "typescript-language-server"));
326
+ process.env["PATH"] = pathDir;
327
+
328
+ // when / then
329
+ expect(isServerInstalled(["typescript-language-server", "--stdio"])).toBe(true);
330
+ });
331
+
332
+ test("#given no binary at all #when checking installation #then it reports not installed", () => {
333
+ // given
334
+ const root = makeTempRoot("lsp-compat-missing-");
335
+ process.env["PATH"] = join(root, "definitely-empty");
336
+
337
+ // when / then
338
+ expect(isServerInstalled(["totally-absent-language-server"], root)).toBe(false);
339
+ expect(isServerInstalled([])).toBe(false);
340
+ });
341
+
342
+ test("#given the node command #when checking installation #then the existing node allowance is preserved", () => {
343
+ // given
344
+ const root = makeTempRoot("lsp-compat-node-");
345
+ process.env["PATH"] = join(root, "definitely-empty");
346
+
347
+ // when / then
348
+ expect(isServerInstalled(["node", "server.js"], root)).toBe(true);
349
+ });
350
+ });
351
+
352
+ describe("resolveServerBinary node fallback", () => {
353
+ test("#given a node command missing from PATH #when resolving #then the name is kept so the server stays selectable", () => {
354
+ // given: a user-configured server such as { command: ["node", "my-ls.js"] } must keep
355
+ // working, matching the node allowance isServerInstalled() has always had.
356
+ const root = makeTempRoot("lsp-node-fallback-");
357
+ process.env["PATH"] = join(root, "definitely-empty");
358
+
359
+ // when
360
+ const resolved = resolveServerBinary(["node", "server.js"], root);
361
+
362
+ // then: unresolved rather than rewritten, so the spawn still goes through node
363
+ expect(resolved).toBe("node");
364
+ expect(isServerInstalled(["node", "server.js"], root)).toBe(true);
365
+ });
366
+
367
+ test("#given a node command present on PATH #when resolving #then the PATH entry still wins", () => {
368
+ // given
369
+ const pathDir = makeTempRoot("lsp-node-on-path-");
370
+ const pathBinary = writeExecutable(join(pathDir, "node"));
371
+ process.env["PATH"] = pathDir;
372
+
373
+ // when / then
374
+ expect(resolveServerBinary(["node", "server.js"])).toBe(pathBinary);
375
+ });
376
+ });
@@ -1,45 +1,218 @@
1
1
  import { existsSync } from "node:fs";
2
- import { delimiter, join } from "node:path";
2
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
3
3
 
4
- export function isServerInstalled(command: string[], _workingDirectory?: string): boolean {
5
- if (command.length === 0) return false;
4
+ /**
5
+ * Project markers that authorize probing a set of repo-local binary directories.
6
+ *
7
+ * A local bin directory is only trusted when the sibling marker proves the directory
8
+ * belongs to a project of that ecosystem. An unmarked `node_modules/.bin` is ignored so
9
+ * that a stray directory cannot inject an executable into the resolution order.
10
+ */
11
+ interface LocalBinRule {
12
+ readonly markers: readonly string[];
13
+ readonly binDirs: readonly string[];
14
+ }
6
15
 
7
- const [cmd] = command;
8
- if (!cmd) return false;
16
+ const LOCAL_BIN_RULES: readonly LocalBinRule[] = [
17
+ {
18
+ markers: ["package.json", "bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
19
+ binDirs: [join("node_modules", ".bin")],
20
+ },
21
+ {
22
+ markers: [
23
+ "pyproject.toml",
24
+ "ty.toml",
25
+ "requirements.txt",
26
+ "setup.py",
27
+ "setup.cfg",
28
+ "Pipfile",
29
+ "pyrightconfig.json",
30
+ "ruff.toml",
31
+ ".ruff.toml",
32
+ ],
33
+ binDirs: [
34
+ join(".venv", "bin"),
35
+ join(".venv", "Scripts"),
36
+ join("venv", "bin"),
37
+ join("venv", "Scripts"),
38
+ join(".env", "bin"),
39
+ join(".env", "Scripts"),
40
+ ],
41
+ },
42
+ {
43
+ markers: ["Gemfile", "Gemfile.lock"],
44
+ binDirs: [join("vendor", "bundle", "bin"), "bin"],
45
+ },
46
+ {
47
+ markers: ["go.mod", "go.sum", "go.work"],
48
+ binDirs: ["bin"],
49
+ },
50
+ ];
9
51
 
10
- if (cmd.includes("/") || cmd.includes("\\")) {
11
- if (existsSync(cmd)) return true;
52
+ export interface ResolveServerBinaryOptions {
53
+ /**
54
+ * Upward walk boundary. The walk never ascends above this directory, which lets callers
55
+ * confine resolution to a trusted request cwd.
56
+ */
57
+ readonly stopAt?: string;
58
+ readonly platform?: NodeJS.Platform;
59
+ readonly pathExt?: string;
60
+ readonly pathEnv?: string;
61
+ }
62
+
63
+ const resolutionCache = new Map<string, string | null>();
64
+ let probeCount = 0;
65
+
66
+ /** Test seam: clears the per-process resolution cache. */
67
+ export function __resetServerBinaryResolutionCacheForTests(): void {
68
+ resolutionCache.clear();
69
+ probeCount = 0;
70
+ }
71
+
72
+ /** Test seam: counts filesystem probes so cache hits are observable. */
73
+ export function __serverBinaryProbeCountForTests(): number {
74
+ return probeCount;
75
+ }
76
+
77
+ function probe(path: string): boolean {
78
+ probeCount += 1;
79
+ return existsSync(path);
80
+ }
81
+
82
+ function executableSuffixes(platform: NodeJS.Platform, pathExt: string | undefined): readonly string[] {
83
+ if (platform !== "win32") return [""];
84
+ const configured = pathExt ?? process.env["PATHEXT"] ?? "";
85
+ // PATHEXT is conventionally uppercase, but the on-disk shims npm/bun write are lowercase.
86
+ // Probe lowercase first so the returned path matches the real filename on case-sensitive
87
+ // filesystems; the resolved path is spawned, so its casing has to be exact.
88
+ const systemExts = configured
89
+ .split(";")
90
+ .filter((entry) => entry.length > 0)
91
+ .map((entry) => entry.toLowerCase());
92
+ return [...new Set(["", ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])];
93
+ }
94
+
95
+ function probeWithSuffixes(directory: string, command: string, suffixes: readonly string[]): string | null {
96
+ for (const suffix of suffixes) {
97
+ const candidate = join(directory, command + suffix);
98
+ if (probe(candidate)) return candidate;
12
99
  }
100
+ return null;
101
+ }
13
102
 
14
- const isWindows = process.platform === "win32";
103
+ function resolveLocal(
104
+ command: string,
105
+ workingDirectory: string,
106
+ suffixes: readonly string[],
107
+ stopAt: string | undefined,
108
+ ): string | null {
109
+ const boundary = stopAt === undefined ? undefined : resolve(stopAt);
110
+ let current = resolve(workingDirectory);
15
111
 
16
- let exts = [""];
17
- if (isWindows) {
18
- const pathExt = process.env["PATHEXT"] ?? "";
19
- if (pathExt) {
20
- const systemExts = pathExt.split(";").filter(Boolean);
21
- exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])];
22
- } else {
23
- exts = ["", ".exe", ".cmd", ".bat", ".ps1"];
112
+ while (true) {
113
+ for (const rule of LOCAL_BIN_RULES) {
114
+ if (!rule.markers.some((marker) => probe(join(current, marker)))) continue;
115
+ for (const binDir of rule.binDirs) {
116
+ const found = probeWithSuffixes(join(current, binDir), command, suffixes);
117
+ if (found !== null) return found;
118
+ }
24
119
  }
120
+
121
+ // A repository root ends the walk: tooling above it belongs to a different project.
122
+ if (probe(join(current, ".git"))) return null;
123
+ if (boundary !== undefined && current === boundary) return null;
124
+
125
+ const parent = dirname(current);
126
+ if (parent === current) return null;
127
+ current = parent;
25
128
  }
129
+ }
26
130
 
27
- let pathEnv = process.env["PATH"] ?? "";
28
- if (isWindows && !pathEnv) {
131
+ function resolveFromPath(
132
+ command: string,
133
+ suffixes: readonly string[],
134
+ platform: NodeJS.Platform,
135
+ pathEnvOverride: string | undefined,
136
+ ): string | null {
137
+ let pathEnv = pathEnvOverride ?? process.env["PATH"] ?? "";
138
+ if (platform === "win32" && !pathEnv) {
29
139
  pathEnv = process.env["Path"] ?? "";
30
140
  }
31
141
 
32
- const paths = pathEnv.split(delimiter);
142
+ for (const entry of pathEnv.split(delimiter)) {
143
+ if (entry.length === 0) continue;
144
+ const found = probeWithSuffixes(entry, command, suffixes);
145
+ if (found !== null) return found;
146
+ }
147
+ return null;
148
+ }
33
149
 
34
- for (const p of paths) {
35
- for (const suffix of exts) {
36
- if (existsSync(join(p, cmd + suffix))) {
37
- return true;
38
- }
39
- }
150
+ /**
151
+ * Resolves the absolute path of an LSP server binary.
152
+ *
153
+ * Probing order:
154
+ * 1. a path-shaped command is validated as-is,
155
+ * 2. marker-gated repo-local bin directories, walking up from `workingDirectory`,
156
+ * 3. `PATH` (including Windows executable suffixes),
157
+ * 4. `null` when nothing matches.
158
+ *
159
+ * Results are cached per process, keyed by working directory and command.
160
+ */
161
+ export function resolveServerBinary(
162
+ command: string[],
163
+ workingDirectory?: string,
164
+ options: ResolveServerBinaryOptions = {},
165
+ ): string | null {
166
+ if (command.length === 0) return null;
167
+ const [cmd] = command;
168
+ if (cmd === undefined || cmd.length === 0) return null;
169
+
170
+ const platform = options.platform ?? process.platform;
171
+ const suffixes = executableSuffixes(platform, options.pathExt);
172
+
173
+ if (cmd.includes("/") || cmd.includes("\\")) {
174
+ const explicit = isAbsolute(cmd) ? cmd : resolve(workingDirectory ?? process.cwd(), cmd);
175
+ return probe(explicit) ? explicit : null;
176
+ }
177
+
178
+ const cacheKey = JSON.stringify([
179
+ workingDirectory ?? "",
180
+ cmd,
181
+ platform,
182
+ options.stopAt ?? "",
183
+ options.pathExt ?? "",
184
+ options.pathEnv ?? process.env["PATH"] ?? "",
185
+ ]);
186
+ const cached = resolutionCache.get(cacheKey);
187
+ if (cached !== undefined) return cached;
188
+
189
+ let resolved: string | null = null;
190
+ if (workingDirectory !== undefined && workingDirectory.length > 0) {
191
+ resolved = resolveLocal(cmd, workingDirectory, suffixes, options.stopAt);
40
192
  }
193
+ resolved ??= resolveFromPath(cmd, suffixes, platform, options.pathEnv);
194
+ // A configured server may be launched through the interpreter itself, e.g.
195
+ // { command: ["node", "my-ls.js"] }. isServerInstalled() has always treated bare `node`
196
+ // as available regardless of PATH, so keep those servers selectable instead of
197
+ // regressing them into a not_installed result. The name is returned unresolved so the
198
+ // spawn still goes through `node`; rewriting it to process.execPath would silently
199
+ // substitute a different interpreter (bun) for the one the config asked for.
200
+ if (resolved === null && cmd === "node") resolved = cmd;
41
201
 
42
- if (cmd === "node") return true;
202
+ resolutionCache.set(cacheKey, resolved);
203
+ return resolved;
204
+ }
43
205
 
44
- return false;
206
+ /**
207
+ * Reports whether an LSP server binary is available.
208
+ *
209
+ * Retained for existing consumers; `resolveServerBinary` is the richer entry point.
210
+ */
211
+ export function isServerInstalled(
212
+ command: string[],
213
+ workingDirectory?: string,
214
+ options: ResolveServerBinaryOptions = {},
215
+ ): boolean {
216
+ if (command.length === 0) return false;
217
+ return resolveServerBinary(command, workingDirectory, options) !== null;
45
218
  }