Skip to content

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

402 linesCodeBlameRaw
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// - GITSTORE (git storage, Cloudflare Artifacts) becomes a service binding to workers/gitstore,
10// which keeps repositories in the git store (gitstore/server.mjs);
11// - EMAIL (Email Sending) becomes a service binding to workers/mail;
12// - the packages service keeps files in S3-compatible storage (RustFS)
13// instead of R2, the repos service its nightly backups (a bucket of
14// their own, BACKUP_S3_BUCKET), and the artifacts service the files in pages
15// (DOCS_S3_BUCKET);
16// - services that are off in this phase (the cloud runner, the g1t.page
17// dispatcher) are bound to workers/off instead, and events stop queueing
18// work for them;
19// - URLs that name g1t.sh name PUBLIC_URL instead, and billing is free.
20//
21// Usage: node configs.mjs [outDir]
22// Environment: PUBLIC_URL, GITSTORE_URL, GITSTORE_SECRET, MAIL_URL,
23// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY, REPOS_KEY, BILLING_KEY,
24// USERCONTENT_URL,
25// PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, DOCS_S3_BUCKET, S3_REGION,
26// S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally
27// your own GitHub App: GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_CLIENT_ID,
28// GITHUB_APP_CLIENT_SECRET, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET.
29//
30// The output is for `wrangler dev` (see start.sh): every Worker in one
31// workerd, the site first, with D1, KV and Queues kept on disk.
32
33import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
34import { dirname, join, relative, resolve } from "node:path";
35import { fileURLToPath } from "node:url";
36
37const here = dirname(fileURLToPath(import.meta.url));
38const root = resolve(here, "../..");
39const out = resolve(process.argv[2] ?? join(here, ".generated"));
40mkdirSync(out, { recursive: true });
41
42const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, "");
43
44/**
45 * Where the API (REST, OAuth) is reached: API_URL, or PUBLIC_URL's host on
46 * API_PORT (8789). The MCP server is a path on it: MCP_URL, or
47 * `<API_URL>/mcp`. The API's OAuth issuer is API_URL.
48 */
49export function apiAddresses(env = process.env, publicUrl = PUBLIC_URL) {
50 let api = (env.API_URL ?? "").trim().replace(/\/$/, "");
51 if (!api) {
52 const url = new URL(publicUrl);
53 url.port = env.API_PORT || "8789";
54 api = url.origin;
55 }
56 const mcp = (env.MCP_URL ?? "").trim().replace(/\/$/, "") || `${api}/mcp`;
57 return { api, mcp };
58}
59const { api: API_URL, mcp: MCP_URL } = apiAddresses();
60
61// What runs, and what is off, is each unit's `self_host` in
62// deploy/stack.jsonc: the list hosted g1t deploys from.
63const STACK = Object.values(parseJsonc(readFileSync(join(root, "deploy/stack.jsonc"), "utf8")).units);
64
65/** Services that run, in the order Wrangler is given them (the site first). */
66export const RUNNING = STACK.filter((unit) => unit.self_host === "run")
67 .map((unit) => ({ name: unit.worker, dir: unit.path, web: unit.kind === "react-router" }))
68 .sort((a, b) => Number(b.web) - Number(a.web));
69
70/** What the off Worker calls each service that is off in phase 1. */
71const OFF_NAMES = {
72 "g1t-runner": "Cloud runners",
73};
74const OFF = Object.fromEntries(STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]));
75
76/** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */
77const SECRETS = {
78 g1t: "USERCONTENT_KEY",
79 "g1t-repos": "REPOS_KEY",
80 "g1t-actions": "ACTIONS_KEY",
81 "g1t-integrations": "INTEGRATIONS_KEY",
82 "g1t-webhooks": "WEBHOOKS_KEY",
83 "g1t-billing": "BILLING_KEY",
84};
85
86/**
87 * g1t.sh's GitHub App is its own: an installation registers one of its
88 * own, or has none, and then no GitHub buttons appear. Its public settings
89 * replace the hosted vars; its secrets go only to the service that uses each.
90 */
91const GITHUB_VARS = ["GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID"];
92const GITHUB_SECRETS = {
93 "g1t-identity": ["GITHUB_APP_CLIENT_SECRET", "IDENTITY_KEY"],
94 "g1t-integrations": ["GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_WEBHOOK_SECRET"],
95};
96
97/**
98 * Services whose cron triggers scheduler.mjs runs here: sweeps and
99 * reminders that need nothing self-hosting lacks (the artifacts service's is
100 * emptying artifacts' trash after 30 days). Not run: actions (its
101 * minute would start scheduled workflows with no runner to take them),
102 * billing (reconciles against Cloudflare and Stripe), deployments (calls
103 * Cloudflare's API) and the services that are off.
104 */
105const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages", "g1t-artifacts"]);
106
107/** Queues whose consumers are off: events stops sending to them. */
108const OFF_QUEUES = new Set(["g1t-events-runner"]);
109
110/** Strips comments and trailing commas from JSONC. Strings are respected. */
111function parseJsonc(text) {
112 let result = "";
113 let inString = false;
114 for (let i = 0; i < text.length; i++) {
115 const char = text[i];
116 if (inString) {
117 result += char;
118 if (char === "\\") result += text[++i];
119 else if (char === '"') inString = false;
120 } else if (char === '"') {
121 inString = true;
122 result += char;
123 } else if (char === "/" && text[i + 1] === "/") {
124 while (i < text.length && text[i] !== "\n") i++;
125 result += "\n";
126 } else if (char === "/" && text[i + 1] === "*") {
127 i = text.indexOf("*/", i + 2) + 1;
128 } else {
129 result += char;
130 }
131 }
132 return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1"));
133}
134
135const rel = (path) => relative(out, resolve(root, path)).replaceAll("\\", "/");
136
137function hostedUrl(value) {
138 return typeof value === "string" ? value.replace(/https:\/\/api\.g1t\.sh/g, API_URL).replace(/https:\/\/g1t\.sh/g, PUBLIC_URL) : value;
139}
140
141function selfHosted(service) {
142 const hosted = parseJsonc(readFileSync(join(root, service.dir, "wrangler.jsonc"), "utf8"));
143 const config = {
144 name: hosted.name,
145 compatibility_date: hosted.compatibility_date,
146 compatibility_flags: hosted.compatibility_flags,
147 rules: hosted.rules,
148 vars: {},
149 };
150
151 if (service.web) {
152 // The site as React Router built it (apps/web/build), not its sources.
153 config.main = rel(`${service.dir}/build/server/index.js`);
154 config.no_bundle = true;
155 config.rules = [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }];
156 config.assets = { directory: rel(`${service.dir}/build/client`) };
157 } else {
158 config.main = rel(join(service.dir, hosted.main));
159 }
160
161 for (const [key, value] of Object.entries(hosted.vars ?? {})) config.vars[key] = hostedUrl(value);
162
163 if (hosted.d1_databases) {
164 config.d1_databases = hosted.d1_databases.map((db) => ({
165 binding: db.binding,
166 database_name: db.database_name,
167 database_id: db.database_id,
168 migrations_dir: rel(join(service.dir, db.migrations_dir ?? "migrations")),
169 }));
170 }
171 if (hosted.kv_namespaces) config.kv_namespaces = hosted.kv_namespaces.map(({ binding, id }) => ({ binding, id }));
172 if (hosted.triggers) config.triggers = hosted.triggers;
173
174 if (hosted.queues) {
175 config.queues = {};
176 if (hosted.queues.producers) {
177 config.queues.producers = hosted.queues.producers.filter((producer) => !OFF_QUEUES.has(producer.queue));
178 }
179 if (hosted.queues.consumers) config.queues.consumers = hosted.queues.consumers;
180 }
181
182 config.services = (hosted.services ?? []).map((binding) =>
183 OFF[binding.service] ? { binding: binding.binding, service: offName(binding.service) } : binding,
184 );
185
186 // Cloudflare-only bindings, and what stands in for them.
187 if (hosted.artifacts) {
188 for (const store of hosted.artifacts) {
189 config.services.push({ binding: store.binding, service: "g1t-gitstore" });
190 }
191 }
192 if (hosted.send_email) {
193 for (const email of hosted.send_email) config.services.push({ binding: email.name, service: "g1t-mail" });
194 }
195
196 // Secrets the hosted services hold, given here from the environment, each
197 // only to the service that uses it.
198 const secret = SECRETS[hosted.name];
199 if (secret && process.env[secret]) config.vars[secret] = process.env[secret];
200 for (const name of GITHUB_VARS) {
201 if (name in config.vars) config.vars[name] = process.env[name] ?? "";
202 }
203 for (const name of GITHUB_SECRETS[hosted.name] ?? []) {
204 if (process.env[name]) config.vars[name] = process.env[name];
205 }
206
207 // Self-hosted g1t charges nothing: billing records usage at cost and never
208 // stops work for it (CHARGING off: nothing gated, nothing charged).
209 if (hosted.name === "g1t-billing") Object.assign(config.vars, { FREE_WHILE_BUILDING: "true", CHARGING: "off" });
210 // How many friends each person may invite to g1t by email (20 unless
211 // set; 0 turns Invite a friend off). Never needed to sign up.
212 if (hosted.name === "g1t-identity" && process.env.INVITES_PER_USER) config.vars.INVITES_PER_USER = process.env.INVITES_PER_USER;
213 // Packages' files go to the compose file's RustFS (or any S3-compatible
214 // store) instead of R2, with no request size limit, and package
215 // addresses start with this installation's host.
216 if (hosted.name === "g1t-packages") {
217 Object.assign(config.vars, {
218 BLOB_STORE: "s3",
219 S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://rustfs:9000",
220 S3_BUCKET: process.env.S3_BUCKET ?? "g1t-packages",
221 S3_REGION: process.env.S3_REGION ?? "us-east-1",
222 S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID ?? "",
223 S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY ?? "",
224 S3_PUBLIC_ENDPOINT: process.env.S3_PUBLIC_ENDPOINT ?? "",
225 MAX_REQUEST_BYTES: "0",
226 STORAGE_LIMITS: "off",
227 REGISTRY_HOST: new URL(PUBLIC_URL).host,
228 PACKAGES_TOKEN_SECRET: process.env.PACKAGES_TOKEN_SECRET ?? "",
229 });
230 delete config.vars.R2_ACCOUNT_ID;
231 delete config.vars.R2_BUCKET;
232 }
233 // Where this installation is reached, for the links and addresses each
234 // shows: the site's clone URLs, meta tags and agent setup, the API's
235 // OAuth issuer and MCP server, and identity's mail. No social cards: the
236 // card service (services/og) is not run here.
237 // Repository files and avatars: USERCONTENT_URL, a host of its own that
238 // reaches this same site, or, empty, a path on it (PUBLIC_URL/-/usercontent).
239 if (service.web) {
240 Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "", USERCONTENT_URL: (process.env.USERCONTENT_URL ?? "").trim() });
241 }
242 // The API names attachments by their address there too (upload_attachment).
243 if (hosted.name === "g1t-api")
244 Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, USERCONTENT_URL: (process.env.USERCONTENT_URL ?? "").trim() });
245 // Notifications on the g1t phone app go through Expo's push service (and
246 // from there Google's and Apple's), with no setup here, unless
247 // PHONE_PUSH=off: notify then sends phones nothing, and the site's
248 // /.well-known/g1t.json tells the app so.
249 if (service.web || hosted.name === "g1t-notify") config.vars.PHONE_PUSH = process.env.PHONE_PUSH === "off" ? "off" : "on";
250 if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL;
251 // Cloudflare Turnstile on sign-up, only with both of your widget's keys.
252 if (service.web) {
253 config.vars.TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY ?? "";
254 if (process.env.TURNSTILE_SECRET) config.vars.TURNSTILE_SECRET = process.env.TURNSTILE_SECRET;
255 }
256 // Nightly backups' bundles go to a bucket of their own on the same
257 // S3-compatible store, instead of the BACKUPS R2 bucket, and so do the
258 // packs kept for fresh clones (src/pack_cache.rs), instead of GIT_PACKS.
259 if (hosted.name === "g1t-repos") {
260 Object.assign(config.vars, {
261 BACKUP_STORE: "s3",
262 BACKUP_S3_BUCKET: process.env.BACKUP_S3_BUCKET ?? "g1t-backups",
263 PACK_STORE: "s3",
264 PACK_S3_BUCKET: process.env.PACK_S3_BUCKET ?? "g1t-git-packs",
265 S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://rustfs:9000",
266 S3_REGION: process.env.S3_REGION ?? "us-east-1",
267 S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID ?? "",
268 S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY ?? "",
269 });
270 }
271 // Files people put in Docs pages go to a bucket of their own on the same
272 // S3-compatible store, instead of the FILES R2 bucket (services/artifacts
273 // src/files.ts, `s3FileStore`).
274 if (hosted.name === "g1t-artifacts") {
275 Object.assign(config.vars, {
276 DOCS_FILES: "s3",
277 DOCS_S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://rustfs:9000",
278 DOCS_S3_BUCKET: process.env.DOCS_S3_BUCKET ?? "g1t-docs-files",
279 DOCS_S3_REGION: process.env.S3_REGION ?? "us-east-1",
280 DOCS_S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID ?? "",
281 DOCS_S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY ?? "",
282 });
283 }
284 // Nothing to deploy to: deployments are off (no Cloudflare API token).
285 if (hosted.name === "g1t-deployments") delete config.vars.CUSTOM_HOSTNAMES_ZONE_ID;
286
287 return config;
288}
289
290function offName(service) {
291 return `${service}-off`;
292}
293
294function write(name, config) {
295 const path = join(out, `${name}.json`);
296 writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
297 return path;
298}
299
300const files = [];
301for (const service of RUNNING) files.push(write(service.name, selfHosted(service)));
302
303const compatibility_date = "2026-09-26";
304files.push(
305 write("g1t-gitstore", {
306 name: "g1t-gitstore",
307 main: rel("deploy/self-host/workers/gitstore/index.js"),
308 compatibility_date,
309 vars: {
310 GITSTORE_URL: process.env.GITSTORE_URL ?? "http://gitstore:8080",
311 GITSTORE_SECRET: process.env.GITSTORE_SECRET ?? "",
312 },
313 }),
314);
315files.push(
316 write("g1t-mail", {
317 name: "g1t-mail",
318 main: rel("deploy/self-host/workers/mail/index.js"),
319 compatibility_date,
320 vars: {
321 PUBLIC_URL,
322 MAIL_URL: process.env.MAIL_URL ?? "",
323 MAIL_FROM: process.env.MAIL_FROM ?? "",
324 },
325 }),
326);
327for (const [service, feature] of Object.entries(OFF)) {
328 files.push(
329 write(offName(service), {
330 name: offName(service),
331 main: rel("deploy/self-host/workers/off/index.js"),
332 compatibility_date,
333 vars: { OFF_NAME: feature },
334 }),
335 );
336}
337
338// The order Wrangler takes them in: the site first, as the one that serves.
339writeFileSync(join(out, "workers.txt"), `${files.map((file) => relative(out, file)).join("\n")}\n`);
340
341// The API (REST, MCP and OAuth) is served on a port of its own (API_PORT),
342// by a second `wrangler dev` (start.sh): not in workers.txt. Its service
343// bindings reach the Workers above through Wrangler's dev registry, as
344// any two `wrangler dev` sessions on one machine do.
345{
346 const api = STACK.find((unit) => unit.worker === "g1t-api");
347 write("g1t-api", selfHosted({ name: api.worker, dir: api.path, web: false }));
348 writeFileSync(join(out, "api.json"), `${JSON.stringify({ api: API_URL, mcp: MCP_URL }, null, 2)}\n`);
349}
350
351// What scheduler.mjs runs: each service's own crons, as hosted g1t's Cron
352// Triggers run them, for the services in SELF_HOST_CRONS.
353const schedules = RUNNING.filter((service) => SELF_HOST_CRONS.has(service.name))
354 .map((service) => ({
355 worker: service.name,
356 crons: parseJsonc(readFileSync(join(root, service.dir, "wrangler.jsonc"), "utf8")).triggers?.crons ?? [],
357 }))
358 .filter((schedule) => schedule.crons.length > 0);
359writeFileSync(join(out, "schedules.json"), `${JSON.stringify(schedules, null, 2)}\n`);
360
361// The status page runs in a workerd of its own (status.sh, the `status`
362// service in docker-compose.yml), so it stays up when the site does not:
363// not in workers.txt. It checks the site from inside Compose
364// (STATUS_CHECK_URL) and links to it at PUBLIC_URL. Parts this
365// installation does not run (the API, MCP, docs, g1t.page, billing) are
366// left off its page; a public repository of yours in
367// STATUS_PROBE_REPO adds the git check.
368{
369 const hosted = parseJsonc(readFileSync(join(root, "apps/status/wrangler.jsonc"), "utf8"));
370 const db = hosted.d1_databases[0];
371 write("g1t-status", {
372 name: hosted.name,
373 main: rel(join("apps/status", hosted.main)),
374 compatibility_date: hosted.compatibility_date,
375 rules: hosted.rules,
376 triggers: hosted.triggers,
377 d1_databases: [
378 {
379 binding: db.binding,
380 database_name: db.database_name,
381 database_id: db.database_id,
382 migrations_dir: rel(join("apps/status", db.migrations_dir)),
383 },
384 ],
385 vars: {
386 SITE_URL: (process.env.STATUS_CHECK_URL ?? "http://g1t:8787").replace(/\/$/, ""),
387 PUBLIC_SITE_URL: PUBLIC_URL,
388 API_URL: "",
389 MCP_URL: "",
390 DOCS_URL: "",
391 PAGES_URL: "",
392 PROBE_REPO: process.env.STATUS_PROBE_REPO ?? "",
393 SUPPORT_URL: `${PUBLIC_URL}/support`,
394 OG_IMAGE: "",
395 // Its own address, for links made outside a request. No email
396 // binding here: subscribing by email is off, the feeds work.
397 STATUS_URL: `http://localhost:${process.env.STATUS_PORT ?? "8788"}`,
398 STATUS_ALERT_EMAIL: "",
399 },
400 });
401}
402console.log(`Wrote ${files.length} configs to ${out}`);