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.
Files changed (54) hide show
  1. package/AGENTS.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +46 -47
  4. package/cli/src/index.ts +5 -3
  5. package/cli/src/registrars/cloudflare.ts +148 -0
  6. package/cli/src/registrars/godaddy.ts +99 -0
  7. package/cli/src/registrars/hostinger.ts +106 -0
  8. package/cli/src/registrars/http.ts +18 -0
  9. package/cli/src/registrars/index.ts +30 -0
  10. package/cli/src/registrars/namecheap.ts +163 -0
  11. package/cli/src/registrars/secret.ts +66 -0
  12. package/cli/src/registrars/store.ts +55 -0
  13. package/cli/src/registrars/types.ts +40 -0
  14. package/cli/src/subcommands/admin.ts +17 -30
  15. package/cli/src/subcommands/db.ts +63 -57
  16. package/cli/src/subcommands/dev.ts +23 -25
  17. package/cli/src/subcommands/domains.ts +268 -0
  18. package/cli/src/subcommands/host-domains.ts +148 -0
  19. package/cli/src/subcommands/host.ts +3 -0
  20. package/cli/src/subcommands/menu/colors.ts +1 -1
  21. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  22. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  23. package/cli/src/subcommands/menu/io.ts +27 -5
  24. package/cli/src/subcommands/menu/menus/domains.ts +199 -0
  25. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  26. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  27. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  28. package/cli/src/subcommands/menu/render.ts +2 -2
  29. package/cli/src/subcommands/menu/tests.ts +1 -1
  30. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  31. package/cli/src/subcommands/menu/types.ts +8 -0
  32. package/cli/src/subcommands/menu.ts +32 -524
  33. package/cli/src/subcommands/system.ts +58 -36
  34. package/cli/src/subcommands/tunnel.ts +124 -33
  35. package/cli/src/templates/index.ts +3 -3
  36. package/cli/src/tui/App.tsx +197 -0
  37. package/cli/src/tui/AppInspector.tsx +114 -0
  38. package/cli/src/tui/HomeStatus.tsx +59 -0
  39. package/cli/src/tui/brand.tsx +20 -0
  40. package/cli/src/tui/format.ts +22 -0
  41. package/cli/src/tui/index.mts +6 -0
  42. package/cli/src/tui/liveTree.ts +40 -0
  43. package/cli/src/tui/package.json +3 -0
  44. package/cli/src/tui/runMenu.tsx +57 -0
  45. package/cli/src/tui/session.mts +382 -0
  46. package/cli/src/tui/snapshot.ts +146 -0
  47. package/cli/src/utils/launchDomainking.ts +64 -0
  48. package/docs/AGENTS.md +113 -147
  49. package/docs/MENU_STRUCTURE.md +56 -288
  50. package/docs/README.md +6 -6
  51. package/package.json +18 -35
  52. package/scripts/tunnel/client-improved.js +127 -38
  53. package/scripts/tunnel/client.js +118 -0
  54. package/assets/cli-screenshot.png +0 -0
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { apiRequest } from "../http";
3
+ import { handleError, printJson } from "../utils/machine";
3
4
 
4
5
  export const dbCommand = new Command("db").description("Manage databases");
5
6
 
@@ -13,19 +14,20 @@ dbCommand
13
14
  .option("--plan <plan>", "Plan", "dev")
14
15
  .option("--json", "Output JSON", false)
15
16
  .action(async (opts) => {
16
- const body = {
17
- name: opts.name,
18
- project: opts.project,
19
- provider: opts.provider,
20
- region: opts.region,
21
- plan: opts.plan,
22
- };
17
+ try {
18
+ const body = {
19
+ name: opts.name,
20
+ project: opts.project,
21
+ provider: opts.provider,
22
+ region: opts.region,
23
+ plan: opts.plan,
24
+ };
23
25
 
24
- const result = await apiRequest("POST", "/v1/dbs", body);
25
- if (opts.json) {
26
- console.log(JSON.stringify(result, null, 2));
27
- } else {
28
- console.log(`Created DB ${result.name} (${result.id}) in ${result.region}`);
26
+ const result = await apiRequest("POST", "/v1/dbs", body);
27
+ if (opts.json) printJson(result);
28
+ else console.log(`Created DB ${result.name} (${result.id}) in ${result.region}`);
29
+ } catch (error) {
30
+ handleError(error, { json: opts.json });
29
31
  }
30
32
  });
31
33
 
@@ -35,16 +37,20 @@ dbCommand
35
37
  .option("--project <project>", "Project name")
36
38
  .option("--json", "Output JSON", false)
37
39
  .action(async (opts) => {
38
- const query = opts.project ? `?project=${encodeURIComponent(opts.project)}` : "";
39
- const result = await apiRequest("GET", `/v1/dbs${query}`);
40
- if (opts.json) {
41
- console.log(JSON.stringify(result, null, 2));
42
- } else {
43
- for (const db of result.items) {
44
- console.log(
45
- `${db.id} ${db.name} ${db.region} ${db.status} ready=${db.ready}`
46
- );
40
+ try {
41
+ const query = opts.project ? `?project=${encodeURIComponent(opts.project)}` : "";
42
+ const result = await apiRequest("GET", `/v1/dbs${query}`);
43
+ if (opts.json) {
44
+ printJson(result);
45
+ } else {
46
+ for (const db of result.items || []) {
47
+ console.log(
48
+ `${db.id} ${db.name} ${db.region} ${db.status} ready=${db.ready}`
49
+ );
50
+ }
47
51
  }
52
+ } catch (error) {
53
+ handleError(error, { json: opts.json });
48
54
  }
49
55
  });
50
56
 
@@ -54,14 +60,18 @@ dbCommand
54
60
  .requiredOption("--id <id>", "Database id")
55
61
  .option("--json", "Output JSON", false)
56
62
  .action(async (opts) => {
57
- const result = await apiRequest("GET", `/v1/dbs/${opts.id}`);
58
- if (opts.json) {
59
- console.log(JSON.stringify(result, null, 2));
60
- } else {
61
- console.log(`DB ${result.name} (${result.id})`);
62
- console.log(` region: ${result.region}`);
63
- console.log(` status: ${result.status}`);
64
- console.log(` engine: ${result.engine} ${result.version}`);
63
+ try {
64
+ const result = await apiRequest("GET", `/v1/dbs/${opts.id}`);
65
+ if (opts.json) {
66
+ printJson(result);
67
+ } else {
68
+ console.log(`DB ${result.name} (${result.id})`);
69
+ console.log(` region: ${result.region}`);
70
+ console.log(` status: ${result.status}`);
71
+ console.log(` engine: ${result.engine} ${result.version}`);
72
+ }
73
+ } catch (error) {
74
+ handleError(error, { json: opts.json });
65
75
  }
66
76
  });
67
77
 
@@ -72,14 +82,15 @@ dbCommand
72
82
  .option("--yes", "Confirm deletion", false)
73
83
  .option("--json", "Output JSON", false)
74
84
  .action(async (opts) => {
75
- if (!opts.yes) {
76
- throw new Error("Refusing to delete without --yes");
77
- }
78
- const result = await apiRequest("DELETE", `/v1/dbs/${opts.id}`);
79
- if (opts.json) {
80
- console.log(JSON.stringify(result, null, 2));
81
- } else {
82
- console.log(`Deleted DB ${result.id}`);
85
+ try {
86
+ if (!opts.yes) {
87
+ throw new Error("Refusing to delete without --yes");
88
+ }
89
+ const result = await apiRequest("DELETE", `/v1/dbs/${opts.id}`);
90
+ if (opts.json) printJson(result);
91
+ else console.log(`Deleted DB ${result.id}`);
92
+ } catch (error) {
93
+ handleError(error, { json: opts.json });
83
94
  }
84
95
  });
85
96
 
@@ -91,27 +102,22 @@ dbCommand
91
102
  .requiredOption("--env-var <envVar>", "Environment variable name")
92
103
  .option("--json", "Output JSON", false)
93
104
  .action(async (opts) => {
94
- const body = {
95
- service: opts.service,
96
- envVar: opts["env-var"],
97
- };
105
+ try {
106
+ const body = {
107
+ service: opts.service,
108
+ envVar: opts.envVar,
109
+ };
98
110
 
99
- const result = await apiRequest(
100
- "POST",
101
- `/v1/dbs/${opts["db-id"]}/link-service`,
102
- body
103
- );
111
+ const result = await apiRequest("POST", `/v1/dbs/${opts.dbId}/link-service`, body);
104
112
 
105
- if (opts.json) {
106
- console.log(JSON.stringify(result, null, 2));
107
- } else {
108
- console.log(
109
- `Linked DB ${result.dbId} to service ${result.service} as ${result.envVar}`
110
- );
113
+ if (opts.json) {
114
+ printJson(result);
115
+ } else {
116
+ console.log(
117
+ `Linked DB ${result.dbId} to service ${result.service} as ${result.envVar}`
118
+ );
119
+ }
120
+ } catch (error) {
121
+ handleError(error, { json: opts.json });
111
122
  }
112
123
  });
113
-
114
-
115
-
116
-
117
-
@@ -1,26 +1,33 @@
1
1
  import { Command } from "commander";
2
2
  import { spawn } from "child_process";
3
3
  import { apiRequest } from "../http";
4
- import path from "path";
4
+ import { handleError, printJson } from "../utils/machine";
5
+ import { resolveTunnelClientPath } from "./menu/effects/tunnel-clients";
5
6
 
6
7
  export const devCommand = new Command("dev")
7
- .description("Run local dev with optional tunnel")
8
- .option("--tunnel", "Enable tunnel")
8
+ .description("Run local tunnel client against a new tunnel (foreground)")
9
+ .option("--tunnel", "Enable tunnel", false)
9
10
  .option("--port <port>", "Local port to expose", "3000")
10
11
  .option("--json", "Output JSON", false)
11
- .option("--improved", "Use improved client with auto-reconnect and better error handling", false)
12
12
  .action(async (opts) => {
13
13
  const port = Number(opts.port);
14
- if (opts.tunnel) {
15
- // Request tunnel from control plane
14
+ if (!Number.isFinite(port) || port <= 0) {
15
+ console.error("Invalid port. Provide a positive integer.");
16
+ process.exit(2);
17
+ }
18
+
19
+ if (!opts.tunnel) {
20
+ console.log("Tunnel not enabled. Provide --tunnel to expose localhost.");
21
+ console.log("Prefer: uplink tunnel create --port <port> --json");
22
+ return;
23
+ }
24
+
25
+ try {
16
26
  const result = await apiRequest("POST", "/v1/tunnels", { port });
17
27
  if (opts.json) {
18
- console.log(JSON.stringify(result, null, 2));
28
+ printJson(result);
19
29
  } else {
20
30
  console.log(`Tunnel URL: ${result.url}`);
21
- // If the control-plane returns an https URL for the dev.uplink.spot domain,
22
- // it may not actually be reachable unless TLS is configured on the relay.
23
- // Print an HTTP fallback to reduce confusion during development.
24
31
  if (
25
32
  typeof result.url === "string" &&
26
33
  result.url.startsWith("https://") &&
@@ -30,14 +37,7 @@ export const devCommand = new Command("dev")
30
37
  }
31
38
  }
32
39
 
33
- // Spawn tunnel client (use improved version if requested)
34
- const clientFile = opts.improved ? "client-improved.js" : "client.js";
35
- const clientPath = path.join(
36
- process.cwd(),
37
- "scripts",
38
- "tunnel",
39
- clientFile
40
- );
40
+ const clientPath = resolveTunnelClientPath();
41
41
  const ctrlHost = process.env.TUNNEL_CTRL ?? "tunnel.uplink.spot:7071";
42
42
  const args = [
43
43
  clientPath,
@@ -48,10 +48,11 @@ export const devCommand = new Command("dev")
48
48
  "--ctrl",
49
49
  ctrlHost,
50
50
  ];
51
- console.log(`Starting tunnel client: node ${args.join(" ")}`);
51
+ if (!opts.json) {
52
+ console.log(`Starting tunnel client: node ${args.join(" ")}`);
53
+ }
52
54
  const child = spawn("node", args, { stdio: "inherit" });
53
55
 
54
- // Forward Ctrl+C / termination to the child so the tunnel shuts down cleanly.
55
56
  const shutdown = () => {
56
57
  try {
57
58
  child.kill("SIGINT");
@@ -62,7 +63,6 @@ export const devCommand = new Command("dev")
62
63
  process.on("SIGINT", shutdown);
63
64
  process.on("SIGTERM", shutdown);
64
65
 
65
- // Keep the CLI process alive while the tunnel client is running.
66
66
  await new Promise<void>((resolve) => {
67
67
  child.on("exit", (code, signal) => {
68
68
  process.off("SIGINT", shutdown);
@@ -78,9 +78,7 @@ export const devCommand = new Command("dev")
78
78
  resolve();
79
79
  });
80
80
  });
81
- } else {
82
- console.log("Tunnel not enabled. Provide --tunnel to expose localhost.");
81
+ } catch (error) {
82
+ handleError(error, { json: opts.json });
83
83
  }
84
84
  });
85
-
86
-
@@ -0,0 +1,268 @@
1
+ import { Command } from "commander";
2
+ import { launchDomainking } from "../utils/launchDomainking";
3
+ import { handleError, printJson } from "../utils/machine";
4
+ import {
5
+ adapters,
6
+ getAdapter,
7
+ isProviderId,
8
+ readRegistrarStore,
9
+ removeProvider,
10
+ saveProvider,
11
+ type DomainQuote,
12
+ type InventoryDomain,
13
+ type ProviderId,
14
+ type RegistrarCredentials,
15
+ } from "../registrars";
16
+ import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
17
+
18
+ const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap"];
19
+
20
+ function parseProvider(raw?: string): ProviderId | undefined {
21
+ if (!raw) return undefined;
22
+ const id = raw.toLowerCase();
23
+ if (!isProviderId(id)) throw new Error(`Unknown provider: ${raw}`);
24
+ return id;
25
+ }
26
+
27
+ async function credentialsFromFlags(
28
+ provider: ProviderId,
29
+ opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; json?: boolean }
30
+ ): Promise<RegistrarCredentials> {
31
+ const interactive = canPrompt() && !opts.json;
32
+
33
+ if (provider === "namecheap") {
34
+ const apiKey = opts.tokenEnv
35
+ ? readEnvValue(opts.tokenEnv)
36
+ : interactive
37
+ ? await promptSecret("Namecheap API key: ")
38
+ : "";
39
+ const apiUser = opts.userEnv
40
+ ? readEnvValue(opts.userEnv)
41
+ : interactive
42
+ ? await promptSecret("Namecheap API user: ")
43
+ : "";
44
+ if (!apiKey || !apiUser) {
45
+ throw new Error(
46
+ "Namecheap needs --token-env NAMECHEAP_API_KEY and --user-env NAMECHEAP_API_USER"
47
+ );
48
+ }
49
+ return { apiKey, apiUser };
50
+ }
51
+
52
+ const token = opts.tokenEnv
53
+ ? readEnvValue(opts.tokenEnv)
54
+ : interactive
55
+ ? await promptSecret(`${provider} API token: `)
56
+ : "";
57
+ if (!token) {
58
+ throw new Error(`${provider} needs --token-env <VAR> (do not pass the secret on the command line)`);
59
+ }
60
+ const creds: RegistrarCredentials = { token };
61
+ if (opts.accountEnv) creds.accountId = readEnvValue(opts.accountEnv);
62
+ return creds;
63
+ }
64
+
65
+ async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
66
+ const store = readRegistrarStore();
67
+ const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
68
+ const domains: InventoryDomain[] = [];
69
+ const errors: string[] = [];
70
+ for (const id of ids) {
71
+ const creds = store[id];
72
+ if (!creds) {
73
+ if (providerFilter) throw new Error(`${id} is not connected`);
74
+ continue;
75
+ }
76
+ try {
77
+ const listed = await getAdapter(id).listDomains(creds);
78
+ domains.push(...listed);
79
+ } catch (error) {
80
+ errors.push(`${id}: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ }
83
+ if (domains.length === 0 && errors.length > 0) {
84
+ throw new Error(errors.join("; "));
85
+ }
86
+ return domains.sort((a, b) => a.domain.localeCompare(b.domain));
87
+ }
88
+
89
+ async function quoteDomain(domain: string, providerFilter?: ProviderId): Promise<DomainQuote> {
90
+ const store = readRegistrarStore();
91
+ const inventory = await listInventory(providerFilter).catch(() => [] as InventoryDomain[]);
92
+ const owned = inventory.find((item) => item.domain === domain);
93
+ if (owned) {
94
+ return { domain, provider: owned.provider, status: "owned", buyable: false };
95
+ }
96
+
97
+ const ids = providerFilter
98
+ ? [providerFilter]
99
+ : CHECK_ORDER.filter((id) => store[id]);
100
+ if (ids.length === 0) {
101
+ throw new Error(
102
+ "No registrar connected. Run: uplink domains providers connect godaddy --token-env GODADDY_PAT --json"
103
+ );
104
+ }
105
+
106
+ let lastError: string | undefined;
107
+ for (const id of ids) {
108
+ const creds = store[id];
109
+ if (!creds) throw new Error(`${id} is not connected`);
110
+ try {
111
+ return await getAdapter(id).check(creds, domain);
112
+ } catch (error) {
113
+ lastError = error instanceof Error ? error.message : String(error);
114
+ }
115
+ }
116
+ return {
117
+ domain,
118
+ provider: ids[0],
119
+ status: "unknown",
120
+ error: lastError || "check failed",
121
+ };
122
+ }
123
+
124
+ export const domainsCommand = new Command("domains").description(
125
+ "Registrar inventory, availability, and search. Attach with `uplink host domains`."
126
+ );
127
+
128
+ domainsCommand.addHelpText(
129
+ "after",
130
+ "\nWith no subcommand, opens the domain search TUI.\n"
131
+ );
132
+
133
+ domainsCommand.action(() => {
134
+ const message = launchDomainking();
135
+ if (message) console.log(message);
136
+ });
137
+
138
+ domainsCommand
139
+ .command("list")
140
+ .description("List domains owned at connected registrars")
141
+ .option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap)")
142
+ .option("--json", "Output JSON", false)
143
+ .action(async (opts) => {
144
+ try {
145
+ const provider = parseProvider(opts.provider);
146
+ const domains = await listInventory(provider);
147
+ if (opts.json) {
148
+ printJson({ domains, count: domains.length });
149
+ return;
150
+ }
151
+ if (domains.length === 0) {
152
+ console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
153
+ return;
154
+ }
155
+ for (const item of domains) {
156
+ const expiry = item.expiresAt ? ` expires ${item.expiresAt}` : "";
157
+ console.log(`- ${item.domain} (${item.provider}${expiry})`);
158
+ }
159
+ } catch (error) {
160
+ handleError(error, { json: opts.json });
161
+ }
162
+ });
163
+
164
+ domainsCommand
165
+ .command("check")
166
+ .description("Ask a connected registrar if a domain is buyable, and at what price")
167
+ .argument("<domain>", "Domain name (e.g. example.com)")
168
+ .option("--provider <id>", "Which registrar to ask")
169
+ .option("--json", "Output JSON", false)
170
+ .action(async (domainArg: string, opts) => {
171
+ try {
172
+ const domain = String(domainArg).trim().toLowerCase();
173
+ if (!domain.includes(".")) throw new Error("Pass a full domain like example.com");
174
+ const provider = parseProvider(opts.provider);
175
+ const quote = await quoteDomain(domain, provider);
176
+ if (opts.json) {
177
+ printJson(quote);
178
+ return;
179
+ }
180
+ const price = quote.priceUsd != null ? ` $${quote.priceUsd.toFixed(2)}/yr` : "";
181
+ const premium = quote.premium ? " premium" : "";
182
+ console.log(`${quote.domain} ${quote.status}${price}${premium} [${quote.provider}]`);
183
+ if (quote.error) console.log(` ${quote.error}`);
184
+ } catch (error) {
185
+ handleError(error, { json: opts.json });
186
+ }
187
+ });
188
+
189
+ const providers = domainsCommand.command("providers").description("Connect registrar accounts");
190
+
191
+ providers
192
+ .command("list")
193
+ .description("Show which registrars are connected (never prints secrets)")
194
+ .option("--json", "Output JSON", false)
195
+ .action((opts) => {
196
+ try {
197
+ const store = readRegistrarStore();
198
+ const items = adapters.map((adapter) => ({
199
+ id: adapter.id,
200
+ label: adapter.label,
201
+ connected: Boolean(store[adapter.id]),
202
+ help: adapter.connectHelp,
203
+ }));
204
+ if (opts.json) {
205
+ printJson({ providers: items });
206
+ return;
207
+ }
208
+ for (const item of items) {
209
+ console.log(`- ${item.id} ${item.connected ? "connected" : "not connected"}`);
210
+ if (!item.connected) console.log(` ${item.help}`);
211
+ }
212
+ } catch (error) {
213
+ handleError(error, { json: opts.json });
214
+ }
215
+ });
216
+
217
+ providers
218
+ .command("connect")
219
+ .description("Save a registrar credential after a live check")
220
+ .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
221
+ .option("--token-env <name>", "Env var holding the API token or Namecheap API key")
222
+ .option("--user-env <name>", "Env var holding the Namecheap API user")
223
+ .option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
224
+ .option("--json", "Output JSON", false)
225
+ .action(async (providerArg: string, opts) => {
226
+ try {
227
+ const provider = String(providerArg).toLowerCase();
228
+ if (!isProviderId(provider)) {
229
+ throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, or namecheap`);
230
+ }
231
+ const adapter = getAdapter(provider);
232
+ const creds = await credentialsFromFlags(provider, opts);
233
+ const verified = await adapter.verify(creds);
234
+ saveProvider(provider, verified);
235
+ const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
236
+ if (opts.json) {
237
+ printJson({
238
+ provider,
239
+ connected: true,
240
+ domains: listed.length,
241
+ });
242
+ return;
243
+ }
244
+ console.log(`Connected ${adapter.label} (${listed.length} domain${listed.length === 1 ? "" : "s"})`);
245
+ } catch (error) {
246
+ handleError(error, { json: opts.json });
247
+ }
248
+ });
249
+
250
+ providers
251
+ .command("disconnect")
252
+ .description("Remove a saved registrar credential")
253
+ .argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
254
+ .option("--json", "Output JSON", false)
255
+ .action((providerArg: string, opts) => {
256
+ try {
257
+ const provider = String(providerArg).toLowerCase();
258
+ if (!isProviderId(provider)) throw new Error(`Unknown provider: ${providerArg}`);
259
+ const removed = removeProvider(provider);
260
+ if (opts.json) {
261
+ printJson({ provider, connected: false, removed });
262
+ return;
263
+ }
264
+ console.log(removed ? `Disconnected ${provider}` : `${provider} was not connected`);
265
+ } catch (error) {
266
+ handleError(error, { json: opts.json });
267
+ }
268
+ });
@@ -0,0 +1,148 @@
1
+ import { Command } from "commander";
2
+ import { apiRequest } from "../http";
3
+ import { handleError, printJson } from "../utils/machine";
4
+
5
+ type AppDomain = {
6
+ id: string;
7
+ hostname: string;
8
+ verified: boolean;
9
+ verifiedAt: string | null;
10
+ createdAt: string;
11
+ dns?: { type: string; host: string; target: string };
12
+ };
13
+ type AppDomainList = { domains: AppDomain[] };
14
+
15
+ async function findDomainByHostname(appId: string, hostname: string): Promise<AppDomain | null> {
16
+ const result = (await apiRequest("GET", `/v1/apps/${appId}/domains`)) as AppDomainList;
17
+ const clean = hostname.trim().toLowerCase();
18
+ return result.domains?.find((d) => d.hostname === clean) ?? null;
19
+ }
20
+
21
+ function printDomain(domain: AppDomain): void {
22
+ const status = domain.verified ? "verified" : "pending verification";
23
+ console.log(`- ${domain.hostname} (${status})`);
24
+ if (domain.dns) {
25
+ console.log(` DNS needed: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
26
+ }
27
+ }
28
+
29
+ /** The verify endpoint responds 409 with a reason while DNS doesn't point at the edge. */
30
+ function extractVerifyFailure(error: unknown): { reason?: string; dns?: AppDomain["dns"] } | null {
31
+ if (!(error instanceof Error)) return null;
32
+ try {
33
+ const parsed = JSON.parse(error.message) as { reason?: string; dns?: AppDomain["dns"] };
34
+ return parsed && (parsed.reason || parsed.dns) ? parsed : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export const domainsCommand = new Command("domains").description(
41
+ "Manage custom domains for hosted apps"
42
+ );
43
+
44
+ domainsCommand
45
+ .command("list")
46
+ .description("List custom domains attached to an app")
47
+ .requiredOption("--id <id>", "App id (app_...)")
48
+ .option("--json", "Output JSON", false)
49
+ .action(async (opts) => {
50
+ try {
51
+ const result = (await apiRequest("GET", `/v1/apps/${opts.id}/domains`)) as AppDomainList;
52
+ if (opts.json) {
53
+ printJson(result);
54
+ return;
55
+ }
56
+ if (!result.domains || result.domains.length === 0) {
57
+ console.log("No custom domains attached.");
58
+ return;
59
+ }
60
+ console.log("Custom domains:");
61
+ for (const domain of result.domains) printDomain(domain);
62
+ } catch (error) {
63
+ handleError(error, { json: opts.json });
64
+ }
65
+ });
66
+
67
+ domainsCommand
68
+ .command("add")
69
+ .description("Attach a custom domain to an app")
70
+ .requiredOption("--id <id>", "App id (app_...)")
71
+ .requiredOption("--hostname <hostname>", "Domain to attach (e.g. example.com)")
72
+ .option("--json", "Output JSON", false)
73
+ .action(async (opts) => {
74
+ try {
75
+ const domain = (await apiRequest("POST", `/v1/apps/${opts.id}/domains`, {
76
+ hostname: opts.hostname,
77
+ })) as AppDomain;
78
+ if (opts.json) {
79
+ printJson(domain);
80
+ return;
81
+ }
82
+ console.log(`Attached ${domain.hostname} (pending verification)`);
83
+ if (domain.dns) {
84
+ console.log(` 1. At your registrar: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
85
+ }
86
+ console.log(` 2. Then run: uplink host domains verify --id ${opts.id} --hostname ${domain.hostname}`);
87
+ } catch (error) {
88
+ handleError(error, { json: opts.json });
89
+ }
90
+ });
91
+
92
+ domainsCommand
93
+ .command("verify")
94
+ .description("Verify a domain's DNS points at the hosting edge (enables routing + TLS)")
95
+ .requiredOption("--id <id>", "App id (app_...)")
96
+ .requiredOption("--hostname <hostname>", "Domain to verify")
97
+ .option("--json", "Output JSON", false)
98
+ .action(async (opts) => {
99
+ try {
100
+ const domain = await findDomainByHostname(opts.id, opts.hostname);
101
+ if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
102
+
103
+ try {
104
+ const verified = (await apiRequest(
105
+ "POST",
106
+ `/v1/apps/${opts.id}/domains/${domain.id}/verify`
107
+ )) as AppDomain;
108
+ if (opts.json) printJson(verified);
109
+ else {
110
+ console.log(`Verified ${verified.hostname}`);
111
+ console.log(` Live at https://${verified.hostname} (cert issues on first request)`);
112
+ }
113
+ } catch (error) {
114
+ const failure = extractVerifyFailure(error);
115
+ if (!failure) throw error;
116
+ if (opts.json) {
117
+ printJson({ hostname: domain.hostname, verified: false, ...failure });
118
+ return;
119
+ }
120
+ console.log(`Not verified yet: ${failure.reason || "DNS does not point at the edge"}`);
121
+ if (failure.dns) {
122
+ console.log(` DNS needed: ${failure.dns.type} ${failure.dns.host} -> ${failure.dns.target}`);
123
+ }
124
+ console.log(" DNS changes can take a few minutes to propagate; try again shortly.");
125
+ process.exitCode = 1;
126
+ }
127
+ } catch (error) {
128
+ handleError(error, { json: opts.json });
129
+ }
130
+ });
131
+
132
+ domainsCommand
133
+ .command("remove")
134
+ .description("Detach a custom domain from an app")
135
+ .requiredOption("--id <id>", "App id (app_...)")
136
+ .requiredOption("--hostname <hostname>", "Domain to detach")
137
+ .option("--json", "Output JSON", false)
138
+ .action(async (opts) => {
139
+ try {
140
+ const domain = await findDomainByHostname(opts.id, opts.hostname);
141
+ if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
142
+ const result = await apiRequest("DELETE", `/v1/apps/${opts.id}/domains/${domain.id}`);
143
+ if (opts.json) printJson(result);
144
+ else console.log(`Detached ${opts.hostname}`);
145
+ } catch (error) {
146
+ handleError(error, { json: opts.json });
147
+ }
148
+ });
@@ -13,6 +13,7 @@ import os from "os";
13
13
  import fetch from "node-fetch";
14
14
  import { spawnSync } from "child_process";
15
15
  import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
16
+ import { domainsCommand } from "./host-domains";
16
17
 
17
18
  type App = { id: string; name: string; url: string; createdAt?: string; updatedAt?: string };
18
19
  type AppList = { apps: App[]; count: number };
@@ -591,6 +592,8 @@ function writeUplinkIgnore(dir: string, entries: string[]): void {
591
592
 
592
593
  export const hostCommand = new Command("host").description("Host persistent web services (Dockerfile required)");
593
594
 
595
+ hostCommand.addCommand(domainsCommand);
596
+
594
597
  async function resolveSqliteConfig(
595
598
  analysis: AnalysisResult,
596
599
  opts: { yes: boolean }