pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
368 lines (367 loc) • 12.3 kB
JavaScript
/**
* Interactive LSP Installer
*
* Provides lazy auto-install with user prompt for common languages.
*
* Features:
* - 30-second timeout with auto-accept
* - --auto-install flag for non-interactive mode
* - User choice caching per project
* - Only prompts for "common" languages (Go, Rust, YAML, JSON, Bash)
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getProjectDataDir } from "../file-utils.js";
import { globalInstallArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
import { safeSpawnAsync } from "../safe-spawn.js";
function canUseInteractivePrompt() {
return process.stdin.isTTY === true && process.stdout.isTTY === true;
}
async function isToolOnPath(toolId) {
const locator = process.platform === "win32" ? "where" : "which";
const result = await safeSpawnAsync(locator, [toolId], {
timeout: 5000,
ignoreAmbientSignal: true,
});
return result.status === 0;
}
// Languages that support interactive auto-install prompt
const COMMON_LANGUAGES = {
// --- Originally supported ---
go: {
toolId: "gopls",
toolName: "Go Language Server (gopls)",
installCommand: "go install golang.org/x/tools/gopls@latest",
installStrategy: "shell",
},
rust: {
toolId: "rust-analyzer",
toolName: "Rust Language Server (rust-analyzer)",
installCommand: "rustup component add rust-analyzer",
installStrategy: "shell",
},
yaml: {
toolId: "yaml-language-server",
toolName: "YAML Language Server",
installCommand: "npm install -g yaml-language-server",
packageName: "yaml-language-server",
installStrategy: "npm",
},
json: {
toolId: "vscode-json-language-server",
toolName: "JSON Language Server",
installCommand: "npm install -g vscode-langservers-extracted",
packageName: "vscode-langservers-extracted",
installStrategy: "npm",
},
bash: {
toolId: "bash-language-server",
toolName: "Bash Language Server",
installCommand: "npm install -g bash-language-server",
packageName: "bash-language-server",
installStrategy: "npm",
},
// --- Tier-4: previously silent on ENOENT ---
ruby: {
toolId: "ruby-lsp",
toolName: "Ruby LSP",
installCommand: "gem install ruby-lsp",
installStrategy: "shell",
},
php: {
toolId: "intelephense",
toolName: "PHP Language Server (Intelephense)",
installCommand: "npm install -g intelephense",
packageName: "intelephense",
installStrategy: "npm",
},
csharp: {
toolId: "csharp-ls",
toolName: "C# Language Server (csharp-ls)",
installCommand: "dotnet tool install -g csharp-ls",
installStrategy: "shell",
},
fsharp: {
toolId: "fsautocomplete",
toolName: "F# Language Server (FSAutocomplete)",
installCommand: "dotnet tool install -g fsautocomplete",
installStrategy: "shell",
},
java: {
toolId: "jdtls",
toolName: "Java Language Server (Eclipse JDT LS)",
installCommand: "brew install jdtls # or: https://github.com/eclipse-jdtls/eclipse.jdt.ls",
installStrategy: "manual",
},
kotlin: {
toolId: "kotlin-language-server",
toolName: "Kotlin Language Server",
installCommand: "brew install kotlin-language-server # or: https://github.com/fwcd/kotlin-language-server",
installStrategy: "manual",
},
swift: {
toolId: "sourcekit-lsp",
toolName: "Swift Language Server (SourceKit-LSP)",
installCommand: "xcode-select --install # bundled with Xcode / Swift toolchain",
installStrategy: "manual",
},
dart: {
toolId: "dart",
toolName: "Dart Language Server",
installCommand: "# Install Dart SDK: https://dart.dev/get-dart",
installStrategy: "manual",
},
lua: {
toolId: "lua-language-server",
toolName: "Lua Language Server",
installCommand: "brew install lua-language-server",
installStrategy: "shell",
},
cpp: {
toolId: "clangd",
toolName: "C/C++ Language Server (clangd)",
installCommand: "brew install llvm # or: apt install clangd",
installStrategy: "manual",
},
zig: {
toolId: "zls",
toolName: "Zig Language Server (ZLS)",
installCommand: "brew install zls",
installStrategy: "shell",
},
haskell: {
toolId: "haskell-language-server-wrapper",
toolName: "Haskell Language Server",
installCommand: "ghcup install hls",
installStrategy: "shell",
},
elixir: {
toolId: "elixir-ls",
toolName: "Elixir Language Server (ElixirLS)",
installCommand: "# Download from: https://github.com/elixir-lsp/elixir-ls/releases",
installStrategy: "manual",
},
gleam: {
toolId: "gleam",
toolName: "Gleam Language Server",
installCommand: "brew install gleam",
installStrategy: "shell",
},
ocaml: {
toolId: "ocamllsp",
toolName: "OCaml Language Server (ocamllsp)",
installCommand: "opam install ocaml-lsp-server",
installStrategy: "shell",
},
clojure: {
toolId: "clojure-lsp",
toolName: "Clojure Language Server",
installCommand: "brew install clojure-lsp/brew/clojure-lsp",
installStrategy: "shell",
},
terraform: {
toolId: "terraform-ls",
toolName: "Terraform Language Server",
installCommand: "brew install hashicorp/tap/terraform-ls",
installStrategy: "shell",
},
nix: {
toolId: "nixd",
toolName: "Nix Language Server (nixd)",
installCommand: "nix profile install nixpkgs#nixd",
installStrategy: "shell",
},
};
/**
* Get the cache file path for install choices
*/
function getCachePath(cwd) {
return path.join(getProjectDataDir(cwd), "install-choices.json");
}
/**
* Read cached install choices
*/
async function readChoices(cwd) {
try {
const cachePath = getCachePath(cwd);
const content = await fs.readFile(cachePath, "utf-8");
return JSON.parse(content);
}
catch {
return {};
}
}
/**
* Save install choice to cache
*/
async function saveChoice(cwd, toolId, choice) {
const choices = await readChoices(cwd);
choices[toolId] = { choice, timestamp: Date.now() };
try {
const cachePath = getCachePath(cwd);
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, JSON.stringify(choices, null, 2));
}
catch {
// Ignore cache write errors
}
}
/**
* Prompt user with timeout
*/
function promptUser(timeoutMs) {
return new Promise((resolve) => {
// Set up stdin for single char input
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");
const onData = (data) => {
const char = data.toString().trim().toLowerCase();
cleanup();
if (char === "y" || char === "\n" || char === "\r") {
resolve("yes");
}
else if (char === "n") {
resolve("no");
}
// For any other input, auto-accept after timeout
};
process.stdin.on("data", onData);
// Auto-decline after timeout
const timeout = setTimeout(() => {
cleanup();
resolve("no");
}, timeoutMs);
// Handle stdin closing
process.stdin.on("end", () => {
cleanup();
resolve("no");
});
function cleanup() {
clearTimeout(timeout);
process.stdin.removeListener("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
}
});
}
/**
* Check if --auto-install flag is set
*/
function isAutoInstallEnabled() {
// Check environment variable or process arguments
return (process.env.PI_LENS_AUTO_INSTALL === "1" ||
process.argv.includes("--auto-install"));
}
/**
* Attempt to install a tool using the configured strategy.
*
* - "npm": global install via the resolved manager (npm/pnpm/yarn/bun)
* - "shell": run the static installCommand as argv (gem, dotnet, brew, etc.)
* - "manual": can't auto-install — print the command and return false
*/
export function _parseStaticInstallCommandForTest(command) {
const trimmed = command.trim();
// Shell-strategy commands in COMMON_LANGUAGES are deliberately simple static
// argv-style commands. Refuse metacharacters instead of routing through
// sh/powershell, so this installer path never executes shell text.
if (!trimmed || /[;&|<>$`\\\r\n]/.test(trimmed))
return undefined;
const [cmd, ...args] = trimmed.split(/\s+/);
return cmd ? [cmd, args] : undefined;
}
async function installTool(config) {
const { installCommand, packageName, installStrategy } = config;
if (installStrategy === "manual") {
return false;
}
let invocation;
if (installStrategy === "npm" && packageName) {
// Resolve the machine's package manager (npm/pnpm/yarn/bun) rather than
// hardcoding npm — this global install is what makes an LSP server
// available on hosts without npm. The result lands in that manager's
// global bin dir, which `allAvailableGlobalBinDirs` already discovers.
const pm = await resolveNodePackageManager();
invocation = [pmBinary(pm), globalInstallArgs(pm, packageName)];
}
else {
invocation = _parseStaticInstallCommandForTest(installCommand);
}
if (!invocation)
return false;
const [cmd, args] = invocation;
const result = await safeSpawnAsync(cmd, args, {
timeout: 180000,
ignoreAmbientSignal: true,
});
return result.status === 0;
}
/**
* Prompt user for installation with timeout, or auto-install if flag set
*
* @param language - Language identifier (go, rust, yaml, json, bash)
* @param cwd - Project root
* @returns true if tool is/should be installed, false to skip
*/
export async function promptForInstall(language, cwd) {
const config = COMMON_LANGUAGES[language];
if (!config) {
// Not a common language, don't prompt
return false;
}
// Check cache first
const choices = await readChoices(cwd);
const cached = choices[config.toolId];
if (cached) {
// Cache valid for 30 days
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (Date.now() - cached.timestamp < thirtyDays) {
if (cached.choice === "yes" || cached.choice === "auto") {
const toolAvailable = await isToolOnPath(config.toolId);
if (toolAvailable) {
return true;
}
}
else {
return false; // User previously declined
}
}
}
// Check auto-install flag
if (isAutoInstallEnabled()) {
await saveChoice(cwd, config.toolId, "auto");
return installTool(config);
}
if (!canUseInteractivePrompt()) {
return false;
}
// For manual-only tools, skip the Y/n prompt because user must install manually.
if (config.installStrategy === "manual") {
await saveChoice(cwd, config.toolId, "no");
return false;
}
const answer = await promptUser(10000);
await saveChoice(cwd, config.toolId, answer);
if (answer === "yes") {
return installTool(config);
}
return false;
}
/**
* Get install command for display purposes
*/
export function getInstallCommand(language) {
return COMMON_LANGUAGES[language]?.installCommand;
}
/**
* Get install strategy for a language (exposed for testing)
*/
export function getInstallStrategy(language) {
return COMMON_LANGUAGES[language]?.installStrategy;
}
/**
* Check if a language supports interactive install
*/
export function supportsInteractiveInstall(language) {
return language in COMMON_LANGUAGES;
}