diff --git a/src/shellConfig.ts b/src/shellConfig.ts index a59e69a..689f874 100644 --- a/src/shellConfig.ts +++ b/src/shellConfig.ts @@ -1,61 +1,140 @@ +// 导入 path 模块,用于处理和转换文件路径 import { posix as path } from 'path'; +// 导入 ssh2 模块中的 Client、ClientChannel 和 SFTP 类型,用于 SSH 连接和文件传输 import type { Client, ClientChannel, SFTP } from 'ssh2'; +// 导入 connection 模块中的 Connection 类型,用于表示 SSH 连接 import type { Connection } from './connection'; +// 导入 logging 模块中的 Logger、Logging 和 LOGGING_NO_STACKTRACE 常量,用于日志记录 import { Logger, Logging, LOGGING_NO_STACKTRACE } from './logging'; +// 导入 utils 模块中的 toPromise 函数,用于将回调函数转换为 Promise import { toPromise } from './utils'; +// 定义一个 Shell 脚本,用于在 SSH FS 环境中通过 VS Code 打开文件或文件夹 const SCRIPT_COMMAND_CODE = `#!/bin/sh +// 如果参数数量不等于 1,或者参数是 help、--help、-h 或 -?,则打印帮助信息 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 +// 如果环境变量 KELVIN_SSHFS_CMD_PATH 不存在,则打印错误信息 +elif [! -n "$KELVIN_SSHFS_CMD_PATH" ]; then echo "Not running in a terminal spawned by SSH FS? Failed to sent!" +// 如果 KELVIN_SSHFS_CMD_PATH 是一个字符设备文件(通常是一个终端设备),则将命令写入该文件 elif [ -c "$KELVIN_SSHFS_CMD_PATH" ]; then echo "::sshfs:code:$(pwd):::$1" >> $KELVIN_SSHFS_CMD_PATH; echo "Command sent to SSH FS extension"; +// 如果 KELVIN_SSHFS_CMD_PATH 不是一个字符设备文件,则打印错误信息 else echo "Missing command shell pty of SSH FS extension? Failed to sent!" fi `; +/** + * 定义一个远程命令初始化器类型,它可以是一个函数、字符串、字符串数组或 Promise, + * 用于初始化远程命令。 + * @typeparam connection - 连接对象类型。 + * @returns 初始化远程命令的结果,可以是 void、字符串、字符串数组或 Promise。 + */ type RemoteCommandInitializer = (connection: Connection) => void | string | string[] | undefined | Promise; +/** + * 确保文件被缓存到远程服务器上 + * @param connection - 连接对象 + * @param key - 文件的键 + * @param path - 文件的路径 + * @param content - 文件的内容 + * @param sftp - SFTP 对象 + * @returns 一个元组,包含文件是否被写入的布尔值和文件的路径 + */ async function ensureCachedFile(connection: Connection, key: string, path: string, content: string, sftp?: SFTP): Promise<[written: boolean, path: string | null]> { + // 从连接的缓存中获取 rc_files 对象,如果不存在则初始化为空对象 const rc_files: Record = connection.cache.rc_files ||= {}; + // 如果文件已经存在于缓存中,则返回 false 和文件的路径 if (rc_files[key]) return [false, rc_files[key]]; try { + // 如果没有提供 SFTP 对象,则从连接的客户端中获取 sftp ||= await toPromise(cb => connection.client.sftp(cb)); + // 使用 SFTP 对象将文件内容写入到指定路径,并设置文件权限为 0o755 await toPromise(cb => sftp!.writeFile(path, content, { mode: 0o755 }, cb)); + // 将文件路径保存到缓存中,并返回 true 和文件路径 return [true, rc_files[key] = path]; } catch (e) { + // 如果写入文件失败,记录错误信息并返回 false 和 null Logging.error`Failed to write ${key} file to '${path}':\n${e}`; return [false, null]; } } +/** + * 初始化远程命令的 PATH 环境变量 + * @param connection - 连接对象 + * @returns 一个字符串数组,包含设置 PATH 环境变量的命令 + */ async function rcInitializePATH(connection: Connection): Promise { + // 定义一个目录路径,用于存放远程命令脚本 const dir = `/tmp/.Kelvin_sshfs.RcBin.${connection.actualConfig.username || Date.now()}`; + // 从连接对象中获取 SFTP 客户端 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 ? [ + // 如果脚本文件路径存在,则返回设置 PATH 环境变量的命令 + return path? [ connection.shellConfig.setEnv('PATH', `${dir}:$PATH`), ] : 'echo "An error occured while adding REMOTE_COMMANDS support"'; } +/** + * 定义了一个 Shell 配置对象的接口。 + * 该接口包含了 shell 的名称、是否为 Windows 系统、设置环境变量的方法、初始化远程命令的方法以及嵌入替换的方法。 + */ export interface ShellConfig { + /** + * shell 的名称。 + */ shell: string; + /** + * 是否为 Windows 系统。 + */ isWindows: boolean; + /** + * 设置环境变量的方法。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ setEnv(key: string, value: string): string; + /** + * 初始化远程命令的方法。 + * @param connection - 连接对象。 + * @returns 初始化远程命令的结果,可以是 void、字符串、字符串数组或 Promise。 + */ setupRemoteCommands?: RemoteCommandInitializer; + /** + * 嵌入替换的方法。 + * @param command - 模板字符串数组。 + * @param substitutions - 替换的字符串或数字。 + * @returns 嵌入替换后的字符串。 + */ embedSubstitutions?(command: TemplateStringsArray, ...substitutions: (string | number)[]): string; } + +// 定义一个对象,用于存储已知的 Shell 配置 export const KNOWN_SHELL_CONFIGS: Record = {}; { + /** + * 添加一个新的 Shell 配置到已知的 Shell 配置列表中。 + * @param shell - Shell 的名称。 + * @param setEnv - 设置环境变量的函数。 + * @param setupRemoteCommands - 初始化远程命令的函数。 + * @param embedSubstitutions - 嵌入替换的函数。 + * @param isWindows - 是否为 Windows 系统。 + * @returns 无返回值。 + */ const add = (shell: string, setEnv: (key: string, value: string) => string, setupRemoteCommands?: RemoteCommandInitializer, @@ -64,104 +143,232 @@ export const KNOWN_SHELL_CONFIGS: Record = {}; { KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions, isWindows }; } // Ways to set an environment variable + /** + * 设置环境变量的方法,用于导出环境变量。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ const setEnvExport = (key: string, value: string) => `export ${key}=${value}`; + /** + * 设置环境变量的方法,用于设置全局环境变量。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ const setEnvSetGX = (key: string, value: string) => `set -gx ${key} ${value}`; + /** + * 设置环境变量的方法,用于设置环境变量。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ const setEnvSetEnv = (key: string, value: string) => `setenv ${key} ${value}`; + /** + * 设置环境变量的方法,用于设置 PowerShell 环境变量。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ const setEnvPowerShell = (key: string, value: string) => `$env:${key}="${value}"`; + /** + * 设置环境变量的方法,用于设置命令行环境变量。 + * @param key - 环境变量的键。 + * @param value - 环境变量的值。 + * @returns 设置环境变量后的字符串。 + */ const setEnvSet = (key: string, value: string) => `set ${key}=${value}`; + // Ways to embed a substitution - const embedSubstitutionsBackticks = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => + /** + * 使用反引号嵌入替换字符串的方法。 + * @param command - 模板字符串数组。 + * @param substitutions - 替换的字符串或数字。 + * @returns 嵌入替换后的字符串。 + */ + 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)[]): string => + /** + * 使用 Fish 脚本嵌入替换字符串的方法。 + * @param command - 模板字符串数组。 + * @param substitutions - 替换的字符串或数字。 + * @returns 嵌入替换后的字符串。 + */ + 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 => + /** + * 使用 PowerShell 嵌入替换字符串的方法。 + * @param command - 模板字符串数组。 + * @param substitutions - 替换的字符串或数字。 + * @returns 嵌入替换后的字符串。 + */ + const embedSubstitutionsPowershell = (command: TemplateStringsArray,...substitutions: (string | number)[]): string => substitutions.reduce((str, sub, i) => `${str}$(${sub})${command[i + 1]}`, '"' + command[0]) + '"'; // Register the known shells + // 添加 sh 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 bash 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 rbash 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('rbash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 ash 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('ash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 dash 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('dash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 ksh 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('ksh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 zsh 外壳配置,使用 setEnvExport 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('zsh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 fish 外壳配置,使用 setEnvSetGX 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsFish 函数嵌入替换字符串 add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions + // 添加 csh 外壳配置,使用 setEnvSetEnv 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 tcsh 外壳配置,使用 setEnvSetEnv 函数设置环境变量,使用 rcInitializePATH 函数初始化远程命令,使用 embedSubstitutionsBackticks 函数嵌入替换字符串 add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); + // 添加 powershell 外壳配置,使用 setEnvPowerShell 函数设置环境变量,使用 embedSubstitutionsPowershell 函数嵌入替换字符串,isWindows 设置为 true add('powershell', setEnvPowerShell, undefined, embedSubstitutionsPowershell, true); // experimental + // 添加 cmd.exe 外壳配置,使用 setEnvSet 函数设置环境变量,isWindows 设置为 true add('cmd.exe', setEnvSet, undefined, undefined, true); // experimental } - +/** + * 在 SSH 客户端上尝试执行指定的命令,并返回命令的输出或 null。 + * 如果命令执行失败,将抛出一个包含错误信息的 Error 对象。 + * @param ssh - SSH 客户端实例。 + * @param command - 要执行的命令字符串。 + * @returns 命令的标准输出字符串,如果命令执行成功;否则返回 null。 + * @throws Error 如果命令执行失败,将抛出一个包含错误信息的 Error 对象。 + */ export async function tryCommand(ssh: Client, command: string): Promise { + // 使用 ssh.exec 方法执行命令,并将结果转换为 Promise 类型 const exec = await toPromise(cb => ssh.exec(command, cb)); + // 初始化一个包含两个空字符串的数组,用于存储命令的标准输出和标准错误输出 const output = ['', ''] as [string, string]; + // 监听 exec 事件,当有数据输出时,将数据添加到 output 数组的第一个元素中 exec.on('data', (chunk: any) => output[0] += chunk); + // 监听 exec.stderr 事件,当有错误数据输出时,将数据添加到 output 数组的第二个元素中 exec.stderr!.on('data', (chunk: any) => output[1] += chunk); + // 等待 exec 事件结束,并处理可能的错误 await toPromise(cb => { exec.once('error', cb); exec.once('close', cb); }).catch(e => { + // 如果捕获到的错误不是数字类型,则重新抛出该错误 if (typeof e !== 'number') throw e; + // 否则,抛出一个新的 Error 对象,包含命令失败的退出码和错误信息 throw new Error(`Command '${command}' failed with exit code ${e}${output[1] ? `:\n${output[1].trim()}` : ''}`); }); + // 如果命令没有产生标准输出 if (!output[0]) { + // 如果也没有标准错误输出,则返回 null if (!output[1]) return null; + // 否则,抛出一个新的 Error 对象,包含命令失败的错误信息 throw new Error(`Command '${command}' only produced stderr:\n${output[1].trim()}`); } + // 返回命令的标准输出 return output[0]; } +/** + * 在 SSH 客户端上尝试执行一个带有变量替换的 echo 命令,并返回变量的值或 null。 + * 如果 shell 配置不支持嵌入替换,则抛出一个错误。 + * @param ssh - SSH 客户端实例。 + * @param shellConfig - Shell 配置对象。 + * @param variable - 要替换的变量名。 + * @returns 变量的值,如果命令执行成功;否则返回 null。 + * @throws Error 如果 shell 配置不支持嵌入替换,则抛出一个错误。 + */ export async function tryEcho(ssh: Client, shellConfig: ShellConfig, variable: string): Promise { + // 检查 shellConfig 是否支持嵌入替换,如果不支持则抛出错误 if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`); + // 生成一个唯一的数字,用于标记 echo 命令的输出 const uniq = Date.now() % 1e5; + // 使用 tryCommand 函数执行 echo 命令,并传入嵌入替换的字符串 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; } +/** + * 获取远程 SSH 客户端上 PowerShell 的版本号。 + * @param client - SSH 客户端实例。 + * @returns PowerShell 的版本号,如果获取成功;否则返回 null。 + */ async function getPowershellVersion(client: Client): Promise { + // 尝试执行命令获取 PowerShell 版本信息 const version = await tryCommand(client, 'echo $PSversionTable.PSVersion.ToString()').catch(e => { + // 如果命令执行失败,记录错误信息并返回 null console.error(e); return null; }); + // 如果获取到版本信息,使用正则表达式匹配版本号 return version?.match(/\d+\.\d+\.\d+\.\d+/)?.[0] || null; } +/** + * 获取远程 SSH 客户端上 Windows 操作系统的版本号。 + * @param client - SSH 客户端实例。 + * @returns Windows 操作系统的版本号,如果获取成功;否则返回 null。 + */ async function getWindowsVersion(client: Client): Promise { + // 尝试执行命令获取 Windows 版本信息 const version = await tryCommand(client, 'systeminfo | findstr /BC:"OS Version"').catch(e => { + // 如果命令执行失败,记录错误信息并返回 null console.error(e); return null; }); + // 如果获取到版本信息,使用正则表达式匹配版本号 const match = version?.trim().match(/^OS Version:[ \t]+(.*)$/); + // 返回匹配结果的第一个捕获组,即版本号 return match?.[1] || null; } +/** + * 计算并返回远程 SSH 客户端的 Shell 配置。 + * @param client - SSH 客户端实例。 + * @param logging - 日志记录器,可选参数。 + * @returns 计算出的 Shell 配置对象。 + * @throws Error 如果计算过程中发生错误,将抛出一个包含错误信息的 Error 对象。 + */ export async function calculateShellConfig(client: Client, logging?: Logger): Promise { try { + // 尝试执行命令获取 Shell 信息 const shellStdout = await tryCommand(client, 'echo :::SHELL:$SHELL:SHELL:::'); + // 从输出中提取 Shell 名称 const shell = shellStdout?.match(/:::SHELL:([^$].*?):SHELL:::/)?.[1]; if (!shell) { + // 如果未获取到 Shell 名称,尝试获取 PowerShell 版本信息 const psVersion = await getPowershellVersion(client); if (psVersion) { logging?.debug(`Detected PowerShell version ${psVersion}`); + // 返回 PowerShell 的 Shell 配置 return { ...KNOWN_SHELL_CONFIGS['powershell'], shell: 'PowerShell' }; } + // 如果未获取到 PowerShell 版本信息,尝试获取 Windows 版本信息 const windowsVersion = await getWindowsVersion(client); if (windowsVersion) { logging?.debug(`Detected Command Prompt for Windows ${windowsVersion}`); + // 返回 cmd.exe 的 Shell 配置 return { ...KNOWN_SHELL_CONFIGS['cmd.exe'], shell: 'cmd.exe' }; } + // 如果既未获取到 Shell 名称,也未获取到 PowerShell 或 Windows 版本信息,记录错误并抛出异常 if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`, LOGGING_NO_STACKTRACE); throw new Error('Could not get $SHELL'); } + // 检查是否存在已知的 Shell 配置 const known = KNOWN_SHELL_CONFIGS[path.basename(shell)]; if (known) { logging?.debug(`Detected known $SHELL '${shell}' (${known.shell})`); + // 返回已知的 Shell 配置 return known; } else { logging?.warning(`Unrecognized $SHELL '${shell}', using default ShellConfig instead`); + // 返回默认的 Shell 配置 return { ...KNOWN_SHELL_CONFIGS['sh'], shell }; } } catch (e) { + // 如果发生错误,记录错误并返回默认的 Shell 配置 logging && logging.error`Error calculating ShellConfig: ${e}`; return { ...KNOWN_SHELL_CONFIGS['sh'], shell: '???' }; } } +