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.
Files changed (67) hide show
  1. package/AGENTS.md +177 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +72 -52
  5. package/cli/src/index.ts +16 -3
  6. package/cli/src/registrars/cloudflare.ts +148 -0
  7. package/cli/src/registrars/dreamhost.ts +129 -0
  8. package/cli/src/registrars/godaddy.ts +105 -0
  9. package/cli/src/registrars/hostinger.ts +106 -0
  10. package/cli/src/registrars/http.ts +18 -0
  11. package/cli/src/registrars/index.ts +32 -0
  12. package/cli/src/registrars/namecheap.ts +163 -0
  13. package/cli/src/registrars/secret.ts +66 -0
  14. package/cli/src/registrars/store.ts +55 -0
  15. package/cli/src/registrars/types.ts +42 -0
  16. package/cli/src/subcommands/admin.ts +17 -30
  17. package/cli/src/subcommands/db.ts +63 -57
  18. package/cli/src/subcommands/dev.ts +23 -25
  19. package/cli/src/subcommands/domains.ts +295 -0
  20. package/cli/src/subcommands/host-domains.ts +148 -0
  21. package/cli/src/subcommands/host.ts +3 -0
  22. package/cli/src/subcommands/login.ts +85 -0
  23. package/cli/src/subcommands/menu/colors.ts +1 -1
  24. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  25. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  26. package/cli/src/subcommands/menu/io.ts +27 -5
  27. package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
  28. package/cli/src/subcommands/menu/menus/domains.ts +197 -0
  29. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  30. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  31. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  32. package/cli/src/subcommands/menu/render.ts +2 -2
  33. package/cli/src/subcommands/menu/requests.ts +9 -2
  34. package/cli/src/subcommands/menu/tests.ts +1 -1
  35. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  36. package/cli/src/subcommands/menu/types.ts +8 -0
  37. package/cli/src/subcommands/menu.ts +32 -524
  38. package/cli/src/subcommands/signup.ts +2 -2
  39. package/cli/src/subcommands/system.ts +58 -36
  40. package/cli/src/subcommands/tunnel.ts +126 -33
  41. package/cli/src/templates/index.ts +3 -3
  42. package/cli/src/tui/App.tsx +202 -0
  43. package/cli/src/tui/AppInspector.tsx +114 -0
  44. package/cli/src/tui/HomeStatus.tsx +92 -0
  45. package/cli/src/tui/brand.tsx +20 -0
  46. package/cli/src/tui/format.ts +22 -0
  47. package/cli/src/tui/index.mts +6 -0
  48. package/cli/src/tui/liveTree.ts +40 -0
  49. package/cli/src/tui/package.json +3 -0
  50. package/cli/src/tui/runMenu.tsx +57 -0
  51. package/cli/src/tui/session.mts +267 -0
  52. package/cli/src/tui/snapshot.ts +175 -0
  53. package/cli/src/utils/api-base.ts +11 -0
  54. package/cli/src/utils/credentials.ts +58 -0
  55. package/cli/src/utils/domain-availability.ts +56 -0
  56. package/cli/src/utils/guest-access.ts +38 -0
  57. package/cli/src/utils/launchDomainking.ts +64 -0
  58. package/cli/src/utils/login-flow.ts +57 -0
  59. package/docs/AGENTS.md +130 -148
  60. package/docs/HOSTING.md +55 -0
  61. package/docs/MENU_STRUCTURE.md +60 -288
  62. package/docs/PRODUCT.md +64 -0
  63. package/docs/README.md +11 -7
  64. package/package.json +22 -36
  65. package/scripts/tunnel/client-improved.js +127 -38
  66. package/scripts/tunnel/client.js +118 -0
  67. package/assets/cli-screenshot.png +0 -0
@@ -0,0 +1,114 @@
1
+ import { Box, Text } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import type { MenuInspect } from "../subcommands/menu/types";
4
+ import { ARTIFACT_CAP_BYTES, fetchAppInspect, type AppInspect } from "./snapshot";
5
+ import { formatBytes, formatDate } from "./format";
6
+
7
+ const LABEL_WIDTH = 10;
8
+ const GAUGE_WIDTH = 16;
9
+
10
+ function Row({
11
+ label,
12
+ value,
13
+ color,
14
+ dim,
15
+ }: {
16
+ label: string;
17
+ value: string;
18
+ color?: "green" | "red";
19
+ dim?: boolean;
20
+ }) {
21
+ return (
22
+ <Box>
23
+ <Box width={LABEL_WIDTH}>
24
+ <Text dimColor>{label}</Text>
25
+ </Box>
26
+ <Text color={color} dimColor={dim}>
27
+ {value}
28
+ </Text>
29
+ </Box>
30
+ );
31
+ }
32
+
33
+ function SizeGauge({ bytes }: { bytes: number }) {
34
+ const ratio = Math.min(1, bytes / ARTIFACT_CAP_BYTES);
35
+ const filled = Math.round(ratio * GAUGE_WIDTH);
36
+ return (
37
+ <Box>
38
+ <Box width={LABEL_WIDTH}>
39
+ <Text dimColor>size</Text>
40
+ </Box>
41
+ <Text>
42
+ <Text color="green">{"█".repeat(filled)}</Text>
43
+ <Text dimColor>{"░".repeat(GAUGE_WIDTH - filled)}</Text>
44
+ <Text dimColor>
45
+ {" "}
46
+ {formatBytes(bytes)} / {formatBytes(ARTIFACT_CAP_BYTES)}
47
+ </Text>
48
+ </Text>
49
+ </Box>
50
+ );
51
+ }
52
+
53
+ function statusColor(value?: string): "green" | "red" | undefined {
54
+ if (!value) return undefined;
55
+ if (value === "running" || value === "ready") return "green";
56
+ if (value === "failed") return "red";
57
+ return undefined;
58
+ }
59
+
60
+ export function AppInspector({ inspect }: { inspect?: MenuInspect }) {
61
+ const [detail, setDetail] = useState<AppInspect | null>(null);
62
+ const [loading, setLoading] = useState(false);
63
+
64
+ useEffect(() => {
65
+ if (!inspect || inspect.kind !== "app") {
66
+ setDetail(null);
67
+ return;
68
+ }
69
+ let cancelled = false;
70
+ const timer = setTimeout(() => {
71
+ setLoading(true);
72
+ fetchAppInspect(inspect.id).then((next) => {
73
+ if (cancelled) return;
74
+ setDetail(next);
75
+ setLoading(false);
76
+ });
77
+ }, 120);
78
+ return () => {
79
+ cancelled = true;
80
+ clearTimeout(timer);
81
+ };
82
+ }, [inspect?.id, inspect?.kind]);
83
+
84
+ if (!inspect) return null;
85
+
86
+ const url = detail?.url || inspect.url || "—";
87
+ const deploy = detail?.deploy || (loading ? "…" : "—");
88
+ const build = detail?.build || (loading ? "…" : "—");
89
+ const domainText =
90
+ detail && detail.domains.length > 0
91
+ ? detail.domains
92
+ .slice(0, 2)
93
+ .map((domain) => `${domain.hostname}${domain.verified ? "" : " (pending)"}`)
94
+ .join(", ") + (detail.domains.length > 2 ? ` +${detail.domains.length - 2}` : "")
95
+ : loading && !detail
96
+ ? "…"
97
+ : "none";
98
+
99
+ return (
100
+ <Box flexDirection="column" marginTop={1}>
101
+ <Text dimColor>── inspect ──</Text>
102
+ <Row label="url" value={url} />
103
+ <Row label="status" value={deploy} color={statusColor(detail?.deploy)} dim={!detail?.deploy} />
104
+ <Row label="build" value={build} color={statusColor(detail?.build)} dim={!detail?.build} />
105
+ {detail?.sizeBytes != null ? (
106
+ <SizeGauge bytes={detail.sizeBytes} />
107
+ ) : (
108
+ <Row label="size" value={loading ? "…" : "none"} dim />
109
+ )}
110
+ <Row label="created" value={formatDate(detail?.createdAt || inspect.createdAt)} dim={!detail?.createdAt && !inspect.createdAt} />
111
+ <Row label="domains" value={domainText} dim={domainText === "none"} />
112
+ </Box>
113
+ );
114
+ }
@@ -0,0 +1,92 @@
1
+ import { Box, Text } from "ink";
2
+ import type { MenuStatus } from "./App";
3
+ import { Wordmark } from "./brand";
4
+ import { formatBytes } from "./format";
5
+
6
+ const LABEL_WIDTH = 12;
7
+ const SPACE_GAUGE = 16;
8
+
9
+ function formatLimit(n: number): string {
10
+ return n < 0 ? "∞" : String(n);
11
+ }
12
+
13
+ function Metric({ label, value }: { label: string; value: string }) {
14
+ return (
15
+ <Box>
16
+ <Box width={LABEL_WIDTH}>
17
+ <Text dimColor>{label}</Text>
18
+ </Box>
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>
55
+ </Box>
56
+ </Box>
57
+ );
58
+ }
59
+
60
+ export function HomeStatus({ status }: { status: MenuStatus }) {
61
+ const latency =
62
+ status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "0ms";
63
+ const plan = status.alwaysOn ? "always-on" : `sleep after ${status.idleMinutes ?? 30}m idle`;
64
+
65
+ return (
66
+ <Box flexDirection="column">
67
+ <Wordmark />
68
+ <Box marginTop={1}>
69
+ <Text color={status.connected ? "green" : "yellow"}>
70
+ ● {status.connected ? "connected" : "offline"}
71
+ </Text>
72
+ <Text dimColor> · {latency}</Text>
73
+ </Box>
74
+ <Box marginTop={1} marginBottom={1}>
75
+ <Text dimColor>{"─".repeat(36)}</Text>
76
+ </Box>
77
+ <Box flexDirection="column">
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} />
86
+ </Box>
87
+ <Box marginTop={1}>
88
+ <Text dimColor>{"─".repeat(36)}</Text>
89
+ </Box>
90
+ </Box>
91
+ );
92
+ }
@@ -0,0 +1,20 @@
1
+ import { Box, Text } from "ink";
2
+
3
+ const WORDMARK = [
4
+ " _ _ ___ _ ___ _ _ _ __",
5
+ "| | | | _ \\ | |_ _| \\| | |/ /",
6
+ "| |_| | _/ |__ | || .` | ' < ",
7
+ " \\___/|_| |____|___|_|\\_|_|\\_\\",
8
+ ];
9
+
10
+ export function Wordmark() {
11
+ return (
12
+ <Box flexDirection="column">
13
+ {WORDMARK.map((line) => (
14
+ <Text key={line} bold>
15
+ {line}
16
+ </Text>
17
+ ))}
18
+ </Box>
19
+ );
20
+ }
@@ -0,0 +1,22 @@
1
+ export function formatBytes(bytes: number): string {
2
+ if (bytes === 0) return "0 B";
3
+ const k = 1024;
4
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
5
+ const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
6
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
7
+ }
8
+
9
+ export function formatDate(iso?: string): string {
10
+ if (!iso) return "—";
11
+ const date = new Date(iso);
12
+ if (Number.isNaN(date.getTime())) return "—";
13
+ return date.toISOString().slice(0, 10);
14
+ }
15
+
16
+ export function cleanLabel(label: string): string {
17
+ return label
18
+ .replace(/^🚀\s*/, "")
19
+ .replace(/^⚠️\s*/, "⚠ ")
20
+ .replace(/^✅\s*/, "")
21
+ .replace(/^❌\s*/, "");
22
+ }
@@ -0,0 +1,6 @@
1
+ import { startMenuSession } from "./session.mts";
2
+
3
+ startMenuSession().catch((error) => {
4
+ console.error(error instanceof Error ? error.message : error);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,40 @@
1
+ import type { MenuChoice } from "../subcommands/menu/types";
2
+ import type { MenuStatus } from "./App";
3
+ import { cleanLabel } from "./format";
4
+ import { fetchAppLogs } from "./snapshot";
5
+
6
+ export function withLiveApps(tree: MenuChoice[], status: MenuStatus): MenuChoice[] {
7
+ return tree.map((item) => {
8
+ if (!item.subMenu || cleanLabel(item.label) !== "Hosting") return item;
9
+ const rest = item.subMenu.filter((choice) => cleanLabel(choice.label) !== "Apps");
10
+ const appsMenu: MenuChoice = {
11
+ label: "Apps",
12
+ subMenu:
13
+ status.apps.length > 0
14
+ ? status.apps.map((app) => ({
15
+ label: app.name,
16
+ inspect: { kind: "app", id: app.id, url: app.url, createdAt: app.createdAt },
17
+ subMenu: [
18
+ {
19
+ label: "Logs",
20
+ action: () => fetchAppLogs(app.id),
21
+ },
22
+ ],
23
+ }))
24
+ : [{ label: "No apps yet", action: async () => "No hosted apps." }],
25
+ };
26
+ return { ...item, subMenu: [appsMenu, ...rest] };
27
+ });
28
+ }
29
+
30
+ export function resolveStack(tree: MenuChoice[], titles: string[]): MenuChoice[][] {
31
+ const stack: MenuChoice[][] = [tree];
32
+ let current = tree;
33
+ for (let i = 1; i < titles.length; i += 1) {
34
+ const found = current.find((choice) => cleanLabel(choice.label) === titles[i]);
35
+ if (!found?.subMenu) break;
36
+ stack.push(found.subMenu);
37
+ current = found.subMenu;
38
+ }
39
+ return stack;
40
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,57 @@
1
+ import { render } from "ink";
2
+ import { MenuApp, type MenuOutcome, type MenuStatus } from "./App";
3
+ import type { MenuChoice } from "../subcommands/menu/types";
4
+ import { prepareStdinForPrompt } from "../subcommands/menu/io";
5
+ import { resolveStack, withLiveApps } from "./liveTree";
6
+
7
+ export async function runInkMenu(opts: {
8
+ tree: MenuChoice[];
9
+ getStatus: () => Promise<MenuStatus>;
10
+ }): Promise<void> {
11
+ let message = "";
12
+ let titles = ["UPLINK"];
13
+ let selected = 0;
14
+
15
+ while (true) {
16
+ const status = await opts.getStatus();
17
+ const tree = withLiveApps(opts.tree, status);
18
+ const stack = resolveStack(tree, titles);
19
+ let outcome: MenuOutcome = { kind: "quit" };
20
+ const instance = render(
21
+ <MenuApp
22
+ tree={tree}
23
+ status={status}
24
+ message={message}
25
+ initialStack={stack}
26
+ initialTitles={titles}
27
+ initialSelected={Math.min(selected, (stack[stack.length - 1]?.length || 1) - 1)}
28
+ onOutcome={(next) => {
29
+ outcome = next;
30
+ }}
31
+ />
32
+ );
33
+ await instance.waitUntilExit();
34
+ instance.unmount();
35
+ prepareStdinForPrompt();
36
+
37
+ if (outcome.kind !== "action") return;
38
+
39
+ titles = outcome.titles;
40
+ selected = outcome.selected;
41
+
42
+ if (outcome.isExit) {
43
+ try {
44
+ await outcome.action();
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ return;
49
+ }
50
+
51
+ try {
52
+ message = (await outcome.action()) || "";
53
+ } catch (error) {
54
+ message = `Error: ${error instanceof Error ? error.message : String(error)}`;
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,267 @@
1
+ import fetch from "node-fetch";
2
+ import { apiRequest } from "../http";
3
+ import { clearScreen, promptLine, restoreRawMode, truncate } from "../subcommands/menu/io";
4
+ import { inlineSelect } from "../subcommands/menu/inline-tree-select";
5
+ import {
6
+ colorDim,
7
+ colorGreen,
8
+ colorRed,
9
+ colorWhite,
10
+ } from "../subcommands/menu/colors";
11
+ import { type MenuChoice } from "../subcommands/menu/types";
12
+ import {
13
+ buildManageAliasesMenu,
14
+ buildManageTokensMenu,
15
+ buildManageTunnelsMenu,
16
+ buildHostingMenu,
17
+ buildDomainsMenu,
18
+ buildSystemStatusMenu,
19
+ buildUsageMenu,
20
+ } from "../subcommands/menu/menus";
21
+ import { buildFindDomainAction } from "../subcommands/menu/menus/domain-check";
22
+ import { ports, smoke, tunnelClients } from "../subcommands/menu/effects";
23
+ import { runInkMenu } from "./runMenu";
24
+ import { fetchMenuSnapshot } from "./snapshot";
25
+ import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
26
+ import { ensureGuestAccess } from "../utils/guest-access";
27
+
28
+ function formatBytes(bytes: number): string {
29
+ if (bytes === 0) return "0 B";
30
+ const k = 1024;
31
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
32
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
33
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
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
+
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
+
93
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
94
+
95
+ let isAdmin = false;
96
+ let accountType: "guest" | "verified" | "admin" | null = null;
97
+ let connectionError: string | null = null;
98
+
99
+ const resolveAccount = async (): Promise<void> => {
100
+ const me = await apiRequest("GET", "/v1/me");
101
+ isAdmin = me?.role === "admin";
102
+ accountType = isAdmin ? "admin" : me?.accountType === "verified" ? "verified" : "guest";
103
+ };
104
+
105
+ try {
106
+ await resolveAccount();
107
+ } catch (err: any) {
108
+ const errorMsg = err?.message || String(err);
109
+ const authFailed =
110
+ errorMsg.includes("UNAUTHORIZED") ||
111
+ errorMsg.includes("401") ||
112
+ errorMsg.includes("Missing or invalid token") ||
113
+ errorMsg.includes("Missing AGENTCLOUD_TOKEN");
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
+ }
125
+ }
126
+
127
+ const mainMenu: MenuChoice[] = [];
128
+
129
+ if (!accountType) {
130
+ // API unreachable (or guest provisioning failed): minimal offline menu.
131
+ mainMenu.push({
132
+ label: "Connection details",
133
+ action: async () => {
134
+ return [
135
+ `Could not reach ${apiBase}.`,
136
+ "",
137
+ connectionError ?? "Unknown error.",
138
+ "",
139
+ "Check your network, then run uplink again.",
140
+ ].join("\n");
141
+ },
142
+ });
143
+ mainMenu.push(aboutItem);
144
+ mainMenu.push(exitItem);
145
+ } else {
146
+
147
+ const shareMenu = buildManageTunnelsMenu({
148
+ apiRequest,
149
+ promptLine,
150
+ restoreRawMode,
151
+ truncate,
152
+ formatBytes,
153
+ inlineSelect,
154
+ scanCommonPorts: ports.scanCommonPorts,
155
+ findTunnelClients: tunnelClients.findTunnelClients,
156
+ createAndStartTunnel: (port: number) => tunnelClients.createAndStartTunnel(apiRequest, port),
157
+ stopTunnelClients: (clients, opts) => tunnelClients.stopTunnelClients(apiRequest, clients, opts),
158
+ colorDim,
159
+ colorRed,
160
+ });
161
+
162
+ shareMenu.subMenu = shareMenu.subMenu || [];
163
+ if (accountType !== "guest") {
164
+ const aliasesMenu = buildManageAliasesMenu({
165
+ apiRequest,
166
+ promptLine,
167
+ restoreRawMode,
168
+ inlineSelect,
169
+ findTunnelClients: tunnelClients.findTunnelClients,
170
+ truncate,
171
+ });
172
+ if (aliasesMenu.subMenu) {
173
+ shareMenu.subMenu.push({
174
+ label: "Aliases",
175
+ subMenu: aliasesMenu.subMenu,
176
+ });
177
+ }
178
+ }
179
+ if (isAdmin) {
180
+ shareMenu.subMenu.push({
181
+ label: "⚠️ Stop ALL Tunnel Clients (kill switch)",
182
+ action: async () => {
183
+ const clients = tunnelClients.findTunnelClients();
184
+ if (clients.length === 0) {
185
+ const ghost = await tunnelClients.stopTunnelClients(apiRequest, [], {
186
+ connectedGhosts: true,
187
+ });
188
+ if (ghost.deleted > 0) {
189
+ return `✓ Removed ${ghost.deleted} relay-connected tunnel${ghost.deleted !== 1 ? "s" : ""} with no local client`;
190
+ }
191
+ return "No running tunnel clients found.";
192
+ }
193
+ const { killed, deleted } = await tunnelClients.stopTunnelClients(apiRequest, clients);
194
+ return `✓ Stopped ${killed} local client${killed !== 1 ? "s" : ""}, removed ${deleted} tunnel record${deleted !== 1 ? "s" : ""}`;
195
+ },
196
+ });
197
+ }
198
+
199
+ mainMenu.push(shareMenu);
200
+
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
+ }
226
+
227
+ // Admin-only: Usage section
228
+ if (isAdmin) {
229
+ mainMenu.push(
230
+ buildUsageMenu({
231
+ apiRequest,
232
+ truncate,
233
+ })
234
+ );
235
+ }
236
+
237
+ if (isAdmin) {
238
+ mainMenu.push(
239
+ buildSystemStatusMenu({
240
+ apiBase,
241
+ apiRequest,
242
+ fetch: (url: string) => fetch(url) as any,
243
+ truncate,
244
+ formatBytes,
245
+ runSmoke: smoke.runSmoke,
246
+ })
247
+ );
248
+ }
249
+
250
+ // Admin-only: Manage Tokens
251
+ if (isAdmin) {
252
+ mainMenu.push(
253
+ buildManageTokensMenu({
254
+ apiRequest,
255
+ promptLine,
256
+ restoreRawMode,
257
+ truncate,
258
+ })
259
+ );
260
+ }
261
+
262
+ mainMenu.push(aboutItem);
263
+ mainMenu.push(exitItem);
264
+ }
265
+
266
+ await runInkMenu({ tree: mainMenu, getStatus: fetchMenuSnapshot });
267
+ }