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 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
- 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,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("List domains owned at connected registrars")
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
- printJson({ domains, count: domains.length });
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 expiry = item.expiresAt ? ` expires ${item.expiresAt}` : "";
212
- console.log(`- ${item.domain} (${item.provider}${expiry})`);
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
- const output = runCliCapture(["domains", "list"]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",