Skip to content

Commit f074325

Browse files
Improve fnm module - error handling, user configuration, and missing version workflow (#1220)
### Summary Rewrites the fnm integration to be more robust, configurable, and helpful when things go wrong — particularly when a project requires a Node.js version that isn't installed yet. I started writing this before i found this module in the nu_scripts repo, and I was already pretty far along when i found it. I stole what was good in the original and kept the particulars that I needed from my thing. ### What changed **Robustness** - `fnm env --json` is wrapped in `try/catch` so a broken or misconfigured fnm doesn't crash shell startup. - Early-return pattern replaces the top-level `if not ... { }` wrapper, improving readability. - PATH is deduplicated with `| uniq` to stay clean across shell reloads. - `fnm use --silent-if-unchanged` avoids redundant output when the version hasn't changed. **Missing version handling** The original hook ran `fnm use` and let its raw error print to the terminal with no follow-up. The new hook detects the failure and either: - **auto-installs** the missing version (if `auto_install: true`), or - **prompts the user** interactively, with a clean message and a `[y/N]` confirmation. Install failures are caught and reported instead of silently ignored. **User configuration via `$env.FNM_NU_CONFIG`** All behavior is configurable without editing the script: | Option | Default | Description | |---|---|---| | `triggers` | `['.nvmrc', '.node-version', 'package.json']` | Files that trigger a version switch | | `auto_install` | `false` | Install missing versions without prompting | | `install_flags` | `[]` | Extra flags passed to `fnm install` | | `fallback_alias` | `null` | Adds a stable fnm alias to PATH for non-shell consumers | Config is re-read on every directory change so updates take effect without restarting the shell. **Fallback alias for non-shell processes** Optionally places an fnm alias's bin directory (e.g. `default`) in PATH below the multishell path. The active fnm version always wins, but the fallback gives systemd services, IDEs, and other non-shell processes a stable path to `node`, `pnpm`, etc. (See README.md for why that's useful. I'd share my "nu env to systemd env" bridge too, but `export def "yeet nu env vars into systemd"[] {...}` tells you the level of polish in that one.) **Comments and documentation** The script is documented with inline comments explaining each section and the reasoning behind less obvious choices. README updated with configuration reference and examples. ### Motivation I don't even remember anymore, I started the day trying to get my backend to talk to my sql server, and five digressions later atleast my IDE sees pnpm. It's been a day. Frontend's running now atleast, and I figured I'd share back. --------- Co-authored-by: Fredrik Stock <fredriks@eltele.no>
1 parent cd3f9be commit f074325

File tree

2 files changed

+171
-36
lines changed

2 files changed

+171
-36
lines changed

modules/fnm/README.md

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,63 @@
11
# Fast Node Manager (fnm)
22

3-
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).
3+
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`.
4+
5+
Based on [this GitHub issue](https://github.com/Schniz/fnm/issues/463) and [fnm-nushell](https://github.com/Southclaws/fnm-nushell).
46

57
Requires `fnm` to be installed separately.
68

79
## Install
810

9-
Clone this repo or copy the `fnm.nu` file wherever your prefer to keep your Nushell scripts.
10-
11-
Edit your Nushell config file (`$nu.config-path`) and add the line:
11+
Copy or clone `fnm.nu` to a location of your choice and add the following to your Nushell config (`$nu.config-path`):
1212

1313
```nu
1414
use /path/to/fnm.nu
1515
```
16+
17+
It also works from `$nu.user-autoload-dirs` if you prefer autoloading.
18+
19+
## Configuration
20+
21+
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.
22+
23+
```nu
24+
$env.FNM_NU_CONFIG = {
25+
triggers: ['.nvmrc', '.node-version'] # files that trigger a version switch
26+
auto_install: true # install missing versions without prompting
27+
install_flags: ['--lts'] # extra flags passed to `fnm install`
28+
fallback_alias: 'default' # optional stable PATH entry for non-shell processes
29+
}
30+
```
31+
32+
### Options
33+
34+
| Option | Default | Description |
35+
|---|---|---|
36+
| `triggers` | `['.nvmrc', '.node-version', 'package.json']` | Files whose presence triggers `fnm use` on directory change. |
37+
| `auto_install` | `false` | When `true`, missing Node.js versions are installed automatically. When `false`, you get an interactive `[y/N]` prompt. |
38+
| `install_flags` | `[]` | Additional flags forwarded to `fnm install` (e.g. `--progress never`). |
39+
| `fallback_alias` | `null` | An fnm alias (e.g. `'default'`) whose bin directory is added to PATH as a stable fallback. See below. |
40+
41+
Configuration is re-read on every directory change, so you can update `$env.FNM_NU_CONFIG` at any time without restarting the shell.
42+
43+
### Fallback alias
44+
45+
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.
46+
47+
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.
48+
49+
_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._
50+
51+
## Missing version handling
52+
53+
When you `cd` into a project that requires a Node.js version you don't have installed:
54+
55+
- **`auto_install: true`** — the version is installed and activated automatically.
56+
- **`auto_install: false`** (default) — you see a message and an interactive prompt:
57+
58+
```
59+
fnm: Requested version v23.x.x is not currently installed
60+
Install it? [y/N]
61+
```
62+
63+
Answering `y` installs and activates the version. Answering anything else skips with a hint to run `fnm install` manually.

modules/fnm/fnm.nu

Lines changed: 119 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,119 @@
1-
export-env {
2-
if not (which fnm | is-empty) {
3-
^fnm env --json | from json | load-env
4-
5-
$env.PATH = $env.PATH | prepend ($env.FNM_MULTISHELL_PATH | path join (if $nu.os-info.name == 'windows' {''} else {'bin'}))
6-
7-
$env.config = (
8-
$env.config?
9-
| default {}
10-
| upsert hooks { default {} }
11-
| upsert hooks.env_change { default {} }
12-
| upsert hooks.env_change.PWD { default [] }
13-
)
14-
let __fnm_hooked = (
15-
$env.config.hooks.env_change.PWD | any { try { get __fnm_hook } catch { false } }
16-
)
17-
if not $__fnm_hooked {
18-
let version_files = [.nvmrc .node-version package.json]
19-
20-
$env.config.hooks.env_change.PWD = (
21-
$env.config.hooks.env_change.PWD | append {
22-
__fnm_hook: true
23-
code: {|before, after|
24-
if ('FNM_DIR' in $env) and ($version_files | path exists | any {|it| $it }) {
25-
^fnm use
26-
}
27-
}
28-
}
29-
)
30-
}
31-
}
32-
}
1+
# fnm.nu - Fast Node Manager integration for Nushell
2+
#
3+
# Automatically switches Node.js versions when changing directories.
4+
# To configure, set $env.FNM_NU_CONFIG in your config.nu:
5+
# $env.FNM_NU_CONFIG = {
6+
# triggers: ['.nvmrc', '.node-version'] # Files to watch for
7+
# auto_install: false # Install missing versions without prompting
8+
# install_flags: [] # Flags passed to `fnm install`
9+
# fallback_alias: 'default' # Optional: add a fallback fnm alias to PATH for systemd/non-shell processes
10+
# }
11+
#
12+
# The fallback_alias option is useful when another script (e.g. for login sessions) copies
13+
# environment variables into systemd so that non-shell processes get access to Node.js tools.
14+
# Without a fallback, the multishell path alone won't help those processes since it's
15+
# session-specific. Setting fallback_alias to e.g. 'default' places that fnm alias's bin
16+
# directory in PATH as a stable fallback, giving tools like pnpm to systemd services, IDEs,
17+
# and other non-shell consumers.
18+
19+
export-env {
20+
if (which fnm | is-empty) { return }
21+
22+
# 1. Initialize fnm environment variables
23+
# We wrap in try/catch to ensure shell startup doesn't crash if fnm is broken
24+
try {
25+
^fnm env --json | from json | load-env
26+
} catch {
27+
return
28+
}
29+
30+
# 2. Build PATH array with multishell path first, then fallback alias if configured
31+
# Multishell path is prepended so fnm's node/npm always takes precedence.
32+
let multishell_bin = if $nu.os-info.name == 'windows' { $env.FNM_MULTISHELL_PATH } else { $env.FNM_MULTISHELL_PATH | path join "bin" }
33+
34+
# Load user config with defaults
35+
let fnm_config = {
36+
triggers: ['.nvmrc', '.node-version', 'package.json']
37+
auto_install: false
38+
install_flags: []
39+
fallback_alias: null
40+
} | merge ($env.FNM_NU_CONFIG? | default {})
41+
42+
# If a fallback alias is set, resolve its bin directory under fnm's aliases dir.
43+
# This path sits below the multishell path in precedence, so the active fnm version
44+
# always wins. But when multishell isn't meaningful (e.g. systemd importing PATH from
45+
# a login session), the fallback alias provides a stable path to node/pnpm/etc.
46+
let fallback_bin = if $fnm_config.fallback_alias != null {
47+
$env.FNM_DIR | path join $"aliases/($fnm_config.fallback_alias)/bin"
48+
} else { null }
49+
50+
$env.PATH = (
51+
$env.PATH
52+
| prepend (if $fallback_bin != null { [$fallback_bin] } else { [] })
53+
| prepend $multishell_bin
54+
| uniq
55+
)
56+
57+
# 3. Register PWD change hook
58+
# Ensure config structure exists
59+
$env.config = (
60+
$env.config?
61+
| default {}
62+
| upsert hooks { default {} }
63+
| upsert hooks.env_change { default {} }
64+
| upsert hooks.env_change.PWD { default [] }
65+
)
66+
67+
# Check if hook is already registered to avoid duplication
68+
let is_hooked = ($env.config.hooks.env_change.PWD | any {|h| try { $h.__fnm_hook } catch { false } })
69+
70+
if not $is_hooked {
71+
$env.config.hooks.env_change.PWD = ($env.config.hooks.env_change.PWD | append {
72+
__fnm_hook: true # Marker to identify this hook
73+
code: {|before, after|
74+
# Load user config dynamically so changes take effect without restarting
75+
let fnm_config = {
76+
triggers: ['.nvmrc', '.node-version', 'package.json']
77+
auto_install: false
78+
install_flags: []
79+
fallback_alias: null
80+
} | merge ($env.FNM_NU_CONFIG? | default {})
81+
82+
# Only run if a trigger file exists in the new directory
83+
if not ($fnm_config.triggers | any {|f| $after | path join $f | path exists }) {
84+
return
85+
}
86+
87+
# Try to switch version
88+
let res = (do { ^fnm use --silent-if-unchanged } | complete)
89+
if $res.exit_code == 0 { return }
90+
91+
# Trim "error: " prefix from fnm output (case-insensitive for robustness)
92+
let err_msg = ($res.stderr | str trim | str replace -r -a '(?i)^error:\s*' '')
93+
94+
if $fnm_config.auto_install {
95+
print $"(ansi yellow_bold)fnm:(ansi reset) ($err_msg). Installing..."
96+
let install_res = (do { ^fnm install ...$fnm_config.install_flags } | complete)
97+
if $install_res.exit_code != 0 {
98+
print $"(ansi red_bold)fnm:(ansi reset) Install failed."
99+
return
100+
}
101+
^fnm use
102+
} else {
103+
print $"(ansi yellow_bold)fnm:(ansi reset) ($err_msg)"
104+
let answer = (input "Install it? [y/N] ")
105+
if ($answer | str downcase) == "y" {
106+
let install_res = (do { ^fnm install ...$fnm_config.install_flags } | complete)
107+
if $install_res.exit_code != 0 {
108+
print $"(ansi red_bold)fnm:(ansi reset) Install failed."
109+
return
110+
}
111+
^fnm use
112+
} else {
113+
print $"(ansi red_dimmed)Skipping. Run (ansi reset)(ansi green)fnm install(ansi reset)(ansi red_dimmed) manually.(ansi reset)"
114+
}
115+
}
116+
}
117+
})
118+
}
119+
}

0 commit comments

Comments
 (0)