Cut over Fabro web app to a server-backed SPA

Replace the old React Router SSR setup with a static SPA build served by
fabro-server, move setup and GitHub auth handling into Rust, and update the
default local web URL and stale Arc-era references to match the Fabro name.
This commit is contained in:
Bryan Helmkamp 2026-04-01 21:36:01 -07:00
parent e849441833
commit eb91f2bcab
No known key found for this signature in database
787 changed files with 9150 additions and 1395 deletions

182
Cargo.lock generated
View file

@ -17,6 +17,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -386,6 +421,29 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.0"
@ -618,6 +676,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.5.60"
@ -748,6 +816,24 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"aes-gcm",
"base64",
"hkdf",
"hmac",
"percent-encoding",
"rand 0.8.5",
"sha2",
"subtle",
"time",
"version_check",
]
[[package]]
name = "coolor"
version = "1.1.0"
@ -942,9 +1028,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.14.4"
@ -1819,10 +1915,12 @@ version = "0.176.2"
dependencies = [
"anyhow",
"axum",
"axum-extra",
"base64",
"bytes",
"chrono",
"clap",
"cookie",
"dirs",
"fabro-agent",
"fabro-api-types",
@ -1847,9 +1945,12 @@ dependencies = [
"hyper",
"hyper-util",
"jsonwebtoken",
"mime_guess",
"object_store",
"openapiv3",
"rand 0.8.5",
"reqwest",
"rust-embed",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
@ -2513,6 +2614,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gimli"
version = "0.32.3"
@ -3094,6 +3205,15 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "insta"
version = "1.46.3"
@ -4040,6 +4160,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@ -4417,6 +4543,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -4927,6 +5065,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.117",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
@ -6677,6 +6849,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"

View file

@ -11,6 +11,8 @@ license = "MIT"
[workspace.dependencies]
anyhow = "1"
axum = { version = "0.8" }
axum-extra = { version = "0.10", features = ["cookie-private"] }
cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
@ -69,6 +71,7 @@ fork = "0.2"
exec = "0.3"
slatedb = "0.11.2"
object_store = "0.12.5"
rust-embed = "8"
[workspace.lints.rust]
unsafe_code = "deny"

View file

@ -1,60 +0,0 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { apiJson } from "./api-client";
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = originalFetch;
});
describe("apiJson", () => {
test("returns parsed JSON on 200", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ id: 1, name: "test" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}))
);
const result = await apiJson<{ id: number; name: string }>("/items/1");
expect(result).toEqual({ id: 1, name: "test" });
});
test("throws Response with status 404 and null body on not found", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response("Not Found: /items/999", { status: 404 }))
);
try {
await apiJson("/items/999");
expect.unreachable("should have thrown");
} catch (thrown) {
expect(thrown).toBeInstanceOf(Response);
const res = thrown as Response;
expect(res.status).toBe(404);
expect(res.body).toBeNull();
}
});
test("throws Response with status 500 and null body, stripping sensitive details", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
"Internal error: database connection string is postgres://admin:secret@db.internal:5432/prod",
{ status: 500 }
)
)
);
try {
await apiJson("/items/1");
expect.unreachable("should have thrown");
} catch (thrown) {
expect(thrown).toBeInstanceOf(Response);
const res = thrown as Response;
expect(res.status).toBe(500);
expect(res.body).toBeNull();
}
});
});

View file

@ -1,80 +0,0 @@
import { importPKCS8, SignJWT } from "jose";
import { getAppConfig } from "./lib/config.server";
import { isDemoMode } from "./lib/demo-mode.server";
import { getUser } from "./lib/session.server";
const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY;
function decodePemEnv(value: string): string {
if (value.startsWith("-----")) return value;
return Buffer.from(value, "base64").toString("utf-8");
}
let cachedKey: CryptoKey | null = null;
async function getSigningKey(): Promise<CryptoKey> {
if (cachedKey) return cachedKey;
if (!FABRO_JWT_PRIVATE_KEY) {
throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set");
}
cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA");
return cachedKey;
}
async function signToken(sub?: string): Promise<string> {
const key = await getSigningKey();
return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) })
.setProtectedHeader({ alg: "EdDSA" })
.setIssuedAt()
.setExpirationTime("30s")
.sign(key);
}
interface ApiOptions {
init?: RequestInit;
request?: Request;
}
/**
* Fetch wrapper that signs requests with a JWT for service-to-service auth.
* When a request is provided, the authenticated user's URL is included as
* the JWT `sub` claim.
*/
export async function apiFetch(
path: string,
options?: ApiOptions
): Promise<Response> {
const { base_url } = getAppConfig().api;
const { init, request } = options ?? {};
let sub: string | undefined;
if (request) {
const user = await getUser(request);
sub = user?.userUrl;
}
const headers = new Headers(init?.headers);
if (FABRO_JWT_PRIVATE_KEY) {
const token = await signToken(sub);
headers.set("Authorization", `Bearer ${token}`);
}
if (request && isDemoMode(request)) {
headers.set("X-Fabro-Demo", "1");
}
const url = `${base_url}${path}`;
try {
return await fetch(url, { ...init, headers });
} catch (cause) {
throw new Error(`API request to ${url} failed`, { cause });
}
}
/**
* Typed JSON fetch helper. Calls apiFetch and parses the JSON response.
*/
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
const res = await apiFetch(path, options);
if (!res.ok) throw new Response(null, { status: res.status });
return res.json() as Promise<T>;
}

58
apps/fabro-web/app/api.ts Normal file
View file

@ -0,0 +1,58 @@
export interface ApiOptions {
init?: RequestInit;
request?: Request;
}
export async function apiFetch(path: string, options?: ApiOptions): Promise<Response> {
const { init } = options ?? {};
const response = await fetch(`/api/v1${path}`, {
...init,
credentials: "include",
headers: init?.headers,
});
if (response.status === 401) {
window.location.href = "/auth/login";
throw new Error("Unauthorized");
}
return response;
}
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
const response = await apiFetch(path, options);
if (!response.ok) {
throw new Response(null, { status: response.status, statusText: response.statusText });
}
return response.json() as Promise<T>;
}
export async function getSetupStatus(): Promise<{ configured: boolean }> {
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
if (!response.ok) {
throw new Response(null, { status: response.status, statusText: response.statusText });
}
return response.json();
}
export async function getAuthMe(): Promise<{
user: {
login: string;
name: string;
email: string;
avatarUrl: string;
userUrl: string;
};
provider: string;
demoMode: boolean;
features: { session_sandboxes: boolean; retros: boolean };
}> {
const response = await fetch("/api/v1/auth/me", { credentials: "include" });
if (response.status === 401) {
throw new Response(null, { status: 401, statusText: "Unauthorized" });
}
if (!response.ok) {
throw new Response(null, { status: response.status, statusText: response.statusText });
}
return response.json();
}

View file

@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { routes } from "./router";
const router = createBrowserRouter(routes);
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root element");
}
createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View file

@ -12,55 +12,19 @@ import {
BeakerIcon,
ChartBarIcon,
CheckBadgeIcon,
Cog6ToothIcon,
LightBulbIcon,
MoonIcon,
PlayIcon,
RectangleStackIcon,
SparklesIcon,
SunIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router";
import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router";
import { getAuthMe } from "../api";
import { useTheme } from "../lib/theme";
import { getAppConfig } from "../lib/config.server";
import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server";
import { isGitHubAppConfigured } from "../lib/github.server";
import { requireUser } from "../lib/session.server";
import type { Route } from "./+types/app-shell";
const DEMO_USER = {
userUrl: "",
login: "demo",
name: "Demo User",
email: "demo@example.com",
avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png",
};
export async function loader({ request }: Route.LoaderArgs) {
const config = getAppConfig();
const { provider } = config.web.auth;
const demoMode = isDemoMode(request);
if (provider === "insecure_disabled") {
return { user: DEMO_USER, demoMode, features: config.features };
}
if (provider === "github" && !isGitHubAppConfigured()) {
throw redirect("/setup");
}
const user = await requireUser(request);
return { user, provider, demoMode, features: config.features };
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (form.get("intent") === "toggle-demo") {
const enabled = !isDemoMode(request);
const referer = request.headers.get("Referer") ?? "/start";
const url = new URL(referer);
return redirect(url.pathname + url.search, {
headers: { "Set-Cookie": demoCookieHeader(enabled) },
});
}
export async function loader() {
return getAuthMe();
}
const navigation = [
@ -75,10 +39,11 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export default function AppShell({ loaderData }: Route.ComponentProps) {
export default function AppShell({ loaderData }: any) {
const { user, provider, demoMode } = loaderData;
const { pathname } = useLocation();
const matches = useMatches();
const revalidator = useRevalidator();
const { theme, toggle } = useTheme();
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
const title = currentNav?.name ?? "";
@ -91,6 +56,16 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon;
async function toggleDemoMode() {
await fetch("/api/v1/demo/toggle", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !demoMode }),
});
revalidator.revalidate();
}
return (
<div className="min-h-full">
<Disclosure as="nav" className="bg-panel/50">
@ -128,20 +103,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center gap-3 md:ml-6">
<Form method="POST" className="flex">
<input type="hidden" name="intent" value="toggle-demo" />
<button
type="submit"
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
</Form>
<button
type="button"
onClick={toggleDemoMode}
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
<button
type="button"
onClick={toggle}
@ -151,35 +124,33 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
<ThemeIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle theme</span>
</button>
{provider !== "tailscale" && (
<Menu as="div" className="relative">
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src={user.avatarUrl}
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</MenuButton>
<Menu as="div" className="relative">
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src={user.avatarUrl}
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Form method="POST" action="/auth/logout">
<button
type="submit"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Sign out
</button>
</Form>
</MenuItem>
</MenuItems>
</Menu>
)}
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Form method="POST" action="/auth/logout">
<button
type="submit"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Sign out
</button>
</Form>
</MenuItem>
</MenuItems>
</Menu>
</div>
</div>
<div className="-mr-2 flex md:hidden">
@ -240,20 +211,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Form method="POST" className="flex">
<input type="hidden" name="intent" value="toggle-demo" />
<button
type="submit"
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
</Form>
<button
type="button"
onClick={toggleDemoMode}
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
<button
type="button"
onClick={toggle}

View file

@ -1,112 +0,0 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { parse } from "smol-toml";
interface AuthConfig {
provider: "github" | "tailscale" | "insecure_disabled";
allowed_usernames: string[];
}
interface ApiConfig {
base_url: string;
authentication_strategy: "jwt" | "insecure_disabled";
}
interface GitConfig {
provider: "github";
app_id: string | null;
client_id: string | null;
slug: string | null;
}
interface Features {
session_sandboxes: boolean;
retros: boolean;
}
interface WebConfig {
url: string;
auth: AuthConfig;
}
interface AppConfig {
web: WebConfig;
api: ApiConfig;
git: GitConfig;
features: Features;
}
const AUTH_DEFAULTS: AuthConfig = {
provider: "github",
allowed_usernames: [],
};
const WEB_DEFAULTS: WebConfig = {
url: "http://localhost:5173",
auth: AUTH_DEFAULTS,
};
const API_DEFAULTS: ApiConfig = {
base_url: "http://localhost:3000/api/v1",
authentication_strategy: "jwt",
};
const GIT_DEFAULTS: GitConfig = {
provider: "github",
app_id: null,
client_id: null,
slug: null,
};
const FEATURES_DEFAULTS: Features = {
session_sandboxes: false,
retros: false,
};
export const FABRO_CONFIG_PATH = join(homedir(), ".fabro", "server.toml");
function loadAppConfig(): AppConfig {
const configPath = FABRO_CONFIG_PATH;
let raw: Record<string, unknown> = {};
try {
raw = parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
// File doesn't exist or is unreadable — use defaults
}
const rawWeb = (raw.web ?? {}) as Record<string, unknown>;
const rawWebAuth = (rawWeb.auth ?? {}) as Partial<AuthConfig>;
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
const rawFeatures = (raw.features ?? {}) as Partial<Features>;
const demo = process.env.FABRO_DEMO === "1";
return {
web: {
...WEB_DEFAULTS,
url: (rawWeb.url as string) ?? WEB_DEFAULTS.url,
auth: demo
? { provider: "insecure_disabled", allowed_usernames: [] }
: { ...AUTH_DEFAULTS, ...rawWebAuth },
},
api: demo
? { ...API_DEFAULTS, ...rawApi, authentication_strategy: "insecure_disabled" }
: { ...API_DEFAULTS, ...rawApi },
git: { ...GIT_DEFAULTS, ...rawGit },
features: { ...FEATURES_DEFAULTS, ...rawFeatures },
};
}
/** Loaded once at module init; call reloadAppConfig() to pick up changes. */
let appConfig: AppConfig = loadAppConfig();
export function getAppConfig(): AppConfig {
return appConfig;
}
export function reloadAppConfig(): void {
appConfig = loadAppConfig();
}

View file

@ -1,29 +0,0 @@
import Database from "better-sqlite3";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
let db: Database.Database | null = null;
export function getDatabase(): Database.Database {
if (db) return db;
const fabroDir = path.join(os.homedir(), ".fabro");
fs.mkdirSync(fabroDir, { recursive: true });
db = new Database(path.join(fabroDir, "fabro-web.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS web_sessions (
id TEXT PRIMARY KEY,
user_url TEXT NOT NULL,
data TEXT NOT NULL,
expires_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_web_sessions_user_url ON web_sessions (user_url);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
`);
return db;
}

View file

@ -1,15 +0,0 @@
const COOKIE_NAME = "fabro-demo";
/** Check whether demo mode is active for this request (cookie, then env var fallback). */
export function isDemoMode(request: Request): boolean {
const cookies = request.headers.get("Cookie") ?? "";
const match = cookies.match(/(?:^|;\s*)fabro-demo=([^;]*)/);
if (match) return match[1] === "1";
return process.env.FABRO_DEMO === "1";
}
/** Build a Set-Cookie header value to persist the demo mode preference. */
export function demoCookieHeader(enabled: boolean): string {
const value = enabled ? "1" : "0";
return `${COOKIE_NAME}=${value}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 365}`;
}

View file

@ -1,18 +0,0 @@
import { GitHub, generateState } from "arctic";
import { getAppConfig } from "./config.server";
export { generateState };
export function getGitHubOAuth() {
const clientId = getAppConfig().git.client_id;
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GitHub App is not configured");
}
return new GitHub(clientId, clientSecret, null);
}
export function isGitHubAppConfigured(): boolean {
return getAppConfig().git.client_id !== null;
}

View file

@ -1,76 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mergeEnv } from "./merge-env";
describe("mergeEnv", () => {
test("replaces existing key with new value", () => {
const result = mergeEnv(
"FOO=old\nBAR=keep\n",
new Map([["FOO", "new"]]),
);
expect(result).toContain("FOO=new");
expect(result).toContain("BAR=keep");
});
test("preserves comments, blank lines, and unrelated vars", () => {
const existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n";
const result = mergeEnv(existing, new Map([["FOO", "new"]]));
expect(result).toContain("# A comment");
expect(result).toContain("# Another");
expect(result).toContain("FOO=new");
expect(result).toContain("BAR=keep");
});
test("appends keys not already present", () => {
const result = mergeEnv(
"FOO=old\n",
new Map([
["FOO", "new"],
["BAZ", "added"],
]),
);
expect(result).toContain("FOO=new");
expect(result).toContain("BAZ=added");
});
test("handles export prefix", () => {
const result = mergeEnv(
"export FOO=old\nexport BAR=keep\n",
new Map([["FOO", "new"]]),
);
expect(result).toContain("export FOO=new");
expect(result).toContain("export BAR=keep");
});
test("idempotent: merging twice produces same result", () => {
const vars = new Map([
["FOO", "new"],
["BAZ", "added"],
]);
const first = mergeEnv("FOO=old\nBAR=keep\n", vars);
const second = mergeEnv(first, vars);
expect(second).toBe(first);
});
test("full scenario matches expected output", () => {
const result = mergeEnv(
"FOO=old\nBAR=keep",
new Map([
["FOO", "new"],
["BAZ", "added"],
]),
);
expect(result).toBe("FOO=new\nBAR=keep\nBAZ=added\n");
});
test("empty existing string", () => {
const result = mergeEnv(
"",
new Map([
["FOO", "bar"],
["BAZ", "qux"],
]),
);
expect(result).toContain("FOO=bar");
expect(result).toContain("BAZ=qux");
});
});

View file

@ -1,46 +0,0 @@
/**
* Merge new key=value pairs into an existing .env file string.
* - Replaces lines whose key matches (handles optional `export ` prefix)
* - Preserves comments, blank lines, and unrelated variables
* - Appends keys not already present
*/
export function mergeEnv(
existing: string,
newVars: Map<string, string>,
): string {
const handledKeys = new Set<string>();
const resultLines: string[] = [];
for (const line of existing.split("\n")) {
const eqPos = line.indexOf("=");
if (eqPos !== -1) {
let rawKey = line.slice(0, eqPos).trim();
const hasExport = rawKey.startsWith("export ");
if (hasExport) {
rawKey = rawKey.slice("export ".length).trim();
}
if (rawKey.length > 0 && !rawKey.startsWith("#")) {
const newVal = newVars.get(rawKey);
if (newVal !== undefined) {
const prefix = hasExport ? "export " : "";
resultLines.push(`${prefix}${rawKey}=${newVal}`);
handledKeys.add(rawKey);
continue;
}
}
}
resultLines.push(line);
}
for (const [key, val] of newVars) {
if (!handledKeys.has(key)) {
resultLines.push(`${key}=${val}`);
}
}
let result = resultLines.join("\n");
if (!result.endsWith("\n")) {
result += "\n";
}
return result;
}

View file

@ -1,78 +0,0 @@
import { createSessionStorage } from "react-router";
import crypto from "node:crypto";
import { getDatabase } from "./db.server";
interface SessionRow {
id: string;
user_url: string;
data: string;
expires_at: number | null;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function cleanupExpiredSessions() {
const db = getDatabase();
db.prepare("DELETE FROM web_sessions WHERE expires_at IS NOT NULL AND expires_at < ?").run(
Date.now(),
);
}
export function createSqliteSessionStorage(secret: string) {
return createSessionStorage({
cookie: {
name: "__fabro_session",
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
secrets: [secret],
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
},
async createData(data, expiresAt) {
const db = getDatabase();
const id = crypto.randomUUID();
const userUrl = (data.userUrl as string) ?? "";
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
db.prepare(
"INSERT INTO web_sessions (id, user_url, data, expires_at) VALUES (?, ?, ?, ?)",
).run(id, userUrl, JSON.stringify(data), expiresAtMs);
// Probabilistic cleanup (~1% of creates)
if (Math.random() < 0.01) {
cleanupExpiredSessions();
}
return id;
},
async readData(id) {
const db = getDatabase();
const row = db
.prepare("SELECT * FROM web_sessions WHERE id = ?")
.get(id) as SessionRow | undefined;
if (!row) return null;
if (row.expires_at && row.expires_at < Date.now()) {
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
return null;
}
return JSON.parse(row.data);
},
async updateData(id, data, expiresAt) {
const db = getDatabase();
const userUrl = (data.userUrl as string) ?? "";
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
db.prepare(
"UPDATE web_sessions SET user_url = ?, data = ?, expires_at = ? WHERE id = ?",
).run(userUrl, JSON.stringify(data), expiresAtMs, id);
},
async deleteData(id) {
const db = getDatabase();
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
},
});
}

View file

@ -1,119 +0,0 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// --- Mocks (must be set up before importing module under test) ---
let testAuthConfig = { provider: "github" as string, allowed_usernames: [] as string[] };
mock.module("./config.server", () => ({
getAppConfig: () => ({ web: { auth: testAuthConfig } }),
reloadAppConfig: () => {},
FABRO_CONFIG_PATH: "/tmp/test.toml",
}));
let sessionData: Record<string, unknown> = {};
mock.module("./session-storage.server", () => ({
createSqliteSessionStorage: () => ({
getSession: async () => ({
get: (key: string) => sessionData[key],
}),
commitSession: async () => "",
destroySession: async () => "",
}),
}));
process.env.SESSION_SECRET = "test-secret";
const { getUser } = await import("./session.server");
// --- Tests ---
describe("getUser", () => {
beforeEach(() => {
sessionData = {};
testAuthConfig = { provider: "github", allowed_usernames: [] };
});
describe("tailscale provider", () => {
beforeEach(() => {
testAuthConfig = { provider: "tailscale", allowed_usernames: ["user@example.com"] };
});
test("returns user from headers when login is in allowed_usernames", async () => {
const request = new Request("http://localhost", {
headers: {
"Tailscale-User-Login": "user@example.com",
"Tailscale-User-Name": "Test User",
"Tailscale-User-Profile-Pic": "https://example.com/pic.jpg",
},
});
const user = await getUser(request);
expect(user).toEqual({
userUrl: "tailscale:user@example.com",
login: "user@example.com",
name: "Test User",
email: "user@example.com",
avatarUrl: "https://example.com/pic.jpg",
});
});
test("returns null when Tailscale-User-Login header is missing", async () => {
const request = new Request("http://localhost");
const user = await getUser(request);
expect(user).toBeNull();
});
test("returns null when login is not in allowed_usernames", async () => {
const request = new Request("http://localhost", {
headers: {
"Tailscale-User-Login": "stranger@example.com",
"Tailscale-User-Name": "Stranger",
},
});
const user = await getUser(request);
expect(user).toBeNull();
});
});
describe("github provider", () => {
beforeEach(() => {
testAuthConfig = { provider: "github", allowed_usernames: [] };
});
test("returns user from session", async () => {
sessionData = {
userUrl: "https://github.com/octocat",
login: "octocat",
name: "Octocat",
email: "octocat@github.com",
avatarUrl: "https://github.com/octocat.png",
};
const request = new Request("http://localhost");
const user = await getUser(request);
expect(user).toEqual({
userUrl: "https://github.com/octocat",
login: "octocat",
name: "Octocat",
email: "octocat@github.com",
avatarUrl: "https://github.com/octocat.png",
});
});
test("returns null when session is empty", async () => {
sessionData = {};
const request = new Request("http://localhost");
const user = await getUser(request);
expect(user).toBeNull();
});
});
});

View file

@ -1,70 +0,0 @@
import { redirect } from "react-router";
import { getAppConfig } from "./config.server";
import { createSqliteSessionStorage } from "./session-storage.server";
interface SessionData {
userUrl: string;
githubId: number;
githubNodeId: string;
login: string;
name: string;
email: string;
avatarUrl: string;
accessToken: string;
}
function getSessionStorage() {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET is not set");
}
return createSqliteSessionStorage(secret);
}
export async function getSession(request: Request) {
const storage = getSessionStorage();
return storage.getSession(request.headers.get("Cookie"));
}
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
const storage = getSessionStorage();
return storage.commitSession(session);
}
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
const storage = getSessionStorage();
return storage.destroySession(session);
}
export async function getUser(request: Request) {
const { provider, allowed_usernames } = getAppConfig().web.auth;
if (provider === "tailscale") {
const login = request.headers.get("Tailscale-User-Login");
if (!login || !allowed_usernames.includes(login)) return null;
return {
userUrl: `tailscale:${login}`,
login,
name: request.headers.get("Tailscale-User-Name") ?? login,
email: login,
avatarUrl: request.headers.get("Tailscale-User-Profile-Pic") ?? "",
};
}
const session = await getSession(request);
const login = session.get("login");
if (!login) return null;
return {
userUrl: session.get("userUrl") ?? "",
login,
name: session.get("name") ?? login,
email: session.get("email") ?? "",
avatarUrl: session.get("avatarUrl") ?? "",
};
}
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) throw redirect("/auth/login");
return user;
}

View file

@ -1,62 +1,23 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import { isRouteErrorResponse, Outlet } from "react-router";
import { ThemeProvider } from "./lib/theme";
import "./app.css";
const themeScript = `(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`;
export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full bg-atmosphere" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="h-full">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
export default function Root() {
return (
<ThemeProvider>
<ThemeBoot />
<Outlet />
</ThemeProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
function ThemeBoot() {
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
}
export function ErrorBoundary({ error }: any) {
let status = 500;
let heading = "Something went wrong";
let message = "An unexpected error occurred. Please try again.";
@ -72,16 +33,14 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
message = error.statusText || message;
}
} else if (error instanceof Error) {
if (import.meta.env.DEV) {
message = error.message;
stack = error.stack;
}
message = error.message;
stack = error.stack;
}
const is404 = status === 404;
return (
<main className="flex min-h-full flex-col items-center justify-center px-6 py-24">
<main className="flex min-h-screen flex-col items-center justify-center px-6 py-24">
<div className="text-center">
<p
className="font-mono text-[8rem] font-bold leading-none tracking-tighter"

View file

@ -0,0 +1,139 @@
import { createElement } from "react";
import {
type RouteObject,
useActionData,
useLoaderData,
useParams,
} from "react-router";
import Root, { ErrorBoundary as RootErrorBoundary } from "./root";
import * as RedirectHome from "./routes/redirect-home";
import * as Setup from "./routes/setup";
import * as SetupComplete from "./routes/setup-complete";
import * as AuthLogin from "./routes/auth-login";
import * as Start from "./routes/start";
import * as SessionDetail from "./routes/session-detail";
import * as Workflows from "./routes/workflows";
import * as WorkflowDetail from "./routes/workflow-detail";
import * as WorkflowDefinition from "./routes/workflow-definition";
import * as WorkflowDiagram from "./routes/workflow-diagram";
import * as WorkflowRuns from "./routes/workflow-runs";
import * as Runs from "./routes/runs";
import * as RunDetail from "./routes/run-detail";
import * as RunOverview from "./routes/run-overview";
import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
import * as RunGraph from "./routes/run-graph";
import * as RunFiles from "./routes/run-files";
import * as RunVerification from "./routes/run-verification";
import * as RunUsage from "./routes/run-usage";
import * as RunRetro from "./routes/run-retro";
import * as VerificationCriteria from "./routes/verification-criteria";
import * as VerificationCriterion from "./routes/verification-criterion";
import * as VerificationControls from "./routes/verification-controls";
import * as VerificationControl from "./routes/verification-control";
import * as Retros from "./routes/retros";
import * as Insights from "./routes/insights";
import * as InsightsEditor from "./routes/insights-editor";
import * as InsightsNew from "./routes/insights-new";
import * as Settings from "./routes/settings";
import AppShellModule from "./layouts/app-shell";
import { loader as appShellLoader } from "./layouts/app-shell";
type RouteModule = {
default: React.ComponentType<any>;
loader?: RouteObject["loader"];
action?: RouteObject["action"];
ErrorBoundary?: React.ComponentType<any>;
};
function withRouteModule(module: RouteModule) {
return function WrappedRouteComponent() {
const loaderData = useLoaderData();
const actionData = useActionData();
const params = useParams();
return createElement(module.default, { loaderData, actionData, params });
};
}
function route(
path: string,
module: RouteModule,
extra: Omit<RouteObject, "path" | "Component" | "loader" | "action" | "index"> = {},
): RouteObject {
return {
path,
loader: module.loader,
action: module.action,
Component: withRouteModule(module),
ErrorBoundary: module.ErrorBoundary,
...extra,
};
}
function indexRoute(module: RouteModule): RouteObject {
return {
index: true,
loader: module.loader,
action: module.action,
Component: withRouteModule(module),
ErrorBoundary: module.ErrorBoundary,
};
}
export const routes: RouteObject[] = [
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
children: [
indexRoute(RedirectHome),
route("setup", Setup),
route("setup/complete", SetupComplete),
route("auth/login", AuthLogin),
{
loader: appShellLoader,
Component: withRouteModule({
default: AppShellModule,
}),
children: [
route("start", Start),
route("sessions/:sessionId", SessionDetail),
route("workflows", Workflows),
route("workflows/:name", WorkflowDetail, {
children: [
indexRoute(WorkflowDefinition),
route("diagram", WorkflowDiagram),
route("runs", WorkflowRuns),
],
}),
route("runs", Runs),
route("runs/:id", RunDetail, {
children: [
indexRoute(RunOverview),
route("stages/:stageId", RunStages),
route("settings", RunSettings),
route("graph", RunGraph),
route("files", RunFiles),
route("verification", RunVerification),
route("usage", RunUsage),
route("retro", RunRetro),
],
}),
route("verification/criteria", VerificationCriteria),
route("verification/criteria/:id", VerificationCriterion),
route("verification/controls", VerificationControls),
route("verification/controls/:id", VerificationControl),
route("retros", Retros),
route("insights", Insights, {
children: [
indexRoute(InsightsEditor),
route("new", InsightsNew),
],
}),
route("settings", Settings),
],
},
],
},
];

View file

@ -1,46 +0,0 @@
import {
type RouteConfig,
index,
layout,
route,
} from "@react-router/dev/routes";
export default [
index("routes/redirect-home.tsx"),
route("setup", "routes/setup.tsx"),
route("setup/callback", "routes/setup-callback.tsx"),
route("auth/login", "routes/auth-login.tsx"),
route("auth/callback", "routes/auth-callback.tsx"),
route("auth/logout", "routes/auth-logout.tsx"),
layout("layouts/app-shell.tsx", [
route("start", "routes/start.tsx"),
route("sessions/:sessionId", "routes/session-detail.tsx"),
route("workflows", "routes/workflows.tsx"),
route("workflows/:name", "routes/workflow-detail.tsx", [
index("routes/workflow-definition.tsx"),
route("diagram", "routes/workflow-diagram.tsx"),
route("runs", "routes/workflow-runs.tsx"),
]),
route("runs", "routes/runs.tsx"),
route("runs/:id", "routes/run-detail.tsx", [
index("routes/run-overview.tsx"),
route("stages/:stageId", "routes/run-stages.tsx"),
route("settings", "routes/run-settings.tsx"),
route("graph", "routes/run-graph.tsx"),
route("files", "routes/run-files.tsx"),
route("verification", "routes/run-verification.tsx"),
route("usage", "routes/run-usage.tsx"),
route("retro", "routes/run-retro.tsx"),
]),
route("verification/criteria", "routes/verification-criteria.tsx"),
route("verification/criteria/:id", "routes/verification-criterion.tsx"),
route("verification/controls", "routes/verification-controls.tsx"),
route("verification/controls/:id", "routes/verification-control.tsx"),
route("retros", "routes/retros.tsx"),
route("insights", "routes/insights.tsx", [
index("routes/insights-editor.tsx"),
route("new", "routes/insights-new.tsx"),
]),
route("settings", "routes/settings.tsx"),
]),
] satisfies RouteConfig;

View file

@ -1,66 +0,0 @@
import { redirect } from "react-router";
import { getAppConfig } from "../lib/config.server";
import { getGitHubOAuth } from "../lib/github.server";
import { getSession, commitSession } from "../lib/session.server";
import type { Route } from "./+types/auth-callback";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const cookies = request.headers.get("Cookie") ?? "";
const stateMatch = cookies.match(/fabro_oauth_state=([^;]+)/);
const storedState = stateMatch?.[1];
if (!code || !state || state !== storedState) {
throw redirect("/auth/login");
}
const github = getGitHubOAuth();
const tokens = await github.validateAuthorizationCode(code);
const accessToken = tokens.accessToken();
const [userResponse, emailsResponse] = await Promise.all([
fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${accessToken}` },
}),
fetch("https://api.github.com/user/emails", {
headers: { Authorization: `Bearer ${accessToken}` },
}),
]);
const profile = (await userResponse.json()) as {
id: number;
node_id: string;
login: string;
name: string | null;
avatar_url: string;
};
const emails = (await emailsResponse.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? "";
const { allowed_usernames } = getAppConfig().web.auth;
if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) {
throw redirect("/auth/login?error=unauthorized");
}
const session = await getSession(request);
session.set("userUrl", `https://github.com/${profile.login}`);
session.set("githubId", profile.id);
session.set("githubNodeId", profile.node_id);
session.set("login", profile.login);
session.set("name", profile.name ?? profile.login);
session.set("email", primaryEmail);
session.set("avatarUrl", profile.avatar_url);
session.set("accessToken", accessToken);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", "fabro_oauth_state=; HttpOnly; Path=/; Max-Age=0");
return redirect("/start", { headers });
}

View file

@ -1,40 +1,5 @@
import { redirect } from "react-router";
import { AuthLayout } from "../components/auth-layout";
import { getAppConfig } from "../lib/config.server";
import { getGitHubOAuth, generateState } from "../lib/github.server";
import type { Route } from "./+types/auth-login";
export function loader() {
const { provider } = getAppConfig().web.auth;
return { provider };
}
export function action({ request }: Route.ActionArgs) {
const github = getGitHubOAuth();
const state = generateState();
const authUrl = github.createAuthorizationURL(state, ["read:user", "user:email"]);
return redirect(authUrl.toString(), {
headers: {
"Set-Cookie": `fabro_oauth_state=${state}; HttpOnly; Path=/; Max-Age=600; SameSite=Lax`,
},
});
}
export default function AuthLogin({ loaderData }: Route.ComponentProps) {
if (loaderData.provider === "tailscale") {
return (
<AuthLayout>
<h1 className="text-center text-lg font-semibold text-fg">
Access via Tailscale
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
This app is protected by Tailscale. Make sure you are connected to your Tailscale network and your account is authorized.
</p>
</AuthLayout>
);
}
export default function AuthLogin() {
return (
<AuthLayout>
<h1 className="text-center text-lg font-semibold text-fg">
@ -43,15 +8,15 @@ export default function AuthLogin({ loaderData }: Route.ComponentProps) {
<p className="mt-2 text-center text-sm text-fg-3">
Authenticate with your GitHub account to continue.
</p>
<form method="POST" className="mt-6">
<button
type="submit"
<div className="mt-6">
<a
href="/auth/login/github"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
<GitHubMark />
Sign in with GitHub
</button>
</form>
</a>
</div>
</AuthLayout>
);
}

View file

@ -1,15 +0,0 @@
import { redirect } from "react-router";
import { getAppConfig } from "../lib/config.server";
import { getSession, destroySession } from "../lib/session.server";
import type { Route } from "./+types/auth-logout";
export async function action({ request }: Route.ActionArgs) {
const { provider } = getAppConfig().web.auth;
if (provider === "tailscale") {
return redirect("/start");
}
const session = await getSession(request);
return redirect("/auth/login", {
headers: { "Set-Cookie": await destroySession(session) },
});
}

View file

@ -1,11 +1,10 @@
import { Link, Outlet, useNavigate } from "react-router";
import { PlusIcon } from "@heroicons/react/24/outline";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { timeAgo } from "../lib/time";
import type { PaginatedSavedQueryList, PaginatedHistoryEntryList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/insights";
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Insights — Fabro" }];
}
@ -29,7 +28,7 @@ export interface HistoryEntry {
rowsReturned: number;
}
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const [{ data: apiQueries }, { data: apiHistory }] = await Promise.all([
apiJson<PaginatedSavedQueryList>("/insights/queries", { request }),
apiJson<PaginatedHistoryEntryList>("/insights/history", { request }),
@ -49,7 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
return { savedQueries, historyEntries };
}
export default function InsightsLayout({ loaderData }: Route.ComponentProps) {
export default function InsightsLayout({ loaderData }: any) {
const { savedQueries, historyEntries } = loaderData;
const navigate = useNavigate();

View file

@ -1,19 +1,24 @@
import { redirect } from "react-router";
import { getAppConfig } from "../lib/config.server";
import { isGitHubAppConfigured } from "../lib/github.server";
import { getUser } from "../lib/session.server";
import type { Route } from "./+types/redirect-home";
import { getAuthMe, getSetupStatus } from "../api";
export async function loader({ request }: Route.LoaderArgs) {
const { provider } = getAppConfig().web.auth;
if (provider === "github" && !isGitHubAppConfigured()) {
export async function loader() {
const setup = await getSetupStatus();
if (!setup.configured) {
return redirect("/setup");
}
if (provider !== "insecure_disabled") {
const user = await getUser(request);
if (!user) {
try {
await getAuthMe();
} catch (error) {
if (error instanceof Response && error.status === 401) {
return redirect("/auth/login");
}
throw error;
}
return redirect("/start");
}
export default function RedirectHome() {
return null;
}

View file

@ -3,9 +3,8 @@ import { useNavigate } from "react-router";
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { smoothnessConfig, formatDurationMs } from "../data/retros";
import type { SmoothnessRating } from "../data/retros";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRetroList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/retros";
interface RetroRow {
run_id: string;
@ -17,7 +16,7 @@ interface RetroRow {
friction_point_count: number;
}
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const { data: apiRetros } = await apiJson<PaginatedRetroList>("/retros", { request });
const retros: RetroRow[] = apiRetros.map((r) => ({
run_id: r.run.id,
@ -31,7 +30,7 @@ export async function loader({ request }: Route.LoaderArgs) {
return { retros };
}
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Retros \u2014 Fabro" }];
}
@ -71,7 +70,7 @@ function truncate(text: string, maxLength: number): string {
return text.slice(0, maxLength) + "\u2026";
}
export default function Retros({ loaderData }: Route.ComponentProps) {
export default function Retros({ loaderData }: any) {
const { retros } = loaderData;
const navigate = useNavigate();
const [query, setQuery] = useState("");

View file

@ -4,9 +4,8 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link, Outlet, useFetcher, useLocation } from "react-router";
import { columnNames, mapRunListItem, statusColors } from "../data/runs";
import type { ColumnStatus } from "../data/runs";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRunList, PreviewUrlResponse } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-detail";
const tabs = [
{ name: "Overview", path: "", count: null },
@ -19,7 +18,7 @@ const tabs = [
export const handle = { hideHeader: true };
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const response = await apiJson<PaginatedRunList>("/runs", { request });
const apiRun = response.data.find((r) => r.id === params.id);
if (!apiRun) return { run: null };
@ -32,7 +31,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
};
}
export async function action({ params, request }: Route.ActionArgs) {
export async function action({ params, request }: any) {
const formData = await request.formData();
const port = formData.get("port");
const expiresInSecs = formData.get("expires_in_secs");
@ -47,12 +46,12 @@ export async function action({ params, request }: Route.ActionArgs) {
return result;
}
export function meta({ data }: Route.MetaArgs) {
export function meta({ data }: any) {
const run = data?.run;
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
}
export default function RunDetail({ loaderData, params }: Route.ComponentProps) {
export default function RunDetail({ loaderData, params }: any) {
const { run } = loaderData;
const { pathname } = useLocation();
const basePath = `/runs/${params.id}`;

View file

@ -5,13 +5,12 @@ import {
type DiffLineAnnotation,
} from "@pierre/diffs/react";
import { useTheme } from "../lib/theme";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRunFileList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-files";
export const handle = { wide: true };
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const data = await apiJson<PaginatedRunFileList>(`/runs/${params.id}/files`, { request });
return data;
}
@ -483,7 +482,7 @@ function buildAnnotationsForFile(
return annotations;
}
export default function RunFiles({ loaderData }: Route.ComponentProps) {
export default function RunFiles({ loaderData }: any) {
const runFiles = loaderData;
const files = runFiles.data.length > 0
? runFiles.data.map((f) => ({

View file

@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiFetch, apiJson } from "../api-client";
import { apiFetch, apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-graph";
export const handle = { wide: true };
@ -22,7 +21,7 @@ interface Stage {
duration: string;
}
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const [{ data: apiStages }, graphRes] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiFetch(`/runs/${params.id}/graph`, { request }),
@ -190,7 +189,7 @@ function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraph
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
const DEFAULT_ZOOM_INDEX = 2;
export default function RunGraph({ loaderData }: Route.ComponentProps) {
export default function RunGraph({ loaderData }: any) {
const { id } = useParams();
const { stages, graphSvg } = loaderData;
const containerRef = useRef<HTMLDivElement>(null);

View file

@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, PaginatedRunList, WorkflowDetail } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-overview";
export const handle = { wide: true };
@ -21,7 +20,7 @@ interface Stage {
duration: string;
}
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const [{ data: apiStages }, response] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<PaginatedRunList>("/runs", { request }),
@ -286,7 +285,7 @@ function DotDiagram({ dot }: { dot: string }) {
);
}
export default function RunOverview({ loaderData }: Route.ComponentProps) {
export default function RunOverview({ loaderData }: any) {
const { id } = useParams();
const { stages, graphDot } = loaderData;

View file

@ -7,15 +7,14 @@ import {
formatDurationMs,
} from "../data/retros";
import type { Retro } from "../data/retros";
import { apiJson } from "../api-client";
import type { Route } from "./+types/run-retro";
import { apiJson } from "../api";
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const retro = await apiJson<Retro>(`/runs/${params.id}/retro`, { request });
return { retro };
}
export function meta({ data }: Route.MetaArgs) {
export function meta({ data }: any) {
const retro = data?.retro;
return [{ title: retro ? `Retro: ${retro.goal} \u2014 Fabro` : "Retro \u2014 Fabro" }];
}
@ -25,7 +24,7 @@ function formatCost(cost: number | undefined): string {
return `$${cost.toFixed(2)}`;
}
export default function RunRetro({ loaderData }: Route.ComponentProps) {
export default function RunRetro({ loaderData }: any) {
const { retro } = loaderData;
if (!retro) {

View file

@ -2,10 +2,9 @@ import { Link, useParams } from "react-router";
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { CollapsibleFile } from "../components/collapsible-file";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, RunSettings } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-settings";
export const handle = { wide: true };
@ -26,7 +25,7 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
};
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const [{ data: apiStages }, settings] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<RunSettings>(`/runs/${params.id}/settings`, { request }),
@ -40,7 +39,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
return { stages, settings };
}
export default function RunSettingsPage({ loaderData }: Route.ComponentProps) {
export default function RunSettingsPage({ loaderData }: any) {
const { id } = useParams();
const { stages, settings } = loaderData;

View file

@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
import { ToolRow, ToolBlock } from "../components/tool-use";
import type { ToolUse } from "../components/tool-use";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-stages";
export const handle = { wide: true };
@ -21,7 +20,7 @@ interface Stage {
duration: string;
}
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const { data: apiStages } = await apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request });
const stages: Stage[] = apiStages.map((s) => ({
id: s.id,
@ -84,7 +83,7 @@ function AssistantBlock({ content }: { content: string }) {
);
}
export default function RunStages({ loaderData }: Route.ComponentProps) {
export default function RunStages({ loaderData }: any) {
const { id, stageId } = useParams();
const { stages, turns: apiTurns } = loaderData;

View file

@ -1,9 +1,8 @@
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { RunUsage } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-usage";
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
const stages = usage.stages.map((s) => ({
stage: s.stage.name,
@ -33,7 +32,7 @@ function formatTokens(n: number) {
return `${(n / 1000).toFixed(1)}k`;
}
export default function RunUsage({ loaderData }: Route.ComponentProps) {
export default function RunUsage({ loaderData }: any) {
const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData;
return (
<div className="space-y-6">

View file

@ -19,11 +19,10 @@ import type {
VerificationType,
VerificationCategory,
} from "../data/verifications";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRunVerificationList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/run-verification";
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const { data: apiCategories } = await apiJson<PaginatedRunVerificationList>(`/runs/${params.id}/verification`, { request });
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
name: cat.name,
@ -135,7 +134,7 @@ function CategoryCard({ category }: { category: VerificationCategory }) {
);
}
export default function RunVerifications({ loaderData }: Route.ComponentProps) {
export default function RunVerifications({ loaderData }: any) {
const { categories } = loaderData;
return (
<div className="space-y-3">

View file

@ -20,11 +20,10 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/runs";
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Runs — Fabro" }];
}
@ -42,7 +41,7 @@ const columnConfig: {
{ id: "merge", name: "Merge", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
];
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const response = await apiJson<PaginatedRunList>("/runs", { request });
const apiRuns = response.data;
@ -497,9 +496,13 @@ function SortableRunRow({ run }: { run: RunWithStatus }) {
);
}
export default function Runs({ loaderData }: Route.ComponentProps) {
export default function Runs({ loaderData }: any) {
const initialColumns = loaderData.columns;
const allRepos = [...new Set(initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => item.repo)))].sort();
const allRepos = [
...new Set(
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
),
].sort();
const [query, setQuery] = useState("");
const [repoFilter, setRepoFilter] = useState("all");
const [view, setView] = useState<ViewMode>("columns");
@ -559,7 +562,7 @@ export default function Runs({ loaderData }: Route.ComponentProps) {
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
>
<option value="all">All repos</option>
{allRepos.map((repo) => (
{allRepos.map((repo: string) => (
<option key={repo} value={repo}>{repo}</option>
))}
</select>

View file

@ -10,17 +10,16 @@ import {
import { ToolRow, ToolBlock } from "../components/tool-use";
import type { ToolUse } from "../components/tool-use";
import { timeAgo, groupSessionsByDate } from "../lib/time";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { SessionDetail as ApiSessionDetail, PaginatedSessionList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/session-detail";
export const handle = { hideHeader: true, wide: true };
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Session — Fabro" }];
}
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const [apiSession, { data: apiSessions }] = await Promise.all([
apiJson<ApiSessionDetail>(`/sessions/${params.sessionId}`, { request }),
apiJson<PaginatedSessionList>("/sessions", { request }),
@ -234,7 +233,7 @@ const sessions: Record<string, Session> = {
{ id: "toolu_s3_02", toolName: "read_file", input: `{ "path": "package.json" }`, result: `"react-router-dom": "^6.22.0"`, isError: false, durationMs: 35 },
],
},
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `any` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
],
},
};
@ -392,7 +391,7 @@ function SessionSidebar({ activeId, groups }: { activeId: string; groups: Sessio
);
}
export default function SessionDetail({ loaderData }: Route.ComponentProps) {
export default function SessionDetail({ loaderData }: any) {
const { session, sessionGroups: loaderGroups } = loaderData;
return (

View file

@ -1,20 +1,19 @@
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { CollapsibleFile } from "../components/collapsible-file";
import type { ServerSettings } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/settings";
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Settings — Fabro" }];
}
export const handle = { hideHeader: true };
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const settings = await apiJson<ServerSettings>("/settings", { request });
return { settings };
}
export default function Settings({ loaderData }: Route.ComponentProps) {
export default function Settings({ loaderData }: any) {
const { settings } = loaderData;
return (

View file

@ -1,114 +0,0 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { randomBytes } from "node:crypto";
import { redirect } from "react-router";
import { parse, stringify } from "smol-toml";
import { FABRO_CONFIG_PATH, reloadAppConfig } from "../lib/config.server";
import { mergeEnv } from "../lib/merge-env";
import type { Route } from "./+types/setup-callback";
const ENV_PATH = resolve(import.meta.dirname, "../../../../.env");
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) {
return { error: "Missing code parameter" };
}
const response = await fetch(
`https://api.github.com/app-manifests/${code}/conversions`,
{ method: "POST", headers: { Accept: "application/vnd.github+json" } }
);
if (!response.ok) {
const body = await response.text();
return { error: `GitHub API error: ${response.status} ${body}` };
}
const data = (await response.json()) as {
id: number;
slug: string;
client_id: string;
client_secret: string;
webhook_secret: string;
pem: string;
};
const sessionSecret = randomBytes(32).toString("hex");
// Write non-secrets to TOML config
let tomlConfig: Record<string, unknown> = {};
try {
tomlConfig = parse(await readFile(FABRO_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
} catch {
// file doesn't exist yet
}
tomlConfig.git = {
...((tomlConfig.git as Record<string, unknown>) ?? {}),
provider: "github",
app_id: String(data.id),
client_id: data.client_id,
slug: data.slug,
};
await mkdir(dirname(FABRO_CONFIG_PATH), { recursive: true });
await writeFile(FABRO_CONFIG_PATH, stringify(tomlConfig), "utf-8");
reloadAppConfig();
// Write secrets to .env (merge to avoid duplicates on re-run)
let existing = "";
try {
existing = await readFile(ENV_PATH, "utf-8");
} catch {
// file doesn't exist yet
}
const envContent = mergeEnv(
existing,
new Map([
["SESSION_SECRET", sessionSecret],
["GITHUB_APP_CLIENT_SECRET", data.client_secret],
["GITHUB_APP_WEBHOOK_SECRET", data.webhook_secret],
["GITHUB_APP_PRIVATE_KEY", Buffer.from(data.pem).toString("base64")],
]),
);
await writeFile(ENV_PATH, envContent, "utf-8");
process.env.SESSION_SECRET = sessionSecret;
process.env.GITHUB_APP_CLIENT_SECRET = data.client_secret;
process.env.GITHUB_APP_WEBHOOK_SECRET = data.webhook_secret;
process.env.GITHUB_APP_PRIVATE_KEY = Buffer.from(data.pem).toString("base64");
throw redirect("/auth/login");
}
export default function SetupCallback({ loaderData }: Route.ComponentProps) {
const { error } = loaderData;
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-atmosphere px-4">
<div className="w-full max-w-sm">
<div className="mb-8 flex justify-center">
<img src="/logo.svg" alt="Fabro" className="h-12 w-12" draggable={false} />
</div>
<div className="rounded-xl border border-line bg-panel/80 p-8 shadow-lg backdrop-blur-sm">
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-coral/10">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="text-coral">
<path d="M7 7L13 13M13 7L7 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<h1 className="text-center text-lg font-semibold text-fg">
Setup failed
</h1>
<p className="mt-2 text-center text-sm text-fg-3">{error}</p>
<a
href="/setup"
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
>
Try again
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from "react";
import { AuthLayout } from "../components/auth-layout";
type SetupState = "registering" | "restart_required" | "ready" | "error";
export default function SetupComplete() {
const code = useMemo(() => new URLSearchParams(window.location.search).get("code"), []);
const [state, setState] = useState<SetupState>(code ? "registering" : "restart_required");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function register() {
if (!code) {
setState("restart_required");
return;
}
const response = await fetch("/api/v1/setup/register", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
if (!cancelled) {
setError(payload.error ?? "Setup registration failed.");
setState("error");
}
return;
}
if (!cancelled) {
setState("restart_required");
window.history.replaceState({}, "", "/setup/complete");
}
}
void register();
return () => {
cancelled = true;
};
}, [code]);
useEffect(() => {
if (state !== "restart_required") {
return;
}
const timer = window.setInterval(async () => {
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
if (!response.ok) {
return;
}
const payload = await response.json().catch(() => ({ configured: false }));
if (payload.configured) {
setState("ready");
}
}, 2000);
return () => {
window.clearInterval(timer);
};
}, [state]);
return (
<AuthLayout>
{state === "registering" && (
<>
<h1 className="text-center text-lg font-semibold text-fg">
Finishing setup
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
Registering your GitHub App and writing local configuration.
</p>
</>
)}
{state === "restart_required" && (
<>
<h1 className="text-center text-lg font-semibold text-fg">
Setup complete
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
Fabro loads auth configuration at process start, so a restart is required before sign-in.
</p>
<pre className="mt-6 rounded-lg border border-line bg-panel/80 px-4 py-3 text-center font-mono text-sm text-fg">
fabro server start
</pre>
<p className="mt-4 text-center text-xs text-fg-muted">
Waiting for the restarted server to come back...
</p>
</>
)}
{state === "ready" && (
<>
<h1 className="text-center text-lg font-semibold text-fg">
Restart detected
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
Authentication is ready.
</p>
<a
href="/auth/login"
className="mt-6 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
Continue to sign in
</a>
</>
)}
{state === "error" && (
<>
<h1 className="text-center text-lg font-semibold text-fg">
Setup failed
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
{error}
</p>
<a
href="/setup"
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
>
Try again
</a>
</>
)}
</AuthLayout>
);
}

View file

@ -1,40 +1,27 @@
import { redirect } from "react-router";
import { useMemo } from "react";
import { AuthLayout } from "../components/auth-layout";
import { isGitHubAppConfigured } from "../lib/github.server";
import type { Route } from "./+types/setup";
export function loader({ request }: Route.LoaderArgs) {
if (isGitHubAppConfigured()) {
return redirect("/");
}
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
const suffix = Math.random().toString(16).slice(2, 8);
const manifest = {
name: `Fabro-${suffix}`,
url: baseUrl,
redirect_url: `${baseUrl}/setup/callback`,
callback_urls: [`${baseUrl}/auth/callback`],
setup_url: `${baseUrl}/setup/callback`,
public: false,
default_permissions: {
contents: "write",
metadata: "read",
pull_requests: "write",
checks: "write",
issues: "write",
emails: "read",
},
default_events: [] as string[],
};
return { manifest: JSON.stringify(manifest), baseUrl };
}
export default function Setup({ loaderData }: Route.ComponentProps) {
const { manifest } = loaderData;
export default function Setup() {
const manifest = useMemo(() => {
const baseUrl = window.location.origin;
const suffix = Math.random().toString(16).slice(2, 8);
return JSON.stringify({
name: `Fabro-${suffix}`,
url: baseUrl,
redirect_url: `${baseUrl}/setup/complete`,
callback_urls: [`${baseUrl}/auth/callback/github`],
setup_url: `${baseUrl}/setup/complete`,
public: false,
default_permissions: {
contents: "write",
metadata: "read",
pull_requests: "write",
checks: "write",
issues: "write",
emails: "read",
},
default_events: [],
});
}, []);
return (
<AuthLayout>

View file

@ -20,20 +20,18 @@ import {
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router";
import { apiJson, getAuthMe } from "../api";
import { timeAgo, groupSessionsByDate } from "../lib/time";
import { apiJson } from "../api-client";
import { getAppConfig } from "../lib/config.server";
import type { PaginatedSessionList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/start";
export const handle = { hideHeader: true, wide: true };
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Start — Fabro" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const { features } = getAppConfig();
export async function loader({ request }: any) {
const { features } = await getAuthMe();
const { data: apiSessions } = await apiJson<PaginatedSessionList>("/sessions", { request });
const sessionGroups = groupSessionsByDate(
apiSessions.map((s) => ({ id: s.id, title: s.title, created_at: s.created_at }))
@ -98,7 +96,7 @@ function SessionSidebar({ groups }: { groups: { label: string; sessions: { id: s
);
}
export default function Start({ loaderData }: Route.ComponentProps) {
export default function Start({ loaderData }: any) {
const { sessionGroups, features } = loaderData;
const [prompt, setPrompt] = useState("");
const [project, setProject] = useState(projects[0]);

View file

@ -54,19 +54,18 @@ import type {
VerificationMode,
VerificationResult,
} from "../data/verifications";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { timeAgo } from "../lib/time";
import type { VerificationDetailResponse } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/verification-control";
export const handle = { hideHeader: true };
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const data = await apiJson<VerificationDetailResponse>(`/verification/controls/${params.id}`, { request });
return { data };
}
export function meta({ data }: Route.MetaArgs) {
export function meta({ data }: any) {
const name = data?.data?.control?.name ?? "Verification";
return [{ title: `${name} — Verification — Fabro` }];
}
@ -204,7 +203,7 @@ function ResultIcon({ result }: { result: VerificationResult }) {
return <MinusCircleIcon className={`size-4 ${config.color}`} />;
}
export default function VerificationDetail({ loaderData }: Route.ComponentProps) {
export default function VerificationDetail({ loaderData }: any) {
const { data } = loaderData;
const { control: controlInfo, performance: apiPerf, control_detail: apiDetail, recent_results: apiRecentResults, siblings: apiSiblings } = data;

View file

@ -40,18 +40,17 @@ import type {
VerificationType,
VerificationMode,
} from "../data/verifications";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { VerificationControlListItem } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/verification-controls";
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const { data: controls } = await apiJson<{ data: VerificationControlListItem[] }>("/verification/controls", { request });
return { controls };
}
export const handle = { wide: true };
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Controls — Verification — Fabro" }];
}
@ -114,7 +113,7 @@ function ModeBadge({ mode }: { mode: VerificationMode }) {
);
}
export default function VerificationControls({ loaderData }: Route.ComponentProps) {
export default function VerificationControls({ loaderData }: any) {
const { controls } = loaderData;
const navigate = useNavigate();

View file

@ -62,11 +62,10 @@ import type {
VerificationCategory,
CriterionPerformance,
} from "../data/verifications";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { VerificationCriterion as ApiVerificationCriterion } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/verification-criteria";
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const { data: apiCategories } = await apiJson<{ data: ApiVerificationCriterion[] }>("/verification/criteria", { request });
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
name: cat.name,
@ -96,7 +95,7 @@ export async function loader({ request }: Route.LoaderArgs) {
export const handle = { wide: true };
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Verifications — Fabro" }];
}
@ -385,7 +384,7 @@ function filterCategories(
.filter((category) => category.criteria.length > 0);
}
export default function Verifications({ loaderData }: Route.ComponentProps) {
export default function Verifications({ loaderData }: any) {
const { categories: verificationCategories, criterionPerformance } = loaderData;
const [view, setView] = useState<ViewMode>("grouped");
const [query, setQuery] = useState("");

View file

@ -50,18 +50,17 @@ import type {
VerificationMode,
VerificationResult,
} from "../data/verifications";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { VerificationCriterionDetail } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/verification-criterion";
export const handle = { hideHeader: true };
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const data = await apiJson<VerificationCriterionDetail>(`/verification/criteria/${params.id}`, { request });
return { data };
}
export function meta({ data }: Route.MetaArgs) {
export function meta({ data }: any) {
const name = data?.data?.name ?? "Criterion";
return [{ title: `${name} — Verification — Fabro` }];
}
@ -170,7 +169,7 @@ function EvaluationDots({ evaluations }: { evaluations: readonly VerificationRes
);
}
export default function VerificationCriterion({ loaderData }: Route.ComponentProps) {
export default function VerificationCriterion({ loaderData }: any) {
const { data } = loaderData;
const navigate = useNavigate();

View file

@ -1,8 +1,7 @@
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useParams } from "react-router";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { WorkflowDetail as ApiWorkflowDetail, RunSettings } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/workflow-detail";
export interface WorkflowEntry {
name: string;
@ -209,7 +208,7 @@ const tabs = [
export const handle = { hideHeader: true };
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const apiWorkflow = await apiJson<ApiWorkflowDetail>(`/workflows/${params.name}`, { request });
const workflow: WorkflowEntry = {
name: apiWorkflow.name,
@ -222,12 +221,12 @@ export async function loader({ request, params }: Route.LoaderArgs) {
return { workflow };
}
export function meta({ data }: Route.MetaArgs) {
export function meta({ data }: any) {
const title = data?.workflow?.name ?? "Workflow";
return [{ title: `${title} — Fabro` }];
}
export default function WorkflowDetail({ loaderData }: Route.ComponentProps) {
export default function WorkflowDetail({ loaderData }: any) {
const { name } = useParams();
const { pathname } = useLocation();
const workflow = loaderData.workflow;

View file

@ -3,11 +3,10 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin
import { Link, useParams } from "react-router";
import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/workflow-runs";
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ request, params }: any) {
const { data: apiRuns } = await apiJson<PaginatedRunList>(`/workflows/${params.name}/runs`, { request });
const runs: RunWithStatus[] = apiRuns.map((r) => ({
...mapRunListItem(r),
@ -68,7 +67,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
);
}
export default function WorkflowRuns({ loaderData }: Route.ComponentProps) {
export default function WorkflowRuns({ loaderData }: any) {
const { runs } = loaderData;
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<ColumnStatus | "all">("all");

View file

@ -13,12 +13,11 @@ import {
WrenchIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router";
import { apiJson } from "../api-client";
import { apiJson } from "../api";
import { timeAgo, timeUntil } from "../lib/time";
import type { PaginatedWorkflowList } from "@qltysh/fabro-api-client";
import type { Route } from "./+types/workflows";
export function meta({}: Route.MetaArgs) {
export function meta({}: any) {
return [{ title: "Workflows — Fabro" }];
}
@ -105,7 +104,7 @@ interface WorkflowData {
nextRun?: string;
}
export async function loader({ request }: Route.LoaderArgs) {
export async function loader({ request }: any) {
const { data: apiWorkflows } = await apiJson<PaginatedWorkflowList>("/workflows", { request });
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
name: w.name,
@ -202,7 +201,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
type TriggerFilter = "all" | "scheduled" | "manual";
export default function Workflows({ loaderData }: Route.ComponentProps) {
export default function Workflows({ loaderData }: any) {
const workflows = enrichWorkflows(loaderData.workflows);
const [query, setQuery] = useState("");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");

BIN
apps/fabro-web/dist/apple-touch-icon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

2
apps/fabro-web/dist/assets/app.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
import{E as a}from"./chunk-k28qfdjw.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=B2B8C7571ED9C9F664756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "B2B8C7571ED9C9F664756E2164756E21",
"names": []
}

View file

@ -0,0 +1,3 @@
import{O as a}from"./chunk-n1k68xa8.js";import"./chunk-pectm3zk.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=A6C25A91CA86528764756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "A6C25A91CA86528764756E2164756E21",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
import{A as a}from"./chunk-3m9zvayn.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=2BADFA3D6582DF5E64756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "2BADFA3D6582DF5E64756E2164756E21",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
import{z as a}from"./chunk-mshhqkhw.js";import"./chunk-eew9p379.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=404DC8C86C76ABB464756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "404DC8C86C76ABB464756E2164756E21",
"names": []
}

View file

@ -0,0 +1,3 @@
import{d as a}from"./chunk-wzb9zsp9.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=B3D290E5FB496EF464756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "B3D290E5FB496EF464756E2164756E21",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
import{u as a}from"./chunk-4v5fshv2.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=0D1B315872C84EA364756E2164756E21

View file

@ -0,0 +1,9 @@
{
"version": 3,
"sources": [],
"sourcesContent": [
],
"mappings": "",
"debugId": "0D1B315872C84EA364756E2164756E21",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
import{v as e}from"./chunk-8mhds0wh.js";import"./chunk-q07bg6gn.js";var t=Object.freeze(JSON.parse('{"displayName":"Git Commit Message","name":"git-commit","patterns":[{"begin":"(?=^diff --git)","contentName":"source.diff","end":"\\\\z","name":"meta.embedded.diff.git-commit","patterns":[{"include":"source.diff"}]},{"begin":"^(?!#)","end":"^(?=#)","name":"meta.scope.message.git-commit","patterns":[{"captures":{"1":{"name":"invalid.deprecated.line-too-long.git-commit"},"2":{"name":"invalid.illegal.line-too-long.git-commit"}},"match":"\\\\G.{0,50}(.{0,22}(.*))$","name":"meta.scope.subject.git-commit"}]},{"begin":"^(?=#)","contentName":"comment.line.number-sign.git-commit","end":"^(?!#)","name":"meta.scope.metadata.git-commit","patterns":[{"captures":{"1":{"name":"markup.changed.git-commit"}},"match":"^#\\\\t((modified|renamed):.*)$"},{"captures":{"1":{"name":"markup.inserted.git-commit"}},"match":"^#\\\\t(new file:.*)$"},{"captures":{"1":{"name":"markup.deleted.git-commit"}},"match":"^#\\\\t(deleted.*)$"},{"captures":{"1":{"name":"keyword.other.file-type.git-commit"},"2":{"name":"string.unquoted.filename.git-commit"}},"match":"^#\\\\t([^:]+): *(.*)$"}]}],"scopeName":"text.git-commit","embeddedLangs":["diff"]}')),i=[...e,t];export{i as default};
//# debugId=FF6F1DB5E1831DE564756E2164756E21

View file

@ -0,0 +1,10 @@
{
"version": 3,
"sources": ["../../../../node_modules/.bun/@shikijs+langs@3.23.0/node_modules/@shikijs/langs/dist/git-commit.mjs"],
"sourcesContent": [
"import diff from './diff.mjs'\n\nconst lang = Object.freeze(JSON.parse(\"{\\\"displayName\\\":\\\"Git Commit Message\\\",\\\"name\\\":\\\"git-commit\\\",\\\"patterns\\\":[{\\\"begin\\\":\\\"(?=^diff --git)\\\",\\\"contentName\\\":\\\"source.diff\\\",\\\"end\\\":\\\"\\\\\\\\z\\\",\\\"name\\\":\\\"meta.embedded.diff.git-commit\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"source.diff\\\"}]},{\\\"begin\\\":\\\"^(?!#)\\\",\\\"end\\\":\\\"^(?=#)\\\",\\\"name\\\":\\\"meta.scope.message.git-commit\\\",\\\"patterns\\\":[{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"invalid.deprecated.line-too-long.git-commit\\\"},\\\"2\\\":{\\\"name\\\":\\\"invalid.illegal.line-too-long.git-commit\\\"}},\\\"match\\\":\\\"\\\\\\\\G.{0,50}(.{0,22}(.*))$\\\",\\\"name\\\":\\\"meta.scope.subject.git-commit\\\"}]},{\\\"begin\\\":\\\"^(?=#)\\\",\\\"contentName\\\":\\\"comment.line.number-sign.git-commit\\\",\\\"end\\\":\\\"^(?!#)\\\",\\\"name\\\":\\\"meta.scope.metadata.git-commit\\\",\\\"patterns\\\":[{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.changed.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t((modified|renamed):.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.inserted.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t(new file:.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"markup.deleted.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t(deleted.*)$\\\"},{\\\"captures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.other.file-type.git-commit\\\"},\\\"2\\\":{\\\"name\\\":\\\"string.unquoted.filename.git-commit\\\"}},\\\"match\\\":\\\"^#\\\\\\\\t([^:]+): *(.*)$\\\"}]}],\\\"scopeName\\\":\\\"text.git-commit\\\",\\\"embeddedLangs\\\":[\\\"diff\\\"]}\"))\n\nexport default [\n...diff,\nlang\n]\n"
],
"mappings": "0EAEA,FAAM,EAAO,OAAO,OAAO,KAAK,MAAM,umCAA2vC,CAAC,EAEnxC,GACf,GAAG,EACH,CACA",
"debugId": "FF6F1DB5E1831DE564756E2164756E21",
"names": []
}

View file

@ -0,0 +1,3 @@
import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"displayName":"Fluent","name":"fluent","patterns":[{"include":"#comment"},{"include":"#message"},{"include":"#wrong-line"}],"repository":{"attributes":{"begin":"\\\\s*(\\\\.[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.attribute-begin.fluent"}},"end":"^(?=\\\\s*[^.])","patterns":[{"include":"#placeable"}]},"comment":{"match":"^##?#?\\\\s.*$","name":"comment.fluent"},"function-comma":{"match":",","name":"support.function.function-comma.fluent"},"function-named-argument":{"begin":"([0-9A-Za-z]+:)\\\\s*([\\"0-9A-Za-z]+)","beginCaptures":{"1":{"name":"support.function.named-argument.name.fluent"},"2":{"name":"variable.other.named-argument.value.fluent"}},"end":"(?=[),\\\\s])","name":"variable.other.named-argument.fluent"},"function-positional-argument":{"match":"\\\\$[-0-9A-Z_a-z]+","name":"variable.other.function.positional-argument.fluent"},"invalid-placeable-string-missing-end-quote":{"match":"\\"[^\\"]+$","name":"invalid.illegal.wrong-placeable-missing-end-quote.fluent"},"invalid-placeable-wrong-placeable-missing-end":{"match":"([^A-Z}]*|[^-][^>])$\\\\b","name":"invalid.illegal.wrong-placeable-missing-end.fluent"},"message":{"begin":"^(-?[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.message-identifier.fluent"}},"contentName":"string.fluent","end":"^(?=\\\\S)","patterns":[{"include":"#attributes"},{"include":"#placeable"}]},"placeable":{"begin":"(\\\\{)","beginCaptures":{"1":{"name":"keyword.placeable.begin.fluent"}},"contentName":"variable.other.placeable.content.fluent","end":"(})","endCaptures":{"1":{"name":"keyword.placeable.end.fluent"}},"patterns":[{"include":"#placeable-string"},{"include":"#placeable-function"},{"include":"#placeable-reference-or-number"},{"include":"#selector"},{"include":"#invalid-placeable-wrong-placeable-missing-end"},{"include":"#invalid-placeable-string-missing-end-quote"},{"include":"#invalid-placeable-wrong-function-name"}]},"placeable-function":{"begin":"([A-Z][-0-9A-Z_]*\\\\()","beginCaptures":{"1":{"name":"support.function.placeable-function.call.begin.fluent"}},"contentName":"string.placeable-function.fluent","end":"(\\\\))","endCaptures":{"1":{"name":"support.function.placeable-function.call.end.fluent"}},"patterns":[{"include":"#function-comma"},{"include":"#function-positional-argument"},{"include":"#function-named-argument"}]},"placeable-reference-or-number":{"match":"(([-$])[-0-9A-Z_a-z]+|[A-Za-z][-0-9A-Z_a-z]*|[0-9]+)","name":"variable.other.placeable.reference-or-number.fluent"},"placeable-string":{"begin":"(\\")(?=[^\\\\n]*\\")","beginCaptures":{"1":{"name":"variable.other.placeable-string-begin.fluent"}},"contentName":"string.placeable-string-content.fluent","end":"(\\")","endCaptures":{"1":{"name":"variable.other.placeable-string-end.fluent"}}},"selector":{"begin":"(->)","beginCaptures":{"1":{"name":"support.function.selector.begin.fluent"}},"contentName":"string.selector.content.fluent","end":"^(?=\\\\s*})","patterns":[{"include":"#selector-item"}]},"selector-item":{"begin":"(\\\\s*\\\\*?\\\\[)([-0-9A-Z_a-z]+)(]\\\\s*)","beginCaptures":{"1":{"name":"support.function.selector-item.begin.fluent"},"2":{"name":"variable.other.selector-item.begin.fluent"},"3":{"name":"support.function.selector-item.begin.fluent"}},"contentName":"string.selector-item.content.fluent","end":"^(?=(\\\\s*})|(\\\\s*\\\\[)|(\\\\s*\\\\*))","patterns":[{"include":"#placeable"}]},"wrong-line":{"match":".*","name":"invalid.illegal.wrong-line.fluent"}},"scopeName":"source.ftl","aliases":["ftl"]}')),n=[e];export{n as default};
//# debugId=ECEE1DD8C17D275564756E2164756E21

View file

@ -0,0 +1,10 @@
{
"version": 3,
"sources": ["../../../../node_modules/.bun/@shikijs+langs@3.23.0/node_modules/@shikijs/langs/dist/fluent.mjs"],
"sourcesContent": [
"const lang = Object.freeze(JSON.parse(\"{\\\"displayName\\\":\\\"Fluent\\\",\\\"name\\\":\\\"fluent\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#comment\\\"},{\\\"include\\\":\\\"#message\\\"},{\\\"include\\\":\\\"#wrong-line\\\"}],\\\"repository\\\":{\\\"attributes\\\":{\\\"begin\\\":\\\"\\\\\\\\s*(\\\\\\\\.[A-Za-z][-0-9A-Z_a-z]*\\\\\\\\s*=\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.class.attribute-begin.fluent\\\"}},\\\"end\\\":\\\"^(?=\\\\\\\\s*[^.])\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable\\\"}]},\\\"comment\\\":{\\\"match\\\":\\\"^##?#?\\\\\\\\s.*$\\\",\\\"name\\\":\\\"comment.fluent\\\"},\\\"function-comma\\\":{\\\"match\\\":\\\",\\\",\\\"name\\\":\\\"support.function.function-comma.fluent\\\"},\\\"function-named-argument\\\":{\\\"begin\\\":\\\"([0-9A-Za-z]+:)\\\\\\\\s*([\\\\\\\"0-9A-Za-z]+)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.named-argument.name.fluent\\\"},\\\"2\\\":{\\\"name\\\":\\\"variable.other.named-argument.value.fluent\\\"}},\\\"end\\\":\\\"(?=[),\\\\\\\\s])\\\",\\\"name\\\":\\\"variable.other.named-argument.fluent\\\"},\\\"function-positional-argument\\\":{\\\"match\\\":\\\"\\\\\\\\$[-0-9A-Z_a-z]+\\\",\\\"name\\\":\\\"variable.other.function.positional-argument.fluent\\\"},\\\"invalid-placeable-string-missing-end-quote\\\":{\\\"match\\\":\\\"\\\\\\\"[^\\\\\\\"]+$\\\",\\\"name\\\":\\\"invalid.illegal.wrong-placeable-missing-end-quote.fluent\\\"},\\\"invalid-placeable-wrong-placeable-missing-end\\\":{\\\"match\\\":\\\"([^A-Z}]*|[^-][^>])$\\\\\\\\b\\\",\\\"name\\\":\\\"invalid.illegal.wrong-placeable-missing-end.fluent\\\"},\\\"message\\\":{\\\"begin\\\":\\\"^(-?[A-Za-z][-0-9A-Z_a-z]*\\\\\\\\s*=\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.class.message-identifier.fluent\\\"}},\\\"contentName\\\":\\\"string.fluent\\\",\\\"end\\\":\\\"^(?=\\\\\\\\S)\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#attributes\\\"},{\\\"include\\\":\\\"#placeable\\\"}]},\\\"placeable\\\":{\\\"begin\\\":\\\"(\\\\\\\\{)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.placeable.begin.fluent\\\"}},\\\"contentName\\\":\\\"variable.other.placeable.content.fluent\\\",\\\"end\\\":\\\"(})\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"keyword.placeable.end.fluent\\\"}},\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable-string\\\"},{\\\"include\\\":\\\"#placeable-function\\\"},{\\\"include\\\":\\\"#placeable-reference-or-number\\\"},{\\\"include\\\":\\\"#selector\\\"},{\\\"include\\\":\\\"#invalid-placeable-wrong-placeable-missing-end\\\"},{\\\"include\\\":\\\"#invalid-placeable-string-missing-end-quote\\\"},{\\\"include\\\":\\\"#invalid-placeable-wrong-function-name\\\"}]},\\\"placeable-function\\\":{\\\"begin\\\":\\\"([A-Z][-0-9A-Z_]*\\\\\\\\()\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.placeable-function.call.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.placeable-function.fluent\\\",\\\"end\\\":\\\"(\\\\\\\\))\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.placeable-function.call.end.fluent\\\"}},\\\"patterns\\\":[{\\\"include\\\":\\\"#function-comma\\\"},{\\\"include\\\":\\\"#function-positional-argument\\\"},{\\\"include\\\":\\\"#function-named-argument\\\"}]},\\\"placeable-reference-or-number\\\":{\\\"match\\\":\\\"(([-$])[-0-9A-Z_a-z]+|[A-Za-z][-0-9A-Z_a-z]*|[0-9]+)\\\",\\\"name\\\":\\\"variable.other.placeable.reference-or-number.fluent\\\"},\\\"placeable-string\\\":{\\\"begin\\\":\\\"(\\\\\\\")(?=[^\\\\\\\\n]*\\\\\\\")\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"variable.other.placeable-string-begin.fluent\\\"}},\\\"contentName\\\":\\\"string.placeable-string-content.fluent\\\",\\\"end\\\":\\\"(\\\\\\\")\\\",\\\"endCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"variable.other.placeable-string-end.fluent\\\"}}},\\\"selector\\\":{\\\"begin\\\":\\\"(->)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.selector.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.selector.content.fluent\\\",\\\"end\\\":\\\"^(?=\\\\\\\\s*})\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#selector-item\\\"}]},\\\"selector-item\\\":{\\\"begin\\\":\\\"(\\\\\\\\s*\\\\\\\\*?\\\\\\\\[)([-0-9A-Z_a-z]+)(]\\\\\\\\s*)\\\",\\\"beginCaptures\\\":{\\\"1\\\":{\\\"name\\\":\\\"support.function.selector-item.begin.fluent\\\"},\\\"2\\\":{\\\"name\\\":\\\"variable.other.selector-item.begin.fluent\\\"},\\\"3\\\":{\\\"name\\\":\\\"support.function.selector-item.begin.fluent\\\"}},\\\"contentName\\\":\\\"string.selector-item.content.fluent\\\",\\\"end\\\":\\\"^(?=(\\\\\\\\s*})|(\\\\\\\\s*\\\\\\\\[)|(\\\\\\\\s*\\\\\\\\*))\\\",\\\"patterns\\\":[{\\\"include\\\":\\\"#placeable\\\"}]},\\\"wrong-line\\\":{\\\"match\\\":\\\".*\\\",\\\"name\\\":\\\"invalid.illegal.wrong-line.fluent\\\"}},\\\"scopeName\\\":\\\"source.ftl\\\",\\\"aliases\\\":[\\\"ftl\\\"]}\"))\n\nexport default [\nlang\n]\n"
],
"mappings": "4BAAA,IAAM,EAAO,OAAO,OAAO,KAAK,MAAM,09GAAo2H,CAAC,EAE53H,GACf,CACA",
"debugId": "ECEE1DD8C17D275564756E2164756E21",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more