import { promises as fsp, chmodSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; /** * Per-provider config file storage. Tokens live as plaintext in * `~/.html-anything/.json` (chmod 600). When the API surfaces them * back to the client we substitute a fixed mask string so the real token * never leaves the server. The client sends the mask back unchanged when it * wants the existing value preserved during a partial update. * * Architecture cribbed from open-design (`apps/daemon/src/deploy.ts`): * single host, no multi-tenant story, no encryption-at-rest beyond chmod. * Acceptable because (a) HTML Anything is a developer tool the user runs * themselves and (b) the same machine already holds the dev's other CLI * sessions (claude, vercel, etc.) at similar trust level. */ export const VERCEL_PROVIDER_ID = "vercel" as const; export const CLOUDFLARE_PAGES_PROVIDER_ID = "cloudflare-pages" as const; export type DeployProviderId = | typeof VERCEL_PROVIDER_ID | typeof CLOUDFLARE_PAGES_PROVIDER_ID; export const SAVED_VERCEL_TOKEN_MASK = "saved-vercel-token"; export const SAVED_CLOUDFLARE_TOKEN_MASK = "saved-cloudflare-token"; /** Stored on-disk shape. Different providers populate different fields. */ export type DeployConfig = { token?: string; // Vercel teamId?: string; teamSlug?: string; // Cloudflare Pages accountId?: string; /** Legacy: pre-computed Pages project name. New writes leave this empty * and the deployer derives it per-task. */ projectName?: string; }; /** Public-facing shape returned to the client. Token is masked. */ export type PublicDeployConfig = { providerId: DeployProviderId; configured: boolean; tokenMask: string; teamId?: string; teamSlug?: string; accountId?: string; projectName?: string; target: "preview"; }; export class DeployError extends Error { status: number; details?: unknown; code?: string; constructor(message: string, status = 400, details?: unknown, code?: string) { super(message); this.name = "DeployError"; this.status = status; this.details = details; this.code = code; } } export function deployConfigPath(providerId: DeployProviderId): string { // `HTML_ANYTHING_USER_STATE_DIR` lets tests / sandboxed environments // redirect storage. Otherwise fall back to `~/.html-anything/`. const base = process.env.HTML_ANYTHING_USER_STATE_DIR || path.join(homedir(), ".html-anything"); const file = providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? "cloudflare-pages.json" : "vercel.json"; return path.join(base, file); } function isEnoent(err: unknown): err is NodeJS.ErrnoException { return ( typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT" ); } async function writeDeployConfigFile( file: string, config: DeployConfig, ): Promise { await fsp.mkdir(path.dirname(file), { recursive: true }); await fsp.writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, }); // mode flag on writeFile only sets perms when the file is created; force // chmod for the overwrite case. Best-effort on filesystems that don't // support it (e.g. Windows / FAT32). try { chmodSync(file, 0o600); } catch { /* ignore */ } } export async function readVercelConfig(): Promise { try { const raw = await fsp.readFile( deployConfigPath(VERCEL_PROVIDER_ID), "utf8", ); const parsed = JSON.parse(raw) as Partial; return { token: typeof parsed.token === "string" ? parsed.token : "", teamId: typeof parsed.teamId === "string" ? parsed.teamId : "", teamSlug: typeof parsed.teamSlug === "string" ? parsed.teamSlug : "", }; } catch (err) { if (isEnoent(err)) return { token: "", teamId: "", teamSlug: "" }; throw err; } } export async function readCloudflarePagesConfig(): Promise { try { const raw = await fsp.readFile( deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), "utf8", ); const parsed = JSON.parse(raw) as Partial; return { token: typeof parsed.token === "string" ? parsed.token : "", accountId: typeof parsed.accountId === "string" ? parsed.accountId : "", projectName: typeof parsed.projectName === "string" ? parsed.projectName : "", }; } catch (err) { if (isEnoent(err)) return { token: "", accountId: "", projectName: "" }; throw err; } } export async function readDeployConfig( providerId: DeployProviderId, ): Promise { return providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? readCloudflarePagesConfig() : readVercelConfig(); } export async function writeVercelConfig( input: Partial, ): Promise { const current = await readVercelConfig(); const tokenInput = typeof input?.token === "string" ? input.token.trim() : ""; // The mask string is what the client sends back when it wants to keep // the existing token. Don't overwrite real value with a mask. const next: DeployConfig = { token: tokenInput && tokenInput !== SAVED_VERCEL_TOKEN_MASK ? tokenInput : current.token, teamId: typeof input?.teamId === "string" ? input.teamId.trim() : current.teamId, teamSlug: typeof input?.teamSlug === "string" ? input.teamSlug.trim() : current.teamSlug, }; if (!next.token) { throw new DeployError("Vercel API token is required.", 400); } await writeDeployConfigFile(deployConfigPath(VERCEL_PROVIDER_ID), next); return publicVercelConfig(next); } export async function writeCloudflarePagesConfig( input: Partial, ): Promise { const current = await readCloudflarePagesConfig(); const tokenInput = typeof input?.token === "string" ? input.token.trim() : ""; const next: DeployConfig = { token: tokenInput && tokenInput !== SAVED_CLOUDFLARE_TOKEN_MASK ? tokenInput : current.token, accountId: typeof input?.accountId === "string" ? input.accountId.trim() : current.accountId, // Pages project name is derived per-task by the deployer (see // `cloudflarePagesProjectNameForTask`), so we no longer persist a // user-set value. Intentionally cleared on write. projectName: "", }; if (!next.token) { throw new DeployError("Cloudflare API token is required.", 400); } if (!next.accountId) { throw new DeployError("Cloudflare account ID is required.", 400); } await writeDeployConfigFile( deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), next, ); return publicCloudflarePagesConfig(next); } export async function writeDeployConfig( providerId: DeployProviderId, input: Partial = {}, ): Promise { return providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? writeCloudflarePagesConfig(input) : writeVercelConfig(input); } // `writeDeployConfig` requires a non-empty token, so it can't be used to clear // a previously saved configuration — Settings → Clear has to delete the file // outright. ENOENT means it was already gone, which we treat as success so the // UI ends up in the same "unconfigured" state either way. export async function clearDeployConfig( providerId: DeployProviderId, ): Promise { try { await fsp.unlink(deployConfigPath(providerId)); } catch (err) { if (!isEnoent(err)) throw err; } return publicDeployConfigForProvider(providerId, {}); } export function publicVercelConfig( config: Partial, ): PublicDeployConfig { return { providerId: VERCEL_PROVIDER_ID, configured: Boolean(config.token), tokenMask: config.token ? SAVED_VERCEL_TOKEN_MASK : "", teamId: config.teamId || "", teamSlug: config.teamSlug || "", target: "preview", }; } export function publicCloudflarePagesConfig( config: Partial, ): PublicDeployConfig { return { providerId: CLOUDFLARE_PAGES_PROVIDER_ID, configured: Boolean(config.token && config.accountId), tokenMask: config.token ? SAVED_CLOUDFLARE_TOKEN_MASK : "", accountId: config.accountId || "", projectName: config.projectName || "", target: "preview", }; } export function publicDeployConfigForProvider( providerId: DeployProviderId, config: Partial = {}, ): PublicDeployConfig { return providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? publicCloudflarePagesConfig(config) : publicVercelConfig(config); } export function isDeployProviderId(value: unknown): value is DeployProviderId { return ( value === VERCEL_PROVIDER_ID || value === CLOUDFLARE_PAGES_PROVIDER_ID ); }