# Binary install script # Compatibility: must run in PowerShell ConstrainedLanguage (common with irm | iex). # # Layout: # %LOCALAPPDATA%\bailian-cli\ # versions\\bl.exe, bailian.exe # current ──junction──► versions\\ # bin ──junction──► current # # One-liner (default / manifest.json): # irm https://bailian.aliyun.com/cli/install.ps1 | iex # # Channel via env (required for irm | iex; do not append -Channel after irm): # $env:BAILIAN_CHANNEL = 'sync-release'; irm https://bailian.aliyun.com/cli/install.ps1 | iex # # Local file (two statements): # irm https://bailian.aliyun.com/cli/install.ps1 -OutFile install.ps1 # .\install.ps1 -Channel sync-release # param( [string]$Channel = $env:BAILIAN_CHANNEL, [string]$Version = $env:BAILIAN_VERSION, [string]$Cdn = $env:BAILIAN_CLI_CDN ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ($Cdn) { $CdnBase = $Cdn while ($CdnBase.EndsWith("/")) { $CdnBase = $CdnBase.Substring(0, $CdnBase.Length - 1) } } else { $CdnBase = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release" } $InstallRoot = if ($env:BAILIAN_SHARE_DIR) { $env:BAILIAN_SHARE_DIR } else { Join-Path $env:LOCALAPPDATA "bailian-cli" } $ConfigDir = if ($env:BAILIAN_CONFIG_DIR) { $env:BAILIAN_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".bailian" } $VersionsDir = Join-Path $InstallRoot "versions" $CurrentPath = Join-Path $InstallRoot "current" $BinDir = Join-Path $InstallRoot "bin" function Write-Log { param([string]$Message) Write-Host $Message } function Get-VersionTag { param([string]$Ver) if ($Ver.StartsWith("v")) { return $Ver } return "v$Ver" } function Test-Blank { param([string]$Value) return [string]::IsNullOrWhiteSpace($Value) } function Warn-NpmConflict { $npmCommand = Get-Command npm.cmd, npm.exe, npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $npmCommand -or -not $npmCommand.Source) { return } $previousErrorAction = $ErrorActionPreference try { $ErrorActionPreference = "Continue" $npmRootOutput = & $npmCommand.Source root -g 2>$null if ($LASTEXITCODE -ne 0 -or -not $npmRootOutput) { return } $npmRoot = ([string]@($npmRootOutput | Select-Object -Last 1)).Trim() if (Test-Blank $npmRoot) { return } $npmPackage = Join-Path $npmRoot "bailian-cli" if (Test-Path -LiteralPath $npmPackage) { Write-Log "warning: npm global bailian-cli is also installed; PATH may prefer one over the other." Write-Log " Consider: npm uninstall -g bailian-cli" } } catch { # Best-effort detection must not block binary install. } finally { $ErrorActionPreference = $previousErrorAction } } function Test-ReparsePoint { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $false } $item = Get-Item -LiteralPath $Path -Force # ReparsePoint = 1024; avoid non-core enum method/type use in ConstrainedLanguage. return [bool](([int]$item.Attributes) -band 1024) } function Get-CurrentVersionName { if (-not (Test-Path -LiteralPath $CurrentPath)) { return $null } try { $item = Get-Item -LiteralPath $CurrentPath -Force if ($item.Target) { $target = [string]@($item.Target)[0] } else { $target = [string]$item.FullName } if (Test-Blank $target) { return $null } return [string](Split-Path -Leaf $target) } catch { return $null } } function New-DirectoryJunction { param( [string]$LinkPath, [string]$TargetPath ) if (-not (Test-Path -LiteralPath $TargetPath)) { throw "Junction target missing: $TargetPath" } $parent = Split-Path -Parent $LinkPath if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } # cmd mklink /J works without elevation and stays ConstrainedLanguage-safe. $cmd = Get-Command cmd.exe -CommandType Application -ErrorAction Stop $null = & $cmd.Source /c "mklink /J `"$LinkPath`" `"$TargetPath`"" if (-not (Test-Path -LiteralPath $LinkPath)) { throw "Failed to create junction: $LinkPath -> $TargetPath" } } function Set-DirectoryJunction { param( [string]$LinkPath, [string]$TargetPath ) $migratedAside = $null if (Test-Path -LiteralPath $LinkPath) { if (Test-ReparsePoint $LinkPath) { # Prefer cmd rmdir: Remove-Item without -Recurse prompts in Windows PowerShell 5.1 # when a junction's target has children; -Recurse would risk deleting the target tree. cmd.exe /c "rmdir `"$LinkPath`"" | Out-Null if (Test-Path -LiteralPath $LinkPath) { Remove-Item -Force -LiteralPath $LinkPath -ErrorAction SilentlyContinue } } else { # Real directory (legacy bin\ with locked bl.exe): rename aside, then junction. $migratedAside = "$LinkPath.migrating.$PID" Remove-Item -Recurse -Force -LiteralPath $migratedAside -ErrorAction SilentlyContinue try { Move-Item -Force -LiteralPath $LinkPath -Destination $migratedAside } catch { Remove-Item -Recurse -Force -LiteralPath $LinkPath -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $LinkPath) { throw "Failed to migrate $LinkPath to a junction. Close running bl.exe, then retry." } $migratedAside = $null } } } if (Test-Path -LiteralPath $LinkPath) { throw "Cannot replace $LinkPath with a junction; path still exists." } New-DirectoryJunction -LinkPath $LinkPath -TargetPath $TargetPath if ($migratedAside -and (Test-Path -LiteralPath $migratedAside)) { Remove-Item -Recurse -Force -LiteralPath $migratedAside -ErrorAction SilentlyContinue } } function Switch-CurrentToVersion { param([string]$Ver) $versionDir = Join-Path $VersionsDir $Ver if (-not (Test-Path -LiteralPath $versionDir)) { throw "Version directory missing: $versionDir" } Set-DirectoryJunction -LinkPath $CurrentPath -TargetPath $versionDir } function Ensure-WindowsBinJunction { Set-DirectoryJunction -LinkPath $BinDir -TargetPath $CurrentPath } function Prune-BinaryVersions { param([string[]]$Keep) if (-not (Test-Path -LiteralPath $VersionsDir)) { return } $keepSet = @{} foreach ($name in $Keep) { if (-not (Test-Blank $name)) { $keepSet[$name] = $true } } Get-ChildItem -LiteralPath $VersionsDir -Force | ForEach-Object { $name = [string]$_.Name if ($name.StartsWith(".")) { Remove-Item -Recurse -Force -LiteralPath $_.FullName -ErrorAction SilentlyContinue return } if (-not $keepSet.ContainsKey($name)) { Remove-Item -Recurse -Force -LiteralPath $_.FullName -ErrorAction SilentlyContinue } } } $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } if ($Arch -eq "arm64") { throw "windows arm64 is not supported for binary install; use: npm install -g bailian-cli" } $PlatformKey = "windows-$Arch" if (-not (Test-Blank $Channel) -and $Channel -ne "latest" -and $Channel -ne "stable") { $ManifestLabel = "$Channel.json" $ManifestUrl = "$CdnBase/$Channel.json" } else { $ManifestLabel = "manifest.json" $ManifestUrl = "$CdnBase/manifest.json" } Write-Log "Fetching $ManifestUrl" $Manifest = Invoke-RestMethod -Uri $ManifestUrl $PinnedVersion = $Version if (Test-Blank $Version) { $Version = [string]$Manifest.version } if (Test-Blank $Version) { throw "Could not parse version from $ManifestLabel" } $ZipFile = $null $ExpectedSha = $null $InnerName = $null $AssetMeta = $null if ($Manifest.assets -and $Manifest.assets.$PlatformKey) { $AssetMeta = $Manifest.assets.$PlatformKey } $UseManifestAsset = $AssetMeta -and ( (Test-Blank $PinnedVersion) -or ($PinnedVersion -eq $Manifest.version) ) if ($UseManifestAsset) { $ZipFile = [string]$AssetMeta.file $ExpectedSha = [string]$AssetMeta.sha256 $InnerName = [string]$AssetMeta.inner } else { $ZipFile = "bl-$Version-$PlatformKey.zip" $InnerName = "bl-$Version-$PlatformKey.exe" } if ((Test-Blank $ZipFile) -or (Test-Blank $InnerName)) { throw "Manifest missing asset metadata for $PlatformKey" } $Tag = Get-VersionTag $Version $Url = "$CdnBase/$Tag/$ZipFile" $TempDir = Join-Path $env:TEMP ("bailian-cli-install-" + $PID + "-" + (Get-Random)) New-Item -ItemType Directory -Path $TempDir | Out-Null try { $ZipPath = Join-Path $TempDir $ZipFile Write-Log "Downloading bailian-cli ${Version} for ${PlatformKey}..." Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing $Actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() if (-not (Test-Blank $ExpectedSha)) { if ($ExpectedSha.ToLowerInvariant() -ne $Actual) { throw "Checksum mismatch for $ZipFile" } } else { $SumsUrl = "$CdnBase/$Tag/SHA256SUMS" $SumsPath = Join-Path $TempDir "SHA256SUMS" Invoke-WebRequest -Uri $SumsUrl -OutFile $SumsPath -UseBasicParsing $Expected = $null Get-Content $SumsPath | ForEach-Object { $line = [string]$_ $parts = @($line -split "\s+", 2) if ($parts.Count -eq 2 -and $parts[1] -eq $ZipFile) { $Expected = $parts[0].ToLowerInvariant() } } if (-not $Expected) { throw "Checksum for $ZipFile not found in SHA256SUMS" } if ($Expected -ne $Actual) { throw "Checksum mismatch for $ZipFile" } } Write-Log "Extracting ${InnerName}..." Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force $ExtractPath = Join-Path $TempDir $InnerName if (-not (Test-Path -LiteralPath $ExtractPath)) { $Found = Get-ChildItem -Path $TempDir -Recurse -File | Where-Object { $_.Name -eq $InnerName } | Select-Object -First 1 if (-not $Found) { throw "Extracted binary missing: $InnerName" } $ExtractPath = [string]$Found.FullName } $PreviousVersion = Get-CurrentVersionName New-Item -ItemType Directory -Force -Path $VersionsDir | Out-Null New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null # Stage into a fresh directory, then atomically promote (never overwrite PATH image). $StagingDir = Join-Path $VersionsDir (".staging.$Version.$PID") Remove-Item -Recurse -Force -LiteralPath $StagingDir -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null $StagingBl = Join-Path $StagingDir "bl.exe" $StagingBailian = Join-Path $StagingDir "bailian.exe" Copy-Item -Force -LiteralPath $ExtractPath -Destination $StagingBl Copy-Item -Force -LiteralPath $StagingBl -Destination $StagingBailian $VersionDir = Join-Path $VersionsDir $Version if (Test-Path -LiteralPath $VersionDir) { Remove-Item -Recurse -Force -LiteralPath $VersionDir -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $VersionDir) { # Same version dir still locked (running image). Keep it; only retarget pointers. Write-Log "Version $Version already present; retargeting current/bin pointers." Remove-Item -Recurse -Force -LiteralPath $StagingDir -ErrorAction SilentlyContinue } else { Move-Item -Force -LiteralPath $StagingDir -Destination $VersionDir } Write-Log "Switching current -> versions\$Version" Switch-CurrentToVersion -Ver $Version Ensure-WindowsBinJunction $keep = @($Version) if (-not (Test-Blank $PreviousVersion) -and $PreviousVersion -ne $Version) { $keep += $PreviousVersion } Prune-BinaryVersions -Keep $keep Set-Content -Path (Join-Path $ConfigDir "install-method") -Value "binary" -NoNewline Set-Content -Path (Join-Path $ConfigDir "install-method.bailian-cli") -Value "binary" -NoNewline # User PATH via HKCU (ConstrainedLanguage-safe; avoid [Environment]::*). $userPath = "" $pathProperty = Get-ItemProperty -Path "HKCU:\Environment" -Name Path -ErrorAction SilentlyContinue if ($pathProperty -and $pathProperty.Path) { $userPath = [string]$pathProperty.Path } $pathEntries = @() if (-not (Test-Blank $userPath)) { $pathEntries = @($userPath -split ";" | Where-Object { $_ -and $_ -ne $BinDir }) } $pathAlreadyPersisted = [bool]($userPath -split ";" | Where-Object { $_ -eq $BinDir }) if (-not $pathAlreadyPersisted) { $newUserPath = (@($BinDir) + $pathEntries) -join ";" Set-ItemProperty -Path "HKCU:\Environment" -Name Path -Value $newUserPath Write-Log "Added $BinDir to your user PATH." } if (-not ($env:Path -split ";" | Where-Object { $_ -eq $BinDir })) { $env:Path = "$BinDir;$env:Path" } Warn-NpmConflict $Target = Join-Path $VersionDir "bl.exe" Write-Log "Installed: $Target (bailian-cli $Version) [$ManifestLabel]" $previousErrorAction = $ErrorActionPreference try { $ErrorActionPreference = "Continue" if (Get-Command bl -ErrorAction SilentlyContinue) { $versionOutput = & bl --version 2>&1 if ($LASTEXITCODE -eq 0) { Write-Log ([string]$versionOutput) } else { Write-Log "warning: bl is installed but could not be executed in this session." Write-Log " Open a new terminal, or ask IT to allowlist bl.exe if AppLocker blocks AppData." } } } catch { Write-Log "warning: bl is installed but could not be executed in this session." Write-Log " Open a new terminal, or ask IT to allowlist bl.exe if AppLocker blocks AppData." } finally { $ErrorActionPreference = $previousErrorAction } $blExe = Join-Path $BinDir "bl.exe" if ($env:BAILIAN_SKIP_SKILL_INIT -eq "1") { # Bootstrap 会单独执行 skill init,这里跳过以免重复输出 } elseif (Test-Path -LiteralPath $blExe) { Write-Log "Installing Bailian skills (bl skill init --output text)..." $skillPreviousErrorAction = $ErrorActionPreference try { $ErrorActionPreference = "Continue" $skillOutput = & $blExe skill init --output text 2>&1 $skillExitCode = $LASTEXITCODE if ($skillOutput) { Write-Log ([string]$skillOutput) } if ($skillExitCode -ne 0) { Write-Log "warning: skill init failed. Run manually: bl skill init --output text" } } catch { Write-Log "warning: skill init failed. Run manually: bl skill init --output text" } finally { $ErrorActionPreference = $skillPreviousErrorAction } } else { Write-Log "warning: bl.exe not found at $blExe; skip skill init. Run manually: bl skill init --output text" } if ($env:BAILIAN_SKIP_SKILL_INIT -ne "1") { Write-Log "Done. Run: bl --help" } } finally { Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue }