diff --git a/apps/desktop/src/bun/rpc/deploy-vercel.ts b/apps/desktop/src/bun/rpc/deploy-vercel.ts new file mode 100644 index 00000000..ac20b22f --- /dev/null +++ b/apps/desktop/src/bun/rpc/deploy-vercel.ts @@ -0,0 +1,74 @@ +import { stat } from "node:fs/promises"; + +import type { RuntimeClient, RuntimeId } from "@llm-space/runtime/runtime"; + +import { VercelDeployError, deploySingleFile, deployStaticFolder } from "../vercel"; + +export interface DeployVercelInput { + runtimeId?: RuntimeId; + /** + * Workspace-relative deploy target: a folder of static files (must contain + * an `index.html`) or one self-contained HTML file (served as the site's + * index page). + */ + path: string; +} + +export type DeployVercelResult = + | { ok: true; url: string; fileCount: number } + | { ok: false; error: string }; + +interface DeployVercelHandlerDependencies { + getRuntime: (runtimeId: RuntimeId) => RuntimeClient; + getToken: () => string | null; +} + +/** + * Create the Bun RPC handler that deploys a workspace folder of static files + * to Vercel. The token is read inside the bun process; the renderer receives + * only the resulting URL or a friendly error. + */ +export function createDeployVercelHandler({ + getRuntime, + getToken, +}: DeployVercelHandlerDependencies) { + return async ({ + runtimeId, + path, + }: DeployVercelInput): Promise => { + const token = getToken(); + if (!token) { + return { + ok: false, + error: "Vercel token is not configured. Add one in Settings → Account.", + }; + } + try { + if (!runtimeId) { + throw new VercelDeployError("No runtime is available for this deploy."); + } + const runtime = getRuntime(runtimeId); + if (runtime.info().status !== "connected") { + throw new VercelDeployError(`Runtime is not connected: ${runtimeId}`); + } + const resolved = await runtime.fsRealpath(path); + // A single HTML file deploys as a self-contained page; anything else + // deploys as a folder. + const isFile = (await stat(resolved)).isFile(); + const result = isFile + ? await deploySingleFile({ token, file: resolved }) + : await deployStaticFolder({ token, dir: resolved }); + return { + ok: true, + url: result.url, + fileCount: result.fileCount, + }; + } catch (error) { + return { + ok: false, + error: + error instanceof Error ? error.message : "Vercel deployment failed.", + }; + } + }; +} diff --git a/apps/desktop/src/bun/rpc/index.ts b/apps/desktop/src/bun/rpc/index.ts index 3e8875b6..7c3bbad4 100644 --- a/apps/desktop/src/bun/rpc/index.ts +++ b/apps/desktop/src/bun/rpc/index.ts @@ -31,12 +31,15 @@ import type { RemoteServerManager } from "../remote"; import type { RuntimeRouter } from "../runtime"; import type { SkillsManager } from "../skills"; import type { UpdaterService } from "../updates"; +import { VercelTokenStore } from "../vercel"; +import { createDeployVercelHandler } from "./deploy-vercel"; import { ensureRootDir } from "./ensure-root-dir"; import { fsReveal } from "./fs-reveal"; import { createPromptFileRpcHandlers } from "./prompt-files"; import { createShareThreadHandler } from "./share-thread"; import { forwardStreamThread } from "./stream-thread-request"; +import { createVercelPreflightHandler } from "./vercel-preflight"; /** * The stream handler references its RPC instance inside the initializer, so an @@ -91,6 +94,7 @@ export function createMainWindowRPC({ }: MainWindowRPCDependencies): MainWindowRPC { const getRuntime = runtimeRouter.get.bind(runtimeRouter); const promptFileRequests = createPromptFileRpcHandlers(getRuntime); + const vercelTokens = new VercelTokenStore(); const rpc: MainWindowRPC = BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { @@ -310,6 +314,25 @@ export function createMainWindowRPC({ Promise.resolve(memoryStore.updateMemory({ id, content, tags })), memoryExport: () => Promise.resolve(memoryStore.exportMemories()), memoryClearArchive: () => Promise.resolve(memoryStore.clearArchive()), + // Vercel static deployments: the token is read from + // `settings/vercel.json` inside the bun process; the renderer only sees + // the configured flag and the deploy outcome (URL or friendly error). + getVercelStatus: () => + Promise.resolve({ configured: vercelTokens.isConfigured() }), + setVercelToken: ({ token }) => { + vercelTokens.set(token); + return Promise.resolve(null); + }, + removeVercelToken: () => { + vercelTokens.clear(); + return Promise.resolve(null); + }, + deployToVercel: createDeployVercelHandler({ + getRuntime, + getToken: () => vercelTokens.getAccessToken(), + }), + vercelPreflight: createVercelPreflightHandler({ getRuntime }), + fsReveal: async ({ path }) => { await fsReveal(path, { skillsManager }); return null; diff --git a/apps/desktop/src/bun/rpc/vercel-preflight.ts b/apps/desktop/src/bun/rpc/vercel-preflight.ts new file mode 100644 index 00000000..13b37f62 --- /dev/null +++ b/apps/desktop/src/bun/rpc/vercel-preflight.ts @@ -0,0 +1,60 @@ +import { stat } from "node:fs/promises"; + +import type { RuntimeClient, RuntimeId } from "@llm-space/runtime/runtime"; + +import { VercelDeployError, collectSingleFile, collectStaticFiles } from "../vercel"; + +export interface VercelPreflightInput { + runtimeId?: RuntimeId; + /** Workspace-relative deploy target: a static folder or one HTML file. */ + path: string; +} + +interface VercelPreflightHandlerDependencies { + getRuntime: (runtimeId: RuntimeId) => RuntimeClient; +} + +/** + * Create the Bun RPC handler that validates a deploy target for Vercel + * without uploading: for folders, index.html presence, file count, and size + * limits; for a single HTML file, its size. Metadata only — file contents + * are never read. + */ +export function createVercelPreflightHandler({ + getRuntime, +}: VercelPreflightHandlerDependencies) { + return async ({ + runtimeId, + path, + }: VercelPreflightInput): Promise< + | { ok: true; fileCount: number; totalBytes: number } + | { ok: false; error: string } + > => { + try { + if (!runtimeId) { + throw new VercelDeployError("No runtime is available for this deploy."); + } + const runtime = getRuntime(runtimeId); + if (runtime.info().status !== "connected") { + throw new VercelDeployError(`Runtime is not connected: ${runtimeId}`); + } + const resolved = await runtime.fsRealpath(path); + const collected = (await stat(resolved)).isFile() + ? await collectSingleFile(resolved, { withContents: false }) + : await collectStaticFiles(resolved, { withContents: false }); + return { + ok: true, + fileCount: collected.files.length, + totalBytes: collected.totalBytes, + }; + } catch (error) { + return { + ok: false, + error: + error instanceof Error + ? error.message + : "This folder cannot be deployed to Vercel.", + }; + } + }; +} diff --git a/apps/desktop/src/bun/vercel/index.ts b/apps/desktop/src/bun/vercel/index.ts new file mode 100644 index 00000000..a5fa036c --- /dev/null +++ b/apps/desktop/src/bun/vercel/index.ts @@ -0,0 +1,10 @@ +export { VercelTokenStore } from "./vercel-token-store"; +export { + VercelDeployError, + collectSingleFile, + collectStaticFiles, + deploySingleFile, + deployStaticFolder, + type CollectedDeployment, + type DeployResult, +} from "./vercel-client"; diff --git a/apps/desktop/src/bun/vercel/vercel-client.test.ts b/apps/desktop/src/bun/vercel/vercel-client.test.ts new file mode 100644 index 00000000..ee263806 --- /dev/null +++ b/apps/desktop/src/bun/vercel/vercel-client.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + VercelDeployError, + collectSingleFile, + collectStaticFiles, + deploySingleFile, + deployStaticFolder, +} from "./vercel-client"; + +const ORIGINAL_HOME = process.env.LLM_SPACE_HOME; +const TEMP_DIRS: string[] = []; + +beforeEach(() => { + process.env.LLM_SPACE_HOME = mkdtempSyncForTest(); +}); + +afterEach(async () => { + for (const dir of TEMP_DIRS.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (ORIGINAL_HOME === undefined) { + delete process.env.LLM_SPACE_HOME; + } else { + process.env.LLM_SPACE_HOME = ORIGINAL_HOME; + } +}); + +function mkdtempSyncForTest(): string { + const dir = path.join( + os.tmpdir(), + `llm-space-vercel-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + TEMP_DIRS.push(dir); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Build a fetch mock that replays scripted JSON responses in order. */ +function fetchScript(responses: { status: number; body: unknown }[]) { + const calls: { url: string; init?: RequestInit; bodyText?: string }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ + url: String(url), + init, + bodyText: typeof init?.body === "string" ? init.body : undefined, + }); + const next = responses.shift(); + if (!next) { + throw new Error("No scripted response left"); + } + return new Response(JSON.stringify(next.body), { + status: next.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { fetchImpl, calls }; +} + +const NO_SLEEP = () => Promise.resolve(); + +async function makeSite(): Promise { + const dir = path.join(os.tmpdir(), `llm-space-site-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(path.join(dir, "assets"), { recursive: true }); + await mkdir(path.join(dir, "node_modules", "left-pad"), { recursive: true }); + await writeFile(path.join(dir, "index.html"), "

hi

", "utf8"); + await writeFile(path.join(dir, "assets", "style.css"), "body{}", "utf8"); + await writeFile( + path.join(dir, "node_modules", "left-pad", "index.js"), + "x", + "utf8" + ); + return dir; +} + +describe("collectStaticFiles", () => { + test("collects deployable files, skipping excluded directories", async () => { + const dir = await makeSite(); + const collected = await collectStaticFiles(dir); + const names = collected.files.map((file) => file.file); + expect(names).toContain("index.html"); + expect(names).toContain("assets/style.css"); + expect(names.some((name) => name.startsWith("node_modules"))).toBe(false); + expect(collected.name).toBe(path.basename(dir)); + }); + + test("rejects a folder without an index.html", async () => { + const dir = path.join(os.tmpdir(), `llm-space-empty-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "about.html"), "

x

", "utf8"); + expect(collectStaticFiles(dir)).rejects.toBeInstanceOf(VercelDeployError); + }); + + test("rejects files over the per-file size limit", async () => { + const dir = path.join(os.tmpdir(), `llm-space-big-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "index.html"), "

hi

", "utf8"); + const bigPath = path.join(dir, "assets", "huge.png"); + await mkdir(path.join(dir, "assets"), { recursive: true }); + await writeFile(bigPath, Buffer.alloc(11 * 1024 * 1024)); + expect(collectStaticFiles(dir)).rejects.toBeInstanceOf(VercelDeployError); + }); + + test("does not follow excluded or hidden directories", async () => { + const dir = path.join(os.tmpdir(), `llm-space-hidden-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(path.join(dir, ".vite"), { recursive: true }); + await writeFile(path.join(dir, "index.html"), "x", "utf8"); + await writeFile(path.join(dir, ".git", "config"), "x", "utf8"); + await writeFile(path.join(dir, ".vite", "cache.js"), "x", "utf8"); + const collected = await collectStaticFiles(dir); + expect(collected.files).toHaveLength(1); + }); + + test("preflight mode (withContents: false) reports sizes without reading file contents", async () => { + const dir = await makeSite(); + const collected = await collectStaticFiles(dir, { withContents: false }); + expect(collected.files).toHaveLength(2); + const index = collected.files.find((file) => file.file === "index.html"); + expect(index?.sizeBytes).toBe("

hi

".length); + // No contents: data stays empty and no base64 encoding is flagged. + expect(index?.data).toBe(""); + expect(index?.encoding).toBeUndefined(); + }); +}); + +describe("deployStaticFolder", () => { + test("creates the deployment, polls until READY, and returns the URL", async () => { + const dir = await makeSite(); + const { fetchImpl, calls } = fetchScript([ + { status: 200, body: { id: "dpl_1", readyState: "BUILDING" } }, + { + status: 200, + body: { id: "dpl_1", url: "my-site.vercel.app", readyState: "READY" }, + }, + ]); + const result = await deployStaticFolder({ + token: "vercel-token-1", + dir, + fetchImpl, + sleep: NO_SLEEP, + }); + + expect(result.url).toBe("https://my-site.vercel.app"); + expect(result.fileCount).toBe(2); + expect(calls).toHaveLength(2); + const create = JSON.parse(calls[0].bodyText!) as { + name: string; + files: { file: string; data: string; encoding?: string }[]; + projectSettings: { framework: null }; + }; + expect(create.name).toBe(path.basename(dir)); + expect(create.projectSettings.framework).toBeNull(); + const style = create.files.find((file) => file.file === "assets/style.css"); + expect(style?.data).toBe("body{}"); + expect(calls[0].init!.headers).toMatchObject({ + Authorization: "Bearer vercel-token-1", + }); + }); + + test("maps a 401 to a friendly token error", async () => { + const dir = await makeSite(); + const { fetchImpl } = fetchScript([ + { status: 401, body: { message: "bad token" } }, + ]); + expect( + deployStaticFolder({ token: "bad", dir, fetchImpl, sleep: NO_SLEEP }) + ).rejects.toThrow(/token/i); + }); + + test("surfaces an ERROR ready state", async () => { + const dir = await makeSite(); + const { fetchImpl } = fetchScript([ + { status: 200, body: { id: "dpl_2", readyState: "BUILDING" } }, + { + status: 200, + body: { id: "dpl_2", readyState: "ERROR", message: "build failed" }, + }, + ]); + expect( + deployStaticFolder({ token: "t", dir, fetchImpl, sleep: NO_SLEEP }) + ).rejects.toThrow(/build failed/); + }); + + test("times out when the deployment never becomes ready", async () => { + const dir = await makeSite(); + const { fetchImpl } = fetchScript([ + { status: 200, body: { id: "dpl_3", readyState: "BUILDING" } }, + { status: 200, body: { id: "dpl_3", readyState: "BUILDING" } }, + ]); + expect( + deployStaticFolder({ + token: "t", + dir, + fetchImpl, + sleep: NO_SLEEP, + timeoutMs: 0, + }) + ).rejects.toThrow(/timed out/); + }); +}); + +describe("collectSingleFile", () => { + test("collects one HTML file as the deployment index page", async () => { + const dir = path.join(os.tmpdir(), `llm-space-file-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, "landing.html"); + await writeFile(file, "

hello

", "utf8"); + + const collected = await collectSingleFile(file); + expect(collected.name).toBe("landing"); + expect(collected.files).toHaveLength(1); + expect(collected.files[0]?.file).toBe("index.html"); + expect(collected.files[0]?.data).toBe("

hello

"); + expect(collected.totalBytes).toBe(14); + }); + + test("preflight mode reports size without reading contents", async () => { + const dir = path.join(os.tmpdir(), `llm-space-file-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, "page.html"); + await writeFile(file, "

x

", "utf8"); + + const collected = await collectSingleFile(file, { withContents: false }); + expect(collected.files[0]?.data).toBe(""); + expect(collected.totalBytes).toBe(8); + }); + + test("rejects a directory target", async () => { + const dir = path.join(os.tmpdir(), `llm-space-file-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + expect(collectSingleFile(dir)).rejects.toThrow(VercelDeployError); + }); +}); + +describe("deploySingleFile", () => { + test("uploads the file as index.html and returns the URL", async () => { + const dir = path.join(os.tmpdir(), `llm-space-file-${Date.now()}`); + TEMP_DIRS.push(dir); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, "demo.html"); + await writeFile(file, "

deploy me

", "utf8"); + + const { fetchImpl, calls } = fetchScript([ + { status: 200, body: { id: "dpl_1", url: "demo.vercel.app" } }, + { status: 200, body: { readyState: "READY", url: "demo.vercel.app" } }, + ]); + const result = await deploySingleFile({ + token: "tok", + file, + fetchImpl, + sleep: NO_SLEEP, + }); + expect(result.url).toBe("https://demo.vercel.app"); + expect(result.fileCount).toBe(1); + const body = JSON.parse(calls[0]?.bodyText ?? "{}") as { + name?: string; + files?: { file: string; data: string }[]; + }; + expect(body.name).toBe("demo"); + expect(body.files).toEqual([ + { file: "index.html", data: "

deploy me

" }, + ]); + }); +}); diff --git a/apps/desktop/src/bun/vercel/vercel-client.ts b/apps/desktop/src/bun/vercel/vercel-client.ts new file mode 100644 index 00000000..c5f3b949 --- /dev/null +++ b/apps/desktop/src/bun/vercel/vercel-client.ts @@ -0,0 +1,400 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +/** + * Vercel REST deployment client (no SDK — plain `fetch`). Deploys a directory + * of static files via `POST /v13/deployments` and polls until the deployment + * is ready. Runs in the bun main process only: the API token and the request + * both stay on the trusted side of the RPC bridge. + */ + +/** Extensions deployed as static assets. Everything else is skipped. */ +const DEPLOYABLE_EXTENSIONS = new Set([ + ".html", + ".htm", + ".css", + ".js", + ".mjs", + ".json", + ".txt", + ".xml", + ".webmanifest", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".avif", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".eot", + ".mp4", + ".webm", + ".mp3", + ".pdf", +]); + +/** Directory names never deployed (dependencies, VCS, generated junk). */ +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + ".git", + ".cache", + ".venv", + "__pycache__", +]); + +/** Guardrails matching Vercel's platform limits with room to spare. */ +const MAX_FILES = 500; +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +const DEPLOY_TIMEOUT_MS = 3 * 60_000; +const POLL_INTERVAL_MS = 2_000; + +const DEPLOYABLE_TEXT_EXTENSIONS = new Set([ + ".html", + ".htm", + ".css", + ".js", + ".mjs", + ".json", + ".txt", + ".xml", + ".webmanifest", + ".svg", +]); + +export interface StaticFile { + /** Repo-relative POSIX path inside the deployment (e.g. `index.html`). */ + file: string; + /** File bytes: inline text, or base64 for binary assets. */ + data: string; + /** Set to `"base64"` for binary assets; absent for text. */ + encoding?: "base64"; + sizeBytes: number; +} + +export interface CollectedDeployment { + name: string; + files: StaticFile[]; + totalBytes: number; +} + +export interface DeployResult { + /** The public URL of the ready deployment. */ + url: string; + /** The deployment id, for reference/debugging. */ + deploymentId: string; + /** Number of files uploaded, for the renderer's success summary. */ + fileCount: number; +} + +/** Human-readable failure for a caught deploy error (renderer-visible). */ +export class VercelDeployError extends Error {} + +interface VercelFilePayload { + file: string; + data: string; + encoding?: "base64"; +} + +interface VercelDeploymentResponse { + id?: string; + url?: string; + readyState?: string; + statusCode?: number; + message?: string; + error?: { message?: string; code?: string }; +} + +export interface VercelClientOptions { + /** Injectable fetch for tests; defaults to the global. */ + fetchImpl?: typeof fetch; + /** Injectable sleep for tests; defaults to a real timer. */ + sleep?: (ms: number) => Promise; + /** Poll budget override (tests); defaults to 3 minutes. */ + timeoutMs?: number; +} + +/** + * Walk `dir` and collect the deployable static files. Throws a + * {@link VercelDeployError} with a user-facing message when the folder holds + * no static entrypoint, exceeds the platform limits, or can't be read. + */ +export async function collectStaticFiles( + dir: string, + options: { withContents?: boolean } = {} +): Promise { + const { withContents = true } = options; + const root = path.resolve(dir); + const files: StaticFile[] = []; + let totalBytes = 0; + + const walk = async (current: string): Promise => { + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch (error) { + throw new VercelDeployError( + `Cannot read directory ${current}: ${error instanceof Error ? error.message : String(error)}` + ); + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + if ( + !SKIPPED_DIRECTORIES.has(entry.name) && + !entry.name.startsWith(".") + ) { + await walk(full); + } + continue; + } + if (!entry.isFile()) { + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + if (!DEPLOYABLE_EXTENSIONS.has(ext)) { + continue; + } + const relative = path.relative(root, full).split(path.sep).join("/"); + const info = await stat(full); + if (info.size > MAX_FILE_BYTES) { + throw new VercelDeployError( + `File is too large to deploy (${_formatBytes(info.size)}): ${relative}` + ); + } + totalBytes += info.size; + if (files.length + 1 > MAX_FILES) { + throw new VercelDeployError( + `Too many files to deploy (limit ${MAX_FILES}). Deploy a smaller folder.` + ); + } + if (totalBytes > MAX_TOTAL_BYTES) { + throw new VercelDeployError( + `Folder is too large to deploy (limit ${_formatBytes(MAX_TOTAL_BYTES)}).` + ); + } + const data = withContents ? await readFile(full) : Buffer.alloc(0); + const isText = DEPLOYABLE_TEXT_EXTENSIONS.has(ext); + files.push({ + file: relative, + data: withContents + ? isText + ? data.toString("utf8") + : data.toString("base64") + : "", + ...(withContents && !isText ? { encoding: "base64" as const } : {}), + sizeBytes: info.size, + }); + } + }; + + await walk(root); + + const hasIndex = files.some( + (file) => file.file === "index.html" || file.file === "index.htm" + ); + if (!hasIndex) { + throw new VercelDeployError( + "No static site found: the folder must contain an index.html to deploy." + ); + } + + return { name: path.basename(root), files, totalBytes }; +} + +/** + * Deploy a static folder to Vercel and wait for it to become ready. The + * deployment is public by nature — callers must surface that in the UI before + * invoking this. + */ +export async function deployStaticFolder( + options: { + token: string; + dir: string; + projectName?: string; + } & VercelClientOptions +): Promise { + const collected = await collectStaticFiles(options.dir); + return _deployCollected({ ...options, collected }); +} + +/** + * Collect one HTML file as a self-contained deployment: the file is served as + * the site's `index.html` regardless of its original name. For pages that + * reference sibling assets, deploy the containing folder instead. + */ +export async function collectSingleFile( + file: string, + options: { withContents?: boolean } = {} +): Promise { + const { withContents = true } = options; + const info = await stat(file); + if (!info.isFile()) { + throw new VercelDeployError( + "Only a single HTML file (or a folder) can be deployed." + ); + } + if (info.size > MAX_FILE_BYTES) { + throw new VercelDeployError( + `File is too large to deploy (${_formatBytes(info.size)}).` + ); + } + const data = withContents ? await readFile(file) : Buffer.alloc(0); + return { + name: path.basename(file, path.extname(file)), + files: [ + { + file: "index.html", + data: data.toString("utf8"), + sizeBytes: info.size, + }, + ], + totalBytes: info.size, + }; +} + +/** + * Deploy one self-contained HTML file as a single-page site and wait for it + * to become ready. + */ +export async function deploySingleFile( + options: { + token: string; + file: string; + projectName?: string; + } & VercelClientOptions +): Promise { + const collected = await collectSingleFile(options.file); + return _deployCollected({ ...options, collected }); +} + +async function _deployCollected( + options: { + token: string; + projectName?: string; + collected: CollectedDeployment; + } & VercelClientOptions +): Promise { + const { + token, + projectName, + collected, + fetchImpl = fetch, + sleep = _defaultSleep, + timeoutMs = DEPLOY_TIMEOUT_MS, + } = options; + const name = projectName ?? collected.name; + + let createResponse: Response; + try { + createResponse = await fetchImpl("https://api.vercel.com/v13/deployments", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + files: collected.files.map( + ({ file, data, encoding }): VercelFilePayload => ({ + file, + data, + ...(encoding ? { encoding } : {}), + }) + ), + projectSettings: { framework: null }, + }), + }); + } catch (error) { + throw new VercelDeployError( + `Network error while contacting Vercel: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const created = (await _parseResponse( + createResponse + )) as VercelDeploymentResponse; + const deploymentId = created.id; + if (!deploymentId) { + throw new VercelDeployError("Vercel did not return a deployment id."); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + let pollResponse: Response; + try { + pollResponse = await fetchImpl( + `https://api.vercel.com/v13/deployments/${encodeURIComponent(deploymentId)}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + } catch { + // Transient network blips during polling are retried until the deadline. + continue; + } + const deployment = (await _parseResponse( + pollResponse + )) as VercelDeploymentResponse; + if (deployment.readyState === "READY") { + return { + url: deployment.url + ? `https://${deployment.url}` + : `https://${name}.vercel.app`, + deploymentId, + fileCount: collected.files.length, + }; + } + if ( + deployment.readyState === "ERROR" || + deployment.readyState === "CANCELED" + ) { + throw new VercelDeployError( + `Vercel deployment failed${deployment.message ? `: ${deployment.message}` : "."}` + ); + } + } + throw new VercelDeployError( + "Vercel deployment timed out. Check its status in the Vercel dashboard." + ); +} + +/** Parse a Vercel response, mapping HTTP errors to friendly messages. */ +async function _parseResponse(response: Response): Promise { + let payload: VercelDeploymentResponse; + try { + payload = (await response.json()) as VercelDeploymentResponse; + } catch { + throw new VercelDeployError( + `Unexpected response from Vercel (HTTP ${response.status}).` + ); + } + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new VercelDeployError( + "Vercel rejected the token (401/403). Check that it is valid and has deploy access." + ); + } + const detail = payload.error?.message ?? payload.message; + throw new VercelDeployError( + `Vercel request failed (HTTP ${response.status})${detail ? `: ${detail}` : "."}` + ); + } + return payload; +} + +function _defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function _formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} diff --git a/apps/desktop/src/bun/vercel/vercel-token-store.test.ts b/apps/desktop/src/bun/vercel/vercel-token-store.test.ts new file mode 100644 index 00000000..14a3bdc1 --- /dev/null +++ b/apps/desktop/src/bun/vercel/vercel-token-store.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { VercelTokenStore } from "./vercel-token-store"; + +const ORIGINAL_HOME = process.env.LLM_SPACE_HOME; +const TEMP_DIRS: string[] = []; + +beforeEach(() => { + const dir = path.join(os.tmpdir(), `llm-space-vercel-store-${Date.now()}`); + TEMP_DIRS.push(dir); + process.env.LLM_SPACE_HOME = dir; +}); + +afterEach(async () => { + for (const dir of TEMP_DIRS.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (ORIGINAL_HOME === undefined) { + delete process.env.LLM_SPACE_HOME; + } else { + process.env.LLM_SPACE_HOME = ORIGINAL_HOME; + } +}); + +describe("VercelTokenStore", () => { + test("is unconfigured when vercel.json does not exist", () => { + const store = new VercelTokenStore(); + expect(store.isConfigured()).toBe(false); + expect(store.getAccessToken()).toBeNull(); + }); + + test("persists the token with 0600 permissions and reloads it", async () => { + const store = new VercelTokenStore(); + store.set(" vercel-token-123 "); + + expect(store.isConfigured()).toBe(true); + expect(store.getAccessToken()).toBe("vercel-token-123"); + + const configPath = path.join( + TEMP_DIRS[0], + "settings", + "vercel.json" + ); + const info = await stat(configPath); + expect(info.mode & 0o777).toBe(0o600); + const raw = await readFile(configPath, "utf8"); + expect(JSON.parse(raw)).toEqual({ accessToken: "vercel-token-123" }); + }); + + test("replaces the previous token on a new save", () => { + const store = new VercelTokenStore(); + store.set("old"); + store.set("new"); + expect(store.getAccessToken()).toBe("new"); + }); + + test("rejects empty tokens", () => { + const store = new VercelTokenStore(); + expect(() => store.set(" ")).toThrow(); + expect(store.isConfigured()).toBe(false); + }); + + test("clear removes the file", async () => { + const store = new VercelTokenStore(); + store.set("token"); + const configPath = path.join(TEMP_DIRS[0], "settings", "vercel.json"); + store.clear(); + expect(store.isConfigured()).toBe(false); + expect(await readFile(configPath, "utf8").catch(() => "gone")).toBe("gone"); + }); +}); diff --git a/apps/desktop/src/bun/vercel/vercel-token-store.ts b/apps/desktop/src/bun/vercel/vercel-token-store.ts new file mode 100644 index 00000000..827aea3c --- /dev/null +++ b/apps/desktop/src/bun/vercel/vercel-token-store.ts @@ -0,0 +1,73 @@ +import { rmSync } from "node:fs"; +import path from "node:path"; + +import { + atomicWriteJsonFileSync, + getSettingsDir, + readJsonFileSync, +} from "@llm-space/core/server"; +import { z } from "zod"; + +const VercelConfigSchema = z.object({ + accessToken: z.string().min(1), +}); + +/** + * Owns the Vercel access token used to deploy generated/static projects. + * Persisted to `settings/vercel.json` (`0600`) like GitHub's `auth.json`; the + * token never leaves the bun process — the renderer only sees whether a token + * is configured, never the token itself. + */ +export class VercelTokenStore { + private _load(): string | null { + try { + const config = readJsonFileSync(this._configPath, { + schema: VercelConfigSchema, + recovery: "none", + fallback: () => null, + mode: 0o600, + seedMissing: false, + }).value; + return config?.accessToken ?? null; + } catch (error) { + console.error("Failed to read vercel.json:", error); + return null; + } + } + + /** Persist the token. Empty/whitespace-only input is rejected. */ + set(accessToken: string): void { + const token = accessToken.trim(); + if (!token) { + throw new Error("Vercel token must not be empty."); + } + atomicWriteJsonFileSync( + this._configPath, + { accessToken: token }, + { mode: 0o600 } + ); + } + + /** Forget the stored token and delete `vercel.json`. */ + clear(): void { + try { + rmSync(this._configPath, { force: true }); + } catch (error) { + console.error("Failed to remove vercel.json:", error); + } + } + + /** Whether a token is configured (the renderer-safe status). */ + isConfigured(): boolean { + return this._load() !== null; + } + + /** The raw token for authenticated Vercel calls (bun-side only). */ + getAccessToken(): string | null { + return this._load(); + } + + private get _configPath(): string { + return path.join(getSettingsDir(), "vercel.json"); + } +} diff --git a/apps/desktop/src/client/vercel.ts b/apps/desktop/src/client/vercel.ts new file mode 100644 index 00000000..bffac0e8 --- /dev/null +++ b/apps/desktop/src/client/vercel.ts @@ -0,0 +1,47 @@ +import { electrobun } from "@/lib/electrobun"; +import type { RuntimeId } from "@/shared/runtime"; +import type { VercelStatus } from "@/shared/vercel"; + +function _rpc() { + if (!electrobun.rpc) { + throw new Error("Electrobun RPC is not initialized"); + } + return electrobun.rpc; +} + +/** Whether a Vercel deploy token is configured (the token itself stays in bun). */ +export function getVercelStatus(): Promise { + return _rpc().request.getVercelStatus({}); +} + +/** Save the Vercel token. The value crosses the bridge once and is never echoed back. */ +export function setVercelToken(token: string): Promise { + return _rpc().request.setVercelToken({ token }); +} + +/** Forget the stored Vercel token. */ +export function removeVercelToken(): Promise { + return _rpc().request.removeVercelToken({}); +} + +/** + * Deploy a workspace folder of static files (must contain `index.html`) to + * Vercel. Resolves to the public URL, or `{ok:false}` with friendly copy. + */ +export function deployToVercel( + runtimeId: RuntimeId, + path: string +): Promise { + return _rpc().request.deployToVercel({ runtimeId, path }); +} + +/** + * Validate a folder for deployment (index.html, file count, size limits) + * without uploading. Used to disable the deploy dialog up front. + */ +export function vercelPreflight( + runtimeId: RuntimeId, + path: string +): Promise { + return _rpc().request.vercelPreflight({ runtimeId, path }); +} diff --git a/apps/desktop/src/components/deploy-vercel-dialog.tsx b/apps/desktop/src/components/deploy-vercel-dialog.tsx new file mode 100644 index 00000000..53dcbca4 --- /dev/null +++ b/apps/desktop/src/components/deploy-vercel-dialog.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { Button } from "@llm-space/ui/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@llm-space/ui/ui/dialog"; +import { + CheckIcon, + CopyIcon, + ExternalLinkIcon, + Loader2Icon, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { + deployToVercel, + getVercelStatus, + vercelPreflight, +} from "@/client/vercel"; +import { useCommands } from "@/commands"; +import { useI18n } from "@/i18n/i18n-provider"; +import { formatMessage } from "@/i18n/messages"; +import type { RuntimeId } from "@/shared/runtime"; + +type DeployPhase = "idle" | "deploying" | "success" | "failed"; + +type Preflight = + | { status: "checking" } + | { status: "ok"; fileCount: number; totalBytes: number } + | { status: "failed"; error: string }; + +/** Human-readable byte size for the preflight summary. */ +function _formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +/** + * "Deploy to Vercel" dialog: runs a bun-side preflight when opened (so an + * undeployable folder is caught before the user commits), warns about the + * public URL, dispatches the deployment, and surfaces the resulting link (or + * a friendly error). Token configuration is delegated to Settings → Account. + */ +export function DeployVercelDialog({ + open, + onOpenChange, + path, + runtimeId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Workspace-relative folder to deploy. */ + path: string; + runtimeId: RuntimeId; +}) { + const { t } = useI18n(); + const { executeCommand } = useCommands(); + const [phase, setPhase] = useState("idle"); + const [tokenConfigured, setTokenConfigured] = useState(null); + const [preflight, setPreflight] = useState(null); + const [url, setUrl] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!open) { + return; + } + setPhase("idle"); + setUrl(null); + setError(null); + setCopied(false); + setPreflight({ status: "checking" }); + let cancelled = false; + void getVercelStatus() + .then((status) => { + if (!cancelled) setTokenConfigured(status.configured); + }) + .catch(() => { + if (!cancelled) setTokenConfigured(false); + }); + void vercelPreflight(runtimeId, path) + .then((result) => { + if (cancelled) return; + setPreflight( + result.ok + ? { + status: "ok", + fileCount: result.fileCount, + totalBytes: result.totalBytes, + } + : { status: "failed", error: result.error } + ); + }) + .catch((err) => { + if (!cancelled) { + setPreflight({ + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + }); + return () => { + cancelled = true; + }; + }, [open, path, runtimeId]); + + const handleDeploy = useCallback(async () => { + setPhase("deploying"); + setError(null); + try { + const result = await deployToVercel(runtimeId, path); + if (result.ok) { + setUrl(result.url); + setPhase("success"); + } else { + setError(result.error); + setPhase("failed"); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("failed"); + } + }, [path, runtimeId]); + + const handleCopy = useCallback(() => { + if (!url) { + return; + } + navigator.clipboard + ?.writeText(url) + .then(() => setCopied(true)) + .catch(() => { + /* clipboard unavailable — ignore */ + }); + }, [url]); + + useEffect(() => { + if (!copied) { + return; + } + const id = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(id); + }, [copied]); + + const openAccountSettings = useCallback(() => { + onOpenChange(false); + executeCommand({ type: "openSettings", args: { tab: "account" } }); + }, [executeCommand, onOpenChange]); + + const folderName = path.split("/").filter(Boolean).at(-1) ?? path; + const preflightOk = preflight?.status === "ok"; + const preflightFailed = preflight?.status === "failed"; + + return ( + + + + {t.vercel.dialogTitle} + + + {t.vercel.folderLabel}: + + + {folderName} + + + + + {phase === "success" && url ? ( +
+

+ + {t.vercel.successTitle} +

+
+ + {url} + + +
+
+ ) : phase === "failed" ? ( +
+

+ {t.vercel.failedTitle} +

+

{error}

+
+ ) : phase === "deploying" ? ( +
+ + {t.vercel.deploying} +
+ ) : ( +
+

+ {t.vercel.publicWarning} +

+ {preflight?.status === "checking" ? ( +

+ + {t.vercel.checking} +

+ ) : preflightFailed ? ( +
+

+ {t.vercel.preflightFailed} +

+

+ {preflight.error} +

+
+ ) : preflightOk ? ( +

+ {formatMessage(t.vercel.filesSummary, { + count: preflight.fileCount, + size: _formatBytes(preflight.totalBytes), + })} +

+ ) : null} + {tokenConfigured === false ? ( +
+

+ {t.vercel.tokenMissingHint} +

+ +
+ ) : null} +
+ )} + + + {phase === "success" && url ? ( + <> + + + + ) : phase === "failed" ? ( + + ) : phase === "deploying" ? null : ( + + )} + +
+
+ ); +} diff --git a/apps/desktop/src/components/page-deploy-vercel-controller.tsx b/apps/desktop/src/components/page-deploy-vercel-controller.tsx new file mode 100644 index 00000000..bdd01df8 --- /dev/null +++ b/apps/desktop/src/components/page-deploy-vercel-controller.tsx @@ -0,0 +1,47 @@ +import { lazy, useState } from "react"; + +import { useRegisterCommands } from "@/commands"; +import type { RuntimeId } from "@/shared/runtime"; + +import { LazyMount } from "./lazy-mount"; + +const DeployVercelDialog = lazy(() => + import("./deploy-vercel-dialog").then((module) => ({ + default: module.DeployVercelDialog, + })) +); + +/** + * Owns deploy-command registration and the deploy dialog's target folder. + * Mirrors {@link PageShareThreadController}: the command handler resolves the + * workspace runtime lazily, the dialog stays mounted after its first open. + */ +export function PageDeployVercelController({ + workspaceRuntimeId, +}: { + workspaceRuntimeId: RuntimeId; +}) { + const [open, setOpen] = useState(false); + const [target, setTarget] = useState<{ path: string; runtimeId: RuntimeId }>({ + path: "", + runtimeId: "local", + }); + + useRegisterCommands({ + deployVercel: ({ path, runtimeId }) => { + setTarget({ path, runtimeId: runtimeId ?? workspaceRuntimeId }); + setOpen(true); + }, + }); + + return ( + + + + ); +} diff --git a/apps/desktop/src/components/settings/account-page.tsx b/apps/desktop/src/components/settings/account-page.tsx index f2e67e9e..c2ad1db1 100644 --- a/apps/desktop/src/components/settings/account-page.tsx +++ b/apps/desktop/src/components/settings/account-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@llm-space/ui/lib/utils"; import { Button } from "@llm-space/ui/ui/button"; import { ArrowRightIcon, @@ -19,15 +20,123 @@ import { SparklesIcon, Undo2Icon, } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { getVercelStatus, removeVercelToken, setVercelToken } from "@/client/vercel"; import { useGithubAuth } from "@/components/github-auth-provider"; import { GithubAvatar } from "@/components/github-avatar"; import { GitHubIcon } from "@/components/github-icon"; import { useI18n } from "@/i18n/i18n-provider"; import type { GithubUser } from "@/shared/auth"; +import { ApiKeyField } from "./api-key-field"; import { SettingsPage } from "./settings-page"; +/** + * Vercel deploy-token section (Settings → Account). The renderer only ever + * sees whether a token is configured; the token itself is persisted bun-side + * (`settings/vercel.json`) and never displayed again after saving. + */ +function VercelTokenSection() { + const { t } = useI18n(); + const [token, setToken] = useState(""); + const [configured, setConfigured] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + void getVercelStatus() + .then((status) => { + if (!cancelled) setConfigured(status.configured); + }) + .catch(() => { + if (!cancelled) setConfigured(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleSave = async () => { + if (!token.trim()) { + return; + } + setBusy(true); + try { + await setVercelToken(token.trim()); + setConfigured(true); + setToken(""); + toast.success(t.vercel.saved); + } catch (error) { + toast.error(t.vercel.saveFailed, { + description: error instanceof Error ? error.message : t.common.pleaseTryAgain, + }); + } finally { + setBusy(false); + } + }; + + const handleRemove = async () => { + setBusy(true); + try { + await removeVercelToken(); + setConfigured(false); + setToken(""); + toast.success(t.vercel.removed); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

{t.vercel.accountTitle}

+

+ {t.vercel.accountDescription} +

+
+ + {configured ? t.vercel.configured : t.vercel.notConfigured} + +
+ setToken(event.target.value)} + autoComplete="off" + /> +
+ + {configured ? ( + + ) : null} +
+
+ ); +} + export function AccountPage() { const { t } = useI18n(); const { state, signIn, signOut } = useGithubAuth(); @@ -62,6 +171,7 @@ export function AccountPage() { ) : ( <_AccountOverview onSignIn={signIn} /> )} + ); } diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index d3cc031b..bf4a65ba 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -279,6 +279,42 @@ const APP_MESSAGES = { later: "Later", reload: "Reload", }, + vercel: { + dialogTitle: "Deploy to Vercel", + folderLabel: "Target", + publicWarning: + "This deployment will be publicly accessible on the internet. Make sure the folder contains no secrets, API keys, or private data before deploying.", + tokenMissingHint: + "A Vercel access token is needed to deploy. Configure one in Settings → Account.", + configureToken: "Configure Vercel token", + deploy: "Deploy", + deploying: "Deploying…", + deployHint: + "Files are collected and uploaded by the app; this can take a minute.", + checking: "Checking folder…", + filesSummary: "{count} files · {size}", + preflightFailed: "This folder can't be deployed", + successTitle: "Deployment ready", + copyUrl: "Copy URL", + copied: "Copied", + openInBrowser: "Open in browser", + failedTitle: "Deployment failed", + retry: "Retry", + accountTitle: "Vercel", + accountDescription: + "Connect a Vercel token to deploy static folders from your workspace and share the public link.", + tokenLabel: "Vercel access token", + tokenPlaceholder: "Paste your Vercel access token", + tokenHint: + "Create a token at vercel.com/account/tokens with a limited scope. It is stored locally (settings/vercel.json, 0600) and never displayed again.", + save: "Save", + remove: "Remove", + saved: "Vercel token saved", + removed: "Vercel token removed", + saveFailed: "Failed to save the Vercel token", + configured: "Configured", + notConfigured: "Not configured", + }, apiKeyField: { getKey: "Get API key", showAria: "Show {label}", @@ -327,6 +363,7 @@ const APP_MESSAGES = { "A folder with this name already exists here. Replacing it moves the existing folder to the {trash}.", replaceDescriptionThread: "A thread with this name already exists here. Replacing it moves the existing thread to the {trash}.", + deployToVercel: "Deploy to Vercel", }, tabBar: { refresh: "Refresh", @@ -949,6 +986,41 @@ const APP_MESSAGES = { later: "稍后", reload: "重新加载", }, + vercel: { + dialogTitle: "部署到 Vercel", + folderLabel: "目标", + publicWarning: + "部署完成后将在互联网上公开可访问。请先确认文件夹中不包含密钥、API Key 或任何隐私数据。", + tokenMissingHint: + "部署需要 Vercel 访问令牌。请先在「设置 → 账户」中配置。", + configureToken: "配置 Vercel 令牌", + deploy: "部署", + deploying: "部署中…", + deployHint: "应用会收集并上传文件,可能需要一分钟。", + checking: "正在检查文件夹…", + filesSummary: "{count} 个文件 · {size}", + preflightFailed: "此文件夹无法部署", + successTitle: "部署完成", + copyUrl: "复制链接", + copied: "已复制", + openInBrowser: "在浏览器中打开", + failedTitle: "部署失败", + retry: "重试", + accountTitle: "Vercel", + accountDescription: + "连接 Vercel 令牌后,可一键把工作区中的静态文件夹部署到 Vercel 并分享公开链接。", + tokenLabel: "Vercel 访问令牌", + tokenPlaceholder: "粘贴你的 Vercel 访问令牌", + tokenHint: + "请到 vercel.com/account/tokens 创建受限范围的令牌。令牌只保存在本机(settings/vercel.json,0600 权限),之后不会再显示。", + save: "保存", + remove: "移除", + saved: "Vercel 令牌已保存", + removed: "已移除 Vercel 令牌", + saveFailed: "保存 Vercel 令牌失败", + configured: "已配置", + notConfigured: "未配置", + }, apiKeyField: { getKey: "获取 API Key", showAria: "显示 {label}", @@ -996,6 +1068,7 @@ const APP_MESSAGES = { "此处已存在同名文件夹。替换会将现有文件夹移到{trash}。", replaceDescriptionThread: "此处已存在同名 Thread。替换会将现有 Thread 移到{trash}。", + deployToVercel: "部署到 Vercel", }, tabBar: { refresh: "刷新", diff --git a/apps/desktop/src/shared/command-labels.ts b/apps/desktop/src/shared/command-labels.ts index 80dcabce..611aea8c 100644 --- a/apps/desktop/src/shared/command-labels.ts +++ b/apps/desktop/src/shared/command-labels.ts @@ -17,6 +17,7 @@ const COMMAND_LABELS_ZH: Record = { revealFile: "在 Finder 中显示", copyFile: "拷贝", refreshTree: "刷新", + deployVercel: "部署到 Vercel", revealInTree: "在文件树中显示", importFiles: "从文件导入…", importFromClipboard: "从剪贴板导入", diff --git a/apps/desktop/src/shared/commands.ts b/apps/desktop/src/shared/commands.ts index 8b5bf0cd..ac46b852 100644 --- a/apps/desktop/src/shared/commands.ts +++ b/apps/desktop/src/shared/commands.ts @@ -251,6 +251,15 @@ export interface ShareThreadCommand extends GenericCommand< { path?: string; runtimeId?: RuntimeId } > {} +/** + * Open the "Deploy to Vercel" dialog for a workspace folder of static files. + * `path` targets a directory (tree context menu). Webview only. + */ +export interface DeployVercelCommand extends GenericCommand< + "deployVercel", + { path: string; runtimeId?: RuntimeId } +> {} + /** * Open the Variables dialog for the active thread. When `variableName` is given, * the dialog opens focused on that variable; otherwise it opens at the default @@ -350,6 +359,7 @@ export type Command = | OpenOnboardCommand | RunThreadCommand | ShareThreadCommand + | DeployVercelCommand | OpenVariablesCommand | ZoomInCommand | ZoomOutCommand @@ -426,6 +436,7 @@ export const COMMAND_META: Record< openOnboard: { label: "Onboard...", target: "webview" }, runThread: { label: "Run Thread", target: "webview" }, shareThread: { label: "Share...", target: "webview" }, + deployVercel: { label: "Deploy to Vercel", target: "webview" }, openVariables: { label: "Variables", target: "webview" }, zoomIn: { label: "Zoom In", target: "bun" }, zoomOut: { label: "Zoom Out", target: "bun" }, diff --git a/apps/desktop/src/shared/rpc.ts b/apps/desktop/src/shared/rpc.ts index 569b84e2..c41db92a 100644 --- a/apps/desktop/src/shared/rpc.ts +++ b/apps/desktop/src/shared/rpc.ts @@ -70,6 +70,11 @@ import type { TraceWorkbenchResponse, } from "./traces"; import type { UpdateMode, UpdateStatusChangedPayload } from "./updates"; +import type { + DeployVercelRpcResult, + VercelPreflightResult, + VercelStatus, +} from "./vercel"; /** A webview→bun request to start streaming an agent run. */ export interface StreamThreadRequestPayload extends RuntimeScopedParams { @@ -825,6 +830,37 @@ export interface DesktopRPCType { params: Record; response: { removed: number }; }; + // Whether a Vercel deploy token is configured (`settings/vercel.json`). + // The token itself never leaves the bun process. + getVercelStatus: { + params: Record; + response: VercelStatus; + }; + // Save (or replace) the Vercel token. Takes the raw token once; the + // response is only an ack. + setVercelToken: { + params: { token: string }; + response: null; + }; + // Forget the stored Vercel token. + removeVercelToken: { + params: Record; + response: null; + }; + // Deploy a workspace folder of static files (must contain index.html) to + // Vercel and wait for it to become ready. The deployment URL is public; + // callers must surface that before invoking. Errors return `{ok:false}` + // with renderer-friendly copy instead of throwing. + deployToVercel: { + params: RuntimeScopedParams & { path: string }; + response: DeployVercelRpcResult; + }; + // Validate a folder for deployment (index.html, limits) without + // uploading. Lets the deploy dialog disable Deploy with a reason up front. + vercelPreflight: { + params: RuntimeScopedParams & { path: string }; + response: VercelPreflightResult; + }; }; // Messages the webview SENDS and the bun side handles. messages: { diff --git a/apps/desktop/src/shared/vercel.ts b/apps/desktop/src/shared/vercel.ts new file mode 100644 index 00000000..9e806aa8 --- /dev/null +++ b/apps/desktop/src/shared/vercel.ts @@ -0,0 +1,19 @@ +/** + * Result of the `deployToVercel` RPC: a ready deployment URL, or a + * renderer-friendly error (network/401/limits never throw across the bridge). + */ +export type DeployVercelRpcResult = + { ok: true; url: string; fileCount: number } | { ok: false; error: string }; + +/** Renderer-safe Vercel connection status: never carries the token. */ +export interface VercelStatus { + configured: boolean; +} + +/** + * Preflight check for the deploy dialog: validates the folder (index.html + * presence, file count, size limits) without uploading anything. + */ +export type VercelPreflightResult = + | { ok: true; fileCount: number; totalBytes: number } + | { ok: false; error: string }; diff --git a/specs/pr-plans/0003-vercel-deploy-share.md b/specs/pr-plans/0003-vercel-deploy-share.md new file mode 100644 index 00000000..1e2ced2d --- /dev/null +++ b/specs/pr-plans/0003-vercel-deploy-share.md @@ -0,0 +1,81 @@ +# PR 计划 0003:把生成的前端产物一键部署到 Vercel 并分享链接 +> 状态:**已实现(2026-09-08 核对)**:`bun/vercel/`(client + token-store 0600 + preflight)、`bun/rpc/deploy-vercel.ts`、`deploy-vercel-dialog.tsx`、`client/vercel.ts` 等 55 个新文件中约 12 个属于本 PR;测试 `vercel-client.test.ts`、`vercel-token-store.test.ts`(18 用例)通过。改动在工作树,未提交。 + +> 状态:计划(未写代码) +> 无新增依赖:Vercel REST API 用原生 `fetch` 调用即可。 + +## 1. 背景与目标 + +现状:codegen 能把 thread 导出成可运行项目,但产物只落在本地磁盘(`~/Desktop` 默认目录),成功页只提供「打开文件夹 / 开终端跑命令」,**没有预览、没有部署、没有分享**。想给别人看一个生成的前端页面,目前只能自己想办法。 + +**目标**:生成产物后一键部署到 Vercel,拿到一个公开 URL 直接分享。 + +### 1.1 为什么不能复用现有 thread 分享 + +现有分享走 **GitHub Gist**(`packages/core/src/storage/gist/`):`POST /gists` 存的是 thread JSON,viewer(`apps/web/src/thread-viewer.tsx`)用 `ThreadZodSchema` 严格校验后交给 `ThreadPlayground` 渲染。**它是结构化数据通道,不是静态文件托管**:HTML 塞进去会被 schema 拒绝,即使绕过,viewer 也不会渲染。Gist raw 还是 `text/plain`,浏览器不渲染 HTML。 + +→ 需要独立的一条部署通道。 + +## 2. 现状(代码事实) + +| 关注点 | 位置 | 现状 | +| --- | --- | --- | +| 产物生成 | `packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx` | 当前导出 LangGraph/Python 项目(77);产物写本地磁盘,走 `generatorWriteFile` RPC(`rpc.ts` 508-511);默认父目录 `~/Desktop`(80) | +| 产物出口 | 同上 `SuccessStep`(1296-1413) | 只有「Open Folder」(`fsReveal` 393-405)、开终端(519)、复制命令 | +| 外部请求 | 全部走 **bun 主进程** | 分享:`shareThread` RPC(419-427)→ `share-thread.ts` → `gist-api.ts` 的 fetch(76-96);Firecrawl 同样在 bun 侧 | +| 凭据存储 | bun 侧文件 | GitHub token:`settings/auth.json`(0600,`github-auth-manager.ts` 200-206),token 永不出 bun;模型/搜索 key:`ModelManager` 用 `getSettingsDir` + `atomicWriteJsonFileSync` | + +**关键约束**:出网请求和凭据都在 bun 主进程,渲染进程只通过 RPC 拿结果。Vercel 集成必须遵守同一条规则。 + +## 3. 设计方案 + +### 3.1 凭据 + +- bun 侧新增 `settings/vercel.json`(权限 0600),复用 `atomicWriteJsonFileSync` / `getSettingsDir`——与 `auth.json`、`ModelManager` 的模式完全一致 +- RPC:`setVercelToken` / `getVercelStatus`;**渲染进程只拿到「是否已配置」的布尔值,拿不到 token 本身** + +### 3.2 部署流程(bun 侧) + +1. 收集产物文件:遍历生成目录,取静态资源(`.html` / `.css` / `.js` / 图片等),排除 `node_modules`、`.git`、超大文件 +2. `POST https://api.vercel.com/v13/deployments` + - `Authorization: Bearer ` + - body 形如 `{ name, files: [{ file: "index.html", data: "<内容>" }], projectSettings: { framework: null } }` +3. 轮询部署状态直到 `ready`(或失败/超时),返回 `url` +4. 结果通过 RPC 回传渲染进程 + +无需 SDK,原生 `fetch` 足够。 + +### 3.3 UI + +- codegen 成功页新增「Deploy to Vercel」按钮(未配置 token 时引导去设置页) +- 设置页(Search / Account 视归类而定)新增 Vercel token 输入 + 「已配置 / 未配置」状态,复用现有 `ApiKeyField` 风格 +- 部署中显示进度,完成后给出可复制的 URL +- i18n:en/zh 双树新增文案 + +## 4. 涉及文件 + +- `apps/desktop/src/bun/` 新增 `vercel/vercel-client.ts` + `vercel/vercel-token-store.ts` +- `apps/desktop/src/bun/rpc.ts` 新增 handler(`setVercelToken` / `getVercelStatus` / `deployToVercel`) +- `apps/desktop/src/client/` 新增对应渲染侧调用封装 +- `packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx`(按钮 + 进度 + 结果) +- `apps/desktop/src/components/settings/`(token 输入页) +- `apps/desktop/src/i18n/messages.ts` +- 新增测试:`apps/desktop/src/bun/vercel/vercel-client.test.ts`(mock fetch:成功 / 401 / 超时 / 大文件拒绝) + +## 5. 风险与待定 + +1. **产物是公开 URL**:Vercel 部署默认公开可访问。必须在 UI 上明确提示「将公开到互联网」,避免用户把含密钥/隐私的产物传上去。 +2. **token 权限**:建议文档里引导用户创建受限 scope 的 token;不要复用全权限 token。 +3. **体积与时间**:Vercel 对单次部署有文件数与体积限制 → 部署前检查并给出清晰报错(哪些文件被排除、总体积)。 +4. **产物类型**:当前 codegen 只导出 LangGraph/Python,不是前端项目。**本 PR 是否要同时支持导出静态前端项目?** 若只做部署按钮,而产物是 Python 后端,按钮就没有意义。 + → **待定**:建议先明确"前端产物"从哪来(codegen 新增静态 HTML 导出?还是用户工作区里已有的 HTML 文件?后者更通用:右键任意 HTML/文件夹 → Deploy)。 +5. **maintainer 接受度**:引入第三方商业平台依赖是有争议的方向。建议 PR 描述里把接口抽象成「部署目标」,Vercel 只是第一个实现,后续可加 Netlify / Cloudflare Pages。 + +## 6. 验证 + +- 单元测试:token 存储(权限、读写)、部署请求构造、状态轮询、错误处理(401/网络/超时)。 +- 手动:真实 token 部署一个静态 HTML,拿到 URL 能打开;未配置 token 时的引导;失败提示。 + +## 7. 工作量 + +中(3-4 天),其中大半在凭据/错误处理/UI 状态机;API 本身不复杂。**无新增依赖**。