uplink-cli 0.2.3 → 0.2.6

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.
@@ -1,15 +1,37 @@
1
1
  import { Box, Text, useApp, useInput, render } from "ink";
2
2
  import { Wordmark } from "./brand";
3
3
  import TextInput from "ink-text-input";
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import type { PublicAvailability } from "../utils/domain-availability";
6
6
  import { expandDomainQuery } from "../utils/domain-search";
7
7
  import { checkDomainAvailability } from "../utils/domain-availability";
8
8
  import { prepareStdinForPrompt } from "../subcommands/menu/io";
9
+ import { readRegistrarStore } from "../registrars/store";
10
+ import { getAdapter } from "../registrars";
11
+ import type { DomainQuote } from "../registrars/types";
12
+ import {
13
+ createNamecheapAddFundsRequest,
14
+ getNamecheapBalance,
15
+ namecheapCartUrl,
16
+ registerNamecheapDomain,
17
+ fetchNamecheapDomainContact,
18
+ type NamecheapBalance,
19
+ } from "../registrars/namecheap-purchase";
20
+ import {
21
+ contactMissingFields,
22
+ readRegistrantContact,
23
+ writeRegistrantContact,
24
+ type RegistrantContact,
25
+ } from "../utils/registrant-contact";
26
+ import { openInBrowser } from "../utils/open-browser";
27
+ import { useTerminalMouse } from "./use-terminal-mouse";
9
28
 
10
29
  type Row = PublicAvailability | { domain: string; status: "checking" };
30
+ type Focus = "search" | "list" | "detail";
11
31
 
12
32
  const DEBOUNCE_MS = 400;
33
+ /** Approx terminal row where the result list starts (1-based, after wordmark/title/search). */
34
+ const LIST_TOP_ROW = 8;
13
35
 
14
36
  function useLiveChecks(raw: string): Row[] {
15
37
  const [rows, setRows] = useState<Row[]>([]);
@@ -37,28 +59,396 @@ function useLiveChecks(raw: string): Row[] {
37
59
  return rows;
38
60
  }
39
61
 
62
+ function statusGlyph(status: Row["status"]): string {
63
+ if (status === "checking") return "·";
64
+ if (status === "available") return "✓";
65
+ if (status === "taken") return "×";
66
+ return "?";
67
+ }
68
+
40
69
  function statusColor(status: Row["status"]): string | undefined {
41
70
  if (status === "available") return "green";
42
- if (status === "taken") return undefined;
43
71
  if (status === "unknown") return "yellow";
44
72
  return undefined;
45
73
  }
46
74
 
75
+ function formatMoney(n: number | undefined, currency = "USD"): string {
76
+ if (n == null || !Number.isFinite(n)) return "—";
77
+ return `$${n.toFixed(2)} ${currency}`;
78
+ }
79
+
80
+ async function ensureRegistrantContact(): Promise<RegistrantContact | null> {
81
+ const existing = readRegistrantContact();
82
+ if (existing && contactMissingFields(existing).length === 0) return existing;
83
+ const store = readRegistrarStore();
84
+ const creds = store.namecheap;
85
+ if (!creds) return existing;
86
+ try {
87
+ const owned = await getAdapter("namecheap").listDomains(creds);
88
+ for (const item of owned.slice(0, 5)) {
89
+ const contact = await fetchNamecheapDomainContact(creds, item.domain);
90
+ if (contact && contactMissingFields(contact).length === 0) {
91
+ writeRegistrantContact(contact);
92
+ return contact;
93
+ }
94
+ }
95
+ } catch {
96
+ /* ignore seed failures */
97
+ }
98
+ return existing;
99
+ }
100
+
101
+ type DetailState = {
102
+ domain: string;
103
+ publicStatus: Row["status"];
104
+ loading: boolean;
105
+ quote?: DomainQuote;
106
+ balance?: NamecheapBalance;
107
+ contact?: RegistrantContact | null;
108
+ namecheapConnected: boolean;
109
+ message?: string;
110
+ error?: string;
111
+ };
112
+
113
+ function DomainDetail({
114
+ state,
115
+ actionHint,
116
+ }: {
117
+ state: DetailState;
118
+ actionHint: string;
119
+ }) {
120
+ const quote = state.quote;
121
+ const price = quote?.priceUsd;
122
+ const balance = state.balance?.available;
123
+ const shortfall =
124
+ price != null && balance != null && balance + 0.001 < price ? price - balance : 0;
125
+ const canBuy =
126
+ state.namecheapConnected &&
127
+ quote?.buyable &&
128
+ quote.status === "available" &&
129
+ shortfall <= 0 &&
130
+ contactMissingFields(state.contact).length === 0;
131
+
132
+ return (
133
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
134
+ <Wordmark />
135
+ <Box marginTop={1}>
136
+ <Text bold>{state.domain}</Text>
137
+ </Box>
138
+ {state.loading ? (
139
+ <Box marginTop={1}>
140
+ <Text dimColor>Looking up Namecheap price & balance…</Text>
141
+ </Box>
142
+ ) : (
143
+ <Box flexDirection="column" marginTop={1}>
144
+ <Text>
145
+ Public: {state.publicStatus}
146
+ {quote ? ` · Namecheap: ${quote.status}` : ""}
147
+ {quote?.premium ? " (premium)" : ""}
148
+ </Text>
149
+ <Text>
150
+ Price: {formatMoney(price)}
151
+ {state.balance ? ` · Balance: ${formatMoney(state.balance.available, state.balance.currency)}` : ""}
152
+ </Text>
153
+ {shortfall > 0 && (
154
+ <Text color="yellow">Need ~{formatMoney(shortfall)} more in Namecheap balance to buy via API.</Text>
155
+ )}
156
+ {!state.namecheapConnected && (
157
+ <Text dimColor>Connect Namecheap to buy: uplink domains providers connect namecheap</Text>
158
+ )}
159
+ {state.namecheapConnected && contactMissingFields(state.contact).length > 0 && (
160
+ <Text color="yellow">
161
+ Missing registrant profile ({contactMissingFields(state.contact).join(", ")}). Run: uplink
162
+ domains contact set
163
+ </Text>
164
+ )}
165
+ {state.error && <Text color="red">{state.error}</Text>}
166
+ {state.message && <Text color="green">{state.message}</Text>}
167
+ </Box>
168
+ )}
169
+ <Box marginTop={1} flexDirection="column">
170
+ <Text dimColor>{actionHint}</Text>
171
+ <Text dimColor>
172
+ {canBuy ? "b buy · " : ""}
173
+ {state.namecheapConnected ? "f add funds · " : ""}
174
+ o open Namecheap cart · esc back
175
+ </Text>
176
+ </Box>
177
+ </Box>
178
+ );
179
+ }
180
+
47
181
  function DomainSearchApp() {
48
182
  const { exit } = useApp();
49
183
  const [query, setQuery] = useState("");
50
184
  const [showTaken, setShowTaken] = useState(false);
185
+ const [focus, setFocus] = useState<Focus>("search");
186
+ const [selected, setSelected] = useState(0);
187
+ const [detail, setDetail] = useState<DetailState | null>(null);
188
+ const [busy, setBusy] = useState(false);
189
+ const [awaitConfirm, setAwaitConfirm] = useState(false);
51
190
  const rows = useLiveChecks(query);
52
- const pending = rows.some((row) => row.status === "checking");
191
+
53
192
  const available = rows.filter((row) => row.status === "available");
54
193
  const taken = rows.filter((row) => row.status === "taken");
55
194
  const rest = rows.filter((row) => row.status !== "taken");
195
+ const visible = useMemo(() => {
196
+ const base = showTaken ? [...rest, ...taken] : rest;
197
+ return base;
198
+ }, [rest, taken, showTaken]);
199
+
200
+ useEffect(() => {
201
+ setSelected((i) => (visible.length === 0 ? 0 : Math.min(i, visible.length - 1)));
202
+ }, [visible.length]);
56
203
 
57
- useInput((_input, key) => {
58
- if (key.escape) exit();
59
- if (key.tab) setShowTaken((prev) => !prev);
204
+ const openDetail = useCallback(async (row: Row) => {
205
+ const store = readRegistrarStore();
206
+ const creds = store.namecheap;
207
+ setFocus("detail");
208
+ setAwaitConfirm(false);
209
+ setDetail({
210
+ domain: row.domain,
211
+ publicStatus: row.status,
212
+ loading: Boolean(creds),
213
+ namecheapConnected: Boolean(creds),
214
+ });
215
+ if (!creds) return;
216
+ try {
217
+ const [quote, balance, contact] = await Promise.all([
218
+ getAdapter("namecheap").check(creds, row.domain),
219
+ getNamecheapBalance(creds),
220
+ ensureRegistrantContact(),
221
+ ]);
222
+ setDetail({
223
+ domain: row.domain,
224
+ publicStatus: row.status,
225
+ loading: false,
226
+ quote,
227
+ balance,
228
+ contact,
229
+ namecheapConnected: true,
230
+ });
231
+ } catch (error) {
232
+ setDetail({
233
+ domain: row.domain,
234
+ publicStatus: row.status,
235
+ loading: false,
236
+ namecheapConnected: true,
237
+ error: error instanceof Error ? error.message : String(error),
238
+ });
239
+ }
240
+ }, []);
241
+
242
+ const moveSelection = useCallback(
243
+ (delta: number) => {
244
+ if (visible.length === 0) return;
245
+ setFocus("list");
246
+ setSelected((i) => (i + delta + visible.length) % visible.length);
247
+ },
248
+ [visible.length]
249
+ );
250
+
251
+ const onMouse = useCallback(
252
+ (event: { type: string; direction?: string; y?: number }) => {
253
+ if (focus === "detail" || busy) return;
254
+ if (event.type === "wheel") {
255
+ moveSelection(event.direction === "up" ? -1 : 1);
256
+ return;
257
+ }
258
+ if (event.type === "click" && typeof event.y === "number") {
259
+ if (visible.length === 0) return;
260
+ const index = event.y - LIST_TOP_ROW;
261
+ if (index >= 0 && index < visible.length) {
262
+ setSelected(index);
263
+ setFocus("list");
264
+ void openDetail(visible[index]);
265
+ } else if (focus === "list" && visible[selected]) {
266
+ void openDetail(visible[selected]);
267
+ }
268
+ }
269
+ },
270
+ [busy, focus, moveSelection, openDetail, selected, visible]
271
+ );
272
+
273
+ useTerminalMouse(onMouse, focus !== "detail");
274
+
275
+ const runBuy = useCallback(async () => {
276
+ if (!detail || busy) return;
277
+ const store = readRegistrarStore();
278
+ const creds = store.namecheap;
279
+ if (!creds || !detail.quote?.buyable || !detail.contact) return;
280
+ setBusy(true);
281
+ try {
282
+ const result = await registerNamecheapDomain(creds, {
283
+ domain: detail.domain,
284
+ years: 1,
285
+ contact: detail.contact,
286
+ premium: detail.quote.premium,
287
+ premiumPrice: detail.quote.premium ? detail.quote.priceUsd : undefined,
288
+ });
289
+ setDetail((prev) =>
290
+ prev
291
+ ? {
292
+ ...prev,
293
+ message: result.registered
294
+ ? `Registered ${result.domain}${result.chargedAmount != null ? ` · charged $${result.chargedAmount.toFixed(2)}` : ""}`
295
+ : `Create returned registered=false for ${result.domain}`,
296
+ error: undefined,
297
+ }
298
+ : prev
299
+ );
300
+ setAwaitConfirm(false);
301
+ } catch (error) {
302
+ setDetail((prev) =>
303
+ prev
304
+ ? { ...prev, error: error instanceof Error ? error.message : String(error), message: undefined }
305
+ : prev
306
+ );
307
+ } finally {
308
+ setBusy(false);
309
+ }
310
+ }, [busy, detail]);
311
+
312
+ const runFund = useCallback(async () => {
313
+ if (!detail || busy) return;
314
+ const store = readRegistrarStore();
315
+ const creds = store.namecheap;
316
+ if (!creds) return;
317
+ const price = detail.quote?.priceUsd ?? 10;
318
+ const balance = detail.balance?.available ?? 0;
319
+ const need = Math.max(10, Math.ceil(Math.max(price - balance, price) + 1));
320
+ setBusy(true);
321
+ try {
322
+ const funds = await createNamecheapAddFundsRequest(creds, need);
323
+ openInBrowser(funds.redirectUrl);
324
+ setDetail((prev) =>
325
+ prev
326
+ ? {
327
+ ...prev,
328
+ message: `Opened Namecheap payment page for $${funds.amount.toFixed(2)}. After funding, press b to buy.`,
329
+ error: undefined,
330
+ }
331
+ : prev
332
+ );
333
+ } catch (error) {
334
+ setDetail((prev) =>
335
+ prev
336
+ ? { ...prev, error: error instanceof Error ? error.message : String(error), message: undefined }
337
+ : prev
338
+ );
339
+ } finally {
340
+ setBusy(false);
341
+ }
342
+ }, [busy, detail]);
343
+
344
+ useInput((input, key) => {
345
+ if (busy) return;
346
+
347
+ if (focus === "detail" && detail) {
348
+ if (key.escape || key.leftArrow) {
349
+ setDetail(null);
350
+ setFocus(visible.length ? "list" : "search");
351
+ setAwaitConfirm(false);
352
+ return;
353
+ }
354
+ if (awaitConfirm) {
355
+ if (input === "y" || input === "Y") {
356
+ void runBuy();
357
+ return;
358
+ }
359
+ if (input === "n" || input === "N" || key.escape) {
360
+ setAwaitConfirm(false);
361
+ return;
362
+ }
363
+ return;
364
+ }
365
+ if (input === "b") {
366
+ const missing = contactMissingFields(detail.contact);
367
+ if (!detail.namecheapConnected) {
368
+ setDetail({ ...detail, error: "Connect Namecheap first." });
369
+ return;
370
+ }
371
+ if (!detail.quote?.buyable) {
372
+ setDetail({ ...detail, error: "Domain is not buyable on Namecheap." });
373
+ return;
374
+ }
375
+ if (missing.length) {
376
+ setDetail({ ...detail, error: `Set registrant contact first: uplink domains contact set` });
377
+ return;
378
+ }
379
+ const price = detail.quote.priceUsd ?? 0;
380
+ const balance = detail.balance?.available ?? 0;
381
+ if (balance + 0.001 < price) {
382
+ setDetail({
383
+ ...detail,
384
+ error: `Insufficient balance (need ~$${price.toFixed(2)}, have $${balance.toFixed(2)}). Press f to add funds.`,
385
+ });
386
+ return;
387
+ }
388
+ setAwaitConfirm(true);
389
+ return;
390
+ }
391
+ if (input === "f") {
392
+ void runFund();
393
+ return;
394
+ }
395
+ if (input === "o") {
396
+ const url = namecheapCartUrl(detail.domain, 1);
397
+ openInBrowser(url);
398
+ setDetail({ ...detail, message: `Opened cart: ${url}` });
399
+ return;
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (key.escape) {
405
+ if (focus === "list") {
406
+ setFocus("search");
407
+ return;
408
+ }
409
+ exit();
410
+ return;
411
+ }
412
+ if (key.tab) {
413
+ setShowTaken((prev) => !prev);
414
+ return;
415
+ }
416
+ if (key.downArrow) {
417
+ if (visible.length) moveSelection(1);
418
+ return;
419
+ }
420
+ if (key.upArrow) {
421
+ if (visible.length) moveSelection(-1);
422
+ return;
423
+ }
424
+ if (key.return && focus === "list" && visible[selected]) {
425
+ void openDetail(visible[selected]);
426
+ return;
427
+ }
428
+ if (focus === "list" && input && !key.ctrl && !key.meta) {
429
+ // Typing returns to search
430
+ setFocus("search");
431
+ setQuery((q) => q + input);
432
+ }
60
433
  });
61
434
 
435
+ if (detail) {
436
+ return (
437
+ <DomainDetail
438
+ state={detail}
439
+ actionHint={
440
+ busy
441
+ ? "Working…"
442
+ : awaitConfirm
443
+ ? `Buy ${detail.domain} for ${formatMoney(detail.quote?.priceUsd)}? [y/n]`
444
+ : "Select an action"
445
+ }
446
+ />
447
+ );
448
+ }
449
+
450
+ const pending = rows.some((row) => row.status === "checking");
451
+
62
452
  return (
63
453
  <Box flexDirection="column" paddingX={1} paddingY={1}>
64
454
  <Wordmark />
@@ -67,36 +457,46 @@ function DomainSearchApp() {
67
457
  </Box>
68
458
  <Box marginTop={1}>
69
459
  <Text dimColor>search › </Text>
70
- <TextInput value={query} onChange={setQuery} placeholder="acme or acme.io" />
460
+ <TextInput
461
+ focus={focus === "search"}
462
+ value={query}
463
+ onChange={(value) => {
464
+ setQuery(value);
465
+ setFocus("search");
466
+ }}
467
+ placeholder="acme or acme.io"
468
+ />
71
469
  </Box>
72
- {rest.length > 0 && (
470
+ {visible.length > 0 && (
73
471
  <Box flexDirection="column" marginTop={1}>
74
- {rest.map((row) => (
75
- <Text key={row.domain} color={statusColor(row.status)} dimColor={row.status === "checking"}>
76
- {row.status === "checking" ? "·" : row.status === "available" ? "✓" : "?"} {row.domain}
77
- </Text>
78
- ))}
472
+ {visible.map((row, index) => {
473
+ const active = focus === "list" && index === selected;
474
+ return (
475
+ <Text
476
+ key={row.domain}
477
+ color={active ? "cyan" : statusColor(row.status)}
478
+ bold={active}
479
+ dimColor={!active && (row.status === "checking" || row.status === "taken")}
480
+ >
481
+ {active ? "› " : " "}
482
+ {statusGlyph(row.status)} {row.domain}
483
+ {row.status === "taken" ? " taken" : ""}
484
+ </Text>
485
+ );
486
+ })}
79
487
  </Box>
80
488
  )}
81
- {taken.length > 0 && (
82
- <Box flexDirection="column" marginTop={1}>
83
- {showTaken ? (
84
- taken.map((row) => (
85
- <Text key={row.domain} dimColor>
86
- × {row.domain}
87
- </Text>
88
- ))
89
- ) : (
90
- <Text dimColor>
91
- {taken.length} taken · tab to show
92
- </Text>
93
- )}
489
+ {!showTaken && taken.length > 0 && (
490
+ <Box marginTop={1}>
491
+ <Text dimColor>
492
+ {taken.length} taken · tab to show
493
+ </Text>
94
494
  </Box>
95
495
  )}
96
496
  <Box marginTop={1}>
97
497
  <Text dimColor>
98
498
  {rows.length > 0 && !pending ? `${available.length} of ${rows.length} free · ` : ""}
99
- tab taken · esc back · DNS + RDAP, no registrar required
499
+ ↑↓ / wheel select · enter / click details · tab taken · esc back
100
500
  </Text>
101
501
  </Box>
102
502
  </Box>
@@ -0,0 +1,60 @@
1
+ import { useEffect } from "react";
2
+
3
+ export type TerminalMouseEvent =
4
+ | { type: "wheel"; direction: "up" | "down" }
5
+ | { type: "click"; x: number; y: number; button: "left" };
6
+
7
+ const ENABLE = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
8
+ const DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
9
+
10
+ /** SGR mouse: ESC [ < btn ; x ; y M/m (1-based x/y) */
11
+ const SGR = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
12
+
13
+ function parseEvents(chunk: string): TerminalMouseEvent[] {
14
+ const events: TerminalMouseEvent[] = [];
15
+ for (const match of chunk.matchAll(SGR)) {
16
+ const btn = Number(match[1]);
17
+ const x = Number(match[2]);
18
+ const y = Number(match[3]);
19
+ const release = match[4] === "m";
20
+ // Wheel buttons (SGR): 64 up, 65 down (sometimes +32 for motion)
21
+ if (btn === 64 || btn === 96) {
22
+ events.push({ type: "wheel", direction: "up" });
23
+ continue;
24
+ }
25
+ if (btn === 65 || btn === 97) {
26
+ events.push({ type: "wheel", direction: "down" });
27
+ continue;
28
+ }
29
+ // Left button release = click
30
+ if (release && (btn === 0 || btn === 32)) {
31
+ events.push({ type: "click", x, y, button: "left" });
32
+ }
33
+ }
34
+ return events;
35
+ }
36
+
37
+ /**
38
+ * Best-effort terminal mouse tracking (wheel + left click).
39
+ * Callers should keep a useInput handler active so escape sequences are consumed.
40
+ */
41
+ export function useTerminalMouse(onEvent: (event: TerminalMouseEvent) => void, active = true): void {
42
+ useEffect(() => {
43
+ if (!active || !process.stdin.isTTY || !process.stdout.isTTY) return;
44
+ const stdin = process.stdin;
45
+ process.stdout.write(ENABLE);
46
+ const onData = (buf: Buffer | string) => {
47
+ const text = typeof buf === "string" ? buf : buf.toString("utf8");
48
+ for (const event of parseEvents(text)) onEvent(event);
49
+ };
50
+ stdin.on("data", onData);
51
+ return () => {
52
+ stdin.off("data", onData);
53
+ try {
54
+ process.stdout.write(DISABLE);
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ };
59
+ }, [onEvent, active]);
60
+ }
@@ -0,0 +1,16 @@
1
+ import { spawn } from "child_process";
2
+
3
+ /** Open a URL in the default browser (best-effort). */
4
+ export function openInBrowser(url: string): void {
5
+ const [cmd, args] =
6
+ process.platform === "darwin"
7
+ ? ["open", [url]]
8
+ : process.platform === "win32"
9
+ ? ["cmd", ["/c", "start", "", url]]
10
+ : ["xdg-open", [url]];
11
+ try {
12
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
13
+ } catch {
14
+ /* Non-fatal — callers still print the URL. */
15
+ }
16
+ }
@@ -0,0 +1,64 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ /** WHOIS / registrant contact used for Namecheap domains.create */
6
+ export type RegistrantContact = {
7
+ firstName: string;
8
+ lastName: string;
9
+ address1: string;
10
+ city: string;
11
+ stateProvince: string;
12
+ postalCode: string;
13
+ country: string;
14
+ phone: string;
15
+ email: string;
16
+ organizationName?: string;
17
+ };
18
+
19
+ function contactPath(): string {
20
+ return join(homedir(), ".uplink", "registrant.json");
21
+ }
22
+
23
+ export function readRegistrantContact(): RegistrantContact | null {
24
+ const path = contactPath();
25
+ if (!existsSync(path)) return null;
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as RegistrantContact;
28
+ if (!parsed?.firstName || !parsed?.lastName || !parsed?.email || !parsed?.phone) return null;
29
+ if (!parsed.address1 || !parsed.city || !parsed.stateProvince || !parsed.postalCode || !parsed.country) {
30
+ return null;
31
+ }
32
+ return parsed;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function writeRegistrantContact(contact: RegistrantContact): void {
39
+ const dir = join(homedir(), ".uplink");
40
+ mkdirSync(dir, { recursive: true });
41
+ const path = contactPath();
42
+ writeFileSync(path, JSON.stringify(contact, null, 2), { encoding: "utf8", mode: 0o600 });
43
+ try {
44
+ chmodSync(path, 0o600);
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ }
49
+
50
+ export function contactMissingFields(contact: Partial<RegistrantContact> | null | undefined): string[] {
51
+ const required: (keyof RegistrantContact)[] = [
52
+ "firstName",
53
+ "lastName",
54
+ "address1",
55
+ "city",
56
+ "stateProvince",
57
+ "postalCode",
58
+ "country",
59
+ "phone",
60
+ "email",
61
+ ];
62
+ if (!contact) return required;
63
+ return required.filter((key) => !String(contact[key] || "").trim());
64
+ }