# +Ai Node installer - Windows. # # irm https://node.add.ai/install.ps1 | iex # # With options, invoke it as a script block so arguments reach it: # # & ([scriptblock]::Create((irm https://node.add.ai/install.ps1))) --code ABCDE --autostart # # or set them in the environment first, which plain `| iex` also honours: # # $env:AINODE_CODE = 'ABCDE'; $env:AINODE_AUTOSTART = '1' # irm https://node.add.ai/install.ps1 | iex # # Everything lands under %USERPROFILE%\.ainode, including a private Node # runtime and a private npm prefix, so the machine's own Node and global # packages are untouched and nothing needs administrator rights. # # Re-running this is the upgrade path. Every step is idempotent. # # The whole body is functions with the call on the last line, so a download # truncated mid-flight defines some functions and then reaches the end without # ever invoking one. Written for Windows PowerShell 5.1: no ternaries, no # null-coalescing, no pipeline chain operators. $ErrorActionPreference = 'Stop' # Invoke-WebRequest spends most of a large download repainting a progress bar. $ProgressPreference = 'SilentlyContinue' # PowerShell 5.1 still defaults to TLS 1.0 on some builds; nodejs.org refuses it. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $script:BaseUrl = if ($env:AINODE_INSTALL_BASE) { $env:AINODE_INSTALL_BASE } else { 'https://node.add.ai' } $script:AinodeHome = if ($env:AINODE_HOME) { $env:AINODE_HOME } else { Join-Path $env:USERPROFILE '.ainode' } $script:NodeDir = Join-Path $script:AinodeHome 'node' $script:ToolsDir = Join-Path $script:AinodeHome 'tools' $script:LogFile = Join-Path $script:AinodeHome 'install.log' $script:DaemonLog = Join-Path $script:AinodeHome 'daemon.log' $script:Launcher = Join-Path $script:AinodeHome 'bin\ainode.cmd' $script:Json = $false $script:ShowHelp = $false $script:PairCode = '' $script:Autostart = $false $script:DoStart = $true $script:PkgPin = '' $script:Arch = '' $script:NodeVersion = '' $script:NodeUrl = '' $script:NodeArchive = '' $script:NodeSha256 = '' $script:PkgVersion = 'latest' $script:NodeBin = '' $script:CliJs = '' $script:RestartAfter = $false $script:Started = $false $script:CurrentStep = 'install' $script:CurrentPct = 0 # -- reporting --------------------------------------------------------------- # One report stream on stdout whose format the caller picks. Diagnostics and # tool output go to the log or the error stream, so a caller parsing stdout # never has to filter anything out of it. function Write-Step { param([string]$Step, [string]$State, [int]$Pct, [string]$Msg) $script:CurrentStep = $Step $script:CurrentPct = $Pct if ($script:Json) { $payload = [ordered]@{ step = $Step; state = $State; pct = $Pct; msg = $Msg } Write-Output ($payload | ConvertTo-Json -Compress) } else { switch ($State) { 'error' { Write-Output "[!!] $Msg" } 'skip' { Write-Output "[--] $Msg" } 'done' { Write-Output "[ok] $Msg" } default { Write-Output "[..] $Msg" } } } } function Stop-WithError { param([string]$Msg) Write-Step $script:CurrentStep 'error' $script:CurrentPct $Msg # `throw`, not `exit`: this script is normally running inside the user's own # session via iex, and taking their shell down with us would be rude. throw $Msg } # Start-Process -ArgumentList joins its array with spaces and quotes nothing, # so a user called "John Smith" would have every path argument split in two. function Format-Arg { param([string]$Value) if ($Value -match '\s') { return '"' + $Value + '"' } return $Value } # Run a native command, append both its streams to the install log, and return # its exit code (with stdout, when asked for). # # Start-Process rather than `&` with a *>> redirection, and this is not a style # preference. In Windows PowerShell 5.1, redirecting a native command's stderr # wraps every line in a NativeCommandError; with $ErrorActionPreference = 'Stop' # that turns a single npm warning on stderr into a terminating error and aborts # an install that in fact succeeded. Found by running this on a machine whose # npmrc makes npm print one. function Start-Logged { param([string]$File, [string[]]$Arguments, [switch]$Capture) $outFile = [System.IO.Path]::GetTempFileName() $errFile = [System.IO.Path]::GetTempFileName() try { $quoted = @($Arguments | ForEach-Object { Format-Arg $_ }) $proc = Start-Process -FilePath $File -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` -RedirectStandardOutput $outFile -RedirectStandardError $errFile $stdout = '' try { $stdout = [System.IO.File]::ReadAllText($outFile) } catch { } $stderr = '' try { $stderr = [System.IO.File]::ReadAllText($errFile) } catch { } foreach ($chunk in @($stdout, $stderr)) { if ($chunk) { Add-Content -Path $script:LogFile -Value $chunk -Encoding UTF8 } } $result = [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = '' } if ($Capture) { $result.Output = $stdout.Trim() } return $result } finally { Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue } } # -- steps ------------------------------------------------------------------- function Read-Options { param([string[]]$Arguments) if ($env:AINODE_CODE) { $script:PairCode = $env:AINODE_CODE } if ($env:AINODE_AUTOSTART) { $script:Autostart = $true } if ($env:AINODE_JSON) { $script:Json = $true } if ($env:AINODE_NO_START) { $script:DoStart = $false } if ($env:AINODE_PKG_VERSION) { $script:PkgPin = $env:AINODE_PKG_VERSION } for ($i = 0; $i -lt $Arguments.Count; $i++) { switch -Regex ($Arguments[$i]) { '^--json$' { $script:Json = $true } '^--autostart$' { $script:Autostart = $true } '^--no-start$' { $script:DoStart = $false } '^--code$' { $i++; $script:PairCode = $Arguments[$i] } '^--code=(.+)$' { $script:PairCode = $Matches[1] } '^--pkg-version$' { $i++; $script:PkgPin = $Arguments[$i] } '^--pkg-version=(.+)$' { $script:PkgPin = $Matches[1] } '^(-h|--help)$' { $script:ShowHelp = $true; return } default { Stop-WithError "unknown option: $($Arguments[$i]) (try --help)" } } } # Both of these end up as arguments to a spawned process, so they are # validated rather than trusted. The version rule is the same one # self-update.ts enforces on a remote roll: a version or a dist-tag and # nothing else, because npm also accepts tarball URLs, git refs and file # paths after the @. if ($script:PkgPin -and $script:PkgPin -notmatch '^(\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?|[a-z][a-z0-9-]{0,31})$') { Stop-WithError "--pkg-version must be a version or a dist-tag, not '$($script:PkgPin)'" } if ($script:PairCode -and $script:PairCode -notmatch '^[A-Za-z0-9]{4,12}$') { Stop-WithError "--code should be the short code from the connect page, not '$($script:PairCode)'" } } function Write-Usage { Write-Output @' +Ai Node installer irm https://node.add.ai/install.ps1 | iex & ([scriptblock]::Create((irm https://node.add.ai/install.ps1))) --code ABCDE Options --code CODE connect to +Ai with a code from the connect page --autostart start this node whenever you log in --no-start install only; do not launch anything --json emit NDJSON progress on stdout instead of prose --pkg-version SPEC install a specific @addai/node version (default: latest) Environment equivalents, for the plain `| iex` form: AINODE_CODE, AINODE_AUTOSTART, AINODE_NO_START, AINODE_JSON, AINODE_PKG_VERSION Everything is installed under %USERPROFILE%\.ainode and nothing needs admin. '@ } function Get-Platform { Write-Step 'detect' 'start' 2 'Checking this machine' # Under a 32-bit host process on 64-bit Windows the first variable lies; # PROCESSOR_ARCHITEW6432 is the one that tells the truth. $raw = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } switch ($raw) { 'AMD64' { $script:Arch = 'x64' } 'ARM64' { $script:Arch = 'arm64' } default { Stop-WithError "unsupported CPU architecture: $raw" } } Write-Step 'detect' 'done' 5 "win/$($script:Arch)" } function Read-Manifest { Write-Step 'manifest' 'start' 6 'Asking node.add.ai which Node to install' $url = "$($script:BaseUrl)/manifest.json?os=win&arch=$($script:Arch)" try { $m = Invoke-RestMethod -Uri $url -UseBasicParsing } catch { # For our own endpoints the response body carries the reason - an arch # nodejs.org does not publish for the current LTS, say - which beats the # bare status line the exception message gives. $reason = '' try { $reason = $_.ErrorDetails.Message } catch { } if ($reason) { Stop-WithError (($reason -split "`n")[0].Trim()) } Stop-WithError "could not reach $url : $($_.Exception.Message)" } $script:NodeVersion = $m.node.version $script:NodeUrl = $m.node.url $script:NodeArchive = $m.node.archive $script:NodeSha256 = $m.node.sha256 $script:PkgVersion = $m.package.version if ($script:PkgPin) { $script:PkgVersion = $script:PkgPin } if (-not $script:NodeVersion) { Stop-WithError 'the manifest carried no Node version' } if (-not $script:NodeSha256) { Stop-WithError 'the manifest carried no checksum' } Write-Step 'manifest' 'done' 9 "Node $($script:NodeVersion.TrimStart('v')) (current LTS)" } # Windows' 260-character path limit is reached more easily here than it looks: # npm ships its own documentation, which sits ~90 characters below the Node # root, and extraction happens one temp level deeper again. A long user name, a # redirected profile or an AINODE_HOME anywhere but the default is enough to # cross it, and the failure reads as "could not find a part of the path", which # points at nothing. # # The \\?\ prefix opts a path out of the limit for the .NET file APIs. UNC # paths spell it differently, and a path that already carries it is left alone. function Get-ExtendedPath { param([string]$Path) $full = [System.IO.Path]::GetFullPath($Path) if ($full.StartsWith('\\?\')) { return $full } if ($full.StartsWith('\\')) { return '\\?\UNC\' + $full.Substring(2) } if ($full -match '^[A-Za-z]:\\') { return '\\?\' + $full } return $full } # Unpack a zip one entry at a time, writing through \\?\ paths. # # ZipFile::ExtractToDirectory cannot be used: handed a plain path it re-imposes # MAX_PATH, and handed a \\?\ one it rejects it outright ("the filename, # directory name, or volume label syntax is incorrect"). Doing it by hand is # twenty lines and puts every path under our control. # # Note the separator swap. \\?\ turns OFF path normalisation, which means a # forward slash stops being a separator - and zip entry names use forward # slashes exclusively. function Expand-ZipLongPath { param([string]$ZipPath, [string]$Destination, [int]$PctFrom, [int]$PctTo) Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) try { $root = [System.IO.Path]::GetFullPath($Destination) $total = $archive.Entries.Count $done = 0 $lastPct = $PctFrom foreach ($entry in $archive.Entries) { $done++ # Directory entries carry a trailing slash and no content. if (-not $entry.Name) { continue } $rel = $entry.FullName.Replace('/', '\') $target = [System.IO.Path]::Combine($root, $rel) # A zip is untrusted input even from nodejs.org: an entry named ..\..\x # would otherwise write outside the staging directory. if (-not [System.IO.Path]::GetFullPath($target).StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { Stop-WithError "archive entry escapes the target directory: $($entry.FullName)" } $dir = [System.IO.Path]::GetDirectoryName($target) [void][System.IO.Directory]::CreateDirectory((Get-ExtendedPath $dir)) $out = [System.IO.File]::Create((Get-ExtendedPath $target)) try { $stream = $entry.Open() try { $stream.CopyTo($out) } finally { $stream.Dispose() } } finally { $out.Dispose() } # Node ships thousands of files; unpacking is long enough to be worth # reporting rather than leaving a UI on one frozen caption. $pct = $PctFrom + [int](($PctTo - $PctFrom) * $done / $total) if ($pct -gt $lastPct) { $lastPct = $pct Write-Step 'node' 'progress' $pct "Unpacking ($done of $total)" } } } finally { $archive.Dispose() } } # Windows cannot move or delete a directory holding a running executable, so a # Node upgrade has to stand the daemon down first. POSIX gets this for free. function Stop-RunningNode { $lock = Join-Path $script:AinodeHome 'runtime.json' if (-not (Test-Path $lock)) { return } try { $body = Get-Content -Raw -Path $lock | ConvertFrom-Json } catch { return } if (-not $body.pid) { return } $proc = Get-Process -Id $body.pid -ErrorAction SilentlyContinue if ($null -eq $proc) { return } Write-Step 'node' 'progress' 12 'Stopping the running node so its runtime can be replaced' Stop-Process -Id $body.pid -Force -ErrorAction SilentlyContinue try { $proc.WaitForExit(15000) | Out-Null } catch { } $script:RestartAfter = $true } function Install-NodeRuntime { $script:NodeBin = Join-Path $script:NodeDir 'node.exe' $current = '' if (Test-Path $script:NodeBin) { try { $current = (& $script:NodeBin -v 2>$null | Out-String).Trim() } catch { $current = '' } } if ($current -eq $script:NodeVersion) { Write-Step 'node' 'skip' 55 "Node $($script:NodeVersion.TrimStart('v')) already installed" return } Write-Step 'node' 'start' 10 "Installing Node $($script:NodeVersion.TrimStart('v'))" Stop-RunningNode $tmp = Join-Path $script:AinodeHome ".install.$PID" if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } New-Item -ItemType Directory -Force -Path $tmp | Out-Null $zip = Join-Path $tmp $script:NodeArchive try { Invoke-WebRequest -Uri $script:NodeUrl -OutFile $zip -UseBasicParsing } catch { Stop-WithError "could not download $($script:NodeUrl) : $($_.Exception.Message)" } Write-Step 'node' 'progress' 35 'Verifying download' $got = (Get-FileHash -Path $zip -Algorithm SHA256).Hash.ToLower() if ($got -ne $script:NodeSha256.ToLower()) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue Stop-WithError "checksum mismatch for $($script:NodeArchive) - refusing to install" } Write-Step 'node' 'progress' 46 'Unpacking' try { Expand-ZipLongPath -ZipPath $zip -Destination $tmp -PctFrom 46 -PctTo 54 } catch { Stop-WithError "could not unpack $($script:NodeArchive) : $($_.Exception.Message)" } $inner = Get-ChildItem -Path $tmp -Directory -Filter 'node-v*' | Select-Object -First 1 if ($null -eq $inner) { Stop-WithError "unexpected archive layout in $($script:NodeArchive)" } # Swap rather than overwrite: an interrupted unpack must never leave a # half-written runtime where a working one used to be. $old = "$($script:NodeDir).old" if (Test-Path $old) { Remove-Item -Recurse -Force $old } if (Test-Path $script:NodeDir) { Move-Item -Path $script:NodeDir -Destination $old } Move-Item -Path $inner.FullName -Destination $script:NodeDir Remove-Item -Recurse -Force $old, $tmp -ErrorAction SilentlyContinue if (-not (Test-Path $script:NodeBin)) { Stop-WithError "Node did not land at $($script:NodeBin)" } Write-Step 'node' 'done' 55 "Node $($script:NodeVersion.TrimStart('v')) ready" } # npm's BUILTIN config - the lowest-precedence file, and the only one every # later `npm i -g` is guaranteed to read, including the ones the daemon runs # itself for self-update and for installing harness CLIs. Without it those # would land inside the Node dir and be thrown away by the next upgrade. function Set-NpmPrefix { $npmrc = Join-Path $script:NodeDir 'node_modules\npm\npmrc' New-Item -ItemType Directory -Force -Path (Split-Path -Parent $npmrc) | Out-Null # Forward slashes: npm reads this as an ini file, where a backslash escapes. $value = $script:ToolsDir -replace '\\', '/' Set-Content -Path $npmrc -Value "prefix=$value" -Encoding ASCII } function Install-Package { Write-Step 'package' 'start' 58 "Installing @addai/node@$($script:PkgVersion)" $npmCli = Join-Path $script:NodeDir 'node_modules\npm\bin\npm-cli.js' if (-not (Test-Path $npmCli)) { Stop-WithError "the Node we installed has no npm at $npmCli" } New-Item -ItemType Directory -Force -Path $script:ToolsDir | Out-Null # Run npm as `node npm-cli.js`, never through npm.cmd: Node refuses to spawn # a .cmd without a shell, and there is deliberately no node on PATH here. # # The user's own ~/.npmrc is deliberately still in play. Registry mirrors, # proxies and auth tokens live there, and ignoring it would break every # corporate machine to tidy up a rare one. node-pty ships prebuilds in its # tarball, so an npmrc that blocks install scripts costs nothing here, and # the daemon repairs the one script that matters at startup anyway. $npm = Start-Logged -File $script:NodeBin -Arguments @( $npmCli, 'install', '-g', '--prefix', $script:ToolsDir, "@addai/node@$($script:PkgVersion)" ) if ($npm.ExitCode -ne 0) { Stop-WithError "npm could not install @addai/node - see $($script:LogFile)" } $script:CliJs = Join-Path $script:ToolsDir 'node_modules\@addai\node\dist\cli.js' if (-not (Test-Path $script:CliJs)) { Stop-WithError "@addai/node installed but $($script:CliJs) is missing" } $version = (Invoke-Node @('version') -Capture).Output if (-not $version) { $version = $script:PkgVersion } Write-Step 'package' 'done' 82 "+Ai Node $version installed" } # Every invocation of the node goes through here, so the PATH it inherits is # decided in one place. ToolsDir is on it because that is where the daemon # installs harness CLIs and its discovery probes shell out to `where`; NodeDir # because the daemon shells out to npm. function Invoke-Node { param([string[]]$NodeArgs, [switch]$Capture) $saved = $env:PATH try { $env:PATH = "$($script:NodeDir);$($script:ToolsDir);$saved" return Start-Logged -File $script:NodeBin -Arguments (@($script:CliJs) + $NodeArgs) -Capture:$Capture } finally { $env:PATH = $saved } } function Start-NodeDetached { param([string[]]$NodeArgs) $saved = $env:PATH $env:PATH = "$($script:NodeDir);$($script:ToolsDir);$saved" $env:AINODE_NO_TUI = '1' try { Start-Process -FilePath $script:NodeBin ` -ArgumentList (@($script:CliJs) + $NodeArgs) ` -WindowStyle Hidden ` -RedirectStandardOutput $script:DaemonLog ` -RedirectStandardError "$($script:DaemonLog).err" | Out-Null $script:Started = $true } finally { $env:PATH = $saved } } function Write-Launcher { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $script:Launcher) | Out-Null $body = @" @echo off rem Written by the +Ai Node installer. Runs the node with the PATH it needs. set "PATH=$($script:NodeDir);$($script:ToolsDir);%PATH%" "$($script:NodeBin)" "$($script:CliJs)" %* "@ Set-Content -Path $script:Launcher -Value $body -Encoding ASCII } function Connect-Node { $state = Join-Path $script:AinodeHome 'state.json' if (Test-Path $state) { Write-Step 'pair' 'skip' 90 'Already connected to +Ai' return } if (-not $script:PairCode) { Write-Step 'pair' 'skip' 90 'Not connected yet' return } Write-Step 'pair' 'start' 84 'Connecting this machine to +Ai' # `ainode entities ` pairs and then BECOMES the running node, so it is # started detached and watched for its state file rather than waited on. Start-NodeDetached @('entities', $script:PairCode) for ($i = 0; $i -lt 90; $i++) { if (Test-Path $state) { Write-Step 'pair' 'done' 90 'Connected' return } Start-Sleep -Seconds 1 } Stop-WithError "connecting timed out - the code may have expired. See $($script:DaemonLog)" } function Enable-Autostart { if (-not $script:Autostart) { Write-Step 'autostart' 'skip' 96 'Not starting at login - pass --autostart to change that' return } Write-Step 'autostart' 'start' 92 'Registering this node to start when you log in' # Unlike launchd, Task Scheduler is not a live supervisor: the logon trigger # has already fired, and the five-minute repeat runs `run --ensure`, which # exits when a node is already up. So there is no need to stand the running # daemon down first the way the POSIX installer does. # A non-zero exit from a native command does NOT throw in PowerShell, so the # exit code has to be read explicitly - a try/catch alone would report every # failed registration as a success. $failed = $false try { if ((Invoke-Node @('startup', 'enable')).ExitCode -ne 0) { $failed = $true } } catch { $failed = $true } if ($failed) { Write-Step 'autostart' 'error' 96 "could not register the logon task - see $($script:LogFile)" } else { Write-Step 'autostart' 'done' 96 'This node now starts when you log in' } } function Start-IfWanted { if (-not $script:DoStart) { return } if ($script:Started) { return } if (-not (Test-Path (Join-Path $script:AinodeHome 'state.json'))) { return } Start-NodeDetached @('run') } function Write-Summary { Write-Step 'done' 'done' 100 'Installed' if ($script:Json) { return } Write-Output '' Write-Output " node runtime $($script:NodeDir)" Write-Output " +Ai Node $($script:ToolsDir)" Write-Output " command $($script:Launcher)" Write-Output " logs $($script:DaemonLog)" Write-Output '' if (-not (Test-Path (Join-Path $script:AinodeHome 'state.json'))) { Write-Output ' Connect this machine to +Ai:' Write-Output '' Write-Output " $($script:Launcher)" Write-Output '' } } function Invoke-Main { param([string[]]$Arguments) # Read-Options is called as a statement and signals through $script: flags. # # It used to return $true/$false and be tested with `if (-not (...))`, which # was quietly catastrophic: in PowerShell every uncaptured write inside a # function becomes part of its return value, so the usage text Write-Usage # produced was swallowed into that boolean. The array it returned was # truthy, `-not` made it false, and `--help` silently performed a complete # install instead of printing anything. Nothing about the code looked wrong. Read-Options -Arguments $Arguments if ($script:ShowHelp) { Write-Usage; return } New-Item -ItemType Directory -Force -Path $script:AinodeHome | Out-Null Get-Platform Read-Manifest Install-NodeRuntime Set-NpmPrefix Install-Package Write-Launcher Connect-Node Enable-Autostart # A Node upgrade stood the running node down; put it back up even if the # caller did not ask for anything else to start. if ($script:RestartAfter -and -not $script:Started) { Start-IfWanted } Start-IfWanted Write-Summary } Invoke-Main -Arguments $args