@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 +46 -6
- package/install.ps1 +444 -0
- package/package.json +2 -1
- package/src/contract.ts +18 -5
- package/src/manifest.ts +3 -3
- package/src/update.ts +287 -23
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: {
|
|
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
|
-
-
|
|
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`,
|
|
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
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
758
|
+
if (!deferredWindowsReplacement) {
|
|
759
|
+
await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
|
|
760
|
+
}
|
|
497
761
|
}
|
|
498
762
|
|
|
499
763
|
return 0;
|