main
ts 138 lines 4.24 KB
Raw
1 import * as os from "os";
2
3 export type ShellTarget = "unix" | "windows";
4
5 export const defaultRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json";
6 export const defaultTunnelDownloadBaseURL = "https://github.com/gosuda/portal-tunnel/releases/latest/download";
7
8 export interface TunnelCommandOptions {
9 host: string;
10 name: string;
11 relayList: string;
12 thumbnail: string;
13 tunnelInstallerURL?: string;
14 }
15
16 export interface TunnelCommandRuntime {
17 shellTarget: ShellTarget;
18 platform: NodeJS.Platform;
19 arch: string;
20 }
21
22 export function validateRelayUrl(value: string): string | undefined {
23 const trimmed = value.trim();
24 if (!trimmed) {
25 return "Required";
26 }
27 let parsed: URL;
28 try {
29 parsed = new URL(trimmed);
30 } catch {
31 return "Enter a valid https:// URL";
32 }
33 if (parsed.protocol !== "https:") {
34 return "Portal relay URLs must use https://";
35 }
36 return undefined;
37 }
38
39 export function shellTargetForPlatform(platform = os.platform()): ShellTarget {
40 return platform === "win32" ? "windows" : "unix";
41 }
42
43 export function defaultTunnelCommandRuntime(
44 platform = os.platform(),
45 arch = os.arch()
46 ): TunnelCommandRuntime {
47 return {
48 shellTarget: shellTargetForPlatform(platform),
49 platform,
50 arch,
51 };
52 }
53
54 export function resolveTunnelInstallerURL(
55 platform = os.platform(),
56 arch = os.arch()
57 ): string | undefined {
58 if (arch !== "x64" && arch !== "arm64") {
59 return undefined;
60 }
61 if (platform === "darwin" || platform === "linux") {
62 return `${defaultTunnelDownloadBaseURL}/install.sh`;
63 }
64 if (platform === "win32") {
65 return `${defaultTunnelDownloadBaseURL}/install.ps1`;
66 }
67 return undefined;
68 }
69
70 export function buildCommand(
71 opts: TunnelCommandOptions,
72 runtime = defaultTunnelCommandRuntime()
73 ): string {
74 const { host, name, relayList, thumbnail } = opts;
75 const target = runtime.shellTarget;
76 const tunnelInstallerURL =
77 opts.tunnelInstallerURL?.trim() ||
78 resolveTunnelInstallerURL(runtime.platform, runtime.arch);
79 if (!tunnelInstallerURL) {
80 throw new Error(
81 `Unsupported platform ${runtime.platform}/${runtime.arch}. Portal supports macOS, Linux, and Windows on x64 or arm64.`
82 );
83 }
84 const exposeArgs: string[] = [];
85
86 const trimmedName = name.trim();
87 if (trimmedName) {
88 exposeArgs.push(`--name ${formatToken(trimmedName, target)}`);
89 }
90 if (relayList.trim()) {
91 exposeArgs.push(`--relays ${formatToken(relayList, target)}`);
92 }
93 if (thumbnail.trim()) {
94 exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`);
95 }
96
97 const exposeCommand = `expose ${[formatToken(host, target), ...exposeArgs].join(" ")}`;
98
99 if (target === "windows") {
100 const commandLines = [
101 `$ProgressPreference = 'SilentlyContinue'`,
102 `irm ${formatToken(tunnelInstallerURL, target)} | iex`,
103 `$PortalBin = Join-Path $env:LOCALAPPDATA 'portal\\bin\\portal.exe'`,
104 `if (-not (Test-Path $PortalBin)) { throw 'Portal install failed: portal.exe not found.' }`,
105 ];
106 commandLines.push(`& $PortalBin ${exposeCommand}`);
107 return commandLines.join("\n");
108 }
109
110 const commandLines = [
111 `set -e`,
112 `PORTAL_INSTALLER="$(mktemp "${"$"}{TMPDIR:-/tmp}/portal-install.XXXXXX" 2>/dev/null || mktemp -t portal-install)"`,
113 `curl -fsSL ${formatToken(tunnelInstallerURL, target)} -o "$PORTAL_INSTALLER"`,
114 `sh "$PORTAL_INSTALLER"`,
115 `rm -f "$PORTAL_INSTALLER"`,
116 `PORTAL_BIN="$(command -v portal 2>/dev/null || true)"`,
117 `if [ -z "$PORTAL_BIN" ] && [ -x "$HOME/.local/bin/portal" ]; then PORTAL_BIN="$HOME/.local/bin/portal"; fi`,
118 `if [ -z "$PORTAL_BIN" ] && [ -x "$HOME/bin/portal" ]; then PORTAL_BIN="$HOME/bin/portal"; fi`,
119 `if [ -z "$PORTAL_BIN" ]; then echo "Portal install failed: portal executable not found." >&2; exit 1; fi`,
120 `"${"$"}PORTAL_BIN" ${exposeCommand}`,
121 ];
122 return commandLines.join("\n");
123 }
124
125 function quoteShellValue(value: string): string {
126 return "'" + value.replace(/'/g, `'\"'\"'`) + "'";
127 }
128
129 function quotePowerShellValue(value: string): string {
130 return `'${value.replace(/'/g, "''")}'`;
131 }
132
133 function formatToken(value: string, target: ShellTarget): string {
134 if (/^[A-Za-z0-9:/.=_,-]+$/.test(value)) {
135 return value;
136 }
137 return target === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
138 }