pi-lsp-adapter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/package.json +79 -0
- package/src/commands/registerCommands.ts +228 -0
- package/src/commands/status.ts +79 -0
- package/src/config/loadConfig.ts +377 -0
- package/src/config/paths.ts +58 -0
- package/src/config/trust.ts +85 -0
- package/src/detect/filetypes.ts +86 -0
- package/src/detect/root.ts +32 -0
- package/src/index.ts +132 -0
- package/src/install/installers.ts +513 -0
- package/src/install/lockfile.ts +159 -0
- package/src/install/manager.ts +177 -0
- package/src/install/version.ts +40 -0
- package/src/lsp/client.ts +465 -0
- package/src/lsp/processRegistry.ts +339 -0
- package/src/lsp/runtimeManager.ts +552 -0
- package/src/registry/builtin.ts +171 -0
- package/src/registry/schema.ts +185 -0
- package/src/resolve/languages.ts +96 -0
- package/src/resolve/resolveServer.ts +315 -0
- package/src/state.ts +20 -0
- package/src/statusLine.ts +21 -0
- package/src/tools/lspFormat.ts +594 -0
- package/src/tools/registerLspTools.ts +241 -0
- package/src/tools/registerLspWarmup.ts +25 -0
- package/src/tools/resultCache.ts +162 -0
- package/src/ui/lspPanel.ts +123 -0
- package/src/util/deepMerge.ts +38 -0
- package/src/util/errors.ts +22 -0
- package/src/util/hash.ts +10 -0
- package/src/util/helpers.ts +41 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readLockfile } from "../install/lockfile.js";
|
|
3
|
+
import { parseServerVersionSpec } from "../install/version.js";
|
|
4
|
+
import type { LspExtensionState } from "../state.js";
|
|
5
|
+
import { setLspStatusLine } from "../statusLine.js";
|
|
6
|
+
import { LspPanel, type LspPanelAction } from "../ui/lspPanel.js";
|
|
7
|
+
import { formatLspDoctor, formatLspStatus, type LspStatusSnapshot } from "./status.js";
|
|
8
|
+
|
|
9
|
+
export type GetLspState = () => LspExtensionState | null;
|
|
10
|
+
|
|
11
|
+
export function registerLspCommand(pi: ExtensionAPI, getState: GetLspState): void {
|
|
12
|
+
pi.registerCommand("lsp", {
|
|
13
|
+
description: "Show and manage LSP server status",
|
|
14
|
+
handler: async (args, ctx) => {
|
|
15
|
+
const state = getState();
|
|
16
|
+
if (!state) {
|
|
17
|
+
notify(ctx, "LSP extension is not initialized.", "error");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parts = args.trim().split(/\s+/u).filter(Boolean);
|
|
22
|
+
const subcommand = parts[0] ?? "";
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
switch (subcommand) {
|
|
26
|
+
case "":
|
|
27
|
+
case "status":
|
|
28
|
+
await showStatusOrPanel(state, ctx, subcommand === "status");
|
|
29
|
+
return;
|
|
30
|
+
case "doctor":
|
|
31
|
+
await showDoctor(state, ctx, parts[1]);
|
|
32
|
+
return;
|
|
33
|
+
case "install":
|
|
34
|
+
await installServer(state, ctx, parts[1]);
|
|
35
|
+
return;
|
|
36
|
+
case "update":
|
|
37
|
+
await updateServer(state, ctx, parts[1]);
|
|
38
|
+
return;
|
|
39
|
+
case "uninstall":
|
|
40
|
+
await uninstallServer(state, ctx, parts[1]);
|
|
41
|
+
return;
|
|
42
|
+
case "stop":
|
|
43
|
+
await stopProcesses(state, ctx, parts[1]);
|
|
44
|
+
return;
|
|
45
|
+
case "start":
|
|
46
|
+
await startServers(state, ctx, parts[1]);
|
|
47
|
+
return;
|
|
48
|
+
case "restart":
|
|
49
|
+
await restartServers(state, ctx, parts[1]);
|
|
50
|
+
return;
|
|
51
|
+
default:
|
|
52
|
+
notify(ctx, `Unknown /lsp subcommand: ${subcommand}`, "error");
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function showStatusOrPanel(
|
|
62
|
+
state: LspExtensionState,
|
|
63
|
+
ctx: ExtensionCommandContext,
|
|
64
|
+
forceText: boolean,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const snapshot = await buildSnapshot(state);
|
|
67
|
+
if (!ctx.hasUI || forceText) {
|
|
68
|
+
notify(ctx, formatLspStatus(snapshot), "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const action = await ctx.ui.custom<LspPanelAction>(
|
|
73
|
+
(tui, theme, _keybindings, done) => {
|
|
74
|
+
const panel = new LspPanel(snapshot, theme, done);
|
|
75
|
+
return {
|
|
76
|
+
render: (width: number) => panel.render(width),
|
|
77
|
+
invalidate: () => panel.invalidate(),
|
|
78
|
+
handleInput: (data: string) => {
|
|
79
|
+
panel.handleInput(data);
|
|
80
|
+
tui.requestRender();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: 92 } },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await handlePanelAction(action, state, ctx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function handlePanelAction(
|
|
91
|
+
action: LspPanelAction | undefined,
|
|
92
|
+
state: LspExtensionState,
|
|
93
|
+
ctx: ExtensionCommandContext,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!action || action.type === "close") return;
|
|
96
|
+
switch (action.type) {
|
|
97
|
+
case "doctor":
|
|
98
|
+
await showDoctor(state, ctx, action.serverId);
|
|
99
|
+
return;
|
|
100
|
+
case "install":
|
|
101
|
+
await installServer(state, ctx, action.serverId);
|
|
102
|
+
return;
|
|
103
|
+
case "update":
|
|
104
|
+
await updateServer(state, ctx, action.serverId);
|
|
105
|
+
return;
|
|
106
|
+
case "uninstall":
|
|
107
|
+
await uninstallServer(state, ctx, action.serverId);
|
|
108
|
+
return;
|
|
109
|
+
case "stop":
|
|
110
|
+
await stopProcesses(state, ctx, action.serverId);
|
|
111
|
+
return;
|
|
112
|
+
case "refresh":
|
|
113
|
+
await showStatusOrPanel(state, ctx, false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function showDoctor(state: LspExtensionState, ctx: ExtensionCommandContext, serverId?: string): Promise<void> {
|
|
118
|
+
notify(ctx, formatLspDoctor(await buildSnapshot(state), serverId), "info");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function installServer(
|
|
122
|
+
state: LspExtensionState,
|
|
123
|
+
ctx: ExtensionCommandContext,
|
|
124
|
+
spec: string | undefined,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!spec) {
|
|
127
|
+
notify(ctx, "Usage: /lsp install <serverId[@version]>", "error");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const { serverId, version } = parseServerVersionSpec(spec);
|
|
131
|
+
const result = await state.installManager.installServer(serverId, version);
|
|
132
|
+
notify(ctx, `Installed ${result.serverId}: ${result.metadata.resolvedCommand.join(" ")}`, "info");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function updateServer(
|
|
136
|
+
state: LspExtensionState,
|
|
137
|
+
ctx: ExtensionCommandContext,
|
|
138
|
+
spec: string | undefined,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
if (!spec) {
|
|
141
|
+
notify(ctx, "Usage: /lsp update <serverId[@version]> or /lsp update --all", "error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (spec === "--all") {
|
|
146
|
+
for (const serverId of Object.keys(state.config.catalog.servers)) {
|
|
147
|
+
await state.installManager.updateServer(serverId);
|
|
148
|
+
}
|
|
149
|
+
notify(ctx, "Updated all configured LSP servers.", "info");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { serverId, version } = parseServerVersionSpec(spec);
|
|
154
|
+
const result = await state.installManager.updateServer(serverId, version);
|
|
155
|
+
notify(ctx, `Updated ${result.serverId}: ${result.metadata.resolvedCommand.join(" ")}`, "info");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function uninstallServer(
|
|
159
|
+
state: LspExtensionState,
|
|
160
|
+
ctx: ExtensionCommandContext,
|
|
161
|
+
serverId: string | undefined,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
if (!serverId) {
|
|
164
|
+
notify(ctx, "Usage: /lsp uninstall <serverId>", "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const runtimeStopped = await state.runtimeManager.stopServer(serverId);
|
|
169
|
+
const stopped = runtimeStopped + (await terminateMatchingProcesses(state, serverId));
|
|
170
|
+
const result = await state.installManager.uninstallServer(serverId);
|
|
171
|
+
setLspStatusLine(ctx, state);
|
|
172
|
+
notify(
|
|
173
|
+
ctx,
|
|
174
|
+
`${result.removed ? "Uninstalled" : "No install found for"} ${serverId}; stopped ${stopped} process(es).`,
|
|
175
|
+
"info",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function startServers(state: LspExtensionState, ctx: ExtensionCommandContext, serverId?: string): Promise<void> {
|
|
180
|
+
const results = await state.runtimeManager.startServer(serverId, { allowPromptInstall: true });
|
|
181
|
+
setLspStatusLine(ctx, state);
|
|
182
|
+
notify(ctx, formatStartResults("start", results), hasStartErrors(results) ? "warning" : "info");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function restartServers(
|
|
186
|
+
state: LspExtensionState,
|
|
187
|
+
ctx: ExtensionCommandContext,
|
|
188
|
+
serverId?: string,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const results = await state.runtimeManager.restartServer(serverId, { allowPromptInstall: true });
|
|
191
|
+
setLspStatusLine(ctx, state);
|
|
192
|
+
notify(ctx, formatStartResults("restart", results), hasStartErrors(results) ? "warning" : "info");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function stopProcesses(state: LspExtensionState, ctx: ExtensionCommandContext, serverId?: string): Promise<void> {
|
|
196
|
+
const runtimeStopped = await state.runtimeManager.stopServer(serverId);
|
|
197
|
+
const stopped = runtimeStopped + (await terminateMatchingProcesses(state, serverId));
|
|
198
|
+
setLspStatusLine(ctx, state);
|
|
199
|
+
notify(ctx, `Stopped ${stopped} tracked LSP process(es)${serverId ? ` for ${serverId}` : ""}.`, "info");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function terminateMatchingProcesses(state: LspExtensionState, serverId?: string): Promise<number> {
|
|
203
|
+
const result = await state.processRegistry.terminateProcesses((entry) => !serverId || entry.serverId === serverId);
|
|
204
|
+
return result.terminated.length + result.removed.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function buildSnapshot(state: LspExtensionState): Promise<LspStatusSnapshot> {
|
|
208
|
+
return {
|
|
209
|
+
config: state.config,
|
|
210
|
+
lockfile: await readLockfile(),
|
|
211
|
+
processes: await state.processRegistry.list(),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function formatStartResults(action: "start" | "restart", results: Array<{ status: string; message: string }>): string {
|
|
216
|
+
if (results.length === 0) return `No installed LSP servers to ${action}. Run /lsp install <serverId> first.`;
|
|
217
|
+
return results.map((result) => result.message).join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasStartErrors(results: Array<{ status: string }>): boolean {
|
|
221
|
+
return results.some(
|
|
222
|
+
(result) => result.status === "missing" || result.status === "declined" || result.status === "error",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function notify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
227
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
228
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LoadLspConfigResult } from "../config/loadConfig.js";
|
|
2
|
+
import type { LspLockfile } from "../install/lockfile.js";
|
|
3
|
+
import type { LspProcessEntry } from "../lsp/processRegistry.js";
|
|
4
|
+
import type { ServerDefinition } from "../registry/schema.js";
|
|
5
|
+
|
|
6
|
+
export interface LspStatusSnapshot {
|
|
7
|
+
config: LoadLspConfigResult;
|
|
8
|
+
lockfile: LspLockfile;
|
|
9
|
+
processes: LspProcessEntry[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatLspStatus(snapshot: LspStatusSnapshot): string {
|
|
13
|
+
const lines = ["LSP status", ""];
|
|
14
|
+
lines.push(`installMode: ${snapshot.config.installMode}`);
|
|
15
|
+
lines.push(`warmup: ${snapshot.config.warmup ? "enabled" : "disabled"}`);
|
|
16
|
+
lines.push(`servers: ${Object.keys(snapshot.config.catalog.servers).length}`);
|
|
17
|
+
lines.push(`tracked processes: ${snapshot.processes.length}`);
|
|
18
|
+
|
|
19
|
+
if (snapshot.config.warnings.length > 0) {
|
|
20
|
+
lines.push("", "warnings:");
|
|
21
|
+
for (const warning of snapshot.config.warnings) lines.push(`- ${warning}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
lines.push("", "servers:");
|
|
25
|
+
for (const server of Object.values(snapshot.config.catalog.servers)) {
|
|
26
|
+
const installed = snapshot.lockfile.servers[server.id] ? "installed" : "missing";
|
|
27
|
+
const processCount = snapshot.processes.filter((process) => process.serverId === server.id).length;
|
|
28
|
+
lines.push(`- ${server.id}: ${installed}, ${processCount} process${processCount === 1 ? "" : "es"}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatLspDoctor(snapshot: LspStatusSnapshot, serverId?: string): string {
|
|
35
|
+
if (serverId) {
|
|
36
|
+
const server = snapshot.config.catalog.servers[serverId];
|
|
37
|
+
if (!server) return `Unknown LSP server: ${serverId}`;
|
|
38
|
+
return formatServerDoctor(server, snapshot);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [formatLspStatus(snapshot), "", "Run /lsp doctor <serverId> for resolved server details."].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatServerDoctor(server: ServerDefinition, snapshot: LspStatusSnapshot): string {
|
|
45
|
+
const lockEntry = snapshot.lockfile.servers[server.id];
|
|
46
|
+
const processes = snapshot.processes.filter((process) => process.serverId === server.id);
|
|
47
|
+
const lines = [
|
|
48
|
+
`server: ${server.id}`,
|
|
49
|
+
`displayName: ${server.displayName}`,
|
|
50
|
+
`filetypes: ${server.filetypes.join(", ")}`,
|
|
51
|
+
`rootMarkers: ${server.rootMarkers.join(", ")}`,
|
|
52
|
+
`install: ${server.install.type}`,
|
|
53
|
+
`lazy: ${server.lazy ? "yes" : "no"}`,
|
|
54
|
+
`installed: ${lockEntry ? "yes" : "no"}`,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (lockEntry) {
|
|
58
|
+
lines.push(`resolvedCommand: ${lockEntry.resolvedCommand.join(" ")}`);
|
|
59
|
+
if (lockEntry.requestedVersion) lines.push(`requestedVersion: ${lockEntry.requestedVersion}`);
|
|
60
|
+
if (lockEntry.packageDir) lines.push(`packageDir: ${lockEntry.packageDir}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (processes.length > 0) {
|
|
64
|
+
lines.push("processes:");
|
|
65
|
+
for (const process of processes) {
|
|
66
|
+
lines.push(`- pid ${process.pid} root=${process.rootDir} owner=${process.ownerId}`);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
lines.push("processes: none");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const relevantWarnings = snapshot.config.warnings.filter((warning) => warning.includes(server.id));
|
|
73
|
+
if (relevantWarnings.length > 0) {
|
|
74
|
+
lines.push("warnings:");
|
|
75
|
+
for (const warning of relevantWarnings) lines.push(`- ${warning}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { isNodeError, isPlainObject, isPythonServerId, messageFromError } from "../util/helpers.js";
|
|
4
|
+
import { BUILTIN_CATALOG } from "../registry/builtin.js";
|
|
5
|
+
import { parseServerDefinition } from "../registry/schema.js";
|
|
6
|
+
import type { Catalog, InstallMode, ServerDefinition } from "../registry/schema.js";
|
|
7
|
+
import { deepClone, deepMerge } from "../util/deepMerge.js";
|
|
8
|
+
import { getProjectConfigPath, getUserConfigPath } from "./paths.js";
|
|
9
|
+
import { isProjectTrusted } from "./trust.js";
|
|
10
|
+
|
|
11
|
+
export interface LoadLspConfigInput {
|
|
12
|
+
cwd: string;
|
|
13
|
+
projectRoot?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LoadLspConfigResult {
|
|
17
|
+
catalog: Catalog;
|
|
18
|
+
warnings: string[];
|
|
19
|
+
installMode: InstallMode;
|
|
20
|
+
warmup: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RawLspConfig {
|
|
24
|
+
installMode?: unknown;
|
|
25
|
+
warmup?: unknown;
|
|
26
|
+
servers?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ConfigSource {
|
|
30
|
+
label: string;
|
|
31
|
+
path: string;
|
|
32
|
+
kind: "global" | "project";
|
|
33
|
+
trustedProjectOverrides: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const INSTALL_MODES = new Set<InstallMode>(["prompt", "auto", "off"]);
|
|
37
|
+
const SAFE_PROJECT_SERVER_FIELDS = new Set([
|
|
38
|
+
"filetypes",
|
|
39
|
+
"rootMarkers",
|
|
40
|
+
"settings",
|
|
41
|
+
"initializationOptions",
|
|
42
|
+
"env",
|
|
43
|
+
"cwd",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const DANGEROUS_PROJECT_ENV_KEYS = new Set([
|
|
47
|
+
"PATH",
|
|
48
|
+
"PATHEXT",
|
|
49
|
+
"NODE_OPTIONS",
|
|
50
|
+
"NODE_PATH",
|
|
51
|
+
"LD_PRELOAD",
|
|
52
|
+
"LD_LIBRARY_PATH",
|
|
53
|
+
"LD_AUDIT",
|
|
54
|
+
"JAVA_TOOL_OPTIONS",
|
|
55
|
+
"JDK_JAVA_OPTIONS",
|
|
56
|
+
"CLASSPATH",
|
|
57
|
+
]);
|
|
58
|
+
const DANGEROUS_PROJECT_ENV_PREFIXES = ["DYLD_", "NPM_CONFIG_", "YARN_", "PNPM_"];
|
|
59
|
+
|
|
60
|
+
export async function loadLspConfig(input: LoadLspConfigInput): Promise<LoadLspConfigResult> {
|
|
61
|
+
const warnings: string[] = [];
|
|
62
|
+
const catalog = deepClone(BUILTIN_CATALOG);
|
|
63
|
+
let installMode: InstallMode = "prompt";
|
|
64
|
+
let warmup = true;
|
|
65
|
+
const projectRoot = input.projectRoot ?? input.cwd;
|
|
66
|
+
const projectTrusted = await isProjectTrusted(projectRoot);
|
|
67
|
+
|
|
68
|
+
const sources: ConfigSource[] = [
|
|
69
|
+
{
|
|
70
|
+
label: "global config",
|
|
71
|
+
path: getUserConfigPath(),
|
|
72
|
+
kind: "global",
|
|
73
|
+
trustedProjectOverrides: true,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: "project config",
|
|
77
|
+
path: getProjectConfigPath(projectRoot),
|
|
78
|
+
kind: "project",
|
|
79
|
+
trustedProjectOverrides: projectTrusted,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const source of sources) {
|
|
84
|
+
const config = await readConfig(source, warnings);
|
|
85
|
+
if (!config) continue;
|
|
86
|
+
|
|
87
|
+
installMode = mergeInstallMode(installMode, config.installMode, source, projectRoot, warnings);
|
|
88
|
+
warmup = mergeWarmup(warmup, config.warmup, source, projectRoot, warnings);
|
|
89
|
+
mergeServers(catalog, config.servers, source, projectRoot, warnings);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { catalog, warnings, installMode, warmup };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readConfig(source: ConfigSource, warnings: string[]): Promise<RawLspConfig | undefined> {
|
|
96
|
+
let raw: string;
|
|
97
|
+
try {
|
|
98
|
+
raw = await readFile(source.path, "utf8");
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
warnings.push(`Could not read ${source.label} at ${source.path}: ${messageFromError(error)}`);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(raw);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
warnings.push(`Could not parse ${source.label} at ${source.path}: ${messageFromError(error)}`);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!isPlainObject(parsed)) {
|
|
116
|
+
warnings.push(`Ignoring ${source.label} at ${source.path}: top-level value must be an object.`);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeInstallMode(
|
|
124
|
+
current: InstallMode,
|
|
125
|
+
value: unknown,
|
|
126
|
+
source: ConfigSource,
|
|
127
|
+
projectRoot: string,
|
|
128
|
+
warnings: string[],
|
|
129
|
+
): InstallMode {
|
|
130
|
+
if (value === undefined) return current;
|
|
131
|
+
|
|
132
|
+
if (source.kind === "project" && !source.trustedProjectOverrides) {
|
|
133
|
+
warnings.push(
|
|
134
|
+
`Ignoring trusted-only project installMode from ${source.path}; run /lsp trust ${projectRoot} to allow project installMode overrides.`,
|
|
135
|
+
);
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof value === "string" && INSTALL_MODES.has(value as InstallMode)) {
|
|
140
|
+
return value as InstallMode;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
warnings.push(`Ignoring invalid installMode in ${source.label} at ${source.path}: expected prompt, auto, or off.`);
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mergeWarmup(
|
|
148
|
+
current: boolean,
|
|
149
|
+
value: unknown,
|
|
150
|
+
source: ConfigSource,
|
|
151
|
+
projectRoot: string,
|
|
152
|
+
warnings: string[],
|
|
153
|
+
): boolean {
|
|
154
|
+
if (value === undefined) return current;
|
|
155
|
+
|
|
156
|
+
if (source.kind === "project" && !source.trustedProjectOverrides) {
|
|
157
|
+
warnings.push(
|
|
158
|
+
`Ignoring trusted-only project warmup from ${source.path}; run /lsp trust ${projectRoot} to allow project warmup overrides.`,
|
|
159
|
+
);
|
|
160
|
+
return current;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (typeof value === "boolean") {
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
warnings.push(`Ignoring invalid warmup in ${source.label} at ${source.path}: expected true or false.`);
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function mergeServers(
|
|
172
|
+
catalog: Catalog,
|
|
173
|
+
servers: unknown,
|
|
174
|
+
source: ConfigSource,
|
|
175
|
+
projectRoot: string,
|
|
176
|
+
warnings: string[],
|
|
177
|
+
): void {
|
|
178
|
+
if (servers === undefined) return;
|
|
179
|
+
if (!isPlainObject(servers)) {
|
|
180
|
+
warnings.push(`Ignoring servers in ${source.label} at ${source.path}: expected an object.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const [serverId, rawOverride] of Object.entries(servers)) {
|
|
185
|
+
if (!isPlainObject(rawOverride)) {
|
|
186
|
+
warnings.push(`Ignoring ${serverId} in ${source.label} at ${source.path}: server override must be an object.`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const override =
|
|
191
|
+
source.kind === "project" && !source.trustedProjectOverrides
|
|
192
|
+
? filterUntrustedProjectServerFields(serverId, rawOverride, source, projectRoot, warnings)
|
|
193
|
+
: rawOverride;
|
|
194
|
+
|
|
195
|
+
const base = catalog.servers[serverId];
|
|
196
|
+
const merged = enforceCatalogKeyId(
|
|
197
|
+
mergeServerDefinition(base, serverId, override),
|
|
198
|
+
serverId,
|
|
199
|
+
override,
|
|
200
|
+
source,
|
|
201
|
+
warnings,
|
|
202
|
+
);
|
|
203
|
+
const parsed = parseServerDefinition(merged);
|
|
204
|
+
if (!parsed.ok) {
|
|
205
|
+
warnings.push(
|
|
206
|
+
`Ignoring invalid ${serverId} server definition from ${source.label} at ${source.path}: ${parsed.errors.join("; ")}.`,
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
catalog.servers[serverId] = parsed.value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mergeServerDefinition(
|
|
216
|
+
base: ServerDefinition | undefined,
|
|
217
|
+
serverId: string,
|
|
218
|
+
override: Record<string, unknown>,
|
|
219
|
+
): unknown {
|
|
220
|
+
const baseForMerge = deepClone((base ?? { id: serverId }) as unknown as Record<string, unknown>);
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
base &&
|
|
224
|
+
isPlainObject(baseForMerge) &&
|
|
225
|
+
isPlainObject(baseForMerge.install) &&
|
|
226
|
+
isPlainObject(override.install) &&
|
|
227
|
+
typeof override.install.type === "string" &&
|
|
228
|
+
override.install.type !== base.install.type
|
|
229
|
+
) {
|
|
230
|
+
baseForMerge.install = {};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return normalizeServerDefinitionShorthand(deepMerge(baseForMerge, override), override);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeServerDefinitionShorthand(merged: unknown, override: Record<string, unknown>): unknown {
|
|
237
|
+
if (!isPlainObject(merged)) return merged;
|
|
238
|
+
|
|
239
|
+
if (isPlainObject(merged.install) && merged.install.type === "system") {
|
|
240
|
+
if (merged.command === undefined && Array.isArray(merged.install.command)) {
|
|
241
|
+
return { ...merged, command: deepClone(merged.install.command) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (override.command === undefined && Array.isArray(merged.install.command)) {
|
|
245
|
+
return { ...merged, command: deepClone(merged.install.command) };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return merged;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function filterUntrustedProjectServerFields(
|
|
253
|
+
serverId: string,
|
|
254
|
+
override: Record<string, unknown>,
|
|
255
|
+
source: ConfigSource,
|
|
256
|
+
projectRoot: string,
|
|
257
|
+
warnings: string[],
|
|
258
|
+
): Record<string, unknown> {
|
|
259
|
+
const filtered: Record<string, unknown> = {};
|
|
260
|
+
|
|
261
|
+
for (const [key, value] of Object.entries(override)) {
|
|
262
|
+
if (!SAFE_PROJECT_SERVER_FIELDS.has(key)) {
|
|
263
|
+
warnings.push(ignoredProjectServerFieldWarning(serverId, key, source.path, projectRoot));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (key === "env") {
|
|
268
|
+
const sanitizedEnv = filterUntrustedProjectEnv(serverId, value, source.path, warnings);
|
|
269
|
+
if (sanitizedEnv !== undefined) filtered.env = sanitizedEnv;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (key === "cwd") {
|
|
274
|
+
const sanitizedCwd = filterUntrustedProjectCwd(serverId, value, source.path, projectRoot, warnings);
|
|
275
|
+
if (sanitizedCwd !== undefined) filtered.cwd = sanitizedCwd;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
filtered[key] = deepClone(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return filtered;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function enforceCatalogKeyId(
|
|
286
|
+
definition: unknown,
|
|
287
|
+
serverId: string,
|
|
288
|
+
override: Record<string, unknown>,
|
|
289
|
+
source: ConfigSource,
|
|
290
|
+
warnings: string[],
|
|
291
|
+
): unknown {
|
|
292
|
+
if (!isPlainObject(definition)) return definition;
|
|
293
|
+
|
|
294
|
+
if (typeof override.id === "string" && override.id !== serverId) {
|
|
295
|
+
warnings.push(
|
|
296
|
+
`Ignoring ${source.label} id override for ${serverId} from ${source.path}; catalog keys are authoritative.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { ...definition, id: serverId };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function filterUntrustedProjectEnv(
|
|
304
|
+
serverId: string,
|
|
305
|
+
value: unknown,
|
|
306
|
+
sourcePath: string,
|
|
307
|
+
warnings: string[],
|
|
308
|
+
): Record<string, string> | unknown {
|
|
309
|
+
if (!isPlainObject(value)) return deepClone(value);
|
|
310
|
+
|
|
311
|
+
const filtered: Record<string, string> = {};
|
|
312
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
313
|
+
if (isDangerousProjectEnvKey(serverId, key)) {
|
|
314
|
+
warnings.push(
|
|
315
|
+
`Ignoring untrusted project env override for ${serverId}.${key} from ${sourcePath}; process-affecting environment variables require /lsp trust.`,
|
|
316
|
+
);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
filtered[key] = deepClone(entry) as string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return filtered;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function filterUntrustedProjectCwd(
|
|
326
|
+
serverId: string,
|
|
327
|
+
value: unknown,
|
|
328
|
+
sourcePath: string,
|
|
329
|
+
projectRoot: string,
|
|
330
|
+
warnings: string[],
|
|
331
|
+
): string | unknown | undefined {
|
|
332
|
+
if (typeof value !== "string") return deepClone(value);
|
|
333
|
+
|
|
334
|
+
if (isAbsolute(value)) {
|
|
335
|
+
warnings.push(
|
|
336
|
+
`Ignoring untrusted project cwd override for ${serverId} from ${sourcePath}; cwd must be relative to the project root.`,
|
|
337
|
+
);
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const resolvedCwd = resolve(projectRoot, value);
|
|
342
|
+
const relativeCwd = relative(projectRoot, resolvedCwd);
|
|
343
|
+
if (relativeCwd.startsWith("..") || isAbsolute(relativeCwd)) {
|
|
344
|
+
warnings.push(
|
|
345
|
+
`Ignoring untrusted project cwd override for ${serverId} from ${sourcePath}; cwd must stay inside the project root.`,
|
|
346
|
+
);
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isDangerousProjectEnvKey(serverId: string, key: string): boolean {
|
|
354
|
+
const upperKey = key.toUpperCase();
|
|
355
|
+
if (upperKey === "PYTHONPATH") return !isPythonServerId(serverId);
|
|
356
|
+
return (
|
|
357
|
+
DANGEROUS_PROJECT_ENV_KEYS.has(upperKey) ||
|
|
358
|
+
DANGEROUS_PROJECT_ENV_PREFIXES.some((prefix) => upperKey.startsWith(prefix))
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function ignoredProjectServerFieldWarning(
|
|
363
|
+
serverId: string,
|
|
364
|
+
field: string,
|
|
365
|
+
sourcePath: string,
|
|
366
|
+
projectRoot: string,
|
|
367
|
+
): string {
|
|
368
|
+
if (field === "command") {
|
|
369
|
+
return `Ignoring trusted-only project override for ${serverId}.command from ${sourcePath}; run /lsp trust ${projectRoot} to allow executable overrides.`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (field === "install") {
|
|
373
|
+
return `Ignoring trusted-only project override for ${serverId}.install from ${sourcePath}; install commands, package names, and package versions require /lsp trust ${projectRoot}.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return `Ignoring trusted-only project override for ${serverId}.${field} from ${sourcePath}; only filetypes, rootMarkers, settings, initializationOptions, env, and cwd are allowed before /lsp trust ${projectRoot}.`;
|
|
377
|
+
}
|