pi-lens 3.8.19 → 3.8.21

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.
@@ -0,0 +1,167 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { detectFileKind, type FileKind } from "./file-kinds.js";
4
+ import { getStartupDefaultsForProfile } from "./language-policy.js";
5
+ import { getSourceFiles } from "./scan-utils.js";
6
+
7
+ export const SUPPORTED_FILE_KINDS: readonly FileKind[] = [
8
+ "jsts",
9
+ "python",
10
+ "go",
11
+ "rust",
12
+ "cxx",
13
+ "cmake",
14
+ "shell",
15
+ "json",
16
+ "markdown",
17
+ "css",
18
+ "yaml",
19
+ "sql",
20
+ "ruby",
21
+ ];
22
+
23
+ const PROJECT_MARKERS_BY_KIND: Partial<Record<FileKind, readonly string[]>> = {
24
+ jsts: ["package.json", "tsconfig.json", "jsconfig.json"],
25
+ python: ["pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"],
26
+ go: ["go.mod"],
27
+ rust: ["Cargo.toml"],
28
+ ruby: ["Gemfile", "Rakefile"],
29
+ yaml: [".yamllint", "yamllint.yaml", "yamllint.yml", "pyproject.toml"],
30
+ sql: [".sqlfluff", "pyproject.toml"],
31
+ };
32
+
33
+ const ROOT_MARKERS_BY_KIND: Partial<Record<FileKind, readonly string[]>> = {
34
+ jsts: ["package.json", "tsconfig.json", "jsconfig.json", "pnpm-workspace.yaml"],
35
+ python: ["pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile"],
36
+ go: ["go.work", "go.mod", "go.sum"],
37
+ rust: ["Cargo.toml"],
38
+ ruby: ["Gemfile", "Rakefile"],
39
+ yaml: [".yamllint", ".yamllint.yml", ".yamllint.yaml"],
40
+ sql: [".sqlfluff", "pyproject.toml", "setup.cfg", "tox.ini"],
41
+ };
42
+
43
+ export interface ProjectLanguageProfile {
44
+ present: Record<FileKind, boolean>;
45
+ configured: Partial<Record<FileKind, boolean>>;
46
+ counts: Partial<Record<FileKind, number>>;
47
+ detectedKinds: FileKind[];
48
+ }
49
+
50
+ function nearestRoot(start: string, markers: readonly string[]): string | undefined {
51
+ let dir = path.resolve(start);
52
+ const { root } = path.parse(dir);
53
+
54
+ while (true) {
55
+ for (const marker of markers) {
56
+ if (fs.existsSync(path.join(dir, marker))) {
57
+ return dir;
58
+ }
59
+ }
60
+ if (dir === root) break;
61
+ const parent = path.dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+
66
+ return undefined;
67
+ }
68
+
69
+ export function detectProjectLanguageProfile(
70
+ projectRoot: string,
71
+ sourceFiles?: string[],
72
+ ): ProjectLanguageProfile {
73
+ const present = Object.fromEntries(
74
+ SUPPORTED_FILE_KINDS.map((kind) => [kind, false]),
75
+ ) as Record<FileKind, boolean>;
76
+ const counts: Partial<Record<FileKind, number>> = {};
77
+ const configured: Partial<Record<FileKind, boolean>> = {};
78
+
79
+ for (const [kind, markers] of Object.entries(PROJECT_MARKERS_BY_KIND)) {
80
+ if (!markers) continue;
81
+ for (const marker of markers) {
82
+ if (fs.existsSync(path.join(projectRoot, marker))) {
83
+ present[kind as FileKind] = true;
84
+ configured[kind as FileKind] = true;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+
90
+ let files = sourceFiles;
91
+ if (!files) {
92
+ try {
93
+ files = getSourceFiles(projectRoot, true);
94
+ } catch {
95
+ files = [];
96
+ }
97
+ }
98
+
99
+ for (const file of files) {
100
+ const kind = detectFileKind(file);
101
+ if (!kind) continue;
102
+ present[kind] = true;
103
+ counts[kind] = (counts[kind] ?? 0) + 1;
104
+ }
105
+
106
+ const detectedKinds = SUPPORTED_FILE_KINDS.filter((kind) => present[kind]);
107
+
108
+ return {
109
+ present,
110
+ configured,
111
+ counts,
112
+ detectedKinds,
113
+ };
114
+ }
115
+
116
+ export function hasLanguage(
117
+ profile: ProjectLanguageProfile,
118
+ kind: FileKind,
119
+ ): boolean {
120
+ return !!profile.present[kind];
121
+ }
122
+
123
+ export function hasAnyLanguage(
124
+ profile: ProjectLanguageProfile,
125
+ kinds: readonly FileKind[],
126
+ ): boolean {
127
+ return kinds.some((kind) => hasLanguage(profile, kind));
128
+ }
129
+
130
+ export function isLanguageConfigured(
131
+ profile: ProjectLanguageProfile,
132
+ kind: FileKind,
133
+ ): boolean {
134
+ return !!profile.configured[kind];
135
+ }
136
+
137
+ export function getDefaultStartupTools(
138
+ profile: ProjectLanguageProfile,
139
+ ): string[] {
140
+ return getStartupDefaultsForProfile(profile);
141
+ }
142
+
143
+ export function resolveLanguageRootForFile(
144
+ filePath: string,
145
+ workspaceRoot: string,
146
+ ): string {
147
+ const absoluteFilePath = path.resolve(filePath);
148
+ const startDir = path.dirname(absoluteFilePath);
149
+ const kind = detectFileKind(absoluteFilePath);
150
+ if (!kind) return path.resolve(workspaceRoot);
151
+
152
+ const markers = ROOT_MARKERS_BY_KIND[kind];
153
+ if (!markers || markers.length === 0) {
154
+ return path.resolve(workspaceRoot);
155
+ }
156
+
157
+ const found = nearestRoot(startDir, markers);
158
+ if (!found) return path.resolve(workspaceRoot);
159
+
160
+ const workspace = path.resolve(workspaceRoot);
161
+ const relative = path.relative(workspace, found);
162
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
163
+ return workspace;
164
+ }
165
+
166
+ return found;
167
+ }
@@ -8,22 +8,41 @@
8
8
  * - Resource cleanup
9
9
  */
10
10
 
11
+ import fs from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
11
14
  import type { LSPClientInfo } from "./client.js";
12
15
  import { createLSPClient } from "./client.js";
13
16
  import { getServersForFileWithConfig } from "./config.js";
14
17
  import { getLanguageId } from "./language.js";
15
18
  import type { LSPServerInfo } from "./server.js";
16
- import { uriToPath } from "../path-utils.js";
19
+ import { normalizeMapKey, uriToPath } from "../path-utils.js";
20
+ import { detectFileKind } from "../file-kinds.js";
21
+ import { detectProjectLanguageProfile } from "../language-profile.js";
17
22
 
18
23
  // --- Types ---
19
24
 
20
25
  export interface LSPState {
21
26
  clients: Map<string, LSPClientInfo>; // key: "serverId:root"
22
27
  servers: Map<string, LSPServerInfo>;
23
- broken: Set<string>; // servers that failed to initialize
28
+ broken: Map<string, number>; // servers that failed to initialize with retry-at timestamp
24
29
  inFlight: Map<string, Promise<SpawnedServer | undefined>>; // prevent duplicate spawns
25
30
  }
26
31
 
32
+ const BROKEN_RETRY_COOLDOWN_MS = 15_000;
33
+ const SESSIONSTART_LOG_DIR = path.join(os.homedir(), ".pi-lens");
34
+ const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
35
+
36
+ function logSessionStart(msg: string): void {
37
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
38
+ void fs
39
+ .mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
40
+ .then(() => fs.appendFile(SESSIONSTART_LOG, line))
41
+ .catch(() => {
42
+ // best-effort logging
43
+ });
44
+ }
45
+
27
46
  export interface SpawnedServer {
28
47
  client: LSPClientInfo;
29
48
  info: LSPServerInfo;
@@ -33,12 +52,13 @@ export interface SpawnedServer {
33
52
 
34
53
  export class LSPService {
35
54
  private state: LSPState;
55
+ private languagePolicyCache = new Map<string, { allowInstall: boolean; expiresAt: number }>();
36
56
 
37
57
  constructor() {
38
58
  this.state = {
39
59
  clients: new Map(),
40
60
  servers: new Map(),
41
- broken: new Set(),
61
+ broken: new Map(),
42
62
  inFlight: new Map(),
43
63
  };
44
64
  }
@@ -55,10 +75,9 @@ export class LSPService {
55
75
  for (const server of servers) {
56
76
  const root = await server.root(filePath);
57
77
  if (!root) continue;
78
+ const allowInstall = this.shouldAllowInstall(filePath, root);
58
79
 
59
- // Normalize root path for consistent cache key on Windows
60
- const normalizedRoot =
61
- process.platform === "win32" ? root.toLowerCase() : root;
80
+ const normalizedRoot = normalizeMapKey(root);
62
81
  const key = `${server.id}:${normalizedRoot}`;
63
82
 
64
83
  // Check cache first (fast path)
@@ -78,9 +97,13 @@ export class LSPService {
78
97
  }
79
98
 
80
99
  // Check if broken
81
- if (this.state.broken.has(key)) {
100
+ const brokenUntil = this.state.broken.get(key);
101
+ if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
82
102
  continue;
83
103
  }
104
+ if (typeof brokenUntil === "number" && brokenUntil <= Date.now()) {
105
+ this.state.broken.delete(key);
106
+ }
84
107
 
85
108
  // Check if there's already an in-flight spawn for this key
86
109
  const inFlight = this.state.inFlight.get(key);
@@ -92,7 +115,7 @@ export class LSPService {
92
115
  }
93
116
 
94
117
  // Create the spawn promise and store it
95
- const spawnPromise = this.spawnClient(server, root, key);
118
+ const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
96
119
  this.state.inFlight.set(key, spawnPromise);
97
120
 
98
121
  try {
@@ -107,6 +130,38 @@ export class LSPService {
107
130
  return undefined;
108
131
  }
109
132
 
133
+ private shouldAllowInstall(filePath: string, root: string): boolean {
134
+ if (process.env.PI_LENS_AUTO_INSTALL === "1") return true;
135
+
136
+ const kind = detectFileKind(filePath);
137
+ if (!kind) return true;
138
+
139
+ const cacheKey = `${normalizeMapKey(root)}:${kind}`;
140
+ const now = Date.now();
141
+ const cached = this.languagePolicyCache.get(cacheKey);
142
+ if (cached && cached.expiresAt > now) {
143
+ return cached.allowInstall;
144
+ }
145
+
146
+ let allowInstall = true;
147
+ try {
148
+ const profile = detectProjectLanguageProfile(root);
149
+ const count = profile.counts[kind] ?? 0;
150
+ const configured = !!profile.configured[kind];
151
+ const singleLanguageProject = profile.detectedKinds.length <= 1;
152
+ allowInstall = configured || count > 1 || singleLanguageProject;
153
+ } catch {
154
+ allowInstall = true;
155
+ }
156
+
157
+ this.languagePolicyCache.set(cacheKey, {
158
+ allowInstall,
159
+ expiresAt: now + 10_000,
160
+ });
161
+
162
+ return allowInstall;
163
+ }
164
+
110
165
  /**
111
166
  * Internal: spawn a client for a server/root combination
112
167
  */
@@ -114,11 +169,20 @@ export class LSPService {
114
169
  server: LSPServerInfo,
115
170
  root: string,
116
171
  key: string,
172
+ filePath: string,
173
+ allowInstall: boolean,
117
174
  ): Promise<SpawnedServer | undefined> {
175
+ const startedAt = Date.now();
176
+ logSessionStart(
177
+ `lsp spawn ${server.id}: start root=${root} policy=${server.installPolicy ?? "unknown"} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`,
178
+ );
118
179
  try {
119
- const spawned = await server.spawn(root);
180
+ const spawned = await server.spawn(root, { allowInstall });
120
181
  if (!spawned) {
121
- this.state.broken.add(key);
182
+ logSessionStart(
183
+ `lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`,
184
+ );
185
+ this.state.broken.set(key, Date.now() + BROKEN_RETRY_COOLDOWN_MS);
122
186
  return undefined;
123
187
  }
124
188
 
@@ -130,8 +194,14 @@ export class LSPService {
130
194
  });
131
195
 
132
196
  this.state.clients.set(key, client);
197
+ logSessionStart(
198
+ `lsp spawn ${server.id}: success source=${spawned.source ?? server.installPolicy ?? "unknown"} (${Date.now() - startedAt}ms)`,
199
+ );
133
200
  return { client, info: server };
134
201
  } catch (err) {
202
+ logSessionStart(
203
+ `lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
204
+ );
135
205
  const errorMsg = err instanceof Error ? err.message : String(err);
136
206
  if (errorMsg.includes("Timeout")) {
137
207
  console.error(
@@ -148,7 +218,7 @@ export class LSPService {
148
218
  } else {
149
219
  console.error(`[lsp] Failed to spawn ${server.id}:`, err);
150
220
  }
151
- this.state.broken.add(key);
221
+ this.state.broken.set(key, Date.now() + BROKEN_RETRY_COOLDOWN_MS);
152
222
  return undefined;
153
223
  }
154
224
  }
@@ -14,6 +14,20 @@ import { spawn } from "node:child_process";
14
14
  import * as fs from "node:fs/promises";
15
15
  import * as path from "node:path";
16
16
 
17
+ function canUseInteractivePrompt(): boolean {
18
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
19
+ }
20
+
21
+ async function isToolOnPath(toolId: string): Promise<boolean> {
22
+ const locator = process.platform === "win32" ? "where" : "which";
23
+
24
+ return new Promise((resolve) => {
25
+ const proc = spawn(locator, [toolId], { stdio: "ignore", shell: false });
26
+ proc.on("close", (code) => resolve(code === 0));
27
+ proc.on("error", () => resolve(false));
28
+ });
29
+ }
30
+
17
31
  /**
18
32
  * Install strategy:
19
33
  * - "npm": npm install -g <packageName> (managed by pi-lens, goes into .pi-lens/tools)
@@ -255,16 +269,16 @@ function promptUser(timeoutMs: number): Promise<"yes" | "no"> {
255
269
 
256
270
  process.stdin.on("data", onData);
257
271
 
258
- // Auto-accept after timeout
272
+ // Auto-decline after timeout
259
273
  const timeout = setTimeout(() => {
260
274
  cleanup();
261
- resolve("yes");
275
+ resolve("no");
262
276
  }, timeoutMs);
263
277
 
264
278
  // Handle stdin closing
265
279
  process.stdin.on("end", () => {
266
280
  cleanup();
267
- resolve("yes");
281
+ resolve("no");
268
282
  });
269
283
 
270
284
  function cleanup() {
@@ -310,7 +324,9 @@ async function installTool(config: LanguageConfig): Promise<boolean> {
310
324
  const [cmd, ...args] =
311
325
  installStrategy === "npm" && packageName
312
326
  ? ["npm", "install", "-g", packageName]
313
- : ["sh", "-c", installCommand];
327
+ : process.platform === "win32"
328
+ ? ["powershell", "-NoProfile", "-Command", installCommand]
329
+ : ["sh", "-c", installCommand];
314
330
 
315
331
  return new Promise((resolve) => {
316
332
  const proc = spawn(cmd, args, { stdio: "inherit", shell: false });
@@ -360,17 +376,13 @@ export async function promptForInstall(
360
376
  const thirtyDays = 30 * 24 * 60 * 60 * 1000;
361
377
  if (Date.now() - cached.timestamp < thirtyDays) {
362
378
  if (cached.choice === "yes" || cached.choice === "auto") {
363
- // Verify binary actually exists before trusting cache
364
- try {
365
- const { execSync } = await import("node:child_process");
366
- execSync(`which ${config.toolId}`, { stdio: "ignore" });
367
- return true; // Binary exists, cache is valid
368
- } catch {
369
- // Binary not found, invalidate cache and continue to install
370
- console.error(
371
- `[pi-lens] Cached ${config.toolId} not found, re-installing...`,
372
- );
379
+ const toolAvailable = await isToolOnPath(config.toolId);
380
+ if (toolAvailable) {
381
+ return true;
373
382
  }
383
+ console.error(
384
+ `[pi-lens] Cached ${config.toolId} not found, re-installing...`,
385
+ );
374
386
  } else {
375
387
  return false; // User previously declined
376
388
  }
@@ -386,6 +398,13 @@ export async function promptForInstall(
386
398
  return await installTool(config);
387
399
  }
388
400
 
401
+ if (!canUseInteractivePrompt()) {
402
+ console.error(
403
+ `[pi-lens] ${config.toolName} missing and interactive prompt unavailable; skipping install. Use --auto-install to allow automatic setup.`,
404
+ );
405
+ return false;
406
+ }
407
+
389
408
  // Show interactive prompt
390
409
  console.error(`\n⚠️ ${config.toolName} not found`);
391
410
  console.error(` Install: ${config.installCommand}`);
@@ -394,9 +413,9 @@ export async function promptForInstall(
394
413
  await saveChoice(cwd, config.toolId, "no");
395
414
  return false;
396
415
  }
397
- console.error(`\n Install now? [Y/n] (auto-accepts in 30s)`);
416
+ console.error(`\n Install now? [Y/n] (auto-declines in 10s)`);
398
417
 
399
- const answer = await promptUser(30000);
418
+ const answer = await promptUser(10000);
400
419
  await saveChoice(cwd, config.toolId, answer);
401
420
 
402
421
  if (answer === "yes") {