Stop PM2's stderr from aborting the autostart installer

`pm2 delete autofirmer` on a machine that has never registered it writes
"[PM2][ERROR] Process or Namespace autofirmer not found" to stderr. PM2 ships a
PowerShell shim, and under $ErrorActionPreference = 'Stop' any native stderr
becomes a terminating NativeCommandError - so the routine "remove it if it is
there" line killed the install before a single task was registered. The 2>$null
at the call site was useless: the error is raised inside pm2.ps1.

Route every pm2 and npm call through helpers that drop to 'Continue' and judge
by $LASTEXITCODE. npm had the same latent problem, since its warnings also go
to stderr.

Also start Next directly instead of via `npm start`. On Windows npm is a .cmd
shim, so PM2 was supervising the shim while the real server ran as its child -
restarts and stops would have missed the process that actually matters.

The file is now pure ASCII. PowerShell 5.1 reads a non-BOM UTF-8 script as
ANSI, so the box-drawing characters in the comments were a latent hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 18:21:59 -05:00
co-authored by Claude Opus 5
parent 97ee03d13b
commit 7e4714c33d
+69 -19
View File
@@ -34,56 +34,106 @@ $UpdateTask = 'AutoFirmer Update'
function Info($m) { Write-Host " $m" }
function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow }
<#
PM2 ships a PowerShell shim, and anything it writes to stderr becomes a
NativeCommandError. With $ErrorActionPreference = 'Stop' that is terminating,
so a routine "process not found" from `pm2 delete` aborts the whole install.
Redirecting at the call site does not help: the error is raised inside
pm2.ps1, not here. Drop to Continue for the duration and judge the result by
$LASTEXITCODE instead.
#>
function Invoke-Native {
param([string]$Exe, [string[]]$Arguments)
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $Exe @Arguments 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
return $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
}
function Invoke-Pm2 {
param([string[]]$Arguments, [switch]$Quiet)
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & pm2 @Arguments 2>&1
$code = $LASTEXITCODE
if (-not $Quiet -and $code -ne 0) {
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
return $code
} finally {
$ErrorActionPreference = $prev
}
}
Info "project root: $Root"
# ── PM2 ─────────────────────────────────────────────────────────────────────
# -- PM2 ---------------------------------------------------------------------
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
Info 'installing PM2 globally...'
npm install -g pm2
if ($LASTEXITCODE -ne 0) { throw 'npm install -g pm2 failed' }
if ((Invoke-Native 'npm' @('install', '-g', 'pm2')) -ne 0) { throw 'npm install -g pm2 failed' }
# A fresh global install is not on this session's PATH yet.
$npmPrefix = (npm prefix -g).Trim()
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$npmPrefix = (& npm prefix -g 2>$null | Select-Object -First 1)
$ErrorActionPreference = $prevEap
if ($npmPrefix) { $npmPrefix = $npmPrefix.Trim() }
$env:PATH = "$npmPrefix;$env:PATH"
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
throw "PM2 installed but not found on PATH. Open a new terminal and re-run."
throw 'PM2 installed but not found on PATH. Open a new terminal and re-run.'
}
}
Info "pm2: $((Get-Command pm2).Source)"
Push-Location $Root
try {
# delete + recreate rather than reusing, so a changed path or interpreter
# actually takes effect
pm2 delete autofirmer 2>$null | Out-Null
pm2 start npm --name autofirmer -- start
if ($LASTEXITCODE -ne 0) { throw 'pm2 start autofirmer failed' }
# Run Next directly rather than through `npm start`. On Windows `npm` is a
# .cmd shim, so PM2 would supervise the shim while the real server ran as
# its child - restarts and stops then miss the process that matters.
$nextBin = Join-Path $Root 'node_modules\next\dist\bin\next'
if (-not (Test-Path $nextBin)) { throw "next not found at $nextBin - run npm install first" }
Invoke-Pm2 @('delete', 'autofirmer') -Quiet | Out-Null # absent is fine
if ((Invoke-Pm2 @('start', $nextBin, '--name', 'autofirmer', '--interpreter', 'node', '--', 'start')) -ne 0) {
throw 'pm2 start autofirmer failed'
}
Info 'pm2: autofirmer registered'
if (-not $SkipClicker) {
# pm2 --interpreter wants one executable, so resolve the real path
# rather than passing something like "py -3".
# --interpreter wants one executable, so resolve the real path rather
# than passing something like "py -3".
$pyCmdFile = Join-Path $Root 'scripts\.python-cmd'
$pyCmd = if (Test-Path $pyCmdFile) { (Get-Content $pyCmdFile -Raw).Trim() } else { 'python' }
$pyExe = $null
try { $pyExe = (& ([scriptblock]::Create("$pyCmd -c `"import sys;print(sys.executable)`""))).Trim() } catch { }
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$pyExe = (& ([scriptblock]::Create("$pyCmd -c `"import sys;print(sys.executable)`"")) 2>$null | Select-Object -First 1)
} catch { } finally { $ErrorActionPreference = $prevEap }
if ($pyExe -and (Test-Path $pyExe)) {
pm2 delete clicker 2>$null | Out-Null
pm2 start (Join-Path $Root 'clicker\runner.py') --name clicker --interpreter $pyExe
if ($LASTEXITCODE -eq 0) { Info "pm2: clicker registered ($pyExe)" }
else { Warn 'pm2 start clicker failed - the dashboard is unaffected' }
Invoke-Pm2 @('delete', 'clicker') -Quiet | Out-Null
if ((Invoke-Pm2 @('start', (Join-Path $Root 'clicker\runner.py'), '--name', 'clicker', '--interpreter', $pyExe)) -eq 0) {
Info "pm2: clicker registered ($pyExe)"
} else {
Warn 'pm2 start clicker failed - the dashboard is unaffected'
}
} else {
Warn 'python not found - skipping the clicker. Re-run setup once Python is installed.'
}
}
pm2 save | Out-Null
if ((Invoke-Pm2 @('save')) -ne 0) { throw 'pm2 save failed' }
Info 'pm2: process list saved'
} finally {
Pop-Location
}
# ── Scheduled tasks ─────────────────────────────────────────────────────────
# -- Scheduled tasks ---------------------------------------------------------
# LogonType Interactive is what grants the desktop the clicker needs.
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-LogonType Interactive -RunLevel Limited