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.
- package/AGENTS.md +161 -0
- package/LICENSE +21 -0
- package/README.md +45 -44
- 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 +106 -32
- 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 +122 -0
- 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/analyze.ts +27 -43
- package/cli/src/utils/framework-output.ts +171 -0
- package/cli/src/utils/launchDomainking.ts +64 -0
- package/docs/AGENTS.md +113 -146
- 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
|
@@ -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
|
-
|
|
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
|
|
911
|
-
const hasStandalone = configPath ? hasStandaloneOutputConfig(readFileSync(configPath, "utf8")) : false;
|
|
936
|
+
const outputCheck = getFrameworkOutputCheck(analysis.frameworkOutput);
|
|
912
937
|
const dockerfilePath = join(dir, "Dockerfile");
|
|
913
|
-
|
|
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
|
|
917
|
-
detail: "
|
|
918
|
-
action: "
|
|
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 (
|
|
946
|
+
} else if (outputCheck?.mode === "unknown") {
|
|
947
|
+
const level = expectsStandalone ? "required" : "recommended";
|
|
921
948
|
items.push({
|
|
922
|
-
level
|
|
923
|
-
title: "Next.js output
|
|
949
|
+
level,
|
|
950
|
+
title: "Next.js output mode not detected",
|
|
924
951
|
detail: "Standalone builds reduce image size and simplify runtime.",
|
|
925
|
-
action: "
|
|
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
|
|
1241
|
-
if (
|
|
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)
|
|
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
|
|
1341
|
-
if (
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
console.log(
|
|
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
|
|
1631
|
-
if (
|
|
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
|
|
1760
|
-
if (
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
console.log(
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
35
|
+
branchColor = colorBold(branch);
|
|
36
36
|
if (opt.label === "Back") {
|
|
37
37
|
label = colorDim(opt.label);
|
|
38
38
|
} else {
|
|
39
|
-
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 ?
|
|
62
|
-
const label = idx === 0 ?
|
|
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
|
-
|
|
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
|
+
}
|