diff --git a/docs/guides/vscode.md b/docs/guides/vscode.md index eaf5803..ae1e588 100644 --- a/docs/guides/vscode.md +++ b/docs/guides/vscode.md @@ -9,8 +9,10 @@ Dev Container extension hosts. Browser-only `vscode.dev` is not supported. 1. Install Compass and confirm `compass --version` works on the workspace host. 2. Install the Compass VSIX. 3. Open a trusted repository. -4. If the extension cannot find Compass on `PATH`, choose **Select Compass - Binary** or set `compass.cliPath`. +4. The extension detects Compass on `PATH` and in common install locations. + Choose **Select Compass CLI** to compare detected paths and versions, browse + for another executable, enter a path manually, or set `compass.cliPath` + directly. 5. Open the Compass activity bar and run **Initialize Repository**. Initialization previews include and exclude globs before it writes @@ -18,7 +20,7 @@ Initialization previews include and exclude globs before it writes The CLI must support `compass capabilities --format json`. If capability negotiation fails or a required versioned contract is missing, the extension does not run the incompatible command; upgrade Compass or use **Compass: Select -CLI Binary**, then reload VS Code. +CLI Version** to choose another detected version, then reload VS Code. ## Current graph diff --git a/editors/vscode/package.json b/editors/vscode/package.json index a46d7ff..5aa6cea 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -126,7 +126,7 @@ }, { "command": "compass.selectCli", - "title": "Compass: Select CLI Binary", + "title": "Compass: Select CLI Version", "icon": "$(terminal)" } ], @@ -143,7 +143,7 @@ "type": "string", "default": "", "scope": "machine", - "description": "Absolute path to an installed Compass CLI. Leave empty to discover compass on PATH." + "description": "Absolute path to the selected Compass CLI. Leave empty to auto-detect Compass on PATH and in common install locations." }, "compass.graphNodeLimit": { "type": "number", @@ -255,7 +255,7 @@ { "id": "compass.cli", "title": "Connect the Compass CLI", - "description": "Install Compass separately and configure `compass.cliPath` if it is not on PATH." + "description": "Compass auto-detects installed CLI versions. Use **Select Compass CLI** to compare paths or choose a different version." }, { "id": "compass.init", diff --git a/editors/vscode/src/cli/discovery.test.ts b/editors/vscode/src/cli/discovery.test.ts index d29a194..397f58e 100644 --- a/editors/vscode/src/cli/discovery.test.ts +++ b/editors/vscode/src/cli/discovery.test.ts @@ -1,9 +1,13 @@ -import path from "node:path"; -import { mkdir, writeFile } from "node:fs/promises"; import { chmodSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { discoverCompass } from "./discovery"; +import { + discoverCompass, + inspectCompassInstallation, + resolveCompassPath +} from "./discovery"; const created: string[] = []; afterEach(async () => { @@ -15,54 +19,140 @@ afterEach(async () => { }); describe("discoverCompass", () => { - it("prefers the configured executable", async () => { - const directory = path.join(process.cwd(), `.tmp-discovery-${Date.now()}`); - created.push(directory); - await mkdir(directory); - const executable = path.join(directory, "compass"); - await writeFile(executable, "#!/bin/sh\n"); - chmodSync(executable, 0o755); - const result = await discoverCompass({ get: () => executable }); - expect(result).toEqual({ kind: "found", executable }); + it("finds every installed version and keeps the configured executable active", async () => { + const directory = await temporaryDirectory("versions"); + const pathDirectory = path.join(directory, "path"); + await mkdir(pathDirectory); + const configured = path.join(directory, "configured-compass"); + const fromPath = path.join(pathDirectory, "compass"); + await Promise.all([ + writeCompass(configured, "0.1.4"), + writeCompass(fromPath, "0.2.0") + ]); + + const result = await discoverCompass( + { get: () => configured }, + { PATH: pathDirectory }, + "darwin", + { commonDirectories: [] } + ); + + expect(result).toEqual({ + kind: "found", + executable: configured, + version: "0.1.4", + installations: [ + { + executable: configured, + version: "0.1.4", + source: "configured" + }, + { + executable: fromPath, + version: "0.2.0", + source: "path" + } + ], + searched: [configured, fromPath] + }); }); it("falls back to PATH when the configured executable is unavailable", async () => { - const directory = path.join(process.cwd(), `.tmp-discovery-fallback-${Date.now()}`); - created.push(directory); - await mkdir(directory); + const directory = await temporaryDirectory("fallback"); const executable = path.join(directory, "compass"); - await writeFile(executable, "#!/bin/sh\n"); - chmodSync(executable, 0o755); + await writeCompass(executable, "0.1.5"); + const missing = path.join(directory, "missing-compass"); const result = await discoverCompass( - { get: () => path.join(directory, "missing-compass") }, + { get: () => missing }, { PATH: directory }, - "darwin" + "darwin", + { commonDirectories: [] } ); - expect(result).toEqual({ kind: "found", executable }); + expect(result).toMatchObject({ + kind: "found", + executable, + version: "0.1.5" + }); + expect(result.installations).toEqual([{ + executable, + version: "0.1.5", + source: "path" + }]); }); - it("keeps a working configured executable ahead of PATH", async () => { - const directory = path.join(process.cwd(), `.tmp-discovery-priority-${Date.now()}`); - const pathDirectory = path.join(directory, "path"); - created.push(directory); - await mkdir(pathDirectory, { recursive: true }); - const configured = path.join(directory, "configured-compass"); - const fromPath = path.join(pathDirectory, "compass"); - await Promise.all([ - writeFile(configured, "#!/bin/sh\n"), - writeFile(fromPath, "#!/bin/sh\n") - ]); - chmodSync(configured, 0o755); - chmodSync(fromPath, 0o755); + it("detects the default install directory even when it is not on PATH", async () => { + const directory = await temporaryDirectory("common"); + const executable = path.join(directory, "compass"); + await writeCompass(executable, "0.3.1"); const result = await discoverCompass( - { get: () => configured }, - { PATH: pathDirectory }, - "darwin" + { get: () => "" }, + { PATH: "" }, + "linux", + { commonDirectories: [directory] } ); - expect(result).toEqual({ kind: "found", executable: configured }); + expect(result).toMatchObject({ + kind: "found", + executable, + version: "0.3.1", + installations: [{ + executable, + version: "0.3.1", + source: "common" + }] + }); + }); + + it("deduplicates a configured executable that is also on PATH", async () => { + const directory = await temporaryDirectory("deduplicate"); + const executable = path.join(directory, "compass"); + await writeCompass(executable, "0.1.5"); + + const result = await discoverCompass( + { get: () => executable }, + { PATH: directory }, + "darwin", + { commonDirectories: [directory] } + ); + + expect(result.installations).toHaveLength(1); + expect(result.installations[0]?.source).toBe("configured"); + }); + + it("keeps an executable discoverable when its version cannot be read", async () => { + const directory = await temporaryDirectory("unknown-version"); + const executable = path.join(directory, "compass"); + await writeFile(executable, "#!/bin/sh\nexit 1\n"); + chmodSync(executable, 0o755); + + await expect(inspectCompassInstallation(executable)).resolves.toEqual({ + executable, + source: "configured" + }); + }); + + it("expands a home-relative path entered by the user", () => { + expect(resolveCompassPath( + "~/.local/bin/compass", + { HOME: "/home/dev" } + )).toBe(path.resolve("/home/dev/.local/bin/compass")); }); }); + +async function temporaryDirectory(label: string): Promise { + const directory = path.join( + process.cwd(), + `.tmp-discovery-${label}-${Date.now()}-${created.length}` + ); + created.push(directory); + await mkdir(directory); + return directory; +} + +async function writeCompass(executable: string, version: string): Promise { + await writeFile(executable, `#!/bin/sh\necho 'compass ${version}'\n`); + chmodSync(executable, 0o755); +} diff --git a/editors/vscode/src/cli/discovery.ts b/editors/vscode/src/cli/discovery.ts index c88554c..706b82d 100644 --- a/editors/vscode/src/cli/discovery.ts +++ b/editors/vscode/src/cli/discovery.ts @@ -1,35 +1,219 @@ +import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import type { WorkspaceConfiguration } from "vscode"; +export type CompassInstallationSource = "configured" | "path" | "common"; + +export type CompassInstallation = { + executable: string; + version?: string | undefined; + source: CompassInstallationSource; +}; + export type CompassDiscovery = - | { kind: "found"; executable: string } - | { kind: "missing"; searched: string[] }; + | { + kind: "found"; + executable: string; + version?: string | undefined; + installations: CompassInstallation[]; + searched: string[]; + } + | { + kind: "missing"; + installations: []; + searched: string[]; + }; + +type Candidate = { + executable: string; + source: CompassInstallationSource; +}; + +export type CompassDiscoveryOptions = { + commonDirectories?: readonly string[] | undefined; +}; export async function discoverCompass( configuration: Pick, environment: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + options: CompassDiscoveryOptions = {} ): Promise { + const candidates = installationCandidates( + configuration, + environment, + platform, + options + ); + const installations = (await Promise.all(candidates.map((candidate) => ( + inspectCompassInstallation(candidate.executable, candidate.source) + )))).filter((installation) => installation !== undefined); + const active = installations[0]; + const searched = candidates.map((candidate) => candidate.executable); + + return active + ? { + kind: "found", + executable: active.executable, + version: active.version, + installations, + searched + } + : { kind: "missing", installations: [], searched }; +} + +export async function inspectCompassInstallation( + executable: string, + source: CompassInstallationSource = "configured" +): Promise { + const resolved = resolveCompassPath(executable); + try { + await access(resolved, constants.X_OK); + } catch { + return undefined; + } + const version = await compassVersion(resolved); + return { + executable: resolved, + ...(version ? { version } : {}), + source + }; +} + +export function resolveCompassPath( + executable: string, + environment: NodeJS.ProcessEnv = process.env +): string { + const trimmed = executable.trim(); + const home = environment.HOME ?? environment.USERPROFILE; + const expanded = home && (trimmed === "~" + || trimmed.startsWith("~/") + || trimmed.startsWith("~\\")) + ? path.join(home, trimmed.slice(2)) + : trimmed; + return path.resolve(expanded); +} + +function installationCandidates( + configuration: Pick, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + options: CompassDiscoveryOptions +): Candidate[] { const configured = configuration.get("cliPath")?.trim(); + const executableNames = platform === "win32" + ? ["compass.exe"] + : ["compass"]; const pathCandidates = (environment.PATH ?? "") .split(path.delimiter) .filter(Boolean) - .flatMap((directory) => platform === "win32" - ? ["compass.exe", "compass.cmd", "compass.bat"].map((name) => path.join(directory, name)) - : [path.join(directory, "compass")]); - const candidates = [ - ...(configured ? [configured] : []), - ...pathCandidates - ].filter((candidate, index, all) => all.indexOf(candidate) === index); + .flatMap((directory) => executableNames.map((name) => ({ + executable: path.join(directory, name), + source: "path" as const + }))); + const commonDirectories = options.commonDirectories + ?? commonInstallDirectories(environment, platform); + const commonCandidates = commonDirectories + .flatMap((directory) => executableNames.map((name) => ({ + executable: path.join(directory, name), + source: "common" as const + }))); + + return deduplicateCandidates([ + ...(configured + ? [{ executable: configured, source: "configured" as const }] + : []), + ...pathCandidates, + ...commonCandidates + ], platform, environment); +} + +function commonInstallDirectories( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): string[] { + const home = environment.HOME ?? environment.USERPROFILE; + if (platform === "win32") { + return [ + ...(home + ? [ + path.join(home, ".cargo", "bin"), + path.join(home, ".local", "bin"), + path.join(home, "scoop", "shims") + ] + : []), + ...(environment.LOCALAPPDATA + ? [path.join(environment.LOCALAPPDATA, "Programs", "Compass")] + : []), + ...(environment.ChocolateyInstall + ? [path.join(environment.ChocolateyInstall, "bin")] + : []) + ]; + } + return [ + ...(environment.COMPASS_INSTALL_DIR ? [environment.COMPASS_INSTALL_DIR] : []), + ...(home + ? [ + path.join(home, ".local", "bin"), + path.join(home, ".cargo", "bin"), + path.join(home, "bin") + ] + : []), + ...(platform === "darwin" ? ["/opt/homebrew/bin"] : []), + "/usr/local/bin", + "/usr/bin" + ]; +} + +function deduplicateCandidates( + candidates: Candidate[], + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv +): Candidate[] { + const seen = new Set(); + const unique: Candidate[] = []; for (const candidate of candidates) { - try { - await access(candidate, constants.X_OK); - return { kind: "found", executable: path.resolve(candidate) }; - } catch { - // Continue through every explicit PATH entry. - } + const resolved = resolveCompassPath(candidate.executable, environment); + const key = platform === "win32" ? resolved.toLocaleLowerCase() : resolved; + if (seen.has(key)) continue; + seen.add(key); + unique.push({ ...candidate, executable: resolved }); } - return { kind: "missing", searched: candidates }; + return unique; +} + +function compassVersion(executable: string): Promise { + return new Promise((resolve) => { + execFile( + executable, + ["--version"], + { + encoding: "utf8", + timeout: 2_000, + windowsHide: true, + maxBuffer: 16 * 1024 + }, + (error, stdout, stderr) => { + if (error) { + resolve(undefined); + return; + } + resolve(parseCompassVersion(`${stdout}\n${stderr}`)); + } + ); + }); +} + +function parseCompassVersion(output: string): string | undefined { + const firstLine = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!firstLine) return undefined; + const semanticVersion = firstLine.match( + /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/ + ); + return semanticVersion?.[1] ?? firstLine; } diff --git a/editors/vscode/src/cli/selection.test.ts b/editors/vscode/src/cli/selection.test.ts new file mode 100644 index 0000000..4f1d4bc --- /dev/null +++ b/editors/vscode/src/cli/selection.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { CompassDiscovery } from "./discovery"; +import { compassSelectionItems } from "./selection"; + +describe("compassSelectionItems", () => { + it("shows detected versions, paths, and the active installation", () => { + const discovery: CompassDiscovery = { + kind: "found", + executable: "/home/dev/.local/bin/compass", + version: "0.1.5", + installations: [ + { + executable: "/home/dev/.local/bin/compass", + version: "0.1.5", + source: "common" + }, + { + executable: "/home/dev/.cargo/bin/compass", + version: "0.2.0-beta.1", + source: "path" + } + ], + searched: [] + }; + + expect(compassSelectionItems(discovery)).toEqual([ + expect.objectContaining({ + label: "$(terminal) Compass 0.1.5", + description: "Current selection", + detail: "/home/dev/.local/bin/compass" + }), + expect.objectContaining({ + label: "$(terminal) Compass 0.2.0-beta.1", + description: "Detected on PATH", + detail: "/home/dev/.cargo/bin/compass" + }), + expect.objectContaining({ + label: "$(folder-opened) Browse for another Compass CLI…", + browse: true + }), + expect.objectContaining({ + label: "$(edit) Enter Compass CLI path manually…", + manual: true + }) + ]); + }); + + it("offers browsing when no installation was detected", () => { + const items = compassSelectionItems({ + kind: "missing", + installations: [], + searched: [] + }); + + expect(items).toHaveLength(2); + expect(items[0]?.browse).toBe(true); + expect(items[1]?.manual).toBe(true); + }); +}); diff --git a/editors/vscode/src/cli/selection.ts b/editors/vscode/src/cli/selection.ts new file mode 100644 index 0000000..8e6f395 --- /dev/null +++ b/editors/vscode/src/cli/selection.ts @@ -0,0 +1,58 @@ +import type { + CompassDiscovery, + CompassInstallation, + CompassInstallationSource +} from "./discovery"; + +export type CompassSelectionItem = { + label: string; + description: string; + detail: string; + installation?: CompassInstallation | undefined; + browse?: boolean | undefined; + manual?: boolean | undefined; +}; + +export function compassSelectionItems( + discovery: CompassDiscovery +): CompassSelectionItem[] { + const activeExecutable = discovery.kind === "found" + ? discovery.executable + : undefined; + const detected = discovery.installations.map((installation) => ({ + label: installation.version + ? `$(terminal) Compass ${installation.version}` + : "$(terminal) Compass (version unavailable)", + description: installation.executable === activeExecutable + ? "Current selection" + : sourceLabel(installation.source), + detail: installation.executable, + installation + })); + return [ + ...detected, + { + label: "$(folder-opened) Browse for another Compass CLI…", + description: "Choose an executable outside detected locations", + detail: "The selected path will be stored in compass.cliPath", + browse: true + }, + { + label: "$(edit) Enter Compass CLI path manually…", + description: "Paste an absolute path when auto-detection is unavailable", + detail: "Supports paths such as ~/.local/bin/compass", + manual: true + } + ]; +} + +function sourceLabel(source: CompassInstallationSource): string { + switch (source) { + case "configured": + return "Configured path"; + case "path": + return "Detected on PATH"; + case "common": + return "Common install location"; + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index a218135..2511c94 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -6,8 +6,12 @@ import { type CapabilityRequirement } from "./cli/compatibility"; import { CapabilityReportSchema } from "./cli/contracts"; -import { discoverCompass } from "./cli/discovery"; +import { + discoverCompass, + inspectCompassInstallation +} from "./cli/discovery"; import { CompassProcessManager } from "./cli/processManager"; +import { compassSelectionItems } from "./cli/selection"; import { registerBuildCommands } from "./commands/buildCommands"; import { GraphPanel } from "./views/graphPanel"; import { CallGraphPanel } from "./views/callGraphPanel"; @@ -29,6 +33,14 @@ export async function activate(context: vscode.ExtensionContext): Promise await registry.refresh(); if (discovery.kind === "found") { + output.info( + `Using Compass ${discovery.version ?? "(version unavailable)"} at ${executable}.` + ); + if (discovery.installations.length > 1) { + output.info( + `Detected ${discovery.installations.length} Compass CLI installations.` + ); + } await Promise.all(registry.all().map(async (session) => { try { session.capabilities = await processes.runJson( @@ -51,20 +63,81 @@ export async function activate(context: vscode.ExtensionContext): Promise statusBar.refresh(); }; const selectCompassBinary = async () => { - const selected = await vscode.window.showOpenDialog({ - title: "Select Compass CLI", - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false - }); - if (!selected?.[0]) return; + const latestDiscovery = await discoverCompass( + vscode.workspace.getConfiguration("compass") + ); + const selected = await vscode.window.showQuickPick( + compassSelectionItems(latestDiscovery), + { + title: "Select Compass CLI", + placeHolder: latestDiscovery.installations.length > 0 + ? "Choose a detected Compass version or browse for another executable" + : "No Compass installation was detected; browse for an executable", + matchOnDescription: true, + matchOnDetail: true + } + ); + if (!selected) return; + + let installation = selected.installation; + let selectedPath: string | undefined; + if (selected.browse) { + const browsed = await vscode.window.showOpenDialog({ + title: "Select Compass CLI", + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false + }); + if (!browsed?.[0]) return; + selectedPath = browsed[0].fsPath; + } else if (selected.manual) { + const configuredPath = vscode.workspace.getConfiguration("compass") + .get("cliPath")?.trim(); + selectedPath = await vscode.window.showInputBox({ + title: "Enter Compass CLI path", + prompt: "Enter the full path to a Compass executable", + placeHolder: "~/.local/bin/compass", + ...(configuredPath ? { value: configuredPath } : {}), + validateInput: (value) => value.trim() + ? undefined + : "Enter a Compass CLI path" + }); + if (selectedPath === undefined) return; + } + if (selectedPath !== undefined) { + installation = await inspectCompassInstallation(selectedPath); + if (!installation) { + void vscode.window.showErrorMessage( + `The Compass CLI path is not an executable file: ${selectedPath}` + ); + return; + } + if (!installation.version) { + const action = await vscode.window.showWarningMessage( + "Compass could not verify this executable with --version. Select it anyway?", + "Use Anyway" + ); + if (action !== "Use Anyway") return; + } + } + if (!installation) return; + + if (installation.executable === executable) { + void vscode.window.showInformationMessage( + `Compass ${installation.version ?? "(version unavailable)"} is already active.` + ); + return; + } await vscode.workspace.getConfiguration("compass").update( "cliPath", - selected[0].fsPath, + installation.executable, vscode.ConfigurationTarget.Global ); + const version = installation.version + ? ` ${installation.version}` + : ""; const action = await vscode.window.showInformationMessage( - "Compass CLI selected. Reload VS Code to activate it.", + `Compass${version} selected from ${installation.executable}. Reload VS Code to activate it.`, "Reload Window" ); if (action === "Reload Window") { @@ -72,7 +145,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } }; const handleSetupAction = async (action: string | undefined) => { - if (action === "Select Compass Binary") { + if (action === "Select Compass CLI") { await vscode.commands.executeCommand("compass.selectCli"); } else if (action === "Open Setup") { await vscode.commands.executeCommand( @@ -94,7 +167,7 @@ export async function activate(context: vscode.ExtensionContext): Promise if (!issue) return true; const action = await vscode.window.showErrorMessage( `${issue} Upgrade Compass or select a newer Compass binary, then reload VS Code.`, - "Select Compass Binary", + "Select Compass CLI", "Open Setup" ); await handleSetupAction(action); @@ -146,7 +219,7 @@ export async function activate(context: vscode.ExtensionContext): Promise }, { label: "$(terminal) Select Compass CLI", - description: "Choose a Compass executable", + description: "Compare detected paths and installed versions", action: "cli" } ], { @@ -277,14 +350,14 @@ export async function activate(context: vscode.ExtensionContext): Promise if (discovery.kind === "found" && incompatible) { void vscode.window.showWarningMessage( `The Compass CLI at ${executable} is not compatible with this extension. ${incompatible.capabilityError}`, - "Select Compass Binary", + "Select Compass CLI", "Open Setup" ).then(handleSetupAction); } else if (discovery.kind === "missing") { void vscode.window.showInformationMessage( "Compass CLI is required. Install it, then select or configure the executable.", "Open Setup", - "Select Compass Binary" + "Select Compass CLI" ).then(handleSetupAction); } } diff --git a/editors/vscode/src/views/treeModel.test.ts b/editors/vscode/src/views/treeModel.test.ts index 1fe9868..d6ac527 100644 --- a/editors/vscode/src/views/treeModel.test.ts +++ b/editors/vscode/src/views/treeModel.test.ts @@ -7,7 +7,14 @@ import { const discovery = { kind: "found" as const, - executable: "/usr/local/bin/compass" + executable: "/usr/local/bin/compass", + version: "0.1.5", + installations: [{ + executable: "/usr/local/bin/compass", + version: "0.1.5", + source: "path" as const + }], + searched: ["/usr/local/bin/compass"] }; const available: SessionTreeSnapshot = { @@ -105,7 +112,7 @@ describe("buildWorkspaceTree", () => { it("focuses the tree on setup when the CLI is missing or incompatible", () => { const missing = buildWorkspaceTree( - { kind: "missing", searched: ["/usr/bin/compass"] }, + { kind: "missing", installations: [], searched: ["/usr/bin/compass"] }, [available] ); expect(missing.map((node) => node.label)).toEqual([