uplink-cli 0.1.37 → 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 (56) hide show
  1. package/AGENTS.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +45 -44
  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 +106 -32
  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 +122 -0
  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/analyze.ts +27 -43
  48. package/cli/src/utils/framework-output.ts +171 -0
  49. package/cli/src/utils/launchDomainking.ts +64 -0
  50. package/docs/AGENTS.md +113 -146
  51. package/docs/MENU_STRUCTURE.md +56 -288
  52. package/docs/README.md +6 -6
  53. package/package.json +18 -35
  54. package/scripts/tunnel/client-improved.js +127 -38
  55. package/scripts/tunnel/client.js +118 -0
  56. package/assets/cli-screenshot.png +0 -0
@@ -1,6 +1,11 @@
1
1
  import { Command } from "commander";
2
2
  import { apiRequest } from "../http";
3
3
  import { handleError, printJson } from "../utils/machine";
4
+ import {
5
+ findTunnelClients,
6
+ killTunnelClient,
7
+ startTunnelClient,
8
+ } from "./menu/effects/tunnel-clients";
4
9
 
5
10
  type TunnelResponse = {
6
11
  id: string;
@@ -9,10 +14,13 @@ type TunnelResponse = {
9
14
  port?: number;
10
15
  token?: string;
11
16
  alias?: string | null;
17
+ aliasUrl?: string | null;
12
18
  status?: string;
19
+ connected?: boolean;
13
20
  createdAt?: string;
14
21
  updatedAt?: string;
15
22
  ingressHttpUrl?: string;
23
+ targetPort?: number;
16
24
  };
17
25
 
18
26
  type TunnelListResponse = {
@@ -22,16 +30,18 @@ type TunnelListResponse = {
22
30
 
23
31
  type TunnelStatsResponse = any;
24
32
 
25
- export const tunnelCommand = new Command("tunnel")
26
- .description("Manage tunnels non-interactively (agent-friendly)");
33
+ export const tunnelCommand = new Command("tunnel").description(
34
+ "Manage tunnels non-interactively (agent-friendly)"
35
+ );
27
36
 
28
- // Create tunnel
37
+ // Create tunnel + start local client (unless --no-client)
29
38
  tunnelCommand
30
39
  .command("create")
31
- .description("Create a tunnel")
40
+ .description("Create a tunnel and start the local client")
32
41
  .requiredOption("--port <port>", "Local port to expose")
33
42
  .option("--alias <alias>", "Optional permanent alias (if enabled on account)")
34
43
  .option("--project <project>", "Optional project id")
44
+ .option("--api-only", "Create API record only; do not start the local client", false)
35
45
  .option("--json", "Output JSON", false)
36
46
  .action(async (opts) => {
37
47
  const port = Number(opts.port);
@@ -41,42 +51,75 @@ tunnelCommand
41
51
  }
42
52
 
43
53
  try {
54
+ const existing = findTunnelClients().filter((c) => c.port === port);
55
+ if (existing.length > 0 && !opts.apiOnly) {
56
+ const err = `Tunnel client already running on port ${port} (pid ${existing[0].pid})`;
57
+ if (opts.json) {
58
+ printJson({ error: err, existing: existing[0] });
59
+ } else {
60
+ console.error(err);
61
+ }
62
+ process.exit(2);
63
+ }
64
+
44
65
  const body: Record<string, unknown> = { port };
45
66
  if (opts.project) body.project = opts.project;
46
67
 
47
- const tunnel = await apiRequest("POST", "/v1/tunnels", body) as TunnelResponse;
68
+ const tunnel = (await apiRequest("POST", "/v1/tunnels", body)) as TunnelResponse;
48
69
  let aliasResult: TunnelResponse | null = null;
49
70
  let aliasError: string | null = null;
50
71
 
51
72
  if (opts.alias) {
52
73
  try {
53
- aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
74
+ aliasResult = (await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
54
75
  alias: opts.alias,
55
- }) as TunnelResponse;
76
+ })) as TunnelResponse;
56
77
  } catch (err: any) {
57
78
  aliasError = err?.message || String(err);
58
79
  }
59
80
  }
60
81
 
82
+ let client: { pid: number; started: boolean } | null = null;
83
+ if (!opts.apiOnly) {
84
+ const token = tunnel.token;
85
+ if (!token) {
86
+ throw new Error("Tunnel created but API returned no token; cannot start client");
87
+ }
88
+ const started = startTunnelClient({ token, port });
89
+ // Brief wait so list/connected is more likely accurate for agents.
90
+ await new Promise((resolve) => setTimeout(resolve, 1500));
91
+ client = { pid: started.pid, started: true };
92
+ }
93
+
94
+ const url =
95
+ aliasResult?.aliasUrl ||
96
+ aliasResult?.url ||
97
+ tunnel.url ||
98
+ tunnel.ingressHttpUrl ||
99
+ null;
100
+ const alias = aliasResult?.alias ?? tunnel.alias ?? null;
101
+
61
102
  if (opts.json) {
62
103
  printJson({
63
- tunnel,
64
- alias: aliasResult?.alias ?? null,
104
+ tunnel: {
105
+ ...tunnel,
106
+ alias,
107
+ aliasUrl: aliasResult?.aliasUrl ?? tunnel.aliasUrl ?? null,
108
+ url: tunnel.url ?? tunnel.ingressHttpUrl,
109
+ },
110
+ alias,
65
111
  aliasError,
112
+ url,
113
+ client,
66
114
  });
67
115
  } else {
68
116
  console.log(`Created tunnel ${tunnel.id}`);
69
- console.log(` url: ${tunnel.url ?? tunnel.ingressHttpUrl ?? "-"}`);
70
- console.log(` token: ${tunnel.token ?? "-"}`);
71
- if (opts.alias) {
72
- if (aliasResult?.alias) {
73
- console.log(` alias: ${aliasResult.alias}`);
74
- } else if (aliasError) {
75
- console.log(` alias: failed - ${aliasError}`);
76
- }
77
- } else if (tunnel.alias) {
78
- console.log(` alias: ${tunnel.alias}`);
79
- }
117
+ console.log(` url: ${url ?? "-"}`);
118
+ console.log(` token: ${tunnel.token ?? "-"}`);
119
+ if (alias) console.log(` alias: ${alias}`);
120
+ else if (aliasError) console.log(` alias: failed - ${aliasError}`);
121
+ if (client) console.log(` client: started (pid ${client.pid})`);
122
+ else console.log(` client: not started (--api-only)`);
80
123
  }
81
124
  } catch (error) {
82
125
  handleError(error, { json: opts.json });
@@ -90,7 +133,7 @@ tunnelCommand
90
133
  .option("--json", "Output JSON", false)
91
134
  .action(async (opts) => {
92
135
  try {
93
- const result = await apiRequest("GET", "/v1/tunnels") as TunnelListResponse;
136
+ const result = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
94
137
  if (opts.json) {
95
138
  printJson(result);
96
139
  } else {
@@ -100,8 +143,10 @@ tunnelCommand
100
143
  }
101
144
  console.log(`Tunnels (${result.count}):`);
102
145
  for (const t of result.tunnels) {
146
+ const connected = t.connected ? "connected" : "idle";
147
+ const token = t.token ? `${String(t.token).slice(0, 8)}…` : "-";
103
148
  console.log(
104
- `${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${t.token ?? "-"} alias=${t.alias ?? "-"} status=${t.status ?? "-"}`
149
+ `${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${token} alias=${t.alias ?? "-"} status=${t.status ?? "-"} ${connected}`
105
150
  );
106
151
  }
107
152
  }
@@ -119,9 +164,9 @@ tunnelCommand
119
164
  .option("--json", "Output JSON", false)
120
165
  .action(async (opts) => {
121
166
  try {
122
- const result = await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
167
+ const result = (await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
123
168
  alias: opts.alias,
124
- }) as TunnelResponse;
169
+ })) as TunnelResponse;
125
170
  if (opts.json) {
126
171
  printJson(result);
127
172
  } else {
@@ -140,7 +185,10 @@ tunnelCommand
140
185
  .option("--json", "Output JSON", false)
141
186
  .action(async (opts) => {
142
187
  try {
143
- const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}/alias`) as TunnelResponse;
188
+ const result = (await apiRequest(
189
+ "DELETE",
190
+ `/v1/tunnels/${opts.id}/alias`
191
+ )) as TunnelResponse;
144
192
  if (opts.json) {
145
193
  printJson(result);
146
194
  } else {
@@ -159,7 +207,10 @@ tunnelCommand
159
207
  .option("--json", "Output JSON", false)
160
208
  .action(async (opts) => {
161
209
  try {
162
- const result = await apiRequest("GET", `/v1/tunnels/${opts.id}/stats`) as TunnelStatsResponse;
210
+ const result = (await apiRequest(
211
+ "GET",
212
+ `/v1/tunnels/${opts.id}/stats`
213
+ )) as TunnelStatsResponse;
163
214
  if (opts.json) {
164
215
  printJson(result);
165
216
  } else {
@@ -171,22 +222,62 @@ tunnelCommand
171
222
  }
172
223
  });
173
224
 
174
- // Stop (delete) tunnel
225
+ // Stop (delete) tunnel and kill any matching local client
175
226
  tunnelCommand
176
227
  .command("stop")
177
- .description("Stop (delete) a tunnel")
178
- .requiredOption("--id <id>", "Tunnel id")
228
+ .description("Stop a tunnel: kill the local client and delete the record")
229
+ .option("--id <id>", "Tunnel id")
230
+ .option("--all", "Stop every tunnel for this account", false)
179
231
  .option("--json", "Output JSON", false)
180
232
  .action(async (opts) => {
233
+ if (!opts.all && !opts.id) {
234
+ console.error("Provide --id or --all");
235
+ process.exit(2);
236
+ }
237
+
181
238
  try {
182
- const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}`) as { id: string; status: string };
239
+ if (opts.all) {
240
+ const listed = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
241
+ let killed = 0;
242
+ for (const client of findTunnelClients()) {
243
+ if (killTunnelClient(client.pid)) killed++;
244
+ }
245
+ let deleted = 0;
246
+ for (const t of listed.tunnels || []) {
247
+ if (!t.id) continue;
248
+ try {
249
+ await apiRequest("DELETE", `/v1/tunnels/${t.id}`);
250
+ deleted++;
251
+ } catch {
252
+ /* already gone */
253
+ }
254
+ }
255
+ if (opts.json) {
256
+ printJson({ ok: true, killed, deleted });
257
+ } else {
258
+ console.log(`Stopped ${killed} local client(s), removed ${deleted} tunnel record(s)`);
259
+ }
260
+ return;
261
+ }
262
+
263
+ const listed = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
264
+ const target = (listed.tunnels || []).find((t) => t.id === opts.id);
265
+ if (target?.token) {
266
+ for (const client of findTunnelClients().filter((c) => c.token === target.token)) {
267
+ killTunnelClient(client.pid);
268
+ }
269
+ }
270
+
271
+ const result = (await apiRequest("DELETE", `/v1/tunnels/${opts.id}`)) as {
272
+ id: string;
273
+ status: string;
274
+ };
183
275
  if (opts.json) {
184
276
  printJson(result);
185
277
  } else {
186
278
  console.log(`Stopped tunnel ${result.id} (status=${result.status})`);
187
279
  }
188
- } catch (error: any) {
189
- console.error(error?.message || String(error));
190
- process.exit(30);
280
+ } catch (error) {
281
+ handleError(error, { json: opts.json });
191
282
  }
192
283
  });
@@ -116,6 +116,109 @@ CMD ["node", "server.js"]
116
116
  `;
117
117
  }
118
118
 
119
+ // Static site Dockerfile (Vite, CRA)
120
+ function staticDockerfile(
121
+ pm: "npm" | "yarn" | "pnpm" | "bun" | null,
122
+ port: number,
123
+ baseImage: string,
124
+ distDir: string
125
+ ): string {
126
+ const packageManager = pm || "npm";
127
+
128
+ if (packageManager === "pnpm") {
129
+ return `FROM ${baseImage} AS base
130
+
131
+ # Install pnpm
132
+ RUN corepack enable && corepack prepare pnpm@latest --activate
133
+
134
+ # Dependencies stage
135
+ FROM base AS deps
136
+ WORKDIR /app
137
+ COPY package.json pnpm-lock.yaml ./
138
+ RUN pnpm install --frozen-lockfile
139
+
140
+ # Build stage
141
+ FROM base AS builder
142
+ WORKDIR /app
143
+ COPY --from=deps /app/node_modules ./node_modules
144
+ COPY . .
145
+ RUN pnpm build
146
+
147
+ # Production stage
148
+ FROM base AS runner
149
+ WORKDIR /app
150
+ ENV NODE_ENV=production
151
+ ENV PORT=${port}
152
+
153
+ RUN npm install -g serve
154
+ COPY --from=builder /app/${distDir} ./public
155
+
156
+ EXPOSE ${port}
157
+ CMD ["sh", "-c", "serve -s public -l \${PORT}"]
158
+ `;
159
+ }
160
+
161
+ if (packageManager === "yarn") {
162
+ return `FROM ${baseImage} AS base
163
+
164
+ # Dependencies stage
165
+ FROM base AS deps
166
+ WORKDIR /app
167
+ COPY package.json yarn.lock ./
168
+ RUN yarn install --frozen-lockfile
169
+
170
+ # Build stage
171
+ FROM base AS builder
172
+ WORKDIR /app
173
+ COPY --from=deps /app/node_modules ./node_modules
174
+ COPY . .
175
+ RUN yarn build
176
+
177
+ # Production stage
178
+ FROM base AS runner
179
+ WORKDIR /app
180
+ ENV NODE_ENV=production
181
+ ENV PORT=${port}
182
+
183
+ RUN npm install -g serve
184
+ COPY --from=builder /app/${distDir} ./public
185
+
186
+ EXPOSE ${port}
187
+ CMD ["sh", "-c", "serve -s public -l \${PORT}"]
188
+ `;
189
+ }
190
+
191
+ // npm (default)
192
+ return `FROM ${baseImage} AS base
193
+
194
+ # Dependencies stage
195
+ FROM base AS deps
196
+ WORKDIR /app
197
+ COPY package.json package-lock.json* ./
198
+ ENV npm_config_optional=true
199
+ RUN npm ci --include=optional
200
+
201
+ # Build stage
202
+ FROM base AS builder
203
+ WORKDIR /app
204
+ COPY --from=deps /app/node_modules ./node_modules
205
+ COPY . .
206
+ RUN npm run build
207
+
208
+ # Production stage
209
+ FROM base AS runner
210
+ WORKDIR /app
211
+ ENV NODE_ENV=production
212
+ ENV PORT=${port}
213
+
214
+ RUN npm install -g serve
215
+ COPY --from=builder /app/${distDir} ./public
216
+
217
+ EXPOSE ${port}
218
+ CMD ["sh", "-c", "serve -s public -l \${PORT}"]
219
+ `;
220
+ }
221
+
119
222
  // Express/Node.js Dockerfile
120
223
  function expressDockerfile(
121
224
  pm: "npm" | "yarn" | "pnpm" | "bun" | null,
@@ -311,11 +414,30 @@ export function generateDockerfile(analysis: AnalysisResult): DockerfileTemplate
311
414
 
312
415
  switch (framework.name) {
313
416
  case "nextjs":
417
+ if (analysis.frameworkOutput?.mode && analysis.frameworkOutput.mode !== "standalone") {
418
+ return null;
419
+ }
314
420
  return {
315
421
  name: "Next.js",
316
422
  content: nextjsDockerfile(packageManager, port, baseImage, { usePrisma: analysis.usesPrisma }),
317
423
  };
318
424
 
425
+ case "vite": {
426
+ const distDir = analysis.frameworkOutput?.distDir || "dist";
427
+ return {
428
+ name: "Vite (static)",
429
+ content: staticDockerfile(packageManager, port, baseImage, distDir),
430
+ };
431
+ }
432
+
433
+ case "cra": {
434
+ const distDir = analysis.frameworkOutput?.distDir || "build";
435
+ return {
436
+ name: "Create React App (static)",
437
+ content: staticDockerfile(packageManager, port, baseImage, distDir),
438
+ };
439
+ }
440
+
319
441
  case "express":
320
442
  case "fastify":
321
443
  case "hono":
@@ -0,0 +1,197 @@
1
+ import { Box, Text, useApp, useInput } from "ink";
2
+ import { useState } from "react";
3
+ import type { MenuChoice } from "../subcommands/menu/types";
4
+ import { HomeStatus } from "./HomeStatus";
5
+ import { AppInspector } from "./AppInspector";
6
+ import { cleanLabel } from "./format";
7
+
8
+ export type TunnelLine = { url: string; port: number };
9
+
10
+ export type MenuStatus = {
11
+ connected: boolean;
12
+ latencyMs: number | null;
13
+ tunnels: TunnelLine[];
14
+ apps: { name: string; id: string; url?: string; createdAt?: string }[];
15
+ providers: string[];
16
+ };
17
+
18
+ export type MenuOutcome =
19
+ | { kind: "quit" }
20
+ | {
21
+ kind: "action";
22
+ action: () => Promise<string>;
23
+ isExit: boolean;
24
+ stack: MenuChoice[][];
25
+ titles: string[];
26
+ selected: number;
27
+ };
28
+
29
+ function isDanger(label: string): boolean {
30
+ const lower = label.toLowerCase();
31
+ return lower.includes("stop all") || lower.includes("⚠") || lower.includes("delete");
32
+ }
33
+
34
+ function isExitLabel(label: string): boolean {
35
+ return label.toLowerCase() === "exit";
36
+ }
37
+
38
+ function noticeColor(line: string): string | undefined {
39
+ if (line.startsWith("Error:") || line.startsWith("✗")) return "red";
40
+ if (line.startsWith("✓")) return "green";
41
+ return undefined;
42
+ }
43
+
44
+ export function MenuApp({
45
+ tree,
46
+ status,
47
+ message,
48
+ initialStack,
49
+ initialTitles,
50
+ initialSelected,
51
+ onOutcome,
52
+ }: {
53
+ tree: MenuChoice[];
54
+ status: MenuStatus;
55
+ message: string;
56
+ initialStack: MenuChoice[][];
57
+ initialTitles: string[];
58
+ initialSelected: number;
59
+ onOutcome: (outcome: MenuOutcome) => void;
60
+ }) {
61
+ const { exit } = useApp();
62
+ const [stack, setStack] = useState<MenuChoice[][]>(initialStack);
63
+ const [titles, setTitles] = useState<string[]>(initialTitles);
64
+ const [selected, setSelected] = useState(initialSelected);
65
+ const [notice, setNotice] = useState(message);
66
+
67
+ const current = stack[stack.length - 1] ?? tree;
68
+ const atRoot = stack.length === 1;
69
+ const crumb = titles.slice(1).join(" › ");
70
+ const selectedChoice = current[selected];
71
+ const inspecting = Boolean(selectedChoice?.inspect);
72
+
73
+ const finish = (outcome: MenuOutcome) => {
74
+ onOutcome(outcome);
75
+ exit();
76
+ };
77
+
78
+ const goBack = () => {
79
+ if (notice) {
80
+ setNotice("");
81
+ return;
82
+ }
83
+ if (atRoot) {
84
+ finish({ kind: "quit" });
85
+ return;
86
+ }
87
+ setStack((prev) => prev.slice(0, -1));
88
+ setTitles((prev) => prev.slice(0, -1));
89
+ setSelected(0);
90
+ };
91
+
92
+ useInput((_input, key) => {
93
+ if (key.escape || key.leftArrow) {
94
+ goBack();
95
+ return;
96
+ }
97
+ if (_input === "q" && atRoot && !notice) {
98
+ finish({ kind: "quit" });
99
+ return;
100
+ }
101
+ if (key.upArrow) {
102
+ setSelected((i) => (i - 1 + current.length) % current.length);
103
+ return;
104
+ }
105
+ if (key.downArrow) {
106
+ setSelected((i) => (i + 1) % current.length);
107
+ return;
108
+ }
109
+ if (key.return) {
110
+ if (notice) {
111
+ setNotice("");
112
+ return;
113
+ }
114
+ const choice = current[selected];
115
+ if (!choice) return;
116
+ if (choice.subMenu && choice.subMenu.length > 0) {
117
+ setStack((prev) => [...prev, choice.subMenu!]);
118
+ setTitles((prev) => [...prev, cleanLabel(choice.label)]);
119
+ setSelected(0);
120
+ return;
121
+ }
122
+ if (choice.action) {
123
+ finish({
124
+ kind: "action",
125
+ action: choice.action,
126
+ isExit: isExitLabel(choice.label),
127
+ stack,
128
+ titles,
129
+ selected,
130
+ });
131
+ }
132
+ }
133
+ });
134
+
135
+ return (
136
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
137
+ {atRoot ? (
138
+ <HomeStatus status={status} />
139
+ ) : (
140
+ <Box flexDirection="column">
141
+ <Text dimColor>UPLINK</Text>
142
+ {crumb ? (
143
+ <Box marginTop={1}>
144
+ <Text dimColor>{crumb}</Text>
145
+ </Box>
146
+ ) : null}
147
+ </Box>
148
+ )}
149
+
150
+ <Box flexDirection="column" marginTop={atRoot ? 1 : 1}>
151
+ {current.map((choice, i) => {
152
+ const active = i === selected;
153
+ const label = cleanLabel(choice.label);
154
+ const suffix = choice.subMenu ? " ›" : "";
155
+ const danger = isDanger(label);
156
+ const exitItem = isExitLabel(label);
157
+ return (
158
+ <Text
159
+ key={`${label}-${i}`}
160
+ bold={active && !exitItem}
161
+ color={active && danger ? "red" : undefined}
162
+ dimColor={!active || exitItem}
163
+ >
164
+ {active ? "› " : " "}
165
+ {label}
166
+ {suffix}
167
+ </Text>
168
+ );
169
+ })}
170
+ </Box>
171
+
172
+ {inspecting ? <AppInspector inspect={selectedChoice?.inspect} /> : null}
173
+
174
+ {notice ? (
175
+ <Box flexDirection="column" marginTop={1}>
176
+ {notice.split("\n").map((line, i) => (
177
+ <Text key={i} color={noticeColor(line)} dimColor={!noticeColor(line)}>
178
+ {line || " "}
179
+ </Text>
180
+ ))}
181
+ </Box>
182
+ ) : null}
183
+
184
+ <Box marginTop={1}>
185
+ <Text dimColor>
186
+ {notice
187
+ ? "enter/esc dismiss"
188
+ : inspecting
189
+ ? "↑↓ inspect · ↵ open · esc back"
190
+ : atRoot
191
+ ? "↑↓ enter · esc/q quit"
192
+ : "↑↓ enter · esc back"}
193
+ </Text>
194
+ </Box>
195
+ </Box>
196
+ );
197
+ }