diff --git a/packages/op-vscode/build.mjs b/packages/op-vscode/build.mjs index e10dd4c6d..9bd7d4238 100644 --- a/packages/op-vscode/build.mjs +++ b/packages/op-vscode/build.mjs @@ -1,7 +1,30 @@ import esbuild from "esbuild"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; const watch = process.argv.includes("--watch"); +// Extract the bundled OpenPencil design skill markdown from the CLI's skill +// bundle into dist/assets so the "Install AI Skill" command can ship it. The +// name openpencil-design lives only here, NOT in the op-ai-skills registry. +function extractSkill() { + const src = "../../crates/op-cli/assets/skill-bundle.json"; + const out = "dist/assets/openpencil-skill.md"; + try { + const bundle = JSON.parse(readFileSync(src, "utf8")); + // `files` is a flat map keyed by full relative path. + const md = bundle?.files?.["skills/openpencil-design/SKILL.md"]; + if (typeof md !== "string") throw new Error("SKILL.md not found in skill-bundle.json"); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, md); + console.log(`extracted skill (${md.length} bytes) → ${out}`); + } catch (err) { + console.warn(`skill extraction skipped: ${err.message}`); + } +} + +extractSkill(); + const ctx = await esbuild.context({ entryPoints: ["src/extension.ts"], bundle: true, diff --git a/packages/op-vscode/package.json b/packages/op-vscode/package.json index 4e621d8c9..8beb332aa 100644 --- a/packages/op-vscode/package.json +++ b/packages/op-vscode/package.json @@ -89,9 +89,10 @@ "scripts": { "build": "node build.mjs", "watch": "node build.mjs --watch", - "test": "bun test", + "test": "bun test src", "typecheck": "tsc --noEmit", - "lint": "oxlint" + "lint": "oxlint", + "test:integration": "bun test test/integration" }, "devDependencies": { "@types/vscode": "~1.90", diff --git a/packages/op-vscode/src/daemon/daemon-pool.test.ts b/packages/op-vscode/src/daemon/daemon-pool.test.ts index 0911b546a..885ec18b5 100644 --- a/packages/op-vscode/src/daemon/daemon-pool.test.ts +++ b/packages/op-vscode/src/daemon/daemon-pool.test.ts @@ -32,11 +32,17 @@ function poolWithSpawns() { const spawned: FakeClient[] = []; let next = 1; let failNext = false; + // When set, the NEXT spawn blocks on this gate (to simulate an in-flight + // respawn); resolve it to complete the spawn. + let gate: { promise: Promise; release: () => void } | undefined; const spawn = async (): Promise => { if (failNext) { failNext = false; throw new Error("spawn failed"); } + const g = gate; + gate = undefined; + if (g) await g.promise; const f = new FakeClient(next++); spawned.push(f); return asClient(f); @@ -48,6 +54,12 @@ function poolWithSpawns() { setFailNext: () => { failNext = true; }, + gateNextSpawn: () => { + let release!: () => void; + const promise = new Promise((r) => (release = r)); + gate = { promise, release }; + return release; + }, }; } @@ -138,6 +150,27 @@ test("restart failure evicts and notifies with no client", async () => { expect(pool.clientFor("/a.op")).toBeUndefined(); }); +test("releasing during a respawn disposes the newly spawned daemon (no orphan)", async () => { + const { pool, spawned, gateNextSpawn } = poolWithSpawns(); + const restarts: Array = []; + pool.onRestart((_f, client) => restarts.push(client !== undefined)); + await pool.acquire("/a.op", "o"); + // Crash A: handleCrash starts respawning, but the spawn is gated (in flight). + const releaseSpawn = gateNextSpawn(); + (spawned[0] as unknown as FakeClient).crash(); + await new Promise((r) => setTimeout(r, 0)); // let handleCrash reach the await + // User closes the panel now → release during the respawn window. + await pool.release("/a.op"); + // Now the respawn completes. + releaseSpawn(); + await new Promise((r) => setTimeout(r, 0)); + // A second daemon WAS spawned, but must have been disposed immediately. + expect(spawned.length).toBe(2); + expect((spawned[1] as unknown as FakeClient).disposed).toBe(true); + expect(pool.clientFor("/a.op")).toBeUndefined(); // no lingering entry + expect(restarts).toEqual([]); // no restart-with-client emitted for an owner-less daemon +}); + test("dispose()-driven exit does not trigger a restart", async () => { const { pool, spawned } = poolWithSpawns(); let restartCalls = 0; diff --git a/packages/op-vscode/src/daemon/daemon-pool.ts b/packages/op-vscode/src/daemon/daemon-pool.ts index 885a0941a..ea41d2b06 100644 --- a/packages/op-vscode/src/daemon/daemon-pool.ts +++ b/packages/op-vscode/src/daemon/daemon-pool.ts @@ -121,6 +121,14 @@ export class DaemonPool { this.logger.error(`daemon for ${filePath} exited; restarting once`); try { const client = await this.spawnFn(filePath, entry.allowOrigin); + // The file may have been released (or the pool disposed) DURING the + // respawn — both mark the crashed entry disposed. If so, the freshly + // spawned daemon has no owner: dispose it immediately instead of adopting + // it, otherwise it orphans (alive, no entry, no mount). + if (entry.disposed || this.entries.get(filePath) !== entry) { + await client.dispose(); + return; + } const next: Entry = { client, allowOrigin: entry.allowOrigin, restarts: entry.restarts + 1, disposed: false }; this.entries.set(filePath, next); this.wireExit(filePath, next); diff --git a/packages/op-vscode/src/extension.ts b/packages/op-vscode/src/extension.ts index fda4cd304..711c37a35 100644 --- a/packages/op-vscode/src/extension.ts +++ b/packages/op-vscode/src/extension.ts @@ -1,12 +1,283 @@ +// Activation assembly. Single registration topology (commands + one editor +// provider registered exactly once), late-bound through a mutable AppState so +// the limited→trusted transition never re-registers anything. Untrusted +// workspaces get a restricted placeholder; granting trust assembles the full +// stack and reopens placeholder tabs. + import * as vscode from "vscode"; +import type { DaemonLogger } from "./daemon/daemon-client"; +import { DaemonClient } from "./daemon/daemon-client"; +import { DaemonPool } from "./daemon/daemon-pool"; +import { McpProxy } from "./mcp/mcp-proxy"; +import { SessionRegistry } from "./session/session-registry"; +import { PenEditorProvider } from "./vscode/pen-editor-provider"; +import { resolveDaemonBinary } from "./vscode/restart-source"; +import { configureMcpCommand, removeMcpCommand } from "./vscode/configure-command"; +import { installSkillCommand, removeSkillCommand } from "./vscode/skill-command"; +import { generateCodeCommand, registerChatParticipant } from "./vscode/codegen-command"; -let outputChannel: vscode.OutputChannel | undefined; +const PROXY_PORT_KEY = "openpencil.proxyPort"; -export function activate(_context: vscode.ExtensionContext): void { - outputChannel = vscode.window.createOutputChannel("OpenPencil"); +interface Assembled { + pool: DaemonPool; + proxy: McpProxy; + registry: SessionRegistry; + proxyPort: number; } -export function deactivate(): void { - outputChannel?.dispose(); - outputChannel = undefined; +interface AppState { + trusted: boolean; + assembled?: Assembled; + /** In-flight assembly, so the provider can await it instead of falling back + * to the restricted placeholder when a trusted workspace opens a .op file + * before assembly finished. */ + assembling?: Promise; +} + +let output: vscode.OutputChannel | undefined; +const placeholderUris: vscode.Uri[] = []; + +export function activate(context: vscode.ExtensionContext): void { + output = vscode.window.createOutputChannel("OpenPencil"); + const logger: DaemonLogger = { + info: (l) => output?.appendLine(l), + error: (l) => output?.appendLine(`[error] ${l}`), + }; + const state: AppState = { trusted: vscode.workspace.isTrusted }; + currentState = state; + + // Commands are registered ONCE; handlers dispatch through the mutable state. + registerCommands(context, state); + + // The editor provider is registered ONCE; it branches on state.trusted. + const provider = new UnifiedPenProvider(context, state, logger); + context.subscriptions.push( + vscode.window.registerCustomEditorProvider("openpencil.penEditor", provider, { + supportsMultipleEditorsPerDocument: false, + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + + if (state.trusted) { + state.assembling = assemble(context, state, logger); + void state.assembling; + } else { + context.subscriptions.push( + vscode.workspace.onDidGrantWorkspaceTrust(() => { + void onTrustGranted(context, state, logger); + }), + ); + } +} + +export async function deactivate(): Promise { + // Await the daemon shutdown chain so no managed daemon is orphaned. + if (currentState?.assembled) { + await currentState.assembled.proxy.dispose(); + await currentState.assembled.pool.disposeAll(); + } + output?.dispose(); + output = undefined; +} + +let currentState: AppState | undefined; + +// ---- assembly ---- + +async function assemble( + context: vscode.ExtensionContext, + state: AppState, + logger: DaemonLogger, +): Promise { + if (state.assembled) return; // idempotent guard + const registry = new SessionRegistry(); + const spawn = makeSpawn(logger); + const pool = new DaemonPool(spawn, logger); + const proxy = new McpProxy(pool, logger); + + const preferred = + context.workspaceState.get(PROXY_PORT_KEY) ?? + vscode.workspace.getConfiguration("openpencil").get("proxy.port", 0); + let proxyPort: number; + try { + proxyPort = await proxy.listen(preferred); + } catch { + proxyPort = await proxy.listen(0); // preferred taken → OS-assign + void vscode.window.showInformationMessage( + "OpenPencil: the saved MCP port was busy; re-run 'Configure AI (MCP)' to update your config.", + ); + } + await context.workspaceState.update(PROXY_PORT_KEY, proxyPort); + + state.assembled = { pool, proxy, registry, proxyPort }; + context.subscriptions.push( + new vscode.Disposable(() => { + void proxy.dispose(); + void pool.disposeAll(); + }), + ); + const participant = registerChatParticipant(registry); + if (participant) context.subscriptions.push(participant); +} + +async function onTrustGranted( + context: vscode.ExtensionContext, + state: AppState, + logger: DaemonLogger, +): Promise { + if (state.assembled) return; + state.trusted = true; + await assemble(context, state, logger); + // VS Code does not re-resolve already-open custom editors, so reopen the + // placeholder tabs to pick up the now-trusted provider branch. + for (const uri of placeholderUris.splice(0)) { + await closeTabFor(uri); + await vscode.commands.executeCommand("vscode.openWith", uri, "openpencil.penEditor"); + } +} + +/** DaemonClient spawn factory: resolves the binary per file's workspace. */ +function makeSpawn(logger: DaemonLogger) { + return async (filePath: string, allowOrigin: string) => { + const uri = vscode.Uri.file(filePath); + const folder = vscode.workspace.getWorkspaceFolder(uri) ?? vscode.workspace.workspaceFolders?.[0]; + const configured = vscode.workspace.getConfiguration("openpencil").get("dev.daemonPath"); + const binary = resolveDaemonBinary(configured, folder?.uri.fsPath); + if (!binary) throw new Error("op-host-web-server binary not found; set openpencil.dev.daemonPath"); + return DaemonClient.spawn({ + command: [binary], + filePath, + allowOrigin, + logger, + expectedVersion: undefined, + }); + }; +} + +// ---- unified provider (trusted branch delegates to PenEditorProvider) ---- + +class UnifiedPenProvider implements vscode.CustomEditorProvider { + private readonly emitter = new vscode.EventEmitter< + vscode.CustomDocumentContentChangeEvent + >(); + readonly onDidChangeCustomDocument = this.emitter.event; + private real?: PenEditorProvider; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly state: AppState, + private readonly logger: DaemonLogger, + ) {} + + /** Trusted → await any in-flight assembly, then the real provider. Untrusted + * → undefined (placeholder). Never returns undefined for a trusted workspace + * just because assembly hasn't finished yet — that would strand a valid file + * on the restricted placeholder with no trust event ever coming. */ + private async realProvider(): Promise { + if (!this.state.trusted) return undefined; + if (!this.state.assembled && this.state.assembling) await this.state.assembling; + if (!this.state.assembled) return undefined; // assembly failed + if (!this.real) { + this.real = new PenEditorProvider( + this.context, + this.state.assembled.pool, + this.state.assembled.registry, + this.logger, + ); + this.real.onDidChangeCustomDocument((e) => + this.emitter.fire({ document: e.document as vscode.CustomDocument }), + ); + } + return this.real; + } + + async openCustomDocument( + uri: vscode.Uri, + openContext: vscode.CustomDocumentOpenContext, + _token: vscode.CancellationToken, + ): Promise { + const real = await this.realProvider(); + if (real) return real.openCustomDocument(uri, openContext); + // Untrusted only: a minimal placeholder document, tracked for the upgrade. + placeholderUris.push(uri); + return { uri, dispose: () => undefined }; + } + + async resolveCustomEditor( + document: vscode.CustomDocument, + panel: vscode.WebviewPanel, + _token: vscode.CancellationToken, + ): Promise { + const real = await this.realProvider(); + if (real) return real.resolveCustomEditor(document as never, panel); + panel.webview.options = { enableScripts: true }; + panel.webview.html = restrictedPage(); + panel.webview.onDidReceiveMessage((m: unknown) => { + if (m === "trust") void vscode.commands.executeCommand("workbench.action.manageTrust"); + }); + } + + // save/backup/revert only run after resolveCustomEditor set `this.real` + // (a placeholder document never becomes dirty/savable), so use it directly. + saveCustomDocument(document: vscode.CustomDocument, c: vscode.CancellationToken): Thenable { + return this.real?.saveCustomDocument(document as never, c) ?? Promise.resolve(); + } + saveCustomDocumentAs( + document: vscode.CustomDocument, + dest: vscode.Uri, + c: vscode.CancellationToken, + ): Thenable { + return this.real?.saveCustomDocumentAs(document as never, dest, c) ?? Promise.resolve(); + } + revertCustomDocument(document: vscode.CustomDocument, _c: vscode.CancellationToken): Thenable { + return this.real?.revertCustomDocument(document as never) ?? Promise.resolve(); + } + backupCustomDocument( + document: vscode.CustomDocument, + ctx: vscode.CustomDocumentBackupContext, + c: vscode.CancellationToken, + ): Thenable { + if (this.real) return this.real.backupCustomDocument(document as never, ctx, c); + return Promise.resolve({ id: ctx.destination.toString(), delete: () => undefined }); + } +} + +// ---- commands ---- + +function registerCommands(context: vscode.ExtensionContext, state: AppState): void { + const guard = (fn: (a: Assembled) => Promise | void) => async () => { + if (!state.trusted || !state.assembled) { + void vscode.window.showWarningMessage("OpenPencil: trust this workspace to use this command."); + return; + } + await fn(state.assembled); + }; + context.subscriptions.push( + vscode.commands.registerCommand("openpencil.configureMcp", guard((a) => configureMcpCommand(a.proxyPort))), + vscode.commands.registerCommand("openpencil.removeMcp", guard(() => removeMcpCommand())), + vscode.commands.registerCommand("openpencil.installSkill", guard(() => installSkillCommand(context))), + vscode.commands.registerCommand("openpencil.removeSkill", guard(() => removeSkillCommand())), + vscode.commands.registerCommand("openpencil.generateCode", guard((a) => generateCodeCommand(a.registry))), + ); +} + +// ---- pages ---- + +async function closeTabFor(uri: vscode.Uri): Promise { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input?.uri?.toString() === uri.toString()) { + await vscode.window.tabGroups.close(tab); + } + } + } +} + +function restrictedPage(): string { + return ` +

OpenPencil is in Restricted Mode

+

This workspace isn't trusted, so OpenPencil won't start a local design daemon or write MCP config.

+ +`; } diff --git a/packages/op-vscode/src/vscode/codegen-command.ts b/packages/op-vscode/src/vscode/codegen-command.ts new file mode 100644 index 000000000..2bd3e9b28 --- /dev/null +++ b/packages/op-vscode/src/vscode/codegen-command.ts @@ -0,0 +1,156 @@ +// "Generate Code from Design" command + the @openpencil chat participant. Both +// use vscode.lm; when it is unavailable, generateCode guides the user to their +// IDE's own chat with a pre-filled prompt. The prompt assembly and output +// validation live in the tested, vscode-free codegen-prompt module. + +import * as vscode from "vscode"; +import * as fsPath from "node:path"; +import type { SessionRegistry } from "../session/session-registry"; +import { + buildCodegenPrompt, + parseCodegenOutput, + type Framework, +} from "./codegen-prompt"; + +interface LmApi { + selectChatModels(selector?: unknown): Thenable; +} +function lm(): LmApi | undefined { + return (vscode as unknown as { lm?: LmApi }).lm; +} + +function framework(): Framework { + const v = vscode.workspace.getConfiguration("openpencil").get("codegen.framework", "react"); + return v === "vue" ? "vue" : "react"; +} + +async function activeDocJson(registry: SessionRegistry): Promise { + const session = registry.activeSession(); + if (!session) { + void vscode.window.showWarningMessage("OpenPencil: open and focus a .op design first."); + return undefined; + } + const bytes = await session.backup(); + return new TextDecoder().decode(bytes); +} + +export async function generateCodeCommand(registry: SessionRegistry): Promise { + const docJson = await activeDocJson(registry); + if (docJson === undefined) return; + const prompt = buildCodegenPrompt(docJson, framework()); + + const api = lm(); + if (!api) { + // No language-model API: hand the prompt to the IDE's own chat. + await vscode.env.clipboard.writeText(prompt); + void vscode.window.showInformationMessage( + "OpenPencil: this IDE has no language-model API. The codegen prompt is on your clipboard — paste it into your AI chat.", + ); + return; + } + + const models = (await api.selectChatModels()) as Array<{ + sendRequest(messages: unknown[], options?: unknown, token?: unknown): Thenable<{ text: AsyncIterable }>; + }>; + if (models.length === 0) { + void vscode.window.showWarningMessage("OpenPencil: no language model available."); + return; + } + const model = models[0]; + + const text = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "OpenPencil: generating code…", cancellable: true }, + async (_progress, token) => { + const LanguageModelChatMessage = ( + vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } } + ).LanguageModelChatMessage; + const res = await model.sendRequest([LanguageModelChatMessage.User(prompt)], {}, token); + let acc = ""; + for await (const chunk of res.text) acc += chunk; + return acc; + }, + ); + + const parsed = parseCodegenOutput(text); + if (!parsed.ok) { + const doc = await vscode.workspace.openTextDocument({ content: text, language: "markdown" }); + await vscode.window.showTextDocument(doc); + void vscode.window.showWarningMessage( + `OpenPencil: could not parse generated files (${parsed.errors[0]}). Raw output opened for you.`, + ); + return; + } + + const dir = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + title: "Where should the generated files go?", + }); + if (!dir || dir.length === 0) return; + const outRoot = dir[0]; + + for (const file of parsed.files) { + const target = vscode.Uri.file(fsPath.join(outRoot.fsPath, file.path)); + if (await existsFs(target)) { + const overwrite = await vscode.window.showWarningMessage( + `${file.path} exists. Overwrite?`, + "Overwrite", + "Skip", + ); + if (overwrite !== "Overwrite") continue; + } + await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath))); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(file.content)); + } + void vscode.window.showInformationMessage(`OpenPencil: wrote ${parsed.files.length} file(s).`); +} + +/** Register @openpencil as a read-only design advisor when the chat + lm APIs + * exist. Returns a disposable, or undefined when the APIs are absent. */ +export function registerChatParticipant( + registry: SessionRegistry, +): vscode.Disposable | undefined { + const chat = (vscode as unknown as { chat?: { createChatParticipant?: unknown } }).chat; + if (!lm() || !chat || typeof chat.createChatParticipant !== "function") return undefined; + + type ChatHandler = ( + request: { prompt: string }, + _ctx: unknown, + response: { markdown(v: string): void }, + token: unknown, + ) => Promise; + const handler: ChatHandler = async (request, _ctx, response, token) => { + const session = registry.activeSession(); + if (!session) { + response.markdown("Open and focus a `.op` design first, then ask again."); + return; + } + const docJson = new TextDecoder().decode(await session.backup()); + const api = lm() as unknown as { + selectChatModels(): Thenable }> }>>; + }; + const models = await api.selectChatModels(); + if (models.length === 0) { + response.markdown("No language model is available."); + return; + } + const LanguageModelChatMessage = ( + vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } } + ).LanguageModelChatMessage; + const prompt = `You are a read-only advisor for this OpenPencil design. Answer the question; do not attempt to modify the design.\n\nDesign:\n${docJson}\n\nQuestion: ${request.prompt}`; + const res = await models[0].sendRequest([LanguageModelChatMessage.User(prompt)], {}, token); + for await (const chunk of res.text) response.markdown(chunk); + }; + + const create = chat.createChatParticipant as (id: string, h: ChatHandler) => vscode.Disposable; + return create("openpencil.assistant", handler); +} + +async function existsFs(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} diff --git a/packages/op-vscode/src/vscode/configure-command.ts b/packages/op-vscode/src/vscode/configure-command.ts new file mode 100644 index 000000000..96dff46de --- /dev/null +++ b/packages/op-vscode/src/vscode/configure-command.ts @@ -0,0 +1,173 @@ +// The "Configure AI (MCP)" and "Remove MCP" command shells. Drives the tested, +// vscode-free mcp-config adapters (detectIde/adapterFor/upsert/remove) with the +// diff-confirm UX, workspace-folder resolution, and reload prompt. + +import * as vscode from "vscode"; +import * as fsPath from "node:path"; +import { + adapterFor, + detectIde, + McpConfigParseError, + type McpAdapter, +} from "../mcp/mcp-config"; + +export function proxyUrl(port: number): string { + return `http://127.0.0.1:${port}/mcp`; +} + +function currentAdapter(root: string): McpAdapter { + const kind = detectIde({ + appName: vscode.env.appName, + hasDir: (rel) => existsSyncQuiet(vscode.Uri.file(fsPath.join(root, rel))), + }); + return adapterFor(kind); +} + +/** Resolve the workspace folder to write the config into: the active .op file's + * folder if any, else a single root, else a QuickPick. */ +async function pickWorkspaceRoot(): Promise { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 0) { + void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first."); + return undefined; + } + const active = vscode.window.activeTextEditor?.document.uri; + if (active) { + const f = vscode.workspace.getWorkspaceFolder(active); + if (f) return f.uri.fsPath; + } + if (folders.length === 1) return folders[0].uri.fsPath; + const pick = await vscode.window.showQuickPick( + folders.map((f) => ({ label: f.name, description: f.uri.fsPath })), + { title: "Configure OpenPencil MCP in which folder?" }, + ); + return pick?.description; +} + +export async function configureMcpCommand(port: number): Promise { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage("OpenPencil: MCP config needs a trusted workspace."); + return; + } + const root = await pickWorkspaceRoot(); + if (!root) return; + const adapter = currentAdapter(root); + const target = vscode.Uri.file(adapter.configPath(root)); + + const existing = await readTextOrNull(target); + let next: string; + try { + next = adapter.upsert(existing, proxyUrl(port)); + } catch (err) { + if (err instanceof McpConfigParseError) { + void vscode.window.showErrorMessage( + `OpenPencil: ${target.fsPath} is not valid JSON — please fix it by hand.`, + ); + return; + } + throw err; + } + if (next === existing) { + void vscode.window.showInformationMessage("OpenPencil MCP is already configured."); + return; + } + + const choice = await vscode.window.showInformationMessage( + `Configure OpenPencil MCP in ${adapter.kind} (${target.fsPath})?`, + "Show diff", + "Write", + ); + if (choice === "Show diff") { + await showDiff(target, existing ?? "", next); + const confirm = await vscode.window.showInformationMessage("Write this MCP config?", "Write"); + if (confirm !== "Write") return; + } else if (choice !== "Write") { + return; + } + + await writeConfig(target, next); + if (adapter.needsReload) { + void vscode.window.showInformationMessage( + "OpenPencil MCP written. Reload the window for the AI agent to pick it up.", + "Reload", + ).then((r) => { + if (r === "Reload") void vscode.commands.executeCommand("workbench.action.reloadWindow"); + }); + } else { + void vscode.window.showInformationMessage("OpenPencil MCP configured."); + } +} + +export async function removeMcpCommand(): Promise { + const root = await pickWorkspaceRoot(); + if (!root) return; + const adapter = currentAdapter(root); + const target = vscode.Uri.file(adapter.configPath(root)); + const existing = await readTextOrNull(target); + let next: string | null; + try { + next = adapter.remove(existing); + } catch (err) { + if (err instanceof McpConfigParseError) { + void vscode.window.showErrorMessage(`OpenPencil: ${target.fsPath} is not valid JSON.`); + return; + } + throw err; + } + if (next === null) { + void vscode.window.showInformationMessage("OpenPencil MCP entry not present."); + return; + } + const confirm = await vscode.window.showInformationMessage( + `Remove the OpenPencil MCP entry from ${target.fsPath}?`, + "Remove", + ); + if (confirm !== "Remove") return; + await writeConfig(target, next); + void vscode.window.showInformationMessage("OpenPencil MCP entry removed."); +} + +// ---- helpers ---- + +async function readTextOrNull(uri: vscode.Uri): Promise { + try { + return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + } catch { + return null; + } +} + +async function writeConfig(uri: vscode.Uri, text: string): Promise { + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(uri.fsPath))); + await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(text)); + } catch (err) { + void vscode.window.showErrorMessage(`OpenPencil: failed to write ${uri.fsPath}: ${String(err)}`); + throw err; + } +} + +async function showDiff(target: vscode.Uri, before: string, after: string): Promise { + const left = target.with({ scheme: "untitled", path: `${target.path}.current` }); + const right = target.with({ scheme: "untitled", path: `${target.path}.proposed` }); + await vscode.workspace.openTextDocument(left).then((d) => applyText(d, before)); + await vscode.workspace.openTextDocument(right).then((d) => applyText(d, after)); + await vscode.commands.executeCommand("vscode.diff", left, right, "OpenPencil MCP: current ↔ proposed"); +} + +async function applyText(doc: vscode.TextDocument, text: string): Promise { + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, new vscode.Position(0, 0), text); + await vscode.workspace.applyEdit(edit); +} + +function existsSyncQuiet(uri: vscode.Uri): boolean { + try { + // fs.stat is async; for the detect probe we accept a best-effort require. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodeFs = require("node:fs") as typeof import("node:fs"); + return nodeFs.existsSync(uri.fsPath); + } catch { + return false; + } +} diff --git a/packages/op-vscode/src/vscode/pen-editor-provider.ts b/packages/op-vscode/src/vscode/pen-editor-provider.ts new file mode 100644 index 000000000..6b7c65583 --- /dev/null +++ b/packages/op-vscode/src/vscode/pen-editor-provider.ts @@ -0,0 +1,420 @@ +// CustomEditorProvider for .op files. Wires the tested cores (DaemonPool, +// PenSession, SessionRegistry, webview shell) to the VS Code custom-editor +// lifecycle. This is the vscode-coupled glue layer — verified by the Task 12 +// integration smoke + manual matrix, not unit tests. The security-sensitive and +// protocol-critical logic all lives in the injected, unit-tested cores. + +import * as vscode from "vscode"; +import * as path from "node:path"; +import type { DaemonClient, DaemonLogger } from "../daemon/daemon-client"; +import { DaemonHttp } from "../daemon/daemon-http"; +import type { DaemonPool } from "../daemon/daemon-pool"; +import { PenSession, type SessionHost } from "../session/pen-session"; +import type { SessionRegistry } from "../session/session-registry"; +import type { BridgeOutboundToPage } from "../protocol/bridge"; +import { encodeOutbound } from "../protocol/bridge"; +import { buildBootHtml, buildWebviewHtml } from "./webview-shell"; +import { pickRestartSource, resolveDaemonBinary, type LatestDurable } from "./restart-source"; + +const SHELL_READY_TIMEOUT_MS = 5_000; +const DAEMON_READY_TIMEOUT_MS = 10_000; +const DAEMON_READY_POLL_MS = 200; +const WATCHER_DEBOUNCE_MS = 300; + +export class PenDocument implements vscode.CustomDocument { + session?: PenSession; + latestDurable: LatestDurable; + constructor( + readonly uri: vscode.Uri, + readonly bootJson: string, + initialSource: LatestDurable["source"], + ) { + this.latestDurable = { source: initialSource, json: bootJson }; + } + dispose(): void { + this.session?.dispose(); + this.session = undefined; + } +} + +export class PenEditorProvider implements vscode.CustomEditorProvider { + private readonly onDidChange = new vscode.EventEmitter< + vscode.CustomDocumentContentChangeEvent + >(); + readonly onDidChangeCustomDocument = this.onDidChange.event; + + // One live mount per file. A single pool.onRestart handler (registered once) + // looks up the mount and rebuilds it, disposing the previous mount's + // listeners/watcher first so nothing accumulates across restarts. + private readonly mounts = new Map(); + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly pool: DaemonPool, + private readonly registry: SessionRegistry, + private readonly logger: DaemonLogger, + ) { + this.pool.onRestart((filePath, client) => void this.handleRestart(filePath, client)); + } + + async openCustomDocument( + uri: vscode.Uri, + openContext: vscode.CustomDocumentOpenContext, + ): Promise { + if (openContext.backupId) { + // Hot-exit recovery: the backup bytes are authoritative and must survive + // a subsequent daemon restart, so mark the durable source as "backup". + const bytes = await vscode.workspace.fs.readFile(vscode.Uri.parse(openContext.backupId)); + return new PenDocument(uri, decode(bytes), "backup"); + } + const bytes = await vscode.workspace.fs.readFile(uri); + return new PenDocument(uri, decode(bytes), "disk"); + } + + async resolveCustomEditor(document: PenDocument, panel: vscode.WebviewPanel): Promise { + panel.webview.options = { enableScripts: true }; + const key = document.uri.fsPath; + const watcherState = { suppressUntil: 0 }; + + // Terminal teardown anchored to the PANEL (registered once, survives + // restarts): whenever the panel closes, tear down the live mount if any and + // ALWAYS release the daemon — the single guarantee that closing an editor + // (even mid-restart, even after a failed re-mount) never orphans a daemon. + panel.onDidDispose(() => { + const live = this.mounts.get(key); + if (live && live.panel === panel) { + this.disposeMount(live); + this.mounts.delete(key); + } + void this.pool.release(key); + }); + + try { + const shellOrigin = await this.bootShellAndGetOrigin(panel); + const client = await this.spawnAndAwaitReady(document.uri, shellOrigin); + await this.mountEditor(document, panel, client, watcherState); + } catch (err) { + this.logger.error(`resolveCustomEditor failed: ${String(err)}`); + panel.webview.html = errorPage(String(err)); + void this.pool.release(key); // failed initial mount → don't orphan + } + } + + /** Phase 1: install the message listener + timeout BEFORE assigning boot HTML + * (the shell script may run synchronously on html assignment). */ + private bootShellAndGetOrigin(panel: vscode.WebviewPanel): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + sub.dispose(); + reject(new Error("webview shell did not report ready")); + }, SHELL_READY_TIMEOUT_MS); + const sub = panel.webview.onDidReceiveMessage((raw: unknown) => { + const origin = parseShellReadyOrigin(raw); + if (origin === undefined || settled) return; + settled = true; + clearTimeout(timer); + sub.dispose(); + resolve(origin); + }); + panel.webview.html = buildBootHtml(makeNonce()); + }); + } + + private async spawnAndAwaitReady(uri: vscode.Uri, shellOrigin: string): Promise { + const client = await this.pool.acquire(uri.fsPath, shellOrigin); + const http = new DaemonHttp(client.baseUrl, client.handshake.token); + const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; + for (;;) { + if (await http.ready().catch(() => false)) return client; + if (Date.now() > deadline) throw new Error("daemon did not become ready"); + await delay(DAEMON_READY_POLL_MS); + } + } + + /** Phase 2: rebuild the full shell around the daemon iframe, wire the session + * and all lifecycle plumbing, and start the init loop. Every listener/watcher + * is collected into the mount's disposable set so a restart (or panel close) + * tears them all down with no accumulation. */ + private async mountEditor( + document: PenDocument, + panel: vscode.WebviewPanel, + client: DaemonClient, + watcherState: { suppressUntil: number }, + ): Promise { + const key = document.uri.fsPath; + const nonce = makeNonce(); + const external = await vscode.env.asExternalUri(vscode.Uri.parse(`${client.baseUrl}/`)); + panel.webview.html = buildWebviewHtml({ iframeSrc: external.toString(), nonce }); + + const host = this.makeHost(document, panel, watcherState); + const session = new PenSession(host, client.handshake.token, document.bootJson); + document.session = session; + this.registry.register(key, session); + + const disposables: vscode.Disposable[] = []; + disposables.push( + panel.webview.onDidReceiveMessage((raw: unknown) => { + if (isShellControl(raw)) return; + session.onPageMessage(raw); + }), + ); + + if (panel.active) this.setActive(key); + disposables.push( + panel.onDidChangeViewState((e) => { + if (e.webviewPanel.active) this.setActive(key); + else if (this.pool.active?.filePath === key) this.setActive(undefined); + }), + ); + + // File watcher (GlobPattern required — Uri is not accepted). + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(path.dirname(key), path.basename(key)), + ); + const onExternal = async () => { + if (Date.now() < watcherState.suppressUntil) return; // our own write + try { + const bytes = await vscode.workspace.fs.readFile(document.uri); + await session.externalFileChanged(decode(bytes), session.isRustDirty); + } catch (err) { + this.logger.error(`external change handling failed: ${String(err)}`); + } + }; + disposables.push(watcher, watcher.onDidChange(onExternal), watcher.onDidCreate(onExternal)); + + // Terminal teardown (daemon release + mount disposal on panel close) is + // anchored at the PANEL scope in resolveCustomEditor — registered once so it + // survives restarts. Here we only record the live mount. + const ctx: MountContext = { document, panel, watcherState, session, disposables }; + this.mounts.set(key, ctx); + + session.start(); + } + + private disposeMount(ctx: MountContext): void { + for (const d of ctx.disposables) { + try { + d.dispose(); + } catch { + /* best-effort */ + } + } + ctx.disposables.length = 0; + this.registry.unregister(ctx.document.uri.fsPath); + ctx.session.dispose(); + } + + /** Single pool.onRestart handler: rebuild the live mount for this file with + * the correct recovery bytes, disposing the old mount's plumbing first. */ + private async handleRestart(filePath: string, client: DaemonClient | undefined): Promise { + const ctx = this.mounts.get(filePath); + if (!ctx) return; // no live editor for this file + const { document, panel } = ctx; + const wasDirty = ctx.session.isRustDirty; + this.disposeMount(ctx); + this.mounts.delete(filePath); + + // Release the daemon on EVERY non-success path (repeated crash, or a failed + // re-mount) so a broken restart can't leave a live daemon with no owning + // mount. On success the fresh mount owns it (released at panel close). The + // panel-scope onDidDispose in resolveCustomEditor is the final backstop; + // pool.release is idempotent so a later close is a harmless no-op. + let remounted = false; + try { + if (!client) { + panel.webview.html = errorPage("The design daemon crashed repeatedly."); + return; + } + const source = pickRestartSource(document.latestDurable, wasDirty); + const bootJson = + source.from === "durable" + ? source.json + : decode(await vscode.workspace.fs.readFile(document.uri)); + (document as { bootJson: string }).bootJson = bootJson; + const shellOrigin = await this.bootShellAndGetOrigin(panel); + const fresh = await this.spawnAndAwaitReady(document.uri, shellOrigin); + await this.mountEditor(document, panel, fresh, { suppressUntil: 0 }); + remounted = true; + } catch (err) { + panel.webview.html = errorPage(`restart failed: ${String(err)}`); + } finally { + if (!remounted) void this.pool.release(filePath); + } + } + + private makeHost( + document: PenDocument, + panel: vscode.WebviewPanel, + watcherState: { suppressUntil: number }, + ): SessionHost { + const post = (msg: BridgeOutboundToPage) => { + void panel.webview.postMessage(encodeOutbound(msg)); + }; + return { + postToPage: post, + contentChanged: () => this.onDidChange.fire({ document }), + writeFile: async (bytes) => { + watcherState.suppressUntil = Date.now() + WATCHER_DEBOUNCE_MS; + await atomicWrite(document.uri, bytes); + document.latestDurable = { source: "save", json: decode(bytes) }; + }, + writeBackup: async (name, bytes) => { + const uri = this.backupUri(name); + await vscode.workspace.fs.writeFile(uri, bytes); + }, + writeBackupFallback: async (bytes) => { + const target = await vscode.window.showSaveDialog({ + title: "Save your OpenPencil version", + saveLabel: "Save copy", + }); + if (!target) throw new Error("backup save cancelled — bytes not persisted"); + await vscode.workspace.fs.writeFile(target, bytes); + return target.fsPath; + }, + showConflictDialog: async (serverVersion) => { + const pick = await vscode.window.showWarningMessage( + `This design changed elsewhere (v${serverVersion}) while you were editing. Keep which version?`, + { modal: true }, + "Keep my version", + "Take the other version", + ); + if (pick === "Keep my version") return "use-local"; + if (pick === "Take the other version") return "accept-remote"; + return undefined; + }, + showExternalChangeDialog: async () => { + const pick = await vscode.window.showWarningMessage( + "The file changed on disk while you had unsaved edits.", + { modal: true }, + "Reload from disk (discard mine)", + "Keep my version", + "Save disk copy elsewhere", + ); + if (pick === "Reload from disk (discard mine)") return "reload"; + if (pick === "Save disk copy elsewhere") return "save-disk-copy"; + if (pick === "Keep my version") return "keep-local"; + return undefined; + }, + schedule: (fn, ms) => { + const t = setTimeout(fn, ms); + return () => clearTimeout(t); + }, + warn: (message) => { + this.logger.error(message); + void vscode.window.showWarningMessage(`OpenPencil: ${message}`); + }, + }; + } + + // ---- CustomEditorProvider save/backup/revert ---- + + saveCustomDocument(document: PenDocument, cancellation: vscode.CancellationToken): Thenable { + return this.requireSession(document).save(() => cancellation.isCancellationRequested); + } + + async saveCustomDocumentAs( + document: PenDocument, + destination: vscode.Uri, + cancellation: vscode.CancellationToken, + ): Promise { + const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested); + await atomicWrite(destination, bytes); + } + + async revertCustomDocument(document: PenDocument): Promise { + const bytes = await vscode.workspace.fs.readFile(document.uri); + const json = decode(bytes); + await this.requireSession(document).revert(json); + document.latestDurable = { source: "revert", json }; + } + + async backupCustomDocument( + document: PenDocument, + context: vscode.CustomDocumentBackupContext, + cancellation: vscode.CancellationToken, + ): Promise { + const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested); + await vscode.workspace.fs.writeFile(context.destination, bytes); + document.latestDurable = { source: "backup", json: decode(bytes) }; + return { + id: context.destination.toString(), + delete: () => void vscode.workspace.fs.delete(context.destination).then(undefined, () => {}), + }; + } + + // ---- helpers ---- + + private requireSession(document: PenDocument): PenSession { + if (!document.session) throw new Error("editor not ready"); + return document.session; + } + private setActive(filePath: string | undefined): void { + this.pool.setActive(filePath); + this.registry.setActive(filePath); + } + private backupUri(name: string): vscode.Uri { + const dir = this.context.storageUri ?? this.context.globalStorageUri; + return vscode.Uri.joinPath(dir, name); + } +} + +interface MountContext { + document: PenDocument; + panel: vscode.WebviewPanel; + watcherState: { suppressUntil: number }; + session: PenSession; + disposables: vscode.Disposable[]; +} + +// ---- module helpers ---- + +const decoder = new TextDecoder(); +function decode(bytes: Uint8Array): string { + return decoder.decode(bytes); +} + +/** Atomic write: temp file + rename, so a crash mid-write can't truncate. */ +async function atomicWrite(uri: vscode.Uri, bytes: Uint8Array): Promise { + const tmp = uri.with({ path: `${uri.path}.op-tmp` }); + await vscode.workspace.fs.writeFile(tmp, bytes); + await vscode.workspace.fs.rename(tmp, uri, { overwrite: true }); +} + +function makeNonce(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Parse an op-shell/ready control message → its reported origin, else undefined. */ +function parseShellReadyOrigin(raw: unknown): string | undefined { + if (typeof raw !== "string") return undefined; + try { + const v = JSON.parse(raw) as { type?: unknown; origin?: unknown }; + if (v.type === "op-shell/ready" && typeof v.origin === "string") return v.origin; + } catch { + /* not a control message */ + } + return undefined; +} + +function isShellControl(raw: unknown): boolean { + return typeof raw === "string" && raw.includes("op-shell/"); +} + +function errorPage(detail: string): string { + const safe = detail.replace(/[<&]/g, (c) => (c === "<" ? "<" : "&")); + return ` +

OpenPencil could not open this design

${safe}

+

Check that op-host-web-server is built (cargo build -p op-host-web-server) or set openpencil.dev.daemonPath.

+`; +} + +export { resolveDaemonBinary }; diff --git a/packages/op-vscode/src/vscode/restart-source.test.ts b/packages/op-vscode/src/vscode/restart-source.test.ts new file mode 100644 index 000000000..980dde19a --- /dev/null +++ b/packages/op-vscode/src/vscode/restart-source.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "bun:test"; +import { pickRestartSource, resolveDaemonBinary } from "./restart-source"; + +test("dirty editor reopens durable bytes", () => { + const r = pickRestartSource({ source: "save", json: '{"d":1}' }, true); + expect(r).toEqual({ from: "durable", json: '{"d":1}' }); +}); + +test("clean editor after save reopens from disk", () => { + const r = pickRestartSource({ source: "save", json: "{}" }, false); + expect(r).toEqual({ from: "disk" }); +}); + +test("clean editor after revert reopens from disk", () => { + const r = pickRestartSource({ source: "revert", json: "{}" }, false); + expect(r).toEqual({ from: "disk" }); +}); + +test("hot-exit backup (clean, source=backup) reopens durable, not disk", () => { + // The recovered content differs from disk even though Rust is clean — reading + // disk here would drop the recovery. + const r = pickRestartSource({ source: "backup", json: '{"recovered":1}' }, false); + expect(r).toEqual({ from: "durable", json: '{"recovered":1}' }); +}); + +test("resolveDaemonBinary: explicit setting wins", () => { + expect(resolveDaemonBinary("/opt/op-host-web-server", "/ws")).toBe("/opt/op-host-web-server"); + expect(resolveDaemonBinary(" /opt/x ", "/ws")).toBe("/opt/x"); +}); + +test("resolveDaemonBinary: falls back to workspace target/debug", () => { + expect(resolveDaemonBinary("", "/ws")).toBe("/ws/target/debug/op-host-web-server"); + expect(resolveDaemonBinary(undefined, "/ws")).toBe("/ws/target/debug/op-host-web-server"); +}); + +test("resolveDaemonBinary: null when nothing available", () => { + expect(resolveDaemonBinary(undefined, undefined)).toBeNull(); + expect(resolveDaemonBinary("", undefined)).toBeNull(); +}); diff --git a/packages/op-vscode/src/vscode/restart-source.ts b/packages/op-vscode/src/vscode/restart-source.ts new file mode 100644 index 000000000..afcfafa33 --- /dev/null +++ b/packages/op-vscode/src/vscode/restart-source.ts @@ -0,0 +1,37 @@ +// Pure helpers for the provider's daemon-crash recovery and binary resolution. +// Kept vscode-free so the recovery decisions (which bytes to reopen after a +// crash; where the daemon binary lives) are unit-testable. + +export type DurableSource = "disk" | "save" | "revert" | "backup"; +export interface LatestDurable { + source: DurableSource; + json: string; +} + +/** After a daemon crash, decide which bytes to reopen with. If the editor was + * dirty, OR the durable state came from a hot-exit backup (Rust side is clean + * but its content differs from disk), reopen the durable bytes — reading disk + * would silently drop the recovered content. Otherwise the on-disk file is the + * source of truth. */ +export function pickRestartSource( + latest: LatestDurable, + isDirty: boolean, +): { from: "durable"; json: string } | { from: "disk" } { + if (isDirty || latest.source === "backup") { + return { from: "durable", json: latest.json }; + } + return { from: "disk" }; +} + +/** Resolve the op-host-web-server command prefix: an explicit setting wins, + * otherwise /target/debug/op-host-web-server. Returns null when + * neither is available (the caller shows a guidance page). */ +export function resolveDaemonBinary( + configuredPath: string | undefined, + workspaceRoot: string | undefined, +): string | null { + const trimmed = configuredPath?.trim(); + if (trimmed) return trimmed; + if (workspaceRoot) return `${workspaceRoot}/target/debug/op-host-web-server`; + return null; +} diff --git a/packages/op-vscode/src/vscode/skill-command.ts b/packages/op-vscode/src/vscode/skill-command.ts new file mode 100644 index 000000000..7326ff9f8 --- /dev/null +++ b/packages/op-vscode/src/vscode/skill-command.ts @@ -0,0 +1,106 @@ +// "Install AI Skill" / "Remove AI Skill" command shells. The skill markdown is +// extracted at build time into dist/assets/openpencil-skill.md (see build.mjs); +// this writes it into the current IDE's rules path. vscode uses MCP directly, so +// it needs no rules file. + +import * as vscode from "vscode"; +import * as fsPath from "node:path"; +import { adapterFor, detectIde, type IdeKind } from "../mcp/mcp-config"; + +/** Rules file path per IDE (relative to the workspace root). null = the IDE + * drives OpenPencil purely through MCP and needs no rules file. */ +function rulesRelPath(kind: IdeKind): string | null { + switch (kind) { + case "cursor": + return ".cursor/rules/openpencil.mdc"; + case "trae": + return ".trae/rules/openpencil.md"; + case "windsurf": + return ".windsurf/rules/openpencil.md"; + case "vscode": + return null; + } +} + +function detectKind(root: string): IdeKind { + return detectIde({ + appName: vscode.env.appName, + hasDir: (rel) => existsQuiet(fsPath.join(root, rel)), + }).valueOf() as IdeKind; +} + +async function firstRoot(): Promise { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +} + +export async function installSkillCommand(context: vscode.ExtensionContext): Promise { + const root = await firstRoot(); + if (!root) { + void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first."); + return; + } + const kind = detectKind(root); + // adapterFor is used only to assert the kind resolves to a known adapter. + adapterFor(kind); + const rel = rulesRelPath(kind); + if (rel === null) { + void vscode.window.showInformationMessage( + "On VS Code, OpenPencil works through MCP — no rules file needed. Run 'Configure AI (MCP)'.", + ); + return; + } + const skill = await readSkillAsset(context); + if (skill === null) { + void vscode.window.showErrorMessage("OpenPencil: bundled skill asset missing from the extension."); + return; + } + const target = vscode.Uri.file(fsPath.join(root, rel)); + const confirm = await vscode.window.showInformationMessage( + `Install the OpenPencil design skill for ${kind} at ${rel}?`, + "Install", + ); + if (confirm !== "Install") return; + await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath))); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(skill)); + void vscode.window.showInformationMessage(`OpenPencil skill installed at ${rel}.`); +} + +export async function removeSkillCommand(): Promise { + const root = await firstRoot(); + if (!root) return; + const rel = rulesRelPath(detectKind(root)); + if (rel === null) { + void vscode.window.showInformationMessage("Nothing to remove on VS Code."); + return; + } + const target = vscode.Uri.file(fsPath.join(root, rel)); + const confirm = await vscode.window.showInformationMessage( + `Remove the OpenPencil skill file at ${rel}?`, + "Remove", + ); + if (confirm !== "Remove") return; + try { + await vscode.workspace.fs.delete(target); + void vscode.window.showInformationMessage("OpenPencil skill removed."); + } catch { + void vscode.window.showInformationMessage("OpenPencil skill file was not present."); + } +} + +async function readSkillAsset(context: vscode.ExtensionContext): Promise { + const uri = vscode.Uri.joinPath(context.extensionUri, "dist", "assets", "openpencil-skill.md"); + try { + return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + } catch { + return null; + } +} + +function existsQuiet(p: string): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return (require("node:fs") as typeof import("node:fs")).existsSync(p); + } catch { + return false; + } +} diff --git a/packages/op-vscode/test/integration/daemon-contract.test.ts b/packages/op-vscode/test/integration/daemon-contract.test.ts new file mode 100644 index 000000000..11d523ebd --- /dev/null +++ b/packages/op-vscode/test/integration/daemon-contract.test.ts @@ -0,0 +1,104 @@ +// Cross-language integration smoke: drives the REAL op-host-web-server binary +// through the extension's own DaemonClient → DaemonHttp → McpProxy stack. It +// does not load VS Code — it exercises the daemon contract end to end. +// +// Requires `cargo build -p op-host-web-server`. Binary missing → the test +// FAILS (never skips) so a broken build is visible. + +import { test, expect, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DaemonClient, type DaemonLogger } from "../../src/daemon/daemon-client"; +import { DaemonHttp } from "../../src/daemon/daemon-http"; +import { McpProxy } from "../../src/mcp/mcp-proxy"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", "..", "..", ".."); // packages/op-vscode/test/integration → repo root +const BINARY = join(repoRoot, "target", "debug", "op-host-web-server"); +const BUNDLE_DIR = join(repoRoot, "crates", "op-host-web", "pkg"); + +const silent: DaemonLogger = { info: () => {}, error: () => {} }; +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + for (const c of cleanups.splice(0)) await c(); +}); + +test("real daemon: handshake → version → mcp-via-proxy → auth/origin defenses → clean exit", async () => { + expect( + existsSync(BINARY), + `op-host-web-server not built — run: cargo build -p op-host-web-server`, + ).toBe(true); + + // A minimal .op file to open. + const fixture = join( + repoRoot, + "vendor", + "jian", + "crates", + "jian-ops-schema", + "tests", + "corpus", + "nested-frame.op", + ); + const client = await DaemonClient.spawn({ + command: [BINARY], + filePath: existsSync(fixture) ? fixture : undefined, + allowOrigin: "vscode-webview://smoke", + logger: silent, + }); + cleanups.push(() => client.dispose()); + + // Handshake produced a usable base URL + token. + expect(client.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(client.handshake.token.length).toBeGreaterThan(0); + + const http = new DaemonHttp(client.baseUrl, client.handshake.token); + const version = await http.version(); + expect(typeof version).toBe("number"); + expect(version).toBeGreaterThanOrEqual(0); + + // ready(): true only when the wasm bundle is present. Report honestly. + const ready = await http.ready(); + if (existsSync(BUNDLE_DIR)) { + expect(ready).toBe(true); + } else { + expect(ready).toBe(false); + console.log("note: op-host-web/pkg bundle absent (needs EMSDK); ready()=false as expected"); + } + + // No-token direct hit on a privileged endpoint → 401. + const unauth = await fetch(`${client.baseUrl}/api/mcp/version`); + expect(unauth.status).toBe(401); + + // MCP initialize through the proxy → a JSON-RPC result. + const proxy = new McpProxy( + { active: { filePath: "x", client }, onActiveChanged: () => {} }, + silent, + ); + const port = await proxy.listen(0); + cleanups.push(() => proxy.dispose()); + const proxyUrl = `http://127.0.0.1:${port}/mcp`; + + const init = await fetch(proxyUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + }); + expect(init.status).toBe(200); + const initBody = (await init.json()) as { result?: unknown }; + expect(initBody.result).toBeDefined(); + + // A browser-style Origin header on the proxy → 403 (DNS-rebind defense). + const withOrigin = await fetch(proxyUrl, { + method: "POST", + headers: { "content-type": "application/json", origin: "http://evil.example" }, + body: '{"jsonrpc":"2.0","id":2,"method":"initialize"}', + }); + expect(withOrigin.status).toBe(403); + + // dispose() closes stdin → the daemon self-exits. + await client.dispose(); + expect(client.alive).toBe(false); +});