Add support for environment variables (closes #241)

pull/285/head
Kelvin Schoofs 3 years ago
parent 4edc2ef315
commit 3109e977a5

@ -1,7 +1,7 @@
import type { Client } from 'ssh2'; import type { Client } from 'ssh2';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { configMatches, loadConfigs } from './config'; import { configMatches, getFlagBoolean, loadConfigs } from './config';
import type { FileSystemConfig } from './fileSystemConfig'; import type { EnvironmentVariable, FileSystemConfig } from './fileSystemConfig';
import { Logging } from './logging'; import { Logging } from './logging';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
@ -10,12 +10,48 @@ export interface Connection {
config: FileSystemConfig; config: FileSystemConfig;
actualConfig: FileSystemConfig; actualConfig: FileSystemConfig;
client: Client; client: Client;
environment: EnvironmentVariable[];
terminals: SSHPseudoTerminal[]; terminals: SSHPseudoTerminal[];
filesystems: SSHFileSystem[]; filesystems: SSHFileSystem[];
pendingUserCount: number; pendingUserCount: number;
idleTimer: NodeJS.Timeout; idleTimer: NodeJS.Timeout;
} }
export function mergeEnvironment(env: EnvironmentVariable[], ...others: (EnvironmentVariable[] | Record<string, string> | undefined)[]): EnvironmentVariable[] {
const result = [...env];
for (const other of others) {
if (!other) continue;
if (Array.isArray(other)) {
for (const variable of other) {
const index = result.findIndex(v => v.key === variable.key);
if (index === -1) result.push(variable);
else result[index] = variable;
}
} else {
for (const [key, value] of Object.entries(other)) {
result.push({ key, value });
}
}
}
return result;
}
// https://stackoverflow.com/a/20053121 way 1
const CLEAN_BASH_VALUE_REGEX = /^[\w-/\\]+$/;
function escapeBashValue(value: string) {
if (CLEAN_BASH_VALUE_REGEX.test(value)) return value;
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function environmentToExportString(env: EnvironmentVariable[]): string {
return env.map(({ key, value }) => `export ${escapeBashValue(key)}=${escapeBashValue(value)}`).join('; ');
}
export function joinCommands(commands: string | string[] | undefined, separator: string): string | undefined {
if (!commands) return undefined;
if (typeof commands === 'string') return commands;
return commands.filter(c => c && c.trim()).join(separator);
}
export class ConnectionManager { export class ConnectionManager {
protected onConnectionAddedEmitter = new vscode.EventEmitter<Connection>(); protected onConnectionAddedEmitter = new vscode.EventEmitter<Connection>();
protected onConnectionRemovedEmitter = new vscode.EventEmitter<Connection>(); protected onConnectionRemovedEmitter = new vscode.EventEmitter<Connection>();
@ -51,9 +87,10 @@ export class ConnectionManager {
if (!actualConfig) throw new Error('Connection cancelled'); if (!actualConfig) throw new Error('Connection cancelled');
const client = await createSSH(actualConfig); const client = await createSSH(actualConfig);
if (!client) throw new Error(`Could not create SSH session for '${name}'`); if (!client) throw new Error(`Could not create SSH session for '${name}'`);
const environment: EnvironmentVariable[] = mergeEnvironment([], config.environment);
let timeoutCounter = 0; let timeoutCounter = 0;
const con: Connection = { const con: Connection = {
config, client, actualConfig, config, client, actualConfig, environment,
terminals: [], terminals: [],
filesystems: [], filesystems: [],
pendingUserCount: 0, pendingUserCount: 0,

@ -8,6 +8,12 @@ export interface ProxyConfig {
export type ConfigLocation = number | string; export type ConfigLocation = number | string;
/** Might support conditional stuff later, although ssh2/OpenSSH might not support that natively */
export interface EnvironmentVariable {
key: string;
value: string;
}
export function formatConfigLocation(location?: ConfigLocation): string { export function formatConfigLocation(location?: ConfigLocation): string {
if (!location) return 'Unknown location'; if (!location) return 'Unknown location';
if (typeof location === 'number') { if (typeof location === 'number') {
@ -100,6 +106,8 @@ export interface FileSystemConfig extends ConnectConfig {
terminalCommand?: string | string[]; 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 */ /** 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[]; taskCommand?: string | string[];
/** An object with environment variables to add to the SSH connection. Affects the whole connection thus all terminals */
environment?: EnvironmentVariable[] | Record<string, string>;
/** The filemode to assign to created files */ /** The filemode to assign to created files */
newFileMode?: number | string; newFileMode?: number | string;
/** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */ /** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */

@ -3,7 +3,7 @@ import * as path from 'path';
import type { Client, ClientChannel } from 'ssh2'; import type { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getConfig, getFlagBoolean, loadConfigsRaw } from './config'; import { getConfig, getFlagBoolean, loadConfigsRaw } from './config';
import { Connection, ConnectionManager } from './connection'; import { Connection, ConnectionManager, joinCommands } from './connection';
import type { FileSystemConfig } from './fileSystemConfig'; import type { FileSystemConfig } from './fileSystemConfig';
import { getRemotePath } from './fileSystemRouter'; import { getRemotePath } from './fileSystemRouter';
import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { Logging, LOGGING_NO_STACKTRACE } from './logging';
@ -143,7 +143,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
const workingDirectory = uri && getRemotePath(con.actualConfig, uri); const workingDirectory = uri && getRemotePath(con.actualConfig, uri);
// Create pseudo terminal // Create pseudo terminal
this.connectionManager.update(con, con => con.pendingUserCount++); this.connectionManager.update(con, con => con.pendingUserCount++);
const pty = await createTerminal({ client: con.client, config: con.actualConfig, workingDirectory }); const pty = await createTerminal({ connection: con, workingDirectory });
pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty))); pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty)));
this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--)); this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--));
// Create and show the graphical representation // Create and show the graphical representation
@ -176,7 +176,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
`SSH Task '${task.name}'`, `SSH Task '${task.name}'`,
'ssh', 'ssh',
new vscode.CustomExecution(async (resolved: SSHShellTaskOptions) => { new vscode.CustomExecution(async (resolved: SSHShellTaskOptions) => {
const { createTerminal, createTextTerminal, joinCommands } = await import('./pseudoTerminal'); const { createTerminal, createTextTerminal } = await import('./pseudoTerminal');
try { try {
if (!resolved.host) throw new Error('Missing field \'host\' in task description'); if (!resolved.host) throw new Error('Missing field \'host\' in task description');
if (!resolved.command) throw new Error('Missing field \'command\' in task description'); if (!resolved.command) throw new Error('Missing field \'command\' in task description');
@ -196,11 +196,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
} }
//if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory); //if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory);
this.connectionManager.update(connection, con => con.pendingUserCount++); this.connectionManager.update(connection, con => con.pendingUserCount++);
const pty = await createTerminal({ const pty = await createTerminal({ command, workingDirectory, connection });
command, workingDirectory,
client: connection.client,
config: connection.actualConfig,
});
this.connectionManager.update(connection, con => (con.pendingUserCount--, con.terminals.push(pty))); this.connectionManager.update(connection, con => (con.pendingUserCount--, con.terminals.push(pty)));
pty.onDidClose(() => this.connectionManager.update(connection, pty.onDidClose(() => this.connectionManager.update(connection,
con => con.terminals = con.terminals.filter(t => t !== pty))); con => con.terminals = con.terminals.filter(t => t !== pty)));

@ -1,9 +1,10 @@
import * as path from 'path'; import * as path from 'path';
import type { Client, ClientChannel, PseudoTtyOptions } from "ssh2"; import type { ClientChannel, PseudoTtyOptions } from "ssh2";
import type { Readable } from "stream"; import type { Readable } from "stream";
import * as vscode from "vscode"; import * as vscode from "vscode";
import { getFlagBoolean } from './config'; import { getFlagBoolean } from './config';
import type { FileSystemConfig } from "./fileSystemConfig"; import { Connection, environmentToExportString, joinCommands, mergeEnvironment } from './connection';
import type { EnvironmentVariable, FileSystemConfig } from "./fileSystemConfig";
import { getRemotePath } from './fileSystemRouter'; import { getRemotePath } from './fileSystemRouter';
import { Logging, LOGGING_NO_STACKTRACE } from "./logging"; import { Logging, LOGGING_NO_STACKTRACE } from "./logging";
import { toPromise } from "./toPromise"; import { toPromise } from "./toPromise";
@ -18,8 +19,7 @@ export interface SSHPseudoTerminal extends vscode.Pseudoterminal {
onDidOpen: vscode.Event<void>; onDidOpen: vscode.Event<void>;
handleInput(data: string): void; // We don't support/need read-only terminals for now handleInput(data: string): void; // We don't support/need read-only terminals for now
status: 'opening' | 'open' | 'closed'; status: 'opening' | 'open' | 'closed';
config: FileSystemConfig; connection: Connection;
client: Client;
/** Could be undefined if it only gets created during psy.open() instead of beforehand */ /** Could be undefined if it only gets created during psy.open() instead of beforehand */
channel?: ClientChannel; channel?: ClientChannel;
/** Either set by the code calling createTerminal, otherwise "calculated" and hopefully found */ /** Either set by the code calling createTerminal, otherwise "calculated" and hopefully found */
@ -28,25 +28,18 @@ export interface SSHPseudoTerminal extends vscode.Pseudoterminal {
export function isSSHPseudoTerminal(terminal: vscode.Pseudoterminal): terminal is SSHPseudoTerminal { export function isSSHPseudoTerminal(terminal: vscode.Pseudoterminal): terminal is SSHPseudoTerminal {
const term = terminal as SSHPseudoTerminal; const term = terminal as SSHPseudoTerminal;
return !!(term.config && term.status && term.client); return !!(term.connection && term.status && term.handleInput);
} }
export interface TerminalOptions { export interface TerminalOptions {
client: Client; connection: Connection;
config: FileSystemConfig; environment?: EnvironmentVariable[];
/** If absent, this defaults to config.root if present, otherwise whatever the remote shell picks as default */ /** If absent, this defaults to config.root if present, otherwise whatever the remote shell picks as default */
workingDirectory?: string; workingDirectory?: string;
/** The command to run in the remote shell. If undefined, a (regular interactive) shell is started instead by running $SHELL*/ /** The command to run in the remote shell. If undefined, a (regular interactive) shell is started instead by running $SHELL*/
command?: string; command?: string;
} }
export function joinCommands(commands: string | string[] | undefined, separator: string): string | undefined {
if (!commands) return undefined;
if (typeof commands === 'string') return commands;
return commands.join(separator);
}
export function replaceVariables(value: string, config: FileSystemConfig): string { export function replaceVariables(value: string, config: FileSystemConfig): string {
return value.replace(/\$\{(.*?)\}/g, (str, match: string) => { return value.replace(/\$\{(.*?)\}/g, (str, match: string) => {
if (!match.startsWith('remote')) return str; // Our variables always start with "remote" if (!match.startsWith('remote')) return str; // Our variables always start with "remote"
@ -145,7 +138,8 @@ export async function replaceVariablesRecursive<T>(object: T, handler: (value: s
} }
export async function createTerminal(options: TerminalOptions): Promise<SSHPseudoTerminal> { export async function createTerminal(options: TerminalOptions): Promise<SSHPseudoTerminal> {
const { client, config } = options; const { connection } = options;
const { actualConfig, client } = connection;
const onDidWrite = new vscode.EventEmitter<string>(); const onDidWrite = new vscode.EventEmitter<string>();
const onDidClose = new vscode.EventEmitter<number>(); const onDidClose = new vscode.EventEmitter<number>();
const onDidOpen = new vscode.EventEmitter<void>(); const onDidOpen = new vscode.EventEmitter<void>();
@ -153,7 +147,7 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
// Won't actually open the remote terminal until pseudo.open(dims) is called // Won't actually open the remote terminal until pseudo.open(dims) is called
const pseudo: SSHPseudoTerminal = { const pseudo: SSHPseudoTerminal = {
status: 'opening', status: 'opening',
config, client, connection,
onDidWrite: onDidWrite.event, onDidWrite: onDidWrite.event,
onDidClose: onDidClose.event, onDidClose: onDidClose.event,
onDidOpen: onDidOpen.event, onDidOpen: onDidOpen.event,
@ -168,20 +162,24 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
pseudo.channel = undefined; pseudo.channel = undefined;
}, },
async open(dims) { async open(dims) {
onDidWrite.fire(`Connecting to ${config.label || config.name}...\r\n`); onDidWrite.fire(`Connecting to ${actualConfig.label || actualConfig.name}...\r\n`);
try { try {
const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', false, config.flags); const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', false, actualConfig.flags);
const separator = useWinCmdSep ? ' && ' : '; '; const separator = useWinCmdSep ? ' && ' : '; ';
let commands: string[] = []; let commands: string[] = [];
// Add exports for environment variables if needed
const env = mergeEnvironment(connection.environment, options.environment);
commands.push(environmentToExportString(env));
// Push the actual command or (default) shell command with replaced variables
if (options.command) { if (options.command) {
commands.push(options.command); commands.push(replaceVariables(options.command, actualConfig));
} else { } else {
const tc = joinCommands(config.terminalCommand, separator); const tc = joinCommands(actualConfig.terminalCommand, separator);
commands.push(tc ? replaceVariables(tc, config) : '$SHELL'); commands.push(tc ? replaceVariables(tc, actualConfig) : '$SHELL');
} }
// There isn't a proper way of setting the working directory, but this should work in most cases // There isn't a proper way of setting the working directory, but this should work in most cases
let { workingDirectory } = options; let { workingDirectory } = options;
workingDirectory = workingDirectory || config.root; workingDirectory = workingDirectory || actualConfig.root;
if (workingDirectory) { if (workingDirectory) {
if (workingDirectory.startsWith('~')) { if (workingDirectory.startsWith('~')) {
// So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does // So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does

@ -8,6 +8,12 @@ export interface ProxyConfig {
export type ConfigLocation = number | string; export type ConfigLocation = number | string;
/** Might support conditional stuff later, although ssh2/OpenSSH might not support that natively */
export interface EnvironmentVariable {
key: string;
value: string;
}
export function formatConfigLocation(location?: ConfigLocation): string { export function formatConfigLocation(location?: ConfigLocation): string {
if (!location) return 'Unknown location'; if (!location) return 'Unknown location';
if (typeof location === 'number') { if (typeof location === 'number') {
@ -100,6 +106,8 @@ export interface FileSystemConfig extends ConnectConfig {
terminalCommand?: string | string[]; 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 */ /** 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[]; taskCommand?: string | string[];
/** An object with environment variables to add to the SSH connection. Affects the whole connection thus all terminals */
environment?: EnvironmentVariable[] | Record<string, string>;
/** The filemode to assign to created files */ /** The filemode to assign to created files */
newFileMode?: number | string; newFileMode?: number | string;
/** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */ /** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */

Loading…
Cancel
Save