browser4-cli 4.14.0-rc.5 → 4.14.0-rc.7

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.
@@ -50,7 +50,8 @@
50
50
 
51
51
  .PARAMETER Force
52
52
  Force reinstallation even if the binary is already installed at the target path.
53
- Overrides -SkipIfInstalled and bypasses locked-file workarounds.
53
+ Overrides -SkipIfInstalled (the locked-file workaround in Set-BinaryFile is
54
+ always attempted, with or without this switch).
54
55
 
55
56
  .PARAMETER SkipBackend
56
57
  Skip installing/upgrading the Browser4 backend (runtime bundle).
@@ -115,6 +116,17 @@ param(
115
116
 
116
117
  $ErrorActionPreference = "Stop"
117
118
 
119
+ # Windows PowerShell 5.1 on an older .NET defaults to TLS 1.0, which GitHub and
120
+ # the OSS mirror both refuse ("Could not create SSL/TLS secure channel"). Pin
121
+ # 1.2 when the runtime offers it; PowerShell 6+ already negotiates correctly.
122
+ if ($PSVersionTable.PSVersion.Major -lt 6) {
123
+ try {
124
+ [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
125
+ } catch {
126
+ # Runtime without TLS 1.2 support -- keep its default
127
+ }
128
+ }
129
+
118
130
  # ----------------------------------------------
119
131
  # Script location -- find ourselves on disk
120
132
  # ----------------------------------------------
@@ -280,16 +292,28 @@ function Test-ChinaLocale {
280
292
  return $true
281
293
  }
282
294
 
283
- # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+)
295
+ # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+).
296
+ # Windows reports Windows ids ("China Standard Time"); the IANA ids only
297
+ # appear on Unix, so matching IANA alone made this branch dead on Windows.
284
298
  try {
285
299
  $tzId = [System.TimeZoneInfo]::Local.Id
286
- if ($tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
300
+ if ($tzId -eq 'China Standard Time' -or $tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
287
301
  return $true
288
302
  }
289
303
  } catch {
290
304
  # TimeZoneInfo not available (unlikely on PS 5.1+ but guard anyway)
291
305
  }
292
306
 
307
+ # 3b -- current culture: a Chinese Windows install reports zh-CN even when
308
+ # no locale environment variables are set.
309
+ try {
310
+ if ([System.Globalization.CultureInfo]::CurrentCulture.Name -match '^zh-(CN|Hans)') {
311
+ return $true
312
+ }
313
+ } catch {
314
+ # Culture lookup failed -- not fatal
315
+ }
316
+
293
317
  # 4 -- /etc/timezone (PowerShell on Linux/macOS)
294
318
  if (-not $script:OSWin -and (Test-Path '/etc/timezone')) {
295
319
  try {
@@ -345,6 +369,43 @@ function Get-DownloadUrls {
345
369
  return $urls
346
370
  }
347
371
 
372
+ <#
373
+ .SYNOPSIS
374
+ Check that a downloaded file really is a native executable.
375
+ Magic bytes are the cheapest integrity signal that needs no network and no
376
+ published checksum file: an HTML error page or a truncated body that happens
377
+ to be larger than the size threshold is rejected here.
378
+ #>
379
+ function Test-BinaryMagic {
380
+ param([string]$Path)
381
+
382
+ if (-not (Test-Path $Path)) { return $false }
383
+ # Read exactly four bytes through .NET: works on 5.1 and 7.x alike
384
+ # (Get-Content -Encoding Byte is gone in PowerShell 7).
385
+ $bytes = New-Object byte[] 4
386
+ $read = 0
387
+ try {
388
+ $stream = [System.IO.File]::OpenRead($Path)
389
+ try {
390
+ $read = $stream.Read($bytes, 0, 4)
391
+ } finally {
392
+ $stream.Dispose()
393
+ }
394
+ } catch {
395
+ return $false
396
+ }
397
+ if ($read -lt 4) { return $false }
398
+
399
+ $magic = ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
400
+
401
+ switch -Regex ($magic) {
402
+ '^7f454c46' { return $true } # ELF (linux glibc / musl)
403
+ '^4d5a' { return $true } # PE / MZ (windows)
404
+ '^(feedface|feedfacf|cefaedfe|cffaedfe|cafebabe)$' { return $true } # Mach-O, incl. fat
405
+ default { return $false }
406
+ }
407
+ }
408
+
348
409
  function Invoke-Download {
349
410
  param([string]$Url, [string]$OutFile, [string]$Label)
350
411
 
@@ -359,12 +420,16 @@ function Invoke-Download {
359
420
  try {
360
421
  $ProgressPreference = if ($Silent) { "SilentlyContinue" } else { "Continue" }
361
422
 
362
- # Use Invoke-WebRequest with progress bar
363
423
  Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
364
424
 
365
425
  if (Test-Path $OutFile) {
366
426
  $size = (Get-Item $OutFile).Length
367
427
  if ($size -gt 102400) { # > 100 KB minimum
428
+ if (-not (Test-BinaryMagic -Path $OutFile)) {
429
+ Write-WarnMsg "Downloaded file is not a native executable (bad magic bytes) -- refusing it"
430
+ Remove-Item $OutFile -Force -ErrorAction SilentlyContinue
431
+ return $false
432
+ }
368
433
  Write-Check "Downloaded $( [math]::Round($size / 1MB, 1) ) MB"
369
434
  return $true
370
435
  } else {
@@ -586,29 +651,39 @@ function Add-DirectoryToUserPath {
586
651
 
587
652
  $dirResolved = (Resolve-Path $Dir -ErrorAction SilentlyContinue).Path
588
653
  if (-not $dirResolved) { $dirResolved = $Dir }
654
+ # Trailing separators would defeat the duplicate check below.
655
+ $dirResolved = $dirResolved.TrimEnd('\', '/')
589
656
 
590
657
  # Read current user PATH
591
658
  $currentUserPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
592
659
  $paths = if ($currentUserPath) { $currentUserPath -split ";" | Where-Object { $_ } } else { @() }
593
660
 
594
- # Check if already present
595
- $normalized = $paths | ForEach-Object { $rp = Resolve-Path $_ -ErrorAction SilentlyContinue; if ($rp) { $rp.Path } else { $_ } }
596
- if ($normalized -contains $dirResolved) {
597
- Write-Check "Already in user PATH: $Dir"
661
+ # Check if already present. Compare normalized, case-insensitively: Windows
662
+ # paths differ in case, a relative entry has to be resolved first, and a
663
+ # trailing separator is not a different directory.
664
+ $normalized = $paths | ForEach-Object {
665
+ $rp = Resolve-Path $_ -ErrorAction SilentlyContinue
666
+ $p = if ($rp) { $rp.Path } else { $_ }
667
+ $p.TrimEnd('\', '/')
668
+ }
669
+ if ($normalized | Where-Object { $_.Equals($dirResolved, [System.StringComparison]::OrdinalIgnoreCase) }) {
670
+ Write-Check "Already in user PATH: $dirResolved"
598
671
  return
599
672
  }
600
673
 
601
674
  if ($DryRun) {
602
- Write-Check "[DRY-RUN] Would add to user PATH: $Dir"
675
+ Write-Check "[DRY-RUN] Would add to user PATH: $dirResolved"
603
676
  return
604
677
  }
605
678
 
606
- $newPath = if ($currentUserPath) { "$currentUserPath;$Dir" } else { $Dir }
679
+ # Append the resolved absolute path: `-InstallDir .\b4` used to write a
680
+ # relative entry into the persistent user PATH.
681
+ $newPath = if ($currentUserPath) { "$currentUserPath;$dirResolved" } else { $dirResolved }
607
682
  [System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::User)
608
- Write-Check "Added to user PATH: $Dir"
683
+ Write-Check "Added to user PATH: $dirResolved"
609
684
 
610
685
  # Also update current session
611
- $env:Path = "$env:Path;$Dir"
686
+ $env:Path = "$env:Path;$dirResolved"
612
687
  }
613
688
 
614
689
  # ----------------------------------------------
@@ -622,10 +697,16 @@ function Add-DirectoryToUserPath {
622
697
  #>
623
698
  function Get-BackendAction {
624
699
  param([string]$StatusOutput)
625
- if ($StatusOutput -match 'Installed bundle: not installed') {
700
+ # Only a status that positively reports an installed bundle means upgrade.
701
+ # An empty status (no CLI yet, or `status` itself failed) must not turn a
702
+ # fresh machine into an `upgrade`.
703
+ if ($StatusOutput -match 'Installed bundle:\s*not installed') {
626
704
  return "install"
627
705
  }
628
- return "upgrade"
706
+ if ($StatusOutput -match 'Installed bundle:') {
707
+ return "upgrade"
708
+ }
709
+ return "install"
629
710
  }
630
711
 
631
712
  <#
@@ -635,28 +716,34 @@ function Get-BackendAction {
635
716
  - no backend installed yet -> browser4-cli install
636
717
  - backend already present -> browser4-cli upgrade (to the latest)
637
718
  Passes -Version through as --tag so the backend matches the CLI version.
638
- Non-fatal: failures print guidance and leave the CLI usable.
719
+ Returns $true when the backend is in place (or was skipped on request) and
720
+ $false when the step failed -- the caller turns that into the exit code, so a
721
+ broken backend is visible to scripts even though the CLI stays usable.
639
722
  #>
640
723
  function Install-Backend {
641
724
  param([string]$CliPath, [string]$Tag)
642
725
 
643
726
  if ($SkipBackend) {
644
727
  Write-Step "Skipping backend install/upgrade (-SkipBackend)"
645
- return
728
+ return $true
646
729
  }
647
730
 
648
731
  Write-Step "Checking for an existing Browser4 backend..."
649
732
 
650
- $statusOutput = ""
651
- try {
652
- $statusOutput = (& $CliPath status 2>&1 | Out-String)
653
- } catch {
654
- # Binary missing or not runnable yet (e.g. --dry-run) -- fall through.
733
+ # -DryRun must not touch the installed CLI at all: ask what would happen
734
+ # instead of running `status`.
735
+ $action = "install"
736
+ if (-not $DryRun) {
655
737
  $statusOutput = ""
738
+ try {
739
+ $statusOutput = (& $CliPath status 2>&1 | Out-String)
740
+ } catch {
741
+ # Binary missing or not runnable -- treat as "no backend yet".
742
+ $statusOutput = ""
743
+ }
744
+ $action = Get-BackendAction -StatusOutput $statusOutput
656
745
  }
657
746
 
658
- $action = Get-BackendAction -StatusOutput $statusOutput
659
-
660
747
  $backendArgs = @($action)
661
748
  if ($Tag) { $backendArgs += @('--tag', $Tag) }
662
749
 
@@ -668,9 +755,10 @@ function Install-Backend {
668
755
 
669
756
  if ($DryRun) {
670
757
  Write-Check "[DRY-RUN] Would run: $CliPath $($backendArgs -join ' ')"
671
- return
758
+ return $true
672
759
  }
673
760
 
761
+ $backendOk = $false
674
762
  if ($Silent) {
675
763
  $backendOutput = ""
676
764
  try {
@@ -678,7 +766,8 @@ function Install-Backend {
678
766
  } catch {
679
767
  $backendOutput = $_.Exception.Message
680
768
  }
681
- if ($LASTEXITCODE -eq 0) {
769
+ $backendOk = ($LASTEXITCODE -eq 0)
770
+ if ($backendOk) {
682
771
  Write-Check "Backend $action succeeded ($($backendArgs -join ' '))."
683
772
  } else {
684
773
  Write-WarnMsg "Backend $action failed ($($backendArgs -join ' ')). Retry with: browser4-cli $($backendArgs -join ' ')"
@@ -686,12 +775,15 @@ function Install-Backend {
686
775
  }
687
776
  } else {
688
777
  & $CliPath @backendArgs
689
- if ($LASTEXITCODE -eq 0) {
778
+ $backendOk = ($LASTEXITCODE -eq 0)
779
+ if ($backendOk) {
690
780
  Write-Check "Backend $action succeeded ($($backendArgs -join ' '))."
691
781
  } else {
692
782
  Write-WarnMsg "Backend $action failed ($($backendArgs -join ' ')). Retry with: browser4-cli $($backendArgs -join ' ')"
693
783
  }
694
784
  }
785
+
786
+ return $backendOk
695
787
  }
696
788
 
697
789
  # ----------------------------------------------
@@ -704,6 +796,19 @@ function Main {
704
796
  Write-Summary "==========================================" -Color Cyan
705
797
  Write-Summary ""
706
798
 
799
+ # Validate -Version before it is interpolated into a download URL: a value
800
+ # like '../../evil' used to produce a valid-looking URL and also reached
801
+ # `--tag`. A bare semver gets the 'v' prefix, as in the Unix installer.
802
+ if ($Version) {
803
+ if ($Version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
804
+ throw "-Version must be a release tag such as v4.13.18 (got '$Version')"
805
+ }
806
+ if ($Version -match '^[0-9]+\.[0-9]+\.[0-9]+') {
807
+ Write-Step "Using release tag 'v$Version' (release tags are prefixed with 'v')"
808
+ $Version = "v$Version"
809
+ }
810
+ }
811
+
707
812
  # Auto-detect China mainland locale when no explicit source is given
708
813
  if (-not $Source) {
709
814
  $script:ChinaDetected = Test-ChinaLocale
@@ -723,7 +828,7 @@ function Main {
723
828
  Write-Step "Script dir: $ScriptDir"
724
829
  Write-Step "Platform key: $platformKey"
725
830
  Write-Step "Binary name: $binaryName"
726
- Write-Step "Default install: $(Get-DefaultInstallDir)"
831
+ Write-Step "Default install: $(if ($InstallDir) { $InstallDir } else { Get-DefaultInstallDir })"
727
832
  Write-Step "China locale: $script:ChinaDetected"
728
833
  Write-Step "Source override: $(if ($Source) { $Source } else { 'auto' })"
729
834
  Write-Step "OS: $(if ($script:OSWin) { 'Windows' } elseif ($script:OSMac) { 'macOS' } elseif ($script:OSLinux) { 'Linux' } else { 'Unknown' })"
@@ -810,18 +915,20 @@ function Main {
810
915
  }
811
916
 
812
917
  $downloaded = $false
918
+ # The temp file is always cleaned up, including on -DryRun (where the
919
+ # download never consumes it) and on the failure path.
813
920
  $tempFile = [System.IO.Path]::GetTempFileName()
814
921
 
815
- foreach ($entry in $urls) {
816
- if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
817
- $downloaded = $true
818
- break
922
+ try {
923
+ foreach ($entry in $urls) {
924
+ if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
925
+ $downloaded = $true
926
+ break
927
+ }
819
928
  }
820
- }
821
929
 
822
- if (-not $downloaded) {
823
- if (Test-Path $tempFile) { Remove-Item $tempFile -Force }
824
- throw @"
930
+ if (-not $downloaded) {
931
+ throw @"
825
932
  Could not download browser4-cli binary.
826
933
 
827
934
  Tried:
@@ -833,13 +940,16 @@ Please check:
833
940
  - For GitHub rate limits, set GITHUB_TOKEN environment variable
834
941
  - If you have a local copy, place it alongside this script and re-run
835
942
  "@
836
- }
943
+ }
837
944
 
838
- # Move from temp to install dir
839
- if (-not $DryRun) {
840
- Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
945
+ # Move from temp to install dir
946
+ if (-not $DryRun) {
947
+ Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
948
+ }
949
+ Write-Check "Installed: $binaryPath"
950
+ } finally {
951
+ if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue }
841
952
  }
842
- Write-Check "Installed: $binaryPath"
843
953
  }
844
954
 
845
955
  # On Unix, ensure executable bit
@@ -875,16 +985,28 @@ Please check:
875
985
  }
876
986
  }
877
987
 
878
- # Verify
988
+ # Verify. A binary that cannot report its version is a failed install --
989
+ # report it red and exit non-zero instead of printing a green success line.
879
990
  Write-Summary ""
991
+ $script:ExitCode = 0
880
992
  if (-not $DryRun) {
993
+ $versionOutput = ""
994
+ $versionOk = $false
881
995
  try {
882
- $versionOutput = & $binaryPath --version 2>&1
996
+ $versionOutput = (& $binaryPath --version 2>&1 | Out-String).Trim()
997
+ $versionOk = ($LASTEXITCODE -eq 0) -and ($versionOutput -match '\d')
998
+ } catch {
999
+ $versionOk = $false
1000
+ }
1001
+
1002
+ if ($versionOk) {
883
1003
  Write-Summary "[v] browser4-cli installed successfully" -Color Green
884
1004
  Write-Summary " Version: $versionOutput"
885
- } catch {
886
- Write-Summary "[v] Binary installed at: $binaryPath" -Color Green
887
- Write-WarnMsg "Could not verify --version (this is normal on first install)"
1005
+ } else {
1006
+ Write-Summary "[x] Installed $binaryPath but it cannot run:" -Color Red
1007
+ Write-Summary " $versionOutput"
1008
+ Write-WarnMsg "The binary may be corrupt or built for a different platform. Delete it and re-run the installer."
1009
+ $script:ExitCode = 1
888
1010
  }
889
1011
  } else {
890
1012
  Write-Summary "[DRY-RUN] Installation plan complete" -Color Yellow
@@ -893,8 +1015,12 @@ Please check:
893
1015
  # Install / upgrade the Browser4 backend (runtime bundle) using the CLI
894
1016
  # binary we just installed. Fresh machines get `browser4-cli install`;
895
1017
  # machines that already have a backend get `browser4-cli upgrade`.
1018
+ # A backend failure keeps the CLI usable but must be visible to scripts
1019
+ # and CI, so it is reflected in the exit code.
896
1020
  Write-Summary ""
897
- Install-Backend -CliPath $binaryPath -Tag $Version
1021
+ if ((Install-Backend -CliPath $binaryPath -Tag $Version) -eq $false) {
1022
+ $script:ExitCode = 1
1023
+ }
898
1024
 
899
1025
  Write-Summary ""
900
1026
  Write-Summary "Run 'browser4-cli --help' to get started." -Color Cyan
@@ -903,6 +1029,8 @@ Please check:
903
1029
  Write-Summary "If the command isn't found, restart your terminal or run:"
904
1030
  Write-Summary " `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User') + ';' + [System.Environment]::GetEnvironmentVariable('Path','Machine')"
905
1031
  }
1032
+
1033
+ exit $script:ExitCode
906
1034
  }
907
1035
 
908
1036
  Main
@@ -55,11 +55,41 @@ SCRIPT_DIR=""
55
55
  # ----------------------------------------------
56
56
 
57
57
  say() { if [[ "$SILENT" != true ]]; then echo -e "$*"; fi; }
58
+ # Informational note for helpers whose stdout is captured by the caller --
59
+ # anything these functions print on stdout ends up inside the captured value.
60
+ note() { if [[ "$SILENT" != true ]]; then echo -e " [i] $*" >&2; fi; }
58
61
  step() { say " -> $*"; }
59
62
  ok() { say " [v] $*"; }
60
63
  warn() { say " [!] $*" >&2; }
61
64
  die() { echo "ERROR: $*" >&2; exit 1; }
62
65
 
66
+ # Validate the value that must follow an option. A missing value and a value
67
+ # that is really the next option are both rejected: `--version --dry-run` used
68
+ # to install the literal tag "--dry-run" (and `--install-dir --silent` created
69
+ # a directory called "--silent"), which fails much later with a confusing 404.
70
+ # Runs in the current shell so `die` really stops the installer -- a `die`
71
+ # inside `$( ... )` only kills the subshell and the install would continue.
72
+ validate_value() {
73
+ local flag="$1" value="${2:-}"
74
+ if [[ -z "$value" ]]; then
75
+ die "$flag requires a value"
76
+ fi
77
+ if [[ "$value" == -* ]]; then
78
+ die "$flag requires a value (got '$value', which looks like another option)"
79
+ fi
80
+ }
81
+
82
+ # Release tags on GitHub are prefixed with "v" (v4.13.18). Accepting a bare
83
+ # "4.13.18" and fixing it up front is friendlier than a 404 from the CDN.
84
+ normalize_tag() {
85
+ local tag="$1"
86
+ if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
87
+ note "Using release tag 'v$tag' (release tags are prefixed with 'v')"
88
+ tag="v$tag"
89
+ fi
90
+ printf '%s' "$tag"
91
+ }
92
+
63
93
  color_cyan='\033[0;36m'
64
94
  color_green='\033[0;32m'
65
95
  color_yellow='\033[0;33m'
@@ -157,13 +187,12 @@ EOF
157
187
  while [[ $# -gt 0 ]]; do
158
188
  case "$1" in
159
189
  --version|-v)
160
- shift; [[ -z "${1:-}" ]] && die "--version requires a value"
161
- VERSION="$1"; shift ;;
190
+ shift; validate_value "--version" "${1:-}"; VERSION=$(normalize_tag "$1"); shift ;;
162
191
  --install-dir|-d)
163
- shift; [[ -z "${1:-}" ]] && die "--install-dir requires a value"
164
- INSTALL_DIR="$1"; shift ;;
192
+ shift; validate_value "--install-dir" "${1:-}"; INSTALL_DIR="$1"; shift ;;
165
193
  --source)
166
- shift; [[ -z "${1:-}" ]] && die "--source requires a value"
194
+ shift
195
+ validate_value "--source" "${1:-}"
167
196
  if [[ "$1" != "github" && "$1" != "oss" ]]; then
168
197
  die "--source must be 'github' or 'oss'"
169
198
  fi
@@ -316,7 +345,7 @@ get_default_install_dir() {
316
345
 
317
346
  check_commands() {
318
347
  local missing=()
319
- for cmd in curl mktemp stat awk grep ln mkdir chmod; do
348
+ for cmd in curl mktemp stat awk grep ln mkdir chmod od cp mv tail cat; do
320
349
  if ! command -v "$cmd" >/dev/null 2>&1; then
321
350
  missing+=("$cmd")
322
351
  fi
@@ -326,6 +355,21 @@ check_commands() {
326
355
  fi
327
356
  }
328
357
 
358
+ # A downloaded file must really be a native executable, not an HTML error page
359
+ # or a wrong-architecture body that happens to be large. Magic bytes are the
360
+ # cheapest check that needs no network and no published checksum file.
361
+ verify_binary_magic() {
362
+ local path="$1"
363
+ local magic
364
+ magic=$(od -An -tx1 -N4 "$path" 2>/dev/null | tr -d ' \n')
365
+ case "$magic" in
366
+ 7f454c46) return 0 ;; # ELF (linux glibc/musl)
367
+ 4d5a*) return 0 ;; # PE / MZ (windows)
368
+ feedface|feedfacf|cefaedfe|cffaedfe|cafebabe) return 0 ;; # Mach-O, incl. fat
369
+ *) return 1 ;;
370
+ esac
371
+ }
372
+
329
373
  get_download_urls() {
330
374
  local binary_name="$1"
331
375
  local version_tag="$2"
@@ -374,8 +418,15 @@ download_file() {
374
418
  curl_stderr=$(mktemp)
375
419
  local http_code curl_exit
376
420
 
421
+ # Only GitHub needs (and only GitHub may receive) the token: the same header
422
+ # must never be sent to the Aliyun OSS mirror.
423
+ local -a auth_args=()
424
+ if [[ -n "${GITHUB_TOKEN:-}" ]] && [[ "$url" == https://github.com/* ]]; then
425
+ auth_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
426
+ fi
427
+
377
428
  http_code=$(curl -sSfL -w "%{http_code}" -o "$dest" \
378
- ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
429
+ ${auth_args[@]+"${auth_args[@]}"} \
379
430
  "$url" 2>"$curl_stderr")
380
431
  curl_exit=$?
381
432
 
@@ -398,6 +449,11 @@ download_file() {
398
449
 
399
450
  # Sanity check: binary must be > 100 KB
400
451
  if [[ "$size" -gt 102400 ]]; then
452
+ if ! verify_binary_magic "$dest"; then
453
+ warn "Downloaded file is not a native executable (bad magic bytes) -- refusing it"
454
+ rm -f "$dest"
455
+ return 1
456
+ fi
401
457
  local size_mb
402
458
  size_mb=$(awk "BEGIN { printf \"%.1f\", $size / 1048576 }")
403
459
  ok "Downloaded ${size_mb} MB"
@@ -439,7 +495,11 @@ add_to_shell_rc() {
439
495
  rc_file="$HOME/.profile"
440
496
  fi
441
497
 
442
- if grep -qF "$dir" "$rc_file" 2>/dev/null; then
498
+ local path_line="export PATH=\"$dir:\$PATH\""
499
+
500
+ # Idempotency: only PATH assignment lines count, so an unrelated mention of the
501
+ # directory (a comment, another tool's variable) does not suppress the entry.
502
+ if grep -E '^[[:space:]]*(export[[:space:]]+)?PATH=' "$rc_file" 2>/dev/null | grep -qF "$dir"; then
443
503
  ok "PATH entry already in $rc_file"
444
504
  return
445
505
  fi
@@ -454,23 +514,25 @@ add_to_shell_rc() {
454
514
  fi
455
515
  fi
456
516
 
457
- # Use a lock file to prevent concurrent append races (best-effort).
517
+ # Serialize concurrent installs when the platform has flock (Linux). macOS
518
+ # ships without it, and waiting for a lock that can never be taken only made
519
+ # every install pause and print a bogus warning.
458
520
  local lock_file="${rc_file}.browser4-install.lock"
459
- local lock_fd=9
460
- # Wait up to 10 seconds for another install process to release the lock.
461
- local waited=0
462
- while ! (umask 0002 && command -v flock >/dev/null 2>&1 && flock -n 9 2>/dev/null); do
463
- if [[ $waited -ge 10 ]]; then
464
- warn "Could not acquire lock on ${rc_file} after 10s; appending anyway"
465
- break
466
- fi
467
- sleep 0.5
468
- waited=$((waited + 1))
469
- done 9>"$lock_file"
521
+ if command -v flock >/dev/null 2>&1; then
522
+ local waited=0
523
+ while ! flock -n 9 2>/dev/null; do
524
+ if [[ $waited -ge 20 ]]; then
525
+ warn "Could not acquire lock on ${rc_file} after 10s; appending anyway"
526
+ break
527
+ fi
528
+ sleep 0.5
529
+ waited=$((waited + 1))
530
+ done 9>"$lock_file"
531
+ fi
470
532
 
471
533
  {
472
534
  echo "# browser4-cli"
473
- echo "export PATH=\"$dir:\$PATH\""
535
+ echo "$path_line"
474
536
  } >> "$rc_file"
475
537
 
476
538
  # Clean up (flock auto-releases when fd 9 is closed)
@@ -511,7 +573,7 @@ create_symlinks() {
511
573
  local b4_exists=false
512
574
  local b4_is_ours=false
513
575
 
514
- # Check in install dir first — anything here is ours
576
+ # Check in install dir first - anything here is ours
515
577
  if [[ -e "$short_path" ]] || [[ -L "$short_path" ]]; then
516
578
  b4_exists=true
517
579
  b4_is_ours=true
@@ -620,7 +682,6 @@ install_backend() {
620
682
  # ----------------------------------------------
621
683
 
622
684
  main() {
623
- check_commands
624
685
  header
625
686
 
626
687
  # Locate ourselves on disk (only works when run as a file, not piped)
@@ -663,9 +724,9 @@ main() {
663
724
  step "Local binary: not found alongside script"
664
725
  fi
665
726
 
666
- # Check for already-installed binary
727
+ # Check for already-installed binary (honour an explicit --install-dir)
667
728
  local default_dir existing_path
668
- default_dir=$(get_default_install_dir)
729
+ default_dir="${INSTALL_DIR:-$(get_default_install_dir)}"
669
730
  existing_path="${default_dir}/${binary_name}"
670
731
  if [[ -f "$existing_path" ]]; then
671
732
  step "Already installed: $existing_path"
@@ -690,6 +751,10 @@ main() {
690
751
  step "Platform: $platform_key"
691
752
  step "Binary: $binary_name"
692
753
 
754
+ # Everything below downloads, unpacks or writes: check the tooling now, so
755
+ # `--locate` still works on a box that has none of it.
756
+ check_commands
757
+
693
758
  # Install directory
694
759
  if [[ -z "$INSTALL_DIR" ]]; then
695
760
  INSTALL_DIR=$(get_default_install_dir)
@@ -699,10 +764,15 @@ main() {
699
764
 
700
765
  # Ensure install directory exists
701
766
  if [[ ! -d "$INSTALL_DIR" ]]; then
702
- if [[ "$DRY_RUN" != true ]]; then
703
- mkdir -p "$INSTALL_DIR"
767
+ if [[ -e "$INSTALL_DIR" ]]; then
768
+ die "Install path exists but is not a directory: $INSTALL_DIR"
769
+ fi
770
+ if [[ "$DRY_RUN" == true ]]; then
771
+ step "[DRY-RUN] Would create directory: $INSTALL_DIR"
772
+ else
773
+ mkdir -p "$INSTALL_DIR" || die "Could not create install directory: $INSTALL_DIR"
774
+ step "Created directory: $INSTALL_DIR"
704
775
  fi
705
- step "Created directory: $INSTALL_DIR"
706
776
  fi
707
777
 
708
778
  local binary_path="${INSTALL_DIR}/${binary_name}"