uplink-cli 0.1.39 → 0.2.1
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 +17 -2
- package/CHANGELOG.md +19 -0
- package/README.md +23 -7
- package/cli/bin/uplink.js +6 -1
- package/cli/src/index.ts +11 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +6 -0
- package/cli/src/registrars/index.ts +2 -0
- package/cli/src/registrars/types.ts +3 -1
- package/cli/src/subcommands/domains.ts +46 -19
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +4 -6
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/tunnel.ts +2 -0
- package/cli/src/tui/App.tsx +5 -0
- package/cli/src/tui/HomeStatus.tsx +53 -20
- package/cli/src/tui/session.mts +133 -248
- package/cli/src/tui/snapshot.ts +30 -1
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +17 -2
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +10 -6
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +4 -1
- package/package.json +6 -2
- package/tsconfig.json +17 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SelectOption } from "../inline-tree-select";
|
|
2
2
|
import type { MenuChoice } from "../types";
|
|
3
|
-
import {
|
|
3
|
+
import { buildFindDomainAction } from "./domain-check";
|
|
4
4
|
import { parseHostedApps, runCli, runCliCapture } from "./hosting";
|
|
5
5
|
|
|
6
6
|
type Deps = {
|
|
@@ -18,6 +18,7 @@ const PROVIDER_OPTIONS: SelectOption[] = [
|
|
|
18
18
|
{ label: "Cloudflare", value: "cloudflare" },
|
|
19
19
|
{ label: "Hostinger", value: "hostinger" },
|
|
20
20
|
{ label: "Namecheap", value: "namecheap" },
|
|
21
|
+
{ label: "DreamHost", value: "dreamhost" },
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
async function pickHostedApp(
|
|
@@ -114,10 +115,7 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
|
114
115
|
},
|
|
115
116
|
{
|
|
116
117
|
label: "Find a domain",
|
|
117
|
-
action:
|
|
118
|
-
restoreRawMode();
|
|
119
|
-
return launchDomainking();
|
|
120
|
-
},
|
|
118
|
+
action: buildFindDomainAction(deps),
|
|
121
119
|
},
|
|
122
120
|
{
|
|
123
121
|
label: "Attach to app",
|
|
@@ -180,7 +178,7 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
|
180
178
|
return [
|
|
181
179
|
"Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
|
|
182
180
|
"",
|
|
183
|
-
" My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap",
|
|
181
|
+
" My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap / DreamHost",
|
|
184
182
|
" Connect — save a registrar token (same as the CLI)",
|
|
185
183
|
" Find — search names that are not yours yet",
|
|
186
184
|
" Attach — bind a hostname to a hosted app",
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import fetch from "node-fetch";
|
|
2
|
-
import { getResolvedApiBase } from "../../utils/api-base";
|
|
2
|
+
import { getResolvedApiBase, getResolvedApiToken } from "../../utils/api-base";
|
|
3
3
|
|
|
4
|
-
export async function unauthenticatedRequest(
|
|
4
|
+
export async function unauthenticatedRequest(
|
|
5
|
+
method: string,
|
|
6
|
+
path: string,
|
|
7
|
+
body?: unknown,
|
|
8
|
+
options: { includeCurrentToken?: boolean } = {}
|
|
9
|
+
): Promise<any> {
|
|
5
10
|
const apiBase = getResolvedApiBase();
|
|
11
|
+
const token = options.includeCurrentToken ? getResolvedApiToken(apiBase) : undefined;
|
|
6
12
|
const response = await fetch(`${apiBase}${path}`, {
|
|
7
13
|
method,
|
|
8
14
|
headers: {
|
|
9
15
|
"Content-Type": "application/json",
|
|
16
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
10
17
|
},
|
|
11
18
|
body: body ? JSON.stringify(body) : undefined,
|
|
12
19
|
});
|
|
@@ -39,7 +39,7 @@ async function signupRequest(body: Record<string, unknown>): Promise<SignupRespo
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export const signupCommand = new Command("signup")
|
|
42
|
-
.description("Create
|
|
42
|
+
.description("Create an explicit guest token (tunnel create does this automatically)")
|
|
43
43
|
.option("--label <label>", "Optional label for the token")
|
|
44
44
|
.option("--expires-days <days>", "Token expiration in days (optional)")
|
|
45
45
|
.option("--json", "Output JSON", false)
|
|
@@ -63,7 +63,7 @@ export const signupCommand = new Command("signup")
|
|
|
63
63
|
} else {
|
|
64
64
|
const apiBase = getResolvedApiBase();
|
|
65
65
|
const tokenExport = formatTokenForEnv(result.token, apiBase);
|
|
66
|
-
console.log("
|
|
66
|
+
console.log("Guest access created successfully!");
|
|
67
67
|
console.log("");
|
|
68
68
|
console.log(` Token: ${result.token}`);
|
|
69
69
|
console.log(` User ID: ${result.userId}`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
killTunnelClient,
|
|
7
7
|
startTunnelClient,
|
|
8
8
|
} from "./menu/effects/tunnel-clients";
|
|
9
|
+
import { ensureGuestAccess } from "../utils/guest-access";
|
|
9
10
|
|
|
10
11
|
type TunnelResponse = {
|
|
11
12
|
id: string;
|
|
@@ -51,6 +52,7 @@ tunnelCommand
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
try {
|
|
55
|
+
await ensureGuestAccess();
|
|
54
56
|
const existing = findTunnelClients().filter((c) => c.port === port);
|
|
55
57
|
if (existing.length > 0 && !opts.apiOnly) {
|
|
56
58
|
const err = `Tunnel client already running on port ${port} (pid ${existing[0].pid})`;
|
package/cli/src/tui/App.tsx
CHANGED
|
@@ -13,6 +13,11 @@ export type MenuStatus = {
|
|
|
13
13
|
tunnels: TunnelLine[];
|
|
14
14
|
apps: { name: string; id: string; url?: string; createdAt?: string }[];
|
|
15
15
|
providers: string[];
|
|
16
|
+
storageUsedBytes: number;
|
|
17
|
+
storageLimitBytes: number;
|
|
18
|
+
appLimit: number;
|
|
19
|
+
alwaysOn: boolean;
|
|
20
|
+
idleMinutes: number | null;
|
|
16
21
|
};
|
|
17
22
|
|
|
18
23
|
export type MenuOutcome =
|
|
@@ -1,45 +1,73 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import type { MenuStatus } from "./App";
|
|
3
3
|
import { Wordmark } from "./brand";
|
|
4
|
+
import { formatBytes } from "./format";
|
|
4
5
|
|
|
5
6
|
const LABEL_WIDTH = 12;
|
|
6
|
-
const
|
|
7
|
+
const SPACE_GAUGE = 16;
|
|
7
8
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
const filled = Math.min(count, BAR_CAP);
|
|
11
|
-
return (
|
|
12
|
-
<Text>
|
|
13
|
-
<Text color="green">{"█".repeat(filled)}</Text>
|
|
14
|
-
{count > BAR_CAP ? <Text dimColor> +{count - BAR_CAP}</Text> : null}
|
|
15
|
-
</Text>
|
|
16
|
-
);
|
|
9
|
+
function formatLimit(n: number): string {
|
|
10
|
+
return n < 0 ? "∞" : String(n);
|
|
17
11
|
}
|
|
18
12
|
|
|
19
|
-
function Metric({ label,
|
|
13
|
+
function Metric({ label, value }: { label: string; value: string }) {
|
|
20
14
|
return (
|
|
21
15
|
<Box>
|
|
22
16
|
<Box width={LABEL_WIDTH}>
|
|
23
17
|
<Text dimColor>{label}</Text>
|
|
24
18
|
</Box>
|
|
25
|
-
<
|
|
26
|
-
|
|
19
|
+
<Text>{value}</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function SpaceMetric({ usedBytes, limitBytes }: { usedBytes: number; limitBytes: number }) {
|
|
25
|
+
const used = Math.max(0, usedBytes);
|
|
26
|
+
const unlimited = limitBytes < 0;
|
|
27
|
+
const cap = unlimited ? Math.max(used, 1) : Math.max(limitBytes, 1);
|
|
28
|
+
const left = unlimited ? used : Math.max(0, limitBytes - used);
|
|
29
|
+
const ratio = unlimited ? 0 : Math.min(1, used / cap);
|
|
30
|
+
const filled = Math.round(ratio * SPACE_GAUGE);
|
|
31
|
+
const nearlyFull = !unlimited && ratio >= 0.85;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box flexDirection="column">
|
|
35
|
+
<Box>
|
|
36
|
+
<Box width={LABEL_WIDTH}>
|
|
37
|
+
<Text dimColor>space</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
<Text>
|
|
40
|
+
<Text color={nearlyFull ? "red" : "green"}>{"█".repeat(filled)}</Text>
|
|
41
|
+
<Text dimColor>{"░".repeat(SPACE_GAUGE - filled)}</Text>
|
|
42
|
+
<Text dimColor>
|
|
43
|
+
{" "}
|
|
44
|
+
{unlimited ? `${formatBytes(used)} used` : `${formatBytes(left)} left`}
|
|
45
|
+
</Text>
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
<Box>
|
|
49
|
+
<Box width={LABEL_WIDTH}>
|
|
50
|
+
<Text> </Text>
|
|
51
|
+
</Box>
|
|
52
|
+
<Text dimColor>
|
|
53
|
+
{unlimited ? "unlimited" : `of ${formatBytes(limitBytes)} hosting budget`}
|
|
54
|
+
</Text>
|
|
27
55
|
</Box>
|
|
28
|
-
<CountBar count={count} />
|
|
29
56
|
</Box>
|
|
30
57
|
);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
export function HomeStatus({ status }: { status: MenuStatus }) {
|
|
34
61
|
const latency =
|
|
35
|
-
status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "
|
|
62
|
+
status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "0ms";
|
|
63
|
+
const plan = status.alwaysOn ? "always-on" : `sleep after ${status.idleMinutes ?? 30}m idle`;
|
|
36
64
|
|
|
37
65
|
return (
|
|
38
66
|
<Box flexDirection="column">
|
|
39
67
|
<Wordmark />
|
|
40
68
|
<Box marginTop={1}>
|
|
41
|
-
<Text color={status.connected ? "green" : "
|
|
42
|
-
{status.connected ? "connected" : "offline"}
|
|
69
|
+
<Text color={status.connected ? "green" : "yellow"}>
|
|
70
|
+
● {status.connected ? "connected" : "offline"}
|
|
43
71
|
</Text>
|
|
44
72
|
<Text dimColor> · {latency}</Text>
|
|
45
73
|
</Box>
|
|
@@ -47,9 +75,14 @@ export function HomeStatus({ status }: { status: MenuStatus }) {
|
|
|
47
75
|
<Text dimColor>{"─".repeat(36)}</Text>
|
|
48
76
|
</Box>
|
|
49
77
|
<Box flexDirection="column">
|
|
50
|
-
<Metric
|
|
51
|
-
|
|
52
|
-
|
|
78
|
+
<Metric
|
|
79
|
+
label="apps"
|
|
80
|
+
value={`${status.apps.length} / ${formatLimit(status.appLimit)}`}
|
|
81
|
+
/>
|
|
82
|
+
<Metric label="tunnels" value={String(status.tunnels.length)} />
|
|
83
|
+
<Metric label="registrars" value={String(status.providers.length)} />
|
|
84
|
+
<SpaceMetric usedBytes={status.storageUsedBytes} limitBytes={status.storageLimitBytes} />
|
|
85
|
+
<Metric label="plan" value={plan} />
|
|
53
86
|
</Box>
|
|
54
87
|
<Box marginTop={1}>
|
|
55
88
|
<Text dimColor>{"─".repeat(36)}</Text>
|
package/cli/src/tui/session.mts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fetch from "node-fetch";
|
|
2
2
|
import { apiRequest } from "../http";
|
|
3
3
|
import { clearScreen, promptLine, restoreRawMode, truncate } from "../subcommands/menu/io";
|
|
4
|
-
import { unauthenticatedRequest } from "../subcommands/menu/requests";
|
|
5
4
|
import { inlineSelect } from "../subcommands/menu/inline-tree-select";
|
|
6
5
|
import {
|
|
7
6
|
colorDim,
|
|
@@ -19,10 +18,12 @@ import {
|
|
|
19
18
|
buildSystemStatusMenu,
|
|
20
19
|
buildUsageMenu,
|
|
21
20
|
} from "../subcommands/menu/menus";
|
|
22
|
-
import {
|
|
21
|
+
import { buildFindDomainAction } from "../subcommands/menu/menus/domain-check";
|
|
22
|
+
import { ports, smoke, tunnelClients } from "../subcommands/menu/effects";
|
|
23
23
|
import { runInkMenu } from "./runMenu";
|
|
24
|
-
import { launchDomainking } from "../utils/launchDomainking";
|
|
25
24
|
import { fetchMenuSnapshot } from "./snapshot";
|
|
25
|
+
import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
|
|
26
|
+
import { ensureGuestAccess } from "../utils/guest-access";
|
|
26
27
|
|
|
27
28
|
function formatBytes(bytes: number): string {
|
|
28
29
|
if (bytes === 0) return "0 B";
|
|
@@ -32,221 +33,116 @@ function formatBytes(bytes: number): string {
|
|
|
32
33
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
async function continueWithEmail(): Promise<string | undefined> {
|
|
37
|
+
restoreRawMode();
|
|
38
|
+
clearScreen();
|
|
39
|
+
try {
|
|
40
|
+
process.stdout.write("\n");
|
|
41
|
+
process.stdout.write(colorWhite("UPLINK") + colorDim(" Continue with email\n\n"));
|
|
42
|
+
const email = normalizeEmail(await promptLine("Email: "));
|
|
43
|
+
if (!isEmail(email)) return "Invalid email.";
|
|
44
|
+
|
|
45
|
+
await requestLoginCode(email);
|
|
46
|
+
process.stdout.write(`\nCode sent to ${email}.\n`);
|
|
47
|
+
const code = (await promptLine("Code: ")).trim();
|
|
48
|
+
if (!/^\d{6}$/.test(code)) return "Code must be 6 digits.";
|
|
49
|
+
|
|
50
|
+
const result = await verifyLoginCode(email, code);
|
|
51
|
+
if (!result?.token) return "Invalid response from server. Token not received.";
|
|
52
|
+
const savedTo = persistLogin(result, email);
|
|
53
|
+
process.stdout.write(`\n${colorGreen("✓")} Account verified\n`);
|
|
54
|
+
process.stdout.write(colorDim(` ${savedTo}\n\n`));
|
|
55
|
+
return "Email verified. Run uplink again to see Hosting and Domains.";
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
const errorMsg = err?.message || String(err);
|
|
58
|
+
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
59
|
+
return "Too many attempts. Please try again later.";
|
|
60
|
+
}
|
|
61
|
+
return `Email verification failed: ${errorMsg}`;
|
|
62
|
+
} finally {
|
|
63
|
+
restoreRawMode();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const aboutItem: MenuChoice = {
|
|
68
|
+
label: "About",
|
|
69
|
+
action: async () => {
|
|
70
|
+
return [
|
|
71
|
+
"Uplink CLI",
|
|
72
|
+
"Open source CLI for sharing localhost and hosting apps.",
|
|
73
|
+
"Interactive menu + agent-friendly commands for automation.",
|
|
74
|
+
"",
|
|
75
|
+
"Website: https://uplink.spot",
|
|
76
|
+
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
77
|
+
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
78
|
+
].join("\n");
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const exitItem: MenuChoice = {
|
|
83
|
+
label: "Exit",
|
|
84
|
+
action: async () => "Goodbye!",
|
|
85
|
+
};
|
|
86
|
+
|
|
35
87
|
export async function startMenuSession(): Promise<void> {
|
|
88
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
89
|
+
console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
36
93
|
const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
|
|
37
|
-
|
|
38
|
-
// Determine role (admin or user) via /v1/me; check if auth failed
|
|
94
|
+
|
|
39
95
|
let isAdmin = false;
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
96
|
+
let accountType: "guest" | "verified" | "admin" | null = null;
|
|
97
|
+
let connectionError: string | null = null;
|
|
98
|
+
|
|
99
|
+
const resolveAccount = async (): Promise<void> => {
|
|
43
100
|
const me = await apiRequest("GET", "/v1/me");
|
|
44
101
|
isAdmin = me?.role === "admin";
|
|
102
|
+
accountType = isAdmin ? "admin" : me?.accountType === "verified" ? "verified" : "guest";
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await resolveAccount();
|
|
45
107
|
} catch (err: any) {
|
|
46
|
-
// Check if it's an authentication error
|
|
47
108
|
const errorMsg = err?.message || String(err);
|
|
48
|
-
authFailed =
|
|
109
|
+
const authFailed =
|
|
49
110
|
errorMsg.includes("UNAUTHORIZED") ||
|
|
50
111
|
errorMsg.includes("401") ||
|
|
51
112
|
errorMsg.includes("Missing or invalid token") ||
|
|
52
113
|
errorMsg.includes("Missing AGENTCLOUD_TOKEN");
|
|
53
|
-
|
|
114
|
+
if (authFailed) {
|
|
115
|
+
// No usable token: quietly create guest access so everyone gets the same menu.
|
|
116
|
+
try {
|
|
117
|
+
await ensureGuestAccess({ force: true });
|
|
118
|
+
await resolveAccount();
|
|
119
|
+
} catch (guestErr: any) {
|
|
120
|
+
connectionError = guestErr?.message || String(guestErr);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
connectionError = errorMsg;
|
|
124
|
+
}
|
|
54
125
|
}
|
|
55
|
-
const meDurationMs = Date.now() - meStart;
|
|
56
126
|
|
|
57
|
-
// Build menu structure dynamically by role and auth status
|
|
58
127
|
const mainMenu: MenuChoice[] = [];
|
|
59
|
-
|
|
60
|
-
// If authentication failed, show ONLY "Get Started", "About", and "Exit"
|
|
61
|
-
if (authFailed) {
|
|
62
|
-
mainMenu.push({
|
|
63
|
-
label: "🚀 Get Started (Create Account)",
|
|
64
|
-
action: async () => {
|
|
65
|
-
restoreRawMode();
|
|
66
|
-
clearScreen();
|
|
67
|
-
try {
|
|
68
|
-
process.stdout.write("\n");
|
|
69
|
-
process.stdout.write(colorWhite("UPLINK") + colorDim(" Create Account\n"));
|
|
70
|
-
process.stdout.write("\n");
|
|
71
128
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const expiresDays = expiresInput ? Number(expiresInput) : undefined;
|
|
75
|
-
|
|
76
|
-
if (expiresDays && (isNaN(expiresDays) || expiresDays <= 0)) {
|
|
77
|
-
restoreRawMode();
|
|
78
|
-
return "Invalid expiration days. Please enter a positive number or leave empty.";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
process.stdout.write("\nCreating your token...\n");
|
|
82
|
-
process.stdout.write("");
|
|
83
|
-
let result;
|
|
84
|
-
try {
|
|
85
|
-
result = await unauthenticatedRequest("POST", "/v1/signup", {
|
|
86
|
-
label: label || undefined,
|
|
87
|
-
expiresInDays: expiresDays || undefined,
|
|
88
|
-
});
|
|
89
|
-
if (!result) {
|
|
90
|
-
restoreRawMode();
|
|
91
|
-
return "❌ Error: No response from server.";
|
|
92
|
-
}
|
|
93
|
-
} catch (err: any) {
|
|
94
|
-
restoreRawMode();
|
|
95
|
-
const errorMsg = err?.message || String(err);
|
|
96
|
-
console.error("\n❌ Signup error:", errorMsg);
|
|
97
|
-
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
98
|
-
return "⚠️ Too many signup attempts. Please try again later.";
|
|
99
|
-
}
|
|
100
|
-
return `❌ Error creating account: ${errorMsg}`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (!result || !result.token) {
|
|
104
|
-
restoreRawMode();
|
|
105
|
-
return "❌ Error: Invalid response from server. Token not received.";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const token = result.token;
|
|
109
|
-
const tokenId = result.id;
|
|
110
|
-
const userId = result.userId;
|
|
111
|
-
|
|
112
|
-
process.stdout.write("\n");
|
|
113
|
-
process.stdout.write(colorGreen("✓") + " Account created\n");
|
|
114
|
-
process.stdout.write("\n");
|
|
115
|
-
process.stdout.write(colorDim("├─") + " Token " + token + "\n");
|
|
116
|
-
process.stdout.write(colorDim("├─") + " ID " + tokenId + "\n");
|
|
117
|
-
process.stdout.write(colorDim("├─") + " User " + userId + "\n");
|
|
118
|
-
process.stdout.write(colorDim("├─") + " Role " + result.role + "\n");
|
|
119
|
-
if (result.expiresAt) {
|
|
120
|
-
process.stdout.write(colorDim("└─") + " Expires " + result.expiresAt + "\n");
|
|
121
|
-
} else {
|
|
122
|
-
process.stdout.write(colorDim("└─") + " Expires " + colorDim("never") + "\n");
|
|
123
|
-
}
|
|
124
|
-
process.stdout.write("\n");
|
|
125
|
-
process.stdout.write(colorDim("!") + " Save this token securely — shown only once\n");
|
|
126
|
-
|
|
127
|
-
// Try to automatically add token to shell config
|
|
128
|
-
const detected = tokenConfig.detectShellConfigFile();
|
|
129
|
-
let configFile: string | null = detected.configFile;
|
|
130
|
-
let shellName = detected.shellName;
|
|
131
|
-
|
|
132
|
-
let tokenAdded = false;
|
|
133
|
-
const tokenExists = configFile ? tokenConfig.shellConfigHasToken(configFile) : false;
|
|
134
|
-
|
|
135
|
-
if (configFile) {
|
|
136
|
-
const promptText = tokenExists
|
|
137
|
-
? `\n→ Update existing token in ~/.${shellName}rc? (Y/n): `
|
|
138
|
-
: `\n→ Add token to ~/.${shellName}rc? (Y/n): `;
|
|
139
|
-
|
|
140
|
-
const addToken = (await promptLine(promptText)).trim().toLowerCase();
|
|
141
|
-
if (addToken !== "n" && addToken !== "no") {
|
|
142
|
-
try {
|
|
143
|
-
const res = tokenConfig.upsertShellToken(configFile, token);
|
|
144
|
-
tokenAdded = res.wrote;
|
|
145
|
-
if (tokenExists) {
|
|
146
|
-
console.log(colorGreen(`\n✓ Token updated in ~/.${shellName}rc`));
|
|
147
|
-
} else {
|
|
148
|
-
console.log(colorGreen(`\n✓ Token added to ~/.${shellName}rc`));
|
|
149
|
-
}
|
|
150
|
-
if (!res.verifyOk) {
|
|
151
|
-
console.log(
|
|
152
|
-
colorRed(`\n! Token may not have been written correctly. Check ~/.${shellName}rc`)
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
} catch (err: any) {
|
|
156
|
-
if (err?.message?.includes("UNSAFE_SHELL_CONFIG_PERMISSIONS")) {
|
|
157
|
-
console.log(
|
|
158
|
-
colorRed(
|
|
159
|
-
`\n! Could not write to ~/.${shellName}rc: file is group/world writable. Fix permissions first.`
|
|
160
|
-
)
|
|
161
|
-
);
|
|
162
|
-
console.log(colorDim(` chmod 600 ~/.${shellName}rc`));
|
|
163
|
-
} else {
|
|
164
|
-
console.log(colorRed(`\n! Could not write to ~/.${shellName}rc: ${err.message}`));
|
|
165
|
-
}
|
|
166
|
-
console.log(`\n Please add manually:`);
|
|
167
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc`));
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} else {
|
|
171
|
-
console.log(colorDim(`\n→ Could not detect your shell. Add the token manually:`));
|
|
172
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.zshrc # for zsh`));
|
|
173
|
-
console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.bashrc # for bash`));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!tokenAdded) {
|
|
177
|
-
process.stdout.write("\n");
|
|
178
|
-
process.stdout.write(colorDim("!") + " Set this token as an environment variable:\n\n");
|
|
179
|
-
process.stdout.write(colorDim(" ") + "export AGENTCLOUD_TOKEN=" + token + "\n");
|
|
180
|
-
if (configFile) {
|
|
181
|
-
process.stdout.write(colorDim(`\n Or add to ~/.${shellName}rc:\n`));
|
|
182
|
-
process.stdout.write(colorDim(" ") + `echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc\n`);
|
|
183
|
-
process.stdout.write(colorDim(" ") + `source ~/.${shellName}rc\n`);
|
|
184
|
-
}
|
|
185
|
-
process.stdout.write(colorDim("\n Then restart this menu.\n\n"));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
restoreRawMode();
|
|
189
|
-
|
|
190
|
-
if (tokenAdded) {
|
|
191
|
-
process.env.AGENTCLOUD_TOKEN = token;
|
|
192
|
-
// Use stdout writes to avoid buffering/race with process.exit()
|
|
193
|
-
process.stdout.write(`\n${colorGreen("✓")} Token saved to ~/.${shellName}rc\n`);
|
|
194
|
-
process.stdout.write(`\n${colorDim("→")} Next: run in your terminal:\n`);
|
|
195
|
-
process.stdout.write(colorDim(` source ~/.${shellName}rc && uplink\n\n`));
|
|
196
|
-
|
|
197
|
-
setTimeout(() => {
|
|
198
|
-
process.exit(0);
|
|
199
|
-
}, 3000);
|
|
200
|
-
|
|
201
|
-
return undefined as any;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
console.log("\nPress Enter to continue...");
|
|
205
|
-
await promptLine("");
|
|
206
|
-
restoreRawMode();
|
|
207
|
-
return "Token created! Please set AGENTCLOUD_TOKEN environment variable and restart the menu.";
|
|
208
|
-
} catch (err: any) {
|
|
209
|
-
restoreRawMode();
|
|
210
|
-
const errorMsg = err?.message || String(err);
|
|
211
|
-
if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
|
|
212
|
-
return "⚠️ Too many signup attempts. Please try again later.";
|
|
213
|
-
}
|
|
214
|
-
return `❌ Error creating account: ${errorMsg}`;
|
|
215
|
-
}
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
|
|
129
|
+
if (!accountType) {
|
|
130
|
+
// API unreachable (or guest provisioning failed): minimal offline menu.
|
|
219
131
|
mainMenu.push({
|
|
220
|
-
label: "
|
|
221
|
-
action: async () => {
|
|
222
|
-
restoreRawMode();
|
|
223
|
-
return launchDomainking();
|
|
224
|
-
},
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
mainMenu.push({
|
|
228
|
-
label: "About",
|
|
132
|
+
label: "Connection details",
|
|
229
133
|
action: async () => {
|
|
230
134
|
return [
|
|
231
|
-
|
|
232
|
-
"
|
|
233
|
-
|
|
135
|
+
`Could not reach ${apiBase}.`,
|
|
136
|
+
"",
|
|
137
|
+
connectionError ?? "Unknown error.",
|
|
234
138
|
"",
|
|
235
|
-
"
|
|
236
|
-
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
237
|
-
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
139
|
+
"Check your network, then run uplink again.",
|
|
238
140
|
].join("\n");
|
|
239
141
|
},
|
|
240
142
|
});
|
|
241
|
-
|
|
242
|
-
mainMenu.push(
|
|
243
|
-
label: "Exit",
|
|
244
|
-
action: async () => {
|
|
245
|
-
return "Goodbye!";
|
|
246
|
-
},
|
|
247
|
-
});
|
|
143
|
+
mainMenu.push(aboutItem);
|
|
144
|
+
mainMenu.push(exitItem);
|
|
248
145
|
} else {
|
|
249
|
-
// Only show other menu items if authentication succeeded
|
|
250
146
|
|
|
251
147
|
const shareMenu = buildManageTunnelsMenu({
|
|
252
148
|
apiRequest,
|
|
@@ -263,21 +159,22 @@ export async function startMenuSession(): Promise<void> {
|
|
|
263
159
|
colorRed,
|
|
264
160
|
});
|
|
265
161
|
|
|
266
|
-
const aliasesMenu = buildManageAliasesMenu({
|
|
267
|
-
apiRequest,
|
|
268
|
-
promptLine,
|
|
269
|
-
restoreRawMode,
|
|
270
|
-
inlineSelect,
|
|
271
|
-
findTunnelClients: tunnelClients.findTunnelClients,
|
|
272
|
-
truncate,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
162
|
shareMenu.subMenu = shareMenu.subMenu || [];
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
163
|
+
if (accountType !== "guest") {
|
|
164
|
+
const aliasesMenu = buildManageAliasesMenu({
|
|
165
|
+
apiRequest,
|
|
166
|
+
promptLine,
|
|
167
|
+
restoreRawMode,
|
|
168
|
+
inlineSelect,
|
|
169
|
+
findTunnelClients: tunnelClients.findTunnelClients,
|
|
170
|
+
truncate,
|
|
280
171
|
});
|
|
172
|
+
if (aliasesMenu.subMenu) {
|
|
173
|
+
shareMenu.subMenu.push({
|
|
174
|
+
label: "Aliases",
|
|
175
|
+
subMenu: aliasesMenu.subMenu,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
281
178
|
}
|
|
282
179
|
if (isAdmin) {
|
|
283
180
|
shareMenu.subMenu.push({
|
|
@@ -301,21 +198,31 @@ export async function startMenuSession(): Promise<void> {
|
|
|
301
198
|
|
|
302
199
|
mainMenu.push(shareMenu);
|
|
303
200
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
restoreRawMode,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
201
|
+
if (accountType === "guest") {
|
|
202
|
+
mainMenu.push({
|
|
203
|
+
label: "Check domain availability",
|
|
204
|
+
action: buildFindDomainAction({ promptLine, restoreRawMode }),
|
|
205
|
+
});
|
|
206
|
+
mainMenu.push({
|
|
207
|
+
label: "Continue with email (unlock hosting + domains)",
|
|
208
|
+
action: continueWithEmail,
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
mainMenu.push(
|
|
212
|
+
buildHostingMenu({
|
|
213
|
+
promptLine,
|
|
214
|
+
restoreRawMode,
|
|
215
|
+
inlineSelect,
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
mainMenu.push(
|
|
219
|
+
buildDomainsMenu({
|
|
220
|
+
promptLine,
|
|
221
|
+
restoreRawMode,
|
|
222
|
+
inlineSelect,
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
}
|
|
319
226
|
|
|
320
227
|
// Admin-only: Usage section
|
|
321
228
|
if (isAdmin) {
|
|
@@ -352,31 +259,9 @@ export async function startMenuSession(): Promise<void> {
|
|
|
352
259
|
);
|
|
353
260
|
}
|
|
354
261
|
|
|
355
|
-
mainMenu.push(
|
|
356
|
-
|
|
357
|
-
action: async () => {
|
|
358
|
-
return [
|
|
359
|
-
"Uplink CLI",
|
|
360
|
-
"Open source CLI for sharing localhost and hosting apps.",
|
|
361
|
-
"Interactive menu + agent-friendly commands for automation.",
|
|
362
|
-
"",
|
|
363
|
-
"Website: https://uplink.spot",
|
|
364
|
-
"GitHub: https://github.com/firstprinciplecode/uplink",
|
|
365
|
-
"Issues: https://github.com/firstprinciplecode/uplink/issues",
|
|
366
|
-
].join("\n");
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
mainMenu.push({
|
|
371
|
-
label: "Exit",
|
|
372
|
-
action: async () => "Goodbye!",
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
377
|
-
console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
|
|
378
|
-
process.exit(1);
|
|
262
|
+
mainMenu.push(aboutItem);
|
|
263
|
+
mainMenu.push(exitItem);
|
|
379
264
|
}
|
|
380
265
|
|
|
381
266
|
await runInkMenu({ tree: mainMenu, getStatus: fetchMenuSnapshot });
|
|
382
|
-
}
|
|
267
|
+
}
|