|
| 1 | +#!/usr/bin/env node |
| 2 | +const apiToken = process.env.CLOUDFLARE_API_TOKEN; |
| 3 | +const zoneName = process.env.CLOUDFLARE_ZONE_NAME ?? "openclaw.ai"; |
| 4 | +const dryRun = process.argv.includes("--dry-run"); |
| 5 | + |
| 6 | +if (!apiToken) { |
| 7 | + throw new Error("CLOUDFLARE_API_TOKEN is required"); |
| 8 | +} |
| 9 | + |
| 10 | +const docsHost = `docs.${zoneName}`; |
| 11 | +const legacyHost = `documentation.${zoneName}`; |
| 12 | +const mintlifyHost = `mintlify.${zoneName}`; |
| 13 | +const docsRouterScript = "openclaw-docs-router"; |
| 14 | +const chatProxyScript = "openclaw-docs-chat-proxy"; |
| 15 | + |
| 16 | +const zone = await findZone(zoneName); |
| 17 | +console.log(`zone:${zone.name}`); |
| 18 | + |
| 19 | +await upsertDns(zone.id, { |
| 20 | + name: docsHost, |
| 21 | + type: "A", |
| 22 | + content: "192.0.2.1", |
| 23 | + proxied: true, |
| 24 | + ttl: 1, |
| 25 | + comment: "OpenClaw generated docs router", |
| 26 | +}); |
| 27 | +await upsertDns(zone.id, { |
| 28 | + name: mintlifyHost, |
| 29 | + type: "CNAME", |
| 30 | + content: "cname.mintlify.builders", |
| 31 | + proxied: false, |
| 32 | + ttl: 1, |
| 33 | + comment: "OpenClaw Mintlify backup docs", |
| 34 | +}); |
| 35 | + |
| 36 | +await upsertRoute(zone.id, `${docsHost}/ask-molty/*`, chatProxyScript); |
| 37 | +await upsertRoute(zone.id, `${legacyHost}/ask-molty/*`, chatProxyScript); |
| 38 | +await upsertRoute(zone.id, `${docsHost}/*`, docsRouterScript); |
| 39 | +await upsertRoute(zone.id, `${legacyHost}/*`, docsRouterScript); |
| 40 | + |
| 41 | +console.log(dryRun ? "dry-run complete" : "cutover complete"); |
| 42 | + |
| 43 | +async function findZone(name) { |
| 44 | + const data = await cloudflare(`/zones?name=${encodeURIComponent(name)}&status=active`); |
| 45 | + const zone = data.result?.find((entry) => entry.name === name); |
| 46 | + if (!zone) throw new Error(`active zone not found: ${name}`); |
| 47 | + return zone; |
| 48 | +} |
| 49 | + |
| 50 | +async function upsertDns(zoneId, desired) { |
| 51 | + const existing = await cloudflare(`/zones/${zoneId}/dns_records?name=${encodeURIComponent(desired.name)}&per_page=100`); |
| 52 | + const records = existing.result ?? []; |
| 53 | + const matching = records.find((record) => |
| 54 | + record.type === desired.type |
| 55 | + && record.content === desired.content |
| 56 | + && record.proxied === desired.proxied |
| 57 | + ); |
| 58 | + for (const record of records) { |
| 59 | + if (matching && record.id === matching.id) continue; |
| 60 | + await mutate(`/zones/${zoneId}/dns_records/${record.id}`, { method: "DELETE" }); |
| 61 | + console.log(`dns:deleted:${record.name}:${record.type}`); |
| 62 | + } |
| 63 | + if (matching) { |
| 64 | + await mutate(`/zones/${zoneId}/dns_records/${matching.id}`, { |
| 65 | + method: "PATCH", |
| 66 | + body: desired, |
| 67 | + }); |
| 68 | + console.log(`dns:ok:${desired.name}:${desired.type}`); |
| 69 | + return; |
| 70 | + } |
| 71 | + await mutate(`/zones/${zoneId}/dns_records`, { |
| 72 | + method: "POST", |
| 73 | + body: desired, |
| 74 | + }); |
| 75 | + console.log(`dns:created:${desired.name}:${desired.type}`); |
| 76 | +} |
| 77 | + |
| 78 | +async function upsertRoute(zoneId, pattern, script) { |
| 79 | + const existing = await cloudflare(`/zones/${zoneId}/workers/routes?per_page=100`); |
| 80 | + const routes = existing.result ?? []; |
| 81 | + const match = routes.find((route) => route.pattern === pattern); |
| 82 | + if (match?.script === script) { |
| 83 | + console.log(`route:ok:${pattern}`); |
| 84 | + return; |
| 85 | + } |
| 86 | + const body = { pattern, script }; |
| 87 | + if (match) { |
| 88 | + await mutate(`/zones/${zoneId}/workers/routes/${match.id}`, { |
| 89 | + method: "PUT", |
| 90 | + body, |
| 91 | + }); |
| 92 | + console.log(`route:updated:${pattern}`); |
| 93 | + return; |
| 94 | + } |
| 95 | + await mutate(`/zones/${zoneId}/workers/routes`, { |
| 96 | + method: "POST", |
| 97 | + body, |
| 98 | + }); |
| 99 | + console.log(`route:created:${pattern}`); |
| 100 | +} |
| 101 | + |
| 102 | +async function mutate(path, init) { |
| 103 | + if (dryRun) { |
| 104 | + console.log(`${init.method}:dry-run:${path}`); |
| 105 | + return {}; |
| 106 | + } |
| 107 | + return cloudflare(path, init); |
| 108 | +} |
| 109 | + |
| 110 | +async function cloudflare(path, init = {}) { |
| 111 | + const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { |
| 112 | + method: init.method ?? "GET", |
| 113 | + headers: { |
| 114 | + Authorization: `Bearer ${apiToken}`, |
| 115 | + "Content-Type": "application/json", |
| 116 | + }, |
| 117 | + body: init.body ? JSON.stringify(init.body) : undefined, |
| 118 | + }); |
| 119 | + const text = await response.text(); |
| 120 | + const data = text ? JSON.parse(text) : {}; |
| 121 | + if (!response.ok || data.success === false) { |
| 122 | + const message = data.errors?.map((error) => `${error.code}: ${error.message}`).join("; ") || response.statusText; |
| 123 | + throw new Error(`${init.method ?? "GET"} ${path}: ${message}`); |
| 124 | + } |
| 125 | + return data; |
| 126 | +} |
0 commit comments