pi-lens 3.1.2 → 3.2.0

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 (154) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +16 -12
  3. package/clients/ast-grep-client.js +8 -1
  4. package/clients/ast-grep-client.ts +9 -1
  5. package/clients/biome-client.js +51 -38
  6. package/clients/biome-client.ts +60 -58
  7. package/clients/dependency-checker.js +30 -1
  8. package/clients/dependency-checker.ts +35 -1
  9. package/clients/dispatch/__tests__/runner-registration.test.ts +286 -282
  10. package/clients/dispatch/bus-dispatcher.js +15 -14
  11. package/clients/dispatch/bus-dispatcher.ts +32 -25
  12. package/clients/dispatch/dispatcher.js +18 -25
  13. package/clients/dispatch/dispatcher.test.ts +2 -1
  14. package/clients/dispatch/dispatcher.ts +17 -28
  15. package/clients/dispatch/plan.js +77 -32
  16. package/clients/dispatch/plan.ts +78 -32
  17. package/clients/dispatch/runners/ast-grep-napi.js +36 -376
  18. package/clients/dispatch/runners/ast-grep-napi.ts +60 -433
  19. package/clients/dispatch/runners/index.js +8 -4
  20. package/clients/dispatch/runners/index.ts +8 -4
  21. package/clients/dispatch/runners/lsp.js +65 -0
  22. package/clients/dispatch/runners/lsp.ts +125 -0
  23. package/clients/dispatch/runners/oxlint.js +2 -2
  24. package/clients/dispatch/runners/oxlint.ts +2 -2
  25. package/clients/dispatch/runners/pyright.js +24 -8
  26. package/clients/dispatch/runners/pyright.ts +28 -14
  27. package/clients/dispatch/runners/rust-clippy.js +2 -2
  28. package/clients/dispatch/runners/rust-clippy.ts +2 -4
  29. package/clients/dispatch/runners/tree-sitter.js +14 -2
  30. package/clients/dispatch/runners/tree-sitter.ts +15 -2
  31. package/clients/dispatch/runners/ts-lsp.js +3 -3
  32. package/clients/dispatch/runners/ts-lsp.ts +8 -5
  33. package/clients/dispatch/runners/yaml-rule-parser.js +292 -0
  34. package/clients/dispatch/runners/yaml-rule-parser.ts +338 -0
  35. package/clients/dispatch/types.js +3 -0
  36. package/clients/dispatch/types.ts +3 -0
  37. package/clients/formatters.js +67 -14
  38. package/clients/formatters.ts +68 -15
  39. package/clients/installer/index.js +78 -10
  40. package/clients/installer/index.ts +519 -426
  41. package/clients/jscpd-client.js +28 -0
  42. package/clients/jscpd-client.ts +41 -3
  43. package/clients/knip-client.js +30 -1
  44. package/clients/knip-client.ts +34 -2
  45. package/clients/lsp/__tests__/client.test.ts +64 -41
  46. package/clients/lsp/__tests__/config.test.ts +25 -17
  47. package/clients/lsp/__tests__/launch.test.ts +108 -43
  48. package/clients/lsp/__tests__/service.test.ts +76 -48
  49. package/clients/lsp/client.js +87 -2
  50. package/clients/lsp/client.ts +150 -6
  51. package/clients/lsp/config.js +8 -11
  52. package/clients/lsp/config.ts +24 -21
  53. package/clients/lsp/index.js +69 -0
  54. package/clients/lsp/index.ts +82 -0
  55. package/clients/lsp/interactive-install.js +19 -8
  56. package/clients/lsp/interactive-install.ts +52 -27
  57. package/clients/lsp/launch.js +182 -32
  58. package/clients/lsp/launch.ts +241 -38
  59. package/clients/lsp/path-utils.js +3 -46
  60. package/clients/lsp/path-utils.ts +11 -51
  61. package/clients/lsp/server.js +93 -71
  62. package/clients/lsp/server.ts +173 -131
  63. package/clients/path-utils.js +142 -0
  64. package/clients/path-utils.ts +153 -0
  65. package/clients/ruff-client.js +33 -4
  66. package/clients/ruff-client.ts +44 -13
  67. package/clients/safe-spawn.js +3 -1
  68. package/clients/safe-spawn.ts +3 -1
  69. package/clients/services/effect-integration.js +11 -7
  70. package/clients/services/effect-integration.ts +34 -26
  71. package/clients/sg-runner.js +51 -9
  72. package/clients/sg-runner.ts +58 -15
  73. package/clients/tree-sitter-client.js +12 -0
  74. package/clients/tree-sitter-client.ts +12 -0
  75. package/clients/typescript-client.js +6 -2
  76. package/clients/typescript-client.ts +9 -2
  77. package/commands/booboo.js +2 -4
  78. package/commands/booboo.ts +2 -4
  79. package/index.ts +377 -93
  80. package/package.json +2 -1
  81. package/rules/tree-sitter-queries/tsx/no-nested-links.yml +45 -0
  82. package/rules/tree-sitter-queries/typescript/constructor-super.yml +55 -0
  83. package/rules/tree-sitter-queries/typescript/debugger.yml +1 -1
  84. package/rules/tree-sitter-queries/typescript/no-dupe-class-members.yml +47 -0
  85. package/tsconfig.json +1 -1
  86. package/clients/__tests__/file-time.test.js +0 -216
  87. package/clients/__tests__/format-service.test.js +0 -245
  88. package/clients/__tests__/formatters.test.js +0 -271
  89. package/clients/agent-behavior-client.test.js +0 -94
  90. package/clients/ast-grep-client.test.js +0 -129
  91. package/clients/ast-grep-client.test.ts +0 -155
  92. package/clients/biome-client.test.js +0 -144
  93. package/clients/cache-manager.test.js +0 -197
  94. package/clients/complexity-client.test.js +0 -234
  95. package/clients/dependency-checker.test.js +0 -60
  96. package/clients/dispatch/__tests__/autofix-integration.test.js +0 -245
  97. package/clients/dispatch/__tests__/runner-registration.test.js +0 -236
  98. package/clients/dispatch/dispatcher.edge.test.js +0 -82
  99. package/clients/dispatch/dispatcher.format.test.js +0 -46
  100. package/clients/dispatch/dispatcher.inline.test.js +0 -74
  101. package/clients/dispatch/dispatcher.test.js +0 -115
  102. package/clients/dispatch/runners/architect.test.js +0 -138
  103. package/clients/dispatch/runners/ast-grep-napi.test.js +0 -106
  104. package/clients/dispatch/runners/oxlint.test.js +0 -230
  105. package/clients/dispatch/runners/pyright.test.js +0 -98
  106. package/clients/dispatch/runners/python-slop.test.js +0 -203
  107. package/clients/dispatch/runners/scan_codebase.test.js +0 -89
  108. package/clients/dispatch/runners/shellcheck.test.js +0 -98
  109. package/clients/dispatch/runners/spellcheck.test.js +0 -158
  110. package/clients/dispatch/runners/ts-slop.test.js +0 -180
  111. package/clients/dispatch/runners/ts-slop.test.ts +0 -230
  112. package/clients/dogfood.test.js +0 -201
  113. package/clients/file-kinds.test.js +0 -169
  114. package/clients/go-client.test.js +0 -127
  115. package/clients/jscpd-client.test.js +0 -127
  116. package/clients/knip-client.test.js +0 -112
  117. package/clients/lsp/__tests__/client.test.js +0 -325
  118. package/clients/lsp/__tests__/config.test.js +0 -166
  119. package/clients/lsp/__tests__/error-recovery.test.js +0 -213
  120. package/clients/lsp/__tests__/integration.test.js +0 -127
  121. package/clients/lsp/__tests__/launch.test.js +0 -260
  122. package/clients/lsp/__tests__/server.test.js +0 -259
  123. package/clients/lsp/__tests__/service.test.js +0 -417
  124. package/clients/metrics-client.test.js +0 -141
  125. package/clients/ruff-client.test.js +0 -132
  126. package/clients/rust-client.test.js +0 -108
  127. package/clients/sanitize.test.js +0 -177
  128. package/clients/secrets-scanner.test.js +0 -100
  129. package/clients/services/__tests__/effect-integration.test.js +0 -86
  130. package/clients/test-runner-client.test.js +0 -192
  131. package/clients/todo-scanner.test.js +0 -301
  132. package/clients/type-coverage-client.test.js +0 -105
  133. package/clients/typescript-client.codefix.test.js +0 -157
  134. package/clients/typescript-client.test.js +0 -105
  135. package/commands/clients/ast-grep-client.js +0 -250
  136. package/commands/clients/ast-grep-parser.js +0 -86
  137. package/commands/clients/ast-grep-rule-manager.js +0 -91
  138. package/commands/clients/ast-grep-types.js +0 -9
  139. package/commands/clients/biome-client.js +0 -380
  140. package/commands/clients/complexity-client.js +0 -667
  141. package/commands/clients/file-kinds.js +0 -177
  142. package/commands/clients/file-utils.js +0 -40
  143. package/commands/clients/jscpd-client.js +0 -169
  144. package/commands/clients/knip-client.js +0 -211
  145. package/commands/clients/ruff-client.js +0 -297
  146. package/commands/clients/safe-spawn.js +0 -88
  147. package/commands/clients/scan-utils.js +0 -83
  148. package/commands/clients/sg-runner.js +0 -190
  149. package/commands/clients/types.js +0 -11
  150. package/commands/clients/typescript-client.js +0 -505
  151. package/commands/rate.test.js +0 -119
  152. package/rules/ast-grep-rules/rules/no-dangerously-set-inner-html.yml +0 -13
  153. package/rules/ast-grep-rules/rules/no-debugger.yml +0 -12
  154. package/rules/ast-grep-rules/rules/no-eval.yml +0 -13
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Interactive LSP Installer
3
- *
3
+ *
4
4
  * Provides lazy auto-install with user prompt for common languages.
5
- *
5
+ *
6
6
  * Features:
7
7
  * - 30-second timeout with auto-accept
8
8
  * - --auto-install flag for non-interactive mode
@@ -10,17 +10,20 @@
10
10
  * - Only prompts for "common" languages (Go, Rust, YAML, JSON, Bash)
11
11
  */
12
12
 
13
- import * as fs from "fs/promises";
14
- import * as path from "path";
15
- import { spawn } from "child_process";
13
+ import { spawn } from "node:child_process";
14
+ import * as fs from "node:fs/promises";
15
+ import * as path from "node:path";
16
16
 
17
17
  // Languages that support interactive auto-install prompt
18
- const COMMON_LANGUAGES: Record<string, {
19
- toolId: string;
20
- toolName: string;
21
- installCommand: string;
22
- packageName: string;
23
- }> = {
18
+ const COMMON_LANGUAGES: Record<
19
+ string,
20
+ {
21
+ toolId: string;
22
+ toolName: string;
23
+ installCommand: string;
24
+ packageName: string;
25
+ }
26
+ > = {
24
27
  go: {
25
28
  toolId: "gopls",
26
29
  toolName: "Go Language Server (gopls)",
@@ -40,7 +43,7 @@ const COMMON_LANGUAGES: Record<string, {
40
43
  packageName: "yaml-language-server",
41
44
  },
42
45
  json: {
43
- toolId: "vscode-json-languageserver",
46
+ toolId: "vscode-json-language-server",
44
47
  toolName: "JSON Language Server",
45
48
  installCommand: "npm install -g vscode-langservers-extracted",
46
49
  packageName: "vscode-langservers-extracted",
@@ -68,7 +71,9 @@ function getCachePath(cwd: string): string {
68
71
  /**
69
72
  * Read cached install choices
70
73
  */
71
- async function readChoices(cwd: string): Promise<Record<string, InstallChoice>> {
74
+ async function readChoices(
75
+ cwd: string,
76
+ ): Promise<Record<string, InstallChoice>> {
72
77
  try {
73
78
  const cachePath = getCachePath(cwd);
74
79
  const content = await fs.readFile(cachePath, "utf-8");
@@ -84,11 +89,11 @@ async function readChoices(cwd: string): Promise<Record<string, InstallChoice>>
84
89
  async function saveChoice(
85
90
  cwd: string,
86
91
  toolId: string,
87
- choice: "yes" | "no" | "auto"
92
+ choice: "yes" | "no" | "auto",
88
93
  ): Promise<void> {
89
94
  const choices = await readChoices(cwd);
90
95
  choices[toolId] = { choice, timestamp: Date.now() };
91
-
96
+
92
97
  try {
93
98
  const cachePath = getCachePath(cwd);
94
99
  await fs.mkdir(path.dirname(cachePath), { recursive: true });
@@ -111,7 +116,7 @@ function promptUser(timeoutMs: number): Promise<"yes" | "no"> {
111
116
  const onData = (data: Buffer | string) => {
112
117
  const char = data.toString().trim().toLowerCase();
113
118
  cleanup();
114
-
119
+
115
120
  if (char === "y" || char === "\n" || char === "\r") {
116
121
  resolve("yes");
117
122
  } else if (char === "n") {
@@ -148,16 +153,21 @@ function promptUser(timeoutMs: number): Promise<"yes" | "no"> {
148
153
  */
149
154
  function isAutoInstallEnabled(): boolean {
150
155
  // Check environment variable or process arguments
151
- return process.env.PI_LENS_AUTO_INSTALL === "1" ||
152
- process.argv.includes("--auto-install");
156
+ return (
157
+ process.env.PI_LENS_AUTO_INSTALL === "1" ||
158
+ process.argv.includes("--auto-install")
159
+ );
153
160
  }
154
161
 
155
162
  /**
156
163
  * Attempt to install a tool
157
164
  */
158
- async function installTool(toolId: string, packageName: string): Promise<boolean> {
165
+ async function installTool(
166
+ toolId: string,
167
+ packageName: string,
168
+ ): Promise<boolean> {
159
169
  console.error(`[pi-lens] Installing ${toolId}...`);
160
-
170
+
161
171
  return new Promise((resolve) => {
162
172
  const proc = spawn("npm", ["install", "-g", packageName], {
163
173
  stdio: "inherit",
@@ -169,7 +179,9 @@ async function installTool(toolId: string, packageName: string): Promise<boolean
169
179
  console.error(`[pi-lens] ✓ ${toolId} installed successfully`);
170
180
  resolve(true);
171
181
  } else {
172
- console.error(`[pi-lens] ✗ ${toolId} installation failed (exit code ${code})`);
182
+ console.error(
183
+ `[pi-lens] ✗ ${toolId} installation failed (exit code ${code})`,
184
+ );
173
185
  resolve(false);
174
186
  }
175
187
  });
@@ -183,14 +195,14 @@ async function installTool(toolId: string, packageName: string): Promise<boolean
183
195
 
184
196
  /**
185
197
  * Prompt user for installation with timeout, or auto-install if flag set
186
- *
198
+ *
187
199
  * @param language - Language identifier (go, rust, yaml, json, bash)
188
200
  * @param cwd - Project root
189
201
  * @returns true if tool is/should be installed, false to skip
190
202
  */
191
203
  export async function promptForInstall(
192
204
  language: string,
193
- cwd: string
205
+ cwd: string,
194
206
  ): Promise<boolean> {
195
207
  const config = COMMON_LANGUAGES[language];
196
208
  if (!config) {
@@ -201,21 +213,34 @@ export async function promptForInstall(
201
213
  // Check cache first
202
214
  const choices = await readChoices(cwd);
203
215
  const cached = choices[config.toolId];
204
-
216
+
205
217
  if (cached) {
206
218
  // Cache valid for 30 days
207
219
  const thirtyDays = 30 * 24 * 60 * 60 * 1000;
208
220
  if (Date.now() - cached.timestamp < thirtyDays) {
209
221
  if (cached.choice === "yes" || cached.choice === "auto") {
210
- return true;
222
+ // Verify binary actually exists before trusting cache
223
+ try {
224
+ const { execSync } = await import("node:child_process");
225
+ execSync(`which ${config.toolId}`, { stdio: "ignore" });
226
+ return true; // Binary exists, cache is valid
227
+ } catch {
228
+ // Binary not found, invalidate cache and continue to install
229
+ console.error(
230
+ `[pi-lens] Cached ${config.toolId} not found, re-installing...`,
231
+ );
232
+ }
233
+ } else {
234
+ return false; // User previously declined
211
235
  }
212
- return false;
213
236
  }
214
237
  }
215
238
 
216
239
  // Check auto-install flag
217
240
  if (isAutoInstallEnabled()) {
218
- console.error(`[pi-lens] Auto-install enabled, installing ${config.toolName}...`);
241
+ console.error(
242
+ `[pi-lens] Auto-install enabled, installing ${config.toolName}...`,
243
+ );
219
244
  await saveChoice(cwd, config.toolId, "auto");
220
245
  return await installTool(config.toolId, config.packageName);
221
246
  }
@@ -6,18 +6,91 @@
6
6
  * - Node.js scripts (npx/bun)
7
7
  * - Package manager execution
8
8
  */
9
- import { spawn as nodeSpawn, } from "node:child_process";
9
+ import { execSync, spawn as nodeSpawn, } from "node:child_process";
10
+ import fs from "node:fs";
10
11
  import path from "node:path";
11
- // Helper to detect if running on Windows
12
12
  const isWindows = process.platform === "win32";
13
+ /**
14
+ * Find binary in npm global directory
15
+ * Works around PATH caching issue after npm install -g
16
+ */
17
+ function _findBinaryInNpmGlobal(command) {
18
+ try {
19
+ // Get npm global prefix
20
+ const prefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
21
+ // On Windows, binaries are directly in the prefix dir
22
+ // On Unix, they're in prefix/bin
23
+ const binDir = isWindows ? prefix : path.join(prefix, "bin");
24
+ // Check for Windows variants
25
+ const candidates = isWindows
26
+ ? [
27
+ path.join(binDir, `${command}.cmd`),
28
+ path.join(binDir, `${command}.exe`),
29
+ path.join(binDir, command),
30
+ ]
31
+ : [path.join(binDir, command)];
32
+ for (const candidate of candidates) {
33
+ if (fs.existsSync(candidate)) {
34
+ return candidate;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * Try to spawn a process, throwing immediately if it fails
45
+ */
46
+ function trySpawn(command, args, cwd, env, needsShell) {
47
+ let proc;
48
+ if (needsShell) {
49
+ // Use shell mode with quoted command
50
+ const shellCommand = `"${command}" ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
51
+ proc = nodeSpawn(shellCommand, [], {
52
+ cwd,
53
+ env,
54
+ stdio: ["pipe", "pipe", "pipe"],
55
+ detached: false,
56
+ windowsHide: true,
57
+ shell: true,
58
+ });
59
+ }
60
+ else {
61
+ // Use normal spawn without shell
62
+ proc = nodeSpawn(command, args, {
63
+ cwd,
64
+ env,
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ detached: false,
67
+ windowsHide: isWindows,
68
+ });
69
+ }
70
+ if (!proc.stdin || !proc.stdout || !proc.stderr) {
71
+ throw new Error(`Failed to spawn LSP server: ${command}`);
72
+ }
73
+ // Check if process exited immediately (spawn failure - synchronous check)
74
+ if (proc.exitCode !== null || proc.killed) {
75
+ throw new Error(`LSP server ${command} exited immediately (code: ${proc.exitCode}). ` +
76
+ `The binary may be missing or corrupted.`);
77
+ }
78
+ return proc;
79
+ }
13
80
  /**
14
81
  * Attach error handler to a spawned process to prevent ENOENT crashes
15
82
  * This catches "command not found" errors and other spawn failures
83
+ * Returns a promise that rejects if an immediate error occurs
16
84
  */
17
- function _attachErrorHandler(proc, context) {
85
+ function _attachErrorHandler(proc, context, rejectOnImmediateError) {
18
86
  proc.on("error", (err) => {
19
87
  // Log the error but don't crash - the caller should handle this gracefully
20
88
  console.error(`[lsp] Spawn error for ${context}:`, err.message);
89
+ // If we have a reject function and this is an immediate spawn error, reject
90
+ if (rejectOnImmediateError &&
91
+ err.code === "ENOENT") {
92
+ rejectOnImmediateError(err);
93
+ }
21
94
  });
22
95
  // Also handle unexpected exit (process crash after successful spawn)
23
96
  proc.on("exit", (code, signal) => {
@@ -38,13 +111,14 @@ function _attachErrorHandler(proc, context) {
38
111
  * - Uses absolute paths (relative paths fail in shell mode)
39
112
  * - Uses shell: true for .cmd files
40
113
  * - Uses windowsHide to prevent console window popup
114
+ * - Detects immediate spawn failures (ENOENT) before returning
41
115
  *
42
116
  * @param command - Command to run (e.g., "typescript-language-server")
43
117
  * @param args - Arguments (e.g., ["--stdio"])
44
118
  * @param options - Spawn options including cwd, env
45
119
  * @returns LSPProcess handle
46
120
  */
47
- export function launchLSP(command, args = [], options = {}) {
121
+ export async function launchLSP(command, args = [], options = {}) {
48
122
  const cwd = String(options.cwd ?? process.cwd());
49
123
  const env = { ...process.env, ...options.env };
50
124
  // Resolve command path
@@ -56,31 +130,53 @@ export function launchLSP(command, args = [], options = {}) {
56
130
  : command.includes(path.sep) || command.includes("/")
57
131
  ? path.resolve(cwd, command)
58
132
  : command; // Let system find it via PATH
59
- // On Windows with shell: true, we need to quote the command if it has spaces
60
- const needsShell = isWindows &&
61
- (resolvedCommand.includes(" ") || resolvedCommand.includes(".cmd"));
133
+ // Compute needsShell based on command
134
+ // On Windows, shell: true is needed for .cmd/.bat files and extensionless binaries
135
+ // .exe files can be spawned directly, but .cmd/.bat require shell interpretation
136
+ const hasScriptExtension = /\.(cmd|bat)$/i.test(resolvedCommand);
137
+ let needsShell = isWindows &&
138
+ (resolvedCommand.includes(" ") ||
139
+ hasScriptExtension ||
140
+ !/\.(exe|cmd|bat)$/i.test(resolvedCommand));
141
+ // Try to spawn the process
142
+ // If command not found, try npm global as fallback (handles PATH caching after install)
143
+ let spawnCommand = resolvedCommand;
144
+ // First, try to find in npm global if it's a simple command name
145
+ if (!path.isAbsolute(command) &&
146
+ !command.includes(path.sep) &&
147
+ !command.includes("/")) {
148
+ const npmGlobalPath = _findBinaryInNpmGlobal(command);
149
+ if (npmGlobalPath) {
150
+ spawnCommand = npmGlobalPath;
151
+ // Recompute needsShell for npm global path
152
+ const globalHasExt = /\.(exe|cmd|bat)$/i.test(spawnCommand);
153
+ needsShell = isWindows && (spawnCommand.includes(" ") || !globalHasExt);
154
+ }
155
+ }
62
156
  let proc;
63
- if (needsShell) {
64
- // Use shell mode with quoted command
65
- const shellCommand = `"${resolvedCommand}" ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
66
- proc = nodeSpawn(shellCommand, [], {
67
- cwd,
68
- env,
69
- stdio: ["pipe", "pipe", "pipe"],
70
- detached: false,
71
- windowsHide: true,
72
- shell: true,
73
- });
157
+ try {
158
+ proc = trySpawn(spawnCommand, args, cwd, env, needsShell);
74
159
  }
75
- else {
76
- // Use normal spawn without shell
77
- proc = nodeSpawn(resolvedCommand, args, {
78
- cwd,
79
- env,
80
- stdio: ["pipe", "pipe", "pipe"],
81
- detached: false,
82
- windowsHide: isWindows,
83
- });
160
+ catch (err) {
161
+ // If spawn failed with simple command, try npm global
162
+ if (!path.isAbsolute(command) &&
163
+ !command.includes(path.sep) &&
164
+ !command.includes("/")) {
165
+ const npmGlobalPath = _findBinaryInNpmGlobal(command);
166
+ if (npmGlobalPath && npmGlobalPath !== spawnCommand) {
167
+ console.error(`[lsp] Trying npm global: ${npmGlobalPath}`);
168
+ // Recompute needsShell for npm global path
169
+ const globalHasExt = /\.(exe|cmd|bat)$/i.test(npmGlobalPath);
170
+ const needsShellGlobal = isWindows && (npmGlobalPath.includes(" ") || !globalHasExt);
171
+ proc = trySpawn(npmGlobalPath, args, cwd, env, needsShellGlobal);
172
+ }
173
+ else {
174
+ throw err;
175
+ }
176
+ }
177
+ else {
178
+ throw err;
179
+ }
84
180
  }
85
181
  if (!proc.stdin || !proc.stdout || !proc.stderr) {
86
182
  throw new Error(`Failed to spawn LSP server: ${command}`);
@@ -90,7 +186,35 @@ export function launchLSP(command, args = [], options = {}) {
90
186
  throw new Error(`LSP server ${command} exited immediately (code: ${proc.exitCode}). ` +
91
187
  `The binary may be missing or corrupted.`);
92
188
  }
93
- // Attach error handler to prevent ENOENT crashes and track later failures
189
+ // For Windows and certain spawn failures, the error is async (ENOENT)
190
+ // We need to wait a small tick to catch immediate spawn failures
191
+ await new Promise((resolve, reject) => {
192
+ let settled = false;
193
+ // Attach error handler that can reject for immediate errors
194
+ proc.on("error", (err) => {
195
+ if (!settled && err.code === "ENOENT") {
196
+ settled = true;
197
+ reject(new Error(`LSP server binary not found: ${command}. ` +
198
+ `Install it or check your PATH.`));
199
+ }
200
+ });
201
+ // Also listen for immediate exit
202
+ proc.on("exit", (code) => {
203
+ if (!settled && code !== null) {
204
+ settled = true;
205
+ reject(new Error(`LSP server ${command} exited immediately with code ${code}. ` +
206
+ `The binary may be missing or corrupted.`));
207
+ }
208
+ });
209
+ // Give it a small window to fail immediately (ENOENT on Windows is fast)
210
+ setTimeout(() => {
211
+ if (!settled) {
212
+ settled = true;
213
+ resolve();
214
+ }
215
+ }, 50);
216
+ });
217
+ // Re-attach the permanent error handler now that we've passed the danger zone
94
218
  _attachErrorHandler(proc, command);
95
219
  return {
96
220
  process: proc,
@@ -103,7 +227,7 @@ export function launchLSP(command, args = [], options = {}) {
103
227
  /**
104
228
  * Spawn via package manager (npx/bun)
105
229
  */
106
- export function launchViaPackageManager(packageName, args = [], options = {}) {
230
+ export async function launchViaPackageManager(packageName, args = [], options = {}) {
107
231
  // Prefer bun if available, fall back to npx (use .cmd on Windows)
108
232
  const isWin = process.platform === "win32";
109
233
  if (process.env.BUN_INSTALL) {
@@ -123,7 +247,33 @@ export function launchViaPackageManager(packageName, args = [], options = {}) {
123
247
  windowsHide: true,
124
248
  shell: true,
125
249
  });
126
- // Attach error handler to prevent ENOENT crashes
250
+ if (!proc.stdin || !proc.stdout || !proc.stderr) {
251
+ throw new Error(`Failed to spawn package manager for: ${packageName}`);
252
+ }
253
+ // Check for immediate spawn failure on Windows
254
+ await new Promise((resolve, reject) => {
255
+ let settled = false;
256
+ proc.on("error", (err) => {
257
+ if (!settled && err.code === "ENOENT") {
258
+ settled = true;
259
+ reject(new Error(`Package manager not found for: ${packageName}. ` +
260
+ `Install Node.js or check your PATH.`));
261
+ }
262
+ });
263
+ proc.on("exit", (code) => {
264
+ if (!settled && code !== null) {
265
+ settled = true;
266
+ reject(new Error(`Package manager exited immediately for: ${packageName} (code: ${code})`));
267
+ }
268
+ });
269
+ setTimeout(() => {
270
+ if (!settled) {
271
+ settled = true;
272
+ resolve();
273
+ }
274
+ }, 50);
275
+ });
276
+ // Attach permanent error handler
127
277
  _attachErrorHandler(proc, packageName);
128
278
  return {
129
279
  process: proc,
@@ -138,13 +288,13 @@ export function launchViaPackageManager(packageName, args = [], options = {}) {
138
288
  /**
139
289
  * Spawn via Node.js directly
140
290
  */
141
- export function launchViaNode(scriptPath, args = [], options = {}) {
291
+ export async function launchViaNode(scriptPath, args = [], options = {}) {
142
292
  return launchLSP(process.execPath, [scriptPath, ...args], options);
143
293
  }
144
294
  /**
145
295
  * Spawn via Python module
146
296
  */
147
- export function launchViaPython(moduleName, args = [], options = {}) {
297
+ export async function launchViaPython(moduleName, args = [], options = {}) {
148
298
  // On Windows, prefer 'py' launcher, fall back to 'python'
149
299
  const pythonCmd = process.platform === "win32" ? "py" : "python3";
150
300
  return launchLSP(pythonCmd, ["-m", moduleName, ...args], options);