add vscode extension v0.0.2

Kim committed Mar 13, 2026 at 15:00 UTC 3eeecec0f5f99c76d92e490ca5044d4db164dd54
11 files changed +368 -199
extensions/vscode/.vscode/launch.json
+1 -2
@@ -14,8 +14,7 @@
14 ],
15 "outFiles": [
16 "${workspaceFolder}/dist/**/*.js"
17 - ],
18 - "preLaunchTask": "${defaultBuildTask}"
17 + ]
18 }
19 ]
20 }
extensions/vscode/.vscode/settings.json
+1 -1
@@ -9,5 +9,5 @@
9 "dist": true // set this to false to include "dist" folder in search results
10 },
11 // Turn off tsc task auto detection since we have the necessary tasks as npm scripts
12 - "typescript.tsc.autoDetect": "off"
12 + "js/ts.tsc.autoDetect": "off"
13 }
\ No newline at end of file
extensions/vscode/.vscode/tasks.json deleted
-43
@@ -1,43 +0,0 @@
1 -{
2 - "version": "2.0.0",
3 - "tasks": [
4 - {
5 - "label": "watch",
6 - "dependsOn": [
7 - "watch:tsc",
8 - "watch:esbuild"
9 - ],
10 - "presentation": {
11 - "reveal": "never"
12 - },
13 - "group": {
14 - "kind": "build",
15 - "isDefault": true
16 - }
17 - },
18 - {
19 - "type": "shell",
20 - "command": "pnpm run watch:esbuild",
21 - "group": "build",
22 - "problemMatcher": "$esbuild-watch",
23 - "isBackground": true,
24 - "label": "watch:esbuild",
25 - "presentation": {
26 - "group": "watch",
27 - "reveal": "never"
28 - }
29 - },
30 - {
31 - "type": "shell",
32 - "command": "pnpm run watch:tsc",
33 - "group": "build",
34 - "problemMatcher": "$tsc-watch",
35 - "isBackground": true,
36 - "label": "watch:tsc",
37 - "presentation": {
38 - "group": "watch",
39 - "reveal": "never"
40 - }
41 - }
42 - ]
43 -}
extensions/vscode/CHANGELOG.md
+10 -2
@@ -4,6 +4,14 @@ 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 -## [Unreleased]
7 +## [0.0.2]
8
9 -- Initial release
\ No newline at end of file
9 +- Split quick start and advanced commands
10 +- Make `Portal: Start Tunnel` prompt only for the local host
11 +- Enforce `https://` relay URLs
12 +- Allow empty service names so the CLI can auto-generate them
13 +- Use the installed Portal binary path after installer execution
14 +
15 +## [0.0.1]
16 +
17 +- Initial release
extensions/vscode/README.md
+34 -18
@@ -1,26 +1,28 @@
1 -# Portal — VSCode Extension
1 +# Portal VSCode Extension
2
3 -Expose your local service to the internet via a [Portal](https://github.com/gosuda/portal) relay tunnel, directly from VSCode — no terminal copy-paste needed.
3 +Expose your local service to the internet via a [Portal](https://github.com/gosuda/portal) relay tunnel, directly from VSCode.
4
5 ## Features
6
7 -- **Portal: Start Tunnel** — prompts for host, service name, relay URL, and optional thumbnail, then runs the tunnel command in the integrated terminal
8 -- **Portal: Stop Tunnel** — stops the active tunnel terminal
9 -- Persisted settings for relay URLs, default host, and default service name
10 -- Auto-detects OS (macOS/Linux uses `curl`, Windows uses PowerShell)
7 +- `Portal: Start Tunnel` prompts only for the local host:port, then starts the tunnel with configured relay URLs or the default public registry
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
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
14 -- A running [Portal relay server](https://github.com/gosuda/portal) (self-hosted or public)
16 +- A running [Portal relay server](https://github.com/gosuda/portal) with an `https://` URL
17 - `curl` on macOS/Linux, PowerShell on Windows
18
19 ## Settings
20
21 | Setting | Default | Description |
22 |---|---|---|
21 -| `portal.relayUrls` | `[]` | Relay server URLs. If empty, prompted on each start. |
22 -| `portal.defaultHost` | `localhost:3000` | Default local host:port to expose. |
23 -| `portal.defaultName` | `""` | Default tunnel service name. Falls back to workspace folder name. |
23 +| `portal.relayUrls` | `[]` | Relay server URLs (`https://` only). If empty, the extension uses `https://raw.githubusercontent.com/gosuda/portal/main/registry.json`. |
24 +| `portal.defaultHost` | `"localhost:3000"` | Default local host:port shown by `Portal: Start Tunnel`. |
25 +| `portal.defaultName` | `""` | Default tunnel service name suggestion. If empty, the advanced prompt starts blank. |
26
27 Example `settings.json`:
28
@@ -35,24 +37,38 @@ Example `settings.json`:
37 ## Usage
38
39 1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`)
38 -2. Run **Portal: Start Tunnel**
39 -3. Fill in the prompts (host, name, relay URL, thumbnail URL — all pre-filled from settings)
40 -4. Tunnel starts in the integrated terminal and prints the public URL
40 +2. Run `Portal: Start Tunnel`
41 +3. Enter the local host:port you want to expose
42 +4. The tunnel starts in the integrated terminal
43
42 -To stop: run **Portal: Stop Tunnel** or close the `Portal Tunnel` terminal.
44 +Use `Portal: Start Tunnel (Advanced)` when you need a different host, custom relay selection, or a thumbnail URL.
45 +
46 +To stop, run `Portal: Stop Tunnel` or close the `Portal Tunnel` terminal.
47
48 ## Development
49
50 ```bash
51 git clone https://github.com/gosuda/portal
48 -cd portal/extensions/vscode/portal
49 -pnpm install
52 +cd portal/extensions/vscode
53 +corepack pnpm install --frozen-lockfile
54 ```
55
56 Open the folder in VSCode, then press `F5` to launch the Extension Development Host.
57
58 +To test the extension locally:
59 +
60 +1. Open `extensions/vscode` in VSCode.
61 +2. Press `F5` and choose `Run Extension`.
62 +3. In the new Extension Development Host window, run `Portal: Start Tunnel` or `Portal: Start Tunnel (Advanced)` from the Command Palette.
63 +
64 +If you want Linux behavior from WSL, open the folder with `Remote - WSL` first so the extension host runs in WSL instead of Windows.
65 +
66 ## Release Notes
67
56 -### 0.0.1
68 +### 0.0.2
69
58 -Initial release — Start/Stop Tunnel commands with thumbnail support.
70 +- Enforce `https://` relay URLs
71 +- Prompt only for the local host in `Portal: Start Tunnel`
72 +- Add `Portal: Start Tunnel (Advanced)` for host, name, relay, and thumbnail overrides
73 +- Allow empty service names so the CLI can auto-generate them
74 +- Use the installed Portal binary path after installer execution
extensions/vscode/esbuild.js
+18 -21
@@ -1,46 +1,43 @@
1 const esbuild = require("esbuild");
2
3 -const production = process.argv.includes('--production');
4 -const watch = process.argv.includes('--watch');
3 +const production = process.argv.includes("--production");
4 +const watch = process.argv.includes("--watch");
5
6 /**
7 * @type {import('esbuild').Plugin}
8 */
9 const esbuildProblemMatcherPlugin = {
10 - name: 'esbuild-problem-matcher',
10 + name: "esbuild-problem-matcher",
11
12 setup(build) {
13 build.onStart(() => {
14 - console.log('[watch] build started');
14 + console.log("[watch] build started");
15 });
16 build.onEnd((result) => {
17 result.errors.forEach(({ text, location }) => {
18 - console.error(`✘ [ERROR] ${text}`);
19 - console.error(` ${location.file}:${location.line}:${location.column}:`);
18 + console.error(`[esbuild] ERROR ${text}`);
19 + if (location) {
20 + console.error(` ${location.file}:${location.line}:${location.column}:`);
21 + }
22 });
21 - console.log('[watch] build finished');
23 + console.log("[watch] build finished");
24 });
25 },
26 };
27
28 async function main() {
29 const ctx = await esbuild.context({
28 - entryPoints: [
29 - 'src/extension.ts'
30 - ],
30 + entryPoints: ["src/extension.ts"],
31 bundle: true,
32 - format: 'cjs',
32 + format: "cjs",
33 minify: production,
34 sourcemap: !production,
35 sourcesContent: false,
36 - platform: 'node',
37 - outfile: 'dist/extension.js',
38 - external: ['vscode'],
39 - logLevel: 'silent',
40 - plugins: [
41 - /* add to the end of plugins array */
42 - esbuildProblemMatcherPlugin,
43 - ],
36 + platform: "node",
37 + outfile: "dist/extension.js",
38 + external: ["vscode"],
39 + logLevel: "silent",
40 + plugins: [esbuildProblemMatcherPlugin],
41 });
42 if (watch) {
43 await ctx.watch();
@@ -50,7 +47,7 @@ async function main() {
47 }
48 }
49
53 -main().catch(e => {
54 - console.error(e);
50 +main().catch((error) => {
51 + console.error(error);
52 process.exit(1);
53 });
extensions/vscode/package.json
+18 -10
@@ -1,15 +1,18 @@
1 {
2 - "name": "portal",
2 + "name": "portal-tunnel",
3 + "publisher": "gosuda",
4 "displayName": "Portal",
5 "description": "Expose local services to the internet via Portal relay tunnels",
5 - "version": "0.0.1",
6 + "version": "0.0.2",
7 "engines": {
8 "vscode": "^1.109.0"
9 },
10 + "extensionKind": [
11 + "workspace"
12 + ],
13 "categories": [
14 "Other"
15 ],
12 - "activationEvents": [],
16 "main": "./dist/extension.js",
17 "contributes": {
18 "commands": [
@@ -18,6 +21,11 @@
21 "title": "Start Tunnel",
22 "category": "Portal"
23 },
24 + {
25 + "command": "portal.startTunnelAdvanced",
26 + "title": "Start Tunnel (Advanced)",
27 + "category": "Portal"
28 + },
29 {
30 "command": "portal.stopTunnel",
31 "title": "Stop Tunnel",
@@ -31,31 +39,31 @@
39 "type": "array",
40 "items": { "type": "string" },
41 "default": [],
34 - "description": "Relay server URLs (e.g. https://my-relay.example.com). Leave empty to be prompted each time."
42 + "description": "Relay server URLs (https only, e.g. https://my-relay.example.com). Leave empty to use the public registry at https://raw.githubusercontent.com/gosuda/portal/main/registry.json."
43 },
44 "portal.defaultHost": {
45 "type": "string",
46 "default": "localhost:3000",
39 - "description": "Default host:port of the local service to expose."
47 + "description": "Default local host:port suggested when starting a tunnel."
48 },
49 "portal.defaultName": {
50 "type": "string",
51 "default": "",
44 - "description": "Default tunnel service name. Leave empty to use the workspace folder name."
52 + "description": "Default tunnel service name suggestion. Leave empty to start with a blank prompt."
53 }
54 }
55 }
56 },
57 "scripts": {
50 - "vscode:prepublish": "pnpm run package",
51 - "compile": "pnpm run check-types && pnpm run lint && node esbuild.js",
58 + "vscode:prepublish": "npm run package",
59 + "compile": "npm run check-types && npm run lint && node esbuild.js",
60 "watch": "npm-run-all -p watch:*",
61 "watch:esbuild": "node esbuild.js --watch",
62 "watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
55 - "package": "pnpm run check-types && pnpm run lint && node esbuild.js --production",
63 + "package": "npm run check-types && npm run lint && node esbuild.js --production",
64 "compile-tests": "tsc -p . --outDir out",
65 "watch-tests": "tsc -p . -w --outDir out",
58 - "pretest": "pnpm run compile-tests && pnpm run compile && pnpm run lint",
66 + "pretest": "npm run compile-tests && npm run compile && npm run lint",
67 "check-types": "tsc --noEmit",
68 "lint": "eslint src",
69 "test": "vscode-test"
extensions/vscode/src/command.ts new
+104
@@ -0,0 +1,104 @@
1 +import * as os from "os";
2 +
3 +export type ShellTarget = "unix" | "windows";
4 +
5 +export const defaultRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json";
6 +
7 +export interface TunnelCommandOptions {
8 + host: string;
9 + name: string;
10 + relayList: string;
11 + relayUrl: string;
12 + thumbnail: string;
13 + isLocal: boolean;
14 +}
15 +
16 +export function validateRelayUrl(value: string): string | undefined {
17 + const trimmed = value.trim();
18 + if (!trimmed) {
19 + return "Required";
20 + }
21 + let parsed: URL;
22 + try {
23 + parsed = new URL(trimmed);
24 + } catch {
25 + return "Enter a valid https:// URL";
26 + }
27 + if (parsed.protocol !== "https:") {
28 + return "Portal relay URLs must use https://";
29 + }
30 + return undefined;
31 +}
32 +
33 +export function shellTargetForPlatform(platform = os.platform()): ShellTarget {
34 + return platform === "win32" ? "windows" : "unix";
35 +}
36 +
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`;
41 + const exposeArgs: string[] = [];
42 +
43 + if (name.trim()) {
44 + exposeArgs.push(`--name ${formatToken(name.trim(), target)}`);
45 + }
46 + if (relayList.trim()) {
47 + exposeArgs.push(`--relays ${formatToken(relayList, target)}`);
48 + }
49 + if (thumbnail.trim()) {
50 + exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`);
51 + }
52 +
53 + const exposeCommand = `expose ${[...exposeArgs, formatToken(host, target)].join(" ")}`;
54 +
55 + if (target === "windows") {
56 + const commandLines = [`$ProgressPreference = 'SilentlyContinue'`];
57 + if (relayUrl.trim()) {
58 + commandLines.push(`irm ${formatToken(installPowerShellUrl, target)} | iex`);
59 + }
60 + commandLines.push(`$PortalBin = Join-Path $env:LOCALAPPDATA 'portal\\bin\\portal.exe'`);
61 + commandLines.push(`if (-not (Test-Path $PortalBin)) { throw "portal CLI not found. Install from a relay first or configure portal.relayUrls." }`);
62 + commandLines.push(`& $PortalBin ${exposeCommand}`);
63 + return commandLines.join("\n");
64 + }
65 +
66 + const commandLines: string[] = [];
67 + if (relayUrl.trim()) {
68 + const curlFlags = isLocal ? "-kfsSL" : "-fsSL";
69 + commandLines.push(`curl ${curlFlags} ${formatToken(installShellUrl, target)} | bash`);
70 + }
71 + commandLines.push(`PORTAL_BIN="$(command -v portal 2>/dev/null || true)"`);
72 + commandLines.push(`if [ -z "$PORTAL_BIN" ]; then`);
73 + commandLines.push(` for candidate in "$HOME/.local/bin/portal" "$HOME/bin/portal"; do`);
74 + commandLines.push(` if [ -x "$candidate" ]; then PORTAL_BIN="$candidate"; break; fi`);
75 + commandLines.push(` done`);
76 + commandLines.push(`fi`);
77 + 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`);
78 + commandLines.push(`"${"$"}PORTAL_BIN" ${exposeCommand}`);
79 + return commandLines.join("\n");
80 +}
81 +
82 +export function isLocalhost(url: string): boolean {
83 + try {
84 + const h = new URL(url).hostname.toLowerCase();
85 + return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
86 + } catch {
87 + return false;
88 + }
89 +}
90 +
91 +function quoteShellValue(value: string): string {
92 + return "'" + value.replace(/'/g, `'\"'\"'`) + "'";
93 +}
94 +
95 +function quotePowerShellValue(value: string): string {
96 + return `'${value.replace(/'/g, "''")}'`;
97 +}
98 +
99 +function formatToken(value: string, target: ShellTarget): string {
100 + if (/^[A-Za-z0-9:/.=_,-]+$/.test(value)) {
101 + return value;
102 + }
103 + return target === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
104 +}
extensions/vscode/src/extension.ts
+107 -88
@@ -1,11 +1,24 @@
1 import * as vscode from "vscode";
2 -import * as os from "os";
2 +
3 +import {
4 + buildCommand,
5 + defaultRelayRegistryURL,
6 + isLocalhost,
7 + validateRelayUrl,
8 +} from "./command";
9
10 let tunnelTerminal: vscode.Terminal | undefined;
11 +const defaultTunnelHost = "localhost:3000";
12 +
13 +interface RelaySelection {
14 + relayUrls: string[];
15 + installRelayUrl: string;
16 +}
17
18 export function activate(context: vscode.ExtensionContext) {
19 context.subscriptions.push(
20 vscode.commands.registerCommand("portal.startTunnel", startTunnel),
21 + vscode.commands.registerCommand("portal.startTunnelAdvanced", startTunnelAdvanced),
22 vscode.commands.registerCommand("portal.stopTunnel", stopTunnel),
23 vscode.window.onDidCloseTerminal((t) => {
24 if (t === tunnelTerminal) {
@@ -23,23 +36,51 @@ async function startTunnel() {
36 const host = await promptHost();
37 if (!host) { return; }
38
39 + const relaySelection = await resolveRelaySelection(false);
40 + if (!relaySelection) { return; }
41 +
42 + runTunnelCommand({
43 + host,
44 + name: "",
45 + relaySelection,
46 + thumbnail: "",
47 + });
48 +}
49 +
50 +async function startTunnelAdvanced() {
51 + const host = await promptHost();
52 + if (!host) { return; }
53 +
54 const name = await promptName();
27 - if (!name) { return; }
55 + if (name === undefined) { return; }
56
29 - const relayUrls = await promptRelayUrls();
30 - if (!relayUrls) { return; }
57 + const relaySelection = await resolveRelaySelection(true);
58 + if (!relaySelection) { return; }
59
60 const thumbnail = await promptThumbnail();
61 if (thumbnail === undefined) { return; }
62
35 - const relayUrl = relayUrls[0];
36 - const command = buildCommand({
63 + runTunnelCommand({
64 host,
65 name,
39 - relayList: relayUrls.join(","),
40 - relayUrl,
66 + relaySelection,
67 thumbnail,
42 - isLocal: isLocalhost(relayUrl),
68 + });
69 +}
70 +
71 +function runTunnelCommand(args: {
72 + host: string;
73 + name: string;
74 + relaySelection: RelaySelection;
75 + thumbnail: string;
76 +}) {
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 });
85
86 if (tunnelTerminal) {
@@ -62,7 +103,7 @@ function stopTunnel() {
103
104 async function promptHost(): Promise<string | undefined> {
105 const config = vscode.workspace.getConfiguration("portal");
65 - const defaultHost = config.get<string>("defaultHost") ?? "localhost:3000";
106 + const defaultHost = config.get<string>("defaultHost") ?? defaultTunnelHost;
107 return vscode.window.showInputBox({
108 title: "Portal: Local Host",
109 prompt: "Hostname or IP:Port where your service is running",
@@ -73,29 +114,74 @@ async function promptHost(): Promise<string | undefined> {
114
115 async function promptName(): Promise<string | undefined> {
116 const config = vscode.workspace.getConfiguration("portal");
76 - const workspaceName = vscode.workspace.workspaceFolders?.[0]?.name ?? "my-app";
77 - const defaultName = config.get<string>("defaultName") || workspaceName;
117 + const defaultName = config.get<string>("defaultName") ?? "";
118 return vscode.window.showInputBox({
119 title: "Portal: Service Name",
80 - prompt: "Unique identifier for your tunnel",
120 + prompt: "Optional public hostname prefix. Leave empty to let the CLI auto-generate one.",
121 value: defaultName,
82 - validateInput: (v) => (v.trim() ? undefined : "Required"),
122 });
123 }
124
86 -async function promptRelayUrls(): Promise<string[] | undefined> {
125 +async function resolveRelaySelection(interactive: boolean): Promise<RelaySelection | undefined> {
126 const config = vscode.workspace.getConfiguration("portal");
127 const saved = config.get<string[]>("relayUrls") ?? [];
89 - if (saved.length > 0) { return saved; }
128 + if (saved.length > 0) {
129 + const invalid = saved.find((url) => validateRelayUrl(url) !== undefined);
130 + if (invalid) {
131 + vscode.window.showErrorMessage("portal.relayUrls must contain only valid https:// relay URLs.");
132 + return undefined;
133 + }
134 + const relayUrls = saved.map((url) => url.trim());
135 + return {
136 + relayUrls,
137 + installRelayUrl: relayUrls[0],
138 + };
139 + }
140 +
141 + if (!interactive) {
142 + return {
143 + relayUrls: [],
144 + installRelayUrl: "",
145 + };
146 + }
147 +
148 + const choice = await vscode.window.showQuickPick([
149 + {
150 + label: "Use default public registry",
151 + description: defaultRelayRegistryURL,
152 + },
153 + {
154 + label: "Enter relay URL",
155 + description: "Install from a specific https:// relay",
156 + },
157 + ], {
158 + title: "Portal: Relay Source",
159 + placeHolder: "Choose a public registry or a specific relay URL",
160 + });
161 + if (!choice) {
162 + return undefined;
163 + }
164 + if (choice.label === "Use default public registry") {
165 + vscode.window.showInformationMessage(`Portal will use the default public registry: ${defaultRelayRegistryURL}`);
166 + return {
167 + relayUrls: [],
168 + installRelayUrl: "",
169 + };
170 + }
171
172 const input = await vscode.window.showInputBox({
173 title: "Portal: Relay URL",
174 prompt: "Relay server URL (e.g. https://my-relay.example.com)",
94 - validateInput: (v) => {
95 - try { new URL(v.trim()); return undefined; } catch { return "Enter a valid URL"; }
96 - },
175 + validateInput: validateRelayUrl,
176 });
98 - return input ? [input.trim()] : undefined;
177 + if (!input) {
178 + return undefined;
179 + }
180 + const relayUrl = input.trim();
181 + return {
182 + relayUrls: [relayUrl],
183 + installRelayUrl: relayUrl,
184 + };
185 }
186
187 async function promptThumbnail(): Promise<string | undefined> {
@@ -108,83 +194,16 @@ async function promptThumbnail(): Promise<string | undefined> {
194 try { new URL(v.trim()); return undefined; } catch { return "Enter a valid URL or leave empty"; }
195 },
196 });
111 - // undefined = user pressed Escape, "" = user skipped
197 return result;
198 }
199
115 -interface TunnelCommandOptions {
116 - host: string;
117 - name: string;
118 - relayList: string;
119 - relayUrl: string;
120 - thumbnail: string;
121 - isLocal: boolean;
122 -}
123 -
124 -function buildCommand(opts: TunnelCommandOptions): string {
125 - const { host, name, relayList, relayUrl, thumbnail, isLocal } = opts;
126 - const target = os.platform() === "win32" ? "windows" : "unix";
127 - const installShellUrl = `${relayUrl}/install.sh`;
128 - const installPowerShellUrl = `${relayUrl}/install.ps1`;
129 - const exposeArgs: string[] = [];
130 -
131 - if (name.trim()) {
132 - exposeArgs.push(`--name ${formatToken(name.trim(), target)}`);
133 - }
134 - exposeArgs.push(`--relays ${formatToken(relayList, target)}`);
135 - if (thumbnail.trim()) {
136 - exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`);
137 - }
138 -
139 - const exposeCommand = `portal expose ${[...exposeArgs, formatToken(host, target)].join(" ")}`;
140 -
141 - if (os.platform() === "win32") {
142 - return [
143 - `$ProgressPreference = 'SilentlyContinue'`,
144 - `irm ${formatToken(installPowerShellUrl, target)} | iex`,
145 - exposeCommand,
146 - ].join("\n");
147 - }
148 -
149 - const curlFlags = isLocal ? "-kfsSL" : "-fsSL";
150 - return [
151 - `curl ${curlFlags} ${formatToken(installShellUrl, target)} | bash`,
152 - exposeCommand,
153 - ].join("\n");
154 -}
155 -
200 function createTunnelTerminal(): vscode.Terminal {
157 - if (os.platform() !== "win32") {
201 + if (process.platform !== "win32") {
202 return vscode.window.createTerminal("Portal Tunnel");
203 }
204
161 - // Use PowerShell on Windows for non-PowerShell terminal profiles (Git Bash, WSL, etc.).
205 return vscode.window.createTerminal({
206 name: "Portal Tunnel",
207 shellPath: "powershell.exe",
208 });
209 }
167 -
168 -function isLocalhost(url: string): boolean {
169 - try {
170 - const h = new URL(url).hostname.toLowerCase();
171 - return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
172 - } catch {
173 - return false;
174 - }
175 -}
176 -
177 -function quoteShellValue(value: string): string {
178 - return "'" + value.replace(/'/g, `'\"'\"'`) + "'";
179 -}
180 -
181 -function quotePowerShellValue(value: string): string {
182 - return `'${value.replace(/'/g, "''")}'`;
183 -}
184 -
185 -function formatToken(value: string, target: "unix" | "windows"): string {
186 - if (/^[A-Za-z0-9:/.=_,-]+$/.test(value)) {
187 - return value;
188 - }
189 - return target === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
190 -}
extensions/vscode/src/test/extension.test.ts
+54 -11
@@ -1,15 +1,58 @@
1 -import * as assert from 'assert';
1 +import * as assert from "assert";
2
3 -// You can import and use all API from the 'vscode' module
4 -// as well as import your extension to test it
5 -import * as vscode from 'vscode';
6 -// import * as myExtension from '../../extension';
3 +import { buildCommand, validateRelayUrl } from "../command";
4
8 -suite('Extension Test Suite', () => {
9 - vscode.window.showInformationMessage('Start all tests.');
5 +suite("Extension Test Suite", () => {
6 + test("validateRelayUrl accepts only https URLs", () => {
7 + assert.strictEqual(validateRelayUrl("https://relay.example.com"), undefined);
8 + assert.strictEqual(validateRelayUrl("http://relay.example.com"), "Portal relay URLs must use https://");
9 + assert.strictEqual(validateRelayUrl("not-a-url"), "Enter a valid https:// URL");
10 + });
11
11 - test('Sample test', () => {
12 - assert.strictEqual(-1, [1, 2, 3].indexOf(5));
13 - assert.strictEqual(-1, [1, 2, 3].indexOf(0));
14 - });
12 + test("buildCommand omits --name when empty and resolves unix portal binary after install", () => {
13 + const command = buildCommand({
14 + host: "localhost:3000",
15 + name: "",
16 + relayList: "https://relay.example.com",
17 + relayUrl: "https://relay.example.com",
18 + thumbnail: "",
19 + isLocal: false,
20 + }, "unix");
21 +
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\)"/);
24 + assert.match(command, /"\$PORTAL_BIN" expose --relays https:\/\/relay\.example\.com localhost:3000/);
25 + assert.ok(!command.includes("--name"));
26 + });
27 +
28 + test("buildCommand can use the default public registry without --relays", () => {
29 + const command = buildCommand({
30 + host: "localhost:3000",
31 + name: "",
32 + relayList: "",
33 + relayUrl: "",
34 + thumbnail: "",
35 + isLocal: false,
36 + }, "unix");
37 +
38 + assert.ok(!command.includes("/install.sh"));
39 + assert.ok(!command.includes("--relays"));
40 + assert.match(command, /portal CLI not found\. Install from a relay first or configure portal\.relayUrls\./);
41 + assert.match(command, /"\$PORTAL_BIN" expose localhost:3000/);
42 + });
43 +
44 + test("buildCommand uses explicit portal.exe path on windows", () => {
45 + const command = buildCommand({
46 + host: "localhost:3000",
47 + name: "my-app",
48 + relayList: "https://relay.example.com",
49 + relayUrl: "https://relay.example.com",
50 + thumbnail: "https://example.com/thumb.png",
51 + isLocal: false,
52 + }, "windows");
53 +
54 + assert.match(command, /irm https:\/\/relay\.example\.com\/install\.ps1 \| iex/);
55 + assert.match(command, /\$PortalBin = Join-Path \$env:LOCALAPPDATA 'portal\\bin\\portal\.exe'/);
56 + assert.match(command, /& \$PortalBin expose --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png localhost:3000/);
57 + });
58 });
sdk/listener.go
+21 -3
@@ -7,6 +7,8 @@ import (
7 "fmt"
8 "io"
9 "net"
10 + "net/url"
11 + "strings"
12 "sync"
13 "time"
14
@@ -113,11 +115,12 @@ func (l *Listener) runStartup(ctx context.Context) {
115 go l.runSessionLoop(ctx)
116 }
117 go l.runRenewLoop(ctx)
118 + publicURL := l.PublicURL()
119 log.Info().
120 Str("relay_url", l.relayURL).
121 Str("lease_id", l.LeaseID()).
119 - Str("hostname", l.Hostname()).
120 - Msg("listener ready")
122 + Str("public_url", publicURL).
123 + Msg("service is available at this URL")
124 return
125 case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
126 return
@@ -203,12 +206,27 @@ func (l *Listener) Metadata() types.LeaseMetadata {
206 func (l *Listener) PublicURL() string {
207 l.mu.Lock()
208 hostname := l.hostname
209 + relayURL := l.relayURL
210 l.mu.Unlock()
211
212 if hostname == "" {
213 return ""
214 }
211 - return "https://" + hostname
215 +
216 + parsed, err := url.Parse(relayURL)
217 + if err != nil || strings.TrimSpace(parsed.Scheme) == "" {
218 + return "https://" + hostname
219 + }
220 +
221 + host := hostname
222 + if port := strings.TrimSpace(parsed.Port()); port != "" {
223 + host = net.JoinHostPort(hostname, port)
224 + }
225 +
226 + return (&url.URL{
227 + Scheme: parsed.Scheme,
228 + Host: host,
229 + }).String()
230 }
231
232 func (l *Listener) runSessionLoop(ctx context.Context) {