browser4-cli 0.1.32 → 4.12.0-rc.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.
package/README.md CHANGED
@@ -79,7 +79,7 @@ browser4-cli htmlsnapshot get text "#main-content"
79
79
  browser4-cli htmlsnapshot query --sql @query.sql
80
80
  browser4-cli htmlsnapshot grep -i "error"
81
81
 
82
- # AI-powered extraction and summarization (requires LLM key — see LLM Configuration above)
82
+ # AI-powered extraction and summarization (requires LLM key — see LLM Configuration below)
83
83
  browser4-cli extract "product name, price, and rating as JSON"
84
84
  browser4-cli summarize "key points in 3 bullets"
85
85
 
@@ -234,11 +234,12 @@ All modes accept `--timeout <ms>` (default: 30000). Load states: `networkidle`,
234
234
 
235
235
  | Command | Description |
236
236
  |---|---|
237
- | `screenshot [ref]` | Take a screenshot. `--filename <path>`, `--full-page`. Optionally of a specific element. |
237
+ | `screenshot [ref]` | Take a screenshot. `--filename <path>`, `--full-page`, `--viewport <n>`. Optionally of a specific element. |
238
238
  | `pdf` | Save page as PDF. `--filename <path>`. |
239
239
 
240
240
  ```bash
241
241
  browser4-cli screenshot --full-page --filename page.png
242
+ browser4-cli screenshot --viewport 0 --filename top.png
242
243
  browser4-cli pdf --filename page.pdf
243
244
  ```
244
245
 
@@ -370,6 +371,30 @@ browser4-cli uninstall --dry-run
370
371
  browser4-cli uninstall -y
371
372
  ```
372
373
 
374
+ ### Skills
375
+
376
+ Manage bundled skill files embedded in the browser4-cli binary. Skill files are AI agent
377
+ instructions that always match the installed CLI version.
378
+
379
+ | Command | Description |
380
+ |---|---|
381
+ | `skills` | List all bundled skill names. Same as `skills list`. |
382
+ | `skills list` | List available bundled skills with file counts. |
383
+ | `skills get <name>` | Output a skill's SKILL.md content. `--full` includes references and templates. `--all` outputs every skill. |
384
+ | `skills path [name]` | Print the skills directory path. With a name, prints the path to that skill's subdirectory. |
385
+
386
+ Skill files are unpacked to the versioned installation directory during `browser4-cli install`.
387
+ Set `BROWSER4_SKILLS_DIR` to override the skills directory path.
388
+
389
+ ```bash
390
+ browser4-cli skills # List bundled skills
391
+ browser4-cli skills get browser4-cli # Get the CLI skill's main content
392
+ browser4-cli skills get browser4-cli --full # Include references and templates
393
+ browser4-cli skills get --all # Output every skill
394
+ browser4-cli skills path # Print skills root directory
395
+ browser4-cli skills path browser4-cli # Print path to a specific skill
396
+ ```
397
+
373
398
  ### Other
374
399
 
375
400
  | Command | Description |
@@ -600,6 +625,29 @@ browser4-cli htmlsnapshot inspect ".s-result-item" --depth 6 --max 20
600
625
 
601
626
  ---
602
627
 
628
+ ### LLM Configuration
629
+
630
+ AI-powered commands (`agent`, `extract`, `summarize`) and X-SQL `llm_*` functions
631
+ require an LLM API key. Configure one provider via environment variables:
632
+
633
+ | Provider | Environment Variables |
634
+ |---|---|
635
+ | DeepSeek | `DEEPSEEK_API_KEY` |
636
+ | OpenRouter | `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_NAME`, `OPENROUTER_BASE_URL` |
637
+ | Volcengine (ByteDance) | `VOLCENGINE_API_KEY`, `VOLCENGINE_MODEL_NAME`, `VOLCENGINE_BASE_URL` |
638
+ | OpenAI-compatible | `OPENAI_API_KEY`, `OPENAI_MODEL_NAME`, `OPENAI_BASE_URL` |
639
+ | Aliyun Qwen (DashScope) | `OPENAI_API_KEY`, `OPENAI_MODEL_NAME`, `OPENAI_BASE_URL` |
640
+
641
+ Example:
642
+
643
+ ```bash
644
+ export DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
645
+ ```
646
+
647
+ If no valid LLM key is configured, AI commands fail fast with a clear error at startup.
648
+
649
+ ---
650
+
603
651
  ### Agent
604
652
 
605
653
  The `agent` subcommands submit natural-language tasks that the Browser4 backend
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "browser4-cli",
3
- "version": "0.1.32",
3
+ "version": "4.12.0-rc.2",
4
4
  "description": "Browser automation CLI for AI agents",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "bin",
8
8
  "scripts",
9
- "skill-data",
10
9
  "skills"
11
10
  ],
12
11
  "bin": {
13
- "browser4-cli": "./bin/browser4-cli.js",
14
- "browser4": "./bin/browser4-cli.js"
12
+ "browser4-cli": "bin/browser4-cli.js",
13
+ "browser4": "bin/browser4-cli.js"
15
14
  },
16
15
  "scripts": {
17
16
  "version:sync": "node ../bin/version.mjs cli sync",
@@ -48,6 +48,10 @@
48
48
  By default the script looks for the platform binary in its own directory
49
49
  before downloading -- use this to force a fresh download.
50
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
+
51
55
  .PARAMETER Locate
52
56
  Print detection results (OS, architecture, script location, China locale)
53
57
  and exit without installing. Useful for diagnostics.
@@ -89,16 +93,17 @@
89
93
 
90
94
  [CmdletBinding()]
91
95
  param(
92
- [string]$Version = "",
93
- [string]$InstallDir = "",
94
- [ValidateSet("", "github", "oss")]
95
- [string]$Source = "",
96
- [bool]$AddToPath = $true,
97
- [switch]$Silent,
98
- [switch]$DryRun,
99
- [switch]$SkipIfInstalled,
100
- [switch]$SkipLocal,
101
- [switch]$Locate
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
102
107
  )
103
108
 
104
109
  $ErrorActionPreference = "Stop"
@@ -110,11 +115,11 @@ $ErrorActionPreference = "Stop"
110
115
  # $PSScriptRoot is the directory containing this script (PS 3+).
111
116
  # Falls back to $MyInvocation for edge cases (dot-sourced, PS 2).
112
117
  $ScriptDir = if ($PSScriptRoot) {
113
- $PSScriptRoot
118
+ $PSScriptRoot
114
119
  } elseif ($MyInvocation -and $MyInvocation.MyCommand.Path) {
115
- Split-Path -Parent $MyInvocation.MyCommand.Path
120
+ Split-Path -Parent $MyInvocation.MyCommand.Path
116
121
  } else {
117
- $null
122
+ $null
118
123
  }
119
124
 
120
125
  <#
@@ -123,18 +128,18 @@ $ScriptDir = if ($PSScriptRoot) {
123
128
  Returns the full path if found, $null otherwise.
124
129
  #>
125
130
  function Find-LocalBinary {
126
- param([string]$BinaryName)
131
+ param([string]$BinaryName)
127
132
 
128
- if (-not $ScriptDir) { return $null }
133
+ if (-not $ScriptDir) { return $null }
129
134
 
130
- $localPath = Join-Path $ScriptDir $BinaryName
131
- if (Test-Path $localPath -PathType Leaf) {
132
- $size = (Get-Item $localPath).Length
133
- if ($size -gt 102400) { # > 100 KB minimum
134
- return $localPath
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
+ }
135
141
  }
136
- }
137
- return $null
142
+ return $null
138
143
  }
139
144
 
140
145
  <#
@@ -143,16 +148,16 @@ function Find-LocalBinary {
143
148
  Returns $true if --version executes successfully, $false otherwise.
144
149
  #>
145
150
  function Test-LocalBinary {
146
- param([string]$Path)
151
+ param([string]$Path)
147
152
 
148
- if (-not $Path -or -not (Test-Path $Path)) { return $false }
153
+ if (-not $Path -or -not (Test-Path $Path)) { return $false }
149
154
 
150
- try {
151
- $null = & $Path --version 2>&1
152
- return ($LASTEXITCODE -eq 0)
153
- } catch {
154
- return $false
155
- }
155
+ try {
156
+ $null = & $Path --version 2>&1
157
+ return ($LASTEXITCODE -eq 0)
158
+ } catch {
159
+ return $false
160
+ }
156
161
  }
157
162
 
158
163
  # ----------------------------------------------
@@ -176,23 +181,23 @@ if ($PSVersionTable.PSVersion.Major -ge 6) {
176
181
  # ----------------------------------------------
177
182
 
178
183
  function Write-Summary {
179
- param([string]$Message, [string]$Color = "White")
180
- if (-not $Silent) { Write-Host $Message -ForegroundColor $Color }
184
+ param([string]$Message, [string]$Color = "White")
185
+ if (-not $Silent) { Write-Host $Message -ForegroundColor $Color }
181
186
  }
182
187
 
183
188
  function Write-Step {
184
- param([string]$Message)
185
- if (-not $Silent) { Write-Host " >> $Message" -ForegroundColor Gray }
189
+ param([string]$Message)
190
+ if (-not $Silent) { Write-Host " >> $Message" -ForegroundColor Gray }
186
191
  }
187
192
 
188
193
  function Write-Check {
189
- param([string]$Message)
190
- if (-not $Silent) { Write-Host " [v] $Message" -ForegroundColor Green }
194
+ param([string]$Message)
195
+ if (-not $Silent) { Write-Host " [v] $Message" -ForegroundColor Green }
191
196
  }
192
197
 
193
198
  function Write-WarnMsg {
194
- param([string]$Message)
195
- if (-not $Silent) { Write-Host " [!] $Message" -ForegroundColor Yellow }
199
+ param([string]$Message)
200
+ if (-not $Silent) { Write-Host " [!] $Message" -ForegroundColor Yellow }
196
201
  }
197
202
 
198
203
  # ----------------------------------------------
@@ -200,50 +205,50 @@ function Write-WarnMsg {
200
205
  # ----------------------------------------------
201
206
 
202
207
  function Get-PlatformKey {
203
- $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "arm64" } else { "x64" }
208
+ $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "arm64" } else { "x64" }
204
209
 
205
- if ($script:OSWin) {
206
- return "win32-$arch"
207
- }
208
- elseif ($script:OSMac) {
209
- return "darwin-$arch"
210
- }
211
- elseif ($script:OSLinux) {
212
- # Detect musl
213
- $isMusl = $false
214
- try {
215
- $lddOutput = ldd --version 2>&1
216
- if ($lddOutput -match "musl") { $isMusl = $true }
217
- } catch {
218
- if ((Test-Path "/lib/ld-musl-x86_64.so.1") -or (Test-Path "/lib/ld-musl-aarch64.so.1")) {
219
- $isMusl = $true
220
- }
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."
221
232
  }
222
- $libc = if ($isMusl) { "musl" } else { "" }
223
- if ($libc) { return "linux-$libc-$arch" } else { return "linux-$arch" }
224
- }
225
- else {
226
- throw "Unsupported OS. Browser4 CLI supports Windows, macOS, and Linux."
227
- }
228
233
  }
229
234
 
230
235
  function Get-BinaryName {
231
- param([string]$PlatformKey)
232
- return "browser4-cli-$PlatformKey" + $(if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" })
236
+ param([string]$PlatformKey)
237
+ return "browser4-cli-$PlatformKey" + $(if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" })
233
238
  }
234
239
 
235
240
  function Get-DefaultInstallDir {
236
- if ($script:OSWin) {
237
- return Join-Path $env:LOCALAPPDATA "Programs\browser4-cli"
238
- }
239
- elseif ($script:OSLinux -or $script:OSMac) {
240
- # Prefer ~/.local/bin for user installs
241
- if (Test-Path "$env:HOME/.local/bin") {
242
- return "$env:HOME/.local/bin"
241
+ if ($script:OSWin) {
242
+ return Join-Path $env:LOCALAPPDATA "Programs\browser4-cli"
243
243
  }
244
- return "$env:HOME/.local/bin"
245
- }
246
- throw "Unsupported OS"
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"
247
252
  }
248
253
 
249
254
  # ----------------------------------------------
@@ -256,41 +261,41 @@ function Get-DefaultInstallDir {
256
261
  Uses only local env vars and .NET APIs -- no network calls.
257
262
  #>
258
263
  function Test-ChinaLocale {
259
- # 1 -- Locale env vars
260
- $lang = $env:LC_ALL, $env:LANG, $env:LC_CTYPE, $env:LC_MESSAGES | Where-Object { $_ } | Select-Object -First 1
261
- if ($lang -and ($lang -match '^zh_CN' -or $lang -match '^zh-CN' -or $lang -match '^Chinese \(Simplified\)_China')) {
262
- return $true
263
- }
264
-
265
- # 2 -- TZ env var
266
- $tzEnv = $env:TZ
267
- if ($tzEnv -and ($tzEnv -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$')) {
268
- return $true
269
- }
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
+ }
270
269
 
271
- # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+)
272
- try {
273
- $tzId = [System.TimeZoneInfo]::Local.Id
274
- if ($tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
275
- return $true
270
+ # 2 -- TZ env var
271
+ $tzEnv = $env:TZ
272
+ if ($tzEnv -and ($tzEnv -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$')) {
273
+ return $true
276
274
  }
277
- } catch {
278
- # TimeZoneInfo not available (unlikely on PS 5.1+ but guard anyway)
279
- }
280
275
 
281
- # 4 -- /etc/timezone (PowerShell on Linux/macOS)
282
- if (-not $script:OSWin -and (Test-Path '/etc/timezone')) {
276
+ # 3 -- .NET TimeZoneInfo (works on Windows and Unix PowerShell 7+)
283
277
  try {
284
- $tz = Get-Content '/etc/timezone' -Raw -ErrorAction Stop
285
- if ($tz -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
286
- return $true
287
- }
278
+ $tzId = [System.TimeZoneInfo]::Local.Id
279
+ if ($tzId -match '^Asia/(Shanghai|Chongqing|Urumqi|Harbin)$') {
280
+ return $true
281
+ }
288
282
  } catch {
289
- # Permission or read error -- skip
283
+ # TimeZoneInfo not available (unlikely on PS 5.1+ but guard anyway)
290
284
  }
291
- }
292
285
 
293
- return $false
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
294
299
  }
295
300
 
296
301
  # ----------------------------------------------
@@ -302,71 +307,71 @@ $OSS_BASE = "https://browser4.oss-cn-beijing.aliyuncs.com"
302
307
  $script:ChinaDetected = $false
303
308
 
304
309
  function Get-DownloadUrls {
305
- param([string]$BinaryName, [string]$VersionTag)
310
+ param([string]$BinaryName, [string]$VersionTag)
306
311
 
307
- $urls = @()
312
+ $urls = @()
308
313
 
309
- $ghBase = "https://github.com/$GITHUB_REPO/releases/download"
310
- if ($VersionTag) {
311
- $ghUrl = "$ghBase/$VersionTag/$BinaryName"
312
- $ossUrl = "$OSS_BASE/releases/download/$VersionTag/$BinaryName"
313
- } else {
314
- # Use 'latest' redirect for GitHub, 'latest' symlink for OSS
315
- $ghUrl = "https://github.com/$GITHUB_REPO/releases/latest/download/$BinaryName"
316
- $ossUrl = "$OSS_BASE/releases/download/latest/$BinaryName"
317
- }
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
+ }
318
323
 
319
- if ($Source -eq "github") {
320
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
321
- } elseif ($Source -eq "oss") {
322
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
323
- } else {
324
- if ($script:ChinaDetected) {
325
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
326
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
324
+ if ($Source -eq "github") {
325
+ $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
326
+ } elseif ($Source -eq "oss") {
327
+ $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
327
328
  } else {
328
- $urls += @{ Url = $ghUrl; Label = "GitHub Releases" }
329
- $urls += @{ Url = $ossUrl; Label = "Aliyun OSS" }
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
+ }
330
336
  }
331
- }
332
337
 
333
- return $urls
338
+ return $urls
334
339
  }
335
340
 
336
341
  function Invoke-Download {
337
- param([string]$Url, [string]$OutFile, [string]$Label)
338
-
339
- Write-Step "Trying $Label..."
340
- Write-Step "URL: $Url"
341
-
342
- if ($DryRun) {
343
- Write-Check "[DRY-RUN] Would download to: $OutFile"
344
- return $true
345
- }
346
-
347
- try {
348
- $ProgressPreference = if ($Silent) { "SilentlyContinue" } else { "Continue" }
342
+ param([string]$Url, [string]$OutFile, [string]$Label)
349
343
 
350
- # Use Invoke-WebRequest with progress bar
351
- Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
344
+ Write-Step "Trying $Label..."
345
+ Write-Step "URL: $Url"
352
346
 
353
- if (Test-Path $OutFile) {
354
- $size = (Get-Item $OutFile).Length
355
- if ($size -gt 102400) { # > 100 KB minimum
356
- Write-Check "Downloaded $( [math]::Round($size / 1MB, 1) ) MB"
347
+ if ($DryRun) {
348
+ Write-Check "[DRY-RUN] Would download to: $OutFile"
357
349
  return $true
358
- } else {
359
- Write-WarnMsg "Downloaded file too small ($size bytes) -- may be an error page"
360
- Remove-Item $OutFile -Force -ErrorAction SilentlyContinue
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)"
361
373
  return $false
362
- }
363
374
  }
364
- Write-WarnMsg "Download appeared to succeed but file not found"
365
- return $false
366
- } catch {
367
- Write-WarnMsg "Failed: $($_.Exception.Message)"
368
- return $false
369
- }
370
375
  }
371
376
 
372
377
  # ----------------------------------------------
@@ -374,129 +379,96 @@ function Invoke-Download {
374
379
  # ----------------------------------------------
375
380
 
376
381
  function New-PlatformLink {
377
- param([string]$LinkPath, [string]$TargetName, [string]$DisplayName)
378
-
379
- # Try symbolic link first (works on Unix; on Windows needs Admin or Developer Mode)
380
- try {
381
- New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetName -Force -ErrorAction Stop | Out-Null
382
- Write-Check "Created symlink: $DisplayName -> $TargetName"
383
- return $true
384
- } catch {
385
- # Symbolic link failed -- try hard link on Windows
386
- }
382
+ param([string]$LinkPath, [string]$TargetName, [string]$DisplayName)
387
383
 
388
- # Try hard link (Windows, same volume)
389
- if ($script:OSWin) {
384
+ # Try symbolic link first (works on Unix; on Windows needs Admin or Developer Mode)
390
385
  try {
391
- $targetPath = Join-Path (Split-Path $LinkPath -Parent) $TargetName
392
- New-Item -ItemType HardLink -Path $LinkPath -Target $targetPath -Force -ErrorAction Stop | Out-Null
393
- Write-Check "Created hard link: $DisplayName -> $TargetName"
394
- return $true
386
+ New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetName -Force -ErrorAction Stop | Out-Null
387
+ Write-Check "Created symlink: $DisplayName -> $TargetName"
388
+ return $true
395
389
  } catch {
396
- # Hard link also failed -- create a .cmd wrapper as last resort
390
+ # Symbolic link failed -- try hard link on Windows
397
391
  }
398
392
 
399
- # Last resort: .cmd wrapper that forwards all arguments
400
- try {
401
- $wrapperContent = '@"%~dp0' + $TargetName + '" %*'
402
- Set-Content -Path ($LinkPath -replace '\.exe$', '.cmd') -Value $wrapperContent -Force -ErrorAction Stop
403
- Write-Check "Created wrapper: " + (Split-Path ($LinkPath -replace '\.exe$', '.cmd') -Leaf) + " -> $TargetName"
404
- return $true
405
- } catch {
406
- Write-WarnMsg "Could not create link for $DisplayName (may need admin privileges)"
407
- return $false
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
+ }
408
414
  }
409
- }
410
415
 
411
- Write-WarnMsg "Could not create link for $DisplayName"
412
- return $false
416
+ Write-WarnMsg "Could not create link for $DisplayName"
417
+ return $false
413
418
  }
414
419
 
415
420
  function New-Symlinks {
416
- param([string]$BinaryName, [string]$InstallDir, [string]$PlatformKey)
417
-
418
- $ext = if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" }
421
+ param([string]$BinaryName, [string]$InstallDir, [string]$PlatformKey)
419
422
 
420
- # 1) Always: browser4-cli -> browser4-cli-<platform>
421
- $linkName = "browser4-cli$ext"
422
- $linkPath = Join-Path $InstallDir $linkName
423
+ $ext = if ($PlatformKey.StartsWith("win32")) { ".exe" } else { "" }
423
424
 
424
- if ($DryRun) {
425
- Write-Step "[DRY-RUN] Would create link: $linkName -> $BinaryName"
426
- } else {
427
- New-PlatformLink -LinkPath $linkPath -TargetName $BinaryName -DisplayName $linkName
428
- }
425
+ # 1) Always: browser4-cli -> browser4-cli-<platform>
426
+ $linkName = "browser4-cli$ext"
427
+ $linkPath = Join-Path $InstallDir $linkName
429
428
 
430
- # 2) Only if no conflict: b4 -> browser4-cli-<platform>
431
- $shortName = "b4$ext"
432
- $shortPath = Join-Path $InstallDir $shortName
433
-
434
- # Check if b4 is already on PATH (conflict with another tool)
435
- $existingCmd = Get-Command b4 -ErrorAction SilentlyContinue
436
- if ($existingCmd) {
437
- Write-WarnMsg "Skipping short link '$shortName': 'b4' already found on PATH ($($existingCmd.Source))"
438
- return
439
- }
440
-
441
- # Check if b4 already exists in the install directory
442
- if (Test-Path $shortPath) {
443
- Write-WarnMsg "Skipping short link '$shortName': already exists in $InstallDir"
444
- return
445
- }
446
-
447
- # Also check b4.cmd if on Windows (wrapper fallback)
448
- if ($script:OSWin) {
449
- $shortCmdPath = Join-Path $InstallDir "b4.cmd"
450
- if (Test-Path $shortCmdPath) {
451
- Write-WarnMsg "Skipping short link '$shortName': 'b4.cmd' already exists in $InstallDir"
452
- return
429
+ if ($DryRun) {
430
+ Write-Step "[DRY-RUN] Would create link: $linkName -> $BinaryName"
431
+ } else {
432
+ New-PlatformLink -LinkPath $linkPath -TargetName $BinaryName -DisplayName $linkName
453
433
  }
454
- }
455
-
456
- if ($DryRun) {
457
- Write-Step "[DRY-RUN] Would create link: $shortName -> $BinaryName"
458
- } else {
459
- New-PlatformLink -LinkPath $shortPath -TargetName $BinaryName -DisplayName $shortName
460
- }
461
- }
462
-
463
- # ----------------------------------------------
464
- # PATH management
465
- # ----------------------------------------------
466
-
467
- function Add-DirectoryToUserPath {
468
- param([string]$Dir)
469
434
 
470
- $dirResolved = (Resolve-Path $Dir -ErrorAction SilentlyContinue).Path
471
- if (-not $dirResolved) { $dirResolved = $Dir }
435
+ # 2) Only if no conflict: b4 -> browser4-cli-<platform>
436
+ $shortName = "b4$ext"
437
+ $shortPath = Join-Path $InstallDir $shortName
472
438
 
473
- # Read current user PATH
474
- $currentUserPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
475
- $paths = if ($currentUserPath) { $currentUserPath -split ";" | Where-Object { $_ } } else { @() }
476
-
477
- # Check if already present
478
- $normalized = $paths | ForEach-Object { $rp = Resolve-Path $_ -ErrorAction SilentlyContinue; if ($rp) { $rp.Path } else { $_ } }
479
- if ($normalized -contains $dirResolved) {
480
- Write-Check "Already in user PATH: $Dir"
481
- return
482
- }
439
+ # Check if b4 is already on PATH (conflict with another tool)
440
+ $existingCmd = Get-Command b4 -ErrorAction SilentlyContinue
441
+ if ($existingCmd) {
442
+ Write-WarnMsg "Skipping short link '$shortName': 'b4' already found on PATH ($($existingCmd.Source))"
443
+ return
444
+ }
483
445
 
484
- if ($DryRun) {
485
- Write-Check "[DRY-RUN] Would add to user PATH: $Dir"
486
- return
487
- }
446
+ # Check if b4 already exists in the install directory
447
+ if (Test-Path $shortPath) {
448
+ Write-WarnMsg "Skipping short link '$shortName': already exists in $InstallDir"
449
+ return
450
+ }
488
451
 
489
- $newPath = if ($currentUserPath) { "$currentUserPath;$Dir" } else { $Dir }
490
- [System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::User)
491
- Write-Check "Added to user PATH: $Dir"
452
+ # Also check b4.cmd if on Windows (wrapper fallback)
453
+ if ($script:OSWin) {
454
+ $shortCmdPath = Join-Path $InstallDir "b4.cmd"
455
+ if (Test-Path $shortCmdPath) {
456
+ Write-WarnMsg "Skipping short link '$shortName': 'b4.cmd' already exists in $InstallDir"
457
+ return
458
+ }
459
+ }
492
460
 
493
- # Also update current session
494
- $env:Path = "$env:Path;$Dir"
461
+ if ($DryRun) {
462
+ Write-Step "[DRY-RUN] Would create link: $shortName -> $BinaryName"
463
+ } else {
464
+ New-PlatformLink -LinkPath $shortPath -TargetName $BinaryName -DisplayName $shortName
465
+ }
495
466
  }
496
467
 
497
468
  # ----------------------------------------------
498
469
  # Helper: replace a binary that may be locked (i.e. currently running)
499
470
  # ----------------------------------------------
471
+
500
472
  function Set-BinaryFile {
501
473
  param(
502
474
  [Parameter(Mandatory=$true)] [string]$TargetPath,
@@ -515,7 +487,7 @@ function Set-BinaryFile {
515
487
  Remove-Item $TargetPath -Force -ErrorAction Stop
516
488
  } catch {
517
489
  # The binary is locked (likely the currently-running process).
518
- # On Windows we can rename a running executable move the old binary
490
+ # On Windows we can rename a running executable -- move the old binary
519
491
  # out of the way, place the new one alongside, and clean up the old one
520
492
  # on the next install/upgrade.
521
493
  try {
@@ -523,7 +495,7 @@ function Set-BinaryFile {
523
495
  Write-WarnMsg "Existing binary is locked (it may be running)."
524
496
  Write-WarnMsg "The old copy will be cleaned up on the next install or upgrade."
525
497
  } catch {
526
- throw "Cannot replace '$TargetPath' it is locked and cannot be renamed. Close all browser4-cli processes and try again."
498
+ throw "Cannot replace '$TargetPath' -- it is locked and cannot be renamed. Close all browser4-cli processes and try again."
527
499
  }
528
500
  }
529
501
  }
@@ -535,133 +507,169 @@ function Set-BinaryFile {
535
507
  Copy-Item $SourcePath $TargetPath -Force -ErrorAction Stop
536
508
  }
537
509
  }
510
+
538
511
  # ----------------------------------------------
539
- # Main
512
+ # PATH management
540
513
  # ----------------------------------------------
541
514
 
542
- function Main {
543
- Write-Summary "==========================================" -Color Cyan
544
- Write-Summary " browser4-cli Installer" -Color Cyan
545
- Write-Summary "==========================================" -Color Cyan
546
- Write-Summary ""
515
+ function Add-DirectoryToUserPath {
516
+ param([string]$Dir)
547
517
 
548
- # Auto-detect China mainland locale when no explicit source is given
549
- if (-not $Source) {
550
- $script:ChinaDetected = Test-ChinaLocale
551
- if ($script:ChinaDetected) {
552
- Write-Step "China mainland locale detected: preferring Aliyun OSS mirror."
553
- }
554
- }
518
+ $dirResolved = (Resolve-Path $Dir -ErrorAction SilentlyContinue).Path
519
+ if (-not $dirResolved) { $dirResolved = $Dir }
555
520
 
556
- # Detect platform
557
- $platformKey = Get-PlatformKey
558
- $binaryName = Get-BinaryName -PlatformKey $platformKey
521
+ # Read current user PATH
522
+ $currentUserPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
523
+ $paths = if ($currentUserPath) { $currentUserPath -split ";" | Where-Object { $_ } } else { @() }
559
524
 
560
- # -- Locate mode: print diagnostics and exit --
561
- if ($Locate) {
562
- Write-Summary "--- Locate / diagnostics ---" -Color Cyan
563
- Write-Summary ""
564
- Write-Step "Script dir: $ScriptDir"
565
- Write-Step "Platform key: $platformKey"
566
- Write-Step "Binary name: $binaryName"
567
- Write-Step "Default install: $(Get-DefaultInstallDir)"
568
- Write-Step "China locale: $script:ChinaDetected"
569
- Write-Step "Source override: $(if ($Source) { $Source } else { 'auto' })"
570
- Write-Step "OS: $(if ($script:OSWin) { 'Windows' } elseif ($script:OSMac) { 'macOS' } elseif ($script:OSLinux) { 'Linux' } else { 'Unknown' })"
571
-
572
- # Check for local binary
573
- $localPath = Find-LocalBinary -BinaryName $binaryName
574
- if ($localPath) {
575
- $localOk = Test-LocalBinary -Path $localPath
576
- Write-Step "Local binary: $localPath $(if ($localOk) { '(valid)' } else { '(present but --version failed)' })"
577
- } else {
578
- Write-Step "Local binary: not found alongside script"
525
+ # Check if already present
526
+ $normalized = $paths | ForEach-Object { $rp = Resolve-Path $_ -ErrorAction SilentlyContinue; if ($rp) { $rp.Path } else { $_ } }
527
+ if ($normalized -contains $dirResolved) {
528
+ Write-Check "Already in user PATH: $Dir"
529
+ return
579
530
  }
580
531
 
581
- # Check for already-installed binary
582
- $defaultDir = Get-DefaultInstallDir
583
- $existingPath = Join-Path $defaultDir $binaryName
584
- if (Test-Path $existingPath) {
585
- Write-Step "Already installed: $existingPath"
586
- } else {
587
- Write-Step "Already installed: not found at $defaultDir"
532
+ if ($DryRun) {
533
+ Write-Check "[DRY-RUN] Would add to user PATH: $Dir"
534
+ return
588
535
  }
589
536
 
590
- # Show download URLs that would be tried
591
- $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
592
- Write-Summary ""
593
- Write-Step "Download order:"
594
- foreach ($entry in $urls) {
595
- Write-Step " $($entry.Label): $($entry.Url)"
596
- }
537
+ $newPath = if ($currentUserPath) { "$currentUserPath;$Dir" } else { $Dir }
538
+ [System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::User)
539
+ Write-Check "Added to user PATH: $Dir"
597
540
 
598
- Write-Summary ""
599
- return
600
- }
541
+ # Also update current session
542
+ $env:Path = "$env:Path;$Dir"
543
+ }
601
544
 
602
- Write-Step "Platform: $platformKey"
603
- Write-Step "Binary: $binaryName"
545
+ # ----------------------------------------------
546
+ # Main
547
+ # ----------------------------------------------
604
548
 
605
- # Determine install directory
606
- $installDir = if ($InstallDir) { $InstallDir } else { Get-DefaultInstallDir }
607
- Write-Step "Install: $installDir"
608
- Write-Summary ""
549
+ function Main {
550
+ Write-Summary "==========================================" -Color Cyan
551
+ Write-Summary " browser4-cli Installer" -Color Cyan
552
+ Write-Summary "==========================================" -Color Cyan
553
+ Write-Summary ""
609
554
 
610
- if (-not (Test-Path $installDir)) {
611
- if (-not $DryRun) {
612
- New-Item -ItemType Directory -Path $installDir -Force | Out-Null
555
+ # Auto-detect China mainland locale when no explicit source is given
556
+ if (-not $Source) {
557
+ $script:ChinaDetected = Test-ChinaLocale
558
+ if ($script:ChinaDetected) {
559
+ Write-Step "China mainland locale detected: preferring Aliyun OSS mirror."
560
+ }
613
561
  }
614
- Write-Step "Created directory: $installDir"
615
- }
616
562
 
617
- $binaryPath = Join-Path $installDir $binaryName
618
-
619
- # -- Local binary discovery (bundled/sideload) --
620
- $useLocalBinary = $false
621
- if (-not $SkipLocal) {
622
- $localBinaryPath = Find-LocalBinary -BinaryName $binaryName
623
- if ($localBinaryPath) {
624
- Write-Step "Found local binary alongside script: $(Split-Path $localBinaryPath -Leaf)"
625
- if (Test-LocalBinary -Path $localBinaryPath) {
626
- Write-Check "Local binary verified (--version OK)"
627
- $useLocalBinary = $true
628
- } else {
629
- Write-WarnMsg "Local binary found but --version check failed -- will download instead"
630
- }
563
+ # Detect platform
564
+ $platformKey = Get-PlatformKey
565
+ $binaryName = Get-BinaryName -PlatformKey $platformKey
566
+
567
+ # -- Locate mode: print diagnostics and exit --
568
+ if ($Locate) {
569
+ Write-Summary "--- Locate / diagnostics ---" -Color Cyan
570
+ Write-Summary ""
571
+ Write-Step "Script dir: $ScriptDir"
572
+ Write-Step "Platform key: $platformKey"
573
+ Write-Step "Binary name: $binaryName"
574
+ Write-Step "Default install: $(Get-DefaultInstallDir)"
575
+ Write-Step "China locale: $script:ChinaDetected"
576
+ Write-Step "Source override: $(if ($Source) { $Source } else { 'auto' })"
577
+ Write-Step "OS: $(if ($script:OSWin) { 'Windows' } elseif ($script:OSMac) { 'macOS' } elseif ($script:OSLinux) { 'Linux' } else { 'Unknown' })"
578
+
579
+ # Check for local binary
580
+ $localPath = Find-LocalBinary -BinaryName $binaryName
581
+ if ($localPath) {
582
+ $localOk = Test-LocalBinary -Path $localPath
583
+ Write-Step "Local binary: $localPath $(if ($localOk) { '(valid)' } else { '(present but --version failed)' })"
584
+ } else {
585
+ Write-Step "Local binary: not found alongside script"
586
+ }
587
+
588
+ # Check for already-installed binary
589
+ $defaultDir = Get-DefaultInstallDir
590
+ $existingPath = Join-Path $defaultDir $binaryName
591
+ if (Test-Path $existingPath) {
592
+ Write-Step "Already installed: $existingPath"
593
+ } else {
594
+ Write-Step "Already installed: not found at $defaultDir"
595
+ }
596
+
597
+ # Show download URLs that would be tried
598
+ $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
599
+ Write-Summary ""
600
+ Write-Step "Download order:"
601
+ foreach ($entry in $urls) {
602
+ Write-Step " $($entry.Label): $($entry.Url)"
603
+ }
604
+
605
+ Write-Summary ""
606
+ return
631
607
  }
632
- } elseif ($SkipLocal) {
633
- Write-Step "Skipping local binary check (-SkipLocal)"
634
- }
635
608
 
636
- # Skip download only when --skip-if-installed is set and binary already exists
637
- if ((Test-Path $binaryPath) -and (-not $Version) -and $SkipIfInstalled -and (-not $useLocalBinary)) {
638
- Write-Check "Binary already installed: $binaryPath"
639
- } elseif ($useLocalBinary) {
640
- # Copy local binary to install dir
641
- if (-not $DryRun) {
642
- Set-BinaryFile -TargetPath $binaryPath -SourcePath $localBinaryPath
643
- }
644
- Write-Check "Installed (local): $binaryPath"
645
- } else {
646
- # Build download URLs
647
- $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
648
- if (-not $urls -or $urls.Count -eq 0) {
649
- throw "No download URLs configured"
650
- }
609
+ Write-Step "Platform: $platformKey"
610
+ Write-Step "Binary: $binaryName"
651
611
 
652
- $downloaded = $false
653
- $tempFile = [System.IO.Path]::GetTempFileName()
612
+ # Determine install directory
613
+ $installDir = if ($InstallDir) { $InstallDir } else { Get-DefaultInstallDir }
614
+ Write-Step "Install: $installDir"
615
+ Write-Summary ""
654
616
 
655
- foreach ($entry in $urls) {
656
- if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
657
- $downloaded = $true
658
- break
659
- }
617
+ if (-not (Test-Path $installDir)) {
618
+ if (-not $DryRun) {
619
+ New-Item -ItemType Directory -Path $installDir -Force | Out-Null
620
+ }
621
+ Write-Step "Created directory: $installDir"
660
622
  }
661
623
 
662
- if (-not $downloaded) {
663
- if (Test-Path $tempFile) { Remove-Item $tempFile -Force }
664
- throw @"
624
+ $binaryPath = Join-Path $installDir $binaryName
625
+
626
+ # -- Local binary discovery (bundled/sideload) --
627
+ $useLocalBinary = $false
628
+ if (-not $SkipLocal) {
629
+ $localBinaryPath = Find-LocalBinary -BinaryName $binaryName
630
+ if ($localBinaryPath) {
631
+ Write-Step "Found local binary alongside script: $(Split-Path $localBinaryPath -Leaf)"
632
+ if (Test-LocalBinary -Path $localBinaryPath) {
633
+ Write-Check "Local binary verified (--version OK)"
634
+ $useLocalBinary = $true
635
+ } else {
636
+ Write-WarnMsg "Local binary found but --version check failed -- will download instead"
637
+ }
638
+ }
639
+ } elseif ($SkipLocal) {
640
+ Write-Step "Skipping local binary check (-SkipLocal)"
641
+ }
642
+
643
+ # Skip download only when --skip-if-installed is set and binary already exists,
644
+ # unless --force overrides it.
645
+ if ((Test-Path $binaryPath) -and (-not $Version) -and $SkipIfInstalled -and (-not $Force) -and (-not $useLocalBinary)) {
646
+ Write-Check "Binary already installed: $binaryPath"
647
+ } elseif ($useLocalBinary) {
648
+ # Copy local binary to install dir
649
+ if (-not $DryRun) {
650
+ Set-BinaryFile -TargetPath $binaryPath -SourcePath $localBinaryPath
651
+ }
652
+ Write-Check "Installed (local): $binaryPath"
653
+ } else {
654
+ # Build download URLs
655
+ $urls = Get-DownloadUrls -BinaryName $binaryName -VersionTag $Version
656
+ if (-not $urls -or $urls.Count -eq 0) {
657
+ throw "No download URLs configured"
658
+ }
659
+
660
+ $downloaded = $false
661
+ $tempFile = [System.IO.Path]::GetTempFileName()
662
+
663
+ foreach ($entry in $urls) {
664
+ if (Invoke-Download -Url $entry.Url -OutFile $tempFile -Label $entry.Label) {
665
+ $downloaded = $true
666
+ break
667
+ }
668
+ }
669
+
670
+ if (-not $downloaded) {
671
+ if (Test-Path $tempFile) { Remove-Item $tempFile -Force }
672
+ throw @"
665
673
  Could not download browser4-cli binary.
666
674
 
667
675
  Tried:
@@ -673,70 +681,70 @@ Please check:
673
681
  - For GitHub rate limits, set GITHUB_TOKEN environment variable
674
682
  - If you have a local copy, place it alongside this script and re-run
675
683
  "@
676
- }
684
+ }
677
685
 
678
- # Move from temp to install dir
679
- if (-not $DryRun) {
680
- Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
686
+ # Move from temp to install dir
687
+ if (-not $DryRun) {
688
+ Set-BinaryFile -TargetPath $binaryPath -SourcePath $tempFile -Move
689
+ }
690
+ Write-Check "Installed: $binaryPath"
681
691
  }
682
- Write-Check "Installed: $binaryPath"
683
- }
684
692
 
685
- # On Unix, ensure executable bit
686
- if (-not $script:OSWin) {
687
- if (-not $DryRun) {
688
- try { chmod +x $binaryPath 2>$null } catch { }
693
+ # On Unix, ensure executable bit
694
+ if (-not $script:OSWin) {
695
+ if (-not $DryRun) {
696
+ try { chmod +x $binaryPath 2>$null } catch { }
697
+ }
689
698
  }
690
- }
691
699
 
692
- # Create symlinks (browser4-cli -> platform binary, b4 if no conflict)
693
- Write-Summary ""
694
- New-Symlinks -BinaryName $binaryName -InstallDir $installDir -PlatformKey $platformKey
695
-
696
- # Add to PATH
697
- if ($AddToPath -and $script:OSWin) {
700
+ # Create symlinks (browser4-cli -> platform binary, b4 if no conflict)
698
701
  Write-Summary ""
699
- Add-DirectoryToUserPath -Dir $installDir
700
- } elseif ($AddToPath -and -not $script:OSWin) {
702
+ New-Symlinks -BinaryName $binaryName -InstallDir $installDir -PlatformKey $platformKey
703
+
704
+ # Add to PATH
705
+ if ($AddToPath -and $script:OSWin) {
706
+ Write-Summary ""
707
+ Add-DirectoryToUserPath -Dir $installDir
708
+ } elseif ($AddToPath -and -not $script:OSWin) {
709
+ Write-Summary ""
710
+ $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" }
711
+ $pathLine = "export PATH=""$installDir`:`$PATH"""
712
+ if (-not $DryRun) {
713
+ if (-not (Select-String -Path $shellRc -Pattern [regex]::Escape($installDir) -ErrorAction SilentlyContinue)) {
714
+ Add-Content -Path $shellRc -Value ""
715
+ Add-Content -Path $shellRc -Value "# browser4-cli"
716
+ Add-Content -Path $shellRc -Value $pathLine
717
+ Write-Check "Added to PATH in $shellRc"
718
+ } else {
719
+ Write-Check "PATH entry already in $shellRc"
720
+ }
721
+ } else {
722
+ Write-Check "[DRY-RUN] Would add to $shellRc"
723
+ }
724
+ }
725
+
726
+ # Verify
701
727
  Write-Summary ""
702
- $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" }
703
- $pathLine = "export PATH=""$installDir`:`$PATH"""
704
728
  if (-not $DryRun) {
705
- if (-not (Select-String -Path $shellRc -Pattern [regex]::Escape($installDir) -ErrorAction SilentlyContinue)) {
706
- Add-Content -Path $shellRc -Value ""
707
- Add-Content -Path $shellRc -Value "# browser4-cli"
708
- Add-Content -Path $shellRc -Value $pathLine
709
- Write-Check "Added to PATH in $shellRc"
710
- } else {
711
- Write-Check "PATH entry already in $shellRc"
712
- }
729
+ try {
730
+ $versionOutput = & $binaryPath --version 2>&1
731
+ Write-Summary "[v] browser4-cli installed successfully" -Color Green
732
+ Write-Summary " Version: $versionOutput"
733
+ } catch {
734
+ Write-Summary "[v] Binary installed at: $binaryPath" -Color Green
735
+ Write-WarnMsg "Could not verify --version (this is normal on first install)"
736
+ }
713
737
  } else {
714
- Write-Check "[DRY-RUN] Would add to $shellRc"
738
+ Write-Summary "[DRY-RUN] Installation plan complete" -Color Yellow
715
739
  }
716
- }
717
740
 
718
- # Verify
719
- Write-Summary ""
720
- if (-not $DryRun) {
721
- try {
722
- $versionOutput = & $binaryPath --version 2>&1
723
- Write-Summary "[v] browser4-cli installed successfully" -Color Green
724
- Write-Summary " Version: $versionOutput"
725
- } catch {
726
- Write-Summary "[v] Binary installed at: $binaryPath" -Color Green
727
- Write-WarnMsg "Could not verify --version (this is normal on first install)"
728
- }
729
- } else {
730
- Write-Summary "[DRY-RUN] Installation plan complete" -Color Yellow
731
- }
732
-
733
- Write-Summary ""
734
- Write-Summary "Run 'browser4-cli --help' to get started." -Color Cyan
741
+ Write-Summary ""
742
+ Write-Summary "Run 'browser4-cli --help' to get started." -Color Cyan
735
743
 
736
- if ($script:OSWin) {
737
- Write-Summary "If the command isn't found, restart your terminal or run:"
738
- Write-Summary " `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User') + ';' + [System.Environment]::GetEnvironmentVariable('Path','Machine')"
739
- }
744
+ if ($script:OSWin) {
745
+ Write-Summary "If the command isn't found, restart your terminal or run:"
746
+ Write-Summary " `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User') + ';' + [System.Environment]::GetEnvironmentVariable('Path','Machine')"
747
+ }
740
748
  }
741
749
 
742
750
  Main
@@ -59,7 +59,9 @@ function main() {
59
59
  return;
60
60
  }
61
61
 
62
- runCommand('npm publish');
62
+ const isPrerelease = prePublishDecision.cliVersion.includes('-');
63
+ const tagFlag = isPrerelease ? '--tag next' : '';
64
+ runCommand(`npm publish ${tagFlag}`);
63
65
  }
64
66
 
65
67
  main();
@@ -1,3 +1,16 @@
1
+ #!/usr/bin/env pwsh
2
+
3
+ # ═══════════════════════════════════════════════════════════════════
4
+ # CROSS-PLATFORM: This script must run on Linux, macOS, and Windows.
5
+ # - Use $IsWindows / $IsLinux / $IsMacOS for platform detection.
6
+ # - Use "($IsWindows -or $env:OS -eq 'Windows_NT')" for PS 5.1 compat.
7
+ # - Avoid Windows-only env vars ($env:TEMP) — use $env:TMPDIR fallback.
8
+ # - Guard "chcp" and other Windows-only commands behind platform checks.
9
+ # - Paths: use Join-Path / Split-Path; never bake \ or / as literal.
10
+ # - [System.IO.Path]::IsPathRooted is platform-aware — C:\foo is NOT
11
+ # rooted on Linux; test with platform-appropriate absolute paths.
12
+ # ═══════════════════════════════════════════════════════════════════
13
+
1
14
  <#
2
15
  .SYNOPSIS
3
16
  Tests for install-browser4-cli.ps1
@@ -30,7 +43,7 @@ function RunScript([string]$scriptArgs, [ref]$exitCode) {
30
43
  $tmpErr = [System.IO.Path]::GetTempFileName()
31
44
  $cmd = "& '$installScript' $scriptArgs 2>'$tmpErr'"
32
45
  $psi = New-Object System.Diagnostics.ProcessStartInfo
33
- $psi.FileName = "powershell.exe"
46
+ $psi.FileName = if ($IsWindows -or ($env:OS -eq 'Windows_NT')) { "powershell.exe" } else { "pwsh" }
34
47
  $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"$cmd`""
35
48
  $psi.RedirectStandardOutput = $true
36
49
  $psi.UseShellExecute = $false
@@ -284,74 +297,6 @@ $sb = [ScriptBlock]::Create($scriptContent)
284
297
  Test "Test-LocalBinary returns false for null" {
285
298
  if (Test-LocalBinary -Path $null) { throw "Should be false" }
286
299
  }
287
-
288
- # ── Set-BinaryFile (locked-binary-safe replacement) ──
289
- Test "Set-BinaryFile creates new file (Copy)" {
290
- $tmpDir = Join-Path $env:TEMP "b4-test-$(New-Guid)"
291
- New-Item -ItemType Directory $tmpDir -Force | Out-Null
292
- try {
293
- $src = Join-Path $tmpDir "source.exe"
294
- $dst = Join-Path $tmpDir "target.exe"
295
- "new-content" | Out-File $src -Encoding ASCII
296
- Set-BinaryFile -TargetPath $dst -SourcePath $src
297
- if (-not (Test-Path $dst)) { throw "Target was not created" }
298
- $actual = (Get-Content $dst -Raw).Trim()
299
- if ($actual -ne "new-content") { throw "Wrong content: '$actual'" }
300
- # Source still exists (Copy mode)
301
- if (-not (Test-Path $src)) { throw "Source should still exist after copy" }
302
- } finally {
303
- Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
304
- }
305
- }
306
-
307
- Test "Set-BinaryFile creates new file (Move)" {
308
- $tmpDir = Join-Path $env:TEMP "b4-test-$(New-Guid)"
309
- New-Item -ItemType Directory $tmpDir -Force | Out-Null
310
- try {
311
- $src = Join-Path $tmpDir "source.exe"
312
- $dst = Join-Path $tmpDir "target.exe"
313
- "moved-content" | Out-File $src -Encoding ASCII
314
- Set-BinaryFile -TargetPath $dst -SourcePath $src -Move
315
- if (-not (Test-Path $dst)) { throw "Target was not created" }
316
- $actual = (Get-Content $dst -Raw).Trim()
317
- if ($actual -ne "moved-content") { throw "Wrong content: '$actual'" }
318
- # Source should be gone (Move mode)
319
- if (Test-Path $src) { throw "Source should not exist after move" }
320
- } finally {
321
- Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
322
- }
323
- }
324
-
325
- Test "Set-BinaryFile replaces existing file (Copy)" {
326
- $tmpDir = Join-Path $env:TEMP "b4-test-$(New-Guid)"
327
- New-Item -ItemType Directory $tmpDir -Force | Out-Null
328
- try {
329
- $src = Join-Path $tmpDir "source.exe"
330
- $dst = Join-Path $tmpDir "target.exe"
331
- "old" | Out-File $dst -Encoding ASCII
332
- "new" | Out-File $src -Encoding ASCII
333
- Set-BinaryFile -TargetPath $dst -SourcePath $src
334
- if ((Get-Content $dst -Raw).Trim() -ne "new") { throw "Content not replaced" }
335
- } finally {
336
- Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
337
- }
338
- }
339
-
340
- Test "Set-BinaryFile replaces existing file (Move)" {
341
- $tmpDir = Join-Path $env:TEMP "b4-test-$(New-Guid)"
342
- New-Item -ItemType Directory $tmpDir -Force | Out-Null
343
- try {
344
- $src = Join-Path $tmpDir "source.exe"
345
- $dst = Join-Path $tmpDir "target.exe"
346
- "old" | Out-File $dst -Encoding ASCII
347
- "moved" | Out-File $src -Encoding ASCII
348
- Set-BinaryFile -TargetPath $dst -SourcePath $src -Move
349
- if ((Get-Content $dst -Raw).Trim() -ne "moved") { throw "Content not replaced" }
350
- if (Test-Path $src) { throw "Source should not exist after move" }
351
- } finally {
352
- Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
353
- }
354
- }
355
300
  }
356
301
 
357
302
  Write-Host ""