pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
2,931 lines • 121 kB
JavaScript
/**
* Auto-Installation System for pi-lens
*
* Minimal auto-install: Core tools that run frequently.
* Other tools require manual installation with clear instructions.
*
* Auto-install (22 tools):
* - typescript-language-server (TypeScript LSP)
* - pyright (Python LSP)
* - bash-language-server (Bash LSP)
* - yaml-language-server (YAML LSP)
* - vscode-langservers-extracted (JSON LSP)
* - ruff (Python linting)
* - @biomejs/biome (JS/TS/JSON linting/formatting)
* - oxlint (JS/TS linting)
* - madge (circular dependency detection)
* - jscpd (duplicate code detection)
* - @ast-grep/cli (structural code search)
* - knip (dead code detection)
* - yamllint (YAML linting)
* - actionlint (GitHub Actions workflow linting) [GitHub release]
* - sqlfluff (SQL linting/formatting)
* - markdownlint-cli2 (Markdown linting)
* - mypy (Python type checking)
* - rubocop (Ruby linting/autofix)
* - stylelint (CSS/SCSS/Less linting)
* - shellcheck (shell script linting) [GitHub release]
* - shfmt (shell script formatting) [GitHub release]
* - rust-analyzer (Rust LSP) [GitHub release]
* - golangci-lint (Go linting) [GitHub release]
*
* Manual install required (25+ tools):
* - yaml-language-server: npm install -g yaml-language-server
* - vscode-json-languageserver: npm install -g vscode-langservers-extracted
* - bash-language-server: npm install -g bash-language-server
* - svelte-language-server: npm install -g svelte-language-server
* - vscode-css-languageserver: npm install -g vscode-langservers-extracted
* - @prisma/language-server: npm install -g @prisma/language-server
* - dockerfile-language-server: npm install -g dockerfile-language-server-nodejs
* - @vue/language-server: npm install -g @vue/language-server
* - And all language-specific servers (gopls, rust-analyzer, etc.)
*
* Strategies:
* - npm packages via npx/bun
* - pip packages
* - GitHub releases (platform-specific binaries → ~/.pi-lens/bin/)
*/
import { spawn } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import fs from "node:fs/promises";
import https from "node:https";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
const _installerRequire = createRequire(import.meta.url);
import { createGunzip } from "node:zlib";
import { isTestMode } from "../env-utils.js";
import { getGlobalPiLensDir } from "../file-utils.js";
import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
// Global installation directory for pi-lens tools
const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
// Directory for GitHub-downloaded binaries
const GITHUB_BIN_DIR = path.join(getGlobalPiLensDir(), "bin");
// Debug flag - set via PI_LENS_DEBUG=1 or --debug
const DEBUG = process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
const SESSIONSTART_LOG_DIR = getGlobalPiLensDir();
const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
/**
* Log debug messages only when DEBUG is enabled
*/
function debugLog(...args) {
if (DEBUG) {
console.error("[auto-install:debug]", ...args);
}
}
function logSessionStart(msg) {
if (isTestMode()) {
return;
}
const line = `[${new Date().toISOString()}] ${msg}\n`;
void fs
.mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
.then(() => fs.appendFile(SESSIONSTART_LOG, line))
.catch(() => {
// best-effort logging
});
}
/**
* Build a GitHub-release `assetMatch` from a small per-platform table, replacing
* the copy-pasted `if (platform === "linux") return arch === "arm64" ? … : …`
* ladder that several release entries repeat verbatim. Each platform maps to its
* `x64` (default) and optional `arm64` asset substring; a missing platform or
* arch ⇒ unsupported (`undefined`), exactly as the hand-written ladders behaved.
*/
function archAssetMatch(table) {
return (platform, arch) => {
const entry = table[platform];
if (!entry)
return undefined;
return arch === "arm64" ? entry.arm64 : entry.x64;
};
}
export const TOOLS = [
// Core LSP servers
{
id: "typescript-language-server",
name: "TypeScript Language Server",
checkCommand: "typescript-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "typescript-language-server",
binaryName: "typescript-language-server",
},
{
id: "typescript",
name: "TypeScript",
checkCommand: "tsc",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "typescript",
binaryName: "tsc",
},
{
id: "pyright",
name: "Pyright",
checkCommand: "pyright",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "pyright",
binaryName: "pyright",
},
// Linting/formatting tools
{
id: "prettier",
name: "Prettier",
checkCommand: "prettier",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "prettier",
binaryName: "prettier",
},
{
id: "ruff",
name: "Ruff",
checkCommand: "ruff",
checkArgs: ["--version"],
installStrategy: "pip",
packageName: "ruff",
binaryName: "ruff",
},
{
// Alternate Python LSP (fallback when pyright/the `python` server is
// unavailable or disabled). Used as a managedToolId by PythonJediServer.
id: "jedi-language-server",
name: "Jedi Language Server",
checkCommand: "jedi-language-server",
checkArgs: ["--version"],
installStrategy: "pip",
packageName: "jedi-language-server",
binaryName: "jedi-language-server",
},
{
id: "biome",
name: "Biome",
checkCommand: "biome",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "@biomejs/biome",
binaryName: "biome",
platformPackage: {
base: "@biomejs/cli",
suffixes: {
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"darwin-x64": "darwin-x64",
"darwin-arm64": "darwin-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
},
binaries: ["biome"],
},
},
// Analysis tools (run at session start / turn end)
{
id: "madge",
name: "Madge",
checkCommand: "madge",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "madge",
binaryName: "madge",
},
{
id: "jscpd",
name: "jscpd",
checkCommand: "jscpd",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "jscpd@5.0.12", // v4's packaging bug (reprism dep missing lib/languages/) is gone in v5's ground-up Rust rewrite — verified: real per-platform native binary (jscpd-windows-x64-msvc etc. via optionalDependencies, no missing-dir regression), --min-lines/--min-tokens/--reporters/--output/--ignore all unchanged, JSON schema fields read by clients/jscpd-client.ts's parseReport() (statistics.total.*, duplicates[].firstFile/secondFile.name+start, .lines, .tokens) are identical, and it's ~50x faster on this repo (4.1s -> 76ms detection time) — closes #582
binaryName: "jscpd",
},
// Structural search and dead code detection
{
id: "ast-grep",
name: "ast-grep CLI",
checkCommand: "ast-grep",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "@ast-grep/cli",
binaryName: "ast-grep",
platformPackage: {
suffixes: {
"linux-x64": "linux-x64-gnu",
"linux-arm64": "linux-arm64-gnu",
"darwin-x64": "darwin-x64",
"darwin-arm64": "darwin-arm64",
"win32-x64": "win32-x64-msvc",
"win32-arm64": "win32-arm64-msvc",
"win32-ia32": "win32-ia32-msvc",
},
binaries: ["ast-grep", "sg"],
},
},
{
id: "knip",
name: "Knip",
checkCommand: "knip",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "knip",
binaryName: "knip",
},
{
id: "yamllint",
name: "yamllint",
checkCommand: "yamllint",
checkArgs: ["--version"],
installStrategy: "pip",
packageName: "yamllint",
binaryName: "yamllint",
},
{
id: "sqlfluff",
name: "sqlfluff",
checkCommand: "sqlfluff",
checkArgs: ["--version"],
installStrategy: "pip",
packageName: "sqlfluff",
binaryName: "sqlfluff",
},
{
id: "bash-language-server",
name: "Bash Language Server",
checkCommand: "bash-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "bash-language-server",
binaryName: "bash-language-server",
},
{
id: "yaml-language-server",
name: "YAML Language Server",
checkCommand: "yaml-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "yaml-language-server",
binaryName: "yaml-language-server",
},
{
id: "vscode-json-language-server",
name: "VSCode JSON Language Server",
checkCommand: "vscode-json-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "vscode-langservers-extracted",
binaryName: "vscode-json-language-server",
},
{
id: "vscode-html-languageserver-bin",
name: "VSCode HTML Language Server",
checkCommand: "vscode-html-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "vscode-html-languageserver-bin",
binaryName: "vscode-html-language-server",
},
{
id: "htmlhint",
name: "HTMLHint",
checkCommand: "htmlhint",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "htmlhint",
binaryName: "htmlhint",
},
{
id: "hadolint",
name: "Hadolint",
checkCommand: "hadolint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "hadolint",
github: {
repo: "hadolint/hadolint",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux.aarch64" : "linux.x86_64";
if (platform === "darwin")
return arch === "arm64" ? "macos-arm64" : "macos-x86_64";
if (platform === "win32")
return "windows-x86_64.exe";
return undefined;
},
},
},
{
// Opengrep: a single standalone binary per platform on GitHub releases —
// no login, no telemetry (the reason for switching off Semgrep, #111).
id: "opengrep",
name: "Opengrep",
checkCommand: "opengrep",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "opengrep",
github: {
repo: "opengrep/opengrep",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "opengrep_manylinux_aarch64"
: "opengrep_manylinux_x86";
if (platform === "darwin")
return arch === "arm64" ? "opengrep_osx_arm64" : "opengrep_osx_x86";
// One x86 Windows build; runs on arm64 Windows via emulation.
if (platform === "win32")
return "opengrep_windows_x86.exe";
return undefined;
},
},
},
{
id: "vscode-css-languageserver",
name: "VSCode CSS Language Server",
checkCommand: "vscode-css-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "vscode-css-languageserver",
binaryName: "vscode-css-language-server",
},
{
id: "dockerfile-language-server-nodejs",
name: "Dockerfile Language Server",
checkCommand: "docker-langserver",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "dockerfile-language-server-nodejs",
binaryName: "docker-langserver",
},
{
id: "intelephense",
name: "Intelephense",
checkCommand: "intelephense",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "intelephense",
binaryName: "intelephense",
},
{
id: "@prisma/language-server",
name: "Prisma Language Server",
checkCommand: "prisma-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "@prisma/language-server",
binaryName: "prisma-language-server",
},
{
id: "@vue/language-server",
name: "Vue Language Server",
checkCommand: "vue-language-server",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "@vue/language-server",
binaryName: "vue-language-server",
},
{
id: "svelte-language-server",
name: "Svelte Language Server",
checkCommand: "svelteserver",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "svelte-language-server",
binaryName: "svelteserver",
},
{
id: "markdownlint",
name: "markdownlint-cli2",
checkCommand: "markdownlint-cli2",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "markdownlint-cli2",
binaryName: "markdownlint-cli2",
},
{
id: "mypy",
name: "mypy",
checkCommand: "mypy",
checkArgs: ["--version"],
installStrategy: "pip",
packageName: "mypy",
binaryName: "mypy",
},
{
id: "rubocop",
name: "RuboCop",
checkCommand: "rubocop",
checkArgs: ["--version"],
installStrategy: "gem",
packageName: "rubocop",
binaryName: "rubocop",
},
{
id: "stylelint",
name: "Stylelint",
checkCommand: "stylelint",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "stylelint",
binaryName: "stylelint",
},
{
id: "oxlint",
name: "Oxlint",
checkCommand: "oxlint",
checkArgs: ["--version"],
installStrategy: "npm",
packageName: "oxlint",
binaryName: "oxlint",
},
// GitHub release binaries
{
id: "shellcheck",
name: "ShellCheck",
checkCommand: "shellcheck",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "shellcheck",
github: {
repo: "koalaman/shellcheck",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "linux.aarch64.tar.xz"
: "linux.x86_64.tar.xz";
if (platform === "darwin")
return arch === "arm64"
? "darwin.aarch64.tar.xz"
: "darwin.x86_64.tar.xz";
if (platform === "win32")
return "zip";
return undefined;
},
binaryInArchive: "shellcheck",
},
},
{
id: "shfmt",
name: "shfmt",
checkCommand: "shfmt",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "shfmt",
github: {
repo: "mvdan/sh",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux_arm64" : "linux_amd64";
if (platform === "darwin")
return arch === "arm64" ? "darwin_arm64" : "darwin_amd64";
if (platform === "win32")
return arch === "arm64" ? "windows_arm64.exe" : "windows_amd64.exe";
return undefined;
},
// bare binary, no archive
},
},
{
id: "rust-analyzer",
name: "rust-analyzer",
checkCommand: "rust-analyzer",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "rust-analyzer",
github: {
repo: "rust-lang/rust-analyzer",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "aarch64-unknown-linux-gnu.gz"
: "x86_64-unknown-linux-gnu.gz";
if (platform === "darwin")
return arch === "arm64"
? "aarch64-apple-darwin.gz"
: "x86_64-apple-darwin.gz";
if (platform === "win32")
return "x86_64-pc-windows-msvc.zip";
return undefined;
},
// Linux/macOS: bare .gz; Windows: .zip archive containing rust-analyzer.exe
},
},
{
// Alternate JS/TS LSP (fallback when the `typescript` server is unavailable
// or disabled — e.g. Deno projects). Used as a managedToolId by DenoServer.
// Every platform ships a .zip containing the `deno` binary (the github
// strategy extracts it, as it does for rust-analyzer's Windows .zip).
id: "deno",
name: "Deno",
checkCommand: "deno",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "deno",
github: {
repo: "denoland/deno",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "deno-aarch64-unknown-linux-gnu.zip"
: "deno-x86_64-unknown-linux-gnu.zip";
if (platform === "darwin")
return arch === "arm64"
? "deno-aarch64-apple-darwin.zip"
: "deno-x86_64-apple-darwin.zip";
// Windows ships only x86_64 (runs under emulation on arm64).
if (platform === "win32")
return "deno-x86_64-pc-windows-msvc.zip";
return undefined;
},
},
},
{
id: "golangci-lint",
name: "golangci-lint",
checkCommand: "golangci-lint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "golangci-lint",
github: {
repo: "golangci/golangci-lint",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux-arm64.tar.gz" : "linux-amd64.tar.gz";
if (platform === "darwin")
return arch === "arm64"
? "darwin-arm64.tar.gz"
: "darwin-amd64.tar.gz";
if (platform === "win32")
return arch === "arm64" ? "windows-arm64.zip" : "windows-amd64.zip";
return undefined;
},
binaryInArchive: "golangci-lint",
},
},
{
id: "ktlint",
name: "ktlint",
checkCommand: "ktlint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "ktlint",
github: {
// ktlint ships a self-executable `ktlint` (a JAR with a shell preamble)
// for Linux/macOS, plus a `ktlint.bat` wrapper for Windows that runs
// `java -jar %~dp0ktlint`. On Windows BOTH files are needed: the .bat AND
// the `ktlint` jar it wraps (#218). No arm64-specific asset.
repo: "pinterest/ktlint",
assetMatch: (platform, _arch) => {
if (platform === "linux")
return "ktlint";
if (platform === "darwin")
return "ktlint";
if (platform === "win32")
return "ktlint.bat";
return undefined;
},
extraAssets: (platform) => (platform === "win32" ? ["ktlint"] : []),
},
},
{
// ktfmt (Meta's opinionated Kotlin formatter) ships only as a Maven-Central
// fat JAR — no native binary, no npm package — so it uses the maven strategy
// (#129). Run via a `java -jar` launcher; requires a JRE.
id: "ktfmt",
name: "ktfmt",
checkCommand: "ktfmt",
checkArgs: ["--version"],
installStrategy: "maven",
binaryName: "ktfmt",
maven: {
groupId: "com.facebook",
artifactId: "ktfmt",
version: "0.63",
classifier: "with-dependencies",
},
},
{
// SpotBugs (bytecode bug-pattern analyzer for Java/Kotlin/Scala/Groovy)
// ships as a distribution archive — a lib/ of many JARs + bin/ launchers,
// NOT a runnable fat JAR — so it uses the archive strategy, not maven
// (refs #133). Requires a JRE (gated by the runner, not the install).
id: "spotbugs",
name: "SpotBugs",
checkCommand: "spotbugs",
checkArgs: ["-version"],
installStrategy: "archive",
binaryName: "spotbugs",
archive: {
url: "https://github.com/spotbugs/spotbugs/releases/download/4.10.2/spotbugs-4.10.2.tgz",
kind: "tgz",
launcher: "bin/spotbugs",
},
},
{
// PowerShell Editor Services (#278). NOT a single binary — a multi-folder
// PowerShell MODULE BUNDLE launched via `pwsh Start-EditorServices.ps1
// -Stdio` (see PowerShellServer.spawn). archive TREE BUNDLE: the release zip
// extracts sibling module dirs (PowerShellEditorServices/, PSReadLine/,
// PSScriptAnalyzer/) at the root with no wrapping dir, so stripComponents:0
// + no launcher — the whole tree is kept and resolved to its extract dir.
// checkCommand "pwsh" documents the runtime but is unused for resolution
// (tree bundles resolve only via the extract dir + treeMarker).
id: "powershell-editor-services",
name: "PowerShell Editor Services",
checkCommand: "pwsh",
checkArgs: ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
installStrategy: "archive",
binaryName: "powershell-editor-services",
archive: {
url: "https://github.com/PowerShell/PowerShellEditorServices/releases/download/v4.6.0/PowerShellEditorServices.zip",
kind: "zip",
stripComponents: 0,
treeMarker: "PowerShellEditorServices/Start-EditorServices.ps1",
},
},
{
// clangd (C/C++/Obj-C LSP, #241) — a self-contained native TREE BUNDLE: the
// release zip wraps `clangd_<ver>/{bin,lib}` (bin/clangd[.exe] + the bundled
// libclang headers under lib/), so stripComponents:1 drops the version dir and
// the whole tree is kept (no launcher). Unlike PSES there is no external
// runtime — CppServer launches `<bundle>/bin/clangd` directly. checkCommand
// documents the binary but is unused for resolution (tree bundles resolve only
// via the extract dir + treeMarker). Platform-matched url: clangd ships x64
// prebuilts; arm runs the x64 build under Rosetta/emulation (darwin/win32),
// while linux/arm64 has no official build → undefined (graceful unavailable).
id: "clangd",
name: "clangd",
checkCommand: "clangd",
checkArgs: ["--version"],
installStrategy: "archive",
binaryName: "clangd",
archive: {
url: (platform, arch) => {
const version = "22.1.0";
const base = `https://github.com/clangd/clangd/releases/download/${version}`;
if (platform === "linux")
return arch === "x64" ? `${base}/clangd-linux-${version}.zip` : undefined;
if (platform === "darwin")
return `${base}/clangd-mac-${version}.zip`;
if (platform === "win32")
return `${base}/clangd-windows-${version}.zip`;
return undefined;
},
kind: "zip",
stripComponents: 1,
treeMarker: "bin",
},
},
{
// lua-language-server (#564, split from #241) — same self-contained native
// TREE BUNDLE shape as clangd: bin/lua-language-server[.exe] + bundled
// locale/meta files, no external runtime. UNLIKE clangd, the release
// archive has NO wrapping version dir (verified by inspecting the actual
// 3.18.2 linux-x64 .tar.gz and win32-x64 .zip contents: `bin/`, `LICENSE`,
// `locale/`, … sit at archive root) — so stripComponents:0, not 1.
// LuaServer launches `<bundle>/bin/lua-language-server` directly.
// checkCommand documents the binary but is unused for resolution (tree
// bundles resolve only via the extract dir + treeMarker). Platform-matched
// url: LuaLS publishes darwin/linux x64+arm64 and win32 x64 (no win32/arm64
// build as of 3.18.2 → undefined, graceful unavailable); asset naming
// verified against the live GitHub release listing, not guessed.
id: "lua-language-server",
name: "lua-language-server",
checkCommand: "lua-language-server",
checkArgs: ["--version"],
installStrategy: "archive",
binaryName: "lua-language-server",
archive: {
url: (platform, arch) => {
const version = "3.18.2";
const base = `https://github.com/LuaLS/lua-language-server/releases/download/${version}`;
if (platform === "linux")
return arch === "arm64"
? `${base}/lua-language-server-${version}-linux-arm64.tar.gz`
: `${base}/lua-language-server-${version}-linux-x64.tar.gz`;
if (platform === "darwin")
return arch === "arm64"
? `${base}/lua-language-server-${version}-darwin-arm64.tar.gz`
: `${base}/lua-language-server-${version}-darwin-x64.tar.gz`;
if (platform === "win32")
return arch === "arm64"
? undefined
: `${base}/lua-language-server-${version}-win32-x64.zip`;
return undefined;
},
kind: "zip",
stripComponents: 0,
treeMarker: "bin",
},
},
{
id: "actionlint",
name: "actionlint",
checkCommand: "actionlint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "actionlint",
github: {
repo: "rhysd/actionlint",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux_arm64.tar.gz" : "linux_amd64.tar.gz";
if (platform === "darwin")
return arch === "arm64"
? "darwin_arm64.tar.gz"
: "darwin_amd64.tar.gz";
if (platform === "win32")
return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
return undefined;
},
binaryInArchive: "actionlint",
},
},
{
// zizmor: GitHub Actions workflow security scanner that speaks LSP (#272).
// cargo-dist release archives, one per target triple, each holding a single
// `zizmor` binary (extracted via the recursive binary find). Online audits
// (known-vulnerable-actions, unpinned-uses, …) need a GitHub token — the LSP
// spawn forwards one via resolveZizmorGitHubToken (clients/zizmor-config.ts).
id: "zizmor",
name: "zizmor",
checkCommand: "zizmor",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "zizmor",
github: {
repo: "zizmorcore/zizmor",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "aarch64-unknown-linux-gnu.tar.gz"
: "x86_64-unknown-linux-gnu.tar.gz";
if (platform === "darwin")
return arch === "arm64"
? "aarch64-apple-darwin.tar.gz"
: "x86_64-apple-darwin.tar.gz";
// One x86 Windows build; arm64 Windows runs it under emulation.
if (platform === "win32")
return "x86_64-pc-windows-msvc.zip";
return undefined;
},
binaryInArchive: "zizmor",
},
},
{
// typos-lsp: source-code spell checker that speaks LSP (#283). cargo-dist
// release archives, one per target triple, each holding a single `typos-lsp`
// binary (extracted via the recursive binary find). NO token / network — the
// dictionary is compiled in. The binary takes no `--version` (it ignores args
// and serves the LSP on stdin/stdout); the PATH probe ignores checkArgs and
// verifyToolBinary runs with stdin:ignore so the server gets EOF and exits.
id: "typos-lsp",
name: "typos-lsp",
checkCommand: "typos-lsp",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "typos-lsp",
github: {
repo: "tekumara/typos-lsp",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "aarch64-unknown-linux-gnu.tar.gz"
: "x86_64-unknown-linux-gnu.tar.gz";
if (platform === "darwin")
return arch === "arm64"
? "aarch64-apple-darwin.tar.gz"
: "x86_64-apple-darwin.tar.gz";
if (platform === "win32")
// Native win-arm64 build (one better than zizmor, which emulates).
return arch === "arm64"
? "aarch64-pc-windows-msvc.zip"
: "x86_64-pc-windows-msvc.zip";
return undefined;
},
binaryInArchive: "typos-lsp",
},
},
{
id: "tflint",
name: "tflint",
checkCommand: "tflint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "tflint",
github: {
repo: "terraform-linters/tflint",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux_arm64.zip" : "linux_amd64.zip";
if (platform === "darwin")
return arch === "arm64" ? "darwin_arm64.zip" : "darwin_amd64.zip";
if (platform === "win32")
return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
return undefined;
},
binaryInArchive: "tflint",
},
},
{
id: "gitleaks",
name: "gitleaks",
checkCommand: "gitleaks",
checkArgs: ["version"],
installStrategy: "github",
binaryName: "gitleaks",
github: {
repo: "gitleaks/gitleaks",
// gitleaks asset naming uses `x64` not `amd64` (unlike most Go-built
// tools). Substring match is exact-enough — release assets are
// named e.g. `gitleaks_8.18.4_linux_x64.tar.gz`.
assetMatch: archAssetMatch({
linux: { x64: "linux_x64.tar.gz", arm64: "linux_arm64.tar.gz" },
darwin: { x64: "darwin_x64.tar.gz", arm64: "darwin_arm64.tar.gz" },
win32: { x64: "windows_x64.zip", arm64: "windows_arm64.zip" },
}),
binaryInArchive: "gitleaks",
},
},
{
id: "trivy",
name: "Trivy",
checkCommand: "trivy",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "trivy",
github: {
repo: "aquasecurity/trivy",
// Trivy asset naming is `trivy_<ver>_<OS>-<bits>.{tar.gz,zip}` with a
// capitalized OS and `64bit`/`ARM64` arch tokens — e.g.
// `trivy_0.71.2_Linux-64bit.tar.gz`, `trivy_0.71.2_macOS-ARM64.tar.gz`.
// No windows-arm64 asset exists (win32.arm64 omitted), so (like
// swiftlint) trivy is absent from GITHUB_TOOLS and covered by the
// weaker "at least one platform" guard.
assetMatch: archAssetMatch({
linux: { x64: "Linux-64bit.tar.gz", arm64: "Linux-ARM64.tar.gz" },
darwin: { x64: "macOS-64bit.tar.gz", arm64: "macOS-ARM64.tar.gz" },
win32: { x64: "windows-64bit.zip" },
}),
binaryInArchive: "trivy",
},
},
{
id: "swiftlint",
name: "SwiftLint",
checkCommand: "swiftlint",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "swiftlint",
github: {
repo: "realm/SwiftLint",
assetMatch: (platform, arch) => {
if (platform === "darwin")
return "portable_swiftlint.zip";
if (platform === "linux")
return arch === "arm64"
? "swiftlint_linux_arm64.zip"
: "swiftlint_linux_amd64.zip";
return undefined;
},
binaryInArchive: "swiftlint",
},
},
{
id: "taplo",
name: "taplo",
checkCommand: "taplo",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "taplo",
github: {
repo: "tamasfe/taplo",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "taplo-linux-aarch64.gz"
: "taplo-linux-x86_64.gz";
if (platform === "darwin")
return arch === "arm64"
? "taplo-darwin-aarch64.gz"
: "taplo-darwin-x86_64.gz";
if (platform === "win32")
return "taplo-windows-x86_64.gz";
return undefined;
},
},
},
{
id: "vale",
name: "Vale",
checkCommand: "vale",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "vale",
github: {
repo: "vale-cli/vale",
assetMatch: (platform, arch) => {
const version = "3.14.2";
if (platform === "linux")
return arch === "arm64"
? `vale_${version}_Linux_arm64.tar.gz`
: `vale_${version}_Linux_64-bit.tar.gz`;
if (platform === "darwin")
return arch === "arm64"
? `vale_${version}_macOS_arm64.tar.gz`
: `vale_${version}_macOS_64-bit.tar.gz`;
if (platform === "win32")
return `vale_${version}_Windows_64-bit.zip`;
return undefined;
},
binaryInArchive: "vale",
},
},
{
id: "terraform-ls",
name: "terraform-ls",
checkCommand: "terraform-ls",
checkArgs: ["version"],
installStrategy: "github",
binaryName: "terraform-ls",
github: {
repo: "hashicorp/terraform-ls",
hashiCorpReleaseProduct: "terraform-ls",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64" ? "linux_arm64.zip" : "linux_amd64.zip";
if (platform === "darwin")
return arch === "arm64" ? "darwin_arm64.zip" : "darwin_amd64.zip";
if (platform === "win32")
return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
return undefined;
},
binaryInArchive: "terraform-ls",
},
},
{
id: "zls",
name: "zls",
checkCommand: "zls",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "zls",
github: {
repo: "zigtools/zls",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "aarch64-linux.tar.xz"
: "x86_64-linux.tar.xz";
if (platform === "darwin")
return arch === "arm64"
? "aarch64-macos.tar.xz"
: "x86_64-macos.tar.xz";
if (platform === "win32")
return arch === "arm64"
? "aarch64-windows.zip"
: "x86_64-windows.zip";
return undefined;
},
binaryInArchive: "zls",
},
},
{
// clojure-lsp ships a self-contained native (GraalVM) binary per platform
// on GitHub releases — no JVM needed. Used as managedToolId by ClojureServer.
// The .zip carries the bare binary (located recursively on extract).
id: "clojure-lsp",
name: "clojure-lsp",
checkCommand: "clojure-lsp",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "clojure-lsp",
github: {
repo: "clojure-lsp/clojure-lsp",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "native-linux-aarch64.zip"
: "native-linux-amd64.zip";
if (platform === "darwin")
return arch === "arm64"
? "native-macos-aarch64.zip"
: "native-macos-amd64.zip";
// Only an x86_64 Windows native build; runs on arm64 via emulation.
if (platform === "win32")
return "native-windows-amd64.zip";
return undefined;
},
binaryInArchive: "clojure-lsp",
},
},
{
// gleam ships a single static binary per platform on GitHub releases; the
// LSP runs via `gleam lsp`. Used as managedToolId by GleamServer. The linux
// build is a FLAT musl tarball (a bare `gleam`), handled by the recursive
// tar-binary lookup in installGitHubTool.
id: "gleam",
name: "Gleam",
checkCommand: "gleam",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "gleam",
github: {
repo: "gleam-lang/gleam",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "aarch64-unknown-linux-musl.tar.gz"
: "x86_64-unknown-linux-musl.tar.gz";
if (platform === "darwin")
return arch === "arm64"
? "aarch64-apple-darwin.tar.gz"
: "x86_64-apple-darwin.tar.gz";
if (platform === "win32")
return arch === "arm64"
? "aarch64-pc-windows-msvc.zip"
: "x86_64-pc-windows-msvc.zip";
return undefined;
},
binaryInArchive: "gleam",
},
},
{
// marksman ships a single BARE (uncompressed) binary per platform on GitHub
// releases — no archive, so it lands via the bare-binary branch of
// installGitHubTool (the `else` that writes the asset directly, like shfmt).
// Used as managedToolId by MarksmanServer; LSP entrypoint is `marksman
// server` (stdio). macOS ships a universal binary; Windows has only x64
// (runs on arm64 via emulation) — so all six platform/arch combos resolve.
id: "marksman",
name: "Marksman",
checkCommand: "marksman",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "marksman",
github: {
repo: "artempyanykh/marksman",
assetMatch: (platform, arch) => {
if (platform === "linux")
return arch === "arm64"
? "marksman-linux-arm64"
: "marksman-linux-x64";
if (platform === "darwin")
return "marksman-macos";
if (platform === "win32")
return "marksman.exe";
return undefined;
},
// bare binary — no binaryInArchive
},
},
{
// Expert ships a bare native binary per platform on GitHub releases. Its
// `--stdio` flag is required to start the LSP transport. Windows arm64 uses
// the x64 binary through Windows' built-in x64 emulation.
id: "expert",
name: "Expert",
checkCommand: "expert",
checkArgs: ["--version"],
installStrategy: "github",
binaryName: "expert",
github: {
repo: "expert-lsp/expert",
assetMatch: (platform, arch) => {
if (arch !== "x64" && arch !== "arm64")
return undefined;
if (platform === "linux")
return arch === "arm64"
? "expert_linux_arm64"
: "expert_linux_amd64";
if (platform === "darwin")
return arch === "arm64"
? "expert_darwin_arm64"
: "expert_darwin_amd64";
if (platform === "win32")
return "expert_windows_amd64.exe";
return undefined;
},
// bare binary — no binaryInArchive
},
},
];
const ensureInFlight = new Map();
// Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
const resolvedPathCache = new Map();
const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
let _probeCache = null;
let _probeCacheDirty = false;
let _probeCacheFlushTimer = null;
async function readProbeCache() {
if (_probeCache !== null)
return _probeCache;
try {
const raw = await fs.readFile(PROBE_CACHE_PATH, "utf-8");
_probeCache = JSON.parse(raw);
}
catch {
_probeCache = {};
}
return _probeCache;
}
function scheduleProbeFlush() {
if (_probeCacheFlushTimer !== null)
return;
_probeCacheFlushTimer = setTimeout(() => {
_probeCacheFlushTimer = null;
if (!_probeCacheDirty || _probeCache === null)
return;
_probeCacheDirty = false;
void fs
.writeFile(PROBE_CACHE_PATH, JSON.stringify(_probeCache, null, 2))
.catch(() => { });
}, 300);
_probeCacheFlushTimer.unref?.();
}
function isAstGrepVersionOutput(output) {
return /\bast[- ]grep\b/i.test(output);
}
async function verifyAstGrepProbePath(binPath) {
return new Promise((resolve) => {
const proc = spawn(binPath, ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(binPath),
timeout: 5000,
});
let output = "";
proc.stdout?.on("data", (data) => (output += data));
proc.stderr?.on("data", (data) => (output += data));
proc.on("exit", (code) => {
resolve(code === 0 && isAstGrepVersionOutput(output));
});
proc.on("error", () => resolve(false));
});
}
// Exported for testing only.
export async function checkProbeCache(toolId) {
const cache = await readProbeCache();
const entry = cache[toolId];
if (!entry)
return undefined;
if (Date.now() - entry.cachedAt > PROBE_CACHE_TTL_MS) {
logSessionStart(`auto-install probe-cache ${toolId}: miss (ttl expired)`);
delete cache[toolId];
_probeCacheDirty = true;
scheduleProbeFlush();
return undefined;
}
try {
await fs.access(entry.path);
const stat = await fs.stat(entry.path);
if (stat.mtimeMs !== entry.mtimeMs) {
logSessionStart(`auto-install probe-cache ${toolId}: miss (mtime changed)`);
delete cache[toolId];
_probeCacheDirty = true;
scheduleProbeFlush();
return undefined;
}
if (toolId === "ast-grep" && !(await verifyAstGrepProbePath(entry.path))) {
logSessionStart(`auto-install probe-cache ${toolId}: miss (not ast-grep: ${entry.path})`);
delete cache[toolId];
_probeCacheDirty = true;
scheduleProbeFlush();
return undefined;
}
return entry.path;
}
catch {
logSessionStart(`auto-install probe-cache ${toolId}: miss (gone: ${entry.path})`);
delete cache[toolId];
_probeCacheDirty = true;
scheduleProbeFlush();
return undefined;
}
}
// Exported for testing only.
export async function updateProbeCache(toolId, resolvedPath) {
try {
const stat = await fs.stat(resolvedPath);
const cache = await readProbeCache();
cache[toolId] = {
path: resolvedPath,
mtimeMs: stat.mtimeMs,
cachedAt: Date.now(),
};
_probeCacheDirty = true;
scheduleProbeFlush();
}
catch {
// best-effort
}
}
// Exported for testing only.
export function resetProbeCacheStateForTesting() {
_probeCache = null;
_probeCacheDirty = false;
resolvedPathCache.clear();
ensureInFlight.clear();
lastManagedInstallVersion.clear();
if (_probeCacheFlushTimer !== null) {
clearTimeout(_probeCacheFlushTimer);
_probeCacheFlushTimer = null;
}
}
// --- Check Functions ---
/**
* Check if a command is available in PATH by walking PATH entries and
* verifying each candidate is a real file with non-zero size.
* Catches broken symlinks (stat throws ENOENT or returns size 0) without
* spawning a process — ~μs per candidate vs ~50ms for which/where.
*/
async function isCommandAvailable(command, _args) {
const isWindows = process.platform === "win32";
const pathEnv = process.env.PATH || process.env.Path || process.env.path || "";
const dirs = pathEnv.split(path.delimiter);
// On Windows, probe .exe, .cmd, and .bat extensions in addition to bare name.
// On Unix, probe bare name and extensionless (scripts, symlinks).
const names = isWindows
? [command, `${command}.exe`, `${command}.cmd`, `${command}.bat`]
: [command];
for (const dir of dirs) {
if (!dir)
continue;
for (const name of names) {
const candidate = path.join(dir, name);
try {
const stat = statSync(candidate);
// isFile() returns false for broken symlinks (target missing)
if (stat.isFile() && stat.size > 0) {
return true;
}
}
catch {
// ENOENT or permission denied — skip this candidate
}
}
}
return false;
}
// --- Verification Functions
/**
* Stdio LSP servers built on `vscode-languageserver-node` (the entire
* `vscode-langservers-extracted` family — json/css/html/eslint, and markdown)
* reject a bare `--version`: `createConnection()` throws immediately because no
* transport flag was supplied, and the process exits non-zero. That error is
* positive proof the binary loaded and is a working LSP server — it just needs
* `--stdio` to actually run — so `--version`-based verification must treat it as
* success rather than a broken install (#208). A genuinely broken binary fails
* with a different error (SyntaxError, ERR_MODULE_NOT_FOUND, …) that does not
* match this pattern, so the broken-install guard is preserved.
*/
export function isLspTransportRequiredError(output) {
return /Connection (?:input|output) stream is not set|Use arguments of createConnection/i.test(output);
}
/**
* Parse the exact version pinned in a TOOLS `packageName` spec, e.g.
* `"jscpd@3.5.10"` -> `"3.5.10"`. Scoped packages (`"@ast-grep/cli"`) have no
* pin unless a version is appended after the package name
* (`"@scope/pkg@1.2.3"`) — `lastIndexOf("@")` on a bare scope marker (index 0)
* correctly reports "no pin" rather than mistaking the scope for a version.
* Returns undefined when packageName has no explicit `@version` suffix.
*/
function parsePinnedVersion(packageName) {
const at = packageName.lastIndexOf("@");
if (at <= 0)
return undefined;
return packageName.slice(at + 1) || undefined;
}
/** Extract the first semver-ish token (e.g. "3.5.10") from `--version` output. */
function extractVersionToken(output) {
return output.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/)?.[0];
}
/**
* Version reported by the last successful `--version` probe of a pi-lens
* managed local npm install, keyed by toolId. Populated only inside
* getToolPath()'s managed-local-install checks below — i.e. only when that
* code path already spawns verifyToolBinary anyway (cache hits in
* ensureTool()'s fast paths never reach getToolPath, so this never adds a new
* spawn). Consumed by ensureTool() to detect drift against the tool's current
* `packageName` pin (#589) — deliberately scoped to installStrategy "npm"
* with an explicit version pin; unpinned tools and non-npm strategies (github/
* maven/archive) never populate or read this map.
*/
const lastManagedInstallVersion = new Map();
/**
* Verify a tool binary actually works by running --version
* This catches broken symlinks, partial installs, and corrupted binaries.
* `onVersionOutput`, when provided, receives the raw stdout on a successful
* (exit 0) probe — used to piggyback version-pin drift detection onto this
* already-happening spawn instead of adding a new one (#589).
*/
async function verifyToolBinary(binPath, onVersionOutput) {
return new Promise((resolve) => {
const isWindows = process.platform === "win32";
const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
// On Windows, resolve the best executable path:
// - extensionless → prefer .cmd (cmd.exe-safe)
// - .ps1 → prefer .cmd sibling to avoid PowerShell execution-policy hangs
// - .cmd / .exe → use as-is
let execPath = isWindows && !hasKnownWindowsExt ? `${binPath}.cmd` : binPath;
let useShell = isWindows && /\.(cmd|bat)$/i.test(execPath);
if (isWindows && /\.ps1$/i.test(execPath)) {
const cmdSibling = `${execPath.slice(0, -4)}.cmd`;
if (require("node:fs").existsSync(cmdSibling)) {
execPath = cmdSibling;
useShell = true;
}
else {
// Fall back to running without shell — cmd.exe can't run .ps1
useShell = false;
}
}
// When shell:true (Windows .cmd), bake args into the command string to avoid DEP0190.
const spawnCmd = useShell ? `"${execPath}" --version` : execPath;
const proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
timeout: 10000,
stdio: ["ignore", "pipe", "pipe"],
shell: useShell,
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => (stdout += data));
proc.stderr?.on("data", (data) => (stderr += data));
proc.on("exit", (code) => {
if (code === 0) {
debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
onVersionOutput?.(stdout);
resolve(true);
}
else if (isLspTransportRequiredError(`${stdout}\n${stderr}`)) {
// Valid stdio LSP server that rejects `--version` (#208) — the
// transport-required error proves the binary works.
debugLog(`Verified (stdio LSP, transport-required): ${binPath}`);
resolve(true);
}
else {
logSessionStart(`auto-install verify: failed for ${binPath} (exit=${code})`);
resolve(false);
}
});
proc.on("error", (err) => {
logSessionStart(`auto-install verify: error for ${binPath}: ${err.message}`);
resolve(false);
});
});
}
/**
* Get detailed status for all tools
*/
export async function getAllToolStatuses() {
const statuses = [];
for (const tool of TOOLS) {
const status = {
id: tool.id,
name: tool.name,
installed: false,
source: "not-installed",
strategy: tool.installStrategy,
};
// 0. Tree-bundle archives resolve ONLY to their extract dir — never via a
// PATH/global probe (the runtime may be present while the bundle is absent).
if (tool.installStrategy === "archive" && !tool.archive?.launcher) {
const bundleDir = await getArchiveTreeBundlePath(tool);
if (bundleDir) {
status.installed = true;
status.source = "archive-dist";
status.path = bundleDir;
}
statuses.push(status);
continue;
}
// 1. Check if in PATH (global)
if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
status.installed = true;
status.source = "global-path";
status.path = tool.checkCommand;
// Try to get version
const versionResult = await new Promise((resolve) => {
const proc = spawn(tool.checkCommand, ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
timeout: 5000,
});
let out = "";
proc.stdout?.on("data", (d) => (out += d));
proc.stderr?.on("data", (d) => (out += d));
proc.on("exit", () => resolve(out.trim().split("\n")[0]?.slice(0, 30) || ""));
proc.on("error", () => resolve(""));
});
status.version = versionResult || undefined;
statuses.push(status);
continue;
}
// 2. Check npm global
if (tool.installStrategy === "npm") {
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
if (npmPath) {
status.installed = true;
status.source = "npm-global";
status.path = npmPath;
statuses.push(status);
continue;
}
}
// 3. Check pip user install
if (tool.installStrategy === "pip") {
const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
if (pipPath) {
status.installed = true;
status.source = "pip-user";
status.path = pipPath;
statuses.push(status);
continue;
}
}
// 4. Check managed bin (~/.pi-lens/bin/) — github releases + maven/archive launchers
if (tool.installStrategy === "github" ||
tool.installStrategy === "maven" ||
tool.installStrategy === "archive") {
const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
if (githubPath) {
status.installed = true;
status.source =
tool.installStrategy === "maven"
? "maven-jar"
: tool.installStrategy === "archive"
? "archive-dist"
: "github-release";
status.path = githubPath;
statuses.push(status);
continue;
}
}
// 5. Check pi-lens auto-install (~/.pi-lens/tools/)
const localBase = path.join(TOOLS_DIR, "node_modules", ".bin", tool.binaryName || tool.id);
const localPath = process.platform === "win32" ? `${localBase}.cmd` : localBase;
try {
await fs.access(localPath);
if (await verifyToolBinary(localPath)) {
status.installed = true;
status.source = "pi-lens-auto";
status.path = localPath;
statuses.push(status);
continue;
}
}
catch {
// fall through to not-installed
}
// 6. Not installed - will use npx fallback if npm strategy
if (tool.installStrategy === "npm") {
status.source = "npx-fallback";
}
statuses.push(status);
}
return statuses;
}
/**
* Check if a tool is installed (globally or locally)
*/
export async function isToolInstalled(toolId) {
return (await getToolPath(toolId)) !== undefined;
}
/**
* Resolve an installed archive TREE BUNDLE (an `archive` tool with no launcher)
* to its extract dir, confirmed via the tree marker. Returns undefined when the
* tool isn't a tree bundle or isn't extracted yet.
*/
async function getArchiveTreeBundlePath(tool) {
if (tool.installStrategy !== "archive" || tool.archive?.launcher) {
return undefined;
}
const extractDir = path.join(TOOLS_DIR, tool.id);
const marker = tool.archive?.treeMarker
? path.join(extractDir, ...tool.archive.treeMarker.split("/"))
: extractDir;
try {
await fs.access(marker);
return extractDir;
}
catch {
return undefined;
}
}
/**
* Get the path to a tool (global or local)
*/
/**
* Resolve a tool's native binary from its per-platform optional-dependency
* package (e.g. `@ast-grep/cli-linux-x64-gnu`), following pnpm/bun symlinks via
* the MAIN package's resolver. This is the reliable path for npm/pnpm/bun
* installs: the JS launcher in the main package frequently can't locate the
* binary under a symlink/isolated store (or after a skipped postinstall), but
* the binary is installed — find it directly. Returns undefined if the tool
* has no platformPackage spec, the platform is unsupported, or it isn't found.
*/
export function resolvePlatformPackageBinary(tool) {
const spec = tool.platformPackage;
if (!spec || !tool.packageName)
return undefined;
const suffix = spec.suffixes[`${process.platform}-${process.arch}`];
if (!suffix)
return undefined;
const platformPkg = `${spec.base ?? tool.packageName}-${suffix}`;
try {
// Resolve the platform package FROM the main package, which owns it as an
// optional dependency (pnpm exposes it there, not to arbitrary roots).
const mainPkgJson = _installerRequire.resolve(`${tool.packageName}/package.json`);
const fromMain = createRequire(mainPkgJson);
let pkgDir;
try {
pkgDir = path.dirname(fromMain.resolve(`${platformPkg}/package.json`));
}
catch {
pkgDir = path.dirname(_installerRequire.resolve(`${platformPkg}/package.json`));
}
const isWin = process.platform === "win32";
for (const bin of spec.binaries) {
for (const name of isWin ? [`${bin}.exe`, bin] : [bin]) {
const candidate = path.join(pkgDir, name);
if (existsSync(candidate))
return candidate;
}
}
}
catch {
// not installed / not resolvable for this layout
}
return undefined;
}
export async function getToolPath(toolId) {
const tool = TOOLS.find((t) => t.id === toolId);
if (!tool)
return undefined;
// Tree-bundle archives (no launcher) are "installed" ONLY when extracted — the
// extract dir is authoritative. No PATH/global/npm fallback: the runtime that
// drives the bundle (e.g. pwsh) may be on PATH while the bundle itself is
// absent, which must NOT read as installed (else the bundle never downloads).
if (tool.installStrategy === "archive" && !tool.archive?.launcher) {
return getArchiveTreeBundlePath(tool);
}
// Version-pin drift detection (#589) is scoped to npm-strategy tools with an
// explicit `@version` pin — everything else (unpinned npm entries, pip/gem,
// github/maven/archive) has no drift signal to piggyback on here. Clear any
// stale entry up front so a miss on the checks below never leaves a prior
// call's version lingering for ensureTool() to misread.
const pinnedVersion = tool.installStrategy === "npm" && tool.packageName
? parsePinnedVersion(tool.packageName)
: undefined;
if (pinnedVersion)
lastManagedInstallVersion.delete(toolId);
const recordVersion = pinnedVersion
? (output) => {
const seen = extractVersionToken(output);
if (seen)
lastManagedInstallVersion.set(toolId, seen);
}
: undefined;
// Fast path: check local npm install first (where auto-install places tools).
// This avoids the ~2-5s overhead of spawning npm global probes and PATH
// searches for tools we already manage locally.
const localBase = path.join(TOOLS_DIR, "node_modules", ".bin", tool.binaryName || tool.id);
if (process.platform === "win32") {
// Prefer .cmd over extensionless — Node.js can't execute POSIX shell scripts on Windows
const cmdPath = `${localBase}.cmd`;
try {
await fs.access(cmdPath);
if (await verifyToolBinary(cmdPath, recordVersion)) {
return cmdPath;
}
logSessionStart(`auto-install verify: ${cmdPath} exists but is broken, will reinstall`);
}
catch {
// fall through to .exe
}
// Also check .exe — some postinstall scripts (e.g. @ast-grep/cli) place a
// .exe directly without a .cmd wrapper
const exePath = `${localBase}.exe`;
try {
await fs.access(exePath);
if (await verifyToolBinary(exePath, recordVersion)) {
return exePath;
}
logSessionStart(`auto-install verify: ${exePath} exists but is broken, will reinstall`);
}
catch {
// fall through to extensionless
}
}
try {
await fs.access(localBase);
if (await verifyToolBinary(localBase, recordVersion)) {
return localBase;
}
logSessionStart(`auto-install verify: ${localBase} exists but is broken, will reinstall`);
}
catch {
// fall through to global checks
}
// npm/pnpm/bun: prefer the native per-platform binary directly. The main
// package's launcher often can't find it under a symlink store / after a
// skipped postinstall, but the binary IS installed — resolve + verify it
// before falling back to PATH or a (re)install.
if (tool.platformPackage) {
const platformBin = resolvePlatformPackageBinary(tool);
if (platformBin && (await verifyToolBinary(platformBin))) {
logSessionStart(`auto-install ${toolId}: resolved platform-package binary at ${platformBin}`);
return platformBin;
}
logSessionStart(`auto-install ${toolId}: platform-package binary not resolved (${process.platform}-${process.arch}, base=${tool.platformPackage.base ?? tool.packageName}) — falling back to PATH/managed install`);
}
// For github/maven tools, prefer the managed install (~/.pi-lens/bin/) over
// PATH. Managed installs are known-good binaries/launchers pi-lens downloaded
// as a fallback when a PATH-resolved tool was broken or missing. Checking
// before PATH ensures force-reinstall flows find the newly downloaded binary.
if (tool.installStrategy === "github" ||
tool.installStrategy === "maven" ||
tool.installStrategy === "archive") {
const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
if (githubPath)
return githubPath;
}
// Check if global
if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
return tool.checkCommand;
}
if (tool.installStrategy === "npm") {
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
if (npmPath) {
return npmPath;
}
}
// For pip tools, also probe user-level script locations
if (tool.installStrategy === "pip") {
const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
if (pipPath) {
return pipPath;
}
}
return undefined;
}
async function findGitHubToolPath(binaryName) {
const isWindows = process.platform === "win32";
const candidates = isWindows
? [
path.join(GITHUB_BIN_DIR, `${binaryName}.exe`),
path.join(GITHUB_BIN_DIR, `${binaryName}.bat`),
path.join(GITHUB_BIN_DIR, `${binaryName}.cmd`),
path.join(GITHUB_BIN_DIR, binaryName),
]
: [path.join(GITHUB_BIN_DIR, binaryName)];
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
}
catch {
// continue
}
}
return undefined;
}
function hasExecutableExtension(name) {
return /\.(exe|bat|cmd|ps1)$/i.test(name);
}
function getGitHubInstalledBinaryName(binaryName, platform, assetName) {
if (platform !== "win32")
return binaryName;
if (hasExecutableExtension(binaryName))
return binaryName;
if (assetName.endsWith(".bat"))
return `${binaryName}.bat`;
if (assetName.endsWith(".cmd"))
return `${binaryName}.cmd`;
return `${binaryName}.exe`;
}
function getArchiveBinaryCandidates(binaryName, platform, assetName) {
if (platform !== "win32")
return [binaryName];
if (hasExecutableExtension(binaryName))
return [binaryName];
const candidates = new Set();
if (assetName.endsWith(".bat"))
candidates.add(`${binaryName}.bat`);
if (assetName.endsWith(".cmd"))
candidates.add(`${binaryName}.cmd`);
candidates.add(`${binaryName}.exe`);
candidates.add(binaryName);
candidates.add(`${binaryName}.bat`);
candidates.add(`${binaryName}.cmd`);
return [...candidates];
}
async function findNpmGlobalToolPath(binaryName) {
const isWindows = process.platform === "win32";
const binDirs = await getNpmGlobalBinCandidates();
for (const dir of binDirs) {
const candidates = isWindows
? [
path.join(dir, `${binaryName}.cmd`),
path.join(dir, `${binaryName}.exe`),
path.join(dir, binaryName),
]
: [path.join(dir, binaryName)];
for (const candidate of candidates) {
try {
await fs.access(candidate);
if (await verifyToolBinary(candidate)) {
return candidate;
}
}
catch {
// continue
}
}
}
return undefined;
}
async function getNpmGlobalBinCandidates() {
const dirs = [];
const seen = new Set();
const add = (value) => {
if (!value)
return;
const normalized = path.resolve(value.trim());
if (!normalized)
return;
if (seen.has(normalized))
return;
seen.add(normalized);
dirs.push(normalized);
};
if (process.platform === "win32") {
add(path.join(process.env.APPDATA || "", "npm"));
}
else {
add(path.join(os.homedir(), ".npm-global", "bin"));
}
// Global bin dirs for every installed manager (npm/pnpm/yarn/bun) — a tool
// may have been installed globally via any of them.
for (const dir of await allAvailableGlobalBinDirs()) {
add(dir);
}
return dirs;
}
async function findPipUserToolPath(binaryName) {
const isWindows = process.platform === "win32";
const userBaseCandidates = await getPythonUserBaseCandidates();
for (const userBase of userBaseCandidates) {
const scriptDirs = [
path.join(userBase, isWindows ? "Scripts" : "bin"),
];
if (isWindows) {
try {
const children = await fs.readdir(userBase, { withFileTypes: true });
for (const entry of children) {
if (!entry.isDirectory())
continue;
if (!/^python\d+$/i.test(entry.name))
continue;
scriptDirs.push(path.join(userBase, entry.name, "Scripts"));
}
}
catch {
// ignore
}
}
for (const dir of scriptDirs) {
const candidates = isWindows
? [
path.join(dir, `${binaryName}.exe`),
path.join(dir, `${binaryName}.cmd`),
path.join(dir, binaryName),
]
: [path.join(dir, binaryName)];
for (const candidate of candidates) {
try {
await fs.access(candidate);
if (await verifyToolBinary(candidate)) {
return candidate;
}
}
catch {
// continue
}
}
}
}
return undefined;
}
async function getPythonUserBaseCandidates() {
const candidates = [];
const seen = new Set();
const add = (value) => {
if (!value)
return;
const normalized = value.trim();
if (!normalized)
return;
if (seen.has(normalized))
return;
seen.add(normalized);
candidates.push(normalized);
};
if (process.platform === "win32") {
add(path.join(process.env.APPDATA || "", "Python"));
}
const probes = process.platform === "win32"
? [
{ command: "py", args: ["-m", "site", "--user-base"] },
{ command: "python", args: ["-m", "site", "--user-base"] },
]
: [
{ command: "python3", args: ["-m", "site", "--user-base"] },
{ command: "python", args: ["-m", "site", "--user-base"] },
];
for (const probe of probes) {
const userBase = await new Promise((resolve) => {
const isWin = process.platform === "win32";
// Bake args into command string when shell:true on Windows to avoid DEP0190.
const spawnCmd = isWin
? [probe.command, ...probe.args].join(" ")
: probe.command;
const proc = spawn(spawnCmd, isWin ? [] : probe.args, {
stdio: ["ignore", "pipe", "pipe"],
shell: isWin,
});
let stdout = "";
proc.stdout?.on("data", (data) => (stdout += data));
proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
proc.on("error", () => resolve(""));
});
add(userBase);
}
return candidates;
}
// --- Installation Functions
/**
* Authorization header for the GitHub REST API, when a token is available.
* Unauthenticated GitHub API is 60 req/hr per IP — exhausted constantly on
* shared-IP CI runners, which silently fails every github-strategy install.
* Authenticated is 5000 req/hr. Used ONLY for the `api.github.com` metadata
* call, never the asset download (see installGitHubTool) — the release CDN must
* not receive the token.
*/
function githubApiAuthHeaders() {
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
return token ? { Authorization: `Bearer ${token}` } : {};
}
function sameHost(a, b) {
try {
return new URL(a).host === new URL(b).host;
}
catch {
return false;
}
}
/**
* Fetch a URL, following up to `maxRedirects` redirects.
* Returns the raw Buffer of the response body. Any caller-supplied headers are
* dropped when a redirect crosses to a different host, so an Authorization
* header can never leak to a redirect target (e.g. a release CDN).
*/
function httpsGet(url, maxRedirects = 5, headers = {}) {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { "User-Agent": "pi-lens/1.0", ...headers } }, (res) => {
if (res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location) {
if (maxRedirects === 0)
return reject(new Error("Too many redirects"));
const location = res.headers.location;
const nextHeaders = sameHost(url, location)
? headers
: (() => {
const { Authorization: _drop, ...rest } = headers;
return rest;
})();
return resolve(httpsGet(location, maxRedirects - 1, nextHeaders));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
})
.on("error", reject);
});
}
/**
* Run a shell command and return true on exit code 0.
*/
function runCommand(command, args, cwd) {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
stdio: "ignore",
shell: process.platform === "win32",
});
proc.on("exit", (code) => resolve(code === 0));
proc.on("error", () => resolve(false));
});
}
/**
* Download and install a tool from a GitHub release.
* Returns the path to the installed binary, or undefined on failure.
*/
async function installGitHubTool(tool) {
const spec = tool.github;
if (!spec)
return undefined;
const platform = process.platform; // "linux" | "darwin" | "win32"
const arch = process.arch; // "x64" | "arm64" | ...
const assetSubstring = spec.assetMatch(platform, arch);
if (!assetSubstring) {
logSessionStart(`github-install ${tool.id}: unsupported platform=${platform} arch=${arch}`);
return undefined;
}
// Fetch latest release metadata from GitHub API
logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
let releaseJson;
try {
const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
releaseJson = JSON.parse(body.toString("utf8"));
}
catch (err) {
logSessionStart(`github-install ${tool.id}: release fetch failed: ${err.message}`);
return undefined;
}
const asset = releaseJson.assets.find((a) => a.name.includes(assetSubstring)) ??
deriveHashiCorpReleaseAsset(tool, releaseJson.tag_name, assetSubstring);
if (!asset) {
logSessionStart(`github-install ${tool.id}: no asset matched "${assetSubstring}"`);
return undefined;
}
logSessionStart(`github-install ${tool.id}: downloading ${asset.name}`);
debugLog(`[github] downloading ${asset.name} from ${asset.browser_download_url}`);
// Download the asset
const downloadStart = Date.now();
let assetBuffer;
try {
assetBuffer = await httpsGet(asset.browser_download_url);
logSessionStart(`github-install ${tool.id}: downloaded ${asset.name} (${assetBuffer.length} bytes, ${Date.now() - downloadStart}ms)`);
}
catch (err) {
logSessionStart(`github-install ${tool.id}: download failed: ${err.message}`);
return undefined;
}
await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
const binaryName = tool.binaryName ?? tool.id;
const isWindows = platform === "win32";
const finalBinaryName = getGitHubInstalledBinaryName(binaryName, platform, asset.name);
const destPath = path.join(GITHUB_BIN_DIR, finalBinaryName);
const assetName = asset.name;
try {
if (assetName.endsWith(".gz") && !assetName.endsWith(".tar.gz")) {
// Bare gzip (e.g. rust-analyzer-x86_64-unknown-linux-gnu.gz) — decompress directly
const decompressed = await new Promise((resolve, reject) => {
const gunzip = createGunzip();
const chunks = [];
gunzip.on("data", (chunk) => chunks.push(chunk));
gunzip.on("end", () => resolve(Buffer.concat(chunks)));
gunzip.on("error", reject);
gunzip.end(assetBuffer);
});
await fs.writeFile(destPath, decompressed, { mode: 0o750 });
}
else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tar.xz")) {
// Write archive to temp file, extract with system tar
const tmpArchive = path.join(GITHUB_BIN_DIR, `_tmp_${assetName}`);
await fs.writeFile(tmpArchive, assetBuffer);
const tmpDir = path.join(GITHUB_BIN_DIR, `_tmp_extract_${tool.id}`);
await fs.mkdir(tmpDir, { recursive: true });
const extracted = await runCommand("tar", ["xf", tmpArchive, "-C", tmpDir], GITHUB_BIN_DIR);
await fs.rm(tmpArchive, { force: true });
if (!extracted) {
await fs.rm(tmpDir, { recursive: true, force: true });
logSessionStart(`github-install ${tool.id}: tar extraction failed for ${assetName}`);
return undefined;
}
// Locate the binary at any depth — handles both flat tarballs (e.g.
// gleam ships a bare `gleam` at the archive root) and tools nested
// under a top-level dir (e.g. shellcheck-vX/shellcheck). Each registered
// tar tool has a uniquely-named binary, so a recursive match is
// unambiguous; this replaces the old `--strip-components=1` assumption,
// which silently extracted nothing from a flat tarball.
const tarBinaryName = spec.binaryInArchive ?? binaryName;
const tarSrcBinary = await findFirstFileRecursive(tmpDir, getArchiveBinaryCandidates(tarBinaryName, platform, assetName));
if (!tarSrcBinary) {
await fs.rm(tmpDir, { recursive: true, force: true });
logSessionStart(`github-install ${tool.id}: binary candidates ${JSON.stringify(getArchiveBinaryCandidates(tarBinaryName, platform, assetName))} not found in tar ${assetName}`);
return undefined;
}
await fs.rename(tarSrcBinary, destPath);
await fs.rm(tmpDir, { recursive: true, force: true });
if (!isWindows)
await fs.chmod(destPath, 0o750);
}
else if (assetName.endsWith(".zip")) {
// Write zip to temp, extract with unzip (Linux/macOS) or Expand-Archive (Windows)
const tmpArchive = path.join(GITHUB_BIN_DIR, `_tmp_${assetName}`);
await fs.writeFile(tmpArchive, assetBuffer);
const tmpDir = path.join(GITHUB_BIN_DIR, `_tmp_extract_${tool.id}`);
await fs.mkdir(tmpDir, { recursive: true });
const extracted = isWindows
? await runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`,
], GITHUB_BIN_DIR)
: await runCommand("unzip", ["-q", "-o", tmpArchive, "-d", tmpDir], GITHUB_BIN_DIR);
await fs.rm(tmpArchive, { force: true });
if (!extracted) {
await fs.rm(tmpDir, { recursive: true, force: true });
logSessionStart(`github-install ${tool.id}: zip extraction failed for ${assetName}`);
return undefined;
}
// Find binary — may be at root or inside a subdir
const archiveBinaryName = spec.binaryInArchive ?? binaryName;
const srcBinary = await findFirstFileRecursive(tmpDir, getArchiveBinaryCandidates(archiveBinaryName, platform, assetName));
if (!srcBinary) {
await fs.rm(tmpDir, { recursive: true, force: true });
logSessionStart(`github-install ${tool.id}: binary candidates ${JSON.stringify(getArchiveBinaryCandidates(archiveBinaryName, platform, assetName))} not found in zip ${assetName}`);
return undefined;
}
await fs.rename(srcBinary, destPath);
await fs.rm(tmpDir, { recursive: true, force: true });
if (!isWindows)
await fs.chmod(destPath, 0o750);
}
else {
// Bare binary (e.g. shfmt_*_linux_amd64)
await fs.writeFile(destPath, assetBuffer, { mode: 0o750 });
}
}
catch (err) {
logSessionStart(`github-install ${tool.id}: install failed: ${err.message}`);
return undefined;
}
// Download any sibling assets the primary wrapper depends on (e.g. ktlint's
// `ktlint` jar next to `ktlint.bat`, #218). Matched by EXACT name and written
// as bare files into the same dir; a missing one fails the install.
for (const extraName of spec.extraAssets?.(platform, arch) ?? []) {
const extraAsset = releaseJson.assets.find((a) => a.name === extraName);
if (!extraAsset) {
logSessionStart(`github-install ${tool.id}: required extra asset "${extraName}" not found`);
return undefined;
}
try {
const extraBuffer = await httpsGet(extraAsset.browser_download_url);
await fs.writeFile(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, {
mode: 0o750,
});
logSessionStart(`github-install ${tool.id}: installed extra asset ${extraName} (${extraBuffer.length} bytes)`);
}
catch (err) {
logSessionStart(`github-install ${tool.id}: extra asset ${extraName} download failed: ${err.message}`);
return undefined;
}
}
debugLog(`[github] installed ${tool.name} → ${destPath}`);
logSessionStart(`github-install ${tool.id}: installed → ${destPath}`);
return destPath;
}
/** Recursively find the first matching file under a directory. */
async function findFirstFileRecursive(dir, names) {
const wanted = new Set(names.map((name) => name.toLowerCase()));
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findFirstFileRecursive(full, names);
if (found)
return found;
}
else if (wanted.has(entry.name.toLowerCase())) {
return full;
}
}
return undefined;
}
/**
* Install an npm package tool
*/
/**
* Packages that require postinstall scripts to download native binaries.
* All others get --ignore-scripts to prevent arbitrary code execution during install.
*/
const NEEDS_POSTINSTALL = new Set([
"@biomejs/biome",
"@ast-grep/cli", // postinstall copies platform binary (ast-grep.exe/sg.exe) into place
"@ast-grep/napi",
"esbuild",
"intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
]);
const MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2";
/**
* Install a Maven-distributed runnable fat JAR: download it into the managed bin
* and write a `java -jar` launcher next to it (so it resolves like any managed
* binary via findGitHubToolPath). Requires a JRE — gated on `java` availability.
*/
async function installMavenTool(tool) {
const spec = tool.maven;
if (!spec)
return undefined;
const binaryName = tool.binaryName ?? tool.id;
const isWindows = process.platform === "win32";
if (!(await isCommandAvailable("java", ["-version"]))) {
logSessionStart(`maven-install ${tool.id}: java not found — a JAR tool can't run without a JRE`);
return undefined;
}
// Strip trailing slashes without a regex (the `\/+$` form trips ReDoS
// scanners — S5852 — even though the input is a trusted constant/registry
// value). A plain loop is unambiguously linear.
let base = spec.repoBaseUrl ?? MAVEN_CENTRAL_BASE;
while (base.endsWith("/"))
base = base.slice(0, -1);
const groupPath = spec.groupId.replace(/\./g, "/");
const jarFile = `${spec.artifactId}-${spec.version}${spec.classifier ? `-${spec.classifier}` : ""}.jar`;
const url = `${base}/${groupPath}/${spec.artifactId}/${spec.version}/${jarFile}`;
logSessionStart(`maven-install ${tool.id}: downloading ${url}`);
let jarBuffer;
try {
jarBuffer = await httpsGet(url);
}
catch (err) {
logSessionStart(`maven-install ${tool.id}: download failed: ${err.message}`);
return undefined;
}
try {
await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
const jarPath = path.join(GITHUB_BIN_DIR, `${tool.id}.jar`);
await fs.writeFile(jarPath, jarBuffer);
// Launcher so the tool resolves as a normal command in the managed bin.
const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
const launcherPath = path.join(GITHUB_BIN_DIR, launcherName);
if (isWindows) {
await fs.writeFile(launcherPath, `@echo off\r\njava -jar "%~dp0${tool.id}.jar" %*\r\n`);
}
else {
await fs.writeFile(launcherPath, `#!/bin/sh\nexec java -jar "$(dirname "$0")/${tool.id}.jar" "$@"\n`, { mode: 0o750 });
}
logSessionStart(`maven-install ${tool.id}: installed → ${launcherPath} (${jarBuffer.length} bytes)`);
debugLog(`[maven] installed ${tool.name} → ${launcherPath}`);
return launcherPath;
}
catch (err) {
logSessionStart(`maven-install ${tool.id}: install failed: ${err.message}`);
return undefined;
}
}
/**
* Install a tool that ships as a distribution archive (.tgz/.zip with a lib/ of
* JARs + bin/ launchers — e.g. SpotBugs), not a single runnable binary or fat
* JAR. Downloads the archive, extracts it (top-level dir stripped) into
* ~/.pi-lens/tools/<id>/, then writes a thin launcher shim into the managed bin
* so the tool resolves like any other via findGitHubToolPath. Extraction uses
* `tar` (present on Windows 10+ as bsdtar, which also reads .zip).
*/
/**
* Resolve an {@link ArchiveSpec} download URL for the current platform/arch.
* A string URL is platform-agnostic; a function resolves per platform/arch and
* may return `undefined` (unsupported → caller degrades to "unavailable").
* Exported for the tool-registry contract test.
*/
export function resolveArchiveUrl(spec, platform = process.platform, arch = process.arch) {
return typeof spec.url === "function" ? spec.url(platform, arch) : spec.url;
}
async function installArchiveTool(tool) {
const spec = tool.archive;
if (!spec)
return undefined;
const binaryName = tool.binaryName ?? tool.id;
const isWindows = process.platform === "win32";
const url = resolveArchiveUrl(spec);
if (!url) {
logSessionStart(`archive-install ${tool.id}: no archive for ${process.platform}/${process.arch} — unsupported, skipping`);
return undefined;
}
logSessionStart(`archive-install ${tool.id}: downloading ${url}`);
let archiveBuffer;
try {
archiveBuffer = await httpsGet(url);
}
catch (err) {
logSessionStart(`archive-install ${tool.id}: download failed: ${err.message}`);
return undefined;
}
// Use basenames + cwd:TOOLS_DIR for the tar spawn so no argument contains a
// drive-letter colon — GNU tar (MSYS) otherwise reads `C:\…` as an rsync
// `host:path` ("Cannot connect to C:"). Relative paths work for both GNU tar
// and Windows bsdtar, so we avoid the GNU-only `--force-local` (which bsdtar
// rejects). fs.* calls still use the absolute paths.
const extractName = tool.id;
const archiveName = `${tool.id}.download.${spec.kind === "zip" ? "zip" : "tgz"}`;
const extractDir = path.join(TOOLS_DIR, extractName);
const tmpArchive = path.join(TOOLS_DIR, archiveName);
try {
await fs.mkdir(TOOLS_DIR, { recursive: true });
// Clear any prior extraction so a reinstall is clean.
await fs.rm(extractDir, { recursive: true, force: true });
await fs.mkdir(extractDir, { recursive: true });
await fs.writeFile(tmpArchive, archiveBuffer);
// `--strip-components=N` drops N leading path components. Default 1 drops a
// versioned top-level dir so a launcher path stays stable (bin/… not
// spotbugs-X.Y.Z/bin/…). A TREE BUNDLE (stripComponents:0) has no wrapping
// dir — stripping would flatten/merge its sibling module folders — so the
// flag is omitted. bsdtar handles both .tgz and .zip with -xf.
const stripComponents = spec.stripComponents ?? 1;
const tarArgs = [
spec.kind === "tgz" ? "-xzf" : "-xf",
archiveName,
"-C",
extractName,
...(stripComponents > 0
? [`--strip-components=${stripComponents}`]
: []),
];
// Resolve `tar` to an absolute path on Windows (System32\tar.exe is the
// bsdtar shipped with Windows 10+) so extraction can't be hijacked via a
// writable PATH entry — same hardening as the taskkill spawn. On POSIX `tar`
// is a trusted coreutil whose absolute path varies by distro, so it stays
// bare (consistent with every other tool spawn).
const tarBin = isWindows
? `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\tar.exe`
: "tar";
const extracted = await new Promise((resolve) => {
const proc = spawn(tarBin, tarArgs, {
cwd: TOOLS_DIR,
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
proc.stderr?.on("data", (d) => (stderr += d));
const timer = setTimeout(() => {
proc.kill();
resolve({ ok: false, stderr: "extraction timed out" });
}, 120_000);
proc.on("exit", (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, stderr });
});
proc.on("error", (err) => resolve({ ok: false, stderr: err.message }));
});
await fs.rm(tmpArchive, { force: true });
if (!extracted.ok) {
logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr}`);
return undefined;
}
// Tree bundle (no launcher): the whole extracted tree IS the artifact. Verify
// the marker exists and resolve to the extract dir — the consuming server
// launches a runtime against a bootstrap inside it (e.g. PSES via pwsh).
if (!spec.launcher) {
const marker = spec.treeMarker
? path.join(extractDir, ...spec.treeMarker.split("/"))
: extractDir;
try {
await fs.access(marker);
}
catch {
logSessionStart(`archive-install ${tool.id}: tree marker not found at ${marker} after extraction`);
return undefined;
}
logSessionStart(`archive-install ${tool.id}: installed tree bundle → ${extractDir} (extracted ${archiveBuffer.length} bytes)`);
debugLog(`[archive] installed ${tool.name} bundle → ${extractDir}`);
return extractDir;
}
// The launcher inside the extracted tree (e.g. bin/spotbugs[.bat]).
const innerLauncher = path.join(extractDir, ...spec.launcher.split("/").map((p) => p));
const resolvedInner = isWindows ? `${innerLauncher}.bat` : innerLauncher;
try {
await fs.access(resolvedInner);
}
catch {
logSessionStart(`archive-install ${tool.id}: launcher not found at ${resolvedInner} after extraction`);
return undefined;
}
if (!isWindows)
await fs.chmod(resolvedInner, 0o750).catch(() => { });
// Thin shim in the managed bin so discovery (findGitHubToolPath) resolves
// it like any other managed tool. `call`/`exec` preserves the real
// launcher's own %~dp0/$0 so it still finds its sibling lib/.
await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
const shimPath = path.join(GITHUB_BIN_DIR, launcherName);
if (isWindows) {
await fs.writeFile(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`);
}
else {
await fs.writeFile(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { mode: 0o750 });
}
logSessionStart(`archive-install ${tool.id}: installed → ${shimPath} (extracted ${archiveBuffer.length} bytes)`);
debugLog(`[archive] installed ${tool.name} → ${shimPath}`);
return shimPath;
}
catch (err) {
await fs.rm(tmpArchive, { force: true }).catch(() => { });
logSessionStart(`archive-install ${tool.id}: install failed: ${err.message}`);
return undefined;
}
}
async function installNpmTool(packageName, binaryName) {
try {
// Ensure tools directory exists
await fs.mkdir(TOOLS_DIR, { recursive: true });
// Create a minimal package.json if it doesn't exist
const packageJsonPath = path.join(TOOLS_DIR, "package.json");
try {
await fs.access(packageJsonPath);
}
catch {
await fs.writeFile(packageJsonPath, JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2));
}
// Resolve the package manager for the tools dir and build install args.
const isWindows = process.platform === "win32";
const pm = await resolveNodePackageManager(TOOLS_DIR);
const pmCommand = pmBinary(pm);
// Use --ignore-scripts unless the package explicitly needs postinstall
// (e.g. biome downloads a platform-specific native binary via postinstall).
const needsScripts = NEEDS_POSTINSTALL.has(packageName);
const baseInstallArgs = installArgs(pm, packageName, {
ignoreScripts: !needsScripts,
});
const INSTALL_TIMEOUT_MS = 120_000;
const runInstallAttempt = async (args) => new Promise((resolve) => {
const proc = spawn(pmCommand, args, {
cwd: TOOLS_DIR,
stdio: ["ignore", "pipe", "pipe"],
shell: isWindows, // Required for .cmd files on Windows
});
let stderr = "";
proc.stderr?.on("data", (data) => (stderr += data));
const timer = setTimeout(() => {
proc.kill();
resolve({
ok: false,
stderr: `install timed out after ${INSTALL_TIMEOUT_MS / 1000}s`,
});
}, INSTALL_TIMEOUT_MS);
proc.on("exit", (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, stderr });
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, stderr: err.message });
});
});
let outcome = await runInstallAttempt(baseInstallArgs);
// --legacy-peer-deps is npm-only; retry just npm's ERESOLVE failures.
const erResolve = outcome.ok === false &&
/npm\s+error\s+ERESOLVE|\bERESOLVE\b|could not resolve/i.test(outcome.stderr);
if (pm === "npm" && erResolve) {
const retryArgs = installArgs(pm, packageName, {
ignoreScripts: !needsScripts,
legacyPeerDeps: true,
});
logSessionStart(`auto-install npm ${packageName}: retry with --legacy-peer-deps after ERESOLVE`);
outcome = await runInstallAttempt(retryArgs);
}
if (!outcome.ok) {
throw new Error(`Failed to install ${packageName}: ${outcome.stderr}`);
}
const binPath = path.join(TOOLS_DIR, "node_modules", ".bin", binaryName);
// Make executable on Unix
if (process.platform !== "win32") {
try {
await fs.chmod(binPath, 0o750);
}
catch {
/* ignore */
}
}
// Brief delay — lets npm postinstall scripts finish writing bin wrappers
// before we stat/exec them (eliminates a race on slow Windows I/O).
await new Promise((r) => setTimeout(r, 500));
// Verify the binary actually works, retrying with backoff to handle
// postinstall scripts that complete asynchronously after npm exits 0.
debugLog(`Verifying ${binaryName}...`);
let isValid = false;
for (let attempt = 1; attempt <= 3; attempt++) {
isValid = await verifyToolBinary(binPath);
if (isValid)
break;
if (attempt < 3) {
logSessionStart(`auto-install verify ${binaryName}: attempt ${attempt} failed, retrying in ${attempt}s`);
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
if (!isValid) {
logSessionStart(`auto-install ${packageName}: installed but verification failed, cleaning up`);
// Clean up the broken installation
try {
const packagePath = path.join(TOOLS_DIR, "node_modules", packageName);
await fs.rm(packagePath, { recursive: true, force: true });
await fs.rm(binPath, { force: true });
if (isWindows) {
await fs.rm(`${binPath}.cmd`, { force: true });
await fs.rm(`${binPath}.ps1`, { force: true });
}
}
catch {
/* ignore cleanup errors */
}
return undefined;
}
return binPath;
}
catch (err) {
logSessionStart(`auto-install npm ${packageName}: exception: ${err.message}`);
return undefined;
}
}
/**
* Install a pip package tool
*/
async function installPipTool(packageName) {
try {
const isWindows = process.platform === "win32";
const pipCandidates = isWindows
? [
{ command: "pip", args: ["install", "--user", packageName] },
{
command: "py",
args: ["-m", "pip", "install", "--user", packageName],
},
{
command: "python",
args: ["-m", "pip", "install", "--user", packageName],
},
]
: [
{ command: "pip3", args: ["install", "--user", packageName] },
{ command: "pip", args: ["install", "--user", packageName] },
{
command: "python3",
args: ["-m", "pip", "install", "--user", packageName],
},
{
command: "python",
args: ["-m", "pip", "install", "--user", packageName],
},
];
let lastError = "";
for (const candidate of pipCandidates) {
const outcome = await new Promise((resolve) => {
const proc = spawn(candidate.command, candidate.args, {
stdio: ["ignore", "pipe", "pipe"],
shell: isWindows, // Required for .cmd files on Windows
});
let stderr = "";
proc.stderr?.on("data", (data) => (stderr += data));
proc.on("exit", (code) => {
if (code === 0) {
resolve({ ok: true, error: "" });
}
else {
resolve({ ok: false, error: stderr.trim() });
}
});
proc.on("error", (err) => {
resolve({ ok: false, error: err.message });
});
});
if (outcome.ok) {
// Ensure user-level scripts directory is available in current process PATH.
// This helps tools installed via `pip install --user` become immediately callable.
const userBaseResult = await new Promise((resolve) => {
const probe = spawn(candidate.command, ["-m", "site", "--user-base"], {
stdio: ["ignore", "pipe", "pipe"],
shell: isWindows,
});
let stdout = "";
probe.stdout?.on("data", (data) => (stdout += data));
probe.on("exit", (code) => {
if (code === 0)
resolve(stdout.trim());
else
resolve("");
});
probe.on("error", () => resolve(""));
});
if (userBaseResult) {
const candidateScriptDirs = [
path.join(userBaseResult, isWindows ? "Scripts" : "bin"),
];
if (isWindows) {
// Some Python setups report USER_BASE as ...\Roaming\Python,
// while scripts live in ...\Roaming\Python\PythonXY\Scripts.
try {
const children = await fs.readdir(userBaseResult, {
withFileTypes: true,
});
for (const entry of children) {
if (!entry.isDirectory())
continue;
if (!/^python\d+$/i.test(entry.name))
continue;
candidateScriptDirs.push(path.join(userBaseResult, entry.name, "Scripts"));
}
}
catch {
// ignore
}
}
const currentPath = process.env.PATH || process.env.Path || process.env.path || "";
const separator = isWindows ? ";" : ":";
const normalizedPath = currentPath
.toLowerCase()
.split(separator)
.map((p) => p.trim());
for (const scriptsDir of candidateScriptDirs) {
try {
await fs.access(scriptsDir);
if (!normalizedPath.includes(scriptsDir.toLowerCase())) {
const existingPath = process.env.PATH ||
process.env.Path ||
process.env.path ||
"";
const updatedPath = `${scriptsDir}${separator}${existingPath}`;
process.env.PATH = updatedPath;
if (isWindows) {
process.env.Path = updatedPath;
}
debugLog(`Added pip user scripts dir to PATH: ${scriptsDir}`);
}
}
catch {
debugLog(`pip user scripts dir not accessible: ${scriptsDir}`);
}
}
}
return packageName;
}
lastError = `${candidate.command} ${candidate.args.join(" ")}: ${outcome.error}`;
debugLog(`[pip-fallback] ${lastError}`);
}
throw new Error(`Failed to install ${packageName}: no usable pip command found (${lastError || "unknown error"})`);
}
catch (err) {
logSessionStart(`auto-install pip ${packageName}: exception: ${err.message}`);
return undefined;
}
}
async function installGemTool(packageName) {
try {
const isWindows = process.platform === "win32";
const outcome = await new Promise((resolve) => {
const proc = spawn("gem", ["install", packageName, "--no-document"], {
stdio: ["ignore", "pipe", "pipe"],
shell: isWindows,
});
let stderr = "";
proc.stderr?.on("data", (data) => (stderr += data));
proc.on("exit", (code) => {
resolve({ ok: code === 0, error: stderr.trim() });
});
proc.on("error", (err) => {
resolve({ ok: false, error: err.message });
});
});
if (!outcome.ok) {
throw new Error(`Failed to install ${packageName} via gem: ${outcome.error}`);
}
return packageName;
}
catch (err) {
logSessionStart(`auto-install gem ${packageName}: exception: ${err.message}`);
return undefined;
}
}
/**
* Install a tool by ID
*/
export async function installTool(toolId) {
const tool = TOOLS.find((t) => t.id === toolId);
if (!tool) {
logSessionStart(`auto-install ${toolId}: unknown tool id`);
return false;
}
const startedAt = Date.now();
logSessionStart(`auto-install ${tool.id}: start strategy=${tool.installStrategy} package=${tool.packageName ?? "n/a"}`);
try {
switch (tool.installStrategy) {
case "npm": {
if (!tool.packageName || !tool.binaryName)
return false;
const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
const ok = npmPath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
case "pip": {
if (!tool.packageName)
return false;
const pipPath = await installPipTool(tool.packageName);
const ok = pipPath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
case "gem": {
if (!tool.packageName)
return false;
const gemPath = await installGemTool(tool.packageName);
const ok = gemPath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
case "github": {
if (!tool.github)
return false;
const ghPath = await installGitHubTool(tool);
const ok = ghPath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
case "maven": {
if (!tool.maven)
return false;
const mavenPath = await installMavenTool(tool);
const ok = mavenPath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
case "archive": {
if (!tool.archive)
return false;
const archivePath = await installArchiveTool(tool);
const ok = archivePath !== undefined;
logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
return ok;
}
default:
logSessionStart(`auto-install ${tool.id}: unsupported strategy`);
return false;
}
}
catch (err) {
logSessionStart(`auto-install ${tool.id}: exception ${err.message} (${Date.now() - startedAt}ms)`);
return false;
}
}
/**
* Ensure a tool is installed (check first, install if missing)
*/
export async function ensureTool(toolId, opts) {
const cacheResolvedPath = (result) => {
if (result) {
resolvedPathCache.set(toolId, result);
void updateProbeCache(toolId, result);
}
return result;
};
// forceReinstall: nuke caches, download from managed source, skip PATH entirely.
// Used when a PATH-resolved tool proves broken at launch (e.g. broken symlink).
// allowInstall:false wins over forceReinstall: caches are still cleared, but
// the function falls back to discovery-only and never downloads.
if (opts?.forceReinstall) {
const ensureStartMs = Date.now();
logSessionStart(`auto-install ensure ${toolId}: force reinstall — clearing caches`);
// Clear in-memory session cache
resolvedPathCache.delete(toolId);
// Clear persistent probe cache entry so getToolPath won't return stale PATH result
try {
const probeCache = await readProbeCache();
delete probeCache[toolId];
_probeCacheDirty = true;
scheduleProbeFlush();
}
catch {
// best-effort
}
if (opts.allowInstall === false) {
logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
return cacheResolvedPath(await getToolPath(toolId));
}
// Force download
const installed = await installTool(toolId);
if (!installed) {
logSessionStart(`auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`);
return undefined;
}
// Find the newly installed binary (github-local check now comes before PATH)
const result = cacheResolvedPath(await getToolPath(toolId));
if (result) {
logSessionStart(`auto-install ensure ${toolId}: force reinstall success at ${result} (${Date.now() - ensureStartMs}ms)`);
}
return result;
}
// Fast path 1: in-memory session cache — no I/O.
const cached = resolvedPathCache.get(toolId);
if (cached)
return cached;
// Fast path 2: persistent probe cache — fs.access + stat, no process spawn.
const diskCached = await checkProbeCache(toolId);
if (diskCached) {
resolvedPathCache.set(toolId, diskCached);
logSessionStart(`auto-install ensure ${toolId}: probe cache hit → ${diskCached}`);
return diskCached;
}
// Coalesce the whole ensure operation, not just installation. Most startup
// duplicates race while checking already-installed tools, before installTool()
// would ever run. The key includes the install policy so a discovery-only
// caller cannot accidentally inherit an install-allowed caller's download (or
// vice versa).
const inFlightKey = opts?.allowInstall === false ? `${toolId}:discovery-only` : toolId;
const inFlight = ensureInFlight.get(inFlightKey);
if (inFlight) {
logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight ensure (${inFlightKey})`);
return inFlight;
}
const ensureStartMs = Date.now();
const ensurePromise = (async () => {
logSessionStart(`auto-install ensure ${toolId}: start`);
// Check if already installed.
const existingPath = await getToolPath(toolId);
if (existingPath) {
// Version-pin drift (#589): getToolPath() above just spawned
// verifyToolBinary on the managed local install anyway (this is the
// slow path — fast paths 1/2 above already returned before reaching
// here), so lastManagedInstallVersion was populated for free if this
// is a pinned npm tool. Compare it to the current pin and, on
// mismatch, route through the EXISTING forceReinstall codepath rather
// than resolving to a known-stale binary. Piggybacks entirely on the
// probe-cache's ~once-per-24h/once-per-session cadence — no new spawn.
const tool = TOOLS.find((t) => t.id === toolId);
const pinnedVersion = tool?.installStrategy === "npm" && tool.packageName
? parsePinnedVersion(tool.packageName)
: undefined;
if (pinnedVersion) {
const seenVersion = lastManagedInstallVersion.get(toolId);
if (seenVersion && seenVersion !== pinnedVersion) {
lastManagedInstallVersion.delete(toolId);
logSessionStart(`auto-install ensure ${toolId}: version drift (installed ${seenVersion} != pinned ${pinnedVersion}) — forcing reinstall (${Date.now() - ensureStartMs}ms)`);
return ensureTool(toolId, {
forceReinstall: true,
allowInstall: opts?.allowInstall,
});
}
}
resolvedPathCache.set(toolId, existingPath);
void updateProbeCache(toolId, existingPath);
logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
return existingPath;
}
// Discovery and install are SEPARATE concerns. getToolPath() above already
// probed PATH / npm-global / managed bin — offline-safe, no download. When the
// caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
// we must still return a discovered binary and only skip the actual install.
if (opts?.allowInstall === false) {
logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
return undefined;
}
const installed = await installTool(toolId);
if (!installed) {
logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
return undefined;
}
const result = await getToolPath(toolId);
if (result) {
resolvedPathCache.set(toolId, result);
void updateProbeCache(toolId, result);
logSessionStart(`auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`);
}
else {
logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
}
return result;
})();
ensureInFlight.set(inFlightKey, ensurePromise);
try {
return await ensurePromise;
}
finally {
ensureInFlight.delete(inFlightKey);
}
}
// --- Integration Helpers ---
/**
* Get environment with tool paths added
*/
export async function getToolEnvironment() {
const localBin = path.join(TOOLS_DIR, "node_modules", ".bin");
const currentPath = process.env.PATH || process.env.Path || process.env.path || "";
const separator = process.platform === "win32" ? ";" : ":";
const nodeDir = path.dirname(process.execPath);
const withNode = nodeDir
? `${nodeDir}${separator}${currentPath}`
: currentPath;
const augmentedPath = `${GITHUB_BIN_DIR}${separator}${localBin}${separator}${withNode}`;
const env = {
...process.env,
PATH: augmentedPath,
};
if (process.platform === "win32") {
env.Path = augmentedPath;
}
return env;
}
// --- Status Check ---
/**
* Check status of all managed tools
*/
export async function checkAllTools() {
const results = [];
for (const tool of TOOLS) {
const path = await getToolPath(tool.id);
results.push({
id: tool.id,
name: tool.name,
installed: path !== undefined,
path,
});
}
return results;
}
export function isKnownToolId(toolId) {
return TOOLS.some((tool) => tool.id === toolId);
}
/**
* GitHub-release tools that ship an asset for **every** supported
* platform/arch combo (linux/darwin/win32 × x64/arm64). This is the set the
* full asset-matrix test (tests/clients/installer/github-release.test.ts)
* iterates, so membership must stay in lockstep with the registry — the
* tool-registry-consistency test enforces that every `installStrategy: "github"`
* entry resolving all six combos appears here, and vice versa.
*
* `swiftlint` is deliberately absent: it has no Windows asset (macOS + Linux
* only), so it cannot satisfy the full matrix and is covered by the weaker
* "at least one platform" guard instead.
*/
export const GITHUB_TOOLS = [
"shellcheck",
"shfmt",
"rust-analyzer",
"golangci-lint",
"ktlint",
"actionlint",
"zizmor",
"typos-lsp",
"tflint",
"terraform-ls",
"zls",
"hadolint",
"gitleaks",
"taplo",
"vale",
"opengrep",
"deno",
"clojure-lsp",
"gleam",
"marksman",
"expert",
];
/**
* Resolve the GitHub asset filename substring for a tool on a given platform/arch.
* Returns undefined if the tool has no GitHub spec or no asset for the platform.
* Exported for testing only.
*/
export function resolveGitHubAsset(toolId, platform, arch) {
const tool = TOOLS.find((t) => t.id === toolId);
return tool?.github?.assetMatch(platform, arch);
}
export function resolveGitHubInstalledBinaryName(toolId, platform, assetName) {
const tool = TOOLS.find((t) => t.id === toolId);
if (!tool)
return undefined;
return getGitHubInstalledBinaryName(tool.binaryName ?? tool.id, platform, assetName);
}
export function resolveGitHubArchiveBinaryCandidates(toolId, platform, assetName) {
const tool = TOOLS.find((t) => t.id === toolId);
if (!tool)
return undefined;
const binaryName = tool.github?.binaryInArchive ?? tool.binaryName ?? tool.id;
return getArchiveBinaryCandidates(binaryName, platform, assetName);
}
function deriveHashiCorpReleaseAsset(tool, tagName, assetSubstring) {
const product = tool.github?.hashiCorpReleaseProduct;
if (!product || !tagName)
return undefined;
const version = tagName.replace(/^v/, "").trim();
if (!version)
return undefined;
const assetName = `${product}_${version}_${assetSubstring}`;
return {
name: assetName,
browser_download_url: `https://releases.hashicorp.com/${product}/${version}/${assetName}`,
};
}
export function resolveDerivedHashiCorpReleaseAsset(toolId, tagName, platform, arch) {
const tool = TOOLS.find((t) => t.id === toolId);
if (!tool)
return undefined;
const assetSubstring = tool.github?.assetMatch(platform, arch);
if (!assetSubstring)
return undefined;
return deriveHashiCorpReleaseAsset(tool, tagName, assetSubstring);
}