@cliftonc/cfld 0.1.1 → 0.2.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/README.md +88 -6
- package/dist/cli.js +529 -66
- package/dist/dashboard-Q6WI32AK.js +175 -0
- package/package.json +1 -1
- package/dist/dashboard-QUPY25QT.js +0 -85
package/README.md
CHANGED
|
@@ -4,17 +4,44 @@
|
|
|
4
4
|
|
|
5
5
|
`cfld` wraps [`cloudflared`](https://github.com/cloudflare/cloudflared) into a frictionless dev experience: the **same** custom URL every run (not a random `trycloudflare.com` one), authorized with a **browser login** (zero API tokens), reusing the same named tunnel and DNS record across restarts instead of churning them. It runs a **live dashboard** (URL, connection count, request/latency counters, tailing logs), **auto-reconnects** on edge blips, and **guards** against stealing a DNS record that belongs to another tunnel.
|
|
6
6
|
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
**New here? Just run this — it walks you through everything, no account or config needed to start:**
|
|
10
|
+
|
|
7
11
|
```bash
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
npx @cliftonc/cfld setup
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`setup` is the front door. It checks `cloudflared` for you, then lets you pick:
|
|
16
|
+
|
|
17
|
+
1. **A public URL right now** — free, no account, no domain (a temporary `trycloudflare.com` URL). Great for a webhook, a demo, or just trying it.
|
|
18
|
+
2. **A persistent custom URL** — opens the browser to sign in (or **sign up** — it's free), then wires up the same URL for every future run.
|
|
19
|
+
|
|
20
|
+
You don't even have to remember `setup`: the **first time you run bare `cfld`** in a fresh project, it drops you into this same guided flow so you're never stuck guessing.
|
|
21
|
+
|
|
22
|
+
Already know what you want? Skip straight to it:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# instant public URL in seconds — no account, no config
|
|
26
|
+
npx @cliftonc/cfld --quick 3000 # temporary trycloudflare.com URL
|
|
27
|
+
|
|
28
|
+
# the everyday command — reuse-or-create your persistent tunnel and go live
|
|
29
|
+
npx @cliftonc/cfld # detect your dev port, go live on the same URL
|
|
10
30
|
npx @cliftonc/cfld 3000 # explicit port
|
|
11
|
-
npx @cliftonc/cfld --quick # ephemeral trycloudflare.com URL — no domain needed
|
|
12
31
|
|
|
13
32
|
# or install once and use the short `cfld` command everywhere
|
|
14
33
|
npm i -g @cliftonc/cfld
|
|
15
|
-
cfld
|
|
34
|
+
cfld setup
|
|
16
35
|
```
|
|
17
36
|
|
|
37
|
+
### What it costs
|
|
38
|
+
|
|
39
|
+
`cfld` is built to get you going at zero → near-zero cost, and it's honest about the one thing it can't automate:
|
|
40
|
+
|
|
41
|
+
- **Instant URL** — `cfld --quick`: **free**, no account, no domain. The URL is temporary and changes each run.
|
|
42
|
+
- **Cloudflare account** — **free**. `cfld` opens the browser to sign in (the same page has a **Sign up** link); there's no API token to create. Account signup itself can't be scripted — Cloudflare requires a browser, terms acceptance, and email verification.
|
|
43
|
+
- **Persistent custom URL** — needs a **domain on Cloudflare**. Free if you already own one (just add the site); otherwise a domain is a few dollars/year via [Cloudflare Registrar](https://dash.cloudflare.com/?to=/:account/domains/register). No account has a free permanent subdomain.
|
|
44
|
+
|
|
18
45
|
## Why not just `cloudflared`?
|
|
19
46
|
|
|
20
47
|
The persistent-tunnel flow normally means: create an API token in the dashboard, find your zone/account IDs, `tunnel create`, hand-write a `config.yml`, `route dns`, and remember not to delete anything. `cfld` collapses all of that into one idempotent command and adds the dev niceties (auto port detection, deterministic hostname, `.env` write-back, a live status view).
|
|
@@ -46,10 +73,11 @@ A machine-wide registry at `~/.cfld/registry.json` indexes every tunnel, so you
|
|
|
46
73
|
|
|
47
74
|
| Command | What it does |
|
|
48
75
|
|---|---|
|
|
76
|
+
| `cfld --quick [port]` | Instant `trycloudflare.com` URL — no account, no cert/domain (temporary). |
|
|
77
|
+
| `cfld setup` | Guided first-run — pick the instant free URL or set up a persistent one. |
|
|
49
78
|
| `cfld [port]` | Ensure + run a persistent tunnel (reuses the same URL). |
|
|
50
|
-
| `cfld --quick [port]` | Ephemeral `trycloudflare.com` URL — no cert/domain. |
|
|
51
79
|
| `cfld login [--reauth]` | Authorize with Cloudflare in your browser. `--reauth` adds another domain. |
|
|
52
|
-
| `cfld init` |
|
|
80
|
+
| `cfld init` | Configure a persistent tunnel without running it. |
|
|
53
81
|
| `cfld list` | List all tunnels across projects/domains. |
|
|
54
82
|
| `cfld status [name]` | Show details for a tunnel. |
|
|
55
83
|
| `cfld up <name>` | Run a registered tunnel by name from anywhere. |
|
|
@@ -113,6 +141,60 @@ cfld service uninstall # stop + remove (tunnel + DNS preserved)
|
|
|
113
141
|
|
|
114
142
|
Windows service management is not supported — run `cfld` in the foreground there.
|
|
115
143
|
|
|
144
|
+
## Running alongside your dev server
|
|
145
|
+
|
|
146
|
+
Give `cfld` your dev command after a `--` and it runs both as one managed unit:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
cfld 3000 -- next dev
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`cfld` will:
|
|
153
|
+
|
|
154
|
+
- **boot your dev server** as a managed child, with `PUBLIC_URL` already in its environment (so it can read its own public URL for webhooks/OAuth callbacks — no `.env` round-trip needed);
|
|
155
|
+
- **wait for `:3000` to actually listen** before flipping to `● LIVE`, so the URL never serves a 502 the moment you reveal it;
|
|
156
|
+
- **stream your dev server's logs** into the same dashboard as the tunnel — one unified view;
|
|
157
|
+
- **stop both on a single Ctrl-C**, gracefully, leaving the tunnel + DNS intact so the next run reuses the same URL;
|
|
158
|
+
- **exit if your dev server exits**, propagating its exit code (the tunnel is still preserved).
|
|
159
|
+
|
|
160
|
+
The pass-through form (`-- <cmd>`) runs your command directly. If you need shell features (pipes, `&&`, globs), use the string form instead:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cfld 5173 --exec "npm run dev"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
This works with `--quick` too, when you don't have a domain set up yet:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cfld --quick 3000 -- next dev
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Wire it into `package.json` so the whole team gets the same public URL:
|
|
173
|
+
|
|
174
|
+
```jsonc
|
|
175
|
+
{
|
|
176
|
+
"scripts": {
|
|
177
|
+
"dev": "next dev",
|
|
178
|
+
"dev:tunnel": "cfld 3000 -- next dev"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
> **Tip:** commit a `"cfld": { "zone": "example.com", "name": "myapp" }` block to `package.json` so the URL is deterministic for the whole team — everyone's `dev:tunnel` maps to `myapp.example.com`.
|
|
184
|
+
|
|
185
|
+
<details>
|
|
186
|
+
<summary>Prefer to keep the two processes separate?</summary>
|
|
187
|
+
|
|
188
|
+
`cfld` also works fine standalone — it only needs the port, and tolerates the app not being up yet (502 until it starts, then recovers). Run them with [`concurrently`](https://www.npmjs.com/package/concurrently):
|
|
189
|
+
|
|
190
|
+
```jsonc
|
|
191
|
+
"dev:tunnel": "concurrently -k -n app,cfld -c blue,magenta \"npm:dev\" \"cfld 3000\""
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
or plain shell: `cfld 3000 & next dev`.
|
|
195
|
+
|
|
196
|
+
</details>
|
|
197
|
+
|
|
116
198
|
## Development
|
|
117
199
|
|
|
118
200
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
+
import { readFileSync as readFileSync7, realpathSync } from "fs";
|
|
6
|
+
import { dirname, join as join7 } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
5
8
|
import pc7 from "picocolors";
|
|
6
9
|
|
|
7
10
|
// src/commands/run.ts
|
|
@@ -476,6 +479,20 @@ async function detectDevPorts() {
|
|
|
476
479
|
);
|
|
477
480
|
return checks.filter((p) => p !== null);
|
|
478
481
|
}
|
|
482
|
+
async function waitForPort(port, opts = {}) {
|
|
483
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
484
|
+
const intervalMs = opts.intervalMs ?? 300;
|
|
485
|
+
const deadline = Date.now() + timeoutMs;
|
|
486
|
+
while (Date.now() < deadline) {
|
|
487
|
+
if (opts.signal?.aborted) return false;
|
|
488
|
+
if (await isPortListening(port)) return true;
|
|
489
|
+
await sleep(intervalMs);
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
function sleep(ms) {
|
|
494
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
495
|
+
}
|
|
479
496
|
function findFreePort() {
|
|
480
497
|
return new Promise((resolve, reject) => {
|
|
481
498
|
const server = createServer();
|
|
@@ -662,6 +679,144 @@ function wireLines(child, stream, onLine) {
|
|
|
662
679
|
});
|
|
663
680
|
}
|
|
664
681
|
|
|
682
|
+
// src/process/child.ts
|
|
683
|
+
import { spawn as spawn3, execSync } from "child_process";
|
|
684
|
+
function describeCommand(cmd) {
|
|
685
|
+
return cmd.shell ? cmd.command : cmd.argv.join(" ");
|
|
686
|
+
}
|
|
687
|
+
function startChild(options) {
|
|
688
|
+
const [command, args, useShell] = options.command.shell ? [options.command.command, [], true] : [options.command.argv[0], options.command.argv.slice(1), false];
|
|
689
|
+
let stopped = false;
|
|
690
|
+
const child = spawn3(command, args, {
|
|
691
|
+
cwd: options.cwd,
|
|
692
|
+
env: { ...process.env, ...options.env },
|
|
693
|
+
// New process group so a terminal Ctrl-C doesn't race us to the child; we
|
|
694
|
+
// stop the whole group ourselves.
|
|
695
|
+
detached: true,
|
|
696
|
+
shell: useShell,
|
|
697
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
698
|
+
});
|
|
699
|
+
let markExited;
|
|
700
|
+
const exited = new Promise((resolve) => {
|
|
701
|
+
markExited = resolve;
|
|
702
|
+
});
|
|
703
|
+
wireLines2(child, "stdout", options.onLine);
|
|
704
|
+
wireLines2(child, "stderr", options.onLine);
|
|
705
|
+
child.on("error", (err) => {
|
|
706
|
+
markExited();
|
|
707
|
+
if (stopped) return;
|
|
708
|
+
stopped = true;
|
|
709
|
+
options.onExit?.(null, null);
|
|
710
|
+
options.onLine?.(String(err.message ?? err), "stderr");
|
|
711
|
+
});
|
|
712
|
+
child.on("exit", (code, signal) => {
|
|
713
|
+
markExited();
|
|
714
|
+
if (stopped) return;
|
|
715
|
+
stopped = true;
|
|
716
|
+
options.onExit?.(code, signal);
|
|
717
|
+
});
|
|
718
|
+
return {
|
|
719
|
+
exited,
|
|
720
|
+
get pid() {
|
|
721
|
+
return child.pid;
|
|
722
|
+
},
|
|
723
|
+
stop() {
|
|
724
|
+
if (stopped) return;
|
|
725
|
+
stopped = true;
|
|
726
|
+
killGroup(child, "SIGINT");
|
|
727
|
+
const t = setTimeout(() => {
|
|
728
|
+
if (child.exitCode === null && !child.signalCode) killGroup(child, "SIGKILL");
|
|
729
|
+
}, 4e3);
|
|
730
|
+
t.unref();
|
|
731
|
+
},
|
|
732
|
+
kill() {
|
|
733
|
+
killGroup(child, "SIGKILL");
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function descendantPids(pid) {
|
|
738
|
+
let out = "";
|
|
739
|
+
try {
|
|
740
|
+
out = execSync("ps -A -o pid=,ppid=", { encoding: "utf8" });
|
|
741
|
+
} catch {
|
|
742
|
+
return [];
|
|
743
|
+
}
|
|
744
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const line of out.split("\n")) {
|
|
746
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
747
|
+
if (!m) continue;
|
|
748
|
+
const child = Number(m[1]);
|
|
749
|
+
const parent = Number(m[2]);
|
|
750
|
+
const list = childrenOf.get(parent);
|
|
751
|
+
if (list) list.push(child);
|
|
752
|
+
else childrenOf.set(parent, [child]);
|
|
753
|
+
}
|
|
754
|
+
const found = [];
|
|
755
|
+
const stack = [pid];
|
|
756
|
+
while (stack.length) {
|
|
757
|
+
for (const child of childrenOf.get(stack.pop()) ?? []) {
|
|
758
|
+
if (!found.includes(child)) {
|
|
759
|
+
found.push(child);
|
|
760
|
+
stack.push(child);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return found;
|
|
765
|
+
}
|
|
766
|
+
function signalPids(pids, signal) {
|
|
767
|
+
for (const pid of pids) {
|
|
768
|
+
try {
|
|
769
|
+
process.kill(pid, signal);
|
|
770
|
+
} catch {
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function reapChild(child, graceMs = 3e3) {
|
|
775
|
+
const tree = child.pid ? descendantPids(child.pid) : [];
|
|
776
|
+
child.stop();
|
|
777
|
+
signalPids(tree, "SIGINT");
|
|
778
|
+
return new Promise((resolve) => {
|
|
779
|
+
const done2 = () => {
|
|
780
|
+
clearTimeout(grace);
|
|
781
|
+
child.kill();
|
|
782
|
+
signalPids(tree, "SIGKILL");
|
|
783
|
+
resolve();
|
|
784
|
+
};
|
|
785
|
+
const grace = setTimeout(done2, graceMs);
|
|
786
|
+
grace.unref();
|
|
787
|
+
child.exited.then(done2);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
function killGroup(child, signal) {
|
|
791
|
+
const pid = child.pid;
|
|
792
|
+
if (pid === void 0) return;
|
|
793
|
+
try {
|
|
794
|
+
process.kill(-pid, signal);
|
|
795
|
+
} catch {
|
|
796
|
+
try {
|
|
797
|
+
child.kill(signal);
|
|
798
|
+
} catch {
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function wireLines2(child, stream, onLine) {
|
|
803
|
+
const source = stream === "stdout" ? child.stdout : child.stderr;
|
|
804
|
+
if (!source || !onLine) return;
|
|
805
|
+
let buffer = "";
|
|
806
|
+
source.setEncoding("utf8");
|
|
807
|
+
source.on("data", (chunk) => {
|
|
808
|
+
buffer += chunk;
|
|
809
|
+
let idx;
|
|
810
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
811
|
+
onLine(buffer.slice(0, idx).replace(/\r$/, ""), stream);
|
|
812
|
+
buffer = buffer.slice(idx + 1);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
source.on("end", () => {
|
|
816
|
+
if (buffer.length > 0) onLine(buffer.replace(/\r$/, ""), stream);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
665
820
|
// src/process/ready.ts
|
|
666
821
|
async function getReady(metricsPort) {
|
|
667
822
|
try {
|
|
@@ -681,7 +836,7 @@ async function waitForReady(metricsPort, opts = {}) {
|
|
|
681
836
|
if (opts.signal?.aborted) throw new Error("aborted");
|
|
682
837
|
const state = await getReady(metricsPort);
|
|
683
838
|
if (state?.ready) return state;
|
|
684
|
-
await
|
|
839
|
+
await sleep2(intervalMs);
|
|
685
840
|
}
|
|
686
841
|
throw new Error("Timed out waiting for the tunnel to connect to Cloudflare.");
|
|
687
842
|
}
|
|
@@ -715,7 +870,7 @@ function sumMetric(text, name) {
|
|
|
715
870
|
}
|
|
716
871
|
return total;
|
|
717
872
|
}
|
|
718
|
-
function
|
|
873
|
+
function sleep2(ms) {
|
|
719
874
|
return new Promise((r) => setTimeout(r, ms));
|
|
720
875
|
}
|
|
721
876
|
|
|
@@ -755,7 +910,7 @@ function colorLevel(level) {
|
|
|
755
910
|
}
|
|
756
911
|
|
|
757
912
|
// src/commands/login.ts
|
|
758
|
-
import { spawn as
|
|
913
|
+
import { spawn as spawn4 } from "child_process";
|
|
759
914
|
import { existsSync as existsSync8 } from "fs";
|
|
760
915
|
|
|
761
916
|
// src/ui/prompts.ts
|
|
@@ -883,7 +1038,7 @@ async function runLogin() {
|
|
|
883
1038
|
step("Opening your browser to authorize with Cloudflare (no API token needed)\u2026");
|
|
884
1039
|
note(" Pick the domain you want to use. Waiting for you to finish\u2026");
|
|
885
1040
|
await new Promise((resolve, reject) => {
|
|
886
|
-
const child =
|
|
1041
|
+
const child = spawn4(bin, ["tunnel", "login"], { stdio: "inherit" });
|
|
887
1042
|
child.on("error", reject);
|
|
888
1043
|
child.on("exit", (code) => {
|
|
889
1044
|
if (code === 0 || existsSync8(defaultCertPath())) resolve();
|
|
@@ -966,6 +1121,12 @@ async function runCommand(flags) {
|
|
|
966
1121
|
const cwd = process.cwd();
|
|
967
1122
|
const interactive = isInteractive();
|
|
968
1123
|
const config = readProjectConfig(cwd);
|
|
1124
|
+
if (flags.exec && flags.port === void 0 && config?.port === void 0 && !config?.routes?.length) {
|
|
1125
|
+
throw new CfldError(
|
|
1126
|
+
"Specify the port your dev command will listen on.",
|
|
1127
|
+
"e.g. `cfld 3000 -- next dev`"
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
969
1130
|
const slug = deriveSlug(cwd, flags.name ?? config?.name);
|
|
970
1131
|
const tunnelName = tunnelNameForSlug(slug);
|
|
971
1132
|
const knownZones = Array.from(
|
|
@@ -1005,7 +1166,13 @@ async function runCommand(flags) {
|
|
|
1005
1166
|
envKey
|
|
1006
1167
|
});
|
|
1007
1168
|
if (ensureGitignored(cwd, ".cfld.json")) info("Added .cfld.json to .gitignore.");
|
|
1008
|
-
await launch(result, {
|
|
1169
|
+
await launch(result, {
|
|
1170
|
+
cwd,
|
|
1171
|
+
envKey,
|
|
1172
|
+
noEnv: flags.noEnv,
|
|
1173
|
+
port: primary.port,
|
|
1174
|
+
exec: flags.exec
|
|
1175
|
+
});
|
|
1009
1176
|
}
|
|
1010
1177
|
async function launch(result, ctx) {
|
|
1011
1178
|
const metricsPort = await findFreePort();
|
|
@@ -1014,7 +1181,7 @@ async function launch(result, ctx) {
|
|
|
1014
1181
|
const interactive = isInteractive();
|
|
1015
1182
|
let dashboard;
|
|
1016
1183
|
if (interactive) {
|
|
1017
|
-
const { startDashboard } = await import("./dashboard-
|
|
1184
|
+
const { startDashboard } = await import("./dashboard-Q6WI32AK.js");
|
|
1018
1185
|
dashboard = startDashboard({
|
|
1019
1186
|
url,
|
|
1020
1187
|
target,
|
|
@@ -1022,7 +1189,10 @@ async function launch(result, ctx) {
|
|
|
1022
1189
|
status: "connecting",
|
|
1023
1190
|
connections: 0,
|
|
1024
1191
|
requests: 0,
|
|
1025
|
-
|
|
1192
|
+
split: Boolean(ctx.exec),
|
|
1193
|
+
devLabel: ctx.exec ? describeCommand(ctx.exec) : void 0,
|
|
1194
|
+
tunnelLogs: [],
|
|
1195
|
+
devLogs: []
|
|
1026
1196
|
});
|
|
1027
1197
|
} else {
|
|
1028
1198
|
step("Connecting to Cloudflare edge\u2026");
|
|
@@ -1030,11 +1200,32 @@ async function launch(result, ctx) {
|
|
|
1030
1200
|
const emitLine = (line) => {
|
|
1031
1201
|
const formatted = formatLogLine(line);
|
|
1032
1202
|
if (!formatted) return;
|
|
1033
|
-
if (dashboard) dashboard.log(formatted);
|
|
1203
|
+
if (dashboard) dashboard.log(formatted, "tunnel");
|
|
1034
1204
|
else process.stderr.write(formatted + "\n");
|
|
1035
1205
|
};
|
|
1036
|
-
|
|
1037
|
-
|
|
1206
|
+
const emitDevLine = (line) => {
|
|
1207
|
+
if (line.trim() === "") return;
|
|
1208
|
+
if (dashboard) dashboard.log(line, "dev");
|
|
1209
|
+
else process.stderr.write(line + "\n");
|
|
1210
|
+
};
|
|
1211
|
+
let runner;
|
|
1212
|
+
let child;
|
|
1213
|
+
let poll;
|
|
1214
|
+
let shuttingDown = false;
|
|
1215
|
+
const childAbort = new AbortController();
|
|
1216
|
+
const shutdown = (opts = {}) => {
|
|
1217
|
+
if (shuttingDown) return;
|
|
1218
|
+
shuttingDown = true;
|
|
1219
|
+
if (poll) clearInterval(poll);
|
|
1220
|
+
runner?.stop();
|
|
1221
|
+
dashboard?.stop();
|
|
1222
|
+
if (opts.reason) note(`
|
|
1223
|
+
${opts.reason}`);
|
|
1224
|
+
const exit = () => setTimeout(() => process.exit(opts.code ?? 0), 100).unref();
|
|
1225
|
+
if (child) reapChild(child).then(exit);
|
|
1226
|
+
else exit();
|
|
1227
|
+
};
|
|
1228
|
+
runner = await startRunner({
|
|
1038
1229
|
configPath: result.configPath,
|
|
1039
1230
|
tunnelName: result.tunnelName,
|
|
1040
1231
|
certPath: result.certPath,
|
|
@@ -1045,32 +1236,53 @@ async function launch(result, ctx) {
|
|
|
1045
1236
|
else if (status === "connecting" && !dashboard) note("Reconnecting\u2026");
|
|
1046
1237
|
},
|
|
1047
1238
|
onFatal: (message) => {
|
|
1048
|
-
dashboard?.stop();
|
|
1049
1239
|
warn(message);
|
|
1050
|
-
|
|
1240
|
+
shutdown({ code: 1 });
|
|
1051
1241
|
}
|
|
1052
1242
|
});
|
|
1053
|
-
|
|
1054
|
-
if (
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1243
|
+
if (ctx.exec) {
|
|
1244
|
+
if (!dashboard) step(`Starting \`${describeCommand(ctx.exec)}\`\u2026`);
|
|
1245
|
+
child = startChild({
|
|
1246
|
+
command: ctx.exec,
|
|
1247
|
+
cwd: ctx.cwd,
|
|
1248
|
+
env: ctx.noEnv ? void 0 : { [ctx.envKey]: url },
|
|
1249
|
+
onLine: (line) => emitDevLine(line),
|
|
1250
|
+
onExit: (code, signal) => {
|
|
1251
|
+
childAbort.abort();
|
|
1252
|
+
const how = code !== null ? `exited (code ${code})` : `was terminated${signal ? ` (${signal})` : ""}`;
|
|
1253
|
+
shutdown({
|
|
1254
|
+
reason: `Your dev command ${how} \u2014 stopping. Your tunnel URL is preserved.`,
|
|
1255
|
+
code: code ?? 1
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1062
1260
|
if (!dashboard) {
|
|
1063
|
-
process.once(
|
|
1064
|
-
|
|
1261
|
+
process.once(
|
|
1262
|
+
"SIGINT",
|
|
1263
|
+
() => shutdown({ reason: "Stopping \u2014 your URL is preserved. Re-run `cfld`." })
|
|
1264
|
+
);
|
|
1265
|
+
process.once(
|
|
1266
|
+
"SIGTERM",
|
|
1267
|
+
() => shutdown({ reason: "Stopping \u2014 your URL is preserved." })
|
|
1268
|
+
);
|
|
1065
1269
|
}
|
|
1066
1270
|
const ready = await waitForReady(metricsPort).catch((err) => {
|
|
1067
|
-
|
|
1271
|
+
child?.stop();
|
|
1272
|
+
runner?.stop();
|
|
1068
1273
|
dashboard?.stop();
|
|
1069
1274
|
throw new CfldError(
|
|
1070
1275
|
"The tunnel did not connect.",
|
|
1071
1276
|
err instanceof Error ? err.message : void 0
|
|
1072
1277
|
);
|
|
1073
1278
|
});
|
|
1279
|
+
if (child && !shuttingDown) {
|
|
1280
|
+
if (dashboard) dashboard.update({ status: "waiting", connections: ready.connections });
|
|
1281
|
+
else step(`Waiting for your dev server on :${ctx.port}\u2026`);
|
|
1282
|
+
const up = await waitForPort(ctx.port, { signal: childAbort.signal });
|
|
1283
|
+
if (shuttingDown) return;
|
|
1284
|
+
if (!up) warn(`Nothing is listening on :${ctx.port} yet \u2014 going live anyway.`);
|
|
1285
|
+
}
|
|
1074
1286
|
if (!ctx.noEnv) {
|
|
1075
1287
|
const { changed } = upsertEnv(join5(ctx.cwd, ".env"), ctx.envKey, url);
|
|
1076
1288
|
if (changed && !dashboard) info(`Wrote ${ctx.envKey}=${url} to .env`);
|
|
@@ -1080,7 +1292,7 @@ ${reason}`);
|
|
|
1080
1292
|
} else {
|
|
1081
1293
|
summaryBox({ url, target, tunnelName: result.tunnelName, connections: ready.connections });
|
|
1082
1294
|
}
|
|
1083
|
-
|
|
1295
|
+
poll = setInterval(async () => {
|
|
1084
1296
|
const m = await scrapeMetrics(metricsPort);
|
|
1085
1297
|
if (m && dashboard) {
|
|
1086
1298
|
dashboard.update({
|
|
@@ -1093,8 +1305,7 @@ ${reason}`);
|
|
|
1093
1305
|
poll.unref();
|
|
1094
1306
|
if (dashboard) {
|
|
1095
1307
|
await dashboard.waitUntilExit();
|
|
1096
|
-
|
|
1097
|
-
stop("Stopping \u2014 your URL is preserved. Re-run `cfld` to resume.");
|
|
1308
|
+
shutdown({ reason: "Stopping \u2014 your URL is preserved. Re-run `cfld` to resume." });
|
|
1098
1309
|
}
|
|
1099
1310
|
}
|
|
1100
1311
|
async function resolveZone(flags, configZone, knownZones, interactive) {
|
|
@@ -1142,13 +1353,13 @@ async function resolvePort(flags, configPort, interactive) {
|
|
|
1142
1353
|
}
|
|
1143
1354
|
|
|
1144
1355
|
// src/process/quick.ts
|
|
1145
|
-
import { spawn as
|
|
1356
|
+
import { spawn as spawn5 } from "child_process";
|
|
1146
1357
|
var QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
1147
1358
|
async function startQuickTunnel(port, opts = {}) {
|
|
1148
1359
|
const bin = await resolveBinary();
|
|
1149
1360
|
const args = ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"];
|
|
1150
1361
|
if (opts.metricsPort) args.push("--metrics", `127.0.0.1:${opts.metricsPort}`);
|
|
1151
|
-
const child =
|
|
1362
|
+
const child = spawn5(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1152
1363
|
return new Promise((resolve, reject) => {
|
|
1153
1364
|
const timeout = setTimeout(() => {
|
|
1154
1365
|
child.kill("SIGKILL");
|
|
@@ -1195,39 +1406,98 @@ async function quickCommand(flags) {
|
|
|
1195
1406
|
const target = `localhost:${port}`;
|
|
1196
1407
|
const interactive = isInteractive();
|
|
1197
1408
|
const metricsPort = await findFreePort();
|
|
1409
|
+
const envKey = flags.envKey ?? "PUBLIC_URL";
|
|
1198
1410
|
if (!interactive) step(`Starting an ephemeral tunnel to ${target}\u2026`);
|
|
1199
1411
|
let dashboard;
|
|
1200
|
-
|
|
1412
|
+
let tunnel;
|
|
1413
|
+
let child;
|
|
1414
|
+
let poll;
|
|
1415
|
+
let shuttingDown = false;
|
|
1416
|
+
const childAbort = new AbortController();
|
|
1417
|
+
const tunnelBuffer = [];
|
|
1418
|
+
const devBuffer = [];
|
|
1201
1419
|
const emitLine = (line) => {
|
|
1202
1420
|
const formatted = formatLogLine(line);
|
|
1203
1421
|
if (!formatted) return;
|
|
1204
|
-
|
|
1205
|
-
if (dashboard) dashboard.log(formatted);
|
|
1422
|
+
tunnelBuffer.push(formatted);
|
|
1423
|
+
if (dashboard) dashboard.log(formatted, "tunnel");
|
|
1206
1424
|
else if (!interactive) process.stderr.write(formatted + "\n");
|
|
1207
1425
|
};
|
|
1208
|
-
const
|
|
1426
|
+
const emitDevLine = (line) => {
|
|
1427
|
+
if (line.trim() === "") return;
|
|
1428
|
+
devBuffer.push(line);
|
|
1429
|
+
if (dashboard) dashboard.log(line, "dev");
|
|
1430
|
+
else if (!interactive) process.stderr.write(line + "\n");
|
|
1431
|
+
};
|
|
1432
|
+
const shutdown = (opts = {}) => {
|
|
1433
|
+
if (shuttingDown) return;
|
|
1434
|
+
shuttingDown = true;
|
|
1435
|
+
if (poll) clearInterval(poll);
|
|
1436
|
+
tunnel?.stop();
|
|
1437
|
+
dashboard?.stop();
|
|
1438
|
+
note(`
|
|
1439
|
+
${opts.reason ?? "Stopped ephemeral tunnel."}`);
|
|
1440
|
+
const exit = () => setTimeout(() => process.exit(opts.code ?? 0), 100).unref();
|
|
1441
|
+
if (child) reapChild(child).then(exit);
|
|
1442
|
+
else exit();
|
|
1443
|
+
};
|
|
1444
|
+
tunnel = await startQuickTunnel(port, { metricsPort, onLine: emitLine });
|
|
1445
|
+
const url = tunnel.url;
|
|
1446
|
+
if (flags.exec) {
|
|
1447
|
+
if (!interactive) step(`Starting \`${describeCommand(flags.exec)}\`\u2026`);
|
|
1448
|
+
child = startChild({
|
|
1449
|
+
command: flags.exec,
|
|
1450
|
+
cwd: process.cwd(),
|
|
1451
|
+
env: flags.noEnv ? void 0 : { [envKey]: url },
|
|
1452
|
+
onLine: (line) => emitDevLine(line),
|
|
1453
|
+
onExit: (code, signal) => {
|
|
1454
|
+
childAbort.abort();
|
|
1455
|
+
const how = code !== null ? `exited (code ${code})` : `was terminated${signal ? ` (${signal})` : ""}`;
|
|
1456
|
+
shutdown({
|
|
1457
|
+
reason: `Your dev command ${how} \u2014 stopping the ephemeral tunnel.`,
|
|
1458
|
+
code: code ?? 1
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1209
1463
|
if (interactive) {
|
|
1210
|
-
const { startDashboard } = await import("./dashboard-
|
|
1464
|
+
const { startDashboard } = await import("./dashboard-Q6WI32AK.js");
|
|
1211
1465
|
dashboard = startDashboard({
|
|
1212
|
-
url
|
|
1466
|
+
url,
|
|
1213
1467
|
target,
|
|
1214
1468
|
tunnelName: "quick (ephemeral)",
|
|
1215
|
-
|
|
1216
|
-
|
|
1469
|
+
// Hold at WAITING until the dev server opens the port (if we're running one).
|
|
1470
|
+
status: child ? "waiting" : "live",
|
|
1471
|
+
connections: child ? 0 : 1,
|
|
1217
1472
|
requests: 0,
|
|
1218
|
-
|
|
1473
|
+
split: Boolean(flags.exec),
|
|
1474
|
+
devLabel: flags.exec ? describeCommand(flags.exec) : void 0,
|
|
1475
|
+
tunnelLogs: tunnelBuffer.slice(-200),
|
|
1476
|
+
devLogs: devBuffer.slice(-200),
|
|
1219
1477
|
ephemeral: true
|
|
1220
1478
|
});
|
|
1221
|
-
}
|
|
1479
|
+
}
|
|
1480
|
+
if (!dashboard) {
|
|
1481
|
+
process.once("SIGINT", () => shutdown());
|
|
1482
|
+
process.once("SIGTERM", () => shutdown());
|
|
1483
|
+
}
|
|
1484
|
+
if (child) {
|
|
1485
|
+
if (!dashboard) step(`Waiting for your dev server on :${port}\u2026`);
|
|
1486
|
+
const up = await waitForPort(port, { signal: childAbort.signal });
|
|
1487
|
+
if (shuttingDown) return;
|
|
1488
|
+
if (!up) warn(`Nothing is listening on :${port} yet \u2014 going live anyway.`);
|
|
1489
|
+
dashboard?.update({ status: "live", connections: 1 });
|
|
1490
|
+
}
|
|
1491
|
+
if (!dashboard) {
|
|
1222
1492
|
summaryBox({
|
|
1223
|
-
url
|
|
1493
|
+
url,
|
|
1224
1494
|
target,
|
|
1225
1495
|
tunnelName: "quick (ephemeral)",
|
|
1226
1496
|
connections: 1,
|
|
1227
1497
|
ephemeral: true
|
|
1228
1498
|
});
|
|
1229
1499
|
}
|
|
1230
|
-
|
|
1500
|
+
poll = setInterval(async () => {
|
|
1231
1501
|
const m = await scrapeMetrics(metricsPort);
|
|
1232
1502
|
if (m && dashboard) {
|
|
1233
1503
|
dashboard.update({
|
|
@@ -1238,23 +1508,19 @@ async function quickCommand(flags) {
|
|
|
1238
1508
|
}
|
|
1239
1509
|
}, dashboard ? 1e3 : 15e3);
|
|
1240
1510
|
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
1511
|
if (dashboard) {
|
|
1249
1512
|
await dashboard.waitUntilExit();
|
|
1250
|
-
|
|
1251
|
-
} else {
|
|
1252
|
-
process.once("SIGINT", stop);
|
|
1253
|
-
process.once("SIGTERM", stop);
|
|
1513
|
+
shutdown();
|
|
1254
1514
|
}
|
|
1255
1515
|
}
|
|
1256
1516
|
async function resolvePort2(flags) {
|
|
1257
1517
|
if (flags.port) return flags.port;
|
|
1518
|
+
if (flags.exec) {
|
|
1519
|
+
throw new CfldError(
|
|
1520
|
+
"Specify the port your dev command will listen on.",
|
|
1521
|
+
"e.g. `cfld --quick 3000 -- next dev`"
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1258
1524
|
const detected = await detectDevPorts();
|
|
1259
1525
|
if (detected.length >= 1) {
|
|
1260
1526
|
info(`Detected a local server on port ${detected[0]}.`);
|
|
@@ -1289,6 +1555,11 @@ async function initCommand(flags) {
|
|
|
1289
1555
|
zone = normalizeZone(await askText("Domain (zone) on Cloudflare?", "example.com"));
|
|
1290
1556
|
}
|
|
1291
1557
|
const certPath = await ensureCert(zone, { interactive: true });
|
|
1558
|
+
await configureProject(flags, { zone, certPath, cwd });
|
|
1559
|
+
note("Run `cfld` to go live.");
|
|
1560
|
+
}
|
|
1561
|
+
async function configureProject(flags, ctx) {
|
|
1562
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1292
1563
|
const defaultSlug = deriveSlug(cwd, flags.name);
|
|
1293
1564
|
const name = slugify(await askText("Project name (used in the URL)", defaultSlug) || defaultSlug);
|
|
1294
1565
|
const detected = await detectDevPorts();
|
|
@@ -1300,13 +1571,13 @@ async function initCommand(flags) {
|
|
|
1300
1571
|
if (!Number.isInteger(port) || port <= 0) {
|
|
1301
1572
|
throw new CfldError(`"${portStr}" is not a valid port.`);
|
|
1302
1573
|
}
|
|
1303
|
-
const hostname = flags.host ?? buildHostname(name, zone);
|
|
1574
|
+
const hostname = flags.host ?? buildHostname(name, ctx.zone);
|
|
1304
1575
|
const result = await reconcile(
|
|
1305
1576
|
{
|
|
1306
1577
|
slug: name,
|
|
1307
1578
|
tunnelName: tunnelNameForSlug(name),
|
|
1308
|
-
zone,
|
|
1309
|
-
certPath,
|
|
1579
|
+
zone: ctx.zone,
|
|
1580
|
+
certPath: ctx.certPath,
|
|
1310
1581
|
routes: [{ hostname, port }],
|
|
1311
1582
|
projectDir: cwd,
|
|
1312
1583
|
force: flags.force
|
|
@@ -1318,7 +1589,7 @@ async function initCommand(flags) {
|
|
|
1318
1589
|
);
|
|
1319
1590
|
writeProjectConfig(cwd, {
|
|
1320
1591
|
name,
|
|
1321
|
-
zone,
|
|
1592
|
+
zone: ctx.zone,
|
|
1322
1593
|
uuid: result.uuid,
|
|
1323
1594
|
hostname,
|
|
1324
1595
|
port,
|
|
@@ -1326,7 +1597,138 @@ async function initCommand(flags) {
|
|
|
1326
1597
|
});
|
|
1327
1598
|
if (ensureGitignored(cwd, ".cfld.json")) info("Added .cfld.json to .gitignore.");
|
|
1328
1599
|
done(`Configured https://${hostname} \u2192 localhost:${port}`);
|
|
1329
|
-
|
|
1600
|
+
return { name, hostname, port };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/commands/setup.ts
|
|
1604
|
+
async function setupCommand(flags) {
|
|
1605
|
+
if (!isInteractive()) {
|
|
1606
|
+
throw new CfldError(
|
|
1607
|
+
"`cfld setup` is interactive.",
|
|
1608
|
+
"In scripts, use `cfld --quick`, or `cfld` with --zone/--host/--name flags."
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
step("Let's get cfld set up.");
|
|
1612
|
+
await preflightCloudflared();
|
|
1613
|
+
const cwd = process.cwd();
|
|
1614
|
+
const knownZones = Array.from(
|
|
1615
|
+
/* @__PURE__ */ new Set([...listStoredZones(), ...listEntries().map((e) => e.zone)])
|
|
1616
|
+
);
|
|
1617
|
+
const alreadyAuthorized = knownZones.length > 0 || certHasZone(defaultCertPath());
|
|
1618
|
+
if (alreadyAuthorized) {
|
|
1619
|
+
const config = readProjectConfig(cwd);
|
|
1620
|
+
if (config?.uuid || config?.zone) {
|
|
1621
|
+
done("You're already set up \u2014 run `cfld` to go live.");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
info("You're already authorized with Cloudflare. Let's configure this project.");
|
|
1625
|
+
const zone2 = await askAuthorizedZone(knownZones);
|
|
1626
|
+
const certPath2 = await ensureCert(zone2, { interactive: true });
|
|
1627
|
+
await configureProject(flags, { zone: zone2, certPath: certPath2, cwd });
|
|
1628
|
+
await offerGoLive(flags, zone2);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const path = await askSelect("How do you want to start?", [
|
|
1632
|
+
{
|
|
1633
|
+
value: "quick",
|
|
1634
|
+
label: "Get a public URL right now \u2014 free, no account (temporary URL)"
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
value: "persistent",
|
|
1638
|
+
label: "Set up a persistent URL \u2014 free Cloudflare account + a domain"
|
|
1639
|
+
}
|
|
1640
|
+
]);
|
|
1641
|
+
if (path === "quick") {
|
|
1642
|
+
note(
|
|
1643
|
+
" Starting a temporary tunnel. Run `cfld setup` again anytime to set up a persistent URL."
|
|
1644
|
+
);
|
|
1645
|
+
await quickCommand(flags);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
note(
|
|
1649
|
+
[
|
|
1650
|
+
"",
|
|
1651
|
+
" A browser window will open to authorize cfld with Cloudflare.",
|
|
1652
|
+
` \u2022 No account yet? Click "Sign up" on that page \u2014 it's free.`,
|
|
1653
|
+
" \u2022 Then pick (or add) the domain you want to use.",
|
|
1654
|
+
" Prefer to create the account first? Open:",
|
|
1655
|
+
" https://dash.cloudflare.com/sign-up",
|
|
1656
|
+
""
|
|
1657
|
+
].join("\n")
|
|
1658
|
+
);
|
|
1659
|
+
if (!await askConfirm("Open the browser to authorize now?")) {
|
|
1660
|
+
note(
|
|
1661
|
+
"No problem \u2014 run `cfld setup` again when you're ready, or `cfld --quick` for a temporary URL."
|
|
1662
|
+
);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
await runLogin();
|
|
1666
|
+
if (!certHasZone(defaultCertPath())) {
|
|
1667
|
+
printZeroZoneGuidance();
|
|
1668
|
+
if (await askConfirm("Start a temporary quick tunnel now instead?")) {
|
|
1669
|
+
await quickCommand(flags);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
throw new CfldError(
|
|
1673
|
+
"Your Cloudflare account has no domain yet, so a persistent URL isn't possible.",
|
|
1674
|
+
"Add a domain (see above), then run `cfld setup` again."
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const zone = await askAuthorizedZone(knownZones);
|
|
1678
|
+
const certPath = importCert(zone);
|
|
1679
|
+
info(`Saved cert for ${zone}.`);
|
|
1680
|
+
await configureProject(flags, { zone, certPath, cwd });
|
|
1681
|
+
await offerGoLive(flags, zone);
|
|
1682
|
+
}
|
|
1683
|
+
async function preflightCloudflared() {
|
|
1684
|
+
try {
|
|
1685
|
+
const bin = await resolveBinary();
|
|
1686
|
+
const res = await exec(bin, ["--version"]);
|
|
1687
|
+
const version = (res.stdout.trim() || res.stderr.trim()).split("\n")[0];
|
|
1688
|
+
done(`cloudflared ready${version ? ` \u2014 ${version}` : ""}`);
|
|
1689
|
+
} catch {
|
|
1690
|
+
warn("cloudflared isn't installed, and the bundled binary couldn't load.");
|
|
1691
|
+
note(
|
|
1692
|
+
[
|
|
1693
|
+
"",
|
|
1694
|
+
" Install it, then run `cfld setup` again:",
|
|
1695
|
+
" macOS brew install cloudflared",
|
|
1696
|
+
" Linux apt / dnf / pacman install cloudflared",
|
|
1697
|
+
" Windows winget install --id Cloudflare.cloudflared",
|
|
1698
|
+
" Any OS https://developers.cloudflare.com/tunnel/downloads/",
|
|
1699
|
+
""
|
|
1700
|
+
].join("\n")
|
|
1701
|
+
);
|
|
1702
|
+
throw new CfldError(
|
|
1703
|
+
"cloudflared is required to continue.",
|
|
1704
|
+
"Install it (see above), or set CFLD_CLOUDFLARED to its path."
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
function isBlankFirstRun(flags, cwd, interactive = isInteractive()) {
|
|
1709
|
+
if (!interactive) return false;
|
|
1710
|
+
if (flags.zone || flags.host || flags.quick) return false;
|
|
1711
|
+
if (readProjectConfig(cwd)) return false;
|
|
1712
|
+
if (listStoredZones().length > 0) return false;
|
|
1713
|
+
if (certHasZone(defaultCertPath())) return false;
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
async function askAuthorizedZone(knownZones) {
|
|
1717
|
+
if (knownZones.length) {
|
|
1718
|
+
const picked = await askSelect("Which domain did you authorize?", [
|
|
1719
|
+
...knownZones.map((z) => ({ value: z, label: z })),
|
|
1720
|
+
{ value: "__new__", label: "A different domain\u2026" }
|
|
1721
|
+
]);
|
|
1722
|
+
if (picked !== "__new__") return picked;
|
|
1723
|
+
}
|
|
1724
|
+
return normalizeZone(await askText("Which domain did you authorize?", "example.com"));
|
|
1725
|
+
}
|
|
1726
|
+
async function offerGoLive(flags, zone) {
|
|
1727
|
+
if (await askConfirm("Go live now?")) {
|
|
1728
|
+
await runCommand({ ...flags, zone });
|
|
1729
|
+
} else {
|
|
1730
|
+
note("Run `cfld` to go live.");
|
|
1731
|
+
}
|
|
1330
1732
|
}
|
|
1331
1733
|
|
|
1332
1734
|
// src/commands/list.ts
|
|
@@ -1770,7 +2172,32 @@ async function doctorCommand() {
|
|
|
1770
2172
|
}
|
|
1771
2173
|
|
|
1772
2174
|
// src/cli.ts
|
|
2175
|
+
function readVersion() {
|
|
2176
|
+
try {
|
|
2177
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2178
|
+
const pkg = JSON.parse(readFileSync7(join7(here, "../package.json"), "utf8"));
|
|
2179
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
2180
|
+
} catch {
|
|
2181
|
+
return "0.0.0";
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
function splitAtDoubleDash(rawArgv) {
|
|
2185
|
+
const i = rawArgv.indexOf("--");
|
|
2186
|
+
if (i === -1) return { argv: rawArgv, afterDash: [] };
|
|
2187
|
+
return { argv: rawArgv.slice(0, i), afterDash: rawArgv.slice(i + 1) };
|
|
2188
|
+
}
|
|
2189
|
+
function resolveExec(afterDash, execFlag) {
|
|
2190
|
+
const hasArgv = afterDash.length > 0;
|
|
2191
|
+
const hasShell = execFlag !== void 0 && execFlag.trim() !== "";
|
|
2192
|
+
if (hasArgv && hasShell) {
|
|
2193
|
+
throw new Error('Use either `-- <cmd>` or `--exec "<cmd>"`, not both.');
|
|
2194
|
+
}
|
|
2195
|
+
if (hasArgv) return { shell: false, argv: afterDash };
|
|
2196
|
+
if (hasShell) return { shell: true, command: execFlag.trim() };
|
|
2197
|
+
return void 0;
|
|
2198
|
+
}
|
|
1773
2199
|
var SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
2200
|
+
"setup",
|
|
1774
2201
|
"login",
|
|
1775
2202
|
"init",
|
|
1776
2203
|
"list",
|
|
@@ -1784,10 +2211,12 @@ var SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
|
1784
2211
|
var HELP = `${pc7.bold("cfld")} \u2014 persistent Cloudflare tunnels for local dev
|
|
1785
2212
|
|
|
1786
2213
|
${pc7.bold("Usage")}
|
|
2214
|
+
cfld --quick [port] Instant public URL, no account needed (temporary)
|
|
2215
|
+
cfld setup Guided first-run: free instant URL, or persistent setup
|
|
1787
2216
|
cfld [port] Ensure + run a persistent tunnel (reuses the same URL)
|
|
1788
|
-
cfld
|
|
2217
|
+
cfld [port] -- <cmd> Run + supervise your dev server too (one lifecycle)
|
|
1789
2218
|
cfld login [--reauth] Authorize with Cloudflare in your browser
|
|
1790
|
-
cfld init
|
|
2219
|
+
cfld init Configure a persistent tunnel (don't run)
|
|
1791
2220
|
cfld list List all tunnels across projects/domains
|
|
1792
2221
|
cfld status [name] Show details for a tunnel
|
|
1793
2222
|
cfld up <name> Run a registered tunnel by name from anywhere
|
|
@@ -1801,11 +2230,17 @@ ${pc7.bold("Options")}
|
|
|
1801
2230
|
--zone <domain> Cloudflare domain to use (e.g. example.com)
|
|
1802
2231
|
--env <KEY> .env key to write the URL to (default: PUBLIC_URL)
|
|
1803
2232
|
--no-env Don't write the URL to .env
|
|
2233
|
+
--exec <cmd> Dev command to run + supervise (shell form of \`-- <cmd>\`)
|
|
1804
2234
|
--force Skip confirmation (destroy)
|
|
1805
2235
|
-h, --help Show this help
|
|
1806
2236
|
-v, --version Show version
|
|
2237
|
+
|
|
2238
|
+
${pc7.bold("Examples")}
|
|
2239
|
+
cfld 3000 -- next dev Start Next.js + the tunnel; one Ctrl-C stops both
|
|
2240
|
+
cfld 5173 --exec "npm run dev" Same, via a shell command string
|
|
1807
2241
|
`;
|
|
1808
|
-
async function main(
|
|
2242
|
+
async function main(rawArgv) {
|
|
2243
|
+
const { argv, afterDash } = splitAtDoubleDash(rawArgv);
|
|
1809
2244
|
const { values, positionals } = parseArgs({
|
|
1810
2245
|
args: argv,
|
|
1811
2246
|
allowPositionals: true,
|
|
@@ -1815,6 +2250,7 @@ async function main(argv) {
|
|
|
1815
2250
|
zone: { type: "string" },
|
|
1816
2251
|
env: { type: "string" },
|
|
1817
2252
|
"no-env": { type: "boolean" },
|
|
2253
|
+
exec: { type: "string" },
|
|
1818
2254
|
quick: { type: "boolean" },
|
|
1819
2255
|
force: { type: "boolean" },
|
|
1820
2256
|
reauth: { type: "boolean" },
|
|
@@ -1823,7 +2259,8 @@ async function main(argv) {
|
|
|
1823
2259
|
}
|
|
1824
2260
|
});
|
|
1825
2261
|
if (values.version) {
|
|
1826
|
-
process.stdout.write(
|
|
2262
|
+
process.stdout.write(`cfld ${readVersion()}
|
|
2263
|
+
`);
|
|
1827
2264
|
return;
|
|
1828
2265
|
}
|
|
1829
2266
|
if (values.help) {
|
|
@@ -1838,14 +2275,23 @@ async function main(argv) {
|
|
|
1838
2275
|
noEnv: values["no-env"],
|
|
1839
2276
|
quick: values.quick,
|
|
1840
2277
|
force: values.force,
|
|
1841
|
-
reauth: values.reauth
|
|
2278
|
+
reauth: values.reauth,
|
|
2279
|
+
exec: resolveExec(afterDash, values.exec)
|
|
1842
2280
|
};
|
|
1843
2281
|
const [first, ...rest] = positionals;
|
|
2282
|
+
if (flags.exec && first && SUBCOMMANDS.has(first)) {
|
|
2283
|
+
throw new Error(
|
|
2284
|
+
`A dev command (\`-- \u2026\` / --exec) can't be combined with \`cfld ${first}\`. Use \`cfld [port] -- <cmd>\`.`
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
1844
2287
|
if (first && SUBCOMMANDS.has(first)) {
|
|
1845
2288
|
switch (first) {
|
|
1846
2289
|
case "help":
|
|
1847
2290
|
process.stdout.write(HELP);
|
|
1848
2291
|
return;
|
|
2292
|
+
case "setup":
|
|
2293
|
+
await setupCommand(flags);
|
|
2294
|
+
return;
|
|
1849
2295
|
case "login":
|
|
1850
2296
|
await loginCommand(flags);
|
|
1851
2297
|
return;
|
|
@@ -1880,11 +2326,28 @@ async function main(argv) {
|
|
|
1880
2326
|
}
|
|
1881
2327
|
if (flags.quick) {
|
|
1882
2328
|
await quickCommand(flags);
|
|
2329
|
+
} else if (isBlankFirstRun(flags, process.cwd())) {
|
|
2330
|
+
await setupCommand(flags);
|
|
1883
2331
|
} else {
|
|
1884
2332
|
await runCommand(flags);
|
|
1885
2333
|
}
|
|
1886
2334
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2335
|
+
function invokedDirectly() {
|
|
2336
|
+
const entry = process.argv[1];
|
|
2337
|
+
if (!entry) return false;
|
|
2338
|
+
try {
|
|
2339
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
2340
|
+
} catch {
|
|
2341
|
+
return false;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (invokedDirectly()) {
|
|
2345
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
2346
|
+
printError(err);
|
|
2347
|
+
process.exit(1);
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
export {
|
|
2351
|
+
resolveExec,
|
|
2352
|
+
splitAtDoubleDash
|
|
2353
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/ui/dashboard.tsx
|
|
4
|
+
import "react";
|
|
5
|
+
import { Box, render, Text } from "ink";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
var MAX_BUFFER = 1e3;
|
|
8
|
+
var HEADER_ROWS = 5;
|
|
9
|
+
function statusDot(status) {
|
|
10
|
+
switch (status) {
|
|
11
|
+
case "live":
|
|
12
|
+
return /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF LIVE" });
|
|
13
|
+
case "waiting":
|
|
14
|
+
return /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CC WAITING" });
|
|
15
|
+
case "reconnecting":
|
|
16
|
+
return /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CC RECONNECTING" });
|
|
17
|
+
default:
|
|
18
|
+
return /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u25CC CONNECTING" });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function Header(state) {
|
|
22
|
+
const latency = state.avgLatencyMs !== void 0 ? ` \u26A1 ${state.avgLatencyMs}ms avg` : "";
|
|
23
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
|
|
24
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
25
|
+
statusDot(state.status),
|
|
26
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
27
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: state.url })
|
|
28
|
+
] }),
|
|
29
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
30
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
31
|
+
"\u2192 ",
|
|
32
|
+
state.target
|
|
33
|
+
] }),
|
|
34
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
35
|
+
" ",
|
|
36
|
+
state.connections,
|
|
37
|
+
" edge conns"
|
|
38
|
+
] })
|
|
39
|
+
] }),
|
|
40
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
41
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
42
|
+
"\u2191 ",
|
|
43
|
+
state.requests,
|
|
44
|
+
" reqs",
|
|
45
|
+
latency
|
|
46
|
+
] }),
|
|
47
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
48
|
+
" ",
|
|
49
|
+
state.ephemeral ? "ephemeral \xB7 Ctrl-C stops" : "Ctrl-C stops \xB7 URL preserved"
|
|
50
|
+
] })
|
|
51
|
+
] })
|
|
52
|
+
] });
|
|
53
|
+
}
|
|
54
|
+
function LogColumn({
|
|
55
|
+
title,
|
|
56
|
+
titleColor,
|
|
57
|
+
lines,
|
|
58
|
+
rows,
|
|
59
|
+
placeholder,
|
|
60
|
+
divider
|
|
61
|
+
}) {
|
|
62
|
+
return /* @__PURE__ */ jsxs(
|
|
63
|
+
Box,
|
|
64
|
+
{
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
flexGrow: 1,
|
|
67
|
+
flexShrink: 1,
|
|
68
|
+
flexBasis: 0,
|
|
69
|
+
paddingLeft: divider ? 1 : 0,
|
|
70
|
+
borderStyle: divider ? "single" : void 0,
|
|
71
|
+
borderColor: "gray",
|
|
72
|
+
borderTop: false,
|
|
73
|
+
borderBottom: false,
|
|
74
|
+
borderRight: false,
|
|
75
|
+
borderLeft: Boolean(divider),
|
|
76
|
+
children: [
|
|
77
|
+
title ? /* @__PURE__ */ jsx(Text, { color: titleColor, bold: true, wrap: "truncate-end", children: title }) : null,
|
|
78
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", overflow: "hidden", children: lines.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: placeholder }) : lines.slice(-rows).map((line, i) => /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: line }, i)) })
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function Dashboard(state) {
|
|
84
|
+
const termRows = process.stdout.rows || 24;
|
|
85
|
+
const titleRows = state.split ? 1 : 0;
|
|
86
|
+
const logRows = Math.max(3, termRows - HEADER_ROWS - titleRows - 1);
|
|
87
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: termRows, overflow: "hidden", children: [
|
|
88
|
+
/* @__PURE__ */ jsx(Header, { ...state }),
|
|
89
|
+
state.split ? /* @__PURE__ */ jsxs(Box, { flexGrow: 1, flexDirection: "row", paddingX: 1, paddingTop: 1, children: [
|
|
90
|
+
/* @__PURE__ */ jsx(
|
|
91
|
+
LogColumn,
|
|
92
|
+
{
|
|
93
|
+
title: state.devLabel ?? "dev server",
|
|
94
|
+
titleColor: "cyan",
|
|
95
|
+
lines: state.devLogs,
|
|
96
|
+
rows: logRows,
|
|
97
|
+
placeholder: "waiting for dev-server output\u2026"
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
/* @__PURE__ */ jsx(
|
|
101
|
+
LogColumn,
|
|
102
|
+
{
|
|
103
|
+
title: "tunnel",
|
|
104
|
+
titleColor: "blue",
|
|
105
|
+
lines: state.tunnelLogs,
|
|
106
|
+
rows: logRows,
|
|
107
|
+
placeholder: "waiting for tunnel activity\u2026",
|
|
108
|
+
divider: true
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
] }) : /* @__PURE__ */ jsx(Box, { flexGrow: 1, paddingX: 1, paddingTop: 1, children: /* @__PURE__ */ jsx(
|
|
112
|
+
LogColumn,
|
|
113
|
+
{
|
|
114
|
+
lines: state.tunnelLogs,
|
|
115
|
+
rows: logRows,
|
|
116
|
+
placeholder: state.ephemeral ? "waiting for traffic\u2026 (Ctrl-C stops; this URL is temporary)" : "waiting for traffic\u2026 (Ctrl-C stops; your URL is preserved)"
|
|
117
|
+
}
|
|
118
|
+
) })
|
|
119
|
+
] });
|
|
120
|
+
}
|
|
121
|
+
var ENTER_ALT_SCREEN = "\x1B[?1049h";
|
|
122
|
+
var LEAVE_ALT_SCREEN = "\x1B[?1049l";
|
|
123
|
+
function startDashboard(initial) {
|
|
124
|
+
const state = {
|
|
125
|
+
...initial,
|
|
126
|
+
tunnelLogs: [...initial.tunnelLogs],
|
|
127
|
+
devLogs: [...initial.devLogs]
|
|
128
|
+
};
|
|
129
|
+
process.stdout.write(ENTER_ALT_SCREEN);
|
|
130
|
+
const instance = render(/* @__PURE__ */ jsx(Dashboard, { ...state }), {
|
|
131
|
+
stdout: process.stdout,
|
|
132
|
+
// We drive graceful shutdown ourselves; ink's Ctrl-C simply resolves
|
|
133
|
+
// waitUntilExit, and the caller then stops the runner (preserving state).
|
|
134
|
+
exitOnCtrlC: true
|
|
135
|
+
});
|
|
136
|
+
const rerender = () => instance.rerender(/* @__PURE__ */ jsx(Dashboard, { ...state }));
|
|
137
|
+
let altActive = true;
|
|
138
|
+
const leaveAltScreen = () => {
|
|
139
|
+
if (!altActive) return;
|
|
140
|
+
altActive = false;
|
|
141
|
+
process.stdout.write(LEAVE_ALT_SCREEN);
|
|
142
|
+
};
|
|
143
|
+
const onResize = () => {
|
|
144
|
+
instance.clear();
|
|
145
|
+
rerender();
|
|
146
|
+
};
|
|
147
|
+
process.stdout.on("resize", onResize);
|
|
148
|
+
process.once("exit", leaveAltScreen);
|
|
149
|
+
return {
|
|
150
|
+
update(partial) {
|
|
151
|
+
Object.assign(state, partial);
|
|
152
|
+
rerender();
|
|
153
|
+
},
|
|
154
|
+
log(line, source = "tunnel") {
|
|
155
|
+
if (source === "dev") {
|
|
156
|
+
state.devLogs = [...state.devLogs, line].slice(-MAX_BUFFER);
|
|
157
|
+
} else {
|
|
158
|
+
state.tunnelLogs = [...state.tunnelLogs, line].slice(-MAX_BUFFER);
|
|
159
|
+
}
|
|
160
|
+
rerender();
|
|
161
|
+
},
|
|
162
|
+
waitUntilExit() {
|
|
163
|
+
return instance.waitUntilExit();
|
|
164
|
+
},
|
|
165
|
+
stop() {
|
|
166
|
+
process.stdout.off("resize", onResize);
|
|
167
|
+
process.removeListener("exit", leaveAltScreen);
|
|
168
|
+
instance.unmount();
|
|
169
|
+
leaveAltScreen();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export {
|
|
174
|
+
startDashboard
|
|
175
|
+
};
|
package/package.json
CHANGED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/ui/dashboard.tsx
|
|
4
|
-
import "react";
|
|
5
|
-
import { Box, render, Text } from "ink";
|
|
6
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
-
var MAX_LOGS = 12;
|
|
8
|
-
function statusDot(status) {
|
|
9
|
-
switch (status) {
|
|
10
|
-
case "live":
|
|
11
|
-
return /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF LIVE" });
|
|
12
|
-
case "reconnecting":
|
|
13
|
-
return /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CC RECONNECTING" });
|
|
14
|
-
default:
|
|
15
|
-
return /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u25CC CONNECTING" });
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function Dashboard(state) {
|
|
19
|
-
const latency = state.avgLatencyMs !== void 0 ? ` \u26A1 ${state.avgLatencyMs}ms avg` : "";
|
|
20
|
-
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
21
|
-
/* @__PURE__ */ jsxs(
|
|
22
|
-
Box,
|
|
23
|
-
{
|
|
24
|
-
flexDirection: "column",
|
|
25
|
-
borderStyle: "round",
|
|
26
|
-
borderColor: "cyan",
|
|
27
|
-
paddingX: 1,
|
|
28
|
-
children: [
|
|
29
|
-
/* @__PURE__ */ jsxs(Box, { children: [
|
|
30
|
-
statusDot(state.status),
|
|
31
|
-
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
32
|
-
/* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: state.url })
|
|
33
|
-
] }),
|
|
34
|
-
/* @__PURE__ */ jsxs(Box, { children: [
|
|
35
|
-
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
36
|
-
"\u2192 ",
|
|
37
|
-
state.target
|
|
38
|
-
] }),
|
|
39
|
-
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
40
|
-
" ",
|
|
41
|
-
state.connections,
|
|
42
|
-
" edge conns"
|
|
43
|
-
] })
|
|
44
|
-
] }),
|
|
45
|
-
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
46
|
-
"\u2191 ",
|
|
47
|
-
state.requests,
|
|
48
|
-
" reqs",
|
|
49
|
-
latency
|
|
50
|
-
] }) })
|
|
51
|
-
]
|
|
52
|
-
}
|
|
53
|
-
),
|
|
54
|
-
/* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, children: state.logs.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: state.ephemeral ? "waiting for traffic\u2026 (Ctrl-C stops; this URL is temporary)" : "waiting for traffic\u2026 (Ctrl-C stops; your URL is preserved)" }) : state.logs.map((line, i) => /* @__PURE__ */ jsx(Text, { dimColor: true, wrap: "truncate-end", children: line }, i)) })
|
|
55
|
-
] });
|
|
56
|
-
}
|
|
57
|
-
function startDashboard(initial) {
|
|
58
|
-
const state = { ...initial, logs: [...initial.logs] };
|
|
59
|
-
const instance = render(/* @__PURE__ */ jsx(Dashboard, { ...state }), {
|
|
60
|
-
stdout: process.stdout,
|
|
61
|
-
// We drive graceful shutdown ourselves; ink's Ctrl-C simply resolves
|
|
62
|
-
// waitUntilExit, and the caller then stops the runner (preserving state).
|
|
63
|
-
exitOnCtrlC: true
|
|
64
|
-
});
|
|
65
|
-
const rerender = () => instance.rerender(/* @__PURE__ */ jsx(Dashboard, { ...state }));
|
|
66
|
-
return {
|
|
67
|
-
update(partial) {
|
|
68
|
-
Object.assign(state, partial);
|
|
69
|
-
rerender();
|
|
70
|
-
},
|
|
71
|
-
log(line) {
|
|
72
|
-
state.logs = [...state.logs, line].slice(-MAX_LOGS);
|
|
73
|
-
rerender();
|
|
74
|
-
},
|
|
75
|
-
waitUntilExit() {
|
|
76
|
-
return instance.waitUntilExit();
|
|
77
|
-
},
|
|
78
|
-
stop() {
|
|
79
|
-
instance.unmount();
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
export {
|
|
84
|
-
startDashboard
|
|
85
|
-
};
|