pi-lens 3.8.18 → 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.
- package/CHANGELOG.md +31 -0
- package/README.md +26 -17
- package/clients/dispatch/dispatcher.ts +53 -1
- package/clients/dispatch/integration.ts +37 -26
- package/clients/dispatch/plan.ts +26 -15
- package/clients/dispatch/runners/lsp.ts +6 -1
- package/clients/dispatch/runners/pyright.ts +4 -6
- package/clients/dispatch/runners/ruff.ts +52 -7
- package/clients/dispatch/runners/sqlfluff.ts +48 -1
- package/clients/dispatch/runners/yamllint.ts +50 -0
- package/clients/file-utils.ts +13 -2
- package/clients/formatters.ts +8 -4
- package/clients/installer/index.ts +371 -49
- package/clients/language-policy.ts +154 -0
- package/clients/language-profile.ts +167 -0
- package/clients/lsp/index.ts +81 -11
- package/clients/lsp/interactive-install.ts +35 -16
- package/clients/lsp/server.ts +357 -267
- package/clients/pipeline.ts +71 -40
- package/clients/runtime-context.ts +26 -0
- package/clients/runtime-coordinator.ts +30 -2
- package/clients/runtime-session.ts +293 -103
- package/clients/runtime-tool-result.ts +8 -10
- package/clients/runtime-turn.ts +21 -4
- package/clients/todo-scanner.ts +6 -1
- package/clients/type-coverage-client.ts +1 -1
- package/commands/booboo.ts +3 -1
- package/index.ts +15 -3
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { FileKind } from "./file-kinds.js";
|
|
2
|
+
import type { ProjectLanguageProfile } from "./language-profile.js";
|
|
3
|
+
import type { RunnerGroup } from "./dispatch/types.js";
|
|
4
|
+
|
|
5
|
+
interface StartupPolicy {
|
|
6
|
+
defaults?: string[];
|
|
7
|
+
heavyScansRequireConfig?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface LanguagePolicy {
|
|
11
|
+
lspCapable: boolean;
|
|
12
|
+
startup?: StartupPolicy;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const LANGUAGE_POLICY: Record<FileKind, LanguagePolicy> = {
|
|
16
|
+
jsts: {
|
|
17
|
+
lspCapable: true,
|
|
18
|
+
startup: {
|
|
19
|
+
defaults: ["typescript-language-server"],
|
|
20
|
+
heavyScansRequireConfig: true,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
python: {
|
|
24
|
+
lspCapable: true,
|
|
25
|
+
startup: {
|
|
26
|
+
defaults: ["pyright", "ruff"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
go: { lspCapable: true },
|
|
30
|
+
rust: { lspCapable: true },
|
|
31
|
+
cxx: { lspCapable: true },
|
|
32
|
+
cmake: { lspCapable: true },
|
|
33
|
+
shell: { lspCapable: true },
|
|
34
|
+
json: { lspCapable: true },
|
|
35
|
+
markdown: { lspCapable: true },
|
|
36
|
+
css: { lspCapable: true },
|
|
37
|
+
yaml: {
|
|
38
|
+
lspCapable: true,
|
|
39
|
+
startup: {
|
|
40
|
+
defaults: ["yamllint"],
|
|
41
|
+
heavyScansRequireConfig: true,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
sql: {
|
|
45
|
+
lspCapable: false,
|
|
46
|
+
startup: {
|
|
47
|
+
defaults: ["sqlfluff"],
|
|
48
|
+
heavyScansRequireConfig: true,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
ruby: { lspCapable: true },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
|
|
55
|
+
jsts: { mode: "fallback", runnerIds: ["lsp", "ts-lsp"], filterKinds: ["jsts"] },
|
|
56
|
+
python: {
|
|
57
|
+
mode: "fallback",
|
|
58
|
+
runnerIds: ["lsp", "pyright"],
|
|
59
|
+
filterKinds: ["python"],
|
|
60
|
+
},
|
|
61
|
+
go: { mode: "fallback", runnerIds: ["lsp", "go-vet"], filterKinds: ["go"] },
|
|
62
|
+
rust: {
|
|
63
|
+
mode: "fallback",
|
|
64
|
+
runnerIds: ["lsp", "rust-clippy"],
|
|
65
|
+
filterKinds: ["rust"],
|
|
66
|
+
},
|
|
67
|
+
ruby: {
|
|
68
|
+
mode: "fallback",
|
|
69
|
+
runnerIds: ["lsp", "rubocop"],
|
|
70
|
+
filterKinds: ["ruby"],
|
|
71
|
+
},
|
|
72
|
+
cxx: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["cxx"] },
|
|
73
|
+
cmake: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["cmake"] },
|
|
74
|
+
shell: {
|
|
75
|
+
mode: "fallback",
|
|
76
|
+
runnerIds: ["lsp", "shellcheck"],
|
|
77
|
+
filterKinds: ["shell"],
|
|
78
|
+
},
|
|
79
|
+
json: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["json"] },
|
|
80
|
+
markdown: {
|
|
81
|
+
mode: "fallback",
|
|
82
|
+
runnerIds: ["lsp", "spellcheck"],
|
|
83
|
+
filterKinds: ["markdown"],
|
|
84
|
+
},
|
|
85
|
+
css: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["css"] },
|
|
86
|
+
yaml: {
|
|
87
|
+
mode: "fallback",
|
|
88
|
+
runnerIds: ["lsp", "yamllint"],
|
|
89
|
+
filterKinds: ["yaml"],
|
|
90
|
+
},
|
|
91
|
+
sql: {
|
|
92
|
+
mode: "fallback",
|
|
93
|
+
runnerIds: ["sqlfluff"],
|
|
94
|
+
filterKinds: ["sql"],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function getLspCapableKinds(): FileKind[] {
|
|
99
|
+
return (Object.keys(LANGUAGE_POLICY) as FileKind[]).filter(
|
|
100
|
+
(kind) => LANGUAGE_POLICY[kind].lspCapable,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getPrimaryDispatchGroup(
|
|
105
|
+
kind: FileKind,
|
|
106
|
+
lspEnabled: boolean,
|
|
107
|
+
): RunnerGroup | undefined {
|
|
108
|
+
const base = PRIMARY_DISPATCH_GROUPS[kind];
|
|
109
|
+
if (!base) return undefined;
|
|
110
|
+
|
|
111
|
+
const ids = lspEnabled
|
|
112
|
+
? [...base.runnerIds]
|
|
113
|
+
: base.runnerIds.filter((id) => id !== "lsp" && id !== "ts-lsp");
|
|
114
|
+
if (ids.length === 0) return undefined;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
mode: base.mode,
|
|
118
|
+
runnerIds: ids,
|
|
119
|
+
filterKinds: base.filterKinds,
|
|
120
|
+
semantic: base.semantic,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getStartupDefaultsForProfile(
|
|
125
|
+
profile: ProjectLanguageProfile,
|
|
126
|
+
): string[] {
|
|
127
|
+
const tools = new Set<string>();
|
|
128
|
+
|
|
129
|
+
for (const kind of Object.keys(LANGUAGE_POLICY) as FileKind[]) {
|
|
130
|
+
if (!profile.present[kind]) continue;
|
|
131
|
+
const defaults = LANGUAGE_POLICY[kind].startup?.defaults ?? [];
|
|
132
|
+
for (const tool of defaults) {
|
|
133
|
+
if (
|
|
134
|
+
LANGUAGE_POLICY[kind].startup?.heavyScansRequireConfig &&
|
|
135
|
+
!profile.configured[kind]
|
|
136
|
+
) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
tools.add(tool);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...tools];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function canRunStartupHeavyScans(
|
|
147
|
+
profile: ProjectLanguageProfile,
|
|
148
|
+
kind: FileKind,
|
|
149
|
+
): boolean {
|
|
150
|
+
if (!profile.present[kind]) return false;
|
|
151
|
+
const needsConfig = LANGUAGE_POLICY[kind].startup?.heavyScansRequireConfig;
|
|
152
|
+
if (!needsConfig) return true;
|
|
153
|
+
return !!profile.configured[kind];
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|
package/clients/lsp/index.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
272
|
+
// Auto-decline after timeout
|
|
259
273
|
const timeout = setTimeout(() => {
|
|
260
274
|
cleanup();
|
|
261
|
-
resolve("
|
|
275
|
+
resolve("no");
|
|
262
276
|
}, timeoutMs);
|
|
263
277
|
|
|
264
278
|
// Handle stdin closing
|
|
265
279
|
process.stdin.on("end", () => {
|
|
266
280
|
cleanup();
|
|
267
|
-
resolve("
|
|
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
|
-
:
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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-
|
|
416
|
+
console.error(`\n Install now? [Y/n] (auto-declines in 10s)`);
|
|
398
417
|
|
|
399
|
-
const answer = await promptUser(
|
|
418
|
+
const answer = await promptUser(10000);
|
|
400
419
|
await saveChoice(cwd, config.toolId, answer);
|
|
401
420
|
|
|
402
421
|
if (answer === "yes") {
|