diff --git a/Cargo.lock b/Cargo.lock index a1804ed1b..744d31492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fabro-install" +version = "0.208.0-nightly.0" +dependencies = [ + "anyhow", + "base64", + "fabro-config", + "fabro-types", + "fabro-vault", + "ring", + "tempfile", + "toml 0.8.23", +] + [[package]] name = "fabro-interview" version = "0.208.0-nightly.0" @@ -1932,6 +1946,7 @@ dependencies = [ "fabro-graphviz", "fabro-hooks", "fabro-http", + "fabro-install", "fabro-interview", "fabro-llm", "fabro-model", diff --git a/Dockerfile b/Dockerfile index 6ad1d4a01..29ff48dbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,16 +24,14 @@ RUN apk add --no-cache \ tini \ && addgroup -S -g 1000 fabro \ && adduser -S -u 1000 -G fabro -h /var/fabro -s /sbin/nologin fabro \ - && install -d -o fabro -g fabro -m 0755 /var/fabro /storage \ - && install -d -m 0755 /etc/fabro + && install -d -o fabro -g fabro -m 0755 /var/fabro /storage COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro -COPY docker/settings.toml /etc/fabro/settings.toml COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint -ENV FABRO_HOME=/var/fabro \ - FABRO_CONFIG=/etc/fabro/settings.toml +ENV FABRO_HOME=/storage/.home \ + FABRO_STORAGE_DIR=/storage VOLUME ["/storage"] EXPOSE 32276 diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx index 542dec68a..d043cfc03 100644 --- a/apps/fabro-web/app/entry.tsx +++ b/apps/fabro-web/app/entry.tsx @@ -1,9 +1,19 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; +import { installRoutes } from "./install-router"; +import { resolveFabroMode } from "./mode"; import { routes } from "./router"; -const router = createBrowserRouter(routes); +declare global { + interface Window { + __FABRO_MODE__?: string; + } +} + +const router = createBrowserRouter( + resolveFabroMode(window.__FABRO_MODE__) === "install" ? installRoutes : routes, +); const rootElement = document.getElementById("root"); if (!rootElement) { diff --git a/apps/fabro-web/app/install-api.test.ts b/apps/fabro-web/app/install-api.test.ts new file mode 100644 index 000000000..8ead4355a --- /dev/null +++ b/apps/fabro-web/app/install-api.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { buildGithubOwnerValue, readInstallError } from "./install-api"; + +describe("readInstallError", () => { + test("prefers the structured install error payload", async () => { + const response = new Response(JSON.stringify({ error: "invalid token" }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + readInstallError(response, "install request failed"), + ).resolves.toBe("invalid token"); + }); + + test("falls back to the provided message when the body is not structured JSON", async () => { + const response = new Response("boom", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + + await expect( + readInstallError(response, "install request failed"), + ).resolves.toBe("install request failed (500)"); + }); +}); + +describe("buildGithubOwnerValue", () => { + test("uses personal for personal app installs", () => { + expect(buildGithubOwnerValue("personal", "")).toBe("personal"); + }); + + test("formats organization owners with the expected prefix", () => { + expect(buildGithubOwnerValue("org", " acme ")).toBe("org:acme"); + }); +}); diff --git a/apps/fabro-web/app/install-api.ts b/apps/fabro-web/app/install-api.ts new file mode 100644 index 000000000..7ee45540e --- /dev/null +++ b/apps/fabro-web/app/install-api.ts @@ -0,0 +1,208 @@ +export interface InstallSessionResponse { + completed_steps: string[]; + llm: + | { + providers: Array<{ + provider: string; + configured: boolean; + openai_base_url?: string | null; + }>; + } + | null; + server: { canonical_url: string } | null; + github: + | { + strategy: string; + username?: string; + owner?: string; + app_name?: string; + slug?: string; + allowed_username?: string; + } + | null; + prefill: { canonical_url: string }; +} + +export interface InstallFinishResponse { + status: "completing"; + restart_url: string; + dev_token: string; +} + +export interface InstallLlmProviderInput { + provider: string; + api_key: string; + openai_base_url?: string | null; +} + +export interface InstallGithubAppManifestInput { + owner: string; + app_name: string; + allowed_username: string; +} + +export interface InstallGithubAppManifestResponse { + manifest: Record; + github_form_action: string; +} + +const INSTALL_TOKEN_KEY = "fabro-install-token"; + +export function readStoredInstallToken(): string | null { + try { + return window.sessionStorage.getItem(INSTALL_TOKEN_KEY); + } catch { + return null; + } +} + +export function persistInstallToken(token: string | null): void { + try { + if (token) { + window.sessionStorage.setItem(INSTALL_TOKEN_KEY, token); + } else { + window.sessionStorage.removeItem(INSTALL_TOKEN_KEY); + } + } catch { + // best-effort only + } +} + +async function installFetch(path: string, token: string, init?: RequestInit): Promise { + return fetch(path, { + ...init, + headers: { + ...(init?.headers ?? {}), + Authorization: `Bearer ${token}`, + }, + }); +} + +export async function readInstallError( + response: Response, + fallback: string, +): Promise { + try { + const body = (await response.clone().json()) as { error?: string }; + if (body.error) return body.error; + } catch { + // fall through to the default message + } + return `${fallback} (${response.status})`; +} + +export function buildGithubOwnerValue( + ownerKind: "personal" | "org", + organizationSlug: string, +): string { + return ownerKind === "org" + ? `org:${organizationSlug.trim()}` + : "personal"; +} + +export async function getInstallSession(token: string): Promise { + const response = await installFetch("/install/session", token); + if (!response.ok) { + throw new Error(await readInstallError(response, "install session request failed")); + } + return response.json() as Promise; +} + +export async function testInstallLlm( + token: string, + provider: InstallLlmProviderInput, +): Promise { + const response = await installFetch("/install/llm/test", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(provider), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install llm validation failed")); + } +} + +export async function putInstallLlm( + token: string, + providers: InstallLlmProviderInput[], +): Promise { + const response = await installFetch("/install/llm", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providers }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install llm request failed")); + } +} + +export async function putInstallServer(token: string, canonicalUrl: string): Promise { + const response = await installFetch("/install/server", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ canonical_url: canonicalUrl }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install server request failed")); + } +} + +export async function testInstallGithubToken( + token: string, + githubToken: string, +): Promise { + const response = await installFetch("/install/github/token/test", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: githubToken }), + }); + if (!response.ok) { + throw new Error( + await readInstallError(response, "install github token validation failed"), + ); + } + const body = (await response.json()) as { username: string }; + return body.username; +} + +export async function putInstallGithubToken( + token: string, + githubToken: string, + username: string, +): Promise { + const response = await installFetch("/install/github/token", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: githubToken, username }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install github token request failed")); + } +} + +export async function createInstallGithubAppManifest( + token: string, + input: InstallGithubAppManifestInput, +): Promise { + const response = await installFetch("/install/github/app/manifest", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new Error( + await readInstallError(response, "install github app manifest request failed"), + ); + } + return response.json() as Promise; +} + +export async function finishInstall(token: string): Promise { + const response = await installFetch("/install/finish", token, { + method: "POST", + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install finish request failed")); + } + return response.json() as Promise; +} diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx new file mode 100644 index 000000000..6e49949e5 --- /dev/null +++ b/apps/fabro-web/app/install-app.tsx @@ -0,0 +1,1121 @@ +import { startTransition, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Link, Navigate, useLocation, useNavigate } from "react-router"; + +import { + type InstallFinishResponse, + type InstallLlmProviderInput, + type InstallSessionResponse, + buildGithubOwnerValue, + createInstallGithubAppManifest, + finishInstall, + getInstallSession, + persistInstallToken, + putInstallGithubToken, + putInstallLlm, + putInstallServer, + readStoredInstallToken, + testInstallGithubToken, + testInstallLlm, +} from "./install-api"; +import { AuthLayout } from "./components/auth-layout"; +import { consumeInstallTokenFromUrl } from "./mode"; + +const INSTALL_STEPS = [ + { id: "welcome", label: "Welcome", href: "/install/welcome" }, + { id: "llm", label: "LLM", href: "/install/llm" }, + { id: "server", label: "Server", href: "/install/server" }, + { id: "github", label: "GitHub", href: "/install/github" }, + { id: "review", label: "Review", href: "/install/review" }, +] as const; + +const PROVIDERS = [ + { + id: "anthropic", + label: "Anthropic", + hint: "Claude API key.", + }, + { + id: "openai", + label: "OpenAI", + hint: "Responses API key.", + }, + { + id: "gemini", + label: "Gemini", + hint: "Google AI Studio API key.", + }, + { + id: "openai_compatible", + label: "OpenAI Compatible", + hint: "API key plus a custom base URL.", + }, +] as const; + +type StepId = (typeof INSTALL_STEPS)[number]["id"]; +type FinishState = InstallFinishResponse | null; +type GithubStrategy = "token" | "app"; +type GithubOwnerKind = "personal" | "org"; + +type ProviderSelection = Record< + string, + { + apiKey: string; + openaiBaseUrl: string; + } +>; + +export default function InstallApp() { + const navigate = useNavigate(); + const location = useLocation(); + const [installToken, setInstallToken] = useState(() => + readStoredInstallToken(), + ); + const [session, setSession] = useState(null); + const [loadingSession, setLoadingSession] = useState(false); + const [sessionError, setSessionError] = useState(null); + const [manualToken, setManualToken] = useState(""); + const [llmSelection, setLlmSelection] = useState(() => + defaultProviderSelection(), + ); + const [canonicalUrl, setCanonicalUrl] = useState(""); + const [githubStrategy, setGithubStrategy] = useState("token"); + const [githubToken, setGithubToken] = useState(""); + const [githubUsername, setGithubUsername] = useState(""); + const [githubOwnerKind, setGithubOwnerKind] = + useState("personal"); + const [githubOrganization, setGithubOrganization] = useState(""); + const [githubAppName, setGithubAppName] = useState("Fabro"); + const [githubAllowedUsername, setGithubAllowedUsername] = useState(""); + const [saveError, setSaveError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [finishState, setFinishState] = useState(null); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href); + if (!token) return; + + persistInstallToken(token); + setInstallToken(token); + window.history.replaceState(window.history.state, "", sanitizedUrl); + }, []); + + useEffect(() => { + setSaveError(null); + }, [location.pathname]); + + useEffect(() => { + if (!installToken) { + setSession(null); + return; + } + + let cancelled = false; + setLoadingSession(true); + setSessionError(null); + getInstallSession(installToken) + .then((nextSession) => { + if (cancelled) return; + setSession(nextSession); + setCanonicalUrl((current) => + current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url, + ); + setLlmSelection((current) => + hydrateProviderSelection(current, nextSession), + ); + if (nextSession.github?.strategy === "app") { + setGithubStrategy("app"); + const owner = nextSession.github.owner ?? "personal"; + if (owner.startsWith("org:")) { + setGithubOwnerKind("org"); + setGithubOrganization(owner.slice(4)); + } else { + setGithubOwnerKind("personal"); + setGithubOrganization(""); + } + setGithubAppName(nextSession.github.app_name || "Fabro"); + setGithubAllowedUsername(nextSession.github.allowed_username || ""); + } else if (nextSession.github?.strategy === "token") { + setGithubStrategy("token"); + setGithubUsername(nextSession.github.username || ""); + } + }) + .catch((error) => { + if (cancelled) return; + setSession(null); + setSessionError(error instanceof Error ? error.message : "Install session failed"); + }) + .finally(() => { + if (!cancelled) setLoadingSession(false); + }); + + return () => { + cancelled = true; + }; + }, [installToken]); + + useEffect(() => { + if (!installToken || !session) return; + if ((location.pathname === "/" || location.pathname === "/install") && !finishState) { + startTransition(() => { + navigate("/install/welcome", { replace: true }); + }); + } + }, [finishState, installToken, location.pathname, navigate, session]); + + useEffect(() => { + if (!finishState) return; + + setTimedOut(false); + const deadline = window.setTimeout(() => { + setTimedOut(true); + }, 30_000); + + const interval = window.setInterval(async () => { + try { + const response = await fetch("/health"); + if (!response.ok) { + window.location.href = finishState.restart_url; + return; + } + const body = (await response.json()) as { mode?: string }; + if (body.mode !== "install") { + window.location.href = finishState.restart_url; + } + } catch { + window.location.href = finishState.restart_url; + } + }, 1_000); + + return () => { + window.clearTimeout(deadline); + window.clearInterval(interval); + }; + }, [finishState]); + + const currentStep = useMemo(() => { + if (location.pathname.startsWith("/install/llm")) return "llm"; + if (location.pathname.startsWith("/install/server")) return "server"; + if (location.pathname.startsWith("/install/github")) return "github"; + if (location.pathname.startsWith("/install/review")) return "review"; + return "welcome"; + }, [location.pathname]); + + const completedSteps = new Set(session?.completed_steps ?? []); + + if (!installToken) { + return ( + { + const nextToken = manualToken.trim(); + if (!nextToken) { + setSessionError("Paste the install token from the server logs."); + return; + } + persistInstallToken(nextToken); + setInstallToken(nextToken); + setSessionError(null); + }} + /> + ); + } + + if (loadingSession && !session) { + return ( + + + Reading the current install state from the server. + + + ); + } + + if (sessionError && !session) { + return ( + { + const nextToken = manualToken.trim(); + persistInstallToken(nextToken); + setInstallToken(nextToken || null); + }} + /> + ); + } + + if (finishState && location.pathname !== "/install/finishing") { + return ; + } + + return ( + + {location.pathname === "/install/finishing" ? ( + + ) : location.pathname === "/install/llm" ? ( + { + const providers = PROVIDERS.map(({ id }) => { + const current = llmSelection[id] ?? { apiKey: "", openaiBaseUrl: "" }; + return { + provider: id, + api_key: current.apiKey.trim(), + openai_base_url: current.openaiBaseUrl.trim() || null, + }; + }).filter((provider) => provider.api_key.length > 0); + + if (providers.length === 0) { + setSaveError("Add at least one provider API key before continuing."); + return; + } + + setSubmitting(true); + setSaveError(null); + try { + for (const provider of providers) { + await testInstallLlm(installToken, provider); + } + await putInstallLlm(installToken, providers); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/server"); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to save LLM settings.", + ); + } finally { + setSubmitting(false); + } + }} + > + + + ) : location.pathname === "/install/server" ? ( + { + if (!canonicalUrl.trim()) { + setSaveError("Enter the canonical server URL before continuing."); + return; + } + setSubmitting(true); + setSaveError(null); + try { + await putInstallServer(installToken, canonicalUrl.trim()); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/github"); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to save server settings.", + ); + } finally { + setSubmitting(false); + } + }} + > + + setCanonicalUrl(event.target.value)} + className={INPUT_CLASS} + placeholder="https://fabro.example.com" + /> + + + ) : location.pathname === "/install/github/done" ? ( + + ) : location.pathname === "/install/github" ? ( + { + setSubmitting(true); + setSaveError(null); + try { + if (githubStrategy === "token") { + if (!githubToken.trim()) { + setSaveError("Provide the GitHub token before continuing."); + return; + } + const username = await testInstallGithubToken( + installToken, + githubToken.trim(), + ); + setGithubUsername(username); + await putInstallGithubToken(installToken, githubToken.trim(), username); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/review"); + return; + } + + if (githubOwnerKind === "org" && !githubOrganization.trim()) { + setSaveError("Enter the organization slug for the GitHub App."); + return; + } + if (!githubAppName.trim()) { + setSaveError("Enter the GitHub App name before continuing."); + return; + } + if (!githubAllowedUsername.trim()) { + setSaveError("Enter the GitHub username that should be allowed to log in."); + return; + } + + const manifest = await createInstallGithubAppManifest(installToken, { + owner: buildGithubOwnerValue(githubOwnerKind, githubOrganization), + app_name: githubAppName.trim(), + allowed_username: githubAllowedUsername.trim(), + }); + submitGithubManifest(manifest.github_form_action, manifest.manifest); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to start GitHub setup.", + ); + } finally { + setSubmitting(false); + } + }} + > + + {githubStrategy === "token" ? ( + <> + +