@cliftonc/cfld 0.1.0
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 +21 -0
- package/README.md +129 -0
- package/dist/cli.js +1890 -0
- package/dist/dashboard-QUPY25QT.js +85 -0
- package/package.json +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1890 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import pc7 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/commands/run.ts
|
|
8
|
+
import { join as join5 } from "path";
|
|
9
|
+
|
|
10
|
+
// src/core/state-machine.ts
|
|
11
|
+
import { existsSync as existsSync2 } from "fs";
|
|
12
|
+
|
|
13
|
+
// src/util/paths.ts
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
function cloudflaredDir() {
|
|
17
|
+
return process.env.CLOUDFLARED_HOME ?? join(homedir(), ".cloudflared");
|
|
18
|
+
}
|
|
19
|
+
function defaultCertPath() {
|
|
20
|
+
return process.env.TUNNEL_ORIGIN_CERT ?? join(cloudflaredDir(), "cert.pem");
|
|
21
|
+
}
|
|
22
|
+
function cfldDir() {
|
|
23
|
+
return process.env.CFLD_HOME ?? join(homedir(), ".cfld");
|
|
24
|
+
}
|
|
25
|
+
function certStoreDir() {
|
|
26
|
+
return join(cfldDir(), "certs");
|
|
27
|
+
}
|
|
28
|
+
function certPathForZone(zone) {
|
|
29
|
+
return join(certStoreDir(), `${zone}.pem`);
|
|
30
|
+
}
|
|
31
|
+
function registryPath() {
|
|
32
|
+
return join(cfldDir(), "registry.json");
|
|
33
|
+
}
|
|
34
|
+
function credentialsPath(uuid) {
|
|
35
|
+
return join(cloudflaredDir(), `${uuid}.json`);
|
|
36
|
+
}
|
|
37
|
+
function ingressConfigPath(slug) {
|
|
38
|
+
return join(cloudflaredDir(), `${slug}.cfld.yml`);
|
|
39
|
+
}
|
|
40
|
+
var PROJECT_CONFIG_FILE = ".cfld.json";
|
|
41
|
+
|
|
42
|
+
// src/util/exec.ts
|
|
43
|
+
import { spawn } from "child_process";
|
|
44
|
+
function exec(command, args, options = {}) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const spawnOptions = {
|
|
47
|
+
env: { ...process.env, ...options.env },
|
|
48
|
+
cwd: options.cwd,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
50
|
+
};
|
|
51
|
+
const child = spawn(command, args, spawnOptions);
|
|
52
|
+
let stdout = "";
|
|
53
|
+
let stderr = "";
|
|
54
|
+
const wire = (stream) => {
|
|
55
|
+
const source = stream === "stdout" ? child.stdout : child.stderr;
|
|
56
|
+
if (!source) return;
|
|
57
|
+
let buffer = "";
|
|
58
|
+
source.setEncoding("utf8");
|
|
59
|
+
source.on("data", (chunk) => {
|
|
60
|
+
if (stream === "stdout") stdout += chunk;
|
|
61
|
+
else stderr += chunk;
|
|
62
|
+
if (!options.onLine) return;
|
|
63
|
+
buffer += chunk;
|
|
64
|
+
let idx;
|
|
65
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
66
|
+
const line = buffer.slice(0, idx);
|
|
67
|
+
buffer = buffer.slice(idx + 1);
|
|
68
|
+
options.onLine(line, stream);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
source.on("end", () => {
|
|
72
|
+
if (options.onLine && buffer.length > 0) options.onLine(buffer, stream);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
wire("stdout");
|
|
76
|
+
wire("stderr");
|
|
77
|
+
child.on("error", (err) => {
|
|
78
|
+
reject(err);
|
|
79
|
+
});
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
resolve({ code, stdout, stderr });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/util/errors.ts
|
|
87
|
+
var CfldError = class extends Error {
|
|
88
|
+
hint;
|
|
89
|
+
constructor(message, hint) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = "CfldError";
|
|
92
|
+
this.hint = hint;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/core/cloudflared.ts
|
|
97
|
+
var cachedBinary;
|
|
98
|
+
async function resolveBinary() {
|
|
99
|
+
if (cachedBinary) return cachedBinary;
|
|
100
|
+
const override = process.env.CFLD_CLOUDFLARED ?? process.env.CLOUDFLARED_BIN;
|
|
101
|
+
if (override) return cachedBinary = override;
|
|
102
|
+
try {
|
|
103
|
+
const res = await exec("cloudflared", ["--version"]);
|
|
104
|
+
if (res.code === 0) return cachedBinary = "cloudflared";
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const mod = await import("cloudflared");
|
|
109
|
+
return cachedBinary = mod.bin;
|
|
110
|
+
} catch {
|
|
111
|
+
throw new CfldError(
|
|
112
|
+
"cloudflared is not installed and the managed binary could not be loaded.",
|
|
113
|
+
"Install it with `brew install cloudflared`, or set CFLD_CLOUDFLARED to its path."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function resolveBinaryAbsolute() {
|
|
118
|
+
const bin = await resolveBinary();
|
|
119
|
+
if (bin.includes("/")) return bin;
|
|
120
|
+
try {
|
|
121
|
+
const res = await exec("which", [bin]);
|
|
122
|
+
const path = res.stdout.trim().split("\n")[0];
|
|
123
|
+
if (res.code === 0 && path) return path;
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
return bin;
|
|
127
|
+
}
|
|
128
|
+
async function runTunnel(subArgs, options = {}) {
|
|
129
|
+
const { originCert, ...execOptions } = options;
|
|
130
|
+
const bin = await resolveBinary();
|
|
131
|
+
const args = ["tunnel"];
|
|
132
|
+
if (originCert) args.push("--origincert", originCert);
|
|
133
|
+
args.push(...subArgs);
|
|
134
|
+
return exec(bin, args, execOptions);
|
|
135
|
+
}
|
|
136
|
+
async function listTunnels(originCert) {
|
|
137
|
+
const res = await runTunnel(["list", "--output", "json"], { originCert });
|
|
138
|
+
if (res.code !== 0) {
|
|
139
|
+
throw new CfldError(
|
|
140
|
+
"Failed to list Cloudflare tunnels.",
|
|
141
|
+
res.stderr.trim() || "Check that your cert is valid \u2014 try `cfld login`."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(res.stdout);
|
|
146
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function findTunnel(name, originCert) {
|
|
152
|
+
const tunnels = await listTunnels(originCert);
|
|
153
|
+
return tunnels.find((t) => t.name === name);
|
|
154
|
+
}
|
|
155
|
+
var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
156
|
+
async function createTunnel(name, originCert) {
|
|
157
|
+
const res = await runTunnel(["create", name], { originCert });
|
|
158
|
+
if (res.code !== 0) {
|
|
159
|
+
if (/already exists/i.test(res.stderr)) {
|
|
160
|
+
const existing = await findTunnel(name, originCert);
|
|
161
|
+
if (existing) return existing.id;
|
|
162
|
+
}
|
|
163
|
+
throw new CfldError(
|
|
164
|
+
`Failed to create tunnel "${name}".`,
|
|
165
|
+
res.stderr.trim() || void 0
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const match = res.stdout.match(UUID_RE);
|
|
169
|
+
if (match) return match[0];
|
|
170
|
+
const created = await findTunnel(name, originCert);
|
|
171
|
+
if (created) return created.id;
|
|
172
|
+
throw new CfldError(
|
|
173
|
+
`Created tunnel "${name}" but could not determine its UUID.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
async function fetchToken(nameOrUuid, credFile, originCert) {
|
|
177
|
+
const res = await runTunnel(
|
|
178
|
+
["token", "--cred-file", credFile, nameOrUuid],
|
|
179
|
+
{ originCert }
|
|
180
|
+
);
|
|
181
|
+
if (res.code !== 0) {
|
|
182
|
+
throw new CfldError(
|
|
183
|
+
`Failed to fetch credentials for tunnel "${nameOrUuid}".`,
|
|
184
|
+
res.stderr.trim() || void 0
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function routeDns(nameOrUuid, hostname, originCert) {
|
|
189
|
+
const res = await runTunnel(
|
|
190
|
+
["route", "dns", "--overwrite-dns", nameOrUuid, hostname],
|
|
191
|
+
{ originCert }
|
|
192
|
+
);
|
|
193
|
+
if (res.code !== 0) {
|
|
194
|
+
throw new CfldError(
|
|
195
|
+
`Failed to route DNS ${hostname} \u2192 tunnel "${nameOrUuid}".`,
|
|
196
|
+
res.stderr.trim() || "The hostname's zone may not match this cert \u2014 try `cfld login --reauth`."
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function deleteTunnel(nameOrUuid, originCert) {
|
|
201
|
+
const res = await runTunnel(["delete", nameOrUuid], { originCert });
|
|
202
|
+
if (res.code !== 0) {
|
|
203
|
+
throw new CfldError(
|
|
204
|
+
`Failed to delete tunnel "${nameOrUuid}".`,
|
|
205
|
+
res.stderr.trim() || void 0
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/core/dns.ts
|
|
211
|
+
import { resolveCname } from "dns/promises";
|
|
212
|
+
var CFARGO_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.cfargotunnel\.com/i;
|
|
213
|
+
async function existingCnameTarget(hostname) {
|
|
214
|
+
try {
|
|
215
|
+
const records = await resolveCname(hostname);
|
|
216
|
+
return records[0];
|
|
217
|
+
} catch {
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function cfargoTunnelUuid(target) {
|
|
222
|
+
return target?.match(CFARGO_RE)?.[1]?.toLowerCase();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/core/config-file.ts
|
|
226
|
+
import { writeFileSync } from "fs";
|
|
227
|
+
function renderIngressConfig(cfg) {
|
|
228
|
+
const rules = cfg.routes.flatMap((r) => {
|
|
229
|
+
const lines = [` - hostname: ${r.hostname}`];
|
|
230
|
+
if (r.path) lines.push(` path: ${r.path}`);
|
|
231
|
+
lines.push(` service: http://localhost:${r.port}`);
|
|
232
|
+
return lines;
|
|
233
|
+
});
|
|
234
|
+
return [
|
|
235
|
+
`tunnel: ${cfg.uuid}`,
|
|
236
|
+
`credentials-file: ${credentialsPath(cfg.uuid)}`,
|
|
237
|
+
`ingress:`,
|
|
238
|
+
...rules,
|
|
239
|
+
` - service: http_status:404`,
|
|
240
|
+
``
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
function writeIngressConfig(cfg) {
|
|
244
|
+
const path = ingressConfigPath(cfg.slug);
|
|
245
|
+
writeFileSync(path, renderIngressConfig(cfg), "utf8");
|
|
246
|
+
return path;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/core/registry.ts
|
|
250
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
251
|
+
function empty() {
|
|
252
|
+
return { version: 1, tunnels: [] };
|
|
253
|
+
}
|
|
254
|
+
function readRegistry() {
|
|
255
|
+
const p = registryPath();
|
|
256
|
+
if (!existsSync(p)) return empty();
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
259
|
+
if (!parsed || !Array.isArray(parsed.tunnels)) return empty();
|
|
260
|
+
return parsed;
|
|
261
|
+
} catch {
|
|
262
|
+
return empty();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function writeRegistry(data) {
|
|
266
|
+
mkdirSync(cfldDir(), { recursive: true });
|
|
267
|
+
writeFileSync2(registryPath(), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
268
|
+
}
|
|
269
|
+
function listEntries() {
|
|
270
|
+
return readRegistry().tunnels;
|
|
271
|
+
}
|
|
272
|
+
function getEntry(name) {
|
|
273
|
+
return readRegistry().tunnels.find((t) => t.name === name);
|
|
274
|
+
}
|
|
275
|
+
function upsertEntry(entry) {
|
|
276
|
+
const data = readRegistry();
|
|
277
|
+
const idx = data.tunnels.findIndex((t) => t.name === entry.name);
|
|
278
|
+
if (idx === -1) data.tunnels.push(entry);
|
|
279
|
+
else data.tunnels[idx] = entry;
|
|
280
|
+
writeRegistry(data);
|
|
281
|
+
}
|
|
282
|
+
function removeEntry(name) {
|
|
283
|
+
const data = readRegistry();
|
|
284
|
+
data.tunnels = data.tunnels.filter((t) => t.name !== name);
|
|
285
|
+
writeRegistry(data);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/core/state-machine.ts
|
|
289
|
+
async function reconcile(input, events = {}) {
|
|
290
|
+
const step2 = (s) => events.onStep?.(s);
|
|
291
|
+
const { tunnelName, certPath, routes, slug, zone, projectDir } = input;
|
|
292
|
+
const primary = routes[0];
|
|
293
|
+
if (!primary) throw new CfldError("At least one route is required.");
|
|
294
|
+
step2(`Resolving tunnel ${tunnelName}`);
|
|
295
|
+
const existing = await findTunnel(tunnelName, certPath);
|
|
296
|
+
let uuid;
|
|
297
|
+
let created = false;
|
|
298
|
+
if (existing) {
|
|
299
|
+
uuid = existing.id;
|
|
300
|
+
if (!existsSync2(credentialsPath(uuid))) {
|
|
301
|
+
step2("Refetching tunnel credentials");
|
|
302
|
+
await fetchToken(tunnelName, credentialsPath(uuid), certPath);
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
step2(`Creating tunnel ${tunnelName}`);
|
|
306
|
+
uuid = await createTunnel(tunnelName, certPath);
|
|
307
|
+
created = true;
|
|
308
|
+
}
|
|
309
|
+
const prior = getEntry(tunnelName);
|
|
310
|
+
const owned = /* @__PURE__ */ new Set([
|
|
311
|
+
prior?.hostname,
|
|
312
|
+
...(prior?.routes ?? []).map((r) => r.hostname)
|
|
313
|
+
]);
|
|
314
|
+
for (const route of routes) {
|
|
315
|
+
if (!input.force && !owned.has(route.hostname)) {
|
|
316
|
+
const target = await existingCnameTarget(route.hostname);
|
|
317
|
+
if (target && cfargoTunnelUuid(target) !== uuid.toLowerCase()) {
|
|
318
|
+
const ok = events.confirmOverwrite ? await events.confirmOverwrite(route.hostname, target) : false;
|
|
319
|
+
if (!ok) {
|
|
320
|
+
throw new CfldError(
|
|
321
|
+
`${route.hostname} already points to ${target}.`,
|
|
322
|
+
"Re-run with --force to repoint it, or pick another host."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
step2(`Routing ${route.hostname}`);
|
|
328
|
+
await routeDns(tunnelName, route.hostname, certPath);
|
|
329
|
+
}
|
|
330
|
+
step2("Writing ingress config");
|
|
331
|
+
const configPath = writeIngressConfig({ slug, uuid, routes });
|
|
332
|
+
upsertEntry({
|
|
333
|
+
name: tunnelName,
|
|
334
|
+
zone,
|
|
335
|
+
certPath,
|
|
336
|
+
uuid,
|
|
337
|
+
hostname: primary.hostname,
|
|
338
|
+
port: primary.port,
|
|
339
|
+
routes,
|
|
340
|
+
projectDir,
|
|
341
|
+
createdAt: prior?.createdAt ?? nowIso(),
|
|
342
|
+
lastRun: nowIso()
|
|
343
|
+
});
|
|
344
|
+
return {
|
|
345
|
+
uuid,
|
|
346
|
+
tunnelName,
|
|
347
|
+
hostname: primary.hostname,
|
|
348
|
+
port: primary.port,
|
|
349
|
+
routes,
|
|
350
|
+
certPath,
|
|
351
|
+
configPath,
|
|
352
|
+
created
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function nowIso() {
|
|
356
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/core/cert-store.ts
|
|
360
|
+
import { copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync } from "fs";
|
|
361
|
+
|
|
362
|
+
// src/core/cert.ts
|
|
363
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
364
|
+
var TOKEN_BLOCK_RE = /-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/;
|
|
365
|
+
function certExists(certPath) {
|
|
366
|
+
return existsSync3(certPath);
|
|
367
|
+
}
|
|
368
|
+
function readCertToken(certPath) {
|
|
369
|
+
if (!existsSync3(certPath)) return void 0;
|
|
370
|
+
let pem;
|
|
371
|
+
try {
|
|
372
|
+
pem = readFileSync2(certPath, "utf8");
|
|
373
|
+
} catch {
|
|
374
|
+
return void 0;
|
|
375
|
+
}
|
|
376
|
+
const match = pem.match(TOKEN_BLOCK_RE);
|
|
377
|
+
if (!match || !match[1]) return void 0;
|
|
378
|
+
try {
|
|
379
|
+
const json = Buffer.from(match[1].replace(/\s+/g, ""), "base64").toString(
|
|
380
|
+
"utf8"
|
|
381
|
+
);
|
|
382
|
+
const parsed = JSON.parse(json);
|
|
383
|
+
return parsed;
|
|
384
|
+
} catch {
|
|
385
|
+
return void 0;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function certHasZone(certPath) {
|
|
389
|
+
const token = readCertToken(certPath);
|
|
390
|
+
return Boolean(token?.zoneID);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/core/cert-store.ts
|
|
394
|
+
function ensureStoreDir() {
|
|
395
|
+
mkdirSync2(certStoreDir(), { recursive: true });
|
|
396
|
+
}
|
|
397
|
+
function storedCertForZone(zone) {
|
|
398
|
+
const p = certPathForZone(zone);
|
|
399
|
+
return existsSync4(p) ? p : void 0;
|
|
400
|
+
}
|
|
401
|
+
function listStoredZones() {
|
|
402
|
+
try {
|
|
403
|
+
return readdirSync(certStoreDir()).filter((f) => f.endsWith(".pem")).map((f) => f.slice(0, -".pem".length));
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function resolveCert(zone) {
|
|
409
|
+
return storedCertForZone(zone) ?? (certExists(defaultCertPath()) ? defaultCertPath() : void 0);
|
|
410
|
+
}
|
|
411
|
+
function importCert(zone, fromPath = defaultCertPath()) {
|
|
412
|
+
ensureStoreDir();
|
|
413
|
+
const dest = certPathForZone(zone);
|
|
414
|
+
copyFileSync(fromPath, dest);
|
|
415
|
+
return dest;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/core/hostname.ts
|
|
419
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
|
|
420
|
+
import { basename, join as join2 } from "path";
|
|
421
|
+
function slugify(input) {
|
|
422
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
423
|
+
}
|
|
424
|
+
function deriveSlug(cwd, nameFlag) {
|
|
425
|
+
if (nameFlag) return slugify(nameFlag);
|
|
426
|
+
const pkgPath = join2(cwd, "package.json");
|
|
427
|
+
if (existsSync5(pkgPath)) {
|
|
428
|
+
try {
|
|
429
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
430
|
+
if (pkg.name) {
|
|
431
|
+
const unscoped = pkg.name.replace(/^@[^/]+\//, "");
|
|
432
|
+
const slug = slugify(unscoped);
|
|
433
|
+
if (slug) return slug;
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return slugify(basename(cwd)) || "app";
|
|
439
|
+
}
|
|
440
|
+
function tunnelNameForSlug(slug) {
|
|
441
|
+
return `cfld-${slug}`;
|
|
442
|
+
}
|
|
443
|
+
function buildHostname(slug, zone) {
|
|
444
|
+
return `${slug}.${zone}`;
|
|
445
|
+
}
|
|
446
|
+
function zoneForHostname(hostname, knownZones) {
|
|
447
|
+
const matches = knownZones.filter((z) => hostname === z || hostname.endsWith(`.${z}`)).sort((a, b) => b.length - a.length);
|
|
448
|
+
if (matches[0]) return matches[0];
|
|
449
|
+
const labels = hostname.split(".");
|
|
450
|
+
return labels.slice(-2).join(".");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/core/port.ts
|
|
454
|
+
import { createServer, Socket } from "net";
|
|
455
|
+
var COMMON_DEV_PORTS = [3e3, 5173, 8080, 8e3, 5e3, 4200, 3001];
|
|
456
|
+
function isPortListening(port, timeoutMs = 300) {
|
|
457
|
+
return new Promise((resolve) => {
|
|
458
|
+
const socket = new Socket();
|
|
459
|
+
let settled = false;
|
|
460
|
+
const done2 = (result) => {
|
|
461
|
+
if (settled) return;
|
|
462
|
+
settled = true;
|
|
463
|
+
socket.destroy();
|
|
464
|
+
resolve(result);
|
|
465
|
+
};
|
|
466
|
+
socket.setTimeout(timeoutMs);
|
|
467
|
+
socket.once("connect", () => done2(true));
|
|
468
|
+
socket.once("timeout", () => done2(false));
|
|
469
|
+
socket.once("error", () => done2(false));
|
|
470
|
+
socket.connect(port, "127.0.0.1");
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async function detectDevPorts() {
|
|
474
|
+
const checks = await Promise.all(
|
|
475
|
+
COMMON_DEV_PORTS.map(async (p) => await isPortListening(p) ? p : null)
|
|
476
|
+
);
|
|
477
|
+
return checks.filter((p) => p !== null);
|
|
478
|
+
}
|
|
479
|
+
function findFreePort() {
|
|
480
|
+
return new Promise((resolve, reject) => {
|
|
481
|
+
const server = createServer();
|
|
482
|
+
server.unref();
|
|
483
|
+
server.on("error", reject);
|
|
484
|
+
server.listen(0, "127.0.0.1", () => {
|
|
485
|
+
const addr = server.address();
|
|
486
|
+
if (addr && typeof addr === "object") {
|
|
487
|
+
const { port } = addr;
|
|
488
|
+
server.close(() => resolve(port));
|
|
489
|
+
} else {
|
|
490
|
+
server.close(() => reject(new Error("Could not acquire a free port")));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/core/project-config.ts
|
|
497
|
+
import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
498
|
+
import { join as join3 } from "path";
|
|
499
|
+
function dotConfigPath(cwd) {
|
|
500
|
+
return join3(cwd, PROJECT_CONFIG_FILE);
|
|
501
|
+
}
|
|
502
|
+
function readAuthored(cwd) {
|
|
503
|
+
const pkgPath = join3(cwd, "package.json");
|
|
504
|
+
if (!existsSync6(pkgPath)) return void 0;
|
|
505
|
+
try {
|
|
506
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
507
|
+
const c = pkg.cfld;
|
|
508
|
+
if (!c) return void 0;
|
|
509
|
+
return {
|
|
510
|
+
name: c.name,
|
|
511
|
+
zone: c.zone,
|
|
512
|
+
port: c.port,
|
|
513
|
+
hostname: c.hostname ?? c.host,
|
|
514
|
+
envKey: c.envKey,
|
|
515
|
+
routes: c.routes
|
|
516
|
+
};
|
|
517
|
+
} catch {
|
|
518
|
+
return void 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function readCached(cwd) {
|
|
522
|
+
const p = dotConfigPath(cwd);
|
|
523
|
+
if (!existsSync6(p)) return void 0;
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(readFileSync4(p, "utf8"));
|
|
526
|
+
} catch {
|
|
527
|
+
return void 0;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function readProjectConfig(cwd) {
|
|
531
|
+
const authored = readAuthored(cwd);
|
|
532
|
+
const cached = readCached(cwd);
|
|
533
|
+
if (!authored && !cached) return void 0;
|
|
534
|
+
return {
|
|
535
|
+
name: authored?.name ?? cached?.name,
|
|
536
|
+
zone: authored?.zone ?? cached?.zone,
|
|
537
|
+
port: authored?.port ?? cached?.port,
|
|
538
|
+
hostname: authored?.hostname ?? cached?.hostname,
|
|
539
|
+
envKey: authored?.envKey ?? cached?.envKey,
|
|
540
|
+
routes: authored?.routes ?? cached?.routes,
|
|
541
|
+
uuid: cached?.uuid
|
|
542
|
+
// resolved identity only ever comes from the cache
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function writeProjectConfig(cwd, config) {
|
|
546
|
+
writeFileSync3(
|
|
547
|
+
dotConfigPath(cwd),
|
|
548
|
+
JSON.stringify(config, null, 2) + "\n",
|
|
549
|
+
"utf8"
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/core/env-writer.ts
|
|
554
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
555
|
+
function upsertEnv(envPath, key, value) {
|
|
556
|
+
const line = `${key}=${value}`;
|
|
557
|
+
const existing = existsSync7(envPath) ? readFileSync5(envPath, "utf8") : "";
|
|
558
|
+
const body = existing.replace(/\n$/, "");
|
|
559
|
+
const lines = body.length ? body.split("\n") : [];
|
|
560
|
+
const keyRe = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
|
561
|
+
let found = false;
|
|
562
|
+
let changed = false;
|
|
563
|
+
const next = lines.map((l) => {
|
|
564
|
+
if (keyRe.test(l)) {
|
|
565
|
+
found = true;
|
|
566
|
+
if (l !== line) changed = true;
|
|
567
|
+
return line;
|
|
568
|
+
}
|
|
569
|
+
return l;
|
|
570
|
+
});
|
|
571
|
+
if (!found) {
|
|
572
|
+
next.push(line);
|
|
573
|
+
changed = true;
|
|
574
|
+
}
|
|
575
|
+
if (!changed) return { changed: false };
|
|
576
|
+
writeFileSync4(envPath, next.join("\n") + "\n", "utf8");
|
|
577
|
+
return { changed: true };
|
|
578
|
+
}
|
|
579
|
+
function escapeRegExp(s) {
|
|
580
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/process/runner.ts
|
|
584
|
+
import { spawn as spawn2 } from "child_process";
|
|
585
|
+
var BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
|
|
586
|
+
var RAPID_FAILURE_MS = 3e3;
|
|
587
|
+
var MAX_RAPID_FAILURES = 5;
|
|
588
|
+
async function startRunner(options) {
|
|
589
|
+
const bin = await resolveBinary();
|
|
590
|
+
const args = [
|
|
591
|
+
"tunnel",
|
|
592
|
+
"--origincert",
|
|
593
|
+
options.certPath,
|
|
594
|
+
"--config",
|
|
595
|
+
options.configPath,
|
|
596
|
+
"--metrics",
|
|
597
|
+
`127.0.0.1:${options.metricsPort}`,
|
|
598
|
+
"run",
|
|
599
|
+
options.tunnelName
|
|
600
|
+
];
|
|
601
|
+
let child;
|
|
602
|
+
let stopped = false;
|
|
603
|
+
let rapidFailures = 0;
|
|
604
|
+
let restartTimer;
|
|
605
|
+
const spawnOnce = () => {
|
|
606
|
+
const startedAt = Date.now();
|
|
607
|
+
child = spawn2(bin, args, {
|
|
608
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
609
|
+
env: { ...process.env, TUNNEL_ORIGIN_CERT: options.certPath }
|
|
610
|
+
});
|
|
611
|
+
wireLines(child, "stdout", options.onLine);
|
|
612
|
+
wireLines(child, "stderr", options.onLine);
|
|
613
|
+
child.on("exit", (code) => {
|
|
614
|
+
if (stopped) return;
|
|
615
|
+
const uptime = Date.now() - startedAt;
|
|
616
|
+
rapidFailures = uptime < RAPID_FAILURE_MS ? rapidFailures + 1 : 0;
|
|
617
|
+
if (rapidFailures >= MAX_RAPID_FAILURES) {
|
|
618
|
+
options.onFatal?.(
|
|
619
|
+
`cloudflared exited repeatedly (last code ${code}). Likely a config or credentials problem \u2014 run \`cfld doctor\`.`
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const delay = BACKOFF_MS[Math.min(rapidFailures, BACKOFF_MS.length - 1)];
|
|
624
|
+
options.onStatus?.("reconnecting");
|
|
625
|
+
restartTimer = setTimeout(() => {
|
|
626
|
+
if (stopped) return;
|
|
627
|
+
options.onStatus?.("connecting");
|
|
628
|
+
spawnOnce();
|
|
629
|
+
}, delay);
|
|
630
|
+
restartTimer.unref();
|
|
631
|
+
});
|
|
632
|
+
};
|
|
633
|
+
options.onStatus?.("connecting");
|
|
634
|
+
spawnOnce();
|
|
635
|
+
return {
|
|
636
|
+
stop() {
|
|
637
|
+
stopped = true;
|
|
638
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
639
|
+
options.onStatus?.("stopped");
|
|
640
|
+
const c = child;
|
|
641
|
+
if (!c || c.exitCode !== null || c.signalCode) return;
|
|
642
|
+
c.kill("SIGINT");
|
|
643
|
+
const t = setTimeout(() => {
|
|
644
|
+
if (c.exitCode === null && !c.signalCode) c.kill("SIGKILL");
|
|
645
|
+
}, 4e3);
|
|
646
|
+
t.unref();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function wireLines(child, stream, onLine) {
|
|
651
|
+
const source = stream === "stdout" ? child.stdout : child.stderr;
|
|
652
|
+
if (!source || !onLine) return;
|
|
653
|
+
let buffer = "";
|
|
654
|
+
source.setEncoding("utf8");
|
|
655
|
+
source.on("data", (chunk) => {
|
|
656
|
+
buffer += chunk;
|
|
657
|
+
let idx;
|
|
658
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
659
|
+
onLine(buffer.slice(0, idx), stream);
|
|
660
|
+
buffer = buffer.slice(idx + 1);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/process/ready.ts
|
|
666
|
+
async function getReady(metricsPort) {
|
|
667
|
+
try {
|
|
668
|
+
const res = await fetch(`http://127.0.0.1:${metricsPort}/ready`);
|
|
669
|
+
if (res.status !== 200) return { ready: false, connections: 0 };
|
|
670
|
+
const body = await res.json();
|
|
671
|
+
return { ready: true, connections: body.readyConnections ?? 0 };
|
|
672
|
+
} catch {
|
|
673
|
+
return void 0;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
async function waitForReady(metricsPort, opts = {}) {
|
|
677
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
678
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
679
|
+
const deadline = Date.now() + timeoutMs;
|
|
680
|
+
while (Date.now() < deadline) {
|
|
681
|
+
if (opts.signal?.aborted) throw new Error("aborted");
|
|
682
|
+
const state = await getReady(metricsPort);
|
|
683
|
+
if (state?.ready) return state;
|
|
684
|
+
await sleep(intervalMs);
|
|
685
|
+
}
|
|
686
|
+
throw new Error("Timed out waiting for the tunnel to connect to Cloudflare.");
|
|
687
|
+
}
|
|
688
|
+
async function scrapeMetrics(metricsPort) {
|
|
689
|
+
try {
|
|
690
|
+
const res = await fetch(`http://127.0.0.1:${metricsPort}/metrics`);
|
|
691
|
+
if (res.status !== 200) return void 0;
|
|
692
|
+
const text = await res.text();
|
|
693
|
+
return parseMetrics(text);
|
|
694
|
+
} catch {
|
|
695
|
+
return void 0;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function parseMetrics(text) {
|
|
699
|
+
const requests = sumMetric(text, "cloudflared_tunnel_total_requests") || sumMetric(text, "cloudflared_tunnel_request_errors");
|
|
700
|
+
const durSum = sumMetric(text, "cloudflared_tunnel_request_duration_seconds_sum");
|
|
701
|
+
const durCount = sumMetric(text, "cloudflared_tunnel_request_duration_seconds_count");
|
|
702
|
+
const avgLatencyMs = durCount > 0 ? Math.round(durSum / durCount * 1e3) : void 0;
|
|
703
|
+
return {
|
|
704
|
+
connections: sumMetric(text, "cloudflared_tunnel_ha_connections"),
|
|
705
|
+
requests,
|
|
706
|
+
avgLatencyMs
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function sumMetric(text, name) {
|
|
710
|
+
let total = 0;
|
|
711
|
+
for (const line of text.split("\n")) {
|
|
712
|
+
if (line.startsWith("#") || !line.startsWith(name)) continue;
|
|
713
|
+
const value = Number(line.trim().split(/\s+/).pop());
|
|
714
|
+
if (!Number.isNaN(value)) total += value;
|
|
715
|
+
}
|
|
716
|
+
return total;
|
|
717
|
+
}
|
|
718
|
+
function sleep(ms) {
|
|
719
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/process/logs.ts
|
|
723
|
+
import pc from "picocolors";
|
|
724
|
+
var LEVEL_RE = /level=(\w+)/;
|
|
725
|
+
var MSG_RE = /msg="([^"]*)"|msg=(\S+)/;
|
|
726
|
+
function parseLogLine(line) {
|
|
727
|
+
const trimmed = line.trim();
|
|
728
|
+
if (!trimmed) return void 0;
|
|
729
|
+
const level = trimmed.match(LEVEL_RE)?.[1] ?? "info";
|
|
730
|
+
const msgMatch = trimmed.match(MSG_RE);
|
|
731
|
+
const message = msgMatch?.[1] ?? msgMatch?.[2] ?? trimmed;
|
|
732
|
+
return { level, message };
|
|
733
|
+
}
|
|
734
|
+
function formatLogLine(line) {
|
|
735
|
+
const parsed = parseLogLine(line);
|
|
736
|
+
if (!parsed) return void 0;
|
|
737
|
+
if (/Registered tunnel connection|Unregistered tunnel connection/.test(parsed.message)) {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
const tag = colorLevel(parsed.level);
|
|
741
|
+
return `${tag} ${parsed.message}`;
|
|
742
|
+
}
|
|
743
|
+
function colorLevel(level) {
|
|
744
|
+
switch (level.toLowerCase()) {
|
|
745
|
+
case "error":
|
|
746
|
+
case "fatal":
|
|
747
|
+
return pc.red("\u2716");
|
|
748
|
+
case "warn":
|
|
749
|
+
return pc.yellow("!");
|
|
750
|
+
case "debug":
|
|
751
|
+
return pc.dim("\xB7");
|
|
752
|
+
default:
|
|
753
|
+
return pc.dim("\u203A");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/commands/login.ts
|
|
758
|
+
import { spawn as spawn3 } from "child_process";
|
|
759
|
+
import { existsSync as existsSync8 } from "fs";
|
|
760
|
+
|
|
761
|
+
// src/ui/prompts.ts
|
|
762
|
+
async function clack() {
|
|
763
|
+
return import("@clack/prompts");
|
|
764
|
+
}
|
|
765
|
+
function ensure(value, p) {
|
|
766
|
+
if (p.isCancel(value)) throw new CfldError("Cancelled.");
|
|
767
|
+
return value;
|
|
768
|
+
}
|
|
769
|
+
async function askText(message, placeholder) {
|
|
770
|
+
const p = await clack();
|
|
771
|
+
const value = await p.text({ message, placeholder });
|
|
772
|
+
return ensure(value, p);
|
|
773
|
+
}
|
|
774
|
+
async function askSelect(message, options) {
|
|
775
|
+
const p = await clack();
|
|
776
|
+
const select = p.select;
|
|
777
|
+
const value = await select({ message, options });
|
|
778
|
+
return ensure(value, p);
|
|
779
|
+
}
|
|
780
|
+
async function askConfirm(message) {
|
|
781
|
+
const p = await clack();
|
|
782
|
+
const value = await p.confirm({ message });
|
|
783
|
+
return ensure(value, p);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/ui/output.ts
|
|
787
|
+
import boxen from "boxen";
|
|
788
|
+
import pc2 from "picocolors";
|
|
789
|
+
function isInteractive() {
|
|
790
|
+
return Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
791
|
+
}
|
|
792
|
+
function step(message) {
|
|
793
|
+
process.stderr.write(`${pc2.cyan("\u203A")} ${message}
|
|
794
|
+
`);
|
|
795
|
+
}
|
|
796
|
+
function done(message) {
|
|
797
|
+
process.stderr.write(`${pc2.green("\u2714")} ${message}
|
|
798
|
+
`);
|
|
799
|
+
}
|
|
800
|
+
function info(message) {
|
|
801
|
+
process.stderr.write(` ${message}
|
|
802
|
+
`);
|
|
803
|
+
}
|
|
804
|
+
function warn(message) {
|
|
805
|
+
process.stderr.write(`${pc2.yellow("!")} ${message}
|
|
806
|
+
`);
|
|
807
|
+
}
|
|
808
|
+
function note(message) {
|
|
809
|
+
process.stderr.write(`${pc2.dim(message)}
|
|
810
|
+
`);
|
|
811
|
+
}
|
|
812
|
+
function summaryBox(s) {
|
|
813
|
+
const footer = s.ephemeral ? `ephemeral \xB7 URL changes each run \xB7 run \`cfld\` for a persistent URL` : `URL preserved on Ctrl-C \xB7 re-run to resume \xB7 cfld destroy to remove`;
|
|
814
|
+
const lines = [
|
|
815
|
+
`${pc2.green("\u25CF LIVE")} ${pc2.bold(pc2.cyan(s.url))}`,
|
|
816
|
+
`${pc2.dim("\u2192")} ${s.target} ${pc2.dim(`${s.connections} edge conns`)}`,
|
|
817
|
+
``,
|
|
818
|
+
pc2.dim(`tunnel: ${s.tunnelName}`),
|
|
819
|
+
pc2.dim(footer)
|
|
820
|
+
];
|
|
821
|
+
process.stdout.write(
|
|
822
|
+
boxen(lines.join("\n"), {
|
|
823
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
824
|
+
borderColor: "cyan",
|
|
825
|
+
borderStyle: "round",
|
|
826
|
+
title: "cfld",
|
|
827
|
+
titleAlignment: "left"
|
|
828
|
+
}) + "\n"
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
function printError(err) {
|
|
832
|
+
if (err instanceof CfldError) {
|
|
833
|
+
process.stderr.write(`
|
|
834
|
+
${pc2.red("\u2716")} ${err.message}
|
|
835
|
+
`);
|
|
836
|
+
if (err.hint) process.stderr.write(`${pc2.dim(" " + err.hint)}
|
|
837
|
+
`);
|
|
838
|
+
} else if (err instanceof Error) {
|
|
839
|
+
process.stderr.write(`
|
|
840
|
+
${pc2.red("\u2716")} ${err.message}
|
|
841
|
+
`);
|
|
842
|
+
} else {
|
|
843
|
+
process.stderr.write(`
|
|
844
|
+
${pc2.red("\u2716")} ${String(err)}
|
|
845
|
+
`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/commands/login.ts
|
|
850
|
+
function normalizeZone(input) {
|
|
851
|
+
return input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
852
|
+
}
|
|
853
|
+
async function loginCommand(flags) {
|
|
854
|
+
const interactive = isInteractive();
|
|
855
|
+
const haveCert = certExists(defaultCertPath());
|
|
856
|
+
if (flags.reauth || !haveCert) {
|
|
857
|
+
await runLogin();
|
|
858
|
+
} else {
|
|
859
|
+
info("A Cloudflare certificate already exists. Use --reauth to authorize another domain.");
|
|
860
|
+
}
|
|
861
|
+
if (!certHasZone(defaultCertPath())) {
|
|
862
|
+
printZeroZoneGuidance();
|
|
863
|
+
throw new CfldError(
|
|
864
|
+
"Logged in, but your account has no domain to authorize.",
|
|
865
|
+
"Add a domain (see above), then run `cfld login --reauth`."
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
let zone = flags.zone ? normalizeZone(flags.zone) : void 0;
|
|
869
|
+
if (!zone) {
|
|
870
|
+
if (!interactive) {
|
|
871
|
+
throw new CfldError(
|
|
872
|
+
"Pass --zone to record which domain this cert is for.",
|
|
873
|
+
"e.g. `cfld login --zone example.com`"
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
zone = normalizeZone(await askText("Which domain did you authorize?", "example.com"));
|
|
877
|
+
}
|
|
878
|
+
importCert(zone);
|
|
879
|
+
done(`Saved cert for ${zone}. You're ready \u2014 run \`cfld\`.`);
|
|
880
|
+
}
|
|
881
|
+
async function runLogin() {
|
|
882
|
+
const bin = await resolveBinary();
|
|
883
|
+
step("Opening your browser to authorize with Cloudflare (no API token needed)\u2026");
|
|
884
|
+
note(" Pick the domain you want to use. Waiting for you to finish\u2026");
|
|
885
|
+
await new Promise((resolve, reject) => {
|
|
886
|
+
const child = spawn3(bin, ["tunnel", "login"], { stdio: "inherit" });
|
|
887
|
+
child.on("error", reject);
|
|
888
|
+
child.on("exit", (code) => {
|
|
889
|
+
if (code === 0 || existsSync8(defaultCertPath())) resolve();
|
|
890
|
+
else reject(new CfldError("Cloudflare login did not complete.", "Re-run `cfld login`."));
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
if (!existsSync8(defaultCertPath())) {
|
|
894
|
+
throw new CfldError(
|
|
895
|
+
"Login finished but no certificate was written.",
|
|
896
|
+
"Re-run `cfld login` and complete the browser step."
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async function ensureCert(zone, opts = { interactive: true }) {
|
|
901
|
+
const existing = resolveCert(zone);
|
|
902
|
+
if (existing) return existing;
|
|
903
|
+
if (!opts.interactive) {
|
|
904
|
+
throw new CfldError(
|
|
905
|
+
`No Cloudflare certificate found for ${zone}.`,
|
|
906
|
+
"Run `cfld login` first (needs a browser), or set TUNNEL_ORIGIN_CERT."
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
await runLogin();
|
|
910
|
+
if (!certHasZone(defaultCertPath())) {
|
|
911
|
+
printZeroZoneGuidance();
|
|
912
|
+
throw new CfldError(
|
|
913
|
+
"Your Cloudflare account has no domain yet, so a persistent URL isn't possible.",
|
|
914
|
+
"Add a domain (see above), then re-run. Or use `cfld --quick` for a throwaway URL now."
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const stored = importCert(zone);
|
|
918
|
+
info(`Saved cert for ${zone}.`);
|
|
919
|
+
return stored;
|
|
920
|
+
}
|
|
921
|
+
function printZeroZoneGuidance() {
|
|
922
|
+
warn("cfld can't create a permanent custom URL yet \u2014 your account has no domains.");
|
|
923
|
+
note(
|
|
924
|
+
[
|
|
925
|
+
"",
|
|
926
|
+
" Option 1 \u2014 Add a domain you own (~5 min + DNS propagation)",
|
|
927
|
+
" 1. Own a domain at any registrar.",
|
|
928
|
+
" 2. Cloudflare dashboard \u2192 Add a Site \u2192 enter the domain.",
|
|
929
|
+
" 3. At your registrar, switch the nameservers to the ones Cloudflare shows.",
|
|
930
|
+
" (we can't do this step for you \u2014 it's on the registrar)",
|
|
931
|
+
" 4. Wait for the zone to go Active, then: cfld login --reauth && cfld",
|
|
932
|
+
" https://dash.cloudflare.com/?to=/:account/add-site",
|
|
933
|
+
"",
|
|
934
|
+
" Option 2 \u2014 Buy through Cloudflare Registrar (nameservers auto-configured)",
|
|
935
|
+
" https://dash.cloudflare.com/?to=/:account/domains/register",
|
|
936
|
+
"",
|
|
937
|
+
" Option 3 \u2014 Just need a URL right now? (ephemeral, not persistent)",
|
|
938
|
+
" cfld --quick",
|
|
939
|
+
""
|
|
940
|
+
].join("\n")
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/util/gitignore.ts
|
|
945
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
946
|
+
import { join as join4 } from "path";
|
|
947
|
+
function ensureGitignored(cwd, pattern) {
|
|
948
|
+
const gitignorePath = join4(cwd, ".gitignore");
|
|
949
|
+
const hasGitignore = existsSync9(gitignorePath);
|
|
950
|
+
const inRepo = hasGitignore || existsSync9(join4(cwd, ".git"));
|
|
951
|
+
if (!inRepo) return false;
|
|
952
|
+
const existing = hasGitignore ? readFileSync6(gitignorePath, "utf8") : "";
|
|
953
|
+
const present = existing.split("\n").map((l) => l.trim()).includes(pattern);
|
|
954
|
+
if (present) return false;
|
|
955
|
+
const body = existing.replace(/\n$/, "");
|
|
956
|
+
const next = body.length ? `${body}
|
|
957
|
+
${pattern}
|
|
958
|
+
` : `${pattern}
|
|
959
|
+
`;
|
|
960
|
+
writeFileSync5(gitignorePath, next, "utf8");
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/commands/run.ts
|
|
965
|
+
async function runCommand(flags) {
|
|
966
|
+
const cwd = process.cwd();
|
|
967
|
+
const interactive = isInteractive();
|
|
968
|
+
const config = readProjectConfig(cwd);
|
|
969
|
+
const slug = deriveSlug(cwd, flags.name ?? config?.name);
|
|
970
|
+
const tunnelName = tunnelNameForSlug(slug);
|
|
971
|
+
const knownZones = Array.from(
|
|
972
|
+
/* @__PURE__ */ new Set([...listStoredZones(), ...listEntries().map((e) => e.zone)])
|
|
973
|
+
);
|
|
974
|
+
const zone = await resolveZone(flags, config?.zone, knownZones, interactive);
|
|
975
|
+
const envKey = flags.envKey ?? config?.envKey ?? "PUBLIC_URL";
|
|
976
|
+
let routes;
|
|
977
|
+
if (config?.routes?.length && !flags.host && !flags.port) {
|
|
978
|
+
routes = config.routes.map((r) => ({
|
|
979
|
+
hostname: r.host ?? buildHostname(r.name ? slugify(r.name) : slug, zone),
|
|
980
|
+
port: r.port,
|
|
981
|
+
path: r.path
|
|
982
|
+
}));
|
|
983
|
+
} else {
|
|
984
|
+
const port = await resolvePort(flags, config?.port, interactive);
|
|
985
|
+
const hostname = flags.host ?? config?.hostname ?? buildHostname(slug, zone);
|
|
986
|
+
routes = [{ hostname, port }];
|
|
987
|
+
}
|
|
988
|
+
const primary = routes[0];
|
|
989
|
+
const certPath = await ensureCert(zone, { interactive });
|
|
990
|
+
const result = await reconcile(
|
|
991
|
+
{ slug, tunnelName, zone, certPath, routes, projectDir: cwd, force: flags.force },
|
|
992
|
+
{
|
|
993
|
+
onStep: (s) => step(s),
|
|
994
|
+
confirmOverwrite: interactive ? (h, t) => askConfirm(`${h} already points to ${t}. Repoint it to this tunnel?`) : void 0
|
|
995
|
+
}
|
|
996
|
+
);
|
|
997
|
+
done(result.created ? `Created tunnel ${tunnelName}` : `Reusing tunnel ${tunnelName}`);
|
|
998
|
+
for (const r of routes) done(`DNS ${r.hostname} \u2192 :${r.port}`);
|
|
999
|
+
writeProjectConfig(cwd, {
|
|
1000
|
+
name: slug,
|
|
1001
|
+
zone,
|
|
1002
|
+
uuid: result.uuid,
|
|
1003
|
+
hostname: primary.hostname,
|
|
1004
|
+
port: primary.port,
|
|
1005
|
+
envKey
|
|
1006
|
+
});
|
|
1007
|
+
if (ensureGitignored(cwd, ".cfld.json")) info("Added .cfld.json to .gitignore.");
|
|
1008
|
+
await launch(result, { cwd, envKey, noEnv: flags.noEnv, port: primary.port });
|
|
1009
|
+
}
|
|
1010
|
+
async function launch(result, ctx) {
|
|
1011
|
+
const metricsPort = await findFreePort();
|
|
1012
|
+
const url = `https://${result.hostname}`;
|
|
1013
|
+
const target = `localhost:${ctx.port}`;
|
|
1014
|
+
const interactive = isInteractive();
|
|
1015
|
+
let dashboard;
|
|
1016
|
+
if (interactive) {
|
|
1017
|
+
const { startDashboard } = await import("./dashboard-QUPY25QT.js");
|
|
1018
|
+
dashboard = startDashboard({
|
|
1019
|
+
url,
|
|
1020
|
+
target,
|
|
1021
|
+
tunnelName: result.tunnelName,
|
|
1022
|
+
status: "connecting",
|
|
1023
|
+
connections: 0,
|
|
1024
|
+
requests: 0,
|
|
1025
|
+
logs: []
|
|
1026
|
+
});
|
|
1027
|
+
} else {
|
|
1028
|
+
step("Connecting to Cloudflare edge\u2026");
|
|
1029
|
+
}
|
|
1030
|
+
const emitLine = (line) => {
|
|
1031
|
+
const formatted = formatLogLine(line);
|
|
1032
|
+
if (!formatted) return;
|
|
1033
|
+
if (dashboard) dashboard.log(formatted);
|
|
1034
|
+
else process.stderr.write(formatted + "\n");
|
|
1035
|
+
};
|
|
1036
|
+
let stopping = false;
|
|
1037
|
+
const runner = await startRunner({
|
|
1038
|
+
configPath: result.configPath,
|
|
1039
|
+
tunnelName: result.tunnelName,
|
|
1040
|
+
certPath: result.certPath,
|
|
1041
|
+
metricsPort,
|
|
1042
|
+
onLine: (line) => emitLine(line),
|
|
1043
|
+
onStatus: (status) => {
|
|
1044
|
+
if (status === "reconnecting") dashboard?.update({ status: "reconnecting" });
|
|
1045
|
+
else if (status === "connecting" && !dashboard) note("Reconnecting\u2026");
|
|
1046
|
+
},
|
|
1047
|
+
onFatal: (message) => {
|
|
1048
|
+
dashboard?.stop();
|
|
1049
|
+
warn(message);
|
|
1050
|
+
process.exit(1);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
const stop = (reason) => {
|
|
1054
|
+
if (stopping) return;
|
|
1055
|
+
stopping = true;
|
|
1056
|
+
runner.stop();
|
|
1057
|
+
dashboard?.stop();
|
|
1058
|
+
note(`
|
|
1059
|
+
${reason}`);
|
|
1060
|
+
setTimeout(() => process.exit(0), 100).unref();
|
|
1061
|
+
};
|
|
1062
|
+
if (!dashboard) {
|
|
1063
|
+
process.once("SIGINT", () => stop("Stopping \u2014 your URL is preserved. Re-run `cfld`."));
|
|
1064
|
+
process.once("SIGTERM", () => stop("Stopping \u2014 your URL is preserved."));
|
|
1065
|
+
}
|
|
1066
|
+
const ready = await waitForReady(metricsPort).catch((err) => {
|
|
1067
|
+
runner.stop();
|
|
1068
|
+
dashboard?.stop();
|
|
1069
|
+
throw new CfldError(
|
|
1070
|
+
"The tunnel did not connect.",
|
|
1071
|
+
err instanceof Error ? err.message : void 0
|
|
1072
|
+
);
|
|
1073
|
+
});
|
|
1074
|
+
if (!ctx.noEnv) {
|
|
1075
|
+
const { changed } = upsertEnv(join5(ctx.cwd, ".env"), ctx.envKey, url);
|
|
1076
|
+
if (changed && !dashboard) info(`Wrote ${ctx.envKey}=${url} to .env`);
|
|
1077
|
+
}
|
|
1078
|
+
if (dashboard) {
|
|
1079
|
+
dashboard.update({ status: "live", connections: ready.connections });
|
|
1080
|
+
} else {
|
|
1081
|
+
summaryBox({ url, target, tunnelName: result.tunnelName, connections: ready.connections });
|
|
1082
|
+
}
|
|
1083
|
+
const poll = setInterval(async () => {
|
|
1084
|
+
const m = await scrapeMetrics(metricsPort);
|
|
1085
|
+
if (m && dashboard) {
|
|
1086
|
+
dashboard.update({
|
|
1087
|
+
connections: m.connections || ready.connections,
|
|
1088
|
+
requests: m.requests,
|
|
1089
|
+
avgLatencyMs: m.avgLatencyMs
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}, dashboard ? 1e3 : 15e3);
|
|
1093
|
+
poll.unref();
|
|
1094
|
+
if (dashboard) {
|
|
1095
|
+
await dashboard.waitUntilExit();
|
|
1096
|
+
clearInterval(poll);
|
|
1097
|
+
stop("Stopping \u2014 your URL is preserved. Re-run `cfld` to resume.");
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async function resolveZone(flags, configZone, knownZones, interactive) {
|
|
1101
|
+
const fromHost = flags.host ? zoneForHostname(flags.host, knownZones) : void 0;
|
|
1102
|
+
const zone = flags.zone ?? fromHost ?? configZone ?? (knownZones.length === 1 ? knownZones[0] : void 0);
|
|
1103
|
+
if (zone) return zone;
|
|
1104
|
+
if (!interactive) {
|
|
1105
|
+
throw new CfldError(
|
|
1106
|
+
"Could not determine which Cloudflare domain to use.",
|
|
1107
|
+
"Pass --zone example.com (or --host app.example.com)."
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
const answer = await askText(
|
|
1111
|
+
"Which domain on Cloudflare should cfld use?",
|
|
1112
|
+
"example.com"
|
|
1113
|
+
);
|
|
1114
|
+
return answer.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
1115
|
+
}
|
|
1116
|
+
async function resolvePort(flags, configPort, interactive) {
|
|
1117
|
+
if (flags.port) return flags.port;
|
|
1118
|
+
if (configPort) return configPort;
|
|
1119
|
+
const detected = await detectDevPorts();
|
|
1120
|
+
if (detected.length === 1) {
|
|
1121
|
+
info(`Detected a local server on port ${detected[0]}.`);
|
|
1122
|
+
return detected[0];
|
|
1123
|
+
}
|
|
1124
|
+
if (detected.length > 1 && interactive) {
|
|
1125
|
+
return askSelect(
|
|
1126
|
+
"Multiple local servers detected \u2014 which port?",
|
|
1127
|
+
detected.map((p) => ({ value: p, label: String(p) }))
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
if (interactive) {
|
|
1131
|
+
const answer = await askText("Which local port is your dev server on?", "3000");
|
|
1132
|
+
const n = Number(answer.trim());
|
|
1133
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
1134
|
+
throw new CfldError(`"${answer}" is not a valid port.`);
|
|
1135
|
+
}
|
|
1136
|
+
return n;
|
|
1137
|
+
}
|
|
1138
|
+
throw new CfldError(
|
|
1139
|
+
"No local port specified and none could be detected.",
|
|
1140
|
+
"Pass a port, e.g. `cfld 3000`."
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/process/quick.ts
|
|
1145
|
+
import { spawn as spawn4 } from "child_process";
|
|
1146
|
+
var QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
1147
|
+
async function startQuickTunnel(port, opts = {}) {
|
|
1148
|
+
const bin = await resolveBinary();
|
|
1149
|
+
const args = ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"];
|
|
1150
|
+
if (opts.metricsPort) args.push("--metrics", `127.0.0.1:${opts.metricsPort}`);
|
|
1151
|
+
const child = spawn4(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1152
|
+
return new Promise((resolve, reject) => {
|
|
1153
|
+
const timeout = setTimeout(() => {
|
|
1154
|
+
child.kill("SIGKILL");
|
|
1155
|
+
reject(new Error("Timed out waiting for a quick tunnel URL."));
|
|
1156
|
+
}, opts.timeoutMs ?? 3e4);
|
|
1157
|
+
timeout.unref();
|
|
1158
|
+
let settled = false;
|
|
1159
|
+
const stop = () => {
|
|
1160
|
+
if (child.exitCode === null && !child.signalCode) child.kill("SIGINT");
|
|
1161
|
+
};
|
|
1162
|
+
const scan = (chunk) => {
|
|
1163
|
+
for (const line of chunk.split("\n")) {
|
|
1164
|
+
if (line.trim()) opts.onLine?.(line);
|
|
1165
|
+
}
|
|
1166
|
+
const match = chunk.match(QUICK_URL_RE);
|
|
1167
|
+
if (match && !settled) {
|
|
1168
|
+
settled = true;
|
|
1169
|
+
clearTimeout(timeout);
|
|
1170
|
+
resolve({ url: match[0], child, stop });
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
child.stdout?.setEncoding("utf8").on("data", scan);
|
|
1174
|
+
child.stderr?.setEncoding("utf8").on("data", scan);
|
|
1175
|
+
child.on("error", (err) => {
|
|
1176
|
+
if (!settled) {
|
|
1177
|
+
settled = true;
|
|
1178
|
+
clearTimeout(timeout);
|
|
1179
|
+
reject(err);
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
child.on("exit", (code) => {
|
|
1183
|
+
if (!settled) {
|
|
1184
|
+
settled = true;
|
|
1185
|
+
clearTimeout(timeout);
|
|
1186
|
+
reject(new Error(`cloudflared exited (code ${code}) before a URL appeared.`));
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// src/commands/quick.ts
|
|
1193
|
+
async function quickCommand(flags) {
|
|
1194
|
+
const port = await resolvePort2(flags);
|
|
1195
|
+
const target = `localhost:${port}`;
|
|
1196
|
+
const interactive = isInteractive();
|
|
1197
|
+
const metricsPort = await findFreePort();
|
|
1198
|
+
if (!interactive) step(`Starting an ephemeral tunnel to ${target}\u2026`);
|
|
1199
|
+
let dashboard;
|
|
1200
|
+
const logBuffer = [];
|
|
1201
|
+
const emitLine = (line) => {
|
|
1202
|
+
const formatted = formatLogLine(line);
|
|
1203
|
+
if (!formatted) return;
|
|
1204
|
+
logBuffer.push(formatted);
|
|
1205
|
+
if (dashboard) dashboard.log(formatted);
|
|
1206
|
+
else if (!interactive) process.stderr.write(formatted + "\n");
|
|
1207
|
+
};
|
|
1208
|
+
const tunnel = await startQuickTunnel(port, { metricsPort, onLine: emitLine });
|
|
1209
|
+
if (interactive) {
|
|
1210
|
+
const { startDashboard } = await import("./dashboard-QUPY25QT.js");
|
|
1211
|
+
dashboard = startDashboard({
|
|
1212
|
+
url: tunnel.url,
|
|
1213
|
+
target,
|
|
1214
|
+
tunnelName: "quick (ephemeral)",
|
|
1215
|
+
status: "live",
|
|
1216
|
+
connections: 1,
|
|
1217
|
+
requests: 0,
|
|
1218
|
+
logs: logBuffer.slice(-12),
|
|
1219
|
+
ephemeral: true
|
|
1220
|
+
});
|
|
1221
|
+
} else {
|
|
1222
|
+
summaryBox({
|
|
1223
|
+
url: tunnel.url,
|
|
1224
|
+
target,
|
|
1225
|
+
tunnelName: "quick (ephemeral)",
|
|
1226
|
+
connections: 1,
|
|
1227
|
+
ephemeral: true
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
const poll = setInterval(async () => {
|
|
1231
|
+
const m = await scrapeMetrics(metricsPort);
|
|
1232
|
+
if (m && dashboard) {
|
|
1233
|
+
dashboard.update({
|
|
1234
|
+
connections: m.connections || 1,
|
|
1235
|
+
requests: m.requests,
|
|
1236
|
+
avgLatencyMs: m.avgLatencyMs
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}, dashboard ? 1e3 : 15e3);
|
|
1240
|
+
poll.unref();
|
|
1241
|
+
const stop = () => {
|
|
1242
|
+
clearInterval(poll);
|
|
1243
|
+
tunnel.stop();
|
|
1244
|
+
dashboard?.stop();
|
|
1245
|
+
note("\nStopped ephemeral tunnel.");
|
|
1246
|
+
setTimeout(() => process.exit(0), 100).unref();
|
|
1247
|
+
};
|
|
1248
|
+
if (dashboard) {
|
|
1249
|
+
await dashboard.waitUntilExit();
|
|
1250
|
+
stop();
|
|
1251
|
+
} else {
|
|
1252
|
+
process.once("SIGINT", stop);
|
|
1253
|
+
process.once("SIGTERM", stop);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async function resolvePort2(flags) {
|
|
1257
|
+
if (flags.port) return flags.port;
|
|
1258
|
+
const detected = await detectDevPorts();
|
|
1259
|
+
if (detected.length >= 1) {
|
|
1260
|
+
info(`Detected a local server on port ${detected[0]}.`);
|
|
1261
|
+
return detected[0];
|
|
1262
|
+
}
|
|
1263
|
+
throw new CfldError(
|
|
1264
|
+
"No port specified and none detected.",
|
|
1265
|
+
"Pass a port, e.g. `cfld --quick 3000`."
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// src/commands/init.ts
|
|
1270
|
+
async function initCommand(flags) {
|
|
1271
|
+
if (!isInteractive()) {
|
|
1272
|
+
throw new CfldError(
|
|
1273
|
+
"`cfld init` is interactive.",
|
|
1274
|
+
"In scripts, run `cfld` directly with --zone/--host/--name flags."
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
const cwd = process.cwd();
|
|
1278
|
+
const knownZones = Array.from(
|
|
1279
|
+
/* @__PURE__ */ new Set([...listStoredZones(), ...listEntries().map((e) => e.zone)])
|
|
1280
|
+
);
|
|
1281
|
+
let zone;
|
|
1282
|
+
if (knownZones.length) {
|
|
1283
|
+
const picked = await askSelect("Which Cloudflare domain?", [
|
|
1284
|
+
...knownZones.map((z) => ({ value: z, label: z })),
|
|
1285
|
+
{ value: "__new__", label: "Use a different domain\u2026" }
|
|
1286
|
+
]);
|
|
1287
|
+
zone = picked === "__new__" ? normalizeZone(await askText("Domain (zone) on Cloudflare?", "example.com")) : picked;
|
|
1288
|
+
} else {
|
|
1289
|
+
zone = normalizeZone(await askText("Domain (zone) on Cloudflare?", "example.com"));
|
|
1290
|
+
}
|
|
1291
|
+
const certPath = await ensureCert(zone, { interactive: true });
|
|
1292
|
+
const defaultSlug = deriveSlug(cwd, flags.name);
|
|
1293
|
+
const name = slugify(await askText("Project name (used in the URL)", defaultSlug) || defaultSlug);
|
|
1294
|
+
const detected = await detectDevPorts();
|
|
1295
|
+
const portStr = await askText(
|
|
1296
|
+
"Local port your dev server runs on",
|
|
1297
|
+
detected[0] ? String(detected[0]) : "3000"
|
|
1298
|
+
);
|
|
1299
|
+
const port = Number(portStr.trim());
|
|
1300
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
1301
|
+
throw new CfldError(`"${portStr}" is not a valid port.`);
|
|
1302
|
+
}
|
|
1303
|
+
const hostname = flags.host ?? buildHostname(name, zone);
|
|
1304
|
+
const result = await reconcile(
|
|
1305
|
+
{
|
|
1306
|
+
slug: name,
|
|
1307
|
+
tunnelName: tunnelNameForSlug(name),
|
|
1308
|
+
zone,
|
|
1309
|
+
certPath,
|
|
1310
|
+
routes: [{ hostname, port }],
|
|
1311
|
+
projectDir: cwd,
|
|
1312
|
+
force: flags.force
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
onStep: (s) => step(s),
|
|
1316
|
+
confirmOverwrite: (h, t) => askConfirm(`${h} already points to ${t}. Repoint it to this tunnel?`)
|
|
1317
|
+
}
|
|
1318
|
+
);
|
|
1319
|
+
writeProjectConfig(cwd, {
|
|
1320
|
+
name,
|
|
1321
|
+
zone,
|
|
1322
|
+
uuid: result.uuid,
|
|
1323
|
+
hostname,
|
|
1324
|
+
port,
|
|
1325
|
+
envKey: flags.envKey ?? "PUBLIC_URL"
|
|
1326
|
+
});
|
|
1327
|
+
if (ensureGitignored(cwd, ".cfld.json")) info("Added .cfld.json to .gitignore.");
|
|
1328
|
+
done(`Configured https://${hostname} \u2192 localhost:${port}`);
|
|
1329
|
+
note("Run `cfld` to go live.");
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// src/commands/list.ts
|
|
1333
|
+
import pc3 from "picocolors";
|
|
1334
|
+
async function listCommand() {
|
|
1335
|
+
const entries = listEntries();
|
|
1336
|
+
if (entries.length === 0) {
|
|
1337
|
+
note("No tunnels registered yet. Run `cfld` in a project to create one.");
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const liveByZone = /* @__PURE__ */ new Map();
|
|
1341
|
+
for (const zone of new Set(entries.map((e) => e.zone))) {
|
|
1342
|
+
const cert = resolveCert(zone);
|
|
1343
|
+
if (!cert) continue;
|
|
1344
|
+
try {
|
|
1345
|
+
const tunnels = await listTunnels(cert);
|
|
1346
|
+
liveByZone.set(zone, new Set(tunnels.map((t) => t.id)));
|
|
1347
|
+
} catch {
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
process.stdout.write(pc3.bold("\n cfld tunnels\n\n"));
|
|
1351
|
+
for (const e of entries) {
|
|
1352
|
+
const live = liveByZone.get(e.zone);
|
|
1353
|
+
const status = live === void 0 ? pc3.dim("?") : live.has(e.uuid) ? pc3.green("\u25CF") : pc3.red("\u2716 missing");
|
|
1354
|
+
process.stdout.write(
|
|
1355
|
+
` ${status} ${pc3.cyan(`https://${e.hostname}`)} ${pc3.dim(
|
|
1356
|
+
`\u2192 :${e.port}`
|
|
1357
|
+
)} ${pc3.dim(e.projectDir)}
|
|
1358
|
+
`
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
process.stdout.write("\n");
|
|
1362
|
+
info(pc3.dim("\u25CF live \xB7 \u2716 deleted remotely (re-run to recreate) \xB7 ? unknown"));
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// src/commands/status.ts
|
|
1366
|
+
import pc4 from "picocolors";
|
|
1367
|
+
async function statusCommand(name) {
|
|
1368
|
+
const entry = name ? getEntry(name) ?? getEntry(`cfld-${name}`) : resolveCurrentEntry();
|
|
1369
|
+
if (!entry) {
|
|
1370
|
+
if (name) {
|
|
1371
|
+
throw new CfldError(
|
|
1372
|
+
`No registered tunnel named "${name}".`,
|
|
1373
|
+
"Run `cfld list` to see all tunnels."
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
note("No cfld tunnel configured in this directory. Run `cfld` to set one up.");
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const cert = resolveCert(entry.zone);
|
|
1380
|
+
let liveState = pc4.dim("unknown (no cert)");
|
|
1381
|
+
if (cert) {
|
|
1382
|
+
try {
|
|
1383
|
+
const found = await findTunnel(entry.name, cert);
|
|
1384
|
+
liveState = found ? pc4.green("exists") : pc4.red("deleted remotely");
|
|
1385
|
+
} catch {
|
|
1386
|
+
liveState = pc4.dim("unreachable");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
const rows = [
|
|
1390
|
+
["URL", pc4.cyan(`https://${entry.hostname}`)],
|
|
1391
|
+
["Local target", `localhost:${entry.port}`],
|
|
1392
|
+
["Tunnel", `${entry.name} (${entry.uuid})`],
|
|
1393
|
+
["Zone", entry.zone],
|
|
1394
|
+
["Cert", cert ?? pc4.red("missing")],
|
|
1395
|
+
["Remote state", liveState],
|
|
1396
|
+
["Project", entry.projectDir],
|
|
1397
|
+
["Last run", entry.lastRun ?? "never"]
|
|
1398
|
+
];
|
|
1399
|
+
process.stdout.write("\n");
|
|
1400
|
+
for (const [k, v] of rows) {
|
|
1401
|
+
process.stdout.write(` ${pc4.dim(k.padEnd(14))} ${v}
|
|
1402
|
+
`);
|
|
1403
|
+
}
|
|
1404
|
+
process.stdout.write("\n");
|
|
1405
|
+
}
|
|
1406
|
+
function resolveCurrentEntry() {
|
|
1407
|
+
const config = readProjectConfig(process.cwd());
|
|
1408
|
+
if (config?.name) {
|
|
1409
|
+
const entry = getEntry(`cfld-${config.name}`);
|
|
1410
|
+
if (entry) return entry;
|
|
1411
|
+
}
|
|
1412
|
+
return listEntries().find((e) => e.projectDir === process.cwd());
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/commands/up.ts
|
|
1416
|
+
async function upCommand(name) {
|
|
1417
|
+
const entry = getEntry(name) ?? getEntry(`cfld-${name}`);
|
|
1418
|
+
if (!entry) {
|
|
1419
|
+
throw new CfldError(
|
|
1420
|
+
`No registered tunnel named "${name}".`,
|
|
1421
|
+
"Run `cfld list` to see available tunnels."
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
const cert = resolveCert(entry.zone);
|
|
1425
|
+
if (!cert) {
|
|
1426
|
+
throw new CfldError(
|
|
1427
|
+
`No certificate available for zone ${entry.zone}.`,
|
|
1428
|
+
"Run `cfld login` to authorize it."
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
const slug = entry.name.replace(/^cfld-/, "");
|
|
1432
|
+
const routes = entry.routes?.length ? entry.routes : [{ hostname: entry.hostname, port: entry.port }];
|
|
1433
|
+
const result = await reconcile(
|
|
1434
|
+
{
|
|
1435
|
+
slug,
|
|
1436
|
+
tunnelName: entry.name,
|
|
1437
|
+
zone: entry.zone,
|
|
1438
|
+
certPath: cert,
|
|
1439
|
+
routes,
|
|
1440
|
+
projectDir: entry.projectDir
|
|
1441
|
+
},
|
|
1442
|
+
{ onStep: (s) => step(s) }
|
|
1443
|
+
);
|
|
1444
|
+
done(result.created ? `Created tunnel ${entry.name}` : `Reusing tunnel ${entry.name}`);
|
|
1445
|
+
await launch(result, {
|
|
1446
|
+
cwd: entry.projectDir,
|
|
1447
|
+
envKey: "PUBLIC_URL",
|
|
1448
|
+
noEnv: true,
|
|
1449
|
+
// don't touch a .env we may not be sitting in
|
|
1450
|
+
port: entry.port
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/commands/destroy.ts
|
|
1455
|
+
async function destroyCommand(name, force) {
|
|
1456
|
+
const entry = name ? getEntry(name) ?? getEntry(`cfld-${name}`) : resolveCurrentEntry2();
|
|
1457
|
+
if (!entry) {
|
|
1458
|
+
throw new CfldError(
|
|
1459
|
+
name ? `No registered tunnel named "${name}".` : "No cfld tunnel found here.",
|
|
1460
|
+
"Run `cfld list` to see all tunnels."
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
if (!force) {
|
|
1464
|
+
if (!isInteractive()) {
|
|
1465
|
+
throw new CfldError(
|
|
1466
|
+
"Refusing to destroy without confirmation in a non-interactive shell.",
|
|
1467
|
+
"Re-run with --force to skip the prompt."
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
const ok = await askConfirm(
|
|
1471
|
+
`Delete tunnel ${entry.name} (https://${entry.hostname})? This is permanent.`
|
|
1472
|
+
);
|
|
1473
|
+
if (!ok) {
|
|
1474
|
+
note("Aborted.");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const cert = resolveCert(entry.zone);
|
|
1479
|
+
if (cert) {
|
|
1480
|
+
await deleteTunnel(entry.name, cert);
|
|
1481
|
+
done(`Deleted tunnel ${entry.name}`);
|
|
1482
|
+
} else {
|
|
1483
|
+
warn(`No cert for ${entry.zone}; skipped remote delete. Remove it in the dashboard.`);
|
|
1484
|
+
}
|
|
1485
|
+
removeEntry(entry.name);
|
|
1486
|
+
done("Removed local registry entry");
|
|
1487
|
+
warn(
|
|
1488
|
+
`The DNS record ${entry.hostname} may still exist \u2014 remove it in the Cloudflare dashboard, or it will be repointed automatically next time you run cfld for this project.`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
function resolveCurrentEntry2() {
|
|
1492
|
+
const config = readProjectConfig(process.cwd());
|
|
1493
|
+
if (config?.name) {
|
|
1494
|
+
const entry = getEntry(`cfld-${config.name}`);
|
|
1495
|
+
if (entry) return entry;
|
|
1496
|
+
}
|
|
1497
|
+
return listEntries().find((e) => e.projectDir === process.cwd());
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/commands/service.ts
|
|
1501
|
+
import pc5 from "picocolors";
|
|
1502
|
+
|
|
1503
|
+
// src/core/service.ts
|
|
1504
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync3, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1505
|
+
import { homedir as homedir2, platform } from "os";
|
|
1506
|
+
import { join as join6 } from "path";
|
|
1507
|
+
function serviceSlug(name) {
|
|
1508
|
+
return name.replace(/^cfld-/, "");
|
|
1509
|
+
}
|
|
1510
|
+
function launchAgentLabel(name) {
|
|
1511
|
+
return `com.cfld.${serviceSlug(name)}`;
|
|
1512
|
+
}
|
|
1513
|
+
function launchAgentPath(name) {
|
|
1514
|
+
return join6(homedir2(), "Library", "LaunchAgents", `${launchAgentLabel(name)}.plist`);
|
|
1515
|
+
}
|
|
1516
|
+
function systemdUnitName(name) {
|
|
1517
|
+
return `cfld-${serviceSlug(name)}.service`;
|
|
1518
|
+
}
|
|
1519
|
+
function systemdUnitPath(name) {
|
|
1520
|
+
return join6(homedir2(), ".config", "systemd", "user", systemdUnitName(name));
|
|
1521
|
+
}
|
|
1522
|
+
function logDir() {
|
|
1523
|
+
return join6(cfldDir(), "logs");
|
|
1524
|
+
}
|
|
1525
|
+
function renderLaunchAgent(spec) {
|
|
1526
|
+
const args = [
|
|
1527
|
+
spec.binPath,
|
|
1528
|
+
"tunnel",
|
|
1529
|
+
"--origincert",
|
|
1530
|
+
spec.certPath,
|
|
1531
|
+
"--config",
|
|
1532
|
+
spec.configPath,
|
|
1533
|
+
"run",
|
|
1534
|
+
spec.name
|
|
1535
|
+
];
|
|
1536
|
+
const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
1537
|
+
const out = join6(logDir(), `${serviceSlug(spec.name)}.out.log`);
|
|
1538
|
+
const err = join6(logDir(), `${serviceSlug(spec.name)}.err.log`);
|
|
1539
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1540
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1541
|
+
<plist version="1.0">
|
|
1542
|
+
<dict>
|
|
1543
|
+
<key>Label</key>
|
|
1544
|
+
<string>${launchAgentLabel(spec.name)}</string>
|
|
1545
|
+
<key>ProgramArguments</key>
|
|
1546
|
+
<array>
|
|
1547
|
+
${argsXml}
|
|
1548
|
+
</array>
|
|
1549
|
+
<key>EnvironmentVariables</key>
|
|
1550
|
+
<dict>
|
|
1551
|
+
<key>TUNNEL_ORIGIN_CERT</key>
|
|
1552
|
+
<string>${escapeXml(spec.certPath)}</string>
|
|
1553
|
+
</dict>
|
|
1554
|
+
<key>RunAtLoad</key>
|
|
1555
|
+
<true/>
|
|
1556
|
+
<key>KeepAlive</key>
|
|
1557
|
+
<true/>
|
|
1558
|
+
<key>StandardOutPath</key>
|
|
1559
|
+
<string>${escapeXml(out)}</string>
|
|
1560
|
+
<key>StandardErrorPath</key>
|
|
1561
|
+
<string>${escapeXml(err)}</string>
|
|
1562
|
+
</dict>
|
|
1563
|
+
</plist>
|
|
1564
|
+
`;
|
|
1565
|
+
}
|
|
1566
|
+
function renderSystemdUnit(spec) {
|
|
1567
|
+
const execStart = [
|
|
1568
|
+
spec.binPath,
|
|
1569
|
+
"tunnel",
|
|
1570
|
+
"--origincert",
|
|
1571
|
+
spec.certPath,
|
|
1572
|
+
"--config",
|
|
1573
|
+
spec.configPath,
|
|
1574
|
+
"run",
|
|
1575
|
+
spec.name
|
|
1576
|
+
].join(" ");
|
|
1577
|
+
return `[Unit]
|
|
1578
|
+
Description=cfld tunnel ${spec.name}
|
|
1579
|
+
After=network-online.target
|
|
1580
|
+
Wants=network-online.target
|
|
1581
|
+
|
|
1582
|
+
[Service]
|
|
1583
|
+
Type=simple
|
|
1584
|
+
Environment=TUNNEL_ORIGIN_CERT=${spec.certPath}
|
|
1585
|
+
ExecStart=${execStart}
|
|
1586
|
+
Restart=always
|
|
1587
|
+
RestartSec=5
|
|
1588
|
+
|
|
1589
|
+
[Install]
|
|
1590
|
+
WantedBy=default.target
|
|
1591
|
+
`;
|
|
1592
|
+
}
|
|
1593
|
+
function assertSupported() {
|
|
1594
|
+
const p = platform();
|
|
1595
|
+
if (p === "darwin" || p === "linux") return p;
|
|
1596
|
+
throw new CfldError(
|
|
1597
|
+
`Service install is not supported on ${p}.`,
|
|
1598
|
+
"Run `cfld` in the foreground, or use a process manager of your choice."
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
async function installService(spec) {
|
|
1602
|
+
const p = assertSupported();
|
|
1603
|
+
mkdirSync3(logDir(), { recursive: true });
|
|
1604
|
+
if (p === "darwin") {
|
|
1605
|
+
const path = launchAgentPath(spec.name);
|
|
1606
|
+
mkdirSync3(join6(homedir2(), "Library", "LaunchAgents"), { recursive: true });
|
|
1607
|
+
writeFileSync6(path, renderLaunchAgent(spec), "utf8");
|
|
1608
|
+
await exec("launchctl", ["unload", path]);
|
|
1609
|
+
const res = await exec("launchctl", ["load", "-w", path]);
|
|
1610
|
+
if (res.code !== 0) {
|
|
1611
|
+
throw new CfldError(
|
|
1612
|
+
"Failed to load the LaunchAgent.",
|
|
1613
|
+
res.stderr.trim() || void 0
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
} else {
|
|
1617
|
+
const path = systemdUnitPath(spec.name);
|
|
1618
|
+
mkdirSync3(join6(homedir2(), ".config", "systemd", "user"), { recursive: true });
|
|
1619
|
+
writeFileSync6(path, renderSystemdUnit(spec), "utf8");
|
|
1620
|
+
await exec("systemctl", ["--user", "daemon-reload"]);
|
|
1621
|
+
const res = await exec("systemctl", [
|
|
1622
|
+
"--user",
|
|
1623
|
+
"enable",
|
|
1624
|
+
"--now",
|
|
1625
|
+
systemdUnitName(spec.name)
|
|
1626
|
+
]);
|
|
1627
|
+
if (res.code !== 0) {
|
|
1628
|
+
throw new CfldError(
|
|
1629
|
+
"Failed to enable the systemd user service.",
|
|
1630
|
+
res.stderr.trim() || "You may need `loginctl enable-linger $USER` so it runs without an active login."
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
async function uninstallService(name) {
|
|
1636
|
+
const p = assertSupported();
|
|
1637
|
+
if (p === "darwin") {
|
|
1638
|
+
const path = launchAgentPath(name);
|
|
1639
|
+
if (existsSync10(path)) {
|
|
1640
|
+
await exec("launchctl", ["unload", "-w", path]);
|
|
1641
|
+
rmSync(path, { force: true });
|
|
1642
|
+
}
|
|
1643
|
+
} else {
|
|
1644
|
+
const unit = systemdUnitName(name);
|
|
1645
|
+
await exec("systemctl", ["--user", "disable", "--now", unit]);
|
|
1646
|
+
const path = systemdUnitPath(name);
|
|
1647
|
+
if (existsSync10(path)) rmSync(path, { force: true });
|
|
1648
|
+
await exec("systemctl", ["--user", "daemon-reload"]);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async function serviceControl(name, action) {
|
|
1652
|
+
const p = assertSupported();
|
|
1653
|
+
if (p === "darwin") {
|
|
1654
|
+
await exec("launchctl", [action, launchAgentLabel(name)]);
|
|
1655
|
+
} else {
|
|
1656
|
+
await exec("systemctl", ["--user", action, systemdUnitName(name)]);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async function serviceState(name) {
|
|
1660
|
+
const p = assertSupported();
|
|
1661
|
+
if (p === "darwin") {
|
|
1662
|
+
if (!existsSync10(launchAgentPath(name))) return "not-installed";
|
|
1663
|
+
const res = await exec("launchctl", ["list", launchAgentLabel(name)]);
|
|
1664
|
+
if (res.code !== 0) return "installed";
|
|
1665
|
+
return /"PID"\s*=\s*\d+/.test(res.stdout) ? "running" : "installed";
|
|
1666
|
+
} else {
|
|
1667
|
+
if (!existsSync10(systemdUnitPath(name))) return "not-installed";
|
|
1668
|
+
const res = await exec("systemctl", ["--user", "is-active", systemdUnitName(name)]);
|
|
1669
|
+
return res.stdout.trim() === "active" ? "running" : "installed";
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
async function buildSpec(name, certPath, configPath) {
|
|
1673
|
+
return { name, certPath, configPath, binPath: await resolveBinaryAbsolute() };
|
|
1674
|
+
}
|
|
1675
|
+
function escapeXml(s) {
|
|
1676
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// src/commands/resolve-entry.ts
|
|
1680
|
+
function resolveEntry(name) {
|
|
1681
|
+
if (name) return getEntry(name) ?? getEntry(`cfld-${name}`);
|
|
1682
|
+
const config = readProjectConfig(process.cwd());
|
|
1683
|
+
if (config?.name) {
|
|
1684
|
+
const entry = getEntry(`cfld-${config.name}`);
|
|
1685
|
+
if (entry) return entry;
|
|
1686
|
+
}
|
|
1687
|
+
return listEntries().find((e) => e.projectDir === process.cwd());
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// src/commands/service.ts
|
|
1691
|
+
var ACTIONS = ["install", "uninstall", "start", "stop", "status"];
|
|
1692
|
+
async function serviceCommand(action, name) {
|
|
1693
|
+
if (!action || !ACTIONS.includes(action)) {
|
|
1694
|
+
throw new CfldError(
|
|
1695
|
+
`Usage: cfld service <${ACTIONS.join("|")}> [name]`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
const entry = resolveEntry(name);
|
|
1699
|
+
if (!entry) {
|
|
1700
|
+
throw new CfldError(
|
|
1701
|
+
name ? `No registered tunnel named "${name}".` : "No cfld tunnel found here.",
|
|
1702
|
+
"Run `cfld` or `cfld init` for this project first, then install the service."
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
const slug = serviceSlug(entry.name);
|
|
1706
|
+
switch (action) {
|
|
1707
|
+
case "install": {
|
|
1708
|
+
writeIngressConfig({
|
|
1709
|
+
slug,
|
|
1710
|
+
uuid: entry.uuid,
|
|
1711
|
+
routes: entry.routes?.length ? entry.routes : [{ hostname: entry.hostname, port: entry.port }]
|
|
1712
|
+
});
|
|
1713
|
+
const spec = await buildSpec(entry.name, entry.certPath, ingressConfigPath(slug));
|
|
1714
|
+
await installService(spec);
|
|
1715
|
+
done(`Installed always-on service for ${entry.name}.`);
|
|
1716
|
+
note(
|
|
1717
|
+
`https://${entry.hostname} now stays up across logout/reboot. Check with \`cfld service status\`, remove with \`cfld service uninstall\`.`
|
|
1718
|
+
);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
case "uninstall":
|
|
1722
|
+
await uninstallService(entry.name);
|
|
1723
|
+
done(`Removed service for ${entry.name}. (Tunnel + DNS are preserved.)`);
|
|
1724
|
+
return;
|
|
1725
|
+
case "start":
|
|
1726
|
+
await serviceControl(entry.name, "start");
|
|
1727
|
+
done(`Started ${entry.name}.`);
|
|
1728
|
+
return;
|
|
1729
|
+
case "stop":
|
|
1730
|
+
await serviceControl(entry.name, "stop");
|
|
1731
|
+
done(`Stopped ${entry.name}. (Tunnel + DNS are preserved.)`);
|
|
1732
|
+
return;
|
|
1733
|
+
case "status": {
|
|
1734
|
+
const state = await serviceState(entry.name);
|
|
1735
|
+
const label = state === "running" ? pc5.green("\u25CF running") : state === "installed" ? pc5.yellow("\u25CC installed (stopped)") : pc5.dim("\xB7 not installed");
|
|
1736
|
+
info(`${entry.name} ${label} ${pc5.dim(`https://${entry.hostname}`)}`);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/commands/doctor.ts
|
|
1743
|
+
import pc6 from "picocolors";
|
|
1744
|
+
async function doctorCommand() {
|
|
1745
|
+
const ok = pc6.green("\u2714");
|
|
1746
|
+
const bad = pc6.red("\u2716");
|
|
1747
|
+
const out = (s) => process.stdout.write(s + "\n");
|
|
1748
|
+
out(pc6.bold("\n cfld doctor\n"));
|
|
1749
|
+
try {
|
|
1750
|
+
const bin = await resolveBinary();
|
|
1751
|
+
const res = await exec(bin, ["--version"]);
|
|
1752
|
+
out(` ${ok} cloudflared: ${res.stdout.trim() || bin}`);
|
|
1753
|
+
} catch {
|
|
1754
|
+
out(` ${bad} cloudflared not found (install with \`brew install cloudflared\`)`);
|
|
1755
|
+
}
|
|
1756
|
+
const defCert = defaultCertPath();
|
|
1757
|
+
if (certExists(defCert)) {
|
|
1758
|
+
out(
|
|
1759
|
+
` ${ok} default cert: ${defCert} ${certHasZone(defCert) ? pc6.dim("(zone-scoped)") : pc6.yellow("(no zone!)")}`
|
|
1760
|
+
);
|
|
1761
|
+
} else {
|
|
1762
|
+
out(` ${pc6.dim("\xB7")} no default cert (run \`cfld login\`)`);
|
|
1763
|
+
}
|
|
1764
|
+
const zones = listStoredZones();
|
|
1765
|
+
if (zones.length) out(` ${ok} cert store: ${zones.join(", ")}`);
|
|
1766
|
+
else out(` ${pc6.dim("\xB7")} cert store empty`);
|
|
1767
|
+
const entries = listEntries();
|
|
1768
|
+
out(` ${ok} registry: ${entries.length} tunnel(s)`);
|
|
1769
|
+
out("");
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/cli.ts
|
|
1773
|
+
var SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
1774
|
+
"login",
|
|
1775
|
+
"init",
|
|
1776
|
+
"list",
|
|
1777
|
+
"status",
|
|
1778
|
+
"up",
|
|
1779
|
+
"destroy",
|
|
1780
|
+
"service",
|
|
1781
|
+
"doctor",
|
|
1782
|
+
"help"
|
|
1783
|
+
]);
|
|
1784
|
+
var HELP = `${pc7.bold("cfld")} \u2014 persistent Cloudflare tunnels for local dev
|
|
1785
|
+
|
|
1786
|
+
${pc7.bold("Usage")}
|
|
1787
|
+
cfld [port] Ensure + run a persistent tunnel (reuses the same URL)
|
|
1788
|
+
cfld --quick [port] Ephemeral trycloudflare.com URL (no domain needed)
|
|
1789
|
+
cfld login [--reauth] Authorize with Cloudflare in your browser
|
|
1790
|
+
cfld init Interactive setup wizard (configure, don't run)
|
|
1791
|
+
cfld list List all tunnels across projects/domains
|
|
1792
|
+
cfld status [name] Show details for a tunnel
|
|
1793
|
+
cfld up <name> Run a registered tunnel by name from anywhere
|
|
1794
|
+
cfld destroy [name] Delete a tunnel (explicit, opt-in)
|
|
1795
|
+
cfld service <action> Always-on background service (install/uninstall/start/stop/status)
|
|
1796
|
+
cfld doctor Diagnose your setup
|
|
1797
|
+
|
|
1798
|
+
${pc7.bold("Options")}
|
|
1799
|
+
--name <slug> Override the project name (default: package.json name / dir)
|
|
1800
|
+
--host <fqdn> Explicit public hostname (e.g. api.example.com)
|
|
1801
|
+
--zone <domain> Cloudflare domain to use (e.g. example.com)
|
|
1802
|
+
--env <KEY> .env key to write the URL to (default: PUBLIC_URL)
|
|
1803
|
+
--no-env Don't write the URL to .env
|
|
1804
|
+
--force Skip confirmation (destroy)
|
|
1805
|
+
-h, --help Show this help
|
|
1806
|
+
-v, --version Show version
|
|
1807
|
+
`;
|
|
1808
|
+
async function main(argv) {
|
|
1809
|
+
const { values, positionals } = parseArgs({
|
|
1810
|
+
args: argv,
|
|
1811
|
+
allowPositionals: true,
|
|
1812
|
+
options: {
|
|
1813
|
+
name: { type: "string" },
|
|
1814
|
+
host: { type: "string" },
|
|
1815
|
+
zone: { type: "string" },
|
|
1816
|
+
env: { type: "string" },
|
|
1817
|
+
"no-env": { type: "boolean" },
|
|
1818
|
+
quick: { type: "boolean" },
|
|
1819
|
+
force: { type: "boolean" },
|
|
1820
|
+
reauth: { type: "boolean" },
|
|
1821
|
+
help: { type: "boolean", short: "h" },
|
|
1822
|
+
version: { type: "boolean", short: "v" }
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
if (values.version) {
|
|
1826
|
+
process.stdout.write("cfld 0.1.0\n");
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (values.help) {
|
|
1830
|
+
process.stdout.write(HELP);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const flags = {
|
|
1834
|
+
name: values.name,
|
|
1835
|
+
host: values.host,
|
|
1836
|
+
zone: values.zone,
|
|
1837
|
+
envKey: values.env,
|
|
1838
|
+
noEnv: values["no-env"],
|
|
1839
|
+
quick: values.quick,
|
|
1840
|
+
force: values.force,
|
|
1841
|
+
reauth: values.reauth
|
|
1842
|
+
};
|
|
1843
|
+
const [first, ...rest] = positionals;
|
|
1844
|
+
if (first && SUBCOMMANDS.has(first)) {
|
|
1845
|
+
switch (first) {
|
|
1846
|
+
case "help":
|
|
1847
|
+
process.stdout.write(HELP);
|
|
1848
|
+
return;
|
|
1849
|
+
case "login":
|
|
1850
|
+
await loginCommand(flags);
|
|
1851
|
+
return;
|
|
1852
|
+
case "init":
|
|
1853
|
+
await initCommand(flags);
|
|
1854
|
+
return;
|
|
1855
|
+
case "list":
|
|
1856
|
+
await listCommand();
|
|
1857
|
+
return;
|
|
1858
|
+
case "status":
|
|
1859
|
+
await statusCommand(rest[0]);
|
|
1860
|
+
return;
|
|
1861
|
+
case "up":
|
|
1862
|
+
if (!rest[0]) throw new Error("Usage: cfld up <name>");
|
|
1863
|
+
await upCommand(rest[0]);
|
|
1864
|
+
return;
|
|
1865
|
+
case "destroy":
|
|
1866
|
+
await destroyCommand(rest[0], Boolean(flags.force));
|
|
1867
|
+
return;
|
|
1868
|
+
case "service":
|
|
1869
|
+
await serviceCommand(rest[0], rest[1]);
|
|
1870
|
+
return;
|
|
1871
|
+
case "doctor":
|
|
1872
|
+
await doctorCommand();
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (first !== void 0) {
|
|
1877
|
+
const port = Number(first);
|
|
1878
|
+
if (Number.isInteger(port) && port > 0) flags.port = port;
|
|
1879
|
+
else throw new Error(`Unknown command "${first}". Try \`cfld --help\`.`);
|
|
1880
|
+
}
|
|
1881
|
+
if (flags.quick) {
|
|
1882
|
+
await quickCommand(flags);
|
|
1883
|
+
} else {
|
|
1884
|
+
await runCommand(flags);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
1888
|
+
printError(err);
|
|
1889
|
+
process.exit(1);
|
|
1890
|
+
});
|