flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/lib/secrets.server.ts

93 lines3,821 bytesCodeBlame
1import { redirect } from "react-router";
2
3import type { Setting, SettingKind, SettingReader, SettingsOwner, User } from "@g1t/contracts";
4
5import { actions, projects } from "./services.server";
6
7export type SecretsData = {
8 rows: Setting[];
9 /** For a workspace: its projects' slugs, to link rows to. */
10 projects: string[];
11 error: string | null;
12};
13
14/** A repository's or a workspace's secrets and variables, as one list. */
15export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> {
16 const [rows, list] = await Promise.all([
17 actions.settings(actor, owner, "all"),
18 "workspace" in owner ? projects.list(owner.workspace, actor) : Promise.resolve(null),
19 ]);
20 return {
21 rows: rows.ok ? rows.value : [],
22 projects: list?.ok ? list.value.map((project) => project.slug) : [],
23 error: rows.ok ? null : rows.error.message,
24 };
25}
26
27export type SecretsAction = { error?: string };
28
29/** `KEY=value` lines, as a .env file has them; quotes around a value are dropped. */
30function parseDotenv(text: string): [string, string][] {
31 const pairs: [string, string][] = [];
32 for (const raw of text.split(/\r?\n/)) {
33 const line = raw.replace(/^\s*export\s+/, "").trim();
34 if (!line || line.startsWith("#")) continue;
35 const at = line.indexOf("=");
36 if (at <= 0) continue;
37 let value = line.slice(at + 1).trim();
38 if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
39 pairs.push([line.slice(0, at).trim(), value]);
40 }
41 return pairs;
42}
43
44/**
45 * Saves the side panel's form (one row, or many pasted as a .env file), or
46 * removes a row, then returns to the list.
47 */
48export async function actOnSecrets(owner: SettingsOwner, actor: User, form: FormData, page: string): Promise<SecretsAction> {
49 const intent = String(form.get("intent") ?? "");
50 const id = String(form.get("id") ?? "") || undefined;
51 if (intent === "delete") {
52 const removed = await actions.deleteSetting(actor, owner, "all", String(form.get("name") ?? ""), id);
53 if (!removed.ok) return { error: removed.error.message };
54 throw redirect(page);
55 }
56 const kind: SettingKind = form.get("type") === "config" ? "variable" : "secret";
57 const environments =
58 form.get("scope") === "some"
59 ? [
60 ...form.getAll("env").map(String),
61 ...String(form.get("envCustom") ?? "")
62 .split(",")
63 .map((name) => name.trim())
64 .filter(Boolean),
65 ]
66 : [];
67 if (form.get("scope") === "some" && environments.length === 0) {
68 return { error: "Choose at least one environment, or All environments." };
69 }
70 const availableTo = form.getAll("availableTo").map(String) as SettingReader[];
71 if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." };
72 const linked =
73 "workspace" in owner && form.get("reach") === "some" ? form.getAll("project").map(String) : [];
74 const note = String(form.get("note") ?? "");
75 const key = String(form.get("key") ?? "").trim();
76 const value = String(form.get("value") ?? "");
77 // A pasted .env file adds a row for each line.
78 const pasted = !id && key.includes("=") ? parseDotenv(key) : [];
79 const entries: [string, string | null][] = pasted.length > 0 ? pasted : [[key, value === "" && id ? null : value]];
80 for (const [name, entryValue] of entries) {
81 if (!name) return { error: "Give it a key." };
82 if (entryValue === "" && !id) return { error: `Give ${name} a value.` };
83 const saved = await actions.setSetting(actor, owner, kind, name, entryValue, {
84 id,
85 availableTo,
86 environments,
87 projects: "workspace" in owner ? linked : undefined,
88 note,
89 });
90 if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message };
91 }
92 throw redirect(page);
93}