pi-lens 3.8.63 → 3.8.65

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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +12 -9
  3. package/dist/clients/deadline-utils.js +23 -0
  4. package/dist/clients/installer/index.js +18 -27
  5. package/dist/clients/log-cleanup.js +36 -13
  6. package/dist/clients/lsp/client.js +44 -0
  7. package/dist/clients/lsp/index.js +208 -33
  8. package/dist/clients/lsp/interactive-install.js +14 -4
  9. package/dist/clients/lsp/launch.js +29 -100
  10. package/dist/clients/mcp/review.js +7 -4
  11. package/dist/clients/package-manager.js +223 -0
  12. package/dist/clients/pipeline.js +55 -2
  13. package/dist/clients/project-metadata.js +3 -23
  14. package/dist/clients/safe-spawn.js +9 -0
  15. package/dist/clients/tree-sitter-client.js +72 -14
  16. package/dist/index.js +16 -0
  17. package/dist/mcp/server.js +4 -2
  18. package/dist/tools/ast-grep-search.js +10 -6
  19. package/dist/tools/lens-diagnostics.js +49 -10
  20. package/dist/tools/lsp-diagnostics.js +27 -4
  21. package/dist/tools/scan-progress.js +53 -0
  22. package/grammars/tree-sitter-bash.wasm +0 -0
  23. package/grammars/tree-sitter-bash.wasm.json +5 -0
  24. package/grammars/tree-sitter-css.wasm +0 -0
  25. package/grammars/tree-sitter-css.wasm.json +5 -0
  26. package/grammars/tree-sitter-go.wasm +0 -0
  27. package/grammars/tree-sitter-go.wasm.json +5 -0
  28. package/grammars/tree-sitter-html.wasm +0 -0
  29. package/grammars/tree-sitter-html.wasm.json +5 -0
  30. package/grammars/tree-sitter-java.wasm +0 -0
  31. package/grammars/tree-sitter-java.wasm.json +5 -0
  32. package/grammars/tree-sitter-javascript.wasm +0 -0
  33. package/grammars/tree-sitter-javascript.wasm.json +5 -0
  34. package/grammars/tree-sitter-json.wasm +0 -0
  35. package/grammars/tree-sitter-json.wasm.json +5 -0
  36. package/grammars/tree-sitter-python.wasm +0 -0
  37. package/grammars/tree-sitter-python.wasm.json +5 -0
  38. package/grammars/tree-sitter-rust.wasm +0 -0
  39. package/grammars/tree-sitter-rust.wasm.json +5 -0
  40. package/grammars/tree-sitter-tsx.wasm +0 -0
  41. package/grammars/tree-sitter-tsx.wasm.json +5 -0
  42. package/grammars/tree-sitter-typescript.wasm +0 -0
  43. package/grammars/tree-sitter-typescript.wasm.json +5 -0
  44. package/grammars/tree-sitter-yaml.wasm +0 -0
  45. package/grammars/tree-sitter-yaml.wasm.json +5 -0
  46. package/package.json +9 -7
  47. package/scripts/analyze-pi-lens-logs.mjs +101 -3
  48. package/scripts/download-grammars.js +171 -30
  49. package/scripts/grammars.lock.json +32 -0
@@ -15,6 +15,7 @@ import { isTestMode } from "../env-utils.js";
15
15
  import { getGlobalPiLensDir, getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
16
16
  import { recordLsp } from "../widget-state.js";
17
17
  import { logLatency } from "../latency-logger.js";
18
+ import { withDeadline } from "../deadline-utils.js";
18
19
  import { normalizeMapKey, uriToPath } from "../path-utils.js";
19
20
  import { createLSPClient } from "./client.js";
20
21
  import { getServersForFileWithConfig } from "./config.js";
@@ -84,6 +85,24 @@ function mergeLspDiagnostics(diagnostics) {
84
85
  return merged;
85
86
  }
86
87
  const WORKSPACE_DIAGNOSTICS_CONCURRENCY = 8;
88
+ // The notify write (didOpen/didChange) is normally instant, but it awaits a
89
+ // JSON-RPC send that BACKPRESSURES when the server's stdin isn't being drained
90
+ // (a wedged/CPU-bound server, e.g. TypeScript mid-recheck). Unbounded, that
91
+ // write parks every touchFile caller: the pre-dispatch sync, the dispatch LSP
92
+ // runner (which then rides to its 30s dispatcher timeout — the observed ~31s
93
+ // edits), and the workspace sweep. Bounding it here degrades a wedged server to
94
+ // "no fresh diagnostics" instead of hanging the edit, for ALL callers.
95
+ function notifyWriteBudgetMs() {
96
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
97
+ return Number.isFinite(raw) && raw > 0 ? raw : 2000;
98
+ }
99
+ // Budget for one project-wide `workspace/diagnostic` pull (#387 Item 2). Larger
100
+ // than a per-file wait — it's a single request but scans the whole program —
101
+ // yet bounded so a hung server still falls back to the per-file path.
102
+ function workspacePullBudgetMs() {
103
+ const raw = Number(process.env.PI_LENS_LSP_WORKSPACE_PULL_MS);
104
+ return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
105
+ }
87
106
  // Hard cap on the workspace-diagnostics walk. Even though this is an explicit,
88
107
  // user-invoked project-wide tool, the walk must be bounded so a misrooted run
89
108
  // (e.g. cwd that resolves to $HOME) can't enumerate an entire home tree (#250).
@@ -711,8 +730,22 @@ export class LSPService {
711
730
  // runner touch sequence expensive on slow TS projects.
712
731
  const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
713
732
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
733
+ let notifyWriteTimedOut = false;
714
734
  if (!notifySkipped) {
715
- await Promise.all(spawned.map((entry) => entry.client.notify.open(filePath, content, languageId, undefined, silent)));
735
+ // Bounded so a backpressured write can't hang the caller (see
736
+ // notifyWriteBudgetMs). On timeout we proceed: the diagnostics wait below
737
+ // is separately bounded and simply returns no fresh diagnostics.
738
+ const wrote = await withDeadline(Promise.all(spawned.map((entry) => entry.client.notify.open(filePath, content, languageId, undefined, silent))), { ms: notifyWriteBudgetMs(), onTimeout: "undefined", onReject: "undefined" });
739
+ if (wrote === undefined) {
740
+ notifyWriteTimedOut = true;
741
+ logLatency({
742
+ type: "phase",
743
+ phase: "lsp_notify_timeout",
744
+ filePath: normalizedPath,
745
+ durationMs: Date.now() - startedAt,
746
+ metadata: { source, clientScope, serverCount: spawned.length },
747
+ });
748
+ }
716
749
  }
717
750
  let diagnosticsTimedOut = false;
718
751
  if (diagnosticsMode !== "none") {
@@ -821,6 +854,7 @@ export class LSPService {
821
854
  failureKind: "success",
822
855
  collectedDiagnostics: collected?.length,
823
856
  notifySkipped,
857
+ notifyWriteTimedOut,
824
858
  diagnosticsTimedOut,
825
859
  },
826
860
  });
@@ -1353,42 +1387,148 @@ export class LSPService {
1353
1387
  ? Math.floor(options.maxFiles)
1354
1388
  : getMaxWorkspaceDiagnosticFiles();
1355
1389
  const files = await collectWorkspaceDiagnosticFiles(root, maxFiles, signal);
1356
- const results = new Array(files.length);
1357
- let nextIndex = 0;
1358
- const workers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, files.length);
1359
- await Promise.all(Array.from({ length: workers }, async () => {
1390
+ // Per-file wall-clock: a language server that hangs during spawn/initialize
1391
+ // would otherwise park a worker on `touchFile` FOREVER (the per-edit
1392
+ // diagnostic wait is bounded, but client acquisition here is not) — the root
1393
+ // of an observed multi-hour hang. Budget each file so the worker always
1394
+ // returns to the loop (and its abort check). Env-tunable.
1395
+ const perFileMs = (() => {
1396
+ const raw = Number(process.env.PI_LENS_LSP_WORKSPACE_PER_FILE_MS);
1397
+ return Number.isFinite(raw) && raw > 0 ? raw : 15_000;
1398
+ })();
1399
+ const results = [];
1400
+ let completed = 0;
1401
+ let timedOutFiles = 0;
1402
+ let lastHeartbeat = Date.now();
1403
+ // Group files by their primary language server (#387). tsserver — and most
1404
+ // servers — is single-threaded per project: N concurrent touches to ONE
1405
+ // server don't parallelize, they queue. That inflates the working set (each
1406
+ // didOpen can force a project recheck) and cascades per-file-budget timeouts
1407
+ // by queue position (observed: 51/123 files "timed out" purely from being
1408
+ // behind others in an 8-wide flat pool). So serialize WITHIN a server (one
1409
+ // in-flight touch each) and parallelize ACROSS servers — real parallelism
1410
+ // where it exists (a mixed TS+Python repo runs both), no flooding where it
1411
+ // doesn't. Capped so a many-language monorepo can't spawn unbounded groups.
1412
+ const byServer = new Map();
1413
+ for (const filePath of files) {
1414
+ const servers = getServersForFileWithConfig(filePath);
1415
+ const primary = servers[0]?.id ?? "none";
1416
+ const group = byServer.get(primary);
1417
+ if (group) {
1418
+ group.files.push(filePath);
1419
+ if (servers.length > 1)
1420
+ group.multiServer = true;
1421
+ }
1422
+ else {
1423
+ byServer.set(primary, {
1424
+ files: [filePath],
1425
+ multiServer: servers.length > 1,
1426
+ });
1427
+ }
1428
+ }
1429
+ const groups = [...byServer.values()];
1430
+ // Opt-in project-wide pull: one `workspace/diagnostic` per server instead of
1431
+ // N per-file opens (#387 Item 2). Gated off by default — a cold server can
1432
+ // answer with an empty/partial report that reads as a false "all clean", and
1433
+ // the pull covers only the primary server (files with auxiliary scanners
1434
+ // would lose those). Enabled per group only when the server advertises it and
1435
+ // no file in the group has an auxiliary; any miss falls back to per-file.
1436
+ const workspacePullEnabled = process.env.PI_LENS_LSP_WORKSPACE_PULL === "1";
1437
+ // Start marker: without this a hang leaves no trace that the sweep even
1438
+ // began (the completion log below never fires). Per-file `lsp_touch_file`
1439
+ // phases + these heartbeats let a hang be bracketed to a file/time.
1440
+ logLatency({
1441
+ type: "phase",
1442
+ phase: "lsp_workspace_diagnostics_start",
1443
+ filePath: root,
1444
+ durationMs: 0,
1445
+ metadata: {
1446
+ fileCount: files.length,
1447
+ maxFiles,
1448
+ perFileMs,
1449
+ serverGroups: byServer.size,
1450
+ },
1451
+ });
1452
+ const processFile = async (filePath) => {
1453
+ try {
1454
+ const content = await nodeFs.promises.readFile(filePath, "utf-8");
1455
+ // onTimeout:"undefined" so a hung file yields no diagnostics and the
1456
+ // worker moves on; a real touchFile rejection still propagates to the
1457
+ // catch below and is recorded as an error.
1458
+ const diagnostics = await withDeadline(this.touchFile(filePath, content, {
1459
+ diagnostics: "document",
1460
+ collectDiagnostics: true,
1461
+ clientScope: "all",
1462
+ source: "lens_diagnostics_full",
1463
+ }), { ms: perFileMs, onTimeout: "undefined" });
1464
+ if (diagnostics === undefined)
1465
+ timedOutFiles += 1;
1466
+ results.push({
1467
+ filePath,
1468
+ diagnostics: diagnostics ?? [],
1469
+ count: diagnostics?.length ?? 0,
1470
+ });
1471
+ }
1472
+ catch (err) {
1473
+ results.push({
1474
+ filePath,
1475
+ diagnostics: [],
1476
+ count: 0,
1477
+ error: err instanceof Error ? err.message : String(err),
1478
+ });
1479
+ }
1480
+ completed += 1;
1481
+ // User-facing progress (streamed to the tool's onUpdate). Per-file so the
1482
+ // bar moves; the tool throttles the actual UI writes.
1483
+ options.onProgress?.(completed, files.length);
1484
+ // Time-based heartbeat (every ~10s): a hang shows the last heartbeat
1485
+ // then silence, so latency.log pinpoints how far it got.
1486
+ if (Date.now() - lastHeartbeat >= 10_000) {
1487
+ lastHeartbeat = Date.now();
1488
+ logLatency({
1489
+ type: "phase",
1490
+ phase: "lsp_workspace_diagnostics_progress",
1491
+ filePath: root,
1492
+ durationMs: Date.now() - startedAt,
1493
+ metadata: {
1494
+ completed,
1495
+ total: files.length,
1496
+ timedOutFiles,
1497
+ aborted: signal?.aborted ?? false,
1498
+ },
1499
+ });
1500
+ }
1501
+ };
1502
+ // One worker per server group (serial within a server), up to the
1503
+ // concurrency cap across distinct servers.
1504
+ let nextGroup = 0;
1505
+ const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
1506
+ await Promise.all(Array.from({ length: groupWorkers }, async () => {
1360
1507
  while (true) {
1361
- // Honor cancellation: a long sweep must stop when the agent/user
1362
- // aborts the turn rather than chew through the remaining files
1363
- // (#341). Already-collected results are returned as a partial.
1364
1508
  if (signal?.aborted)
1365
1509
  return;
1366
- const index = nextIndex;
1367
- nextIndex += 1;
1368
- if (index >= files.length)
1510
+ const gi = nextGroup;
1511
+ nextGroup += 1;
1512
+ if (gi >= groups.length)
1369
1513
  return;
1370
- const filePath = files[index];
1371
- try {
1372
- const content = await nodeFs.promises.readFile(filePath, "utf-8");
1373
- const diagnostics = await this.touchFile(filePath, content, {
1374
- diagnostics: "document",
1375
- collectDiagnostics: true,
1376
- clientScope: "all",
1377
- source: "lens_diagnostics_full",
1378
- });
1379
- results[index] = {
1380
- filePath,
1381
- diagnostics: diagnostics ?? [],
1382
- count: diagnostics?.length ?? 0,
1383
- };
1514
+ const group = groups[gi];
1515
+ // Fast path: one project-wide pull for the whole group (opt-in).
1516
+ if (workspacePullEnabled && !group.multiServer) {
1517
+ const pulled = await this.tryWorkspacePull(group.files, perFileMs);
1518
+ if (pulled) {
1519
+ for (const result of pulled)
1520
+ results.push(result);
1521
+ completed += group.files.length;
1522
+ options.onProgress?.(completed, files.length);
1523
+ continue;
1524
+ }
1384
1525
  }
1385
- catch (err) {
1386
- results[index] = {
1387
- filePath,
1388
- diagnostics: [],
1389
- count: 0,
1390
- error: err instanceof Error ? err.message : String(err),
1391
- };
1526
+ for (const filePath of group.files) {
1527
+ // Honor cancellation between files (#341); already-collected
1528
+ // results are returned as a partial.
1529
+ if (signal?.aborted)
1530
+ return;
1531
+ await processFile(filePath);
1392
1532
  }
1393
1533
  }
1394
1534
  }));
@@ -1400,13 +1540,48 @@ export class LSPService {
1400
1540
  metadata: {
1401
1541
  filesChecked: files.length,
1402
1542
  diagnosticCount: results.reduce((sum, result) => sum + (result?.count ?? 0), 0),
1403
- concurrency: WORKSPACE_DIAGNOSTICS_CONCURRENCY,
1543
+ serverGroups: byServer.size,
1544
+ concurrency: groupWorkers,
1404
1545
  maxFiles,
1546
+ timedOutFiles,
1405
1547
  aborted: signal?.aborted ?? false,
1406
1548
  },
1407
1549
  });
1408
1550
  return results.filter(Boolean);
1409
1551
  }
1552
+ /**
1553
+ * #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
1554
+ * instead of N per-file opens. Returns per-file results (files absent from the
1555
+ * report are reported clean), or `undefined` when the server doesn't advertise
1556
+ * workspace pull / the pull fails — the caller then falls back to per-file.
1557
+ */
1558
+ async tryWorkspacePull(groupFiles, perFileMs) {
1559
+ try {
1560
+ const first = groupFiles[0];
1561
+ if (!first)
1562
+ return undefined;
1563
+ const spawned = await this.getClientForFile(first, perFileMs);
1564
+ if (!spawned)
1565
+ return undefined;
1566
+ if (!spawned.client.getWorkspaceDiagnosticsSupport().workspaceDiagnostics) {
1567
+ return undefined;
1568
+ }
1569
+ const report = await spawned.client.requestWorkspaceDiagnostics(Math.max(perFileMs, workspacePullBudgetMs()));
1570
+ if (!report)
1571
+ return undefined;
1572
+ const byPath = new Map();
1573
+ for (const entry of report) {
1574
+ byPath.set(normalizeMapKey(entry.filePath), entry.diagnostics);
1575
+ }
1576
+ return groupFiles.map((filePath) => {
1577
+ const diagnostics = byPath.get(normalizeMapKey(filePath)) ?? [];
1578
+ return { filePath, diagnostics, count: diagnostics.length };
1579
+ });
1580
+ }
1581
+ catch {
1582
+ return undefined;
1583
+ }
1584
+ }
1410
1585
  /**
1411
1586
  * Get all diagnostics across all tracked files (for cascade checking)
1412
1587
  */
@@ -12,6 +12,7 @@
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { getProjectDataDir } from "../file-utils.js";
15
+ import { globalInstallArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
15
16
  import { safeSpawnAsync } from "../safe-spawn.js";
16
17
  function canUseInteractivePrompt() {
17
18
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
@@ -255,7 +256,7 @@ function isAutoInstallEnabled() {
255
256
  /**
256
257
  * Attempt to install a tool using the configured strategy.
257
258
  *
258
- * - "npm": npm install -g <packageName>
259
+ * - "npm": global install via the resolved manager (npm/pnpm/yarn/bun)
259
260
  * - "shell": run the static installCommand as argv (gem, dotnet, brew, etc.)
260
261
  * - "manual": can't auto-install — print the command and return false
261
262
  */
@@ -274,9 +275,18 @@ async function installTool(config) {
274
275
  if (installStrategy === "manual") {
275
276
  return false;
276
277
  }
277
- const invocation = installStrategy === "npm" && packageName
278
- ? ["npm", ["install", "-g", packageName]]
279
- : _parseStaticInstallCommandForTest(installCommand);
278
+ let invocation;
279
+ if (installStrategy === "npm" && packageName) {
280
+ // Resolve the machine's package manager (npm/pnpm/yarn/bun) rather than
281
+ // hardcoding npm — this global install is what makes an LSP server
282
+ // available on hosts without npm. The result lands in that manager's
283
+ // global bin dir, which `allAvailableGlobalBinDirs` already discovers.
284
+ const pm = await resolveNodePackageManager();
285
+ invocation = [pmBinary(pm), globalInstallArgs(pm, packageName)];
286
+ }
287
+ else {
288
+ invocation = _parseStaticInstallCommandForTest(installCommand);
289
+ }
280
290
  if (!invocation)
281
291
  return false;
282
292
  const [cmd, args] = invocation;
@@ -6,12 +6,13 @@
6
6
  * - Node.js scripts (npx/bun)
7
7
  * - Package manager execution
8
8
  */
9
- import { execFileSync, execSync, spawn as nodeSpawn, } from "node:child_process";
9
+ import { execFileSync, spawn as nodeSpawn, } from "node:child_process";
10
10
  import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { isTestMode } from "../env-utils.js";
14
14
  import { getGlobalPiLensDir } from "../file-utils.js";
15
+ import { allAvailableGlobalBinDirs } from "../package-manager.js";
15
16
  const isWindows = process.platform === "win32";
16
17
  /**
17
18
  * Whether a resolved command must be spawned through a shell on Windows.
@@ -161,27 +162,26 @@ function buildAugmentedPath(basePath) {
161
162
  return `${basePath}${path.delimiter}${toAppend.join(path.delimiter)}`;
162
163
  }
163
164
  /**
164
- * Find binary in npm global directory
165
- * Works around PATH caching issue after npm install -g
165
+ * Find a globally-installed binary across every installed package manager's
166
+ * global bin dir (npm/pnpm/yarn/bun). Works around PATH caching right after an
167
+ * `install -g` and covers tools installed via any manager.
166
168
  */
167
- function _findBinaryInNpmGlobal(command) {
169
+ async function _findBinaryInGlobalBin(command) {
168
170
  try {
169
- // Get npm global prefix
170
- const prefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
171
- // On Windows, binaries are directly in the prefix dir
172
- // On Unix, they're in prefix/bin
173
- const binDir = isWindows ? prefix : path.join(prefix, "bin");
174
- // Check for Windows variants
175
- const candidates = isWindows
176
- ? [
177
- path.join(binDir, `${command}.cmd`),
178
- path.join(binDir, `${command}.exe`),
179
- path.join(binDir, command),
180
- ]
181
- : [path.join(binDir, command)];
182
- for (const candidate of candidates) {
183
- if (fs.existsSync(candidate)) {
184
- return candidate;
171
+ for (const binDir of await allAvailableGlobalBinDirs()) {
172
+ // On Windows, binaries are directly in the dir (with .cmd/.exe shims);
173
+ // on Unix they're the bare command name.
174
+ const candidates = isWindows
175
+ ? [
176
+ path.join(binDir, `${command}.cmd`),
177
+ path.join(binDir, `${command}.exe`),
178
+ path.join(binDir, command),
179
+ ]
180
+ : [path.join(binDir, command)];
181
+ for (const candidate of candidates) {
182
+ if (fs.existsSync(candidate)) {
183
+ return candidate;
184
+ }
185
185
  }
186
186
  }
187
187
  return undefined;
@@ -457,10 +457,10 @@ export async function launchLSP(command, args = [], options = {}) {
457
457
  if (!path.isAbsolute(command) &&
458
458
  !command.includes(path.sep) &&
459
459
  !command.includes("/")) {
460
- const npmGlobalPath = _findBinaryInNpmGlobal(command);
461
- if (npmGlobalPath) {
462
- spawnCommand = npmGlobalPath;
463
- // Recompute needsShell for npm global path
460
+ const globalBinPath = await _findBinaryInGlobalBin(command);
461
+ if (globalBinPath) {
462
+ spawnCommand = globalBinPath;
463
+ // Recompute needsShell for the resolved global path
464
464
  needsShell = computeNeedsShell(spawnCommand);
465
465
  }
466
466
  }
@@ -495,11 +495,11 @@ export async function launchLSP(command, args = [], options = {}) {
495
495
  if (!path.isAbsolute(command) &&
496
496
  !command.includes(path.sep) &&
497
497
  !command.includes("/")) {
498
- const npmGlobalPath = _findBinaryInNpmGlobal(command);
499
- if (npmGlobalPath && npmGlobalPath !== spawnCommand) {
500
- // Recompute needsShell for npm global path
501
- const needsShellGlobal = computeNeedsShell(npmGlobalPath);
502
- proc = trySpawn(npmGlobalPath, args, cwd, env, needsShellGlobal);
498
+ const globalBinPath = await _findBinaryInGlobalBin(command);
499
+ if (globalBinPath && globalBinPath !== spawnCommand) {
500
+ // Recompute needsShell for the resolved global path
501
+ const needsShellGlobal = computeNeedsShell(globalBinPath);
502
+ proc = trySpawn(globalBinPath, args, cwd, env, needsShellGlobal);
503
503
  }
504
504
  else {
505
505
  throw err;
@@ -597,77 +597,6 @@ export async function launchLSP(command, args = [], options = {}) {
597
597
  pid: proc.pid ?? 0,
598
598
  };
599
599
  }
600
- /**
601
- * Spawn via package manager (npx/bun)
602
- */
603
- export async function launchViaPackageManager(packageName, args = [], options = {}) {
604
- // Prefer bun if available, fall back to npx (use .cmd on Windows)
605
- const isWin = process.platform === "win32";
606
- if (process.env.BUN_INSTALL) {
607
- return launchLSP(isWin ? "bun.exe" : "bun", ["x", packageName, ...args], options);
608
- }
609
- // shell:true justified: npx on Windows is npx.cmd — requires shell to execute.
610
- if (isWin) {
611
- const argsStr = args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ");
612
- // --no prevents silent download of uncached packages
613
- const shellCommand = `npx --no ${packageName}${argsStr ? ` ${argsStr}` : ""}`;
614
- const cwd = String(options.cwd ?? process.cwd());
615
- const mergedEnv = { ...process.env, ...options.env };
616
- const augmentedPath = buildAugmentedPath(resolvePathValue(mergedEnv));
617
- const env = {
618
- ...mergedEnv,
619
- PATH: augmentedPath,
620
- ...(isWindows ? { Path: augmentedPath } : {}),
621
- };
622
- const proc = nodeSpawn(shellCommand, [], {
623
- cwd,
624
- env,
625
- stdio: ["pipe", "pipe", "pipe"],
626
- detached: false,
627
- windowsHide: true,
628
- shell: true,
629
- });
630
- if (!proc.stdin || !proc.stdout || !proc.stderr) {
631
- throw new Error(`Failed to spawn package manager for: ${packageName}`);
632
- }
633
- // Check for immediate spawn failure on Windows
634
- await new Promise((resolve, reject) => {
635
- let settled = false;
636
- proc.on("error", (err) => {
637
- if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) {
638
- settled = true;
639
- reject(new Error(`Package manager not found for: ${packageName}. ` +
640
- `Install Node.js or check your PATH.`));
641
- }
642
- });
643
- proc.on("exit", (code) => {
644
- if (!settled && code !== null) {
645
- settled = true;
646
- reject(new Error(`Package manager exited immediately for: ${packageName} (code: ${code})`));
647
- }
648
- });
649
- setTimeout(() => {
650
- if (!settled) {
651
- settled = true;
652
- resolve();
653
- }
654
- }, 50);
655
- });
656
- // Attach permanent error handler
657
- _attachErrorHandler(proc, packageName);
658
- unrefLspProcessHandles(proc);
659
- return {
660
- process: proc,
661
- stdin: proc.stdin,
662
- stdout: proc.stdout,
663
- stderr: proc.stderr,
664
- pid: proc.pid ?? 0,
665
- };
666
- }
667
- // --no prevents silent download of uncached packages; user must have
668
- // already installed the LSP server via the interactive-install flow.
669
- return launchLSP("npx", ["--no", packageName, ...args], options);
670
- }
671
600
  /**
672
601
  * Spawn via Node.js directly
673
602
  */
@@ -7,6 +7,7 @@
7
7
  * `pilens_analyze mode=fresh` measures the just-built code first-hand.
8
8
  */
9
9
  import { spawn } from "node:child_process";
10
+ import { pmBinary, resolveNodePackageManager, runScriptArgs, } from "../package-manager.js";
10
11
  import { safeSpawnAsync } from "../safe-spawn.js";
11
12
  /**
12
13
  * Which npm script rebuilds the layout the server is running from. A server at
@@ -71,13 +72,14 @@ export function analyzeFileFresh(workerPath, file, cwd, options = {}, timeoutMs
71
72
  });
72
73
  }
73
74
  /**
74
- * Run `npm run <script>` in the pi-lens repo. Uses `safeSpawnAsync` (Windows
75
- * `.cmd`/shell-aware) since the command is plain `npm`. `ignoreAmbientSignal`
76
- * a rebuild must run to completion.
75
+ * Run `<pm> run <script>` in the pi-lens repo, where `<pm>` is resolved from the
76
+ * repo's lockfile / installed managers. Uses `safeSpawnAsync` (Windows
77
+ * `.cmd`/shell-aware). `ignoreAmbientSignal` — a rebuild must run to completion.
77
78
  */
78
79
  export async function runRebuild(repoRoot, script, timeoutMs = 300_000) {
79
80
  const start = Date.now();
80
- const res = await safeSpawnAsync("npm", ["run", script], {
81
+ const packageManager = await resolveNodePackageManager(repoRoot);
82
+ const res = await safeSpawnAsync(pmBinary(packageManager), runScriptArgs(script), {
81
83
  cwd: repoRoot,
82
84
  timeout: timeoutMs,
83
85
  ignoreAmbientSignal: true,
@@ -86,6 +88,7 @@ export async function runRebuild(repoRoot, script, timeoutMs = 300_000) {
86
88
  return {
87
89
  ok: !res.error && res.status === 0,
88
90
  script,
91
+ packageManager,
89
92
  durationMs: Date.now() - start,
90
93
  output: output.slice(-2000),
91
94
  };