uplink-cli 0.1.3 → 0.1.6

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.
@@ -0,0 +1,55 @@
1
+ import { apiRequest } from "../http";
2
+
3
+ export type Tunnel = {
4
+ id: string;
5
+ url?: string;
6
+ ingressHttpUrl?: string;
7
+ token?: string;
8
+ alias?: string | null;
9
+ status?: string;
10
+ createdAt?: string;
11
+ updatedAt?: string;
12
+ };
13
+
14
+ export class TunnelsClient {
15
+ async create(port: number, opts?: { project?: string; alias?: string }) {
16
+ const tunnel = await apiRequest("POST", "/v1/tunnels", {
17
+ port,
18
+ project: opts?.project,
19
+ }) as Tunnel;
20
+
21
+ let aliasError: string | null = null;
22
+ if (opts?.alias) {
23
+ try {
24
+ const aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
25
+ alias: opts.alias,
26
+ }) as Tunnel;
27
+ tunnel.alias = aliasResult.alias ?? tunnel.alias;
28
+ } catch (err: any) {
29
+ aliasError = err?.message || String(err);
30
+ }
31
+ }
32
+
33
+ return { tunnel, aliasError };
34
+ }
35
+
36
+ async list() {
37
+ return apiRequest("GET", "/v1/tunnels") as Promise<{ tunnels: Tunnel[]; count: number }>;
38
+ }
39
+
40
+ async setAlias(id: string, alias: string) {
41
+ return apiRequest("POST", `/v1/tunnels/${id}/alias`, { alias }) as Promise<Tunnel>;
42
+ }
43
+
44
+ async deleteAlias(id: string) {
45
+ return apiRequest("DELETE", `/v1/tunnels/${id}/alias`) as Promise<Tunnel>;
46
+ }
47
+
48
+ async stats(id: string) {
49
+ return apiRequest("GET", `/v1/tunnels/${id}/stats`);
50
+ }
51
+
52
+ async stop(id: string) {
53
+ return apiRequest("DELETE", `/v1/tunnels/${id}`) as Promise<{ id: string; status: string }>;
54
+ }
55
+ }
package/cli/src/index.ts CHANGED
@@ -4,19 +4,54 @@ import { dbCommand } from "./subcommands/db";
4
4
  import { devCommand } from "./subcommands/dev";
5
5
  import { adminCommand } from "./subcommands/admin";
6
6
  import { menuCommand } from "./subcommands/menu";
7
+ import { tunnelCommand } from "./subcommands/tunnel";
8
+ import { signupCommand } from "./subcommands/signup";
7
9
 
8
10
  const program = new Command();
9
11
 
10
12
  program
11
13
  .name("uplink")
12
14
  .description("Agent-friendly cloud CLI")
13
- .version("0.1.0");
15
+ .version("0.1.0")
16
+ .option("--api-base <url>", "Override API base URL (default env AGENTCLOUD_API_BASE)")
17
+ .option("--token-stdin", "Read AGENTCLOUD_TOKEN from stdin once");
14
18
 
15
19
  program.addCommand(dbCommand);
16
20
  program.addCommand(devCommand);
17
21
  program.addCommand(adminCommand);
22
+ program.addCommand(tunnelCommand);
23
+ program.addCommand(signupCommand);
18
24
  program.addCommand(menuCommand);
19
25
 
26
+ // Global pre-action hook to apply shared options
27
+ let cachedTokenStdin: string | null = null;
28
+ program.hook("preAction", async (thisCommand) => {
29
+ // Collect global options
30
+ const opts =
31
+ typeof thisCommand.optsWithGlobals === "function"
32
+ ? thisCommand.optsWithGlobals()
33
+ : thisCommand.opts();
34
+
35
+ if (opts.apiBase) {
36
+ process.env.AGENTCLOUD_API_BASE = String(opts.apiBase);
37
+ }
38
+
39
+ if (opts.tokenStdin) {
40
+ if (!cachedTokenStdin) {
41
+ cachedTokenStdin = await new Promise<string>((resolve, reject) => {
42
+ let data = "";
43
+ process.stdin.setEncoding("utf8");
44
+ process.stdin.on("data", (chunk) => (data += chunk));
45
+ process.stdin.on("end", () => resolve(data.trim()));
46
+ process.stdin.on("error", (err) => reject(err));
47
+ });
48
+ }
49
+ if (cachedTokenStdin) {
50
+ process.env.AGENTCLOUD_TOKEN = cachedTokenStdin;
51
+ }
52
+ }
53
+ });
54
+
20
55
  // If no command provided and not a flag, default to menu
21
56
  if (process.argv.length === 2) {
22
57
  process.argv.push("menu");
@@ -8,6 +8,11 @@ import { homedir } from "os";
8
8
  import { join } from "path";
9
9
  import { existsSync, readFileSync, writeFileSync } from "fs";
10
10
 
11
+ // Check if running from project directory (smoke tests require local scripts)
12
+ function isInProjectDir(): boolean {
13
+ return existsSync("scripts/test-comprehensive.sh") && existsSync("package.json");
14
+ }
15
+
11
16
  type MenuChoice = {
12
17
  label: string;
13
18
  action?: () => Promise<string>;
@@ -471,33 +476,36 @@ export const menuCommand = new Command("menu")
471
476
  // Only show other menu items if authentication succeeded
472
477
 
473
478
  if (isAdmin) {
474
- mainMenu.push({
475
- label: "System Status",
476
- subMenu: [
477
- {
478
- label: "View Status",
479
- action: async () => {
480
- let health = "unknown";
481
- try {
482
- const res = await fetch(`${apiBase}/health`);
483
- const json = await res.json().catch(() => ({}));
484
- health = json.status || res.statusText || "unknown";
485
- } catch {
486
- health = "unreachable";
487
- }
479
+ const systemStatusItems: MenuChoice[] = [
480
+ {
481
+ label: "View Status",
482
+ action: async () => {
483
+ let health = "unknown";
484
+ try {
485
+ const res = await fetch(`${apiBase}/health`);
486
+ const json = await res.json().catch(() => ({}));
487
+ health = json.status || res.statusText || "unknown";
488
+ } catch {
489
+ health = "unreachable";
490
+ }
488
491
 
489
- const stats = await apiRequest("GET", "/v1/admin/stats");
490
- return [
491
- `API health: ${health}`,
492
- "Tunnels:",
493
- ` Active ${stats.tunnels.active} | Inactive ${stats.tunnels.inactive} | Deleted ${stats.tunnels.deleted} | Total ${stats.tunnels.total}`,
494
- ` Created last 24h: ${stats.tunnels.createdLast24h}`,
495
- "Databases:",
496
- ` Ready ${stats.databases.ready} | Provisioning ${stats.databases.provisioning} | Failed ${stats.databases.failed} | Deleted ${stats.databases.deleted} | Total ${stats.databases.total}`,
497
- ` Created last 24h: ${stats.databases.createdLast24h}`,
498
- ].join("\n");
499
- },
492
+ const stats = await apiRequest("GET", "/v1/admin/stats");
493
+ return [
494
+ `API health: ${health}`,
495
+ "Tunnels:",
496
+ ` Active ${stats.tunnels.active} | Inactive ${stats.tunnels.inactive} | Deleted ${stats.tunnels.deleted} | Total ${stats.tunnels.total}`,
497
+ ` Created last 24h: ${stats.tunnels.createdLast24h}`,
498
+ "Databases:",
499
+ ` Ready ${stats.databases.ready} | Provisioning ${stats.databases.provisioning} | Failed ${stats.databases.failed} | Deleted ${stats.databases.deleted} | Total ${stats.databases.total}`,
500
+ ` Created last 24h: ${stats.databases.createdLast24h}`,
501
+ ].join("\n");
500
502
  },
503
+ },
504
+ ];
505
+
506
+ // Smoke tests only available when running from project directory
507
+ if (isInProjectDir()) {
508
+ systemStatusItems.push(
501
509
  {
502
510
  label: "Test: Tunnel",
503
511
  action: async () => {
@@ -525,72 +533,80 @@ export const menuCommand = new Command("menu")
525
533
  await runSmoke("test:comprehensive");
526
534
  return "test:comprehensive completed";
527
535
  },
528
- },
529
- {
530
- label: "View Connected Tunnels",
531
- action: async () => {
532
- try {
533
- const data = await apiRequest("GET", "/v1/admin/relay-status") as {
534
- connectedTunnels?: number;
535
- tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
536
- timestamp?: string;
537
- error?: string;
538
- message?: string;
539
- };
540
-
541
- if (data.error) {
542
- return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
543
- }
544
- if (!data.tunnels || data.tunnels.length === 0) {
545
- return "No tunnels currently connected to the relay.";
546
- }
547
-
548
- const lines = data.tunnels.map((t) =>
549
- `${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
550
- );
536
+ }
537
+ );
538
+ }
551
539
 
552
- return [
553
- `Connected Tunnels: ${data.connectedTunnels}`,
554
- "",
555
- "Token Client IP Port Uptime Connected At",
556
- "-".repeat(75),
557
- ...lines,
558
- ].join("\n");
559
- } catch (err: any) {
560
- return `Error: Failed to get relay status - ${err.message}`;
561
- }
562
- },
563
- },
564
- {
565
- label: "View Traffic Stats",
566
- action: async () => {
567
- const data = await apiRequest("GET", "/v1/admin/traffic-stats?sync=true") as {
568
- count?: number;
569
- aliases?: Array<{ alias: string; requests: number; bytesIn: number; bytesOut: number; lastSeenAt: string | null; lastStatus: number | null }>;
540
+ systemStatusItems.push(
541
+ {
542
+ label: "View Connected Tunnels",
543
+ action: async () => {
544
+ try {
545
+ const data = await apiRequest("GET", "/v1/admin/relay-status") as {
546
+ connectedTunnels?: number;
547
+ tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
548
+ timestamp?: string;
549
+ error?: string;
550
+ message?: string;
570
551
  };
571
552
 
572
- const aliases = data.aliases || [];
573
- if (aliases.length === 0) return "No persisted alias stats yet.";
553
+ if (data.error) {
554
+ return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
555
+ }
556
+ if (!data.tunnels || data.tunnels.length === 0) {
557
+ return "No tunnels currently connected to the relay.";
558
+ }
574
559
 
575
- const top = aliases.slice(0, 25);
576
- const lines = top.map((a) => {
577
- const last = a.lastSeenAt ? truncate(a.lastSeenAt, 19) : "-";
578
- return `${truncate(a.alias, 22).padEnd(24)} ${String(a.requests || 0).padStart(8)} ${formatBytes(a.bytesIn || 0).padStart(9)} ${formatBytes(a.bytesOut || 0).padStart(9)} ${String(a.lastStatus ?? "-").padStart(3)} ${last}`;
579
- });
560
+ const lines = data.tunnels.map((t) =>
561
+ `${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
562
+ );
580
563
 
581
564
  return [
582
- `Aliases tracked: ${aliases.length}`,
565
+ `Connected Tunnels: ${data.connectedTunnels}`,
583
566
  "",
584
- "Alias Requests In Out Sts Last Seen",
567
+ "Token Client IP Port Uptime Connected At",
585
568
  "-".repeat(75),
586
569
  ...lines,
587
- aliases.length > top.length ? `\nShowing top ${top.length} by requests.` : "",
588
- ]
589
- .filter(Boolean)
590
- .join("\n");
591
- },
570
+ ].join("\n");
571
+ } catch (err: any) {
572
+ return `Error: Failed to get relay status - ${err.message}`;
573
+ }
592
574
  },
593
- ],
575
+ },
576
+ {
577
+ label: "View Traffic Stats",
578
+ action: async () => {
579
+ const data = await apiRequest("GET", "/v1/admin/traffic-stats?sync=true") as {
580
+ count?: number;
581
+ aliases?: Array<{ alias: string; requests: number; bytesIn: number; bytesOut: number; lastSeenAt: string | null; lastStatus: number | null }>;
582
+ };
583
+
584
+ const aliases = data.aliases || [];
585
+ if (aliases.length === 0) return "No persisted alias stats yet.";
586
+
587
+ const top = aliases.slice(0, 25);
588
+ const lines = top.map((a) => {
589
+ const last = a.lastSeenAt ? truncate(a.lastSeenAt, 19) : "-";
590
+ return `${truncate(a.alias, 22).padEnd(24)} ${String(a.requests || 0).padStart(8)} ${formatBytes(a.bytesIn || 0).padStart(9)} ${formatBytes(a.bytesOut || 0).padStart(9)} ${String(a.lastStatus ?? "-").padStart(3)} ${last}`;
591
+ });
592
+
593
+ return [
594
+ `Aliases tracked: ${aliases.length}`,
595
+ "",
596
+ "Alias Requests In Out Sts Last Seen",
597
+ "-".repeat(75),
598
+ ...lines,
599
+ aliases.length > top.length ? `\nShowing top ${top.length} by requests.` : "",
600
+ ]
601
+ .filter(Boolean)
602
+ .join("\n");
603
+ },
604
+ }
605
+ );
606
+
607
+ mainMenu.push({
608
+ label: "System Status",
609
+ subMenu: systemStatusItems,
594
610
  });
595
611
  }
596
612
 
@@ -0,0 +1,79 @@
1
+ import { Command } from "commander";
2
+ import { printJson, handleError } from "../utils/machine";
3
+
4
+ type SignupResponse = {
5
+ id: string;
6
+ token: string;
7
+ tokenPrefix: string;
8
+ role: string;
9
+ userId: string;
10
+ label: string;
11
+ createdAt: string;
12
+ expiresAt: string | null;
13
+ message: string;
14
+ };
15
+
16
+ /**
17
+ * Unauthenticated request for signup (no token required)
18
+ */
19
+ async function signupRequest(body: Record<string, unknown>): Promise<SignupResponse> {
20
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
21
+ const url = `${apiBase}/v1/signup`;
22
+
23
+ const res = await fetch(url, {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify(body),
27
+ });
28
+
29
+ const json = await res.json();
30
+
31
+ if (!res.ok) {
32
+ const code = json?.error?.code || "SIGNUP_ERROR";
33
+ const msg = json?.error?.message || json?.message || res.statusText;
34
+ throw new Error(`${code}: ${msg}`);
35
+ }
36
+
37
+ return json as SignupResponse;
38
+ }
39
+
40
+ export const signupCommand = new Command("signup")
41
+ .description("Create a new user account and token (no auth required)")
42
+ .option("--label <label>", "Optional label for the token")
43
+ .option("--expires-days <days>", "Token expiration in days (optional)")
44
+ .option("--json", "Output JSON", false)
45
+ .action(async (opts) => {
46
+ try {
47
+ const body: Record<string, unknown> = {};
48
+ if (opts.label) body.label = opts.label;
49
+ if (opts.expiresDays) {
50
+ const days = Number(opts.expiresDays);
51
+ if (!Number.isFinite(days) || days <= 0) {
52
+ console.error("Invalid --expires-days. Provide a positive number.");
53
+ process.exit(2);
54
+ }
55
+ body.expiresInDays = days;
56
+ }
57
+
58
+ const result = await signupRequest(body);
59
+
60
+ if (opts.json) {
61
+ printJson(result);
62
+ } else {
63
+ console.log("Account created successfully!");
64
+ console.log("");
65
+ console.log(` Token: ${result.token}`);
66
+ console.log(` User ID: ${result.userId}`);
67
+ console.log(` Role: ${result.role}`);
68
+ console.log(` Label: ${result.label}`);
69
+ console.log(` Expires: ${result.expiresAt ?? "never"}`);
70
+ console.log("");
71
+ console.log("Save this token securely - it will not be shown again.");
72
+ console.log("");
73
+ console.log("To use this token, set the environment variable:");
74
+ console.log(` export AGENTCLOUD_TOKEN="${result.token}"`);
75
+ }
76
+ } catch (error) {
77
+ handleError(error, { json: opts.json });
78
+ }
79
+ });
@@ -0,0 +1,192 @@
1
+ import { Command } from "commander";
2
+ import { apiRequest } from "../http";
3
+ import { handleError, printJson } from "../utils/machine";
4
+
5
+ type TunnelResponse = {
6
+ id: string;
7
+ url?: string;
8
+ host?: string;
9
+ port?: number;
10
+ token?: string;
11
+ alias?: string | null;
12
+ status?: string;
13
+ createdAt?: string;
14
+ updatedAt?: string;
15
+ ingressHttpUrl?: string;
16
+ };
17
+
18
+ type TunnelListResponse = {
19
+ tunnels: TunnelResponse[];
20
+ count: number;
21
+ };
22
+
23
+ type TunnelStatsResponse = any;
24
+
25
+ export const tunnelCommand = new Command("tunnel")
26
+ .description("Manage tunnels non-interactively (agent-friendly)");
27
+
28
+ // Create tunnel
29
+ tunnelCommand
30
+ .command("create")
31
+ .description("Create a tunnel")
32
+ .requiredOption("--port <port>", "Local port to expose")
33
+ .option("--alias <alias>", "Optional permanent alias (if enabled on account)")
34
+ .option("--project <project>", "Optional project id")
35
+ .option("--json", "Output JSON", false)
36
+ .action(async (opts) => {
37
+ const port = Number(opts.port);
38
+ if (!Number.isFinite(port) || port <= 0) {
39
+ console.error("Invalid port. Provide a positive integer.");
40
+ process.exit(2);
41
+ }
42
+
43
+ try {
44
+ const body: Record<string, unknown> = { port };
45
+ if (opts.project) body.project = opts.project;
46
+
47
+ const tunnel = await apiRequest("POST", "/v1/tunnels", body) as TunnelResponse;
48
+ let aliasResult: TunnelResponse | null = null;
49
+ let aliasError: string | null = null;
50
+
51
+ if (opts.alias) {
52
+ try {
53
+ aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
54
+ alias: opts.alias,
55
+ }) as TunnelResponse;
56
+ } catch (err: any) {
57
+ aliasError = err?.message || String(err);
58
+ }
59
+ }
60
+
61
+ if (opts.json) {
62
+ printJson({
63
+ tunnel,
64
+ alias: aliasResult?.alias ?? null,
65
+ aliasError,
66
+ });
67
+ } else {
68
+ console.log(`Created tunnel ${tunnel.id}`);
69
+ console.log(` url: ${tunnel.url ?? tunnel.ingressHttpUrl ?? "-"}`);
70
+ console.log(` token: ${tunnel.token ?? "-"}`);
71
+ if (opts.alias) {
72
+ if (aliasResult?.alias) {
73
+ console.log(` alias: ${aliasResult.alias}`);
74
+ } else if (aliasError) {
75
+ console.log(` alias: failed - ${aliasError}`);
76
+ }
77
+ } else if (tunnel.alias) {
78
+ console.log(` alias: ${tunnel.alias}`);
79
+ }
80
+ }
81
+ } catch (error) {
82
+ handleError(error, { json: opts.json });
83
+ }
84
+ });
85
+
86
+ // List tunnels
87
+ tunnelCommand
88
+ .command("list")
89
+ .description("List your tunnels")
90
+ .option("--json", "Output JSON", false)
91
+ .action(async (opts) => {
92
+ try {
93
+ const result = await apiRequest("GET", "/v1/tunnels") as TunnelListResponse;
94
+ if (opts.json) {
95
+ printJson(result);
96
+ } else {
97
+ if (!result.tunnels || result.tunnels.length === 0) {
98
+ console.log("No tunnels found.");
99
+ return;
100
+ }
101
+ console.log(`Tunnels (${result.count}):`);
102
+ for (const t of result.tunnels) {
103
+ console.log(
104
+ `${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${t.token ?? "-"} alias=${t.alias ?? "-"} status=${t.status ?? "-"}`
105
+ );
106
+ }
107
+ }
108
+ } catch (error) {
109
+ handleError(error, { json: opts.json });
110
+ }
111
+ });
112
+
113
+ // Set alias
114
+ tunnelCommand
115
+ .command("alias-set")
116
+ .description("Set or update an alias for a tunnel")
117
+ .requiredOption("--id <id>", "Tunnel id")
118
+ .requiredOption("--alias <alias>", "Alias to set")
119
+ .option("--json", "Output JSON", false)
120
+ .action(async (opts) => {
121
+ try {
122
+ const result = await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
123
+ alias: opts.alias,
124
+ }) as TunnelResponse;
125
+ if (opts.json) {
126
+ printJson(result);
127
+ } else {
128
+ console.log(`Alias set: ${result.alias ?? opts.alias} -> ${result.url ?? "-"}`);
129
+ }
130
+ } catch (error) {
131
+ handleError(error, { json: opts.json });
132
+ }
133
+ });
134
+
135
+ // Delete alias
136
+ tunnelCommand
137
+ .command("alias-delete")
138
+ .description("Remove alias from a tunnel")
139
+ .requiredOption("--id <id>", "Tunnel id")
140
+ .option("--json", "Output JSON", false)
141
+ .action(async (opts) => {
142
+ try {
143
+ const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}/alias`) as TunnelResponse;
144
+ if (opts.json) {
145
+ printJson(result);
146
+ } else {
147
+ console.log(`Alias removed for tunnel ${result.id}`);
148
+ }
149
+ } catch (error) {
150
+ handleError(error, { json: opts.json });
151
+ }
152
+ });
153
+
154
+ // Stats
155
+ tunnelCommand
156
+ .command("stats")
157
+ .description("Get tunnel stats (in-memory or alias totals)")
158
+ .requiredOption("--id <id>", "Tunnel id")
159
+ .option("--json", "Output JSON", false)
160
+ .action(async (opts) => {
161
+ try {
162
+ const result = await apiRequest("GET", `/v1/tunnels/${opts.id}/stats`) as TunnelStatsResponse;
163
+ if (opts.json) {
164
+ printJson(result);
165
+ } else {
166
+ console.log(`Stats for tunnel ${opts.id}`);
167
+ console.log(JSON.stringify(result, null, 2));
168
+ }
169
+ } catch (error) {
170
+ handleError(error, { json: opts.json });
171
+ }
172
+ });
173
+
174
+ // Stop (delete) tunnel
175
+ tunnelCommand
176
+ .command("stop")
177
+ .description("Stop (delete) a tunnel")
178
+ .requiredOption("--id <id>", "Tunnel id")
179
+ .option("--json", "Output JSON", false)
180
+ .action(async (opts) => {
181
+ try {
182
+ const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}`) as { id: string; status: string };
183
+ if (opts.json) {
184
+ printJson(result);
185
+ } else {
186
+ console.log(`Stopped tunnel ${result.id} (status=${result.status})`);
187
+ }
188
+ } catch (error: any) {
189
+ console.error(error?.message || String(error));
190
+ process.exit(30);
191
+ }
192
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Machine-mode helpers for CLI commands.
3
+ * Ensures JSON-only stdout in --json mode and consistent exit codes.
4
+ */
5
+
6
+ export type MachineOptions = { json?: boolean };
7
+
8
+ export function printJson(data: unknown) {
9
+ process.stdout.write(JSON.stringify(data, null, 2));
10
+ process.stdout.write("\n");
11
+ }
12
+
13
+ export function selectExitCode(message: string): number {
14
+ const msg = message.toLowerCase();
15
+ if (
16
+ msg.includes("missing agentcloud_token") ||
17
+ msg.includes("unauthorized") ||
18
+ msg.includes("forbidden") ||
19
+ msg.includes("401") ||
20
+ msg.includes("403")
21
+ ) {
22
+ return 10; // auth missing/invalid
23
+ }
24
+ if (
25
+ msg.includes("econnrefused") ||
26
+ msg.includes("enotfound") ||
27
+ msg.includes("etimedout") ||
28
+ msg.includes("econnreset") ||
29
+ msg.includes("socket hang up") ||
30
+ msg.includes("network")
31
+ ) {
32
+ return 20; // network
33
+ }
34
+ return 30; // server/unknown
35
+ }
36
+
37
+ export function handleError(error: unknown, opts: MachineOptions = {}) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ if (opts.json) {
40
+ printJson({ error: message });
41
+ } else {
42
+ console.error(message);
43
+ }
44
+ process.exit(selectExitCode(message));
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
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",