diff --git a/modules/fnm/README.md b/modules/fnm/README.md index f35fe0aa..273ab95e 100644 --- a/modules/fnm/README.md +++ b/modules/fnm/README.md @@ -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. diff --git a/modules/fnm/fnm.nu b/modules/fnm/fnm.nu index 1e03afc1..0584730f 100644 --- a/modules/fnm/fnm.nu +++ b/modules/fnm/fnm.nu @@ -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)" + } + } + } + }) + } +}