@cliftonc/cfld 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,6 +113,60 @@ cfld service uninstall # stop + remove (tunnel + DNS preserved)
113
113
 
114
114
  Windows service management is not supported — run `cfld` in the foreground there.
115
115
 
116
+ ## Running alongside your dev server
117
+
118
+ Give `cfld` your dev command after a `--` and it runs both as one managed unit:
119
+
120
+ ```bash
121
+ cfld 3000 -- next dev
122
+ ```
123
+
124
+ `cfld` will:
125
+
126
+ - **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);
127
+ - **wait for `:3000` to actually listen** before flipping to `● LIVE`, so the URL never serves a 502 the moment you reveal it;
128
+ - **stream your dev server's logs** into the same dashboard as the tunnel — one unified view;
129
+ - **stop both on a single Ctrl-C**, gracefully, leaving the tunnel + DNS intact so the next run reuses the same URL;
130
+ - **exit if your dev server exits**, propagating its exit code (the tunnel is still preserved).
131
+
132
+ The pass-through form (`-- <cmd>`) runs your command directly. If you need shell features (pipes, `&&`, globs), use the string form instead:
133
+
134
+ ```bash
135
+ cfld 5173 --exec "npm run dev"
136
+ ```
137
+
138
+ This works with `--quick` too, when you don't have a domain set up yet:
139
+
140
+ ```bash
141
+ cfld --quick 3000 -- next dev
142
+ ```
143
+
144
+ Wire it into `package.json` so the whole team gets the same public URL:
145
+
146
+ ```jsonc
147
+ {
148
+ "scripts": {
149
+ "dev": "next dev",
150
+ "dev:tunnel": "cfld 3000 -- next dev"
151
+ }
152
+ }
153
+ ```
154
+
155
+ > **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`.
156
+
157
+ <details>
158
+ <summary>Prefer to keep the two processes separate?</summary>
159
+
160
+ `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):
161
+
162
+ ```jsonc
163
+ "dev:tunnel": "concurrently -k -n app,cfld -c blue,magenta \"npm:dev\" \"cfld 3000\""
164
+ ```
165
+
166
+ or plain shell: `cfld 3000 & next dev`.
167
+
168
+ </details>
169
+
116
170
  ## Development
117
171
 
118
172
  ```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]}.`);
@@ -1770,6 +2036,30 @@ async function doctorCommand() {
1770
2036
  }
1771
2037
 
1772
2038
  // src/cli.ts
2039
+ function readVersion() {
2040
+ try {
2041
+ const here = dirname(fileURLToPath(import.meta.url));
2042
+ const pkg = JSON.parse(readFileSync7(join7(here, "../package.json"), "utf8"));
2043
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
2044
+ } catch {
2045
+ return "0.0.0";
2046
+ }
2047
+ }
2048
+ function splitAtDoubleDash(rawArgv) {
2049
+ const i = rawArgv.indexOf("--");
2050
+ if (i === -1) return { argv: rawArgv, afterDash: [] };
2051
+ return { argv: rawArgv.slice(0, i), afterDash: rawArgv.slice(i + 1) };
2052
+ }
2053
+ function resolveExec(afterDash, execFlag) {
2054
+ const hasArgv = afterDash.length > 0;
2055
+ const hasShell = execFlag !== void 0 && execFlag.trim() !== "";
2056
+ if (hasArgv && hasShell) {
2057
+ throw new Error('Use either `-- <cmd>` or `--exec "<cmd>"`, not both.');
2058
+ }
2059
+ if (hasArgv) return { shell: false, argv: afterDash };
2060
+ if (hasShell) return { shell: true, command: execFlag.trim() };
2061
+ return void 0;
2062
+ }
1773
2063
  var SUBCOMMANDS = /* @__PURE__ */ new Set([
1774
2064
  "login",
1775
2065
  "init",
@@ -1785,6 +2075,7 @@ var HELP = `${pc7.bold("cfld")} \u2014 persistent Cloudflare tunnels for local d
1785
2075
 
1786
2076
  ${pc7.bold("Usage")}
1787
2077
  cfld [port] Ensure + run a persistent tunnel (reuses the same URL)
2078
+ cfld [port] -- <cmd> Run + supervise your dev server too (one lifecycle)
1788
2079
  cfld --quick [port] Ephemeral trycloudflare.com URL (no domain needed)
1789
2080
  cfld login [--reauth] Authorize with Cloudflare in your browser
1790
2081
  cfld init Interactive setup wizard (configure, don't run)
@@ -1801,11 +2092,17 @@ ${pc7.bold("Options")}
1801
2092
  --zone <domain> Cloudflare domain to use (e.g. example.com)
1802
2093
  --env <KEY> .env key to write the URL to (default: PUBLIC_URL)
1803
2094
  --no-env Don't write the URL to .env
2095
+ --exec <cmd> Dev command to run + supervise (shell form of \`-- <cmd>\`)
1804
2096
  --force Skip confirmation (destroy)
1805
2097
  -h, --help Show this help
1806
2098
  -v, --version Show version
2099
+
2100
+ ${pc7.bold("Examples")}
2101
+ cfld 3000 -- next dev Start Next.js + the tunnel; one Ctrl-C stops both
2102
+ cfld 5173 --exec "npm run dev" Same, via a shell command string
1807
2103
  `;
1808
- async function main(argv) {
2104
+ async function main(rawArgv) {
2105
+ const { argv, afterDash } = splitAtDoubleDash(rawArgv);
1809
2106
  const { values, positionals } = parseArgs({
1810
2107
  args: argv,
1811
2108
  allowPositionals: true,
@@ -1815,6 +2112,7 @@ async function main(argv) {
1815
2112
  zone: { type: "string" },
1816
2113
  env: { type: "string" },
1817
2114
  "no-env": { type: "boolean" },
2115
+ exec: { type: "string" },
1818
2116
  quick: { type: "boolean" },
1819
2117
  force: { type: "boolean" },
1820
2118
  reauth: { type: "boolean" },
@@ -1823,7 +2121,8 @@ async function main(argv) {
1823
2121
  }
1824
2122
  });
1825
2123
  if (values.version) {
1826
- process.stdout.write("cfld 0.1.0\n");
2124
+ process.stdout.write(`cfld ${readVersion()}
2125
+ `);
1827
2126
  return;
1828
2127
  }
1829
2128
  if (values.help) {
@@ -1838,9 +2137,15 @@ async function main(argv) {
1838
2137
  noEnv: values["no-env"],
1839
2138
  quick: values.quick,
1840
2139
  force: values.force,
1841
- reauth: values.reauth
2140
+ reauth: values.reauth,
2141
+ exec: resolveExec(afterDash, values.exec)
1842
2142
  };
1843
2143
  const [first, ...rest] = positionals;
2144
+ if (flags.exec && first && SUBCOMMANDS.has(first)) {
2145
+ throw new Error(
2146
+ `A dev command (\`-- \u2026\` / --exec) can't be combined with \`cfld ${first}\`. Use \`cfld [port] -- <cmd>\`.`
2147
+ );
2148
+ }
1844
2149
  if (first && SUBCOMMANDS.has(first)) {
1845
2150
  switch (first) {
1846
2151
  case "help":
@@ -1884,7 +2189,22 @@ async function main(argv) {
1884
2189
  await runCommand(flags);
1885
2190
  }
1886
2191
  }
1887
- main(process.argv.slice(2)).catch((err) => {
1888
- printError(err);
1889
- process.exit(1);
1890
- });
2192
+ function invokedDirectly() {
2193
+ const entry = process.argv[1];
2194
+ if (!entry) return false;
2195
+ try {
2196
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
2197
+ } catch {
2198
+ return false;
2199
+ }
2200
+ }
2201
+ if (invokedDirectly()) {
2202
+ main(process.argv.slice(2)).catch((err) => {
2203
+ printError(err);
2204
+ process.exit(1);
2205
+ });
2206
+ }
2207
+ export {
2208
+ resolveExec,
2209
+ splitAtDoubleDash
2210
+ };
@@ -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.0",
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
- };