vipcode 0.0.0-stage → 0.2.1

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/dist/login.js ADDED
@@ -0,0 +1,157 @@
1
+ import { spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import http from "node:http";
4
+ import { debugLog } from "./debug.js";
5
+ // Abre una dirección en el navegador. VIPCODE_BROWSER permite usar otro comando ("firefox", "echo"…)
6
+ export function openBrowser(url) {
7
+ let command;
8
+ let args;
9
+ const custom = (process.env.VIPCODE_BROWSER || "").split(/\s+/).filter(Boolean);
10
+ if (custom.length > 0) {
11
+ [command, ...args] = custom;
12
+ args.push(url);
13
+ }
14
+ else if (process.platform === "darwin") {
15
+ command = "open";
16
+ args = [url];
17
+ }
18
+ else if (process.platform === "win32") {
19
+ // Con «start» habría que escapar los & de la dirección
20
+ command = "rundll32";
21
+ args = ["url.dll,FileProtocolHandler", url];
22
+ }
23
+ else {
24
+ command = "xdg-open";
25
+ args = [url];
26
+ }
27
+ try {
28
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
29
+ child.on("error", (err) => debugLog("navegador", err));
30
+ child.unref();
31
+ }
32
+ catch (err) {
33
+ debugLog("navegador", err);
34
+ }
35
+ }
36
+ // Sin navegador en esta máquina (por ejemplo, conectado por SSH)
37
+ export function isRemoteSession() {
38
+ return Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY);
39
+ }
40
+ // PKCE: el verificador se queda aquí y al proveedor solo le llega su hash
41
+ function pkcePair() {
42
+ const verifier = crypto.randomBytes(32).toString("base64url");
43
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
44
+ return { verifier, challenge };
45
+ }
46
+ function page(text) {
47
+ return `<!doctype html><html lang="es"><head><meta charset="utf-8"><title>vipcode</title></head><body style="font-family:system-ui,sans-serif;margin:15vh auto;max-width:32rem;padding:0 1rem;text-align:center"><h1>vipcode</h1><p>${text}</p></body></html>`;
48
+ }
49
+ // Espera a que el navegador vuelva a http://localhost:<puerto>/callback con el código
50
+ async function callbackServer(state, timeoutMs) {
51
+ let resolveCode;
52
+ let rejectCode;
53
+ const code = new Promise((resolve, reject) => {
54
+ resolveCode = resolve;
55
+ rejectCode = reject;
56
+ });
57
+ const handler = (req, res) => {
58
+ const url = new URL(req.url || "/", "http://localhost");
59
+ if (url.pathname !== "/callback") {
60
+ res.writeHead(404);
61
+ res.end();
62
+ return;
63
+ }
64
+ const html = { "content-type": "text/html; charset=utf-8" };
65
+ const returned = url.searchParams.get("state");
66
+ if (returned && returned !== state) {
67
+ res.writeHead(400, html);
68
+ res.end(page("Esta respuesta no es de este inicio de sesión. Vuelve a intentarlo desde vipcode."));
69
+ return;
70
+ }
71
+ const value = url.searchParams.get("code");
72
+ if (!value) {
73
+ res.writeHead(400, html);
74
+ res.end(page("No se ha dado permiso. Ya puedes cerrar esta pestaña."));
75
+ rejectCode(new Error("no has dado permiso en el navegador"));
76
+ return;
77
+ }
78
+ res.writeHead(200, html);
79
+ res.end(page("Listo. Ya puedes cerrar esta pestaña y volver a vipcode."));
80
+ resolveCode(value);
81
+ };
82
+ const servers = [http.createServer(handler)];
83
+ await new Promise((resolve, reject) => {
84
+ servers[0].once("error", reject);
85
+ servers[0].listen(0, "127.0.0.1", () => resolve());
86
+ });
87
+ const port = servers[0].address().port;
88
+ // También en IPv6, porque el navegador puede resolver «localhost» como ::1
89
+ const v6 = http.createServer(handler);
90
+ await new Promise((resolve) => {
91
+ v6.once("error", () => resolve());
92
+ v6.listen(port, "::1", () => {
93
+ servers.push(v6);
94
+ resolve();
95
+ });
96
+ });
97
+ const timer = setTimeout(() => rejectCode(new Error(`se ha acabado el tiempo (${Math.round(timeoutMs / 60000)} min) sin iniciar sesión`)), timeoutMs);
98
+ const close = () => {
99
+ clearTimeout(timer);
100
+ for (const server of servers) {
101
+ server.closeAllConnections();
102
+ server.close();
103
+ }
104
+ };
105
+ return { url: `http://localhost:${port}/callback`, code, close };
106
+ }
107
+ // OpenRouter: inicio de sesión oficial con OAuth PKCE (https://openrouter.ai/docs/guides/overview/auth/oauth).
108
+ // Crea en tu cuenta una clave llamada «vipcode» y la devuelve; null si no se completa.
109
+ export async function loginOpenRouter(ui, baseURL, options = {}) {
110
+ const api = baseURL.replace(/\/+$/, "");
111
+ const site = new URL(api).origin;
112
+ const { verifier, challenge } = pkcePair();
113
+ const params = new URLSearchParams({ code_challenge: challenge, code_challenge_method: "S256", key_label: "vipcode" });
114
+ let code = null;
115
+ if (options.headless) {
116
+ // Sin callback, OpenRouter enseña el código en pantalla para copiarlo aquí
117
+ if (!ui.prompt) {
118
+ return null;
119
+ }
120
+ ui.info(`Abre este enlace en cualquier navegador, inicia sesión y copia el código que te enseñe:\n${site}/auth?${params}`);
121
+ const answer = await ui.prompt("Código de OpenRouter");
122
+ code = answer?.trim() || null;
123
+ }
124
+ else {
125
+ const state = crypto.randomBytes(16).toString("hex");
126
+ const callback = await callbackServer(state, options.timeoutMs ?? 5 * 60 * 1000);
127
+ params.set("callback_url", callback.url);
128
+ params.set("state", state);
129
+ const url = `${site}/auth?${params}`;
130
+ ui.info(`Abro el navegador para que inicies sesión en OpenRouter (espero 5 minutos). Si no se abre, entra en:\n${url}`);
131
+ openBrowser(url);
132
+ try {
133
+ code = await callback.code;
134
+ }
135
+ finally {
136
+ callback.close();
137
+ }
138
+ }
139
+ if (!code) {
140
+ return null;
141
+ }
142
+ const response = await fetch(`${api}/auth/keys`, {
143
+ method: "POST",
144
+ headers: { "content-type": "application/json" },
145
+ body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
146
+ signal: AbortSignal.timeout(20000),
147
+ });
148
+ if (!response.ok) {
149
+ const body = await response.text().catch(() => "");
150
+ throw new Error(`OpenRouter no ha dado la clave (HTTP ${response.status} ${body.slice(0, 120)})`);
151
+ }
152
+ const json = await response.json();
153
+ if (typeof json?.key !== "string" || !json.key) {
154
+ throw new Error("OpenRouter no ha devuelto ninguna clave");
155
+ }
156
+ return json.key;
157
+ }
package/dist/lsp.js ADDED
@@ -0,0 +1,480 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { debugLog } from "./debug.js";
7
+ const LANGUAGE_IDS = {
8
+ ".ts": "typescript", ".tsx": "typescriptreact", ".mts": "typescript", ".cts": "typescript",
9
+ ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript", ".cjs": "javascript",
10
+ ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
11
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp",
12
+ ".php": "php", ".rb": "ruby", ".sh": "shellscript", ".bash": "shellscript",
13
+ ".yaml": "yaml", ".yml": "yaml", ".css": "css", ".scss": "scss", ".less": "less",
14
+ ".html": "html", ".htm": "html", ".json": "json", ".jsonc": "jsonc", ".lua": "lua",
15
+ ".zig": "zig", ".dart": "dart", ".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".svelte": "svelte",
16
+ };
17
+ const TS_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
18
+ // TypeScript 7 trae su propio servidor de lenguaje (tsc --lsp --stdio); vipcode lo lleva como dependencia
19
+ async function bundledTypescript() {
20
+ try {
21
+ const require = createRequire(import.meta.url);
22
+ const pkg = require.resolve("typescript/package.json");
23
+ const helper = path.join(path.dirname(pkg), "lib", "getExePath.js");
24
+ if (!fs.existsSync(helper)) {
25
+ return null;
26
+ }
27
+ const module = await import(pathToFileURL(helper).href);
28
+ const exe = module.default();
29
+ if (typeof exe === "string" && fs.existsSync(exe)) {
30
+ return [exe, "--lsp", "--stdio"];
31
+ }
32
+ }
33
+ catch (err) {
34
+ debugLog("lsp typescript", err);
35
+ }
36
+ return null;
37
+ }
38
+ const BUILTIN_SERVERS = [
39
+ { id: "typescript", command: [], extensions: TS_EXTENSIONS, resolve: bundledTypescript },
40
+ { id: "typescript-language-server", command: ["typescript-language-server", "--stdio"], extensions: TS_EXTENSIONS },
41
+ { id: "basedpyright", command: ["basedpyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
42
+ { id: "pyright", command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
43
+ { id: "pylsp", command: ["pylsp"], extensions: [".py"] },
44
+ { id: "gopls", command: ["gopls"], extensions: [".go"] },
45
+ { id: "rust-analyzer", command: ["rust-analyzer"], extensions: [".rs"] },
46
+ { id: "clangd", command: ["clangd"], extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp"] },
47
+ { id: "intelephense", command: ["intelephense", "--stdio"], extensions: [".php"] },
48
+ { id: "phpactor", command: ["phpactor", "language-server"], extensions: [".php"] },
49
+ { id: "ruby-lsp", command: ["ruby-lsp"], extensions: [".rb"] },
50
+ { id: "solargraph", command: ["solargraph", "stdio"], extensions: [".rb"] },
51
+ { id: "bash", command: ["bash-language-server", "start"], extensions: [".sh", ".bash"] },
52
+ { id: "yaml", command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] },
53
+ { id: "css", command: ["vscode-css-language-server", "--stdio"], extensions: [".css", ".scss", ".less"] },
54
+ { id: "html", command: ["vscode-html-language-server", "--stdio"], extensions: [".html", ".htm"] },
55
+ { id: "json", command: ["vscode-json-language-server", "--stdio"], extensions: [".json", ".jsonc"] },
56
+ { id: "lua", command: ["lua-language-server"], extensions: [".lua"] },
57
+ { id: "zls", command: ["zls"], extensions: [".zig"] },
58
+ { id: "dart", command: ["dart", "language-server", "--protocol=lsp"], extensions: [".dart"] },
59
+ { id: "csharp", command: ["csharp-ls"], extensions: [".cs"] },
60
+ { id: "sourcekit", command: ["sourcekit-lsp"], extensions: [".swift"] },
61
+ { id: "kotlin", command: ["kotlin-language-server"], extensions: [".kt"] },
62
+ { id: "svelte", command: ["svelteserver", "--stdio"], extensions: [".svelte"] },
63
+ ];
64
+ // Busca un ejecutable en node_modules/.bin del proyecto y en el PATH
65
+ function which(command, root) {
66
+ if (path.isAbsolute(command)) {
67
+ return fs.existsSync(command) ? command : null;
68
+ }
69
+ const dirs = [path.join(root, "node_modules", ".bin"), ...(process.env.PATH || "").split(path.delimiter)];
70
+ const names = process.platform === "win32" ? [`${command}.cmd`, `${command}.exe`, command] : [command];
71
+ for (const dir of dirs) {
72
+ for (const name of names) {
73
+ const candidate = path.join(dir, name);
74
+ try {
75
+ if (fs.statSync(candidate).isFile()) {
76
+ return candidate;
77
+ }
78
+ }
79
+ catch {
80
+ // no está aquí
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ function uriKey(uri) {
87
+ // macOS y Windows no distinguen mayúsculas en las rutas, y algunos servidores las cambian
88
+ if (process.platform === "darwin" || process.platform === "win32") {
89
+ return decodeURIComponent(uri).toLowerCase();
90
+ }
91
+ return decodeURIComponent(uri);
92
+ }
93
+ // Un servidor de lenguaje hablando JSON-RPC por stdin/stdout
94
+ class LspClient {
95
+ spec;
96
+ root;
97
+ child;
98
+ buffer = Buffer.alloc(0);
99
+ nextId = 1;
100
+ pending = new Map();
101
+ versions = new Map();
102
+ published = new Map();
103
+ waiters = [];
104
+ pull = false;
105
+ dead = false;
106
+ files = new Set();
107
+ constructor(spec, command, root) {
108
+ this.spec = spec;
109
+ this.root = root;
110
+ this.child = spawn(command[0], command.slice(1), {
111
+ cwd: root,
112
+ env: { ...process.env, ...(spec.env || {}) },
113
+ stdio: ["pipe", "pipe", "pipe"],
114
+ });
115
+ this.child.on("error", (err) => {
116
+ debugLog("lsp error", spec.id, err);
117
+ this.dead = true;
118
+ this.failAll(err);
119
+ });
120
+ this.child.on("exit", () => {
121
+ this.dead = true;
122
+ this.failAll(new Error("el servidor de lenguaje se ha cerrado"));
123
+ });
124
+ this.child.stderr?.on("data", (d) => debugLog("lsp stderr", spec.id, d.toString().slice(0, 500)));
125
+ this.child.stdout?.on("data", (d) => this.onData(d));
126
+ }
127
+ failAll(err) {
128
+ for (const entry of this.pending.values()) {
129
+ clearTimeout(entry.timer);
130
+ entry.reject(err);
131
+ }
132
+ this.pending.clear();
133
+ }
134
+ send(message) {
135
+ if (this.dead || !this.child.stdin) {
136
+ return;
137
+ }
138
+ const text = JSON.stringify({ jsonrpc: "2.0", ...message });
139
+ this.child.stdin.write(`Content-Length: ${Buffer.byteLength(text)}\r\n\r\n${text}`);
140
+ }
141
+ request(method, params, timeoutMs = 10000) {
142
+ if (this.dead) {
143
+ return Promise.reject(new Error("servidor de lenguaje cerrado"));
144
+ }
145
+ const id = this.nextId++;
146
+ return new Promise((resolve, reject) => {
147
+ const timer = setTimeout(() => {
148
+ this.pending.delete(id);
149
+ reject(new Error(`${method}: sin respuesta`));
150
+ }, timeoutMs);
151
+ this.pending.set(id, { resolve, reject, timer });
152
+ this.send({ id, method, params });
153
+ });
154
+ }
155
+ notify(method, params) {
156
+ this.send({ method, params });
157
+ }
158
+ onData(chunk) {
159
+ this.buffer = Buffer.concat([this.buffer, chunk]);
160
+ while (true) {
161
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
162
+ if (headerEnd < 0) {
163
+ return;
164
+ }
165
+ const header = this.buffer.subarray(0, headerEnd).toString();
166
+ const length = /Content-Length:\s*(\d+)/i.exec(header);
167
+ if (!length) {
168
+ this.buffer = this.buffer.subarray(headerEnd + 4);
169
+ continue;
170
+ }
171
+ const size = Number(length[1]);
172
+ if (this.buffer.length < headerEnd + 4 + size) {
173
+ return;
174
+ }
175
+ const body = this.buffer.subarray(headerEnd + 4, headerEnd + 4 + size).toString();
176
+ this.buffer = this.buffer.subarray(headerEnd + 4 + size);
177
+ try {
178
+ this.onMessage(JSON.parse(body));
179
+ }
180
+ catch (err) {
181
+ debugLog("lsp mensaje inválido", this.spec.id, err);
182
+ }
183
+ }
184
+ }
185
+ onMessage(message) {
186
+ if (message.id !== undefined && message.method === undefined) {
187
+ const entry = this.pending.get(message.id);
188
+ if (entry) {
189
+ clearTimeout(entry.timer);
190
+ this.pending.delete(message.id);
191
+ if (message.error) {
192
+ entry.reject(new Error(message.error.message || "error del servidor de lenguaje"));
193
+ }
194
+ else {
195
+ entry.resolve(message.result);
196
+ }
197
+ }
198
+ return;
199
+ }
200
+ if (message.method && message.id !== undefined) {
201
+ // Petición del servidor: contestamos lo mínimo para que siga funcionando
202
+ let result = null;
203
+ if (message.method === "workspace/configuration") {
204
+ result = (message.params?.items || []).map(() => this.spec.initialization?.settings ?? null);
205
+ }
206
+ else if (message.method === "workspace/workspaceFolders") {
207
+ result = [{ uri: pathToFileURL(this.root).href, name: path.basename(this.root) }];
208
+ }
209
+ else if (message.method === "workspace/applyEdit") {
210
+ result = { applied: false };
211
+ }
212
+ this.send({ id: message.id, result });
213
+ return;
214
+ }
215
+ if (message.method === "textDocument/publishDiagnostics") {
216
+ const items = (message.params?.diagnostics || []).map(toDiagnostic);
217
+ this.published.set(uriKey(message.params.uri), { at: Date.now(), items });
218
+ const waiters = this.waiters;
219
+ this.waiters = [];
220
+ for (const wake of waiters) {
221
+ wake();
222
+ }
223
+ }
224
+ }
225
+ async initialize() {
226
+ const rootUri = pathToFileURL(this.root).href;
227
+ const result = await this.request("initialize", {
228
+ processId: process.pid,
229
+ clientInfo: { name: "vipcode" },
230
+ rootUri,
231
+ rootPath: this.root,
232
+ workspaceFolders: [{ uri: rootUri, name: path.basename(this.root) }],
233
+ initializationOptions: this.spec.initialization,
234
+ capabilities: {
235
+ workspace: { configuration: true, workspaceFolders: true },
236
+ textDocument: {
237
+ synchronization: { didSave: true, dynamicRegistration: false },
238
+ publishDiagnostics: { versionSupport: true, relatedInformation: false },
239
+ diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
240
+ },
241
+ window: { workDoneProgress: false },
242
+ },
243
+ }, 30000);
244
+ this.pull = Boolean(result?.capabilities?.diagnosticProvider);
245
+ this.notify("initialized", {});
246
+ this.notify("workspace/didChangeConfiguration", { settings: this.spec.initialization?.settings ?? {} });
247
+ }
248
+ // Manda al servidor el contenido actual del fichero
249
+ sync(file, text) {
250
+ const uri = pathToFileURL(file).href;
251
+ const version = (this.versions.get(uri) || 0) + 1;
252
+ this.versions.set(uri, version);
253
+ if (version === 1) {
254
+ const languageId = LANGUAGE_IDS[path.extname(file).toLowerCase()] || "plaintext";
255
+ this.notify("textDocument/didOpen", { textDocument: { uri, languageId, version, text } });
256
+ }
257
+ else {
258
+ this.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] });
259
+ }
260
+ this.notify("textDocument/didSave", { textDocument: { uri }, text });
261
+ this.files.add(file);
262
+ return uri;
263
+ }
264
+ async diagnostics(file, firstTime) {
265
+ const text = fs.readFileSync(file, "utf8");
266
+ const since = Date.now();
267
+ const uri = this.sync(file, text);
268
+ if (this.pull) {
269
+ const result = await this.request("textDocument/diagnostic", { textDocument: { uri } }, firstTime ? 20000 : 8000);
270
+ if (result?.kind === "unchanged") {
271
+ return this.published.get(uriKey(uri))?.items || [];
272
+ }
273
+ return (result?.items || []).map(toDiagnostic);
274
+ }
275
+ // Servidores que mandan los diagnósticos por su cuenta: esperamos a que lleguen
276
+ const key = uriKey(uri);
277
+ const deadline = Date.now() + (firstTime ? 15000 : 4000);
278
+ while (Date.now() < deadline) {
279
+ const found = this.published.get(key);
280
+ if (found && found.at >= since) {
281
+ // Un momento más por si llega una segunda tanda (algunos servidores mandan dos)
282
+ await new Promise((resolve) => setTimeout(resolve, 300));
283
+ return this.published.get(key)?.items || found.items;
284
+ }
285
+ await new Promise((resolve) => {
286
+ const timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
287
+ this.waiters.push(() => {
288
+ clearTimeout(timer);
289
+ resolve();
290
+ });
291
+ });
292
+ }
293
+ return this.published.get(key)?.items || [];
294
+ }
295
+ async shutdown() {
296
+ if (this.dead) {
297
+ return;
298
+ }
299
+ try {
300
+ await this.request("shutdown", null, 1500);
301
+ this.notify("exit", null);
302
+ }
303
+ catch {
304
+ // si no contesta, lo matamos
305
+ }
306
+ setTimeout(() => {
307
+ if (!this.dead) {
308
+ this.child.kill("SIGTERM");
309
+ }
310
+ }, 300).unref();
311
+ }
312
+ }
313
+ function toDiagnostic(item) {
314
+ return {
315
+ line: (item?.range?.start?.line ?? 0) + 1,
316
+ column: (item?.range?.start?.character ?? 0) + 1,
317
+ severity: item?.severity ?? 1,
318
+ message: String(item?.message || "").replace(/\s+/g, " ").trim(),
319
+ code: item?.code !== undefined ? String(item.code) : undefined,
320
+ source: item?.source,
321
+ };
322
+ }
323
+ const SEVERITY = ["", "error", "aviso", "info", "pista"];
324
+ // Arranca los servidores de lenguaje cuando hacen falta y les pregunta por errores
325
+ export class LspManager {
326
+ root;
327
+ clients = new Map();
328
+ specs = [];
329
+ failed = new Map();
330
+ constructor(root, config) {
331
+ this.root = root;
332
+ if (config.lsp === false) {
333
+ return;
334
+ }
335
+ const overrides = config.lsp || {};
336
+ const specs = [];
337
+ // Primero los tuyos del config, después los integrados que no hayas desactivado
338
+ for (const [id, value] of Object.entries(overrides)) {
339
+ if (value && !value.disabled && Array.isArray(value.command) && Array.isArray(value.extensions)) {
340
+ const custom = value;
341
+ specs.push({ id, command: custom.command, extensions: custom.extensions.map((ext) => ext.toLowerCase()), env: custom.env, initialization: custom.initialization });
342
+ }
343
+ }
344
+ for (const spec of BUILTIN_SERVERS) {
345
+ const override = overrides[spec.id];
346
+ if (override === false || (override && override.disabled)) {
347
+ continue;
348
+ }
349
+ if (specs.some((existing) => existing.id === spec.id)) {
350
+ continue;
351
+ }
352
+ specs.push(spec);
353
+ }
354
+ this.specs = specs;
355
+ }
356
+ enabled() {
357
+ return this.specs.length > 0;
358
+ }
359
+ async commandFor(spec) {
360
+ if (spec.resolve) {
361
+ return spec.resolve();
362
+ }
363
+ const exe = which(spec.command[0], this.root);
364
+ if (!exe) {
365
+ return null;
366
+ }
367
+ return [exe, ...spec.command.slice(1)];
368
+ }
369
+ clientFor(file) {
370
+ const ext = path.extname(file).toLowerCase();
371
+ const candidates = this.specs.filter((spec) => spec.extensions.includes(ext));
372
+ if (candidates.length === 0) {
373
+ return Promise.resolve(null);
374
+ }
375
+ const cacheKey = `ext:${ext}`;
376
+ let client = this.clients.get(cacheKey);
377
+ if (!client) {
378
+ client = (async () => {
379
+ for (const spec of candidates) {
380
+ // Si ya está arrancado para otra extensión, lo reutilizamos
381
+ const existing = [...this.clients.entries()].find(([key]) => key === `id:${spec.id}`);
382
+ if (existing) {
383
+ const running = await existing[1];
384
+ if (running && !running.dead) {
385
+ return running;
386
+ }
387
+ }
388
+ const command = await this.commandFor(spec);
389
+ if (!command) {
390
+ continue;
391
+ }
392
+ try {
393
+ const started = new LspClient(spec, command, this.root);
394
+ const ready = started.initialize().then(() => started);
395
+ this.clients.set(`id:${spec.id}`, ready.catch(() => null));
396
+ await ready;
397
+ return started;
398
+ }
399
+ catch (err) {
400
+ this.failed.set(spec.id, err.message);
401
+ debugLog("lsp arranque", spec.id, err);
402
+ }
403
+ }
404
+ return null;
405
+ })();
406
+ this.clients.set(cacheKey, client);
407
+ }
408
+ return client;
409
+ }
410
+ async diagnostics(file) {
411
+ if (!this.enabled() || !fs.existsSync(file)) {
412
+ return null;
413
+ }
414
+ const client = await this.clientFor(file);
415
+ if (!client || client.dead) {
416
+ return null;
417
+ }
418
+ const firstTime = !client.files.has(file);
419
+ try {
420
+ return await client.diagnostics(file, firstTime);
421
+ }
422
+ catch (err) {
423
+ debugLog("lsp diagnósticos", client.spec.id, err);
424
+ return null;
425
+ }
426
+ }
427
+ // Texto para el modelo: errores (y avisos si all) con fichero:línea:columna
428
+ async report(file, all) {
429
+ const found = await this.diagnostics(file);
430
+ if (!found) {
431
+ return "";
432
+ }
433
+ const relevant = found.filter((item) => item.severity === 1 || (all && item.severity === 2));
434
+ if (relevant.length === 0) {
435
+ return "";
436
+ }
437
+ const name = path.relative(this.root, file);
438
+ const lines = relevant.slice(0, 20).map((item) => {
439
+ const code = item.code ? ` ${item.source || ""}${item.code}` : "";
440
+ return `${name}:${item.line}:${item.column} ${SEVERITY[item.severity] || "error"}${code}: ${item.message}`;
441
+ });
442
+ if (relevant.length > 20) {
443
+ lines.push(`[… y ${relevant.length - 20} más]`);
444
+ }
445
+ return lines.join("\n");
446
+ }
447
+ async status() {
448
+ const rows = [];
449
+ for (const [key, promise] of this.clients.entries()) {
450
+ if (!key.startsWith("id:")) {
451
+ continue;
452
+ }
453
+ const client = await promise;
454
+ rows.push({ id: key.slice(3), state: client && !client.dead ? "activo" : "cerrado", files: client ? client.files.size : 0 });
455
+ }
456
+ for (const [id, error] of this.failed.entries()) {
457
+ rows.push({ id, state: `error: ${error}`, files: 0 });
458
+ }
459
+ return rows;
460
+ }
461
+ // Servidores que se podrían usar en este ordenador (para /lsp)
462
+ async availableServers() {
463
+ const found = [];
464
+ for (const spec of this.specs) {
465
+ if (await this.commandFor(spec)) {
466
+ found.push(`${spec.id} (${spec.extensions.join(" ")})`);
467
+ }
468
+ }
469
+ return found;
470
+ }
471
+ async shutdown() {
472
+ const clients = await Promise.all([...this.clients.values()]);
473
+ await Promise.all([...new Set(clients)].map((client) => {
474
+ if (client) {
475
+ return client.shutdown();
476
+ }
477
+ return Promise.resolve();
478
+ }));
479
+ }
480
+ }