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.
Files changed (67) hide show
  1. package/AGENTS.md +177 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +72 -52
  5. package/cli/src/index.ts +16 -3
  6. package/cli/src/registrars/cloudflare.ts +148 -0
  7. package/cli/src/registrars/dreamhost.ts +129 -0
  8. package/cli/src/registrars/godaddy.ts +105 -0
  9. package/cli/src/registrars/hostinger.ts +106 -0
  10. package/cli/src/registrars/http.ts +18 -0
  11. package/cli/src/registrars/index.ts +32 -0
  12. package/cli/src/registrars/namecheap.ts +163 -0
  13. package/cli/src/registrars/secret.ts +66 -0
  14. package/cli/src/registrars/store.ts +55 -0
  15. package/cli/src/registrars/types.ts +42 -0
  16. package/cli/src/subcommands/admin.ts +17 -30
  17. package/cli/src/subcommands/db.ts +63 -57
  18. package/cli/src/subcommands/dev.ts +23 -25
  19. package/cli/src/subcommands/domains.ts +295 -0
  20. package/cli/src/subcommands/host-domains.ts +148 -0
  21. package/cli/src/subcommands/host.ts +3 -0
  22. package/cli/src/subcommands/login.ts +85 -0
  23. package/cli/src/subcommands/menu/colors.ts +1 -1
  24. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  25. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  26. package/cli/src/subcommands/menu/io.ts +27 -5
  27. package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
  28. package/cli/src/subcommands/menu/menus/domains.ts +197 -0
  29. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  30. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  31. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  32. package/cli/src/subcommands/menu/render.ts +2 -2
  33. package/cli/src/subcommands/menu/requests.ts +9 -2
  34. package/cli/src/subcommands/menu/tests.ts +1 -1
  35. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  36. package/cli/src/subcommands/menu/types.ts +8 -0
  37. package/cli/src/subcommands/menu.ts +32 -524
  38. package/cli/src/subcommands/signup.ts +2 -2
  39. package/cli/src/subcommands/system.ts +58 -36
  40. package/cli/src/subcommands/tunnel.ts +126 -33
  41. package/cli/src/templates/index.ts +3 -3
  42. package/cli/src/tui/App.tsx +202 -0
  43. package/cli/src/tui/AppInspector.tsx +114 -0
  44. package/cli/src/tui/HomeStatus.tsx +92 -0
  45. package/cli/src/tui/brand.tsx +20 -0
  46. package/cli/src/tui/format.ts +22 -0
  47. package/cli/src/tui/index.mts +6 -0
  48. package/cli/src/tui/liveTree.ts +40 -0
  49. package/cli/src/tui/package.json +3 -0
  50. package/cli/src/tui/runMenu.tsx +57 -0
  51. package/cli/src/tui/session.mts +267 -0
  52. package/cli/src/tui/snapshot.ts +175 -0
  53. package/cli/src/utils/api-base.ts +11 -0
  54. package/cli/src/utils/credentials.ts +58 -0
  55. package/cli/src/utils/domain-availability.ts +56 -0
  56. package/cli/src/utils/guest-access.ts +38 -0
  57. package/cli/src/utils/launchDomainking.ts +64 -0
  58. package/cli/src/utils/login-flow.ts +57 -0
  59. package/docs/AGENTS.md +130 -148
  60. package/docs/HOSTING.md +55 -0
  61. package/docs/MENU_STRUCTURE.md +60 -288
  62. package/docs/PRODUCT.md +64 -0
  63. package/docs/README.md +11 -7
  64. package/package.json +22 -36
  65. package/scripts/tunnel/client-improved.js +127 -38
  66. package/scripts/tunnel/client.js +118 -0
  67. package/assets/cli-screenshot.png +0 -0
@@ -1,4 +1,4 @@
1
- import { colorCyan, colorDim } from "./colors";
1
+ import { colorBold, colorDim } from "./colors";
2
2
 
3
3
  // Inline arrow-key selector (returns selected option, or null for "Back")
4
4
  export type SelectOption = { label: string; value: string | number | null };
@@ -32,11 +32,11 @@ export async function inlineSelect(
32
32
  let branchColor: string;
33
33
 
34
34
  if (isSelected) {
35
- branchColor = colorCyan(branch);
35
+ branchColor = colorBold(branch);
36
36
  if (opt.label === "Back") {
37
37
  label = colorDim(opt.label);
38
38
  } else {
39
- label = colorCyan(opt.label);
39
+ label = colorBold(opt.label);
40
40
  }
41
41
  } else {
42
42
  branchColor = colorDim(branch);
@@ -58,13 +58,14 @@ export async function inlineSelect(
58
58
  allOptions.forEach((opt, idx) => {
59
59
  const isLast = idx === allOptions.length - 1;
60
60
  const branch = isLast ? "└─" : "├─";
61
- const branchColor = idx === 0 ? colorCyan(branch) : colorDim(branch);
62
- const label = idx === 0 ? colorCyan(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
61
+ const branchColor = idx === 0 ? colorBold(branch) : colorDim(branch);
62
+ const label = idx === 0 ? colorBold(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
63
63
  console.log(`${branchColor} ${label}`);
64
64
  });
65
65
 
66
66
  // Set up key handler
67
67
  try {
68
+ process.stdin.ref();
68
69
  process.stdin.setRawMode(true);
69
70
  process.stdin.resume();
70
71
  } catch {
@@ -10,13 +10,35 @@ function stylePrompt(question: string): string {
10
10
  .replace(backTokenRegex, (match) => colorBold(match));
11
11
  }
12
12
 
13
+ export function prepareStdinForPrompt(): void {
14
+ try {
15
+ process.stdin.setRawMode(false);
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ process.stdin.ref();
20
+ process.stdin.resume();
21
+ process.stdin.setEncoding("utf8");
22
+ drainStdin();
23
+ }
24
+
25
+ /** Drop a leftover Enter from Ink so readline does not auto-answer the next prompt. */
26
+ function drainStdin(): void {
27
+ const stdin = process.stdin as NodeJS.ReadStream & { read?: () => unknown };
28
+ if (typeof stdin.read !== "function") return;
29
+ try {
30
+ let chunk: unknown;
31
+ while ((chunk = stdin.read()) !== null) {
32
+ void chunk;
33
+ }
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
13
39
  export function promptLine(question: string): Promise<string> {
14
40
  return new Promise((resolve) => {
15
- try {
16
- process.stdin.setRawMode(false);
17
- } catch {
18
- /* ignore */
19
- }
41
+ prepareStdinForPrompt();
20
42
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21
43
  rl.question(stylePrompt(question), (answer) => {
22
44
  rl.close();
@@ -0,0 +1,34 @@
1
+ import { launchDomainking, resolveDomainkingEntry } from "../../../utils/launchDomainking";
2
+ import { runCliCapture } from "./hosting";
3
+
4
+ type Deps = {
5
+ promptLine: (question: string) => Promise<string>;
6
+ restoreRawMode: () => void;
7
+ };
8
+
9
+ /**
10
+ * Shared "find a domain" action: the Domainking search TUI when it is
11
+ * available, otherwise an inline prompt against `uplink domains check`
12
+ * (which works without a registrar via public DNS/RDAP).
13
+ */
14
+ export function buildFindDomainAction(deps: Deps): () => Promise<string> {
15
+ return async () => {
16
+ if (resolveDomainkingEntry()) {
17
+ deps.restoreRawMode();
18
+ return launchDomainking();
19
+ }
20
+
21
+ const domain = (await deps.promptLine("Domain to check (e.g. example.com, or back): "))
22
+ .trim()
23
+ .toLowerCase();
24
+ deps.restoreRawMode();
25
+ if (!domain || domain === "back") return "";
26
+ if (!domain.includes(".")) return "Pass a full domain like example.com";
27
+
28
+ try {
29
+ return runCliCapture(["domains", "check", domain]) || `${domain}: no result.`;
30
+ } catch (error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,197 @@
1
+ import type { SelectOption } from "../inline-tree-select";
2
+ import type { MenuChoice } from "../types";
3
+ import { buildFindDomainAction } from "./domain-check";
4
+ import { parseHostedApps, runCli, runCliCapture } from "./hosting";
5
+
6
+ type Deps = {
7
+ promptLine: (question: string) => Promise<string>;
8
+ restoreRawMode: () => void;
9
+ inlineSelect: (
10
+ title: string,
11
+ options: SelectOption[],
12
+ includeBack?: boolean
13
+ ) => Promise<{ index: number; value: string | number | null } | null>;
14
+ };
15
+
16
+ const PROVIDER_OPTIONS: SelectOption[] = [
17
+ { label: "GoDaddy", value: "godaddy" },
18
+ { label: "Cloudflare", value: "cloudflare" },
19
+ { label: "Hostinger", value: "hostinger" },
20
+ { label: "Namecheap", value: "namecheap" },
21
+ { label: "DreamHost", value: "dreamhost" },
22
+ ];
23
+
24
+ async function pickHostedApp(
25
+ deps: Deps,
26
+ title: string
27
+ ): Promise<{ name: string; id: string; url?: string } | null> {
28
+ const output = runCliCapture(["host", "list"]);
29
+ if (!output || output.includes("No apps found")) {
30
+ deps.restoreRawMode();
31
+ return null;
32
+ }
33
+ const apps = parseHostedApps(output);
34
+ if (apps.length === 0) {
35
+ deps.restoreRawMode();
36
+ return null;
37
+ }
38
+ const options: SelectOption[] = apps.map((app) => ({
39
+ label: `${app.name}${app.url ? ` ${app.url}` : ""}`,
40
+ value: app.id,
41
+ }));
42
+ const choice = await deps.inlineSelect(title, options, true);
43
+ if (choice === null) {
44
+ deps.restoreRawMode();
45
+ return null;
46
+ }
47
+ return apps.find((app) => app.id === choice.value) ?? null;
48
+ }
49
+
50
+ export function buildDomainsMenu(deps: Deps): MenuChoice {
51
+ const { restoreRawMode, promptLine } = deps;
52
+
53
+ return {
54
+ label: "Domains",
55
+ subMenu: [
56
+ {
57
+ label: "My domains",
58
+ action: async () => {
59
+ try {
60
+ const output = runCliCapture(["domains", "list"]);
61
+ restoreRawMode();
62
+ return output || "No domains. Connect a registrar first.";
63
+ } catch (error) {
64
+ restoreRawMode();
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+ },
68
+ },
69
+ {
70
+ label: "Connect registrar",
71
+ action: async () => {
72
+ const choice = await deps.inlineSelect("Which registrar?", PROVIDER_OPTIONS, true);
73
+ if (choice === null || typeof choice.value !== "string") {
74
+ restoreRawMode();
75
+ return "";
76
+ }
77
+ const provider = choice.value;
78
+ const extraEnv: Record<string, string> = {};
79
+ const args = ["domains", "providers", "connect", provider, "--token-env", "UPLINK_CONNECT_TOKEN"];
80
+ if (provider === "namecheap") {
81
+ const user = (await promptLine("Namecheap API user (or back): ")).trim();
82
+ if (!user || user === "back") {
83
+ restoreRawMode();
84
+ return "";
85
+ }
86
+ const key = (await promptLine("Namecheap API key (or back): ")).trim();
87
+ if (!key || key === "back") {
88
+ restoreRawMode();
89
+ return "";
90
+ }
91
+ extraEnv.UPLINK_CONNECT_TOKEN = key;
92
+ extraEnv.UPLINK_CONNECT_USER = user;
93
+ args.push("--user-env", "UPLINK_CONNECT_USER");
94
+ } else {
95
+ const token = (
96
+ await promptLine(
97
+ `${PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider} API token (or back): `
98
+ )
99
+ ).trim();
100
+ if (!token || token === "back") {
101
+ restoreRawMode();
102
+ return "";
103
+ }
104
+ extraEnv.UPLINK_CONNECT_TOKEN = token;
105
+ }
106
+ try {
107
+ runCli(args, extraEnv);
108
+ restoreRawMode();
109
+ return `Connected ${provider}.`;
110
+ } catch (error) {
111
+ restoreRawMode();
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
114
+ },
115
+ },
116
+ {
117
+ label: "Find a domain",
118
+ action: buildFindDomainAction(deps),
119
+ },
120
+ {
121
+ label: "Attach to app",
122
+ action: async () => {
123
+ const app = await pickHostedApp(deps, "Attach domain to which app?");
124
+ if (!app) return "No hosted apps. Deploy one under Host first.";
125
+ const hostname = (await promptLine("Hostname (e.g. example.com, or back): ")).trim().toLowerCase();
126
+ if (!hostname || hostname === "back") {
127
+ restoreRawMode();
128
+ return "";
129
+ }
130
+ runCli(["host", "domains", "add", "--id", app.id, "--hostname", hostname]);
131
+ restoreRawMode();
132
+ return `Attached ${hostname} to ${app.name}. Point DNS, then Verify.`;
133
+ },
134
+ },
135
+ {
136
+ label: "Verify DNS",
137
+ action: async () => {
138
+ const app = await pickHostedApp(deps, "Verify a domain on which app?");
139
+ if (!app) return "No hosted apps.";
140
+ const hostname = (await promptLine("Hostname to verify (or back): ")).trim().toLowerCase();
141
+ if (!hostname || hostname === "back") {
142
+ restoreRawMode();
143
+ return "";
144
+ }
145
+ runCli(["host", "domains", "verify", "--id", app.id, "--hostname", hostname]);
146
+ restoreRawMode();
147
+ return `Checked ${hostname} on ${app.name}.`;
148
+ },
149
+ },
150
+ {
151
+ label: "List on app",
152
+ action: async () => {
153
+ const app = await pickHostedApp(deps, "List domains for which app?");
154
+ if (!app) return "No hosted apps.";
155
+ const output = runCliCapture(["host", "domains", "list", "--id", app.id]);
156
+ restoreRawMode();
157
+ return `${app.name}\n${output || "No custom domains attached."}`;
158
+ },
159
+ },
160
+ {
161
+ label: "Detach from app",
162
+ action: async () => {
163
+ const app = await pickHostedApp(deps, "Detach a domain from which app?");
164
+ if (!app) return "No hosted apps.";
165
+ const hostname = (await promptLine("Hostname to detach (or back): ")).trim().toLowerCase();
166
+ if (!hostname || hostname === "back") {
167
+ restoreRawMode();
168
+ return "";
169
+ }
170
+ runCli(["host", "domains", "remove", "--id", app.id, "--hostname", hostname]);
171
+ restoreRawMode();
172
+ return `Detached ${hostname} from ${app.name}.`;
173
+ },
174
+ },
175
+ {
176
+ label: "Help",
177
+ action: async () => {
178
+ return [
179
+ "Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
180
+ "",
181
+ " My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap / DreamHost",
182
+ " Connect — save a registrar token (same as the CLI)",
183
+ " Find — search names that are not yours yet",
184
+ " Attach — bind a hostname to a hosted app",
185
+ " Verify — check DNS points at the hosting edge, then TLS",
186
+ "",
187
+ "CLI (agents):",
188
+ " uplink domains providers connect godaddy --token-env GODADDY_PAT --json",
189
+ " uplink domains list --json",
190
+ " uplink domains check example.com --json",
191
+ " uplink host domains add --id <app> --hostname example.com --json",
192
+ ].join("\n");
193
+ },
194
+ },
195
+ ],
196
+ };
197
+ }
@@ -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,12 +1,19 @@
1
1
  import fetch from "node-fetch";
2
- import { getResolvedApiBase } from "../../utils/api-base";
2
+ import { getResolvedApiBase, getResolvedApiToken } from "../../utils/api-base";
3
3
 
4
- export async function unauthenticatedRequest(method: string, path: string, body?: unknown): Promise<any> {
4
+ export async function unauthenticatedRequest(
5
+ method: string,
6
+ path: string,
7
+ body?: unknown,
8
+ options: { includeCurrentToken?: boolean } = {}
9
+ ): Promise<any> {
5
10
  const apiBase = getResolvedApiBase();
11
+ const token = options.includeCurrentToken ? getResolvedApiToken(apiBase) : undefined;
6
12
  const response = await fetch(`${apiBase}${path}`, {
7
13
  method,
8
14
  headers: {
9
15
  "Content-Type": "application/json",
16
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
10
17
  },
11
18
  body: body ? JSON.stringify(body) : undefined,
12
19
  });
@@ -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") {