mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
bec07c2218
commit
4aa5aae691
6 changed files with 139 additions and 9 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
76
apps/arc-web/app/lib/merge-env.test.ts
Normal file
76
apps/arc-web/app/lib/merge-env.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
46
apps/arc-web/app/lib/merge-env.ts
Normal file
46
apps/arc-web/app/lib/merge-env.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue