uplink-cli 0.1.38 → 0.1.39
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 +161 -0
- package/LICENSE +21 -0
- package/README.md +46 -47
- package/cli/src/index.ts +5 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/godaddy.ts +99 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +30 -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 +40 -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 +268 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +3 -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/domains.ts +199 -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/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/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +124 -33
- package/cli/src/templates/index.ts +3 -3
- package/cli/src/tui/App.tsx +197 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +59 -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 +382 -0
- package/cli/src/tui/snapshot.ts +146 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/docs/AGENTS.md +113 -147
- package/docs/MENU_STRUCTURE.md +56 -288
- package/docs/README.md +6 -6
- package/package.json +18 -35
- 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,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,30 @@
|
|
|
1
|
+
import { cloudflareAdapter } from "./cloudflare";
|
|
2
|
+
import { godaddyAdapter } from "./godaddy";
|
|
3
|
+
import { hostingerAdapter } from "./hostinger";
|
|
4
|
+
import { namecheapAdapter } from "./namecheap";
|
|
5
|
+
import type { ProviderId, RegistrarAdapter } from "./types";
|
|
6
|
+
|
|
7
|
+
/** GoDaddy first, then the rest. Namecheap last because connect is the clumsiest. */
|
|
8
|
+
export const adapters: RegistrarAdapter[] = [
|
|
9
|
+
godaddyAdapter,
|
|
10
|
+
cloudflareAdapter,
|
|
11
|
+
hostingerAdapter,
|
|
12
|
+
namecheapAdapter,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
16
|
+
|
|
17
|
+
export function getAdapter(id: ProviderId): RegistrarAdapter {
|
|
18
|
+
const adapter = byId.get(id);
|
|
19
|
+
if (!adapter) throw new Error(`Unknown provider: ${id}`);
|
|
20
|
+
return adapter;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { connectedProviders, readRegistrarStore, removeProvider, saveProvider } from "./store";
|
|
24
|
+
export { PROVIDER_IDS, isProviderId } from "./types";
|
|
25
|
+
export type {
|
|
26
|
+
DomainQuote,
|
|
27
|
+
InventoryDomain,
|
|
28
|
+
ProviderId,
|
|
29
|
+
RegistrarCredentials,
|
|
30
|
+
} 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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const PROVIDER_IDS = ["godaddy", "cloudflare", "hostinger", "namecheap"] as const;
|
|
2
|
+
export type ProviderId = (typeof PROVIDER_IDS)[number];
|
|
3
|
+
|
|
4
|
+
export function isProviderId(value: string): value is ProviderId {
|
|
5
|
+
return (PROVIDER_IDS as readonly string[]).includes(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RegistrarCredentials = {
|
|
9
|
+
token?: string;
|
|
10
|
+
apiUser?: string;
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
accountId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type InventoryDomain = {
|
|
16
|
+
domain: string;
|
|
17
|
+
provider: ProviderId;
|
|
18
|
+
status: "owned";
|
|
19
|
+
expiresAt?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DomainQuote = {
|
|
23
|
+
domain: string;
|
|
24
|
+
provider: ProviderId;
|
|
25
|
+
status: "owned" | "available" | "taken" | "not_for_sale" | "unknown";
|
|
26
|
+
buyable?: boolean;
|
|
27
|
+
priceUsd?: number;
|
|
28
|
+
premium?: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RegistrarAdapter = {
|
|
33
|
+
id: ProviderId;
|
|
34
|
+
label: string;
|
|
35
|
+
/** What an agent must pass to connect. */
|
|
36
|
+
connectHelp: string;
|
|
37
|
+
verify(creds: RegistrarCredentials): Promise<RegistrarCredentials>;
|
|
38
|
+
listDomains(creds: RegistrarCredentials): Promise<InventoryDomain[]>;
|
|
39
|
+
check(creds: RegistrarCredentials, domain: string): Promise<DomainQuote>;
|
|
40
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { apiRequest } from "../http";
|
|
3
3
|
import { getResolvedApiBase } from "../utils/api-base";
|
|
4
|
+
import { handleError } from "../utils/machine";
|
|
4
5
|
|
|
5
6
|
export const adminCommand = new Command("admin")
|
|
6
7
|
.description("Admin commands for system management");
|
|
@@ -63,10 +64,8 @@ adminCommand
|
|
|
63
64
|
console.log(` Created 24h: ${stats.databases.createdLast24h}`);
|
|
64
65
|
console.log();
|
|
65
66
|
}
|
|
66
|
-
} catch (error
|
|
67
|
-
|
|
68
|
-
console.error("Error getting status:", errorMsg);
|
|
69
|
-
process.exit(1);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
handleError(error, { json: opts.json });
|
|
70
69
|
}
|
|
71
70
|
});
|
|
72
71
|
|
|
@@ -118,10 +117,8 @@ adminCommand
|
|
|
118
117
|
}
|
|
119
118
|
console.log();
|
|
120
119
|
}
|
|
121
|
-
} catch (error
|
|
122
|
-
|
|
123
|
-
console.error("Error listing tunnels:", errorMsg);
|
|
124
|
-
process.exit(1);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
handleError(error, { json: opts.json });
|
|
125
122
|
}
|
|
126
123
|
});
|
|
127
124
|
|
|
@@ -176,10 +173,8 @@ adminCommand
|
|
|
176
173
|
}
|
|
177
174
|
console.log();
|
|
178
175
|
}
|
|
179
|
-
} catch (error
|
|
180
|
-
|
|
181
|
-
console.error("Error listing databases:", errorMsg);
|
|
182
|
-
process.exit(1);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
handleError(error, { json: opts.json });
|
|
183
178
|
}
|
|
184
179
|
});
|
|
185
180
|
|
|
@@ -222,10 +217,8 @@ tokensCommand
|
|
|
222
217
|
console.log("\nIMPORTANT: This token is shown only once. Store it securely.\n");
|
|
223
218
|
console.log(result.token);
|
|
224
219
|
console.log();
|
|
225
|
-
} catch (error
|
|
226
|
-
|
|
227
|
-
console.error("Error creating token:", errorMsg);
|
|
228
|
-
process.exit(1);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
handleError(error, { json: opts.json });
|
|
229
222
|
}
|
|
230
223
|
});
|
|
231
224
|
|
|
@@ -287,10 +280,8 @@ tokensCommand
|
|
|
287
280
|
);
|
|
288
281
|
}
|
|
289
282
|
console.log();
|
|
290
|
-
} catch (error
|
|
291
|
-
|
|
292
|
-
console.error("Error listing tokens:", errorMsg);
|
|
293
|
-
process.exit(1);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
handleError(error, { json: opts.json });
|
|
294
285
|
}
|
|
295
286
|
});
|
|
296
287
|
|
|
@@ -306,7 +297,7 @@ tokensCommand
|
|
|
306
297
|
const token = opts.token ? String(opts.token) : "";
|
|
307
298
|
if (!id && !token) {
|
|
308
299
|
console.error("Provide --id or --token");
|
|
309
|
-
process.exit(
|
|
300
|
+
process.exit(2);
|
|
310
301
|
}
|
|
311
302
|
|
|
312
303
|
const result = await apiRequest("POST", "/v1/admin/tokens/revoke", {
|
|
@@ -319,10 +310,8 @@ tokensCommand
|
|
|
319
310
|
} else {
|
|
320
311
|
console.log(`✅ Revoked token${id ? ` ${id}` : ""} at ${result.revokedAt || ""}`);
|
|
321
312
|
}
|
|
322
|
-
} catch (error
|
|
323
|
-
|
|
324
|
-
console.error("Error revoking token:", errorMsg);
|
|
325
|
-
process.exit(1);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
handleError(error, { json: opts.json });
|
|
326
315
|
}
|
|
327
316
|
});
|
|
328
317
|
|
|
@@ -344,12 +333,10 @@ adminCommand
|
|
|
344
333
|
}
|
|
345
334
|
} else {
|
|
346
335
|
console.error("Specify what to cleanup: --dev-user-tunnels");
|
|
347
|
-
process.exit(
|
|
336
|
+
process.exit(2);
|
|
348
337
|
}
|
|
349
|
-
} catch (error
|
|
350
|
-
|
|
351
|
-
console.error("Error during cleanup:", errorMsg);
|
|
352
|
-
process.exit(1);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
handleError(error, { json: opts.json });
|
|
353
340
|
}
|
|
354
341
|
});
|
|
355
342
|
|