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
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { apiRequest } from "../http";
|
|
3
|
+
import { clearScreen, promptLine, restoreRawMode, truncate } from "../subcommands/menu/io";
|
|
4
|
+
import { unauthenticatedRequest } from "../subcommands/menu/requests";
|
|
5
|
+
import { inlineSelect } from "../subcommands/menu/inline-tree-select";
|
|
6
|
+
import {
|
|
7
|
+
colorDim,
|
|
8
|
+
colorGreen,
|
|
9
|
+
colorRed,
|
|
10
|
+
colorWhite,
|
|
11
|
+
} from "../subcommands/menu/colors";
|
|
12
|
+
import { type MenuChoice } from "../subcommands/menu/types";
|
|
13
|
+
import {
|
|
14
|
+
buildManageAliasesMenu,
|
|
15
|
+
buildManageTokensMenu,
|
|
16
|
+
buildManageTunnelsMenu,
|
|
17
|
+
buildHostingMenu,
|
|
18
|
+
buildDomainsMenu,
|
|
19
|
+
buildSystemStatusMenu,
|
|
20
|
+
buildUsageMenu,
|
|
21
|
+
} from "../subcommands/menu/menus";
|
|
22
|
+
import { ports, smoke, tokenConfig, tunnelClients } from "../subcommands/menu/effects";
|
|
23
|
+
import { runInkMenu } from "./runMenu";
|
|
24
|
+
import { launchDomainking } from "../utils/launchDomainking";
|
|
25
|
+
import { fetchMenuSnapshot } from "./snapshot";
|
|
26
|
+
|
|
27
|
+
function formatBytes(bytes: number): string {
|
|
28
|
+
if (bytes === 0) return "0 B";
|
|
29
|
+
const k = 1024;
|
|
30
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
31
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
32
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function startMenuSession(): Promise<void> {
|
|
36
|
+
const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
|
|
37
|
+
|
|
38
|
+
// Determine role (admin or user) via /v1/me; check if auth failed
|
|
39
|
+
let isAdmin = false;
|
|
40
|
+
let authFailed = false;
|
|
41
|
+
const meStart = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
const me = await apiRequest("GET", "/v1/me");
|
|
44
|
+
isAdmin = me?.role === "admin";
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
// Check if it's an authentication error
|
|
47
|
+
const errorMsg = err?.message || String(err);
|
|
48
|
+
authFailed =
|
|
49
|
+
errorMsg.includes("UNAUTHORIZED") ||
|
|
50
|
+
errorMsg.includes("401") ||
|
|
51
|
+
errorMsg.includes("Missing or invalid token") ||
|
|
52
|
+
errorMsg.includes("Missing AGENTCLOUD_TOKEN");
|
|
53
|
+
isAdmin = false;
|
|
54
|
+
}
|
|
55
|
+
const meDurationMs = Date.now() - meStart;
|
|
56
|
+
|
|
57
|
+
// Build menu structure dynamically by role and auth status
|
|
58
|
+
const mainMenu: MenuChoice[] = [];
|
|
59
|
+
|
|
60
|
+
// If authentication failed, show ONLY "Get Started", "About", and "Exit"
|
|
61
|
+
if (authFailed) {
|
|
62
|
+
mainMenu.push({
|
|
63
|
+
label: "🚀 Get Started (Create Account)",
|
|
64
|
+
action: async () => {
|
|
65
|
+
restoreRawMode();
|
|
66
|
+
clearScreen();
|
|
67
|
+
try {
|
|
68
|
+
process.stdout.write("\n");
|
|
69
|
+
process.stdout.write(colorWhite("UPLINK") + colorDim(" Create Account\n"));
|
|
70
|
+
process.stdout.write("\n");
|
|
71
|
+
|
|
72
|
+
const label = (await promptLine("Label (optional): ")).trim();
|
|
73
|
+
const expiresInput = (await promptLine("Expires in days (optional): ")).trim();
|
|
74
|
+
const expiresDays = expiresInput ? Number(expiresInput) : undefined;
|
|
75
|
+
|
|
76
|
+
if (expiresDays && (isNaN(expiresDays) || expiresDays <= 0)) {
|
|
77
|
+
restoreRawMode();
|
|
78
|
+
return "Invalid expiration days. Please enter a positive number or leave empty.";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.stdout.write("\nCreating your token...\n");
|
|
82
|
+
process.stdout.write("");
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await unauthenticatedRequest("POST", "/v1/signup", {
|
|
86
|
+
label: label || undefined,
|
|
87
|
+
expiresInDays: expiresDays || undefined,
|
|
88
|
+
});
|
|
89
|
+
if (!result) {
|
|
90
|
+
restoreRawMode();
|
|
91
|
+
return "❌ Error: No response from server.";
|
|
92
|
+
}
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
restoreRawMode();
|
|
95
|
+
const errorMsg = err?.message || String(err);
|
|
96
|
+
console.error("\n❌ Signup error:", errorMsg);
|
|
97
|
+
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
98
|
+
return "⚠️ Too many signup attempts. Please try again later.";
|
|
99
|
+
}
|
|
100
|
+
return `❌ Error creating account: ${errorMsg}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!result || !result.token) {
|
|
104
|
+
restoreRawMode();
|
|
105
|
+
return "❌ Error: Invalid response from server. Token not received.";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const token = result.token;
|
|
109
|
+
const tokenId = result.id;
|
|
110
|
+
const userId = result.userId;
|
|
111
|
+
|
|
112
|
+
process.stdout.write("\n");
|
|
113
|
+
process.stdout.write(colorGreen("✓") + " Account created\n");
|
|
114
|
+
process.stdout.write("\n");
|
|
115
|
+
process.stdout.write(colorDim("├─") + " Token " + token + "\n");
|
|
116
|
+
process.stdout.write(colorDim("├─") + " ID " + tokenId + "\n");
|
|
117
|
+
process.stdout.write(colorDim("├─") + " User " + userId + "\n");
|
|
118
|
+
process.stdout.write(colorDim("├─") + " Role " + result.role + "\n");
|
|
119
|
+
if (result.expiresAt) {
|
|
120
|
+
process.stdout.write(colorDim("└─") + " Expires " + result.expiresAt + "\n");
|
|
121
|
+
} else {
|
|
122
|
+
process.stdout.write(colorDim("└─") + " Expires " + colorDim("never") + "\n");
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write("\n");
|
|
125
|
+
process.stdout.write(colorDim("!") + " Save this token securely — shown only once\n");
|
|
126
|
+
|
|
127
|
+
// Try to automatically add token to shell config
|
|
128
|
+
const detected = tokenConfig.detectShellConfigFile();
|
|
129
|
+
let configFile: string | null = detected.configFile;
|
|
130
|
+
let shellName = detected.shellName;
|
|
131
|
+
|
|
132
|
+
let tokenAdded = false;
|
|
133
|
+
const tokenExists = configFile ? tokenConfig.shellConfigHasToken(configFile) : false;
|
|
134
|
+
|
|
135
|
+
if (configFile) {
|
|
136
|
+
const promptText = tokenExists
|
|
137
|
+
? `\n→ Update existing token in ~/.${shellName}rc? (Y/n): `
|
|
138
|
+
: `\n→ Add token to ~/.${shellName}rc? (Y/n): `;
|
|
139
|
+
|
|
140
|
+
const addToken = (await promptLine(promptText)).trim().toLowerCase();
|
|
141
|
+
if (addToken !== "n" && addToken !== "no") {
|
|
142
|
+
try {
|
|
143
|
+
const res = tokenConfig.upsertShellToken(configFile, token);
|
|
144
|
+
tokenAdded = res.wrote;
|
|
145
|
+
if (tokenExists) {
|
|
146
|
+
console.log(colorGreen(`\n✓ Token updated in ~/.${shellName}rc`));
|
|
147
|
+
} else {
|
|
148
|
+
console.log(colorGreen(`\n✓ Token added to ~/.${shellName}rc`));
|
|
149
|
+
}
|
|
150
|
+
if (!res.verifyOk) {
|
|
151
|
+
console.log(
|
|
152
|
+
colorRed(`\n! Token may not have been written correctly. Check ~/.${shellName}rc`)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
if (err?.message?.includes("UNSAFE_SHELL_CONFIG_PERMISSIONS")) {
|
|
157
|
+
console.log(
|
|
158
|
+
colorRed(
|
|
159
|
+
`\n! Could not write to ~/.${shellName}rc: file is group/world writable. Fix permissions first.`
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
console.log(colorDim(` chmod 600 ~/.${shellName}rc`));
|
|
163
|
+
} else {
|
|
164
|
+
console.log(colorRed(`\n! Could not write to ~/.${shellName}rc: ${err.message}`));
|
|
165
|
+
}
|
|
166
|
+
console.log(`\n Please add manually:`);
|
|
167
|
+
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
console.log(colorDim(`\n→ Could not detect your shell. Add the token manually:`));
|
|
172
|
+
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.zshrc # for zsh`));
|
|
173
|
+
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.bashrc # for bash`));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!tokenAdded) {
|
|
177
|
+
process.stdout.write("\n");
|
|
178
|
+
process.stdout.write(colorDim("!") + " Set this token as an environment variable:\n\n");
|
|
179
|
+
process.stdout.write(colorDim(" ") + "export AGENTCLOUD_TOKEN=" + token + "\n");
|
|
180
|
+
if (configFile) {
|
|
181
|
+
process.stdout.write(colorDim(`\n Or add to ~/.${shellName}rc:\n`));
|
|
182
|
+
process.stdout.write(colorDim(" ") + `echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc\n`);
|
|
183
|
+
process.stdout.write(colorDim(" ") + `source ~/.${shellName}rc\n`);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(colorDim("\n Then restart this menu.\n\n"));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
restoreRawMode();
|
|
189
|
+
|
|
190
|
+
if (tokenAdded) {
|
|
191
|
+
process.env.AGENTCLOUD_TOKEN = token;
|
|
192
|
+
// Use stdout writes to avoid buffering/race with process.exit()
|
|
193
|
+
process.stdout.write(`\n${colorGreen("✓")} Token saved to ~/.${shellName}rc\n`);
|
|
194
|
+
process.stdout.write(`\n${colorDim("→")} Next: run in your terminal:\n`);
|
|
195
|
+
process.stdout.write(colorDim(` source ~/.${shellName}rc && uplink\n\n`));
|
|
196
|
+
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}, 3000);
|
|
200
|
+
|
|
201
|
+
return undefined as any;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log("\nPress Enter to continue...");
|
|
205
|
+
await promptLine("");
|
|
206
|
+
restoreRawMode();
|
|
207
|
+
return "Token created! Please set AGENTCLOUD_TOKEN environment variable and restart the menu.";
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
restoreRawMode();
|
|
210
|
+
const errorMsg = err?.message || String(err);
|
|
211
|
+
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
212
|
+
return "⚠️ Too many signup attempts. Please try again later.";
|
|
213
|
+
}
|
|
214
|
+
return `❌ Error creating account: ${errorMsg}`;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
mainMenu.push({
|
|
220
|
+
label: "Find a domain",
|
|
221
|
+
action: async () => {
|
|
222
|
+
restoreRawMode();
|
|
223
|
+
return launchDomainking();
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
mainMenu.push({
|
|
228
|
+
label: "About",
|
|
229
|
+
action: async () => {
|
|
230
|
+
return [
|
|
231
|
+
"Uplink CLI",
|
|
232
|
+
"Open source CLI for sharing localhost and hosting apps.",
|
|
233
|
+
"Interactive menu + agent-friendly commands for automation.",
|
|
234
|
+
"",
|
|
235
|
+
"Website: https://uplink.spot",
|
|
236
|
+
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
237
|
+
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
238
|
+
].join("\n");
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
mainMenu.push({
|
|
243
|
+
label: "Exit",
|
|
244
|
+
action: async () => {
|
|
245
|
+
return "Goodbye!";
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
// Only show other menu items if authentication succeeded
|
|
250
|
+
|
|
251
|
+
const shareMenu = buildManageTunnelsMenu({
|
|
252
|
+
apiRequest,
|
|
253
|
+
promptLine,
|
|
254
|
+
restoreRawMode,
|
|
255
|
+
truncate,
|
|
256
|
+
formatBytes,
|
|
257
|
+
inlineSelect,
|
|
258
|
+
scanCommonPorts: ports.scanCommonPorts,
|
|
259
|
+
findTunnelClients: tunnelClients.findTunnelClients,
|
|
260
|
+
createAndStartTunnel: (port: number) => tunnelClients.createAndStartTunnel(apiRequest, port),
|
|
261
|
+
stopTunnelClients: (clients, opts) => tunnelClients.stopTunnelClients(apiRequest, clients, opts),
|
|
262
|
+
colorDim,
|
|
263
|
+
colorRed,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const aliasesMenu = buildManageAliasesMenu({
|
|
267
|
+
apiRequest,
|
|
268
|
+
promptLine,
|
|
269
|
+
restoreRawMode,
|
|
270
|
+
inlineSelect,
|
|
271
|
+
findTunnelClients: tunnelClients.findTunnelClients,
|
|
272
|
+
truncate,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
shareMenu.subMenu = shareMenu.subMenu || [];
|
|
276
|
+
if (aliasesMenu.subMenu) {
|
|
277
|
+
shareMenu.subMenu.push({
|
|
278
|
+
label: "Aliases",
|
|
279
|
+
subMenu: aliasesMenu.subMenu,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (isAdmin) {
|
|
283
|
+
shareMenu.subMenu.push({
|
|
284
|
+
label: "⚠️ Stop ALL Tunnel Clients (kill switch)",
|
|
285
|
+
action: async () => {
|
|
286
|
+
const clients = tunnelClients.findTunnelClients();
|
|
287
|
+
if (clients.length === 0) {
|
|
288
|
+
const ghost = await tunnelClients.stopTunnelClients(apiRequest, [], {
|
|
289
|
+
connectedGhosts: true,
|
|
290
|
+
});
|
|
291
|
+
if (ghost.deleted > 0) {
|
|
292
|
+
return `✓ Removed ${ghost.deleted} relay-connected tunnel${ghost.deleted !== 1 ? "s" : ""} with no local client`;
|
|
293
|
+
}
|
|
294
|
+
return "No running tunnel clients found.";
|
|
295
|
+
}
|
|
296
|
+
const { killed, deleted } = await tunnelClients.stopTunnelClients(apiRequest, clients);
|
|
297
|
+
return `✓ Stopped ${killed} local client${killed !== 1 ? "s" : ""}, removed ${deleted} tunnel record${deleted !== 1 ? "s" : ""}`;
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
mainMenu.push(shareMenu);
|
|
303
|
+
|
|
304
|
+
mainMenu.push(
|
|
305
|
+
buildHostingMenu({
|
|
306
|
+
promptLine,
|
|
307
|
+
restoreRawMode,
|
|
308
|
+
inlineSelect,
|
|
309
|
+
})
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
mainMenu.push(
|
|
313
|
+
buildDomainsMenu({
|
|
314
|
+
promptLine,
|
|
315
|
+
restoreRawMode,
|
|
316
|
+
inlineSelect,
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Admin-only: Usage section
|
|
321
|
+
if (isAdmin) {
|
|
322
|
+
mainMenu.push(
|
|
323
|
+
buildUsageMenu({
|
|
324
|
+
apiRequest,
|
|
325
|
+
truncate,
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (isAdmin) {
|
|
331
|
+
mainMenu.push(
|
|
332
|
+
buildSystemStatusMenu({
|
|
333
|
+
apiBase,
|
|
334
|
+
apiRequest,
|
|
335
|
+
fetch: (url: string) => fetch(url) as any,
|
|
336
|
+
truncate,
|
|
337
|
+
formatBytes,
|
|
338
|
+
runSmoke: smoke.runSmoke,
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Admin-only: Manage Tokens
|
|
344
|
+
if (isAdmin) {
|
|
345
|
+
mainMenu.push(
|
|
346
|
+
buildManageTokensMenu({
|
|
347
|
+
apiRequest,
|
|
348
|
+
promptLine,
|
|
349
|
+
restoreRawMode,
|
|
350
|
+
truncate,
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
mainMenu.push({
|
|
356
|
+
label: "About",
|
|
357
|
+
action: async () => {
|
|
358
|
+
return [
|
|
359
|
+
"Uplink CLI",
|
|
360
|
+
"Open source CLI for sharing localhost and hosting apps.",
|
|
361
|
+
"Interactive menu + agent-friendly commands for automation.",
|
|
362
|
+
"",
|
|
363
|
+
"Website: https://uplink.spot",
|
|
364
|
+
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
365
|
+
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
366
|
+
].join("\n");
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
mainMenu.push({
|
|
371
|
+
label: "Exit",
|
|
372
|
+
action: async () => "Goodbye!",
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
377
|
+
console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await runInkMenu({ tree: mainMenu, getStatus: fetchMenuSnapshot });
|
|
382
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { connectedProviders } from "../registrars";
|
|
3
|
+
import { health, tunnelClients } from "../subcommands/menu/effects";
|
|
4
|
+
import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
|
|
5
|
+
import type { MenuStatus } from "./App";
|
|
6
|
+
|
|
7
|
+
const SNAPSHOT_TIMEOUT_MS = 2000;
|
|
8
|
+
const ARTIFACT_CAP_BYTES = 500_000_000;
|
|
9
|
+
|
|
10
|
+
export { ARTIFACT_CAP_BYTES };
|
|
11
|
+
|
|
12
|
+
type JsonObject = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
function localTunnels(): MenuStatus["tunnels"] {
|
|
15
|
+
const domain = process.env.TUNNEL_DOMAIN || "x.uplink.spot";
|
|
16
|
+
const scheme = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
|
|
17
|
+
return tunnelClients.findTunnelClients().map((client) => ({
|
|
18
|
+
url: `${scheme}://${client.token}.${domain}`,
|
|
19
|
+
port: client.port,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function apiGet(path: string, timeoutMs = SNAPSHOT_TIMEOUT_MS): Promise<unknown | null> {
|
|
24
|
+
const apiBase = getResolvedApiBase();
|
|
25
|
+
const token = getResolvedApiToken(apiBase);
|
|
26
|
+
if (!token) return null;
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${apiBase}${path}`, {
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) return null;
|
|
35
|
+
return await res.json();
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asObject(value: unknown): JsonObject | null {
|
|
44
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asString(value: unknown): string | undefined {
|
|
48
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchApps(): Promise<MenuStatus["apps"]> {
|
|
52
|
+
const body = asObject(await apiGet("/v1/apps"));
|
|
53
|
+
const apps = body?.apps;
|
|
54
|
+
if (!Array.isArray(apps)) return [];
|
|
55
|
+
const parsed: MenuStatus["apps"] = [];
|
|
56
|
+
for (const item of apps) {
|
|
57
|
+
const rec = asObject(item);
|
|
58
|
+
if (!rec) continue;
|
|
59
|
+
const name = asString(rec.name);
|
|
60
|
+
const id = asString(rec.id);
|
|
61
|
+
if (!name || !id) continue;
|
|
62
|
+
parsed.push({
|
|
63
|
+
name,
|
|
64
|
+
id,
|
|
65
|
+
url: asString(rec.url),
|
|
66
|
+
createdAt: asString(rec.createdAt),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchHealth(): Promise<{ connected: boolean; latencyMs: number | null }> {
|
|
73
|
+
const started = Date.now();
|
|
74
|
+
const healthRes = await health.checkApiHealth({});
|
|
75
|
+
if (!healthRes.ok) return { connected: false, latencyMs: null };
|
|
76
|
+
return { connected: true, latencyMs: Date.now() - started };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function fetchMenuSnapshot(): Promise<MenuStatus> {
|
|
80
|
+
const tunnels = localTunnels();
|
|
81
|
+
const [healthStatus, apps, providers] = await Promise.all([
|
|
82
|
+
fetchHealth(),
|
|
83
|
+
fetchApps(),
|
|
84
|
+
Promise.resolve(connectedProviders()),
|
|
85
|
+
]);
|
|
86
|
+
return {
|
|
87
|
+
connected: healthStatus.connected,
|
|
88
|
+
latencyMs: healthStatus.latencyMs,
|
|
89
|
+
tunnels,
|
|
90
|
+
apps,
|
|
91
|
+
providers,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type AppInspect = {
|
|
96
|
+
name: string;
|
|
97
|
+
url: string;
|
|
98
|
+
createdAt?: string;
|
|
99
|
+
deploy?: string;
|
|
100
|
+
build?: string;
|
|
101
|
+
sizeBytes?: number;
|
|
102
|
+
domains: { hostname: string; verified: boolean }[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export async function fetchAppInspect(id: string): Promise<AppInspect | null> {
|
|
106
|
+
const [statusBody, domainsBody] = await Promise.all([
|
|
107
|
+
apiGet(`/v1/apps/${id}/status`),
|
|
108
|
+
apiGet(`/v1/apps/${id}/domains`),
|
|
109
|
+
]);
|
|
110
|
+
const status = asObject(statusBody);
|
|
111
|
+
if (!status) return null;
|
|
112
|
+
const app = asObject(status.app);
|
|
113
|
+
const release = asObject(status.activeRelease);
|
|
114
|
+
const deployment = asObject(status.activeDeployment);
|
|
115
|
+
const domainList = asObject(domainsBody)?.domains;
|
|
116
|
+
const domains: AppInspect["domains"] = [];
|
|
117
|
+
if (Array.isArray(domainList)) {
|
|
118
|
+
for (const item of domainList) {
|
|
119
|
+
const rec = asObject(item);
|
|
120
|
+
const hostname = rec ? asString(rec.hostname) : undefined;
|
|
121
|
+
if (!hostname) continue;
|
|
122
|
+
domains.push({ hostname, verified: rec?.verified === true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const size = release?.sizeBytes;
|
|
126
|
+
return {
|
|
127
|
+
name: asString(app?.name) || id,
|
|
128
|
+
url: asString(app?.url) || "",
|
|
129
|
+
createdAt: asString(app?.createdAt),
|
|
130
|
+
deploy: asString(deployment?.status),
|
|
131
|
+
build: asString(release?.buildStatus),
|
|
132
|
+
sizeBytes: typeof size === "number" && Number.isFinite(size) ? size : undefined,
|
|
133
|
+
domains,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function fetchAppLogs(id: string): Promise<string> {
|
|
138
|
+
const body = asObject(await apiGet(`/v1/apps/${id}/logs`, 4000));
|
|
139
|
+
if (!body) return "No logs available.";
|
|
140
|
+
const lines = body.lines;
|
|
141
|
+
if (!Array.isArray(lines) || lines.length === 0) return "No log lines.";
|
|
142
|
+
return lines
|
|
143
|
+
.filter((line): line is string => typeof line === "string")
|
|
144
|
+
.slice(-40)
|
|
145
|
+
.join("\n");
|
|
146
|
+
}
|
package/cli/src/utils/analyze.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
2
2
|
import { join, basename, isAbsolute, dirname } from "path";
|
|
3
|
+
import { detectFrameworkOutput, type FrameworkOutputInfo } from "./framework-output";
|
|
3
4
|
|
|
4
5
|
export interface FrameworkInfo {
|
|
5
6
|
name: string;
|
|
@@ -36,6 +37,7 @@ export interface HealthCheck {
|
|
|
36
37
|
|
|
37
38
|
export interface AnalysisResult {
|
|
38
39
|
framework: FrameworkInfo | null;
|
|
40
|
+
frameworkOutput: FrameworkOutputInfo | null;
|
|
39
41
|
packageManager: "npm" | "yarn" | "pnpm" | "bun" | null;
|
|
40
42
|
database: DatabaseInfo | null;
|
|
41
43
|
storage: StorageInfo[];
|
|
@@ -206,6 +208,14 @@ export function detectFramework(dir: string): FrameworkInfo | null {
|
|
|
206
208
|
if (deps["next"]) {
|
|
207
209
|
return { name: "nextjs", version: deps["next"]?.replace(/[\^~]/, "") };
|
|
208
210
|
}
|
|
211
|
+
// Vite
|
|
212
|
+
if (deps["vite"]) {
|
|
213
|
+
return { name: "vite", version: deps["vite"]?.replace(/[\^~]/, "") };
|
|
214
|
+
}
|
|
215
|
+
// Create React App
|
|
216
|
+
if (deps["react-scripts"]) {
|
|
217
|
+
return { name: "cra", version: deps["react-scripts"]?.replace(/[\^~]/, "") };
|
|
218
|
+
}
|
|
209
219
|
// Express
|
|
210
220
|
if (deps["express"]) {
|
|
211
221
|
return { name: "express", version: deps["express"]?.replace(/[\^~]/, "") };
|
|
@@ -463,49 +473,21 @@ function detectHealthChecks(dir: string, analysis: AnalysisResult): HealthCheck[
|
|
|
463
473
|
}
|
|
464
474
|
}
|
|
465
475
|
|
|
466
|
-
if (analysis.framework?.name === "nextjs"
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
level: "info",
|
|
482
|
-
message: "Next.js config missing output: \"standalone\"",
|
|
483
|
-
detail: "Standalone builds are recommended for smaller Docker images.",
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (analysis.framework?.name === "nextjs" && analysis.dockerfile.exists) {
|
|
490
|
-
const nextConfigPath = join(dir, "next.config.ts");
|
|
491
|
-
const nextConfigJsPath = join(dir, "next.config.js");
|
|
492
|
-
const nextConfigMjsPath = join(dir, "next.config.mjs");
|
|
493
|
-
const configPath = existsSync(nextConfigPath)
|
|
494
|
-
? nextConfigPath
|
|
495
|
-
: existsSync(nextConfigJsPath)
|
|
496
|
-
? nextConfigJsPath
|
|
497
|
-
: existsSync(nextConfigMjsPath)
|
|
498
|
-
? nextConfigMjsPath
|
|
499
|
-
: null;
|
|
500
|
-
if (configPath) {
|
|
501
|
-
const content = readFileSync(configPath, "utf8");
|
|
502
|
-
if (!/output\s*:\s*["']standalone["']/.test(content)) {
|
|
503
|
-
checks.push({
|
|
504
|
-
level: "warning",
|
|
505
|
-
message: "Next.js output missing standalone build config",
|
|
506
|
-
detail: "Dockerfile expects .next/standalone; add output: \"standalone\".",
|
|
507
|
-
});
|
|
508
|
-
}
|
|
476
|
+
if (analysis.framework?.name === "nextjs") {
|
|
477
|
+
const output = analysis.frameworkOutput;
|
|
478
|
+
if (output?.mode === "export") {
|
|
479
|
+
checks.push({
|
|
480
|
+
level: "warning",
|
|
481
|
+
message: "Next.js output is set to export",
|
|
482
|
+
detail: "Uplink hosting expects standalone output or a static-export Dockerfile.",
|
|
483
|
+
});
|
|
484
|
+
} else if (output?.mode === "unknown") {
|
|
485
|
+
const level = analysis.dockerfile.exists ? "warning" : "info";
|
|
486
|
+
checks.push({
|
|
487
|
+
level,
|
|
488
|
+
message: "Next.js output mode not detected",
|
|
489
|
+
detail: "Set output: \"standalone\" in next.config.* for server hosting.",
|
|
490
|
+
});
|
|
509
491
|
}
|
|
510
492
|
}
|
|
511
493
|
|
|
@@ -578,6 +560,7 @@ function detectHealthChecks(dir: string, analysis: AnalysisResult): HealthCheck[
|
|
|
578
560
|
|
|
579
561
|
export function analyzeProject(dir: string): AnalysisResult {
|
|
580
562
|
const framework = detectFramework(dir);
|
|
563
|
+
const frameworkOutput = detectFrameworkOutput(dir, framework);
|
|
581
564
|
const packageManager = detectPackageManager(dir);
|
|
582
565
|
const database = detectDatabase(dir);
|
|
583
566
|
const storage = detectStorage(dir);
|
|
@@ -603,6 +586,7 @@ export function analyzeProject(dir: string): AnalysisResult {
|
|
|
603
586
|
|
|
604
587
|
const analysis: AnalysisResult = {
|
|
605
588
|
framework,
|
|
589
|
+
frameworkOutput,
|
|
606
590
|
packageManager,
|
|
607
591
|
database,
|
|
608
592
|
storage,
|