uplink-cli 0.1.38 → 0.1.39

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 (54) hide show
  1. package/AGENTS.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +46 -47
  4. package/cli/src/index.ts +5 -3
  5. package/cli/src/registrars/cloudflare.ts +148 -0
  6. package/cli/src/registrars/godaddy.ts +99 -0
  7. package/cli/src/registrars/hostinger.ts +106 -0
  8. package/cli/src/registrars/http.ts +18 -0
  9. package/cli/src/registrars/index.ts +30 -0
  10. package/cli/src/registrars/namecheap.ts +163 -0
  11. package/cli/src/registrars/secret.ts +66 -0
  12. package/cli/src/registrars/store.ts +55 -0
  13. package/cli/src/registrars/types.ts +40 -0
  14. package/cli/src/subcommands/admin.ts +17 -30
  15. package/cli/src/subcommands/db.ts +63 -57
  16. package/cli/src/subcommands/dev.ts +23 -25
  17. package/cli/src/subcommands/domains.ts +268 -0
  18. package/cli/src/subcommands/host-domains.ts +148 -0
  19. package/cli/src/subcommands/host.ts +3 -0
  20. package/cli/src/subcommands/menu/colors.ts +1 -1
  21. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  22. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  23. package/cli/src/subcommands/menu/io.ts +27 -5
  24. package/cli/src/subcommands/menu/menus/domains.ts +199 -0
  25. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  26. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  27. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  28. package/cli/src/subcommands/menu/render.ts +2 -2
  29. package/cli/src/subcommands/menu/tests.ts +1 -1
  30. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  31. package/cli/src/subcommands/menu/types.ts +8 -0
  32. package/cli/src/subcommands/menu.ts +32 -524
  33. package/cli/src/subcommands/system.ts +58 -36
  34. package/cli/src/subcommands/tunnel.ts +124 -33
  35. package/cli/src/templates/index.ts +3 -3
  36. package/cli/src/tui/App.tsx +197 -0
  37. package/cli/src/tui/AppInspector.tsx +114 -0
  38. package/cli/src/tui/HomeStatus.tsx +59 -0
  39. package/cli/src/tui/brand.tsx +20 -0
  40. package/cli/src/tui/format.ts +22 -0
  41. package/cli/src/tui/index.mts +6 -0
  42. package/cli/src/tui/liveTree.ts +40 -0
  43. package/cli/src/tui/package.json +3 -0
  44. package/cli/src/tui/runMenu.tsx +57 -0
  45. package/cli/src/tui/session.mts +382 -0
  46. package/cli/src/tui/snapshot.ts +146 -0
  47. package/cli/src/utils/launchDomainking.ts +64 -0
  48. package/docs/AGENTS.md +113 -147
  49. package/docs/MENU_STRUCTURE.md +56 -288
  50. package/docs/README.md +6 -6
  51. package/package.json +18 -35
  52. package/scripts/tunnel/client-improved.js +127 -38
  53. package/scripts/tunnel/client.js +118 -0
  54. package/assets/cli-screenshot.png +0 -0
@@ -67,5 +67,5 @@ export function colorSoftGray(text: string) {
67
67
  }
68
68
 
69
69
  export function colorAccent(text: string) {
70
- return `${c.brightBlue}${text}${c.reset}`;
70
+ return `${c.bold}${c.brightWhite}${text}${c.reset}`;
71
71
  }
@@ -1,8 +1,44 @@
1
1
  import { execSync, spawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import path from "path";
2
4
  import { resolveProjectRoot } from "../../../utils/project-root";
3
5
 
4
6
  export type TunnelClient = { pid: number; port: number; token: string };
5
7
 
8
+ export function resolveTunnelClientPath(): string {
9
+ const projectRoot = resolveProjectRoot(__dirname);
10
+ const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
11
+ if (!existsSync(clientPath)) {
12
+ throw new Error(`Tunnel client not found at ${clientPath}`);
13
+ }
14
+ return clientPath;
15
+ }
16
+
17
+ /** Start the local tunnel client in the background (detached). */
18
+ export function startTunnelClient(opts: {
19
+ token: string;
20
+ port: number;
21
+ ctrl?: string;
22
+ }): { pid: number; clientPath: string } {
23
+ const projectRoot = resolveProjectRoot(__dirname);
24
+ const clientPath = resolveTunnelClientPath();
25
+ const ctrl = opts.ctrl || process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
26
+ const clientProcess = spawn(
27
+ "node",
28
+ [clientPath, "--token", opts.token, "--port", String(opts.port), "--ctrl", ctrl],
29
+ {
30
+ stdio: "ignore",
31
+ detached: true,
32
+ cwd: projectRoot,
33
+ }
34
+ );
35
+ clientProcess.unref();
36
+ if (!clientProcess.pid) {
37
+ throw new Error("Failed to start tunnel client process");
38
+ }
39
+ return { pid: clientProcess.pid, clientPath };
40
+ }
41
+
6
42
  export function findTunnelClients(): TunnelClient[] {
7
43
  try {
8
44
  // Find processes running client-improved.js (current user, match script path to avoid false positives)
@@ -39,11 +75,23 @@ export function findTunnelClients(): TunnelClient[] {
39
75
 
40
76
  export function killTunnelClient(pid: number): boolean {
41
77
  try {
42
- execSync(`kill -TERM ${pid}`, { stdio: "ignore" });
43
- return true;
78
+ process.kill(pid, "SIGTERM");
44
79
  } catch {
45
80
  return false;
46
81
  }
82
+ try {
83
+ execSync(`kill -0 ${pid} && sleep 0.4 && kill -KILL ${pid} || true`, {
84
+ stdio: "ignore",
85
+ });
86
+ } catch {
87
+ /* process already gone */
88
+ }
89
+ try {
90
+ process.kill(pid, 0);
91
+ return false;
92
+ } catch {
93
+ return true;
94
+ }
47
95
  }
48
96
 
49
97
  export function killAllTunnelClients(clients: TunnelClient[]): number {
@@ -54,8 +102,44 @@ export function killAllTunnelClients(clients: TunnelClient[]): number {
54
102
  return killed;
55
103
  }
56
104
 
105
+ type ApiTunnel = { id?: string; token?: string; connected?: boolean };
106
+
57
107
  type ApiRequest = (method: string, path: string, body?: unknown) => Promise<any>;
58
108
 
109
+ export async function stopTunnelClients(
110
+ apiRequest: ApiRequest,
111
+ clients: TunnelClient[],
112
+ opts: { connectedGhosts?: boolean } = {}
113
+ ): Promise<{ killed: number; deleted: number }> {
114
+ const tokens = new Set(clients.map((c) => c.token));
115
+ let deleted = 0;
116
+
117
+ try {
118
+ const result = await apiRequest("GET", "/v1/tunnels");
119
+ const tunnels = (result.tunnels || []) as ApiTunnel[];
120
+ for (const tunnel of tunnels) {
121
+ if (!tunnel.id) continue;
122
+ const matched = Boolean(tunnel.token && tokens.has(tunnel.token));
123
+ const ghost = Boolean(opts.connectedGhosts && tunnel.connected);
124
+ if (!matched && !ghost) continue;
125
+ try {
126
+ await apiRequest("DELETE", `/v1/tunnels/${tunnel.id}`);
127
+ deleted++;
128
+ } catch {
129
+ /* keep stopping the rest */
130
+ }
131
+ }
132
+ } catch {
133
+ /* still kill local processes */
134
+ }
135
+
136
+ let killed = 0;
137
+ for (const c of clients) {
138
+ if (killTunnelClient(c.pid)) killed++;
139
+ }
140
+ return { killed, deleted };
141
+ }
142
+
59
143
  /**
60
144
  * Create a tunnel via API and start the local client in background.
61
145
  * NOTE: Maintains existing behavior including the brief post-spawn delay.
@@ -79,19 +163,8 @@ export async function createAndStartTunnel(apiRequest: ApiRequest, port: number)
79
163
  const url = result.url || "(no url)";
80
164
  const token = result.token || "(no token)";
81
165
  const alias = result.alias || null;
82
- const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
83
166
 
84
- // Start tunnel client in background
85
- // (CommonJS build: __dirname available)
86
- const path = require("path");
87
- const projectRoot = resolveProjectRoot(__dirname);
88
- const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
89
- const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
90
- stdio: "ignore",
91
- detached: true,
92
- cwd: projectRoot,
93
- });
94
- clientProcess.unref();
167
+ startTunnelClient({ token, port });
95
168
 
96
169
  // Wait a moment for client to connect
97
170
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -1,4 +1,4 @@
1
- import { colorCyan, colorDim } from "./colors";
1
+ import { colorBold, colorDim } from "./colors";
2
2
 
3
3
  // Inline arrow-key selector (returns selected option, or null for "Back")
4
4
  export type SelectOption = { label: string; value: string | number | null };
@@ -32,11 +32,11 @@ export async function inlineSelect(
32
32
  let branchColor: string;
33
33
 
34
34
  if (isSelected) {
35
- branchColor = colorCyan(branch);
35
+ branchColor = colorBold(branch);
36
36
  if (opt.label === "Back") {
37
37
  label = colorDim(opt.label);
38
38
  } else {
39
- label = colorCyan(opt.label);
39
+ label = colorBold(opt.label);
40
40
  }
41
41
  } else {
42
42
  branchColor = colorDim(branch);
@@ -58,13 +58,14 @@ export async function inlineSelect(
58
58
  allOptions.forEach((opt, idx) => {
59
59
  const isLast = idx === allOptions.length - 1;
60
60
  const branch = isLast ? "└─" : "├─";
61
- const branchColor = idx === 0 ? colorCyan(branch) : colorDim(branch);
62
- const label = idx === 0 ? colorCyan(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
61
+ const branchColor = idx === 0 ? colorBold(branch) : colorDim(branch);
62
+ const label = idx === 0 ? colorBold(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
63
63
  console.log(`${branchColor} ${label}`);
64
64
  });
65
65
 
66
66
  // Set up key handler
67
67
  try {
68
+ process.stdin.ref();
68
69
  process.stdin.setRawMode(true);
69
70
  process.stdin.resume();
70
71
  } catch {
@@ -10,13 +10,35 @@ function stylePrompt(question: string): string {
10
10
  .replace(backTokenRegex, (match) => colorBold(match));
11
11
  }
12
12
 
13
+ export function prepareStdinForPrompt(): void {
14
+ try {
15
+ process.stdin.setRawMode(false);
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ process.stdin.ref();
20
+ process.stdin.resume();
21
+ process.stdin.setEncoding("utf8");
22
+ drainStdin();
23
+ }
24
+
25
+ /** Drop a leftover Enter from Ink so readline does not auto-answer the next prompt. */
26
+ function drainStdin(): void {
27
+ const stdin = process.stdin as NodeJS.ReadStream & { read?: () => unknown };
28
+ if (typeof stdin.read !== "function") return;
29
+ try {
30
+ let chunk: unknown;
31
+ while ((chunk = stdin.read()) !== null) {
32
+ void chunk;
33
+ }
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
13
39
  export function promptLine(question: string): Promise<string> {
14
40
  return new Promise((resolve) => {
15
- try {
16
- process.stdin.setRawMode(false);
17
- } catch {
18
- /* ignore */
19
- }
41
+ prepareStdinForPrompt();
20
42
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21
43
  rl.question(stylePrompt(question), (answer) => {
22
44
  rl.close();
@@ -0,0 +1,199 @@
1
+ import type { SelectOption } from "../inline-tree-select";
2
+ import type { MenuChoice } from "../types";
3
+ import { launchDomainking } from "../../../utils/launchDomainking";
4
+ import { parseHostedApps, runCli, runCliCapture } from "./hosting";
5
+
6
+ type Deps = {
7
+ promptLine: (question: string) => Promise<string>;
8
+ restoreRawMode: () => void;
9
+ inlineSelect: (
10
+ title: string,
11
+ options: SelectOption[],
12
+ includeBack?: boolean
13
+ ) => Promise<{ index: number; value: string | number | null } | null>;
14
+ };
15
+
16
+ const PROVIDER_OPTIONS: SelectOption[] = [
17
+ { label: "GoDaddy", value: "godaddy" },
18
+ { label: "Cloudflare", value: "cloudflare" },
19
+ { label: "Hostinger", value: "hostinger" },
20
+ { label: "Namecheap", value: "namecheap" },
21
+ ];
22
+
23
+ async function pickHostedApp(
24
+ deps: Deps,
25
+ title: string
26
+ ): Promise<{ name: string; id: string; url?: string } | null> {
27
+ const output = runCliCapture(["host", "list"]);
28
+ if (!output || output.includes("No apps found")) {
29
+ deps.restoreRawMode();
30
+ return null;
31
+ }
32
+ const apps = parseHostedApps(output);
33
+ if (apps.length === 0) {
34
+ deps.restoreRawMode();
35
+ return null;
36
+ }
37
+ const options: SelectOption[] = apps.map((app) => ({
38
+ label: `${app.name}${app.url ? ` ${app.url}` : ""}`,
39
+ value: app.id,
40
+ }));
41
+ const choice = await deps.inlineSelect(title, options, true);
42
+ if (choice === null) {
43
+ deps.restoreRawMode();
44
+ return null;
45
+ }
46
+ return apps.find((app) => app.id === choice.value) ?? null;
47
+ }
48
+
49
+ export function buildDomainsMenu(deps: Deps): MenuChoice {
50
+ const { restoreRawMode, promptLine } = deps;
51
+
52
+ return {
53
+ label: "Domains",
54
+ subMenu: [
55
+ {
56
+ label: "My domains",
57
+ action: async () => {
58
+ try {
59
+ const output = runCliCapture(["domains", "list"]);
60
+ restoreRawMode();
61
+ return output || "No domains. Connect a registrar first.";
62
+ } catch (error) {
63
+ restoreRawMode();
64
+ return error instanceof Error ? error.message : String(error);
65
+ }
66
+ },
67
+ },
68
+ {
69
+ label: "Connect registrar",
70
+ action: async () => {
71
+ const choice = await deps.inlineSelect("Which registrar?", PROVIDER_OPTIONS, true);
72
+ if (choice === null || typeof choice.value !== "string") {
73
+ restoreRawMode();
74
+ return "";
75
+ }
76
+ const provider = choice.value;
77
+ const extraEnv: Record<string, string> = {};
78
+ const args = ["domains", "providers", "connect", provider, "--token-env", "UPLINK_CONNECT_TOKEN"];
79
+ if (provider === "namecheap") {
80
+ const user = (await promptLine("Namecheap API user (or back): ")).trim();
81
+ if (!user || user === "back") {
82
+ restoreRawMode();
83
+ return "";
84
+ }
85
+ const key = (await promptLine("Namecheap API key (or back): ")).trim();
86
+ if (!key || key === "back") {
87
+ restoreRawMode();
88
+ return "";
89
+ }
90
+ extraEnv.UPLINK_CONNECT_TOKEN = key;
91
+ extraEnv.UPLINK_CONNECT_USER = user;
92
+ args.push("--user-env", "UPLINK_CONNECT_USER");
93
+ } else {
94
+ const token = (
95
+ await promptLine(
96
+ `${PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider} API token (or back): `
97
+ )
98
+ ).trim();
99
+ if (!token || token === "back") {
100
+ restoreRawMode();
101
+ return "";
102
+ }
103
+ extraEnv.UPLINK_CONNECT_TOKEN = token;
104
+ }
105
+ try {
106
+ runCli(args, extraEnv);
107
+ restoreRawMode();
108
+ return `Connected ${provider}.`;
109
+ } catch (error) {
110
+ restoreRawMode();
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+ },
114
+ },
115
+ {
116
+ label: "Find a domain",
117
+ action: async () => {
118
+ restoreRawMode();
119
+ return launchDomainking();
120
+ },
121
+ },
122
+ {
123
+ label: "Attach to app",
124
+ action: async () => {
125
+ const app = await pickHostedApp(deps, "Attach domain to which app?");
126
+ if (!app) return "No hosted apps. Deploy one under Host first.";
127
+ const hostname = (await promptLine("Hostname (e.g. example.com, or back): ")).trim().toLowerCase();
128
+ if (!hostname || hostname === "back") {
129
+ restoreRawMode();
130
+ return "";
131
+ }
132
+ runCli(["host", "domains", "add", "--id", app.id, "--hostname", hostname]);
133
+ restoreRawMode();
134
+ return `Attached ${hostname} to ${app.name}. Point DNS, then Verify.`;
135
+ },
136
+ },
137
+ {
138
+ label: "Verify DNS",
139
+ action: async () => {
140
+ const app = await pickHostedApp(deps, "Verify a domain on which app?");
141
+ if (!app) return "No hosted apps.";
142
+ const hostname = (await promptLine("Hostname to verify (or back): ")).trim().toLowerCase();
143
+ if (!hostname || hostname === "back") {
144
+ restoreRawMode();
145
+ return "";
146
+ }
147
+ runCli(["host", "domains", "verify", "--id", app.id, "--hostname", hostname]);
148
+ restoreRawMode();
149
+ return `Checked ${hostname} on ${app.name}.`;
150
+ },
151
+ },
152
+ {
153
+ label: "List on app",
154
+ action: async () => {
155
+ const app = await pickHostedApp(deps, "List domains for which app?");
156
+ if (!app) return "No hosted apps.";
157
+ const output = runCliCapture(["host", "domains", "list", "--id", app.id]);
158
+ restoreRawMode();
159
+ return `${app.name}\n${output || "No custom domains attached."}`;
160
+ },
161
+ },
162
+ {
163
+ label: "Detach from app",
164
+ action: async () => {
165
+ const app = await pickHostedApp(deps, "Detach a domain from which app?");
166
+ if (!app) return "No hosted apps.";
167
+ const hostname = (await promptLine("Hostname to detach (or back): ")).trim().toLowerCase();
168
+ if (!hostname || hostname === "back") {
169
+ restoreRawMode();
170
+ return "";
171
+ }
172
+ runCli(["host", "domains", "remove", "--id", app.id, "--hostname", hostname]);
173
+ restoreRawMode();
174
+ return `Detached ${hostname} from ${app.name}.`;
175
+ },
176
+ },
177
+ {
178
+ label: "Help",
179
+ action: async () => {
180
+ return [
181
+ "Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
182
+ "",
183
+ " My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap",
184
+ " Connect — save a registrar token (same as the CLI)",
185
+ " Find — search names that are not yours yet",
186
+ " Attach — bind a hostname to a hosted app",
187
+ " Verify — check DNS points at the hosting edge, then TLS",
188
+ "",
189
+ "CLI (agents):",
190
+ " uplink domains providers connect godaddy --token-env GODADDY_PAT --json",
191
+ " uplink domains list --json",
192
+ " uplink domains check example.com --json",
193
+ " uplink host domains add --id <app> --hostname example.com --json",
194
+ ].join("\n");
195
+ },
196
+ },
197
+ ],
198
+ };
199
+ }
@@ -14,9 +14,13 @@ type Deps = {
14
14
  ) => Promise<{ index: number; value: string | number | null } | null>;
15
15
  };
16
16
 
17
- type HostedApp = { name: string; id: string; url?: string };
17
+ export type HostedApp = { name: string; id: string; url?: string };
18
18
 
19
- function parseHostedApps(output: string): HostedApp[] {
19
+ function appOptionLabel(app: HostedApp): string {
20
+ return app.url ? `${app.name} ${app.url}` : app.name;
21
+ }
22
+
23
+ export function parseHostedApps(output: string): HostedApp[] {
20
24
  const lines = output.split("\n");
21
25
  const apps: HostedApp[] = [];
22
26
  for (let i = 0; i < lines.length; i += 1) {
@@ -47,7 +51,7 @@ async function resolvePath(promptLine: Deps["promptLine"]): Promise<string | nul
47
51
  }
48
52
  }
49
53
 
50
- function runCli(args: string[]): void {
54
+ export function runCli(args: string[], extraEnv?: Record<string, string>): void {
51
55
  try {
52
56
  process.stdin.setRawMode(false);
53
57
  } catch {
@@ -57,6 +61,7 @@ function runCli(args: string[]): void {
57
61
  const cmd = cliBin ? [cliBin, ...args] : [process.argv[1], ...args];
58
62
  const result = spawnSync(process.execPath, cmd, {
59
63
  stdio: "inherit",
64
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
60
65
  });
61
66
  if (result.error) throw result.error;
62
67
  if (result.status && result.status !== 0) {
@@ -64,7 +69,7 @@ function runCli(args: string[]): void {
64
69
  }
65
70
  }
66
71
 
67
- function runCliCapture(args: string[]): string {
72
+ export function runCliCapture(args: string[], extraEnv?: Record<string, string>): string {
68
73
  try {
69
74
  process.stdin.setRawMode(false);
70
75
  } catch {
@@ -75,6 +80,7 @@ function runCliCapture(args: string[]): string {
75
80
  const result = spawnSync(process.execPath, cmd, {
76
81
  stdio: ["ignore", "pipe", "pipe"],
77
82
  encoding: "utf8",
83
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
78
84
  });
79
85
  if (result.error) throw result.error;
80
86
  if (result.status && result.status !== 0) {
@@ -120,7 +126,7 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
120
126
  return "No apps found. Use Setup Wizard to create one first.";
121
127
  }
122
128
  const options: SelectOption[] = apps.map((app) => ({
123
- label: `${app.name} (${app.id})`,
129
+ label: appOptionLabel(app),
124
130
  value: app.id,
125
131
  }));
126
132
  const choice = await inlineSelect("Select app to deploy to", options, true);
@@ -160,45 +166,6 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
160
166
  return "Analysis complete.";
161
167
  },
162
168
  },
163
- {
164
- label: "List Hosted Apps",
165
- action: async () => {
166
- const output = runCliCapture(["host", "list"]);
167
- if (!output || output.includes("No apps found")) {
168
- restoreRawMode();
169
- return "No apps found.";
170
- }
171
- const apps = parseHostedApps(output);
172
- if (apps.length === 0) {
173
- restoreRawMode();
174
- return "No apps found.";
175
- }
176
- const options: SelectOption[] = apps.map((app) => ({
177
- label: `${app.name} (${app.id})${app.url ? ` ${app.url}` : ""}`,
178
- value: app.id,
179
- }));
180
- const choice = await inlineSelect("Hosted apps", options, true);
181
- if (choice === null) {
182
- restoreRawMode();
183
- return "";
184
- }
185
- const selected = apps.find((app) => app.id === choice.value);
186
- if (!selected) {
187
- restoreRawMode();
188
- return "Invalid selection.";
189
- }
190
- restoreRawMode();
191
- return [
192
- `App: ${selected.name}`,
193
- `ID: ${selected.id}`,
194
- selected.url ? `URL: ${selected.url}` : "",
195
- "",
196
- "Commands:",
197
- ` uplink host status --id ${selected.id}`,
198
- ` uplink host logs --id ${selected.id}`,
199
- ].join("\n");
200
- },
201
- },
202
169
  {
203
170
  label: "Delete Hosted App",
204
171
  action: async () => {
@@ -213,7 +180,7 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
213
180
  return "No apps found.";
214
181
  }
215
182
  const options: SelectOption[] = apps.map((app) => ({
216
- label: `${app.name} (${app.id})`,
183
+ label: appOptionLabel(app),
217
184
  value: app.id,
218
185
  }));
219
186
  const choice = await inlineSelect("Select app to delete", options, true);
@@ -251,8 +218,9 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
251
218
  " Setup Wizard - First-time setup: creates Dockerfile, config, app, and deploys",
252
219
  " Deploy - Redeploy to an existing app (faster, skips setup)",
253
220
  " Analyze - Check project for deployment readiness",
254
- " List Apps - Show your deployed apps",
221
+ " Apps - Arrow through apps to inspect url, status, and size",
255
222
  " Delete App - Remove an app and optionally its data",
223
+ " Custom domains - also under the top-level Domains menu",
256
224
  "",
257
225
  "CLI commands:",
258
226
  " uplink host setup --name <app> --path <path> # Full setup + deploy",
@@ -4,4 +4,5 @@ export { buildManageTokensMenu } from "./tokens";
4
4
  export { buildManageTunnelsMenu } from "./tunnels";
5
5
  export { buildManageAliasesMenu } from "./aliases";
6
6
  export { buildHostingMenu } from "./hosting";
7
+ export { buildDomainsMenu } from "./domains";
7
8