# 育见 · BreedLens — 一键安装 / 升级(Windows,PowerShell 5.1+) # # irm https://yj-get.bioagentk.org/install.ps1 | iex # 交互:会提示粘贴接入码 # & ([scriptblock]::Create((irm https://yj-get.bioagentk.org/install.ps1))) -Code blc1.… # 带参数 # .\install.ps1 -Code blc1.… [-Registry https://registry.npmmirror.com] [-DshHome D:\dsh] [-Tarball x.tgz] [-Port 3080] # # 做什么(全部幂等,无需管理员权限,重跑即升级): # 1. Node:系统 Node 满足 ^22.19 || >=24 就用它,否则下载便携版到 %LOCALAPPDATA%\BreedLens\node # 2. pnpm + @deepseek-ai/dsh 装进私有前缀 %LOCALAPPDATA%\BreedLens\npm(不动全局 npm) # 3. 预热 dsh web profile(写 .npmrc 指向镜像) # 4. 用接入码向育见服务取插件包(签名请求,sha256 校验)→ dsh plugin --profile web add # 5. 保存接入码到 dsh 凭据、首次同步、写启动器与开始菜单/桌面快捷方式,然后启动 # 本文件由 installer/build.mjs 从 install.src.ps1 生成(内嵌 launch.ps1 / uninstall.ps1 与版本 pins)。不含任何密钥。 # 注意:不能加 [CmdletBinding()]——`irm | iex` 不接受脚本级属性;失败用 throw 而不是 exit,否则会关掉用户的终端。 param( [string]$Code = $env:BREEDLENS_CODE, [string]$Registry = 'https://registry.npmmirror.com', [string]$DshVersion = '', [string]$NodeVersion = '', [string]$DshHome = '', [string]$Tarball = '', [int]$Port = 3080, [switch]$NoShortcut, [switch]$SkipSync, [switch]$NoLaunch, [switch]$PortableNode ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } # ---- pins (stamped from installer/versions.json by build.mjs) --------------------------------- $Pins = @{ dsh = '0.1.1-rc.2' node = '24.15.0' pnpm = '11.22.0' plugin = '0.6.0' } if ($DshVersion) { $Pins.dsh = $DshVersion } if ($NodeVersion) { $Pins.node = $NodeVersion } $Root = Join-Path $env:LOCALAPPDATA 'BreedLens' $NodeDir = Join-Path $Root 'node' $NpmDir = Join-Path $Root 'npm' $Downloads = Join-Path $Root 'downloads' $Logs = Join-Path $Root 'logs' foreach ($d in @($Root, $Downloads, $Logs)) { New-Item -ItemType Directory -Force -Path $d | Out-Null } $Transcript = Join-Path $Logs ("install-{0:yyyyMMdd-HHmmss}.log" -f (Get-Date)) try { Start-Transcript -Path $Transcript -Append | Out-Null } catch { } function Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } function Info([string]$msg) { Write-Host " $msg" } function Fail([string]$msg) { Write-Host "`n!! $msg" -ForegroundColor Red Write-Host " 日志:$Transcript" try { Stop-Transcript | Out-Null } catch { } # under `irm | iex` an `exit` would close the user's shell; a terminating error stops just the installer throw "育见安装未完成:$msg" } function Get-Download([string[]]$urls, [string]$out) { foreach ($u in $urls) { try { Info "下载 $u"; Invoke-WebRequest -Uri $u -OutFile $out -UseBasicParsing -TimeoutSec 900; return } catch { Info "失败:$($_.Exception.Message)" } } Fail "下载失败:$out" } function Get-Sha256([string]$file) { # not Get-FileHash: it is missing on some locked-down / minimal PowerShell 5.1 installs $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [IO.File]::ReadAllBytes($file); return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLower() } finally { $sha.Dispose() } } function Test-NodeOk([string]$v) { if ($v -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { return $false } $maj = [int]$Matches[1]; $min = [int]$Matches[2] return (($maj -eq 22 -and $min -ge 19) -or ($maj -ge 24)) } # ---- connection code + signed GET (same protocol as the plugin: shared/sign.js) ------------ function ConvertFrom-Base64Url([string]$s) { $b = $s.Replace('-', '+').Replace('_', '/') switch ($b.Length % 4) { 2 { $b += '==' } 3 { $b += '=' } } return [Convert]::FromBase64String($b) } function ConvertTo-Base64Url([byte[]]$bytes) { return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function Read-ConnectionCode([string]$code) { $code = $code.Trim() if (-not $code.StartsWith('blc1.')) { throw '接入码应以 blc1. 开头' } $json = [Text.Encoding]::UTF8.GetString((ConvertFrom-Base64Url $code.Substring(5))) $o = $json | ConvertFrom-Json if (-not $o.u -or -not $o.t) { throw '接入码缺少字段' } if ($o.u -notmatch '^https://' -and $env:PBW_EDGE_ALLOW_HTTP -ne '1') { throw '接入码里的服务地址不是 https' } if ($o.exp -and ([DateTime]::Parse($o.exp).ToUniversalTime() -lt (Get-Date).ToUniversalTime())) { throw "接入码已于 $($o.exp) 过期,请向管理员重新申请" } return $o } $script:ClockOffset = 0 function Invoke-EdgeGet($conn, [string]$pathAndQuery, [string]$OutFile = '', [int]$TimeoutSec = 60, [int]$Attempt = 1) { $secret = $conn.t.Split('.')[2] $ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:ClockOffset $nonceBytes = New-Object byte[] 16 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($nonceBytes) $nonce = ConvertTo-Base64Url $nonceBytes $emptySha = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' $canonical = "BL1`nGET`n$pathAndQuery`n$ts`n$nonce`n$emptySha" $hmac = New-Object System.Security.Cryptography.HMACSHA256 (, [Text.Encoding]::UTF8.GetBytes($secret)) $sig = ConvertTo-Base64Url ($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))) $headers = @{ 'Authorization' = "Bearer $($conn.t)"; 'X-BL-Timestamp' = "$ts"; 'X-BL-Nonce' = $nonce; 'X-BL-Signature' = $sig 'X-BL-Client' = 'breedlens-installer/1'; 'Accept' = $(if ($OutFile) { 'application/octet-stream, */*' } else { 'application/json' }) } if ($conn.a -and $conn.s) { $headers['CF-Access-Client-Id'] = "$($conn.a)"; $headers['CF-Access-Client-Secret'] = "$($conn.s)" } $url = $conn.u.TrimEnd('/') + $pathAndQuery try { if ($OutFile) { Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -OutFile $OutFile -TimeoutSec $TimeoutSec | Out-Null; return $null } $r = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -TimeoutSec $TimeoutSec return ($r.Content | ConvertFrom-Json) } catch { $resp = $_.Exception.Response $status = 0; $serverTime = $null if ($resp) { try { $status = [int]$resp.StatusCode } catch { }; try { $serverTime = $resp.Headers['X-Server-Time'] } catch { } } if ($status -eq 404 -and $serverTime -and $Attempt -eq 1) { # empty 404 = rejected before authentication; the usual cause on a fresh PC is the clock — resign with the server's time once $st = [DateTimeOffset]::Parse($serverTime).ToUnixTimeSeconds() $script:ClockOffset = $st - [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() if ([Math]::Abs($script:ClockOffset) -gt 30) { Info "本机时钟与服务端相差 $($script:ClockOffset) 秒,按服务端时间重签一次"; return Invoke-EdgeGet $conn $pathAndQuery $OutFile $TimeoutSec 2 } } if ($status -eq 404) { throw '育见服务拒绝了接入码(无效 / 已撤销 / 已过期,或本机时钟不准)' } if ($status -eq 0) { throw "无法连接育见服务:$($_.Exception.Message)(网络 / 代理?)" } throw "育见服务返回 $status:$($_.Exception.Message)" } } # ================================================================================================ Write-Host '' Write-Host ' 育见 · BreedLens 安装程序' -ForegroundColor Green Write-Host " 目标目录 $Root" if (-not $Code -and -not $Tarball) { # Under `irm … | iex` the script runs inside a pipeline; in some hosts Read-Host then returns at once with # nothing. Fall back to the raw console, and if that is silent too, tell the user the non-interactive form. try { $Code = Read-Host ' 请粘贴管理员发给你的接入码(blc1.…)' } catch { $Code = '' } if (-not $Code -and [Environment]::UserInteractive) { try { [Console]::Out.Write(' 接入码(blc1.…): '); $Code = [Console]::In.ReadLine() } catch { $Code = '' } } if (-not $Code) { Write-Host '' Write-Host ' 没有读到接入码。请改用不需要交互的写法(把 blc1.… 换成你的接入码):' -ForegroundColor Yellow Write-Host " `$env:BREEDLENS_CODE = 'blc1.…'; irm https://yj-get.bioagentk.org/install.ps1 | iex" Fail '需要接入码(-Code / $env:BREEDLENS_CODE)或本地插件包(-Tarball)' } } $conn = $null if ($Code) { try { $conn = Read-ConnectionCode $Code } catch { Fail "接入码无效:$($_.Exception.Message)" } } # ---- 1. Node ---------------------------------------------------------------------------------- Step 'Node.js' $nodeExe = $null $sys = if ($PortableNode) { $null } else { Get-Command node.exe -ErrorAction SilentlyContinue } if ($sys) { $v = (& $sys.Source --version) 2>$null; if (Test-NodeOk $v) { $nodeExe = $sys.Source; Info "使用系统 Node $v($nodeExe)" } else { Info "系统 Node $v 不满足 ^22.19 || >=24,改用便携版" } } if (-not $nodeExe) { $portable = Join-Path $NodeDir 'node.exe' $have = $null if (Test-Path $portable) { $have = (& $portable --version) 2>$null } if ($have -eq "v$($Pins.node)") { Info "便携版 Node $have 已就绪" } else { $zip = Join-Path $Downloads "node-v$($Pins.node)-win-x64.zip" if (-not (Test-Path $zip)) { Get-Download @("https://registry.npmmirror.com/-/binary/node/v$($Pins.node)/node-v$($Pins.node)-win-x64.zip", "https://nodejs.org/dist/v$($Pins.node)/node-v$($Pins.node)-win-x64.zip") $zip } Info '解压…' $tmp = Join-Path $Downloads 'node-unzip' if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } Expand-Archive -Path $zip -DestinationPath $tmp -Force $inner = Get-ChildItem $tmp -Directory | Select-Object -First 1 if (Test-Path $NodeDir) { Remove-Item -Recurse -Force $NodeDir } Move-Item $inner.FullName $NodeDir Remove-Item -Recurse -Force $tmp Info "便携版 Node $((& $portable --version)) 装到 $NodeDir" } $nodeExe = $portable } $NodeBin = Split-Path -Parent $nodeExe $env:Path = "$NodeBin;$env:Path" # ---- 2. pnpm + dsh in a private prefix -------------------------------------------------------- Step "pnpm $($Pins.pnpm) + DeepSeek Harness (dsh) $($Pins.dsh)" $dshPkg = Join-Path $NpmDir 'node_modules\@deepseek-ai\dsh\package.json' $haveDsh = $null if (Test-Path $dshPkg) { try { $haveDsh = (Get-Content $dshPkg -Raw | ConvertFrom-Json).version } catch { } } if ($haveDsh -eq $Pins.dsh -and (Test-Path (Join-Path $NpmDir 'pnpm.cmd'))) { Info "dsh $haveDsh 已就绪($NpmDir)" } else { New-Item -ItemType Directory -Force -Path $NpmDir | Out-Null $npmCmd = Join-Path $NodeBin 'npm.cmd' if (-not (Test-Path $npmCmd)) { $npmCmd = 'npm' } Info "npm install -g pnpm@$($Pins.pnpm) @deepseek-ai/dsh@$($Pins.dsh) --prefix $NpmDir --registry $Registry(首次约 1–3 分钟)" & $npmCmd install -g "pnpm@$($Pins.pnpm)" "@deepseek-ai/dsh@$($Pins.dsh)" --prefix $NpmDir --registry $Registry --no-audit --no-fund --loglevel error if ($LASTEXITCODE -ne 0) { Info '镜像安装失败,改用官方源重试…' & $npmCmd install -g "pnpm@$($Pins.pnpm)" "@deepseek-ai/dsh@$($Pins.dsh)" --prefix $NpmDir --registry https://registry.npmjs.org --no-audit --no-fund --loglevel error if ($LASTEXITCODE -ne 0) { Fail 'dsh 安装失败' } } } $env:Path = "$NpmDir;$env:Path" $dshCmd = Join-Path $NpmDir 'dsh.cmd' $pnpmCmd = Join-Path $NpmDir 'pnpm.cmd' Info "dsh $((& $dshCmd --version) 2>$null)" # ---- 3. profile ------------------------------------------------------------------------------ Step 'dsh web profile' if ($DshHome) { $env:DSH_HOME = $DshHome } $HomeDir = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' } $ProfileDir = Join-Path $HomeDir 'profiles\web' New-Item -ItemType Directory -Force -Path $ProfileDir | Out-Null $npmrc = Join-Path $ProfileDir '.npmrc' if (-not (Test-Path $npmrc)) { Set-Content -Path $npmrc -Value "registry=$Registry" -Encoding ASCII; Info "写入 $npmrc" } & $dshCmd --profile web --dump-default-config 2>$null | Out-Null Info "profile:$ProfileDir" # ---- 4. the plugin tarball --------------------------------------------------------------------- Step '插件包 dsh-breedlens' $tgz = $null $targetVersion = $null if ($Tarball) { $tgz = (Resolve-Path $Tarball).Path if ($tgz -match 'dsh-breedlens-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz$') { $targetVersion = $Matches[1] } Info "使用本地包 $tgz" } elseif ($conn) { Info '向育见服务查询最新版本…' $latest = Invoke-EdgeGet $conn '/v1/plugin/latest?channel=stable' '' 30 if (-not $latest.current) { Fail '育见服务上还没有发布任何插件版本,请联系管理员(或用 -Tarball 指定本地包)' } $targetVersion = "$($latest.current.version)" $expected = "$($latest.current.sha256)".ToLower() $tgz = Join-Path $Downloads "dsh-breedlens-$targetVersion.tgz" $ok = $false if (Test-Path $tgz) { $ok = ((Get-Sha256 $tgz) -eq $expected) } if (-not $ok) { Info "下载 v$targetVersion($([math]::Round($latest.current.bytes / 1024)) KB)…" $part = "$tgz.part" Invoke-EdgeGet $conn "/v1/plugin/$targetVersion/tarball" $part 900 | Out-Null $actual = Get-Sha256 $part if ($actual -ne $expected) { Remove-Item -Force $part; Fail "插件包 sha256 不匹配($actual ≠ $expected)" } Move-Item -Force $part $tgz } Info "v$targetVersion 校验通过:$tgz" } else { Fail '需要接入码(-Code)或本地插件包(-Tarball)' } $installedPkg = Join-Path $ProfileDir 'node_modules\dsh-breedlens\package.json' $installed = $null if (Test-Path $installedPkg) { try { $installed = (Get-Content $installedPkg -Raw | ConvertFrom-Json).version } catch { } } if ($targetVersion -and $installed -eq $targetVersion) { Info "dsh-breedlens $installed 已安装" } else { Info "dsh plugin --profile web add $tgz(约 1 分钟)" & $dshCmd plugin --profile web add "$tgz" if ($LASTEXITCODE -ne 0) { Fail 'dsh plugin add 失败(见上方 pnpm 输出)' } $installed = (Get-Content $installedPkg -Raw | ConvertFrom-Json).version Info "dsh-breedlens $installed 已安装到 profile" } $pbw = Join-Path $ProfileDir 'node_modules\dsh-breedlens\bin\pbw.js' # ---- 5. connection code → dsh credentials, first sync ------------------------------------------- if ($Code) { Step '接入码' # PowerShell 5.1 + ErrorActionPreference=Stop turns any native stderr line into a terminating error once redirected; scope it $out = & { $ErrorActionPreference = 'Continue'; $Code | & $nodeExe $pbw edge set-token 2>&1 } if ($LASTEXITCODE -ne 0) { Info ($out | Out-String); Fail '接入码保存失败' } Info "已保存到 dsh 凭据($HomeDir\.credentials.yaml)" if (-not $SkipSync) { Step '首次同步(拉取文献台账与判定,约 10–60 秒)' & { $ErrorActionPreference = 'Continue'; & $nodeExe $pbw edge sync 2>&1 } | Where-Object { "$_" -cmatch 'edge-sync]|WARN|ERROR' } | ForEach-Object { Info "$_" } if ($LASTEXITCODE -ne 0) { Info '首次同步未完成;打开育见后会自动重试' } } } # ---- 6. launcher, versions.json, shortcuts ------------------------------------------------------- Step '启动器与快捷方式' $launchPs1 = @' # 育见 · BreedLens — 启动器(Windows) # 由开始菜单/桌面快捷方式调用:拉起 dsh web(隐藏窗口)并打开浏览器。 # 插件自更新点"重启育见"时,宿主会分离地运行 `launch.ps1 -WaitPid <旧进程 pid>`:等旧进程退出后重新启动。 # 安装器把本文件复制到 %LOCALAPPDATA%\BreedLens\launch.ps1;不要放到别处(它按自身位置找 node / dsh)。 [CmdletBinding()] param( [int]$Port = 3080, [int]$WaitPid = 0, [switch]$NoBrowser, [switch]$Browser, [switch]$NoSupervise, [string]$DshHome = '' ) # 重启协议:启动器启动 dsh 后不退出,而是隐藏着等它结束(supervise)。插件的"重启育见"让宿主以退出码 75 # 退出;启动器看到 75 就重新拉起 dsh(其它退出码则一起退出)。宿主通过 BREEDLENS_SUPERVISOR_PID 判断 # 启动器是否在监督——Windows 上宿主退出时子进程会被一并杀掉,所以由宿主自己拉起新进程行不通。 $RESTART_EXIT_CODE = 75 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $Root = Split-Path -Parent $MyInvocation.MyCommand.Path $Logs = Join-Path $Root 'logs' New-Item -ItemType Directory -Force -Path $Logs | Out-Null $LogFile = Join-Path $Logs 'launch.log' function Log([string]$msg) { $line = "{0:yyyy-MM-dd HH:mm:ss} {1}" -f (Get-Date), $msg; Add-Content -Path $LogFile -Value $line -Encoding UTF8; Write-Host $line } $versions = $null try { $versions = Get-Content (Join-Path $Root 'versions.json') -Raw -Encoding UTF8 | ConvertFrom-Json } catch { } # --- 1. hand-over from the updater: wait for the old dsh to exit --------------------------- if ($WaitPid -gt 0) { Log "waiting for dsh pid $WaitPid to exit" $deadline = (Get-Date).AddSeconds(60) while ((Get-Process -Id $WaitPid -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 300 } if (Get-Process -Id $WaitPid -ErrorAction SilentlyContinue) { Log "pid $WaitPid still alive after 60 s; stopping it"; Stop-Process -Id $WaitPid -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1 } } # --- 2. environment: private node / npm prefix first on PATH ------------------------------- $NodeDir = Join-Path $Root 'node' $NpmDir = Join-Path $Root 'npm' $prefix = @() if (Test-Path (Join-Path $NpmDir 'dsh.cmd')) { $prefix += $NpmDir } if (Test-Path (Join-Path $NodeDir 'node.exe')) { $prefix += $NodeDir } if ($prefix.Count) { $env:Path = ($prefix -join ';') + ';' + $env:Path } if ($DshHome) { $env:DSH_HOME = $DshHome } $env:BREEDLENS_LAUNCHER = $MyInvocation.MyCommand.Path $env:BREEDLENS_PORT = "$Port" if ($versions -and $versions.dsh) { $env:BREEDLENS_DSH_VERSION = "$($versions.dsh)" } if (-not $NoSupervise) { $env:BREEDLENS_SUPERVISOR_PID = "$PID" } else { Remove-Item Env:BREEDLENS_SUPERVISOR_PID -ErrorAction SilentlyContinue } $dshCmd = Join-Path $NpmDir 'dsh.cmd' if (-not (Test-Path $dshCmd)) { $found = Get-Command dsh.cmd -ErrorAction SilentlyContinue if ($found) { $dshCmd = $found.Source } else { Log 'dsh not found: run install.ps1 again'; Start-Process notepad $LogFile; exit 1 } } $Url = "http://127.0.0.1:$Port/" function Test-Up { try { Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 | Out-Null; return $true } catch { return $false } } function Open-Browser { if (-not $NoBrowser) { Start-Process $Url | Out-Null } } # --- 3. already running? just open the page ---------------------------------------------- if (Test-Up) { Log "dsh already answers on $Url" if ($WaitPid -le 0 -or $Browser) { Open-Browser } exit 0 } # --- 4. start dsh web hidden, logs rotated at 5 MB × 3; supervise and restart on exit code 75 ----- function Start-Dsh { foreach ($name in @('dsh.out.log', 'dsh.err.log')) { $f = Join-Path $Logs $name if ((Test-Path $f) -and ((Get-Item $f).Length -gt 5MB)) { for ($i = 2; $i -ge 1; $i--) { $from = "$f.$i"; $to = "$f.$($i + 1)"; if (Test-Path $from) { Move-Item -Force $from $to } } Move-Item -Force $f "$f.1" } } Log "starting: $dshCmd --profile web --no-open --port $Port" $p = Start-Process -FilePath $dshCmd -ArgumentList @('--profile', 'web', '--no-open', '--port', "$Port") -WorkingDirectory $env:USERPROFILE -WindowStyle Hidden -PassThru ` -RedirectStandardOutput (Join-Path $Logs 'dsh.out.log') -RedirectStandardError (Join-Path $Logs 'dsh.err.log') $null = $p.Handle # cache the handle now, otherwise .ExitCode is empty after the process ends $deadline = (Get-Date).AddSeconds(90) while (-not (Test-Up)) { if ($p.HasExited) { Log "dsh exited early with code $($p.ExitCode); see logs\dsh.err.log"; Start-Process notepad (Join-Path $Logs 'dsh.err.log'); return $null } if ((Get-Date) -gt $deadline) { Log 'dsh did not answer within 90 s; see logs\dsh.err.log'; Start-Process notepad (Join-Path $Logs 'dsh.err.log'); return $null } Start-Sleep -Milliseconds 500 } Log "dsh up on $Url (pid $($p.Id))" return $p } $proc = Start-Dsh if (-not $proc) { exit 1 } if ($WaitPid -le 0 -or $Browser) { Open-Browser } if ($NoSupervise) { exit 0 } # supervise: stay (hidden) until dsh exits; 75 = the plugin asked for a restart $restarts = 0 while ($true) { $proc.WaitForExit() $proc.Refresh() $code = $proc.ExitCode if ($null -eq $code) { try { $code = (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue).ExitCode } catch { } } if ($code -ne $RESTART_EXIT_CODE) { Log "dsh exited with code $code; launcher done"; exit 0 } $restarts++ if ($restarts -gt 20) { Log 'too many restarts; giving up'; exit 1 } Log "restart requested (exit $RESTART_EXIT_CODE); relaunching in 1 s" Start-Sleep -Seconds 1 $proc = Start-Dsh if (-not $proc) { exit 1 } } '@ $uninstallPs1 = @' # 育见 · BreedLens — 卸载(Windows) # 删除快捷方式与 %LOCALAPPDATA%\BreedLens(私有 node / dsh / 日志)。不碰系统安装的 Node。 # -Purge 同时删除 ~/.dsh 下的育见数据(索引、下载)并清除接入码;~/.dsh 本身保留(其它 dsh 插件与设置仍在)。 [CmdletBinding()] param( [switch]$Purge, [string]$DshHome = '' ) $ErrorActionPreference = 'Continue' $Root = Split-Path -Parent $MyInvocation.MyCommand.Path if ($DshHome) { $env:DSH_HOME = $DshHome } $Home_ = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' } Write-Host '停止正在运行的 dsh(如果是本机启动器拉起的)…' Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -match 'dsh[\\/]lib[\\/]bin\.js' -and $_.CommandLine -match [regex]::Escape($Root) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) { $lnk = Join-Path $dir '育见 BreedLens.lnk' if (Test-Path $lnk) { Remove-Item -Force $lnk; Write-Host "已删除 $lnk" } } if ($Purge) { $pbw = Join-Path $Home_ 'profiles\web\node_modules\dsh-breedlens\bin\pbw.js' $node = Join-Path $Root 'node\node.exe' if (-not (Test-Path $node)) { $node = 'node' } if (Test-Path $pbw) { & $node $pbw edge clear-token 2>$null | Out-Null; Write-Host '已清除接入码' } $data = Join-Path $Home_ 'pubmed-breeding' if (Test-Path $data) { Remove-Item -Recurse -Force $data; Write-Host "已删除 $data" } $profile = Join-Path $Home_ 'profiles\web' $pnpm = Join-Path $Root 'npm\pnpm.cmd' if ((Test-Path $profile) -and (Test-Path $pnpm)) { Push-Location $profile; & $pnpm remove dsh-breedlens 2>$null | Out-Null; Pop-Location; Write-Host '已从 dsh profile 移除 dsh-breedlens' } } Write-Host "删除 $Root …" Set-Location $env:USERPROFILE Remove-Item -Recurse -Force $Root -ErrorAction SilentlyContinue if (Test-Path $Root) { Write-Host "部分文件仍被占用,请关闭 dsh 后手动删除 $Root" } else { Write-Host '卸载完成。' } '@ $utf8Bom = New-Object System.Text.UTF8Encoding $true [IO.File]::WriteAllText((Join-Path $Root 'launch.ps1'), $launchPs1, $utf8Bom) [IO.File]::WriteAllText((Join-Path $Root 'uninstall.ps1'), $uninstallPs1, $utf8Bom) $versionsJson = @{ dsh = $Pins.dsh; node = $Pins.node; pnpm = $Pins.pnpm; plugin = $installed; installedAt = (Get-Date).ToString('o'); nodeDir = $NodeBin; port = $Port } | ConvertTo-Json [IO.File]::WriteAllText((Join-Path $Root 'versions.json'), $versionsJson, (New-Object System.Text.UTF8Encoding $false)) if (-not $NoShortcut) { $ws = New-Object -ComObject WScript.Shell $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) { if (-not $dir) { continue } $lnk = $ws.CreateShortcut((Join-Path $dir '育见 BreedLens.lnk')) $lnk.TargetPath = $psExe $lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Root\launch.ps1`"" + $(if ($Port -ne 3080) { " -Port $Port" } else { '' }) + $(if ($DshHome) { " -DshHome `"$DshHome`"" } else { '' }) $lnk.WorkingDirectory = $Root $lnk.IconLocation = "$nodeExe,0" $lnk.Description = '育见 · BreedLens — 智慧育种文献雷达' $lnk.Save() Info "快捷方式:$dir\育见 BreedLens.lnk" } } Write-Host '' Write-Host ' 安装完成。' -ForegroundColor Green Write-Host " Node:$nodeExe" Write-Host " dsh:$dshCmd($($Pins.dsh))" Write-Host " 插件:dsh-breedlens $installed → $ProfileDir" Write-Host " 启动:开始菜单 / 桌面 “育见 BreedLens”,或 $Root\launch.ps1" Write-Host " 卸载:$Root\uninstall.ps1 [-Purge]" Write-Host " 日志:$Transcript" try { Stop-Transcript | Out-Null } catch { } if (-not $NoLaunch) { $launchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $Root 'launch.ps1'), '-Port', "$Port") if ($DshHome) { $launchArgs += @('-DshHome', $DshHome) } Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $launchArgs -WindowStyle Hidden | Out-Null Write-Host ' 正在启动育见,浏览器稍后打开…' }