uplink-cli 0.1.38 → 0.2.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.
- package/AGENTS.md +177 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +72 -52
- package/cli/src/index.ts +16 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +105 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +32 -0
- package/cli/src/registrars/namecheap.ts +163 -0
- package/cli/src/registrars/secret.ts +66 -0
- package/cli/src/registrars/store.ts +55 -0
- package/cli/src/registrars/types.ts +42 -0
- package/cli/src/subcommands/admin.ts +17 -30
- package/cli/src/subcommands/db.ts +63 -57
- package/cli/src/subcommands/dev.ts +23 -25
- package/cli/src/subcommands/domains.ts +295 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +3 -0
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/colors.ts +1 -1
- package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
- package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
- package/cli/src/subcommands/menu/io.ts +27 -5
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +197 -0
- package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
- package/cli/src/subcommands/menu/menus/index.ts +1 -0
- package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
- package/cli/src/subcommands/menu/render.ts +2 -2
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/menu/tests.ts +1 -1
- package/cli/src/subcommands/menu/tunnels.ts +10 -99
- package/cli/src/subcommands/menu/types.ts +8 -0
- package/cli/src/subcommands/menu.ts +32 -524
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +126 -33
- package/cli/src/templates/index.ts +3 -3
- package/cli/src/tui/App.tsx +202 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +92 -0
- package/cli/src/tui/brand.tsx +20 -0
- package/cli/src/tui/format.ts +22 -0
- package/cli/src/tui/index.mts +6 -0
- package/cli/src/tui/liveTree.ts +40 -0
- package/cli/src/tui/package.json +3 -0
- package/cli/src/tui/runMenu.tsx +57 -0
- package/cli/src/tui/session.mts +267 -0
- package/cli/src/tui/snapshot.ts +175 -0
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +130 -148
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +60 -288
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +11 -7
- package/package.json +22 -36
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- package/assets/cli-screenshot.png +0 -0
|
@@ -1,99 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`⚠ Tunnel already running on port ${port}`,
|
|
12
|
-
``,
|
|
13
|
-
`→ PID: ${existing[0].pid}`,
|
|
14
|
-
`→ Token: ${existing[0].token.substring(0, 8)}...`,
|
|
15
|
-
``,
|
|
16
|
-
`Use "Stop Tunnel" first to disconnect the existing tunnel.`,
|
|
17
|
-
].join("\n");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const result = await apiRequest("POST", "/v1/tunnels", { port });
|
|
21
|
-
const url = result.url || "(no url)";
|
|
22
|
-
const token = result.token || "(no token)";
|
|
23
|
-
const alias = result.alias || null;
|
|
24
|
-
const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
|
|
25
|
-
|
|
26
|
-
const path = require("path");
|
|
27
|
-
const projectRoot = resolveProjectRoot(__dirname);
|
|
28
|
-
const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
|
|
29
|
-
const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
|
|
30
|
-
stdio: "ignore",
|
|
31
|
-
detached: true,
|
|
32
|
-
cwd: projectRoot,
|
|
33
|
-
});
|
|
34
|
-
clientProcess.unref();
|
|
35
|
-
|
|
36
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
process.stdin.setRawMode(true);
|
|
40
|
-
process.stdin.resume();
|
|
41
|
-
} catch {
|
|
42
|
-
/* ignore */
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const lines = [
|
|
46
|
-
`✓ Tunnel created and client started`,
|
|
47
|
-
``,
|
|
48
|
-
`→ Public URL ${url}`,
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
if (alias) {
|
|
52
|
-
// Use aliasUrl from backend if available, otherwise construct it
|
|
53
|
-
const aliasUrl = result.aliasUrl || `https://${alias}.uplink.spot`;
|
|
54
|
-
lines.push(`→ Alias ${alias}`);
|
|
55
|
-
lines.push(`→ Alias URL ${aliasUrl}`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
lines.push(
|
|
59
|
-
`→ Token ${token}`,
|
|
60
|
-
`→ Local port ${port}`,
|
|
61
|
-
``,
|
|
62
|
-
`Tunnel client running in background.`,
|
|
63
|
-
`Use "Stop Tunnel" to disconnect.`,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
return lines.join("\n");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function findTunnelClients(): Array<{ pid: number; port: number; token: string }> {
|
|
70
|
-
try {
|
|
71
|
-
const user = process.env.USER || "";
|
|
72
|
-
const psCmd = user ? `ps -u ${user} -o pid=,command=` : "ps -eo pid=,command=";
|
|
73
|
-
const output = execSync(psCmd, { encoding: "utf-8" });
|
|
74
|
-
const lines = output
|
|
75
|
-
.trim()
|
|
76
|
-
.split("\n")
|
|
77
|
-
.filter((line) => line.includes("scripts/tunnel/client-improved.js"));
|
|
78
|
-
|
|
79
|
-
const clients: Array<{ pid: number; port: number; token: string }> = [];
|
|
80
|
-
|
|
81
|
-
for (const line of lines) {
|
|
82
|
-
const pidMatch = line.match(/^\s*(\d+)/);
|
|
83
|
-
const tokenMatch = line.match(/--token\s+(\S+)/);
|
|
84
|
-
const portMatch = line.match(/--port\s+(\d+)/);
|
|
85
|
-
|
|
86
|
-
if (pidMatch && tokenMatch && portMatch) {
|
|
87
|
-
clients.push({
|
|
88
|
-
pid: parseInt(pidMatch[1], 10),
|
|
89
|
-
port: parseInt(portMatch[1], 10),
|
|
90
|
-
token: tokenMatch[1],
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return clients;
|
|
96
|
-
} catch {
|
|
97
|
-
return [];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
1
|
+
/** @deprecated Prefer `./effects/tunnel-clients` — kept as a thin re-export. */
|
|
2
|
+
export {
|
|
3
|
+
createAndStartTunnel,
|
|
4
|
+
findTunnelClients,
|
|
5
|
+
killAllTunnelClients,
|
|
6
|
+
killTunnelClient,
|
|
7
|
+
startTunnelClient,
|
|
8
|
+
stopTunnelClients,
|
|
9
|
+
resolveTunnelClientPath,
|
|
10
|
+
} from "./effects/tunnel-clients";
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
export type MenuInspect = {
|
|
2
|
+
kind: "app";
|
|
3
|
+
id: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
createdAt?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
1
8
|
export type MenuChoice = {
|
|
2
9
|
label: string;
|
|
3
10
|
action?: () => Promise<string>;
|
|
4
11
|
subMenu?: MenuChoice[];
|
|
12
|
+
inspect?: MenuInspect;
|
|
5
13
|
};
|
|
6
14
|
|
|
7
15
|
export const DEFAULT_MENU_MESSAGE = "Use ↑/↓ and Enter. ← to go back. Ctrl+C to quit.";
|
|
@@ -1,533 +1,41 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { clearScreen, promptLine, restoreRawMode, truncate } from "./menu/io";
|
|
5
|
-
import { unauthenticatedRequest } from "./menu/requests";
|
|
6
|
-
import { inlineSelect, type SelectOption } from "./menu/inline-tree-select";
|
|
7
|
-
import {
|
|
8
|
-
colorBold,
|
|
9
|
-
colorCyan,
|
|
10
|
-
colorDim,
|
|
11
|
-
colorGreen,
|
|
12
|
-
colorMagenta,
|
|
13
|
-
colorRed,
|
|
14
|
-
colorWhite,
|
|
15
|
-
colorYellow,
|
|
16
|
-
} from "./menu/colors";
|
|
17
|
-
import { DEFAULT_MENU_MESSAGE, type MenuChoice } from "./menu/types";
|
|
18
|
-
import { getCurrentMenu, initNav, moveSelection, popMenu, pushSubMenu, type MenuNavState } from "./menu/nav";
|
|
19
|
-
import { renderMenu } from "./menu/render";
|
|
20
|
-
import {
|
|
21
|
-
buildManageAliasesMenu,
|
|
22
|
-
buildManageTokensMenu,
|
|
23
|
-
buildManageTunnelsMenu,
|
|
24
|
-
buildHostingMenu,
|
|
25
|
-
buildSystemStatusMenu,
|
|
26
|
-
buildUsageMenu,
|
|
27
|
-
} from "./menu/menus";
|
|
28
|
-
import { health, ports, smoke, tokenConfig, tunnelClients, tty } from "./menu/effects";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { join } from "path";
|
|
29
4
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
|
|
33
|
-
"██║ ██║██╔══██╗██║ ██║████╗ ██║██║ ██╔╝",
|
|
34
|
-
"██║ ██║██████╔╝██║ ██║██╔██╗ ██║█████╔╝ ",
|
|
35
|
-
"██║ ██║██╔═══╝ ██║ ██║██║╚██╗██║██╔═██╗ ",
|
|
36
|
-
"╚██████╔╝██║ ███████╗██║██║ ╚████║██║ ██╗",
|
|
37
|
-
" ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝",
|
|
38
|
-
].join("\n"));
|
|
39
|
-
|
|
40
|
-
function formatBytes(bytes: number): string {
|
|
41
|
-
if (bytes === 0) return "0 B";
|
|
42
|
-
const k = 1024;
|
|
43
|
-
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
44
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
45
|
-
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
5
|
+
function projectRoot(): string {
|
|
6
|
+
return join(__dirname, "../../..");
|
|
46
7
|
}
|
|
47
8
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// Determine role (admin or user) via /v1/me; check if auth failed
|
|
54
|
-
let isAdmin = false;
|
|
55
|
-
let authFailed = false;
|
|
56
|
-
const meStart = Date.now();
|
|
9
|
+
function resolveTsx(): string {
|
|
10
|
+
const root = projectRoot();
|
|
11
|
+
try {
|
|
12
|
+
return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
|
|
13
|
+
} catch {
|
|
57
14
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Check if it's an authentication error
|
|
62
|
-
const errorMsg = err?.message || String(err);
|
|
63
|
-
authFailed =
|
|
64
|
-
errorMsg.includes("UNAUTHORIZED") ||
|
|
65
|
-
errorMsg.includes("401") ||
|
|
66
|
-
errorMsg.includes("Missing or invalid token") ||
|
|
67
|
-
errorMsg.includes("Missing AGENTCLOUD_TOKEN");
|
|
68
|
-
isAdmin = false;
|
|
69
|
-
}
|
|
70
|
-
const meDurationMs = Date.now() - meStart;
|
|
71
|
-
|
|
72
|
-
// Build menu structure dynamically by role and auth status
|
|
73
|
-
const mainMenu: MenuChoice[] = [];
|
|
74
|
-
|
|
75
|
-
// If authentication failed, show ONLY "Get Started", "About", and "Exit"
|
|
76
|
-
if (authFailed) {
|
|
77
|
-
mainMenu.push({
|
|
78
|
-
label: "🚀 Get Started (Create Account)",
|
|
79
|
-
action: async () => {
|
|
80
|
-
restoreRawMode();
|
|
81
|
-
clearScreen();
|
|
82
|
-
try {
|
|
83
|
-
process.stdout.write("\n");
|
|
84
|
-
process.stdout.write(colorCyan("UPLINK") + colorDim(" │ ") + "Create Account\n");
|
|
85
|
-
process.stdout.write(colorDim("─".repeat(40)) + "\n\n");
|
|
86
|
-
|
|
87
|
-
const label = (await promptLine("Label (optional): ")).trim();
|
|
88
|
-
const expiresInput = (await promptLine("Expires in days (optional): ")).trim();
|
|
89
|
-
const expiresDays = expiresInput ? Number(expiresInput) : undefined;
|
|
90
|
-
|
|
91
|
-
if (expiresDays && (isNaN(expiresDays) || expiresDays <= 0)) {
|
|
92
|
-
restoreRawMode();
|
|
93
|
-
return "Invalid expiration days. Please enter a positive number or leave empty.";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
process.stdout.write("\nCreating your token...\n");
|
|
97
|
-
process.stdout.write("");
|
|
98
|
-
let result;
|
|
99
|
-
try {
|
|
100
|
-
result = await unauthenticatedRequest("POST", "/v1/signup", {
|
|
101
|
-
label: label || undefined,
|
|
102
|
-
expiresInDays: expiresDays || undefined,
|
|
103
|
-
});
|
|
104
|
-
if (!result) {
|
|
105
|
-
restoreRawMode();
|
|
106
|
-
return "❌ Error: No response from server.";
|
|
107
|
-
}
|
|
108
|
-
} catch (err: any) {
|
|
109
|
-
restoreRawMode();
|
|
110
|
-
const errorMsg = err?.message || String(err);
|
|
111
|
-
console.error("\n❌ Signup error:", errorMsg);
|
|
112
|
-
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
113
|
-
return "⚠️ Too many signup attempts. Please try again later.";
|
|
114
|
-
}
|
|
115
|
-
return `❌ Error creating account: ${errorMsg}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!result || !result.token) {
|
|
119
|
-
restoreRawMode();
|
|
120
|
-
return "❌ Error: Invalid response from server. Token not received.";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const token = result.token;
|
|
124
|
-
const tokenId = result.id;
|
|
125
|
-
const userId = result.userId;
|
|
126
|
-
|
|
127
|
-
process.stdout.write("\n");
|
|
128
|
-
process.stdout.write(colorGreen("✓") + " Account created\n");
|
|
129
|
-
process.stdout.write("\n");
|
|
130
|
-
process.stdout.write(colorDim("├─") + " Token " + colorCyan(token) + "\n");
|
|
131
|
-
process.stdout.write(colorDim("├─") + " ID " + tokenId + "\n");
|
|
132
|
-
process.stdout.write(colorDim("├─") + " User " + userId + "\n");
|
|
133
|
-
process.stdout.write(colorDim("├─") + " Role " + result.role + "\n");
|
|
134
|
-
if (result.expiresAt) {
|
|
135
|
-
process.stdout.write(colorDim("└─") + " Expires " + result.expiresAt + "\n");
|
|
136
|
-
} else {
|
|
137
|
-
process.stdout.write(colorDim("└─") + " Expires " + colorDim("never") + "\n");
|
|
138
|
-
}
|
|
139
|
-
process.stdout.write("\n");
|
|
140
|
-
process.stdout.write(colorYellow("!") + " Save this token securely - shown only once\n");
|
|
141
|
-
|
|
142
|
-
// Try to automatically add token to shell config
|
|
143
|
-
const detected = tokenConfig.detectShellConfigFile();
|
|
144
|
-
let configFile: string | null = detected.configFile;
|
|
145
|
-
let shellName = detected.shellName;
|
|
146
|
-
|
|
147
|
-
let tokenAdded = false;
|
|
148
|
-
const tokenExists = configFile ? tokenConfig.shellConfigHasToken(configFile) : false;
|
|
149
|
-
|
|
150
|
-
if (configFile) {
|
|
151
|
-
const promptText = tokenExists
|
|
152
|
-
? `\n→ Update existing token in ~/.${shellName}rc? (Y/n): `
|
|
153
|
-
: `\n→ Add token to ~/.${shellName}rc? (Y/n): `;
|
|
154
|
-
|
|
155
|
-
const addToken = (await promptLine(promptText)).trim().toLowerCase();
|
|
156
|
-
if (addToken !== "n" && addToken !== "no") {
|
|
157
|
-
try {
|
|
158
|
-
const res = tokenConfig.upsertShellToken(configFile, token);
|
|
159
|
-
tokenAdded = res.wrote;
|
|
160
|
-
if (tokenExists) {
|
|
161
|
-
console.log(colorGreen(`\n✓ Token updated in ~/.${shellName}rc`));
|
|
162
|
-
} else {
|
|
163
|
-
console.log(colorGreen(`\n✓ Token added to ~/.${shellName}rc`));
|
|
164
|
-
}
|
|
165
|
-
if (!res.verifyOk) {
|
|
166
|
-
console.log(
|
|
167
|
-
colorYellow(`\n! Warning: Token may not have been written correctly. Please check ~/.${shellName}rc`)
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
} catch (err: any) {
|
|
171
|
-
if (err?.message?.includes("UNSAFE_SHELL_CONFIG_PERMISSIONS")) {
|
|
172
|
-
console.log(
|
|
173
|
-
colorYellow(
|
|
174
|
-
`\n! Could not write to ~/.${shellName}rc: file is group/world writable. Fix permissions first.`
|
|
175
|
-
)
|
|
176
|
-
);
|
|
177
|
-
console.log(colorDim(` chmod 600 ~/.${shellName}rc`));
|
|
178
|
-
} else {
|
|
179
|
-
console.log(colorYellow(`\n! Could not write to ~/.${shellName}rc: ${err.message}`));
|
|
180
|
-
}
|
|
181
|
-
console.log(`\n Please add manually:`);
|
|
182
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc`));
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
console.log(colorYellow(`\n→ Could not detect your shell. Add the token manually:`));
|
|
187
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.zshrc # for zsh`));
|
|
188
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.bashrc # for bash`));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (!tokenAdded) {
|
|
192
|
-
process.stdout.write("\n");
|
|
193
|
-
process.stdout.write(colorYellow("!") + " Set this token as an environment variable:\n\n");
|
|
194
|
-
process.stdout.write(colorDim(" ") + "export AGENTCLOUD_TOKEN=" + token + "\n");
|
|
195
|
-
if (configFile) {
|
|
196
|
-
process.stdout.write(colorDim(`\n Or add to ~/.${shellName}rc:\n`));
|
|
197
|
-
process.stdout.write(colorDim(" ") + `echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc\n`);
|
|
198
|
-
process.stdout.write(colorDim(" ") + `source ~/.${shellName}rc\n`);
|
|
199
|
-
}
|
|
200
|
-
process.stdout.write(colorDim("\n Then restart this menu.\n\n"));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
restoreRawMode();
|
|
204
|
-
|
|
205
|
-
if (tokenAdded) {
|
|
206
|
-
process.env.AGENTCLOUD_TOKEN = token;
|
|
207
|
-
// Use stdout writes to avoid buffering/race with process.exit()
|
|
208
|
-
process.stdout.write(`\n${colorGreen("✓")} Token saved to ~/.${shellName}rc\n`);
|
|
209
|
-
process.stdout.write(`\n${colorYellow("→")} Next: run in your terminal:\n`);
|
|
210
|
-
process.stdout.write(colorDim(` source ~/.${shellName}rc && uplink\n\n`));
|
|
211
|
-
|
|
212
|
-
setTimeout(() => {
|
|
213
|
-
process.exit(0);
|
|
214
|
-
}, 3000);
|
|
215
|
-
|
|
216
|
-
return undefined as any;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
console.log("\nPress Enter to continue...");
|
|
220
|
-
await promptLine("");
|
|
221
|
-
restoreRawMode();
|
|
222
|
-
return "Token created! Please set AGENTCLOUD_TOKEN environment variable and restart the menu.";
|
|
223
|
-
} catch (err: any) {
|
|
224
|
-
restoreRawMode();
|
|
225
|
-
const errorMsg = err?.message || String(err);
|
|
226
|
-
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
227
|
-
return "⚠️ Too many signup attempts. Please try again later.";
|
|
228
|
-
}
|
|
229
|
-
return `❌ Error creating account: ${errorMsg}`;
|
|
230
|
-
}
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
mainMenu.push({
|
|
235
|
-
label: "About",
|
|
236
|
-
action: async () => {
|
|
237
|
-
return [
|
|
238
|
-
"Uplink CLI",
|
|
239
|
-
"Open source CLI for sharing localhost and hosting apps.",
|
|
240
|
-
"Interactive menu + agent-friendly commands for automation.",
|
|
241
|
-
"",
|
|
242
|
-
"Website: https://uplink.spot",
|
|
243
|
-
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
244
|
-
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
245
|
-
].join("\n");
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
mainMenu.push({
|
|
250
|
-
label: "Exit",
|
|
251
|
-
action: async () => {
|
|
252
|
-
return "Goodbye!";
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
} else {
|
|
256
|
-
// Only show other menu items if authentication succeeded
|
|
257
|
-
|
|
258
|
-
const shareMenu = buildManageTunnelsMenu({
|
|
259
|
-
apiRequest,
|
|
260
|
-
promptLine,
|
|
261
|
-
restoreRawMode,
|
|
262
|
-
truncate,
|
|
263
|
-
formatBytes,
|
|
264
|
-
inlineSelect,
|
|
265
|
-
scanCommonPorts: ports.scanCommonPorts,
|
|
266
|
-
findTunnelClients: tunnelClients.findTunnelClients,
|
|
267
|
-
createAndStartTunnel: (port: number) => tunnelClients.createAndStartTunnel(apiRequest, port),
|
|
268
|
-
killTunnelClient: tunnelClients.killTunnelClient,
|
|
269
|
-
killAllTunnelClients: tunnelClients.killAllTunnelClients,
|
|
270
|
-
colorDim,
|
|
271
|
-
colorRed,
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const aliasesMenu = buildManageAliasesMenu({
|
|
275
|
-
apiRequest,
|
|
276
|
-
promptLine,
|
|
277
|
-
restoreRawMode,
|
|
278
|
-
inlineSelect,
|
|
279
|
-
findTunnelClients: tunnelClients.findTunnelClients,
|
|
280
|
-
truncate,
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
shareMenu.subMenu = shareMenu.subMenu || [];
|
|
284
|
-
if (aliasesMenu.subMenu) {
|
|
285
|
-
shareMenu.subMenu.push({
|
|
286
|
-
label: "Aliases",
|
|
287
|
-
subMenu: aliasesMenu.subMenu,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
if (isAdmin) {
|
|
291
|
-
shareMenu.subMenu.push({
|
|
292
|
-
label: "⚠️ Stop ALL Tunnel Clients (kill switch)",
|
|
293
|
-
action: async () => {
|
|
294
|
-
const clients = tunnelClients.findTunnelClients();
|
|
295
|
-
if (clients.length === 0) {
|
|
296
|
-
return "No running tunnel clients found.";
|
|
297
|
-
}
|
|
298
|
-
const killed = tunnelClients.killAllTunnelClients(clients);
|
|
299
|
-
return `✓ Stopped ${killed} tunnel client${killed !== 1 ? "s" : ""}`;
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
mainMenu.push(shareMenu);
|
|
305
|
-
|
|
306
|
-
mainMenu.push(
|
|
307
|
-
buildHostingMenu({
|
|
308
|
-
promptLine,
|
|
309
|
-
restoreRawMode,
|
|
310
|
-
inlineSelect,
|
|
311
|
-
})
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
// Admin-only: Usage section
|
|
315
|
-
if (isAdmin) {
|
|
316
|
-
mainMenu.push(
|
|
317
|
-
buildUsageMenu({
|
|
318
|
-
apiRequest,
|
|
319
|
-
truncate,
|
|
320
|
-
})
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (isAdmin) {
|
|
325
|
-
mainMenu.push(
|
|
326
|
-
buildSystemStatusMenu({
|
|
327
|
-
apiBase,
|
|
328
|
-
apiRequest,
|
|
329
|
-
fetch: (url: string) => fetch(url) as any,
|
|
330
|
-
truncate,
|
|
331
|
-
formatBytes,
|
|
332
|
-
runSmoke: smoke.runSmoke,
|
|
333
|
-
})
|
|
334
|
-
);
|
|
15
|
+
return require.resolve("tsx/cli", { paths: [root] });
|
|
16
|
+
} catch {
|
|
17
|
+
return "tsx";
|
|
335
18
|
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
336
21
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
);
|
|
22
|
+
/**
|
|
23
|
+
* Ink 6 is ESM-only (yoga-layout uses top-level await). The CLI package is
|
|
24
|
+
* CommonJS, so the menu runs as a child ESM process rather than importing Ink here.
|
|
25
|
+
*/
|
|
26
|
+
export const menuCommand = new Command("menu")
|
|
27
|
+
.description("Interactive terminal menu (arrow keys + enter)")
|
|
28
|
+
.action(() => {
|
|
29
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
30
|
+
console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
|
|
31
|
+
process.exit(1);
|
|
347
32
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
"Uplink CLI",
|
|
354
|
-
"Open source CLI for sharing localhost and hosting apps.",
|
|
355
|
-
"Interactive menu + agent-friendly commands for automation.",
|
|
356
|
-
"",
|
|
357
|
-
"Website: https://uplink.spot",
|
|
358
|
-
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
359
|
-
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
360
|
-
].join("\n");
|
|
361
|
-
},
|
|
33
|
+
const entry = join(__dirname, "../tui/index.mts");
|
|
34
|
+
const result = spawnSync(resolveTsx(), [entry], {
|
|
35
|
+
stdio: "inherit",
|
|
36
|
+
cwd: projectRoot(),
|
|
37
|
+
env: process.env,
|
|
362
38
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
action: async () => "Goodbye!",
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Menu navigation state
|
|
371
|
-
let nav: MenuNavState = initNav(mainMenu);
|
|
372
|
-
let message = DEFAULT_MENU_MESSAGE;
|
|
373
|
-
let exiting = false;
|
|
374
|
-
let busy = false;
|
|
375
|
-
|
|
376
|
-
// Cache active tunnels info - only update at start or when returning to main menu
|
|
377
|
-
let cachedActiveTunnels = "";
|
|
378
|
-
let cachedRelayStatus = "";
|
|
379
|
-
|
|
380
|
-
const updateActiveTunnelsCache = () => {
|
|
381
|
-
const clients = tunnelClients.findTunnelClients();
|
|
382
|
-
if (clients.length === 0) {
|
|
383
|
-
cachedActiveTunnels = "";
|
|
384
|
-
} else {
|
|
385
|
-
// Default domain should be the current production domain; allow override via env.
|
|
386
|
-
const domain = process.env.TUNNEL_DOMAIN || "x.uplink.spot";
|
|
387
|
-
const scheme = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
|
|
388
|
-
|
|
389
|
-
const tunnelLines = clients.map((client, idx) => {
|
|
390
|
-
const url = `${scheme}://${client.token}.${domain}`;
|
|
391
|
-
const isLast = idx === clients.length - 1;
|
|
392
|
-
const branch = isLast ? "└─" : "├─";
|
|
393
|
-
return colorDim(branch) + " " + colorGreen(url) + colorDim(" → ") + `localhost:${client.port}`;
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
cachedActiveTunnels = [
|
|
397
|
-
colorDim("├─") + " Active " + colorGreen(`${clients.length} tunnel${clients.length > 1 ? "s" : ""}`),
|
|
398
|
-
colorDim("│"),
|
|
399
|
-
...tunnelLines,
|
|
400
|
-
].join("\n");
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const updateRelayStatusCache = async () => {
|
|
405
|
-
const res = await health.checkApiHealth({});
|
|
406
|
-
if (res.ok) cachedRelayStatus = "API: ok";
|
|
407
|
-
else if (typeof res.status === "number") cachedRelayStatus = `API: unreachable (HTTP ${res.status})`;
|
|
408
|
-
else cachedRelayStatus = "API: unreachable";
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
const refreshMainMenuCaches = async () => {
|
|
412
|
-
updateActiveTunnelsCache();
|
|
413
|
-
await updateRelayStatusCache();
|
|
414
|
-
render();
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
const render = () => {
|
|
418
|
-
renderMenu({
|
|
419
|
-
banner: ASCII_UPLINK,
|
|
420
|
-
cachedRelayStatus,
|
|
421
|
-
cachedActiveTunnels,
|
|
422
|
-
menuPath: nav.menuPath,
|
|
423
|
-
currentMenu: getCurrentMenu(nav),
|
|
424
|
-
selected: nav.selected,
|
|
425
|
-
message,
|
|
426
|
-
busy,
|
|
427
|
-
showStatusIndicator: nav.menuStack.length === 1,
|
|
428
|
-
});
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
const cleanup = () => {
|
|
432
|
-
try {
|
|
433
|
-
process.stdin.setRawMode(false);
|
|
434
|
-
} catch {
|
|
435
|
-
/* ignore */
|
|
436
|
-
}
|
|
437
|
-
process.stdin.pause();
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
const handleAction = async () => {
|
|
441
|
-
const currentMenu = getCurrentMenu(nav);
|
|
442
|
-
const choice = currentMenu[nav.selected];
|
|
443
|
-
|
|
444
|
-
if (choice.subMenu) {
|
|
445
|
-
// Navigate into sub-menu
|
|
446
|
-
nav = pushSubMenu(nav, choice);
|
|
447
|
-
// Invalidate caches when leaving main menu
|
|
448
|
-
cachedActiveTunnels = "";
|
|
449
|
-
cachedRelayStatus = "";
|
|
450
|
-
render();
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (!choice.action) {
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
busy = true;
|
|
459
|
-
render();
|
|
460
|
-
try {
|
|
461
|
-
const result = await choice.action();
|
|
462
|
-
// If action returns undefined, it handled its own output/exit (e.g., signup flow)
|
|
463
|
-
if (result === undefined) {
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
message = result;
|
|
467
|
-
if (choice.label === "Exit") {
|
|
468
|
-
exiting = true;
|
|
469
|
-
}
|
|
470
|
-
} catch (err: any) {
|
|
471
|
-
message = `Error: ${err?.message || String(err)}`;
|
|
472
|
-
} finally {
|
|
473
|
-
busy = false;
|
|
474
|
-
render();
|
|
475
|
-
if (exiting) {
|
|
476
|
-
cleanup();
|
|
477
|
-
process.exit(0);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
const NAV_DEBOUNCE_MS = 30;
|
|
483
|
-
let lastNavAt = 0;
|
|
484
|
-
const onKey = async (key: Buffer) => {
|
|
485
|
-
if (busy) return;
|
|
486
|
-
const str = key.toString();
|
|
487
|
-
const currentMenu = getCurrentMenu(nav);
|
|
488
|
-
const now = Date.now();
|
|
489
|
-
const isNavKey = str === "\u001b[A" || str === "\u001b[B" || str === "\u001b[D";
|
|
490
|
-
|
|
491
|
-
if (str === "\u0003") {
|
|
492
|
-
cleanup();
|
|
493
|
-
process.exit(0);
|
|
494
|
-
} else if (str === "\u001b[D") {
|
|
495
|
-
if (now - lastNavAt < NAV_DEBOUNCE_MS) return;
|
|
496
|
-
lastNavAt = now;
|
|
497
|
-
// Left arrow - go back
|
|
498
|
-
if (nav.menuStack.length > 1) {
|
|
499
|
-
nav = popMenu(nav);
|
|
500
|
-
// Refresh caches when returning to main menu
|
|
501
|
-
if (nav.menuStack.length === 1) {
|
|
502
|
-
await refreshMainMenuCaches();
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
render();
|
|
506
|
-
}
|
|
507
|
-
} else if (str === "\u001b[A") {
|
|
508
|
-
if (now - lastNavAt < NAV_DEBOUNCE_MS) return;
|
|
509
|
-
lastNavAt = now;
|
|
510
|
-
// Up
|
|
511
|
-
const prevSelected = nav.selected;
|
|
512
|
-
nav = moveSelection(nav, -1);
|
|
513
|
-
if (nav.selected !== prevSelected) render();
|
|
514
|
-
} else if (str === "\u001b[B") {
|
|
515
|
-
if (now - lastNavAt < NAV_DEBOUNCE_MS) return;
|
|
516
|
-
lastNavAt = now;
|
|
517
|
-
// Down
|
|
518
|
-
const prevSelected = nav.selected;
|
|
519
|
-
nav = moveSelection(nav, 1);
|
|
520
|
-
if (nav.selected !== prevSelected) render();
|
|
521
|
-
} else if (str === "\r") {
|
|
522
|
-
await handleAction();
|
|
523
|
-
} else if (isNavKey) {
|
|
524
|
-
lastNavAt = now;
|
|
525
|
-
}
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
// Initial scans for active tunnels and relay status at startup
|
|
529
|
-
await refreshMainMenuCaches();
|
|
530
|
-
process.stdin.setRawMode(true);
|
|
531
|
-
process.stdin.resume();
|
|
532
|
-
process.stdin.on("data", onKey);
|
|
533
|
-
});
|
|
39
|
+
if (result.error) throw result.error;
|
|
40
|
+
process.exit(result.status ?? 0);
|
|
41
|
+
});
|