diff --git a/CHANGELOG.md b/CHANGELOG.md index 316cdb0..711af83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ - For this major update a `ssh2.ts` replacing `@types/ssh2` is added to the `common` module - This does pull in a lot of new fixes/features added since `ssh2@1.0.0` though - Some feature requests are now easier/possible to implement with these new features +- Add initial support for Windows OpenSSH servers (fixes #338) + - This adds initial support for Command Prompt, and theoretically PowerShell (untested) + - The `REMOTE_COMMANDS` is not yet supported, as it uses the pty's `tty` for cross-terminal communication + - Future `REMOTE_COMMANDS` support for PowerShell (since it can interact with named pipes) is planned + - Future `REMOTE_COMMANDS` support for Command Prompt is currently not yet planned, but might be possible + - Mind that some (future) features won't work (maybe just for now, maybe forever) on Windows ### New features - Added `FS_NOTIFY_ERRORS` flag to display notifications for FS errors (#282) diff --git a/src/connection.ts b/src/connection.ts index 365391c..f68ccc5 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { configMatches, getFlagBoolean, loadConfigs } from './config'; import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import type { SSHPseudoTerminal } from './pseudoTerminal'; -import { calculateShellConfig, ShellConfig, tryEcho } from './shellConfig'; +import { calculateShellConfig, ShellConfig, tryCommand, tryEcho } from './shellConfig'; import type { SSHFileSystem } from './sshFileSystem'; import { mergeEnvironment, toPromise } from './utils'; @@ -51,6 +51,7 @@ export class ConnectionManager { } protected async _createCommandTerminal(client: Client, shellConfig: ShellConfig, authority: string, debugLogging: boolean): Promise { const logging = Logging.scope(`CmdTerm(${authority})`); + if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`); const shell = await toPromise(cb => client.shell({}, cb)); logging.debug(`TTY COMMAND: ${`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`}`); shell.write(`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`); @@ -128,7 +129,15 @@ export class ConnectionManager { // Calculate shell config const shellConfig = await calculateShellConfig(client, logging); // Query home directory - let home = await tryEcho(client, shellConfig, '~').catch((e: Error) => e); + let home: string | Error | null; + if (shellConfig.isWindows) { + home = await tryCommand(client, "echo %USERPROFILE%").catch((e: Error) => e); + if (home === null) home = new Error(`No output for "echo %USERPROFILE%"`); + if (typeof home === 'string') home = home.trim(); + if (home === "%USERPROFILE%") home = new Error(`Non-substituted output for "echo %USERPROFILE%"`); + } else { + home = await tryEcho(client, shellConfig, '~').catch((e: Error) => e); + } if (typeof home !== 'string') { const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags); logging.error('Could not detect home directory', LOGGING_NO_STACKTRACE); @@ -153,8 +162,12 @@ 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, shellConfig, name, flagRCDV); - environment.push({ key: 'KELVIN_SSHFS_CMD_PATH', value: cmdPath }); + if (shellConfig.isWindows) { + logging.error(`Windows detected, command terminal is not yet supported`, LOGGING_NO_STACKTRACE); + } else { + const cmdPath = await this._createCommandTerminal(client, shellConfig, name, flagRCDV); + environment.push({ key: 'KELVIN_SSHFS_CMD_PATH', value: cmdPath }); + } } logging.debug`Environment: ${environment}`; // Set up the Connection object diff --git a/src/manager.ts b/src/manager.ts index f394413..f2b1697 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -156,7 +156,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider const connection = await this.connectionManager.createConnection(resolved.host); resolved = await replaceVariablesRecursive(resolved, value => replaceVariables(value, connection.actualConfig)); let { command, workingDirectory } = resolved; - const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', false, connection.actualConfig.flags); + const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', connection.shellConfig.isWindows, connection.actualConfig.flags); const separator = useWinCmdSep ? ' && ' : '; '; let { taskCommand = '$COMMAND' } = connection.actualConfig; taskCommand = joinCommands(taskCommand, separator)!; diff --git a/src/pseudoTerminal.ts b/src/pseudoTerminal.ts index e360505..93f0e47 100644 --- a/src/pseudoTerminal.ts +++ b/src/pseudoTerminal.ts @@ -170,15 +170,16 @@ export async function createTerminal(options: TerminalOptions): Promise" command in terminals to open the file locally - if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0]) { + if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0] && shellConfig.setupRemoteCommands) { const rcCmds = await shellConfig.setupRemoteCommands(connection); if (rcCmds?.length) commands.push(joinCommands(rcCmds, separator)!); } @@ -198,12 +199,16 @@ export async function createTerminal(options: TerminalOptions): Promise = {}; { const add = (shell: string, setEnv: (key: string, value: string) => string, - setupRemoteCommands: RemoteCommandInitializer, - embedSubstitution: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string) => { - KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions: embedSubstitution }; + setupRemoteCommands?: RemoteCommandInitializer, + embedSubstitutions?: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string, + isWindows = false) => { + KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions, isWindows }; } // Ways to set an environment variable 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}`; + const setEnvPowerShell = (key: string, value: string) => `$env:${key}="${value}"`; + const setEnvSet = (key: string, value: string) => `set ${key}=${value}`; // 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)[]) => + const embedSubstitutionsFish = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => substitutions.reduce((str, sub, i) => `${str}"(${sub})"${command[i + 1]}`, '"' + command[0]) + '"'; + const embedSubstitutionsPowershell = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => + substitutions.reduce((str, sub, i) => `${str}$(${sub})${command[i + 1]}`, '"' + command[0]) + '"'; // Register the known shells add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); @@ -81,6 +87,8 @@ const KNOWN_SHELL_CONFIGS: Record = {}; { add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); + add('powershell', setEnvPowerShell, undefined, embedSubstitutionsPowershell, true); // experimental + add('cmd.exe', setEnvSet, undefined, undefined, true); // experimental } export async function tryCommand(ssh: Client, command: string): Promise { @@ -103,17 +111,45 @@ export async function tryCommand(ssh: Client, command: string): Promise { + if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`); const uniq = Date.now() % 1e5; const output = await tryCommand(ssh, `echo ${shellConfig.embedSubstitutions`::${'echo ' + uniq}:echo_result:${`echo ${variable}`}:${'echo ' + uniq}::`}`); return output?.match(`::${uniq}:echo_result:(.*?):${uniq}::`)?.[1] || null; } +async function getPowershellVersion(client: Client): Promise { + const version = await tryCommand(client, 'echo $PSversionTable.PSVersion.ToString()').catch(e => { + console.error(e); + return null; + }); + return !version?.includes('PSVersion') ? version : null; +} + +async function getWindowsVersion(client: Client): Promise { + const version = await tryCommand(client, 'systeminfo | findstr /BC:"OS Version"').catch(e => { + console.error(e); + return null; + }); + const match = version?.trim().match(/^OS Version:[ \t]+(.*)$/); + return match?.[1] || null; +} + export async function calculateShellConfig(client: Client, logging?: Logger): Promise { try { const shellStdout = await tryCommand(client, 'echo :::SHELL:$SHELL:SHELL:::'); const shell = shellStdout?.match(/:::SHELL:([^$].*?):SHELL:::/)?.[1]; if (!shell) { - if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`); + const psVersion = await getPowershellVersion(client); + if (psVersion) { + logging?.debug(`Detected PowerShell version ${psVersion}`); + return { ...KNOWN_SHELL_CONFIGS['powershell'], shell: 'PowerShell' }; + } + const windowsVersion = await getWindowsVersion(client); + if (windowsVersion) { + logging?.debug(`Detected Command Prompt for Windows ${windowsVersion}`); + return { ...KNOWN_SHELL_CONFIGS['cmd.exe'], shell: 'cmd.exe' }; + } + if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`, LOGGING_NO_STACKTRACE); throw new Error('Could not get $SHELL'); } const known = KNOWN_SHELL_CONFIGS[path.basename(shell)];