uplink-cli 0.1.37 → 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 (56) hide show
  1. package/AGENTS.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +45 -44
  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 +106 -32
  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 +122 -0
  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/analyze.ts +27 -43
  48. package/cli/src/utils/framework-output.ts +171 -0
  49. package/cli/src/utils/launchDomainking.ts +64 -0
  50. package/docs/AGENTS.md +113 -146
  51. package/docs/MENU_STRUCTURE.md +56 -288
  52. package/docs/README.md +6 -6
  53. package/package.json +18 -35
  54. package/scripts/tunnel/client-improved.js +127 -38
  55. package/scripts/tunnel/client.js +118 -0
  56. package/assets/cli-screenshot.png +0 -0
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { apiRequest } from "../http";
3
3
  import { handleError, printJson } from "../utils/machine";
4
4
  import { analyzeProject, AnalysisResult, buildRequirements } from "../utils/analyze";
5
+ import { getFrameworkOutputCheck } from "../utils/framework-output";
5
6
  import { generateDockerfile, generateHostConfig } from "../templates";
6
7
  import { createHash } from "crypto";
7
8
  import { createReadStream, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
@@ -12,6 +13,7 @@ import os from "os";
12
13
  import fetch from "node-fetch";
13
14
  import { spawnSync } from "child_process";
14
15
  import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
16
+ import { domainsCommand } from "./host-domains";
15
17
 
16
18
  type App = { id: string; name: string; url: string; createdAt?: string; updatedAt?: string };
17
19
  type AppList = { apps: App[]; count: number };
@@ -590,6 +592,8 @@ function writeUplinkIgnore(dir: string, entries: string[]): void {
590
592
 
591
593
  export const hostCommand = new Command("host").description("Host persistent web services (Dockerfile required)");
592
594
 
595
+ hostCommand.addCommand(domainsCommand);
596
+
593
597
  async function resolveSqliteConfig(
594
598
  analysis: AnalysisResult,
595
599
  opts: { yes: boolean }
@@ -651,13 +655,15 @@ function findNextConfigPath(dir: string): string | null {
651
655
  return null;
652
656
  }
653
657
 
654
- function hasStandaloneOutputConfig(content: string): boolean {
655
- return /output\s*:\s*["']standalone["']/.test(content);
656
- }
657
-
658
658
  function applyStandaloneOutputConfig(configPath: string): boolean {
659
659
  const content = readFileSync(configPath, "utf8");
660
- if (hasStandaloneOutputConfig(content)) return false;
660
+ const outputRegex = /output\s*:\s*["'](?:standalone|export)["']\s*,?/g;
661
+ if (outputRegex.test(content)) {
662
+ const updated = content.replace(outputRegex, 'output: "standalone",');
663
+ if (updated === content) return false;
664
+ writeFileSync(configPath, updated, "utf8");
665
+ return true;
666
+ }
661
667
  const nextConfigAssign = /(const\s+nextConfig[^=]*=\s*\{)/;
662
668
  const moduleExportsAssign = /(module\.exports\s*=\s*\{)/;
663
669
  const exportDefaultAssign = /(export\s+default\s*\{)/;
@@ -672,6 +678,26 @@ function applyStandaloneOutputConfig(configPath: string): boolean {
672
678
  return true;
673
679
  }
674
680
 
681
+ function updateNextOutputMode(
682
+ analysis: AnalysisResult,
683
+ mode: "standalone" | "export" | "unknown",
684
+ configPath?: string
685
+ ): void {
686
+ if (analysis.framework?.name !== "nextjs") return;
687
+ analysis.frameworkOutput = {
688
+ framework: "nextjs",
689
+ mode,
690
+ distDir: analysis.frameworkOutput?.distDir,
691
+ configPath: configPath || analysis.frameworkOutput?.configPath,
692
+ };
693
+ }
694
+
695
+ function formatFrameworkOutputGuidance(analysis: AnalysisResult): string[] {
696
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
697
+ if (!outputCheck) return [];
698
+ return outputCheck.guidance;
699
+ }
700
+
675
701
  function dockerfileHasPrismaGenerate(dockerfilePath: string): boolean {
676
702
  if (!existsSync(dockerfilePath)) return false;
677
703
  const content = readFileSync(dockerfilePath, "utf8");
@@ -907,22 +933,23 @@ function buildPreflightChecklist(
907
933
  }
908
934
 
909
935
  if (analysis.framework?.name === "nextjs") {
910
- const configPath = findNextConfigPath(dir);
911
- const hasStandalone = configPath ? hasStandaloneOutputConfig(readFileSync(configPath, "utf8")) : false;
936
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
912
937
  const dockerfilePath = join(dir, "Dockerfile");
913
- if (analysis.dockerfile.exists && dockerfileExpectsStandalone(dockerfilePath) && !hasStandalone) {
938
+ const expectsStandalone = analysis.dockerfile.exists && dockerfileExpectsStandalone(dockerfilePath);
939
+ if (outputCheck?.mode === "export") {
914
940
  items.push({
915
- level: "required",
916
- title: "Next.js output is missing `standalone`",
917
- detail: "Dockerfile expects .next/standalone.",
918
- action: "Add `output: \"standalone\"` to your Next.js config.",
941
+ level: expectsStandalone ? "required" : "recommended",
942
+ title: "Next.js output is set to export",
943
+ detail: "Static export does not produce .next/standalone.",
944
+ action: outputCheck.guidance.join(" "),
919
945
  });
920
- } else if (!analysis.dockerfile.exists && configPath && !hasStandalone) {
946
+ } else if (outputCheck?.mode === "unknown") {
947
+ const level = expectsStandalone ? "required" : "recommended";
921
948
  items.push({
922
- level: "recommended",
923
- title: "Next.js output is not set to `standalone`",
949
+ level,
950
+ title: "Next.js output mode not detected",
924
951
  detail: "Standalone builds reduce image size and simplify runtime.",
925
- action: "Consider adding `output: \"standalone\"`.",
952
+ action: outputCheck.guidance.join(" "),
926
953
  });
927
954
  }
928
955
  }
@@ -1031,6 +1058,13 @@ hostCommand
1031
1058
  } else {
1032
1059
  console.log("Framework: (not detected)");
1033
1060
  }
1061
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1062
+ if (outputCheck) {
1063
+ console.log(`Output mode: ${outputCheck.mode}`);
1064
+ if (analysis.frameworkOutput?.distDir) {
1065
+ console.log(`Output dir: ${analysis.frameworkOutput.distDir}`);
1066
+ }
1067
+ }
1034
1068
 
1035
1069
  // Package manager
1036
1070
  if (analysis.packageManager) {
@@ -1190,6 +1224,13 @@ hostCommand
1190
1224
  } else {
1191
1225
  console.log(" Framework: (not detected)");
1192
1226
  }
1227
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1228
+ if (outputCheck) {
1229
+ console.log(` Output mode: ${outputCheck.mode}`);
1230
+ if (analysis.frameworkOutput?.distDir) {
1231
+ console.log(` Output dir: ${analysis.frameworkOutput.distDir}`);
1232
+ }
1233
+ }
1193
1234
  if (analysis.packageManager) {
1194
1235
  console.log(` Package manager: ${analysis.packageManager}`);
1195
1236
  }
@@ -1237,8 +1278,8 @@ hostCommand
1237
1278
  if (interactive && analysis.framework?.name === "nextjs") {
1238
1279
  const configPath = findNextConfigPath(dir);
1239
1280
  if (configPath) {
1240
- const content = readFileSync(configPath, "utf8");
1241
- if (!hasStandaloneOutputConfig(content)) {
1281
+ const outputMode = analysis.frameworkOutput?.mode || "unknown";
1282
+ if (outputMode !== "standalone") {
1242
1283
  const answer = (await promptLine(
1243
1284
  `\nAdd output: "standalone" to ${basename(configPath)}? (Y/n): `
1244
1285
  ))
@@ -1246,7 +1287,10 @@ hostCommand
1246
1287
  .toLowerCase();
1247
1288
  if (answer === "" || answer === "y" || answer === "yes") {
1248
1289
  const updated = applyStandaloneOutputConfig(configPath);
1249
- if (updated) console.log(" Updated Next.js config for standalone output");
1290
+ if (updated) {
1291
+ updateNextOutputMode(analysis, "standalone", configPath);
1292
+ console.log(" Updated Next.js config for standalone output");
1293
+ }
1250
1294
  }
1251
1295
  }
1252
1296
  }
@@ -1293,6 +1337,16 @@ hostCommand
1293
1337
 
1294
1338
  // Generate Dockerfile if needed
1295
1339
  if (needsDockerfile) {
1340
+ const outputGuidance = formatFrameworkOutputGuidance(analysis);
1341
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1342
+ if (analysis.framework?.name === "nextjs" && outputCheck && outputCheck.mode !== "standalone") {
1343
+ console.log(`\n${outputCheck.summary}`);
1344
+ for (const line of outputGuidance) {
1345
+ console.log(` ${line}`);
1346
+ }
1347
+ console.log(" Resolve this or provide a Dockerfile manually.");
1348
+ return;
1349
+ }
1296
1350
  if (analysis.nativeNodeDeps.length > 0) {
1297
1351
  const choice = opts.yes
1298
1352
  ? "y"
@@ -1337,11 +1391,11 @@ hostCommand
1337
1391
 
1338
1392
  // Next.js specific: check for standalone output
1339
1393
  if (analysis.framework?.name === "nextjs" && needsDockerfile) {
1340
- const configPath = findNextConfigPath(dir);
1341
- if (configPath) {
1342
- const content = readFileSync(configPath, "utf8");
1343
- if (!hasStandaloneOutputConfig(content)) {
1344
- console.log("\n Note: Add `output: \"standalone\"` to your next.config for Docker builds");
1394
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1395
+ if (outputCheck && outputCheck.mode !== "standalone") {
1396
+ console.log(`\n Note: ${outputCheck.summary}`);
1397
+ for (const line of outputCheck.guidance) {
1398
+ console.log(` ${line}`);
1345
1399
  }
1346
1400
  }
1347
1401
  }
@@ -1588,6 +1642,13 @@ hostCommand
1588
1642
  const preflight = buildPreflightChecklist(dir, analysis, extraEnv);
1589
1643
  if (!opts.json) {
1590
1644
  console.log(` Framework: ${analysis.framework?.name || "(not detected)"}`);
1645
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1646
+ if (outputCheck) {
1647
+ console.log(` Output mode: ${outputCheck.mode}`);
1648
+ if (analysis.frameworkOutput?.distDir) {
1649
+ console.log(` Output dir: ${analysis.frameworkOutput.distDir}`);
1650
+ }
1651
+ }
1591
1652
  const sqliteMeta = getSqliteMeta(analysis.database);
1592
1653
  const dbPath = sqliteMeta.path ? ` path=${sqliteMeta.path}` : "";
1593
1654
  const dbEnv = sqliteMeta.envVar ? ` env=${sqliteMeta.envVar}` : "";
@@ -1627,8 +1688,8 @@ hostCommand
1627
1688
  if (interactive && analysis.framework?.name === "nextjs") {
1628
1689
  const configPath = findNextConfigPath(dir);
1629
1690
  if (configPath) {
1630
- const content = readFileSync(configPath, "utf8");
1631
- if (!hasStandaloneOutputConfig(content)) {
1691
+ const outputMode = analysis.frameworkOutput?.mode || "unknown";
1692
+ if (outputMode !== "standalone") {
1632
1693
  const answer = (await promptLine(
1633
1694
  ` Add output: "standalone" to ${basename(configPath)}? (Y/n): `
1634
1695
  ))
@@ -1637,6 +1698,7 @@ hostCommand
1637
1698
  if (answer === "" || answer === "y" || answer === "yes") {
1638
1699
  const updated = applyStandaloneOutputConfig(configPath);
1639
1700
  if (updated && !opts.json) {
1701
+ updateNextOutputMode(analysis, "standalone", configPath);
1640
1702
  console.log(" Updated Next.js config for standalone output");
1641
1703
  }
1642
1704
  }
@@ -1710,6 +1772,18 @@ hostCommand
1710
1772
 
1711
1773
  // Step 2: Generate Dockerfile
1712
1774
  if (!analysis.dockerfile.exists) {
1775
+ const outputGuidance = formatFrameworkOutputGuidance(analysis);
1776
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1777
+ if (analysis.framework?.name === "nextjs" && outputCheck && outputCheck.mode !== "standalone") {
1778
+ if (!opts.json) {
1779
+ console.log(`\n${outputCheck.summary}`);
1780
+ for (const line of outputGuidance) {
1781
+ console.log(` ${line}`);
1782
+ }
1783
+ console.log(" Resolve this or provide a Dockerfile manually.");
1784
+ }
1785
+ return;
1786
+ }
1713
1787
  if (analysis.nativeNodeDeps.length > 0) {
1714
1788
  const choice = useDefaults
1715
1789
  ? "y"
@@ -1756,12 +1830,12 @@ hostCommand
1756
1830
 
1757
1831
  // Next.js: check for standalone output
1758
1832
  if (analysis.framework?.name === "nextjs") {
1759
- const configPath = findNextConfigPath(dir);
1760
- if (configPath) {
1761
- const content = readFileSync(configPath, "utf8");
1762
- if (!hasStandaloneOutputConfig(content)) {
1763
- if (!opts.json) {
1764
- console.log("\n Note: Your next.config may need `output: \"standalone\"` for Docker builds");
1833
+ const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
1834
+ if (outputCheck && outputCheck.mode !== "standalone") {
1835
+ if (!opts.json) {
1836
+ console.log(`\n Note: ${outputCheck.summary}`);
1837
+ for (const line of outputCheck.guidance) {
1838
+ console.log(` ${line}`);
1765
1839
  }
1766
1840
  }
1767
1841
  }
@@ -67,5 +67,5 @@ export function colorSoftGray(text: string) {
67
67
  }
68
68
 
69
69
  export function colorAccent(text: string) {
70
- return `${c.brightBlue}${text}${c.reset}`;
70
+ return `${c.bold}${c.brightWhite}${text}${c.reset}`;
71
71
  }
@@ -1,8 +1,44 @@
1
1
  import { execSync, spawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import path from "path";
2
4
  import { resolveProjectRoot } from "../../../utils/project-root";
3
5
 
4
6
  export type TunnelClient = { pid: number; port: number; token: string };
5
7
 
8
+ export function resolveTunnelClientPath(): string {
9
+ const projectRoot = resolveProjectRoot(__dirname);
10
+ const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
11
+ if (!existsSync(clientPath)) {
12
+ throw new Error(`Tunnel client not found at ${clientPath}`);
13
+ }
14
+ return clientPath;
15
+ }
16
+
17
+ /** Start the local tunnel client in the background (detached). */
18
+ export function startTunnelClient(opts: {
19
+ token: string;
20
+ port: number;
21
+ ctrl?: string;
22
+ }): { pid: number; clientPath: string } {
23
+ const projectRoot = resolveProjectRoot(__dirname);
24
+ const clientPath = resolveTunnelClientPath();
25
+ const ctrl = opts.ctrl || process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
26
+ const clientProcess = spawn(
27
+ "node",
28
+ [clientPath, "--token", opts.token, "--port", String(opts.port), "--ctrl", ctrl],
29
+ {
30
+ stdio: "ignore",
31
+ detached: true,
32
+ cwd: projectRoot,
33
+ }
34
+ );
35
+ clientProcess.unref();
36
+ if (!clientProcess.pid) {
37
+ throw new Error("Failed to start tunnel client process");
38
+ }
39
+ return { pid: clientProcess.pid, clientPath };
40
+ }
41
+
6
42
  export function findTunnelClients(): TunnelClient[] {
7
43
  try {
8
44
  // Find processes running client-improved.js (current user, match script path to avoid false positives)
@@ -39,11 +75,23 @@ export function findTunnelClients(): TunnelClient[] {
39
75
 
40
76
  export function killTunnelClient(pid: number): boolean {
41
77
  try {
42
- execSync(`kill -TERM ${pid}`, { stdio: "ignore" });
43
- return true;
78
+ process.kill(pid, "SIGTERM");
44
79
  } catch {
45
80
  return false;
46
81
  }
82
+ try {
83
+ execSync(`kill -0 ${pid} && sleep 0.4 && kill -KILL ${pid} || true`, {
84
+ stdio: "ignore",
85
+ });
86
+ } catch {
87
+ /* process already gone */
88
+ }
89
+ try {
90
+ process.kill(pid, 0);
91
+ return false;
92
+ } catch {
93
+ return true;
94
+ }
47
95
  }
48
96
 
49
97
  export function killAllTunnelClients(clients: TunnelClient[]): number {
@@ -54,8 +102,44 @@ export function killAllTunnelClients(clients: TunnelClient[]): number {
54
102
  return killed;
55
103
  }
56
104
 
105
+ type ApiTunnel = { id?: string; token?: string; connected?: boolean };
106
+
57
107
  type ApiRequest = (method: string, path: string, body?: unknown) => Promise<any>;
58
108
 
109
+ export async function stopTunnelClients(
110
+ apiRequest: ApiRequest,
111
+ clients: TunnelClient[],
112
+ opts: { connectedGhosts?: boolean } = {}
113
+ ): Promise<{ killed: number; deleted: number }> {
114
+ const tokens = new Set(clients.map((c) => c.token));
115
+ let deleted = 0;
116
+
117
+ try {
118
+ const result = await apiRequest("GET", "/v1/tunnels");
119
+ const tunnels = (result.tunnels || []) as ApiTunnel[];
120
+ for (const tunnel of tunnels) {
121
+ if (!tunnel.id) continue;
122
+ const matched = Boolean(tunnel.token && tokens.has(tunnel.token));
123
+ const ghost = Boolean(opts.connectedGhosts && tunnel.connected);
124
+ if (!matched && !ghost) continue;
125
+ try {
126
+ await apiRequest("DELETE", `/v1/tunnels/${tunnel.id}`);
127
+ deleted++;
128
+ } catch {
129
+ /* keep stopping the rest */
130
+ }
131
+ }
132
+ } catch {
133
+ /* still kill local processes */
134
+ }
135
+
136
+ let killed = 0;
137
+ for (const c of clients) {
138
+ if (killTunnelClient(c.pid)) killed++;
139
+ }
140
+ return { killed, deleted };
141
+ }
142
+
59
143
  /**
60
144
  * Create a tunnel via API and start the local client in background.
61
145
  * NOTE: Maintains existing behavior including the brief post-spawn delay.
@@ -79,19 +163,8 @@ export async function createAndStartTunnel(apiRequest: ApiRequest, port: number)
79
163
  const url = result.url || "(no url)";
80
164
  const token = result.token || "(no token)";
81
165
  const alias = result.alias || null;
82
- const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
83
166
 
84
- // Start tunnel client in background
85
- // (CommonJS build: __dirname available)
86
- const path = require("path");
87
- const projectRoot = resolveProjectRoot(__dirname);
88
- const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
89
- const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
90
- stdio: "ignore",
91
- detached: true,
92
- cwd: projectRoot,
93
- });
94
- clientProcess.unref();
167
+ startTunnelClient({ token, port });
95
168
 
96
169
  // Wait a moment for client to connect
97
170
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -1,4 +1,4 @@
1
- import { colorCyan, colorDim } from "./colors";
1
+ import { colorBold, colorDim } from "./colors";
2
2
 
3
3
  // Inline arrow-key selector (returns selected option, or null for "Back")
4
4
  export type SelectOption = { label: string; value: string | number | null };
@@ -32,11 +32,11 @@ export async function inlineSelect(
32
32
  let branchColor: string;
33
33
 
34
34
  if (isSelected) {
35
- branchColor = colorCyan(branch);
35
+ branchColor = colorBold(branch);
36
36
  if (opt.label === "Back") {
37
37
  label = colorDim(opt.label);
38
38
  } else {
39
- label = colorCyan(opt.label);
39
+ label = colorBold(opt.label);
40
40
  }
41
41
  } else {
42
42
  branchColor = colorDim(branch);
@@ -58,13 +58,14 @@ export async function inlineSelect(
58
58
  allOptions.forEach((opt, idx) => {
59
59
  const isLast = idx === allOptions.length - 1;
60
60
  const branch = isLast ? "└─" : "├─";
61
- const branchColor = idx === 0 ? colorCyan(branch) : colorDim(branch);
62
- const label = idx === 0 ? colorCyan(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
61
+ const branchColor = idx === 0 ? colorBold(branch) : colorDim(branch);
62
+ const label = idx === 0 ? colorBold(opt.label) : opt.label === "Back" ? colorDim(opt.label) : opt.label;
63
63
  console.log(`${branchColor} ${label}`);
64
64
  });
65
65
 
66
66
  // Set up key handler
67
67
  try {
68
+ process.stdin.ref();
68
69
  process.stdin.setRawMode(true);
69
70
  process.stdin.resume();
70
71
  } catch {
@@ -10,13 +10,35 @@ function stylePrompt(question: string): string {
10
10
  .replace(backTokenRegex, (match) => colorBold(match));
11
11
  }
12
12
 
13
+ export function prepareStdinForPrompt(): void {
14
+ try {
15
+ process.stdin.setRawMode(false);
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ process.stdin.ref();
20
+ process.stdin.resume();
21
+ process.stdin.setEncoding("utf8");
22
+ drainStdin();
23
+ }
24
+
25
+ /** Drop a leftover Enter from Ink so readline does not auto-answer the next prompt. */
26
+ function drainStdin(): void {
27
+ const stdin = process.stdin as NodeJS.ReadStream & { read?: () => unknown };
28
+ if (typeof stdin.read !== "function") return;
29
+ try {
30
+ let chunk: unknown;
31
+ while ((chunk = stdin.read()) !== null) {
32
+ void chunk;
33
+ }
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
13
39
  export function promptLine(question: string): Promise<string> {
14
40
  return new Promise((resolve) => {
15
- try {
16
- process.stdin.setRawMode(false);
17
- } catch {
18
- /* ignore */
19
- }
41
+ prepareStdinForPrompt();
20
42
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21
43
  rl.question(stylePrompt(question), (answer) => {
22
44
  rl.close();
@@ -0,0 +1,199 @@
1
+ import type { SelectOption } from "../inline-tree-select";
2
+ import type { MenuChoice } from "../types";
3
+ import { launchDomainking } from "../../../utils/launchDomainking";
4
+ import { parseHostedApps, runCli, runCliCapture } from "./hosting";
5
+
6
+ type Deps = {
7
+ promptLine: (question: string) => Promise<string>;
8
+ restoreRawMode: () => void;
9
+ inlineSelect: (
10
+ title: string,
11
+ options: SelectOption[],
12
+ includeBack?: boolean
13
+ ) => Promise<{ index: number; value: string | number | null } | null>;
14
+ };
15
+
16
+ const PROVIDER_OPTIONS: SelectOption[] = [
17
+ { label: "GoDaddy", value: "godaddy" },
18
+ { label: "Cloudflare", value: "cloudflare" },
19
+ { label: "Hostinger", value: "hostinger" },
20
+ { label: "Namecheap", value: "namecheap" },
21
+ ];
22
+
23
+ async function pickHostedApp(
24
+ deps: Deps,
25
+ title: string
26
+ ): Promise<{ name: string; id: string; url?: string } | null> {
27
+ const output = runCliCapture(["host", "list"]);
28
+ if (!output || output.includes("No apps found")) {
29
+ deps.restoreRawMode();
30
+ return null;
31
+ }
32
+ const apps = parseHostedApps(output);
33
+ if (apps.length === 0) {
34
+ deps.restoreRawMode();
35
+ return null;
36
+ }
37
+ const options: SelectOption[] = apps.map((app) => ({
38
+ label: `${app.name}${app.url ? ` ${app.url}` : ""}`,
39
+ value: app.id,
40
+ }));
41
+ const choice = await deps.inlineSelect(title, options, true);
42
+ if (choice === null) {
43
+ deps.restoreRawMode();
44
+ return null;
45
+ }
46
+ return apps.find((app) => app.id === choice.value) ?? null;
47
+ }
48
+
49
+ export function buildDomainsMenu(deps: Deps): MenuChoice {
50
+ const { restoreRawMode, promptLine } = deps;
51
+
52
+ return {
53
+ label: "Domains",
54
+ subMenu: [
55
+ {
56
+ label: "My domains",
57
+ action: async () => {
58
+ try {
59
+ const output = runCliCapture(["domains", "list"]);
60
+ restoreRawMode();
61
+ return output || "No domains. Connect a registrar first.";
62
+ } catch (error) {
63
+ restoreRawMode();
64
+ return error instanceof Error ? error.message : String(error);
65
+ }
66
+ },
67
+ },
68
+ {
69
+ label: "Connect registrar",
70
+ action: async () => {
71
+ const choice = await deps.inlineSelect("Which registrar?", PROVIDER_OPTIONS, true);
72
+ if (choice === null || typeof choice.value !== "string") {
73
+ restoreRawMode();
74
+ return "";
75
+ }
76
+ const provider = choice.value;
77
+ const extraEnv: Record<string, string> = {};
78
+ const args = ["domains", "providers", "connect", provider, "--token-env", "UPLINK_CONNECT_TOKEN"];
79
+ if (provider === "namecheap") {
80
+ const user = (await promptLine("Namecheap API user (or back): ")).trim();
81
+ if (!user || user === "back") {
82
+ restoreRawMode();
83
+ return "";
84
+ }
85
+ const key = (await promptLine("Namecheap API key (or back): ")).trim();
86
+ if (!key || key === "back") {
87
+ restoreRawMode();
88
+ return "";
89
+ }
90
+ extraEnv.UPLINK_CONNECT_TOKEN = key;
91
+ extraEnv.UPLINK_CONNECT_USER = user;
92
+ args.push("--user-env", "UPLINK_CONNECT_USER");
93
+ } else {
94
+ const token = (
95
+ await promptLine(
96
+ `${PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider} API token (or back): `
97
+ )
98
+ ).trim();
99
+ if (!token || token === "back") {
100
+ restoreRawMode();
101
+ return "";
102
+ }
103
+ extraEnv.UPLINK_CONNECT_TOKEN = token;
104
+ }
105
+ try {
106
+ runCli(args, extraEnv);
107
+ restoreRawMode();
108
+ return `Connected ${provider}.`;
109
+ } catch (error) {
110
+ restoreRawMode();
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+ },
114
+ },
115
+ {
116
+ label: "Find a domain",
117
+ action: async () => {
118
+ restoreRawMode();
119
+ return launchDomainking();
120
+ },
121
+ },
122
+ {
123
+ label: "Attach to app",
124
+ action: async () => {
125
+ const app = await pickHostedApp(deps, "Attach domain to which app?");
126
+ if (!app) return "No hosted apps. Deploy one under Host first.";
127
+ const hostname = (await promptLine("Hostname (e.g. example.com, or back): ")).trim().toLowerCase();
128
+ if (!hostname || hostname === "back") {
129
+ restoreRawMode();
130
+ return "";
131
+ }
132
+ runCli(["host", "domains", "add", "--id", app.id, "--hostname", hostname]);
133
+ restoreRawMode();
134
+ return `Attached ${hostname} to ${app.name}. Point DNS, then Verify.`;
135
+ },
136
+ },
137
+ {
138
+ label: "Verify DNS",
139
+ action: async () => {
140
+ const app = await pickHostedApp(deps, "Verify a domain on which app?");
141
+ if (!app) return "No hosted apps.";
142
+ const hostname = (await promptLine("Hostname to verify (or back): ")).trim().toLowerCase();
143
+ if (!hostname || hostname === "back") {
144
+ restoreRawMode();
145
+ return "";
146
+ }
147
+ runCli(["host", "domains", "verify", "--id", app.id, "--hostname", hostname]);
148
+ restoreRawMode();
149
+ return `Checked ${hostname} on ${app.name}.`;
150
+ },
151
+ },
152
+ {
153
+ label: "List on app",
154
+ action: async () => {
155
+ const app = await pickHostedApp(deps, "List domains for which app?");
156
+ if (!app) return "No hosted apps.";
157
+ const output = runCliCapture(["host", "domains", "list", "--id", app.id]);
158
+ restoreRawMode();
159
+ return `${app.name}\n${output || "No custom domains attached."}`;
160
+ },
161
+ },
162
+ {
163
+ label: "Detach from app",
164
+ action: async () => {
165
+ const app = await pickHostedApp(deps, "Detach a domain from which app?");
166
+ if (!app) return "No hosted apps.";
167
+ const hostname = (await promptLine("Hostname to detach (or back): ")).trim().toLowerCase();
168
+ if (!hostname || hostname === "back") {
169
+ restoreRawMode();
170
+ return "";
171
+ }
172
+ runCli(["host", "domains", "remove", "--id", app.id, "--hostname", hostname]);
173
+ restoreRawMode();
174
+ return `Detached ${hostname} from ${app.name}.`;
175
+ },
176
+ },
177
+ {
178
+ label: "Help",
179
+ action: async () => {
180
+ return [
181
+ "Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
182
+ "",
183
+ " My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap",
184
+ " Connect — save a registrar token (same as the CLI)",
185
+ " Find — search names that are not yours yet",
186
+ " Attach — bind a hostname to a hosted app",
187
+ " Verify — check DNS points at the hosting edge, then TLS",
188
+ "",
189
+ "CLI (agents):",
190
+ " uplink domains providers connect godaddy --token-env GODADDY_PAT --json",
191
+ " uplink domains list --json",
192
+ " uplink domains check example.com --json",
193
+ " uplink host domains add --id <app> --hostname example.com --json",
194
+ ].join("\n");
195
+ },
196
+ },
197
+ ],
198
+ };
199
+ }