browser4-cli 4.12.0 → 4.12.2

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.
@@ -1,812 +1,812 @@
1
- <#
2
- .SYNOPSIS
3
- Install browser4-cli -- download the native binary and set it up on your PATH.
4
-
5
- .DESCRIPTION
6
- Detects your OS and CPU architecture, downloads the matching native binary from
7
- GitHub Releases or Alibaba Cloud OSS, installs it to a user-local directory, and
8
- optionally adds it to your PATH.
9
-
10
- Default install location:
11
- Windows: $env:LOCALAPPDATA\Programs\browser4-cli
12
- (override with -InstallDir)
13
-
14
- Download sources (auto-selected by locale; use -Source to override):
15
- Outside China: 1. GitHub Releases -> 2. Aliyun OSS
16
- China mainland: 1. Aliyun OSS -> 2. GitHub Releases
17
- GitHub Releases -- https://github.com/platonai/Browser4
18
- Aliyun OSS -- https://browser4.oss-cn-beijing.aliyuncs.com
19
-
20
- .PARAMETER Version
21
- Release version tag to download (e.g. "v4.11.0" or "v0.1.12-cli").
22
- Defaults to "latest" which resolves to the most recent stable release.
23
-
24
- .PARAMETER InstallDir
25
- Directory to install the binary into.
26
- Default: $env:LOCALAPPDATA\Programs\browser4-cli
27
-
28
- .PARAMETER Source
29
- Force a specific download source: "github" or "oss".
30
- Default (auto): locale-aware -- OSS first in China mainland, GitHub first elsewhere.
31
-
32
- .PARAMETER AddToPath
33
- Add the install directory to the current user's PATH environment variable.
34
- Default: true.
35
-
36
- .PARAMETER Silent
37
- Suppress all non-error output.
38
-
39
- .PARAMETER DryRun
40
- Print what would be done without actually doing it.
41
-
42
- .PARAMETER SkipIfInstalled
43
- Skip download if the binary already exists at the install path.
44
- By default the script always downloads the latest version.
45
-
46
- .PARAMETER SkipLocal
47
- Skip checking for a locally-bundled binary alongside the script.
48
- By default the script looks for the platform binary in its own directory
49
- before downloading -- use this to force a fresh download.
50
-
51
- .PARAMETER Force
52
- Force reinstallation even if the binary is already installed at the target path.
53
- Overrides -SkipIfInstalled and bypasses locked-file workarounds.
54
-
55
- .PARAMETER Locate
56
- Print detection results (OS, architecture, script location, China locale)
57
- and exit without installing. Useful for diagnostics.
58
-
59
- .EXAMPLE
60
- # Quick install -- default location, latest version, add to PATH
61
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1
62
-
63
- .EXAMPLE
64
- # Silent install with a specific version
65
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Version "v4.11.0" -Silent
66
-
67
- .EXAMPLE
68
- # Install from OSS only, custom directory
69
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Source oss -InstallDir "C:\tools\browser4"
70
-
71
- .EXAMPLE
72
- # Run diagnostics -- see what the script detects without installing
73
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Locate
74
-
75
- .EXAMPLE
76
- # Use a locally-bundled binary (place binary next to the script)
77
- # The script auto-detects binaries in its own directory
78
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1
79
-
80
- .EXAMPLE
81
- # Force download even when a local binary exists
82
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -SkipLocal
83
-
84
- .EXAMPLE
85
- # Skip download if already installed (opt out of default reinstall)
86
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -SkipIfInstalled
87
-
88
- .EXAMPLE
89
- # For China mainland: OSS is auto-preferred via locale detection,
90
- # or force it explicitly
91
- powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Source oss
92
- #>
93
-
94
- [CmdletBinding()]
95
- param(
96
- [string]$Version = "",
97
- [string]$InstallDir = "",
98
- [ValidateSet("", "github", "oss")]
99
- [string]$Source = "",
100
- [bool]$AddToPath = $true,
101
- [switch]$Silent,
102
- [switch]$DryRun,
103
- [switch]$SkipIfInstalled,
104
- [switch]$SkipLocal,
105
- [switch]$Force,
106
- [switch]$Locate
107
- )
108
-
109
- $ErrorActionPreference = "Stop"
110
-
111
- # ----------------------------------------------
112
- # Script location -- find ourselves on disk
113
- # ----------------------------------------------
114
-
115
- # $PSScriptRoot is the directory containing this script (PS 3+).
116
- # Falls back to $MyInvocation for edge cases (dot-sourced, PS 2).
117
- $ScriptDir = if ($PSScriptRoot) {
118
- $PSScriptRoot
119
- } elseif ($MyInvocation -and $MyInvocation.MyCommand.Path) {
120
- Split-Path -Parent $MyInvocation.MyCommand.Path
121
- } else {
122
- $null
123
- }
124
-
125
- <#
126
- .SYNOPSIS
127
- Search for a pre-downloaded binary near the script (bundled/sideload install).
128
- Returns the full path if found, $null otherwise.
129
- #>
130
- function Find-LocalBinary {
131
- param([string]$BinaryName)
132
-
133
- if (-not $ScriptDir) { return $null }
134
-
135
- $localPath = Join-Path $ScriptDir $BinaryName
136
- if (Test-Path $localPath -PathType Leaf) {
137
- $size = (Get-Item $localPath).Length
138
- if ($size -gt 102400) { # > 100 KB minimum
139
- return $localPath
140
- }
141
- }
142
- return $null
143
- }
144
-
145
- <#
146
- .SYNOPSIS
147
- Check whether a local binary is usable by querying its version.
148
- Returns $true if --version executes successfully, $false otherwise.
149
- #>
150
- function Test-LocalBinary {
151
- param([string]$Path)
152
-
153
- if (-not $Path -or -not (Test-Path $Path)) { return $false }
154
-
155
- try {
156
- $null = & $Path --version 2>&1
157
- return ($LASTEXITCODE -eq 0)
158
- } catch {
159
- return $false
160
- }
161
- }
162
-
163
- # ----------------------------------------------
164
- # OS detection (compatible with PS 5.1+)
165
- # ----------------------------------------------
166
-
167
- # Avoid assigning to $IsLinux / $IsWindows / $IsMacOS directly --
168
- # they are read-only automatic variables in PowerShell 7+.
169
- if ($PSVersionTable.PSVersion.Major -ge 6) {
170
- $script:OSWin = $IsWindows
171
- $script:OSLinux = $IsLinux
172
- $script:OSMac = $IsMacOS
173
- } else {
174
- $script:OSWin = [System.Environment]::OSVersion.Platform -eq "Win32NT"
175
- $script:OSMac = $false
176
- $script:OSLinux = $false
177
- }
178
-
179
- # ----------------------------------------------
180
- # Helpers
181
- # ----------------------------------------------
182
-
183
- function Write-Summary {
184
- param([string]$Message, [string]$Color = "White")
185
- if (-not $Silent) { Write-Host $Message -ForegroundColor $Color }
186
- }
187
-
188
- function Write-Step {
189
- param([string]$Message)
190
- if (-not $Silent) { Write-Host " >> $Message" -ForegroundColor Gray }
191
- }
192
-
193
- function Write-Check {
194
- param([string]$Message)
195
- if (-not $Silent) { Write-Host " [v] $Message" -ForegroundColor Green }
196
- }
197
-
198
- function Write-WarnMsg {
199
- param([string]$Message)
200
- if (-not $Silent) { Write-Host " [!] $Message" -ForegroundColor Yellow }
201
- }
202
-
203
- # ----------------------------------------------
204
- # Detection
205
- # ----------------------------------------------
206
-
207
- function Get-PlatformKey {
208
- $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "arm64" } else { "x64" }
209
-
210
- if ($script:OSWin) {
211
- return "win32-$arch"
212
- }
213
- elseif ($script:OSMac) {
214
- return "darwin-$arch"
215
- }
216
- elseif ($script:OSLinux) {
217
- # Detect musl
218
- $isMusl = $false
219
- try {
220
- $lddOutput = ldd --version 2>&1
221
- if ($lddOutput -match "musl") { $isMusl = $true }
222
- } catch {
223
- if ((Test-Path "/lib/ld-musl-x86_64.so.1") -or (Test-Path "/lib/ld-musl-aarch64.so.1")) {
224
- $isMusl = $true
225
- }
226
- }
227
- $libc = if ($isMusl) { "musl" } else { "" }
228
- if ($libc) { return "linux-$libc-$arch" } else { return "linux-$arch" }
229
- }
230
- else {
231
- throw "Unsupported OS. Browser4 CLI supports Windows, macOS, and Linux."
232
- }
233
- }
234
-
235
- function Get-BinaryName {
236
- param([string]$PlatformKey)
237
- return "browser4-cli-$PlatformKey" + $(if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" })
238
- }
239
-
240
- function Get-DefaultInstallDir {
241
- if ($script:OSWin) {
242
- return Join-Path $env:LOCALAPPDATA "Programs\browser4-cli"
243
- }
244
- elseif ($script:OSLinux -or $script:OSMac) {
245
- # Prefer ~/.local/bin for user installs
246
- if (Test-Path "$env:HOME/.local/bin") {
247
- return "$env:HOME/.local/bin"
248
- }
249
- return "$env:HOME/.local/bin"
250
- }
251
- throw "Unsupported OS"
252
- }
253
-
254
- # ----------------------------------------------
255
- # China mainland locale detection (zero-network)
256
- # ----------------------------------------------
257
-
258
- <#
259
- .SYNOPSIS
260
- Detect whether the current system is likely in China mainland.
261
- Uses only local env vars and .NET APIs -- no network calls.
262
- #>
263
- function Test-ChinaLocale {
264
- # 1 -- Locale env vars
265
- $lang = $env:LC_ALL, $env:LANG, $env:LC_CTYPE, $env:LC_MESSAGES | Where-Object { $_ } | Select-Object -First 1
266
- if ($lang -and ($lang -match '^zh_CN' -or $lang -match '^zh-CN' -or $lang -match '^Chinese \(Simplified\)_China')) {
267
- return $true
268
- }
269
-
270
- # 2 -- TZ env var
271
- $tzEnv = $env:TZ
272
- if ($tzEnv -and ($tzEnv -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$')) {
273
- return $true
274
- }
275
-
276
- # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+)
277
- try {
278
- $tzId = [System.TimeZoneInfo]::Local.Id
279
- if ($tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
280
- return $true
281
- }
282
- } catch {
283
- # TimeZoneInfo not available (unlikely on PS 5.1+ but guard anyway)
284
- }
285
-
286
- # 4 -- /etc/timezone (PowerShell on Linux/macOS)
287
- if (-not $script:OSWin -and (Test-Path '/etc/timezone')) {
288
- try {
289
- $tz = Get-Content '/etc/timezone' -Raw -ErrorAction Stop
290
- if ($tz -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
291
- return $true
292
- }
293
- } catch {
294
- # Permission or read error -- skip
295
- }
296
- }
297
-
298
- return $false
299
- }
300
-
301
- # ----------------------------------------------
302
- # Download URLs
303
- # ----------------------------------------------
304
-
305
- $GITHUB_REPO = "platonai/Browser4"
306
- $OSS_BASE = "https://browser4.oss-cn-beijing.aliyuncs.com"
307
- $script:ChinaDetected = $false
308
-
309
- function Get-DownloadUrls {
310
- param([string]$BinaryName, [string]$VersionTag)
311
-
312
- $urls = @()
313
-
314
- $ghBase = "https://github.com/$GITHUB_REPO/releases/download"
315
- if ($VersionTag) {
316
- $ghUrl = "$ghBase/$VersionTag/$BinaryName"
317
- $ossUrl = "$OSS_BASE/releases/download/$VersionTag/$BinaryName"
318
- } else {
319
- # Use 'latest' redirect for GitHub, 'latest' symlink for OSS
320
- $ghUrl = "https://github.com/$GITHUB_REPO/releases/latest/download/$BinaryName"
321
- $ossUrl = "$OSS_BASE/releases/download/latest/$BinaryName"
322
- }
323
-
324
- if ($Source -eq "github") {
325
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
326
- } elseif ($Source -eq "oss") {
327
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
328
- } else {
329
- if ($script:ChinaDetected) {
330
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
331
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
332
- } else {
333
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
334
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
335
- }
336
- }
337
-
338
- return $urls
339
- }
340
-
341
- function Invoke-Download {
342
- param([string]$Url, [string]$OutFile, [string]$Label)
343
-
344
- Write-Step "Trying $Label..."
345
- Write-Step "URL: $Url"
346
-
347
- if ($DryRun) {
348
- Write-Check "[DRY-RUN] Would download to: $OutFile"
349
- return $true
350
- }
351
-
352
- try {
353
- $ProgressPreference = if ($Silent) { "SilentlyContinue" } else { "Continue" }
354
-
355
- # Use Invoke-WebRequest with progress bar
356
- Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
357
-
358
- if (Test-Path $OutFile) {
359
- $size = (Get-Item $OutFile).Length
360
- if ($size -gt 102400) { # > 100 KB minimum
361
- Write-Check "Downloaded $( [math]::Round($size / 1MB, 1) ) MB"
362
- return $true
363
- } else {
364
- Write-WarnMsg "Downloaded file too small ($size bytes) -- may be an error page"
365
- Remove-Item $OutFile -Force -ErrorAction SilentlyContinue
366
- return $false
367
- }
368
- }
369
- Write-WarnMsg "Download appeared to succeed but file not found"
370
- return $false
371
- } catch {
372
- Write-WarnMsg "Failed: $($_.Exception.Message)"
373
- return $false
374
- }
375
- }
376
-
377
- # ----------------------------------------------
378
- # Symlinks
379
- # ----------------------------------------------
380
-
381
- function New-PlatformLink {
382
- param([string]$LinkPath, [string]$TargetName, [string]$DisplayName)
383
-
384
- # Try symbolic link first (works on Unix; on Windows needs Admin or Developer Mode)
385
- try {
386
- New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetName -Force -ErrorAction Stop | Out-Null
387
- Write-Check "Created symlink: $DisplayName -> $TargetName"
388
- return $true
389
- } catch {
390
- # Symbolic link failed -- try hard link on Windows
391
- }
392
-
393
- # Try hard link (Windows, same volume)
394
- if ($script:OSWin) {
395
- try {
396
- $targetPath = Join-Path (Split-Path $LinkPath -Parent) $TargetName
397
- New-Item -ItemType HardLink -Path $LinkPath -Target $targetPath -Force -ErrorAction Stop | Out-Null
398
- Write-Check "Created hard link: $DisplayName -> $TargetName"
399
- return $true
400
- } catch {
401
- # Hard link also failed -- create a .cmd wrapper as last resort
402
- }
403
-
404
- # Last resort: .cmd wrapper that forwards all arguments
405
- try {
406
- $wrapperContent = '@"%~dp0' + $TargetName + '" %*'
407
- Set-Content -Path ($LinkPath -replace '\.exe$', '.cmd') -Value $wrapperContent -Force -ErrorAction Stop
408
- Write-Check "Created wrapper: " + (Split-Path ($LinkPath -replace '\.exe$', '.cmd') -Leaf) + " -> $TargetName"
409
- return $true
410
- } catch {
411
- Write-WarnMsg "Could not create link for $DisplayName (may need admin privileges)"
412
- return $false
413
- }
414
- }
415
-
416
- Write-WarnMsg "Could not create link for $DisplayName"
417
- return $false
418
- }
419
-
420
- function New-Symlinks {
421
- param([string]$BinaryName, [string]$InstallDir, [string]$PlatformKey)
422
-
423
- $ext = if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" }
424
-
425
- # 1) Always: browser4-cli -> browser4-cli-<platform>
426
- $linkName = "browser4-cli$ext"
427
- $linkPath = Join-Path $InstallDir $linkName
428
-
429
- if ($DryRun) {
430
- Write-Step "[DRY-RUN] Would create link: $linkName -> $BinaryName"
431
- } else {
432
- $null = New-PlatformLink -LinkPath $linkPath -TargetName $BinaryName -DisplayName $linkName
433
- }
434
-
435
- # 2) b4 -> browser4-cli-<platform> (only if b4 is our tool or doesn't exist)
436
- $shortName = "b4$ext"
437
- $shortPath = Join-Path $InstallDir $shortName
438
-
439
- $b4Exists = $false
440
- $b4IsOurs = $false
441
-
442
- # Check in install dir first --anything here is ours
443
- if (Test-Path $shortPath) {
444
- $b4Exists = $true
445
- $b4IsOurs = $true
446
- }
447
-
448
- # Also check b4.cmd wrapper on Windows
449
- if ($script:OSWin -and -not $b4Exists) {
450
- $shortCmdPath = Join-Path $InstallDir "b4.cmd"
451
- if (Test-Path $shortCmdPath) {
452
- $b4Exists = $true
453
- $b4IsOurs = $true
454
- }
455
- }
456
-
457
- # Check if b4 is on PATH from somewhere else.
458
- # Try Get-Command first; fall back to scanning PATH directories manually
459
- # because Get-Command may miss extensionless executables on Linux/macOS
460
- # or may cache stale results in some PowerShell versions.
461
- if (-not $b4Exists) {
462
- $existingCmd = Get-Command b4 -ErrorAction SilentlyContinue
463
- if ($existingCmd) {
464
- $b4Exists = $true
465
- # Check if it's ours by running --version
466
- try {
467
- $b4Version = & b4 --version 2>&1
468
- if ($b4Version -match "browser4-cli") {
469
- $b4IsOurs = $true
470
- }
471
- } catch {
472
- # Can't determine --leave it alone
473
- }
474
- }
475
- }
476
-
477
- # Fallback: scan PATH manually when Get-Command didn't find anything.
478
- # On Linux/macOS, PowerShell's Get-Command may miss scripts that are
479
- # executable but not in its command cache, especially in CI containers.
480
- if (-not $b4Exists) {
481
- $separator = [System.IO.Path]::PathSeparator
482
- $pathDirs = @($env:Path -split $separator | Where-Object { $_ })
483
- foreach ($dir in $pathDirs) {
484
- $candidatePath = Join-Path $dir $shortName
485
- if (Test-Path $candidatePath -PathType Leaf) {
486
- $b4Exists = $true
487
- try {
488
- $b4Version = & $candidatePath --version 2>&1
489
- if ($b4Version -match "browser4-cli") {
490
- $b4IsOurs = $true
491
- }
492
- } catch {
493
- # Can't determine --leave it alone
494
- }
495
- break
496
- }
497
- # On Windows, also check for b4.cmd / b4.bat wrappers
498
- if ($script:OSWin) {
499
- foreach ($pext in @('.cmd', '.bat')) {
500
- $candidateWin = Join-Path $dir "b4$pext"
501
- if (Test-Path $candidateWin -PathType Leaf) {
502
- $b4Exists = $true
503
- try {
504
- $b4Version = & $candidateWin --version 2>&1
505
- if ($b4Version -match "browser4-cli") {
506
- $b4IsOurs = $true
507
- }
508
- } catch { }
509
- break
510
- }
511
- }
512
- if ($b4Exists) { break }
513
- }
514
- }
515
- }
516
-
517
- if ($b4Exists -and -not $b4IsOurs) {
518
- Write-WarnMsg "Skipping short link '$shortName': 'b4' is not browser4-cli"
519
- return
520
- }
521
-
522
- if ($DryRun) {
523
- $action = if ($b4Exists) { "Would update link" } else { "Would create link" }
524
- Write-Step "[DRY-RUN] $action`: $shortName -> $BinaryName"
525
- } else {
526
- $null = New-PlatformLink -LinkPath $shortPath -TargetName $BinaryName -DisplayName $shortName
527
- }
528
- }
529
-
530
- # ----------------------------------------------
531
- # Helper: replace a binary that may be locked (i.e. currently running)
532
- # ----------------------------------------------
533
-
534
- function Set-BinaryFile {
535
- param(
536
- [Parameter(Mandatory=$true)] [string]$TargetPath,
537
- [Parameter(Mandatory=$true)] [string]$SourcePath,
538
- [Parameter(Mandatory=$false)] [switch]$Move # $true = move temp file, $false = copy
539
- )
540
-
541
- if (Test-Path $TargetPath) {
542
- # Remove any stale .old from a previous upgrade
543
- $oldPath = "$TargetPath.old"
544
- try {
545
- if (Test-Path $oldPath) { Remove-Item $oldPath -Force -ErrorAction Stop }
546
- } catch { }
547
-
548
- try {
549
- Remove-Item $TargetPath -Force -ErrorAction Stop
550
- } catch {
551
- # The binary is locked (likely the currently-running process).
552
- # On Windows we can rename a running executable -- move the old binary
553
- # out of the way, place the new one alongside, and clean up the old one
554
- # on the next install/upgrade.
555
- try {
556
- Move-Item $TargetPath $oldPath -Force -ErrorAction Stop
557
- Write-WarnMsg "Existing binary is locked (it may be running)."
558
- Write-WarnMsg "The old copy will be cleaned up on the next install or upgrade."
559
- } catch {
560
- throw "Cannot replace '$TargetPath' -- it is locked and cannot be renamed. Close all browser4-cli processes and try again."
561
- }
562
- }
563
- }
564
-
565
- # Place the new binary at the target path
566
- if ($Move) {
567
- Move-Item $SourcePath $TargetPath -Force -ErrorAction Stop
568
- } else {
569
- Copy-Item $SourcePath $TargetPath -Force -ErrorAction Stop
570
- }
571
- }
572
-
573
- # ----------------------------------------------
574
- # PATH management
575
- # ----------------------------------------------
576
-
577
- function Add-DirectoryToUserPath {
578
- param([string]$Dir)
579
-
580
- $dirResolved = (Resolve-Path $Dir -ErrorAction SilentlyContinue).Path
581
- if (-not $dirResolved) { $dirResolved = $Dir }
582
-
583
- # Read current user PATH
584
- $currentUserPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
585
- $paths = if ($currentUserPath) { $currentUserPath -split ";" | Where-Object { $_ } } else { @() }
586
-
587
- # Check if already present
588
- $normalized = $paths | ForEach-Object { $rp = Resolve-Path $_ -ErrorAction SilentlyContinue; if ($rp) { $rp.Path } else { $_ } }
589
- if ($normalized -contains $dirResolved) {
590
- Write-Check "Already in user PATH: $Dir"
591
- return
592
- }
593
-
594
- if ($DryRun) {
595
- Write-Check "[DRY-RUN] Would add to user PATH: $Dir"
596
- return
597
- }
598
-
599
- $newPath = if ($currentUserPath) { "$currentUserPath;$Dir" } else { $Dir }
600
- [System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::User)
601
- Write-Check "Added to user PATH: $Dir"
602
-
603
- # Also update current session
604
- $env:Path = "$env:Path;$Dir"
605
- }
606
-
607
- # ----------------------------------------------
608
- # Main
609
- # ----------------------------------------------
610
-
611
- function Main {
612
- Write-Summary "==========================================" -Color Cyan
613
- Write-Summary " browser4-cli Installer" -Color Cyan
614
- Write-Summary "==========================================" -Color Cyan
615
- Write-Summary ""
616
-
617
- # Auto-detect China mainland locale when no explicit source is given
618
- if (-not $Source) {
619
- $script:ChinaDetected = Test-ChinaLocale
620
- if ($script:ChinaDetected) {
621
- Write-Step "China mainland locale detected: preferring Aliyun OSS mirror."
622
- }
623
- }
624
-
625
- # Detect platform
626
- $platformKey = Get-PlatformKey
627
- $binaryName = Get-BinaryName -PlatformKey $platformKey
628
-
629
- # -- Locate mode: print diagnostics and exit --
630
- if ($Locate) {
631
- Write-Summary "--- Locate / diagnostics ---" -Color Cyan
632
- Write-Summary ""
633
- Write-Step "Script dir: $ScriptDir"
634
- Write-Step "Platform key: $platformKey"
635
- Write-Step "Binary name: $binaryName"
636
- Write-Step "Default install: $(Get-DefaultInstallDir)"
637
- Write-Step "China locale: $script:ChinaDetected"
638
- Write-Step "Source override: $(if ($Source) { $Source } else { 'auto' })"
639
- Write-Step "OS: $(if ($script:OSWin) { 'Windows' } elseif ($script:OSMac) { 'macOS' } elseif ($script:OSLinux) { 'Linux' } else { 'Unknown' })"
640
-
641
- # Check for local binary
642
- $localPath = Find-LocalBinary -BinaryName $binaryName
643
- if ($localPath) {
644
- $localOk = Test-LocalBinary -Path $localPath
645
- Write-Step "Local binary: $localPath $(if ($localOk) { '(valid)' } else { '(present but --version failed)' })"
646
- } else {
647
- Write-Step "Local binary: not found alongside script"
648
- }
649
-
650
- # Check for already-installed binary
651
- $defaultDir = Get-DefaultInstallDir
652
- $existingPath = Join-Path $defaultDir $binaryName
653
- if (Test-Path $existingPath) {
654
- Write-Step "Already installed: $existingPath"
655
- } else {
656
- Write-Step "Already installed: not found at $defaultDir"
657
- }
658
-
659
- # Show download URLs that would be tried
660
- $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
661
- Write-Summary ""
662
- Write-Step "Download order:"
663
- foreach ($entry in $urls) {
664
- Write-Step " $($entry.Label): $($entry.Url)"
665
- }
666
-
667
- Write-Summary ""
668
- return
669
- }
670
-
671
- Write-Step "Platform: $platformKey"
672
- Write-Step "Binary: $binaryName"
673
-
674
- # Determine install directory
675
- $installDir = if ($InstallDir) { $InstallDir } else { Get-DefaultInstallDir }
676
- Write-Step "Install: $installDir"
677
- Write-Summary ""
678
-
679
- if (-not (Test-Path $installDir)) {
680
- if (-not $DryRun) {
681
- New-Item -ItemType Directory -Path $installDir -Force | Out-Null
682
- }
683
- Write-Step "Created directory: $installDir"
684
- }
685
-
686
- $binaryPath = Join-Path $installDir $binaryName
687
-
688
- # -- Local binary discovery (bundled/sideload) --
689
- $useLocalBinary = $false
690
- if (-not $SkipLocal) {
691
- $localBinaryPath = Find-LocalBinary -BinaryName $binaryName
692
- if ($localBinaryPath) {
693
- Write-Step "Found local binary alongside script: $(Split-Path $localBinaryPath -Leaf)"
694
- if (Test-LocalBinary -Path $localBinaryPath) {
695
- Write-Check "Local binary verified (--version OK)"
696
- $useLocalBinary = $true
697
- } else {
698
- Write-WarnMsg "Local binary found but --version check failed -- will download instead"
699
- }
700
- }
701
- } elseif ($SkipLocal) {
702
- Write-Step "Skipping local binary check (-SkipLocal)"
703
- }
704
-
705
- # Skip download only when --skip-if-installed is set and binary already exists,
706
- # unless --force overrides it.
707
- if ((Test-Path $binaryPath) -and (-not $Version) -and $SkipIfInstalled -and (-not $Force) -and (-not $useLocalBinary)) {
708
- Write-Check "Binary already installed: $binaryPath"
709
- } elseif ($useLocalBinary) {
710
- # Copy local binary to install dir
711
- if (-not $DryRun) {
712
- Set-BinaryFile -TargetPath $binaryPath -SourcePath $localBinaryPath
713
- }
714
- Write-Check "Installed (local): $binaryPath"
715
- } else {
716
- # Build download URLs
717
- $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
718
- if (-not $urls -or $urls.Count -eq 0) {
719
- throw "No download URLs configured"
720
- }
721
-
722
- $downloaded = $false
723
- $tempFile = [System.IO.Path]::GetTempFileName()
724
-
725
- foreach ($entry in $urls) {
726
- if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
727
- $downloaded = $true
728
- break
729
- }
730
- }
731
-
732
- if (-not $downloaded) {
733
- if (Test-Path $tempFile) { Remove-Item $tempFile -Force }
734
- throw @"
735
- Could not download browser4-cli binary.
736
-
737
- Tried:
738
- $($urls | ForEach-Object { " - $($_.Label): $($_.Url)" } | Out-String)
739
-
740
- Please check:
741
- - Network connectivity
742
- - The version/tag exists: $Version
743
- - For GitHub rate limits, set GITHUB_TOKEN environment variable
744
- - If you have a local copy, place it alongside this script and re-run
745
- "@
746
- }
747
-
748
- # Move from temp to install dir
749
- if (-not $DryRun) {
750
- Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
751
- }
752
- Write-Check "Installed: $binaryPath"
753
- }
754
-
755
- # On Unix, ensure executable bit
756
- if (-not $script:OSWin) {
757
- if (-not $DryRun) {
758
- try { chmod +x $binaryPath 2>$null } catch { }
759
- }
760
- }
761
-
762
- # Create symlinks (browser4-cli -> platform binary, b4 if no conflict)
763
- Write-Summary ""
764
- New-Symlinks -BinaryName $binaryName -InstallDir $installDir -PlatformKey $platformKey
765
-
766
- # Add to PATH
767
- if ($AddToPath -and $script:OSWin) {
768
- Write-Summary ""
769
- Add-DirectoryToUserPath -Dir $installDir
770
- } elseif ($AddToPath -and -not $script:OSWin) {
771
- Write-Summary ""
772
- $shellRc = if (Test-Path "$env:HOME/.zshrc") { "$env:HOME/.zshrc" } elseif (Test-Path "$env:HOME/.bashrc") { "$env:HOME/.bashrc" } elseif (Test-Path "$env:HOME/.bash_profile") { "$env:HOME/.bash_profile" } else { "$env:HOME/.profile" }
773
- $pathLine = "export PATH=""$installDir`:`$PATH"""
774
- if (-not $DryRun) {
775
- if (-not (Select-String -Path $shellRc -Pattern [regex]::Escape($installDir) -ErrorAction SilentlyContinue)) {
776
- Add-Content -Path $shellRc -Value ""
777
- Add-Content -Path $shellRc -Value "# browser4-cli"
778
- Add-Content -Path $shellRc -Value $pathLine
779
- Write-Check "Added to PATH in $shellRc"
780
- } else {
781
- Write-Check "PATH entry already in $shellRc"
782
- }
783
- } else {
784
- Write-Check "[DRY-RUN] Would add to $shellRc"
785
- }
786
- }
787
-
788
- # Verify
789
- Write-Summary ""
790
- if (-not $DryRun) {
791
- try {
792
- $versionOutput = & $binaryPath --version 2>&1
793
- Write-Summary "[v] browser4-cli installed successfully" -Color Green
794
- Write-Summary " Version: $versionOutput"
795
- } catch {
796
- Write-Summary "[v] Binary installed at: $binaryPath" -Color Green
797
- Write-WarnMsg "Could not verify --version (this is normal on first install)"
798
- }
799
- } else {
800
- Write-Summary "[DRY-RUN] Installation plan complete" -Color Yellow
801
- }
802
-
803
- Write-Summary ""
804
- Write-Summary "Run 'browser4-cli --help' to get started." -Color Cyan
805
-
806
- if ($script:OSWin) {
807
- Write-Summary "If the command isn't found, restart your terminal or run:"
808
- Write-Summary " `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User') + ';' + [System.Environment]::GetEnvironmentVariable('Path','Machine')"
809
- }
810
- }
811
-
812
- Main
1
+ <#
2
+ .SYNOPSIS
3
+ Install browser4-cli -- download the native binary and set it up on your PATH.
4
+
5
+ .DESCRIPTION
6
+ Detects your OS and CPU architecture, downloads the matching native binary from
7
+ GitHub Releases or Alibaba Cloud OSS, installs it to a user-local directory, and
8
+ optionally adds it to your PATH.
9
+
10
+ Default install location:
11
+ Windows: $env:LOCALAPPDATA\Programs\browser4-cli
12
+ (override with -InstallDir)
13
+
14
+ Download sources (auto-selected by locale; use -Source to override):
15
+ Outside China: 1. GitHub Releases -> 2. Aliyun OSS
16
+ China mainland: 1. Aliyun OSS -> 2. GitHub Releases
17
+ GitHub Releases -- https://github.com/platonai/Browser4
18
+ Aliyun OSS -- https://browser4.oss-cn-beijing.aliyuncs.com
19
+
20
+ .PARAMETER Version
21
+ Release version tag to download (e.g. "v4.11.0" or "v0.1.12-cli").
22
+ Defaults to "latest" which resolves to the most recent stable release.
23
+
24
+ .PARAMETER InstallDir
25
+ Directory to install the binary into.
26
+ Default: $env:LOCALAPPDATA\Programs\browser4-cli
27
+
28
+ .PARAMETER Source
29
+ Force a specific download source: "github" or "oss".
30
+ Default (auto): locale-aware -- OSS first in China mainland, GitHub first elsewhere.
31
+
32
+ .PARAMETER AddToPath
33
+ Add the install directory to the current user's PATH environment variable.
34
+ Default: true.
35
+
36
+ .PARAMETER Silent
37
+ Suppress all non-error output.
38
+
39
+ .PARAMETER DryRun
40
+ Print what would be done without actually doing it.
41
+
42
+ .PARAMETER SkipIfInstalled
43
+ Skip download if the binary already exists at the install path.
44
+ By default the script always downloads the latest version.
45
+
46
+ .PARAMETER SkipLocal
47
+ Skip checking for a locally-bundled binary alongside the script.
48
+ By default the script looks for the platform binary in its own directory
49
+ before downloading -- use this to force a fresh download.
50
+
51
+ .PARAMETER Force
52
+ Force reinstallation even if the binary is already installed at the target path.
53
+ Overrides -SkipIfInstalled and bypasses locked-file workarounds.
54
+
55
+ .PARAMETER Locate
56
+ Print detection results (OS, architecture, script location, China locale)
57
+ and exit without installing. Useful for diagnostics.
58
+
59
+ .EXAMPLE
60
+ # Quick install -- default location, latest version, add to PATH
61
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1
62
+
63
+ .EXAMPLE
64
+ # Silent install with a specific version
65
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Version "v4.11.0" -Silent
66
+
67
+ .EXAMPLE
68
+ # Install from OSS only, custom directory
69
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Source oss -InstallDir "C:\tools\browser4"
70
+
71
+ .EXAMPLE
72
+ # Run diagnostics -- see what the script detects without installing
73
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Locate
74
+
75
+ .EXAMPLE
76
+ # Use a locally-bundled binary (place binary next to the script)
77
+ # The script auto-detects binaries in its own directory
78
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1
79
+
80
+ .EXAMPLE
81
+ # Force download even when a local binary exists
82
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -SkipLocal
83
+
84
+ .EXAMPLE
85
+ # Skip download if already installed (opt out of default reinstall)
86
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -SkipIfInstalled
87
+
88
+ .EXAMPLE
89
+ # For China mainland: OSS is auto-preferred via locale detection,
90
+ # or force it explicitly
91
+ powershell -ExecutionPolicy Bypass -File install-browser4-cli.ps1 -Source oss
92
+ #>
93
+
94
+ [CmdletBinding()]
95
+ param(
96
+ [string]$Version = "",
97
+ [string]$InstallDir = "",
98
+ [ValidateSet("", "github", "oss")]
99
+ [string]$Source = "",
100
+ [bool]$AddToPath = $true,
101
+ [switch]$Silent,
102
+ [switch]$DryRun,
103
+ [switch]$SkipIfInstalled,
104
+ [switch]$SkipLocal,
105
+ [switch]$Force,
106
+ [switch]$Locate
107
+ )
108
+
109
+ $ErrorActionPreference = "Stop"
110
+
111
+ # ----------------------------------------------
112
+ # Script location -- find ourselves on disk
113
+ # ----------------------------------------------
114
+
115
+ # $PSScriptRoot is the directory containing this script (PS 3+).
116
+ # Falls back to $MyInvocation for edge cases (dot-sourced, PS 2).
117
+ $ScriptDir = if ($PSScriptRoot) {
118
+ $PSScriptRoot
119
+ } elseif ($MyInvocation -and $MyInvocation.MyCommand.Path) {
120
+ Split-Path -Parent $MyInvocation.MyCommand.Path
121
+ } else {
122
+ $null
123
+ }
124
+
125
+ <#
126
+ .SYNOPSIS
127
+ Search for a pre-downloaded binary near the script (bundled/sideload install).
128
+ Returns the full path if found, $null otherwise.
129
+ #>
130
+ function Find-LocalBinary {
131
+ param([string]$BinaryName)
132
+
133
+ if (-not $ScriptDir) { return $null }
134
+
135
+ $localPath = Join-Path $ScriptDir $BinaryName
136
+ if (Test-Path $localPath -PathType Leaf) {
137
+ $size = (Get-Item $localPath).Length
138
+ if ($size -gt 102400) { # > 100 KB minimum
139
+ return $localPath
140
+ }
141
+ }
142
+ return $null
143
+ }
144
+
145
+ <#
146
+ .SYNOPSIS
147
+ Check whether a local binary is usable by querying its version.
148
+ Returns $true if --version executes successfully, $false otherwise.
149
+ #>
150
+ function Test-LocalBinary {
151
+ param([string]$Path)
152
+
153
+ if (-not $Path -or -not (Test-Path $Path)) { return $false }
154
+
155
+ try {
156
+ $null = & $Path --version 2>&1
157
+ return ($LASTEXITCODE -eq 0)
158
+ } catch {
159
+ return $false
160
+ }
161
+ }
162
+
163
+ # ----------------------------------------------
164
+ # OS detection (compatible with PS 5.1+)
165
+ # ----------------------------------------------
166
+
167
+ # Avoid assigning to $IsLinux / $IsWindows / $IsMacOS directly --
168
+ # they are read-only automatic variables in PowerShell 7+.
169
+ if ($PSVersionTable.PSVersion.Major -ge 6) {
170
+ $script:OSWin = $IsWindows
171
+ $script:OSLinux = $IsLinux
172
+ $script:OSMac = $IsMacOS
173
+ } else {
174
+ $script:OSWin = [System.Environment]::OSVersion.Platform -eq "Win32NT"
175
+ $script:OSMac = $false
176
+ $script:OSLinux = $false
177
+ }
178
+
179
+ # ----------------------------------------------
180
+ # Helpers
181
+ # ----------------------------------------------
182
+
183
+ function Write-Summary {
184
+ param([string]$Message, [string]$Color = "White")
185
+ if (-not $Silent) { Write-Host $Message -ForegroundColor $Color }
186
+ }
187
+
188
+ function Write-Step {
189
+ param([string]$Message)
190
+ if (-not $Silent) { Write-Host " >> $Message" -ForegroundColor Gray }
191
+ }
192
+
193
+ function Write-Check {
194
+ param([string]$Message)
195
+ if (-not $Silent) { Write-Host " [v] $Message" -ForegroundColor Green }
196
+ }
197
+
198
+ function Write-WarnMsg {
199
+ param([string]$Message)
200
+ if (-not $Silent) { Write-Host " [!] $Message" -ForegroundColor Yellow }
201
+ }
202
+
203
+ # ----------------------------------------------
204
+ # Detection
205
+ # ----------------------------------------------
206
+
207
+ function Get-PlatformKey {
208
+ $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "arm64" } else { "x64" }
209
+
210
+ if ($script:OSWin) {
211
+ return "win32-$arch"
212
+ }
213
+ elseif ($script:OSMac) {
214
+ return "darwin-$arch"
215
+ }
216
+ elseif ($script:OSLinux) {
217
+ # Detect musl
218
+ $isMusl = $false
219
+ try {
220
+ $lddOutput = ldd --version 2>&1
221
+ if ($lddOutput -match "musl") { $isMusl = $true }
222
+ } catch {
223
+ if ((Test-Path "/lib/ld-musl-x86_64.so.1") -or (Test-Path "/lib/ld-musl-aarch64.so.1")) {
224
+ $isMusl = $true
225
+ }
226
+ }
227
+ $libc = if ($isMusl) { "musl" } else { "" }
228
+ if ($libc) { return "linux-$libc-$arch" } else { return "linux-$arch" }
229
+ }
230
+ else {
231
+ throw "Unsupported OS. Browser4 CLI supports Windows, macOS, and Linux."
232
+ }
233
+ }
234
+
235
+ function Get-BinaryName {
236
+ param([string]$PlatformKey)
237
+ return "browser4-cli-$PlatformKey" + $(if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" })
238
+ }
239
+
240
+ function Get-DefaultInstallDir {
241
+ if ($script:OSWin) {
242
+ return Join-Path $env:LOCALAPPDATA "Programs\browser4-cli"
243
+ }
244
+ elseif ($script:OSLinux -or $script:OSMac) {
245
+ # Prefer ~/.local/bin for user installs
246
+ if (Test-Path "$env:HOME/.local/bin") {
247
+ return "$env:HOME/.local/bin"
248
+ }
249
+ return "$env:HOME/.local/bin"
250
+ }
251
+ throw "Unsupported OS"
252
+ }
253
+
254
+ # ----------------------------------------------
255
+ # China mainland locale detection (zero-network)
256
+ # ----------------------------------------------
257
+
258
+ <#
259
+ .SYNOPSIS
260
+ Detect whether the current system is likely in China mainland.
261
+ Uses only local env vars and .NET APIs -- no network calls.
262
+ #>
263
+ function Test-ChinaLocale {
264
+ # 1 -- Locale env vars
265
+ $lang = $env:LC_ALL, $env:LANG, $env:LC_CTYPE, $env:LC_MESSAGES | Where-Object { $_ } | Select-Object -First 1
266
+ if ($lang -and ($lang -match '^zh_CN' -or $lang -match '^zh-CN' -or $lang -match '^Chinese \(Simplified\)_China')) {
267
+ return $true
268
+ }
269
+
270
+ # 2 -- TZ env var
271
+ $tzEnv = $env:TZ
272
+ if ($tzEnv -and ($tzEnv -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$')) {
273
+ return $true
274
+ }
275
+
276
+ # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+)
277
+ try {
278
+ $tzId = [System.TimeZoneInfo]::Local.Id
279
+ if ($tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
280
+ return $true
281
+ }
282
+ } catch {
283
+ # TimeZoneInfo not available (unlikely on PS 5.1+ but guard anyway)
284
+ }
285
+
286
+ # 4 -- /etc/timezone (PowerShell on Linux/macOS)
287
+ if (-not $script:OSWin -and (Test-Path '/etc/timezone')) {
288
+ try {
289
+ $tz = Get-Content '/etc/timezone' -Raw -ErrorAction Stop
290
+ if ($tz -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
291
+ return $true
292
+ }
293
+ } catch {
294
+ # Permission or read error -- skip
295
+ }
296
+ }
297
+
298
+ return $false
299
+ }
300
+
301
+ # ----------------------------------------------
302
+ # Download URLs
303
+ # ----------------------------------------------
304
+
305
+ $GITHUB_REPO = "platonai/Browser4"
306
+ $OSS_BASE = "https://browser4.oss-cn-beijing.aliyuncs.com"
307
+ $script:ChinaDetected = $false
308
+
309
+ function Get-DownloadUrls {
310
+ param([string]$BinaryName, [string]$VersionTag)
311
+
312
+ $urls = @()
313
+
314
+ $ghBase = "https://github.com/$GITHUB_REPO/releases/download"
315
+ if ($VersionTag) {
316
+ $ghUrl = "$ghBase/$VersionTag/$BinaryName"
317
+ $ossUrl = "$OSS_BASE/releases/download/$VersionTag/$BinaryName"
318
+ } else {
319
+ # Use 'latest' redirect for GitHub, 'latest' symlink for OSS
320
+ $ghUrl = "https://github.com/$GITHUB_REPO/releases/latest/download/$BinaryName"
321
+ $ossUrl = "$OSS_BASE/releases/download/latest/$BinaryName"
322
+ }
323
+
324
+ if ($Source -eq "github") {
325
+ $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
326
+ } elseif ($Source -eq "oss") {
327
+ $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
328
+ } else {
329
+ if ($script:ChinaDetected) {
330
+ $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
331
+ $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
332
+ } else {
333
+ $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
334
+ $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
335
+ }
336
+ }
337
+
338
+ return $urls
339
+ }
340
+
341
+ function Invoke-Download {
342
+ param([string]$Url, [string]$OutFile, [string]$Label)
343
+
344
+ Write-Step "Trying $Label..."
345
+ Write-Step "URL: $Url"
346
+
347
+ if ($DryRun) {
348
+ Write-Check "[DRY-RUN] Would download to: $OutFile"
349
+ return $true
350
+ }
351
+
352
+ try {
353
+ $ProgressPreference = if ($Silent) { "SilentlyContinue" } else { "Continue" }
354
+
355
+ # Use Invoke-WebRequest with progress bar
356
+ Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
357
+
358
+ if (Test-Path $OutFile) {
359
+ $size = (Get-Item $OutFile).Length
360
+ if ($size -gt 102400) { # > 100 KB minimum
361
+ Write-Check "Downloaded $( [math]::Round($size / 1MB, 1) ) MB"
362
+ return $true
363
+ } else {
364
+ Write-WarnMsg "Downloaded file too small ($size bytes) -- may be an error page"
365
+ Remove-Item $OutFile -Force -ErrorAction SilentlyContinue
366
+ return $false
367
+ }
368
+ }
369
+ Write-WarnMsg "Download appeared to succeed but file not found"
370
+ return $false
371
+ } catch {
372
+ Write-WarnMsg "Failed: $($_.Exception.Message)"
373
+ return $false
374
+ }
375
+ }
376
+
377
+ # ----------------------------------------------
378
+ # Symlinks
379
+ # ----------------------------------------------
380
+
381
+ function New-PlatformLink {
382
+ param([string]$LinkPath, [string]$TargetName, [string]$DisplayName)
383
+
384
+ # Try symbolic link first (works on Unix; on Windows needs Admin or Developer Mode)
385
+ try {
386
+ New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetName -Force -ErrorAction Stop | Out-Null
387
+ Write-Check "Created symlink: $DisplayName -> $TargetName"
388
+ return $true
389
+ } catch {
390
+ # Symbolic link failed -- try hard link on Windows
391
+ }
392
+
393
+ # Try hard link (Windows, same volume)
394
+ if ($script:OSWin) {
395
+ try {
396
+ $targetPath = Join-Path (Split-Path $LinkPath -Parent) $TargetName
397
+ New-Item -ItemType HardLink -Path $LinkPath -Target $targetPath -Force -ErrorAction Stop | Out-Null
398
+ Write-Check "Created hard link: $DisplayName -> $TargetName"
399
+ return $true
400
+ } catch {
401
+ # Hard link also failed -- create a .cmd wrapper as last resort
402
+ }
403
+
404
+ # Last resort: .cmd wrapper that forwards all arguments
405
+ try {
406
+ $wrapperContent = '@"%~dp0' + $TargetName + '" %*'
407
+ Set-Content -Path ($LinkPath -replace '\.exe$', '.cmd') -Value $wrapperContent -Force -ErrorAction Stop
408
+ Write-Check "Created wrapper: " + (Split-Path ($LinkPath -replace '\.exe$', '.cmd') -Leaf) + " -> $TargetName"
409
+ return $true
410
+ } catch {
411
+ Write-WarnMsg "Could not create link for $DisplayName (may need admin privileges)"
412
+ return $false
413
+ }
414
+ }
415
+
416
+ Write-WarnMsg "Could not create link for $DisplayName"
417
+ return $false
418
+ }
419
+
420
+ function New-Symlinks {
421
+ param([string]$BinaryName, [string]$InstallDir, [string]$PlatformKey)
422
+
423
+ $ext = if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" }
424
+
425
+ # 1) Always: browser4-cli -> browser4-cli-<platform>
426
+ $linkName = "browser4-cli$ext"
427
+ $linkPath = Join-Path $InstallDir $linkName
428
+
429
+ if ($DryRun) {
430
+ Write-Step "[DRY-RUN] Would create link: $linkName -> $BinaryName"
431
+ } else {
432
+ $null = New-PlatformLink -LinkPath $linkPath -TargetName $BinaryName -DisplayName $linkName
433
+ }
434
+
435
+ # 2) b4 -> browser4-cli-<platform> (only if b4 is our tool or doesn't exist)
436
+ $shortName = "b4$ext"
437
+ $shortPath = Join-Path $InstallDir $shortName
438
+
439
+ $b4Exists = $false
440
+ $b4IsOurs = $false
441
+
442
+ # Check in install dir first --anything here is ours
443
+ if (Test-Path $shortPath) {
444
+ $b4Exists = $true
445
+ $b4IsOurs = $true
446
+ }
447
+
448
+ # Also check b4.cmd wrapper on Windows
449
+ if ($script:OSWin -and -not $b4Exists) {
450
+ $shortCmdPath = Join-Path $InstallDir "b4.cmd"
451
+ if (Test-Path $shortCmdPath) {
452
+ $b4Exists = $true
453
+ $b4IsOurs = $true
454
+ }
455
+ }
456
+
457
+ # Check if b4 is on PATH from somewhere else.
458
+ # Try Get-Command first; fall back to scanning PATH directories manually
459
+ # because Get-Command may miss extensionless executables on Linux/macOS
460
+ # or may cache stale results in some PowerShell versions.
461
+ if (-not $b4Exists) {
462
+ $existingCmd = Get-Command b4 -ErrorAction SilentlyContinue
463
+ if ($existingCmd) {
464
+ $b4Exists = $true
465
+ # Check if it's ours by running --version
466
+ try {
467
+ $b4Version = & b4 --version 2>&1
468
+ if ($b4Version -match "browser4-cli") {
469
+ $b4IsOurs = $true
470
+ }
471
+ } catch {
472
+ # Can't determine --leave it alone
473
+ }
474
+ }
475
+ }
476
+
477
+ # Fallback: scan PATH manually when Get-Command didn't find anything.
478
+ # On Linux/macOS, PowerShell's Get-Command may miss scripts that are
479
+ # executable but not in its command cache, especially in CI containers.
480
+ if (-not $b4Exists) {
481
+ $separator = [System.IO.Path]::PathSeparator
482
+ $pathDirs = @($env:Path -split $separator | Where-Object { $_ })
483
+ foreach ($dir in $pathDirs) {
484
+ $candidatePath = Join-Path $dir $shortName
485
+ if (Test-Path $candidatePath -PathType Leaf) {
486
+ $b4Exists = $true
487
+ try {
488
+ $b4Version = & $candidatePath --version 2>&1
489
+ if ($b4Version -match "browser4-cli") {
490
+ $b4IsOurs = $true
491
+ }
492
+ } catch {
493
+ # Can't determine --leave it alone
494
+ }
495
+ break
496
+ }
497
+ # On Windows, also check for b4.cmd / b4.bat wrappers
498
+ if ($script:OSWin) {
499
+ foreach ($pext in @('.cmd', '.bat')) {
500
+ $candidateWin = Join-Path $dir "b4$pext"
501
+ if (Test-Path $candidateWin -PathType Leaf) {
502
+ $b4Exists = $true
503
+ try {
504
+ $b4Version = & $candidateWin --version 2>&1
505
+ if ($b4Version -match "browser4-cli") {
506
+ $b4IsOurs = $true
507
+ }
508
+ } catch { }
509
+ break
510
+ }
511
+ }
512
+ if ($b4Exists) { break }
513
+ }
514
+ }
515
+ }
516
+
517
+ if ($b4Exists -and -not $b4IsOurs) {
518
+ Write-WarnMsg "Skipping short link '$shortName': 'b4' is not browser4-cli"
519
+ return
520
+ }
521
+
522
+ if ($DryRun) {
523
+ $action = if ($b4Exists) { "Would update link" } else { "Would create link" }
524
+ Write-Step "[DRY-RUN] $action`: $shortName -> $BinaryName"
525
+ } else {
526
+ $null = New-PlatformLink -LinkPath $shortPath -TargetName $BinaryName -DisplayName $shortName
527
+ }
528
+ }
529
+
530
+ # ----------------------------------------------
531
+ # Helper: replace a binary that may be locked (i.e. currently running)
532
+ # ----------------------------------------------
533
+
534
+ function Set-BinaryFile {
535
+ param(
536
+ [Parameter(Mandatory=$true)] [string]$TargetPath,
537
+ [Parameter(Mandatory=$true)] [string]$SourcePath,
538
+ [Parameter(Mandatory=$false)] [switch]$Move # $true = move temp file, $false = copy
539
+ )
540
+
541
+ if (Test-Path $TargetPath) {
542
+ # Remove any stale .old from a previous upgrade
543
+ $oldPath = "$TargetPath.old"
544
+ try {
545
+ if (Test-Path $oldPath) { Remove-Item $oldPath -Force -ErrorAction Stop }
546
+ } catch { }
547
+
548
+ try {
549
+ Remove-Item $TargetPath -Force -ErrorAction Stop
550
+ } catch {
551
+ # The binary is locked (likely the currently-running process).
552
+ # On Windows we can rename a running executable -- move the old binary
553
+ # out of the way, place the new one alongside, and clean up the old one
554
+ # on the next install/upgrade.
555
+ try {
556
+ Move-Item $TargetPath $oldPath -Force -ErrorAction Stop
557
+ Write-WarnMsg "Existing binary is locked (it may be running)."
558
+ Write-WarnMsg "The old copy will be cleaned up on the next install or upgrade."
559
+ } catch {
560
+ throw "Cannot replace '$TargetPath' -- it is locked and cannot be renamed. Close all browser4-cli processes and try again."
561
+ }
562
+ }
563
+ }
564
+
565
+ # Place the new binary at the target path
566
+ if ($Move) {
567
+ Move-Item $SourcePath $TargetPath -Force -ErrorAction Stop
568
+ } else {
569
+ Copy-Item $SourcePath $TargetPath -Force -ErrorAction Stop
570
+ }
571
+ }
572
+
573
+ # ----------------------------------------------
574
+ # PATH management
575
+ # ----------------------------------------------
576
+
577
+ function Add-DirectoryToUserPath {
578
+ param([string]$Dir)
579
+
580
+ $dirResolved = (Resolve-Path $Dir -ErrorAction SilentlyContinue).Path
581
+ if (-not $dirResolved) { $dirResolved = $Dir }
582
+
583
+ # Read current user PATH
584
+ $currentUserPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
585
+ $paths = if ($currentUserPath) { $currentUserPath -split ";" | Where-Object { $_ } } else { @() }
586
+
587
+ # Check if already present
588
+ $normalized = $paths | ForEach-Object { $rp = Resolve-Path $_ -ErrorAction SilentlyContinue; if ($rp) { $rp.Path } else { $_ } }
589
+ if ($normalized -contains $dirResolved) {
590
+ Write-Check "Already in user PATH: $Dir"
591
+ return
592
+ }
593
+
594
+ if ($DryRun) {
595
+ Write-Check "[DRY-RUN] Would add to user PATH: $Dir"
596
+ return
597
+ }
598
+
599
+ $newPath = if ($currentUserPath) { "$currentUserPath;$Dir" } else { $Dir }
600
+ [System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::User)
601
+ Write-Check "Added to user PATH: $Dir"
602
+
603
+ # Also update current session
604
+ $env:Path = "$env:Path;$Dir"
605
+ }
606
+
607
+ # ----------------------------------------------
608
+ # Main
609
+ # ----------------------------------------------
610
+
611
+ function Main {
612
+ Write-Summary "==========================================" -Color Cyan
613
+ Write-Summary " browser4-cli Installer" -Color Cyan
614
+ Write-Summary "==========================================" -Color Cyan
615
+ Write-Summary ""
616
+
617
+ # Auto-detect China mainland locale when no explicit source is given
618
+ if (-not $Source) {
619
+ $script:ChinaDetected = Test-ChinaLocale
620
+ if ($script:ChinaDetected) {
621
+ Write-Step "China mainland locale detected: preferring Aliyun OSS mirror."
622
+ }
623
+ }
624
+
625
+ # Detect platform
626
+ $platformKey = Get-PlatformKey
627
+ $binaryName = Get-BinaryName -PlatformKey $platformKey
628
+
629
+ # -- Locate mode: print diagnostics and exit --
630
+ if ($Locate) {
631
+ Write-Summary "--- Locate / diagnostics ---" -Color Cyan
632
+ Write-Summary ""
633
+ Write-Step "Script dir: $ScriptDir"
634
+ Write-Step "Platform key: $platformKey"
635
+ Write-Step "Binary name: $binaryName"
636
+ Write-Step "Default install: $(Get-DefaultInstallDir)"
637
+ Write-Step "China locale: $script:ChinaDetected"
638
+ Write-Step "Source override: $(if ($Source) { $Source } else { 'auto' })"
639
+ Write-Step "OS: $(if ($script:OSWin) { 'Windows' } elseif ($script:OSMac) { 'macOS' } elseif ($script:OSLinux) { 'Linux' } else { 'Unknown' })"
640
+
641
+ # Check for local binary
642
+ $localPath = Find-LocalBinary -BinaryName $binaryName
643
+ if ($localPath) {
644
+ $localOk = Test-LocalBinary -Path $localPath
645
+ Write-Step "Local binary: $localPath $(if ($localOk) { '(valid)' } else { '(present but --version failed)' })"
646
+ } else {
647
+ Write-Step "Local binary: not found alongside script"
648
+ }
649
+
650
+ # Check for already-installed binary
651
+ $defaultDir = Get-DefaultInstallDir
652
+ $existingPath = Join-Path $defaultDir $binaryName
653
+ if (Test-Path $existingPath) {
654
+ Write-Step "Already installed: $existingPath"
655
+ } else {
656
+ Write-Step "Already installed: not found at $defaultDir"
657
+ }
658
+
659
+ # Show download URLs that would be tried
660
+ $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
661
+ Write-Summary ""
662
+ Write-Step "Download order:"
663
+ foreach ($entry in $urls) {
664
+ Write-Step " $($entry.Label): $($entry.Url)"
665
+ }
666
+
667
+ Write-Summary ""
668
+ return
669
+ }
670
+
671
+ Write-Step "Platform: $platformKey"
672
+ Write-Step "Binary: $binaryName"
673
+
674
+ # Determine install directory
675
+ $installDir = if ($InstallDir) { $InstallDir } else { Get-DefaultInstallDir }
676
+ Write-Step "Install: $installDir"
677
+ Write-Summary ""
678
+
679
+ if (-not (Test-Path $installDir)) {
680
+ if (-not $DryRun) {
681
+ New-Item -ItemType Directory -Path $installDir -Force | Out-Null
682
+ }
683
+ Write-Step "Created directory: $installDir"
684
+ }
685
+
686
+ $binaryPath = Join-Path $installDir $binaryName
687
+
688
+ # -- Local binary discovery (bundled/sideload) --
689
+ $useLocalBinary = $false
690
+ if (-not $SkipLocal) {
691
+ $localBinaryPath = Find-LocalBinary -BinaryName $binaryName
692
+ if ($localBinaryPath) {
693
+ Write-Step "Found local binary alongside script: $(Split-Path $localBinaryPath -Leaf)"
694
+ if (Test-LocalBinary -Path $localBinaryPath) {
695
+ Write-Check "Local binary verified (--version OK)"
696
+ $useLocalBinary = $true
697
+ } else {
698
+ Write-WarnMsg "Local binary found but --version check failed -- will download instead"
699
+ }
700
+ }
701
+ } elseif ($SkipLocal) {
702
+ Write-Step "Skipping local binary check (-SkipLocal)"
703
+ }
704
+
705
+ # Skip download only when --skip-if-installed is set and binary already exists,
706
+ # unless --force overrides it.
707
+ if ((Test-Path $binaryPath) -and (-not $Version) -and $SkipIfInstalled -and (-not $Force) -and (-not $useLocalBinary)) {
708
+ Write-Check "Binary already installed: $binaryPath"
709
+ } elseif ($useLocalBinary) {
710
+ # Copy local binary to install dir
711
+ if (-not $DryRun) {
712
+ Set-BinaryFile -TargetPath $binaryPath -SourcePath $localBinaryPath
713
+ }
714
+ Write-Check "Installed (local): $binaryPath"
715
+ } else {
716
+ # Build download URLs
717
+ $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
718
+ if (-not $urls -or $urls.Count -eq 0) {
719
+ throw "No download URLs configured"
720
+ }
721
+
722
+ $downloaded = $false
723
+ $tempFile = [System.IO.Path]::GetTempFileName()
724
+
725
+ foreach ($entry in $urls) {
726
+ if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
727
+ $downloaded = $true
728
+ break
729
+ }
730
+ }
731
+
732
+ if (-not $downloaded) {
733
+ if (Test-Path $tempFile) { Remove-Item $tempFile -Force }
734
+ throw @"
735
+ Could not download browser4-cli binary.
736
+
737
+ Tried:
738
+ $($urls | ForEach-Object { " - $($_.Label): $($_.Url)" } | Out-String)
739
+
740
+ Please check:
741
+ - Network connectivity
742
+ - The version/tag exists: $Version
743
+ - For GitHub rate limits, set GITHUB_TOKEN environment variable
744
+ - If you have a local copy, place it alongside this script and re-run
745
+ "@
746
+ }
747
+
748
+ # Move from temp to install dir
749
+ if (-not $DryRun) {
750
+ Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
751
+ }
752
+ Write-Check "Installed: $binaryPath"
753
+ }
754
+
755
+ # On Unix, ensure executable bit
756
+ if (-not $script:OSWin) {
757
+ if (-not $DryRun) {
758
+ try { chmod +x $binaryPath 2>$null } catch { }
759
+ }
760
+ }
761
+
762
+ # Create symlinks (browser4-cli -> platform binary, b4 if no conflict)
763
+ Write-Summary ""
764
+ New-Symlinks -BinaryName $binaryName -InstallDir $installDir -PlatformKey $platformKey
765
+
766
+ # Add to PATH
767
+ if ($AddToPath -and $script:OSWin) {
768
+ Write-Summary ""
769
+ Add-DirectoryToUserPath -Dir $installDir
770
+ } elseif ($AddToPath -and -not $script:OSWin) {
771
+ Write-Summary ""
772
+ $shellRc = if (Test-Path "$env:HOME/.zshrc") { "$env:HOME/.zshrc" } elseif (Test-Path "$env:HOME/.bashrc") { "$env:HOME/.bashrc" } elseif (Test-Path "$env:HOME/.bash_profile") { "$env:HOME/.bash_profile" } else { "$env:HOME/.profile" }
773
+ $pathLine = "export PATH=""$installDir`:`$PATH"""
774
+ if (-not $DryRun) {
775
+ if (-not (Select-String -Path $shellRc -Pattern [regex]::Escape($installDir) -ErrorAction SilentlyContinue)) {
776
+ Add-Content -Path $shellRc -Value ""
777
+ Add-Content -Path $shellRc -Value "# browser4-cli"
778
+ Add-Content -Path $shellRc -Value $pathLine
779
+ Write-Check "Added to PATH in $shellRc"
780
+ } else {
781
+ Write-Check "PATH entry already in $shellRc"
782
+ }
783
+ } else {
784
+ Write-Check "[DRY-RUN] Would add to $shellRc"
785
+ }
786
+ }
787
+
788
+ # Verify
789
+ Write-Summary ""
790
+ if (-not $DryRun) {
791
+ try {
792
+ $versionOutput = & $binaryPath --version 2>&1
793
+ Write-Summary "[v] browser4-cli installed successfully" -Color Green
794
+ Write-Summary " Version: $versionOutput"
795
+ } catch {
796
+ Write-Summary "[v] Binary installed at: $binaryPath" -Color Green
797
+ Write-WarnMsg "Could not verify --version (this is normal on first install)"
798
+ }
799
+ } else {
800
+ Write-Summary "[DRY-RUN] Installation plan complete" -Color Yellow
801
+ }
802
+
803
+ Write-Summary ""
804
+ Write-Summary "Run 'browser4-cli --help' to get started." -Color Cyan
805
+
806
+ if ($script:OSWin) {
807
+ Write-Summary "If the command isn't found, restart your terminal or run:"
808
+ Write-Summary " `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User') + ';' + [System.Environment]::GetEnvironmentVariable('Path','Machine')"
809
+ }
810
+ }
811
+
812
+ Main