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