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.
- package/AGENTS.md +177 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +72 -52
- package/cli/src/index.ts +16 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +105 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +32 -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 +42 -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 +295 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +3 -0
- package/cli/src/subcommands/login.ts +85 -0
- 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/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +197 -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/requests.ts +9 -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/signup.ts +2 -2
- package/cli/src/subcommands/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +126 -33
- package/cli/src/templates/index.ts +3 -3
- package/cli/src/tui/App.tsx +202 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +92 -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 +267 -0
- package/cli/src/tui/snapshot.ts +175 -0
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +130 -148
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +60 -288
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +11 -7
- package/package.json +22 -36
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- package/assets/cli-screenshot.png +0 -0
|
@@ -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
|
|
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("
|
|
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}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { apiRequest } from "../http";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
3
4
|
|
|
4
5
|
type SystemStatus = {
|
|
5
6
|
hasInternalSecret: boolean;
|
|
@@ -25,50 +26,71 @@ export const systemCommand = new Command("system")
|
|
|
25
26
|
.description("Show system status for relay/TLS wiring")
|
|
26
27
|
.option("--json", "Output raw JSON")
|
|
27
28
|
.action(async (opts) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
try {
|
|
30
|
+
const status = await fetchStatus();
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
printJson(status);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
console.log(
|
|
37
|
+
[
|
|
38
|
+
"System Status",
|
|
39
|
+
"-------------",
|
|
40
|
+
`Internal secret configured: ${formatBoolean(status.hasInternalSecret)}`,
|
|
41
|
+
`Relay reachable: ${formatBoolean(status.relayReachable)}`,
|
|
42
|
+
`Relay connected tunnels: ${status.relayConnectedCount}`,
|
|
43
|
+
`TLS mode: ${status.tlsMode}`,
|
|
44
|
+
`Wildcard domains: ${status.wildcardDomains.join(", ")}`,
|
|
45
|
+
`Ask endpoint: ${status.askEndpoint.path} (${status.askEndpoint.protected ? "protected" : "unprotected"})`,
|
|
46
|
+
status.askEndpoint.note ? `Note: ${status.askEndpoint.note}` : "",
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join("\n")
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
handleError(error, { json: opts.json });
|
|
53
|
+
}
|
|
45
54
|
})
|
|
46
55
|
)
|
|
47
56
|
.addCommand(
|
|
48
57
|
new Command("explain")
|
|
49
58
|
.description("Explain missing/unsafe settings")
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
.option("--json", "Output JSON", false)
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
try {
|
|
62
|
+
const status = await fetchStatus();
|
|
63
|
+
const issues: string[] = [];
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
if (!status.hasInternalSecret) {
|
|
66
|
+
issues.push(
|
|
67
|
+
"- RELAY_INTERNAL_SECRET is missing. Set it for backend + relay to protect internal endpoints."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!status.relayReachable) {
|
|
71
|
+
issues.push(
|
|
72
|
+
"- Relay unreachable via /internal/connected-tokens. Check relay service and secret header."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (status.askEndpoint && !status.askEndpoint.protected) {
|
|
76
|
+
issues.push("- Ask endpoint is not protected; ensure RELAY_INTERNAL_SECRET is set.");
|
|
77
|
+
}
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
printJson({ status, issues });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log("System Explain");
|
|
85
|
+
console.log("--------------");
|
|
86
|
+
if (issues.length === 0) {
|
|
87
|
+
console.log("No critical issues detected. TLS mode:", status.tlsMode);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
console.log("Issues:");
|
|
91
|
+
issues.forEach((i) => console.log(i));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
handleError(error, { json: opts.json });
|
|
69
94
|
}
|
|
70
|
-
console.log("Issues:");
|
|
71
|
-
issues.forEach((i) => console.log(i));
|
|
72
95
|
})
|
|
73
96
|
);
|
|
74
|
-
|
|
@@ -1,6 +1,12 @@
|
|
|
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";
|
|
9
|
+
import { ensureGuestAccess } from "../utils/guest-access";
|
|
4
10
|
|
|
5
11
|
type TunnelResponse = {
|
|
6
12
|
id: string;
|
|
@@ -9,10 +15,13 @@ type TunnelResponse = {
|
|
|
9
15
|
port?: number;
|
|
10
16
|
token?: string;
|
|
11
17
|
alias?: string | null;
|
|
18
|
+
aliasUrl?: string | null;
|
|
12
19
|
status?: string;
|
|
20
|
+
connected?: boolean;
|
|
13
21
|
createdAt?: string;
|
|
14
22
|
updatedAt?: string;
|
|
15
23
|
ingressHttpUrl?: string;
|
|
24
|
+
targetPort?: number;
|
|
16
25
|
};
|
|
17
26
|
|
|
18
27
|
type TunnelListResponse = {
|
|
@@ -22,16 +31,18 @@ type TunnelListResponse = {
|
|
|
22
31
|
|
|
23
32
|
type TunnelStatsResponse = any;
|
|
24
33
|
|
|
25
|
-
export const tunnelCommand = new Command("tunnel")
|
|
26
|
-
|
|
34
|
+
export const tunnelCommand = new Command("tunnel").description(
|
|
35
|
+
"Manage tunnels non-interactively (agent-friendly)"
|
|
36
|
+
);
|
|
27
37
|
|
|
28
|
-
// Create tunnel
|
|
38
|
+
// Create tunnel + start local client (unless --no-client)
|
|
29
39
|
tunnelCommand
|
|
30
40
|
.command("create")
|
|
31
|
-
.description("Create a tunnel")
|
|
41
|
+
.description("Create a tunnel and start the local client")
|
|
32
42
|
.requiredOption("--port <port>", "Local port to expose")
|
|
33
43
|
.option("--alias <alias>", "Optional permanent alias (if enabled on account)")
|
|
34
44
|
.option("--project <project>", "Optional project id")
|
|
45
|
+
.option("--api-only", "Create API record only; do not start the local client", false)
|
|
35
46
|
.option("--json", "Output JSON", false)
|
|
36
47
|
.action(async (opts) => {
|
|
37
48
|
const port = Number(opts.port);
|
|
@@ -41,42 +52,76 @@ tunnelCommand
|
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
try {
|
|
55
|
+
await ensureGuestAccess();
|
|
56
|
+
const existing = findTunnelClients().filter((c) => c.port === port);
|
|
57
|
+
if (existing.length > 0 && !opts.apiOnly) {
|
|
58
|
+
const err = `Tunnel client already running on port ${port} (pid ${existing[0].pid})`;
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
printJson({ error: err, existing: existing[0] });
|
|
61
|
+
} else {
|
|
62
|
+
console.error(err);
|
|
63
|
+
}
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
const body: Record<string, unknown> = { port };
|
|
45
68
|
if (opts.project) body.project = opts.project;
|
|
46
69
|
|
|
47
|
-
const tunnel = await apiRequest("POST", "/v1/tunnels", body) as TunnelResponse;
|
|
70
|
+
const tunnel = (await apiRequest("POST", "/v1/tunnels", body)) as TunnelResponse;
|
|
48
71
|
let aliasResult: TunnelResponse | null = null;
|
|
49
72
|
let aliasError: string | null = null;
|
|
50
73
|
|
|
51
74
|
if (opts.alias) {
|
|
52
75
|
try {
|
|
53
|
-
aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
|
|
76
|
+
aliasResult = (await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
|
|
54
77
|
alias: opts.alias,
|
|
55
|
-
}) as TunnelResponse;
|
|
78
|
+
})) as TunnelResponse;
|
|
56
79
|
} catch (err: any) {
|
|
57
80
|
aliasError = err?.message || String(err);
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
let client: { pid: number; started: boolean } | null = null;
|
|
85
|
+
if (!opts.apiOnly) {
|
|
86
|
+
const token = tunnel.token;
|
|
87
|
+
if (!token) {
|
|
88
|
+
throw new Error("Tunnel created but API returned no token; cannot start client");
|
|
89
|
+
}
|
|
90
|
+
const started = startTunnelClient({ token, port });
|
|
91
|
+
// Brief wait so list/connected is more likely accurate for agents.
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
93
|
+
client = { pid: started.pid, started: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const url =
|
|
97
|
+
aliasResult?.aliasUrl ||
|
|
98
|
+
aliasResult?.url ||
|
|
99
|
+
tunnel.url ||
|
|
100
|
+
tunnel.ingressHttpUrl ||
|
|
101
|
+
null;
|
|
102
|
+
const alias = aliasResult?.alias ?? tunnel.alias ?? null;
|
|
103
|
+
|
|
61
104
|
if (opts.json) {
|
|
62
105
|
printJson({
|
|
63
|
-
tunnel
|
|
64
|
-
|
|
106
|
+
tunnel: {
|
|
107
|
+
...tunnel,
|
|
108
|
+
alias,
|
|
109
|
+
aliasUrl: aliasResult?.aliasUrl ?? tunnel.aliasUrl ?? null,
|
|
110
|
+
url: tunnel.url ?? tunnel.ingressHttpUrl,
|
|
111
|
+
},
|
|
112
|
+
alias,
|
|
65
113
|
aliasError,
|
|
114
|
+
url,
|
|
115
|
+
client,
|
|
66
116
|
});
|
|
67
117
|
} else {
|
|
68
118
|
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
|
-
}
|
|
119
|
+
console.log(` url: ${url ?? "-"}`);
|
|
120
|
+
console.log(` token: ${tunnel.token ?? "-"}`);
|
|
121
|
+
if (alias) console.log(` alias: ${alias}`);
|
|
122
|
+
else if (aliasError) console.log(` alias: failed - ${aliasError}`);
|
|
123
|
+
if (client) console.log(` client: started (pid ${client.pid})`);
|
|
124
|
+
else console.log(` client: not started (--api-only)`);
|
|
80
125
|
}
|
|
81
126
|
} catch (error) {
|
|
82
127
|
handleError(error, { json: opts.json });
|
|
@@ -90,7 +135,7 @@ tunnelCommand
|
|
|
90
135
|
.option("--json", "Output JSON", false)
|
|
91
136
|
.action(async (opts) => {
|
|
92
137
|
try {
|
|
93
|
-
const result = await apiRequest("GET", "/v1/tunnels") as TunnelListResponse;
|
|
138
|
+
const result = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
|
|
94
139
|
if (opts.json) {
|
|
95
140
|
printJson(result);
|
|
96
141
|
} else {
|
|
@@ -100,8 +145,10 @@ tunnelCommand
|
|
|
100
145
|
}
|
|
101
146
|
console.log(`Tunnels (${result.count}):`);
|
|
102
147
|
for (const t of result.tunnels) {
|
|
148
|
+
const connected = t.connected ? "connected" : "idle";
|
|
149
|
+
const token = t.token ? `${String(t.token).slice(0, 8)}…` : "-";
|
|
103
150
|
console.log(
|
|
104
|
-
`${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${
|
|
151
|
+
`${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${token} alias=${t.alias ?? "-"} status=${t.status ?? "-"} ${connected}`
|
|
105
152
|
);
|
|
106
153
|
}
|
|
107
154
|
}
|
|
@@ -119,9 +166,9 @@ tunnelCommand
|
|
|
119
166
|
.option("--json", "Output JSON", false)
|
|
120
167
|
.action(async (opts) => {
|
|
121
168
|
try {
|
|
122
|
-
const result = await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
|
|
169
|
+
const result = (await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
|
|
123
170
|
alias: opts.alias,
|
|
124
|
-
}) as TunnelResponse;
|
|
171
|
+
})) as TunnelResponse;
|
|
125
172
|
if (opts.json) {
|
|
126
173
|
printJson(result);
|
|
127
174
|
} else {
|
|
@@ -140,7 +187,10 @@ tunnelCommand
|
|
|
140
187
|
.option("--json", "Output JSON", false)
|
|
141
188
|
.action(async (opts) => {
|
|
142
189
|
try {
|
|
143
|
-
const result = await apiRequest(
|
|
190
|
+
const result = (await apiRequest(
|
|
191
|
+
"DELETE",
|
|
192
|
+
`/v1/tunnels/${opts.id}/alias`
|
|
193
|
+
)) as TunnelResponse;
|
|
144
194
|
if (opts.json) {
|
|
145
195
|
printJson(result);
|
|
146
196
|
} else {
|
|
@@ -159,7 +209,10 @@ tunnelCommand
|
|
|
159
209
|
.option("--json", "Output JSON", false)
|
|
160
210
|
.action(async (opts) => {
|
|
161
211
|
try {
|
|
162
|
-
const result = await apiRequest(
|
|
212
|
+
const result = (await apiRequest(
|
|
213
|
+
"GET",
|
|
214
|
+
`/v1/tunnels/${opts.id}/stats`
|
|
215
|
+
)) as TunnelStatsResponse;
|
|
163
216
|
if (opts.json) {
|
|
164
217
|
printJson(result);
|
|
165
218
|
} else {
|
|
@@ -171,22 +224,62 @@ tunnelCommand
|
|
|
171
224
|
}
|
|
172
225
|
});
|
|
173
226
|
|
|
174
|
-
// Stop (delete) tunnel
|
|
227
|
+
// Stop (delete) tunnel and kill any matching local client
|
|
175
228
|
tunnelCommand
|
|
176
229
|
.command("stop")
|
|
177
|
-
.description("Stop
|
|
178
|
-
.
|
|
230
|
+
.description("Stop a tunnel: kill the local client and delete the record")
|
|
231
|
+
.option("--id <id>", "Tunnel id")
|
|
232
|
+
.option("--all", "Stop every tunnel for this account", false)
|
|
179
233
|
.option("--json", "Output JSON", false)
|
|
180
234
|
.action(async (opts) => {
|
|
235
|
+
if (!opts.all && !opts.id) {
|
|
236
|
+
console.error("Provide --id or --all");
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
|
|
181
240
|
try {
|
|
182
|
-
|
|
241
|
+
if (opts.all) {
|
|
242
|
+
const listed = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
|
|
243
|
+
let killed = 0;
|
|
244
|
+
for (const client of findTunnelClients()) {
|
|
245
|
+
if (killTunnelClient(client.pid)) killed++;
|
|
246
|
+
}
|
|
247
|
+
let deleted = 0;
|
|
248
|
+
for (const t of listed.tunnels || []) {
|
|
249
|
+
if (!t.id) continue;
|
|
250
|
+
try {
|
|
251
|
+
await apiRequest("DELETE", `/v1/tunnels/${t.id}`);
|
|
252
|
+
deleted++;
|
|
253
|
+
} catch {
|
|
254
|
+
/* already gone */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (opts.json) {
|
|
258
|
+
printJson({ ok: true, killed, deleted });
|
|
259
|
+
} else {
|
|
260
|
+
console.log(`Stopped ${killed} local client(s), removed ${deleted} tunnel record(s)`);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const listed = (await apiRequest("GET", "/v1/tunnels")) as TunnelListResponse;
|
|
266
|
+
const target = (listed.tunnels || []).find((t) => t.id === opts.id);
|
|
267
|
+
if (target?.token) {
|
|
268
|
+
for (const client of findTunnelClients().filter((c) => c.token === target.token)) {
|
|
269
|
+
killTunnelClient(client.pid);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const result = (await apiRequest("DELETE", `/v1/tunnels/${opts.id}`)) as {
|
|
274
|
+
id: string;
|
|
275
|
+
status: string;
|
|
276
|
+
};
|
|
183
277
|
if (opts.json) {
|
|
184
278
|
printJson(result);
|
|
185
279
|
} else {
|
|
186
280
|
console.log(`Stopped tunnel ${result.id} (status=${result.status})`);
|
|
187
281
|
}
|
|
188
|
-
} catch (error
|
|
189
|
-
|
|
190
|
-
process.exit(30);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
handleError(error, { json: opts.json });
|
|
191
284
|
}
|
|
192
285
|
});
|
|
@@ -154,7 +154,7 @@ RUN npm install -g serve
|
|
|
154
154
|
COPY --from=builder /app/${distDir} ./public
|
|
155
155
|
|
|
156
156
|
EXPOSE ${port}
|
|
157
|
-
CMD ["sh", "-c", "serve -s public -l
|
|
157
|
+
CMD ["sh", "-c", "serve -s public -l \${PORT}"]
|
|
158
158
|
`;
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -184,7 +184,7 @@ RUN npm install -g serve
|
|
|
184
184
|
COPY --from=builder /app/${distDir} ./public
|
|
185
185
|
|
|
186
186
|
EXPOSE ${port}
|
|
187
|
-
CMD ["sh", "-c", "serve -s public -l
|
|
187
|
+
CMD ["sh", "-c", "serve -s public -l \${PORT}"]
|
|
188
188
|
`;
|
|
189
189
|
}
|
|
190
190
|
|
|
@@ -215,7 +215,7 @@ RUN npm install -g serve
|
|
|
215
215
|
COPY --from=builder /app/${distDir} ./public
|
|
216
216
|
|
|
217
217
|
EXPOSE ${port}
|
|
218
|
-
CMD ["sh", "-c", "serve -s public -l
|
|
218
|
+
CMD ["sh", "-c", "serve -s public -l \${PORT}"]
|
|
219
219
|
`;
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
storageUsedBytes: number;
|
|
17
|
+
storageLimitBytes: number;
|
|
18
|
+
appLimit: number;
|
|
19
|
+
alwaysOn: boolean;
|
|
20
|
+
idleMinutes: number | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MenuOutcome =
|
|
24
|
+
| { kind: "quit" }
|
|
25
|
+
| {
|
|
26
|
+
kind: "action";
|
|
27
|
+
action: () => Promise<string>;
|
|
28
|
+
isExit: boolean;
|
|
29
|
+
stack: MenuChoice[][];
|
|
30
|
+
titles: string[];
|
|
31
|
+
selected: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function isDanger(label: string): boolean {
|
|
35
|
+
const lower = label.toLowerCase();
|
|
36
|
+
return lower.includes("stop all") || lower.includes("⚠") || lower.includes("delete");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isExitLabel(label: string): boolean {
|
|
40
|
+
return label.toLowerCase() === "exit";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function noticeColor(line: string): string | undefined {
|
|
44
|
+
if (line.startsWith("Error:") || line.startsWith("✗")) return "red";
|
|
45
|
+
if (line.startsWith("✓")) return "green";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function MenuApp({
|
|
50
|
+
tree,
|
|
51
|
+
status,
|
|
52
|
+
message,
|
|
53
|
+
initialStack,
|
|
54
|
+
initialTitles,
|
|
55
|
+
initialSelected,
|
|
56
|
+
onOutcome,
|
|
57
|
+
}: {
|
|
58
|
+
tree: MenuChoice[];
|
|
59
|
+
status: MenuStatus;
|
|
60
|
+
message: string;
|
|
61
|
+
initialStack: MenuChoice[][];
|
|
62
|
+
initialTitles: string[];
|
|
63
|
+
initialSelected: number;
|
|
64
|
+
onOutcome: (outcome: MenuOutcome) => void;
|
|
65
|
+
}) {
|
|
66
|
+
const { exit } = useApp();
|
|
67
|
+
const [stack, setStack] = useState<MenuChoice[][]>(initialStack);
|
|
68
|
+
const [titles, setTitles] = useState<string[]>(initialTitles);
|
|
69
|
+
const [selected, setSelected] = useState(initialSelected);
|
|
70
|
+
const [notice, setNotice] = useState(message);
|
|
71
|
+
|
|
72
|
+
const current = stack[stack.length - 1] ?? tree;
|
|
73
|
+
const atRoot = stack.length === 1;
|
|
74
|
+
const crumb = titles.slice(1).join(" › ");
|
|
75
|
+
const selectedChoice = current[selected];
|
|
76
|
+
const inspecting = Boolean(selectedChoice?.inspect);
|
|
77
|
+
|
|
78
|
+
const finish = (outcome: MenuOutcome) => {
|
|
79
|
+
onOutcome(outcome);
|
|
80
|
+
exit();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const goBack = () => {
|
|
84
|
+
if (notice) {
|
|
85
|
+
setNotice("");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (atRoot) {
|
|
89
|
+
finish({ kind: "quit" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setStack((prev) => prev.slice(0, -1));
|
|
93
|
+
setTitles((prev) => prev.slice(0, -1));
|
|
94
|
+
setSelected(0);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
useInput((_input, key) => {
|
|
98
|
+
if (key.escape || key.leftArrow) {
|
|
99
|
+
goBack();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (_input === "q" && atRoot && !notice) {
|
|
103
|
+
finish({ kind: "quit" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.upArrow) {
|
|
107
|
+
setSelected((i) => (i - 1 + current.length) % current.length);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key.downArrow) {
|
|
111
|
+
setSelected((i) => (i + 1) % current.length);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (key.return) {
|
|
115
|
+
if (notice) {
|
|
116
|
+
setNotice("");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const choice = current[selected];
|
|
120
|
+
if (!choice) return;
|
|
121
|
+
if (choice.subMenu && choice.subMenu.length > 0) {
|
|
122
|
+
setStack((prev) => [...prev, choice.subMenu!]);
|
|
123
|
+
setTitles((prev) => [...prev, cleanLabel(choice.label)]);
|
|
124
|
+
setSelected(0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (choice.action) {
|
|
128
|
+
finish({
|
|
129
|
+
kind: "action",
|
|
130
|
+
action: choice.action,
|
|
131
|
+
isExit: isExitLabel(choice.label),
|
|
132
|
+
stack,
|
|
133
|
+
titles,
|
|
134
|
+
selected,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
142
|
+
{atRoot ? (
|
|
143
|
+
<HomeStatus status={status} />
|
|
144
|
+
) : (
|
|
145
|
+
<Box flexDirection="column">
|
|
146
|
+
<Text dimColor>UPLINK</Text>
|
|
147
|
+
{crumb ? (
|
|
148
|
+
<Box marginTop={1}>
|
|
149
|
+
<Text dimColor>{crumb}</Text>
|
|
150
|
+
</Box>
|
|
151
|
+
) : null}
|
|
152
|
+
</Box>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<Box flexDirection="column" marginTop={atRoot ? 1 : 1}>
|
|
156
|
+
{current.map((choice, i) => {
|
|
157
|
+
const active = i === selected;
|
|
158
|
+
const label = cleanLabel(choice.label);
|
|
159
|
+
const suffix = choice.subMenu ? " ›" : "";
|
|
160
|
+
const danger = isDanger(label);
|
|
161
|
+
const exitItem = isExitLabel(label);
|
|
162
|
+
return (
|
|
163
|
+
<Text
|
|
164
|
+
key={`${label}-${i}`}
|
|
165
|
+
bold={active && !exitItem}
|
|
166
|
+
color={active && danger ? "red" : undefined}
|
|
167
|
+
dimColor={!active || exitItem}
|
|
168
|
+
>
|
|
169
|
+
{active ? "› " : " "}
|
|
170
|
+
{label}
|
|
171
|
+
{suffix}
|
|
172
|
+
</Text>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</Box>
|
|
176
|
+
|
|
177
|
+
{inspecting ? <AppInspector inspect={selectedChoice?.inspect} /> : null}
|
|
178
|
+
|
|
179
|
+
{notice ? (
|
|
180
|
+
<Box flexDirection="column" marginTop={1}>
|
|
181
|
+
{notice.split("\n").map((line, i) => (
|
|
182
|
+
<Text key={i} color={noticeColor(line)} dimColor={!noticeColor(line)}>
|
|
183
|
+
{line || " "}
|
|
184
|
+
</Text>
|
|
185
|
+
))}
|
|
186
|
+
</Box>
|
|
187
|
+
) : null}
|
|
188
|
+
|
|
189
|
+
<Box marginTop={1}>
|
|
190
|
+
<Text dimColor>
|
|
191
|
+
{notice
|
|
192
|
+
? "enter/esc dismiss"
|
|
193
|
+
: inspecting
|
|
194
|
+
? "↑↓ inspect · ↵ open · esc back"
|
|
195
|
+
: atRoot
|
|
196
|
+
? "↑↓ enter · esc/q quit"
|
|
197
|
+
: "↑↓ enter · esc back"}
|
|
198
|
+
</Text>
|
|
199
|
+
</Box>
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|