From 5eab615279579275968aaa5b043a7d5c908dc27d Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 31 Jul 2026 17:36:00 +0530 Subject: [PATCH] feat(server): accept prompt input, shell out, open the editor The three things the overlay cannot do from inside a page: normalise prompt input before it reaches an agent, run a command, and open a file in the user's editor at the line they picked. --- packages/server/src/open-editor.ts | 170 ++++++++++++ packages/server/src/prompt-input.test.ts | 312 +++++++++++++++++++++++ packages/server/src/prompt-input.ts | 103 ++++++++ packages/server/src/shell.ts | 94 +++++++ 4 files changed, 679 insertions(+) create mode 100644 packages/server/src/open-editor.ts create mode 100644 packages/server/src/prompt-input.test.ts create mode 100644 packages/server/src/prompt-input.ts create mode 100644 packages/server/src/shell.ts diff --git a/packages/server/src/open-editor.ts b/packages/server/src/open-editor.ts new file mode 100644 index 0000000..091c9e9 --- /dev/null +++ b/packages/server/src/open-editor.ts @@ -0,0 +1,170 @@ +/** + * Open a project file in the user's editor. + * + * Two paths, because neither works alone. The CLI launchers (`code -g`) are the + * reliable ones *when they exist* — but `code` is only on `PATH` if the user + * ever ran VS Code's "Install 'code' command in PATH", which a fresh machine + * has not. The URL schemes always work if the app is installed but cannot carry + * a column and are awkward to detect. So: try the binary, fall back to the + * scheme. + */ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { platform } from "node:os"; +import { resolve, sep } from "node:path"; +import type { Editor } from "@airship/protocol"; + +export interface OpenRequest { + column?: number; + editor?: Editor; + /** Repo-relative. Resolved against `cwd` and required to stay inside it. */ + file: string; + line?: number; +} + +export interface OpenResult { + editor?: string; + error?: string; + ok: boolean; +} + +interface Launcher { + /** Argv for the CLI form, given an absolute `file:line:col` target. */ + args: (target: string) => string[]; + bin: string; + /** URL scheme host, e.g. `vscode` in `vscode://file/...`. */ + scheme: string; +} + +const LAUNCHERS: Record = { + cursor: { args: (t) => ["-g", t], bin: "cursor", scheme: "cursor" }, + vscode: { args: (t) => ["-g", t], bin: "code", scheme: "vscode" }, + windsurf: { args: (t) => ["-g", t], bin: "windsurf", scheme: "windsurf" }, + // Zed takes the position inline with no flag. + zed: { args: (t) => [t], bin: "zed", scheme: "zed" }, +}; + +/** Probe order when nothing is configured. */ +const PREFERENCE: Editor[] = ["vscode", "cursor", "windsurf", "zed"]; + +/** Vite serves out-of-root files under `/@fs/`. */ +const VITE_FS_PREFIX = /^\/@fs(\/.*)$/; +const LEADING_SLASHES = /^\/+/; + +export function openInEditor(cwd: string, req: OpenRequest): OpenResult { + const root = resolve(cwd); + const abs = resolve(root, projectPath(req.file)); + // This socket is unauthenticated and local, and this handler spawns + // processes — without the containment check any page the browser loads could + // ask the daemon to open arbitrary files on disk. + if (abs !== root && !abs.startsWith(root + sep)) { + return { error: "path is outside the project", ok: false }; + } + if (!existsSync(abs)) { + return { error: `no such file: ${req.file}`, ok: false }; + } + + const line = req.line ?? 1; + const column = req.column ?? 1; + const target = `${abs}:${line}:${column}`; + + for (const editor of candidates(req.editor)) { + const launcher = LAUNCHERS[editor]; + if (!onPath(launcher.bin)) { + continue; + } + try { + // Detached and unref'd: the editor outlives the daemon, and inheriting + // stdio would wire its output into ours. Never `shell: true` — argv + // arrays mean a path with a space or a quote is just a path. + spawn(launcher.bin, launcher.args(target), { + detached: true, + stdio: "ignore", + }).unref(); + return { editor, ok: true }; + } catch { + // Fall through to the next candidate, then to the URL scheme. + } + } + + const wanted = req.editor ?? PREFERENCE[0]; + if (openUrl(`${LAUNCHERS[wanted].scheme}://file/${abs}:${line}:${column}`)) { + return { editor: wanted, ok: true }; + } + return { + error: `could not launch ${wanted} — is it installed?`, + ok: false, + }; +} + +/** + * Normalize a path that may have come from the *browser* rather than from a + * diff. Source locations are resolved from framework metadata, and what React + * and Vite hand back is a dev-server URL path — `/src/App.tsx`, or `/@fs/` + + * an absolute path for files outside the Vite root. Both are absolute as far + * as `resolve` is concerned, so without this the first one silently escapes + * `cwd` and gets rejected as "outside the project". + * + * Paths that are genuinely absolute and genuinely inside the project still + * work: they survive this untouched and the containment check below passes. + */ +function projectPath(file: string): string { + const fs = file.match(VITE_FS_PREFIX); + if (fs?.[1]) { + return fs[1]; + } + // A real absolute path exists on disk; a URL path like `/src/App.tsx` does + // not, and is meant to be read relative to the project root. + if (file.startsWith("/") && !existsSync(file)) { + return file.replace(LEADING_SLASHES, ""); + } + return file; +} + +function candidates(explicit?: Editor): Editor[] { + if (explicit) { + return [explicit]; + } + const configured = process.env.AIRSHIP_EDITOR as Editor | undefined; + if (configured && configured in LAUNCHERS) { + return [configured, ...PREFERENCE.filter((e) => e !== configured)]; + } + return PREFERENCE; +} + +/** Cached: a `which` per menu click is pointless, and PATH does not move. */ +const pathCache = new Map(); + +function onPath(bin: string): boolean { + const hit = pathCache.get(bin); + if (hit !== undefined) { + return hit; + } + const probe = platform() === "win32" ? "where" : "which"; + let found = false; + try { + found = spawnSync(probe, [bin], { stdio: "ignore" }).status === 0; + } catch { + found = false; + } + pathCache.set(bin, found); + return found; +} + +function openUrl(url: string): boolean { + const os = platform(); + let bin = "xdg-open"; + let args = [url]; + if (os === "darwin") { + bin = "open"; + } else if (os === "win32") { + bin = "cmd"; + args = ["/c", "start", "", url]; + } + try { + spawn(bin, args, { detached: true, stdio: "ignore" }).unref(); + return true; + } catch { + return false; + } +} diff --git a/packages/server/src/prompt-input.test.ts b/packages/server/src/prompt-input.test.ts new file mode 100644 index 0000000..5ed90a5 --- /dev/null +++ b/packages/server/src/prompt-input.test.ts @@ -0,0 +1,312 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildEditPrompt } from "@airship/core"; +import type { CreateJobRequest, ElementContext } from "@airship/protocol"; +import { invalidateTokenCache } from "@airship/source/tokens"; +import { describe, expect, it } from "vitest"; +import { preparePromptInput } from "./prompt-input"; + +/** Build a throwaway project tree and return its root. */ +function fixture(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "airship-prompt-")); + for (const [path, contents] of Object.entries(files)) { + const full = join(root, path); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, contents); + } + invalidateTokenCache(); + return root; +} + +const APP_TSX = `export function App() { + return ( +
+ + New +
+ ); +} +`; + +const TOKENS_CSS = `:root { + --pk-space-sm: 8px; + --pk-space-md: 16px; + --pk-radius-lg: 12px; +} +`; + +/** A project with one component and one stylesheet, rooted for the scanner. */ +function project(): string { + return fixture({ + ".git": "", + "src/App.tsx": APP_TSX, + "src/tokens.css": TOKENS_CSS, + }); +} + +function element(overrides: Partial = {}): ElementContext { + return { + classes: ["btn", "btn-primary"], + displayName: "Button", + selector: ".btn.btn-primary", + tagName: "button", + textPreview: "Get Started", + ...overrides, + }; +} + +/** The location the browser reports: a real file and line, no context yet. */ +const BUTTON_AT = { file: "src/App.tsx", line: 4 }; + +function request(overrides: Partial = {}): CreateJobRequest { + return { prompt: "", ...overrides }; +} + +describe("preparePromptInput — source backfill", () => { + it("attaches surrounding code to a style target", () => { + const cwd = project(); + const input = preparePromptInput( + cwd, + request({ + visualChanges: [ + { + changes: [{ from: "12px", property: "padding", to: "16px" }], + element: element(), + source: BUTTON_AT, + }, + ], + }) + ); + expect(input.visualChanges?.[0].source?.context).toContain( + 'className="btn btn-primary"' + ); + }); + + it("resolves all three locations on a move, not just the element", () => { + // The two easiest to forget: an agent told to relocate JSX needs the new + // parent and the anchor sibling as much as it needs the element itself. + const cwd = project(); + const input = preparePromptInput( + cwd, + request({ + moveChanges: [ + { + before: element({ + classes: ["tag"], + displayName: "Tag", + selector: ".tag", + tagName: "span", + textPreview: "New", + }), + beforeSource: { file: "src/App.tsx", line: 5 }, + element: element(), + newParent: element({ + classes: [], + displayName: undefined, + selector: "main", + tagName: "main", + textPreview: "", + }), + newParentSource: { file: "src/App.tsx", line: 3 }, + source: BUTTON_AT, + }, + ], + }) + ); + const move = input.moveChanges?.[0]; + expect(move?.source?.context).toContain("Get Started"); + expect(move?.newParentSource?.context).toContain("
"); + expect(move?.beforeSource?.context).toContain('className="tag"'); + }); + + it("backfills structural, text and attribute targets too", () => { + const cwd = project(); + const input = preparePromptInput( + cwd, + request({ + attrChanges: [ + { + changes: [{ attribute: "disabled", from: null, to: "true" }], + element: element(), + source: BUTTON_AT, + }, + ], + structuralChanges: [ + { element: element(), op: "duplicate", source: BUTTON_AT }, + ], + textChanges: [ + { + element: element(), + from: "Get Started", + source: BUTTON_AT, + to: "Start now", + }, + ], + }) + ); + expect(input.structuralChanges?.[0].source?.context).toContain( + "Get Started" + ); + expect(input.textChanges?.[0].source?.context).toContain("Get Started"); + expect(input.attrChanges?.[0].source?.context).toContain("Get Started"); + }); + + it("normalizes a root-relative path the dev server reported", () => { + // Vite reports `/src/App.tsx`; `resolve(cwd, …)` would read that as + // absolute and hand the agent a path outside the project. + const cwd = project(); + const input = preparePromptInput( + cwd, + request({ + element: element(), + source: { file: "/src/App.tsx", line: 4 }, + }) + ); + expect(input.source?.file).toBe(join("src", "App.tsx")); + expect(input.source?.context).toContain("Get Started"); + }); +}); + +describe("preparePromptInput — the primary element", () => { + it("prefers the explicit selection", () => { + const cwd = project(); + const input = preparePromptInput( + cwd, + request({ + element: element({ displayName: "Selected" }), + source: BUTTON_AT, + textChanges: [ + { + element: element({ displayName: "Retyped" }), + from: "a", + source: BUTTON_AT, + to: "b", + }, + ], + }) + ); + expect(input.element?.displayName).toBe("Selected"); + }); + + it("falls back down the delta chain when nothing is selected", () => { + const cwd = project(); + const fallback = (overrides: Partial) => + preparePromptInput(cwd, request(overrides)).element?.displayName; + + expect( + fallback({ + moveChanges: [ + { + before: null, + beforeSource: null, + element: element({ displayName: "Moved" }), + newParent: null, + newParentSource: null, + source: BUTTON_AT, + }, + ], + }) + ).toBe("Moved"); + expect( + fallback({ + structuralChanges: [ + { + element: element({ displayName: "Deleted" }), + op: "delete", + source: BUTTON_AT, + }, + ], + }) + ).toBe("Deleted"); + expect( + fallback({ + attrChanges: [ + { + changes: [{ attribute: "alt", from: null, to: "x" }], + element: element({ displayName: "Retagged" }), + source: BUTTON_AT, + }, + ], + }) + ).toBe("Retagged"); + }); +}); + +describe("preparePromptInput — comments and tokens", () => { + it("passes comments through untouched", () => { + // A comment already carries a repo-relative path and a real line; the + // element resolver has nothing to add and would only corrupt it. + const cwd = project(); + const comments = [ + { + body: "tighten this", + file: "src/App.tsx", + fromLine: 4, + jobId: "job-1", + snippet: "