Public/Tools/Enable-FastNodeManager.ps1

function Enable-FastNodeManager {
    <#
    .SYNOPSIS
        Installs (if necessary) and activates Fast Node Manager (fnm) for the session.
 
    .DESCRIPTION
        Runs two nested Invoke-Step substeps:
          - Install: if fnm.exe isn't on PATH, installs it with winget (Schniz.fnm, a
            portable package) and patches the current session's PATH so the Initialize
            substep can see it immediately.
          - Initialize: applies `fnm env` (multishell PATH + FNM_* variables, recursive
            version-file strategy) and registers fnm completions, then registers a
            LocationChangedAction hook so changing into a Node project auto-switches the node
            version (via `fnm use`).
 
        The directory hook uses PowerShell's
        $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction (6.2+), which fires
        after *any* location change — `cd`, `z`/`cdi`, `Set-Location`, `Push-Location`, `..` —
        so it works whether or not zoxide is enabled and regardless of zoxide's jump command.
        Because it fires for *every* change (including scripts' Push-Location), the hook first
        walks up from the new directory for a version file (.node-version / .nvmrc) and only runs
        `fnm use` when one resolves — so moving around a non-Node tree neither spawns fnm nor
        prints fnm's "can't find version file" error to stderr on every change. It chains any
        pre-existing LocationChangedAction and is guarded against re-registering on profile reload.
 
        If the install doesn't produce fnm.exe on PATH, a warning is emitted (with winget's
        captured output) and Initialize is skipped (guarded by Get-Command) so profile startup
        continues.
 
    .EXAMPLE
        Enable-FastNodeManager
 
    .NOTES
        Independent of zoxide and of call order: the directory hook is a LocationChangedAction,
        not a wrap of zoxide's cd helper, so no "call after Enable-Zoxide" requirement applies.
    #>

    [CmdletBinding()]
    param()

    Invoke-Step "Install" {
        # fnm is a winget portable: its exe lands in the default Links dir.
        Install-WingetPackageSafe -Id 'Schniz.fnm' -Exe 'fnm.exe' -CallerName 'Enable-FastNodeManager'
    }

    Invoke-Step "Initialize" {
        if (Get-Command fnm.exe -ErrorAction SilentlyContinue) {
            # Run in the global scope (not this module's) so the emitted env/completion helpers
            # aren't tagged to the module — see Private/Invoke-InGlobalScope.ps1.
            Invoke-InGlobalScope (fnm env --version-file-strategy=recursive --shell powershell | Out-String)
            Invoke-InGlobalScope (fnm completions --shell powershell | Out-String)

            # Auto-switch the node version on every directory change via PowerShell's
            # LocationChangedAction (fires for cd, z/cdi, Set-Location, Push-Location, .., etc.),
            # so it works without zoxide and regardless of zoxide's --cmd. Run in the global scope
            # so the handler and its $global:__fnm_loc_base capture aren't tagged to the module and
            # resolve when the hook fires later from the prompt.
            #
            # Capture any pre-existing handler ONCE (guarded by $global:__fnm_loc_hooked) so a
            # profile reload doesn't re-capture our own wrapper and stack fnm calls. The base is
            # Enable-Zoxide's LocationChangedAction (it runs first and also hooks here) or $null;
            # either way fnm chains onto it so both fire. But always (re)install the wrapper, so
            # reloading the profile in a live session repairs or updates the hook rather than leaving
            # a stale one frozen behind the guard.
            Invoke-InGlobalScope @'
if (-not (Get-Variable -Name __fnm_loc_hooked -Scope Global -ErrorAction SilentlyContinue)) {
    $global:__fnm_loc_base = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction
    $global:__fnm_loc_hooked = $true
}
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = {
    param($source, $eventArgs)
    # The captured base is an EventHandler delegate (the property's type), so call .Invoke.
    if ($null -ne $global:__fnm_loc_base) { $global:__fnm_loc_base.Invoke($source, $eventArgs) }
    # Only act inside a Node project: walk up from the new directory looking for a version file
    # (.node-version / .nvmrc — fnm's recursive strategy). Because LocationChangedAction fires for
    # EVERY location change (incl. scripts' Push-Location, not just interactive cd), running `fnm use`
    # unconditionally would spawn fnm and print its "can't find version file" error to stderr on every
    # change in a non-Node tree. Guard on the FileSystem provider so cd into Registry:/Cert: is a no-op.
    $new = $eventArgs.NewPath
    if ($new -and $new.Provider.Name -eq 'FileSystem') {
        $dir = $new.ProviderPath
        while ($dir) {
            if ((Test-Path -LiteralPath (Join-Path $dir '.node-version')) -or
                (Test-Path -LiteralPath (Join-Path $dir '.nvmrc'))) {
                # Pipe through Out-Host: PowerShell discards stdout emitted inside a
                # LocationChangedAction, and fnm writes its "Using Node vX.X.X" confirmation to
                # stdout — so without Out-Host the version switches silently. (Errors go to stderr,
                # which surfaces regardless. Nothing is emitted when the version is unchanged.)
                fnm use --silent-if-unchanged | Out-Host
                break
            }
            $parent = [System.IO.Path]::GetDirectoryName($dir)
            if (-not $parent -or $parent -eq $dir) { break }
            $dir = $parent
        }
    }
}
'@

        }
    }
}