freecode-sh 0.1.0-alpha.15 → 0.1.0-alpha.19

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/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  Free Code is a free coding agent for your terminal. Ask it to build, fix,
4
4
  explain, and test code in your own projects.
5
5
 
6
- This npm package is a small macOS bootstrapper. On first launch it installs the
7
- matching signed native Free Code release, verifies its publisher signature and
8
- checksum, and then runs the native executable. Application code and prompts are
9
- not shipped in the npm package.
6
+ This npm package is a small macOS and Windows bootstrapper. On first launch it
7
+ installs the matching native Free Code release, verifies its publisher signature
8
+ and checksum, and then runs the native executable. Application code and prompts
9
+ are not shipped in the npm package.
10
10
 
11
11
  Both `freecode` and `freecode-sh` launch the same Free Code CLI.
12
12
 
@@ -22,7 +22,7 @@ freecode
22
22
  ```bash
23
23
  freecode logout
24
24
  npm uninstall -g freecode-sh
25
- rm -rf ~/.local/share/freecode # optional: also remove downloaded native versions
25
+ rm -rf ~/.local/share/freecode # macOS: optionally remove downloaded native versions
26
26
  ```
27
27
 
28
- Free Code currently supports Apple Silicon and Intel Macs.
28
+ Free Code supports Apple Silicon and Intel Macs, plus Windows x64.
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+
3
+ const requestedPlatform = process.env.FREECODE_INSTALL_OS
4
+ const platform = requestedPlatform === 'Darwin'
5
+ ? 'darwin'
6
+ : requestedPlatform === 'Windows_NT'
7
+ ? 'win32'
8
+ : process.platform
9
+ const architecture = process.env.FREECODE_INSTALL_ARCH || process.arch
10
+ const supportedHost = (
11
+ (platform === 'darwin' && ['arm64', 'x64'].includes(architecture))
12
+ || (platform === 'win32' && architecture === 'x64')
13
+ )
14
+
15
+ if (!supportedHost) {
16
+ process.stderr.write(
17
+ `freecode: Free Code's native npm installer supports macOS arm64/x64 and Windows x64 (detected ${platform}/${architecture}).\n`,
18
+ )
19
+ process.exit(1)
20
+ }
package/freecode.mjs CHANGED
@@ -6,10 +6,10 @@ import { access, readFile, realpath } from 'node:fs/promises'
6
6
  import { homedir } from 'node:os'
7
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
+ import { withoutPowerShellModulePath } from './npm-bootstrap-environment.mjs'
9
10
 
10
11
  const packageDirectory = dirname(fileURLToPath(import.meta.url))
11
12
  const bootstrapPath = fileURLToPath(import.meta.url)
12
- const installerPath = join(packageDirectory, 'install.sh')
13
13
  const packageMetadata = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
14
14
 
15
15
  if (packageMetadata.name !== 'freecode-sh' || !/^\d+\.\d+\.\d+-alpha\.\d+$/.test(packageMetadata.version)) {
@@ -17,49 +17,79 @@ if (packageMetadata.name !== 'freecode-sh' || !/^\d+\.\d+\.\d+-alpha\.\d+$/.test
17
17
  }
18
18
 
19
19
  const homeDirectory = homedir()
20
- if (!homeDirectory) fail('HOME is required to install Free Code.')
20
+ if (!homeDirectory) fail('A home directory is required to install Free Code.')
21
21
 
22
- const installRoot = resolve(process.env.FREECODE_INSTALL_ROOT || join(homeDirectory, '.local', 'share', 'freecode'))
22
+ const requestedPlatform = process.env.FREECODE_INSTALL_OS
23
+ const platform = requestedPlatform === 'Darwin'
24
+ ? 'darwin'
25
+ : requestedPlatform === 'Windows_NT'
26
+ ? 'win32'
27
+ : process.platform
28
+ const architecture = process.env.FREECODE_INSTALL_ARCH || process.arch
29
+ const supportedHost = (
30
+ (platform === 'darwin' && ['arm64', 'x64'].includes(architecture))
31
+ || (platform === 'win32' && architecture === 'x64')
32
+ )
33
+ if (!supportedHost) {
34
+ fail(`Free Code's native npm installer supports macOS arm64/x64 and Windows x64 (detected ${platform}/${architecture}).`)
35
+ }
36
+ const windows = platform === 'win32'
37
+ const defaultInstallRoot = windows
38
+ ? join(process.env.LOCALAPPDATA || join(homeDirectory, 'AppData', 'Local'), 'Freecode')
39
+ : join(homeDirectory, '.local', 'share', 'freecode')
40
+ const installRoot = resolve(process.env.FREECODE_INSTALL_ROOT || defaultInstallRoot)
23
41
  const recordedBinDirectory = await readFirstLine(join(installRoot, 'bin-dir'))
24
42
  let binDirectory = resolve(
25
- process.env.FREECODE_BIN_DIR ||
26
- recordedBinDirectory ||
27
- join(installRoot, 'npm-bin'),
43
+ windows
44
+ ? join(installRoot, 'bin')
45
+ : process.env.FREECODE_BIN_DIR || recordedBinDirectory || join(installRoot, 'npm-bin'),
28
46
  )
29
- let nativeExecutable = join(binDirectory, 'freecode')
30
- let nativeVersion = await managedNativeVersion(nativeExecutable, installRoot)
47
+ let nativeExecutable = join(binDirectory, windows ? 'freecode.cmd' : 'freecode')
48
+ let nativeVersion = await managedNativeVersion(nativeExecutable, installRoot, platform)
31
49
 
32
50
  if (nativeVersion && compareAlphaVersions(nativeVersion, packageMetadata.version) >= 0) {
33
- process.exitCode = await run(nativeExecutable, process.argv.slice(2))
51
+ process.exitCode = await runManaged(nativeExecutable, nativeVersion, process.argv.slice(2))
34
52
  } else {
35
- if (await sameFile(nativeExecutable, bootstrapPath)) {
53
+ if (!windows && await sameFile(nativeExecutable, bootstrapPath)) {
36
54
  binDirectory = join(installRoot, 'npm-bin')
37
55
  nativeExecutable = join(binDirectory, 'freecode')
38
56
  }
39
57
 
40
- const platform = process.env.FREECODE_INSTALL_OS === 'Darwin' ? 'darwin' : process.platform
41
- if (platform !== 'darwin') {
42
- fail(`Free Code's native npm installer currently supports macOS only (detected ${process.platform}).`)
43
- }
44
-
45
58
  process.stderr.write(`Installing Free Code ${packageMetadata.version}…\n`)
46
- const installStatus = await run('/bin/sh', [installerPath, '--version', packageMetadata.version], {
59
+ const installer = windows
60
+ ? {
61
+ command: 'powershell.exe',
62
+ args: ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(packageDirectory, 'install.ps1'), '-Version', packageMetadata.version],
63
+ }
64
+ : { command: '/bin/sh', args: [join(packageDirectory, 'install.sh'), '--version', packageMetadata.version] }
65
+ const installerEnvironment = {
47
66
  ...process.env,
48
67
  FREECODE_INSTALL_ROOT: installRoot,
49
68
  FREECODE_BIN_DIR: binDirectory,
50
69
  FREECODE_INSTALL_EMBEDDED: 'true',
51
- })
70
+ }
71
+ const installStatus = await run(
72
+ installer.command,
73
+ installer.args,
74
+ windows ? withoutPowerShellModulePath(installerEnvironment) : installerEnvironment,
75
+ )
52
76
  if (installStatus !== 0) process.exit(installStatus)
53
- nativeVersion = await managedNativeVersion(nativeExecutable, installRoot)
77
+ nativeVersion = await managedNativeVersion(nativeExecutable, installRoot, platform)
54
78
  if (!nativeVersion || compareAlphaVersions(nativeVersion, packageMetadata.version) < 0) {
55
79
  fail('The signed installer completed without creating a managed Free Code executable.')
56
80
  }
57
- process.exitCode = await run(nativeExecutable, process.argv.slice(2))
81
+ process.exitCode = await runManaged(nativeExecutable, nativeVersion, process.argv.slice(2))
58
82
  }
59
83
 
60
- async function managedNativeVersion(path, root) {
84
+ async function managedNativeVersion(path, root, currentPlatform) {
61
85
  try {
62
- await access(path, constants.X_OK)
86
+ await access(path, currentPlatform === 'win32' ? constants.F_OK : constants.X_OK)
87
+ if (currentPlatform === 'win32') {
88
+ const version = await readFirstLine(join(root, 'current-version'))
89
+ if (!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(version || '')) return undefined
90
+ await access(join(root, 'versions', version, 'bin', 'freecode.exe'), constants.F_OK)
91
+ return version
92
+ }
63
93
  const target = await realpath(path)
64
94
  const versionsDirectory = await realpath(resolve(root, 'versions'))
65
95
  const relativeTarget = relative(versionsDirectory, target)
@@ -81,6 +111,11 @@ async function managedNativeVersion(path, root) {
81
111
  }
82
112
  }
83
113
 
114
+ function runManaged(path, version, args) {
115
+ const command = windows ? join(installRoot, 'versions', version, 'bin', 'freecode.exe') : path
116
+ return run(command, args)
117
+ }
118
+
84
119
  function compareAlphaVersions(left, right) {
85
120
  const parse = (version) => {
86
121
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$/)
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
+ }
package/install.sh CHANGED
@@ -447,7 +447,8 @@ manifest_asset_value() {
447
447
  target=$2
448
448
  field=$3
449
449
  awk -v target="$target" -v field="$field" '
450
- index($0, "\"" target "\"") && index($0, ":") { in_target = 1; next }
450
+ /^[[:space:]]*"assets"[[:space:]]*:[[:space:]]*{/ { in_assets = 1; next }
451
+ in_assets && index($0, "\"" target "\"") && index($0, ":") { in_target = 1; next }
451
452
  in_target && index($0, "\"" field "\"") {
452
453
  value = $0
453
454
  sub(/^[^:]*:[[:space:]]*/, "", value)
@@ -0,0 +1,7 @@
1
+ export function withoutPowerShellModulePath(environment) {
2
+ const clean = { ...environment }
3
+ for (const key of Object.keys(clean)) {
4
+ if (key.toLowerCase() === 'psmodulepath') delete clean[key]
5
+ }
6
+ return clean
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freecode-sh",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "A free coding agent for your terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Free Code",
@@ -25,19 +25,22 @@
25
25
  "freecode-sh": "./freecode.mjs"
26
26
  },
27
27
  "files": [
28
+ "check-platform.mjs",
28
29
  "freecode.mjs",
29
- "install.sh"
30
+ "npm-bootstrap-environment.mjs",
31
+ "install.sh",
32
+ "install.ps1"
30
33
  ],
31
34
  "engines": {
32
35
  "node": ">=20.9.0"
33
36
  },
34
37
  "os": [
35
- "darwin"
36
- ],
37
- "cpu": [
38
- "arm64",
39
- "x64"
38
+ "darwin",
39
+ "win32"
40
40
  ],
41
+ "scripts": {
42
+ "preinstall": "node check-platform.mjs"
43
+ },
41
44
  "publishConfig": {
42
45
  "access": "public",
43
46
  "tag": "latest"