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) {