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.
- package/cli/src/subcommands/menu.ts +281 -52
- package/package.json +1 -1
|
@@ -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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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
|
-
|
|
730
|
-
"\n"
|
|
731
|
-
);
|
|
960
|
+
});
|
|
961
|
+
return ["ID Token Port Connection Created", "-".repeat(70), ...lines].join("\n");
|
|
962
|
+
},
|
|
732
963
|
},
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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