uplink-cli 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +161 -0
- package/LICENSE +21 -0
- package/README.md +45 -44
- package/cli/src/index.ts +5 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/godaddy.ts +99 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +30 -0
- package/cli/src/registrars/namecheap.ts +163 -0
- package/cli/src/registrars/secret.ts +66 -0
- package/cli/src/registrars/store.ts +55 -0
- package/cli/src/registrars/types.ts +40 -0
- package/cli/src/subcommands/admin.ts +17 -30
- package/cli/src/subcommands/db.ts +63 -57
- package/cli/src/subcommands/dev.ts +23 -25
- package/cli/src/subcommands/domains.ts +268 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +106 -32
- package/cli/src/subcommands/menu/colors.ts +1 -1
- package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
- package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
- package/cli/src/subcommands/menu/io.ts +27 -5
- package/cli/src/subcommands/menu/menus/domains.ts +199 -0
- package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
- package/cli/src/subcommands/menu/menus/index.ts +1 -0
- package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
- package/cli/src/subcommands/menu/render.ts +2 -2
- package/cli/src/subcommands/menu/tests.ts +1 -1
- package/cli/src/subcommands/menu/tunnels.ts +10 -99
- package/cli/src/subcommands/menu/types.ts +8 -0
- package/cli/src/subcommands/menu.ts +32 -524
- package/cli/src/subcommands/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +124 -33
- package/cli/src/templates/index.ts +122 -0
- package/cli/src/tui/App.tsx +197 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +59 -0
- package/cli/src/tui/brand.tsx +20 -0
- package/cli/src/tui/format.ts +22 -0
- package/cli/src/tui/index.mts +6 -0
- package/cli/src/tui/liveTree.ts +40 -0
- package/cli/src/tui/package.json +3 -0
- package/cli/src/tui/runMenu.tsx +57 -0
- package/cli/src/tui/session.mts +382 -0
- package/cli/src/tui/snapshot.ts +146 -0
- package/cli/src/utils/analyze.ts +27 -43
- package/cli/src/utils/framework-output.ts +171 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/docs/AGENTS.md +113 -146
- package/docs/MENU_STRUCTURE.md +56 -288
- package/docs/README.md +6 -6
- package/package.json +18 -35
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- package/assets/cli-screenshot.png +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { apiRequest } from "../http";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
3
4
|
|
|
4
5
|
export const dbCommand = new Command("db").description("Manage databases");
|
|
5
6
|
|
|
@@ -13,19 +14,20 @@ dbCommand
|
|
|
13
14
|
.option("--plan <plan>", "Plan", "dev")
|
|
14
15
|
.option("--json", "Output JSON", false)
|
|
15
16
|
.action(async (opts) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
try {
|
|
18
|
+
const body = {
|
|
19
|
+
name: opts.name,
|
|
20
|
+
project: opts.project,
|
|
21
|
+
provider: opts.provider,
|
|
22
|
+
region: opts.region,
|
|
23
|
+
plan: opts.plan,
|
|
24
|
+
};
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
console.log(
|
|
27
|
-
}
|
|
28
|
-
|
|
26
|
+
const result = await apiRequest("POST", "/v1/dbs", body);
|
|
27
|
+
if (opts.json) printJson(result);
|
|
28
|
+
else console.log(`Created DB ${result.name} (${result.id}) in ${result.region}`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
handleError(error, { json: opts.json });
|
|
29
31
|
}
|
|
30
32
|
});
|
|
31
33
|
|
|
@@ -35,16 +37,20 @@ dbCommand
|
|
|
35
37
|
.option("--project <project>", "Project name")
|
|
36
38
|
.option("--json", "Output JSON", false)
|
|
37
39
|
.action(async (opts) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
try {
|
|
41
|
+
const query = opts.project ? `?project=${encodeURIComponent(opts.project)}` : "";
|
|
42
|
+
const result = await apiRequest("GET", `/v1/dbs${query}`);
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
printJson(result);
|
|
45
|
+
} else {
|
|
46
|
+
for (const db of result.items || []) {
|
|
47
|
+
console.log(
|
|
48
|
+
`${db.id} ${db.name} ${db.region} ${db.status} ready=${db.ready}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
47
51
|
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
handleError(error, { json: opts.json });
|
|
48
54
|
}
|
|
49
55
|
});
|
|
50
56
|
|
|
@@ -54,14 +60,18 @@ dbCommand
|
|
|
54
60
|
.requiredOption("--id <id>", "Database id")
|
|
55
61
|
.option("--json", "Output JSON", false)
|
|
56
62
|
.action(async (opts) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
try {
|
|
64
|
+
const result = await apiRequest("GET", `/v1/dbs/${opts.id}`);
|
|
65
|
+
if (opts.json) {
|
|
66
|
+
printJson(result);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`DB ${result.name} (${result.id})`);
|
|
69
|
+
console.log(` region: ${result.region}`);
|
|
70
|
+
console.log(` status: ${result.status}`);
|
|
71
|
+
console.log(` engine: ${result.engine} ${result.version}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
handleError(error, { json: opts.json });
|
|
65
75
|
}
|
|
66
76
|
});
|
|
67
77
|
|
|
@@ -72,14 +82,15 @@ dbCommand
|
|
|
72
82
|
.option("--yes", "Confirm deletion", false)
|
|
73
83
|
.option("--json", "Output JSON", false)
|
|
74
84
|
.action(async (opts) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
try {
|
|
86
|
+
if (!opts.yes) {
|
|
87
|
+
throw new Error("Refusing to delete without --yes");
|
|
88
|
+
}
|
|
89
|
+
const result = await apiRequest("DELETE", `/v1/dbs/${opts.id}`);
|
|
90
|
+
if (opts.json) printJson(result);
|
|
91
|
+
else console.log(`Deleted DB ${result.id}`);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
handleError(error, { json: opts.json });
|
|
83
94
|
}
|
|
84
95
|
});
|
|
85
96
|
|
|
@@ -91,27 +102,22 @@ dbCommand
|
|
|
91
102
|
.requiredOption("--env-var <envVar>", "Environment variable name")
|
|
92
103
|
.option("--json", "Output JSON", false)
|
|
93
104
|
.action(async (opts) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
try {
|
|
106
|
+
const body = {
|
|
107
|
+
service: opts.service,
|
|
108
|
+
envVar: opts.envVar,
|
|
109
|
+
};
|
|
98
110
|
|
|
99
|
-
|
|
100
|
-
"POST",
|
|
101
|
-
`/v1/dbs/${opts["db-id"]}/link-service`,
|
|
102
|
-
body
|
|
103
|
-
);
|
|
111
|
+
const result = await apiRequest("POST", `/v1/dbs/${opts.dbId}/link-service`, body);
|
|
104
112
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
if (opts.json) {
|
|
114
|
+
printJson(result);
|
|
115
|
+
} else {
|
|
116
|
+
console.log(
|
|
117
|
+
`Linked DB ${result.dbId} to service ${result.service} as ${result.envVar}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
handleError(error, { json: opts.json });
|
|
111
122
|
}
|
|
112
123
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
3
|
import { apiRequest } from "../http";
|
|
4
|
-
import
|
|
4
|
+
import { handleError, printJson } from "../utils/machine";
|
|
5
|
+
import { resolveTunnelClientPath } from "./menu/effects/tunnel-clients";
|
|
5
6
|
|
|
6
7
|
export const devCommand = new Command("dev")
|
|
7
|
-
.description("Run local
|
|
8
|
-
.option("--tunnel", "Enable tunnel")
|
|
8
|
+
.description("Run local tunnel client against a new tunnel (foreground)")
|
|
9
|
+
.option("--tunnel", "Enable tunnel", false)
|
|
9
10
|
.option("--port <port>", "Local port to expose", "3000")
|
|
10
11
|
.option("--json", "Output JSON", false)
|
|
11
|
-
.option("--improved", "Use improved client with auto-reconnect and better error handling", false)
|
|
12
12
|
.action(async (opts) => {
|
|
13
13
|
const port = Number(opts.port);
|
|
14
|
-
if (
|
|
15
|
-
|
|
14
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
15
|
+
console.error("Invalid port. Provide a positive integer.");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!opts.tunnel) {
|
|
20
|
+
console.log("Tunnel not enabled. Provide --tunnel to expose localhost.");
|
|
21
|
+
console.log("Prefer: uplink tunnel create --port <port> --json");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
16
26
|
const result = await apiRequest("POST", "/v1/tunnels", { port });
|
|
17
27
|
if (opts.json) {
|
|
18
|
-
|
|
28
|
+
printJson(result);
|
|
19
29
|
} else {
|
|
20
30
|
console.log(`Tunnel URL: ${result.url}`);
|
|
21
|
-
// If the control-plane returns an https URL for the dev.uplink.spot domain,
|
|
22
|
-
// it may not actually be reachable unless TLS is configured on the relay.
|
|
23
|
-
// Print an HTTP fallback to reduce confusion during development.
|
|
24
31
|
if (
|
|
25
32
|
typeof result.url === "string" &&
|
|
26
33
|
result.url.startsWith("https://") &&
|
|
@@ -30,14 +37,7 @@ export const devCommand = new Command("dev")
|
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
const clientFile = opts.improved ? "client-improved.js" : "client.js";
|
|
35
|
-
const clientPath = path.join(
|
|
36
|
-
process.cwd(),
|
|
37
|
-
"scripts",
|
|
38
|
-
"tunnel",
|
|
39
|
-
clientFile
|
|
40
|
-
);
|
|
40
|
+
const clientPath = resolveTunnelClientPath();
|
|
41
41
|
const ctrlHost = process.env.TUNNEL_CTRL ?? "tunnel.uplink.spot:7071";
|
|
42
42
|
const args = [
|
|
43
43
|
clientPath,
|
|
@@ -48,10 +48,11 @@ export const devCommand = new Command("dev")
|
|
|
48
48
|
"--ctrl",
|
|
49
49
|
ctrlHost,
|
|
50
50
|
];
|
|
51
|
-
|
|
51
|
+
if (!opts.json) {
|
|
52
|
+
console.log(`Starting tunnel client: node ${args.join(" ")}`);
|
|
53
|
+
}
|
|
52
54
|
const child = spawn("node", args, { stdio: "inherit" });
|
|
53
55
|
|
|
54
|
-
// Forward Ctrl+C / termination to the child so the tunnel shuts down cleanly.
|
|
55
56
|
const shutdown = () => {
|
|
56
57
|
try {
|
|
57
58
|
child.kill("SIGINT");
|
|
@@ -62,7 +63,6 @@ export const devCommand = new Command("dev")
|
|
|
62
63
|
process.on("SIGINT", shutdown);
|
|
63
64
|
process.on("SIGTERM", shutdown);
|
|
64
65
|
|
|
65
|
-
// Keep the CLI process alive while the tunnel client is running.
|
|
66
66
|
await new Promise<void>((resolve) => {
|
|
67
67
|
child.on("exit", (code, signal) => {
|
|
68
68
|
process.off("SIGINT", shutdown);
|
|
@@ -78,9 +78,7 @@ export const devCommand = new Command("dev")
|
|
|
78
78
|
resolve();
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
|
-
}
|
|
82
|
-
|
|
81
|
+
} catch (error) {
|
|
82
|
+
handleError(error, { json: opts.json });
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
|
-
|
|
86
|
-
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { launchDomainking } from "../utils/launchDomainking";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
import {
|
|
5
|
+
adapters,
|
|
6
|
+
getAdapter,
|
|
7
|
+
isProviderId,
|
|
8
|
+
readRegistrarStore,
|
|
9
|
+
removeProvider,
|
|
10
|
+
saveProvider,
|
|
11
|
+
type DomainQuote,
|
|
12
|
+
type InventoryDomain,
|
|
13
|
+
type ProviderId,
|
|
14
|
+
type RegistrarCredentials,
|
|
15
|
+
} from "../registrars";
|
|
16
|
+
import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
|
|
17
|
+
|
|
18
|
+
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap"];
|
|
19
|
+
|
|
20
|
+
function parseProvider(raw?: string): ProviderId | undefined {
|
|
21
|
+
if (!raw) return undefined;
|
|
22
|
+
const id = raw.toLowerCase();
|
|
23
|
+
if (!isProviderId(id)) throw new Error(`Unknown provider: ${raw}`);
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function credentialsFromFlags(
|
|
28
|
+
provider: ProviderId,
|
|
29
|
+
opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; json?: boolean }
|
|
30
|
+
): Promise<RegistrarCredentials> {
|
|
31
|
+
const interactive = canPrompt() && !opts.json;
|
|
32
|
+
|
|
33
|
+
if (provider === "namecheap") {
|
|
34
|
+
const apiKey = opts.tokenEnv
|
|
35
|
+
? readEnvValue(opts.tokenEnv)
|
|
36
|
+
: interactive
|
|
37
|
+
? await promptSecret("Namecheap API key: ")
|
|
38
|
+
: "";
|
|
39
|
+
const apiUser = opts.userEnv
|
|
40
|
+
? readEnvValue(opts.userEnv)
|
|
41
|
+
: interactive
|
|
42
|
+
? await promptSecret("Namecheap API user: ")
|
|
43
|
+
: "";
|
|
44
|
+
if (!apiKey || !apiUser) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Namecheap needs --token-env NAMECHEAP_API_KEY and --user-env NAMECHEAP_API_USER"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return { apiKey, apiUser };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const token = opts.tokenEnv
|
|
53
|
+
? readEnvValue(opts.tokenEnv)
|
|
54
|
+
: interactive
|
|
55
|
+
? await promptSecret(`${provider} API token: `)
|
|
56
|
+
: "";
|
|
57
|
+
if (!token) {
|
|
58
|
+
throw new Error(`${provider} needs --token-env <VAR> (do not pass the secret on the command line)`);
|
|
59
|
+
}
|
|
60
|
+
const creds: RegistrarCredentials = { token };
|
|
61
|
+
if (opts.accountEnv) creds.accountId = readEnvValue(opts.accountEnv);
|
|
62
|
+
return creds;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
|
|
66
|
+
const store = readRegistrarStore();
|
|
67
|
+
const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
|
|
68
|
+
const domains: InventoryDomain[] = [];
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const creds = store[id];
|
|
72
|
+
if (!creds) {
|
|
73
|
+
if (providerFilter) throw new Error(`${id} is not connected`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const listed = await getAdapter(id).listDomains(creds);
|
|
78
|
+
domains.push(...listed);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
errors.push(`${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (domains.length === 0 && errors.length > 0) {
|
|
84
|
+
throw new Error(errors.join("; "));
|
|
85
|
+
}
|
|
86
|
+
return domains.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function quoteDomain(domain: string, providerFilter?: ProviderId): Promise<DomainQuote> {
|
|
90
|
+
const store = readRegistrarStore();
|
|
91
|
+
const inventory = await listInventory(providerFilter).catch(() => [] as InventoryDomain[]);
|
|
92
|
+
const owned = inventory.find((item) => item.domain === domain);
|
|
93
|
+
if (owned) {
|
|
94
|
+
return { domain, provider: owned.provider, status: "owned", buyable: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ids = providerFilter
|
|
98
|
+
? [providerFilter]
|
|
99
|
+
: CHECK_ORDER.filter((id) => store[id]);
|
|
100
|
+
if (ids.length === 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"No registrar connected. Run: uplink domains providers connect godaddy --token-env GODADDY_PAT --json"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let lastError: string | undefined;
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
const creds = store[id];
|
|
109
|
+
if (!creds) throw new Error(`${id} is not connected`);
|
|
110
|
+
try {
|
|
111
|
+
return await getAdapter(id).check(creds, domain);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
domain,
|
|
118
|
+
provider: ids[0],
|
|
119
|
+
status: "unknown",
|
|
120
|
+
error: lastError || "check failed",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const domainsCommand = new Command("domains").description(
|
|
125
|
+
"Registrar inventory, availability, and search. Attach with `uplink host domains`."
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
domainsCommand.addHelpText(
|
|
129
|
+
"after",
|
|
130
|
+
"\nWith no subcommand, opens the domain search TUI.\n"
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
domainsCommand.action(() => {
|
|
134
|
+
const message = launchDomainking();
|
|
135
|
+
if (message) console.log(message);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
domainsCommand
|
|
139
|
+
.command("list")
|
|
140
|
+
.description("List domains owned at connected registrars")
|
|
141
|
+
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap)")
|
|
142
|
+
.option("--json", "Output JSON", false)
|
|
143
|
+
.action(async (opts) => {
|
|
144
|
+
try {
|
|
145
|
+
const provider = parseProvider(opts.provider);
|
|
146
|
+
const domains = await listInventory(provider);
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
printJson({ domains, count: domains.length });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (domains.length === 0) {
|
|
152
|
+
console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const item of domains) {
|
|
156
|
+
const expiry = item.expiresAt ? ` expires ${item.expiresAt}` : "";
|
|
157
|
+
console.log(`- ${item.domain} (${item.provider}${expiry})`);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
handleError(error, { json: opts.json });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
domainsCommand
|
|
165
|
+
.command("check")
|
|
166
|
+
.description("Ask a connected registrar if a domain is buyable, and at what price")
|
|
167
|
+
.argument("<domain>", "Domain name (e.g. example.com)")
|
|
168
|
+
.option("--provider <id>", "Which registrar to ask")
|
|
169
|
+
.option("--json", "Output JSON", false)
|
|
170
|
+
.action(async (domainArg: string, opts) => {
|
|
171
|
+
try {
|
|
172
|
+
const domain = String(domainArg).trim().toLowerCase();
|
|
173
|
+
if (!domain.includes(".")) throw new Error("Pass a full domain like example.com");
|
|
174
|
+
const provider = parseProvider(opts.provider);
|
|
175
|
+
const quote = await quoteDomain(domain, provider);
|
|
176
|
+
if (opts.json) {
|
|
177
|
+
printJson(quote);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const price = quote.priceUsd != null ? ` $${quote.priceUsd.toFixed(2)}/yr` : "";
|
|
181
|
+
const premium = quote.premium ? " premium" : "";
|
|
182
|
+
console.log(`${quote.domain} ${quote.status}${price}${premium} [${quote.provider}]`);
|
|
183
|
+
if (quote.error) console.log(` ${quote.error}`);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
handleError(error, { json: opts.json });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const providers = domainsCommand.command("providers").description("Connect registrar accounts");
|
|
190
|
+
|
|
191
|
+
providers
|
|
192
|
+
.command("list")
|
|
193
|
+
.description("Show which registrars are connected (never prints secrets)")
|
|
194
|
+
.option("--json", "Output JSON", false)
|
|
195
|
+
.action((opts) => {
|
|
196
|
+
try {
|
|
197
|
+
const store = readRegistrarStore();
|
|
198
|
+
const items = adapters.map((adapter) => ({
|
|
199
|
+
id: adapter.id,
|
|
200
|
+
label: adapter.label,
|
|
201
|
+
connected: Boolean(store[adapter.id]),
|
|
202
|
+
help: adapter.connectHelp,
|
|
203
|
+
}));
|
|
204
|
+
if (opts.json) {
|
|
205
|
+
printJson({ providers: items });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const item of items) {
|
|
209
|
+
console.log(`- ${item.id} ${item.connected ? "connected" : "not connected"}`);
|
|
210
|
+
if (!item.connected) console.log(` ${item.help}`);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
handleError(error, { json: opts.json });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
providers
|
|
218
|
+
.command("connect")
|
|
219
|
+
.description("Save a registrar credential after a live check")
|
|
220
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
|
|
221
|
+
.option("--token-env <name>", "Env var holding the API token or Namecheap API key")
|
|
222
|
+
.option("--user-env <name>", "Env var holding the Namecheap API user")
|
|
223
|
+
.option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
|
|
224
|
+
.option("--json", "Output JSON", false)
|
|
225
|
+
.action(async (providerArg: string, opts) => {
|
|
226
|
+
try {
|
|
227
|
+
const provider = String(providerArg).toLowerCase();
|
|
228
|
+
if (!isProviderId(provider)) {
|
|
229
|
+
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, or namecheap`);
|
|
230
|
+
}
|
|
231
|
+
const adapter = getAdapter(provider);
|
|
232
|
+
const creds = await credentialsFromFlags(provider, opts);
|
|
233
|
+
const verified = await adapter.verify(creds);
|
|
234
|
+
saveProvider(provider, verified);
|
|
235
|
+
const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
|
|
236
|
+
if (opts.json) {
|
|
237
|
+
printJson({
|
|
238
|
+
provider,
|
|
239
|
+
connected: true,
|
|
240
|
+
domains: listed.length,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
console.log(`Connected ${adapter.label} (${listed.length} domain${listed.length === 1 ? "" : "s"})`);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
handleError(error, { json: opts.json });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
providers
|
|
251
|
+
.command("disconnect")
|
|
252
|
+
.description("Remove a saved registrar credential")
|
|
253
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
|
|
254
|
+
.option("--json", "Output JSON", false)
|
|
255
|
+
.action((providerArg: string, opts) => {
|
|
256
|
+
try {
|
|
257
|
+
const provider = String(providerArg).toLowerCase();
|
|
258
|
+
if (!isProviderId(provider)) throw new Error(`Unknown provider: ${providerArg}`);
|
|
259
|
+
const removed = removeProvider(provider);
|
|
260
|
+
if (opts.json) {
|
|
261
|
+
printJson({ provider, connected: false, removed });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
console.log(removed ? `Disconnected ${provider}` : `${provider} was not connected`);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
handleError(error, { json: opts.json });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { apiRequest } from "../http";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
|
|
5
|
+
type AppDomain = {
|
|
6
|
+
id: string;
|
|
7
|
+
hostname: string;
|
|
8
|
+
verified: boolean;
|
|
9
|
+
verifiedAt: string | null;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
dns?: { type: string; host: string; target: string };
|
|
12
|
+
};
|
|
13
|
+
type AppDomainList = { domains: AppDomain[] };
|
|
14
|
+
|
|
15
|
+
async function findDomainByHostname(appId: string, hostname: string): Promise<AppDomain | null> {
|
|
16
|
+
const result = (await apiRequest("GET", `/v1/apps/${appId}/domains`)) as AppDomainList;
|
|
17
|
+
const clean = hostname.trim().toLowerCase();
|
|
18
|
+
return result.domains?.find((d) => d.hostname === clean) ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printDomain(domain: AppDomain): void {
|
|
22
|
+
const status = domain.verified ? "verified" : "pending verification";
|
|
23
|
+
console.log(`- ${domain.hostname} (${status})`);
|
|
24
|
+
if (domain.dns) {
|
|
25
|
+
console.log(` DNS needed: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The verify endpoint responds 409 with a reason while DNS doesn't point at the edge. */
|
|
30
|
+
function extractVerifyFailure(error: unknown): { reason?: string; dns?: AppDomain["dns"] } | null {
|
|
31
|
+
if (!(error instanceof Error)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(error.message) as { reason?: string; dns?: AppDomain["dns"] };
|
|
34
|
+
return parsed && (parsed.reason || parsed.dns) ? parsed : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const domainsCommand = new Command("domains").description(
|
|
41
|
+
"Manage custom domains for hosted apps"
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
domainsCommand
|
|
45
|
+
.command("list")
|
|
46
|
+
.description("List custom domains attached to an app")
|
|
47
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
48
|
+
.option("--json", "Output JSON", false)
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = (await apiRequest("GET", `/v1/apps/${opts.id}/domains`)) as AppDomainList;
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
printJson(result);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!result.domains || result.domains.length === 0) {
|
|
57
|
+
console.log("No custom domains attached.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
console.log("Custom domains:");
|
|
61
|
+
for (const domain of result.domains) printDomain(domain);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
handleError(error, { json: opts.json });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
domainsCommand
|
|
68
|
+
.command("add")
|
|
69
|
+
.description("Attach a custom domain to an app")
|
|
70
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
71
|
+
.requiredOption("--hostname <hostname>", "Domain to attach (e.g. example.com)")
|
|
72
|
+
.option("--json", "Output JSON", false)
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
try {
|
|
75
|
+
const domain = (await apiRequest("POST", `/v1/apps/${opts.id}/domains`, {
|
|
76
|
+
hostname: opts.hostname,
|
|
77
|
+
})) as AppDomain;
|
|
78
|
+
if (opts.json) {
|
|
79
|
+
printJson(domain);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.log(`Attached ${domain.hostname} (pending verification)`);
|
|
83
|
+
if (domain.dns) {
|
|
84
|
+
console.log(` 1. At your registrar: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(` 2. Then run: uplink host domains verify --id ${opts.id} --hostname ${domain.hostname}`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
handleError(error, { json: opts.json });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
domainsCommand
|
|
93
|
+
.command("verify")
|
|
94
|
+
.description("Verify a domain's DNS points at the hosting edge (enables routing + TLS)")
|
|
95
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
96
|
+
.requiredOption("--hostname <hostname>", "Domain to verify")
|
|
97
|
+
.option("--json", "Output JSON", false)
|
|
98
|
+
.action(async (opts) => {
|
|
99
|
+
try {
|
|
100
|
+
const domain = await findDomainByHostname(opts.id, opts.hostname);
|
|
101
|
+
if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const verified = (await apiRequest(
|
|
105
|
+
"POST",
|
|
106
|
+
`/v1/apps/${opts.id}/domains/${domain.id}/verify`
|
|
107
|
+
)) as AppDomain;
|
|
108
|
+
if (opts.json) printJson(verified);
|
|
109
|
+
else {
|
|
110
|
+
console.log(`Verified ${verified.hostname}`);
|
|
111
|
+
console.log(` Live at https://${verified.hostname} (cert issues on first request)`);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const failure = extractVerifyFailure(error);
|
|
115
|
+
if (!failure) throw error;
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
printJson({ hostname: domain.hostname, verified: false, ...failure });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
console.log(`Not verified yet: ${failure.reason || "DNS does not point at the edge"}`);
|
|
121
|
+
if (failure.dns) {
|
|
122
|
+
console.log(` DNS needed: ${failure.dns.type} ${failure.dns.host} -> ${failure.dns.target}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(" DNS changes can take a few minutes to propagate; try again shortly.");
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
handleError(error, { json: opts.json });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
domainsCommand
|
|
133
|
+
.command("remove")
|
|
134
|
+
.description("Detach a custom domain from an app")
|
|
135
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
136
|
+
.requiredOption("--hostname <hostname>", "Domain to detach")
|
|
137
|
+
.option("--json", "Output JSON", false)
|
|
138
|
+
.action(async (opts) => {
|
|
139
|
+
try {
|
|
140
|
+
const domain = await findDomainByHostname(opts.id, opts.hostname);
|
|
141
|
+
if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
|
|
142
|
+
const result = await apiRequest("DELETE", `/v1/apps/${opts.id}/domains/${domain.id}`);
|
|
143
|
+
if (opts.json) printJson(result);
|
|
144
|
+
else console.log(`Detached ${opts.hostname}`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
handleError(error, { json: opts.json });
|
|
147
|
+
}
|
|
148
|
+
});
|