Skip to content
346 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a985c01f43538af01'1import { CheckCircle2, Globe, Info, LoaderCircle, RotateCw, Trash2 } from "lucide-react";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2import { useEffect, useState } from "react";
Merge branch 'worktree-agent-a985c01f43538af01'3import { Form, Link } from "react-router";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains4
5import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings-domains";
8import { page } from "../../lib/meta";
Project settings move into the sidebar, grouped, with a heading per page9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
Merge branch 'worktree-agent-a985c01f43538af01'10import { Button, CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, usePending } from "../../components/ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains11import {
12 AlertDialog,
13 AlertDialogAction,
14 AlertDialogCancel,
15 AlertDialogContent,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogTitle,
20 AlertDialogTrigger,
21} from "../../components/ui/alert-dialog";
22import { CheckboxOption } from "../../components/ui/checkbox";
Merge branch 'worktree-agent-ae1299e92e4462012'23import { Hint } from "../../components/ui/hint";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains24import { deployments } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
26import { requireCapability, requireInsider } from "../../lib/access.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending27import { useRefreshWhile } from "../../lib/refresh";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` });
31}
32
33export async function loader({ params, context }: Route.LoaderArgs) {
34 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35 // Admins; to anyone without a role here the page does not exist.
36 await requireInsider(context, params, "manage_integrations");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains37 const ref = { workspace: params.owner, slug: params.repo };
38 const [settings, domains] = await Promise.all([deployments.settings(ref, viewer), deployments.domains(ref, viewer)]);
39 return { settings: unwrap(settings), ...unwrap(domains) };
40}
41
42export async function action({ request, params, context }: Route.ActionArgs) {
43 assertSameOrigin(request);
44 const user = requireUser(context, request);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45 await requireCapability(context, params, "manage_integrations");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains46 const form = await request.formData();
47 const ref = { workspace: params.owner, slug: params.repo };
48 const intent = form.get("intent");
49 const id = String(form.get("id") ?? "");
50 if (intent === "add") {
51 const added = await deployments.addDomain(user, ref, String(form.get("hostname") ?? ""), { twin: form.get("twin") === "on" });
52 if (!added.ok) return { error: added.error.message };
53 const names = added.value.map((d) => d.hostname);
54 return { notice: names.length ? `Added ${names.join(" and ")}. Add the DNS records below.` : "Saved." };
55 }
56 if (intent === "refresh") {
57 const checked = await deployments.refreshDomain(user, ref, id);
58 if (!checked.ok) return { error: checked.error.message };
59 return { notice: checked.value.status === "active" ? `${checked.value.hostname} is active.` : `Checked ${checked.value.hostname} again.` };
60 }
61 if (intent === "remove") {
62 const removed = await deployments.removeDomain(user, ref, id);
63 return removed.ok ? { notice: "Domain removed." } : { error: removed.error.message };
64 }
65 return { error: "Unknown request." };
66}
67
68const STATUS: Record<DomainStatus, { label: string; tone: string; live?: boolean }> = {
69 pending: { label: "Waiting for DNS", tone: "text-warn border-warn/30 bg-warn/5", live: true },
70 verifying: { label: "Issuing certificate", tone: "text-warn border-warn/30 bg-warn/5", live: true },
Merge branch 'worktree-agent-ad7c6d88d93adc817'71 active: { label: "Active", tone: "text-success border-success/30 bg-success/5" },
Agents and memory, checks and conflicts, profiles, slug renames, custom domains72 failed: { label: "Failed", tone: "text-danger border-danger/30 bg-danger/5" },
73 removing: { label: "Removing", tone: "text-faint border-line" },
74};
75
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas76/** A custom domain's monthly price, to the cent: `$0.12`. */
77function domainPrice(micros: number | undefined): string {
78 return `$${((micros ?? 120_000) / 1_000_000).toFixed(2)}`;
79}
80
Agents and memory, checks and conflicts, profiles, slug renames, custom domains81function StatusBadge({ status }: { status: DomainStatus }) {
82 const { label, tone, live } = STATUS[status];
83 return (
84 <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${tone}`}>
85 <span className={`size-1.5 rounded-full bg-current ${live ? "animate-pulse motion-reduce:animate-none" : ""}`} />
86 {label}
87 </span>
88 );
89}
90
91/** The www or apex twin of what is typed, for the pairing option's label. */
92function twinOf(input: string): string | null {
93 const host = input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, "");
94 if (!/^[a-z0-9.-]+\.[a-z0-9-]+$/.test(host)) return null;
95 if (host.startsWith("www.")) return host.slice(4).includes(".") ? host.slice(4) : null;
96 return host.split(".").length === 2 ? `www.${host}` : null;
97}
98
99export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas100 const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains101 const base = `/${params.owner}/${params.repo}`;
102 const [hostname, setHostname] = useState("");
103 const twin = twinOf(hostname);
104 const added = actionData && "notice" in actionData && !!actionData.notice?.startsWith("Added");
105 useEffect(() => {
106 if (added) setHostname("");
107 }, [added, actionData]);
108
109 // While a domain is on its way, the page follows it.
110 const waiting = domains.some((d) => d.status === "pending" || d.status === "verifying");
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending111 useRefreshWhile(waiting, 10_000);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains112
113 // A redirecting domain is shown under the one it redirects to.
114 const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo));
115
116 return (
117 <div className="max-w-4xl">
Project settings move into the sidebar, grouped, with a heading per page118 <RepoSettingsHeading base={base} />
Agents and memory, checks and conflicts, profiles, slug renames, custom domains119 <div className="min-h-6">
Merge branch 'worktree-agent-ad7c6d88d93adc817'120 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains121 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
122 </div>
123
124 <header className="mb-6">
125 <h2 className="flex items-center gap-2 font-medium">
126 <Globe size={16} className="text-accent" />
127 Custom domains
128 </h2>
129 <p className="mt-1 max-w-2xl text-sm text-muted">
130 Serve production at a domain of your own, such as <span className="font-mono text-fg">example.com</span> or{" "}
131 <span className="font-mono text-fg">www.example.com</span>, beside{" "}
132 <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>. Point it at{" "}
133 <span className="font-mono text-fg">{target}</span>; g1t issues and renews its certificate. Redeploys never change
134 it.{" "}
135 <a href="https://docs.g1t.sh/guides/deployments/#custom-domains" className="text-fg hover:underline">
136 How custom domains work
137 </a>
138 </p>
139 </header>
140
141 {!available && notice && (
142 <p className="mb-6 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/5 px-4 py-3 text-sm text-warn">
143 <Info size={15} className="mt-0.5 shrink-0" />
144 {notice}
145 </p>
146 )}
147
148 {!settings.enabled && (
149 <p className="mb-6 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
150 Deployments are off for this project, so a domain added now shows that nothing is deployed until they are on.{" "}
151 <Link to={`${base}/settings/deployments`} className="text-fg hover:underline">
152 Turn on deployments
153 </Link>
154 </p>
155 )}
156
157 <Form method="post" className="rounded-xl border border-line bg-surface p-5">
158 <input type="hidden" name="intent" value="add" />
159 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
160 <div className="grow">
161 <Field label="Domain" hint="A domain (the apex, such as example.com) or any subdomain, such as www.example.com or app.example.com.">
162 <Input
163 name="hostname"
164 value={hostname}
165 onChange={(event) => setHostname(event.target.value)}
166 placeholder="example.com"
167 autoComplete="off"
168 spellCheck={false}
169 required
170 />
171 </Field>
172 </div>
173 <div className="sm:mb-6">
Merge branch 'worktree-agent-a985c01f43538af01'174 <SubmitButton variant="accent" match={{ intent: "add" }} pending="Adding…" disabled={!hostname.trim()}>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains175 Add domain
Merge branch 'worktree-agent-a985c01f43538af01'176 </SubmitButton>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains177 </div>
178 </div>
179 <CheckboxOption
180 name="twin"
181 defaultChecked
182 disabled={!twin}
183 className="mt-2"
184 label={twin ? `Also add ${twin}, redirecting to ${hostname.trim().toLowerCase()}` : "Also add the www or apex twin, redirecting to it"}
185 description="Most sites answer at both example.com and www.example.com; one serves the app and the other sends visitors to it, path and query kept."
186 />
187 <p className="mt-4 text-xs text-faint">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas188 Each custom domain is {domainPrice(monthlyMicros)} a month on the g1t plan, its cost plus 20%, from the included usage
189 first. As many as you like: {used} in use across the workspace.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains190 </p>
191 </Form>
192
193 <div className="mt-8 space-y-4">
194 {domains.length === 0 ? (
195 <EmptyState title="No custom domains yet">Add one above, then the DNS records it asks for.</EmptyState>
196 ) : (
197 primary.map((domain) => (
198 <DomainCard
199 key={domain.id}
200 domain={domain}
201 redirects={domains.filter((d) => d.redirectTo === domain.hostname)}
202 />
203 ))
204 )}
205 </div>
206 </div>
207 );
208}
209
Merge branch 'worktree-agent-a985c01f43538af01'210function DomainCard({ domain, redirects }: { domain: Domain; redirects: Domain[] }) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains211 return (
212 <section className="overflow-hidden rounded-xl border border-line bg-surface">
Merge branch 'worktree-agent-a985c01f43538af01'213 <DomainRow domain={domain} />
Agents and memory, checks and conflicts, profiles, slug renames, custom domains214 {redirects.map((other) => (
215 <div key={other.id} className="border-t border-line">
Merge branch 'worktree-agent-a985c01f43538af01'216 <DomainRow domain={other} />
Agents and memory, checks and conflicts, profiles, slug renames, custom domains217 </div>
218 ))}
219 </section>
220 );
221}
222
Merge branch 'worktree-agent-a985c01f43538af01'223function DomainRow({ domain }: { domain: Domain }) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains224 const active = domain.status === "active";
225 return (
226 <div className="p-5">
227 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
228 {active ? (
229 <a href={`https://${domain.hostname}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
230 {domain.hostname}
231 </a>
232 ) : (
233 <span className="min-w-0 truncate font-mono text-sm font-medium">{domain.hostname}</span>
234 )}
235 <StatusBadge status={domain.status} />
236 {domain.redirectTo && <span className="text-xs text-muted">redirects to {domain.redirectTo}</span>}
237 <span className="ml-auto flex items-center gap-2">
238 {domain.status !== "removing" && (
239 <Form method="post">
240 <input type="hidden" name="intent" value="refresh" />
241 <input type="hidden" name="id" value={domain.id} />
Merge branch 'worktree-agent-ae1299e92e4462012'242 <Hint label="Ask Cloudflare to check the records again now">
243 <SubmitButton variant="quiet" match={{ intent: "refresh", id: domain.id }} pending="Checking…">
244 <RotateCw size={14} />
245 Check now
246 </SubmitButton>
247 </Hint>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains248 </Form>
249 )}
Merge branch 'worktree-agent-a985c01f43538af01'250 <RemoveDomain domain={domain} />
Agents and memory, checks and conflicts, profiles, slug renames, custom domains251 </span>
252 </div>
253 {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>}
254 {active ? (
255 <p className="mt-2 flex items-center gap-1.5 text-sm text-muted">
Merge branch 'worktree-agent-ad7c6d88d93adc817'256 <CheckCircle2 size={14} className="text-success" />
Agents and memory, checks and conflicts, profiles, slug renames, custom domains257 {domain.redirectTo ? "Redirecting, with its certificate." : "Serving production, with its certificate."}
258 {domain.verifiedAt && <span className="text-faint">Since {new Date(domain.verifiedAt).toLocaleDateString()}.</span>}
259 </p>
260 ) : (
261 <Records domain={domain} />
262 )}
263 </div>
264 );
265}
266
267function Records({ domain }: { domain: Domain }) {
268 return (
269 <div className="mt-4 space-y-3">
270 <p className="text-sm text-muted">Add these records where the domain's DNS is managed:</p>
271 {domain.records.map((record) => (
272 <RecordRow key={`${record.type}-${record.name}-${record.value}`} record={record} />
273 ))}
274 {domain.apex && (
275 <p className="text-xs text-faint">
276 The apex (the bare domain) cannot have a plain CNAME. Use your provider's flattened CNAME or ALIAS record: a CNAME
277 at <span className="font-mono">@</span> on Cloudflare DNS, an ALIAS on DNSimple, NS1, Namecheap, Porkbun or Gandi,
278 or an ANAME on DNS Made Easy. Route 53's alias records cannot point outside AWS. If your provider has none of
279 these, add only the www domain here and forward the apex to it at your provider.
280 </p>
281 )}
282 <p className="text-xs text-faint">
283 DNS changes can take a few minutes to an hour to be seen. The certificate is issued once the domain points here.
284 </p>
285 </div>
286 );
287}
288
289function RecordRow({ record }: { record: DomainRecord }) {
290 const type = record.type === "ALIAS" ? "CNAME (flattened) or ALIAS" : record.type;
291 return (
292 <div className="grid gap-2 rounded-lg border border-line bg-bg p-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
293 <div className="text-xs">
294 <p className="font-medium text-fg">{type}</p>
295 <p className="mt-0.5 text-faint">{record.purpose}</p>
296 </div>
297 <div className="grid min-w-0 gap-2">
298 <div>
299 <p className="mb-1 text-xs text-faint">Name</p>
300 <CopyLine text={record.name} />
301 </div>
302 <div>
303 <p className="mb-1 text-xs text-faint">{record.type === "TXT" ? "Content" : "Target"}</p>
304 <CopyLine text={record.value} />
305 </div>
306 </div>
307 </div>
308 );
309}
310
Merge branch 'worktree-agent-a985c01f43538af01'311function RemoveDomain({ domain }: { domain: Domain }) {
312 // The dialog closes as it posts, so the row's own button says it is going.
313 const removing = usePending({ intent: "remove", id: domain.id });
Agents and memory, checks and conflicts, profiles, slug renames, custom domains314 return (
315 <AlertDialog>
316 <AlertDialogTrigger asChild>
Merge branch 'worktree-agent-a985c01f43538af01'317 <Button type="button" variant="quiet" disabled={removing} aria-label={`Remove ${domain.hostname}`}>
318 {removing ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
319 {removing ? "Removing…" : "Remove"}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains320 </Button>
321 </AlertDialogTrigger>
322 <AlertDialogContent>
323 <Form method="post" className="grid gap-4">
324 <input type="hidden" name="intent" value="remove" />
325 <input type="hidden" name="id" value={domain.id} />
326 <AlertDialogHeader>
327 <AlertDialogTitle>Remove {domain.hostname}?</AlertDialogTitle>
328 <AlertDialogDescription>
329 It stops serving this project at once, and its certificate is given up. Any domain redirecting to it is
330 removed too. You can add it again later; its DNS records stay as you set them.
331 </AlertDialogDescription>
332 </AlertDialogHeader>
333 <AlertDialogFooter>
334 <AlertDialogCancel>Cancel</AlertDialogCancel>
335 <AlertDialogAction asChild>
336 <button type="submit">
337 <Trash2 size={14} />
338 Remove domain
339 </button>
340 </AlertDialogAction>
341 </AlertDialogFooter>
342 </Form>
343 </AlertDialogContent>
344 </AlertDialog>
345 );
346}

This file's history is long; its oldest lines are credited to the oldest commit read.