@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/LICENSE +21 -0
- package/README.md +131 -34
- package/dist/index.js +174 -168
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
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
|
|
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})
|
|
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
|
|
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
|
|
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
|
|
690
|
-
hint: `use
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
853
|
-
|
|
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
|
|
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 (
|
|
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
|
-
["
|
|
909
|
-
rows.map((r) => [r.
|
|
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("[
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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:
|
|
1072
|
+
detach: !!opts.detach,
|
|
1119
1073
|
logFile,
|
|
1120
|
-
onExit: () => say.warn(`Connector for ${fqdn} exited
|
|
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
|
|
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
|
-
|
|
1139
|
-
|
|
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}
|
|
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
|
-
"
|
|
1188
|
-
"update",
|
|
1189
|
-
"status",
|
|
1194
|
+
"ps",
|
|
1190
1195
|
"down",
|
|
1191
|
-
"
|
|
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,
|
|
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
|
}
|