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. 48
      .ci/install.sh
  11. 7
      .ci/print-command-docs.js
  12. 6
      .ci/record_screen.sh
  13. 8
      .github/workflows/release.yml
  14. 162
      .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. 917
      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. 5
      src/default_version.rs
  51. 19
      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. 47
      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. 57
      src/version_files.rs
  70. 65
      tests/proxy-server/index.mjs

5
.changeset/config.json

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

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

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

5
.changeset/fifty-emus-type.md

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

5
.changeset/many-paws-fetch.md

@ -0,0 +1,5 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -0,0 +1,5 @@
---
"fnm": patch
---
fix: return default version if canonicalize fails

5
.changeset/show-download-progress.md

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

48
.ci/install.sh

@ -5,6 +5,10 @@ set -e @@ -5,6 +5,10 @@ set -e
RELEASE="latest"
OS="$(uname -s)"
case "${OS}" in
MINGW* | Win*) OS="Windows" ;;
esac
if [ -d "$HOME/.fnm" ]; then
INSTALL_DIR="$HOME/.fnm"
elif [ -n "$XDG_DATA_HOME" ]; then
@ -70,6 +74,9 @@ set_filename() { @@ -70,6 +74,9 @@ set_filename() {
elif [ "$OS" = "Darwin" ]; then
USE_HOMEBREW="true"
echo "Downloading fnm using Homebrew..."
elif [ "$OS" = "Windows" ] ; then
FILENAME="fnm-windows"
echo "Downloading the latest fnm binary from GitHub..."
else
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"
@ -161,28 +168,29 @@ setup_shell() { @@ -161,28 +168,29 @@ setup_shell() {
CONF_FILE=${ZDOTDIR:-$HOME}/.zshrc
ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Zsh. Appending the following to $CONF_FILE:"
echo ""
{
echo ''
echo '# fnm'
echo ' export PATH="'"$INSTALL_DIR"':$PATH"'
echo 'FNM_PATH="'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]; then'
echo ' export PATH="'$INSTALL_DIR':$PATH"'
echo ' eval "`fnm env`"'
echo '' >>$CONF_FILE
echo '# fnm' >>$CONF_FILE
echo 'export PATH="'$INSTALL_DIR':$PATH"' >>$CONF_FILE
echo 'eval "`fnm env`"' >>$CONF_FILE
echo 'fi'
} | tee -a "$CONF_FILE"
elif [ "$CURRENT_SHELL" = "fish" ]; then
CONF_FILE=$HOME/.config/fish/conf.d/fnm.fish
ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Fish. Appending the following to $CONF_FILE:"
echo ""
{
echo ''
echo '# fnm'
echo ' set PATH "'"$INSTALL_DIR"'" $PATH'
echo 'set FNM_PATH "'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]'
echo ' set PATH "$FNM_PATH" $PATH'
echo ' fnm env | source'
echo '# fnm' >>$CONF_FILE
echo 'set PATH "'"$INSTALL_DIR"'" $PATH' >>$CONF_FILE
echo 'fnm env | source' >>$CONF_FILE
echo 'end'
} | tee -a "$CONF_FILE"
elif [ "$CURRENT_SHELL" = "bash" ]; then
if [ "$OS" = "Darwin" ]; then
@ -192,15 +200,15 @@ setup_shell() { @@ -192,15 +200,15 @@ setup_shell() {
fi
ensure_containing_dir_exists "$CONF_FILE"
echo "Installing for Bash. Appending the following to $CONF_FILE:"
echo ""
{
echo ''
echo '# fnm'
echo ' export PATH="'"$INSTALL_DIR"':$PATH"'
echo 'FNM_PATH="'"$INSTALL_DIR"'"'
echo 'if [ -d "$FNM_PATH" ]; then'
echo ' export PATH="$FNM_PATH:$PATH"'
echo ' eval "`fnm env`"'
echo '' >>$CONF_FILE
echo '# fnm' >>$CONF_FILE
echo 'export PATH="'"$INSTALL_DIR"':$PATH"' >>$CONF_FILE
echo 'eval "`fnm env`"' >>$CONF_FILE
echo 'fi'
} | tee -a "$CONF_FILE"
else
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) { @@ -106,11 +106,14 @@ async function getCommandHelp(fnmPath, command) {
const result = await run(fnmPath, [...cmdArg, "--help"])
const text = result.stdout
const rows = text.split("\n")
const headerIndex = rows.findIndex((x) => x.includes("SUBCOMMANDS"))
const headerIndex = rows.findIndex((x) => x.includes("Commands:"))
/** @type {string[]} */
const subcommands = []
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+/)
if (word && word[0].toLowerCase() === word[0]) {
subcommands.push(word)

6
.ci/record_screen.sh

@ -20,7 +20,11 @@ RECORDING_PATH=$DIRECTORY/screen_recording @@ -20,7 +20,11 @@ RECORDING_PATH=$DIRECTORY/screen_recording
(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" | \
svg-term \
--window \

8
.github/workflows/release.yml

@ -8,7 +8,6 @@ on: @@ -8,7 +8,6 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
create_pull_request:
runs-on: ubuntu-latest
steps:
@ -29,7 +28,7 @@ jobs: @@ -29,7 +28,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'pnpm'
cache: "pnpm"
- name: Get pnpm store directory
id: pnpm-cache
@ -44,10 +43,9 @@ jobs: @@ -44,10 +43,9 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install script dependencies
- name: Install Asciinema
run: |
sudo apt-get update
sudo apt-get install -y asciinema
pipx install asciinema
- name: Install Node.js project dependencies
run: pnpm install

162
.github/workflows/rust.yml

@ -10,13 +10,16 @@ concurrency: @@ -10,13 +10,16 @@ concurrency:
group: ci-${{ github.head_ref }}
cancel-in-progress: true
env:
RUST_VERSION: "1.78"
jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- name: cargo fmt
@ -27,7 +30,7 @@ jobs: @@ -27,7 +30,7 @@ jobs:
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- name: cargo clippy
@ -41,7 +44,7 @@ jobs: @@ -41,7 +44,7 @@ jobs:
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- name: Run tests
@ -53,7 +56,7 @@ jobs: @@ -53,7 +56,7 @@ jobs:
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- name: Build release binary
@ -71,7 +74,7 @@ jobs: @@ -71,7 +74,7 @@ jobs:
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- name: Build release binary
@ -107,7 +110,7 @@ jobs: @@ -107,7 +110,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
cache: "pnpm"
- name: Get pnpm store directory
id: pnpm-cache
run: |
@ -141,7 +144,7 @@ jobs: @@ -141,7 +144,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
cache: "pnpm"
- name: Get pnpm store directory
id: pnpm-cache
run: |
@ -213,7 +216,7 @@ jobs: @@ -213,7 +216,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
cache: "pnpm"
- name: Get pnpm store directory
id: pnpm-cache
run: |
@ -237,7 +240,7 @@ jobs: @@ -237,7 +240,7 @@ jobs:
steps:
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
@ -278,11 +281,11 @@ jobs: @@ -278,11 +281,11 @@ jobs:
uses: docker/setup-qemu-action@v2
- uses: hecrj/setup-rust-action@v1
with:
rust-version: stable
rust-version: ${{env.RUST_VERSION}}
- uses: Swatinem/rust-cache@v2
with:
key: arm-binary-${{ matrix.arch }}
- name: 'Download `cross` crate'
- name: "Download `cross` crate"
run: cargo install cross
- uses: actions/checkout@v3
- name: "Build release"
@ -341,7 +344,7 @@ jobs: @@ -341,7 +344,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
cache: "pnpm"
- name: Get pnpm store directory
id: pnpm-cache
run: |
@ -358,71 +361,70 @@ jobs: @@ -358,71 +361,70 @@ jobs:
run: |
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
if: ${{ !github.event.pull_request }}
uses: peter-evans/commit-comment@v2
with:
body: |
## Linux Benchmarks
${{ steps.benchmark.outputs.markdown }}
# TODO: use bnz
# 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: |
# ## 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 @@ -7,3 +7,4 @@ feature_tests/.tmp
*.log
/site/public
.vercel
.proxy

2
.node-version

@ -1 +1 @@ @@ -1 +1 @@
18.12.1
18.16.1

30
CHANGELOG.md

@ -1,5 +1,35 @@ @@ -1,5 +1,35 @@
## 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
### Patch Changes

1224
Cargo.lock generated

File diff suppressed because it is too large Load Diff

45
Cargo.toml

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

54
README.md

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
<h1 align="center">
Fast Node Manager (<code>fnm</code>)
<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>
> :rocket: Fast and simple Node.js version manager, built in Rust
> 🚀 Fast and simple Node.js version manager, built in Rust
<div align="center">
<img src="./docs/fnm.svg" alt="Blazing fast!">
@ -12,13 +12,13 @@ @@ -12,13 +12,13 @@
## 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
@ -42,7 +42,7 @@ On other operating systems, upgrading `fnm` is almost the same as installing it. @@ -42,7 +42,7 @@ On other operating systems, upgrading `fnm` is almost the same as installing it.
`--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`
@ -68,6 +68,12 @@ brew install fnm @@ -68,6 +68,12 @@ brew install fnm
Then, [set up your shell for fnm](#shell-setup)
#### Using Winget (Windows)
```sh
winget install Schniz.fnm
```
#### Using Scoop (Windows)
```sh
@ -96,9 +102,10 @@ Then, [set up your shell for fnm](#shell-setup) @@ -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
- Make it available globally on `PATH` environment variable
- Configure your shell profile:
- [Set up your shell for fnm](#shell-setup)
### 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).
## Completions
@ -114,7 +121,7 @@ Where `<SHELL>` can be one of the supported shells: @@ -114,7 +121,7 @@ Where `<SHELL>` can be one of the supported shells:
- `bash`
- `zsh`
- `fish`
- `powershell`
- `power-shell`
Please follow your shell instructions to install them.
@ -122,9 +129,13 @@ 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.
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:
```bash
$ node --version
v14.18.3
@ -165,17 +176,24 @@ Add the following to the end of your profile file: @@ -165,17 +176,24 @@ Add the following to the end of your profile file:
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`
- On Windows to edit your profile you can run this in a PowerShell
```powershell
notepad $profile
```
#### 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
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
@ -183,18 +201,26 @@ Usage is very similar to the normal WinCMD install, apart for a few tweaks to al @@ -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:
- Make a .cmd file to invoke it
```batch
:: %CMDER_ROOT%\bin\fnm_init.cmd
@echo off
FOR /f "tokens=*" %%z IN ('fnm env --use-on-cd') DO CALL %%z
```
- Add it to the startup script
```batch
:: %CMDER_ROOT%\config\user_profile.cmd
call "%CMDER_ROOT%\bin\fnm_init.cmd"
```
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)
[See the available commands for an extended usage documentation](./docs/commands.md)

2
benchmarks/basic/fnm

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

6
benchmarks/basic/fnm_latest_master

@ -1,6 +0,0 @@ @@ -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 @@ @@ -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

917
docs/commands.md

File diff suppressed because it is too large Load Diff

72
docs/configuration.md

@ -0,0 +1,72 @@ @@ -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 @@ -42,6 +42,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then
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`] = `
"set -e
eval "$(fnm env)"
@ -118,6 +140,28 @@ if test "$____test____" != "v8.11.3" @@ -118,6 +140,28 @@ if test "$____test____" != "v8.11.3"
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`] = `
"fnm env | source
fnm install 6
@ -182,6 +226,22 @@ fnm use v8.11.3 @@ -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 }"
`;
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`] = `
"$ErrorActionPreference = "Stop"
fnm env | Out-String | Invoke-Expression
@ -249,6 +309,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then @@ -249,6 +309,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then
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`] = `
"set -e
eval "$(fnm env)"

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

@ -0,0 +1,28 @@ @@ -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]) { @@ -40,6 +40,34 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) {
.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 () => {
await mkdir(join(testCwd(), "subdir"), { recursive: true })
await writeFile(join(testCwd(), "subdir", ".node-version"), "v12.22.12")

54
e2e/corepack.test.ts

@ -0,0 +1,54 @@ @@ -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]) { @@ -26,6 +26,8 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) {
FNM_LOGLEVEL: "info",
FNM_MULTISHELL_PATH: expect.any(String),
FNM_NODE_DIST_MIRROR: expect.any(String),
FNM_RESOLVE_ENGINES: "false",
FNM_COREPACK_ENABLED: "false",
FNM_VERSION_FILE_STRATEGY: "local",
})
}

21
e2e/shellcode/script.ts

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

2
e2e/shellcode/shells.ts

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

11
e2e/shellcode/shells/cmdEnv.ts

@ -1,14 +1,21 @@ @@ -1,14 +1,21 @@
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 }
function stringify(envConfig: Partial<EnvConfig> = {}) {
const { useOnCd, logLevel } = envConfig
const { useOnCd, logLevel, corepackEnabled, resolveEngines } = envConfig
return [
`fnm env`,
useOnCd && "--use-on-cd",
logLevel && `--log-level=${logLevel}`,
corepackEnabled && "--corepack-enabled",
resolveEngines && `--resolve-engines`,
]
.filter(Boolean)
.join(" ")

3
jest.config.cjs

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

5
jest.global-setup.js

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

17
package.json

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

2937
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

3
rust-toolchain.toml

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

9
src/arch.rs

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

4
src/archive/mod.rs

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

34
src/commands/completions.rs

@ -1,16 +1,16 @@ @@ -1,16 +1,16 @@
use super::command::Command;
use crate::cli::Cli;
use crate::config::FnmConfig;
use crate::shell::{infer_shell, AVAILABLE_SHELLS};
use clap::{IntoApp, Parser};
use clap_complete::{Generator, Shell};
use crate::shell::{infer_shell, Shell};
use crate::{cli::Cli, shell::Shells};
use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{Generator, Shell as ClapShell};
use thiserror::Error;
#[derive(Parser, Debug)]
pub struct Completions {
/// The shell syntax to use. Infers when missing.
#[clap(long)]
shell: Option<Shell>,
shell: Option<Shells>,
}
impl Command for Completions {
@ -18,11 +18,14 @@ impl Command for Completions { @@ -18,11 +18,14 @@ impl Command for Completions {
fn apply(self, _config: &FnmConfig) -> Result<(), Self::Error> {
let mut stdio = std::io::stdout();
let shell = self
let shell: Box<dyn Shell> = self
.shell
.map(Into::into)
.or_else(|| infer_shell().map(Into::into))
.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);
Ok(())
}
@ -41,9 +44,24 @@ pub enum Error { @@ -41,9 +44,24 @@ pub enum Error {
}
fn shells_as_string() -> String {
AVAILABLE_SHELLS
Shells::value_variants()
.iter()
.map(|x| format!("* {x}"))
.collect::<Vec<_>>()
.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; @@ -4,7 +4,8 @@ use crate::directories;
use crate::fs::symlink_dir;
use crate::outln;
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 std::collections::HashMap;
use std::fmt::Debug;
@ -14,8 +15,7 @@ use thiserror::Error; @@ -14,8 +15,7 @@ use thiserror::Error;
pub struct Env {
/// The shell syntax to use. Infers when missing.
#[clap(long)]
#[clap(possible_values = AVAILABLE_SHELLS)]
shell: Option<Box<dyn Shell>>,
shell: Option<Shells>,
/// Print JSON instead of shell commands.
#[clap(long, conflicts_with = "shell")]
json: bool,
@ -44,7 +44,7 @@ fn make_symlink(config: &FnmConfig) -> Result<std::path::PathBuf, Error> { @@ -44,7 +44,7 @@ fn make_symlink(config: &FnmConfig) -> Result<std::path::PathBuf, Error> {
}
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 }),
}
}
@ -64,17 +64,16 @@ impl Command for Env { @@ -64,17 +64,16 @@ impl Command for Env {
}
let multishell_path = make_symlink(config)?;
let multishell_path_str = multishell_path.to_str().unwrap().to_owned();
let binary_path = if cfg!(windows) {
multishell_path.clone()
multishell_path
} else {
multishell_path.join("bin")
};
let env_vars = HashMap::from([
(
"FNM_MULTISHELL_PATH",
multishell_path.to_str().unwrap().to_owned(),
),
("FNM_MULTISHELL_PATH", multishell_path_str),
(
"FNM_VERSION_FILE_STRATEGY",
config.version_file_strategy().as_str().to_owned(),
@ -91,6 +90,11 @@ impl Command for Env { @@ -91,6 +90,11 @@ impl Command for Env {
"FNM_NODE_DIST_MIRROR",
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()),
]);
@ -101,6 +105,7 @@ impl Command for Env { @@ -101,6 +105,7 @@ impl Command for Env {
let shell: Box<dyn Shell> = self
.shell
.map(Into::into)
.or_else(infer_shell)
.ok_or(Error::CantInferShell)?;
@ -145,7 +150,7 @@ pub enum Error { @@ -145,7 +150,7 @@ pub enum Error {
}
fn shells_as_string() -> String {
AVAILABLE_SHELLS
Shells::value_variants()
.iter()
.map(|x| format!("* {x}"))
.collect::<Vec<_>>()
@ -158,16 +163,13 @@ mod tests { @@ -158,16 +163,13 @@ mod tests {
#[test]
fn test_smoke() {
use crate::shell;
let config = FnmConfig::default();
let shell: Box<dyn Shell> = if cfg!(windows) {
Box::from(shell::WindowsCmd)
} else {
Box::from(shell::Bash)
};
Env {
shell: Some(shell),
..Env::default()
#[cfg(windows)]
shell: Some(Shells::Cmd),
#[cfg(not(windows))]
shell: Some(Shells::Bash),
..Default::default()
}
.call(config);
}

31
src/commands/exec.rs

@ -23,6 +23,25 @@ pub struct Exec { @@ -23,6 +23,25 @@ pub struct Exec {
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 {
type Error = Error;
@ -69,6 +88,8 @@ impl Cmd for Exec { @@ -69,6 +88,8 @@ impl Cmd for Exec {
.map_err(|source| Error::CantAddPathToEnvironment { source })?
};
log::debug!("Running {} with PATH={:?}", binary, path_env);
let exit_status = Command::new(binary)
.args(arguments)
.stdin(Stdio::inherit())
@ -76,7 +97,10 @@ impl Cmd for Exec { @@ -76,7 +97,10 @@ impl Cmd for Exec {
.stderr(Stdio::inherit())
.env("PATH", path_env)
.spawn()
.expect("Can't spawn program")
.map_err(|source| Error::CantSpawnProgram {
source,
binary: binary.to_string(),
})?
.wait()
.expect("Failed to grab exit code");
@ -87,6 +111,11 @@ impl Cmd for Exec { @@ -87,6 +111,11 @@ impl Cmd for Exec {
#[derive(Debug, 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")]
CantReadPathVariable,
#[error("Can't add path to environment variable: {}", source)]

44
src/commands/install.rs

@ -1,9 +1,11 @@ @@ -1,9 +1,11 @@
use super::command::Command;
use crate::alias::create_alias;
use crate::arch::get_safe_arch;
use crate::config::FnmConfig;
use crate::downloader::{install_node_dist, Error as DownloaderError};
use crate::lts::LtsType;
use crate::outln;
use crate::progress::ProgressConfig;
use crate::remote_node_index;
use crate::user_version::UserVersion;
use crate::version::Version;
@ -24,6 +26,12 @@ pub struct Install { @@ -24,6 +26,12 @@ pub struct Install {
/// Install latest version
#[clap(long, conflicts_with_all = &["version", "lts"])]
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 {
@ -33,27 +41,31 @@ impl Install { @@ -33,27 +41,31 @@ impl Install {
version: v,
lts: false,
latest: false,
..
} => Ok(v),
Self {
version: None,
lts: true,
latest: false,
..
} => Ok(Some(UserVersion::Full(Version::Lts(LtsType::Latest)))),
Self {
version: None,
lts: false,
latest: true,
..
} => Ok(Some(UserVersion::Full(Version::Latest))),
_ => Err(Error::TooManyVersionsProvided),
}
}
}
impl super::command::Command for Install {
impl Command for Install {
type Error = Error;
fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
let current_dir = std::env::current_dir().unwrap();
let show_progress = self.progress.enabled(config);
let current_version = self
.version()?
@ -130,11 +142,13 @@ impl super::command::Command for Install { @@ -130,11 +142,13 @@ impl super::command::Command for Install {
&config.node_dist_mirror,
config.installations_dir(),
safe_arch,
show_progress,
) {
Err(err @ DownloaderError::VersionAlreadyInstalled { .. }) => {
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 {
@ -147,6 +161,11 @@ impl super::command::Command for Install { @@ -147,6 +161,11 @@ impl super::command::Command for Install {
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 {
let alias_name = Version::Lts(lts_type).v_str();
debug!(
@ -166,6 +185,19 @@ impl super::command::Command for Install { @@ -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)]
pub enum Error {
#[error("Can't download the requested binary: {}", source)]
@ -175,6 +207,11 @@ pub enum Error { @@ -175,6 +207,11 @@ pub enum Error {
#[from]
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.")]
CantInferVersion,
#[error("Having a hard time listing the remote versions: {}", source)]
@ -196,7 +233,6 @@ pub enum Error { @@ -196,7 +233,6 @@ pub enum Error {
#[cfg(test)]
mod tests {
use super::super::command::Command;
use super::*;
use pretty_assertions::assert_eq;
use std::str::FromStr;
@ -211,6 +247,7 @@ mod tests { @@ -211,6 +247,7 @@ mod tests {
version: UserVersion::from_str("12.0.0").ok(),
lts: false,
latest: false,
progress: ProgressConfig::Never,
}
.apply(&config)
.expect("Can't install");
@ -236,6 +273,7 @@ mod tests { @@ -236,6 +273,7 @@ mod tests {
version: None,
lts: false,
latest: true,
progress: ProgressConfig::Never,
}
.apply(&config)
.expect("Can't install");

67
src/commands/ls_remote.rs

@ -1,20 +1,79 @@ @@ -1,20 +1,79 @@
use crate::config::FnmConfig;
use crate::remote_node_index;
use crate::user_version::UserVersion;
use colored::Colorize;
use thiserror::Error;
#[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 {
type Error = 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);
if let Some(lts) = &version.lts {
print!(" ({lts})");
print!("{}", format!(" ({lts})").cyan());
}
println!();
}

14
src/commands/use.rs

@ -4,6 +4,7 @@ use crate::current_version::current_version; @@ -4,6 +4,7 @@ use crate::current_version::current_version;
use crate::fs;
use crate::installed_versions;
use crate::outln;
use crate::shell;
use crate::system_version;
use crate::user_version::UserVersion;
use crate::version::Version;
@ -152,15 +153,15 @@ fn install_new_version( @@ -152,15 +153,15 @@ fn install_new_version(
fn replace_symlink(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
let symlink_deletion_result = fs::remove_symlink_dir(to);
match fs::symlink_dir(from, to) {
ok @ Ok(_) => ok,
ok @ Ok(()) => ok,
err @ Err(_) => symlink_deletion_result.and(err),
}
}
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;
}
@ -169,7 +170,7 @@ fn should_install_interactively(requested_version: &UserVersion) -> bool { @@ -169,7 +170,7 @@ fn should_install_interactively(requested_version: &UserVersion) -> bool {
requested_version.to_string().italic()
);
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());
std::io::stdout().flush().unwrap();
let mut s = String::new();
@ -190,8 +191,11 @@ fn warn_if_multishell_path_not_in_path_env_var( @@ -190,8 +191,11 @@ fn warn_if_multishell_path_not_in_path_env_var(
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()) {
if bin_path == path {
if bin_path == path || fixed_path == path.to_str() {
return;
}
}

49
src/config.rs

@ -7,7 +7,7 @@ use url::Url; @@ -7,7 +7,7 @@ use url::Url;
#[derive(clap::Parser, Debug)]
pub struct FnmConfig {
/// https://nodejs.org/dist/ mirror
/// <https://nodejs.org/dist/> mirror
#[clap(
long,
env = "FNM_NODE_DIST_MIRROR",
@ -36,10 +36,9 @@ pub struct FnmConfig { @@ -36,10 +36,9 @@ pub struct FnmConfig {
#[clap(
long,
env = "FNM_LOGLEVEL",
default_value = "info",
default_value_t,
global = true,
hide_env_values = true,
possible_values = LogLevel::possible_values()
hide_env_values = true
)]
log_level: LogLevel,
@ -57,19 +56,37 @@ pub struct FnmConfig { @@ -57,19 +56,37 @@ pub struct FnmConfig {
/// 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.
///
/// * `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(
long,
env = "FNM_VERSION_FILE_STRATEGY",
possible_values = VersionFileStrategy::possible_values(),
default_value = "local",
default_value_t,
global = true,
hide_env_values = true,
hide_env_values = true
)]
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 {
@ -81,6 +98,8 @@ impl Default for FnmConfig { @@ -81,6 +98,8 @@ impl Default for FnmConfig {
log_level: LogLevel::Info,
arch: Arch::default(),
version_file_strategy: VersionFileStrategy::default(),
corepack_enabled: false,
resolve_engines: false,
}
}
}
@ -90,6 +109,14 @@ impl FnmConfig { @@ -90,6 +109,14 @@ impl FnmConfig {
&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> {
match &self.multishell_path {
None => None,

5
src/default_version.rs

@ -3,7 +3,10 @@ use crate::version::Version; @@ -3,7 +3,10 @@ use crate::version::Version;
use std::str::FromStr;
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()?;
Version::from_str(file_name.to_str()?).ok()?.into()
} else {
Some(Version::Alias("default".into()))
}
}

19
src/downloader.rs

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

31
src/log_level.rs

@ -1,10 +1,26 @@ @@ -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 {
Quiet,
Error,
#[default]
#[value(alias("all"))]
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 {
pub fn is_writable(&self, logging: &Self) -> bool {
use std::cmp::Ordering;
@ -37,19 +53,6 @@ impl From<LogLevel> for &'static str { @@ -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_rules! outln {
($config:ident, $level:path, $($expr:expr),+) => {{

2
src/main.rs

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

19
src/package_json.rs

@ -0,0 +1,19 @@ @@ -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 @@ @@ -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 { @@ -16,7 +16,9 @@ impl Shell for Bash {
let path = path
.to_str()
.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 {
@ -26,13 +28,13 @@ impl Shell for Bash { @@ -26,13 +28,13 @@ impl Shell for Bash {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!(
r#"
r"
if [[ -f .node-version || -f .nvmrc ]]; then
fnm use --silent-if-unchanged
fi
"#
"
),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#,
VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
};
Ok(formatdoc!(
r#"

8
src/shell/fish.rs

@ -16,6 +16,8 @@ impl Shell for Fish { @@ -16,6 +16,8 @@ impl Shell for Fish {
let path = path
.to_str()
.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;"))
}
@ -26,13 +28,13 @@ impl Shell for Fish { @@ -26,13 +28,13 @@ impl Shell for Fish {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!(
r#"
r"
if test -f .node-version -o -f .nvmrc
fnm use --silent-if-unchanged
end
"#
"
),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#,
VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
};
Ok(formatdoc!(
r#"

2
src/shell/infer/mod.rs

@ -7,7 +7,7 @@ pub use self::unix::infer_shell; @@ -7,7 +7,7 @@ pub use self::unix::infer_shell;
#[cfg(not(unix))]
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};
match shell {
"sh" | "bash" => return Some(Box::from(Bash)),

4
src/shell/mod.rs

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

6
src/shell/powershell.rs

@ -28,11 +28,11 @@ impl Shell for PowerShell { @@ -28,11 +28,11 @@ impl Shell for PowerShell {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!(
r#"
r"
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!(
r#"

47
src/shell/shell.rs

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

2
src/shell/windows_cmd/cd.cmd

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

21
src/shell/windows_compat.rs

@ -0,0 +1,21 @@ @@ -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 { @@ -16,6 +16,8 @@ impl Shell for Zsh {
let path = path
.to_str()
.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"))
}
@ -30,13 +32,13 @@ impl Shell for Zsh { @@ -30,13 +32,13 @@ impl Shell for Zsh {
fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result<String> {
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => indoc!(
r#"
r"
if [[ -f .node-version || -f .nvmrc ]]; then
fnm use --silent-if-unchanged
fi
"#
"
),
VersionFileStrategy::Recursive => r#"fnm use --silent-if-unchanged"#,
VersionFileStrategy::Recursive => r"fnm use --silent-if-unchanged",
};
Ok(formatdoc!(
r#"

3
src/user_version.rs

@ -5,6 +5,7 @@ use std::str::FromStr; @@ -5,6 +5,7 @@ use std::str::FromStr;
pub enum UserVersion {
OnlyMajor(u64),
MajorMinor(u64, u64),
SemverRange(node_semver::Range),
Full(Version),
}
@ -41,6 +42,7 @@ impl UserVersion { @@ -41,6 +42,7 @@ impl UserVersion {
}
}
}
(Self::SemverRange(range), Version::Semver(semver)) => semver.satisfies(range),
(_, Version::Bypassed | Version::Lts(_) | Version::Alias(_) | Version::Latest) => false,
(Self::OnlyMajor(major), Version::Semver(other)) => *major == other.major,
(Self::MajorMinor(major, minor), Version::Semver(other)) => {
@ -59,6 +61,7 @@ impl std::fmt::Display for UserVersion { @@ -59,6 +61,7 @@ impl std::fmt::Display for UserVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Full(x) => x.fmt(f),
Self::SemverRange(x) => x.fmt(f),
Self::OnlyMajor(major) => write!(f, "v{major}.x.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_ @@ -4,7 +4,7 @@ use crate::version_files::{get_user_version_for_directory, get_user_version_for_
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum UserVersionReader {
Direct(UserVersion),
Path(PathBuf),
@ -14,7 +14,7 @@ impl UserVersionReader { @@ -14,7 +14,7 @@ impl UserVersionReader {
pub fn into_user_version(self, config: &FnmConfig) -> Option<UserVersion> {
match self {
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),
}
}

3
src/version.rs

@ -77,6 +77,9 @@ impl Version { @@ -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 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where

40
src/version_file_strategy.rs

@ -1,16 +1,25 @@ @@ -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 {
/// Use the local version of Node defined within the current directory
#[default]
Local,
/// Use the version of Node defined within the current directory and all parent directories
Recursive,
}
impl VersionFileStrategy {
pub fn possible_values() -> &'static [&'static str] {
&["local", "recursive"]
impl Display for VersionFileStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionFileStrategy::Local => write!(f, "local"),
VersionFileStrategy::Recursive => write!(f, "recursive"),
}
}
}
impl VersionFileStrategy {
pub fn as_str(&self) -> &'static str {
match self {
VersionFileStrategy::Local => "local",
@ -18,24 +27,3 @@ impl VersionFileStrategy { @@ -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
)),
}
}
}

57
src/version_files.rs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
use crate::config::FnmConfig;
use crate::default_version;
use crate::package_json::PackageJson;
use crate::user_version::UserVersion;
use crate::version_file_strategy::VersionFileStrategy;
use encoding_rs_io::DecodeReaderBytes;
@ -8,28 +9,30 @@ use std::io::Read; @@ -8,28 +9,30 @@ use std::io::Read;
use std::path::Path;
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(
path: impl AsRef<Path>,
config: &FnmConfig,
) -> Option<UserVersion> {
match config.version_file_strategy() {
VersionFileStrategy::Local => get_user_version_for_single_directory(path),
VersionFileStrategy::Recursive => {
get_user_version_for_directory_recursive(path).or_else(|| {
VersionFileStrategy::Local => get_user_version_for_single_directory(path, config),
VersionFileStrategy::Recursive => get_user_version_for_directory_recursive(path, config)
.or_else(|| {
info!("Did not find anything recursively. Falling back to default alias.");
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());
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);
}
@ -39,7 +42,10 @@ fn get_user_version_for_directory_recursive(path: impl AsRef<Path>) -> Option<Us @@ -39,7 +42,10 @@ fn get_user_version_for_directory_recursive(path: impl AsRef<Path>) -> Option<Us
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();
for path_part in &PATH_PARTS {
@ -49,7 +55,7 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U @@ -49,7 +55,7 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U
new_path.display(),
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);
}
}
@ -57,22 +63,43 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U @@ -57,22 +63,43 @@ pub fn get_user_version_for_single_directory(path: impl AsRef<Path>) -> Option<U
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 version = {
let file = {
let mut reader = DecodeReaderBytes::new(file);
let mut version = String::new();
reader.read_to_string(&mut version).map(|_| version)
};
match version {
Err(err) => {
match (file, is_pkg_json, config.resolve_engines()) {
(_, true, false) => None,
(Err(err), _, _) => {
info!("Can't read file: {}", err);
None
}
Ok(version) => {
(Ok(version), false, _) => {
info!("Found string {:?} in version file", version);
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 @@ @@ -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