uplink-cli 0.1.38 → 0.2.0

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 (67) hide show
  1. package/AGENTS.md +177 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +72 -52
  5. package/cli/src/index.ts +16 -3
  6. package/cli/src/registrars/cloudflare.ts +148 -0
  7. package/cli/src/registrars/dreamhost.ts +129 -0
  8. package/cli/src/registrars/godaddy.ts +105 -0
  9. package/cli/src/registrars/hostinger.ts +106 -0
  10. package/cli/src/registrars/http.ts +18 -0
  11. package/cli/src/registrars/index.ts +32 -0
  12. package/cli/src/registrars/namecheap.ts +163 -0
  13. package/cli/src/registrars/secret.ts +66 -0
  14. package/cli/src/registrars/store.ts +55 -0
  15. package/cli/src/registrars/types.ts +42 -0
  16. package/cli/src/subcommands/admin.ts +17 -30
  17. package/cli/src/subcommands/db.ts +63 -57
  18. package/cli/src/subcommands/dev.ts +23 -25
  19. package/cli/src/subcommands/domains.ts +295 -0
  20. package/cli/src/subcommands/host-domains.ts +148 -0
  21. package/cli/src/subcommands/host.ts +3 -0
  22. package/cli/src/subcommands/login.ts +85 -0
  23. package/cli/src/subcommands/menu/colors.ts +1 -1
  24. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  25. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  26. package/cli/src/subcommands/menu/io.ts +27 -5
  27. package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
  28. package/cli/src/subcommands/menu/menus/domains.ts +197 -0
  29. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  30. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  31. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  32. package/cli/src/subcommands/menu/render.ts +2 -2
  33. package/cli/src/subcommands/menu/requests.ts +9 -2
  34. package/cli/src/subcommands/menu/tests.ts +1 -1
  35. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  36. package/cli/src/subcommands/menu/types.ts +8 -0
  37. package/cli/src/subcommands/menu.ts +32 -524
  38. package/cli/src/subcommands/signup.ts +2 -2
  39. package/cli/src/subcommands/system.ts +58 -36
  40. package/cli/src/subcommands/tunnel.ts +126 -33
  41. package/cli/src/templates/index.ts +3 -3
  42. package/cli/src/tui/App.tsx +202 -0
  43. package/cli/src/tui/AppInspector.tsx +114 -0
  44. package/cli/src/tui/HomeStatus.tsx +92 -0
  45. package/cli/src/tui/brand.tsx +20 -0
  46. package/cli/src/tui/format.ts +22 -0
  47. package/cli/src/tui/index.mts +6 -0
  48. package/cli/src/tui/liveTree.ts +40 -0
  49. package/cli/src/tui/package.json +3 -0
  50. package/cli/src/tui/runMenu.tsx +57 -0
  51. package/cli/src/tui/session.mts +267 -0
  52. package/cli/src/tui/snapshot.ts +175 -0
  53. package/cli/src/utils/api-base.ts +11 -0
  54. package/cli/src/utils/credentials.ts +58 -0
  55. package/cli/src/utils/domain-availability.ts +56 -0
  56. package/cli/src/utils/guest-access.ts +38 -0
  57. package/cli/src/utils/launchDomainking.ts +64 -0
  58. package/cli/src/utils/login-flow.ts +57 -0
  59. package/docs/AGENTS.md +130 -148
  60. package/docs/HOSTING.md +55 -0
  61. package/docs/MENU_STRUCTURE.md +60 -288
  62. package/docs/PRODUCT.md +64 -0
  63. package/docs/README.md +11 -7
  64. package/package.json +22 -36
  65. package/scripts/tunnel/client-improved.js +127 -38
  66. package/scripts/tunnel/client.js +118 -0
  67. package/assets/cli-screenshot.png +0 -0
@@ -0,0 +1,175 @@
1
+ import fetch from "node-fetch";
2
+ import { connectedProviders } from "../registrars";
3
+ import { health, tunnelClients } from "../subcommands/menu/effects";
4
+ import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
5
+ import type { MenuStatus } from "./App";
6
+
7
+ const SNAPSHOT_TIMEOUT_MS = 2000;
8
+ /** Hard max for a single upload (paid). Free accounts are tighter via /v1/me. */
9
+ const ARTIFACT_CAP_BYTES = 500_000_000;
10
+
11
+ export { ARTIFACT_CAP_BYTES };
12
+
13
+ const FREE_STORAGE = 100_000_000;
14
+ const FREE_APP_LIMIT = 1;
15
+
16
+ type JsonObject = Record<string, unknown>;
17
+
18
+ function localTunnels(): MenuStatus["tunnels"] {
19
+ const domain = process.env.TUNNEL_DOMAIN || "x.uplink.spot";
20
+ const scheme = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
21
+ return tunnelClients.findTunnelClients().map((client) => ({
22
+ url: `${scheme}://${client.token}.${domain}`,
23
+ port: client.port,
24
+ }));
25
+ }
26
+
27
+ async function apiGet(path: string, timeoutMs = SNAPSHOT_TIMEOUT_MS): Promise<unknown | null> {
28
+ const apiBase = getResolvedApiBase();
29
+ const token = getResolvedApiToken(apiBase);
30
+ if (!token) return null;
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const res = await fetch(`${apiBase}${path}`, {
35
+ signal: controller.signal,
36
+ headers: { Authorization: `Bearer ${token}` },
37
+ });
38
+ if (!res.ok) return null;
39
+ return await res.json();
40
+ } catch {
41
+ return null;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ function asObject(value: unknown): JsonObject | null {
48
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null;
49
+ }
50
+
51
+ function asString(value: unknown): string | undefined {
52
+ return typeof value === "string" && value.length > 0 ? value : undefined;
53
+ }
54
+
55
+ async function fetchApps(): Promise<MenuStatus["apps"]> {
56
+ const body = asObject(await apiGet("/v1/apps"));
57
+ const apps = body?.apps;
58
+ if (!Array.isArray(apps)) return [];
59
+ const parsed: MenuStatus["apps"] = [];
60
+ for (const item of apps) {
61
+ const rec = asObject(item);
62
+ if (!rec) continue;
63
+ const name = asString(rec.name);
64
+ const id = asString(rec.id);
65
+ if (!name || !id) continue;
66
+ parsed.push({
67
+ name,
68
+ id,
69
+ url: asString(rec.url),
70
+ createdAt: asString(rec.createdAt),
71
+ });
72
+ }
73
+ return parsed;
74
+ }
75
+
76
+ async function fetchHealth(): Promise<{ connected: boolean; latencyMs: number | null }> {
77
+ const started = Date.now();
78
+ const healthRes = await health.checkApiHealth({});
79
+ if (!healthRes.ok) return { connected: false, latencyMs: null };
80
+ return { connected: true, latencyMs: Date.now() - started };
81
+ }
82
+
83
+ function parseHosting(me: JsonObject | null): {
84
+ storageUsedBytes: number;
85
+ storageLimitBytes: number;
86
+ appLimit: number;
87
+ alwaysOn: boolean;
88
+ idleMinutes: number | null;
89
+ } {
90
+ const hosting = asObject(me?.hosting);
91
+ const storage = asObject(hosting?.storageBytes);
92
+ const apps = asObject(hosting?.apps);
93
+ const used = Number(storage?.used);
94
+ const limit = Number(storage?.limit);
95
+ const appLimit = Number(apps?.limit);
96
+ return {
97
+ storageUsedBytes: Number.isFinite(used) ? used : 0,
98
+ storageLimitBytes: Number.isFinite(limit) ? limit : FREE_STORAGE,
99
+ appLimit: Number.isFinite(appLimit) ? appLimit : FREE_APP_LIMIT,
100
+ alwaysOn: hosting?.alwaysOn === true,
101
+ idleMinutes: typeof hosting?.idleMinutes === "number" ? hosting.idleMinutes : 30,
102
+ };
103
+ }
104
+
105
+ export async function fetchMenuSnapshot(): Promise<MenuStatus> {
106
+ const tunnels = localTunnels();
107
+ const [healthStatus, apps, providers, meBody] = await Promise.all([
108
+ fetchHealth(),
109
+ fetchApps(),
110
+ Promise.resolve(connectedProviders()),
111
+ apiGet("/v1/me"),
112
+ ]);
113
+ const hosting = parseHosting(asObject(meBody));
114
+ return {
115
+ connected: healthStatus.connected,
116
+ latencyMs: healthStatus.latencyMs,
117
+ tunnels,
118
+ apps,
119
+ providers,
120
+ ...hosting,
121
+ };
122
+ }
123
+
124
+ export type AppInspect = {
125
+ name: string;
126
+ url: string;
127
+ createdAt?: string;
128
+ deploy?: string;
129
+ build?: string;
130
+ sizeBytes?: number;
131
+ domains: { hostname: string; verified: boolean }[];
132
+ };
133
+
134
+ export async function fetchAppInspect(id: string): Promise<AppInspect | null> {
135
+ const [statusBody, domainsBody] = await Promise.all([
136
+ apiGet(`/v1/apps/${id}/status`),
137
+ apiGet(`/v1/apps/${id}/domains`),
138
+ ]);
139
+ const status = asObject(statusBody);
140
+ if (!status) return null;
141
+ const app = asObject(status.app);
142
+ const release = asObject(status.activeRelease);
143
+ const deployment = asObject(status.activeDeployment);
144
+ const domainList = asObject(domainsBody)?.domains;
145
+ const domains: AppInspect["domains"] = [];
146
+ if (Array.isArray(domainList)) {
147
+ for (const item of domainList) {
148
+ const rec = asObject(item);
149
+ const hostname = rec ? asString(rec.hostname) : undefined;
150
+ if (!hostname) continue;
151
+ domains.push({ hostname, verified: rec?.verified === true });
152
+ }
153
+ }
154
+ const size = release?.sizeBytes;
155
+ return {
156
+ name: asString(app?.name) || id,
157
+ url: asString(app?.url) || "",
158
+ createdAt: asString(app?.createdAt),
159
+ deploy: asString(deployment?.status),
160
+ build: asString(release?.buildStatus),
161
+ sizeBytes: typeof size === "number" && Number.isFinite(size) ? size : undefined,
162
+ domains,
163
+ };
164
+ }
165
+
166
+ export async function fetchAppLogs(id: string): Promise<string> {
167
+ const body = asObject(await apiGet(`/v1/apps/${id}/logs`, 4000));
168
+ if (!body) return "No logs available.";
169
+ const lines = body.lines;
170
+ if (!Array.isArray(lines) || lines.length === 0) return "No log lines.";
171
+ return lines
172
+ .filter((line): line is string => typeof line === "string")
173
+ .slice(-40)
174
+ .join("\n");
175
+ }
@@ -2,6 +2,7 @@ import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { createInterface } from "readline";
5
+ import { readStoredCredentials } from "./credentials";
5
6
 
6
7
  export const DEFAULT_API_BASE = "https://api.uplink.spot";
7
8
 
@@ -75,6 +76,8 @@ export function getResolvedApiBase(): string {
75
76
  if (envBase) return envBase;
76
77
  const parsedToken = parseTokenEnv(process.env.AGENTCLOUD_TOKEN);
77
78
  if (parsedToken.apiBase) return parsedToken.apiBase;
79
+ const storedBase = normalizeApiBase(readStoredCredentials()?.apiBase);
80
+ if (storedBase) return storedBase;
78
81
  const configBase = readApiBaseConfig();
79
82
  if (configBase) return configBase;
80
83
  return DEFAULT_API_BASE;
@@ -83,6 +86,8 @@ export function getResolvedApiBase(): string {
83
86
  export function getResolvedApiToken(apiBase: string): string | undefined {
84
87
  const parsedToken = parseTokenEnv(process.env.AGENTCLOUD_TOKEN);
85
88
  if (parsedToken.token) return parsedToken.token;
89
+ const stored = readStoredCredentials();
90
+ if (stored?.token) return stored.token;
86
91
  if (isLocalApiBase(apiBase)) {
87
92
  return process.env.AGENTCLOUD_TOKEN_DEV || undefined;
88
93
  }
@@ -128,6 +133,12 @@ export async function ensureApiBase(options: { interactive: boolean }): Promise<
128
133
  return parsedToken.apiBase;
129
134
  }
130
135
 
136
+ const storedBase = normalizeApiBase(readStoredCredentials()?.apiBase);
137
+ if (storedBase) {
138
+ process.env.AGENTCLOUD_API_BASE = storedBase;
139
+ return storedBase;
140
+ }
141
+
131
142
  const configBase = readApiBaseConfig();
132
143
  if (configBase) {
133
144
  process.env.AGENTCLOUD_API_BASE = configBase;
@@ -0,0 +1,58 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export type StoredCredentials = {
6
+ token: string;
7
+ userId?: string;
8
+ email?: string;
9
+ accountType?: "guest" | "verified";
10
+ apiBase?: string;
11
+ updatedAt?: string;
12
+ };
13
+
14
+ function credentialsDir(): string {
15
+ return join(homedir(), ".uplink");
16
+ }
17
+
18
+ export function credentialsPath(): string {
19
+ return join(credentialsDir(), "credentials");
20
+ }
21
+
22
+ export function readStoredCredentials(): StoredCredentials | null {
23
+ try {
24
+ const path = credentialsPath();
25
+ if (!existsSync(path)) return null;
26
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as StoredCredentials;
27
+ if (!parsed?.token || typeof parsed.token !== "string") return null;
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writeStoredCredentials(creds: StoredCredentials): string {
35
+ const dir = credentialsDir();
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ try {
38
+ chmodSync(dir, 0o700);
39
+ } catch {
40
+ /* best effort */
41
+ }
42
+ const path = credentialsPath();
43
+ const body: StoredCredentials = {
44
+ token: creds.token,
45
+ userId: creds.userId,
46
+ email: creds.email,
47
+ accountType: creds.accountType,
48
+ apiBase: creds.apiBase,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ writeFileSync(path, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 });
52
+ try {
53
+ chmodSync(path, 0o600);
54
+ } catch {
55
+ /* best effort */
56
+ }
57
+ return path;
58
+ }
@@ -0,0 +1,56 @@
1
+ import { promises as dns } from "dns";
2
+ import fetch from "node-fetch";
3
+
4
+ export type PublicAvailability = {
5
+ domain: string;
6
+ status: "available" | "taken" | "unknown";
7
+ source: "dns" | "rdap";
8
+ detail: string;
9
+ };
10
+
11
+ const RDAP_TIMEOUT_MS = 8000;
12
+
13
+ /**
14
+ * Registrar-free availability check: DNS nameservers first (fast, definitive
15
+ * for registered domains), then RDAP for the authoritative registration record.
16
+ * Price and purchase still require a connected registrar.
17
+ */
18
+ export async function checkDomainAvailability(domain: string): Promise<PublicAvailability> {
19
+ try {
20
+ const nameservers = await dns.resolveNs(domain);
21
+ if (nameservers.length > 0) {
22
+ return { domain, status: "taken", source: "dns", detail: "domain has nameservers" };
23
+ }
24
+ } catch {
25
+ // NXDOMAIN or no NS records — RDAP decides.
26
+ }
27
+
28
+ let lastFailure = "RDAP unreachable";
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ const response = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
32
+ headers: { accept: "application/rdap+json" },
33
+ timeout: RDAP_TIMEOUT_MS,
34
+ });
35
+ if (response.status === 404) {
36
+ return { domain, status: "available", source: "rdap", detail: "no registration record" };
37
+ }
38
+ if (response.ok) {
39
+ return { domain, status: "taken", source: "rdap", detail: "registration record exists" };
40
+ }
41
+ lastFailure = `RDAP returned ${response.status}`;
42
+ } catch (error) {
43
+ lastFailure = error instanceof Error ? error.message : String(error);
44
+ }
45
+ }
46
+ return { domain, status: "unknown", source: "rdap", detail: lastFailure };
47
+ }
48
+
49
+ export function formatPublicAvailability(result: PublicAvailability): string {
50
+ const lines = [`${result.domain} ${result.status} (${result.detail})`];
51
+ if (result.status === "available") {
52
+ lines.push(" Availability is from public DNS/RDAP. For price and purchase, connect a registrar:");
53
+ lines.push(" uplink domains providers connect godaddy --token-env GODADDY_PAT");
54
+ }
55
+ return lines.join("\n");
56
+ }
@@ -0,0 +1,38 @@
1
+ import fetch from "node-fetch";
2
+ import { getResolvedApiBase, getResolvedApiToken } from "./api-base";
3
+ import { readStoredCredentials, writeStoredCredentials } from "./credentials";
4
+
5
+ type GuestSignup = {
6
+ token: string;
7
+ userId: string;
8
+ accountType?: "guest";
9
+ };
10
+
11
+ export async function ensureGuestAccess(options: { force?: boolean } = {}): Promise<void> {
12
+ const apiBase = getResolvedApiBase();
13
+ // force: mint a fresh guest token even when one resolves (e.g. it was rejected by the API).
14
+ if (!options.force && getResolvedApiToken(apiBase)) return;
15
+
16
+ const response = await fetch(`${apiBase}/v1/signup`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ label: "Automatic guest access" }),
20
+ });
21
+ const json = (await response.json().catch(() => ({}))) as GuestSignup & {
22
+ error?: { code?: string; message?: string };
23
+ };
24
+ if (!response.ok || !json.token) {
25
+ const code = json.error?.code || "GUEST_ACCESS_FAILED";
26
+ const message = json.error?.message || response.statusText;
27
+ throw new Error(`${code}: ${message}`);
28
+ }
29
+
30
+ writeStoredCredentials({
31
+ ...(readStoredCredentials() ?? {}),
32
+ token: json.token,
33
+ userId: json.userId,
34
+ accountType: "guest",
35
+ apiBase,
36
+ });
37
+ process.env.AGENTCLOUD_TOKEN = json.token;
38
+ }
@@ -0,0 +1,64 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+
6
+ function projectRoot(): string {
7
+ return join(__dirname, "../..");
8
+ }
9
+
10
+ function resolveTsx(): string {
11
+ const root = projectRoot();
12
+ try {
13
+ return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
14
+ } catch {
15
+ try {
16
+ return require.resolve("tsx/cli", { paths: [root] });
17
+ } catch {
18
+ return "tsx";
19
+ }
20
+ }
21
+ }
22
+
23
+ /** Domainking stays its own package; we spawn it, we do not import it. */
24
+ export function resolveDomainkingEntry(): string | null {
25
+ if (process.env.DOMAINKING_ENTRY && existsSync(process.env.DOMAINKING_ENTRY)) {
26
+ return process.env.DOMAINKING_ENTRY;
27
+ }
28
+ const candidates = [
29
+ join(homedir(), "domainking", "src", "index.tsx"),
30
+ join(projectRoot(), "..", "domainking", "src", "index.tsx"),
31
+ join(projectRoot(), "..", "..", "domainking", "src", "index.tsx"),
32
+ ];
33
+ return candidates.find((path) => existsSync(path)) ?? null;
34
+ }
35
+
36
+ export function launchDomainking(): string {
37
+ const entry = resolveDomainkingEntry();
38
+ if (!entry) {
39
+ return [
40
+ "Domain search TUI is not bundled with uplink-cli.",
41
+ "",
42
+ "Agent-friendly commands (no TUI needed):",
43
+ " uplink domains list --json",
44
+ " uplink domains check example.com --json",
45
+ " uplink host domains add --id app_xxx --hostname example.com --json",
46
+ " uplink host domains verify --id app_xxx --hostname example.com --json",
47
+ "",
48
+ "Optional: set DOMAINKING_ENTRY to a Domainking src/index.tsx for the search UI.",
49
+ ].join("\n");
50
+ }
51
+
52
+ const result = spawnSync(resolveTsx(), [entry], {
53
+ stdio: "inherit",
54
+ cwd: join(entry, "..", ".."),
55
+ env: process.env,
56
+ });
57
+ if (result.error) {
58
+ throw result.error;
59
+ }
60
+ if (result.status && result.status !== 0) {
61
+ return "Domain search exited.";
62
+ }
63
+ return "Back from domain search. Attach a hostname with Domains › Attach to app.";
64
+ }
@@ -0,0 +1,57 @@
1
+ import { unauthenticatedRequest } from "../subcommands/menu/requests";
2
+ import { getResolvedApiBase } from "./api-base";
3
+ import { writeStoredCredentials } from "./credentials";
4
+
5
+ export type LoginToken = {
6
+ id: string;
7
+ token: string;
8
+ tokenPrefix: string;
9
+ role: string;
10
+ userId: string;
11
+ label: string;
12
+ createdAt: string;
13
+ expiresAt: string | null;
14
+ message?: string;
15
+ };
16
+
17
+ export function normalizeEmail(raw: string): string {
18
+ return raw.trim().toLowerCase();
19
+ }
20
+
21
+ export function isEmail(raw: string): boolean {
22
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(raw));
23
+ }
24
+
25
+ export async function requestLoginCode(email: string): Promise<{ ok: boolean; message?: string }> {
26
+ return unauthenticatedRequest(
27
+ "POST",
28
+ "/v1/auth/otp/request",
29
+ { email: normalizeEmail(email) },
30
+ { includeCurrentToken: true }
31
+ );
32
+ }
33
+
34
+ export async function verifyLoginCode(email: string, code: string): Promise<LoginToken> {
35
+ return unauthenticatedRequest(
36
+ "POST",
37
+ "/v1/auth/otp/verify",
38
+ {
39
+ email: normalizeEmail(email),
40
+ code: code.trim(),
41
+ },
42
+ { includeCurrentToken: true }
43
+ );
44
+ }
45
+
46
+ export function persistLogin(result: { token: string; userId: string }, email?: string): string {
47
+ const apiBase = getResolvedApiBase();
48
+ const path = writeStoredCredentials({
49
+ token: result.token,
50
+ userId: result.userId,
51
+ email,
52
+ accountType: email ? "verified" : "guest",
53
+ apiBase,
54
+ });
55
+ process.env.AGENTCLOUD_TOKEN = result.token;
56
+ return path;
57
+ }