安装-VSCode远程服务.psm1

# 安装-VSCode远程服务 Module
# 通过 SSH 与 BITS 在远程 Windows 主机上安装 VS Code Server

# 加载私有远程脚本模板
$script:模块根目录 = Split-Path -Parent $MyInvocation.MyCommand.Path
$script:远程脚本_通用 = Get-Content -Path (Join-Path $script:模块根目录 'private\远程安装脚本-通用.ps1') -Raw -Encoding UTF8
$script:远程脚本_Win7 = Get-Content -Path (Join-Path $script:模块根目录 'private\远程安装脚本-Win7.ps1') -Raw -Encoding UTF8
$script:远程脚本_Linux = Get-Content -Path (Join-Path $script:模块根目录 'private\远程安装脚本-Linux.sh') -Raw -Encoding UTF8

# SSH 密码复用状态(由 初始化-SSH密码复用 设置)
$script:SSH密码选项 = @()
$script:密码已注入 = $false

function 安装-VSCode远程服务 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [Alias('目标服务器', '计算机名', 'IP', '主机')]
        [string]$远程主机,

        [string]$远程账户,

        [int]$SSH端口 = 22,

        [ValidateSet('预览版', '稳定版')]
        [string]$本地版本
    )

    function 取-本地VSCode信息 {
        param(
            [string]$指定版本
        )

        function 取-单个版本信息 {
            param(
                [string]$命令名,
                [string]$版本名,
                [string]$发布通道
            )

            $命令对象 = Get-Command $命令名 -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($null -eq $命令对象) {
                return $null
            }

            $命令路径 = if ($命令对象.Path) { $命令对象.Path } else { $命令对象.Source }
            if ([string]::IsNullOrWhiteSpace($命令路径)) {
                return $null
            }

            # --version 失败属于预期情况(命令存在但无法执行或输出格式不符),
            # 其 stderr 在 PS 5.1 下会被提升为终止性错误,此处静默捕获并返回 $null,不影响后续版本探测逻辑
            try {
                $版本输出 = & $命令路径 --version
                if ($LASTEXITCODE -ne 0 -or $版本输出.Count -lt 2) {
                    return $null
                }
            } catch {
                return $null
            }

            $提交号 = [string]($版本输出 | Select-Object -Skip 1 -First 1)
            $提交号 = $提交号.Trim()
            if ($提交号 -notmatch '^[0-9a-f]{40}$') {
                return $null
            }

            return [pscustomobject]@{
                版本名   = $版本名
                命令路径 = $命令路径
                命令名称 = $命令对象.Name
                发布通道 = $发布通道
                提交号   = $提交号
            }
        }

        $预览版信息 = 取-单个版本信息 -命令名 'code-insiders' -版本名 '预览版' -发布通道 'insider'
        $稳定版信息 = 取-单个版本信息 -命令名 'code' -版本名 '稳定版' -发布通道 'stable'

        if (-not [string]::IsNullOrWhiteSpace($指定版本)) {
            if ($指定版本 -eq '预览版') {
                if ($null -eq $预览版信息) {
                    throw '用户指定了预览版,但本机未安装可用的 VS Code Insiders。'
                }
                return $预览版信息
            }

            if ($null -eq $稳定版信息) {
                throw '用户指定了稳定版,但本机未安装可用的 VS Code Stable。'
            }
            return $稳定版信息
        }

        if ($null -ne $预览版信息 -and $null -ne $稳定版信息) {
            throw '本机同时安装了预览版和稳定版。请通过 -本地版本 显式指定要使用的版本。'
        }

        if ($null -ne $预览版信息) { return $预览版信息 }
        if ($null -ne $稳定版信息) { return $稳定版信息 }

        throw '本机未检测到可用的 VS Code 预览版或稳定版。'
    }

    function 取-SSH连接目标 {
        param(
            [string]$主机,
            [string]$账户
        )

        if ([string]::IsNullOrWhiteSpace($账户)) {
            return $主机
        }

        return ('{0}@{1}' -f $账户, $主机)
    }

    function 初始化-SSH密码复用 {
        param(
            [string]$连接目标,
            [int]$端口
        )

        $script:SSH密码选项 = @()
        $script:密码已注入 = $false

        $ssh命令 = Get-Command ssh -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($null -eq $ssh命令) {
            throw '未找到 ssh 命令,无法连接远程服务器。'
        }

        # 先用 BatchMode 探测:免密(密钥/agent)可直接成功;需要密码时会立即失败。
        # 探测命令必须用 'exit 0' 而非 'true':Windows 远程主机(无论默认 shell 是 cmd 还是 PowerShell)
        # 都没有 true 命令,会导致认证虽成功但命令退出码非 0,被误判为需要密码。'exit 0' 在
        # PowerShell、cmd、bash/sh 下均是合法且退出码为 0 的命令。
        # 注意:此处绝不能对 stderr 做任何重定向(2>$null / 2>&1)。外层作用域 EAP=Stop 时,
        # 一旦被重定向,stderr 的每一行(如 CNAME 警告)都会被提升为 NativeCommandError 终止性错误,
        # 被 catch 误判为认证失败而错误进入密码收集流程。不重定向让 stderr 直达控制台(与手动 ssh 行为一致),仅凭 $LASTEXITCODE 判断。
        try {
            & $ssh命令.Source '-p' $端口 '-o' 'BatchMode=yes' '-o' 'ConnectTimeout=10' $连接目标 'exit 0' | Out-Null
            if ($LASTEXITCODE -eq 0) {
                Write-Host '检测到远程主机已配置免密登录,无需输入密码。'
                return
            }
        } catch {
            # 仅捕获 ssh 无法启动等真正异常;BatchMode 下密码认证失败以非 0 退出码体现,由下面流程处理
        }

        # 需要密码:交互收集一次,之后通过 SSH_ASKPASS 注入到后续每次 ssh/scp 调用
        $安全密码 = Read-Host ('请输入 {0} 的 SSH 密码(仅本次会话使用,不会保存)' -f $连接目标) -AsSecureString
        $密码指针 = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($安全密码)
        try {
            $明文密码 = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($密码指针)
        } finally {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($密码指针)
        }

        if ([string]::IsNullOrEmpty($明文密码)) {
            throw '密码不能为空。'
        }

        # SSH_ASKPASS 协议要求一个可执行程序:ssh 需要密码时执行它并把 stdout 第一行当作密码。
        # 密码本体只保存在当前进程的环境变量中(不写盘),模块自带的 bat 负责把该环境变量打印出来。
        $env:VSCODE_SSH密码 = $明文密码
        $env:SSH_ASKPASS = Join-Path $script:模块根目录 'private\SSH密码回显.bat'
        $env:SSH_ASKPASS_REQUIRE = 'force'
        # 注意:不要加 BatchMode=yes——它会禁用 askpass 机制,导致密码无法注入
        $script:SSH密码选项 = @()
        $script:密码已注入 = $true

        Write-Host '密码已收集,后续所有 SSH/SCP 操作将自动复用,无需再次输入。'
    }

    function 清除-SSH密码复用 {
        # 清理敏感状态(无害清理,逐条容错)
        if ($script:密码已注入) {
            Remove-Item Env:VSCODE_SSH密码 -ErrorAction SilentlyContinue
            Remove-Item Env:SSH_ASKPASS -ErrorAction SilentlyContinue
            Remove-Item Env:SSH_ASKPASS_REQUIRE -ErrorAction SilentlyContinue
            $script:密码已注入 = $false
        }
    }

    function 执行-SSH命令 {
        param(
            [string]$连接目标,
            [int]$端口,
            [string]$命令文本,
            [switch]$TTY
        )

        $ssh命令 = Get-Command ssh -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($null -eq $ssh命令) {
            throw '未找到 ssh 命令,无法连接远程服务器。'
        }

        $ssh参数 = @('-p', $端口) + $script:SSH密码选项
        if ($TTY) { $ssh参数 += '-t' }
        $ssh参数 += $连接目标
        $ssh参数 += $命令文本

        & $ssh命令.Source $ssh参数
        if ($LASTEXITCODE -ne 0) {
            throw ('SSH 执行失败,退出码: {0}' -f $LASTEXITCODE)
        }
    }

    function 上传-文件到远程 {
        param(
            [string]$本地路径,
            [string]$连接目标,
            [int]$端口,
            [string]$远程路径
        )

        $SCP命令 = Get-Command scp -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($null -eq $SCP命令) {
            throw '未找到 scp 命令。请确保已安装 OpenSSH 客户端。'
        }

        & $SCP命令.Source (@('-P', $端口) + $script:SSH密码选项 + @($本地路径, ('{0}:{1}' -f $连接目标, $远程路径)))
        if ($LASTEXITCODE -ne 0) {
            throw ('SCP 上传失败,退出码: {0}。请检查网络连接与远程主机权限。' -f $LASTEXITCODE)
        }
    }

    function 取-服务器压缩包文件名 {
        param(
            [string]$架构
        )

        return ('vscode-server-{0}.zip' -f $架构)
    }

    function 取-服务器压缩包下载地址 {
        param(
            [string]$发布通道,
            [string]$提交号,
            [string]$架构
        )

        $文件名 = 取-服务器压缩包文件名 -架构 $架构
        return ('https://vscode.download.prss.microsoft.com/dbazure/download/{0}/{1}/{2}' -f $发布通道, $提交号, $文件名)
    }

    function 下载-本地服务器压缩包 {
        param(
            [string]$发布通道,
            [string]$提交号,
            [string]$架构
        )

        $下载地址 = 取-服务器压缩包下载地址 -发布通道 $发布通道 -提交号 $提交号 -架构 $架构
        $本地压缩包路径 = Join-Path $env:TEMP ('vscode-server-{0}-{1}.zip' -f $架构, $提交号)

        if (-not (Test-Path $本地压缩包路径)) {
            Write-Host ('本机开始下载 VS Code Server 压缩包: {0}' -f $下载地址)
            try {
                Invoke-WebRequest -Uri $下载地址 -OutFile $本地压缩包路径 -UseBasicParsing
            } catch {
                throw ('本机下载 VS Code Server 压缩包失败: {0}' -f $_.Exception.Message)
            }
        }

        if (-not (Test-Path $本地压缩包路径)) {
            throw ('本机下载完成后未找到压缩包: {0}' -f $本地压缩包路径)
        }

        return $本地压缩包路径
    }

    function 探测-远程系统类型 {
        param(
            [string]$连接目标,
            [int]$端口
        )

        $ssh命令 = Get-Command ssh -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($null -eq $ssh命令) {
            throw '未找到 ssh 命令,无法探测远程系统类型。'
        }

        # Windows 远程主机的默认 shell(cmd)没有 uname 命令,只有 Linux 会原样输出内核名。
        # Windows 主机执行 uname 必失败,其 stderr 在 PS 5.1 下会被提升为终止性错误,
        # 此处静默捕获并视为 Windows,这是预期的判别路径,不影响功能
        try {
            $输出 = & $ssh命令.Source (@('-p', $端口) + $script:SSH密码选项 + @($连接目标, 'uname -s'))
            if ($LASTEXITCODE -eq 0 -and @($输出 | Where-Object { $_ -and $_.Trim() -eq 'Linux' }).Count -gt 0) {
                Write-Host '检测到远程系统类型: Linux'
                return 'Linux'
            }

            # uname 执行失败但认证已通过(LASTEXITCODE 非 0 但不是认证问题),视为 Windows
            # 认证失败的退出码通常是 255,且伴随 Permission denied;此处简化处理:非 0 即 Windows
            Write-Host '检测到远程系统类型: Windows'
            return 'Windows'
        } catch {
            # 已知且无害:Windows 无 uname 命令,失败即代表远程是 Windows
            Write-Host '检测到远程系统类型: Windows'
            return 'Windows'
        }
    }

    function 探测-远程PS版本 {
        param(
            [string]$连接目标,
            [int]$端口
        )

        $输出 = & (Get-Command ssh -ErrorAction SilentlyContinue | Select-Object -First 1).Source (@('-p', $端口) + $script:SSH密码选项 + @($连接目标, 'powershell -NoProfile -Command "Write-Output $PSVersionTable.PSVersion.Major"'))
        if ($LASTEXITCODE -eq 0 -and $输出) {
            foreach ($行 in $输出) {
                $行 = $行.Trim()
                # 跳过 SSH 诊断信息(CNAME 警告等)
                if ($行 -match '^\d+$') {
                    $主版本 = [int]$行
                    Write-Host ('检测到远程 PowerShell 主版本: {0}' -f $主版本)
                    return $主版本
                }
            }
        }

        throw '无法从远程主机获取 PowerShell 版本。请确认远程主机已安装 PowerShell 且可通过 SSH 执行。'
    }

    function 选择-远程脚本 {
        param(
            [string]$连接目标,
            [int]$端口
        )

        # 一律自动探测:按远程 PowerShell 版本决定使用 Win7 兼容版还是通用版
        $远程PS版本 = 探测-远程PS版本 -连接目标 $连接目标 -端口 $端口
        if ($远程PS版本 -le 2) {
            Write-Host ('检测到远程 PowerShell {0}.0,使用 Win7 适配版远程脚本。' -f $远程PS版本)
            return $script:远程脚本_Win7
        } else {
            Write-Host ('检测到远程 PowerShell {0}.0,使用通用版远程脚本。' -f $远程PS版本)
            return $script:远程脚本_通用
        }
    }

    function 执行-远程安装脚本 {
        param(
            [string]$连接目标,
            [int]$端口,
            [string]$脚本文本,
            [string]$本地附加压缩包路径,
            [string]$远程附加压缩包文件名
        )

        $本地临时脚本路径 = Join-Path $env:TEMP ('临时安装-VSCode远程服务-{0}.ps1' -f ([guid]::NewGuid().ToString('N')))
        $远程临时脚本文件名 = 'vscode-server-install-temp.ps1'
        $远程临时脚本路径 = ('./{0}' -f $远程临时脚本文件名)

        try {
            [System.IO.File]::WriteAllText($本地临时脚本路径, $脚本文本, [System.Text.UTF8Encoding]::new($true))
            上传-文件到远程 -本地路径 $本地临时脚本路径 -连接目标 $连接目标 -端口 $端口 -远程路径 $远程临时脚本路径
            if (-not [string]::IsNullOrWhiteSpace($本地附加压缩包路径) -and -not [string]::IsNullOrWhiteSpace($远程附加压缩包文件名)) {
                上传-文件到远程 -本地路径 $本地附加压缩包路径 -连接目标 $连接目标 -端口 $端口 -远程路径 ('./{0}' -f $远程附加压缩包文件名)
            }
            执行-SSH命令 -连接目标 $连接目标 -端口 $端口 -命令文本 ('powershell -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\{0}"' -f $远程临时脚本文件名) -TTY
        } finally {
            # 清理临时文件(无害操作,失败不影响主流程)
            if (Test-Path $本地临时脚本路径) {
                Remove-Item $本地临时脚本路径 -Force -ErrorAction SilentlyContinue
            }

            try {
                # 远程默认 shell 可能是 PowerShell 也可能是 cmd,cmd 专属语法(del、2>nul)在 PowerShell 下会报错,
                # 统一用 powershell 包装,两种 shell 下都能正确执行且静默失败
                执行-SSH命令 -连接目标 $连接目标 -端口 $端口 -命令文本 ('powershell -NoProfile -Command "Remove-Item $env:USERPROFILE\{0} -Force -ErrorAction SilentlyContinue"' -f $远程临时脚本文件名)
            } catch {
                # 远程清理失败不影响主流程,临时文件会被系统定期清理
            }
        }
    }

    function 执行-Linux远程安装脚本 {
        param(
            [string]$连接目标,
            [int]$端口,
            [string]$脚本文本
        )

        $本地临时脚本路径 = Join-Path $env:TEMP ('临时安装-VSCode远程服务-{0}.sh' -f ([guid]::NewGuid().ToString('N')))
        $远程临时脚本文件名 = 'vscode-server-install-temp.sh'
        $远程临时脚本路径 = ('./{0}' -f $远程临时脚本文件名)

        try {
            # Linux sh 脚本要求 LF 行尾,且不得带 BOM,否则 shebang 与语法会出错
            $脚本文本 = $脚本文本 -replace "`r`n", "`n"
            [System.IO.File]::WriteAllText($本地临时脚本路径, $脚本文本, [System.Text.UTF8Encoding]::new($false))
            上传-文件到远程 -本地路径 $本地临时脚本路径 -连接目标 $连接目标 -端口 $端口 -远程路径 $远程临时脚本路径
            执行-SSH命令 -连接目标 $连接目标 -端口 $端口 -命令文本 ('sh ~/{0}' -f $远程临时脚本文件名) -TTY
        } finally {
            # 清理临时文件(无害操作,失败不影响主流程)
            if (Test-Path $本地临时脚本路径) {
                Remove-Item $本地临时脚本路径 -Force -ErrorAction SilentlyContinue
            }

            try {
                执行-SSH命令 -连接目标 $连接目标 -端口 $端口 -命令文本 ('rm -f ~/{0}' -f $远程临时脚本文件名)
            } catch {
                # 远程清理失败不影响主流程,临时文件会被系统定期清理
            }
        }
    }

    # ===== 主流程 =====

    $ErrorActionPreference = 'Stop'
    $本地信息 = 取-本地VSCode信息 -指定版本 $本地版本
    $连接目标 = 取-SSH连接目标 -主机 $远程主机 -账户 $远程账户
    $本地附加压缩包路径 = $null
    $远程附加压缩包文件名 = $null

    # 检测是否免密;需要密码时收集一次并注入 SSH_ASKPASS,后续调用自动复用
    初始化-SSH密码复用 -连接目标 $连接目标 -端口 $SSH端口

    Write-Host ('本机自动检测到的 VS Code 命令: {0}' -f $本地信息.命令路径)
    Write-Host ('本机自动检测到的发布通道: {0}' -f $本地信息.发布通道)
    Write-Host ('本机自动检测到的提交号: {0}' -f $本地信息.提交号)
    Write-Host ('远程连接目标: {0}' -f $连接目标)

    # 自动检测远程系统类型
    $远程系统类型 = 探测-远程系统类型 -连接目标 $连接目标 -端口 $SSH端口

    if ($远程系统类型 -eq 'Linux') {
        # Linux 远程主机直接走 sh 安装流程
        $远程安装脚本 = $script:远程脚本_Linux
        $远程安装脚本 = $远程安装脚本.Replace('__提交号__', $本地信息.提交号)
        $远程安装脚本 = $远程安装脚本.Replace('__发布通道__', $本地信息.发布通道)

        执行-Linux远程安装脚本 -连接目标 $连接目标 -端口 $SSH端口 -脚本文本 $远程安装脚本
        清除-SSH密码复用
        return
    }

    # Windows 远程主机:一律自动探测脚本版本(按远程 PowerShell 版本选择)
    $远程安装脚本 = 选择-远程脚本 -连接目标 $连接目标 -端口 $SSH端口
    if ($远程安装脚本 -eq $script:远程脚本_Win7) {
        $本地附加压缩包路径 = 下载-本地服务器压缩包 -发布通道 $本地信息.发布通道 -提交号 $本地信息.提交号 -架构 'win32-x64'
        $远程附加压缩包文件名 = 'vscode-server-upload-temp.zip'
    }

    # 替换占位符
    $远程安装脚本 = $远程安装脚本.Replace('__提交号__', $本地信息.提交号)
    $远程安装脚本 = $远程安装脚本.Replace('__发布通道__', $本地信息.发布通道)

    执行-远程安装脚本 -连接目标 $连接目标 -端口 $SSH端口 -脚本文本 $远程安装脚本 -本地附加压缩包路径 $本地附加压缩包路径 -远程附加压缩包文件名 $远程附加压缩包文件名
    清除-SSH密码复用
}

# 导出公共函数
Export-ModuleMember -Function '安装-VSCode远程服务'