Merge branch master into feature/ssh-config

feature/ssh-config
Kelvin Schoofs 4 years ago
commit 38f5b735b2

@ -3,7 +3,7 @@ name: Build extension
on:
push:
tags: '**'
tags: ['**']
branches:
- '*'
- 'feature/**'
@ -23,65 +23,61 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: Define variables
id: vars
run: |
SOURCE_NAME=${GITHUB_REF#refs/*/}
VSIX_NAME="vscode-sshfs-$SOURCE_NAME.vsix"
if [[ $GITHUB_REF == refs/tags/v* ]]; then
TAG_VERSION=${GITHUB_REF#refs/tags/v}
VSIX_NAME="vscode-sshfs-$TAG_VERSION.vsix"
echo ::set-output name=TAG_VERSION::$TAG_VERSION
elif [[ $GITHUB_REF == refs/pull/*/head || $GITHUB_REF == refs/pull/*/merge ]]; then
PR_NUMBER=${GITHUB_REF#refs/pull/}
PR_NUMBER=${PR_NUMBER%/head}
PR_NUMBER=${PR_NUMBER%/merge}
VSIX_NAME="vscode-sshfs-pr-$PR_NUMBER.vsix"
echo ::set-output name=PR_NUMBER::$PR_NUMBER
elif [[ -n $SOURCE_NAME ]]; then
VSIX_NAME="vscode-sshfs-$SOURCE_NAME.vsix"
fi
VSIX_NAME=${VSIX_NAME//"/"/"-"}
echo ::set-output name=VSIX_NAME::$VSIX_NAME
- name: Use Node.js 10.x
- name: Event Utilities
uses: SchoofsKelvin/event-utilities@v1
id: utils
with:
artifact_prefix: "vscode-sshfs"
artifact_extension: "vsix"
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 10.x
node-version: 12.x
- name: Install VSCE
run: |
yarn global add vsce
echo "$(yarn global bin)" >> $GITHUB_PATH
- name: Get Yarn cache directory
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
uses: actions/cache@v2.1.4
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies in /
run: yarn --frozen-lockfile
- name: Install dependencies in /webview/
working-directory: webview
run: yarn --frozen-lockfile
- name: Build extension
run: vsce package -o ${{ steps.vars.outputs.VSIX_NAME }}
run: vsce package -o ${{ steps.utils.outputs.artifact_name }}
- name: Upload a Build Artifact
uses: actions/upload-artifact@v2.2.1
with:
name: ${{ steps.vars.outputs.VSIX_NAME }}
path: ${{ steps.vars.outputs.VSIX_NAME }}
name: ${{ steps.utils.outputs.artifact_name }}
path: ${{ steps.utils.outputs.artifact_name }}
if-no-files-found: error
- name: Create release
id: create_release
if: ${{ success() && steps.vars.outputs.TAG_VERSION }}
if: ${{ success() && steps.utils.outputs.tag_version }}
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.vars.outputs.TAG_VERSION }}
release_name: Release ${{ steps.utils.outputs.tag_version }}
draft: true
- name: Upload release asset
id: upload_release_asset
if: ${{ success() && steps.vars.outputs.TAG_VERSION }}
if: ${{ success() && steps.utils.outputs.tag_version }}
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.vars.outputs.VSIX_NAME }}
asset_name: ${{ steps.vars.outputs.VSIX_NAME }}
asset_path: ${{ steps.utils.outputs.artifact_name }}
asset_name: ${{ steps.utils.outputs.artifact_name }}
asset_content_type: application/vsix

@ -0,0 +1,38 @@
name: Publish extension
on:
release:
types: [published]
jobs:
openvsx:
name: "Open VSX Registry"
if: endsWith(github.event.release.assets[0].name, '.vsix')
runs-on: ubuntu-latest
steps:
- name: Download release artifact
run: "curl -L -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H 'Accept: application/octet-stream' ${{ github.event.release.assets[0].url }} --output extension.vsix"
- name: Validate extension file
run: unzip -f extension.vsix extension/package.json
- name: Publish to Open VSX Registry
uses: HaaLeo/publish-vscode-extension@v0
with:
pat: ${{ secrets.OPEN_VSX_TOKEN }}
extensionFile: extension.vsix
packagePath: ''
vs:
name: "Visual Studio Marketplace"
if: endsWith(github.event.release.assets[0].name, '.vsix')
runs-on: ubuntu-latest
steps:
- name: Download release artifact
run: "curl -L -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H 'Accept: application/octet-stream' ${{ github.event.release.assets[0].url }} --output extension.vsix"
- name: Validate extension file
run: unzip -f extension.vsix extension/package.json
- name: Publish to Visual Studio Marketplace
uses: HaaLeo/publish-vscode-extension@v0
with:
pat: ${{ secrets.VS_MARKETPLACE_TOKEN }}
registryUrl: https://marketplace.visualstudio.com
extensionFile: extension.vsix
packagePath: ''

@ -1,71 +1,94 @@
# SSH FS
![Logo](./resources/Logo.png)
[![GitHub package version](./media/github.png)](https://github.com/SchoofsKelvin/vscode-sshfs)
[![Visual Studio Marketplace](https://vsmarketplacebadge.apphb.com/version-short/Kelvin.vscode-sshfs.svg)](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs)
[![Donate](./media/paypal.png)](https://www.paypal.me/KSchoofs)
[![GitHub package version](https://img.shields.io/github/v/release/SchoofsKelvin/vscode-sshfs?include_prereleases&label=GitHub%20version)](https://github.com/SchoofsKelvin/vscode-sshfs)
[![Visual Studio Marketplace](https://img.shields.io/visual-studio-marketplace/v/Kelvin.vscode-sshfs?label=VS%20Marketplace&logo=sdf)](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs)
[![OpenVSX Registry](https://img.shields.io/open-vsx/v/Kelvin/vscode-sshfs?label=Open%20VSX)](https://open-vsx.org/extension/Kelvin/vscode-sshfs)
[![VS Market installs](https://img.shields.io/visual-studio-marketplace/i/Kelvin.vscode-sshfs?color=green&label=Installs)](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/SchoofsKelvin?color=green&label=GitHub%20Sponsors)](https://github.com/sponsors/SchoofsKelvin)
[![Donate](./media/paypal.png)](https://www.paypal.me/KSchoofs)
This extension makes use of the new FileSystemProvider, added in version 1.23.0 of Visual Studio Code. It allows "mounting" a remote folder over SSH as a local Workspace folder.
This extension allows mounting remote folders as local workspace folders, launch integrated remote terminals and run `ssh-shell` tasks.
## Summary
* Use a remote directory (over SSH) as workspace folder
* Instantly create one or multiple terminals on the same host
* A built-in UI to add, edit and remove configurations
* Use agents, including Pageant and OpenSSH on Windows
* Use private keys (any supported by ssh2-streams, including PuTTY's PPK)
* Get prompted for a password/passphrase (plain text password aren't required)
* Easily create configurations that reference a PuTTY session/configuration
* Create tasks that run commands on a remote host (remote version of "shell" task type)
* Have multiple SSH (and regular) workspace folders at once
* Make use of SOCKS 4/5 and HTTP proxies and connection hopping
## Features
## Usage
Use the command `SSH FS: Create a SSH FS configuration`, or open the Settings UI using the `SSH FS: Open settings and edit configurations` and click Add:
### Config editor
The built-in config editor makes it easy to create and edit configurations:
![Config editor](./media/config-editor.png)
![Create a new configuration](./media/screenshot-create-config.png)
The config editors stores this, by default, in your User Settings (`settings.json`) as:
```json
"sshfs.configs": [
{
"name": "hetzner",
"putty": "Hetzner",
"label": "Hetzner",
"hop": "hetzner2",
"root": "/root"
}
],
```
This config is configured to copy settings (e.g. username, host, ...) from my PuTTY session. Due to me having loaded my private key in Pageant (PuTTY's agent), this config allows me to create a connection without having to provide a password/passphrase. It also specifies that all file operations _(`ssh://hetzner/some/file.js`)_ are relative to the `/root` directory on the server.
In this UI, you can also edit/delete existing configurations:
Configurations are read from your global User Settings, the current workspace's settings, and any JSON files configured with `sshfs.configpaths`. Even when the workspace overrides this setting, the globally-configured paths will still be read. The workspace versions do have higher priority for merging or ignoring duplicates.
![Config Editor](./media/screenshot-config-editor.png)
### Terminals
Using a simple button or the command palette, a remote terminal can be started:
![Terminals](./media/terminals.png)
To connect, either rightclick the name in the Explorer tab, or use the command panel:
_Uses `$SHELL` by default to launch your default user shell. A config option exists to change this, e.g. `"ksh -"` or `"exec .special-profile; $SHELL"`_
![Connect](./media/screenshot-connect.png)
If a connection is already opened for a configuration, there is no need to reauthenticate. As long as the configuration hasn't changed, existing connections (both for workspace folders and terminals) will be reused.
This will add a Workspace folder linked to a SSH (SFTP) session:
### Remote shell tasks
A new task type `ssh-shell` is added to run shell commands remotely:
![Remote shell tasks](./media/shell-tasks.png)
![Workspace folder added](./media/screenshot-explorer.png)
The task terminal opens a full PTY terminal on the server.
## Changelog 1.18.0
Starting from version 1.18.0 of the extension, a few new features are added:
### Remote workspace folders
Using a simple button or the command palette, we can mount a remote workspace folder as a regular local workspace folder:
![Remote workspace folder](./media/workspace-folder.png)
### Terminals
The configurations for SSH file systems can now also be used to spawn terminals:
_Same configuration used as from the [Config editor](#Config%20editor) above._
![Terminals](./media/terminals.png)
This works seamlessly with extensions using the `vscode.workspace.fs` API _(added in VS Code 1.37.0)_, although not all extensions switched over, especially ones making use of binary files.
As can be seen, right-clicking a remote directory gives the option to instantly open a remote terminal in this directory.
The extension supports any `ssh://` URI. I actually opened `ssh://hetzner/ng-ui` as my folder, which resolves to `/root/ng-ui` on my remote server. By default, the button/command opens `ssh://hetzner/` which would then mount `/root`, as that is what my `Root` config field is set to. You can set it to whatever, including `~/path`.
### Miscellaneous
The extension comes with a bunch of other improvements/features. Internally the [ssh2](https://www.npmjs.com/package/ssh2) package is used. The raw config JSON objects _(as seen in [Config editor](#Config%20editor))_ is, apart from some special fields, a one-on-one mapping of the config options supported by this package. Power users can edit their `settings.json` to e.g. make use of the `algorithms.cipher` field to specify a list of ciphers to use.
Some other features worth mentioning:
#### Prompt host/username/password/... for every connection
![Prompt username](./media/prompt-username.png)
Opening a terminal automatically sets the working directory to the `root` directory, unless a directory was explicitly selected to open the terminal in:
Active connections are reused to minimize prompts. A connection gets closed if there's no terminal or file system using it for over 5 seconds.
![Explorer Terminal](./media/explorer-terminal.png)
#### Proxy settings
Several proxy types (SSH hopping, HTTP and SOCKS 4/5) are supported:
This replaces the built-in "Open terminal" context menu option that isn't provided for remote field systems. For non-ssh:// file systems, the original "Open terminal" menu item is still displayed, the remote version only affects ssh:// file systems.
![Proxy settings](./media/proxy-settings.png)
### New task type
This extension adds a new task type `ssh-shell` which can be used to run commands on a configured remote host:
`SSH Hop` refers to using another configuration to hop through, similar to OpenSSH's `ProxyJump`:
![Tasks](./media/tasks.png)
![Hop config field](./media/hop-config.png)
Currently only the `command` field is supported. The goal is to replicate part of the `shell` task structure, e.g. an `args` array, support for `${workspaceFolder}`, ...
#### SFTP Command/Sudo and Terminal command
![SFTP and Terminal Command config fields](./media/sftp-config.png)
### Connection reuse
The way the extension connects to the remote hosts is reworked. The extension tries to only keep one connection per host active, with one connection supporting the file system access and a bunch of terminals. If the saved configuration has changed after a connection has been established, the next terminal/filesystem will start a new connection, but leave the first one alive and fine.
The extension supports using a custom `sftp` subsystem command. By default, it uses the `sftp` subsystem as indicated by the remote SSH server. In reality, this usually results in `/usr/lib/openssh/sftp-server` being used.
A handy enhancement this brings is that prompts (e.g. for passwords) should only happen once. As long as a connection is open (either by having a connected file system or terminal to the host), opening e.g. a new terminal skips the whole authentication phase and is basically instant.
The `SFTP Command` setting allows specifying to use a certain command instead of the default subsystem. The `SFTP Sudo` setting makes the extension try to create a sudo shell _(for the given user, or whatever sudo defaults to)_ and run `SFTP Command` _(or `/usr/lib/openssh/sftp-server` by default)_. For most users, setting this to `<Default>` should allow operating on the remote file system as `root`. Power users with esoteric setups can resort to changing `SFTP Command` to e.g. `sudo /some-sftp-server`, but might run into trouble with password prompts.
Connections without an active file system or terminals will automatically be closed somewhere after 5 seconds. If you're planning on running a bunch of tasks on a host without having a workspace folder connected to it, keeping a terminal open is handy and advised.
The `Terminal command` option, as mentioned in [Terminals](#Terminals), allows overriding the command used to launch the remote shell. By default, the extension launches a remote shell over the SSH connection, runs `cd ...` if necessary, followed by `$SHELL` to start the user's default shell. This config option allows to replace this `$SHELL` with a custom way way of starting the shell, or configuring the provided default SSH shell.
### Logging
Logging has slightly improved, resulting in better logs that help with resolving issues.
## Links
- [GitHub](https://github.com/SchoofsKelvin/vscode-sshfs) ([Issues](https://github.com/SchoofsKelvin/vscode-sshfs/issues) | [(Pre)-releases](https://github.com/SchoofsKelvin/vscode-sshfs/releases) | [Roadmap](https://github.com/SchoofsKelvin/vscode-sshfs/projects/1) | [Sponsor](https://github.com/sponsors/SchoofsKelvin))
- [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs)
- [Open VSX Registry](https://open-vsx.org/extension/Kelvin/vscode-sshfs)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@ -1,9 +1,9 @@
{
"name": "vscode-sshfs",
"displayName": "SSH FS",
"description": "File system provider using SSH",
"description": "File system, terminal and task provider using SSH",
"publisher": "Kelvin",
"version": "1.19.1",
"version": "1.20.0",
"engines": {
"vscode": "^1.49.0"
},
@ -28,6 +28,7 @@
"onCommand:sshfs.refresh"
],
"main": "./dist/extension.js",
"homepage": "https://github.com/SchoofsKelvin/vscode-sshfs",
"author": {
"name": "Kelvin Schoofs",
"email": "schoofs.kelvin@gmail.com",
@ -48,7 +49,31 @@
"sshfs",
"sync",
"filesystem",
"terminal"
"terminal",
"sftp",
"scp"
],
"badges": [
{
"url": "https://img.shields.io/github/v/release/SchoofsKelvin/vscode-sshfs?include_prereleases&label=GitHub%20version",
"href": "https://github.com/SchoofsKelvin/vscode-sshfs/releases",
"description": "Releases on GitHub"
},
{
"url": "https://img.shields.io/open-vsx/v/Kelvin/vscode-sshfs?label=Open%20VSX",
"href": "https://open-vsx.org/extension/Kelvin/vscode-sshfs",
"description": "Open VSX Registry"
},
{
"url": "https://img.shields.io/visual-studio-marketplace/v/Kelvin.vscode-sshfs?label=VS%20Marketplace&logo=sdf",
"href": "https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs",
"description": "Visual Studio Marketplace"
},
{
"url": "https://img.shields.io/visual-studio-marketplace/i/Kelvin.vscode-sshfs?label=Installs",
"href": "https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs",
"description": "Unique installs using Visual Studio Marketplace"
}
],
"contributes": {
"views": {
@ -300,6 +325,13 @@
"password": "CorrectHorseBatteryStaple"
}
]
},
"sshfs.flags": {
"title": "List of special flags to enable/disable certain fixes/features",
"description": "Flags are usually used for issues or beta testing. Flags can disappear/change anytime!",
"type": "array",
"items": "string",
"default": []
}
}
},
@ -344,7 +376,7 @@
"clean-webpack-plugin": "^2.0.0",
"source-map-support": "^0.5.19",
"ts-loader": "^7.0.5",
"typescript": "^4.0.2",
"typescript": "^4.2.3",
"webpack": "^4.29.6",
"webpack-cli": "^3.2.3"
},
@ -354,5 +386,8 @@
"socks": "^2.2.0",
"ssh2": "^0.8.9",
"winreg": "^1.2.4"
},
"resolutions": {
"ssh2-streams": "Timmmm/ssh2-streams#patch-1"
}
}

@ -2,7 +2,7 @@
import { readFile, writeFile } from 'fs';
import { parse as parseJsonc, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { ConfigLocation, FileSystemConfig, invalidConfigName } from './fileSystemConfig';
import { ConfigLocation, FileSystemConfig, invalidConfigName, parseConnectionString } from './fileSystemConfig';
import { Logging } from './logging';
import { toPromise } from './toPromise';
@ -283,9 +283,32 @@ export async function deleteConfig(config: FileSystemConfig) {
});
}
export function getConfig(name: string) {
if (name === '<config>') return null;
return getConfigs().find(c => c.name === name);
/** If a loaded config with the given name exists (case insensitive), it is returned.
* Otherwise, if it contains a `@`, we parse it as a connection string.
* If this results in no (valid) configuration, `undefined` is returned.
*/
export function getConfig(input: string): FileSystemConfig | undefined {
const lower = input.toLowerCase();
const loaded = getConfigs().find(c => c.name.toLowerCase() === lower);
if (loaded) return loaded;
if (!input.includes('@')) return undefined;
const parseString = parseConnectionString(input);
if (typeof parseString === 'string') return undefined;
const [parsed] = parseString;
// If we're using the instant connection string, the host name might be a config name
const existing = getConfigs().find(c => c.name.toLowerCase() === parsed.host!.toLowerCase());
if (existing) {
Logging.info(`getConfig('${input}') led to '${parsed.name}' which matches config '${existing.name}'`);
// Take the existing config, but (more or less) override it with the values present in `parsed`
// `name` be the same as in `parsed`, meaning it can be reused with `getConfig` on window reload.
return {
...existing, ...parsed,
host: existing.host || parsed.host, // `parsed.host` is the session name, which might not be the actual hostname
_location: undefined, // Since this is a merged config, we have to flag it as such
_locations: [...existing._locations, ...parsed._locations], // Merge locations
};
}
return parsed;
}
function valueMatches(a: any, b: any): boolean {
@ -318,3 +341,103 @@ vscode.workspace.onDidChangeConfiguration(async (e) => {
return loadConfigs();
});
loadConfigs();
export type FlagValue = string | boolean | null;
export type FlagCombo = [value: FlagValue, origin: string];
export const DEFAULT_FLAGS: string[] = ['-DF-GE'];
let cachedFlags: Record<string, FlagCombo> = {};
function calculateFlags(): Record<string, FlagCombo> {
const flags: Record<string, FlagCombo> = {};
const config = vscode.workspace.getConfiguration('sshfs').inspect<string[]>('flags');
if (!config) throw new Error(`Could not inspect "sshfs.flags" config field`);
function parseList(list: string[] | undefined, origin: string) {
if (list === undefined) return;
if (!Array.isArray(list)) throw new Error(`Expected string array for flags, but got: ${list}`);
const scope: Record<string, FlagCombo> = {};
for (const flag of list) {
let name: string = flag;
let value: FlagValue = null;
const eq = flag.indexOf('=');
if (eq !== -1) {
name = flag.substring(0, eq);
value = flag.substring(eq + 1);
} else if (flag.startsWith('+')) {
name = flag.substring(1);
value = true;
} else if (flag.startsWith('-')) {
name = flag.substring(1);
value = false;
}
name = name.toLocaleLowerCase();
if (name in scope) continue;
scope[name] = [value, origin];
}
// Override if necessary (since workspace settings come after global settings)
// Per "location", we still ignore duplicate flag names
Object.assign(flags, scope);
}
parseList(DEFAULT_FLAGS, 'Built-in Default');
parseList(config.defaultValue, 'Default Settings');
// Electron v11 crashes for DiffieHellman GroupExchange, although it's fixed in 11.3.0
if ((process.versions as { electron?: string }).electron?.match(/^11\.(0|1|2)\./)) {
parseList(['+DF-GE'], 'Fix for issue #239')
}
parseList(config.globalValue, 'Global Settings');
parseList(config.workspaceValue, 'Workspace Settings');
parseList(config.workspaceFolderValue, 'WorkspaceFolder Settings');
Logging.info(`Calculated config flags: ${JSON.stringify(flags)}`);
return cachedFlags = flags;
}
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('sshfs.flags')) calculateFlags();
});
calculateFlags();
/** Returns a cached version. Gets updated by ConfigurationChangeEvent events */
export function getFlags(): Record<string, FlagCombo> { return cachedFlags; }
/**
* Checks the `sshfs.flags` config (overridable by e.g. workspace settings).
* - Flag names are case-insensitive
* - If a flag appears twice, the first mention of it is used
* - If a flag appears as "NAME", `null` is returned
* - If a flag appears as "FLAG=VALUE", `VALUE` is returned as a string
* - If a flag appears as `+FLAG` (and no `=`), `true` is returned (as a boolean)
* - If a flag appears as `-FLAG` (and no `=`), `false` is returned (as a boolean)
* - If a flag is missing, `undefined` is returned (different from `null`!)
*
* For `undefined`, an actual `undefined` is returned. For all other cases, a FlagCombo
* is returned, e.g. "NAME" returns `[null, "someOrigin"]` and `"+F"` returns `[true, "someOrigin"]`
* @param target The name of the flag to look for
*/
export function getFlag(target: string): FlagCombo | undefined {
return calculateFlags()[target.toLowerCase()];
}
/**
* Built on top of getFlag. Tries to convert the flag value to a boolean using these rules:
* - If the flag isn't present, `missingValue` is returned
* Although this probably means I'm using a flag that I never added to `DEFAULT_FLAGS`
* - Booleans are kept
* - `null` is counted as `true` (means a flag like "NAME" was present without any value or prefix)
* - Strings try to get converted in a case-insensitive way:
* - `true/t/yes/y` becomes true
* - `false/f/no/n` becomes false
* - All other strings result in an error
* @param target The name of the flag to look for
* @param defaultValue The value to return when no flag with the given name is present
* @returns The matching FlagCombo or `[missingValue, 'missing']` instead
*/
export function getFlagBoolean(target: string, missingValue: boolean): FlagCombo {
const combo = getFlag(target);
if (!combo) return [missingValue, 'missing'];
const [value, reason] = combo;
if (value == null) return [true, reason];
if (typeof value === 'boolean') return combo;
const lower = value.toLowerCase();
if (lower === 'true' || lower === 't' || lower === 'yes' || lower === 'y') return [true, reason];
if (lower === 'false' || lower === 'f' || lower === 'no' || lower === 'n') return [false, reason];
throw new Error(`Could not convert '${value}' for flag '${target}' to a boolean!`);
}

@ -4,9 +4,10 @@ import { userInfo } from 'os';
import { Client, ClientChannel, ConnectConfig, SFTPWrapper as SFTPWrapperReal } from 'ssh2';
import { SFTPStream } from 'ssh2-streams';
import * as vscode from 'vscode';
import { getConfigs } from './config';
import { getConfig, getFlag, getFlagBoolean } from './config';
import type { FileSystemConfig } from './fileSystemConfig';
import { censorConfig, Logging } from './logging';
import type { PuttySession } from './putty';
import { toPromise } from './toPromise';
// tslint:disable-next-line:variable-name
@ -33,8 +34,8 @@ const PROMPT_FIELDS: Partial<Record<keyof FileSystemConfig, [
promptOnEmpty: boolean, password?: boolean]>> = {
host: ['Host', c => `Host for ${c.name}`, true],
username: ['Username', c => `Username for ${c.name}`, true],
password: ['Password', c => `Password for ${c.username}@${c.name}`, false, true],
passphrase: ['Passphrase', c => `Passphrase for provided export/private key for ${c.username}@${c.name}`, false, true],
password: ['Password', c => `Password for '${c.username}' for ${c.name}`, false, true],
passphrase: ['Passphrase', c => `Passphrase for provided export/private key for '${c.username}' for ${c.name}`, false, true],
};
async function promptFields(config: FileSystemConfig, ...fields: (keyof FileSystemConfig)[]): Promise<void> {
@ -63,17 +64,35 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
// Add the internal _calculated field to cache the actual config for the next calculateActualConfig call
// (and it also allows accessing the original config that generated this actual config, if ever necessary)
config = { ...config, _calculated: config };
config.username = replaceVariables(config.username);
// Windows uses `$USERNAME` while Unix uses `$USER`, let's normalize it here
if (config.username === '$USERNAME') config.username = '$USER';
// Delay handling just `$USER` until later, as PuTTY might handle it specially
if (config.username !== '$USER') config.username = replaceVariables(config.username);
config.host = replaceVariables(config.host);
const port = replaceVariables((config.port || '') + '');
if (port) config.port = Number(port);
config.agent = replaceVariables(config.agent);
config.privateKeyPath = replaceVariables(config.privateKeyPath);
logging.info(`Calculating actual config`);
if (config.instantConnection) {
// Created from an instant connection string, so enable PuTTY (in try mode)
config.putty = '<TRY>'; // Could just set it to `true` but... consistency?
}
if (config.putty) {
if (process.platform !== 'win32') {
logging.warning(`\tConfigurating uses putty, but platform is ${process.platform}`);
}
const { getCachedFinder } = await import('./putty');
const getSession = await getCachedFinder();
const cUsername = config.username === '$USER' ? undefined : config.username;
const tryPutty = config.instantConnection || config.putty === '<TRY>';
let session: PuttySession | undefined;
if (tryPutty) {
// If we're trying to find one, we also check whether `config.host` represents the name of a PuTTY session
session = await getSession(config.host);
logging.info(`\ttryPutty is true, tried finding a config named '${config.host}' and found ${session ? `'${session.name}'` : 'no match'}`);
}
if (!session) {
let nameOnly = true;
if (config.putty === true) {
await promptFields(config, 'host');
@ -84,20 +103,19 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
} else {
config.putty = replaceVariables(config.putty);
}
const session = await (await import('./putty')).getSession(config.putty, config.host, config.username, nameOnly);
if (!session) throw new Error(`Couldn't find the requested PuTTY session`);
session = await getSession(config.putty, config.host, cUsername, nameOnly);
}
if (session) {
if (session.protocol !== 'ssh') throw new Error(`The requested PuTTY session isn't a SSH session`);
config.username = config.username || session.username;
config.username = cUsername || session.username;
if (!config.username && session.hostname && session.hostname.indexOf('@') >= 1) {
config.username = session.hostname.substr(0, session.hostname.indexOf('@'));
}
config.host = config.host || session.hostname;
// Used to be `config.host || session.hostname`, but `config.host` could've been just the session name
config.host = session.hostname.replace(/^.*?@/, '');
config.port = session.portnumber || config.port;
config.agent = config.agent || (session.tryagent ? 'pageant' : undefined);
if (session.usernamefromenvironment) {
config.username = process.env.USERNAME || process.env.USER;
if (!config.username) throw new Error(`Trying to use the system username, but process.env.USERNAME or process.env.USER is missing`);
}
if (session.usernamefromenvironment) config.username = '$USER';
config.privateKeyPath = config.privateKeyPath || (!config.agent && session.publickeyfile) || undefined;
switch (session.proxymethod) {
case 0:
@ -116,7 +134,14 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
throw new Error(`The requested PuTTY session uses an unsupported proxy method`);
}
logging.debug(`\tReading PuTTY configuration lead to the following configuration:\n${JSON.stringify(config, null, 4)}`);
} else if (!tryPutty) {
throw new Error(`Couldn't find the requested PuTTY session`);
} else {
logging.debug(`\tConfig suggested finding a PuTTY configuration, did not find one`);
}
}
// If the username is (still) `$USER` at this point, use the local user's username
if (config.username === '$USER') config.username = userInfo().username;
if (config.sshConfig) {
await promptFields(config, 'host');
let paths = vscode.workspace.getConfiguration('sshfs').get<string[]>('paths.ssh');
@ -160,6 +185,11 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
// Issue with the ssh2 dependency apparently not liking false
delete config.passphrase;
}
if (!config.privateKey && !config.agent && !config.password) {
logging.debug(`\tNo privateKey, agent or password. Gonna prompt for password`);
config.password = true as any;
await promptFields(config, 'password');
}
logging.debug(`\tFinal configuration:\n${JSON.stringify(censorConfig(config), null, 4)}`);
return config;
}
@ -171,7 +201,7 @@ export async function createSocket(config: FileSystemConfig): Promise<NodeJS.Rea
logging.info(`Creating socket`);
if (config.hop) {
logging.debug(`\tHopping through ${config.hop}`);
const hop = getConfigs().find(c => c.name === config.hop);
const hop = getConfig(config.hop);
if (!hop) throw new Error(`A SSH FS configuration with the name '${config.hop}' doesn't exist`);
const ssh = await createSSH(hop);
if (!ssh) {
@ -242,8 +272,22 @@ export async function createSSH(config: FileSystemConfig, sock?: NodeJS.Readable
reject(error);
});
try {
logging.info(`Creating SSH session over the opened socket`);
client.connect(Object.assign<ConnectConfig, ConnectConfig, ConnectConfig>(config, { sock }, DEFAULT_CONFIG));
const finalConfig: ConnectConfig = { ...config, sock, ...DEFAULT_CONFIG };
if (config.debug || getFlag('DEBUG_SSH2') !== undefined) {
const scope = Logging.scope(`ssh2(${config.name})`);
finalConfig.debug = (msg: string) => scope.debug(msg);
}
// Unless the flag 'DF-GE' is specified, disable DiffieHellman groupex algorithms (issue #239)
// Note: If the config already specifies a custom `algorithms.key`, ignore it (trust the user?)
const [flagV, flagR] = getFlagBoolean('DF-GE', false);
if (flagV) {
logging.info(`Flag "DF-GE" enabled due to '${flagR}', disabling DiffieHellman kex groupex algorithms`);
let kex: string[] = require('ssh2-streams/lib/constants').ALGORITHMS.KEX;
kex = kex.filter(algo => !algo.includes('diffie-hellman-group-exchange'));
logging.debug(`\tResulting algorithms.kex: ${kex}`);
finalConfig.algorithms = { ...finalConfig.algorithms, kex };
}
client.connect(finalConfig);
} catch (e) {
reject(e);
}
@ -337,7 +381,7 @@ export async function getSFTP(client: Client, config: FileSystemConfig): Promise
if (config.sftpSudo) await startSudo(shell, config, config.sftpSudo);
shell.write(`echo SFTP READY\n`);
// Wait until we see "SFTP READY" (skipping welcome messages etc)
await new Promise((ready, nvm) => {
await new Promise<void>((ready, nvm) => {
const handler = (data: string | Buffer) => {
if (data.toString().trim() !== 'SFTP READY') return;
shell.stdout.removeListener('data', handler);

@ -12,7 +12,7 @@ import { pickComplex, PickComplexOptions, pickConnection, setAsAbsolutePath } fr
function getVersion(): string | undefined {
const ext = vscode.extensions.getExtension('Kelvin.vscode-sshfs');
return ext && ext.packageJSON && ext.packageJSON.version;
return ext?.packageJSON?.version;
}
interface CommandHandler {
@ -31,6 +31,17 @@ export function activate(context: vscode.ExtensionContext) {
setDebug(context.extensionMode !== vscode.ExtensionMode.Production);
// Likely that we'll have a breaking change in the future that requires users to check
// their configs, or at least reconfigure already existing workspaces with new URIs.
// See https://github.com/SchoofsKelvin/vscode-sshfs/issues/198#issuecomment-785926352
const previousVersion = context.globalState.get<string>('lastVersion');
context.globalState.update('lastVersion', getVersion());
if (!previousVersion) {
Logging.info('No previous version detected. Fresh or pre-v1.21.0 installation?');
} else if (previousVersion !== getVersion()) {
Logging.info(`Previously used version ${previousVersion}, first run after install.`);
}
// Really too bad we *need* the ExtensionContext for relative resources
// I really don't like having to pass context to *everything*, so let's do it this way
setAsAbsolutePath(context.asAbsolutePath.bind(context));
@ -74,7 +85,7 @@ export function activate(context: vscode.ExtensionContext) {
// sshfs.add(target?: string | FileSystemConfig)
registerCommandHandler('sshfs.add', {
promptOptions: { promptConfigs: true },
promptOptions: { promptConfigs: true, promptConnections: true, promptInstantConnection: true },
handleConfig: config => manager.commandConnect(config),
});
@ -95,7 +106,7 @@ export function activate(context: vscode.ExtensionContext) {
// sshfs.termninal(target?: string | FileSystemConfig | Connection | vscode.Uri)
registerCommandHandler('sshfs.terminal', {
promptOptions: { promptConfigs: true, promptConnections: true },
promptOptions: { promptConfigs: true, promptConnections: true, promptInstantConnection: true },
handleConfig: config => manager.commandTerminal(config),
handleConnection: con => manager.commandTerminal(con),
handleUri: async uri => {

@ -100,8 +100,12 @@ export interface FileSystemConfig extends ConnectConfig {
sftpSudo?: string | boolean;
/** The command(s) to run when a new SSH terminal gets created. Defaults to `$SHELL`. Internally the command `cd ...` is run first */
terminalCommand?: string | string[];
/** The command(s) to run when a `ssh-shell` task gets run. Defaults to the placeholder `$COMMAND`. Internally the command `cd ...` is run first */
taskCommand?: string | string[];
/** The filemode to assign to created files */
newFileMode?: number | string;
/** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */
instantConnection?: boolean;
/** Internal property saying where this config comes from. Undefined if this config is merged or something */
_location?: ConfigLocation;
/** Internal property keeping track of where this config comes from (including merges) */
@ -115,3 +119,35 @@ export function invalidConfigName(name: string) {
if (name.match(/^[\w_\\/.@\-+]+$/)) return null;
return `A SSH FS name can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@`;
}
/**
* https://regexr.com/5m3gl (mostly based on https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04)
* Supports several formats, the first one being the "full" format, with others being partial:
* - `user;abc=def,a-b=1-5@server.example.com:22/some/file.ext`
* - `user@server.example.com/directory`
* - `server:22/directory`
* - `user@server`
* - `server`
* - `@server/path` - Unlike OpenSSH, we allow a @ (and connection parameters) without a username
*
* The resulting FileSystemConfig will have as name basically the input, but without the path. If there is no
* username given, the name will start with `@`, as to differentiate between connection strings and config names.
*/
const CONNECTION_REGEX = /^((?<user>\w+)?(;[\w-]+=[\w\d-]+(,[\w\d-]+=[\w\d-]+)*)?@)?(?<host>[^@\\/:,=]+)(:(?<port>\d+))?(?<path>\/.*)?$/;
export function parseConnectionString(input: string): [config: FileSystemConfig, path?: string] | string {
input = input.trim();
const match = input.match(CONNECTION_REGEX);
if (!match) return 'Invalid format, expected something like "user@example.com:22/some/path"';
const { user, host, path } = match.groups!;
const portStr = match.groups!.port;
const port = portStr ? Number.parseInt(portStr) : undefined;
if (portStr && (!port || port < 1 || port > 65535)) return `The string '${port}' is not a valid port number`;
const name = `${user || ''}@${host}${port ? `:${port}` : ''}${path || ''}`;
return [{
name, host, port,
instantConnection: true,
username: user || '$USERNAME',
_locations: [],
}, path];
}

@ -1,7 +1,9 @@
import * as vscode from 'vscode';
import type { FileSystemConfig } from './fileSystemConfig';
// Since the Extension Development Host runs with debugger, we can use this to detect if we're debugging
// Since the Extension Development Host runs with debugger, we can use this to detect if we're debugging.
// The only things it currently does is copying Logging messages to the console, while also enabling
// the webview (Settings UI) from trying a local dev server first instead of the pre-built version.
export let DEBUG: boolean = false;
export function setDebug(debug: boolean) {
console.warn(`[vscode-sshfs] Debug mode set to ${debug}`);
@ -161,3 +163,12 @@ export function censorConfig(config: FileSystemConfig): CensoredFileSystemConfig
export const Logging = new (Logger as any) as Logger;
Logging.info('Created output channel for vscode-sshfs');
Logging.info(`When posting your logs somewhere, keep the following in mind:
- While the logging tries to censor your passwords/passphrases/..., double check!
Maybe you also want to censor out e.g. the hostname/IP you're connecting to.
- If you want to report an issue regarding authentication or something else that
seems to be more of an issue with the actual SSH2 connection, it might be handy
to reconnect with this added to your User Settings (settings.json) first:
"sshfs.flags": [ "DEBUG_SSH2" ],
This will (for new connections) also enable internal SSH2 logging.
`);

@ -2,10 +2,10 @@
import * as path from 'path';
import type { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode';
import { getConfig, getConfigs, loadConfigsRaw } from './config';
import { getConfig, loadConfigsRaw } from './config';
import { Connection, ConnectionManager } from './connection';
import type { FileSystemConfig } from './fileSystemConfig';
import { Logging } from './logging';
import { Logging, LOGGING_NO_STACKTRACE } from './logging';
import { isSSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem';
import { catchingPromise, toPromise } from './toPromise';
@ -29,7 +29,7 @@ function commandArgumentToName(arg?: string | FileSystemConfig | Connection): st
return `FileSystemConfig(${arg.name})`;
}
interface SSHShellTaskOptions {
interface SSHShellTaskOptions extends vscode.TaskDefinition {
host: string;
command: string;
workingDirectory?: string;
@ -63,7 +63,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
if (existing) return existing;
let con: Connection | undefined;
return this.creatingFileSystems[name] ||= catchingPromise<SSHFileSystem>(async (resolve, reject) => {
config = config || getConfigs().find(c => c.name === name);
config ||= getConfig(name);
if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`);
const con = await this.connectionManager.createConnection(name, config);
this.connectionManager.update(con, con => con.pendingUserCount++);
@ -126,7 +126,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
if (chosen === 'Retry') {
this.createFileSystem(name).catch(() => { });
} else if (chosen === 'Configure') {
this.commandConfigure(name);
this.commandConfigure(config || name);
} else {
this.commandDisconnect(name);
}
@ -134,7 +134,12 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
throw e;
});
}
public getRemotePath(config: FileSystemConfig, relativePath: string) {
public getRemotePath(config: FileSystemConfig, relativePath: string | vscode.Uri) {
if (relativePath instanceof vscode.Uri) {
if (relativePath.authority !== config.name)
throw new Error(`Uri authority for '${relativePath}' does not match config with name '${config.name}'`);
relativePath = relativePath.path;
}
if (relativePath.startsWith('/')) relativePath = relativePath.substr(1);
if (!config.root) return '/' + relativePath;
const result = path.posix.join(config.root, relativePath);
@ -147,8 +152,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
// Create connection (early so we have .actualConfig.root)
const con = (config && 'client' in config) ? config : await this.connectionManager.createConnection(name, config);
// Calculate working directory if applicable
let workingDirectory: string | undefined = uri && uri.path;
if (workingDirectory) workingDirectory = this.getRemotePath(con.actualConfig, workingDirectory);
const workingDirectory = uri && this.getRemotePath(con.actualConfig, uri);
// Create pseudo terminal
this.connectionManager.update(con, con => con.pendingUserCount++);
const pty = await createTerminal({ client: con.client, config: con.actualConfig, workingDirectory });
@ -169,32 +173,134 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
}
public async promptReconnect(name: string) {
const config = getConfig(name);
console.log('config', name, config);
if (!config) return;
const choice = await vscode.window.showWarningMessage(`SSH FS ${config.label || config.name} disconnected`, 'Ignore', 'Disconnect');
if (choice === 'Disconnect') this.commandDisconnect(name);
}
/* TaskProvider */
protected async replaceTaskVariables(value: string, config: FileSystemConfig): Promise<string> {
return value.replace(/\$\{(.*?)\}/g, (str, match: string) => {
if (!match.startsWith('remote')) return str; // Our variables always start with "remote"
// https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/workbench/services/configurationResolver/common/variableResolver.ts#L156
const [key, argument] = match.split(':') as [string, string?];
const getFilePath = (): vscode.Uri => {
const uri = vscode.window.activeTextEditor?.document?.uri;
if (uri && uri.scheme === 'ssh') return uri;
if (uri) throw new Error(`Variable ${str}: Active editor is not a ssh:// file`);
throw new Error(`Variable ${str} can not be resolved. Please open an editor.`);
}
const getFolderPathForFile = (): vscode.Uri => {
const filePath = getFilePath();
const uri = vscode.workspace.getWorkspaceFolder(filePath)?.uri;
if (uri) return uri;
throw new Error(`Variable ${str}: can not find workspace folder of '${filePath}'.`);
}
const { workspaceFolders = [] } = vscode.workspace;
const sshFolders = workspaceFolders.filter(ws => ws.uri.scheme === 'ssh');
const sshFolder = sshFolders.length === 1 ? sshFolders[0] : undefined;
const getFolderUri = (): vscode.Uri => {
const { workspaceFolders = [] } = vscode.workspace;
if (argument) {
const uri = workspaceFolders.find(ws => ws.name === argument)?.uri;
if (uri && uri.scheme === 'ssh') return uri;
if (uri) throw new Error(`Variable ${str}: Workspace folder '${argument}' is not a ssh:// folder`);
throw new Error(`Variable ${str} can not be resolved. No such folder '${argument}'.`);
}
if (sshFolder) return sshFolder.uri;
if (sshFolders.length > 1) {
throw new Error(`Variable ${str} can not be resolved in a multi ssh:// folder workspace. Scope this variable using ':' and a workspace folder name.`);
}
throw new Error(`Variable ${str} can not be resolved. Please open an ssh:// folder.`);
};
switch (key) {
case 'remoteWorkspaceRoot':
case 'remoteWorkspaceFolder':
return this.getRemotePath(config, getFolderUri());
case 'remoteWorkspaceRootFolderName':
case 'remoteWorkspaceFolderBasename':
return path.basename(getFolderUri().path);
case 'remoteFile':
return this.getRemotePath(config, getFilePath());
case 'remoteFileWorkspaceFolder':
return this.getRemotePath(config, getFolderPathForFile());
case 'remoteRelativeFile':
if (sshFolder || argument)
return path.relative(getFolderUri().path, getFilePath().path);
return getFilePath().path;
case 'remoteRelativeFileDirname': {
const dirname = path.dirname(getFilePath().path);
if (sshFolder || argument) {
const relative = path.relative(getFolderUri().path, dirname);
return relative.length === 0 ? '.' : relative;
}
return dirname;
}
case 'remoteFileDirname':
return path.dirname(getFilePath().path);
case 'remoteFileExtname':
return path.extname(getFilePath().path);
case 'remoteFileBasename':
return path.basename(getFilePath().path);
case 'remoteFileBasenameNoExtension': {
const basename = path.basename(getFilePath().path);
return (basename.slice(0, basename.length - path.extname(basename).length));
}
case 'remoteFileDirnameBasename':
return path.basename(path.dirname(getFilePath().path));
case 'remotePathSeparator':
// Not sure if we even need/want this variable, but sure
return path.posix.sep;
default:
const msg = `Unrecognized task variable '${str}' starting with 'remote', ignoring`;
Logging.warning(msg, LOGGING_NO_STACKTRACE);
vscode.window.showWarningMessage(msg);
return str;
}
});
}
protected async replaceTaskVariablesRecursive<T>(object: T, handler: (value: string) => string | Promise<string>): Promise<T> {
if (typeof object === 'string') return handler(object) as any;
if (Array.isArray(object)) return object.map(v => this.replaceTaskVariablesRecursive(v, handler)) as any;
if (typeof object == 'object' && object !== null && !(object instanceof RegExp) && !(object instanceof Date)) {
// ^ Same requirements VS Code applies: https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/base/common/types.ts#L34
const result: any = {};
for (let key in object) {
const value = await this.replaceTaskVariablesRecursive(object[key], handler);
key = await this.replaceTaskVariablesRecursive(key, handler);
result[key] = value;
}
return result;
}
return object;
}
public provideTasks(token?: vscode.CancellationToken | undefined): vscode.ProviderResult<vscode.Task[]> {
return [];
}
public async resolveTask(task: vscode.Task, token?: vscode.CancellationToken | undefined): Promise<vscode.Task> {
let { host, command, workingDirectory } = task.definition as unknown as SSHShellTaskOptions;
if (!host) throw new Error('Missing field \'host\' for ssh-shell task');
if (!command) throw new Error('Missing field \'command\' for ssh-shell task');
const config = getConfig(host);
if (!config) throw new Error(`No configuration with the name '${host}' found for ssh-shell task`);
// Calculate working directory if applicable
if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory);
return new vscode.Task(
task.definition,
task.definition, // Can't replace/modify this, otherwise we're not contributing to "this" task
vscode.TaskScope.Workspace,
`SSH Task '${task.name}' for ${host}`,
`SSH Task '${task.name}'`,
'ssh',
new vscode.CustomExecution(async () => {
const connection = await this.connectionManager.createConnection(host);
new vscode.CustomExecution(async (resolved: SSHShellTaskOptions) => {
const { createTerminal, createTextTerminal, joinCommands } = await import('./pseudoTerminal');
try {
if (!resolved.host) throw new Error('Missing field \'host\' in task description');
if (!resolved.command) throw new Error('Missing field \'command\' in task description');
const connection = await this.connectionManager.createConnection(resolved.host);
resolved = await this.replaceTaskVariablesRecursive(resolved, value => this.replaceTaskVariables(value, connection.actualConfig));
let { command, workingDirectory } = resolved;
let { taskCommand = '$COMMAND' } = connection.actualConfig;
taskCommand = joinCommands(taskCommand)!;
if (taskCommand.includes('$COMMAND')) {
command = taskCommand.replace(/\$COMMAND/g, command);
} else {
const message = `The taskCommand '${taskCommand}' is missing the '$COMMAND' placeholder!`;
Logging.warning(message, LOGGING_NO_STACKTRACE);
command = `echo "Missing '$COMMAND' placeholder"`;
}
//if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory);
this.connectionManager.update(connection, con => con.pendingUserCount++);
const { createTerminal } = await import('./pseudoTerminal');
const pty = await createTerminal({
command, workingDirectory,
client: connection.client,
@ -204,6 +310,9 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
pty.onDidClose(() => this.connectionManager.update(connection,
con => con.terminals = con.terminals.filter(t => t !== pty)));
return pty;
} catch (e) {
return createTextTerminal(`Error: ${e.message || e}`);
}
})
)
}
@ -216,7 +325,6 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
if (!isSSHPseudoTerminal(pty)) return;
const conn = this.connectionManager.getActiveConnections().find(c => c.terminals.includes(pty));
if (!conn) return; // Connection died, which means the terminal should also be closed already?
console.log('provideTerminalLinks', line, pty.config.root, conn ? conn.filesystems.length : 'No connection?');
const links: TerminalLinkUri[] = [];
const PATH_REGEX = /\/\S+/g;
while (true) {
@ -298,13 +406,20 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
public async commandConfigure(target: string | FileSystemConfig) {
Logging.info(`Command received to configure ${typeof target === 'string' ? target : target.name}`);
if (typeof target === 'object') {
if (!target._location && !target._locations.length) {
vscode.window.showErrorMessage('Cannot configure a config-less connection!');
return;
}
this.openSettings({ config: target, type: 'editconfig' });
return;
}
target = target.toLowerCase();
let configs = await loadConfigsRaw();
configs = configs.filter(c => c.name === target);
if (configs.length === 0) throw new Error('Unexpectedly found no matching configs?');
if (configs.length === 0) {
vscode.window.showErrorMessage(`Found no matching configs for '${target}'`);
return Logging.error(`Unexpectedly found no matching configs for '${target}' in commandConfigure?`);
}
const config = configs.length === 1 ? configs[0] : configs;
this.openSettings({ config, type: 'editconfig' });
}

@ -36,10 +36,10 @@ export interface TerminalOptions {
command?: string;
}
function joinCommands(commands?: string | string[]): string | undefined {
export function joinCommands(commands?: string | string[]): string | undefined {
if (!commands) return undefined;
if (typeof commands === 'string') return commands;
return commands.join(';');
return commands.join('; ');
}
export async function createTerminal(options: TerminalOptions): Promise<SSHPseudoTerminal> {
@ -116,3 +116,24 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
};
return pseudo;
}
export interface TextTerminal extends vscode.Pseudoterminal {
write(text: string): void;
close(code?: number): void;
onDidClose: vscode.Event<number>; // Redeclaring that it isn't undefined
onDidOpen: vscode.Event<void>;
}
export function createTextTerminal(initialText?: string): TextTerminal {
const onDidWrite = new vscode.EventEmitter<string>();
const onDidClose = new vscode.EventEmitter<number>();
const onDidOpen = new vscode.EventEmitter<void>();
return {
write: onDidWrite.fire.bind(onDidWrite),
close: onDidClose.fire.bind(onDidClose),
onDidWrite: onDidWrite.event,
onDidClose: onDidClose.event,
onDidOpen: onDidOpen.event,
open: () => initialText && (onDidWrite.fire(initialText + '\r\n'), onDidClose.fire(1)),
};
}

@ -10,7 +10,7 @@ const winreg = new Winreg({
export type NumberAsBoolean = 0 | 1;
export interface PuttySession {
[key: string]: string | number | undefined;
//[key: string]: string | number | undefined;
// General settings
name: string;
hostname: string;
@ -24,7 +24,7 @@ export interface PuttySession {
proxyhost?: string;
proxyport: number;
proxylocalhost: NumberAsBoolean;
proxymethod: number; // Key of ['None', 'SOCKS 4', 'SOCKS 5', 'HTTP', 'Telnet', 'Local'] // Only checked first 3
proxymethod: number; // Key of ['None', 'SOCKS 4', 'SOCKS 5', 'HTTP', 'Telnet', 'Local']
}
function valueFromItem(item: Winreg.RegistryItem) {
@ -37,11 +37,22 @@ function valueFromItem(item: Winreg.RegistryItem) {
throw new Error(`Unknown RegistryItem type: '${item.type}'`);
}
const FORMATTED_FIELDS: (keyof PuttySession)[] = [
'name', 'hostname', 'protocol', 'portnumber',
'username', 'usernamefromenvironment', 'tryagent', 'publickeyfile',
'proxyhost', 'proxyport', 'proxylocalhost', 'proxymethod',
];
export function formatSession(session: PuttySession): string {
const partial: Partial<PuttySession> = {};
for (const field of FORMATTED_FIELDS) partial[field] = session[field] as any;
return JSON.stringify(partial);
}
export async function getSessions() {
Logging.info(`Fetching PuTTY sessions from registry`);
const values = await toPromise<Winreg.Registry[]>(cb => winreg.keys(cb));
const sessions: PuttySession[] = [];
await Promise.all(values.map(regSession => (async (res, rej) => {
await Promise.all(values.map(regSession => (async () => {
const name = decodeURIComponent(regSession.key.substr(winreg.key.length));
const props = await toPromise<Winreg.RegistryItem[]>(cb => regSession.values(cb));
const properties: { [key: string]: string | number } = {};
@ -49,21 +60,30 @@ export async function getSessions() {
sessions.push({ name, ...(properties as any) });
})()));
Logging.debug(`\tFound ${sessions.length} sessions:`);
sessions.forEach(s => Logging.debug(`\t${JSON.stringify(s)}`));
sessions.forEach(s => Logging.debug(`\t- ${formatSession(s)}`));
return sessions;
}
export async function getSession(name?: string, host?: string, username?: string, nameOnly = false): Promise<PuttySession | null> {
const sessions = await getSessions();
export async function findSession(sessions: PuttySession[], name?: string, host?: string, username?: string, nameOnly = true): Promise<PuttySession | undefined> {
if (name) {
name = name.toLowerCase();
const session = sessions.find(s => s.name.toLowerCase() === name) || null;
const session = sessions.find(s => s.name.toLowerCase() === name);
if (nameOnly || session) return session;
}
if (!host) return null;
if (!host) return undefined;
host = host.toLowerCase();
const hosts = sessions.filter(s => s.hostname && s.hostname.toLowerCase() === host);
if (!username) return hosts[0] || null;
username = username.toLowerCase();
return hosts.find(s => !s.username || s.username.toLowerCase() === username) || null;
return hosts.find(s => !s.username || s.username.toLowerCase() === username);
}
export async function getSession(name?: string, host?: string, username?: string, nameOnly = true): Promise<PuttySession | undefined> {
const sessions = await getSessions();
return findSession(sessions, name, host, username, nameOnly);
}
export async function getCachedFinder(): Promise<typeof getSession> {
const sessions = await getSessions();
return (...args) => findSession(sessions, ...args);
}

@ -11,7 +11,7 @@ import { Logger, Logging, LOGGING_NO_STACKTRACE, LOGGING_SINGLE_LINE_STACKTRACE,
// (usually the errors we report on happen deep inside ssh2 or ssh2-streams, we don't really care that much about it)
const LOGGING_HANDLE_ERROR = withStacktraceOffset(1, { ...LOGGING_SINGLE_LINE_STACKTRACE, maxErrorStack: 4 });
// All absolute paths (relative to the FS root)
// All absolute paths (relative to the FS root or a workspace root)
// If it ends with /, .startsWith is used, otherwise a raw equal
const IGNORE_NOT_FOUND: string[] = [
'/.vscode',
@ -19,9 +19,17 @@ const IGNORE_NOT_FOUND: string[] = [
'/.git/',
'/node_modules',
'/pom.xml',
'/app/src/main/AndroidManifest.xml',
];
function shouldIgnoreNotFound(path: string) {
return IGNORE_NOT_FOUND.some(entry => entry === path || entry.endsWith('/') && path.startsWith(entry));
function shouldIgnoreNotFound(target: string) {
if (IGNORE_NOT_FOUND.some(entry => entry === target || entry.endsWith('/') && target.startsWith(entry))) return true;
for (const { uri: { path: wsPath } } of vscode.workspace.workspaceFolders || []) {
if (!target.startsWith(wsPath)) continue;
let local = path.posix.relative(wsPath, target);
if (!local.startsWith('/')) local = `/${local}`;
if (IGNORE_NOT_FOUND.some(entry => entry === local || entry.endsWith('/') && local.startsWith(entry))) return true;
}
return false;
}
export class SSHFileSystem implements vscode.FileSystemProvider {
@ -58,7 +66,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
this.waitForContinue = false;
if (this.closed) return reject(new Error('Connection closed'));
try {
const canContinue = func((err, res) => err ? reject(err) : resolve(res));
const canContinue = func((err, res) => err ? reject(err) : resolve(res!));
if (!canContinue) this.waitForContinue = true;
} catch (e) {
reject(e);

@ -3,7 +3,7 @@ export type toPromiseCallback<T> = (err?: Error | null, res?: T) => void;
export async function toPromise<T>(func: (cb: toPromiseCallback<T>) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
try {
func((err, res) => err ? reject(err) : resolve(res));
func((err, res) => err ? reject(err) : resolve(res!));
} catch (e) {
reject(e);
}

@ -2,7 +2,7 @@
import * as vscode from 'vscode';
import { getConfigs } from './config';
import type { Connection } from './connection';
import type { FileSystemConfig } from './fileSystemConfig';
import { parseConnectionString, FileSystemConfig } from './fileSystemConfig';
import type { Manager } from './manager';
import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem';
@ -70,6 +70,8 @@ export interface PickComplexOptions {
immediateReturn?: boolean;
/** If true, add all connections. If this is a string, filter by config name first */
promptConnections?: boolean | string;
/** If true, add an option to enter a connection string */
promptInstantConnection?: boolean;
/** If true, add all configurations. If this is a string, filter by config name first */
promptConfigs?: boolean | string;
/** If true, add all terminals. If this is a string, filter by config name first */
@ -78,10 +80,27 @@ export interface PickComplexOptions {
nameFilter?: string;
}
async function inputInstantConnection(value?: string): Promise<FileSystemConfig | undefined> {
const valueSelection = value ? [value.length, value.length] as [number, number] : undefined;
const name = await vscode.window.showInputBox({
value, valueSelection,
placeHolder: 'user@host:/home/user',
prompt: 'SSH connection string',
validateInput(value: string) {
const result = parseConnectionString(value);
return typeof result === 'string' ? result : undefined;
}
});
if (!name) return;
const result = parseConnectionString(name);
if (typeof result === 'string') return;
return result[0];
}
export async function pickComplex(manager: Manager, options: PickComplexOptions):
Promise<FileSystemConfig | Connection | SSHPseudoTerminal | undefined> {
return new Promise((resolve) => {
const { promptConnections, promptConfigs, promptTerminals, immediateReturn, nameFilter } = options;
return new Promise<any>((resolve) => {
const { promptConnections, promptConfigs, nameFilter } = options;
const items: QuickPickItemWithItem[] = [];
const toSelect: string[] = [];
if (promptConnections) {
@ -98,7 +117,7 @@ export async function pickComplex(manager: Manager, options: PickComplexOptions)
items.push(...configs.map(config => formatItem(config, true)));
toSelect.push('configuration');
}
if (promptTerminals) {
if (options.promptTerminals) {
let cons = manager.connectionManager.getActiveConnections();
if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections);
if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter);
@ -106,16 +125,27 @@ export async function pickComplex(manager: Manager, options: PickComplexOptions)
items.push(...terminals.map(config => formatItem(config, true)));
toSelect.push('terminal');
}
if (immediateReturn && items.length <= 1) return resolve(items[0]?.item);
if (options.promptInstantConnection) {
items.unshift({
label: '$(terminal) Create instant connection',
detail: 'Opens an input box where you can type e.g. `user@host:22/home/user`',
picked: true, alwaysShow: true,
item: inputInstantConnection,
});
}
if (options.immediateReturn && items.length <= 1) return resolve(items[0]?.item);
const quickPick = vscode.window.createQuickPick<QuickPickItemWithItem>();
quickPick.items = items;
quickPick.title = 'Select ' + toSelect.join(' / ');
quickPick.onDidAccept(() => {
const value = quickPick.activeItems[0]?.item;
let value = quickPick.activeItems[0]?.item;
quickPick.hide();
if (typeof value === 'function') {
value = value(quickPick.value);
}
resolve(value);
});
quickPick.onDidHide(() => resolve());
quickPick.onDidHide(() => resolve(undefined));
quickPick.show();
});
}

@ -3,10 +3,10 @@
"strictNullChecks": true,
"module": "esnext",
"moduleResolution": "node",
"target": "es6",
"target": "ES2019",
"outDir": "out",
"lib": [
"es6"
"ES2019"
],
"sourceMap": true,
"rootDir": "src"

@ -34,15 +34,15 @@ export function merge(config: FileSystemConfig, onChange: FSCChanged<'merge'>):
}
export function label(config: FileSystemConfig, onChange: FSCChanged<'label'>): React.ReactElement {
const callback = (value?: string) => onChange('label', value);
const callback = (newValue?: string) => onChange('label', newValue);
const description = 'Label to display in some UI places (e.g. popups)';
return <FieldString key="label" label="Label" value={config.label} onChange={callback} optional={true} description={description} />
return <FieldString key="label" label="Label" value={config.label} onChange={callback} optional description={description} />
}
export function group(config: FileSystemConfig, onChange: FSCChanged<'group'>): React.ReactElement {
const callback = (newValue: string) => onChange('group', newValue);
const description = 'Group for this config, to group configs together in some UI places. Allows subgroups, in the format "Group1.SubGroup1.Subgroup2"';
return <FieldConfigGroup key="group" label="Group" value={config.group} {...{ description }} onChange={callback} optional={true} />
return <FieldConfigGroup key="group" label="Group" value={config.group} {...{ description }} onChange={callback} optional />
}
export function putty(config: FileSystemConfig, onChange: FSCChanged<'putty'>): React.ReactElement {
@ -50,7 +50,7 @@ export function putty(config: FileSystemConfig, onChange: FSCChanged<'putty'>):
const description = 'A name of a PuTTY session, or `true` to find the PuTTY session from the host address';
const values = ['<Auto-detect>'];
const value = config.putty === true ? '<Auto-detect>' : config.putty || undefined;
return <FieldDropdownWithInput key="putty" label="PuTTY" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="putty" label="PuTTY" {...{ value, values, description }} onChange={callback} optional />
}
export function host(config: FileSystemConfig, onChange: FSCChanged<'host'>): React.ReactElement {
@ -58,26 +58,26 @@ export function host(config: FileSystemConfig, onChange: FSCChanged<'host'>): Re
const description = 'Hostname or IP address of the server. Supports environment variables, e.g. $HOST';
const values = ['<Prompt>'];
const value = (config.host as any) === true ? '<Prompt>' : config.host;
return <FieldDropdownWithInput key="host" label="Host" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="host" label="Host" {...{ value, values, description }} onChange={callback} optional />
}
export function port(config: FileSystemConfig, onChange: FSCChanged<'port'>): React.ReactElement {
const callback = (value: number) => onChange('port', value);
const description = 'Port number of the server. Supports environment variables, e.g. $PORT';
return <FieldNumber key="port" label="Port" value={config.port} onChange={callback} optional={true} description={description} />
return <FieldNumber key="port" label="Port" value={config.port} onChange={callback} optional description={description} />
}
export function root(config: FileSystemConfig, onChange: FSCChanged<'root'>): React.ReactElement {
const callback = (value: string) => onChange('root', value);
const description = 'Path on the remote server where the root path in vscode should point to. Defaults to /';
return <FieldString key="root" label="Root" value={config.root} onChange={callback} optional={true} validator={pathValidator} description={description} />
return <FieldString key="root" label="Root" value={config.root} onChange={callback} optional validator={pathValidator} description={description} />
}
export function agent(config: FileSystemConfig, onChange: FSCChanged<'agent'>): React.ReactElement {
const callback = (newValue: string) => onChange('agent', newValue);
const description = `Path to ssh-agent's UNIX socket for ssh-agent-based user authentication. Supports 'pageant' for PuTTY's Pagent, and environment variables, e.g. $SSH_AUTH_SOCK`;
const values = ['pageant', '//./pipe/openssh-ssh-agent', '$SSH_AUTH_SOCK'];
return <FieldDropdownWithInput key="agent" label="Agent" {...{ value: config.agent, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="agent" label="Agent" {...{ value: config.agent, values, description }} onChange={callback} optional />
}
export function username(config: FileSystemConfig, onChange: FSCChanged<'username'>): React.ReactElement {
@ -85,7 +85,7 @@ export function username(config: FileSystemConfig, onChange: FSCChanged<'usernam
const description = 'Username for authentication. Supports environment variables, e.g. $USERNAME';
const values = ['<Prompt>', '$USERNAME'];
const value = (config.username as any) === true ? '<Prompt>' : config.username;
return <FieldDropdownWithInput key="username" label="Username" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="username" label="Username" {...{ value, values, description }} onChange={callback} optional />
}
export function password(config: FileSystemConfig, onChange: FSCChanged<'password'>): React.ReactElement {
@ -93,13 +93,13 @@ export function password(config: FileSystemConfig, onChange: FSCChanged<'passwor
const description = 'Password for password-based user authentication. Supports env variables. This gets saved in plaintext! Using prompts or private keys is recommended!';
const values = ['<Prompt>'];
const value = (config.password as any) === true ? '<Prompt>' : config.password;
return <FieldDropdownWithInput key="password" label="Password" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="password" label="Password" {...{ value, values, description }} onChange={callback} optional />
}
export function privateKeyPath(config: FileSystemConfig, onChange: FSCChanged<'privateKeyPath'>): React.ReactElement {
const callback = (value?: string) => onChange('privateKeyPath', value);
const callback = (newValue?: string) => onChange('privateKeyPath', newValue);
const description = 'A path to a private key. Supports environment variables, e.g. `$USERPROFILE/.ssh/myKey.ppk` or `$HOME/.ssh/myKey`';
return <FieldPath key="privateKeyPath" label="Private key" value={config.privateKeyPath} onChange={callback} optional={true} description={description} />
return <FieldPath key="privateKeyPath" label="Private key" value={config.privateKeyPath} onChange={callback} optional description={description} />
}
export function passphrase(config: FileSystemConfig, onChange: FSCChanged<'passphrase'>): React.ReactElement {
@ -107,21 +107,21 @@ export function passphrase(config: FileSystemConfig, onChange: FSCChanged<'passp
const description = 'Passphrase for unlocking an encrypted private key. Supports env variables. This gets saved in plaintext! Using prompts or private keys is recommended!';
const values = ['<Prompt>'];
const value = (config.passphrase as any) === true ? '<Prompt>' : config.passphrase;
return <FieldDropdownWithInput key="passphrase" label="Passphrase" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="passphrase" label="Passphrase" {...{ value, values, description }} onChange={callback} optional />
}
export function sftpCommand(config: FileSystemConfig, onChange: FSCChanged<'sftpCommand'>): React.ReactElement {
const callback = (newValue?: string) => onChange('sftpCommand', newValue);
const description = 'A command to run on the remote SSH session to start a SFTP session (defaults to sftp subsystem)';
return <FieldString key="sftpCommand" label="SFTP Command" value={config.sftpCommand} onChange={callback} optional={true} validator={pathValidator} description={description} />
return <FieldString key="sftpCommand" label="SFTP Command" value={config.sftpCommand} onChange={callback} optional validator={pathValidator} description={description} />
}
export function sftpSudo(config: FileSystemConfig, onChange: FSCChanged<'sftpSudo'>): React.ReactElement {
const callback = (newValue?: string) => onChange('sftpSudo', newValue === '<Default>' ? true : newValue);
const description = 'Whether to use a sudo shell (and for which user) to run the sftpCommand in (if present, gets passed as -u to sudo)';
const values = ['<Default>'];
const value = (config.sftpSudo && typeof config.sftpSudo === 'string') ? config.sftpSudo : '<Default>';
return <FieldDropdownWithInput key="sftpSudo" label="SFTP Sudo" {...{ value, values, description }} onChange={callback} />
const value = config.sftpSudo === true ? '<Default>' : (typeof config.sftpSudo === 'string' ? config.sftpSudo : undefined);
return <FieldDropdownWithInput key="sftpSudo" label="SFTP Sudo" {...{ value, values, description }} onChange={callback} optional />
}
export function terminalCommand(config: FileSystemConfig, onChange: FSCChanged<'terminalCommand'>): React.ReactElement {
@ -130,12 +130,21 @@ export function terminalCommand(config: FileSystemConfig, onChange: FSCChanged<'
const values = ['$SHELL', '/usr/bin/bash', '/usr/bin/sh'];
let value = config.terminalCommand === '$SHELL' ? '' : config.terminalCommand || '';
if (Array.isArray(value)) value = value.join('; ');
return <FieldDropdownWithInput key="terminalCommand" label="Terminal command" {...{ value, values, description }} onChange={callback} optional={true} />
return <FieldDropdownWithInput key="terminalCommand" label="Terminal command" {...{ value, values, description }} onChange={callback} optional />
}
export function taskCommand(config: FileSystemConfig, onChange: FSCChanged<'taskCommand'>): React.ReactElement {
const callback = (newValue?: string) => onChange('taskCommand', newValue);
const description = 'The command(s) to run when a `ssh-shell` task gets run. Defaults to the placeholder `$COMMAND`. Internally the command `cd ...` is run first';
const values = ['$COMMAND'];
let value = config.taskCommand;
if (Array.isArray(value)) value = value.join('; ');
return <FieldDropdownWithInput key="taskCommand" label="Task command" {...{ value, values, description }} onChange={callback} optional />
}
export type FieldFactory = (config: FileSystemConfig, onChange: FSCChanged, onChangeMultiple: FSCChangedMultiple) => React.ReactElement | null;
export const FIELDS: FieldFactory[] = [
name, merge, label, group, putty, host, port,
root, agent, username, password, privateKeyPath, passphrase,
sftpCommand, sftpSudo, terminalCommand,
sftpCommand, sftpSudo, terminalCommand, taskCommand,
PROXY_FIELD];

@ -25,7 +25,7 @@ class ConfigEditor extends React.Component<StateProps & DispatchProps> {
return <FieldGroup>
<div className="ConfigEditor">
<div className="header">
<button className="cancel" onClick={this.props.cancel}>Cancel</button>
<button className="cancel" onClick={this.props.cancel}>Back</button>
<div className="title">
<h3>{oldConfig.label || oldConfig.name}</h3>
<h4>{formatConfigLocation(oldConfig._location!)}</h4>

@ -98,8 +98,12 @@ export interface FileSystemConfig extends ConnectConfig {
sftpSudo?: string | boolean;
/** The command(s) to run when a new SSH terminal gets created. Defaults to `$SHELL`. Internally the command `cd ...` is run first */
terminalCommand?: string | string[];
/** The command(s) to run when a `ssh-shell` task gets run. Defaults to the placeholder `$COMMAND`. Internally the command `cd ...` is run first */
taskCommand?: string | string[];
/** The filemode to assign to created files */
newFileMode?: number | string;
/** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */
instantConnection?: boolean;
/** Internal property saying where this config comes from. Undefined if this config is merged or something */
_location?: ConfigLocation;
/** Internal property keeping track of where this config comes from (including merges) */
@ -113,3 +117,35 @@ export function invalidConfigName(name: string) {
if (name.match(/^[\w_\\/.@\-+]+$/)) return null;
return `A SSH FS name can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@`;
}
/**
* https://regexr.com/5m3gl (mostly based on https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04)
* Supports several formats, the first one being the "full" format, with others being partial:
* - `user;abc=def,a-b=1-5@server.example.com:22/some/file.ext`
* - `user@server.example.com/directory`
* - `server:22/directory`
* - `user@server`
* - `server`
* - `@server/path` - Unlike OpenSSH, we allow a @ (and connection parameters) without a username
*
* The resulting FileSystemConfig will have as name basically the input, but without the path. If there is no
* username given, the name will start with `@`, as to differentiate between connection strings and config names.
*/
const CONNECTION_REGEX = /^((?<user>\w+)?(;[\w-]+=[\w\d-]+(,[\w\d-]+=[\w\d-]+)*)?@)?(?<host>[^@\\/:,=]+)(:(?<port>\d+))?(?<path>\/.*)?$/;
export function parseConnectionString(input: string): [config: FileSystemConfig, path?: string] | string {
input = input.trim();
const match = input.match(CONNECTION_REGEX);
if (!match) return 'Invalid format, expected something like "user@example.com:22/some/path"';
const { user, host, path } = match.groups!;
const portStr = match.groups!.port;
const port = portStr ? Number.parseInt(portStr) : undefined;
if (portStr && (!port || port < 1 || port > 65535)) return `The string '${port}' is not a valid port number`;
const name = `${user || ''}@${host}${port ? `:${port}` : ''}${path || ''}`;
return [{
name, host, port,
instantConnection: true,
username: user || '$USERNAME',
_locations: [],
}, path];
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save