diff --git a/AGENTS.md b/AGENTS.md index c44be2d..6f91c77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ src/ test/ unit/ Unit tests (node:test, dependency-injected, no VS Code API) batchApply.test.ts Batch template and operation count parsing (16 tests) - binary.test.ts Binary discovery, managed install, compatibility, workspace env (59 tests) + binary.test.ts Binary discovery, managed install, compatibility, workspace env (64 tests) binaryDiscovery.test.ts Real executable discovery on PATH (13 tests) initializeProject.test.ts Status display, agents file classification, formatError (39 tests) managedLifecycle.test.ts Managed install with real file I/O (22 tests) diff --git a/README.md b/README.md index b28d06a..a94fe33 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ On CLI 0.25+, section ops that match the same heading more than once report `err Run `Patchloom: Configure MCP` and select the target editor config. **Managed install failure persists after restart** -Run `Patchloom: Show Status` to see persisted diagnostic details. +Run `Patchloom: Show Status` to see persisted diagnostic details. If the managed binary is present but not usable, choose **Reinstall Patchloom** (or the status-bar action) to re-download from GitHub Releases. **Debugging CLI errors** Run `Patchloom: Show Output` to see full CLI invocations, arguments, stdout, and stderr in the output channel. diff --git a/package-lock.json b/package-lock.json index f4e41e4..4cb611b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "@types/vscode": "^1.90.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.0.0", @@ -1676,9 +1676,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { @@ -2487,9 +2487,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3868,9 +3868,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -4923,9 +4923,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5493,13 +5493,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -8000,9 +8000,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 0c4ba6a..2858fad 100644 --- a/package.json +++ b/package.json @@ -253,7 +253,7 @@ "check": "npm run test && npm run test:coverage && npm run package" }, "overrides": { - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "mocha": { "diff": "^8.0.3", "serialize-javascript": "^7.0.5" @@ -261,7 +261,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "@types/vscode": "^1.90.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.0.0", diff --git a/src/binary/patchloom.ts b/src/binary/patchloom.ts index 07dad45..6e57d38 100644 --- a/src/binary/patchloom.ts +++ b/src/binary/patchloom.ts @@ -153,10 +153,65 @@ export function patchloomNeedsUpgrade(status: PatchloomStatus): boolean { return status.compatibility === "unsupported"; } +/** Primary palette action when the CLI is missing or too old. */ +export interface PatchloomRemediationAction { + readonly title: string; + readonly command: string; +} + +/** + * Choose the best one-click remediation for a missing or outdated CLI. + * Prefers managed install/update (GitHub Releases) so users do not stick on + * lagging community packages (winget, Chocolatey). Pure: unit-testable without VS Code. + */ +export function preferredBinaryRemediationAction( + status: PatchloomStatus +): PatchloomRemediationAction | undefined { + if (!status.ready || !status.binaryPath) { + if (status.managedInstall?.exists) { + return { + title: "Reinstall Patchloom", + command: "patchloom.reinstallBinary" + }; + } + if (status.managedInstall) { + return { + title: "Install Patchloom", + command: "patchloom.installBinary" + }; + } + return { + title: "Open Settings", + command: "patchloom.openPatchloomSettings" + }; + } + + if (patchloomNeedsUpgrade(status)) { + if (status.source === "managed" || status.managedInstall?.exists) { + return { + title: "Update Patchloom", + command: "patchloom.updateBinary" + }; + } + if (status.managedInstall) { + return { + title: "Install Patchloom", + command: "patchloom.installBinary" + }; + } + return { + title: "Open Releases", + command: "patchloom.openPatchloomReleases" + }; + } + + return undefined; +} + /** * Ensures Patchloom is ready (found + compatible). If not, shows a warning - * and offers to open Settings or Releases. Returns the binaryPath if ready, - * otherwise null (after showing UI). + * with the preferred remediation (managed install/update when available). + * Returns the binaryPath if ready, otherwise null (after showing UI). * * This removes duplicated ready-check + notify logic across commands. */ @@ -168,43 +223,24 @@ export async function ensurePatchloomReadyOrNotify( ? await resolvePatchloomStatusWithInputs(testInputs) : await resolvePatchloomStatus(); - if (!status.ready || !status.binaryPath) { - const vscode = await import("vscode"); - const choice = await vscode.window.showWarningMessage( - `${status.message}${contextSuffix ? `\n\n${contextSuffix}` : ""}`, - "Open Settings" - ); - if (choice === "Open Settings") { - await vscode.commands.executeCommand("patchloom.openPatchloomSettings"); - } - return null; + const remediation = preferredBinaryRemediationAction(status); + if (!remediation) { + return status.binaryPath ?? null; } - if (patchloomNeedsUpgrade(status)) { - const vscode = await import("vscode"); - // Prefer managed install/update (GitHub Releases) over lagging community packages. - const canUpdateManaged = status.source === "managed" || status.managedInstall?.exists === true; - const canInstallManaged = status.managedInstall !== undefined; - const primaryAction = canUpdateManaged - ? "Update Patchloom" - : canInstallManaged - ? "Install Patchloom" - : "Open Releases"; - const choice = await vscode.window.showWarningMessage( - `${status.compatibilityMessage}${contextSuffix ? `\n\n${contextSuffix}` : ""}`, - primaryAction - ); - if (choice === "Update Patchloom") { - await vscode.commands.executeCommand("patchloom.updateBinary"); - } else if (choice === "Install Patchloom") { - await vscode.commands.executeCommand("patchloom.installBinary"); - } else if (choice === "Open Releases") { - await vscode.commands.executeCommand("patchloom.openPatchloomReleases"); - } - return null; + const vscode = await import("vscode"); + const baseMessage = + status.ready && patchloomNeedsUpgrade(status) + ? (status.compatibilityMessage ?? status.message) + : status.message; + const choice = await vscode.window.showWarningMessage( + `${baseMessage}${contextSuffix ? `\n\n${contextSuffix}` : ""}`, + remediation.title + ); + if (choice === remediation.title) { + await vscode.commands.executeCommand(remediation.command); } - - return status.binaryPath; + return null; } export function parsePatchloomVersion(versionText?: string): string | undefined { diff --git a/src/status/details.ts b/src/status/details.ts index da3b133..ffb4145 100644 --- a/src/status/details.ts +++ b/src/status/details.ts @@ -2,6 +2,7 @@ import { describePatchloomCompatibility, describePatchloomSource, patchloomNeedsUpgrade, + preferredBinaryRemediationAction, PatchloomStatus } from "../binary/patchloom.js"; import type { McpTargetStatus } from "../mcp/config.js"; @@ -43,38 +44,9 @@ export function buildStatusDetails(status: PatchloomStatus, workspaceReadiness?: } export function preferredStatusAction(status: PatchloomStatus, workspaceReadiness?: WorkspaceReadiness): SetupAction | undefined { - if (!status.ready) { - if (status.source === "missing" && status.managedInstall && !status.managedInstall.exists) { - return { - title: "Install Patchloom", - command: "patchloom.installBinary" - }; - } - return { - title: "Open Settings", - command: "patchloom.openPatchloomSettings" - }; - } - - if (patchloomNeedsUpgrade(status)) { - // Prefer managed install/update so users stay on GitHub Releases (not lagging - // community packages such as winget/Chocolatey). - if (status.source === "managed" || status.managedInstall?.exists) { - return { - title: "Update Patchloom", - command: "patchloom.updateBinary" - }; - } - if (status.managedInstall) { - return { - title: "Install Patchloom", - command: "patchloom.installBinary" - }; - } - return { - title: "Open Releases", - command: "patchloom.openPatchloomReleases" - }; + // Missing or outdated CLI: shared remediation (managed install preferred). + if (!status.ready || patchloomNeedsUpgrade(status)) { + return preferredBinaryRemediationAction(status); } if (workspaceReadiness?.hasWorkspace && workspaceReadiness.hasAgentsFile === false) { diff --git a/test/unit/binary.test.ts b/test/unit/binary.test.ts index 3f7b7ad..bf9b96d 100644 --- a/test/unit/binary.test.ts +++ b/test/unit/binary.test.ts @@ -14,6 +14,7 @@ import { patchloomNeedsUpgrade, parsePatchloomVersion, ensurePatchloomReadyOrNotify, + preferredBinaryRemediationAction, resolvePatchloomStatusWithInputs } from "../../src/binary/patchloom.js"; import { @@ -138,10 +139,106 @@ test("ensurePatchloomReadyOrNotify returns path for ready supported status (test assert.equal(path, "/good/patchloom"); }); -// Note: error paths (not-ready, upgrade) execute vscode.window.show* which is -// not unit-testable in pure node (would require full VS Code test env or mocks). -// They are exercised via command integration and manual. The core logic delegates -// to resolve+needsUpgrade which are unit tested. +test("preferredBinaryRemediationAction installs when CLI missing and managed available", () => { + const action = preferredBinaryRemediationAction({ + ready: false, + source: "missing", + message: "Patchloom binary not found.", + managedInstall: { + exists: false, + binaryPath: "/managed/managed-bin/patchloom", + target: { + platform: "darwin", + arch: "arm64", + targetTriple: "aarch64-apple-darwin", + archiveFormat: ".tar.xz" + } + } + }); + assert.deepEqual(action, { + title: "Install Patchloom", + command: "patchloom.installBinary" + }); +}); + +test("preferredBinaryRemediationAction reinstalls when managed binary present but not ready", () => { + const action = preferredBinaryRemediationAction({ + ready: false, + source: "managed", + message: "Managed Patchloom binary is not executable.", + managedInstall: { + exists: true, + binaryPath: "/managed/managed-bin/patchloom", + target: { + platform: "darwin", + arch: "arm64", + targetTriple: "aarch64-apple-darwin", + archiveFormat: ".tar.xz" + } + } + }); + assert.deepEqual(action, { + title: "Reinstall Patchloom", + command: "patchloom.reinstallBinary" + }); +}); + +test("preferredBinaryRemediationAction updates outdated managed install", () => { + const action = preferredBinaryRemediationAction({ + ready: true, + source: "managed", + message: "Using managed Patchloom install.", + binaryPath: "/managed/managed-bin/patchloom", + version: "patchloom 0.0.9", + detectedVersion: "0.0.9", + compatibility: "unsupported", + minimumSupportedVersion: MINIMUM_SUPPORTED_PATCHLOOM_VERSION, + compatibilityMessage: `Patchloom 0.0.9 is older than the minimum supported version ${MINIMUM_SUPPORTED_PATCHLOOM_VERSION}.`, + managedInstall: { + exists: true, + binaryPath: "/managed/managed-bin/patchloom", + target: { + platform: "darwin", + arch: "arm64", + targetTriple: "aarch64-apple-darwin", + archiveFormat: ".tar.xz" + } + } + }); + assert.deepEqual(action, { + title: "Update Patchloom", + command: "patchloom.updateBinary" + }); +}); + +test("preferredBinaryRemediationAction opens settings when no managed path exists", () => { + const action = preferredBinaryRemediationAction({ + ready: false, + source: "missing", + message: "Patchloom binary not found." + }); + assert.deepEqual(action, { + title: "Open Settings", + command: "patchloom.openPatchloomSettings" + }); +}); + +test("preferredBinaryRemediationAction is undefined when ready and supported", () => { + const action = preferredBinaryRemediationAction({ + ready: true, + source: "path", + message: "Using Patchloom from PATH.", + binaryPath: "/usr/local/bin/patchloom", + version: "patchloom 0.28.0", + detectedVersion: "0.28.0", + compatibility: "supported", + minimumSupportedVersion: MINIMUM_SUPPORTED_PATCHLOOM_VERSION + }); + assert.equal(action, undefined); +}); + +// Note: ensurePatchloomReadyOrNotify UI branches call vscode.window.show* which is +// not unit-testable in pure node. Remediation selection is covered above. test("defaultWorkspaceFolderIndex prefers active folders and only auto-selects single roots", () => { assert.equal(defaultWorkspaceFolderIndex(3, 2), 2);