uplink-cli 0.1.0-alpha.1

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,98 @@
1
+ import net from "net";
2
+
3
+ /**
4
+ * Check if a port is in use by trying to connect to it
5
+ * More reliable than trying to bind to the port
6
+ */
7
+ export async function isPortInUse(port: number): Promise<boolean> {
8
+ return new Promise((resolve) => {
9
+ // Try to connect to the port - if we can connect, something is listening
10
+ const socket = new net.Socket();
11
+ let resolved = false;
12
+
13
+ const cleanup = () => {
14
+ if (!resolved) {
15
+ resolved = true;
16
+ socket.destroy();
17
+ }
18
+ };
19
+
20
+ socket.setTimeout(500);
21
+
22
+ socket.once("connect", () => {
23
+ cleanup();
24
+ resolve(true); // Port is in use (we connected)
25
+ });
26
+
27
+ socket.once("timeout", () => {
28
+ cleanup();
29
+ resolve(false); // Port is not in use (timeout)
30
+ });
31
+
32
+ socket.once("error", (err: any) => {
33
+ // ECONNREFUSED means nothing is listening
34
+ if (err.code === "ECONNREFUSED") {
35
+ cleanup();
36
+ resolve(false);
37
+ } else {
38
+ cleanup();
39
+ resolve(true); // Other error might mean port is in use
40
+ }
41
+ });
42
+
43
+ socket.connect(port, "127.0.0.1");
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Scan common development ports to find active servers
49
+ */
50
+ export async function scanCommonPorts(): Promise<number[]> {
51
+ const commonPorts = [
52
+ 3000, 3001, 3002, 3003, // Common Node.js/React ports
53
+ 8000, 8001, 8080, 8081, // Common web server ports
54
+ 5000, 5001, 5002, // Common Flask/Python ports
55
+ 4000, 4001, // Common API ports
56
+ 5173, 5174, // Vite default ports
57
+ 4200, // Angular default
58
+ 9000, // Common dev ports
59
+ ];
60
+
61
+ const activePorts: number[] = [];
62
+
63
+ for (const port of commonPorts) {
64
+ if (await isPortInUse(port)) {
65
+ activePorts.push(port);
66
+ }
67
+ }
68
+
69
+ return activePorts;
70
+ }
71
+
72
+ /**
73
+ * Test if a port is actually serving HTTP
74
+ */
75
+ export async function testHttpPort(port: number): Promise<boolean> {
76
+ return new Promise((resolve) => {
77
+ const http = require("http");
78
+ const req = http.request(
79
+ {
80
+ hostname: "127.0.0.1",
81
+ port,
82
+ path: "/",
83
+ method: "HEAD",
84
+ timeout: 1000,
85
+ },
86
+ () => {
87
+ resolve(true);
88
+ }
89
+ );
90
+ req.on("error", () => resolve(false));
91
+ req.on("timeout", () => {
92
+ req.destroy();
93
+ resolve(false);
94
+ });
95
+ req.end();
96
+ });
97
+ }
98
+
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "uplink-cli",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Localhost to public URL in seconds. No signup forms, no browser - everything in your terminal.",
5
+ "keywords": [
6
+ "tunnel",
7
+ "localhost",
8
+ "ngrok",
9
+ "expose",
10
+ "cli",
11
+ "dev-tools",
12
+ "webhook",
13
+ "public-url",
14
+ "port-forwarding"
15
+ ],
16
+ "homepage": "https://github.com/firstprinciplecode/uplink#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/firstprinciplecode/uplink.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/firstprinciplecode/uplink/issues"
23
+ },
24
+ "author": "First Principle Code",
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=20.0.0"
28
+ },
29
+ "bin": {
30
+ "uplink": "./cli/bin/uplink.js"
31
+ },
32
+ "files": [
33
+ "cli/",
34
+ "scripts/tunnel/client-improved.js",
35
+ "README.md"
36
+ ],
37
+ "scripts": {
38
+ "dev:relay": "node scripts/tunnel/relay.js",
39
+ "dev:stub": "PORT=4100 AGENTCLOUD_TOKEN_DEV=dev-token node scripts/dev/stub-control-plane.js",
40
+ "dev:cli": "tsx cli/src/index.ts",
41
+ "dev:api": "tsx backend/src/server.ts",
42
+ "migrate": "tsx backend/scripts/migrate.ts",
43
+ "smoke:tunnel": "bash scripts/tunnel-smoke.sh",
44
+ "smoke:db": "bash scripts/db-api-smoke.sh",
45
+ "smoke:all": "bash scripts/smoke-all.sh",
46
+ "test:features": "bash scripts/test-new-features.sh",
47
+ "test:comprehensive": "bash scripts/test-comprehensive.sh"
48
+ },
49
+ "dependencies": {
50
+ "better-sqlite3": "^11.10.0",
51
+ "body-parser": "^1.20.3",
52
+ "commander": "^12.1.0",
53
+ "dotenv": "^16.6.1",
54
+ "express": "^4.19.2",
55
+ "express-rate-limit": "^8.2.1",
56
+ "helmet": "^8.1.0",
57
+ "node-fetch": "^2.7.0",
58
+ "pg": "^8.12.0",
59
+ "pino": "^10.1.0",
60
+ "pino-pretty": "^13.1.3",
61
+ "tsx": "^4.21.0",
62
+ "zod": "^4.2.1"
63
+ },
64
+ "devDependencies": {
65
+ "@types/express": "^4.17.21",
66
+ "@types/express-rate-limit": "^5.1.3",
67
+ "@types/node": "^22.7.4",
68
+ "@types/node-fetch": "^2.6.11",
69
+ "typescript": "^5.6.2"
70
+ }
71
+ }
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Improved tunnel client with auto-reconnect, better error handling, and health checks.
4
+ * Usage: node scripts/tunnel/client-improved.js --token <token> --port 3000 --ctrl 127.0.0.1:7071
5
+ */
6
+
7
+ const net = require("net");
8
+ const tls = require("tls");
9
+ const http = require("http");
10
+ const fs = require("fs");
11
+
12
+ // Configuration
13
+ const MAX_RECONNECT_DELAY = 30000; // 30 seconds max
14
+ const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
15
+ const MAX_REQUEST_SIZE = 10 * 1024 * 1024; // 10MB max request body
16
+ const HEALTH_CHECK_INTERVAL = 30000; // Check local service every 30s
17
+ const REQUEST_TIMEOUT = 30000; // 30s timeout for local requests
18
+
19
+ function parseArgs() {
20
+ const args = process.argv.slice(2);
21
+ const out = {};
22
+ for (let i = 0; i < args.length; i++) {
23
+ const a = args[i];
24
+ if (a === "--token") out.token = args[++i];
25
+ else if (a === "--port") out.port = Number(args[++i]);
26
+ else if (a === "--ctrl") out.ctrl = args[++i];
27
+ else if (a === "--max-size") out.maxSize = Number(args[++i]);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const { token, port, ctrl, maxSize } = parseArgs();
33
+ if (!token || !port || !ctrl) {
34
+ console.error("Usage: node scripts/tunnel/client-improved.js --token <token> --port <port> --ctrl <host:port> [--max-size <bytes>]");
35
+ process.exit(1);
36
+ }
37
+
38
+ const [CTRL_HOST, CTRL_PORT] = ctrl.split(":");
39
+ const MAX_BODY_SIZE = maxSize || MAX_REQUEST_SIZE;
40
+ const CTRL_TLS_ENABLED = process.env.TUNNEL_CTRL_TLS === "true";
41
+ const CTRL_TLS_INSECURE = process.env.TUNNEL_CTRL_TLS_INSECURE === "true";
42
+ const CTRL_TLS_CA_PATH = process.env.TUNNEL_CTRL_CA || "";
43
+ const CTRL_TLS_CERT_PATH = process.env.TUNNEL_CTRL_CERT || "";
44
+ const CTRL_TLS_KEY_PATH = process.env.TUNNEL_CTRL_KEY || "";
45
+
46
+ function optionalRead(path) {
47
+ if (!path) return undefined;
48
+ try {
49
+ return fs.readFileSync(path);
50
+ } catch {
51
+ log("warn", `Could not read TLS file: ${path}`);
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ // State
57
+ let socket = null;
58
+ let reconnectDelay = INITIAL_RECONNECT_DELAY;
59
+ let reconnectTimer = null;
60
+ let isConnected = false;
61
+ let isRegistered = false;
62
+ let healthCheckTimer = null;
63
+ let stats = {
64
+ requests: 0,
65
+ errors: 0,
66
+ reconnects: 0,
67
+ startTime: Date.now(),
68
+ };
69
+
70
+ function log(level, ...args) {
71
+ const prefix = {
72
+ info: "ℹ️",
73
+ error: "❌",
74
+ warn: "⚠️",
75
+ success: "✅",
76
+ }[level] || "•";
77
+ console.log(`${new Date().toISOString()} ${prefix}`, ...args);
78
+ }
79
+
80
+ function logError(err, context) {
81
+ const message = err.message || String(err);
82
+ const code = err.code || "";
83
+ log("error", context, message, code ? `(${code})` : "");
84
+
85
+ // Provide helpful error messages
86
+ if (code === "ECONNREFUSED") {
87
+ log("info", `Cannot connect to relay at ${CTRL_HOST}:${CTRL_PORT}. Is the relay running?`);
88
+ } else if (code === "ETIMEDOUT") {
89
+ log("info", `Connection timeout. Check network connectivity and firewall rules.`);
90
+ } else if (code === "ENOTFOUND") {
91
+ log("info", `Cannot resolve hostname "${CTRL_HOST}". Check DNS settings.`);
92
+ }
93
+ }
94
+
95
+ function checkLocalService() {
96
+ const options = {
97
+ hostname: "127.0.0.1",
98
+ port,
99
+ path: "/",
100
+ method: "HEAD",
101
+ timeout: 2000,
102
+ };
103
+
104
+ const req = http.request(options, (res) => {
105
+ if (res.statusCode >= 200 && res.statusCode < 500) {
106
+ // Service is responding
107
+ return;
108
+ }
109
+ log("warn", `Local service returned status ${res.statusCode}. Is it running correctly?`);
110
+ });
111
+
112
+ req.on("error", (err) => {
113
+ if (err.code === "ECONNREFUSED") {
114
+ log("error", `Local service on port ${port} is not responding. Is your app running?`);
115
+ } else {
116
+ log("warn", `Health check failed: ${err.message}`);
117
+ }
118
+ });
119
+
120
+ req.on("timeout", () => {
121
+ req.destroy();
122
+ log("warn", `Health check timeout. Local service may be slow or unresponsive.`);
123
+ });
124
+
125
+ req.end();
126
+ }
127
+
128
+ function connect() {
129
+ if (socket && !socket.destroyed) {
130
+ socket.destroy();
131
+ }
132
+
133
+ log("info", `Connecting to relay at ${CTRL_HOST}:${CTRL_PORT}... ${CTRL_TLS_ENABLED ? "(TLS)" : "(plain)"}`);
134
+
135
+ if (CTRL_TLS_ENABLED) {
136
+ const ca = optionalRead(CTRL_TLS_CA_PATH);
137
+ const cert = optionalRead(CTRL_TLS_CERT_PATH);
138
+ const key = optionalRead(CTRL_TLS_KEY_PATH);
139
+ const options = {
140
+ host: CTRL_HOST,
141
+ port: Number(CTRL_PORT),
142
+ ca: ca ? [ca] : undefined,
143
+ cert,
144
+ key,
145
+ rejectUnauthorized: !CTRL_TLS_INSECURE,
146
+ servername: CTRL_HOST,
147
+ };
148
+ socket = tls.connect(options, () => {
149
+ isConnected = true;
150
+ reconnectDelay = INITIAL_RECONNECT_DELAY;
151
+ log("success", "Connected to relay (TLS)");
152
+ socket.setKeepAlive(true, 15000);
153
+ socket.write(JSON.stringify({ type: "register", token, targetPort: port }) + "\n");
154
+ });
155
+ } else {
156
+ socket = net.createConnection({ host: CTRL_HOST, port: Number(CTRL_PORT) }, () => {
157
+ isConnected = true;
158
+ reconnectDelay = INITIAL_RECONNECT_DELAY; // Reset delay on successful connection
159
+ log("success", "Connected to relay");
160
+ socket.setKeepAlive(true, 15000);
161
+
162
+ // Register with relay
163
+ socket.write(
164
+ JSON.stringify({ type: "register", token, targetPort: port }) + "\n"
165
+ );
166
+ });
167
+ }
168
+
169
+ let buf = "";
170
+ socket.on("data", (chunk) => {
171
+ buf += chunk.toString("utf8");
172
+ let idx;
173
+ while ((idx = buf.indexOf("\n")) >= 0) {
174
+ const line = buf.slice(0, idx);
175
+ buf = buf.slice(idx + 1);
176
+ if (!line.trim()) continue;
177
+
178
+ // Check message size
179
+ if (line.length > MAX_BODY_SIZE) {
180
+ log("error", `Message too large: ${line.length} bytes (max: ${MAX_BODY_SIZE})`);
181
+ continue;
182
+ }
183
+
184
+ try {
185
+ const msg = JSON.parse(line);
186
+ if (msg.type === "request") {
187
+ handleRequest(msg);
188
+ } else if (msg.type === "registered") {
189
+ isRegistered = true;
190
+ log("success", `Registered with relay (token: ${token.substring(0, 8)}...)`);
191
+ stats.reconnects = 0; // Reset reconnect count on successful registration
192
+ } else if (msg.type === "error") {
193
+ log("error", "Relay error:", msg.message || "Unknown error");
194
+ }
195
+ } catch (err) {
196
+ log("error", "Parse error:", err.message, `(message length: ${line.length})`);
197
+ }
198
+ }
199
+ });
200
+
201
+ socket.on("error", (err) => {
202
+ isConnected = false;
203
+ isRegistered = false;
204
+ logError(err, "Connection error");
205
+ scheduleReconnect();
206
+ });
207
+
208
+ socket.on("close", () => {
209
+ const wasRegistered = isRegistered;
210
+ isConnected = false;
211
+ isRegistered = false;
212
+
213
+ if (wasRegistered) {
214
+ log("warn", "Connection closed. Attempting to reconnect...");
215
+ scheduleReconnect();
216
+ } else {
217
+ log("warn", "Connection closed before registration");
218
+ }
219
+ });
220
+ }
221
+
222
+ function scheduleReconnect() {
223
+ if (reconnectTimer) {
224
+ clearTimeout(reconnectTimer);
225
+ }
226
+
227
+ stats.reconnects++;
228
+ const delay = Math.min(reconnectDelay, MAX_RECONNECT_DELAY);
229
+
230
+ log("info", `Reconnecting in ${delay / 1000}s (attempt ${stats.reconnects})...`);
231
+
232
+ reconnectTimer = setTimeout(() => {
233
+ reconnectDelay *= 2; // Exponential backoff
234
+ connect();
235
+ }, delay);
236
+ }
237
+
238
+ function handleRequest(msg) {
239
+ stats.requests++;
240
+
241
+ // Validate request
242
+ if (!msg.id) {
243
+ log("error", "Received request without ID");
244
+ return;
245
+ }
246
+
247
+ // Check body size
248
+ if (msg.body) {
249
+ const bodySize = Buffer.from(msg.body, "base64").length;
250
+ if (bodySize > MAX_BODY_SIZE) {
251
+ log("error", `Request body too large: ${bodySize} bytes (max: ${MAX_BODY_SIZE})`);
252
+ sendErrorResponse(msg.id, 413, "Request entity too large");
253
+ return;
254
+ }
255
+ }
256
+
257
+ // Clean headers - remove hop-by-hop headers and undefined values
258
+ const cleanHeaders = { ...msg.headers };
259
+ delete cleanHeaders.connection;
260
+ delete cleanHeaders["keep-alive"];
261
+ delete cleanHeaders["transfer-encoding"];
262
+ // Remove any undefined values
263
+ Object.keys(cleanHeaders).forEach(key => {
264
+ if (cleanHeaders[key] === undefined) {
265
+ delete cleanHeaders[key];
266
+ }
267
+ });
268
+
269
+ const options = {
270
+ hostname: "127.0.0.1",
271
+ port,
272
+ path: msg.path || "/",
273
+ method: msg.method || "GET",
274
+ headers: cleanHeaders,
275
+ timeout: REQUEST_TIMEOUT,
276
+ };
277
+
278
+ const req = http.request(options, (resp) => {
279
+ const chunks = [];
280
+ let totalSize = 0;
281
+
282
+ resp.on("data", (d) => {
283
+ chunks.push(d);
284
+ totalSize += d.length;
285
+
286
+ // Check response size
287
+ if (totalSize > MAX_BODY_SIZE) {
288
+ req.destroy();
289
+ log("error", `Response too large: ${totalSize} bytes (max: ${MAX_BODY_SIZE})`);
290
+ sendErrorResponse(msg.id, 413, "Response entity too large");
291
+ return;
292
+ }
293
+ });
294
+
295
+ resp.on("end", () => {
296
+ const body = Buffer.concat(chunks);
297
+ const resMsg = {
298
+ type: "response",
299
+ id: msg.id,
300
+ status: resp.statusCode,
301
+ headers: resp.headers,
302
+ body: body.length ? body.toString("base64") : "",
303
+ };
304
+
305
+ if (socket && !socket.destroyed && isRegistered) {
306
+ socket.write(JSON.stringify(resMsg) + "\n");
307
+ } else {
308
+ log("warn", "Cannot send response: not connected");
309
+ }
310
+ });
311
+ });
312
+
313
+ req.on("error", (err) => {
314
+ stats.errors++;
315
+ logError(err, "Local request error");
316
+
317
+ const errorMsg = err.code === "ECONNREFUSED"
318
+ ? "Local service not available"
319
+ : err.message;
320
+
321
+ sendErrorResponse(msg.id, 502, errorMsg);
322
+ });
323
+
324
+ req.on("timeout", () => {
325
+ req.destroy();
326
+ stats.errors++;
327
+ log("error", `Request timeout after ${REQUEST_TIMEOUT}ms`);
328
+ sendErrorResponse(msg.id, 504, "Gateway timeout");
329
+ });
330
+
331
+ if (msg.body) {
332
+ try {
333
+ req.write(Buffer.from(msg.body, "base64"));
334
+ } catch (err) {
335
+ log("error", "Error writing request body:", err.message);
336
+ sendErrorResponse(msg.id, 400, "Invalid request body");
337
+ return;
338
+ }
339
+ }
340
+
341
+ req.end();
342
+ }
343
+
344
+ function sendErrorResponse(id, status, message) {
345
+ if (!socket || socket.destroyed || !isRegistered) {
346
+ return;
347
+ }
348
+
349
+ const resMsg = {
350
+ type: "response",
351
+ id,
352
+ status,
353
+ headers: { "content-type": "text/plain" },
354
+ body: Buffer.from(message).toString("base64"),
355
+ };
356
+
357
+ try {
358
+ socket.write(JSON.stringify(resMsg) + "\n");
359
+ } catch (err) {
360
+ log("error", "Failed to send error response:", err.message);
361
+ }
362
+ }
363
+
364
+ // Graceful shutdown
365
+ function shutdown() {
366
+ log("info", "Shutting down...");
367
+
368
+ if (reconnectTimer) {
369
+ clearTimeout(reconnectTimer);
370
+ }
371
+
372
+ if (healthCheckTimer) {
373
+ clearInterval(healthCheckTimer);
374
+ }
375
+
376
+ if (socket && !socket.destroyed) {
377
+ socket.end();
378
+ }
379
+
380
+ // Print stats
381
+ const uptime = Math.floor((Date.now() - stats.startTime) / 1000);
382
+ log("info", `Stats: ${stats.requests} requests, ${stats.errors} errors, ${stats.reconnects} reconnects, ${uptime}s uptime`);
383
+
384
+ process.exit(0);
385
+ }
386
+
387
+ // Start connection
388
+ connect();
389
+
390
+ // Start health checks
391
+ healthCheckTimer = setInterval(checkLocalService, HEALTH_CHECK_INTERVAL);
392
+ checkLocalService(); // Immediate check
393
+
394
+ // Handle shutdown signals
395
+ process.on("SIGINT", shutdown);
396
+ process.on("SIGTERM", shutdown);
397
+
398
+ // Print connection info
399
+ log("info", `Tunnel client starting`);
400
+ log("info", `Token: ${token.substring(0, 8)}...`);
401
+ log("info", `Local port: ${port}`);
402
+ log("info", `Relay: ${CTRL_HOST}:${CTRL_PORT}`);
403
+ log("info", `Max request size: ${MAX_BODY_SIZE / 1024 / 1024}MB`);
404
+