Register-ScheduledTask failed with Access denied (0x80070005). Writing to the root task folder needs elevation, so the claim that this install needed no admin was simply wrong. Rather than demand UAC, use two mechanisms that need no privileges at all: - a Startup-folder entry (AutoFirmer.cmd) runs start-all.bat at logon - PM2's own --cron-restart with --no-autorestart drives the 5-minute update check, so PM2 owns the schedule it was already going to resurrect anyway Both still run in the logged-in interactive session, which is the requirement that ruled out a Windows service in the first place: the clicker sends real input and needs a desktop. pm2 save now runs after the updater is registered, so `pm2 resurrect` brings back all three processes rather than two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.6 KiB
PowerShell
184 lines
7.6 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Register AutoFirmer to start at logon and keep itself updated.
|
|
|
|
.DESCRIPTION
|
|
The only Windows-specific piece of the setup. Everything it schedules is
|
|
plain Node, so porting to macOS or Linux means replacing this file alone.
|
|
|
|
Deliberately needs no administrator rights, and changes nothing
|
|
machine-wide:
|
|
|
|
* PM2 supervises the dashboard, the clicker and the update checker.
|
|
* A Startup-folder entry runs scripts\start-all.bat at logon.
|
|
* PM2's own cron restart drives the update checks.
|
|
|
|
Task Scheduler is avoided on purpose - Register-ScheduledTask needs
|
|
elevation to write to the root task folder.
|
|
|
|
Everything runs in the logged-in user's interactive session. That is not
|
|
incidental: the clicker sends real mouse and keyboard input and must own a
|
|
desktop, which a Windows service (session 0) does not have. The trade-off
|
|
is that an unattended reboot leaves the instance down until someone logs in.
|
|
|
|
Idempotent - safe to re-run.
|
|
|
|
.PARAMETER IntervalMinutes
|
|
How often to check master for updates. Default 5.
|
|
|
|
.PARAMETER SkipClicker
|
|
Register only the dashboard, leaving the clicker to be run by hand.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[int]$IntervalMinutes = 5,
|
|
[switch]$SkipClicker
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$Root = Split-Path -Parent $PSScriptRoot
|
|
|
|
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 ---------------------------------------------------------------------
|
|
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
|
|
Info 'installing PM2 globally...'
|
|
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.
|
|
$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.'
|
|
}
|
|
}
|
|
Info "pm2: $((Get-Command pm2).Source)"
|
|
|
|
Push-Location $Root
|
|
try {
|
|
# 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) {
|
|
# --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
|
|
$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)) {
|
|
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.'
|
|
}
|
|
}
|
|
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
|
|
# -- Start at logon (Startup folder) -----------------------------------------
|
|
# Not Task Scheduler: Register-ScheduledTask needs elevation to write to the
|
|
# root task folder, and the whole point is an install that never asks for
|
|
# admin. A Startup-folder entry needs no privileges and runs in the user's
|
|
# interactive session, which is exactly what the clicker requires - a service
|
|
# in session 0 has no desktop and its clicks would go nowhere.
|
|
$startupDir = [Environment]::GetFolderPath('Startup')
|
|
$startupCmd = Join-Path $startupDir 'AutoFirmer.cmd'
|
|
$startAll = Join-Path $Root 'scripts\start-all.bat'
|
|
|
|
@"
|
|
@echo off
|
|
REM Written by scripts\install-autostart.ps1 - delete this file to stop
|
|
REM AutoFirmer starting at logon.
|
|
call "$startAll"
|
|
"@ | Set-Content -Path $startupCmd -Encoding ASCII
|
|
|
|
Info "logon entry: $startupCmd"
|
|
|
|
# -- Update checks (PM2 cron) ------------------------------------------------
|
|
# Also not Task Scheduler, same reason. PM2 is already running and already
|
|
# comes back at logon, so it can own the schedule too: --cron-restart fires the
|
|
# script on a schedule and --no-autorestart stops PM2 relaunching it the moment
|
|
# it exits.
|
|
$updateScript = Join-Path $Root 'scripts\update-check.mjs'
|
|
Invoke-Pm2 @('delete', 'autofirmer-update') -Quiet | Out-Null
|
|
$cron = "*/$IntervalMinutes * * * *"
|
|
if ((Invoke-Pm2 @('start', $updateScript, '--name', 'autofirmer-update',
|
|
'--no-autorestart', '--cron-restart', $cron)) -eq 0) {
|
|
Info "update checks: every $IntervalMinutes minutes ($cron)"
|
|
} else {
|
|
Warn 'could not register the update checker - run scripts\update-check.mjs by hand to update'
|
|
}
|
|
|
|
# Save again so `pm2 resurrect` at logon brings the updater back too.
|
|
if ((Invoke-Pm2 @('save')) -ne 0) { Warn 'pm2 save failed - processes may not return after a reboot' }
|
|
|
|
Write-Host ''
|
|
Info 'Done. No admin was needed and nothing machine-wide was changed.'
|
|
Info 'Running now, and again at every logon.'
|
|
Info "Updates checked every $IntervalMinutes minutes; see scripts\update.log"
|
|
Info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
|
|
Info "To disable autostart: delete $startupCmd"
|