uplink-cli 0.2.2 → 0.2.6
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 +18 -2
- package/CHANGELOG.md +18 -0
- package/README.md +5 -2
- package/cli/bin/uplink.js +3 -3
- package/cli/src/index.ts +3 -0
- package/cli/src/registrars/cpanel.ts +119 -0
- package/cli/src/registrars/index.ts +2 -0
- package/cli/src/registrars/namecheap-purchase.ts +219 -0
- package/cli/src/registrars/types.ts +3 -1
- package/cli/src/subcommands/domains.ts +328 -16
- package/cli/src/subcommands/menu/menus/domain-check.ts +9 -3
- package/cli/src/subcommands/menu/menus/domains.ts +24 -4
- package/cli/src/subcommands/menu.ts +2 -26
- package/cli/src/subcommands/upgrade.ts +62 -0
- package/cli/src/tui/DomainSearch.tsx +427 -27
- package/cli/src/tui/domain-search.mts +10 -0
- package/cli/src/tui/use-terminal-mouse.ts +60 -0
- package/cli/src/utils/open-browser.ts +16 -0
- package/cli/src/utils/registrant-contact.ts +64 -0
- package/cli/src/utils/run-esm.ts +56 -0
- package/docs/AGENTS.md +18 -2
- package/docs/PRODUCT.md +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -111,7 +111,10 @@ Notes:
|
|
|
111
111
|
|
|
112
112
|
## Custom domains
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
Uplink is a hub for domains and hosting spread across providers: registrar inventory
|
|
115
|
+
(GoDaddy, Cloudflare, Hostinger, Namecheap, DreamHost) and hosted domains from any
|
|
116
|
+
**cPanel** account (Namecheap shared, Bluehost, HostGator, …) land in one `domains list`.
|
|
117
|
+
Attach/verify is under `host domains`.
|
|
115
118
|
The bare `uplink domains` command opens Find a domain. Agents should use JSON:
|
|
116
119
|
|
|
117
120
|
```bash
|
|
@@ -123,6 +126,7 @@ uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
|
|
|
123
126
|
uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
|
|
124
127
|
uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
|
|
125
128
|
uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
|
|
129
|
+
uplink domains providers connect cpanel --host server341.web-hosting.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
|
|
126
130
|
uplink domains providers list --json
|
|
127
131
|
uplink domains providers disconnect godaddy --json
|
|
128
132
|
|
|
@@ -132,7 +136,19 @@ echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
|
|
|
132
136
|
echo "$TOKEN" | uplink --token-stdin host domains remove --id app_xxx --hostname example.com --json
|
|
133
137
|
```
|
|
134
138
|
|
|
135
|
-
`domains check` works without a registrar: it falls back to public DNS/RDAP and returns `provider: "public"` with no price. Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`.
|
|
139
|
+
`domains check` works without a registrar: it falls back to public DNS/RDAP and returns `provider: "public"` with no price. Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`.
|
|
140
|
+
|
|
141
|
+
Purchase (Namecheap):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
uplink domains contact seed --json # copy WHOIS from an owned domain
|
|
145
|
+
uplink domains contact set --json # or set flags / interactive
|
|
146
|
+
uplink domains fund --amount 20 --json # payment page URL to tank balance
|
|
147
|
+
uplink domains buy alchemy.photos --yes --json
|
|
148
|
+
uplink domains buy alchemy.photos --open-cart --json # browser cart instead
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
API buys charge Namecheap **account balance**. If balance is low, `buy` / the Find a domain TUI return/open an add-funds `redirectUrl` from `namecheap.users.createaddfundsrequest`.
|
|
136
152
|
|
|
137
153
|
## Databases (optional)
|
|
138
154
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.6 — 2026-08-30
|
|
4
|
+
|
|
5
|
+
Uplink as a **domain hub**: connect any **cPanel** host (Namecheap shared, Bluehost, HostGator, and most shared hosting) alongside registrars, so domains and sites scattered across providers land in one `domains list`. Connect with `uplink domains providers connect cpanel --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN` (token from cPanel → Security → Manage API Tokens), or through the menu under Domains → Connect registrar.
|
|
6
|
+
|
|
7
|
+
## 0.2.5 — 2026-08-30
|
|
8
|
+
|
|
9
|
+
Find a domain: ↑↓ / mouse wheel to select results, enter or click for details, then buy via Namecheap API (or open cart / add-funds payment URL when balance is low). New commands: `domains buy`, `domains fund`, `domains contact show|set|seed`.
|
|
10
|
+
|
|
11
|
+
## 0.2.4 — 2026-08-30
|
|
12
|
+
|
|
13
|
+
Fix interactive menu crash (`uplink` / `uplink menu`): `domain-check.ts` still statically imported Ink's DomainSearch into the CommonJS graph, so tsx transformed `yoga-layout` as CJS (`Top-level await is currently not supported with the "cjs" output format`). Find a domain from the menu now spawns the ESM child the same way `uplink domains` does. Also stop passing the root CJS `tsconfig` into ESM Ink screens, and resolve `tsx` via `tsx/cli` for newer tsx releases.
|
|
14
|
+
|
|
15
|
+
## 0.2.3 — 2026-08-30
|
|
16
|
+
|
|
17
|
+
New **`uplink upgrade`** (Uplink Pro subscription — $9/mo or `--yearly` $90/yr via Stripe Checkout) and **`uplink billing`** (Stripe billing portal for cancel/card changes). Pro unlocks unlimited hosted apps within 1 GB of storage, always-on for your 5 most-active apps, custom domains, and permanent aliases.
|
|
18
|
+
|
|
19
|
+
Fix global CLI crash on every command (`host list`, `login`, …): `domains.ts` was statically importing Ink, so tsx tried to load `yoga-layout` as CommonJS (`Top-level await is currently not supported with the "cjs" output format`). Interactive Find a domain now runs as a child ESM process, same as the menu.
|
|
20
|
+
|
|
3
21
|
## 0.2.2 — 2026-08-29
|
|
4
22
|
|
|
5
23
|
Built-in **Find a domain** in the CLI (no Domainking install). Type a label to check common TLDs via DNS/RDAP. `uplink domains` opens it; agents use `uplink domains search acme --json`. Domainking remains a separate app.
|
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
- **Share any local port**: `localhost:<port>` → public HTTPS (`https://abc123.x.uplink.spot`)
|
|
9
9
|
- **Agent-first**: `--json`, stable exit codes, `--token-stdin` (no browser required)
|
|
10
10
|
- **Hosting**: deploy Next.js / Vite / static apps to Uplink
|
|
11
|
-
- **
|
|
11
|
+
- **Domain hub**: one inventory for domains scattered across GoDaddy, Cloudflare, Hostinger, Namecheap, DreamHost — and any cPanel host (Namecheap shared, Bluehost, HostGator, …)
|
|
12
|
+
- **Custom hostnames**: attach a domain you own to a hosted app and verify DNS
|
|
12
13
|
- **Interactive menu**: `uplink` for humans; CLI subcommands for agents
|
|
13
14
|
|
|
14
15
|
Learn more at [uplink.spot](https://uplink.spot)
|
|
@@ -21,7 +22,7 @@ npm install -g uplink-cli
|
|
|
21
22
|
npx uplink-cli --help
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
Changelog: [CHANGELOG.md](./CHANGELOG.md). Product: [docs/PRODUCT.md](./docs/PRODUCT.md). Hosting: [docs/HOSTING.md](./docs/HOSTING.md).
|
|
25
26
|
|
|
26
27
|
## Start without signup
|
|
27
28
|
```bash
|
|
@@ -68,6 +69,8 @@ echo "$AGENTCLOUD_TOKEN" | uplink --token-stdin host setup \
|
|
|
68
69
|
## Domains (non-interactive)
|
|
69
70
|
```bash
|
|
70
71
|
uplink domains providers connect godaddy --token-env GODADDY_PAT --json
|
|
72
|
+
uplink domains providers connect cpanel --host server341.web-hosting.com \
|
|
73
|
+
--user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
|
|
71
74
|
uplink domains list --json
|
|
72
75
|
echo "$AGENTCLOUD_TOKEN" | uplink --token-stdin \
|
|
73
76
|
host domains add --id app_xxx --hostname example.com --json
|
package/cli/bin/uplink.js
CHANGED
|
@@ -27,11 +27,11 @@ if (args.length === 0) {
|
|
|
27
27
|
// Find tsx - prefer project-local, otherwise fall back to PATH
|
|
28
28
|
let tsxPath = "tsx";
|
|
29
29
|
try {
|
|
30
|
-
//
|
|
31
|
-
tsxPath = require.resolve("tsx/
|
|
30
|
+
// Package exports entry (tsx 4.21–4.23+)
|
|
31
|
+
tsxPath = require.resolve("tsx/cli", { paths: [projectRoot] });
|
|
32
32
|
} catch (e) {
|
|
33
33
|
try {
|
|
34
|
-
tsxPath = require.resolve("tsx/cli", { paths: [projectRoot] });
|
|
34
|
+
tsxPath = require.resolve("tsx/dist/cli.cjs", { paths: [projectRoot] });
|
|
35
35
|
} catch (_) {
|
|
36
36
|
// keep fallback to PATH
|
|
37
37
|
}
|
package/cli/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { signupCommand } from "./subcommands/signup";
|
|
|
10
10
|
import { systemCommand } from "./subcommands/system";
|
|
11
11
|
import { hostCommand } from "./subcommands/host";
|
|
12
12
|
import { domainsCommand } from "./subcommands/domains";
|
|
13
|
+
import { upgradeCommand, billingCommand } from "./subcommands/upgrade";
|
|
13
14
|
import { readFileSync } from "fs";
|
|
14
15
|
import { join } from "path";
|
|
15
16
|
import { ensureApiBase, parseTokenEnv } from "./utils/api-base";
|
|
@@ -39,6 +40,8 @@ program.addCommand(systemCommand);
|
|
|
39
40
|
program.addCommand(menuCommand);
|
|
40
41
|
program.addCommand(hostCommand);
|
|
41
42
|
program.addCommand(domainsCommand);
|
|
43
|
+
program.addCommand(upgradeCommand);
|
|
44
|
+
program.addCommand(billingCommand);
|
|
42
45
|
|
|
43
46
|
// Global pre-action hook to apply shared options
|
|
44
47
|
let cachedTokenStdin: string | null = null;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cPanel is not a registrar, but it is where many people's sites actually
|
|
5
|
+
* live (Namecheap shared, Bluehost, HostGator, A2, GoDaddy shared, …).
|
|
6
|
+
* Connecting a cPanel account pulls its hosted domains into the same
|
|
7
|
+
* inventory as registrar-owned domains, so Uplink can act as the hub for
|
|
8
|
+
* domains and hosting scattered across providers.
|
|
9
|
+
*
|
|
10
|
+
* Auth: a cPanel API token (Security → Manage API Tokens in the panel),
|
|
11
|
+
* sent as `Authorization: cpanel user:token` to the UAPI on port 2083.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type UapiResponse = {
|
|
15
|
+
status?: number;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
errors?: string[] | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{2,5})?$/i;
|
|
21
|
+
|
|
22
|
+
export function normalizeCpanelHost(raw: string): string {
|
|
23
|
+
const host = raw
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^https?:\/\//i, "")
|
|
26
|
+
.replace(/\/.*$/, "")
|
|
27
|
+
.toLowerCase();
|
|
28
|
+
if (!HOST_RE.test(host)) {
|
|
29
|
+
throw new Error(`Invalid cPanel host: ${raw}. Pass a hostname like server341.web-hosting.com`);
|
|
30
|
+
}
|
|
31
|
+
return host;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseUrl(creds: RegistrarCredentials): string {
|
|
35
|
+
const host = normalizeCpanelHost(creds.host || "");
|
|
36
|
+
return host.includes(":") ? `https://${host}` : `https://${host}:2083`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function uapi(creds: RegistrarCredentials, module: string, fn: string): Promise<unknown> {
|
|
40
|
+
const user = creds.apiUser || "";
|
|
41
|
+
const token = creds.token || "";
|
|
42
|
+
if (!user || !token) throw new Error("cPanel needs a username and an API token");
|
|
43
|
+
const url = `${baseUrl(creds)}/execute/${module}/${fn}`;
|
|
44
|
+
let res: Response;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(url, {
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `cpanel ${user}:${token}`,
|
|
49
|
+
Accept: "application/json",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
54
|
+
throw new Error(`Could not reach cPanel at ${baseUrl(creds)} (${detail})`);
|
|
55
|
+
}
|
|
56
|
+
if (res.status === 401 || res.status === 403) {
|
|
57
|
+
throw new Error("cPanel rejected the credentials (check username and API token)");
|
|
58
|
+
}
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
let body: UapiResponse;
|
|
61
|
+
try {
|
|
62
|
+
body = text ? (JSON.parse(text) as UapiResponse) : {};
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`cPanel returned a non-JSON response (HTTP ${res.status})`);
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok || body.status !== 1) {
|
|
67
|
+
const detail = body.errors?.filter(Boolean).join("; ") || `HTTP ${res.status}`;
|
|
68
|
+
throw new Error(`cPanel ${module}/${fn} failed: ${detail}`);
|
|
69
|
+
}
|
|
70
|
+
return body.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stringsOf(value: unknown): string[] {
|
|
74
|
+
if (!Array.isArray(value)) return [];
|
|
75
|
+
return value.filter((item): item is string => typeof item === "string" && item.includes("."));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function listHostedDomains(creds: RegistrarCredentials): Promise<InventoryDomain[]> {
|
|
79
|
+
const data = (await uapi(creds, "DomainInfo", "list_domains")) as Record<string, unknown> | null;
|
|
80
|
+
const out: InventoryDomain[] = [];
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const push = (domain: string) => {
|
|
83
|
+
const normalized = domain.toLowerCase();
|
|
84
|
+
if (seen.has(normalized)) return;
|
|
85
|
+
seen.add(normalized);
|
|
86
|
+
out.push({ domain: normalized, provider: "cpanel", status: "owned" });
|
|
87
|
+
};
|
|
88
|
+
const main = data?.main_domain;
|
|
89
|
+
if (typeof main === "string" && main.includes(".")) push(main);
|
|
90
|
+
for (const domain of stringsOf(data?.addon_domains)) push(domain);
|
|
91
|
+
for (const domain of stringsOf(data?.parked_domains)) push(domain);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const cpanelAdapter: RegistrarAdapter = {
|
|
96
|
+
id: "cpanel",
|
|
97
|
+
label: "cPanel hosting",
|
|
98
|
+
connectHelp:
|
|
99
|
+
"Any cPanel host (Namecheap shared, Bluehost, HostGator, …). --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN (token: cPanel → Security → Manage API Tokens)",
|
|
100
|
+
async verify(creds) {
|
|
101
|
+
if (!creds.host) throw new Error("cPanel needs --host (e.g. server341.web-hosting.com)");
|
|
102
|
+
creds.host = normalizeCpanelHost(creds.host);
|
|
103
|
+
await listHostedDomains(creds);
|
|
104
|
+
return creds;
|
|
105
|
+
},
|
|
106
|
+
async listDomains(creds) {
|
|
107
|
+
const domains = await listHostedDomains(creds);
|
|
108
|
+
return domains.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
109
|
+
},
|
|
110
|
+
async check(creds, domain) {
|
|
111
|
+
// cPanel hosts sites; it has no availability API. Confirm hosted
|
|
112
|
+
// domains, otherwise let the chain fall through to a registrar.
|
|
113
|
+
const hosted = await listHostedDomains(creds);
|
|
114
|
+
if (hosted.some((item) => item.domain === domain.toLowerCase())) {
|
|
115
|
+
return { domain, provider: "cpanel", status: "owned", buyable: false };
|
|
116
|
+
}
|
|
117
|
+
throw new Error("cPanel only knows domains hosted on the connected account");
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cloudflareAdapter } from "./cloudflare";
|
|
2
|
+
import { cpanelAdapter } from "./cpanel";
|
|
2
3
|
import { dreamhostAdapter } from "./dreamhost";
|
|
3
4
|
import { godaddyAdapter } from "./godaddy";
|
|
4
5
|
import { hostingerAdapter } from "./hostinger";
|
|
@@ -12,6 +13,7 @@ export const adapters: RegistrarAdapter[] = [
|
|
|
12
13
|
hostingerAdapter,
|
|
13
14
|
namecheapAdapter,
|
|
14
15
|
dreamhostAdapter,
|
|
16
|
+
cpanelAdapter,
|
|
15
17
|
];
|
|
16
18
|
|
|
17
19
|
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { httpError } from "./http";
|
|
2
|
+
import type { RegistrarCredentials } from "./types";
|
|
3
|
+
import type { RegistrantContact } from "../utils/registrant-contact";
|
|
4
|
+
|
|
5
|
+
const API_URL = "https://api.namecheap.com/xml.response";
|
|
6
|
+
|
|
7
|
+
let clientIpPromise: Promise<string> | undefined;
|
|
8
|
+
|
|
9
|
+
async function clientIp(): Promise<string> {
|
|
10
|
+
clientIpPromise ??= fetch("https://api.ipify.org")
|
|
11
|
+
.then(async (res) => {
|
|
12
|
+
if (!res.ok) throw new Error(`ip lookup failed (${res.status})`);
|
|
13
|
+
return (await res.text()).trim();
|
|
14
|
+
})
|
|
15
|
+
.catch((error: unknown) => {
|
|
16
|
+
clientIpPromise = undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
return clientIpPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function attr(tag: string, name: string): string | undefined {
|
|
23
|
+
return new RegExp(`${name}="([^"]*)"`, "i").exec(tag)?.[1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function namecheapCall(
|
|
27
|
+
creds: RegistrarCredentials,
|
|
28
|
+
command: string,
|
|
29
|
+
extra: Record<string, string>,
|
|
30
|
+
method: "GET" | "POST" = "GET"
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
if (!creds.apiUser || !creds.apiKey) throw new Error("Namecheap API user and key are required");
|
|
33
|
+
const params = new URLSearchParams({
|
|
34
|
+
ApiUser: creds.apiUser,
|
|
35
|
+
ApiKey: creds.apiKey,
|
|
36
|
+
UserName: creds.apiUser,
|
|
37
|
+
Command: command,
|
|
38
|
+
ClientIp: await clientIp(),
|
|
39
|
+
...extra,
|
|
40
|
+
});
|
|
41
|
+
const res =
|
|
42
|
+
method === "POST"
|
|
43
|
+
? await fetch(API_URL, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: params,
|
|
47
|
+
})
|
|
48
|
+
: await fetch(`${API_URL}?${params}`);
|
|
49
|
+
const xml = await res.text();
|
|
50
|
+
const status = /Status="([^"]+)"/.exec(xml)?.[1];
|
|
51
|
+
if (status !== "OK") {
|
|
52
|
+
const err = /<Error[^>]*>([^<]+)<\/Error>/.exec(xml)?.[1] || `Namecheap ${status || res.status}`;
|
|
53
|
+
const ip = await clientIp().catch(() => "");
|
|
54
|
+
if (/ip/i.test(err) && ip) {
|
|
55
|
+
throw new Error(`${err}. Whitelist this machine's IP in Namecheap API access: ${ip}`);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(err);
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) throw httpError(res, xml);
|
|
60
|
+
return xml;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type NamecheapBalance = {
|
|
64
|
+
currency: string;
|
|
65
|
+
available: number;
|
|
66
|
+
account: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export async function getNamecheapBalance(creds: RegistrarCredentials): Promise<NamecheapBalance> {
|
|
70
|
+
const xml = await namecheapCall(creds, "namecheap.users.getBalances", {});
|
|
71
|
+
const tag = xml.match(/<UserGetBalancesResult\b([^>]*)\/?>/i)?.[1];
|
|
72
|
+
if (!tag) throw new Error("Namecheap balance response missing");
|
|
73
|
+
return {
|
|
74
|
+
currency: attr(tag, "Currency") || "USD",
|
|
75
|
+
available: Number(attr(tag, "AvailableBalance") || "0"),
|
|
76
|
+
account: Number(attr(tag, "AccountBalance") || "0"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type AddFundsRequest = {
|
|
81
|
+
tokenId: string;
|
|
82
|
+
redirectUrl: string;
|
|
83
|
+
returnUrl: string;
|
|
84
|
+
amount: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates a Namecheap credit-card add-funds session and returns the payment URL.
|
|
89
|
+
* Domain purchase charges account balance — fund first if AvailableBalance is low.
|
|
90
|
+
*/
|
|
91
|
+
export async function createNamecheapAddFundsRequest(
|
|
92
|
+
creds: RegistrarCredentials,
|
|
93
|
+
amount: number,
|
|
94
|
+
returnUrl = "https://www.namecheap.com/"
|
|
95
|
+
): Promise<AddFundsRequest> {
|
|
96
|
+
if (!Number.isFinite(amount) || amount < 5) {
|
|
97
|
+
throw new Error("Namecheap add-funds amount must be at least $5");
|
|
98
|
+
}
|
|
99
|
+
const xml = await namecheapCall(creds, "namecheap.users.createaddfundsrequest", {
|
|
100
|
+
Username: creds.apiUser || "",
|
|
101
|
+
PaymentType: "creditcard",
|
|
102
|
+
Amount: amount.toFixed(2),
|
|
103
|
+
ReturnUrl: returnUrl,
|
|
104
|
+
});
|
|
105
|
+
const tag =
|
|
106
|
+
xml.match(/<Createaddfundsrequestresult\b([^>]*)\/?>/i)?.[1] ||
|
|
107
|
+
xml.match(/<CreateAddFundsRequestResult\b([^>]*)\/?>/i)?.[1];
|
|
108
|
+
if (!tag) throw new Error("Namecheap add-funds response missing redirect URL");
|
|
109
|
+
const redirectUrl = attr(tag, "RedirectURL") || attr(tag, "ReturnURL");
|
|
110
|
+
const tokenId = attr(tag, "TokenID") || "";
|
|
111
|
+
if (!redirectUrl) throw new Error("Namecheap add-funds response missing RedirectURL");
|
|
112
|
+
return {
|
|
113
|
+
tokenId,
|
|
114
|
+
redirectUrl,
|
|
115
|
+
returnUrl: attr(tag, "ReturnURL") || returnUrl,
|
|
116
|
+
amount,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function contactFields(prefix: string, contact: RegistrantContact): Record<string, string> {
|
|
121
|
+
const fields: Record<string, string> = {
|
|
122
|
+
[`${prefix}FirstName`]: contact.firstName,
|
|
123
|
+
[`${prefix}LastName`]: contact.lastName,
|
|
124
|
+
[`${prefix}Address1`]: contact.address1,
|
|
125
|
+
[`${prefix}City`]: contact.city,
|
|
126
|
+
[`${prefix}StateProvince`]: contact.stateProvince,
|
|
127
|
+
[`${prefix}PostalCode`]: contact.postalCode,
|
|
128
|
+
[`${prefix}Country`]: contact.country,
|
|
129
|
+
[`${prefix}Phone`]: contact.phone,
|
|
130
|
+
[`${prefix}EmailAddress`]: contact.email,
|
|
131
|
+
};
|
|
132
|
+
if (contact.organizationName) fields[`${prefix}OrganizationName`] = contact.organizationName;
|
|
133
|
+
return fields;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type RegisterDomainResult = {
|
|
137
|
+
domain: string;
|
|
138
|
+
registered: boolean;
|
|
139
|
+
chargedAmount?: number;
|
|
140
|
+
orderId?: string;
|
|
141
|
+
domainId?: string;
|
|
142
|
+
transactionId?: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export async function registerNamecheapDomain(
|
|
146
|
+
creds: RegistrarCredentials,
|
|
147
|
+
opts: {
|
|
148
|
+
domain: string;
|
|
149
|
+
years?: number;
|
|
150
|
+
contact: RegistrantContact;
|
|
151
|
+
premium?: boolean;
|
|
152
|
+
premiumPrice?: number;
|
|
153
|
+
}
|
|
154
|
+
): Promise<RegisterDomainResult> {
|
|
155
|
+
const years = opts.years ?? 1;
|
|
156
|
+
const extra: Record<string, string> = {
|
|
157
|
+
DomainName: opts.domain,
|
|
158
|
+
Years: String(years),
|
|
159
|
+
AddFreeWhoisguard: "yes",
|
|
160
|
+
WGEnabled: "yes",
|
|
161
|
+
...contactFields("Registrant", opts.contact),
|
|
162
|
+
...contactFields("Tech", opts.contact),
|
|
163
|
+
...contactFields("Admin", opts.contact),
|
|
164
|
+
...contactFields("AuxBilling", opts.contact),
|
|
165
|
+
};
|
|
166
|
+
if (opts.premium) {
|
|
167
|
+
extra.IsPremiumDomain = "true";
|
|
168
|
+
if (opts.premiumPrice != null) extra.PremiumPrice = opts.premiumPrice.toFixed(2);
|
|
169
|
+
}
|
|
170
|
+
const xml = await namecheapCall(creds, "namecheap.domains.create", extra, "POST");
|
|
171
|
+
const tag = xml.match(/<DomainCreateResult\b([^>]*)\/?>/i)?.[1];
|
|
172
|
+
if (!tag) throw new Error("Namecheap create response missing DomainCreateResult");
|
|
173
|
+
const registered = attr(tag, "Registered")?.toLowerCase() === "true";
|
|
174
|
+
return {
|
|
175
|
+
domain: attr(tag, "Domain") || opts.domain,
|
|
176
|
+
registered,
|
|
177
|
+
chargedAmount: Number(attr(tag, "ChargedAmount") || "") || undefined,
|
|
178
|
+
orderId: attr(tag, "OrderID"),
|
|
179
|
+
domainId: attr(tag, "DomainID"),
|
|
180
|
+
transactionId: attr(tag, "TransactionID"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Pull registrant contact from an already-owned domain (seeds ~/.uplink/registrant.json). */
|
|
185
|
+
export async function fetchNamecheapDomainContact(
|
|
186
|
+
creds: RegistrarCredentials,
|
|
187
|
+
domain: string
|
|
188
|
+
): Promise<RegistrantContact | null> {
|
|
189
|
+
const xml = await namecheapCall(creds, "namecheap.domains.getContacts", { DomainName: domain });
|
|
190
|
+
const block = xml.match(/<Registrant>([\s\S]*?)<\/Registrant>/i)?.[1];
|
|
191
|
+
if (!block) return null;
|
|
192
|
+
const get = (name: string) =>
|
|
193
|
+
new RegExp(`<${name}>([^<]*)</${name}>`, "i").exec(block)?.[1]?.trim() || "";
|
|
194
|
+
const contact: RegistrantContact = {
|
|
195
|
+
firstName: get("FirstName"),
|
|
196
|
+
lastName: get("LastName"),
|
|
197
|
+
address1: get("Address1"),
|
|
198
|
+
city: get("City"),
|
|
199
|
+
stateProvince: get("StateProvince"),
|
|
200
|
+
postalCode: get("PostalCode"),
|
|
201
|
+
country: get("Country"),
|
|
202
|
+
phone: get("Phone"),
|
|
203
|
+
email: get("EmailAddress"),
|
|
204
|
+
organizationName: get("OrganizationName") || undefined,
|
|
205
|
+
};
|
|
206
|
+
if (!contact.firstName || !contact.lastName || !contact.email) return null;
|
|
207
|
+
return contact;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Browser cart deep-link (manual checkout — does not use API balance). */
|
|
211
|
+
export function namecheapCartUrl(domain: string, years = 1): string {
|
|
212
|
+
const params = new URLSearchParams({
|
|
213
|
+
producttype: "domains",
|
|
214
|
+
action: "purchase",
|
|
215
|
+
domainlist: domain,
|
|
216
|
+
billingcycle: String(years),
|
|
217
|
+
});
|
|
218
|
+
return `https://www.namecheap.com/cart/addtocart.aspx?${params}`;
|
|
219
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PROVIDER_IDS = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"] as const;
|
|
1
|
+
export const PROVIDER_IDS = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost", "cpanel"] as const;
|
|
2
2
|
export type ProviderId = (typeof PROVIDER_IDS)[number];
|
|
3
3
|
|
|
4
4
|
export function isProviderId(value: string): value is ProviderId {
|
|
@@ -10,6 +10,8 @@ export type RegistrarCredentials = {
|
|
|
10
10
|
apiUser?: string;
|
|
11
11
|
apiKey?: string;
|
|
12
12
|
accountId?: string;
|
|
13
|
+
/** Server hostname for panel-based providers (e.g. cPanel on port 2083). */
|
|
14
|
+
host?: string;
|
|
13
15
|
/** Additional API keys for providers with multiple accounts (e.g. DreamHost). */
|
|
14
16
|
extraTokens?: string[];
|
|
15
17
|
};
|