private/远程安装脚本-通用.ps1
|
# 远程安装脚本(通用版) # 要求远程 PowerShell 3.0+ # 此文件通过主脚本上传到远程主机执行,__占位符__ 会在上传前被替换为实际值。 $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 function 取-系统架构标识 { $原始架构 = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } elseif ($env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE } else { $null } if ([string]::IsNullOrEmpty($原始架构) -or $原始架构.Trim().Length -eq 0) { throw '未能自动检测远程系统架构。' } switch ($原始架构.ToUpperInvariant()) { 'AMD64' { return 'win32-x64' } 'X64' { return 'win32-x64' } 'ARM64' { return 'win32-arm64' } default { throw ('暂不支持的远程系统架构: {0}' -f $原始架构) } } } function 取-安装目录 { param( [string]$通道, [string]$版本提交号 ) # 新版 exec server 布局:<数据目录>\cli\servers\<质量名>-<提交号>\server # 旧版 bin\<提交号> 布局已不再写入(Remote-SSH 的 CLI 只认新布局) $质量名 = if ($通道 -eq 'insider') { 'Insiders' } else { 'Stable' } $数据目录 = if ($通道 -eq 'insider') { Join-Path $HOME '.vscode-server-insiders' } else { Join-Path $HOME '.vscode-server' } return Join-Path $数据目录 ('cli\servers\{0}-{1}\server' -f $质量名, $版本提交号) } function 取-CLI安装信息 { param( [string]$通道, [string]$版本提交号, [string]$架构 ) # Remote-SSH 引导的契约:数据根目录下需存在 <cli名>-<提交号>[.exe],cli 名稳定版为 code、预览版为 code-insiders;Windows 的 CLI 下载 artifact 为 cli-win32-<短架构>(如 cli-win32-x64),与服务器压缩包的 win32-x64 标识不同,需去掉 win32- 前缀取短架构;包内可执行文件不带提交号,落地后按上述规则重命名 $cli基础名 = if ($通道 -eq 'insider') { 'code-insiders' } else { 'code' } $cli落地名 = ('{0}-{1}.exe' -f $cli基础名, $版本提交号) $短架构 = $架构 -replace '^win32-', '' $数据目录 = if ($通道 -eq 'insider') { Join-Path $HOME '.vscode-server-insiders' } else { Join-Path $HOME '.vscode-server' } return [pscustomobject]@{ 包内可执行名 = ('{0}.exe' -f $cli基础名) 落地路径 = (Join-Path $数据目录 $cli落地名) 下载地址 = ('https://update.code.visualstudio.com/commit:{0}/cli-win32-{1}/{2}' -f $版本提交号, $短架构, $通道) } } function 安装-远程CLI { param( [pscustomobject]$CLI信息 ) # 仅补装缺失的 CLI(Remote-SSH 引导仅做文件存在性检查,存在即视为已安装);CLI 压缩包约 30MB,直接整包下载,无需断点续传,失败按递增间隔无限重试 if (Test-Path -LiteralPath $CLI信息.落地路径) { Write-Host ('[{0}] CLI 已存在,跳过安装: {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $CLI信息.落地路径) return } $数据目录 = Split-Path -Parent $CLI信息.落地路径 New-Item -ItemType Directory -Force $数据目录 | Out-Null $cli压缩包路径 = Join-Path $数据目录 ('vscode-cli-{0}.zip' -f [System.IO.Path]::GetFileNameWithoutExtension($CLI信息.落地路径)) $重试次数 = 0 while ($true) { $重试次数++ try { Write-Host ('[{0}] 开始下载远程 CLI(第 {1} 次尝试): {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $重试次数, $CLI信息.下载地址) Invoke-WebRequest -Uri $CLI信息.下载地址 -OutFile $cli压缩包路径 -UseBasicParsing $临时解压目录 = Join-Path $数据目录 ('cli-unpack-{0}' -f ([System.IO.Path]::GetRandomFileName())) try { Expand-Archive -Path $cli压缩包路径 -DestinationPath $临时解压目录 -Force $包内路径 = Join-Path $临时解压目录 $CLI信息.包内可执行名 if (-not (Test-Path -LiteralPath $包内路径)) { throw ('CLI 压缩包内未找到 {0}。' -f $CLI信息.包内可执行名) } Copy-Item -LiteralPath $包内路径 -Destination $CLI信息.落地路径 -Force } finally { Remove-Item $临时解压目录 -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $cli压缩包路径 -Force -ErrorAction SilentlyContinue } Write-Host ('[{0}] 远程 CLI 安装完成: {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $CLI信息.落地路径) return } catch { Write-Host ('[{0}] 第 {1} 次 CLI 下载中断: {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $重试次数, $_.Exception.Message) } Start-Sleep -Seconds $重试次数 } } function 取-下载地址 { param( [string]$通道, [string]$版本提交号, [string]$架构 ) $文件名 = 'vscode-server-{0}.zip' -f $架构 return 'https://vscode.download.prss.microsoft.com/dbazure/download/{0}/{1}/{2}' -f $通道, $版本提交号, $文件名 } function 停止-占用安装目录的进程 { param( [string]$安装目录, [string]$版本提交号 ) $进程列表 = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($安装目录, [System.StringComparison]::OrdinalIgnoreCase)) -or ($_.CommandLine -and $_.CommandLine -like ('*' + $版本提交号 + '*')) } foreach ($进程 in $进程列表) { try { Stop-Process -Id $进程.ProcessId -Force -ErrorAction SilentlyContinue } catch { } } } function 通过HTTP断点续传下载 { param( [string]$下载地址, [string]$目标路径 ) # 基于 HTTP Range 头的断点续传:本地已存在部分文件时从偏移处继续。 # 不限重试次数与总时长,失败等待秒数逐次递增(1秒、2秒、3秒……),所有输出带时间戳。 $重试次数 = 0 while ($true) { $重试次数++ $已存在字节数 = 0 if (Test-Path $目标路径) { $已存在字节数 = (Get-Item $目标路径).Length } try { $Web请求 = [System.Net.HttpWebRequest]::Create($下载地址) $Web请求.Timeout = 60000 $Web请求.ReadWriteTimeout = 60000 if ($已存在字节数 -gt 0) { $Web请求.AddRange($已存在字节数) Write-Host ('[{0}] 从 {1} 字节处断点续传(第 {2} 次尝试): {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $已存在字节数, $重试次数, $下载地址) } else { Write-Host ('[{0}] 开始下载(第 {1} 次尝试): {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $重试次数, $下载地址) } $响应 = $Web请求.GetResponse() $状态码 = [int]$响应.StatusCode $响应流 = $null $文件流 = $null try { if ($状态码 -eq 206) { # 服务器支持断点续传:以追加方式写入剩余内容,文件总长度 = 已有部分 + 本次 ContentLength $内容总长度 = $已存在字节数 + $响应.ContentLength if ($响应.ContentLength -lt 0) { $内容总长度 = 0 } $文件流 = [System.IO.File]::Open($目标路径, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write) } elseif ($状态码 -eq 200) { if ($已存在字节数 -gt 0) { Write-Host ('[{0}] 服务器未响应 Range 请求(返回 200),将重新完整下载。' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) } $内容总长度 = $响应.ContentLength if ($内容总长度 -lt 0) { $内容总长度 = 0 } $文件流 = [System.IO.File]::Open($目标路径, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write) } else { throw ('收到意外的 HTTP 状态码: {0}' -f $状态码) } $响应流 = $响应.GetResponseStream() $缓冲区 = New-Object byte[] 65536 $已写入字节数 = 0 $上次报告时间 = [datetime]::MinValue # 报告周期从 1 秒起:进度不足 2/3 时每个周期比上个周期多 1 秒,超过 2/3 后每个周期比上个周期少 1 秒,不低于 1 秒 $报告周期秒数 = 1 while ($true) { $读取字节数 = $响应流.Read($缓冲区, 0, $缓冲区.Length) if ($读取字节数 -le 0) { break } $文件流.Write($缓冲区, 0, $读取字节数) $已写入字节数 += $读取字节数 $当前时间 = Get-Date if (($当前时间 - $上次报告时间).TotalSeconds -ge $报告周期秒数) { $上次报告时间 = $当前时间 $已传总字节数 = $已存在字节数 + $已写入字节数 if ($内容总长度 -gt 0) { $进度 = [math]::Round(($已传总字节数 * 100.0) / $内容总长度, 1) Write-Host ('[{0}] 状态: Transferring | 进度: {1}% | {2} / {3} 字节' -f ($当前时间.ToString('HH:mm:ss')), $进度, $已传总字节数, $内容总长度) # 按当前进度决定下个周期:不足 2/3 递增,超过 2/3 递减(下限 1 秒) if ($已传总字节数 * 3 -gt $内容总长度 * 2) { if ($报告周期秒数 -gt 1) { $报告周期秒数-- } } else { $报告周期秒数++ } } else { Write-Host ('[{0}] 状态: Transferring | 已传: {1} 字节' -f ($当前时间.ToString('HH:mm:ss')), $已传总字节数) # 总大小未知时无法计算进度比例,按未达 2/3 处理递增周期 $报告周期秒数++ } } } } finally { if ($null -ne $响应流) { $响应流.Dispose() } if ($null -ne $文件流) { $文件流.Dispose() } $响应.Dispose() } if ($内容总长度 -gt 0 -and (Get-Item $目标路径).Length -lt $内容总长度) { throw ('下载提前结束: 文件 {0} 字节,预期至少 {1} 字节。' -f (Get-Item $目标路径).Length, $内容总长度) } Write-Host ('[{0}] 下载完成。' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) return } catch { Write-Host ('[{0}] 第 {1} 次下载中断: {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $重试次数, $_.Exception.Message) } # 保留已下载的部分文件,等待递增秒数后自动续传重试(1秒、2秒、3秒……) Start-Sleep -Seconds $重试次数 } } function 展开-服务器压缩包 { param( [string]$压缩包路径, [string]$目标目录 ) Expand-Archive -Path $压缩包路径 -DestinationPath $目标目录 -Force $目录项列表 = @(Get-ChildItem -Force $目标目录) if ($目录项列表.Count -eq 1 -and $目录项列表[0].PSIsContainer) { $内层目录 = $目录项列表[0] Get-ChildItem -Force $内层目录.FullName | Move-Item -Destination $目标目录 -Force Remove-Item $内层目录.FullName -Recurse -Force } $包装目录 = Get-ChildItem -Force $目标目录 -Directory | Where-Object { $_.Name -like 'vscode-server-*' } | Select-Object -First 1 if ($null -ne $包装目录) { foreach ($包装目录项 in (Get-ChildItem -Force $包装目录.FullName)) { $目标路径 = Join-Path $目标目录 $包装目录项.Name if (-not (Test-Path $目标路径)) { Move-Item $包装目录项.FullName -Destination $目标目录 -Force } } Remove-Item $包装目录.FullName -Recurse -Force } Remove-Item $压缩包路径 -Force -ErrorAction SilentlyContinue } $提交号 = '__提交号__' $发布通道 = '__发布通道__' $系统架构 = 取-系统架构标识 $最终安装目录 = 取-安装目录 -通道 $发布通道 -版本提交号 $提交号 # 固定文件名(不含随机后缀),使同一安装目录下的下载路径确定,便于识别与断点续传定位 $下载压缩包路径 = Join-Path $最终安装目录 'vscode-server-download.zip' $下载地址 = 取-下载地址 -通道 $发布通道 -版本提交号 $提交号 -架构 $系统架构 $CLI信息 = 取-CLI安装信息 -通道 $发布通道 -版本提交号 $提交号 -架构 $系统架构 Write-Host ('准备安装 VS Code 远程服务,提交号: {0}' -f $提交号) Write-Host ('发布通道: {0}' -f $发布通道) Write-Host ('自动检测到的系统架构: {0}' -f $系统架构) Write-Host ('自动检测到的安装目录: {0}' -f $最终安装目录) Write-Host ('下载地址: {0}' -f $下载地址) # 远程服务端的启动入口是 CLI(code[-insiders]-<提交号>.exe),由它按需拉起 server,二者缺一不可,故先补 CLI 安装-远程CLI -CLI信息 $CLI信息 $服务端已就绪 = (Test-Path -LiteralPath (Join-Path $最终安装目录 'product.json')) -and (Test-Path -LiteralPath (Join-Path $最终安装目录 'bin')) if ($服务端已就绪) { # Remote-SSH 的 CLI 用 server 目录下的 product.json 与 bin 判定安装完整性,同提交号且结构完整则直接复用,避免重复下载约 190MB 的 Server 压缩包 Write-Host ('[{0}] 同提交号的 Server 已完整安装,跳过下载与解压: {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $最终安装目录) Get-ChildItem -Force $最终安装目录 | Select-Object Name, Length, Mode | Format-Table -AutoSize return } 停止-占用安装目录的进程 -安装目录 $最终安装目录 -版本提交号 $提交号 if (Test-Path $最终安装目录) { Remove-Item $最终安装目录 -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Force $最终安装目录 | Out-Null Write-Host '开始通过 HTTP 断点续传下载压缩包。' 通过HTTP断点续传下载 -下载地址 $下载地址 -目标路径 $下载压缩包路径 if (-not (Test-Path $下载压缩包路径)) { throw ('下载完成后未找到压缩包: {0}' -f $下载压缩包路径) } Write-Host '下载完成,开始解压。' 展开-服务器压缩包 -压缩包路径 $下载压缩包路径 -目标目录 $最终安装目录 Write-Host '安装完成,当前目录内容如下。' Get-ChildItem -Force $最终安装目录 | Select-Object Name, Length, Mode | Format-Table -AutoSize |