Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Patchloom for VS Code is the official VS Code extension for [Patchloom](https://
| `npm run test` | Compile + compile-tests + compile-uitests + unit tests |
| `npm run test:coverage` | Unit tests with line coverage (80% threshold) |
| `npm run package` | Package the `.vsix` using `@vscode/vsce` |
| `npm run check` | Full CI gate: test + coverage + package |
| `npm run check` | Local gate: unit tests + coverage + package (required before commit) |

Always run `npm run check` before committing.
Always run `npm run check` before committing. Merge CI also runs extension integration tests (`test:extension`) and ExTester UI tests (`test:ui`). `npm run test:all` covers compile + unit + extension tests but does not include UI tests.

## Project structure

Expand Down Expand Up @@ -48,16 +48,17 @@ src/
workspace/readiness.ts Workspace readiness: environment detection, folder selection
test/
unit/ Unit tests (node:test, dependency-injected, no VS Code API)
batchApply.test.ts Batch template and operation count parsing (16 tests)
batchApply.test.ts Batch template and operation count parsing (18 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (66 tests)
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
initializeProject.test.ts Status display, agents file classification, formatError (61 tests)
managedLifecycle.test.ts Managed install with real file I/O (22 tests)
mcpConfig.test.ts MCP config with real temp directories (12 tests)
outputChannel.test.ts Output channel logging wrapper (21 tests)
mcpRegister.test.ts Native MCP definition helper for binary path (2 tests)
outputChannel.test.ts Output channel logging wrapper (22 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (46 tests incl. e2e)
propertyBased.test.ts Property-based tests with fast-check (13 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (71 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (73 tests)
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests)
suite/
Expand Down Expand Up @@ -126,7 +127,7 @@ All I/O-dependent functions accept an `inputs` object with injectable callbacks
- No `any` types without justification.
- Pure helpers with injected I/O for testability.
- Keep `extension.ts` thin. No business logic in the entrypoint.
- `npm run check` is the full gate. Nothing merges unless it passes.
- `npm run check` is the local gate (unit tests + coverage + package). Run it before every commit. Merge CI also requires extension integration and UI tests.
- All relative imports must use `.js` extensions (`from "./foo.js"`, not `from "./foo"`). Required by `moduleResolution: "node16"`.
- All commits require a `Signed-off-by` line (DCO). Use `git commit -s`.
- When adding commands to `package.json`, update the expected count in `test/suite/index.ts`.
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ npm test

| Script | Purpose |
|--------|---------|
| `npm run check` | Full CI gate (run before every commit) |
| `npm run check` | Local gate: unit tests + coverage + package (run before every commit) |
| `npm run compile` | Compile extension source |
| `npm run compile-tests` | Compile test source |
| `npm test` | Compile + run all tests |
| `npm test` | Compile + compile-tests + compile-uitests + unit tests |
| `npm run test:unit` | Unit tests only |
| `npm run test:coverage` | Unit tests with 80% line coverage threshold |
| `npm run test:extension` | VS Code extension integration tests |
| `npm run test:all` | Compile + unit tests + extension integration tests (does not include UI tests) |
| `npm run test:ui` | ExTester UI tests (downloads VS Code if needed; not part of `test:all`) |
| `npm run package` | Package the `.vsix` |

### Running in VS Code
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay insid

### Output channel

Search, search `-L`, undo, and patch-merge success or conflict details always appear in the **Patchloom** output channel. Other CLI invocations, arguments, and I/O follow `patchloom.trace.server` (`off` by default; set `verbose` to dump every command). Run `Patchloom: Show Output` to open it.
Search hits, search `-L`, undo restore details, patch-merge results (including failures), and batch-apply results always appear in the **Patchloom** output channel. Diagnostic command traces (invocations, arguments, and other CLI I/O) follow `patchloom.trace.server` (`off` by default; set `verbose` to dump every command). Run `Patchloom: Show Output` to open it.

### Compatibility diagnostics

Expand Down Expand Up @@ -156,7 +156,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
| `patchloom.path` | `""` | Absolute path to the Patchloom binary. When empty, the extension searches `PATH` and then the managed install location. |
| `patchloom.showStatusBar` | `true` | Show a status bar item reporting whether Patchloom is available. |
| `patchloom.enable` | `true` | Enable the extension. When disabled, the status bar is hidden and background checks are skipped. |
| `patchloom.trace.server` | `"off"` | Trace level for CLI output (`off`, `messages`, `verbose`). |
| `patchloom.trace.server` | `"off"` | Diagnostic CLI trace level (`off`, `messages`, `verbose`). User-facing search, undo, patch-merge, and batch-apply streams always go to Output. |
| `patchloom.env` | `{}` | Extra `PATCHLOOM_*` variables for the CLI (for example `{"PATCHLOOM_LOG": "debug"}`). Workspace values apply only in a trusted folder. |
| `patchloom.managedInstall.autoUpdate` | `true` | Automatically check for CLI updates on activation. |

Expand Down Expand Up @@ -218,7 +218,7 @@ On CLI 0.20+, sole-path loads of binary or invalid UTF-8 files report `error_kin
On CLI 0.22+, over-wide fuzzy matches can report `error_kind: fuzzy_span_suspicious`. Prefer an exact `old` string, structured `doc`/`md`/`ast` edits, or `apply-fragment` with a unique anchor.

**Doc selector needs multi-match op**
On CLI 0.27+, `doc set` / `doc ensure` / `doc delete` with a predicate or wildcard selector stay `error_kind: invalid_input` and may include `suggested_op` (`doc.update` or `doc.delete_where`). The extension surfaces that hint in the Output channel and notifications. Use the multi-match op (or a concrete index path such as `items.0.val`).
On CLI 0.27+, `doc set` / `doc ensure` / `doc delete` with a predicate or wildcard selector stay `error_kind: invalid_input` and may include `suggested_op` (`doc.update` or `doc.delete_where`). The extension surfaces that hint in notifications; full CLI text for search, undo, patch-merge, and batch apply is in the Output channel. Use the multi-match op (or a concrete index path such as `items.0.val`).

**Ambiguous markdown heading**
On CLI 0.25+, section ops that match the same heading more than once report `error_kind: ambiguous`. Make the heading unique or use a level-qualified query (for example `## Rules`).
Expand All @@ -230,7 +230,7 @@ Run `Patchloom: Configure MCP` and select the target editor config.
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 inspect the channel. Search, undo, and patch-merge success or conflict details are always written there; set `patchloom.trace.server` to `verbose` for other CLI invocations, arguments, stdout, and stderr.
Run `Patchloom: Show Output` to inspect the channel. Search, search `-L`, undo restore details, patch-merge results (including failures), and batch-apply streams are always written there; set `patchloom.trace.server` to `verbose` for other CLI invocations, arguments, stdout, and stderr.

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@
"messages",
"verbose"
],
"markdownDescription": "Trace level for Patchloom CLI output. `messages` logs command invocations and results. `verbose` includes full stdout/stderr."
"markdownDescription": "Trace level for diagnostic Patchloom CLI logging. `messages` logs command invocations and exit codes. `verbose` includes full stdout/stderr for every command. User-facing results (search hits, search `-L`, undo restore details, patch-merge streams, and batch-apply streams) always appear in the Output channel regardless of this setting."
},
"patchloom.env": {
"type": "object",
Expand Down
24 changes: 21 additions & 3 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { execFile } from "node:child_process";
import type * as VSCode from "vscode";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { formatCliOutput, mergePatchloomEnv } from "../util.js";
import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult } from "../logging/outputChannel.js";
import {
getPatchloomLog,
getPatchloomRuntimeConfig,
logCliCommand,
logCliResult,
presentCliResultInOutput
} from "../logging/outputChannel.js";
import { activeWorkspaceFolder } from "../workspace/readiness.js";

// Batch replace is PATH OLD NEW (not CLI `replace OLD --new NEW path`). See CLI 0.18+ batch --help.
Expand All @@ -28,6 +34,11 @@ export function parseBatchOperationCount(plan: string): number {
return plan.split("\n").filter((line) => line.trim().length > 0).length;
}

/** True when the plan has no non-empty operation lines. */
export function isEmptyBatchPlan(plan: string): boolean {
return parseBatchOperationCount(plan) === 0;
}

/** CLI argv for Batch Apply. Global --contain first (CLI 0.10+ path guard). */
export function buildBatchApplyArgs(): string[] {
return ["--contain", "batch", "--apply"];
Expand Down Expand Up @@ -64,6 +75,13 @@ export async function batchApply(): Promise<void> {
}

const plan = doc.getText();
if (isEmptyBatchPlan(plan)) {
await vscode.window.showWarningMessage(
"Batch plan is empty. Add at least one operation."
);
return;
}

const log = getPatchloomLog();
const runtime = await getPatchloomRuntimeConfig();
const env = mergePatchloomEnv(process.env, runtime.extraEnv);
Expand All @@ -74,15 +92,15 @@ export async function batchApply(): Promise<void> {
logCliResult(log, runtime.trace, result.exitCode, result.stdout, result.stderr);

if (result.exitCode !== 0) {
log?.show();
presentCliResultInOutput(log, result);
await vscode.window.showErrorMessage(
`Batch apply failed: ${formatCliOutput(result)}`
);
return;
}

const ops = parseBatchOperationCount(plan);
log?.show();
presentCliResultInOutput(log, result);
await vscode.window.showInformationMessage(
`Batch apply completed: ${ops} operation(s) applied.`
);
Expand Down
4 changes: 4 additions & 0 deletions src/commands/managedInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
performManagedInstall
} from "../install/managed.js";
import { getPatchloomLog } from "../logging/outputChannel.js";
import { refreshMcpServerBinary } from "../mcp/register.js";
import { refreshStatusBar } from "../status/statusBar.js";
import { formatError } from "../util.js";

Expand Down Expand Up @@ -67,6 +68,7 @@ export async function installPatchloom(): Promise<void> {

log?.log(`Managed install complete: Patchloom ${result.version} at ${result.binaryPath}`);
await refreshStatusBar();
await refreshMcpServerBinary();
await vscode.window.showInformationMessage(
`Patchloom ${result.version} installed successfully.`
);
Expand Down Expand Up @@ -142,6 +144,7 @@ export async function updatePatchloom(): Promise<void> {

log?.log(`Managed update complete: Patchloom ${result.version} at ${result.binaryPath}`);
await refreshStatusBar();
await refreshMcpServerBinary();
await vscode.window.showInformationMessage(
`Patchloom updated to ${result.version}.`
);
Expand Down Expand Up @@ -194,6 +197,7 @@ export async function reinstallPatchloom(): Promise<void> {

log?.log(`Managed reinstall complete: Patchloom ${result.version} at ${result.binaryPath}`);
await refreshStatusBar();
await refreshMcpServerBinary();
await vscode.window.showInformationMessage(
`Patchloom ${result.version} reinstalled successfully.`
);
Expand Down
18 changes: 13 additions & 5 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function presentPatchMergeOutcome(
return "conflicts";
}
if (result.exitCode !== 0) {
presentCliResultInOutput(log, result);
return "error";
}
presentCliResultInOutput(log, result);
Expand All @@ -65,6 +66,17 @@ export function presentUndoSuccess(
presentCliResultInOutput(log, result);
}

export function formatUndoFailureMessage(result: {
exitCode: number;
stdout: string;
stderr: string;
}): string {
if (result.stderr.includes("no backup")) {
return "No patchloom backup to undo.";
}
return `Patchloom undo failed: ${formatCliOutput(result)}`;
}

interface WorkspaceFileTarget {
readonly workspaceFolder: VSCode.WorkspaceFolder;
readonly absolutePath: string;
Expand Down Expand Up @@ -1047,7 +1059,6 @@ export async function runQuickAction(): Promise<void> {
if (outcome === "conflicts") {
await vscode.window.showWarningMessage("Patch merge completed with unresolved conflicts. Check the output for details.");
} else if (outcome === "error") {
log?.show();
await vscode.window.showErrorMessage(`Patch merge failed: ${formatCliOutput(result)}`);
} else {
await vscode.window.showInformationMessage("Patch merged successfully.");
Expand Down Expand Up @@ -1081,10 +1092,7 @@ export async function runQuickAction(): Promise<void> {
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);

if (result.exitCode !== 0) {
const message = result.stderr.includes("no backup")
? "No patchloom backup to undo."
: `Patchloom undo failed: ${formatCliOutput(result)}`;
await vscode.window.showWarningMessage(message);
await vscode.window.showWarningMessage(formatUndoFailureMessage(result));
return;
}

Expand Down
67 changes: 64 additions & 3 deletions src/install/managed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,49 @@ export function assertTrustedManagedInstallDownloadUrl(
}
}

/** Hosts GitHub release downloads may redirect to after the first hop. */
const TRUSTED_MANAGED_INSTALL_REDIRECT_HOSTS = new Set([
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
"github-releases.githubusercontent.com"
]);

/**
* Resolve a redirect Location against the current request URL.
* Handles absolute and relative Location values.
*/
export function resolveManagedInstallRedirectUrl(
currentUrl: string,
location: string
): string {
return new URL(location, currentUrl).href;
}

/**
* Whether a redirect target is safe for managed-install downloads.
* Allows HTTPS only on known GitHub release asset hosts (not the first-hop
* /releases/download/ path allowlist, which real CDN redirects would fail).
*/
export function isTrustedManagedInstallRedirectUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:"
&& TRUSTED_MANAGED_INSTALL_REDIRECT_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}

export function assertTrustedManagedInstallRedirectUrl(url: string): void {
if (!isTrustedManagedInstallRedirectUrl(url)) {
throw new ManagedInstallVerificationError(
"untrusted-download-url",
`Managed install redirect target is not a trusted GitHub release host: ${url}`
);
}
}

export async function clearManagedInstallStaging(
inputs: ManagedInstallStagingCleanupInputs
): Promise<void> {
Expand Down Expand Up @@ -773,9 +816,16 @@ export type HttpGetFn = (
callback: (response: IncomingMessage) => void
) => ClientRequest;

export interface CreateDownloaderOptions {
readonly validateRedirect?: (url: string) => void;
}

export function createDownloader(
get: HttpGetFn
get: HttpGetFn,
options?: CreateDownloaderOptions
): (url: string, destPath: string, redirectsRemaining?: number) => Promise<void> {
const validateRedirect = options?.validateRedirect;

function download(url: string, destPath: string, redirectsRemaining = 5): Promise<void> {
return new Promise((resolve, reject) => {
const file = createWriteStream(destPath);
Expand All @@ -786,7 +836,17 @@ export function createDownloader(
reject(new Error(`Download failed: too many redirects for ${url}`));
return;
}
download(response.headers.location, destPath, redirectsRemaining - 1).then(resolve, reject);
let nextUrl: string;
try {
nextUrl = resolveManagedInstallRedirectUrl(url, response.headers.location);
if (validateRedirect) {
validateRedirect(nextUrl);
}
} catch (error) {
reject(error);
return;
}
download(nextUrl, destPath, redirectsRemaining - 1).then(resolve, reject);
return;
}
if (response.statusCode && response.statusCode >= 400) {
Expand Down Expand Up @@ -819,7 +879,8 @@ export function createDownloader(
}

const defaultDownloadToFile = createDownloader(
https.get.bind(https) as HttpGetFn
https.get.bind(https) as HttpGetFn,
{ validateRedirect: assertTrustedManagedInstallRedirectUrl }
);

async function defaultExecCommand(cmd: string, args: string[], cwd: string): Promise<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/logging/outputChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export function writeUserVisibleOutput(
if (log === undefined || text === "") {
return;
}
if (log.writeUserOutput !== undefined) {
log.writeUserOutput(text);
return;
}
log.log(text);
}

Expand All @@ -92,6 +96,7 @@ export interface PatchloomLog {
log(message: string): void;
logCommand(binary: string, args: readonly string[], cwd: string): void;
logResult(exitCode: number, stdout: string, stderr: string): void;
writeUserOutput?(text: string): void;
show(): void;
dispose(): void;
}
Expand Down Expand Up @@ -141,6 +146,12 @@ export function createPatchloomLog(
}
ch.appendLine(`[${new Date().toISOString()}] Exit code: ${exitCode}`);
},
writeUserOutput(text: string): void {
const ch = ensureChannel();
for (const line of text.split(/\r?\n/)) {
ch.appendLine(line);
}
},
show(): void {
ensureChannel().show(true);
},
Expand Down
Loading
Loading