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.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Uplink
2
+
3
+ **Localhost to public URL in seconds.** No browser, no signup forms, no friction.
4
+
5
+ Uplink lets you expose your local dev server to the internet with a single command. Everything happens in your terminal—create an account, get a token, start a tunnel. Done.
6
+
7
+ Perfect for sharing work-in-progress, testing webhooks, or demoing to clients. And because it's 100% CLI-based, AI coding assistants like **Cursor**, **Claude Code**, and **Windsurf** can set it up for you automatically.
8
+
9
+ ![Uplink CLI](./assets/cli-screenshot.png)
10
+
11
+ ## Features
12
+
13
+ - **Instant Public URLs** - Your `localhost:3000` becomes `https://xyz.t.uplink.spot`
14
+ - **Zero Browser Required** - Signup, auth, and tunnel management all in terminal
15
+ - **Agent-Friendly** - AI assistants can create tokens and start tunnels via API
16
+ - **Auto Port Detection** - Scans for running servers, select with arrow keys
17
+
18
+ ## Quick Start
19
+
20
+ ### 1. Install
21
+
22
+ ```bash
23
+ npm install -g uplink-cli
24
+ # Or run without global install:
25
+ npx uplink-cli
26
+ ```
27
+
28
+ ### 2. Run Uplink
29
+
30
+ ```bash
31
+ uplink
32
+ ```
33
+
34
+ This opens an interactive menu. If you don't have a token yet, you'll see:
35
+
36
+ ```
37
+ ├─ Get Started (Create Account)
38
+ └─ Exit
39
+ ```
40
+
41
+ ### 3. Create Your Account
42
+
43
+ 1. Select **"Get Started (Create Account)"**
44
+ 2. Enter an optional label (e.g., "my-laptop")
45
+ 3. Optionally set expiration days (or leave empty for no expiration)
46
+ 4. Your token will be displayed **once** - save it securely
47
+ 5. The CLI will offer to automatically add it to your `~/.zshrc` or `~/.bashrc`
48
+
49
+ After adding the token, run:
50
+ ```bash
51
+ source ~/.zshrc # or ~/.bashrc
52
+ uplink
53
+ ```
54
+
55
+ ### 4. Start a Tunnel
56
+
57
+ Once authenticated, select **"Manage Tunnels"** → **"Start Tunnel"**:
58
+
59
+ - The CLI will scan for active servers on your local machine
60
+ - Use arrow keys to select a port, or choose "Enter custom port"
61
+ - Press "Back" if you want to cancel
62
+ - Your tunnel URL will be displayed (e.g., `https://abc123.t.uplink.spot`)
63
+
64
+ **Keep the terminal running** - the tunnel client must stay active.
65
+
66
+ ## CLI Commands
67
+
68
+ ### Interactive Menu (Recommended)
69
+
70
+ ```bash
71
+ uplink
72
+ # or
73
+ uplink menu
74
+ ```
75
+
76
+ ### Direct Commands
77
+
78
+ ```bash
79
+ # Start a tunnel for port 3000
80
+ uplink dev --tunnel --port 3000
81
+
82
+ # List databases
83
+ uplink db list
84
+
85
+ # Create a database
86
+ uplink db create --name mydb --region us-east-1
87
+
88
+ # Admin commands (requires admin token)
89
+ uplink admin status
90
+ uplink admin tunnels
91
+ uplink admin databases
92
+ ```
93
+
94
+ ## Environment Variables
95
+
96
+ ```bash
97
+ # API endpoint (default: https://api.uplink.spot)
98
+ export AGENTCLOUD_API_BASE=https://api.uplink.spot
99
+
100
+ # Your API token (required)
101
+ export AGENTCLOUD_TOKEN=your-token-here
102
+
103
+ # Tunnel control server (default: tunnel.uplink.spot:7071)
104
+ export TUNNEL_CTRL=tunnel.uplink.spot:7071
105
+
106
+ # Tunnel domain (default: t.uplink.spot)
107
+ export TUNNEL_DOMAIN=t.uplink.spot
108
+ ```
109
+
110
+ ## Requirements
111
+
112
+ - **Node.js** 20.x or later
113
+ - **API Token** - Created automatically via signup, or provided by an admin
114
+
115
+ ## How It Works
116
+
117
+ ### Tunnel Service
118
+
119
+ 1. **Create tunnel** - Request a tunnel from the API
120
+ 2. **Get token** - Receive a unique token (e.g., `abc123`)
121
+ 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`
123
+
124
+ The tunnel client forwards HTTP requests from the public URL to your local server.
125
+
126
+ ### Database Service
127
+
128
+ Create and manage PostgreSQL databases via Neon. Databases are provisioned automatically and connection strings are provided.
129
+
130
+ ## Troubleshooting
131
+
132
+ ### "Connection refused" error
133
+ - Make sure your local server is running on the specified port
134
+ - Start your server first, then create the tunnel
135
+
136
+ ### "Cannot connect to relay" error
137
+ - Verify the `TUNNEL_CTRL` address is correct
138
+ - Check if the tunnel relay service is running
139
+
140
+ ### Tunnel URL returns "Gateway timeout"
141
+ - Make sure the tunnel client is still running
142
+ - Restart the tunnel client if it exited
143
+
144
+ ### Token not working
145
+ - Verify `AGENTCLOUD_TOKEN` is set: `echo $AGENTCLOUD_TOKEN`
146
+ - Make sure you ran `source ~/.zshrc` (or `~/.bashrc`) after adding the token
147
+ - Create a new token if needed
148
+
149
+ ## API Endpoints
150
+
151
+ The CLI communicates with the Uplink API. Main endpoints:
152
+
153
+ - `POST /v1/signup` - Create account (public, no auth)
154
+ - `POST /v1/tunnels` - Create tunnel
155
+ - `GET /v1/tunnels` - List your tunnels
156
+ - `DELETE /v1/tunnels/:id` - Delete tunnel
157
+ - `POST /v1/databases` - Create database
158
+ - `GET /v1/databases` - List your databases
159
+ - `GET /v1/admin/stats` - System statistics (admin only)
160
+
161
+ ## Security
162
+
163
+ ### Token Security
164
+ - Tokens are hashed with HMAC-SHA256 before storage (never stored in plain text)
165
+ - Tokens can be revoked instantly or set to auto-expire
166
+ - User tokens only see their own resources (tunnels, databases)
167
+ - Admin tokens required for system-wide operations
168
+
169
+ ### Rate Limiting
170
+ - Signup: 5 requests/hour per IP (strictest)
171
+ - Authentication: 10 attempts/15 min per IP
172
+ - API calls: 100 requests/15 min per IP
173
+ - Token creation: 20/hour
174
+ - Tunnel creation: 50/hour
175
+
176
+ ### Production Recommendations
177
+
178
+ 1. **Set a token pepper** (strongly recommended):
179
+ ```bash
180
+ export CONTROL_PLANE_TOKEN_PEPPER=your-random-secret-here
181
+ ```
182
+ This adds an extra layer of protection - even if the database is compromised, tokens can't be used without the pepper.
183
+
184
+ 2. **Disable dev tokens** in production:
185
+ - Don't set `AGENTCLOUD_TOKEN_DEV`
186
+ - Use only database-backed tokens
187
+
188
+ 3. **Break-glass admin access** (emergency only):
189
+ ```bash
190
+ export ADMIN_TOKENS=emergency-admin-token-1,emergency-admin-token-2
191
+ ```
192
+ These bypass the database - use only for emergencies and rotate after use.
193
+
194
+ 4. **Use HTTPS** for all API endpoints (handled by Caddy in production)
195
+
196
+ ## License
197
+
198
+ MIT
199
+
200
+ ## Support
201
+
202
+ For issues or questions, check:
203
+ - Run `uplink` and use the interactive menu
204
+ - Check environment variables are set correctly
205
+ - Verify your token is valid: `uplink admin status` (if admin)
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Uplink CLI entry point
4
+ * Defaults to menu if no command provided
5
+ */
6
+
7
+ const { spawn } = require("child_process");
8
+ const path = require("path");
9
+ const fs = require("fs");
10
+
11
+ // Get paths
12
+ const binDir = __dirname;
13
+ const projectRoot = path.join(binDir, "../..");
14
+ const cliPath = path.join(projectRoot, "cli/src/index.ts");
15
+
16
+ // Get arguments
17
+ const args = process.argv.slice(2);
18
+
19
+ // If no command provided, default to menu
20
+ // Otherwise pass through - commander will handle help/version/unknown commands
21
+ if (args.length === 0) {
22
+ args.push("menu");
23
+ }
24
+
25
+ // Find tsx - prefer project-local, otherwise fall back to PATH
26
+ let tsxPath = "tsx";
27
+ try {
28
+ // Newer tsx exposes a CLI entry at dist/cli.cjs
29
+ tsxPath = require.resolve("tsx/dist/cli.cjs", { paths: [projectRoot] });
30
+ } catch (e) {
31
+ try {
32
+ tsxPath = require.resolve("tsx/cli", { paths: [projectRoot] });
33
+ } catch (_) {
34
+ // keep fallback to PATH
35
+ }
36
+ }
37
+
38
+ // Run the CLI via tsx (no extra node wrapper so PATH/global tsx works)
39
+ const child = spawn(tsxPath, [cliPath, ...args], {
40
+ stdio: "inherit",
41
+ cwd: projectRoot,
42
+ env: process.env,
43
+ shell: false,
44
+ });
45
+
46
+ child.on("error", (err) => {
47
+ console.error("Error running uplink:", err.message);
48
+ console.error("\nMake sure tsx is installed: npm install -g tsx");
49
+ process.exit(1);
50
+ });
51
+
52
+ child.on("exit", (code) => {
53
+ process.exit(code || 0);
54
+ });
55
+
@@ -0,0 +1,60 @@
1
+ import fetch from "node-fetch";
2
+
3
+ function getApiBase(): string {
4
+ return process.env.AGENTCLOUD_API_BASE ?? "https://api.uplink.spot";
5
+ }
6
+
7
+ function isLocalApiBase(apiBase: string): boolean {
8
+ return (
9
+ apiBase.includes("://localhost") ||
10
+ apiBase.includes("://127.0.0.1") ||
11
+ apiBase.includes("://0.0.0.0")
12
+ );
13
+ }
14
+
15
+ function getApiToken(apiBase: string): string | undefined {
16
+ // Production (non-local) always requires an explicit token.
17
+ if (!isLocalApiBase(apiBase)) {
18
+ return process.env.AGENTCLOUD_TOKEN || undefined;
19
+ }
20
+
21
+ // Local dev convenience:
22
+ // - Prefer AGENTCLOUD_TOKEN if set
23
+ // - Otherwise allow AGENTCLOUD_TOKEN_DEV / dev-token
24
+ return (
25
+ process.env.AGENTCLOUD_TOKEN ||
26
+ process.env.AGENTCLOUD_TOKEN_DEV ||
27
+ "dev-token"
28
+ );
29
+ }
30
+
31
+ export async function apiRequest(
32
+ method: string,
33
+ path: string,
34
+ body?: unknown
35
+ ): Promise<any> {
36
+ const apiBase = getApiBase();
37
+ const apiToken = getApiToken(apiBase);
38
+ if (!apiToken) {
39
+ throw new Error("Missing AGENTCLOUD_TOKEN");
40
+ }
41
+
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
+ }
58
+
59
+
60
+
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { dbCommand } from "./subcommands/db";
4
+ import { devCommand } from "./subcommands/dev";
5
+ import { adminCommand } from "./subcommands/admin";
6
+ import { menuCommand } from "./subcommands/menu";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("uplink")
12
+ .description("Agent-friendly cloud CLI")
13
+ .version("0.1.0");
14
+
15
+ program.addCommand(dbCommand);
16
+ program.addCommand(devCommand);
17
+ program.addCommand(adminCommand);
18
+ program.addCommand(menuCommand);
19
+
20
+ // If no command provided and not a flag, default to menu
21
+ if (process.argv.length === 2) {
22
+ process.argv.push("menu");
23
+ } else if (process.argv.length === 3 && process.argv[2] === "--help") {
24
+ // Show main help, not menu
25
+ process.argv.pop();
26
+ }
27
+
28
+ program.parseAsync(process.argv).catch((err) => {
29
+ console.error(err);
30
+ process.exit(1);
31
+ });
32
+
@@ -0,0 +1,351 @@
1
+ import { Command } from "commander";
2
+ import { apiRequest } from "../http";
3
+
4
+ export const adminCommand = new Command("admin")
5
+ .description("Admin commands for system management");
6
+
7
+ // Format date for display
8
+ function formatDate(dateStr: string): string {
9
+ const date = new Date(dateStr);
10
+ const now = new Date();
11
+ const diffMs = now.getTime() - date.getTime();
12
+ const diffMins = Math.floor(diffMs / 60000);
13
+ const diffHours = Math.floor(diffMs / 3600000);
14
+ const diffDays = Math.floor(diffMs / 86400000);
15
+
16
+ if (diffMins < 1) return "just now";
17
+ if (diffMins < 60) return `${diffMins}m ago`;
18
+ if (diffHours < 24) return `${diffHours}h ago`;
19
+ if (diffDays < 7) return `${diffDays}d ago`;
20
+ return date.toLocaleDateString();
21
+ }
22
+
23
+ // Status command
24
+ adminCommand
25
+ .command("status")
26
+ .description("Show system status and statistics")
27
+ .option("--json", "Output JSON", false)
28
+ .action(async (opts) => {
29
+ try {
30
+ // Check health
31
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
32
+ let health = { status: "unknown" };
33
+ try {
34
+ const fetch = (await import("node-fetch")).default;
35
+ const healthRes = await fetch(`${apiBase}/health`);
36
+ health = await healthRes.json();
37
+ } catch (err) {
38
+ // Health check failed, continue anyway
39
+ }
40
+
41
+ // Get stats
42
+ const stats = await apiRequest("GET", "/v1/admin/stats");
43
+
44
+ if (opts.json) {
45
+ console.log(JSON.stringify({ health, stats }, null, 2));
46
+ } else {
47
+ console.log("\n📊 System Status\n");
48
+ console.log(`API Health: ${health.status === "ok" ? "✅ OK" : "❌ Error"}`);
49
+ console.log("\n📈 Statistics\n");
50
+ console.log("Tunnels:");
51
+ console.log(` Active: ${stats.tunnels.active}`);
52
+ console.log(` Inactive: ${stats.tunnels.inactive}`);
53
+ console.log(` Deleted: ${stats.tunnels.deleted}`);
54
+ console.log(` Total: ${stats.tunnels.total}`);
55
+ console.log(` Created 24h: ${stats.tunnels.createdLast24h}`);
56
+ console.log("\nDatabases:");
57
+ console.log(` Ready: ${stats.databases.ready}`);
58
+ console.log(` Provisioning: ${stats.databases.provisioning}`);
59
+ console.log(` Failed: ${stats.databases.failed}`);
60
+ console.log(` Deleted: ${stats.databases.deleted}`);
61
+ console.log(` Total: ${stats.databases.total}`);
62
+ console.log(` Created 24h: ${stats.databases.createdLast24h}`);
63
+ console.log();
64
+ }
65
+ } catch (error: any) {
66
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
67
+ console.error("Error getting status:", errorMsg);
68
+ process.exit(1);
69
+ }
70
+ });
71
+
72
+ // Tunnels command
73
+ adminCommand
74
+ .command("tunnels")
75
+ .description("List all tunnels")
76
+ .option("--status <status>", "Filter by status (active, inactive, deleted)")
77
+ .option("--limit <limit>", "Limit results", "20")
78
+ .option("--json", "Output JSON", false)
79
+ .action(async (opts) => {
80
+ try {
81
+ const query: string[] = [];
82
+ if (opts.status) query.push(`status=${encodeURIComponent(opts.status)}`);
83
+ if (opts.limit) query.push(`limit=${opts.limit}`);
84
+ const queryStr = query.length > 0 ? `?${query.join("&")}` : "";
85
+
86
+ const result = await apiRequest("GET", `/v1/admin/tunnels${queryStr}`);
87
+
88
+ if (opts.json) {
89
+ console.log(JSON.stringify(result, null, 2));
90
+ } else {
91
+ console.log(`\n🔗 Tunnels (showing ${result.count} of ${result.total})\n`);
92
+ if (result.tunnels.length === 0) {
93
+ console.log("No tunnels found.");
94
+ } else {
95
+ console.log(
96
+ "ID".padEnd(40) +
97
+ "Token".padEnd(14) +
98
+ "Port".padEnd(6) +
99
+ "Status".padEnd(10) +
100
+ "Created"
101
+ );
102
+ console.log("-".repeat(90));
103
+ for (const tunnel of result.tunnels) {
104
+ const id = tunnel.id.substring(0, 38);
105
+ const token = tunnel.token.substring(0, 12);
106
+ const port = String(tunnel.target_port || tunnel.targetPort || "-");
107
+ const status = tunnel.status || "unknown";
108
+ const created = formatDate(tunnel.created_at || tunnel.createdAt);
109
+ console.log(
110
+ id.padEnd(40) +
111
+ token.padEnd(14) +
112
+ port.padEnd(6) +
113
+ status.padEnd(10) +
114
+ created
115
+ );
116
+ }
117
+ }
118
+ console.log();
119
+ }
120
+ } catch (error: any) {
121
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
122
+ console.error("Error listing tunnels:", errorMsg);
123
+ process.exit(1);
124
+ }
125
+ });
126
+
127
+ // Databases command
128
+ adminCommand
129
+ .command("databases")
130
+ .description("List all databases")
131
+ .option("--status <status>", "Filter by status (ready, provisioning, failed, deleted)")
132
+ .option("--limit <limit>", "Limit results", "20")
133
+ .option("--json", "Output JSON", false)
134
+ .action(async (opts) => {
135
+ try {
136
+ const query: string[] = [];
137
+ if (opts.status) query.push(`status=${encodeURIComponent(opts.status)}`);
138
+ if (opts.limit) query.push(`limit=${opts.limit}`);
139
+ const queryStr = query.length > 0 ? `?${query.join("&")}` : "";
140
+
141
+ const result = await apiRequest("GET", `/v1/admin/databases${queryStr}`);
142
+
143
+ if (opts.json) {
144
+ console.log(JSON.stringify(result, null, 2));
145
+ } else {
146
+ console.log(`\n🗄️ Databases (showing ${result.count} of ${result.total})\n`);
147
+ if (result.databases.length === 0) {
148
+ console.log("No databases found.");
149
+ } else {
150
+ console.log(
151
+ "ID".padEnd(40) +
152
+ "Name".padEnd(20) +
153
+ "Provider".padEnd(12) +
154
+ "Region".padEnd(15) +
155
+ "Status".padEnd(12) +
156
+ "Created"
157
+ );
158
+ console.log("-".repeat(110));
159
+ for (const db of result.databases) {
160
+ const id = db.id.substring(0, 38);
161
+ const name = (db.name || "-").substring(0, 18);
162
+ const provider = (db.provider || "-").substring(0, 10);
163
+ const region = (db.region || "-").substring(0, 13);
164
+ const status = db.status || "unknown";
165
+ const created = formatDate(db.created_at || db.createdAt);
166
+ console.log(
167
+ id.padEnd(40) +
168
+ name.padEnd(20) +
169
+ provider.padEnd(12) +
170
+ region.padEnd(15) +
171
+ status.padEnd(12) +
172
+ created
173
+ );
174
+ }
175
+ }
176
+ console.log();
177
+ }
178
+ } catch (error: any) {
179
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
180
+ console.error("Error listing databases:", errorMsg);
181
+ process.exit(1);
182
+ }
183
+ });
184
+
185
+ // Tokens command group
186
+ const tokensCommand = adminCommand
187
+ .command("tokens")
188
+ .description("Manage API tokens (mint/list/revoke)");
189
+
190
+ tokensCommand
191
+ .command("create")
192
+ .description("Mint a new token (returned once)")
193
+ .requiredOption("--role <role>", "Role: user|admin", "user")
194
+ .option("--label <label>", "Optional label (e.g. customer name)")
195
+ .option("--expires-days <days>", "Optional expiry in days (integer)")
196
+ .option("--json", "Output JSON", false)
197
+ .action(async (opts) => {
198
+ try {
199
+ const role = String(opts.role || "user");
200
+ const label = opts.label ? String(opts.label) : undefined;
201
+ const expiresInDays = opts.expiresDays ? Number(opts.expiresDays) : undefined;
202
+
203
+ const result = await apiRequest("POST", "/v1/admin/tokens", {
204
+ role,
205
+ label,
206
+ expiresInDays: Number.isFinite(expiresInDays as any) ? expiresInDays : undefined,
207
+ });
208
+
209
+ if (opts.json) {
210
+ console.log(JSON.stringify(result, null, 2));
211
+ return;
212
+ }
213
+
214
+ console.log("\n🔑 Token created\n");
215
+ console.log(`Role: ${result.role}`);
216
+ console.log(`User ID: ${result.userId}`);
217
+ console.log(`Token ID: ${result.id}`);
218
+ console.log(`Prefix: ${result.tokenPrefix}`);
219
+ if (result.label) console.log(`Label: ${result.label}`);
220
+ if (result.expiresAt) console.log(`Expires: ${result.expiresAt}`);
221
+ console.log("\nIMPORTANT: This token is shown only once. Store it securely.\n");
222
+ console.log(result.token);
223
+ console.log();
224
+ } catch (error: any) {
225
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
226
+ console.error("Error creating token:", errorMsg);
227
+ process.exit(1);
228
+ }
229
+ });
230
+
231
+ tokensCommand
232
+ .command("list")
233
+ .description("List tokens (no raw token values)")
234
+ .option("--limit <limit>", "Limit results", "50")
235
+ .option("--offset <offset>", "Offset", "0")
236
+ .option("--json", "Output JSON", false)
237
+ .action(async (opts) => {
238
+ try {
239
+ const limit = Number(opts.limit) || 50;
240
+ const offset = Number(opts.offset) || 0;
241
+ const result = await apiRequest(
242
+ "GET",
243
+ `/v1/admin/tokens?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(
244
+ String(offset)
245
+ )}`
246
+ );
247
+
248
+ if (opts.json) {
249
+ console.log(JSON.stringify(result, null, 2));
250
+ return;
251
+ }
252
+
253
+ console.log(`\n🪪 Tokens (showing ${result.count} of ${result.total})\n`);
254
+ if (!result.tokens || result.tokens.length === 0) {
255
+ console.log("No tokens found.");
256
+ return;
257
+ }
258
+
259
+ console.log(
260
+ "ID".padEnd(18) +
261
+ "Role".padEnd(8) +
262
+ "Prefix".padEnd(10) +
263
+ "User ID".padEnd(40) +
264
+ "Status".padEnd(12) +
265
+ "Created"
266
+ );
267
+ console.log("-".repeat(100));
268
+
269
+ for (const t of result.tokens) {
270
+ const id = String(t.id || "").slice(0, 16);
271
+ const role = String(t.role || "-").slice(0, 6);
272
+ const prefix = String(t.token_prefix || t.tokenPrefix || "-").slice(0, 8);
273
+ const userId = String(t.user_id || t.userId || "-").slice(0, 38);
274
+ const status = t.revoked_at || t.revokedAt ? "revoked" : "active";
275
+ const created = formatDate(t.created_at || t.createdAt || "");
276
+ console.log(
277
+ id.padEnd(18) +
278
+ role.padEnd(8) +
279
+ prefix.padEnd(10) +
280
+ userId.padEnd(40) +
281
+ status.padEnd(12) +
282
+ created
283
+ );
284
+ }
285
+ console.log();
286
+ } catch (error: any) {
287
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
288
+ console.error("Error listing tokens:", errorMsg);
289
+ process.exit(1);
290
+ }
291
+ });
292
+
293
+ tokensCommand
294
+ .command("revoke")
295
+ .description("Revoke a token (prefer by id)")
296
+ .option("--id <id>", "Token id (recommended)")
297
+ .option("--token <token>", "Raw token (avoid: may end up in shell history)")
298
+ .option("--json", "Output JSON", false)
299
+ .action(async (opts) => {
300
+ try {
301
+ const id = opts.id ? String(opts.id) : "";
302
+ const token = opts.token ? String(opts.token) : "";
303
+ if (!id && !token) {
304
+ console.error("Provide --id or --token");
305
+ process.exit(1);
306
+ }
307
+
308
+ const result = await apiRequest("POST", "/v1/admin/tokens/revoke", {
309
+ id: id || undefined,
310
+ token: token || undefined,
311
+ });
312
+
313
+ if (opts.json) {
314
+ console.log(JSON.stringify(result, null, 2));
315
+ } else {
316
+ console.log(`✅ Revoked token${id ? ` ${id}` : ""} at ${result.revokedAt || ""}`);
317
+ }
318
+ } catch (error: any) {
319
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
320
+ console.error("Error revoking token:", errorMsg);
321
+ process.exit(1);
322
+ }
323
+ });
324
+
325
+ // Cleanup command
326
+ adminCommand
327
+ .command("cleanup")
328
+ .description("Cleanup old data")
329
+ .option("--dev-user-tunnels", "Clean up tunnels owned by dev-user", false)
330
+ .option("--json", "Output JSON", false)
331
+ .action(async (opts) => {
332
+ try {
333
+ if (opts.devUserTunnels) {
334
+ const result = await apiRequest("POST", "/v1/admin/cleanup/dev-user-tunnels", {});
335
+
336
+ if (opts.json) {
337
+ console.log(JSON.stringify(result, null, 2));
338
+ } else {
339
+ console.log(`✅ ${result.message || `Cleaned up ${result.deleted || 0} dev-user tunnels`}`);
340
+ }
341
+ } else {
342
+ console.error("Specify what to cleanup: --dev-user-tunnels");
343
+ process.exit(1);
344
+ }
345
+ } catch (error: any) {
346
+ const errorMsg = error.message || error.toString() || JSON.stringify(error);
347
+ console.error("Error during cleanup:", errorMsg);
348
+ process.exit(1);
349
+ }
350
+ });
351
+