| 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 | } |