uplink-cli 0.1.0-alpha.2 → 0.1.0-alpha.4
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 +244 -28
- package/package.json +1 -1
|
@@ -79,12 +79,20 @@ function colorRed(text: string) {
|
|
|
79
79
|
return `${c.red}${text}${c.reset}`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
const TOKEN_DOMAIN = process.env.TUNNEL_DOMAIN || "x.uplink.spot";
|
|
83
|
+
const ALIAS_DOMAIN = process.env.ALIAS_DOMAIN || "uplink.spot";
|
|
84
|
+
const URL_SCHEME = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
|
|
85
|
+
|
|
82
86
|
function colorMagenta(text: string) {
|
|
83
87
|
return `${c.magenta}${text}${c.reset}`;
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
function colorWhite(text: string) {
|
|
91
|
+
return `${c.brightWhite}${text}${c.reset}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
// ASCII banner with color styling
|
|
87
|
-
const ASCII_UPLINK =
|
|
95
|
+
const ASCII_UPLINK = colorWhite([
|
|
88
96
|
"██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
|
|
89
97
|
"██║ ██║██╔══██╗██║ ██║████╗ ██║██║ ██╔╝",
|
|
90
98
|
"██║ ██║██████╔╝██║ ██║██╔██╗ ██║█████╔╝ ",
|
|
@@ -141,6 +149,7 @@ async function inlineSelect(
|
|
|
141
149
|
let branchColor: string;
|
|
142
150
|
|
|
143
151
|
if (isSelected) {
|
|
152
|
+
// Selected: cyan highlight
|
|
144
153
|
branchColor = colorCyan(branch);
|
|
145
154
|
if (opt.label === "Back") {
|
|
146
155
|
label = colorDim(opt.label);
|
|
@@ -148,11 +157,12 @@ async function inlineSelect(
|
|
|
148
157
|
label = colorCyan(opt.label);
|
|
149
158
|
}
|
|
150
159
|
} else {
|
|
151
|
-
|
|
160
|
+
// Not selected: white
|
|
161
|
+
branchColor = colorWhite(branch);
|
|
152
162
|
if (opt.label === "Back") {
|
|
153
163
|
label = colorDim(opt.label);
|
|
154
164
|
} else {
|
|
155
|
-
label = opt.label;
|
|
165
|
+
label = colorWhite(opt.label);
|
|
156
166
|
}
|
|
157
167
|
}
|
|
158
168
|
|
|
@@ -167,8 +177,8 @@ async function inlineSelect(
|
|
|
167
177
|
allOptions.forEach((opt, idx) => {
|
|
168
178
|
const isLast = idx === allOptions.length - 1;
|
|
169
179
|
const branch = isLast ? "└─" : "├─";
|
|
170
|
-
const branchColor = idx === 0 ? colorCyan(branch) :
|
|
171
|
-
const label = idx === 0 ? colorCyan(opt.label) : (opt.label === "Back" ? colorDim(opt.label) : opt.label);
|
|
180
|
+
const branchColor = idx === 0 ? colorCyan(branch) : colorWhite(branch);
|
|
181
|
+
const label = idx === 0 ? colorCyan(opt.label) : (opt.label === "Back" ? colorDim(opt.label) : colorWhite(opt.label));
|
|
172
182
|
console.log(`${branchColor} ${label}`);
|
|
173
183
|
});
|
|
174
184
|
|
|
@@ -652,6 +662,137 @@ export const menuCommand = new Command("menu")
|
|
|
652
662
|
}
|
|
653
663
|
},
|
|
654
664
|
},
|
|
665
|
+
{
|
|
666
|
+
label: "Set Permanent Alias",
|
|
667
|
+
action: async () => {
|
|
668
|
+
const data = await apiRequest("GET", "/v1/tunnels");
|
|
669
|
+
const tunnels = data.tunnels || [];
|
|
670
|
+
if (!tunnels.length) return "No tunnels found.";
|
|
671
|
+
|
|
672
|
+
const options: SelectOption[] = tunnels.map((t: any) => {
|
|
673
|
+
const token = truncate(t.token || "", 10);
|
|
674
|
+
const alias = t.alias ? colorGreen(t.alias) : colorDim("none");
|
|
675
|
+
const port = t.target_port ?? t.targetPort ?? "-";
|
|
676
|
+
return {
|
|
677
|
+
label: `${token.padEnd(12)} port ${String(port).padEnd(5)} alias ${alias}`,
|
|
678
|
+
value: t.id,
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const choice = await inlineSelect("Select tunnel for alias", options, true);
|
|
683
|
+
if (choice === null) return "";
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
process.stdin.setRawMode(false);
|
|
687
|
+
} catch {
|
|
688
|
+
/* ignore */
|
|
689
|
+
}
|
|
690
|
+
const aliasInput = await promptLine("Enter alias (e.g. thomas): ");
|
|
691
|
+
restoreRawMode();
|
|
692
|
+
const alias = aliasInput.trim();
|
|
693
|
+
if (!alias) return "Alias not set (empty).";
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
const result = await apiRequest("POST", `/v1/tunnels/${choice.value}/alias`, {
|
|
697
|
+
alias,
|
|
698
|
+
});
|
|
699
|
+
const aliasUrl = result.aliasUrl || `${URL_SCHEME}://${alias}.${ALIAS_DOMAIN}`;
|
|
700
|
+
const tokenUrl = result.url || `${URL_SCHEME}://${result.token}.${TOKEN_DOMAIN}`;
|
|
701
|
+
return [
|
|
702
|
+
"✓ Alias updated",
|
|
703
|
+
`→ Alias URL ${aliasUrl}`,
|
|
704
|
+
`→ Token URL ${tokenUrl}`,
|
|
705
|
+
].join("\n");
|
|
706
|
+
} catch (err: any) {
|
|
707
|
+
const errMsg = err?.message || String(err);
|
|
708
|
+
// Check for premium feature errors
|
|
709
|
+
if (errMsg.includes("ALIAS_NOT_ENABLED")) {
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(errMsg);
|
|
712
|
+
const userId = parsed?.error?.details?.user_id || "(check your token)";
|
|
713
|
+
return [
|
|
714
|
+
"",
|
|
715
|
+
colorYellow("🔒 Permanent Aliases - Premium Feature"),
|
|
716
|
+
"",
|
|
717
|
+
"Permanent aliases give you stable URLs like:",
|
|
718
|
+
` ${colorGreen(`https://myapp.${ALIAS_DOMAIN}`)}`,
|
|
719
|
+
"",
|
|
720
|
+
"Instead of regenerating tokens each time.",
|
|
721
|
+
"",
|
|
722
|
+
"To unlock this feature:",
|
|
723
|
+
` → Join our Discord: ${colorCyan("https://uplink.spot")}`,
|
|
724
|
+
` → Share your user ID: ${colorDim(userId)}`,
|
|
725
|
+
"",
|
|
726
|
+
"We'll enable it for your account!",
|
|
727
|
+
"",
|
|
728
|
+
].join("\n");
|
|
729
|
+
} catch {
|
|
730
|
+
return colorYellow("🔒 Aliases are a premium feature. Contact us at uplink.spot to upgrade.");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (errMsg.includes("ALIAS_LIMIT_REACHED")) {
|
|
734
|
+
return colorYellow("⚠️ You've reached your alias limit. Contact us to increase it.");
|
|
735
|
+
}
|
|
736
|
+
throw err; // Re-throw other errors
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
label: "Remove Alias",
|
|
742
|
+
action: async () => {
|
|
743
|
+
const data = await apiRequest("GET", "/v1/tunnels");
|
|
744
|
+
const tunnels = (data.tunnels || []).filter((t: any) => !!t.alias);
|
|
745
|
+
if (!tunnels.length) return "No tunnels with aliases.";
|
|
746
|
+
|
|
747
|
+
const options: SelectOption[] = tunnels.map((t: any) => ({
|
|
748
|
+
label: `${truncate(t.token || "", 10).padEnd(12)} alias ${colorGreen(t.alias)}`,
|
|
749
|
+
value: t.id,
|
|
750
|
+
}));
|
|
751
|
+
|
|
752
|
+
const choice = await inlineSelect("Select tunnel to remove alias", options, true);
|
|
753
|
+
if (choice === null) return "";
|
|
754
|
+
|
|
755
|
+
await apiRequest("DELETE", `/v1/tunnels/${choice.value}/alias`);
|
|
756
|
+
return "✓ Alias removed";
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
label: "View Connected (with IPs)",
|
|
761
|
+
action: async () => {
|
|
762
|
+
try {
|
|
763
|
+
// Use the API endpoint which proxies to the relay
|
|
764
|
+
const data = await apiRequest("GET", "/v1/admin/relay-status") as {
|
|
765
|
+
connectedTunnels?: number;
|
|
766
|
+
tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
|
|
767
|
+
timestamp?: string;
|
|
768
|
+
error?: string;
|
|
769
|
+
message?: string;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
if (data.error) {
|
|
773
|
+
return `❌ Relay error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (!data.tunnels || data.tunnels.length === 0) {
|
|
777
|
+
return "No tunnels currently connected to the relay.";
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const lines = data.tunnels.map((t) =>
|
|
781
|
+
`${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
return [
|
|
785
|
+
`Connected Tunnels: ${data.connectedTunnels}`,
|
|
786
|
+
"",
|
|
787
|
+
"Token Client IP Port Uptime Connected At",
|
|
788
|
+
"-".repeat(75),
|
|
789
|
+
...lines,
|
|
790
|
+
].join("\n");
|
|
791
|
+
} catch (err: any) {
|
|
792
|
+
return `❌ Failed to get relay status: ${err.message}`;
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
},
|
|
655
796
|
],
|
|
656
797
|
});
|
|
657
798
|
|
|
@@ -668,27 +809,38 @@ export const menuCommand = new Command("menu")
|
|
|
668
809
|
if (!tunnels || tunnels.length === 0) {
|
|
669
810
|
return "No tunnels found.";
|
|
670
811
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
812
|
+
const lines = tunnels.map((t: any) => {
|
|
813
|
+
const token = t.token || "";
|
|
814
|
+
const alias = t.alias || "-";
|
|
815
|
+
const tokenUrl = t.url || `${URL_SCHEME}://${token}.${TOKEN_DOMAIN}`;
|
|
816
|
+
const aliasUrl =
|
|
817
|
+
t.aliasUrl || (t.alias ? `${URL_SCHEME}://${t.alias}.${ALIAS_DOMAIN}` : "-");
|
|
818
|
+
const connectedFromApi = t.connected ?? false;
|
|
819
|
+
const connectedLocal = runningClients.some((c) => c.token === token);
|
|
820
|
+
const connectionStatus = isAdmin
|
|
821
|
+
? connectedFromApi
|
|
822
|
+
? "connected"
|
|
823
|
+
: "disconnected"
|
|
824
|
+
: connectedLocal
|
|
825
|
+
? "connected"
|
|
826
|
+
: "unknown";
|
|
827
|
+
|
|
828
|
+
return [
|
|
829
|
+
`${truncate(t.id, 12)} ${truncate(token, 10).padEnd(12)} ${String(
|
|
682
830
|
t.target_port ?? t.targetPort ?? "-"
|
|
683
831
|
).padEnd(5)} ${connectionStatus.padEnd(12)} ${truncate(
|
|
684
832
|
t.created_at ?? t.createdAt ?? "",
|
|
685
833
|
19
|
|
686
|
-
)}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
834
|
+
)}`,
|
|
835
|
+
` url: ${tokenUrl}`,
|
|
836
|
+
` alias: ${aliasUrl} (${alias})`,
|
|
837
|
+
].join("\n");
|
|
838
|
+
});
|
|
839
|
+
return [
|
|
840
|
+
"ID Token Port Connection Created",
|
|
841
|
+
"-".repeat(90),
|
|
842
|
+
...lines,
|
|
843
|
+
].join("\n\n");
|
|
692
844
|
},
|
|
693
845
|
},
|
|
694
846
|
{
|
|
@@ -816,6 +968,71 @@ export const menuCommand = new Command("menu")
|
|
|
816
968
|
}
|
|
817
969
|
},
|
|
818
970
|
},
|
|
971
|
+
{
|
|
972
|
+
label: "Grant Alias Access",
|
|
973
|
+
action: async () => {
|
|
974
|
+
try {
|
|
975
|
+
// Fetch available tokens to show users
|
|
976
|
+
const result = await apiRequest("GET", "/v1/admin/tokens");
|
|
977
|
+
const tokens = result.tokens || [];
|
|
978
|
+
|
|
979
|
+
if (tokens.length === 0) {
|
|
980
|
+
restoreRawMode();
|
|
981
|
+
return "No tokens found.";
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Build options from tokens (group by user_id)
|
|
985
|
+
const userMap = new Map<string, any>();
|
|
986
|
+
for (const t of tokens) {
|
|
987
|
+
const userId = t.user_id || t.userId;
|
|
988
|
+
if (userId && !userMap.has(userId)) {
|
|
989
|
+
userMap.set(userId, t);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const options: SelectOption[] = Array.from(userMap.entries()).map(([userId, t]) => ({
|
|
994
|
+
label: `${truncate(userId, 20)} ${colorDim(`${t.role || "user"} - ${t.label || "no label"}`)}`,
|
|
995
|
+
value: userId,
|
|
996
|
+
}));
|
|
997
|
+
|
|
998
|
+
const selected = await inlineSelect("Select user to grant alias access", options, true);
|
|
999
|
+
|
|
1000
|
+
if (selected === null) {
|
|
1001
|
+
restoreRawMode();
|
|
1002
|
+
return "";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const userId = selected.value as string;
|
|
1006
|
+
|
|
1007
|
+
// Prompt for limit
|
|
1008
|
+
try {
|
|
1009
|
+
process.stdin.setRawMode(false);
|
|
1010
|
+
} catch {
|
|
1011
|
+
/* ignore */
|
|
1012
|
+
}
|
|
1013
|
+
const limitAnswer = await promptLine("Alias limit (1-10, or -1 for unlimited, default 1): ");
|
|
1014
|
+
restoreRawMode();
|
|
1015
|
+
|
|
1016
|
+
const aliasLimit = limitAnswer.trim() ? parseInt(limitAnswer.trim(), 10) : 1;
|
|
1017
|
+
if (isNaN(aliasLimit) || aliasLimit < -1 || aliasLimit > 100) {
|
|
1018
|
+
return "Invalid limit. Must be -1 (unlimited) or 0-100.";
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
await apiRequest("POST", "/v1/admin/grant-alias", { userId, aliasLimit });
|
|
1022
|
+
|
|
1023
|
+
const limitDesc = aliasLimit === -1 ? "unlimited" : String(aliasLimit);
|
|
1024
|
+
return [
|
|
1025
|
+
"✓ Alias access granted",
|
|
1026
|
+
"",
|
|
1027
|
+
`→ User ${userId}`,
|
|
1028
|
+
`→ Limit ${limitDesc} alias(es)`,
|
|
1029
|
+
].join("\n");
|
|
1030
|
+
} catch (err: any) {
|
|
1031
|
+
restoreRawMode();
|
|
1032
|
+
throw err;
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
819
1036
|
],
|
|
820
1037
|
});
|
|
821
1038
|
|
|
@@ -866,11 +1083,8 @@ export const menuCommand = new Command("menu")
|
|
|
866
1083
|
if (clients.length === 0) {
|
|
867
1084
|
cachedActiveTunnels = "";
|
|
868
1085
|
} else {
|
|
869
|
-
const domain = process.env.TUNNEL_DOMAIN || "t.uplink.spot";
|
|
870
|
-
const scheme = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
|
|
871
|
-
|
|
872
1086
|
const tunnelLines = clients.map((client, idx) => {
|
|
873
|
-
const url = `${
|
|
1087
|
+
const url = `${URL_SCHEME}://${client.token}.${TOKEN_DOMAIN}`;
|
|
874
1088
|
const isLast = idx === clients.length - 1;
|
|
875
1089
|
const branch = isLast ? "└─" : "├─";
|
|
876
1090
|
return colorDim(branch) + " " + colorGreen(url) + colorDim(" → ") + `localhost:${client.port}`;
|
|
@@ -964,6 +1178,7 @@ export const menuCommand = new Command("menu")
|
|
|
964
1178
|
let branchColor: string;
|
|
965
1179
|
|
|
966
1180
|
if (isSelected) {
|
|
1181
|
+
// Selected: cyan highlight
|
|
967
1182
|
branchColor = colorCyan(branch);
|
|
968
1183
|
if (cleanLabel.toLowerCase().includes("exit")) {
|
|
969
1184
|
label = colorDim(cleanLabel);
|
|
@@ -975,7 +1190,8 @@ export const menuCommand = new Command("menu")
|
|
|
975
1190
|
label = colorCyan(cleanLabel);
|
|
976
1191
|
}
|
|
977
1192
|
} else {
|
|
978
|
-
|
|
1193
|
+
// Not selected: white text
|
|
1194
|
+
branchColor = colorWhite(branch);
|
|
979
1195
|
if (cleanLabel.toLowerCase().includes("exit")) {
|
|
980
1196
|
label = colorDim(cleanLabel);
|
|
981
1197
|
} else if (cleanLabel.toLowerCase().includes("stop all") || cleanLabel.toLowerCase().includes("kill")) {
|
|
@@ -983,7 +1199,7 @@ export const menuCommand = new Command("menu")
|
|
|
983
1199
|
} else if (cleanLabel.toLowerCase().includes("get started")) {
|
|
984
1200
|
label = colorGreen(cleanLabel);
|
|
985
1201
|
} else {
|
|
986
|
-
label = cleanLabel;
|
|
1202
|
+
label = colorWhite(cleanLabel);
|
|
987
1203
|
}
|
|
988
1204
|
}
|
|
989
1205
|
|