uplink-cli 0.1.38 → 0.2.0
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 +177 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +72 -52
- package/cli/src/index.ts +16 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +105 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +32 -0
- package/cli/src/registrars/namecheap.ts +163 -0
- package/cli/src/registrars/secret.ts +66 -0
- package/cli/src/registrars/store.ts +55 -0
- package/cli/src/registrars/types.ts +42 -0
- package/cli/src/subcommands/admin.ts +17 -30
- package/cli/src/subcommands/db.ts +63 -57
- package/cli/src/subcommands/dev.ts +23 -25
- package/cli/src/subcommands/domains.ts +295 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +3 -0
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/colors.ts +1 -1
- package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
- package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
- package/cli/src/subcommands/menu/io.ts +27 -5
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +197 -0
- package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
- package/cli/src/subcommands/menu/menus/index.ts +1 -0
- package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
- package/cli/src/subcommands/menu/render.ts +2 -2
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/menu/tests.ts +1 -1
- package/cli/src/subcommands/menu/tunnels.ts +10 -99
- package/cli/src/subcommands/menu/types.ts +8 -0
- package/cli/src/subcommands/menu.ts +32 -524
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +126 -33
- package/cli/src/templates/index.ts +3 -3
- package/cli/src/tui/App.tsx +202 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +92 -0
- package/cli/src/tui/brand.tsx +20 -0
- package/cli/src/tui/format.ts +22 -0
- package/cli/src/tui/index.mts +6 -0
- package/cli/src/tui/liveTree.ts +40 -0
- package/cli/src/tui/package.json +3 -0
- package/cli/src/tui/runMenu.tsx +57 -0
- package/cli/src/tui/session.mts +267 -0
- package/cli/src/tui/snapshot.ts +175 -0
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +130 -148
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +60 -288
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +11 -7
- package/package.json +22 -36
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- package/assets/cli-screenshot.png +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
2
|
+
|
|
3
|
+
const BASE = "https://api.dreamhost.com/";
|
|
4
|
+
const NO_ACCESS = "this_key_cannot_access_this_cmd";
|
|
5
|
+
|
|
6
|
+
type DreamhostEnvelope = {
|
|
7
|
+
result?: string;
|
|
8
|
+
data?: unknown;
|
|
9
|
+
reason?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function keysOf(creds: RegistrarCredentials): string[] {
|
|
13
|
+
const keys = [creds.apiKey || creds.token, ...(creds.extraTokens || [])];
|
|
14
|
+
return [...new Set(keys.filter((key): key is string => Boolean(key)))];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function dreamhostCmd(key: string, cmd: string): Promise<unknown> {
|
|
18
|
+
const url = `${BASE}?key=${encodeURIComponent(key)}&cmd=${encodeURIComponent(cmd)}&format=json`;
|
|
19
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
let body: DreamhostEnvelope;
|
|
22
|
+
try {
|
|
23
|
+
body = text ? (JSON.parse(text) as DreamhostEnvelope) : {};
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`DreamHost returned a non-JSON response (HTTP ${res.status})`);
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok || body.result !== "success") {
|
|
28
|
+
const detail = body.reason || (typeof body.data === "string" ? body.data : "") || `HTTP ${res.status}`;
|
|
29
|
+
throw new Error(`DreamHost ${cmd} failed: ${detail}`);
|
|
30
|
+
}
|
|
31
|
+
return body.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rowsOf(data: unknown): Array<Record<string, unknown>> {
|
|
35
|
+
if (!Array.isArray(data)) return [];
|
|
36
|
+
return data.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function domainOf(row: Record<string, unknown>): string | undefined {
|
|
40
|
+
const value = row.domain ?? row.name ?? row.domain_name;
|
|
41
|
+
return typeof value === "string" && value.includes(".") ? value.toLowerCase() : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function expiryOf(row: Record<string, unknown>): string | undefined {
|
|
45
|
+
const value = row.expires ?? row.expiry ?? row.expiration_date;
|
|
46
|
+
return typeof value === "string" && value ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* DreamHost API keys are scoped per command. Panel keys with domain access can
|
|
51
|
+
* list registrations; DNS-only keys can still reveal the account's domains via
|
|
52
|
+
* dns-list_records zones, so each lookup falls through that chain.
|
|
53
|
+
*/
|
|
54
|
+
async function listForKey(key: string): Promise<InventoryDomain[]> {
|
|
55
|
+
const out: InventoryDomain[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const push = (domain: string | undefined, expiresAt?: string) => {
|
|
58
|
+
if (!domain || seen.has(domain)) return;
|
|
59
|
+
seen.add(domain);
|
|
60
|
+
out.push({ domain, provider: "dreamhost", status: "owned", expiresAt });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let lastError: Error | null = null;
|
|
64
|
+
for (const cmd of ["domain-list_registrations", "domain-list_domains"]) {
|
|
65
|
+
try {
|
|
66
|
+
for (const row of rowsOf(await dreamhostCmd(key, cmd))) {
|
|
67
|
+
push(domainOf(row), expiryOf(row));
|
|
68
|
+
}
|
|
69
|
+
if (out.length > 0) return out;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
if (!lastError.message.includes(NO_ACCESS)) throw lastError;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
for (const row of rowsOf(await dreamhostCmd(key, "dns-list_records"))) {
|
|
78
|
+
const zone = row.zone;
|
|
79
|
+
if (typeof zone === "string" && zone.includes(".")) push(zone.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
84
|
+
throw lastError && err.message.includes(NO_ACCESS) ? lastError : err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const dreamhostAdapter: RegistrarAdapter = {
|
|
89
|
+
id: "dreamhost",
|
|
90
|
+
label: "DreamHost",
|
|
91
|
+
connectHelp:
|
|
92
|
+
"Panel API key(s); DNS-scoped keys work too. --token-env DREAMHOST_API_KEY (comma-separate env names for multiple accounts)",
|
|
93
|
+
async verify(creds) {
|
|
94
|
+
const keys = keysOf(creds);
|
|
95
|
+
if (keys.length === 0) throw new Error("DreamHost API key is missing");
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
await listForKey(key);
|
|
98
|
+
}
|
|
99
|
+
return creds;
|
|
100
|
+
},
|
|
101
|
+
async listDomains(creds) {
|
|
102
|
+
const out: InventoryDomain[] = [];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const errors: string[] = [];
|
|
105
|
+
for (const key of keysOf(creds)) {
|
|
106
|
+
try {
|
|
107
|
+
for (const item of await listForKey(key)) {
|
|
108
|
+
if (seen.has(item.domain)) continue;
|
|
109
|
+
seen.add(item.domain);
|
|
110
|
+
out.push(item);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (out.length === 0 && errors.length > 0) throw new Error(errors.join("; "));
|
|
117
|
+
return out.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
118
|
+
},
|
|
119
|
+
async check(creds, domain) {
|
|
120
|
+
// DreamHost has no availability API; it can only confirm ownership.
|
|
121
|
+
// Throw for unowned domains so `domains check` falls through to a
|
|
122
|
+
// provider that can quote (or the public DNS/RDAP fallback).
|
|
123
|
+
const owned = await this.listDomains(creds);
|
|
124
|
+
if (owned.some((item) => item.domain === domain.toLowerCase())) {
|
|
125
|
+
return { domain, provider: "dreamhost", status: "owned", buyable: false };
|
|
126
|
+
}
|
|
127
|
+
throw new Error("DreamHost cannot check availability for domains outside the account");
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { httpError, parseJson } from "./http";
|
|
2
|
+
import type { DomainQuote, InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
3
|
+
|
|
4
|
+
const BASE = "https://api.godaddy.com";
|
|
5
|
+
|
|
6
|
+
async function godaddyFetch(
|
|
7
|
+
creds: RegistrarCredentials,
|
|
8
|
+
path: string,
|
|
9
|
+
init: RequestInit = {}
|
|
10
|
+
): Promise<Response> {
|
|
11
|
+
if (!creds.token) throw new Error("GoDaddy token is missing");
|
|
12
|
+
return fetch(`${BASE}${path}`, {
|
|
13
|
+
...init,
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${creds.token}`,
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
...(init.headers || {}),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function centsToUsd(value: number): number {
|
|
24
|
+
return value / 100;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function microToUsd(value: number): number {
|
|
28
|
+
return value / 1_000_000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const godaddyAdapter: RegistrarAdapter = {
|
|
32
|
+
id: "godaddy",
|
|
33
|
+
label: "GoDaddy",
|
|
34
|
+
connectHelp: "Personal Access Token with domains.domain:read. --token-env GODADDY_PAT",
|
|
35
|
+
async verify(creds) {
|
|
36
|
+
const res = await godaddyFetch(creds, "/v1/domains?limit=1");
|
|
37
|
+
if (!res.ok) throw httpError(res, await res.text());
|
|
38
|
+
return creds;
|
|
39
|
+
},
|
|
40
|
+
async listDomains(creds) {
|
|
41
|
+
const out: InventoryDomain[] = [];
|
|
42
|
+
let marker: string | undefined;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const params = new URLSearchParams({ limit: "100" });
|
|
45
|
+
if (marker) params.set("marker", marker);
|
|
46
|
+
const res = await godaddyFetch(creds, `/v1/domains?${params}`);
|
|
47
|
+
if (!res.ok) throw httpError(res, await res.text());
|
|
48
|
+
const batch = await parseJson<Array<{ domain?: string; expires?: string }>>(res);
|
|
49
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
50
|
+
for (const item of batch) {
|
|
51
|
+
if (!item.domain) continue;
|
|
52
|
+
out.push({
|
|
53
|
+
domain: item.domain.toLowerCase(),
|
|
54
|
+
provider: "godaddy",
|
|
55
|
+
status: "owned",
|
|
56
|
+
expiresAt: item.expires,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (batch.length < 100) break;
|
|
60
|
+
marker = batch[batch.length - 1]?.domain;
|
|
61
|
+
if (!marker) break;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
},
|
|
65
|
+
async check(creds, domain) {
|
|
66
|
+
const v3 = await godaddyFetch(
|
|
67
|
+
creds,
|
|
68
|
+
`/v3/domains/check-availability?domain=${encodeURIComponent(domain)}`
|
|
69
|
+
);
|
|
70
|
+
if (v3.ok) {
|
|
71
|
+
const data = await parseJson<{
|
|
72
|
+
available?: boolean;
|
|
73
|
+
prices?: Array<{ period?: number; price?: { value?: number } }>;
|
|
74
|
+
}>(v3);
|
|
75
|
+
const year1 = data.prices?.find((p) => p.period === 1) ?? data.prices?.[0];
|
|
76
|
+
const priceUsd = year1?.price?.value != null ? centsToUsd(year1.price.value) : undefined;
|
|
77
|
+
if (data.available) {
|
|
78
|
+
return { domain, provider: "godaddy", status: "available", buyable: true, priceUsd };
|
|
79
|
+
}
|
|
80
|
+
return { domain, provider: "godaddy", status: "taken", buyable: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fall back only when this API version is unavailable. Retrying a 401,
|
|
84
|
+
// 429, or 5xx against v1 wastes quota and can hide the real failure.
|
|
85
|
+
if (v3.status !== 404 && v3.status !== 405) {
|
|
86
|
+
throw httpError(v3, await v3.text());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const v1 = await godaddyFetch(
|
|
90
|
+
creds,
|
|
91
|
+
`/v1/domains/available?domain=${encodeURIComponent(domain)}`
|
|
92
|
+
);
|
|
93
|
+
if (!v1.ok) throw httpError(v1, await v1.text());
|
|
94
|
+
const data = await parseJson<{
|
|
95
|
+
available?: boolean;
|
|
96
|
+
price?: number;
|
|
97
|
+
currency?: string;
|
|
98
|
+
}>(v1);
|
|
99
|
+
const priceUsd = data.price != null ? microToUsd(data.price) : undefined;
|
|
100
|
+
if (data.available) {
|
|
101
|
+
return { domain, provider: "godaddy", status: "available", buyable: true, priceUsd };
|
|
102
|
+
}
|
|
103
|
+
return { domain, provider: "godaddy", status: "taken", buyable: false };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { httpError, parseJson } from "./http";
|
|
2
|
+
import type { DomainQuote, InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
3
|
+
|
|
4
|
+
const BASE = "https://developers.hostinger.com";
|
|
5
|
+
|
|
6
|
+
async function hostingerFetch(
|
|
7
|
+
creds: RegistrarCredentials,
|
|
8
|
+
path: string,
|
|
9
|
+
init: RequestInit = {}
|
|
10
|
+
): Promise<Response> {
|
|
11
|
+
if (!creds.token) throw new Error("Hostinger token is missing");
|
|
12
|
+
return fetch(`${BASE}${path}`, {
|
|
13
|
+
...init,
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${creds.token}`,
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
...(init.headers || {}),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asList(data: unknown): unknown[] {
|
|
24
|
+
if (Array.isArray(data)) return data;
|
|
25
|
+
if (data && typeof data === "object") {
|
|
26
|
+
const obj = data as { data?: unknown; items?: unknown; domains?: unknown };
|
|
27
|
+
if (Array.isArray(obj.data)) return obj.data;
|
|
28
|
+
if (Array.isArray(obj.items)) return obj.items;
|
|
29
|
+
if (Array.isArray(obj.domains)) return obj.domains;
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function domainField(item: unknown): string | undefined {
|
|
35
|
+
if (!item || typeof item !== "object") return undefined;
|
|
36
|
+
const obj = item as { domain?: string; name?: string; domain_name?: string };
|
|
37
|
+
return obj.domain || obj.name || obj.domain_name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function splitDomain(fqdn: string): { label: string; tld: string } {
|
|
41
|
+
const i = fqdn.indexOf(".");
|
|
42
|
+
if (i <= 0) return { label: fqdn, tld: "com" };
|
|
43
|
+
return { label: fqdn.slice(0, i), tld: fqdn.slice(i + 1) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const hostingerAdapter: RegistrarAdapter = {
|
|
47
|
+
id: "hostinger",
|
|
48
|
+
label: "Hostinger",
|
|
49
|
+
connectHelp: "hPanel API token. --token-env HOSTINGER_API_TOKEN",
|
|
50
|
+
async verify(creds) {
|
|
51
|
+
const res = await hostingerFetch(creds, "/api/domains/v1/portfolio");
|
|
52
|
+
if (!res.ok) throw httpError(res, await res.text());
|
|
53
|
+
return creds;
|
|
54
|
+
},
|
|
55
|
+
async listDomains(creds) {
|
|
56
|
+
const res = await hostingerFetch(creds, "/api/domains/v1/portfolio");
|
|
57
|
+
if (!res.ok) throw httpError(res, await res.text());
|
|
58
|
+
const data = await parseJson<unknown>(res);
|
|
59
|
+
const out: InventoryDomain[] = [];
|
|
60
|
+
for (const item of asList(data)) {
|
|
61
|
+
const domain = domainField(item);
|
|
62
|
+
if (!domain) continue;
|
|
63
|
+
const expires =
|
|
64
|
+
item && typeof item === "object"
|
|
65
|
+
? (item as { expires_at?: string; expiry_date?: string }).expires_at ||
|
|
66
|
+
(item as { expiry_date?: string }).expiry_date
|
|
67
|
+
: undefined;
|
|
68
|
+
out.push({
|
|
69
|
+
domain: domain.toLowerCase(),
|
|
70
|
+
provider: "hostinger",
|
|
71
|
+
status: "owned",
|
|
72
|
+
expiresAt: expires,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
},
|
|
77
|
+
async check(creds, domain) {
|
|
78
|
+
const { label, tld } = splitDomain(domain);
|
|
79
|
+
const res = await hostingerFetch(creds, "/api/domains/v1/availability", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
body: JSON.stringify({ domain: label, tlds: [tld], with_alternatives: false }),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw httpError(res, await res.text());
|
|
84
|
+
const data = await parseJson<unknown>(res);
|
|
85
|
+
const rows = asList(data);
|
|
86
|
+
const match =
|
|
87
|
+
rows.find((row) => domainField(row)?.toLowerCase() === domain.toLowerCase()) || rows[0];
|
|
88
|
+
const available =
|
|
89
|
+
match && typeof match === "object"
|
|
90
|
+
? Boolean((match as { is_available?: boolean; available?: boolean }).is_available ??
|
|
91
|
+
(match as { available?: boolean }).available)
|
|
92
|
+
: false;
|
|
93
|
+
if (available) {
|
|
94
|
+
return { domain, provider: "hostinger", status: "available", buyable: true };
|
|
95
|
+
}
|
|
96
|
+
const restriction =
|
|
97
|
+
match && typeof match === "object" ? (match as { restriction?: string }).restriction : undefined;
|
|
98
|
+
return {
|
|
99
|
+
domain,
|
|
100
|
+
provider: "hostinger",
|
|
101
|
+
status: restriction ? "not_for_sale" : "taken",
|
|
102
|
+
buyable: false,
|
|
103
|
+
error: restriction,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export async function readResponseBody(res: Response): Promise<string> {
|
|
2
|
+
return res.text();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function parseJson<T>(res: Response): Promise<T> {
|
|
6
|
+
const text = await readResponseBody(res);
|
|
7
|
+
if (!text) return {} as T;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(text) as T;
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Invalid JSON from ${res.url} (${res.status})`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function httpError(res: Response, body: string): Error {
|
|
16
|
+
const trimmed = body.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
17
|
+
return new Error(`HTTP ${res.status}${trimmed ? `: ${trimmed}` : ""}`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cloudflareAdapter } from "./cloudflare";
|
|
2
|
+
import { dreamhostAdapter } from "./dreamhost";
|
|
3
|
+
import { godaddyAdapter } from "./godaddy";
|
|
4
|
+
import { hostingerAdapter } from "./hostinger";
|
|
5
|
+
import { namecheapAdapter } from "./namecheap";
|
|
6
|
+
import type { ProviderId, RegistrarAdapter } from "./types";
|
|
7
|
+
|
|
8
|
+
/** GoDaddy first, then the rest. Namecheap last because connect is the clumsiest. */
|
|
9
|
+
export const adapters: RegistrarAdapter[] = [
|
|
10
|
+
godaddyAdapter,
|
|
11
|
+
cloudflareAdapter,
|
|
12
|
+
hostingerAdapter,
|
|
13
|
+
namecheapAdapter,
|
|
14
|
+
dreamhostAdapter,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
18
|
+
|
|
19
|
+
export function getAdapter(id: ProviderId): RegistrarAdapter {
|
|
20
|
+
const adapter = byId.get(id);
|
|
21
|
+
if (!adapter) throw new Error(`Unknown provider: ${id}`);
|
|
22
|
+
return adapter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { connectedProviders, readRegistrarStore, removeProvider, saveProvider } from "./store";
|
|
26
|
+
export { PROVIDER_IDS, isProviderId } from "./types";
|
|
27
|
+
export type {
|
|
28
|
+
DomainQuote,
|
|
29
|
+
InventoryDomain,
|
|
30
|
+
ProviderId,
|
|
31
|
+
RegistrarCredentials,
|
|
32
|
+
} from "./types";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { httpError } from "./http";
|
|
2
|
+
import type { DomainQuote, InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
3
|
+
|
|
4
|
+
const API_URL = "https://api.namecheap.com/xml.response";
|
|
5
|
+
|
|
6
|
+
let clientIpPromise: Promise<string> | undefined;
|
|
7
|
+
const pricingCache = new Map<string, Promise<Map<string, number>>>();
|
|
8
|
+
|
|
9
|
+
async function clientIp(): Promise<string> {
|
|
10
|
+
clientIpPromise ??= fetch("https://api.ipify.org")
|
|
11
|
+
.then(async (res) => {
|
|
12
|
+
if (!res.ok) throw new Error(`ip lookup failed (${res.status})`);
|
|
13
|
+
return (await res.text()).trim();
|
|
14
|
+
})
|
|
15
|
+
.catch((error: unknown) => {
|
|
16
|
+
clientIpPromise = undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
return clientIpPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function attr(tag: string, name: string): string | undefined {
|
|
23
|
+
return new RegExp(`${name}="([^"]*)"`, "i").exec(tag)?.[1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function namecheapCall(
|
|
27
|
+
creds: RegistrarCredentials,
|
|
28
|
+
command: string,
|
|
29
|
+
extra: Record<string, string>
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
if (!creds.apiUser || !creds.apiKey) throw new Error("Namecheap API user and key are required");
|
|
32
|
+
const params = new URLSearchParams({
|
|
33
|
+
ApiUser: creds.apiUser,
|
|
34
|
+
ApiKey: creds.apiKey,
|
|
35
|
+
UserName: creds.apiUser,
|
|
36
|
+
Command: command,
|
|
37
|
+
ClientIp: await clientIp(),
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
const res = await fetch(`${API_URL}?${params}`);
|
|
41
|
+
const xml = await res.text();
|
|
42
|
+
const status = /Status="([^"]+)"/.exec(xml)?.[1];
|
|
43
|
+
if (status !== "OK") {
|
|
44
|
+
const err = /<Error[^>]*>([^<]+)<\/Error>/.exec(xml)?.[1] || `Namecheap ${status || res.status}`;
|
|
45
|
+
const ip = await clientIp().catch(() => "");
|
|
46
|
+
if (/ip/i.test(err) && ip) {
|
|
47
|
+
throw new Error(`${err}. Whitelist this machine's IP in Namecheap API access: ${ip}`);
|
|
48
|
+
}
|
|
49
|
+
throw new Error(err);
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) throw httpError(res, xml);
|
|
52
|
+
return xml;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadRegisterPrices(creds: RegistrarCredentials): Promise<Map<string, number>> {
|
|
56
|
+
const xml = await namecheapCall(creds, "namecheap.users.getPricing", {
|
|
57
|
+
ProductType: "DOMAIN",
|
|
58
|
+
ActionName: "REGISTER",
|
|
59
|
+
});
|
|
60
|
+
const prices = new Map<string, number>();
|
|
61
|
+
const categories = xml.matchAll(/<ProductCategory Name="register">([\s\S]*?)<\/ProductCategory>/gi);
|
|
62
|
+
for (const category of categories) {
|
|
63
|
+
const products = category[1].matchAll(/<Product Name="([^"]+)">([\s\S]*?)<\/Product>/gi);
|
|
64
|
+
for (const match of products) {
|
|
65
|
+
const tld = match[1].toLowerCase();
|
|
66
|
+
const year1 =
|
|
67
|
+
/<Price\b([^>]*Duration="1"[^>]*DurationType="YEAR"[^>]*)\/?>/i.exec(match[2]) ??
|
|
68
|
+
/<Price\b([^>]*DurationType="YEAR"[^>]*Duration="1"[^>]*)\/?>/i.exec(match[2]);
|
|
69
|
+
if (!year1) continue;
|
|
70
|
+
const fields = year1[1];
|
|
71
|
+
const your = Number(attr(fields, "YourPrice") ?? attr(fields, "Price"));
|
|
72
|
+
if (!Number.isFinite(your)) continue;
|
|
73
|
+
const extra = Number(attr(fields, "YourAdditonalCost") ?? attr(fields, "AdditionalCost") ?? "0");
|
|
74
|
+
prices.set(tld, your + (Number.isFinite(extra) ? extra : 0));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return prices;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function registerPrices(creds: RegistrarCredentials): Promise<Map<string, number>> {
|
|
81
|
+
const key = creds.apiUser || "";
|
|
82
|
+
let pending = pricingCache.get(key);
|
|
83
|
+
if (!pending) {
|
|
84
|
+
pending = loadRegisterPrices(creds).catch((error: unknown) => {
|
|
85
|
+
pricingCache.delete(key);
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
pricingCache.set(key, pending);
|
|
89
|
+
}
|
|
90
|
+
return pending;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const namecheapAdapter: RegistrarAdapter = {
|
|
94
|
+
id: "namecheap",
|
|
95
|
+
label: "Namecheap",
|
|
96
|
+
connectHelp:
|
|
97
|
+
"API key + username, and whitelist this machine's IP. --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER",
|
|
98
|
+
async verify(creds) {
|
|
99
|
+
await namecheapCall(creds, "namecheap.domains.getList", { PageSize: "20" });
|
|
100
|
+
return creds;
|
|
101
|
+
},
|
|
102
|
+
async listDomains(creds) {
|
|
103
|
+
const out: InventoryDomain[] = [];
|
|
104
|
+
let page = 1;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const xml = await namecheapCall(creds, "namecheap.domains.getList", {
|
|
107
|
+
Page: String(page),
|
|
108
|
+
PageSize: "100",
|
|
109
|
+
});
|
|
110
|
+
const tags = xml.matchAll(/<Domain\b([^>]*)\/?>/gi);
|
|
111
|
+
let count = 0;
|
|
112
|
+
for (const match of tags) {
|
|
113
|
+
count += 1;
|
|
114
|
+
const name = attr(match[1], "Name") || attr(match[1], "Domain");
|
|
115
|
+
if (!name) continue;
|
|
116
|
+
out.push({
|
|
117
|
+
domain: name.toLowerCase(),
|
|
118
|
+
provider: "namecheap",
|
|
119
|
+
status: "owned",
|
|
120
|
+
expiresAt: attr(match[1], "Expires") || attr(match[1], "ExpiredDate"),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (count < 100) break;
|
|
124
|
+
page += 1;
|
|
125
|
+
if (page > 50) break;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
},
|
|
129
|
+
async check(creds, domain) {
|
|
130
|
+
const xml = await namecheapCall(creds, "namecheap.domains.check", { DomainList: domain });
|
|
131
|
+
const tag = xml.match(/<DomainCheckResult\b([^>]*)\/?>/i)?.[1];
|
|
132
|
+
if (!tag) {
|
|
133
|
+
return { domain, provider: "namecheap", status: "unknown", error: "no check result" };
|
|
134
|
+
}
|
|
135
|
+
const available = attr(tag, "Available")?.toLowerCase() === "true";
|
|
136
|
+
const premium = attr(tag, "IsPremiumName")?.toLowerCase() === "true";
|
|
137
|
+
if (!available) {
|
|
138
|
+
return { domain, provider: "namecheap", status: "not_for_sale", buyable: false, premium };
|
|
139
|
+
}
|
|
140
|
+
if (premium) {
|
|
141
|
+
const price =
|
|
142
|
+
Number(attr(tag, "PremiumRegistrationPrice") || "0") + Number(attr(tag, "IcannFee") || "0");
|
|
143
|
+
return {
|
|
144
|
+
domain,
|
|
145
|
+
provider: "namecheap",
|
|
146
|
+
status: "available",
|
|
147
|
+
buyable: true,
|
|
148
|
+
premium: true,
|
|
149
|
+
priceUsd: price > 0 ? price : undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const tld = domain.slice(domain.lastIndexOf(".") + 1).toLowerCase();
|
|
153
|
+
const prices = await registerPrices(creds);
|
|
154
|
+
return {
|
|
155
|
+
domain,
|
|
156
|
+
provider: "namecheap",
|
|
157
|
+
status: "available",
|
|
158
|
+
buyable: true,
|
|
159
|
+
premium: false,
|
|
160
|
+
priceUsd: prices.get(tld),
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
|
|
3
|
+
export function readEnvValue(name: string): string {
|
|
4
|
+
const value = process.env[name]?.trim();
|
|
5
|
+
if (!value) throw new Error(`Environment variable ${name} is empty or missing`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Hidden prompt. Agents should use --token-env instead so the secret never hits argv. */
|
|
10
|
+
export async function promptSecret(question: string): Promise<string> {
|
|
11
|
+
const stdin = process.stdin;
|
|
12
|
+
const stdout = process.stdout;
|
|
13
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
|
|
14
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
15
|
+
const answer = await new Promise<string>((resolve) => {
|
|
16
|
+
rl.question(question, (value) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(value);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
return answer.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
stdout.write(question);
|
|
25
|
+
stdin.setRawMode(true);
|
|
26
|
+
stdin.resume();
|
|
27
|
+
stdin.setEncoding("utf8");
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let value = "";
|
|
31
|
+
const onData = (chunk: string) => {
|
|
32
|
+
for (const char of chunk) {
|
|
33
|
+
if (char === "\n" || char === "\r") {
|
|
34
|
+
cleanup();
|
|
35
|
+
stdout.write("\n");
|
|
36
|
+
resolve(value.trim());
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (char === "\u0003") {
|
|
40
|
+
cleanup();
|
|
41
|
+
stdout.write("\n");
|
|
42
|
+
reject(new Error("Cancelled"));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (char === "\u007f" || char === "\b") {
|
|
46
|
+
value = value.slice(0, -1);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char >= " ") value += char;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
stdin.off("data", onData);
|
|
54
|
+
try {
|
|
55
|
+
stdin.setRawMode(false);
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
stdin.on("data", onData);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function canPrompt(): boolean {
|
|
65
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
66
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { ProviderId, RegistrarCredentials } from "./types";
|
|
5
|
+
|
|
6
|
+
export type RegistrarStore = Partial<Record<ProviderId, RegistrarCredentials>>;
|
|
7
|
+
|
|
8
|
+
function storeDir(): string {
|
|
9
|
+
return join(homedir(), ".uplink");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registrarStorePath(): string {
|
|
13
|
+
return join(storeDir(), "registrars.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readRegistrarStore(): RegistrarStore {
|
|
17
|
+
const path = registrarStorePath();
|
|
18
|
+
if (!existsSync(path)) return {};
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as RegistrarStore;
|
|
21
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeRegistrarStore(store: RegistrarStore): void {
|
|
28
|
+
mkdirSync(storeDir(), { recursive: true });
|
|
29
|
+
const path = registrarStorePath();
|
|
30
|
+
writeFileSync(path, JSON.stringify(store, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
31
|
+
try {
|
|
32
|
+
chmodSync(path, 0o600);
|
|
33
|
+
} catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveProvider(id: ProviderId, creds: RegistrarCredentials): void {
|
|
39
|
+
const store = readRegistrarStore();
|
|
40
|
+
store[id] = creds;
|
|
41
|
+
writeRegistrarStore(store);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function removeProvider(id: ProviderId): boolean {
|
|
45
|
+
const store = readRegistrarStore();
|
|
46
|
+
if (!store[id]) return false;
|
|
47
|
+
delete store[id];
|
|
48
|
+
writeRegistrarStore(store);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function connectedProviders(): ProviderId[] {
|
|
53
|
+
const store = readRegistrarStore();
|
|
54
|
+
return (Object.keys(store) as ProviderId[]).filter((id) => Boolean(store[id]));
|
|
55
|
+
}
|