extention: use github tunnel

Kim committed Mar 31, 2026 at 13:53 UTC 9ffc74beb31734ad4d13fe709b6e88c6b2078ae7
6 files changed +148 -83
extensions/vscode/CHANGELOG.md
+4 -1
@@ -4,13 +4,16 @@ All notable changes to the "portal" extension will be documented in this file.
4
5 Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
6
7 +## [0.0.3]
8 +
9 +- Download the tunnel binary directly from the latest GitHub release assets before execution
10 +
11 ## [0.0.2]
12
13 - Split quick start and advanced commands
14 - Make `Portal: Start Tunnel` prompt only for the local host
15 - Enforce `https://` relay URLs
16 - Generate a stable default service name in the extension when the name is empty
13 -- Use the installed Portal binary path after installer execution
17
18 ## [0.0.1]
19
extensions/vscode/README.md
+9 -2
@@ -8,13 +8,14 @@ Expose your local service to the internet via a [Portal](https://github.com/gosu
8 - `Portal: Start Tunnel (Advanced)` prompts for host, optional service name, relay source, and optional thumbnail
9 - `Portal: Stop Tunnel` stops the active tunnel terminal
10 - Persisted settings for relay URLs, default local host, and default service name
11 -- Uses the installed Portal binary path after running the installer, so first-run install + expose works in one terminal
11 +- Downloads the matching Portal tunnel binary from the latest GitHub release assets before starting the tunnel
12 - When no relay URL is configured, the extension can use the public registry at `https://raw.githubusercontent.com/gosuda/portal/main/registry.json`
13
14 ## Requirements
15
16 - A running [Portal relay server](https://github.com/gosuda/portal) with an `https://` URL
17 - `curl` on macOS/Linux, PowerShell on Windows
18 +- GitHub release asset access for `https://github.com/gosuda/portal/releases/latest/download/...`
19
20 ## Settings
21
@@ -43,6 +44,8 @@ Example `settings.json`:
44
45 Use `Portal: Start Tunnel (Advanced)` when you need a different host, custom relay selection, or a thumbnail URL.
46
47 +The extension downloads the platform-specific tunnel binary from the latest GitHub release assets and then runs `portal expose ...` with the selected relay settings.
48 +
49 To stop, run `Portal: Stop Tunnel` or close the `Portal Tunnel` terminal.
50
51 ## Development
@@ -65,9 +68,13 @@ If you want Linux behavior from WSL, open the folder with `Remote - WSL` first s
68
69 ## Release Notes
70
71 +### 0.0.3
72 +
73 +- Download the tunnel binary directly from the latest GitHub release assets before execution
74 +
75 ### 0.0.2
76
77 - Enforce `https://` relay URLs
78 - Prompt only for the local host in `Portal: Start Tunnel`
79 - Add `Portal: Start Tunnel (Advanced)` for host, name, relay, and thumbnail overrides
73 -- Use the installed Portal binary path after installer execution
80 +- Generate a stable default service name in the extension when the name is empty
extensions/vscode/package.json
+1 -1
@@ -3,7 +3,7 @@
3 "publisher": "gosuda",
4 "displayName": "Portal",
5 "description": "Expose local services to the internet via Portal relay tunnels",
6 - "version": "0.0.2",
6 + "version": "0.0.3",
7 "engines": {
8 "vscode": "^1.109.0"
9 },
extensions/vscode/src/command.ts
+74 -34
@@ -3,14 +3,20 @@ import * as os from "os";
3 export type ShellTarget = "unix" | "windows";
4
5 export const defaultRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json";
6 +export const defaultTunnelDownloadBaseURL = "https://github.com/gosuda/portal/releases/latest/download";
7
8 export interface TunnelCommandOptions {
9 host: string;
10 name: string;
11 relayList: string;
11 - relayUrl: string;
12 thumbnail: string;
13 - isLocal: boolean;
13 + tunnelBinaryURL?: 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 {
@@ -34,10 +40,54 @@ export function shellTargetForPlatform(platform = os.platform()): ShellTarget {
40 return platform === "win32" ? "windows" : "unix";
41 }
42
37 -export function buildCommand(opts: TunnelCommandOptions, target = shellTargetForPlatform()): string {
38 - const { host, name, relayList, relayUrl, thumbnail, isLocal } = opts;
39 - const installShellUrl = `${relayUrl}/install.sh`;
40 - const installPowerShellUrl = `${relayUrl}/install.ps1`;
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 resolveTunnelBinaryURL(
55 + platform = os.platform(),
56 + arch = os.arch()
57 +): string | undefined {
58 + const platformName =
59 + platform === "darwin" || platform === "linux"
60 + ? platform
61 + : platform === "win32"
62 + ? "windows"
63 + : undefined;
64 + const archName =
65 + arch === "x64"
66 + ? "amd64"
67 + : arch === "arm64"
68 + ? "arm64"
69 + : undefined;
70 + if (!platformName || !archName) {
71 + return undefined;
72 + }
73 + const extension = platformName === "windows" ? ".exe" : "";
74 + return `${defaultTunnelDownloadBaseURL}/portal-${platformName}-${archName}${extension}`;
75 +}
76 +
77 +export function buildCommand(
78 + opts: TunnelCommandOptions,
79 + runtime = defaultTunnelCommandRuntime()
80 +): string {
81 + const { host, name, relayList, thumbnail } = opts;
82 + const target = runtime.shellTarget;
83 + const tunnelBinaryURL =
84 + opts.tunnelBinaryURL?.trim() ||
85 + resolveTunnelBinaryURL(runtime.platform, runtime.arch);
86 + if (!tunnelBinaryURL) {
87 + throw new Error(
88 + `Unsupported platform ${runtime.platform}/${runtime.arch}. Portal supports macOS, Linux, and Windows on x64 or arm64.`
89 + );
90 + }
91 const exposeArgs: string[] = [];
92
93 const trimmedName = name.trim();
@@ -54,41 +104,31 @@ export function buildCommand(opts: TunnelCommandOptions, target = shellTargetFor
104 const exposeCommand = `expose ${[formatToken(host, target), ...exposeArgs].join(" ")}`;
105
106 if (target === "windows") {
57 - const commandLines = [`$ProgressPreference = 'SilentlyContinue'`];
58 - if (relayUrl.trim()) {
59 - commandLines.push(`irm ${formatToken(installPowerShellUrl, target)} | iex`);
60 - }
61 - commandLines.push(`$PortalBin = Join-Path $env:LOCALAPPDATA 'portal\\bin\\portal.exe'`);
62 - commandLines.push(`if (-not (Test-Path $PortalBin)) { throw "portal CLI not found. Install from a relay first or configure portal.relayUrls." }`);
107 + const commandLines = [
108 + `$ProgressPreference = 'SilentlyContinue'`,
109 + `$PortalDir = Join-Path $env:LOCALAPPDATA 'portal\\bin'`,
110 + `$PortalBin = Join-Path $PortalDir 'portal.exe'`,
111 + `$PortalTmp = Join-Path $PortalDir 'portal.exe.download'`,
112 + `New-Item -ItemType Directory -Force -Path $PortalDir | Out-Null`,
113 + `Invoke-WebRequest -Uri ${formatToken(tunnelBinaryURL, target)} -OutFile $PortalTmp`,
114 + `Move-Item -Force $PortalTmp $PortalBin`,
115 + ];
116 commandLines.push(`& $PortalBin ${exposeCommand}`);
117 return commandLines.join("\n");
118 }
119
67 - const commandLines: string[] = [];
68 - if (relayUrl.trim()) {
69 - const curlFlags = isLocal ? "-kfsSL" : "-fsSL";
70 - commandLines.push(`curl ${curlFlags} ${formatToken(installShellUrl, target)} | bash`);
71 - }
72 - commandLines.push(`PORTAL_BIN="$(command -v portal 2>/dev/null || true)"`);
73 - commandLines.push(`if [ -z "$PORTAL_BIN" ]; then`);
74 - commandLines.push(` for candidate in "$HOME/.local/bin/portal" "$HOME/bin/portal"; do`);
75 - commandLines.push(` if [ -x "$candidate" ]; then PORTAL_BIN="$candidate"; break; fi`);
76 - commandLines.push(` done`);
77 - commandLines.push(`fi`);
78 - commandLines.push(`if [ -z "$PORTAL_BIN" ]; then echo "portal CLI not found. Install from a relay first or configure portal.relayUrls." >&2; exit 1; fi`);
79 - commandLines.push(`"${"$"}PORTAL_BIN" ${exposeCommand}`);
120 + const commandLines = [
121 + `PORTAL_BIN="$HOME/.local/bin/portal"`,
122 + `PORTAL_TMP="$PORTAL_BIN.download"`,
123 + `mkdir -p "$(dirname "$PORTAL_BIN")"`,
124 + `curl -fsSL ${formatToken(tunnelBinaryURL, target)} -o "$PORTAL_TMP"`,
125 + `chmod +x "$PORTAL_TMP"`,
126 + `mv "$PORTAL_TMP" "$PORTAL_BIN"`,
127 + `"${"$"}PORTAL_BIN" ${exposeCommand}`,
128 + ];
129 return commandLines.join("\n");
130 }
131
83 -export function isLocalhost(url: string): boolean {
84 - try {
85 - const h = new URL(url).hostname.toLowerCase();
86 - return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
87 - } catch {
88 - return false;
89 - }
90 -}
91 -
132 function quoteShellValue(value: string): string {
133 return "'" + value.replace(/'/g, `'\"'\"'`) + "'";
134 }
extensions/vscode/src/extension.ts
+18 -27
@@ -3,7 +3,6 @@ import * as vscode from "vscode";
3 import {
4 buildCommand,
5 defaultRelayRegistryURL,
6 - isLocalhost,
6 validateRelayUrl,
7 } from "./command";
8
@@ -12,7 +11,6 @@ const defaultTunnelHost = "localhost:3000";
11
12 interface RelaySelection {
13 relayUrls: string[];
15 - installRelayUrl: string;
14 }
15
16 export function activate(context: vscode.ExtensionContext) {
@@ -74,14 +72,19 @@ function runTunnelCommand(args: {
72 relaySelection: RelaySelection;
73 thumbnail: string;
74 }) {
77 - const command = buildCommand({
78 - host: args.host,
79 - name: args.name,
80 - relayList: args.relaySelection.relayUrls.join(","),
81 - relayUrl: args.relaySelection.installRelayUrl,
82 - thumbnail: args.thumbnail,
83 - isLocal: isLocalhost(args.relaySelection.installRelayUrl),
84 - });
75 + let command: string;
76 + try {
77 + command = buildCommand({
78 + host: args.host,
79 + name: args.name,
80 + relayList: args.relaySelection.relayUrls.join(","),
81 + thumbnail: args.thumbnail,
82 + });
83 + } catch (error) {
84 + const message = error instanceof Error ? error.message : "Failed to build the Portal tunnel command.";
85 + vscode.window.showErrorMessage(message);
86 + return;
87 + }
88
89 if (tunnelTerminal) {
90 tunnelTerminal.dispose();
@@ -132,17 +135,11 @@ async function resolveRelaySelection(interactive: boolean): Promise<RelaySelecti
135 return undefined;
136 }
137 const relayUrls = saved.map((url) => url.trim());
135 - return {
136 - relayUrls,
137 - installRelayUrl: relayUrls[0],
138 - };
138 + return { relayUrls };
139 }
140
141 if (!interactive) {
142 - return {
143 - relayUrls: [],
144 - installRelayUrl: "",
145 - };
142 + return { relayUrls: [] };
143 }
144
145 const choice = await vscode.window.showQuickPick([
@@ -152,7 +149,7 @@ async function resolveRelaySelection(interactive: boolean): Promise<RelaySelecti
149 },
150 {
151 label: "Enter relay URL",
155 - description: "Install from a specific https:// relay",
152 + description: "Connect the tunnel to a specific https:// relay",
153 },
154 ], {
155 title: "Portal: Relay Source",
@@ -163,10 +160,7 @@ async function resolveRelaySelection(interactive: boolean): Promise<RelaySelecti
160 }
161 if (choice.label === "Use default public registry") {
162 vscode.window.showInformationMessage(`Portal will use the default public registry: ${defaultRelayRegistryURL}`);
166 - return {
167 - relayUrls: [],
168 - installRelayUrl: "",
169 - };
163 + return { relayUrls: [] };
164 }
165
166 const input = await vscode.window.showInputBox({
@@ -178,10 +172,7 @@ async function resolveRelaySelection(interactive: boolean): Promise<RelaySelecti
172 return undefined;
173 }
174 const relayUrl = input.trim();
181 - return {
182 - relayUrls: [relayUrl],
183 - installRelayUrl: relayUrl,
184 - };
175 + return { relayUrls: [relayUrl] };
176 }
177
178 async function promptThumbnail(): Promise<string | undefined> {
extensions/vscode/src/test/extension.test.ts
+42 -18
@@ -1,6 +1,10 @@
1 import * as assert from "assert";
2
3 -import { buildCommand, validateRelayUrl } from "../command";
3 +import {
4 + buildCommand,
5 + resolveTunnelBinaryURL,
6 + validateRelayUrl,
7 +} from "../command";
8
9 suite("Extension Test Suite", () => {
10 test("validateRelayUrl accepts only https URLs", () => {
@@ -9,18 +13,33 @@ suite("Extension Test Suite", () => {
13 assert.strictEqual(validateRelayUrl("not-a-url"), "Enter a valid https:// URL");
14 });
15
12 - test("buildCommand omits --name when empty and resolves unix portal binary after install", () => {
16 + test("resolveTunnelBinaryURL maps darwin amd64 assets to GitHub releases", () => {
17 + assert.strictEqual(
18 + resolveTunnelBinaryURL("darwin", "x64"),
19 + "https://github.com/gosuda/portal/releases/latest/download/portal-darwin-amd64"
20 + );
21 + assert.strictEqual(
22 + resolveTunnelBinaryURL("win32", "arm64"),
23 + "https://github.com/gosuda/portal/releases/latest/download/portal-windows-arm64.exe"
24 + );
25 + assert.strictEqual(resolveTunnelBinaryURL("freebsd", "x64"), undefined);
26 + });
27 +
28 + test("buildCommand omits --name when empty and downloads the unix tunnel binary directly", () => {
29 const command = buildCommand({
30 host: "localhost:3000",
31 name: "",
32 relayList: "https://relay.example.com",
17 - relayUrl: "https://relay.example.com",
33 thumbnail: "",
19 - isLocal: false,
20 - }, "unix");
34 + tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-linux-amd64",
35 + }, {
36 + shellTarget: "unix",
37 + platform: "linux",
38 + arch: "x64",
39 + });
40
22 - assert.match(command, /curl -fsSL https:\/\/relay\.example\.com\/install\.sh \| bash/);
23 - assert.match(command, /PORTAL_BIN="\$\(command -v portal 2>\/dev\/null \|\| true\)"/);
41 + assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-linux-amd64 -o "\$PORTAL_TMP"/);
42 + assert.match(command, /PORTAL_BIN="\$HOME\/\.local\/bin\/portal"/);
43 assert.match(command, /"\$PORTAL_BIN" expose localhost:3000 --relays https:\/\/relay\.example\.com/);
44 assert.ok(!command.includes("--name"));
45 });
@@ -30,30 +49,35 @@ suite("Extension Test Suite", () => {
49 host: "localhost:3000",
50 name: "",
51 relayList: "",
33 - relayUrl: "",
52 thumbnail: "",
35 - isLocal: false,
36 - }, "unix");
53 + tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-linux-amd64",
54 + }, {
55 + shellTarget: "unix",
56 + platform: "linux",
57 + arch: "x64",
58 + });
59
38 - assert.ok(!command.includes("/install.sh"));
60 + assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-linux-amd64 -o "\$PORTAL_TMP"/);
61 assert.ok(!command.includes("--relays"));
62 assert.ok(!command.includes("--name"));
41 - assert.match(command, /portal CLI not found\. Install from a relay first or configure portal\.relayUrls\./);
63 assert.match(command, /"\$PORTAL_BIN" expose localhost:3000/);
64 });
65
45 - test("buildCommand uses explicit portal.exe path on windows", () => {
66 + test("buildCommand downloads portal.exe on windows", () => {
67 const command = buildCommand({
68 host: "localhost:3000",
69 name: "my-app",
70 relayList: "https://relay.example.com",
50 - relayUrl: "https://relay.example.com",
71 thumbnail: "https://example.com/thumb.png",
52 - isLocal: false,
53 - }, "windows");
72 + tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-windows-amd64.exe",
73 + }, {
74 + shellTarget: "windows",
75 + platform: "win32",
76 + arch: "x64",
77 + });
78
55 - assert.match(command, /irm https:\/\/relay\.example\.com\/install\.ps1 \| iex/);
56 - assert.match(command, /\$PortalBin = Join-Path \$env:LOCALAPPDATA 'portal\\bin\\portal\.exe'/);
79 + assert.match(command, /Invoke-WebRequest -Uri https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-windows-amd64\.exe -OutFile \$PortalTmp/);
80 + assert.match(command, /\$PortalBin = Join-Path \$PortalDir 'portal\.exe'/);
81 assert.match(command, /& \$PortalBin expose localhost:3000 --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png/);
82 });
83 });