Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:
uses: ./.github/actions/setup-node

- name: Compile extension and tests
run: npm run compile && npm run compile-tests
run: npm run compile && npm run compile-tests && npm run compile-uitests

- name: Run extension integration tests (Linux)
if: runner.os == 'Linux'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node

- name: Audit production dependencies
run: npm audit --audit-level=high --omit=dev
- name: Audit dependencies
run: npm audit --audit-level=high

trivy:
name: Trivy vulnerability scan
Expand Down
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Patchloom for VS Code is the official VS Code extension for [Patchloom](https://
| `npm run test:extension` | Run VS Code extension integration tests |
| `npm run test:ui` | Run ExTester UI tests (downloads VS Code if needed) |
| `npm run test:all` | Compile + unit tests + extension integration tests |
| `npm run test` | Compile + compile-tests + unit tests |
| `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 |
Expand Down Expand Up @@ -51,13 +51,13 @@ test/
batchApply.test.ts Batch template and operation count parsing (16 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 (41 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 (10 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (40 tests incl. e2e)
outputChannel.test.ts Output channel logging wrapper (15 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 (62 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (64 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
| `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.env` | `{}` | Additional environment variables passed to the CLI (e.g., `{"PATCHLOOM_LOG": "debug"}`). |
| `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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"patchloom.env": {
"type": "object",
"default": {},
"markdownDescription": "Additional environment variables passed to the Patchloom CLI. For example, `{\"PATCHLOOM_LOG\": \"debug\"}` enables debug logging in the CLI."
"restricted": true,
"markdownDescription": "Additional `PATCHLOOM_*` environment variables passed to the CLI (for example `{\"PATCHLOOM_LOG\": \"debug\"}`). Workspace values apply only in a trusted folder. Loader keys such as PATH and LD_PRELOAD are ignored."
},
"patchloom.managedInstall.autoUpdate": {
"type": "boolean",
Expand All @@ -246,7 +247,7 @@
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-exclude='out-test/src/extension*' --test-coverage-exclude='out-test/src/commands/showStatus*' --test-coverage-exclude='out-test/src/commands/configureMcp*' --test-coverage-exclude='out-test/src/commands/setupWorkspace*' --test-coverage-exclude='out-test/src/commands/batchApply*' --test-coverage-exclude='out-test/src/commands/quickActions*' --test-coverage-exclude='out-test/src/status/statusBar*' --test-coverage-exclude='out-test/test/unit/patchloomCli*' ./out-test/test/unit/*.test.js",
"test:extension": "node ./out-test/test/suite/runExtensionTests.js",
"test:ui": "npm run compile && npm run compile-uitests && extest setup-and-run './out-uitest/test/ui/*.test.js' --code_version max --extensions_dir .vscode-test/extensions",
"test": "npm run compile && npm run compile-tests && npm run test:unit",
"test": "npm run compile && npm run compile-tests && npm run compile-uitests && npm run test:unit",
"test:all": "npm run compile && npm run compile-tests && npm run test:unit && npm run test:extension",
"vscode:prepublish": "npm run compile",
"package": "vsce package",
Expand All @@ -261,8 +262,8 @@
},
"devDependencies": {
"@types/mocha": "^10.0.10",
"@types/node": "^26.2.0",
"@types/vscode": "^1.90.0",
"@types/node": "^26.4.0",
"@types/vscode": "~1.90.0",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.0.0",
"fast-check": "^4.9.0",
Expand Down
16 changes: 10 additions & 6 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { execFile } from "node:child_process";
import type * as VSCode from "vscode";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { formatCliOutput } from "../util.js";
import { getPatchloomLog } from "../logging/outputChannel.js";
import { formatCliOutput, mergePatchloomEnv } from "../util.js";
import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult } 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 Down Expand Up @@ -65,11 +65,13 @@ export async function batchApply(): Promise<void> {

const plan = doc.getText();
const log = getPatchloomLog();
const runtime = await getPatchloomRuntimeConfig();
const env = mergePatchloomEnv(process.env, runtime.extraEnv);
const args = buildBatchApplyArgs();
log?.logCommand(binaryPath, args, folder.uri.fsPath);
logCliCommand(log, runtime.trace, binaryPath, args, folder.uri.fsPath);

const result = await executePatchloomWithStdin(binaryPath, args, folder.uri.fsPath, plan);
log?.logResult(result.exitCode, result.stdout, result.stderr);
const result = await executePatchloomWithStdin(binaryPath, args, folder.uri.fsPath, plan, env);
logCliResult(log, runtime.trace, result.exitCode, result.stdout, result.stderr);

if (result.exitCode !== 0) {
log?.show();
Expand All @@ -96,11 +98,13 @@ function executePatchloomWithStdin(
binaryPath: string,
args: readonly string[],
cwd: string,
stdin: string
stdin: string,
env: NodeJS.ProcessEnv
): Promise<BatchCommandResult> {
return new Promise((resolve) => {
const child = execFile(binaryPath, [...args], {
cwd,
env,
timeout: 30_000,
maxBuffer: 8 * 1024 * 1024,
windowsHide: true
Expand Down
76 changes: 63 additions & 13 deletions src/commands/initializeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type * as VSCode from "vscode";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { getPatchloomLog } from "../logging/outputChannel.js";
import { formatError } from "../util.js";
import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult } from "../logging/outputChannel.js";
import { formatCliOutput, formatError, mergePatchloomEnv } from "../util.js";
import { activeWorkspaceFolder } from "../workspace/readiness.js";

const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -72,12 +72,16 @@ export async function initializeProject(): Promise<void> {
return;
}

const runtime = await getPatchloomRuntimeConfig();
let rules: string;
try {
rules = await generateAgentRules(binaryPath, folder.uri.fsPath, {
mode: modePick.mode,
platform: platformPick.platform,
surface: surfacePick.surface
}, {
extraEnv: runtime.extraEnv,
trace: runtime.trace
});
} catch (error) {
await vscode.window.showErrorMessage(`Failed to run patchloom agent-rules in ${folder.name}: ${formatError(error)}`);
Expand All @@ -90,8 +94,15 @@ export async function initializeProject(): Promise<void> {
}

const agentsUri = vscode.Uri.joinPath(folder.uri, "AGENTS.md");
const existingContent = await readTextFileIfExists(agentsUri);
const state = classifyAgentsFile(existingContent, rules);
let existingContent: string | undefined;
let state: AgentsFileState;
try {
existingContent = await readTextFileIfExists(agentsUri);
state = classifyAgentsFile(existingContent, rules);
} catch (error) {
await vscode.window.showErrorMessage(`Could not read AGENTS.md: ${formatError(error)}`);
return;
}

if (state === "missing") {
await vscode.workspace.fs.writeFile(agentsUri, encoder.encode(rules));
Expand Down Expand Up @@ -160,36 +171,75 @@ export function buildAgentRulesArgs(options: AgentRulesOptions = {}): string[] {
return args;
}

export interface GenerateAgentRulesDeps {
readonly execFile?: (
file: string,
args: readonly string[],
options: {
cwd?: string;
timeout?: number;
maxBuffer?: number;
windowsHide?: boolean;
env?: NodeJS.ProcessEnv;
}
) => Promise<{ stdout: string; stderr: string }>;
readonly extraEnv?: Record<string, string>;
readonly processEnv?: NodeJS.ProcessEnv;
readonly trace?: string;
}

export async function generateAgentRules(
binaryPath: string,
cwd: string,
options: AgentRulesOptions = {}
options: AgentRulesOptions = {},
deps: GenerateAgentRulesDeps = {}
): Promise<string> {
const log = getPatchloomLog();
const args = buildAgentRulesArgs(options);
log?.logCommand(binaryPath, args, cwd);
const env = mergePatchloomEnv(deps.processEnv ?? process.env, deps.extraEnv);
const trace = deps.trace ?? "off";
logCliCommand(log, trace, binaryPath, args, cwd);
const run = deps.execFile ?? execFileAsync;
try {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
const { stdout, stderr } = await run(binaryPath, args, {
cwd,
timeout: 10_000,
maxBuffer: 1024 * 1024,
windowsHide: true
windowsHide: true,
env
});
log?.logResult(0, stdout, stderr);
logCliResult(log, trace, 0, stdout, stderr);
return stdout.endsWith("\n") ? stdout : `${stdout}\n`;
} catch (error) {
log?.logResult(1, "", formatError(error));
throw error;
const candidate = error as { code?: unknown; stdout?: unknown; stderr?: unknown };
const exitCode = typeof candidate.code === "number" ? candidate.code : 1;
const stdout = typeof candidate.stdout === "string" ? candidate.stdout : "";
const stderr = typeof candidate.stderr === "string" && candidate.stderr.length > 0
? candidate.stderr
: formatError(error);
logCliResult(log, trace, exitCode, stdout, stderr);
throw new Error(formatCliOutput({ exitCode, stdout, stderr }));
}
}

/** True only for VS Code FileNotFound (missing file). Other FS errors must propagate. */
export function isMissingFileError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
return "code" in error && (error as { code: unknown }).code === "FileNotFound";
}

async function readTextFileIfExists(uri: VSCode.Uri): Promise<string | undefined> {
const vscode = await import("vscode");
try {
const bytes = await vscode.workspace.fs.readFile(uri);
return decoder.decode(bytes);
} catch {
return undefined;
} catch (error) {
if (isMissingFileError(error)) {
return undefined;
}
throw error;
}
}

Expand Down
Loading
Loading