From 105d3ee0456c1fb35778f7a22af2bd7ad3da666a Mon Sep 17 00:00:00 2001 From: Luxian Date: Mon, 27 Jul 2026 12:53:35 +0800 Subject: [PATCH 1/2] fix(upgrade): restore Windows release build Signed-off-by: Luxian --- src/command/agent/import.rs | 2 +- src/internal/upgrade/lock.rs | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/command/agent/import.rs b/src/command/agent/import.rs index 162390dce..5a8579a81 100644 --- a/src/command/agent/import.rs +++ b/src/command/agent/import.rs @@ -799,7 +799,7 @@ fn discover_codex(since: Option, deadline: Instant) -> Result Result<(), InstallDirError> { if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') { return Err(InstallDirError::BadEntryName(name.to_string())); @@ -423,6 +424,50 @@ impl InstallDir { pub fn open_validated(_path: &Path) -> Result { Err(InstallDirError::UnsupportedPlatform) } + + /// The canonical directory path (diagnostics only). + pub fn path(&self) -> &Path { + &self.path + } + + // Keep the Unix-only upgrade callers type-checking on unsupported + // platforms while failing closed before any filesystem operation. + pub fn stat_entry(&self, _name: &str) -> Result, InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn read_file(&self, _name: &str) -> Result>, InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn write_file_atomic( + &self, + _name: &str, + _bytes: &[u8], + _mode: u32, + ) -> Result<(), InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn rename_entry(&self, _from: &str, _to: &str) -> Result<(), InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn remove_file(&self, _name: &str) -> Result { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn fsync_dir(&self) -> Result<(), InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn try_lock(&self) -> Result, InstallDirError> { + Err(InstallDirError::UnsupportedPlatform) + } + + pub fn lock_blocking(&self) -> Result { + Err(InstallDirError::UnsupportedPlatform) + } } #[cfg(all(test, unix))] From 65b64e2bda6ae9e92e2b49564f91f4f35bb892f3 Mon Sep 17 00:00:00 2001 From: Luxian Date: Sat, 8 Aug 2026 20:16:34 +0800 Subject: [PATCH 2/2] feat(installer): add Windows lba shim (v0.19.64) Signed-off-by: Luxian --- .github/workflows/release.yml | 3 + CHANGELOG.md | 9 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 + README.zh-CN.md | 7 +- docs/installation.md | 19 ++ docs/installation.zh-CN.md | 17 ++ install.ps1 | 313 +++++++++++++++++++++++++ install.sh | 3 +- tests/INDEX.md | 2 +- tests/compat/README.md | 2 +- tests/compat/install_alias_test.rs | 32 ++- tests/compat/install_alias_windows.ps1 | 136 +++++++++++ web/package.json | 2 +- worker/package.json | 2 +- 16 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 install.ps1 create mode 100644 tests/compat/install_alias_windows.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 947fb4b9a..ce43f9e0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,6 +123,9 @@ jobs: New-Item -ItemType Directory -Force -Path "build" | Out-Null Copy-Item "target\${{ matrix.target.rust }}\release\${{ matrix.component }}.exe" "build\$binaryName.exe" + (Get-FileHash "build\$binaryName.exe" -Algorithm SHA256).Hash.ToLowerInvariant() | + Set-Content "build\$binaryName.exe.sha256" -NoNewline + Write-Output "Built binary: $binaryName" - name: Upload to Cloudflare R2 (Unix) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ffe9ea0..bdba939cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ associations). Lease mutation stays internal to the agent runtime services. +### Added (PD-10) + +- **Windows PowerShell installer and `lba.cmd` shim (plan-20260714 PD-10)**: + `install.ps1` installs the published `libra-windows-amd64.exe`, verifies a + published SHA-256 when available, updates the user PATH, and creates an + ownership-marked `lba.cmd`. Reinstalls repair only Libra-managed shims; + `-NoAlias`/`LIBRA_NO_ALIAS=1` preserve opt-out behavior, and `-Uninstall` + removes only files and PATH entries created by the installer. + ### Fixed ### Added (PD-03) diff --git a/Cargo.lock b/Cargo.lock index cdae6db3c..b9b13f24d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3423,7 +3423,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libra" -version = "0.19.63" +version = "0.19.64" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 0f3f2b5d1..f1fe81cd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libra" -version = "0.19.63" +version = "0.19.64" edition = "2024" license = "MIT" description = "AI agent-native version control system with Git on-disk compatibility, SQLite-backed metadata, and tiered cloud storage" diff --git a/README.md b/README.md index 903928edd..6e9eb7c01 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ curl -fsSL https://download.libra.tools/install.sh | sh # Homebrew (macOS) brew install libra +# Windows PowerShell +irm https://download.libra.tools/install.ps1 | iex + # From source (requires Rust) git clone https://github.com/wingwangsz/libra.git cd libra @@ -61,6 +64,9 @@ The script installer also creates the optional shorthand repairs a missing alias without replacing the binary. Use `--no-alias` or `LIBRA_NO_ALIAS=1` to opt out; an existing user-owned `lba` is never overwritten. See [installer behavior and options](docs/installation.md). +On Windows, the PowerShell installer creates `lba.cmd` beside `libra.exe` and +adds the install directory to the user PATH. Use `-NoAlias` or +`LIBRA_NO_ALIAS=1` to opt out. #### Auto-upgrade (opt-in) diff --git a/README.zh-CN.md b/README.zh-CN.md index 67cbc8880..9e31433dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,6 +50,9 @@ curl -fsSL https://download.libra.tools/install.sh | sh # Homebrew(macOS) brew install libra +# Windows PowerShell +irm https://download.libra.tools/install.ps1 | iex + # 从源码编译(需要 Rust) git clone https://github.com/wingwangsz/libra.git cd libra @@ -60,6 +63,8 @@ cargo build --release 重复安装同一版本会修复缺失的 alias,不替换二进制。使用 `--no-alias` 或 `LIBRA_NO_ALIAS=1` 可关闭;已存在的用户自有 `lba` 绝不会被覆盖。详见 [安装器行为与选项](docs/installation.zh-CN.md)。 +Windows PowerShell 安装器会在 `libra.exe` 旁创建 `lba.cmd`,并将安装目录加入 +当前用户的 PATH。使用 `-NoAlias` 或 `LIBRA_NO_ALIAS=1` 可关闭该行为。 ### 初始化你的第一个仓库 @@ -181,7 +186,7 @@ cargo +nightly fmt --all --check cargo +nightly fmt --all ``` -Windows 构建用户请查看 [Windows 构建指南](docs/installation/windows.md) 了解 OpenSSL 配置。 +Windows 安装与 alias 行为请查看[安装文档](docs/installation.zh-CN.md)。 详细贡献指南请参见 [docs/development/contributing.md](docs/development/contributing.md)。 diff --git a/docs/installation.md b/docs/installation.md index ed0332a15..41f78f230 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -34,6 +34,25 @@ whole Libra home directory is moved. The installer does not create a copy, hard link, shell function, or alias in a profile. `lba` is only the optional filesystem symlink beside `libra`. +## Windows + +Run the PowerShell installer from PowerShell: + +```powershell +irm https://download.libra.tools/install.ps1 | iex +``` + +It installs `libra.exe` in `%USERPROFILE%\.libra\bin`, adds that directory to +the user PATH, and creates the optional `lba.cmd` shim. The shim resolves +`libra.exe` beside itself, so moving the `.libra` directory keeps both commands +working. Re-running the same version repairs a missing or Libra-managed shim +without replacing the binary. + +Use `-NoAlias`, `LIBRA_NO_ALIAS=1`, or `-NoModifyPath` for opt-out behavior. +`-Uninstall` removes only the Libra-managed binary, shim, marker, and PATH +entry; existing user-owned `lba.*` files are preserved. The installer accepts +`-Version `, `-Dir `, and `-BaseUrl ` for scripted installs. + ## Opting out Use the flag for one invocation: diff --git a/docs/installation.zh-CN.md b/docs/installation.zh-CN.md index e48d602f7..eaea29b69 100644 --- a/docs/installation.zh-CN.md +++ b/docs/installation.zh-CN.md @@ -31,6 +31,23 @@ curl -fsSL https://download.libra.tools/install.sh | sh 安装器不会创建二进制副本、hard link、shell function 或 profile alias。`lba` 只是 `libra` 旁的可选文件系统 symlink。 +## Windows + +在 PowerShell 中运行: + +```powershell +irm https://download.libra.tools/install.ps1 | iex +``` + +安装器把 `libra.exe` 放到 `%USERPROFILE%\.libra\bin`,加入当前用户的 PATH, +并创建可选的 `lba.cmd` shim。shim 总是调用自身旁边的 `libra.exe`,因此移动整个 +`.libra` 目录后两个命令仍然有效。重复安装同一版本时,会修复缺失或由 Libra +管理的 shim,而不会替换二进制文件。 + +可使用 `-NoAlias`、`LIBRA_NO_ALIAS=1` 或 `-NoModifyPath` 关闭对应行为。`-Uninstall` +只删除 Libra 管理的二进制、shim、标记文件和 PATH 项;已有的用户 `lba.*` 文件会 +保留。脚本安装支持 `-Version `、`-Dir ` 和 `-BaseUrl `。 + ## 关闭 alias 单次调用使用 flag: diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 000000000..b25a95f7c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,313 @@ +[CmdletBinding()] +param( + [string]$Version, + [string]$Dir, + [string]$BaseUrl = $env:LIBRA_BASE_URL, + [string]$BinaryPath, + [switch]$NoModifyPath, + [switch]$NoAlias, + [switch]$Uninstall +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw "libra installer: $Message" +} + +function Get-UserHome { + $userProfile = [Environment]::GetEnvironmentVariable("USERPROFILE") + if ([string]::IsNullOrWhiteSpace($userProfile)) { + $userProfile = [Environment]::GetFolderPath("UserProfile") + } + if ([string]::IsNullOrWhiteSpace($userProfile)) { + Fail "could not determine the Windows user profile" + } + return $userProfile +} + +function Get-InstallDirectory { + param([string]$Requested) + + if (-not [string]::IsNullOrWhiteSpace($Requested)) { + return [IO.Path]::GetFullPath($Requested) + } + if (-not [string]::IsNullOrWhiteSpace($env:LIBRA_INSTALL_DIR)) { + return [IO.Path]::GetFullPath($env:LIBRA_INSTALL_DIR) + } + + $libraHome = $env:LIBRA_HOME + if ([string]::IsNullOrWhiteSpace($libraHome)) { + $libraHome = Join-Path (Get-UserHome) ".libra" + } + return [IO.Path]::GetFullPath((Join-Path $libraHome "bin")) +} + +function Normalize-Version([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { + return "" + } + if ($Value -notmatch '^v?[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*$') { + Fail "invalid version '$Value'" + } + if ($Value.StartsWith("v")) { + return $Value + } + return "v$Value" +} + +function Get-LatestVersion { + try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/libra-tools/libra/releases/latest" -Headers @{ "User-Agent" = "libra-installer" } + return Normalize-Version ([string]$release.tag_name) + } + catch { + Fail "could not determine the latest version; pass -Version and retry" + } +} + +function Get-LibraVersion([string]$Path) { + try { + $output = & $Path --version 2>$null + if ($LASTEXITCODE -ne 0) { + return "" + } + $text = ($output -join "`n") + $match = [regex]::Match($text, 'v?[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*') + if ($match.Success) { + return Normalize-Version $match.Value + } + } + catch { + return "" + } + return "" +} + +function Get-LbaShimContent { + return "@echo off`r`nrem Libra-managed lba.cmd; do not edit.`r`n`"%~dp0libra.exe`" %*`r`nexit /b %ERRORLEVEL%`r`n" +} + +function Test-LibraAlias([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + return $false + } + $actual = [IO.File]::ReadAllText($Path) -replace "`r`n", "`n" + $expected = (Get-LbaShimContent) -replace "`r`n", "`n" + return [string]::Equals($actual, $expected, [StringComparison]::Ordinal) +} + +function Write-LbaShim([string]$Path) { + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + $utf8 = New-Object Text.UTF8Encoding($false) + [IO.File]::WriteAllText($temporary, (Get-LbaShimContent), $utf8) + Move-Item -LiteralPath $temporary -Destination $Path -Force + } + finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } + } +} + +function Ensure-LbaAlias([string]$InstallDirectory, [bool]$Enabled) { + if (-not $Enabled) { + return + } + + $aliasPath = Join-Path $InstallDirectory "lba.cmd" + if (Test-Path -LiteralPath $aliasPath) { + if (Test-LibraAlias $aliasPath) { + Write-LbaShim $aliasPath + return + } + Write-Warning "lba.cmd already exists and is not a Libra alias; leaving it unchanged" + return + } + + Write-LbaShim $aliasPath +} + +function Remove-LibraAlias([string]$InstallDirectory) { + $aliasPath = Join-Path $InstallDirectory "lba.cmd" + if (Test-LibraAlias $aliasPath) { + Remove-Item -LiteralPath $aliasPath -Force + } +} + +function Update-UserPath([string]$InstallDirectory) { + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = @($current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $alreadyPresent = @($entries | Where-Object { [StringComparer]::OrdinalIgnoreCase.Equals($_.TrimEnd('\'), $InstallDirectory.TrimEnd('\')) }) + if ($alreadyPresent.Count -eq 0) { + $entries += $InstallDirectory + [Environment]::SetEnvironmentVariable("Path", ($entries -join ';'), "User") + return $true + } + return $false +} + +function Remove-UserPath([string]$InstallDirectory) { + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = @($current -split ';' | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + -not [StringComparer]::OrdinalIgnoreCase.Equals($_.TrimEnd('\'), $InstallDirectory.TrimEnd('\')) + }) + [Environment]::SetEnvironmentVariable("Path", ($entries -join ';'), "User") +} + +function Get-InstallMarker([string]$InstallDirectory) { + $marker = Join-Path $InstallDirectory ".libra-install.json" + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + return $null + } + try { + $metadata = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json + if ($metadata.product -eq "libra" -and $metadata.binary -eq "libra.exe") { + return $metadata + } + } + catch { + return $null + } + return $null +} + +function Write-InstallMarker([string]$InstallDirectory, [bool]$PathAdded) { + $metadata = @{ schema_version = 1; product = "libra"; binary = "libra.exe"; path_added = $PathAdded } | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $InstallDirectory ".libra-install.json"), $metadata, (New-Object Text.UTF8Encoding($false))) +} + +function Get-Sha256([string]$Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Verify-Checksum([string]$Path, [string]$Url) { + $sumPath = "$Path.sha256" + try { + Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $sumPath + $expected = ((Get-Content -LiteralPath $sumPath -Raw) -split '\s+')[0].ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$') { + Fail "checksum file at $Url.sha256 is malformed" + } + $actual = Get-Sha256 $Path + if ($expected -ne $actual) { + Fail "sha256 mismatch for $Url (expected $expected, got $actual)" + } + } + catch [System.Net.WebException] { + if ($env:LIBRA_REQUIRE_CHECKSUM -eq "1") { + Fail "no checksum published at $Url.sha256; unset LIBRA_REQUIRE_CHECKSUM or wait for a release that publishes one" + } + Write-Warning "checksum not published at the mirror; skipping verification" + } + finally { + Remove-Item -LiteralPath $sumPath -Force -ErrorAction SilentlyContinue + } +} + +function Remove-Installation([string]$InstallDirectory) { + $binary = Join-Path $InstallDirectory "libra.exe" + $marker = Join-Path $InstallDirectory ".libra-install.json" + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + Write-Warning "no Libra install marker found; leaving installed files unchanged" + return + } + + try { + $metadata = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json + } + catch { + Write-Warning "could not validate $marker; leaving installed files unchanged" + return + } + if ($metadata.product -ne "libra" -or $metadata.binary -ne "libra.exe") { + Write-Warning "$marker is not a Libra install marker; leaving installed files unchanged" + return + } + + Remove-LibraAlias $InstallDirectory + if (Test-Path -LiteralPath $binary) { + Remove-Item -LiteralPath $binary -Force + } + Remove-Item -LiteralPath $marker -Force + if ($metadata.path_added -eq $true) { + Remove-UserPath $InstallDirectory + } +} + +$installDirectory = Get-InstallDirectory $Dir +$aliasEnabled = -not ($NoAlias -or $env:LIBRA_NO_ALIAS -eq "1") + +if ($Uninstall) { + Remove-Installation $installDirectory + Write-Output "libra uninstalled from $installDirectory" + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = $env:LIBRA_VERSION +} +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = Get-LatestVersion +} +$Version = Normalize-Version $Version + +New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null +$target = Join-Path $installDirectory "libra.exe" +$existingMarker = Get-InstallMarker $installDirectory +$pathAdded = $existingMarker -and $existingMarker.path_added -eq $true +$existingVersion = "" +if (Test-Path -LiteralPath $target -PathType Leaf) { + $existingVersion = Get-LibraVersion $target +} + +if ($existingVersion -eq $Version) { + if ($null -eq $existingMarker) { + Write-Warning "$target is not managed by the Libra installer; leaving it unchanged" + exit 0 + } + Ensure-LbaAlias $installDirectory $aliasEnabled + if (-not $NoModifyPath) { + $pathAdded = (Update-UserPath $installDirectory) -or $pathAdded + } + Write-InstallMarker $installDirectory $pathAdded + Write-Output "libra $Version is already installed at $target" + exit 0 +} + +$temporary = Join-Path ([IO.Path]::GetTempPath()) "libra-$([guid]::NewGuid().ToString('N')).exe" +try { + if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { + Copy-Item -LiteralPath $BinaryPath -Destination $temporary -Force + } + else { + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = "https://download.libra.tools/libra/releases" + } + $url = "$($BaseUrl.TrimEnd('/'))/$Version/libra-windows-amd64.exe" + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $temporary + Verify-Checksum $temporary $url + } + if ((Get-Item -LiteralPath $temporary).Length -eq 0) { + Fail "downloaded binary is empty" + } + Move-Item -LiteralPath $temporary -Destination $target -Force +} +finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } +} + +Ensure-LbaAlias $installDirectory $aliasEnabled +if (-not $NoModifyPath) { + $pathAdded = (Update-UserPath $installDirectory) -or $pathAdded +} +Write-InstallMarker $installDirectory $pathAdded +Write-Output "libra $Version installed at $target" diff --git a/install.sh b/install.sh index 0be8ceb94..8781756dc 100644 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ INSTALL_DIR="${LIBRA_INSTALL_DIR:-$LIBRA_HOME/bin}" # user opts in with LIBRA_ALLOW_FALLBACK=1. Default behaviour is fail-fast so # offline installs cannot silently regress to a stale version. Bump this on # every release so the opt-in fallback remains useful. -DEFAULT_VERSION="v0.19.62" +DEFAULT_VERSION="v0.19.64" # ─── theme (Dusk) ──────────────────────────────────────────────────────────── if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ -z "${LIBRA_NO_TUI:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then @@ -355,6 +355,7 @@ detect_os() { case "$OS_RAW" in Linux) OS=linux ;; Darwin) OS=darwin ;; + MINGW*|MSYS*|CYGWIN*) error_exit "Windows requires the PowerShell installer" "detect" "run irm https://download.libra.tools/install.ps1 | iex from PowerShell" ;; *) error_exit "unsupported operating system: $OS_RAW" "detect" "libra ships builds for linux & darwin" ;; esac } diff --git a/tests/INDEX.md b/tests/INDEX.md index a4fbbcf8d..53de7e56f 100644 --- a/tests/INDEX.md +++ b/tests/INDEX.md @@ -23,7 +23,7 @@ | `compat_worktree_delete_dir` | 1 | Guards worktree delete semantics on dir removal | `src/command/worktree.rs` | | `compat_checkout_alias_help` | 1 | Guards `--help` text for checkout aliases | `src/command/checkout.rs` | | `compat_matrix_alignment` | 1 | Guards public docs/release matrices vs. real CLI/API surfaces, including P2-04's explicit non-sending `send-email` policy | `COMPATIBILITY.md`, `docs/commands/code.md`, `docs/commands/send-email.md`, `.github/workflows/base.yml`, `src/cli.rs`, `src/internal/ai/web/mod.rs` | -| `compat_install_alias` | 1 | Guards IX-01 full-installer `lba -> libra` creation, same-version repair/idempotency, CLI/env opt-outs, foreign-path preservation, and symlink-unavailable fallback with an isolated fake downloader | `install.sh`, `tests/compat/install_alias_smoke.sh`, `README.md`, `README.zh-CN.md` | +| `compat_install_alias` | 1 | Guards IX-01 Unix `lba -> libra` and plan-20260714 PD-10 Windows `lba.cmd` creation, same-version repair/idempotency, CLI/env opt-outs, foreign-path preservation, and managed uninstall | `install.sh`, `install.ps1`, `tests/compat/install_alias_smoke.sh`, `tests/compat/install_alias_windows.ps1`, `README.md`, `README.zh-CN.md` | | `compat_live_compat_workflow` | 1 | Guards optional live AI/cloud workflow remains manual/scheduled and secret-gated | `.github/workflows/live-compat.yml` | | `compat_branch_lossy_wrapper_guard` | 1 | Guards branch-name lossy conversion wrapper | `src/internal/branch.rs` | | `compat_lfs_client_production_unwrap_guard` | 1 | Bans `unwrap()/expect()` in `internal/protocol/lfs_client.rs` | `src/internal/protocol/lfs_client.rs` | diff --git a/tests/compat/README.md b/tests/compat/README.md index ec6041ca7..b1409326e 100644 --- a/tests/compat/README.md +++ b/tests/compat/README.md @@ -33,7 +33,7 @@ top-level `[[test]]` entries in `Cargo.toml`. | `worktree_delete_dir.rs` | C5 | `worktree remove` with and without `--delete-dir`; dirty-worktree refusal | | `checkout_alias_help.rs` | C5 | top-level `--help` includes `checkout`; the help banner mentions `switch` / `restore` | | `matrix_alignment.rs` | C2 / Web Phase E / P2-04 | `COMPATIBILITY.md` ↔ `src/cli.rs::Commands` enum drift detection; explicit no-CLI/no-SMTP `send-email` policy with EN/zh/dev docs; `docs/commands/code.md` docs script coverage for every `/api/code/*` router endpoint; Web CI checks `web/out` drift after static export | -| `install_alias_test.rs` + `install_alias_smoke.sh` | IX-01 (Issue #437) | 隔离 HOME + 假 downloader 驱动完整 installer:默认相对 `lba -> libra`、same-version 缺失修复/幂等、CLI/env opt-out、regular/foreign-symlink 不覆盖、无 symlink 能力时告警但安装成功 | +| `install_alias_test.rs` + `install_alias_smoke.sh` + `install_alias_windows.ps1` | IX-01 / plan-20260714 PD-10 | Unix symlink 与 Windows `lba.cmd` 安装器:same-version 缺失修复/幂等、CLI/env opt-out、用户文件保护、卸载清理;Windows 通过 PowerShell 分支执行,非 Windows gate-skip | | `live_compat_workflow.rs` | C2 | optional `compat-live-ai` / `compat-live-cloud` workflow stays manual/scheduled, secret-gated, and outside `base.yml` | | `branch_lossy_wrapper_guard.rs` | branch follow-up | `src/` production code must use branch `*_result` APIs instead of lossy compatibility wrappers | | `lfs_client_production_unwrap_guard.rs` | unwrap audit (v0.17.260) | `src/internal/protocol/lfs_client.rs` must not regress on bare `.unwrap()` | diff --git a/tests/compat/install_alias_test.rs b/tests/compat/install_alias_test.rs index 3c9610385..217f59787 100644 --- a/tests/compat/install_alias_test.rs +++ b/tests/compat/install_alias_test.rs @@ -30,8 +30,34 @@ fn installer_creates_and_repairs_safe_optional_lba_alias() { ); } -#[cfg(not(unix))] +#[cfg(windows)] #[test] -fn installer_alias_smoke_is_unix_only() { - eprintln!("skipped: install.sh supports Linux and macOS"); +fn installer_alias_smoke_windows() { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let script = repo.join("tests/compat/install_alias_windows.ps1"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .arg("-RepoRoot") + .arg(&repo) + .output() + .expect("run Windows install alias smoke script"); + + assert!( + output.status.success(), + "Windows install alias smoke failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("install alias Windows smoke: ok"), + "Windows smoke script did not print its completion marker\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[cfg(all(not(unix), not(windows)))] +#[test] +fn installer_alias_smoke_is_unsupported_platform() { + eprintln!("skipped: installer aliases are covered on Unix and Windows"); } diff --git a/tests/compat/install_alias_windows.ps1 b/tests/compat/install_alias_windows.ps1 new file mode 100644 index 000000000..1bdd086d8 --- /dev/null +++ b/tests/compat/install_alias_windows.ps1 @@ -0,0 +1,136 @@ +param([string]$RepoRoot) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw "install alias Windows smoke: $Message" +} + +$installer = Join-Path $RepoRoot "install.ps1" +$source = Join-Path $RepoRoot "target\debug\libra.exe" +if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + $source = Join-Path $RepoRoot "target\x86_64-pc-windows-msvc\release\libra.exe" +} +if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + Fail "no built libra.exe found in target\debug or target\x86_64-pc-windows-msvc\release" +} +$sourceOutput = (& $source --version | Out-String).Trim() +$sourceVersionMatch = [regex]::Match($sourceOutput, 'v?[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*') +if (-not $sourceVersionMatch.Success) { + Fail "could not determine the version of $source" +} +$sourceVersion = $sourceVersionMatch.Value +if (-not $sourceVersion.StartsWith("v")) { + $sourceVersion = "v$sourceVersion" +} + +$work = Join-Path ([IO.Path]::GetTempPath()) "libra-install-alias-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $work -Force | Out-Null + +function Invoke-Installer([string]$InstallDirectory, [string[]]$ExtraArguments) { + $arguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $installer, + "-Version", $sourceVersion, "-Dir", $InstallDirectory, "-BinaryPath", $source, + "-NoModifyPath" + ) + $ExtraArguments + & powershell.exe @arguments + if ($LASTEXITCODE -ne 0) { + Fail "installer failed for $InstallDirectory" + } +} + +try { + $installDir = Join-Path $work "managed" + Invoke-Installer $installDir @() + $libraVersion = (& (Join-Path $installDir "libra.exe") --version | Out-String).Trim() + $lbaVersion = (& (Join-Path $installDir "lba.cmd") --version | Out-String).Trim() + if ($libraVersion -ne $lbaVersion) { + Fail "lba --version differs from libra --version" + } + + $binaryHashBefore = (Get-FileHash -LiteralPath (Join-Path $installDir "libra.exe") -Algorithm SHA256).Hash + Remove-Item -LiteralPath (Join-Path $installDir "lba.cmd") -Force + $rerunOutput = Invoke-Installer $installDir @() + if (($rerunOutput -join "`n") -notmatch "already installed") { + Fail "same-version install did not take the early-return path" + } + if (-not (Test-Path -LiteralPath (Join-Path $installDir "lba.cmd") -PathType Leaf)) { + Fail "same-version install did not repair lba.cmd" + } + $binaryHashAfter = (Get-FileHash -LiteralPath (Join-Path $installDir "libra.exe") -Algorithm SHA256).Hash + if ($binaryHashBefore -ne $binaryHashAfter) { + Fail "same-version repair replaced libra.exe" + } + Invoke-Installer $installDir @("-NoAlias") + if (-not (Test-Path -LiteralPath (Join-Path $installDir "lba.cmd") -PathType Leaf)) { + Fail "-NoAlias removed an existing Libra-managed lba.cmd" + } + + Invoke-Installer $installDir @("-Uninstall") + if (Test-Path -LiteralPath (Join-Path $installDir "libra.exe")) { + Fail "uninstall left Libra-managed libra.exe" + } + if (Test-Path -LiteralPath (Join-Path $installDir "lba.cmd")) { + Fail "uninstall left Libra-managed lba.cmd" + } + + $foreignDir = Join-Path $work "foreign" + New-Item -ItemType Directory -Path $foreignDir -Force | Out-Null + $foreignPath = Join-Path $foreignDir "lba.cmd" + $foreignContent = "@echo foreign`r`n" + [IO.File]::WriteAllText($foreignPath, $foreignContent) + Invoke-Installer $foreignDir @() + if ([IO.File]::ReadAllText($foreignPath) -ne $foreignContent) { + Fail "foreign lba.cmd was overwritten" + } + Invoke-Installer $foreignDir @("-Uninstall") + if ([IO.File]::ReadAllText($foreignPath) -ne $foreignContent) { + Fail "uninstall removed foreign lba.cmd" + } + $foreignPs1Path = Join-Path $foreignDir "lba.ps1" + $foreignPs1Content = "Write-Output 'foreign'`r`n" + [IO.File]::WriteAllText($foreignPs1Path, $foreignPs1Content) + Invoke-Installer $foreignDir @() + if ([IO.File]::ReadAllText($foreignPs1Path) -ne $foreignPs1Content) { + Fail "foreign lba.ps1 was overwritten" + } + + $unmanagedDir = Join-Path $work "unmanaged" + New-Item -ItemType Directory -Path $unmanagedDir -Force | Out-Null + $unmanagedBinary = Join-Path $unmanagedDir "libra.exe" + Copy-Item -LiteralPath $source -Destination $unmanagedBinary + Invoke-Installer $unmanagedDir @() + if (Test-Path -LiteralPath (Join-Path $unmanagedDir ".libra-install.json")) { + Fail "same-version external libra.exe was incorrectly adopted" + } + Invoke-Installer $unmanagedDir @("-Uninstall") + if (-not (Test-Path -LiteralPath $unmanagedBinary -PathType Leaf)) { + Fail "uninstall removed an external libra.exe" + } + + $noAliasDir = Join-Path $work "no-alias" + Invoke-Installer $noAliasDir @("-NoAlias") + if (Test-Path -LiteralPath (Join-Path $noAliasDir "lba.cmd")) { + Fail "-NoAlias created lba.cmd" + } + + $envAliasDir = Join-Path $work "env-no-alias" + $previousNoAlias = $env:LIBRA_NO_ALIAS + try { + $env:LIBRA_NO_ALIAS = "1" + Invoke-Installer $envAliasDir @() + } + finally { + $env:LIBRA_NO_ALIAS = $previousNoAlias + } + if (Test-Path -LiteralPath (Join-Path $envAliasDir "lba.cmd")) { + Fail "LIBRA_NO_ALIAS=1 created lba.cmd" + } + + Write-Output "install alias Windows smoke: ok" +} +finally { + if (Test-Path -LiteralPath $work) { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/web/package.json b/web/package.json index 57711c807..1e4e95096 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "libra", - "version": "0.19.63", + "version": "0.19.64", "private": true, "scripts": { "dev": "next dev", diff --git a/worker/package.json b/worker/package.json index 744033151..5ea8c710e 100644 --- a/worker/package.json +++ b/worker/package.json @@ -1,6 +1,6 @@ { "name": "libra-publish-worker", - "version": "0.19.63", + "version": "0.19.64", "private": true, "description": "Libra publish — Cloudflare Worker (Next.js + OpenNext) reading D1/R2 publish snapshots", "scripts": {