@octocodeai/octocode-engine 16.6.0 → 16.7.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/README.md +190 -249
- package/dist/lsp/client.d.ts +18 -9
- package/dist/lsp/client.js +35 -25
- package/dist/lsp/config.d.ts +29 -1
- package/dist/lsp/config.js +156 -1
- package/dist/lsp/ideContext.d.ts +18 -0
- package/dist/lsp/ideContext.js +29 -0
- package/dist/lsp/index.d.ts +8 -3
- package/dist/lsp/index.js +7 -2
- package/dist/lsp/lspClientPool.d.ts +2 -0
- package/dist/lsp/lspClientPool.js +9 -4
- package/dist/lsp/manager.d.ts +11 -0
- package/dist/lsp/manager.js +72 -25
- package/dist/lsp/native.d.ts +2 -1
- package/dist/lsp/platform.d.ts +20 -0
- package/dist/lsp/platform.js +64 -0
- package/dist/lsp/resolver.js +10 -4
- package/dist/lsp/serverDiscovery.d.ts +63 -0
- package/dist/lsp/serverDiscovery.js +226 -0
- package/dist/lsp/serverManifest.d.ts +71 -0
- package/dist/lsp/serverManifest.js +116 -0
- package/dist/lsp/serverManifestData.d.ts +2 -0
- package/dist/lsp/serverManifestData.js +96 -0
- package/dist/lsp/serverProvisioner.d.ts +19 -0
- package/dist/lsp/serverProvisioner.js +262 -0
- package/dist/lsp/types.d.ts +18 -0
- package/dist/security/commandValidator.js +74 -10
- package/dist/security/contentSanitizer.js +4 -3
- package/dist/security/discoveryFilter.js +10 -0
- package/dist/security/filePatterns.js +6 -0
- package/dist/security/ignoredPathFilter.js +10 -2
- package/dist/security/mask.js +5 -22
- package/dist/security/maskUtils.d.ts +6 -0
- package/dist/security/maskUtils.js +22 -0
- package/dist/security/native.js +5 -22
- package/dist/security/pathPatterns.js +5 -0
- package/dist/security/pathValidator.js +38 -37
- package/dist/security/registry.js +27 -9
- package/dist/security/securityConstants.d.ts +1 -1
- package/dist/security/securityConstants.js +22 -1
- package/dist/security/withSecurityValidation.js +18 -3
- package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
- package/index.d.ts +8 -2
- package/package.json +54 -10
package/dist/lsp/manager.js
CHANGED
|
@@ -1,29 +1,57 @@
|
|
|
1
1
|
import { LSPClient } from './client.js';
|
|
2
|
-
import { getLanguageServerForFile } from './config.js';
|
|
3
|
-
import { LspClientPool } from './lspClientPool.js';
|
|
4
|
-
import {
|
|
2
|
+
import { getLanguageServerForFile, resolveServerForFile, } from './config.js';
|
|
3
|
+
import { LspClientPool, serializeKey } from './lspClientPool.js';
|
|
4
|
+
import { manifestInstallHint } from './serverManifest.js';
|
|
5
5
|
import { resolveWorkspaceRootForFile } from './workspaceRoot.js';
|
|
6
6
|
export async function isLanguageServerAvailable(filePath, workspaceRoot) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
return false;
|
|
10
|
-
return nativeBinding.isCommandAvailable(serverConfig.command);
|
|
7
|
+
const resolution = await resolveServerForFile(filePath, workspaceRoot ?? process.cwd());
|
|
8
|
+
return resolution != null && resolution.source !== 'unavailable';
|
|
11
9
|
}
|
|
12
10
|
export const LSP_UNAVAILABLE_HINT = 'No language server is available for this file, so no semantic results were returned. Install a matching language server or set the relevant OCTOCODE_*_SERVER_PATH environment variable. For a text-based search meanwhile, use localSearchCode.';
|
|
11
|
+
export const TOOLCHAIN_SERVERS = [
|
|
12
|
+
{
|
|
13
|
+
server: 'gopls',
|
|
14
|
+
languageId: 'go',
|
|
15
|
+
hint: 'Install Go, then `go install golang.org/x/tools/gopls@latest` (gopls needs the Go toolchain at runtime).',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
server: 'jdtls',
|
|
19
|
+
languageId: 'java',
|
|
20
|
+
hint: 'Install a JDK/JRE 21+ and Eclipse JDT LS (https://download.eclipse.org/jdtls/).',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
server: 'sourcekit-lsp',
|
|
24
|
+
languageId: 'swift',
|
|
25
|
+
hint: 'Install Xcode or Xcode Command Line Tools (`xcode-select --install`); sourcekit-lsp ships at /usr/bin/sourcekit-lsp on macOS.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
server: 'csharp-ls',
|
|
29
|
+
languageId: 'csharp',
|
|
30
|
+
hint: 'Install .NET SDK, then `dotnet tool install -g csharp-ls` (adds csharp-ls to ~/.dotnet/tools).',
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
const TOOLCHAIN_INSTALL_HINTS = Object.fromEntries(TOOLCHAIN_SERVERS.map(t => [t.languageId, t.hint]));
|
|
34
|
+
/** Honest, actionable guidance for a file whose server did not resolve. */
|
|
35
|
+
export function unavailableHintFor(languageId, command) {
|
|
36
|
+
const toolchain = languageId ? TOOLCHAIN_INSTALL_HINTS[languageId] : undefined;
|
|
37
|
+
if (toolchain)
|
|
38
|
+
return toolchain;
|
|
39
|
+
const manifest = command ? manifestInstallHint(command) : null;
|
|
40
|
+
if (manifest)
|
|
41
|
+
return manifest;
|
|
42
|
+
return LSP_UNAVAILABLE_HINT;
|
|
43
|
+
}
|
|
13
44
|
const POOL_IDLE_TIMEOUT_MS = parseInt(process.env.OCTOCODE_LSP_POOL_IDLE_MS || '60000', 10);
|
|
14
45
|
// Languages whose servers emit $/progress notifications and need waitForReady.
|
|
15
|
-
// TypeScript, Python, C/C++, and data-format servers (JSON/YAML/
|
|
46
|
+
// TypeScript, Python, C/C++, and data-format servers (JSON/YAML/HTML/CSS)
|
|
16
47
|
// answer queries immediately after the LSP handshake — skipping waitForReady
|
|
17
48
|
// avoids burning the 2-second SETTLE_MS window for them.
|
|
18
49
|
const PROGRESS_LANGUAGES = new Set([
|
|
19
50
|
'go',
|
|
20
51
|
'rust',
|
|
21
52
|
'java',
|
|
22
|
-
'kotlin',
|
|
23
|
-
'swift',
|
|
24
53
|
'csharp',
|
|
25
|
-
'
|
|
26
|
-
'erlang',
|
|
54
|
+
'swift',
|
|
27
55
|
]);
|
|
28
56
|
// Per-language upper bound for $/progress drain (ms).
|
|
29
57
|
// These are ceilings — waitForReady returns as soon as the server goes idle.
|
|
@@ -31,20 +59,31 @@ const SERVER_READY_TIMEOUT_MS = {
|
|
|
31
59
|
go: 15_000,
|
|
32
60
|
rust: 60_000,
|
|
33
61
|
java: 120_000,
|
|
34
|
-
kotlin: 60_000,
|
|
35
|
-
swift: 30_000,
|
|
36
62
|
csharp: 30_000,
|
|
37
|
-
|
|
38
|
-
erlang: 30_000,
|
|
63
|
+
swift: 30_000,
|
|
39
64
|
};
|
|
40
65
|
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
41
66
|
function readyTimeoutForLanguage(languageId) {
|
|
42
67
|
return SERVER_READY_TIMEOUT_MS[languageId] ?? DEFAULT_READY_TIMEOUT_MS;
|
|
43
68
|
}
|
|
69
|
+
// Eliminates the double getLanguageServerForFile call that would otherwise
|
|
70
|
+
// happen once in poolKeyForFile (to build the key) and again inside the factory
|
|
71
|
+
// (to create the client). poolKeyForFile deposits the already-resolved config
|
|
72
|
+
// here before calling sharedPool.acquire; the factory reads and clears it.
|
|
73
|
+
//
|
|
74
|
+
// The deposit is conditional: when sharedPool already has an entry or an
|
|
75
|
+
// inflight promise for this key, acquire() returns the cached client or the
|
|
76
|
+
// existing promise WITHOUT invoking the factory — so depositing again here
|
|
77
|
+
// would never be read or cleared and would leak the entry forever. Key format
|
|
78
|
+
// is the shared serializeKey from lspClientPool.ts.
|
|
79
|
+
const _pendingConfigs = new Map();
|
|
44
80
|
const sharedPool = new LspClientPool({
|
|
45
81
|
idleTimeoutMs: POOL_IDLE_TIMEOUT_MS,
|
|
46
82
|
factory: async (key) => {
|
|
47
|
-
const
|
|
83
|
+
const cacheKey = serializeKey(key);
|
|
84
|
+
const serverConfig = _pendingConfigs.get(cacheKey) ??
|
|
85
|
+
(await getLanguageServerForFile(synthesizeFilePathForKey(key), key.workspaceRoot));
|
|
86
|
+
_pendingConfigs.delete(cacheKey);
|
|
48
87
|
if (!serverConfig)
|
|
49
88
|
return null;
|
|
50
89
|
const client = new LSPClient(serverConfig);
|
|
@@ -96,20 +135,20 @@ export async function getLspStatus(input = {}) {
|
|
|
96
135
|
};
|
|
97
136
|
}
|
|
98
137
|
const workspaceRoot = input.workspaceRoot ?? (await resolveWorkspaceRootForFile(input.filePath));
|
|
99
|
-
const
|
|
100
|
-
const languageId =
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
: false;
|
|
138
|
+
const resolution = await resolveServerForFile(input.filePath, workspaceRoot);
|
|
139
|
+
const languageId = resolution?.config.languageId;
|
|
140
|
+
const serverSource = resolution?.source ?? 'unavailable';
|
|
141
|
+
const serverAvailable = serverSource !== 'unavailable';
|
|
104
142
|
return {
|
|
105
143
|
...base,
|
|
106
144
|
filePath: input.filePath,
|
|
107
145
|
workspaceRoot,
|
|
108
146
|
languageId,
|
|
109
147
|
serverAvailable,
|
|
148
|
+
serverSource,
|
|
110
149
|
hints: serverAvailable
|
|
111
|
-
? [
|
|
112
|
-
: [
|
|
150
|
+
? [`Language server resolved for this file (source: ${serverSource}).`]
|
|
151
|
+
: [unavailableHintFor(languageId, resolution?.config.command)],
|
|
113
152
|
};
|
|
114
153
|
}
|
|
115
154
|
export function pooledClientCount() {
|
|
@@ -122,10 +161,18 @@ async function poolKeyForFile(workspaceRoot, filePath) {
|
|
|
122
161
|
const serverConfig = await getLanguageServerForFile(filePath, workspaceRoot);
|
|
123
162
|
if (!serverConfig)
|
|
124
163
|
return null;
|
|
125
|
-
|
|
164
|
+
const key = {
|
|
126
165
|
workspaceRoot,
|
|
127
166
|
filePath,
|
|
128
167
|
languageId: serverConfig.languageId ?? 'unknown',
|
|
129
168
|
serverId: `${serverConfig.command} ${(serverConfig.args ?? []).join(' ')}`.trim(),
|
|
130
169
|
};
|
|
170
|
+
const serialized = serializeKey(key);
|
|
171
|
+
// Only deposit when the pool will actually call the factory for this key.
|
|
172
|
+
// If there's already an entry or inflight, acquire() won't start a new factory
|
|
173
|
+
// run — so depositing here would permanently leak the entry.
|
|
174
|
+
if (!sharedPool.has(key)) {
|
|
175
|
+
_pendingConfigs.set(serialized, serverConfig);
|
|
176
|
+
}
|
|
177
|
+
return key;
|
|
131
178
|
}
|
package/dist/lsp/native.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { LspReadiness } from './types.js';
|
|
1
2
|
export type NativeLspClientBinding = {
|
|
2
3
|
start(): Promise<void>;
|
|
3
4
|
stop(): Promise<void>;
|
|
4
|
-
waitForReady(timeoutMs?: number): Promise<
|
|
5
|
+
waitForReady(timeoutMs?: number): Promise<LspReadiness | undefined>;
|
|
5
6
|
hasCapability?(capability: string): boolean;
|
|
6
7
|
getRecentStderr?(): string[];
|
|
7
8
|
openDocument(filePath: string, content: string): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical `{os}-{arch}[-musl]` platform identifier, matching the npm
|
|
3
|
+
* native-addon convention (the same keys esbuild / swc / the engine's own
|
|
4
|
+
* optionalDependencies use): `darwin-arm64`, `darwin-x64`, `linux-x64`,
|
|
5
|
+
* `linux-arm64`, `linux-x64-musl`, `linux-arm64-musl`, `win32-x64`,
|
|
6
|
+
* `win32-arm64`. This is the key the server download manifest is keyed on.
|
|
7
|
+
*/
|
|
8
|
+
export type PlatformId = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64' | 'linux-x64-musl' | 'linux-arm64-musl' | 'win32-x64' | 'win32-arm64';
|
|
9
|
+
/** True when the current Linux runtime links musl libc (Alpine etc.). */
|
|
10
|
+
export declare function isMuslLinux(): boolean;
|
|
11
|
+
/** Resolve (and cache) the canonical platform id for this machine. */
|
|
12
|
+
export declare function detectPlatformId(): PlatformId;
|
|
13
|
+
/**
|
|
14
|
+
* Executable file-name candidates for a bare command on this OS. On Windows a
|
|
15
|
+
* server may exist only as `gopls.exe` / `pyright.cmd`, so we expand against
|
|
16
|
+
* `PATHEXT`; on POSIX the bare name is the only candidate.
|
|
17
|
+
*/
|
|
18
|
+
export declare function executableNames(command: string): string[];
|
|
19
|
+
/** First existing path among `dir/<name>` for every executable-name candidate. */
|
|
20
|
+
export declare function firstExecutableIn(dir: string, command: string, join: (dir: string, name: string) => string): string | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
let cachedPlatformId;
|
|
3
|
+
/** True when the current Linux runtime links musl libc (Alpine etc.). */
|
|
4
|
+
export function isMuslLinux() {
|
|
5
|
+
if (process.platform !== 'linux')
|
|
6
|
+
return false;
|
|
7
|
+
// 1) Fastest, in-process: glibc runtime version is absent on musl.
|
|
8
|
+
try {
|
|
9
|
+
const report = process.report?.getReport();
|
|
10
|
+
if (report?.header) {
|
|
11
|
+
return report.header.glibcVersionRuntime == null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// process.report unavailable — fall through to the loader probe.
|
|
16
|
+
}
|
|
17
|
+
// 2) Dynamic-loader probe: musl ships `/lib/ld-musl-*.so.1`.
|
|
18
|
+
try {
|
|
19
|
+
return readdirSync('/lib').some(name => name.startsWith('ld-musl-'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Resolve (and cache) the canonical platform id for this machine. */
|
|
26
|
+
export function detectPlatformId() {
|
|
27
|
+
if (cachedPlatformId)
|
|
28
|
+
return cachedPlatformId;
|
|
29
|
+
const arch = process.arch === 'x64' ? 'x64' : 'arm64';
|
|
30
|
+
if (process.platform === 'darwin') {
|
|
31
|
+
cachedPlatformId = `darwin-${arch}`;
|
|
32
|
+
}
|
|
33
|
+
else if (process.platform === 'win32') {
|
|
34
|
+
cachedPlatformId = `win32-${arch}`;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const suffix = isMuslLinux() ? '-musl' : '';
|
|
38
|
+
cachedPlatformId = `linux-${arch}${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
return cachedPlatformId;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Executable file-name candidates for a bare command on this OS. On Windows a
|
|
44
|
+
* server may exist only as `gopls.exe` / `pyright.cmd`, so we expand against
|
|
45
|
+
* `PATHEXT`; on POSIX the bare name is the only candidate.
|
|
46
|
+
*/
|
|
47
|
+
export function executableNames(command) {
|
|
48
|
+
if (process.platform !== 'win32')
|
|
49
|
+
return [command];
|
|
50
|
+
const pathext = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
|
|
51
|
+
.split(';')
|
|
52
|
+
.map(ext => ext.trim().toLowerCase())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
return [command, ...pathext.map(ext => `${command}${ext}`)];
|
|
55
|
+
}
|
|
56
|
+
/** First existing path among `dir/<name>` for every executable-name candidate. */
|
|
57
|
+
export function firstExecutableIn(dir, command, join) {
|
|
58
|
+
for (const name of executableNames(command)) {
|
|
59
|
+
const candidate = join(dir, name);
|
|
60
|
+
if (existsSync(candidate))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
package/dist/lsp/resolver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { access, constants, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nativeBinding } from './native.js';
|
|
4
4
|
export class SymbolResolutionError extends Error {
|
|
@@ -7,7 +7,7 @@ export class SymbolResolutionError extends Error {
|
|
|
7
7
|
reason;
|
|
8
8
|
searchRadius;
|
|
9
9
|
constructor(symbolName, lineHint, reason, searchRadius = 5) {
|
|
10
|
-
super(`Could not find symbol '${symbolName}' at or near line ${lineHint}
|
|
10
|
+
super(`Could not find symbol '${symbolName}' at or near line ${lineHint}.${reason ? ` ${reason}` : ''}`);
|
|
11
11
|
this.symbolName = symbolName;
|
|
12
12
|
this.lineHint = lineHint;
|
|
13
13
|
this.reason = reason;
|
|
@@ -27,7 +27,13 @@ function normalizeResolvedSymbol(value) {
|
|
|
27
27
|
function toSymbolResolutionError(error, fuzzy, searchRadius = 5) {
|
|
28
28
|
if (error instanceof SymbolResolutionError)
|
|
29
29
|
return error;
|
|
30
|
-
const
|
|
30
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
31
|
+
// The native resolver already emits the canonical "Could not find symbol …"
|
|
32
|
+
// sentence; strip it so the wrapper message doesn't repeat it.
|
|
33
|
+
const prefix = `Could not find symbol '${fuzzy.symbolName}' at or near line ${fuzzy.lineHint ?? 0}`;
|
|
34
|
+
const reason = raw.startsWith(prefix)
|
|
35
|
+
? raw.slice(prefix.length).replace(/^[.\s]+/, '')
|
|
36
|
+
: raw;
|
|
31
37
|
return new SymbolResolutionError(fuzzy.symbolName, fuzzy.lineHint ?? 0, reason, searchRadius);
|
|
32
38
|
}
|
|
33
39
|
export function resolveSymbolPosition(fileOrContent, fuzzyOrSymbolName, lineHint, orderHint) {
|
|
@@ -152,7 +158,7 @@ async function resolveLocalModulePath(importerPath, moduleSpecifier) {
|
|
|
152
158
|
];
|
|
153
159
|
for (const candidate of candidates) {
|
|
154
160
|
try {
|
|
155
|
-
await
|
|
161
|
+
await access(candidate, constants.R_OK);
|
|
156
162
|
return candidate;
|
|
157
163
|
}
|
|
158
164
|
catch {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platformized language-server discovery.
|
|
3
|
+
*
|
|
4
|
+
* The native layer (`config.rs`) already resolves explicit overrides
|
|
5
|
+
* (`OCTOCODE_*_SERVER_PATH`, `.octocode/lsp-servers.json`) and anything on
|
|
6
|
+
* `PATH` via `which`. This module covers the gap that bites in production: a
|
|
7
|
+
* server IS installed, but in a per-ecosystem directory that a GUI-launched
|
|
8
|
+
* IDE's (or a bare `spawn`'s) impoverished `PATH` never sees — `~/.cargo/bin`,
|
|
9
|
+
* `~/go/bin`, `~/.local/bin`, the npm/pnpm global prefixes, the Neovim `mason`
|
|
10
|
+
* dir, the Homebrew prefix, version-manager shims, and the per-OS Windows
|
|
11
|
+
* shim dirs.
|
|
12
|
+
*
|
|
13
|
+
* ## Performance (CLI-critical)
|
|
14
|
+
*
|
|
15
|
+
* `lsp-server list` calls `discoverServer` for every known server with the
|
|
16
|
+
* same workspaceRoot, so cold-start cost matters. Three caches work together:
|
|
17
|
+
*
|
|
18
|
+
* 1. `_discoveryCache` — result per `(command, workspaceRoot)`. After the
|
|
19
|
+
* first call, every subsequent call for the same pair is O(1): zero fs ops.
|
|
20
|
+
*
|
|
21
|
+
* 2. `_existingEcoDirs` — ecosystem dirs filtered to only those that
|
|
22
|
+
* exist on disk. Computed once per process (or after `clearDiscoveryCache`).
|
|
23
|
+
* Turns ~15 dirs × N servers into ~15 stats once + only real-dir probes.
|
|
24
|
+
*
|
|
25
|
+
* 3. `_projectLocalDirs` — resolved project-local path lists per workspace
|
|
26
|
+
* root (avoids re-walking the directory tree).
|
|
27
|
+
*
|
|
28
|
+
* Call `clearDiscoveryCache()` after installing a server mid-session.
|
|
29
|
+
*
|
|
30
|
+
* Search order (first hit wins): project-local → known ecosystem dirs →
|
|
31
|
+
* IDE-bundled extension servers. Returns an absolute path plus a provenance
|
|
32
|
+
* label, or `null` if nothing on the machine provides the command.
|
|
33
|
+
*/
|
|
34
|
+
export type DiscoverySource = 'project-local' | `ecosystem:${string}` | 'ide-bundled';
|
|
35
|
+
export interface DiscoveredServer {
|
|
36
|
+
command: string;
|
|
37
|
+
source: DiscoverySource;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Invalidate all discovery caches. Call after programmatically installing a
|
|
41
|
+
* server (e.g. `provisionServer`) or in tests between scenarios.
|
|
42
|
+
*/
|
|
43
|
+
export declare function clearDiscoveryCache(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Locate `command` on this machine outside of `PATH`. `command` is the bare
|
|
46
|
+
* server name (e.g. `rust-analyzer`, `gopls`); absolute commands are returned
|
|
47
|
+
* by the caller before this is reached.
|
|
48
|
+
*
|
|
49
|
+
* Results are memoised for the process lifetime. Call `clearDiscoveryCache()`
|
|
50
|
+
* after installing a server mid-session.
|
|
51
|
+
*/
|
|
52
|
+
export declare function discoverServer(command: string, workspaceRoot: string): DiscoveredServer | null;
|
|
53
|
+
/**
|
|
54
|
+
* Discover multiple servers in one call, sharing the ecosystem-dir pre-filter
|
|
55
|
+
* and project-local dir computation across all commands. Results are written
|
|
56
|
+
* into the shared cache, so subsequent `discoverServer` calls for the same
|
|
57
|
+
* `(command, workspaceRoot)` pair are O(1).
|
|
58
|
+
*
|
|
59
|
+
* Equivalent to calling `discoverServer` for each command individually, but
|
|
60
|
+
* avoids redundant work when the caller needs results for many commands against
|
|
61
|
+
* the same workspace (e.g. the `lsp-server list` CLI command).
|
|
62
|
+
*/
|
|
63
|
+
export declare function discoverServerBatch(commands: string[], workspaceRoot: string): Record<string, DiscoveredServer | null>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { detectIdeContext } from './ideContext.js';
|
|
5
|
+
import { firstExecutableIn } from './platform.js';
|
|
6
|
+
/** Per-command-per-workspace result. `undefined` = not yet looked up. */
|
|
7
|
+
const _discoveryCache = new Map();
|
|
8
|
+
/** Ecosystem dirs that actually exist; `null` = not yet computed. */
|
|
9
|
+
let _existingEcoDirs = null;
|
|
10
|
+
/** Project-local dir lists per resolved workspace root. */
|
|
11
|
+
const _projectLocalDirs = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Invalidate all discovery caches. Call after programmatically installing a
|
|
14
|
+
* server (e.g. `provisionServer`) or in tests between scenarios.
|
|
15
|
+
*/
|
|
16
|
+
export function clearDiscoveryCache() {
|
|
17
|
+
_discoveryCache.clear();
|
|
18
|
+
_existingEcoDirs = null;
|
|
19
|
+
_projectLocalDirs.clear();
|
|
20
|
+
}
|
|
21
|
+
const HOME = homedir();
|
|
22
|
+
const join = path.join;
|
|
23
|
+
function envPath(...segments) {
|
|
24
|
+
return join(...segments);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every ecosystem `bin` directory worth probing on this OS, paired with a
|
|
28
|
+
* label for provenance. Computed from env vars + canonical defaults — no
|
|
29
|
+
* subprocess spawning, so it stays cheap to call per resolution.
|
|
30
|
+
*/
|
|
31
|
+
function ecosystemBinDirs() {
|
|
32
|
+
const env = process.env;
|
|
33
|
+
const out = [];
|
|
34
|
+
const add = (dir, label) => {
|
|
35
|
+
if (dir)
|
|
36
|
+
out.push({ dir, label });
|
|
37
|
+
};
|
|
38
|
+
if (process.platform === 'win32') {
|
|
39
|
+
const appData = env.APPDATA;
|
|
40
|
+
const localAppData = env.LOCALAPPDATA;
|
|
41
|
+
add(env.USERPROFILE && envPath(env.USERPROFILE, '.cargo', 'bin'), 'cargo');
|
|
42
|
+
add(env.USERPROFILE && envPath(env.USERPROFILE, 'go', 'bin'), 'go');
|
|
43
|
+
// Go Windows installer default: C:\Go\bin (distinct from %USERPROFILE%\go\bin)
|
|
44
|
+
add('C:\\Go\\bin', 'go');
|
|
45
|
+
// LLVM/clangd: official Windows installer defaults
|
|
46
|
+
add('C:\\Program Files\\LLVM\\bin', 'llvm');
|
|
47
|
+
add('C:\\Program Files (x86)\\LLVM\\bin', 'llvm');
|
|
48
|
+
// .NET global tools (csharp-ls, OmniSharp, etc.)
|
|
49
|
+
add(env.USERPROFILE && envPath(env.USERPROFILE, '.dotnet', 'tools'), 'dotnet');
|
|
50
|
+
add(appData && envPath(appData, 'npm'), 'npm-global');
|
|
51
|
+
add(localAppData && envPath(localAppData, 'pnpm'), 'pnpm');
|
|
52
|
+
add(env.USERPROFILE && envPath(env.USERPROFILE, 'scoop', 'shims'), 'scoop');
|
|
53
|
+
add(env.ProgramData && envPath(env.ProgramData, 'chocolatey', 'bin'), 'choco');
|
|
54
|
+
add(localAppData && envPath(localAppData, 'Microsoft', 'WinGet', 'Links'), 'winget');
|
|
55
|
+
add(localAppData && envPath(localAppData, 'nvim-data', 'mason', 'bin'), 'mason');
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
// Rust
|
|
59
|
+
add(envPath(HOME, '.cargo', 'bin'), 'cargo');
|
|
60
|
+
// Go
|
|
61
|
+
add(env.GOBIN, 'go');
|
|
62
|
+
add(env.GOPATH ? envPath(env.GOPATH, 'bin') : undefined, 'go');
|
|
63
|
+
add(envPath(HOME, 'go', 'bin'), 'go');
|
|
64
|
+
// Python user installs
|
|
65
|
+
add(envPath(HOME, '.local', 'bin'), 'python-user');
|
|
66
|
+
// Node global (env-driven prefixes + canonical defaults)
|
|
67
|
+
add(env.npm_config_prefix ? envPath(env.npm_config_prefix, 'bin') : undefined, 'npm-global');
|
|
68
|
+
add(env.PNPM_HOME, 'pnpm');
|
|
69
|
+
add(envPath(HOME, '.volta', 'bin'), 'volta');
|
|
70
|
+
add(envPath(HOME, '.asdf', 'shims'), 'asdf');
|
|
71
|
+
add(envPath(HOME, '.local', 'share', 'mise', 'shims'), 'mise');
|
|
72
|
+
// Neovim mason (high-value on dev machines)
|
|
73
|
+
add(envPath(HOME, '.local', 'share', 'nvim', 'mason', 'bin'), 'mason');
|
|
74
|
+
// Homebrew (Apple Silicon first — the #1 "installed but not found" cause)
|
|
75
|
+
add('/opt/homebrew/bin', 'homebrew');
|
|
76
|
+
add('/usr/local/bin', 'homebrew');
|
|
77
|
+
add('/home/linuxbrew/.linuxbrew/bin', 'homebrew');
|
|
78
|
+
// System binary dirs: clangd ships in /usr/bin on macOS (Xcode CLI tools) and
|
|
79
|
+
// on Linux distros that install it via the package manager. Check after homebrew
|
|
80
|
+
// so a homebrew-managed version takes precedence over the system one.
|
|
81
|
+
add('/usr/bin', 'system');
|
|
82
|
+
// macOS framework Python: ~/Library/Python/<X.Y>/bin
|
|
83
|
+
if (process.platform === 'darwin') {
|
|
84
|
+
add(envPath(HOME, 'Library', 'Python'), 'python-framework');
|
|
85
|
+
}
|
|
86
|
+
// .NET global tools (csharp-ls, OmniSharp, etc.)
|
|
87
|
+
add(envPath(HOME, '.dotnet', 'tools'), 'dotnet');
|
|
88
|
+
// Active virtualenv / conda
|
|
89
|
+
add(env.VIRTUAL_ENV ? envPath(env.VIRTUAL_ENV, 'bin') : undefined, 'venv');
|
|
90
|
+
add(env.CONDA_PREFIX ? envPath(env.CONDA_PREFIX, 'bin') : undefined, 'conda');
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Ecosystem dirs that exist on this machine, cached for the process lifetime.
|
|
95
|
+
* Pre-filtering eliminates stat calls for non-existent dirs on every lookup —
|
|
96
|
+
* on a typical dev machine this shrinks the probe list from ~15 to ~5.
|
|
97
|
+
*/
|
|
98
|
+
function existingEcoDirs() {
|
|
99
|
+
if (!_existingEcoDirs) {
|
|
100
|
+
_existingEcoDirs = ecosystemBinDirs().filter(({ dir }) => existsSync(dir));
|
|
101
|
+
}
|
|
102
|
+
return _existingEcoDirs;
|
|
103
|
+
}
|
|
104
|
+
/** Project-local server dirs, walking up from the workspace root. */
|
|
105
|
+
function buildProjectLocalDirs(workspaceRoot) {
|
|
106
|
+
const dirs = [];
|
|
107
|
+
let current = workspaceRoot; // already resolved by callers
|
|
108
|
+
for (;;) {
|
|
109
|
+
dirs.push(join(current, 'node_modules', '.bin'));
|
|
110
|
+
const parent = path.dirname(current);
|
|
111
|
+
if (parent === current)
|
|
112
|
+
break;
|
|
113
|
+
current = parent;
|
|
114
|
+
}
|
|
115
|
+
const venvBin = process.platform === 'win32' ? 'Scripts' : 'bin';
|
|
116
|
+
dirs.push(join(workspaceRoot, '.venv', venvBin));
|
|
117
|
+
if (process.env.VIRTUAL_ENV)
|
|
118
|
+
dirs.push(join(process.env.VIRTUAL_ENV, venvBin));
|
|
119
|
+
return dirs;
|
|
120
|
+
}
|
|
121
|
+
function cachedProjectLocalDirs(resolvedRoot) {
|
|
122
|
+
let dirs = _projectLocalDirs.get(resolvedRoot);
|
|
123
|
+
if (!dirs) {
|
|
124
|
+
dirs = buildProjectLocalDirs(resolvedRoot);
|
|
125
|
+
_projectLocalDirs.set(resolvedRoot, dirs);
|
|
126
|
+
}
|
|
127
|
+
return dirs;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* VS Code (and fork) extension roots whose `<ext>/server/` dirs may hold a
|
|
131
|
+
* reusable server. Skipped unless we detect an IDE host.
|
|
132
|
+
*/
|
|
133
|
+
function ideExtensionRoots() {
|
|
134
|
+
const { isIde, host } = detectIdeContext();
|
|
135
|
+
if (!isIde)
|
|
136
|
+
return [];
|
|
137
|
+
const roots = {
|
|
138
|
+
vscode: join(HOME, '.vscode', 'extensions'),
|
|
139
|
+
cursor: join(HOME, '.cursor', 'extensions'),
|
|
140
|
+
windsurf: join(HOME, '.windsurf', 'extensions'),
|
|
141
|
+
};
|
|
142
|
+
const candidates = [roots[host], join(HOME, '.vscode-server', 'extensions')].filter((dir) => Boolean(dir));
|
|
143
|
+
return candidates.filter(existsSync);
|
|
144
|
+
}
|
|
145
|
+
function resolveCommand(command) {
|
|
146
|
+
return path.basename(command);
|
|
147
|
+
}
|
|
148
|
+
function resolveRoot(workspaceRoot) {
|
|
149
|
+
return path.resolve(workspaceRoot);
|
|
150
|
+
}
|
|
151
|
+
function cacheKey(command, resolvedRoot) {
|
|
152
|
+
return `${command}\0${resolvedRoot}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Core lookup — runs only on cache miss. Assumes `command` is a basename and
|
|
156
|
+
* `resolvedRoot` is an absolute path.
|
|
157
|
+
*/
|
|
158
|
+
function scan(command, resolvedRoot) {
|
|
159
|
+
if (!command)
|
|
160
|
+
return null;
|
|
161
|
+
// 1) Project-local — matches the project's pinned toolchain.
|
|
162
|
+
for (const dir of cachedProjectLocalDirs(resolvedRoot)) {
|
|
163
|
+
const hit = firstExecutableIn(dir, command, join);
|
|
164
|
+
if (hit)
|
|
165
|
+
return { command: hit, source: 'project-local' };
|
|
166
|
+
}
|
|
167
|
+
// 2) Known ecosystem dirs (pre-filtered to existing).
|
|
168
|
+
for (const { dir, label } of existingEcoDirs()) {
|
|
169
|
+
const hit = firstExecutableIn(dir, command, join);
|
|
170
|
+
if (hit)
|
|
171
|
+
return { command: hit, source: `ecosystem:${label}` };
|
|
172
|
+
}
|
|
173
|
+
// 3) IDE-bundled extension servers (only when hosted in an IDE).
|
|
174
|
+
for (const root of ideExtensionRoots()) {
|
|
175
|
+
if (existsSync(root)) {
|
|
176
|
+
// No safe generic launch — see LSP_GUIDE.md "IDE reuse".
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Locate `command` on this machine outside of `PATH`. `command` is the bare
|
|
184
|
+
* server name (e.g. `rust-analyzer`, `gopls`); absolute commands are returned
|
|
185
|
+
* by the caller before this is reached.
|
|
186
|
+
*
|
|
187
|
+
* Results are memoised for the process lifetime. Call `clearDiscoveryCache()`
|
|
188
|
+
* after installing a server mid-session.
|
|
189
|
+
*/
|
|
190
|
+
export function discoverServer(command, workspaceRoot) {
|
|
191
|
+
const base = resolveCommand(command);
|
|
192
|
+
const resolved = resolveRoot(workspaceRoot);
|
|
193
|
+
const key = cacheKey(base, resolved);
|
|
194
|
+
if (_discoveryCache.has(key)) {
|
|
195
|
+
return _discoveryCache.get(key) ?? null;
|
|
196
|
+
}
|
|
197
|
+
const result = scan(base, resolved);
|
|
198
|
+
_discoveryCache.set(key, result);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Discover multiple servers in one call, sharing the ecosystem-dir pre-filter
|
|
203
|
+
* and project-local dir computation across all commands. Results are written
|
|
204
|
+
* into the shared cache, so subsequent `discoverServer` calls for the same
|
|
205
|
+
* `(command, workspaceRoot)` pair are O(1).
|
|
206
|
+
*
|
|
207
|
+
* Equivalent to calling `discoverServer` for each command individually, but
|
|
208
|
+
* avoids redundant work when the caller needs results for many commands against
|
|
209
|
+
* the same workspace (e.g. the `lsp-server list` CLI command).
|
|
210
|
+
*/
|
|
211
|
+
export function discoverServerBatch(commands, workspaceRoot) {
|
|
212
|
+
const resolved = resolveRoot(workspaceRoot);
|
|
213
|
+
const results = {};
|
|
214
|
+
for (const command of commands) {
|
|
215
|
+
const base = resolveCommand(command);
|
|
216
|
+
const key = cacheKey(base, resolved);
|
|
217
|
+
if (_discoveryCache.has(key)) {
|
|
218
|
+
results[command] = _discoveryCache.get(key) ?? null;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const result = scan(base, resolved);
|
|
222
|
+
_discoveryCache.set(key, result);
|
|
223
|
+
results[command] = result;
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type PlatformId } from './platform.js';
|
|
2
|
+
/**
|
|
3
|
+
* Download manifest for portable, toolchain-free language servers (the
|
|
4
|
+
* AUTO-DOWNLOAD-OK class). `serverManifest.json` is the human-authored source;
|
|
5
|
+
* `serverManifestData.ts` mirrors it as a TS module so the compiled dist needs
|
|
6
|
+
* no JSON-copy step. This module is the typed loader + per-platform selector +
|
|
7
|
+
* managed-cache locator.
|
|
8
|
+
*
|
|
9
|
+
* Provisioning policy (see docs/context/LSP_GUIDE.md):
|
|
10
|
+
* OCTOCODE_LSP_AUTO_INSTALL = prompt (default) | off | auto
|
|
11
|
+
* Live network download is gated and requires a pinned `sha256` per asset.
|
|
12
|
+
* Until SHAs are pinned the manifest still drives (a) honest detect-and-instruct
|
|
13
|
+
* guidance and (b) reuse of a server a user/CI has pre-populated into the
|
|
14
|
+
* managed cache `~/.octocode/lsp/<server>/<releaseTag>/<binName>`.
|
|
15
|
+
*/
|
|
16
|
+
export type ArchiveKind = 'none' | 'gz' | 'zip' | 'tar.gz' | 'tar.xz';
|
|
17
|
+
export interface ManifestAsset {
|
|
18
|
+
url: string;
|
|
19
|
+
archive: ArchiveKind;
|
|
20
|
+
binName: string;
|
|
21
|
+
/** Path of the executable inside the archive (zip/tar); absent for gz/none. */
|
|
22
|
+
binPath?: string;
|
|
23
|
+
/** SHA-256 of the downloaded asset; download is refused while this is null. */
|
|
24
|
+
sha256: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface ManifestServer {
|
|
27
|
+
languageId: string;
|
|
28
|
+
repo: string;
|
|
29
|
+
releaseTag: string;
|
|
30
|
+
launchArgs?: string[];
|
|
31
|
+
downloadHost?: string;
|
|
32
|
+
platforms: Partial<Record<PlatformId, ManifestAsset>>;
|
|
33
|
+
unsupportedPlatforms?: Partial<Record<PlatformId, string>>;
|
|
34
|
+
}
|
|
35
|
+
export interface ManifestFile {
|
|
36
|
+
$comment?: string;
|
|
37
|
+
version: number;
|
|
38
|
+
servers: Record<string, ManifestServer>;
|
|
39
|
+
}
|
|
40
|
+
export type ProvisionMode = 'off' | 'prompt' | 'auto';
|
|
41
|
+
/** The configured auto-install policy. Defaults to `prompt` when unset (asks before downloading). Set OCTOCODE_LSP_AUTO_INSTALL=off to disable all downloads, or =auto to skip the prompt. */
|
|
42
|
+
export declare function provisionMode(env?: NodeJS.ProcessEnv): ProvisionMode;
|
|
43
|
+
/** The manifest entry for a server, keyed by its bare command name. */
|
|
44
|
+
export declare function manifestServer(serverName: string): ManifestServer | null;
|
|
45
|
+
/** Every auto-downloadable server in the manifest (for `lsp-server list`). */
|
|
46
|
+
export declare function listManifestServers(): Array<{
|
|
47
|
+
name: string;
|
|
48
|
+
languageId: string;
|
|
49
|
+
releaseTag: string;
|
|
50
|
+
}>;
|
|
51
|
+
/** Whether the manifest can, in principle, auto-provide this server. */
|
|
52
|
+
export declare function isAutoDownloadable(serverName: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Root of the managed server cache. Defaults to `~/.octocode/lsp` (consistent
|
|
55
|
+
* with the rest of octocode's home), overridable via `OCTOCODE_LSP_CACHE_DIR`
|
|
56
|
+
* for read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
|
|
57
|
+
*/
|
|
58
|
+
export declare function managedCacheRoot(env?: NodeJS.ProcessEnv): string;
|
|
59
|
+
/** Where a provisioned binary lives once installed/extracted. */
|
|
60
|
+
export declare function cachedServerBinPath(serverName: string, platformId?: PlatformId): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* If a managed binary is already present AND verified in the cache (downloaded
|
|
63
|
+
* by a prior run, by CI, or pre-baked), return its absolute path. A binary is
|
|
64
|
+
* only trusted when its sibling `<binName>.ok` completion marker contains the
|
|
65
|
+
* current binary's SHA-256 and size. A half-written or tampered binary from an
|
|
66
|
+
* interrupted install has no matching marker and is ignored, so it never
|
|
67
|
+
* resolves as "installed". Read-only — always safe to call.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveCachedServer(serverName: string, platformId?: PlatformId): string | null;
|
|
70
|
+
/** Human-readable provisioning guidance for a server with no resolved binary. */
|
|
71
|
+
export declare function manifestInstallHint(serverName: string): string | null;
|