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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import type { MenuInspect } from "../subcommands/menu/types";
|
|
4
|
+
import { ARTIFACT_CAP_BYTES, fetchAppInspect, type AppInspect } from "./snapshot";
|
|
5
|
+
import { formatBytes, formatDate } from "./format";
|
|
6
|
+
|
|
7
|
+
const LABEL_WIDTH = 10;
|
|
8
|
+
const GAUGE_WIDTH = 16;
|
|
9
|
+
|
|
10
|
+
function Row({
|
|
11
|
+
label,
|
|
12
|
+
value,
|
|
13
|
+
color,
|
|
14
|
+
dim,
|
|
15
|
+
}: {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
color?: "green" | "red";
|
|
19
|
+
dim?: boolean;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<Box>
|
|
23
|
+
<Box width={LABEL_WIDTH}>
|
|
24
|
+
<Text dimColor>{label}</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
<Text color={color} dimColor={dim}>
|
|
27
|
+
{value}
|
|
28
|
+
</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function SizeGauge({ bytes }: { bytes: number }) {
|
|
34
|
+
const ratio = Math.min(1, bytes / ARTIFACT_CAP_BYTES);
|
|
35
|
+
const filled = Math.round(ratio * GAUGE_WIDTH);
|
|
36
|
+
return (
|
|
37
|
+
<Box>
|
|
38
|
+
<Box width={LABEL_WIDTH}>
|
|
39
|
+
<Text dimColor>size</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Text>
|
|
42
|
+
<Text color="green">{"█".repeat(filled)}</Text>
|
|
43
|
+
<Text dimColor>{"░".repeat(GAUGE_WIDTH - filled)}</Text>
|
|
44
|
+
<Text dimColor>
|
|
45
|
+
{" "}
|
|
46
|
+
{formatBytes(bytes)} / {formatBytes(ARTIFACT_CAP_BYTES)}
|
|
47
|
+
</Text>
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function statusColor(value?: string): "green" | "red" | undefined {
|
|
54
|
+
if (!value) return undefined;
|
|
55
|
+
if (value === "running" || value === "ready") return "green";
|
|
56
|
+
if (value === "failed") return "red";
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function AppInspector({ inspect }: { inspect?: MenuInspect }) {
|
|
61
|
+
const [detail, setDetail] = useState<AppInspect | null>(null);
|
|
62
|
+
const [loading, setLoading] = useState(false);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!inspect || inspect.kind !== "app") {
|
|
66
|
+
setDetail(null);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
setLoading(true);
|
|
72
|
+
fetchAppInspect(inspect.id).then((next) => {
|
|
73
|
+
if (cancelled) return;
|
|
74
|
+
setDetail(next);
|
|
75
|
+
setLoading(false);
|
|
76
|
+
});
|
|
77
|
+
}, 120);
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
};
|
|
82
|
+
}, [inspect?.id, inspect?.kind]);
|
|
83
|
+
|
|
84
|
+
if (!inspect) return null;
|
|
85
|
+
|
|
86
|
+
const url = detail?.url || inspect.url || "—";
|
|
87
|
+
const deploy = detail?.deploy || (loading ? "…" : "—");
|
|
88
|
+
const build = detail?.build || (loading ? "…" : "—");
|
|
89
|
+
const domainText =
|
|
90
|
+
detail && detail.domains.length > 0
|
|
91
|
+
? detail.domains
|
|
92
|
+
.slice(0, 2)
|
|
93
|
+
.map((domain) => `${domain.hostname}${domain.verified ? "" : " (pending)"}`)
|
|
94
|
+
.join(", ") + (detail.domains.length > 2 ? ` +${detail.domains.length - 2}` : "")
|
|
95
|
+
: loading && !detail
|
|
96
|
+
? "…"
|
|
97
|
+
: "none";
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Box flexDirection="column" marginTop={1}>
|
|
101
|
+
<Text dimColor>── inspect ──</Text>
|
|
102
|
+
<Row label="url" value={url} />
|
|
103
|
+
<Row label="status" value={deploy} color={statusColor(detail?.deploy)} dim={!detail?.deploy} />
|
|
104
|
+
<Row label="build" value={build} color={statusColor(detail?.build)} dim={!detail?.build} />
|
|
105
|
+
{detail?.sizeBytes != null ? (
|
|
106
|
+
<SizeGauge bytes={detail.sizeBytes} />
|
|
107
|
+
) : (
|
|
108
|
+
<Row label="size" value={loading ? "…" : "none"} dim />
|
|
109
|
+
)}
|
|
110
|
+
<Row label="created" value={formatDate(detail?.createdAt || inspect.createdAt)} dim={!detail?.createdAt && !inspect.createdAt} />
|
|
111
|
+
<Row label="domains" value={domainText} dim={domainText === "none"} />
|
|
112
|
+
</Box>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { MenuStatus } from "./App";
|
|
3
|
+
import { Wordmark } from "./brand";
|
|
4
|
+
|
|
5
|
+
const LABEL_WIDTH = 12;
|
|
6
|
+
const BAR_CAP = 24;
|
|
7
|
+
|
|
8
|
+
function CountBar({ count }: { count: number }) {
|
|
9
|
+
if (count <= 0) return null;
|
|
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
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Metric({ label, count }: { label: string; count: number }) {
|
|
20
|
+
return (
|
|
21
|
+
<Box>
|
|
22
|
+
<Box width={LABEL_WIDTH}>
|
|
23
|
+
<Text dimColor>{label}</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
<Box width={4}>
|
|
26
|
+
{count > 0 ? <Text>{count}</Text> : <Text dimColor>—</Text>}
|
|
27
|
+
</Box>
|
|
28
|
+
<CountBar count={count} />
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function HomeStatus({ status }: { status: MenuStatus }) {
|
|
34
|
+
const latency =
|
|
35
|
+
status.connected && status.latencyMs != null ? `${status.latencyMs}ms` : "—";
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Box flexDirection="column">
|
|
39
|
+
<Wordmark />
|
|
40
|
+
<Box marginTop={1}>
|
|
41
|
+
<Text color={status.connected ? "green" : "red"}>
|
|
42
|
+
{status.connected ? "connected" : "offline"}
|
|
43
|
+
</Text>
|
|
44
|
+
<Text dimColor> · {latency}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
<Box marginTop={1} marginBottom={1}>
|
|
47
|
+
<Text dimColor>{"─".repeat(36)}</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
<Box flexDirection="column">
|
|
50
|
+
<Metric label="apps" count={status.apps.length} />
|
|
51
|
+
<Metric label="tunnels" count={status.tunnels.length} />
|
|
52
|
+
<Metric label="registrars" count={status.providers.length} />
|
|
53
|
+
</Box>
|
|
54
|
+
<Box marginTop={1}>
|
|
55
|
+
<Text dimColor>{"─".repeat(36)}</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
|
|
3
|
+
const WORDMARK = [
|
|
4
|
+
" _ _ ___ _ ___ _ _ _ __",
|
|
5
|
+
"| | | | _ \\ | |_ _| \\| | |/ /",
|
|
6
|
+
"| |_| | _/ |__ | || .` | ' < ",
|
|
7
|
+
" \\___/|_| |____|___|_|\\_|_|\\_\\",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function Wordmark() {
|
|
11
|
+
return (
|
|
12
|
+
<Box flexDirection="column">
|
|
13
|
+
{WORDMARK.map((line) => (
|
|
14
|
+
<Text key={line} bold>
|
|
15
|
+
{line}
|
|
16
|
+
</Text>
|
|
17
|
+
))}
|
|
18
|
+
</Box>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function formatBytes(bytes: number): string {
|
|
2
|
+
if (bytes === 0) return "0 B";
|
|
3
|
+
const k = 1024;
|
|
4
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
5
|
+
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
|
6
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatDate(iso?: string): string {
|
|
10
|
+
if (!iso) return "—";
|
|
11
|
+
const date = new Date(iso);
|
|
12
|
+
if (Number.isNaN(date.getTime())) return "—";
|
|
13
|
+
return date.toISOString().slice(0, 10);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cleanLabel(label: string): string {
|
|
17
|
+
return label
|
|
18
|
+
.replace(/^🚀\s*/, "")
|
|
19
|
+
.replace(/^⚠️\s*/, "⚠ ")
|
|
20
|
+
.replace(/^✅\s*/, "")
|
|
21
|
+
.replace(/^❌\s*/, "");
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MenuChoice } from "../subcommands/menu/types";
|
|
2
|
+
import type { MenuStatus } from "./App";
|
|
3
|
+
import { cleanLabel } from "./format";
|
|
4
|
+
import { fetchAppLogs } from "./snapshot";
|
|
5
|
+
|
|
6
|
+
export function withLiveApps(tree: MenuChoice[], status: MenuStatus): MenuChoice[] {
|
|
7
|
+
return tree.map((item) => {
|
|
8
|
+
if (!item.subMenu || cleanLabel(item.label) !== "Hosting") return item;
|
|
9
|
+
const rest = item.subMenu.filter((choice) => cleanLabel(choice.label) !== "Apps");
|
|
10
|
+
const appsMenu: MenuChoice = {
|
|
11
|
+
label: "Apps",
|
|
12
|
+
subMenu:
|
|
13
|
+
status.apps.length > 0
|
|
14
|
+
? status.apps.map((app) => ({
|
|
15
|
+
label: app.name,
|
|
16
|
+
inspect: { kind: "app", id: app.id, url: app.url, createdAt: app.createdAt },
|
|
17
|
+
subMenu: [
|
|
18
|
+
{
|
|
19
|
+
label: "Logs",
|
|
20
|
+
action: () => fetchAppLogs(app.id),
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
}))
|
|
24
|
+
: [{ label: "No apps yet", action: async () => "No hosted apps." }],
|
|
25
|
+
};
|
|
26
|
+
return { ...item, subMenu: [appsMenu, ...rest] };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveStack(tree: MenuChoice[], titles: string[]): MenuChoice[][] {
|
|
31
|
+
const stack: MenuChoice[][] = [tree];
|
|
32
|
+
let current = tree;
|
|
33
|
+
for (let i = 1; i < titles.length; i += 1) {
|
|
34
|
+
const found = current.find((choice) => cleanLabel(choice.label) === titles[i]);
|
|
35
|
+
if (!found?.subMenu) break;
|
|
36
|
+
stack.push(found.subMenu);
|
|
37
|
+
current = found.subMenu;
|
|
38
|
+
}
|
|
39
|
+
return stack;
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import { MenuApp, type MenuOutcome, type MenuStatus } from "./App";
|
|
3
|
+
import type { MenuChoice } from "../subcommands/menu/types";
|
|
4
|
+
import { prepareStdinForPrompt } from "../subcommands/menu/io";
|
|
5
|
+
import { resolveStack, withLiveApps } from "./liveTree";
|
|
6
|
+
|
|
7
|
+
export async function runInkMenu(opts: {
|
|
8
|
+
tree: MenuChoice[];
|
|
9
|
+
getStatus: () => Promise<MenuStatus>;
|
|
10
|
+
}): Promise<void> {
|
|
11
|
+
let message = "";
|
|
12
|
+
let titles = ["UPLINK"];
|
|
13
|
+
let selected = 0;
|
|
14
|
+
|
|
15
|
+
while (true) {
|
|
16
|
+
const status = await opts.getStatus();
|
|
17
|
+
const tree = withLiveApps(opts.tree, status);
|
|
18
|
+
const stack = resolveStack(tree, titles);
|
|
19
|
+
let outcome: MenuOutcome = { kind: "quit" };
|
|
20
|
+
const instance = render(
|
|
21
|
+
<MenuApp
|
|
22
|
+
tree={tree}
|
|
23
|
+
status={status}
|
|
24
|
+
message={message}
|
|
25
|
+
initialStack={stack}
|
|
26
|
+
initialTitles={titles}
|
|
27
|
+
initialSelected={Math.min(selected, (stack[stack.length - 1]?.length || 1) - 1)}
|
|
28
|
+
onOutcome={(next) => {
|
|
29
|
+
outcome = next;
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
await instance.waitUntilExit();
|
|
34
|
+
instance.unmount();
|
|
35
|
+
prepareStdinForPrompt();
|
|
36
|
+
|
|
37
|
+
if (outcome.kind !== "action") return;
|
|
38
|
+
|
|
39
|
+
titles = outcome.titles;
|
|
40
|
+
selected = outcome.selected;
|
|
41
|
+
|
|
42
|
+
if (outcome.isExit) {
|
|
43
|
+
try {
|
|
44
|
+
await outcome.action();
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
message = (await outcome.action()) || "";
|
|
53
|
+
} catch (error) {
|
|
54
|
+
message = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|