fabro/apps/fabro-web/app/install-app.tsx
Bryan Helmkamp 160f587a1d
feat(install): enable only allowed sandbox providers
Add an "Allow local sandboxes" checkbox (checked by default) below the
Docker/Daytona choice in the web installer, and stop unconditionally
enabling all three providers when generating settings.toml. The wizard
now enables only the selected runtime plus local when allowed; the
unselected runtime is written as `enabled = false` so the config
resolver does not default it back on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:39:31 -04:00

2455 lines
76 KiB
TypeScript

import { useMemo, useReducer, useRef, useState } from "react";
import type { FormEvent, ReactNode, Ref } from "react";
import {
Link,
Navigate,
useLocation,
useNavigate,
} from "react-router";
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowTopRightOnSquareIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ClipboardDocumentCheckIcon,
ClipboardIcon,
EyeIcon,
EyeSlashIcon,
} from "@heroicons/react/16/solid";
import {
type InstallFinishResponse,
type InstallGithubAppOwner,
type InstallLlmProviderInput,
type InstallObjectStoreInput,
type InstallSandboxInput,
type InstallSessionResponse,
createInstallGithubAppManifest,
finishInstall,
getInstallSession,
persistInstallToken,
putInstallGithubToken,
putInstallLlm,
putInstallObjectStore,
putInstallSandbox,
putInstallServer,
readStoredInstallToken,
testInstallGithubToken,
testInstallLlm,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
import { INSTALL_PROVIDERS } from "./install-config";
import { useInstallSessionQuery } from "./install-query";
import {
CopyButton,
ErrorMessage,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "./components/ui";
import { LoadingState } from "./components/state";
import {
useInstallGithubCallbackError,
useInstallRestartHealthPolling,
useInstallTokenFromUrl,
} from "./hooks/use-install-effects";
import { consumeInstallTokenFromUrl } from "./mode";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
{ id: "server", label: "Server", href: "/install/server" },
{ id: "object_store", label: "Storage", href: "/install/object-store" },
{ id: "sandbox", label: "Sandbox", href: "/install/sandbox" },
{ id: "llm", label: "LLMs", href: "/install/llm" },
{ id: "github", label: "GitHub", href: "/install/github" },
{ id: "review", label: "Review", href: "/install/review" },
] as const;
const STEPPER_STEPS = INSTALL_STEPS.slice(1);
type StepId = (typeof INSTALL_STEPS)[number]["id"];
type FinishState = InstallFinishResponse | null;
type GithubStrategy = "token" | "app";
type GithubOwnerKind = "personal" | "org";
type SessionState =
| { status: "idle" }
| { status: "loading"; token: string }
| { status: "error"; token: string | null; message: string }
| { status: "ready"; token: string; data: InstallSessionResponse };
type TokenForm = { token: string; username: string };
type AppForm = {
owner: InstallGithubAppOwner;
appName: string;
allowedUsername: string;
};
type ProviderSelection = Record<string, { apiKey: string }>;
type ObjectStoreProvider = "local" | "s3";
type ObjectStoreCredentialMode = "runtime" | "access_key";
type ObjectStoreForm = {
provider: ObjectStoreProvider;
localRoot: string;
bucket: string;
region: string;
credentialMode: ObjectStoreCredentialMode;
accessKeyId: string;
secretAccessKey: string;
manualCredentialsSaved: boolean;
};
type SandboxProvider = NonNullable<InstallSandboxInput["provider"]>;
type SandboxForm = {
provider: SandboxProvider;
apiKey: string;
apiKeySaved: boolean;
allowLocal: boolean;
};
type RunStepSubmit = (args: {
action: () => Promise<void>;
fallback: string;
next?: string;
}) => Promise<void>;
type InstallDispatch = (action: InstallAction) => void;
type InstallState = {
sessionState: SessionState;
manualToken: string;
llmSelection: ProviderSelection;
objectStoreForm: ObjectStoreForm;
sandboxForm: SandboxForm;
canonicalUrl: string;
githubStrategy: GithubStrategy;
tokenForm: TokenForm;
appForm: AppForm;
saveError: string | null;
submitting: boolean;
finishState: FinishState;
timedOut: boolean;
};
type InstallAction =
| { type: "manualTokenChanged"; value: string }
| { type: "sessionCleared" }
| { type: "sessionReady"; token: string; session: InstallSessionResponse }
| { type: "sessionFailed"; token: string | null; message: string }
| { type: "saveErrorChanged"; message: string | null }
| { type: "submittingChanged"; submitting: boolean }
| { type: "timedOutChanged"; timedOut: boolean }
| { type: "finishStarted"; result: FinishState }
| { type: "canonicalUrlChanged"; value: string }
| { type: "llmProviderApiKeyChanged"; provider: string; apiKey: string }
| { type: "llmSelectionChanged"; value: ProviderSelection }
| { type: "objectStorePatched"; patch: Partial<ObjectStoreForm> }
| { type: "sandboxPatched"; patch: Partial<SandboxForm> }
| { type: "githubStrategyChanged"; strategy: GithubStrategy }
| { type: "tokenFormPatched"; patch: Partial<TokenForm> }
| { type: "tokenFormReplaced"; value: TokenForm }
| { type: "appFormPatched"; patch: Partial<AppForm> }
| { type: "githubOwnerChanged"; kind: GithubOwnerKind }
| { type: "githubOrgSlugChanged"; slug: string };
function initialInstallState(): InstallState {
return {
sessionState: { status: "idle" },
manualToken: "",
llmSelection: defaultProviderSelection(),
objectStoreForm: defaultObjectStoreForm(),
sandboxForm: defaultSandboxForm(),
canonicalUrl: "",
githubStrategy: "token",
tokenForm: { token: "", username: "" },
appForm: {
owner: { kind: "personal" },
appName: "Fabro",
allowedUsername: "",
},
saveError: null,
submitting: false,
finishState: null,
timedOut: false,
};
}
function hydrateInstallState(
state: InstallState,
token: string,
session: InstallSessionResponse,
): InstallState {
let githubStrategy = state.githubStrategy;
let tokenForm = state.tokenForm;
let appForm = state.appForm;
if (session.github?.strategy === "app") {
githubStrategy = "app";
appForm = {
owner: session.github.owner ?? { kind: "personal" },
appName: session.github.app_name || "Fabro",
allowedUsername: session.github.allowed_username || "",
};
} else if (session.github?.strategy === "token") {
githubStrategy = "token";
tokenForm = {
...state.tokenForm,
username: session.github.username || state.tokenForm.username,
};
}
return {
...state,
sessionState: { status: "ready", token, data: session },
canonicalUrl:
state.canonicalUrl ||
session.server?.canonical_url ||
session.prefill.canonical_url,
objectStoreForm: hydrateObjectStoreForm(session),
sandboxForm: hydrateSandboxForm(state.sandboxForm, session),
llmSelection: hydrateProviderSelection(state.llmSelection, session),
githubStrategy,
tokenForm,
appForm,
};
}
function installReducer(state: InstallState, action: InstallAction): InstallState {
switch (action.type) {
case "manualTokenChanged":
return { ...state, manualToken: action.value };
case "sessionCleared":
return { ...state, sessionState: { status: "idle" } };
case "sessionReady":
return hydrateInstallState(state, action.token, action.session);
case "sessionFailed":
return { ...state, sessionState: { status: "error", token: action.token, message: action.message } };
case "saveErrorChanged":
return { ...state, saveError: action.message };
case "submittingChanged":
return { ...state, submitting: action.submitting };
case "timedOutChanged":
return { ...state, timedOut: action.timedOut };
case "finishStarted":
return { ...state, finishState: action.result };
case "canonicalUrlChanged":
return { ...state, canonicalUrl: action.value };
case "llmProviderApiKeyChanged":
return {
...state,
llmSelection: {
...state.llmSelection,
[action.provider]: { apiKey: action.apiKey },
},
};
case "llmSelectionChanged":
return { ...state, llmSelection: action.value };
case "objectStorePatched":
return {
...state,
objectStoreForm: { ...state.objectStoreForm, ...action.patch },
};
case "sandboxPatched":
return { ...state, sandboxForm: { ...state.sandboxForm, ...action.patch } };
case "githubStrategyChanged":
return { ...state, githubStrategy: action.strategy };
case "tokenFormPatched":
return { ...state, tokenForm: { ...state.tokenForm, ...action.patch } };
case "tokenFormReplaced":
return { ...state, tokenForm: action.value };
case "appFormPatched":
return { ...state, appForm: { ...state.appForm, ...action.patch } };
case "githubOwnerChanged":
return {
...state,
appForm: {
...state.appForm,
owner:
action.kind === "org"
? {
kind: "org",
slug: state.appForm.owner.kind === "org" ? state.appForm.owner.slug ?? "" : "",
}
: { kind: "personal" },
},
};
case "githubOrgSlugChanged":
return {
...state,
appForm: {
...state.appForm,
owner: { kind: "org", slug: action.slug },
},
};
}
}
function installSessionErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Install session failed";
}
function sessionStateForInstallToken(
installToken: string | null,
sessionState: SessionState,
queryError: unknown,
): SessionState {
if (!installToken) {
return sessionState.status === "error" && sessionState.token === null
? sessionState
: { status: "idle" };
}
if (
(sessionState.status === "ready" || sessionState.status === "error") &&
sessionState.token === installToken
) {
return sessionState;
}
if (queryError) {
return {
status: "error",
token: installToken,
message: installSessionErrorMessage(queryError),
};
}
return { status: "loading", token: installToken };
}
function readInitialInstallToken(): string | null {
const stored = readStoredInstallToken();
if (stored) return stored;
if (typeof window === "undefined") return null;
return consumeInstallTokenFromUrl(window.location.href).token;
}
/**
* Coordinates install-mode browser integrations: token/error URL scrubbing,
* install-session query state, and restart health polling. Timers, intervals,
* and in-flight requests are cancelled when their install identity changes.
*/
function useInstallController() {
const { pathname } = useLocation();
const [installToken, setInstallToken] = useState<string | null>(() =>
readInitialInstallToken(),
);
const [installState, dispatchInstall] = useReducer(
installReducer,
undefined,
initialInstallState,
);
const { finishState } = installState;
const installSessionQuery = useInstallSessionQuery(installToken, {
onSuccess: (session) => {
if (!installToken) return;
dispatchInstall({ type: "sessionReady", token: installToken, session });
},
onError: (error) => {
dispatchInstall({
type: "sessionFailed",
token: installToken,
message: installSessionErrorMessage(error),
});
},
});
useInstallTokenFromUrl({ setInstallToken });
useInstallGithubCallbackError({ dispatchInstall, pathname });
useInstallRestartHealthPolling({ dispatchInstall, finishState });
const sessionState = sessionStateForInstallToken(
installToken,
installState.sessionState,
installSessionQuery.error,
);
const controllerState =
sessionState === installState.sessionState
? installState
: { ...installState, sessionState };
const refreshInstallSession = async () => {
if (!installToken) {
throw new Error("Install token is required to refresh the session.");
}
const nextSession = await getInstallSession(installToken);
dispatchInstall({
type: "sessionReady",
token: installToken,
session: nextSession,
});
await installSessionQuery.mutate(nextSession, { revalidate: false });
return nextSession;
};
return {
pathname,
installToken,
setInstallToken,
installState: controllerState,
dispatchInstall,
refreshInstallSession,
};
}
export default function InstallApp() {
const navigate = useNavigate();
const {
pathname,
installToken,
setInstallToken,
installState,
dispatchInstall,
refreshInstallSession,
} = useInstallController();
const {
sessionState,
manualToken,
llmSelection,
objectStoreForm,
sandboxForm,
canonicalUrl,
githubStrategy,
tokenForm,
appForm,
saveError,
submitting,
finishState,
timedOut,
} = installState;
const session = sessionState.status === "ready" ? sessionState.data : null;
const currentStep = useMemo<StepId>(
() =>
STEPPER_STEPS.find((step) => pathname.startsWith(step.href))?.id ??
"welcome",
[pathname],
);
const completedSteps = new Set(session?.completed_steps ?? []);
const sessionError =
sessionState.status === "error" ? sessionState.message : null;
if (!installToken) {
return (
<TokenEntryScreen
manualToken={manualToken}
onManualTokenChange={(value) =>
dispatchInstall({ type: "manualTokenChanged", value })
}
sessionError={sessionError}
onSubmit={() => {
const nextToken = manualToken.trim();
if (!nextToken) {
dispatchInstall({
type: "sessionFailed",
token: null,
message: "Paste the install token from the server logs.",
});
return;
}
persistInstallToken(nextToken);
setInstallToken(nextToken);
dispatchInstall({ type: "sessionCleared" });
}}
/>
);
}
const runStepSubmit: RunStepSubmit = async (args) => {
// Re-entrancy guard: the StepPanel form guards its own onSubmit, but the
// LLM step's "Skip LLM setup" button calls this directly, so a fast
// double-click could otherwise fire two requests before `submitting`
// re-renders the disabled state.
if (submitting) return;
dispatchInstall({ type: "submittingChanged", submitting: true });
dispatchInstall({ type: "saveErrorChanged", message: null });
try {
await args.action();
if (args.next) {
await refreshInstallSession();
navigate(args.next);
}
} catch (error) {
dispatchInstall({
type: "saveErrorChanged",
message: error instanceof Error ? error.message : args.fallback,
});
} finally {
dispatchInstall({ type: "submittingChanged", submitting: false });
}
};
if (sessionState.status === "error") {
return (
<TokenEntryScreen
manualToken={manualToken}
onManualTokenChange={(value) =>
dispatchInstall({ type: "manualTokenChanged", value })
}
sessionError={sessionError}
onSubmit={() => {
const nextToken = manualToken.trim();
persistInstallToken(nextToken);
setInstallToken(nextToken || null);
}}
/>
);
}
// Covers both sessionState "loading" AND the brief "idle" window before the
// install session query reports data. Without this guard,
// screens like GithubAppDoneScreen see `session == null` and navigate away
// before the first fetch finishes — trapping the user in a redirect loop.
if (!session) {
return (
<InstallLayout currentStep={currentStep} completedSteps={completedSteps}>
<LoadingState label="Connecting to install session…" />
</InstallLayout>
);
}
if ((pathname === "/" || pathname === "/install") && !finishState) {
return <Navigate to="/install/welcome" replace />;
}
if (finishState && pathname !== "/install/finishing") {
return <Navigate to="/install/finishing" replace />;
}
return (
<InstallLayout currentStep={currentStep} completedSteps={completedSteps}>
{pathname === "/install/finishing" ? (
<FinishingScreen finishState={finishState} timedOut={timedOut} />
) : pathname === "/install/llm" ? (
<LlmStep
installToken={installToken}
llmSelection={llmSelection}
saveError={saveError}
submitting={submitting}
runStepSubmit={runStepSubmit}
dispatchInstall={dispatchInstall}
/>
) : pathname === "/install/server" ? (
<ServerStep
installToken={installToken}
canonicalUrl={canonicalUrl}
saveError={saveError}
submitting={submitting}
runStepSubmit={runStepSubmit}
dispatchInstall={dispatchInstall}
/>
) : pathname === "/install/object-store" ? (
<ObjectStoreStep
installToken={installToken}
objectStoreForm={objectStoreForm}
saveError={saveError}
submitting={submitting}
runStepSubmit={runStepSubmit}
dispatchInstall={dispatchInstall}
/>
) : pathname === "/install/sandbox" ? (
<SandboxStep
installToken={installToken}
sandboxForm={sandboxForm}
saveError={saveError}
submitting={submitting}
runStepSubmit={runStepSubmit}
dispatchInstall={dispatchInstall}
/>
) : pathname === "/install/github/done" ? (
<GithubAppDoneScreen github={session?.github} />
) : pathname === "/install/github" ? (
<GithubStep
installToken={installToken}
session={session}
githubStrategy={githubStrategy}
tokenForm={tokenForm}
appForm={appForm}
saveError={saveError}
submitting={submitting}
runStepSubmit={runStepSubmit}
dispatchInstall={dispatchInstall}
/>
) : pathname === "/install/review" ? (
<ReviewScreen
session={session}
error={saveError}
submitting={submitting}
onInstall={async () => {
dispatchInstall({ type: "submittingChanged", submitting: true });
dispatchInstall({ type: "saveErrorChanged", message: null });
try {
const result = await finishInstall(installToken);
dispatchInstall({ type: "finishStarted", result });
navigate("/install/finishing");
} catch (error) {
dispatchInstall({
type: "saveErrorChanged",
message: error instanceof Error ? error.message : "Install failed.",
});
} finally {
dispatchInstall({ type: "submittingChanged", submitting: false });
}
}}
/>
) : (
<WelcomeScreen />
)}
</InstallLayout>
);
}
function LlmStep({
installToken,
llmSelection,
saveError,
submitting,
runStepSubmit,
dispatchInstall,
}: {
installToken: string;
llmSelection: ProviderSelection;
saveError: string | null;
submitting: boolean;
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
return (
<StepPanel
title="Add your LLM credentials"
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank, or skip LLM setup entirely and configure providers later."
error={saveError}
submitting={submitting}
backHref="/install/sandbox"
secondaryAction={
<button
type="button"
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
onClick={() => {
void runStepSubmit({
action: () => putInstallLlm(installToken, []),
fallback: "Failed to skip LLM setup.",
next: "/install/github",
});
}}
>
Skip LLM setup
</button>
}
onSubmit={async () => {
const providers: InstallLlmProviderInput[] = [];
for (const { id } of INSTALL_PROVIDERS) {
const current = llmSelection[id] ?? { apiKey: "" };
const provider = {
provider: id,
api_key: current.apiKey.trim(),
};
if (provider.api_key.length > 0) providers.push(provider);
}
if (providers.length === 0) {
dispatchInstall({
type: "saveErrorChanged",
message: "Add at least one provider API key before continuing.",
});
return;
}
await runStepSubmit({
action: async () => {
await Promise.all(
providers.map((provider) => testInstallLlm(installToken, provider)),
);
await putInstallLlm(installToken, providers);
},
fallback: "Failed to save LLM settings.",
next: "/install/github",
});
}}
>
<ProviderFields
value={llmSelection}
onProviderApiKeyChange={(provider, apiKey) =>
dispatchInstall({
type: "llmProviderApiKeyChanged",
provider,
apiKey,
})
}
/>
</StepPanel>
);
}
function ServerStep({
installToken,
canonicalUrl,
saveError,
submitting,
runStepSubmit,
dispatchInstall,
}: {
installToken: string;
canonicalUrl: string;
saveError: string | null;
submitting: boolean;
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
const canonicalUrlInputRef = useRef<HTMLInputElement>(null);
return (
<StepPanel
title="Confirm the public URL"
description="This is where operators will reach Fabro after setup. It's also the redirect target for the GitHub App callback."
error={saveError}
submitting={submitting}
backHref="/install/welcome"
onSubmit={async () => {
if (!canonicalUrl.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the canonical server URL before continuing.",
});
focusInput(canonicalUrlInputRef);
return;
}
await runStepSubmit({
action: () => putInstallServer(installToken, canonicalUrl.trim()),
fallback: "Failed to save server settings.",
next: "/install/object-store",
});
}}
>
<Field
label="Canonical URL"
hint="Auto-detected from forwarded headers when available."
>
<input
type="url"
name="canonical_url"
aria-label="Canonical URL"
ref={canonicalUrlInputRef}
value={canonicalUrl}
onChange={(event) =>
dispatchInstall({
type: "canonicalUrlChanged",
value: event.target.value,
})
}
className={INPUT_CLASS}
placeholder="https://fabro.example.com"
autoComplete="url"
spellCheck={false}
/>
</Field>
</StepPanel>
);
}
function ObjectStoreStep({
installToken,
objectStoreForm,
saveError,
submitting,
runStepSubmit,
dispatchInstall,
}: {
installToken: string;
objectStoreForm: ObjectStoreForm;
saveError: string | null;
submitting: boolean;
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
const localRootInputRef = useRef<HTMLInputElement>(null);
const bucketInputRef = useRef<HTMLInputElement>(null);
const regionInputRef = useRef<HTMLInputElement>(null);
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
const secretAccessKeyInputRef = useRef<HTMLInputElement>(null);
return (
<StepPanel
title="Choose the shared object store"
description="This configures the shared backend for both SlateDB and run artifacts. Fabro still keeps its local storage root on disk."
error={saveError}
submitting={submitting}
submittingLabel={
objectStoreForm.provider === "s3" ? "Checking access..." : "Saving..."
}
backHref="/install/server"
onSubmit={async () => {
if (objectStoreForm.provider === "local") {
if (!objectStoreForm.localRoot.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the local object-store directory before continuing.",
});
focusInput(localRootInputRef);
return;
}
} else {
if (!objectStoreForm.bucket.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the S3 bucket before continuing.",
});
focusInput(bucketInputRef);
return;
}
if (!objectStoreForm.region.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the AWS region before continuing.",
});
focusInput(regionInputRef);
return;
}
if (objectStoreForm.credentialMode === "access_key") {
const accessKeyId = objectStoreForm.accessKeyId.trim();
const secretAccessKey = objectStoreForm.secretAccessKey.trim();
const keepStoredCredentials =
objectStoreForm.manualCredentialsSaved &&
!accessKeyId &&
!secretAccessKey;
if (!keepStoredCredentials && !accessKeyId) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the AWS access key ID before continuing.",
});
focusInput(accessKeyIdInputRef);
return;
}
if (!keepStoredCredentials && !secretAccessKey) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the AWS secret access key before continuing.",
});
focusInput(secretAccessKeyInputRef);
return;
}
}
}
const payload = buildObjectStorePayload(objectStoreForm);
await runStepSubmit({
action: async () => {
if (objectStoreForm.provider === "s3") {
await testInstallObjectStore(installToken, payload);
}
await putInstallObjectStore(installToken, payload);
},
fallback: "Failed to save object-store settings.",
next: "/install/sandbox",
});
}}
>
<CardPicker
legend="Object store"
options={OBJECT_STORE_PROVIDER_OPTIONS}
value={objectStoreForm.provider}
onChange={(provider) => {
dispatchInstall({
type: "objectStorePatched",
patch: { provider },
});
if (provider === "s3") {
focusInput(bucketInputRef);
} else {
focusInput(localRootInputRef);
}
}}
/>
{objectStoreForm.provider === "s3" ? (
<div className="space-y-5">
<Field label="Bucket">
<input
ref={bucketInputRef}
name="object_store_bucket"
aria-label="Bucket"
value={objectStoreForm.bucket}
onChange={(event) =>
dispatchInstall({
type: "objectStorePatched",
patch: { bucket: event.target.value },
})
}
className={`${INPUT_CLASS} font-mono`}
placeholder="my-fabro-data"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<Field label="Region">
<input
ref={regionInputRef}
name="object_store_region"
aria-label="Region"
value={objectStoreForm.region}
onChange={(event) =>
dispatchInstall({
type: "objectStorePatched",
patch: { region: event.target.value },
})
}
className={`${INPUT_CLASS} font-mono`}
placeholder="us-east-1"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<CardPicker
legend="Credentials"
options={OBJECT_STORE_CREDENTIAL_MODE_OPTIONS}
value={objectStoreForm.credentialMode}
onChange={(credentialMode) => {
dispatchInstall({
type: "objectStorePatched",
patch: { credentialMode },
});
if (credentialMode === "access_key") {
focusInput(accessKeyIdInputRef);
}
}}
/>
{objectStoreForm.credentialMode === "access_key" ? (
<div className="space-y-5">
<Field label="AWS access key ID">
<input
ref={accessKeyIdInputRef}
id="aws_access_key_id"
name="aws_access_key_id"
aria-label="AWS access key ID"
value={objectStoreForm.accessKeyId}
onChange={(event) =>
dispatchInstall({
type: "objectStorePatched",
patch: { accessKeyId: event.target.value },
})
}
className={`${INPUT_CLASS} font-mono`}
placeholder="AKIA..."
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
</Field>
<Field label="AWS secret access key">
<PasswordInput
inputRef={secretAccessKeyInputRef}
id="aws_secret_access_key"
name="aws_secret_access_key"
value={objectStoreForm.secretAccessKey}
onChange={(value) =>
dispatchInstall({
type: "objectStorePatched",
patch: { secretAccessKey: value },
})
}
placeholder="Secret access key"
/>
</Field>
{objectStoreForm.manualCredentialsSaved ? (
<p className="text-xs text-fg-muted">
Credentials saved. Leave both fields blank to keep them, or
enter both fields to replace them.
</p>
) : null}
</div>
) : (
<p className="text-xs/5 text-fg-muted">
Fabro will use AWS credentials already provided by the runtime,
such as EC2, ECS, or IRSA-based auth.
</p>
)}
</div>
) : (
<div className="space-y-3">
<Field
label="Local directory"
hint="Shared root for SlateDB and run artifacts."
>
<input
ref={localRootInputRef}
name="object_store_local_root"
aria-label="Local directory"
value={objectStoreForm.localRoot}
onChange={(event) =>
dispatchInstall({
type: "objectStorePatched",
patch: { localRoot: event.target.value },
})
}
className={`${INPUT_CLASS} font-mono`}
placeholder="Local object-store directory"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will store SlateDB and run artifacts under this directory.
</p>
</div>
)}
</StepPanel>
);
}
function SandboxStep({
installToken,
sandboxForm,
saveError,
submitting,
runStepSubmit,
dispatchInstall,
}: {
installToken: string;
sandboxForm: SandboxForm;
saveError: string | null;
submitting: boolean;
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
const sandboxApiKeyInputRef = useRef<HTMLInputElement>(null);
return (
<StepPanel
title="Choose the sandbox runtime"
description="Workflows run inside this sandbox. Docker uses the host daemon; Daytona runs each sandbox in its cloud."
error={saveError}
submitting={submitting}
submittingLabel={
sandboxForm.provider === "daytona" ? "Checking access..." : "Saving..."
}
backHref="/install/object-store"
onSubmit={async () => {
if (sandboxForm.provider === "daytona") {
const apiKey = sandboxForm.apiKey.trim();
const keepStoredKey = sandboxForm.apiKeySaved && !apiKey;
if (!keepStoredKey && !apiKey) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the Daytona API key before continuing.",
});
focusInput(sandboxApiKeyInputRef);
return;
}
}
const payload = buildSandboxPayload(sandboxForm);
await runStepSubmit({
action: async () => {
if (sandboxForm.provider === "daytona") {
await testInstallSandbox(installToken, payload);
}
await putInstallSandbox(installToken, payload);
},
fallback: "Failed to save sandbox settings.",
next: "/install/llm",
});
}}
>
<CardPicker
legend="Sandbox runtime"
options={SANDBOX_PROVIDER_OPTIONS}
value={sandboxForm.provider}
onChange={(provider) => {
dispatchInstall({
type: "sandboxPatched",
patch: { provider },
});
if (provider === "daytona") {
focusInput(sandboxApiKeyInputRef);
}
}}
/>
<label className="flex cursor-pointer items-start gap-3 rounded-lg bg-overlay px-4 py-3.5 outline-1 -outline-offset-1 outline-white/10 transition-colors hover:bg-overlay-strong hover:outline-white/15">
<input
type="checkbox"
name="sandbox_allow_local"
checked={sandboxForm.allowLocal}
onChange={(event) =>
dispatchInstall({
type: "sandboxPatched",
patch: { allowLocal: event.target.checked },
})
}
className="mt-0.5 size-4 shrink-0 rounded border-white/20 bg-transparent accent-teal-500 focus:ring-1 focus:ring-teal-500/60"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-fg">
Allow local sandboxes
</span>
<span className="mt-1 block text-xs/5 text-fg-3">
Permit runs that use the local provider, which executes directly on
the Fabro host. The runtime selected above stays the default.
</span>
</span>
</label>
{sandboxForm.provider === "daytona" ? (
<div className="space-y-5">
<Field
label="Daytona API key"
hint={
sandboxForm.apiKeySaved
? "A key is already saved. Leave blank to keep using it."
: "Stored in the vault and exported to workflows as DAYTONA_API_KEY."
}
>
<input
ref={sandboxApiKeyInputRef}
type="password"
name="sandbox_api_key"
aria-label="Daytona API key"
value={sandboxForm.apiKey}
onChange={(event) =>
dispatchInstall({
type: "sandboxPatched",
patch: { apiKey: event.target.value },
})
}
className={`${INPUT_CLASS} font-mono`}
placeholder={sandboxForm.apiKeySaved ? "•••• (saved)" : "dtn_..."}
autoComplete="off"
spellCheck={false}
/>
</Field>
</div>
) : (
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will use the host Docker daemon. Make sure the server has
access to <code className="font-mono text-fg-2">/var/run/docker.sock</code>.
</p>
)}
</StepPanel>
);
}
function GithubStep({
installToken,
session,
githubStrategy,
tokenForm,
appForm,
saveError,
submitting,
runStepSubmit,
dispatchInstall,
}: {
installToken: string;
session: InstallSessionResponse;
githubStrategy: GithubStrategy;
tokenForm: TokenForm;
appForm: AppForm;
saveError: string | null;
submitting: boolean;
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
return (
<StepPanel
title="Connect GitHub"
description="Choose how Fabro should authenticate. Tokens are stored in the vault; apps hand off to GitHub and return here."
error={saveError}
submitting={submitting}
submitLabel={githubStrategy === "app" ? "Continue on GitHub" : "Continue"}
backHref="/install/llm"
onSubmit={async () => {
if (githubStrategy === "token") {
const trimmedToken = tokenForm.token.trim();
if (!trimmedToken) {
dispatchInstall({
type: "saveErrorChanged",
message: "Provide the GitHub token before continuing.",
});
return;
}
await runStepSubmit({
action: async () => {
const username = await testInstallGithubToken(installToken, trimmedToken);
dispatchInstall({
type: "tokenFormReplaced",
value: { token: trimmedToken, username },
});
await putInstallGithubToken(installToken, trimmedToken, username);
},
fallback: "Failed to start GitHub setup.",
next: "/install/review",
});
return;
}
const { owner, appName, allowedUsername } = appForm;
if (owner.kind === "org" && !(owner.slug ?? "").trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the organization slug for the GitHub App.",
});
return;
}
if (!appName.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the GitHub App name before continuing.",
});
return;
}
if (!allowedUsername.trim()) {
dispatchInstall({
type: "saveErrorChanged",
message: "Enter the GitHub username that should be allowed to log in.",
});
return;
}
await runStepSubmit({
action: async () => {
const manifest = await createInstallGithubAppManifest(installToken, {
owner:
owner.kind === "org"
? { kind: "org", slug: (owner.slug ?? "").trim() }
: { kind: "personal" },
app_name: appName.trim(),
allowed_username: allowedUsername.trim(),
});
submitGithubManifest(
manifest.github_form_action,
manifest.manifest,
manifest.state,
);
},
fallback: "Failed to start GitHub setup.",
});
}}
>
<CardPicker
legend="Authentication"
options={GITHUB_STRATEGY_OPTIONS}
value={githubStrategy}
onChange={(strategy) =>
dispatchInstall({ type: "githubStrategyChanged", strategy })
}
/>
{githubStrategy === "token" ? (
<div className="space-y-5">
<div>
<label
htmlFor="github_token"
className="text-sm font-medium text-fg"
>
Personal access token
</label>
<div className="mt-2">
<PasswordInput
id="github_token"
name="github_token"
value={tokenForm.token}
onChange={(value) =>
dispatchInstall({
type: "tokenFormPatched",
patch: { token: value },
})
}
placeholder="ghp_..."
/>
</div>
{tokenForm.username ? (
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-mint">
<CheckCircleIcon className="size-4 shrink-0" />
Previously validated as{" "}
<span className="font-medium">@{tokenForm.username}</span>
</p>
) : null}
<HelpDisclosure summary="Where do I get this?">
<p>
Create a fine-grained or classic token with{" "}
<code className="font-mono text-fg-2">repo</code> scope.
</p>
<ExternalLink href="https://github.com/settings/tokens">
github.com/settings/tokens
</ExternalLink>
</HelpDisclosure>
</div>
</div>
) : (
<div className="space-y-5">
<CardPicker
legend="Owner"
options={GITHUB_OWNER_OPTIONS}
value={appForm.owner.kind}
onChange={(kind) =>
dispatchInstall({ type: "githubOwnerChanged", kind })
}
/>
{appForm.owner.kind === "org" ? (
<Field label="Organization slug">
<input
name="github_org_slug"
aria-label="Organization slug"
value={appForm.owner.slug ?? ""}
onChange={(event) =>
dispatchInstall({
type: "githubOrgSlugChanged",
slug: event.target.value,
})
}
className={INPUT_CLASS}
placeholder="acme"
spellCheck={false}
/>
</Field>
) : null}
<Field
label="Allowed GitHub username"
hint="Only this username can log in through GitHub after setup."
>
<input
name="github_allowed_username"
aria-label="Allowed GitHub username"
value={appForm.allowedUsername}
onChange={(event) =>
dispatchInstall({
type: "appFormPatched",
patch: { allowedUsername: event.target.value },
})
}
className={INPUT_CLASS}
placeholder="octocat"
spellCheck={false}
/>
</Field>
{session.server?.canonical_url ? (
<p className="text-xs text-fg-muted">
After creating the app, GitHub will redirect back to{" "}
<code className="font-mono text-fg-3">{session.server.canonical_url}</code>.
</p>
) : null}
</div>
)}
</StepPanel>
);
}
function TokenEntryScreen({
manualToken,
onManualTokenChange,
sessionError,
onSubmit,
}: {
manualToken: string;
onManualTokenChange: (value: string) => void;
sessionError: string | null;
onSubmit: () => void;
}) {
// react-doctor-disable-next-line react-doctor/no-prevent-default -- Install finalization writes server config through the install API, not a native form action.
return (
<main className="min-h-dvh bg-atmosphere px-4 py-16 text-fg-2 antialiased sm:py-20">
<div className="relative mx-auto max-w-md">
<div className="flex items-center gap-3">
<img src="/images/logo.svg" alt="Fabro" className="size-8" draggable={false} />
<span className="text-sm font-medium text-fg-3">Install</span>
</div>
<div className="mt-10">
<h1 className="text-2xl font-semibold tracking-tight text-fg sm:text-[1.75rem]">
Finish configuring this Fabro server
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
Find the one-time install token in your terminal, Docker logs, or
platform log viewer, then paste it here to continue.
</p>
</div>
{/* react-doctor-disable-next-line react-doctor/no-prevent-default -- Install token entry is a client-side API step with no meaningful non-JS endpoint. */}
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
className="mt-8 space-y-5"
>
<div>
<label htmlFor="install-token" className="sr-only">
Install token
</label>
<input
id="install-token"
type="password"
name="install_token"
aria-label="Install token"
value={manualToken}
onChange={(event) => onManualTokenChange(event.target.value)}
className={`${INPUT_CLASS} font-mono`}
placeholder="Paste install token"
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
</div>
{sessionError ? <ErrorMessage message={sessionError} /> : null}
<button type="submit" className={PRIMARY_BUTTON_CLASS}>
Continue
<ArrowRightIcon className="size-4 shrink-0" />
</button>
</form>
<section className="mt-10 border-t border-line pt-6">
<h2 className="text-xs font-semibold tracking-wide text-fg uppercase">
Where to find it
</h2>
<dl className="mt-4 space-y-2 text-sm/6 text-fg-3">
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Local</dt>
<dd>
Output of{" "}
<code className="font-mono text-fg-2">fabro server start</code>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Docker</dt>
<dd>
<code className="font-mono text-fg-2">docker logs &lt;container&gt;</code>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Hosted</dt>
<dd>Your platform's log viewer or <code className="font-mono text-fg-2">journalctl</code></dd>
</div>
</dl>
</section>
<p className="mt-10 text-xs text-fg-muted">
Install mode is temporary and only available until setup completes.
</p>
</div>
</main>
);
}
function InstallLayout({
children,
currentStep,
completedSteps,
}: {
children: ReactNode;
currentStep: StepId;
completedSteps: Set<string>;
}) {
const showStepper = currentStep !== "welcome";
return (
<main className="min-h-dvh bg-atmosphere px-4 py-12 text-fg-2 antialiased sm:py-16">
<div className="relative mx-auto max-w-xl">
<div className="flex items-center gap-3">
<img src="/images/logo.svg" alt="Fabro" className="size-8" draggable={false} />
<span className="text-sm font-medium text-fg-3">Install</span>
</div>
{showStepper ? (
<div className="mt-8">
<Stepper currentStep={currentStep} completedSteps={completedSteps} />
</div>
) : null}
<div className="mt-10 sm:mt-12">{children}</div>
</div>
</main>
);
}
function Stepper({
currentStep,
completedSteps,
}: {
currentStep: StepId;
completedSteps: Set<string>;
}) {
const activeIndex = STEPPER_STEPS.findIndex((step) => step.id === currentStep);
const safeIndex = activeIndex === -1 ? 0 : activeIndex;
const activeStep = STEPPER_STEPS[safeIndex];
const progress = ((safeIndex + 1) / STEPPER_STEPS.length) * 100;
return (
<nav aria-label="Install progress">
<div className="sm:hidden">
<p className="text-xs font-medium text-fg-3 tabular-nums">
Step {safeIndex + 1} of {STEPPER_STEPS.length}
<span className="text-fg"> · {activeStep.label}</span>
</p>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-overlay">
<div
className="h-full rounded-full bg-teal-500 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ol className="hidden items-center sm:flex">
{STEPPER_STEPS.map((step, index) => {
const isComplete = completedSteps.has(step.id);
const isCurrent = step.id === currentStep;
const isLast = index === STEPPER_STEPS.length - 1;
const isLinkable = isComplete || isCurrent;
const circleClass = isComplete
? "bg-mint text-on-primary"
: isCurrent
? "bg-teal-500 text-on-primary"
: "bg-overlay text-fg-muted outline-1 -outline-offset-1 outline-white/10";
const labelClass = isCurrent
? "text-fg"
: isComplete
? "text-fg-2"
: "text-fg-muted";
const connectorClass = isComplete ? "bg-mint/40" : "bg-line-strong";
const inner = (
<>
<span
className={`flex size-6 items-center justify-center rounded-full text-xs font-semibold tabular-nums ${circleClass}`}
aria-hidden="true"
>
{isComplete ? <CheckIcon className="size-3.5" /> : index + 1}
</span>
<span className={`text-xs font-medium ${labelClass}`}>
{step.label}
</span>
</>
);
return (
<li
key={step.id}
className={`flex items-center ${isLast ? "" : "flex-1"}`}
>
{isLinkable ? (
<Link
to={step.href}
aria-current={isCurrent ? "step" : undefined}
className="flex items-center gap-2 rounded-md outline-teal-500 focus-visible:outline-2 focus-visible:outline-offset-4"
>
{inner}
</Link>
) : (
<span className="flex items-center gap-2" aria-disabled="true">
{inner}
</span>
)}
{isLast ? null : (
<span
aria-hidden="true"
className={`mx-3 h-px flex-1 ${connectorClass}`}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}
function WelcomeScreen() {
return (
<div>
<h1 className="text-3xl font-semibold tracking-tight text-fg text-balance sm:text-4xl">
Set up your Fabro server
</h1>
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
A short walkthrough to confirm the public server URL, choose the shared
object store and sandbox runtime, validate your LLM credentials, and
connect GitHub. When you finish, Fabro restarts into normal mode.
</p>
<ol className="mt-10 divide-y divide-line border-y border-line">
{[
["Server URL", "Confirm where operators will reach Fabro."],
[
"Object store",
"Choose local disk or AWS S3 for SlateDB and artifacts.",
],
["Sandbox", "Choose Docker or Daytona for workflow execution."],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
["GitHub", "Choose a personal access token or a GitHub App."],
["Review", "Double-check the plan, then write the files."],
].map(([title, body], index) => (
<li key={title} className="flex items-start gap-4 py-4">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
aria-hidden="true"
>
{index + 1}
</span>
<div>
<p className="text-sm font-medium text-fg">{title}</p>
<p className="mt-1 text-sm/6 text-fg-3">{body}</p>
</div>
</li>
))}
</ol>
<div className="mt-10 flex justify-end">
<Link to="/install/server" className={PRIMARY_BUTTON_CLASS}>
Start setup
<ArrowRightIcon className="size-4 shrink-0" />
</Link>
</div>
</div>
);
}
function StepPanel({
title,
description,
children,
error,
submitting,
submitLabel = "Continue",
submittingLabel = "Saving...",
backHref,
secondaryAction,
onSubmit,
}: {
title: string;
description: string;
children: ReactNode;
error: string | null;
submitting: boolean;
submitLabel?: string;
submittingLabel?: string;
backHref?: string;
secondaryAction?: ReactNode;
onSubmit: () => Promise<void>;
}) {
return (
// react-doctor-disable-next-line react-doctor/no-prevent-default -- Install wizard forms are client-side API steps with no meaningful non-JS endpoint.
<form
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (submitting) return;
void onSubmit();
}}
className="space-y-8"
>
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
{title}
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
{description}
</p>
</header>
<div className="space-y-5">{children}</div>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-between gap-3 pt-2">
{backHref ? (
<Link to={backHref} className={SECONDARY_BUTTON_CLASS}>
<ArrowLeftIcon className="size-4 shrink-0" />
Back
</Link>
) : (
<span />
)}
<div className="flex items-center gap-3">
{secondaryAction}
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
{submittingLabel}
</>
) : (
<>
{submitLabel}
<ArrowRightIcon className="size-4 shrink-0" />
</>
)}
</button>
</div>
</div>
</form>
);
}
function ReviewScreen({
session,
error,
submitting,
onInstall,
}: {
session: InstallSessionResponse | null;
error: string | null;
submitting: boolean;
onInstall: () => Promise<void>;
}) {
const llmSummary = describeLlmSummary(session?.llm);
const serverUrl =
session?.server?.canonical_url || session?.prefill.canonical_url || "Unknown";
return (
// react-doctor-disable-next-line react-doctor/no-prevent-default -- Install finalization writes server config through the install API, not a native form action.
<form
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (submitting) return;
void onInstall();
}}
className="space-y-8"
>
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
Review and install
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
Confirm the plan below. Fabro writes the configuration to disk, then
restarts into normal mode.
</p>
</header>
<dl className="divide-y divide-line border-y border-line">
<SummaryRow
label="Server URL"
value={serverUrl}
mono
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
<ObjectStoreSummaryRows objectStore={session?.object_store} />
<SandboxSummaryRows sandbox={session?.sandbox} />
<SummaryRow label="LLM providers" value={llmSummary} />
<GithubSummaryRows github={session?.github} serverUrl={serverUrl} />
</dl>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-between gap-3 pt-2">
<Link to="/install/github" className={SECONDARY_BUTTON_CLASS}>
<ArrowLeftIcon className="size-4 shrink-0" />
Back
</Link>
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
Installing
</>
) : (
"Install"
)}
</button>
</div>
</form>
);
}
function FinishingScreen({
finishState,
timedOut,
}: {
finishState: FinishState;
timedOut: boolean;
}) {
if (!finishState) {
return <Navigate to="/install/review" replace />;
}
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
{timedOut ? "Install complete" : "Finishing up"}
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
{timedOut
? "The server didn't come back automatically. Start it manually and return to the URL below."
: "Configuration written. Waiting for the server to restart into normal mode."}
</p>
</header>
{timedOut ? (
<div className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-amber/30">
Run <code className="font-mono text-fg">fabro server start</code>, then
visit{" "}
<code className="font-mono text-fg">{finishState.restart_url}</code>.
</div>
) : (
<div className="flex items-center gap-3 rounded-lg bg-overlay px-4 py-3 outline-1 -outline-offset-1 outline-white/10">
<Spinner className="text-teal-300" />
<p className="text-sm/6 text-fg-3">
Polling <code className="font-mono text-fg-2">/health</code>…
</p>
</div>
)}
{finishState.dev_token ? (
<div className="rounded-lg bg-overlay p-4 outline-1 -outline-offset-1 outline-white/10">
<p className="text-xs font-semibold tracking-wide text-fg uppercase">
Development token
</p>
<p className="mt-1 text-sm/6 text-fg-3">
Use this to sign in after the server restarts.
</p>
<div className="mt-3">
<CopyableToken token={finishState.dev_token} />
</div>
</div>
) : null}
</div>
);
}
function ProviderFields({
value,
onProviderApiKeyChange,
}: {
value: ProviderSelection;
onProviderApiKeyChange: (provider: string, apiKey: string) => void;
}) {
return (
<div className="space-y-6">
{INSTALL_PROVIDERS.map((provider) => {
const current = value[provider.id] ?? { apiKey: "" };
return (
<div key={provider.id}>
<label
htmlFor={`${provider.id}_api_key`}
className="text-sm font-medium text-fg"
>
{provider.label}
</label>
<div className="mt-2">
<PasswordInput
id={`${provider.id}_api_key`}
name={`${provider.id}_api_key`}
value={current.apiKey}
onChange={(next) => onProviderApiKeyChange(provider.id, next)}
placeholder={provider.envVar}
/>
</div>
<HelpDisclosure summary="Where do I get this?">
<p>{provider.keyHelp.text}</p>
<ExternalLink href={provider.keyHelp.url}>
{provider.keyHelp.url.replace(/^https?:\/\//, "")}
</ExternalLink>
</HelpDisclosure>
</div>
);
})}
</div>
);
}
type CardOption<T extends string> = { id: T; title: string; body: string };
function CardPicker<T extends string>({
legend,
options,
value,
onChange,
}: {
legend: string;
options: ReadonlyArray<CardOption<T>>;
value: T;
onChange: (value: T) => void;
}) {
return (
<fieldset>
<legend className="text-sm font-medium text-fg">{legend}</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={value === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
const GITHUB_STRATEGY_OPTIONS: ReadonlyArray<CardOption<GithubStrategy>> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
const OBJECT_STORE_PROVIDER_OPTIONS: ReadonlyArray<CardOption<ObjectStoreProvider>> = [
{
id: "local",
title: "Local disk",
body: "Uses the host filesystem for SlateDB and run artifacts.",
},
{
id: "s3",
title: "AWS S3",
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
},
];
const OBJECT_STORE_CREDENTIAL_MODE_OPTIONS: ReadonlyArray<
CardOption<ObjectStoreCredentialMode>
> = [
{
id: "runtime",
title: "Use AWS runtime credentials",
body: "Use credentials already supplied by the deployment environment.",
},
{
id: "access_key",
title: "Enter AWS access key credentials",
body: "Store an access key pair in server.env for startup and validation.",
},
];
const SANDBOX_PROVIDER_OPTIONS: ReadonlyArray<CardOption<SandboxProvider>> = [
{
id: "docker",
title: "Docker",
body: "Default. Uses the host Docker daemon to run sandbox containers.",
},
{
id: "daytona",
title: "Daytona",
body: "Each run gets a managed Daytona cloud sandbox. Requires an API key.",
},
];
const GITHUB_OWNER_OPTIONS: ReadonlyArray<CardOption<GithubOwnerKind>> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow requires the org slug.",
},
];
function OptionCard({
selected,
onSelect,
title,
body,
}: {
selected: boolean;
onSelect: () => void;
title: string;
body: string;
}) {
const base =
"group relative flex items-start gap-3 rounded-lg px-4 py-3.5 text-left outline-1 -outline-offset-1 transition-colors";
const state = selected
? "bg-teal-500/10 outline-teal-500/60"
: "bg-overlay outline-white/10 hover:bg-overlay-strong hover:outline-white/15";
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={`${base} ${state}`}
>
<span
aria-hidden="true"
className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full outline-1 -outline-offset-1 ${
selected
? "bg-teal-500 outline-teal-500"
: "bg-transparent outline-white/20"
}`}
>
{selected ? (
<span className="size-1.5 rounded-full bg-navy-950" />
) : null}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-fg">{title}</span>
<span className="mt-1 block text-xs/5 text-fg-3">{body}</span>
</span>
</button>
);
}
function GithubAppDoneScreen({
github,
}: {
github: InstallSessionResponse["github"];
}) {
if (!github || github.strategy !== "app") {
return <Navigate to="/install/github" replace />;
}
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
GitHub App connected
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
The app credentials are staged. They'll be written into the runtime
env file when the install finishes.
</p>
</header>
<dl className="divide-y divide-line border-y border-line">
<SummaryRow label="Owner" value={describeGithubAppOwner(github.owner)} />
<SummaryRow
label="App"
value={github.slug || github.app_name || "GitHub App"}
mono
/>
<SummaryRow
label="Allowed user"
value={github.allowed_username || "Unknown"}
mono
/>
</dl>
<div className="flex justify-end">
<Link to="/install/review" className={PRIMARY_BUTTON_CLASS}>
Continue to review
<ArrowRightIcon className="size-4 shrink-0" />
</Link>
</div>
</div>
);
}
function SummaryRow({
label,
value,
mono,
action,
}: {
label: string;
value: string;
mono?: boolean;
action?: ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4 py-4">
<dt className="text-sm text-fg-3">{label}</dt>
<dd className="col-span-2 flex items-start gap-2">
<span className={`min-w-0 flex-1 text-sm text-fg break-words ${mono ? "font-mono" : ""}`}>
{value}
</span>
{action}
</dd>
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block">
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<span className="text-sm font-medium text-fg">{label}</span>
{hint ? <span className="text-xs text-fg-muted">{hint}</span> : null}
</div>
<div className="mt-2">{children}</div>
</label>
);
}
function PasswordInput({
id,
name,
value,
onChange,
placeholder,
inputRef,
}: {
id?: string;
name: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
inputRef?: Ref<HTMLInputElement>;
}) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
ref={inputRef}
type={visible ? "text" : "password"}
id={id}
name={name}
aria-label={placeholder ?? name}
value={value}
onChange={(event) => onChange(event.target.value)}
className={`${INPUT_CLASS} pr-11 font-mono`}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
className="absolute inset-y-0 right-0 flex items-center rounded-r-lg px-3 text-fg-muted outline-teal-500 hover:text-fg-2 focus-visible:outline-2 focus-visible:-outline-offset-2"
aria-label={visible ? "Hide value" : "Show value"}
>
{visible ? (
<EyeSlashIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
);
}
function CopyableToken({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="flex items-stretch gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-panel-alt px-3 py-2 font-mono text-sm text-fg-2 outline-1 -outline-offset-1 outline-white/10">
<code>{token}</code>
</pre>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be blocked; leave state unchanged.
}
}}
className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-3 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
aria-label={copied ? "Copied" : "Copy token"}
>
{copied ? (
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
) : (
<ClipboardIcon className="size-4" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
);
}
function HelpDisclosure({
summary,
children,
}: {
summary: string;
children: ReactNode;
}) {
return (
<details className="group mt-2">
<summary className="inline-flex list-none items-center gap-1 rounded text-xs text-fg-3 outline-teal-500 select-none hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-2 [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:-rotate-180" />
<span>{summary}</span>
</summary>
<div className="mt-2 space-y-1.5 text-xs/5 text-fg-3">{children}</div>
</details>
);
}
function ExternalLink({
href,
children,
}: {
href: string;
children: ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-teal-300 hover:text-teal-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 rounded"
>
{children}
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
</a>
);
}
function Spinner({ className = "" }: { className?: string }) {
return (
<svg
className={`size-4 shrink-0 animate-spin ${className}`}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function defaultProviderSelection(): ProviderSelection {
return Object.fromEntries(
INSTALL_PROVIDERS.map((provider) => [provider.id, { apiKey: "" }]),
);
}
function defaultObjectStoreForm(localRoot = ""): ObjectStoreForm {
return {
provider: "local",
localRoot,
bucket: "",
region: "",
credentialMode: "runtime",
accessKeyId: "",
secretAccessKey: "",
manualCredentialsSaved: false,
};
}
function hydrateProviderSelection(
current: ProviderSelection,
session: InstallSessionResponse,
): ProviderSelection {
const hasUserInput = Object.values(current).some((provider) => provider.apiKey);
if (hasUserInput) return current;
const next = defaultProviderSelection();
for (const provider of session.llm?.providers ?? []) {
next[provider.provider] = { apiKey: "" };
}
return next;
}
function hydrateObjectStoreForm(session: InstallSessionResponse): ObjectStoreForm {
const summary = session.object_store;
if (!summary || summary.provider === "local") {
return defaultObjectStoreForm(
summary?.root ?? session.prefill.object_store_local_root,
);
}
return {
provider: "s3",
localRoot: session.prefill.object_store_local_root,
bucket: summary.bucket ?? "",
region: summary.region ?? "",
credentialMode: summary.credential_mode === "access_key" ? "access_key" : "runtime",
accessKeyId: "",
secretAccessKey: "",
manualCredentialsSaved: Boolean(summary.manual_credentials_saved),
};
}
function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput {
if (form.provider === "local") {
return { provider: "local", root: form.localRoot.trim() };
}
const payload: InstallObjectStoreInput = {
provider: "s3",
bucket: form.bucket.trim(),
region: form.region.trim(),
credential_mode: form.credentialMode,
};
const accessKeyId = form.accessKeyId.trim();
const secretAccessKey = form.secretAccessKey.trim();
if (form.credentialMode === "access_key") {
if (accessKeyId) {
payload.access_key_id = accessKeyId;
}
if (secretAccessKey) {
payload.secret_access_key = secretAccessKey;
}
}
return payload;
}
function defaultSandboxForm(): SandboxForm {
return { provider: "docker", apiKey: "", apiKeySaved: false, allowLocal: true };
}
function hydrateSandboxForm(
current: SandboxForm,
session: InstallSessionResponse,
): SandboxForm {
const summary = session.sandbox;
if (!summary) {
return current.apiKey ? { ...current, apiKeySaved: false } : defaultSandboxForm();
}
if (current.apiKey) {
return { ...current, apiKeySaved: Boolean(summary.api_key_saved) };
}
return {
provider: summary.provider === "daytona" ? "daytona" : "docker",
apiKey: "",
apiKeySaved: Boolean(summary.api_key_saved),
allowLocal: summary.allow_local ?? true,
};
}
function buildSandboxPayload(form: SandboxForm): InstallSandboxInput {
if (form.provider === "docker") {
return { provider: "docker", allow_local: form.allowLocal };
}
const apiKey = form.apiKey.trim();
const payload: InstallSandboxInput = {
provider: "daytona",
allow_local: form.allowLocal,
};
if (apiKey) {
payload.api_key = apiKey;
}
return payload;
}
function focusInput(ref: { current: HTMLInputElement | null }): void {
window.setTimeout(() => ref.current?.focus(), 0);
}
function describeProvider(id: string): string {
const match = INSTALL_PROVIDERS.find((provider) => provider.id === id);
return match?.label ?? id;
}
function describeLlmSummary(llm: InstallSessionResponse["llm"]): string {
// `null` means the LLM step has not been completed. A present summary with
// an empty providers list is an explicit skip.
if (!llm) {
return "Not configured";
}
const providers = (llm.providers ?? []).map((provider) =>
describeProvider(provider.provider),
);
return providers.length > 0 ? providers.join(", ") : "Skipped";
}
function GithubSummaryRows({
github,
serverUrl,
}: {
github: InstallSessionResponse["github"];
serverUrl: string;
}) {
if (!github) {
return <SummaryRow label="GitHub" value="Not configured" />;
}
if (github.strategy === "app") {
return (
<>
<SummaryRow label="GitHub connection" value="GitHub App" />
<SummaryRow label="App owner" value={describeGithubAppOwner(github.owner)} />
<SummaryRow
label="Allowed user"
value={github.allowed_username ? `@${github.allowed_username}` : "Not set"}
mono={Boolean(github.allowed_username)}
/>
<SummaryRow
label="GitHub callback URL"
value={githubCallbackUrl(serverUrl)}
mono
/>
</>
);
}
return (
<>
<SummaryRow label="GitHub connection" value="Personal access token" />
<SummaryRow
label="User"
value={github.username ? `@${github.username}` : "Not set"}
mono={Boolean(github.username)}
/>
</>
);
}
function githubCallbackUrl(serverUrl: string): string {
return `${serverUrl.replace(/\/+$/, "")}/auth/callback/github`;
}
function ObjectStoreSummaryRows({
objectStore,
}: {
objectStore: InstallSessionResponse["object_store"];
}) {
if (!objectStore) {
return <SummaryRow label="Object store" value="Not configured" />;
}
if (objectStore.provider === "local") {
return (
<>
<SummaryRow label="Object store" value="Local disk" />
<SummaryRow label="Directory" value={objectStore.root ?? "Not set"} mono />
</>
);
}
return (
<>
<SummaryRow label="Object store" value="AWS S3" />
<SummaryRow label="Bucket" value={objectStore.bucket ?? "Not set"} mono />
<SummaryRow label="Region" value={objectStore.region ?? "Not set"} mono />
<SummaryRow
label="Credentials"
value={
objectStore.credential_mode === "access_key"
? "Access key"
: "Runtime credentials"
}
/>
<SummaryRow label="Prefixes" value="slatedb/, artifacts/" mono />
</>
);
}
function SandboxSummaryRows({
sandbox,
}: {
sandbox: InstallSessionResponse["sandbox"];
}) {
if (!sandbox) {
return <SummaryRow label="Sandbox" value="Not configured" />;
}
if (sandbox.provider === "daytona") {
return (
<>
<SummaryRow label="Sandbox" value="Daytona" />
<SummaryRow
label="Daytona API key"
value={sandbox.api_key_saved ? "Saved" : "Not set"}
/>
</>
);
}
return <SummaryRow label="Sandbox" value="Docker" />;
}
function describeGithubAppOwner(
owner: InstallGithubAppOwner | undefined,
): string {
if (!owner || owner.kind === "personal") return "Personal account";
return owner.slug ? `@${owner.slug} (organization)` : "Organization";
}
function submitGithubManifest(
formAction: string,
manifest: Record<string, unknown>,
state: string,
): void {
const form = document.createElement("form");
form.method = "post";
form.action = formAction;
form.style.display = "none";
const manifestInput = document.createElement("input");
manifestInput.type = "hidden";
manifestInput.name = "manifest";
manifestInput.value = JSON.stringify(manifest);
form.appendChild(manifestInput);
const stateInput = document.createElement("input");
stateInput.type = "hidden";
stateInput.name = "state";
stateInput.value = state;
form.appendChild(stateInput);
document.body.appendChild(form);
form.submit();
}