uplink-cli 0.1.4 → 0.1.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.
@@ -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");
@@ -38,7 +38,7 @@ export const devCommand = new Command("dev")
38
38
  "tunnel",
39
39
  clientFile
40
40
  );
41
- const ctrlHost = process.env.TUNNEL_CTRL ?? "127.0.0.1:7071";
41
+ const ctrlHost = process.env.TUNNEL_CTRL ?? "tunnel.uplink.spot:7071";
42
42
  const args = [
43
43
  clientPath,
44
44
  "--token",
@@ -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.4",
3
+ "version": "0.1.7",
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",