uplink-cli 0.2.7 → 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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
|
+
|
|
3
7
|
## 0.2.7 — 2026-08-31
|
|
4
8
|
|
|
5
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.
|
|
@@ -129,7 +129,9 @@ async function listHostedDomains(creds: RegistrarCredentials): Promise<Inventory
|
|
|
129
129
|
const normalized = domain.toLowerCase();
|
|
130
130
|
if (seen.has(normalized)) continue;
|
|
131
131
|
seen.add(normalized);
|
|
132
|
-
|
|
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" });
|
|
133
135
|
}
|
|
134
136
|
} catch (error) {
|
|
135
137
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
@@ -165,7 +167,7 @@ export const cpanelAdapter: RegistrarAdapter = {
|
|
|
165
167
|
// domains, otherwise let the chain fall through to a registrar.
|
|
166
168
|
const hosted = await listHostedDomains(creds);
|
|
167
169
|
if (hosted.some((item) => item.domain === domain.toLowerCase())) {
|
|
168
|
-
return { domain, provider: "cpanel", status: "
|
|
170
|
+
return { domain, provider: "cpanel", status: "taken", buyable: false };
|
|
169
171
|
}
|
|
170
172
|
throw new Error("cPanel only knows domains hosted on the connected accounts");
|
|
171
173
|
},
|
|
@@ -27,7 +27,8 @@ export type RegistrarCredentials = {
|
|
|
27
27
|
export type InventoryDomain = {
|
|
28
28
|
domain: string;
|
|
29
29
|
provider: ProviderId;
|
|
30
|
-
|
|
30
|
+
/** "owned" = registrar registration; "hosted" = served by a panel (says nothing about ownership). */
|
|
31
|
+
status: "owned" | "hosted";
|
|
31
32
|
expiresAt?: string;
|
|
32
33
|
};
|
|
33
34
|
|
|
@@ -19,6 +19,8 @@ import { cpanelAccountsOf, mergeCpanelCredentials, normalizeCpanelHost } from ".
|
|
|
19
19
|
import {
|
|
20
20
|
checkDomainAvailability,
|
|
21
21
|
formatPublicAvailability,
|
|
22
|
+
rdapRegistration,
|
|
23
|
+
type RdapRegistration,
|
|
22
24
|
} from "../utils/domain-availability";
|
|
23
25
|
import { searchDomains } from "../utils/domain-search";
|
|
24
26
|
import {
|
|
@@ -124,6 +126,60 @@ async function credentialsFromFlags(
|
|
|
124
126
|
return creds;
|
|
125
127
|
}
|
|
126
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
|
+
|
|
127
183
|
async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
|
|
128
184
|
const store = readRegistrarStore();
|
|
129
185
|
const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
|
|
@@ -192,24 +248,62 @@ domainsCommand.action(() => {
|
|
|
192
248
|
|
|
193
249
|
domainsCommand
|
|
194
250
|
.command("list")
|
|
195
|
-
.description("
|
|
251
|
+
.description("Inventory across connected registrars and cPanel hosts, grouped by provider")
|
|
196
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)
|
|
197
254
|
.option("--json", "Output JSON", false)
|
|
198
255
|
.action(async (opts) => {
|
|
199
256
|
try {
|
|
200
257
|
const provider = parseProvider(opts.provider);
|
|
201
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
|
+
|
|
202
268
|
if (opts.json) {
|
|
203
|
-
|
|
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 });
|
|
204
283
|
return;
|
|
205
284
|
}
|
|
285
|
+
|
|
206
286
|
if (domains.length === 0) {
|
|
207
287
|
console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
|
|
208
288
|
return;
|
|
209
289
|
}
|
|
290
|
+
|
|
291
|
+
const byProvider = new Map<ProviderId, InventoryDomain[]>();
|
|
210
292
|
for (const item of domains) {
|
|
211
|
-
const
|
|
212
|
-
|
|
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");
|
|
213
307
|
}
|
|
214
308
|
} catch (error) {
|
|
215
309
|
handleError(error, { json: opts.json });
|
|
@@ -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/package.json
CHANGED