pi-lens 3.8.63 → 3.8.64
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.
- package/CHANGELOG.md +22 -0
- package/README.md +12 -9
- package/dist/clients/deadline-utils.js +23 -0
- package/dist/clients/installer/index.js +18 -27
- package/dist/clients/log-cleanup.js +36 -13
- package/dist/clients/lsp/client.js +44 -0
- package/dist/clients/lsp/index.js +208 -33
- package/dist/clients/lsp/interactive-install.js +14 -4
- package/dist/clients/lsp/launch.js +29 -100
- package/dist/clients/mcp/review.js +7 -4
- package/dist/clients/package-manager.js +223 -0
- package/dist/clients/pipeline.js +55 -2
- package/dist/clients/project-metadata.js +3 -23
- package/dist/clients/safe-spawn.js +9 -0
- package/dist/clients/tree-sitter-client.js +72 -14
- package/dist/index.js +16 -0
- package/dist/mcp/server.js +4 -2
- package/dist/tools/ast-grep-search.js +10 -6
- package/dist/tools/lens-diagnostics.js +43 -10
- package/dist/tools/lsp-diagnostics.js +23 -4
- package/dist/tools/scan-progress.js +38 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-bash.wasm.json +5 -0
- package/grammars/tree-sitter-css.wasm +0 -0
- package/grammars/tree-sitter-css.wasm.json +5 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-go.wasm.json +5 -0
- package/grammars/tree-sitter-html.wasm +0 -0
- package/grammars/tree-sitter-html.wasm.json +5 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-java.wasm.json +5 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm.json +5 -0
- package/grammars/tree-sitter-json.wasm +0 -0
- package/grammars/tree-sitter-json.wasm.json +5 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-python.wasm.json +5 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm.json +5 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm.json +5 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm.json +5 -0
- package/grammars/tree-sitter-yaml.wasm +0 -0
- package/grammars/tree-sitter-yaml.wasm.json +5 -0
- package/package.json +7 -5
- package/scripts/analyze-pi-lens-logs.mjs +101 -3
- package/scripts/download-grammars.js +171 -30
- 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
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
|
1367
|
-
|
|
1368
|
-
if (
|
|
1510
|
+
const gi = nextGroup;
|
|
1511
|
+
nextGroup += 1;
|
|
1512
|
+
if (gi >= groups.length)
|
|
1369
1513
|
return;
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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,
|
|
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
|
|
165
|
-
* Works around PATH caching
|
|
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
|
|
169
|
+
async function _findBinaryInGlobalBin(command) {
|
|
168
170
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
path.join(binDir,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
461
|
-
if (
|
|
462
|
-
spawnCommand =
|
|
463
|
-
// Recompute needsShell for
|
|
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
|
|
499
|
-
if (
|
|
500
|
-
// Recompute needsShell for
|
|
501
|
-
const needsShellGlobal = computeNeedsShell(
|
|
502
|
-
proc = trySpawn(
|
|
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
|
|
75
|
-
*
|
|
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
|
|
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
|
};
|