g1t/deploy/self-host/configs.mjs
| 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 | |
| 26 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 27 | import { dirname, join, relative, resolve } from "node:path"; |
| 28 | import { fileURLToPath } from "node:url"; |
| 29 | |
| 30 | const here = dirname(fileURLToPath(import.meta.url)); |
| 31 | const root = resolve(here, "../.."); |
| 32 | const out = resolve(process.argv[2] ?? join(here, ".generated")); |
| 33 | mkdirSync(out, { recursive: true }); |
| 34 | |
| 35 | const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, ""); |
| 36 | |
| 37 | /** Services that run, in the order Wrangler is given them (the site first). */ |
| 38 | export const RUNNING = [ |
| 39 | { name: "g1t", dir: "apps/web", web: true }, |
| 40 | { name: "g1t-identity", dir: "services/identity" }, |
| 41 | { name: "g1t-repos", dir: "services/repos" }, |
| 42 | { name: "g1t-work", dir: "services/work" }, |
| 43 | { name: "g1t-events", dir: "services/events" }, |
| 44 | { name: "g1t-projects", dir: "services/projects" }, |
| 45 | { name: "g1t-search", dir: "services/search" }, |
| 46 | { name: "g1t-billing", dir: "services/billing" }, |
| 47 | { name: "g1t-security", dir: "services/security" }, |
| 48 | { name: "g1t-actions", dir: "services/actions" }, |
| 49 | { name: "g1t-webhooks", dir: "services/webhooks" }, |
| 50 | { name: "g1t-integrations", dir: "services/integrations" }, |
| 51 | { name: "g1t-deployments", dir: "services/deployments" }, |
| 52 | ]; |
| 53 | |
| 54 | /** Services that are off in phase 1, and what the off Worker calls them. */ |
| 55 | const OFF = { |
| 56 | "g1t-runner": "Agents", |
| 57 | "g1t-context": "Context search and memory", |
| 58 | }; |
| 59 | |
| 60 | /** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */ |
| 61 | const SECRETS = { |
| 62 | "g1t-actions": "ACTIONS_KEY", |
| 63 | "g1t-integrations": "INTEGRATIONS_KEY", |
| 64 | "g1t-webhooks": "WEBHOOKS_KEY", |
| 65 | }; |
| 66 | |
| 67 | /** |
| 68 | * g1t.sh's GitHub App is its own: an installation registers one of its |
| 69 | * own, or has none, and then no GitHub buttons appear. Its public settings |
| 70 | * replace the hosted vars; its secrets go only to the service that uses each. |
| 71 | */ |
| 72 | const GITHUB_VARS = ["GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID"]; |
| 73 | const GITHUB_SECRETS = { |
| 74 | "g1t-identity": ["GITHUB_APP_CLIENT_SECRET", "IDENTITY_KEY", "REGISTRATION_MODE"], |
| 75 | "g1t-integrations": ["GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_WEBHOOK_SECRET"], |
| 76 | }; |
| 77 | |
| 78 | /** Queues whose consumers are off: events stops sending to them. */ |
| 79 | const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]); |
| 80 | |
| 81 | /** Strips comments and trailing commas from JSONC. Strings are respected. */ |
| 82 | function parseJsonc(text) { |
| 83 | let result = ""; |
| 84 | let inString = false; |
| 85 | for (let i = 0; i < text.length; i++) { |
| 86 | const char = text[i]; |
| 87 | if (inString) { |
| 88 | result += char; |
| 89 | if (char === "\\") result += text[++i]; |
| 90 | else if (char === '"') inString = false; |
| 91 | } else if (char === '"') { |
| 92 | inString = true; |
| 93 | result += char; |
| 94 | } else if (char === "/" && text[i + 1] === "/") { |
| 95 | while (i < text.length && text[i] !== "\n") i++; |
| 96 | result += "\n"; |
| 97 | } else if (char === "/" && text[i + 1] === "*") { |
| 98 | i = text.indexOf("*/", i + 2) + 1; |
| 99 | } else { |
| 100 | result += char; |
| 101 | } |
| 102 | } |
| 103 | return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1")); |
| 104 | } |
| 105 | |
| 106 | const rel = (path) => relative(out, resolve(root, path)).replaceAll("\\", "/"); |
| 107 | |
| 108 | function hostedUrl(value) { |
| 109 | return typeof value === "string" ? value.replace(/https:\/\/(api\.)?g1t\.sh/g, PUBLIC_URL) : value; |
| 110 | } |
| 111 | |
| 112 | function selfHosted(service) { |
| 113 | const hosted = parseJsonc(readFileSync(join(root, service.dir, "wrangler.jsonc"), "utf8")); |
| 114 | const config = { |
| 115 | name: hosted.name, |
| 116 | compatibility_date: hosted.compatibility_date, |
| 117 | compatibility_flags: hosted.compatibility_flags, |
| 118 | rules: hosted.rules, |
| 119 | vars: {}, |
| 120 | }; |
| 121 | |
| 122 | if (service.web) { |
| 123 | // The site as React Router built it (apps/web/build), not its sources. |
| 124 | config.main = rel(`${service.dir}/build/server/index.js`); |
| 125 | config.no_bundle = true; |
| 126 | config.rules = [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }]; |
| 127 | config.assets = { directory: rel(`${service.dir}/build/client`) }; |
| 128 | } else { |
| 129 | config.main = rel(join(service.dir, hosted.main)); |
| 130 | } |
| 131 | |
| 132 | for (const [key, value] of Object.entries(hosted.vars ?? {})) config.vars[key] = hostedUrl(value); |
| 133 | |
| 134 | if (hosted.d1_databases) { |
| 135 | config.d1_databases = hosted.d1_databases.map((db) => ({ |
| 136 | binding: db.binding, |
| 137 | database_name: db.database_name, |
| 138 | database_id: db.database_id, |
| 139 | migrations_dir: rel(join(service.dir, db.migrations_dir ?? "migrations")), |
| 140 | })); |
| 141 | } |
| 142 | if (hosted.kv_namespaces) config.kv_namespaces = hosted.kv_namespaces.map(({ binding, id }) => ({ binding, id })); |
| 143 | if (hosted.triggers) config.triggers = hosted.triggers; |
| 144 | |
| 145 | if (hosted.queues) { |
| 146 | config.queues = {}; |
| 147 | if (hosted.queues.producers) { |
| 148 | config.queues.producers = hosted.queues.producers.filter((producer) => !OFF_QUEUES.has(producer.queue)); |
| 149 | } |
| 150 | if (hosted.queues.consumers) config.queues.consumers = hosted.queues.consumers; |
| 151 | } |
| 152 | |
| 153 | config.services = (hosted.services ?? []).map((binding) => |
| 154 | OFF[binding.service] ? { binding: binding.binding, service: offName(binding.service) } : binding, |
| 155 | ); |
| 156 | |
| 157 | // Cloudflare-only bindings, and what stands in for them. |
| 158 | if (hosted.artifacts) { |
| 159 | for (const artifacts of hosted.artifacts) { |
| 160 | config.services.push({ binding: artifacts.binding, service: "g1t-artifacts" }); |
| 161 | } |
| 162 | } |
| 163 | if (hosted.send_email) { |
| 164 | for (const email of hosted.send_email) config.services.push({ binding: email.name, service: "g1t-mail" }); |
| 165 | } |
| 166 | |
| 167 | // Secrets the hosted services hold, given here from the environment, each |
| 168 | // only to the service that uses it. |
| 169 | const secret = SECRETS[hosted.name]; |
| 170 | if (secret && process.env[secret]) config.vars[secret] = process.env[secret]; |
| 171 | for (const name of GITHUB_VARS) { |
| 172 | if (name in config.vars) config.vars[name] = process.env[name] ?? ""; |
| 173 | } |
| 174 | for (const name of GITHUB_SECRETS[hosted.name] ?? []) { |
| 175 | if (process.env[name]) config.vars[name] = process.env[name]; |
| 176 | } |
| 177 | |
| 178 | // Self-hosted g1t charges nothing: billing records usage at cost and never |
| 179 | // stops work for it. |
| 180 | if (hosted.name === "g1t-billing") config.vars.FREE_WHILE_BUILDING = "true"; |
| 181 | // Anyone may register on an installation of your own unless you set |
| 182 | // REGISTRATION_MODE=invite; invites then work as on g1t.sh, and the owners |
| 183 | // of INVITE_STAFF_WORKSPACES (yours, not g1t.sh's) invite without limit. |
| 184 | if (hosted.name === "g1t-identity") { |
| 185 | config.vars.REGISTRATION_MODE = process.env.REGISTRATION_MODE || "open"; |
| 186 | config.vars.INVITE_STAFF_WORKSPACES = process.env.INVITE_STAFF_WORKSPACES ?? ""; |
| 187 | if (process.env.INVITES_PER_USER) config.vars.INVITES_PER_USER = process.env.INVITES_PER_USER; |
| 188 | } |
| 189 | // Nothing to deploy to: deployments are off (no Cloudflare API token). |
| 190 | if (hosted.name === "g1t-deployments") delete config.vars.CUSTOM_HOSTNAMES_ZONE_ID; |
| 191 | |
| 192 | return config; |
| 193 | } |
| 194 | |
| 195 | function offName(service) { |
| 196 | return `${service}-off`; |
| 197 | } |
| 198 | |
| 199 | function write(name, config) { |
| 200 | const path = join(out, `${name}.json`); |
| 201 | writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); |
| 202 | return path; |
| 203 | } |
| 204 | |
| 205 | const files = []; |
| 206 | for (const service of RUNNING) files.push(write(service.name, selfHosted(service))); |
| 207 | |
| 208 | const compatibility_date = "2026-09-26"; |
| 209 | files.push( |
| 210 | write("g1t-artifacts", { |
| 211 | name: "g1t-artifacts", |
| 212 | main: rel("deploy/self-host/workers/artifacts/index.js"), |
| 213 | compatibility_date, |
| 214 | vars: { |
| 215 | GITSTORE_URL: process.env.GITSTORE_URL ?? "http://gitstore:8080", |
| 216 | GITSTORE_SECRET: process.env.GITSTORE_SECRET ?? "", |
| 217 | }, |
| 218 | }), |
| 219 | ); |
| 220 | files.push( |
| 221 | write("g1t-mail", { |
| 222 | name: "g1t-mail", |
| 223 | main: rel("deploy/self-host/workers/mail/index.js"), |
| 224 | compatibility_date, |
| 225 | vars: { |
| 226 | PUBLIC_URL, |
| 227 | MAIL_URL: process.env.MAIL_URL ?? "", |
| 228 | MAIL_FROM: process.env.MAIL_FROM ?? "", |
| 229 | }, |
| 230 | }), |
| 231 | ); |
| 232 | for (const [service, feature] of Object.entries(OFF)) { |
| 233 | files.push( |
| 234 | write(offName(service), { |
| 235 | name: offName(service), |
| 236 | main: rel("deploy/self-host/workers/off/index.js"), |
| 237 | compatibility_date, |
| 238 | vars: { OFF_NAME: feature }, |
| 239 | }), |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | // The order Wrangler takes them in: the site first, as the one that serves. |
| 244 | writeFileSync(join(out, "workers.txt"), `${files.map((file) => relative(out, file)).join("\n")}\n`); |
| 245 | console.log(`Wrote ${files.length} configs to ${out}`); |