uplink-cli 0.1.0-alpha.5 → 0.1.0-alpha.7

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/README.md CHANGED
@@ -10,10 +10,12 @@ Perfect for sharing work-in-progress, testing webhooks, or demoing to clients. A
10
10
 
11
11
  ## Features
12
12
 
13
- - **Instant Public URLs** - Your `localhost:3000` becomes `https://xyz.t.uplink.spot`
13
+ - **Instant Public URLs** - Your `localhost:3000` becomes `https://xyz.x.uplink.spot`
14
+ - **Permanent Aliases** - Claim memorable URLs like `https://myapp.uplink.spot`
14
15
  - **Zero Browser Required** - Signup, auth, and tunnel management all in terminal
15
16
  - **Agent-Friendly** - AI assistants can create tokens and start tunnels via API
16
17
  - **Auto Port Detection** - Scans for running servers, select with arrow keys
18
+ - **Auto-Retry** - Built-in retry logic with exponential backoff for reliability
17
19
 
18
20
  ## Quick Start
19
21
 
@@ -59,7 +61,7 @@ Once authenticated, select **"Manage Tunnels"** → **"Start Tunnel"**:
59
61
  - The CLI will scan for active servers on your local machine
60
62
  - Use arrow keys to select a port, or choose "Enter custom port"
61
63
  - Press "Back" if you want to cancel
62
- - Your tunnel URL will be displayed (e.g., `https://abc123.t.uplink.spot`)
64
+ - Your tunnel URL will be displayed (e.g., `https://abc123.x.uplink.spot`)
63
65
 
64
66
  **Keep the terminal running** - the tunnel client must stay active.
65
67
 
@@ -103,8 +105,8 @@ export AGENTCLOUD_TOKEN=your-token-here
103
105
  # Tunnel control server (default: tunnel.uplink.spot:7071)
104
106
  export TUNNEL_CTRL=tunnel.uplink.spot:7071
105
107
 
106
- # Tunnel domain (default: t.uplink.spot)
107
- export TUNNEL_DOMAIN=t.uplink.spot
108
+ # Tunnel domain (default: x.uplink.spot)
109
+ export TUNNEL_DOMAIN=x.uplink.spot
108
110
  ```
109
111
 
110
112
  ## Requirements
@@ -119,7 +121,7 @@ export TUNNEL_DOMAIN=t.uplink.spot
119
121
  1. **Create tunnel** - Request a tunnel from the API
120
122
  2. **Get token** - Receive a unique token (e.g., `abc123`)
121
123
  3. **Start client** - Run the tunnel client locally, connecting to the relay
122
- 4. **Access** - Your local server is accessible at `https://abc123.t.uplink.spot`
124
+ 4. **Access** - Your local server is accessible at `https://abc123.x.uplink.spot`
123
125
 
124
126
  The tunnel client forwards HTTP requests from the public URL to your local server.
125
127
 
package/cli/src/http.ts CHANGED
@@ -1,4 +1,9 @@
1
- import fetch from "node-fetch";
1
+ import fetch, { AbortError } from "node-fetch";
2
+
3
+ // Configuration
4
+ const REQUEST_TIMEOUT = 30000; // 30 seconds
5
+ const MAX_RETRIES = 3;
6
+ const INITIAL_RETRY_DELAY = 1000; // 1 second
2
7
 
3
8
  function getApiBase(): string {
4
9
  return process.env.AGENTCLOUD_API_BASE ?? "https://api.uplink.spot";
@@ -28,6 +33,30 @@ function getApiToken(apiBase: string): string | undefined {
28
33
  );
29
34
  }
30
35
 
36
+ // Check if error is retryable (network issues, 5xx errors)
37
+ function isRetryable(error: unknown, statusCode?: number): boolean {
38
+ // Network errors are retryable
39
+ if (error instanceof AbortError) return true;
40
+ if (error instanceof Error) {
41
+ const msg = error.message.toLowerCase();
42
+ if (msg.includes("econnrefused") || msg.includes("enotfound") ||
43
+ msg.includes("etimedout") || msg.includes("econnreset") ||
44
+ msg.includes("socket hang up")) {
45
+ return true;
46
+ }
47
+ }
48
+ // 5xx server errors are retryable
49
+ if (statusCode && statusCode >= 500 && statusCode < 600) return true;
50
+ // 429 Too Many Requests is retryable
51
+ if (statusCode === 429) return true;
52
+ return false;
53
+ }
54
+
55
+ // Sleep helper
56
+ function sleep(ms: number): Promise<void> {
57
+ return new Promise(resolve => setTimeout(resolve, ms));
58
+ }
59
+
31
60
  export async function apiRequest(
32
61
  method: string,
33
62
  path: string,
@@ -39,22 +68,57 @@ export async function apiRequest(
39
68
  throw new Error("Missing AGENTCLOUD_TOKEN");
40
69
  }
41
70
 
42
- const response = await fetch(`${apiBase}${path}`, {
43
- method,
44
- headers: {
45
- "Content-Type": "application/json",
46
- Authorization: `Bearer ${apiToken}`,
47
- },
48
- body: body ? JSON.stringify(body) : undefined,
49
- });
50
-
51
- const json = await response.json().catch(() => ({}));
52
- if (!response.ok) {
53
- throw new Error(JSON.stringify(json, null, 2));
54
- }
55
-
56
- return json;
57
- }
71
+ let lastError: Error | null = null;
72
+
73
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
74
+ // Create abort controller for timeout
75
+ const controller = new AbortController();
76
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
77
+
78
+ try {
79
+ const response = await fetch(`${apiBase}${path}`, {
80
+ method,
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ Authorization: `Bearer ${apiToken}`,
84
+ },
85
+ body: body ? JSON.stringify(body) : undefined,
86
+ signal: controller.signal,
87
+ });
58
88
 
89
+ clearTimeout(timeoutId);
59
90
 
91
+ const json = await response.json().catch(() => ({}));
92
+
93
+ if (!response.ok) {
94
+ // Check if this error is retryable
95
+ if (isRetryable(null, response.status) && attempt < MAX_RETRIES - 1) {
96
+ const delay = INITIAL_RETRY_DELAY * Math.pow(2, attempt);
97
+ await sleep(delay);
98
+ continue;
99
+ }
100
+ throw new Error(JSON.stringify(json, null, 2));
101
+ }
60
102
 
103
+ return json;
104
+ } catch (error) {
105
+ clearTimeout(timeoutId);
106
+ lastError = error instanceof Error ? error : new Error(String(error));
107
+
108
+ // Check if we should retry
109
+ if (isRetryable(error) && attempt < MAX_RETRIES - 1) {
110
+ const delay = INITIAL_RETRY_DELAY * Math.pow(2, attempt);
111
+ await sleep(delay);
112
+ continue;
113
+ }
114
+
115
+ // Not retryable or out of retries
116
+ if (error instanceof AbortError) {
117
+ throw new Error(`Request timeout after ${REQUEST_TIMEOUT}ms`);
118
+ }
119
+ throw lastError;
120
+ }
121
+ }
122
+
123
+ throw lastError || new Error("Request failed after retries");
124
+ }
@@ -79,18 +79,14 @@ 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
-
86
- function colorMagenta(text: string) {
87
- return `${c.magenta}${text}${c.reset}`;
88
- }
89
-
90
82
  function colorWhite(text: string) {
91
83
  return `${c.brightWhite}${text}${c.reset}`;
92
84
  }
93
85
 
86
+ const TOKEN_DOMAIN = process.env.TUNNEL_DOMAIN || "x.uplink.spot";
87
+ const ALIAS_DOMAIN = process.env.ALIAS_DOMAIN || "uplink.spot";
88
+ const URL_SCHEME = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
89
+
94
90
  // ASCII banner with color styling
95
91
  const ASCII_UPLINK = colorWhite([
96
92
  "██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
@@ -273,7 +269,7 @@ export const menuCommand = new Command("menu")
273
269
  // If authentication failed, show ONLY "Get Started" and "Exit"
274
270
  if (authFailed) {
275
271
  mainMenu.push({
276
- label: "🚀 Get Started (Create Account)",
272
+ label: "Get Started",
277
273
  action: async () => {
278
274
  restoreRawMode();
279
275
  clearScreen();
@@ -301,21 +297,21 @@ export const menuCommand = new Command("menu")
301
297
  });
302
298
  if (!result) {
303
299
  restoreRawMode();
304
- return "Error: No response from server.";
300
+ return "Error: No response from server.";
305
301
  }
306
302
  } catch (err: any) {
307
303
  restoreRawMode();
308
304
  const errorMsg = err?.message || String(err);
309
- console.error("\n❌ Signup error:", errorMsg);
305
+ console.error("\nSignup error:", errorMsg);
310
306
  if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
311
- return "⚠️ Too many signup attempts. Please try again later.";
307
+ return "Too many signup attempts. Please try again later.";
312
308
  }
313
- return `❌ Error creating account: ${errorMsg}`;
309
+ return `Error creating account: ${errorMsg}`;
314
310
  }
315
311
 
316
312
  if (!result || !result.token) {
317
313
  restoreRawMode();
318
- return "Error: Invalid response from server. Token not received.";
314
+ return "Error: Invalid response from server. Token not received.";
319
315
  }
320
316
 
321
317
  const token = result.token;
@@ -455,9 +451,9 @@ export const menuCommand = new Command("menu")
455
451
  restoreRawMode();
456
452
  const errorMsg = err?.message || String(err);
457
453
  if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
458
- return "⚠️ Too many signup attempts. Please try again later.";
454
+ return "Too many signup attempts. Please try again later.";
459
455
  }
460
- return `❌ Error creating account: ${errorMsg}`;
456
+ return `Error creating account: ${errorMsg}`;
461
457
  }
462
458
  },
463
459
  });
@@ -527,6 +523,42 @@ export const menuCommand = new Command("menu")
527
523
  return "test:comprehensive completed";
528
524
  },
529
525
  },
526
+ {
527
+ label: "View Connected Tunnels",
528
+ action: async () => {
529
+ try {
530
+ const data = await apiRequest("GET", "/v1/admin/relay-status") as {
531
+ connectedTunnels?: number;
532
+ tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
533
+ timestamp?: string;
534
+ error?: string;
535
+ message?: string;
536
+ };
537
+
538
+ if (data.error) {
539
+ return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
540
+ }
541
+
542
+ if (!data.tunnels || data.tunnels.length === 0) {
543
+ return "No tunnels currently connected to the relay.";
544
+ }
545
+
546
+ const lines = data.tunnels.map((t) =>
547
+ `${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
548
+ );
549
+
550
+ return [
551
+ `Connected Tunnels: ${data.connectedTunnels}`,
552
+ "",
553
+ "Token Client IP Port Uptime Connected At",
554
+ "-".repeat(75),
555
+ ...lines,
556
+ ].join("\n");
557
+ } catch (err: any) {
558
+ return `Error: Failed to get relay status - ${err.message}`;
559
+ }
560
+ },
561
+ },
530
562
  ],
531
563
  });
532
564
  }
@@ -712,7 +744,7 @@ export const menuCommand = new Command("menu")
712
744
  const userId = parsed?.error?.details?.user_id || "(check your token)";
713
745
  return [
714
746
  "",
715
- colorYellow("🔒 Permanent Aliases - Premium Feature"),
747
+ colorYellow("Permanent Aliases - Premium Feature"),
716
748
  "",
717
749
  "Permanent aliases give you stable URLs like:",
718
750
  ` ${colorGreen(`https://myapp.${ALIAS_DOMAIN}`)}`,
@@ -727,11 +759,11 @@ export const menuCommand = new Command("menu")
727
759
  "",
728
760
  ].join("\n");
729
761
  } catch {
730
- return colorYellow("🔒 Aliases are a premium feature. Contact us at uplink.spot to upgrade.");
762
+ return colorYellow("Aliases are a premium feature. Contact us at uplink.spot to upgrade.");
731
763
  }
732
764
  }
733
765
  if (errMsg.includes("ALIAS_LIMIT_REACHED")) {
734
- return colorYellow("⚠️ You've reached your alias limit. Contact us to increase it.");
766
+ return colorYellow("You've reached your alias limit. Contact us to increase it.");
735
767
  }
736
768
  throw err; // Re-throw other errors
737
769
  }
@@ -756,43 +788,6 @@ export const menuCommand = new Command("menu")
756
788
  return "✓ Alias removed";
757
789
  },
758
790
  },
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
- },
796
791
  ],
797
792
  });
798
793
 
@@ -800,7 +795,7 @@ export const menuCommand = new Command("menu")
800
795
  label: "Usage",
801
796
  subMenu: [
802
797
  {
803
- label: isAdmin ? "List Tunnels (admin)" : "List My Tunnels",
798
+ label: isAdmin ? "List All Tunnels" : "List My Tunnels",
804
799
  action: async () => {
805
800
  const runningClients = findTunnelClients();
806
801
  const path = isAdmin ? "/v1/admin/tunnels?limit=20" : "/v1/tunnels";
@@ -844,7 +839,7 @@ export const menuCommand = new Command("menu")
844
839
  },
845
840
  },
846
841
  {
847
- label: isAdmin ? "List Databases (admin)" : "List My Databases",
842
+ label: isAdmin ? "List All Databases" : "List My Databases",
848
843
  action: async () => {
849
844
  const path = isAdmin ? "/v1/admin/databases?limit=20" : "/v1/dbs";
850
845
  const result = await apiRequest("GET", path);
@@ -875,7 +870,7 @@ export const menuCommand = new Command("menu")
875
870
  // Admin-only: Manage Tokens
876
871
  if (isAdmin) {
877
872
  mainMenu.push({
878
- label: "Manage Tokens (admin)",
873
+ label: "Manage Tokens",
879
874
  subMenu: [
880
875
  {
881
876
  label: "List Tokens",
@@ -1036,9 +1031,9 @@ export const menuCommand = new Command("menu")
1036
1031
  ],
1037
1032
  });
1038
1033
 
1039
- // Admin-only: Stop ALL Tunnel Clients (kill switch)
1034
+ // Admin-only: Stop ALL Tunnel Clients
1040
1035
  mainMenu.push({
1041
- label: "⚠️ Stop ALL Tunnel Clients (kill switch)",
1036
+ label: "Stop All Tunnel Clients",
1042
1037
  action: async () => {
1043
1038
  const clients = findTunnelClients();
1044
1039
  if (clients.length === 0) {
@@ -1166,40 +1161,34 @@ export const menuCommand = new Command("menu")
1166
1161
  const isSelected = idx === selected;
1167
1162
  const branch = isLast ? "└─" : "├─";
1168
1163
 
1169
- // Clean up labels - remove emojis for cleaner look
1170
- let cleanLabel = choice.label
1171
- .replace(/^🚀\s*/, "")
1172
- .replace(/^⚠️\s*/, "")
1173
- .replace(/^✅\s*/, "")
1174
- .replace(/^❌\s*/, "");
1175
-
1176
1164
  // Style based on selection and type
1177
1165
  let label: string;
1178
1166
  let branchColor: string;
1167
+ const labelLower = choice.label.toLowerCase();
1179
1168
 
1180
1169
  if (isSelected) {
1181
1170
  // Selected: cyan highlight
1182
1171
  branchColor = colorCyan(branch);
1183
- if (cleanLabel.toLowerCase().includes("exit")) {
1184
- label = colorDim(cleanLabel);
1185
- } else if (cleanLabel.toLowerCase().includes("stop all") || cleanLabel.toLowerCase().includes("kill")) {
1186
- label = colorRed(cleanLabel);
1187
- } else if (cleanLabel.toLowerCase().includes("get started")) {
1188
- label = colorGreen(cleanLabel);
1172
+ if (labelLower.includes("exit")) {
1173
+ label = colorDim(choice.label);
1174
+ } else if (labelLower.includes("stop all")) {
1175
+ label = colorRed(choice.label);
1176
+ } else if (labelLower.includes("get started")) {
1177
+ label = colorGreen(choice.label);
1189
1178
  } else {
1190
- label = colorCyan(cleanLabel);
1179
+ label = colorCyan(choice.label);
1191
1180
  }
1192
1181
  } else {
1193
1182
  // Not selected: white text
1194
1183
  branchColor = colorWhite(branch);
1195
- if (cleanLabel.toLowerCase().includes("exit")) {
1196
- label = colorDim(cleanLabel);
1197
- } else if (cleanLabel.toLowerCase().includes("stop all") || cleanLabel.toLowerCase().includes("kill")) {
1198
- label = colorRed(cleanLabel);
1199
- } else if (cleanLabel.toLowerCase().includes("get started")) {
1200
- label = colorGreen(cleanLabel);
1184
+ if (labelLower.includes("exit")) {
1185
+ label = colorDim(choice.label);
1186
+ } else if (labelLower.includes("stop all")) {
1187
+ label = colorRed(choice.label);
1188
+ } else if (labelLower.includes("get started")) {
1189
+ label = colorGreen(choice.label);
1201
1190
  } else {
1202
- label = colorWhite(cleanLabel);
1191
+ label = colorWhite(choice.label);
1203
1192
  }
1204
1193
  }
1205
1194
 
@@ -1220,14 +1209,11 @@ export const menuCommand = new Command("menu")
1220
1209
  const lines = message.split("\n");
1221
1210
  lines.forEach((line) => {
1222
1211
  // Color success/error indicators
1212
+ // Style success/error prefixes consistently
1223
1213
  let styledLine = line
1224
- .replace(/^✅/, colorGreen("✓"))
1225
- .replace(/^❌/, colorRed(""))
1226
- .replace(/^⚠️/, colorYellow("!"))
1227
- .replace(/^🔑/, colorCyan("→"))
1228
- .replace(/^🌐/, colorCyan("→"))
1229
- .replace(/^📡/, colorCyan("→"))
1230
- .replace(/^💡/, colorYellow("→"));
1214
+ .replace(/^✓\s*/, colorGreen("✓ "))
1215
+ .replace(/^→\s*/, colorCyan(""))
1216
+ .replace(/^Error:\s*/, colorRed(""));
1231
1217
  console.log(colorDim("│ ") + styledLine);
1232
1218
  });
1233
1219
  }
@@ -1424,7 +1410,7 @@ function runSmoke(script: "smoke:tunnel" | "smoke:db" | "smoke:all" | "test:comp
1424
1410
  const env = {
1425
1411
  ...process.env,
1426
1412
  AGENTCLOUD_API_BASE: process.env.AGENTCLOUD_API_BASE ?? "https://api.uplink.spot",
1427
- AGENTCLOUD_TOKEN: process.env.AGENTCLOUD_TOKEN ?? "dev-token",
1413
+ AGENTCLOUD_TOKEN: process.env.AGENTCLOUD_TOKEN,
1428
1414
  };
1429
1415
 
1430
1416
  // For test:comprehensive, run inline (no subprocess)
package/package.json CHANGED
@@ -1,17 +1,23 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.1.0-alpha.5",
4
- "description": "Localhost to public URL in seconds. No signup forms, no browser - everything in your terminal.",
3
+ "version": "0.1.0-alpha.7",
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",
7
7
  "localhost",
8
8
  "ngrok",
9
+ "ngrok-alternative",
9
10
  "expose",
10
11
  "cli",
11
12
  "dev-tools",
12
13
  "webhook",
13
14
  "public-url",
14
- "port-forwarding"
15
+ "port-forwarding",
16
+ "reverse-proxy",
17
+ "tunneling",
18
+ "local-development",
19
+ "https-tunnel",
20
+ "custom-domain"
15
21
  ],
16
22
  "homepage": "https://github.com/firstprinciplecode/uplink#readme",
17
23
  "repository": {