From 6eb760448be2339fd456bf75a21e608c7bc48750 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 1 Aug 2023 14:51:54 -0700 Subject: [PATCH] File Explorer: Introduce sftp connection method (#139) Currently behind the setting: `tailscale.nodeExplorer.connectionMethod` which defaults to `ssh` --------- Signed-off-by: Tyler Smalley Co-authored-by: Marwan Sulaiman --- package.json | 22 +++ src/config-manager.test.ts | 2 +- src/config-manager.ts | 4 +- src/extension.ts | 21 ++- src/filesystem-provider-sftp.ts | 108 +++++++++++++++ ...provider.ts => filesystem-provider-ssh.ts} | 39 +++--- src/filesystem-provider-timing.ts | 94 +++++++++++++ src/filesystem-provider.ts | 21 +++ src/node-explorer-provider.ts | 117 ++++++++-------- src/sftp.ts | 85 ++++++++++++ src/ssh-connection-manager.ts | 125 ++++++++++++++++++ src/{utils => }/ssh.ts | 4 +- src/utils/host.ts | 14 ++ src/utils/uri.test.ts | 100 ++++++++++---- src/utils/uri.ts | 25 +++- webpack.config.mjs | 4 + yarn.lock | 93 ++++++++++++- 17 files changed, 750 insertions(+), 128 deletions(-) create mode 100644 src/filesystem-provider-sftp.ts rename src/{ts-file-system-provider.ts => filesystem-provider-ssh.ts} (83%) create mode 100644 src/filesystem-provider-timing.ts create mode 100644 src/filesystem-provider.ts create mode 100644 src/sftp.ts create mode 100644 src/ssh-connection-manager.ts rename src/{utils => }/ssh.ts (97%) create mode 100644 src/utils/host.ts diff --git a/package.json b/package.json index 5c4ffbc..71625a0 100644 --- a/package.json +++ b/package.json @@ -267,6 +267,10 @@ "command": "tailscale.node.fs.delete", "title": "Delete" }, + { + "command": "tailscale.node.fs.createDirectory", + "title": "Create Directory" + }, { "command": "tailscale.node.setUsername", "title": "Change SSH username" @@ -341,6 +345,15 @@ "default": false, "markdownDescription": "(IN DEVELOPMENT) Enable the Tailscale Node Explorer view." }, + "tailscale.nodeExplorer.connectionMethod": { + "type": "string", + "enum": [ + "ssh", + "sftp" + ], + "default": "ssh", + "markdownDescription": "The connection method to use interacting with the File system view within the Node Explorer view." + }, "tailscale.ssh.defaultUsername": { "type": "string", "default": null, @@ -349,6 +362,12 @@ "examples": [ "amelie" ] + }, + "tailscale.ssh.connectionTimeout": { + "type": "number", + "default": 30000, + "markdownDescription": "The connection timeout for SSH connections in milliseconds.", + "scope": "window" } } } @@ -372,6 +391,7 @@ "@types/node": "16.11.68", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/ssh2": "^1.11.13", "@types/vscode": "^1.74.0", "@types/vscode-webview": "^1.57.1", "@typescript-eslint/eslint-plugin": "^6.1.0", @@ -391,11 +411,13 @@ "husky": "^8.0.3", "lint-staged": "^13.2.3", "node-fetch": "^3.3.1", + "node-loader": "^2.0.0", "postcss": "^8.4.26", "postcss-loader": "^7.3.3", "prettier": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "ssh2": "^1.14.0", "style-loader": "^3.3.3", "swr": "^2.2.0", "tailwindcss": "^3.3.3", diff --git a/src/config-manager.test.ts b/src/config-manager.test.ts index 66afcb2..b73c62e 100644 --- a/src/config-manager.test.ts +++ b/src/config-manager.test.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { test, expect, beforeEach } from 'vitest'; +import { test, expect, beforeEach, vi } from 'vitest'; import { ConfigManager } from './config-manager'; const fsPath = '/tmp/vscode-tailscale'; diff --git a/src/config-manager.ts b/src/config-manager.ts index 261b7f7..e338e88 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -1,4 +1,4 @@ -import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; @@ -24,7 +24,7 @@ export class ConfigManager { } } - static withGlobalStorageUri(globalStorageUri: vscode.Uri) { + static withGlobalStorageUri(globalStorageUri: Uri) { const globalStoragePath = globalStorageUri.fsPath; if (!fs.existsSync(globalStoragePath)) { diff --git a/src/extension.ts b/src/extension.ts index 60e0706..5bfdf4c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,10 +8,13 @@ import { Logger } from './logger'; import { errorForType } from './tailscale/error'; import { FileExplorer, NodeExplorerProvider, PeerTree } from './node-explorer-provider'; -import { TSFileSystemProvider } from './ts-file-system-provider'; +import { FileSystemProviderSFTP } from './filesystem-provider-sftp'; import { ConfigManager } from './config-manager'; -import { SSH } from './utils/ssh'; import { parseTsUri } from './utils/uri'; +import { EXTENSION_NS } from './constants'; +import { FileSystemProviderSSH } from './filesystem-provider-ssh'; +import { WithFSTiming } from './filesystem-provider-timing'; +import { FileSystemProvider } from './filesystem-provider'; let tailscaleInstance: Tailscale; @@ -21,7 +24,6 @@ export async function activate(context: vscode.ExtensionContext) { tailscaleInstance = await Tailscale.withInit(vscode); const configManager = ConfigManager.withGlobalStorageUri(context.globalStorageUri); - const ssh = new SSH(configManager); // walkthrough completion tailscaleInstance.serveStatus().then((status) => { @@ -55,9 +57,16 @@ export async function activate(context: vscode.ExtensionContext) { tailscaleInstance ); - const tsFileSystemProvider = new TSFileSystemProvider(configManager); + const connMethod = vscode.workspace + .getConfiguration(EXTENSION_NS) + .get('nodeExplorer.connectionMethod'); + + const Provider = connMethod === 'ssh' ? FileSystemProviderSSH : FileSystemProviderSFTP; + let fileSystemProvider: FileSystemProvider = new Provider(configManager); + fileSystemProvider = new WithFSTiming(fileSystemProvider); + context.subscriptions.push( - vscode.workspace.registerFileSystemProvider('ts', tsFileSystemProvider, { + vscode.workspace.registerFileSystemProvider('ts', fileSystemProvider, { isCaseSensitive: true, }) ); @@ -80,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) { const nodeExplorerProvider = new NodeExplorerProvider( tailscaleInstance, configManager, - ssh, + fileSystemProvider, updateNodeExplorerTailnetName ); diff --git a/src/filesystem-provider-sftp.ts b/src/filesystem-provider-sftp.ts new file mode 100644 index 0000000..c0be1a7 --- /dev/null +++ b/src/filesystem-provider-sftp.ts @@ -0,0 +1,108 @@ +import * as vscode from 'vscode'; +import { Logger } from './logger'; +import { ConfigManager } from './config-manager'; +import { parseTsUri } from './utils/uri'; +import { SshConnectionManager } from './ssh-connection-manager'; +import { fileSorter } from './filesystem-provider'; + +export class FileSystemProviderSFTP implements vscode.FileSystemProvider { + public manager: SshConnectionManager; + + constructor(configManager: ConfigManager) { + this.manager = new SshConnectionManager(configManager); + } + + // Implementation of the `onDidChangeFile` event + onDidChangeFile: vscode.Event = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >().event; + + watch(): vscode.Disposable { + throw new Error('Watch not supported'); + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + Logger.info(`readDirectory: ${uri}`, `tsFs-sftp`); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) { + throw new Error('Unable to establish SFTP connection'); + } + + const files = await sftp.readDirectory(resourcePath); + return files.sort(fileSorter); + } + + async stat(uri: vscode.Uri): Promise { + Logger.info(`stat: ${uri}`, 'tsFs-sftp'); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) { + throw new Error('Unable to establish SFTP connection'); + } + + return await sftp.stat(resourcePath); + } + + async createDirectory(uri: vscode.Uri): Promise { + try { + Logger.info(`createDirectory: ${uri}`, 'tsFs-sftp'); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) throw new Error('Failed to establish SFTP connection'); + + return await sftp.createDirectory(resourcePath); + } catch (err) { + Logger.error(`createDirectory: ${err}`, 'tsFs-sftp'); + throw err; + } + } + + async getHomeDirectory(hostname: string): Promise { + const sftp = await this.manager.getSftp(hostname); + if (!sftp) throw new Error('Failed to establish SFTP connection'); + + return await sftp.getHomeDirectory(); + } + + async readFile(uri: vscode.Uri): Promise { + Logger.info(`readFile: ${uri}`, 'tsFs-sftp'); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) { + throw new Error('Unable to establish SFTP connection'); + } + + return await sftp.readFile(resourcePath); + } + + async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + Logger.info(`readFile: ${uri}`, 'tsFs-sftp'); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) { + throw new Error('Unable to establish SFTP connection'); + } + + return await sftp.writeFile(resourcePath, content); + } + + async delete(uri: vscode.Uri): Promise { + Logger.info(`delete: ${uri}`, 'tsFs-sftp'); + const { hostname, resourcePath } = parseTsUri(uri); + + const sftp = await this.manager.getSftp(hostname); + if (!sftp) { + throw new Error('Unable to establish SFTP connection'); + } + + return await sftp.delete(resourcePath); + } + + async rename(): Promise {} +} diff --git a/src/ts-file-system-provider.ts b/src/filesystem-provider-ssh.ts similarity index 83% rename from src/ts-file-system-provider.ts rename to src/filesystem-provider-ssh.ts index b1076a0..228029a 100644 --- a/src/ts-file-system-provider.ts +++ b/src/filesystem-provider-ssh.ts @@ -1,12 +1,13 @@ import * as vscode from 'vscode'; import { exec } from 'child_process'; import { Logger } from './logger'; -import { SSH } from './utils/ssh'; +import { SSH } from './ssh'; import { ConfigManager } from './config-manager'; import { escapeSpace } from './utils/string'; import { parseTsUri } from './utils/uri'; +import { fileSorter } from './filesystem-provider'; -export class TSFileSystemProvider implements vscode.FileSystemProvider { +export class FileSystemProviderSSH implements vscode.FileSystemProvider { private ssh: SSH; constructor(configManager?: ConfigManager) { @@ -23,7 +24,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { } async stat(uri: vscode.Uri): Promise { - Logger.info(`stat: ${uri.toString()}`, 'tsFs'); + Logger.info(`stat: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); if (!hostname) { @@ -47,11 +48,11 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { } async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { - Logger.info(`readDirectory: ${uri.toString()}`, 'tsFs'); + Logger.info(`readDirectory: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); - Logger.info(`hostname: ${hostname}`, 'tsFs'); - Logger.info(`remotePath: ${resourcePath}`, 'tsFs'); + Logger.info(`hostname: ${hostname}`, 'tsFs-ssh'); + Logger.info(`remotePath: ${resourcePath}`, 'tsFs-ssh'); if (!hostname) { throw new Error('hostname is undefined'); @@ -71,21 +72,15 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { files.push([name, type]); } - return files.sort((a, b) => { - if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) { - return -1; - } - if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) { - return 1; - } + return files.sort(fileSorter); + } - // If same type, sort by name - return a[0].localeCompare(b[0]); - }); + async getHomeDirectory(hostname: string): Promise { + return (await this.ssh.executeCommand(hostname, 'echo', ['~'])).trim(); } async readFile(uri: vscode.Uri): Promise { - Logger.info(`readFile: ${uri.toString()}`, 'tsFs-readFile'); + Logger.info(`readFile: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); if (!hostname) { @@ -102,7 +97,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { content: Uint8Array, options: { create: boolean; overwrite: boolean } ): Promise { - Logger.info(`writeFile: ${uri.toString()}`, 'tsFs'); + Logger.info(`writeFile: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); @@ -120,7 +115,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { } async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise { - Logger.info(`delete: ${uri.toString()}`, 'tsFs'); + Logger.info(`delete: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); @@ -135,7 +130,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { } async createDirectory(uri: vscode.Uri): Promise { - Logger.info(`createDirectory: ${uri.toString()}`, 'tsFs'); + Logger.info(`createDirectory: ${uri.toString()}`, 'tsFs-ssh'); const { hostname, resourcePath } = parseTsUri(uri); @@ -151,7 +146,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { newUri: vscode.Uri, options: { overwrite: boolean } ): Promise { - Logger.info('rename', 'tsFs'); + Logger.info('rename', 'tsFs-ssh'); const { hostname: oldHost, resourcePath: oldPath } = parseTsUri(oldUri); const { hostname: newHost, resourcePath: newPath } = parseTsUri(newUri); @@ -176,7 +171,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider { // scp ubuntu@backup:/home/ubuntu/ /Users/Tyler/foo.txt scp(src: vscode.Uri, dest: vscode.Uri): Promise { - Logger.info('scp', 'tsFs'); + Logger.info('scp', 'tsFs-ssh'); const { resourcePath: srcPath } = parseTsUri(src); const { hostname: destHostName, resourcePath: destPath } = parseTsUri(dest); diff --git a/src/filesystem-provider-timing.ts b/src/filesystem-provider-timing.ts new file mode 100644 index 0000000..06f33fa --- /dev/null +++ b/src/filesystem-provider-timing.ts @@ -0,0 +1,94 @@ +import * as vscode from 'vscode'; +import { Logger } from './logger'; +import { FileSystemProvider } from './filesystem-provider'; + +// WithFSTiming is a FileSystemProvider implementation +// that just wraps each call and logs the timing it took +// for performance comparisons. +export class WithFSTiming implements FileSystemProvider { + constructor(private readonly fsp: FileSystemProvider) {} + + // Implementation of the `onDidChangeFile` event + onDidChangeFile: vscode.Event = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >().event; + + watch( + uri: vscode.Uri, + options: { readonly recursive: boolean; readonly excludes: readonly string[] } + ): vscode.Disposable { + return this.fsp.watch(uri, options); + } + + async stat(uri: vscode.Uri): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.stat(uri); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for stat`, `tsFs-timing`); + return res; + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const startTime = new Date().getTime(); + const res = await this.fsp.readDirectory(uri); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for readDirectory`, `tsFs-timing`); + return res; + } + + async createDirectory(uri: vscode.Uri): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.createDirectory(uri); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for createDirectory`, `tsFs-timing`); + return res; + } + + async readFile(uri: vscode.Uri): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.readFile(uri); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for readFile`, `tsFs-timing`); + return res; + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { readonly create: boolean; readonly overwrite: boolean } + ): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.writeFile(uri, content, options); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for writeFile`, `tsFs-timing`); + return res; + } + + async delete(uri: vscode.Uri, options: { readonly recursive: boolean }): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.delete(uri, options); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for delete`, `tsFs-timing`); + return res; + } + + async rename( + oldUri: vscode.Uri, + newUri: vscode.Uri, + options: { readonly overwrite: boolean } + ): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.rename(oldUri, newUri, options); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for rename`, `tsFs-timing`); + return res; + } + + async getHomeDirectory(hostname: string): Promise { + const startTime = new Date().getTime(); + const res = await this.fsp.getHomeDirectory(hostname); + const endTime = new Date().getTime(); + Logger.info(`${endTime - startTime}ms for getHomeDirectory`, `tsFs-timing`); + return res; + } +} diff --git a/src/filesystem-provider.ts b/src/filesystem-provider.ts new file mode 100644 index 0000000..df270ff --- /dev/null +++ b/src/filesystem-provider.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +// FileSystemProvider adds to vscode.FileSystemProvider as different +// implementations grab the home directory differently +export interface FileSystemProvider extends vscode.FileSystemProvider { + getHomeDirectory(hostname: string): Promise; +} + +// fileSorter mimicks the Node Explorer file structure in that directories +// are displayed first in alphabetical followed by files in the same fashion. +export function fileSorter(a: [string, vscode.FileType], b: [string, vscode.FileType]): number { + if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) { + return -1; + } + if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) { + return 1; + } + + // If same type, sort by name + return a[0].localeCompare(b[0]); +} diff --git a/src/node-explorer-provider.ts b/src/node-explorer-provider.ts index da2de99..7624ec4 100644 --- a/src/node-explorer-provider.ts +++ b/src/node-explorer-provider.ts @@ -3,17 +3,13 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Peer } from './types'; import { Tailscale } from './tailscale/cli'; -import { TSFileSystemProvider } from './ts-file-system-provider'; -import { SSH } from './utils/ssh'; import { ConfigManager } from './config-manager'; import { Logger } from './logger'; -import { parseTsUri } from './utils/uri'; +import { createTsUri, parseTsUri } from './utils/uri'; +import { getUsername } from './utils/host'; +import { FileSystemProvider } from './filesystem-provider'; -export class NodeExplorerProvider - implements - vscode.TreeDataProvider, - vscode.TreeDragAndDropController -{ +export class NodeExplorerProvider implements vscode.TreeDataProvider { dropMimeTypes = ['text/uri-list']; // add 'application/vnd.code.tree.testViewDragAndDrop' when we have file explorer dragMimeTypes = []; @@ -29,24 +25,22 @@ export class NodeExplorerProvider } private peers: { [hostName: string]: Peer } = {}; - private fsProvider: TSFileSystemProvider; constructor( private readonly ts: Tailscale, private readonly configManager: ConfigManager, - private ssh: SSH, + private fsProvider: FileSystemProvider, private updateNodeExplorerTailnetName: (title: string) => void ) { - this.fsProvider = new TSFileSystemProvider(); - - this.registerDeleteCommand(); + this.registerCopyHostnameCommand(); this.registerCopyIPv4Command(); this.registerCopyIPv6Command(); - this.registerCopyHostnameCommand(); - this.registerOpenTerminalCommand(); + this.registerCreateDirectoryCommand(); + this.registerDeleteCommand(); + this.registerOpenNodeDetailsCommand(); this.registerOpenRemoteCodeCommand(); this.registerOpenRemoteCodeLocationCommand(); - this.registerOpenNodeDetailsCommand(); + this.registerOpenTerminalCommand(); this.registerRefresh(); } @@ -72,7 +66,8 @@ export class NodeExplorerProvider let rootDir = hosts?.[element.HostName]?.rootDir; let dirDesc = rootDir; try { - const homeDir = (await this.ssh.executeCommand(element.HostName, 'echo', ['~'])).trim(); + const homeDir = await this.fsProvider.getHomeDirectory(element.HostName); + if (rootDir && rootDir !== '~') { dirDesc = trimPathPrefix(rootDir, homeDir); } else { @@ -85,13 +80,13 @@ export class NodeExplorerProvider rootDir = '~'; dirDesc = '~'; } - // This method of building the Uri cleans up the path, removing any - // leading or trailing slashes. - const uri = vscode.Uri.joinPath( - vscode.Uri.from({ scheme: 'ts', authority: element.TailnetName, path: '/' }), - element.HostName, - ...rootDir.split('/') - ); + + const uri = createTsUri({ + tailnet: element.TailnetName, + hostname: element.HostName, + resourcePath: rootDir, + }); + return [ new FileExplorer( 'File explorer', @@ -129,49 +124,43 @@ export class NodeExplorerProvider } } - public async handleDrop(target: FileExplorer, dataTransfer: vscode.DataTransfer): Promise { - // TODO: figure out why the progress bar doesn't show up - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - cancellable: false, - title: 'Tailscale', - }, - async (progress) => { - dataTransfer.forEach(async ({ value }) => { - const uri = vscode.Uri.parse(value); - - try { - await this.fsProvider.scp(uri, target?.uri); - } catch (e) { - vscode.window.showErrorMessage(`unable to copy ${uri} to ${target?.uri}`); - console.error(`Error copying ${uri} to ${target?.uri}: ${e}`); - } - - progress.report({ increment: 100 }); - this._onDidChangeTreeData.fire([target]); + registerDeleteCommand() { + vscode.commands.registerCommand('tailscale.node.fs.delete', this.delete.bind(this)); + } + + registerCreateDirectoryCommand() { + vscode.commands.registerCommand( + 'tailscale.node.fs.createDirectory', + async (node: FileExplorer) => { + const { hostname, tailnet, resourcePath } = parseTsUri(node.uri); + if (!hostname || !resourcePath) { + return; + } + + // TODO: validate input + const dirName = await vscode.window.showInputBox({ + prompt: 'Enter a name for the new directory', + placeHolder: 'New directory', }); - } - ); - if (!target) { - return; - } - } + if (!dirName) { + return; + } - public async handleDrag( - source: PeerTree[], - treeDataTransfer: vscode.DataTransfer, - _: vscode.CancellationToken - ): Promise { - treeDataTransfer.set( - 'application/vnd.code.tree.testViewDragAndDrop', - new vscode.DataTransferItem(source) - ); - } + const newUri = createTsUri({ + tailnet, + hostname, + resourcePath: `${resourcePath}/${dirName}`, + }); - registerDeleteCommand() { - vscode.commands.registerCommand('tailscale.node.fs.delete', this.delete.bind(this)); + try { + await vscode.workspace.fs.createDirectory(newUri); + this._onDidChangeTreeData.fire([node]); + } catch (e) { + vscode.window.showErrorMessage(`Could not create directory: ${e}`); + } + } + ); } registerOpenRemoteCodeLocationCommand() { @@ -222,7 +211,7 @@ export class NodeExplorerProvider registerOpenTerminalCommand() { vscode.commands.registerCommand('tailscale.node.openTerminal', async (node: PeerTree) => { const t = vscode.window.createTerminal(node.HostName); - t.sendText(`ssh ${this.ssh.sshHostnameWithUser(node.HostName)}`); + t.sendText(`ssh ${getUsername(this.configManager, node.HostName)}@${node.HostName}`); t.show(); }); } diff --git a/src/sftp.ts b/src/sftp.ts new file mode 100644 index 0000000..cb3e796 --- /dev/null +++ b/src/sftp.ts @@ -0,0 +1,85 @@ +import * as ssh2 from 'ssh2'; +import * as util from 'util'; +import * as vscode from 'vscode'; +import { Logger } from './logger'; + +export class Sftp { + private sftpPromise: Promise; + + constructor(private conn: ssh2.Client) { + this.sftpPromise = util.promisify(this.conn.sftp).call(this.conn); + } + + private async getSftp(): Promise { + return this.sftpPromise; + } + + async readDirectory(path: string): Promise<[string, vscode.FileType][]> { + const sftp = await this.getSftp(); + const files = await util.promisify(sftp.readdir).call(sftp, path); + const result: [string, vscode.FileType][] = []; + + for (const file of files) { + result.push([file.filename, this.convertFileType(file.attrs as ssh2.Stats)]); + } + + return result; + } + + async getHomeDirectory(): Promise { + const sftp = await this.getSftp(); + return await util.promisify(sftp.realpath).call(sftp, '.'); + } + + async stat(path: string): Promise { + Logger.info(`stat: ${path}`, 'sftp'); + const sftp = await this.getSftp(); + const s = await util.promisify(sftp.stat).call(sftp, path); + + return { + type: this.convertFileType(s), + ctime: s.atime, + mtime: s.mtime, + size: s.size, + }; + } + + async createDirectory(path: string): Promise { + const sftp = await this.getSftp(); + return util.promisify(sftp.mkdir).call(sftp, path); + } + + async readFile(path: string): Promise { + const sftp = await this.getSftp(); + const buffer = await util.promisify(sftp.readFile).call(sftp, path); + return new Uint8Array(buffer); + } + + async writeFile(path: string, data: Uint8Array | string): Promise { + const sftp = await this.getSftp(); + const buffer = data instanceof Uint8Array ? Buffer.from(data.buffer) : data; + return util.promisify(sftp.writeFile).call(sftp, path, buffer); + } + + async delete(path: string): Promise { + const sftp = await this.getSftp(); + return util.promisify(sftp.unlink).call(sftp, path); + } + + async rename(oldPath: string, newPath: string): Promise { + const sftp = await this.getSftp(); + return util.promisify(sftp.rename).call(sftp, oldPath, newPath); + } + + convertFileType(stats: ssh2.Stats): vscode.FileType { + if (stats.isDirectory()) { + return vscode.FileType.Directory; + } else if (stats.isFile()) { + return vscode.FileType.File; + } else if (stats.isSymbolicLink()) { + return vscode.FileType.SymbolicLink; + } else { + return vscode.FileType.Unknown; + } + } +} diff --git a/src/ssh-connection-manager.ts b/src/ssh-connection-manager.ts new file mode 100644 index 0000000..9ada855 --- /dev/null +++ b/src/ssh-connection-manager.ts @@ -0,0 +1,125 @@ +import * as ssh2 from 'ssh2'; +import * as vscode from 'vscode'; + +import { ConfigManager } from './config-manager'; +import { getUsername } from './utils/host'; +import { Sftp } from './sftp'; +import { EXTENSION_NS } from './constants'; + +export class SshConnectionManager { + private connections: Map; + private configManager: ConfigManager; + + constructor(configManager: ConfigManager) { + this.connections = new Map(); + this.configManager = configManager; + } + + async getConnection(hostname: string): Promise { + const username = getUsername(this.configManager, hostname); + const key = this.formatKey(hostname, username); + + if (this.connections.has(key)) { + return this.connections.get(key) as ssh2.Client; + } + + const conn = new ssh2.Client(); + const config = { host: hostname, username }; + + try { + await Promise.race([ + new Promise((resolve, reject): void => { + conn.on('ready', resolve); + conn.on('error', reject); + conn.on('close', () => { + this.connections.delete(key); + }); + + // this might require a brower to open and the user to authenticate + conn.connect(config); + }), + new Promise((_, reject) => + // TODO: how does Tailscale re-authentication effect this? + // TODO: can we cancel the connection attempt? + setTimeout( + () => reject(new Error('Connection timeout')), + vscode.workspace.getConfiguration(EXTENSION_NS).get('ssh.connectionTimeout') + ) + ), + ]); + + this.connections.set(key, conn); + + return conn; + } catch (err) { + let message = 'Unknown error'; + if (err instanceof Error) { + message = err.message; + } + vscode.window.showErrorMessage( + `Failed to connect to ${hostname} with username ${username}: ${message}` + ); + throw err; + } + } + + async getSftp(hostname: string): Promise { + try { + const conn = await this.getConnection(hostname); + return new Sftp(conn); + } catch (err) { + if (this.isAuthenticationError(err)) { + const username = await this.promptForUsername(hostname); + + if (username) { + return await this.getSftp(hostname); + } + + this.showUsernameRequiredError(); + } + throw err; + } + } + + private isAuthenticationError(err: unknown): err is { level: string } { + return ( + typeof err === 'object' && + err !== null && + 'level' in err && + err.level === 'client-authentication' + ); + } + + private async showUsernameRequiredError(): Promise { + const msg = 'Username is required to connect to remote host'; + vscode.window.showErrorMessage(msg); + throw new Error(msg); + } + + async promptForUsername(hostname: string): Promise { + const username = await vscode.window.showInputBox({ + prompt: `Please enter a valid username for host "${hostname}"`, + }); + + if (username && this.configManager) { + this.configManager.setForHost(hostname, 'user', username); + } + + return username; + } + + closeConnection(hostname: string): void { + const key = this.formatKey(hostname); + const connection = this.connections.get(key); + + if (connection) { + connection.end(); + this.connections.delete(key); + } + } + + private formatKey(hostname: string, username?: string): string { + const u = username || getUsername(this.configManager, hostname); + return `${u}@${hostname}`; + } +} diff --git a/src/utils/ssh.ts b/src/ssh.ts similarity index 97% rename from src/utils/ssh.ts rename to src/ssh.ts index c7e04f2..9c15612 100644 --- a/src/utils/ssh.ts +++ b/src/ssh.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import * as vscode from 'vscode'; -import { Logger } from '../logger'; -import { ConfigManager } from '../config-manager'; +import { Logger } from './logger'; +import { ConfigManager } from './config-manager'; export class SSH { constructor(private readonly configManager?: ConfigManager) {} diff --git a/src/utils/host.ts b/src/utils/host.ts new file mode 100644 index 0000000..771df82 --- /dev/null +++ b/src/utils/host.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; +import { userInfo } from 'os'; +import { ConfigManager } from '../config-manager'; + +export function getUsername(configManager: ConfigManager, hostname: string) { + const { hosts } = configManager?.config || {}; + const userForHost = hosts?.[hostname]?.user?.trim(); + const defaultUser = vscode.workspace + .getConfiguration('tailscale') + .get('ssh.defaultUser') + ?.trim(); + + return userForHost || defaultUser || userInfo().username; +} diff --git a/src/utils/uri.test.ts b/src/utils/uri.test.ts index 3d19a67..f9efef6 100644 --- a/src/utils/uri.test.ts +++ b/src/utils/uri.test.ts @@ -1,35 +1,81 @@ -import { test, expect, vi } from 'vitest'; -import { parseTsUri } from './uri'; -import { URI } from 'vscode-uri'; - -vi.mock('vscode'); - -test('parses ts URIs correctly', () => { - const testUri = URI.parse('ts://tailnet-scales/foo/home/amalie'); - const expected = { - hostname: 'foo', - tailnet: 'tailnet-scales', - resourcePath: '/home/amalie', - }; +import { test, expect, describe, vi } from 'vitest'; +import { createTsUri, parseTsUri } from './uri'; +import { URI, Utils } from 'vscode-uri'; - const result = parseTsUri(testUri); - expect(result).toEqual(expected); +vi.mock('vscode', async () => { + return { + Uri: { + parse: (uri: string) => URI.parse(uri), + from: (params: { scheme: string; authority: string; path: string }) => URI.from(params), + joinPath: (uri: URI, ...paths: string[]) => Utils.joinPath(uri, ...paths), + }, + }; }); -test('throws an error when scheme is not supported', () => { - const testUri = URI.parse('http://example.com'); +describe('parseTsUri', () => { + test('parses ts URIs correctly', () => { + const testUri = URI.parse('ts://tails-scales/foo/home/amalie'); + const expected = { + hostname: 'foo', + tailnet: 'tails-scales', + resourcePath: '/home/amalie', + }; + + const result = parseTsUri(testUri); + expect(result).toEqual(expected); + }); + + test('throws an error when scheme is not supported', () => { + const testUri = URI.parse('http://example.com'); + + expect(() => parseTsUri(testUri)).toThrow('Unsupported scheme: http'); + }); + + test('correctly returns ~ as a resourcePath', () => { + const testUri = URI.parse('ts://tails-scales/foo/~'); + const expected = { + hostname: 'foo', + tailnet: 'tails-scales', + resourcePath: '.', + }; - expect(() => parseTsUri(testUri)).toThrow('Unsupported scheme: http'); + const result = parseTsUri(testUri); + expect(result).toEqual(expected); + }); + + test('correctly returns ~ in a deeply nested resourcePath', () => { + const testUri = URI.parse('ts://tails-scales/foo/~/bar/baz'); + const expected = { + hostname: 'foo', + tailnet: 'tails-scales', + resourcePath: './bar/baz', + }; + + const result = parseTsUri(testUri); + expect(result).toEqual(expected); + }); }); -test('correctly returns ~ as a resourcePath', () => { - const testUri = URI.parse('ts://tailnet-scales/foo/~'); - const expected = { - hostname: 'foo', - tailnet: 'tailnet-scales', - resourcePath: '~', - }; +describe('createTsUri', () => { + test('creates ts URIs correctly', () => { + const expected = URI.parse('ts://tails-scales/foo/home/amalie'); + const params = { + hostname: 'foo', + tailnet: 'tails-scales', + resourcePath: '/home/amalie', + }; + + expect(createTsUri(params)).toEqual(expected); + }); + + test('creates ts URIs correctly', () => { + const expected = URI.parse('ts://tails-scales/foo/~'); + const params = { + hostname: 'foo', + tailnet: 'tails-scales', + resourcePath: '~', + }; - const result = parseTsUri(testUri); - expect(result).toEqual(expected); + expect(createTsUri(params)).toEqual(expected); + }); }); diff --git a/src/utils/uri.ts b/src/utils/uri.ts index 33b153d..0ec39ba 100644 --- a/src/utils/uri.ts +++ b/src/utils/uri.ts @@ -1,4 +1,4 @@ -import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import { escapeSpace } from './string'; export interface TsUri { @@ -15,7 +15,7 @@ export interface TsUri { * |> resourcePath: /home/amalie */ -export function parseTsUri(uri: vscode.Uri): TsUri { +export function parseTsUri(uri: Uri): TsUri { switch (uri.scheme) { case 'ts': { let hostPath = uri.path; @@ -27,8 +27,13 @@ export function parseTsUri(uri: vscode.Uri): TsUri { const segments = hostPath.split('/'); const [hostname, ...pathSegments] = segments; + if (pathSegments[0] === '~') { + pathSegments[0] = '.'; + } + let resourcePath = decodeURIComponent(pathSegments.join('/')); - if (resourcePath !== '~') { + + if (!resourcePath.startsWith('.')) { resourcePath = `/${escapeSpace(resourcePath)}`; } @@ -38,3 +43,17 @@ export function parseTsUri(uri: vscode.Uri): TsUri { throw new Error(`Unsupported scheme: ${uri.scheme}`); } } + +interface TsUriParams { + tailnet: string; + hostname: string; + resourcePath: string; +} + +export function createTsUri({ tailnet, hostname, resourcePath }: TsUriParams): Uri { + return Uri.joinPath( + Uri.from({ scheme: 'ts', authority: tailnet, path: '/' }), + hostname, + ...resourcePath.split('/') + ); +} diff --git a/webpack.config.mjs b/webpack.config.mjs index 251b812..0fdf7df 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -31,6 +31,10 @@ const baseConfig = { ], exclude: /node_modules/, }, + { + test: /\.node$/, + loader: 'node-loader', + }, { test: /\.css$/i, use: ['style-loader', 'css-loader', 'postcss-loader'], diff --git a/yarn.lock b/yarn.lock index f213d55..ebcf8fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -643,6 +643,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.68.tgz#30ee923f4d940793e0380f5ce61c0bd4b7196b6c" integrity sha512-JkRpuVz3xCNCWaeQ5EHLR/6woMbHZz/jZ7Kmc63AkU+1HxnoUugzSWMck7dsR4DvNYX8jp9wTi9K7WvnxOIQZQ== +"@types/node@^18.11.18": + version "18.17.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.1.tgz#84c32903bf3a09f7878c391d31ff08f6fe7d8335" + integrity sha512-xlR1jahfizdplZYRU59JlUx9uzF1ARa8jbhM11ccpCJya8kvos5jwdm2ZAgxSCwOl0fq21svP18EVwPBXMQudw== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz" @@ -733,6 +738,13 @@ dependencies: "@types/node" "*" +"@types/ssh2@^1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.11.13.tgz#e6224da936abec0541bf26aa826b1cc37ea70d69" + integrity sha512-08WbG68HvQ2YVi74n2iSUnYHYpUdFc/s2IsI0BHBdJwaqYJpWlVv9elL0tYShTv60yr0ObdxJR5NrCRiGJ/0CQ== + dependencies: + "@types/node" "^18.11.18" + "@types/triple-beam@^1.3.2": version "1.3.2" resolved "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.2.tgz" @@ -1258,6 +1270,13 @@ array-union@^2.1.0: resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -1372,11 +1391,23 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + before-after-hook@^2.2.0: version "2.2.3" resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz" integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -1493,6 +1524,11 @@ bufferutil@^4.0.7: dependencies: node-gyp-build "^4.3.0" +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + builtin-modules@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz" @@ -1871,6 +1907,14 @@ cosmiconfig@^8.2.0: parse-json "^5.0.0" path-type "^4.0.0" +cpu-features@~0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.8.tgz#a2d464b023b8ad09004c8cdca23b33f192f63546" + integrity sha512-BbHBvtYhUhksqTjr6bhNOjGgMnhwhGTQmOoZGD+K7BCaQDCuZl/Ve1ZxUSMRwVC4D/rkCPQ2MAIeYzrWyK7eEg== + dependencies: + buildcheck "~0.0.6" + nan "^2.17.0" + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -2212,6 +2256,11 @@ emoji-regex@^9.2.2: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + enabled@2.0.x: version "2.0.0" resolved "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz" @@ -3642,6 +3691,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json5@^2.1.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + jsonc-parser@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz" @@ -3741,6 +3795,15 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-utils@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + local-pkg@^0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" @@ -4060,6 +4123,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.17.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" @@ -4138,6 +4206,13 @@ node-gyp-build@^4.3.0: resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz" integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== +node-loader@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/node-loader/-/node-loader-2.0.0.tgz#9109a6d828703fd3e0aa03c1baec12a798071562" + integrity sha512-I5VN34NO4/5UYJaUBtkrODPWxbobrE4hgDqPrjB25yPkonFhCmZ146vTH+Zg417E9Iwoh1l/MbRs1apc5J295Q== + dependencies: + loader-utils "^2.0.0" + node-releases@^2.0.12: version "2.0.12" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.12.tgz#35627cc224a23bfb06fb3380f2b3afaaa7eb1039" @@ -5054,7 +5129,7 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz" integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -5407,6 +5482,17 @@ sql-summary@^1.0.1: resolved "https://registry.npmjs.org/sql-summary/-/sql-summary-1.0.1.tgz" integrity sha512-IpCr2tpnNkP3Jera4ncexsZUp0enJBLr+pHCyTweMUBrbJsTgQeLWx1FXLhoBj/MvcnUQpkgOn2EY8FKOkUzww== +ssh2@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.14.0.tgz#8f68440e1b768b66942c9e4e4620b2725b3555bb" + integrity sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.8" + nan "^2.17.0" + stack-trace@0.0.x: version "0.0.10" resolved "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz" @@ -5875,6 +5961,11 @@ tunnel@0.0.6: resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"