uplink-cli 0.2.2 → 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.
- package/AGENTS.md +18 -2
- package/CHANGELOG.md +18 -0
- package/README.md +5 -2
- package/cli/bin/uplink.js +3 -3
- package/cli/src/index.ts +3 -0
- package/cli/src/registrars/cpanel.ts +119 -0
- package/cli/src/registrars/index.ts +2 -0
- package/cli/src/registrars/namecheap-purchase.ts +219 -0
- package/cli/src/registrars/types.ts +3 -1
- package/cli/src/subcommands/domains.ts +328 -16
- package/cli/src/subcommands/menu/menus/domain-check.ts +9 -3
- package/cli/src/subcommands/menu/menus/domains.ts +24 -4
- package/cli/src/subcommands/menu.ts +2 -26
- package/cli/src/subcommands/upgrade.ts +62 -0
- package/cli/src/tui/DomainSearch.tsx +427 -27
- package/cli/src/tui/domain-search.mts +10 -0
- package/cli/src/tui/use-terminal-mouse.ts +60 -0
- package/cli/src/utils/open-browser.ts +16 -0
- package/cli/src/utils/registrant-contact.ts +64 -0
- package/cli/src/utils/run-esm.ts +56 -0
- package/docs/AGENTS.md +18 -2
- package/docs/PRODUCT.md +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { join } from "path";
|
|
2
3
|
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
import { runEsmEntry } from "../utils/run-esm";
|
|
3
5
|
import {
|
|
4
6
|
adapters,
|
|
5
7
|
getAdapter,
|
|
@@ -18,10 +20,32 @@ import {
|
|
|
18
20
|
formatPublicAvailability,
|
|
19
21
|
} from "../utils/domain-availability";
|
|
20
22
|
import { searchDomains } from "../utils/domain-search";
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createNamecheapAddFundsRequest,
|
|
25
|
+
fetchNamecheapDomainContact,
|
|
26
|
+
getNamecheapBalance,
|
|
27
|
+
namecheapCartUrl,
|
|
28
|
+
registerNamecheapDomain,
|
|
29
|
+
} from "../registrars/namecheap-purchase";
|
|
30
|
+
import {
|
|
31
|
+
contactMissingFields,
|
|
32
|
+
readRegistrantContact,
|
|
33
|
+
writeRegistrantContact,
|
|
34
|
+
type RegistrantContact,
|
|
35
|
+
} from "../utils/registrant-contact";
|
|
36
|
+
import { openInBrowser } from "../utils/open-browser";
|
|
37
|
+
import { promptLine } from "./menu/io";
|
|
38
|
+
|
|
39
|
+
function runDomainSearchTui(): void {
|
|
40
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
41
|
+
console.log("Domain search needs a terminal. Agents: uplink domains search myapp --json");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
runEsmEntry(join(__dirname, "../tui/domain-search.mts"));
|
|
45
|
+
}
|
|
22
46
|
|
|
23
|
-
// DreamHost last:
|
|
24
|
-
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
|
|
47
|
+
// DreamHost and cPanel last: they can only confirm ownership, not quote availability.
|
|
48
|
+
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost", "cpanel"];
|
|
25
49
|
|
|
26
50
|
function parseProvider(raw?: string): ProviderId | undefined {
|
|
27
51
|
if (!raw) return undefined;
|
|
@@ -32,10 +56,34 @@ function parseProvider(raw?: string): ProviderId | undefined {
|
|
|
32
56
|
|
|
33
57
|
async function credentialsFromFlags(
|
|
34
58
|
provider: ProviderId,
|
|
35
|
-
opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; json?: boolean }
|
|
59
|
+
opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; host?: string; json?: boolean }
|
|
36
60
|
): Promise<RegistrarCredentials> {
|
|
37
61
|
const interactive = canPrompt() && !opts.json;
|
|
38
62
|
|
|
63
|
+
if (provider === "cpanel") {
|
|
64
|
+
const host = opts.host
|
|
65
|
+
? String(opts.host)
|
|
66
|
+
: interactive
|
|
67
|
+
? (await promptLine("cPanel host (e.g. server341.web-hosting.com): ")).trim()
|
|
68
|
+
: "";
|
|
69
|
+
const apiUser = opts.userEnv
|
|
70
|
+
? readEnvValue(opts.userEnv)
|
|
71
|
+
: interactive
|
|
72
|
+
? await promptSecret("cPanel username: ")
|
|
73
|
+
: "";
|
|
74
|
+
const token = opts.tokenEnv
|
|
75
|
+
? readEnvValue(opts.tokenEnv)
|
|
76
|
+
: interactive
|
|
77
|
+
? await promptSecret("cPanel API token: ")
|
|
78
|
+
: "";
|
|
79
|
+
if (!host || !apiUser || !token) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"cPanel needs --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return { host, apiUser, token };
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
if (provider === "namecheap") {
|
|
40
88
|
const apiKey = opts.tokenEnv
|
|
41
89
|
? readEnvValue(opts.tokenEnv)
|
|
@@ -137,15 +185,14 @@ domainsCommand.addHelpText(
|
|
|
137
185
|
"\nWith no subcommand, opens Find a domain (type a name; common TLDs are checked via DNS/RDAP).\n"
|
|
138
186
|
);
|
|
139
187
|
|
|
140
|
-
domainsCommand.action(
|
|
141
|
-
|
|
142
|
-
if (message) console.log(message);
|
|
188
|
+
domainsCommand.action(() => {
|
|
189
|
+
runDomainSearchTui();
|
|
143
190
|
});
|
|
144
191
|
|
|
145
192
|
domainsCommand
|
|
146
193
|
.command("list")
|
|
147
194
|
.description("List domains owned at connected registrars")
|
|
148
|
-
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost)")
|
|
195
|
+
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost|cpanel)")
|
|
149
196
|
.option("--json", "Output JSON", false)
|
|
150
197
|
.action(async (opts) => {
|
|
151
198
|
try {
|
|
@@ -223,8 +270,7 @@ domainsCommand
|
|
|
223
270
|
try {
|
|
224
271
|
if (!name) {
|
|
225
272
|
if (opts.json) throw new Error("Pass a name: uplink domains search acme --json");
|
|
226
|
-
|
|
227
|
-
if (message) console.log(message);
|
|
273
|
+
runDomainSearchTui();
|
|
228
274
|
return;
|
|
229
275
|
}
|
|
230
276
|
const results = await searchDomains(name);
|
|
@@ -249,6 +295,271 @@ domainsCommand
|
|
|
249
295
|
}
|
|
250
296
|
});
|
|
251
297
|
|
|
298
|
+
domainsCommand
|
|
299
|
+
.command("buy")
|
|
300
|
+
.description("Register a domain via Namecheap (charges account balance)")
|
|
301
|
+
.argument("<domain>", "Domain to register (e.g. alchemy.photos)")
|
|
302
|
+
.option("--years <n>", "Registration years", "1")
|
|
303
|
+
.option("--yes", "Skip confirmation (required for non-interactive)", false)
|
|
304
|
+
.option("--open-cart", "Open Namecheap browser cart instead of API purchase", false)
|
|
305
|
+
.option("--json", "Output JSON", false)
|
|
306
|
+
.action(async (domainArg: string, opts) => {
|
|
307
|
+
try {
|
|
308
|
+
const domain = String(domainArg).trim().toLowerCase();
|
|
309
|
+
if (!domain.includes(".")) throw new Error("Pass a full domain like alchemy.photos");
|
|
310
|
+
const years = Math.max(1, Number(opts.years) || 1);
|
|
311
|
+
|
|
312
|
+
if (opts.openCart) {
|
|
313
|
+
const url = namecheapCartUrl(domain, years);
|
|
314
|
+
if (opts.json) {
|
|
315
|
+
printJson({ domain, url, mode: "cart" });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
console.log(url);
|
|
319
|
+
openInBrowser(url);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const store = readRegistrarStore();
|
|
324
|
+
const creds = store.namecheap;
|
|
325
|
+
if (!creds) throw new Error("Connect Namecheap first: uplink domains providers connect namecheap");
|
|
326
|
+
|
|
327
|
+
const quote = await getAdapter("namecheap").check(creds, domain);
|
|
328
|
+
if (!quote.buyable || quote.status !== "available") {
|
|
329
|
+
throw new Error(`${domain} is not available on Namecheap (${quote.status})`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let contact = readRegistrantContact();
|
|
333
|
+
if (!contact || contactMissingFields(contact).length) {
|
|
334
|
+
const owned = await getAdapter("namecheap").listDomains(creds);
|
|
335
|
+
for (const item of owned.slice(0, 5)) {
|
|
336
|
+
const seeded = await fetchNamecheapDomainContact(creds, item.domain);
|
|
337
|
+
if (seeded && contactMissingFields(seeded).length === 0) {
|
|
338
|
+
writeRegistrantContact(seeded);
|
|
339
|
+
contact = seeded;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!contact || contactMissingFields(contact).length) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
"Registrant contact missing. Run: uplink domains contact set (or buy once you own another Namecheap domain so we can copy WHOIS)"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const balance = await getNamecheapBalance(creds);
|
|
351
|
+
const price = quote.priceUsd ?? 0;
|
|
352
|
+
if (balance.available + 0.001 < price) {
|
|
353
|
+
const need = Math.max(10, Math.ceil(price - balance.available + 1));
|
|
354
|
+
const funds = await createNamecheapAddFundsRequest(creds, need);
|
|
355
|
+
if (opts.json) {
|
|
356
|
+
printJson({
|
|
357
|
+
domain,
|
|
358
|
+
error: "INSUFFICIENT_BALANCE",
|
|
359
|
+
priceUsd: price,
|
|
360
|
+
balanceUsd: balance.available,
|
|
361
|
+
addFundsUrl: funds.redirectUrl,
|
|
362
|
+
amount: funds.amount,
|
|
363
|
+
cartUrl: namecheapCartUrl(domain, years),
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
console.log(
|
|
368
|
+
`Insufficient Namecheap balance (need ~$${price.toFixed(2)}, have $${balance.available.toFixed(2)}).`
|
|
369
|
+
);
|
|
370
|
+
console.log(`Add funds: ${funds.redirectUrl}`);
|
|
371
|
+
console.log(`Or browser cart: ${namecheapCartUrl(domain, years)}`);
|
|
372
|
+
openInBrowser(funds.redirectUrl);
|
|
373
|
+
process.exitCode = 30;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!opts.yes) {
|
|
378
|
+
if (!canPrompt()) throw new Error("Pass --yes to buy non-interactively");
|
|
379
|
+
const answer = (await promptLine(`Buy ${domain} for ~$${price.toFixed(2)}/${years}yr? [y/N] `))
|
|
380
|
+
.trim()
|
|
381
|
+
.toLowerCase();
|
|
382
|
+
if (answer !== "y" && answer !== "yes") {
|
|
383
|
+
if (opts.json) printJson({ domain, cancelled: true });
|
|
384
|
+
else console.log("Cancelled");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const result = await registerNamecheapDomain(creds, {
|
|
390
|
+
domain,
|
|
391
|
+
years,
|
|
392
|
+
contact,
|
|
393
|
+
premium: quote.premium,
|
|
394
|
+
premiumPrice: quote.premium ? quote.priceUsd : undefined,
|
|
395
|
+
});
|
|
396
|
+
if (opts.json) {
|
|
397
|
+
printJson({ ...result, priceUsd: price, provider: "namecheap" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
console.log(
|
|
401
|
+
result.registered
|
|
402
|
+
? `Registered ${result.domain}${result.chargedAmount != null ? ` · charged $${result.chargedAmount.toFixed(2)}` : ""}`
|
|
403
|
+
: `Namecheap returned registered=false for ${result.domain}`
|
|
404
|
+
);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
handleError(error, { json: opts.json });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
domainsCommand
|
|
411
|
+
.command("fund")
|
|
412
|
+
.description("Open a Namecheap add-funds payment page (account balance for API purchases)")
|
|
413
|
+
.option("--amount <usd>", "Amount to add (min $5)", "20")
|
|
414
|
+
.option("--json", "Output JSON (does not open a browser)", false)
|
|
415
|
+
.action(async (opts) => {
|
|
416
|
+
try {
|
|
417
|
+
const store = readRegistrarStore();
|
|
418
|
+
const creds = store.namecheap;
|
|
419
|
+
if (!creds) throw new Error("Connect Namecheap first: uplink domains providers connect namecheap");
|
|
420
|
+
const amount = Number(opts.amount);
|
|
421
|
+
const funds = await createNamecheapAddFundsRequest(creds, amount);
|
|
422
|
+
const balance = await getNamecheapBalance(creds).catch(() => null);
|
|
423
|
+
if (opts.json) {
|
|
424
|
+
printJson({
|
|
425
|
+
amount: funds.amount,
|
|
426
|
+
redirectUrl: funds.redirectUrl,
|
|
427
|
+
tokenId: funds.tokenId,
|
|
428
|
+
balanceUsd: balance?.available,
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
console.log(`Namecheap payment page (add $${funds.amount.toFixed(2)}):`);
|
|
433
|
+
console.log(funds.redirectUrl);
|
|
434
|
+
if (balance) console.log(`Current balance: $${balance.available.toFixed(2)} ${balance.currency}`);
|
|
435
|
+
openInBrowser(funds.redirectUrl);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
handleError(error, { json: opts.json });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const contactCmd = domainsCommand.command("contact").description("Registrant WHOIS profile for Namecheap purchases");
|
|
442
|
+
|
|
443
|
+
contactCmd
|
|
444
|
+
.command("show")
|
|
445
|
+
.description("Show saved registrant contact (no secrets beyond WHOIS fields)")
|
|
446
|
+
.option("--json", "Output JSON", false)
|
|
447
|
+
.action((opts) => {
|
|
448
|
+
try {
|
|
449
|
+
const contact = readRegistrantContact();
|
|
450
|
+
if (opts.json) {
|
|
451
|
+
printJson({ contact, missing: contactMissingFields(contact) });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!contact) {
|
|
455
|
+
console.log("No registrant profile. Run: uplink domains contact set");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
console.log(`${contact.firstName} ${contact.lastName} <${contact.email}>`);
|
|
459
|
+
console.log(`${contact.address1}`);
|
|
460
|
+
console.log(`${contact.city}, ${contact.stateProvince} ${contact.postalCode} ${contact.country}`);
|
|
461
|
+
console.log(contact.phone);
|
|
462
|
+
const missing = contactMissingFields(contact);
|
|
463
|
+
if (missing.length) console.log(`Missing: ${missing.join(", ")}`);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
handleError(error, { json: opts.json });
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
contactCmd
|
|
470
|
+
.command("set")
|
|
471
|
+
.description("Save registrant contact used for Namecheap domains.create")
|
|
472
|
+
.option("--first-name <v>", "First name")
|
|
473
|
+
.option("--last-name <v>", "Last name")
|
|
474
|
+
.option("--address1 <v>", "Street address")
|
|
475
|
+
.option("--city <v>", "City")
|
|
476
|
+
.option("--state <v>", "State / province")
|
|
477
|
+
.option("--postal <v>", "Postal code")
|
|
478
|
+
.option("--country <v>", "Country code (e.g. US)")
|
|
479
|
+
.option("--phone <v>", "Phone in +1.5555555555 form")
|
|
480
|
+
.option("--email <v>", "Email")
|
|
481
|
+
.option("--org <v>", "Organization (optional)")
|
|
482
|
+
.option("--from-domain <domain>", "Copy WHOIS from an owned Namecheap domain")
|
|
483
|
+
.option("--json", "Output JSON", false)
|
|
484
|
+
.action(async (opts) => {
|
|
485
|
+
try {
|
|
486
|
+
let contact: RegistrantContact | null = readRegistrantContact();
|
|
487
|
+
|
|
488
|
+
if (opts.fromDomain) {
|
|
489
|
+
const store = readRegistrarStore();
|
|
490
|
+
const creds = store.namecheap;
|
|
491
|
+
if (!creds) throw new Error("Connect Namecheap first");
|
|
492
|
+
contact = await fetchNamecheapDomainContact(creds, String(opts.fromDomain).toLowerCase());
|
|
493
|
+
if (!contact) throw new Error(`No contacts returned for ${opts.fromDomain}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const ask = async (label: string, current?: string, flag?: string) => {
|
|
497
|
+
if (flag) return flag;
|
|
498
|
+
if (!canPrompt() || opts.json) return current || "";
|
|
499
|
+
const answer = (await promptLine(`${label}${current ? ` [${current}]` : ""}: `)).trim();
|
|
500
|
+
return answer || current || "";
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
contact = {
|
|
504
|
+
firstName: await ask("First name", contact?.firstName, opts.firstName),
|
|
505
|
+
lastName: await ask("Last name", contact?.lastName, opts.lastName),
|
|
506
|
+
address1: await ask("Address", contact?.address1, opts.address1),
|
|
507
|
+
city: await ask("City", contact?.city, opts.city),
|
|
508
|
+
stateProvince: await ask("State/province", contact?.stateProvince, opts.state),
|
|
509
|
+
postalCode: await ask("Postal code", contact?.postalCode, opts.postal),
|
|
510
|
+
country: await ask("Country (US)", contact?.country || "US", opts.country),
|
|
511
|
+
phone: await ask("Phone (+1.5555555555)", contact?.phone, opts.phone),
|
|
512
|
+
email: await ask("Email", contact?.email, opts.email),
|
|
513
|
+
organizationName: (await ask("Organization (optional)", contact?.organizationName, opts.org)) || undefined,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const missing = contactMissingFields(contact);
|
|
517
|
+
if (missing.length) throw new Error(`Missing required fields: ${missing.join(", ")}`);
|
|
518
|
+
writeRegistrantContact(contact);
|
|
519
|
+
if (opts.json) {
|
|
520
|
+
printJson({ saved: true, contact });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
console.log("Saved registrant profile to ~/.uplink/registrant.json");
|
|
524
|
+
} catch (error) {
|
|
525
|
+
handleError(error, { json: opts.json });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
contactCmd
|
|
530
|
+
.command("seed")
|
|
531
|
+
.description("Copy registrant contact from the first owned Namecheap domain")
|
|
532
|
+
.option("--json", "Output JSON", false)
|
|
533
|
+
.action(async (opts) => {
|
|
534
|
+
try {
|
|
535
|
+
const store = readRegistrarStore();
|
|
536
|
+
const creds = store.namecheap;
|
|
537
|
+
if (!creds) throw new Error("Connect Namecheap first");
|
|
538
|
+
const owned = await getAdapter("namecheap").listDomains(creds);
|
|
539
|
+
if (owned.length === 0) throw new Error("No owned Namecheap domains to copy from");
|
|
540
|
+
let contact: RegistrantContact | null = null;
|
|
541
|
+
let source = "";
|
|
542
|
+
for (const item of owned) {
|
|
543
|
+
contact = await fetchNamecheapDomainContact(creds, item.domain);
|
|
544
|
+
if (contact && contactMissingFields(contact).length === 0) {
|
|
545
|
+
source = item.domain;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!contact || contactMissingFields(contact).length) {
|
|
550
|
+
throw new Error("Could not read a complete contact from owned domains");
|
|
551
|
+
}
|
|
552
|
+
writeRegistrantContact(contact);
|
|
553
|
+
if (opts.json) {
|
|
554
|
+
printJson({ saved: true, source, contact });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
console.log(`Saved registrant profile from ${source}`);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
handleError(error, { json: opts.json });
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
252
563
|
const providers = domainsCommand.command("providers").description("Connect registrar accounts");
|
|
253
564
|
|
|
254
565
|
providers
|
|
@@ -279,17 +590,18 @@ providers
|
|
|
279
590
|
|
|
280
591
|
providers
|
|
281
592
|
.command("connect")
|
|
282
|
-
.description("Save a registrar credential after a live check")
|
|
283
|
-
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
593
|
+
.description("Save a registrar or cPanel credential after a live check")
|
|
594
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
|
|
284
595
|
.option("--token-env <name>", "Env var holding the API token (comma-separate names for multiple keys)")
|
|
285
|
-
.option("--user-env <name>", "Env var holding the Namecheap API user")
|
|
596
|
+
.option("--user-env <name>", "Env var holding the Namecheap API user or cPanel username")
|
|
286
597
|
.option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
|
|
598
|
+
.option("--host <hostname>", "cPanel server hostname (e.g. server341.web-hosting.com)")
|
|
287
599
|
.option("--json", "Output JSON", false)
|
|
288
600
|
.action(async (providerArg: string, opts) => {
|
|
289
601
|
try {
|
|
290
602
|
const provider = String(providerArg).toLowerCase();
|
|
291
603
|
if (!isProviderId(provider)) {
|
|
292
|
-
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, or
|
|
604
|
+
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, dreamhost, or cpanel`);
|
|
293
605
|
}
|
|
294
606
|
const adapter = getAdapter(provider);
|
|
295
607
|
const creds = await credentialsFromFlags(provider, opts);
|
|
@@ -312,8 +624,8 @@ providers
|
|
|
312
624
|
|
|
313
625
|
providers
|
|
314
626
|
.command("disconnect")
|
|
315
|
-
.description("Remove a saved registrar credential")
|
|
316
|
-
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
627
|
+
.description("Remove a saved registrar or cPanel credential")
|
|
628
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
|
|
317
629
|
.option("--json", "Output JSON", false)
|
|
318
630
|
.action((providerArg: string, opts) => {
|
|
319
631
|
try {
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
"
|
|
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",
|
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { spawnSync } from "child_process";
|
|
3
2
|
import { join } from "path";
|
|
4
|
-
|
|
5
|
-
function projectRoot(): string {
|
|
6
|
-
return join(__dirname, "../../..");
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function resolveTsx(): string {
|
|
10
|
-
const root = projectRoot();
|
|
11
|
-
try {
|
|
12
|
-
return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
|
|
13
|
-
} catch {
|
|
14
|
-
try {
|
|
15
|
-
return require.resolve("tsx/cli", { paths: [root] });
|
|
16
|
-
} catch {
|
|
17
|
-
return "tsx";
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
3
|
+
import { runEsmEntry } from "../utils/run-esm";
|
|
21
4
|
|
|
22
5
|
/**
|
|
23
6
|
* Ink 6 is ESM-only (yoga-layout uses top-level await). The CLI package is
|
|
@@ -30,12 +13,5 @@ export const menuCommand = new Command("menu")
|
|
|
30
13
|
console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
|
|
31
14
|
process.exit(1);
|
|
32
15
|
}
|
|
33
|
-
|
|
34
|
-
const result = spawnSync(resolveTsx(), [entry], {
|
|
35
|
-
stdio: "inherit",
|
|
36
|
-
cwd: projectRoot(),
|
|
37
|
-
env: process.env,
|
|
38
|
-
});
|
|
39
|
-
if (result.error) throw result.error;
|
|
40
|
-
process.exit(result.status ?? 0);
|
|
16
|
+
runEsmEntry(join(__dirname, "../tui/index.mts"));
|
|
41
17
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { apiRequest } from "../http";
|
|
4
|
+
import { handleError, printJson } from "../utils/machine";
|
|
5
|
+
|
|
6
|
+
function openInBrowser(url: string): void {
|
|
7
|
+
const [cmd, args] =
|
|
8
|
+
process.platform === "darwin"
|
|
9
|
+
? ["open", [url]]
|
|
10
|
+
: process.platform === "win32"
|
|
11
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
12
|
+
: ["xdg-open", [url]];
|
|
13
|
+
try {
|
|
14
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
15
|
+
} catch {
|
|
16
|
+
// Non-fatal: the URL is printed either way.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const upgradeCommand = new Command("upgrade")
|
|
21
|
+
.description("Upgrade to Uplink Pro — unlimited apps in 1 GB, 5 always-on, custom domains, aliases")
|
|
22
|
+
.option("--yearly", "Yearly billing (2 months free)", false)
|
|
23
|
+
.option("--json", "Output JSON (prints the checkout URL, does not open a browser)", false)
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
try {
|
|
26
|
+
const interval = opts.yearly ? "year" : "month";
|
|
27
|
+
const result = await apiRequest("POST", "/v1/billing/checkout", { interval });
|
|
28
|
+
if (opts.json) {
|
|
29
|
+
printJson({ url: result.url, interval: result.interval });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log("Uplink Pro — complete your upgrade in the browser:");
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log(` ${result.url}`);
|
|
36
|
+
console.log("");
|
|
37
|
+
openInBrowser(result.url);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
handleError(error, { json: opts.json });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const billingCommand = new Command("billing")
|
|
44
|
+
.description("Manage your subscription (opens the Stripe billing portal)")
|
|
45
|
+
.option("--json", "Output JSON (prints the portal URL, does not open a browser)", false)
|
|
46
|
+
.action(async (opts) => {
|
|
47
|
+
try {
|
|
48
|
+
const result = await apiRequest("POST", "/v1/billing/portal");
|
|
49
|
+
if (opts.json) {
|
|
50
|
+
printJson({ url: result.url });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log("Manage your subscription here:");
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log(` ${result.url}`);
|
|
57
|
+
console.log("");
|
|
58
|
+
openInBrowser(result.url);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
handleError(error, { json: opts.json });
|
|
61
|
+
}
|
|
62
|
+
});
|