diff --git a/.env.example b/.env.example index d2576ff94..daa85d879 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,6 @@ export ARC_JWT_PRIVATE_KEY= export ARC_JWT_PUBLIC_KEY= export SESSION_SECRET= -export GITHUB_APP_ID= -export GITHUB_APP_CLIENT_ID= export GITHUB_APP_CLIENT_SECRET= export GITHUB_APP_WEBHOOK_SECRET= export GITHUB_APP_PRIVATE_KEY= \ No newline at end of file diff --git a/apps/arc-web/app/lib/config.server.ts b/apps/arc-web/app/lib/config.server.ts index 3c5fb657d..b317b9467 100644 --- a/apps/arc-web/app/lib/config.server.ts +++ b/apps/arc-web/app/lib/config.server.ts @@ -13,9 +13,16 @@ interface ApiConfig { authentication_strategy: "jwt" | "insecure_disabled"; } +interface GitConfig { + provider: "github"; + app_id: string | null; + client_id: string | null; +} + export interface AppConfig { auth: AuthConfig; api: ApiConfig; + git: GitConfig; } const AUTH_DEFAULTS: AuthConfig = { @@ -28,6 +35,12 @@ const API_DEFAULTS: ApiConfig = { authentication_strategy: "jwt", }; +const GIT_DEFAULTS: GitConfig = { + provider: "github", + app_id: null, + client_id: null, +}; + function loadAppConfig(): AppConfig { const configPath = join(homedir(), ".arc", "arc.toml"); @@ -40,10 +53,12 @@ function loadAppConfig(): AppConfig { const rawAuth = (raw.auth ?? {}) as Partial; const rawApi = (raw.api ?? {}) as Partial; + const rawGit = (raw.git ?? {}) as Partial; return { auth: { ...AUTH_DEFAULTS, ...rawAuth }, api: { ...API_DEFAULTS, ...rawApi }, + git: { ...GIT_DEFAULTS, ...rawGit }, }; } diff --git a/apps/arc-web/app/lib/github.server.ts b/apps/arc-web/app/lib/github.server.ts index 7ef6f832a..f2465055a 100644 --- a/apps/arc-web/app/lib/github.server.ts +++ b/apps/arc-web/app/lib/github.server.ts @@ -1,9 +1,10 @@ import { GitHub, generateState } from "arctic"; +import { getAppConfig } from "./config.server"; export { generateState }; export function getGitHubOAuth() { - const clientId = process.env.GITHUB_APP_CLIENT_ID; + 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"); @@ -12,7 +13,7 @@ export function getGitHubOAuth() { } export function isGitHubAppConfigured(): boolean { - return !!process.env.GITHUB_APP_CLIENT_ID; + return getAppConfig().git.client_id !== null; } export function getGitHubAppPrivateKey(): string { diff --git a/apps/arc-web/app/routes/setup-callback.tsx b/apps/arc-web/app/routes/setup-callback.tsx index 56a8e2651..38b1835ba 100644 --- a/apps/arc-web/app/routes/setup-callback.tsx +++ b/apps/arc-web/app/routes/setup-callback.tsx @@ -1,10 +1,13 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; +import { homedir } from "node:os"; import { randomBytes } from "node:crypto"; import { redirect } from "react-router"; +import { parse, stringify } from "smol-toml"; import type { Route } from "./+types/setup-callback"; const ENV_PATH = resolve(import.meta.dirname, "../../../../.env"); +const TOML_PATH = resolve(homedir(), ".arc", "arc.toml"); export async function loader({ request }: Route.LoaderArgs) { const url = new URL(request.url); @@ -33,6 +36,23 @@ export async function loader({ request }: Route.LoaderArgs) { const sessionSecret = randomBytes(32).toString("hex"); + // Write non-secrets to TOML config + let tomlConfig: Record = {}; + try { + tomlConfig = parse(await readFile(TOML_PATH, "utf-8")) as Record; + } catch { + // file doesn't exist yet + } + tomlConfig.git = { + ...((tomlConfig.git as Record) ?? {}), + provider: "github", + app_id: String(data.id), + client_id: data.client_id, + }; + await mkdir(resolve(homedir(), ".arc"), { recursive: true }); + await writeFile(TOML_PATH, stringify(tomlConfig), "utf-8"); + + // Write secrets to .env let existing = ""; try { existing = await readFile(ENV_PATH, "utf-8"); @@ -42,8 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) { const newVars = [ `export SESSION_SECRET=${sessionSecret}`, - `export GITHUB_APP_ID=${data.id}`, - `export GITHUB_APP_CLIENT_ID=${data.client_id}`, `export GITHUB_APP_CLIENT_SECRET=${data.client_secret}`, `export GITHUB_APP_WEBHOOK_SECRET=${data.webhook_secret}`, `export GITHUB_APP_PRIVATE_KEY=${Buffer.from(data.pem).toString("base64")}`, @@ -53,8 +71,6 @@ export async function loader({ request }: Route.LoaderArgs) { await writeFile(ENV_PATH, envContent, "utf-8"); process.env.SESSION_SECRET = sessionSecret; - process.env.GITHUB_APP_ID = String(data.id); - process.env.GITHUB_APP_CLIENT_ID = data.client_id; 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"); diff --git a/crates/arc-api/src/app_config.rs b/crates/arc-api/src/app_config.rs index 4cd1b2b69..da21ce8c6 100644 --- a/crates/arc-api/src/app_config.rs +++ b/crates/arc-api/src/app_config.rs @@ -57,6 +57,26 @@ impl Default for ApiConfig { } } +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum GitProvider { + Github, +} + +impl Default for GitProvider { + fn default() -> Self { + Self::Github + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct GitConfig { + #[serde(default)] + pub provider: GitProvider, + pub app_id: Option, + pub client_id: Option, +} + #[derive(Debug, Default, Deserialize)] pub struct AppConfig { pub data_dir: Option, @@ -64,6 +84,8 @@ pub struct AppConfig { pub auth: AuthConfig, #[serde(default)] pub api: ApiConfig, + #[serde(default)] + pub git: GitConfig, } /// Load app config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist. @@ -138,6 +160,11 @@ allowed_usernames = ["brynary", "alice"] [api] base_url = "http://example.com:8080" authentication_strategy = "jwt" + +[git] +provider = "github" +app_id = "12345" +client_id = "Iv1.abc123" "#; let config: AppConfig = toml::from_str(toml).unwrap(); assert_eq!(config.auth.provider, AuthProvider::Github); @@ -147,6 +174,9 @@ authentication_strategy = "jwt" config.api.authentication_strategy, ApiAuthenticationStrategy::Jwt ); + assert_eq!(config.git.provider, GitProvider::Github); + assert_eq!(config.git.app_id.as_deref(), Some("12345")); + assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123")); } #[test] @@ -168,6 +198,29 @@ authentication_strategy = "jwt" ); } + #[test] + fn parse_git_config() { + let toml = r#" +[git] +provider = "github" +app_id = "12345" +client_id = "Iv1.abc123" +"#; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.git.provider, GitProvider::Github); + assert_eq!(config.git.app_id.as_deref(), Some("12345")); + assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123")); + } + + #[test] + fn parse_git_defaults() { + let toml = ""; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.git.provider, GitProvider::Github); + assert_eq!(config.git.app_id, None); + assert_eq!(config.git.client_id, None); + } + #[test] fn parse_insecure_disabled_values() { let toml = r#"