# 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 ''