@pablozaiden/installer 0.0.10 → 0.2.0

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
@@ -23,6 +23,14 @@ Supported targets are:
23
23
  | --- | --- |
24
24
  | `linux` | `x64`, `arm64` |
25
25
  | `darwin` | `x64`, `arm64` |
26
+ | `windows` | `x64`, `arm64` |
27
+
28
+ Windows assets add the executable extension after the target:
29
+
30
+ ```text
31
+ <assetPrefix>-<tag>-windows-<arch>.exe
32
+ <assetPrefix>-<tag>-windows-<arch>.exe.sha256
33
+ ```
26
34
 
27
35
  Tags may be provided as `1.2.3` or `v1.2.3`; release assets are always resolved with the `v` tag form published by GitHub releases.
28
36
 
@@ -69,9 +77,20 @@ The target repository should publish an installer manifest at either:
69
77
  - `.github/installer.json`
70
78
  - `.installer.json`
71
79
 
80
+ On Windows, run the PowerShell installer:
81
+
82
+ ```powershell
83
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/pablozaiden/installer/main/install.ps1))) pablozaiden/link
84
+ ```
85
+
86
+ It installs `<name>.exe` into `$HOME/.local/bin` by default and adds that
87
+ directory to the user `PATH`. Pass `-NoModifyPath` to print guidance without
88
+ changing `PATH`.
89
+
72
90
  The installer:
73
91
 
74
- 1. Detects Linux/macOS and x64/arm64.
92
+ 1. Detects Linux/macOS and x64/arm64; the PowerShell entrypoint detects
93
+ Windows x64/arm64.
75
94
  2. Loads the target repository's manifest.
76
95
  3. Fetches the latest GitHub release.
77
96
  4. Downloads each configured binary asset.
@@ -101,7 +120,8 @@ Single-binary example:
101
120
  },
102
121
  "platforms": {
103
122
  "linux": ["x64", "arm64"],
104
- "darwin": ["x64", "arm64"]
123
+ "darwin": ["x64", "arm64"],
124
+ "windows": ["x64", "arm64"]
105
125
  }
106
126
  }
107
127
  ```
@@ -164,12 +184,18 @@ Use `runUpdateCommand` from an installed binary's `update` command.
164
184
  import { runUpdateCommand } from "@pablozaiden/installer";
165
185
  import { MYPROJECT_VERSION } from "./version";
166
186
 
167
- export async function runCliCommand(command: { kind: string; checkOnly?: boolean; version?: string }) {
187
+ export async function runCliCommand(command: {
188
+ kind: string;
189
+ checkOnly?: boolean;
190
+ version?: string;
191
+ preRelease?: boolean;
192
+ }) {
168
193
  if (command.kind === "update") {
169
194
  return await runUpdateCommand(
170
195
  {
171
196
  checkOnly: command.checkOnly ?? false,
172
197
  version: command.version,
198
+ preRelease: command.preRelease,
173
199
  },
174
200
  {
175
201
  repository: "pablozaiden/myproject",
@@ -183,6 +209,12 @@ export async function runCliCommand(command: { kind: string; checkOnly?: boolean
183
209
  }
184
210
  ```
185
211
 
212
+ Set `preRelease: true` to consider published prereleases. The updater selects
213
+ the prerelease with the highest semantic version only when it is newer than
214
+ the latest stable release; otherwise, it uses the stable release. The same
215
+ selection applies to `checkOnly`. An explicit `version` continues to install
216
+ that exact release.
217
+
186
218
  For a CLI with a companion binary installed beside it:
187
219
 
188
220
  ```ts
@@ -214,15 +246,22 @@ await runUpdateCommand(
214
246
  The updater supports:
215
247
 
216
248
  - latest release checks,
249
+ - opt-in prerelease selection when a prerelease is newer than the latest stable release,
217
250
  - explicit version installs,
218
- - semver comparison including prereleases,
251
+ - SemVer comparison including prereleases, with build metadata ignored for precedence,
219
252
  - GitHub release metadata validation,
220
- - Linux/macOS x64/arm64 target resolution,
253
+ - Linux/macOS/Windows x64/arm64 target resolution,
221
254
  - checksum verification before replacement,
222
255
  - source-mode rejection when running from `bun`,
223
256
  - staged temp-file replacement with executable permission preservation,
224
257
  - companion binary updates that are committed together with the primary binary and rolled back on replacement failure.
225
258
 
259
+ Windows cannot replace a running executable. The updater stages and verifies
260
+ the complete update, then starts a detached PowerShell helper. The helper waits
261
+ for the current process to exit, applies all replacements as one rollback-aware
262
+ operation, and writes `<binary>.update-error.log` beside the executable if the
263
+ deferred operation fails.
264
+
226
265
  Exported helpers include:
227
266
 
228
267
  ```ts
@@ -310,7 +349,8 @@ jobs:
310
349
  The workflow:
311
350
 
312
351
  - runs on GitHub release publication,
313
- - builds `linux-x64`, `linux-arm64`, `darwin-x64`, and `darwin-arm64`,
352
+ - builds `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`,
353
+ `windows-x64`, and `windows-arm64`,
314
354
  - exports `TAG`, `VERSION`, `RELEASE_TARGET`, `BUN_TARGET`, `BINARY_NAME`, `ASSET_PREFIX`, and `ASSET_PATH` to each build command,
315
355
  - stages release assets using the shared naming convention,
316
356
  - generates `.sha256` files by default,
package/install.ps1 ADDED
@@ -0,0 +1,444 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Position = 0)]
4
+ [string]$Repository,
5
+ [string]$Ref = "main",
6
+ [string[]]$Binary = @(),
7
+ [string[]]$AssetPrefix = @(),
8
+ [string]$InstallDir,
9
+ [ValidateSet("required", "optional", "none")]
10
+ [string]$Checksum = "required",
11
+ [switch]$NoModifyPath,
12
+ [switch]$Help
13
+ )
14
+
15
+ Set-StrictMode -Version Latest
16
+ $ErrorActionPreference = "Stop"
17
+
18
+ $InstallerVersion = "0.0.0-development"
19
+ $DefaultInstallDir = '$HOME/.local/bin'
20
+ $DefaultManifestPaths = @(".github/installer.json", ".installer.json")
21
+ $DefaultChecksumExtension = ".sha256"
22
+ $ChecksumWasSpecified = $PSBoundParameters.ContainsKey("Checksum")
23
+
24
+ function Show-Usage {
25
+ @"
26
+ Usage:
27
+ install.ps1 <owner>/<repo> [options]
28
+
29
+ Options:
30
+ -Ref <ref> Repository ref used to read manifests (default: main)
31
+ -Binary <name[]> Binary names when no manifest is available
32
+ -AssetPrefix <prefix[]> Asset prefixes corresponding to -Binary values
33
+ -InstallDir <dir> Install directory (default: `$HOME/.local/bin)
34
+ -Checksum required|optional|none
35
+ -NoModifyPath Do not add the install directory to the user PATH
36
+ -Help Show this help
37
+ "@
38
+ }
39
+
40
+ function Fail([string]$Message) {
41
+ [Console]::Error.WriteLine("Error: $Message")
42
+ exit 1
43
+ }
44
+
45
+ function Invoke-Download {
46
+ param(
47
+ [Parameter(Mandatory = $true)]
48
+ [string]$Uri,
49
+ [Parameter(Mandatory = $true)]
50
+ [string]$OutFile
51
+ )
52
+
53
+ Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $OutFile
54
+ }
55
+
56
+ function Invoke-JsonRequest([string]$Uri) {
57
+ return Invoke-RestMethod -UseBasicParsing -Uri $Uri -Headers @{
58
+ "User-Agent" = "pablozaiden-installer"
59
+ "Accept" = "application/vnd.github+json"
60
+ }
61
+ }
62
+
63
+ function Invoke-RawJsonRequest([string]$Uri) {
64
+ $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -Headers @{
65
+ "User-Agent" = "pablozaiden-installer"
66
+ "Accept" = "application/json"
67
+ }
68
+ return ConvertFrom-Json -InputObject ([string]$response.Content)
69
+ }
70
+
71
+ function Test-HttpStatus {
72
+ param(
73
+ [Parameter(Mandatory = $true)]
74
+ [object]$ErrorRecord,
75
+ [Parameter(Mandatory = $true)]
76
+ [int]$StatusCode
77
+ )
78
+
79
+ $responseProperty = $ErrorRecord.Exception.PSObject.Properties["Response"]
80
+ if ($null -eq $responseProperty -or $null -eq $responseProperty.Value) {
81
+ return $false
82
+ }
83
+ $statusProperty = $responseProperty.Value.PSObject.Properties["StatusCode"]
84
+ return $null -ne $statusProperty -and [int]$statusProperty.Value -eq $StatusCode
85
+ }
86
+
87
+ function Expand-InstallDirectory([string]$Value) {
88
+ if ($Value -eq '$HOME' -or $Value -eq "~") {
89
+ return $HOME
90
+ }
91
+ if ($Value.StartsWith('$HOME/') -or $Value.StartsWith('$HOME\')) {
92
+ return Join-Path $HOME $Value.Substring(6)
93
+ }
94
+ if ($Value.StartsWith("~/") -or $Value.StartsWith("~\")) {
95
+ return Join-Path $HOME $Value.Substring(2)
96
+ }
97
+ return $Value
98
+ }
99
+
100
+ function Get-ReleaseArchitecture {
101
+ $architecture = $env:PROCESSOR_ARCHITEW6432
102
+ if ([string]::IsNullOrWhiteSpace($architecture)) {
103
+ $architecture = $env:PROCESSOR_ARCHITECTURE
104
+ }
105
+ switch ($architecture.ToUpperInvariant()) {
106
+ "AMD64" { return "x64" }
107
+ "ARM64" { return "arm64" }
108
+ default { Fail "Unsupported architecture: $architecture. Supported architectures are x64 and arm64." }
109
+ }
110
+ }
111
+
112
+ function Get-Manifest {
113
+ param(
114
+ [Parameter(Mandatory = $true)]
115
+ [string]$TargetRepository,
116
+ [Parameter(Mandatory = $true)]
117
+ [string]$TargetRef
118
+ )
119
+
120
+ $rawBaseUrl = if ($env:RAW_BASE_URL) {
121
+ $env:RAW_BASE_URL.TrimEnd("/")
122
+ } else {
123
+ "https://raw.githubusercontent.com"
124
+ }
125
+
126
+ foreach ($path in $DefaultManifestPaths) {
127
+ $uri = "$rawBaseUrl/$TargetRepository/$TargetRef/$path"
128
+ try {
129
+ $manifest = Invoke-RawJsonRequest $uri
130
+ return [PSCustomObject]@{
131
+ Path = $path
132
+ Value = $manifest
133
+ }
134
+ } catch {
135
+ if (Test-HttpStatus -ErrorRecord $_ -StatusCode 404) {
136
+ continue
137
+ }
138
+ throw
139
+ }
140
+ }
141
+ return $null
142
+ }
143
+
144
+ function Assert-ManifestPlatform {
145
+ param(
146
+ [Parameter(Mandatory = $true)]
147
+ [object]$Manifest,
148
+ [Parameter(Mandatory = $true)]
149
+ [string]$Architecture
150
+ )
151
+
152
+ $platformsProperty = $Manifest.PSObject.Properties["platforms"]
153
+ if ($null -eq $platformsProperty) {
154
+ return
155
+ }
156
+ $windowsProperty = $platformsProperty.Value.PSObject.Properties["windows"]
157
+ if ($null -eq $windowsProperty -or @($windowsProperty.Value) -notcontains $Architecture) {
158
+ Fail "Manifest does not support platform windows-$Architecture."
159
+ }
160
+ }
161
+
162
+ function Get-ExpectedChecksum {
163
+ param(
164
+ [Parameter(Mandatory = $true)]
165
+ [string]$ChecksumPath,
166
+ [Parameter(Mandatory = $true)]
167
+ [string]$AssetName
168
+ )
169
+
170
+ $plainHashes = @()
171
+ foreach ($line in (Get-Content -LiteralPath $ChecksumPath)) {
172
+ $match = [regex]::Match($line.Trim(), '^([0-9a-fA-F]{64})(?:\s+\*?(.+))?$')
173
+ if (-not $match.Success) {
174
+ continue
175
+ }
176
+ $hash = $match.Groups[1].Value.ToLowerInvariant()
177
+ $fileName = $match.Groups[2].Value.Trim()
178
+ if ($fileName) {
179
+ if ([IO.Path]::GetFileName($fileName) -eq $AssetName) {
180
+ return $hash
181
+ }
182
+ continue
183
+ }
184
+ $plainHashes += $hash
185
+ }
186
+ if ($plainHashes.Count -eq 1) {
187
+ return $plainHashes[0]
188
+ }
189
+ Fail "Checksum for $AssetName did not contain a valid SHA-256 entry."
190
+ }
191
+
192
+ function Get-Sha256([string]$Path) {
193
+ $stream = [IO.File]::OpenRead($Path)
194
+ $algorithm = [Security.Cryptography.SHA256]::Create()
195
+ try {
196
+ return [BitConverter]::ToString($algorithm.ComputeHash($stream)).Replace("-", "").ToLowerInvariant()
197
+ } finally {
198
+ $algorithm.Dispose()
199
+ $stream.Dispose()
200
+ }
201
+ }
202
+
203
+ function Install-Binary {
204
+ param(
205
+ [Parameter(Mandatory = $true)]
206
+ [string]$TargetRepository,
207
+ [Parameter(Mandatory = $true)]
208
+ [string]$Tag,
209
+ [Parameter(Mandatory = $true)]
210
+ [string]$Architecture,
211
+ [Parameter(Mandatory = $true)]
212
+ [string]$TargetInstallDir,
213
+ [Parameter(Mandatory = $true)]
214
+ [string]$ChecksumPolicy,
215
+ [Parameter(Mandatory = $true)]
216
+ [string]$ChecksumExtension,
217
+ [Parameter(Mandatory = $true)]
218
+ [object]$BinaryDefinition
219
+ )
220
+
221
+ $name = [string]$BinaryDefinition.name
222
+ $prefixProperty = $BinaryDefinition.PSObject.Properties["assetPrefix"]
223
+ $assetPrefix = if ($null -eq $prefixProperty -or [string]::IsNullOrWhiteSpace([string]$prefixProperty.Value)) {
224
+ $name
225
+ } else {
226
+ [string]$prefixProperty.Value
227
+ }
228
+ $requiredProperty = $BinaryDefinition.PSObject.Properties["required"]
229
+ $required = $null -eq $requiredProperty -or [bool]$requiredProperty.Value
230
+ $assetName = "$assetPrefix-$Tag-windows-$Architecture.exe"
231
+ $releaseBaseUrl = if ($env:GITHUB_RELEASE_BASE_URL) {
232
+ $env:GITHUB_RELEASE_BASE_URL.TrimEnd("/")
233
+ } else {
234
+ "https://github.com"
235
+ }
236
+ $downloadUrl = "$releaseBaseUrl/$TargetRepository/releases/download/$Tag/$assetName"
237
+ $tempFile = [IO.Path]::GetTempFileName()
238
+ $checksumFile = [IO.Path]::GetTempFileName()
239
+
240
+ try {
241
+ [Console]::WriteLine("Downloading $assetName...")
242
+ try {
243
+ Invoke-Download -Uri $downloadUrl -OutFile $tempFile
244
+ } catch {
245
+ if (-not $required -and (Test-HttpStatus -ErrorRecord $_ -StatusCode 404)) {
246
+ [Console]::WriteLine("Skipping optional binary $name; $assetName was not published.")
247
+ return $false
248
+ }
249
+ throw
250
+ }
251
+
252
+ if ($ChecksumPolicy -eq "none") {
253
+ [Console]::WriteLine("Skipping checksum verification for $assetName; checksum policy is none.")
254
+ } else {
255
+ $checksumName = "$assetName$ChecksumExtension"
256
+ $checksumUrl = "$downloadUrl$ChecksumExtension"
257
+ $checksumDownloaded = $true
258
+ try {
259
+ Invoke-Download -Uri $checksumUrl -OutFile $checksumFile
260
+ } catch {
261
+ if ($ChecksumPolicy -eq "required" -or -not (Test-HttpStatus -ErrorRecord $_ -StatusCode 404)) {
262
+ throw
263
+ }
264
+ [Console]::WriteLine("Skipping checksum verification for $assetName; $checksumName was not published.")
265
+ $checksumDownloaded = $false
266
+ }
267
+ if ($checksumDownloaded) {
268
+ [Console]::WriteLine("Verifying $checksumName...")
269
+ $expected = Get-ExpectedChecksum -ChecksumPath $checksumFile -AssetName $assetName
270
+ $actual = Get-Sha256 $tempFile
271
+ if ($expected -ne $actual) {
272
+ Fail "Checksum verification failed for $assetName`: expected $expected, got $actual."
273
+ }
274
+ }
275
+ }
276
+
277
+ $fileName = if ($name.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) {
278
+ $name
279
+ } else {
280
+ "$name.exe"
281
+ }
282
+ $targetPath = Join-Path $TargetInstallDir $fileName
283
+ Move-Item -LiteralPath $tempFile -Destination $targetPath -Force
284
+ [Console]::WriteLine("Installed $name to $targetPath")
285
+ return $true
286
+ } finally {
287
+ Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
288
+ Remove-Item -LiteralPath $checksumFile -Force -ErrorAction SilentlyContinue
289
+ }
290
+ }
291
+
292
+ function Add-UserPath([string]$Directory) {
293
+ $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd("\")
294
+ $currentEntries = @($env:Path -split ";" | Where-Object { $_ })
295
+ $alreadyCurrent = $currentEntries | Where-Object {
296
+ try {
297
+ [IO.Path]::GetFullPath(
298
+ [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"'))
299
+ ).TrimEnd("\").Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
300
+ } catch {
301
+ $false
302
+ }
303
+ }
304
+ if ($alreadyCurrent) {
305
+ [Console]::WriteLine("")
306
+ [Console]::WriteLine("Installation complete!")
307
+ return
308
+ }
309
+
310
+ if ($NoModifyPath) {
311
+ [Console]::WriteLine("")
312
+ [Console]::WriteLine("Warning: $fullDirectory is not in PATH.")
313
+ [Console]::WriteLine("Add it to your user PATH before invoking installed binaries by name.")
314
+ return
315
+ }
316
+
317
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
318
+ $userEntries = @($userPath -split ";" | Where-Object { $_ })
319
+ $alreadyUser = $userEntries | Where-Object {
320
+ try {
321
+ [IO.Path]::GetFullPath(
322
+ [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"'))
323
+ ).TrimEnd("\").Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
324
+ } catch {
325
+ $false
326
+ }
327
+ }
328
+ if (-not $alreadyUser) {
329
+ $nextUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) {
330
+ $fullDirectory
331
+ } else {
332
+ "$fullDirectory;$userPath"
333
+ }
334
+ [Environment]::SetEnvironmentVariable("Path", $nextUserPath, "User")
335
+ }
336
+ $env:Path = "$fullDirectory;$env:Path"
337
+ [Console]::WriteLine("")
338
+ [Console]::WriteLine("Added $fullDirectory to the user PATH.")
339
+ [Console]::WriteLine("Open a new terminal before invoking installed binaries by name.")
340
+ }
341
+
342
+ if ($Help) {
343
+ Show-Usage
344
+ exit 0
345
+ }
346
+ if ([string]::IsNullOrWhiteSpace($Repository)) {
347
+ Show-Usage
348
+ exit 1
349
+ }
350
+ if ($env:OS -ne "Windows_NT") {
351
+ Fail "install.ps1 supports Windows only. Use install.sh on Linux or macOS."
352
+ }
353
+ if ($Repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
354
+ Fail "Repository must be <owner>/<repo>."
355
+ }
356
+
357
+ $architecture = Get-ReleaseArchitecture
358
+ [Console]::WriteLine("Installer version: $InstallerVersion")
359
+ [Console]::WriteLine("Detected platform: windows-$architecture")
360
+
361
+ $manifestResult = Get-Manifest -TargetRepository $Repository -TargetRef $Ref
362
+ $binaries = @()
363
+ $checksumPolicy = $Checksum
364
+ $checksumExtension = $DefaultChecksumExtension
365
+ $postInstallMessage = $null
366
+
367
+ if ($null -ne $manifestResult) {
368
+ [Console]::WriteLine("Loaded installer manifest: $($manifestResult.Path)")
369
+ $manifest = $manifestResult.Value
370
+ if ($manifest.schemaVersion -ne 1) {
371
+ Fail "Unsupported installer manifest schemaVersion: $($manifest.schemaVersion). Supported schemaVersion is 1."
372
+ }
373
+ Assert-ManifestPlatform -Manifest $manifest -Architecture $architecture
374
+ if ($manifest.PSObject.Properties["repo"]) {
375
+ $Repository = [string]$manifest.repo
376
+ }
377
+ if ($manifest.PSObject.Properties["installDir"]) {
378
+ $InstallDir = [string]$manifest.installDir
379
+ }
380
+ if (-not $ChecksumWasSpecified -and $manifest.PSObject.Properties["checksums"]) {
381
+ $checksumPolicy = if ([bool]$manifest.checksums.required) { "required" } else { "optional" }
382
+ if ($manifest.checksums.PSObject.Properties["extension"]) {
383
+ $checksumExtension = [string]$manifest.checksums.extension
384
+ }
385
+ }
386
+ $binaries = @($manifest.binaries)
387
+ if ($manifest.PSObject.Properties["postInstallMessage"]) {
388
+ $postInstallMessage = [string]$manifest.postInstallMessage
389
+ }
390
+ } else {
391
+ if ($Binary.Count -eq 0) {
392
+ Fail "No installer manifest found. Add .github/installer.json or pass -Binary <name>."
393
+ }
394
+ for ($index = 0; $index -lt $Binary.Count; $index += 1) {
395
+ $prefix = if ($index -lt $AssetPrefix.Count) { $AssetPrefix[$index] } else { $Binary[$index] }
396
+ $binaries += [PSCustomObject]@{
397
+ name = $Binary[$index]
398
+ assetPrefix = $prefix
399
+ required = $true
400
+ }
401
+ }
402
+ }
403
+
404
+ if ($binaries.Count -eq 0) {
405
+ Fail "No binaries configured for installation."
406
+ }
407
+ if ([string]::IsNullOrWhiteSpace($InstallDir)) {
408
+ $InstallDir = $DefaultInstallDir
409
+ }
410
+ $InstallDir = Expand-InstallDirectory $InstallDir
411
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
412
+
413
+ $apiBaseUrl = if ($env:GITHUB_API_BASE_URL) {
414
+ $env:GITHUB_API_BASE_URL.TrimEnd("/")
415
+ } else {
416
+ "https://api.github.com"
417
+ }
418
+ [Console]::WriteLine("Fetching latest release...")
419
+ $release = Invoke-JsonRequest "$apiBaseUrl/repos/$Repository/releases/latest"
420
+ $tag = [string]$release.tag_name
421
+ if ([string]::IsNullOrWhiteSpace($tag)) {
422
+ Fail "Could not determine latest release version."
423
+ }
424
+ [Console]::WriteLine("Latest version: $tag")
425
+
426
+ foreach ($binaryDefinition in $binaries) {
427
+ $installed = Install-Binary `
428
+ -TargetRepository $Repository `
429
+ -Tag $tag `
430
+ -Architecture $architecture `
431
+ -TargetInstallDir $InstallDir `
432
+ -ChecksumPolicy $checksumPolicy `
433
+ -ChecksumExtension $checksumExtension `
434
+ -BinaryDefinition $binaryDefinition
435
+ $messageProperty = $binaryDefinition.PSObject.Properties["postInstallMessage"]
436
+ if ($installed -and $null -ne $messageProperty -and $messageProperty.Value) {
437
+ [Console]::WriteLine([string]$messageProperty.Value)
438
+ }
439
+ }
440
+
441
+ if ($postInstallMessage) {
442
+ [Console]::WriteLine($postInstallMessage)
443
+ }
444
+ Add-UserPath $InstallDir
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/installer",
3
- "version": "0.0.10",
3
+ "version": "0.2.0",
4
4
  "description": "Reusable GitHub release binary installer and updater utilities",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  "index.ts",
13
13
  "src/**/*",
14
14
  "install.sh",
15
+ "install.ps1",
15
16
  "README.md"
16
17
  ],
17
18
  "exports": {
package/src/contract.ts CHANGED
@@ -11,9 +11,11 @@ export const SUPPORTED_RELEASE_TARGETS = [
11
11
  "linux-arm64",
12
12
  "darwin-x64",
13
13
  "darwin-arm64",
14
+ "windows-x64",
15
+ "windows-arm64",
14
16
  ] as const;
15
17
 
16
- export type ReleaseOs = "linux" | "darwin";
18
+ export type ReleaseOs = "linux" | "darwin" | "windows";
17
19
  export type ReleaseArch = "x64" | "arm64";
18
20
  export type ReleaseTarget = `${ReleaseOs}-${ReleaseArch}`;
19
21
 
@@ -44,7 +46,7 @@ type ParsedVersion = {
44
46
  };
45
47
 
46
48
  function parseVersion(value: string): ParsedVersion | null {
47
- const parsed = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(normalizeReleaseVersion(value));
49
+ const parsed = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalizeReleaseVersion(value));
48
50
  if (!parsed) {
49
51
  return null;
50
52
  }
@@ -133,7 +135,11 @@ export function parseReleaseTarget(target: string): ReleasePlatform {
133
135
  }
134
136
 
135
137
  export function resolveReleasePlatform(platform: string, arch: string): ReleasePlatform {
136
- const os = platform === "linux" || platform === "darwin" ? platform : undefined;
138
+ const os = platform === "win32"
139
+ ? "windows"
140
+ : platform === "linux" || platform === "darwin" || platform === "windows"
141
+ ? platform
142
+ : undefined;
137
143
  const normalizedArch = arch === "x64" || arch === "amd64"
138
144
  ? "x64"
139
145
  : arch === "arm64" || arch === "aarch64"
@@ -147,11 +153,18 @@ export function resolveReleasePlatform(platform: string, arch: string): ReleaseP
147
153
  };
148
154
  }
149
155
 
150
- throw new Error(`Unsupported platform: ${platform}-${arch}. Supported release targets are Linux and macOS on x64 and arm64.`);
156
+ throw new Error(`Unsupported platform: ${platform}-${arch}. Supported release targets are Linux, macOS, and Windows on x64 and arm64.`);
151
157
  }
152
158
 
153
159
  export function buildReleaseAssetName(assetPrefix: string, tag: string, target: ReleasePlatform): string {
154
- return `${assetPrefix}-${normalizeReleaseTag(tag)}-${target.os}-${target.arch}`;
160
+ const extension = target.os === "windows" ? ".exe" : "";
161
+ return `${assetPrefix}-${normalizeReleaseTag(tag)}-${target.os}-${target.arch}${extension}`;
162
+ }
163
+
164
+ export function releaseBinaryFileName(binaryName: string, target: ReleasePlatform): string {
165
+ return target.os === "windows" && !binaryName.toLowerCase().endsWith(".exe")
166
+ ? `${binaryName}.exe`
167
+ : binaryName;
155
168
  }
156
169
 
157
170
  export function assertGitHubRepository(value: string): GitHubRepository {
package/src/manifest.ts CHANGED
@@ -26,7 +26,7 @@ export type InstallerManifest = {
26
26
  installDir?: string;
27
27
  binaries: InstallerManifestBinary[];
28
28
  checksums?: InstallerChecksumPolicy;
29
- platforms?: Partial<Record<"linux" | "darwin", Array<"x64" | "arm64">>>;
29
+ platforms?: Partial<Record<"linux" | "darwin" | "windows", Array<"x64" | "arm64">>>;
30
30
  postInstallMessage?: string;
31
31
  };
32
32
 
@@ -118,7 +118,7 @@ function parsePlatforms(value: unknown): InstallerManifest["platforms"] {
118
118
  }
119
119
  const raw = expectObject(value, "platforms");
120
120
  const platforms: InstallerManifest["platforms"] = {};
121
- for (const os of ["linux", "darwin"] as const) {
121
+ for (const os of ["linux", "darwin", "windows"] as const) {
122
122
  const arches = raw[os];
123
123
  if (arches === undefined) {
124
124
  continue;
@@ -168,7 +168,7 @@ function normalizeTargets(platforms: InstallerManifest["platforms"]): ReleaseTar
168
168
  return [...SUPPORTED_RELEASE_TARGETS];
169
169
  }
170
170
  const targets: ReleaseTarget[] = [];
171
- for (const os of ["linux", "darwin"] as const) {
171
+ for (const os of ["linux", "darwin", "windows"] as const) {
172
172
  for (const arch of platforms[os] ?? []) {
173
173
  targets.push(`${os}-${arch}`);
174
174
  }
package/src/update.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  githubApiRepositoryUrl,
11
11
  normalizeReleaseTag,
12
12
  normalizeReleaseVersion,
13
+ releaseBinaryFileName,
13
14
  resolveReleasePlatform,
14
15
  type GitHubRepository,
15
16
  type ReleasePlatform,
@@ -18,6 +19,7 @@ import {
18
19
  export type UpdateCommandOptions = {
19
20
  checkOnly: boolean;
20
21
  version?: string;
22
+ preRelease?: boolean;
21
23
  };
22
24
 
23
25
  export type UpdaterChecksumPolicy = {
@@ -62,6 +64,8 @@ export type UpdaterDependencies = {
62
64
  renameFile: (from: string, to: string) => Promise<void>;
63
65
  removeFile: (path: string) => Promise<void>;
64
66
  statFile: (path: string) => Promise<{ mode: number }>;
67
+ getCurrentProcessId: () => number;
68
+ spawnDetached: (command: string, args: readonly string[]) => Promise<void>;
65
69
  };
66
70
 
67
71
  export type GitHubReleaseAsset = {
@@ -72,6 +76,8 @@ export type GitHubReleaseAsset = {
72
76
  export type GitHubRelease = {
73
77
  tag_name: string;
74
78
  assets: GitHubReleaseAsset[];
79
+ draft?: boolean;
80
+ prerelease?: boolean;
75
81
  };
76
82
 
77
83
  export type ResolvedReleaseAsset = {
@@ -98,6 +104,102 @@ type StagedBinaryReplacement = {
98
104
  backupPath: string;
99
105
  };
100
106
 
107
+ function powerShellString(value: string): string {
108
+ return `'${value.replaceAll("'", "''")}'`;
109
+ }
110
+
111
+ function windowsUpdateHelper(
112
+ stagedReplacements: StagedBinaryReplacement[],
113
+ statusPath: string,
114
+ parentProcessId: number,
115
+ ): string {
116
+ const replacements = stagedReplacements.map((staged) => [
117
+ " [PSCustomObject]@{",
118
+ ` TargetPath = ${powerShellString(staged.target.targetPath)}`,
119
+ ` TempDirectory = ${powerShellString(staged.tempDirectory)}`,
120
+ ` TempPath = ${powerShellString(staged.tempPath)}`,
121
+ ` BackupPath = ${powerShellString(staged.backupPath)}`,
122
+ " }",
123
+ ].join("\n")).join("\n");
124
+
125
+ return String.raw`
126
+ $ErrorActionPreference = "Stop"
127
+ $ParentProcessId = ${String(parentProcessId)}
128
+ $StatusPath = ${powerShellString(statusPath)}
129
+ $replacements = @(
130
+ ${replacements}
131
+ )
132
+ $utf8 = [System.Text.UTF8Encoding]::new($false)
133
+
134
+ function Write-UpdateStatus([string]$Message) {
135
+ [System.IO.File]::WriteAllText($StatusPath, $Message, $utf8)
136
+ }
137
+
138
+ Write-UpdateStatus "Update helper started."
139
+ $deadline = [DateTime]::UtcNow.AddMinutes(5)
140
+
141
+ while ($ParentProcessId -gt 0) {
142
+ try {
143
+ $parentProcess = [System.Diagnostics.Process]::GetProcessById($ParentProcessId)
144
+ if ($parentProcess.HasExited) {
145
+ break
146
+ }
147
+ } catch [System.ArgumentException] {
148
+ break
149
+ }
150
+ if ([DateTime]::UtcNow -ge $deadline) {
151
+ Write-UpdateStatus "Timed out waiting for process $ParentProcessId to exit."
152
+ exit 1
153
+ }
154
+ [System.Threading.Thread]::Sleep(100)
155
+ }
156
+
157
+ $movedBackups = [System.Collections.Generic.List[object]]::new()
158
+ $installedReplacements = [System.Collections.Generic.List[object]]::new()
159
+
160
+ try {
161
+ foreach ($replacement in $replacements) {
162
+ [System.IO.File]::Move($replacement.TargetPath, $replacement.BackupPath)
163
+ $movedBackups.Add($replacement)
164
+ [System.IO.File]::Move($replacement.TempPath, $replacement.TargetPath)
165
+ $installedReplacements.Add($replacement)
166
+ }
167
+ } catch {
168
+ $failure = $_.Exception.Message
169
+ try {
170
+ for ($index = $installedReplacements.Count - 1; $index -ge 0; $index -= 1) {
171
+ [System.IO.File]::Delete($installedReplacements[$index].TargetPath)
172
+ }
173
+ for ($index = $movedBackups.Count - 1; $index -ge 0; $index -= 1) {
174
+ [System.IO.File]::Move(
175
+ $movedBackups[$index].BackupPath,
176
+ $movedBackups[$index].TargetPath
177
+ )
178
+ }
179
+ } catch {
180
+ $failure = $failure + " Rollback failed: " + $_.Exception.Message
181
+ }
182
+ Write-UpdateStatus $failure
183
+ exit 1
184
+ }
185
+
186
+ [System.IO.File]::Delete($StatusPath)
187
+ $cleanupDirectories = [System.Collections.Generic.HashSet[string]]::new(
188
+ [System.StringComparer]::OrdinalIgnoreCase
189
+ )
190
+ foreach ($replacement in $replacements) {
191
+ [void]$cleanupDirectories.Add($replacement.TempDirectory)
192
+ }
193
+ foreach ($directory in $cleanupDirectories) {
194
+ try {
195
+ [System.IO.Directory]::Delete($directory, $true)
196
+ } catch {
197
+ Write-UpdateStatus ("Update installed but cleanup failed: " + $_.Exception.Message)
198
+ }
199
+ }
200
+ `;
201
+ }
202
+
101
203
  function createDefaultUpdateDependencies(): UpdaterDependencies {
102
204
  return {
103
205
  fetchFn: fetch,
@@ -127,6 +229,20 @@ function createDefaultUpdateDependencies(): UpdaterDependencies {
127
229
  const result = await stat(path);
128
230
  return { mode: result.mode };
129
231
  },
232
+ getCurrentProcessId: () => process.pid,
233
+ spawnDetached: async (command, args) => {
234
+ // Bun's detached children remain in its kill-on-close job object on Windows.
235
+ const bootstrap = Bun.spawn(["cmd.exe", "/d", "/c", "start", "", "/b", command, ...args], {
236
+ stdin: "ignore",
237
+ stdout: "ignore",
238
+ stderr: "ignore",
239
+ windowsHide: true,
240
+ });
241
+ const exitCode = await bootstrap.exited;
242
+ if (exitCode !== 0) {
243
+ throw new Error(`Failed to launch the deferred updater (cmd.exe exited with ${String(exitCode)}).`);
244
+ }
245
+ },
130
246
  };
131
247
  }
132
248
 
@@ -158,15 +274,102 @@ export function parseGitHubRelease(value: unknown): GitHubRelease {
158
274
  if (!Array.isArray(raw["assets"])) {
159
275
  throw new Error("release.assets must be an array.");
160
276
  }
277
+ const draft = parseOptionalBoolean(raw["draft"], "release.draft");
278
+ const prerelease = parseOptionalBoolean(raw["prerelease"], "release.prerelease");
161
279
  return {
162
280
  tag_name: raw["tag_name"],
163
281
  assets: raw["assets"].map(assertReleaseAsset),
282
+ ...(draft === undefined ? {} : { draft }),
283
+ ...(prerelease === undefined ? {} : { prerelease }),
164
284
  };
165
285
  }
166
286
 
287
+ function parseOptionalBoolean(value: unknown, label: string): boolean | undefined {
288
+ if (value === undefined) {
289
+ return undefined;
290
+ }
291
+ if (typeof value !== "boolean") {
292
+ throw new Error(`${label} must be a boolean.`);
293
+ }
294
+ return value;
295
+ }
296
+
297
+ function parseGitHubReleaseList(value: unknown): GitHubRelease[] {
298
+ if (!Array.isArray(value)) {
299
+ throw new Error("releases must be an array.");
300
+ }
301
+ return value.map((entry, index) => {
302
+ const release = parseGitHubRelease(entry);
303
+ if (release.draft === undefined) {
304
+ throw new Error(`releases[${index}].draft must be a boolean.`);
305
+ }
306
+ if (release.prerelease === undefined) {
307
+ throw new Error(`releases[${index}].prerelease must be a boolean.`);
308
+ }
309
+ return release;
310
+ });
311
+ }
312
+
313
+ async function fetchGitHubApiResponse(
314
+ config: UpdaterConfig,
315
+ url: string,
316
+ dependencies: Pick<UpdaterDependencies, "fetchFn">,
317
+ ): Promise<Response> {
318
+ return await dependencies.fetchFn(url, {
319
+ headers: {
320
+ accept: "application/vnd.github+json",
321
+ "user-agent": config.userAgent ?? `${config.binaryName}-updater`,
322
+ "x-github-api-version": DEFAULT_GITHUB_API_VERSION,
323
+ },
324
+ });
325
+ }
326
+
327
+ async function parseGitHubApiResponse(response: Response, metadataName: string): Promise<unknown> {
328
+ try {
329
+ return await response.json();
330
+ } catch (error) {
331
+ throw new Error(`Failed to parse ${metadataName}: ${String(error)}`);
332
+ }
333
+ }
334
+
335
+ function hasNextReleasePage(response: Response): boolean {
336
+ return response.headers.get("link")
337
+ ?.split(",")
338
+ .some(link => /;\s*rel="?next"?\s*(?:;|$)/i.test(link)) ?? false;
339
+ }
340
+
341
+ async function fetchGitHubReleases(
342
+ repository: GitHubRepository,
343
+ config: UpdaterConfig,
344
+ dependencies: Pick<UpdaterDependencies, "fetchFn">,
345
+ ): Promise<GitHubRelease[]> {
346
+ const releasesUrl = `${githubApiRepositoryUrl(repository)}/releases`;
347
+ const releases: GitHubRelease[] = [];
348
+ let page = 1;
349
+
350
+ while (true) {
351
+ const response = await fetchGitHubApiResponse(
352
+ config,
353
+ `${releasesUrl}?per_page=100&page=${String(page)}`,
354
+ dependencies,
355
+ );
356
+ if (!response.ok) {
357
+ throw new Error(`Failed to load prerelease metadata: GitHub returned ${String(response.status)}.`);
358
+ }
359
+ const pageReleases = parseGitHubReleaseList(
360
+ await parseGitHubApiResponse(response, "prerelease metadata"),
361
+ );
362
+ releases.push(...pageReleases);
363
+ if (!hasNextReleasePage(response)) {
364
+ return releases;
365
+ }
366
+ page += 1;
367
+ }
368
+ }
369
+
167
370
  export async function fetchGitHubRelease(
168
371
  config: UpdaterConfig,
169
- command: Pick<UpdateCommandOptions, "version">,
372
+ command: Pick<UpdateCommandOptions, "version" | "preRelease">,
170
373
  dependencies: Pick<UpdaterDependencies, "fetchFn" | "out">,
171
374
  ): Promise<GitHubRelease> {
172
375
  const repository = assertGitHubRepository(config.repository);
@@ -175,13 +378,7 @@ export async function fetchGitHubRelease(
175
378
  ? `${githubApiRepositoryUrl(repository)}/releases/tags/${tag}`
176
379
  : `${githubApiRepositoryUrl(repository)}/releases/latest`;
177
380
  dependencies.out(tag ? `Fetching release metadata for ${tag}...` : "Fetching release metadata...");
178
- const response = await dependencies.fetchFn(releaseUrl, {
179
- headers: {
180
- accept: "application/vnd.github+json",
181
- "user-agent": config.userAgent ?? `${config.binaryName}-updater`,
182
- "x-github-api-version": DEFAULT_GITHUB_API_VERSION,
183
- },
184
- });
381
+ const response = await fetchGitHubApiResponse(config, releaseUrl, dependencies);
185
382
 
186
383
  if (response.status === 404 && tag) {
187
384
  throw new Error(`Release not found: ${tag}`);
@@ -190,13 +387,27 @@ export async function fetchGitHubRelease(
190
387
  throw new Error(`Failed to load release metadata: GitHub returned ${String(response.status)}.`);
191
388
  }
192
389
 
193
- let rawBody: unknown;
194
- try {
195
- rawBody = await response.json();
196
- } catch (error) {
197
- throw new Error(`Failed to parse release metadata: ${String(error)}`);
390
+ const release = parseGitHubRelease(await parseGitHubApiResponse(response, "release metadata"));
391
+ if (tag || !command.preRelease) {
392
+ return release;
393
+ }
394
+
395
+ dependencies.out("Checking for a newer prerelease...");
396
+ const prereleases = (await fetchGitHubReleases(repository, config, dependencies))
397
+ .filter(candidate => candidate.prerelease === true && candidate.draft !== true);
398
+ const newestPrerelease = prereleases.reduce<GitHubRelease | undefined>(
399
+ (newest, candidate) => !newest || compareReleaseVersions(candidate.tag_name, newest.tag_name) > 0
400
+ ? candidate
401
+ : newest,
402
+ undefined,
403
+ );
404
+ if (
405
+ newestPrerelease
406
+ && compareReleaseVersions(newestPrerelease.tag_name, release.tag_name) > 0
407
+ ) {
408
+ return newestPrerelease;
198
409
  }
199
- return parseGitHubRelease(rawBody);
410
+ return release;
200
411
  }
201
412
 
202
413
  export function resolveReleaseAsset(
@@ -307,7 +518,7 @@ export async function verifyReleaseAssetChecksum(
307
518
 
308
519
  async function resolveInstalledBinaryPath(binaryName: string, productName: string, dependencies: UpdaterDependencies): Promise<string> {
309
520
  const executablePath = dependencies.getExecutablePath();
310
- const executableName = basename(executablePath);
521
+ const executableName = basename(executablePath).toLowerCase().replace(/\.exe$/, "");
311
522
  if (executableName === "bun" || executableName.startsWith("bun-")) {
312
523
  throw new Error(`${binaryName} update only works from an installed ${productName} binary. Use the installer script when running from source.`);
313
524
  }
@@ -323,6 +534,7 @@ async function stageInstalledBinaryReplacement(
323
534
  asset: ResolvedReleaseAsset,
324
535
  config: UpdaterConfig,
325
536
  checksumPolicy: Required<UpdaterChecksumPolicy>,
537
+ platform: ReleasePlatform,
326
538
  dependencies: UpdaterDependencies,
327
539
  ): Promise<StagedBinaryReplacement> {
328
540
  const targetPath = target.targetPath;
@@ -343,9 +555,11 @@ async function stageInstalledBinaryReplacement(
343
555
  await verifyReleaseAssetChecksum(asset, payload, checksumPolicy, dependencies);
344
556
  await dependencies.writeBinary(tempPath, payload);
345
557
 
346
- const installedBinaryStat = await dependencies.statFile(targetPath);
347
- const executableMode = installedBinaryStat.mode & 0o777;
348
- await dependencies.chmodFile(tempPath, executableMode || 0o755);
558
+ if (platform.os !== "windows") {
559
+ const installedBinaryStat = await dependencies.statFile(targetPath);
560
+ const executableMode = installedBinaryStat.mode & 0o777;
561
+ await dependencies.chmodFile(tempPath, executableMode || 0o755);
562
+ }
349
563
  staged = true;
350
564
  return {
351
565
  target,
@@ -404,11 +618,41 @@ async function replaceStagedBinaryReplacements(
404
618
  }
405
619
  }
406
620
 
407
- async function resolveInstalledBinaryTargets(config: UpdaterConfig, dependencies: UpdaterDependencies): Promise<InstalledBinaryTarget[]> {
621
+ async function scheduleWindowsBinaryReplacements(
622
+ stagedReplacements: StagedBinaryReplacement[],
623
+ dependencies: UpdaterDependencies,
624
+ ): Promise<void> {
625
+ const primary = stagedReplacements.at(-1);
626
+ if (!primary) {
627
+ throw new Error("No staged Windows binary replacements were provided.");
628
+ }
629
+ const statusPath = `${primary.target.targetPath}.update-error.log`;
630
+ const parentProcessId = dependencies.getCurrentProcessId();
631
+ const encodedHelper = Buffer.from(
632
+ windowsUpdateHelper(stagedReplacements, statusPath, parentProcessId),
633
+ "utf16le",
634
+ ).toString("base64");
635
+ await dependencies.removeFile(statusPath);
636
+ await dependencies.spawnDetached("powershell.exe", [
637
+ "-NoLogo",
638
+ "-NoProfile",
639
+ "-NonInteractive",
640
+ "-ExecutionPolicy",
641
+ "Bypass",
642
+ "-EncodedCommand",
643
+ encodedHelper,
644
+ ]);
645
+ }
646
+
647
+ async function resolveInstalledBinaryTargets(
648
+ config: UpdaterConfig,
649
+ platform: ReleasePlatform,
650
+ dependencies: UpdaterDependencies,
651
+ ): Promise<InstalledBinaryTarget[]> {
408
652
  const primaryPath = await resolveInstalledBinaryPath(config.binaryName, config.productName ?? config.binaryName, dependencies);
409
653
  const targets: InstalledBinaryTarget[] = [];
410
654
  for (const companion of config.companionBinaries ?? []) {
411
- const companionPath = join(dirname(primaryPath), companion.binaryName);
655
+ const companionPath = join(dirname(primaryPath), releaseBinaryFileName(companion.binaryName, platform));
412
656
  if (await dependencies.fileExists(companionPath)) {
413
657
  targets.push({
414
658
  binaryName: companion.binaryName,
@@ -472,15 +716,33 @@ export async function runUpdateCommand(
472
716
  }
473
717
 
474
718
  const stagedReplacements: StagedBinaryReplacement[] = [];
719
+ let deferredWindowsReplacement = false;
475
720
  try {
476
- const installedTargets = await resolveInstalledBinaryTargets(normalizedConfig, dependencies);
721
+ const installedTargets = await resolveInstalledBinaryTargets(normalizedConfig, releasePlatform, dependencies);
477
722
  for (const target of installedTargets) {
478
723
  const asset = resolveReleaseAsset(release, releasePlatform, {
479
724
  binaryName: target.binaryName,
480
725
  assetPrefix: target.assetPrefix,
481
726
  checksum: checksumPolicy,
482
727
  });
483
- stagedReplacements.push(await stageInstalledBinaryReplacement(target, asset, normalizedConfig, checksumPolicy, dependencies));
728
+ stagedReplacements.push(await stageInstalledBinaryReplacement(
729
+ target,
730
+ asset,
731
+ normalizedConfig,
732
+ checksumPolicy,
733
+ releasePlatform,
734
+ dependencies,
735
+ ));
736
+ }
737
+
738
+ if (releasePlatform.os === "windows") {
739
+ await scheduleWindowsBinaryReplacements(stagedReplacements, dependencies);
740
+ deferredWindowsReplacement = true;
741
+ dependencies.out(
742
+ `Staged ${normalizedConfig.productName ?? normalizedConfig.binaryName} ${primaryAsset.version}. `
743
+ + `The update will complete after process ${String(dependencies.getCurrentProcessId())} exits.`,
744
+ );
745
+ return 0;
484
746
  }
485
747
 
486
748
  await replaceStagedBinaryReplacements(stagedReplacements, normalizedConfig, dependencies);
@@ -493,7 +755,9 @@ export async function runUpdateCommand(
493
755
  }
494
756
  }
495
757
  } finally {
496
- await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
758
+ if (!deferredWindowsReplacement) {
759
+ await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
760
+ }
497
761
  }
498
762
 
499
763
  return 0;