@beeos-ai/cli 1.1.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beeos-ai/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "description": "BeeOS CLI — run AI agents from your desktop",
6
6
  "bin": {
@@ -86,7 +86,12 @@ Describe "install.ps1 parse-time integrity" {
86
86
  # function for Linux. Listed here so a future cleanup
87
87
  # that drops the helper triggers a Pester failure
88
88
  # before reaching customers.
89
- "Test-VncServer"
89
+ "Test-VncServer",
90
+ # EACCES auto-recovery (Phase 3 of the install pipeline).
91
+ # install.sh has the parallel `recover_eacces_prefix`.
92
+ # Dropping this helper would silently regress the Phase 3
93
+ # self-healing path back to "hint + exit 3".
94
+ "Invoke-RecoverEaccesPrefix"
90
95
  )
91
96
 
92
97
  foreach ($name in $expected) {
@@ -167,6 +172,58 @@ Describe "install.ps1 stderr log file (P1-I)" {
167
172
  }
168
173
  }
169
174
 
175
+ # ── EACCES auto-recovery (Phase 3) ───────────────────────────
176
+ #
177
+ # Static / structural guards for the Phase 3 self-healing path.
178
+ # Mirrors the install.sh design: when `npm install -g` fails with
179
+ # EACCES on the global node_modules, swap the npm prefix to a
180
+ # user-owned directory and retry exactly once. We deliberately do
181
+ # NOT mock `npm` / `[Environment]::SetEnvironmentVariable` here —
182
+ # Pester mocks for static .NET methods are fragile, and the rest of
183
+ # this test file uses structural / regex assertions for the same
184
+ # reason. Behaviour is exercised end-to-end via the staging EACCES
185
+ # soak (see `.cursor/skills/deploy-staging/install-verify.md`).
186
+
187
+ Describe "install.ps1 EACCES auto-recovery" {
188
+ It "writes to User-scope PATH (never Machine, to avoid UAC)" {
189
+ $content = Get-Content -Raw -Path $script:ScriptPath
190
+ # The recovery MUST persist on User scope; Machine-scope writes
191
+ # would silently re-prompt UAC and undo the whole point of
192
+ # falling back to a user-owned prefix.
193
+ $content | Should -Match 'SetEnvironmentVariable\(\s*"PATH"\s*,[^,]+,\s*"User"\s*\)'
194
+ $content | Should -Not -Match 'SetEnvironmentVariable\(\s*"PATH"\s*,[^,]+,\s*"Machine"\s*\)'
195
+ }
196
+
197
+ It "honours BEEOS_NO_NPM_PREFIX_FIX=1 as an opt-out" {
198
+ $content = Get-Content -Raw -Path $script:ScriptPath
199
+ $content | Should -Match "BEEOS_NO_NPM_PREFIX_FIX"
200
+ }
201
+
202
+ It "matches the EACCES fingerprint in the npm log" {
203
+ $content = Get-Content -Raw -Path $script:ScriptPath
204
+ # Same fingerprint string as install.sh's `grep -qE` — keeps
205
+ # the two scripts' classifier behaviour aligned. If you tighten
206
+ # the regex on one side, tighten it here too (and on
207
+ # install.sh) so a real EACCES on either platform still
208
+ # triggers recovery.
209
+ $content | Should -Match 'EACCES.*permission denied.*node_modules'
210
+ }
211
+
212
+ It "emits install.bootstrap.npm_recovered on retry success" {
213
+ $content = Get-Content -Raw -Path $script:ScriptPath
214
+ $content | Should -Match 'install\.bootstrap\.npm_recovered'
215
+ $content | Should -Match 'eacces_prefix'
216
+ }
217
+
218
+ It "still falls through to exit 3 when recovery is skipped or fails" {
219
+ $content = Get-Content -Raw -Path $script:ScriptPath
220
+ # Regression guard: the original hint + exit 3 path MUST stay
221
+ # reachable for non-EACCES failure modes (ETIMEDOUT, EBADENGINE,
222
+ # ENOSPC ...) and for the case where recovery itself bails.
223
+ $content | Should -Match 'exit 3'
224
+ }
225
+ }
226
+
170
227
  # ── Dry-run hook ─────────────────────────────────────────────
171
228
 
172
229
  Describe "install.ps1 dry-run hook" {
@@ -31,6 +31,16 @@
31
31
  install-link refactor).
32
32
  $env:BEEOS_USE_NPX = "1" Power-user throwaway install (beeos
33
33
  won't persist on PATH).
34
+ $env:BEEOS_NO_NPM_PREFIX_FIX="1" Disable Phase 3 EACCES auto-recovery.
35
+ Recovery (default ON) detects
36
+ "EACCES on lib/node_modules" in the
37
+ npm log, switches the npm prefix
38
+ to %APPDATA%\npm, prepends it on
39
+ User-scope PATH (NEVER Machine —
40
+ that would re-trigger UAC), and
41
+ retries `npm install -g` once.
42
+ Set to 1 to fall back to the legacy
43
+ "hint + exit 3" behaviour.
34
44
 
35
45
  IMPORTANT (env inheritance):
36
46
  $env:BEEOS_API_URL / BEEOS_AGENT_GATEWAY_URL / BEEOS_DASHBOARD_URL
@@ -431,6 +441,80 @@ function Show-InstallHints {
431
441
  }
432
442
  }
433
443
 
444
+ # ── EACCES auto-recovery (Phase 3) ────────────────────────────
445
+ #
446
+ # Mirror of `install.sh::recover_eacces_prefix`. Phase 2 (Node install)
447
+ # already self-heals via winget → choco; Phase 3 (`npm install -g`)
448
+ # historically just printed a hint and exited. EACCES on Windows is
449
+ # rarer than on macOS — the default npm prefix is already user-scoped
450
+ # (`%APPDATA%\npm`) — but it does happen when an Administrator-
451
+ # installed Node has flipped the prefix to `C:\Program Files\nodejs\
452
+ # node_modules`. The fix is symmetric to the POSIX path: switch the
453
+ # prefix back to a user-owned directory and persist on User-scope
454
+ # PATH. We DELIBERATELY do not touch Machine-scope PATH — that would
455
+ # require UAC and silently re-trigger the elevation prompt the user
456
+ # already declined when winget/choco asked.
457
+ #
458
+ # Escape hatch: `$env:BEEOS_NO_NPM_PREFIX_FIX = "1"` short-circuits
459
+ # this function so power users / strict-policy operators can opt out.
460
+ function Invoke-RecoverEaccesPrefix {
461
+ if ($env:BEEOS_NO_NPM_PREFIX_FIX -eq "1") {
462
+ Write-BeeInfo "BEEOS_NO_NPM_PREFIX_FIX=1 — skipping auto npm prefix switch."
463
+ return $false
464
+ }
465
+
466
+ $appData = $env:APPDATA
467
+ if (-not $appData -or $appData.Length -eq 0) {
468
+ Write-BeeWarn "APPDATA env not set — cannot pick a user prefix. Falling back to hint."
469
+ return $false
470
+ }
471
+ $prefix = Join-Path $appData "npm"
472
+ Write-BeeInfo "Switching npm global prefix to $prefix (user-owned)."
473
+
474
+ try {
475
+ New-Item -ItemType Directory -Path $prefix -Force | Out-Null
476
+ } catch {
477
+ Write-BeeError "Could not create ${prefix}: $_"
478
+ return $false
479
+ }
480
+
481
+ try {
482
+ & npm config set prefix "$prefix" 2>&1 | Tee-Object -FilePath $BeeosInstallLog -Append | Out-Null
483
+ if ($LASTEXITCODE -ne 0) {
484
+ Write-BeeError "npm config set prefix failed (exit $LASTEXITCODE)."
485
+ return $false
486
+ }
487
+ } catch {
488
+ Write-BeeError "npm config set prefix threw: $_"
489
+ return $false
490
+ }
491
+
492
+ # Make $prefix usable for the upcoming retry IN this session.
493
+ # Persistence (next terminal) happens via SetEnvironmentVariable
494
+ # below.
495
+ $env:Path = "$prefix;" + $env:Path
496
+
497
+ # Persist to User-scope PATH so a new terminal sees it. Idempotent —
498
+ # re-running on an already-fixed host MUST NOT append a duplicate
499
+ # entry (the User PATH is global to the account; appending without
500
+ # a contains-check would grow it unboundedly across re-runs).
501
+ try {
502
+ $userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
503
+ if (-not $userPath) { $userPath = "" }
504
+ if (($userPath -split ";") -notcontains $prefix) {
505
+ $newUserPath = if ($userPath.Length -gt 0) { "$prefix;$userPath" } else { "$prefix" }
506
+ [Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")
507
+ Write-BeeInfo "Persisted PATH to user environment. Set BEEOS_NO_NPM_PREFIX_FIX=1 to skip on re-runs."
508
+ } else {
509
+ Write-BeeInfo "User PATH already contains $prefix — leaving as-is."
510
+ }
511
+ } catch {
512
+ Write-BeeWarn "Could not persist user PATH: $_"
513
+ Write-BeeWarn " Add $prefix to PATH manually to keep beeos discoverable in new terminals."
514
+ }
515
+ return $true
516
+ }
517
+
434
518
  # ── Run CLI ──────────────────────────────────────────────────
435
519
 
436
520
  function Invoke-BeeosCli {
@@ -469,18 +553,49 @@ function Invoke-BeeosCli {
469
553
  & npm install -g $CliPackage 2>&1 | Tee-Object -FilePath $BeeosInstallLog -Append
470
554
  $npmExit = $LASTEXITCODE
471
555
  if ($npmExit -ne 0) {
472
- # P0-A of the install-link review: fail-after-bootstrap signal.
473
- # The `install.bootstrap.cli_installed` event is only emitted
474
- # AFTER npm reports success.
475
- Send-Telemetry -Event "install.bootstrap.npm_fail" -ErrorCode "npm_install_cli_failed" -Success $false
476
- Write-BeeError "npm install -g $CliPackage failed."
477
- Write-BeeError " Full log: $BeeosInstallLog"
478
- Write-BeeError ""
479
- Write-BeeError "Common fixes:"
480
- Write-BeeError " - EEXIST on beeos from another package:"
481
- Write-BeeError " npm uninstall -g @beeos-ai/cli # then re-run installer"
482
- Write-BeeError " - EACCES / permission error: run PowerShell as Administrator"
483
- exit 3
556
+ # ── EACCES auto-recovery ──────────────────────────────────
557
+ # Mirror of install.sh's failure-branch recovery. We grep the
558
+ # teed log for the EACCES fingerprint; if it matches AND the
559
+ # user hasn't disabled the auto-fix, we swap the npm prefix
560
+ # to %APPDATA%\npm + persist to User PATH and retry exactly
561
+ # once. Other npm error classes (ENOSPC / E401 / EBADENGINE
562
+ # / ...) fall through to the existing hint+exit path —
563
+ # rarer, and need user judgement we can't safely automate.
564
+ $recovered = $false
565
+ $logContent = if (Test-Path $BeeosInstallLog) {
566
+ Get-Content -Raw -Path $BeeosInstallLog
567
+ } else { "" }
568
+ if ($logContent -match 'EACCES.*permission denied.*node_modules') {
569
+ Write-BeeWarn "Detected EACCES on global node_modules — attempting auto-recovery."
570
+ if (Invoke-RecoverEaccesPrefix) {
571
+ Write-BeeInfo "Retrying npm install with user-owned prefix..."
572
+ & npm install -g $CliPackage 2>&1 | Tee-Object -FilePath $BeeosInstallLog -Append
573
+ if ($LASTEXITCODE -eq 0) {
574
+ Send-Telemetry -Event "install.bootstrap.npm_recovered" -ErrorCode "eacces_prefix"
575
+ $recovered = $true
576
+ }
577
+ }
578
+ }
579
+ # ── End recovery ──────────────────────────────────────────
580
+
581
+ if (-not $recovered) {
582
+ # P0-A of the install-link review: fail-after-bootstrap signal.
583
+ # The `install.bootstrap.cli_installed` event is only emitted
584
+ # AFTER npm reports success.
585
+ Send-Telemetry -Event "install.bootstrap.npm_fail" -ErrorCode "npm_install_cli_failed" -Success $false
586
+ Write-BeeError "npm install -g $CliPackage failed."
587
+ Write-BeeError " Full log: $BeeosInstallLog"
588
+ Write-BeeError ""
589
+ Write-BeeError "Common fixes:"
590
+ Write-BeeError " - EEXIST on beeos from another package:"
591
+ Write-BeeError " npm uninstall -g @beeos-ai/cli # then re-run installer"
592
+ Write-BeeError " - EACCES / permission error:"
593
+ Write-BeeError " auto-recovery already attempted; if it failed, manually"
594
+ Write-BeeError " run ``npm config set prefix `"`$env:APPDATA\npm`"`` and"
595
+ Write-BeeError " add `$env:APPDATA\npm to your User PATH, then re-run installer."
596
+ Write-BeeError " (Set `$env:BEEOS_NO_NPM_PREFIX_FIX = '1' to disable auto-recovery.)"
597
+ exit 3
598
+ }
484
599
  }
485
600
  # Device-agent suite is intentionally NOT installed here; `beeos
486
601
  # device attach` will auto-install it on first use via
@@ -29,7 +29,9 @@
29
29
  # 3 `npm install -g @beeos-ai/cli` failed (CLI didn't reach PATH)
30
30
  #
31
31
  # Automation can branch on these — `1` is "fix your Node install",
32
- # `2` is "wrong machine", `3` is "your npm prefix / proxy / disk".
32
+ # `2` is "wrong machine", `3` is "your npm prefix / proxy / disk
33
+ # (after EACCES auto-recovery already attempted, see
34
+ # `BEEOS_NO_NPM_PREFIX_FIX` below)".
33
35
  #
34
36
  # ── Optional env vars ─────────────────────────────────────────────
35
37
  # BEEOS_API_URL Public API base (this script uses it for
@@ -51,6 +53,22 @@
51
53
  # install.ps1's same-named env var.
52
54
  # BEEOS_USE_NPX=1 Power-user throwaway install (beeos won't
53
55
  # persist on PATH).
56
+ # BEEOS_NO_NPM_PREFIX_FIX=1 Disable Phase 3 EACCES auto-recovery.
57
+ # Recovery (default ON) detects "EACCES on
58
+ # lib/node_modules" in the npm log, switches
59
+ # the npm global prefix to ~/.npm-global,
60
+ # persists $HOME/.npm-global/bin on PATH via
61
+ # the user's shell rc (zsh -> ~/.zshrc, bash
62
+ # -> ~/.bash_profile on macOS or ~/.bashrc
63
+ # on Linux), and retries `npm install -g`
64
+ # exactly once. The shell rc edit is wrapped
65
+ # in a sentinel block (`# >>> beeos
66
+ # npm-prefix >>>` ... `# <<< beeos
67
+ # npm-prefix <<<`) so it can be reverted by
68
+ # removing the block AND running
69
+ # `npm config delete prefix`. Set this var
70
+ # to 1 to fall back to the legacy
71
+ # "hint + exit 3" behaviour.
54
72
  #
55
73
  # IMPORTANT (env inheritance):
56
74
  # BEEOS_API_URL / BEEOS_AGENT_GATEWAY_URL / BEEOS_DASHBOARD_URL must
@@ -401,8 +419,8 @@ try_install_node() {
401
419
  fi
402
420
  fi
403
421
 
404
- # 4. macOS Homebrew (last resort — already-installed brew only,
405
- # we don't try to install brew itself).
422
+ # 4. macOS Homebrew (already-installed brew only — we don't install
423
+ # brew itself).
406
424
  if [[ "$OS_KIND" == "darwin" ]] && command -v brew &>/dev/null; then
407
425
  info "Trying Homebrew..."
408
426
  if brew install node >> "$BEEOS_INSTALL_LOG" 2>&1; then
@@ -411,6 +429,91 @@ try_install_node() {
411
429
  fi
412
430
  fi
413
431
 
432
+ # 5. Linux distro package manager (last resort).
433
+ #
434
+ # nvm + fnm both depend on `curl` to fetch the Node tarball from
435
+ # nodejs.org. Locked-down corporate networks frequently block
436
+ # `nodejs.org` while still allowing the distro mirror, so a system
437
+ # package install is the only way through. We deliberately put this
438
+ # AFTER nvm/fnm because:
439
+ #
440
+ # 1. distro Node LTS versions lag — Debian stable was on Node 18
441
+ # well into the Node 20 era, breaking device-agent's engines
442
+ # requirement. nvm/fnm pull current LTS regardless.
443
+ # 2. apt/dnf/pacman normally need root, which is a different
444
+ # security posture than user-scope nvm. We require sudo -n
445
+ # (non-interactive) to detect "user is already root or has
446
+ # passwordless sudo" — anything else falls through to the
447
+ # hint block.
448
+ #
449
+ # Operators on hardened systems can disable this branch entirely
450
+ # with BEEOS_NO_PKG_MANAGER_NODE=1 and rely on their own provisioning.
451
+ if [[ "$OS_KIND" == "linux" ]] \
452
+ && [[ "${BEEOS_NO_PKG_MANAGER_NODE:-}" != "1" ]]; then
453
+ # `sudo -n true` returns 0 only when sudo is configured to skip the
454
+ # password prompt for the current user, OR when we're already root.
455
+ # No prompt = no surprise password dialog mid-install.
456
+ local sudo_cmd=""
457
+ if [[ $EUID -eq 0 ]]; then
458
+ sudo_cmd=""
459
+ elif command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then
460
+ sudo_cmd="sudo -n"
461
+ else
462
+ info "Linux package managers require root and sudo is not passwordless — skipping."
463
+ fi
464
+
465
+ if [[ -n "$sudo_cmd" || $EUID -eq 0 ]]; then
466
+ # Probe in order of decreasing prevalence. Each branch is
467
+ # idempotent and short-circuits the chain on success.
468
+ if command -v apt-get &>/dev/null; then
469
+ info "Trying apt-get (Debian / Ubuntu)..."
470
+ # NodeSource setup script is the canonical way to get a
471
+ # current Node LTS on Debian-family distros. Same retry/log
472
+ # discipline as nvm above.
473
+ if curl -fsSL https://deb.nodesource.com/setup_lts.x \
474
+ | $sudo_cmd bash >> "$BEEOS_INSTALL_LOG" 2>&1 \
475
+ && $sudo_cmd apt-get install -y nodejs >> "$BEEOS_INSTALL_LOG" 2>&1; then
476
+ success "Node.js installed via apt-get"
477
+ return 0
478
+ fi
479
+ elif command -v dnf &>/dev/null; then
480
+ info "Trying dnf (Fedora / RHEL)..."
481
+ if curl -fsSL https://rpm.nodesource.com/setup_lts.x \
482
+ | $sudo_cmd bash >> "$BEEOS_INSTALL_LOG" 2>&1 \
483
+ && $sudo_cmd dnf install -y nodejs >> "$BEEOS_INSTALL_LOG" 2>&1; then
484
+ success "Node.js installed via dnf"
485
+ return 0
486
+ fi
487
+ elif command -v yum &>/dev/null; then
488
+ info "Trying yum (CentOS / Amazon Linux)..."
489
+ if curl -fsSL https://rpm.nodesource.com/setup_lts.x \
490
+ | $sudo_cmd bash >> "$BEEOS_INSTALL_LOG" 2>&1 \
491
+ && $sudo_cmd yum install -y nodejs >> "$BEEOS_INSTALL_LOG" 2>&1; then
492
+ success "Node.js installed via yum"
493
+ return 0
494
+ fi
495
+ elif command -v pacman &>/dev/null; then
496
+ info "Trying pacman (Arch)..."
497
+ if $sudo_cmd pacman -Sy --noconfirm nodejs npm >> "$BEEOS_INSTALL_LOG" 2>&1; then
498
+ success "Node.js installed via pacman"
499
+ return 0
500
+ fi
501
+ elif command -v zypper &>/dev/null; then
502
+ info "Trying zypper (openSUSE)..."
503
+ if $sudo_cmd zypper --non-interactive install nodejs npm >> "$BEEOS_INSTALL_LOG" 2>&1; then
504
+ success "Node.js installed via zypper"
505
+ return 0
506
+ fi
507
+ elif command -v apk &>/dev/null; then
508
+ info "Trying apk (Alpine)..."
509
+ if $sudo_cmd apk add --no-cache nodejs npm >> "$BEEOS_INSTALL_LOG" 2>&1; then
510
+ success "Node.js installed via apk"
511
+ return 0
512
+ fi
513
+ fi
514
+ fi
515
+ fi
516
+
414
517
  return 1
415
518
  }
416
519
 
@@ -507,6 +610,182 @@ test_vnc_server() {
507
610
  echo ""
508
611
  }
509
612
 
613
+ # ── EACCES auto-recovery (Phase 3) ────────────────────────────
614
+ #
615
+ # Phase 2 of the bootstrap pipeline (Node install) already has a
616
+ # multi-strategy auto-recovery loop in `try_install_node` (nvm → fnm
617
+ # → brew). Phase 3 (`npm install -g @beeos-ai/cli`) historically had
618
+ # none — it just printed a hint and exited 3. The single most common
619
+ # Phase 3 failure is EACCES on the system-owned global node_modules
620
+ # (typically `/usr/local/lib/node_modules` after a nodejs.org .pkg
621
+ # install or a sudo brew install). The two helpers below close that
622
+ # gap with the same shape Phase 2 uses: a small detect function +
623
+ # an idempotent recovery function. `run_cli` calls them only when
624
+ # the npm log carries the EACCES fingerprint; every other npm
625
+ # failure mode is rarer and needs user judgement we can't safely
626
+ # automate from a curl|bash one-liner.
627
+
628
+ # Detect the user's preferred shell init file so that an `export
629
+ # PATH=...` write survives a new terminal. We support zsh, bash, fish,
630
+ # and pwsh-on-Unix (~99% of macOS / Linux users); tcsh / nushell users
631
+ # still get the printed hint instead of a silent edit to a file we
632
+ # don't fully understand. `$SHELL` is the login shell as recorded in
633
+ # /etc/passwd, NOT the currently-running interpreter — that's exactly
634
+ # what we want for "what does the user's NEXT terminal source?".
635
+ #
636
+ # fish + pwsh emit different PATH syntaxes. The persisted block in
637
+ # `recover_eacces_prefix` below picks the right form by checking the
638
+ # rc path's basename, keeping shell detection isolated to this helper.
639
+ detect_shell_rc() {
640
+ local shell_name
641
+ shell_name="$(basename "${SHELL:-}")"
642
+ case "$shell_name" in
643
+ zsh)
644
+ # zsh's per-user interactive rc. macOS Terminal.app sources it
645
+ # for both login and non-login zsh sessions. We deliberately do
646
+ # NOT touch ~/.zprofile (login-only) — keeping all our writes
647
+ # in a single file makes "how do I revert?" a one-line answer.
648
+ printf '%s' "$HOME/.zshrc"
649
+ ;;
650
+ bash)
651
+ # macOS Terminal.app launches each window as a login bash, so
652
+ # the canonical sourced file is ~/.bash_profile. Linux GUI
653
+ # terminals run interactive non-login bash and source ~/.bashrc.
654
+ if [[ "$OS_KIND" == "darwin" ]]; then
655
+ printf '%s' "$HOME/.bash_profile"
656
+ else
657
+ printf '%s' "$HOME/.bashrc"
658
+ fi
659
+ ;;
660
+ fish)
661
+ # fish auto-sources every *.fish under ~/.config/fish/conf.d/,
662
+ # so a single dropped-in file is the idiomatic way to add PATH
663
+ # without touching the user's main config.fish. mkdir is
664
+ # idempotent and ignored on permission errors.
665
+ mkdir -p "$HOME/.config/fish/conf.d" 2>/dev/null || true
666
+ printf '%s' "$HOME/.config/fish/conf.d/beeos.fish"
667
+ ;;
668
+ pwsh)
669
+ # PowerShell on Linux / macOS reads
670
+ # ~/.config/powershell/Microsoft.PowerShell_profile.ps1 by
671
+ # default for the current user, all hosts. The exact filename
672
+ # is case-sensitive on Linux — preserve it.
673
+ mkdir -p "$HOME/.config/powershell" 2>/dev/null || true
674
+ printf '%s' "$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1"
675
+ ;;
676
+ *)
677
+ # Empty string ⇒ caller falls back to a manual-paste hint.
678
+ printf '%s' ""
679
+ ;;
680
+ esac
681
+ }
682
+
683
+ # Switch the npm global prefix to a user-owned directory and persist
684
+ # the resulting PATH change to the user's shell rc. Used as the EACCES
685
+ # auto-recovery in `run_cli`. Idempotent — running on an already-
686
+ # fixed host is a no-op (the sentinel block check below prevents
687
+ # duplicate appends).
688
+ #
689
+ # Why this approach (and not the alternatives):
690
+ #
691
+ # - sudo retry: would leave /usr/local/lib/node_modules/@beeos-ai/cli
692
+ # owned by root, breaking the next `npm update -g` and creating
693
+ # ownership inconsistencies between the CLI binary and ~/.beeos
694
+ # (which the user owns). It's the kind of "fix" that buries the
695
+ # real problem deeper.
696
+ #
697
+ # - force-install nvm: intrusive for a user who already has a
698
+ # working Node toolchain, and risks breaking other tools that
699
+ # depend on the system Node path.
700
+ #
701
+ # - just print a hint: the original behaviour. Pushes the fix onto
702
+ # the user, who has to switch contexts mid-install. Most users
703
+ # give up here.
704
+ #
705
+ # The chosen path mirrors npm's own documentation:
706
+ # https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally
707
+ #
708
+ # Escape hatch: `BEEOS_NO_NPM_PREFIX_FIX=1` short-circuits this
709
+ # function so power users / strict-dotfiles operators can opt out.
710
+ recover_eacces_prefix() {
711
+ if [[ "${BEEOS_NO_NPM_PREFIX_FIX:-}" == "1" ]]; then
712
+ info "BEEOS_NO_NPM_PREFIX_FIX=1 — skipping auto npm prefix switch."
713
+ return 1
714
+ fi
715
+
716
+ local prefix="$HOME/.npm-global"
717
+ info "Switching npm global prefix to ${prefix} (user-owned)."
718
+
719
+ if ! mkdir -p "$prefix" >> "$BEEOS_INSTALL_LOG" 2>&1; then
720
+ error "Could not create ${prefix} — falling back to hint."
721
+ return 1
722
+ fi
723
+ if ! npm config set prefix "$prefix" >> "$BEEOS_INSTALL_LOG" 2>&1; then
724
+ error "npm config set prefix failed — falling back to hint."
725
+ return 1
726
+ fi
727
+
728
+ # Make the new prefix usable for the upcoming retry IN this
729
+ # session. Persistence (next terminal) happens via shell rc below.
730
+ export PATH="$prefix/bin:$PATH"
731
+
732
+ local rc
733
+ rc="$(detect_shell_rc)"
734
+ if [[ -z "$rc" ]]; then
735
+ warn "Unsupported login shell ($(basename "${SHELL:-unknown}")) — could not"
736
+ warn " persist PATH automatically. Add this line to your shell init:"
737
+ warn " export PATH=\"$prefix/bin:\$PATH\""
738
+ return 0
739
+ fi
740
+
741
+ # Sentinel block — grep + sed can remove it cleanly. We check for
742
+ # the BEGIN marker so re-runs of the installer don't append the
743
+ # block twice (the file may have been edited by hand between runs).
744
+ local marker_begin="# >>> beeos npm-prefix >>>"
745
+ local marker_end="# <<< beeos npm-prefix <<<"
746
+ if [[ -f "$rc" ]] && grep -qF "$marker_begin" "$rc"; then
747
+ info "Sentinel block already present in ${rc} — leaving as-is."
748
+ return 0
749
+ fi
750
+
751
+ # Pick PATH-export syntax based on the rc file's basename. fish and
752
+ # pwsh need different forms; sh-family rcs all share `export PATH=...`.
753
+ local rc_base
754
+ rc_base="$(basename "$rc")"
755
+ local export_line
756
+ case "$rc_base" in
757
+ *.fish)
758
+ # `fish_add_path -g` prepends to the global PATH list and is the
759
+ # idiomatic way fish persists PATH (universal fish_user_paths).
760
+ export_line="fish_add_path -g \"${prefix}/bin\""
761
+ ;;
762
+ Microsoft.PowerShell_profile.ps1)
763
+ # PowerShell uses semicolon-delimited PATH on Windows but ':' on
764
+ # Unix where pwsh runs. `$env:PATH` mutation in the profile is
765
+ # the canonical way; we use the Unix separator literal because
766
+ # pwsh-on-Unix is the only host that reads this rc.
767
+ export_line="\$env:PATH = \"${prefix}/bin:\" + \$env:PATH"
768
+ ;;
769
+ *)
770
+ # zsh / bash / dash-family — the historical contract.
771
+ export_line="export PATH=\"${prefix}/bin:\$PATH\""
772
+ ;;
773
+ esac
774
+
775
+ # `>>` never clobbers existing content. If the rc doesn't exist yet
776
+ # (fresh user account), the redirect creates it with 0644 perms.
777
+ {
778
+ printf '\n%s\n' "$marker_begin"
779
+ printf '# Added by https://beeos.ai/install on %s\n' "$(date -u +%FT%TZ)"
780
+ printf '# Reason: system npm prefix was not writable (EACCES).\n'
781
+ printf '# To revert: delete this block AND run `npm config delete prefix`.\n'
782
+ printf '%s\n' "$export_line"
783
+ printf '%s\n' "$marker_end"
784
+ } >> "$rc"
785
+ info "Persisted PATH to ${rc}. Set BEEOS_NO_NPM_PREFIX_FIX=1 to skip on re-runs."
786
+ return 0
787
+ }
788
+
510
789
  # ── Main ─────────────────────────────────────────────────────
511
790
 
512
791
  run_cli() {
@@ -553,6 +832,28 @@ run_cli() {
553
832
  # the time they go to copy-paste. `set -o pipefail` (file header)
554
833
  # ensures the pipe's exit status is `npm`'s, not `tee`'s.
555
834
  if ! npm install -g "$CLI_PACKAGE" 2>&1 | tee -a "$BEEOS_INSTALL_LOG"; then
835
+ # ── EACCES auto-recovery ──────────────────────────────────────
836
+ # The single most common Phase 3 failure: the system npm prefix
837
+ # is owned by root, EACCES on rename. Detect the fingerprint in
838
+ # the npm log we just teed; if it matches, swap the prefix to a
839
+ # user-owned dir (see `recover_eacces_prefix` for the rationale)
840
+ # and retry exactly once. Every other npm error class
841
+ # (ENOSPC / ETIMEDOUT / EBADENGINE / ...) falls through to the
842
+ # hint+exit path below — those are rarer in practice and need
843
+ # human judgement we can't safely automate.
844
+ if grep -qE 'EACCES.*permission denied.*node_modules' "$BEEOS_INSTALL_LOG"; then
845
+ warn "Detected EACCES on global node_modules — attempting auto-recovery."
846
+ if recover_eacces_prefix; then
847
+ info "Retrying npm install with user-owned prefix..."
848
+ if npm install -g "$CLI_PACKAGE" 2>&1 | tee -a "$BEEOS_INSTALL_LOG"; then
849
+ send_telemetry "install.bootstrap.npm_recovered" "eacces_prefix"
850
+ send_telemetry "install.bootstrap.cli_installed"
851
+ exec beeos "$subcmd" "$@" <"$stdin_src"
852
+ fi
853
+ fi
854
+ fi
855
+ # ── End recovery ──────────────────────────────────────────────
856
+
556
857
  # P0-A of the install-link review: separate "Node ready" from
557
858
  # "CLI is actually on PATH" so the dashboard never reports a
558
859
  # success that the user can't reproduce. `install.bootstrap.npm_fail`
@@ -565,7 +866,10 @@ run_cli() {
565
866
  error " - EEXIST on /bin/beeos from another package:"
566
867
  error " npm uninstall -g @beeos-ai/cli # then re-run installer"
567
868
  error " - EACCES permission error:"
568
- error " use a node version manager (nvm / fnm) or sudo"
869
+ error " auto-recovery already attempted; if it failed, manually"
870
+ error " run \`npm config set prefix \"\$HOME/.npm-global\"\` and"
871
+ error " add \$HOME/.npm-global/bin to PATH, then re-run installer."
872
+ error " (Set BEEOS_NO_NPM_PREFIX_FIX=1 to disable auto-recovery.)"
569
873
  exit 3
570
874
  fi
571
875
  # NPM succeeded — CLI is now on PATH. The device-agent suite is