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 +205 -0
- package/cli/bin/uplink.js +55 -0
- package/cli/src/http.ts +60 -0
- package/cli/src/index.ts +32 -0
- package/cli/src/subcommands/admin.ts +351 -0
- package/cli/src/subcommands/db.ts +117 -0
- package/cli/src/subcommands/dev.ts +86 -0
- package/cli/src/subcommands/menu.ts +1222 -0
- package/cli/src/utils/port-scanner.ts +98 -0
- package/package.json +71 -0
- package/scripts/tunnel/client-improved.js +404 -0
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
|
+

|
|
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
|
+
|
package/cli/src/http.ts
ADDED
|
@@ -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
|
+
|
package/cli/src/index.ts
ADDED
|
@@ -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
|
+
|