@brewnet/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +184 -0
- package/dist/admin-server-DQVIEHV3.js +14 -0
- package/dist/admin-server-DQVIEHV3.js.map +1 -0
- package/dist/boilerplate-manager-P6QYUU7Q.js +29 -0
- package/dist/boilerplate-manager-P6QYUU7Q.js.map +1 -0
- package/dist/chunk-2VWMDHGI.js +1393 -0
- package/dist/chunk-2VWMDHGI.js.map +1 -0
- package/dist/chunk-4TJMJZMO.js +1173 -0
- package/dist/chunk-4TJMJZMO.js.map +1 -0
- package/dist/chunk-BAVGYMGA.js +114 -0
- package/dist/chunk-BAVGYMGA.js.map +1 -0
- package/dist/chunk-DH2VK3YI.js +293 -0
- package/dist/chunk-DH2VK3YI.js.map +1 -0
- package/dist/chunk-HCHY5UIQ.js +301 -0
- package/dist/chunk-HCHY5UIQ.js.map +1 -0
- package/dist/chunk-JFPHGZ6Z.js +254 -0
- package/dist/chunk-JFPHGZ6Z.js.map +1 -0
- package/dist/chunk-SIXBB6JU.js +2973 -0
- package/dist/chunk-SIXBB6JU.js.map +1 -0
- package/dist/chunk-SYV6PK3R.js +181 -0
- package/dist/chunk-SYV6PK3R.js.map +1 -0
- package/dist/chunk-ZKMWE5AH.js +444 -0
- package/dist/chunk-ZKMWE5AH.js.map +1 -0
- package/dist/cloudflare-client-TFT6VCXF.js +32 -0
- package/dist/cloudflare-client-TFT6VCXF.js.map +1 -0
- package/dist/compose-generator-O7GSIJ2S.js +19 -0
- package/dist/compose-generator-O7GSIJ2S.js.map +1 -0
- package/dist/frameworks-Z7VXDGP4.js +18 -0
- package/dist/frameworks-Z7VXDGP4.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +7897 -0
- package/dist/index.js.map +1 -0
- package/dist/services/admin-daemon.d.ts +2 -0
- package/dist/services/admin-daemon.js +33 -0
- package/dist/services/admin-daemon.js.map +1 -0
- package/dist/stacks-M4FBTVO5.js +16 -0
- package/dist/stacks-M4FBTVO5.js.map +1 -0
- package/dist/state-2SI3P4JG.js +27 -0
- package/dist/state-2SI3P4JG.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/services/cloudflare-client.ts
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
var CF_BASE = "https://api.cloudflare.com/client/v4";
|
|
6
|
+
function cfHeaders(apiToken) {
|
|
7
|
+
return {
|
|
8
|
+
"Authorization": `Bearer ${apiToken}`,
|
|
9
|
+
"Content-Type": "application/json"
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async function fetchWithRetry(url, init, config = {}) {
|
|
13
|
+
const maxRetries = config.maxRetries ?? 3;
|
|
14
|
+
const baseDelayMs = config.baseDelayMs ?? 1e3;
|
|
15
|
+
let lastError;
|
|
16
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(url, init);
|
|
19
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
if (response.status >= 500 || response.status === 429) {
|
|
23
|
+
if (attempt < maxRetries) {
|
|
24
|
+
await retryDelay(attempt, baseDelayMs);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
return response;
|
|
28
|
+
}
|
|
29
|
+
return response;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
lastError = err;
|
|
32
|
+
if (attempt < maxRetries) {
|
|
33
|
+
await retryDelay(attempt, baseDelayMs);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw lastError ?? new Error("fetchWithRetry: exhausted retries");
|
|
39
|
+
}
|
|
40
|
+
function retryDelay(attempt, baseDelayMs) {
|
|
41
|
+
const jitterFactor = 0.9 + Math.random() * 0.2;
|
|
42
|
+
const delay = baseDelayMs * Math.pow(2, attempt) * jitterFactor;
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
44
|
+
}
|
|
45
|
+
async function deleteTunnel(apiToken, accountId, tunnelId) {
|
|
46
|
+
const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}`;
|
|
47
|
+
const response = await fetchWithRetry(url, {
|
|
48
|
+
method: "DELETE",
|
|
49
|
+
headers: cfHeaders(apiToken)
|
|
50
|
+
});
|
|
51
|
+
if (response.status === 404) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
if (!response.ok || !data.success) {
|
|
56
|
+
const errMsg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
57
|
+
if (response.status === 400 && errMsg.toLowerCase().includes("active connection")) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`\uD130\uB110 \uC0AD\uC81C \uC2E4\uD328: \uD65C\uC131 \uC5F0\uACB0\uC774 \uC788\uC2B5\uB2C8\uB2E4. cloudflared \uCEE8\uD14C\uC774\uB108\uB97C \uBA3C\uC800 \uC911\uC9C0\uD558\uC138\uC694. (${errMsg})`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`\uD130\uB110 \uC0AD\uC81C \uC2E4\uD328: ${errMsg}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function verifyToken(apiToken) {
|
|
66
|
+
const verifyResp = await fetch(`${CF_BASE}/user/tokens/verify`, {
|
|
67
|
+
headers: cfHeaders(apiToken)
|
|
68
|
+
});
|
|
69
|
+
const verifyData = await verifyResp.json();
|
|
70
|
+
if (!verifyResp.ok || !verifyData.success || verifyData.result?.status !== "active") {
|
|
71
|
+
return { valid: false };
|
|
72
|
+
}
|
|
73
|
+
const userResp = await fetch(`${CF_BASE}/user`, {
|
|
74
|
+
headers: cfHeaders(apiToken)
|
|
75
|
+
});
|
|
76
|
+
const userData = await userResp.json();
|
|
77
|
+
const email = userData.success ? userData.result?.email : void 0;
|
|
78
|
+
return { valid: true, email };
|
|
79
|
+
}
|
|
80
|
+
async function getAccounts(apiToken) {
|
|
81
|
+
const response = await fetch(`${CF_BASE}/accounts`, {
|
|
82
|
+
headers: cfHeaders(apiToken)
|
|
83
|
+
});
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
if (!response.ok || !data.success) return [];
|
|
86
|
+
return data.result ?? [];
|
|
87
|
+
}
|
|
88
|
+
async function getZones(apiToken) {
|
|
89
|
+
const response = await fetch(`${CF_BASE}/zones`, {
|
|
90
|
+
headers: cfHeaders(apiToken)
|
|
91
|
+
});
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
if (!response.ok || !data.success) return [];
|
|
94
|
+
return (data.result ?? []).map((z) => ({
|
|
95
|
+
id: z.id,
|
|
96
|
+
name: z.name,
|
|
97
|
+
status: z.status,
|
|
98
|
+
accountId: z.account?.id
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
async function createTunnel(apiToken, accountId, name) {
|
|
102
|
+
const tunnelSecret = crypto.randomBytes(32).toString("base64");
|
|
103
|
+
const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel`;
|
|
104
|
+
const response = await fetch(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: cfHeaders(apiToken),
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
name,
|
|
109
|
+
config_src: "cloudflare",
|
|
110
|
+
tunnel_secret: tunnelSecret
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
if (!response.ok || !data.success) {
|
|
115
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
116
|
+
throw new Error(msg);
|
|
117
|
+
}
|
|
118
|
+
if (!data.result?.id || !data.result?.token) {
|
|
119
|
+
throw new Error("Cloudflare API returned unexpected response (missing id or token)");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
tunnelId: data.result.id,
|
|
123
|
+
tunnelToken: data.result.token
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function configureTunnelIngress(apiToken, accountId, tunnelId, domain, routes) {
|
|
127
|
+
const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`;
|
|
128
|
+
const ingress = [
|
|
129
|
+
...routes.map((r) => ({
|
|
130
|
+
hostname: `${r.subdomain}.${r.domain ?? domain}`,
|
|
131
|
+
service: `http://${r.containerName}:${r.port}`
|
|
132
|
+
})),
|
|
133
|
+
{ service: "http_status:404" }
|
|
134
|
+
];
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
method: "PUT",
|
|
137
|
+
headers: cfHeaders(apiToken),
|
|
138
|
+
body: JSON.stringify({ config: { ingress } })
|
|
139
|
+
});
|
|
140
|
+
const data = await response.json();
|
|
141
|
+
if (!response.ok || !data.success) {
|
|
142
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
143
|
+
throw new Error(`Failed to configure ingress: ${msg}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function createDnsRecord(apiToken, zoneId, tunnelId, subdomain, domain) {
|
|
147
|
+
const url = `${CF_BASE}/zones/${zoneId}/dns_records`;
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: cfHeaders(apiToken),
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
type: "CNAME",
|
|
153
|
+
name: `${subdomain}.${domain}`,
|
|
154
|
+
content: `${tunnelId}.cfargotunnel.com`,
|
|
155
|
+
proxied: true
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
if (!response.ok || !data.success) {
|
|
160
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
161
|
+
if (!msg.toLowerCase().includes("already exists")) {
|
|
162
|
+
throw new Error(`DNS record creation failed: ${msg}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function getDnsRecords(apiToken, zoneId, hostname) {
|
|
167
|
+
const url = `${CF_BASE}/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(hostname)}`;
|
|
168
|
+
const response = await fetchWithRetry(url, {
|
|
169
|
+
headers: cfHeaders(apiToken)
|
|
170
|
+
});
|
|
171
|
+
const data = await response.json();
|
|
172
|
+
if (!response.ok || !data.success) {
|
|
173
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
174
|
+
throw new Error(`Failed to query DNS records: ${msg}`);
|
|
175
|
+
}
|
|
176
|
+
return data.result ?? [];
|
|
177
|
+
}
|
|
178
|
+
async function deleteDnsRecord(apiToken, zoneId, recordId) {
|
|
179
|
+
const url = `${CF_BASE}/zones/${zoneId}/dns_records/${recordId}`;
|
|
180
|
+
const response = await fetchWithRetry(url, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
headers: cfHeaders(apiToken)
|
|
183
|
+
});
|
|
184
|
+
if (response.status === 404) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const data = await response.json();
|
|
188
|
+
if (!response.ok || !data.success) {
|
|
189
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
190
|
+
throw new Error(`Failed to delete DNS record: ${msg}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function getTunnelHealth(apiToken, accountId, tunnelId) {
|
|
194
|
+
const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}`;
|
|
195
|
+
const response = await fetchWithRetry(url, {
|
|
196
|
+
headers: cfHeaders(apiToken)
|
|
197
|
+
});
|
|
198
|
+
const data = await response.json();
|
|
199
|
+
if (!response.ok || !data.success) {
|
|
200
|
+
const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
201
|
+
throw new Error(`Failed to get tunnel health: ${msg}`);
|
|
202
|
+
}
|
|
203
|
+
const rawStatus = data.result?.status ?? "inactive";
|
|
204
|
+
const connectorCount = data.result?.connections?.length ?? 0;
|
|
205
|
+
const status = rawStatus === "healthy" ? "healthy" : rawStatus === "degraded" ? "degraded" : "inactive";
|
|
206
|
+
return { status, connectorCount };
|
|
207
|
+
}
|
|
208
|
+
function buildTokenCreationUrl(projectName) {
|
|
209
|
+
const perms = encodeURIComponent(
|
|
210
|
+
JSON.stringify([
|
|
211
|
+
{ key: "cloudflare_tunnel", type: "edit" },
|
|
212
|
+
{ key: "dns", type: "edit" }
|
|
213
|
+
])
|
|
214
|
+
);
|
|
215
|
+
return `https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=${perms}&name=brewnet-${projectName}`;
|
|
216
|
+
}
|
|
217
|
+
function getActiveServiceRoutes(state) {
|
|
218
|
+
const routes = [];
|
|
219
|
+
routes.push({ subdomain: "git", containerName: "gitea", port: 3e3 });
|
|
220
|
+
if (state.servers.fileServer?.enabled) {
|
|
221
|
+
if (state.servers.fileServer.service === "nextcloud") {
|
|
222
|
+
routes.push({ subdomain: "cloud", containerName: "nextcloud", port: 80 });
|
|
223
|
+
} else if (state.servers.fileServer.service === "minio") {
|
|
224
|
+
routes.push({ subdomain: "minio", containerName: "minio", port: 9001 });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (state.servers.media?.enabled && state.servers.media.services?.includes("jellyfin")) {
|
|
228
|
+
routes.push({ subdomain: "media", containerName: "jellyfin", port: 8096 });
|
|
229
|
+
}
|
|
230
|
+
if (state.servers.dbServer?.enabled && state.servers.dbServer.adminUI && state.servers.dbServer.primary === "postgresql") {
|
|
231
|
+
routes.push({ subdomain: "pgadmin", containerName: "pgadmin", port: 80 });
|
|
232
|
+
}
|
|
233
|
+
if (state.servers.fileBrowser?.enabled) {
|
|
234
|
+
routes.push({ subdomain: "files", containerName: "filebrowser", port: 80 });
|
|
235
|
+
}
|
|
236
|
+
return routes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export {
|
|
240
|
+
fetchWithRetry,
|
|
241
|
+
deleteTunnel,
|
|
242
|
+
verifyToken,
|
|
243
|
+
getAccounts,
|
|
244
|
+
getZones,
|
|
245
|
+
createTunnel,
|
|
246
|
+
configureTunnelIngress,
|
|
247
|
+
createDnsRecord,
|
|
248
|
+
getDnsRecords,
|
|
249
|
+
deleteDnsRecord,
|
|
250
|
+
getTunnelHealth,
|
|
251
|
+
buildTokenCreationUrl,
|
|
252
|
+
getActiveServiceRoutes
|
|
253
|
+
};
|
|
254
|
+
//# sourceMappingURL=chunk-JFPHGZ6Z.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/services/cloudflare-client.ts"],"sourcesContent":["/**\n * Cloudflare API client for Brewnet tunnel automation.\n *\n * Covers the full tunnel setup flow:\n * 1. Token verification\n * 2. Account listing / auto-selection\n * 3. Zone (domain) listing / selection\n * 4. Tunnel creation\n * 5. Ingress rule configuration\n * 6. DNS CNAME record creation\n *\n * API reference:\n * https://developers.cloudflare.com/api/\n *\n * @module services/cloudflare-client\n */\n\nimport crypto from 'node:crypto';\nimport type { WizardState } from '@brewnet/shared';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ServiceRoute {\n subdomain: string;\n containerName: string;\n port: number;\n /** Per-route domain override. When set, takes precedence over the shared `domain` param in configureTunnelIngress. */\n domain?: string;\n}\n\nexport interface RetryConfig {\n /** Maximum number of retry attempts (default: 3) */\n maxRetries?: number;\n /** Base delay in ms before first retry (default: 1000) */\n baseDelayMs?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst CF_BASE = 'https://api.cloudflare.com/client/v4';\n\nfunction cfHeaders(apiToken: string): Record<string, string> {\n return {\n 'Authorization': `Bearer ${apiToken}`,\n 'Content-Type': 'application/json',\n };\n}\n\n// ---------------------------------------------------------------------------\n// fetchWithRetry\n// ---------------------------------------------------------------------------\n\n/**\n * Wrapper around `fetch` with exponential backoff retry logic.\n *\n * Retry conditions:\n * - Network errors (fetch throws)\n * - HTTP 5xx responses\n * - HTTP 429 (rate limit)\n *\n * No retry conditions:\n * - HTTP 400 / 401 / 403 (client errors — retrying won't help)\n * - Other 4xx responses\n *\n * Backoff: baseDelay * 2^attempt ± 10% jitter (default: 1s → 2s → 4s)\n */\nexport async function fetchWithRetry(\n url: string,\n init?: RequestInit,\n config: RetryConfig = {},\n): Promise<Response> {\n const maxRetries = config.maxRetries ?? 3;\n const baseDelayMs = config.baseDelayMs ?? 1000;\n\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n const response = await fetch(url, init);\n\n // No retry for auth/client errors\n if (response.status === 400 || response.status === 401 || response.status === 403) {\n return response;\n }\n\n // Retry on 5xx or 429\n if (response.status >= 500 || response.status === 429) {\n if (attempt < maxRetries) {\n await retryDelay(attempt, baseDelayMs);\n continue;\n }\n return response;\n }\n\n return response;\n } catch (err) {\n // Network error — retry\n lastError = err;\n if (attempt < maxRetries) {\n await retryDelay(attempt, baseDelayMs);\n continue;\n }\n }\n }\n\n throw lastError ?? new Error('fetchWithRetry: exhausted retries');\n}\n\nfunction retryDelay(attempt: number, baseDelayMs: number): Promise<void> {\n const jitterFactor = 0.9 + Math.random() * 0.2; // ±10%\n const delay = baseDelayMs * Math.pow(2, attempt) * jitterFactor;\n return new Promise((resolve) => setTimeout(resolve, delay));\n}\n\n// ---------------------------------------------------------------------------\n// deleteTunnel\n// ---------------------------------------------------------------------------\n\n/**\n * Delete a Cloudflare Tunnel via the API.\n *\n * DELETE /client/v4/accounts/{accountId}/cfd_tunnel/{tunnelId}\n *\n * Throws with a descriptive error if the tunnel has active connections\n * (HTTP 400) or if the request fails for any other reason.\n *\n * Used during auto-rollback when tunnel setup fails partway through.\n */\nexport async function deleteTunnel(\n apiToken: string,\n accountId: string,\n tunnelId: string,\n): Promise<void> {\n const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}`;\n\n const response = await fetchWithRetry(url, {\n method: 'DELETE',\n headers: cfHeaders(apiToken),\n });\n\n if (response.status === 404) {\n // Already deleted — treat as success\n return;\n }\n\n const data = (await response.json()) as {\n success: boolean;\n errors?: Array<{ message: string; code?: number }>;\n };\n\n if (!response.ok || !data.success) {\n const errMsg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n if (response.status === 400 && errMsg.toLowerCase().includes('active connection')) {\n throw new Error(\n `터널 삭제 실패: 활성 연결이 있습니다. cloudflared 컨테이너를 먼저 중지하세요. (${errMsg})`,\n );\n }\n throw new Error(`터널 삭제 실패: ${errMsg}`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// verifyToken\n// ---------------------------------------------------------------------------\n\n/**\n * Verify a Cloudflare API token and retrieve the associated account email.\n *\n * GET /client/v4/user/tokens/verify\n * GET /client/v4/user (to get email)\n */\nexport async function verifyToken(\n apiToken: string,\n): Promise<{ valid: boolean; email?: string }> {\n const verifyResp = await fetch(`${CF_BASE}/user/tokens/verify`, {\n headers: cfHeaders(apiToken),\n });\n\n const verifyData = (await verifyResp.json()) as {\n success: boolean;\n result?: { status: string };\n };\n\n if (!verifyResp.ok || !verifyData.success || verifyData.result?.status !== 'active') {\n return { valid: false };\n }\n\n // Fetch email from /user endpoint\n const userResp = await fetch(`${CF_BASE}/user`, {\n headers: cfHeaders(apiToken),\n });\n const userData = (await userResp.json()) as {\n success: boolean;\n result?: { email: string };\n };\n\n const email = userData.success ? userData.result?.email : undefined;\n return { valid: true, email };\n}\n\n// ---------------------------------------------------------------------------\n// getAccounts\n// ---------------------------------------------------------------------------\n\n/**\n * List all Cloudflare accounts accessible with the given token.\n *\n * GET /client/v4/accounts\n */\nexport async function getAccounts(\n apiToken: string,\n): Promise<Array<{ id: string; name: string }>> {\n const response = await fetch(`${CF_BASE}/accounts`, {\n headers: cfHeaders(apiToken),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n result?: Array<{ id: string; name: string }>;\n };\n\n if (!response.ok || !data.success) return [];\n return data.result ?? [];\n}\n\n// ---------------------------------------------------------------------------\n// getZones\n// ---------------------------------------------------------------------------\n\n/**\n * List all DNS zones (domains) accessible with the given token.\n *\n * GET /client/v4/zones\n */\nexport async function getZones(\n apiToken: string,\n): Promise<Array<{ id: string; name: string; status: string; accountId?: string }>> {\n const response = await fetch(`${CF_BASE}/zones`, {\n headers: cfHeaders(apiToken),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n result?: Array<{ id: string; name: string; status: string; account?: { id: string } }>;\n };\n\n if (!response.ok || !data.success) return [];\n return (data.result ?? []).map((z) => ({\n id: z.id,\n name: z.name,\n status: z.status,\n accountId: z.account?.id,\n }));\n}\n\n// ---------------------------------------------------------------------------\n// createTunnel\n// ---------------------------------------------------------------------------\n\n/**\n * Create a Cloudflare Tunnel via the API.\n *\n * POST /client/v4/accounts/{accountId}/cfd_tunnel\n */\nexport async function createTunnel(\n apiToken: string,\n accountId: string,\n name: string,\n): Promise<{ tunnelId: string; tunnelToken: string }> {\n const tunnelSecret = crypto.randomBytes(32).toString('base64');\n const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: cfHeaders(apiToken),\n body: JSON.stringify({\n name,\n config_src: 'cloudflare',\n tunnel_secret: tunnelSecret,\n }),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n result?: { id: string; token: string };\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n throw new Error(msg);\n }\n\n if (!data.result?.id || !data.result?.token) {\n throw new Error('Cloudflare API returned unexpected response (missing id or token)');\n }\n\n return {\n tunnelId: data.result.id,\n tunnelToken: data.result.token,\n };\n}\n\n// ---------------------------------------------------------------------------\n// configureTunnelIngress\n// ---------------------------------------------------------------------------\n\n/**\n * Configure ingress rules for a Cloudflare Tunnel.\n *\n * PUT /client/v4/accounts/{accountId}/cfd_tunnel/{tunnelId}/configurations\n */\nexport async function configureTunnelIngress(\n apiToken: string,\n accountId: string,\n tunnelId: string,\n domain: string,\n routes: ServiceRoute[],\n): Promise<void> {\n const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`;\n\n const ingress = [\n ...routes.map((r) => ({\n hostname: `${r.subdomain}.${r.domain ?? domain}`,\n service: `http://${r.containerName}:${r.port}`,\n })),\n { service: 'http_status:404' },\n ];\n\n const response = await fetch(url, {\n method: 'PUT',\n headers: cfHeaders(apiToken),\n body: JSON.stringify({ config: { ingress } }),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n throw new Error(`Failed to configure ingress: ${msg}`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// createDnsRecord\n// ---------------------------------------------------------------------------\n\n/**\n * Create a CNAME DNS record pointing to the Cloudflare Tunnel.\n *\n * POST /client/v4/zones/{zoneId}/dns_records\n * Record: {subdomain}.{domain} → {tunnelId}.cfargotunnel.com (proxied)\n */\nexport async function createDnsRecord(\n apiToken: string,\n zoneId: string,\n tunnelId: string,\n subdomain: string,\n domain: string,\n): Promise<void> {\n const url = `${CF_BASE}/zones/${zoneId}/dns_records`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: cfHeaders(apiToken),\n body: JSON.stringify({\n type: 'CNAME',\n name: `${subdomain}.${domain}`,\n content: `${tunnelId}.cfargotunnel.com`,\n proxied: true,\n }),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n // Non-fatal if record already exists\n if (!msg.toLowerCase().includes('already exists')) {\n throw new Error(`DNS record creation failed: ${msg}`);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// getDnsRecords\n// ---------------------------------------------------------------------------\n\n/**\n * Query CNAME DNS records for a specific hostname in a zone.\n *\n * GET /client/v4/zones/{zoneId}/dns_records?type=CNAME&name={hostname}\n */\nexport async function getDnsRecords(\n apiToken: string,\n zoneId: string,\n hostname: string,\n): Promise<Array<{ id: string; name: string; content: string; proxied: boolean }>> {\n const url = `${CF_BASE}/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(hostname)}`;\n\n const response = await fetchWithRetry(url, {\n headers: cfHeaders(apiToken),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n result?: Array<{ id: string; name: string; content: string; proxied: boolean }>;\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n throw new Error(`Failed to query DNS records: ${msg}`);\n }\n\n return data.result ?? [];\n}\n\n// ---------------------------------------------------------------------------\n// deleteDnsRecord\n// ---------------------------------------------------------------------------\n\n/**\n * Delete a DNS record by ID.\n *\n * DELETE /client/v4/zones/{zoneId}/dns_records/{recordId}\n */\nexport async function deleteDnsRecord(\n apiToken: string,\n zoneId: string,\n recordId: string,\n): Promise<void> {\n const url = `${CF_BASE}/zones/${zoneId}/dns_records/${recordId}`;\n\n const response = await fetchWithRetry(url, {\n method: 'DELETE',\n headers: cfHeaders(apiToken),\n });\n\n if (response.status === 404) {\n // Already deleted — treat as success\n return;\n }\n\n const data = (await response.json()) as {\n success: boolean;\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n throw new Error(`Failed to delete DNS record: ${msg}`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// getTunnelHealth\n// ---------------------------------------------------------------------------\n\n/**\n * Query the health of a Cloudflare Tunnel.\n *\n * GET /client/v4/accounts/{accountId}/cfd_tunnel/{tunnelId}\n *\n * Returns the tunnel status and number of active connectors.\n * Used for health verification after tunnel creation and for `brewnet domain tunnel status`.\n */\nexport async function getTunnelHealth(\n apiToken: string,\n accountId: string,\n tunnelId: string,\n): Promise<{ status: 'healthy' | 'degraded' | 'inactive'; connectorCount: number }> {\n const url = `${CF_BASE}/accounts/${accountId}/cfd_tunnel/${tunnelId}`;\n\n const response = await fetchWithRetry(url, {\n headers: cfHeaders(apiToken),\n });\n\n const data = (await response.json()) as {\n success: boolean;\n result?: {\n status: string;\n connections?: Array<unknown>;\n };\n errors?: Array<{ message: string }>;\n };\n\n if (!response.ok || !data.success) {\n const msg = data.errors?.[0]?.message ?? `HTTP ${response.status}`;\n throw new Error(`Failed to get tunnel health: ${msg}`);\n }\n\n const rawStatus = data.result?.status ?? 'inactive';\n const connectorCount = data.result?.connections?.length ?? 0;\n\n const status: 'healthy' | 'degraded' | 'inactive' =\n rawStatus === 'healthy' ? 'healthy'\n : rawStatus === 'degraded' ? 'degraded'\n : 'inactive';\n\n return { status, connectorCount };\n}\n\n// ---------------------------------------------------------------------------\n// buildTokenCreationUrl\n// ---------------------------------------------------------------------------\n\n/**\n * Build a pre-filled Cloudflare API Token creation URL.\n *\n * Sets permissions: Cloudflare Tunnel (Edit) + DNS (Edit)\n * Sets name: brewnet-{projectName}\n */\nexport function buildTokenCreationUrl(projectName: string): string {\n const perms = encodeURIComponent(\n JSON.stringify([\n { key: 'cloudflare_tunnel', type: 'edit' },\n { key: 'dns', type: 'edit' },\n ]),\n );\n return `https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=${perms}&name=brewnet-${projectName}`;\n}\n\n// ---------------------------------------------------------------------------\n// getActiveServiceRoutes\n// ---------------------------------------------------------------------------\n\n/**\n * Build the list of service routes to expose through the Cloudflare Tunnel.\n * Called after server component selection to determine which ingress rules to create.\n */\nexport function getActiveServiceRoutes(state: WizardState): ServiceRoute[] {\n const routes: ServiceRoute[] = [];\n\n // Git server (always enabled)\n routes.push({ subdomain: 'git', containerName: 'gitea', port: 3000 });\n\n // File server\n if (state.servers.fileServer?.enabled) {\n if (state.servers.fileServer.service === 'nextcloud') {\n routes.push({ subdomain: 'cloud', containerName: 'nextcloud', port: 80 });\n } else if (state.servers.fileServer.service === 'minio') {\n routes.push({ subdomain: 'minio', containerName: 'minio', port: 9001 });\n }\n }\n\n // Media\n if (state.servers.media?.enabled && state.servers.media.services?.includes('jellyfin')) {\n routes.push({ subdomain: 'media', containerName: 'jellyfin', port: 8096 });\n }\n\n // Database admin UI (pgAdmin for PostgreSQL)\n if (\n state.servers.dbServer?.enabled &&\n state.servers.dbServer.adminUI &&\n state.servers.dbServer.primary === 'postgresql'\n ) {\n routes.push({ subdomain: 'pgadmin', containerName: 'pgadmin', port: 80 });\n }\n\n // FileBrowser\n if (state.servers.fileBrowser?.enabled) {\n routes.push({ subdomain: 'files', containerName: 'filebrowser', port: 80 });\n }\n\n return routes;\n}\n"],"mappings":"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","names":[]}
|