Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions modules/fnm/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,63 @@
# Fast Node Manager (fnm)

Enables Nushell support for [Fast Node Manager (fnm)](https://github.com/Schniz/fnm), a fast and simple Node.js version manager. Based on [this GitHub issue](https://github.com/Schniz/fnm/issues/463) and [fnm-nushell](https://github.com/Southclaws/fnm-nushell).
Nushell integration for [Fast Node Manager (fnm)](https://github.com/Schniz/fnm), a fast and simple Node.js version manager. Automatically switches Node.js versions when you `cd` into a directory containing `.nvmrc`, `.node-version`, or `package.json`.

Based on [this GitHub issue](https://github.com/Schniz/fnm/issues/463) and [fnm-nushell](https://github.com/Southclaws/fnm-nushell).

Requires `fnm` to be installed separately.

## Install

Clone this repo or copy the `fnm.nu` file wherever your prefer to keep your Nushell scripts.

Edit your Nushell config file (`$nu.config-path`) and add the line:
Copy or clone `fnm.nu` to a location of your choice and add the following to your Nushell config (`$nu.config-path`):

```nu
use /path/to/fnm.nu
```

It also works from `$nu.user-autoload-dirs` if you prefer autoloading.

## Configuration

All options are configured through `$env.FNM_NU_CONFIG`. Set it in your `config.nu` **before** `use fnm.nu` is evaluated. Every field is optional — defaults are applied for anything you omit.

```nu
$env.FNM_NU_CONFIG = {
triggers: ['.nvmrc', '.node-version'] # files that trigger a version switch
auto_install: true # install missing versions without prompting
install_flags: ['--lts'] # extra flags passed to `fnm install`
fallback_alias: 'default' # optional stable PATH entry for non-shell processes
}
```

### Options

| Option | Default | Description |
|---|---|---|
| `triggers` | `['.nvmrc', '.node-version', 'package.json']` | Files whose presence triggers `fnm use` on directory change. |
| `auto_install` | `false` | When `true`, missing Node.js versions are installed automatically. When `false`, you get an interactive `[y/N]` prompt. |
| `install_flags` | `[]` | Additional flags forwarded to `fnm install` (e.g. `--progress never`). |
| `fallback_alias` | `null` | An fnm alias (e.g. `'default'`) whose bin directory is added to PATH as a stable fallback. See below. |

Configuration is re-read on every directory change, so you can update `$env.FNM_NU_CONFIG` at any time without restarting the shell.

### Fallback alias

fnm's multishell path is session-specific. If another tool copies your login environment into systemd (or similar), that path won't resolve for non-shell processes like IDEs or background services.

Setting `fallback_alias` to e.g. `'default'` places that alias's bin directory in PATH *below* the multishell path. The active fnm version still takes precedence inside the shell, but non-shell consumers get a stable path to `node`, `pnpm`, and other tools.

_The concrete use case that triggered adding this was [Aspire](https://aspire.dev) launching ViteApp resources without env vars from the shell, and thus the install step failed when looking for pnpm. With `fallback_alias: 'default'`, Aspire can find the default Node.js version even without the multishell path. So if you're playing around with Aspire and your dashboard yells at you that frontend couldn't start because "**Missing command** Required command 'pnpm' was not found on PATH or at the specified location" - there you go._

## Missing version handling

When you `cd` into a project that requires a Node.js version you don't have installed:

- **`auto_install: true`** — the version is installed and activated automatically.
- **`auto_install: false`** (default) — you see a message and an interactive prompt:

```
fnm: Requested version v23.x.x is not currently installed
Install it? [y/N]
```

Answering `y` installs and activates the version. Answering anything else skips with a hint to run `fnm install` manually.
151 changes: 119 additions & 32 deletions modules/fnm/fnm.nu
Original file line number Diff line number Diff line change
@@ -1,32 +1,119 @@
export-env {
if not (which fnm | is-empty) {
^fnm env --json | from json | load-env

$env.PATH = $env.PATH | prepend ($env.FNM_MULTISHELL_PATH | path join (if $nu.os-info.name == 'windows' {''} else {'bin'}))

$env.config = (
$env.config?
| default {}
| upsert hooks { default {} }
| upsert hooks.env_change { default {} }
| upsert hooks.env_change.PWD { default [] }
)
let __fnm_hooked = (
$env.config.hooks.env_change.PWD | any { try { get __fnm_hook } catch { false } }
)
if not $__fnm_hooked {
let version_files = [.nvmrc .node-version package.json]

$env.config.hooks.env_change.PWD = (
$env.config.hooks.env_change.PWD | append {
__fnm_hook: true
code: {|before, after|
if ('FNM_DIR' in $env) and ($version_files | path exists | any {|it| $it }) {
^fnm use
}
}
}
)
}
}
}
# fnm.nu - Fast Node Manager integration for Nushell
#
# Automatically switches Node.js versions when changing directories.
# To configure, set $env.FNM_NU_CONFIG in your config.nu:
# $env.FNM_NU_CONFIG = {
# triggers: ['.nvmrc', '.node-version'] # Files to watch for
# auto_install: false # Install missing versions without prompting
# install_flags: [] # Flags passed to `fnm install`
# fallback_alias: 'default' # Optional: add a fallback fnm alias to PATH for systemd/non-shell processes
# }
#
# The fallback_alias option is useful when another script (e.g. for login sessions) copies
# environment variables into systemd so that non-shell processes get access to Node.js tools.
# Without a fallback, the multishell path alone won't help those processes since it's
# session-specific. Setting fallback_alias to e.g. 'default' places that fnm alias's bin
# directory in PATH as a stable fallback, giving tools like pnpm to systemd services, IDEs,
# and other non-shell consumers.

export-env {
if (which fnm | is-empty) { return }

# 1. Initialize fnm environment variables
# We wrap in try/catch to ensure shell startup doesn't crash if fnm is broken
try {
^fnm env --json | from json | load-env
} catch {
return
}

# 2. Build PATH array with multishell path first, then fallback alias if configured
# Multishell path is prepended so fnm's node/npm always takes precedence.
let multishell_bin = if $nu.os-info.name == 'windows' { $env.FNM_MULTISHELL_PATH } else { $env.FNM_MULTISHELL_PATH | path join "bin" }

# Load user config with defaults
let fnm_config = {
triggers: ['.nvmrc', '.node-version', 'package.json']
auto_install: false
install_flags: []
fallback_alias: null
} | merge ($env.FNM_NU_CONFIG? | default {})

# If a fallback alias is set, resolve its bin directory under fnm's aliases dir.
# This path sits below the multishell path in precedence, so the active fnm version
# always wins. But when multishell isn't meaningful (e.g. systemd importing PATH from
# a login session), the fallback alias provides a stable path to node/pnpm/etc.
let fallback_bin = if $fnm_config.fallback_alias != null {
$env.FNM_DIR | path join $"aliases/($fnm_config.fallback_alias)/bin"
} else { null }

$env.PATH = (
$env.PATH
| prepend (if $fallback_bin != null { [$fallback_bin] } else { [] })
| prepend $multishell_bin
| uniq
)

# 3. Register PWD change hook
# Ensure config structure exists
$env.config = (
$env.config?
| default {}
| upsert hooks { default {} }
| upsert hooks.env_change { default {} }
| upsert hooks.env_change.PWD { default [] }
)

# Check if hook is already registered to avoid duplication
let is_hooked = ($env.config.hooks.env_change.PWD | any {|h| try { $h.__fnm_hook } catch { false } })

if not $is_hooked {
$env.config.hooks.env_change.PWD = ($env.config.hooks.env_change.PWD | append {
__fnm_hook: true # Marker to identify this hook
code: {|before, after|
# Load user config dynamically so changes take effect without restarting
let fnm_config = {
triggers: ['.nvmrc', '.node-version', 'package.json']
auto_install: false
install_flags: []
fallback_alias: null
} | merge ($env.FNM_NU_CONFIG? | default {})

# Only run if a trigger file exists in the new directory
if not ($fnm_config.triggers | any {|f| $after | path join $f | path exists }) {
return
}

# Try to switch version
let res = (do { ^fnm use --silent-if-unchanged } | complete)
if $res.exit_code == 0 { return }

# Trim "error: " prefix from fnm output (case-insensitive for robustness)
let err_msg = ($res.stderr | str trim | str replace -r -a '(?i)^error:\s*' '')

if $fnm_config.auto_install {
print $"(ansi yellow_bold)fnm:(ansi reset) ($err_msg). Installing..."
let install_res = (do { ^fnm install ...$fnm_config.install_flags } | complete)
if $install_res.exit_code != 0 {
print $"(ansi red_bold)fnm:(ansi reset) Install failed."
return
}
^fnm use
} else {
print $"(ansi yellow_bold)fnm:(ansi reset) ($err_msg)"
let answer = (input "Install it? [y/N] ")
if ($answer | str downcase) == "y" {
let install_res = (do { ^fnm install ...$fnm_config.install_flags } | complete)
if $install_res.exit_code != 0 {
print $"(ansi red_bold)fnm:(ansi reset) Install failed."
return
}
^fnm use
} else {
print $"(ansi red_dimmed)Skipping. Run (ansi reset)(ansi green)fnm install(ansi reset)(ansi red_dimmed) manually.(ansi reset)"
}
}
}
})
}
}