pi-lsp-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ import { constants } from "node:fs";
2
+ import { access, chmod, copyFile, mkdir, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { getBinDir, getLogsDir, getPackagesDir } from "../config/paths.js";
7
+ import { messageFromError, normalizeProcessEnv } from "../util/helpers.js";
8
+ import type {
9
+ GithubInstallSpec,
10
+ GoInstallSpec,
11
+ InstalledServerMetadata,
12
+ NpmInstallSpec,
13
+ ServerDefinition,
14
+ SystemInstallSpec,
15
+ } from "../registry/schema.js";
16
+ import { ConfigError, MissingBinaryError } from "../util/errors.js";
17
+ import { createInstalledServerMetadata, ensureManagedLspRoot } from "./lockfile.js";
18
+
19
+ export interface CommandInvocation {
20
+ command: string;
21
+ args: string[];
22
+ cwd?: string;
23
+ env?: Record<string, string>;
24
+ }
25
+
26
+ export interface CommandResult {
27
+ code: number;
28
+ stdout: string;
29
+ stderr: string;
30
+ }
31
+
32
+ export type CommandRunner = (invocation: CommandInvocation) => Promise<CommandResult>;
33
+ export type DownloadFile = (url: string, destinationPath: string) => Promise<void>;
34
+
35
+ export interface InstallerOptions {
36
+ runner?: CommandRunner;
37
+ downloadFile?: DownloadFile;
38
+ env?: Record<string, string>;
39
+ now?: Date;
40
+ }
41
+
42
+ export interface InstallerResult {
43
+ metadata: InstalledServerMetadata;
44
+ logPath: string;
45
+ }
46
+
47
+ export async function installServerBackend(
48
+ server: ServerDefinition,
49
+ requestedVersion?: string,
50
+ options: InstallerOptions = {},
51
+ ): Promise<InstallerResult> {
52
+ await ensureManagedLspRoot();
53
+
54
+ try {
55
+ const result =
56
+ (await trySystemInstall(server, requestedVersion, options)) ??
57
+ (await installByType(server, requestedVersion, options));
58
+ await writeInstallLog(
59
+ server.id,
60
+ formatInstallLog([`Installed ${server.id}.`, `Command: ${result.metadata.resolvedCommand.join(" ")}`]),
61
+ );
62
+ return result;
63
+ } catch (error) {
64
+ await writeInstallLog(server.id, formatInstallLog([`Failed to install ${server.id}.`, messageFromError(error)]));
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export function buildNpmInstallCommand(
70
+ serverId: string,
71
+ install: NpmInstallSpec,
72
+ requestedVersion?: string,
73
+ env?: Record<string, string>,
74
+ ): CommandInvocation {
75
+ const packageDir = getServerPackageDir(serverId);
76
+ const packages = resolveNpmPackages(install.packages, requestedVersion);
77
+ const invocation: CommandInvocation = {
78
+ command: "npm",
79
+ args: [
80
+ "install",
81
+ "--prefix",
82
+ packageDir,
83
+ "--ignore-scripts",
84
+ "--no-audit",
85
+ "--fund=false",
86
+ ...Object.entries(packages).map(([name, version]) => `${name}@${version}`),
87
+ ],
88
+ };
89
+ if (env) invocation.env = env;
90
+ return invocation;
91
+ }
92
+
93
+ export function buildGoInstallCommand(
94
+ _serverId: string,
95
+ install: GoInstallSpec,
96
+ requestedVersion?: string,
97
+ env: Record<string, string> = normalizeProcessEnv(process.env),
98
+ ): CommandInvocation {
99
+ const version = requestedVersion ?? install.version ?? "latest";
100
+ return {
101
+ command: "go",
102
+ args: ["install", `${install.module}@${version}`],
103
+ env: { ...env, GOBIN: getBinDir() },
104
+ };
105
+ }
106
+
107
+ export async function writeInstallLog(serverId: string, content: string): Promise<string> {
108
+ const logPath = getInstallLogPath(serverId);
109
+ await mkdir(dirname(logPath), { recursive: true });
110
+ await writeFile(logPath, content, "utf8");
111
+ return logPath;
112
+ }
113
+
114
+ export function getInstallLogPath(serverId: string): string {
115
+ return join(getLogsDir(), `${serverId}-install.log`);
116
+ }
117
+
118
+ export function getServerPackageDir(serverId: string): string {
119
+ return join(getPackagesDir(), serverId);
120
+ }
121
+
122
+ export async function resolveSystemCommand(
123
+ server: ServerDefinition,
124
+ install: SystemInstallSpec,
125
+ env: Record<string, string> = normalizeProcessEnv(process.env),
126
+ ): Promise<string[]> {
127
+ const command = install.command ?? (install.bin ? [install.bin] : server.command);
128
+ if (command.length === 0) {
129
+ throw new MissingBinaryError(server.id, "<empty command>");
130
+ }
131
+
132
+ const executable = await resolveExecutable(command[0]!, env, server.id);
133
+ return [executable, ...command.slice(1)];
134
+ }
135
+
136
+ async function trySystemInstall(
137
+ server: ServerDefinition,
138
+ requestedVersion: string | undefined,
139
+ options: InstallerOptions,
140
+ ): Promise<InstallerResult | undefined> {
141
+ if (requestedVersion || server.install.type === "system") return undefined;
142
+
143
+ try {
144
+ return await installSystemServer(server, buildSystemFallbackInstall(server), undefined, options);
145
+ } catch (error) {
146
+ if (error instanceof MissingBinaryError) return undefined;
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ function buildSystemFallbackInstall(server: ServerDefinition): SystemInstallSpec {
152
+ const bin = getInstallBinName(server);
153
+ return { type: "system", command: [bin, ...systemFallbackArgs(server)] };
154
+ }
155
+
156
+ export function getInstallBinName(server: ServerDefinition): string {
157
+ switch (server.install.type) {
158
+ case "npm":
159
+ case "go":
160
+ case "github":
161
+ return server.install.bin;
162
+ case "system":
163
+ return server.install.bin ?? server.install.command?.[0] ?? server.command[0] ?? server.id;
164
+ }
165
+ }
166
+
167
+ function systemFallbackArgs(server: ServerDefinition): string[] {
168
+ const command = server.command[0];
169
+ return command?.includes("{installBin}") ? commandArgsFromServerTemplate(server.command) : [];
170
+ }
171
+
172
+ async function installByType(
173
+ server: ServerDefinition,
174
+ requestedVersion: string | undefined,
175
+ options: InstallerOptions,
176
+ ): Promise<InstallerResult> {
177
+ switch (server.install.type) {
178
+ case "npm":
179
+ return installNpmServer(server, server.install, requestedVersion, options);
180
+ case "go":
181
+ return installGoServer(server, server.install, requestedVersion, options);
182
+ case "github":
183
+ return installGithubServer(server, server.install, requestedVersion, options);
184
+ case "system":
185
+ return installSystemServer(server, server.install, requestedVersion, options);
186
+ }
187
+ }
188
+
189
+ async function installNpmServer(
190
+ server: ServerDefinition,
191
+ install: NpmInstallSpec,
192
+ requestedVersion: string | undefined,
193
+ options: InstallerOptions,
194
+ ): Promise<InstallerResult> {
195
+ const runner = options.runner ?? defaultCommandRunner;
196
+ const packageDir = getServerPackageDir(server.id);
197
+ const command = buildNpmInstallCommand(server.id, install, requestedVersion, options.env);
198
+ await mkdir(packageDir, { recursive: true });
199
+ await runChecked(runner, command);
200
+
201
+ const packageBin = join(packageDir, "node_modules", ".bin", install.bin);
202
+ const resolvedBin = join(getBinDir(), install.bin);
203
+ await linkExecutable(packageBin, resolvedBin, server.id);
204
+
205
+ return {
206
+ metadata: createInstalledServerMetadata({
207
+ installer: "npm",
208
+ requestedVersion,
209
+ packages: resolveNpmPackages(install.packages, requestedVersion),
210
+ resolvedCommand: [resolvedBin, ...commandArgsFromServerTemplate(server.command)],
211
+ packageDir,
212
+ binDir: getBinDir(),
213
+ installedAt: options.now,
214
+ }),
215
+ logPath: getInstallLogPath(server.id),
216
+ };
217
+ }
218
+
219
+ async function installGoServer(
220
+ server: ServerDefinition,
221
+ install: GoInstallSpec,
222
+ requestedVersion: string | undefined,
223
+ options: InstallerOptions,
224
+ ): Promise<InstallerResult> {
225
+ const runner = options.runner ?? defaultCommandRunner;
226
+ const command = buildGoInstallCommand(server.id, install, requestedVersion, options.env);
227
+ await runChecked(runner, command);
228
+
229
+ return {
230
+ metadata: createInstalledServerMetadata({
231
+ installer: "go",
232
+ requestedVersion: requestedVersion ?? install.version,
233
+ resolvedCommand: [join(getBinDir(), install.bin)],
234
+ binDir: getBinDir(),
235
+ installedAt: options.now,
236
+ }),
237
+ logPath: getInstallLogPath(server.id),
238
+ };
239
+ }
240
+
241
+ async function installGithubServer(
242
+ server: ServerDefinition,
243
+ install: GithubInstallSpec,
244
+ requestedVersion: string | undefined,
245
+ options: InstallerOptions,
246
+ ): Promise<InstallerResult> {
247
+ const runner = options.runner ?? defaultCommandRunner;
248
+ const downloadFile = options.downloadFile ?? defaultDownloadFile;
249
+ const assetName = resolveGithubAssetName(install, requestedVersion);
250
+ const url = buildGithubAssetUrl(install, assetName, requestedVersion ?? install.version);
251
+ const packageDir = getServerPackageDir(server.id);
252
+ const archivePath = join(tmpdir(), `pi-lsp-${server.id}-${process.pid}-${Date.now()}-${basename(assetName)}`);
253
+ await mkdir(packageDir, { recursive: true });
254
+ await downloadFile(url, archivePath);
255
+
256
+ const extractedPath = await extractGithubAsset(runner, archivePath, packageDir, install.stripComponents ?? 0);
257
+ const needsManagedBin = commandUsesInstallBin(server.command);
258
+ const resolvedBin = join(getBinDir(), install.bin);
259
+ const resolvedCommand = needsManagedBin
260
+ ? [resolvedBin, ...commandArgsFromServerTemplate(server.command)]
261
+ : server.command;
262
+
263
+ if (needsManagedBin) {
264
+ const packageBin = await prepareGithubExecutable(packageDir, install.bin, extractedPath, server.id);
265
+ await linkExecutable(packageBin, resolvedBin, server.id);
266
+ }
267
+
268
+ return {
269
+ metadata: createInstalledServerMetadata({
270
+ installer: "github",
271
+ requestedVersion: requestedVersion ?? install.version,
272
+ resolvedCommand,
273
+ packageDir,
274
+ binDir: needsManagedBin ? getBinDir() : undefined,
275
+ installedAt: options.now,
276
+ }),
277
+ logPath: getInstallLogPath(server.id),
278
+ };
279
+ }
280
+
281
+ async function installSystemServer(
282
+ server: ServerDefinition,
283
+ install: SystemInstallSpec,
284
+ requestedVersion: string | undefined,
285
+ options: InstallerOptions,
286
+ ): Promise<InstallerResult> {
287
+ const resolvedCommand = await resolveSystemCommand(server, install, options.env ?? normalizeProcessEnv(process.env));
288
+ return {
289
+ metadata: createInstalledServerMetadata({
290
+ installer: "system",
291
+ requestedVersion,
292
+ resolvedCommand,
293
+ installedAt: options.now,
294
+ }),
295
+ logPath: getInstallLogPath(server.id),
296
+ };
297
+ }
298
+
299
+ async function runChecked(runner: CommandRunner, invocation: CommandInvocation): Promise<CommandResult> {
300
+ const result = await runner(invocation);
301
+ if (result.code !== 0) {
302
+ throw new ConfigError(
303
+ `${invocation.command} ${invocation.args.join(" ")} failed with exit code ${result.code}: ${tail(result.stderr || result.stdout)}`,
304
+ );
305
+ }
306
+ return result;
307
+ }
308
+
309
+ function resolveNpmPackages(
310
+ packages: Record<string, string>,
311
+ requestedVersion: string | undefined,
312
+ ): Record<string, string> {
313
+ const entries = Object.entries(packages);
314
+ if (!requestedVersion || entries.length === 0) {
315
+ return { ...packages };
316
+ }
317
+
318
+ const [primaryName] = entries[0]!;
319
+ return { ...packages, [primaryName]: requestedVersion };
320
+ }
321
+
322
+ function commandArgsFromServerTemplate(command: string[]): string[] {
323
+ return command.slice(1).filter((arg) => !arg.includes("{installBin}") && !arg.includes("{installDir}"));
324
+ }
325
+
326
+ function commandUsesInstallBin(command: string[]): boolean {
327
+ return command.some((part) => part.includes("{installBin}"));
328
+ }
329
+
330
+ async function linkExecutable(target: string, linkPath: string, serverId: string): Promise<void> {
331
+ await assertExecutable(target, target, serverId);
332
+ await mkdir(dirname(linkPath), { recursive: true });
333
+ await rm(linkPath, { force: true });
334
+ try {
335
+ await symlink(target, linkPath);
336
+ } catch {
337
+ await copyFile(target, linkPath);
338
+ await chmod(linkPath, 0o755).catch(() => undefined);
339
+ }
340
+ }
341
+
342
+ async function prepareGithubExecutable(
343
+ packageDir: string,
344
+ binName: string,
345
+ extractedPath: string | undefined,
346
+ serverId: string,
347
+ ): Promise<string> {
348
+ const expectedPath = join(packageDir, binName);
349
+ if (await isExecutable(expectedPath)) return expectedPath;
350
+
351
+ if (extractedPath && (await isExecutable(extractedPath))) {
352
+ await copyFile(extractedPath, expectedPath);
353
+ await chmod(expectedPath, 0o755).catch(() => undefined);
354
+ return expectedPath;
355
+ }
356
+
357
+ throw new MissingBinaryError(serverId, expectedPath);
358
+ }
359
+
360
+ async function resolveExecutable(command: string, env: Record<string, string>, serverId: string): Promise<string> {
361
+ if (command.includes("/") || command.includes("\\") || isAbsolute(command)) {
362
+ const absolute = isAbsolute(command) ? command : resolve(command);
363
+ await assertExecutable(absolute, command, serverId);
364
+ return absolute;
365
+ }
366
+
367
+ const pathEntries = (env.PATH ?? "").split(process.platform === "win32" ? ";" : ":").filter(Boolean);
368
+ const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
369
+
370
+ for (const directory of pathEntries) {
371
+ for (const extension of extensions) {
372
+ const candidate = join(directory, `${command}${extension}`);
373
+ if (await isExecutable(candidate)) {
374
+ return candidate;
375
+ }
376
+ }
377
+ }
378
+
379
+ throw new MissingBinaryError(serverId, command);
380
+ }
381
+
382
+ async function assertExecutable(path: string, originalCommand: string, serverId: string): Promise<void> {
383
+ if (!(await isExecutable(path))) {
384
+ throw new MissingBinaryError(serverId, originalCommand);
385
+ }
386
+ }
387
+
388
+ async function isExecutable(path: string): Promise<boolean> {
389
+ try {
390
+ await access(path, constants.X_OK);
391
+ return true;
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
397
+ export function resolveGithubAssetName(install: GithubInstallSpec, requestedVersion: string | undefined): string {
398
+ if (!install.asset) {
399
+ return install.bin;
400
+ }
401
+
402
+ const version = requestedVersion ?? install.version;
403
+ if (install.asset.includes("{version}") && !version) {
404
+ throw new ConfigError(`GitHub installer for ${install.repo} requires a version because asset uses {version}.`);
405
+ }
406
+
407
+ return install.asset.replaceAll("{version}", version ?? "latest").replaceAll("{platform}", getGithubPlatformToken());
408
+ }
409
+
410
+ export function buildGithubAssetUrl(
411
+ install: GithubInstallSpec,
412
+ assetName: string,
413
+ version: string | undefined,
414
+ ): string {
415
+ if (install.downloadUrl) {
416
+ const resolvedVersion = version ?? "latest";
417
+ return install.downloadUrl.replaceAll("{version}", resolvedVersion).replaceAll("{asset}", assetName);
418
+ }
419
+
420
+ const releasePath = version ? `download/${version}` : "latest/download";
421
+ return `https://github.com/${install.repo}/releases/${releasePath}/${assetName}`;
422
+ }
423
+
424
+ async function extractGithubAsset(
425
+ runner: CommandRunner,
426
+ archivePath: string,
427
+ packageDir: string,
428
+ stripComponents: number,
429
+ ): Promise<string | undefined> {
430
+ if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) {
431
+ const args = ["-xzf", archivePath, "-C", packageDir];
432
+ if (stripComponents > 0) args.push("--strip-components", String(stripComponents));
433
+ await runChecked(runner, { command: "tar", args });
434
+ return undefined;
435
+ }
436
+
437
+ if (archivePath.endsWith(".zip")) {
438
+ await runChecked(runner, { command: "unzip", args: ["-q", archivePath, "-d", packageDir] });
439
+ return undefined;
440
+ }
441
+
442
+ const destination = join(packageDir, basename(archivePath));
443
+ await copyFile(archivePath, destination);
444
+ await chmod(destination, 0o755).catch(() => undefined);
445
+ return destination;
446
+ }
447
+
448
+ export function getGithubPlatformToken(platform: string = process.platform, arch: string = process.arch): string {
449
+ if (platform === "linux") {
450
+ if (arch === "x64") return "x86_64-unknown-linux-gnu";
451
+ if (arch === "arm64") return "aarch64-unknown-linux-gnu";
452
+ if (arch === "arm") return "arm-unknown-linux-gnueabihf";
453
+ }
454
+
455
+ if (platform === "darwin") {
456
+ if (arch === "x64") return "x86_64-apple-darwin";
457
+ if (arch === "arm64") return "aarch64-apple-darwin";
458
+ }
459
+
460
+ if (platform === "win32") {
461
+ if (arch === "x64") return "x86_64-pc-windows-msvc";
462
+ if (arch === "arm64") return "aarch64-pc-windows-msvc";
463
+ if (arch === "ia32") return "i686-pc-windows-msvc";
464
+ }
465
+
466
+ return `${platform}-${arch}`;
467
+ }
468
+
469
+ async function defaultDownloadFile(url: string, destinationPath: string): Promise<void> {
470
+ const response = await fetch(url);
471
+ if (!response.ok) {
472
+ throw new ConfigError(`Failed to download ${url}: HTTP ${response.status}`);
473
+ }
474
+
475
+ const buffer = Buffer.from(await response.arrayBuffer());
476
+ await writeFile(destinationPath, buffer);
477
+ }
478
+
479
+ export async function defaultCommandRunner(invocation: CommandInvocation): Promise<CommandResult> {
480
+ return new Promise((resolvePromise, reject) => {
481
+ const child = spawn(invocation.command, invocation.args, {
482
+ cwd: invocation.cwd,
483
+ env: invocation.env,
484
+ shell: false,
485
+ stdio: ["ignore", "pipe", "pipe"],
486
+ });
487
+
488
+ let stdout = "";
489
+ let stderr = "";
490
+
491
+ child.stdout.setEncoding("utf8");
492
+ child.stderr.setEncoding("utf8");
493
+ child.stdout.on("data", (chunk: string) => {
494
+ stdout += chunk;
495
+ });
496
+ child.stderr.on("data", (chunk: string) => {
497
+ stderr += chunk;
498
+ });
499
+ child.on("error", reject);
500
+ child.on("close", (code) => {
501
+ resolvePromise({ code: code ?? 1, stdout, stderr });
502
+ });
503
+ });
504
+ }
505
+
506
+ function formatInstallLog(lines: string[]): string {
507
+ return `${lines.join("\n")}\n`;
508
+ }
509
+
510
+ function tail(value: string): string {
511
+ const lines = value.trim().split(/\r?\n/u);
512
+ return lines.slice(-10).join("\n");
513
+ }
@@ -0,0 +1,159 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import {
4
+ getBinDir,
5
+ getCacheDir,
6
+ getLockfilePath,
7
+ getLogsDir,
8
+ getManagedLspRoot,
9
+ getPackagesDir,
10
+ getProcessRegistryDir,
11
+ getRegistryDir,
12
+ getWorkspacesDir,
13
+ } from "../config/paths.js";
14
+ import { InstalledServerMetadataSchema } from "../registry/schema.js";
15
+ import type { InstalledServerMetadata, InstallerType } from "../registry/schema.js";
16
+ import { isNodeError, isPlainObject, messageFromError } from "../util/helpers.js";
17
+ import { Value } from "typebox/value";
18
+ import { ConfigError } from "../util/errors.js";
19
+
20
+ export interface LspLockfile {
21
+ servers: Record<string, InstalledServerMetadata>;
22
+ }
23
+
24
+ export interface LockfileOptions {
25
+ lockfilePath?: string;
26
+ }
27
+
28
+ export interface CreateInstalledServerMetadataInput {
29
+ installer: InstallerType;
30
+ requestedVersion?: string;
31
+ packages?: Record<string, string>;
32
+ resolvedCommand: string[];
33
+ packageDir?: string;
34
+ binDir?: string;
35
+ installedAt?: Date;
36
+ }
37
+
38
+ export async function ensureManagedLspRoot(): Promise<void> {
39
+ await Promise.all([
40
+ mkdir(getManagedLspRoot(), { recursive: true }),
41
+ mkdir(getRegistryDir(), { recursive: true }),
42
+ mkdir(getPackagesDir(), { recursive: true }),
43
+ mkdir(getBinDir(), { recursive: true }),
44
+ mkdir(getCacheDir(), { recursive: true }),
45
+ mkdir(getLogsDir(), { recursive: true }),
46
+ mkdir(getWorkspacesDir(), { recursive: true }),
47
+ mkdir(getProcessRegistryDir(), { recursive: true }),
48
+ ]);
49
+ }
50
+
51
+ export async function readLockfile(options: LockfileOptions = {}): Promise<LspLockfile> {
52
+ const lockfilePath = options.lockfilePath ?? getLockfilePath();
53
+ const paths = options.lockfilePath ? [lockfilePath] : [lockfilePath, getLegacyLockfilePath(lockfilePath)];
54
+
55
+ for (const path of paths) {
56
+ try {
57
+ return await readLockfileAt(path);
58
+ } catch (error) {
59
+ if (isNodeError(error) && error.code === "ENOENT") continue;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ return { servers: {} };
65
+ }
66
+
67
+ async function readLockfileAt(lockfilePath: string): Promise<LspLockfile> {
68
+ const raw = await readFile(lockfilePath, "utf8");
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch (error) {
73
+ throw new ConfigError(`Invalid LSP lockfile at ${lockfilePath}: ${messageFromError(error)}`);
74
+ }
75
+
76
+ if (!isLockfile(parsed)) {
77
+ throw new ConfigError(
78
+ `Invalid LSP lockfile at ${lockfilePath}: expected { servers: { ... } } with valid server metadata.`,
79
+ );
80
+ }
81
+
82
+ return { servers: parsed.servers };
83
+ }
84
+
85
+ function getLegacyLockfilePath(lockfilePath: string): string {
86
+ return join(dirname(lockfilePath), "lock.json");
87
+ }
88
+
89
+ export async function writeLockfile(lockfile: LspLockfile, options: LockfileOptions = {}): Promise<void> {
90
+ const lockfilePath = options.lockfilePath ?? getLockfilePath();
91
+ await ensureManagedLspRoot();
92
+ await mkdir(dirname(lockfilePath), { recursive: true });
93
+
94
+ const tempPath = `${lockfilePath}.${process.pid}.${Date.now()}.tmp`;
95
+ await writeFile(tempPath, `${JSON.stringify(normalizeLockfile(lockfile), null, 2)}\n`, "utf8");
96
+ await rename(tempPath, lockfilePath);
97
+ }
98
+
99
+ export async function writeServerLockfileEntry(
100
+ serverId: string,
101
+ metadata: InstalledServerMetadata,
102
+ options: LockfileOptions = {},
103
+ ): Promise<LspLockfile> {
104
+ const lockfile = await readLockfile(options);
105
+ lockfile.servers[serverId] = metadata;
106
+ await writeLockfile(lockfile, options);
107
+ return lockfile;
108
+ }
109
+
110
+ export async function removeServerLockfileEntry(serverId: string, options: LockfileOptions = {}): Promise<LspLockfile> {
111
+ const lockfile = await readLockfile(options);
112
+ delete lockfile.servers[serverId];
113
+ await writeLockfile(lockfile, options);
114
+ return lockfile;
115
+ }
116
+
117
+ export function createInstalledServerMetadata(input: CreateInstalledServerMetadataInput): InstalledServerMetadata {
118
+ const metadata: InstalledServerMetadata = {
119
+ installer: input.installer,
120
+ resolvedCommand: input.resolvedCommand,
121
+ installedAt: (input.installedAt ?? new Date()).toISOString(),
122
+ };
123
+
124
+ if (input.requestedVersion !== undefined) {
125
+ metadata.requestedVersion = input.requestedVersion;
126
+ }
127
+
128
+ if (input.packages !== undefined) {
129
+ metadata.packages = input.packages;
130
+ }
131
+
132
+ if (input.packageDir !== undefined) {
133
+ metadata.packageDir = input.packageDir;
134
+ }
135
+
136
+ if (input.binDir !== undefined) {
137
+ metadata.binDir = input.binDir;
138
+ }
139
+
140
+ return metadata;
141
+ }
142
+
143
+ function normalizeLockfile(lockfile: LspLockfile): LspLockfile {
144
+ return {
145
+ servers: Object.fromEntries(Object.entries(lockfile.servers).sort(([left], [right]) => left.localeCompare(right))),
146
+ };
147
+ }
148
+
149
+ function isLockfile(value: unknown): value is LspLockfile {
150
+ if (!isPlainObject(value) || !isPlainObject(value.servers)) {
151
+ return false;
152
+ }
153
+
154
+ return Object.values(value.servers).every(isInstalledServerMetadata);
155
+ }
156
+
157
+ function isInstalledServerMetadata(value: unknown): value is InstalledServerMetadata {
158
+ return Value.Check(InstalledServerMetadataSchema, value);
159
+ }