uplink-cli 0.1.12 → 0.1.13

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.
@@ -98,6 +98,14 @@ function truncate(text: string, max: number) {
98
98
  return text.slice(0, max - 1) + "…";
99
99
  }
100
100
 
101
+ function formatBytes(bytes: number): string {
102
+ if (bytes === 0) return "0 B";
103
+ const k = 1024;
104
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
105
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
106
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
107
+ }
108
+
101
109
  function restoreRawMode() {
102
110
  try {
103
111
  process.stdin.setRawMode(true);
@@ -256,7 +264,6 @@ export const menuCommand = new Command("menu")
256
264
  errorMsg.includes("Missing AGENTCLOUD_TOKEN");
257
265
  isAdmin = false;
258
266
  }
259
-
260
267
  // Build menu structure dynamically by role and auth status
261
268
  const mainMenu: MenuChoice[] = [];
262
269
 
@@ -489,6 +496,72 @@ export const menuCommand = new Command("menu")
489
496
  ].join("\n");
490
497
  },
491
498
  },
499
+ {
500
+ label: "View Connected Tunnels",
501
+ action: async () => {
502
+ try {
503
+ const data = await apiRequest("GET", "/v1/admin/relay-status") as {
504
+ connectedTunnels?: number;
505
+ tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
506
+ timestamp?: string;
507
+ error?: string;
508
+ message?: string;
509
+ };
510
+
511
+ if (data.error) {
512
+ return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
513
+ }
514
+ if (!data.tunnels || data.tunnels.length === 0) {
515
+ return "No tunnels currently connected to the relay.";
516
+ }
517
+
518
+ const lines = data.tunnels.map((t) =>
519
+ `${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
520
+ );
521
+
522
+ return [
523
+ `Connected Tunnels: ${data.connectedTunnels}`,
524
+ "",
525
+ "Token Client IP Port Uptime Connected At",
526
+ "-".repeat(75),
527
+ ...lines,
528
+ ].join("\n");
529
+ } catch (err: any) {
530
+ return `Error fetching relay status: ${err.message || err}`;
531
+ }
532
+ },
533
+ },
534
+ {
535
+ label: "View Traffic Stats",
536
+ action: async () => {
537
+ try {
538
+ const data = await apiRequest("GET", "/v1/admin/traffic-stats") as {
539
+ stats?: Array<{ alias: string; requests: number; bytesIn: number; bytesOut: number; lastStatus: number; lastSeen: string }>;
540
+ error?: string;
541
+ message?: string;
542
+ };
543
+
544
+ if (data.error) {
545
+ return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
546
+ }
547
+ if (!data.stats || data.stats.length === 0) {
548
+ return "No traffic stats available.";
549
+ }
550
+
551
+ const lines = data.stats.map((s) =>
552
+ `${truncate(s.alias || "-", 24).padEnd(26)} ${String(s.requests).padEnd(10)} ${formatBytes(s.bytesIn).padEnd(10)} ${formatBytes(s.bytesOut).padEnd(10)} ${String(s.lastStatus).padEnd(4)} ${truncate(s.lastSeen, 19)}`
553
+ );
554
+
555
+ return [
556
+ "Alias Requests In Out Sts Last Seen",
557
+ "-".repeat(85),
558
+ ...lines,
559
+ ].join("\n");
560
+ } catch (err: any) {
561
+ return `Error fetching traffic stats: ${err.message || err}`;
562
+ }
563
+ },
564
+ },
492
565
  {
493
566
  label: "Test: Tunnel",
494
567
  action: async () => {
@@ -692,31 +765,191 @@ export const menuCommand = new Command("menu")
692
765
  }
693
766
  },
694
767
  },
768
+ {
769
+ label: "View Tunnel Stats",
770
+ action: async () => {
771
+ try {
772
+ const result = await apiRequest("GET", "/v1/tunnels");
773
+ const tunnels = result.tunnels || result?.items || [];
774
+ if (!tunnels || tunnels.length === 0) {
775
+ restoreRawMode();
776
+ return "No tunnels found.";
777
+ }
778
+
779
+ const options: SelectOption[] = tunnels.map((t: any) => {
780
+ const token = truncate(t.token || t.id, 12);
781
+ const alias = t.alias ? `${t.alias}.x.uplink.spot` : "(no permanent URL)";
782
+ return {
783
+ label: `${token} ${alias}`,
784
+ value: t.id,
785
+ };
786
+ });
787
+
788
+ const choice = await inlineSelect("Select tunnel to view stats", options, true);
789
+ if (choice === null) {
790
+ restoreRawMode();
791
+ return "";
792
+ }
793
+
794
+ const stats = await apiRequest("GET", `/v1/tunnels/${choice.value}/stats`) as any;
795
+ const connected = stats.connected ? "yes" : "no";
796
+ const alias = stats.alias || null;
797
+
798
+ if (!alias) {
799
+ const s = stats.inMemory || {};
800
+ return [
801
+ `Connected: ${connected}`,
802
+ `Requests: ${s.requests || 0}`,
803
+ `In: ${formatBytes(s.bytesIn || 0)}`,
804
+ `Out: ${formatBytes(s.bytesOut || 0)}`,
805
+ ].join("\n");
806
+ }
807
+
808
+ const totals = stats.totals || {};
809
+ const current = stats.currentRun || {};
810
+ const permanentUrl = `https://${alias}.x.uplink.spot`;
811
+ return [
812
+ `Permanent URL: ${permanentUrl}`,
813
+ `Connected: ${connected}`,
814
+ "",
815
+ "Totals (persisted):",
816
+ ` Requests ${totals.requests || 0}`,
817
+ ` In ${formatBytes(totals.bytesIn || 0)}`,
818
+ ` Out ${formatBytes(totals.bytesOut || 0)}`,
819
+ "",
820
+ "Current run:",
821
+ ` Requests ${current.requests || 0}`,
822
+ ` In ${formatBytes(current.bytesIn || 0)}`,
823
+ ` Out ${formatBytes(current.bytesOut || 0)}`,
824
+ ].join("\n");
825
+ } catch (err: any) {
826
+ restoreRawMode();
827
+ throw err;
828
+ }
829
+ },
830
+ },
831
+ {
832
+ label: "Create Permanent URL",
833
+ action: async () => {
834
+ try {
835
+ const result = await apiRequest("GET", "/v1/tunnels");
836
+ const tunnels = result.tunnels || result?.items || [];
837
+ if (!tunnels || tunnels.length === 0) {
838
+ restoreRawMode();
839
+ return "No tunnels found. Create a tunnel first.";
840
+ }
841
+
842
+ const options: SelectOption[] = tunnels.map((t: any) => {
843
+ const token = truncate(t.token || t.id, 12);
844
+ const alias = t.alias ? `${t.alias}.x.uplink.spot` : "(no permanent URL)";
845
+ return {
846
+ label: `${token} ${alias}`,
847
+ value: t.id,
848
+ };
849
+ });
850
+
851
+ const choice = await inlineSelect("Select tunnel to set permanent URL", options, true);
852
+ if (choice === null) {
853
+ restoreRawMode();
854
+ return "";
855
+ }
856
+
857
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
858
+ const aliasName = await promptLine("Enter alias name (e.g. my-app): ");
859
+ restoreRawMode();
860
+
861
+ if (!aliasName.trim()) {
862
+ return "No alias provided.";
863
+ }
864
+
865
+ try {
866
+ const aliasResult = await apiRequest("POST", `/v1/tunnels/${choice.value}/alias`, {
867
+ alias: aliasName.trim(),
868
+ });
869
+ const permanentUrl = `https://${aliasResult.alias}.x.uplink.spot`;
870
+ return [
871
+ "✓ Permanent URL created",
872
+ "",
873
+ `→ Alias ${aliasResult.alias}`,
874
+ `→ URL ${permanentUrl}`,
875
+ "",
876
+ "Your tunnel will now be accessible at this permanent URL.",
877
+ ].join("\n");
878
+ } catch (err: any) {
879
+ const msg = err?.message || String(err);
880
+ if (msg.includes("ALIAS_NOT_ENABLED")) {
881
+ return [
882
+ "❌ Permanent URLs are a premium feature",
883
+ "",
884
+ "Contact us on Discord at uplink.spot to upgrade your account.",
885
+ ].join("\n");
886
+ }
887
+ if (msg.includes("ALIAS_LIMIT_REACHED")) {
888
+ return [
889
+ "❌ URL limit reached",
890
+ "",
891
+ "You've reached your URL limit. Contact us to increase it.",
892
+ ].join("\n");
893
+ }
894
+ if (msg.includes("ALIAS_TAKEN") || msg.includes("already in use")) {
895
+ return `❌ Alias "${aliasName.trim()}" is already in use. Try a different name.`;
896
+ }
897
+ throw err;
898
+ }
899
+ } catch (err: any) {
900
+ restoreRawMode();
901
+ throw err;
902
+ }
903
+ },
904
+ },
905
+ {
906
+ label: "My Tunnels",
907
+ action: async () => {
908
+ const runningClients = findTunnelClients();
909
+ const result = await apiRequest("GET", "/v1/tunnels");
910
+ const tunnels = result.tunnels || result?.items || [];
911
+ if (!tunnels || tunnels.length === 0) {
912
+ return "No tunnels found.";
913
+ }
914
+
915
+ const lines = tunnels.map((t: any) => {
916
+ const token = truncate(t.token || "", 12);
917
+ const port = String(t.target_port ?? t.targetPort ?? "-").padEnd(5);
918
+ const connectedLocal = runningClients.some((c) => c.token === (t.token || ""));
919
+ const status = connectedLocal ? "connected" : "unknown";
920
+ const alias = t.alias ? `${t.alias}.x.uplink.spot` : "-";
921
+ return `${token.padEnd(14)} ${port} ${status.padEnd(11)} ${alias}`;
922
+ });
923
+
924
+ return [
925
+ "Token Port Status Permanent URL",
926
+ "-".repeat(60),
927
+ ...lines,
928
+ ].join("\n");
929
+ },
930
+ },
695
931
  ],
696
932
  });
697
933
 
698
- mainMenu.push({
699
- label: "Usage",
700
- subMenu: [
701
- {
702
- label: isAdmin ? "List Tunnels (admin)" : "List My Tunnels",
703
- action: async () => {
704
- const runningClients = findTunnelClients();
705
- const path = isAdmin ? "/v1/admin/tunnels?limit=20" : "/v1/tunnels";
706
- const result = await apiRequest("GET", path);
707
- const tunnels = result.tunnels || result?.items || [];
708
- if (!tunnels || tunnels.length === 0) {
709
- return "No tunnels found.";
710
- }
711
-
712
- const lines = tunnels.map(
713
- (t: any) => {
934
+ // Admin-only: Usage section
935
+ if (isAdmin) {
936
+ mainMenu.push({
937
+ label: "Usage",
938
+ subMenu: [
939
+ {
940
+ label: "List All Tunnels",
941
+ action: async () => {
942
+ const runningClients = findTunnelClients();
943
+ const result = await apiRequest("GET", "/v1/admin/tunnels?limit=20");
944
+ const tunnels = result.tunnels || result?.items || [];
945
+ if (!tunnels || tunnels.length === 0) {
946
+ return "No tunnels found.";
947
+ }
948
+
949
+ const lines = tunnels.map((t: any) => {
714
950
  const token = t.token || "";
715
951
  const connectedFromApi = t.connected ?? false;
716
- const connectedLocal = runningClients.some((c) => c.token === token);
717
- const connectionStatus = isAdmin
718
- ? (connectedFromApi ? "connected" : "disconnected")
719
- : (connectedLocal ? "connected" : "unknown");
952
+ const connectionStatus = connectedFromApi ? "connected" : "disconnected";
720
953
 
721
954
  return `${truncate(t.id, 12)} ${truncate(token, 10).padEnd(12)} ${String(
722
955
  t.target_port ?? t.targetPort ?? "-"
@@ -724,24 +957,19 @@ export const menuCommand = new Command("menu")
724
957
  t.created_at ?? t.createdAt ?? "",
725
958
  19
726
959
  )}`;
727
- }
728
- );
729
- return ["ID Token Port Connection Created", "-".repeat(70), ...lines].join(
730
- "\n"
731
- );
960
+ });
961
+ return ["ID Token Port Connection Created", "-".repeat(70), ...lines].join("\n");
962
+ },
732
963
  },
733
- },
734
- {
735
- label: isAdmin ? "List Databases (admin)" : "List My Databases",
736
- action: async () => {
737
- const path = isAdmin ? "/v1/admin/databases?limit=20" : "/v1/dbs";
738
- const result = await apiRequest("GET", path);
739
- const databases = result.databases || result.items || [];
740
- if (!databases || databases.length === 0) {
741
- return "No databases found.";
742
- }
743
- const lines = databases.map(
744
- (db: any) =>
964
+ {
965
+ label: "List All Databases",
966
+ action: async () => {
967
+ const result = await apiRequest("GET", "/v1/admin/databases?limit=20");
968
+ const databases = result.databases || result.items || [];
969
+ if (!databases || databases.length === 0) {
970
+ return "No databases found.";
971
+ }
972
+ const lines = databases.map((db: any) =>
745
973
  `${truncate(db.id, 12)} ${truncate(db.name ?? "-", 14).padEnd(14)} ${truncate(
746
974
  db.provider ?? "-",
747
975
  8
@@ -749,21 +977,22 @@ export const menuCommand = new Command("menu")
749
977
  db.status ?? (db.ready ? "ready" : db.status ?? "unknown"),
750
978
  10
751
979
  ).padEnd(10)} ${truncate(db.created_at ?? db.createdAt ?? "", 19)}`
752
- );
753
- return [
754
- "ID Name Prov Region Status Created",
755
- "-".repeat(80),
756
- ...lines,
757
- ].join("\n");
980
+ );
981
+ return [
982
+ "ID Name Prov Region Status Created",
983
+ "-".repeat(80),
984
+ ...lines,
985
+ ].join("\n");
986
+ },
758
987
  },
759
- },
760
- ],
761
- });
988
+ ],
989
+ });
990
+ }
762
991
 
763
992
  // Admin-only: Manage Tokens
764
993
  if (isAdmin) {
765
994
  mainMenu.push({
766
- label: "Manage Tokens (admin)",
995
+ label: "Manage Tokens",
767
996
  subMenu: [
768
997
  {
769
998
  label: "List Tokens",
@@ -855,10 +1084,10 @@ export const menuCommand = new Command("menu")
855
1084
  });
856
1085
  }
857
1086
 
858
- mainMenu.push({
859
- label: "Exit",
860
- action: async () => "Goodbye!",
861
- });
1087
+ mainMenu.push({
1088
+ label: "Exit",
1089
+ action: async () => "Goodbye!",
1090
+ });
862
1091
  }
863
1092
 
864
1093
  // Menu navigation state
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Expose localhost to the internet in seconds. Interactive terminal UI, permanent custom domains, zero config. A modern ngrok alternative.",
5
5
  "keywords": [
6
6
  "tunnel",