AIGC标识 DeepSeek Harness 一键后台启停:关掉终端也能继续跑

DeepSeek Harness 一键启动脚本

最近 DeepSeek Harness(dsh)热度不低,官方给的启动方式很简单:

npx @deepseek-ai/dsh web

dsh界面

命令本身没毛病,但它必须前台运行:终端窗口一关,dsh 就跟着停了。想让 dsh 像服务一样常驻后台,官方没给现成方案,于是就有了下面这套 PowerShell 一键脚本。配好之后,在任意目录敲 dsh start、dsh stop、dsh restart、dsh status 就能管理它,浏览器访问 http://127.0.0.1:3080 直接进 Web UI。

一、解决了什么问题

痛点 脚本的处理方式
前台运行,关终端就停 用 Start-Process 后台拉起,-WindowStyle Hidden 隐藏窗口,彻底脱离终端
不知道是否已在运行、PID 是多少 start / status 自动检测,PID 记录在独立文件,随时可查
停止时要自己翻进程列表 stop 优先按记录的 PID 精确停止;记录丢了还能按 3080 端口反查兜底
命令难记 封装成 dsh start / dsh stop / dsh restart / dsh status / dsh 五个短命令

二、环境要求

不需要额外装什么,下面三样确认能用就行:

工具 检查命令 说明
Node.js node --version 必需,dsh 通过 npx 运行
npm npm --version 随 Node.js 一起安装
PowerShell 打开即用 Windows 自带(脚本针对 Windows PowerShell 5.1 编写)

注意:不用单独装 pnpm。这套方案走 npx 路线,绕开了源码构建对 pnpm 的依赖。

三、配置步骤

步骤 1:创建辅助目录

在用户主目录下建一个放脚本的目录。别用 ~/.dsh,那个目录 dsh 项目自己要用(存 credentials、profiles 等数据):

New-Item -ItemType Directory -Force "$HOME\.dsh-launcher"

步骤 2:创建主脚本 dsh.ps1

在 C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1 创建脚本文件,完整源码见文末附录 A。

这里有个最大的坑:脚本必须保存为 UTF-8 with BOM 编码,否则 Windows PowerShell 5.1 会把中文按 GBK 误读,报一堆 UnexpectedToken 语法错误。用 VS Code 的话,右下角编码选「Save with Encoding」→「UTF-8 with BOM」即可。

如果文件已经存成了无 BOM 的 UTF-8,可以用下面命令就地转换(路径按需替换):

$c = Get-Content "$HOME\.dsh-launcher\dsh.ps1" -Raw -Encoding UTF8
[System.IO.File]::WriteAllText("$HOME\.dsh-launcher\dsh.ps1", $c, (New-Object System.Text.UTF8Encoding $true))

步骤 3:把 dsh 命令注册进 Profile

打开用户级 PowerShell Profile:

notepad $PROFILE.CurrentUserAllHosts

路径通常是 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1,不存在会提示新建。在文件末尾追加下面内容(原有内容比如 conda 初始化块,保留别动):

# dsh launcher - DeepSeek Harness 后台管理
function dsh {
    & (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}

保存后新开一个 PowerShell 窗口(或者先执行 . $PROFILE),dsh 命令就生效了。

四、使用方法

任意目录打开 PowerShell:

命令 作用 典型输出
dsh start 后台启动 dsh(已在运行则提示,不重复启动) dsh 已启动 (PID 28072),访问 http://127.0.0.1:3080
dsh stop 停止 dsh(优先 PID 记录,否则按端口反查) 正在停止 dsh (通过PID 记录找到 PID 28072) ...
dsh restart 重启 dsh(先停止再启动,未运行则直接启动) 正在重启 dsh ... → dsh 已停止。 → dsh 已启动 (PID 28072),访问 http://127.0.0.1:3080
dsh status 查看运行状态 dsh 正在运行。 PID : 28072 (来自 PID 记录)
dsh 显示帮助 列出所有用法

下面是我本机实际跑一遍的效果(依次执行 start → status → stop):

dsh 一键脚本运行效果

下面是 v2.2 版本后的运行效果图:

2.2版本运行效果

五、脚本在干什么

脚本在 ~\.dsh-launcher 下维护几个辅助文件:

文件 作用
dsh.ps1 主脚本
dsh.pid 记录当前 dsh 进程的 PID(start 写入,stop 删除,restart 先删后写)
dsh.log 标准输出日志
dsh.err.log 标准错误日志

四个核心逻辑:

  1. 怎么判断「是不是 dsh 进程」:npx @deepseek-ai/dsh web 最终是由一个 node.exe 跑的。脚本通过 Win32_Process 拿到进程命令行,同时满足「进程名是 node.exe」+「命令行含 dsh」才认定是 dsh,避免误杀其它 node 程序。
  2. start 幂等:先查 PID 文件,失效就清理;再按 3080 端口兜底查一次,已经在跑就直接提示;确认没跑才拉起,然后轮询端口(最多 30 秒)拿到真实 PID 写入文件。
  3. stop 兜底:有有效 PID 记录就按记录杀;没有(比如 dsh 是别人起的、或旧版本脚本起的)就按 3080 端口反查,找到一样能停;都找不到才提示「未运行」。
  4. restart = stop + start:先按 stop 的兜底逻辑查杀(没在运行也只提示不报错),等 1 秒让端口释放,再按 start 的幂等逻辑拉起。改完配置想快速生效,一条 dsh restart 搞定。

六、踩过的坑

都是实际配的过程中踩出来又解决的,换电脑重配时注意:

  1. 编码必须 UTF-8 with BOM:最大的坑。无 BOM 的中文脚本在 PowerShell 5.1 下必报 UnexpectedToken,存文件时务必带 BOM。
  2. $Pid 是 PowerShell 只读自动变量:它永远等于当前 PowerShell 进程的 PID,不能当普通变量或参数名用。脚本里函数参数改叫 $TargetPid 来规避(早前因此报过 VariableNotWritable)。
  3. Start-Process 的 stdout/stderr 不能重定向到同一个文件:会报 InvalidOperationException,所以拆成了 dsh.log 和 dsh.err.log 两个文件。
  4. 改完 profile 要新开窗口:已经打开的 PowerShell 窗口不会自动加载新 Profile。
  5. 改过脚本记得重新存 BOM:有些编辑器编辑完会把 BOM 丢掉,中文解析错误就回来了。改完重跑上面那条转换命令最保险。
  6. 别占 ~/.dsh 目录:那是 dsh 项目自己的数据目录,这套启动器用的是独立的 ~/.dsh-launcher。

附录 A:主脚本 dsh.ps1(完整源码)

文件路径:C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1

点击查看代码
# dsh launcher - DeepSeek Harness (dsh) 后台管理
# 版本: v2.1 (2026-08-22)
# 相比 v2.0 的改进:
#   * 修复 npm 11.x 的 peer 依赖解析死循环 bug(npx / npm install 在下载阶段
#     永久卡死,对 2026-08-20 之后发布的 dsh 依赖树必然复现)
#   * 下载/升级统一改用 pnpm(解析器独立,实测正常;无 pnpm 时自动经 npx 拉取)
#   * 启动一律走缓存离线运行,不再使用 npx 在线启动
# 相比 dsh.ps1 的改进:
#   * 不再盲目用 `npx -y @deepseek-ai/dsh`(会去在线解析/下载,网络卡或缓存锁损坏时启动失败)
#   * 默认改为直接运行 npx 缓存里已存在的旧版 bin.js,全程离线、秒起
#   * 启动前先检查网络是否有更新版,有则询问是否更新,避免悄悄装上不兼容的新版
#   * 新增 `dsh update`: 手动检查更新,确认后预下载最新版到 npx 缓存
#
# 用法: dsh start | stop | restart | status | update
#       在 PowerShell profile 中定义函数后,任意目录下均可直接调用:
#       function dsh { & (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args }
#
# 前提条件(换新机器使用前请确认):
#   1. 已安装 Node.js LTS(含 npm),且 node / npm 均在 PATH 中
#   2. PowerShell 执行策略为 Restricted 时,profile 与脚本都无法加载,
#      请先执行: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

$ErrorActionPreference = 'Stop'

$LauncherDir = Join-Path $HOME '.dsh-launcher'
$PidFile     = Join-Path $LauncherDir 'dsh.pid'
$LogFile     = Join-Path $LauncherDir 'dsh.log'
$ErrLogFile  = Join-Path $LauncherDir 'dsh.err.log'
$Port        = 3080
$WebUrl      = "http://127.0.0.1:$Port"

# 指定 dsh 包名(registry 与 npx 缓存里均以此为名)
$PkgName = '@deepseek-ai/dsh'
try { $NodeCacheRoot = Join-Path (npm config get cache) '_npx' } catch { $NodeCacheRoot = $null }

# 超时(毫秒): 版本检查 8 秒;更新下载 3 分钟(可按网络情况调整)
$RegistryTimeoutMs = 8000
$DownloadTimeoutMs = 180000

# 确保辅助目录存在(首次运行 / 换机器时)
if (-not (Test-Path $LauncherDir)) {
    New-Item -ItemType Directory -Path $LauncherDir -Force | Out-Null
}

# ---------------------------------------------------------------------------
# semver 解析: 返回 [Major, Minor, Patch, Pre] 结构; Pre 为 $null 表示正式版; 解析失败返回 $null
# ---------------------------------------------------------------------------
function ConvertTo-SemVerParts([string]$v) {
    $m = [regex]::Match($v, '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$')
    if (-not $m.Success) { return $null }
    return [PSCustomObject]@{
        Major = [int]$m.Groups[1].Value
        Minor = [int]$m.Groups[2].Value
        Patch = [int]$m.Groups[3].Value
        Pre   = if ($m.Groups[4].Success) { $m.Groups[4].Value } else { $null }
    }
}

# ---------------------------------------------------------------------------
# 判断 a 是否"严格新于" b(按 npm semver 规则,正式版 > 同版本预发布版,如 0.1.1 > 0.1.1-rc.2)
# 原实现用字符串比较会把 rc 版误判为比正式版新,这里改为真正的 semver 比较。
# ---------------------------------------------------------------------------
function Test-IsNewer([string]$a, [string]$b) {
    if (-not $a -or -not $b) { return $false }
    $pa = ConvertTo-SemVerParts $a
    $pb = ConvertTo-SemVerParts $b
    if (-not $pa -or -not $pb) { return $false }   # 无法解析的版本一律视为"不更新"

    foreach ($k in 'Major','Minor','Patch') {
        if ($pa.$k -gt $pb.$k) { return $true }
        if ($pa.$k -lt $pb.$k) { return $false }
    }

    # 主版本相同: 无预发布标识(正式版) > 有预发布标识(rc/beta 等)
    $aPre = $pa.Pre; $bPre = $pb.Pre
    if (-not $aPre -and -not $bPre) { return $false }  # 完全相同
    if (-not $aPre) { return $true }                    # a 正式版、b 预发布 -> a 新
    if (-not $bPre) { return $false }                   # a 预发布、b 正式版 -> a 旧

    # 同为预发布: 按段比较(数字段按数值、字母段按字典序;数字段 < 字母段;段数多者新)
    $aseg = $aPre.Split('.')
    $bseg = $bPre.Split('.')
    $n = [Math]::Min($aseg.Count, $bseg.Count)
    for ($i = 0; $i -lt $n; $i++) {
        $xn = 0; $yn = 0
        $xIsNum = [int]::TryParse($aseg[$i], [ref]$xn)
        $yIsNum = [int]::TryParse($bseg[$i], [ref]$yn)
        if ($xIsNum -and $yIsNum) {
            if ($xn -ne $yn) { return $xn -gt $yn }
        } elseif ($xIsNum) {
            return $false      # 数字段 < 字母段(semver 规则)
        } elseif ($yIsNum) {
            return $true
        } else {
            $c = [string]::CompareOrdinal($aseg[$i], $bseg[$i])
            if ($c -ne 0) { return $c -gt 0 }
        }
    }
    return $aseg.Count -gt $bseg.Count   # 段数多者新
}

# ---------------------------------------------------------------------------
# 查找缓存里已经存在的 dsh 可执行文件(lib/bin.js)。
# 遍历 npx 缓存目录 <hash>/node_modules/@deepseek-ai/dsh/package.json,
# 取版本号最高且 bin 真实存在的那个,返回 PSCustomObject[Path,Version];找不到返回 $null。
# 这是"用当前缓存旧版启动"的核心——不联网、不触碰 registry。
# ---------------------------------------------------------------------------
function Find-CachedDshBin {
    if (-not $NodeCacheRoot -or -not (Test-Path $NodeCacheRoot)) { return $null }

    $best = $null
    Get-ChildItem -Path $NodeCacheRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
        $pkgJson = Join-Path $_.FullName "node_modules\$PkgName\package.json"
        if (-not (Test-Path $pkgJson)) { return }
        try {
            $pkg = Get-Content $pkgJson -Raw | ConvertFrom-Json
            # bin 可能是字符串(单 bin 包)或对象 {dsh: path}
            $binRel = $null
            if ($pkg.bin -is [string]) { $binRel = $pkg.bin }
            elseif ($pkg.bin) { $binRel = $pkg.bin.dsh }
            if (-not $binRel) { return }
            $binPath = Join-Path $_.FullName "node_modules\$PkgName\$binRel"
            # 必须是真实文件(PathType Leaf 防止误把目录当 bin)
            if ((Test-Path $binPath -PathType Leaf) -and (-not $best -or (Test-IsNewer $pkg.version $best.Version))) {
                $best = [PSCustomObject]@{ Path = $binPath; Version = $pkg.version }
            }
        } catch { }
    }
    return $best
}

# ---------------------------------------------------------------------------
# 短超时地获取 registry 上的最新版本号。返回 string;无法确定(网络不通/超时/解析失败)返回 $null。
# 用 cmd.exe /c 包一层,避免直接 Start-Process *.cmd 在部分环境下的兼容问题;
# 超时后用 taskkill /T 连子进程(node)一起终止,避免残留。
# ---------------------------------------------------------------------------
function Get-LatestVersionCore {
    $tmpOut = Join-Path $env:TEMP ("dshview_" + [guid]::NewGuid().ToString('N') + ".out")
    $tmpErr = Join-Path $env:TEMP ("dshview_" + [guid]::NewGuid().ToString('N') + ".err")
    $p = Start-Process -FilePath $env:ComSpec `
        -ArgumentList @('/c', "npm view $PkgName version") `
        -NoNewWindow `
        -RedirectStandardOutput $tmpOut `
        -RedirectStandardError $tmpErr `
        -PassThru
    if (-not $p.WaitForExit($RegistryTimeoutMs)) {
        taskkill /PID $p.Id /T /F 2>$null | Out-Null   # 连子进程一起终止
        Remove-Item $tmpOut,$tmpErr -Force -ErrorAction SilentlyContinue
        return $null
    }
    $v = ''
    if (Test-Path $tmpOut) { $v = (Get-Content $tmpOut -Raw -ErrorAction SilentlyContinue).Trim() }
    Remove-Item $tmpOut,$tmpErr -Force -ErrorAction SilentlyContinue
    if ($v -match '^\d+\.\d+\.\d+') { return $v }
    return $null
}

# ---------------------------------------------------------------------------
# 【核心】启动逻辑:
#   0) 若已在运行 -> 提示并返回
#   1) 查找缓存里的 dsh bin
#   2) 网络检查最新版;有更新则询问是否升级
#      - 用户选 Y -> 用 npx 在线安装最新版启动(提示兼容风险)
#      - 用户选 N / 回车 / 无法确定 -> 用缓存旧版启动(离线)
#   3) 找不到任何缓存 bin 时,回退到 npx 在线启动
# ---------------------------------------------------------------------------
function Start-Dsh {
    # 0. 已在运行检查(复用与 dsh.ps1 相同的 PID 记录与端口反查逻辑)
    $recorded = Get-RecordedPid
    if ($recorded -and (Test-IsDshProcess $recorded)) {
        Write-Host "dsh 已在运行 (PID $recorded),访问 $WebUrl" -ForegroundColor Yellow
        return
    }
    $portPid = Get-DshPidByPort
    if ($portPid -and (Test-IsDshProcess $portPid)) {
        Write-Host "dsh 已在运行 (PID $portPid),访问 $WebUrl" -ForegroundColor Yellow
        return
    }

    # 1. 查找缓存里的旧版
    $cached = Find-CachedDshBin
    if ($cached) {
        Write-Host "在 npx 缓存中找到 dsh 旧版: $($cached.Version)" -ForegroundColor Cyan
    } else {
        Write-Host "未在本机 npx 缓存中找到 dsh,将尝试在线安装最新版启动。" -ForegroundColor Yellow
    }

    # 2. 网络检查 + 询问是否升级(升级/下载统一走 pnpm -> 缓存 -> 离线启动,
    #    不再使用 npx:npm 11.x 对 dsh 依赖树的 peer 依赖解析存在死循环 bug)
    if ($cached) {
        Write-Host "正在检查是否有新版本 ..." -ForegroundColor Cyan
        $latest = Get-LatestVersionCore
        if ($latest) {
            Write-Host "registry 最新版: $latest | 本地缓存版: $($cached.Version)" -ForegroundColor Cyan
            if (Test-IsNewer $latest $cached.Version) {
                $ans = Read-Host "检测到新版本 $latest。是否升级?`n(Y=在线升级并启动 / N 或回车=继续用缓存旧版启动)"
                if ($ans -match '^[Yy]$') {
                    Write-Host "选择升级到 $latest。注意:新版本可能存在数据兼容性问题,请自行评估。" -ForegroundColor Yellow
                    if (Install-DshToCache $latest) {
                        $cached = Find-CachedDshBin
                    } else {
                        Write-Host "升级下载失败,继续使用缓存旧版启动。" -ForegroundColor Yellow
                    }
                } else {
                    Write-Host "保持使用缓存旧版 $($cached.Version)。" -ForegroundColor Green
                }
            } else {
                Write-Host "当前缓存已是最新版本,直接使用缓存启动。" -ForegroundColor Green
            }
        } else {
            Write-Host "(无法连通 registry 确认版本,跳过检查) 直接使用缓存旧版启动。" -ForegroundColor DarkYellow
        }
    } else {
        # 无缓存 -> 尝试下载最新版后启动
        $latest = Get-LatestVersionCore
    }

    # 3. 启动(始终从缓存启动:先按需下载到缓存,再离线运行 bin.js)
    if (-not $cached) {
        if (-not $latest) {
            Write-Host "未找到任何缓存版本且无法获取最新版本,请检查网络后重试 dsh update。" -ForegroundColor Red
            return
        }
        if (-not (Install-DshToCache $latest)) {
            Write-Host "下载失败,无法启动。请检查网络后重试 dsh update。" -ForegroundColor Red
            return
        }
        $cached = Find-CachedDshBin
        if (-not $cached) {
            Write-Host "下载完成但未在缓存中找到 dsh,请手动检查。" -ForegroundColor Red
            return
        }
    }
    $nodeExe = (Get-Command node.exe -ErrorAction SilentlyContinue).Source
    if (-not $nodeExe) {
        Write-Host "未找到 node.exe,请确认已安装 Node.js 且已加入 PATH。" -ForegroundColor Red
        return
    }
    if ($PSVersionTable.PSVersion.Major -ge 7) {
        $argList = @($cached.Path, 'web')            # PS7: 按元素自动转义,勿手动加引号
    } else {
        $argList = @('"' + $cached.Path + '"', 'web') # PS5.1: 手动加引号以支持含空格的路径
    }
    $proc = Start-Process -FilePath $nodeExe `
        -ArgumentList $argList `
        -WorkingDirectory $HOME `
        -WindowStyle Hidden `
        -RedirectStandardOutput $LogFile `
        -RedirectStandardError $ErrLogFile `
        -PassThru

    # 等端口起来
    $pidFound = $null
    for ($i = 0; $i -lt 30; $i++) {
        Start-Sleep -Seconds 1
        $pidFound = Get-DshPidByPort
        if ($pidFound -and (Test-IsDshProcess $pidFound)) { break }
    }

    if ($pidFound) {
        $pidFound | Out-File -FilePath $PidFile -Encoding ascii
        Write-Host "dsh 已启动 (PID $pidFound),访问 $WebUrl" -ForegroundColor Green
    } else {
        Write-Host "dsh 启动可能失败,请查看日志: $ErrLogFile" -ForegroundColor Red
        if (Test-Path $ErrLogFile) { Get-Content $ErrLogFile -Tail 20 }
    }
}

# ---------------------------------------------------------------------------
# dsh update: 手动检查 registry 最新版,确认后预下载到 npx 缓存(不启动服务)。
# 下载通过 pnpm 完成(npm 11.x 对 dsh 依赖树的 peer 依赖解析存在死循环 bug,
# 会导致 npx / npm install 在下载阶段永久卡死;pnpm 解析器独立,实测正常)。
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# 用 pnpm 把指定版本的 dsh 安装到 npx 缓存目录($NodeCacheRoot\manual-<version>,
# 注:$NodeCacheRoot 已含 _npx,即 <cache>\_npx\manual-<version>),
# 目录布局与 npx 安装一致,Find-CachedDshBin 可直接发现。
# 本机无 pnpm 时回退到 npx 临时拉取 pnpm(pnpm 自身依赖树很小,不会触发该 bug)。
# 返回 $true/$false 表示安装是否成功。
# ---------------------------------------------------------------------------
function Install-DshToCache([string]$version) {
    $targetDir = Join-Path $NodeCacheRoot "manual-$version"
    $storeDir  = Join-Path $LauncherDir 'pnpm-store'

    if (Test-Path (Join-Path $targetDir "node_modules\$PkgName\package.json")) {
        Write-Host "缓存中已存在 $version,跳过下载。" -ForegroundColor Green
        return $true
    }

    New-Item -ItemType Directory -Path $targetDir -Force | Out-Null

    if (Get-Command pnpm -ErrorAction SilentlyContinue) {
        $pnpmRunner = "pnpm"
    } else {
        Write-Host "未找到 pnpm,将通过 npx 临时拉取 pnpm(pnpm 依赖树小,不会触发 npm 的解析问题)..." -ForegroundColor DarkYellow
        $pnpmRunner = "npx -y pnpm@latest"
    }
    # 命令末尾追加 echo ERRORLEVEL,从输出流解析退出码:
    # PS 5.1 部分环境下 Start-Process 的 ExitCode 可能读取不到,此写法跨版本可靠
    $installCmd = "$pnpmRunner add --dir `"$targetDir`" --store-dir `"$storeDir`" `"${PkgName}@${version}`" & echo DSHUPD_EXIT=%ERRORLEVEL%"

    Write-Host "正在下载并安装 $version(pnpm,视网速可能需要几分钟)..." -ForegroundColor Cyan
    $dlOut = Join-Path $env:TEMP ("dshupd_" + [guid]::NewGuid().ToString('N') + ".out")
    $dlErr = Join-Path $env:TEMP ("dshupd_" + [guid]::NewGuid().ToString('N') + ".err")
    $p = Start-Process -FilePath $env:ComSpec `
        -ArgumentList @('/c', $installCmd) `
        -NoNewWindow `
        -RedirectStandardOutput $dlOut `
        -RedirectStandardError $dlErr `
        -PassThru
    if (-not $p.WaitForExit($DownloadTimeoutMs)) {
        taskkill /PID $p.Id /T /F 2>$null | Out-Null
        Remove-Item $dlOut,$dlErr -Force -ErrorAction SilentlyContinue
        Write-Host "下载超时(已等待 3 分钟),已终止。请检查网络后重试。" -ForegroundColor Red
        return $false
    }
    $p.WaitForExit()   # 等重定向输出完全写入文件
    $rawOut = ''
    if (Test-Path $dlOut) {
        $rawOut = Get-Content $dlOut -Raw -ErrorAction SilentlyContinue
        if (-not $rawOut) { $rawOut = '' }
    }
    $exitMatch = [regex]::Match($rawOut, 'DSHUPD_EXIT=(\d+)')
    $exitCode = if ($exitMatch.Success) { [int]$exitMatch.Groups[1].Value } else { -1 }
    $errText = ''
    if (Test-Path $dlErr) {
        $raw = Get-Content $dlErr -Raw -ErrorAction SilentlyContinue
        if ($raw) { $errText = $raw.Trim() }
    }
    Remove-Item $dlOut,$dlErr -Force -ErrorAction SilentlyContinue
    $installedOk = $exitCode -eq 0 -and (Test-Path (Join-Path $targetDir "node_modules\$PkgName\package.json"))
    if (-not $installedOk) {
        Write-Host "pnpm 安装失败(退出码: $exitCode)。" -ForegroundColor Red
        if ($errText) { Write-Host $errText }
        return $false
    }
    Write-Host "安装完成。" -ForegroundColor Green
    return $true
}
function Update-Dsh {
    # 若 dsh 正在运行,先提示更新生效时机
    $recorded = Get-RecordedPid
    if ($recorded -and (Test-IsDshProcess $recorded)) {
        Write-Host "提示: dsh 正在运行 (PID $recorded)。本次更新只写入缓存,执行 'dsh restart' 后新版本才会生效。" -ForegroundColor Yellow
    }

    # 1. 获取 registry 最新版
    $latest = Get-LatestVersionCore
    if (-not $latest) {
        Write-Host "无法获取最新版本(网络不通或 registry 超时),请稍后重试。" -ForegroundColor Red
        return
    }

    # 2. 查找本地缓存版本
    $cached = Find-CachedDshBin
    $current = if ($cached) { $cached.Version } else { $null }

    Write-Host "当前缓存版本: $(if ($current) { $current } else { '未安装' })" -ForegroundColor Cyan
    Write-Host "registry 最新 : $latest" -ForegroundColor Cyan

    # 3. 判断是否有更新
    if ($current -and -not (Test-IsNewer $latest $current)) {
        Write-Host "当前已是最新版本,无需更新。" -ForegroundColor Green
        return
    }

    # 4. 询问用户
    $msg = if ($current) { "检测到新版本 $latest(当前 $current)。是否更新?" } else { "检测到最新版本 $latest,是否安装?" }
    $ans = Read-Host "$msg (Y=更新 / N=取消)"
    if ($ans -notmatch '^[Yy]$') {
        Write-Host "已取消更新。" -ForegroundColor Yellow
        return
    }

    # 5. 下载到 npx 缓存(用 pnpm 绕过 npm 的 peer 解析死循环 bug;带超时)
    if (-not (Install-DshToCache $latest)) {
        Write-Host "可手动重试: pnpm add --dir `"$(Join-Path $NodeCacheRoot "manual-$latest")`" ${PkgName}@${latest}" -ForegroundColor Yellow
        return
    }

    # 6. 验证:重新查找缓存,确认新版本已就位
    $newCached = Find-CachedDshBin
    if ($newCached -and ($null -eq $current -or (Test-IsNewer $newCached.Version $current))) {
        Write-Host "更新完成,当前缓存版本: $($newCached.Version)" -ForegroundColor Green
        Write-Host "执行 'dsh start' 即可用新版本启动。" -ForegroundColor Cyan
    } else {
        Write-Host "更新未生效,请检查上面的错误信息。" -ForegroundColor Red
    }
}

# ---------------------------------------------------------------------------
# stop / restart / status / help
# ---------------------------------------------------------------------------
function Get-DshPidByPort {
    $conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
            Sort-Object -Property OwningProcess -Unique
    if ($conn) { return $conn[0].OwningProcess }
    return $null
}

function Test-IsDshProcess([int]$TargetPid) {
    try {
        $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction Stop
        if (-not $proc) { return $false }
        $name = $proc.Name
        $cmdline = $proc.CommandLine
        # 精确匹配 deepseek-ai 包路径,避免误杀其他 node 进程
        return ($name -eq 'node.exe' -and $cmdline -like '*deepseek-ai*dsh*')
    } catch {
        return $false
    }
}

function Get-RecordedPid {
    if (-not (Test-Path $PidFile)) { return $null }
    $raw = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()
    if ($raw -match '^\d+$') { return [int]$raw }
    return $null
}

function Stop-Dsh {
    $recorded = Get-RecordedPid
    $target = $null
    $source = ''

    if ($recorded -and (Test-IsDshProcess $recorded)) {
        $target = $recorded
        $source = 'PID 记录'
    } else {
        $portPid = Get-DshPidByPort
        if ($portPid -and (Test-IsDshProcess $portPid)) {
            $target = $portPid
            $source = '端口反查'
        }
    }

    if (-not $target) {
        if ($recorded) {
            Write-Host "PID 文件记录了 $recorded,但该进程已不存在或不是 dsh,未执行任何操作。" -ForegroundColor Yellow
        } else {
            Write-Host "未找到正在运行的 dsh 进程,无需停止。" -ForegroundColor Yellow
        }
        Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
        return
    }

    Write-Host "正在停止 dsh (通过${source}找到 PID $target) ..." -ForegroundColor Cyan
    Stop-Process -Id $target -Force -ErrorAction SilentlyContinue
    Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
    Write-Host "dsh 已停止。" -ForegroundColor Green
}

function Restart-Dsh {
    Write-Host "正在重启 dsh ..." -ForegroundColor Cyan
    Stop-Dsh
    Start-Sleep -Seconds 1
    Start-Dsh
}

function Show-DshStatus {
    $recorded = Get-RecordedPid

    if ($recorded -and (Test-IsDshProcess $recorded)) {
        Write-Host "dsh 正在运行。" -ForegroundColor Green
        Write-Host "  PID  : $recorded (来自 PID 记录)"
        Write-Host "  地址 : $WebUrl"
        return
    }

    $portPid = Get-DshPidByPort
    if ($portPid -and (Test-IsDshProcess $portPid)) {
        Write-Host "dsh 正在运行。" -ForegroundColor Green
        Write-Host "  PID  : $portPid (来自端口反查,PID 记录缺失/失效)"
        Write-Host "  地址 : $WebUrl"
        return
    }

    Write-Host "dsh 未在运行。" -ForegroundColor Yellow
    if ($recorded) { Write-Host "  (存在失效的 PID 记录: $recorded)" -ForegroundColor DarkYellow }
}

function Show-DshHelp {
    Write-Host @"
dsh launcher - DeepSeek Harness 后台管理  v2.1 (2026-08-22)

用法:
  dsh start    检查更新后,后台启动 dsh Web UI (${WebUrl})
  dsh stop     停止 dsh(优先用 PID 记录,否则按端口反查)
  dsh restart  重启 dsh(先停止,再后台启动)
  dsh status   查看 dsh 运行状态
  dsh update   检查 registry 是否有新版本,确认后预下载到缓存
  dsh          显示本帮助

特点:
  - 优先使用 npx 缓存中的版本直接启动,不联网,秒起
  - 启动前检查 registry 是否有新版,提示是否升级(默认不升级)
  - 新机器使用前提: 已安装 Node.js LTS 且 node/npm 在 PATH

辅助文件目录: ${LauncherDir}
"@
}

# 入口
switch ($args[0]) {
    'start'   { Start-Dsh }
    'stop'    { Stop-Dsh }
    'restart' { Restart-Dsh }
    'status'  { Show-DshStatus }
    'update'  { Update-Dsh }
    $null     { Show-DshHelp }
    default   { Write-Host "未知命令: $($args[0])" -ForegroundColor Red; Show-DshHelp }
}

附录 B:Profile 注入片段

追加到 $PROFILE.CurrentUserAllHosts(即 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1)末尾:

# dsh launcher - DeepSeek Harness 后台管理
function dsh {
    & (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}

文件里如果已经有别的内容(比如 conda 初始化块),保留原样,只在末尾追加。


整套配下来五分钟,之后 dsh 就随开随用了。如果还遇到别的坑,欢迎评论区一起聊。

posted @ 2026-08-14 17:02  H_Elden  阅读(3911)  评论(1)    收藏  举报