march-cli 0.1.9 → 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.
@@ -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
+ }
@@ -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 = spawn(this.command, this.args, {
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,3 +1,5 @@
1
+ import { sameLspPath } from "./path-match.mjs";
2
+
1
3
  const MAX_DIAGNOSTICS = 20;
2
4
 
3
5
  export function formatLspDiagnostics({ snapshot } = {}) {
@@ -20,7 +22,7 @@ export function formatLspDiagnostics({ snapshot } = {}) {
20
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 === targetPath);
25
+ const diagnostics = (snapshot?.diagnostics ?? []).filter((diagnostic) => sameLspPath(diagnostic.path, targetPath));
24
26
  if (diagnostics.length === 0) return "";
25
27
  return formatLspDiagnostics({
26
28
  snapshot: {
@@ -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
+ }
@@ -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,23 +12,28 @@ 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
17
  initialization: ({ root, workspaceRoot }) => {
15
18
  const tsdk = resolveTypeScriptSdk({ root, workspaceRoot });
16
19
  return tsdk ? { typescript: { tsdk } } : null;
17
20
  },
21
+ managedTypeScript: true,
18
22
  missingInitialization: "missing project typescript SDK",
19
23
  },
20
24
  {
21
25
  id: "typescript",
22
26
  extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
23
27
  rootMarkers: NODE_ROOT_MARKERS,
28
+ projectRoot: resolveTypeScriptProjectRoot,
24
29
  command: ["typescript-language-server"],
30
+ managedCommand: "typescript-language-server",
25
31
  args: ["--stdio"],
26
32
  initialization: ({ root, workspaceRoot }) => {
27
33
  const tsserver = resolveTypeScriptServer({ root, workspaceRoot });
28
34
  return tsserver ? { tsserver: { path: tsserver } } : null;
29
35
  },
36
+ managedTypeScript: true,
30
37
  missingInitialization: "missing project typescript/tsserver.js",
31
38
  },
32
39
  {
@@ -129,23 +136,23 @@ const LSP_SERVERS = [
129
136
  },
130
137
  ];
131
138
 
132
- export function resolveLspServer({ filePath, workspaceRoot }) {
133
- const result = resolveLspServerStatus({ filePath, workspaceRoot });
139
+ export async function resolveLspServer({ filePath, workspaceRoot, onEvent = null } = {}) {
140
+ const result = await resolveLspServerStatus({ filePath, workspaceRoot, onEvent });
134
141
  return result.status === "available" ? result.server : null;
135
142
  }
136
143
 
137
- export function resolveLspServerStatus({ filePath, workspaceRoot }) {
144
+ export async function resolveLspServerStatus({ filePath, workspaceRoot, onEvent = null } = {}) {
138
145
  const ext = extensionOf(filePath);
139
146
  const def = LSP_SERVERS.find((server) => server.extensions.includes(ext));
140
147
  if (!def) return { status: "unsupported", extension: ext };
141
148
 
142
- const root = def.rootMarkers.length > 0
143
- ? findNearestRoot(dirname(filePath), workspaceRoot, def.rootMarkers) ?? workspaceRoot
144
- : workspaceRoot;
145
- const command = findCommand(def.command, { root, workspaceRoot });
146
- if (!command) {
147
- return { status: "unavailable", id: def.id, root, reason: `missing ${def.command[0]}` };
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 };
149
156
 
150
157
  const initialization = def.initialization?.({ root, workspaceRoot }) ?? {};
151
158
  if (initialization === null) {
@@ -153,7 +160,7 @@ export function resolveLspServerStatus({ filePath, workspaceRoot }) {
153
160
  }
154
161
  return {
155
162
  status: "available",
156
- server: { id: def.id, command, args: def.args, root, initialization },
163
+ server: { id: def.id, command: command.command, args: def.args, root, initialization, managed: command.managed },
157
164
  };
158
165
  }
159
166
 
@@ -161,6 +168,42 @@ export function listLspServerDefinitions() {
161
168
  return LSP_SERVERS.map(({ id, extensions, rootMarkers, command, args }) => ({ id, extensions, rootMarkers, command, args }));
162
169
  }
163
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
+
164
207
  function findCommand(names, { root, workspaceRoot }) {
165
208
  for (const name of names) {
166
209
  const hit = findBin(name, { root, workspaceRoot });
@@ -197,13 +240,13 @@ function findOnPath(names) {
197
240
  }
198
241
 
199
242
  function resolveTypeScriptServer({ root, workspaceRoot }) {
200
- return resolveModuleFromRoots("typescript/lib/tsserver.js", { root, workspaceRoot });
243
+ return resolveModuleFromRoots("typescript/lib/tsserver.js", { root, workspaceRoot }) ?? findManagedTypeScriptServer();
201
244
  }
202
245
 
203
246
  function resolveTypeScriptSdk({ root, workspaceRoot }) {
204
247
  const serverLibrary = resolveModuleFromRoots("typescript/lib/tsserverlibrary.js", { root, workspaceRoot });
205
248
  const tsserver = serverLibrary ?? resolveTypeScriptServer({ root, workspaceRoot });
206
- return tsserver ? dirname(tsserver) : null;
249
+ return tsserver ? dirname(tsserver) : findManagedTypeScriptSdk();
207
250
  }
208
251
 
209
252
  function resolveModuleFromRoots(id, { root, workspaceRoot }) {
@@ -13,8 +13,8 @@ export class LspService {
13
13
  this.announced = new Set();
14
14
  }
15
15
 
16
- touchFile(path) {
17
- const result = resolveLspServerStatus({ filePath: path, workspaceRoot: this.cwd });
16
+ async touchFile(path) {
17
+ const result = await resolveLspServerStatus({ filePath: path, workspaceRoot: this.cwd, onEvent: (event) => this.onEvent?.(event) });
18
18
  if (result.status === "unsupported") return result;
19
19
  if (result.status === "unavailable") {
20
20
  this.unavailable.set(result.id, result);
@@ -34,7 +34,7 @@ export class LspService {
34
34
  return { status: "starting", id: server.id, root: server.root };
35
35
  }
36
36
 
37
- this.#emitOnce(`starting:${key}`, { status: "starting", id: server.id, root: server.root });
37
+ this.#emitOnce(`starting:${key}`, { status: "starting", id: server.id, root: server.root, managed: server.managed });
38
38
  const task = this.#startClient(server, key).then((client) => {
39
39
  client?.touchFile(path);
40
40
  return client;
@@ -47,7 +47,7 @@ export class LspService {
47
47
  }
48
48
 
49
49
  snapshot() {
50
- const diagnostics = this.store.snapshot();
50
+ const storeSnapshot = this.store.snapshot();
51
51
  const servers = [
52
52
  ...[...this.clients.values()].map((client) => ({
53
53
  id: client.serverId,
@@ -61,7 +61,7 @@ export class LspService {
61
61
  })),
62
62
  ...this.unavailable.values(),
63
63
  ];
64
- return { status: summarizeStatus(servers), diagnostics, servers };
64
+ return { status: summarizeStatus(servers), diagnostics: storeSnapshot.diagnostics, files: storeSnapshot.files, servers };
65
65
  }
66
66
 
67
67
  async dispose() {
@@ -81,7 +81,7 @@ export class LspService {
81
81
  await client.start();
82
82
  this.clients.set(key, client);
83
83
  this.unavailable.delete(server.id);
84
- this.#emitOnce(`attached:${key}`, { status: "attached", id: server.id, root: server.root });
84
+ this.#emitOnce(`attached:${key}`, { status: "attached", id: server.id, root: server.root, managed: server.managed });
85
85
  return client;
86
86
  } catch (err) {
87
87
  client.status = "failed";
@@ -2,6 +2,7 @@ export function formatLspServiceEvent(event) {
2
2
  const id = event?.id ? String(event.id) : "server";
3
3
  if (event?.status === "attached") return `LSP attached: ${id}`;
4
4
  if (event?.status === "starting") return `LSP starting: ${id}`;
5
+ if (event?.status === "installing") return `LSP installing: ${id} - ${event.reason}`;
5
6
  if (event?.status === "failed") return `LSP failed: ${id} - ${event.reason}`;
6
7
  if (event?.status === "unavailable") return `LSP unavailable: ${id} - ${event.reason}`;
7
8
  return `LSP ${event?.status ?? "status"}: ${id}`;