fix: harden mirrored downloads and first-run upgrades

This commit is contained in:
chenmingxuan committed 2026-10-12 13:26:58 +08:00
1 parent df0a7dc37a
commit 53c5680260
10 files changed
+432 -155

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ cd Artemis-Eapil
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 国内下载源失败时自动尝试备用源,每个安装包均校验官方 SHA256;也可预先安装,或通过 `overrides.scrcpy_url` 和 `scrcpy_fallback_urls` 自定义 HTTPS 地址。
### macOS / Linux
+6 -1
View File
@@ -7,7 +7,11 @@
"npm_registry": "https://registry.npmmirror.com",
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip"
"scrcpy_url": "https://ghfast.top/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"scrcpy_fallback_urls": [
"https://gh-proxy.com/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip"
]
},
"overrides": {
"python_index": "",
@@ -16,6 +20,7 @@
"node_mirror": "",
"adb_url": "",
"scrcpy_url": "",
"scrcpy_fallback_urls": null,
"uv_installer_url": ""
}
}
+3 -1
View File
@@ -34,7 +34,9 @@
Windows 启动入口 `start.bat` / `start-cn.bat` 只检查并提示源码更新,`-SkipUpdate` 跳过提示。独立升级入口为 `update.bat` / `update-cn.bat`:先把升级程序复制到项目外的临时目录,检查本地修改,再终止本项目的 ARTEMIS 服务、MCP 和任务进程;之后仅做 Git 快进更新,并用新版启动程序准备环境,完成后由用户重新启动。其他项目、Codex、模拟器和共享 ADB 不受影响;运行中的任务会被中断,不会作为成功任务处理。1.1.1 及更新版本的发布 ZIP 含 Git 工作树,旧版无 `.git` 的 ZIP 需改用新版包或 Git 克隆。没有 Git、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。
Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。
Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。未运行过启动脚本的 Git 源码目录也可直接升级:先自动准备最小 Python 环境,快进源码后再安装新版依赖。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。双击升级失败时会保留原因并等待回车;自动化调用可设置 `ARTEMIS_NO_PAUSE=1`,输入重定向时也不会等待。
scrcpy 固定版本的国内下载采用多个来源,失败或 SHA256 不符时自动尝试下一来源,全部失败会显示 HTTP 状态、超时或校验原因。`config/downloads.json` 的 `overrides.scrcpy_url` 指定首选 HTTPS 地址,`overrides.scrcpy_fallback_urls` 指定备用地址数组;`null` 使用默认备用源,`[]` 禁用备用源。国内模式不自动回退到 GitHub 直连。
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
+5 -1
View File
@@ -8,7 +8,7 @@ function Get-BootstrapText {
}
function Initialize-ArtemisPython {
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
param([Parameter(Mandatory=$true)][string]$ProjectRoot, [switch]$RuntimeOnly)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
@@ -65,6 +65,10 @@ function Initialize-ArtemisPython {
throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
}
# The frozen source updater uses only Python's standard library. Install
# project dependencies once, from the new lock file AFTER the fast-forward.
if ($RuntimeOnly) { return $pythonExe }
# A cheap check skips installation when the lock and installed packages agree.
# --inexact preserves any optional development tools already installed.
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
+104 -5
View File
@@ -1,8 +1,95 @@
# Download settings are process-local; never edit global pip/npm/uv settings.
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Get-DownloadText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
$script:ArtemisDownloadError = ''
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
$previousErrors = $ErrorActionPreference
try {
# Native stderr is an ErrorRecord in PowerShell 5.1. Inspect the code,
# and log only HTTP status/exit code, never a URL containing credentials.
$ErrorActionPreference = 'Continue'
$status = & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec --proto '=https' --proto-redir '=https' --write-out '%{http_code}' -o $OutFile $Uri 2>$null
$curlCode = $LASTEXITCODE
if ($curlCode -eq 0 -and (Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { return $true }
$script:ArtemisDownloadError = if ($curlCode -eq 22) { "HTTP $status" }
elseif ($curlCode -eq 28) { Get-DownloadText "Download timed out after $TimeoutSec seconds" "下载超时($TimeoutSec 秒)" }
else { "curl exit $curlCode" }
# Do not repeat an HTTP rejection or a full timeout with a second client.
if ($curlCode -in @(22, 28)) { return $false }
} catch {
$script:ArtemisDownloadError = 'curl failed'
} finally {
$ErrorActionPreference = $previousErrors
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
}
$previousProgress = $ProgressPreference
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
if ((Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) {
$script:ArtemisDownloadError = ''
return $true
}
$script:ArtemisDownloadError = Get-DownloadText 'Empty download' '下载内容为空'
} catch {
$response = $_.Exception.Response
$script:ArtemisDownloadError = if ($response) { "HTTP $([int]$response.StatusCode)" }
else { Get-DownloadText 'Network/TLS error or download timeout' '网络、TLS 连接失败或下载超时' }
} finally {
$ProgressPreference = $previousProgress
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
return $false
}
function Get-ArtemisFileSha256 {
param([Parameter(Mandatory=$true)][string]$Path)
# Do not depend on Get-FileHash module autoloading: PowerShell 7 can pass its
# own module search path to the Windows PowerShell 5.1 launcher.
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
finally { $stream.Dispose(); $hasher.Dispose() }
}
function Invoke-ArtemisVerifiedDownload {
param(
[Parameter(Mandatory=$true)][string[]]$Urls,
[Parameter(Mandatory=$true)][string]$OutFile,
[Parameter(Mandatory=$true)][string]$Sha256
)
$sources = @($Urls | Select-Object -Unique)
for ($index = 0; $index -lt $sources.Count; $index++) {
$sourceHost = ([Uri]$sources[$index]).Host
Write-Host (Get-DownloadText " [DOWNLOAD $($index + 1)/$($sources.Count)] scrcpy: $sourceHost" " [下载 $($index + 1)/$($sources.Count)] scrcpy:$sourceHost") -ForegroundColor Cyan
if (Invoke-DownloadFile -Uri $sources[$index] -OutFile $OutFile) {
$actualHash = Get-ArtemisFileSha256 -Path $OutFile
if ($actualHash -eq $Sha256) { return $true }
$script:ArtemisDownloadError = Get-DownloadText 'Official SHA256 verification failed; archive rejected' '官方 SHA256 校验失败,已拒绝此安装包'
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
}
Write-Host (Get-DownloadText " [WARN] $sourceHost`: $script:ArtemisDownloadError" " [提示] $sourceHost 下载失败:$script:ArtemisDownloadError") -ForegroundColor Yellow
}
return $false
}
function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$path = Join-Path $ProjectRoot 'config\downloads.json'
$config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if (-not $Mode) { $Mode = $config.mode }
@@ -28,6 +115,21 @@ function Initialize-ArtemisDownloads {
}
}
}
$fallbacks = @('https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip')
if ($Mode -eq 'China' -and $null -ne $config.china.scrcpy_fallback_urls) { $fallbacks = $config.china.scrcpy_fallback_urls }
if ($null -ne $config.overrides.scrcpy_fallback_urls) { $fallbacks = $config.overrides.scrcpy_fallback_urls }
if ($fallbacks -isnot [Array]) { throw 'config/downloads.json: scrcpy_fallback_urls must be an array of HTTPS URLs.' }
foreach ($url in $fallbacks) {
$uri = $null
if ($url -isnot [string] -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') {
throw 'config/downloads.json: scrcpy_fallback_urls must contain only HTTPS URLs.'
}
}
$values.scrcpy_urls = @(@($values.scrcpy_url) + @($fallbacks) | Select-Object -Unique)
if ($Mode -eq 'China') {
# China mode must never silently fall back to a direct GitHub download.
$values.scrcpy_urls = @($values.scrcpy_urls | Where-Object { ([Uri]$_).Host -notin @('github.com', 'www.github.com') })
}
# Explicit caller environment settings take priority over the selected preset.
if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX }
elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL }
@@ -42,7 +144,7 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') {
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mode -eq 'China') {
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
} else {
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
@@ -67,10 +169,7 @@ function Install-ArtemisUvFromIndex {
if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' }
$zip = Join-Path $work 'uv.zip'
if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' }
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($zip)
try { $digest = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') }
finally { $stream.Dispose(); $hasher.Dispose() }
$digest = Get-ArtemisFileSha256 -Path $zip
if ($digest -ne $candidate.Groups['hash'].Value) {
throw 'The downloaded uv package failed SHA256 verification.'
}
+23 -120
View File
@@ -83,6 +83,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check
# Network/authentication failures are warnings, never a reason to prevent startup.
}
. (Join-Path $ScriptDir 'windows_tools.ps1')
. (Join-Path $ScriptDir 'download_config.ps1')
try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
@@ -96,55 +97,6 @@ Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI
Write-Host "======================================================" -ForegroundColor Cyan
Write-Host ""
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
function Start-LocalAdbServer {
if (Test-CommandExists "adb") {
$adbErrorPreference = $ErrorActionPreference
@@ -167,54 +119,6 @@ function Start-LocalAdbServer {
}
}
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
# 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11)
if (Test-CommandExists "curl.exe") {
try {
& curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
}
} catch {}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
}
# 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs)
try {
$prevProgress = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
$ProgressPreference = $prevProgress
if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
}
} catch {
$ProgressPreference = $prevProgress
}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
return $false
}
function Install-PortablePlatformTools {
$ptDir = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$ptDir\adb.exe") {
@@ -258,38 +162,37 @@ function Install-PortableScrcpy {
}
Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow
if ($ArtemisDownloads.scrcpy_urls.Count -eq 0) {
Write-Host (Get-LaunchText ' [WARN] No scrcpy download source is configured. Set overrides.scrcpy_url.' ' [提示] 未配置可用的 scrcpy 下载源;请设置 overrides.scrcpy_url。') -ForegroundColor Yellow
return $false
}
$work = Join-Path ([IO.Path]::GetTempPath()) ('artemis-scrcpy-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $work -ErrorAction Stop | Out-Null
try {
$zipPath = "$env:TEMP\scrcpy-win64.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
}
$extractDir = "$env:TEMP\scrcpy_extract"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
$zipPath = Join-Path $work 'scrcpy.zip'
if (Invoke-ArtemisVerifiedDownload -Urls $ArtemisDownloads.scrcpy_urls -OutFile $zipPath -Sha256 $ArtemisDownloads.scrcpy_sha256) {
$extractDir = Join-Path $work 'extracted'
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDir)
$extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1
if ($extractedFolder) {
if (-not (Test-Path $scrcpyDir)) {
New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null
}
Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force
}
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") {
$contentDir = if (Test-Path -LiteralPath (Join-Path $extractDir 'scrcpy.exe')) { $extractDir }
elseif ($extractedFolder) { $extractedFolder.FullName }
else { throw 'scrcpy ZIP contains no application directory.' }
if (-not (Test-Path -LiteralPath (Join-Path $contentDir 'scrcpy.exe'))) { throw 'scrcpy ZIP contains no scrcpy.exe.' }
New-Item -ItemType Directory -Path $scrcpyDir -Force -ErrorAction Stop | Out-Null
Get-ChildItem -LiteralPath $contentDir | Copy-Item -Destination $scrcpyDir -Recurse -Force -ErrorAction Stop
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
}
} catch {
Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
} finally {
$fullWork = [IO.Path]::GetFullPath($work)
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if ($fullWork.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $fullWork -Leaf) -like 'artemis-scrcpy-*') {
Remove-Item -LiteralPath $fullWork -Recurse -Force -ErrorAction SilentlyContinue
}
}
return $false
}
@@ -446,7 +349,7 @@ if ($missingCore.Count -gt 0) {
}
if (-not (Test-CommandExists "scrcpy")) {
if (-not (Install-PortableScrcpy)) {
Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow
Write-Host (Get-LaunchText ' [WARN] All scrcpy sources failed. Check the HTTP/timeout/checksum error above, or configure overrides.scrcpy_url and scrcpy_fallback_urls in config/downloads.json.' ' [提示] scrcpy 下载源均未成功;请查看上方 HTTP、超时或校验错误,或在 config/downloads.json 中设置 overrides.scrcpy_url 与备用地址 scrcpy_fallback_urls。') -ForegroundColor Yellow
}
}
}
+14 -2
View File
@@ -11,6 +11,7 @@ $env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
if ($Help) {
Write-Host '用法:update.bat 或 update-cn.bat [-Help]'
Write-Host '未运行过启动脚本也可升级;缺少的 Python 环境会自动准备。'
Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。'
Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。'
exit 0
@@ -33,7 +34,7 @@ if (-not $Staged) {
if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' }
New-Item -ItemType Directory -Path $stage | Out-Null
$stageCreated = $true
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py')) {
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py', 'windows_tools.ps1', 'download_config.ps1', 'bootstrap_python.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name)
}
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $stage 'upgrade.ps1'), '-Staged', '-ProjectRoot', $ProjectRoot)
@@ -47,13 +48,15 @@ if (-not $Staged) {
if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
Exit-ArtemisInstallationLock -Mutex $lock
}
if ($code -ne 0 -and $env:ARTEMIS_NO_PAUSE -ne '1' -and -not [Console]::IsInputRedirected) {
try { Read-Host '[升级失败] 请查看上方原因。按回车键关闭窗口' | Out-Null } catch {}
}
exit $code
}
try {
Set-Location -LiteralPath $PSScriptRoot
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $python)) { throw '尚未安装项目 Python 环境;请先运行 start.bat 或 start-cn.bat。' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' }
# Refuse dirty checkouts before terminating the user's running processes.
@@ -64,6 +67,15 @@ try {
Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow
. (Join-Path $PSScriptRoot 'upgrade_processes.ps1')
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
if (-not (Test-Path -LiteralPath $python)) {
Write-Host '[首次准备] 未找到项目 Python 环境,正在自动安装;随后拉取源码并同步新版依赖。' -ForegroundColor Cyan
. (Join-Path $PSScriptRoot 'windows_tools.ps1')
. (Join-Path $PSScriptRoot 'download_config.ps1')
. (Join-Path $PSScriptRoot 'bootstrap_python.ps1')
Initialize-ArtemisDownloads -ProjectRoot $ProjectRoot -Mode $Mirror
Update-EnvironmentPath
$python = Initialize-ArtemisPython -ProjectRoot $ProjectRoot -RuntimeOnly
}
& $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot
if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' }
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
+58
View File
@@ -0,0 +1,58 @@
# Process-local Windows tool discovery shared by startup and the frozen upgrader.
# Keep UTF-8 BOM for Windows PowerShell 5.1.
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
+153 -7
View File
@@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif(
def powershell(tmp_path, script, **env):
path = tmp_path / "test.ps1"
path.write_text(
"$ErrorActionPreference = 'Stop'\n. $env:DOWNLOAD_SCRIPT\n" + script, encoding="ascii"
"$ErrorActionPreference = 'Stop'\n"
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
". $env:DOWNLOAD_SCRIPT\n" + script,
encoding="ascii",
)
clean_env = {
key: value
@@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env):
**env,
},
capture_output=True,
text=True,
encoding="utf-8",
timeout=45,
)
@@ -58,7 +61,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
if (([Uri]$ArtemisDownloads.scrcpy_url).Host -eq 'github.com') { throw 'China scrcpy still uses GitHub' }
if ($ArtemisDownloads.scrcpy_urls.Count -lt 2) { throw 'China scrcpy has no fallback' }
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
Remove-Item Env:npm_config_registry
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
@@ -71,11 +75,153 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr
def test_scrcpy_archive_is_checked_before_extraction():
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
"Remove-Item $extractDir -Recurse"
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
archive = tmp_path / "fixture.zip"
with zipfile.ZipFile(archive, "w") as package:
package.writestr("scrcpy-win64-v4.1/scrcpy.exe", b"verified executable")
package.writestr("scrcpy-win64-v4.1/scrcpy-server", b"verified server")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
install_dir = tmp_path / "user/Programs/scrcpy"
install_dir.mkdir(parents=True)
(install_dir / "keep.txt").write_text("user file")
temp_dir = tmp_path / "temporary"
temp_dir.mkdir()
result = powershell(
tmp_path,
"""
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
if ($errors) { throw 'Launcher parse error' }
$installer = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Install-PortableScrcpy' }, $true)
. ([scriptblock]::Create($installer.Extent.Text))
function Get-LaunchText { param($English, $Chinese); return $English }
$script:attempts = @()
function Invoke-DownloadFile {
param($Uri, $OutFile)
$script:attempts += $Uri
if ($Uri -eq 'https://first.example/scrcpy.zip' -or $env:FIRST_FAILURE -eq 'all') {
Set-Content -LiteralPath $OutFile -Value 'rejected content'
if ($env:FIRST_FAILURE -eq 'hash') { return $true }
$script:ArtemisDownloadError = if ($env:FIRST_FAILURE -eq 'timeout') { 'Download timed out' } else { 'HTTP 404' }
return $false
}
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile -Force
return $true
}
$script:ArtemisDownloads = [pscustomobject]@{
scrcpy_urls = @('https://first.example/scrcpy.zip', 'https://second.example/scrcpy.zip')
scrcpy_sha256 = $env:FIXTURE_HASH
}
$installed = Install-PortableScrcpy
Write-Host "INSTALLED=$installed"
Write-Host ('ATTEMPTS=' + ($script:attempts -join ','))
""",
LOCALAPPDATA=str(tmp_path / "user"),
TEMP=str(temp_dir),
TMP=str(temp_dir),
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
FIRST_FAILURE=first_failure,
FIXTURE_HASH=digest,
)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
assert "ATTEMPTS=https://first.example/scrcpy.zip,https://second.example/scrcpy.zip" in (
result.stdout
)
assert (install_dir / "keep.txt").read_text() == "user file"
assert not list(temp_dir.iterdir()), "Installer must clean its temporary download directory"
if first_failure == "all":
assert "INSTALLED=False" in result.stdout
assert "HTTP 404" in result.stdout
assert not (install_dir / "scrcpy.exe").exists()
else:
assert "INSTALLED=True" in result.stdout
assert (install_dir / "scrcpy.exe").read_bytes() == b"verified executable"
assert (install_dir / "scrcpy-server").read_bytes() == b"verified server"
expected_error = {
"http": "HTTP 404",
"timeout": "Download timed out",
"hash": "SHA256 verification failed",
}[first_failure]
assert expected_error in result.stdout
@pytest.mark.parametrize("curl_exit", [22, 28])
def test_download_reports_http_or_timeout_without_repeating_request(tmp_path, curl_exit):
result = powershell(
tmp_path,
"""
function curl.exe {
$target = $args[[Array]::IndexOf($args, '-o') + 1]
Set-Content -LiteralPath $target -Value 'partial download'
$global:LASTEXITCODE = [int]$env:CURL_EXIT
return '404'
}
function Invoke-WebRequest { throw 'HTTP rejection/timeout must not be retried with a second client' }
$destination = Join-Path $env:TEST_ROOT 'download.zip'
$downloaded = Invoke-DownloadFile -Uri 'https://missing.example/file.zip' -OutFile $destination -TimeoutSec 10
Write-Host "DOWNLOADED=$downloaded"
Write-Host "REASON=$script:ArtemisDownloadError"
if (Test-Path -LiteralPath $destination) { throw 'Partial download was left behind' }
""",
CURL_EXIT=str(curl_exit),
)
assert result.returncode == 0, result.stdout + result.stderr
assert "DOWNLOADED=False" in result.stdout
assert ("HTTP 404" if curl_exit == 22 else "timed out after 10 seconds") in result.stdout
@pytest.mark.parametrize(
"fallbacks,valid",
[
(["https://backup.example/scrcpy.zip"], True),
([], True),
(["http://insecure.example/scrcpy.zip"], False),
("https://not-an-array.example/scrcpy.zip", False),
([None], False),
],
)
def test_scrcpy_custom_sources_validate_urls_and_preserve_order(tmp_path, fallbacks, valid):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_url"] = "https://custom.example/scrcpy.zip"
config["overrides"]["scrcpy_fallback_urls"] = fallbacks
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
Write-Host ('SOURCES=' + ($ArtemisDownloads.scrcpy_urls -join ','))
""",
)
if valid:
assert result.returncode == 0, result.stdout + result.stderr
assert "SOURCES=" + ",".join(["https://custom.example/scrcpy.zip", *fallbacks]) in (
result.stdout
)
else:
assert result.returncode != 0
assert "HTTPS URLs" in result.stderr
def test_china_sources_never_fall_back_to_direct_github(tmp_path):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_fallback_urls"] = [
"https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
config["china"]["scrcpy_url"], # Duplicate should be downloaded only once.
]
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
if ($ArtemisDownloads.scrcpy_urls.Count -ne 1) { throw 'Invalid China sources' }
if (([Uri]$ArtemisDownloads.scrcpy_urls[0]).Host -eq 'github.com') { throw 'Direct GitHub fallback' }
""",
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("corrupt", [False, True])
+65 -17
View File
@@ -11,6 +11,15 @@ import time
import pytest
ROOT = Path(__file__).resolve().parents[2]
UPGRADE_FILES = (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
"windows_tools.ps1",
"download_config.ps1",
"bootstrap_python.ps1",
)
pytestmark = pytest.mark.skipif(
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
reason="Windows PowerShell and uv required",
@@ -138,20 +147,22 @@ def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path
@pytest.mark.parametrize("prepare_exit", [0, 7])
@pytest.mark.parametrize("initialized", [True, False])
def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
tmp_path, prepare_exit
tmp_path, prepare_exit, initialized
):
if not shutil.which("git"):
pytest.skip("Git required")
author = tmp_path / "author"
(author / "scripts").mkdir(parents=True)
for name in (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
for name in UPGRADE_FILES:
shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name)
(author / "config").mkdir()
shutil.copyfile(ROOT / "config/downloads.json", author / "config/downloads.json")
(author / "pyproject.toml").write_text(
'[project]\nname="upgrade-fixture"\nversion="0.1.0"\nrequires-python=">=3.12"\ndependencies=[]\n'
)
(author / "uv.lock").write_text('version = 1\nrequires-python = ">=3.12"\n')
for name in ("update.bat", "update-cn.bat"):
shutil.copyfile(ROOT / name, author / name)
(author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n")
@@ -161,8 +172,9 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
git(author, "config", "user.email", "fixture@example.test")
git(author, "add", ".")
git(author, "commit", "-m", "old version")
installed = tmp_path / "installed with spaces!"
installed = tmp_path / "installed 中文 with spaces!"
git(tmp_path, "clone", "-b", "main", str(author), str(installed))
if initialized:
make_venv(installed)
(installed / ".env").write_text("secret stays local")
# A running file AND both BAT wrappers are replaced during the merge.
@@ -170,6 +182,8 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
(author / "scripts/upgrade_processes.ps1").write_text(
"throw 'new process helper must not run'\n"
)
for name in ("windows_tools.ps1", "download_config.ps1", "bootstrap_python.ps1"):
(author / "scripts" / name).write_text("throw 'new bootstrap helper must not run'\n")
(author / "update.bat").write_text("@echo off\nexit /b 33\n")
(author / "update-cn.bat").write_text("@echo off\nexit /b 34\n")
(author / "scripts/start.ps1").write_text(
@@ -184,17 +198,25 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
result = subprocess.run(
["cmd.exe", "/d", "/c", "update-cn.bat"],
cwd=installed,
env={**os.environ, "UV_OFFLINE": "1", "ARTEMIS_NO_PAUSE": "1"},
capture_output=True,
encoding="utf-8",
timeout=45,
)
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
if initialized:
assert result.stderr == ""
else:
# uv prints normal venv creation progress to stderr.
assert "Creating virtual environment" in result.stderr
assert (installed / "prepared.txt").read_text().strip() == "new version prepared"
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert (installed / ".env").read_text() == "secret stays local"
assert ("升级完成" in result.stdout) == (prepare_exit == 0)
assert "new updater must not run" not in result.stdout
assert "new bootstrap helper must not run" not in result.stdout
assert ("首次准备" in result.stdout) == (not initialized)
assert (installed / ".venv/Scripts/python.exe").exists()
@pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"])
@@ -262,7 +284,7 @@ def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(t
def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path):
root = tmp_path / "prepared project"
(root / "scripts").mkdir(parents=True)
for name in ("start.ps1", "installation_lock.ps1"):
for name in ("start.ps1", "installation_lock.ps1", "windows_tools.ps1"):
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
(root / "scripts/download_config.ps1").write_text(
"function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n",
@@ -314,17 +336,13 @@ def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_pat
assert (root / ".env").read_text() == "keep credentials"
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
@pytest.mark.parametrize("initialized", [True, False])
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path, initialized):
if not shutil.which("git"):
pytest.skip("Git required")
root = tmp_path / "dirty installation"
(root / "scripts").mkdir(parents=True)
for name in (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
for name in UPGRADE_FILES:
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
(root / ".gitignore").write_text(".venv/\n")
source = root / "user.txt"
@@ -334,7 +352,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
git(root, "config", "user.email", "fixture@example.test")
git(root, "add", ".")
git(root, "commit", "-m", "initial")
python = make_venv(root)
python = make_venv(root) if initialized else sys.executable
source.write_text("user changes")
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
try:
@@ -344,6 +362,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
assert "[STOP]" not in result.stdout
assert worker.poll() is None
assert source.read_text() == "user changes"
assert (root / ".venv").exists() == initialized
finally:
# Windows venv redirectors can have a second interpreter process.
import psutil
@@ -355,3 +374,32 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
if child.is_running():
child.kill()
worker.wait(timeout=5)
@pytest.mark.parametrize("launcher", ["update.bat", "update-cn.bat"])
def test_fresh_non_git_directory_reports_error_without_bootstrapping_or_hanging(tmp_path, launcher):
root = tmp_path / "未初始化 source with spaces!"
(root / "scripts").mkdir(parents=True)
for name in UPGRADE_FILES:
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
for name in ("update.bat", "update-cn.bat"):
shutil.copyfile(ROOT / name, root / name)
(root / ".env").write_text("existing config must stay local")
result = subprocess.run(
["cmd.exe", "/d", "/c", launcher],
cwd=root,
# Redirected input must not wait for a key, even without ARTEMIS_NO_PAUSE.
env={key: value for key, value in os.environ.items() if key != "ARTEMIS_NO_PAUSE"},
input="",
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == 1
assert result.stderr == ""
assert "[升级失败]" in result.stdout
assert "此目录不含 Git 信息" in result.stdout
assert "首次准备" not in result.stdout
assert "�" not in result.stdout
assert not (root / ".venv").exists()
assert (root / ".env").read_text() == "existing config must stay local"