Compare commits

...

2 Commits

Author SHA1 Message Date
Olivier 'reivilibre
92b00934a2 Replace config: use Valibot and allow multiple forges of one type
Signed-off-by: Olivier 'reivilibre <git.contact@librepush.net>
2025-09-30 22:44:57 +01:00
Olivier 'reivilibre
bff741c7df Disable 'noNonNullAssertion' lint
Signed-off-by: Olivier 'reivilibre <git.contact@librepush.net>
2025-09-30 22:44:57 +01:00
17 changed files with 238 additions and 170 deletions

View File

@ -8,9 +8,10 @@ DB_PASSWORD=
DB_HOST=localhost DB_HOST=localhost
DB_NAME=database DB_NAME=database
GITEA_URL=http://localhost:3001
GITEA_CLIENT_ID=
GITEA_CLIENT_SECRET=
SESSION_SECRET=your_session_secret_here SESSION_SECRET=your_session_secret_here
GITEA_REDIRECT_URI=http://localhost:3000/auth/callback
FORGE_TYPES=gitea FORGES_1_TYPE=gitea
FORGES_1_URL=http://localhost:3001
FORGES_1_CLIENTID=
FORGES_1_CLIENTSECRET=
FORGES_1_REDIRECTURI=http://localhost:3002/auth/callback

View File

@ -16,7 +16,10 @@
"enabled": true, "enabled": true,
"includes": ["src/**", "!src/types/openapi.d.ts"], "includes": ["src/**", "!src/types/openapi.d.ts"],
"rules": { "rules": {
"recommended": true "recommended": true,
"style": {
"noNonNullAssertion": "off"
}
} }
}, },
"javascript": { "javascript": {

View File

@ -13,9 +13,9 @@ export async function listAvailableRepos(
): Promise<ExpressResponse> { ): Promise<ExpressResponse> {
try { try {
const userId = req.session?.userId; const userId = req.session?.userId;
const forgeType = req.session?.forgeType; const forgeId = req.session?.forgeId;
if (!userId || !forgeType) { if (!userId || forgeId === undefined) {
return res.status(401).json({ error: "Not authenticated" }); return res.status(401).json({ error: "Not authenticated" });
} }
@ -24,7 +24,7 @@ export async function listAvailableRepos(
return res.status(401).json({ error: "Missing or expired access token" }); return res.status(401).json({ error: "Missing or expired access token" });
} }
const forge = createForge(forgeType); const forge = createForge(forgeId);
const repos = await forge.listRepositories(accessToken); const repos = await forge.listRepositories(accessToken);
return res.json(repos); return res.json(repos);

View File

@ -1,20 +1,14 @@
import { Pool } from "pg"; import { Pool } from "pg";
import { config } from "./env"; import { config } from "./index";
let pool: Pool; let pool: Pool;
const { DB_USER, DB_PASSWORD, DB_NAME } = config;
if (!DB_USER || !DB_PASSWORD || !DB_NAME) {
throw new Error("DB_USER, DB_PASSWORD, and DB_NAME must be set");
}
pool = new Pool({ pool = new Pool({
user: config.DB_USER, user: config.db.user,
host: config.DB_HOST, host: config.db.host,
database: config.DB_NAME, database: config.db.name,
password: config.DB_PASSWORD, password: config.db.password,
port: config.DB_PORT, port: config.db.port,
}); });
const connectDB = async () => { const connectDB = async () => {

View File

@ -1,55 +0,0 @@
type EnvMode = "development" | "production" | "test";
interface ProcessEnv {
NODE_ENV?: EnvMode;
APP_PORT?: string;
DB_USER: string;
DB_PASSWORD: string;
DB_HOST?: string;
DB_NAME: string;
DB_PORT?: string;
GITEA_URL?: string;
GITEA_CLIENT_ID?: string;
GITEA_CLIENT_SECRET?: string;
GITEA_REDIRECT_URI?: string;
FORGE_TYPES?: string; // comma-separated list of enabled forges
SESSION_SECRET: string;
}
const env = process.env as unknown as ProcessEnv;
function requiredEnv(key: keyof ProcessEnv): string {
const value = env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const config = {
NODE_ENV: env.NODE_ENV ?? "development",
APP_PORT: env.APP_PORT ? Number(env.APP_PORT) : 3000,
DB_HOST: env.DB_HOST ?? "localhost",
DB_NAME: requiredEnv("DB_NAME"),
DB_USER: requiredEnv("DB_USER"),
DB_PASSWORD: requiredEnv("DB_PASSWORD"),
DB_PORT: env.DB_PORT ? Number(env.DB_PORT) : 5432,
GITEA_URL: env.GITEA_URL ?? "http://localhost:3001",
GITEA_CLIENT_ID: env.GITEA_CLIENT_ID,
GITEA_CLIENT_SECRET: env.GITEA_CLIENT_SECRET,
GITEA_REDIRECT_URI:
env.GITEA_REDIRECT_URI ?? "http://localhost:3000/auth/callback",
FORGE_TYPES: (env.FORGE_TYPES ?? "gitea")
.split(",")
.map((f) => f.trim().toLowerCase()),
SESSION_SECRET: requiredEnv("SESSION_SECRET"),
IS_PRODUCTION: env.NODE_ENV === "production",
};
export const isProduction = config.NODE_ENV === "production";

View File

@ -0,0 +1,22 @@
import * as v from "valibot";
import { CONFIG_SCHEMA_KEYS, type Config, ConfigSchema } from "./schema";
import { transformEnvToConfig } from "./transformer";
function createConfig(): Config {
const rawConfig = transformEnvToConfig(
process.env as Record<string, string>,
CONFIG_SCHEMA_KEYS,
);
const result = v.safeParse(ConfigSchema, rawConfig);
if (!result.success) {
const issueStrings = result.issues.map(
(issue) => `- ${v.getDotPath(issue)}: ${issue.message}`,
);
throw new Error(`Invalid configuration:\n${issueStrings.join("\n")}`);
}
return result.output;
}
export const config = createConfig();

View File

@ -0,0 +1,76 @@
import * as v from "valibot";
const ForgeInstanceSchema = v.object({
type: v.picklist(["gitea", "github", "gitlab"]),
url: v.string(),
clientid: v.string(),
clientsecret: v.string(),
redirecturi: v.string(),
});
// Convert `{"1": {...}, "2": {...}}` to a `Map<number, { ... }>`
const ForgesSchema = v.pipe(
v.record(v.pipe(v.string(), v.digits()), ForgeInstanceSchema),
v.transform(
(obj) => new Map(Object.entries(obj).map(([k, v]) => [Number(k), v])),
),
);
// All these database configuration fields are optional;
// the Postgres client driver has defaults.
const DatabaseConfigSchema = v.object({
host: v.optional(v.string()),
port: v.optional(
v.pipe(v.string(), v.digits(), v.transform(Number), v.integer()),
),
name: v.optional(v.string()),
user: v.optional(v.string()),
password: v.optional(v.string()),
});
const AppConfigSchema = v.object({
port: v.pipe(
v.optional(v.string(), "3000"),
v.transform(Number),
v.integer(),
),
});
const SessionConfigSchema = v.object({
secret: v.string(),
});
export const ConfigSchema = v.object({
app: optionalObject(AppConfigSchema),
db: optionalObject(DatabaseConfigSchema),
forges: v.optional(ForgesSchema, {}),
session: SessionConfigSchema,
node: v.object({
env: v.picklist(["production", "development", "test"]),
}),
});
// Keys included in the config that should be parsed
// from environment variables.
export const CONFIG_SCHEMA_KEYS = [
"app",
"db",
"forges",
"session",
"node",
] satisfies (keyof Config)[];
export type Config = v.InferOutput<typeof ConfigSchema>;
export type DatabaseConfig = v.InferOutput<typeof DatabaseConfigSchema>;
export type ForgesConfig = v.InferOutput<typeof ForgesSchema>;
export type ForgeInstanceConfig = v.InferOutput<typeof ForgeInstanceSchema>;
// Wrap a `v.object` schema so that if the object isn't supplied,
// it gets filled in with defaults.
function optionalObject<
TEntries extends v.ObjectEntries,
TMessage extends v.ErrorMessage<v.ObjectIssue> | undefined,
>(sch: v.ObjectSchema<TEntries, TMessage>) {
const defaultObject = v.getDefaults(sch);
return v.optional(sch, defaultObject!);
}

View File

@ -0,0 +1,47 @@
/**
* Transforms environment variables into a hierarchical (nested) configuration object.
* Environment variables are expected to have underscore-delimited keys.
* Keys are lowercased.
*
* Only variables with root keys included in the allowedKeys array are processed.
*
* For example, the environment variable `DATABASE_HOST=localhost` would be
* transformed into `{ database: { host: "localhost" } }`
* as long as "database" is in the allowedKeys.
*/
export function transformEnvToConfig<Keys extends string[]>(
env: Record<string, string>,
allowedKeys: Keys,
): Record<(typeof allowedKeys)[number], unknown> {
const config: Record<string, unknown> = {};
for (const [keyPath, value] of Object.entries(env)) {
const keyPathParts = keyPath.toLowerCase().split("_");
if (!allowedKeys.includes(keyPathParts[0]!) || keyPathParts.length < 2) {
// Not an env var we care about.
continue;
}
// Start at the top-level config map, then
// descend into the deeper layers
// (following keyPath)
let current: Record<string, unknown> = config;
for (const part of keyPathParts.slice(0, -1)) {
if (!(part in current)) {
current[part] = {};
}
if (typeof current[part] !== "object") {
throw new Error(
`Mixed config types: can't descend into ${part} on ${keyPath}, have: ${current[part]}`,
);
}
current = current[part] as Record<string, unknown>;
}
// then set the value.
const lastPart = keyPathParts[keyPathParts.length - 1]!;
current[lastPart] = value;
}
return config;
}

View File

@ -1,16 +1,7 @@
import { pool } from "../../config/db"; import { pool } from "../../config/db";
import { FORGE_IDS } from "../../services/forges/constants";
import type { User } from "../models/user"; import type { User } from "../models/user";
class UserRepository { class UserRepository {
private resolveForgeId(forgeType: string): number {
const forgeId = FORGE_IDS[forgeType];
if (forgeId === undefined) {
throw new Error(`Unsupported forge type: ${forgeType}`);
}
return forgeId;
}
/** /**
* Find a user by internal Molci ID * Find a user by internal Molci ID
*/ */
@ -52,13 +43,11 @@ class UserRepository {
} }
async getOrCreateUser( async getOrCreateUser(
forgeType: string, forgeId: number,
forgeUserId: string, forgeUserId: string,
access_token?: string, access_token?: string,
token_expires_at?: Date, token_expires_at?: Date,
): Promise<User> { ): Promise<User> {
const forgeId = this.resolveForgeId(forgeType);
const result = await pool.query( const result = await pool.query(
`INSERT INTO users (forge_id, forge_user_id, access_token, token_expires_at) `INSERT INTO users (forge_id, forge_user_id, access_token, token_expires_at)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)

View File

@ -1,36 +1,42 @@
/** biome-ignore-all lint/complexity/useLiteralKeys: <oli said it was ok> */ /** biome-ignore-all lint/complexity/useLiteralKeys: <oli said it was ok> */
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { config } from "../config/env"; import { config } from "../config";
import { createForge, listAvailableForges } from "../services/forges"; import { createForge, listAvailableForgeIds } from "../services/forges";
import { getOrCreateUser } from "../services/user"; import { getOrCreateUser } from "../services/user";
const authRouter = Router(); const authRouter = Router();
authRouter.get("/providers", (_req, res) => { authRouter.get("/providers", (_req, res) => {
res.json({ providers: listAvailableForges() }); res.json({ providers: listAvailableForgeIds() });
}); });
authRouter.get("/login/:forgeType", (req: Request, res: Response) => { authRouter.get("/login/:forgeId", (req: Request, res: Response) => {
const forgeType = req.params["forgeType"]; const forgeIdParam = req.params["forgeId"];
if (!forgeType) { if (!forgeIdParam) {
res.status(400).json({ error: "Missing forgeType" }); res.status(400).json({ error: "Missing forgeId" });
return; return;
} }
const forgeId = parseInt(forgeIdParam, 10);
if (Number.isNaN(forgeId)) {
res.status(400).json({ error: "Invalid forgeId" });
return;
}
const isProduction = config.node.env === "production";
try { try {
const forge = createForge(forgeType); const forge = createForge(forgeId);
const authData = forge.getAuthorizationUrl(); const authData = forge.getAuthorizationUrl();
req.session.codeVerifier = authData.codeVerifier; req.session.codeVerifier = authData.codeVerifier;
req.session.forgeType = forgeType; req.session.forgeId = forgeId;
const cookieName = config.IS_PRODUCTION const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
? "__Host-oauth_state"
: "oauth_state";
// Store OAuth state in a cookie // Store OAuth state in a cookie
res.cookie(cookieName, authData.state, { res.cookie(cookieName, authData.state, {
secure: config.IS_PRODUCTION, secure: isProduction,
path: "/", path: "/",
httpOnly: true, httpOnly: true,
maxAge: 10 * 60 * 1000, // 10 minutes maxAge: 10 * 60 * 1000, // 10 minutes
@ -45,18 +51,17 @@ authRouter.get("/login/:forgeType", (req: Request, res: Response) => {
}); });
authRouter.get("/callback", async (req: Request, res: Response) => { authRouter.get("/callback", async (req: Request, res: Response) => {
const forgeType = req.session.forgeType; const forgeId = req.session.forgeId;
const codeVerifier = req.session.codeVerifier; const codeVerifier = req.session.codeVerifier;
const state = req.query["state"] as string; const state = req.query["state"] as string;
const code = req.query["code"] as string; const code = req.query["code"] as string;
const isProduction = config.node.env === "production";
const cookies = parseCookies(req); const cookies = parseCookies(req);
const cookieName = config.IS_PRODUCTION const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
? "__Host-oauth_state"
: "oauth_state";
const stateFromCookie = cookies[cookieName]; const stateFromCookie = cookies[cookieName];
if (!forgeType || !state || !code || !codeVerifier) { if (forgeId === undefined || !state || !code || !codeVerifier) {
res.status(400).json({ error: "Missing OAuth callback parameters" }); res.status(400).json({ error: "Missing OAuth callback parameters" });
return; return;
} }
@ -67,21 +72,21 @@ authRouter.get("/callback", async (req: Request, res: Response) => {
} }
try { try {
const forge = createForge(forgeType); const forge = createForge(forgeId);
const { accessToken, accessTokenExpiresAt } = const { accessToken, accessTokenExpiresAt } =
await forge.exchangeCodeForToken(code, codeVerifier); await forge.exchangeCodeForToken(code, codeVerifier);
const forgeUser = await forge.getUserInfo(accessToken); const forgeUser = await forge.getUserInfo(accessToken);
const internalUser = await getOrCreateUser( const internalUser = await getOrCreateUser(
forgeType, forgeId,
forgeUser.id.toString(), forgeUser.id.toString(),
accessToken, accessToken,
accessTokenExpiresAt, accessTokenExpiresAt,
); );
req.session.userId = internalUser.user_id; req.session.userId = internalUser.user_id;
req.session.forgeType = forgeType; req.session.forgeId = forgeId;
// Clear OAuth session data // Clear OAuth session data
delete req.session.codeVerifier; delete req.session.codeVerifier;
@ -110,9 +115,8 @@ authRouter.post("/logout", (req: Request, res: Response) => {
return; return;
} }
res.clearCookie("__Host-SessionID", { path: "/" }); res.clearCookie("__Host-SessionID", { path: "/" });
const cookieName = config.IS_PRODUCTION const isProduction = config.node.env === "production";
? "__Host-oauth_state" const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
: "oauth_state";
res.clearCookie(cookieName, { path: "/" }); res.clearCookie(cookieName, { path: "/" });
res.json({ success: true }); res.json({ success: true });
}); });

View File

@ -1,8 +1,8 @@
import express from "express"; import express from "express";
import session from "express-session"; import session from "express-session";
import { createOpenAPIBackend, createOpenAPIMiddleware } from "./api/openapi"; import { createOpenAPIBackend, createOpenAPIMiddleware } from "./api/openapi";
import { config } from "./config";
import { connectDB } from "./config/db"; import { connectDB } from "./config/db";
import { config, isProduction } from "./config/env";
import authRouter from "./routes/auth"; import authRouter from "./routes/auth";
import { seedForges } from "./util/seedforges"; import { seedForges } from "./util/seedforges";
@ -13,18 +13,17 @@ async function startServer() {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
const sessionCookieName = config.IS_PRODUCTION const isProduction = config.node.env === "production";
? "__Host-SessionID" const sessionCookieName = isProduction ? "__Host-SessionID" : "sessionID";
: "sessionID";
app.use( app.use(
session({ session({
name: sessionCookieName, name: sessionCookieName,
secret: config.SESSION_SECRET, secret: config.session.secret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: config.IS_PRODUCTION, // false in dev secure: isProduction, // false in dev
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
@ -37,7 +36,7 @@ async function startServer() {
const openapi = createOpenAPIBackend(); const openapi = createOpenAPIBackend();
app.use(createOpenAPIMiddleware(openapi)); app.use(createOpenAPIMiddleware(openapi));
const PORT = config.APP_PORT; const PORT = config.app.port;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log( console.log(
`Server running in ${isProduction ? "production" : "development"} mode on port ${PORT}`, `Server running in ${isProduction ? "production" : "development"} mode on port ${PORT}`,

View File

@ -1,5 +0,0 @@
export const FORGE_IDS: Record<string, number> = {
gitea: 0,
github: 1,
gitlab: 2,
};

View File

@ -1,26 +1,34 @@
import * as arctic from "arctic"; import * as arctic from "arctic";
import { config } from "../../config/env"; import type { ForgeInstanceConfig } from "../../config/schema";
import type { Forge } from "../../types/forge"; import type { Forge } from "../../types/forge";
import type { ForgeUser } from "../../types/forgeuser"; import type { ForgeUser } from "../../types/forgeuser";
import type { GiteaRepo } from "../../types/gitearepo"; import type { GiteaRepo } from "../../types/gitearepo";
import type { Repository } from "../../types/openapi"; import type { Repository } from "../../types/openapi";
export class GiteaForge implements Forge { export class GiteaForge implements Forge {
private gitea: arctic.Gitea; private gitea: arctic.Gitea;
private forgeId: number;
private baseUrl: string;
constructor() { constructor(forgeId: number, config: ForgeInstanceConfig) {
if (!config.GITEA_CLIENT_ID || !config.GITEA_CLIENT_SECRET) { this.forgeId = forgeId;
this.baseUrl = config.url;
if (!config.clientid || !config.clientsecret) {
throw new Error("Gitea OAuth2 credentials not configured"); throw new Error("Gitea OAuth2 credentials not configured");
} }
this.gitea = new arctic.Gitea( this.gitea = new arctic.Gitea(
config.GITEA_URL, config.url,
config.GITEA_CLIENT_ID, config.clientid,
config.GITEA_CLIENT_SECRET, config.clientsecret,
config.GITEA_REDIRECT_URI, config.redirecturi,
); );
} }
getForgeId(): number {
return this.forgeId;
}
getAuthorizationUrl() { getAuthorizationUrl() {
const state = arctic.generateState(); const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier(); const codeVerifier = arctic.generateCodeVerifier();
@ -51,7 +59,7 @@ export class GiteaForge implements Forge {
} }
async getUserInfo(accessToken: string): Promise<ForgeUser> { async getUserInfo(accessToken: string): Promise<ForgeUser> {
const res = await fetch(`${config.GITEA_URL}/api/v1/user`, { const res = await fetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: `token ${accessToken}` }, headers: { Authorization: `token ${accessToken}` },
}); });
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
@ -60,7 +68,7 @@ export class GiteaForge implements Forge {
} }
async listRepositories(accessToken: string): Promise<Repository[]> { async listRepositories(accessToken: string): Promise<Repository[]> {
const res = await fetch(`${config.GITEA_URL}/api/v1/user/repos`, { const res = await fetch(`${this.baseUrl}/api/v1/user/repos`, {
headers: { Authorization: `token ${accessToken}` }, headers: { Authorization: `token ${accessToken}` },
}); });
if (!res.ok) { if (!res.ok) {

View File

@ -1,31 +1,33 @@
import { config } from "../../config/env"; import { config } from "../../config";
import type { Forge } from "../../types/forge"; import type { Forge } from "../../types/forge";
import { GiteaForge } from "./gitea"; import { GiteaForge } from "./gitea";
/** /**
* Returns a Forge implementation for the given forgeType. * Returns a Forge implementation for the given forge ID.
* Throws an error if the forge is not enabled or not implemented. * Throws an error if the forge is not enabled or not implemented.
*/ */
export function createForge(forgeType: string): Forge { export function createForge(forgeId: number): Forge {
if (!config.FORGE_TYPES.includes(forgeType)) { const forgeConfig = config.forges.get(forgeId);
throw new Error(`Unsupported forge type: ${forgeType}`);
if (!forgeConfig) {
throw new Error(`Forge with ID ${forgeId} not found`);
} }
switch (forgeType) { switch (forgeConfig.type) {
case "gitea": case "gitea":
return new GiteaForge(); return new GiteaForge(forgeId, forgeConfig);
case "github": case "github":
throw new Error("GitHubForge not implemented yet"); throw new Error("GitHubForge not implemented yet");
case "gitlab": case "gitlab":
throw new Error("GitLabForge not implemented yet"); throw new Error("GitLabForge not implemented yet");
default: default:
throw new Error(`Unsupported forge type: ${forgeType}`); throw new Error(`Unsupported forge type: ${forgeConfig.type}`);
} }
} }
/** /**
* Returns the list of available forge types from config. * Returns the list of available forge IDs from config.
*/ */
export function listAvailableForges(): string[] { export function listAvailableForgeIds(): number[] {
return [...config.FORGE_TYPES]; return Array.from(config.forges.keys());
} }

View File

@ -27,13 +27,13 @@ export const insertUser = (
}; };
export const getOrCreateUser = ( export const getOrCreateUser = (
forgeType: string, forgeId: number,
forgeUserId: string, forgeUserId: string,
access_token?: string, access_token?: string,
token_expires_at?: Date, token_expires_at?: Date,
): Promise<User> => { ): Promise<User> => {
return userRepository.getOrCreateUser( return userRepository.getOrCreateUser(
forgeType, forgeId,
forgeUserId, forgeUserId,
access_token, access_token,
token_expires_at, token_expires_at,

View File

@ -3,7 +3,7 @@ import "express-session";
declare module "express-session" { declare module "express-session" {
interface SessionData { interface SessionData {
userId?: number; // internal DB ID userId?: number; // internal DB ID
forgeType?: string; // e.g., "gitea" or "github" forgeId?: number; // numeric forge ID
codeVerifier?: string; // PKCE code verifier for OAuth codeVerifier?: string; // PKCE code verifier for OAuth
} }
} }

View File

@ -1,36 +1,19 @@
import { config } from "../config";
import { pool } from "../config/db"; import { pool } from "../config/db";
import { config } from "../config/env";
import { FORGE_IDS } from "../services/forges/constants";
export async function seedForges(): Promise<void> { export async function seedForges(): Promise<void> {
for (const forgeType of config.FORGE_TYPES) { for (const [forgeId, forgeConfig] of config.forges) {
const forgeId = FORGE_IDS[forgeType];
if (forgeId === undefined) {
console.warn(`Skipping unknown forge type: ${forgeType}`);
continue;
}
let baseUrl: string;
switch (forgeType) {
case "gitea":
baseUrl = config.GITEA_URL;
break;
default:
console.warn(`Forge type ${forgeType} not supported`);
continue;
}
try { try {
await pool.query( await pool.query(
`INSERT INTO forges (forge_id, display_name, base_url) `INSERT INTO forges (forge_id, display_name, base_url)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (forge_id) ON CONFLICT (forge_id)
DO UPDATE SET base_url = EXCLUDED.base_url`, DO UPDATE SET base_url = EXCLUDED.base_url`,
[forgeId, forgeType, baseUrl], [forgeId, forgeConfig.type, forgeConfig.url],
); );
console.log(`Forge seeded: ${forgeType} (${baseUrl})`); console.log(`Forge seeded: ${forgeConfig.type} (${forgeConfig.url})`);
} catch (err) { } catch (err) {
console.error(`Failed to seed forge ${forgeType}:`, err); console.error(`Failed to seed forge ${forgeConfig.type}:`, err);
throw err; throw err;
} }
} }