uplink-cli 0.2.6 → 0.2.8
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 +2 -0
- package/CHANGELOG.md +8 -0
- package/cli/src/registrars/cpanel.ts +86 -31
- package/cli/src/registrars/types.ts +10 -1
- package/cli/src/subcommands/domains.ts +137 -12
- package/cli/src/utils/domain-availability.ts +81 -0
- package/docs/AGENTS.md +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -127,6 +127,8 @@ uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --jso
|
|
|
127
127
|
uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
|
|
128
128
|
uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
|
|
129
129
|
uplink domains providers connect cpanel --host server341.web-hosting.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
|
|
130
|
+
# Repeat connect with another --host to add more cPanel accounts; remove one with:
|
|
131
|
+
uplink domains providers disconnect cpanel --host server341.web-hosting.com --json
|
|
130
132
|
uplink domains providers list --json
|
|
131
133
|
uplink domains providers disconnect godaddy --json
|
|
132
134
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.8 — 2026-08-31
|
|
4
|
+
|
|
5
|
+
`domains list` is now grouped by provider and honest about what it knows: cPanel entries show as **hosted** (a panel serving a site says nothing about ownership), registrar expiry dates in the past show as **EXPIRED**, and new **`--verify`** RDAP-checks every entry without registration data (DNS zones, hosted sites) to reveal lapsed domains. RDAP goes straight to each TLD registry via the IANA bootstrap, with a DNS-delegation fallback for registries that 404.
|
|
6
|
+
|
|
7
|
+
## 0.2.7 — 2026-08-31
|
|
8
|
+
|
|
9
|
+
Multiple **cPanel** accounts: `providers connect cpanel --host …` now appends (people have sites on several hosts), `providers disconnect cpanel --host …` removes one account, and `providers list` shows the connected hosts. One user, many shared hosts, one inventory.
|
|
10
|
+
|
|
3
11
|
## 0.2.6 — 2026-08-30
|
|
4
12
|
|
|
5
13
|
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.
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CpanelAccount,
|
|
3
|
+
InventoryDomain,
|
|
4
|
+
RegistrarAdapter,
|
|
5
|
+
RegistrarCredentials,
|
|
6
|
+
} from "./types";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* cPanel is not a registrar, but it is where many people's sites actually
|
|
@@ -9,6 +14,9 @@ import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./
|
|
|
9
14
|
*
|
|
10
15
|
* Auth: a cPanel API token (Security → Manage API Tokens in the panel),
|
|
11
16
|
* sent as `Authorization: cpanel user:token` to the UAPI on port 2083.
|
|
17
|
+
*
|
|
18
|
+
* A user can connect several cPanel accounts (different hosts); each
|
|
19
|
+
* `providers connect cpanel --host …` appends to `creds.accounts`.
|
|
12
20
|
*/
|
|
13
21
|
|
|
14
22
|
type UapiResponse = {
|
|
@@ -31,41 +39,65 @@ export function normalizeCpanelHost(raw: string): string {
|
|
|
31
39
|
return host;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
/** All connected cPanel accounts, including the legacy single-account shape. */
|
|
43
|
+
export function cpanelAccountsOf(creds: RegistrarCredentials): CpanelAccount[] {
|
|
44
|
+
const accounts: CpanelAccount[] = [];
|
|
45
|
+
const push = (account: { host?: string; apiUser?: string; token?: string }) => {
|
|
46
|
+
if (!account.host || !account.apiUser || !account.token) return;
|
|
47
|
+
const host = normalizeCpanelHost(account.host);
|
|
48
|
+
if (accounts.some((a) => a.host === host && a.apiUser === account.apiUser)) return;
|
|
49
|
+
accounts.push({ host, apiUser: account.apiUser, token: account.token });
|
|
50
|
+
};
|
|
51
|
+
for (const account of creds.accounts || []) push(account);
|
|
52
|
+
push(creds);
|
|
53
|
+
return accounts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Merge a newly verified account into stored creds (replace same host+user). */
|
|
57
|
+
export function mergeCpanelCredentials(
|
|
58
|
+
existing: RegistrarCredentials | undefined,
|
|
59
|
+
incoming: RegistrarCredentials
|
|
60
|
+
): RegistrarCredentials {
|
|
61
|
+
const merged: CpanelAccount[] = existing ? cpanelAccountsOf(existing) : [];
|
|
62
|
+
for (const account of cpanelAccountsOf(incoming)) {
|
|
63
|
+
const at = merged.findIndex((a) => a.host === account.host && a.apiUser === account.apiUser);
|
|
64
|
+
if (at >= 0) merged[at] = account;
|
|
65
|
+
else merged.push(account);
|
|
66
|
+
}
|
|
67
|
+
return { accounts: merged };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function baseUrl(host: string): string {
|
|
36
71
|
return host.includes(":") ? `https://${host}` : `https://${host}:2083`;
|
|
37
72
|
}
|
|
38
73
|
|
|
39
|
-
async function uapi(
|
|
40
|
-
const
|
|
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}`;
|
|
74
|
+
async function uapi(account: CpanelAccount, module: string, fn: string): Promise<unknown> {
|
|
75
|
+
const url = `${baseUrl(account.host)}/execute/${module}/${fn}`;
|
|
44
76
|
let res: Response;
|
|
45
77
|
try {
|
|
46
78
|
res = await fetch(url, {
|
|
47
79
|
headers: {
|
|
48
|
-
Authorization: `cpanel ${
|
|
80
|
+
Authorization: `cpanel ${account.apiUser}:${account.token}`,
|
|
49
81
|
Accept: "application/json",
|
|
50
82
|
},
|
|
51
83
|
});
|
|
52
84
|
} catch (error) {
|
|
53
85
|
const detail = error instanceof Error ? error.message : String(error);
|
|
54
|
-
throw new Error(`Could not reach cPanel at ${baseUrl(
|
|
86
|
+
throw new Error(`Could not reach cPanel at ${baseUrl(account.host)} (${detail})`);
|
|
55
87
|
}
|
|
56
88
|
if (res.status === 401 || res.status === 403) {
|
|
57
|
-
throw new Error(
|
|
89
|
+
throw new Error(`cPanel at ${account.host} rejected the credentials (check username and API token)`);
|
|
58
90
|
}
|
|
59
91
|
const text = await res.text();
|
|
60
92
|
let body: UapiResponse;
|
|
61
93
|
try {
|
|
62
94
|
body = text ? (JSON.parse(text) as UapiResponse) : {};
|
|
63
95
|
} catch {
|
|
64
|
-
throw new Error(`cPanel returned a non-JSON response (HTTP ${res.status})`);
|
|
96
|
+
throw new Error(`cPanel at ${account.host} returned a non-JSON response (HTTP ${res.status})`);
|
|
65
97
|
}
|
|
66
98
|
if (!res.ok || body.status !== 1) {
|
|
67
99
|
const detail = body.errors?.filter(Boolean).join("; ") || `HTTP ${res.status}`;
|
|
68
|
-
throw new Error(`cPanel ${module}/${fn} failed: ${detail}`);
|
|
100
|
+
throw new Error(`cPanel ${module}/${fn} failed on ${account.host}: ${detail}`);
|
|
69
101
|
}
|
|
70
102
|
return body.data;
|
|
71
103
|
}
|
|
@@ -75,20 +107,37 @@ function stringsOf(value: unknown): string[] {
|
|
|
75
107
|
return value.filter((item): item is string => typeof item === "string" && item.includes("."));
|
|
76
108
|
}
|
|
77
109
|
|
|
110
|
+
async function listAccountDomains(account: CpanelAccount): Promise<string[]> {
|
|
111
|
+
const data = (await uapi(account, "DomainInfo", "list_domains")) as Record<string, unknown> | null;
|
|
112
|
+
const out: string[] = [];
|
|
113
|
+
const main = data?.main_domain;
|
|
114
|
+
if (typeof main === "string" && main.includes(".")) out.push(main);
|
|
115
|
+
out.push(...stringsOf(data?.addon_domains));
|
|
116
|
+
out.push(...stringsOf(data?.parked_domains));
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
78
120
|
async function listHostedDomains(creds: RegistrarCredentials): Promise<InventoryDomain[]> {
|
|
79
|
-
const
|
|
121
|
+
const accounts = cpanelAccountsOf(creds);
|
|
122
|
+
if (accounts.length === 0) throw new Error("cPanel needs a host, username, and API token");
|
|
80
123
|
const out: InventoryDomain[] = [];
|
|
81
124
|
const seen = new Set<string>();
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
for (const account of accounts) {
|
|
127
|
+
try {
|
|
128
|
+
for (const domain of await listAccountDomains(account)) {
|
|
129
|
+
const normalized = domain.toLowerCase();
|
|
130
|
+
if (seen.has(normalized)) continue;
|
|
131
|
+
seen.add(normalized);
|
|
132
|
+
// "hosted": the panel serves this domain; it says nothing about
|
|
133
|
+
// whether the registration is still owned.
|
|
134
|
+
out.push({ domain: normalized, provider: "cpanel", status: "hosted" });
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (out.length === 0 && errors.length > 0) throw new Error(errors.join("; "));
|
|
92
141
|
return out;
|
|
93
142
|
}
|
|
94
143
|
|
|
@@ -96,12 +145,18 @@ export const cpanelAdapter: RegistrarAdapter = {
|
|
|
96
145
|
id: "cpanel",
|
|
97
146
|
label: "cPanel hosting",
|
|
98
147
|
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)",
|
|
148
|
+
"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). Repeat with another --host to add more accounts.",
|
|
100
149
|
async verify(creds) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
150
|
+
// Verify only the incoming account(s); the connect flow merges them
|
|
151
|
+
// into any previously stored accounts afterwards.
|
|
152
|
+
const accounts = cpanelAccountsOf(creds);
|
|
153
|
+
if (accounts.length === 0) {
|
|
154
|
+
throw new Error("cPanel needs --host (e.g. server341.web-hosting.com), a username, and an API token");
|
|
155
|
+
}
|
|
156
|
+
for (const account of accounts) {
|
|
157
|
+
await listAccountDomains(account);
|
|
158
|
+
}
|
|
159
|
+
return { accounts };
|
|
105
160
|
},
|
|
106
161
|
async listDomains(creds) {
|
|
107
162
|
const domains = await listHostedDomains(creds);
|
|
@@ -112,8 +167,8 @@ export const cpanelAdapter: RegistrarAdapter = {
|
|
|
112
167
|
// domains, otherwise let the chain fall through to a registrar.
|
|
113
168
|
const hosted = await listHostedDomains(creds);
|
|
114
169
|
if (hosted.some((item) => item.domain === domain.toLowerCase())) {
|
|
115
|
-
return { domain, provider: "cpanel", status: "
|
|
170
|
+
return { domain, provider: "cpanel", status: "taken", buyable: false };
|
|
116
171
|
}
|
|
117
|
-
throw new Error("cPanel only knows domains hosted on the connected
|
|
172
|
+
throw new Error("cPanel only knows domains hosted on the connected accounts");
|
|
118
173
|
},
|
|
119
174
|
};
|
|
@@ -5,6 +5,12 @@ export function isProviderId(value: string): value is ProviderId {
|
|
|
5
5
|
return (PROVIDER_IDS as readonly string[]).includes(value);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export type CpanelAccount = {
|
|
9
|
+
host: string;
|
|
10
|
+
apiUser: string;
|
|
11
|
+
token: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
export type RegistrarCredentials = {
|
|
9
15
|
token?: string;
|
|
10
16
|
apiUser?: string;
|
|
@@ -14,12 +20,15 @@ export type RegistrarCredentials = {
|
|
|
14
20
|
host?: string;
|
|
15
21
|
/** Additional API keys for providers with multiple accounts (e.g. DreamHost). */
|
|
16
22
|
extraTokens?: string[];
|
|
23
|
+
/** Multiple cPanel accounts — people often have sites on several hosts. */
|
|
24
|
+
accounts?: CpanelAccount[];
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
export type InventoryDomain = {
|
|
20
28
|
domain: string;
|
|
21
29
|
provider: ProviderId;
|
|
22
|
-
|
|
30
|
+
/** "owned" = registrar registration; "hosted" = served by a panel (says nothing about ownership). */
|
|
31
|
+
status: "owned" | "hosted";
|
|
23
32
|
expiresAt?: string;
|
|
24
33
|
};
|
|
25
34
|
|
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
type RegistrarCredentials,
|
|
16
16
|
} from "../registrars";
|
|
17
17
|
import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
|
|
18
|
+
import { cpanelAccountsOf, mergeCpanelCredentials, normalizeCpanelHost } from "../registrars/cpanel";
|
|
18
19
|
import {
|
|
19
20
|
checkDomainAvailability,
|
|
20
21
|
formatPublicAvailability,
|
|
22
|
+
rdapRegistration,
|
|
23
|
+
type RdapRegistration,
|
|
21
24
|
} from "../utils/domain-availability";
|
|
22
25
|
import { searchDomains } from "../utils/domain-search";
|
|
23
26
|
import {
|
|
@@ -123,6 +126,60 @@ async function credentialsFromFlags(
|
|
|
123
126
|
return creds;
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/** Registrars report expiry in mixed formats (GoDaddy ISO, Namecheap MM/DD/YYYY). */
|
|
130
|
+
function parseExpiry(value?: string): Date | undefined {
|
|
131
|
+
if (!value) return undefined;
|
|
132
|
+
const mdy = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
133
|
+
const date = mdy ? new Date(`${mdy[3]}-${mdy[1]}-${mdy[2]}T00:00:00Z`) : new Date(value);
|
|
134
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatDay(date: Date): string {
|
|
138
|
+
return date.toISOString().slice(0, 10);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
142
|
+
|
|
143
|
+
/** RDAP-check registration for domains whose source can't attest ownership. */
|
|
144
|
+
async function verifyRegistrations(domains: string[]): Promise<Map<string, RdapRegistration>> {
|
|
145
|
+
const out = new Map<string, RdapRegistration>();
|
|
146
|
+
const run = async (targets: string[], batchSize: number, pauseMs: number) => {
|
|
147
|
+
for (let i = 0; i < targets.length; i += batchSize) {
|
|
148
|
+
if (i > 0) await sleep(pauseMs);
|
|
149
|
+
const results = await Promise.all(targets.slice(i, i + batchSize).map(rdapRegistration));
|
|
150
|
+
for (const result of results) out.set(result.domain, result);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
await run([...new Set(domains)], 4, 400);
|
|
154
|
+
// rdap.org rate-limits bursts; give inconclusive lookups one slower retry.
|
|
155
|
+
const inconclusive = [...out.values()].filter((r) => r.registered === null).map((r) => r.domain);
|
|
156
|
+
if (inconclusive.length > 0) {
|
|
157
|
+
await sleep(2000);
|
|
158
|
+
await run(inconclusive, 2, 1000);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function inventoryMarker(item: InventoryDomain, verified?: RdapRegistration): string {
|
|
164
|
+
const now = new Date();
|
|
165
|
+
if (verified) {
|
|
166
|
+
if (verified.registered === false) return "NOT REGISTERED — lapsed";
|
|
167
|
+
if (verified.registered === null) return `rdap inconclusive (${verified.detail})`;
|
|
168
|
+
const expiry = parseExpiry(verified.expiresAt);
|
|
169
|
+
if (expiry) {
|
|
170
|
+
return expiry < now
|
|
171
|
+
? `registered · EXPIRED ${formatDay(expiry)} (rdap)`
|
|
172
|
+
: `registered · expires ${formatDay(expiry)} (rdap)`;
|
|
173
|
+
}
|
|
174
|
+
return verified.detail ? `registered · ${verified.detail}` : "registered (rdap)";
|
|
175
|
+
}
|
|
176
|
+
const expiry = parseExpiry(item.expiresAt);
|
|
177
|
+
if (expiry) {
|
|
178
|
+
return expiry < now ? `EXPIRED ${formatDay(expiry)}` : `expires ${formatDay(expiry)}`;
|
|
179
|
+
}
|
|
180
|
+
return item.status === "hosted" ? "hosted (ownership unknown)" : "no expiry data";
|
|
181
|
+
}
|
|
182
|
+
|
|
126
183
|
async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
|
|
127
184
|
const store = readRegistrarStore();
|
|
128
185
|
const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
|
|
@@ -191,24 +248,62 @@ domainsCommand.action(() => {
|
|
|
191
248
|
|
|
192
249
|
domainsCommand
|
|
193
250
|
.command("list")
|
|
194
|
-
.description("
|
|
251
|
+
.description("Inventory across connected registrars and cPanel hosts, grouped by provider")
|
|
195
252
|
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost|cpanel)")
|
|
253
|
+
.option("--verify", "RDAP-check registration for entries without expiry data (zones/hosted sites)", false)
|
|
196
254
|
.option("--json", "Output JSON", false)
|
|
197
255
|
.action(async (opts) => {
|
|
198
256
|
try {
|
|
199
257
|
const provider = parseProvider(opts.provider);
|
|
200
258
|
const domains = await listInventory(provider);
|
|
259
|
+
|
|
260
|
+
// Zones (DreamHost) and hosted sites (cPanel) carry no registration
|
|
261
|
+
// data — a lapsed domain can linger there forever. --verify asks RDAP.
|
|
262
|
+
let verified: Map<string, RdapRegistration> | undefined;
|
|
263
|
+
if (opts.verify) {
|
|
264
|
+
const unverifiable = domains.filter((item) => !parseExpiry(item.expiresAt));
|
|
265
|
+
verified = await verifyRegistrations(unverifiable.map((item) => item.domain));
|
|
266
|
+
}
|
|
267
|
+
|
|
201
268
|
if (opts.json) {
|
|
202
|
-
|
|
269
|
+
const enriched = domains.map((item) => {
|
|
270
|
+
const check = verified?.get(item.domain);
|
|
271
|
+
return check
|
|
272
|
+
? {
|
|
273
|
+
...item,
|
|
274
|
+
registration: {
|
|
275
|
+
registered: check.registered,
|
|
276
|
+
...(check.expiresAt ? { expiresAt: check.expiresAt } : {}),
|
|
277
|
+
...(check.detail ? { detail: check.detail } : {}),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
: item;
|
|
281
|
+
});
|
|
282
|
+
printJson({ domains: enriched, count: enriched.length });
|
|
203
283
|
return;
|
|
204
284
|
}
|
|
285
|
+
|
|
205
286
|
if (domains.length === 0) {
|
|
206
287
|
console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
|
|
207
288
|
return;
|
|
208
289
|
}
|
|
290
|
+
|
|
291
|
+
const byProvider = new Map<ProviderId, InventoryDomain[]>();
|
|
209
292
|
for (const item of domains) {
|
|
210
|
-
const
|
|
211
|
-
|
|
293
|
+
const group = byProvider.get(item.provider) || [];
|
|
294
|
+
group.push(item);
|
|
295
|
+
byProvider.set(item.provider, group);
|
|
296
|
+
}
|
|
297
|
+
for (const [id, items] of byProvider) {
|
|
298
|
+
console.log(`${id} (${items.length})`);
|
|
299
|
+
for (const item of items) {
|
|
300
|
+
console.log(` ${item.domain.padEnd(30)} ${inventoryMarker(item, verified?.get(item.domain))}`);
|
|
301
|
+
}
|
|
302
|
+
console.log("");
|
|
303
|
+
}
|
|
304
|
+
if (!opts.verify && domains.some((item) => !parseExpiry(item.expiresAt))) {
|
|
305
|
+
console.log("Entries without expiry come from DNS zones / hosted sites and may be lapsed.");
|
|
306
|
+
console.log("Check actual registration: uplink domains list --verify");
|
|
212
307
|
}
|
|
213
308
|
} catch (error) {
|
|
214
309
|
handleError(error, { json: opts.json });
|
|
@@ -569,18 +664,25 @@ providers
|
|
|
569
664
|
.action((opts) => {
|
|
570
665
|
try {
|
|
571
666
|
const store = readRegistrarStore();
|
|
572
|
-
const items = adapters.map((adapter) =>
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
667
|
+
const items = adapters.map((adapter) => {
|
|
668
|
+
const creds = store[adapter.id];
|
|
669
|
+
const cpanelHosts =
|
|
670
|
+
adapter.id === "cpanel" && creds ? cpanelAccountsOf(creds).map((a) => a.host) : undefined;
|
|
671
|
+
return {
|
|
672
|
+
id: adapter.id,
|
|
673
|
+
label: adapter.label,
|
|
674
|
+
connected: Boolean(creds),
|
|
675
|
+
...(cpanelHosts ? { hosts: cpanelHosts } : {}),
|
|
676
|
+
help: adapter.connectHelp,
|
|
677
|
+
};
|
|
678
|
+
});
|
|
578
679
|
if (opts.json) {
|
|
579
680
|
printJson({ providers: items });
|
|
580
681
|
return;
|
|
581
682
|
}
|
|
582
683
|
for (const item of items) {
|
|
583
|
-
|
|
684
|
+
const hosts = item.hosts?.length ? ` (${item.hosts.join(", ")})` : "";
|
|
685
|
+
console.log(`- ${item.id} ${item.connected ? `connected${hosts}` : "not connected"}`);
|
|
584
686
|
if (!item.connected) console.log(` ${item.help}`);
|
|
585
687
|
}
|
|
586
688
|
} catch (error) {
|
|
@@ -606,7 +708,13 @@ providers
|
|
|
606
708
|
const adapter = getAdapter(provider);
|
|
607
709
|
const creds = await credentialsFromFlags(provider, opts);
|
|
608
710
|
const verified = await adapter.verify(creds);
|
|
609
|
-
|
|
711
|
+
// cPanel accumulates accounts (people have sites on several hosts);
|
|
712
|
+
// other providers replace the stored credential.
|
|
713
|
+
const toSave =
|
|
714
|
+
provider === "cpanel"
|
|
715
|
+
? mergeCpanelCredentials(readRegistrarStore().cpanel, verified)
|
|
716
|
+
: verified;
|
|
717
|
+
saveProvider(provider, toSave);
|
|
610
718
|
const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
|
|
611
719
|
if (opts.json) {
|
|
612
720
|
printJson({
|
|
@@ -626,11 +734,28 @@ providers
|
|
|
626
734
|
.command("disconnect")
|
|
627
735
|
.description("Remove a saved registrar or cPanel credential")
|
|
628
736
|
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
|
|
737
|
+
.option("--host <hostname>", "cPanel only: remove just this host's account")
|
|
629
738
|
.option("--json", "Output JSON", false)
|
|
630
739
|
.action((providerArg: string, opts) => {
|
|
631
740
|
try {
|
|
632
741
|
const provider = String(providerArg).toLowerCase();
|
|
633
742
|
if (!isProviderId(provider)) throw new Error(`Unknown provider: ${providerArg}`);
|
|
743
|
+
|
|
744
|
+
if (provider === "cpanel" && opts.host) {
|
|
745
|
+
const host = normalizeCpanelHost(String(opts.host));
|
|
746
|
+
const existing = readRegistrarStore().cpanel;
|
|
747
|
+
const accounts = existing ? cpanelAccountsOf(existing).filter((a) => a.host !== host) : [];
|
|
748
|
+
const removed = existing ? cpanelAccountsOf(existing).length !== accounts.length : false;
|
|
749
|
+
if (accounts.length === 0) removeProvider(provider);
|
|
750
|
+
else saveProvider(provider, { accounts });
|
|
751
|
+
if (opts.json) {
|
|
752
|
+
printJson({ provider, host, removed, remainingAccounts: accounts.length });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
console.log(removed ? `Removed cPanel account on ${host}` : `No cPanel account on ${host}`);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
634
759
|
const removed = removeProvider(provider);
|
|
635
760
|
if (opts.json) {
|
|
636
761
|
printJson({ provider, connected: false, removed });
|
|
@@ -46,6 +46,87 @@ export async function checkDomainAvailability(domain: string): Promise<PublicAva
|
|
|
46
46
|
return { domain, status: "unknown", source: "rdap", detail: lastFailure };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export type RdapRegistration = {
|
|
50
|
+
domain: string;
|
|
51
|
+
/** null = RDAP unreachable / inconclusive. */
|
|
52
|
+
registered: boolean | null;
|
|
53
|
+
expiresAt?: string;
|
|
54
|
+
detail?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* IANA RDAP bootstrap: maps a TLD to its authoritative registry RDAP base.
|
|
59
|
+
* Querying registries directly avoids the rdap.org aggregator's tight rate
|
|
60
|
+
* limits when verifying many domains.
|
|
61
|
+
*/
|
|
62
|
+
let rdapBootstrap: Map<string, string> | null = null;
|
|
63
|
+
|
|
64
|
+
async function rdapBaseFor(domain: string): Promise<string> {
|
|
65
|
+
const tld = domain.slice(domain.lastIndexOf(".") + 1).toLowerCase();
|
|
66
|
+
if (!rdapBootstrap) {
|
|
67
|
+
rdapBootstrap = new Map();
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch("https://data.iana.org/rdap/dns.json", {
|
|
70
|
+
timeout: RDAP_TIMEOUT_MS,
|
|
71
|
+
});
|
|
72
|
+
if (response.ok) {
|
|
73
|
+
const body = (await response.json()) as { services?: Array<[string[], string[]]> };
|
|
74
|
+
for (const [tlds, urls] of body.services || []) {
|
|
75
|
+
const url = urls.find((u) => u.startsWith("https://")) || urls[0];
|
|
76
|
+
if (!url) continue;
|
|
77
|
+
const base = url.endsWith("/") ? url : `${url}/`;
|
|
78
|
+
for (const t of tlds) rdapBootstrap.set(t.toLowerCase(), base);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Bootstrap unavailable — fall back to the aggregator for everything.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const base = rdapBootstrap.get(tld);
|
|
86
|
+
return base ? `${base}domain/` : "https://rdap.org/domain/";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Authoritative registration lookup via RDAP: is the domain registered at
|
|
91
|
+
* all, and when does the registration expire? Used to audit inventory
|
|
92
|
+
* entries whose source (DNS zones, cPanel) says nothing about ownership.
|
|
93
|
+
*/
|
|
94
|
+
export async function rdapRegistration(domain: string): Promise<RdapRegistration> {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`${await rdapBaseFor(domain)}${encodeURIComponent(domain)}`, {
|
|
97
|
+
headers: { accept: "application/rdap+json" },
|
|
98
|
+
timeout: RDAP_TIMEOUT_MS,
|
|
99
|
+
});
|
|
100
|
+
if (response.status === 404) {
|
|
101
|
+
// Some registries 404 on rdap.org for registered domains. A domain
|
|
102
|
+
// that is delegated in public DNS is registered regardless.
|
|
103
|
+
try {
|
|
104
|
+
const nameservers = await dns.resolveNs(domain);
|
|
105
|
+
if (nameservers.length > 0) {
|
|
106
|
+
return { domain, registered: true, detail: "delegated in DNS (no RDAP record)" };
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// NXDOMAIN — the 404 stands.
|
|
110
|
+
}
|
|
111
|
+
return { domain, registered: false, detail: "no registration record" };
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
return { domain, registered: null, detail: `RDAP returned ${response.status}` };
|
|
115
|
+
}
|
|
116
|
+
const body = (await response.json()) as {
|
|
117
|
+
events?: Array<{ eventAction?: string; eventDate?: string }>;
|
|
118
|
+
};
|
|
119
|
+
const expiration = body.events?.find((e) => e.eventAction === "expiration")?.eventDate;
|
|
120
|
+
return { domain, registered: true, expiresAt: expiration };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
domain,
|
|
124
|
+
registered: null,
|
|
125
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
49
130
|
export function formatPublicAvailability(result: PublicAvailability): string {
|
|
50
131
|
const lines = [`${result.domain} ${result.status} (${result.detail})`];
|
|
51
132
|
if (result.status === "available") {
|
package/docs/AGENTS.md
CHANGED
|
@@ -127,6 +127,8 @@ uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --jso
|
|
|
127
127
|
uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
|
|
128
128
|
uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
|
|
129
129
|
uplink domains providers connect cpanel --host server341.web-hosting.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
|
|
130
|
+
# Repeat connect with another --host to add more cPanel accounts; remove one with:
|
|
131
|
+
uplink domains providers disconnect cpanel --host server341.web-hosting.com --json
|
|
130
132
|
uplink domains providers list --json
|
|
131
133
|
uplink domains providers disconnect godaddy --json
|
|
132
134
|
|
package/package.json
CHANGED