@iamken/cloudtunnel 0.1.0 → 0.1.2

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/dist/index.js CHANGED
@@ -358,12 +358,21 @@ function upsertEntry(fqdn, patch) {
358
358
  const prev = reg[fqdn];
359
359
  reg[fqdn] = {
360
360
  createdAt: prev?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
361
+ index: prev?.index ?? nextIndex(reg),
361
362
  state: "provisioning",
362
363
  ...prev,
363
364
  ...patch
364
365
  };
365
366
  });
366
367
  }
368
+ function nextIndex(reg) {
369
+ const used = new Set(
370
+ Object.values(reg).map((e) => e.index).filter((n) => typeof n === "number")
371
+ );
372
+ let i = 1;
373
+ while (used.has(i)) i++;
374
+ return i;
375
+ }
367
376
  function patchEntry(fqdn, patch) {
368
377
  return mutateRegistry((reg) => {
369
378
  const prev = reg[fqdn];
@@ -476,6 +485,21 @@ async function getTunnel(cf, id) {
476
485
  async function deleteTunnel(cf, id) {
477
486
  await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}`);
478
487
  }
488
+ async function cleanupConnections(cf, id) {
489
+ await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`);
490
+ }
491
+ async function deleteTunnelWithConnections(cf, id) {
492
+ try {
493
+ await deleteTunnel(cf, id);
494
+ } catch (err) {
495
+ if (err instanceof CliError && /active connections/i.test(err.message)) {
496
+ await cleanupConnections(cf, id);
497
+ await deleteTunnel(cf, id);
498
+ } else {
499
+ throw err;
500
+ }
501
+ }
502
+ }
479
503
  async function getTunnelToken(cf, id) {
480
504
  return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}/token`)).result;
481
505
  }
@@ -590,21 +614,13 @@ async function createTunnelSubdomain(cf, opts) {
590
614
  const zone = await resolveZone(cf.token, host.zone);
591
615
  const existing = await findCname(cf.token, zone.id, host.hostname);
592
616
  if (existing) {
593
- if (isManagedDns(existing) && !opts.force) {
594
- const tunnelId2 = tunnelIdFromCname(existing.content);
595
- const token = await getTunnelToken(cf, tunnelId2);
596
- await putIngress(cf, tunnelId2, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto }));
597
- await recordRunning(host, zone.id, tunnelId2, existing.id, opts);
598
- say.dim(`Re-attaching to existing tunnel for ${host.hostname}.`);
599
- return { host, tunnelId: tunnelId2, token, adopted: true };
600
- }
601
- if (!opts.force) {
602
- throw new CliError(`${host.hostname} is already taken by a record not managed by cloudtunnel.`, {
603
- hint: "pick another --subdomain/--hostname, or pass -f/--force to take it over"
617
+ const isTunnelRecord = existing.content.endsWith(".cfargotunnel.com");
618
+ if (!isTunnelRecord && !opts.force) {
619
+ throw new CliError(`${host.hostname} is taken by a non-tunnel DNS record.`, {
620
+ hint: "pick another --subdomain/--hostname, or pass -f/--force to replace it"
604
621
  });
605
622
  }
606
623
  await releaseHostname(cf, zone.id, existing);
607
- say.dim(`Released ${host.hostname} (--force) \u2014 recreating.`);
608
624
  }
609
625
  await upsertEntry(host.hostname, {
610
626
  subdomain: host.subdomain,
@@ -625,7 +641,7 @@ async function createTunnelSubdomain(cf, opts) {
625
641
  const record = await createCname(cf.token, zone.id, host.hostname, tunnelId);
626
642
  dnsRecordId = record.id;
627
643
  await recordRunning(host, zone.id, tunnelId, dnsRecordId, opts);
628
- return { host, tunnelId, token, adopted: false };
644
+ return { host, tunnelId, token };
629
645
  } catch (err) {
630
646
  const clean = await rollback(cf, zone.id, tunnelId, dnsRecordId, host.hostname);
631
647
  if (clean) await removeEntry(host.hostname);
@@ -651,7 +667,7 @@ async function releaseHostname(cf, zoneId, record) {
651
667
  const oldTunnelId = tunnelIdFromCname(record.content);
652
668
  try {
653
669
  const tunnel = await getTunnel(cf, oldTunnelId);
654
- if (isManagedTunnel(tunnel)) await deleteTunnel(cf, oldTunnelId);
670
+ if (isManagedTunnel(tunnel)) await deleteTunnelWithConnections(cf, oldTunnelId);
655
671
  } catch {
656
672
  }
657
673
  }
@@ -664,7 +680,7 @@ async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
664
680
  await deleteDnsRecord(cf.token, zoneId, dnsRecordId);
665
681
  } catch {
666
682
  clean = false;
667
- say.warn(`Left a DNS record behind for ${hostname} (${dnsRecordId}) \u2014 run \`cloudtunnel rm --force ${hostname}\`.`);
683
+ say.warn(`Left a DNS record behind for ${hostname} (${dnsRecordId}).`);
668
684
  }
669
685
  }
670
686
  if (tunnelId) {
@@ -672,7 +688,7 @@ async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
672
688
  await deleteTunnel(cf, tunnelId);
673
689
  } catch {
674
690
  clean = false;
675
- say.warn(`Left tunnel ${tunnelId} behind \u2014 run \`cloudtunnel gc\`.`);
691
+ say.warn(`Left tunnel ${tunnelId} behind \u2014 remove it with \`cloudtunnel down ${hostname}\`.`);
676
692
  }
677
693
  }
678
694
  return clean;
@@ -684,29 +700,37 @@ var isNotFound = (err) => err instanceof CliError && err.status === 404;
684
700
  var zoneFromFqdn = (fqdn) => fqdn.slice(fqdn.indexOf(".") + 1);
685
701
  function resolveTarget(target) {
686
702
  if (target.includes(".")) return { fqdn: target, entry: getEntry(target) };
687
- const matches = listEntries().filter((e) => e.subdomain === target);
703
+ const entries = listEntries();
704
+ if (/^\d+$/.test(target)) {
705
+ const byIndex = entries.find((e) => e.index === Number(target));
706
+ if (byIndex) return { fqdn: `${byIndex.subdomain}.${byIndex.zone}`, entry: byIndex };
707
+ }
708
+ const byId = entries.filter((e) => e.tunnelId?.startsWith(target));
709
+ const matches = byId.length > 0 ? byId : entries.filter((e) => e.subdomain === target);
688
710
  if (matches.length > 1) {
689
- throw new CliError(`"${target}" matches multiple zones.`, {
690
- hint: `use the full hostname: ${matches.map((m) => `${m.subdomain}.${m.zone}`).join(", ")}`
711
+ throw new CliError(`"${target}" matches multiple subdomains.`, {
712
+ hint: `use a full hostname or a longer id: ${matches.map((m) => `${m.subdomain}.${m.zone}`).join(", ")}`
691
713
  });
692
714
  }
693
715
  const entry = matches[0];
694
- if (!entry) throw new CliError(`No tracked subdomain named "${target}".`, { hint: "pass a full hostname" });
716
+ if (!entry) {
717
+ throw new CliError(`No tracked subdomain matching "${target}".`, { hint: "see `cloudtunnel ls` for the #, name, or id" });
718
+ }
695
719
  return { fqdn: `${entry.subdomain}.${entry.zone}`, entry };
696
720
  }
697
721
  async function removeTunnelSubdomain(cf, target, opts = {}) {
698
722
  const { fqdn, entry } = resolveTarget(target);
699
723
  if (!entry && !opts.force) {
700
- throw new CliError(`${fqdn} is not managed by cloudtunnel.`, { hint: "pass --force to delete it anyway" });
724
+ throw new CliError(`${fqdn} is not managed by cloudtunnel.`, { hint: "pass --force to release it anyway" });
701
725
  }
702
726
  const zoneId = entry?.zoneId ?? (await resolveZone(cf.token, zoneFromFqdn(fqdn))).id;
703
727
  const record = await findCname(cf.token, zoneId, fqdn);
704
728
  if (record && !isManagedDns(record) && !opts.force) {
705
- throw new CliError(`${fqdn} points to a record not managed by cloudtunnel.`, { hint: "pass --force to delete it" });
729
+ throw new CliError(`${fqdn} points to a record not managed by cloudtunnel.`, { hint: "pass --force to release it" });
706
730
  }
707
731
  const tunnelId = record ? tunnelIdFromCname2(record.content) : entry?.tunnelId;
708
732
  if (opts.dryRun) {
709
- say.info(`Would delete: tunnel ${tunnelId ?? "(none)"}${record && !opts.keepDns ? `, DNS ${record.id}` : ""}`);
733
+ say.info(`Would release: tunnel ${tunnelId ?? "(none)"}${record ? `, DNS ${record.id}` : ""}`);
710
734
  return;
711
735
  }
712
736
  if (entry) await stopConnector(entry);
@@ -722,13 +746,13 @@ async function removeTunnelSubdomain(cf, target, opts = {}) {
722
746
  }
723
747
  if (tunnel) {
724
748
  try {
725
- await deleteTunnel(cf, tunnelId);
749
+ await deleteTunnelWithConnections(cf, tunnelId);
726
750
  } catch (err) {
727
751
  if (!isNotFound(err)) throw err;
728
752
  }
729
753
  }
730
754
  }
731
- if (record && !opts.keepDns) {
755
+ if (record) {
732
756
  try {
733
757
  await deleteDnsRecord(cf.token, zoneId, record.id);
734
758
  } catch (err) {
@@ -736,26 +760,22 @@ async function removeTunnelSubdomain(cf, target, opts = {}) {
736
760
  }
737
761
  }
738
762
  await removeEntry(fqdn);
739
- say.ok(`Removed ${fqdn}`);
740
- }
741
- async function updateIngress(cf, target, port, proto) {
742
- const { fqdn, entry } = resolveTarget(target);
743
- if (!entry?.tunnelId) throw new CliError(`No tracked tunnel for ${fqdn}.`);
744
- const nextProto = proto ?? entry.proto;
745
- await putIngress(cf, entry.tunnelId, buildIngress({ hostname: fqdn, port, proto: nextProto }));
746
- await patchEntry(fqdn, { port, proto: nextProto });
747
- say.ok(`${fqdn} now points to ${nextProto}://localhost:${port} (no restart needed)`);
763
+ if (!opts.quiet) say.ok(`Released ${fqdn}`);
748
764
  }
749
765
  async function listAll(cf, opts = {}) {
750
766
  const entries = await reconcile();
751
767
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
752
- const rows = entries.map((e) => ({
753
- hostname: `${e.subdomain}.${e.zone}`,
754
- zone: e.zone,
755
- port: `${e.proto}://localhost:${e.port}`,
756
- state: e.tunnelId && !tunnels.has(e.tunnelId) ? "dangling" : e.state,
757
- managed: true
758
- }));
768
+ const rows = entries.map((e) => {
769
+ const gone = e.tunnelId ? !tunnels.has(e.tunnelId) : false;
770
+ return {
771
+ num: e.index ? String(e.index) : "-",
772
+ hostname: `${e.subdomain}.${e.zone}`,
773
+ port: `${e.proto}://localhost:${e.port}`,
774
+ state: !gone && e.state === "running" ? "up" : "down",
775
+ pid: e.state === "running" && e.pid ? String(e.pid) : "-",
776
+ managed: true
777
+ };
778
+ });
759
779
  if (opts.all) {
760
780
  const { listCargoCnames } = await import("./dns-PAPFSYFP.js");
761
781
  const { listZones: listZones3 } = await import("./zones-YNGQYXAF.js");
@@ -763,7 +783,7 @@ async function listAll(cf, opts = {}) {
763
783
  for (const zone of await listZones3(cf.token)) {
764
784
  for (const rec of await listCargoCnames(cf.token, zone.id)) {
765
785
  if (!tracked.has(rec.name)) {
766
- rows.push({ hostname: rec.name, zone: zone.name, port: "-", state: "unmanaged", managed: false });
786
+ rows.push({ num: "-", hostname: rec.name, port: "-", state: "unmanaged", pid: "-", managed: false });
767
787
  }
768
788
  }
769
789
  }
@@ -779,19 +799,27 @@ function parsePort(port) {
779
799
  }
780
800
  return n;
781
801
  }
782
- async function resolveDomain(token, explicit, saved) {
802
+ async function resolveDomain(cf, opts, creds) {
803
+ if (opts.hostname) return void 0;
804
+ const explicit = opts.domain ?? opts.zone;
783
805
  if (explicit) return explicit;
784
- if (saved) return saved;
785
- const zones = await listZones(token);
806
+ const zones = await listZones(cf.token);
786
807
  if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
787
808
  if (zones.length === 1) return zones[0].name;
788
- if (!process.stdin.isTTY) {
789
- throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>, e.g. -d example.com" });
809
+ if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
810
+ if (creds.defaultZone) return creds.defaultZone;
811
+ throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
812
+ }
813
+ async function resolveSubdomain(opts) {
814
+ const explicit = opts.subdomain ?? opts.name;
815
+ if (explicit || opts.hostname) return explicit;
816
+ if (!process.stdin.isTTY) return void 0;
817
+ const input = await clack2.text({ message: "Subdomain", placeholder: "leave blank for a random name" });
818
+ if (clack2.isCancel(input)) {
819
+ clack2.cancel("Cancelled.");
820
+ process.exit(130);
790
821
  }
791
- const chosen = await selectOne("Choose a domain", zones, (z) => z.name);
792
- saveConfig({ ...loadConfig(), defaultZone: chosen.name });
793
- say.dim(`Saved ${chosen.name} as your default domain (change it with \`cloudtunnel login --zone <domain>\`).`);
794
- return chosen.name;
822
+ return input.trim() || void 0;
795
823
  }
796
824
  function showLogTail(logFile) {
797
825
  try {
@@ -805,9 +833,9 @@ async function runUp(portArg, opts) {
805
833
  const creds = await ensureAuth();
806
834
  const cf = resolveCf();
807
835
  const bin = await ensureCloudflared();
808
- const subdomain = opts.subdomain ?? opts.name;
809
- const domain = opts.hostname ? void 0 : await resolveDomain(cf.token, opts.domain ?? opts.zone, creds.defaultZone);
810
836
  if (process.stdout.isTTY) clack2.intro("cloudtunnel");
837
+ const domain = await resolveDomain(cf, opts, creds);
838
+ const subdomain = await resolveSubdomain(opts);
811
839
  const spin = clack2.spinner();
812
840
  let spinnerActive = true;
813
841
  const stopSpin = (msg) => {
@@ -849,14 +877,8 @@ async function runUp(portArg, opts) {
849
877
  controller.abort();
850
878
  stopSpin("Stopping\u2026");
851
879
  try {
852
- const entry = getEntry(fqdn);
853
- if (entry) await stopConnector(entry);
854
- if (opts.ephemeral) {
855
- await removeTunnelSubdomain(cf, fqdn, { force: true });
856
- clack2.outro(`Stopped \xB7 ${fqdn} deleted`);
857
- } else {
858
- clack2.outro(`Stopped \xB7 ${fqdn} kept \u2014 re-attach: cloudtunnel ${port} -s ${result.host.subdomain}`);
859
- }
880
+ await removeTunnelSubdomain(cf, fqdn, { force: true, quiet: true });
881
+ clack2.outro(`Stopped \xB7 ${fqdn} released`);
860
882
  } catch (err) {
861
883
  reportError(err);
862
884
  } finally {
@@ -884,19 +906,19 @@ async function runUp(portArg, opts) {
884
906
  if (health === "healthy") {
885
907
  stopSpin("Connected");
886
908
  clack2.note(`${formatRoute(fqdn, target)}
887
- ${dim("Ctrl-C stops the connector \u2014 the subdomain is kept")}`, "Live");
909
+ ${dim("Ctrl-C stops and releases this subdomain")}`, "Live");
888
910
  } else if (health === "provisioning") {
889
911
  stopSpin("Provisioning");
890
912
  say.warn(`${fqdn} is not healthy yet \u2014 it should be live shortly.`);
891
913
  }
892
914
  }
893
915
  function registerUp(program) {
894
- program.command("up").argument("<port>", "local port to expose (e.g. 3000)").description("Expose a local port at an HTTPS subdomain (also: `cloudtunnel <port>`)").option("-s, --subdomain <name>", "subdomain label (default: a friendly random slug)").option("-d, --domain <domain>", "domain to create the subdomain under (default: your default; picks interactively if unset)").option("--name <name>", "alias of --subdomain").option("--zone <domain>", "alias of --domain").option("--hostname <fqdn>", "full hostname override (instead of --subdomain + --domain)").option("--detach", "run the connector in the background").option("--ephemeral", "delete the tunnel + DNS on exit (nport-style; default keeps them)").option("-f, --force", "take over a subdomain already occupied by another record").option("--proto <proto>", "local service protocol: http | https", "http").action((port, opts) => runUp(port, opts));
916
+ program.command("up").argument("<port>", "local port to expose (e.g. 3000)").description("Expose a local port at an HTTPS subdomain (also: `cloudtunnel <port>`)").option("-s, --subdomain <name>", "subdomain label (prompted, or random if left blank)").option("-d, --domain <domain>", "domain to create the subdomain under (prompted from a list if unset)").option("--name <name>", "alias of --subdomain").option("--zone <domain>", "alias of --domain").option("--hostname <fqdn>", "full hostname override (instead of --subdomain + --domain)").option("--detach", "run the connector in the background").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("--proto <proto>", "local service protocol: http | https", "http").action((port, opts) => runUp(port, opts));
895
917
  }
896
918
 
897
919
  // src/commands/ls.ts
898
920
  function registerLs(program) {
899
- program.command("ls").description("List tunnel subdomains (managed by default; --all scans the whole account)").option("--all", "scan every zone in the account (slower; shows unmanaged tunnels too)").action(async (opts) => {
921
+ program.command("ls").alias("ps").description("List tunnel subdomains (managed by default; --all scans the whole account)").option("--all", "scan every zone in the account (slower; shows unmanaged tunnels too)").action(async (opts) => {
900
922
  await ensureAuth();
901
923
  const cf = resolveCf();
902
924
  const rows = await listAll(cf, { all: opts.all });
@@ -905,102 +927,34 @@ function registerLs(program) {
905
927
  return;
906
928
  }
907
929
  printTable(
908
- ["SUBDOMAIN", "ZONE", "TARGET", "STATE"],
909
- rows.map((r) => [r.hostname, r.zone, r.port, r.state])
930
+ ["#", "SUBDOMAIN", "TARGET", "STATE", "PID"],
931
+ rows.map((r) => [r.num, r.hostname, r.port, r.state, r.pid])
910
932
  );
911
933
  });
912
934
  }
913
935
 
914
- // src/commands/rm.ts
915
- function registerRm(program) {
916
- program.command("rm").argument("<target>", "subdomain name or full hostname to delete").description("Delete a tunnel subdomain (stops connector, removes tunnel + DNS)").option("--force", "allow deleting a resource not created by cloudtunnel").option("--dry-run", "show what would be deleted without deleting").option("--keep-dns", "delete the tunnel but leave the DNS record").action(async (target, opts) => {
917
- await ensureAuth();
918
- const cf = resolveCf();
919
- await removeTunnelSubdomain(cf, target, opts);
920
- });
921
- }
922
-
923
- // src/commands/update.ts
924
- function registerUpdate(program) {
925
- program.command("update").argument("<name>", "subdomain name or full hostname to update").description("Change the local port/protocol a subdomain points to (zero-downtime)").option("--port <port>", "new local port").option("--proto <proto>", "new local protocol: http | https").action(async (name, opts) => {
926
- if (!opts.port) throw new CliError("--port is required", { hint: "e.g. `cloudtunnel update myapp --port 8080`" });
927
- const port = Number(opts.port);
928
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError(`Invalid port: ${opts.port}`);
929
- await ensureAuth();
930
- const cf = resolveCf();
931
- await updateIngress(cf, name, port, opts.proto);
932
- });
933
- }
934
-
935
- // src/commands/status.ts
936
- function registerStatus(program) {
937
- program.command("status").argument("<name>", "subdomain name or full hostname").description("Show tunnel health and connector state for a subdomain").action(async (name) => {
938
- await ensureAuth();
939
- const cf = resolveCf();
940
- const { fqdn, entry } = resolveTarget(name);
941
- if (!entry?.tunnelId) throw new CliError(`No tracked tunnel for ${fqdn}.`);
942
- const connections = await getConnections(cf, entry.tunnelId);
943
- const connectorAlive = await isOurConnector(entry);
944
- say.info(`Host: https://${fqdn}`);
945
- say.info(`Tunnel: ${entry.tunnelId} \u2014 ${connections.length} edge connection(s)`);
946
- say.info(`Connector: ${connectorAlive ? `running (pid ${entry.pid})` : "stopped"}`);
947
- say.info(`Target: ${entry.proto}://localhost:${entry.port}`);
948
- });
949
- }
950
-
951
936
  // src/commands/down.ts
952
- async function stopEntry(entry) {
953
- const stopped = await stopConnector(entry);
954
- await mutateRegistry((reg) => {
955
- const e = reg[`${entry.subdomain}.${entry.zone}`];
956
- if (e) {
957
- e.state = "stopped";
958
- delete e.pid;
959
- }
960
- });
961
- return stopped;
962
- }
963
937
  function registerDown(program) {
964
- program.command("down").argument("[name]", "subdomain to stop (omit with --all to stop everything)").description("Stop a running connector, leaving the tunnel + DNS intact").option("--all", "stop all running connectors").action(async (name, opts) => {
965
- if (opts.all) {
966
- const running = listEntries().filter((e) => e.pid);
967
- let stopped = 0;
968
- for (const entry2 of running) if (await stopEntry(entry2)) stopped++;
969
- say.ok(`Stopped ${stopped} connector(s).`);
970
- return;
971
- }
972
- if (!name) throw new CliError("Pass a subdomain name or --all.");
973
- const { fqdn, entry } = resolveTarget(name);
974
- if (!entry) throw new CliError(`No tracked subdomain for ${fqdn}.`);
975
- await stopEntry(entry);
976
- say.ok(`Stopped ${fqdn}.`);
977
- });
978
- }
979
-
980
- // src/commands/gc.ts
981
- function registerGc(program) {
982
- program.command("gc").description("Prune crash orphans (provisioning/orphaned entries) after confirmation").option("--yes", "skip the confirmation prompt").action(async (opts) => {
938
+ program.command("down").aliases(["rm", "remove", "delete", "stop"]).argument("[target]", "subdomain name / hostname / id / # to release (omit with --all)").description("Stop and release a subdomain \u2014 removes the tunnel + DNS on Cloudflare").option("--all", "release every tracked subdomain").option("-f, --force", "release even a resource not created by cloudtunnel").option("--dry-run", "show what would be released without doing it").action(async (target, opts) => {
983
939
  await ensureAuth();
984
940
  const cf = resolveCf();
985
- await reconcile();
986
- const orphans = listEntries().filter((e) => e.state === "provisioning" || e.state === "orphaned");
987
- if (orphans.length === 0) {
988
- say.info("Nothing to clean up.");
989
- return;
990
- }
991
- say.info(`Found ${orphans.length} orphaned entr${orphans.length === 1 ? "y" : "ies"}:`);
992
- for (const o of orphans) say.dim(` ${o.subdomain}.${o.zone} (${o.state})`);
993
- if (!opts.yes) {
994
- say.warn("Re-run with --yes to delete these tunnels/records.");
995
- return;
996
- }
997
- for (const o of orphans) {
998
- try {
999
- await removeTunnelSubdomain(cf, `${o.subdomain}.${o.zone}`, { force: true });
1000
- } catch {
1001
- say.warn(`Could not fully clean ${o.subdomain}.${o.zone} \u2014 check the dashboard.`);
941
+ if (opts.all) {
942
+ const entries = listEntries();
943
+ if (entries.length === 0) {
944
+ say.info("Nothing to release.");
945
+ return;
946
+ }
947
+ for (const e of entries) {
948
+ try {
949
+ await removeTunnelSubdomain(cf, `${e.subdomain}.${e.zone}`, { force: opts.force, dryRun: opts.dryRun });
950
+ } catch (err) {
951
+ say.warn(`Could not release ${e.subdomain}.${e.zone}: ${err.message}`);
952
+ }
1002
953
  }
954
+ return;
1003
955
  }
956
+ if (!target) throw new CliError("Pass a subdomain (name / id / #) or --all.");
957
+ await removeTunnelSubdomain(cf, target, { force: opts.force, dryRun: opts.dryRun });
1004
958
  });
1005
959
  }
1006
960
 
@@ -1115,12 +1069,19 @@ async function runProfile(name, opts) {
1115
1069
  const conn = startConnector({
1116
1070
  bin,
1117
1071
  token: result.token,
1118
- detach: false,
1072
+ detach: !!opts.detach,
1119
1073
  logFile,
1120
- onExit: () => say.warn(`Connector for ${fqdn} exited \u2014 check \`cloudtunnel status ${result.host.subdomain}\`.`)
1074
+ onExit: opts.detach ? void 0 : () => say.warn(`Connector for ${fqdn} exited.`)
1121
1075
  });
1122
1076
  await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile });
1123
- started.push({ fqdn, subdomain: result.host.subdomain, tunnelId: result.tunnelId, target: `${svc.proto}://localhost:${svc.port}` });
1077
+ started.push({ fqdn, subdomain: result.host.subdomain, tunnelId: result.tunnelId, target: `${svc.proto}://localhost:${svc.port}`, pid: conn.pid });
1078
+ }
1079
+ if (opts.detach) {
1080
+ spin.stop(`${started.length} service(s) started in the background`);
1081
+ const lines2 = started.map((s) => `${formatRoute(s.fqdn, s.target)} ${dim(`pid ${s.pid}`)}`);
1082
+ clack3.note(lines2.join("\n"), `profile "${name}" \u2014 running in background`);
1083
+ if (process.stdout.isTTY) clack3.outro("Stop them with: cloudtunnel down --all");
1084
+ return;
1124
1085
  }
1125
1086
  spin.message("Connecting to the Cloudflare edge\u2026");
1126
1087
  const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
@@ -1128,17 +1089,19 @@ async function runProfile(name, opts) {
1128
1089
  spin.stop(`${started.length} service(s) started`);
1129
1090
  const lines = started.map((s, i) => `${formatRoute(s.fqdn, s.target)}${healths[i] === "healthy" ? "" : dim(` (${healths[i]})`)}`);
1130
1091
  clack3.note(lines.join("\n"), `profile "${name}" \u2014 ${live}/${started.length} live`);
1131
- say.dim("Ctrl-C stops all connectors (subdomains are kept).");
1092
+ say.dim("Ctrl-C stops and releases all of them.");
1132
1093
  let tornDown = false;
1133
1094
  const teardownAll = async (code) => {
1134
1095
  if (tornDown) return;
1135
1096
  tornDown = true;
1136
1097
  try {
1137
1098
  for (const s of started) {
1138
- const entry = getEntry(s.fqdn);
1139
- if (entry) await stopConnector(entry);
1099
+ try {
1100
+ await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
1101
+ } catch {
1102
+ }
1140
1103
  }
1141
- if (process.stdout.isTTY) clack3.outro(`Stopped ${started.length} connector(s) \xB7 subdomains kept`);
1104
+ if (process.stdout.isTTY) clack3.outro(`Stopped \xB7 released ${started.length} subdomain(s)`);
1142
1105
  } catch (err) {
1143
1106
  reportError(err);
1144
1107
  } finally {
@@ -1150,7 +1113,7 @@ async function runProfile(name, opts) {
1150
1113
  }
1151
1114
  }
1152
1115
  function registerRun(program) {
1153
- program.command("run").argument("<profile>", "name of a saved profile (see `cloudtunnel profiles`)").description("Start every service in a saved profile at once").option("-f, --force", "take over subdomains already occupied by another record").option("-d, --domain <domain>", "override the profile's domain for this run").action((name, opts) => runProfile(name, opts));
1116
+ program.command("run").argument("<profile>", "name of a saved profile (see `cloudtunnel profiles`)").description("Start every service in a saved profile at once").option("-f, --force", "take over subdomains already occupied by another record").option("-d, --domain <domain>", "override the profile's domain for this run").option("--detach", "run all connectors in the background (stop with `cloudtunnel down --all`)").action((name, opts) => runProfile(name, opts));
1154
1117
  }
1155
1118
 
1156
1119
  // src/commands/profiles.ts
@@ -1177,6 +1140,50 @@ function registerProfiles(program) {
1177
1140
  });
1178
1141
  }
1179
1142
 
1143
+ // src/commands/logs.ts
1144
+ import { closeSync, existsSync as existsSync3, openSync as openSync2, readFileSync as readFileSync5, readSync, statSync, watch } from "fs";
1145
+ function printTail(file, n) {
1146
+ const lines = readFileSync5(file, "utf8").split("\n");
1147
+ const tail = lines.slice(-n).join("\n");
1148
+ process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
1149
+ `);
1150
+ return statSync(file).size;
1151
+ }
1152
+ function follow(file, fromPos) {
1153
+ let pos = fromPos;
1154
+ say.dim("\u2014 following (Ctrl-C to stop) \u2014");
1155
+ const watcher = watch(file, () => {
1156
+ const size = statSync(file).size;
1157
+ if (size < pos) {
1158
+ pos = 0;
1159
+ return;
1160
+ }
1161
+ if (size > pos) {
1162
+ const fd = openSync2(file, "r");
1163
+ const buf = Buffer.alloc(size - pos);
1164
+ readSync(fd, buf, 0, size - pos, pos);
1165
+ closeSync(fd);
1166
+ process.stdout.write(buf.toString("utf8"));
1167
+ pos = size;
1168
+ }
1169
+ });
1170
+ process.on("SIGINT", () => {
1171
+ watcher.close();
1172
+ process.exit(0);
1173
+ });
1174
+ }
1175
+ function registerLogs(program) {
1176
+ program.command("logs").argument("<target>", "subdomain name / hostname / id / #").description("Show the connector log for a subdomain (use -f to follow)").option("-f, --follow", "keep printing new log lines (like tail -f)").option("-n, --lines <n>", "number of lines to show", "50").action((name, opts) => {
1177
+ const { fqdn, entry } = resolveTarget(name);
1178
+ if (!entry?.logFile || !existsSync3(entry.logFile)) {
1179
+ throw new CliError(`No logs for ${fqdn} yet.`, { hint: "start it with `cloudtunnel up` or `cloudtunnel run`" });
1180
+ }
1181
+ const n = Math.max(1, Number(opts.lines) || 50);
1182
+ const pos = printTail(entry.logFile, n);
1183
+ if (opts.follow) follow(entry.logFile, pos);
1184
+ });
1185
+ }
1186
+
1180
1187
  // src/index.ts
1181
1188
  var require2 = createRequire(import.meta.url);
1182
1189
  var pkg = require2("../package.json");
@@ -1184,11 +1191,13 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
1184
1191
  "login",
1185
1192
  "up",
1186
1193
  "ls",
1187
- "rm",
1188
- "update",
1189
- "status",
1194
+ "ps",
1190
1195
  "down",
1191
- "gc",
1196
+ "rm",
1197
+ "remove",
1198
+ "delete",
1199
+ "stop",
1200
+ "logs",
1192
1201
  "zones",
1193
1202
  "save",
1194
1203
  "run",
@@ -1205,7 +1214,7 @@ function applyBarePortAlias(argv) {
1205
1214
  }
1206
1215
  function buildProgram() {
1207
1216
  const program = new Command();
1208
- program.name("cloudtunnel").description("Manage Cloudflare Tunnels and subdomains account-wide, nport-style.").version(pkg.version, "-v, --version").showHelpAfterError();
1217
+ program.name("cloudtunnel").description("Manage Cloudflare Tunnels and subdomains account-wide, from your terminal.").version(pkg.version, "-v, --version").showHelpAfterError();
1209
1218
  program.addHelpText(
1210
1219
  "before",
1211
1220
  [
@@ -1219,15 +1228,12 @@ function buildProgram() {
1219
1228
  registerLogin,
1220
1229
  registerUp,
1221
1230
  registerLs,
1222
- registerRm,
1223
- registerUpdate,
1224
- registerStatus,
1225
1231
  registerDown,
1226
- registerGc,
1227
1232
  registerZones,
1228
1233
  registerSave,
1229
1234
  registerRun,
1230
- registerProfiles
1235
+ registerProfiles,
1236
+ registerLogs
1231
1237
  ]) {
1232
1238
  register(program);
1233
1239
  }