# Matrix light-client installer for Windows (C9). ONE copy-paste from the web: # # irm /matrix.ps1 | iex; matrix-install -Token XXXX -Cloud # # (the script DEFINES the matrix-install function — iex loads it, then you # # call it; saved-file usage also works: .\matrix.ps1 -Token ... -Cloud ...) # # Mirrors scripts/install.sh: user-level Node >=22.5 (downloaded when missing), # bundle to ~\.matrix\versions\, 'current' junction, matrix.cmd launcher on # the USER PATH, then hands the one-time token to `matrix onboard`. # NO admin rights, loud failures. # # ⚠ 诚实标注: SQLite WAL on NTFS is functionally supported but NOT yet load- # verified by us on a real Windows machine (master plan §7.2 P2 item) — treat # the first Windows deployment as a validation run. param( [string]$Token, [string]$Cloud, [switch]$Headless, [switch]$Server ) function Enter-MatrixInstallLock { param( [Parameter(Mandatory = $true)][string]$MatrixHome, [Parameter(Mandatory = $true)][string]$Identity ) $lockPath = Join-Path $MatrixHome '.install.lock' $stream = $null try { $stream = [System.IO.FileStream]::new( $lockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) } catch [System.IO.IOException] { throw "another Matrix installer is already running for $MatrixHome" } try { $item = Get-Item -LiteralPath $lockPath -Force -ErrorAction Stop if ($item.PSIsContainer -or (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "Matrix install lock is not a regular file: $lockPath" } $bytes = [System.Text.Encoding]::UTF8.GetBytes("$Identity`n") $stream.SetLength(0) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) return $stream } catch { if ($null -ne $stream) { $stream.Dispose() } throw } } function Exit-MatrixInstallLock { param([System.IO.FileStream]$Stream) if ($null -ne $Stream) { $Stream.Dispose() } } function Test-MatrixInstallIdentity { param( [Parameter(Mandatory = $true)][string]$Directory, [Parameter(Mandatory = $true)][string]$Identity ) try { $item = Get-Item -LiteralPath $Directory -Force -ErrorAction Stop if (-not $item.PSIsContainer -or (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) { return $false } $marker = Join-Path $Directory '.matrix-install-identity' return ((Get-Content -LiteralPath $marker -Raw -ErrorAction Stop).Trim() -eq $Identity) } catch { return $false } } function matrix-install { param( [Parameter(Mandatory = $true)][string]$Token, [Parameter(Mandatory = $true)][string]$Cloud, [switch]$Headless, [switch]$Server ) $ErrorActionPreference = 'Stop' function Fail($msg) { Write-Error "[matrix-install] $msg"; exit 1 } function Log($msg) { Write-Host "[matrix-install] $msg" } function Test-ReparsePoint($item) { return (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) } function Assert-RegularDirectory($dir, $label) { if (-not (Test-Path -LiteralPath $dir)) { return } $item = Get-Item -LiteralPath $dir -Force -ErrorAction Stop if (-not $item.PSIsContainer -or (Test-ReparsePoint $item)) { throw "$label is not a regular directory: $dir" } } function Remove-PathNoFollow($target) { try { $item = Get-Item -LiteralPath $target -Force -ErrorAction Stop } catch { return } if (Test-ReparsePoint $item) { if ($item.PSIsContainer) { [System.IO.Directory]::Delete($item.FullName) } else { [System.IO.File]::Delete($item.FullName) } return } Remove-Item -LiteralPath $item.FullName -Recurse -Force } function Remove-DirectoryLink($link) { # Delete only the Junction/reparse-point entry. Never recurse through a # previous `current` pointer into the bundle it references. try { Get-Item -LiteralPath $link -Force -ErrorAction Stop | Out-Null } catch { return } try { [System.IO.Directory]::Delete($link) } catch { return } } $Cloud = $Cloud.TrimEnd('/') $MatrixHome = Join-Path $env:USERPROFILE '.matrix' $NodeVersion = if ($env:MATRIX_NODE_VERSION) { $env:MATRIX_NODE_VERSION } else { 'v22.23.1' } if ($NodeVersion -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$') { Fail "MATRIX_NODE_VERSION must match v.. (got: $NodeVersion)" } Assert-RegularDirectory $MatrixHome 'Matrix home' if (-not (Test-Path -LiteralPath $MatrixHome)) { [System.IO.Directory]::CreateDirectory($MatrixHome) | Out-Null } Assert-RegularDirectory $MatrixHome 'Matrix home' $InstallIdentity = [guid]::NewGuid().ToString('n') $InstallLock = $null try { $InstallLock = Enter-MatrixInstallLock -MatrixHome $MatrixHome -Identity $InstallIdentity } catch { Fail $_.Exception.Message } try { # ---- 1. Node runtime (>=22.23) ---------------------------------------------- function Test-NodeOk($bin) { # Parse `node --version` with a PowerShell regex instead of an inline JS `-e` # program: Windows PowerShell 5.1 (Legacy parsing) strips the embedded double # quotes, so Node receives `split(.)` and dies with SyntaxError — misreading a # usable Node as too old. The pinned floor guarantees unflagged node:sqlite. try { $version = & $bin --version if ($version -match 'v(\d+)\.(\d+)') { $major = [int]$Matches[1] $minor = [int]$Matches[2] return (($major -gt 22) -or (($major -eq 22) -and ($minor -ge 23))) } return $false } catch { return $false } } $Node = $null $sys = Get-Command node -ErrorAction SilentlyContinue $bundledNode = Join-Path $MatrixHome 'runtime\node\node.exe' if (Test-NodeOk $bundledNode) { $Node = $bundledNode Log "using existing Matrix Node runtime: $Node" } elseif ($sys -and (Test-NodeOk $sys.Source)) { $Node = $sys.Source Log "using system Node: $Node" } else { $plat = if ([Environment]::Is64BitOperatingSystem) { 'win-x64' } else { Fail 'unsupported: 32-bit Windows' } if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { $plat = 'win-arm64' } $zipName = "node-$NodeVersion-$plat.zip" $rt = Join-Path $MatrixHome 'runtime' New-Item -ItemType Directory -Force -Path $rt | Out-Null Assert-RegularDirectory $rt 'Matrix runtime directory' $tmp = Join-Path $env:TEMP "matrix-node-$([guid]::NewGuid().ToString('n')).zip" $sums = Join-Path $env:TEMP "matrix-node-shasums-$([guid]::NewGuid().ToString('n')).txt" Log "system Node missing or < 22.23 — downloading Node $NodeVersion ($plat, user-level)" Invoke-WebRequest -Uri "https://nodejs.org/dist/$NodeVersion/$zipName" -OutFile $tmp Invoke-WebRequest -Uri "https://nodejs.org/dist/$NodeVersion/SHASUMS256.txt" -OutFile $sums $sumRows = @(Get-Content -LiteralPath $sums | Where-Object { $_ -match "^([A-Fa-f0-9]{64})\s+\*?$([regex]::Escape($zipName))$" }) if ($sumRows.Count -ne 1) { Remove-PathNoFollow $tmp Remove-PathNoFollow $sums Fail "official Node checksum entry is missing or ambiguous for $zipName" } $sumRows[0] -match '^([A-Fa-f0-9]{64})\s+' | Out-Null $expectedNodeSha = $Matches[1].ToLowerInvariant() $actualNodeSha = (Get-FileHash -LiteralPath $tmp -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualNodeSha -ne $expectedNodeSha) { Remove-PathNoFollow $tmp Remove-PathNoFollow $sums Fail "Node archive sha256 mismatch (got $actualNodeSha, expected $expectedNodeSha)" } Remove-PathNoFollow $sums Log 'Node archive sha256 verified against SHASUMS256.txt' $dest = Join-Path $rt 'node' $runtimeId = [guid]::NewGuid().ToString('n') $runtimeStage = Join-Path $rt ".node-stage-$runtimeId" $runtimeNext = Join-Path $rt ".node-next-$runtimeId" $runtimePrev = Join-Path $rt ".node-prev-$runtimeId" $runtimePrevMoved = $false $runtimeNewAtDest = $false try { New-Item -ItemType Directory -Force -Path $runtimeStage | Out-Null Expand-Archive -Path $tmp -DestinationPath $runtimeStage -Force $runtimeCandidate = Join-Path $runtimeStage "node-$NodeVersion-$plat" $runtimeCandidateNode = Join-Path $runtimeCandidate 'node.exe' if (-not (Test-NodeOk $runtimeCandidateNode)) { throw 'downloaded Node candidate failed the >=22.5 check' } Set-Content -LiteralPath (Join-Path $runtimeCandidate '.matrix-install-identity') ` -Value $InstallIdentity -Encoding ascii -NoNewline Move-Item -LiteralPath $runtimeCandidate -Destination $runtimeNext if (Test-Path -LiteralPath $dest) { Assert-RegularDirectory $dest 'existing Matrix Node runtime' Move-Item -LiteralPath $dest -Destination $runtimePrev $runtimePrevMoved = $true } try { Move-Item -LiteralPath $runtimeNext -Destination $dest $runtimeNewAtDest = $true if (-not (Test-NodeOk (Join-Path $dest 'node.exe'))) { throw 'installed Node runtime failed the >=22.5 check' } } catch { if ($runtimeNewAtDest -and (Test-MatrixInstallIdentity -Directory $dest -Identity $InstallIdentity)) { Remove-PathNoFollow $dest } if ($runtimePrevMoved -and -not (Test-Path -LiteralPath $dest)) { Move-Item -LiteralPath $runtimePrev -Destination $dest $runtimePrevMoved = $false } throw } } catch { $runtimeError = $_.Exception.Message if ($runtimePrevMoved -and -not (Test-Path -LiteralPath $dest) -and (Test-Path -LiteralPath $runtimePrev)) { try { Move-Item -LiteralPath $runtimePrev -Destination $dest $runtimePrevMoved = $false } catch { Fail "Node runtime switch failed and the previous runtime could not be restored: $runtimeError" } } foreach ($item in @($runtimeNext, $runtimeStage, $tmp, $sums)) { Remove-PathNoFollow $item } Fail "could not install the verified Node runtime; previous runtime preserved: $runtimeError" } foreach ($item in @($runtimePrev, $runtimeStage, $tmp, $sums)) { Remove-PathNoFollow $item } $runtimeIdentityMarker = Join-Path $dest '.matrix-install-identity' if (Test-MatrixInstallIdentity -Directory $dest -Identity $InstallIdentity) { Remove-PathNoFollow $runtimeIdentityMarker } $Node = Join-Path $dest 'node.exe' if (-not (Test-NodeOk $Node)) { Fail 'downloaded node failed the >=22.5 check' } Log "installed Node runtime at $Node" } # ---- 2. client bundle --------------------------------------------------------- $tmpDir = Join-Path $env:TEMP "matrix-bundle-$([guid]::NewGuid().ToString('n'))" New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null $bundleDst = Join-Path $tmpDir 'bundle.tar.gz' # Offline-first: the Standalone ZIP ships client-bundle.tar.gz next to this # script, so prefer it and skip the network entirely. $PSScriptRoot is empty # under `irm /matrix.ps1 | iex`, so guard before Join-Path. $localBundle = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'client-bundle.tar.gz' } else { $null } $metadata = $null if ($localBundle -and (Test-Path $localBundle)) { $localMetadata = Join-Path $PSScriptRoot 'latest.json' if (-not (Test-Path $localMetadata)) { Fail 'offline bundle is missing latest.json; refusing an unverified install' } try { $metadata = Get-Content $localMetadata -Raw | ConvertFrom-Json } catch { Fail "offline latest.json is invalid: $($_.Exception.Message)" } Copy-Item $localBundle $bundleDst Log "using local client bundle (offline): $localBundle" } else { Log 'downloading client bundle' try { $metadata = Invoke-RestMethod -Uri "$Cloud/latest.json" } catch { Fail "could not download latest.json: $($_.Exception.Message)" } Invoke-WebRequest -Uri "$Cloud/client-bundle.tar.gz" -OutFile $bundleDst } if (-not $metadata.version) { Fail 'latest.json is missing version; refusing an unverified install' } if (-not $metadata.sha256) { Fail 'latest.json is missing sha256; refusing an unverified install' } $metadataVersion = ([string]$metadata.version).Trim() if ($metadataVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$') { Fail "latest.json contains an unsafe version: $metadataVersion" } $actualSha = (Get-FileHash -Path $bundleDst -Algorithm SHA256).Hash.ToLowerInvariant() $expectedSha = ([string]$metadata.sha256).ToLowerInvariant() if ($actualSha -ne $expectedSha) { Fail "client bundle sha256 mismatch (got $actualSha, expected $expectedSha)" } Log 'client bundle sha256 verified' $x = Join-Path $tmpDir 'x' New-Item -ItemType Directory -Force -Path $x | Out-Null tar -xzf (Join-Path $tmpDir 'bundle.tar.gz') -C $x # bsdtar ships with Windows 10+ $ver = $null try { $ver = (Get-Content (Join-Path $x 'VERSION') -Raw).Trim() } catch { Fail 'bad bundle: missing VERSION marker' } if ($ver -notmatch '^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$') { Fail "bad bundle: unsafe VERSION marker $ver" } if ($metadataVersion -ne $ver) { Fail "latest.json version $metadataVersion does not match bundle VERSION $ver" } $packageVersion = $null try { $packageVersion = ([string](Get-Content (Join-Path $x 'package.json') -Raw | ConvertFrom-Json).version).Trim() } catch { Fail 'bad bundle: missing or invalid package.json' } if ($packageVersion -ne $ver) { Fail "bundle package.json version $packageVersion does not match VERSION $ver" } if (-not (Test-Path (Join-Path $x 'services/control-plane/src/server.mjs'))) { Fail 'bad bundle: missing server.mjs' } $cliEntry = Join-Path $x 'workers/matrix-worker/src/cli.mjs' if (-not (Test-Path $cliEntry)) { Fail 'bad bundle: missing workers/matrix-worker/src/cli.mjs' } # Load the exact extracted CLI before deleting/replacing any installed version. # The help boundary needs no credentials or network but imports the full Worker # runtime dependency graph, so a missing npm package fails before current moves. $hadBundleRoot = Test-Path Env:MATRIX_BUNDLE_ROOT $oldBundleRoot = $env:MATRIX_BUNDLE_ROOT try { $env:MATRIX_BUNDLE_ROOT = $x & $Node $cliEntry help | Out-Null $preflightExit = $LASTEXITCODE } finally { if ($hadBundleRoot) { $env:MATRIX_BUNDLE_ROOT = $oldBundleRoot } else { Remove-Item Env:MATRIX_BUNDLE_ROOT -ErrorAction SilentlyContinue } } if ($preflightExit -ne 0) { Fail 'bad bundle: Matrix CLI preflight failed before install' } Log 'client bundle CLI preflight passed' Set-Content -LiteralPath (Join-Path $x '.matrix-install-identity') ` -Value $InstallIdentity -Encoding ascii -NoNewline $versionsDir = Join-Path $MatrixHome 'versions' New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null Assert-RegularDirectory $versionsDir 'Matrix versions directory' $vdir = Join-Path $versionsDir $ver $installId = [guid]::NewGuid().ToString('n') $vnext = Join-Path $versionsDir ".$ver.next-$installId" $vprev = Join-Path $versionsDir ".$ver.prev-$installId" $current = Join-Path $MatrixHome 'current' $currentNext = Join-Path $MatrixHome ".current.next-$installId" $currentPrev = Join-Path $MatrixHome ".current.prev-$installId" $versionPrevMoved = $false $versionNewAtDest = $false $currentPrevMoved = $false try { Move-Item -LiteralPath $x -Destination $vnext if (Test-Path -LiteralPath $vdir) { Assert-RegularDirectory $vdir "existing Matrix version $ver" Move-Item -LiteralPath $vdir -Destination $vprev $versionPrevMoved = $true } Move-Item -LiteralPath $vnext -Destination $vdir $versionNewAtDest = $true # Construct the replacement Junction before moving `current`. All paths are # on the user's Matrix volume, and both the old pointer and old same-version # directory remain available until the final rename succeeds. New-Item -ItemType Junction -Path $currentNext -Target $vdir | Out-Null if (Test-Path -LiteralPath $current) { Move-Item -LiteralPath $current -Destination $currentPrev $currentPrevMoved = $true } Move-Item -LiteralPath $currentNext -Destination $current } catch { $installError = $_.Exception.Message if ($currentPrevMoved -and -not (Test-Path -LiteralPath $current) -and (Test-Path -LiteralPath $currentPrev)) { try { Move-Item -LiteralPath $currentPrev -Destination $current $currentPrevMoved = $false } catch { Fail "bundle switch failed and the previous current Junction could not be restored: $installError" } } if ($versionNewAtDest -and (Test-MatrixInstallIdentity -Directory $vdir -Identity $InstallIdentity)) { Remove-PathNoFollow $vdir } if ($versionPrevMoved -and -not (Test-Path -LiteralPath $vdir) -and (Test-Path -LiteralPath $vprev)) { try { Move-Item -LiteralPath $vprev -Destination $vdir $versionPrevMoved = $false } catch { Fail "bundle switch failed and existing version $ver could not be restored: $installError" } } Remove-DirectoryLink $currentNext foreach ($item in @($vnext, $tmpDir)) { Remove-PathNoFollow $item } Fail "could not switch to verified bundle $ver; previous install preserved: $installError" } Remove-DirectoryLink $currentPrev foreach ($item in @($vprev, $vnext, $tmpDir)) { Remove-PathNoFollow $item } $bundleIdentityMarker = Join-Path $vdir '.matrix-install-identity' if (Test-MatrixInstallIdentity -Directory $vdir -Identity $InstallIdentity) { Remove-PathNoFollow $bundleIdentityMarker } Log "installed bundle -> $vdir (current -> $ver)" # ---- 3. launcher on the user PATH --------------------------------------------- $bin = Join-Path $MatrixHome 'bin' New-Item -ItemType Directory -Force -Path $bin | Out-Null Assert-RegularDirectory $bin 'Matrix launcher directory' $cmd = Join-Path $bin 'matrix.cmd' $launcherPs = Join-Path $bin 'matrix-launch.ps1' $nodeForPs = ([string]$Node).Replace("'", "''") if ($nodeForPs -match '[\r\n]') { Fail 'verified Node path cannot be encoded safely in matrix-launch.ps1' } @" `$ErrorActionPreference = 'Stop' `$matrixNode = '$nodeForPs' `$matrixCli = Join-Path `$env:USERPROFILE '.matrix\current\workers\matrix-worker\src\cli.mjs' `$env:MATRIX_BUNDLE_ROOT = Join-Path `$env:USERPROFILE '.matrix\current' & `$matrixNode `$matrixCli @args exit `$LASTEXITCODE "@ | Set-Content -Path $launcherPs -Encoding utf8 @" @echo off "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.matrix\bin\matrix-launch.ps1" %* "@ | Set-Content -Path $cmd -Encoding ascii $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') if ($userPath -notlike "*$bin*") { [Environment]::SetEnvironmentVariable('Path', "$bin;$userPath", 'User') Log "added $bin to your user PATH (new terminals pick it up)" } $env:Path = "$bin;$env:Path" Log "wrote launchers $cmd and $launcherPs" # ---- 4. onboard ---------------------------------------------------------------- $onboardArgs = @('onboard', '--cloud', $Cloud, '--token-stdin') $shownArgs = @('onboard', '--cloud', $Cloud, '--token', '[REDACTED]') if ($Headless) { $onboardArgs += '--headless' } if ($Server) { $onboardArgs += '--server' } if ($Headless) { $shownArgs += '--headless' } if ($Server) { $shownArgs += '--server' } Log "launching: matrix $($shownArgs -join ' ')" $Token | & $Node (Join-Path $MatrixHome 'current\workers\matrix-worker\src\cli.mjs') @onboardArgs exit $LASTEXITCODE } finally { Exit-MatrixInstallLock -Stream $InstallLock } } # Saved-file usage (.\matrix.ps1 -Token ... -Cloud ...): forward the named # script parameters explicitly. PowerShell array splatting (`@args`) is # positional and does not preserve names such as -Token / -Cloud. # Under `irm | iex` there are no args — the function is simply defined for the # documented second half of the one-liner. if ($PSBoundParameters.Count -gt 0) { if (-not $PSBoundParameters.ContainsKey('Token') -or -not $PSBoundParameters.ContainsKey('Cloud')) { Write-Error '[matrix-install] saved-file usage requires both -Token and -Cloud' exit 2 } $invokeParams = @{ Token = $Token; Cloud = $Cloud } if ($Headless) { $invokeParams.Headless = $true } if ($Server) { $invokeParams.Server = $true } matrix-install @invokeParams }