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.
- package/AGENTS.md +161 -0
- package/LICENSE +21 -0
- package/README.md +45 -44
- package/cli/src/index.ts +5 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/godaddy.ts +99 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +30 -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 +40 -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 +268 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +106 -32
- 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/domains.ts +199 -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/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/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +124 -33
- package/cli/src/templates/index.ts +122 -0
- package/cli/src/tui/App.tsx +197 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +59 -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 +382 -0
- package/cli/src/tui/snapshot.ts +146 -0
- package/cli/src/utils/analyze.ts +27 -43
- package/cli/src/utils/framework-output.ts +171 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/docs/AGENTS.md +113 -146
- package/docs/MENU_STRUCTURE.md +56 -288
- package/docs/README.md +6 -6
- package/package.json +18 -35
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
70
|
-
console.log(` token:
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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=${
|
|
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(
|
|
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(
|
|
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
|
|
178
|
-
.
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
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
|
+
}
|