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/deploy/self-host/configs.mjs

285 lines11,125 bytesCodeBlame
1#!/usr/bin/env node
2// Writes the Wrangler configs a self-hosted g1t runs with, derived from the
3// hosted ones, so the two never drift apart.
4//
5// Each hosted service's wrangler.jsonc is read and changed only where
6// Cloudflare-only things live:
7//
8// - account, routes, placement, observability and builds are dropped;
9// - ARTIFACTS (git storage) becomes a service binding to workers/artifacts,
10// which keeps repositories in the git store (gitstore/server.mjs);
11// - EMAIL (Email Sending) becomes a service binding to workers/mail;
12// - services that are off in this phase (agents, the context hub, the
13// g1t.page dispatcher, model proxy) are bound to workers/off instead, and
14// events stop queueing work for them;
15// - URLs that name g1t.sh name PUBLIC_URL instead, and billing is free.
16//
17// Usage: node configs.mjs [outDir]
18// Environment: PUBLIC_URL, GITSTORE_URL, GITSTORE_SECRET, MAIL_URL,
19// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, and optionally
20// your own GitHub App: GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_CLIENT_ID,
21// GITHUB_APP_CLIENT_SECRET, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET.
22//
23// The output is for `wrangler dev` (see start.sh): every Worker in one
24// workerd, the site first, with D1, KV and Queues kept on disk.
25
26import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
27import { dirname, join, relative, resolve } from "node:path";
28import { fileURLToPath } from "node:url";
29
30const here = dirname(fileURLToPath(import.meta.url));
31const root = resolve(here, "../..");
32const out = resolve(process.argv[2] ?? join(here, ".generated"));
33mkdirSync(out, { recursive: true });
34
35const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, "");
36
37// What runs, and what is off, is each unit's `self_host` in
38// deploy/stack.jsonc: the list hosted g1t deploys from.
39const STACK = Object.values(parseJsonc(readFileSync(join(root, "deploy/stack.jsonc"), "utf8")).units);
40
41/** Services that run, in the order Wrangler is given them (the site first). */
42export const RUNNING = STACK.filter((unit) => unit.self_host === "run")
43 .map((unit) => ({ name: unit.worker, dir: unit.path, web: unit.kind === "react-router" }))
44 .sort((a, b) => Number(b.web) - Number(a.web));
45
46/** What the off Worker calls each service that is off in phase 1. */
47const OFF_NAMES = {
48 "g1t-runner": "Agents",
49 "g1t-context": "Context search and memory",
50};
51const OFF = Object.fromEntries(
52 STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]),
53);
54
55/** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */
56const SECRETS = {
57 "g1t-actions": "ACTIONS_KEY",
58 "g1t-integrations": "INTEGRATIONS_KEY",
59 "g1t-webhooks": "WEBHOOKS_KEY",
60};
61
62/**
63 * g1t.sh's GitHub App is its own: an installation registers one of its
64 * own, or has none, and then no GitHub buttons appear. Its public settings
65 * replace the hosted vars; its secrets go only to the service that uses each.
66 */
67const GITHUB_VARS = ["GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID"];
68const GITHUB_SECRETS = {
69 "g1t-identity": ["GITHUB_APP_CLIENT_SECRET", "IDENTITY_KEY", "REGISTRATION_MODE"],
70 "g1t-integrations": ["GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_WEBHOOK_SECRET"],
71};
72
73/** Queues whose consumers are off: events stops sending to them. */
74const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]);
75
76/** Strips comments and trailing commas from JSONC. Strings are respected. */
77function parseJsonc(text) {
78 let result = "";
79 let inString = false;
80 for (let i = 0; i < text.length; i++) {
81 const char = text[i];
82 if (inString) {
83 result += char;
84 if (char === "\\") result += text[++i];
85 else if (char === '"') inString = false;
86 } else if (char === '"') {
87 inString = true;
88 result += char;
89 } else if (char === "/" && text[i + 1] === "/") {
90 while (i < text.length && text[i] !== "\n") i++;
91 result += "\n";
92 } else if (char === "/" && text[i + 1] === "*") {
93 i = text.indexOf("*/", i + 2) + 1;
94 } else {
95 result += char;
96 }
97 }
98 return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1"));
99}
100
101const rel = (path) => relative(out, resolve(root, path)).replaceAll("\\", "/");
102
103function hostedUrl(value) {
104 return typeof value === "string" ? value.replace(/https:\/\/(api\.)?g1t\.sh/g, PUBLIC_URL) : value;
105}
106
107function selfHosted(service) {
108 const hosted = parseJsonc(readFileSync(join(root, service.dir, "wrangler.jsonc"), "utf8"));
109 const config = {
110 name: hosted.name,
111 compatibility_date: hosted.compatibility_date,
112 compatibility_flags: hosted.compatibility_flags,
113 rules: hosted.rules,
114 vars: {},
115 };
116
117 if (service.web) {
118 // The site as React Router built it (apps/web/build), not its sources.
119 config.main = rel(`${service.dir}/build/server/index.js`);
120 config.no_bundle = true;
121 config.rules = [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }];
122 config.assets = { directory: rel(`${service.dir}/build/client`) };
123 } else {
124 config.main = rel(join(service.dir, hosted.main));
125 }
126
127 for (const [key, value] of Object.entries(hosted.vars ?? {})) config.vars[key] = hostedUrl(value);
128
129 if (hosted.d1_databases) {
130 config.d1_databases = hosted.d1_databases.map((db) => ({
131 binding: db.binding,
132 database_name: db.database_name,
133 database_id: db.database_id,
134 migrations_dir: rel(join(service.dir, db.migrations_dir ?? "migrations")),
135 }));
136 }
137 if (hosted.kv_namespaces) config.kv_namespaces = hosted.kv_namespaces.map(({ binding, id }) => ({ binding, id }));
138 if (hosted.triggers) config.triggers = hosted.triggers;
139
140 if (hosted.queues) {
141 config.queues = {};
142 if (hosted.queues.producers) {
143 config.queues.producers = hosted.queues.producers.filter((producer) => !OFF_QUEUES.has(producer.queue));
144 }
145 if (hosted.queues.consumers) config.queues.consumers = hosted.queues.consumers;
146 }
147
148 config.services = (hosted.services ?? []).map((binding) =>
149 OFF[binding.service] ? { binding: binding.binding, service: offName(binding.service) } : binding,
150 );
151
152 // Cloudflare-only bindings, and what stands in for them.
153 if (hosted.artifacts) {
154 for (const artifacts of hosted.artifacts) {
155 config.services.push({ binding: artifacts.binding, service: "g1t-artifacts" });
156 }
157 }
158 if (hosted.send_email) {
159 for (const email of hosted.send_email) config.services.push({ binding: email.name, service: "g1t-mail" });
160 }
161
162 // Secrets the hosted services hold, given here from the environment, each
163 // only to the service that uses it.
164 const secret = SECRETS[hosted.name];
165 if (secret && process.env[secret]) config.vars[secret] = process.env[secret];
166 for (const name of GITHUB_VARS) {
167 if (name in config.vars) config.vars[name] = process.env[name] ?? "";
168 }
169 for (const name of GITHUB_SECRETS[hosted.name] ?? []) {
170 if (process.env[name]) config.vars[name] = process.env[name];
171 }
172
173 // Self-hosted g1t charges nothing: billing records usage at cost and never
174 // stops work for it.
175 if (hosted.name === "g1t-billing") config.vars.FREE_WHILE_BUILDING = "true";
176 // Anyone may register on an installation of your own unless you set
177 // REGISTRATION_MODE=invite; invites then work as on g1t.sh, and the owners
178 // of INVITE_STAFF_WORKSPACES (yours, not g1t.sh's) invite without limit.
179 if (hosted.name === "g1t-identity") {
180 config.vars.REGISTRATION_MODE = process.env.REGISTRATION_MODE || "open";
181 config.vars.INVITE_STAFF_WORKSPACES = process.env.INVITE_STAFF_WORKSPACES ?? "";
182 if (process.env.INVITES_PER_USER) config.vars.INVITES_PER_USER = process.env.INVITES_PER_USER;
183 // Access requests are summarised to your own address, not g1t.sh's.
184 config.vars.WAITLIST_NOTIFY_EMAIL = process.env.WAITLIST_NOTIFY_EMAIL ?? "";
185 }
186 // Nothing to deploy to: deployments are off (no Cloudflare API token).
187 if (hosted.name === "g1t-deployments") delete config.vars.CUSTOM_HOSTNAMES_ZONE_ID;
188
189 return config;
190}
191
192function offName(service) {
193 return `${service}-off`;
194}
195
196function write(name, config) {
197 const path = join(out, `${name}.json`);
198 writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
199 return path;
200}
201
202const files = [];
203for (const service of RUNNING) files.push(write(service.name, selfHosted(service)));
204
205const compatibility_date = "2026-09-26";
206files.push(
207 write("g1t-artifacts", {
208 name: "g1t-artifacts",
209 main: rel("deploy/self-host/workers/artifacts/index.js"),
210 compatibility_date,
211 vars: {
212 GITSTORE_URL: process.env.GITSTORE_URL ?? "http://gitstore:8080",
213 GITSTORE_SECRET: process.env.GITSTORE_SECRET ?? "",
214 },
215 }),
216);
217files.push(
218 write("g1t-mail", {
219 name: "g1t-mail",
220 main: rel("deploy/self-host/workers/mail/index.js"),
221 compatibility_date,
222 vars: {
223 PUBLIC_URL,
224 MAIL_URL: process.env.MAIL_URL ?? "",
225 MAIL_FROM: process.env.MAIL_FROM ?? "",
226 },
227 }),
228);
229for (const [service, feature] of Object.entries(OFF)) {
230 files.push(
231 write(offName(service), {
232 name: offName(service),
233 main: rel("deploy/self-host/workers/off/index.js"),
234 compatibility_date,
235 vars: { OFF_NAME: feature },
236 }),
237 );
238}
239
240// The order Wrangler takes them in: the site first, as the one that serves.
241writeFileSync(join(out, "workers.txt"), `${files.map((file) => relative(out, file)).join("\n")}\n`);
242
243// The status page runs in a workerd of its own (status.sh, the `status`
244// service in docker-compose.yml), so it stays up when the site does not:
245// not in workers.txt. It checks the site from inside Compose
246// (STATUS_CHECK_URL) and links to it at PUBLIC_URL. Parts this
247// installation does not run (the API, MCP, docs, g1t.page, the model
248// proxy, billing) are left off its page; a public repository of yours in
249// STATUS_PROBE_REPO adds the git check.
250{
251 const hosted = parseJsonc(readFileSync(join(root, "apps/status/wrangler.jsonc"), "utf8"));
252 const db = hosted.d1_databases[0];
253 write("g1t-status", {
254 name: hosted.name,
255 main: rel(join("apps/status", hosted.main)),
256 compatibility_date: hosted.compatibility_date,
257 rules: hosted.rules,
258 triggers: hosted.triggers,
259 d1_databases: [
260 {
261 binding: db.binding,
262 database_name: db.database_name,
263 database_id: db.database_id,
264 migrations_dir: rel(join("apps/status", db.migrations_dir)),
265 },
266 ],
267 vars: {
268 SITE_URL: (process.env.STATUS_CHECK_URL ?? "http://g1t:8787").replace(/\/$/, ""),
269 PUBLIC_SITE_URL: PUBLIC_URL,
270 API_URL: "",
271 MCP_URL: "",
272 DOCS_URL: "",
273 PAGES_URL: "",
274 MODELS_URL: "",
275 PROBE_REPO: process.env.STATUS_PROBE_REPO ?? "",
276 SUPPORT_URL: `${PUBLIC_URL}/support`,
277 OG_IMAGE: "",
278 // Its own address, for links made outside a request. No email
279 // binding here: subscribing by email is off, the feeds work.
280 STATUS_URL: `http://localhost:${process.env.STATUS_PORT ?? "8788"}`,
281 STATUS_ALERT_EMAIL: "",
282 },
283 });
284}
285console.log(`Wrote ${files.length} configs to ${out}`);