diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c787c..0a14eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### Changes - Set `$TERM` to `xterm-256color` instead of the default `vt100` (#299) - Terminals that exit within 5 seconds should now remain open until a key is pressed +- Refactored the `REMOTE_COMMANDS` beta feature (#270) to use the new `ShellConfig` system + - Commands (currently only `code`) are now written to a unique folder and passed to `$PATH` + - Commands are written in shell scripts (`#!/bin/sh` shebang) and should work on all shells/systems + - Using `$PATH` should allow support for recursive shells, switching shells, ... ### Fixes - Write `REMOTE_COMMANDS` profile script to separate file for each user (#292) diff --git a/src/connection.ts b/src/connection.ts index 7889e7e..bdbb0d7 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,6 +1,6 @@ import { posix as path } from 'path'; import * as readline from 'readline'; -import type { Client, ClientChannel, SFTPWrapper } from 'ssh2'; +import type { Client, ClientChannel } from 'ssh2'; import * as vscode from 'vscode'; import { configMatches, getFlagBoolean, loadConfigs } from './config'; import type { EnvironmentVariable, FileSystemConfig } from './fileSystemConfig'; @@ -19,32 +19,11 @@ export interface Connection { environment: EnvironmentVariable[]; terminals: SSHPseudoTerminal[]; filesystems: SSHFileSystem[]; + cache: Record; pendingUserCount: number; idleTimer: NodeJS.Timeout; } -const TMP_PROFILE_SCRIPT = ` -if type code > /dev/null 2> /dev/null; then - return 0; -fi -code() { - if [ "$#" -ne 1 ] || [ $1 = "help" ] || [ $1 = "--help" ] || [ $1 = "-h" ] || [ $1 = "-?" ]; then - echo "Usage:"; - echo " code Will make VS Code open the file"; - echo " code Will make VS Code add the folder as an additional workspace folder"; - echo " code Will prompt VS Code to create an empty file, then open it afterwards"; - elif [ ! -n "$KELVIN_SSHFS_CMD_PATH" ]; then - echo "Not running in a terminal spawned by SSH FS? Failed to sent!" - elif [ -c "$KELVIN_SSHFS_CMD_PATH" ]; then - echo "::sshfs:code:$(pwd):::$1" >> $KELVIN_SSHFS_CMD_PATH; - echo "Command sent to SSH FS extension"; - else - echo "Missing command shell pty of SSH FS extension? Failed to sent!" - fi -} -echo "Injected 'code' alias"; -`; - export class ConnectionManager { protected onConnectionAddedEmitter = new vscode.EventEmitter(); protected onConnectionRemovedEmitter = new vscode.EventEmitter(); @@ -70,10 +49,11 @@ export class ConnectionManager { public getPendingConnections(): [string, FileSystemConfig | undefined][] { return Object.keys(this.pendingConnections).map(name => [name, this.pendingConnections[name][1]]); } - protected async _createCommandTerminal(client: Client, authority: string, debugLogging: boolean): Promise { + protected async _createCommandTerminal(client: Client, shellConfig: ShellConfig, authority: string, debugLogging: boolean): Promise { const logging = Logging.scope(`CmdTerm(${authority})`); const shell = await toPromise(cb => client.shell({}, cb)); - shell.write('echo ::sshfs:TTY:`tty`\n'); + logging.debug(`TTY COMMAND: ${`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`}`); + shell.write(`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`); return new Promise((resolvePath, rejectPath) => { setTimeout(() => rejectPath(new Error('Timeout fetching command path')), 10e3); const rl = readline.createInterface(shell.stdout); @@ -173,15 +153,8 @@ export class ConnectionManager { const [flagRCDV, flagRCDR] = getFlagBoolean('DEBUG_REMOTE_COMMANDS', false, actualConfig.flags); const withDebugStr = flagRCDV ? ` with debug logging enabled by '${flagRCDR}'` : ''; logging.info`Flag REMOTE_COMMANDS provided in '${flagRCR}', setting up command terminal${withDebugStr}`; - const cmdPath = await this._createCommandTerminal(client, name, flagRCDV); + const cmdPath = await this._createCommandTerminal(client, shellConfig, name, flagRCDV); environment.push({ key: 'KELVIN_SSHFS_CMD_PATH', value: cmdPath }); - const profilePath = `/tmp/.Kelvin_sshfs.${actualConfig.username || Date.now()}`; - environment.push({ key: 'KELVIN_SSHFS_PROFILE_PATH', value: profilePath }); - const sftp = await toPromise(cb => client.sftp(cb)); - await toPromise(cb => sftp.writeFile(profilePath, TMP_PROFILE_SCRIPT, { mode: 0o666 }, cb)).catch(e => { - logging.error`Failed to write profile script to '${profilePath}':\n${e}\nDisabling REMOTE_COMMANDS flag`; - actualConfig.flags = ['-REMOTE_COMMANDS', ...(actualConfig.flags || [])]; - }); } logging.debug`Environment: ${environment}`; // Set up the Connection object @@ -190,6 +163,7 @@ export class ConnectionManager { config, client, actualConfig, home, shellConfig, environment, terminals: [], filesystems: [], + cache: {}, pendingUserCount: 0, idleTimer: setInterval(() => { // Automatically close connection when idle for a while timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0; diff --git a/src/pseudoTerminal.ts b/src/pseudoTerminal.ts index 7f15465..6ea9d43 100644 --- a/src/pseudoTerminal.ts +++ b/src/pseudoTerminal.ts @@ -179,9 +179,8 @@ export async function createTerminal(options: TerminalOptions): Promise" command in terminals to open the file locally if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0]) { - const profilePathEnv = env.find(e => e.key === 'KELVIN_SSHFS_PROFILE_PATH'); - if (!profilePathEnv) throw new Error(`Missing KELVIN_SSHFS_PROFILE_PATH environment variable`); - commands.push(shellConfig.setupRemoteCommands(profilePathEnv.value)); + const rcCmds = await shellConfig.setupRemoteCommands(connection); + if (rcCmds?.length) commands.push(joinCommands(rcCmds, separator)!); } // Push the actual command or (default) shell command with replaced variables if (options.command) { diff --git a/src/shellConfig.ts b/src/shellConfig.ts index 80280f6..65fb502 100644 --- a/src/shellConfig.ts +++ b/src/shellConfig.ts @@ -1,18 +1,63 @@ import { posix as path } from 'path'; -import type { Client, ClientChannel } from "ssh2"; -import type { Logger } from "./logging"; +import type { Client, ClientChannel, SFTPWrapper } from "ssh2"; +import type { Connection } from './connection'; +import { Logger, Logging } from "./logging"; import { toPromise } from "./utils"; +const SCRIPT_COMMAND_CODE = `#!/bin/sh +if [ "$#" -ne 1 ] || [ $1 = "help" ] || [ $1 = "--help" ] || [ $1 = "-h" ] || [ $1 = "-?" ]; then + echo "Usage:"; + echo " code Will make VS Code open the file"; + echo " code Will make VS Code add the folder as an additional workspace folder"; + echo " code Will prompt VS Code to create an empty file, then open it afterwards"; +elif [ ! -n "$KELVIN_SSHFS_CMD_PATH" ]; then + echo "Not running in a terminal spawned by SSH FS? Failed to sent!" +elif [ -c "$KELVIN_SSHFS_CMD_PATH" ]; then + echo "::sshfs:code:$(pwd):::$1" >> $KELVIN_SSHFS_CMD_PATH; + echo "Command sent to SSH FS extension"; +else + echo "Missing command shell pty of SSH FS extension? Failed to sent!" +fi +`; + +type RemoteCommandInitializer = (connection: Connection) => void + | string | string[] | undefined + | Promise; + +async function ensureCachedFile(connection: Connection, key: string, path: string, content: string, sftp?: SFTPWrapper): + Promise<[written: boolean, path: string | null]> { + const rc_files: Record = connection.cache.rc_files ||= {}; + if (rc_files[key]) return [false, rc_files[key]]; + try { + sftp ||= await toPromise(cb => connection.client.sftp(cb)); + await toPromise(cb => sftp!.writeFile(path, content, { mode: 0o755 }, cb)); + return [true, rc_files[key] = path]; + } catch (e) { + Logging.error`Failed to write ${key} file to '${path}':\n${e}`; + return [false, null]; + } +} + +async function rcInitializePATH(connection: Connection): Promise { + const dir = `/tmp/.Kelvin_sshfs.RcBin.${connection.actualConfig.username || Date.now()}`; + const sftp = await toPromise(cb => connection.client.sftp(cb)); + await toPromise(cb => sftp!.mkdir(dir, { mode: 0o755 }, cb)).catch(() => { }); + const [, path] = await ensureCachedFile(connection, 'CmdCode', `${dir}/code`, SCRIPT_COMMAND_CODE, sftp); + return path ? [ + connection.shellConfig.setEnv('PATH', `${dir}:$PATH`), + ] : 'echo "An error occured while adding REMOTE_COMMANDS support"'; +} + export interface ShellConfig { shell: string; setEnv(key: string, value: string): string; - setupRemoteCommands(path: string): string; + setupRemoteCommands: RemoteCommandInitializer; embedSubstitutions(command: TemplateStringsArray, ...substitutions: (string | number)[]): string; } const KNOWN_SHELL_CONFIGS: Record = {}; { const add = (shell: string, setEnv: (key: string, value: string) => string, - setupRemoteCommands: (path: string) => string, + setupRemoteCommands: RemoteCommandInitializer, embedSubstitution: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string) => { KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions: embedSubstitution }; } @@ -20,31 +65,22 @@ const KNOWN_SHELL_CONFIGS: Record = {}; { const setEnvExport = (key: string, value: string) => `export ${key}=${value}`; const setEnvSetGX = (key: string, value: string) => `set -gx ${key} ${value}`; const setEnvSetEnv = (key: string, value: string) => `setenv ${key} ${value}`; - // Ways to set up the remote commands script auto-execution - const setupRemoteCommandsENV = (path: string) => [ - `export OLD_ENV="$ENV"`, // OLD_ENV ignored for now - `export ENV="${path}"`].join('; '); - const setupRemoteCommandsPROMPT_COMMAND = (path: string) => [ - `export ORIG_PROMPT_COMMAND="$PROMPT_COMMAND"`, - `export PROMPT_COMMAND='source "${path}" PC; $ORIG_PROMPT_COMMAND'`].join('; '); - const setupRemoteCommandsUnknown = () => 'echo "This shell does not yet have REMOTE_COMMANDS support"'; // Ways to embed a substitution const embedSubstitutionsBackticks = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => '"' + substitutions.reduce((str, sub, i) => `${str}\`${sub}\`${command[i + 1]}`, command[0]) + '"'; const embedSubstitutionsFish = (command: TemplateStringsArray, ...substitutions: (string | number)[]) => substitutions.reduce((str, sub, i) => `${str}"(${sub})"${command[i + 1]}`, '"' + command[0]) + '"'; // Register the known shells - add('sh', setEnvExport, setupRemoteCommandsENV, embedSubstitutionsBackticks); - add('bash', setEnvExport, setupRemoteCommandsPROMPT_COMMAND, embedSubstitutionsBackticks); - add('rbash', setEnvExport, setupRemoteCommandsPROMPT_COMMAND, embedSubstitutionsBackticks); - add('ash', setEnvExport, setupRemoteCommandsENV, embedSubstitutionsBackticks); - add('dash', setEnvExport, setupRemoteCommandsENV, embedSubstitutionsBackticks); - add('ksh', setEnvExport, setupRemoteCommandsENV, embedSubstitutionsBackticks); - // Shells that we know `setEnv` and `embedSubstitution` for, but don't support `setupRemoteCommands` for yet - add('zsh', setEnvExport, setupRemoteCommandsUnknown, embedSubstitutionsBackticks); - add('fish', setEnvSetGX, setupRemoteCommandsUnknown, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions - add('csh', setEnvSetEnv, setupRemoteCommandsUnknown, embedSubstitutionsBackticks); - add('tcsh', setEnvSetEnv, setupRemoteCommandsUnknown, embedSubstitutionsBackticks); + add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('rbash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('ash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('dash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('ksh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('zsh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions + add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); + add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); } export async function tryCommand(ssh: Client, command: string): Promise {