#Requires -Version 5.1 <# Ycodium runtime 安装器(Windows)。语义与 scripts/install.sh 对齐一一对应, 详细设计约束见该文件顶部注释;两边各自维护一份字面量,靠 scripts/install-runtime.test.ts 里的交叉校验测试防止漂移。 -SelfTest 只跑纯逻辑断言(URL 同源校验、路径/版本安全校验),不碰网络或 磁盘事务;真正的安装事务用 -Archive 模式做端到端验证,跳过下载但其余步骤 (校验清单、smoke test、sentinel、原子改名、发布 launcher、写 origin/current) 完全一致。 #> param( [string]$Archive, [string]$Version, [ValidateSet("download", "desktop")] [string]$Origin = "download", [switch]$SelfTest ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $script:VersionsDirName = "versions" $script:ManifestFileName = "runtime-manifest.json" $script:CurrentFileName = "current.json" $script:OriginFileName = "install-origin.json" $script:SentinelFileName = ".install-complete" $script:KeepVersions = 3 $script:DefaultUpdateRoot = "https://dl.vpnla.pro/runtime" # 语义对齐 runtime.ts 的 PATH_SAFE_SEGMENTS_PATTERN:只允许 [A-Za-z0-9._-] 与 # /,且每段首字符不得是 .(一并拒绝 . 和 .. 穿越)。 function Assert-SafeRelativePath { param([string]$Value, [string]$Label) if ([string]::IsNullOrEmpty($Value)) { throw "$Label is missing." } if ($Value -notmatch '^[A-Za-z0-9_-][A-Za-z0-9._-]*(/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$') { throw "$Label is not a safe relative path: $Value" } } # 语义对齐 runtime.ts 的 VERSION_PATTERN,但额外拒绝 "." 和 ".."—— # VERSION_PATTERN 本身不禁止它们,这里加码是因为脚本把这个值当成单一路径段 # 拼进 versions/<版本>,不能让它变成穿越序列。对 -Version 参数与解出的 # $ResolvedVersion(不管来自 latest.json 还是归档自带的 manifest)都要过这关。 function Assert-SafeVersion { param([string]$Value, [string]$Label) if ([string]::IsNullOrEmpty($Value) -or $Value -eq "." -or $Value -eq ".." -or $Value -notmatch '^[A-Za-z0-9._-]+$') { throw "$Label is invalid: $Value" } } # 语义对齐 runtime.ts 的 resolveRuntimeArtifactUrl:必须 https、同源、路径落在 # root 前缀之下,且不带 userinfo/query/fragment。[Uri] 在解析相对引用时会先做 # 点号分段归一化,所以 ".." 穿越在做前缀比较之前就已经被解析掉。 function Resolve-RuntimeArtifactUrl { param([string]$UpdateRoot, [string]$RelativeUrl) $NormalizedRoot = $UpdateRoot.TrimEnd("/") + "/" try { $Root = [Uri]::new($NormalizedRoot, [UriKind]::Absolute) } catch { throw "Runtime update root is invalid: $UpdateRoot" } if ($Root.Scheme -ne "https") { throw "Runtime update root must use https: $UpdateRoot" } try { $Resolved = [Uri]::new($Root, $RelativeUrl) } catch { throw "Runtime artifact url is invalid: $RelativeUrl" } if ($Resolved.Scheme -ne "https") { throw "Runtime artifact url must use https: $RelativeUrl" } $SameOrigin = ($Resolved.GetLeftPart([UriPartial]::Authority) -eq $Root.GetLeftPart([UriPartial]::Authority)) if (-not $SameOrigin -or -not $Resolved.AbsolutePath.StartsWith($Root.AbsolutePath, [StringComparison]::Ordinal)) { throw "Runtime artifact url is outside the configured update root: $($Resolved.AbsoluteUri)" } if ($Resolved.UserInfo -or $Resolved.Query -or $Resolved.Fragment) { throw "Runtime artifact url must not include userinfo, a query, or a fragment: $($Resolved.AbsoluteUri)" } return $Resolved.AbsoluteUri } # 用 RuntimeInformation 而不是 $env:PROCESSOR_ARCHITECTURE——前者在 pwsh 7+ # 下跨平台一致,方便本仓库在非 Windows 机器上也能跑通端到端测试;在真实 # Windows 5.1 上同样可用(.NET Framework 4.7.1+ 自带该类型)。 function Get-CurrentTarget { $Arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture if ($Arch -ne [System.Runtime.InteropServices.Architecture]::X64) { throw "Ycodium runtime installer does not support this CPU architecture: $Arch" } return "win32-x64" } # 只有 YCODIUM_RUNTIME_ROOT 是绝对路径时才生效,否则落回 # %LOCALAPPDATA%\Ycodium\runtime。 function Resolve-InstallRoot { $Candidate = $env:YCODIUM_RUNTIME_ROOT if ($Candidate -and [System.IO.Path]::IsPathRooted($Candidate)) { return $Candidate } return (Join-Path $env:LOCALAPPDATA "Ycodium\runtime") } function Read-JsonFile { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing file: $Path" } $Raw = Get-Content -LiteralPath $Path -Raw try { return $Raw | ConvertFrom-Json } catch { throw "Invalid JSON at $Path" } } # targets 对象的属性名带连字符(如 linux-x64),不能用点号语法直接取。 function Get-TargetArtifact { param($Pointer, [string]$Target) if (-not $Pointer.targets) { return $null } $Prop = $Pointer.targets.PSObject.Properties[$Target] if (-not $Prop) { return $null } return $Prop.Value } # 把 manifest 里用 / 分隔的相对路径拼到某个根目录下——Windows 的文件系统 API # 普遍接受 /,但统一换成原生分隔符更稳妥。 function Join-PayloadPath { param([string]$Base, [string]$RelativePath) $Native = $RelativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) return (Join-Path $Base $Native) } # 先写临时文件再 Move-Item 改名,避免在目标路径留下写了一半的文件;显式不带 # BOM 的 UTF-8,避免 Windows PowerShell 5.1 与 pwsh 7 在 -Encoding utf8 上是否 # 带 BOM 的差异。 function Write-TextAtomic { param([string]$TargetPath, [string]$Content) $TempPath = "$TargetPath.tmp.$PID" [System.IO.File]::WriteAllText($TempPath, $Content, [System.Text.UTF8Encoding]::new($false)) Move-Item -Force -LiteralPath $TempPath -Destination $TargetPath } function Write-InstallOrigin { param([string]$Root, [string]$OriginValue) $Content = '{{"schemaVersion":1,"origin":"{0}"}}' -f $OriginValue Write-TextAtomic (Join-Path $Root $script:OriginFileName) $Content } function Write-CurrentPointer { param([string]$Root, [string]$VersionValue) $Content = '{{"schemaVersion":1,"version":"{0}"}}' -f $VersionValue Write-TextAtomic (Join-Path $Root $script:CurrentFileName) $Content } function Get-CurrentVersion { param([string]$Root) $CurrentPath = Join-Path $Root $script:CurrentFileName if (-not (Test-Path -LiteralPath $CurrentPath -PathType Leaf)) { return $null } return (Read-JsonFile $CurrentPath).version } # 与 install.sh 的 version_sort_key 同一套编码:每段左补零到 10 位,纯序数字典序 # 即数值序。两边必须给出同样的排序结果,这是 sh/ps1 不漂移的落点。用字符串补零 # 而不是数值转换,超长段不会溢出。 function Get-VersionSortKey { param([string]$Value) $Key = "" foreach ($Segment in $Value.Split(".")) { $Digits = if ($Segment -match '^[0-9]+$') { $Segment.TrimStart("0") } else { "" } if ([string]::IsNullOrEmpty($Digits)) { $Digits = "0" } $Key += $Digits.PadLeft(10, "0") + "." } return $Key } # $Left 严格新于 $Right 时返回 $true。 function Test-VersionIsNewer { param([string]$Left, [string]$Right) return [string]::CompareOrdinal((Get-VersionSortKey $Left), (Get-VersionSortKey $Right)) -gt 0 } # ADR-0014「只升不降」:目标版本不比 current.json 新就什么都不做,显式的 # -Archive 安装也没有例外。这里仍重发 launcher 与 origin 是有意的——它顺手修好 # 一个被弄坏的 shim。current.json 缺失(全新安装)一律放行;返回 $true 表示 # 调用方应当停下。 function Test-ShouldSkipDowngrade { param([string]$Root, [string]$NextVersion, [string]$CurrentVersion, [string]$OriginValue) if ([string]::IsNullOrEmpty($CurrentVersion)) { return $false } if (Test-VersionIsNewer $NextVersion $CurrentVersion) { return $false } Publish-StableLauncher $Root $CurrentVersion Write-InstallOrigin $Root $OriginValue if ($NextVersion -eq $CurrentVersion) { Write-Host "Ycodium runtime $CurrentVersion is already current." } else { Write-Host "Ycodium runtime $CurrentVersion is already newer than $NextVersion; leaving it in place." } return $true } # versions/ 下当前有活进程在跑的目录名(小写)。全量枚举进程,不采样; # CommandLine 需要权限才读得到,所以 ExecutablePath 也一并看。路径分隔符两种都 # 可能出现,统一归一化成 / 再做前缀匹配。返回值一律用 `,` 包住——PowerShell 会 # 把返回的集合摊平进管道,空集合就变成了 $null。 function Get-InUseVersionDirs { param([string]$VersionsDir) $Prefix = ($VersionsDir.Replace('\', '/').TrimEnd('/') + "/").ToLowerInvariant() $Names = New-Object System.Collections.Generic.HashSet[string] $Processes = @() try { $Processes = @(Get-CimInstance Win32_Process -ErrorAction Stop) } catch { return , $Names } foreach ($Process in $Processes) { foreach ($Field in @($Process.CommandLine, $Process.ExecutablePath)) { if ([string]::IsNullOrEmpty($Field)) { continue } $Haystack = $Field.Replace('\', '/').ToLowerInvariant() $At = $Haystack.IndexOf($Prefix, [StringComparison]::Ordinal) if ($At -lt 0) { continue } $Tail = $Haystack.Substring($At + $Prefix.Length) $Name = ($Tail -split '[/" ]')[0] if ($Name) { [void]$Names.Add($Name) } } } return , $Names } # 只保留最新的 $script:KeepVersions 个版本目录(current 算其中一个)。四类目录 # 永不删:current 指向的、pack- 前缀(ADR-0014 已退休的 host pack 布局)、. 开头 # 的(staging/sentinel)、以及有活进程正在运行的。 function Remove-StaleVersions { param([string]$Root, [string]$CurrentVersion) $VersionsDir = Join-Path $Root $script:VersionsDirName if (-not (Test-Path -LiteralPath $VersionsDir -PathType Container)) { return } $InUse = Get-InUseVersionDirs $VersionsDir $Candidates = @( Get-ChildItem -LiteralPath $VersionsDir -Directory -Force | Where-Object { $_.Name -ne $CurrentVersion -and -not $_.Name.StartsWith("pack-") -and -not $_.Name.StartsWith(".") -and -not $InUse.Contains($_.Name.ToLowerInvariant()) } | Sort-Object -Property @{ Expression = { Get-VersionSortKey $_.Name } } -Descending ) # current 已经占掉保留窗口的一个名额,候选里因此只留 KeepVersions - 1 个。 if ($Candidates.Count -le ($script:KeepVersions - 1)) { return } foreach ($Stale in $Candidates[($script:KeepVersions - 1)..($Candidates.Count - 1)]) { Remove-Item -Recurse -Force -LiteralPath $Stale.FullName } } # 只做路径穿越/绝对路径防护,不做符号链接类型探测,与 install.sh 的 # validate_archive_entries 同一个安全边界。tar -C dir . 打包会带一条代表归档 # 根目录自身的 "./" 条目,归一化后是空字符串,视为无害。 function Assert-SafeTarEntries { param([string]$ArchivePath) $Entries = & tar -tzf $ArchivePath 2>$null if ($LASTEXITCODE -ne 0) { throw "Failed to list the Ycodium runtime archive." } foreach ($RawEntry in $Entries) { $Entry = $RawEntry if ($Entry.StartsWith("./")) { $Entry = $Entry.Substring(2) } $Entry = $Entry.TrimEnd("/") if ([string]::IsNullOrEmpty($Entry)) { continue } $Unsafe = $Entry.StartsWith("/") -or $Entry -eq ".." -or $Entry.StartsWith("../") ` -or $Entry.EndsWith("/..") -or $Entry.Contains("/../") -or $Entry.Contains("\") if ($Unsafe) { throw "Ycodium runtime archive contains an unsafe path: $RawEntry" } } } # 把 versions/<版本>/ 同步为 /bin 下的稳定路径,Windows 侧再配一个 # .cmd shim。版本号不来自参数猜测,而是回读该版本自己的 manifest,两边永远一致。 # # 归档里自带的 .cmd(build-runtime.ts 生成)靠 %~dp0 相对路径指向 # "自己所在版本目录"下的私有 node.exe——一旦原样复制到 /bin 就会指向不 # 存在的 /node。稳定路径的 .cmd 因此不是复制来的,而是每次发布都重新 # 生成、内嵌当前版本 node.exe 绝对路径,天然不受版本升级影响,也不必依赖 # 系统装了 node。 function Publish-StableLauncher { param([string]$Root, [string]$VersionValue) $VersionDir = Join-Path (Join-Path $Root $script:VersionsDirName) $VersionValue $ManifestPath = Join-Path $VersionDir $script:ManifestFileName if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw "Cannot publish the Ycodium launcher: $ManifestPath is missing." } $Manifest = Read-JsonFile $ManifestPath Assert-SafeRelativePath $Manifest.launcher "launcher" Assert-SafeRelativePath $Manifest.node "node" $LauncherSource = Join-PayloadPath $VersionDir $Manifest.launcher $NodeSource = Join-PayloadPath $VersionDir $Manifest.node if (-not (Test-Path -LiteralPath $LauncherSource -PathType Leaf)) { throw "Cannot publish the Ycodium launcher: $LauncherSource is missing." } if (-not (Test-Path -LiteralPath $NodeSource -PathType Leaf)) { throw "Cannot publish the Ycodium launcher: $NodeSource is missing." } $LauncherDest = Join-PayloadPath $Root $Manifest.launcher New-Item -ItemType Directory -Force -Path (Split-Path $LauncherDest -Parent) | Out-Null Copy-Item -Force -LiteralPath $LauncherSource -Destination $LauncherDest $CmdShimContent = "@echo off`r`n`"$NodeSource`" `"$LauncherDest`" %*`r`n" Write-TextAtomic "$LauncherDest.cmd" $CmdShimContent } function Write-PathHint { param([string]$Root) $BinDir = Join-Path $Root "bin" Write-Host "Run: $(Join-Path $BinDir 'ycodium.cmd') " $PathEntries = @(($env:Path -split ";") | Where-Object { $_ }) if ($PathEntries -notcontains $BinDir) { Write-Host ('Add it to your PATH: $env:Path = "{0};$env:Path"' -f $BinDir) } } function Invoke-SelfTest { $Root = "https://dl.vpnla.pro/runtime" $Resolved = Resolve-RuntimeArtifactUrl $Root "0.0.34/ycodium-runtime-0.0.34-linux-x64.tar.gz" if ($Resolved -ne "https://dl.vpnla.pro/runtime/0.0.34/ycodium-runtime-0.0.34-linux-x64.tar.gz") { throw "Resolve-RuntimeArtifactUrl happy path failed: $Resolved" } $RejectedUrls = @( "http://dl.vpnla.pro/runtime/latest.json", "https://evil.example/runtime/latest.json", "https://dl.vpnla.pro/other/latest.json", "https://dl.vpnla.pro/runtime/../secrets.json", "https://user:pass@dl.vpnla.pro/runtime/latest.json", "https://dl.vpnla.pro/runtime/latest.json?x=1", "https://dl.vpnla.pro/runtime/latest.json#frag" ) foreach ($BadUrl in $RejectedUrls) { $Rejected = $false try { Resolve-RuntimeArtifactUrl $Root $BadUrl | Out-Null } catch { $Rejected = $true } if (-not $Rejected) { throw "Resolve-RuntimeArtifactUrl failed to reject: $BadUrl" } } Assert-SafeRelativePath "bin/ycodium" "test" foreach ($BadPath in @("", "/etc/passwd", "apps/../secret", "a/..", ".hidden", "https://x")) { $Rejected = $false try { Assert-SafeRelativePath $BadPath "test" } catch { $Rejected = $true } if (-not $Rejected) { throw "Assert-SafeRelativePath failed to reject: $BadPath" } } Assert-SafeVersion "0.0.34" "test" foreach ($BadVersion in @("", "..", "0.0.34; rm -rf /")) { $Rejected = $false try { Assert-SafeVersion $BadVersion "test" } catch { $Rejected = $true } if (-not $Rejected) { throw "Assert-SafeVersion failed to reject: $BadVersion" } } # 数值序,不是字典序——0.2.99 必须排在 0.2.1066 之前,这是朴素字符串比较会 # 弄反的那一对。断言与 install.sh 的同名用例逐条对应。 foreach ($Pair in @( @("0.2.1066", "0.2.99"), @("0.2.1313", "0.2.1283"), @("0.2.1158", "0.2.1066"), @("0.0.35", "0.0.34"), @("1.0.0", "0.99.99") )) { if (-not (Test-VersionIsNewer $Pair[0] $Pair[1])) { throw "Test-VersionIsNewer says $($Pair[0]) is not newer than $($Pair[1])" } if (Test-VersionIsNewer $Pair[1] $Pair[0]) { throw "Test-VersionIsNewer says $($Pair[1]) is newer than $($Pair[0])" } } if (Test-VersionIsNewer "0.0.34" "0.0.34") { throw "Test-VersionIsNewer treats an equal version as newer." } Write-Host "Ycodium runtime installer self-test passed." } function Invoke-Main { if ($Version) { Assert-SafeVersion $Version "-Version" } if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { throw "tar is required (ships with Windows 10 1803+ / Windows Server 2019+)." } $Target = Get-CurrentTarget $Root = Resolve-InstallRoot $VersionsDir = Join-Path $Root $script:VersionsDirName New-Item -ItemType Directory -Force -Path $VersionsDir | Out-Null $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("ycodium-runtime-install-" + [guid]::NewGuid().ToString("N")) $StagingDir = $null try { New-Item -ItemType Directory -Force -Path $TempDir | Out-Null $CurrentVersion = Get-CurrentVersion $Root $ResolvedVersion = $Version if ($Archive) { if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) { throw "Archive not found: $Archive" } $ArchiveFile = $Archive } else { $UpdateRoot = if ($env:YCODIUM_RUNTIME_UPDATE_ROOT) { $env:YCODIUM_RUNTIME_UPDATE_ROOT } else { $script:DefaultUpdateRoot } $UpdateRoot = $UpdateRoot.TrimEnd("/") if ($UpdateRoot -notmatch '^https://') { throw "Runtime update root must be https: $UpdateRoot" } $PointerUrl = if ($Version) { "$UpdateRoot/$Version/manifest.json" } else { "$UpdateRoot/latest.json" } $PointerFile = Join-Path $TempDir "release-index.json" Invoke-WebRequest -Uri $PointerUrl -OutFile $PointerFile -UseBasicParsing $Pointer = Read-JsonFile $PointerFile $ResolvedVersion = $Pointer.version if ([string]::IsNullOrEmpty($ResolvedVersion)) { throw "Ycodium runtime release index at $PointerUrl has no version." } if ($Version -and $ResolvedVersion -ne $Version) { throw "Ycodium runtime manifest for $Version reports version $ResolvedVersion; refusing to continue." } if (Test-ShouldSkipDowngrade $Root $ResolvedVersion $CurrentVersion $Origin) { return } $Artifact = Get-TargetArtifact $Pointer $Target if (-not $Artifact -or -not $Artifact.url -or -not $Artifact.sha256 -or -not $Artifact.size) { throw "Ycodium runtime release index entry for $Target is incomplete." } Assert-SafeRelativePath $Artifact.url "Runtime artifact url" $ArtifactUrl = Resolve-RuntimeArtifactUrl $UpdateRoot $Artifact.url $ArchiveFile = Join-Path $TempDir "archive.tar.gz" Invoke-WebRequest -Uri $ArtifactUrl -OutFile $ArchiveFile -UseBasicParsing $ActualSize = (Get-Item -LiteralPath $ArchiveFile).Length if ([int64]$ActualSize -ne [int64]$Artifact.size) { throw "Ycodium runtime archive size mismatch (expected $($Artifact.size), got $ActualSize)." } $ActualSha256 = (Get-FileHash -LiteralPath $ArchiveFile -Algorithm SHA256).Hash.ToLowerInvariant() if ($ActualSha256 -ne $Artifact.sha256) { throw "Ycodium runtime archive checksum mismatch." } } Assert-SafeTarEntries $ArchiveFile $StagingDir = Join-Path $VersionsDir ".staging-$PID" if (Test-Path -LiteralPath $StagingDir) { Remove-Item -Recurse -Force -LiteralPath $StagingDir } New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null & tar -xzf $ArchiveFile -C $StagingDir if ($LASTEXITCODE -ne 0) { throw "Failed to extract the Ycodium runtime archive." } $ManifestPath = Join-Path $StagingDir $script:ManifestFileName if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw "Extracted Ycodium runtime archive is missing $($script:ManifestFileName)." } $Manifest = Read-JsonFile $ManifestPath foreach ($Field in @("version", "target", "nodeVersion", "node", "entry", "launcher")) { if ([string]::IsNullOrEmpty($Manifest.$Field)) { throw "Extracted Ycodium runtime manifest is missing required field: $Field." } } Assert-SafeRelativePath $Manifest.node "node" Assert-SafeRelativePath $Manifest.entry "entry" Assert-SafeRelativePath $Manifest.launcher "launcher" if ($Manifest.target -ne $Target) { throw "Ycodium runtime manifest target ($($Manifest.target)) does not match this host ($Target)." } if ($Archive) { if ($Version -and $Manifest.version -ne $Version) { throw "Ycodium runtime archive version ($($Manifest.version)) does not match the requested version ($Version)." } $ResolvedVersion = $Manifest.version } elseif ($Manifest.version -ne $ResolvedVersion) { throw "Ycodium runtime manifest version ($($Manifest.version)) does not match the release index ($ResolvedVersion)." } # $ResolvedVersion 接下来会被当成单一路径段拼进 versions/<版本>;不管它来自 # latest.json 还是归档自带的 manifest,都要在这里统一把关。 Assert-SafeVersion $ResolvedVersion "version" # -Archive 路径的版本号到这一步才定下来,「只升不降」在这里第二次把关:拿旧 # 归档去装一台已经更新的机器必须安全地不做事,而不是把它退回旧版本。 if (Test-ShouldSkipDowngrade $Root $ResolvedVersion $CurrentVersion $Origin) { return } $NodePath = Join-PayloadPath $StagingDir $Manifest.node $EntryPath = Join-PayloadPath $StagingDir $Manifest.entry $LauncherPath = Join-PayloadPath $StagingDir $Manifest.launcher if (-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw "Extracted Ycodium runtime is missing its Node binary at $($Manifest.node)." } if (-not (Test-Path -LiteralPath $EntryPath -PathType Leaf)) { throw "Extracted Ycodium runtime is missing its server entry at $($Manifest.entry)." } if (-not (Test-Path -LiteralPath $LauncherPath -PathType Leaf)) { throw "Extracted Ycodium runtime is missing its launcher at $($Manifest.launcher)." } $NodeVersionOutput = & $NodePath -v 2>$null if ($LASTEXITCODE -ne 0 -or -not $NodeVersionOutput) { throw "Smoke test failed: the bundled Node binary did not run." } $NodeVersionOutput = (($NodeVersionOutput -join "`n").Trim()).TrimStart("v") if ($NodeVersionOutput -ne $Manifest.nodeVersion) { throw "Smoke test failed: bundled Node reports $NodeVersionOutput, manifest declares $($Manifest.nodeVersion)." } $EntryVersionOutput = & $NodePath $EntryPath --version 2>$null if ($LASTEXITCODE -ne 0 -or -not $EntryVersionOutput) { throw "Smoke test failed: the server entry did not run." } $EntryVersionJoined = $EntryVersionOutput -join "`n" if ($EntryVersionJoined -notmatch [regex]::Escape($ResolvedVersion)) { throw "Smoke test failed: server entry reports '$EntryVersionJoined', expected version $ResolvedVersion." } [System.IO.File]::WriteAllText( (Join-Path $StagingDir $script:SentinelFileName), "$ResolvedVersion`n", [System.Text.UTF8Encoding]::new($false) ) $FinalVersionDir = Join-Path $VersionsDir $ResolvedVersion if (Test-Path -LiteralPath $FinalVersionDir) { Remove-Item -Recurse -Force -LiteralPath $FinalVersionDir } Move-Item -LiteralPath $StagingDir -Destination $FinalVersionDir $StagingDir = $null Publish-StableLauncher $Root $ResolvedVersion Write-InstallOrigin $Root $Origin Write-CurrentPointer $Root $ResolvedVersion # 清理排在 current.json 之后,且失败只 warn:安装事务此刻已经提交,回收磁盘 # 不该有能力把它弄坏。 try { Remove-StaleVersions $Root $ResolvedVersion } catch { Write-Warning "Ycodium runtime installed, but pruning old versions failed; they are still on disk." } Write-Host "Ycodium runtime $ResolvedVersion installed." Write-PathHint $Root } finally { if ($TempDir -and (Test-Path -LiteralPath $TempDir)) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $TempDir } if ($StagingDir -and (Test-Path -LiteralPath $StagingDir)) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $StagingDir } } } if ($SelfTest) { Invoke-SelfTest } else { Invoke-Main }