uplink-cli 0.2.6 → 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.
package/AGENTS.md CHANGED
@@ -127,6 +127,8 @@ uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --jso
127
127
  uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
128
128
  uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
129
129
  uplink domains providers connect cpanel --host server341.web-hosting.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
130
+ # Repeat connect with another --host to add more cPanel accounts; remove one with:
131
+ uplink domains providers disconnect cpanel --host server341.web-hosting.com --json
130
132
  uplink domains providers list --json
131
133
  uplink domains providers disconnect godaddy --json
132
134
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7 — 2026-08-31
4
+
5
+ Multiple **cPanel** accounts: `providers connect cpanel --host …` now appends (people have sites on several hosts), `providers disconnect cpanel --host …` removes one account, and `providers list` shows the connected hosts. One user, many shared hosts, one inventory.
6
+
3
7
  ## 0.2.6 — 2026-08-30
4
8
 
5
9
  Uplink as a **domain hub**: connect any **cPanel** host (Namecheap shared, Bluehost, HostGator, and most shared hosting) alongside registrars, so domains and sites scattered across providers land in one `domains list`. Connect with `uplink domains providers connect cpanel --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN` (token from cPanel → Security → Manage API Tokens), or through the menu under Domains → Connect registrar.
@@ -1,4 +1,9 @@
1
- import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
1
+ import type {
2
+ CpanelAccount,
3
+ InventoryDomain,
4
+ RegistrarAdapter,
5
+ RegistrarCredentials,
6
+ } from "./types";
2
7
 
3
8
  /**
4
9
  * cPanel is not a registrar, but it is where many people's sites actually
@@ -9,6 +14,9 @@ import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./
9
14
  *
10
15
  * Auth: a cPanel API token (Security → Manage API Tokens in the panel),
11
16
  * sent as `Authorization: cpanel user:token` to the UAPI on port 2083.
17
+ *
18
+ * A user can connect several cPanel accounts (different hosts); each
19
+ * `providers connect cpanel --host …` appends to `creds.accounts`.
12
20
  */
13
21
 
14
22
  type UapiResponse = {
@@ -31,41 +39,65 @@ export function normalizeCpanelHost(raw: string): string {
31
39
  return host;
32
40
  }
33
41
 
34
- function baseUrl(creds: RegistrarCredentials): string {
35
- const host = normalizeCpanelHost(creds.host || "");
42
+ /** All connected cPanel accounts, including the legacy single-account shape. */
43
+ export function cpanelAccountsOf(creds: RegistrarCredentials): CpanelAccount[] {
44
+ const accounts: CpanelAccount[] = [];
45
+ const push = (account: { host?: string; apiUser?: string; token?: string }) => {
46
+ if (!account.host || !account.apiUser || !account.token) return;
47
+ const host = normalizeCpanelHost(account.host);
48
+ if (accounts.some((a) => a.host === host && a.apiUser === account.apiUser)) return;
49
+ accounts.push({ host, apiUser: account.apiUser, token: account.token });
50
+ };
51
+ for (const account of creds.accounts || []) push(account);
52
+ push(creds);
53
+ return accounts;
54
+ }
55
+
56
+ /** Merge a newly verified account into stored creds (replace same host+user). */
57
+ export function mergeCpanelCredentials(
58
+ existing: RegistrarCredentials | undefined,
59
+ incoming: RegistrarCredentials
60
+ ): RegistrarCredentials {
61
+ const merged: CpanelAccount[] = existing ? cpanelAccountsOf(existing) : [];
62
+ for (const account of cpanelAccountsOf(incoming)) {
63
+ const at = merged.findIndex((a) => a.host === account.host && a.apiUser === account.apiUser);
64
+ if (at >= 0) merged[at] = account;
65
+ else merged.push(account);
66
+ }
67
+ return { accounts: merged };
68
+ }
69
+
70
+ function baseUrl(host: string): string {
36
71
  return host.includes(":") ? `https://${host}` : `https://${host}:2083`;
37
72
  }
38
73
 
39
- async function uapi(creds: RegistrarCredentials, module: string, fn: string): Promise<unknown> {
40
- const user = creds.apiUser || "";
41
- const token = creds.token || "";
42
- if (!user || !token) throw new Error("cPanel needs a username and an API token");
43
- const url = `${baseUrl(creds)}/execute/${module}/${fn}`;
74
+ async function uapi(account: CpanelAccount, module: string, fn: string): Promise<unknown> {
75
+ const url = `${baseUrl(account.host)}/execute/${module}/${fn}`;
44
76
  let res: Response;
45
77
  try {
46
78
  res = await fetch(url, {
47
79
  headers: {
48
- Authorization: `cpanel ${user}:${token}`,
80
+ Authorization: `cpanel ${account.apiUser}:${account.token}`,
49
81
  Accept: "application/json",
50
82
  },
51
83
  });
52
84
  } catch (error) {
53
85
  const detail = error instanceof Error ? error.message : String(error);
54
- throw new Error(`Could not reach cPanel at ${baseUrl(creds)} (${detail})`);
86
+ throw new Error(`Could not reach cPanel at ${baseUrl(account.host)} (${detail})`);
55
87
  }
56
88
  if (res.status === 401 || res.status === 403) {
57
- throw new Error("cPanel rejected the credentials (check username and API token)");
89
+ throw new Error(`cPanel at ${account.host} rejected the credentials (check username and API token)`);
58
90
  }
59
91
  const text = await res.text();
60
92
  let body: UapiResponse;
61
93
  try {
62
94
  body = text ? (JSON.parse(text) as UapiResponse) : {};
63
95
  } catch {
64
- throw new Error(`cPanel returned a non-JSON response (HTTP ${res.status})`);
96
+ throw new Error(`cPanel at ${account.host} returned a non-JSON response (HTTP ${res.status})`);
65
97
  }
66
98
  if (!res.ok || body.status !== 1) {
67
99
  const detail = body.errors?.filter(Boolean).join("; ") || `HTTP ${res.status}`;
68
- throw new Error(`cPanel ${module}/${fn} failed: ${detail}`);
100
+ throw new Error(`cPanel ${module}/${fn} failed on ${account.host}: ${detail}`);
69
101
  }
70
102
  return body.data;
71
103
  }
@@ -75,20 +107,35 @@ function stringsOf(value: unknown): string[] {
75
107
  return value.filter((item): item is string => typeof item === "string" && item.includes("."));
76
108
  }
77
109
 
110
+ async function listAccountDomains(account: CpanelAccount): Promise<string[]> {
111
+ const data = (await uapi(account, "DomainInfo", "list_domains")) as Record<string, unknown> | null;
112
+ const out: string[] = [];
113
+ const main = data?.main_domain;
114
+ if (typeof main === "string" && main.includes(".")) out.push(main);
115
+ out.push(...stringsOf(data?.addon_domains));
116
+ out.push(...stringsOf(data?.parked_domains));
117
+ return out;
118
+ }
119
+
78
120
  async function listHostedDomains(creds: RegistrarCredentials): Promise<InventoryDomain[]> {
79
- const data = (await uapi(creds, "DomainInfo", "list_domains")) as Record<string, unknown> | null;
121
+ const accounts = cpanelAccountsOf(creds);
122
+ if (accounts.length === 0) throw new Error("cPanel needs a host, username, and API token");
80
123
  const out: InventoryDomain[] = [];
81
124
  const seen = new Set<string>();
82
- const push = (domain: string) => {
83
- const normalized = domain.toLowerCase();
84
- if (seen.has(normalized)) return;
85
- seen.add(normalized);
86
- out.push({ domain: normalized, provider: "cpanel", status: "owned" });
87
- };
88
- const main = data?.main_domain;
89
- if (typeof main === "string" && main.includes(".")) push(main);
90
- for (const domain of stringsOf(data?.addon_domains)) push(domain);
91
- for (const domain of stringsOf(data?.parked_domains)) push(domain);
125
+ const errors: string[] = [];
126
+ for (const account of accounts) {
127
+ try {
128
+ for (const domain of await listAccountDomains(account)) {
129
+ const normalized = domain.toLowerCase();
130
+ if (seen.has(normalized)) continue;
131
+ seen.add(normalized);
132
+ out.push({ domain: normalized, provider: "cpanel", status: "owned" });
133
+ }
134
+ } catch (error) {
135
+ errors.push(error instanceof Error ? error.message : String(error));
136
+ }
137
+ }
138
+ if (out.length === 0 && errors.length > 0) throw new Error(errors.join("; "));
92
139
  return out;
93
140
  }
94
141
 
@@ -96,12 +143,18 @@ export const cpanelAdapter: RegistrarAdapter = {
96
143
  id: "cpanel",
97
144
  label: "cPanel hosting",
98
145
  connectHelp:
99
- "Any cPanel host (Namecheap shared, Bluehost, HostGator, …). --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN (token: cPanel → Security → Manage API Tokens)",
146
+ "Any cPanel host (Namecheap shared, Bluehost, HostGator, …). --host server.example.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN (token: cPanel → Security → Manage API Tokens). Repeat with another --host to add more accounts.",
100
147
  async verify(creds) {
101
- if (!creds.host) throw new Error("cPanel needs --host (e.g. server341.web-hosting.com)");
102
- creds.host = normalizeCpanelHost(creds.host);
103
- await listHostedDomains(creds);
104
- return creds;
148
+ // Verify only the incoming account(s); the connect flow merges them
149
+ // into any previously stored accounts afterwards.
150
+ const accounts = cpanelAccountsOf(creds);
151
+ if (accounts.length === 0) {
152
+ throw new Error("cPanel needs --host (e.g. server341.web-hosting.com), a username, and an API token");
153
+ }
154
+ for (const account of accounts) {
155
+ await listAccountDomains(account);
156
+ }
157
+ return { accounts };
105
158
  },
106
159
  async listDomains(creds) {
107
160
  const domains = await listHostedDomains(creds);
@@ -114,6 +167,6 @@ export const cpanelAdapter: RegistrarAdapter = {
114
167
  if (hosted.some((item) => item.domain === domain.toLowerCase())) {
115
168
  return { domain, provider: "cpanel", status: "owned", buyable: false };
116
169
  }
117
- throw new Error("cPanel only knows domains hosted on the connected account");
170
+ throw new Error("cPanel only knows domains hosted on the connected accounts");
118
171
  },
119
172
  };
@@ -5,6 +5,12 @@ export function isProviderId(value: string): value is ProviderId {
5
5
  return (PROVIDER_IDS as readonly string[]).includes(value);
6
6
  }
7
7
 
8
+ export type CpanelAccount = {
9
+ host: string;
10
+ apiUser: string;
11
+ token: string;
12
+ };
13
+
8
14
  export type RegistrarCredentials = {
9
15
  token?: string;
10
16
  apiUser?: string;
@@ -14,6 +20,8 @@ export type RegistrarCredentials = {
14
20
  host?: string;
15
21
  /** Additional API keys for providers with multiple accounts (e.g. DreamHost). */
16
22
  extraTokens?: string[];
23
+ /** Multiple cPanel accounts — people often have sites on several hosts. */
24
+ accounts?: CpanelAccount[];
17
25
  };
18
26
 
19
27
  export type InventoryDomain = {
@@ -15,6 +15,7 @@ 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,
@@ -569,18 +570,25 @@ providers
569
570
  .action((opts) => {
570
571
  try {
571
572
  const store = readRegistrarStore();
572
- const items = adapters.map((adapter) => ({
573
- id: adapter.id,
574
- label: adapter.label,
575
- connected: Boolean(store[adapter.id]),
576
- help: adapter.connectHelp,
577
- }));
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
+ });
578
585
  if (opts.json) {
579
586
  printJson({ providers: items });
580
587
  return;
581
588
  }
582
589
  for (const item of items) {
583
- 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"}`);
584
592
  if (!item.connected) console.log(` ${item.help}`);
585
593
  }
586
594
  } catch (error) {
@@ -606,7 +614,13 @@ providers
606
614
  const adapter = getAdapter(provider);
607
615
  const creds = await credentialsFromFlags(provider, opts);
608
616
  const verified = await adapter.verify(creds);
609
- 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);
610
624
  const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
611
625
  if (opts.json) {
612
626
  printJson({
@@ -626,11 +640,28 @@ providers
626
640
  .command("disconnect")
627
641
  .description("Remove a saved registrar or cPanel credential")
628
642
  .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost | cpanel")
643
+ .option("--host <hostname>", "cPanel only: remove just this host's account")
629
644
  .option("--json", "Output JSON", false)
630
645
  .action((providerArg: string, opts) => {
631
646
  try {
632
647
  const provider = String(providerArg).toLowerCase();
633
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
+
634
665
  const removed = removeProvider(provider);
635
666
  if (opts.json) {
636
667
  printJson({ provider, connected: false, removed });
package/docs/AGENTS.md CHANGED
@@ -127,6 +127,8 @@ uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --jso
127
127
  uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
128
128
  uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
129
129
  uplink domains providers connect cpanel --host server341.web-hosting.com --user-env CPANEL_USER --token-env CPANEL_API_TOKEN --json
130
+ # Repeat connect with another --host to add more cPanel accounts; remove one with:
131
+ uplink domains providers disconnect cpanel --host server341.web-hosting.com --json
130
132
  uplink domains providers list --json
131
133
  uplink domains providers disconnect godaddy --json
132
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Software for agents — share localhost, host apps, and attach domains from the terminal. JSON-first CLI for Cursor, Claude, Codex, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-agents",