uplink-cli 0.1.0-alpha.7 → 0.1.0

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,56 @@
1
+ // Color palette and helpers for the interactive menu UI.
2
+ export const c = {
3
+ reset: "\x1b[0m",
4
+ bold: "\x1b[1m",
5
+ dim: "\x1b[2m",
6
+ // Colors
7
+ cyan: "\x1b[36m",
8
+ green: "\x1b[32m",
9
+ yellow: "\x1b[33m",
10
+ red: "\x1b[31m",
11
+ magenta: "\x1b[35m",
12
+ white: "\x1b[97m",
13
+ gray: "\x1b[90m",
14
+ // Bright variants
15
+ brightCyan: "\x1b[96m",
16
+ brightGreen: "\x1b[92m",
17
+ brightYellow: "\x1b[93m",
18
+ brightWhite: "\x1b[97m",
19
+ };
20
+
21
+ export function colorCyan(text: string) {
22
+ return `${c.brightCyan}${text}${c.reset}`;
23
+ }
24
+
25
+ export function colorYellow(text: string) {
26
+ return `${c.yellow}${text}${c.reset}`;
27
+ }
28
+
29
+ export function colorGreen(text: string) {
30
+ return `${c.brightGreen}${text}${c.reset}`;
31
+ }
32
+
33
+ export function colorDim(text: string) {
34
+ return `${c.dim}${text}${c.reset}`;
35
+ }
36
+
37
+ export function colorBold(text: string) {
38
+ return `${c.bold}${c.brightWhite}${text}${c.reset}`;
39
+ }
40
+
41
+ export function colorRed(text: string) {
42
+ return `${c.red}${text}${c.reset}`;
43
+ }
44
+
45
+ export function colorWhite(text: string) {
46
+ return `${c.brightWhite}${text}${c.reset}`;
47
+ }
48
+
49
+ export const ASCII_UPLINK = colorWhite([
50
+ "██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
51
+ "██║ ██║██╔══██╗██║ ██║████╗ ██║██║ ██╔╝",
52
+ "██║ ██║██████╔╝██║ ██║██╔██╗ ██║█████╔╝ ",
53
+ "██║ ██║██╔═══╝ ██║ ██║██║╚██╗██║██╔═██╗ ",
54
+ "╚██████╔╝██║ ███████╗██║██║ ╚████║██║ ██╗",
55
+ " ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝",
56
+ ].join("\n"));
@@ -0,0 +1,91 @@
1
+ import { colorCyan, colorDim, colorWhite } from "./colors";
2
+
3
+ export type SelectOption = { label: string; value: string | number | null };
4
+
5
+ // Inline arrow-key selector (returns selected index, or null for "Back")
6
+ export async function inlineSelect(
7
+ title: string,
8
+ options: SelectOption[],
9
+ includeBack: boolean = true
10
+ ): Promise<{ index: number; value: string | number | null } | null> {
11
+ return new Promise((resolve) => {
12
+ const allOptions = includeBack ? [...options, { label: "Back", value: null }] : options;
13
+ let selected = 0;
14
+
15
+ const renderSelector = () => {
16
+ const linesToClear = allOptions.length + 3;
17
+ process.stdout.write(`\x1b[${linesToClear}A\x1b[0J`);
18
+
19
+ console.log();
20
+ console.log(colorDim(title));
21
+ console.log();
22
+
23
+ allOptions.forEach((opt, idx) => {
24
+ const isLast = idx === allOptions.length - 1;
25
+ const isSelected = idx === selected;
26
+ const branch = isLast ? "└─" : "├─";
27
+
28
+ let label: string;
29
+ let branchColor: string;
30
+
31
+ if (isSelected) {
32
+ branchColor = colorCyan(branch);
33
+ label = opt.label === "Back" ? colorDim(opt.label) : colorCyan(opt.label);
34
+ } else {
35
+ branchColor = colorWhite(branch);
36
+ label = opt.label === "Back" ? colorDim(opt.label) : colorWhite(opt.label);
37
+ }
38
+
39
+ console.log(`${branchColor} ${label}`);
40
+ });
41
+ };
42
+
43
+ console.log();
44
+ console.log(colorDim(title));
45
+ console.log();
46
+ allOptions.forEach((opt, idx) => {
47
+ const isLast = idx === allOptions.length - 1;
48
+ const branch = isLast ? "└─" : "├─";
49
+ const branchColor = idx === 0 ? colorCyan(branch) : colorWhite(branch);
50
+ const label = idx === 0 ? colorCyan(opt.label) : opt.label === "Back" ? colorDim(opt.label) : colorWhite(opt.label);
51
+ console.log(`${branchColor} ${label}`);
52
+ });
53
+
54
+ try {
55
+ process.stdin.setRawMode(true);
56
+ process.stdin.resume();
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+
61
+ const keyHandler = (key: Buffer) => {
62
+ const str = key.toString();
63
+
64
+ if (str === "\u0003") {
65
+ process.stdin.removeListener("data", keyHandler);
66
+ process.stdin.setRawMode(false);
67
+ process.stdin.pause();
68
+ process.exit(0);
69
+ } else if (str === "\u001b[A") {
70
+ selected = (selected - 1 + allOptions.length) % allOptions.length;
71
+ renderSelector();
72
+ } else if (str === "\u001b[B") {
73
+ selected = (selected + 1) % allOptions.length;
74
+ renderSelector();
75
+ } else if (str === "\u001b[D") {
76
+ process.stdin.removeListener("data", keyHandler);
77
+ resolve(null);
78
+ } else if (str === "\r") {
79
+ process.stdin.removeListener("data", keyHandler);
80
+ const selectedOption = allOptions[selected];
81
+ if (selectedOption.label === "Back" || selectedOption.value === null) {
82
+ resolve(null);
83
+ } else {
84
+ resolve({ index: selected, value: selectedOption.value });
85
+ }
86
+ }
87
+ };
88
+
89
+ process.stdin.on("data", keyHandler);
90
+ });
91
+ }
@@ -0,0 +1,34 @@
1
+ import readline from "readline";
2
+
3
+ export function promptLine(question: string): Promise<string> {
4
+ return new Promise((resolve) => {
5
+ try {
6
+ process.stdin.setRawMode(false);
7
+ } catch {
8
+ /* ignore */
9
+ }
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ rl.question(question, (answer) => {
12
+ rl.close();
13
+ resolve(answer);
14
+ });
15
+ });
16
+ }
17
+
18
+ export function clearScreen() {
19
+ process.stdout.write("\x1b[2J\x1b[0f");
20
+ }
21
+
22
+ export function truncate(text: string, max: number) {
23
+ if (text.length <= max) return text;
24
+ return text.slice(0, max - 1) + "…";
25
+ }
26
+
27
+ export function restoreRawMode() {
28
+ try {
29
+ process.stdin.setRawMode(true);
30
+ process.stdin.resume();
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ }
@@ -0,0 +1,18 @@
1
+ import fetch from "node-fetch";
2
+
3
+ export async function unauthenticatedRequest(method: string, path: string, body?: unknown): Promise<any> {
4
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
5
+ const response = await fetch(`${apiBase}${path}`, {
6
+ method,
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ },
10
+ body: body ? JSON.stringify(body) : undefined,
11
+ });
12
+
13
+ const json = await response.json().catch(() => ({}));
14
+ if (!response.ok) {
15
+ throw new Error(JSON.stringify(json, null, 2));
16
+ }
17
+ return json;
18
+ }
@@ -0,0 +1,211 @@
1
+ import fetch from "node-fetch";
2
+ import { spawn } from "child_process";
3
+ import { join } from "path";
4
+
5
+ export function runSmoke(script: "smoke:tunnel" | "smoke:db" | "smoke:all" | "test:comprehensive") {
6
+ return new Promise<void>((resolve, reject) => {
7
+ const projectRoot = join(__dirname, "../../..");
8
+ const env = {
9
+ ...process.env,
10
+ AGENTCLOUD_API_BASE: process.env.AGENTCLOUD_API_BASE ?? "https://api.uplink.spot",
11
+ AGENTCLOUD_TOKEN: process.env.AGENTCLOUD_TOKEN,
12
+ };
13
+
14
+ if (script === "test:comprehensive") {
15
+ runComprehensiveTest(env).then(resolve).catch(reject);
16
+ return;
17
+ }
18
+
19
+ const child = spawn("npm", ["run", script], {
20
+ stdio: "inherit",
21
+ env,
22
+ cwd: projectRoot,
23
+ shell: true,
24
+ });
25
+ child.on("close", (code) => {
26
+ if (code === 0) {
27
+ resolve();
28
+ } else {
29
+ reject(new Error(`${script} failed with exit code ${code}`));
30
+ }
31
+ });
32
+ child.on("error", (err) => reject(err));
33
+ });
34
+ }
35
+
36
+ async function runComprehensiveTest(env: Record<string, string | undefined>) {
37
+ const API_BASE = env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
38
+ const ADMIN_TOKEN = env.AGENTCLOUD_TOKEN || "";
39
+
40
+ const c = {
41
+ reset: "\x1b[0m",
42
+ red: "\x1b[31m",
43
+ green: "\x1b[32m",
44
+ yellow: "\x1b[33m",
45
+ blue: "\x1b[34m",
46
+ };
47
+
48
+ let PASSED = 0;
49
+ let FAILED = 0;
50
+ let SKIPPED = 0;
51
+
52
+ const logPass = (msg: string) => {
53
+ console.log(`${c.green}✅ PASS${c.reset}: ${msg}`);
54
+ PASSED++;
55
+ };
56
+ const logFail = (msg: string) => {
57
+ console.log(`${c.red}❌ FAIL${c.reset}: ${msg}`);
58
+ FAILED++;
59
+ };
60
+ const logSkip = (msg: string) => {
61
+ console.log(`${c.yellow}⏭️ SKIP${c.reset}: ${msg}`);
62
+ SKIPPED++;
63
+ };
64
+ const logInfo = (msg: string) => {
65
+ console.log(`${c.blue}ℹ️ INFO${c.reset}: ${msg}`);
66
+ };
67
+ const logSection = (title: string) => {
68
+ console.log(`\n${c.blue}═══════════════════════════════════════════════════════════${c.reset}`);
69
+ console.log(`${c.blue} ${title}${c.reset}`);
70
+ console.log(`${c.blue}═══════════════════════════════════════════════════════════${c.reset}`);
71
+ };
72
+
73
+ const api = async (method: string, path: string, body?: object, token?: string) => {
74
+ try {
75
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
76
+ if (token) headers["Authorization"] = `Bearer ${token}`;
77
+
78
+ const res = await fetch(`${API_BASE}${path}`, {
79
+ method,
80
+ headers,
81
+ body: body ? JSON.stringify(body) : undefined,
82
+ });
83
+
84
+ let responseBody: any;
85
+ try {
86
+ responseBody = await res.json();
87
+ } catch {
88
+ responseBody = {};
89
+ }
90
+ return { status: res.status, body: responseBody };
91
+ } catch (err: any) {
92
+ return { status: 0, body: { error: err.message } };
93
+ }
94
+ };
95
+
96
+ console.log("");
97
+ console.log("╔═══════════════════════════════════════════════════════════╗");
98
+ console.log("║ UPLINK COMPREHENSIVE TEST SUITE ║");
99
+ console.log("╚═══════════════════════════════════════════════════════════╝");
100
+ console.log(`\nAPI Base: ${API_BASE}\n`);
101
+
102
+ if (!ADMIN_TOKEN) {
103
+ console.log(`${c.red}ERROR: AGENTCLOUD_TOKEN not set.${c.reset}`);
104
+ throw new Error("AGENTCLOUD_TOKEN not set");
105
+ }
106
+
107
+ logSection("1. HEALTH CHECKS");
108
+ let res = await api("GET", "/health");
109
+ if (res.status === 200 && res.body?.status === "ok") logPass("GET /health returns 200");
110
+ else logFail(`GET /health - got ${res.status}`);
111
+
112
+ res = await api("GET", "/health/live");
113
+ if (res.status === 200) logPass("GET /health/live returns 200");
114
+ else logFail(`GET /health/live - got ${res.status}`);
115
+
116
+ logSection("2. AUTHENTICATION");
117
+ res = await api("GET", "/v1/me");
118
+ if (res.status === 401) logPass("Missing token returns 401");
119
+ else logFail(`Missing token - got ${res.status}`);
120
+
121
+ res = await api("GET", "/v1/me", undefined, "invalid-token");
122
+ if (res.status === 401) logPass("Invalid token returns 401");
123
+ else logFail(`Invalid token - got ${res.status}`);
124
+
125
+ res = await api("GET", "/v1/me", undefined, ADMIN_TOKEN);
126
+ if (res.status === 200 && res.body?.role === "admin") logPass("Valid admin token works");
127
+ else logFail(`Admin token - got ${res.status}`);
128
+
129
+ logSection("3. SIGNUP FLOW");
130
+ let USER_TOKEN = "";
131
+ let USER_TOKEN_ID = "";
132
+ res = await api("POST", "/v1/signup", { label: `test-${Date.now()}` });
133
+ if (res.status === 201 && res.body?.token) {
134
+ USER_TOKEN = res.body.token;
135
+ USER_TOKEN_ID = res.body.id;
136
+ logPass("POST /v1/signup creates token");
137
+ if (res.body.role === "user") logPass("Signup creates user role");
138
+ else logFail(`Signup role: ${res.body.role}`);
139
+ } else if (res.status === 429) {
140
+ logSkip("Signup rate limited");
141
+ } else {
142
+ logFail(`Signup - got ${res.status}`);
143
+ }
144
+
145
+ logSection("4. AUTHORIZATION");
146
+ if (USER_TOKEN) {
147
+ res = await api("GET", "/v1/admin/stats", undefined, USER_TOKEN);
148
+ if (res.status === 403) logPass("User blocked from admin endpoint");
149
+ else logFail(`User accessed admin - got ${res.status}`);
150
+ } else {
151
+ logSkip("No user token for auth tests");
152
+ }
153
+
154
+ logSection("5. TUNNEL API");
155
+ res = await api("GET", "/v1/tunnels", undefined, ADMIN_TOKEN);
156
+ if (res.status === 200) logPass("GET /v1/tunnels works");
157
+ else logFail(`Tunnels list - got ${res.status}`);
158
+
159
+ res = await api("POST", "/v1/tunnels", { port: 3000 }, ADMIN_TOKEN);
160
+ if (res.status === 201) {
161
+ logPass("POST /v1/tunnels creates tunnel");
162
+ if (res.body?.id) {
163
+ const delRes = await api("DELETE", `/v1/tunnels/${res.body.id}`, undefined, ADMIN_TOKEN);
164
+ if (delRes.status === 200) logPass("DELETE tunnel works");
165
+ else logFail(`Delete tunnel - got ${delRes.status}`);
166
+ }
167
+ } else {
168
+ logFail(`Create tunnel - got ${res.status}`);
169
+ }
170
+
171
+ logSection("6. DATABASE API");
172
+ res = await api("GET", "/v1/dbs", undefined, ADMIN_TOKEN);
173
+ if (res.status === 200) logPass("GET /v1/dbs works");
174
+ else logFail(`Databases list - got ${res.status}`);
175
+ logInfo("Skipping DB creation (provisions real resources)");
176
+
177
+ logSection("7. ADMIN STATS");
178
+ res = await api("GET", "/v1/admin/stats", undefined, ADMIN_TOKEN);
179
+ if (res.status === 200) {
180
+ logPass("GET /v1/admin/stats works");
181
+ if (res.body?.tunnels !== undefined) logPass("Stats include tunnels");
182
+ if (res.body?.databases !== undefined) logPass("Stats include databases");
183
+ } else {
184
+ logFail(`Admin stats - got ${res.status}`);
185
+ }
186
+
187
+ logSection("8. CLEANUP");
188
+ if (USER_TOKEN_ID) {
189
+ res = await api("DELETE", `/v1/admin/tokens/${USER_TOKEN_ID}`, undefined, ADMIN_TOKEN);
190
+ if (res.status === 200) logPass("Cleaned up test token");
191
+ else logInfo("Could not clean up token");
192
+ } else {
193
+ logInfo("No test token to clean up");
194
+ }
195
+
196
+ logSection("TEST SUMMARY");
197
+ console.log(`\n ${c.green}Passed${c.reset}: ${PASSED}`);
198
+ console.log(` ${c.red}Failed${c.reset}: ${FAILED}`);
199
+ console.log(` ${c.yellow}Skipped${c.reset}: ${SKIPPED}\n`);
200
+
201
+ if (FAILED === 0) {
202
+ console.log(`${c.green}═══════════════════════════════════════════════════════════${c.reset}`);
203
+ console.log(`${c.green} ✅ ALL TESTS PASSED (${PASSED}/${PASSED + FAILED})${c.reset}`);
204
+ console.log(`${c.green}═══════════════════════════════════════════════════════════${c.reset}`);
205
+ } else {
206
+ console.log(`${c.red}═══════════════════════════════════════════════════════════${c.reset}`);
207
+ console.log(`${c.red} ❌ SOME TESTS FAILED (${FAILED}/${PASSED + FAILED})${c.reset}`);
208
+ console.log(`${c.red}═══════════════════════════════════════════════════════════${c.reset}`);
209
+ throw new Error(`${FAILED} tests failed`);
210
+ }
211
+ }
@@ -0,0 +1,72 @@
1
+ import { spawn, execSync } from "child_process";
2
+ import { join } from "path";
3
+ import { apiRequest } from "../../http";
4
+
5
+ export async function createAndStartTunnel(port: number): Promise<string> {
6
+ const result = await apiRequest("POST", "/v1/tunnels", { port });
7
+ const url = result.url || "(no url)";
8
+ const token = result.token || "(no token)";
9
+ const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
10
+
11
+ const path = require("path");
12
+ const projectRoot = path.join(__dirname, "../../..");
13
+ const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
14
+ const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
15
+ stdio: "ignore",
16
+ detached: true,
17
+ cwd: projectRoot,
18
+ });
19
+ clientProcess.unref();
20
+
21
+ await new Promise((resolve) => setTimeout(resolve, 2000));
22
+
23
+ try {
24
+ process.stdin.setRawMode(true);
25
+ process.stdin.resume();
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+
30
+ return [
31
+ `✓ Tunnel created and client started`,
32
+ ``,
33
+ `→ Public URL ${url}`,
34
+ `→ Token ${token}`,
35
+ `→ Local port ${port}`,
36
+ ``,
37
+ `Tunnel client running in background.`,
38
+ `Use "Stop Tunnel" to disconnect.`,
39
+ ].join("\n");
40
+ }
41
+
42
+ export function findTunnelClients(): Array<{ pid: number; port: number; token: string }> {
43
+ try {
44
+ const user = process.env.USER || "";
45
+ const psCmd = user ? `ps -u ${user} -o pid=,command=` : "ps -eo pid=,command=";
46
+ const output = execSync(psCmd, { encoding: "utf-8" });
47
+ const lines = output
48
+ .trim()
49
+ .split("\n")
50
+ .filter((line) => line.includes("scripts/tunnel/client-improved.js"));
51
+
52
+ const clients: Array<{ pid: number; port: number; token: string }> = [];
53
+
54
+ for (const line of lines) {
55
+ const pidMatch = line.match(/^\s*(\d+)/);
56
+ const tokenMatch = line.match(/--token\s+(\S+)/);
57
+ const portMatch = line.match(/--port\s+(\d+)/);
58
+
59
+ if (pidMatch && tokenMatch && portMatch) {
60
+ clients.push({
61
+ pid: parseInt(pidMatch[1], 10),
62
+ port: parseInt(portMatch[1], 10),
63
+ token: tokenMatch[1],
64
+ });
65
+ }
66
+ }
67
+
68
+ return clients;
69
+ } catch {
70
+ return [];
71
+ }
72
+ }