Fix duplicate .env vars on re-setup, capture GitHub App slug

Replace append-based .env writing with mergeEnv() that upserts keys,
preventing duplicates when setup runs multiple times. Capture the GitHub
App slug from the manifest API response and persist it in TOML config
so we can construct installation URLs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-06 19:28:00 -05:00
parent bec07c2218
commit 4aa5aae691
6 changed files with 139 additions and 9 deletions

View file

@ -17,6 +17,7 @@ interface GitConfig {
provider: "github";
app_id: string | null;
client_id: string | null;
slug: string | null;
}
interface FeatureFlags {
@ -54,6 +55,7 @@ const GIT_DEFAULTS: GitConfig = {
provider: "github",
app_id: null,
client_id: null,
slug: null,
};
const FEATURE_FLAGS_DEFAULTS: FeatureFlags = {

View file

@ -0,0 +1,76 @@
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

@ -0,0 +1,46 @@
/**
* 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

@ -4,6 +4,7 @@ import { randomBytes } from "node:crypto";
import { redirect } from "react-router";
import { parse, stringify } from "smol-toml";
import { ARC_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");
@ -27,6 +28,7 @@ export async function loader({ request }: Route.LoaderArgs) {
const data = (await response.json()) as {
id: number;
slug: string;
client_id: string;
client_secret: string;
webhook_secret: string;
@ -47,12 +49,13 @@ export async function loader({ request }: Route.LoaderArgs) {
provider: "github",
app_id: String(data.id),
client_id: data.client_id,
slug: data.slug,
};
await mkdir(dirname(ARC_CONFIG_PATH), { recursive: true });
await writeFile(ARC_CONFIG_PATH, stringify(tomlConfig), "utf-8");
reloadAppConfig();
// Write secrets to .env
// Write secrets to .env (merge to avoid duplicates on re-run)
let existing = "";
try {
existing = await readFile(ENV_PATH, "utf-8");
@ -60,14 +63,15 @@ export async function loader({ request }: Route.LoaderArgs) {
// file doesn't exist yet
}
const newVars = [
`export SESSION_SECRET=${sessionSecret}`,
`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")}`,
].join("\n");
const envContent = existing ? `${existing.trimEnd()}\n\n${newVars}\n` : `${newVars}\n`;
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;

View file

@ -2568,6 +2568,7 @@ mod settings {
provider: GitProvider::Github,
app_id: Some("12345".into()),
client_id: Some("Iv1.abc123".into()),
slug: Some("arc-dev".into()),
},
feature_flags: FeatureFlags {
session_sandboxes: false,

View file

@ -71,6 +71,7 @@ pub struct GitConfig {
pub provider: GitProvider,
pub app_id: Option<String>,
pub client_id: Option<String>,
pub slug: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]