scripts\pkg-manage.ps1¶
源码路径:
scripts\pkg-manage.ps1· 458 行
# NFarms HUB · 运行环境统一管理入口
#
# ===========================================================================
# 为什么要有这个脚本
# ===========================================================================
#
# 原来「哪个包管理器装哪个组件」这件事散在**两个地方**:
#
# * `nfarms-core/src/envprobe.rs` 里一张 (组件, winget id, choco 名, scoop 名) 表,
# 加上一段按管理器分支拼命令的代码;
# * 界面上一个 `winget / choco / scoop` 选择器。
#
# 后果:加一个组件要改 Rust 重新编译、界面还要让用户先选管理器 ——
# 而**用户通常并不知道自己机器上有哪个管理器**,更不该被要求先选。
#
# 现在全部收进这一个脚本:
#
# * 脚本自己探测有哪些管理器,自己决定用哪个(也可以由调用方指定);
# * 一个管理器都没有时,回退到**直链下载**(见下面的 Direct 字段);
# * 加组件只改这个脚本的表,不用动 Rust。
#
# ===========================================================================
# 用法
# ===========================================================================
#
# # 本机有哪几个管理器(JSON)
# .\pkg-manage.ps1 -Action managers
#
# # 全部组件的状态(JSON)
# .\pkg-manage.ps1 -Action check
#
# # 只查一个
# .\pkg-manage.ps1 -Action check -Id cmake
#
# # 装/更新/卸载(自动选管理器)
# .\pkg-manage.ps1 -Action install -Id cmake
# .\pkg-manage.ps1 -Action update -Id cmake
# .\pkg-manage.ps1 -Action uninstall -Id cmake
#
# # 指定管理器(不指定就是 auto)
# .\pkg-manage.ps1 -Action install -Id cmake -Manager choco
#
# # 光看会执行什么,不真跑
# .\pkg-manage.ps1 -Action install -Id cmake -DryRun
#
# 程序侧一律加 `-Json` 拿机器可读的结果。
#
# ===========================================================================
# 注意(踩过的坑)
# ===========================================================================
#
# * 这个文件**必须存成带 BOM 的 UTF-8**。PowerShell 5.1 读 .ps1 时,
# 没有 BOM 就按系统 ANSI(中文机器上是 GBK)解码 —— 中文全变乱码,
# 而且乱码字节可能**吃掉字符串的引号**,直接变成语法错误。
# * 下面所有注释与字符串里**避免使用反引号**:它是 PowerShell 的转义符,
# 在注释里也会引起解析问题(实测踩过)。
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('managers', 'check', 'install', 'update', 'uninstall', 'list')]
[string]$Action,
[string]$Id,
[ValidateSet('auto', 'winget', 'choco', 'scoop', 'direct')]
[string]$Manager = 'auto',
[switch]$Json,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# ===========================================================================
# 组件表
# ===========================================================================
#
# Probe —— 怎么判断装没装,以及怎么从输出里抠出版本号
# Winget —— winget 的包 id(空字符串 = 这个管理器没有)
# Choco —— choco 的包名
# Scoop —— scoop 的包名
# Direct —— 一个管理器都没有时的直链兜底(空 = 没有兜底)
# Note —— 给用户看的注意事项
#
# 版本探测统一用:命令 + 一个正则,取第一个捕获组。
#
# 正则是 (数字(?:.数字)+) 而不是更宽松的 ([\d.]+) —— 后者会:
# * 匹配到输出里一个孤立的点(dotnet 就踩了,版本变成 ".");
# * 把 2.55.0.windows.1 里的 2.55.0. 连尾点一起吃掉。
# 要求「至少一个 数字.数字」之后两个问题都没了。
$Components = [ordered]@{
'node' = @{
Name = 'Node.js'
Cmd = 'node --version'
Regex = 'v?([\d.]+)'
Winget = 'OpenJS.NodeJS.LTS'
Choco = 'nodejs-lts'
Scoop = 'nodejs-lts'
Note = ''
}
'jdk' = @{
Name = 'JDK'
Cmd = 'java -version'
# java -version 把版本号打在 **stderr** 上,而且带引号:
# openjdk version "21.0.1" 2023-10-17
# 所以正则要允许引号,调用方也要把 stderr 一起抓。
Regex = '"?(\d+(?:[._]\d+)*)'
Winget = 'Microsoft.OpenJDK.21'
Choco = 'temurin21'
Scoop = 'temurin21-jdk'
Note = '装完要新开终端,并确认 JAVA_HOME 指到新版本'
}
'dotnet' = @{
Name = '.NET SDK'
# 用 **--list-sdks** 而不是 --version:
# 只有运行时、没有 SDK 时,`dotnet --version` 会打印一大段错误,
# 里面的 "aka.ms" 还能被宽松正则匹配成一个点 —— 假阳性。
# --list-sdks 只列 SDK,没有就输出空。
Cmd = 'dotnet --list-sdks'
Regex = '^(\d+(?:\.\d+)+)'
Winget = 'Microsoft.DotNet.SDK.8'
Choco = 'dotnet-sdk'
Scoop = 'dotnet-sdk'
Note = ''
}
'cmake' = @{
Name = 'CMake'
Cmd = 'cmake --version'
Regex = '(\d+(?:\.\d+)+)'
Winget = 'Kitware.CMake'
Choco = 'cmake'
Scoop = 'cmake'
Note = ''
}
'python' = @{
Name = 'Python'
Cmd = 'python --version'
Regex = '(\d+(?:\.\d+)+)'
Winget = 'Python.Python.3.12'
Choco = 'python'
Scoop = 'python'
Note = 'PATH 里的 python 可能是 Microsoft Store 的占位程序,装完要确认指向真 Python'
}
'rust' = @{
Name = 'Rust'
Cmd = 'rustc --version'
Regex = '(\d+(?:\.\d+)+)'
Winget = ''
Choco = ''
Scoop = ''
# Rust **刻意不给包管理器路径**:包管理器装的是它自己那份 rust,
# 会把 rustup 换掉,之后 rustup 的组件管理就乱了。
Direct = 'https://win.rustup.rs/x86_64'
Note = '**用 rustup 装,不要用包管理器** —— 包管理器会把 rustup 自己换掉'
}
'git' = @{
Name = 'Git'
Cmd = 'git --version'
Regex = '(\d+(?:\.\d+)+)'
Winget = 'Git.Git'
Choco = 'git'
Scoop = 'git'
Note = ''
}
'go' = @{
Name = 'Go'
Cmd = 'go version'
Regex = 'go(\d+(?:\.\d+)+)'
Winget = 'GoLang.Go'
Choco = 'golang'
Scoop = 'go'
Note = ''
}
'qt' = @{
Name = 'Qt'
Cmd = 'qmake -v'
Regex = 'Qt version (\d+(?:\.\d+)+)'
Winget = 'QtProject.Qt'
Choco = 'qt'
Scoop = 'qt'
# Qt 的 winget 包只管一个外壳,真正的 Kit 要用 MaintenanceTool 升。
Note = 'winget 装的只是外壳;Kit 版本请用 Qt Creator 的 MaintenanceTool 升级'
}
}
# ===========================================================================
# 包管理器探测
# ===========================================================================
# 找得到 exe 才算装了。
#
# winget 在 Windows 11 上通常**不在 PATH 里**(它是 App Execution Alias),
# 所以要额外看一眼 WindowsApps 下的固定位置。
function Test-Manager {
param([string]$Name)
switch ($Name) {
'winget' {
if (Get-Command winget.exe -ErrorAction SilentlyContinue) { return $true }
$alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\winget.exe'
return (Test-Path $alias)
}
'choco' {
if (Get-Command choco.exe -ErrorAction SilentlyContinue) { return $true }
return (Test-Path (Join-Path $env:ProgramData 'chocolatey\bin\choco.exe'))
}
'scoop' {
if (Get-Command scoop.cmd -ErrorAction SilentlyContinue) { return $true }
if (Get-Command scoop.ps1 -ErrorAction SilentlyContinue) { return $true }
return $false
}
default { return $false }
}
}
function Get-Managers {
$out = @()
foreach ($m in 'winget', 'choco', 'scoop') {
$ok = $false
try { $ok = Test-Manager -Name $m } catch { $ok = $false }
$out += [pscustomobject]@{ name = $m; available = $ok }
}
return $out
}
# auto = 按这个顺序挑第一个可用的。
# winget 优先:它是 Windows 自带的,不需要用户先装东西。
function Resolve-Manager {
param([string]$Want)
$avail = @(Get-Managers | Where-Object { $_.available } | ForEach-Object { $_.name })
if ($Want -ne 'auto') {
if ($avail -contains $Want) { return $Want }
return $null
}
foreach ($m in 'winget', 'choco', 'scoop') {
if ($avail -contains $m) { return $m }
}
return $null
}
# ===========================================================================
# 版本探测
# ===========================================================================
# 两段式探测:
# * exe 在不在 -> installed(装了就是装了)
# * 版本读不读得出来 -> version(读不出来是 null,**不等于没装**)
#
# 这个区分是必要的:`dotnet` 有运行时没 SDK 时,exe 在、版本读不出来。
# 合成一个布尔值的话,那种机器会被报成"没装 .NET",而用户去装一个新的
# 也不会解决 SDK 缺失的问题。
function Test-ExePresent {
param([hashtable]$Spec)
if (-not $Spec.Cmd) { return $false }
$exe = ($Spec.Cmd -split ' ')[0]
return [bool](Get-Command $exe -ErrorAction SilentlyContinue)
}
function Get-InstalledVersion {
param([hashtable]$Spec)
if (-not $Spec.Cmd) { return $null }
$exe = ($Spec.Cmd -split ' ')[0]
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { return $null }
try {
# 合并 stderr:java -version 打在 stderr 上,只抓 stdout 会什么都拿不到
$raw = (& cmd /c "$($Spec.Cmd) 2>&1" | Out-String)
}
catch { return $null }
if ($Spec.Regex) {
$m = [regex]::Match($raw, $Spec.Regex)
if ($m.Success) { return $m.Groups[1].Value }
}
return $null
}
# ===========================================================================
# 命令构造
# ===========================================================================
function Get-Commands {
param([string]$Key, [hashtable]$Spec, [string]$Mgr, [string]$Verb)
$pkg = switch ($Mgr) {
'winget' { $Spec.Winget }
'choco' { $Spec.Choco }
'scoop' { $Spec.Scoop }
default { '' }
}
if (-not $pkg) { return @() }
switch ("$Mgr/$Verb") {
'winget/install' { return @("winget install --id $pkg -e --accept-source-agreements --accept-package-agreements") }
'winget/update' { return @("winget upgrade --id $pkg -e --accept-source-agreements") }
'winget/uninstall' { return @("winget uninstall --id $pkg -e") }
'choco/install' { return @("choco install $pkg -y") }
'choco/update' { return @("choco upgrade $pkg -y") }
'choco/uninstall' { return @("choco uninstall $pkg -y") }
'scoop/install' { return @("scoop install $pkg") }
'scoop/update' { return @("scoop update $pkg") }
'scoop/uninstall' { return @("scoop uninstall $pkg") }
default { return @() }
}
}
# ===========================================================================
# 动作
# ===========================================================================
function Invoke-Action {
param([string]$Key, [hashtable]$Spec, [string]$Verb, [string]$WantMgr, [bool]$JsonOut, [bool]$NoRun)
$mgr = Resolve-Manager -Want $WantMgr
$result = [ordered]@{
id = $Key
name = $Spec.Name
action = $Verb
manager = $mgr
note = $Spec.Note
ok = $false
message = ''
plan = ""
}
# **「一个管理器都没有」和「你要的那个没有」是两回事**,不能合用一句
# 报错 —— 后者说成前者会把用户引向完全错误的方向(他机器上明明有 winget)。
if (-not $mgr) {
$avail = @(Get-Managers | Where-Object { $_.available } | ForEach-Object { $_.name })
if ($WantMgr -ne 'auto') {
$result.message = "本机没有 $WantMgr(可用的:$($avail -join ' / '))"
}
else {
$result.message = '本机没有 winget / choco / scoop'
}
# 有直链兜底就给出直链 —— 这条路径不该因为"没管理器"就断掉
if ($Spec.Direct) {
$result.manager = 'direct'
$result.plan = "下载 $($Spec.Direct) 并安装"
$result.message += ";可手动下载:$($Spec.Direct)"
}
else {
$result.message += ',而该组件没有直链兜底 —— 请手动安装'
}
if (-not $JsonOut) { Write-Host $result.message -ForegroundColor Yellow }
return $result
}
$cmds = Get-Commands -Key $Key -Spec $Spec -Mgr $mgr -Verb $Verb
if (-not $cmds -or $cmds.Count -eq 0) {
# **这个管理器没有那个包 ≠ 这个组件装不了。**
# 有直链兜底就走直链(rust 就是这种:刻意不用包管理器,只能走 rustup)。
$result.manager = $mgr
if ($Spec.Direct) {
$result.manager = 'direct'
$result.plan = "下载 $($Spec.Direct) 并安装"
$result.message = "$mgr 没有 $($Spec.Name);请手动下载:$($Spec.Direct)"
}
else {
$result.plan = ''
$result.message = "$mgr 没有 $($Spec.Name) 这个包"
}
if (-not $JsonOut) { Write-Host $result.message -ForegroundColor Yellow }
return $result
}
# plan 用**单个字符串**而不是数组:PS 5.1 的 ConvertTo-Json 会把单元素数组
# 压成标量,于是调用方要同时处理"数组"和"字符串"两种形状 —— 那正是容易写漏的接口。
$result.plan = ($cmds -join ' ; ')
if ($NoRun) {
$result.ok = $true
$result.message = "(演练)将执行:$($result.plan)"
if (-not $JsonOut) { Write-Host $result.message -ForegroundColor Cyan }
return $result
}
foreach ($c in $cmds) {
if (-not $JsonOut) { Write-Host " > $c" -ForegroundColor DarkGray }
try {
& cmd /c $c
if ($LASTEXITCODE -eq 0) {
$result.ok = $true
$result.message = "$mgr 执行成功"
}
else {
$result.ok = $false
$result.message = "$mgr 返回 $LASTEXITCODE"
}
}
catch {
$result.ok = $false
$result.message = $_.Exception.Message
}
}
if (-not $JsonOut) {
$color = if ($result.ok) { 'Green' } else { 'Red' }
Write-Host " $($result.message)" -ForegroundColor $color
}
return $result
}
# ===========================================================================
# 主流程
# ===========================================================================
switch ($Action) {
'managers' {
$r = @(Get-Managers)
if ($Json) { $r | ConvertTo-Json -Compress }
else { $r | Format-Table -AutoSize | Out-String | Write-Host }
}
'list' {
$r = @()
foreach ($k in $Components.Keys) {
$r += [pscustomobject]@{
id = $k
name = $Components[$k].Name
note = $Components[$k].Note
}
}
if ($Json) { $r | ConvertTo-Json -Compress }
else { $r | Format-Table -AutoSize | Out-String | Write-Host }
}
'check' {
$keys = if ($Id) { @($Id) } else { @($Components.Keys) }
$r = @()
foreach ($k in $keys) {
if (-not $Components.Contains($k)) {
$r += [pscustomobject]@{ id = $k; name = $k; installed = $false; version = $null; error = 'unknown id' }
continue
}
$spec = $Components[$k]
$present = Test-ExePresent -Spec $spec
$v = if ($present) { Get-InstalledVersion -Spec $spec } else { $null }
$r += [pscustomobject]@{
id = $k
name = $spec.Name
installed = $present
version = $v
note = $spec.Note
}
}
if ($Json) {
if ($Id) { $r[0] | ConvertTo-Json -Compress } else { $r | ConvertTo-Json -Compress }
}
else { $r | Format-Table -AutoSize | Out-String | Write-Host }
}
default {
# install / update / uninstall
if (-not $Id) { throw "-Action $Action 需要 -Id" }
if (-not $Components.Contains($Id)) {
# 不抛栈:调用方(程序)要的是能解析的结果,不是一段 PowerShell 错误文本
$e = [ordered]@{ id = $Id; action = $Action; ok = $false; manager = $null; plan = ''; message = "不认识的组件:$Id(用 -Action list 看清单)" }
if ($Json) { $e | ConvertTo-Json -Compress } else { Write-Host $e.message -ForegroundColor Red }
return
}
$spec = $Components[$Id]
$r = Invoke-Action -Key $Id -Spec $spec -Verb $Action -WantMgr $Manager -JsonOut ([bool]$Json) -NoRun ([bool]$DryRun)
if ($Json) { $r | ConvertTo-Json -Compress }
}
}