From 684a9b2e6edb5ab0fcdccb8ce3abe93614b95bca Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 23 May 2026 02:51:32 +0800 Subject: [PATCH] fix(installer): tolerate WSL UNC launch cwd --- CHANGELOG.md | 1 + scripts/install.ps1 | 113 ++++++++++++++++++++++++++++--- test/scripts/install-ps1.test.ts | 59 ++++++++++++++-- 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5cb36f3364..b19e277ecb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu. - Tlon: stop advertising a non-existent agent tool contract in the plugin manifest. - Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives `language-*` code classes. (#85209) Thanks @leno23. +- Windows installer: run npm and Corepack command shims from a Windows-local directory so installs launched from WSL2 UNC paths do not fail before OpenClaw is installed. - Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails. - Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates. - Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 679da680b8b..eff67d38fbe 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -623,6 +623,53 @@ function Get-PnpmCommandPath { return (Resolve-CommandPath -Candidates @("pnpm.cmd", "pnpm.exe", "pnpm")) } +function Get-WindowsCommandSafeDirectory { + $userHome = [Environment]::GetFolderPath("UserProfile") + if (-not [string]::IsNullOrWhiteSpace($userHome) -and (Test-Path $userHome)) { + return $userHome + } + if (-not [string]::IsNullOrWhiteSpace($env:TEMP) -and (Test-Path $env:TEMP)) { + return $env:TEMP + } + return $null +} + +function Invoke-CommandFromWindowsSafeDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$CommandPath, + [string[]]$Arguments = @() + ) + + $safeDir = Get-WindowsCommandSafeDirectory + $pushedLocation = $false + try { + if (-not [string]::IsNullOrWhiteSpace($safeDir)) { + Push-Location -LiteralPath $safeDir + $pushedLocation = $true + } + & $CommandPath @Arguments + } finally { + if ($pushedLocation) { + Pop-Location + } + } +} + +function Invoke-NpmCommand { + param([string[]]$Arguments = @()) + Invoke-CommandFromWindowsSafeDirectory -CommandPath (Get-NpmCommandPath) -Arguments $Arguments +} + +function Invoke-CorepackCommand { + param([string[]]$Arguments = @()) + $corepackCommand = Get-CorepackCommandPath + if (-not $corepackCommand) { + throw "corepack not found on PATH." + } + Invoke-CommandFromWindowsSafeDirectory -CommandPath $corepackCommand -Arguments $Arguments +} + function Get-NpmGlobalBinCandidates { param( [string]$NpmPrefix @@ -647,7 +694,7 @@ function Ensure-OpenClawOnPath { $npmPrefix = $null try { - $npmPrefix = (& (Get-NpmCommandPath) config get prefix 2>$null).Trim() + $npmPrefix = (Invoke-NpmCommand -Arguments @("config", "get", "prefix") 2>$null).Trim() } catch { $npmPrefix = $null } @@ -751,8 +798,8 @@ function Ensure-Pnpm { $corepackCommand = Get-CorepackCommandPath if ($corepackCommand) { try { - & $corepackCommand enable | Out-Null - & $corepackCommand prepare $pnpmSpec --activate | Out-Null + Invoke-CorepackCommand -Arguments @("enable") | Out-Null + Invoke-CorepackCommand -Arguments @("prepare", $pnpmSpec, "--activate") | Out-Null if (Test-PnpmCommandMatchesVersion -PnpmVersion $pnpmVersion -RepoDir $RepoDir) { Write-Host "[OK] pnpm installed via corepack ($pnpmSpec)" -ForegroundColor Green return @@ -765,7 +812,7 @@ function Ensure-Pnpm { $prevScriptShell = $env:NPM_CONFIG_SCRIPT_SHELL $env:NPM_CONFIG_SCRIPT_SHELL = "cmd.exe" try { - & (Get-NpmCommandPath) install -g $pnpmSpec + Invoke-NpmCommand -Arguments @("install", "-g", $pnpmSpec) } finally { $env:NPM_CONFIG_SCRIPT_SHELL = $prevScriptShell } @@ -776,6 +823,52 @@ function Ensure-Pnpm { } # Install OpenClaw +function Resolve-LocalNpmPackagePath { + param([string]$PackagePath) + + try { + return (Resolve-Path -LiteralPath $PackagePath -ErrorAction Stop).ProviderPath + } catch { + return [System.IO.Path]::GetFullPath($PackagePath) + } +} + +function Resolve-LocalNpmPackageInstallSpec { + param([string]$InstallSpec) + + if ([string]::IsNullOrWhiteSpace($InstallSpec)) { + return $InstallSpec + } + if ($InstallSpec -match '^file:(?.+)$') { + $filePath = $Matches["path"] + if ( + $filePath -match '^/' -or + $filePath -match '^\\\\' -or + $filePath -match '^[A-Za-z]:[\\/]' + ) { + return $InstallSpec + } + return ([System.Uri](Resolve-LocalNpmPackagePath -PackagePath $filePath)).AbsoluteUri + } + if ( + $InstallSpec -match '^https?:' -or + $InstallSpec -match '^(git\+|github:)' -or + $InstallSpec -match '^[A-Za-z]:[\\/]' -or + $InstallSpec -match '^\\\\' + ) { + return $InstallSpec + } + if ($InstallSpec -notmatch '^\.\.?[\\/]' -and $InstallSpec -notmatch '\.tgz$') { + return $InstallSpec + } + + try { + return (Resolve-LocalNpmPackagePath -PackagePath $InstallSpec) + } catch { + return $InstallSpec + } +} + function Resolve-NpmOpenClawInstallSpec { param( [string]$PackageName, @@ -795,7 +888,7 @@ function Resolve-NpmOpenClawInstallSpec { $trimmedTag -match '^\.\.?[\\/]' -or $trimmedTag -match '\.tgz($|[?#])' ) { - return $trimmedTag + return (Resolve-LocalNpmPackageInstallSpec -InstallSpec $trimmedTag) } return "$PackageName@$trimmedTag" @@ -852,9 +945,9 @@ function Install-OpenClaw { $installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow $freshnessArgs = @("--min-release-age=0") - $minReleaseAge = (& (Get-NpmCommandPath) config get min-release-age 2>$null) + $minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age") 2>$null) if ($LASTEXITCODE -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") { - $beforeValue = (& (Get-NpmCommandPath) config get before 2>$null) + $beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before") 2>$null) if ($LASTEXITCODE -eq 0 -and $beforeValue -and $beforeValue.Trim() -ne "null" -and $beforeValue.Trim() -ne "undefined") { $freshnessArgs = @("--before=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))") } @@ -876,7 +969,7 @@ function Install-OpenClaw { Remove-Item Env:NPM_CONFIG_BEFORE -ErrorAction SilentlyContinue Remove-Item Env:NPM_CONFIG_MIN_RELEASE_AGE -ErrorAction SilentlyContinue try { - $npmOutput = & (Get-NpmCommandPath) install -g @freshnessArgs "$installSpec" 2>&1 + $npmOutput = Invoke-NpmCommand -Arguments (@("install", "-g") + $freshnessArgs + @("$installSpec")) 2>&1 if ($LASTEXITCODE -ne 0) { Write-Host "[!] npm install failed" -ForegroundColor Red if ($npmOutput -match "spawn git" -or $npmOutput -match "ENOENT.*git") { @@ -1104,7 +1197,7 @@ function Main { try { $npmCommand = Get-NpmCommandPath if ($npmCommand) { - & $npmCommand uninstall -g openclaw 2>$null | Out-Null + Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw") 2>$null | Out-Null Write-Host "[OK] Removed npm global install if present" -ForegroundColor Green } } catch { } @@ -1144,7 +1237,7 @@ function Main { } if (-not $installedVersion) { try { - $npmList = & (Get-NpmCommandPath) list -g --depth 0 --json 2>$null | ConvertFrom-Json + $npmList = Invoke-NpmCommand -Arguments @("list", "-g", "--depth", "0", "--json") 2>$null | ConvertFrom-Json if ($npmList -and $npmList.dependencies -and $npmList.dependencies.openclaw -and $npmList.dependencies.openclaw.version) { $installedVersion = $npmList.dependencies.openclaw.version } diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index c5cc4b9ff12..a3c93a22701 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -84,7 +84,7 @@ describe("install.ps1 failure handling", () => { it("runs npm install through the resolved command with quiet CI defaults", () => { const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw"); - expect(npmInstallBody).toContain("$npmOutput = & (Get-NpmCommandPath) install -g"); + expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments"); expect(npmInstallBody).toContain('$env:NPM_CONFIG_LOGLEVEL = "error"'); expect(npmInstallBody).toContain('$env:NPM_CONFIG_UPDATE_NOTIFIER = "false"'); expect(npmInstallBody).toContain('$env:NPM_CONFIG_FUND = "false"'); @@ -95,7 +95,10 @@ describe("install.ps1 failure handling", () => { expect(npmInstallBody).toContain("Remove-Item Env:NPM_CONFIG_MIN_RELEASE_AGE"); expect(npmInstallBody).toContain('$env:NODE_LLAMA_CPP_SKIP_DOWNLOAD = "1"'); expect(npmInstallBody).toContain( - '$npmOutput = & (Get-NpmCommandPath) install -g @freshnessArgs "$installSpec"', + [ + "$npmOutput = Invoke-NpmCommand -Arguments", + '(@("install", "-g") + $freshnessArgs + @("$installSpec"))', + ].join(" "), ); expect(npmInstallBody).toContain("$env:NPM_CONFIG_LOGLEVEL = $prevLogLevel"); expect(npmInstallBody).toContain("$env:NPM_CONFIG_BEFORE = $prevBefore"); @@ -104,6 +107,35 @@ describe("install.ps1 failure handling", () => { ); }); + it("runs Windows command shims from a Windows-local cwd", () => { + const commandSafeBody = extractFunctionBody(source, "Invoke-CommandFromWindowsSafeDirectory"); + const npmCommandBody = extractFunctionBody(source, "Invoke-NpmCommand"); + const corepackCommandBody = extractFunctionBody(source, "Invoke-CorepackCommand"); + const openClawPathBody = extractFunctionBody(source, "Ensure-OpenClawOnPath"); + const ensurePnpmBody = extractFunctionBody(source, "Ensure-Pnpm"); + const mainBody = extractFunctionBody(source, "Main"); + + expect(commandSafeBody).toContain("Get-WindowsCommandSafeDirectory"); + expect(commandSafeBody).toContain("Push-Location -LiteralPath $safeDir"); + expect(commandSafeBody).toContain("& $CommandPath @Arguments"); + expect(commandSafeBody).toContain("Pop-Location"); + expect(npmCommandBody).toContain("Invoke-CommandFromWindowsSafeDirectory"); + expect(corepackCommandBody).toContain("Invoke-CommandFromWindowsSafeDirectory"); + expect(openClawPathBody).toContain( + 'Invoke-NpmCommand -Arguments @("config", "get", "prefix")', + ); + expect(ensurePnpmBody).toContain( + 'Invoke-CorepackCommand -Arguments @("prepare", $pnpmSpec, "--activate")', + ); + expect(ensurePnpmBody).toContain('Invoke-NpmCommand -Arguments @("install", "-g", $pnpmSpec)'); + expect(mainBody).toContain( + 'Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw")', + ); + expect(mainBody).toContain( + 'Invoke-NpmCommand -Arguments @("list", "-g", "--depth", "0", "--json")', + ); + }); + it("rejects OpenClaw GitHub source targets for npm installs", () => { const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw"); const sourceTargetBody = extractFunctionBody(source, "Test-OpenClawSourcePackageInstallSpec"); @@ -114,6 +146,23 @@ describe("install.ps1 failure handling", () => { expect(npmInstallBody).toContain("-InstallMethod git -Tag main"); }); + it("preserves caller-relative local tarball install specs before safe-cwd npm calls", () => { + const resolveSpecBody = extractFunctionBody(source, "Resolve-NpmOpenClawInstallSpec"); + const localSpecBody = extractFunctionBody(source, "Resolve-LocalNpmPackageInstallSpec"); + const localPathBody = extractFunctionBody(source, "Resolve-LocalNpmPackagePath"); + + expect(resolveSpecBody).toContain( + "Resolve-LocalNpmPackageInstallSpec -InstallSpec $trimmedTag", + ); + expect(localSpecBody).toContain("$InstallSpec -match '^file:(?.+)$'"); + expect(localSpecBody).toContain("Resolve-LocalNpmPackagePath -PackagePath $filePath"); + expect(localSpecBody).toContain(").AbsoluteUri"); + expect(localSpecBody).toContain("$InstallSpec -notmatch '^\\.\\.?[\\\\/]'"); + expect(localSpecBody).toContain("$InstallSpec -notmatch '\\.tgz$'"); + expect(localPathBody).toContain("Resolve-Path -LiteralPath $PackagePath"); + expect(localPathBody).toContain("[System.IO.Path]::GetFullPath($PackagePath)"); + }); + it("falls back to a user-local portable Node.js bootstrap when package managers are absent", () => { const installNodeBody = extractFunctionBody(source, "Install-Node"); const portableNodeBody = extractFunctionBody(source, "Install-PortableNode"); @@ -188,8 +237,10 @@ describe("install.ps1 failure handling", () => { expect(ensurePnpmBody).toContain( "Test-PnpmCommandMatchesVersion -PnpmVersion $pnpmVersion -RepoDir $RepoDir", ); - expect(ensurePnpmBody).toContain("& $corepackCommand prepare $pnpmSpec --activate"); - expect(ensurePnpmBody).toContain("& (Get-NpmCommandPath) install -g $pnpmSpec"); + expect(ensurePnpmBody).toContain( + 'Invoke-CorepackCommand -Arguments @("prepare", $pnpmSpec, "--activate")', + ); + expect(ensurePnpmBody).toContain('Invoke-NpmCommand -Arguments @("install", "-g", $pnpmSpec)'); expect(gitInstallBody.indexOf("git clone $repoUrl $RepoDir")).toBeLessThan( gitInstallBody.indexOf("Ensure-Pnpm -RepoDir $RepoDir"), );