uplink-cli 0.2.3 → 0.2.7

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.
@@ -15,11 +15,27 @@ import {
15
15
  type RegistrarCredentials,
16
16
  } from "../registrars";
17
17
  import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
18
+ import { cpanelAccountsOf, mergeCpanelCredentials, normalizeCpanelHost } from "../registrars/cpanel";
18
19
  import {
19
20
  checkDomainAvailability,
20
21
  formatPublicAvailability,
21
22
  } from "../utils/domain-availability";
22
23
  import { searchDomains } from "../utils/domain-search";
24
+ import {
25
+ createNamecheapAddFundsRequest,
26
+ fetchNamecheapDomainContact,
27
+ getNamecheapBalance,
28
+ namecheapCartUrl,
29
+ registerNamecheapDomain,
30
+ } from "../registrars/namecheap-purchase";
31
+ import {
32
+ contactMissingFields,
33
+ readRegistrantContact,
34
+ writeRegistrantContact,
35
+ type RegistrantContact,
36
+ } from "../utils/registrant-contact";
37
+ import { openInBrowser } from "../utils/open-browser";
38
+ import { promptLine } from "./menu/io";
23
39
 
24
40
  function runDomainSearchTui(): void {
25
41
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -29,8 +45,8 @@ function runDomainSearchTui(): void {
29
45
  runEsmEntry(join(__dirname, "../tui/domain-search.mts"));
30
46
  }
31
47
 
32
- // DreamHost last: it can only confirm ownership, not quote availability.
33
- const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
48
+ // DreamHost and cPanel last: they can only confirm ownership, not quote availability.
49
+ const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost", "cpanel"];
34
50
 
35
51
  function parseProvider(raw?: string): ProviderId | undefined {
36
52
  if (!raw) return undefined;
@@ -41,10 +57,34 @@ function parseProvider(raw?: string): ProviderId | undefined {
41
57
 
42
58
  async function credentialsFromFlags(
43
59
  provider: ProviderId,
44
- opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; json?: boolean }
60
+ opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; host?: string; json?: boolean }
45
61
  ): Promise<RegistrarCredentials> {
46
62
  const interactive = canPrompt() && !opts.json;
47
63
 
64
+ if (provider === "cpanel") {
65
+ const host = opts.host
66
+ ? String(opts.host)
67
+ : interactive
68
+ ? (await promptLine("cPanel host (e.g. server341.web-hosting.com): ")).trim()
69
+ : "";
70
+ const apiUser = opts.userEnv
71
+ ? readEnvValue(opts.userEnv)
72
+ : interactive
73
+ ? await promptSecret("cPanel username: ")
74
+ : "";
75
+ const token = opts.tokenEnv
76
+ ? readEnvValue(opts.tokenEnv)
77
+ : interactive
78
+ ? await promptSecret("cPanel API token: ")
79
+ : "";
80
+ if (!host || !apiUser || !token) {
81
+ throw new Error(
82
+ "cPanel needs --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN"
83
+ );
84
+ }
85
+ return { host, apiUser, token };
86
+ }
87
+
48
88
  if (provider === "namecheap") {
49
89
  const apiKey = opts.tokenEnv
50
90
  ? readEnvValue(opts.tokenEnv)
@@ -153,7 +193,7 @@ domainsCommand.action(() => {
153
193
  domainsCommand
154
194
  .command("list")
155
195
  .description("List domains owned at connected registrars")
156
- .option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost)")
196
+ .option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost|cpanel)")
157
197
  .option("--json", "Output JSON", false)
158
198
  .action(async (opts) => {
159
199
  try {
@@ -256,6 +296,271 @@ domainsCommand
256
296
  }
257
297
  });
258
298
 
299
+ domainsCommand
300
+ .command("buy")
301
+ .description("Register a domain via Namecheap (charges account balance)")
302
+ .argument("<domain>", "Domain to register (e.g. alchemy.photos)")
303
+ .option("--years <n>", "Registration years", "1")
304
+ .option("--yes", "Skip confirmation (required for non-interactive)", false)
305
+ .option("--open-cart", "Open Namecheap browser cart instead of API purchase", false)
306
+ .option("--json", "Output JSON", false)
307
+ .action(async (domainArg: string, opts) => {
308
+ try {
309
+ const domain = String(domainArg).trim().toLowerCase();
310
+ if (!domain.includes(".")) throw new Error("Pass a full domain like alchemy.photos");
311
+ const years = Math.max(1, Number(opts.years) || 1);
312
+
313
+ if (opts.openCart) {
314
+ const url = namecheapCartUrl(domain, years);
315
+ if (opts.json) {
316
+ printJson({ domain, url, mode: "cart" });
317
+ return;
318
+ }
319
+ console.log(url);
320
+ openInBrowser(url);
321
+ return;
322
+ }
323
+
324
+ const store = readRegistrarStore();
325
+ const creds = store.namecheap;
326
+ if (!creds) throw new Error("Connect Namecheap first: uplink domains providers connect namecheap");
327
+
328
+ const quote = await getAdapter("namecheap").check(creds, domain);
329
+ if (!quote.buyable || quote.status !== "available") {
330
+ throw new Error(`${domain} is not available on Namecheap (${quote.status})`);
331
+ }
332
+
333
+ let contact = readRegistrantContact();
334
+ if (!contact || contactMissingFields(contact).length) {
335
+ const owned = await getAdapter("namecheap").listDomains(creds);
336
+ for (const item of owned.slice(0, 5)) {
337
+ const seeded = await fetchNamecheapDomainContact(creds, item.domain);
338
+ if (seeded && contactMissingFields(seeded).length === 0) {
339
+ writeRegistrantContact(seeded);
340
+ contact = seeded;
341
+ break;
342
+ }
343
+ }
344
+ }
345
+ if (!contact || contactMissingFields(contact).length) {
346
+ throw new Error(
347
+ "Registrant contact missing. Run: uplink domains contact set (or buy once you own another Namecheap domain so we can copy WHOIS)"
348
+ );
349
+ }
350
+
351
+ const balance = await getNamecheapBalance(creds);
352
+ const price = quote.priceUsd ?? 0;
353
+ if (balance.available + 0.001 < price) {
354
+ const need = Math.max(10, Math.ceil(price - balance.available + 1));
355
+ const funds = await createNamecheapAddFundsRequest(creds, need);
356
+ if (opts.json) {
357
+ printJson({
358
+ domain,
359
+ error: "INSUFFICIENT_BALANCE",
360
+ priceUsd: price,
361
+ balanceUsd: balance.available,
362
+ addFundsUrl: funds.redirectUrl,
363
+ amount: funds.amount,
364
+ cartUrl: namecheapCartUrl(domain, years),
365
+ });
366
+ return;
367
+ }
368
+ console.log(
369
+ `Insufficient Namecheap balance (need ~$${price.toFixed(2)}, have $${balance.available.toFixed(2)}).`
370
+ );
371
+ console.log(`Add funds: ${funds.redirectUrl}`);
372
+ console.log(`Or browser cart: ${namecheapCartUrl(domain, years)}`);
373
+ openInBrowser(funds.redirectUrl);
374
+ process.exitCode = 30;
375
+ return;
376
+ }
377
+
378
+ if (!opts.yes) {
379
+ if (!canPrompt()) throw new Error("Pass --yes to buy non-interactively");
380
+ const answer = (await promptLine(`Buy ${domain} for ~$${price.toFixed(2)}/${years}yr? [y/N] `))
381
+ .trim()
382
+ .toLowerCase();
383
+ if (answer !== "y" && answer !== "yes") {
384
+ if (opts.json) printJson({ domain, cancelled: true });
385
+ else console.log("Cancelled");
386
+ return;
387
+ }
388
+ }
389
+
390
+ const result = await registerNamecheapDomain(creds, {
391
+ domain,
392
+ years,
393
+ contact,
394
+ premium: quote.premium,
395
+ premiumPrice: quote.premium ? quote.priceUsd : undefined,
396
+ });
397
+ if (opts.json) {
398
+ printJson({ ...result, priceUsd: price, provider: "namecheap" });
399
+ return;
400
+ }
401
+ console.log(
402
+ result.registered
403
+ ? `Registered ${result.domain}${result.chargedAmount != null ? ` · charged $${result.chargedAmount.toFixed(2)}` : ""}`
404
+ : `Namecheap returned registered=false for ${result.domain}`
405
+ );
406
+ } catch (error) {
407
+ handleError(error, { json: opts.json });
408
+ }
409
+ });
410
+
411
+ domainsCommand
412
+ .command("fund")
413
+ .description("Open a Namecheap add-funds payment page (account balance for API purchases)")
414
+ .option("--amount <usd>", "Amount to add (min $5)", "20")
415
+ .option("--json", "Output JSON (does not open a browser)", false)
416
+ .action(async (opts) => {
417
+ try {
418
+ const store = readRegistrarStore();
419
+ const creds = store.namecheap;
420
+ if (!creds) throw new Error("Connect Namecheap first: uplink domains providers connect namecheap");
421
+ const amount = Number(opts.amount);
422
+ const funds = await createNamecheapAddFundsRequest(creds, amount);
423
+ const balance = await getNamecheapBalance(creds).catch(() => null);
424
+ if (opts.json) {
425
+ printJson({
426
+ amount: funds.amount,
427
+ redirectUrl: funds.redirectUrl,
428
+ tokenId: funds.tokenId,
429
+ balanceUsd: balance?.available,
430
+ });
431
+ return;
432
+ }
433
+ console.log(`Namecheap payment page (add $${funds.amount.toFixed(2)}):`);
434
+ console.log(funds.redirectUrl);
435
+ if (balance) console.log(`Current balance: $${balance.available.toFixed(2)} ${balance.currency}`);
436
+ openInBrowser(funds.redirectUrl);
437
+ } catch (error) {
438
+ handleError(error, { json: opts.json });
439
+ }
440
+ });
441
+
442
+ const contactCmd = domainsCommand.command("contact").description("Registrant WHOIS profile for Namecheap purchases");
443
+
444
+ contactCmd
445
+ .command("show")
446
+ .description("Show saved registrant contact (no secrets beyond WHOIS fields)")
447
+ .option("--json", "Output JSON", false)
448
+ .action((opts) => {
449
+ try {
450
+ const contact = readRegistrantContact();
451
+ if (opts.json) {
452
+ printJson({ contact, missing: contactMissingFields(contact) });
453
+ return;
454
+ }
455
+ if (!contact) {
456
+ console.log("No registrant profile. Run: uplink domains contact set");
457
+ return;
458
+ }
459
+ console.log(`${contact.firstName} ${contact.lastName} <${contact.email}>`);
460
+ console.log(`${contact.address1}`);
461
+ console.log(`${contact.city}, ${contact.stateProvince} ${contact.postalCode} ${contact.country}`);
462
+ console.log(contact.phone);
463
+ const missing = contactMissingFields(contact);
464
+ if (missing.length) console.log(`Missing: ${missing.join(", ")}`);
465
+ } catch (error) {
466
+ handleError(error, { json: opts.json });
467
+ }
468
+ });
469
+
470
+ contactCmd
471
+ .command("set")
472
+ .description("Save registrant contact used for Namecheap domains.create")
473
+ .option("--first-name <v>", "First name")
474
+ .option("--last-name <v>", "Last name")
475
+ .option("--address1 <v>", "Street address")
476
+ .option("--city <v>", "City")
477
+ .option("--state <v>", "State / province")
478
+ .option("--postal <v>", "Postal code")
479
+ .option("--country <v>", "Country code (e.g. US)")
480
+ .option("--phone <v>", "Phone in +1.5555555555 form")
481
+ .option("--email <v>", "Email")
482
+ .option("--org <v>", "Organization (optional)")
483
+ .option("--from-domain <domain>", "Copy WHOIS from an owned Namecheap domain")
484
+ .option("--json", "Output JSON", false)
485
+ .action(async (opts) => {
486
+ try {
487
+ let contact: RegistrantContact | null = readRegistrantContact();
488
+
489
+ if (opts.fromDomain) {
490
+ const store = readRegistrarStore();
491
+ const creds = store.namecheap;
492
+ if (!creds) throw new Error("Connect Namecheap first");
493
+ contact = await fetchNamecheapDomainContact(creds, String(opts.fromDomain).toLowerCase());
494
+ if (!contact) throw new Error(`No contacts returned for ${opts.fromDomain}`);
495
+ }
496
+
497
+ const ask = async (label: string, current?: string, flag?: string) => {
498
+ if (flag) return flag;
499
+ if (!canPrompt() || opts.json) return current || "";
500
+ const answer = (await promptLine(`${label}${current ? ` [${current}]` : ""}: `)).trim();
501
+ return answer || current || "";
502
+ };
503
+
504
+ contact = {
505
+ firstName: await ask("First name", contact?.firstName, opts.firstName),
506
+ lastName: await ask("Last name", contact?.lastName, opts.lastName),
507
+ address1: await ask("Address", contact?.address1, opts.address1),
508
+ city: await ask("City", contact?.city, opts.city),
509
+ stateProvince: await ask("State/province", contact?.stateProvince, opts.state),
510
+ postalCode: await ask("Postal code", contact?.postalCode, opts.postal),
511
+ country: await ask("Country (US)", contact?.country || "US", opts.country),
512
+ phone: await ask("Phone (+1.5555555555)", contact?.phone, opts.phone),
513
+ email: await ask("Email", contact?.email, opts.email),
514
+ organizationName: (await ask("Organization (optional)", contact?.organizationName, opts.org)) || undefined,
515
+ };
516
+
517
+ const missing = contactMissingFields(contact);
518
+ if (missing.length) throw new Error(`Missing required fields: ${missing.join(", ")}`);
519
+ writeRegistrantContact(contact);
520
+ if (opts.json) {
521
+ printJson({ saved: true, contact });
522
+ return;
523
+ }
524
+ console.log("Saved registrant profile to ~/.uplink/registrant.json");
525
+ } catch (error) {
526
+ handleError(error, { json: opts.json });
527
+ }
528
+ });
529
+
530
+ contactCmd
531
+ .command("seed")
532
+ .description("Copy registrant contact from the first owned Namecheap domain")
533
+ .option("--json", "Output JSON", false)
534
+ .action(async (opts) => {
535
+ try {
536
+ const store = readRegistrarStore();
537
+ const creds = store.namecheap;
538
+ if (!creds) throw new Error("Connect Namecheap first");
539
+ const owned = await getAdapter("namecheap").listDomains(creds);
540
+ if (owned.length === 0) throw new Error("No owned Namecheap domains to copy from");
541
+ let contact: RegistrantContact | null = null;
542
+ let source = "";
543
+ for (const item of owned) {
544
+ contact = await fetchNamecheapDomainContact(creds, item.domain);
545
+ if (contact && contactMissingFields(contact).length === 0) {
546
+ source = item.domain;
547
+ break;
548
+ }
549
+ }
550
+ if (!contact || contactMissingFields(contact).length) {
551
+ throw new Error("Could not read a complete contact from owned domains");
552
+ }
553
+ writeRegistrantContact(contact);
554
+ if (opts.json) {
555
+ printJson({ saved: true, source, contact });
556
+ return;
557
+ }
558
+ console.log(`Saved registrant profile from ${source}`);
559
+ } catch (error) {
560
+ handleError(error, { json: opts.json });
561
+ }
562
+ });
563
+
259
564
  const providers = domainsCommand.command("providers").description("Connect registrar accounts");
260
565
 
261
566
  providers
@@ -265,18 +570,25 @@ providers
265
570
  .action((opts) => {
266
571
  try {
267
572
  const store = readRegistrarStore();
268
- const items = adapters.map((adapter) => ({
269
- id: adapter.id,
270
- label: adapter.label,
271
- connected: Boolean(store[adapter.id]),
272
- help: adapter.connectHelp,
273
- }));
573
+ const items = adapters.map((adapter) => {
574
+ const creds = store[adapter.id];
575
+ const cpanelHosts =
576
+ adapter.id === "cpanel" && creds ? cpanelAccountsOf(creds).map((a) => a.host) : undefined;
577
+ return {
578
+ id: adapter.id,
579
+ label: adapter.label,
580
+ connected: Boolean(creds),
581
+ ...(cpanelHosts ? { hosts: cpanelHosts } : {}),
582
+ help: adapter.connectHelp,
583
+ };
584
+ });
274
585
  if (opts.json) {
275
586
  printJson({ providers: items });
276
587
  return;
277
588
  }
278
589
  for (const item of items) {
279
- console.log(`- ${item.id} ${item.connected ? "connected" : "not connected"}`);
590
+ const hosts = item.hosts?.length ? ` (${item.hosts.join(", ")})` : "";
591
+ console.log(`- ${item.id} ${item.connected ? `connected${hosts}` : "not connected"}`);
280
592
  if (!item.connected) console.log(` ${item.help}`);
281
593
  }
282
594
  } catch (error) {
@@ -286,22 +598,29 @@ providers
286
598
 
287
599
  providers
288
600
  .command("connect")
289
- .description("Save a registrar credential after a live check")
290
- .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
601
+ .description("Save a registrar or cPanel credential after a live check")
602
+ .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
291
603
  .option("--token-env <name>", "Env var holding the API token (comma-separate names for multiple keys)")
292
- .option("--user-env <name>", "Env var holding the Namecheap API user")
604
+ .option("--user-env <name>", "Env var holding the Namecheap API user or cPanel username")
293
605
  .option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
606
+ .option("--host <hostname>", "cPanel server hostname (e.g. server341.web-hosting.com)")
294
607
  .option("--json", "Output JSON", false)
295
608
  .action(async (providerArg: string, opts) => {
296
609
  try {
297
610
  const provider = String(providerArg).toLowerCase();
298
611
  if (!isProviderId(provider)) {
299
- throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, or dreamhost`);
612
+ throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, dreamhost, or cpanel`);
300
613
  }
301
614
  const adapter = getAdapter(provider);
302
615
  const creds = await credentialsFromFlags(provider, opts);
303
616
  const verified = await adapter.verify(creds);
304
- saveProvider(provider, verified);
617
+ // cPanel accumulates accounts (people have sites on several hosts);
618
+ // other providers replace the stored credential.
619
+ const toSave =
620
+ provider === "cpanel"
621
+ ? mergeCpanelCredentials(readRegistrarStore().cpanel, verified)
622
+ : verified;
623
+ saveProvider(provider, toSave);
305
624
  const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
306
625
  if (opts.json) {
307
626
  printJson({
@@ -319,13 +638,30 @@ providers
319
638
 
320
639
  providers
321
640
  .command("disconnect")
322
- .description("Remove a saved registrar credential")
323
- .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
641
+ .description("Remove a saved registrar or cPanel credential")
642
+ .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
643
+ .option("--host <hostname>", "cPanel only: remove just this host's account")
324
644
  .option("--json", "Output JSON", false)
325
645
  .action((providerArg: string, opts) => {
326
646
  try {
327
647
  const provider = String(providerArg).toLowerCase();
328
648
  if (!isProviderId(provider)) throw new Error(`Unknown provider: ${providerArg}`);
649
+
650
+ if (provider === "cpanel" && opts.host) {
651
+ const host = normalizeCpanelHost(String(opts.host));
652
+ const existing = readRegistrarStore().cpanel;
653
+ const accounts = existing ? cpanelAccountsOf(existing).filter((a) => a.host !== host) : [];
654
+ const removed = existing ? cpanelAccountsOf(existing).length !== accounts.length : false;
655
+ if (accounts.length === 0) removeProvider(provider);
656
+ else saveProvider(provider, { accounts });
657
+ if (opts.json) {
658
+ printJson({ provider, host, removed, remainingAccounts: accounts.length });
659
+ return;
660
+ }
661
+ console.log(removed ? `Removed cPanel account on ${host}` : `No cPanel account on ${host}`);
662
+ return;
663
+ }
664
+
329
665
  const removed = removeProvider(provider);
330
666
  if (opts.json) {
331
667
  printJson({ provider, connected: false, removed });
@@ -1,14 +1,20 @@
1
- import { runDomainSearch } from "../../../tui/DomainSearch";
1
+ import { join } from "path";
2
+ import { runEsmEntryAndWait } from "../../../utils/run-esm";
2
3
 
3
4
  type Deps = {
4
5
  promptLine: (question: string) => Promise<string>;
5
6
  restoreRawMode: () => void;
6
7
  };
7
8
 
8
- /** Built-in Find a domain TUI (DNS + RDAP). Domainking remains a separate app. */
9
+ /**
10
+ * Built-in Find a domain TUI (DNS + RDAP).
11
+ * Must not statically import Ink/DomainSearch — that file is ESM-only and
12
+ * pulling it into the CJS menu graph crashes tsx on yoga-layout.
13
+ */
9
14
  export function buildFindDomainAction(deps: Deps): () => Promise<string> {
10
15
  return async () => {
11
16
  deps.restoreRawMode();
12
- return runDomainSearch();
17
+ runEsmEntryAndWait(join(__dirname, "../../../tui/domain-search.mts"));
18
+ return "";
13
19
  };
14
20
  }
@@ -19,6 +19,7 @@ const PROVIDER_OPTIONS: SelectOption[] = [
19
19
  { label: "Hostinger", value: "hostinger" },
20
20
  { label: "Namecheap", value: "namecheap" },
21
21
  { label: "DreamHost", value: "dreamhost" },
22
+ { label: "cPanel hosting (Namecheap shared, Bluehost, HostGator, …)", value: "cpanel" },
22
23
  ];
23
24
 
24
25
  async function pickHostedApp(
@@ -77,7 +78,26 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
77
78
  const provider = choice.value;
78
79
  const extraEnv: Record<string, string> = {};
79
80
  const args = ["domains", "providers", "connect", provider, "--token-env", "UPLINK_CONNECT_TOKEN"];
80
- if (provider === "namecheap") {
81
+ if (provider === "cpanel") {
82
+ const host = (await promptLine("cPanel host (e.g. server341.web-hosting.com, or back): ")).trim();
83
+ if (!host || host === "back") {
84
+ restoreRawMode();
85
+ return "";
86
+ }
87
+ const user = (await promptLine("cPanel username (or back): ")).trim();
88
+ if (!user || user === "back") {
89
+ restoreRawMode();
90
+ return "";
91
+ }
92
+ const token = (await promptLine("cPanel API token (Security → Manage API Tokens, or back): ")).trim();
93
+ if (!token || token === "back") {
94
+ restoreRawMode();
95
+ return "";
96
+ }
97
+ extraEnv.UPLINK_CONNECT_TOKEN = token;
98
+ extraEnv.UPLINK_CONNECT_USER = user;
99
+ args.push("--user-env", "UPLINK_CONNECT_USER", "--host", host);
100
+ } else if (provider === "namecheap") {
81
101
  const user = (await promptLine("Namecheap API user (or back): ")).trim();
82
102
  if (!user || user === "back") {
83
103
  restoreRawMode();
@@ -176,10 +196,10 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
176
196
  label: "Help",
177
197
  action: async () => {
178
198
  return [
179
- "Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
199
+ "One hub for domains and hosting spread across providers: registrars and cPanel hosts in a single inventory.",
180
200
  "",
181
- " My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap / DreamHost",
182
- " Connect — save a registrar token (same as the CLI)",
201
+ " My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap / DreamHost / any cPanel host",
202
+ " Connect — save a registrar token or cPanel API token (same as the CLI)",
183
203
  " Find — search names that are not yours yet",
184
204
  " Attach — bind a hostname to a hosted app",
185
205
  " Verify — check DNS points at the hosting edge, then TLS",