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.
@@ -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 = colorCyan([
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
- branchColor = colorDim(branch);
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) : colorDim(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
- const lines = tunnels.map(
673
- (t: any) => {
674
- const token = t.token || "";
675
- const connectedFromApi = t.connected ?? false;
676
- const connectedLocal = runningClients.some((c) => c.token === token);
677
- const connectionStatus = isAdmin
678
- ? (connectedFromApi ? "connected" : "disconnected")
679
- : (connectedLocal ? "connected" : "unknown");
680
-
681
- return `${truncate(t.id, 12)} ${truncate(token, 10).padEnd(12)} ${String(
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
- return ["ID Token Port Connection Created", "-".repeat(70), ...lines].join(
690
- "\n"
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 = `${scheme}://${client.token}.${domain}`;
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
- branchColor = colorDim(branch);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Localhost to public URL in seconds. No signup forms, no browser - everything in your terminal.",
5
5
  "keywords": [
6
6
  "tunnel",