uplink-cli 0.1.39 → 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.
@@ -1,12 +1,19 @@
1
1
  import fetch from "node-fetch";
2
- import { getResolvedApiBase } from "../../utils/api-base";
2
+ import { getResolvedApiBase, getResolvedApiToken } from "../../utils/api-base";
3
3
 
4
- export async function unauthenticatedRequest(method: string, path: string, body?: unknown): Promise<any> {
4
+ export async function unauthenticatedRequest(
5
+ method: string,
6
+ path: string,
7
+ body?: unknown,
8
+ options: { includeCurrentToken?: boolean } = {}
9
+ ): Promise<any> {
5
10
  const apiBase = getResolvedApiBase();
11
+ const token = options.includeCurrentToken ? getResolvedApiToken(apiBase) : undefined;
6
12
  const response = await fetch(`${apiBase}${path}`, {
7
13
  method,
8
14
  headers: {
9
15
  "Content-Type": "application/json",
16
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
10
17
  },
11
18
  body: body ? JSON.stringify(body) : undefined,
12
19
  });
@@ -39,7 +39,7 @@ async function signupRequest(body: Record<string, unknown>): Promise<SignupRespo
39
39
  }
40
40
 
41
41
  export const signupCommand = new Command("signup")
42
- .description("Create a new user account and token (no auth required)")
42
+ .description("Create an explicit guest token (tunnel create does this automatically)")
43
43
  .option("--label <label>", "Optional label for the token")
44
44
  .option("--expires-days <days>", "Token expiration in days (optional)")
45
45
  .option("--json", "Output JSON", false)
@@ -63,7 +63,7 @@ export const signupCommand = new Command("signup")
63
63
  } else {
64
64
  const apiBase = getResolvedApiBase();
65
65
  const tokenExport = formatTokenForEnv(result.token, apiBase);
66
- console.log("Account created successfully!");
66
+ console.log("Guest access created successfully!");
67
67
  console.log("");
68
68
  console.log(` Token: ${result.token}`);
69
69
  console.log(` User ID: ${result.userId}`);
@@ -6,6 +6,7 @@ import {
6
6
  killTunnelClient,
7
7
  startTunnelClient,
8
8
  } from "./menu/effects/tunnel-clients";
9
+ import { ensureGuestAccess } from "../utils/guest-access";
9
10
 
10
11
  type TunnelResponse = {
11
12
  id: string;
@@ -51,6 +52,7 @@ tunnelCommand
51
52
  }
52
53
 
53
54
  try {
55
+ await ensureGuestAccess();
54
56
  const existing = findTunnelClients().filter((c) => c.port === port);
55
57
  if (existing.length > 0 && !opts.apiOnly) {
56
58
  const err = `Tunnel client already running on port ${port} (pid ${existing[0].pid})`;
@@ -13,6 +13,11 @@ export type MenuStatus = {
13
13
  tunnels: TunnelLine[];
14
14
  apps: { name: string; id: string; url?: string; createdAt?: string }[];
15
15
  providers: string[];
16
+ storageUsedBytes: number;
17
+ storageLimitBytes: number;
18
+ appLimit: number;
19
+ alwaysOn: boolean;
20
+ idleMinutes: number | null;
16
21
  };
17
22
 
18
23
  export type MenuOutcome =
@@ -1,45 +1,73 @@
1
1
  import { Box, Text } from "ink";
2
2
  import type { MenuStatus } from "./App";
3
3
  import { Wordmark } from "./brand";
4
+ import { formatBytes } from "./format";
4
5
 
5
6
  const LABEL_WIDTH = 12;
6
- const BAR_CAP = 24;
7
+ const SPACE_GAUGE = 16;
7
8
 
8
- function CountBar({ count }: { count: number }) {
9
- if (count <= 0) return null;
10
- const filled = Math.min(count, BAR_CAP);
11
- return (
12
- <Text>
13
- <Text color="green">{"█".repeat(filled)}</Text>
14
- {count > BAR_CAP ? <Text dimColor> +{count - BAR_CAP}</Text> : null}
15
- </Text>
16
- );
9
+ function formatLimit(n: number): string {
10
+ return n < 0 ? "∞" : String(n);
17
11
  }
18
12
 
19
- function Metric({ label, count }: { label: string; count: number }) {
13
+ function Metric({ label, value }: { label: string; value: string }) {
20
14
  return (
21
15
  <Box>
22
16
  <Box width={LABEL_WIDTH}>
23
17
  <Text dimColor>{label}</Text>
24
18
  </Box>
25
- <Box width={4}>
26
- {count > 0 ? <Text>{count}</Text> : <Text dimColor>—</Text>}
19
+ <Text>{value}</Text>
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ function SpaceMetric({ usedBytes, limitBytes }: { usedBytes: number; limitBytes: number }) {
25
+ const used = Math.max(0, usedBytes);
26
+ const unlimited = limitBytes < 0;
27
+ const cap = unlimited ? Math.max(used, 1) : Math.max(limitBytes, 1);
28
+ const left = unlimited ? used : Math.max(0, limitBytes - used);
29
+ const ratio = unlimited ? 0 : Math.min(1, used / cap);
30
+ const filled = Math.round(ratio * SPACE_GAUGE);
31
+ const nearlyFull = !unlimited && ratio >= 0.85;
32
+
33
+ return (
34
+ <Box flexDirection="column">
35
+ <Box>
36
+ <Box width={LABEL_WIDTH}>
37
+ <Text dimColor>space</Text>
38
+ </Box>
39
+ <Text>
40
+ <Text color={nearlyFull ? "red" : "green"}>{"█".repeat(filled)}</Text>
41
+ <Text dimColor>{"░".repeat(SPACE_GAUGE - filled)}</Text>
42
+ <Text dimColor>
43
+ {" "}
44
+ {unlimited ? `${formatBytes(used)} used` : `${formatBytes(left)} left`}
45
+ </Text>
46
+ </Text>
47
+ </Box>
48
+ <Box>
49
+ <Box width={LABEL_WIDTH}>
50
+ <Text> </Text>
51
+ </Box>
52
+ <Text dimColor>
53
+ {unlimited ? "unlimited" : `of ${formatBytes(limitBytes)} hosting budget`}
54
+ </Text>
27
55
  </Box>
28
- <CountBar count={count} />
29
56
  </Box>
30
57
  );
31
58
  }
32
59
 
33
60
  export function HomeStatus({ status }: { status: MenuStatus }) {
34
61
  const latency =
35
- status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "";
62
+ status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "0ms";
63
+ const plan = status.alwaysOn ? "always-on" : `sleep after ${status.idleMinutes ?? 30}m idle`;
36
64
 
37
65
  return (
38
66
  <Box flexDirection="column">
39
67
  <Wordmark />
40
68
  <Box marginTop={1}>
41
- <Text color={status.connected ? "green" : "red"}>
42
- {status.connected ? "connected" : "offline"}
69
+ <Text color={status.connected ? "green" : "yellow"}>
70
+ {status.connected ? "connected" : "offline"}
43
71
  </Text>
44
72
  <Text dimColor> · {latency}</Text>
45
73
  </Box>
@@ -47,9 +75,14 @@ export function HomeStatus({ status }: { status: MenuStatus }) {
47
75
  <Text dimColor>{"─".repeat(36)}</Text>
48
76
  </Box>
49
77
  <Box flexDirection="column">
50
- <Metric label="apps" count={status.apps.length} />
51
- <Metric label="tunnels" count={status.tunnels.length} />
52
- <Metric label="registrars" count={status.providers.length} />
78
+ <Metric
79
+ label="apps"
80
+ value={`${status.apps.length} / ${formatLimit(status.appLimit)}`}
81
+ />
82
+ <Metric label="tunnels" value={String(status.tunnels.length)} />
83
+ <Metric label="registrars" value={String(status.providers.length)} />
84
+ <SpaceMetric usedBytes={status.storageUsedBytes} limitBytes={status.storageLimitBytes} />
85
+ <Metric label="plan" value={plan} />
53
86
  </Box>
54
87
  <Box marginTop={1}>
55
88
  <Text dimColor>{"─".repeat(36)}</Text>
@@ -1,7 +1,6 @@
1
1
  import fetch from "node-fetch";
2
2
  import { apiRequest } from "../http";
3
3
  import { clearScreen, promptLine, restoreRawMode, truncate } from "../subcommands/menu/io";
4
- import { unauthenticatedRequest } from "../subcommands/menu/requests";
5
4
  import { inlineSelect } from "../subcommands/menu/inline-tree-select";
6
5
  import {
7
6
  colorDim,
@@ -19,10 +18,12 @@ import {
19
18
  buildSystemStatusMenu,
20
19
  buildUsageMenu,
21
20
  } from "../subcommands/menu/menus";
22
- import { ports, smoke, tokenConfig, tunnelClients } from "../subcommands/menu/effects";
21
+ import { buildFindDomainAction } from "../subcommands/menu/menus/domain-check";
22
+ import { ports, smoke, tunnelClients } from "../subcommands/menu/effects";
23
23
  import { runInkMenu } from "./runMenu";
24
- import { launchDomainking } from "../utils/launchDomainking";
25
24
  import { fetchMenuSnapshot } from "./snapshot";
25
+ import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
26
+ import { ensureGuestAccess } from "../utils/guest-access";
26
27
 
27
28
  function formatBytes(bytes: number): string {
28
29
  if (bytes === 0) return "0 B";
@@ -32,221 +33,116 @@ function formatBytes(bytes: number): string {
32
33
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
33
34
  }
34
35
 
36
+ async function continueWithEmail(): Promise<string | undefined> {
37
+ restoreRawMode();
38
+ clearScreen();
39
+ try {
40
+ process.stdout.write("\n");
41
+ process.stdout.write(colorWhite("UPLINK") + colorDim(" Continue with email\n\n"));
42
+ const email = normalizeEmail(await promptLine("Email: "));
43
+ if (!isEmail(email)) return "Invalid email.";
44
+
45
+ await requestLoginCode(email);
46
+ process.stdout.write(`\nCode sent to ${email}.\n`);
47
+ const code = (await promptLine("Code: ")).trim();
48
+ if (!/^\d{6}$/.test(code)) return "Code must be 6 digits.";
49
+
50
+ const result = await verifyLoginCode(email, code);
51
+ if (!result?.token) return "Invalid response from server. Token not received.";
52
+ const savedTo = persistLogin(result, email);
53
+ process.stdout.write(`\n${colorGreen("✓")} Account verified\n`);
54
+ process.stdout.write(colorDim(` ${savedTo}\n\n`));
55
+ return "Email verified. Run uplink again to see Hosting and Domains.";
56
+ } catch (err: any) {
57
+ const errorMsg = err?.message || String(err);
58
+ if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
59
+ return "Too many attempts. Please try again later.";
60
+ }
61
+ return `Email verification failed: ${errorMsg}`;
62
+ } finally {
63
+ restoreRawMode();
64
+ }
65
+ }
66
+
67
+ const aboutItem: MenuChoice = {
68
+ label: "About",
69
+ action: async () => {
70
+ return [
71
+ "Uplink CLI",
72
+ "Open source CLI for sharing localhost and hosting apps.",
73
+ "Interactive menu + agent-friendly commands for automation.",
74
+ "",
75
+ "Website: https://uplink.spot",
76
+ "GitHub: https://github.com/firstprinciplecode/uplink",
77
+ "Issues: https://github.com/firstprinciplecode/uplink/issues",
78
+ ].join("\n");
79
+ },
80
+ };
81
+
82
+ const exitItem: MenuChoice = {
83
+ label: "Exit",
84
+ action: async () => "Goodbye!",
85
+ };
86
+
35
87
  export async function startMenuSession(): Promise<void> {
88
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
89
+ console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
90
+ process.exit(1);
91
+ }
92
+
36
93
  const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
37
-
38
- // Determine role (admin or user) via /v1/me; check if auth failed
94
+
39
95
  let isAdmin = false;
40
- let authFailed = false;
41
- const meStart = Date.now();
42
- try {
96
+ let accountType: "guest" | "verified" | "admin" | null = null;
97
+ let connectionError: string | null = null;
98
+
99
+ const resolveAccount = async (): Promise<void> => {
43
100
  const me = await apiRequest("GET", "/v1/me");
44
101
  isAdmin = me?.role === "admin";
102
+ accountType = isAdmin ? "admin" : me?.accountType === "verified" ? "verified" : "guest";
103
+ };
104
+
105
+ try {
106
+ await resolveAccount();
45
107
  } catch (err: any) {
46
- // Check if it's an authentication error
47
108
  const errorMsg = err?.message || String(err);
48
- authFailed =
109
+ const authFailed =
49
110
  errorMsg.includes("UNAUTHORIZED") ||
50
111
  errorMsg.includes("401") ||
51
112
  errorMsg.includes("Missing or invalid token") ||
52
113
  errorMsg.includes("Missing AGENTCLOUD_TOKEN");
53
- isAdmin = false;
114
+ if (authFailed) {
115
+ // No usable token: quietly create guest access so everyone gets the same menu.
116
+ try {
117
+ await ensureGuestAccess({ force: true });
118
+ await resolveAccount();
119
+ } catch (guestErr: any) {
120
+ connectionError = guestErr?.message || String(guestErr);
121
+ }
122
+ } else {
123
+ connectionError = errorMsg;
124
+ }
54
125
  }
55
- const meDurationMs = Date.now() - meStart;
56
126
 
57
- // Build menu structure dynamically by role and auth status
58
127
  const mainMenu: MenuChoice[] = [];
59
-
60
- // If authentication failed, show ONLY "Get Started", "About", and "Exit"
61
- if (authFailed) {
62
- mainMenu.push({
63
- label: "🚀 Get Started (Create Account)",
64
- action: async () => {
65
- restoreRawMode();
66
- clearScreen();
67
- try {
68
- process.stdout.write("\n");
69
- process.stdout.write(colorWhite("UPLINK") + colorDim(" Create Account\n"));
70
- process.stdout.write("\n");
71
128
 
72
- const label = (await promptLine("Label (optional): ")).trim();
73
- const expiresInput = (await promptLine("Expires in days (optional): ")).trim();
74
- const expiresDays = expiresInput ? Number(expiresInput) : undefined;
75
-
76
- if (expiresDays && (isNaN(expiresDays) || expiresDays <= 0)) {
77
- restoreRawMode();
78
- return "Invalid expiration days. Please enter a positive number or leave empty.";
79
- }
80
-
81
- process.stdout.write("\nCreating your token...\n");
82
- process.stdout.write("");
83
- let result;
84
- try {
85
- result = await unauthenticatedRequest("POST", "/v1/signup", {
86
- label: label || undefined,
87
- expiresInDays: expiresDays || undefined,
88
- });
89
- if (!result) {
90
- restoreRawMode();
91
- return "❌ Error: No response from server.";
92
- }
93
- } catch (err: any) {
94
- restoreRawMode();
95
- const errorMsg = err?.message || String(err);
96
- console.error("\n❌ Signup error:", errorMsg);
97
- if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
98
- return "⚠️ Too many signup attempts. Please try again later.";
99
- }
100
- return `❌ Error creating account: ${errorMsg}`;
101
- }
102
-
103
- if (!result || !result.token) {
104
- restoreRawMode();
105
- return "❌ Error: Invalid response from server. Token not received.";
106
- }
107
-
108
- const token = result.token;
109
- const tokenId = result.id;
110
- const userId = result.userId;
111
-
112
- process.stdout.write("\n");
113
- process.stdout.write(colorGreen("✓") + " Account created\n");
114
- process.stdout.write("\n");
115
- process.stdout.write(colorDim("├─") + " Token " + token + "\n");
116
- process.stdout.write(colorDim("├─") + " ID " + tokenId + "\n");
117
- process.stdout.write(colorDim("├─") + " User " + userId + "\n");
118
- process.stdout.write(colorDim("├─") + " Role " + result.role + "\n");
119
- if (result.expiresAt) {
120
- process.stdout.write(colorDim("└─") + " Expires " + result.expiresAt + "\n");
121
- } else {
122
- process.stdout.write(colorDim("└─") + " Expires " + colorDim("never") + "\n");
123
- }
124
- process.stdout.write("\n");
125
- process.stdout.write(colorDim("!") + " Save this token securely — shown only once\n");
126
-
127
- // Try to automatically add token to shell config
128
- const detected = tokenConfig.detectShellConfigFile();
129
- let configFile: string | null = detected.configFile;
130
- let shellName = detected.shellName;
131
-
132
- let tokenAdded = false;
133
- const tokenExists = configFile ? tokenConfig.shellConfigHasToken(configFile) : false;
134
-
135
- if (configFile) {
136
- const promptText = tokenExists
137
- ? `\n→ Update existing token in ~/.${shellName}rc? (Y/n): `
138
- : `\n→ Add token to ~/.${shellName}rc? (Y/n): `;
139
-
140
- const addToken = (await promptLine(promptText)).trim().toLowerCase();
141
- if (addToken !== "n" && addToken !== "no") {
142
- try {
143
- const res = tokenConfig.upsertShellToken(configFile, token);
144
- tokenAdded = res.wrote;
145
- if (tokenExists) {
146
- console.log(colorGreen(`\n✓ Token updated in ~/.${shellName}rc`));
147
- } else {
148
- console.log(colorGreen(`\n✓ Token added to ~/.${shellName}rc`));
149
- }
150
- if (!res.verifyOk) {
151
- console.log(
152
- colorRed(`\n! Token may not have been written correctly. Check ~/.${shellName}rc`)
153
- );
154
- }
155
- } catch (err: any) {
156
- if (err?.message?.includes("UNSAFE_SHELL_CONFIG_PERMISSIONS")) {
157
- console.log(
158
- colorRed(
159
- `\n! Could not write to ~/.${shellName}rc: file is group/world writable. Fix permissions first.`
160
- )
161
- );
162
- console.log(colorDim(` chmod 600 ~/.${shellName}rc`));
163
- } else {
164
- console.log(colorRed(`\n! Could not write to ~/.${shellName}rc: ${err.message}`));
165
- }
166
- console.log(`\n Please add manually:`);
167
- console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc`));
168
- }
169
- }
170
- } else {
171
- console.log(colorDim(`\n→ Could not detect your shell. Add the token manually:`));
172
- console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.zshrc # for zsh`));
173
- console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.bashrc # for bash`));
174
- }
175
-
176
- if (!tokenAdded) {
177
- process.stdout.write("\n");
178
- process.stdout.write(colorDim("!") + " Set this token as an environment variable:\n\n");
179
- process.stdout.write(colorDim(" ") + "export AGENTCLOUD_TOKEN=" + token + "\n");
180
- if (configFile) {
181
- process.stdout.write(colorDim(`\n Or add to ~/.${shellName}rc:\n`));
182
- process.stdout.write(colorDim(" ") + `echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc\n`);
183
- process.stdout.write(colorDim(" ") + `source ~/.${shellName}rc\n`);
184
- }
185
- process.stdout.write(colorDim("\n Then restart this menu.\n\n"));
186
- }
187
-
188
- restoreRawMode();
189
-
190
- if (tokenAdded) {
191
- process.env.AGENTCLOUD_TOKEN = token;
192
- // Use stdout writes to avoid buffering/race with process.exit()
193
- process.stdout.write(`\n${colorGreen("✓")} Token saved to ~/.${shellName}rc\n`);
194
- process.stdout.write(`\n${colorDim("→")} Next: run in your terminal:\n`);
195
- process.stdout.write(colorDim(` source ~/.${shellName}rc && uplink\n\n`));
196
-
197
- setTimeout(() => {
198
- process.exit(0);
199
- }, 3000);
200
-
201
- return undefined as any;
202
- }
203
-
204
- console.log("\nPress Enter to continue...");
205
- await promptLine("");
206
- restoreRawMode();
207
- return "Token created! Please set AGENTCLOUD_TOKEN environment variable and restart the menu.";
208
- } catch (err: any) {
209
- restoreRawMode();
210
- const errorMsg = err?.message || String(err);
211
- if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
212
- return "⚠️ Too many signup attempts. Please try again later.";
213
- }
214
- return `❌ Error creating account: ${errorMsg}`;
215
- }
216
- },
217
- });
218
-
129
+ if (!accountType) {
130
+ // API unreachable (or guest provisioning failed): minimal offline menu.
219
131
  mainMenu.push({
220
- label: "Find a domain",
221
- action: async () => {
222
- restoreRawMode();
223
- return launchDomainking();
224
- },
225
- });
226
-
227
- mainMenu.push({
228
- label: "About",
132
+ label: "Connection details",
229
133
  action: async () => {
230
134
  return [
231
- "Uplink CLI",
232
- "Open source CLI for sharing localhost and hosting apps.",
233
- "Interactive menu + agent-friendly commands for automation.",
135
+ `Could not reach ${apiBase}.`,
136
+ "",
137
+ connectionError ?? "Unknown error.",
234
138
  "",
235
- "Website: https://uplink.spot",
236
- "GitHub: https://github.com/firstprinciplecode/uplink",
237
- "Issues: https://github.com/firstprinciplecode/uplink/issues",
139
+ "Check your network, then run uplink again.",
238
140
  ].join("\n");
239
141
  },
240
142
  });
241
-
242
- mainMenu.push({
243
- label: "Exit",
244
- action: async () => {
245
- return "Goodbye!";
246
- },
247
- });
143
+ mainMenu.push(aboutItem);
144
+ mainMenu.push(exitItem);
248
145
  } else {
249
- // Only show other menu items if authentication succeeded
250
146
 
251
147
  const shareMenu = buildManageTunnelsMenu({
252
148
  apiRequest,
@@ -263,21 +159,22 @@ export async function startMenuSession(): Promise<void> {
263
159
  colorRed,
264
160
  });
265
161
 
266
- const aliasesMenu = buildManageAliasesMenu({
267
- apiRequest,
268
- promptLine,
269
- restoreRawMode,
270
- inlineSelect,
271
- findTunnelClients: tunnelClients.findTunnelClients,
272
- truncate,
273
- });
274
-
275
162
  shareMenu.subMenu = shareMenu.subMenu || [];
276
- if (aliasesMenu.subMenu) {
277
- shareMenu.subMenu.push({
278
- label: "Aliases",
279
- subMenu: aliasesMenu.subMenu,
163
+ if (accountType !== "guest") {
164
+ const aliasesMenu = buildManageAliasesMenu({
165
+ apiRequest,
166
+ promptLine,
167
+ restoreRawMode,
168
+ inlineSelect,
169
+ findTunnelClients: tunnelClients.findTunnelClients,
170
+ truncate,
280
171
  });
172
+ if (aliasesMenu.subMenu) {
173
+ shareMenu.subMenu.push({
174
+ label: "Aliases",
175
+ subMenu: aliasesMenu.subMenu,
176
+ });
177
+ }
281
178
  }
282
179
  if (isAdmin) {
283
180
  shareMenu.subMenu.push({
@@ -301,21 +198,31 @@ export async function startMenuSession(): Promise<void> {
301
198
 
302
199
  mainMenu.push(shareMenu);
303
200
 
304
- mainMenu.push(
305
- buildHostingMenu({
306
- promptLine,
307
- restoreRawMode,
308
- inlineSelect,
309
- })
310
- );
311
-
312
- mainMenu.push(
313
- buildDomainsMenu({
314
- promptLine,
315
- restoreRawMode,
316
- inlineSelect,
317
- })
318
- );
201
+ if (accountType === "guest") {
202
+ mainMenu.push({
203
+ label: "Check domain availability",
204
+ action: buildFindDomainAction({ promptLine, restoreRawMode }),
205
+ });
206
+ mainMenu.push({
207
+ label: "Continue with email (unlock hosting + domains)",
208
+ action: continueWithEmail,
209
+ });
210
+ } else {
211
+ mainMenu.push(
212
+ buildHostingMenu({
213
+ promptLine,
214
+ restoreRawMode,
215
+ inlineSelect,
216
+ })
217
+ );
218
+ mainMenu.push(
219
+ buildDomainsMenu({
220
+ promptLine,
221
+ restoreRawMode,
222
+ inlineSelect,
223
+ })
224
+ );
225
+ }
319
226
 
320
227
  // Admin-only: Usage section
321
228
  if (isAdmin) {
@@ -352,31 +259,9 @@ export async function startMenuSession(): Promise<void> {
352
259
  );
353
260
  }
354
261
 
355
- mainMenu.push({
356
- label: "About",
357
- action: async () => {
358
- return [
359
- "Uplink CLI",
360
- "Open source CLI for sharing localhost and hosting apps.",
361
- "Interactive menu + agent-friendly commands for automation.",
362
- "",
363
- "Website: https://uplink.spot",
364
- "GitHub: https://github.com/firstprinciplecode/uplink",
365
- "Issues: https://github.com/firstprinciplecode/uplink/issues",
366
- ].join("\n");
367
- },
368
- });
369
-
370
- mainMenu.push({
371
- label: "Exit",
372
- action: async () => "Goodbye!",
373
- });
374
- }
375
-
376
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
377
- console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
378
- process.exit(1);
262
+ mainMenu.push(aboutItem);
263
+ mainMenu.push(exitItem);
379
264
  }
380
265
 
381
266
  await runInkMenu({ tree: mainMenu, getStatus: fetchMenuSnapshot });
382
- }
267
+ }