@basein/runner 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,669 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Set up the BaseIn runner on Windows in one paste: a Node if needed, the
4
+ @basein/runner package, Claude Code, then `bir setup` for this folder.
5
+
6
+ .DESCRIPTION
7
+ Served by the API as https://api.<domain>/install.ps1 and meant to be run
8
+ from a terminal opened in the project you start Claude Code in:
9
+
10
+ $env:BIR_SETUP_TOKEN="<token>"; irm https://api.<domain>/install.ps1 | iex
11
+
12
+ It never prompts, never calls exit (under iex that would close the window),
13
+ never touches the execution policy, and is safe to re-run: re-running is
14
+ also the upgrade path. Everything lives under ~\.baseinstrunner.
15
+
16
+ Steps, in order (each prints a "==> " line):
17
+
18
+ 0. the folder: the home directory and a drive root are refused before
19
+ anything is downloaded; the setup token is taken out of the
20
+ environment before anything else runs
21
+ 1. Node: a Node >= 20 on PATH is used as long as its real binary is not
22
+ inside a version manager (fnm, nvm, Volta, Scoop, asdf, mise), else a
23
+ private, digest-checked Node goes to ~\.baseinstrunner\node\v<V>\ and
24
+ is never put on PATH
25
+ 2. the runner: @basein/runner is installed into ~\.baseinstrunner\npm,
26
+ always through npm-cli.js (never the npm shim); bir.cmd and
27
+ bir-hooks.cmd are written to ~\.baseinstrunner\bin, which goes first
28
+ on the user Path
29
+ 3. Claude Code, through its official installer, when `claude` is missing
30
+ 4. `bir setup --auth-url <api> --project <folder>`: signs in, wires the
31
+ project, starts the recorder, and prints what is left to do
32
+
33
+ The API rewrites two placeholders in this file (the service address and
34
+ the runner version, marked TEMPLATED below). Read raw from the repository
35
+ they count as unset: BIR_AUTH_URL is then required, the version is latest.
36
+
37
+ Environment (all optional):
38
+
39
+ BIR_SETUP_TOKEN one-time setup token from the console; removed from the
40
+ environment at once and handed to `bir setup` only
41
+ BIR_AUTH_URL the API address, when this file was not served by it
42
+ BIR_PROJECT the project folder, instead of the current one
43
+ BIR_NODE_VERSION private Node version instead of the pinned one; its
44
+ digest then comes from nodejs.org's SHASUMS256.txt
45
+ BIR_NODE_ARCHIVE a local node-v<V>-win-<arch>.zip instead of the download
46
+ BIR_TARBALL a local @basein/runner .tgz instead of the registry
47
+ BIR_NO_CLAUDE=1 do not install Claude Code
48
+ BIR_DRY_RUN=1 print every action and perform none: no download, no
49
+ install, no registry write, no `bir setup`. For support:
50
+ it shows what the line would do on a given machine.
51
+ HTTPS_PROXY, HTTP_PROXY, NO_PROXY, NODE_EXTRA_CA_CERTS
52
+ used for the downloads and passed to every child
53
+ #>
54
+
55
+ function Invoke-BirInstall {
56
+ # The token first, before any other statement: copied out of the environment
57
+ # and removed from it, so npm, the Claude Code installer and the `claude` the
58
+ # person starts next never see it. It reaches `bir setup` through that
59
+ # child's environment only, set right before the spawn and removed after.
60
+ $setupToken = $env:BIR_SETUP_TOKEN
61
+ if ($setupToken) { Remove-Item -Path Env:BIR_SETUP_TOKEN -ErrorAction SilentlyContinue }
62
+
63
+ # Inside the function on purpose: under `irm | iex` these would otherwise
64
+ # change the session the person keeps working in.
65
+ Set-StrictMode -Version Latest
66
+ $ErrorActionPreference = 'Stop'
67
+ # Invoke-WebRequest repaints a progress bar per chunk; on a 30 MB zip that
68
+ # takes longer than the download itself.
69
+ $ProgressPreference = 'SilentlyContinue'
70
+
71
+ # -- what the API templated in ----------------------------------------------
72
+ #
73
+ # TEMPLATED: the server rewrites the two values below. The comparison
74
+ # assembles the placeholder name at run time, so the comparison itself is
75
+ # never rewritten.
76
+ $templatedAuthUrl = '__BIR_AUTH_URL__'
77
+ $templatedVersion = '__BIR_VERSION__'
78
+
79
+ $packageName = '@basein/runner'
80
+ $pinnedNodeVersion = '24.21.0'
81
+ # Published digests for the pinned version, the same six as install.sh
82
+ # embeds; only the two Windows ones are used here. Anything else is checked
83
+ # against nodejs.org's SHASUMS256.txt, which is not pinned.
84
+ $pinnedDigests = @{
85
+ 'node-v24.21.0-win-x64.zip' = '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541'
86
+ 'node-v24.21.0-win-arm64.zip' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921'
87
+ 'node-v24.21.0-darwin-arm64.tar.gz' = 'bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057'
88
+ 'node-v24.21.0-darwin-x64.tar.gz' = '1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097'
89
+ 'node-v24.21.0-linux-x64.tar.gz' = '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff'
90
+ 'node-v24.21.0-linux-arm64.tar.gz' = '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5'
91
+ }
92
+
93
+ $dryRun = ($env:BIR_DRY_RUN -eq '1')
94
+ $birHome = Join-Path $env:USERPROFILE '.baseinstrunner'
95
+ $tlsHint = "Your network inspects TLS. Set NODE_EXTRA_CA_CERTS to your company's CA bundle (ask IT) and run this again. Offline: BIR_TARBALL=<file.tgz> and BIR_NODE_ARCHIVE=<archive>."
96
+
97
+ # -- output -----------------------------------------------------------------
98
+
99
+ function Write-Step([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan }
100
+ function Write-Note([string]$Message) { Write-Host " $Message" -ForegroundColor DarkGray }
101
+ function Write-Warn([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow }
102
+ function Write-Would([string]$Message) { Write-Host " [dry-run] would $Message" -ForegroundColor Magenta }
103
+
104
+ # -- helpers ----------------------------------------------------------------
105
+
106
+ # Runs a program and returns its exit code with everything it printed.
107
+ # $ErrorActionPreference is 'Continue' in here on purpose: Windows PowerShell
108
+ # 5.1 turns each redirected stderr line into an ErrorRecord, and under 'Stop'
109
+ # the first one (an "npm warn", say) would end the whole run.
110
+ function Invoke-Tool([string]$Exe, [string[]]$Arguments, [switch]$Quiet) {
111
+ $ErrorActionPreference = 'Continue'
112
+ $lines = New-Object System.Collections.Generic.List[string]
113
+ # Reset first: a program that never started (access denied, a quarantined
114
+ # binary) leaves $LASTEXITCODE at the previous command's value, which
115
+ # after a successful npm is 0 - and 0 would read as "it worked".
116
+ $global:LASTEXITCODE = -1
117
+ & $Exe @Arguments 2>&1 | ForEach-Object {
118
+ if ($_ -is [System.Management.Automation.ErrorRecord]) { $line = $_.Exception.Message } else { $line = [string]$_ }
119
+ $lines.Add($line)
120
+ if (-not $Quiet) { Write-Host $line }
121
+ }
122
+ $code = $global:LASTEXITCODE
123
+ if ($code -eq -1) { $lines.Add("$Exe could not be started."); $code = 127 }
124
+ return @{ Code = $code; Lines = $lines.ToArray() }
125
+ }
126
+
127
+ # The version line among a program's output. stderr is merged into stdout
128
+ # above, so a warning node prints at startup (a NODE_OPTIONS note, an extra
129
+ # certs complaint) can come first; the version is the line that looks like one.
130
+ function Get-VersionLine([string[]]$Lines) {
131
+ foreach ($l in @($Lines)) { if ($l.Trim() -match '^v?\d+\.\d+\.\d+$') { return $l.Trim() } }
132
+ return ''
133
+ }
134
+
135
+ # The first output line that names an existing file - for `node -p process.execPath`.
136
+ function Get-ExistingPathLine([string[]]$Lines) {
137
+ foreach ($l in @($Lines)) {
138
+ $t = $l.Trim()
139
+ if ($t -and (Test-Path -LiteralPath $t -PathType Leaf)) { return $t }
140
+ }
141
+ return ''
142
+ }
143
+
144
+ # A command line as the dry run prints it: arguments with spaces quoted, so
145
+ # the line can be pasted as it is.
146
+ function Format-CommandLine([string]$Exe, [string[]]$Arguments) {
147
+ $parts = New-Object System.Collections.Generic.List[string]
148
+ foreach ($a in @($Exe) + @($Arguments)) {
149
+ if ($a -match '\s') { $parts.Add('"' + $a + '"') } else { $parts.Add($a) }
150
+ }
151
+ return ($parts -join ' ')
152
+ }
153
+
154
+ # Trailing separators trimmed and case folded: "C:\Users\me\" and
155
+ # "c:\users\me" are the same folder.
156
+ function Get-ComparablePath([string]$Path) {
157
+ $trimmed = $Path.TrimEnd('\', '/')
158
+ if ($trimmed -eq '') { $trimmed = $Path }
159
+ return $trimmed.ToLowerInvariant()
160
+ }
161
+
162
+ # "v24.21.0" -> major, minor, and the bare text; $null when it is not a version.
163
+ function Get-NodeVersionParts([string]$Text) {
164
+ if ($Text.Trim() -match '^v?(\d+)\.(\d+)\.(\d+)') {
165
+ return @{ Major = [int]$matches[1]; Minor = [int]$matches[2]; Text = $Text.Trim().TrimStart('v') }
166
+ }
167
+ return $null
168
+ }
169
+
170
+ # --use-system-ca exists from Node 22.15; with it a corporate root in the
171
+ # Windows store is trusted without NODE_EXTRA_CA_CERTS.
172
+ function Get-NodeFlags([int]$Major, [int]$Minor) {
173
+ if (($Major -gt 22) -or ($Major -eq 22 -and $Minor -ge 15)) { return @('--use-system-ca') }
174
+ return @()
175
+ }
176
+
177
+ # A per-shell or per-version directory: fnm's multishell links vanish with
178
+ # the shell, and nvm, Volta, Scoop, asdf and mise switch or remove versions
179
+ # under the same path. The SessionStart hook pins an absolute node path, so
180
+ # such a Node must not be the one it pins.
181
+ function Test-ManagedNodePath([string]$Path) {
182
+ $markers = @('fnm_multishells', 'fnm', '.nvm', 'nvm', '.volta', 'volta', 'nodenv', '.nodenv', 'asdf', '.asdf', 'mise', '.mise', 'scoop')
183
+ foreach ($segment in $Path.Split([char[]]@('\', '/'))) {
184
+ if ($markers -icontains $segment) { return $true }
185
+ }
186
+ return $false
187
+ }
188
+
189
+ function Get-Sha256([string]$Path) {
190
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
191
+ }
192
+
193
+ # The digest of one archive from nodejs.org's SHASUMS256.txt: an anchored,
194
+ # exact-filename match, and exactly one line or nothing.
195
+ function Get-PublishedDigest([string]$Version, [string]$FileName) {
196
+ $url = "https://nodejs.org/dist/v$Version/SHASUMS256.txt"
197
+ Write-Note "v$Version is not the pinned version; its digest comes from $url"
198
+ $text = (Invoke-WebRequest -UseBasicParsing -Uri $url @webOptions).Content
199
+ $pattern = '^([0-9a-fA-F]{64})\s+\*?' + [regex]::Escape($FileName) + '\s*$'
200
+ $found = New-Object System.Collections.Generic.List[string]
201
+ foreach ($line in ($text -split "`n")) {
202
+ if ($line.TrimEnd("`r") -match $pattern) { $found.Add($matches[1].ToLowerInvariant()) }
203
+ }
204
+ if ($found.Count -ne 1) { throw "SHASUMS256.txt for v$Version lists $FileName $($found.Count) times; expected exactly once." }
205
+ return $found[0]
206
+ }
207
+
208
+ # A private Node under ~\.baseinstrunner\node\v<V>\: versioned, so a running
209
+ # recorder keeps its files and nothing is extracted over a locked node.exe.
210
+ function Install-PrivateNode([string]$Version) {
211
+ $arch = $env:PROCESSOR_ARCHITEW6432
212
+ if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE }
213
+ switch ($arch) {
214
+ 'AMD64' { $nodeArch = 'x64' }
215
+ 'ARM64' { $nodeArch = 'arm64' }
216
+ default { throw "No Node build for a $arch Windows. Install Node 20 or newer from https://nodejs.org and paste the line again." }
217
+ }
218
+ $fileName = "node-v$Version-win-$nodeArch.zip"
219
+ $nodeDir = Join-Path $birHome "node\v$Version"
220
+ $exe = Join-Path $nodeDir 'node.exe'
221
+
222
+ if (Test-Path -LiteralPath $exe) {
223
+ $probe = Invoke-Tool $exe @('-v') -Quiet
224
+ if ($probe.Code -eq 0 -and (Get-VersionLine $probe.Lines) -eq "v$Version") {
225
+ Write-Note "node v$Version already at $exe"
226
+ return $exe
227
+ }
228
+ # Half an extraction or a foreign file: removed, never extracted over.
229
+ if ($dryRun) { Write-Would "remove the unusable $nodeDir" }
230
+ else {
231
+ try { Remove-Item -LiteralPath $nodeDir -Recurse -Force }
232
+ catch { throw "$nodeDir is unusable and cannot be removed (a recorder still using it?). Run 'bir down' and paste the line again." }
233
+ }
234
+ }
235
+
236
+ if ($Version -eq $pinnedNodeVersion -and $pinnedDigests.ContainsKey($fileName)) {
237
+ $expected = $pinnedDigests[$fileName]
238
+ }
239
+ elseif ($dryRun) {
240
+ Write-Would "fetch https://nodejs.org/dist/v$Version/SHASUMS256.txt for the digest of $fileName"
241
+ $expected = '<from SHASUMS256.txt>'
242
+ }
243
+ else {
244
+ $expected = Get-PublishedDigest $Version $fileName
245
+ }
246
+
247
+ $url = "https://nodejs.org/dist/v$Version/$fileName"
248
+ $archive = $env:BIR_NODE_ARCHIVE
249
+ $downloaded = $false
250
+ if ($archive) {
251
+ if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw "BIR_NODE_ARCHIVE is not a file: $archive" }
252
+ $archive = (Resolve-Path -LiteralPath $archive).ProviderPath
253
+ Write-Note "using the local archive $archive (BIR_NODE_ARCHIVE)"
254
+ }
255
+ else {
256
+ $archive = Join-Path $env:TEMP "bir-$fileName"
257
+ $downloaded = $true
258
+ }
259
+
260
+ $tar = Join-Path $env:SystemRoot 'System32\tar.exe'
261
+ $useTar = Test-Path -LiteralPath $tar
262
+ if ($dryRun) {
263
+ if ($downloaded) { Write-Would "download $url to $archive" }
264
+ Write-Would "check its SHA-256 against $expected"
265
+ if ($useTar) { Write-Would "extract it with $tar --strip-components=1 into $nodeDir" }
266
+ else { Write-Would "extract it with Expand-Archive (slow in Windows PowerShell 5.1) into $nodeDir" }
267
+ return $exe
268
+ }
269
+
270
+ if ($downloaded) {
271
+ Write-Note "downloading $url"
272
+ Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive @webOptions
273
+ }
274
+ $actual = Get-Sha256 $archive
275
+ if ($actual -ne $expected) {
276
+ if ($downloaded) { Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue }
277
+ throw "$fileName does not match its published digest (expected $expected, got $actual). Nothing was installed."
278
+ }
279
+ Write-Note "digest ok: $expected"
280
+
281
+ # Extracted into a sibling directory, then one rename into place: there
282
+ # is never a half-written v<V>, and the rename stays on one volume.
283
+ $staging = Join-Path (Split-Path -Parent $nodeDir) (".v$Version-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
284
+ New-Item -ItemType Directory -Path $staging -Force | Out-Null
285
+ try {
286
+ if ($useTar) {
287
+ # bsdtar reads zip files and strips the inner folder itself.
288
+ # Windows 10 1803+ ships it; Git's tar.exe is GNU tar and does not
289
+ # read zips, which is why the System32 one is named explicitly.
290
+ $r = Invoke-Tool $tar @('-xf', $archive, '-C', $staging, '--strip-components=1') -Quiet
291
+ if ($r.Code -ne 0) { throw "tar.exe could not extract $archive`: $($r.Lines -join ' ')" }
292
+ Move-Item -LiteralPath $staging -Destination $nodeDir
293
+ }
294
+ else {
295
+ Expand-Archive -LiteralPath $archive -DestinationPath $staging -Force
296
+ $inner = @(Get-ChildItem -LiteralPath $staging -Directory)
297
+ if ($inner.Count -ne 1) { throw "$fileName did not unpack into one folder as expected." }
298
+ Move-Item -LiteralPath $inner[0].FullName -Destination $nodeDir
299
+ }
300
+ }
301
+ finally {
302
+ if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue }
303
+ if ($downloaded) { Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue }
304
+ }
305
+
306
+ $probe = Invoke-Tool $exe @('-v') -Quiet
307
+ if (-not ($probe.Code -eq 0 -and (Get-VersionLine $probe.Lines) -eq "v$Version")) {
308
+ throw "$exe does not run as v$Version after extraction."
309
+ }
310
+ Write-Note "node v$Version at $exe (not on PATH; only the runner uses it)"
311
+ return $exe
312
+ }
313
+
314
+ # %USERPROFILE% instead of the literal profile path in a .cmd file: cmd.exe
315
+ # reads it in the console code page, and a profile named after a person
316
+ # (an umlaut, say) would not survive that as ASCII.
317
+ function ConvertTo-LauncherPath([string]$Path) {
318
+ $profileDir = $env:USERPROFILE.TrimEnd('\')
319
+ if ($Path.Length -gt $profileDir.Length -and $Path.Substring(0, $profileDir.Length + 1) -ieq ($profileDir + '\')) {
320
+ return '%USERPROFILE%' + $Path.Substring($profileDir.Length)
321
+ }
322
+ return $Path
323
+ }
324
+
325
+ function Write-Launcher([string]$Path, [string]$NodeExe, [string[]]$NodeFlags, [string]$Script) {
326
+ $flags = ''
327
+ if (@($NodeFlags).Count -gt 0) { $flags = ' ' + (@($NodeFlags) -join ' ') }
328
+ $content = '@"' + (ConvertTo-LauncherPath $NodeExe) + '"' + $flags + ' "' + (ConvertTo-LauncherPath $Script) + '" %*' + "`r`n"
329
+ if ($dryRun) {
330
+ Write-Would "write $Path`: $($content.Trim())"
331
+ return
332
+ }
333
+ $encoding = [System.Text.Encoding]::ASCII
334
+ foreach ($c in $content.ToCharArray()) {
335
+ if ([int]$c -gt 127) {
336
+ $encoding = [System.Text.Encoding]::GetEncoding([System.Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage)
337
+ break
338
+ }
339
+ }
340
+ [System.IO.File]::WriteAllText($Path, $content, $encoding)
341
+ Write-Note "$Path -> $($content.Trim())"
342
+ }
343
+
344
+ # Explorer, and so every terminal started from the taskbar or Start, reads
345
+ # the user environment again only after this broadcast. setx sends it,
346
+ # Set-ItemProperty does not. Best effort: a host without a C# compiler still
347
+ # has the registry change, picked up at the next sign-in.
348
+ function Send-EnvironmentChange {
349
+ try {
350
+ if (-not ('BirInstall.Native' -as [type])) {
351
+ Add-Type -Namespace BirInstall -Name Native -MemberDefinition @'
352
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
353
+ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
354
+ '@
355
+ }
356
+ $result = [UIntPtr]::Zero
357
+ [BirInstall.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null
358
+ }
359
+ catch {
360
+ Write-Note 'could not tell Explorer about the new Path; terminals opened from Start see it after the next sign-in'
361
+ }
362
+ }
363
+
364
+ # Additive only: this runs inside the person's shell, and replacing
365
+ # $env:Path would drop what that shell added for itself (a venv, a tool's
366
+ # per-shell directory). Registry entries missing here are appended.
367
+ function Update-SessionPath {
368
+ $known = New-Object System.Collections.Generic.List[string]
369
+ foreach ($p in $env:Path.Split(';')) { $known.Add($p.Trim().TrimEnd('\').ToLowerInvariant()) }
370
+ $registry = [string][Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [string][Environment]::GetEnvironmentVariable('Path', 'User')
371
+ foreach ($p in $registry.Split(';')) {
372
+ $dir = $p.Trim()
373
+ if ($dir -eq '') { continue }
374
+ $key = $dir.TrimEnd('\').ToLowerInvariant()
375
+ if (-not $known.Contains($key)) {
376
+ $env:Path = $env:Path + ';' + $dir
377
+ $known.Add($key)
378
+ }
379
+ }
380
+ }
381
+
382
+ # `claude` on PATH, or the native installer's ~\.local\bin when the Path
383
+ # change has not reached this shell yet.
384
+ function Get-ClaudePath {
385
+ $found = @(Get-Command claude -CommandType Application -ErrorAction SilentlyContinue)
386
+ if ($found.Count -ge 1) { return $found[0].Source }
387
+ $native = Join-Path $env:USERPROFILE '.local\bin\claude.exe'
388
+ if (Test-Path -LiteralPath $native) { return $native }
389
+ return ''
390
+ }
391
+
392
+ # ===========================================================================
393
+
394
+ try {
395
+ if ($dryRun) { Write-Warn 'BIR_DRY_RUN=1: printing what would happen; nothing is downloaded, installed or written.' }
396
+
397
+ # -- 00. the folder, before anything is downloaded ----------------------
398
+ #
399
+ # A terminal opened from Start begins in the home folder, and that is
400
+ # where a first paste often lands. Nothing is fetched until the folder
401
+ # looks like a project; BIR_PROJECT names one explicitly.
402
+ $project = ''
403
+ if ($env:BIR_PROJECT) {
404
+ if (-not (Test-Path -LiteralPath $env:BIR_PROJECT -PathType Container)) {
405
+ throw "BIR_PROJECT is not a folder: $($env:BIR_PROJECT)"
406
+ }
407
+ $project = (Resolve-Path -LiteralPath $env:BIR_PROJECT).ProviderPath
408
+ # Tab completion leaves a trailing backslash, and `"C:\has space\proj\"`
409
+ # on a command line reads as a path ending in a quote. A root keeps its.
410
+ if ($project.Length -gt ([System.IO.Path]::GetPathRoot($project)).Length) { $project = $project.TrimEnd('\', '/') }
411
+ Set-Location -LiteralPath $project
412
+ Write-Step "Setting up in $project (BIR_PROJECT)"
413
+ }
414
+ else {
415
+ $project = (Get-Location -PSProvider FileSystem).ProviderPath
416
+ $here = Get-ComparablePath $project
417
+ $reason = ''
418
+ foreach ($candidate in @($env:USERPROFILE, $HOME)) {
419
+ if ($candidate -and (Get-ComparablePath $candidate) -eq $here) { $reason = 'your home folder' }
420
+ }
421
+ if (-not $reason -and (Get-ComparablePath ([System.IO.Path]::GetPathRoot($project))) -eq $here) {
422
+ $reason = 'the root of a drive'
423
+ }
424
+ if ($reason) {
425
+ Write-Host "This is $reason. Open a terminal in the project you start Claude Code in (cd there) and paste the line again." -ForegroundColor Red
426
+ Write-Note 'Or name the project first: $env:BIR_PROJECT = "C:\path\to\your\project"'
427
+ return
428
+ }
429
+ Write-Step "Setting up in $project"
430
+ }
431
+
432
+ # -- the service address and the version ---------------------------------
433
+ $authUrl = $templatedAuthUrl
434
+ if (-not $authUrl -or $authUrl -eq ('__BIR_' + 'AUTH_URL__')) { $authUrl = $env:BIR_AUTH_URL }
435
+ if (-not $authUrl) {
436
+ throw 'No service address: this file was not served by the API. Set $env:BIR_AUTH_URL = "https://api.your-service" and run it again.'
437
+ }
438
+ $authUrl = $authUrl.Trim().TrimEnd('/')
439
+ $version = $templatedVersion
440
+ if (-not $version -or $version -eq ('__BIR_' + 'VERSION__')) { $version = 'latest' }
441
+ Write-Note "service $authUrl, runner $packageName@$version"
442
+
443
+ # TLS 1.2 for the downloads; PowerShell 5.1 on an older Windows 10 still
444
+ # offers 1.0 and 1.1 by default and nodejs.org refuses those.
445
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
446
+ $webOptions = @{}
447
+ $proxy = $env:HTTPS_PROXY
448
+ if (-not $proxy) { $proxy = $env:HTTP_PROXY }
449
+ if ($proxy) {
450
+ $webOptions['Proxy'] = $proxy
451
+ Write-Note "downloads go through $proxy"
452
+ }
453
+
454
+ # -- 1. Node ------------------------------------------------------------
455
+ Write-Step 'Checking Node'
456
+ $nodeExe = ''
457
+ $nodeFlags = @()
458
+ $npmCli = ''
459
+ if (Get-Command node -ErrorAction SilentlyContinue) {
460
+ $probe = Invoke-Tool 'node' @('-v') -Quiet
461
+ $parts = $null
462
+ if ($probe.Code -eq 0) { $parts = Get-NodeVersionParts (Get-VersionLine $probe.Lines) }
463
+ if ($null -eq $parts) {
464
+ Write-Note 'the node on PATH does not answer -v; installing a private one'
465
+ }
466
+ elseif ($parts.Major -lt 20) {
467
+ Write-Note "node v$($parts.Text) on PATH is older than 20; installing a private one"
468
+ }
469
+ else {
470
+ # The real binary: shims (Volta, Scoop, fnm, nvm) exec it, and
471
+ # that is where node_modules\npm lives.
472
+ $real = Invoke-Tool 'node' @('-p', 'process.execPath') -Quiet
473
+ $realPath = ''
474
+ if ($real.Code -eq 0) { $realPath = Get-ExistingPathLine $real.Lines }
475
+ if (-not $realPath -or -not (Test-Path -LiteralPath $realPath -PathType Leaf)) {
476
+ Write-Note 'the node on PATH does not say where its binary is; installing a private one'
477
+ }
478
+ elseif (Test-ManagedNodePath $realPath) {
479
+ Write-Note "node v$($parts.Text) at $realPath belongs to a version manager; installing a private one so the hook outlives this shell"
480
+ }
481
+ else {
482
+ $candidateCli = Join-Path (Split-Path -Parent $realPath) 'node_modules\npm\bin\npm-cli.js'
483
+ if (-not (Test-Path -LiteralPath $candidateCli -PathType Leaf)) {
484
+ Write-Note "node v$($parts.Text) at $realPath has no npm next to it; installing a private one"
485
+ }
486
+ else {
487
+ $nodeExe = $realPath
488
+ $npmCli = $candidateCli
489
+ # @(...) around the call: a one-element array unrolls to a
490
+ # string on assignment, and string + array is one string.
491
+ $nodeFlags = @(Get-NodeFlags $parts.Major $parts.Minor)
492
+ Write-Note "node v$($parts.Text) at $realPath"
493
+ }
494
+ }
495
+ }
496
+ }
497
+ else {
498
+ Write-Note 'no node on PATH; installing a private one'
499
+ }
500
+ if (-not $nodeExe) {
501
+ $nodeVersion = $pinnedNodeVersion
502
+ if ($env:BIR_NODE_VERSION) {
503
+ $nodeVersion = $env:BIR_NODE_VERSION.Trim().TrimStart('v')
504
+ if ($nodeVersion -notmatch '^\d+\.\d+\.\d+$') { throw "BIR_NODE_VERSION must look like 24.21.0, not '$($env:BIR_NODE_VERSION)'." }
505
+ }
506
+ Write-Step "Installing Node $nodeVersion privately"
507
+ $nodeExe = Install-PrivateNode $nodeVersion
508
+ $npmCli = Join-Path (Split-Path -Parent $nodeExe) 'node_modules\npm\bin\npm-cli.js'
509
+ $pinnedParts = Get-NodeVersionParts $nodeVersion
510
+ $nodeFlags = @(Get-NodeFlags $pinnedParts.Major $pinnedParts.Minor)
511
+ }
512
+
513
+ # -- 2. the runner ------------------------------------------------------
514
+ #
515
+ # One code path for every Node: always into the private prefix, never
516
+ # into the machine's npm prefix (per-version under nvm and Volta,
517
+ # shim-managed under Scoop) and always through npm-cli.js, never the npm
518
+ # shim (PowerShell resolves npm.ps1 first, and a Restricted policy blocks it).
519
+ Write-Step 'Installing the runner'
520
+ $npmPrefix = Join-Path $birHome 'npm'
521
+ $spec = "$packageName@$version"
522
+ if ($env:BIR_TARBALL) {
523
+ if (-not (Test-Path -LiteralPath $env:BIR_TARBALL -PathType Leaf)) { throw "BIR_TARBALL is not a file: $($env:BIR_TARBALL)" }
524
+ $spec = (Resolve-Path -LiteralPath $env:BIR_TARBALL).ProviderPath
525
+ Write-Note "from $spec (BIR_TARBALL)"
526
+ }
527
+ else {
528
+ Write-Note "$spec from the registry"
529
+ }
530
+ $npmArgs = @($nodeFlags) + @($npmCli, 'install', '-g', '--prefix', $npmPrefix, '--no-fund', '--no-audit', $spec)
531
+ $runnerDir = Join-Path $npmPrefix ('node_modules\' + $packageName.Replace('/', '\'))
532
+ $birJs = Join-Path $runnerDir 'dist\bin\bir.js'
533
+ $hooksJs = Join-Path $runnerDir 'dist\bin\bir-hooks.js'
534
+ if ($dryRun) {
535
+ Write-Would "run: $(Format-CommandLine $nodeExe $npmArgs)"
536
+ }
537
+ else {
538
+ New-Item -ItemType Directory -Path $npmPrefix -Force | Out-Null
539
+ $r = Invoke-Tool $nodeExe $npmArgs
540
+ if ($r.Code -ne 0) {
541
+ if (($r.Lines -join "`n") -match 'CERT|ISSUER|SELF_SIGNED') { Write-Warn $tlsHint }
542
+ throw "npm install failed (exit $($r.Code)); see above."
543
+ }
544
+ if (-not (Test-Path -LiteralPath $birJs -PathType Leaf)) { throw "npm reported success but $birJs is missing." }
545
+ $installed = (Get-Content -LiteralPath (Join-Path $runnerDir 'package.json') -Raw | ConvertFrom-Json).version
546
+ Write-Note "bir $installed at $runnerDir"
547
+ # `bir setup` arrived in 0.2.9. A registry whose `latest` is older
548
+ # would install cleanly and then fail on the next line with nothing
549
+ # to say; say it here.
550
+ if ([version]($installed -replace '[^0-9.].*$', '') -lt [version]'0.2.9') {
551
+ throw "@basein/runner $installed has no 'bir setup'. This service must serve RUNNER_VERSION >= 0.2.9 (or set BIR_TARBALL to a newer build)."
552
+ }
553
+ }
554
+
555
+ # Launchers first on the user Path: `bir` in PowerShell then resolves to
556
+ # bir.cmd, so no execution-policy change, ever. The private Node stays
557
+ # off PATH; the launchers name it.
558
+ Write-Step 'Putting bir on your PATH'
559
+ $binDir = Join-Path $birHome 'bin'
560
+ if (-not $dryRun) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
561
+ # The launchers carry the same node flags this run used, so a `bir setup`
562
+ # typed later - and everything it wires - trusts the same certificates.
563
+ Write-Launcher (Join-Path $binDir 'bir.cmd') $nodeExe $nodeFlags $birJs
564
+ Write-Launcher (Join-Path $binDir 'bir-hooks.cmd') $nodeExe $nodeFlags $hooksJs
565
+
566
+ # The user Path is read raw and written back as REG_EXPAND_SZ. Not
567
+ # [Environment]::SetEnvironmentVariable: it flattens the value to REG_SZ,
568
+ # and every %USERPROFILE% already in there stops expanding.
569
+ $pathEntry = '%USERPROFILE%\.baseinstrunner\bin'
570
+ $rawPath = [string](Get-Item -Path HKCU:\Environment).GetValue('Path', '', 'DoNotExpandEnvironmentNames')
571
+ $present = $false
572
+ foreach ($part in $rawPath.Split(';')) {
573
+ $p = $part.Trim().TrimEnd('\')
574
+ if ($p -ieq $pathEntry -or $p -ieq $binDir.TrimEnd('\')) { $present = $true }
575
+ }
576
+ if ($present) {
577
+ Write-Note "user Path already has $pathEntry"
578
+ }
579
+ else {
580
+ $newPath = if ($rawPath.Trim() -eq '') { $pathEntry } else { $pathEntry + ';' + $rawPath }
581
+ if ($dryRun) {
582
+ Write-Would "prepend $pathEntry to the user Path in HKCU:\Environment (REG_EXPAND_SZ); it is now: $rawPath"
583
+ }
584
+ else {
585
+ Set-ItemProperty -Path HKCU:\Environment -Name Path -Value $newPath -Type ExpandString
586
+ Send-EnvironmentChange
587
+ Write-Note "user Path: $pathEntry first; new terminals see it"
588
+ }
589
+ }
590
+ $sessionHas = $false
591
+ foreach ($part in $env:Path.Split(';')) {
592
+ if ($part.Trim().TrimEnd('\') -ieq $binDir.TrimEnd('\')) { $sessionHas = $true }
593
+ }
594
+ if (-not $sessionHas) {
595
+ if ($dryRun) { Write-Would "prepend $binDir to `$env:Path for this window" }
596
+ else { $env:Path = $binDir + ';' + $env:Path }
597
+ }
598
+
599
+ # -- 3. Claude Code -----------------------------------------------------
600
+ Write-Step 'Checking Claude Code'
601
+ Update-SessionPath
602
+ $claude = Get-ClaudePath
603
+ if ($claude) {
604
+ Write-Note "claude at $claude"
605
+ }
606
+ elseif ($env:BIR_NO_CLAUDE -eq '1') {
607
+ Write-Note 'not installed; skipping (BIR_NO_CLAUDE=1)'
608
+ }
609
+ else {
610
+ Write-Step 'Installing Claude Code (this can take a minute)'
611
+ # A child process, so the installer's own policy switch and any exit
612
+ # it calls stay in that child.
613
+ $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
614
+ if (-not (Test-Path -LiteralPath $powershell)) { $powershell = 'powershell.exe' }
615
+ $claudeArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'irm https://claude.ai/install.ps1 | iex')
616
+ if ($dryRun) {
617
+ Write-Would "run: $(Format-CommandLine $powershell $claudeArgs)"
618
+ }
619
+ else {
620
+ $r = Invoke-Tool $powershell $claudeArgs
621
+ Update-SessionPath
622
+ $claude = Get-ClaudePath
623
+ if ($r.Code -ne 0 -or -not $claude) {
624
+ Write-Warn "Claude Code did not install (exit $($r.Code)). The runner is set up anyway; install it later with: irm https://claude.ai/install.ps1 | iex"
625
+ }
626
+ else {
627
+ Write-Note "claude at $claude"
628
+ Write-Note 'Claude Code needs a Claude subscription and asks you to sign in the first time it starts.'
629
+ }
630
+ }
631
+ }
632
+
633
+ # -- 4. setup -----------------------------------------------------------
634
+ Write-Step 'Running bir setup'
635
+ $setupArgs = @($nodeFlags) + @($birJs, 'setup', '--auth-url', $authUrl, '--project', $project)
636
+ if ($dryRun) {
637
+ Write-Would "run: $(Format-CommandLine $nodeExe $setupArgs)"
638
+ if ($setupToken) { Write-Would 'hand it BIR_SETUP_TOKEN through its environment (set right before, removed right after)' }
639
+ else { Write-Would 'run it without a token: it uses the cached sign-in or opens the browser' }
640
+ Write-Step 'Dry run finished; nothing was changed.'
641
+ return
642
+ }
643
+ # The token goes to this one child through its environment: set right
644
+ # before the spawn, removed right after, never on a command line.
645
+ if ($setupToken) { $env:BIR_SETUP_TOKEN = $setupToken }
646
+ try {
647
+ $r = Invoke-Tool $nodeExe $setupArgs
648
+ }
649
+ finally {
650
+ Remove-Item -Path Env:BIR_SETUP_TOKEN -ErrorAction SilentlyContinue
651
+ }
652
+ if ($r.Code -ne 0) {
653
+ throw "bir setup ended with exit code $($r.Code). Fix what it reported above, then paste the line again or run: bir setup"
654
+ }
655
+ # This window only when it is the PowerShell the line was pasted into: the
656
+ # cmd variant runs this in a child powershell.exe, whose $env:Path dies
657
+ # with it, so a cmd window sees `bir` only once it is a new window.
658
+ Write-Note 'bir works in every new terminal (and in this window when it is PowerShell); `bir doctor` checks the setup.'
659
+ Write-Note 'From a cmd window: open a new one, or run set PATH=%USERPROFILE%\.baseinstrunner\bin;%USERPROFILE%\.local\bin;%PATH%'
660
+ }
661
+ catch {
662
+ $message = $_.Exception.Message
663
+ if ($message -match 'CERT|ISSUER|SELF_SIGNED|secure channel|trust relationship') { Write-Warn $tlsHint }
664
+ Write-Host "error: $message" -ForegroundColor Red
665
+ return
666
+ }
667
+ }
668
+
669
+ Invoke-BirInstall