@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 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
- # one-off, no install
9
- npx @cliftonc/cfld # detect your dev port, reuse-or-create a tunnel, go live
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` | Interactive setup wizard — configure a project without running it. |
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 sleep(intervalMs);
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 sleep(ms) {
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 spawn3 } from "child_process";
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 = spawn3(bin, ["tunnel", "login"], { stdio: "inherit" });
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, { cwd, envKey, noEnv: flags.noEnv, port: primary.port });
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-QUPY25QT.js");
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
- logs: []
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
- let stopping = false;
1037
- const runner = await startRunner({
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
- process.exit(1);
1240
+ shutdown({ code: 1 });
1051
1241
  }
1052
1242
  });
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
- };
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("SIGINT", () => stop("Stopping \u2014 your URL is preserved. Re-run `cfld`."));
1064
- process.once("SIGTERM", () => stop("Stopping \u2014 your URL is preserved."));
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
- runner.stop();
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
- const poll = setInterval(async () => {
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
- clearInterval(poll);
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 spawn4 } from "child_process";
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 = spawn4(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
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
- const logBuffer = [];
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
- logBuffer.push(formatted);
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 tunnel = await startQuickTunnel(port, { metricsPort, onLine: emitLine });
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-QUPY25QT.js");
1464
+ const { startDashboard } = await import("./dashboard-Q6WI32AK.js");
1211
1465
  dashboard = startDashboard({
1212
- url: tunnel.url,
1466
+ url,
1213
1467
  target,
1214
1468
  tunnelName: "quick (ephemeral)",
1215
- status: "live",
1216
- connections: 1,
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
- logs: logBuffer.slice(-12),
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
- } else {
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: tunnel.url,
1493
+ url,
1224
1494
  target,
1225
1495
  tunnelName: "quick (ephemeral)",
1226
1496
  connections: 1,
1227
1497
  ephemeral: true
1228
1498
  });
1229
1499
  }
1230
- const poll = setInterval(async () => {
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
- stop();
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
- note("Run `cfld` to go live.");
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 --quick [port] Ephemeral trycloudflare.com URL (no domain needed)
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 Interactive setup wizard (configure, don't run)
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(argv) {
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("cfld 0.1.0\n");
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
- main(process.argv.slice(2)).catch((err) => {
1888
- printError(err);
1889
- process.exit(1);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliftonc/cfld",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Zero-token, persistent Cloudflare tunnel CLI for local dev — same public HTTPS URL every run.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- };