@pablozaiden/installer 0.0.10 → 0.1.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
  ```
@@ -217,12 +237,18 @@ The updater supports:
217
237
  - explicit version installs,
218
238
  - semver comparison including prereleases,
219
239
  - GitHub release metadata validation,
220
- - Linux/macOS x64/arm64 target resolution,
240
+ - Linux/macOS/Windows x64/arm64 target resolution,
221
241
  - checksum verification before replacement,
222
242
  - source-mode rejection when running from `bun`,
223
243
  - staged temp-file replacement with executable permission preservation,
224
244
  - companion binary updates that are committed together with the primary binary and rolled back on replacement failure.
225
245
 
246
+ Windows cannot replace a running executable. The updater stages and verifies
247
+ the complete update, then starts a detached PowerShell helper. The helper waits
248
+ for the current process to exit, applies all replacements as one rollback-aware
249
+ operation, and writes `<binary>.update-error.log` beside the executable if the
250
+ deferred operation fails.
251
+
226
252
  Exported helpers include:
227
253
 
228
254
  ```ts
@@ -310,7 +336,8 @@ jobs:
310
336
  The workflow:
311
337
 
312
338
  - runs on GitHub release publication,
313
- - builds `linux-x64`, `linux-arm64`, `darwin-x64`, and `darwin-arm64`,
339
+ - builds `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`,
340
+ `windows-x64`, and `windows-arm64`,
314
341
  - exports `TAG`, `VERSION`, `RELEASE_TARGET`, `BUN_TARGET`, `BINARY_NAME`, `ASSET_PREFIX`, and `ASSET_PATH` to each build command,
315
342
  - stages release assets using the shared naming convention,
316
343
  - 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.1.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
 
@@ -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,
@@ -62,6 +63,8 @@ export type UpdaterDependencies = {
62
63
  renameFile: (from: string, to: string) => Promise<void>;
63
64
  removeFile: (path: string) => Promise<void>;
64
65
  statFile: (path: string) => Promise<{ mode: number }>;
66
+ getCurrentProcessId: () => number;
67
+ spawnDetached: (command: string, args: readonly string[]) => Promise<void>;
65
68
  };
66
69
 
67
70
  export type GitHubReleaseAsset = {
@@ -98,6 +101,102 @@ type StagedBinaryReplacement = {
98
101
  backupPath: string;
99
102
  };
100
103
 
104
+ function powerShellString(value: string): string {
105
+ return `'${value.replaceAll("'", "''")}'`;
106
+ }
107
+
108
+ function windowsUpdateHelper(
109
+ stagedReplacements: StagedBinaryReplacement[],
110
+ statusPath: string,
111
+ parentProcessId: number,
112
+ ): string {
113
+ const replacements = stagedReplacements.map((staged) => [
114
+ " [PSCustomObject]@{",
115
+ ` TargetPath = ${powerShellString(staged.target.targetPath)}`,
116
+ ` TempDirectory = ${powerShellString(staged.tempDirectory)}`,
117
+ ` TempPath = ${powerShellString(staged.tempPath)}`,
118
+ ` BackupPath = ${powerShellString(staged.backupPath)}`,
119
+ " }",
120
+ ].join("\n")).join("\n");
121
+
122
+ return String.raw`
123
+ $ErrorActionPreference = "Stop"
124
+ $ParentProcessId = ${String(parentProcessId)}
125
+ $StatusPath = ${powerShellString(statusPath)}
126
+ $replacements = @(
127
+ ${replacements}
128
+ )
129
+ $utf8 = [System.Text.UTF8Encoding]::new($false)
130
+
131
+ function Write-UpdateStatus([string]$Message) {
132
+ [System.IO.File]::WriteAllText($StatusPath, $Message, $utf8)
133
+ }
134
+
135
+ Write-UpdateStatus "Update helper started."
136
+ $deadline = [DateTime]::UtcNow.AddMinutes(5)
137
+
138
+ while ($ParentProcessId -gt 0) {
139
+ try {
140
+ $parentProcess = [System.Diagnostics.Process]::GetProcessById($ParentProcessId)
141
+ if ($parentProcess.HasExited) {
142
+ break
143
+ }
144
+ } catch [System.ArgumentException] {
145
+ break
146
+ }
147
+ if ([DateTime]::UtcNow -ge $deadline) {
148
+ Write-UpdateStatus "Timed out waiting for process $ParentProcessId to exit."
149
+ exit 1
150
+ }
151
+ [System.Threading.Thread]::Sleep(100)
152
+ }
153
+
154
+ $movedBackups = [System.Collections.Generic.List[object]]::new()
155
+ $installedReplacements = [System.Collections.Generic.List[object]]::new()
156
+
157
+ try {
158
+ foreach ($replacement in $replacements) {
159
+ [System.IO.File]::Move($replacement.TargetPath, $replacement.BackupPath)
160
+ $movedBackups.Add($replacement)
161
+ [System.IO.File]::Move($replacement.TempPath, $replacement.TargetPath)
162
+ $installedReplacements.Add($replacement)
163
+ }
164
+ } catch {
165
+ $failure = $_.Exception.Message
166
+ try {
167
+ for ($index = $installedReplacements.Count - 1; $index -ge 0; $index -= 1) {
168
+ [System.IO.File]::Delete($installedReplacements[$index].TargetPath)
169
+ }
170
+ for ($index = $movedBackups.Count - 1; $index -ge 0; $index -= 1) {
171
+ [System.IO.File]::Move(
172
+ $movedBackups[$index].BackupPath,
173
+ $movedBackups[$index].TargetPath
174
+ )
175
+ }
176
+ } catch {
177
+ $failure = $failure + " Rollback failed: " + $_.Exception.Message
178
+ }
179
+ Write-UpdateStatus $failure
180
+ exit 1
181
+ }
182
+
183
+ [System.IO.File]::Delete($StatusPath)
184
+ $cleanupDirectories = [System.Collections.Generic.HashSet[string]]::new(
185
+ [System.StringComparer]::OrdinalIgnoreCase
186
+ )
187
+ foreach ($replacement in $replacements) {
188
+ [void]$cleanupDirectories.Add($replacement.TempDirectory)
189
+ }
190
+ foreach ($directory in $cleanupDirectories) {
191
+ try {
192
+ [System.IO.Directory]::Delete($directory, $true)
193
+ } catch {
194
+ Write-UpdateStatus ("Update installed but cleanup failed: " + $_.Exception.Message)
195
+ }
196
+ }
197
+ `;
198
+ }
199
+
101
200
  function createDefaultUpdateDependencies(): UpdaterDependencies {
102
201
  return {
103
202
  fetchFn: fetch,
@@ -127,6 +226,20 @@ function createDefaultUpdateDependencies(): UpdaterDependencies {
127
226
  const result = await stat(path);
128
227
  return { mode: result.mode };
129
228
  },
229
+ getCurrentProcessId: () => process.pid,
230
+ spawnDetached: async (command, args) => {
231
+ // Bun's detached children remain in its kill-on-close job object on Windows.
232
+ const bootstrap = Bun.spawn(["cmd.exe", "/d", "/c", "start", "", "/b", command, ...args], {
233
+ stdin: "ignore",
234
+ stdout: "ignore",
235
+ stderr: "ignore",
236
+ windowsHide: true,
237
+ });
238
+ const exitCode = await bootstrap.exited;
239
+ if (exitCode !== 0) {
240
+ throw new Error(`Failed to launch the deferred updater (cmd.exe exited with ${String(exitCode)}).`);
241
+ }
242
+ },
130
243
  };
131
244
  }
132
245
 
@@ -307,7 +420,7 @@ export async function verifyReleaseAssetChecksum(
307
420
 
308
421
  async function resolveInstalledBinaryPath(binaryName: string, productName: string, dependencies: UpdaterDependencies): Promise<string> {
309
422
  const executablePath = dependencies.getExecutablePath();
310
- const executableName = basename(executablePath);
423
+ const executableName = basename(executablePath).toLowerCase().replace(/\.exe$/, "");
311
424
  if (executableName === "bun" || executableName.startsWith("bun-")) {
312
425
  throw new Error(`${binaryName} update only works from an installed ${productName} binary. Use the installer script when running from source.`);
313
426
  }
@@ -323,6 +436,7 @@ async function stageInstalledBinaryReplacement(
323
436
  asset: ResolvedReleaseAsset,
324
437
  config: UpdaterConfig,
325
438
  checksumPolicy: Required<UpdaterChecksumPolicy>,
439
+ platform: ReleasePlatform,
326
440
  dependencies: UpdaterDependencies,
327
441
  ): Promise<StagedBinaryReplacement> {
328
442
  const targetPath = target.targetPath;
@@ -343,9 +457,11 @@ async function stageInstalledBinaryReplacement(
343
457
  await verifyReleaseAssetChecksum(asset, payload, checksumPolicy, dependencies);
344
458
  await dependencies.writeBinary(tempPath, payload);
345
459
 
346
- const installedBinaryStat = await dependencies.statFile(targetPath);
347
- const executableMode = installedBinaryStat.mode & 0o777;
348
- await dependencies.chmodFile(tempPath, executableMode || 0o755);
460
+ if (platform.os !== "windows") {
461
+ const installedBinaryStat = await dependencies.statFile(targetPath);
462
+ const executableMode = installedBinaryStat.mode & 0o777;
463
+ await dependencies.chmodFile(tempPath, executableMode || 0o755);
464
+ }
349
465
  staged = true;
350
466
  return {
351
467
  target,
@@ -404,11 +520,41 @@ async function replaceStagedBinaryReplacements(
404
520
  }
405
521
  }
406
522
 
407
- async function resolveInstalledBinaryTargets(config: UpdaterConfig, dependencies: UpdaterDependencies): Promise<InstalledBinaryTarget[]> {
523
+ async function scheduleWindowsBinaryReplacements(
524
+ stagedReplacements: StagedBinaryReplacement[],
525
+ dependencies: UpdaterDependencies,
526
+ ): Promise<void> {
527
+ const primary = stagedReplacements.at(-1);
528
+ if (!primary) {
529
+ throw new Error("No staged Windows binary replacements were provided.");
530
+ }
531
+ const statusPath = `${primary.target.targetPath}.update-error.log`;
532
+ const parentProcessId = dependencies.getCurrentProcessId();
533
+ const encodedHelper = Buffer.from(
534
+ windowsUpdateHelper(stagedReplacements, statusPath, parentProcessId),
535
+ "utf16le",
536
+ ).toString("base64");
537
+ await dependencies.removeFile(statusPath);
538
+ await dependencies.spawnDetached("powershell.exe", [
539
+ "-NoLogo",
540
+ "-NoProfile",
541
+ "-NonInteractive",
542
+ "-ExecutionPolicy",
543
+ "Bypass",
544
+ "-EncodedCommand",
545
+ encodedHelper,
546
+ ]);
547
+ }
548
+
549
+ async function resolveInstalledBinaryTargets(
550
+ config: UpdaterConfig,
551
+ platform: ReleasePlatform,
552
+ dependencies: UpdaterDependencies,
553
+ ): Promise<InstalledBinaryTarget[]> {
408
554
  const primaryPath = await resolveInstalledBinaryPath(config.binaryName, config.productName ?? config.binaryName, dependencies);
409
555
  const targets: InstalledBinaryTarget[] = [];
410
556
  for (const companion of config.companionBinaries ?? []) {
411
- const companionPath = join(dirname(primaryPath), companion.binaryName);
557
+ const companionPath = join(dirname(primaryPath), releaseBinaryFileName(companion.binaryName, platform));
412
558
  if (await dependencies.fileExists(companionPath)) {
413
559
  targets.push({
414
560
  binaryName: companion.binaryName,
@@ -472,15 +618,33 @@ export async function runUpdateCommand(
472
618
  }
473
619
 
474
620
  const stagedReplacements: StagedBinaryReplacement[] = [];
621
+ let deferredWindowsReplacement = false;
475
622
  try {
476
- const installedTargets = await resolveInstalledBinaryTargets(normalizedConfig, dependencies);
623
+ const installedTargets = await resolveInstalledBinaryTargets(normalizedConfig, releasePlatform, dependencies);
477
624
  for (const target of installedTargets) {
478
625
  const asset = resolveReleaseAsset(release, releasePlatform, {
479
626
  binaryName: target.binaryName,
480
627
  assetPrefix: target.assetPrefix,
481
628
  checksum: checksumPolicy,
482
629
  });
483
- stagedReplacements.push(await stageInstalledBinaryReplacement(target, asset, normalizedConfig, checksumPolicy, dependencies));
630
+ stagedReplacements.push(await stageInstalledBinaryReplacement(
631
+ target,
632
+ asset,
633
+ normalizedConfig,
634
+ checksumPolicy,
635
+ releasePlatform,
636
+ dependencies,
637
+ ));
638
+ }
639
+
640
+ if (releasePlatform.os === "windows") {
641
+ await scheduleWindowsBinaryReplacements(stagedReplacements, dependencies);
642
+ deferredWindowsReplacement = true;
643
+ dependencies.out(
644
+ `Staged ${normalizedConfig.productName ?? normalizedConfig.binaryName} ${primaryAsset.version}. `
645
+ + `The update will complete after process ${String(dependencies.getCurrentProcessId())} exits.`,
646
+ );
647
+ return 0;
484
648
  }
485
649
 
486
650
  await replaceStagedBinaryReplacements(stagedReplacements, normalizedConfig, dependencies);
@@ -493,7 +657,9 @@ export async function runUpdateCommand(
493
657
  }
494
658
  }
495
659
  } finally {
496
- await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
660
+ if (!deferredWindowsReplacement) {
661
+ await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
662
+ }
497
663
  }
498
664
 
499
665
  return 0;