march-cli 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/agent/editing/lsp-report.mjs +69 -0
- package/src/agent/file-edit-tool.mjs +10 -24
- package/src/agent/model-payload-dumper.mjs +11 -4
- package/src/agent/runner/runner-utils.mjs +18 -0
- package/src/agent/runner.mjs +29 -28
- package/src/agent/runtime/runner-runtime-host.mjs +2 -0
- package/src/agent/turn/turn-logging.mjs +30 -0
- package/src/agent/turn/turn-runner.mjs +40 -0
- package/src/cli/commands/status-command.mjs +45 -0
- package/src/cli/permissions.mjs +1 -1
- package/src/cli/startup/runtime-close.mjs +23 -0
- package/src/cli/status-line-updater.mjs +1 -0
- package/src/cli/tui/tool-rendering.mjs +1 -1
- package/src/config/loader.mjs +28 -1
- package/src/debug/logger.mjs +141 -0
- package/src/lsp/client.mjs +2 -2
- package/src/lsp/diagnostic-store.mjs +5 -2
- package/src/{context/diagnostics.mjs → lsp/diagnostics-format.mjs} +6 -4
- package/src/lsp/managed-node-server.mjs +94 -0
- package/src/lsp/path-match.mjs +10 -0
- package/src/lsp/servers.mjs +97 -21
- package/src/lsp/service.mjs +57 -12
- package/src/lsp/status-message.mjs +9 -0
- package/src/lsp/typescript-project-resolver.mjs +186 -0
- package/src/main.mjs +17 -24
- package/src/platform/spawn-command.mjs +27 -0
- package/src/provider/hosted-tools.mjs +111 -0
- package/src/web/tools.mjs +2 -2
package/src/config/loader.mjs
CHANGED
|
@@ -46,13 +46,20 @@ function mergeLayers(layers) {
|
|
|
46
46
|
serviceTier: null,
|
|
47
47
|
providers: {},
|
|
48
48
|
webSearch: { provider: null, providers: {} },
|
|
49
|
+
hostedTools: {
|
|
50
|
+
openai: { webSearch: "auto" },
|
|
51
|
+
openaiCodex: { webSearch: "auto" },
|
|
52
|
+
azureOpenai: { webSearch: "auto" },
|
|
53
|
+
anthropic: { webSearch: "auto" },
|
|
54
|
+
google: { webSearch: "auto" },
|
|
55
|
+
xai: { webSearch: "auto", xSearch: "auto" },
|
|
56
|
+
},
|
|
49
57
|
network: { proxy: "system", ca: "system" },
|
|
50
58
|
maxTurns: null,
|
|
51
59
|
trimBatch: null,
|
|
52
60
|
memoryRoot: null,
|
|
53
61
|
notifications: { turnEnd: true, desktop: true, bell: false, command: null, minDurationMs: 0, sound: true },
|
|
54
62
|
};
|
|
55
|
-
|
|
56
63
|
for (const layer of layers) {
|
|
57
64
|
if (!layer) continue;
|
|
58
65
|
if (layer.model != null) result.model = layer.model;
|
|
@@ -64,6 +71,9 @@ function mergeLayers(layers) {
|
|
|
64
71
|
if (layer.webSearch && typeof layer.webSearch === "object" && !Array.isArray(layer.webSearch)) {
|
|
65
72
|
result.webSearch = mergeWebSearch(result.webSearch, layer.webSearch);
|
|
66
73
|
}
|
|
74
|
+
if (layer.hostedTools && typeof layer.hostedTools === "object" && !Array.isArray(layer.hostedTools)) {
|
|
75
|
+
result.hostedTools = mergeHostedTools(result.hostedTools, layer.hostedTools);
|
|
76
|
+
}
|
|
67
77
|
if (layer.notifications && typeof layer.notifications === "object" && !Array.isArray(layer.notifications)) {
|
|
68
78
|
result.notifications = {
|
|
69
79
|
...result.notifications,
|
|
@@ -99,6 +109,23 @@ function mergeWebSearch(current, next) {
|
|
|
99
109
|
return merged;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
function mergeHostedTools(current, next) {
|
|
113
|
+
const merged = {
|
|
114
|
+
openai: { ...(current.openai ?? {}) },
|
|
115
|
+
openaiCodex: { ...(current.openaiCodex ?? {}) },
|
|
116
|
+
azureOpenai: { ...(current.azureOpenai ?? {}) },
|
|
117
|
+
anthropic: { ...(current.anthropic ?? {}) },
|
|
118
|
+
google: { ...(current.google ?? {}) },
|
|
119
|
+
xai: { ...(current.xai ?? {}) },
|
|
120
|
+
};
|
|
121
|
+
for (const provider of ["openai", "openaiCodex", "azureOpenai", "anthropic", "google", "xai"]) {
|
|
122
|
+
if (next[provider] && typeof next[provider] === "object" && !Array.isArray(next[provider])) {
|
|
123
|
+
merged[provider] = { ...merged[provider], ...next[provider] };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return merged;
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
function mergeProviders(current, next) {
|
|
103
130
|
const merged = { ...current };
|
|
104
131
|
for (const [id, profile] of Object.entries(next)) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40, silent: 99 });
|
|
6
|
+
const REDACTED = "[redacted]";
|
|
7
|
+
const MAX_STRING_LENGTH = 2000;
|
|
8
|
+
const MAX_ARRAY_LENGTH = 50;
|
|
9
|
+
const MAX_OBJECT_KEYS = 80;
|
|
10
|
+
const SENSITIVE_KEY = /(api[-_]?key|authorization|auth|token|secret|password|cookie|credential|b64|base64|image)/i;
|
|
11
|
+
|
|
12
|
+
export function createLogger({
|
|
13
|
+
enabled = process.env.MARCH_LOG !== "0",
|
|
14
|
+
level = process.env.MARCH_LOG_LEVEL ?? "info",
|
|
15
|
+
logDir = defaultLogDir(),
|
|
16
|
+
now = () => new Date(),
|
|
17
|
+
pid = process.pid,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const threshold = normalizeLevel(level);
|
|
20
|
+
const path = join(logDir, `${dateStamp(now())}-march-${pid}.jsonl`);
|
|
21
|
+
const base = { enabled: Boolean(enabled), level: levelName(threshold), path };
|
|
22
|
+
|
|
23
|
+
function write(levelNameValue, event, fields = {}) {
|
|
24
|
+
if (!base.enabled || normalizeLevel(levelNameValue) < threshold) return;
|
|
25
|
+
const entry = {
|
|
26
|
+
ts: now().toISOString(),
|
|
27
|
+
level: levelNameValue,
|
|
28
|
+
event,
|
|
29
|
+
pid,
|
|
30
|
+
...sanitize(fields),
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(logDir, { recursive: true });
|
|
34
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
35
|
+
} catch {
|
|
36
|
+
// Logging must never change CLI behavior.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const logger = {
|
|
41
|
+
...base,
|
|
42
|
+
event: (event, fields) => write("info", event, fields),
|
|
43
|
+
debug: (event, fields) => write("debug", event, fields),
|
|
44
|
+
warn: (event, fields) => write("warn", event, fields),
|
|
45
|
+
error: (event, fields) => write("error", event, fields),
|
|
46
|
+
child(extraFields = {}) {
|
|
47
|
+
return createChildLogger(logger, sanitize(extraFields));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return logger;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createHeartbeat({ logger, event = "heartbeat", intervalMs = 10_000, getFields = () => ({}) } = {}) {
|
|
54
|
+
if (!logger?.enabled || intervalMs <= 0) return { stop() {} };
|
|
55
|
+
const timer = setInterval(() => logger.event(event, getFields()), intervalMs);
|
|
56
|
+
timer.unref?.();
|
|
57
|
+
return {
|
|
58
|
+
stop() { clearInterval(timer); },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function installProcessLogHandlers(logger) {
|
|
63
|
+
if (!logger?.enabled) return;
|
|
64
|
+
process.once("uncaughtException", (err) => {
|
|
65
|
+
logger.error("process.uncaughtException", { error: formatError(err) });
|
|
66
|
+
});
|
|
67
|
+
process.once("unhandledRejection", (reason) => {
|
|
68
|
+
logger.error("process.unhandledRejection", { error: formatError(reason) });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatError(err) {
|
|
73
|
+
if (err instanceof Error) {
|
|
74
|
+
return {
|
|
75
|
+
name: err.name,
|
|
76
|
+
message: err.message,
|
|
77
|
+
stack: err.stack,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { message: String(err) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sanitize(value, seen = new WeakSet(), key = "") {
|
|
84
|
+
if (SENSITIVE_KEY.test(key)) return REDACTED;
|
|
85
|
+
if (value == null || typeof value === "number" || typeof value === "boolean") return value;
|
|
86
|
+
if (typeof value === "string") return truncateString(value);
|
|
87
|
+
if (typeof value === "bigint") return String(value);
|
|
88
|
+
if (typeof value === "function" || typeof value === "symbol") return `[${typeof value}]`;
|
|
89
|
+
if (value instanceof Error) return sanitize(formatError(value), seen);
|
|
90
|
+
if (typeof value !== "object") return String(value);
|
|
91
|
+
if (seen.has(value)) return "[circular]";
|
|
92
|
+
seen.add(value);
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => sanitize(item, seen));
|
|
95
|
+
if (value.length > MAX_ARRAY_LENGTH) items.push(`[${value.length - MAX_ARRAY_LENGTH} more items]`);
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [entryKey, entryValue] of entries) out[entryKey] = sanitize(entryValue, seen, entryKey);
|
|
101
|
+
const omitted = Object.keys(value).length - entries.length;
|
|
102
|
+
if (omitted > 0) out.__omittedKeys = omitted;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createChildLogger(parent, extraFields) {
|
|
107
|
+
function withFields(fields) {
|
|
108
|
+
return { ...extraFields, ...(fields ?? {}) };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
enabled: parent.enabled,
|
|
112
|
+
level: parent.level,
|
|
113
|
+
path: parent.path,
|
|
114
|
+
event: (event, fields) => parent.event(event, withFields(fields)),
|
|
115
|
+
debug: (event, fields) => parent.debug(event, withFields(fields)),
|
|
116
|
+
warn: (event, fields) => parent.warn(event, withFields(fields)),
|
|
117
|
+
error: (event, fields) => parent.error(event, withFields(fields)),
|
|
118
|
+
child: (fields = {}) => createChildLogger(parent, withFields(sanitize(fields))),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function defaultLogDir() {
|
|
123
|
+
return join(homedir(), ".march", "logs");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dateStamp(now) {
|
|
127
|
+
return now.toISOString().slice(0, 10);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function truncateString(value) {
|
|
131
|
+
if (value.length <= MAX_STRING_LENGTH) return value;
|
|
132
|
+
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated ${value.length - MAX_STRING_LENGTH} chars]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeLevel(level) {
|
|
136
|
+
return LEVELS[String(level).toLowerCase()] ?? LEVELS.info;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function levelName(value) {
|
|
140
|
+
return Object.entries(LEVELS).find(([, level]) => level === value)?.[0] ?? "info";
|
|
141
|
+
}
|
package/src/lsp/client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { spawnCommand } from "../platform/spawn-command.mjs";
|
|
3
3
|
import { extname } from "node:path";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
|
|
@@ -36,7 +36,7 @@ export class LspClient {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
async start() {
|
|
39
|
-
this.process =
|
|
39
|
+
this.process = spawnCommand(this.command, this.args, {
|
|
40
40
|
cwd: this.cwd,
|
|
41
41
|
env: process.env,
|
|
42
42
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { lspPathKey } from "./path-match.mjs";
|
|
2
3
|
|
|
3
4
|
export class LspDiagnosticStore {
|
|
4
5
|
constructor() {
|
|
@@ -13,7 +14,7 @@ export class LspDiagnosticStore {
|
|
|
13
14
|
serverId,
|
|
14
15
|
path,
|
|
15
16
|
}));
|
|
16
|
-
this.byPath.set(path, {
|
|
17
|
+
this.byPath.set(lspPathKey(path), {
|
|
17
18
|
path,
|
|
18
19
|
updatedAt: Date.now(),
|
|
19
20
|
diagnostics: normalized,
|
|
@@ -22,10 +23,12 @@ export class LspDiagnosticStore {
|
|
|
22
23
|
|
|
23
24
|
snapshot() {
|
|
24
25
|
const diagnostics = [];
|
|
26
|
+
const files = [];
|
|
25
27
|
for (const entry of this.byPath.values()) {
|
|
26
28
|
diagnostics.push(...entry.diagnostics);
|
|
29
|
+
files.push({ path: entry.path, updatedAt: entry.updatedAt, diagnostics: entry.diagnostics.length });
|
|
27
30
|
}
|
|
28
|
-
return diagnostics;
|
|
31
|
+
return { diagnostics, files };
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { sameLspPath } from "./path-match.mjs";
|
|
2
|
+
|
|
1
3
|
const MAX_DIAGNOSTICS = 20;
|
|
2
4
|
|
|
3
|
-
export function
|
|
5
|
+
export function formatLspDiagnostics({ snapshot } = {}) {
|
|
4
6
|
const diagnostics = snapshot?.diagnostics ?? [];
|
|
5
7
|
if (diagnostics.length === 0) return "[diagnostics]";
|
|
6
8
|
|
|
@@ -17,12 +19,12 @@ export function buildDiagnosticsLayer({ snapshot } = {}) {
|
|
|
17
19
|
return lines.join("\n");
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function
|
|
22
|
+
export function formatLspDiagnosticsForPath({ snapshot, path } = {}) {
|
|
21
23
|
const targetPath = String(path ?? "");
|
|
22
24
|
if (!targetPath) return "";
|
|
23
|
-
const diagnostics = (snapshot?.diagnostics ?? []).filter((diagnostic) => diagnostic.path
|
|
25
|
+
const diagnostics = (snapshot?.diagnostics ?? []).filter((diagnostic) => sameLspPath(diagnostic.path, targetPath));
|
|
24
26
|
if (diagnostics.length === 0) return "";
|
|
25
|
-
return
|
|
27
|
+
return formatLspDiagnostics({
|
|
26
28
|
snapshot: {
|
|
27
29
|
...snapshot,
|
|
28
30
|
diagnostics,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnCommand } from "../platform/spawn-command.mjs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CACHE_ROOT = join(homedir(), ".march", "lsp", "node");
|
|
7
|
+
|
|
8
|
+
const MANAGED_PACKAGES = {
|
|
9
|
+
"typescript-language-server": ["typescript-language-server", "typescript"],
|
|
10
|
+
"vue-language-server": ["@vue/language-server", "typescript"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const installs = new Map();
|
|
14
|
+
|
|
15
|
+
export function getManagedNodeLspRoot() {
|
|
16
|
+
return process.env.MARCH_LSP_NODE_ROOT || DEFAULT_CACHE_ROOT;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function ensureManagedNodeCommand(name) {
|
|
20
|
+
if (!MANAGED_PACKAGES[name]) return null;
|
|
21
|
+
const root = getManagedNodeLspRoot();
|
|
22
|
+
const existing = findManagedBin(name, root);
|
|
23
|
+
if (existing) return { command: existing, root, installed: false };
|
|
24
|
+
|
|
25
|
+
await ensureManagedPackages(name, root);
|
|
26
|
+
const command = findManagedBin(name, root);
|
|
27
|
+
if (!command) throw new Error(`managed install did not provide ${name}`);
|
|
28
|
+
return { command, root, installed: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function ensureManagedTypeScript() {
|
|
32
|
+
const root = getManagedNodeLspRoot();
|
|
33
|
+
if (findManagedTypeScriptServer()) return { root, installed: false };
|
|
34
|
+
await installPackages(root, ["typescript"]);
|
|
35
|
+
if (!findManagedTypeScriptServer()) throw new Error("managed install did not provide typescript");
|
|
36
|
+
return { root, installed: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findManagedTypeScriptServer() {
|
|
40
|
+
const root = getManagedNodeLspRoot();
|
|
41
|
+
const tsserver = join(root, "node_modules", "typescript", "lib", "tsserver.js");
|
|
42
|
+
return existsSync(tsserver) ? tsserver : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findManagedTypeScriptSdk() {
|
|
46
|
+
const root = getManagedNodeLspRoot();
|
|
47
|
+
const lib = join(root, "node_modules", "typescript", "lib");
|
|
48
|
+
return existsSync(join(lib, "tsserverlibrary.js")) || existsSync(join(lib, "tsserver.js")) ? lib : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function ensureManagedPackages(name, root) {
|
|
52
|
+
const key = `${root}:${name}`;
|
|
53
|
+
if (!installs.has(key)) {
|
|
54
|
+
installs.set(key, installPackages(root, MANAGED_PACKAGES[name]).finally(() => installs.delete(key)));
|
|
55
|
+
}
|
|
56
|
+
return await installs.get(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function installPackages(root, packages) {
|
|
60
|
+
mkdirSync(root, { recursive: true });
|
|
61
|
+
const packageJson = join(root, "package.json");
|
|
62
|
+
if (!existsSync(packageJson)) writeFileSync(packageJson, JSON.stringify({ private: true, name: "march-managed-lsp" }, null, 2));
|
|
63
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
64
|
+
await run(npm, ["install", "--no-audit", "--no-fund", "--save-exact", ...packages], { cwd: root });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findManagedBin(name, root) {
|
|
68
|
+
for (const bin of platformCommandNames(name)) {
|
|
69
|
+
const candidate = join(root, "node_modules", ".bin", bin);
|
|
70
|
+
if (existsSync(candidate)) return candidate;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function platformCommandNames(name) {
|
|
76
|
+
if (process.platform !== "win32") return [name];
|
|
77
|
+
return [`${name}.cmd`, `${name}.exe`, name];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function run(command, args, { cwd }) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const child = spawnCommand(command, args, { cwd, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
83
|
+
let stderr = "";
|
|
84
|
+
child.stderr.on("data", (chunk) => {
|
|
85
|
+
stderr += chunk.toString("utf8");
|
|
86
|
+
if (stderr.length > 4000) stderr = stderr.slice(-4000);
|
|
87
|
+
});
|
|
88
|
+
child.on("error", reject);
|
|
89
|
+
child.on("exit", (code) => {
|
|
90
|
+
if (code === 0) resolve();
|
|
91
|
+
else reject(new Error(`${command} ${args.join(" ")} failed with exit ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function sameLspPath(left, right) {
|
|
4
|
+
return lspPathKey(left) === lspPathKey(right);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function lspPathKey(path) {
|
|
8
|
+
const normalized = normalize(String(path ?? ""));
|
|
9
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
10
|
+
}
|
package/src/lsp/servers.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import { ensureManagedNodeCommand, ensureManagedTypeScript, findManagedTypeScriptSdk, findManagedTypeScriptServer } from "./managed-node-server.mjs";
|
|
5
|
+
import { resolveTypeScriptProjectRoot } from "./typescript-project-resolver.mjs";
|
|
4
6
|
|
|
5
7
|
const NODE_ROOT_MARKERS = ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock", "package.json"];
|
|
6
8
|
|
|
@@ -10,20 +12,29 @@ const LSP_SERVERS = [
|
|
|
10
12
|
extensions: [".vue"],
|
|
11
13
|
rootMarkers: NODE_ROOT_MARKERS,
|
|
12
14
|
command: ["vue-language-server"],
|
|
15
|
+
managedCommand: "vue-language-server",
|
|
13
16
|
args: ["--stdio"],
|
|
14
|
-
initialization: () =>
|
|
17
|
+
initialization: ({ root, workspaceRoot }) => {
|
|
18
|
+
const tsdk = resolveTypeScriptSdk({ root, workspaceRoot });
|
|
19
|
+
return tsdk ? { typescript: { tsdk } } : null;
|
|
20
|
+
},
|
|
21
|
+
managedTypeScript: true,
|
|
22
|
+
missingInitialization: "missing project typescript SDK",
|
|
15
23
|
},
|
|
16
24
|
{
|
|
17
25
|
id: "typescript",
|
|
18
26
|
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
19
27
|
rootMarkers: NODE_ROOT_MARKERS,
|
|
28
|
+
projectRoot: resolveTypeScriptProjectRoot,
|
|
20
29
|
command: ["typescript-language-server"],
|
|
30
|
+
managedCommand: "typescript-language-server",
|
|
21
31
|
args: ["--stdio"],
|
|
22
32
|
initialization: ({ root, workspaceRoot }) => {
|
|
23
|
-
const tsserver =
|
|
24
|
-
|
|
25
|
-
return { tsserver: { path: tsserver } };
|
|
33
|
+
const tsserver = resolveTypeScriptServer({ root, workspaceRoot });
|
|
34
|
+
return tsserver ? { tsserver: { path: tsserver } } : null;
|
|
26
35
|
},
|
|
36
|
+
managedTypeScript: true,
|
|
37
|
+
missingInitialization: "missing project typescript/tsserver.js",
|
|
27
38
|
},
|
|
28
39
|
{
|
|
29
40
|
id: "python",
|
|
@@ -68,10 +79,10 @@ const LSP_SERVERS = [
|
|
|
68
79
|
command: ["astro-ls", "@astrojs/language-server"],
|
|
69
80
|
args: ["--stdio"],
|
|
70
81
|
initialization: ({ root, workspaceRoot }) => {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
return { typescript: { tsdk: dirname(tsserver) } };
|
|
82
|
+
const tsdk = resolveTypeScriptSdk({ root, workspaceRoot });
|
|
83
|
+
return tsdk ? { typescript: { tsdk } } : null;
|
|
74
84
|
},
|
|
85
|
+
missingInitialization: "missing project typescript SDK",
|
|
75
86
|
},
|
|
76
87
|
{
|
|
77
88
|
id: "yaml",
|
|
@@ -125,24 +136,31 @@ const LSP_SERVERS = [
|
|
|
125
136
|
},
|
|
126
137
|
];
|
|
127
138
|
|
|
128
|
-
export function resolveLspServer({ filePath, workspaceRoot }) {
|
|
139
|
+
export async function resolveLspServer({ filePath, workspaceRoot, onEvent = null } = {}) {
|
|
140
|
+
const result = await resolveLspServerStatus({ filePath, workspaceRoot, onEvent });
|
|
141
|
+
return result.status === "available" ? result.server : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function resolveLspServerStatus({ filePath, workspaceRoot, onEvent = null } = {}) {
|
|
129
145
|
const ext = extensionOf(filePath);
|
|
130
146
|
const def = LSP_SERVERS.find((server) => server.extensions.includes(ext));
|
|
131
|
-
if (!def) return
|
|
147
|
+
if (!def) return { status: "unsupported", extension: ext };
|
|
148
|
+
|
|
149
|
+
const root = resolveServerRoot(def, { filePath, workspaceRoot });
|
|
150
|
+
const command = await resolveCommand(def, { root, workspaceRoot, onEvent });
|
|
151
|
+
if (command?.error) return { status: "unavailable", id: def.id, root, reason: command.error };
|
|
152
|
+
if (!command) return { status: "unavailable", id: def.id, root, reason: `missing ${def.command[0]}` };
|
|
153
|
+
|
|
154
|
+
const managedTypeScript = await ensureTypeScriptFallback(def, { root, workspaceRoot, onEvent });
|
|
155
|
+
if (managedTypeScript?.error) return { status: "unavailable", id: def.id, root, reason: managedTypeScript.error };
|
|
132
156
|
|
|
133
|
-
const root = def.rootMarkers.length > 0
|
|
134
|
-
? findNearestRoot(dirname(filePath), workspaceRoot, def.rootMarkers) ?? workspaceRoot
|
|
135
|
-
: workspaceRoot;
|
|
136
|
-
const command = findCommand(def.command, { root, workspaceRoot });
|
|
137
|
-
if (!command) return null;
|
|
138
157
|
const initialization = def.initialization?.({ root, workspaceRoot }) ?? {};
|
|
139
|
-
if (initialization === null)
|
|
158
|
+
if (initialization === null) {
|
|
159
|
+
return { status: "unavailable", id: def.id, root, reason: def.missingInitialization ?? "missing SDK" };
|
|
160
|
+
}
|
|
140
161
|
return {
|
|
141
|
-
|
|
142
|
-
command,
|
|
143
|
-
args: def.args,
|
|
144
|
-
root,
|
|
145
|
-
initialization,
|
|
162
|
+
status: "available",
|
|
163
|
+
server: { id: def.id, command: command.command, args: def.args, root, initialization, managed: command.managed },
|
|
146
164
|
};
|
|
147
165
|
}
|
|
148
166
|
|
|
@@ -150,6 +168,42 @@ export function listLspServerDefinitions() {
|
|
|
150
168
|
return LSP_SERVERS.map(({ id, extensions, rootMarkers, command, args }) => ({ id, extensions, rootMarkers, command, args }));
|
|
151
169
|
}
|
|
152
170
|
|
|
171
|
+
async function resolveCommand(def, { root, workspaceRoot, onEvent }) {
|
|
172
|
+
const local = findCommand(def.command, { root, workspaceRoot });
|
|
173
|
+
if (local) return { command: local, managed: false };
|
|
174
|
+
if (!def.managedCommand) return null;
|
|
175
|
+
onEvent?.({ status: "installing", id: def.id, root, reason: `installing ${def.managedCommand}` });
|
|
176
|
+
try {
|
|
177
|
+
const managed = await ensureManagedNodeCommand(def.managedCommand);
|
|
178
|
+
return { command: managed.command, managed: true };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
const reason = err.message;
|
|
181
|
+
onEvent?.({ status: "failed", id: def.id, root, reason });
|
|
182
|
+
return { error: reason };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function ensureTypeScriptFallback(def, { root, workspaceRoot, onEvent }) {
|
|
187
|
+
if (!def.managedTypeScript || resolveTypeScriptServer({ root, workspaceRoot })) return null;
|
|
188
|
+
onEvent?.({ status: "installing", id: def.id, root, reason: "installing typescript" });
|
|
189
|
+
try {
|
|
190
|
+
await ensureManagedTypeScript();
|
|
191
|
+
return null;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const reason = err.message;
|
|
194
|
+
onEvent?.({ status: "failed", id: def.id, root, reason });
|
|
195
|
+
return { error: reason };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveServerRoot(def, { filePath, workspaceRoot }) {
|
|
200
|
+
const projectRoot = def.projectRoot?.({ filePath, workspaceRoot });
|
|
201
|
+
if (projectRoot) return projectRoot;
|
|
202
|
+
return def.rootMarkers.length > 0
|
|
203
|
+
? findNearestRoot(dirname(filePath), workspaceRoot, def.rootMarkers) ?? workspaceRoot
|
|
204
|
+
: workspaceRoot;
|
|
205
|
+
}
|
|
206
|
+
|
|
153
207
|
function findCommand(names, { root, workspaceRoot }) {
|
|
154
208
|
for (const name of names) {
|
|
155
209
|
const hit = findBin(name, { root, workspaceRoot });
|
|
@@ -160,7 +214,7 @@ function findCommand(names, { root, workspaceRoot }) {
|
|
|
160
214
|
|
|
161
215
|
function findBin(name, { root, workspaceRoot }) {
|
|
162
216
|
const names = platformCommandNames(name);
|
|
163
|
-
for (const base of [root, workspaceRoot]) {
|
|
217
|
+
for (const base of uniquePaths([root, workspaceRoot])) {
|
|
164
218
|
for (const bin of names) {
|
|
165
219
|
const candidate = join(base, "node_modules", ".bin", bin);
|
|
166
220
|
if (existsSync(candidate)) return candidate;
|
|
@@ -185,6 +239,24 @@ function findOnPath(names) {
|
|
|
185
239
|
return null;
|
|
186
240
|
}
|
|
187
241
|
|
|
242
|
+
function resolveTypeScriptServer({ root, workspaceRoot }) {
|
|
243
|
+
return resolveModuleFromRoots("typescript/lib/tsserver.js", { root, workspaceRoot }) ?? findManagedTypeScriptServer();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveTypeScriptSdk({ root, workspaceRoot }) {
|
|
247
|
+
const serverLibrary = resolveModuleFromRoots("typescript/lib/tsserverlibrary.js", { root, workspaceRoot });
|
|
248
|
+
const tsserver = serverLibrary ?? resolveTypeScriptServer({ root, workspaceRoot });
|
|
249
|
+
return tsserver ? dirname(tsserver) : findManagedTypeScriptSdk();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resolveModuleFromRoots(id, { root, workspaceRoot }) {
|
|
253
|
+
for (const base of uniquePaths([root, workspaceRoot])) {
|
|
254
|
+
const hit = resolveModule(id, base);
|
|
255
|
+
if (hit) return hit;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
188
260
|
function resolveModule(id, base) {
|
|
189
261
|
try {
|
|
190
262
|
return createRequire(join(base, "package.json")).resolve(id);
|
|
@@ -193,6 +265,10 @@ function resolveModule(id, base) {
|
|
|
193
265
|
}
|
|
194
266
|
}
|
|
195
267
|
|
|
268
|
+
function uniquePaths(paths) {
|
|
269
|
+
return [...new Set(paths.map((path) => resolve(path)))];
|
|
270
|
+
}
|
|
271
|
+
|
|
196
272
|
function findNearestRoot(start, stop, markers) {
|
|
197
273
|
let dir = resolve(start);
|
|
198
274
|
const boundary = resolve(stop);
|
package/src/lsp/service.mjs
CHANGED
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
import { LspClient } from "./client.mjs";
|
|
2
2
|
import { LspDiagnosticStore } from "./diagnostic-store.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveLspServerStatus } from "./servers.mjs";
|
|
4
4
|
|
|
5
5
|
export class LspService {
|
|
6
|
-
constructor({ cwd }) {
|
|
6
|
+
constructor({ cwd, onEvent = null }) {
|
|
7
7
|
this.cwd = cwd;
|
|
8
|
+
this.onEvent = onEvent;
|
|
8
9
|
this.store = new LspDiagnosticStore();
|
|
9
10
|
this.clients = new Map();
|
|
10
11
|
this.spawning = new Map();
|
|
12
|
+
this.unavailable = new Map();
|
|
13
|
+
this.announced = new Set();
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
touchFile(path) {
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
+
async touchFile(path) {
|
|
17
|
+
const result = await resolveLspServerStatus({ filePath: path, workspaceRoot: this.cwd, onEvent: (event) => this.onEvent?.(event) });
|
|
18
|
+
if (result.status === "unsupported") return result;
|
|
19
|
+
if (result.status === "unavailable") {
|
|
20
|
+
this.unavailable.set(result.id, result);
|
|
21
|
+
this.#emitOnce(`unavailable:${result.id}:${result.reason}`, result);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const server = result.server;
|
|
16
26
|
const key = `${server.id}:${server.root}`;
|
|
17
27
|
const existing = this.clients.get(key);
|
|
18
28
|
if (existing) {
|
|
19
29
|
existing.touchFile(path);
|
|
20
|
-
return;
|
|
30
|
+
return { status: "already_attached", id: server.id, root: server.root };
|
|
21
31
|
}
|
|
22
32
|
if (this.spawning.has(key)) {
|
|
23
33
|
this.spawning.get(key).then((client) => client?.touchFile(path)).catch(() => {});
|
|
24
|
-
return;
|
|
34
|
+
return { status: "starting", id: server.id, root: server.root };
|
|
25
35
|
}
|
|
36
|
+
|
|
37
|
+
this.#emitOnce(`starting:${key}`, { status: "starting", id: server.id, root: server.root, managed: server.managed });
|
|
26
38
|
const task = this.#startClient(server, key).then((client) => {
|
|
27
39
|
client?.touchFile(path);
|
|
28
40
|
return client;
|
|
@@ -31,13 +43,25 @@ export class LspService {
|
|
|
31
43
|
task.finally(() => {
|
|
32
44
|
if (this.spawning.get(key) === task) this.spawning.delete(key);
|
|
33
45
|
}).catch(() => {});
|
|
46
|
+
return { status: "starting", id: server.id, root: server.root };
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
snapshot() {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
const storeSnapshot = this.store.snapshot();
|
|
51
|
+
const servers = [
|
|
52
|
+
...[...this.clients.values()].map((client) => ({
|
|
53
|
+
id: client.serverId,
|
|
54
|
+
root: client.cwd,
|
|
55
|
+
status: client.status,
|
|
56
|
+
})),
|
|
57
|
+
...[...this.spawning.keys()].map((key) => ({
|
|
58
|
+
id: key.slice(0, key.indexOf(":")),
|
|
59
|
+
root: key.slice(key.indexOf(":") + 1),
|
|
60
|
+
status: "starting",
|
|
61
|
+
})),
|
|
62
|
+
...this.unavailable.values(),
|
|
63
|
+
];
|
|
64
|
+
return { status: summarizeStatus(servers), diagnostics: storeSnapshot.diagnostics, files: storeSnapshot.files, servers };
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
async dispose() {
|
|
@@ -56,10 +80,31 @@ export class LspService {
|
|
|
56
80
|
try {
|
|
57
81
|
await client.start();
|
|
58
82
|
this.clients.set(key, client);
|
|
83
|
+
this.unavailable.delete(server.id);
|
|
84
|
+
this.#emitOnce(`attached:${key}`, { status: "attached", id: server.id, root: server.root, managed: server.managed });
|
|
59
85
|
return client;
|
|
60
|
-
} catch {
|
|
86
|
+
} catch (err) {
|
|
61
87
|
client.status = "failed";
|
|
88
|
+
const event = { status: "failed", id: server.id, root: server.root, reason: err.message };
|
|
89
|
+
this.unavailable.set(server.id, event);
|
|
90
|
+
this.#emitOnce(`failed:${key}:${err.message}`, event);
|
|
62
91
|
return null;
|
|
63
92
|
}
|
|
64
93
|
}
|
|
94
|
+
|
|
95
|
+
#emitOnce(key, event) {
|
|
96
|
+
if (this.announced.has(key)) return;
|
|
97
|
+
this.announced.add(key);
|
|
98
|
+
this.onEvent?.(event);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function summarizeStatus(servers) {
|
|
103
|
+
const statuses = servers.map((server) => server.status);
|
|
104
|
+
if (statuses.includes("busy")) return "busy";
|
|
105
|
+
if (statuses.includes("starting")) return "starting";
|
|
106
|
+
if (statuses.includes("failed")) return "failed";
|
|
107
|
+
if (statuses.includes("ready") || statuses.includes("idle")) return "idle";
|
|
108
|
+
if (statuses.includes("unavailable")) return "unavailable";
|
|
109
|
+
return "";
|
|
65
110
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function formatLspServiceEvent(event) {
|
|
2
|
+
const id = event?.id ? String(event.id) : "server";
|
|
3
|
+
if (event?.status === "attached") return `LSP attached: ${id}`;
|
|
4
|
+
if (event?.status === "starting") return `LSP starting: ${id}`;
|
|
5
|
+
if (event?.status === "installing") return `LSP installing: ${id} - ${event.reason}`;
|
|
6
|
+
if (event?.status === "failed") return `LSP failed: ${id} - ${event.reason}`;
|
|
7
|
+
if (event?.status === "unavailable") return `LSP unavailable: ${id} - ${event.reason}`;
|
|
8
|
+
return `LSP ${event?.status ?? "status"}: ${id}`;
|
|
9
|
+
}
|