freecode-sh 0.1.0-alpha.12 → 0.1.0-alpha.18

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.
package/install.ps1 ADDED
@@ -0,0 +1,653 @@
1
+ #Requires -Version 5.1
2
+
3
+ <#
4
+ .SYNOPSIS
5
+ Installs the Free Code native Windows alpha without administrator access.
6
+
7
+ .EXAMPLE
8
+ irm https://freecode.sh/install.ps1 | iex
9
+
10
+ .EXAMPLE
11
+ .\install.ps1 -Version 0.1.0-alpha.1 -NoModifyPath
12
+ #>
13
+
14
+ [CmdletBinding()]
15
+ param(
16
+ [string]$Version,
17
+ [string]$Channel,
18
+ [string]$BaseUrl,
19
+ [string]$ChannelBaseUrl,
20
+ [string]$ArchivePath,
21
+ [string]$ChecksumPath,
22
+ [string]$ManifestPath,
23
+ [Alias('InstallRoot')]
24
+ [string]$InstallDir,
25
+ [switch]$AllowUnsigned,
26
+ [switch]$NoModifyPath,
27
+ [switch]$Rollback,
28
+ [switch]$Uninstall
29
+ )
30
+
31
+ Set-StrictMode -Version 2.0
32
+ $ErrorActionPreference = 'Stop'
33
+ $ProgressPreference = 'SilentlyContinue'
34
+
35
+ $Product = 'freecode-sh'
36
+ $Target = 'windows-x64'
37
+ $AssetName = 'freecode-windows-x64.zip'
38
+ $LauncherRelativePath = 'bin/freecode.exe'
39
+ $InterfaceRelativePath = 'libexec/freecode/freecode-tui.exe'
40
+ $EngineRelativePath = 'libexec/freecode/freecode-engine.exe'
41
+ $DefaultBaseUrl = 'https://releases.freecode.sh/releases'
42
+ $DefaultChannelBaseUrl = 'https://releases.freecode.sh/channels'
43
+ $ReleaseSigningModulusBase64 = '8so4sWArOV0GXihYvoSoxDLI1yMoILnVfCjYQwe4CKQ9EC10JV30cntMJZWXvXZS6STqQGIjyWisa6Op4nIdOuXKDjaEcQIeftgZBzzyGrMmAMC3W57VAi3d+w/F2XnkXBaH3NOphfftF6Fxt3Q02CxN3PjuuFaOGytKsYF5uDLCvEb+MpzJlD3HZoBzhhMCAoY6wmCdlgaDxD20UFdyEigF5wwpcUWpYmbYMFp+6SLe/Fi8IrYirwiCv5eLur6PzFFcwFrJgUeZAdxmGuM8eJzZlc7mK0+gzi8KpC2FZ9eOx1SafPE4An4+u8rZ7WA1Qk5eCAhNU9lm+JAeeKcCXzhQpPUafybJg75dIbIayOC3tf5mn+X+Vl5+uY19nT2qcJsjgUeIcqbMs9FGXiIA2G292N5WppyiDcx8yAIG6rOrAUGzHMYCrhvxQLAx2KP5QfC53VZ3La7yZOATY5icF78qSSBrKDrT42vinbosbX++Wr34YB6pXhiZF6jHPHrx'
44
+ $ReleaseSigningExponentBase64 = 'AQAB'
45
+
46
+ function Fail([string]$Message) {
47
+ throw "freecode installer: $Message"
48
+ }
49
+
50
+ function Get-EnvironmentValue([string]$Name) {
51
+ return [Environment]::GetEnvironmentVariable($Name, 'Process')
52
+ }
53
+
54
+ if (-not $Version) { $Version = Get-EnvironmentValue 'FREECODE_INSTALL_VERSION' }
55
+ if (-not $Channel) {
56
+ $Channel = Get-EnvironmentValue 'FREECODE_INSTALL_CHANNEL'
57
+ if (-not $Channel) { $Channel = 'alpha' }
58
+ }
59
+ if (-not $BaseUrl) {
60
+ $BaseUrl = Get-EnvironmentValue 'FREECODE_INSTALL_BASE_URL'
61
+ if (-not $BaseUrl) { $BaseUrl = $DefaultBaseUrl }
62
+ }
63
+ if (-not $ChannelBaseUrl) {
64
+ $ChannelBaseUrl = Get-EnvironmentValue 'FREECODE_CHANNEL_BASE_URL'
65
+ if (-not $ChannelBaseUrl) { $ChannelBaseUrl = $DefaultChannelBaseUrl }
66
+ }
67
+
68
+ $testReleaseModulus = Get-EnvironmentValue 'FREECODE_TEST_RELEASE_PUBLIC_KEY_MODULUS'
69
+ $testReleaseExponent = Get-EnvironmentValue 'FREECODE_TEST_RELEASE_PUBLIC_KEY_EXPONENT'
70
+ if ($testReleaseModulus -or $testReleaseExponent) {
71
+ $testBaseUri = $null
72
+ if (
73
+ -not $testReleaseModulus -or
74
+ -not $testReleaseExponent -or
75
+ -not [Uri]::TryCreate($BaseUrl, [UriKind]::Absolute, [ref]$testBaseUri) -or
76
+ $testBaseUri.Scheme -ne 'http' -or
77
+ -not $testBaseUri.IsLoopback
78
+ ) {
79
+ Fail 'the test release key override is permitted only with a loopback HTTP release URL'
80
+ }
81
+ $ReleaseSigningModulusBase64 = $testReleaseModulus
82
+ $ReleaseSigningExponentBase64 = $testReleaseExponent
83
+ }
84
+ if (-not $InstallDir) {
85
+ $InstallDir = Get-EnvironmentValue 'FREECODE_INSTALL_ROOT'
86
+ if (-not $InstallDir) {
87
+ if (-not $env:LOCALAPPDATA) { Fail 'LOCALAPPDATA is required' }
88
+ $InstallDir = Join-Path $env:LOCALAPPDATA 'Freecode'
89
+ }
90
+ }
91
+ if ((Get-EnvironmentValue 'FREECODE_ALLOW_UNSIGNED_INSTALL') -eq 'true') {
92
+ $AllowUnsigned = $true
93
+ }
94
+ if ((Get-EnvironmentValue 'FREECODE_INSTALL_EMBEDDED') -eq 'true') {
95
+ $NoModifyPath = $true
96
+ }
97
+
98
+ $InstallDir = [IO.Path]::GetFullPath($InstallDir)
99
+ $VersionsDir = Join-Path $InstallDir 'versions'
100
+ $BinDir = Join-Path $InstallDir 'bin'
101
+ $CurrentVersionPath = Join-Path $InstallDir 'current-version'
102
+ $PreviousVersionPath = Join-Path $InstallDir 'previous-version'
103
+ $MarkerPath = Join-Path $InstallDir '.freecode-install.json'
104
+ $ShimPath = Join-Path $BinDir 'freecode.cmd'
105
+
106
+ function Assert-SafeInstallRoot {
107
+ if ($InstallDir.IndexOfAny([char[]]@('"', '%', '!', "`r", "`n", ';')) -ge 0) {
108
+ Fail 'the install directory contains characters that cannot be represented safely in the command shim'
109
+ }
110
+ $comparison = [StringComparison]::OrdinalIgnoreCase
111
+ $driveRoot = [IO.Path]::GetPathRoot($InstallDir)
112
+ $unsafeRoots = @($driveRoot, $env:USERPROFILE, $env:LOCALAPPDATA) | Where-Object { $_ }
113
+ foreach ($unsafe in $unsafeRoots) {
114
+ if ([string]::Equals($InstallDir.TrimEnd('\'), ([IO.Path]::GetFullPath($unsafe)).TrimEnd('\'), $comparison)) {
115
+ Fail "refusing to use a broad install directory: $InstallDir"
116
+ }
117
+ }
118
+ }
119
+
120
+ function Assert-Version([string]$Value) {
121
+ if ($Value -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$') {
122
+ Fail "invalid release version: $Value"
123
+ }
124
+ }
125
+
126
+ function Assert-Channel([string]$Value) {
127
+ if ($Value -notmatch '^[0-9A-Za-z][0-9A-Za-z._-]*$') {
128
+ Fail "invalid release channel: $Value"
129
+ }
130
+ }
131
+
132
+ function Receive-File([string]$Uri, [string]$Destination) {
133
+ try {
134
+ Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Destination
135
+ } catch {
136
+ Fail "could not download $Uri ($($_.Exception.Message))"
137
+ }
138
+ }
139
+
140
+ function Read-Json([string]$Path, [string]$Description) {
141
+ try {
142
+ return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
143
+ } catch {
144
+ Fail "$Description is not valid JSON"
145
+ }
146
+ }
147
+
148
+ function Get-Sha256Hex([string]$Path) {
149
+ $stream = [IO.File]::OpenRead($Path)
150
+ $sha256 = [Security.Cryptography.SHA256]::Create()
151
+ try {
152
+ return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant()
153
+ } finally {
154
+ $sha256.Dispose()
155
+ $stream.Dispose()
156
+ }
157
+ }
158
+
159
+ function Enter-InstallLock {
160
+ $normalizedInstallDir = $InstallDir.TrimEnd('\').ToUpperInvariant()
161
+ $sha256 = [Security.Cryptography.SHA256]::Create()
162
+ try {
163
+ $digest = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($normalizedInstallDir))
164
+ } finally {
165
+ $sha256.Dispose()
166
+ }
167
+ $mutexName = "Local\FreeCodeInstall-$(([BitConverter]::ToString($digest)).Replace('-', ''))"
168
+ $mutex = [Threading.Mutex]::new($false, $mutexName)
169
+ $acquired = $false
170
+ try {
171
+ try {
172
+ $acquired = $mutex.WaitOne(0)
173
+ if (-not $acquired) {
174
+ Write-Host 'Waiting for another Free Code setup to finish...'
175
+ $acquired = $mutex.WaitOne([TimeSpan]::FromMinutes(5))
176
+ }
177
+ } catch [Threading.AbandonedMutexException] {
178
+ $acquired = $true
179
+ }
180
+ if (-not $acquired) {
181
+ Fail 'timed out waiting for another Free Code setup to finish'
182
+ }
183
+ return $mutex
184
+ } catch {
185
+ $mutex.Dispose()
186
+ throw
187
+ }
188
+ }
189
+
190
+ function Exit-InstallLock([Threading.Mutex]$Mutex) {
191
+ try {
192
+ $Mutex.ReleaseMutex()
193
+ } finally {
194
+ $Mutex.Dispose()
195
+ }
196
+ }
197
+
198
+ function Assert-ReleaseManifestSignature([string]$ManifestPath, [string]$SignaturePath, [string]$Description) {
199
+ if (-not (Test-Path -LiteralPath $SignaturePath -PathType Leaf)) {
200
+ Fail "$Description does not have a valid Free Code release signature"
201
+ }
202
+ $rsa = [Security.Cryptography.RSA]::Create()
203
+ try {
204
+ $parameters = New-Object Security.Cryptography.RSAParameters
205
+ $parameters.Modulus = [Convert]::FromBase64String($ReleaseSigningModulusBase64)
206
+ $parameters.Exponent = [Convert]::FromBase64String($ReleaseSigningExponentBase64)
207
+ $rsa.ImportParameters($parameters)
208
+ $valid = $rsa.VerifyData(
209
+ [IO.File]::ReadAllBytes($ManifestPath),
210
+ [IO.File]::ReadAllBytes($SignaturePath),
211
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
212
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
213
+ )
214
+ } catch {
215
+ $valid = $false
216
+ } finally {
217
+ $rsa.Dispose()
218
+ }
219
+ if (-not $valid) {
220
+ Fail "$Description does not have a valid Free Code release signature"
221
+ }
222
+ }
223
+
224
+ function Get-Asset($Manifest, [string]$Description) {
225
+ if (-not $Manifest.version) { Fail "$Description does not contain a version" }
226
+ if (-not $Manifest.assets) { Fail "$Description does not contain release assets" }
227
+ $property = $Manifest.assets.PSObject.Properties[$Target]
228
+ if (-not $property) { Fail "$Description does not contain a $Target asset" }
229
+ return $property.Value
230
+ }
231
+
232
+ function Assert-PayloadContract($Manifest, [string]$Description) {
233
+ $payload = $null
234
+ $payloads = Get-OptionalProperty $Manifest 'payloads'
235
+ if ($payloads) {
236
+ $targetPayload = $payloads.PSObject.Properties[$Target]
237
+ if ($targetPayload) { $payload = $targetPayload.Value }
238
+ }
239
+ if (-not $payload) { $payload = Get-OptionalProperty $Manifest 'payload' }
240
+ if (
241
+ -not $payload -or
242
+ [string](Get-OptionalProperty $payload 'launcher') -ne $LauncherRelativePath -or
243
+ [string](Get-OptionalProperty $payload 'interface') -ne $InterfaceRelativePath -or
244
+ [string](Get-OptionalProperty $payload 'engine') -ne $EngineRelativePath
245
+ ) {
246
+ Fail "$Description does not declare the expected $Target payload paths"
247
+ }
248
+ }
249
+
250
+ function Get-OptionalProperty($Object, [string]$Name) {
251
+ if (-not $Object) { return $null }
252
+ $property = $Object.PSObject.Properties[$Name]
253
+ if (-not $property) { return $null }
254
+ return $property.Value
255
+ }
256
+
257
+ function Test-UnsignedAsset($Security) {
258
+ return (Get-OptionalProperty $Security 'testUnsigned') -eq $true
259
+ }
260
+
261
+ function Assert-WindowsAssetSecurity($Asset, [string]$Description) {
262
+ $security = Get-OptionalProperty $Asset 'security'
263
+ if ($security -and (Test-UnsignedAsset $security)) {
264
+ if (-not $AllowUnsigned) {
265
+ Fail "$Description is an unsigned test artifact; rerun with -AllowUnsigned only on a trusted test PC"
266
+ }
267
+ return
268
+ }
269
+ if (
270
+ -not $security -or
271
+ $security.windowsAuthenticodeSigned -ne $true -or
272
+ $security.timestamped -ne $true -or
273
+ -not $security.publisher
274
+ ) {
275
+ Fail "$Description is not marked as Authenticode signed and timestamped"
276
+ }
277
+ }
278
+
279
+ function Assert-ZipPaths([string]$Archive, [string]$Destination) {
280
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
281
+ $root = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
282
+ $zip = [IO.Compression.ZipFile]::OpenRead($Archive)
283
+ try {
284
+ foreach ($entry in $zip.Entries) {
285
+ $name = $entry.FullName.Replace('\', '/')
286
+ if (
287
+ [string]::IsNullOrWhiteSpace($name) -or
288
+ $name.StartsWith('/') -or
289
+ $name -match '^[A-Za-z]:' -or
290
+ ($name.Split('/') -contains '..')
291
+ ) {
292
+ Fail "release archive contains an unsafe path: $name"
293
+ }
294
+ $destinationPath = [IO.Path]::GetFullPath((Join-Path $Destination $name))
295
+ if (-not $destinationPath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) {
296
+ Fail "release archive escapes the staging directory: $name"
297
+ }
298
+ }
299
+ } finally {
300
+ $zip.Dispose()
301
+ }
302
+ }
303
+
304
+ function Get-RequiredExecutables([string]$Root) {
305
+ return @(
306
+ (Join-Path $Root $LauncherRelativePath),
307
+ (Join-Path $Root $InterfaceRelativePath),
308
+ (Join-Path $Root $EngineRelativePath)
309
+ )
310
+ }
311
+
312
+ function Assert-CompleteInstall([string]$Root) {
313
+ foreach ($executable in Get-RequiredExecutables $Root) {
314
+ if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) {
315
+ Fail "release is missing required executable: $executable"
316
+ }
317
+ }
318
+ }
319
+
320
+ function Assert-BundleIntegrity([string]$Root, [string]$CandidateVersion) {
321
+ $bundlePath = Join-Path $Root 'libexec\freecode\bundle-manifest.json'
322
+ $bundle = Read-Json $bundlePath 'bundle manifest'
323
+ if ([string]$bundle.version -ne $CandidateVersion) {
324
+ Fail "bundle manifest version $($bundle.version) does not match $CandidateVersion"
325
+ }
326
+ if (-not $bundle.payload) { Fail 'bundle manifest does not contain payload checksums' }
327
+ $rootPrefix = [IO.Path]::GetFullPath($Root).TrimEnd('\') + '\'
328
+ foreach ($property in $bundle.payload.PSObject.Properties) {
329
+ $name = $property.Name.Replace('\', '/')
330
+ if ($name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or ($name.Split('/') -contains '..')) {
331
+ Fail "bundle manifest contains an unsafe path: $name"
332
+ }
333
+ $path = [IO.Path]::GetFullPath((Join-Path $Root $name))
334
+ if (-not $path.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
335
+ Fail "bundle manifest path escapes the install root: $name"
336
+ }
337
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "bundle payload is missing $name" }
338
+ $actual = Get-Sha256Hex $path
339
+ if ($actual -ne [string]$property.Value) { Fail "bundle payload checksum failed for $name" }
340
+ }
341
+ }
342
+
343
+ function Assert-Signatures([string]$Root, $Security) {
344
+ if ((Test-UnsignedAsset $Security) -and $AllowUnsigned) {
345
+ Write-Warning 'Installing an unsigned draft Windows artifact for testing. Do not redistribute this build.'
346
+ return
347
+ }
348
+ foreach ($executable in Get-RequiredExecutables $Root) {
349
+ $signature = Get-AuthenticodeSignature -LiteralPath $executable
350
+ if ([string]$signature.Status -ne 'Valid') {
351
+ Fail "Authenticode verification failed for $executable ($($signature.Status))"
352
+ }
353
+ if (-not $signature.SignerCertificate -or $signature.SignerCertificate.Subject -ne $Security.publisher) {
354
+ $actual = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { 'missing' }
355
+ Fail "unexpected Authenticode publisher for $executable (expected $($Security.publisher), received $actual)"
356
+ }
357
+ if (-not $signature.TimeStamperCertificate) {
358
+ Fail "trusted timestamp is missing from $executable"
359
+ }
360
+ }
361
+ }
362
+
363
+ function Write-TextAtomically([string]$Path, [string]$Text) {
364
+ $temporary = "$Path.$([Guid]::NewGuid().ToString('N')).tmp"
365
+ [IO.File]::WriteAllText($temporary, $Text, [Text.UTF8Encoding]::new($false))
366
+ if (Test-Path -LiteralPath $Path) {
367
+ $backup = "$Path.$([Guid]::NewGuid().ToString('N')).bak"
368
+ try {
369
+ [IO.File]::Replace($temporary, $Path, $backup, $true)
370
+ } finally {
371
+ Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
372
+ Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
373
+ }
374
+ } else {
375
+ [IO.File]::Move($temporary, $Path)
376
+ }
377
+ }
378
+
379
+ function Write-CommandShim {
380
+ New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
381
+ if ((Test-Path -LiteralPath $ShimPath) -and (Get-Item -LiteralPath $ShimPath).PSIsContainer) {
382
+ Fail "refusing to replace directory at $ShimPath"
383
+ }
384
+ $shim = @"
385
+ @echo off
386
+ setlocal
387
+ set "FREECODE_VERSION="
388
+ set /p FREECODE_VERSION=<"$CurrentVersionPath"
389
+ if not defined FREECODE_VERSION (
390
+ echo Free Code is not activated. Re-run install.ps1. 1>&2
391
+ exit /b 1
392
+ )
393
+ "$VersionsDir\%FREECODE_VERSION%\bin\freecode.exe" %*
394
+ "@
395
+ Write-TextAtomically $ShimPath ($shim -replace "`n", "`r`n")
396
+ }
397
+
398
+ function Set-UserPath([bool]$Add) {
399
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
400
+ $entries = @($userPath -split ';' | Where-Object { $_ })
401
+ $comparison = [StringComparer]::OrdinalIgnoreCase
402
+ $matches = @($entries | Where-Object { $comparison.Equals($_.TrimEnd('\'), $BinDir.TrimEnd('\')) })
403
+ if ($Add) {
404
+ if ($matches.Count -eq 0) {
405
+ $updated = (@($entries) + $BinDir) -join ';'
406
+ [Environment]::SetEnvironmentVariable('Path', $updated, 'User')
407
+ Write-Host "Added $BinDir to your user PATH."
408
+ }
409
+ if (-not (@($env:Path -split ';') | Where-Object { $comparison.Equals($_.TrimEnd('\'), $BinDir.TrimEnd('\')) })) {
410
+ $env:Path = "$BinDir;$env:Path"
411
+ }
412
+ } elseif ($matches.Count -gt 0) {
413
+ $updated = @($entries | Where-Object { -not $comparison.Equals($_.TrimEnd('\'), $BinDir.TrimEnd('\')) }) -join ';'
414
+ [Environment]::SetEnvironmentVariable('Path', $updated, 'User')
415
+ }
416
+ }
417
+
418
+ function Test-VersionHealth([string]$CandidateVersion) {
419
+ $launcher = Join-Path (Join-Path $VersionsDir $CandidateVersion) $LauncherRelativePath
420
+ $versionOutput = @(& $launcher --version 2>&1)
421
+ $versionExit = $LASTEXITCODE
422
+ $versionOutput | ForEach-Object { Write-Host $_ }
423
+ if ($versionExit -ne 0 -or $versionOutput.Count -eq 0 -or [string]$versionOutput[0] -ne "freecode-sh $CandidateVersion") {
424
+ return $false
425
+ }
426
+ $doctorOutput = @(& $launcher doctor 2>&1)
427
+ $doctorExit = $LASTEXITCODE
428
+ $doctorOutput | ForEach-Object { Write-Host $_ }
429
+ return $doctorExit -eq 0
430
+ }
431
+
432
+ function Invoke-Uninstall {
433
+ if (-not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) {
434
+ Fail "refusing to remove an unrecognized directory without $MarkerPath"
435
+ }
436
+ $marker = Read-Json $MarkerPath 'install marker'
437
+ if ($marker.product -ne $Product -or $marker.installDir -ne $InstallDir) {
438
+ Fail 'install marker does not match this Free Code installation'
439
+ }
440
+ if (-not $NoModifyPath) { Set-UserPath $false }
441
+ Remove-Item -LiteralPath $InstallDir -Recurse -Force
442
+ Write-Host "Free Code was removed from $InstallDir."
443
+ }
444
+
445
+ function Invoke-Rollback {
446
+ if (-not (Test-Path -LiteralPath $PreviousVersionPath -PathType Leaf)) {
447
+ Fail 'there is no previous Free Code version to restore'
448
+ }
449
+ $previous = (Get-Content -LiteralPath $PreviousVersionPath -Raw).Trim()
450
+ Assert-Version $previous
451
+ $previousRoot = Join-Path $VersionsDir $previous
452
+ Assert-CompleteInstall $previousRoot
453
+ Assert-BundleIntegrity $previousRoot $previous
454
+ $metadataPath = Join-Path $previousRoot 'release-metadata.json'
455
+ $metadata = Read-Json $metadataPath 'stored release metadata'
456
+ Assert-WindowsAssetSecurity $metadata.asset 'stored release metadata'
457
+ Assert-Signatures $previousRoot $metadata.asset.security
458
+ $current = if (Test-Path -LiteralPath $CurrentVersionPath) { (Get-Content -LiteralPath $CurrentVersionPath -Raw).Trim() } else { '' }
459
+ Write-TextAtomically $CurrentVersionPath "$previous`n"
460
+ if (-not (Test-VersionHealth $previous)) {
461
+ if ($current) { Write-TextAtomically $CurrentVersionPath "$current`n" }
462
+ Fail 'the previous version failed its health check; activation was restored'
463
+ }
464
+ if ($current) { Write-TextAtomically $PreviousVersionPath "$current`n" }
465
+ Write-CommandShim
466
+ Write-Host "Rolled back Free Code to $previous."
467
+ }
468
+
469
+ Assert-SafeInstallRoot
470
+ Assert-Channel $Channel
471
+
472
+ $localInputs = @(@($ArchivePath, $ChecksumPath, $ManifestPath) | Where-Object { $_ })
473
+ if ($localInputs.Count -ne 0 -and $localInputs.Count -ne 3) {
474
+ Fail '-ArchivePath, -ChecksumPath, and -ManifestPath must be provided together'
475
+ }
476
+ if ($localInputs.Count -eq 3 -and -not $Version) {
477
+ Fail '-Version is required when installing local candidate files'
478
+ }
479
+
480
+ if ($Uninstall -and $Rollback) { Fail '-Uninstall and -Rollback cannot be used together' }
481
+ if ($Uninstall) {
482
+ $operationMutex = Enter-InstallLock
483
+ try {
484
+ Invoke-Uninstall
485
+ } finally {
486
+ Exit-InstallLock $operationMutex
487
+ }
488
+ exit 0
489
+ }
490
+ if ($Rollback) {
491
+ $operationMutex = Enter-InstallLock
492
+ try {
493
+ Invoke-Rollback
494
+ } finally {
495
+ Exit-InstallLock $operationMutex
496
+ }
497
+ exit 0
498
+ }
499
+
500
+ $architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
501
+ if ($architecture -ne 'AMD64') {
502
+ Fail "this beta supports only Windows x64 (detected $architecture)"
503
+ }
504
+
505
+ # Windows PowerShell 5.1 may otherwise negotiate an obsolete TLS version.
506
+ if ([Net.ServicePointManager]::SecurityProtocol -band [Net.SecurityProtocolType]::Tls12) {
507
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
508
+ } else {
509
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
510
+ }
511
+
512
+ $BaseUrl = $BaseUrl.TrimEnd('/')
513
+ $ChannelBaseUrl = $ChannelBaseUrl.TrimEnd('/')
514
+ $TemporaryDir = Join-Path ([IO.Path]::GetTempPath()) "freecode-install-$([Guid]::NewGuid().ToString('N'))"
515
+ $StageDir = $null
516
+ $InstallMutex = $null
517
+ New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null
518
+
519
+ try {
520
+ if (-not $Version) {
521
+ $channelPath = Join-Path $TemporaryDir 'channel.json'
522
+ $channelSignaturePath = Join-Path $TemporaryDir 'channel.json.sig'
523
+ Write-Host "Resolving Free Code channel $Channel..."
524
+ Receive-File "$ChannelBaseUrl/$Channel.json" $channelPath
525
+ Receive-File "$ChannelBaseUrl/$Channel.json.sig" $channelSignaturePath
526
+ Assert-ReleaseManifestSignature $channelPath $channelSignaturePath "channel $Channel"
527
+ $channelManifest = Read-Json $channelPath "channel $Channel"
528
+ if ($channelManifest.channel -ne $Channel) { Fail "channel manifest identifies $($channelManifest.channel), expected $Channel" }
529
+ Assert-PayloadContract $channelManifest "channel $Channel"
530
+ $channelAsset = Get-Asset $channelManifest "channel $Channel"
531
+ Assert-WindowsAssetSecurity $channelAsset "channel $Channel"
532
+ $Version = [string]$channelManifest.version
533
+ }
534
+ Assert-Version $Version
535
+
536
+ $downloadedArchivePath = Join-Path $TemporaryDir $AssetName
537
+ $downloadedChecksumPath = "$downloadedArchivePath.sha256"
538
+ $downloadedManifestPath = Join-Path $TemporaryDir 'release-manifest.json'
539
+ $downloadedSignaturePath = "$downloadedManifestPath.sig"
540
+ if ($localInputs.Count -eq 3) {
541
+ Write-Host "Loading local Free Code $Version candidate files..."
542
+ Copy-Item -LiteralPath ([IO.Path]::GetFullPath($ArchivePath)) -Destination $downloadedArchivePath
543
+ Copy-Item -LiteralPath ([IO.Path]::GetFullPath($ChecksumPath)) -Destination $downloadedChecksumPath
544
+ Copy-Item -LiteralPath ([IO.Path]::GetFullPath($ManifestPath)) -Destination $downloadedManifestPath
545
+ $localSignaturePath = "$([IO.Path]::GetFullPath($ManifestPath)).sig"
546
+ if (Test-Path -LiteralPath $localSignaturePath -PathType Leaf) {
547
+ Copy-Item -LiteralPath $localSignaturePath -Destination $downloadedSignaturePath
548
+ }
549
+ } else {
550
+ $releaseRoot = "$BaseUrl/v$Version"
551
+ Write-Host "Downloading Free Code $Version for Windows x64..."
552
+ Receive-File "$releaseRoot/$AssetName" $downloadedArchivePath
553
+ Receive-File "$releaseRoot/$AssetName.sha256" $downloadedChecksumPath
554
+ Receive-File "$releaseRoot/release-manifest.json" $downloadedManifestPath
555
+ Receive-File "$releaseRoot/release-manifest.json.sig" $downloadedSignaturePath
556
+ }
557
+
558
+ $manifest = Read-Json $downloadedManifestPath "release $Version"
559
+ $asset = Get-Asset $manifest "release $Version"
560
+ $localUnsignedOverride = $localInputs.Count -eq 3 -and $AllowUnsigned -and (Test-UnsignedAsset $asset.security)
561
+ if (-not $localUnsignedOverride) {
562
+ Assert-ReleaseManifestSignature $downloadedManifestPath $downloadedSignaturePath "release $Version"
563
+ }
564
+ if ([string]$manifest.version -ne $Version) {
565
+ Fail "release manifest version $($manifest.version) does not match requested version $Version"
566
+ }
567
+ if ($manifest.channel -ne $Channel) { Fail "release manifest channel $($manifest.channel) does not match $Channel" }
568
+ Assert-PayloadContract $manifest "release $Version"
569
+ Assert-WindowsAssetSecurity $asset "release $Version"
570
+ if ($asset.archive -ne $AssetName) { Fail "release manifest names unexpected asset $($asset.archive)" }
571
+
572
+ $checksumLine = (Get-Content -LiteralPath $downloadedChecksumPath | Select-Object -First 1)
573
+ if ($checksumLine -notmatch '^([a-fA-F0-9]{64})\s{1,2}(\*?)([^\s]+)$') {
574
+ Fail 'release checksum file is malformed'
575
+ }
576
+ $expectedChecksum = $Matches[1].ToLowerInvariant()
577
+ if ($Matches[3] -ne $AssetName) { Fail "checksum file names unexpected asset $($Matches[3])" }
578
+ if ([string]$asset.sha256 -ne $expectedChecksum) { Fail 'release manifest and checksum file disagree' }
579
+ $actualChecksum = Get-Sha256Hex $downloadedArchivePath
580
+ if ($actualChecksum -ne $expectedChecksum) { Fail "checksum verification failed for $AssetName" }
581
+
582
+ $InstallMutex = Enter-InstallLock
583
+ New-Item -ItemType Directory -Path $VersionsDir -Force | Out-Null
584
+ $versionRoot = Join-Path $VersionsDir $Version
585
+ $newInstall = -not (Test-Path -LiteralPath $versionRoot)
586
+ if ($newInstall) {
587
+ $StageDir = Join-Path $InstallDir ".install-$Version-$([Guid]::NewGuid().ToString('N'))"
588
+ New-Item -ItemType Directory -Path $StageDir -Force | Out-Null
589
+ Assert-ZipPaths $downloadedArchivePath $StageDir
590
+ [IO.Compression.ZipFile]::ExtractToDirectory($downloadedArchivePath, $StageDir)
591
+ Assert-CompleteInstall $StageDir
592
+ Assert-BundleIntegrity $StageDir $Version
593
+ Assert-Signatures $StageDir $asset.security
594
+ @{
595
+ schemaVersion = 1
596
+ product = $Product
597
+ version = $Version
598
+ asset = $asset
599
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $StageDir 'release-metadata.json') -Encoding UTF8
600
+ Move-Item -LiteralPath $StageDir -Destination $versionRoot
601
+ $StageDir = $null
602
+ } else {
603
+ Assert-CompleteInstall $versionRoot
604
+ Assert-BundleIntegrity $versionRoot $Version
605
+ $storedMetadata = Read-Json (Join-Path $versionRoot 'release-metadata.json') 'stored release metadata'
606
+ if (
607
+ [string]$storedMetadata.version -ne $Version -or
608
+ [string]$storedMetadata.asset.sha256 -ne [string]$asset.sha256 -or
609
+ [string](Get-OptionalProperty $storedMetadata.asset.security 'publisher') -ne [string](Get-OptionalProperty $asset.security 'publisher') -or
610
+ [bool](Test-UnsignedAsset $storedMetadata.asset.security) -ne [bool](Test-UnsignedAsset $asset.security)
611
+ ) {
612
+ Fail "existing version metadata does not match the verified release manifest for $Version"
613
+ }
614
+ Assert-Signatures $versionRoot $asset.security
615
+ }
616
+
617
+ New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
618
+ @{
619
+ schemaVersion = 1
620
+ product = $Product
621
+ installDir = $InstallDir
622
+ binDir = $BinDir
623
+ } | ConvertTo-Json | Set-Content -LiteralPath $MarkerPath -Encoding UTF8
624
+
625
+ $previous = if (Test-Path -LiteralPath $CurrentVersionPath) { (Get-Content -LiteralPath $CurrentVersionPath -Raw).Trim() } else { '' }
626
+ if ($previous -and $previous -ne $Version) { Write-TextAtomically $PreviousVersionPath "$previous`n" }
627
+ Write-TextAtomically $CurrentVersionPath "$Version`n"
628
+ Write-CommandShim
629
+
630
+ if (-not (Test-VersionHealth $Version)) {
631
+ if ($previous) {
632
+ Write-TextAtomically $CurrentVersionPath "$previous`n"
633
+ } else {
634
+ Remove-Item -LiteralPath $CurrentVersionPath -Force -ErrorAction SilentlyContinue
635
+ }
636
+ if ($newInstall) { Remove-Item -LiteralPath $versionRoot -Recurse -Force -ErrorAction SilentlyContinue }
637
+ Fail 'Free Code health checks failed; restored the previous activation'
638
+ }
639
+
640
+ if (-not $NoModifyPath) { Set-UserPath $true }
641
+ Write-Host ''
642
+ Write-Host "Free Code $Version installed successfully."
643
+ Write-Host "Command: $ShimPath"
644
+ Write-Host 'Open a new terminal, then run: freecode doctor'
645
+ } finally {
646
+ if ($StageDir -and (Test-Path -LiteralPath $StageDir)) {
647
+ Remove-Item -LiteralPath $StageDir -Recurse -Force -ErrorAction SilentlyContinue
648
+ }
649
+ if ($InstallMutex) {
650
+ Exit-InstallLock $InstallMutex
651
+ }
652
+ Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue
653
+ }