Browse Source

Merge remote-tracking branch 'origin/main' into alias-latest

remotes/origin/alias-latest
Gal Schlezinger 9 months ago
parent
commit
9868a12c20
  1. 5
      .changeset/config.json
  2. 5
      .changeset/disable-unused-chrono-features.md
  3. 5
      .changeset/fifty-emus-type.md
  4. 5
      .changeset/many-paws-fetch.md
  5. 5
      .changeset/poor-otters-cheer.md
  6. 5
      .changeset/poor-poets-compete.md
  7. 5
      .changeset/rotten-pumpkins-search.md
  8. 5
      .changeset/show-download-progress.md
  9. 5
      .changeset/violet-rice-brake.md
  10. 60
      .ci/install.sh
  11. 7
      .ci/print-command-docs.js
  12. 6
      .ci/record_screen.sh
  13. 94
      .github/workflows/release.yml
  14. 608
      .github/workflows/rust.yml
  15. 1
      .gitignore
  16. 2
      .node-version
  17. 30
      CHANGELOG.md
  18. 1224
      Cargo.lock
  19. 45
      Cargo.toml
  20. 54
      README.md
  21. 2
      benchmarks/basic/fnm
  22. 6
      benchmarks/basic/fnm_latest_master
  23. 6
      benchmarks/basic/fnm_reason
  24. 1115
      docs/commands.md
  25. 72
      docs/configuration.md
  26. 2
      docs/fnm.svg
  27. 82
      e2e/__snapshots__/basic.test.ts.snap
  28. 28
      e2e/__snapshots__/corepack.test.ts.snap
  29. 28
      e2e/basic.test.ts
  30. 54
      e2e/corepack.test.ts
  31. 2
      e2e/env.test.ts
  32. 21
      e2e/shellcode/script.ts
  33. 2
      e2e/shellcode/shells.ts
  34. 11
      e2e/shellcode/shells/cmdEnv.ts
  35. 3
      jest.config.cjs
  36. 5
      jest.global-setup.js
  37. 5
      jest.global-teardown.js
  38. 17
      package.json
  39. 2937
      pnpm-lock.yaml
  40. 3
      rust-toolchain.toml
  41. 9
      src/arch.rs
  42. 4
      src/archive/mod.rs
  43. 34
      src/commands/completions.rs
  44. 38
      src/commands/env.rs
  45. 31
      src/commands/exec.rs
  46. 44
      src/commands/install.rs
  47. 67
      src/commands/ls_remote.rs
  48. 14
      src/commands/use.rs
  49. 49
      src/config.rs
  50. 9
      src/default_version.rs
  51. 21
      src/downloader.rs
  52. 31
      src/log_level.rs
  53. 2
      src/main.rs
  54. 19
      src/package_json.rs
  55. 165
      src/progress.rs
  56. 10
      src/shell/bash.rs
  57. 8
      src/shell/fish.rs
  58. 2
      src/shell/infer/mod.rs
  59. 4
      src/shell/mod.rs
  60. 6
      src/shell/powershell.rs
  61. 49
      src/shell/shell.rs
  62. 2
      src/shell/windows_cmd/cd.cmd
  63. 21
      src/shell/windows_compat.rs
  64. 8
      src/shell/zsh.rs
  65. 3
      src/user_version.rs
  66. 4
      src/user_version_reader.rs
  67. 3
      src/version.rs
  68. 40
      src/version_file_strategy.rs
  69. 59
      src/version_files.rs
  70. 65
      tests/proxy-server/index.mjs

5
.changeset/config.json

@ -1,9 +1,6 @@
{ {
"$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
"changelog": [ "changelog": ["@changesets/changelog-github", { "repo": "Schniz/fnm" }],
"@svitejs/changesets-changelog-github-compact",
{ "repo": "Schniz/fnm" }
],
"commit": false, "commit": false,
"fixed": [], "fixed": [],
"linked": [], "linked": [],

5
.changeset/disable-unused-chrono-features.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
Disable unused chrono features (#1014)

5
.changeset/fifty-emus-type.md

@ -0,0 +1,5 @@
---
"fnm": minor
---
feat: add remote version sorting and filtering

5
.changeset/many-paws-fetch.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
Fix `cd /D` on windows with `--use-on-cd`

5
.changeset/poor-otters-cheer.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
support `x64-musl` arch by adding a `--arch x64-musl` to fnm env

5
.changeset/poor-poets-compete.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
make nicer styling in progress bar (add newline, make it unicode)

5
.changeset/rotten-pumpkins-search.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
fix: return default version if canonicalize fails

5
.changeset/show-download-progress.md

@ -0,0 +1,5 @@
---
"fnm": minor
---
Show a progress bar when downloading and extracting node

5
.changeset/violet-rice-brake.md

@ -0,0 +1,5 @@
---
"fnm": patch
---
Fixes a bug when running `eval $(fnm env)` in sh when there a spaces in the $PATH

60
.ci/install.sh

@ -5,6 +5,10 @@ set -e
RELEASE="latest" RELEASE="latest"
OS="$(uname -s)" OS="$(uname -s)"
case "${OS}" in
MINGW* | Win*) OS="Windows" ;;
esac
if [ -d "$HOME/.fnm" ]; then if [ -d "$HOME/.fnm" ]; then
INSTALL_DIR="$HOME/.fnm" INSTALL_DIR="$HOME/.fnm"
elif [ -n "$XDG_DATA_HOME" ]; then elif [ -n "$XDG_DATA_HOME" ]; then
@ -70,6 +74,9 @@ set_filename() {
elif [ "$OS" = "Darwin" ]; then elif [ "$OS" = "Darwin" ]; then
USE_HOMEBREW="true" USE_HOMEBREW="true"
echo "Downloading fnm using Homebrew..." echo "Downloading fnm using Homebrew..."
elif [ "$OS" = "Windows" ] ; then
FILENAME="fnm-windows"
echo "Downloading the latest fnm binary from GitHub..."
else else
echo "OS $OS is not supported." echo "OS $OS is not supported."
echo "If you think that's a bug - please file an issue to https://github.com/Schniz/fnm/issues" echo "If you think that's a bug - please file an issue to https://github.com/Schniz/fnm/issues"
@ -161,28 +168,29 @@ setup_shell() {
CONF_FILE=${ZDOTDIR:-$HOME}/.zshrc CONF_FILE=${ZDOTDIR:-$HOME}/.zshrc
ensure_containing_dir_exists "$CONF_FILE" ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Zsh. Appending the following to $CONF_FILE:" echo "Installing for Zsh. Appending the following to $CONF_FILE:"
echo "" {
echo ' # fnm' echo ''
echo ' export PATH="'"$INSTALL_DIR"':$PATH"' echo '# fnm'
echo ' eval "`fnm env`"' echo 'FNM_PATH="'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]; then'
echo '' >>$CONF_FILE echo ' export PATH="'$INSTALL_DIR':$PATH"'
echo '# fnm' >>$CONF_FILE echo ' eval "`fnm env`"'
echo 'export PATH="'$INSTALL_DIR':$PATH"' >>$CONF_FILE echo 'fi'
echo 'eval "`fnm env`"' >>$CONF_FILE } | tee -a "$CONF_FILE"
elif [ "$CURRENT_SHELL" = "fish" ]; then elif [ "$CURRENT_SHELL" = "fish" ]; then
CONF_FILE=$HOME/.config/fish/conf.d/fnm.fish CONF_FILE=$HOME/.config/fish/conf.d/fnm.fish
ensure_containing_dir_exists "$CONF_FILE" ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Fish. Appending the following to $CONF_FILE:" echo "Installing for Fish. Appending the following to $CONF_FILE:"
echo "" {
echo ' # fnm' echo ''
echo ' set PATH "'"$INSTALL_DIR"'" $PATH' echo '# fnm'
echo ' fnm env | source' echo 'set FNM_PATH "'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]'
echo '# fnm' >>$CONF_FILE echo ' set PATH "$FNM_PATH" $PATH'
echo 'set PATH "'"$INSTALL_DIR"'" $PATH' >>$CONF_FILE echo ' fnm env | source'
echo 'fnm env | source' >>$CONF_FILE echo 'end'
} | tee -a "$CONF_FILE"
elif [ "$CURRENT_SHELL" = "bash" ]; then elif [ "$CURRENT_SHELL" = "bash" ]; then
if [ "$OS" = "Darwin" ]; then if [ "$OS" = "Darwin" ]; then
@ -192,15 +200,15 @@ setup_shell() {
fi fi
ensure_containing_dir_exists "$CONF_FILE" ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Bash. Appending the following to $CONF_FILE:" echo "Installing for Bash. Appending the following to $CONF_FILE:"
echo "" {
echo ' # fnm' echo ''
echo ' export PATH="'"$INSTALL_DIR"':$PATH"' echo '# fnm'
echo ' eval "`fnm env`"' echo 'FNM_PATH="'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]; then'
echo '' >>$CONF_FILE echo ' export PATH="$FNM_PATH:$PATH"'
echo '# fnm' >>$CONF_FILE echo ' eval "`fnm env`"'
echo 'export PATH="'"$INSTALL_DIR"':$PATH"' >>$CONF_FILE echo 'fi'
echo 'eval "`fnm env`"' >>$CONF_FILE } | tee -a "$CONF_FILE"
else else
echo "Could not infer shell type. Please set up manually." echo "Could not infer shell type. Please set up manually."

7
.ci/print-command-docs.js

@ -106,11 +106,14 @@ async function getCommandHelp(fnmPath, command) {
const result = await run(fnmPath, [...cmdArg, "--help"]) const result = await run(fnmPath, [...cmdArg, "--help"])
const text = result.stdout const text = result.stdout
const rows = text.split("\n") const rows = text.split("\n")
const headerIndex = rows.findIndex((x) => x.includes("SUBCOMMANDS")) const headerIndex = rows.findIndex((x) => x.includes("Commands:"))
/** @type {string[]} */ /** @type {string[]} */
const subcommands = [] const subcommands = []
if (!command) { if (!command) {
for (const row of rows.slice(headerIndex + 1)) { for (const row of rows.slice(
headerIndex + 1,
rows.indexOf("", headerIndex + 1)
)) {
const [, word] = row.split(/\s+/) const [, word] = row.split(/\s+/)
if (word && word[0].toLowerCase() === word[0]) { if (word && word[0].toLowerCase() === word[0]) {
subcommands.push(word) subcommands.push(word)

6
.ci/record_screen.sh

@ -20,7 +20,11 @@ RECORDING_PATH=$DIRECTORY/screen_recording
(rm -rf "$RECORDING_PATH" &> /dev/null || true) (rm -rf "$RECORDING_PATH" &> /dev/null || true)
asciinema rec -c "$DIRECTORY/recorded_screen_script.sh" "$RECORDING_PATH" asciinema rec \
--command "$DIRECTORY/recorded_screen_script.sh" \
--cols 70 \
--rows 17 \
"$RECORDING_PATH"
sed "s@$TEMP_DIR@~@g" "$RECORDING_PATH" | \ sed "s@$TEMP_DIR@~@g" "$RECORDING_PATH" | \
svg-term \ svg-term \
--window \ --window \

94
.github/workflows/release.yml

@ -8,54 +8,52 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
create_pull_request: create_pull_request:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# set up # set up
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
run_install: false run_install: false
# pnpm # pnpm
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 16.x
cache: 'pnpm' cache: "pnpm"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
run: | run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3 - uses: actions/cache@v3
name: Setup pnpm cache name: Setup pnpm cache
with: with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install script dependencies - name: Install Asciinema
run: | run: |
sudo apt-get update pipx install asciinema
sudo apt-get install -y asciinema
- name: Install Node.js project dependencies
- name: Install Node.js project dependencies run: pnpm install
run: pnpm install
- name: Create Release Pull Request
- name: Create Release Pull Request uses: changesets/action@v1
uses: changesets/action@v1 with:
with: version: "pnpm version:prepare"
version: "pnpm version:prepare" env:
env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TERM: xterm
TERM: xterm

608
.github/workflows/rust.yml

@ -10,28 +10,31 @@ concurrency:
group: ci-${{ github.head_ref }} group: ci-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
env:
RUST_VERSION: "1.78"
jobs: jobs:
fmt: fmt:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: cargo fmt - name: cargo fmt
run: cargo fmt -- --check run: cargo fmt -- --check
clippy: clippy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: cargo clippy - name: cargo clippy
run: cargo clippy -- -D warnings run: cargo clippy -- -D warnings
unit_tests: unit_tests:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@ -39,125 +42,125 @@ jobs:
matrix: matrix:
os: [ubuntu-latest, macOS-latest, windows-latest] os: [ubuntu-latest, macOS-latest, windows-latest]
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Run tests - name: Run tests
run: cargo test run: cargo test
build_release: build_release:
runs-on: windows-latest runs-on: windows-latest
name: "Release build for Windows" name: "Release build for Windows"
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Build release binary - name: Build release binary
run: cargo build --release run: cargo build --release
env: env:
RUSTFLAGS: "-C target-feature=+crt-static" RUSTFLAGS: "-C target-feature=+crt-static"
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: fnm-windows name: fnm-windows
path: target/release/fnm.exe path: target/release/fnm.exe
build_macos_release: build_macos_release:
runs-on: macos-latest runs-on: macos-latest
name: "Release build for macOS" name: "Release build for macOS"
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Build release binary - name: Build release binary
run: cargo build --release run: cargo build --release
env: env:
LZMA_API_STATIC: "true" LZMA_API_STATIC: "true"
- name: Strip binary from debug symbols - name: Strip binary from debug symbols
run: strip target/release/fnm run: strip target/release/fnm
- name: List dynamically linked libraries - name: List dynamically linked libraries
run: otool -L target/release/fnm run: otool -L target/release/fnm
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: fnm-macos name: fnm-macos
path: target/release/fnm path: target/release/fnm
e2e_macos: e2e_macos:
runs-on: macos-latest runs-on: macos-latest
needs: [build_macos_release] needs: [build_macos_release]
name: "e2e/macos" name: "e2e/macos"
steps: steps:
- name: install necessary shells - name: install necessary shells
run: brew install fish zsh bash run: brew install fish zsh bash
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: fnm-macos name: fnm-macos
path: target/release path: target/release
- name: mark binary as executable - name: mark binary as executable
run: chmod +x target/release/fnm run: chmod +x target/release/fnm
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: 'pnpm' cache: "pnpm"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
run: | run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3 - uses: actions/cache@v3
name: Setup pnpm cache name: Setup pnpm cache
with: with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- run: pnpm install - run: pnpm install
- run: pnpm test - run: pnpm test
env: env:
FNM_TARGET_NAME: "release" FNM_TARGET_NAME: "release"
FORCE_COLOR: "1" FORCE_COLOR: "1"
e2e_windows: e2e_windows:
runs-on: windows-latest runs-on: windows-latest
needs: [build_release] needs: [build_release]
name: "e2e/windows" name: "e2e/windows"
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: fnm-windows name: fnm-windows
path: target/release path: target/release
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: 'pnpm' cache: "pnpm"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
run: | run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3 - uses: actions/cache@v3
name: Setup pnpm cache name: Setup pnpm cache
with: with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- run: pnpm install - run: pnpm install
- run: pnpm test - run: pnpm test
env: env:
FNM_TARGET_NAME: "release" FNM_TARGET_NAME: "release"
FORCE_COLOR: "1" FORCE_COLOR: "1"
# e2e_windows_debug: # e2e_windows_debug:
# runs-on: windows-latest # runs-on: windows-latest
@ -198,63 +201,63 @@ jobs:
needs: [build_static_linux_binary] needs: [build_static_linux_binary]
name: "e2e/linux" name: "e2e/linux"
steps: steps:
- name: install necessary shells - name: install necessary shells
run: sudo apt-get update && sudo apt-get install -y fish zsh bash run: sudo apt-get update && sudo apt-get install -y fish zsh bash
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: fnm-linux name: fnm-linux
path: target/release path: target/release
- name: mark binary as executable - name: mark binary as executable
run: chmod +x target/release/fnm run: chmod +x target/release/fnm
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: 'pnpm' cache: "pnpm"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
run: | run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3 - uses: actions/cache@v3
name: Setup pnpm cache name: Setup pnpm cache
with: with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- run: pnpm install - run: pnpm install
- run: pnpm test - run: pnpm test
env: env:
FNM_TARGET_NAME: "release" FNM_TARGET_NAME: "release"
FORCE_COLOR: "1" FORCE_COLOR: "1"
build_static_linux_binary: build_static_linux_binary:
name: "Build static Linux binary" name: "Build static Linux binary"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
targets: x86_64-unknown-linux-musl targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
key: static-linux-binary key: static-linux-binary
- name: Install musl tools - name: Install musl tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends musl-tools sudo apt-get install -y --no-install-recommends musl-tools
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Build release binary - name: Build release binary
run: cargo build --release --target x86_64-unknown-linux-musl run: cargo build --release --target x86_64-unknown-linux-musl
- name: Strip binary from debug symbols - name: Strip binary from debug symbols
run: strip target/x86_64-unknown-linux-musl/release/fnm run: strip target/x86_64-unknown-linux-musl/release/fnm
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: fnm-linux name: fnm-linux
path: target/x86_64-unknown-linux-musl/release/fnm path: target/x86_64-unknown-linux-musl/release/fnm
build_static_arm_binary: build_static_arm_binary:
name: "Build ARM binary" name: "Build ARM binary"
@ -273,156 +276,155 @@ jobs:
env: env:
RUST_TARGET: ${{ matrix.rust_target }} RUST_TARGET: ${{ matrix.rust_target }}
steps: steps:
- name: Set up QEMU - name: Set up QEMU
id: qemu id: qemu
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: hecrj/setup-rust-action@v1 - uses: hecrj/setup-rust-action@v1
with: with:
rust-version: stable rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
key: arm-binary-${{ matrix.arch }} key: arm-binary-${{ matrix.arch }}
- name: 'Download `cross` crate' - name: "Download `cross` crate"
run: cargo install cross run: cargo install cross
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: "Build release" - name: "Build release"
run: cross build --target $RUST_TARGET --release run: cross build --target $RUST_TARGET --release
- uses: uraimo/run-on-arch-action@v2.1.2 - uses: uraimo/run-on-arch-action@v2.1.2
name: Sanity test name: Sanity test
with: with:
arch: ${{matrix.docker_platform}} arch: ${{matrix.docker_platform}}
distro: ubuntu18.04 distro: ubuntu18.04
# Not required, but speeds up builds by storing container images in # Not required, but speeds up builds by storing container images in
# a GitHub package registry. # a GitHub package registry.
githubToken: ${{ github.token }} githubToken: ${{ github.token }}
env: | env: |
RUST_LOG: fnm=debug RUST_LOG: fnm=debug
dockerRunArgs: | dockerRunArgs: |
--volume "${PWD}/target/${{matrix.rust_target}}/release:/artifacts" --volume "${PWD}/target/${{matrix.rust_target}}/release:/artifacts"
# Set an output parameter `uname` for use in subsequent steps # Set an output parameter `uname` for use in subsequent steps
run: | run: |
echo "Hello from $(uname -a)" echo "Hello from $(uname -a)"
/artifacts/fnm --version /artifacts/fnm --version
echo "fnm install 12.0.0" echo "fnm install 12.0.0"
/artifacts/fnm install 12.0.0 /artifacts/fnm install 12.0.0
echo "fnm exec --using=12 -- node --version" echo "fnm exec --using=12 -- node --version"
/artifacts/fnm exec --using=12 -- node --version /artifacts/fnm exec --using=12 -- node --version
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: fnm-${{ matrix.arch }} name: fnm-${{ matrix.arch }}
path: target/${{ env.RUST_TARGET }}/release/fnm path: target/${{ env.RUST_TARGET }}/release/fnm
ensure_commands_markdown_is_up_to_date: ensure_commands_markdown_is_up_to_date:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Ensure command docs are up-to-date name: Ensure command docs are up-to-date
needs: [build_static_linux_binary] needs: [build_static_linux_binary]
steps: steps:
- name: install necessary shells - name: install necessary shells
run: sudo apt-get update && sudo apt-get install -y fish zsh bash run: sudo apt-get update && sudo apt-get install -y fish zsh bash
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: fnm-linux name: fnm-linux
path: target/release path: target/release
- name: mark binary as executable - name: mark binary as executable
run: chmod +x target/release/fnm run: chmod +x target/release/fnm
- name: install fnm as binary - name: install fnm as binary
run: | run: |
sudo install target/release/fnm /bin sudo install target/release/fnm /bin
fnm --version fnm --version
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: 'pnpm' cache: "pnpm"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
run: | run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3 - uses: actions/cache@v3
name: Setup pnpm cache name: Setup pnpm cache
with: with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- run: pnpm install - run: pnpm install
- name: Generate command markdown - name: Generate command markdown
run: | run: |
pnpm run generate-command-docs --check --binary-path=$(which fnm) pnpm run generate-command-docs --check --binary-path=$(which fnm)
run_e2e_benchmarks:
runs-on: ubuntu-latest
name: bench/linux
needs: [build_static_linux_binary]
permissions:
contents: write
pull-requests: write
steps:
- name: install necessary shells
run: sudo apt-get update && sudo apt-get install -y fish zsh bash hyperfine
- uses: actions/checkout@v3
- uses: actions/download-artifact@v3
with:
name: fnm-linux
path: target/release
- name: mark binary as executable
run: chmod +x target/release/fnm
- name: install fnm as binary
run: |
sudo install target/release/fnm /bin
fnm --version
- uses: pnpm/action-setup@v2.2.4
with:
run_install: false
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- run: pnpm install
- name: Run benchmarks
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHOULD_STORE: ${{ toJson(!github.event.pull_request) }}
id: benchmark
run: |
delimiter="$(openssl rand -hex 8)"
echo "markdown<<${delimiter}" >> "${GITHUB_OUTPUT}"
node benchmarks/run.mjs --store=$SHOULD_STORE >> "${GITHUB_OUTPUT}"
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
- name: Create a PR comment
if: ${{ github.event.pull_request }}
uses: thollander/actions-comment-pull-request@v2
with:
message: |
<!--benchy comment-->
## Linux Benchmarks for ${{ github.event.pull_request.head.sha }}
${{ steps.benchmark.outputs.markdown }}
comment_includes: '<!--benchy comment-->'
- name: Create a commit comment # TODO: use bnz
if: ${{ !github.event.pull_request }} # run_e2e_benchmarks:
uses: peter-evans/commit-comment@v2 # runs-on: ubuntu-latest
with: # name: bench/linux
body: | # needs: [build_static_linux_binary]
## Linux Benchmarks # permissions:
${{ steps.benchmark.outputs.markdown }} # contents: write
# pull-requests: write
# steps:
# - name: install necessary shells
# run: sudo apt-get update && sudo apt-get install -y fish zsh bash hyperfine
# - uses: actions/checkout@v3
# - uses: actions/download-artifact@v3
# with:
# name: fnm-linux
# path: target/release
# - name: mark binary as executable
# run: chmod +x target/release/fnm
# - name: install fnm as binary
# run: |
# sudo install target/release/fnm /bin
# fnm --version
# - uses: pnpm/action-setup@v2.2.4
# with:
# run_install: false
# - uses: actions/setup-node@v3
# with:
# node-version: 18.x
# cache: "pnpm"
# - name: Get pnpm store directory
# id: pnpm-cache
# run: |
# echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
# - uses: actions/cache@v3
# name: Setup pnpm cache
# with:
# path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
# key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
# restore-keys: |
# ${{ runner.os }}-pnpm-store-
# - run: pnpm install
# - name: Run benchmarks
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# SHOULD_STORE: ${{ toJson(!github.event.pull_request) }}
# id: benchmark
# run: |
# delimiter="$(openssl rand -hex 8)"
# echo "markdown<<${delimiter}" >> "${GITHUB_OUTPUT}"
# node benchmarks/run.mjs --store=$SHOULD_STORE >> "${GITHUB_OUTPUT}"
# echo "${delimiter}" >> "${GITHUB_OUTPUT}"
# - name: Create a PR comment
# if: ${{ github.event.pull_request }}
# uses: thollander/actions-comment-pull-request@v2
# with:
# message: |
# ## Linux Benchmarks for ${{ github.event.pull_request.head.sha }}
# ${{ steps.benchmark.outputs.markdown }}
# comment_tag: "benchy comment"
#
# - name: Create a commit comment
# if: ${{ !github.event.pull_request }}
# uses: peter-evans/commit-comment@v2
# with:
# body: |
# ## Linux Benchmarks
# ${{ steps.benchmark.outputs.markdown }}

1
.gitignore vendored

@ -7,3 +7,4 @@ feature_tests/.tmp
*.log *.log
/site/public /site/public
.vercel .vercel
.proxy

2
.node-version

@ -1 +1 @@
18.12.1 18.16.1

30
CHANGELOG.md

@ -1,5 +1,35 @@
## 1.31.0 (2022-02-16) ## 1.31.0 (2022-02-16)
## 1.35.1
### Patch Changes
- [#1010](https://github.com/Schniz/fnm/pull/1010) [`b185446`](https://github.com/Schniz/fnm/commit/b185446cfa08d4d6e2552db53937762a3c38d4db) Thanks [@quixoten](https://github.com/quixoten)! - fix: panic on `fnm completions`
## 1.35.0
### Minor Changes
- [#839](https://github.com/Schniz/fnm/pull/839) [`97be792`](https://github.com/Schniz/fnm/commit/97be792a4410d8f121e03a1f81f60c48cbfdee2c) Thanks [@amitdahan](https://github.com/amitdahan)! - Support resolving `engines.node` field via experimental `--resolve-engines` flag
### Patch Changes
- [#991](https://github.com/Schniz/fnm/pull/991) [`b19eb29`](https://github.com/Schniz/fnm/commit/b19eb29b26323f0b9fb427d3d9271c9cc13a58f8) Thanks [@amitdahan](https://github.com/amitdahan)! - Bump Clap 3 -> 4
## 1.34.0
### Minor Changes
- Add --corepack-enabled flag for automatically enabling corepack on fnm install ([#960](https://github.com/Schniz/fnm/pull/960))
### Patch Changes
- modernize tty check (#973 by @tottoto) ([`48b2611`](https://github.com/Schniz/fnm/commit/48b2611e4b1c205f07dcbd50f2fff436becb77c1))
- use cygwinpath to make the path posix-like on Windows Bash usage ([#960](https://github.com/Schniz/fnm/pull/960))
- capitalize "n" to show default (#963 by @Joshuahuahua) ([`48b2611`](https://github.com/Schniz/fnm/commit/48b2611e4b1c205f07dcbd50f2fff436becb77c1))
## 1.33.1 ## 1.33.1
### Patch Changes ### Patch Changes

1224
Cargo.lock generated

File diff suppressed because it is too large Load Diff

45
Cargo.toml

@ -1,6 +1,6 @@
[package] [package]
name = "fnm" name = "fnm"
version = "1.33.1" version = "1.35.1"
authors = ["Gal Schlezinger <gal@spitfire.co.il>"] authors = ["Gal Schlezinger <gal@spitfire.co.il>"]
edition = "2021" edition = "2021"
build = "build.rs" build = "build.rs"
@ -9,39 +9,40 @@ repository = "https://github.com/Schniz/fnm"
description = "Fast and simple Node.js version manager" description = "Fast and simple Node.js version manager"
[dependencies] [dependencies]
serde = { version = "1.0.149", features = ["derive"] } serde = { version = "1.0.166", features = ["derive"] }
clap = { version = "3.2.23", features = ["derive", "env"] } clap = { version = "4.3.10", features = ["derive", "env"] }
serde_json = "1.0.89" serde_json = "1.0.100"
chrono = { version = "0.4.23", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde", "now"], default-features = false }
tar = "0.4.38" tar = "0.4.38"
xz2 = "0.1.7" xz2 = "0.1.7"
node-semver = "2.1.0" node-semver = "2.1.0"
dirs = "4.0.0" dirs = "5.0.1"
colored = "2.0.0" colored = "2.0.4"
zip = "0.6.3" zip = "0.6.6"
tempfile = "3.3.0" tempfile = "3.6.0"
indoc = "1.0.8" indoc = "2.0.2"
log = "0.4.17" log = "0.4.19"
env_logger = "0.10.0" env_logger = "0.10.0"
atty = "0.2.14"
encoding_rs_io = "0.1.7" encoding_rs_io = "0.1.7"
reqwest = { version = "0.11.13", features = ["blocking", "json", "rustls-tls", "rustls-tls-native-roots", "brotli"], default-features = false } reqwest = { version = "0.11.18", features = ["blocking", "json", "rustls-tls", "rustls-tls-native-roots", "brotli"], default-features = false }
url = "2.3.1" url = "2.4.0"
sysinfo = "0.27.1" sysinfo = "0.29.3"
thiserror = "1.0.37" thiserror = "1.0.44"
clap_complete = "3.2.5" clap_complete = "4.3.1"
anyhow = "1.0.66" anyhow = "1.0.71"
indicatif = { version = "0.17.8", features = ["improved_unicode"] }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.3.0" pretty_assertions = "1.4.0"
duct = "0.13.6" duct = "0.13.6"
test-log = "0.2.11" test-log = "0.2.12"
http = "0.2.9"
[build-dependencies] [build-dependencies]
embed-resource = "1.8.0" embed-resource = "1.8.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
csv = "1.1.6" csv = "1.2.2"
junction = "0.2.0" junction = "1.0.0"
[features] [features]

54
README.md

@ -1,10 +1,10 @@
<h1 align="center"> <h1 align="center">
Fast Node Manager (<code>fnm</code>) Fast Node Manager (<code>fnm</code>)
<img alt="Amount of downloads" src="https://img.shields.io/github/downloads/Schniz/fnm/total.svg?style=flat" /> <img alt="Amount of downloads" src="https://img.shields.io/github/downloads/Schniz/fnm/total.svg?style=flat" />
<a href="https://github.com/Schniz/fnm/actions"><img src="https://img.shields.io/github/workflow/status/Schniz/fnm/Rust/master?label=workflow" alt="GitHub Actions workflow status" /></a> <a href="https://github.com/Schniz/fnm/actions"><img src="https://img.shields.io/github/actions/workflow/status/Schniz/fnm/rust.yml?branch=master&label=workflow" alt="GitHub Actions workflow status" /></a>
</h1> </h1>
> :rocket: Fast and simple Node.js version manager, built in Rust > 🚀 Fast and simple Node.js version manager, built in Rust
<div align="center"> <div align="center">
<img src="./docs/fnm.svg" alt="Blazing fast!"> <img src="./docs/fnm.svg" alt="Blazing fast!">
@ -12,13 +12,13 @@
## Features ## Features
:earth_americas: Cross-platform support (macOS, Windows, Linux) 🌎 Cross-platform support (macOS, Windows, Linux)
:sparkles: Single file, easy installation, instant startup Single file, easy installation, instant startup
:rocket: Built with speed in mind 🚀 Built with speed in mind
:open_file_folder: Works with `.node-version` and `.nvmrc` files 📂 Works with `.node-version` and `.nvmrc` files
## Installation ## Installation
@ -42,7 +42,7 @@ On other operating systems, upgrading `fnm` is almost the same as installing it.
`--install-dir` `--install-dir`
Set a custom directory for fnm to be installed. The default is `$HOME/.fnm`. Set a custom directory for fnm to be installed. The default is `$XDG_DATA_HOME/fnm` (if `$XDG_DATA_HOME` is not defined it falls back to `$HOME/.local/share/fnm` on linux and `$HOME/Library/Application Support/fnm` on MacOS).
`--skip-shell` `--skip-shell`
@ -68,6 +68,12 @@ brew install fnm
Then, [set up your shell for fnm](#shell-setup) Then, [set up your shell for fnm](#shell-setup)
#### Using Winget (Windows)
```sh
winget install Schniz.fnm
```
#### Using Scoop (Windows) #### Using Scoop (Windows)
```sh ```sh
@ -96,9 +102,10 @@ Then, [set up your shell for fnm](#shell-setup)
- Download the [latest release binary](https://github.com/Schniz/fnm/releases) for your system - Download the [latest release binary](https://github.com/Schniz/fnm/releases) for your system
- Make it available globally on `PATH` environment variable - Make it available globally on `PATH` environment variable
- Configure your shell profile: - [Set up your shell for fnm](#shell-setup)
### Removing ### Removing
To remove fnm (😢), just delete the `.fnm` folder in your home directory. You should also edit your shell configuration to remove any references to fnm (ie. read [Shell Setup](#shell-setup), and do the opposite). To remove fnm (😢), just delete the `.fnm` folder in your home directory. You should also edit your shell configuration to remove any references to fnm (ie. read [Shell Setup](#shell-setup), and do the opposite).
## Completions ## Completions
@ -114,7 +121,7 @@ Where `<SHELL>` can be one of the supported shells:
- `bash` - `bash`
- `zsh` - `zsh`
- `fish` - `fish`
- `powershell` - `power-shell`
Please follow your shell instructions to install them. Please follow your shell instructions to install them.
@ -122,9 +129,13 @@ Please follow your shell instructions to install them.
Environment variables need to be setup before you can start using fnm. Environment variables need to be setup before you can start using fnm.
This is done by evaluating the output of `fnm env`. This is done by evaluating the output of `fnm env`.
To automatically run `fnm use` when a directory contains a `.node-version` or `.nvmrc` file, add the `--use-on-cd` option to your shell setup.
> [!NOTE]
> Check out the [Configuration](./docs/configuration.md) section to enable highly
> recommended features, like automatic version switching.
Adding a `.node-version` to your project is as simple as: Adding a `.node-version` to your project is as simple as:
```bash ```bash
$ node --version $ node --version
v14.18.3 v14.18.3
@ -165,17 +176,24 @@ Add the following to the end of your profile file:
fnm env --use-on-cd | Out-String | Invoke-Expression fnm env --use-on-cd | Out-String | Invoke-Expression
``` ```
- On Windows, the profile is located at `~\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` or `$PROFILE`
- For macOS/Linux, the profile is located at `~/.config/powershell/Microsoft.PowerShell_profile.ps1` - For macOS/Linux, the profile is located at `~/.config/powershell/Microsoft.PowerShell_profile.ps1`
- On Windows to edit your profile you can run this in a PowerShell
```powershell
notepad $profile
```
#### Windows Command Prompt aka Batch aka WinCMD #### Windows Command Prompt aka Batch aka WinCMD
fnm is also supported but is not entirely covered. [You can set up a startup script](https://superuser.com/a/144348) and append the following line: fnm is also supported but is not entirely covered. [You can set up a startup script](https://superuser.com/a/144348) and append the following lines:
```batch ```batch
FOR /f "tokens=*" %i IN ('fnm env --use-on-cd') DO CALL %i @echo off
:: for /F will launch a new instance of cmd so we create a guard to prevent an infnite loop
if not defined FNM_AUTORUN_GUARD (
set "FNM_AUTORUN_GUARD=AutorunGuard"
FOR /f "tokens=*" %%z IN ('fnm env --use-on-cd') DO CALL %%z
)
``` ```
If you get the error `i was unexpected at this time`, please make a .cmd file as suggested by the first step in the Usage with Cmder secton add it's path to the `AutoRun` registry key.
#### Usage with Cmder #### Usage with Cmder
@ -183,18 +201,26 @@ Usage is very similar to the normal WinCMD install, apart for a few tweaks to al
Then you can do something like this: Then you can do something like this:
- Make a .cmd file to invoke it - Make a .cmd file to invoke it
```batch ```batch
:: %CMDER_ROOT%\bin\fnm_init.cmd :: %CMDER_ROOT%\bin\fnm_init.cmd
@echo off @echo off
FOR /f "tokens=*" %%z IN ('fnm env --use-on-cd') DO CALL %%z FOR /f "tokens=*" %%z IN ('fnm env --use-on-cd') DO CALL %%z
``` ```
- Add it to the startup script - Add it to the startup script
```batch ```batch
:: %CMDER_ROOT%\config\user_profile.cmd :: %CMDER_ROOT%\config\user_profile.cmd
call "%CMDER_ROOT%\bin\fnm_init.cmd" call "%CMDER_ROOT%\bin\fnm_init.cmd"
``` ```
You can replace `%CMDER_ROOT%` with any other convenient path too. You can replace `%CMDER_ROOT%` with any other convenient path too.
## [Configuration](./docs/configuration.md)
[See the available configuration options for an extended configuration documentation](./docs/configuration.md)
## [Usage](./docs/commands.md) ## [Usage](./docs/commands.md)
[See the available commands for an extended usage documentation](./docs/commands.md) [See the available commands for an extended usage documentation](./docs/commands.md)

2
benchmarks/basic/fnm

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
eval "$(fnm env --multi)" eval "$(fnm env --shell=bash)"
fnm install v10.11.0 fnm install v10.11.0
fnm use v10.11.0 fnm use v10.11.0
node -v node -v

6
benchmarks/basic/fnm_latest_master

@ -1,6 +0,0 @@
#!/bin/bash
eval "$(~/.fnm-latest/fnm env --multi)"
~/.fnm-latest/fnm install v10.11.0
~/.fnm-latest/fnm use v10.11.0
node -v

6
benchmarks/basic/fnm_reason

@ -1,6 +0,0 @@
#!/bin/bash
eval "$(~/.fnm/fnm env --multi)"
~/.fnm/fnm install v10.11.0
~/.fnm/fnm use v10.11.0
node -v

1115
docs/commands.md

File diff suppressed because it is too large Load Diff

72
docs/configuration.md

@ -0,0 +1,72 @@
# Configuration
fnm comes with many features out of the box. Some of them are not activated by default as they’re changing your shell default behavior, and some are just a feature flag to avoid breaking changes or just experimental until we decide it is worthwhile to introduce them.
All these features can be configured by adding flags to the `fnm env` call when initializing the shell. For instance, if your shell set up looks like `eval "$(fnm env)"` then you can add a flag to it by changing it to `eval "$(fnm env --my-flag=value)"`
Here’s a list of these features and capabilities:
### `--use-on-cd`
**✅ Highly recommended**
`--use-on-cd` appends output to `fnm env`'s output that will hook into your shell upon changing directories, and will switch the Node.js version based on the requirements of the current directory, based on `.node-version` or `.nvmrc` (or `packages.json#engines#node` if `--resolve-engines` was enabled).
This allows you do avoid thinking about `fnm use`, and only `cd <DIR>` to make it work.
### `--version-file-strategy=recursive`
**✅ Highly recommended**
Makes `fnm use` and `fnm install` take parent directories into account when looking for a version file ("dotfile")--when no argument was given.
So, let's say we have the following directory structure:
```
repo/
├── package.json
├── .node-version <- with content: `20.0.0`
└── packages/
└── my-package/ <- I am here
└── package.json
```
And I'm running the following command:
```sh-session
repo/packages/my-package$ fnm use
```
Then fnm will switch to Node.js v20.0.0.
Without the explicit flag, the value is set to `local`, which will not traverse the directory tree and therefore will print:
```sh-session
repo/packages/my-package$ fnm use
error: Can't find version in dotfiles. Please provide a version manually to the command.
```
### `--enable-corepack`
**🧪 Experimental**
Runs [`corepack enable`](https://nodejs.org/api/corepack.html#enabling-the-feature) when a new version of Node.js is installed. Experimental due to the fact Corepack itself is experimental.
### `--resolve-engines`
**🧪 Experimental**
Treats `package.json#engines#node` as a valid Node.js version file ("dotfile"). So, if you have a package.json with the following content:
```json
{
"engines": {
"node": ">=20 <21"
}
}
```
Then:
- `fnm install` will install the latest satisfying Node.js 20.x version available in the Node.js dist server
- `fnm use` will use the latest satisfying Node.js 20.x version available on your system, or prompt to install if no version matched.

2
docs/fnm.svg

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

82
e2e/__snapshots__/basic.test.ts.snap

@ -42,6 +42,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then
fi" fi"
`; `;
exports[`Bash package.json engines.node with semver range: Bash 1`] = `
"set -e
eval "$(fnm env --resolve-engines)"
fnm install
fnm use
if [ "$(node --version)" != "v6.17.0" ]; then
echo "Expected node version to be v6.17.0. Got $(node --version)"
exit 1
fi"
`;
exports[`Bash package.json engines.node: Bash 1`] = `
"set -e
eval "$(fnm env --resolve-engines)"
fnm install
fnm use
if [ "$(node --version)" != "v8.11.3" ]; then
echo "Expected node version to be v8.11.3. Got $(node --version)"
exit 1
fi"
`;
exports[`Bash resolves partial semver: Bash 1`] = ` exports[`Bash resolves partial semver: Bash 1`] = `
"set -e "set -e
eval "$(fnm env)" eval "$(fnm env)"
@ -118,6 +140,28 @@ if test "$____test____" != "v8.11.3"
end" end"
`; `;
exports[`Fish package.json engines.node with semver range: Fish 1`] = `
"fnm env --resolve-engines | source
fnm install
fnm use
set ____test____ (node --version)
if test "$____test____" != "v6.17.0"
echo "Expected node version to be v6.17.0. Got $____test____"
exit 1
end"
`;
exports[`Fish package.json engines.node: Fish 1`] = `
"fnm env --resolve-engines | source
fnm install
fnm use
set ____test____ (node --version)
if test "$____test____" != "v8.11.3"
echo "Expected node version to be v8.11.3. Got $____test____"
exit 1
end"
`;
exports[`Fish resolves partial semver: Fish 1`] = ` exports[`Fish resolves partial semver: Fish 1`] = `
"fnm env | source "fnm env | source
fnm install 6 fnm install 6
@ -182,6 +226,22 @@ fnm use v8.11.3
if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }"
`; `;
exports[`PowerShell package.json engines.node with semver range: PowerShell 1`] = `
"$ErrorActionPreference = "Stop"
fnm env --resolve-engines | Out-String | Invoke-Expression
fnm install
fnm use
if ( "$(node --version)" -ne "v6.17.0" ) { echo "Expected node version to be v6.17.0. Got $(node --version)"; exit 1 }"
`;
exports[`PowerShell package.json engines.node: PowerShell 1`] = `
"$ErrorActionPreference = "Stop"
fnm env --resolve-engines | Out-String | Invoke-Expression
fnm install
fnm use
if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }"
`;
exports[`PowerShell resolves partial semver: PowerShell 1`] = ` exports[`PowerShell resolves partial semver: PowerShell 1`] = `
"$ErrorActionPreference = "Stop" "$ErrorActionPreference = "Stop"
fnm env | Out-String | Invoke-Expression fnm env | Out-String | Invoke-Expression
@ -249,6 +309,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then
fi" fi"
`; `;
exports[`Zsh package.json engines.node with semver range: Zsh 1`] = `
"set -e
eval "$(fnm env --resolve-engines)"
fnm install
fnm use
if [ "$(node --version)" != "v6.17.0" ]; then
echo "Expected node version to be v6.17.0. Got $(node --version)"
exit 1
fi"
`;
exports[`Zsh package.json engines.node: Zsh 1`] = `
"set -e
eval "$(fnm env --resolve-engines)"
fnm install
fnm use
if [ "$(node --version)" != "v8.11.3" ]; then
echo "Expected node version to be v8.11.3. Got $(node --version)"
exit 1
fi"
`;
exports[`Zsh resolves partial semver: Zsh 1`] = ` exports[`Zsh resolves partial semver: Zsh 1`] = `
"set -e "set -e
eval "$(fnm env)" eval "$(fnm env)"

28
e2e/__snapshots__/corepack.test.ts.snap

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Bash installs corepack: Bash 1`] = `
"set -e
eval "$(fnm env --corepack-enabled)"
fnm install 18
fnm exec --using=18 node test-pnpm-corepack.js"
`;
exports[`Fish installs corepack: Fish 1`] = `
"fnm env --corepack-enabled | source
fnm install 18
fnm exec --using=18 node test-pnpm-corepack.js"
`;
exports[`PowerShell installs corepack: PowerShell 1`] = `
"$ErrorActionPreference = "Stop"
fnm env --corepack-enabled | Out-String | Invoke-Expression
fnm install 18
fnm exec --using=18 node test-pnpm-corepack.js"
`;
exports[`Zsh installs corepack: Zsh 1`] = `
"set -e
eval "$(fnm env --corepack-enabled)"
fnm install 18
fnm exec --using=18 node test-pnpm-corepack.js"
`;

28
e2e/basic.test.ts

@ -40,6 +40,34 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) {
.execute(shell) .execute(shell)
}) })
test(`package.json engines.node`, async () => {
await writeFile(
join(testCwd(), "package.json"),
JSON.stringify({ engines: { node: "8.11.3" } })
)
await script(shell)
.then(shell.env({ resolveEngines: true }))
.then(shell.call("fnm", ["install"]))
.then(shell.call("fnm", ["use"]))
.then(testNodeVersion(shell, "v8.11.3"))
.takeSnapshot(shell)
.execute(shell)
})
test(`package.json engines.node with semver range`, async () => {
await writeFile(
join(testCwd(), "package.json"),
JSON.stringify({ engines: { node: "^6 < 6.17.1" } })
)
await script(shell)
.then(shell.env({ resolveEngines: true }))
.then(shell.call("fnm", ["install"]))
.then(shell.call("fnm", ["use"]))
.then(testNodeVersion(shell, "v6.17.0"))
.takeSnapshot(shell)
.execute(shell)
})
test(`use on cd`, async () => { test(`use on cd`, async () => {
await mkdir(join(testCwd(), "subdir"), { recursive: true }) await mkdir(join(testCwd(), "subdir"), { recursive: true })
await writeFile(join(testCwd(), "subdir", ".node-version"), "v12.22.12") await writeFile(join(testCwd(), "subdir", ".node-version"), "v12.22.12")

54
e2e/corepack.test.ts

@ -0,0 +1,54 @@
import fs from "fs"
import { script } from "./shellcode/script.js"
import { Bash, Fish, PowerShell, Zsh } from "./shellcode/shells.js"
import describe from "./describe.js"
import path from "path"
import testCwd from "./shellcode/test-cwd.js"
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const whichPath = require.resolve("which")
const nodescript = `
const which = require(${JSON.stringify(whichPath)});
const pnpmBinary = which.sync('pnpm')
const nodeBinary = which.sync('node')
const binPath = require('path').dirname(nodeBinary);
if (!pnpmBinary.includes(binPath)) {
console.log('pnpm not found in current Node.js bin', { binPath, pnpmBinary });
process.exit(1);
}
const scriptContents = require('fs').readFileSync(pnpmBinary, 'utf8');
console.log('scriptContents', scriptContents)
if (!scriptContents.includes('corepack')) {
console.log('corepack not found in pnpm script');
process.exit(1);
}
`
for (const shell of [Bash, Fish, PowerShell, Zsh]) {
describe(shell, () => {
test(`installs corepack`, async () => {
const cwd = testCwd()
const filepath = path.join(cwd, "test-pnpm-corepack.js")
fs.writeFileSync(filepath, nodescript)
await script(shell)
.then(shell.env({ corepackEnabled: true }))
.then(shell.call("fnm", ["install", "18"]))
.then(
shell.call("fnm", [
"exec",
"--using=18",
"node",
"test-pnpm-corepack.js",
])
)
.takeSnapshot(shell)
// .addExtraEnvVar("RUST_LOG", "fnm=debug")
.execute(shell)
})
})
}

2
e2e/env.test.ts

@ -26,6 +26,8 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) {
FNM_LOGLEVEL: "info", FNM_LOGLEVEL: "info",
FNM_MULTISHELL_PATH: expect.any(String), FNM_MULTISHELL_PATH: expect.any(String),
FNM_NODE_DIST_MIRROR: expect.any(String), FNM_NODE_DIST_MIRROR: expect.any(String),
FNM_RESOLVE_ENGINES: "false",
FNM_COREPACK_ENABLED: "false",
FNM_VERSION_FILE_STRATEGY: "local", FNM_VERSION_FILE_STRATEGY: "local",
}) })
} }

21
e2e/shellcode/script.ts

@ -15,7 +15,8 @@ class Script {
private readonly config: { private readonly config: {
fnmDir: string fnmDir: string
}, },
private readonly lines: ScriptLine[] private readonly lines: ScriptLine[],
private readonly extraEnvVars: Record<string, string> = {}
) {} ) {}
then(line: ScriptLine): Script { then(line: ScriptLine): Script {
return new Script(this.config, [...this.lines, line]) return new Script(this.config, [...this.lines, line])
@ -28,6 +29,14 @@ class Script {
return this return this
} }
addExtraEnvVar(name: string, value: string): this {
return new Script(
this.config,
this.lines,
Object.assign({}, this.extraEnvVars, { [name]: value })
) as this
}
async execute( async execute(
shell: Pick< shell: Pick<
Shell, Shell,
@ -54,11 +63,13 @@ class Script {
cwd: testCwd(), cwd: testCwd(),
env: (() => { env: (() => {
const newProcessEnv: Record<string, string> = { const newProcessEnv: Record<string, string> = {
...this.extraEnvVars,
...removeAllFnmEnvVars(process.env), ...removeAllFnmEnvVars(process.env),
PATH: [testBinDir(), fnmTargetDir(), process.env.PATH] PATH: [testBinDir(), fnmTargetDir(), process.env.PATH]
.filter(Boolean) .filter(Boolean)
.join(path.delimiter), .join(path.delimiter),
FNM_DIR: this.config.fnmDir, FNM_DIR: this.config.fnmDir,
FNM_NODE_DIST_MIRROR: "http://localhost:8080",
} }
delete newProcessEnv.NODE_OPTIONS delete newProcessEnv.NODE_OPTIONS
@ -119,8 +130,8 @@ function streamOutputsAndBuffer(child: ExecaChildProcess) {
if (child.stdout) { if (child.stdout) {
child.stdout.on("data", (data) => { child.stdout.on("data", (data) => {
const line = data.toString().trim() const lines = data.toString().trim().split(/\r?\n/)
if (line) { for (const line of lines) {
process.stdout.write(`${stdoutPrefix}${line}\n`) process.stdout.write(`${stdoutPrefix}${line}\n`)
} }
stdout.push(data.toString()) stdout.push(data.toString())
@ -129,8 +140,8 @@ function streamOutputsAndBuffer(child: ExecaChildProcess) {
if (child.stderr) { if (child.stderr) {
child.stderr.on("data", (data) => { child.stderr.on("data", (data) => {
const line = data.toString().trim() const lines = data.toString().trim().split(/\r?\n/)
if (line) { for (const line of lines) {
process.stdout.write(`${stderrPrefix}${line}\n`) process.stdout.write(`${stderrPrefix}${line}\n`)
} }
stderr.push(data.toString()) stderr.push(data.toString())

2
e2e/shellcode/shells.ts

@ -61,7 +61,7 @@ export const PowerShell = {
...define<Shell>({ ...define<Shell>({
binaryName: () => "pwsh", binaryName: () => "pwsh",
forceFile: ".ps1", forceFile: ".ps1",
currentlySupported: () => true, currentlySupported: () => process.platform === "win32",
name: () => "PowerShell", name: () => "PowerShell",
launchArgs: () => ["-NoProfile"], launchArgs: () => ["-NoProfile"],
escapeText: (x) => x, escapeText: (x) => x,

11
e2e/shellcode/shells/cmdEnv.ts

@ -1,14 +1,21 @@
import { ScriptLine, define } from "./types.js" import { ScriptLine, define } from "./types.js"
type EnvConfig = { useOnCd: boolean; logLevel: string } type EnvConfig = {
useOnCd: boolean
logLevel: string
corepackEnabled: boolean
resolveEngines: boolean
}
export type HasEnv = { env(cfg: Partial<EnvConfig>): ScriptLine } export type HasEnv = { env(cfg: Partial<EnvConfig>): ScriptLine }
function stringify(envConfig: Partial<EnvConfig> = {}) { function stringify(envConfig: Partial<EnvConfig> = {}) {
const { useOnCd, logLevel } = envConfig const { useOnCd, logLevel, corepackEnabled, resolveEngines } = envConfig
return [ return [
`fnm env`, `fnm env`,
useOnCd && "--use-on-cd", useOnCd && "--use-on-cd",
logLevel && `--log-level=${logLevel}`, logLevel && `--log-level=${logLevel}`,
corepackEnabled && "--corepack-enabled",
resolveEngines && `--resolve-engines`,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")

3
jest.config.cjs

@ -1,9 +1,12 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */ /** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = { module.exports = {
preset: "ts-jest/presets/default-esm", preset: "ts-jest/presets/default-esm",
globalSetup: "./jest.global-setup.js",
globalTeardown: "./jest.global-teardown.js",
testEnvironment: "node", testEnvironment: "node",
testTimeout: 120000, testTimeout: 120000,
extensionsToTreatAsEsm: [".ts"], extensionsToTreatAsEsm: [".ts"],
testPathIgnorePatterns: ["/node_modules/", "/dist/", "/target/"],
moduleNameMapper: { moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1", "^(\\.{1,2}/.*)\\.js$": "$1",
"#ansi-styles": "ansi-styles/index.js", "#ansi-styles": "ansi-styles/index.js",

5
jest.global-setup.js

@ -0,0 +1,5 @@
import { server } from "./tests/proxy-server/index.mjs"
export default function () {
server.listen(8080)
}

5
jest.global-teardown.js

@ -0,0 +1,5 @@
import { server } from "./tests/proxy-server/index.mjs"
export default () => {
server.close()
}

17
package.json

@ -1,11 +1,11 @@
{ {
"name": "fnm", "name": "fnm",
"version": "1.33.1", "version": "1.35.1",
"private": true, "private": true,
"repository": "git@github.com:Schniz/fnm.git", "repository": "git@github.com:Schniz/fnm.git",
"author": "Gal Schlezinger <gal@spitfire.co.il>", "author": "Gal Schlezinger <gal@spitfire.co.il>",
"type": "module", "type": "module",
"packageManager": "pnpm@7.18.2", "packageManager": "pnpm@8.6.5",
"license": "GPLv3", "license": "GPLv3",
"scripts": { "scripts": {
"test": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest", "test": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest",
@ -22,26 +22,27 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "2.26.0", "@changesets/cli": "2.26.2",
"@svitejs/changesets-changelog-github-compact": "1.0.0", "@changesets/changelog-github": "0.4.8",
"@types/jest": "^29.2.3", "@types/jest": "^29.2.3",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/shell-escape": "^0.2.1", "@types/shell-escape": "^0.2.1",
"chalk": "^5.1.2", "chalk": "^5.1.2",
"cmd-ts": "0.11.0", "cmd-ts": "0.13.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"execa": "6.1.0", "execa": "7.2.0",
"jest": "^29.3.1", "jest": "^29.3.1",
"lerna-changelog": "2.2.0", "lerna-changelog": "2.2.0",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
"prettier": "2.8.1", "prettier": "3.0.2",
"pv": "1.0.1", "pv": "1.0.1",
"shell-escape": "^0.2.0", "shell-escape": "^0.2.0",
"svg-term-cli": "2.1.1", "svg-term-cli": "2.1.1",
"toml": "3.0.0", "toml": "3.0.0",
"ts-dedent": "^2.2.0", "ts-dedent": "^2.2.0",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^4.8.4", "typescript": "^5.0.0",
"which": "^3.0.1",
"zod": "^3.19.1" "zod": "^3.19.1"
}, },
"prettier": { "prettier": {

2937
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

3
rust-toolchain.toml

@ -0,0 +1,3 @@
[toolchain]
channel = "1.78"
components = ["rustfmt", "clippy"]

9
src/arch.rs

@ -4,6 +4,7 @@ use crate::version::Version;
pub enum Arch { pub enum Arch {
X86, X86,
X64, X64,
X64Musl,
Arm64, Arm64,
Armv7l, Armv7l,
Ppc64le, Ppc64le,
@ -16,16 +17,16 @@ pub enum Arch {
pub fn get_safe_arch<'a>(arch: &'a Arch, version: &Version) -> &'a Arch { pub fn get_safe_arch<'a>(arch: &'a Arch, version: &Version) -> &'a Arch {
use crate::system_info::{platform_arch, platform_name}; use crate::system_info::{platform_arch, platform_name};
return match (platform_name(), platform_arch(), version) { match (platform_name(), platform_arch(), version) {
("darwin", "arm64", Version::Semver(v)) if v.major < 16 => &Arch::X64, ("darwin", "arm64", Version::Semver(v)) if v.major < 16 => &Arch::X64,
_ => arch, _ => arch,
}; }
} }
#[cfg(windows)] #[cfg(windows)]
/// handle common case: Apple Silicon / Node < 16 /// handle common case: Apple Silicon / Node < 16
pub fn get_safe_arch<'a>(arch: &'a Arch, _version: &Version) -> &'a Arch { pub fn get_safe_arch<'a>(arch: &'a Arch, _version: &Version) -> &'a Arch {
return &arch; arch
} }
impl Default for Arch { impl Default for Arch {
@ -43,6 +44,7 @@ impl std::str::FromStr for Arch {
match s { match s {
"x86" => Ok(Arch::X86), "x86" => Ok(Arch::X86),
"x64" => Ok(Arch::X64), "x64" => Ok(Arch::X64),
"x64-musl" => Ok(Arch::X64Musl),
"arm64" => Ok(Arch::Arm64), "arm64" => Ok(Arch::Arm64),
"armv7l" => Ok(Arch::Armv7l), "armv7l" => Ok(Arch::Armv7l),
"ppc64le" => Ok(Arch::Ppc64le), "ppc64le" => Ok(Arch::Ppc64le),
@ -58,6 +60,7 @@ impl std::fmt::Display for Arch {
let arch_str = match self { let arch_str = match self {
Arch::X86 => String::from("x86"), Arch::X86 => String::from("x86"),
Arch::X64 => String::from("x64"), Arch::X64 => String::from("x64"),
Arch::X64Musl => String::from("x64-musl"),
Arch::Arm64 => String::from("arm64"), Arch::Arm64 => String::from("arm64"),
Arch::Armv7l => String::from("armv7l"), Arch::Armv7l => String::from("armv7l"),
Arch::Ppc64le => String::from("ppc64le"), Arch::Ppc64le => String::from("ppc64le"),

4
src/archive/mod.rs

@ -3,5 +3,9 @@ pub mod tar_xz;
pub mod zip; pub mod zip;
pub use self::extract::{Error, Extract}; pub use self::extract::{Error, Extract};
#[cfg(unix)]
pub use self::tar_xz::TarXz; pub use self::tar_xz::TarXz;
#[cfg(windows)]
pub use self::zip::Zip; pub use self::zip::Zip;

34
src/commands/completions.rs

@ -1,16 +1,16 @@
use super::command::Command; use super::command::Command;
use crate::cli::Cli;
use crate::config::FnmConfig; use crate::config::FnmConfig;
use crate::shell::{infer_shell, AVAILABLE_SHELLS}; use crate::shell::{infer_shell, Shell};
use clap::{IntoApp, Parser}; use crate::{cli::Cli, shell::Shells};
use clap_complete::{Generator, Shell}; use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{Generator, Shell as ClapShell};
use thiserror::Error; use thiserror::Error;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub struct Completions { pub struct Completions {
/// The shell syntax to use. Infers when missing. /// The shell syntax to use. Infers when missing.
#[clap(long)] #[clap(long)]
shell: Option<Shell>, shell: Option<Shells>,
} }
impl Command for Completions { impl Command for Completions {
@ -18,11 +18,14 @@ impl Command for Completions {
fn apply(self, _config: &FnmConfig) -> Result<(), Self::Error> { fn apply(self, _config: &FnmConfig) -> Result<(), Self::Error> {
let mut stdio = std::io::stdout(); let mut stdio = std::io::stdout();
let shell = self let shell: Box<dyn Shell> = self
.shell .shell
.map(Into::into)
.or_else(|| infer_shell().map(Into::into)) .or_else(|| infer_shell().map(Into::into))
.ok_or(Error::CantInferShell)?; .ok_or(Error::CantInferShell)?;
let app = Cli::command(); let shell: ClapShell = shell.into();
let mut app = Cli::command();
app.build();
shell.generate(&app, &mut stdio); shell.generate(&app, &mut stdio);
Ok(()) Ok(())
} }
@ -41,9 +44,24 @@ pub enum Error {
} }
fn shells_as_string() -> String { fn shells_as_string() -> String {
AVAILABLE_SHELLS Shells::value_variants()
.iter() .iter()
.map(|x| format!("* {x}")) .map(|x| format!("* {x}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(windows))]
fn test_smoke() {
let config = FnmConfig::default();
Completions {
shell: Some(Shells::Bash),
}
.call(config);
}
}

38
src/commands/env.rs

@ -4,7 +4,8 @@ use crate::directories;
use crate::fs::symlink_dir; use crate::fs::symlink_dir;
use crate::outln; use crate::outln;
use crate::path_ext::PathExt; use crate::path_ext::PathExt;
use crate::shell::{infer_shell, Shell, AVAILABLE_SHELLS}; use crate::shell::{infer_shell, Shell, Shells};
use clap::ValueEnum;
use colored::Colorize; use colored::Colorize;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Debug; use std::fmt::Debug;
@ -14,8 +15,7 @@ use thiserror::Error;
pub struct Env { pub struct Env {
/// The shell syntax to use. Infers when missing. /// The shell syntax to use. Infers when missing.
#[clap(long)] #[clap(long)]
#[clap(possible_values = AVAILABLE_SHELLS)] shell: Option<Shells>,
shell: Option<Box<dyn Shell>>,
/// Print JSON instead of shell commands. /// Print JSON instead of shell commands.
#[clap(long, conflicts_with = "shell")] #[clap(long, conflicts_with = "shell")]
json: bool, json: bool,
@ -44,7 +44,7 @@ fn make_symlink(config: &FnmConfig) -> Result<std::path::PathBuf, Error> {
} }
match symlink_dir(config.default_version_dir(), &temp_dir) { match symlink_dir(config.default_version_dir(), &temp_dir) {
Ok(_) => Ok(temp_dir), Ok(()) => Ok(temp_dir),
Err(source) => Err(Error::CantCreateSymlink { source, temp_dir }), Err(source) => Err(Error::CantCreateSymlink { source, temp_dir }),
} }
} }
@ -64,17 +64,16 @@ impl Command for Env {
} }
let multishell_path = make_symlink(config)?; let multishell_path = make_symlink(config)?;
let multishell_path_str = multishell_path.to_str().unwrap().to_owned();
let binary_path = if cfg!(windows) { let binary_path = if cfg!(windows) {
multishell_path.clone() multishell_path
} else { } else {
multishell_path.join("bin") multishell_path.join("bin")
}; };
let env_vars = HashMap::from([ let env_vars = HashMap::from([
( ("FNM_MULTISHELL_PATH", multishell_path_str),
"FNM_MULTISHELL_PATH",
multishell_path.to_str().unwrap().to_owned(),
),
( (
"FNM_VERSION_FILE_STRATEGY", "FNM_VERSION_FILE_STRATEGY",
config.version_file_strategy().as_str().to_owned(), config.version_file_strategy().as_str().to_owned(),
@ -91,6 +90,11 @@ impl Command for Env {
"FNM_NODE_DIST_MIRROR", "FNM_NODE_DIST_MIRROR",
config.node_dist_mirror.as_str().to_owned(), config.node_dist_mirror.as_str().to_owned(),
), ),
(
"FNM_COREPACK_ENABLED",
config.corepack_enabled().to_string(),
),
("FNM_RESOLVE_ENGINES", config.resolve_engines().to_string()),
("FNM_ARCH", config.arch.to_string()), ("FNM_ARCH", config.arch.to_string()),
]); ]);
@ -101,6 +105,7 @@ impl Command for Env {
let shell: Box<dyn Shell> = self let shell: Box<dyn Shell> = self
.shell .shell
.map(Into::into)
.or_else(infer_shell) .or_else(infer_shell)
.ok_or(Error::CantInferShell)?; .ok_or(Error::CantInferShell)?;
@ -145,7 +150,7 @@ pub enum Error {
} }
fn shells_as_string() -> String { fn shells_as_string() -> String {
AVAILABLE_SHELLS Shells::value_variants()
.iter() .iter()
.map(|x| format!("* {x}")) .map(|x| format!("* {x}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -158,16 +163,13 @@ mod tests {
#[test] #[test]
fn test_smoke() { fn test_smoke() {
use crate::shell;
let config = FnmConfig::default(); let config = FnmConfig::default();
let shell: Box<dyn Shell> = if cfg!(windows) {
Box::from(shell::WindowsCmd)
} else {
Box::from(shell::Bash)
};
Env { Env {
shell: Some(shell), #[cfg(windows)]
..Env::default() shell: Some(Shells::Cmd),
#[cfg(not(windows))]
shell: Some(Shells::Bash),
..Default::default()
} }
.call(config); .call(config);
} }

31
src/commands/exec.rs

@ -23,6 +23,25 @@ pub struct Exec {
arguments: Vec<String>, arguments: Vec<String>,
} }
impl Exec {
pub(crate) fn new_for_version(
version: &crate::version::Version,
cmd: &str,
arguments: &[&str],
) -> Self {
let reader = UserVersionReader::Direct(UserVersion::Full(version.clone()));
let args: Vec<_> = std::iter::once(cmd)
.chain(arguments.iter().copied())
.map(String::from)
.collect();
Self {
version: Some(reader),
using_file: false,
arguments: args,
}
}
}
impl Cmd for Exec { impl Cmd for Exec {
type Error = Error; type Error = Error;
@ -69,6 +88,8 @@ impl Cmd for Exec {
.map_err(|source| Error::CantAddPathToEnvironment { source })? .map_err(|source| Error::CantAddPathToEnvironment { source })?
}; };
log::debug!("Running {} with PATH={:?}", binary, path_env);
let exit_status = Command::new(binary) let exit_status = Command::new(binary)
.args(arguments) .args(arguments)
.stdin(Stdio::inherit()) .stdin(Stdio::inherit())
@ -76,7 +97,10 @@ impl Cmd for Exec {
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.env("PATH", path_env) .env("PATH", path_env)
.spawn() .spawn()
.expect("Can't spawn program") .map_err(|source| Error::CantSpawnProgram {
source,
binary: binary.to_string(),
})?
.wait() .wait()
.expect("Failed to grab exit code"); .expect("Failed to grab exit code");
@ -87,6 +111,11 @@ impl Cmd for Exec {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("Can't spawn program: {source}\nMaybe the program {} does not exist on not available in PATH?", binary.bold())]
CantSpawnProgram {
source: std::io::Error,
binary: String,
},
#[error("Can't read path environment variable")] #[error("Can't read path environment variable")]
CantReadPathVariable, CantReadPathVariable,
#[error("Can't add path to environment variable: {}", source)] #[error("Can't add path to environment variable: {}", source)]

44
src/commands/install.rs

@ -1,9 +1,11 @@
use super::command::Command;
use crate::alias::create_alias; use crate::alias::create_alias;
use crate::arch::get_safe_arch; use crate::arch::get_safe_arch;
use crate::config::FnmConfig; use crate::config::FnmConfig;
use crate::downloader::{install_node_dist, Error as DownloaderError}; use crate::downloader::{install_node_dist, Error as DownloaderError};
use crate::lts::LtsType; use crate::lts::LtsType;
use crate::outln; use crate::outln;
use crate::progress::ProgressConfig;
use crate::remote_node_index; use crate::remote_node_index;
use crate::user_version::UserVersion; use crate::user_version::UserVersion;
use crate::version::Version; use crate::version::Version;
@ -24,6 +26,12 @@ pub struct Install {
/// Install latest version /// Install latest version
#[clap(long, conflicts_with_all = &["version", "lts"])] #[clap(long, conflicts_with_all = &["version", "lts"])]
pub latest: bool, pub latest: bool,
/// Show an interactive progress bar for the download
/// status.
#[clap(long, default_value_t)]
#[arg(value_enum)]
pub progress: ProgressConfig,
} }
impl Install { impl Install {
@ -33,27 +41,31 @@ impl Install {
version: v, version: v,
lts: false, lts: false,
latest: false, latest: false,
..
} => Ok(v), } => Ok(v),
Self { Self {
version: None, version: None,
lts: true, lts: true,
latest: false, latest: false,
..
} => Ok(Some(UserVersion::Full(Version::Lts(LtsType::Latest)))), } => Ok(Some(UserVersion::Full(Version::Lts(LtsType::Latest)))),
Self { Self {
version: None, version: None,
lts: false, lts: false,
latest: true, latest: true,
..
} => Ok(Some(UserVersion::Full(Version::Latest))), } => Ok(Some(UserVersion::Full(Version::Latest))),
_ => Err(Error::TooManyVersionsProvided), _ => Err(Error::TooManyVersionsProvided),
} }
} }
} }
impl super::command::Command for Install { impl Command for Install {
type Error = Error; type Error = Error;
fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> { fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
let current_dir = std::env::current_dir().unwrap(); let current_dir = std::env::current_dir().unwrap();
let show_progress = self.progress.enabled(config);
let current_version = self let current_version = self
.version()? .version()?
@ -130,11 +142,13 @@ impl super::command::Command for Install {
&config.node_dist_mirror, &config.node_dist_mirror,
config.installations_dir(), config.installations_dir(),
safe_arch, safe_arch,
show_progress,
) { ) {
Err(err @ DownloaderError::VersionAlreadyInstalled { .. }) => { Err(err @ DownloaderError::VersionAlreadyInstalled { .. }) => {
outln!(config, Error, "{} {}", "warning:".bold().yellow(), err); outln!(config, Error, "{} {}", "warning:".bold().yellow(), err);
} }
other_err => other_err.map_err(|source| Error::DownloadError { source })?, Err(source) => Err(Error::DownloadError { source })?,
Ok(()) => {}
}; };
if let UserVersion::Full(Version::Latest) = current_version { if let UserVersion::Full(Version::Latest) = current_version {
@ -147,6 +161,11 @@ impl super::command::Command for Install {
create_alias(config, &alias_name, &version)?; create_alias(config, &alias_name, &version)?;
} }
if config.corepack_enabled() {
outln!(config, Info, "Enabling corepack for {}", version_str.cyan());
enable_corepack(&version, config)?;
}
if let UserVersion::Full(Version::Lts(lts_type)) = current_version { if let UserVersion::Full(Version::Lts(lts_type)) = current_version {
let alias_name = Version::Lts(lts_type).v_str(); let alias_name = Version::Lts(lts_type).v_str();
debug!( debug!(
@ -166,6 +185,19 @@ impl super::command::Command for Install {
} }
} }
fn enable_corepack(version: &Version, config: &FnmConfig) -> Result<(), Error> {
let corepack_path = version.installation_path(config);
let corepack_path = if cfg!(windows) {
corepack_path.join("corepack.cmd")
} else {
corepack_path.join("bin").join("corepack")
};
super::exec::Exec::new_for_version(version, corepack_path.to_str().unwrap(), &["enable"])
.apply(config)
.map_err(|source| Error::CorepackError { source })?;
Ok(())
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("Can't download the requested binary: {}", source)] #[error("Can't download the requested binary: {}", source)]
@ -175,6 +207,11 @@ pub enum Error {
#[from] #[from]
source: std::io::Error, source: std::io::Error,
}, },
#[error("Can't enable corepack: {source}")]
CorepackError {
#[from]
source: super::exec::Error,
},
#[error("Can't find version in dotfiles. Please provide a version manually to the command.")] #[error("Can't find version in dotfiles. Please provide a version manually to the command.")]
CantInferVersion, CantInferVersion,
#[error("Having a hard time listing the remote versions: {}", source)] #[error("Having a hard time listing the remote versions: {}", source)]
@ -196,7 +233,6 @@ pub enum Error {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::command::Command;
use super::*; use super::*;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::str::FromStr; use std::str::FromStr;
@ -211,6 +247,7 @@ mod tests {
version: UserVersion::from_str("12.0.0").ok(), version: UserVersion::from_str("12.0.0").ok(),
lts: false, lts: false,
latest: false, latest: false,
progress: ProgressConfig::Never,
} }
.apply(&config) .apply(&config)
.expect("Can't install"); .expect("Can't install");
@ -236,6 +273,7 @@ mod tests {
version: None, version: None,
lts: false, lts: false,
latest: true, latest: true,
progress: ProgressConfig::Never,
} }
.apply(&config) .apply(&config)
.expect("Can't install"); .expect("Can't install");

67
src/commands/ls_remote.rs

@ -1,20 +1,79 @@
use crate::config::FnmConfig; use crate::config::FnmConfig;
use crate::remote_node_index; use crate::remote_node_index;
use crate::user_version::UserVersion;
use colored::Colorize;
use thiserror::Error; use thiserror::Error;
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
pub struct LsRemote {} pub struct LsRemote {
/// Filter versions by a user-defined version or a semver range
#[arg(long)]
filter: Option<UserVersion>,
/// Show only LTS versions (optionally filter by LTS codename)
#[arg(long)]
#[allow(clippy::option_option)]
lts: Option<Option<String>>,
/// Version sorting order
#[arg(long, default_value = "asc")]
sort: SortingMethod,
/// Only show the latest matching version
#[arg(long)]
latest: bool,
}
#[derive(clap::ValueEnum, Clone, Debug, PartialEq)]
pub enum SortingMethod {
#[clap(name = "desc")]
/// Sort versions in descending order (latest to earliest)
Descending,
#[clap(name = "asc")]
/// Sort versions in ascending order (earliest to latest)
Ascending,
}
impl super::command::Command for LsRemote { impl super::command::Command for LsRemote {
type Error = Error; type Error = Error;
fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> { fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
let all_versions = remote_node_index::list(&config.node_dist_mirror)?; let mut all_versions = remote_node_index::list(&config.node_dist_mirror)?;
if let Some(lts) = &self.lts {
match lts {
Some(codename) => all_versions.retain(|v| {
v.lts
.as_ref()
.is_some_and(|v_lts| v_lts.eq_ignore_ascii_case(codename))
}),
None => all_versions.retain(|v| v.lts.is_some()),
};
}
if let Some(filter) = &self.filter {
all_versions.retain(|v| filter.matches(&v.version, config));
}
if self.latest {
all_versions.truncate(1);
}
all_versions.sort_by_key(|v| v.version.clone());
if let SortingMethod::Descending = self.sort {
all_versions.reverse();
}
if all_versions.is_empty() {
eprintln!("{}", "No versions were found!".red());
return Ok(());
}
for version in all_versions { for version in &all_versions {
print!("{}", version.version); print!("{}", version.version);
if let Some(lts) = &version.lts { if let Some(lts) = &version.lts {
print!(" ({lts})"); print!("{}", format!(" ({lts})").cyan());
} }
println!(); println!();
} }

14
src/commands/use.rs

@ -4,6 +4,7 @@ use crate::current_version::current_version;
use crate::fs; use crate::fs;
use crate::installed_versions; use crate::installed_versions;
use crate::outln; use crate::outln;
use crate::shell;
use crate::system_version; use crate::system_version;
use crate::user_version::UserVersion; use crate::user_version::UserVersion;
use crate::version::Version; use crate::version::Version;
@ -152,15 +153,15 @@ fn install_new_version(
fn replace_symlink(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { fn replace_symlink(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
let symlink_deletion_result = fs::remove_symlink_dir(to); let symlink_deletion_result = fs::remove_symlink_dir(to);
match fs::symlink_dir(from, to) { match fs::symlink_dir(from, to) {
ok @ Ok(_) => ok, ok @ Ok(()) => ok,
err @ Err(_) => symlink_deletion_result.and(err), err @ Err(_) => symlink_deletion_result.and(err),
} }
} }
fn should_install_interactively(requested_version: &UserVersion) -> bool { fn should_install_interactively(requested_version: &UserVersion) -> bool {
use std::io::Write; use std::io::{IsTerminal, Write};
if !(atty::is(atty::Stream::Stdout) && atty::is(atty::Stream::Stdin)) { if !(std::io::stdout().is_terminal() && std::io::stdin().is_terminal()) {
return false; return false;
} }
@ -169,7 +170,7 @@ fn should_install_interactively(requested_version: &UserVersion) -> bool {
requested_version.to_string().italic() requested_version.to_string().italic()
); );
eprintln!("{}", error_message.red()); eprintln!("{}", error_message.red());
let do_you_want = format!("Do you want to install it? {} [y/n]:", "answer".bold()); let do_you_want = format!("Do you want to install it? {} [y/N]:", "answer".bold());
eprint!("{} ", do_you_want.yellow()); eprint!("{} ", do_you_want.yellow());
std::io::stdout().flush().unwrap(); std::io::stdout().flush().unwrap();
let mut s = String::new(); let mut s = String::new();
@ -190,8 +191,11 @@ fn warn_if_multishell_path_not_in_path_env_var(
multishell_path.to_path_buf() multishell_path.to_path_buf()
}; };
let fixed_path = bin_path.to_str().and_then(shell::maybe_fix_windows_path);
let fixed_path = fixed_path.as_ref().map(|x| &x[..]);
for path in std::env::split_paths(&std::env::var("PATH").unwrap_or_default()) { for path in std::env::split_paths(&std::env::var("PATH").unwrap_or_default()) {
if bin_path == path { if bin_path == path || fixed_path == path.to_str() {
return; return;
} }
} }

49
src/config.rs

@ -7,7 +7,7 @@ use url::Url;
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
pub struct FnmConfig { pub struct FnmConfig {
/// https://nodejs.org/dist/ mirror /// <https://nodejs.org/dist/> mirror
#[clap( #[clap(
long, long,
env = "FNM_NODE_DIST_MIRROR", env = "FNM_NODE_DIST_MIRROR",
@ -36,10 +36,9 @@ pub struct FnmConfig {
#[clap( #[clap(
long, long,
env = "FNM_LOGLEVEL", env = "FNM_LOGLEVEL",
default_value = "info", default_value_t,
global = true, global = true,
hide_env_values = true, hide_env_values = true
possible_values = LogLevel::possible_values()
)] )]
log_level: LogLevel, log_level: LogLevel,
@ -57,19 +56,37 @@ pub struct FnmConfig {
/// A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is /// A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is
/// called without a version, or when `--use-on-cd` is configured on evaluation. /// called without a version, or when `--use-on-cd` is configured on evaluation.
///
/// * `local`: Use the local version of Node defined within the current directory
///
/// * `recursive`: Use the version of Node defined within the current directory and all parent directories
#[clap( #[clap(
long, long,
env = "FNM_VERSION_FILE_STRATEGY", env = "FNM_VERSION_FILE_STRATEGY",
possible_values = VersionFileStrategy::possible_values(), default_value_t,
default_value = "local",
global = true, global = true,
hide_env_values = true, hide_env_values = true
)] )]
version_file_strategy: VersionFileStrategy, version_file_strategy: VersionFileStrategy,
/// Enable corepack support for each new installation.
/// This will make fnm call `corepack enable` on every Node.js installation.
/// For more information about corepack see <https://nodejs.org/api/corepack.html>
#[clap(
long,
env = "FNM_COREPACK_ENABLED",
global = true,
hide_env_values = true
)]
corepack_enabled: bool,
/// Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present.
/// Experimental: This feature is subject to change.
/// Note: `engines.node` can be any semver range, with the latest satisfying version being resolved.
#[clap(
long,
env = "FNM_RESOLVE_ENGINES",
global = true,
hide_env_values = true,
verbatim_doc_comment
)]
resolve_engines: bool,
} }
impl Default for FnmConfig { impl Default for FnmConfig {
@ -81,6 +98,8 @@ impl Default for FnmConfig {
log_level: LogLevel::Info, log_level: LogLevel::Info,
arch: Arch::default(), arch: Arch::default(),
version_file_strategy: VersionFileStrategy::default(), version_file_strategy: VersionFileStrategy::default(),
corepack_enabled: false,
resolve_engines: false,
} }
} }
} }
@ -90,6 +109,14 @@ impl FnmConfig {
&self.version_file_strategy &self.version_file_strategy
} }
pub fn corepack_enabled(&self) -> bool {
self.corepack_enabled
}
pub fn resolve_engines(&self) -> bool {
self.resolve_engines
}
pub fn multishell_path(&self) -> Option<&std::path::Path> { pub fn multishell_path(&self) -> Option<&std::path::Path> {
match &self.multishell_path { match &self.multishell_path {
None => None, None => None,

9
src/default_version.rs

@ -3,7 +3,10 @@ use crate::version::Version;
use std::str::FromStr; use std::str::FromStr;
pub fn find_default_version(config: &FnmConfig) -> Option<Version> { pub fn find_default_version(config: &FnmConfig) -> Option<Version> {
let version_path = config.default_version_dir().canonicalize().ok()?; if let Ok(version_path) = config.default_version_dir().canonicalize() {
let file_name = version_path.parent()?.file_name()?; let file_name = version_path.parent()?.file_name()?;
Version::from_str(file_name.to_str()?).ok()?.into() Version::from_str(file_name.to_str()?).ok()?.into()
} else {
Some(Version::Alias("default".into()))
}
} }

21
src/downloader.rs

@ -2,8 +2,11 @@ use crate::arch::Arch;
use crate::archive; use crate::archive;
use crate::archive::{Error as ExtractError, Extract}; use crate::archive::{Error as ExtractError, Extract};
use crate::directory_portal::DirectoryPortal; use crate::directory_portal::DirectoryPortal;
use crate::progress::ResponseProgress;
use crate::version::Version; use crate::version::Version;
use indicatif::ProgressDrawTarget;
use log::debug; use log::debug;
use std::io::Read;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use thiserror::Error; use thiserror::Error;
@ -63,10 +66,7 @@ fn download_url(base_url: &Url, version: &Version, arch: &Arch) -> Url {
.unwrap() .unwrap()
} }
pub fn extract_archive_into<P: AsRef<Path>>( fn extract_archive_into(path: impl AsRef<Path>, response: impl Read) -> Result<(), Error> {
path: P,
response: crate::http::Response,
) -> Result<(), Error> {
#[cfg(unix)] #[cfg(unix)]
let extractor = archive::TarXz::new(response); let extractor = archive::TarXz::new(response);
#[cfg(windows)] #[cfg(windows)]
@ -81,6 +81,7 @@ pub fn install_node_dist<P: AsRef<Path>>(
node_dist_mirror: &Url, node_dist_mirror: &Url,
installations_dir: P, installations_dir: P,
arch: &Arch, arch: &Arch,
show_progress: bool,
) -> Result<(), Error> { ) -> Result<(), Error> {
let installation_dir = PathBuf::from(installations_dir.as_ref()).join(version.v_str()); let installation_dir = PathBuf::from(installations_dir.as_ref()).join(version.v_str());
@ -109,7 +110,14 @@ pub fn install_node_dist<P: AsRef<Path>>(
} }
debug!("Extracting response..."); debug!("Extracting response...");
extract_archive_into(&portal, response)?; if show_progress {
extract_archive_into(
&portal,
ResponseProgress::new(response, ProgressDrawTarget::stderr()),
)?;
} else {
extract_archive_into(&portal, response)?;
}
debug!("Extraction completed"); debug!("Extraction completed");
let installed_directory = std::fs::read_dir(&portal)? let installed_directory = std::fs::read_dir(&portal)?
@ -171,7 +179,8 @@ mod tests {
let version = Version::parse("12.0.0").unwrap(); let version = Version::parse("12.0.0").unwrap();
let arch = Arch::X64; let arch = Arch::X64;
let node_dist_mirror = Url::parse("https://nodejs.org/dist/").unwrap(); let node_dist_mirror = Url::parse("https://nodejs.org/dist/").unwrap();
install_node_dist(&version, &node_dist_mirror, path, &arch).expect("Can't install Node 12"); install_node_dist(&version, &node_dist_mirror, path, &arch, false)
.expect("Can't install Node 12");
let mut location_path = path.join(version.v_str()).join("installation"); let mut location_path = path.join(version.v_str()).join("installation");

31
src/log_level.rs

@ -1,10 +1,26 @@
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Clone)] use std::fmt::Display;
use clap::ValueEnum;
#[derive(Debug, Default, PartialEq, PartialOrd, Eq, Ord, Clone, ValueEnum)]
pub enum LogLevel { pub enum LogLevel {
Quiet, Quiet,
Error, Error,
#[default]
#[value(alias("all"))]
Info, Info,
} }
impl Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LogLevel::Quiet => write!(f, "quiet"),
LogLevel::Error => write!(f, "error"),
LogLevel::Info => write!(f, "info"),
}
}
}
impl LogLevel { impl LogLevel {
pub fn is_writable(&self, logging: &Self) -> bool { pub fn is_writable(&self, logging: &Self) -> bool {
use std::cmp::Ordering; use std::cmp::Ordering;
@ -37,19 +53,6 @@ impl From<LogLevel> for &'static str {
} }
} }
impl std::str::FromStr for LogLevel {
type Err = &'static str;
fn from_str(s: &str) -> Result<LogLevel, Self::Err> {
match s {
"quiet" => Ok(Self::Quiet),
"info" | "all" => Ok(Self::Info),
"error" => Ok(Self::Error),
_ => Err("Unsupported log level"),
}
}
}
#[macro_export] #[macro_export]
macro_rules! outln { macro_rules! outln {
($config:ident, $level:path, $($expr:expr),+) => {{ ($config:ident, $level:path, $($expr:expr),+) => {{

2
src/main.rs

@ -20,7 +20,9 @@ mod fs;
mod http; mod http;
mod installed_versions; mod installed_versions;
mod lts; mod lts;
mod package_json;
mod path_ext; mod path_ext;
mod progress;
mod remote_node_index; mod remote_node_index;
mod shell; mod shell;
mod system_info; mod system_info;

19
src/package_json.rs

@ -0,0 +1,19 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
struct EnginesField {
node: Option<node_semver::Range>,
}
#[derive(Debug, Deserialize, Default)]
pub struct PackageJson {
engines: Option<EnginesField>,
}
impl PackageJson {
pub fn node_range(&self) -> Option<&node_semver::Range> {
self.engines
.as_ref()
.and_then(|engines| engines.node.as_ref())
}
}

165
src/progress.rs

@ -0,0 +1,165 @@
use std::io::Read;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use reqwest::blocking::Response;
pub struct ResponseProgress {
progress: Option<ProgressBar>,
response: Response,
}
#[derive(Default, Clone, Debug, clap::ValueEnum)]
pub enum ProgressConfig {
#[default]
Auto,
Never,
Always,
}
impl ProgressConfig {
pub fn enabled(&self, config: &crate::config::FnmConfig) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::Auto => config
.log_level()
.is_writable(&crate::log_level::LogLevel::Info),
}
}
}
fn make_progress_bar(size: u64, target: ProgressDrawTarget) -> ProgressBar {
let bar = ProgressBar::with_draw_target(Some(size), target);
bar.set_style(
ProgressStyle::with_template(
"{elapsed_precise:.white.dim} {wide_bar:.cyan} {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
)
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
bar
}
impl ResponseProgress {
pub fn new(response: Response, target: ProgressDrawTarget) -> Self {
Self {
progress: response
.content_length()
.map(|len| make_progress_bar(len, target)),
response,
}
}
pub fn finish(&self) {
if let Some(ref bar) = self.progress {
bar.finish();
}
}
}
impl Read for ResponseProgress {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let size = self.response.read(buf)?;
if let Some(ref bar) = self.progress {
bar.inc(size as u64);
}
Ok(size)
}
}
impl Drop for ResponseProgress {
fn drop(&mut self) {
self.finish();
eprintln!();
}
}
#[cfg(test)]
mod tests {
use indicatif::{ProgressDrawTarget, TermLike};
use reqwest::blocking::Response;
use std::{
io::Read,
sync::{Arc, Mutex},
};
use super::ResponseProgress;
const CONTENT_LENGTH: usize = 100;
#[derive(Debug)]
struct MockedTerm {
pub buf: Arc<Mutex<String>>,
}
impl TermLike for MockedTerm {
fn width(&self) -> u16 {
80
}
fn move_cursor_up(&self, _n: usize) -> std::io::Result<()> {
Ok(())
}
fn move_cursor_down(&self, _n: usize) -> std::io::Result<()> {
Ok(())
}
fn move_cursor_right(&self, _n: usize) -> std::io::Result<()> {
Ok(())
}
fn move_cursor_left(&self, _n: usize) -> std::io::Result<()> {
Ok(())
}
fn write_line(&self, s: &str) -> std::io::Result<()> {
self.buf.lock().unwrap().push_str(s);
Ok(())
}
fn write_str(&self, s: &str) -> std::io::Result<()> {
self.buf.lock().unwrap().push_str(s);
Ok(())
}
fn clear_line(&self) -> std::io::Result<()> {
Ok(())
}
fn flush(&self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn test_reads_data_and_shows_progress() {
let response: Response = http::Response::builder()
.header("Content-Length", CONTENT_LENGTH)
.body("a".repeat(CONTENT_LENGTH))
.unwrap()
.into();
let mut buf = [0; CONTENT_LENGTH];
let out_buf = Arc::new(Mutex::new(String::new()));
let mut progress = ResponseProgress::new(
response,
ProgressDrawTarget::term_like(Box::new(MockedTerm {
buf: out_buf.clone(),
})),
);
let size = progress.read(&mut buf[..]).unwrap();
drop(progress);
assert_eq!(size, CONTENT_LENGTH);
assert_eq!(buf, "a".repeat(CONTENT_LENGTH).as_bytes());
assert!(out_buf.lock().unwrap().contains(&"█".repeat(40)));
}
}

10
src/shell/bash.rs

@ -16,7 +16,9 @@ impl Shell for Bash {
let path = path let path = path
.to_str() .to_str()
.ok_or_else(|| anyhow::anyhow!("Can't convert path to string"))?; .ok_or_else(|| anyhow::anyhow!("Can't convert path to string"))?;
Ok(format!("export PATH={path:?}:$PATH")) let path =
super::windows_compat::maybe_fix_windows_path(path).unwrap_or_else(|| path.to_string());
Ok(format!("export PATH={path:?}:\"$PATH\""))
} }
fn set_env_var(&self, name: &str, value: &str) -> String { fn set_env_var(&self, name: &str, value: &str) -> String {
@ -26,13 +28,13 @@ impl Shell for Bash {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> { fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() { let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!( VersionFileStrategy::Local => indoc!(
r#" r"
if [[ -f .node-version || -f .nvmrc ]]; then if [[ -f .node-version || -f .nvmrc ]]; then
fnm use --silent-if-unchanged fnm use --silent-if-unchanged
fi fi
"# "
), ),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#, VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
}; };
Ok(formatdoc!( Ok(formatdoc!(
r#" r#"

8
src/shell/fish.rs

@ -16,6 +16,8 @@ impl Shell for Fish {
let path = path let path = path
.to_str() .to_str()
.ok_or_else(|| anyhow::anyhow!("Can't convert path to string"))?; .ok_or_else(|| anyhow::anyhow!("Can't convert path to string"))?;
let path =
super::windows_compat::maybe_fix_windows_path(path).unwrap_or_else(|| path.to_string());
Ok(format!("set -gx PATH {path:?} $PATH;")) Ok(format!("set -gx PATH {path:?} $PATH;"))
} }
@ -26,13 +28,13 @@ impl Shell for Fish {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> { fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() { let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!( VersionFileStrategy::Local => indoc!(
r#" r"
if test -f .node-version -o -f .nvmrc if test -f .node-version -o -f .nvmrc
fnm use --silent-if-unchanged fnm use --silent-if-unchanged
end end
"# "
), ),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#, VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
}; };
Ok(formatdoc!( Ok(formatdoc!(
r#" r#"

2
src/shell/infer/mod.rs

@ -7,7 +7,7 @@ pub use self::unix::infer_shell;
#[cfg(not(unix))] #[cfg(not(unix))]
pub use self::windows::infer_shell; pub use self::windows::infer_shell;
pub(self) fn shell_from_string(shell: &str) -> Option<Box<dyn super::Shell>> { fn shell_from_string(shell: &str) -> Option<Box<dyn super::Shell>> {
use super::{Bash, Fish, PowerShell, WindowsCmd, Zsh}; use super::{Bash, Fish, PowerShell, WindowsCmd, Zsh};
match shell { match shell {
"sh" | "bash" => return Some(Box::from(Bash)), "sh" | "bash" => return Some(Box::from(Bash)),

4
src/shell/mod.rs

@ -7,11 +7,13 @@ mod zsh;
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
mod shell; mod shell;
mod windows_compat;
pub use bash::Bash; pub use bash::Bash;
pub use fish::Fish; pub use fish::Fish;
pub use infer::infer_shell; pub use infer::infer_shell;
pub use powershell::PowerShell; pub use powershell::PowerShell;
pub use shell::{Shell, AVAILABLE_SHELLS}; pub use shell::{Shell, Shells};
pub use windows_cmd::WindowsCmd; pub use windows_cmd::WindowsCmd;
pub use windows_compat::maybe_fix_windows_path;
pub use zsh::Zsh; pub use zsh::Zsh;

6
src/shell/powershell.rs

@ -28,11 +28,11 @@ impl Shell for PowerShell {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> { fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() { let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!( VersionFileStrategy::Local => indoc!(
r#" r"
If ((Test-Path .nvmrc) -Or (Test-Path .node-version)) { & fnm use --silent-if-unchanged } If ((Test-Path .nvmrc) -Or (Test-Path .node-version)) { & fnm use --silent-if-unchanged }
"# "
), ),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#, VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
}; };
Ok(formatdoc!( Ok(formatdoc!(
r#" r#"

49
src/shell/shell.rs

@ -1,6 +1,8 @@
use std::fmt::Debug; use std::fmt::{Debug, Display};
use std::path::Path; use std::path::Path;
use clap::ValueEnum;
pub trait Shell: Debug { pub trait Shell: Debug {
fn path(&self, path: &Path) -> anyhow::Result<String>; fn path(&self, path: &Path) -> anyhow::Result<String>;
fn set_env_var(&self, name: &str, value: &str) -> String; fn set_env_var(&self, name: &str, value: &str) -> String;
@ -11,23 +13,38 @@ pub trait Shell: Debug {
fn to_clap_shell(&self) -> clap_complete::Shell; fn to_clap_shell(&self) -> clap_complete::Shell;
} }
#[cfg(windows)] #[derive(Debug, Clone, ValueEnum)]
pub const AVAILABLE_SHELLS: &[&str; 5] = &["cmd", "powershell", "bash", "zsh", "fish"]; pub enum Shells {
Bash,
#[cfg(unix)] Zsh,
pub const AVAILABLE_SHELLS: &[&str; 4] = &["bash", "zsh", "fish", "powershell"]; Fish,
PowerShell,
#[cfg(windows)]
Cmd,
}
impl std::str::FromStr for Box<dyn Shell> { impl Display for Shells {
type Err = String; fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Shells::Bash => write!(f, "bash"),
Shells::Zsh => write!(f, "zsh"),
Shells::Fish => write!(f, "fish"),
Shells::PowerShell => write!(f, "powershell"),
#[cfg(windows)]
Shells::Cmd => write!(f, "cmd"),
}
}
}
fn from_str(s: &str) -> Result<Box<dyn Shell>, Self::Err> { impl From<Shells> for Box<dyn Shell> {
match s { fn from(shell: Shells) -> Box<dyn Shell> {
"cmd" => Ok(Box::from(super::windows_cmd::WindowsCmd)), match shell {
"zsh" => Ok(Box::from(super::zsh::Zsh)), Shells::Zsh => Box::from(super::zsh::Zsh),
"bash" => Ok(Box::from(super::bash::Bash)), Shells::Bash => Box::from(super::bash::Bash),
"fish" => Ok(Box::from(super::fish::Fish)), Shells::Fish => Box::from(super::fish::Fish),
"powershell" => Ok(Box::from(super::powershell::PowerShell)), Shells::PowerShell => Box::from(super::powershell::PowerShell),
shell_type => Err(format!("I don't know the shell type of {shell_type:?}",)), #[cfg(windows)]
Shells::Cmd => Box::from(super::windows_cmd::WindowsCmd),
} }
} }
} }

2
src/shell/windows_cmd/cd.cmd

@ -1,5 +1,5 @@
@echo off @echo off
cd %1 cd %*
if "%FNM_VERSION_FILE_STRATEGY%" == "recursive" ( if "%FNM_VERSION_FILE_STRATEGY%" == "recursive" (
fnm use --silent-if-unchanged fnm use --silent-if-unchanged
) else ( ) else (

21
src/shell/windows_compat.rs

@ -0,0 +1,21 @@
/// On Bash for Windows, we need to convert the path from a Windows-style
/// path to a Unix-style path. This is because Bash for Windows doesn't
/// understand Windows-style paths. We use `cygpath` to do this conversion.
/// If `cygpath` fails, we assume we're not on Bash for Windows and just
/// return the original path.
pub fn maybe_fix_windows_path(path: &str) -> Option<String> {
if !cfg!(windows) {
return None;
}
let output = std::process::Command::new("cygpath")
.arg(path)
.output()
.ok()?;
if output.status.success() {
let output = String::from_utf8(output.stdout).ok()?;
Some(output.trim().to_string())
} else {
None
}
}

8
src/shell/zsh.rs

@ -16,6 +16,8 @@ impl Shell for Zsh {
let path = path let path = path
.to_str() .to_str()
.ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?; .ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?;
let path =
super::windows_compat::maybe_fix_windows_path(path).unwrap_or_else(|| path.to_string());
Ok(format!("export PATH={path:?}:$PATH")) Ok(format!("export PATH={path:?}:$PATH"))
} }
@ -30,13 +32,13 @@ impl Shell for Zsh {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> { fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() { let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!( VersionFileStrategy::Local => indoc!(
r#" r"
if [[ -f .node-version || -f .nvmrc ]]; then if [[ -f .node-version || -f .nvmrc ]]; then
fnm use --silent-if-unchanged fnm use --silent-if-unchanged
fi fi
"# "
), ),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#, VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
}; };
Ok(formatdoc!( Ok(formatdoc!(
r#" r#"

3
src/user_version.rs

@ -5,6 +5,7 @@ use std::str::FromStr;
pub enum UserVersion { pub enum UserVersion {
OnlyMajor(u64), OnlyMajor(u64),
MajorMinor(u64, u64), MajorMinor(u64, u64),
SemverRange(node_semver::Range),
Full(Version), Full(Version),
} }
@ -41,6 +42,7 @@ impl UserVersion {
} }
} }
} }
(Self::SemverRange(range), Version::Semver(semver)) => semver.satisfies(range),
(_, Version::Bypassed | Version::Lts(_) | Version::Alias(_) | Version::Latest) => false, (_, Version::Bypassed | Version::Lts(_) | Version::Alias(_) | Version::Latest) => false,
(Self::OnlyMajor(major), Version::Semver(other)) => *major == other.major, (Self::OnlyMajor(major), Version::Semver(other)) => *major == other.major,
(Self::MajorMinor(major, minor), Version::Semver(other)) => { (Self::MajorMinor(major, minor), Version::Semver(other)) => {
@ -59,6 +61,7 @@ impl std::fmt::Display for UserVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Full(x) => x.fmt(f), Self::Full(x) => x.fmt(f),
Self::SemverRange(x) => x.fmt(f),
Self::OnlyMajor(major) => write!(f, "v{major}.x.x"), Self::OnlyMajor(major) => write!(f, "v{major}.x.x"),
Self::MajorMinor(major, minor) => write!(f, "v{major}.{minor}.x"), Self::MajorMinor(major, minor) => write!(f, "v{major}.{minor}.x"),
} }

4
src/user_version_reader.rs

@ -4,7 +4,7 @@ use crate::version_files::{get_user_version_for_directory, get_user_version_for_
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug)] #[derive(Debug, Clone)]
pub enum UserVersionReader { pub enum UserVersionReader {
Direct(UserVersion), Direct(UserVersion),
Path(PathBuf), Path(PathBuf),
@ -14,7 +14,7 @@ impl UserVersionReader {
pub fn into_user_version(self, config: &FnmConfig) -> Option<UserVersion> { pub fn into_user_version(self, config: &FnmConfig) -> Option<UserVersion> {
match self { match self {
Self::Direct(uv) => Some(uv), Self::Direct(uv) => Some(uv),
Self::Path(pathbuf) if pathbuf.is_file() => get_user_version_for_file(pathbuf), Self::Path(pathbuf) if pathbuf.is_file() => get_user_version_for_file(pathbuf, config),
Self::Path(pathbuf) => get_user_version_for_directory(pathbuf, config), Self::Path(pathbuf) => get_user_version_for_directory(pathbuf, config),
} }
} }

3
src/version.rs

@ -77,6 +77,9 @@ impl Version {
} }
} }
// TODO: add a trait called BinPath that &Path and PathBuf implements
// which adds the `.bin_path()` which works both on windows and unix :)
impl<'de> serde::Deserialize<'de> for Version { impl<'de> serde::Deserialize<'de> for Version {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where

40
src/version_file_strategy.rs

@ -1,16 +1,25 @@
use std::str::FromStr; use clap::ValueEnum;
use std::fmt::Display;
#[derive(Debug)] #[derive(Debug, Clone, Default, ValueEnum)]
pub enum VersionFileStrategy { pub enum VersionFileStrategy {
/// Use the local version of Node defined within the current directory
#[default]
Local, Local,
/// Use the version of Node defined within the current directory and all parent directories
Recursive, Recursive,
} }
impl VersionFileStrategy { impl Display for VersionFileStrategy {
pub fn possible_values() -> &'static [&'static str] { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
&["local", "recursive"] match self {
VersionFileStrategy::Local => write!(f, "local"),
VersionFileStrategy::Recursive => write!(f, "recursive"),
}
} }
}
impl VersionFileStrategy {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
VersionFileStrategy::Local => "local", VersionFileStrategy::Local => "local",
@ -18,24 +27,3 @@ impl VersionFileStrategy {
} }
} }
} }
impl Default for VersionFileStrategy {
fn default() -> Self {
VersionFileStrategy::Local
}
}
impl FromStr for VersionFileStrategy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"local" => Ok(VersionFileStrategy::Local),
"recursive" => Ok(VersionFileStrategy::Recursive),
_ => Err(format!(
"Invalid strategy: {}. Expected one of: local, recursive",
s
)),
}
}
}

59
src/version_files.rs

@ -1,5 +1,6 @@
use crate::config::FnmConfig; use crate::config::FnmConfig;
use crate::default_version; use crate::default_version;
use crate::package_json::PackageJson;
use crate::user_version::UserVersion; use crate::user_version::UserVersion;
use crate::version_file_strategy::VersionFileStrategy; use crate::version_file_strategy::VersionFileStrategy;
use encoding_rs_io::DecodeReaderBytes; use encoding_rs_io::DecodeReaderBytes;
@ -8,28 +9,30 @@ use std::io::Read;
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
const PATH_PARTS: [&str; 2] = [".nvmrc", ".node-version"]; const PATH_PARTS: [&str; 3] = [".nvmrc", ".node-version", "package.json"];
pub fn get_user_version_for_directory( pub fn get_user_version_for_directory(
path: impl AsRef<Path>, path: impl AsRef<Path>,
config: &FnmConfig, config: &FnmConfig,
) -> Option<UserVersion> { ) -> Option<UserVersion> {
match config.version_file_strategy() { match config.version_file_strategy() {
VersionFileStrategy::Local => get_user_version_for_single_directory(path), VersionFileStrategy::Local => get_user_version_for_single_directory(path, config),
VersionFileStrategy::Recursive => { VersionFileStrategy::Recursive => get_user_version_for_directory_recursive(path, config)
get_user_version_for_directory_recursive(path).or_else(|| { .or_else(|| {
info!("Did not find anything recursively. Falling back to default alias."); info!("Did not find anything recursively. Falling back to default alias.");
default_version::find_default_version(config).map(UserVersion::Full) default_version::find_default_version(config).map(UserVersion::Full)
}) }),
}
} }
} }
fn get_user_version_for_directory_recursive(path: impl AsRef<Path>) -> Option<UserVersion> { fn get_user_version_for_directory_recursive(
path: impl AsRef<Path>,
config: &FnmConfig,
) -> Option<UserVersion> {
let mut current_path = Some(path.as_ref()); let mut current_path = Some(path.as_ref());
while let Some(child_path) = current_path { while let Some(child_path) = current_path {
if let Some(version) = get_user_version_for_single_directory(child_path) { if let Some(version) = get_user_version_for_single_directory(child_path, config) {
return Some(version); return Some(version);
} }
@ -39,7 +42,10 @@ fn get_user_version_for_directory_recursive(path: impl AsRef<Path>) -> Option<Us
None None
} }
pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<UserVersion> { fn get_user_version_for_single_directory(
path: impl AsRef<Path>,
config: &FnmConfig,
) -> Option<UserVersion> {
let path = path.as_ref(); let path = path.as_ref();
for path_part in &PATH_PARTS { for path_part in &PATH_PARTS {
@ -49,7 +55,7 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U
new_path.display(), new_path.display(),
new_path.exists() new_path.exists()
); );
if let Some(version) = get_user_version_for_file(&new_path) { if let Some(version) = get_user_version_for_file(&new_path, config) {
return Some(version); return Some(version);
} }
} }
@ -57,22 +63,43 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U
None None
} }
pub fn get_user_version_for_file(path: impl AsRef<Path>) -> Option<UserVersion> { pub fn get_user_version_for_file(
path: impl AsRef<Path>,
config: &FnmConfig,
) -> Option<UserVersion> {
let is_pkg_json = match path.as_ref().file_name() {
Some(name) => name == "package.json",
None => false,
};
let file = std::fs::File::open(path).ok()?; let file = std::fs::File::open(path).ok()?;
let version = { let file = {
let mut reader = DecodeReaderBytes::new(file); let mut reader = DecodeReaderBytes::new(file);
let mut version = String::new(); let mut version = String::new();
reader.read_to_string(&mut version).map(|_| version) reader.read_to_string(&mut version).map(|_| version)
}; };
match version { match (file, is_pkg_json, config.resolve_engines()) {
Err(err) => { (_, true, false) => None,
(Err(err), _, _) => {
info!("Can't read file: {}", err); info!("Can't read file: {}", err);
None None
} }
Ok(version) => { (Ok(version), false, _) => {
info!("Found string {:?} in version file", version); info!("Found string {:?} in version file", version);
UserVersion::from_str(version.trim()).ok() UserVersion::from_str(version.trim()).ok()
} }
(Ok(pkg_json), true, true) => {
let pkg_json = serde_json::from_str::<PackageJson>(&pkg_json).ok();
let range: Option<node_semver::Range> =
pkg_json.as_ref().and_then(PackageJson::node_range).cloned();
if let Some(range) = range {
info!("Found package.json with {:?} in engines.node field", range);
Some(UserVersion::SemverRange(range))
} else {
info!("No engines.node range found in package.json");
None
}
}
} }
} }

65
tests/proxy-server/index.mjs

@ -0,0 +1,65 @@
// @ts-check
import { createServer } from "node:http"
import path from "node:path"
import fs from "node:fs"
import crypto from "node:crypto"
import fetch from "node-fetch"
import chalk from "chalk"
const baseDir = path.join(process.cwd(), ".proxy")
try {
fs.mkdirSync(baseDir, { recursive: true })
} catch (e) {}
/** @type {Map<string, Promise<{ headers: Record<string, string>, body: ArrayBuffer }>>} */
const cache = new Map()
export const server = createServer((req, res) => {
const pathname = req.url ?? "/"
const hash = crypto
.createHash("sha1")
.update(pathname ?? "/")
.digest("hex")
const extension = path.extname(pathname)
const filename = path.join(baseDir, hash) + extension
const headersFilename = path.join(baseDir, hash) + ".headers.json"
try {
const headers = JSON.parse(fs.readFileSync(headersFilename, "utf-8"))
const body = fs.createReadStream(filename)
console.log(chalk.green.dim(`[proxy] hit: ${pathname} -> ${filename}`))
res.writeHead(200, headers)
body.pipe(res)
} catch {
let promise = cache.get(filename)
if (!promise) {
console.log(chalk.red.dim(`[proxy] miss: ${pathname} -> ${filename}`))
promise = fetch(
"https://nodejs.org/dist/" + pathname.replace(/^\/+/, ""),
{
compress: false,
}
).then(async (response) => {
const headers = Object.fromEntries(response.headers.entries())
const body = await response.arrayBuffer()
fs.writeFileSync(headersFilename, JSON.stringify(headers))
fs.writeFileSync(filename, Buffer.from(body))
return { headers, body }
})
cache.set(filename, promise)
promise.finally(() => cache.delete(filename))
}
promise.then(
({ headers, body }) => {
res.writeHead(200, headers)
res.end(Buffer.from(body))
},
(err) => {
console.error(err)
res.writeHead(500)
res.end()
}
)
}
})
Loading…
Cancel
Save