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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
35
|
+
branchColor = colorBold(branch);
|
|
36
36
|
if (opt.label === "Back") {
|
|
37
37
|
label = colorDim(opt.label);
|
|
38
38
|
} else {
|
|
39
|
-
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 ?
|
|
62
|
-
const label = idx === 0 ?
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
"
|
|
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",
|
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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 "";
|
|
173
|
+
return "";
|
|
214
174
|
}
|
|
215
175
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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,
|
|
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(/^→/,
|
|
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(
|
|
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 "
|
|
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") {
|