跳转至

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 }
    }
}