# Interlock CLI installer for Windows PowerShell 5.1+ / PowerShell 7. The site prints # irm https://interlock.run/install.ps1 | iex # # That form cannot pass arguments, so when piped this script is NON-INTERACTIVE BY DEFAULT and # installs to $HOME\.interlock. Run it as a file to choose: # .\install.ps1 -Yes -Prefix C:\tools\interlock # or set INTERLOCK_PREFIX / INTERLOCK_BASE_URL in the environment before the irm | iex line. # # What it does, in order, and nothing else: # 1. checks Node >= 22.6 and git are on PATH, and names the missing one if not # 2. downloads /cli/interlock-cli-.tgz (an `npm pack` of the package) # 3. extracts it to \lib and writes shims at \bin\interlock.cmd (+ a sh shim) # 4. runs `interlock doctor --json` and prints the result, so success is proved not assumed # # It writes NOTHING outside . It does not edit your PATH or your profile; it tells you # the line to run. No telemetry, no phone-home: one GET for the tarball and that is all. # # VERIFIED BY EXECUTION on Windows 11 (Windows PowerShell 5.1). Nothing else is claimed. # Deliberately no param() block: a param block at the top of text fed to iex is a parse error # in 5.1, and this file must work both piped and as a file. ASCII only, for the same reason. $ErrorActionPreference = 'Stop' $Version = '0.1.0' $BaseUrl = if ($env:INTERLOCK_BASE_URL) { $env:INTERLOCK_BASE_URL.TrimEnd('/') } else { 'https://interlock.run' } $Prefix = if ($env:INTERLOCK_PREFIX) { $env:INTERLOCK_PREFIX } else { Join-Path $HOME '.interlock' } $Piped = [string]::IsNullOrEmpty($PSCommandPath) $Yes = $Piped # irm | iex cannot answer a prompt, so it does not get one # --- flags, when run as a file --------------------------------------------------------------- $argv = @($args) for ($i = 0; $i -lt $argv.Count; $i++) { switch -Regex ($argv[$i]) { '^-{1,2}[Yy](es)?$' { $Yes = $true } '^-{1,2}[Pp]refix$' { $i++; if ($i -ge $argv.Count) { throw 'install.ps1: -Prefix needs a directory' }; $Prefix = $argv[$i] } '^-{1,2}[Pp]refix=(.+)$' { $Prefix = $Matches[1] } '^-{1,2}[Hh](elp)?$' { Write-Host "usage: install.ps1 [-Yes] [-Prefix ]"; return } default { throw "install.ps1: unknown flag: $($argv[$i])" } } } function Fail([string]$msg) { Write-Host '' Write-Host "install.ps1: $msg" -ForegroundColor Red Write-Host '' throw $msg } # --- 1. prerequisites: Node >= 22.6 and git -------------------------------------------------- $nodeCmd = Get-Command node -ErrorAction SilentlyContinue if (-not $nodeCmd) { Fail 'Node.js is not on PATH. Interlock needs Node >= 22.6 - https://nodejs.org - then run this again.' } $nodeV = (& node -v).Trim().TrimStart('v') $parts = $nodeV.Split('.') $maj = [int]$parts[0]; $min = [int]$parts[1] if ($maj -lt 22 -or ($maj -eq 22 -and $min -lt 6)) { Fail "Node v$nodeV is too old. Interlock needs Node >= 22.6 (Node 20 LTS is not enough). Upgrade at https://nodejs.org and run this again." } $gitCmd = Get-Command git -ErrorAction SilentlyContinue if (-not $gitCmd) { Fail 'git is not on PATH. Interlock needs git - https://git-scm.com - then run this again.' } # Windows' own bsdtar, by full path. A PATH that puts Git's usr\bin first resolves `tar` to GNU # tar, which reads `C:\...` as a remote host ("Cannot connect to C: resolve failed") - seen 2026-08-25. $Tar = Join-Path $env:SystemRoot 'System32\tar.exe' if (-not (Test-Path $Tar)) { $tarCmd = Get-Command tar -ErrorAction SilentlyContinue if (-not $tarCmd) { Fail 'tar.exe is not on PATH (it ships with Windows 10 1803+). It is needed to unpack the package.' } $Tar = $tarCmd.Source } $TgzUrl = "$BaseUrl/cli/interlock-cli-$Version.tgz" Write-Host '' Write-Host " interlock-cli $Version" Write-Host " node v$nodeV ok" Write-Host " git $(& git --version)" Write-Host " from $TgzUrl" Write-Host " to $Prefix" Write-Host '' if (-not $Yes) { $answer = Read-Host ' Proceed? [y/N]' if ($answer -notmatch '^(y|yes)$') { Write-Host ' aborted.'; return } } # --- 2. download ------------------------------------------------------------------------------ New-Item -ItemType Directory -Force -Path (Join-Path $Prefix 'bin') | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Prefix 'tmp') | Out-Null $Prefix = (Resolve-Path $Prefix).Path $Tgz = Join-Path $Prefix "tmp\interlock-cli-$Version.tgz" try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {} try { Invoke-WebRequest -UseBasicParsing -Uri $TgzUrl -OutFile $Tgz } catch { Fail "download failed: $TgzUrl ($($_.Exception.Message))" } if (-not (Test-Path $Tgz) -or (Get-Item $Tgz).Length -eq 0) { Fail "download produced an empty file: $TgzUrl" } # --- 3. extract + shims ----------------------------------------------------------------------- $Lib = Join-Path $Prefix 'lib' if (Test-Path $Lib) { Remove-Item -Recurse -Force $Lib } New-Item -ItemType Directory -Force -Path $Lib | Out-Null & $Tar -xzf $Tgz -C $Lib --strip-components=1 if ($LASTEXITCODE -ne 0) { Fail "could not unpack $Tgz (tar exit $LASTEXITCODE)" } Remove-Item -Force $Tgz Remove-Item -Force (Join-Path $Prefix 'tmp') -ErrorAction SilentlyContinue $Entry = Join-Path $Lib 'bin\interlock.mjs' if (-not (Test-Path $Entry)) { Fail "the package did not contain bin\interlock.mjs - wrong tarball at $TgzUrl?" } $cmdShim = "@echo off`r`nnode `"%~dp0..\lib\bin\interlock.mjs`" %*`r`n" [IO.File]::WriteAllText((Join-Path $Prefix 'bin\interlock.cmd'), $cmdShim, (New-Object Text.UTF8Encoding $false)) $posix = $Prefix -replace '\\', '/' $shShim = "#!/bin/sh`n# interlock-cli $Version shim, written by install.ps1. Delete $posix to uninstall.`nexec node `"$posix/lib/bin/interlock.mjs`" `"`$@`"`n" [IO.File]::WriteAllText((Join-Path $Prefix 'bin\interlock'), $shShim, (New-Object Text.UTF8Encoding $false)) # --- 4. prove it ------------------------------------------------------------------------------- Write-Host " installed to $Prefix" Write-Host '' Write-Host ' self-check:' $doctor = (& node $Entry doctor --json) -join '' if ($LASTEXITCODE -ne 0) { Fail "the installed CLI failed its self-check: $doctor" } Write-Host " $doctor" Write-Host '' Write-Host ' Add it to your PATH for this session (this script does not edit your profile):' Write-Host " `$env:Path = `"$Prefix\bin;`" + `$env:Path" Write-Host ' Or permanently:' Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$Prefix\bin;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" Write-Host '' Write-Host ' Then: interlock check tasks.md' Write-Host ''