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
@@ -14,9 +14,13 @@ type Deps = {
14
14
  ) => Promise<{ index: number; value: string | number | null } | null>;
15
15
  };
16
16
 
17
- type HostedApp = { name: string; id: string; url?: string };
17
+ export type HostedApp = { name: string; id: string; url?: string };
18
18
 
19
- function parseHostedApps(output: string): HostedApp[] {
19
+ function appOptionLabel(app: HostedApp): string {
20
+ return app.url ? `${app.name} ${app.url}` : app.name;
21
+ }
22
+
23
+ export function parseHostedApps(output: string): HostedApp[] {
20
24
  const lines = output.split("\n");
21
25
  const apps: HostedApp[] = [];
22
26
  for (let i = 0; i < lines.length; i += 1) {
@@ -47,7 +51,7 @@ async function resolvePath(promptLine: Deps["promptLine"]): Promise<string | nul
47
51
  }
48
52
  }
49
53
 
50
- function runCli(args: string[]): void {
54
+ export function runCli(args: string[], extraEnv?: Record<string, string>): void {
51
55
  try {
52
56
  process.stdin.setRawMode(false);
53
57
  } catch {
@@ -57,6 +61,7 @@ function runCli(args: string[]): void {
57
61
  const cmd = cliBin ? [cliBin, ...args] : [process.argv[1], ...args];
58
62
  const result = spawnSync(process.execPath, cmd, {
59
63
  stdio: "inherit",
64
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
60
65
  });
61
66
  if (result.error) throw result.error;
62
67
  if (result.status && result.status !== 0) {
@@ -64,7 +69,7 @@ function runCli(args: string[]): void {
64
69
  }
65
70
  }
66
71
 
67
- function runCliCapture(args: string[]): string {
72
+ export function runCliCapture(args: string[], extraEnv?: Record<string, string>): string {
68
73
  try {
69
74
  process.stdin.setRawMode(false);
70
75
  } catch {
@@ -75,6 +80,7 @@ function runCliCapture(args: string[]): string {
75
80
  const result = spawnSync(process.execPath, cmd, {
76
81
  stdio: ["ignore", "pipe", "pipe"],
77
82
  encoding: "utf8",
83
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
78
84
  });
79
85
  if (result.error) throw result.error;
80
86
  if (result.status && result.status !== 0) {
@@ -120,7 +126,7 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
120
126
  return "No apps found. Use Setup Wizard to create one first.";
121
127
  }
122
128
  const options: SelectOption[] = apps.map((app) => ({
123
- label: `${app.name} (${app.id})`,
129
+ label: appOptionLabel(app),
124
130
  value: app.id,
125
131
  }));
126
132
  const choice = await inlineSelect("Select app to deploy to", options, true);
@@ -160,45 +166,6 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
160
166
  return "Analysis complete.";
161
167
  },
162
168
  },
163
- {
164
- label: "List Hosted Apps",
165
- action: async () => {
166
- const output = runCliCapture(["host", "list"]);
167
- if (!output || output.includes("No apps found")) {
168
- restoreRawMode();
169
- return "No apps found.";
170
- }
171
- const apps = parseHostedApps(output);
172
- if (apps.length === 0) {
173
- restoreRawMode();
174
- return "No apps found.";
175
- }
176
- const options: SelectOption[] = apps.map((app) => ({
177
- label: `${app.name} (${app.id})${app.url ? ` ${app.url}` : ""}`,
178
- value: app.id,
179
- }));
180
- const choice = await inlineSelect("Hosted apps", options, true);
181
- if (choice === null) {
182
- restoreRawMode();
183
- return "";
184
- }
185
- const selected = apps.find((app) => app.id === choice.value);
186
- if (!selected) {
187
- restoreRawMode();
188
- return "Invalid selection.";
189
- }
190
- restoreRawMode();
191
- return [
192
- `App: ${selected.name}`,
193
- `ID: ${selected.id}`,
194
- selected.url ? `URL: ${selected.url}` : "",
195
- "",
196
- "Commands:",
197
- ` uplink host status --id ${selected.id}`,
198
- ` uplink host logs --id ${selected.id}`,
199
- ].join("\n");
200
- },
201
- },
202
169
  {
203
170
  label: "Delete Hosted App",
204
171
  action: async () => {
@@ -213,7 +180,7 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
213
180
  return "No apps found.";
214
181
  }
215
182
  const options: SelectOption[] = apps.map((app) => ({
216
- label: `${app.name} (${app.id})`,
183
+ label: appOptionLabel(app),
217
184
  value: app.id,
218
185
  }));
219
186
  const choice = await inlineSelect("Select app to delete", options, true);
@@ -251,8 +218,9 @@ export function buildHostingMenu(deps: Deps): MenuChoice {
251
218
  " Setup Wizard - First-time setup: creates Dockerfile, config, app, and deploys",
252
219
  " Deploy - Redeploy to an existing app (faster, skips setup)",
253
220
  " Analyze - Check project for deployment readiness",
254
- " List Apps - Show your deployed apps",
221
+ " Apps - Arrow through apps to inspect url, status, and size",
255
222
  " Delete App - Remove an app and optionally its data",
223
+ " Custom domains - also under the top-level Domains menu",
256
224
  "",
257
225
  "CLI commands:",
258
226
  " uplink host setup --name <app> --path <path> # Full setup + deploy",
@@ -4,4 +4,5 @@ export { buildManageTokensMenu } from "./tokens";
4
4
  export { buildManageTunnelsMenu } from "./tunnels";
5
5
  export { buildManageAliasesMenu } from "./aliases";
6
6
  export { buildHostingMenu } from "./hosting";
7
+ export { buildDomainsMenu } from "./domains";
7
8
 
@@ -16,8 +16,10 @@ type Deps = {
16
16
  scanCommonPorts: () => Promise<number[]>;
17
17
  findTunnelClients: () => Array<{ pid: number; port: number; token: string }>;
18
18
  createAndStartTunnel: (port: number) => Promise<string>;
19
- killTunnelClient: (pid: number) => boolean;
20
- killAllTunnelClients: (clients: Array<{ pid: number; port: number; token: string }>) => number;
19
+ stopTunnelClients: (
20
+ clients: Array<{ pid: number; port: number; token: string }>,
21
+ opts?: { connectedGhosts?: boolean }
22
+ ) => Promise<{ killed: number; deleted: number }>;
21
23
  colorDim: (text: string) => string;
22
24
  colorRed: (text: string) => string;
23
25
  };
@@ -33,8 +35,7 @@ export function buildManageTunnelsMenu(deps: Deps): MenuChoice {
33
35
  scanCommonPorts,
34
36
  findTunnelClients,
35
37
  createAndStartTunnel,
36
- killTunnelClient,
37
- killAllTunnelClients,
38
+ stopTunnelClients,
38
39
  colorDim,
39
40
  colorRed,
40
41
  } = deps;
@@ -43,7 +44,7 @@ export function buildManageTunnelsMenu(deps: Deps): MenuChoice {
43
44
  label: "Share",
44
45
  subMenu: [
45
46
  {
46
- label: "Start (Auto)",
47
+ label: "Start tunnel",
47
48
  action: async () => {
48
49
  try {
49
50
  // Scan for active ports
@@ -142,65 +143,25 @@ export function buildManageTunnelsMenu(deps: Deps): MenuChoice {
142
143
  },
143
144
  },
144
145
  {
145
- label: "Start (Manual)",
146
+ label: "Stop tunnel",
146
147
  action: async () => {
147
- const answer = await promptLine('Local port to expose (default 3000, or "back"): ');
148
- if (isBackInput(answer)) {
149
- restoreRawMode();
150
- return "";
151
- }
152
- const port = Number(answer) || 3000;
153
148
  try {
154
- const result = await apiRequest("POST", "/v1/tunnels", { port });
155
- try {
156
- process.stdin.setRawMode(true);
157
- process.stdin.resume();
158
- } catch {
159
- /* ignore */
160
- }
161
- const url = result.url || "(no url)";
162
- const token = result.token || "(no token)";
163
- const httpFallback = typeof url === "string" && url.startsWith("https://") ? url.replace(/^https:\/\//, "http://") : "";
164
- return [
165
- `Created tunnel: ${url}`,
166
- httpFallback && url !== httpFallback ? `HTTP fallback: ${httpFallback}` : "",
167
- `Token: ${token}`,
168
- "",
169
- "To start the tunnel client, run:",
170
- ` node scripts/tunnel/client-improved.js --token ${token} --port ${port} --ctrl ${process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071"}`,
171
- ]
172
- .filter(Boolean)
173
- .join("\n");
174
- } catch (err: any) {
175
- try {
176
- process.stdin.setRawMode(true);
177
- process.stdin.resume();
178
- } catch {
179
- /* ignore */
180
- }
181
- throw err;
182
- }
183
- },
184
- },
185
- {
186
- label: "Stop Tunnel",
187
- action: async () => {
188
- try {
189
- // Find running tunnel client processes
190
149
  const processes = findTunnelClients();
191
150
 
192
151
  if (processes.length === 0) {
152
+ const ghost = await stopTunnelClients([], { connectedGhosts: true });
193
153
  restoreRawMode();
154
+ if (ghost.deleted > 0) {
155
+ return `✓ Removed ${ghost.deleted} relay-connected tunnel${ghost.deleted !== 1 ? "s" : ""} with no local client`;
156
+ }
194
157
  return "No running tunnel clients found.";
195
158
  }
196
159
 
197
- // Build options from running tunnels
198
160
  const options: SelectOption[] = processes.map((p) => ({
199
161
  label: `Port ${p.port} ${colorDim(`(${truncate(p.token, 8)})`)}`,
200
162
  value: p.pid,
201
163
  }));
202
164
 
203
- // Add "Stop all" option if more than one tunnel
204
165
  if (processes.length > 1) {
205
166
  options.push({ label: colorRed("Stop all tunnels"), value: "all" });
206
167
  }
@@ -208,28 +169,25 @@ export function buildManageTunnelsMenu(deps: Deps): MenuChoice {
208
169
  const result = await inlineSelect("Select tunnel to stop", options, true);
209
170
 
210
171
  if (result === null) {
211
- // User selected Back
212
172
  restoreRawMode();
213
- return ""; // Return empty to go back without message
173
+ return "";
214
174
  }
215
175
 
216
- let killed = 0;
217
- if (result.value === "all") {
218
- // Kill all
219
- killed = killAllTunnelClients(processes);
220
- } else {
221
- // Kill specific client
222
- const pid = result.value as number;
223
- const ok = killTunnelClient(pid);
224
- if (!ok) {
225
- restoreRawMode();
226
- throw new Error(`Failed to kill process ${pid}`);
227
- }
228
- killed = 1;
229
- }
176
+ const selected =
177
+ result.value === "all"
178
+ ? processes
179
+ : processes.filter((p) => {
180
+ const pid = result.value as number;
181
+ const chosen = processes.find((c) => c.pid === pid);
182
+ return p.pid === pid || (chosen && p.token === chosen.token);
183
+ });
230
184
 
185
+ const { killed, deleted } = await stopTunnelClients(selected);
231
186
  restoreRawMode();
232
- return `✓ Stopped ${killed} tunnel client${killed !== 1 ? "s" : ""}`;
187
+ if (killed === 0 && deleted === 0) {
188
+ throw new Error("Failed to stop tunnel");
189
+ }
190
+ return `✓ Stopped ${killed} local client${killed !== 1 ? "s" : ""}, removed ${deleted} tunnel record${deleted !== 1 ? "s" : ""}`;
233
191
  } catch (err: any) {
234
192
  restoreRawMode();
235
193
  throw err;
@@ -1,5 +1,5 @@
1
1
  import { clearScreen } from "./io";
2
- import { colorBold, colorCyan, colorDim, colorGreen, colorRed } from "./colors";
2
+ import { colorBold, colorDim, colorGreen, colorRed } from "./colors";
3
3
  import { DEFAULT_MENU_MESSAGE, type MenuChoice } from "./types";
4
4
 
5
5
  export type RenderArgs = {
@@ -107,7 +107,7 @@ export function renderMenu(args: RenderArgs) {
107
107
  const styledLine = line
108
108
  .replace(/^✓/, colorGreen("✓"))
109
109
  .replace(/^✗/, colorRed("✗"))
110
- .replace(/^→/, colorCyan("→"));
110
+ .replace(/^→/, colorDim("→"));
111
111
  console.log(styledLine);
112
112
  });
113
113
  }
@@ -1,6 +1,6 @@
1
1
  import fetch from "node-fetch";
2
2
  import { spawn } from "child_process";
3
- import { resolveProjectRoot } from "../../../utils/project-root";
3
+ import { resolveProjectRoot } from "../../utils/project-root";
4
4
  import { getResolvedApiBase, getResolvedApiToken } from "../../utils/api-base";
5
5
 
6
6
  export function runSmoke(script: "smoke:tunnel" | "smoke:db" | "smoke:all" | "test:comprehensive") {
@@ -1,99 +1,10 @@
1
- import { spawn, execSync } from "child_process";
2
- import { join } from "path";
3
- import { apiRequest } from "../../http";
4
- import { resolveProjectRoot } from "../../../utils/project-root";
5
-
6
- export async function createAndStartTunnel(port: number): Promise<string> {
7
- // Check if tunnel already running on this port
8
- const existing = findTunnelClients().filter(c => c.port === port);
9
- if (existing.length > 0) {
10
- return [
11
- `⚠ Tunnel already running on port ${port}`,
12
- ``,
13
- `→ PID: ${existing[0].pid}`,
14
- `→ Token: ${existing[0].token.substring(0, 8)}...`,
15
- ``,
16
- `Use "Stop Tunnel" first to disconnect the existing tunnel.`,
17
- ].join("\n");
18
- }
19
-
20
- const result = await apiRequest("POST", "/v1/tunnels", { port });
21
- const url = result.url || "(no url)";
22
- const token = result.token || "(no token)";
23
- const alias = result.alias || null;
24
- const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
25
-
26
- const path = require("path");
27
- const projectRoot = resolveProjectRoot(__dirname);
28
- const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
29
- const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
30
- stdio: "ignore",
31
- detached: true,
32
- cwd: projectRoot,
33
- });
34
- clientProcess.unref();
35
-
36
- await new Promise((resolve) => setTimeout(resolve, 2000));
37
-
38
- try {
39
- process.stdin.setRawMode(true);
40
- process.stdin.resume();
41
- } catch {
42
- /* ignore */
43
- }
44
-
45
- const lines = [
46
- `✓ Tunnel created and client started`,
47
- ``,
48
- `→ Public URL ${url}`,
49
- ];
50
-
51
- if (alias) {
52
- // Use aliasUrl from backend if available, otherwise construct it
53
- const aliasUrl = result.aliasUrl || `https://${alias}.uplink.spot`;
54
- lines.push(`→ Alias ${alias}`);
55
- lines.push(`→ Alias URL ${aliasUrl}`);
56
- }
57
-
58
- lines.push(
59
- `→ Token ${token}`,
60
- `→ Local port ${port}`,
61
- ``,
62
- `Tunnel client running in background.`,
63
- `Use "Stop Tunnel" to disconnect.`,
64
- );
65
-
66
- return lines.join("\n");
67
- }
68
-
69
- export function findTunnelClients(): Array<{ pid: number; port: number; token: string }> {
70
- try {
71
- const user = process.env.USER || "";
72
- const psCmd = user ? `ps -u ${user} -o pid=,command=` : "ps -eo pid=,command=";
73
- const output = execSync(psCmd, { encoding: "utf-8" });
74
- const lines = output
75
- .trim()
76
- .split("\n")
77
- .filter((line) => line.includes("scripts/tunnel/client-improved.js"));
78
-
79
- const clients: Array<{ pid: number; port: number; token: string }> = [];
80
-
81
- for (const line of lines) {
82
- const pidMatch = line.match(/^\s*(\d+)/);
83
- const tokenMatch = line.match(/--token\s+(\S+)/);
84
- const portMatch = line.match(/--port\s+(\d+)/);
85
-
86
- if (pidMatch && tokenMatch && portMatch) {
87
- clients.push({
88
- pid: parseInt(pidMatch[1], 10),
89
- port: parseInt(portMatch[1], 10),
90
- token: tokenMatch[1],
91
- });
92
- }
93
- }
94
-
95
- return clients;
96
- } catch {
97
- return [];
98
- }
99
- }
1
+ /** @deprecated Prefer `./effects/tunnel-clients` kept as a thin re-export. */
2
+ export {
3
+ createAndStartTunnel,
4
+ findTunnelClients,
5
+ killAllTunnelClients,
6
+ killTunnelClient,
7
+ startTunnelClient,
8
+ stopTunnelClients,
9
+ resolveTunnelClientPath,
10
+ } from "./effects/tunnel-clients";
@@ -1,7 +1,15 @@
1
+ export type MenuInspect = {
2
+ kind: "app";
3
+ id: string;
4
+ url?: string;
5
+ createdAt?: string;
6
+ };
7
+
1
8
  export type MenuChoice = {
2
9
  label: string;
3
10
  action?: () => Promise<string>;
4
11
  subMenu?: MenuChoice[];
12
+ inspect?: MenuInspect;
5
13
  };
6
14
 
7
15
  export const DEFAULT_MENU_MESSAGE = "Use ↑/↓ and Enter. ← to go back. Ctrl+C to quit.";