uplink-cli 0.1.38 → 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 +46 -47
- 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 +3 -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/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 +3 -3
- 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/launchDomainking.ts +64 -0
- package/docs/AGENTS.md +113 -147
- 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
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
2
4
|
import { resolveProjectRoot } from "../../../utils/project-root";
|
|
3
5
|
|
|
4
6
|
export type TunnelClient = { pid: number; port: number; token: string };
|
|
5
7
|
|
|
8
|
+
export function resolveTunnelClientPath(): string {
|
|
9
|
+
const projectRoot = resolveProjectRoot(__dirname);
|
|
10
|
+
const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
|
|
11
|
+
if (!existsSync(clientPath)) {
|
|
12
|
+
throw new Error(`Tunnel client not found at ${clientPath}`);
|
|
13
|
+
}
|
|
14
|
+
return clientPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Start the local tunnel client in the background (detached). */
|
|
18
|
+
export function startTunnelClient(opts: {
|
|
19
|
+
token: string;
|
|
20
|
+
port: number;
|
|
21
|
+
ctrl?: string;
|
|
22
|
+
}): { pid: number; clientPath: string } {
|
|
23
|
+
const projectRoot = resolveProjectRoot(__dirname);
|
|
24
|
+
const clientPath = resolveTunnelClientPath();
|
|
25
|
+
const ctrl = opts.ctrl || process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
|
|
26
|
+
const clientProcess = spawn(
|
|
27
|
+
"node",
|
|
28
|
+
[clientPath, "--token", opts.token, "--port", String(opts.port), "--ctrl", ctrl],
|
|
29
|
+
{
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
detached: true,
|
|
32
|
+
cwd: projectRoot,
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
clientProcess.unref();
|
|
36
|
+
if (!clientProcess.pid) {
|
|
37
|
+
throw new Error("Failed to start tunnel client process");
|
|
38
|
+
}
|
|
39
|
+
return { pid: clientProcess.pid, clientPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export function findTunnelClients(): TunnelClient[] {
|
|
7
43
|
try {
|
|
8
44
|
// Find processes running client-improved.js (current user, match script path to avoid false positives)
|
|
@@ -39,11 +75,23 @@ export function findTunnelClients(): TunnelClient[] {
|
|
|
39
75
|
|
|
40
76
|
export function killTunnelClient(pid: number): boolean {
|
|
41
77
|
try {
|
|
42
|
-
|
|
43
|
-
return true;
|
|
78
|
+
process.kill(pid, "SIGTERM");
|
|
44
79
|
} catch {
|
|
45
80
|
return false;
|
|
46
81
|
}
|
|
82
|
+
try {
|
|
83
|
+
execSync(`kill -0 ${pid} && sleep 0.4 && kill -KILL ${pid} || true`, {
|
|
84
|
+
stdio: "ignore",
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
/* process already gone */
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return false;
|
|
92
|
+
} catch {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
47
95
|
}
|
|
48
96
|
|
|
49
97
|
export function killAllTunnelClients(clients: TunnelClient[]): number {
|
|
@@ -54,8 +102,44 @@ export function killAllTunnelClients(clients: TunnelClient[]): number {
|
|
|
54
102
|
return killed;
|
|
55
103
|
}
|
|
56
104
|
|
|
105
|
+
type ApiTunnel = { id?: string; token?: string; connected?: boolean };
|
|
106
|
+
|
|
57
107
|
type ApiRequest = (method: string, path: string, body?: unknown) => Promise<any>;
|
|
58
108
|
|
|
109
|
+
export async function stopTunnelClients(
|
|
110
|
+
apiRequest: ApiRequest,
|
|
111
|
+
clients: TunnelClient[],
|
|
112
|
+
opts: { connectedGhosts?: boolean } = {}
|
|
113
|
+
): Promise<{ killed: number; deleted: number }> {
|
|
114
|
+
const tokens = new Set(clients.map((c) => c.token));
|
|
115
|
+
let deleted = 0;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await apiRequest("GET", "/v1/tunnels");
|
|
119
|
+
const tunnels = (result.tunnels || []) as ApiTunnel[];
|
|
120
|
+
for (const tunnel of tunnels) {
|
|
121
|
+
if (!tunnel.id) continue;
|
|
122
|
+
const matched = Boolean(tunnel.token && tokens.has(tunnel.token));
|
|
123
|
+
const ghost = Boolean(opts.connectedGhosts && tunnel.connected);
|
|
124
|
+
if (!matched && !ghost) continue;
|
|
125
|
+
try {
|
|
126
|
+
await apiRequest("DELETE", `/v1/tunnels/${tunnel.id}`);
|
|
127
|
+
deleted++;
|
|
128
|
+
} catch {
|
|
129
|
+
/* keep stopping the rest */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
/* still kill local processes */
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let killed = 0;
|
|
137
|
+
for (const c of clients) {
|
|
138
|
+
if (killTunnelClient(c.pid)) killed++;
|
|
139
|
+
}
|
|
140
|
+
return { killed, deleted };
|
|
141
|
+
}
|
|
142
|
+
|
|
59
143
|
/**
|
|
60
144
|
* Create a tunnel via API and start the local client in background.
|
|
61
145
|
* NOTE: Maintains existing behavior including the brief post-spawn delay.
|
|
@@ -79,19 +163,8 @@ export async function createAndStartTunnel(apiRequest: ApiRequest, port: number)
|
|
|
79
163
|
const url = result.url || "(no url)";
|
|
80
164
|
const token = result.token || "(no token)";
|
|
81
165
|
const alias = result.alias || null;
|
|
82
|
-
const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
|
|
83
166
|
|
|
84
|
-
|
|
85
|
-
// (CommonJS build: __dirname available)
|
|
86
|
-
const path = require("path");
|
|
87
|
-
const projectRoot = resolveProjectRoot(__dirname);
|
|
88
|
-
const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
|
|
89
|
-
const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
|
|
90
|
-
stdio: "ignore",
|
|
91
|
-
detached: true,
|
|
92
|
-
cwd: projectRoot,
|
|
93
|
-
});
|
|
94
|
-
clientProcess.unref();
|
|
167
|
+
startTunnelClient({ token, port });
|
|
95
168
|
|
|
96
169
|
// Wait a moment for client to connect
|
|
97
170
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
@@ -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,199 @@
|
|
|
1
|
+
import type { SelectOption } from "../inline-tree-select";
|
|
2
|
+
import type { MenuChoice } from "../types";
|
|
3
|
+
import { launchDomainking } from "../../../utils/launchDomainking";
|
|
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
|
+
];
|
|
22
|
+
|
|
23
|
+
async function pickHostedApp(
|
|
24
|
+
deps: Deps,
|
|
25
|
+
title: string
|
|
26
|
+
): Promise<{ name: string; id: string; url?: string } | null> {
|
|
27
|
+
const output = runCliCapture(["host", "list"]);
|
|
28
|
+
if (!output || output.includes("No apps found")) {
|
|
29
|
+
deps.restoreRawMode();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const apps = parseHostedApps(output);
|
|
33
|
+
if (apps.length === 0) {
|
|
34
|
+
deps.restoreRawMode();
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const options: SelectOption[] = apps.map((app) => ({
|
|
38
|
+
label: `${app.name}${app.url ? ` ${app.url}` : ""}`,
|
|
39
|
+
value: app.id,
|
|
40
|
+
}));
|
|
41
|
+
const choice = await deps.inlineSelect(title, options, true);
|
|
42
|
+
if (choice === null) {
|
|
43
|
+
deps.restoreRawMode();
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return apps.find((app) => app.id === choice.value) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
50
|
+
const { restoreRawMode, promptLine } = deps;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
label: "Domains",
|
|
54
|
+
subMenu: [
|
|
55
|
+
{
|
|
56
|
+
label: "My domains",
|
|
57
|
+
action: async () => {
|
|
58
|
+
try {
|
|
59
|
+
const output = runCliCapture(["domains", "list"]);
|
|
60
|
+
restoreRawMode();
|
|
61
|
+
return output || "No domains. Connect a registrar first.";
|
|
62
|
+
} catch (error) {
|
|
63
|
+
restoreRawMode();
|
|
64
|
+
return error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
label: "Connect registrar",
|
|
70
|
+
action: async () => {
|
|
71
|
+
const choice = await deps.inlineSelect("Which registrar?", PROVIDER_OPTIONS, true);
|
|
72
|
+
if (choice === null || typeof choice.value !== "string") {
|
|
73
|
+
restoreRawMode();
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
const provider = choice.value;
|
|
77
|
+
const extraEnv: Record<string, string> = {};
|
|
78
|
+
const args = ["domains", "providers", "connect", provider, "--token-env", "UPLINK_CONNECT_TOKEN"];
|
|
79
|
+
if (provider === "namecheap") {
|
|
80
|
+
const user = (await promptLine("Namecheap API user (or back): ")).trim();
|
|
81
|
+
if (!user || user === "back") {
|
|
82
|
+
restoreRawMode();
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
const key = (await promptLine("Namecheap API key (or back): ")).trim();
|
|
86
|
+
if (!key || key === "back") {
|
|
87
|
+
restoreRawMode();
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
extraEnv.UPLINK_CONNECT_TOKEN = key;
|
|
91
|
+
extraEnv.UPLINK_CONNECT_USER = user;
|
|
92
|
+
args.push("--user-env", "UPLINK_CONNECT_USER");
|
|
93
|
+
} else {
|
|
94
|
+
const token = (
|
|
95
|
+
await promptLine(
|
|
96
|
+
`${PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider} API token (or back): `
|
|
97
|
+
)
|
|
98
|
+
).trim();
|
|
99
|
+
if (!token || token === "back") {
|
|
100
|
+
restoreRawMode();
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
extraEnv.UPLINK_CONNECT_TOKEN = token;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
runCli(args, extraEnv);
|
|
107
|
+
restoreRawMode();
|
|
108
|
+
return `Connected ${provider}.`;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
restoreRawMode();
|
|
111
|
+
return error instanceof Error ? error.message : String(error);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: "Find a domain",
|
|
117
|
+
action: async () => {
|
|
118
|
+
restoreRawMode();
|
|
119
|
+
return launchDomainking();
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: "Attach to app",
|
|
124
|
+
action: async () => {
|
|
125
|
+
const app = await pickHostedApp(deps, "Attach domain to which app?");
|
|
126
|
+
if (!app) return "No hosted apps. Deploy one under Host first.";
|
|
127
|
+
const hostname = (await promptLine("Hostname (e.g. example.com, or back): ")).trim().toLowerCase();
|
|
128
|
+
if (!hostname || hostname === "back") {
|
|
129
|
+
restoreRawMode();
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
runCli(["host", "domains", "add", "--id", app.id, "--hostname", hostname]);
|
|
133
|
+
restoreRawMode();
|
|
134
|
+
return `Attached ${hostname} to ${app.name}. Point DNS, then Verify.`;
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
label: "Verify DNS",
|
|
139
|
+
action: async () => {
|
|
140
|
+
const app = await pickHostedApp(deps, "Verify a domain on which app?");
|
|
141
|
+
if (!app) return "No hosted apps.";
|
|
142
|
+
const hostname = (await promptLine("Hostname to verify (or back): ")).trim().toLowerCase();
|
|
143
|
+
if (!hostname || hostname === "back") {
|
|
144
|
+
restoreRawMode();
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
runCli(["host", "domains", "verify", "--id", app.id, "--hostname", hostname]);
|
|
148
|
+
restoreRawMode();
|
|
149
|
+
return `Checked ${hostname} on ${app.name}.`;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: "List on app",
|
|
154
|
+
action: async () => {
|
|
155
|
+
const app = await pickHostedApp(deps, "List domains for which app?");
|
|
156
|
+
if (!app) return "No hosted apps.";
|
|
157
|
+
const output = runCliCapture(["host", "domains", "list", "--id", app.id]);
|
|
158
|
+
restoreRawMode();
|
|
159
|
+
return `${app.name}\n${output || "No custom domains attached."}`;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
label: "Detach from app",
|
|
164
|
+
action: async () => {
|
|
165
|
+
const app = await pickHostedApp(deps, "Detach a domain from which app?");
|
|
166
|
+
if (!app) return "No hosted apps.";
|
|
167
|
+
const hostname = (await promptLine("Hostname to detach (or back): ")).trim().toLowerCase();
|
|
168
|
+
if (!hostname || hostname === "back") {
|
|
169
|
+
restoreRawMode();
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
runCli(["host", "domains", "remove", "--id", app.id, "--hostname", hostname]);
|
|
173
|
+
restoreRawMode();
|
|
174
|
+
return `Detached ${hostname} from ${app.name}.`;
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
label: "Help",
|
|
179
|
+
action: async () => {
|
|
180
|
+
return [
|
|
181
|
+
"Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
|
|
182
|
+
"",
|
|
183
|
+
" My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap",
|
|
184
|
+
" Connect — save a registrar token (same as the CLI)",
|
|
185
|
+
" Find — search names that are not yours yet",
|
|
186
|
+
" Attach — bind a hostname to a hosted app",
|
|
187
|
+
" Verify — check DNS points at the hosting edge, then TLS",
|
|
188
|
+
"",
|
|
189
|
+
"CLI (agents):",
|
|
190
|
+
" uplink domains providers connect godaddy --token-env GODADDY_PAT --json",
|
|
191
|
+
" uplink domains list --json",
|
|
192
|
+
" uplink domains check example.com --json",
|
|
193
|
+
" uplink host domains add --id <app> --hostname example.com --json",
|
|
194
|
+
].join("\n");
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -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",
|