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
- out.push({ domain: normalized, provider: "cpanel", status: "owned" });
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: "owned", buyable: false };
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
- status: "owned";
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("List domains owned at connected registrars")
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
- printJson({ domains, count: domains.length });
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 expiry = item.expiresAt ? ` expires ${item.expiresAt}` : "";
212
- console.log(`- ${item.domain} (${item.provider}${expiry})`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Software for agents — share localhost, host apps, and attach domains from the terminal. JSON-first CLI for Cursor, Claude, Codex, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-agents",