uplink-cli 0.2.7 → 0.2.9
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 +8 -0
- package/cli/src/registrars/cpanel.ts +4 -2
- package/cli/src/registrars/types.ts +2 -1
- package/cli/src/subcommands/domains.ts +105 -4
- package/cli/src/subcommands/menu/menus/domains.ts +3 -1
- package/cli/src/utils/domain-availability.ts +81 -0
- package/cli/src/utils/rdap-cache.ts +75 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.9 — 2026-08-31
|
|
4
|
+
|
|
5
|
+
The menu's **My domains** now shows verified registration status (the same as `domains list --verify`) instead of "no expiry data" for zone/hosted entries. RDAP results are cached in `~/.uplink/rdap-cache.json` for 24 hours, so only the first open pays for the lookups.
|
|
6
|
+
|
|
7
|
+
## 0.2.8 — 2026-08-31
|
|
8
|
+
|
|
9
|
+
`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.
|
|
10
|
+
|
|
3
11
|
## 0.2.7 — 2026-08-31
|
|
4
12
|
|
|
5
13
|
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,7 +19,10 @@ 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";
|
|
25
|
+
import { cachedRegistrations, storeRegistrations } from "../utils/rdap-cache";
|
|
23
26
|
import { searchDomains } from "../utils/domain-search";
|
|
24
27
|
import {
|
|
25
28
|
createNamecheapAddFundsRequest,
|
|
@@ -124,6 +127,66 @@ async function credentialsFromFlags(
|
|
|
124
127
|
return creds;
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/** Registrars report expiry in mixed formats (GoDaddy ISO, Namecheap MM/DD/YYYY). */
|
|
131
|
+
function parseExpiry(value?: string): Date | undefined {
|
|
132
|
+
if (!value) return undefined;
|
|
133
|
+
const mdy = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
134
|
+
const date = mdy ? new Date(`${mdy[3]}-${mdy[1]}-${mdy[2]}T00:00:00Z`) : new Date(value);
|
|
135
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatDay(date: Date): string {
|
|
139
|
+
return date.toISOString().slice(0, 10);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* RDAP-check registration for domains whose source can't attest ownership.
|
|
146
|
+
* Fresh results (24h) come from the local cache, so repeat runs are instant.
|
|
147
|
+
*/
|
|
148
|
+
async function verifyRegistrations(domains: string[]): Promise<Map<string, RdapRegistration>> {
|
|
149
|
+
const unique = [...new Set(domains)];
|
|
150
|
+
const out = cachedRegistrations(unique);
|
|
151
|
+
const missing = unique.filter((domain) => !out.has(domain));
|
|
152
|
+
const run = async (targets: string[], batchSize: number, pauseMs: number) => {
|
|
153
|
+
for (let i = 0; i < targets.length; i += batchSize) {
|
|
154
|
+
if (i > 0) await sleep(pauseMs);
|
|
155
|
+
const results = await Promise.all(targets.slice(i, i + batchSize).map(rdapRegistration));
|
|
156
|
+
for (const result of results) out.set(result.domain, result);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
await run(missing, 4, 400);
|
|
160
|
+
// Registries rate-limit bursts; give inconclusive lookups one slower retry.
|
|
161
|
+
const inconclusive = [...out.values()].filter((r) => r.registered === null).map((r) => r.domain);
|
|
162
|
+
if (inconclusive.length > 0) {
|
|
163
|
+
await sleep(2000);
|
|
164
|
+
await run(inconclusive, 2, 1000);
|
|
165
|
+
}
|
|
166
|
+
if (missing.length > 0) storeRegistrations(out.values());
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function inventoryMarker(item: InventoryDomain, verified?: RdapRegistration): string {
|
|
171
|
+
const now = new Date();
|
|
172
|
+
if (verified) {
|
|
173
|
+
if (verified.registered === false) return "NOT REGISTERED — lapsed";
|
|
174
|
+
if (verified.registered === null) return `rdap inconclusive (${verified.detail})`;
|
|
175
|
+
const expiry = parseExpiry(verified.expiresAt);
|
|
176
|
+
if (expiry) {
|
|
177
|
+
return expiry < now
|
|
178
|
+
? `registered · EXPIRED ${formatDay(expiry)} (rdap)`
|
|
179
|
+
: `registered · expires ${formatDay(expiry)} (rdap)`;
|
|
180
|
+
}
|
|
181
|
+
return verified.detail ? `registered · ${verified.detail}` : "registered (rdap)";
|
|
182
|
+
}
|
|
183
|
+
const expiry = parseExpiry(item.expiresAt);
|
|
184
|
+
if (expiry) {
|
|
185
|
+
return expiry < now ? `EXPIRED ${formatDay(expiry)}` : `expires ${formatDay(expiry)}`;
|
|
186
|
+
}
|
|
187
|
+
return item.status === "hosted" ? "hosted (ownership unknown)" : "no expiry data";
|
|
188
|
+
}
|
|
189
|
+
|
|
127
190
|
async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
|
|
128
191
|
const store = readRegistrarStore();
|
|
129
192
|
const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
|
|
@@ -192,24 +255,62 @@ domainsCommand.action(() => {
|
|
|
192
255
|
|
|
193
256
|
domainsCommand
|
|
194
257
|
.command("list")
|
|
195
|
-
.description("
|
|
258
|
+
.description("Inventory across connected registrars and cPanel hosts, grouped by provider")
|
|
196
259
|
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost|cpanel)")
|
|
260
|
+
.option("--verify", "RDAP-check registration for entries without expiry data (zones/hosted sites)", false)
|
|
197
261
|
.option("--json", "Output JSON", false)
|
|
198
262
|
.action(async (opts) => {
|
|
199
263
|
try {
|
|
200
264
|
const provider = parseProvider(opts.provider);
|
|
201
265
|
const domains = await listInventory(provider);
|
|
266
|
+
|
|
267
|
+
// Zones (DreamHost) and hosted sites (cPanel) carry no registration
|
|
268
|
+
// data — a lapsed domain can linger there forever. --verify asks RDAP.
|
|
269
|
+
let verified: Map<string, RdapRegistration> | undefined;
|
|
270
|
+
if (opts.verify) {
|
|
271
|
+
const unverifiable = domains.filter((item) => !parseExpiry(item.expiresAt));
|
|
272
|
+
verified = await verifyRegistrations(unverifiable.map((item) => item.domain));
|
|
273
|
+
}
|
|
274
|
+
|
|
202
275
|
if (opts.json) {
|
|
203
|
-
|
|
276
|
+
const enriched = domains.map((item) => {
|
|
277
|
+
const check = verified?.get(item.domain);
|
|
278
|
+
return check
|
|
279
|
+
? {
|
|
280
|
+
...item,
|
|
281
|
+
registration: {
|
|
282
|
+
registered: check.registered,
|
|
283
|
+
...(check.expiresAt ? { expiresAt: check.expiresAt } : {}),
|
|
284
|
+
...(check.detail ? { detail: check.detail } : {}),
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
: item;
|
|
288
|
+
});
|
|
289
|
+
printJson({ domains: enriched, count: enriched.length });
|
|
204
290
|
return;
|
|
205
291
|
}
|
|
292
|
+
|
|
206
293
|
if (domains.length === 0) {
|
|
207
294
|
console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
|
|
208
295
|
return;
|
|
209
296
|
}
|
|
297
|
+
|
|
298
|
+
const byProvider = new Map<ProviderId, InventoryDomain[]>();
|
|
210
299
|
for (const item of domains) {
|
|
211
|
-
const
|
|
212
|
-
|
|
300
|
+
const group = byProvider.get(item.provider) || [];
|
|
301
|
+
group.push(item);
|
|
302
|
+
byProvider.set(item.provider, group);
|
|
303
|
+
}
|
|
304
|
+
for (const [id, items] of byProvider) {
|
|
305
|
+
console.log(`${id} (${items.length})`);
|
|
306
|
+
for (const item of items) {
|
|
307
|
+
console.log(` ${item.domain.padEnd(30)} ${inventoryMarker(item, verified?.get(item.domain))}`);
|
|
308
|
+
}
|
|
309
|
+
console.log("");
|
|
310
|
+
}
|
|
311
|
+
if (!opts.verify && domains.some((item) => !parseExpiry(item.expiresAt))) {
|
|
312
|
+
console.log("Entries without expiry come from DNS zones / hosted sites, which don't include");
|
|
313
|
+
console.log("registration data. Confirm each one: uplink domains list --verify");
|
|
213
314
|
}
|
|
214
315
|
} catch (error) {
|
|
215
316
|
handleError(error, { json: opts.json });
|
|
@@ -58,7 +58,9 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
|
58
58
|
label: "My domains",
|
|
59
59
|
action: async () => {
|
|
60
60
|
try {
|
|
61
|
-
|
|
61
|
+
// --verify resolves registration for zone/hosted entries; results
|
|
62
|
+
// are cached for a day, so only the first open is slow.
|
|
63
|
+
const output = runCliCapture(["domains", "list", "--verify"]);
|
|
62
64
|
restoreRawMode();
|
|
63
65
|
return output || "No domains. Connect a registrar first.";
|
|
64
66
|
} catch (error) {
|
|
@@ -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") {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { RdapRegistration } from "./domain-availability";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Registration status changes rarely, and registry RDAP lookups are slow in
|
|
8
|
+
* bulk. Cache results for a day so `domains list --verify` (and the menu's
|
|
9
|
+
* My domains view) is instant after the first check.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
type CacheEntry = {
|
|
15
|
+
registered: boolean | null;
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
detail?: string;
|
|
18
|
+
checkedAt: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type CacheFile = Record<string, CacheEntry>;
|
|
22
|
+
|
|
23
|
+
function cachePath(): string {
|
|
24
|
+
return join(homedir(), ".uplink", "rdap-cache.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadCache(): CacheFile {
|
|
28
|
+
const path = cachePath();
|
|
29
|
+
if (!existsSync(path)) return {};
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile;
|
|
32
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cachedRegistrations(domains: string[]): Map<string, RdapRegistration> {
|
|
39
|
+
const cache = loadCache();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const out = new Map<string, RdapRegistration>();
|
|
42
|
+
for (const domain of domains) {
|
|
43
|
+
const entry = cache[domain];
|
|
44
|
+
// Inconclusive results (registered === null) are never served from cache.
|
|
45
|
+
if (!entry || entry.registered === null || now - entry.checkedAt > TTL_MS) continue;
|
|
46
|
+
out.set(domain, {
|
|
47
|
+
domain,
|
|
48
|
+
registered: entry.registered,
|
|
49
|
+
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
|
|
50
|
+
...(entry.detail ? { detail: entry.detail } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function storeRegistrations(results: Iterable<RdapRegistration>): void {
|
|
57
|
+
const cache = loadCache();
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const result of results) {
|
|
60
|
+
if (result.registered === null) continue;
|
|
61
|
+
cache[result.domain] = {
|
|
62
|
+
registered: result.registered,
|
|
63
|
+
...(result.expiresAt ? { expiresAt: result.expiresAt } : {}),
|
|
64
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
65
|
+
checkedAt: now,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
mkdirSync(join(homedir(), ".uplink"), { recursive: true });
|
|
69
|
+
writeFileSync(cachePath(), JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(cachePath(), 0o600);
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
}
|
package/package.json
CHANGED