Repository: volta-cli/volta Branch: main Commit: 5eedd5fb2f68 Files: 226 Total size: 1008.4 KB Directory structure: gitextract_9r0abwvz/ ├── .cargo/ │ └── config.toml ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── api-docs.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ ├── launch.json │ └── settings.json ├── CODE_OF_CONDUCT.md ├── COMPATIBILITY.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── RELEASES.md ├── ci/ │ ├── build-linux.sh │ ├── build-macos.sh │ ├── docker/ │ │ └── Dockerfile │ └── volta.manifest ├── crates/ │ ├── archive/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ ├── tarball.rs │ │ └── zip.rs │ ├── fs-utils/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── progress-read/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── test-support/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ ├── matchers.rs │ │ ├── paths.rs │ │ └── process.rs │ ├── validate-npm-package-name/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── volta-core/ │ │ ├── Cargo.toml │ │ ├── fixtures/ │ │ │ ├── basic/ │ │ │ │ ├── package.json │ │ │ │ └── subdir/ │ │ │ │ └── .gitkeep │ │ │ ├── cycle-1/ │ │ │ │ ├── package.json │ │ │ │ └── volta.json │ │ │ ├── cycle-2/ │ │ │ │ ├── package.json │ │ │ │ ├── workspace-1.json │ │ │ │ └── workspace-2.json │ │ │ ├── hooks/ │ │ │ │ ├── bins.json │ │ │ │ ├── event_url.json │ │ │ │ ├── format_github.json │ │ │ │ ├── format_npm.json │ │ │ │ ├── prefixes.json │ │ │ │ ├── project/ │ │ │ │ │ ├── .volta/ │ │ │ │ │ │ └── hooks.json │ │ │ │ │ └── package.json │ │ │ │ └── templates.json │ │ │ ├── nested/ │ │ │ │ ├── package.json │ │ │ │ └── subproject/ │ │ │ │ ├── inner_project/ │ │ │ │ │ └── package.json │ │ │ │ └── package.json │ │ │ ├── no_toolchain/ │ │ │ │ └── package.json │ │ │ └── yarn/ │ │ │ ├── pnp-cjs/ │ │ │ │ ├── .pnp.cjs │ │ │ │ └── package.json │ │ │ ├── pnp-js/ │ │ │ │ ├── .pnp.js │ │ │ │ └── package.json │ │ │ └── yarnrc-yml/ │ │ │ ├── .yarnrc.yml │ │ │ └── package.json │ │ └── src/ │ │ ├── command.rs │ │ ├── error/ │ │ │ ├── kind.rs │ │ │ ├── mod.rs │ │ │ └── reporter.rs │ │ ├── event.rs │ │ ├── fs.rs │ │ ├── hook/ │ │ │ ├── mod.rs │ │ │ ├── serial.rs │ │ │ └── tool.rs │ │ ├── inventory.rs │ │ ├── layout/ │ │ │ ├── mod.rs │ │ │ ├── unix.rs │ │ │ └── windows.rs │ │ ├── lib.rs │ │ ├── log.rs │ │ ├── monitor.rs │ │ ├── platform/ │ │ │ ├── image.rs │ │ │ ├── mod.rs │ │ │ ├── system.rs │ │ │ └── tests.rs │ │ ├── project/ │ │ │ ├── mod.rs │ │ │ ├── serial.rs │ │ │ └── tests.rs │ │ ├── run/ │ │ │ ├── binary.rs │ │ │ ├── executor.rs │ │ │ ├── mod.rs │ │ │ ├── node.rs │ │ │ ├── npm.rs │ │ │ ├── npx.rs │ │ │ ├── parser.rs │ │ │ ├── pnpm.rs │ │ │ └── yarn.rs │ │ ├── session.rs │ │ ├── shim.rs │ │ ├── signal.rs │ │ ├── style.rs │ │ ├── sync.rs │ │ ├── tool/ │ │ │ ├── mod.rs │ │ │ ├── node/ │ │ │ │ ├── fetch.rs │ │ │ │ ├── metadata.rs │ │ │ │ ├── mod.rs │ │ │ │ └── resolve.rs │ │ │ ├── npm/ │ │ │ │ ├── fetch.rs │ │ │ │ ├── mod.rs │ │ │ │ └── resolve.rs │ │ │ ├── package/ │ │ │ │ ├── configure.rs │ │ │ │ ├── install.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── metadata.rs │ │ │ │ ├── mod.rs │ │ │ │ └── uninstall.rs │ │ │ ├── pnpm/ │ │ │ │ ├── fetch.rs │ │ │ │ ├── mod.rs │ │ │ │ └── resolve.rs │ │ │ ├── registry.rs │ │ │ ├── serial.rs │ │ │ └── yarn/ │ │ │ ├── fetch.rs │ │ │ ├── metadata.rs │ │ │ ├── mod.rs │ │ │ └── resolve.rs │ │ ├── toolchain/ │ │ │ ├── mod.rs │ │ │ └── serial.rs │ │ └── version/ │ │ ├── mod.rs │ │ └── serial.rs │ ├── volta-layout/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ ├── macros.rs │ │ ├── v0.rs │ │ ├── v1.rs │ │ ├── v2.rs │ │ ├── v3.rs │ │ └── v4.rs │ ├── volta-layout-macro/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── ast.rs │ │ ├── ir.rs │ │ └── lib.rs │ └── volta-migrate/ │ ├── Cargo.toml │ └── src/ │ ├── empty.rs │ ├── lib.rs │ ├── v0.rs │ ├── v1.rs │ ├── v2.rs │ ├── v3/ │ │ └── config.rs │ ├── v3.rs │ └── v4.rs ├── dev/ │ ├── package.json │ ├── rpm/ │ │ ├── build-rpm.sh │ │ └── volta.spec │ └── unix/ │ ├── SHASUMS256.txt │ ├── boot-install.sh │ ├── build.sh │ ├── install.sh.in │ ├── release.sh │ ├── test-events │ ├── tests/ │ │ └── install-script.bats │ ├── volta-install-legacy.sh │ └── volta-install.sh ├── rust-toolchain.toml ├── src/ │ ├── cli.rs │ ├── command/ │ │ ├── completions.rs │ │ ├── fetch.rs │ │ ├── install.rs │ │ ├── list/ │ │ │ ├── human.rs │ │ │ ├── mod.rs │ │ │ ├── plain.rs │ │ │ └── toolchain.rs │ │ ├── mod.rs │ │ ├── pin.rs │ │ ├── run.rs │ │ ├── setup.rs │ │ ├── uninstall.rs │ │ ├── use.rs │ │ └── which.rs │ ├── common.rs │ ├── main.rs │ ├── volta-migrate.rs │ └── volta-shim.rs ├── tests/ │ ├── acceptance/ │ │ ├── corrupted_download.rs │ │ ├── direct_install.rs │ │ ├── direct_uninstall.rs │ │ ├── execute_binary.rs │ │ ├── hooks.rs │ │ ├── main.rs │ │ ├── merged_platform.rs │ │ ├── migrations.rs │ │ ├── run_shim_directly.rs │ │ ├── support/ │ │ │ ├── events_helpers.rs │ │ │ ├── mod.rs │ │ │ └── sandbox.rs │ │ ├── verbose_errors.rs │ │ ├── volta_bypass.rs │ │ ├── volta_install.rs │ │ ├── volta_pin.rs │ │ ├── volta_run.rs │ │ └── volta_uninstall.rs │ ├── fixtures/ │ │ ├── cli-dist-2.4.159.tgz │ │ ├── cli-dist-3.12.99.tgz │ │ ├── cli-dist-3.2.42.tgz │ │ ├── cli-dist-3.7.71.tgz │ │ ├── npm-1.2.3.tgz │ │ ├── npm-4.5.6.tgz │ │ ├── npm-8.1.5.tgz │ │ ├── pnpm-0.0.1.tgz │ │ ├── pnpm-6.34.0.tgz │ │ ├── pnpm-7.7.1.tgz │ │ ├── volta-test-1.0.0.tgz │ │ ├── yarn-0.0.1.tgz │ │ ├── yarn-1.12.99.tgz │ │ ├── yarn-1.2.42.tgz │ │ ├── yarn-1.4.159.tgz │ │ └── yarn-1.7.71.tgz │ └── smoke/ │ ├── autodownload.rs │ ├── direct_install.rs │ ├── direct_upgrade.rs │ ├── main.rs │ ├── npm_link.rs │ ├── package_migration.rs │ ├── support/ │ │ ├── mod.rs │ │ └── temp_project.rs │ ├── volta_fetch.rs │ ├── volta_install.rs │ └── volta_run.rs ├── volta.iml └── wix/ ├── License.rtf ├── main.wxs └── shim.cmd ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.x86_64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] [target.aarch64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "cargo" open-pull-requests-limit: 5 schedule: interval: "daily" directories: - "/" - "/crates/*" ================================================ FILE: .github/workflows/api-docs.yml ================================================ on: push: branches: - main name: API Docs jobs: publish: name: Build and publish runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 with: persist-credentials: false - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Cargo Cache uses: Swatinem/rust-cache@v2 - name: Build docs run: | cargo doc --all --features cross-platform-docs --no-deps --document-private-items - name: Prepare docs for publication run: | mkdir -p publish mv target/doc publish/main echo 'volta' > publish/main/index.html echo 'main' > publish/index.html - name: Publish docs to GitHub pages uses: JamesIves/github-pages-deploy-action@releases/v3 with: COMMIT_MESSAGE: Publishing GitHub Pages GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages FOLDER: publish SINGLE_COMMIT: true ================================================ FILE: .github/workflows/release.yml ================================================ on: push: tags: - v* pull_request: branches: - main name: Production jobs: linux: name: Build - Linux runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up docker buildx uses: docker/setup-buildx-action@v2 - name: Build docker image uses: docker/build-push-action@v3 with: cache-from: type=gha cache-to: type=gha,mode=max context: ./ci/docker push: false load: true tags: volta - name: Compile and package Volta run: docker run --volume ${PWD}:/root/workspace --workdir /root/workspace --rm --init --tty volta /root/workspace/ci/build-linux.sh volta-linux - name: Upload release artifact uses: actions/upload-artifact@v4 with: name: linux path: target/release/volta-linux.tar.gz linux-arm: name: Build - Linux ARM runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Install cross-rs uses: taiki-e/install-action@v2 with: tool: cross - name: Compile Volta run: cross build --release --target aarch64-unknown-linux-gnu - name: Package Volta run: | cd target/aarch64-unknown-linux-gnu/release && tar -zcvf "volta-linux-arm.tar.gz" volta volta-shim volta-migrate - name: Upload release artifact uses: actions/upload-artifact@v4 with: name: linux-arm path: target/aarch64-unknown-linux-gnu/release/volta-linux-arm.tar.gz macos: name: Build - MacOS runs-on: macos-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 with: target: aarch64-apple-darwin,x86_64-apple-darwin - name: Compile and package Volta run: ./ci/build-macos.sh volta-macos - name: Upload release artifact uses: actions/upload-artifact@v4 with: name: macos path: target/universal-apple-darwin/release/volta-macos.tar.gz windows: name: Build - Windows runs-on: windows-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 with: rustflags: "" - name: Add cargo-wix subcommand run: cargo install --locked cargo-wix - name: Compile and package installer run: | cargo wix --nocapture --package volta --output target\wix\volta-windows.msi - name: Create zip of binaries run: powershell Compress-Archive volta*.exe volta-windows.zip working-directory: ./target/release - name: Upload installer uses: actions/upload-artifact@v4 with: name: windows-installer path: target/wix/volta-windows.msi - name: Upload zip uses: actions/upload-artifact@v4 with: name: windows-zip path: target/release/volta-windows.zip windows-arm: name: Build - Windows ARM runs-on: windows-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 with: target: aarch64-pc-windows-msvc rustflags: "" - name: Add cargo-wix subcommand run: cargo install --locked cargo-wix - name: Compile and package installer run: | cargo wix --nocapture --package volta --target aarch64-pc-windows-msvc --output target\wix\volta-windows-arm.msi - name: Create zip of binaries run: powershell Compress-Archive volta*.exe volta-windows-arm.zip working-directory: ./target/aarch64-pc-windows-msvc/release - name: Upload installer uses: actions/upload-artifact@v4 with: name: windows-installer-arm path: target/wix/volta-windows-arm.msi - name: Upload zip uses: actions/upload-artifact@v4 with: name: windows-zip-arm path: target/aarch64-pc-windows-msvc/release/volta-windows-arm.zip release: name: Publish release runs-on: ubuntu-latest needs: - linux - linux-arm - macos - windows - windows-arm if: github.event_name == 'push' steps: - name: Check out code uses: actions/checkout@v4 - name: Determine release version id: release_info env: TAG: ${{ github.ref }} run: echo "version=${TAG:11}" >> $GITHUB_OUTPUT - name: Fetch Linux artifact uses: actions/download-artifact@v4 with: name: linux path: release - name: Fetch Linux ARM artifact uses: actions/download-artifact@v4 with: name: linux-arm path: release - name: Fetch MacOS artifact uses: actions/download-artifact@v4 with: name: macos path: release - name: Fetch Windows installer uses: actions/download-artifact@v4 with: name: windows-installer path: release - name: Fetch Windows zip uses: actions/download-artifact@v4 with: name: windows-zip path: release - name: Fetch Windows ARM installer uses: actions/download-artifact@v4 with: name: windows-installer-arm path: release - name: Fetch Windows ARM zip uses: actions/download-artifact@v4 with: name: windows-zip-arm path: release - name: Show release artifacts run: ls -la release - name: Create draft release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: true - name: Upload Linux artifact uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-linux.tar.gz asset_name: volta-${{ steps.release_info.outputs.version }}-linux.tar.gz asset_content_type: application/gzip - name: Upload Linux ARM artifact uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-linux-arm.tar.gz asset_name: volta-${{ steps.release_info.outputs.version }}-linux-arm.tar.gz asset_content_type: application/gzip - name: Upload MacOS artifact uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-macos.tar.gz asset_name: volta-${{ steps.release_info.outputs.version }}-macos.tar.gz asset_content_type: application/gzip - name: Upload Windows installer uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-windows.msi asset_name: volta-${{ steps.release_info.outputs.version }}-windows-x86_64.msi asset_content_type: application/x-msi - name: Upload Windows zip uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-windows.zip asset_name: volta-${{ steps.release_info.outputs.version }}-windows.zip asset_content_type: application/zip - name: Upload Windows ARM installer uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-windows-arm.msi asset_name: volta-${{ steps.release_info.outputs.version }}-windows-arm64.msi asset_content_type: application/x-msi - name: Upload Windows ARM zip uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release/volta-windows-arm.zip asset_name: volta-${{ steps.release_info.outputs.version }}-windows-arm64.zip asset_content_type: application/zip - name: Upload manifest file uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./ci/volta.manifest asset_name: volta.manifest asset_content_type: text/plain ================================================ FILE: .github/workflows/test.yml ================================================ on: push: branches: - main pull_request: branches: - main name: Test jobs: tests: strategy: matrix: os: - ubuntu - macos - windows name: Acceptance Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }}-latest env: RUST_BACKTRACE: full steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run tests run: | cargo test --all --features mock-network - name: Lint with clippy run: cargo clippy - name: Lint tests with clippy run: | cargo clippy --tests --features mock-network smoke-tests: name: Smoke Tests runs-on: macos-latest env: RUST_BACKTRACE: full steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run tests run: | cargo test --test smoke --features smoke-tests -- --test-threads 1 shell-tests: name: Shell Script Tests runs-on: ubuntu-latest steps: - name: Setup BATS run: sudo npm install -g bats - name: Check out code uses: actions/checkout@v4 - name: Run tests run: bats dev/unix/tests/ check-formatting: name: Check code formatting runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up cargo uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run check run: | cargo fmt --all --quiet -- --check validate-installer-checksum: name: Validate installer checksum runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Run check run: | cd dev/unix sha256sum --check SHASUMS256.txt ================================================ FILE: .gitignore ================================================ /target/ **/*.rs.bk Notion.msi Volta.msi dev/windows/*.log dev/windows/Notion.wixobj dev/windows/Volta.wixobj dev/windows/Notion.wixpdb dev/windows/Volta.wixpdb dev/unix/install.sh /.idea/ /rls/ /rls* # Created by https://www.gitignore.io/api/intellij (and then modified heavily) ### Intellij ### # Ignore all IDEA files. This means you may have to rebuild the project on your # new machines at times, but avoids checking in a bunch of files which are not # generally relevant to other developers. .idea # CMake cmake-build-*/ # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # End of https://www.gitignore.io/api/intellij ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "type": "lldb", "request": "launch", "name": "Cargo run volta core", "cargo": { "args": ["run", "--bin", "volta"], "filter": { "kind": "bin", "name": "volta" } }, "program": "${cargo:program}", "args": [], "sourceLanguages": ["rust"] }, { "type": "lldb", "request": "launch", "name": "Cargo test volta core", "cargo": { "args": [ "test", "--lib", "--no-run", "--package", "volta-core", "--", "--test-threads", "1" ], "filter": { "kind": "lib", "name": "volta-core" } }, "program": "${cargo:program}", "args": [], "sourceLanguages": ["rust"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at david.herman@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: COMPATIBILITY.md ================================================ # Compatibility Volta currently tests against the following platforms, and will treat it as a breaking change to drop support for them: - macOS - x86-64 - Apple Silicon - Linux x86-64 - Windows x86-64 We compile release artifacts compatible with the following, and likewise will treat it as a breaking change to drop support for them: - macOS v11 - RHEL and CentOS v7 - Windows 10 In general, Volta should build and run against any other modern hardware and operating system supported by stable Rust, and we will make a best effort not to break them. However, we do *not* include them in our SemVer guarantees or test against them. ================================================ FILE: CONTRIBUTING.md ================================================ Please see https://docs.volta.sh/contributing/ ================================================ FILE: Cargo.toml ================================================ [package] name = "volta" version = "2.0.2" authors = ["David Herman ", "Charles Pierce "] license = "BSD-2-Clause" repository = "https://github.com/volta-cli/volta" edition = "2021" [features] cross-platform-docs = ["volta-core/cross-platform-docs"] mock-network = ["mockito", "volta-core/mock-network"] volta-dev = [] smoke-tests = [] [[bin]] name = "volta-shim" path = "src/volta-shim.rs" [[bin]] name = "volta-migrate" path = "src/volta-migrate.rs" [profile.release] lto = "fat" codegen-units = 1 [dependencies] volta-core = { path = "crates/volta-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.135" once_cell = "1.19.0" log = { version = "0.4", features = ["std"] } node-semver = "2" clap = { version = "4.5.24", features = ["color", "derive", "wrap_help"] } clap_complete = "4.5.46" mockito = { version = "0.31.1", optional = true } textwrap = "0.16.1" which = "7.0.1" dirs = "6.0.0" volta-migrate = { path = "crates/volta-migrate" } [target.'cfg(windows)'.dependencies] winreg = "0.53.0" [dev-dependencies] hamcrest2 = "0.3.0" envoy = "0.1.3" ci_info = "0.14.14" headers = "0.4" cfg-if = "1.0" test-support = { path = "crates/test-support" } [workspace] ================================================ FILE: LICENSE ================================================ BSD 2-CLAUSE LICENSE Copyright (c) 2017, The Volta Contributors. All rights reserved. This product includes: Contributions from LinkedIn Corporation Copyright (c) 2017, LinkedIn Corporation. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of the FreeBSD Project. ================================================ FILE: README.md ================================================

Volta

The Hassle-Free JavaScript Tool Manager

Production Build Status Test Status

--- > [!IMPORTANT] > **Volta is unmaintained.** Everything that works today should continue to do so for the foreseeable future, so if it is working for you, there is no particular *urgency* to migrate to another tool, but we will not be able to address breakages from new OS releases or other changes in the ecosystem, so you should put it on your maintenance roadmap at some point. We recommend migrating to [`mise`](https://mise.jdx.dev/). See [issue #2080](https://github.com/volta-cli/volta/issues/2080). --- **Fast:** Install and run any JS tool quickly and seamlessly! Volta is built in Rust and ships as a snappy static binary. **Reliable:** Ensure everyone in your project has the same tools—without interfering with their workflow. **Universal:** No matter the package manager, Node runtime, or OS, one command is all you need: `volta install`. ## Features - Speed 🚀 - Seamless, per-project version switching - Cross-platform support, including Windows and all Unix shells - Support for multiple package managers - Stable tool installation—no reinstalling on every Node upgrade! - Extensibility hooks for site-specific customization ## Installing Volta Read the [Getting Started Guide](https://docs.volta.sh/guide/getting-started) on our website for detailed instructions on how to install Volta. ## Using Volta Read the [Understanding Volta Guide](https://docs.volta.sh/guide/understanding) on our website for detailed instructions on how to use Volta. ## Contributing to Volta Contributions are always welcome, no matter how large or small. Substantial feature ideas should be proposed as an [RFC](https://github.com/volta-cli/rfcs). Before contributing, please read the [code of conduct](CODE_OF_CONDUCT.md). See the [Contributing Guide](https://docs.volta.sh/contributing/) on our website for detailed instructions on how to contribute to Volta. ## Who is using Volta?
TypeScript Sentry
See [here](https://sourcegraph.com/search?q=context:global+%22volta%22+file:package.json&patternType=literal) for more Volta users. ================================================ FILE: RELEASES.md ================================================ # Version 2.0.2 - Dependency updates - Improvements to header handling for HTTP requests (#1822, #1877) # Version 2.0.1 - Improved accuracy of Node download progress bar on Windows (#1833) - You should no longer run into errors about needing the VC++ Runtime on Windows (#1844) - The data provided when installing a new Node version is now more relevant and accurate (#1846, #1848) - Increased performance to make Volta even more responsive in typical use (#1849) - `volta run` will now correctly handle flags in more situations (#1857) # Version 2.0.0 - 🚨 (BREAKING) 🚨 We upgraded the version of Rust used to build Volta, which drops support for older versions of glibc & Linux kernel. See [the Rust announcement from August 2022](https://blog.rust-lang.org/2022/08/01/Increasing-glibc-kernel-requirements.html) for details about the supported versions. Notably, this means that we no longer support CentOS 6 (#1611) - 🚨 (BREAKING) 🚨 Due to costs and changes in the code signing process, we have dropped the code signing for the Windows installer. We now recommend using `winget` to install Volta on Windows (#1650) - 🎉 (NEW) 🎉 We now ship a pre-built binary for ARM Linux & ARM Windows (#1696, #1801) - Volta no longer requires Developer Mode to be enabled on Windows (#1755) - `volta uninstall` now provides better help & error messages to describe its use and limitations (#1628, #1786) - Volta will now use a universal binary on Mac, rather than separate Intel- & ARM-specific builds (#1635) - Switched to installing profile scripts into `.zshenv` by default, rather than `.zshrc` (#1657) - Added a default shim for the `yarnpkg` command, which is an alias of `yarn` (#1670) - Added a new `--very-verbose` flag to enable even more logging (note: we haven't yet implemented much additional logging) (#1815) - Simplified the fetching process to remove an extra network request and resolve hangs (#1812) - Several dependency upgrades and clean-up refactors from @tottoto # Version 1.1.1 - Experimental support for pnpm (requires `VOLTA_FEATURE_PNPM` environment variable) (#1273) - Fix to correctly import native root certificates (#1375) - Better detection of executables provided by `yarn` (#1388, #1393) # Version 1.1.0 - Added support for pinning / installing Yarn 3+ (#1305) - Improved portability and installer effectiveness by removing dependency on OpenSSL (#1214) # Version 1.0.8 - Fix for malformed `bin` entries when installing global packages (#997) - Dependency updates # Version 1.0.7 - Added build for Linux distros with OpenSSL 3.0 (#1211) # Version 1.0.6 - Fixed panic when `stdout` is closed (#1058) - Disabled global package interception when `--prefix` is provided (#1171) - Numerous dependency updates # Version 1.0.5 - Added error when attempting to install Node using `nvm` syntax (#1020) - Avoid modifying shell config if the environment is already correct (#990) - Prevent trying to read OS-generated files as package configs (#981) # Version 1.0.4 - Fetch native Apple silicon versions of Node when available (#974) # Version 1.0.3 - Fix pinning of `npm@bundled` when there is a custom default npm version (#957) - Use correct binary name for scoped packages with a string `bin` entry in `package.json` (#969) # Version 1.0.2 - Fix issues where `volta list` wasn't showing the correct information in all cases (#778, #926) - Make detection of tool name case-insensitive on Windows (#941) - Fix problem with `npm link` in a scoped package under npm 7 (#945) # Version 1.0.1 - Create Native build for Apple Silicon machines (#915, #917) # Version 1.0.0 - Support for `npm link` (#888, #889, #891) - Support for `npm update -g` and `yarn global upgrade` (#895) - Improvements in the handling of `npm` and `yarn` commands (#886, #887) # Version 0.9.3 - Various fixes to event plugin logic (#892, #894, #897) # Version 0.9.2 - Correctly detect Volta binary installation directory (#864) # Version 0.9.1 - Fix an issue with installing globals using npm 7 (#858) # Version 0.9.0 - Support Proxies through environment variables (#809, #851) - Avoid unnecessary `exists` calls for files (#834) - Rework package installs to allow for directly calling package manager (#848, #849) - **Breaking Change**: Remove support for `packages` hooks (#817) # Version 0.8.7 - Support fetching older versions of Yarn (#771) - Correctly detect `zsh` environment with `ZDOTDIR` variable (#799) - Prevent race conditions when installing tools (#684, #796) # Version 0.8.6 - Improve parsing of `engines` when installing a package (#791, #792) # Version 0.8.5 - Improve the stability of installing tools on systems with virus scanning software (#784) - Make `volta uninstall` work correctly when the original install had an issue (#787) # Version 0.8.4 - Add `{{filename}}` and `{{ext}}` (extension) replacements for `template` hooks (#774) - Show better error when running `volta install yarn` without a Node version available (#763) # Version 0.8.3 - Fix bug preventing custom `npm` versions from launching on Windows (#777) - Fix for completions in `zsh` for `volta list` (#772) # Version 0.8.2 - Add support for workspaces through the `extends` key in `package.json` (#755) - Improve `volta setup` to make profile scripts more shareable across machines (#756) # Version 0.8.1 - Fix panic when running `volta completions zsh` (#746) - Improve startup latency by reducing binary size (#732, #733, #734, #735) # Version 0.8.0 - Support for pinning / installing custom versions of `npm` (#691) - New command: `volta run` which will let you run one-off commands using custom versions of Node / Yarn / npm (#713) - Added default pretty formatter for `volta list` (#697) - Improved setup of Volta environment to make it work in more scenarios (#666, #725) - Bug fixes and performance improvements (#683, #701, #703, #704, #707, #717) # Version 0.7.2 - Added `npm.cmd`, `npx.cmd`, and `yarn.cmd` on Windows to support tools that look for CMD files specifically (#663) - Updated `volta setup` to also ensure that the shim symlinks are set up correctly (#662) # Version 0.7.1 - Added warning when attempting to `volta uninstall` a package you don't have installed (#638) - Added informational message about pinned project version when running `volta install` (#646) - `volta completions` will attempt to create the output directory if it doesn't exist (#647) - `volta install` will correctly handle script files that have CRLF as the line ending (#644) # Version 0.7.0 - Removed deprecated commands `volta activate`, `volta deactivate`, and `volta current` (#620, #559) - Simplified installer behavior and added data directory migration support (#619) - Removed reliance on UNC paths when executing node scripts (#637) # Version 0.6.8 - You can now use tagged versions when installing a tool with `volta install` (#604) - `volta install ` will now prefer LTS Node when pinning a version (#604) # Version 0.6.7 - `volta pin` will no longer remove a closing newline from `package.json` (#603) - New environment variable `VOLTA_BYPASS` will allow you to temporarily disable Volta shims (#603) # Version 0.6.6 - Node and Yarn can now both be pinned in the same command `volta pin node yarn` (#593) - Windows installer will now work on minimal Windows installs (e.g. Windows Sandbox) (#592) # Version 0.6.5 - `volta list` Now always outputs to stdout, regardless of how it is called (#581) - DEPRECATION: `volta activate` and `volta deactivate` are deprecated and will be removed in a future version (#571) # Version 0.6.4 - `volta install` now works for installing packages from a private, authenticated registry (#554) - `volta install` now has better diagnostic messages when things go wrong (#548) # Version 0.6.3 - `volta install` will no longer error when installing a scoped binary package (#537) # Version 0.6.2 - Added `volta list` command for inspecting the available tools and versions (#461) # Version 0.6.1 - Windows users will see a spinner instead of a � when Volta is loading data (#511) - Interrupting a tool with Ctrl+C will correctly wait for the tool to exit (#513) # Version 0.6.0 - Allow installing 3rd-party binaries from private registries (#469) # Version 0.5.7 - Prevent corrupting local cache by downloading tools to temp directory (#498) # Version 0.5.6 - Improve expected behavior with Yarn in projects (#470) - Suppress an erroneous "toolchain" key warning message (#486) # Version 0.5.5 - Proper support for relative paths in Bin hooks (#468) - Diagnostic messages for shims with `VOLTA_LOGLEVEL=debug` (#466) - Preserve user order for multiple tool installs (#479) # Version 0.5.4 - Show additional diagnostic messages when run with `--verbose` (#455) # Version 0.5.3 - Prevent unnecessary warning output when not running interactively (#451) - Fix a bug in load script for fish shell on Linux (#456) - Improve wrapping behavior for warning messages (#453) # Version 0.5.2 - Improve error messages when running a project-local binary fails (#426) - Fix execution of user binaries on Windows (#445) # Version 0.5.1 - Add per-project hooks configuration in `/.volta/hooks.json` (#411) - Support backwards compatibility with `toolchain` key in `package.json` (#434) # Version 0.5.0 - Rename to Volta: The JavaScript Launcher ⚡️ - Change `package.json` key to `volta` from `toolchain` (#413) - Update `volta completions` behavior to be more usable (#416) - Improve `volta which` to correctly find user tools (#419) - Remove unneeded lookups of `package.json` files (#420) - Cleanup of error messages and extraneous output (#421, #422) # Version 0.4.1 - Allow tool executions to pass through to the system if no Notion platform exists (#372) - Improve installer support for varied Linux distros # Version 0.4.0 - Update `notion install` to use `tool@version` formatting for specifying a tool (#383, #403) - Further error message improvements (#344, #395, #399, #400) - Clean up bugs around installing and running packages (#368, #390, #394, #396) - Include success messages when running `notion install` and `notion pin` (#397) # Version 0.3.0 - Support `lts` pseudo-version for Node (#331) - Error message improvements - Add `notion install` and `notion uninstall` for package binaries - Remove autoshimming # Version 0.2.2 - Add `notion which` command (#293) - Show progress when fetching Notion installer (#279) - Improved styling for usage information (#283) - Support for `fish` shell (#266, #290) - Consolidate binaries, for a ~2/3 size reduction of Notion installer (#274) # Version 0.2.1 - Move preventing globals behind a feature flag (#273) # Version 0.2.0 - Add support for OpenSSL 1.1.1 (#267) - Fix: ensure temp files are on the same volume (#257) - Intercept global package installations (#248) - Fix: make npx compatible with prelrease versions of npm (#239) - Fix: make `notion deactivate` work infallibly, without loading any files (#237) - Fix: make `"npm"` key optional in `package.json` (#233) - Fix: publish latest Notion version via self-hosted endpoint (#230) - Fix: eliminate excessive fetching and scanning for exact versions (#227) - Rename `notion use` to `notion pin` (#226) - Base filesystem isolation on `NOTION_HOME` env var (#224) - Fix: robust progress bar logic (#221) - Use JSON for internal state files (#220) - Support for npm and npx (#205) - Changes to directory layout (#181) # Version 0.1.5 - Autoshimming! (#163) - `notion deactivate` also unsets `NOTION_HOME` (#195) - Implemented `notion activate` (#201) - Fix for Yarn over-fetching bug (#203) # Version 0.1.4 - Fix for `package.json` parsing bug (#156) # Version 0.1.3 - Fix for Yarn path bug (#153) # Version 0.1.2 - Correct logic for computing `latest` version of Node (#144) - Don't crash if cache dir was deleted (#138) - Improved tests (#135) # Version 0.1.1 - Support for specifying `latest` as a version specifier (#133) - Suppress scary-looking symlink warnings on reinstall (#132) - Clearer error message for not-yet-implemented `notion install somebin` (#131) - Support optional `v` prefix to version specifiers (#130) # Version 0.1.0 First pre-release, supporting: - macOS and Linux (bash-only) - `notion install` (Node and Yarn only, no package binaries) - `notion use` - Proof-of-concept plugin API ================================================ FILE: ci/build-linux.sh ================================================ #!/bin/bash set -e # Activate the upgraded versions of GCC and binutils # See https://linux.web.cern.ch/centos7/docs/softwarecollections/#inst source /opt/rh/devtoolset-8/enable echo "Building Volta" cargo build --release echo "Packaging Binaries" cd target/release tar -zcvf "$1.tar.gz" volta volta-shim volta-migrate ================================================ FILE: ci/build-macos.sh ================================================ #!/bin/bash set -e echo "Building Volta" MACOSX_DEPLOYMENT_TARGET=11.0 cargo build --release --target=aarch64-apple-darwin MACOSX_DEPLOYMENT_TARGET=11.0 cargo build --release --target=x86_64-apple-darwin echo "Packaging Binaries" mkdir -p target/universal-apple-darwin/release for exe in volta volta-shim volta-migrate do lipo -create -output target/universal-apple-darwin/release/$exe target/x86_64-apple-darwin/release/$exe target/aarch64-apple-darwin/release/$exe done cd target/universal-apple-darwin/release tar -zcvf "$1.tar.gz" volta volta-shim volta-migrate ================================================ FILE: ci/docker/Dockerfile ================================================ FROM cern/cc7-base # This repo file references a URL that is no longer valid. It also isn't used by the build # toolchain, so we can safely remove it entirely RUN rm /etc/yum.repos.d/epel.repo # https://linux.web.cern.ch/centos7/docs/softwarecollections/#inst # Tools needed for the build and setup process RUN yum -y install wget tar # Fetch the repo information for the devtoolset repo RUN yum install -y centos-release-scl # Install more recent GCC and binutils, to allow us to compile RUN yum install -y devtoolset-8 RUN curl https://sh.rustup.rs -sSf | sh -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" ================================================ FILE: ci/volta.manifest ================================================ volta volta-shim volta-migrate ================================================ FILE: crates/archive/Cargo.toml ================================================ [package] name = "archive" version = "0.1.0" authors = ["David Herman "] edition = "2021" [dependencies] flate2 = "1.0" tar = "0.4.13" # Set features manually to drop usage of `time` crate: we do not rely on that # set of capabilities, and it has a vulnerability. We also don't need to use # every single compression algorithm feature since we are only downloading # Node as a zip file zip_rs = { version = "=2.1.6", package = "zip", default-features = false, features = ["deflate", "bzip2"] } tee = "0.1.0" fs-utils = { path = "../fs-utils" } progress-read = { path = "../progress-read" } verbatim = "0.1" cfg-if = "1.0" headers = "0.4" thiserror = "2.0.0" attohttpc = { version = "0.28", default-features = false, features = ["json", "compress", "tls-rustls-native-roots"] } log = { version = "0.4", features = ["std"] } ================================================ FILE: crates/archive/src/lib.rs ================================================ //! This crate provides types for fetching and unpacking compressed //! archives in tarball or zip format. use std::fs::File; use std::path::Path; use attohttpc::header::HeaderMap; use headers::{ContentLength, Header, HeaderMapExt}; use thiserror::Error; mod tarball; mod zip; pub use crate::tarball::Tarball; pub use crate::zip::Zip; /// Error type for this crate #[derive(Error, Debug)] pub enum ArchiveError { #[error("HTTP failure ({0})")] HttpError(attohttpc::StatusCode), #[error("HTTP header '{0}' not found")] MissingHeaderError(&'static attohttpc::header::HeaderName), #[error("unexpected content length in HTTP response: {0}")] UnexpectedContentLengthError(u64), #[error("{0}")] IoError(#[from] std::io::Error), #[error("{0}")] AttohttpcError(#[from] attohttpc::Error), #[error("{0}")] ZipError(#[from] zip_rs::result::ZipError), } /// Metadata describing whether an archive comes from a local or remote origin. #[derive(Copy, Clone)] pub enum Origin { Local, Remote, } pub trait Archive { fn compressed_size(&self) -> u64; /// Unpacks the zip archive to the specified destination folder. fn unpack( self: Box, dest: &Path, progress: &mut dyn FnMut(&(), usize), ) -> Result<(), ArchiveError>; fn origin(&self) -> Origin; } cfg_if::cfg_if! { if #[cfg(unix)] { /// Load an archive in the native OS-preferred format from the specified file. /// /// On Windows, the preferred format is zip. On Unixes, the preferred format /// is tarball. pub fn load_native(source: File) -> Result, ArchiveError> { Tarball::load(source) } /// Fetch a remote archive in the native OS-preferred format from the specified /// URL and store its results at the specified file path. /// /// On Windows, the preferred format is zip. On Unixes, the preferred format /// is tarball. pub fn fetch_native(url: &str, cache_file: &Path) -> Result, ArchiveError> { Tarball::fetch(url, cache_file) } } else if #[cfg(windows)] { /// Load an archive in the native OS-preferred format from the specified file. /// /// On Windows, the preferred format is zip. On Unixes, the preferred format /// is tarball. pub fn load_native(source: File) -> Result, ArchiveError> { Zip::load(source) } /// Fetch a remote archive in the native OS-preferred format from the specified /// URL and store its results at the specified file path. /// /// On Windows, the preferred format is zip. On Unixes, the preferred format /// is tarball. pub fn fetch_native(url: &str, cache_file: &Path) -> Result, ArchiveError> { Zip::fetch(url, cache_file) } } else { compile_error!("Unsupported OS (expected 'unix' or 'windows')."); } } /// Determines the length of an HTTP response's content in bytes, using /// the HTTP `"Content-Length"` header. fn content_length(headers: &HeaderMap) -> Result { headers .typed_get() .map(|ContentLength(v)| v) .ok_or_else(|| ArchiveError::MissingHeaderError(ContentLength::name())) } ================================================ FILE: crates/archive/src/tarball.rs ================================================ //! Provides types and functions for fetching and unpacking a Node installation //! tarball in Unix operating systems. use std::fs::File; use std::io::Read; use std::path::Path; use super::{content_length, Archive, ArchiveError, Origin}; use flate2::read::GzDecoder; use fs_utils::ensure_containing_dir_exists; use progress_read::ProgressRead; use tee::TeeReader; /// A Node installation tarball. pub struct Tarball { compressed_size: u64, data: Box, origin: Origin, } impl Tarball { /// Loads a tarball from the specified file. pub fn load(source: File) -> Result, ArchiveError> { let compressed_size = source.metadata()?.len(); Ok(Box::new(Tarball { compressed_size, data: Box::new(source), origin: Origin::Local, })) } /// Initiate fetching of a tarball from the given URL, returning a /// tarball that can be streamed (and that tees its data to a local /// file as it streams). pub fn fetch(url: &str, cache_file: &Path) -> Result, ArchiveError> { let (status, headers, response) = attohttpc::get(url).send()?.split(); if !status.is_success() { return Err(ArchiveError::HttpError(status)); } let compressed_size = content_length(&headers)?; ensure_containing_dir_exists(&cache_file)?; let file = File::create(cache_file)?; let data = Box::new(TeeReader::new(response, file)); Ok(Box::new(Tarball { compressed_size, data, origin: Origin::Remote, })) } } impl Archive for Tarball { fn compressed_size(&self) -> u64 { self.compressed_size } fn unpack( self: Box, dest: &Path, progress: &mut dyn FnMut(&(), usize), ) -> Result<(), ArchiveError> { let decoded = GzDecoder::new(ProgressRead::new(self.data, (), progress)); let mut tarball = tar::Archive::new(decoded); tarball.unpack(dest)?; Ok(()) } fn origin(&self) -> Origin { self.origin } } #[cfg(test)] pub mod tests { use crate::tarball::Tarball; use std::fs::File; use std::path::PathBuf; fn fixture_path(fixture_dir: &str) -> PathBuf { let mut cargo_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); cargo_manifest_dir.push("fixtures"); cargo_manifest_dir.push(fixture_dir); cargo_manifest_dir } #[test] fn test_load() { let mut test_file_path = fixture_path("tarballs"); test_file_path.push("test-file.tar.gz"); let test_file = File::open(test_file_path).expect("Couldn't open test file"); let tarball = Tarball::load(test_file).expect("Failed to load tarball"); assert_eq!(tarball.compressed_size(), 402); } } ================================================ FILE: crates/archive/src/zip.rs ================================================ //! Provides types and functions for fetching and unpacking a Node installation //! zip file in Windows operating systems. use std::fs::File; use std::io::Read; use std::path::Path; use super::{content_length, ArchiveError}; use fs_utils::ensure_containing_dir_exists; use progress_read::ProgressRead; use tee::TeeReader; use verbatim::PathExt; use zip_rs::unstable::stream::ZipStreamReader; use super::Archive; use super::Origin; pub struct Zip { compressed_size: u64, data: Box, origin: Origin, } impl Zip { /// Loads a cached Node zip archive from the specified file. pub fn load(source: File) -> Result, ArchiveError> { let compressed_size = source.metadata()?.len(); Ok(Box::new(Zip { compressed_size, data: Box::new(source), origin: Origin::Local, })) } /// Initiate fetching of a Node zip archive from the given URL, returning /// a `Remote` data source. pub fn fetch(url: &str, cache_file: &Path) -> Result, ArchiveError> { let (status, headers, response) = attohttpc::get(url).send()?.split(); if !status.is_success() { return Err(ArchiveError::HttpError(status)); } let compressed_size = content_length(&headers)?; ensure_containing_dir_exists(&cache_file)?; let file = File::create(cache_file)?; let data = Box::new(TeeReader::new(response, file)); Ok(Box::new(Zip { compressed_size, data, origin: Origin::Remote, })) } } impl Archive for Zip { fn compressed_size(&self) -> u64 { self.compressed_size } fn unpack( self: Box, dest: &Path, progress: &mut dyn FnMut(&(), usize), ) -> Result<(), ArchiveError> { // Use a verbatim path to avoid the legacy Windows 260 byte path limit. let dest: &Path = &dest.to_verbatim(); let zip = ZipStreamReader::new(ProgressRead::new(self.data, (), progress)); zip.extract(dest)?; Ok(()) } fn origin(&self) -> Origin { self.origin } } #[cfg(test)] pub mod tests { use crate::zip::Zip; use std::fs::File; use std::path::PathBuf; fn fixture_path(fixture_dir: &str) -> PathBuf { let mut cargo_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); cargo_manifest_dir.push("fixtures"); cargo_manifest_dir.push(fixture_dir); cargo_manifest_dir } #[test] fn test_load() { let mut test_file_path = fixture_path("zips"); test_file_path.push("test-file.zip"); let test_file = File::open(test_file_path).expect("Couldn't open test file"); let zip = Zip::load(test_file).expect("Failed to load zip file"); assert_eq!(zip.compressed_size(), 214); } } ================================================ FILE: crates/fs-utils/Cargo.toml ================================================ [package] name = "fs-utils" version = "0.1.0" authors = ["Michael Stewart "] edition = "2021" [dependencies] ================================================ FILE: crates/fs-utils/src/lib.rs ================================================ //! This crate provides utilities for operating on the filesystem. use std::fs; use std::io; use std::path::Path; /// This creates the parent directory of the input path, assuming the input path is a file. pub fn ensure_containing_dir_exists>(path: &P) -> io::Result<()> { path.as_ref() .parent() .ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!( "Could not determine directory information for {}", path.as_ref().display() ), ) }) .and_then(fs::create_dir_all) } ================================================ FILE: crates/progress-read/Cargo.toml ================================================ [package] name = "progress-read" version = "0.1.0" authors = ["David Herman "] edition = "2021" [dependencies] ================================================ FILE: crates/progress-read/src/lib.rs ================================================ //! This crate provides an adapter for the `std::io::Read` trait to //! allow reporting incremental progress to a callback function. use std::io::{self, Read, Seek, SeekFrom}; /// A reader that reports incremental progress while reading. pub struct ProgressRead T> { source: R, accumulator: T, progress: F, } impl T> Read for ProgressRead { /// Read some bytes from the underlying reader into the specified buffer, /// and report progress to the progress callback. The progress callback is /// passed the current value of the accumulator as its first argument and /// the number of bytes read as its second argument. The result of the /// progress callback is stored as the updated value of the accumulator, /// to be passed to the next invocation of the callback. fn read(&mut self, buf: &mut [u8]) -> io::Result { let len = self.source.read(buf)?; let new_accumulator = { let progress = &mut self.progress; progress(&self.accumulator, len) }; self.accumulator = new_accumulator; Ok(len) } } impl T> ProgressRead { /// Construct a new progress reader with the specified underlying reader, /// initial value for an accumulator, and progress callback. pub fn new(source: R, init: T, progress: F) -> ProgressRead { ProgressRead { source, accumulator: init, progress, } } } impl T> Seek for ProgressRead { fn seek(&mut self, pos: SeekFrom) -> io::Result { self.source.seek(pos) } } ================================================ FILE: crates/test-support/Cargo.toml ================================================ [package] name = "test-support" version = "0.1.0" authors = ["David Herman "] edition = "2021" [dependencies] hamcrest2 = "0.3.0" serde_json = { version = "1.0.135" } thiserror = "2.0.9" ================================================ FILE: crates/test-support/src/lib.rs ================================================ //! Utilities to use with acceptance tests in Volta. #[macro_export] macro_rules! ok_or_panic { { $e:expr } => { match $e { Ok(x) => x, Err(err) => panic!("{} failed with {}", stringify!($e), err), } }; } pub mod matchers; pub mod paths; pub mod process; ================================================ FILE: crates/test-support/src/matchers.rs ================================================ use std::fmt; use std::process::Output; use std::str; use crate::process::ProcessBuilder; use hamcrest2::core::{MatchResult, Matcher}; use serde_json::{self, Value}; #[derive(Clone)] pub struct Execs { expect_stdout: Option, expect_stderr: Option, expect_exit_code: Option, expect_stdout_contains: Vec, expect_stderr_contains: Vec, expect_either_contains: Vec, expect_stdout_contains_n: Vec<(String, usize)>, expect_stdout_not_contains: Vec, expect_stderr_not_contains: Vec, expect_stderr_unordered: Vec, expect_neither_contains: Vec, expect_json: Option>, } impl Execs { /// Verify that stdout is equal to the given lines. /// See `lines_match` for supported patterns. pub fn with_stdout(mut self, expected: S) -> Execs { self.expect_stdout = Some(expected.to_string()); self } /// Verify that stderr is equal to the given lines. /// See `lines_match` for supported patterns. pub fn with_stderr(mut self, expected: S) -> Execs { self._with_stderr(&expected); self } fn _with_stderr(&mut self, expected: &dyn ToString) { self.expect_stderr = Some(expected.to_string()); } /// Verify the exit code from the process. pub fn with_status(mut self, expected: i32) -> Execs { self.expect_exit_code = Some(expected); self } /// Verify that stdout contains the given contiguous lines somewhere in /// its output. /// See `lines_match` for supported patterns. pub fn with_stdout_contains(mut self, expected: S) -> Execs { self.expect_stdout_contains.push(expected.to_string()); self } /// Verify that stderr contains the given contiguous lines somewhere in /// its output. /// See `lines_match` for supported patterns. pub fn with_stderr_contains(mut self, expected: S) -> Execs { self.expect_stderr_contains.push(expected.to_string()); self } /// Verify that either stdout or stderr contains the given contiguous /// lines somewhere in its output. /// See `lines_match` for supported patterns. pub fn with_either_contains(mut self, expected: S) -> Execs { self.expect_either_contains.push(expected.to_string()); self } /// Verify that stdout contains the given contiguous lines somewhere in /// its output, and should be repeated `number` times. /// See `lines_match` for supported patterns. pub fn with_stdout_contains_n(mut self, expected: S, number: usize) -> Execs { self.expect_stdout_contains_n .push((expected.to_string(), number)); self } /// Verify that stdout does not contain the given contiguous lines. /// See `lines_match` for supported patterns. /// See note on `with_stderr_does_not_contain`. pub fn with_stdout_does_not_contain(mut self, expected: S) -> Execs { self.expect_stdout_not_contains.push(expected.to_string()); self } /// Verify that stderr does not contain the given contiguous lines. /// See `lines_match` for supported patterns. /// /// Care should be taken when using this method because there is a /// limitless number of possible things that *won't* appear. A typo means /// your test will pass without verifying the correct behavior. If /// possible, write the test first so that it fails, and then implement /// your fix/feature to make it pass. pub fn with_stderr_does_not_contain(mut self, expected: S) -> Execs { self.expect_stderr_not_contains.push(expected.to_string()); self } /// Verify that all of the stderr output is equal to the given lines, /// ignoring the order of the lines. /// See `lines_match` for supported patterns. /// This is useful when checking the output of `cargo build -v` since /// the order of the output is not always deterministic. /// Recommend use `with_stderr_contains` instead unless you really want to /// check *every* line of output. /// /// Be careful when using patterns such as `[..]`, because you may end up /// with multiple lines that might match, and this is not smart enough to /// do anything like longest-match. For example, avoid something like: /// [RUNNING] `rustc [..] /// [RUNNING] `rustc --crate-name foo [..] /// This will randomly fail if the other crate name is `bar`, and the /// order changes. pub fn with_stderr_unordered(mut self, expected: S) -> Execs { self.expect_stderr_unordered.push(expected.to_string()); self } /// Verify the JSON output matches the given JSON. /// Typically used when testing cargo commands that emit JSON. /// Each separate JSON object should be separated by a blank line. /// Example: /// assert_that( /// p.cargo("metadata"), /// execs().with_json(r#" /// {"example": "abc"} /// {"example": "def"} /// "#) /// ); /// Objects should match in the order given. /// The order of arrays is ignored. /// Strings support patterns described in `lines_match`. /// Use `{...}` to match any object. pub fn with_json(mut self, expected: &str) -> Execs { self.expect_json = Some( expected .split("\n\n") .map(|obj| obj.parse().unwrap()) .collect(), ); self } fn match_output(&self, actual: &Output) -> MatchResult { self.match_status(actual) .and(self.match_stdout(actual)) .and(self.match_stderr(actual)) } fn match_status(&self, actual: &Output) -> MatchResult { match self.expect_exit_code { None => Ok(()), Some(code) if actual.status.code() == Some(code) => Ok(()), Some(_) => Err(format!( "exited with {}\n--- stdout\n{}\n--- stderr\n{}", actual.status, String::from_utf8_lossy(&actual.stdout), String::from_utf8_lossy(&actual.stderr) )), } } fn match_stdout(&self, actual: &Output) -> MatchResult { self.match_std( self.expect_stdout.as_ref(), &actual.stdout, "stdout", &actual.stderr, MatchKind::Exact, )?; for expect in self.expect_stdout_contains.iter() { self.match_std( Some(expect), &actual.stdout, "stdout", &actual.stderr, MatchKind::Partial, )?; } for expect in self.expect_stderr_contains.iter() { self.match_std( Some(expect), &actual.stderr, "stderr", &actual.stdout, MatchKind::Partial, )?; } for &(ref expect, number) in self.expect_stdout_contains_n.iter() { self.match_std( Some(expect), &actual.stdout, "stdout", &actual.stderr, MatchKind::PartialN(number), )?; } for expect in self.expect_stdout_not_contains.iter() { self.match_std( Some(expect), &actual.stdout, "stdout", &actual.stderr, MatchKind::NotPresent, )?; } for expect in self.expect_stderr_not_contains.iter() { self.match_std( Some(expect), &actual.stderr, "stderr", &actual.stdout, MatchKind::NotPresent, )?; } for expect in self.expect_stderr_unordered.iter() { self.match_std( Some(expect), &actual.stderr, "stderr", &actual.stdout, MatchKind::Unordered, )?; } for expect in self.expect_neither_contains.iter() { self.match_std( Some(expect), &actual.stdout, "stdout", &actual.stdout, MatchKind::NotPresent, )?; self.match_std( Some(expect), &actual.stderr, "stderr", &actual.stderr, MatchKind::NotPresent, )?; } for expect in self.expect_either_contains.iter() { let match_std = self.match_std( Some(expect), &actual.stdout, "stdout", &actual.stdout, MatchKind::Partial, ); let match_err = self.match_std( Some(expect), &actual.stderr, "stderr", &actual.stderr, MatchKind::Partial, ); if let (Err(_), Err(_)) = (match_std, match_err) { return Err(format!( "expected to find:\n\ {}\n\n\ did not find in either output.", expect )); } } if let Some(ref objects) = self.expect_json { let stdout = str::from_utf8(&actual.stdout) .map_err(|_| "stdout was not utf8 encoded".to_owned())?; let lines = stdout .lines() .filter(|line| line.starts_with('{')) .collect::>(); if lines.len() != objects.len() { return Err(format!( "expected {} json lines, got {}, stdout:\n{}", objects.len(), lines.len(), stdout )); } for (obj, line) in objects.iter().zip(lines) { self.match_json(obj, line)?; } } Ok(()) } fn match_stderr(&self, actual: &Output) -> MatchResult { self.match_std( self.expect_stderr.as_ref(), &actual.stderr, "stderr", &actual.stdout, MatchKind::Exact, ) } fn match_std( &self, expected: Option<&String>, actual: &[u8], description: &str, extra: &[u8], kind: MatchKind, ) -> MatchResult { let out = match expected { Some(out) => out, None => return Ok(()), }; let actual = match str::from_utf8(actual) { Err(..) => return Err(format!("{} was not utf8 encoded", description)), Ok(actual) => actual, }; // Let's not deal with \r\n vs \n on windows... let actual = actual.replace('\r', ""); let actual = actual.replace('\t', ""); match kind { MatchKind::Exact => { let a = actual.lines(); let e = out.lines(); let diffs = self.diff_lines(a, e, false); if diffs.is_empty() { Ok(()) } else { Err(format!( "differences:\n\ {}\n\n\ other output:\n\ `{}`", diffs.join("\n"), String::from_utf8_lossy(extra) )) } } MatchKind::Partial => { let mut a = actual.lines(); let e = out.lines(); let mut diffs = self.diff_lines(a.clone(), e.clone(), true); #[allow(clippy::while_let_on_iterator)] while let Some(..) = a.next() { let a = self.diff_lines(a.clone(), e.clone(), true); if a.len() < diffs.len() { diffs = a; } } if diffs.is_empty() { Ok(()) } else { Err(format!( "expected to find:\n\ {}\n\n\ did not find in output:\n\ {}", out, actual )) } } MatchKind::PartialN(number) => { let mut a = actual.lines(); let e = out.lines(); let mut matches = 0; loop { if self.diff_lines(a.clone(), e.clone(), true).is_empty() { matches += 1; } if a.next().is_none() { break; } } if matches == number { Ok(()) } else { Err(format!( "expected to find {} occurrences:\n\ {}\n\n\ did not find in output:\n\ {}", number, out, actual )) } } MatchKind::NotPresent => { let mut a = actual.lines(); let e = out.lines(); let mut diffs = self.diff_lines(a.clone(), e.clone(), true); #[allow(clippy::while_let_on_iterator)] while let Some(..) = a.next() { let a = self.diff_lines(a.clone(), e.clone(), true); if a.len() < diffs.len() { diffs = a; } } if diffs.is_empty() { Err(format!( "expected not to find:\n\ {}\n\n\ but found in output:\n\ {}", out, actual )) } else { Ok(()) } } MatchKind::Unordered => { let mut a = actual.lines().collect::>(); let e = out.lines(); for e_line in e { match a.iter().position(|a_line| lines_match(e_line, a_line)) { Some(index) => a.remove(index), None => { return Err(format!( "Did not find expected line:\n\ {}\n\ Remaining available output:\n\ {}\n", e_line, a.join("\n") )); } }; } if !a.is_empty() { Err(format!( "Output included extra lines:\n\ {}\n", a.join("\n") )) } else { Ok(()) } } } } fn match_json(&self, expected: &Value, line: &str) -> MatchResult { let actual = match line.parse() { Err(e) => return Err(format!("invalid json, {}:\n`{}`", e, line)), Ok(actual) => actual, }; match find_mismatch(expected, &actual) { Some((expected_part, actual_part)) => Err(format!( "JSON mismatch\nExpected:\n{}\nWas:\n{}\nExpected part:\n{}\nActual part:\n{}\n", serde_json::to_string_pretty(expected).unwrap(), serde_json::to_string_pretty(&actual).unwrap(), serde_json::to_string_pretty(expected_part).unwrap(), serde_json::to_string_pretty(actual_part).unwrap(), )), None => Ok(()), } } fn diff_lines<'a>( &self, actual: str::Lines<'a>, expected: str::Lines<'a>, partial: bool, ) -> Vec { let actual = actual.take(if partial { expected.clone().count() } else { usize::MAX }); zip_all(actual, expected) .enumerate() .filter_map(|(i, (a, e))| match (a, e) { (Some(a), Some(e)) => { if lines_match(e, a) { None } else { Some(format!("{:3} - |{}|\n + |{}|\n", i, e, a)) } } (Some(a), None) => Some(format!("{:3} -\n + |{}|\n", i, a)), (None, Some(e)) => Some(format!("{:3} - |{}|\n +\n", i, e)), (None, None) => panic!("Cannot get here"), }) .collect() } } #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum MatchKind { Exact, Partial, PartialN(usize), NotPresent, Unordered, } /// Compare a line with an expected pattern. /// - Use `[..]` as a wildcard to match 0 or more characters on the same line /// (similar to `.*` in a regex). /// - Use `[EXE]` to optionally add `.exe` on Windows (empty string on other /// platforms). /// - There is a wide range of macros (such as `[COMPILING]` or `[WARNING]`) /// to match cargo's "status" output and allows you to ignore the alignment. /// See `substitute_macros` for a complete list of macros. pub fn lines_match(expected: &str, actual: &str) -> bool { // Let's not deal with / vs \ (windows...) let expected = expected.replace('\\', "/"); let mut actual: &str = &actual.replace('\\', "/"); let expected = substitute_macros(&expected); for (i, part) in expected.split("[..]").enumerate() { match actual.find(part) { Some(j) => { if i == 0 && j != 0 { return false; } actual = &actual[j + part.len()..]; } None => return false, } } actual.is_empty() || expected.ends_with("[..]") } #[test] fn lines_match_works() { assert!(lines_match("a b", "a b")); assert!(lines_match("a[..]b", "a b")); assert!(lines_match("a[..]", "a b")); assert!(lines_match("[..]", "a b")); assert!(lines_match("[..]b", "a b")); assert!(!lines_match("[..]b", "c")); assert!(!lines_match("b", "c")); assert!(!lines_match("b", "cb")); } // Compares JSON object for approximate equality. // You can use `[..]` wildcard in strings (useful for OS dependent things such // as paths). You can use a `"{...}"` string literal as a wildcard for // arbitrary nested JSON (useful for parts of object emitted by other programs // (e.g. rustc) rather than Cargo itself). Arrays are sorted before comparison. fn find_mismatch<'a>(expected: &'a Value, actual: &'a Value) -> Option<(&'a Value, &'a Value)> { use serde_json::Value::*; match (expected, actual) { (Number(l), Number(r)) if l == r => None, (Bool(l), Bool(r)) if l == r => None, (String(l), String(r)) if lines_match(l, r) => None, (Array(l), Array(r)) => { if l.len() != r.len() { return Some((expected, actual)); } let mut l = l.iter().collect::>(); let mut r = r.iter().collect::>(); l.retain( |l| match r.iter().position(|r| find_mismatch(l, r).is_none()) { Some(i) => { r.remove(i); false } None => true, }, ); if !l.is_empty() { assert!(!r.is_empty()); Some((l[0], r[0])) } else { assert_eq!(r.len(), 0); None } } (Object(l), Object(r)) => { let same_keys = l.len() == r.len() && l.keys().all(|k| r.contains_key(k)); if !same_keys { return Some((expected, actual)); } l.values() .zip(r.values()) .find_map(|(l, r)| find_mismatch(l, r)) } (Null, Null) => None, // magic string literal "{...}" acts as wildcard for any sub-JSON (String(l), _) if l == "{...}" => None, _ => Some((expected, actual)), } } struct ZipAll { first: I1, second: I2, } impl, I2: Iterator> Iterator for ZipAll { type Item = (Option, Option); fn next(&mut self) -> Option<(Option, Option)> { let first = self.first.next(); let second = self.second.next(); match (first, second) { (None, None) => None, (a, b) => Some((a, b)), } } } fn zip_all, I2: Iterator>(a: I1, b: I2) -> ZipAll { ZipAll { first: a, second: b, } } impl fmt::Display for Execs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "execs") } } impl fmt::Debug for Execs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "execs") } } impl Matcher for Execs { fn matches(&self, mut process: ProcessBuilder) -> MatchResult { self.matches(&mut process) } } impl<'a> Matcher<&'a mut ProcessBuilder> for Execs { fn matches(&self, process: &'a mut ProcessBuilder) -> MatchResult { println!("running {}", process); let res = process.exec_with_output(); match res { Ok(out) => self.match_output(&out), Err(err) => { if let Some(out) = &err.output { return self.match_output(out); } Err(format!("could not exec process {}: {}", process, err)) } } } } impl Matcher for Execs { fn matches(&self, output: Output) -> MatchResult { self.match_output(&output) } } pub fn execs() -> Execs { Execs { expect_stdout: None, expect_stderr: None, expect_exit_code: Some(0), expect_stdout_contains: Vec::new(), expect_stderr_contains: Vec::new(), expect_either_contains: Vec::new(), expect_stdout_contains_n: Vec::new(), expect_stdout_not_contains: Vec::new(), expect_stderr_not_contains: Vec::new(), expect_stderr_unordered: Vec::new(), expect_neither_contains: Vec::new(), expect_json: None, } } fn substitute_macros(input: &str) -> String { let macros = [ ("[RUNNING]", " Running"), ("[COMPILING]", " Compiling"), ("[CHECKING]", " Checking"), ("[CREATED]", " Created"), ("[FINISHED]", " Finished"), ("[ERROR]", "error:"), ("[WARNING]", "warning:"), ("[DOCUMENTING]", " Documenting"), ("[FRESH]", " Fresh"), ("[UPDATING]", " Updating"), ("[ADDING]", " Adding"), ("[REMOVING]", " Removing"), ("[DOCTEST]", " Doc-tests"), ("[PACKAGING]", " Packaging"), ("[DOWNLOADING]", " Downloading"), ("[UPLOADING]", " Uploading"), ("[VERIFYING]", " Verifying"), ("[ARCHIVING]", " Archiving"), ("[INSTALLING]", " Installing"), ("[REPLACING]", " Replacing"), ("[UNPACKING]", " Unpacking"), ("[SUMMARY]", " Summary"), ("[FIXING]", " Fixing"), ("[EXE]", if cfg!(windows) { ".exe" } else { "" }), ]; let mut result = input.to_owned(); for &(pat, subst) in ¯os { result = result.replace(pat, subst) } result } ================================================ FILE: crates/test-support/src/paths.rs ================================================ use std::cell::Cell; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Once; static SMOKE_TEST_DIR: &str = "smoke_test"; static NEXT_ID: AtomicUsize = AtomicUsize::new(0); thread_local!(static TASK_ID: usize = NEXT_ID.fetch_add(1, Ordering::SeqCst)); // creates the root directory for the tests (once), and // initializes the root and home directories for the current task fn init() { static GLOBAL_INIT: Once = Once::new(); thread_local!(static LOCAL_INIT: Cell = Cell::new(false)); GLOBAL_INIT.call_once(|| { global_root().mkdir_p(); }); LOCAL_INIT.with(|i| { if i.get() { return; } i.set(true); root().rm_rf(); home().mkdir_p(); }) } // the root directory for the smoke tests, in `target/smoke_test` fn global_root() -> PathBuf { let mut path = ok_or_panic! { env::current_exe() }; path.pop(); // chop off exe name path.pop(); // chop off 'debug' // If `cargo test` is run manually then our path looks like // `target/debug/foo`, in which case our `path` is already pointing at // `target`. If, however, `cargo test --target $target` is used then the // output is `target/$target/debug/foo`, so our path is pointing at // `target/$target`. Here we conditionally pop the `$target` name. if path.file_name().and_then(|s| s.to_str()) != Some("target") { path.pop(); } path.join(SMOKE_TEST_DIR) } pub fn root() -> PathBuf { init(); global_root().join(TASK_ID.with(|my_id| format!("t{}", my_id))) } pub fn home() -> PathBuf { root().join("home") } enum Remove { File, Dir, } impl Remove { fn to_str(&self) -> &'static str { match *self { Remove::File => "remove file", Remove::Dir => "remove dir", } } fn at(&self, path: &Path) { if cfg!(windows) { let mut p = ok_or_panic!(path.metadata()).permissions(); // This lint rule is not applicable: this is in a `cfg!(windows)` block. #[allow(clippy::permissions_set_readonly_false)] p.set_readonly(false); ok_or_panic! { fs::set_permissions(path, p) }; } match *self { Remove::File => fs::remove_file(path), Remove::Dir => fs::remove_dir_all(path), // ensure all dir contents are removed } .unwrap_or_else(|e| { panic!("failed to {} {}: {}", self.to_str(), path.display(), e); }) } } pub trait PathExt { fn rm(&self); fn rm_rf(&self); fn rm_contents(&self); fn ensure_empty(&self); fn mkdir_p(&self); } impl PathExt for Path { // delete a file if it exists fn rm(&self) { if !self.exists() { return; } // On windows we can't remove a readonly file, and git will // often clone files as readonly. As a result, we have some // special logic to remove readonly files on windows. Remove::File.at(self); } /* Technically there is a potential race condition, but we don't * care all that much for our tests */ fn rm_rf(&self) { if !self.exists() { return; } self.rm_contents(); Remove::Dir.at(self); } // remove directory contents but not the directory itself fn rm_contents(&self) { for file in ok_or_panic! { fs::read_dir(self) } { let file = ok_or_panic! { file }; if file.file_type().map(|m| m.is_dir()).unwrap_or(false) { file.path().rm_rf(); } else { file.path().rm(); } } } // ensure the directory is created and empty fn ensure_empty(&self) { self.mkdir_p(); self.rm_contents(); } // create all paths up to the input path fn mkdir_p(&self) { fs::create_dir_all(self) .unwrap_or_else(|e| panic!("failed to mkdir_p {}: {}", self.display(), e)) } } ================================================ FILE: crates/test-support/src/process.rs ================================================ use std::collections::HashMap; use std::env; use std::ffi::{OsStr, OsString}; use std::fmt; use std::path::Path; use std::process::{Command, ExitStatus, Output}; use std::str; use thiserror::Error; /// A builder object for an external process, similar to `std::process::Command`. #[derive(Clone, Debug)] pub struct ProcessBuilder { /// The program to execute. program: OsString, /// A list of arguments to pass to the program. args: Vec, /// Any environment variables that should be set for the program. env: HashMap>, /// Which directory to run the program from. cwd: Option, } impl fmt::Display for ProcessBuilder { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "`{}", self.program.to_string_lossy())?; for arg in &self.args { write!(f, " {}", arg.to_string_lossy())?; } write!(f, "`") } } impl ProcessBuilder { /// (chainable) Set the executable for the process. pub fn program>(&mut self, program: T) -> &mut ProcessBuilder { self.program = program.as_ref().to_os_string(); self } /// (chainable) Add an arg to the args list. pub fn arg>(&mut self, arg: T) -> &mut ProcessBuilder { self.args.push(arg.as_ref().to_os_string()); self } /// (chainable) Add many args to the args list. pub fn args>(&mut self, arguments: &[T]) -> &mut ProcessBuilder { self.args .extend(arguments.iter().map(|t| t.as_ref().to_os_string())); self } /// (chainable) Replace args with new args list pub fn args_replace>(&mut self, arguments: &[T]) -> &mut ProcessBuilder { self.args = arguments .iter() .map(|t| t.as_ref().to_os_string()) .collect(); self } /// (chainable) Set the current working directory of the process pub fn cwd>(&mut self, path: T) -> &mut ProcessBuilder { self.cwd = Some(path.as_ref().to_os_string()); self } /// (chainable) Set an environment variable for the process. pub fn env>(&mut self, key: &str, val: T) -> &mut ProcessBuilder { self.env .insert(key.to_string(), Some(val.as_ref().to_os_string())); self } /// (chainable) Unset an environment variable for the process. pub fn env_remove(&mut self, key: &str) -> &mut ProcessBuilder { self.env.insert(key.to_string(), None); self } /// Get the executable name. pub fn get_program(&self) -> &OsString { &self.program } /// Get the program arguments pub fn get_args(&self) -> &[OsString] { &self.args } /// Get the current working directory for the process pub fn get_cwd(&self) -> Option<&Path> { self.cwd.as_ref().map(Path::new) } /// Get an environment variable as the process will see it (will inherit from environment /// unless explicitally unset). pub fn get_env(&self, var: &str) -> Option { self.env .get(var) .cloned() .or_else(|| Some(env::var_os(var))) .and_then(|s| s) } /// Get all environment variables explicitly set or unset for the process (not inherited /// vars). pub fn get_envs(&self) -> &HashMap> { &self.env } /// Run the process, waiting for completion, and mapping non-success exit codes to an error. pub fn exec(&self) -> Result<(), ProcessError> { let mut command = self.build_command(); let exit = match command.status() { Ok(e) => e, Err(_) => { return Err(process_error( &format!("could not execute process {}", self), None, None, )); } }; if exit.success() { Ok(()) } else { Err(process_error( &format!("process didn't exit successfully: {}", self), Some(exit), None, )) } } /// Execute the process, returning the stdio output, or an error if non-zero exit status. pub fn exec_with_output(&self) -> Result { let mut command = self.build_command(); let output = match command.output() { Ok(o) => o, Err(_) => { return Err(process_error( &format!("could not execute process {}", self), None, None, )); } }; if output.status.success() { Ok(output) } else { Err(process_error( &format!("process didn't exit successfully: {}", self), Some(output.status), Some(&output), )) } } /// Converts ProcessBuilder into a `std::process::Command` pub fn build_command(&self) -> Command { let mut command = Command::new(&self.program); if let Some(cwd) = self.get_cwd() { command.current_dir(cwd); } for arg in &self.args { command.arg(arg); } for (k, v) in &self.env { match *v { Some(ref v) => { command.env(k, v); } None => { command.env_remove(k); } } } command } } /// A helper function to create a `ProcessBuilder`. pub fn process>(cmd: T) -> ProcessBuilder { ProcessBuilder { program: cmd.as_ref().to_os_string(), args: Vec::new(), cwd: None, env: HashMap::new(), } } #[derive(Debug, Error)] #[error("{desc}")] pub struct ProcessError { pub desc: String, pub exit: Option, pub output: Option, } pub fn process_error( msg: &str, status: Option, output: Option<&Output>, ) -> ProcessError { let exit = match status { Some(s) => status_to_string(s), None => "never executed".to_string(), }; let mut desc = format!("{} ({})", &msg, exit); if let Some(out) = output { match str::from_utf8(&out.stdout) { Ok(s) if !s.trim().is_empty() => { desc.push_str("\n--- stdout\n"); desc.push_str(s); } Ok(..) | Err(..) => {} } match str::from_utf8(&out.stderr) { Ok(s) if !s.trim().is_empty() => { desc.push_str("\n--- stderr\n"); desc.push_str(s); } Ok(..) | Err(..) => {} } } return ProcessError { desc, exit: status, output: output.cloned(), }; fn status_to_string(status: ExitStatus) -> String { status.to_string() } } ================================================ FILE: crates/validate-npm-package-name/Cargo.toml ================================================ [package] name = "validate-npm-package-name" version = "0.1.0" authors = ["Chris Krycho "] edition = "2021" [lib] [dependencies] once_cell = "1.19.0" percent-encoding = "2.1.0" regex = "1.1.6" ================================================ FILE: crates/validate-npm-package-name/src/lib.rs ================================================ //! A Rust implementation of the validation rules from the core JS package //! [`validate-npm-package-name`](https://github.com/npm/validate-npm-package-name/). use once_cell::sync::Lazy; use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; use regex::Regex; /// The set of characters to encode, matching the characters encoded by /// [`encodeURIComponent`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#description) static ENCODE_URI_SET: &AsciiSet = &NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.') .remove(b'!') .remove(b'~') .remove(b'*') .remove(b'\'') .remove(b'(') .remove(b')'); static SCOPED_PACKAGE: Lazy = Lazy::new(|| Regex::new(r"^(?:@([^/]+?)[/])?([^/]+?)$").expect("regex is valid")); static SPECIAL_CHARS: Lazy = Lazy::new(|| Regex::new(r"[~'!()*]").expect("regex is valid")); const BLACKLIST: [&str; 2] = ["node_modules", "favicon.ico"]; // Borrowed from https://github.com/juliangruber/builtins const BUILTINS: [&str; 39] = [ "assert", "buffer", "child_process", "cluster", "console", "constants", "crypto", "dgram", "dns", "domain", "events", "fs", "http", "https", "module", "net", "os", "path", "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", "tty", "url", "util", "vm", "zlib", "freelist", // excluded only in some versions "freelist", "v8", "process", "async_hooks", "http2", "perf_hooks", ]; #[derive(Debug, PartialEq, Eq)] pub enum Validity { /// Valid for new and old packages Valid, /// Valid only for old packages ValidForOldPackages { warnings: Vec }, /// Not valid for new or old packages Invalid { warnings: Vec, errors: Vec, }, } impl Validity { pub fn valid_for_old_packages(&self) -> bool { matches!(self, Validity::Valid | Validity::ValidForOldPackages { .. }) } pub fn valid_for_new_packages(&self) -> bool { matches!(self, Validity::Valid) } } pub fn validate(name: &str) -> Validity { let mut warnings = Vec::new(); let mut errors = Vec::new(); if name.is_empty() { errors.push("name length must be greater than zero".into()); } if name.starts_with('.') { errors.push("name cannot start with a period".into()); } if name.starts_with('_') { errors.push("name cannot start with an underscore".into()); } if name.trim() != name { errors.push("name cannot contain leading or trailing spaces".into()); } // No funny business for blacklisted_name in BLACKLIST.iter() { if &name.to_lowercase() == blacklisted_name { errors.push(format!("{} is a blacklisted name", blacklisted_name)); } } // Generate warnings for stuff that used to be allowed for builtin in BUILTINS.iter() { if name.to_lowercase() == *builtin { warnings.push(format!("{} is a core module name", builtin)); } } // really-long-package-names-------------------------------such--length-----many---wow // the thisisareallyreallylongpackagenameitshouldpublishdowenowhavealimittothelengthofpackagenames-poch. if name.len() > 214 { warnings.push("name can no longer contain more than 214 characters".into()); } // mIxeD CaSe nAMEs if name.to_lowercase() != name { warnings.push("name can no longer contain capital letters".into()); } if name .split('/') .last() .map(|final_part| SPECIAL_CHARS.is_match(final_part)) .unwrap_or(false) { warnings.push(r#"name can no longer contain special characters ("~\'!()*")"#.into()); } if utf8_percent_encode(name, ENCODE_URI_SET).to_string() != name { // Maybe it's a scoped package name, like @user/package if let Some(captures) = SCOPED_PACKAGE.captures(name) { let valid_scope_name = captures .get(1) .map(|scope| scope.as_str()) .map(|scope| utf8_percent_encode(scope, ENCODE_URI_SET).to_string() == scope) .unwrap_or(true); let valid_package_name = captures .get(2) .map(|package| package.as_str()) .map(|package| utf8_percent_encode(package, ENCODE_URI_SET).to_string() == package) .unwrap_or(true); if valid_scope_name && valid_package_name { return done(warnings, errors); } } errors.push("name can only contain URL-friendly characters".into()); } done(warnings, errors) } fn done(warnings: Vec, errors: Vec) -> Validity { match (warnings.len(), errors.len()) { (0, 0) => Validity::Valid, (_, 0) => Validity::ValidForOldPackages { warnings }, (_, _) => Validity::Invalid { warnings, errors }, } } #[cfg(test)] mod tests { use super::*; #[test] fn traditional() { assert_eq!(validate("some-package"), Validity::Valid); assert_eq!(validate("example.com"), Validity::Valid); assert_eq!(validate("under_score"), Validity::Valid); assert_eq!(validate("period.js"), Validity::Valid); assert_eq!(validate("123numeric"), Validity::Valid); assert_eq!( validate("crazy!"), Validity::ValidForOldPackages { warnings: vec![ r#"name can no longer contain special characters ("~\'!()*")"#.into() ] } ); } #[test] fn scoped() { assert_eq!(validate("@npm/thingy"), Validity::Valid); assert_eq!( validate("@npm-zors/money!time.js"), Validity::ValidForOldPackages { warnings: vec![ r#"name can no longer contain special characters ("~\'!()*")"#.into() ] } ); } #[test] fn invalid() { assert_eq!( validate(""), Validity::Invalid { errors: vec!["name length must be greater than zero".into()], warnings: vec![] } ); assert_eq!( validate(".start-with-period"), Validity::Invalid { errors: vec!["name cannot start with a period".into()], warnings: vec![] } ); assert_eq!( validate("_start-with-underscore"), Validity::Invalid { errors: vec!["name cannot start with an underscore".into()], warnings: vec![] } ); assert_eq!( validate("contain:colons"), Validity::Invalid { errors: vec!["name can only contain URL-friendly characters".into()], warnings: vec![] } ); assert_eq!( validate(" leading-space"), Validity::Invalid { errors: vec![ "name cannot contain leading or trailing spaces".into(), "name can only contain URL-friendly characters".into() ], warnings: vec![] } ); assert_eq!( validate("trailing-space "), Validity::Invalid { errors: vec![ "name cannot contain leading or trailing spaces".into(), "name can only contain URL-friendly characters".into() ], warnings: vec![] } ); assert_eq!( validate("s/l/a/s/h/e/s"), Validity::Invalid { errors: vec!["name can only contain URL-friendly characters".into()], warnings: vec![] } ); assert_eq!( validate("node_modules"), Validity::Invalid { errors: vec!["node_modules is a blacklisted name".into()], warnings: vec![] } ); assert_eq!( validate("favicon.ico"), Validity::Invalid { errors: vec!["favicon.ico is a blacklisted name".into()], warnings: vec![] } ); } #[test] fn node_io_core() { assert_eq!( validate("http"), Validity::ValidForOldPackages { warnings: vec!["http is a core module name".into()] } ); } #[test] fn long_package_names() { let one_too_long = "ifyouwanttogetthesumoftwonumberswherethosetwonumbersarechosenbyfindingthelargestoftwooutofthreenumbersandsquaringthemwhichismultiplyingthembyitselfthenyoushouldinputthreenumbersintothisfunctionanditwilldothatforyou-"; let short_enough = "ifyouwanttogetthesumoftwonumberswherethosetwonumbersarechosenbyfindingthelargestoftwooutofthreenumbersandsquaringthemwhichismultiplyingthembyitselfthenyoushouldinputthreenumbersintothisfunctionanditwilldothatforyou"; assert_eq!( validate(one_too_long), Validity::ValidForOldPackages { warnings: vec!["name can no longer contain more than 214 characters".into()] } ); assert_eq!(validate(short_enough), Validity::Valid); } #[test] fn legacy_mixed_case() { assert_eq!( validate("CAPITAL-LETTERS"), Validity::ValidForOldPackages { warnings: vec!["name can no longer contain capital letters".into()] } ); } } ================================================ FILE: crates/volta-core/Cargo.toml ================================================ [package] name = "volta-core" version = "0.1.0" authors = ["David Herman "] edition = "2021" [features] mock-network = ["mockito"] # The `cross-platform-docs` feature flag is used for generating API docs for # multiple platforms in one build. # See ci/publish-docs.yml for an example of how it's enabled. # See volta-core::path for an example of where it's used. cross-platform-docs = [] [dependencies] terminal_size = "0.4.1" indicatif = "0.17.9" console = ">=0.11.3, <1.0.0" readext = "0.1.0" serde_json = { version = "1.0.135", features = ["preserve_order"] } serde = { version = "1.0.217", features = ["derive"] } archive = { path = "../archive" } node-semver = "2" cmdline_words_parser = "0.2.1" fs-utils = { path = "../fs-utils" } cfg-if = "1.0" tempfile = "3.14.0" os_info = "3.9.2" detect-indent = "0.1" envoy = "0.1.3" mockito = { version = "0.31.1", optional = true } regex = "1.11.1" dirs = "6.0.0" # We manually configure the feature list here because 0.4.16 includes the # `oldtime` feature by default to avoid a breaking change. Additionally, using # the feature list explicitly lets us drop the `wasmbind` feature, which we do # not need. chrono = { version = "0.4.39", default-features = false, features = ["alloc", "std", "clock"] } validate-npm-package-name = { path = "../validate-npm-package-name" } textwrap = "0.16.1" log = { version = "0.4", features = ["std"] } ctrlc = "3.4.5" walkdir = "2.5.0" volta-layout = { path = "../volta-layout" } once_cell = "1.19.0" dunce = "1.0.5" ci_info = "0.14.14" httpdate = "1" headers = "0.4" attohttpc = { version = "0.28", default-features = false, features = ["json", "compress", "tls-rustls-native-roots"] } chain-map = "0.1.0" indexmap = "2.7.0" retry = "2" fs2 = "0.4.3" which = "7.0.1" [target.'cfg(windows)'.dependencies] winreg = "0.55.0" junction = "1.2.0" ================================================ FILE: crates/volta-core/fixtures/basic/package.json ================================================ { "name": "basic-project", "version": "0.0.7", "description": "Testing that manifest pulls things out of this correctly", "license": "To Kill", "dependencies": { "@namespace/some-dep": "0.2.4", "rsvp": "^3.5.0" }, "devDependencies": { "@namespaced/something-else": "^6.3.7", "eslint": "~4.8.0" }, "volta": { "node": "6.11.1", "npm": "3.10.10", "yarn": "1.2.0" } } ================================================ FILE: crates/volta-core/fixtures/basic/subdir/.gitkeep ================================================ ================================================ FILE: crates/volta-core/fixtures/cycle-1/package.json ================================================ { "name": "cycle-1-project", "version": "0.0.1", "description": "Testing that project correctly detects a cycle between this and volta.json", "license": "To Kill", "volta": { "extends": "./volta.json" } } ================================================ FILE: crates/volta-core/fixtures/cycle-1/volta.json ================================================ { "volta": { "extends": "./package.json" } } ================================================ FILE: crates/volta-core/fixtures/cycle-2/package.json ================================================ { "name": "cycle-2-project", "version": "0.0.1", "description": "Testing that project correctly detects a cycle between workspace-1.json and workspace-2.json", "license": "To Kill", "volta": { "extends": "./workspace-1.json" } } ================================================ FILE: crates/volta-core/fixtures/cycle-2/workspace-1.json ================================================ { "volta": { "extends": "./workspace-2.json" } } ================================================ FILE: crates/volta-core/fixtures/cycle-2/workspace-2.json ================================================ { "volta": { "extends": "./workspace-1.json" } } ================================================ FILE: crates/volta-core/fixtures/hooks/bins.json ================================================ { "node": { "distro": { "bin": "/some/bin/for/node/distro" }, "latest": { "bin": "/some/bin/for/node/latest" }, "index": { "bin": "/some/bin/for/node/index" } }, "pnpm": { "distro": { "bin": "/bin/to/pnpm/distro" }, "latest": { "bin": "/bin/to/pnpm/latest" }, "index": { "bin": "/bin/to/pnpm/index" } }, "yarn": { "distro": { "bin": "/bin/to/yarn/distro" }, "latest": { "bin": "/bin/to/yarn/latest" }, "index": { "bin": "/bin/to/yarn/index" } }, "events": { "publish": { "bin": "/events/bin" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/event_url.json ================================================ { "events": { "publish": { "url": "https://google.com" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/format_github.json ================================================ { "node": { "index": { "prefix": "http://localhost/node/index/", "format": "github" } }, "npm": { "index": { "prefix": "http://localhost/npm/index/", "format": "github" } }, "pnpm": { "index": { "prefix": "http://localhost/pnpm/index/", "format": "github" } }, "yarn": { "index": { "prefix": "http://localhost/yarn/index/", "format": "github" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/format_npm.json ================================================ { "node": { "index": { "prefix": "http://localhost/node/index/", "format": "npm" } }, "npm": { "index": { "prefix": "http://localhost/npm/index/", "format": "npm" } }, "pnpm": { "index": { "prefix": "http://localhost/pnpm/index/", "format": "npm" } }, "yarn": { "index": { "prefix": "http://localhost/yarn/index/", "format": "npm" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/prefixes.json ================================================ { "node": { "distro": { "prefix": "http://localhost/node/distro/" }, "latest": { "prefix": "http://localhost/node/latest/" }, "index": { "prefix": "http://localhost/node/index/" } }, "pnpm": { "distro": { "prefix": "http://localhost/pnpm/distro/" }, "latest": { "prefix": "http://localhost/pnpm/latest/" }, "index": { "prefix": "http://localhost/pnpm/index/" } }, "yarn": { "distro": { "prefix": "http://localhost/yarn/distro/" }, "latest": { "prefix": "http://localhost/yarn/latest/" }, "index": { "prefix": "http://localhost/yarn/index/" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/project/.volta/hooks.json ================================================ { "node": { "distro": { "bin": "/some/bin/for/node/distro" }, "latest": { "bin": "/some/bin/for/node/latest" }, "index": { "bin": "/some/bin/for/node/index" } }, "events": { "publish": { "bin": "/events/bin" } } } ================================================ FILE: crates/volta-core/fixtures/hooks/project/package.json ================================================ { "this file": "causes this directory to be recognized as a project" } ================================================ FILE: crates/volta-core/fixtures/hooks/templates.json ================================================ { "node": { "distro": { "template": "http://localhost/node/distro/{{version}}/" }, "latest": { "template": "http://localhost/node/latest/{{version}}/" }, "index": { "template": "http://localhost/node/index/{{version}}/" } }, "pnpm": { "distro": { "template": "http://localhost/pnpm/distro/{{version}}/" }, "latest": { "template": "http://localhost/pnpm/latest/{{version}}/" }, "index": { "template": "http://localhost/pnpm/index/{{version}}/" } }, "yarn": { "distro": { "template": "http://localhost/yarn/distro/{{version}}/" }, "latest": { "template": "http://localhost/yarn/latest/{{version}}/" }, "index": { "template": "http://localhost/yarn/index/{{version}}/" } } } ================================================ FILE: crates/volta-core/fixtures/nested/package.json ================================================ { "name": "nested-project", "version": "0.0.1", "description": "Testing that project correctly detects a nested workspace", "license": "To Kill", "dependencies": { "lodash": "*" }, "devDependencies": { "eslint": "*" }, "volta": { "yarn": "1.11.0", "npm": "6.12.1", "node": "12.14.0" } } ================================================ FILE: crates/volta-core/fixtures/nested/subproject/inner_project/package.json ================================================ { "name": "inner-project", "version": "0.0.1", "description": "Testing that project correctly detects a nested workspace", "license": "To Kill", "dependencies": { "express": "*" }, "devDependencies": { "typescript": "*" }, "volta": { "yarn": "1.22.4", "extends": "../package.json" } } ================================================ FILE: crates/volta-core/fixtures/nested/subproject/package.json ================================================ { "name": "subproject", "version": "0.0.1", "description": "Testing that project correctly detects a nested workspace", "license": "To Kill", "dependencies": { "rsvp": "*" }, "devDependencies": { "glob": "*" }, "volta": { "yarn": "1.17.0", "npm": "6.9.0", "extends": "../package.json" } } ================================================ FILE: crates/volta-core/fixtures/no_toolchain/package.json ================================================ { "name": "basic-project", "version": "0.0.7", "description": "Testing that manifest pulls things out of this correctly", "license": "To Kill", "dependencies": { "@namespace/some-dep": "0.2.4", "rsvp": "^3.5.0" }, "devDependencies": { "@namespaced/something-else": "^6.3.7", "eslint": "~4.8.0" } } ================================================ FILE: crates/volta-core/fixtures/yarn/pnp-cjs/.pnp.cjs ================================================ // plug and play ================================================ FILE: crates/volta-core/fixtures/yarn/pnp-cjs/package.json ================================================ { "name": "plug-n-play-cjs", "version": "2.0.0", "description": "Testing that Plug-n-Play things work", "license": "To Ill", "volta": { "node": "6.11.1", "npm": "3.10.10", "yarn": "3.2.0" } } ================================================ FILE: crates/volta-core/fixtures/yarn/pnp-js/.pnp.js ================================================ // plug and play ================================================ FILE: crates/volta-core/fixtures/yarn/pnp-js/package.json ================================================ { "name": "plug-n-play-js", "version": "2.0.0", "description": "Testing that Plug-n-Play things work", "license": "To Ill", "volta": { "node": "6.11.1", "npm": "3.10.10", "yarn": "2.4.0" } } ================================================ FILE: crates/volta-core/fixtures/yarn/yarnrc-yml/.yarnrc.yml ================================================ yarnPath: .yarn/releases/yarn-3.3.0.cjs ================================================ FILE: crates/volta-core/fixtures/yarn/yarnrc-yml/package.json ================================================ { "name": "yarnrc-yml", "version": "2.0.0", "description": "Testing that Yarn berry things work", "license": "To Ill", "volta": { "node": "6.11.1", "npm": "3.10.10", "yarn": "3.3.0" } } ================================================ FILE: crates/volta-core/src/command.rs ================================================ use std::ffi::OsStr; use std::process::Command; use cfg_if::cfg_if; cfg_if! { if #[cfg(windows)] { pub fn create_command(exe: E) -> Command where E: AsRef { // Several of the node utilities are implemented as `.bat` or `.cmd` files // When executing those files with `Command`, we need to call them with: // cmd.exe /C // Instead of: // See: https://github.com/rust-lang/rust/issues/42791 For a longer discussion let mut command = Command::new("cmd.exe"); command.arg("/C"); command.arg(exe); command } } else { pub fn create_command(exe: E) -> Command where E: AsRef { Command::new(exe) } } } ================================================ FILE: crates/volta-core/src/error/kind.rs ================================================ use std::fmt; use std::path::PathBuf; use super::ExitCode; use crate::style::{text_width, tool_version}; use crate::tool; use crate::tool::package::PackageManager; use textwrap::{fill, indent}; const REPORT_BUG_CTA: &str = "Please rerun the command that triggered this error with the environment variable `VOLTA_LOGLEVEL` set to `debug` and open an issue at https://github.com/volta-cli/volta/issues with the details!"; const PERMISSIONS_CTA: &str = "Please ensure you have correct permissions to the Volta directory."; #[derive(Debug)] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum ErrorKind { /// Thrown when package tries to install a binary that is already installed. BinaryAlreadyInstalled { bin_name: String, existing_package: String, new_package: String, }, /// Thrown when executing an external binary fails BinaryExecError, /// Thrown when a binary could not be found in the local inventory BinaryNotFound { name: String, }, /// Thrown when building the virtual environment path fails BuildPathError, /// Thrown when unable to launch a command with VOLTA_BYPASS set BypassError { command: String, }, /// Thrown when a user tries to `volta fetch` something other than node/yarn/npm. CannotFetchPackage { package: String, }, /// Thrown when a user tries to `volta pin` something other than node/yarn/npm. CannotPinPackage { package: String, }, /// Thrown when the Completions out-dir is not a directory CompletionsOutFileError { path: PathBuf, }, /// Thrown when the containing directory could not be determined ContainingDirError { path: PathBuf, }, CouldNotDetermineTool, /// Thrown when unable to start the migration executable CouldNotStartMigration, CreateDirError { dir: PathBuf, }, /// Thrown when unable to create the layout file CreateLayoutFileError { file: PathBuf, }, /// Thrown when unable to create a link to the shared global library directory CreateSharedLinkError { name: String, }, /// Thrown when creating a temporary directory fails CreateTempDirError { in_dir: PathBuf, }, /// Thrown when creating a temporary file fails CreateTempFileError { in_dir: PathBuf, }, CurrentDirError, /// Thrown when deleting a directory fails DeleteDirectoryError { directory: PathBuf, }, /// Thrown when deleting a file fails DeleteFileError { file: PathBuf, }, DeprecatedCommandError { command: String, advice: String, }, DownloadToolNetworkError { tool: tool::Spec, from_url: String, }, /// Thrown when unable to execute a hook command ExecuteHookError { command: String, }, /// Thrown when `volta.extends` keys result in an infinite cycle ExtensionCycleError { paths: Vec, duplicate: PathBuf, }, /// Thrown when determining the path to an extension manifest fails ExtensionPathError { path: PathBuf, }, /// Thrown when a hook command returns a non-zero exit code HookCommandFailed { command: String, }, /// Thrown when a hook contains multiple fields (prefix, template, or bin) HookMultipleFieldsSpecified, /// Thrown when a hook doesn't contain any of the known fields (prefix, template, or bin) HookNoFieldsSpecified, /// Thrown when determining the path to a hook fails HookPathError { command: String, }, /// Thrown when determining the name of a newly-installed package fails InstalledPackageNameError, InvalidHookCommand { command: String, }, /// Thrown when output from a hook command could not be read InvalidHookOutput { command: String, }, /// Thrown when a user does e.g. `volta install node 12` instead of /// `volta install node@12`. InvalidInvocation { action: String, name: String, version: String, }, /// Thrown when a user does e.g. `volta install 12` instead of /// `volta install node@12`. InvalidInvocationOfBareVersion { action: String, version: String, }, /// Thrown when a format other than "npm" or "github" is given for yarn.index in the hooks InvalidRegistryFormat { format: String, }, /// Thrown when a tool name is invalid per npm's rules. InvalidToolName { name: String, errors: Vec, }, /// Thrown when unable to acquire a lock on the Volta directory LockAcquireError, /// Thrown when pinning or installing npm@bundled and couldn't detect the bundled version NoBundledNpm { command: String, }, /// Thrown when pnpm is not set at the command-line NoCommandLinePnpm, /// Thrown when Yarn is not set at the command-line NoCommandLineYarn, /// Thrown when a user tries to install a Yarn or npm version before installing a Node version. NoDefaultNodeVersion { tool: String, }, /// Thrown when there is no Node version matching a requested semver specifier. NodeVersionNotFound { matching: String, }, NoHomeEnvironmentVar, /// Thrown when the install dir could not be determined NoInstallDir, NoLocalDataDir, /// Thrown when a user tries to pin a npm, pnpm, or Yarn version before pinning a Node version. NoPinnedNodeVersion { tool: String, }, /// Thrown when the platform (Node version) could not be determined NoPlatform, /// Thrown when parsing the project manifest and there is a `"volta"` key without Node NoProjectNodeInManifest, /// Thrown when Yarn is not set in a project NoProjectYarn, /// Thrown when pnpm is not set in a project NoProjectPnpm, /// Thrown when no shell profiles could be found NoShellProfile { env_profile: String, bin_dir: PathBuf, }, /// Thrown when the user tries to pin Node or Yarn versions outside of a package. NotInPackage, /// Thrown when default Yarn is not set NoDefaultYarn, /// Thrown when default pnpm is not set NoDefaultPnpm, /// Thrown when `npm link` is called with a package that isn't available NpmLinkMissingPackage { package: String, }, /// Thrown when `npm link` is called with a package that was not installed / linked with npm NpmLinkWrongManager { package: String, }, /// Thrown when there is no npm version matching the requested Semver/Tag NpmVersionNotFound { matching: String, }, NpxNotAvailable { version: String, }, /// Thrown when the command to install a global package is not successful PackageInstallFailed { package: String, }, /// Thrown when parsing the package manifest fails PackageManifestParseError { package: String, }, /// Thrown when reading the package manifest fails PackageManifestReadError { package: String, }, /// Thrown when a specified package could not be found on the npm registry PackageNotFound { package: String, }, /// Thrown when parsing a package manifest fails PackageParseError { file: PathBuf, }, /// Thrown when reading a package manifest fails PackageReadError { file: PathBuf, }, /// Thrown when a package has been unpacked but is not formed correctly. PackageUnpackError, /// Thrown when writing a package manifest fails PackageWriteError { file: PathBuf, }, /// Thrown when unable to parse a bin config file ParseBinConfigError, /// Thrown when unable to parse a hooks.json file ParseHooksError { file: PathBuf, }, /// Thrown when unable to parse the node index cache ParseNodeIndexCacheError, /// Thrown when unable to parse the node index ParseNodeIndexError { from_url: String, }, /// Thrown when unable to parse the node index cache expiration ParseNodeIndexExpiryError, /// Thrown when unable to parse the npm manifest file from a node install ParseNpmManifestError, /// Thrown when unable to parse a package configuration ParsePackageConfigError, /// Thrown when unable to parse the platform.json file ParsePlatformError, /// Thrown when unable to parse a tool spec (`[@]`) ParseToolSpecError { tool_spec: String, }, /// Thrown when persisting an archive to the inventory fails PersistInventoryError { tool: String, }, /// Thrown when there is no pnpm version matching a requested semver specifier. PnpmVersionNotFound { matching: String, }, /// Thrown when executing a project-local binary fails ProjectLocalBinaryExecError { command: String, }, /// Thrown when a project-local binary could not be found ProjectLocalBinaryNotFound { command: String, }, /// Thrown when a publish hook contains both the url and bin fields PublishHookBothUrlAndBin, /// Thrown when a publish hook contains neither url nor bin fields PublishHookNeitherUrlNorBin, /// Thrown when there was an error reading the user bin directory ReadBinConfigDirError { dir: PathBuf, }, /// Thrown when there was an error reading the config for a binary ReadBinConfigError { file: PathBuf, }, /// Thrown when unable to read the default npm version file ReadDefaultNpmError { file: PathBuf, }, /// Thrown when unable to read the contents of a directory ReadDirError { dir: PathBuf, }, /// Thrown when there was an error opening a hooks.json file ReadHooksError { file: PathBuf, }, /// Thrown when there was an error reading the Node Index Cache ReadNodeIndexCacheError { file: PathBuf, }, /// Thrown when there was an error reading the Node Index Cache Expiration ReadNodeIndexExpiryError { file: PathBuf, }, /// Thrown when there was an error reading the npm manifest file ReadNpmManifestError, /// Thrown when there was an error reading a package configuration file ReadPackageConfigError { file: PathBuf, }, /// Thrown when there was an error opening the user platform file ReadPlatformError { file: PathBuf, }, /// Thrown when unable to read the user Path environment variable from the registry #[cfg(windows)] ReadUserPathError, /// Thrown when the public registry for Node or Yarn could not be downloaded. RegistryFetchError { tool: String, from_url: String, }, /// Thrown when the shim binary is called directly, not through a symlink RunShimDirectly, /// Thrown when there was an error setting a tool to executable SetToolExecutable { tool: String, }, /// Thrown when there was an error copying an unpacked tool to the image directory SetupToolImageError { tool: String, version: String, dir: PathBuf, }, /// Thrown when Volta is unable to create a shim ShimCreateError { name: String, }, /// Thrown when Volta is unable to remove a shim ShimRemoveError { name: String, }, /// Thrown when serializing a bin config to JSON fails StringifyBinConfigError, /// Thrown when serializing a package config to JSON fails StringifyPackageConfigError, /// Thrown when serializing the platform to JSON fails StringifyPlatformError, /// Thrown when a given feature has not yet been implemented Unimplemented { feature: String, }, /// Thrown when unpacking an archive (tarball or zip) fails UnpackArchiveError { tool: String, version: String, }, /// Thrown when a package to upgrade was not found UpgradePackageNotFound { package: String, manager: PackageManager, }, /// Thrown when a package to upgrade was installed with a different package manager UpgradePackageWrongManager { package: String, manager: PackageManager, }, VersionParseError { version: String, }, /// Thrown when there was an error writing a bin config file WriteBinConfigError { file: PathBuf, }, /// Thrown when there was an error writing the default npm to file WriteDefaultNpmError { file: PathBuf, }, /// Thrown when there was an error writing the npm launcher WriteLauncherError { tool: String, }, /// Thrown when there was an error writing the node index cache WriteNodeIndexCacheError { file: PathBuf, }, /// Thrown when there was an error writing the node index expiration WriteNodeIndexExpiryError { file: PathBuf, }, /// Thrown when there was an error writing a package config WritePackageConfigError { file: PathBuf, }, /// Thrown when writing the platform.json file fails WritePlatformError { file: PathBuf, }, /// Thrown when unable to write the user PATH environment variable #[cfg(windows)] WriteUserPathError, /// Thrown when a user attempts to install a version of Yarn2 Yarn2NotSupported, /// Thrown when there is an error fetching the latest version of Yarn YarnLatestFetchError { from_url: String, }, /// Thrown when there is no Yarn version matching a requested semver specifier. YarnVersionNotFound { matching: String, }, } impl fmt::Display for ErrorKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ErrorKind::BinaryAlreadyInstalled { bin_name, existing_package, new_package, } => write!( f, "Executable '{}' is already installed by {} Please remove {} before installing {}", bin_name, existing_package, existing_package, new_package ), ErrorKind::BinaryExecError => write!( f, "Could not execute command. See `volta help install` and `volta help pin` for info about making tools available." ), ErrorKind::BinaryNotFound { name } => write!( f, r#"Could not find executable "{}" Use `volta install` to add a package to your toolchain (see `volta help install` for more info)."#, name ), ErrorKind::BuildPathError => write!( f, "Could not create execution environment. Please ensure your PATH is valid." ), ErrorKind::BypassError { command } => write!( f, "Could not execute command '{}' VOLTA_BYPASS is enabled, please ensure that the command exists on your system or unset VOLTA_BYPASS", command, ), ErrorKind::CannotFetchPackage { package } => write!( f, "Fetching packages without installing them is not supported. Use `volta install {}` to update the default version.", package ), ErrorKind::CannotPinPackage { package } => write!( f, "Only node and yarn can be pinned in a project Use `npm install` or `yarn add` to select a version of {} for this project.", package ), ErrorKind::CompletionsOutFileError { path } => write!( f, "Completions file `{}` already exists. Please remove the file or pass `-f` or `--force` to override.", path.display() ), ErrorKind::ContainingDirError { path } => write!( f, "Could not create the containing directory for {} {}", path.display(), PERMISSIONS_CTA ), ErrorKind::CouldNotDetermineTool => write!( f, "Could not determine tool name {}", REPORT_BUG_CTA ), ErrorKind::CouldNotStartMigration => write!( f, "Could not start migration process to upgrade your Volta directory. Please ensure you have 'volta-migrate' on your PATH and run it directly." ), ErrorKind::CreateDirError { dir } => write!( f, "Could not create directory {} Please ensure that you have the correct permissions.", dir.display() ), ErrorKind::CreateLayoutFileError { file } => write!( f, "Could not create layout file {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::CreateSharedLinkError { name } => write!( f, "Could not create shared environment for package '{}' {}", name, PERMISSIONS_CTA ), ErrorKind::CreateTempDirError { in_dir } => write!( f, "Could not create temporary directory in {} {}", in_dir.display(), PERMISSIONS_CTA ), ErrorKind::CreateTempFileError { in_dir } => write!( f, "Could not create temporary file in {} {}", in_dir.display(), PERMISSIONS_CTA ), ErrorKind::CurrentDirError => write!( f, "Could not determine current directory Please ensure that you have the correct permissions." ), ErrorKind::DeleteDirectoryError { directory } => write!( f, "Could not remove directory at {} {}", directory.display(), PERMISSIONS_CTA ), ErrorKind::DeleteFileError { file } => write!( f, "Could not remove file at {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::DeprecatedCommandError { command, advice } => { write!(f, "The subcommand `{}` is deprecated.\n{}", command, advice) } ErrorKind::DownloadToolNetworkError { tool, from_url } => write!( f, "Could not download {} from {} Please verify your internet connection and ensure the correct version is specified.", tool, from_url ), ErrorKind::ExecuteHookError { command } => write!( f, "Could not execute hook command: '{}' Please ensure that the correct command is specified.", command ), ErrorKind::ExtensionCycleError { paths, duplicate } => { // Detected infinite loop in project workspace: // // --> /home/user/workspace/project/package.json // /home/user/workspace/package.json // --> /home/user/workspace/project/package.json // // Please ensure that project workspaces do not depend on each other. f.write_str("Detected infinite loop in project workspace:\n\n")?; for path in paths { if path == duplicate { f.write_str("--> ")?; } else { f.write_str(" ")?; } writeln!(f, "{}", path.display())?; } writeln!(f, "--> {}", duplicate.display())?; writeln!(f)?; f.write_str("Please ensure that project workspaces do not depend on each other.") } ErrorKind::ExtensionPathError { path } => write!( f, "Could not determine path to project workspace: '{}' Please ensure that the file exists and is accessible.", path.display(), ), ErrorKind::HookCommandFailed { command } => write!( f, "Hook command '{}' indicated a failure. Please verify the requested tool and version.", command ), ErrorKind::HookMultipleFieldsSpecified => write!( f, "Hook configuration includes multiple hook types. Please include only one of 'bin', 'prefix', or 'template'" ), ErrorKind::HookNoFieldsSpecified => write!( f, "Hook configuration includes no hook types. Please include one of 'bin', 'prefix', or 'template'" ), ErrorKind::HookPathError { command } => write!( f, "Could not determine path to hook command: '{}' Please ensure that the correct command is specified.", command ), ErrorKind::InstalledPackageNameError => write!( f, "Could not determine the name of the package that was just installed. {}", REPORT_BUG_CTA ), ErrorKind::InvalidHookCommand { command } => write!( f, "Invalid hook command: '{}' Please ensure that the correct command is specified.", command ), ErrorKind::InvalidHookOutput { command } => write!( f, "Could not read output from hook command: '{}' Please ensure that the command output is valid UTF-8 text.", command ), ErrorKind::InvalidInvocation { action, name, version, } => { let error = format!( "`volta {action} {name} {version}` is not supported.", action = action, name = name, version = version ); let call_to_action = format!( "To {action} '{name}' version '{version}', please run `volta {action} {formatted}`. \ To {action} the packages '{name}' and '{version}', please {action} them in separate commands, or with explicit versions.", action=action, name=name, version=version, formatted=tool_version(name, version) ); let wrapped_cta = match text_width() { Some(width) => fill(&call_to_action, width), None => call_to_action, }; write!(f, "{}\n\n{}", error, wrapped_cta) } ErrorKind::InvalidInvocationOfBareVersion { action, version, } => { let error = format!( "`volta {action} {version}` is not supported.", action = action, version = version ); let call_to_action = format!( "To {action} node version '{version}', please run `volta {action} {formatted}`. \ To {action} the package '{version}', please use an explicit version such as '{version}@latest'.", action=action, version=version, formatted=tool_version("node", version) ); let wrapped_cta = match text_width() { Some(width) => fill(&call_to_action, width), None => call_to_action, }; write!(f, "{}\n\n{}", error, wrapped_cta) } ErrorKind::InvalidRegistryFormat { format } => write!( f, "Unrecognized index registry format: '{}' Please specify either 'npm' or 'github' for the format.", format ), ErrorKind::InvalidToolName { name, errors } => { let indentation = " "; let wrapped = match text_width() { Some(width) => fill(&errors.join("\n"), width - indentation.len()), None => errors.join("\n"), }; let formatted_errs = indent(&wrapped, indentation); let call_to_action = if errors.len() > 1 { "Please fix the following errors:" } else { "Please fix the following error:" }; write!( f, "Invalid tool name `{}`\n\n{}\n{}", name, call_to_action, formatted_errs ) } // Note: No CTA as this error is purely informational and shouldn't be exposed to the user ErrorKind::LockAcquireError => write!( f, "Unable to acquire lock on Volta directory" ), ErrorKind::NoBundledNpm { command } => write!( f, "Could not detect bundled npm version. Please ensure you have a Node version selected with `volta {} node` (see `volta help {0}` for more info).", command ), ErrorKind::NoCommandLinePnpm => write!( f, "No pnpm version specified. Use `volta run --pnpm` to select a version (see `volta help run` for more info)." ), ErrorKind::NoCommandLineYarn => write!( f, "No Yarn version specified. Use `volta run --yarn` to select a version (see `volta help run` for more info)." ), ErrorKind::NoDefaultNodeVersion { tool } => write!( f, "Cannot install {} because the default Node version is not set. Use `volta install node` to select a default Node first, then install a {0} version.", tool ), ErrorKind::NodeVersionNotFound { matching } => write!( f, r#"Could not find Node version matching "{}" in the version registry. Please verify that the version is correct."#, matching ), ErrorKind::NoHomeEnvironmentVar => write!( f, "Could not determine home directory. Please ensure the environment variable 'HOME' is set." ), ErrorKind::NoInstallDir => write!( f, "Could not determine Volta install directory. Please ensure Volta was installed correctly" ), ErrorKind::NoLocalDataDir => write!( f, "Could not determine LocalAppData directory. Please ensure the directory is available." ), ErrorKind::NoPinnedNodeVersion { tool } => write!( f, "Cannot pin {} because the Node version is not pinned in this project. Use `volta pin node` to pin Node first, then pin a {0} version.", tool ), ErrorKind::NoPlatform => write!( f, "Node is not available. To run any Node command, first set a default version using `volta install node`" ), ErrorKind::NoProjectNodeInManifest => write!( f, "No Node version found in this project. Use `volta pin node` to select a version (see `volta help pin` for more info)." ), ErrorKind::NoProjectPnpm => write!( f, "No pnpm version found in this project. Use `volta pin pnpm` to select a version (see `volta help pin` for more info)." ), ErrorKind::NoProjectYarn => write!( f, "No Yarn version found in this project. Use `volta pin yarn` to select a version (see `volta help pin` for more info)." ), ErrorKind::NoShellProfile { env_profile, bin_dir } => write!( f, "Could not locate user profile. Tried $PROFILE ({}), ~/.bashrc, ~/.bash_profile, ~/.zshenv ~/.zshrc, ~/.profile, and ~/.config/fish/config.fish Please create one of these and try again; or you can edit your profile manually to add '{}' to your PATH", env_profile, bin_dir.display() ), ErrorKind::NotInPackage => write!( f, "Not in a node package. Use `volta install` to select a default version of a tool." ), ErrorKind::NoDefaultPnpm => write!( f, "pnpm is not available. Use `volta install pnpm` to select a default version (see `volta help install` for more info)." ), ErrorKind::NoDefaultYarn => write!( f, "Yarn is not available. Use `volta install yarn` to select a default version (see `volta help install` for more info)." ), ErrorKind::NpmLinkMissingPackage { package } => write!( f, "Could not locate the package '{}' Please ensure it is available by running `npm link` in its source directory.", package ), ErrorKind::NpmLinkWrongManager { package } => write!( f, "The package '{}' was not installed using npm and cannot be linked with `npm link` Please ensure it is linked with `npm link` or installed with `npm i -g {0}`.", package ), ErrorKind::NpmVersionNotFound { matching } => write!( f, r#"Could not find Node version matching "{}" in the version registry. Please verify that the version is correct."#, matching ), ErrorKind::NpxNotAvailable { version } => write!( f, "'npx' is only available with npm >= 5.2.0 This project is configured to use version {} of npm.", version ), ErrorKind::PackageInstallFailed { package } => write!( f, "Could not install package '{}' Please confirm the package is valid and run with `--verbose` for more diagnostics.", package ), ErrorKind::PackageManifestParseError { package } => write!( f, "Could not parse package.json manifest for {} Please ensure the package includes a valid manifest file.", package ), ErrorKind::PackageManifestReadError { package } => write!( f, "Could not read package.json manifest for {} Please ensure the package includes a valid manifest file.", package ), ErrorKind::PackageNotFound { package } => write!( f, "Could not find '{}' in the package registry. Please verify the requested package is correct.", package ), ErrorKind::PackageParseError { file } => write!( f, "Could not parse project manifest at {} Please ensure that the file is correctly formatted.", file.display() ), ErrorKind::PackageReadError { file } => write!( f, "Could not read project manifest from {} Please ensure that the file exists.", file.display() ), ErrorKind::PackageUnpackError => write!( f, "Could not determine package directory layout. Please ensure the package is correctly formatted." ), ErrorKind::PackageWriteError { file } => write!( f, "Could not write project manifest to {} Please ensure you have correct permissions.", file.display() ), ErrorKind::ParseBinConfigError => write!( f, "Could not parse executable configuration file. {}", REPORT_BUG_CTA ), ErrorKind::ParseHooksError { file } => write!( f, "Could not parse hooks configuration file. from {} Please ensure the file is correctly formatted.", file.display() ), ErrorKind::ParseNodeIndexCacheError => write!( f, "Could not parse Node index cache file. {}", REPORT_BUG_CTA ), ErrorKind::ParseNodeIndexError { from_url } => write!( f, "Could not parse Node version index from {} Please verify your internet connection.", from_url ), ErrorKind::ParseNodeIndexExpiryError => write!( f, "Could not parse Node index cache expiration file. {}", REPORT_BUG_CTA ), ErrorKind::ParseNpmManifestError => write!( f, "Could not parse package.json file for bundled npm. Please ensure the version of Node is correct." ), ErrorKind::ParsePackageConfigError => write!( f, "Could not parse package configuration file. {}", REPORT_BUG_CTA ), ErrorKind::ParsePlatformError => write!( f, "Could not parse platform settings file. {}", REPORT_BUG_CTA ), ErrorKind::ParseToolSpecError { tool_spec } => write!( f, "Could not parse tool spec `{}` Please supply a spec in the format `[@]`.", tool_spec ), ErrorKind::PersistInventoryError { tool } => write!( f, "Could not store {} archive in inventory cache {}", tool, PERMISSIONS_CTA ), ErrorKind::PnpmVersionNotFound { matching } => write!( f, r#"Could not find pnpm version matching "{}" in the version registry. Please verify that the version is correct."#, matching ), ErrorKind::ProjectLocalBinaryExecError { command } => write!( f, "Could not execute `{}` Please ensure you have correct permissions to access the file.", command ), ErrorKind::ProjectLocalBinaryNotFound { command } => write!( f, "Could not locate executable `{}` in your project. Please ensure that all project dependencies are installed with `npm install` or `yarn install`", command ), ErrorKind::PublishHookBothUrlAndBin => write!( f, "Publish hook configuration includes both hook types. Please include only one of 'bin' or 'url'" ), ErrorKind::PublishHookNeitherUrlNorBin => write!( f, "Publish hook configuration includes no hook types. Please include one of 'bin' or 'url'" ), ErrorKind::ReadBinConfigDirError { dir } => write!( f, "Could not read executable metadata directory at {} {}", dir.display(), PERMISSIONS_CTA ), ErrorKind::ReadBinConfigError { file } => write!( f, "Could not read executable configuration from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadDefaultNpmError { file } => write!( f, "Could not read default npm version from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadDirError { dir } => write!( f, "Could not read contents from directory {} {}", dir.display(), PERMISSIONS_CTA ), ErrorKind::ReadHooksError { file } => write!( f, "Could not read hooks file from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadNodeIndexCacheError { file } => write!( f, "Could not read Node index cache from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadNodeIndexExpiryError { file } => write!( f, "Could not read Node index cache expiration from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadNpmManifestError => write!( f, "Could not read package.json file for bundled npm. Please ensure the version of Node is correct." ), ErrorKind::ReadPackageConfigError { file } => write!( f, "Could not read package configuration file from {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::ReadPlatformError { file } => write!( f, "Could not read default platform file from {} {}", file.display(), PERMISSIONS_CTA ), #[cfg(windows)] ErrorKind::ReadUserPathError => write!( f, "Could not read user Path environment variable. Please ensure you have access to the your environment variables." ), ErrorKind::RegistryFetchError { tool, from_url } => write!( f, "Could not download {} version registry from {} Please verify your internet connection.", tool, from_url ), ErrorKind::RunShimDirectly => write!( f, "'volta-shim' should not be called directly. Please use the existing shims provided by Volta (node, yarn, etc.) to run tools." ), ErrorKind::SetToolExecutable { tool } => write!( f, r#"Could not set "{}" to executable {}"#, tool, PERMISSIONS_CTA ), ErrorKind::SetupToolImageError { tool, version, dir } => write!( f, "Could not create environment for {} v{} at {} {}", tool, version, dir.display(), PERMISSIONS_CTA ), ErrorKind::ShimCreateError { name } => write!( f, r#"Could not create shim for "{}" {}"#, name, PERMISSIONS_CTA ), ErrorKind::ShimRemoveError { name } => write!( f, r#"Could not remove shim for "{}" {}"#, name, PERMISSIONS_CTA ), ErrorKind::StringifyBinConfigError => write!( f, "Could not serialize executable configuration. {}", REPORT_BUG_CTA ), ErrorKind::StringifyPackageConfigError => write!( f, "Could not serialize package configuration. {}", REPORT_BUG_CTA ), ErrorKind::StringifyPlatformError => write!( f, "Could not serialize platform settings. {}", REPORT_BUG_CTA ), ErrorKind::Unimplemented { feature } => { write!(f, "{} is not supported yet.", feature) } ErrorKind::UnpackArchiveError { tool, version } => write!( f, "Could not unpack {} v{} Please ensure the correct version is specified.", tool, version ), ErrorKind::UpgradePackageNotFound { package, manager } => write!( f, r#"Could not locate the package '{}' to upgrade. Please ensure it is installed with `{} {0}`"#, package, match manager { PackageManager::Npm => "npm i -g", PackageManager::Pnpm => "pnpm add -g", PackageManager::Yarn => "yarn global add", } ), ErrorKind::UpgradePackageWrongManager { package, manager } => { let (name, command) = match manager { PackageManager::Npm => ("npm", "npm update -g"), PackageManager::Pnpm => ("pnpm", "pnpm update -g"), PackageManager::Yarn => ("Yarn", "yarn global upgrade"), }; write!( f, r#"The package '{}' was installed using {}. To upgrade it, please use the command `{} {0}`"#, package, name, command ) } ErrorKind::VersionParseError { version } => write!( f, r#"Could not parse version "{}" Please verify the intended version."#, version ), ErrorKind::WriteBinConfigError { file } => write!( f, "Could not write executable configuration to {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::WriteDefaultNpmError { file } => write!( f, "Could not write bundled npm version to {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::WriteLauncherError { tool } => write!( f, "Could not set up launcher for {} This is most likely an intermittent failure, please try again.", tool ), ErrorKind::WriteNodeIndexCacheError { file } => write!( f, "Could not write Node index cache to {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::WriteNodeIndexExpiryError { file } => write!( f, "Could not write Node index cache expiration to {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::WritePackageConfigError { file } => write!( f, "Could not write package configuration to {} {}", file.display(), PERMISSIONS_CTA ), ErrorKind::WritePlatformError { file } => write!( f, "Could not save platform settings to {} {}", file.display(), PERMISSIONS_CTA ), #[cfg(windows)] ErrorKind::WriteUserPathError => write!( f, "Could not write Path environment variable. Please ensure you have permissions to edit your environment variables." ), ErrorKind::Yarn2NotSupported => write!( f, "Yarn version 2 is not recommended for use, and not supported by Volta. Please use version 3 or greater instead." ), ErrorKind::YarnLatestFetchError { from_url } => write!( f, "Could not fetch latest version of Yarn from {} Please verify your internet connection.", from_url ), ErrorKind::YarnVersionNotFound { matching } => write!( f, r#"Could not find Yarn version matching "{}" in the version registry. Please verify that the version is correct."#, matching ), } } } impl ErrorKind { pub fn exit_code(&self) -> ExitCode { match self { ErrorKind::BinaryAlreadyInstalled { .. } => ExitCode::FileSystemError, ErrorKind::BinaryExecError => ExitCode::ExecutionFailure, ErrorKind::BinaryNotFound { .. } => ExitCode::ExecutableNotFound, ErrorKind::BuildPathError => ExitCode::EnvironmentError, ErrorKind::BypassError { .. } => ExitCode::ExecutionFailure, ErrorKind::CannotFetchPackage { .. } => ExitCode::InvalidArguments, ErrorKind::CannotPinPackage { .. } => ExitCode::InvalidArguments, ErrorKind::CompletionsOutFileError { .. } => ExitCode::InvalidArguments, ErrorKind::ContainingDirError { .. } => ExitCode::FileSystemError, ErrorKind::CouldNotDetermineTool => ExitCode::UnknownError, ErrorKind::CouldNotStartMigration => ExitCode::EnvironmentError, ErrorKind::CreateDirError { .. } => ExitCode::FileSystemError, ErrorKind::CreateLayoutFileError { .. } => ExitCode::FileSystemError, ErrorKind::CreateSharedLinkError { .. } => ExitCode::FileSystemError, ErrorKind::CreateTempDirError { .. } => ExitCode::FileSystemError, ErrorKind::CreateTempFileError { .. } => ExitCode::FileSystemError, ErrorKind::CurrentDirError => ExitCode::EnvironmentError, ErrorKind::DeleteDirectoryError { .. } => ExitCode::FileSystemError, ErrorKind::DeleteFileError { .. } => ExitCode::FileSystemError, ErrorKind::DeprecatedCommandError { .. } => ExitCode::InvalidArguments, ErrorKind::DownloadToolNetworkError { .. } => ExitCode::NetworkError, ErrorKind::ExecuteHookError { .. } => ExitCode::ExecutionFailure, ErrorKind::ExtensionCycleError { .. } => ExitCode::ConfigurationError, ErrorKind::ExtensionPathError { .. } => ExitCode::FileSystemError, ErrorKind::HookCommandFailed { .. } => ExitCode::ConfigurationError, ErrorKind::HookMultipleFieldsSpecified => ExitCode::ConfigurationError, ErrorKind::HookNoFieldsSpecified => ExitCode::ConfigurationError, ErrorKind::HookPathError { .. } => ExitCode::ConfigurationError, ErrorKind::InstalledPackageNameError => ExitCode::UnknownError, ErrorKind::InvalidHookCommand { .. } => ExitCode::ExecutableNotFound, ErrorKind::InvalidHookOutput { .. } => ExitCode::ExecutionFailure, ErrorKind::InvalidInvocation { .. } => ExitCode::InvalidArguments, ErrorKind::InvalidInvocationOfBareVersion { .. } => ExitCode::InvalidArguments, ErrorKind::InvalidRegistryFormat { .. } => ExitCode::ConfigurationError, ErrorKind::InvalidToolName { .. } => ExitCode::InvalidArguments, ErrorKind::LockAcquireError => ExitCode::FileSystemError, ErrorKind::NoBundledNpm { .. } => ExitCode::ConfigurationError, ErrorKind::NoCommandLinePnpm => ExitCode::ConfigurationError, ErrorKind::NoCommandLineYarn => ExitCode::ConfigurationError, ErrorKind::NoDefaultNodeVersion { .. } => ExitCode::ConfigurationError, ErrorKind::NodeVersionNotFound { .. } => ExitCode::NoVersionMatch, ErrorKind::NoHomeEnvironmentVar => ExitCode::EnvironmentError, ErrorKind::NoInstallDir => ExitCode::EnvironmentError, ErrorKind::NoLocalDataDir => ExitCode::EnvironmentError, ErrorKind::NoPinnedNodeVersion { .. } => ExitCode::ConfigurationError, ErrorKind::NoPlatform => ExitCode::ConfigurationError, ErrorKind::NoProjectNodeInManifest => ExitCode::ConfigurationError, ErrorKind::NoProjectPnpm => ExitCode::ConfigurationError, ErrorKind::NoProjectYarn => ExitCode::ConfigurationError, ErrorKind::NoShellProfile { .. } => ExitCode::EnvironmentError, ErrorKind::NotInPackage => ExitCode::ConfigurationError, ErrorKind::NoDefaultPnpm => ExitCode::ConfigurationError, ErrorKind::NoDefaultYarn => ExitCode::ConfigurationError, ErrorKind::NpmLinkMissingPackage { .. } => ExitCode::ConfigurationError, ErrorKind::NpmLinkWrongManager { .. } => ExitCode::ConfigurationError, ErrorKind::NpmVersionNotFound { .. } => ExitCode::NoVersionMatch, ErrorKind::NpxNotAvailable { .. } => ExitCode::ExecutableNotFound, ErrorKind::PackageInstallFailed { .. } => ExitCode::UnknownError, ErrorKind::PackageManifestParseError { .. } => ExitCode::ConfigurationError, ErrorKind::PackageManifestReadError { .. } => ExitCode::FileSystemError, ErrorKind::PackageNotFound { .. } => ExitCode::InvalidArguments, ErrorKind::PackageParseError { .. } => ExitCode::ConfigurationError, ErrorKind::PackageReadError { .. } => ExitCode::FileSystemError, ErrorKind::PackageUnpackError => ExitCode::ConfigurationError, ErrorKind::PackageWriteError { .. } => ExitCode::FileSystemError, ErrorKind::ParseBinConfigError => ExitCode::UnknownError, ErrorKind::ParseHooksError { .. } => ExitCode::ConfigurationError, ErrorKind::ParseToolSpecError { .. } => ExitCode::InvalidArguments, ErrorKind::ParseNodeIndexCacheError => ExitCode::UnknownError, ErrorKind::ParseNodeIndexError { .. } => ExitCode::NetworkError, ErrorKind::ParseNodeIndexExpiryError => ExitCode::UnknownError, ErrorKind::ParseNpmManifestError => ExitCode::UnknownError, ErrorKind::ParsePackageConfigError => ExitCode::UnknownError, ErrorKind::ParsePlatformError => ExitCode::ConfigurationError, ErrorKind::PersistInventoryError { .. } => ExitCode::FileSystemError, ErrorKind::PnpmVersionNotFound { .. } => ExitCode::NoVersionMatch, ErrorKind::ProjectLocalBinaryExecError { .. } => ExitCode::ExecutionFailure, ErrorKind::ProjectLocalBinaryNotFound { .. } => ExitCode::FileSystemError, ErrorKind::PublishHookBothUrlAndBin => ExitCode::ConfigurationError, ErrorKind::PublishHookNeitherUrlNorBin => ExitCode::ConfigurationError, ErrorKind::ReadBinConfigDirError { .. } => ExitCode::FileSystemError, ErrorKind::ReadBinConfigError { .. } => ExitCode::FileSystemError, ErrorKind::ReadDefaultNpmError { .. } => ExitCode::FileSystemError, ErrorKind::ReadDirError { .. } => ExitCode::FileSystemError, ErrorKind::ReadHooksError { .. } => ExitCode::FileSystemError, ErrorKind::ReadNodeIndexCacheError { .. } => ExitCode::FileSystemError, ErrorKind::ReadNodeIndexExpiryError { .. } => ExitCode::FileSystemError, ErrorKind::ReadNpmManifestError => ExitCode::UnknownError, ErrorKind::ReadPackageConfigError { .. } => ExitCode::FileSystemError, ErrorKind::ReadPlatformError { .. } => ExitCode::FileSystemError, #[cfg(windows)] ErrorKind::ReadUserPathError => ExitCode::EnvironmentError, ErrorKind::RegistryFetchError { .. } => ExitCode::NetworkError, ErrorKind::RunShimDirectly => ExitCode::InvalidArguments, ErrorKind::SetupToolImageError { .. } => ExitCode::FileSystemError, ErrorKind::SetToolExecutable { .. } => ExitCode::FileSystemError, ErrorKind::ShimCreateError { .. } => ExitCode::FileSystemError, ErrorKind::ShimRemoveError { .. } => ExitCode::FileSystemError, ErrorKind::StringifyBinConfigError => ExitCode::UnknownError, ErrorKind::StringifyPackageConfigError => ExitCode::UnknownError, ErrorKind::StringifyPlatformError => ExitCode::UnknownError, ErrorKind::Unimplemented { .. } => ExitCode::UnknownError, ErrorKind::UnpackArchiveError { .. } => ExitCode::UnknownError, ErrorKind::UpgradePackageNotFound { .. } => ExitCode::ConfigurationError, ErrorKind::UpgradePackageWrongManager { .. } => ExitCode::ConfigurationError, ErrorKind::VersionParseError { .. } => ExitCode::NoVersionMatch, ErrorKind::WriteBinConfigError { .. } => ExitCode::FileSystemError, ErrorKind::WriteDefaultNpmError { .. } => ExitCode::FileSystemError, ErrorKind::WriteLauncherError { .. } => ExitCode::FileSystemError, ErrorKind::WriteNodeIndexCacheError { .. } => ExitCode::FileSystemError, ErrorKind::WriteNodeIndexExpiryError { .. } => ExitCode::FileSystemError, ErrorKind::WritePackageConfigError { .. } => ExitCode::FileSystemError, ErrorKind::WritePlatformError { .. } => ExitCode::FileSystemError, #[cfg(windows)] ErrorKind::WriteUserPathError => ExitCode::EnvironmentError, ErrorKind::Yarn2NotSupported => ExitCode::NoVersionMatch, ErrorKind::YarnLatestFetchError { .. } => ExitCode::NetworkError, ErrorKind::YarnVersionNotFound { .. } => ExitCode::NoVersionMatch, } } } ================================================ FILE: crates/volta-core/src/error/mod.rs ================================================ use std::error::Error; use std::fmt; use std::process::exit; mod kind; mod reporter; pub use kind::ErrorKind; pub use reporter::report_error; pub type Fallible = Result; /// Error type for Volta #[derive(Debug)] pub struct VoltaError { inner: Box, } #[derive(Debug)] struct Inner { kind: ErrorKind, source: Option>, } impl VoltaError { /// The exit code Volta should use when this error stops execution pub fn exit_code(&self) -> ExitCode { self.inner.kind.exit_code() } /// Create a new VoltaError instance including a source error pub fn from_source(source: E, kind: ErrorKind) -> Self where E: Into>, { VoltaError { inner: Box::new(Inner { kind, source: Some(source.into()), }), } } /// Get a reference to the ErrorKind for this error pub fn kind(&self) -> &ErrorKind { &self.inner.kind } } impl fmt::Display for VoltaError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.inner.kind.fmt(f) } } impl Error for VoltaError { fn source(&self) -> Option<&(dyn Error + 'static)> { self.inner.source.as_ref().map(|b| b.as_ref()) } } impl From for VoltaError { fn from(kind: ErrorKind) -> Self { VoltaError { inner: Box::new(Inner { kind, source: None }), } } } /// Trait providing the with_context method to easily convert any Result error into a VoltaError pub trait Context { fn with_context(self, f: F) -> Fallible where F: FnOnce() -> ErrorKind; } impl Context for Result where E: Error + 'static, { fn with_context(self, f: F) -> Fallible where F: FnOnce() -> ErrorKind, { self.map_err(|e| VoltaError::from_source(e, f())) } } /// Exit codes supported by Volta Errors #[derive(Copy, Clone, Debug)] pub enum ExitCode { /// No error occurred. Success = 0, /// An unknown error occurred. UnknownError = 1, /// An invalid combination of command-line arguments was supplied. InvalidArguments = 3, /// No match could be found for the requested version string. NoVersionMatch = 4, /// A network error occurred. NetworkError = 5, /// A required environment variable was unset or invalid. EnvironmentError = 6, /// A file could not be read or written. FileSystemError = 7, /// Package configuration is missing or incorrect. ConfigurationError = 8, /// The command or feature is not yet implemented. NotYetImplemented = 9, /// The requested executable could not be run. ExecutionFailure = 126, /// The requested executable is not available. ExecutableNotFound = 127, } impl ExitCode { pub fn exit(self) -> ! { exit(self as i32); } } ================================================ FILE: crates/volta-core/src/error/reporter.rs ================================================ use std::env::args_os; use std::error::Error; use std::fs::File; use std::io::Write; use std::path::PathBuf; use super::VoltaError; use crate::layout::volta_home; use crate::style::format_error_cause; use chrono::Local; use ci_info::is_ci; use console::strip_ansi_codes; use fs_utils::ensure_containing_dir_exists; use log::{debug, error}; /// Report an error, both to the console and to error logs pub fn report_error(volta_version: &str, err: &VoltaError) { let message = err.to_string(); error!("{}", message); if let Some(details) = compose_error_details(err) { if is_ci() { // In CI, we write the error details to the log so that they are available in the CI logs // A log file may not even exist by the time the user is reviewing a failure error!("{}", details); } else { // Outside of CI, we write the error details as Debug (Verbose) information // And we write an actual error log that the user can review debug!("{}", details); // Note: Writing the error log info directly to stderr as it is a message for the user // Any custom logs will have all of the details already, so showing a message about writing // the error log would be redundant match write_error_log(volta_version, message, details) { Ok(log_file) => { eprintln!("Error details written to {}", log_file.to_string_lossy()); } Err(_) => { eprintln!("Unable to write error log!"); } } } } } /// Write an error log with all details about the error fn write_error_log( volta_version: &str, message: String, details: String, ) -> Result> { let file_name = Local::now() .format("volta-error-%Y-%m-%d_%H_%M_%S%.3f.log") .to_string(); let log_file_path = volta_home()?.log_dir().join(file_name); ensure_containing_dir_exists(&log_file_path)?; let mut log_file = File::create(&log_file_path)?; writeln!(log_file, "{}", collect_arguments())?; writeln!(log_file, "Volta v{}", volta_version)?; writeln!(log_file)?; writeln!(log_file, "{}", strip_ansi_codes(&message))?; writeln!(log_file)?; writeln!(log_file, "{}", strip_ansi_codes(&details))?; Ok(log_file_path) } fn compose_error_details(err: &VoltaError) -> Option { // Only compose details if there is an underlying cause for the error let mut current = err.source()?; let mut details = String::new(); // Walk up the tree of causes and include all of them loop { details.push_str(&format_error_cause(current)); match current.source() { Some(cause) => { details.push_str("\n\n"); current = cause; } None => { break; } }; } Some(details) } /// Combines all the arguments into a single String fn collect_arguments() -> String { // The Debug formatter for OsString properly quotes and escapes each value args_os() .map(|arg| format!("{:?}", arg)) .collect::>() .join(" ") } ================================================ FILE: crates/volta-core/src/event.rs ================================================ //! Events for the sessions in executables and shims and everything use std::env; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::error::{ExitCode, VoltaError}; use crate::hook::Publish; use crate::monitor::send_events; use crate::session::ActivityKind; // the Event data that is serialized to JSON and sent the plugin #[derive(Deserialize, Serialize)] pub struct Event { timestamp: u64, pub name: String, pub event: EventKind, } #[derive(Deserialize, Serialize, PartialEq, Eq, Debug)] pub struct ErrorEnv { argv: String, exec_path: String, path: String, platform: String, platform_version: String, } #[derive(Deserialize, Serialize, PartialEq, Eq, Debug)] #[serde(rename_all = "lowercase")] pub enum EventKind { Start, End { exit_code: i32, }, Error { exit_code: i32, error: String, env: ErrorEnv, }, ToolEnd { exit_code: i32, }, Args { argv: String, }, } impl EventKind { pub fn into_event(self, activity_kind: ActivityKind) -> Event { Event { timestamp: unix_timestamp(), name: activity_kind.to_string(), event: self, } } } // returns the current number of milliseconds since the epoch fn unix_timestamp() -> u64 { let start = SystemTime::now(); let duration = start .duration_since(UNIX_EPOCH) .expect("Time went backwards"); let nanosecs_since_epoch = duration.as_secs() * 1_000_000_000 + duration.subsec_nanos() as u64; nanosecs_since_epoch / 1_000_000 } fn get_error_env() -> ErrorEnv { let path = match env::var("PATH") { Ok(p) => p, Err(_e) => "error: Unable to get path from environment".to_string(), }; let argv = env::args().collect::>().join(" "); let exec_path = match env::current_exe() { Ok(ep) => ep.display().to_string(), Err(_e) => "error: Unable to get executable path from environment".to_string(), }; let info = os_info::get(); let platform = info.os_type().to_string(); let platform_version = info.version().to_string(); ErrorEnv { argv, exec_path, path, platform, platform_version, } } pub struct EventLog { events: Vec, } impl EventLog { /// Constructs a new 'EventLog' pub fn init() -> Self { EventLog { events: Vec::new() } } pub fn add_event_start(&mut self, activity_kind: ActivityKind) { self.add_event(EventKind::Start, activity_kind) } pub fn add_event_end(&mut self, activity_kind: ActivityKind, exit_code: ExitCode) { self.add_event( EventKind::End { exit_code: exit_code as i32, }, activity_kind, ) } pub fn add_event_tool_end(&mut self, activity_kind: ActivityKind, exit_code: i32) { self.add_event(EventKind::ToolEnd { exit_code }, activity_kind) } pub fn add_event_error(&mut self, activity_kind: ActivityKind, error: &VoltaError) { self.add_event( EventKind::Error { exit_code: error.exit_code() as i32, error: error.to_string(), env: get_error_env(), }, activity_kind, ) } pub fn add_event_args(&mut self) { let argv = env::args_os() .enumerate() .fold(String::new(), |mut result, (i, arg)| { if i > 0 { result.push(' '); } result.push_str(&arg.to_string_lossy()); result }); self.add_event(EventKind::Args { argv }, ActivityKind::Args) } fn add_event(&mut self, event_kind: EventKind, activity_kind: ActivityKind) { let event = event_kind.into_event(activity_kind); self.events.push(event); } pub fn publish(&self, plugin: Option<&Publish>) { match plugin { // Note: This call to unimplemented is left in, as it's not a Fallible operation that can use ErrorKind::Unimplemented Some(Publish::Url(_)) => unimplemented!(), Some(Publish::Bin(command)) => { send_events(command, &self.events); } None => {} } } } #[cfg(test)] pub mod tests { use super::{EventKind, EventLog}; use crate::error::{ErrorKind, ExitCode}; use crate::session::ActivityKind; use regex::Regex; #[test] fn test_adding_events() { let mut event_log = EventLog::init(); assert_eq!(event_log.events.len(), 0); event_log.add_event_start(ActivityKind::Current); assert_eq!(event_log.events.len(), 1); assert_eq!(event_log.events[0].name, "current"); assert_eq!(event_log.events[0].event, EventKind::Start); event_log.add_event_end(ActivityKind::Pin, ExitCode::NetworkError); assert_eq!(event_log.events.len(), 2); assert_eq!(event_log.events[1].name, "pin"); assert_eq!(event_log.events[1].event, EventKind::End { exit_code: 5 }); event_log.add_event_tool_end(ActivityKind::Version, 12); assert_eq!(event_log.events.len(), 3); assert_eq!(event_log.events[2].name, "version"); assert_eq!( event_log.events[2].event, EventKind::ToolEnd { exit_code: 12 } ); let error = ErrorKind::BinaryExecError.into(); event_log.add_event_error(ActivityKind::Install, &error); assert_eq!(event_log.events.len(), 4); assert_eq!(event_log.events[3].name, "install"); // not checking the error because it has too much machine-specific info event_log.add_event_args(); assert_eq!(event_log.events.len(), 5); assert_eq!(event_log.events[4].name, "args"); match event_log.events[4].event { EventKind::Args { ref argv } => { let re = Regex::new("volta_core").unwrap(); assert!(re.is_match(argv)); } _ => { panic!( "Expected EventKind::Args {{ argv }}, Got: {:?}", event_log.events[4].event ); } } } } ================================================ FILE: crates/volta-core/src/fs.rs ================================================ //! Provides utilities for operating on the filesystem. use std::fs::{self, create_dir_all, read_dir, DirEntry, File, Metadata}; use std::io; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::Path; use crate::error::{Context, ErrorKind, Fallible}; use crate::layout::volta_home; use retry::delay::Fibonacci; use retry::{retry, OperationResult}; use tempfile::{tempdir_in, NamedTempFile, TempDir}; /// Opens a file, creating it if it doesn't exist pub fn touch(path: &Path) -> io::Result { if !path.is_file() { if let Some(basedir) = path.parent() { create_dir_all(basedir)?; } File::create(path)?; } File::open(path) } /// Removes the target directory, if it exists. If the directory doesn't exist, that is treated as /// success. pub fn remove_dir_if_exists>(path: P) -> Fallible<()> { fs::remove_dir_all(&path) .or_else(ok_if_not_found) .with_context(|| ErrorKind::DeleteDirectoryError { directory: path.as_ref().to_owned(), }) } /// Removes the target file, if it exists. If the file doesn't exist, that is treated as success. pub fn remove_file_if_exists>(path: P) -> Fallible<()> { fs::remove_file(&path) .or_else(ok_if_not_found) .with_context(|| ErrorKind::DeleteFileError { file: path.as_ref().to_owned(), }) } /// Converts a failure because of file not found into a success. /// /// Handling the error is preferred over checking if a file exists before removing it, since /// that avoids a potential race condition between the check and the removal. pub fn ok_if_not_found(err: io::Error) -> io::Result { match err.kind() { io::ErrorKind::NotFound => Ok(T::default()), _ => Err(err), } } /// Reads a file, if it exists. pub fn read_file>(path: P) -> io::Result> { let result: io::Result = fs::read_to_string(path); match result { Ok(string) => Ok(Some(string)), Err(error) => match error.kind() { io::ErrorKind::NotFound => Ok(None), _ => Err(error), }, } } /// Reads the full contents of a directory, eagerly extracting each directory entry /// and its metadata and returning an iterator over them. Returns `Error` if any of /// these steps fails. /// /// This function makes it easier to write high level logic for manipulating the /// contents of directories (map, filter, etc). /// /// Note that this function allocates an intermediate vector of directory entries to /// construct the iterator from, so if a directory is expected to be very large, it /// will allocate temporary data proportional to the number of entries. pub fn read_dir_eager(dir: &Path) -> io::Result> { let entries = read_dir(dir)?; let vec = entries .map(|entry| { let entry = entry?; let metadata = entry.metadata()?; Ok((entry, metadata)) }) .collect::>>()?; Ok(vec.into_iter()) } /// Reads the contents of a directory and returns a Vec of the matched results /// from the input function pub fn dir_entry_match(dir: &Path, mut f: F) -> io::Result> where F: FnMut(&DirEntry) -> Option, { let entries = read_dir_eager(dir)?; Ok(entries .filter(|(_, metadata)| metadata.is_file()) .filter_map(|(entry, _)| f(&entry)) .collect::>()) } /// Creates a NamedTempFile in the Volta tmp directory pub fn create_staging_file() -> Fallible { let tmp_dir = volta_home()?.tmp_dir(); NamedTempFile::new_in(tmp_dir).with_context(|| ErrorKind::CreateTempFileError { in_dir: tmp_dir.to_owned(), }) } /// Creates a staging directory in the Volta tmp directory pub fn create_staging_dir() -> Fallible { let tmp_root = volta_home()?.tmp_dir(); tempdir_in(tmp_root).with_context(|| ErrorKind::CreateTempDirError { in_dir: tmp_root.to_owned(), }) } /// Create a file symlink. The `dst` path will be a symbolic link pointing to the `src` path. pub fn symlink_file(src: S, dest: D) -> io::Result<()> where S: AsRef, D: AsRef, { #[cfg(windows)] return std::os::windows::fs::symlink_file(src, dest); #[cfg(unix)] return std::os::unix::fs::symlink(src, dest); } /// Create a directory symlink. The `dst` path will be a symbolic link pointing to the `src` path pub fn symlink_dir(src: S, dest: D) -> io::Result<()> where S: AsRef, D: AsRef, { #[cfg(windows)] return junction::create(src, dest); #[cfg(unix)] return std::os::unix::fs::symlink(src, dest); } /// Ensure that a given file has 'executable' permissions, otherwise we won't be able to call it #[cfg(unix)] pub fn set_executable(bin: &Path) -> io::Result<()> { let mut permissions = fs::metadata(bin)?.permissions(); let mode = permissions.mode(); if mode & 0o111 != 0o111 { permissions.set_mode(mode | 0o111); fs::set_permissions(bin, permissions) } else { Ok(()) } } /// Ensure that a given file has 'executable' permissions, otherwise we won't be able to call it /// /// Note: This is a no-op on Windows, which has no concept of 'executable' permissions #[cfg(windows)] pub fn set_executable(_bin: &Path) -> io::Result<()> { Ok(()) } /// Rename a file or directory to a new name, retrying if the operation fails because of permissions /// /// Will retry for ~30 seconds with longer and longer delays between each, to allow for virus scan /// and other automated operations to complete. pub fn rename(from: F, to: T) -> io::Result<()> where F: AsRef, T: AsRef, { // 21 Fibonacci steps starting at 1 ms is ~28 seconds total // See https://github.com/rust-lang/rustup/pull/1873 where this was used by Rustup to work around // virus scanning file locks let from = from.as_ref(); let to = to.as_ref(); retry(Fibonacci::from_millis(1).take(21), || { match fs::rename(from, to) { Ok(_) => OperationResult::Ok(()), Err(e) => match e.kind() { io::ErrorKind::PermissionDenied => OperationResult::Retry(e), _ => OperationResult::Err(e), }, } }) .map_err(|e| e.error) } ================================================ FILE: crates/volta-core/src/hook/mod.rs ================================================ //! Provides types for working with Volta hooks. use std::borrow::Cow; use std::fs::File; use std::iter::once; use std::marker::PhantomData; use std::path::Path; use crate::error::{Context, ErrorKind, Fallible}; use crate::layout::volta_home; use crate::project::Project; use crate::tool::{Node, Npm, Pnpm, Tool}; use log::debug; use once_cell::unsync::OnceCell; pub(crate) mod serial; pub mod tool; /// A hook for publishing Volta events. #[derive(PartialEq, Eq, Debug)] pub enum Publish { /// Reports an event by sending a POST request to a URL. Url(String), /// Reports an event by forking a process and sending the event by IPC. Bin(String), } /// Lazily loaded Volta hook configuration pub struct LazyHookConfig { settings: OnceCell, } impl LazyHookConfig { /// Constructs a new `LazyHookConfig` pub fn init() -> LazyHookConfig { LazyHookConfig { settings: OnceCell::new(), } } /// Forces the loading of the hook configuration from both project-local and user-default hooks pub fn get(&self, project: Option<&Project>) -> Fallible<&HookConfig> { self.settings .get_or_try_init(|| HookConfig::current(project)) } } /// Volta hook configuration pub struct HookConfig { node: Option>, npm: Option>, pnpm: Option>, yarn: Option, events: Option, } /// Volta hooks for an individual tool pub struct ToolHooks { /// The hook for resolving the URL for a distro version pub distro: Option, /// The hook for resolving the URL for the latest version pub latest: Option, /// The hook for resolving the Tool Index URL pub index: Option, phantom: PhantomData, } /// Volta hooks for Yarn pub struct YarnHooks { /// The hook for resolving the URL for a distro version pub distro: Option, /// The hook for resolving the URL for the latest version pub latest: Option, /// The hook for resolving the Tool Index URL pub index: Option, } impl ToolHooks { /// Extends this ToolHooks with another, giving precendence to the current instance fn merge(self, other: Self) -> Self { Self { distro: self.distro.or(other.distro), latest: self.latest.or(other.latest), index: self.index.or(other.index), phantom: PhantomData, } } } impl YarnHooks { /// Extends this YarnHooks with another, giving precendence to the current instance fn merge(self, other: Self) -> Self { Self { distro: self.distro.or(other.distro), latest: self.latest.or(other.latest), index: self.index.or(other.index), } } } macro_rules! merge_hooks { ($self:ident, $other:ident, $field:ident) => { match ($self.$field, $other.$field) { (Some(current), Some(other)) => Some(current.merge(other)), (Some(single), None) | (None, Some(single)) => Some(single), (None, None) => None, } }; } impl HookConfig { pub fn node(&self) -> Option<&ToolHooks> { self.node.as_ref() } pub fn npm(&self) -> Option<&ToolHooks> { self.npm.as_ref() } pub fn pnpm(&self) -> Option<&ToolHooks> { self.pnpm.as_ref() } pub fn yarn(&self) -> Option<&YarnHooks> { self.yarn.as_ref() } pub fn events(&self) -> Option<&EventHooks> { self.events.as_ref() } /// Returns the current hooks, which are a merge between the user hooks and /// the project hooks (if any). fn current(project: Option<&Project>) -> Fallible { let default_hooks_file = volta_home()?.default_hooks_file(); // Since `from_paths` expects the paths to be sorted in descending precedence order, we // include all project hooks first (workspace_roots is already sorted in descending // precedence order) // See the per-project configuration RFC for more details on the configuration precedence: // https://github.com/volta-cli/rfcs/blob/main/text/0033-per-project-config.md#configuration-precedence let paths = project .into_iter() .flat_map(Project::workspace_roots) .map(|root| { let mut path = root.join(".volta"); path.push("hooks.json"); Cow::Owned(path) }) .chain(once(Cow::Borrowed(default_hooks_file))); Self::from_paths(paths) } /// Returns the merged hooks loaded from an iterator of potential hook files /// /// `paths` should be sorted in order of descending precedence. fn from_paths(paths: I) -> Fallible where P: AsRef, I: IntoIterator, { paths .into_iter() .try_fold(None, |acc: Option, hooks_file| { // Try to load the hooks and merge with any already loaded hooks match Self::from_file(hooks_file.as_ref())? { Some(hooks) => { debug!( "Loaded custom hooks file: {}", hooks_file.as_ref().display() ); Ok(Some(match acc { Some(loaded) => loaded.merge(hooks), None => hooks, })) } None => Ok(acc), } }) // If there were no hooks loaded at all, provide a default empty HookConfig .map(|maybe_config| { maybe_config.unwrap_or_else(|| { debug!("No custom hooks found"); Self { node: None, npm: None, pnpm: None, yarn: None, events: None, } }) }) } fn from_file(file_path: &Path) -> Fallible> { if !file_path.is_file() { return Ok(None); } let file = File::open(file_path).with_context(|| ErrorKind::ReadHooksError { file: file_path.to_path_buf(), })?; let raw: serial::RawHookConfig = serde_json::de::from_reader(file).with_context(|| ErrorKind::ParseHooksError { file: file_path.to_path_buf(), })?; // Invariant: Since we successfully loaded it, we know we have a valid file path let hooks_path = file_path.parent().expect("File paths always have a parent"); raw.into_hook_config(hooks_path).map(Some) } /// Merges this HookConfig with another, giving precedence to the current instance fn merge(self, other: Self) -> Self { Self { node: merge_hooks!(self, other, node), npm: merge_hooks!(self, other, npm), pnpm: merge_hooks!(self, other, pnpm), yarn: merge_hooks!(self, other, yarn), events: merge_hooks!(self, other, events), } } } /// Format of the registry used for Yarn (Npm or Github) #[derive(PartialEq, Eq, Debug)] pub enum RegistryFormat { Npm, Github, } impl RegistryFormat { pub fn from_str(raw_format: &str) -> Fallible { match raw_format { "npm" => Ok(RegistryFormat::Npm), "github" => Ok(RegistryFormat::Github), other => Err(ErrorKind::InvalidRegistryFormat { format: String::from(other), } .into()), } } } /// Volta hooks related to events. pub struct EventHooks { /// The hook for publishing events, if any. pub publish: Option, } impl EventHooks { /// Merges this EventHooks with another, giving precedence to the current instance fn merge(self, other: Self) -> Self { Self { publish: self.publish.or(other.publish), } } } #[cfg(test)] pub mod tests { use super::{tool, HookConfig, Publish, RegistryFormat}; use std::path::PathBuf; fn fixture_path(fixture_dir: &str) -> PathBuf { let mut cargo_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); cargo_manifest_dir.push("fixtures"); cargo_manifest_dir.push(fixture_dir); cargo_manifest_dir } #[test] fn test_from_str_event_url() { let fixture_dir = fixture_path("hooks"); let url_file = fixture_dir.join("event_url.json"); let hooks = HookConfig::from_file(&url_file).unwrap().unwrap(); assert_eq!( hooks.events.unwrap().publish, Some(Publish::Url("https://google.com".to_string())) ); } #[test] fn test_from_str_bins() { let fixture_dir = fixture_path("hooks"); let bin_file = fixture_dir.join("bins.json"); let hooks = HookConfig::from_file(&bin_file).unwrap().unwrap(); let node = hooks.node.unwrap(); let pnpm = hooks.pnpm.unwrap(); let yarn = hooks.yarn.unwrap(); assert_eq!( node.distro, Some(tool::DistroHook::Bin { bin: "/some/bin/for/node/distro".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( node.latest, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/latest".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( node.index, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/index".to_string(), base_path: fixture_dir.clone(), }) ); // pnpm assert_eq!( pnpm.distro, Some(tool::DistroHook::Bin { bin: "/bin/to/pnpm/distro".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( pnpm.latest, Some(tool::MetadataHook::Bin { bin: "/bin/to/pnpm/latest".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( pnpm.index, Some(tool::MetadataHook::Bin { bin: "/bin/to/pnpm/index".to_string(), base_path: fixture_dir.clone(), }) ); // Yarn assert_eq!( yarn.distro, Some(tool::DistroHook::Bin { bin: "/bin/to/yarn/distro".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( yarn.latest, Some(tool::MetadataHook::Bin { bin: "/bin/to/yarn/latest".to_string(), base_path: fixture_dir.clone(), }) ); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Bin { bin: "/bin/to/yarn/index".to_string(), base_path: fixture_dir, }, }) ); assert_eq!( hooks.events.unwrap().publish, Some(Publish::Bin("/events/bin".to_string())) ); } #[test] fn test_from_str_prefixes() { let fixture_dir = fixture_path("hooks"); let prefix_file = fixture_dir.join("prefixes.json"); let hooks = HookConfig::from_file(&prefix_file).unwrap().unwrap(); let node = hooks.node.unwrap(); let pnpm = hooks.pnpm.unwrap(); let yarn = hooks.yarn.unwrap(); assert_eq!( node.distro, Some(tool::DistroHook::Prefix( "http://localhost/node/distro/".to_string() )) ); assert_eq!( node.latest, Some(tool::MetadataHook::Prefix( "http://localhost/node/latest/".to_string() )) ); assert_eq!( node.index, Some(tool::MetadataHook::Prefix( "http://localhost/node/index/".to_string() )) ); // pnpm assert_eq!( pnpm.distro, Some(tool::DistroHook::Prefix( "http://localhost/pnpm/distro/".to_string() )) ); assert_eq!( pnpm.latest, Some(tool::MetadataHook::Prefix( "http://localhost/pnpm/latest/".to_string() )) ); assert_eq!( pnpm.index, Some(tool::MetadataHook::Prefix( "http://localhost/pnpm/index/".to_string() )) ); // Yarn assert_eq!( yarn.distro, Some(tool::DistroHook::Prefix( "http://localhost/yarn/distro/".to_string() )) ); assert_eq!( yarn.latest, Some(tool::MetadataHook::Prefix( "http://localhost/yarn/latest/".to_string() )) ); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Prefix("http://localhost/yarn/index/".to_string()) }) ); } #[test] fn test_from_str_templates() { let fixture_dir = fixture_path("hooks"); let template_file = fixture_dir.join("templates.json"); let hooks = HookConfig::from_file(&template_file).unwrap().unwrap(); let node = hooks.node.unwrap(); let pnpm = hooks.pnpm.unwrap(); let yarn = hooks.yarn.unwrap(); assert_eq!( node.distro, Some(tool::DistroHook::Template( "http://localhost/node/distro/{{version}}/".to_string() )) ); assert_eq!( node.latest, Some(tool::MetadataHook::Template( "http://localhost/node/latest/{{version}}/".to_string() )) ); assert_eq!( node.index, Some(tool::MetadataHook::Template( "http://localhost/node/index/{{version}}/".to_string() )) ); // pnpm assert_eq!( pnpm.distro, Some(tool::DistroHook::Template( "http://localhost/pnpm/distro/{{version}}/".to_string() )) ); assert_eq!( pnpm.latest, Some(tool::MetadataHook::Template( "http://localhost/pnpm/latest/{{version}}/".to_string() )) ); assert_eq!( pnpm.index, Some(tool::MetadataHook::Template( "http://localhost/pnpm/index/{{version}}/".to_string() )) ); // Yarn assert_eq!( yarn.distro, Some(tool::DistroHook::Template( "http://localhost/yarn/distro/{{version}}/".to_string() )) ); assert_eq!( yarn.latest, Some(tool::MetadataHook::Template( "http://localhost/yarn/latest/{{version}}/".to_string() )) ); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Template( "http://localhost/yarn/index/{{version}}/".to_string() ) }) ); } #[test] fn test_from_str_format_npm() { let fixture_dir = fixture_path("hooks"); let format_npm_file = fixture_dir.join("format_npm.json"); let hooks = HookConfig::from_file(&format_npm_file).unwrap().unwrap(); let yarn = hooks.yarn.unwrap(); let node = hooks.node.unwrap(); let npm = hooks.npm.unwrap(); let pnpm = hooks.pnpm.unwrap(); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Npm, metadata: tool::MetadataHook::Prefix("http://localhost/yarn/index/".to_string()) }) ); // node and npm don't have format assert_eq!( node.index, Some(tool::MetadataHook::Prefix( "http://localhost/node/index/".to_string() )) ); assert_eq!( npm.index, Some(tool::MetadataHook::Prefix( "http://localhost/npm/index/".to_string() )) ); // pnpm also doesn't have format assert_eq!( pnpm.index, Some(tool::MetadataHook::Prefix( "http://localhost/pnpm/index/".to_string() )) ); } #[test] fn test_from_str_format_github() { let fixture_dir = fixture_path("hooks"); let format_github_file = fixture_dir.join("format_github.json"); let hooks = HookConfig::from_file(&format_github_file).unwrap().unwrap(); let yarn = hooks.yarn.unwrap(); let node = hooks.node.unwrap(); let npm = hooks.npm.unwrap(); let pnpm = hooks.pnpm.unwrap(); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Prefix("http://localhost/yarn/index/".to_string()) }) ); // node and npm don't have format assert_eq!( node.index, Some(tool::MetadataHook::Prefix( "http://localhost/node/index/".to_string() )) ); assert_eq!( npm.index, Some(tool::MetadataHook::Prefix( "http://localhost/npm/index/".to_string() )) ); // pnpm also doesn't have format assert_eq!( pnpm.index, Some(tool::MetadataHook::Prefix( "http://localhost/pnpm/index/".to_string() )) ); } #[test] fn test_merge() { let fixture_dir = fixture_path("hooks"); let default_hooks = HookConfig::from_file(&fixture_dir.join("templates.json")) .unwrap() .unwrap(); let project_hooks_dir = fixture_path("hooks/project/.volta"); let project_hooks_file = project_hooks_dir.join("hooks.json"); let project_hooks = HookConfig::from_file(&project_hooks_file) .expect("Could not read project hooks.json") .expect("Could not find project hooks.json"); let merged_hooks = project_hooks.merge(default_hooks); let node = merged_hooks.node.expect("No node config found"); let pnpm = merged_hooks.pnpm.expect("No pnpm config found"); let yarn = merged_hooks.yarn.expect("No yarn config found"); assert_eq!( node.distro, Some(tool::DistroHook::Bin { bin: "/some/bin/for/node/distro".to_string(), base_path: project_hooks_dir.clone(), }) ); assert_eq!( node.latest, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/latest".to_string(), base_path: project_hooks_dir.clone(), }) ); assert_eq!( node.index, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/index".to_string(), base_path: project_hooks_dir, }) ); // pnpm assert_eq!( pnpm.distro, Some(tool::DistroHook::Template( "http://localhost/pnpm/distro/{{version}}/".to_string() )) ); assert_eq!( pnpm.latest, Some(tool::MetadataHook::Template( "http://localhost/pnpm/latest/{{version}}/".to_string() )) ); assert_eq!( pnpm.index, Some(tool::MetadataHook::Template( "http://localhost/pnpm/index/{{version}}/".to_string() )) ); // Yarn assert_eq!( yarn.distro, Some(tool::DistroHook::Template( "http://localhost/yarn/distro/{{version}}/".to_string() )) ); assert_eq!( yarn.latest, Some(tool::MetadataHook::Template( "http://localhost/yarn/latest/{{version}}/".to_string() )) ); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Template( "http://localhost/yarn/index/{{version}}/".to_string() ) }) ); assert_eq!( merged_hooks.events.expect("No events config found").publish, Some(Publish::Bin("/events/bin".to_string())) ); } #[test] fn test_from_paths() { let project_hooks_dir = fixture_path("hooks/project/.volta"); let project_hooks_file = project_hooks_dir.join("hooks.json"); let default_hooks_file = fixture_path("hooks/templates.json"); let merged_hooks = HookConfig::from_paths([project_hooks_file, default_hooks_file]).unwrap(); let node = merged_hooks.node.expect("No node config found"); let pnpm = merged_hooks.pnpm.expect("No pnpm config found"); let yarn = merged_hooks.yarn.expect("No yarn config found"); assert_eq!( node.distro, Some(tool::DistroHook::Bin { bin: "/some/bin/for/node/distro".to_string(), base_path: project_hooks_dir.clone(), }) ); assert_eq!( node.latest, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/latest".to_string(), base_path: project_hooks_dir.clone(), }) ); assert_eq!( node.index, Some(tool::MetadataHook::Bin { bin: "/some/bin/for/node/index".to_string(), base_path: project_hooks_dir, }) ); // pnpm assert_eq!( pnpm.distro, Some(tool::DistroHook::Template( "http://localhost/pnpm/distro/{{version}}/".to_string() )) ); assert_eq!( pnpm.latest, Some(tool::MetadataHook::Template( "http://localhost/pnpm/latest/{{version}}/".to_string() )) ); assert_eq!( pnpm.index, Some(tool::MetadataHook::Template( "http://localhost/pnpm/index/{{version}}/".to_string() )) ); // Yarn assert_eq!( yarn.distro, Some(tool::DistroHook::Template( "http://localhost/yarn/distro/{{version}}/".to_string() )) ); assert_eq!( yarn.latest, Some(tool::MetadataHook::Template( "http://localhost/yarn/latest/{{version}}/".to_string() )) ); assert_eq!( yarn.index, Some(tool::YarnIndexHook { format: RegistryFormat::Github, metadata: tool::MetadataHook::Template( "http://localhost/yarn/index/{{version}}/".to_string() ) }) ); assert_eq!( merged_hooks.events.expect("No events config found").publish, Some(Publish::Bin("/events/bin".to_string())) ); } } ================================================ FILE: crates/volta-core/src/hook/serial.rs ================================================ use std::marker::PhantomData; use std::path::Path; use super::tool; use super::RegistryFormat; use crate::error::{ErrorKind, Fallible, VoltaError}; use crate::tool::{Node, Npm, Pnpm, Tool}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct RawResolveHook { prefix: Option, template: Option, bin: Option, } #[derive(Serialize, Deserialize)] pub struct RawIndexHook { prefix: Option, template: Option, bin: Option, format: Option, } #[derive(Serialize, Deserialize)] pub struct RawPublishHook { url: Option, bin: Option, } impl RawResolveHook { fn into_hook(self, to_prefix: P, to_template: T, to_bin: B) -> Fallible where P: FnOnce(String) -> H, T: FnOnce(String) -> H, B: FnOnce(String) -> H, { match self { RawResolveHook { prefix: Some(prefix), template: None, bin: None, } => Ok(to_prefix(prefix)), RawResolveHook { prefix: None, template: Some(template), bin: None, } => Ok(to_template(template)), RawResolveHook { prefix: None, template: None, bin: Some(bin), } => Ok(to_bin(bin)), RawResolveHook { prefix: None, template: None, bin: None, } => Err(ErrorKind::HookNoFieldsSpecified.into()), _ => Err(ErrorKind::HookMultipleFieldsSpecified.into()), } } pub fn into_distro_hook(self, base_dir: &Path) -> Fallible { self.into_hook( tool::DistroHook::Prefix, tool::DistroHook::Template, |bin| tool::DistroHook::Bin { bin, base_path: base_dir.to_owned(), }, ) } pub fn into_metadata_hook(self, base_dir: &Path) -> Fallible { self.into_hook( tool::MetadataHook::Prefix, tool::MetadataHook::Template, |bin| tool::MetadataHook::Bin { bin, base_path: base_dir.to_owned(), }, ) } } impl RawIndexHook { pub fn into_index_hook(self, base_dir: &Path) -> Fallible { // use user-specified format, or default to Github (legacy) let format = match self.format { Some(format_str) => RegistryFormat::from_str(&format_str)?, None => RegistryFormat::Github, }; Ok(tool::YarnIndexHook { format, metadata: RawResolveHook { prefix: self.prefix, template: self.template, bin: self.bin, } .into_metadata_hook(base_dir)?, }) } } impl TryFrom for super::Publish { type Error = VoltaError; fn try_from(raw: RawPublishHook) -> Fallible { match raw { RawPublishHook { url: Some(url), bin: None, } => Ok(super::Publish::Url(url)), RawPublishHook { url: None, bin: Some(bin), } => Ok(super::Publish::Bin(bin)), RawPublishHook { url: None, bin: None, } => Err(ErrorKind::PublishHookNeitherUrlNorBin.into()), _ => Err(ErrorKind::PublishHookBothUrlAndBin.into()), } } } #[derive(Serialize, Deserialize)] pub struct RawHookConfig { pub node: Option>, pub npm: Option>, pub pnpm: Option>, pub yarn: Option, pub events: Option, } #[derive(Serialize, Deserialize)] #[serde(rename = "events")] pub struct RawEventHooks { pub publish: Option, } impl TryFrom for super::EventHooks { type Error = VoltaError; fn try_from(raw: RawEventHooks) -> Fallible { let publish = raw.publish.map(|p| p.try_into()).transpose()?; Ok(super::EventHooks { publish }) } } #[derive(Serialize, Deserialize)] #[serde(rename = "tool")] pub struct RawToolHooks { pub distro: Option, pub latest: Option, pub index: Option, #[serde(skip)] phantom: PhantomData, } #[derive(Serialize, Deserialize)] #[serde(rename = "yarn")] pub struct RawYarnHooks { pub distro: Option, pub latest: Option, pub index: Option, } impl RawHookConfig { pub fn into_hook_config(self, base_dir: &Path) -> Fallible { let node = self.node.map(|n| n.into_tool_hooks(base_dir)).transpose()?; let npm = self.npm.map(|n| n.into_tool_hooks(base_dir)).transpose()?; let pnpm = self.pnpm.map(|p| p.into_tool_hooks(base_dir)).transpose()?; let yarn = self.yarn.map(|y| y.into_yarn_hooks(base_dir)).transpose()?; let events = self.events.map(|e| e.try_into()).transpose()?; Ok(super::HookConfig { node, npm, pnpm, yarn, events, }) } } impl RawToolHooks { pub fn into_tool_hooks(self, base_dir: &Path) -> Fallible> { let distro = self .distro .map(|d| d.into_distro_hook(base_dir)) .transpose()?; let latest = self .latest .map(|d| d.into_metadata_hook(base_dir)) .transpose()?; let index = self .index .map(|d| d.into_metadata_hook(base_dir)) .transpose()?; Ok(super::ToolHooks { distro, latest, index, phantom: PhantomData, }) } } impl RawYarnHooks { pub fn into_yarn_hooks(self, base_dir: &Path) -> Fallible { let distro = self .distro .map(|d| d.into_distro_hook(base_dir)) .transpose()?; let latest = self .latest .map(|d| d.into_metadata_hook(base_dir)) .transpose()?; let index = self .index .map(|d| d.into_index_hook(base_dir)) .transpose()?; Ok(super::YarnHooks { distro, latest, index, }) } } ================================================ FILE: crates/volta-core/src/hook/tool.rs ================================================ //! Types representing Volta Tool Hooks. use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; use crate::command::create_command; use crate::error::{Context, ErrorKind, Fallible}; use crate::hook::RegistryFormat; use crate::tool::{NODE_DISTRO_ARCH, NODE_DISTRO_OS}; use cmdline_words_parser::parse_posix; use dunce::canonicalize; use log::debug; use node_semver::Version; use once_cell::sync::Lazy; const ARCH_TEMPLATE: &str = "{{arch}}"; const OS_TEMPLATE: &str = "{{os}}"; const VERSION_TEMPLATE: &str = "{{version}}"; const EXTENSION_TEMPLATE: &str = "{{ext}}"; const FILENAME_TEMPLATE: &str = "{{filename}}"; static REL_PATH: Lazy = Lazy::new(|| format!(".{}", std::path::MAIN_SEPARATOR)); static REL_PATH_PARENT: Lazy = Lazy::new(|| format!("..{}", std::path::MAIN_SEPARATOR)); /// A hook for resolving the distro URL for a given tool version #[derive(PartialEq, Eq, Debug)] pub enum DistroHook { Prefix(String), Template(String), Bin { bin: String, base_path: PathBuf }, } impl DistroHook { /// Performs resolution of the distro URL based on the given version and file name pub fn resolve(&self, version: &Version, filename: &str) -> Fallible { let extension = calculate_extension(filename).unwrap_or(""); match &self { DistroHook::Prefix(prefix) => Ok(format!("{}{}", prefix, filename)), DistroHook::Template(template) => Ok(template .replace(ARCH_TEMPLATE, NODE_DISTRO_ARCH) .replace(OS_TEMPLATE, NODE_DISTRO_OS) .replace(EXTENSION_TEMPLATE, extension) .replace(FILENAME_TEMPLATE, filename) .replace(VERSION_TEMPLATE, &version.to_string())), DistroHook::Bin { bin, base_path } => { execute_binary(bin, base_path, Some(version.to_string())) } } } } /// Use the expected filename to determine the extension for this hook /// /// This will include the multi-part `tar.gz` extension if it is present, otherwise it will use /// the standard extension. fn calculate_extension(filename: &str) -> Option<&str> { let mut parts = filename.rsplit('.'); match (parts.next(), parts.next(), parts.next()) { (Some(ext), Some("tar"), Some(_)) => { // .tar.gz style extension, return both parts // tar . gz let index = filename.len() - 3 - 1 - ext.len(); filename.get(index..) } (Some(_), Some(""), None) => { // Dotfile, e.g. `.npmrc`, where the `.` character is at the beginning - No extension None } (Some(ext), Some(_), _) => { // Standard File Extension Some(ext) } _ => None, } } /// A hook for resolving the URL for metadata about a tool #[derive(PartialEq, Eq, Debug)] pub enum MetadataHook { Prefix(String), Template(String), Bin { bin: String, base_path: PathBuf }, } impl MetadataHook { /// Performs resolution of the metadata URL based on the given default file name pub fn resolve(&self, filename: &str) -> Fallible { match &self { MetadataHook::Prefix(prefix) => Ok(format!("{}{}", prefix, filename)), MetadataHook::Template(template) => Ok(template .replace(ARCH_TEMPLATE, NODE_DISTRO_ARCH) .replace(OS_TEMPLATE, NODE_DISTRO_OS) .replace(FILENAME_TEMPLATE, filename)), MetadataHook::Bin { bin, base_path } => execute_binary(bin, base_path, None), } } } /// A hook for resolving the URL for the Yarn index #[derive(PartialEq, Eq, Debug)] pub struct YarnIndexHook { pub format: RegistryFormat, pub metadata: MetadataHook, } impl YarnIndexHook { /// Performs resolution of the metadata URL based on the given default file name pub fn resolve(&self, filename: &str) -> Fallible { match &self.metadata { MetadataHook::Prefix(prefix) => Ok(format!("{}{}", prefix, filename)), MetadataHook::Template(template) => Ok(template .replace(ARCH_TEMPLATE, NODE_DISTRO_ARCH) .replace(OS_TEMPLATE, NODE_DISTRO_OS) .replace(FILENAME_TEMPLATE, filename)), MetadataHook::Bin { bin, base_path } => execute_binary(bin, base_path, None), } } } /// Execute a shell command and return the trimmed stdout from that command fn execute_binary(bin: &str, base_path: &Path, extra_arg: Option) -> Fallible { let mut trimmed = bin.trim().to_string(); let mut words = parse_posix(&mut trimmed); let cmd = match words.next() { Some(word) => { // Treat any path that starts with a './' or '../' as a relative path (using OS separator) if word.starts_with(REL_PATH.as_str()) || word.starts_with(REL_PATH_PARENT.as_str()) { canonicalize(base_path.join(word)).with_context(|| ErrorKind::HookPathError { command: String::from(word), })? } else { PathBuf::from(word) } } None => { return Err(ErrorKind::InvalidHookCommand { command: String::from(bin.trim()), } .into()) } }; let mut args: Vec = words.map(OsString::from).collect(); if let Some(arg) = extra_arg { args.push(OsString::from(arg)); } let mut command = create_command(cmd); command .args(&args) .current_dir(base_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); debug!("Running hook command: {:?}", command); let output = command .output() .with_context(|| ErrorKind::ExecuteHookError { command: String::from(bin.trim()), })?; if !output.status.success() { return Err(ErrorKind::HookCommandFailed { command: bin.trim().into(), } .into()); } let url = String::from_utf8(output.stdout).with_context(|| ErrorKind::InvalidHookOutput { command: String::from(bin.trim()), })?; Ok(url.trim().to_string()) } #[cfg(test)] pub mod tests { use super::{calculate_extension, DistroHook, MetadataHook}; use crate::tool::{NODE_DISTRO_ARCH, NODE_DISTRO_OS}; use node_semver::Version; #[test] fn test_distro_prefix_resolve() { let prefix = "http://localhost/node/distro/"; let filename = "node.tar.gz"; let hook = DistroHook::Prefix(prefix.to_string()); let version = Version::parse("1.0.0").unwrap(); assert_eq!( hook.resolve(&version, filename) .expect("Could not resolve URL"), format!("{}{}", prefix, filename) ); } #[test] fn test_distro_template_resolve() { let hook = DistroHook::Template( "http://localhost/node/{{os}}/{{arch}}/{{version}}/{{ext}}/{{filename}}".to_string(), ); let version = Version::parse("1.0.0").unwrap(); // tar.gz format has extra handling, to support a multi-part extension let expected = format!( "http://localhost/node/{}/{}/{}/tar.gz/node-v1.0.0.tar.gz", NODE_DISTRO_OS, NODE_DISTRO_ARCH, version ); assert_eq!( hook.resolve(&version, "node-v1.0.0.tar.gz") .expect("Could not resolve URL"), expected ); // zip is a standard extension let expected = format!( "http://localhost/node/{}/{}/{}/zip/node-v1.0.0.zip", NODE_DISTRO_OS, NODE_DISTRO_ARCH, version ); assert_eq!( hook.resolve(&version, "node-v1.0.0.zip") .expect("Could not resolve URL"), expected ); } #[test] fn test_metadata_prefix_resolve() { let prefix = "http://localhost/node/index/"; let filename = "index.json"; let hook = MetadataHook::Prefix(prefix.to_string()); assert_eq!( hook.resolve(filename).expect("Could not resolve URL"), format!("{}{}", prefix, filename) ); } #[test] fn test_metadata_template_resolve() { let hook = MetadataHook::Template( "http://localhost/node/{{os}}/{{arch}}/{{filename}}".to_string(), ); let expected = format!( "http://localhost/node/{}/{}/index.json", NODE_DISTRO_OS, NODE_DISTRO_ARCH ); assert_eq!( hook.resolve("index.json").expect("Could not resolve URL"), expected ); } #[test] fn test_calculate_extension() { // Handles .tar.* files assert_eq!(calculate_extension("file.tar.gz"), Some("tar.gz")); assert_eq!(calculate_extension("file.tar.xz"), Some("tar.xz")); assert_eq!(calculate_extension("file.tar.xyz"), Some("tar.xyz")); // Handles dotfiles assert_eq!(calculate_extension(".filerc"), None); // Handles standard extensions assert_eq!(calculate_extension("tar.gz"), Some("gz")); assert_eq!(calculate_extension("file.zip"), Some("zip")); // Handles files with no extension at all assert_eq!(calculate_extension("bare_file"), None); } } ================================================ FILE: crates/volta-core/src/inventory.rs ================================================ //! Provides types for working with Volta's _inventory_, the local repository //! of available tool versions. use std::collections::BTreeSet; use std::ffi::OsStr; use std::path::Path; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::read_dir_eager; use crate::layout::volta_home; use crate::tool::PackageConfig; use crate::version::parse_version; use log::debug; use node_semver::Version; use walkdir::WalkDir; /// Checks if a given Node version image is available on the local machine pub fn node_available(version: &Version) -> Fallible { volta_home().map(|home| { home.node_image_root_dir() .join(version.to_string()) .exists() }) } /// Collects a set of all Node versions fetched on the local machine pub fn node_versions() -> Fallible> { volta_home().and_then(|home| read_versions(home.node_image_root_dir())) } /// Checks if a given npm version image is available on the local machine pub fn npm_available(version: &Version) -> Fallible { volta_home().map(|home| home.npm_image_dir(&version.to_string()).exists()) } /// Collects a set of all npm versions fetched on the local machine pub fn npm_versions() -> Fallible> { volta_home().and_then(|home| read_versions(home.npm_image_root_dir())) } /// Checks if a given pnpm version image is available on the local machine pub fn pnpm_available(version: &Version) -> Fallible { volta_home().map(|home| home.pnpm_image_dir(&version.to_string()).exists()) } /// Collects a set of all pnpm versions fetched on the local machine pub fn pnpm_versions() -> Fallible> { volta_home().and_then(|home| read_versions(home.pnpm_image_root_dir())) } /// Checks if a given Yarn version image is available on the local machine pub fn yarn_available(version: &Version) -> Fallible { volta_home().map(|home| home.yarn_image_dir(&version.to_string()).exists()) } /// Collects a set of all Yarn versions fetched on the local machine pub fn yarn_versions() -> Fallible> { volta_home().and_then(|home| read_versions(home.yarn_image_root_dir())) } /// Collects a set of all Package Configs on the local machine pub fn package_configs() -> Fallible> { let package_dir = volta_home()?.default_package_dir(); WalkDir::new(package_dir) .max_depth(2) .into_iter() // Ignore any items which didn't resolve as `DirEntry` correctly. // There is no point trying to do anything with those, and no error // we can report to the user in any case. Log the failure in the // debug output, though .filter_map(|entry| match entry { Ok(dir_entry) => { // Ignore directory entries and any files that don't have a .json extension. // This will prevent us from trying to parse OS-generated files as package // configs (e.g. `.DS_Store` on macOS) let extension = dir_entry.path().extension().and_then(OsStr::to_str); match (dir_entry.file_type().is_file(), extension) { (true, Some(ext)) if ext.eq_ignore_ascii_case("json") => { Some(dir_entry.into_path()) } _ => None, } } Err(e) => { debug!("{}", e); None } }) .map(PackageConfig::from_file) .collect() } /// Reads the contents of a directory and returns the set of all versions found /// in the directory's listing by parsing the directory names as semantic versions fn read_versions(dir: &Path) -> Fallible> { let contents = read_dir_eager(dir).with_context(|| ErrorKind::ReadDirError { dir: dir.to_owned(), })?; Ok(contents .filter(|(_, metadata)| metadata.is_dir()) .filter_map(|(entry, _)| parse_version(entry.file_name().to_string_lossy()).ok()) .collect()) } ================================================ FILE: crates/volta-core/src/layout/mod.rs ================================================ use std::env; use std::path::PathBuf; use crate::error::{Context, ErrorKind, Fallible}; use cfg_if::cfg_if; use dunce::canonicalize; use once_cell::sync::OnceCell; use volta_layout::v4::{VoltaHome, VoltaInstall}; cfg_if! { if #[cfg(unix)] { mod unix; pub use unix::*; } else if #[cfg(windows)] { mod windows; pub use windows::*; } } static VOLTA_HOME: OnceCell = OnceCell::new(); static VOLTA_INSTALL: OnceCell = OnceCell::new(); pub fn volta_home<'a>() -> Fallible<&'a VoltaHome> { VOLTA_HOME.get_or_try_init(|| { let home_dir = match env::var_os("VOLTA_HOME") { Some(home) => PathBuf::from(home), None => default_home_dir()?, }; Ok(VoltaHome::new(home_dir)) }) } pub fn volta_install<'a>() -> Fallible<&'a VoltaInstall> { VOLTA_INSTALL.get_or_try_init(|| { let install_dir = match env::var_os("VOLTA_INSTALL_DIR") { Some(install) => PathBuf::from(install), None => default_install_dir()?, }; Ok(VoltaInstall::new(install_dir)) }) } /// Determine the binary install directory from the currently running executable /// /// The volta-shim and volta binaries will be installed in the same location, so we can use the /// currently running executable to find the binary install directory. Note that we need to /// canonicalize the path we get from current_exe to make sure we resolve symlinks and find the /// actual binary files fn default_install_dir() -> Fallible { env::current_exe() .and_then(canonicalize) .map(|mut path| { path.pop(); // Remove the executable name from the path path }) .with_context(|| ErrorKind::NoInstallDir) } ================================================ FILE: crates/volta-core/src/layout/unix.rs ================================================ use std::path::PathBuf; use super::volta_home; use crate::error::{ErrorKind, Fallible}; pub(super) fn default_home_dir() -> Fallible { let mut home = dirs::home_dir().ok_or(ErrorKind::NoHomeEnvironmentVar)?; home.push(".volta"); Ok(home) } pub fn env_paths() -> Fallible> { let home = volta_home()?; Ok(vec![home.shim_dir().to_owned()]) } ================================================ FILE: crates/volta-core/src/layout/windows.rs ================================================ use std::path::PathBuf; use super::{volta_home, volta_install}; use crate::error::{ErrorKind, Fallible}; pub(super) fn default_home_dir() -> Fallible { let mut home = dirs::data_local_dir().ok_or(ErrorKind::NoLocalDataDir)?; home.push("Volta"); Ok(home) } pub fn env_paths() -> Fallible> { let home = volta_home()?; let install = volta_install()?; Ok(vec![home.shim_dir().to_owned(), install.root().to_owned()]) } ================================================ FILE: crates/volta-core/src/lib.rs ================================================ //! The main implementation crate for the core of Volta. mod command; pub mod error; pub mod event; pub mod fs; mod hook; pub mod inventory; pub mod layout; pub mod log; pub mod monitor; pub mod platform; pub mod project; pub mod run; pub mod session; pub mod shim; pub mod signal; pub mod style; pub mod sync; pub mod tool; pub mod toolchain; pub mod version; const VOLTA_FEATURE_PNPM: &str = "VOLTA_FEATURE_PNPM"; ================================================ FILE: crates/volta-core/src/log.rs ================================================ //! This module provides a custom Logger implementation for use with the `log` crate use console::style; use log::{trace, Level, LevelFilter, Log, Metadata, Record, SetLoggerError}; use std::env; use std::fmt::Display; use std::io::IsTerminal; use textwrap::{fill, Options, WordSplitter}; use crate::style::text_width; const ERROR_PREFIX: &str = "error:"; const WARNING_PREFIX: &str = "warning:"; const SHIM_ERROR_PREFIX: &str = "Volta error:"; const SHIM_WARNING_PREFIX: &str = "Volta warning:"; const MIGRATION_ERROR_PREFIX: &str = "Volta update error:"; const MIGRATION_WARNING_PREFIX: &str = "Volta update warning:"; const VOLTA_LOGLEVEL: &str = "VOLTA_LOGLEVEL"; const ALLOWED_PREFIXES: [&str; 5] = [ "volta", "archive", "fs-utils", "progress-read", "validate-npm-package-name", ]; const WRAP_INDENT: &str = " "; /// Represents the context from which the logger was created pub enum LogContext { /// Log messages from the `volta` executable Volta, /// Log messages from one of the shims Shim, /// Log messages from the migration Migration, } /// Represents the level of verbosity that was requested by the user #[derive(Debug, Copy, Clone)] pub enum LogVerbosity { Quiet, Default, Verbose, VeryVerbose, } pub struct Logger { context: LogContext, level: LevelFilter, } impl Log for Logger { fn enabled(&self, metadata: &Metadata) -> bool { metadata.level() <= self.level } fn log(&self, record: &Record) { let level_allowed = self.enabled(record.metadata()); let is_valid_target = ALLOWED_PREFIXES .iter() .any(|prefix| record.target().starts_with(prefix)); if level_allowed && is_valid_target { match record.level() { Level::Error => self.log_error(record.args()), Level::Warn => self.log_warning(record.args()), // all info-level messages go to stdout Level::Info => println!("{}", record.args()), // all debug- and trace-level messages go to stderr Level::Debug => eprintln!("[verbose] {}", record.args()), Level::Trace => eprintln!("[trace] {}", record.args()), } } } fn flush(&self) {} } impl Logger { /// Initialize the global logger with a Logger instance /// Will use the requested level of Verbosity /// If set to Default, will use the environment to determine the level of verbosity pub fn init(context: LogContext, verbosity: LogVerbosity) -> Result<(), SetLoggerError> { let logger = Logger::new(context, verbosity); log::set_max_level(logger.level); log::set_boxed_logger(Box::new(logger))?; Ok(()) } fn new(context: LogContext, verbosity: LogVerbosity) -> Self { let level = match verbosity { LogVerbosity::Quiet => LevelFilter::Error, LogVerbosity::Default => level_from_env(), LogVerbosity::Verbose => LevelFilter::Debug, LogVerbosity::VeryVerbose => LevelFilter::Trace, }; Logger { context, level } } fn log_error(&self, message: &D) where D: Display, { let prefix = match &self.context { LogContext::Volta => ERROR_PREFIX, LogContext::Shim => SHIM_ERROR_PREFIX, LogContext::Migration => MIGRATION_ERROR_PREFIX, }; eprintln!("{} {}", style(prefix).red().bold(), message); } fn log_warning(&self, message: &D) where D: Display, { let prefix = match &self.context { LogContext::Volta => WARNING_PREFIX, LogContext::Shim => SHIM_WARNING_PREFIX, LogContext::Migration => MIGRATION_WARNING_PREFIX, }; eprintln!( "{} {}", style(prefix).yellow().bold(), wrap_content(prefix, message) ); } } /// Wraps the supplied content to the terminal width, if we are in a terminal. /// If not, returns the content as a String /// /// Note: Uses the supplied prefix to calculate the terminal width, but then removes /// it so that it can be styled (style characters are counted against the wrapped width) fn wrap_content(prefix: &str, content: &D) -> String where D: Display, { match text_width() { Some(width) => { let options = Options::new(width) .word_splitter(WordSplitter::NoHyphenation) .subsequent_indent(WRAP_INDENT) .break_words(false); fill(&format!("{} {}", prefix, content), options).replace(prefix, "") } None => format!(" {}", content), } } /// Determines the correct logging level based on the environment /// If VOLTA_LOGLEVEL is set to a valid level, we use that /// If not, we check the current stdout to determine whether it is a TTY or not /// If it is a TTY, we use Info /// If it is NOT a TTY, we use Error as we don't want to show warnings when running as a script fn level_from_env() -> LevelFilter { env::var(VOLTA_LOGLEVEL) .ok() .and_then(|level| level.to_uppercase().parse().ok()) .unwrap_or_else(|| { if std::io::stdout().is_terminal() { trace!("using fallback log level (info)"); LevelFilter::Info } else { LevelFilter::Error } }) } #[cfg(test)] mod tests {} ================================================ FILE: crates/volta-core/src/monitor.rs ================================================ use std::env; use std::io::Write; use std::path::PathBuf; use std::process::{Child, Stdio}; use log::debug; use tempfile::NamedTempFile; use crate::command::create_command; use crate::event::Event; /// Send event to the spawned command process // if hook command is not configured, this is not called pub fn send_events(command: &str, events: &[Event]) { match serde_json::to_string_pretty(&events) { Ok(events_json) => { let tempfile_path = env::var_os("VOLTA_WRITE_EVENTS_FILE") .and_then(|_| write_events_file(events_json.clone())); if let Some(ref mut child_process) = spawn_process(command, tempfile_path) { if let Some(ref mut p_stdin) = child_process.stdin.as_mut() { if let Err(error) = writeln!(p_stdin, "{}", events_json) { debug!("Could not write events to executable stdin: {:?}", error); } } } } Err(error) => { debug!("Could not serialize events data to JSON: {:?}", error); } } } // Write the events JSON to a file in the temporary directory fn write_events_file(events_json: String) -> Option { match NamedTempFile::new() { Ok(mut events_file) => { match events_file.write_all(events_json.as_bytes()) { Ok(()) => { let path = events_file.into_temp_path(); // if it's not persisted, the temp file will be automatically deleted // (and the executable won't be able to read it) match path.keep() { Ok(tempfile_path) => Some(tempfile_path), Err(error) => { debug!("Failed to persist temp file for events data: {:?}", error); None } } } Err(error) => { debug!("Failed to write events to the temp file: {:?}", error); None } } } Err(error) => { debug!("Failed to create a temp file for events data: {:?}", error); None } } } // Spawn a child process to receive the events data, setting the path to the events file as an env var fn spawn_process(command: &str, tempfile_path: Option) -> Option { command.split(' ').take(1).next().and_then(|executable| { let mut child = create_command(executable); child.args(command.split(' ').skip(1)); child.stdin(Stdio::piped()); if let Some(events_file) = tempfile_path { child.env("EVENTS_FILE", events_file); } #[cfg(not(debug_assertions))] // Hide stdout and stderr of spawned process in release mode child.stdout(Stdio::null()).stderr(Stdio::null()); match child.spawn() { Err(err) => { debug!("Unable to run executable command: '{}'\n{}", command, err); None } Ok(c) => Some(c), } }) } ================================================ FILE: crates/volta-core/src/platform/image.rs ================================================ use std::ffi::OsString; use std::path::PathBuf; use super::{build_path_error, Sourced}; use crate::error::{Context, Fallible}; use crate::layout::volta_home; use crate::tool::load_default_npm_version; use node_semver::Version; /// A platform image. pub struct Image { /// The pinned version of Node. pub node: Sourced, /// The custom version of npm, if any. `None` represents using the npm that is bundled with Node pub npm: Option>, /// The pinned version of pnpm, if any. pub pnpm: Option>, /// The pinned version of Yarn, if any. pub yarn: Option>, } impl Image { fn bins(&self) -> Fallible> { let home = volta_home()?; let mut bins = Vec::with_capacity(3); if let Some(npm) = &self.npm { let npm_str = npm.value.to_string(); bins.push(home.npm_image_bin_dir(&npm_str)); } if let Some(pnpm) = &self.pnpm { let pnpm_str = pnpm.value.to_string(); bins.push(home.pnpm_image_bin_dir(&pnpm_str)); } if let Some(yarn) = &self.yarn { let yarn_str = yarn.value.to_string(); bins.push(home.yarn_image_bin_dir(&yarn_str)); } // Add Node path to the bins last, so that any custom version of npm will be earlier in the PATH let node_str = self.node.value.to_string(); bins.push(home.node_image_bin_dir(&node_str)); Ok(bins) } /// Produces a modified version of the current `PATH` environment variable that /// will find toolchain executables (Node, npm, pnpm, Yarn) in the installation directories /// for the given versions instead of in the Volta shim directory. pub fn path(&self) -> Fallible { let old_path = envoy::path().unwrap_or_else(|| envoy::Var::from("")); old_path .split() .prefix(self.bins()?) .join() .with_context(build_path_error) } /// Determines the sourced version of npm that will be available, resolving the version bundled with Node, if needed pub fn resolve_npm(&self) -> Fallible> { match &self.npm { Some(npm) => Ok(npm.clone()), None => load_default_npm_version(&self.node.value).map(|npm| Sourced { value: npm, source: self.node.source, }), } } } ================================================ FILE: crates/volta-core/src/platform/mod.rs ================================================ use std::env; use std::fmt; use crate::error::{ErrorKind, Fallible}; use crate::session::Session; use crate::tool::{Node, Npm, Pnpm, Yarn}; use crate::VOLTA_FEATURE_PNPM; use node_semver::Version; mod image; mod system; // Note: The tests get their own module because we need them to run as a single unit to prevent // clobbering environment variable changes #[cfg(test)] mod tests; pub use image::Image; pub use system::System; /// The source with which a version is associated #[derive(Clone, Copy)] #[cfg_attr(test, derive(Eq, PartialEq, Debug))] pub enum Source { /// Represents a version from the user default platform Default, /// Represents a version from a project manifest Project, /// Represents a version from a pinned Binary platform Binary, /// Represents a version from the command line (via `volta run`) CommandLine, } impl fmt::Display for Source { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Source::Default => write!(f, "default"), Source::Project => write!(f, "project"), Source::Binary => write!(f, "binary"), Source::CommandLine => write!(f, "command-line"), } } } pub struct Sourced { pub value: T, pub source: Source, } impl Sourced { pub fn with_default(value: T) -> Self { Sourced { value, source: Source::Default, } } pub fn with_project(value: T) -> Self { Sourced { value, source: Source::Project, } } pub fn with_binary(value: T) -> Self { Sourced { value, source: Source::Binary, } } pub fn with_command_line(value: T) -> Self { Sourced { value, source: Source::CommandLine, } } } impl Sourced { pub fn as_ref(&self) -> Sourced<&T> { Sourced { value: &self.value, source: self.source, } } } impl Sourced<&T> where T: Clone, { pub fn cloned(self) -> Sourced { Sourced { value: self.value.clone(), source: self.source, } } } impl Clone for Sourced where T: Clone, { fn clone(&self) -> Sourced { Sourced { value: self.value.clone(), source: self.source, } } } /// Represents 3 possible states: Having a value, not having a value, and inheriting a value #[cfg_attr(test, derive(Eq, PartialEq, Debug))] #[derive(Clone, Default)] pub enum InheritOption { Some(T), None, #[default] Inherit, } impl InheritOption { /// Applies a function to the contained value (if any) pub fn map(self, f: F) -> InheritOption where F: FnOnce(T) -> U, { match self { InheritOption::Some(value) => InheritOption::Some(f(value)), InheritOption::None => InheritOption::None, InheritOption::Inherit => InheritOption::Inherit, } } /// Converts the `InheritOption` into a regular `Option` by inheriting from the provided value if needed pub fn inherit(self, other: Option) -> Option { match self { InheritOption::Some(value) => Some(value), InheritOption::None => None, InheritOption::Inherit => other, } } } impl From> for Option { fn from(base: InheritOption) -> Option { base.inherit(None) } } #[derive(Clone, PartialOrd, Ord, PartialEq, Eq)] #[cfg_attr(test, derive(Debug))] /// Represents the specification of a single Platform, regardless of the source pub struct PlatformSpec { pub node: Version, pub npm: Option, pub pnpm: Option, pub yarn: Option, } impl PlatformSpec { /// Convert this PlatformSpec into a Platform with all sources set to `Default` pub fn as_default(&self) -> Platform { Platform { node: Sourced::with_default(self.node.clone()), npm: self.npm.clone().map(Sourced::with_default), pnpm: self.pnpm.clone().map(Sourced::with_default), yarn: self.yarn.clone().map(Sourced::with_default), } } /// Convert this PlatformSpec into a Platform with all sources set to `Project` pub fn as_project(&self) -> Platform { Platform { node: Sourced::with_project(self.node.clone()), npm: self.npm.clone().map(Sourced::with_project), pnpm: self.pnpm.clone().map(Sourced::with_project), yarn: self.yarn.clone().map(Sourced::with_project), } } /// Convert this PlatformSpec into a Platform with all sources set to `Binary` pub fn as_binary(&self) -> Platform { Platform { node: Sourced::with_binary(self.node.clone()), npm: self.npm.clone().map(Sourced::with_binary), pnpm: self.pnpm.clone().map(Sourced::with_binary), yarn: self.yarn.clone().map(Sourced::with_binary), } } } /// Represents a (maybe) platform with values from the command line #[derive(Clone)] pub struct CliPlatform { pub node: Option, pub npm: InheritOption, pub pnpm: InheritOption, pub yarn: InheritOption, } impl CliPlatform { /// Merges the `CliPlatform` with a `Platform`, inheriting from the base where needed pub fn merge(self, base: Platform) -> Platform { Platform { node: self.node.map_or(base.node, Sourced::with_command_line), npm: self.npm.map(Sourced::with_command_line).inherit(base.npm), pnpm: self.pnpm.map(Sourced::with_command_line).inherit(base.pnpm), yarn: self.yarn.map(Sourced::with_command_line).inherit(base.yarn), } } } impl From for Option { /// Converts the `CliPlatform` into a possible Platform without a base from which to inherit fn from(base: CliPlatform) -> Option { match base.node { None => None, Some(node) => Some(Platform { node: Sourced::with_command_line(node), npm: base.npm.map(Sourced::with_command_line).into(), pnpm: base.pnpm.map(Sourced::with_command_line).into(), yarn: base.yarn.map(Sourced::with_command_line).into(), }), } } } /// Represents a real Platform, with Versions pulled from one or more `PlatformSpec`s #[derive(Clone)] pub struct Platform { pub node: Sourced, pub npm: Option>, pub pnpm: Option>, pub yarn: Option>, } impl Platform { /// Returns the user's currently active platform, if any /// /// Active platform is determined by first looking at the Project Platform /// /// - If there is a project platform then we use it /// - If there is no pnpm/Yarn version in the project platform, we pull /// pnpm/Yarn from the default platform if available, and merge the two /// platforms into a final one /// - If there is no Project platform, then we use the user Default Platform pub fn current(session: &mut Session) -> Fallible> { if let Some(mut platform) = session.project_platform()?.map(PlatformSpec::as_project) { if platform.pnpm.is_none() { platform.pnpm = session .default_platform()? .and_then(|default_platform| default_platform.pnpm.clone()) .map(Sourced::with_default); } if platform.yarn.is_none() { platform.yarn = session .default_platform()? .and_then(|default_platform| default_platform.yarn.clone()) .map(Sourced::with_default); } Ok(Some(platform)) } else { Ok(session.default_platform()?.map(PlatformSpec::as_default)) } } /// Check out a `Platform` into a fully-realized `Image` /// /// This will ensure that all necessary tools are fetched and available for execution pub fn checkout(self, session: &mut Session) -> Fallible { Node::new(self.node.value.clone()).ensure_fetched(session)?; if let Some(Sourced { value: version, .. }) = &self.npm { Npm::new(version.clone()).ensure_fetched(session)?; } // Only force download of the pnpm version if the pnpm feature flag is set. If it isn't, // then we won't be using the `Pnpm` tool to execute (we will be relying on the global // package logic), so fetching the Pnpm version would only be redundant work. if env::var_os(VOLTA_FEATURE_PNPM).is_some() { if let Some(Sourced { value: version, .. }) = &self.pnpm { Pnpm::new(version.clone()).ensure_fetched(session)?; } } if let Some(Sourced { value: version, .. }) = &self.yarn { Yarn::new(version.clone()).ensure_fetched(session)?; } Ok(Image { node: self.node, npm: self.npm, pnpm: self.pnpm, yarn: self.yarn, }) } } fn build_path_error() -> ErrorKind { ErrorKind::BuildPathError } ================================================ FILE: crates/volta-core/src/platform/system.rs ================================================ use std::ffi::OsString; use super::build_path_error; use crate::error::{Context, Fallible}; use crate::layout::env_paths; /// A lightweight namespace type representing the system environment, i.e. the environment /// with Volta removed. pub struct System; impl System { /// Produces a modified version of the current `PATH` environment variable that /// removes the Volta shims and binaries, to use for running system node and /// executables. pub fn path() -> Fallible { let old_path = envoy::path().unwrap_or_else(|| envoy::Var::from("")); let mut new_path = old_path.split(); for remove_path in env_paths()? { new_path = new_path.remove(remove_path); } new_path.join().with_context(build_path_error) } } ================================================ FILE: crates/volta-core/src/platform/tests.rs ================================================ use super::*; use crate::layout::volta_home; #[cfg(windows)] use crate::layout::volta_install; use node_semver::Version; #[cfg(windows)] use std::path::PathBuf; // Since unit tests are run in parallel, tests that modify the PATH environment variable are subject to race conditions // To prevent that, ensure that all tests that rely on PATH are run in serial by adding them to this meta-test #[test] fn test_paths() { test_image_path(); test_system_path(); } #[cfg(unix)] fn build_test_path() -> String { format!( "{}:/usr/bin:/bin", volta_home().unwrap().shim_dir().to_string_lossy() ) } #[cfg(windows)] fn build_test_path() -> String { let pathbufs = vec![ volta_home().unwrap().shim_dir().to_owned(), PathBuf::from("C:\\\\somebin"), volta_install().unwrap().root().to_owned(), PathBuf::from("D:\\\\ProbramFlies"), ]; std::env::join_paths(pathbufs.iter()) .unwrap() .into_string() .expect("Could not create path containing shim dir") } fn test_image_path() { #[cfg(unix)] let path_delimiter = ":"; #[cfg(windows)] let path_delimiter = ";"; let path = build_test_path(); std::env::set_var("PATH", &path); let node_bin = volta_home().unwrap().node_image_bin_dir("1.2.3"); let expected_node_bin = node_bin.to_str().unwrap(); let npm_bin = volta_home().unwrap().npm_image_bin_dir("6.4.3"); let expected_npm_bin = npm_bin.to_str().unwrap(); let pnpm_bin = volta_home().unwrap().pnpm_image_bin_dir("7.7.1"); let expected_pnpm_bin = pnpm_bin.to_str().unwrap(); let yarn_bin = volta_home().unwrap().yarn_image_bin_dir("4.5.7"); let expected_yarn_bin = yarn_bin.to_str().unwrap(); let v123 = Version::parse("1.2.3").unwrap(); let v457 = Version::parse("4.5.7").unwrap(); let v643 = Version::parse("6.4.3").unwrap(); let v771 = Version::parse("7.7.1").unwrap(); let only_node = Image { node: Sourced::with_default(v123.clone()), npm: None, pnpm: None, yarn: None, }; assert_eq!( only_node.path().unwrap().into_string().unwrap(), [expected_node_bin, &path].join(path_delimiter) ); let node_npm = Image { node: Sourced::with_default(v123.clone()), npm: Some(Sourced::with_default(v643.clone())), pnpm: None, yarn: None, }; assert_eq!( node_npm.path().unwrap().into_string().unwrap(), [expected_npm_bin, expected_node_bin, &path].join(path_delimiter) ); let node_pnpm = Image { node: Sourced::with_default(v123.clone()), npm: None, pnpm: Some(Sourced::with_default(v771.clone())), yarn: None, }; assert_eq!( node_pnpm.path().unwrap().into_string().unwrap(), [expected_pnpm_bin, expected_node_bin, &path].join(path_delimiter) ); let node_yarn = Image { node: Sourced::with_default(v123.clone()), npm: None, pnpm: None, yarn: Some(Sourced::with_default(v457.clone())), }; assert_eq!( node_yarn.path().unwrap().into_string().unwrap(), [expected_yarn_bin, expected_node_bin, &path].join(path_delimiter) ); let node_npm_pnpm = Image { node: Sourced::with_default(v123.clone()), npm: Some(Sourced::with_default(v643.clone())), pnpm: Some(Sourced::with_default(v771)), yarn: None, }; assert_eq!( node_npm_pnpm.path().unwrap().into_string().unwrap(), [ expected_npm_bin, expected_pnpm_bin, expected_node_bin, &path ] .join(path_delimiter) ); let node_npm_yarn = Image { node: Sourced::with_default(v123), npm: Some(Sourced::with_default(v643)), pnpm: None, yarn: Some(Sourced::with_default(v457)), }; assert_eq!( node_npm_yarn.path().unwrap().into_string().unwrap(), [ expected_npm_bin, expected_yarn_bin, expected_node_bin, &path ] .join(path_delimiter) ); } fn test_system_path() { let path = build_test_path(); std::env::set_var("PATH", path); #[cfg(unix)] let expected_path = String::from("/usr/bin:/bin"); #[cfg(windows)] let expected_path = String::from("C:\\\\somebin;D:\\\\ProbramFlies"); assert_eq!( System::path().unwrap().into_string().unwrap(), expected_path ); } mod inherit_option { mod map { use super::super::super::*; #[test] fn converts_some_value() { let opt = InheritOption::Some(1); assert_eq!(opt.map(|n| n + 1), InheritOption::Some(2)); } #[test] fn leaves_none() { let opt: InheritOption = InheritOption::None; assert_eq!(opt.map(|n| n + 1), InheritOption::None); } #[test] fn leaves_inherit() { let opt: InheritOption = InheritOption::Inherit; assert_eq!(opt.map(|n| n + 1), InheritOption::Inherit); } } mod inherit { use super::super::super::*; #[test] fn keeps_some_value() { let opt = InheritOption::Some(1); assert_eq!(opt.inherit(Some(2)), Some(1)); } #[test] fn leaves_none() { let opt = InheritOption::None; assert_eq!(opt.inherit(Some(2)), None); } #[test] fn inherits_from_base() { let opt = InheritOption::Inherit; assert_eq!(opt.inherit(Some(2)), Some(2)); } } } mod cli_platform { use node_semver::Version; const NODE_VERSION: Version = Version { major: 12, minor: 14, patch: 1, build: Vec::new(), pre_release: Vec::new(), }; const NPM_VERSION: Version = Version { major: 6, minor: 13, patch: 2, build: Vec::new(), pre_release: Vec::new(), }; const YARN_VERSION: Version = Version { major: 1, minor: 17, patch: 0, build: Vec::new(), pre_release: Vec::new(), }; mod merge { use super::super::super::*; use super::*; #[test] fn uses_node() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: None, pnpm: None, yarn: None, }; let merged = test.merge(base); assert_eq!(merged.node.value, NODE_VERSION); assert_eq!(merged.node.source, Source::CommandLine); } #[test] fn inherits_node() { let test = CliPlatform { node: None, npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let base = Platform { node: Sourced::with_default(NODE_VERSION), npm: None, pnpm: None, yarn: None, }; let merged = test.merge(base); assert_eq!(merged.node.value, NODE_VERSION); assert_eq!(merged.node.source, Source::Default); } #[test] fn uses_npm() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::Some(NPM_VERSION), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: Some(Sourced::with_default(Version::from((5, 6, 3)))), pnpm: None, yarn: None, }; let merged = test.merge(base); let merged_npm = merged.npm.unwrap(); assert_eq!(merged_npm.value, NPM_VERSION); assert_eq!(merged_npm.source, Source::CommandLine); } #[test] fn inherits_npm() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::Inherit, pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: Some(Sourced::with_default(NPM_VERSION)), pnpm: None, yarn: None, }; let merged = test.merge(base); let merged_npm = merged.npm.unwrap(); assert_eq!(merged_npm.value, NPM_VERSION); assert_eq!(merged_npm.source, Source::Default); } #[test] fn none_does_not_inherit_npm() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::None, pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: Some(Sourced::with_default(NPM_VERSION)), pnpm: None, yarn: None, }; let merged = test.merge(base); assert!(merged.npm.is_none()); } #[test] fn uses_yarn() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::Some(YARN_VERSION), }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: None, pnpm: None, yarn: Some(Sourced::with_default(Version::from((1, 10, 3)))), }; let merged = test.merge(base); let merged_yarn = merged.yarn.unwrap(); assert_eq!(merged_yarn.value, YARN_VERSION); assert_eq!(merged_yarn.source, Source::CommandLine); } #[test] fn inherits_yarn() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::Inherit, }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: None, pnpm: None, yarn: Some(Sourced::with_default(YARN_VERSION)), }; let merged = test.merge(base); let merged_yarn = merged.yarn.unwrap(); assert_eq!(merged_yarn.value, YARN_VERSION); assert_eq!(merged_yarn.source, Source::Default); } #[test] fn none_does_not_inherit_yarn() { let test = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::None, }; let base = Platform { node: Sourced::with_default(Version::from((10, 10, 10))), npm: None, pnpm: None, yarn: Some(Sourced::with_default(YARN_VERSION)), }; let merged = test.merge(base); assert!(merged.yarn.is_none()); } } mod into_platform { use super::super::super::*; use super::*; #[test] fn none_if_no_node() { let cli = CliPlatform { node: None, npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let transformed: Option = cli.into(); assert!(transformed.is_none()); } #[test] fn uses_cli_node() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let transformed: Option = cli.into(); let node = transformed.unwrap().node; assert_eq!(node.value, NODE_VERSION); assert_eq!(node.source, Source::CommandLine); } #[test] fn uses_cli_npm() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::Some(NPM_VERSION), pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let transformed: Option = cli.into(); let npm = transformed.unwrap().npm.unwrap(); assert_eq!(npm.value, NPM_VERSION); assert_eq!(npm.source, Source::CommandLine); } #[test] fn no_npm() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::None, pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let transformed: Option = cli.into(); assert!(transformed.unwrap().npm.is_none()); } #[test] fn inherit_npm_becomes_none() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::Inherit, pnpm: InheritOption::default(), yarn: InheritOption::default(), }; let transformed: Option = cli.into(); assert!(transformed.unwrap().npm.is_none()); } #[test] fn uses_cli_yarn() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::Some(YARN_VERSION), }; let transformed: Option = cli.into(); let yarn = transformed.unwrap().yarn.unwrap(); assert_eq!(yarn.value, YARN_VERSION); assert_eq!(yarn.source, Source::CommandLine); } #[test] fn no_yarn() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::None, }; let transformed: Option = cli.into(); assert!(transformed.unwrap().yarn.is_none()); } #[test] fn inherit_yarn_becomes_none() { let cli = CliPlatform { node: Some(NODE_VERSION), npm: InheritOption::default(), pnpm: InheritOption::default(), yarn: InheritOption::Inherit, }; let transformed: Option = cli.into(); assert!(transformed.unwrap().yarn.is_none()); } } } ================================================ FILE: crates/volta-core/src/project/mod.rs ================================================ //! Provides the `Project` type, which represents a Node project tree in //! the filesystem. use std::env; use std::ffi::OsStr; use std::iter::once; use std::path::{Path, PathBuf}; use node_semver::Version; use once_cell::unsync::OnceCell; use crate::error::{Context, ErrorKind, Fallible, VoltaError}; use crate::layout::volta_home; use crate::platform::PlatformSpec; use crate::tool::BinConfig; use chain_map::ChainMap; use indexmap::IndexSet; mod serial; #[cfg(test)] mod tests; use serial::{update_manifest, Manifest, ManifestKey}; /// A lazily loaded Project pub struct LazyProject { project: OnceCell>, } impl LazyProject { pub fn init() -> Self { LazyProject { project: OnceCell::new(), } } pub fn get(&self) -> Fallible> { let project = self.project.get_or_try_init(Project::for_current_dir)?; Ok(project.as_ref()) } pub fn get_mut(&mut self) -> Fallible> { let _ = self.project.get_or_try_init(Project::for_current_dir)?; Ok(self.project.get_mut().unwrap().as_mut()) } } /// A Node project workspace in the filesystem #[cfg_attr(test, derive(Debug))] pub struct Project { manifest_file: PathBuf, workspace_manifests: IndexSet, dependencies: ChainMap, platform: Option, } impl Project { /// Creates an optional Project instance from the current directory fn for_current_dir() -> Fallible> { let current_dir = env::current_dir().with_context(|| ErrorKind::CurrentDirError)?; Self::for_dir(current_dir) } /// Creates an optional Project instance from the specified directory /// /// Will search ancestors to find a `package.json` and use that as the root of the project fn for_dir(base_dir: PathBuf) -> Fallible> { match find_closest_root(base_dir) { Some(mut project) => { project.push("package.json"); Self::from_file(project).map(Some) } None => Ok(None), } } /// Creates a Project instance from the given package manifest file (`package.json`) fn from_file(manifest_file: PathBuf) -> Fallible { let manifest = Manifest::from_file(&manifest_file)?; let mut dependencies: ChainMap = manifest.dependency_maps.collect(); let mut workspace_manifests = IndexSet::new(); let mut platform = manifest.platform; let mut extends = manifest.extends; // Iterate the `volta.extends` chain, parsing each file in turn while let Some(path) = extends { // Detect cycles to prevent infinite looping if path == manifest_file || workspace_manifests.contains(&path) { let mut paths = vec![manifest_file]; paths.extend(workspace_manifests); return Err(ErrorKind::ExtensionCycleError { paths, duplicate: path, } .into()); } let manifest = Manifest::from_file(&path)?; workspace_manifests.insert(path); dependencies.extend(manifest.dependency_maps); platform = match (platform, manifest.platform) { (Some(base), Some(ext)) => Some(base.merge(ext)), (Some(plat), None) | (None, Some(plat)) => Some(plat), (None, None) => None, }; extends = manifest.extends; } let platform = platform.map(TryInto::try_into).transpose()?; Ok(Project { manifest_file, workspace_manifests, dependencies, platform, }) } /// Returns a reference to the manifest file for the current project pub fn manifest_file(&self) -> &Path { &self.manifest_file } /// Returns an iterator of paths to all of the workspace roots pub fn workspace_roots(&self) -> impl Iterator { // Invariant: self.manifest_file and self.extensions will only contain paths to files that we successfully loaded once(&self.manifest_file) .chain(self.workspace_manifests.iter()) .map(|file| file.parent().expect("File paths always have a parent")) } /// Returns a reference to the Project's `PlatformSpec`, if available pub fn platform(&self) -> Option<&PlatformSpec> { self.platform.as_ref() } /// Returns true if the project dependency map contains the specified dependency pub fn has_direct_dependency(&self, dependency: &str) -> bool { self.dependencies.contains_key(dependency) } /// Returns true if the input binary name is a direct dependency of the input project pub fn has_direct_bin(&self, bin_name: &OsStr) -> Fallible { if let Some(name) = bin_name.to_str() { let config_path = volta_home()?.default_tool_bin_config(name); return match BinConfig::from_file_if_exists(config_path)? { None => Ok(false), Some(config) => Ok(self.has_direct_dependency(&config.package)), }; } Ok(false) } /// Searches the project roots to find the path to a project-local binary file pub fn find_bin>(&self, bin_name: P) -> Option { self.workspace_roots().find_map(|root| { let mut bin_path = root.join("node_modules"); bin_path.push(".bin"); bin_path.push(&bin_name); if bin_path.is_file() { Some(bin_path) } else { None } }) } /// Yarn projects that are using PnP or pnpm linker need to use yarn run. // (project uses Yarn berry if 'yarnrc.yml' exists, uses PnP if '.pnp.js' or '.pnp.cjs' exist) pub fn needs_yarn_run(&self) -> bool { self.platform() .is_some_and(|platform| platform.yarn.is_some()) && self.workspace_roots().any(|x| { x.join(".yarnrc.yml").exists() || x.join(".pnp.cjs").exists() || x.join(".pnp.js").exists() }) } /// Pins the Node version in this project's manifest file pub fn pin_node(&mut self, version: Version) -> Fallible<()> { update_manifest(&self.manifest_file, ManifestKey::Node, Some(&version))?; if let Some(platform) = self.platform.as_mut() { platform.node = version; } else { self.platform = Some(PlatformSpec { node: version, npm: None, pnpm: None, yarn: None, }); } Ok(()) } /// Pins the npm version in this project's manifest file pub fn pin_npm(&mut self, version: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { update_manifest(&self.manifest_file, ManifestKey::Npm, version.as_ref())?; platform.npm = version; Ok(()) } else { Err(ErrorKind::NoPinnedNodeVersion { tool: "npm".into() }.into()) } } /// Pins the pnpm version in this project's manifest file pub fn pin_pnpm(&mut self, version: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { update_manifest(&self.manifest_file, ManifestKey::Pnpm, version.as_ref())?; platform.pnpm = version; Ok(()) } else { Err(ErrorKind::NoPinnedNodeVersion { tool: "pnpm".into(), } .into()) } } /// Pins the Yarn version in this project's manifest file pub fn pin_yarn(&mut self, version: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { update_manifest(&self.manifest_file, ManifestKey::Yarn, version.as_ref())?; platform.yarn = version; Ok(()) } else { Err(ErrorKind::NoPinnedNodeVersion { tool: "Yarn".into(), } .into()) } } } fn is_node_root(dir: &Path) -> bool { dir.join("package.json").exists() } fn is_node_modules(dir: &Path) -> bool { dir.file_name().is_some_and(|tail| tail == "node_modules") } fn is_dependency(dir: &Path) -> bool { dir.parent().is_some_and(is_node_modules) } fn is_project_root(dir: &Path) -> bool { is_node_root(dir) && !is_dependency(dir) } /// Starts at `base_dir` and walks up the directory tree until a package.json file is found pub(crate) fn find_closest_root(mut dir: PathBuf) -> Option { while !is_project_root(&dir) { if !dir.pop() { return None; } } Some(dir) } struct PartialPlatform { node: Option, npm: Option, pnpm: Option, yarn: Option, } impl PartialPlatform { fn merge(self, other: PartialPlatform) -> PartialPlatform { PartialPlatform { node: self.node.or(other.node), npm: self.npm.or(other.npm), pnpm: self.pnpm.or(other.pnpm), yarn: self.yarn.or(other.yarn), } } } impl TryFrom for PlatformSpec { type Error = VoltaError; fn try_from(partial: PartialPlatform) -> Fallible { let node = partial.node.ok_or(ErrorKind::NoProjectNodeInManifest)?; Ok(PlatformSpec { node, npm: partial.npm, pnpm: partial.pnpm, yarn: partial.yarn, }) } } ================================================ FILE: crates/volta-core/src/project/serial.rs ================================================ use std::collections::HashMap; use std::fmt; use std::fs::{read_to_string, File}; use std::io::Write; use std::path::{Path, PathBuf}; use super::PartialPlatform; use crate::error::{Context, ErrorKind, Fallible}; use crate::version::parse_version; use dunce::canonicalize; use node_semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; pub type DependencyMapIterator = std::iter::Chain< std::option::IntoIter>, std::option::IntoIter>, >; pub(super) struct Manifest { pub dependency_maps: DependencyMapIterator, pub platform: Option, pub extends: Option, } impl Manifest { pub fn from_file(file: &Path) -> Fallible { let raw = RawManifest::from_file(file)?; let dependency_maps = raw.dependencies.into_iter().chain(raw.dev_dependencies); let (platform, extends) = match raw.volta { Some(toolchain) => { let (partial, extends) = toolchain.parse_split()?; let next = extends .map(|path| { // Invariant: Since we successfully parsed it, we know we have a path to a file let unresolved = file .parent() .expect("File paths always have a parent") .join(&path); canonicalize(unresolved) .with_context(|| ErrorKind::ExtensionPathError { path }) }) .transpose()?; (Some(partial), next) } None => (None, None), }; Ok(Manifest { dependency_maps, platform, extends, }) } } pub(super) enum ManifestKey { Node, Npm, Pnpm, Yarn, } impl fmt::Display for ManifestKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { ManifestKey::Node => "node", ManifestKey::Npm => "npm", ManifestKey::Pnpm => "pnpm", ManifestKey::Yarn => "yarn", }) } } /// Updates the `volta` hash in the specified manifest with the given key and value /// /// Will create the `volta` hash if it isn't already present /// /// If the value is `None`, will remove the key from the hash pub(super) fn update_manifest( file: &Path, key: ManifestKey, value: Option<&Version>, ) -> Fallible<()> { let contents = read_to_string(file).with_context(|| ErrorKind::PackageReadError { file: file.to_owned(), })?; let mut manifest: serde_json::Value = serde_json::from_str(&contents).with_context(|| ErrorKind::PackageParseError { file: file.to_owned(), })?; let root = manifest .as_object_mut() .ok_or_else(|| ErrorKind::PackageParseError { file: file.to_owned(), })?; let key = key.to_string(); match (value, root.get_mut("volta").and_then(|v| v.as_object_mut())) { (Some(v), Some(hash)) => { hash.insert(key, Value::String(v.to_string())); } (None, Some(hash)) => { hash.remove(&key); } (Some(v), None) => { let mut map = Map::new(); map.insert(key, Value::String(v.to_string())); root.insert("volta".into(), Value::Object(map)); } (None, None) => {} } let indent = detect_indent::detect_indent(&contents); let mut output = File::create(file).with_context(|| ErrorKind::PackageWriteError { file: file.to_owned(), })?; let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.indent().as_bytes()); let mut ser = serde_json::Serializer::with_formatter(&output, formatter); manifest .serialize(&mut ser) .with_context(|| ErrorKind::PackageWriteError { file: file.to_owned(), })?; if contents.ends_with('\n') { writeln!(output).with_context(|| ErrorKind::PackageWriteError { file: file.to_owned(), })?; } Ok(()) } #[derive(Deserialize)] struct RawManifest { dependencies: Option>, #[serde(rename = "devDependencies")] dev_dependencies: Option>, volta: Option, } impl RawManifest { fn from_file(package: &Path) -> Fallible { let file = File::open(package).with_context(|| ErrorKind::PackageReadError { file: package.to_owned(), })?; serde_json::de::from_reader(file).with_context(|| ErrorKind::PackageParseError { file: package.to_owned(), }) } } #[derive(Default, Deserialize, Serialize)] struct ToolchainSpec { #[serde(skip_serializing_if = "Option::is_none")] node: Option, #[serde(skip_serializing_if = "Option::is_none")] npm: Option, #[serde(skip_serializing_if = "Option::is_none")] pnpm: Option, #[serde(skip_serializing_if = "Option::is_none")] yarn: Option, #[serde(skip_serializing_if = "Option::is_none")] extends: Option, } impl ToolchainSpec { /// Moves the tool versions into a `PartialPlatform` and returns that along with the `extends` value fn parse_split(self) -> Fallible<(PartialPlatform, Option)> { let node = self.node.map(parse_version).transpose()?; let npm = self.npm.map(parse_version).transpose()?; let pnpm = self.pnpm.map(parse_version).transpose()?; let yarn = self.yarn.map(parse_version).transpose()?; let platform = PartialPlatform { node, npm, pnpm, yarn, }; Ok((platform, self.extends)) } } ================================================ FILE: crates/volta-core/src/project/tests.rs ================================================ use std::path::PathBuf; use super::*; fn fixture_path(fixture_dirs: &[&str]) -> PathBuf { let mut cargo_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); cargo_manifest_dir.push("fixtures"); for fixture_dir in fixture_dirs.iter() { cargo_manifest_dir.push(fixture_dir); } cargo_manifest_dir } mod find_closest_root { use super::*; #[test] fn test_find_closest_root_direct() { let base_dir = fixture_path(&["basic"]); let project_dir = find_closest_root(base_dir.clone()).expect("Failed to find project directory"); assert_eq!(project_dir, base_dir); } #[test] fn test_find_closest_root_ancestor() { let base_dir = fixture_path(&["basic", "subdir"]); let project_dir = find_closest_root(base_dir).expect("Failed to find project directory"); assert_eq!(project_dir, fixture_path(&["basic"])); } #[test] fn test_find_closest_root_dependency() { let base_dir = fixture_path(&["basic", "node_modules", "eslint"]); let project_dir = find_closest_root(base_dir).expect("Failed to find project directory"); assert_eq!(project_dir, fixture_path(&["basic"])); } } mod project { use super::*; #[test] fn manifest_file() { let project_path = fixture_path(&["basic"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); let expected = fixture_path(&["basic", "package.json"]); assert_eq!(test_project.manifest_file(), &expected); } #[test] fn workspace_roots() { let project_path = fixture_path(&["nested", "subproject", "inner_project"]); let expected_base = project_path.clone(); let test_project = Project::for_dir(project_path).unwrap().unwrap(); let expected = vec![ &*expected_base, expected_base.parent().unwrap(), expected_base.parent().unwrap().parent().unwrap(), ]; assert_eq!(test_project.workspace_roots().collect::>(), expected); } #[test] fn platform_simple() { let project_path = fixture_path(&["basic"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); let platform = test_project.platform().unwrap(); assert_eq!(platform.node, "6.11.1".parse().unwrap()); assert_eq!(platform.npm, Some("3.10.10".parse().unwrap())); assert_eq!(platform.yarn, Some("1.2.0".parse().unwrap())); } #[test] fn platform_workspace() { let project_path = fixture_path(&["nested", "subproject", "inner_project"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); let platform = test_project.platform().unwrap(); // From the top level `nested/package.json` assert_eq!(platform.node, "12.14.0".parse().unwrap()); // From the middle project `nested/subproject/package.json` assert_eq!(platform.npm, Some("6.9.0".parse().unwrap())); // From the innermost project `nested/subproject/inner_project/package.json` assert_eq!(platform.yarn, Some("1.22.4".parse().unwrap())); } #[test] fn direct_dependencies_single() { let project_path = fixture_path(&["basic"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); // eslint, rsvp, bin-1, and bin-2 are direct dependencies assert!(test_project.has_direct_dependency("eslint")); assert!(test_project.has_direct_dependency("rsvp")); assert!(test_project.has_direct_dependency("@namespace/some-dep")); assert!(test_project.has_direct_dependency("@namespaced/something-else")); // typescript is not a direct dependency assert!(!test_project.has_direct_dependency("typescript")); } #[test] fn direct_dependencies_workspace() { let project_path = fixture_path(&["nested", "subproject", "inner_project"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); // express and typescript are direct dependencies of the innermost project assert!(test_project.has_direct_dependency("express")); assert!(test_project.has_direct_dependency("typescript")); // rsvp and glob are direct dependencies of the middle project assert!(test_project.has_direct_dependency("rsvp")); assert!(test_project.has_direct_dependency("glob")); // lodash and eslint are direct dependencies of the top-level workspace assert!(test_project.has_direct_dependency("lodash")); assert!(test_project.has_direct_dependency("eslint")); // react is not a direct dependency of any project assert!(!test_project.has_direct_dependency("react")); } #[test] fn find_bin_single() { let project_path = fixture_path(&["basic"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); assert_eq!( test_project.find_bin("rsvp"), Some(fixture_path(&["basic", "node_modules", ".bin", "rsvp"])) ); assert!(test_project.find_bin("eslint").is_none()); } #[test] fn find_bin_workspace() { // eslint, rsvp, tsc let project_path = fixture_path(&["nested", "subproject", "inner_project"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); // eslint is a binary in the root workspace assert_eq!( test_project.find_bin("eslint"), Some(fixture_path(&["nested", "node_modules", ".bin", "eslint"])) ); // rsvp is a binary in the middle project assert_eq!( test_project.find_bin("rsvp"), Some(fixture_path(&[ "nested", "subproject", "node_modules", ".bin", "rsvp" ])) ); // tsc is a binary in the inner project assert_eq!( test_project.find_bin("tsc"), Some(fixture_path(&[ "nested", "subproject", "inner_project", "node_modules", ".bin", "tsc" ])) ); assert!(test_project.find_bin("ember").is_none()); } #[test] fn detects_workspace_cycles() { // cycle-1 has a cycle with the original package.json let cycle_path = fixture_path(&["cycle-1"]); let project_error = Project::for_dir(cycle_path).unwrap_err(); match project_error.kind() { ErrorKind::ExtensionCycleError { paths, duplicate } => { let expected_paths = vec![ fixture_path(&["cycle-1", "package.json"]), fixture_path(&["cycle-1", "volta.json"]), ]; assert_eq!(&expected_paths, paths); assert_eq!(&expected_paths[0], duplicate); } kind => panic!("Wrong error kind: {:?}", kind), } // cycle-2 has a cycle with 2 separate extensions, not including the original package.json let cycle_path = fixture_path(&["cycle-2"]); let project_error = Project::for_dir(cycle_path).unwrap_err(); match project_error.kind() { ErrorKind::ExtensionCycleError { paths, duplicate } => { let expected_paths = vec![ fixture_path(&["cycle-2", "package.json"]), fixture_path(&["cycle-2", "workspace-1.json"]), fixture_path(&["cycle-2", "workspace-2.json"]), ]; assert_eq!(&expected_paths, paths); assert_eq!(&expected_paths[1], duplicate); } kind => panic!("Wrong error kind: {:?}", kind), } } } mod needs_yarn_run { use super::*; #[test] fn project_does_not_need_yarn_run() { let project_path = fixture_path(&["basic"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); assert!(!test_project.needs_yarn_run()); } #[test] fn project_has_yarnrc_yml() { let project_path = fixture_path(&["yarn", "yarnrc-yml"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); assert!(test_project.needs_yarn_run()); } #[test] fn project_has_pnp_js() { let project_path = fixture_path(&["yarn", "pnp-js"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); assert!(test_project.needs_yarn_run()); } #[test] fn project_has_pnp_cjs() { let project_path = fixture_path(&["yarn", "pnp-cjs"]); let test_project = Project::for_dir(project_path).unwrap().unwrap(); assert!(test_project.needs_yarn_run()); } } ================================================ FILE: crates/volta-core/src/run/binary.rs ================================================ use std::env; use std::ffi::{OsStr, OsString}; use std::path::PathBuf; use super::executor::{Executor, ToolCommand, ToolKind}; use super::{debug_active_image, debug_no_platform}; use crate::error::{Context, ErrorKind, Fallible}; use crate::layout::volta_home; use crate::platform::{Platform, Sourced, System}; use crate::session::Session; use crate::tool::package::BinConfig; use log::debug; /// Determine the correct command to run for a 3rd-party binary /// /// Will detect if we should delegate to the project-local version or use the default version pub(super) fn command(exe: &OsStr, args: &[OsString], session: &mut Session) -> Fallible { let bin = exe.to_string_lossy().to_string(); // First try to use the project toolchain if let Some(project) = session.project()? { // Check if the executable is a direct dependency if project.has_direct_bin(exe)? { match project.find_bin(exe) { Some(path_to_bin) => { debug!("Found {} in project at '{}'", bin, path_to_bin.display()); let platform = Platform::current(session)?; return Ok(ToolCommand::new( path_to_bin, args, platform, ToolKind::ProjectLocalBinary(bin), ) .into()); } None => { if project.needs_yarn_run() { debug!( "Project needs to use yarn to run command, calling {} with 'yarn'", bin ); let platform = Platform::current(session)?; let mut exe_and_args = vec![exe.to_os_string()]; exe_and_args.extend_from_slice(args); return Ok(ToolCommand::new( "yarn", exe_and_args, platform, ToolKind::Yarn, ) .into()); } else { return Err(ErrorKind::ProjectLocalBinaryNotFound { command: exe.to_string_lossy().to_string(), } .into()); } } } } } // Try to use the default toolchain if let Some(default_tool) = DefaultBinary::from_name(exe, session)? { debug!( "Found default {} in '{}'", bin, default_tool.bin_path.display() ); let mut command = ToolCommand::new( default_tool.bin_path, args, Some(default_tool.platform), ToolKind::DefaultBinary(bin), ); command.env("NODE_PATH", shared_module_path()?); return Ok(command.into()); } // At this point, the binary is not known to Volta, so we have no platform to use to execute it // This should be rare, as anything we have a shim for should have a config file to load Ok(ToolCommand::new(exe, args, None, ToolKind::DefaultBinary(bin)).into()) } /// Determine the execution context (PATH and failure error message) for a project-local binary pub(super) fn local_execution_context( tool: String, platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok(( path, ErrorKind::ProjectLocalBinaryExecError { command: tool }, )) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } /// Determine the execution context (PATH and failure error message) for a default binary pub(super) fn default_execution_context( tool: String, platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::BinaryNotFound { name: tool })) } } } /// Information about the location and execution context of default binaries /// /// Fetched from the config files in the Volta directory, represents the binary that is executed /// when the user is outside of a project that has the given bin as a dependency. pub struct DefaultBinary { pub bin_path: PathBuf, pub platform: Platform, } impl DefaultBinary { pub fn from_config(bin_config: BinConfig, session: &mut Session) -> Fallible { let package_dir = volta_home()?.package_image_dir(&bin_config.package); let mut bin_path = bin_config.manager.binary_dir(package_dir); bin_path.push(&bin_config.name); // If the user does not have yarn set in the platform for this binary, use the default // This is necessary because some tools (e.g. ember-cli with the `--yarn` option) invoke `yarn` let yarn = match bin_config.platform.yarn { Some(yarn) => Some(yarn), None => session .default_platform()? .and_then(|plat| plat.yarn.clone()), }; let platform = Platform { node: Sourced::with_binary(bin_config.platform.node), npm: bin_config.platform.npm.map(Sourced::with_binary), pnpm: bin_config.platform.pnpm.map(Sourced::with_binary), yarn: yarn.map(Sourced::with_binary), }; Ok(DefaultBinary { bin_path, platform }) } /// Load information about a default binary by name, if available /// /// A `None` response here means that the tool information couldn't be found. Either the tool /// name is not a valid UTF-8 string, or the tool config doesn't exist. pub fn from_name(tool_name: &OsStr, session: &mut Session) -> Fallible> { let bin_config_file = match tool_name.to_str() { Some(name) => volta_home()?.default_tool_bin_config(name), None => return Ok(None), }; match BinConfig::from_file_if_exists(bin_config_file)? { Some(config) => DefaultBinary::from_config(config, session).map(Some), None => Ok(None), } } } /// Determine the value for NODE_PATH, with the shared lib directory prepended /// /// This will ensure that global bins can `require` other global libs fn shared_module_path() -> Fallible { let node_path = match env::var("NODE_PATH") { Ok(path) => envoy::Var::from(path), Err(_) => envoy::Var::from(""), }; node_path .split() .prefix_entry(volta_home()?.shared_lib_root()) .join() .with_context(|| ErrorKind::BuildPathError) } ================================================ FILE: crates/volta-core/src/run/executor.rs ================================================ use std::collections::HashMap; use std::ffi::OsStr; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; #[cfg(windows)] use std::os::windows::process::ExitStatusExt; use std::process::{Command, ExitStatus}; use super::RECURSION_ENV_VAR; use crate::command::create_command; use crate::error::{Context, ErrorKind, Fallible}; use crate::layout::volta_home; use crate::platform::{CliPlatform, Platform, System}; use crate::session::Session; use crate::signal::pass_control_to_shim; use crate::style::{note_prefix, tool_version}; use crate::sync::VoltaLock; use crate::tool::package::{DirectInstall, InPlaceUpgrade, PackageConfig, PackageManager}; use crate::tool::Spec; use log::{info, warn}; pub enum Executor { Tool(Box), PackageInstall(Box), PackageLink(Box), PackageUpgrade(Box), InternalInstall(Box), Uninstall(Box), Multiple(Vec), } impl Executor { pub fn envs(&mut self, envs: &HashMap) where K: AsRef, V: AsRef, { match self { Executor::Tool(cmd) => cmd.envs(envs), Executor::PackageInstall(cmd) => cmd.envs(envs), Executor::PackageLink(cmd) => cmd.envs(envs), Executor::PackageUpgrade(cmd) => cmd.envs(envs), // Internal installs use Volta's logic and don't rely on the environment variables Executor::InternalInstall(_) => {} // Uninstalls use Volta's logic and don't rely on environment variables Executor::Uninstall(_) => {} Executor::Multiple(executors) => { for exe in executors { exe.envs(envs); } } } } pub fn cli_platform(&mut self, cli: CliPlatform) { match self { Executor::Tool(cmd) => cmd.cli_platform(cli), Executor::PackageInstall(cmd) => cmd.cli_platform(cli), Executor::PackageLink(cmd) => cmd.cli_platform(cli), Executor::PackageUpgrade(cmd) => cmd.cli_platform(cli), // Internal installs use Volta's logic and don't rely on the Node platform Executor::InternalInstall(_) => {} // Uninstall use Volta's logic and don't rely on the Node platform Executor::Uninstall(_) => {} Executor::Multiple(executors) => { for exe in executors { exe.cli_platform(cli.clone()); } } } } pub fn execute(self, session: &mut Session) -> Fallible { match self { Executor::Tool(cmd) => cmd.execute(session), Executor::PackageInstall(cmd) => cmd.execute(session), Executor::PackageLink(cmd) => cmd.execute(session), Executor::PackageUpgrade(cmd) => cmd.execute(session), Executor::InternalInstall(cmd) => cmd.execute(session), Executor::Uninstall(cmd) => cmd.execute(), Executor::Multiple(executors) => { info!( "{} Volta is processing each package separately", note_prefix() ); for exe in executors { let status = exe.execute(session)?; // If any of the sub-commands fail, then we should stop installing and return // that failure. if !status.success() { return Ok(status); } } // If we get here, then all of the sub-commands succeeded, so we should report success Ok(ExitStatus::from_raw(0)) } } } } impl From> for Executor { fn from(mut executors: Vec) -> Self { if executors.len() == 1 { executors.pop().unwrap() } else { Executor::Multiple(executors) } } } /// Process builder for launching a Volta-managed tool /// /// Tracks the Platform as well as what kind of tool is being executed, to allow individual tools /// to customize the behavior before execution. pub struct ToolCommand { command: Command, platform: Option, kind: ToolKind, } /// The kind of tool being executed, used to determine the correct execution context pub enum ToolKind { Node, Npm, Npx, Pnpm, Yarn, ProjectLocalBinary(String), DefaultBinary(String), Bypass(String), } impl ToolCommand { pub fn new(exe: E, args: A, platform: Option, kind: ToolKind) -> Self where E: AsRef, A: IntoIterator, S: AsRef, { let mut command = create_command(exe); command.args(args); Self { command, platform, kind, } } /// Adds or updates environment variables that the command will use pub fn envs(&mut self, envs: E) where E: IntoIterator, K: AsRef, V: AsRef, { self.command.envs(envs); } /// Adds or updates a single environment variable that the command will use pub fn env(&mut self, key: K, value: V) where K: AsRef, V: AsRef, { self.command.env(key, value); } /// Updates the Platform for the command to include values from the command-line pub fn cli_platform(&mut self, cli: CliPlatform) { self.platform = match self.platform.take() { Some(base) => Some(cli.merge(base)), None => cli.into(), }; } /// Runs the command, returning the `ExitStatus` if it successfully launches pub fn execute(mut self, session: &mut Session) -> Fallible { let (path, on_failure) = match self.kind { ToolKind::Node => super::node::execution_context(self.platform, session)?, ToolKind::Npm => super::npm::execution_context(self.platform, session)?, ToolKind::Npx => super::npx::execution_context(self.platform, session)?, ToolKind::Pnpm => super::pnpm::execution_context(self.platform, session)?, ToolKind::Yarn => super::yarn::execution_context(self.platform, session)?, ToolKind::DefaultBinary(bin) => { super::binary::default_execution_context(bin, self.platform, session)? } ToolKind::ProjectLocalBinary(bin) => { super::binary::local_execution_context(bin, self.platform, session)? } ToolKind::Bypass(command) => (System::path()?, ErrorKind::BypassError { command }), }; self.command.env(RECURSION_ENV_VAR, "1"); self.command.env("PATH", path); pass_control_to_shim(); self.command.status().with_context(|| on_failure) } } impl From for Executor { fn from(cmd: ToolCommand) -> Self { Executor::Tool(Box::new(cmd)) } } /// Process builder for launching a package install command (e.g. `npm install --global`) /// /// This will use a `DirectInstall` instance to modify the command before running to point it to /// the Volta directory. It will also complete the install, writing config files and shims pub struct PackageInstallCommand { /// The command that will ultimately be executed command: Command, /// The installer that modifies the command as necessary and provides the completion method installer: DirectInstall, /// The platform to use when running the command. platform: Platform, } impl PackageInstallCommand { pub fn new(args: A, platform: Platform, manager: PackageManager) -> Fallible where A: IntoIterator, S: AsRef, { let installer = DirectInstall::new(manager)?; let mut command = match manager { PackageManager::Npm => create_command("npm"), PackageManager::Pnpm => create_command("pnpm"), PackageManager::Yarn => create_command("yarn"), }; command.args(args); Ok(PackageInstallCommand { command, installer, platform, }) } pub fn for_npm_link(args: A, platform: Platform, name: String) -> Fallible where A: IntoIterator, S: AsRef, { let installer = DirectInstall::with_name(PackageManager::Npm, name)?; let mut command = create_command("npm"); command.args(args); Ok(PackageInstallCommand { command, installer, platform, }) } /// Adds or updates environment variables that the command will use pub fn envs(&mut self, envs: E) where E: IntoIterator, K: AsRef, V: AsRef, { self.command.envs(envs); } /// Updates the Platform for the command to include values from the command-line pub fn cli_platform(&mut self, cli: CliPlatform) { self.platform = cli.merge(self.platform.clone()); } /// Runs the install command, applying the necessary modifications to install into the Volta /// data directory pub fn execute(mut self, session: &mut Session) -> Fallible { let _lock = VoltaLock::acquire(); let image = self.platform.checkout(session)?; let path = image.path()?; self.command.env(RECURSION_ENV_VAR, "1"); self.command.env("PATH", path); self.installer.setup_command(&mut self.command); let status = self .command .status() .with_context(|| ErrorKind::BinaryExecError)?; if status.success() { self.installer.complete_install(&image)?; } Ok(status) } } impl From for Executor { fn from(cmd: PackageInstallCommand) -> Self { Executor::PackageInstall(Box::new(cmd)) } } /// Process builder for launching a `npm link ` command /// /// This will set the appropriate environment variables to ensure that the linked package can be /// found. pub struct PackageLinkCommand { /// The command that will ultimately be executed command: Command, /// The tool the user wants to link tool: String, /// The platform to use when running the command platform: Platform, } impl PackageLinkCommand { pub fn new(args: A, platform: Platform, tool: String) -> Self where A: IntoIterator, S: AsRef, { let mut command = create_command("npm"); command.args(args); PackageLinkCommand { command, tool, platform, } } /// Adds or updates environment variables that the command will use pub fn envs(&mut self, envs: E) where E: IntoIterator, K: AsRef, V: AsRef, { self.command.envs(envs); } /// Updates the Platform for the command to include values from the command-line pub fn cli_platform(&mut self, cli: CliPlatform) { self.platform = cli.merge(self.platform.clone()); } /// Runs the link command, applying the necessary modifications to pull from the Volta data /// directory. /// /// This will also check for some common failure cases and alert the user pub fn execute(mut self, session: &mut Session) -> Fallible { self.check_linked_package(session)?; let image = self.platform.checkout(session)?; let path = image.path()?; self.command.env(RECURSION_ENV_VAR, "1"); self.command.env("PATH", path); let package_root = volta_home()?.package_image_dir(&self.tool); PackageManager::Npm.setup_global_command(&mut self.command, package_root); self.command .status() .with_context(|| ErrorKind::BinaryExecError) } /// Check for possible failure cases with the linked package: /// - The package is not found as a global /// - The package exists, but was linked using a different package manager /// - The package is using a different version of Node than the current project (warning) fn check_linked_package(&self, session: &mut Session) -> Fallible<()> { let config = PackageConfig::from_file(volta_home()?.default_package_config_file(&self.tool)) .with_context(|| ErrorKind::NpmLinkMissingPackage { package: self.tool.clone(), })?; if config.manager != PackageManager::Npm { return Err(ErrorKind::NpmLinkWrongManager { package: self.tool.clone(), } .into()); } if let Some(platform) = session.project_platform()? { if platform.node.major != config.platform.node.major { warn!( "the current project is using {}, but package '{}' was linked using {}. These might not interact correctly.", tool_version("node", &platform.node), self.tool, tool_version("node", &config.platform.node) ); } } Ok(()) } } impl From for Executor { fn from(cmd: PackageLinkCommand) -> Self { Executor::PackageLink(Box::new(cmd)) } } /// Process builder for launching a global package upgrade command (e.g. `npm update -g`) /// /// This will use an `InPlaceUpgrade` instance to modify the command and point at the appropriate /// image directory. It will also complete the install, writing any updated configs and shims pub struct PackageUpgradeCommand { /// The command that will ultimately be executed command: Command, /// Helper utility to modify the command and provide the completion method upgrader: InPlaceUpgrade, /// The platform to run the command under platform: Platform, } impl PackageUpgradeCommand { pub fn new( args: A, package: String, platform: Platform, manager: PackageManager, ) -> Fallible where A: IntoIterator, S: AsRef, { let upgrader = InPlaceUpgrade::new(package, manager)?; let mut command = match manager { PackageManager::Npm => create_command("npm"), PackageManager::Pnpm => create_command("pnpm"), PackageManager::Yarn => create_command("yarn"), }; command.args(args); Ok(PackageUpgradeCommand { command, upgrader, platform, }) } /// Adds or updates environment variables that the command will use pub fn envs(&mut self, envs: E) where E: IntoIterator, K: AsRef, V: AsRef, { self.command.envs(envs); } /// Updates the Platform for the command to include values from the command-line pub fn cli_platform(&mut self, cli: CliPlatform) { self.platform = cli.merge(self.platform.clone()); } /// Runs the upgrade command, applying the necessary modifications to point at the Volta image /// directory /// /// Will also check for common failure cases, such as non-existant package or wrong package /// manager pub fn execute(mut self, session: &mut Session) -> Fallible { self.upgrader.check_upgraded_package()?; let _lock = VoltaLock::acquire(); let image = self.platform.checkout(session)?; let path = image.path()?; self.command.env(RECURSION_ENV_VAR, "1"); self.command.env("PATH", path); self.upgrader.setup_command(&mut self.command); let status = self .command .status() .with_context(|| ErrorKind::BinaryExecError)?; if status.success() { self.upgrader.complete_upgrade(&image)?; } Ok(status) } } impl From for Executor { fn from(cmd: PackageUpgradeCommand) -> Self { Executor::PackageUpgrade(Box::new(cmd)) } } /// Executor for running an internal install (installing Node, npm, pnpm or Yarn using the `volta /// install` logic) /// /// Note: This is not intended to be used for Package installs. Those should go through the /// `PackageInstallCommand` above, to more seamlessly integrate with the package manager pub struct InternalInstallCommand { tool: Spec, } impl InternalInstallCommand { pub fn new(tool: Spec) -> Self { InternalInstallCommand { tool } } /// Runs the install, using Volta's internal install logic for the appropriate tool fn execute(self, session: &mut Session) -> Fallible { info!( "{} using Volta to install {}", note_prefix(), self.tool.name() ); self.tool.resolve(session)?.install(session)?; Ok(ExitStatus::from_raw(0)) } } impl From for Executor { fn from(cmd: InternalInstallCommand) -> Self { Executor::InternalInstall(Box::new(cmd)) } } /// Executor for running a tool uninstall command. /// /// This will use the `volta uninstall` logic to correctly ensure that the package is fully /// uninstalled pub struct UninstallCommand { tool: Spec, } impl UninstallCommand { pub fn new(tool: Spec) -> Self { UninstallCommand { tool } } /// Runs the uninstall with Volta's internal uninstall logic fn execute(self) -> Fallible { info!( "{} using Volta to uninstall {}", note_prefix(), self.tool.name() ); self.tool.uninstall()?; Ok(ExitStatus::from_raw(0)) } } impl From for Executor { fn from(cmd: UninstallCommand) -> Self { Executor::Uninstall(Box::new(cmd)) } } ================================================ FILE: crates/volta-core/src/run/mod.rs ================================================ use std::collections::HashMap; use std::env::{self, ArgsOs}; use std::ffi::{OsStr, OsString}; use std::path::Path; use std::process::ExitStatus; use crate::error::{ErrorKind, Fallible}; use crate::platform::{CliPlatform, Image, Sourced}; use crate::session::Session; use crate::VOLTA_FEATURE_PNPM; use log::debug; use node_semver::Version; pub mod binary; mod executor; mod node; mod npm; mod npx; mod parser; mod pnpm; mod yarn; /// Environment variable set internally when a shim has been executed and the context evaluated /// /// This is set when executing a shim command. If this is already, then the built-in shims (Node, /// npm, npx, pnpm and Yarn) will assume that the context has already been evaluated & the PATH has /// already been modified, so they will use the pass-through behavior. /// /// Shims should only be called recursively when the environment is misconfigured, so this will /// prevent infinite recursion as the pass-through logic removes the shim directory from the PATH. /// /// Note: This is explicitly _removed_ when calling a command through `volta run`, as that will /// never happen due to the Volta environment. const RECURSION_ENV_VAR: &str = "_VOLTA_TOOL_RECURSION"; const VOLTA_BYPASS: &str = "VOLTA_BYPASS"; /// Execute a shim command, based on the command-line arguments to the current process pub fn execute_shim(session: &mut Session) -> Fallible { let mut native_args = env::args_os(); let exe = get_tool_name(&mut native_args)?; let args: Vec<_> = native_args.collect(); get_executor(&exe, &args, session)?.execute(session) } /// Execute a tool with the provided arguments pub fn execute_tool( exe: &OsStr, args: &[OsString], envs: &HashMap, cli: CliPlatform, session: &mut Session, ) -> Fallible where K: AsRef, V: AsRef, { // Remove the recursion environment variable so that the context is correctly re-evaluated // when calling `volta run` (even when called from a Node script) env::remove_var(RECURSION_ENV_VAR); let mut runner = get_executor(exe, args, session)?; runner.cli_platform(cli); runner.envs(envs); runner.execute(session) } /// Get the appropriate Tool command, based on the requested executable and arguments fn get_executor( exe: &OsStr, args: &[OsString], session: &mut Session, ) -> Fallible { if env::var_os(VOLTA_BYPASS).is_some() { Ok(executor::ToolCommand::new( exe, args, None, executor::ToolKind::Bypass(exe.to_string_lossy().to_string()), ) .into()) } else { match exe.to_str() { Some("volta-shim") => Err(ErrorKind::RunShimDirectly.into()), Some("node") => node::command(args, session), Some("npm") => npm::command(args, session), Some("npx") => npx::command(args, session), Some("pnpm") => { // If the pnpm feature flag variable is set, delegate to the pnpm handler // If not, use the binary handler as a fallback (prior to pnpm support, installing // pnpm would be handled the same as any other global binary) if env::var_os(VOLTA_FEATURE_PNPM).is_some() { pnpm::command(args, session) } else { binary::command(exe, args, session) } } Some("yarn") | Some("yarnpkg") => yarn::command(args, session), _ => binary::command(exe, args, session), } } } /// Determine the name of the command to run by inspecting the first argument to the active process fn get_tool_name(args: &mut ArgsOs) -> Fallible { args.next() .and_then(|arg0| Path::new(&arg0).file_name().map(tool_name_from_file_name)) .ok_or_else(|| ErrorKind::CouldNotDetermineTool.into()) } #[cfg(unix)] fn tool_name_from_file_name(file_name: &OsStr) -> OsString { file_name.to_os_string() } #[cfg(windows)] fn tool_name_from_file_name(file_name: &OsStr) -> OsString { // On Windows PowerShell, the file name includes the .exe suffix, // and the Windows file system is case-insensitive // We need to remove that to get the raw tool name match file_name.to_str() { Some(file) => OsString::from(file.to_ascii_lowercase().trim_end_matches(".exe")), None => OsString::from(file_name), } } /// Write a debug message that there is no platform available #[inline] fn debug_no_platform() { debug!("Could not find Volta-managed platform, delegating to system"); } /// Write a debug message with the full image that will be used to execute a command #[inline] fn debug_active_image(image: &Image) { debug!( "Active Image: Node: {} npm: {} pnpm: {} Yarn: {}", format_tool_version(&image.node), image .resolve_npm() .ok() .as_ref() .map(format_tool_version) .unwrap_or_else(|| "Bundled with Node".into()), image .pnpm .as_ref() .map(format_tool_version) .unwrap_or_else(|| "None".into()), image .yarn .as_ref() .map(format_tool_version) .unwrap_or_else(|| "None".into()), ) } fn format_tool_version(version: &Sourced) -> String { format!("{} from {} configuration", version.value, version.source) } ================================================ FILE: crates/volta-core/src/run/node.rs ================================================ use std::env; use std::ffi::OsString; use super::executor::{Executor, ToolCommand, ToolKind}; use super::{debug_active_image, debug_no_platform, RECURSION_ENV_VAR}; use crate::error::{ErrorKind, Fallible}; use crate::platform::{Platform, System}; use crate::session::{ActivityKind, Session}; /// Build a `ToolCommand` for Node pub(super) fn command(args: &[OsString], session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Node); // Don't re-evaluate the platform if this is a recursive call let platform = match env::var_os(RECURSION_ENV_VAR) { Some(_) => None, None => Platform::current(session)?, }; Ok(ToolCommand::new("node", args, platform, ToolKind::Node).into()) } /// Determine the execution context (PATH and failure error message) for Node pub(super) fn execution_context( platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } ================================================ FILE: crates/volta-core/src/run/npm.rs ================================================ use std::env; use std::ffi::OsString; use std::fs::File; use super::executor::{Executor, ToolCommand, ToolKind, UninstallCommand}; use super::parser::{CommandArg, InterceptedCommand}; use super::{debug_active_image, debug_no_platform, RECURSION_ENV_VAR}; use crate::error::{ErrorKind, Fallible}; use crate::platform::{Platform, System}; use crate::session::{ActivityKind, Session}; use crate::tool::{PackageManifest, Spec}; use crate::version::VersionSpec; /// Build an `Executor` for npm /// /// If the command is a global install or uninstall and we have a default platform available, then /// we will use custom logic to ensure that the package is correctly installed / uninstalled in the /// Volta directory. /// /// If the command is _not_ a global install / uninstall or we don't have a default platform, then /// we will allow npm to execute the command as usual. pub(super) fn command(args: &[OsString], session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Npm); // Don't re-evaluate the context or global install interception if this is a recursive call let platform = match env::var_os(RECURSION_ENV_VAR) { Some(_) => None, None => { match CommandArg::for_npm(args) { CommandArg::Global(cmd) => { // For globals, only intercept if the default platform exists if let Some(default_platform) = session.default_platform()? { return cmd.executor(default_platform); } } CommandArg::Intercepted(InterceptedCommand::Link(link)) => { // For link commands, only intercept if a platform exists if let Some(platform) = Platform::current(session)? { return link.executor(platform, current_project_name(session)); } } CommandArg::Intercepted(InterceptedCommand::Unlink) => { // For unlink, attempt to find the current project name. If successful, treat // this as a global uninstall of the current project. if let Some(name) = current_project_name(session) { // Same as for link, only intercept if a platform exists if Platform::current(session)?.is_some() { return Ok(UninstallCommand::new(Spec::Package( name, VersionSpec::None, )) .into()); } } } _ => {} } Platform::current(session)? } }; Ok(ToolCommand::new("npm", args, platform, ToolKind::Npm).into()) } /// Determine the execution context (PATH and failure error message) for npm pub(super) fn execution_context( platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } /// Determine the name of the current project, if possible fn current_project_name(session: &mut Session) -> Option { let project = session.project().ok()??; let manifest_file = File::open(project.manifest_file()).ok()?; let manifest: PackageManifest = serde_json::de::from_reader(manifest_file).ok()?; Some(manifest.name) } ================================================ FILE: crates/volta-core/src/run/npx.rs ================================================ use std::env; use std::ffi::OsString; use super::executor::{Executor, ToolCommand, ToolKind}; use super::{debug_active_image, debug_no_platform, RECURSION_ENV_VAR}; use crate::error::{ErrorKind, Fallible}; use crate::platform::{Platform, System}; use crate::session::{ActivityKind, Session}; use node_semver::Version; use once_cell::sync::Lazy; static REQUIRED_NPM_VERSION: Lazy = Lazy::new(|| Version { major: 5, minor: 2, patch: 0, build: vec![], pre_release: vec![], }); /// Build a `ToolCommand` for npx pub(super) fn command(args: &[OsString], session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Npx); // Don't re-evaluate the context if this is a recursive call let platform = match env::var_os(RECURSION_ENV_VAR) { Some(_) => None, None => Platform::current(session)?, }; Ok(ToolCommand::new("npx", args, platform, ToolKind::Npx).into()) } /// Determine the execution context (PATH and failure error message) for npx pub(super) fn execution_context( platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { let image = plat.checkout(session)?; // If the npm version is lower than the minimum required, we can show a helpful error // message instead of a 'command not found' error. let active_npm = image.resolve_npm()?; if active_npm.value < *REQUIRED_NPM_VERSION { return Err(ErrorKind::NpxNotAvailable { version: active_npm.value.to_string(), } .into()); } let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } ================================================ FILE: crates/volta-core/src/run/parser.rs ================================================ use std::env; use std::ffi::OsStr; use std::iter::once; use super::executor::{ Executor, InternalInstallCommand, PackageInstallCommand, PackageLinkCommand, PackageUpgradeCommand, UninstallCommand, }; use crate::error::{ErrorKind, Fallible}; use crate::inventory::package_configs; use crate::platform::{Platform, PlatformSpec}; use crate::tool::package::PackageManager; use crate::tool::Spec; use log::debug; const UNSAFE_GLOBAL: &str = "VOLTA_UNSAFE_GLOBAL"; /// Aliases that npm supports for the 'install' command const NPM_INSTALL_ALIASES: [&str; 12] = [ "i", "in", "ins", "inst", "insta", "instal", "install", "isnt", "isnta", "isntal", "isntall", "add", ]; /// Aliases that npm supports for the 'uninstall' command const NPM_UNINSTALL_ALIASES: [&str; 5] = ["un", "uninstall", "remove", "rm", "r"]; /// Aliases that npm supports for the 'link' command const NPM_LINK_ALIASES: [&str; 2] = ["link", "ln"]; /// Aliases that npm supports for the `update` command const NPM_UPDATE_ALIASES: [&str; 4] = ["update", "udpate", "upgrade", "up"]; /// Aliases that pnpm supports for the 'remove' command, /// see: https://pnpm.io/cli/remove const PNPM_UNINSTALL_ALIASES: [&str; 4] = ["remove", "uninstall", "rm", "un"]; /// Aliases that pnpm supports for the 'update' command, /// see: https://pnpm.io/cli/update const PNPM_UPDATE_ALIASES: [&str; 3] = ["update", "upgrade", "up"]; /// Aliases that pnpm supports for the 'link' command /// see: https://pnpm.io/cli/link const PNPM_LINK_ALIASES: [&str; 2] = ["link", "ln"]; pub enum CommandArg<'a> { Global(GlobalCommand<'a>), Intercepted(InterceptedCommand<'a>), Standard, } impl<'a> CommandArg<'a> { /// Parse the given set of arguments to see if they correspond to an intercepted npm command pub fn for_npm(args: &'a [S]) -> Self where S: AsRef, { // If VOLTA_UNSAFE_GLOBAL is set, then we always skip any interception parsing if env::var_os(UNSAFE_GLOBAL).is_some() { return CommandArg::Standard; } let mut positionals = args.iter().filter(is_positional).map(AsRef::as_ref); // The first positional argument will always be the command, however npm supports multiple // aliases for commands (see https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js) // Additionally, if we have a global install or uninstall, all of the remaining positional // arguments will be the tools to install or uninstall. If there are _no_ other arguments, // then we treat the command not a global and allow npm to handle any error messages. match positionals.next() { Some(cmd) if NPM_INSTALL_ALIASES.iter().any(|a| a == &cmd) => { if has_global_without_prefix(args) { let tools: Vec<_> = positionals.collect(); if tools.is_empty() { CommandArg::Standard } else { // The common args for an install should be the command combined with any flags let mut common_args = vec![cmd]; common_args.extend(args.iter().filter(is_flag).map(AsRef::as_ref)); CommandArg::Global(GlobalCommand::Install(InstallArgs { manager: PackageManager::Npm, common_args, tools, })) } } else { CommandArg::Standard } } Some(cmd) if NPM_UNINSTALL_ALIASES.iter().any(|a| a == &cmd) => { if has_global_without_prefix(args) { let tools: Vec<_> = positionals.collect(); if tools.is_empty() { CommandArg::Standard } else { CommandArg::Global(GlobalCommand::Uninstall(UninstallArgs { tools })) } } else { CommandArg::Standard } } Some(cmd) if cmd == "unlink" => { let tools: Vec<_> = positionals.collect(); if tools.is_empty() { // `npm unlink` without any arguments is used to unlink the current project CommandArg::Intercepted(InterceptedCommand::Unlink) } else if has_global_without_prefix(args) { // With arguments, `npm unlink` is an alias of `npm remove` CommandArg::Global(GlobalCommand::Uninstall(UninstallArgs { tools })) } else { CommandArg::Standard } } Some(cmd) if NPM_LINK_ALIASES.iter().any(|a| a == &cmd) => { // Much like install, the common args for a link are the command combined with any flags let mut common_args = vec![cmd]; common_args.extend(args.iter().filter(is_flag).map(AsRef::as_ref)); let tools: Vec<_> = positionals.collect(); CommandArg::Intercepted(InterceptedCommand::Link(LinkArgs { common_args, tools })) } Some(cmd) if NPM_UPDATE_ALIASES.iter().any(|a| a == &cmd) => { if has_global_without_prefix(args) { // Once again, the common args are the command combined with any flags let mut common_args = vec![cmd]; common_args.extend(args.iter().filter(is_flag).map(AsRef::as_ref)); let tools: Vec<_> = positionals.collect(); CommandArg::Global(GlobalCommand::Upgrade(UpgradeArgs { common_args, tools, manager: PackageManager::Npm, })) } else { CommandArg::Standard } } _ => CommandArg::Standard, } } /// Parse the given set of arguments to see if they correspond to an intercepted pnpm command #[allow(dead_code)] pub fn for_pnpm(args: &'a [S]) -> CommandArg<'a> where S: AsRef, { // If VOLTA_UNSAFE_GLOBAL is set, then we always skip any global parsing if env::var_os(UNSAFE_GLOBAL).is_some() { return CommandArg::Standard; } let (flags, positionals): (Vec<&OsStr>, Vec<&OsStr>) = args.iter().map(AsRef::::as_ref).partition(is_flag); // The first positional argument will always be the subcommand for pnpm match positionals.split_first() { None => CommandArg::Standard, Some((&subcommand, tools)) => { let is_global = flags.iter().any(|&f| f == "--global" || f == "-g"); // Do not intercept if a custom global dir is explicitly specified // See: https://pnpm.io/npmrc#global-dir let prefixed = flags.iter().any(|&f| f == "--global-dir"); // pnpm subcommands that support the `global` flag: // `add`, `update`, `remove`, `link`, `list`, `outdated`, // `why`, `env`, `root`, `bin`. match is_global && !prefixed { false => CommandArg::Standard, true => match subcommand.to_str() { // `add` Some("add") => { let manager = PackageManager::Pnpm; let mut common_args = vec![subcommand]; common_args.extend(flags); CommandArg::Global(GlobalCommand::Install(InstallArgs { manager, common_args, tools: tools.to_vec(), })) } // `update` Some(cmd) if PNPM_UPDATE_ALIASES.iter().any(|&a| a == cmd) => { let manager = PackageManager::Pnpm; let mut common_args = vec![subcommand]; common_args.extend(flags); CommandArg::Global(GlobalCommand::Upgrade(UpgradeArgs { manager, common_args, tools: tools.to_vec(), })) } // `remove` Some(cmd) if PNPM_UNINSTALL_ALIASES.iter().any(|&a| a == cmd) => { CommandArg::Global(GlobalCommand::Uninstall(UninstallArgs { tools: tools.to_vec(), })) } // `link` Some(cmd) if PNPM_LINK_ALIASES.iter().any(|&a| a == cmd) => { let mut common_args = vec![subcommand]; common_args.extend(flags); CommandArg::Intercepted(InterceptedCommand::Link(LinkArgs { common_args, tools: tools.to_vec(), })) } _ => CommandArg::Standard, }, } } } } /// Parse the given set of arguments to see if they correspond to an intercepted Yarn command pub fn for_yarn(args: &'a [S]) -> Self where S: AsRef, { // If VOLTA_UNSAFE_GLOBAL is set, then we always skip any global parsing if env::var_os(UNSAFE_GLOBAL).is_some() { return CommandArg::Standard; } let mut positionals = args.iter().filter(is_positional).map(AsRef::as_ref); // Yarn globals must always start with `global ` // If we have a global add, remove, or upgrade, then all of the remaining positional // arguments will be the tools to modify. As with npm, if there are no arguments then we // can treat it as if it's not a global command and allow Yarn to show any errors. match (positionals.next(), positionals.next()) { (Some(global), Some(add)) if global == "global" && add == "add" => { let tools: Vec<_> = positionals.collect(); if tools.is_empty() { CommandArg::Standard } else { // The common args for an install should be `global add` and any flags let mut common_args = vec![global, add]; common_args.extend(args.iter().filter(is_flag).map(AsRef::as_ref)); CommandArg::Global(GlobalCommand::Install(InstallArgs { manager: PackageManager::Yarn, common_args, tools, })) } } (Some(global), Some(remove)) if global == "global" && remove == "remove" => { let tools: Vec<_> = positionals.collect(); if tools.is_empty() { CommandArg::Standard } else { CommandArg::Global(GlobalCommand::Uninstall(UninstallArgs { tools })) } } (Some(global), Some(upgrade)) if global == "global" && upgrade == "upgrade" => { // The common args for an upgrade should be `global upgrade` and any flags let mut common_args = vec![global, upgrade]; common_args.extend(args.iter().filter(is_flag).map(AsRef::as_ref)); CommandArg::Global(GlobalCommand::Upgrade(UpgradeArgs { common_args, tools: positionals.collect(), manager: PackageManager::Yarn, })) } _ => CommandArg::Standard, } } } pub enum GlobalCommand<'a> { Install(InstallArgs<'a>), Uninstall(UninstallArgs<'a>), Upgrade(UpgradeArgs<'a>), } impl GlobalCommand<'_> { pub fn executor(self, platform: &PlatformSpec) -> Fallible { match self { GlobalCommand::Install(cmd) => cmd.executor(platform), GlobalCommand::Uninstall(cmd) => cmd.executor(), GlobalCommand::Upgrade(cmd) => cmd.executor(platform), } } } /// The arguments passed to a global install command pub struct InstallArgs<'a> { /// The package manager being used manager: PackageManager, /// Common arguments that apply to each tool (e.g. flags) common_args: Vec<&'a OsStr>, /// The individual tool arguments tools: Vec<&'a OsStr>, } impl InstallArgs<'_> { /// Convert these global install arguments into an executor for the command /// /// If there are multiple packages specified to install, then they will be broken out into /// individual commands and run separately. That allows us to keep Volta's sandboxing for each /// package while still supporting the ability to install multiple packages at once. pub fn executor(self, platform_spec: &PlatformSpec) -> Fallible { let mut executors = Vec::with_capacity(self.tools.len()); for tool in self.tools { // External tool installs may be in a form that doesn't match a `Spec` (such as a git // URL or path to a tarball). If parsing into a `Spec` fails, we assume that it's a // 3rd-party Tool and attempt to install anyway. match Spec::try_from_str(&tool.to_string_lossy()) { Ok(Spec::Package(_, _)) | Err(_) => { let platform = platform_spec.as_default(); // The args for an individual install command are the common args combined // with the name of the tool. let args = self.common_args.iter().chain(once(&tool)); let command = PackageInstallCommand::new(args, platform, self.manager)?; executors.push(command.into()); } Ok(internal) => executors.push(InternalInstallCommand::new(internal).into()), } } Ok(executors.into()) } } /// The list of tools passed to an uninstall command pub struct UninstallArgs<'a> { tools: Vec<&'a OsStr>, } impl UninstallArgs<'_> { /// Convert the tools into an executor for the uninstall command /// /// Since the packages are sandboxed, each needs to be uninstalled separately pub fn executor(self) -> Fallible { let mut executors = Vec::with_capacity(self.tools.len()); for tool_name in self.tools { let tool = Spec::try_from_str(&tool_name.to_string_lossy())?; executors.push(UninstallCommand::new(tool).into()); } Ok(executors.into()) } } /// The list of tools passed to an upgrade command pub struct UpgradeArgs<'a> { /// The package manager being used manager: PackageManager, /// Common arguments that apply to each tool (e.g. flags) common_args: Vec<&'a OsStr>, /// The individual tool arguments tools: Vec<&'a OsStr>, } impl UpgradeArgs<'_> { /// Convert these global upgrade arguments into an executor for the command /// /// If there are multiple packages specified to upgrade, then they will be broken out into /// individual commands and run separately. If no packages are specified, then we will upgrade /// _all_ installed packages that were installed with the same package manager. pub fn executor(self, platform_spec: &PlatformSpec) -> Fallible { if self.tools.is_empty() { return self.executor_all_packages(platform_spec); } let mut executors = Vec::with_capacity(self.tools.len()); for tool in self.tools { match Spec::try_from_str(&tool.to_string_lossy()) { Ok(Spec::Package(package, _)) => { let platform = platform_spec.as_default(); let args = self.common_args.iter().chain(once(&tool)); executors.push( PackageUpgradeCommand::new(args, package, platform, self.manager)?.into(), ); } Ok(internal) => { executors.push(UninstallCommand::new(internal).into()); } Err(_) => { return Err(ErrorKind::UpgradePackageNotFound { package: tool.to_string_lossy().to_string(), manager: self.manager, } .into()) } } } Ok(executors.into()) } /// Build an executor to upgrade _all_ global packages that were installed with the same /// package manager as we are currently running. fn executor_all_packages(self, platform_spec: &PlatformSpec) -> Fallible { package_configs()? .into_iter() .filter(|config| config.manager == self.manager) .map(|config| { let platform = platform_spec.as_default(); let package_name = config.name.as_ref(); let args = self.common_args.iter().chain(once(&package_name)); let executor = PackageUpgradeCommand::new(args, config.name.clone(), platform, self.manager)? .into(); Ok(executor) }) .collect::>>() .map(Into::into) } } /// An intercepted local command pub enum InterceptedCommand<'a> { Link(LinkArgs<'a>), Unlink, } /// The arguments passed to an `npm link` command pub struct LinkArgs<'a> { /// The common arguments that apply to each tool common_args: Vec<&'a OsStr>, /// The list of tools to link (if any) tools: Vec<&'a OsStr>, } impl LinkArgs<'_> { pub fn executor(self, platform: Platform, project_name: Option) -> Fallible { if self.tools.is_empty() { // If no tools are specified, then this is a bare link command, linking the current // project as a global package. We treat this like a global install except we look up // the name from the current directory first. match project_name { Some(name) => PackageInstallCommand::for_npm_link(self.common_args, platform, name), None => PackageInstallCommand::new(self.common_args, platform, PackageManager::Npm), } .map(Into::into) } else { // If there are tools specified, then this represents a command to link a global // package into the current project. We handle each tool separately to support Volta's // package sandboxing. let common_args = self.common_args; Ok(self .tools .into_iter() .map(|tool| { let args = common_args.iter().chain(once(&tool)); PackageLinkCommand::new( args, platform.clone(), tool.to_string_lossy().to_string(), ) .into() }) .collect::>() .into()) } } } /// Check if the provided argument list includes a global flag and _doesn't_ have a prefix setting /// /// For our interception, we only want to intercept global commands. Additionally, if the user /// passes a prefix setting, that will override the logic we use to redirect the install, so our /// process won't work and will cause an error. We should avoid intercepting in those cases since /// a command with an explicit prefix is something beyond the "standard" global install anyway. fn has_global_without_prefix(args: &[A]) -> bool where A: AsRef, { let (has_global, has_prefix) = args.iter().fold((false, false), |(global, prefix), arg| { match arg.as_ref().to_str() { Some("-g") | Some("--global") => (true, prefix), Some(pre) if pre.starts_with("--prefix") => (global, true), _ => (global, prefix), } }); if has_global && has_prefix { debug!("Skipping global interception due to prefix argument"); } has_global && !has_prefix } fn is_flag(arg: &A) -> bool where A: AsRef, { match arg.as_ref().to_str() { Some(a) => a.starts_with('-'), None => false, } } fn is_positional(arg: &A) -> bool where A: AsRef, { !is_flag(arg) } #[cfg(test)] mod tests { use std::ffi::{OsStr, OsString}; fn arg_list(args: A) -> Vec where A: IntoIterator, S: AsRef, { args.into_iter().map(|a| a.as_ref().to_owned()).collect() } mod npm { use super::super::*; use super::arg_list; #[test] fn handles_global_install() { match CommandArg::for_npm(&arg_list(["install", "--global", "typescript@3"])) { CommandArg::Global(GlobalCommand::Install(install)) => { assert_eq!(install.manager, PackageManager::Npm); assert_eq!(install.common_args, vec!["install", "--global"]); assert_eq!(install.tools, vec!["typescript@3"]); } _ => panic!("Doesn't parse global install as a global"), }; } #[test] fn handles_local_install() { match CommandArg::for_npm(&arg_list(["install", "--save-dev", "typescript"])) { CommandArg::Standard => (), _ => panic!("Parses local install as global"), }; } #[test] fn handles_global_uninstall() { match CommandArg::for_npm(&arg_list(["uninstall", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["typescript"]); } _ => panic!("Doesn't parse global uninstall as a global"), }; } #[test] fn handles_local_uninstall() { match CommandArg::for_npm(&arg_list(["uninstall", "--save-dev", "typescript"])) { CommandArg::Standard => (), _ => panic!("Parses local uninstall as global"), }; } #[test] fn handles_multiple_install() { match CommandArg::for_npm(&arg_list([ "install", "--global", "typescript@3", "cowsay@1", "ember-cli@2", ])) { CommandArg::Global(GlobalCommand::Install(install)) => { assert_eq!(install.manager, PackageManager::Npm); assert_eq!(install.common_args, vec!["install", "--global"]); assert_eq!( install.tools, vec!["typescript@3", "cowsay@1", "ember-cli@2"] ); } _ => panic!("Doesn't parse global install as a global"), }; } #[test] fn handles_multiple_uninstall() { match CommandArg::for_npm(&arg_list([ "uninstall", "--global", "typescript", "cowsay", "ember-cli", ])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["typescript", "cowsay", "ember-cli"]); } _ => panic!("Doesn't parse global uninstall as a global"), }; } #[test] fn handles_bare_link() { match CommandArg::for_npm(&arg_list(["link"])) { CommandArg::Intercepted(InterceptedCommand::Link(_)) => (), _ => panic!("Doesn't parse bare link command ('npm link' with no packages"), }; } #[test] fn handles_multiple_link() { match CommandArg::for_npm(&arg_list(["link", "typescript", "react"])) { CommandArg::Intercepted(InterceptedCommand::Link(link)) => { assert_eq!(link.tools, vec!["typescript", "react"]); } _ => panic!("Doesn't parse link command with packages"), }; } #[test] fn handles_bare_unlink() { match CommandArg::for_npm(&arg_list(["unlink"])) { CommandArg::Intercepted(InterceptedCommand::Unlink) => (), _ => panic!("Doesn't parse bare unlink command ('npm unlink' with no packages"), }; } #[test] fn handles_local_unlink() { match CommandArg::for_npm(&arg_list(["unlink", "@angular/cli"])) { CommandArg::Standard => (), _ => panic!("Doesn't pass through local 'unlink' command"), } } #[test] fn handles_global_aliases() { match CommandArg::for_npm(&arg_list(["install", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse long form (--global)"), }; match CommandArg::for_npm(&arg_list(["install", "-g", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (-g)"), }; } #[test] fn handles_install_aliases() { match CommandArg::for_npm(&arg_list(["i", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (i)"), }; match CommandArg::for_npm(&arg_list(["in", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (in)"), }; match CommandArg::for_npm(&arg_list(["ins", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (ins)"), }; match CommandArg::for_npm(&arg_list(["inst", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (inst)"), }; match CommandArg::for_npm(&arg_list(["insta", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (insta)"), }; match CommandArg::for_npm(&arg_list(["instal", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form (instal)"), }; match CommandArg::for_npm(&arg_list(["install", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse exact command (install)"), }; match CommandArg::for_npm(&arg_list(["isnt", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form misspelling (isnt)"), }; match CommandArg::for_npm(&arg_list(["isnta", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form misspelling (isnta)"), }; match CommandArg::for_npm(&arg_list(["isntal", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse short form misspelling (isntal)"), }; match CommandArg::for_npm(&arg_list(["isntall", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse misspelling (isntall)"), }; match CommandArg::for_npm(&arg_list(["add", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Install(_)) => (), _ => panic!("Doesn't parse 'add' alias"), }; } #[test] fn handles_uninstall_aliases() { match CommandArg::for_npm(&arg_list(["uninstall", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse long form (uninstall)"), }; match CommandArg::for_npm(&arg_list(["unlink", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse 'unlink'"), }; match CommandArg::for_npm(&arg_list(["remove", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse 'remove'"), }; match CommandArg::for_npm(&arg_list(["un", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse short form (un)"), }; match CommandArg::for_npm(&arg_list(["rm", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse short form (rm)"), }; match CommandArg::for_npm(&arg_list(["r", "--global", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(_)) => (), _ => panic!("Doesn't parse short form (r)"), }; } #[test] fn handles_link_aliases() { match CommandArg::for_npm(&arg_list(["link"])) { CommandArg::Intercepted(InterceptedCommand::Link(_)) => (), _ => panic!("Doesn't parse long form (link)"), }; match CommandArg::for_npm(&arg_list(["ln"])) { CommandArg::Intercepted(InterceptedCommand::Link(_)) => (), _ => panic!("Doesn't parse short form (ln)"), }; } #[test] fn processes_flags() { match CommandArg::for_npm(&arg_list([ "--global", "install", "typescript", "--no-audit", "cowsay", "--no-update-notifier", ])) { CommandArg::Global(GlobalCommand::Install(install)) => { // The command gets moved to the front of common_args assert_eq!( install.common_args, vec!["install", "--global", "--no-audit", "--no-update-notifier"] ); assert_eq!(install.tools, vec!["typescript", "cowsay"]); } _ => panic!("Doesn't parse install with extra flags as a global"), }; match CommandArg::for_npm(&arg_list([ "uninstall", "--silent", "typescript", "-g", "cowsay", ])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["typescript", "cowsay"]); } _ => panic!("Doesn't parse uninstall with extra flags as a global"), } } #[test] fn skips_commands_with_prefix() { match CommandArg::for_npm(&arg_list(["install", "-g", "--prefix", "~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["install", "-g", "--prefix=~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["uninstall", "-g", "--prefix", "~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["uninstall", "-g", "--prefix=~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["unlink", "-g", "--prefix", "~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["unlink", "-g", "--prefix=~/", "ember"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["update", "-g", "--prefix", "~/"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } match CommandArg::for_npm(&arg_list(["update", "-g", "--prefix=~/"])) { CommandArg::Standard => {} _ => panic!("Parsed command with prefix as a global"), } } } mod yarn { use super::super::*; use super::*; #[test] fn handles_global_add() { match CommandArg::for_yarn(&arg_list(["global", "add", "typescript"])) { CommandArg::Global(GlobalCommand::Install(install)) => { assert_eq!(install.manager, PackageManager::Yarn); assert_eq!(install.common_args, vec!["global", "add"]); assert_eq!(install.tools, vec!["typescript"]); } _ => panic!("Doesn't parse global add as a global"), }; } #[test] fn handles_local_add() { match CommandArg::for_yarn(&arg_list(["add", "typescript"])) { CommandArg::Standard => (), _ => panic!("Parses local add as a global"), }; match CommandArg::for_yarn(&arg_list(["add", "global"])) { CommandArg::Standard => (), _ => panic!("Incorrectly handles bad order"), }; } #[test] fn handles_global_remove() { match CommandArg::for_yarn(&arg_list(["global", "remove", "typescript"])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["typescript"]); } _ => panic!("Doesn't parse global remove as a global"), }; } #[test] fn handles_local_remove() { match CommandArg::for_yarn(&arg_list(["remove", "typescript"])) { CommandArg::Standard => (), _ => panic!("Parses local remove as a global"), }; match CommandArg::for_yarn(&arg_list(["remove", "global"])) { CommandArg::Standard => (), _ => panic!("Incorrectly handles bad order"), }; } #[test] fn handles_multiple_add() { match CommandArg::for_yarn(&arg_list([ "global", "add", "typescript", "cowsay", "ember-cli", ])) { CommandArg::Global(GlobalCommand::Install(install)) => { assert_eq!(install.manager, PackageManager::Yarn); assert_eq!(install.common_args, vec!["global", "add"]); assert_eq!(install.tools, vec!["typescript", "cowsay", "ember-cli"]); } _ => panic!("Doesn't parse global add as a global"), }; } #[test] fn handles_multiple_remove() { match CommandArg::for_yarn(&arg_list([ "global", "remove", "typescript", "cowsay", "ember-cli", ])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["typescript", "cowsay", "ember-cli"]); } _ => panic!("Doesn't parse global remove as a global"), }; } #[test] fn processes_flags() { match CommandArg::for_yarn(&arg_list([ "global", "--silent", "add", "ember-cli", "--prefix=~/", "typescript", ])) { CommandArg::Global(GlobalCommand::Install(install)) => { // The commands get moved to the front of common_args assert_eq!( install.common_args, vec!["global", "add", "--silent", "--prefix=~/"] ); assert_eq!(install.tools, vec!["ember-cli", "typescript"]); } _ => panic!("Doesn't parse global add as a global"), }; match CommandArg::for_yarn(&arg_list([ "global", "--silent", "remove", "ember-cli", "--prefix=~/", "typescript", ])) { CommandArg::Global(GlobalCommand::Uninstall(uninstall)) => { assert_eq!(uninstall.tools, vec!["ember-cli", "typescript"]); } _ => panic!("Doesn't parse global add as a global"), }; } } } ================================================ FILE: crates/volta-core/src/run/pnpm.rs ================================================ use std::env; use std::ffi::OsString; use super::executor::{Executor, ToolCommand, ToolKind}; use super::{debug_active_image, debug_no_platform, RECURSION_ENV_VAR}; use crate::error::{ErrorKind, Fallible}; use crate::platform::{Platform, Source, System}; use crate::session::{ActivityKind, Session}; pub(super) fn command(args: &[OsString], session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Pnpm); // Don't re-evaluate the context or global install interception if this is a recursive call let platform = match env::var_os(RECURSION_ENV_VAR) { Some(_) => None, None => { // FIXME: Figure out how to intercept pnpm global commands properly. // This guard prevents all global commands from running, it should // be removed when we fully implement global command interception. let is_global = args.iter().any(|f| f == "--global" || f == "-g"); if is_global { return Err(ErrorKind::Unimplemented { feature: "pnpm global commands".into(), } .into()); } Platform::current(session)? } }; Ok(ToolCommand::new("pnpm", args, platform, ToolKind::Pnpm).into()) } /// Determine the execution context (PATH and failure error message) for pnpm pub(super) fn execution_context( platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { validate_platform_pnpm(&plat)?; let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } fn validate_platform_pnpm(platform: &Platform) -> Fallible<()> { match &platform.pnpm { Some(_) => Ok(()), None => match platform.node.source { Source::Project => Err(ErrorKind::NoProjectPnpm.into()), Source::Default | Source::Binary => Err(ErrorKind::NoDefaultPnpm.into()), Source::CommandLine => Err(ErrorKind::NoCommandLinePnpm.into()), }, } } ================================================ FILE: crates/volta-core/src/run/yarn.rs ================================================ use std::env; use std::ffi::OsString; use super::executor::{Executor, ToolCommand, ToolKind}; use super::parser::CommandArg; use super::{debug_active_image, debug_no_platform, RECURSION_ENV_VAR}; use crate::error::{ErrorKind, Fallible}; use crate::platform::{Platform, Source, System}; use crate::session::{ActivityKind, Session}; /// Build an `Executor` for Yarn /// /// If the command is a global add or remove and we have a default platform available, then we will /// use custom logic to ensure that the package is correctly installed / uninstalled in the Volta /// directory. /// /// If the command is _not_ a global add / remove or we don't have a default platform, then /// we will allow Yarn to execute the command as usual. pub(super) fn command(args: &[OsString], session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Yarn); // Don't re-evaluate the context or global install interception if this is a recursive call let platform = match env::var_os(RECURSION_ENV_VAR) { Some(_) => None, None => { if let CommandArg::Global(cmd) = CommandArg::for_yarn(args) { // For globals, only intercept if the default platform exists if let Some(default_platform) = session.default_platform()? { return cmd.executor(default_platform); } } Platform::current(session)? } }; Ok(ToolCommand::new("yarn", args, platform, ToolKind::Yarn).into()) } /// Determine the execution context (PATH and failure error message) for Yarn pub(super) fn execution_context( platform: Option, session: &mut Session, ) -> Fallible<(OsString, ErrorKind)> { match platform { Some(plat) => { validate_platform_yarn(&plat)?; let image = plat.checkout(session)?; let path = image.path()?; debug_active_image(&image); Ok((path, ErrorKind::BinaryExecError)) } None => { let path = System::path()?; debug_no_platform(); Ok((path, ErrorKind::NoPlatform)) } } } fn validate_platform_yarn(platform: &Platform) -> Fallible<()> { match &platform.yarn { Some(_) => Ok(()), None => match platform.node.source { Source::Project => Err(ErrorKind::NoProjectYarn.into()), Source::Default | Source::Binary => Err(ErrorKind::NoDefaultYarn.into()), Source::CommandLine => Err(ErrorKind::NoCommandLineYarn.into()), }, } } ================================================ FILE: crates/volta-core/src/session.rs ================================================ //! Provides the `Session` type, which represents the user's state during an //! execution of a Volta tool, including their current directory, Volta //! hook configuration, and the state of the local inventory. use std::fmt::{self, Display, Formatter}; use std::process::exit; use crate::error::{ExitCode, Fallible, VoltaError}; use crate::event::EventLog; use crate::hook::{HookConfig, LazyHookConfig}; use crate::platform::PlatformSpec; use crate::project::{LazyProject, Project}; use crate::toolchain::{LazyToolchain, Toolchain}; use log::debug; #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy)] pub enum ActivityKind { Fetch, Install, Uninstall, List, Current, Default, Pin, Node, Npm, Npx, Pnpm, Yarn, Volta, Tool, Help, Version, Binary, Shim, Completions, Which, Setup, Run, Args, } impl Display for ActivityKind { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> { let s = match self { ActivityKind::Fetch => "fetch", ActivityKind::Install => "install", ActivityKind::Uninstall => "uninstall", ActivityKind::List => "list", ActivityKind::Current => "current", ActivityKind::Default => "default", ActivityKind::Pin => "pin", ActivityKind::Node => "node", ActivityKind::Npm => "npm", ActivityKind::Npx => "npx", ActivityKind::Pnpm => "pnpm", ActivityKind::Yarn => "yarn", ActivityKind::Volta => "volta", ActivityKind::Tool => "tool", ActivityKind::Help => "help", ActivityKind::Version => "version", ActivityKind::Binary => "binary", ActivityKind::Setup => "setup", ActivityKind::Shim => "shim", ActivityKind::Completions => "completions", ActivityKind::Which => "which", ActivityKind::Run => "run", ActivityKind::Args => "args", }; f.write_str(s) } } /// Represents the user's state during an execution of a Volta tool. The session /// encapsulates a number of aspects of the environment in which the tool was /// invoked, including: /// /// - the current directory /// - the Node project tree that contains the current directory (if any) /// - the Volta hook configuration /// - the inventory of locally-fetched Volta tools pub struct Session { hooks: LazyHookConfig, toolchain: LazyToolchain, project: LazyProject, event_log: EventLog, } impl Session { /// Constructs a new `Session`. pub fn init() -> Session { Session { hooks: LazyHookConfig::init(), toolchain: LazyToolchain::init(), project: LazyProject::init(), event_log: EventLog::init(), } } /// Produces a reference to the current Node project, if any. pub fn project(&self) -> Fallible> { self.project.get() } /// Produces a mutable reference to the current Node project, if any. pub fn project_mut(&mut self) -> Fallible> { self.project.get_mut() } /// Returns the user's default platform, if any pub fn default_platform(&self) -> Fallible> { self.toolchain.get().map(Toolchain::platform) } /// Returns the current project's pinned platform image, if any. pub fn project_platform(&self) -> Fallible> { if let Some(project) = self.project()? { return Ok(project.platform()); } Ok(None) } /// Produces a reference to the current toolchain (default platform specification) pub fn toolchain(&self) -> Fallible<&Toolchain> { self.toolchain.get() } /// Produces a mutable reference to the current toolchain pub fn toolchain_mut(&mut self) -> Fallible<&mut Toolchain> { self.toolchain.get_mut() } /// Produces a reference to the hook configuration pub fn hooks(&self) -> Fallible<&HookConfig> { self.hooks.get(self.project()?) } pub fn add_event_start(&mut self, activity_kind: ActivityKind) { self.event_log.add_event_start(activity_kind) } pub fn add_event_end(&mut self, activity_kind: ActivityKind, exit_code: ExitCode) { self.event_log.add_event_end(activity_kind, exit_code) } pub fn add_event_tool_end(&mut self, activity_kind: ActivityKind, exit_code: i32) { self.event_log.add_event_tool_end(activity_kind, exit_code) } pub fn add_event_error(&mut self, activity_kind: ActivityKind, error: &VoltaError) { self.event_log.add_event_error(activity_kind, error) } fn publish_to_event_log(self) { let Self { project, hooks, mut event_log, .. } = self; let plugin_res = project .get() .and_then(|p| hooks.get(p)) .map(|hooks| hooks.events().and_then(|e| e.publish.as_ref())); match plugin_res { Ok(plugin) => { event_log.add_event_args(); event_log.publish(plugin); } Err(e) => { debug!("Unable to publish event log.\n{}", e); } } } pub fn exit(self, code: ExitCode) -> ! { self.publish_to_event_log(); code.exit(); } pub fn exit_tool(self, code: i32) -> ! { self.publish_to_event_log(); exit(code); } } #[cfg(test)] pub mod tests { use crate::session::Session; use std::env; use std::path::PathBuf; fn fixture_path(fixture_dir: &str) -> PathBuf { let mut cargo_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); cargo_manifest_dir.push("fixtures"); cargo_manifest_dir.push(fixture_dir); cargo_manifest_dir } #[test] fn test_in_pinned_project() { let project_pinned = fixture_path("basic"); env::set_current_dir(project_pinned).expect("Could not set current directory"); let pinned_session = Session::init(); let pinned_platform = pinned_session .project_platform() .expect("Couldn't create Project"); assert!(pinned_platform.is_some()); let project_unpinned = fixture_path("no_toolchain"); env::set_current_dir(project_unpinned).expect("Could not set current directory"); let unpinned_session = Session::init(); let unpinned_platform = unpinned_session .project_platform() .expect("Couldn't create Project"); assert!(unpinned_platform.is_none()); } } ================================================ FILE: crates/volta-core/src/shim.rs ================================================ //! Provides utilities for modifying shims for 3rd-party executables use std::collections::HashSet; use std::fs; use std::io; use std::path::Path; use crate::error::{Context, ErrorKind, Fallible, VoltaError}; use crate::fs::read_dir_eager; use crate::layout::volta_home; use crate::sync::VoltaLock; use log::debug; pub use platform::create; pub fn regenerate_shims_for_dir(dir: &Path) -> Fallible<()> { // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); debug!("Rebuilding shims for directory: {}", dir.display()); for shim_name in get_shim_list_deduped(dir)?.iter() { delete(shim_name)?; create(shim_name)?; } Ok(()) } fn get_shim_list_deduped(dir: &Path) -> Fallible> { let contents = read_dir_eager(dir).with_context(|| ErrorKind::ReadDirError { dir: dir.to_owned(), })?; #[cfg(unix)] { let mut shims: HashSet = contents.filter_map(platform::entry_to_shim_name).collect(); shims.insert("node".into()); shims.insert("npm".into()); shims.insert("npx".into()); shims.insert("pnpm".into()); shims.insert("yarn".into()); shims.insert("yarnpkg".into()); Ok(shims) } #[cfg(windows)] { // On Windows, the default shims are installed in Program Files, so we don't need to generate them here Ok(contents.filter_map(platform::entry_to_shim_name).collect()) } } #[derive(PartialEq, Eq)] pub enum ShimResult { Created, AlreadyExists, Deleted, DoesntExist, } pub fn delete(shim_name: &str) -> Fallible { let shim = volta_home()?.shim_file(shim_name); #[cfg(windows)] platform::delete_git_bash_script(shim_name)?; match fs::remove_file(shim) { Ok(_) => Ok(ShimResult::Deleted), Err(err) => { if err.kind() == io::ErrorKind::NotFound { Ok(ShimResult::DoesntExist) } else { Err(VoltaError::from_source( err, ErrorKind::ShimRemoveError { name: shim_name.to_string(), }, )) } } } } #[cfg(unix)] mod platform { //! Unix-specific shim utilities //! //! On macOS and Linux, creating a shim involves creating a symlink to the `volta-shim` //! executable. Additionally, filtering the shims from directory entries means looking //! for symlinks and ignoring the actual binaries use std::ffi::OsStr; use std::fs::{DirEntry, Metadata}; use std::io; use super::ShimResult; use crate::error::{ErrorKind, Fallible, VoltaError}; use crate::fs::symlink_file; use crate::layout::{volta_home, volta_install}; pub fn create(shim_name: &str) -> Fallible { let executable = volta_install()?.shim_executable(); let shim = volta_home()?.shim_file(shim_name); match symlink_file(executable, shim) { Ok(_) => Ok(ShimResult::Created), Err(err) => { if err.kind() == io::ErrorKind::AlreadyExists { Ok(ShimResult::AlreadyExists) } else { Err(VoltaError::from_source( err, ErrorKind::ShimCreateError { name: shim_name.to_string(), }, )) } } } } pub fn entry_to_shim_name((entry, metadata): (DirEntry, Metadata)) -> Option { if metadata.file_type().is_symlink() { entry .path() .file_stem() .and_then(OsStr::to_str) .map(ToOwned::to_owned) } else { None } } } #[cfg(windows)] mod platform { //! Windows-specific shim utilities //! //! On Windows, creating a shim involves creating a small .cmd script, rather than a symlink. //! This allows us to create shims without requiring administrator privileges or developer //! mode. Also, to support Git Bash, we create a similar script with bash syntax that doesn't //! have a file extension. This allows Powershell and Cmd to ignore it, while Bash detects it //! as an executable script. //! //! Finally, filtering directory entries to find the shim files involves looking for the .cmd //! files. use std::ffi::OsStr; use std::fs::{write, DirEntry, Metadata}; use super::ShimResult; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::remove_file_if_exists; use crate::layout::volta_home; const SHIM_SCRIPT_CONTENTS: &str = r#"@echo off volta run %~n0 %* "#; const GIT_BASH_SCRIPT_CONTENTS: &str = r#"#!/bin/bash volta run "$(basename $0)" "$@""#; pub fn create(shim_name: &str) -> Fallible { let shim = volta_home()?.shim_file(shim_name); write(shim, SHIM_SCRIPT_CONTENTS).with_context(|| ErrorKind::ShimCreateError { name: shim_name.to_owned(), })?; let git_bash_script = volta_home()?.shim_git_bash_script_file(shim_name); write(git_bash_script, GIT_BASH_SCRIPT_CONTENTS).with_context(|| { ErrorKind::ShimCreateError { name: shim_name.to_owned(), } })?; Ok(ShimResult::Created) } pub fn entry_to_shim_name((entry, _): (DirEntry, Metadata)) -> Option { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "cmd") { path.file_stem() .and_then(OsStr::to_str) .map(ToOwned::to_owned) } else { None } } pub fn delete_git_bash_script(shim_name: &str) -> Fallible<()> { let script_path = volta_home()?.shim_git_bash_script_file(shim_name); remove_file_if_exists(script_path).with_context(|| ErrorKind::ShimRemoveError { name: shim_name.to_string(), }) } } ================================================ FILE: crates/volta-core/src/signal.rs ================================================ use std::process::exit; use std::sync::atomic::{AtomicBool, Ordering}; use log::debug; static SHIM_HAS_CONTROL: AtomicBool = AtomicBool::new(false); const INTERRUPTED_EXIT_CODE: i32 = 130; pub fn pass_control_to_shim() { SHIM_HAS_CONTROL.store(true, Ordering::SeqCst); } pub fn setup_signal_handler() { let result = ctrlc::set_handler(|| { if !SHIM_HAS_CONTROL.load(Ordering::SeqCst) { exit(INTERRUPTED_EXIT_CODE); } }); if result.is_err() { debug!("Unable to set Ctrl+C handler, SIGINT will not be handled correctly"); } } ================================================ FILE: crates/volta-core/src/style.rs ================================================ //! The view layer of Volta, with utilities for styling command-line output. use std::borrow::Cow; use std::error::Error; use std::time::Duration; use archive::Origin; use cfg_if::cfg_if; use console::{style, StyledObject}; use indicatif::{ProgressBar, ProgressStyle}; use terminal_size::{terminal_size, Width}; pub const MAX_WIDTH: usize = 100; const MAX_PROGRESS_WIDTH: usize = 40; /// Generate the styled prefix for a success message pub fn success_prefix() -> StyledObject<&'static str> { style("success:").green().bold() } /// Generate the styled prefix for a note pub fn note_prefix() -> StyledObject<&'static str> { style(" note:").magenta().bold() } /// Format the underlying cause of an error pub(crate) fn format_error_cause(inner: &dyn Error) -> String { format!( "{}{} {}", style("Error cause").underlined().bold(), style(":").bold(), inner ) } /// Determines the string to display based on the Origin of the operation. fn action_str(origin: Origin) -> &'static str { match origin { Origin::Local => "Unpacking", Origin::Remote => "Fetching", } } pub fn tool_version(name: N, version: V) -> String where N: std::fmt::Display + Sized, V: std::fmt::Display + Sized, { format!("{:}@{:}", name, version) } /// Get the width of the terminal, limited to a maximum of MAX_WIDTH pub fn text_width() -> Option { terminal_size().map(|(Width(w), _)| (w as usize).min(MAX_WIDTH)) } /// Constructs a command-line progress bar based on the specified Origin enum /// (e.g., `Origin::Remote`), details string (e.g., `"v1.23.4"`), and logical /// length (i.e., the number of logical progress steps in the process being /// visualized by the progress bar). pub fn progress_bar(origin: Origin, details: &str, len: u64) -> ProgressBar { let action = action_str(origin); let action_width = action.len() + 2; // plus 2 spaces to look nice let msg_width = action_width + 1 + details.len(); // Fetching node@9.11.2 [=============> ] 34% // |--------| |---------| |--------------------------------------| |-| // action details bar percentage let bar_width = match text_width() { Some(width) => MAX_PROGRESS_WIDTH.min(width - 2 - msg_width - 2 - 2 - 1 - 3 - 1), None => MAX_PROGRESS_WIDTH, }; let progress = ProgressBar::new(len); progress.set_message(format!( "{: >width$} {}", style(action).green().bold(), details, width = action_width, )); progress.set_style( ProgressStyle::default_bar() .template(&format!( "{{msg}} [{{bar:{}.cyan/blue}}] {{percent:>3}}%", bar_width )) .expect("template is valid") .progress_chars("=> "), ); progress } cfg_if! { if #[cfg(windows)] { /// Constructs a command-line progress spinner with the specified "message" /// string. The spinner is ticked by default every 100ms. pub fn progress_spinner(message: S) -> ProgressBar where S: Into>, { let spinner = ProgressBar::new_spinner(); // Windows CMD prompt doesn't support Unicode characters, so use a simplified spinner let style = ProgressStyle::default_spinner().tick_chars(r#"-\|/-"#); spinner.set_message(message); spinner.set_style(style); spinner.enable_steady_tick(Duration::from_millis(100)); spinner } } else { /// Constructs a command-line progress spinner with the specified "message" /// string. The spinner is ticked by default every 50ms. pub fn progress_spinner(message: S) -> ProgressBar where S: Into>, { // ⠋ Fetching public registry: https://nodejs.org/dist/index.json let spinner = ProgressBar::new_spinner(); spinner.set_message(message); spinner.set_style(ProgressStyle::default_spinner()); spinner.enable_steady_tick(Duration::from_millis(50)); spinner } } } ================================================ FILE: crates/volta-core/src/sync.rs ================================================ //! Inter-process locking on the Volta directory //! //! To avoid issues where multiple separate invocations of Volta modify the //! data directory simultaneously, we provide a locking mechanism that only //! allows a single process to modify the directory at a time. //! //! However, within a single process, we may attempt to lock the directory in //! different code paths. For example, when installing a package we require a //! lock, however we also may need to install Node, which requires a lock as //! well. To avoid deadlocks in those situations, we track the state of the //! lock globally: //! //! - If a lock is requested and no locks are active, then we acquire a file //! lock on the `volta.lock` file and initialize the state with a count of 1 //! - If a lock already exists, then we increment the count of active locks //! - When a lock is no longer needed, we decrement the count of active locks //! - When the last lock is released, we release the file lock and clear the //! global lock state. //! //! This allows multiple code paths to request a lock and not worry about //! potential deadlocks, while still preventing multiple processes from making //! concurrent changes. use std::fs::{File, OpenOptions}; use std::marker::PhantomData; use std::ops::Drop; use std::sync::Mutex; use crate::error::{Context, ErrorKind, Fallible}; use crate::layout::volta_home; use crate::style::progress_spinner; use fs2::FileExt; use log::debug; use once_cell::sync::Lazy; static LOCK_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); /// The current state of locks for this process. /// /// Note: To ensure thread safety _within_ this process, we enclose the /// state in a Mutex. This Mutex and it's associated locks are separate /// from the overall process lock and are only used to ensure the count /// is accurately maintained within a given process. struct LockState { file: File, count: usize, } const LOCK_FILE: &str = "volta.lock"; /// An RAII implementation of a process lock on the Volta directory. A given Volta process can have /// multiple active locks, but only one process can have any locks at a time. /// /// Once all of the `VoltaLock` objects go out of scope, the lock will be released to other /// processes. pub struct VoltaLock { // Private field ensures that this cannot be created except for with the `acquire()` method _private: PhantomData<()>, } impl VoltaLock { pub fn acquire() -> Fallible { let mut state = LOCK_STATE .lock() .with_context(|| ErrorKind::LockAcquireError)?; // Check if there is an active lock for this process. If so, increment // the count of active locks. If not, create a file lock and initialize // the state with a count of 1 match &mut *state { Some(inner) => { inner.count += 1; } None => { let path = volta_home()?.root().join(LOCK_FILE); debug!("Acquiring lock on Volta directory: {}", path.display()); let file = OpenOptions::new() .write(true) .create(true) .open(path) .with_context(|| ErrorKind::LockAcquireError)?; // First we try to lock the file without blocking. If that fails, then we show a spinner // and block until the lock completes. if file.try_lock_exclusive().is_err() { let spinner = progress_spinner("Waiting for file lock on Volta directory"); // Note: Blocks until the file can be locked let lock_result = file .lock_exclusive() .with_context(|| ErrorKind::LockAcquireError); spinner.finish_and_clear(); lock_result?; } *state = Some(LockState { file, count: 1 }); } } Ok(Self { _private: PhantomData, }) } } impl Drop for VoltaLock { fn drop(&mut self) { // On drop, decrement the count of active locks. If the count is 1, // then this is the last active lock, so instead unlock the file and // clear out the lock state. if let Ok(mut state) = LOCK_STATE.lock() { match &mut *state { Some(inner) => { if inner.count == 1 { debug!("Unlocking Volta Directory"); let _ = inner.file.unlock(); *state = None; } else { inner.count -= 1; } } None => { debug!("Unexpected unlock of Volta directory when it wasn't locked"); } } } } } ================================================ FILE: crates/volta-core/src/tool/mod.rs ================================================ use std::env; use std::fmt::{self, Display}; use std::path::PathBuf; use crate::error::{ErrorKind, Fallible}; use crate::layout::volta_home; use crate::session::Session; use crate::style::{note_prefix, success_prefix, tool_version}; use crate::sync::VoltaLock; use crate::version::VersionSpec; use crate::VOLTA_FEATURE_PNPM; use cfg_if::cfg_if; use log::{debug, info}; pub mod node; pub mod npm; pub mod package; pub mod pnpm; mod registry; mod serial; pub mod yarn; pub use node::{ load_default_npm_version, Node, NODE_DISTRO_ARCH, NODE_DISTRO_EXTENSION, NODE_DISTRO_OS, }; pub use npm::{BundledNpm, Npm}; pub use package::{BinConfig, Package, PackageConfig, PackageManifest}; pub use pnpm::Pnpm; pub use registry::PackageDetails; pub use yarn::Yarn; fn debug_already_fetched(tool: T) { debug!("{} has already been fetched, skipping download", tool); } fn info_installed(tool: T) { info!("{} installed and set {tool} as default", success_prefix()); } fn info_fetched(tool: T) { info!("{} fetched {tool}", success_prefix()); } fn info_pinned(tool: T) { info!("{} pinned {tool} in package.json", success_prefix()); } fn info_project_version(project_version: P, default_version: D) where P: Display, D: Display, { info!( r#"{} you are using {project_version} in the current project; to instead use {default_version}, run `volta pin {default_version}`"#, note_prefix() ); } /// Trait representing all of the actions that can be taken with a tool pub trait Tool: Display { /// Fetch a Tool into the local inventory fn fetch(self: Box, session: &mut Session) -> Fallible<()>; /// Install a tool, making it the default so it is available everywhere on the user's machine fn install(self: Box, session: &mut Session) -> Fallible<()>; /// Pin a tool in the local project so that it is usable within the project fn pin(self: Box, session: &mut Session) -> Fallible<()>; } /// Specification for a tool and its associated version. #[derive(Debug)] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum Spec { Node(VersionSpec), Npm(VersionSpec), Pnpm(VersionSpec), Yarn(VersionSpec), Package(String, VersionSpec), } impl Spec { /// Resolve a tool spec into a fully realized Tool that can be fetched pub fn resolve(self, session: &mut Session) -> Fallible> { match self { Spec::Node(version) => { let version = node::resolve(version, session)?; Ok(Box::new(Node::new(version))) } Spec::Npm(version) => match npm::resolve(version, session)? { Some(version) => Ok(Box::new(Npm::new(version))), None => Ok(Box::new(BundledNpm)), }, Spec::Pnpm(version) => { // If the pnpm feature flag is set, use the special-cased package manager logic // to handle resolving (and ultimately fetching / installing) pnpm. If not, then // fall back to the global package behavior, which was the case prior to pnpm // support being added if env::var_os(VOLTA_FEATURE_PNPM).is_some() { let version = pnpm::resolve(version, session)?; Ok(Box::new(Pnpm::new(version))) } else { let package = Package::new("pnpm".to_owned(), version)?; Ok(Box::new(package)) } } Spec::Yarn(version) => { let version = yarn::resolve(version, session)?; Ok(Box::new(Yarn::new(version))) } // When using global package install, we allow the package manager to perform the version resolution Spec::Package(name, version) => { let package = Package::new(name, version)?; Ok(Box::new(package)) } } } /// Uninstall a tool, removing it from the local inventory /// /// This is implemented on Spec, instead of Resolved, because there is currently no need to /// resolve the specific version before uninstalling a tool. pub fn uninstall(self) -> Fallible<()> { match self { Spec::Node(_) => Err(ErrorKind::Unimplemented { feature: "Uninstalling node".into(), } .into()), Spec::Npm(_) => Err(ErrorKind::Unimplemented { feature: "Uninstalling npm".into(), } .into()), Spec::Pnpm(_) => { if env::var_os(VOLTA_FEATURE_PNPM).is_some() { Err(ErrorKind::Unimplemented { feature: "Uninstalling pnpm".into(), } .into()) } else { package::uninstall("pnpm") } } Spec::Yarn(_) => Err(ErrorKind::Unimplemented { feature: "Uninstalling yarn".into(), } .into()), Spec::Package(name, _) => package::uninstall(&name), } } /// The name of the tool, without the version, used for messaging pub fn name(&self) -> &str { match self { Spec::Node(_) => "Node", Spec::Npm(_) => "npm", Spec::Pnpm(_) => "pnpm", Spec::Yarn(_) => "Yarn", Spec::Package(name, _) => name, } } } impl Display for Spec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { Spec::Node(ref version) => tool_version("node", version), Spec::Npm(ref version) => tool_version("npm", version), Spec::Pnpm(ref version) => tool_version("pnpm", version), Spec::Yarn(ref version) => tool_version("yarn", version), Spec::Package(ref name, ref version) => tool_version(name, version), }; f.write_str(&s) } } /// Represents the result of checking if a tool is available locally or not /// /// If a fetch is required, will include an exclusive lock on the Volta directory where possible enum FetchStatus { AlreadyFetched, FetchNeeded(Option), } /// Uses the supplied `already_fetched` predicate to determine if a tool is available or not. /// /// This uses double-checking logic, to correctly handle concurrent fetch requests: /// /// - If `already_fetched` indicates that a fetch is needed, we acquire an exclusive lock on the Volta directory /// - Then, we check _again_, to confirm that no other process completed the fetch while we waited for the lock /// /// Note: If acquiring the lock fails, we proceed anyway, since the fetch is still necessary. fn check_fetched(already_fetched: F) -> Fallible where F: Fn() -> Fallible, { if !already_fetched()? { let lock = match VoltaLock::acquire() { Ok(l) => Some(l), Err(_) => { debug!("Unable to acquire lock on Volta directory!"); None } }; if !already_fetched()? { Ok(FetchStatus::FetchNeeded(lock)) } else { Ok(FetchStatus::AlreadyFetched) } } else { Ok(FetchStatus::AlreadyFetched) } } fn download_tool_error(tool: Spec, from_url: impl AsRef) -> impl FnOnce() -> ErrorKind { let from_url = from_url.as_ref().to_string(); || ErrorKind::DownloadToolNetworkError { tool, from_url } } fn registry_fetch_error( tool: impl AsRef, from_url: impl AsRef, ) -> impl FnOnce() -> ErrorKind { let tool = tool.as_ref().to_string(); let from_url = from_url.as_ref().to_string(); || ErrorKind::RegistryFetchError { tool, from_url } } cfg_if!( if #[cfg(windows)] { const PATH_VAR_NAME: &str = "Path"; } else { const PATH_VAR_NAME: &str = "PATH"; } ); /// Check if a newly-installed shim is first on the PATH. If it isn't, we want to inform the user /// that they'll want to move it to the start of PATH to make sure things work as expected. pub fn check_shim_reachable(shim_name: &str) { let Some(expected_dir) = find_expected_shim_dir(shim_name) else { return; }; let Ok(resolved) = which::which(shim_name) else { info!( "{} cannot find command {}. Please ensure that {} is available on your {}.", note_prefix(), shim_name, expected_dir.display(), PATH_VAR_NAME, ); return; }; if !resolved.starts_with(&expected_dir) { info!( "{} {} is shadowed by another binary of the same name at {}. To ensure your commands work as expected, please move {} to the start of your {}.", note_prefix(), shim_name, resolved.display(), expected_dir.display(), PATH_VAR_NAME ); } } /// Locate the base directory for the relevant shim in the Volta directories. /// /// On Unix, all of the shims, including the default ones, are installed in `VoltaHome::shim_dir` #[cfg(unix)] fn find_expected_shim_dir(_shim_name: &str) -> Option { volta_home().ok().map(|home| home.shim_dir().to_owned()) } /// Locate the base directory for the relevant shim in the Volta directories. /// /// On Windows, the default shims (node, npm, yarn, etc.) are installed in `Program Files` /// alongside the Volta binaries. To determine where we should be checking, we first look for the /// relevant shim inside of `VoltaHome::shim_dir`. If it's there, we use that directory. If it /// isn't, we assume it must be a default shim and return `VoltaInstall::root`, which is where /// Volta itself is installed. #[cfg(windows)] fn find_expected_shim_dir(shim_name: &str) -> Option { use crate::layout::volta_install; let home = volta_home().ok()?; if home.shim_file(shim_name).exists() { Some(home.shim_dir().to_owned()) } else { volta_install() .ok() .map(|install| install.root().to_owned()) } } ================================================ FILE: crates/volta-core/src/tool/node/fetch.rs ================================================ //! Provides fetcher for Node distributions use std::fs::{read_to_string, write, File}; use std::path::{Path, PathBuf}; use super::NodeVersion; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{create_staging_dir, create_staging_file, rename}; use crate::hook::ToolHooks; use crate::layout::volta_home; use crate::style::{progress_bar, tool_version}; use crate::tool::{self, download_tool_error, Node}; use crate::version::{parse_version, VersionSpec}; use archive::{self, Archive}; use cfg_if::cfg_if; use fs_utils::ensure_containing_dir_exists; use log::debug; use node_semver::Version; use serde::Deserialize; cfg_if! { if #[cfg(feature = "mock-network")] { // TODO: We need to reconsider our mocking strategy in light of mockito deprecating the // SERVER_URL constant: Since our acceptance tests run the binary in a separate process, // we can't use `mockito::server_url()`, which relies on shared memory. fn public_node_server_root() -> String { #[allow(deprecated)] mockito::SERVER_URL.to_string() } } else { fn public_node_server_root() -> String { "https://nodejs.org/dist".to_string() } } } fn npm_manifest_path(version: &Version) -> PathBuf { let mut manifest = PathBuf::from(Node::archive_basename(version)); #[cfg(unix)] manifest.push("lib"); manifest.push("node_modules"); manifest.push("npm"); manifest.push("package.json"); manifest } pub fn fetch(version: &Version, hooks: Option<&ToolHooks>) -> Fallible { let home = volta_home()?; let node_dir = home.node_inventory_dir(); let cache_file = node_dir.join(Node::archive_filename(version)); let (archive, staging) = match load_cached_distro(&cache_file) { Some(archive) => { debug!( "Loading {} from cached archive at '{}'", tool_version("node", version), cache_file.display() ); (archive, None) } None => { let staging = create_staging_file()?; let remote_url = determine_remote_url(version, hooks)?; let archive = fetch_remote_distro(version, &remote_url, staging.path())?; (archive, Some(staging)) } }; let node_version = unpack_archive(archive, version)?; if let Some(staging_file) = staging { ensure_containing_dir_exists(&cache_file).with_context(|| { ErrorKind::ContainingDirError { path: cache_file.clone(), } })?; staging_file .persist(cache_file) .with_context(|| ErrorKind::PersistInventoryError { tool: "Node".into(), })?; } Ok(node_version) } /// Unpack the node archive into the image directory so that it is ready for use fn unpack_archive(archive: Box, version: &Version) -> Fallible { let temp = create_staging_dir()?; debug!("Unpacking node into '{}'", temp.path().display()); let progress = progress_bar( archive.origin(), &tool_version("node", version), archive.compressed_size(), ); let version_string = version.to_string(); archive .unpack(temp.path(), &mut |_, read| { progress.inc(read as u64); }) .with_context(|| ErrorKind::UnpackArchiveError { tool: "Node".into(), version: version_string.clone(), })?; // Save the npm version number in the npm version file for this distro let npm_package_json = temp.path().join(npm_manifest_path(version)); let npm = Manifest::version(&npm_package_json)?; save_default_npm_version(version, &npm)?; let dest = volta_home()?.node_image_dir(&version_string); ensure_containing_dir_exists(&dest) .with_context(|| ErrorKind::ContainingDirError { path: dest.clone() })?; rename(temp.path().join(Node::archive_basename(version)), &dest).with_context(|| { ErrorKind::SetupToolImageError { tool: "Node".into(), version: version_string, dir: dest.clone(), } })?; progress.finish_and_clear(); // Note: We write these after the progress bar is finished to avoid display bugs with re-renders of the progress debug!("Saving bundled npm version ({})", npm); debug!("Installing node in '{}'", dest.display()); Ok(NodeVersion { runtime: version.clone(), npm, }) } /// Return the archive if it is valid. It may have been corrupted or interrupted in the middle of /// downloading. // ISSUE(#134) - verify checksum fn load_cached_distro(file: &Path) -> Option> { if file.is_file() { let file = File::open(file).ok()?; archive::load_native(file).ok() } else { None } } /// Determine the remote URL to download from, using the hooks if available fn determine_remote_url(version: &Version, hooks: Option<&ToolHooks>) -> Fallible { let distro_file_name = Node::archive_filename(version); match hooks { Some(&ToolHooks { distro: Some(ref hook), .. }) => { debug!("Using node.distro hook to determine download URL"); hook.resolve(version, &distro_file_name) } _ => Ok(format!( "{}/v{}/{}", public_node_server_root(), version, distro_file_name )), } } /// Fetch the distro archive from the internet fn fetch_remote_distro( version: &Version, url: &str, staging_path: &Path, ) -> Fallible> { debug!("Downloading {} from {}", tool_version("node", version), url); archive::fetch_native(url, staging_path).with_context(download_tool_error( tool::Spec::Node(VersionSpec::Exact(version.clone())), url, )) } /// The portion of npm's `package.json` file that we care about #[derive(Deserialize)] struct Manifest { version: String, } impl Manifest { /// Parse the version out of a package.json file fn version(path: &Path) -> Fallible { let file = File::open(path).with_context(|| ErrorKind::ReadNpmManifestError)?; let manifest: Manifest = serde_json::de::from_reader(file).with_context(|| ErrorKind::ParseNpmManifestError)?; parse_version(manifest.version) } } /// Load the local npm version file to determine the default npm version for a given version of Node pub fn load_default_npm_version(node: &Version) -> Fallible { let npm_version_file_path = volta_home()?.node_npm_version_file(&node.to_string()); let npm_version = read_to_string(&npm_version_file_path).with_context(|| ErrorKind::ReadDefaultNpmError { file: npm_version_file_path, })?; parse_version(npm_version) } /// Save the default npm version to the filesystem for a given version of Node fn save_default_npm_version(node: &Version, npm: &Version) -> Fallible<()> { let npm_version_file_path = volta_home()?.node_npm_version_file(&node.to_string()); write(&npm_version_file_path, npm.to_string().as_bytes()).with_context(|| { ErrorKind::WriteDefaultNpmError { file: npm_version_file_path, } }) } ================================================ FILE: crates/volta-core/src/tool/node/metadata.rs ================================================ use std::collections::HashSet; use super::NODE_DISTRO_IDENTIFIER; #[cfg(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "windows", target_arch = "aarch64") ))] use super::NODE_DISTRO_IDENTIFIER_FALLBACK; use crate::version::{option_version_serde, version_serde}; use node_semver::Version; use serde::{Deserialize, Deserializer}; /// The index of the public Node server. pub struct NodeIndex { pub(super) entries: Vec, } #[derive(Debug)] pub struct NodeEntry { pub version: Version, pub lts: bool, } #[derive(Deserialize)] pub struct RawNodeIndex(Vec); #[derive(Deserialize)] pub struct RawNodeEntry { #[serde(with = "version_serde")] version: Version, #[serde(default)] // handles Option #[serde(with = "option_version_serde")] npm: Option, files: HashSet, #[serde(deserialize_with = "lts_version_serde")] lts: bool, } impl From for NodeIndex { fn from(raw: RawNodeIndex) -> NodeIndex { let entries = raw .0 .into_iter() .filter_map(|entry| { #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "windows", target_arch = "aarch64") )))] if entry.npm.is_some() && entry.files.contains(NODE_DISTRO_IDENTIFIER) { Some(NodeEntry { version: entry.version, lts: entry.lts, }) } else { None } #[cfg(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "windows", target_arch = "aarch64") ))] if entry.npm.is_some() && (entry.files.contains(NODE_DISTRO_IDENTIFIER) || entry.files.contains(NODE_DISTRO_IDENTIFIER_FALLBACK)) { Some(NodeEntry { version: entry.version, lts: entry.lts, }) } else { None } }) .collect(); NodeIndex { entries } } } #[allow(clippy::unnecessary_wraps)] // Needs to match the API expected by Serde fn lts_version_serde<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { match String::deserialize(deserializer) { Ok(_) => Ok(true), Err(_) => Ok(false), } } ================================================ FILE: crates/volta-core/src/tool/node/mod.rs ================================================ use std::fmt::{self, Display}; use super::{ check_fetched, check_shim_reachable, debug_already_fetched, info_fetched, info_installed, info_pinned, info_project_version, FetchStatus, Tool, }; use crate::error::{ErrorKind, Fallible}; use crate::inventory::node_available; use crate::session::Session; use crate::style::{note_prefix, tool_version}; use crate::sync::VoltaLock; use cfg_if::cfg_if; use log::info; use node_semver::Version; mod fetch; mod metadata; mod resolve; pub use fetch::load_default_npm_version; pub use resolve::resolve; cfg_if! { if #[cfg(all(target_os = "windows", target_arch = "x86"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "win"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "x86"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "zip"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "win-x86-zip"; } else if #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "win"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "x64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "zip"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "win-x64-zip"; } else if #[cfg(all(target_os = "windows", target_arch = "aarch64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "win"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "arm64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "zip"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "win-arm64-zip"; // NOTE: Node support for pre-built ARM64 binaries on Windows was added in major version 20 // For versions prior to that, we need to fall back on the x64 binaries via emulator /// The fallback architecture component of a Node distro filename pub const NODE_DISTRO_ARCH_FALLBACK: &str = "x64"; /// The fallback file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER_FALLBACK: &str = "win-x64-zip"; } else if #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "darwin"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "x64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "tar.gz"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "osx-x64-tar"; } else if #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "darwin"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "arm64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "tar.gz"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "osx-arm64-tar"; // NOTE: Node support for pre-built Apple Silicon binaries was added in major version 16 // For versions prior to that, we need to fall back on the x64 binaries via Rosetta 2 /// The fallback architecture component of a Node distro filename pub const NODE_DISTRO_ARCH_FALLBACK: &str = "x64"; /// The fallback file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER_FALLBACK: &str = "osx-x64-tar"; } else if #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "linux"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "x64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "tar.gz"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "linux-x64"; } else if #[cfg(all(target_os = "linux", target_arch = "aarch64"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "linux"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "arm64"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "tar.gz"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "linux-arm64"; } else if #[cfg(all(target_os = "linux", target_arch = "arm"))] { /// The OS component of a Node distro filename pub const NODE_DISTRO_OS: &str = "linux"; /// The architecture component of a Node distro filename pub const NODE_DISTRO_ARCH: &str = "armv7l"; /// The extension for Node distro files pub const NODE_DISTRO_EXTENSION: &str = "tar.gz"; /// The file identifier in the Node index `files` array pub const NODE_DISTRO_IDENTIFIER: &str = "linux-armv7l"; } else { compile_error!("Unsuppored operating system + architecture combination"); } } /// A full Node version including not just the version of Node itself /// but also the specific version of npm installed globally with that /// Node installation. #[derive(Clone, Debug)] pub struct NodeVersion { /// The version of Node itself. pub runtime: Version, /// The npm version globally installed with the Node distro. pub npm: Version, } impl Display for NodeVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{} (with {})", tool_version("node", &self.runtime), tool_version("npm", &self.npm) ) } } /// The Tool implementation for fetching and installing Node pub struct Node { pub(super) version: Version, } impl Node { pub fn new(version: Version) -> Self { Node { version } } #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "windows", target_arch = "aarch64") )))] pub fn archive_basename(version: &Version) -> String { format!("node-v{}-{}-{}", version, NODE_DISTRO_OS, NODE_DISTRO_ARCH) } #[cfg(all(target_os = "macos", target_arch = "aarch64"))] pub fn archive_basename(version: &Version) -> String { // Note: Node began shipping pre-built binaries for Apple Silicon with Major version 16 // Prior to that, we need to fall back on the x64 binaries format!( "node-v{}-{}-{}", version, NODE_DISTRO_OS, if version.major >= 16 { NODE_DISTRO_ARCH } else { NODE_DISTRO_ARCH_FALLBACK } ) } #[cfg(all(target_os = "windows", target_arch = "aarch64"))] pub fn archive_basename(version: &Version) -> String { // Note: Node began shipping pre-built binaries for Windows ARM with Major version 20 // Prior to that, we need to fall back on the x64 binaries format!( "node-v{}-{}-{}", version, NODE_DISTRO_OS, if version.major >= 20 { NODE_DISTRO_ARCH } else { NODE_DISTRO_ARCH_FALLBACK } ) } pub fn archive_filename(version: &Version) -> String { format!( "{}.{}", Node::archive_basename(version), NODE_DISTRO_EXTENSION ) } pub(crate) fn ensure_fetched(&self, session: &mut Session) -> Fallible { match check_fetched(|| node_available(&self.version))? { FetchStatus::AlreadyFetched => { debug_already_fetched(self); let npm = fetch::load_default_npm_version(&self.version)?; Ok(NodeVersion { runtime: self.version.clone(), npm, }) } FetchStatus::FetchNeeded(_lock) => fetch::fetch(&self.version, session.hooks()?.node()), } } } impl Tool for Node { fn fetch(self: Box, session: &mut Session) -> Fallible<()> { let node_version = self.ensure_fetched(session)?; info_fetched(node_version); Ok(()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); let node_version = self.ensure_fetched(session)?; let default_toolchain = session.toolchain_mut()?; default_toolchain.set_active_node(&self.version)?; // If the user has a default version of `npm`, we shouldn't show the "(with npm@X.Y.ZZZ)" text in the success message // Instead we should check if the bundled version is higher than the default and inform the user // Note: The previous line ensures that there will be a default platform if let Some(default_npm) = &default_toolchain.platform().unwrap().npm { info_installed(&self); // includes node version if node_version.npm > *default_npm { info!("{} this version of Node includes {}, which is higher than your default version ({}). To use the version included with Node, run `volta install npm@bundled`", note_prefix(), tool_version("npm", node_version.npm), default_npm.to_string() ); } } else { info_installed(node_version); // includes node and npm version } check_shim_reachable("node"); if let Ok(Some(project)) = session.project_platform() { info_project_version(tool_version("node", &project.node), &self); } Ok(()) } fn pin(self: Box, session: &mut Session) -> Fallible<()> { if session.project()?.is_some() { let node_version = self.ensure_fetched(session)?; // Note: We know this will succeed, since we checked above let project = session.project_mut()?.unwrap(); project.pin_node(self.version.clone())?; // If the user has a pinned version of `npm`, we shouldn't show the "(with npm@X.Y.ZZZ)" text in the success message // Instead we should check if the bundled version is higher than the pinned and inform the user // Note: The pin operation guarantees there will be a platform if let Some(pinned_npm) = &project.platform().unwrap().npm { info_pinned(self); // includes node version if node_version.npm > *pinned_npm { info!("{} this version of Node includes {}, which is higher than your pinned version ({}). To use the version included with Node, run `volta pin npm@bundled`", note_prefix(), tool_version("npm", node_version.npm), pinned_npm.to_string() ); } } else { info_pinned(node_version); // includes node and npm version } Ok(()) } else { Err(ErrorKind::NotInPackage.into()) } } } impl Display for Node { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&tool_version("node", &self.version)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_node_archive_basename() { assert_eq!( Node::archive_basename(&Version::parse("20.2.3").unwrap()), format!("node-v20.2.3-{}-{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH) ); } #[test] fn test_node_archive_filename() { assert_eq!( Node::archive_filename(&Version::parse("20.2.3").unwrap()), format!( "node-v20.2.3-{}-{}.{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH, NODE_DISTRO_EXTENSION ) ); } #[test] #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn test_fallback_node_archive_basename() { assert_eq!( Node::archive_basename(&Version::parse("15.2.3").unwrap()), format!( "node-v15.2.3-{}-{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH_FALLBACK ) ); } #[test] #[cfg(all(target_os = "windows", target_arch = "aarch64"))] fn test_fallback_node_archive_basename() { assert_eq!( Node::archive_basename(&Version::parse("19.2.3").unwrap()), format!( "node-v19.2.3-{}-{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH_FALLBACK ) ); } #[test] #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn test_fallback_node_archive_filename() { assert_eq!( Node::archive_filename(&Version::parse("15.2.3").unwrap()), format!( "node-v15.2.3-{}-{}.{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH_FALLBACK, NODE_DISTRO_EXTENSION ) ); } #[test] #[cfg(all(target_os = "windows", target_arch = "aarch64"))] fn test_fallback_node_archive_filename() { assert_eq!( Node::archive_filename(&Version::parse("19.2.3").unwrap()), format!( "node-v19.2.3-{}-{}.{}", NODE_DISTRO_OS, NODE_DISTRO_ARCH_FALLBACK, NODE_DISTRO_EXTENSION ) ); } } ================================================ FILE: crates/volta-core/src/tool/node/resolve.rs ================================================ //! Provides resolution of Node requirements into specific versions, using the NodeJS index use std::fs::File; use std::io::Write; use std::time::{Duration, SystemTime}; use super::super::registry_fetch_error; use super::metadata::{NodeEntry, NodeIndex, RawNodeIndex}; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{create_staging_file, read_file}; use crate::hook::ToolHooks; use crate::layout::volta_home; use crate::session::Session; use crate::style::progress_spinner; use crate::tool::Node; use crate::version::{VersionSpec, VersionTag}; use attohttpc::header::HeaderMap; use attohttpc::Response; use cfg_if::cfg_if; use fs_utils::ensure_containing_dir_exists; use headers::{CacheControl, Expires, HeaderMapExt}; use log::debug; use node_semver::{Range, Version}; // ISSUE (#86): Move public repository URLs to config file cfg_if! { if #[cfg(feature = "mock-network")] { // TODO: We need to reconsider our mocking strategy in light of mockito deprecating the // SERVER_URL constant: Since our acceptance tests run the binary in a separate process, // we can't use `mockito::server_url()`, which relies on shared memory. #[allow(deprecated)] const SERVER_URL: &str = mockito::SERVER_URL; fn public_node_version_index() -> String { format!("{}/node-dist/index.json", SERVER_URL) } } else { /// Returns the URL of the index of available Node versions on the public Node server. fn public_node_version_index() -> String { "https://nodejs.org/dist/index.json".to_string() } } } pub fn resolve(matching: VersionSpec, session: &mut Session) -> Fallible { let hooks = session.hooks()?.node(); match matching { VersionSpec::Semver(requirement) => resolve_semver(requirement, hooks), VersionSpec::Exact(version) => Ok(version), VersionSpec::None | VersionSpec::Tag(VersionTag::Lts) => resolve_lts(hooks), VersionSpec::Tag(VersionTag::Latest) => resolve_latest(hooks), // Node doesn't have "tagged" versions (apart from 'latest' and 'lts'), so custom tags will always be an error VersionSpec::Tag(VersionTag::Custom(tag)) => { Err(ErrorKind::NodeVersionNotFound { matching: tag }.into()) } } } fn resolve_latest(hooks: Option<&ToolHooks>) -> Fallible { // NOTE: This assumes the registry always produces a list in sorted order // from newest to oldest. This should be specified as a requirement // when we document the plugin API. let url = match hooks { Some(&ToolHooks { latest: Some(ref hook), .. }) => { debug!("Using node.latest hook to determine node index URL"); hook.resolve("index.json")? } _ => public_node_version_index(), }; let version_opt = match_node_version(&url, |_| true)?; match version_opt { Some(version) => { debug!("Found latest node version ({}) from {}", version, url); Ok(version) } None => Err(ErrorKind::NodeVersionNotFound { matching: "latest".into(), } .into()), } } fn resolve_lts(hooks: Option<&ToolHooks>) -> Fallible { let url = match hooks { Some(&ToolHooks { index: Some(ref hook), .. }) => { debug!("Using node.index hook to determine node index URL"); hook.resolve("index.json")? } _ => public_node_version_index(), }; let version_opt = match_node_version(&url, |&NodeEntry { lts, .. }| lts)?; match version_opt { Some(version) => { debug!("Found newest LTS node version ({}) from {}", version, url); Ok(version) } None => Err(ErrorKind::NodeVersionNotFound { matching: "lts".into(), } .into()), } } fn resolve_semver(matching: Range, hooks: Option<&ToolHooks>) -> Fallible { let url = match hooks { Some(&ToolHooks { index: Some(ref hook), .. }) => { debug!("Using node.index hook to determine node index URL"); hook.resolve("index.json")? } _ => public_node_version_index(), }; let version_opt = match_node_version(&url, |NodeEntry { version, .. }| { matching.satisfies(version) })?; match version_opt { Some(version) => { debug!( "Found node@{} matching requirement '{}' from {}", version, matching, url ); Ok(version) } None => Err(ErrorKind::NodeVersionNotFound { matching: matching.to_string(), } .into()), } } fn match_node_version( url: &str, predicate: impl Fn(&NodeEntry) -> bool, ) -> Fallible> { let index: NodeIndex = resolve_node_versions(url)?.into(); let mut entries = index.entries.into_iter(); Ok(entries .find(predicate) .map(|NodeEntry { version, .. }| version)) } /// Reads a public index from the Node cache, if it exists and hasn't expired. fn read_cached_opt(url: &str) -> Fallible> { let expiry_file = volta_home()?.node_index_expiry_file(); let expiry = read_file(expiry_file).with_context(|| ErrorKind::ReadNodeIndexExpiryError { file: expiry_file.to_owned(), })?; if !expiry .map(|date| httpdate::parse_http_date(&date)) .transpose() .with_context(|| ErrorKind::ParseNodeIndexExpiryError)? .is_some_and(|expiry_date| SystemTime::now() < expiry_date) { return Ok(None); }; let index_file = volta_home()?.node_index_file(); let cached = read_file(index_file).with_context(|| ErrorKind::ReadNodeIndexCacheError { file: index_file.to_owned(), })?; let Some(json) = cached .as_ref() .and_then(|content| content.strip_prefix(url)) else { return Ok(None); }; serde_json::de::from_str(json).with_context(|| ErrorKind::ParseNodeIndexCacheError) } /// Get the cache max-age of an HTTP response. fn max_age(headers: &HeaderMap) -> Duration { const FOUR_HOURS: Duration = Duration::from_secs(4 * 60 * 60); headers .typed_get::() .and_then(|cache_control| cache_control.max_age()) .unwrap_or(FOUR_HOURS) } fn resolve_node_versions(url: &str) -> Fallible { match read_cached_opt(url)? { Some(serial) => { debug!("Found valid cache of Node version index"); Ok(serial) } None => { debug!("Node index cache was not found or was invalid"); let spinner = progress_spinner(format!("Fetching public registry: {}", url)); let (_, headers, response) = attohttpc::get(url) .send() .and_then(Response::error_for_status) .with_context(registry_fetch_error("Node", url))? .split(); let expires = headers .typed_get::() .map(SystemTime::from) .unwrap_or_else(|| SystemTime::now() + max_age(&headers)); let response_text = response .text() .with_context(registry_fetch_error("Node", url))?; let index: RawNodeIndex = serde_json::de::from_str(&response_text).with_context(|| { ErrorKind::ParseNodeIndexError { from_url: url.to_string(), } })?; let cached = create_staging_file()?; let mut cached_file: &File = cached.as_file(); writeln!(cached_file, "{}", url) .and_then(|_| cached_file.write(response_text.as_bytes())) .with_context(|| ErrorKind::WriteNodeIndexCacheError { file: cached.path().to_path_buf(), })?; let index_cache_file = volta_home()?.node_index_file(); ensure_containing_dir_exists(&index_cache_file).with_context(|| { ErrorKind::ContainingDirError { path: index_cache_file.to_owned(), } })?; cached.persist(index_cache_file).with_context(|| { ErrorKind::WriteNodeIndexCacheError { file: index_cache_file.to_owned(), } })?; let expiry = create_staging_file()?; let mut expiry_file: &File = expiry.as_file(); write!(expiry_file, "{}", httpdate::fmt_http_date(expires)).with_context(|| { ErrorKind::WriteNodeIndexExpiryError { file: expiry.path().to_path_buf(), } })?; let index_expiry_file = volta_home()?.node_index_expiry_file(); ensure_containing_dir_exists(&index_expiry_file).with_context(|| { ErrorKind::ContainingDirError { path: index_expiry_file.to_owned(), } })?; expiry.persist(index_expiry_file).with_context(|| { ErrorKind::WriteNodeIndexExpiryError { file: index_expiry_file.to_owned(), } })?; spinner.finish_and_clear(); Ok(index) } } } ================================================ FILE: crates/volta-core/src/tool/npm/fetch.rs ================================================ //! Provides fetcher for npm distributions use std::fs::{write, File}; use std::path::Path; use super::super::download_tool_error; use super::super::registry::public_registry_package; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{create_staging_dir, create_staging_file, rename, set_executable}; use crate::hook::ToolHooks; use crate::layout::volta_home; use crate::style::{progress_bar, tool_version}; use crate::tool::{self, Npm}; use crate::version::VersionSpec; use archive::{Archive, Tarball}; use fs_utils::ensure_containing_dir_exists; use log::debug; use node_semver::Version; pub fn fetch(version: &Version, hooks: Option<&ToolHooks>) -> Fallible<()> { let npm_dir = volta_home()?.npm_inventory_dir(); let cache_file = npm_dir.join(Npm::archive_filename(&version.to_string())); let (archive, staging) = match load_cached_distro(&cache_file) { Some(archive) => { debug!( "Loading {} from cached archive at '{}'", tool_version("npm", version), cache_file.display() ); (archive, None) } None => { let staging = create_staging_file()?; let remote_url = determine_remote_url(version, hooks)?; let archive = fetch_remote_distro(version, &remote_url, staging.path())?; (archive, Some(staging)) } }; unpack_archive(archive, version)?; if let Some(staging_file) = staging { ensure_containing_dir_exists(&cache_file).with_context(|| { ErrorKind::ContainingDirError { path: cache_file.clone(), } })?; staging_file .persist(cache_file) .with_context(|| ErrorKind::PersistInventoryError { tool: "npm".into() })?; } Ok(()) } /// Unpack the npm archive into the image directory so that it is ready for use fn unpack_archive(archive: Box, version: &Version) -> Fallible<()> { let temp = create_staging_dir()?; debug!("Unpacking npm into '{}'", temp.path().display()); let progress = progress_bar( archive.origin(), &tool_version("npm", version), archive.compressed_size(), ); let version_string = version.to_string(); archive .unpack(temp.path(), &mut |_, read| { progress.inc(read as u64); }) .with_context(|| ErrorKind::UnpackArchiveError { tool: "npm".into(), version: version_string.clone(), })?; let bin_path = temp.path().join("package").join("bin"); overwrite_launcher(&bin_path, "npm")?; overwrite_launcher(&bin_path, "npx")?; #[cfg(windows)] { overwrite_cmd_launcher(&bin_path, "npm")?; overwrite_cmd_launcher(&bin_path, "npx")?; } let dest = volta_home()?.npm_image_dir(&version_string); ensure_containing_dir_exists(&dest) .with_context(|| ErrorKind::ContainingDirError { path: dest.clone() })?; rename(temp.path().join("package"), &dest).with_context(|| ErrorKind::SetupToolImageError { tool: "npm".into(), version: version_string.clone(), dir: dest.clone(), })?; progress.finish_and_clear(); // Note: We write this after the progress bar is finished to avoid display bugs with re-renders of the progress debug!("Installing npm in '{}'", dest.display()); Ok(()) } /// Return the archive if it is valid. It may have been corrupted or interrupted in the middle of /// downloading. /// ISSUE(#134) - verify checksum fn load_cached_distro(file: &Path) -> Option> { if file.is_file() { let file = File::open(file).ok()?; Tarball::load(file).ok() } else { None } } /// Determine the remote URL to download from, using the hooks if avaialble fn determine_remote_url(version: &Version, hooks: Option<&ToolHooks>) -> Fallible { let version_str = version.to_string(); match hooks { Some(&ToolHooks { distro: Some(ref hook), .. }) => { debug!("Using npm.distro hook to determine download URL"); let distro_file_name = Npm::archive_filename(&version_str); hook.resolve(version, &distro_file_name) } _ => Ok(public_registry_package("npm", &version_str)), } } /// Fetch the distro archive from the internet fn fetch_remote_distro( version: &Version, url: &str, staging_path: &Path, ) -> Fallible> { debug!("Downloading {} from {}", tool_version("npm", version), url); Tarball::fetch(url, staging_path).with_context(download_tool_error( tool::Spec::Npm(VersionSpec::Exact(version.clone())), url, )) } /// Overwrite the launcher script fn overwrite_launcher(base_path: &Path, tool: &str) -> Fallible<()> { let path = base_path.join(tool); write( &path, // Note: Adapted from the existing npm/npx launcher, without unnecessary detection of Node location format!( r#"#!/bin/sh (set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix basedir=`dirname "$0"` case `uname` in *CYGWIN*) basedir=`cygpath -w "$basedir"`;; esac node "$basedir/{}-cli.js" "$@" "#, tool ), ) .and_then(|_| set_executable(&path)) .with_context(|| ErrorKind::WriteLauncherError { tool: tool.into() }) } /// Overwrite the CMD launcher #[cfg(windows)] fn overwrite_cmd_launcher(base_path: &Path, tool: &str) -> Fallible<()> { write( base_path.join(format!("{}.cmd", tool)), // Note: Adapted from the existing npm/npx cmd launcher, without unnecessary detection of Node location format!( r#"@ECHO OFF node "%~dp0\{}-cli.js" %* "#, tool ), ) .with_context(|| ErrorKind::WriteLauncherError { tool: tool.into() }) } ================================================ FILE: crates/volta-core/src/tool/npm/mod.rs ================================================ use std::fmt::{self, Display}; use super::node::load_default_npm_version; use super::{ check_fetched, check_shim_reachable, debug_already_fetched, info_fetched, info_installed, info_pinned, info_project_version, FetchStatus, Tool, }; use crate::error::{Context, ErrorKind, Fallible}; use crate::inventory::npm_available; use crate::session::Session; use crate::style::{success_prefix, tool_version}; use crate::sync::VoltaLock; use log::info; use node_semver::Version; mod fetch; mod resolve; pub use resolve::resolve; /// The Tool implementation for fetching and installing npm pub struct Npm { pub(super) version: Version, } impl Npm { pub fn new(version: Version) -> Self { Npm { version } } pub fn archive_basename(version: &str) -> String { format!("npm-{}", version) } pub fn archive_filename(version: &str) -> String { format!("{}.tgz", Npm::archive_basename(version)) } pub(crate) fn ensure_fetched(&self, session: &mut Session) -> Fallible<()> { match check_fetched(|| npm_available(&self.version))? { FetchStatus::AlreadyFetched => { debug_already_fetched(self); Ok(()) } FetchStatus::FetchNeeded(_lock) => fetch::fetch(&self.version, session.hooks()?.npm()), } } } impl Tool for Npm { fn fetch(self: Box, session: &mut Session) -> Fallible<()> { self.ensure_fetched(session)?; info_fetched(self); Ok(()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); self.ensure_fetched(session)?; session .toolchain_mut()? .set_active_npm(Some(self.version.clone()))?; info_installed(&self); check_shim_reachable("npm"); if let Ok(Some(project)) = session.project_platform() { if let Some(npm) = &project.npm { info_project_version(tool_version("npm", npm), &self); } } Ok(()) } fn pin(self: Box, session: &mut Session) -> Fallible<()> { if session.project()?.is_some() { self.ensure_fetched(session)?; // Note: We know this will succeed, since we checked above let project = session.project_mut()?.unwrap(); project.pin_npm(Some(self.version.clone()))?; info_pinned(self); Ok(()) } else { Err(ErrorKind::NotInPackage.into()) } } } impl Display for Npm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&tool_version("npm", &self.version)) } } /// The Tool implementation for setting npm to the version bundled with Node pub struct BundledNpm; impl Tool for BundledNpm { fn fetch(self: Box, _session: &mut Session) -> Fallible<()> { info!("Bundled npm is included with Node, use `volta fetch node` to fetch Node"); Ok(()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { let toolchain = session.toolchain_mut()?; toolchain.set_active_npm(None)?; let bundled_version = match toolchain.platform() { Some(platform) => { let version = load_default_npm_version(&platform.node).with_context(|| { ErrorKind::NoBundledNpm { command: "install".into(), } })?; version.to_string() } None => { return Err(ErrorKind::NoBundledNpm { command: "install".into(), } .into()); } }; info!( "{} set bundled npm (currently {}) as default", success_prefix(), bundled_version ); Ok(()) } fn pin(self: Box, session: &mut Session) -> Fallible<()> { match session.project_mut()? { Some(project) => { project.pin_npm(None)?; let bundled_version = match project.platform() { Some(platform) => { let version = load_default_npm_version(&platform.node).with_context(|| { ErrorKind::NoBundledNpm { command: "pin".into(), } })?; version.to_string() } None => { return Err(ErrorKind::NoBundledNpm { command: "pin".into(), } .into()); } }; info!( "{} set package.json to use bundled npm (currently {})", success_prefix(), bundled_version ); Ok(()) } None => Err(ErrorKind::NotInPackage.into()), } } } impl Display for BundledNpm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&tool_version("npm", "bundled")) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_npm_archive_basename() { assert_eq!(Npm::archive_basename("1.2.3"), "npm-1.2.3"); } #[test] fn test_npm_archive_filename() { assert_eq!(Npm::archive_filename("1.2.3"), "npm-1.2.3.tgz"); } } ================================================ FILE: crates/volta-core/src/tool/npm/resolve.rs ================================================ //! Provides resolution of npm Version requirements into specific versions use super::super::registry::{ fetch_npm_registry, public_registry_index, PackageDetails, PackageIndex, }; use crate::error::{ErrorKind, Fallible}; use crate::hook::ToolHooks; use crate::session::Session; use crate::tool::Npm; use crate::version::{VersionSpec, VersionTag}; use log::debug; use node_semver::{Range, Version}; pub fn resolve(matching: VersionSpec, session: &mut Session) -> Fallible> { let hooks = session.hooks()?.npm(); match matching { VersionSpec::Semver(requirement) => resolve_semver(requirement, hooks).map(Some), VersionSpec::Exact(version) => Ok(Some(version)), VersionSpec::None | VersionSpec::Tag(VersionTag::Latest) => { resolve_tag("latest", hooks).map(Some) } VersionSpec::Tag(VersionTag::Custom(tag)) if tag == "bundled" => Ok(None), VersionSpec::Tag(tag) => resolve_tag(&tag.to_string(), hooks).map(Some), } } fn fetch_npm_index(hooks: Option<&ToolHooks>) -> Fallible<(String, PackageIndex)> { let url = match hooks { Some(&ToolHooks { index: Some(ref hook), .. }) => { debug!("Using npm.index hook to determine npm index URL"); hook.resolve("npm")? } _ => public_registry_index("npm"), }; fetch_npm_registry(url, "npm") } fn resolve_tag(tag: &str, hooks: Option<&ToolHooks>) -> Fallible { let (url, mut index) = fetch_npm_index(hooks)?; match index.tags.remove(tag) { Some(version) => { debug!("Found npm@{} matching tag '{}' from {}", version, tag, url); Ok(version) } None => Err(ErrorKind::NpmVersionNotFound { matching: tag.into(), } .into()), } } fn resolve_semver(matching: Range, hooks: Option<&ToolHooks>) -> Fallible { let (url, index) = fetch_npm_index(hooks)?; let details_opt = index .entries .into_iter() .find(|PackageDetails { version, .. }| matching.satisfies(version)); match details_opt { Some(details) => { debug!( "Found npm@{} matching requirement '{}' from {}", details.version, matching, url ); Ok(details.version) } None => Err(ErrorKind::NpmVersionNotFound { matching: matching.to_string(), } .into()), } } ================================================ FILE: crates/volta-core/src/tool/package/configure.rs ================================================ use std::path::PathBuf; use super::manager::PackageManager; use super::metadata::{BinConfig, PackageConfig, PackageManifest}; use crate::error::{ErrorKind, Fallible}; use crate::layout::volta_home; use crate::platform::{Image, PlatformSpec}; use crate::shim; use crate::tool::check_shim_reachable; /// Read the manifest for the package being installed pub(super) fn parse_manifest( package_name: &str, staging_dir: PathBuf, manager: PackageManager, ) -> Fallible { let mut package_dir = manager.source_dir(staging_dir); package_dir.push(package_name); PackageManifest::for_dir(package_name, &package_dir) } /// Generate configuration files and shims for the package and each of its bins pub(super) fn write_config_and_shims( name: &str, manifest: &PackageManifest, image: &Image, manager: PackageManager, ) -> Fallible<()> { validate_bins(name, manifest)?; let platform = PlatformSpec { node: image.node.value.clone(), npm: image.npm.clone().map(|s| s.value), pnpm: image.pnpm.clone().map(|s| s.value), yarn: image.yarn.clone().map(|s| s.value), }; // Generate the shims and bin configs for each bin provided by the package for bin_name in &manifest.bin { shim::create(bin_name)?; check_shim_reachable(bin_name); BinConfig { name: bin_name.clone(), package: name.into(), version: manifest.version.clone(), platform: platform.clone(), manager, } .write()?; } // Write the config for the package PackageConfig { name: name.into(), version: manifest.version.clone(), platform, bins: manifest.bin.clone(), manager, } .write()?; Ok(()) } /// Validate that we aren't attempting to install a bin that is already installed by /// another package. fn validate_bins(package_name: &str, manifest: &PackageManifest) -> Fallible<()> { let home = volta_home()?; for bin_name in &manifest.bin { // Check for name conflicts with already-installed bins // Some packages may install bins with the same name if let Ok(config) = BinConfig::from_file(home.default_tool_bin_config(bin_name)) { // The file exists, so there is a bin with this name // That is okay iff it came from the package that is currently being installed if package_name != config.package { return Err(ErrorKind::BinaryAlreadyInstalled { bin_name: bin_name.into(), existing_package: config.package, new_package: package_name.into(), } .into()); } } } Ok(()) } ================================================ FILE: crates/volta-core/src/tool/package/install.rs ================================================ use std::path::PathBuf; use super::manager::PackageManager; use crate::command::create_command; use crate::error::{Context, ErrorKind, Fallible}; use crate::platform::Image; use crate::style::progress_spinner; use log::debug; /// Use `npm install --global` to install the package /// /// Sets the environment variable `npm_config_prefix` to redirect the install to the Volta /// data directory, taking advantage of the standard global install behavior with a custom /// location pub(super) fn run_global_install( package: String, staging_dir: PathBuf, platform_image: &Image, ) -> Fallible<()> { let mut command = create_command("npm"); command.args([ "install", "--global", "--loglevel=warn", "--no-update-notifier", "--no-audit", ]); command.arg(&package); command.env("PATH", platform_image.path()?); PackageManager::Npm.setup_global_command(&mut command, staging_dir); debug!("Installing {} with command: {:?}", package, command); let spinner = progress_spinner(format!("Installing {}", package)); let output_result = command .output() .with_context(|| ErrorKind::PackageInstallFailed { package: package.clone(), }); spinner.finish_and_clear(); let output = output_result?; let stderr = String::from_utf8_lossy(&output.stderr); debug!("[install stderr]\n{}", stderr); debug!( "[install stdout]\n{}", String::from_utf8_lossy(&output.stdout) ); if output.status.success() { Ok(()) } else if stderr.contains("code E404") { // npm outputs "code E404" as part of the error output when a package couldn't be found // Detect that and show a nicer error message (since we likely know the problem in that case) Err(ErrorKind::PackageNotFound { package }.into()) } else { Err(ErrorKind::PackageInstallFailed { package }.into()) } } ================================================ FILE: crates/volta-core/src/tool/package/manager.rs ================================================ use std::ffi::OsStr; use std::fs::File; use std::path::{Path, PathBuf}; use std::process::Command; use super::metadata::GlobalYarnManifest; use crate::fs::read_dir_eager; /// The package manager used to install a given package #[derive( Copy, Clone, serde::Serialize, serde::Deserialize, PartialOrd, Ord, PartialEq, Eq, Debug, )] pub enum PackageManager { Npm, Pnpm, Yarn, } impl PackageManager { /// Given the `package_root`, returns the directory where the source is stored for this /// package manager. This will include the top-level `node_modules`, where appropriate. pub fn source_dir(self, package_root: PathBuf) -> PathBuf { let mut path = self.source_root(package_root); path.push("node_modules"); path } /// Given the `package_root`, returns the root of the source directory. This directory will /// contain the top-level `node-modules` #[cfg(unix)] pub fn source_root(self, package_root: PathBuf) -> PathBuf { let mut path = package_root; match self { // On Unix, the source is always within a `lib` subdirectory, with both npm and Yarn PackageManager::Npm | PackageManager::Yarn => path.push("lib"), // pnpm puts the source node_modules directory in the global-dir // plus a versioned subdirectory. // FIXME: Here the subdirectory is hard-coded, I don't know if it's // possible to retrieve it from pnpm dynamically. PackageManager::Pnpm => path.push("5"), } path } /// Given the `package_root`, returns the root of the source directory. This directory will /// contain the top-level `node-modules` #[cfg(windows)] pub fn source_root(self, package_root: PathBuf) -> PathBuf { match self { // On Windows, npm puts the source node_modules directory in the root of the `prefix` PackageManager::Npm => package_root, // On Windows, we still tell yarn to use the `lib` subdirectory PackageManager::Yarn => { let mut path = package_root; path.push("lib"); path } // pnpm puts the source node_modules directory in the global-dir // plus a versioned subdirectory. // FIXME: Here the subdirectory is hard-coded, I don't know if it's // possible to retrieve it from pnpm dynamically. PackageManager::Pnpm => { let mut path = package_root; path.push("5"); path } } } /// Given the `package_root`, returns the directory where binaries are stored for this package /// manager. #[cfg(unix)] pub fn binary_dir(self, package_root: PathBuf) -> PathBuf { // On Unix, the binaries are always within a `bin` subdirectory for both npm and Yarn let mut path = package_root; path.push("bin"); path } /// Given the `package_root`, returns the directory where binaries are stored for this package /// manager. #[cfg(windows)] pub fn binary_dir(self, package_root: PathBuf) -> PathBuf { match self { // On Windows, npm leaves the binaries at the root of the `prefix` directory PackageManager::Npm => package_root, // On Windows, Yarn still includes the `bin` subdirectory. pnpm by // default generates binaries into the `PNPM_HOME` path PackageManager::Yarn | PackageManager::Pnpm => { let mut path = package_root; path.push("bin"); path } } } /// Modify a given `Command` to be set up for global installs, given the package root pub fn setup_global_command(self, command: &mut Command, package_root: PathBuf) { command.env("npm_config_prefix", &package_root); if let PackageManager::Yarn = self { command.env("npm_config_global_folder", self.source_root(package_root)); } else if let PackageManager::Pnpm = self { // FIXME: Find out if there is a perfect way to intercept pnpm global // installs by using environment variables or whatever. // Using `--global-dir` and `--global-bin-dir` flags here is not enough, // because pnpm generates _absolute path_ based symlinks, and this makes // impossible to simply move installed packages from the staging directory // to the final `image/packages/` destination. // Specify the staging directory to store global package, // see: https://pnpm.io/npmrc#global-dir command.arg("--global-dir").arg(&package_root); // Specify the staging directory for the bin files of globally installed packages. // See: https://pnpm.io/npmrc#global-bin-dir (>= 6.15.0) // and https://github.com/volta-cli/rfcs/pull/46#discussion_r933296625 let global_bin_dir = self.binary_dir(package_root); command.arg("--global-bin-dir").arg(&global_bin_dir); // pnpm requires the `global-bin-dir` to be in PATH, otherwise it // will not trigger global installs. One can also use the `PNPM_HOME` // environment variable, which is only available in pnpm v7+, to // pass the check. // See: https://github.com/volta-cli/rfcs/pull/46#discussion_r861943740 let mut new_path = global_bin_dir; for (name, value) in command.get_envs() { if name == "PATH" { if let Some(old_path) = value { #[cfg(unix)] let path_delimiter = OsStr::new(":"); #[cfg(windows)] let path_delimiter = OsStr::new(";"); new_path = PathBuf::from([new_path.as_os_str(), old_path].join(path_delimiter)); break; } } } command.env("PATH", new_path); } } /// Determine the name of the package that was installed into the `package_root` /// /// If there are none or more than one package installed, then we return None pub(super) fn get_installed_package(self, package_root: PathBuf) -> Option { match self { PackageManager::Npm => get_npm_package_name(self.source_dir(package_root)), PackageManager::Pnpm | PackageManager::Yarn => { get_pnpm_or_yarn_package_name(self.source_root(package_root)) } } } } /// Determine the package name for an npm global install /// /// npm doesn't hoist the packages inside of `node_modules`, so the only directory will be the /// globally installed package. fn get_npm_package_name(mut source_dir: PathBuf) -> Option { let possible_name = get_single_directory_name(&source_dir)?; // If the directory starts with `@`, that represents a scoped package, so we need to step // a level deeper to determine the full package name (`@scope/package`) if possible_name.starts_with('@') { source_dir.push(&possible_name); let package = get_single_directory_name(&source_dir)?; Some(format!("{}/{}", possible_name, package)) } else { Some(possible_name) } } /// Return the name of the single subdirectory (if any) to the given `parent_dir` /// /// If there are more than one subdirectory, then this will return `None` fn get_single_directory_name(parent_dir: &Path) -> Option { let mut entries = read_dir_eager(parent_dir) .ok()? .filter_map(|(entry, metadata)| { // If the entry is a symlink, _both_ is_dir() _and_ is_file() will be false. We want to // include symlinks as well as directories in our search, since `npm link` uses // symlinks internally, so we only exclude files from this search if !metadata.is_file() { Some(entry) } else { None } }); match (entries.next(), entries.next()) { (Some(entry), None) => entry.file_name().into_string().ok(), _ => None, } } /// Determine the package name for a pnpm or Yarn global install /// /// pnpm/Yarn creates a `package.json` file with the globally installed package as a dependency fn get_pnpm_or_yarn_package_name(source_root: PathBuf) -> Option { let package_file = source_root.join("package.json"); let file = File::open(package_file).ok()?; let manifest: GlobalYarnManifest = serde_json::de::from_reader(file).ok()?; let mut dependencies = manifest.dependencies.into_iter(); match (dependencies.next(), dependencies.next()) { // If there is exactly one dependency, we return it (Some((key, _)), None) => Some(key), // Otherwise, we can't determine the package name _ => None, } } ================================================ FILE: crates/volta-core/src/tool/package/metadata.rs ================================================ use std::collections::HashMap; use std::fs::File; use std::io; use std::path::Path; use super::manager::PackageManager; use crate::error::{Context, ErrorKind, Fallible, VoltaError}; use crate::layout::volta_home; use crate::platform::PlatformSpec; use crate::version::{option_version_serde, version_serde}; use fs_utils::ensure_containing_dir_exists; use node_semver::Version; /// Configuration information about an installed package /// /// Will be stored in `/tools/user/packages/.json` #[derive(serde::Serialize, serde::Deserialize, PartialOrd, Ord, PartialEq, Eq)] pub struct PackageConfig { /// The package name pub name: String, /// The package version #[serde(with = "version_serde")] pub version: Version, /// The platform used to install this package #[serde(with = "RawPlatformSpec")] pub platform: PlatformSpec, /// The binaries installed by this package pub bins: Vec, /// The package manager that was used to install this package pub manager: PackageManager, } impl PackageConfig { /// Parse a `PackageConfig` instance from a config file pub fn from_file

(file: P) -> Fallible where P: AsRef, { let config = File::open(&file).with_context(|| ErrorKind::ReadPackageConfigError { file: file.as_ref().to_owned(), })?; serde_json::from_reader(config).with_context(|| ErrorKind::ParsePackageConfigError) } pub fn from_file_if_exists

(file: P) -> Fallible> where P: AsRef, { match File::open(&file) { Err(error) => { if error.kind() == io::ErrorKind::NotFound { Ok(None) } else { Err(VoltaError::from_source( error, ErrorKind::ReadPackageConfigError { file: file.as_ref().to_owned(), }, )) } } Ok(config) => serde_json::from_reader(config) .with_context(|| ErrorKind::ParsePackageConfigError) .map(Some), } } /// Write this `PackageConfig` into the appropriate config file pub fn write(self) -> Fallible<()> { let config_file_path = volta_home()?.default_package_config_file(&self.name); ensure_containing_dir_exists(&config_file_path).with_context(|| { ErrorKind::ContainingDirError { path: config_file_path.clone(), } })?; let file = File::create(&config_file_path).with_context(|| { ErrorKind::WritePackageConfigError { file: config_file_path, } })?; serde_json::to_writer_pretty(file, &self) .with_context(|| ErrorKind::StringifyPackageConfigError) } } /// Configuration information about a single installed binary from a package /// /// Will be stored in /tools/user/bins/.json #[derive(serde::Serialize, serde::Deserialize)] pub struct BinConfig { /// The binary name pub name: String, /// The package that installed the binary pub package: String, /// The package version #[serde(with = "version_serde")] pub version: Version, /// The platform used to install this binary #[serde(with = "RawPlatformSpec")] pub platform: PlatformSpec, /// The package manager used to install this binary pub manager: PackageManager, } impl BinConfig { /// Parse a `BinConfig` instance from the given config file pub fn from_file

(file: P) -> Fallible where P: AsRef, { let config = File::open(&file).with_context(|| ErrorKind::ReadBinConfigError { file: file.as_ref().to_owned(), })?; serde_json::from_reader(config).with_context(|| ErrorKind::ParseBinConfigError) } pub fn from_file_if_exists

(file: P) -> Fallible> where P: AsRef, { match File::open(&file) { Err(error) => { if error.kind() == io::ErrorKind::NotFound { Ok(None) } else { Err(VoltaError::from_source( error, ErrorKind::ReadBinConfigError { file: file.as_ref().to_owned(), }, )) } } Ok(config) => serde_json::from_reader(config) .with_context(|| ErrorKind::ParseBinConfigError) .map(Some), } } /// Write this `BinConfig` to the appropriate config file pub fn write(self) -> Fallible<()> { let config_file_path = volta_home()?.default_tool_bin_config(&self.name); ensure_containing_dir_exists(&config_file_path).with_context(|| { ErrorKind::ContainingDirError { path: config_file_path.clone(), } })?; let file = File::create(&config_file_path).with_context(|| ErrorKind::WriteBinConfigError { file: config_file_path, })?; serde_json::to_writer_pretty(file, &self) .with_context(|| ErrorKind::StringifyBinConfigError) } } #[derive(serde::Serialize, serde::Deserialize)] #[serde(remote = "PlatformSpec")] struct RawPlatformSpec { #[serde(with = "version_serde")] node: Version, #[serde(with = "option_version_serde")] npm: Option, // The magic: // `serde(default)` to assign the pnpm field with a default value, this // ensures a seamless migration is performed from the previous package // platformspec which did not have a pnpm field despite the same layout.v3 #[serde(default)] #[serde(with = "option_version_serde")] pnpm: Option, #[serde(with = "option_version_serde")] yarn: Option, } /// The relevant information we need out of a package's `package.json` file /// /// This includes the exact Version (since we can install using a range) /// and the list of bins provided by the package. #[derive(serde::Deserialize)] pub struct PackageManifest { /// The name of the package pub name: String, /// The version of the package #[serde(deserialize_with = "version_serde::deserialize")] pub version: Version, /// The `bin` section, containing a map of binary names to locations #[serde(default, deserialize_with = "serde_bins::deserialize")] pub bin: Vec, } impl PackageManifest { /// Parse the `package.json` for a given package directory pub fn for_dir(package: &str, package_root: &Path) -> Fallible { let package_file = package_root.join("package.json"); let file = File::open(package_file).with_context(|| ErrorKind::PackageManifestReadError { package: package.into(), })?; let mut manifest: Self = serde_json::de::from_reader(file).with_context(|| { ErrorKind::PackageManifestParseError { package: package.into(), } })?; // If the bin list contains only an empty string, that means `bin` was a string value, // rather than a map. In that case, to match `npm`s behavior, we use the name of the package // as the bin name. // Note: For a scoped package, we should remove the scope and only use the package name if manifest.bin == [""] { manifest.bin.pop(); manifest.bin.push(default_binary_name(&manifest.name)); } Ok(manifest) } } #[derive(serde::Deserialize)] /// Struct to read the `dependencies` out of Yarn's global manifest. /// /// For global installs, yarn creates a `package.json` file in the `global-folder` and installs /// global packages as dependencies of that pseudo-package pub(super) struct GlobalYarnManifest { #[serde(default)] pub dependencies: HashMap, } mod serde_bins { use std::fmt; use serde::de::{Deserializer, Error, MapAccess, Visitor}; pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(BinMapVisitor) } struct BinMapVisitor; impl<'de> Visitor<'de> for BinMapVisitor { type Value = Vec; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("string or map") } // Handle String values with only the path fn visit_str(self, _path: &str) -> Result where E: Error, { // Use an empty string as a placeholder for the binary name, since at this level we // don't know the binary name for sure (npm uses the package name in this case) Ok(vec![String::new()]) } // Handle maps of Name -> Path fn visit_map(self, mut access: M) -> Result where M: MapAccess<'de>, { let mut bins = Vec::new(); while let Some((name, _)) = access.next_entry::()? { // Bin names that include path separators are invalid, as they would then point to // other locations on the filesystem. To match the behavior of npm & Yarn, we // filter those values out of the list of bins. if !name.contains(&['/', '\\'][..]) { bins.push(name); } } Ok(bins) } } } /// Determine the default binary name from the package name /// /// For non-scoped packages, this is just the package name /// For scoped packages, to match the behavior of the package managers, we remove the scope and use /// only the package part, e.g. `@microsoft/rush` would have a default name of `rush` fn default_binary_name(package_name: &str) -> String { if package_name.starts_with('@') { let mut chars = package_name.chars(); loop { match chars.next() { Some('/') | None => break, _ => {} } } let name = chars.as_str(); if name.is_empty() { package_name.to_string() } else { name.to_string() } } else { package_name.to_string() } } #[cfg(test)] mod tests { use super::default_binary_name; #[test] fn default_binary_uses_full_name_if_unscoped() { assert_eq!(default_binary_name("my-package"), "my-package"); } #[test] fn default_binary_removes_scope() { assert_eq!(default_binary_name("@scope/my-package"), "my-package"); } } ================================================ FILE: crates/volta-core/src/tool/package/mod.rs ================================================ use std::fmt::{self, Display}; use std::fs::create_dir_all; use std::path::{Path, PathBuf}; use std::process::Command; use super::Tool; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{remove_dir_if_exists, rename, symlink_dir}; use crate::layout::volta_home; use crate::platform::{Image, PlatformSpec}; use crate::session::Session; use crate::style::{success_prefix, tool_version}; use crate::sync::VoltaLock; use crate::version::VersionSpec; use fs_utils::ensure_containing_dir_exists; use log::info; use tempfile::{tempdir_in, TempDir}; mod configure; mod install; mod manager; mod metadata; mod uninstall; pub use manager::PackageManager; pub use metadata::{BinConfig, PackageConfig, PackageManifest}; pub use uninstall::uninstall; /// The Tool implementation for installing 3rd-party global packages pub struct Package { name: String, version: VersionSpec, staging: TempDir, } impl Package { pub fn new(name: String, version: VersionSpec) -> Fallible { let staging = setup_staging_directory(PackageManager::Npm, NeedsScope::No)?; Ok(Package { name, version, staging, }) } pub fn run_install(&self, platform_image: &Image) -> Fallible<()> { install::run_global_install( self.to_string(), self.staging.path().to_owned(), platform_image, ) } pub fn complete_install(self, image: &Image) -> Fallible { let manager = PackageManager::Npm; let manifest = configure::parse_manifest(&self.name, self.staging.path().to_owned(), manager)?; persist_install(&self.name, &self.version, self.staging.path())?; link_package_to_shared_dir(&self.name, manager)?; configure::write_config_and_shims(&self.name, &manifest, image, manager)?; Ok(manifest) } } impl Tool for Package { fn fetch(self: Box, _session: &mut Session) -> Fallible<()> { Err(ErrorKind::CannotFetchPackage { package: self.to_string(), } .into()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { let _lock = VoltaLock::acquire(); let default_image = session .default_platform()? .map(PlatformSpec::as_default) .ok_or(ErrorKind::NoPlatform)? .checkout(session)?; self.run_install(&default_image)?; let manifest = self.complete_install(&default_image)?; let bins = manifest.bin.join(", "); if bins.is_empty() { info!( "{} installed {}", success_prefix(), tool_version(manifest.name, manifest.version) ); } else { info!( "{} installed {} with executables: {}", success_prefix(), tool_version(manifest.name, manifest.version), bins ); } Ok(()) } fn pin(self: Box, _session: &mut Session) -> Fallible<()> { Err(ErrorKind::CannotPinPackage { package: self.name }.into()) } } impl Display for Package { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.version { VersionSpec::None => f.write_str(&self.name), _ => f.write_str(&tool_version(&self.name, &self.version)), } } } /// Helper struct for direct installs through `npm i -g` or `yarn global add` /// /// Provides methods to simplify installing into a staging directory and then moving that install /// into the proper location after it is complete. /// /// Note: We don't always know the name of the package up-front, as the install could be from a /// tarball or a git coordinate. If we do know ahead of time, then we can skip looking it up pub struct DirectInstall { staging: TempDir, manager: PackageManager, name: Option, } impl DirectInstall { pub fn new(manager: PackageManager) -> Fallible { let staging = setup_staging_directory(manager, NeedsScope::No)?; Ok(DirectInstall { staging, manager, name: None, }) } pub fn with_name(manager: PackageManager, name: String) -> Fallible { let staging = setup_staging_directory(manager, name.contains('/').into())?; Ok(DirectInstall { staging, manager, name: Some(name), }) } pub fn setup_command(&self, command: &mut Command) { self.manager .setup_global_command(command, self.staging.path().to_owned()); } pub fn complete_install(self, image: &Image) -> Fallible<()> { let DirectInstall { staging, name, manager, } = self; let name = name .or_else(|| manager.get_installed_package(staging.path().to_owned())) .ok_or(ErrorKind::InstalledPackageNameError)?; let manifest = configure::parse_manifest(&name, staging.path().to_owned(), manager)?; persist_install(&name, &manifest.version, staging.path())?; link_package_to_shared_dir(&name, manager)?; configure::write_config_and_shims(&name, &manifest, image, manager) } } /// Helper struct for direct in-place upgrades using `npm update -g` or `yarn global upgrade` /// /// Upgrades the requested package directly in the image directory pub struct InPlaceUpgrade { package: String, directory: PathBuf, manager: PackageManager, } impl InPlaceUpgrade { pub fn new(package: String, manager: PackageManager) -> Fallible { let directory = volta_home()?.package_image_dir(&package); Ok(Self { package, directory, manager, }) } /// Check for possible failure cases with the package to be upgraded /// - The package is not installed as a global /// - The package exists, but was installed with a different package manager pub fn check_upgraded_package(&self) -> Fallible<()> { let config = PackageConfig::from_file(volta_home()?.default_package_config_file(&self.package)) .with_context(|| ErrorKind::UpgradePackageNotFound { package: self.package.clone(), manager: self.manager, })?; if config.manager != self.manager { Err(ErrorKind::UpgradePackageWrongManager { package: self.package.clone(), manager: config.manager, } .into()) } else { Ok(()) } } pub fn setup_command(&self, command: &mut Command) { self.manager .setup_global_command(command, self.directory.clone()); } pub fn complete_upgrade(self, image: &Image) -> Fallible<()> { let manifest = configure::parse_manifest(&self.package, self.directory, self.manager)?; link_package_to_shared_dir(&self.package, self.manager)?; configure::write_config_and_shims(&self.package, &manifest, image, self.manager) } } #[derive(Clone, Copy, PartialEq)] enum NeedsScope { Yes, No, } impl From for NeedsScope { fn from(value: bool) -> Self { if value { NeedsScope::Yes } else { NeedsScope::No } } } /// Create the temporary staging directory we will use to install and ensure expected /// subdirectories exist within it fn setup_staging_directory(manager: PackageManager, needs_scope: NeedsScope) -> Fallible { // Workaround to ensure relative symlinks continue to work. // The final installed location of packages is: // $VOLTA_HOME/tools/image/packages/{name}/ // To ensure that the temp directory has the same amount of nesting, we use: // $VOLTA_HOME/tmp/image/packages/{tempdir}/ // This way any relative symlinks will have the same amount of nesting and will remain valid // even when the directory is persisted. // We also need to handle the case when the linked package has a scope, which requires another // level of nesting let mut staging_root = volta_home()?.tmp_dir().to_owned(); staging_root.push("image"); staging_root.push("packages"); if needs_scope == NeedsScope::Yes { staging_root.push("scope"); } create_dir_all(&staging_root).with_context(|| ErrorKind::ContainingDirError { path: staging_root.clone(), })?; let staging = tempdir_in(&staging_root).with_context(|| ErrorKind::CreateTempDirError { in_dir: staging_root, })?; let source_dir = manager.source_dir(staging.path().to_owned()); ensure_containing_dir_exists(&source_dir) .with_context(|| ErrorKind::ContainingDirError { path: source_dir })?; let binary_dir = manager.binary_dir(staging.path().to_owned()); ensure_containing_dir_exists(&binary_dir) .with_context(|| ErrorKind::ContainingDirError { path: binary_dir })?; Ok(staging) } fn persist_install(package_name: &str, package_version: V, staging_dir: &Path) -> Fallible<()> where V: Display, { let package_dir = volta_home()?.package_image_dir(package_name); remove_dir_if_exists(&package_dir)?; // Handle scoped packages (@vue/cli), which have an extra directory for the scope ensure_containing_dir_exists(&package_dir).with_context(|| ErrorKind::ContainingDirError { path: package_dir.to_owned(), })?; rename(staging_dir, &package_dir).with_context(|| ErrorKind::SetupToolImageError { tool: package_name.into(), version: package_version.to_string(), dir: package_dir, })?; Ok(()) } fn link_package_to_shared_dir(package_name: &str, manager: PackageManager) -> Fallible<()> { let home = volta_home()?; let mut source = manager.source_dir(home.package_image_dir(package_name)); source.push(package_name); let target = home.shared_lib_dir(package_name); remove_dir_if_exists(&target)?; // Handle scoped packages (@vue/cli), which have an extra directory for the scope ensure_containing_dir_exists(&target).with_context(|| ErrorKind::ContainingDirError { path: target.clone(), })?; symlink_dir(source, target).with_context(|| ErrorKind::CreateSharedLinkError { name: package_name.into(), }) } ================================================ FILE: crates/volta-core/src/tool/package/uninstall.rs ================================================ use super::metadata::{BinConfig, PackageConfig}; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{ dir_entry_match, ok_if_not_found, read_dir_eager, remove_dir_if_exists, remove_file_if_exists, }; use crate::layout::volta_home; use crate::shim; use crate::style::success_prefix; use crate::sync::VoltaLock; use log::{info, warn}; /// Uninstalls the specified package. /// /// This removes: /// /// - The JSON configuration files for both the package and its bins /// - The shims for the package bins /// - The package directory itself pub fn uninstall(name: &str) -> Fallible<()> { let home = volta_home()?; // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); // If the package config file exists, use that to remove any installed bins and shims let package_config_file = home.default_package_config_file(name); let package_found = match PackageConfig::from_file_if_exists(&package_config_file)? { None => { // there is no package config - check for orphaned binaries let package_binary_list = binaries_from_package(name)?; if !package_binary_list.is_empty() { for bin_name in package_binary_list { remove_config_and_shim(&bin_name, name)?; } true } else { false } } Some(package_config) => { for bin_name in package_config.bins { remove_config_and_shim(&bin_name, name)?; } remove_file_if_exists(package_config_file)?; true } }; remove_shared_link_dir(name)?; // Remove the package directory itself let package_image_dir = home.package_image_dir(name); remove_dir_if_exists(package_image_dir)?; if package_found { info!("{} package '{}' uninstalled", success_prefix(), name); } else { warn!("No package '{}' found to uninstall", name); } Ok(()) } /// Remove a shim and its associated configuration file fn remove_config_and_shim(bin_name: &str, pkg_name: &str) -> Fallible<()> { shim::delete(bin_name)?; let config_file = volta_home()?.default_tool_bin_config(bin_name); remove_file_if_exists(config_file)?; info!( "Removed executable '{}' installed by '{}'", bin_name, pkg_name ); Ok(()) } /// Reads the contents of a directory and returns a Vec containing the names of /// all the binaries installed by the given package. fn binaries_from_package(package: &str) -> Fallible> { let bin_config_dir = volta_home()?.default_bin_dir(); dir_entry_match(bin_config_dir, |entry| { let path = entry.path(); if let Ok(config) = BinConfig::from_file(path) { if config.package == package { return Some(config.name); } } None }) .or_else(ok_if_not_found) .with_context(|| ErrorKind::ReadBinConfigDirError { dir: bin_config_dir.to_owned(), }) } /// Remove the link to the package in the shared lib directory /// /// For scoped packages, if the scope directory is now empty, it will also be removed fn remove_shared_link_dir(name: &str) -> Fallible<()> { // Remove the link in the shared package directory, if it exists let mut shared_lib_dir = volta_home()?.shared_lib_dir(name); remove_dir_if_exists(&shared_lib_dir)?; // For scoped packages, clean up the scope directory if it is now empty if name.starts_with('@') { shared_lib_dir.pop(); if let Ok(mut entries) = read_dir_eager(&shared_lib_dir) { if entries.next().is_none() { remove_dir_if_exists(&shared_lib_dir)?; } } } Ok(()) } ================================================ FILE: crates/volta-core/src/tool/pnpm/fetch.rs ================================================ //! Provides fetcher for pnpm distributions use std::fs::{write, File}; use std::path::Path; use archive::{Archive, Tarball}; use fs_utils::ensure_containing_dir_exists; use log::debug; use node_semver::Version; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{create_staging_dir, create_staging_file, rename, set_executable}; use crate::hook::ToolHooks; use crate::layout::volta_home; use crate::style::{progress_bar, tool_version}; use crate::tool::registry::public_registry_package; use crate::tool::{self, download_tool_error, Pnpm}; use crate::version::VersionSpec; pub fn fetch(version: &Version, hooks: Option<&ToolHooks>) -> Fallible<()> { let pnpm_dir = volta_home()?.pnpm_inventory_dir(); let cache_file = pnpm_dir.join(Pnpm::archive_filename(&version.to_string())); let (archive, staging) = match load_cached_distro(&cache_file) { Some(archive) => { debug!( "Loading {} from cached archive at '{}'", tool_version("pnpm", version), cache_file.display(), ); (archive, None) } None => { let staging = create_staging_file()?; let remote_url = determine_remote_url(version, hooks)?; let archive = fetch_remote_distro(version, &remote_url, staging.path())?; (archive, Some(staging)) } }; unpack_archive(archive, version)?; if let Some(staging_file) = staging { ensure_containing_dir_exists(&cache_file).with_context(|| { ErrorKind::ContainingDirError { path: cache_file.clone(), } })?; staging_file .persist(cache_file) .with_context(|| ErrorKind::PersistInventoryError { tool: "pnpm".into(), })?; } Ok(()) } /// Unpack the pnpm archive into the image directory so that it is ready for use fn unpack_archive(archive: Box, version: &Version) -> Fallible<()> { let temp = create_staging_dir()?; debug!("Unpacking pnpm into '{}'", temp.path().display()); let progress = progress_bar( archive.origin(), &tool_version("pnpm", version), archive.compressed_size(), ); let version_string = version.to_string(); archive .unpack(temp.path(), &mut |_, read| { progress.inc(read as u64); }) .with_context(|| ErrorKind::UnpackArchiveError { tool: "pnpm".into(), version: version_string.clone(), })?; let bin_path = temp.path().join("package").join("bin"); write_launcher(&bin_path, "pnpm")?; write_launcher(&bin_path, "pnpx")?; #[cfg(windows)] { write_cmd_launcher(&bin_path, "pnpm")?; write_cmd_launcher(&bin_path, "pnpx")?; } let dest = volta_home()?.pnpm_image_dir(&version_string); ensure_containing_dir_exists(&dest) .with_context(|| ErrorKind::ContainingDirError { path: dest.clone() })?; rename(temp.path().join("package"), &dest).with_context(|| ErrorKind::SetupToolImageError { tool: "pnpm".into(), version: version_string.clone(), dir: dest.clone(), })?; progress.finish_and_clear(); // Note: We write this after the progress bar is finished to avoid display bugs with re-renders of the progress debug!("Installing pnpm in '{}'", dest.display()); Ok(()) } /// Return the archive if it is valid. It may have been corrupted or interrupted in the middle of /// downloading. // ISSUE(#134) - verify checksum fn load_cached_distro(file: &Path) -> Option> { if file.is_file() { let file = File::open(file).ok()?; Tarball::load(file).ok() } else { None } } /// Determine the remote URL to download from, using the hooks if avaialble fn determine_remote_url(version: &Version, hooks: Option<&ToolHooks>) -> Fallible { let version_str = version.to_string(); match hooks { Some(&ToolHooks { distro: Some(ref hook), .. }) => { debug!("Using pnpm.distro hook to determine download URL"); let distro_file_name = Pnpm::archive_filename(&version_str); hook.resolve(version, &distro_file_name) } _ => Ok(public_registry_package("pnpm", &version_str)), } } /// Fetch the distro archive from the internet fn fetch_remote_distro( version: &Version, url: &str, staging_path: &Path, ) -> Fallible> { debug!("Downloading {} from {}", tool_version("pnpm", version), url); Tarball::fetch(url, staging_path).with_context(download_tool_error( tool::Spec::Pnpm(VersionSpec::Exact(version.clone())), url, )) } /// Create executable launchers for the pnpm and pnpx binaries fn write_launcher(base_path: &Path, tool: &str) -> Fallible<()> { let path = base_path.join(tool); write( &path, format!( r#"#!/bin/sh (set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix basedir=`dirname "$0"` case `uname` in *CYGWIN*) basedir=`cygpath -w "$basedir"`;; esac node "$basedir/{}.cjs" "$@" "#, tool ), ) .and_then(|_| set_executable(&path)) .with_context(|| ErrorKind::WriteLauncherError { tool: tool.into() }) } /// Create CMD executable launchers for the pnpm and pnpx binaries for Windows #[cfg(windows)] fn write_cmd_launcher(base_path: &Path, tool: &str) -> Fallible<()> { write( base_path.join(format!("{}.cmd", tool)), format!("@echo off\nnode \"%~dp0\\{}.cjs\" %*", tool), ) .with_context(|| ErrorKind::WriteLauncherError { tool: tool.into() }) } ================================================ FILE: crates/volta-core/src/tool/pnpm/mod.rs ================================================ use node_semver::Version; use std::fmt::{self, Display}; use crate::error::{ErrorKind, Fallible}; use crate::inventory::pnpm_available; use crate::session::Session; use crate::style::tool_version; use crate::sync::VoltaLock; use super::{ check_fetched, check_shim_reachable, debug_already_fetched, info_fetched, info_installed, info_pinned, info_project_version, FetchStatus, Tool, }; mod fetch; mod resolve; pub use resolve::resolve; /// The Tool implementation for fetching and installing pnpm pub struct Pnpm { pub(super) version: Version, } impl Pnpm { pub fn new(version: Version) -> Self { Pnpm { version } } pub fn archive_basename(version: &str) -> String { format!("pnpm-{}", version) } pub fn archive_filename(version: &str) -> String { format!("{}.tgz", Pnpm::archive_basename(version)) } pub(crate) fn ensure_fetched(&self, session: &mut Session) -> Fallible<()> { match check_fetched(|| pnpm_available(&self.version))? { FetchStatus::AlreadyFetched => { debug_already_fetched(self); Ok(()) } FetchStatus::FetchNeeded(_lock) => fetch::fetch(&self.version, session.hooks()?.pnpm()), } } } impl Tool for Pnpm { fn fetch(self: Box, session: &mut Session) -> Fallible<()> { self.ensure_fetched(session)?; info_fetched(self); Ok(()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); self.ensure_fetched(session)?; session .toolchain_mut()? .set_active_pnpm(Some(self.version.clone()))?; info_installed(&self); check_shim_reachable("pnpm"); if let Ok(Some(project)) = session.project_platform() { if let Some(pnpm) = &project.pnpm { info_project_version(tool_version("pnpm", pnpm), &self); } } Ok(()) } fn pin(self: Box, session: &mut Session) -> Fallible<()> { if session.project()?.is_some() { self.ensure_fetched(session)?; // Note: We know this will succeed, since we checked above let project = session.project_mut()?.unwrap(); project.pin_pnpm(Some(self.version.clone()))?; info_pinned(self); Ok(()) } else { Err(ErrorKind::NotInPackage.into()) } } } impl Display for Pnpm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&tool_version("pnpm", &self.version)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_pnpm_archive_basename() { assert_eq!(Pnpm::archive_basename("1.2.3"), "pnpm-1.2.3"); } #[test] fn test_pnpm_archive_filename() { assert_eq!(Pnpm::archive_filename("1.2.3"), "pnpm-1.2.3.tgz"); } } ================================================ FILE: crates/volta-core/src/tool/pnpm/resolve.rs ================================================ use log::debug; use node_semver::{Range, Version}; use crate::error::{ErrorKind, Fallible}; use crate::hook::ToolHooks; use crate::session::Session; use crate::tool::registry::{fetch_npm_registry, public_registry_index, PackageIndex}; use crate::tool::{PackageDetails, Pnpm}; use crate::version::{VersionSpec, VersionTag}; pub fn resolve(matching: VersionSpec, session: &mut Session) -> Fallible { let hooks = session.hooks()?.pnpm(); match matching { VersionSpec::Semver(requirement) => resolve_semver(requirement, hooks), VersionSpec::Exact(version) => Ok(version), VersionSpec::None | VersionSpec::Tag(VersionTag::Latest) => resolve_tag("latest", hooks), VersionSpec::Tag(tag) => resolve_tag(&tag.to_string(), hooks), } } fn resolve_tag(tag: &str, hooks: Option<&ToolHooks>) -> Fallible { let (url, mut index) = fetch_pnpm_index(hooks)?; match index.tags.remove(tag) { Some(version) => { debug!("Found pnpm@{} matching tag '{}' from {}", version, tag, url); Ok(version) } None => Err(ErrorKind::PnpmVersionNotFound { matching: tag.into(), } .into()), } } fn resolve_semver(matching: Range, hooks: Option<&ToolHooks>) -> Fallible { let (url, index) = fetch_pnpm_index(hooks)?; let details_opt = index .entries .into_iter() .find(|PackageDetails { version, .. }| matching.satisfies(version)); match details_opt { Some(details) => { debug!( "Found pnpm@{} matching requirement '{}' from {}", details.version, matching, url ); Ok(details.version) } None => Err(ErrorKind::PnpmVersionNotFound { matching: matching.to_string(), } .into()), } } /// Fetch the index of available pnpm versions from the npm registry fn fetch_pnpm_index(hooks: Option<&ToolHooks>) -> Fallible<(String, PackageIndex)> { let url = match hooks { Some(&ToolHooks { index: Some(ref hook), .. }) => { debug!("Using pnpm.index hook to determine pnpm index URL"); hook.resolve("pnpm")? } _ => public_registry_index("pnpm"), }; fetch_npm_registry(url, "pnpm") } ================================================ FILE: crates/volta-core/src/tool/registry.rs ================================================ use std::collections::HashMap; use std::path::{Path, PathBuf}; use super::registry_fetch_error; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::read_dir_eager; use crate::style::progress_spinner; use crate::version::{hashmap_version_serde, version_serde}; use attohttpc::header::ACCEPT; use attohttpc::Response; use cfg_if::cfg_if; use node_semver::Version; use serde::Deserialize; // Accept header needed to request the abbreviated metadata from the npm registry // See https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md pub const NPM_ABBREVIATED_ACCEPT_HEADER: &str = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; cfg_if! { if #[cfg(feature = "mock-network")] { // TODO: We need to reconsider our mocking strategy in light of mockito deprecating the // SERVER_URL constant: Since our acceptance tests run the binary in a separate process, // we can't use `mockito::server_url()`, which relies on shared memory. #[allow(deprecated)] const SERVER_URL: &str = mockito::SERVER_URL; pub fn public_registry_index(package: &str) -> String { format!("{}/{}", SERVER_URL, package) } } else { pub fn public_registry_index(package: &str) -> String { format!("https://registry.npmjs.org/{}", package) } } } // fetch a registry that returns info in Npm format pub fn fetch_npm_registry(url: String, name: &str) -> Fallible<(String, PackageIndex)> { let spinner = progress_spinner(format!("Fetching npm registry: {}", url)); let metadata: RawPackageMetadata = attohttpc::get(&url) .header(ACCEPT, NPM_ABBREVIATED_ACCEPT_HEADER) .send() .and_then(Response::error_for_status) .and_then(Response::json) .with_context(registry_fetch_error(name, &url))?; spinner.finish_and_clear(); Ok((url, metadata.into())) } pub fn public_registry_package(package: &str, version: &str) -> String { format!( "{}/-/{}-{}.tgz", public_registry_index(package), package, version ) } // need package and filename for namespaced tools like @yarnpkg/cli-dist, which is located at // https://registry.npmjs.org/@yarnpkg/cli-dist/-/cli-dist-1.2.3.tgz pub fn scoped_public_registry_package(scope: &str, package: &str, version: &str) -> String { format!( "{}/{}/-/{}-{}.tgz", public_registry_index(scope), package, package, version ) } /// Figure out the unpacked package directory name dynamically /// /// Packages typically extract to a "package" directory, but not always pub fn find_unpack_dir(in_dir: &Path) -> Fallible { let dirs: Vec<_> = read_dir_eager(in_dir) .with_context(|| ErrorKind::PackageUnpackError)? .collect(); // if there is only one directory, return that if let [(entry, metadata)] = dirs.as_slice() { if metadata.is_dir() { return Ok(entry.path()); } } // there is more than just a single directory here, something is wrong Err(ErrorKind::PackageUnpackError.into()) } /// Details about a package in the npm Registry #[derive(Debug)] pub struct PackageDetails { pub(crate) version: Version, } /// Index of versions of a specific package from the npm Registry pub struct PackageIndex { pub tags: HashMap, pub entries: Vec, } /// Package Metadata Response /// /// See npm registry API doc: /// https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md #[derive(Deserialize, Debug)] pub struct RawPackageMetadata { pub name: String, pub versions: HashMap, #[serde( rename = "dist-tags", deserialize_with = "hashmap_version_serde::deserialize" )] pub dist_tags: HashMap, } #[derive(Deserialize, Debug)] pub struct RawPackageVersionInfo { // there's a lot more in there, but right now just care about the version #[serde(with = "version_serde")] pub version: Version, pub dist: RawDistInfo, } #[derive(Deserialize, Clone, Debug)] pub struct RawDistInfo { pub shasum: String, pub tarball: String, } impl From for PackageIndex { fn from(serial: RawPackageMetadata) -> PackageIndex { let mut entries: Vec = serial .versions .into_values() .map(|version_info| PackageDetails { version: version_info.version, }) .collect(); entries.sort_by(|a, b| b.version.cmp(&a.version)); PackageIndex { tags: serial.dist_tags, entries, } } } ================================================ FILE: crates/volta-core/src/tool/serial.rs ================================================ use std::cmp::Ordering; use super::Spec; use crate::error::{ErrorKind, Fallible}; use crate::version::{VersionSpec, VersionTag}; use once_cell::sync::Lazy; use regex::Regex; use validate_npm_package_name::{validate, Validity}; static TOOL_SPEC_PATTERN: Lazy = Lazy::new(|| { Regex::new("^(?P(?:@([^/]+?)[/])?([^/]+?))(@(?P.+))?$").expect("regex is valid") }); static HAS_VERSION: Lazy = Lazy::new(|| Regex::new(r"^[^\s]+@").expect("regex is valid")); /// Methods for parsing a Spec out of string values impl Spec { pub fn from_str_and_version(tool_name: &str, version: VersionSpec) -> Self { match tool_name { "node" => Spec::Node(version), "npm" => Spec::Npm(version), "pnpm" => Spec::Pnpm(version), "yarn" => Spec::Yarn(version), package => Spec::Package(package.to_string(), version), } } /// Try to parse a tool and version from a string like `[@]. pub fn try_from_str(tool_spec: &str) -> Fallible { let captures = TOOL_SPEC_PATTERN .captures(tool_spec) .ok_or_else(|| ErrorKind::ParseToolSpecError { tool_spec: tool_spec.into(), })?; // Validate that the captured name is a valid NPM package name. let name = &captures["name"]; if let Validity::Invalid { errors, .. } = validate(name) { return Err(ErrorKind::InvalidToolName { name: name.into(), errors, } .into()); } let version = captures .name("version") .map(|version| version.as_str().parse()) .transpose()? .unwrap_or_default(); Ok(match name { "node" => Spec::Node(version), "npm" => Spec::Npm(version), "pnpm" => Spec::Pnpm(version), "yarn" => Spec::Yarn(version), package => Spec::Package(package.into(), version), }) } /// Get a valid, sorted `Vec` given a `Vec`. /// /// Accounts for the following error conditions: /// /// - `volta install node 12`, where the user intended to install `node@12` /// but used syntax like in nodenv or nvm /// - invalid version specs /// /// Returns a listed sorted so that if `node` is included in the list, it is /// always first. pub fn from_strings(tool_strs: &[T], action: &str) -> Fallible> where T: AsRef, { Self::check_args(tool_strs, action)?; let mut tools = tool_strs .iter() .map(|arg| Self::try_from_str(arg.as_ref())) .collect::>>()?; tools.sort_by(Self::sort_comparator); Ok(tools) } /// Check the args for the bad patterns of /// - `volta install ` /// - `volta install ` fn check_args(args: &[T], action: &str) -> Fallible<()> where T: AsRef, { let mut args = args.iter(); match (args.next(), args.next(), args.next()) { // The case we are concerned with here is where we have ``. // That is, exactly one argument, which is a valid version specifier. // // - `volta install node@12` is allowed. // - `volta install 12` is an error. // - `volta install lts` is an error. (Some(maybe_version), None, None) if is_version_like(maybe_version.as_ref()) => { Err(ErrorKind::InvalidInvocationOfBareVersion { action: action.to_string(), version: maybe_version.as_ref().to_string(), } .into()) } // The case we are concerned with here is where we have ` `. // This is only interesting if there are exactly two args. Then we care // whether the two items are a bare name (with no `@version`), followed // by a valid version specifier (ignoring custom tags). That is: // // - `volta install node@lts latest` is allowed. // - `volta install node latest` is an error. // - `volta install node latest yarn` is allowed. (Some(name), Some(maybe_version), None) if !HAS_VERSION.is_match(name.as_ref()) && is_version_like(maybe_version.as_ref()) => { Err(ErrorKind::InvalidInvocation { action: action.to_string(), name: name.as_ref().to_string(), version: maybe_version.as_ref().to_string(), } .into()) } _ => Ok(()), } } /// Compare `Spec`s for sorting when converting from strings /// /// We want to preserve the original order as much as possible, so we treat tools in /// the same tool category as equal. We still need to pull Node to the front of the /// list, followed by Npm, pnpm, Yarn, and then Packages last. fn sort_comparator(left: &Spec, right: &Spec) -> Ordering { match (left, right) { (Spec::Node(_), Spec::Node(_)) => Ordering::Equal, (Spec::Node(_), _) => Ordering::Less, (_, Spec::Node(_)) => Ordering::Greater, (Spec::Npm(_), Spec::Npm(_)) => Ordering::Equal, (Spec::Npm(_), _) => Ordering::Less, (_, Spec::Npm(_)) => Ordering::Greater, (Spec::Pnpm(_), Spec::Pnpm(_)) => Ordering::Equal, (Spec::Pnpm(_), _) => Ordering::Less, (_, Spec::Pnpm(_)) => Ordering::Greater, (Spec::Yarn(_), Spec::Yarn(_)) => Ordering::Equal, (Spec::Yarn(_), _) => Ordering::Less, (_, Spec::Yarn(_)) => Ordering::Greater, (Spec::Package(_, _), Spec::Package(_, _)) => Ordering::Equal, } } } /// Determine if a given string is "version-like". /// /// This means it is either 'latest', 'lts', a Version, or a Version Range. fn is_version_like(value: &str) -> bool { matches!( value.parse(), Ok(VersionSpec::Exact(_)) | Ok(VersionSpec::Semver(_)) | Ok(VersionSpec::Tag(VersionTag::Latest)) | Ok(VersionSpec::Tag(VersionTag::Lts)) ) } #[cfg(test)] mod tests { mod try_from_str { use std::str::FromStr as _; use super::super::super::Spec; use crate::version::{VersionSpec, VersionTag}; const LTS: &str = "lts"; const LATEST: &str = "latest"; const MAJOR: &str = "3"; const MINOR: &str = "3.0"; const PATCH: &str = "3.0.0"; const BETA: &str = "beta"; /// Convenience macro for generating the @ string. macro_rules! versioned_tool { ($tool:expr, $version:expr) => { format!("{}@{}", $tool, $version) }; } #[test] fn parses_bare_node() { assert_eq!( Spec::try_from_str("node").expect("succeeds"), Spec::Node(VersionSpec::default()) ); } #[test] fn parses_node_with_valid_versions() { let tool = "node"; assert_eq!( Spec::try_from_str(&versioned_tool!(tool, MAJOR)).expect("succeeds"), Spec::Node(VersionSpec::from_str(MAJOR).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, MINOR)).expect("succeeds"), Spec::Node(VersionSpec::from_str(MINOR).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, PATCH)).expect("succeeds"), Spec::Node(VersionSpec::from_str(PATCH).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, LATEST)).expect("succeeds"), Spec::Node(VersionSpec::Tag(VersionTag::Latest)) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, LTS)).expect("succeeds"), Spec::Node(VersionSpec::Tag(VersionTag::Lts)) ); } #[test] fn parses_bare_yarn() { assert_eq!( Spec::try_from_str("yarn").expect("succeeds"), Spec::Yarn(VersionSpec::default()) ); } #[test] fn parses_yarn_with_valid_versions() { let tool = "yarn"; assert_eq!( Spec::try_from_str(&versioned_tool!(tool, MAJOR)).expect("succeeds"), Spec::Yarn(VersionSpec::from_str(MAJOR).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, MINOR)).expect("succeeds"), Spec::Yarn(VersionSpec::from_str(MINOR).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, PATCH)).expect("succeeds"), Spec::Yarn(VersionSpec::from_str(PATCH).expect("`VersionSpec` has its own tests")) ); assert_eq!( Spec::try_from_str(&versioned_tool!(tool, LATEST)).expect("succeeds"), Spec::Yarn(VersionSpec::Tag(VersionTag::Latest)) ); } #[test] fn parses_bare_packages() { let package = "ember-cli"; assert_eq!( Spec::try_from_str(package).expect("succeeds"), Spec::Package(package.into(), VersionSpec::default()) ); } #[test] fn parses_namespaced_packages() { let package = "@types/lodash"; assert_eq!( Spec::try_from_str(package).expect("succeeds"), Spec::Package(package.into(), VersionSpec::default()) ); } #[test] fn parses_bare_packages_with_valid_versions() { let package = "something-awesome"; assert_eq!( Spec::try_from_str(&versioned_tool!(package, MAJOR)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(MAJOR).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, MINOR)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(MINOR).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, PATCH)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(PATCH).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, LATEST)).expect("succeeds"), Spec::Package(package.into(), VersionSpec::Tag(VersionTag::Latest)) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, LTS)).expect("succeeds"), Spec::Package(package.into(), VersionSpec::Tag(VersionTag::Lts)) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, BETA)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::Tag(VersionTag::Custom(BETA.into())) ) ); } #[test] fn parses_namespaced_packages_with_valid_versions() { let package = "@something/awesome"; assert_eq!( Spec::try_from_str(&versioned_tool!(package, MAJOR)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(MAJOR).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, MINOR)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(MINOR).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, PATCH)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::from_str(PATCH).expect("`VersionSpec` has its own tests") ) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, LATEST)).expect("succeeds"), Spec::Package(package.into(), VersionSpec::Tag(VersionTag::Latest)) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, LTS)).expect("succeeds"), Spec::Package(package.into(), VersionSpec::Tag(VersionTag::Lts)) ); assert_eq!( Spec::try_from_str(&versioned_tool!(package, BETA)).expect("succeeds"), Spec::Package( package.into(), VersionSpec::Tag(VersionTag::Custom(BETA.into())) ) ); } } mod from_strings { use super::super::*; use std::str::FromStr; static PIN: &str = "pin"; #[test] fn special_cases_just_number() { let version = "1.2.3"; let args: Vec = vec![version.into()]; let err = Spec::from_strings(&args, PIN).unwrap_err(); assert_eq!( err.kind(), &ErrorKind::InvalidInvocationOfBareVersion { action: PIN.into(), version: version.into() }, "`volta number` results in the correct error" ); } #[test] fn special_cases_tool_space_number() { let name = "potato"; let version = "1.2.3"; let args: Vec = vec![name.into(), version.into()]; let err = Spec::from_strings(&args, PIN).unwrap_err(); assert_eq!( err.kind(), &ErrorKind::InvalidInvocation { action: PIN.into(), name: name.into(), version: version.into() }, "`volta tool number` results in the correct error" ); } #[test] fn leaves_other_scenarios_alone() { let empty: Vec<&str> = Vec::new(); assert_eq!( Spec::from_strings(&empty, PIN).expect("is ok").len(), empty.len(), "when there are no args" ); let only_one = ["node".to_owned()]; assert_eq!( Spec::from_strings(&only_one, PIN).expect("is ok").len(), only_one.len(), "when there is only one arg" ); let one_with_explicit_verson = ["10@latest".to_owned()]; assert_eq!( Spec::from_strings(&one_with_explicit_verson, PIN) .expect("is ok") .len(), only_one.len(), "when the sole arg is version-like but has an explicit version" ); let two_but_unmistakable = ["12".to_owned(), "node".to_owned()]; assert_eq!( Spec::from_strings(&two_but_unmistakable, PIN) .expect("is ok") .len(), two_but_unmistakable.len(), "when there are two args but the order is not likely to be a mistake" ); let two_but_valid_first = ["node@lts".to_owned(), "12".to_owned()]; assert_eq!( Spec::from_strings(&two_but_valid_first, PIN) .expect("is ok") .len(), two_but_valid_first.len(), "when there are two args but the first is a valid tool spec" ); let more_than_two_tools = ["node".to_owned(), "12".to_owned(), "yarn".to_owned()]; assert_eq!( Spec::from_strings(&more_than_two_tools, PIN) .expect("is ok") .len(), more_than_two_tools.len(), "when there are more than two args" ); } #[test] fn sorts_node_npm_yarn_to_front() { let multiple = [ "ember-cli@3".to_owned(), "yarn".to_owned(), "npm@5".to_owned(), "node@latest".to_owned(), ]; let expected = [ Spec::Node(VersionSpec::Tag(VersionTag::Latest)), Spec::Npm(VersionSpec::from_str("5").expect("requirement is valid")), Spec::Yarn(VersionSpec::default()), Spec::Package( "ember-cli".to_owned(), VersionSpec::from_str("3").expect("requirement is valid"), ), ]; assert_eq!(Spec::from_strings(&multiple, PIN).expect("is ok"), expected); } #[test] fn keeps_package_order_unchanged() { let packages_with_node = ["typescript@latest", "ember-cli@3", "node@lts", "mocha"]; let expected = [ Spec::Node(VersionSpec::Tag(VersionTag::Lts)), Spec::Package( "typescript".to_owned(), VersionSpec::Tag(VersionTag::Latest), ), Spec::Package( "ember-cli".to_owned(), VersionSpec::from_str("3").expect("requirement is valid"), ), Spec::Package("mocha".to_owned(), VersionSpec::default()), ]; assert_eq!( Spec::from_strings(&packages_with_node, PIN).expect("is ok"), expected ); } } } ================================================ FILE: crates/volta-core/src/tool/yarn/fetch.rs ================================================ //! Provides fetcher for Yarn distributions use std::fs::File; use std::path::Path; use super::super::download_tool_error; use super::super::registry::{ find_unpack_dir, public_registry_package, scoped_public_registry_package, }; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::{create_staging_dir, create_staging_file, rename, set_executable}; use crate::hook::YarnHooks; use crate::layout::volta_home; use crate::style::{progress_bar, tool_version}; use crate::tool::{self, Yarn}; use crate::version::VersionSpec; use archive::{Archive, Tarball}; use fs_utils::ensure_containing_dir_exists; use log::debug; use node_semver::Version; pub fn fetch(version: &Version, hooks: Option<&YarnHooks>) -> Fallible<()> { let yarn_dir = volta_home()?.yarn_inventory_dir(); let cache_file = yarn_dir.join(Yarn::archive_filename(&version.to_string())); let (archive, staging) = match load_cached_distro(&cache_file) { Some(archive) => { debug!( "Loading {} from cached archive at '{}'", tool_version("yarn", version), cache_file.display(), ); (archive, None) } None => { let staging = create_staging_file()?; let remote_url = determine_remote_url(version, hooks)?; let archive = fetch_remote_distro(version, &remote_url, staging.path())?; (archive, Some(staging)) } }; unpack_archive(archive, version)?; if let Some(staging_file) = staging { ensure_containing_dir_exists(&cache_file).with_context(|| { ErrorKind::ContainingDirError { path: cache_file.clone(), } })?; staging_file .persist(cache_file) .with_context(|| ErrorKind::PersistInventoryError { tool: "Yarn".into(), })?; } Ok(()) } /// Unpack the yarn archive into the image directory so that it is ready for use fn unpack_archive(archive: Box, version: &Version) -> Fallible<()> { let temp = create_staging_dir()?; debug!("Unpacking yarn into '{}'", temp.path().display()); let progress = progress_bar( archive.origin(), &tool_version("yarn", version), archive.compressed_size(), ); let version_string = version.to_string(); archive .unpack(temp.path(), &mut |_, read| { progress.inc(read as u64); }) .with_context(|| ErrorKind::UnpackArchiveError { tool: "Yarn".into(), version: version_string.clone(), })?; let unpack_dir = find_unpack_dir(temp.path())?; // "bin/yarn" is not executable in the @yarnpkg/cli-dist package ensure_bin_is_executable(&unpack_dir, "yarn")?; let dest = volta_home()?.yarn_image_dir(&version_string); ensure_containing_dir_exists(&dest) .with_context(|| ErrorKind::ContainingDirError { path: dest.clone() })?; rename(unpack_dir, &dest).with_context(|| ErrorKind::SetupToolImageError { tool: "Yarn".into(), version: version_string.clone(), dir: dest.clone(), })?; progress.finish_and_clear(); // Note: We write this after the progress bar is finished to avoid display bugs with re-renders of the progress debug!("Installing yarn in '{}'", dest.display()); Ok(()) } /// Return the archive if it is valid. It may have been corrupted or interrupted in the middle of /// downloading. // ISSUE(#134) - verify checksum fn load_cached_distro(file: &Path) -> Option> { if file.is_file() { let file = File::open(file).ok()?; Tarball::load(file).ok() } else { None } } /// Determine the remote URL to download from, using the hooks if available fn determine_remote_url(version: &Version, hooks: Option<&YarnHooks>) -> Fallible { let version_str = version.to_string(); match hooks { Some(&YarnHooks { distro: Some(ref hook), .. }) => { debug!("Using yarn.distro hook to determine download URL"); let distro_file_name = Yarn::archive_filename(&version_str); hook.resolve(version, &distro_file_name) } _ => { if version.major >= 2 { Ok(scoped_public_registry_package( "@yarnpkg", "cli-dist", &version_str, )) } else { Ok(public_registry_package("yarn", &version_str)) } } } } /// Fetch the distro archive from the internet fn fetch_remote_distro( version: &Version, url: &str, staging_path: &Path, ) -> Fallible> { debug!("Downloading {} from {}", tool_version("yarn", version), url); Tarball::fetch(url, staging_path).with_context(download_tool_error( tool::Spec::Yarn(VersionSpec::Exact(version.clone())), url, )) } fn ensure_bin_is_executable(unpack_dir: &Path, tool: &str) -> Fallible<()> { let exec_path = unpack_dir.join("bin").join(tool); set_executable(&exec_path).with_context(|| ErrorKind::SetToolExecutable { tool: tool.into() }) } ================================================ FILE: crates/volta-core/src/tool/yarn/metadata.rs ================================================ use std::collections::BTreeSet; use crate::version::version_serde; use node_semver::Version; use serde::Deserialize; /// The public Yarn index. pub struct YarnIndex { pub(super) entries: BTreeSet, } #[derive(Deserialize)] pub struct RawYarnIndex(Vec); #[derive(Deserialize)] pub struct RawYarnEntry { /// Yarn releases are given a tag name of the form "v$version" where $version /// is the release's version string. #[serde(with = "version_serde")] pub tag_name: Version, /// The GitHub API provides a list of assets. Some Yarn releases don't include /// a tarball, so we don't support them and remove them from the set of available /// Yarn versions. pub assets: Vec, } impl RawYarnEntry { /// Is this entry a full release, i.e., does this entry's asset list include a /// proper release tarball? fn is_full_release(&self) -> bool { let release_filename = &format!("yarn-v{}.tar.gz", self.tag_name)[..]; self.assets .iter() .any(|raw_asset| raw_asset.name == release_filename) } } #[derive(Deserialize)] pub struct RawYarnAsset { /// The filename of an asset included in a Yarn GitHub release. pub name: String, } impl From for YarnIndex { fn from(raw: RawYarnIndex) -> YarnIndex { let mut entries = BTreeSet::new(); for entry in raw.0 { if entry.is_full_release() { entries.insert(entry.tag_name); } } YarnIndex { entries } } } ================================================ FILE: crates/volta-core/src/tool/yarn/mod.rs ================================================ use std::fmt::{self, Display}; use super::{ check_fetched, check_shim_reachable, debug_already_fetched, info_fetched, info_installed, info_pinned, info_project_version, FetchStatus, Tool, }; use crate::error::{ErrorKind, Fallible}; use crate::inventory::yarn_available; use crate::session::Session; use crate::style::tool_version; use crate::sync::VoltaLock; use node_semver::Version; mod fetch; mod metadata; mod resolve; pub use resolve::resolve; /// The Tool implementation for fetching and installing Yarn pub struct Yarn { pub(super) version: Version, } impl Yarn { pub fn new(version: Version) -> Self { Yarn { version } } pub fn archive_basename(version: &str) -> String { format!("yarn-v{}", version) } pub fn archive_filename(version: &str) -> String { format!("{}.tar.gz", Yarn::archive_basename(version)) } pub(crate) fn ensure_fetched(&self, session: &mut Session) -> Fallible<()> { match check_fetched(|| yarn_available(&self.version))? { FetchStatus::AlreadyFetched => { debug_already_fetched(self); Ok(()) } FetchStatus::FetchNeeded(_lock) => fetch::fetch(&self.version, session.hooks()?.yarn()), } } } impl Tool for Yarn { fn fetch(self: Box, session: &mut Session) -> Fallible<()> { self.ensure_fetched(session)?; info_fetched(self); Ok(()) } fn install(self: Box, session: &mut Session) -> Fallible<()> { // Acquire a lock on the Volta directory, if possible, to prevent concurrent changes let _lock = VoltaLock::acquire(); self.ensure_fetched(session)?; session .toolchain_mut()? .set_active_yarn(Some(self.version.clone()))?; info_installed(&self); check_shim_reachable("yarn"); if let Ok(Some(project)) = session.project_platform() { if let Some(yarn) = &project.yarn { info_project_version(tool_version("yarn", yarn), &self); } } Ok(()) } fn pin(self: Box, session: &mut Session) -> Fallible<()> { if session.project()?.is_some() { self.ensure_fetched(session)?; // Note: We know this will succeed, since we checked above let project = session.project_mut()?.unwrap(); project.pin_yarn(Some(self.version.clone()))?; info_pinned(self); Ok(()) } else { Err(ErrorKind::NotInPackage.into()) } } } impl Display for Yarn { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&tool_version("yarn", &self.version)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_yarn_archive_basename() { assert_eq!(Yarn::archive_basename("1.2.3"), "yarn-v1.2.3"); } #[test] fn test_yarn_archive_filename() { assert_eq!(Yarn::archive_filename("1.2.3"), "yarn-v1.2.3.tar.gz"); } } ================================================ FILE: crates/volta-core/src/tool/yarn/resolve.rs ================================================ //! Provides resolution of Yarn requirements into specific versions use super::super::registry::{ fetch_npm_registry, public_registry_index, PackageDetails, PackageIndex, }; use super::super::registry_fetch_error; use super::metadata::{RawYarnIndex, YarnIndex}; use crate::error::{Context, ErrorKind, Fallible}; use crate::hook::{RegistryFormat, YarnHooks}; use crate::session::Session; use crate::style::progress_spinner; use crate::version::{parse_version, VersionSpec, VersionTag}; use attohttpc::Response; use log::debug; use node_semver::{Range, Version}; pub fn resolve(matching: VersionSpec, session: &mut Session) -> Fallible { let hooks = session.hooks()?.yarn(); match matching { VersionSpec::Semver(requirement) => resolve_semver(requirement, hooks), VersionSpec::Exact(version) => Ok(version), VersionSpec::None => resolve_tag(VersionTag::Latest, hooks), VersionSpec::Tag(tag) => resolve_tag(tag, hooks), } } fn resolve_tag(tag: VersionTag, hooks: Option<&YarnHooks>) -> Fallible { // This triage is complicated because we need to maintain the legacy behavior of hooks // First, if the tag is 'latest' and we have a 'latest' hook, we use the old behavior // Next, if the tag is 'latest' and we _do not_ have a 'latest' hook, we use the new behavior // Next, if the tag is _not_ 'latest' and we have an 'index' hook, we show an error since // the previous behavior did not support generic tags // Finally, we don't have any relevant hooks, so we can use the new behavior match (tag, hooks) { ( VersionTag::Latest, Some(&YarnHooks { latest: Some(ref hook), .. }), ) => { debug!("Using yarn.latest hook to determine latest-version URL"); // does yarn3 use latest-version? no resolve_latest_legacy(hook.resolve("latest-version")?) } (VersionTag::Latest, _) => resolve_custom_tag(VersionTag::Latest.to_string()), (tag, Some(&YarnHooks { index: Some(_), .. })) => Err(ErrorKind::YarnVersionNotFound { matching: tag.to_string(), } .into()), (tag, _) => resolve_custom_tag(tag.to_string()), } } fn resolve_semver(matching: Range, hooks: Option<&YarnHooks>) -> Fallible { // For semver, the triage is less complicated: The previous behavior _always_ used // the 'index' hook, so we can check for that to decide which behavior to use. // // If the user specifies a format for the registry, we use that. Otherwise Github format // is the default legacy behavior. if let Some(&YarnHooks { index: Some(ref hook), .. }) = hooks { debug!("Using yarn.index hook to determine yarn index URL"); match hook.format { RegistryFormat::Github => resolve_semver_legacy(matching, hook.resolve("releases")?), RegistryFormat::Npm => resolve_semver_npm(matching, hook.resolve("")?), } } else { resolve_semver_from_registry(matching) } } fn fetch_yarn_index(package: &str) -> Fallible<(String, PackageIndex)> { let url = public_registry_index(package); fetch_npm_registry(url, "Yarn") } fn resolve_custom_tag(tag: String) -> Fallible { // first try yarn2+, which uses "@yarnpkg/cli-dist" instead of "yarn" if let Ok((url, mut index)) = fetch_yarn_index("@yarnpkg/cli-dist") { if let Some(version) = index.tags.remove(&tag) { debug!("Found yarn@{} matching tag '{}' from {}", version, tag, url); if version.major == 2 { return Err(ErrorKind::Yarn2NotSupported.into()); } return Ok(version); } } debug!( "Did not find yarn matching tag '{}' from @yarnpkg/cli-dist", tag ); let (url, mut index) = fetch_yarn_index("yarn")?; match index.tags.remove(&tag) { Some(version) => { debug!("Found yarn@{} matching tag '{}' from {}", version, tag, url); Ok(version) } None => Err(ErrorKind::YarnVersionNotFound { matching: tag }.into()), } } fn resolve_latest_legacy(url: String) -> Fallible { let response_text = attohttpc::get(&url) .send() .and_then(Response::error_for_status) .and_then(Response::text) .with_context(|| ErrorKind::YarnLatestFetchError { from_url: url.clone(), })?; debug!("Found yarn latest version ({}) from {}", response_text, url); parse_version(response_text) } fn resolve_semver_from_registry(matching: Range) -> Fallible { // first try yarn2+, which uses "@yarnpkg/cli-dist" instead of "yarn" if let Ok((url, index)) = fetch_yarn_index("@yarnpkg/cli-dist") { let matching_entries: Vec = index .entries .into_iter() .filter(|PackageDetails { version, .. }| matching.satisfies(version)) .collect(); if !matching_entries.is_empty() { let details_opt = matching_entries .iter() .find(|PackageDetails { version, .. }| version.major >= 3); match details_opt { Some(details) => { debug!( "Found yarn@{} matching requirement '{}' from {}", details.version, matching, url ); return Ok(details.version.clone()); } None => { return Err(ErrorKind::Yarn2NotSupported.into()); } } } } debug!( "Did not find yarn matching requirement '{}' for @yarnpkg/cli-dist", matching ); let (url, index) = fetch_yarn_index("yarn")?; let details_opt = index .entries .into_iter() .find(|PackageDetails { version, .. }| matching.satisfies(version)); match details_opt { Some(details) => { debug!( "Found yarn@{} matching requirement '{}' from {}", details.version, matching, url ); Ok(details.version) } // at this point Yarn is not found in either registry None => Err(ErrorKind::YarnVersionNotFound { matching: matching.to_string(), } .into()), } } fn resolve_semver_legacy(matching: Range, url: String) -> Fallible { let spinner = progress_spinner(format!("Fetching registry: {}", url)); let releases: RawYarnIndex = attohttpc::get(&url) .send() .and_then(Response::error_for_status) .and_then(Response::json) .with_context(registry_fetch_error("Yarn", &url))?; let index = YarnIndex::from(releases); let releases = index.entries; spinner.finish_and_clear(); let version_opt = releases.into_iter().rev().find(|v| matching.satisfies(v)); match version_opt { Some(version) => { debug!( "Found yarn@{} matching requirement '{}' from {}", version, matching, url ); Ok(version) } None => Err(ErrorKind::YarnVersionNotFound { matching: matching.to_string(), } .into()), } } fn resolve_semver_npm(matching: Range, url: String) -> Fallible { let (url, index) = fetch_npm_registry(url, "Yarn")?; let details_opt = index .entries .into_iter() .find(|PackageDetails { version, .. }| matching.satisfies(version)); match details_opt { Some(details) => { debug!( "Found yarn@{} matching requirement '{}' from {}", details.version, matching, url ); Ok(details.version) } None => Err(ErrorKind::YarnVersionNotFound { matching: matching.to_string(), } .into()), } } ================================================ FILE: crates/volta-core/src/toolchain/mod.rs ================================================ use std::fs::write; use crate::error::{Context, ErrorKind, Fallible}; use crate::fs::touch; use crate::layout::volta_home; use crate::platform::PlatformSpec; use log::debug; use node_semver::Version; use once_cell::unsync::OnceCell; use readext::ReadExt; pub mod serial; /// Lazily loaded toolchain pub struct LazyToolchain { toolchain: OnceCell, } impl LazyToolchain { /// Creates a new `LazyToolchain` pub fn init() -> Self { LazyToolchain { toolchain: OnceCell::new(), } } /// Forces loading of the toolchain and returns an immutable reference to it pub fn get(&self) -> Fallible<&Toolchain> { self.toolchain.get_or_try_init(Toolchain::current) } /// Forces loading of the toolchain and returns a mutable reference to it pub fn get_mut(&mut self) -> Fallible<&mut Toolchain> { let _ = self.toolchain.get_or_try_init(Toolchain::current)?; Ok(self.toolchain.get_mut().unwrap()) } } pub struct Toolchain { platform: Option, } impl Toolchain { fn current() -> Fallible { let path = volta_home()?.default_platform_file(); let src = touch(path) .and_then(|mut file| file.read_into_string()) .with_context(|| ErrorKind::ReadPlatformError { file: path.to_owned(), })?; let platform: Option = serial::Platform::try_from(src)?.into(); if platform.is_some() { debug!("Found default configuration at '{}'", path.display()); } Ok(Toolchain { platform }) } pub fn platform(&self) -> Option<&PlatformSpec> { self.platform.as_ref() } /// Set the active Node version in the default platform file. pub fn set_active_node(&mut self, node_version: &Version) -> Fallible<()> { let mut dirty = false; match self.platform.as_mut() { Some(platform) => { if platform.node != *node_version { platform.node = node_version.clone(); dirty = true; } } None => { self.platform = Some(PlatformSpec { node: node_version.clone(), npm: None, pnpm: None, yarn: None, }); dirty = true; } } if dirty { self.save()?; } Ok(()) } /// Set the active Yarn version in the default platform file. pub fn set_active_yarn(&mut self, yarn: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { if platform.yarn != yarn { platform.yarn = yarn; self.save()?; } } else if yarn.is_some() { return Err(ErrorKind::NoDefaultNodeVersion { tool: "Yarn".into(), } .into()); } Ok(()) } /// Set the active pnpm version in the default platform file. pub fn set_active_pnpm(&mut self, pnpm: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { if platform.pnpm != pnpm { platform.pnpm = pnpm; self.save()?; } } else if pnpm.is_some() { return Err(ErrorKind::NoDefaultNodeVersion { tool: "pnpm".into(), } .into()); } Ok(()) } /// Set the active Npm version in the default platform file. pub fn set_active_npm(&mut self, npm: Option) -> Fallible<()> { if let Some(platform) = self.platform.as_mut() { if platform.npm != npm { platform.npm = npm; self.save()?; } } else if npm.is_some() { return Err(ErrorKind::NoDefaultNodeVersion { tool: "npm".into() }.into()); } Ok(()) } pub fn save(&self) -> Fallible<()> { let path = volta_home()?.default_platform_file(); let result = match &self.platform { Some(platform) => { let src = serial::Platform::of(platform).into_json()?; write(path, src) } None => write(path, "{}"), }; result.with_context(|| ErrorKind::WritePlatformError { file: path.to_owned(), }) } } ================================================ FILE: crates/volta-core/src/toolchain/serial.rs ================================================ use crate::error::{Context, ErrorKind, Fallible, VoltaError}; use crate::platform::PlatformSpec; use crate::version::{option_version_serde, version_serde}; use node_semver::Version; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct NodeVersion { #[serde(with = "version_serde")] pub runtime: Version, #[serde(with = "option_version_serde")] pub npm: Option, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Platform { #[serde(default)] pub node: Option, #[serde(default)] #[serde(with = "option_version_serde")] pub pnpm: Option, #[serde(default)] #[serde(with = "option_version_serde")] pub yarn: Option, } impl Platform { pub fn of(source: &PlatformSpec) -> Self { Platform { node: Some(NodeVersion { runtime: source.node.clone(), npm: source.npm.clone(), }), pnpm: source.pnpm.clone(), yarn: source.yarn.clone(), } } /// Serialize the Platform to a JSON String pub fn into_json(self) -> Fallible { serde_json::to_string_pretty(&self).with_context(|| ErrorKind::StringifyPlatformError) } } impl TryFrom for Platform { type Error = VoltaError; fn try_from(src: String) -> Fallible { let result = if src.is_empty() { serde_json::de::from_str("{}") } else { serde_json::de::from_str(&src) }; result.with_context(|| ErrorKind::ParsePlatformError) } } impl From for Option { fn from(platform: Platform) -> Option { let yarn = platform.yarn; let pnpm = platform.pnpm; platform.node.map(|node_version| PlatformSpec { node: node_version.runtime, npm: node_version.npm, pnpm, yarn, }) } } #[cfg(test)] pub mod tests { use super::*; use crate::platform; use node_semver::Version; // NOTE: serde_json is required with the "preserve_order" feature in Cargo.toml, // so these tests will serialized/deserialize in a predictable order const BASIC_JSON_STR: &str = r#"{ "node": { "runtime": "4.5.6", "npm": "7.8.9" }, "pnpm": "3.2.1", "yarn": "1.2.3" }"#; #[test] fn test_from_json() { let json_str = BASIC_JSON_STR.to_string(); let platform = Platform::try_from(json_str).expect("could not parse JSON string"); let expected_platform = Platform { pnpm: Some(Version::parse("3.2.1").expect("could not parse version")), yarn: Some(Version::parse("1.2.3").expect("could not parse version")), node: Some(NodeVersion { runtime: Version::parse("4.5.6").expect("could not parse version"), npm: Some(Version::parse("7.8.9").expect("could not parse version")), }), }; assert_eq!(platform, expected_platform); } #[test] fn test_from_json_empty_string() { let json_str = "".to_string(); let platform = Platform::try_from(json_str).expect("could not parse JSON string"); let expected_platform = Platform { node: None, pnpm: None, yarn: None, }; assert_eq!(platform, expected_platform); } #[test] fn test_into_json() { let platform_spec = platform::PlatformSpec { pnpm: Some(Version::parse("3.2.1").expect("could not parse version")), yarn: Some(Version::parse("1.2.3").expect("could not parse version")), node: Version::parse("4.5.6").expect("could not parse version"), npm: Some(Version::parse("7.8.9").expect("could not parse version")), }; let json_str = Platform::of(&platform_spec) .into_json() .expect("could not serialize platform to JSON"); let expected_json_str = BASIC_JSON_STR.to_string(); assert_eq!(json_str, expected_json_str); } } ================================================ FILE: crates/volta-core/src/version/mod.rs ================================================ use std::fmt; use std::str::FromStr; use crate::error::{Context, ErrorKind, Fallible, VoltaError}; use node_semver::{Range, Version}; mod serial; #[derive(Debug, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum VersionSpec { /// No version specified (default) #[default] None, /// SemVer Range Semver(Range), /// Exact Version Exact(Version), /// Arbitrary Version Tag Tag(VersionTag), } #[derive(Debug)] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum VersionTag { /// The 'latest' tag, a special case that exists for all packages Latest, /// The 'lts' tag, a special case for Node Lts, /// An arbitrary tag version Custom(String), } impl fmt::Display for VersionSpec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { VersionSpec::None => write!(f, ""), VersionSpec::Semver(req) => req.fmt(f), VersionSpec::Exact(version) => version.fmt(f), VersionSpec::Tag(tag) => tag.fmt(f), } } } impl fmt::Display for VersionTag { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { VersionTag::Latest => write!(f, "latest"), VersionTag::Lts => write!(f, "lts"), VersionTag::Custom(s) => s.fmt(f), } } } impl FromStr for VersionSpec { type Err = VoltaError; fn from_str(s: &str) -> Fallible { if let Ok(version) = parse_version(s) { Ok(VersionSpec::Exact(version)) } else if let Ok(req) = parse_requirements(s) { Ok(VersionSpec::Semver(req)) } else { s.parse().map(VersionSpec::Tag) } } } impl FromStr for VersionTag { type Err = VoltaError; fn from_str(s: &str) -> Fallible { if s == "latest" { Ok(VersionTag::Latest) } else if s == "lts" { Ok(VersionTag::Lts) } else { Ok(VersionTag::Custom(s.into())) } } } pub fn parse_requirements(s: impl AsRef) -> Fallible { let s = s.as_ref(); serial::parse_requirements(s) .with_context(|| ErrorKind::VersionParseError { version: s.into() }) } pub fn parse_version(s: impl AsRef) -> Fallible { let s = s.as_ref(); s.parse() .with_context(|| ErrorKind::VersionParseError { version: s.into() }) } // remove the leading 'v' from the version string, if present fn trim_version(s: &str) -> &str { let s = s.trim(); match s.strip_prefix('v') { Some(stripped) => stripped, None => s, } } // custom serialization and de-serialization for Version // because Version doesn't work with serde out of the box pub mod version_serde { use node_semver::Version; use serde::de::{Error, Visitor}; use serde::{self, Deserializer, Serializer}; use std::fmt; struct VersionVisitor; impl Visitor<'_> for VersionVisitor { type Value = Version; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("string") } // parse the version from the string fn visit_str(self, value: &str) -> Result where E: Error, { Version::parse(super::trim_version(value)).map_err(Error::custom) } } pub fn serialize(version: &Version, s: S) -> Result where S: Serializer, { s.serialize_str(&version.to_string()) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_string(VersionVisitor) } } // custom serialization and de-serialization for Option // because Version doesn't work with serde out of the box pub mod option_version_serde { use node_semver::Version; use serde::de::Error; use serde::{self, Deserialize, Deserializer, Serializer}; pub fn serialize(version: &Option, s: S) -> Result where S: Serializer, { match version { Some(v) => s.serialize_str(&v.to_string()), None => s.serialize_none(), } } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let s: Option = Option::deserialize(deserializer)?; if let Some(v) = s { return Ok(Some( Version::parse(super::trim_version(&v)).map_err(Error::custom)?, )); } Ok(None) } } // custom deserialization for HashMap // because Version doesn't work with serde out of the box pub mod hashmap_version_serde { use super::version_serde; use node_semver::Version; use serde::{self, Deserialize, Deserializer}; use std::collections::HashMap; #[derive(Deserialize)] struct Wrapper(#[serde(deserialize_with = "version_serde::deserialize")] Version); pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let m = HashMap::::deserialize(deserializer)?; Ok(m.into_iter().map(|(k, Wrapper(v))| (k, v)).collect()) } } ================================================ FILE: crates/volta-core/src/version/serial.rs ================================================ use node_semver::{Range, SemverError}; // NOTE: using `parse_compat` here because the semver crate defaults to // parsing in a cargo-compatible way. This is normally fine, except for // 2 cases (that I know about): // * "1.2.3" parses as `^1.2.3` for cargo, but `=1.2.3` for Node // * `>1.2.3 <2.0.0` serializes to ">1.2.3, <2.0.0" for cargo (with the // comma), but ">1.2.3 <2.0.0" for Node (no comma, because Node parses // commas differently) // // Because we are parsing the version requirements from the command line, // then serializing them to pass to `npm view`, they need to be handled in // a Node-compatible way (or we get the wrong version info returned). pub fn parse_requirements(src: &str) -> Result { let src = src.trim().trim_start_matches('v'); Range::parse(src) } #[cfg(test)] pub mod tests { use crate::version::serial::parse_requirements; use node_semver::Range; #[test] fn test_parse_requirements() { assert_eq!( parse_requirements("1.2.3").unwrap(), Range::parse("=1.2.3").unwrap() ); assert_eq!( parse_requirements("v1.5").unwrap(), Range::parse("=1.5").unwrap() ); assert_eq!( parse_requirements("=1.2.3").unwrap(), Range::parse("=1.2.3").unwrap() ); assert_eq!( parse_requirements("^1.2").unwrap(), Range::parse("^1.2").unwrap() ); assert_eq!( parse_requirements(">=1.4").unwrap(), Range::parse(">=1.4").unwrap() ); assert_eq!( parse_requirements("8.11 - 8.17 || 10.* || >= 12").unwrap(), Range::parse("8.11 - 8.17 || 10.* || >= 12").unwrap() ); } } ================================================ FILE: crates/volta-layout/Cargo.toml ================================================ [package] name = "volta-layout" version = "0.1.1" authors = ["Chuck Pierce "] edition = "2021" [dependencies] volta-layout-macro = { path = "../volta-layout-macro" } ================================================ FILE: crates/volta-layout/src/lib.rs ================================================ #[macro_use] mod macros; pub mod v0; pub mod v1; pub mod v2; pub mod v3; pub mod v4; fn executable(name: &str) -> String { format!("{}{}", name, std::env::consts::EXE_SUFFIX) } ================================================ FILE: crates/volta-layout/src/macros.rs ================================================ macro_rules! path_buf { ($base:expr, $( $x:expr ), *) => { { let mut temp = $base; $( temp.push($x); )* temp } } } ================================================ FILE: crates/volta-layout/src/v0.rs ================================================ use std::path::PathBuf; use super::executable; use volta_layout_macro::layout; layout! { pub struct VoltaInstall { "shim[.exe]": shim_executable; } pub struct VoltaHome { "cache": cache_dir { "node": node_cache_dir { "index.json": node_index_file; "index.json.expires": node_index_expiry_file; } } "bin": shim_dir {} "log": log_dir {} "tools": tools_dir { "inventory": inventory_dir { "node": node_inventory_dir {} "packages": package_inventory_dir {} "yarn": yarn_inventory_dir {} } "image": image_dir { "node": node_image_root_dir {} "yarn": yarn_image_root_dir {} "packages": package_image_root_dir {} } "user": default_toolchain_dir { "bins": default_bin_dir {} "packages": default_package_dir {} "platform.json": default_platform_file; } } "tmp": tmp_dir {} "hooks.json": default_hooks_file; } } impl VoltaHome { pub fn package_distro_file(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.tgz", name, version) ) } pub fn package_distro_shasum(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.shasum", name, version) ) } pub fn node_image_dir(&self, node: &str, npm: &str) -> PathBuf { path_buf!(self.node_image_root_dir.clone(), node, npm) } pub fn yarn_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_root_dir.clone(), version) } pub fn yarn_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_dir(version), "bin") } pub fn package_image_dir(&self, name: &str, version: &str) -> PathBuf { path_buf!(self.package_image_root_dir.clone(), name, version) } pub fn default_package_config_file(&self, package_name: &str) -> PathBuf { path_buf!( self.default_package_dir.clone(), format!("{}.json", package_name) ) } pub fn default_tool_bin_config(&self, bin_name: &str) -> PathBuf { path_buf!(self.default_bin_dir.clone(), format!("{}.json", bin_name)) } pub fn node_npm_version_file(&self, version: &str) -> PathBuf { path_buf!( self.node_inventory_dir.clone(), format!("node-v{}-npm", version) ) } pub fn shim_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), executable(toolname)) } } #[cfg(windows)] impl VoltaHome { pub fn shim_git_bash_script_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), toolname) } pub fn node_image_bin_dir(&self, node: &str, npm: &str) -> PathBuf { self.node_image_dir(node, npm) } } #[cfg(windows)] impl VoltaInstall { pub fn bin_dir(&self) -> PathBuf { path_buf!(self.root.clone(), "bin") } } #[cfg(unix)] impl VoltaHome { pub fn node_image_bin_dir(&self, node: &str, npm: &str) -> PathBuf { path_buf!(self.node_image_dir(node, npm), "bin") } } ================================================ FILE: crates/volta-layout/src/v1.rs ================================================ use std::path::PathBuf; use super::executable; use volta_layout_macro::layout; layout! { pub struct VoltaInstall { "volta-shim[.exe]": shim_executable; "volta[.exe]": main_executable; "volta-migrate[.exe]": migrate_executable; } pub struct VoltaHome { "cache": cache_dir { "node": node_cache_dir { "index.json": node_index_file; "index.json.expires": node_index_expiry_file; } } "bin": shim_dir {} "log": log_dir {} "tools": tools_dir { "inventory": inventory_dir { "node": node_inventory_dir {} "packages": package_inventory_dir {} "yarn": yarn_inventory_dir {} } "image": image_dir { "node": node_image_root_dir {} "yarn": yarn_image_root_dir {} "packages": package_image_root_dir {} } "user": default_toolchain_dir { "bins": default_bin_dir {} "packages": default_package_dir {} "platform.json": default_platform_file; } } "tmp": tmp_dir {} "hooks.json": default_hooks_file; "layout.v1": layout_file; } } impl VoltaHome { pub fn package_distro_file(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.tgz", name, version) ) } pub fn package_distro_shasum(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.shasum", name, version) ) } pub fn node_image_dir(&self, node: &str, npm: &str) -> PathBuf { path_buf!(self.node_image_root_dir.clone(), node, npm) } pub fn yarn_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_root_dir.clone(), version) } pub fn yarn_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_dir(version), "bin") } pub fn package_image_dir(&self, name: &str, version: &str) -> PathBuf { path_buf!(self.package_image_root_dir.clone(), name, version) } pub fn default_package_config_file(&self, package_name: &str) -> PathBuf { path_buf!( self.default_package_dir.clone(), format!("{}.json", package_name) ) } pub fn default_tool_bin_config(&self, bin_name: &str) -> PathBuf { path_buf!(self.default_bin_dir.clone(), format!("{}.json", bin_name)) } pub fn node_npm_version_file(&self, version: &str) -> PathBuf { path_buf!( self.node_inventory_dir.clone(), format!("node-v{}-npm", version) ) } pub fn shim_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), executable(toolname)) } } #[cfg(windows)] impl VoltaHome { pub fn shim_git_bash_script_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), toolname) } pub fn node_image_bin_dir(&self, node: &str, npm: &str) -> PathBuf { self.node_image_dir(node, npm) } } #[cfg(unix)] impl VoltaHome { pub fn node_image_bin_dir(&self, node: &str, npm: &str) -> PathBuf { path_buf!(self.node_image_dir(node, npm), "bin") } } ================================================ FILE: crates/volta-layout/src/v2.rs ================================================ use std::path::PathBuf; use super::executable; use volta_layout_macro::layout; pub use crate::v1::VoltaInstall; layout! { pub struct VoltaHome { "cache": cache_dir { "node": node_cache_dir { "index.json": node_index_file; "index.json.expires": node_index_expiry_file; } } "bin": shim_dir {} "log": log_dir {} "tools": tools_dir { "inventory": inventory_dir { "node": node_inventory_dir {} "npm": npm_inventory_dir {} "packages": package_inventory_dir {} "yarn": yarn_inventory_dir {} } "image": image_dir { "node": node_image_root_dir {} "npm": npm_image_root_dir {} "yarn": yarn_image_root_dir {} "packages": package_image_root_dir {} } "user": default_toolchain_dir { "bins": default_bin_dir {} "packages": default_package_dir {} "platform.json": default_platform_file; } } "tmp": tmp_dir {} "hooks.json": default_hooks_file; "layout.v2": layout_file; } } impl VoltaHome { pub fn package_distro_file(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.tgz", name, version) ) } pub fn package_distro_shasum(&self, name: &str, version: &str) -> PathBuf { path_buf!( self.package_inventory_dir.clone(), format!("{}-{}.shasum", name, version) ) } pub fn node_image_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_root_dir.clone(), node) } pub fn npm_image_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_root_dir.clone(), npm) } pub fn npm_image_bin_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_dir(npm), "bin") } pub fn yarn_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_root_dir.clone(), version) } pub fn yarn_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_dir(version), "bin") } pub fn package_image_dir(&self, name: &str, version: &str) -> PathBuf { path_buf!(self.package_image_root_dir.clone(), name, version) } pub fn default_package_config_file(&self, package_name: &str) -> PathBuf { path_buf!( self.default_package_dir.clone(), format!("{}.json", package_name) ) } pub fn default_tool_bin_config(&self, bin_name: &str) -> PathBuf { path_buf!(self.default_bin_dir.clone(), format!("{}.json", bin_name)) } pub fn node_npm_version_file(&self, version: &str) -> PathBuf { path_buf!( self.node_inventory_dir.clone(), format!("node-v{}-npm", version) ) } pub fn shim_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), executable(toolname)) } } #[cfg(windows)] impl VoltaHome { pub fn shim_git_bash_script_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), toolname) } pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { self.node_image_dir(node) } } #[cfg(unix)] impl VoltaHome { pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_dir(node), "bin") } } ================================================ FILE: crates/volta-layout/src/v3.rs ================================================ use std::path::PathBuf; use super::executable; use volta_layout_macro::layout; pub use crate::v1::VoltaInstall; layout! { pub struct VoltaHome { "cache": cache_dir { "node": node_cache_dir { "index.json": node_index_file; "index.json.expires": node_index_expiry_file; } } "bin": shim_dir {} "log": log_dir {} "tools": tools_dir { "inventory": inventory_dir { "node": node_inventory_dir {} "npm": npm_inventory_dir {} "pnpm": pnpm_inventory_dir {} "yarn": yarn_inventory_dir {} } "image": image_dir { "node": node_image_root_dir {} "npm": npm_image_root_dir {} "pnpm": pnpm_image_root_dir {} "yarn": yarn_image_root_dir {} "packages": package_image_root_dir {} } "shared": shared_lib_root {} "user": default_toolchain_dir { "bins": default_bin_dir {} "packages": default_package_dir {} "platform.json": default_platform_file; } } "tmp": tmp_dir {} "hooks.json": default_hooks_file; "layout.v3": layout_file; } } impl VoltaHome { pub fn node_image_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_root_dir.clone(), node) } pub fn npm_image_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_root_dir.clone(), npm) } pub fn npm_image_bin_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_dir(npm), "bin") } pub fn pnpm_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.pnpm_image_root_dir.clone(), version) } pub fn pnpm_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.pnpm_image_dir(version), "bin") } pub fn yarn_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_root_dir.clone(), version) } pub fn yarn_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_dir(version), "bin") } pub fn package_image_dir(&self, name: &str) -> PathBuf { path_buf!(self.package_image_root_dir.clone(), name) } pub fn default_package_config_file(&self, package_name: &str) -> PathBuf { path_buf!( self.default_package_dir.clone(), format!("{}.json", package_name) ) } pub fn default_tool_bin_config(&self, bin_name: &str) -> PathBuf { path_buf!(self.default_bin_dir.clone(), format!("{}.json", bin_name)) } pub fn node_npm_version_file(&self, version: &str) -> PathBuf { path_buf!( self.node_inventory_dir.clone(), format!("node-v{}-npm", version) ) } pub fn shim_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), executable(toolname)) } pub fn shared_lib_dir(&self, library: &str) -> PathBuf { path_buf!(self.shared_lib_root.clone(), library) } } #[cfg(windows)] impl VoltaHome { pub fn shim_git_bash_script_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), toolname) } pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { self.node_image_dir(node) } } #[cfg(unix)] impl VoltaHome { pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_dir(node), "bin") } } ================================================ FILE: crates/volta-layout/src/v4.rs ================================================ use std::path::PathBuf; use volta_layout_macro::layout; pub use crate::v1::VoltaInstall; layout! { pub struct VoltaHome { "cache": cache_dir { "node": node_cache_dir { "index.json": node_index_file; "index.json.expires": node_index_expiry_file; } } "bin": shim_dir {} "log": log_dir {} "tools": tools_dir { "inventory": inventory_dir { "node": node_inventory_dir {} "npm": npm_inventory_dir {} "pnpm": pnpm_inventory_dir {} "yarn": yarn_inventory_dir {} } "image": image_dir { "node": node_image_root_dir {} "npm": npm_image_root_dir {} "pnpm": pnpm_image_root_dir {} "yarn": yarn_image_root_dir {} "packages": package_image_root_dir {} } "shared": shared_lib_root {} "user": default_toolchain_dir { "bins": default_bin_dir {} "packages": default_package_dir {} "platform.json": default_platform_file; } } "tmp": tmp_dir {} "hooks.json": default_hooks_file; "layout.v4": layout_file; } } impl VoltaHome { pub fn node_image_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_root_dir.clone(), node) } pub fn npm_image_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_root_dir.clone(), npm) } pub fn npm_image_bin_dir(&self, npm: &str) -> PathBuf { path_buf!(self.npm_image_dir(npm), "bin") } pub fn pnpm_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.pnpm_image_root_dir.clone(), version) } pub fn pnpm_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.pnpm_image_dir(version), "bin") } pub fn yarn_image_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_root_dir.clone(), version) } pub fn yarn_image_bin_dir(&self, version: &str) -> PathBuf { path_buf!(self.yarn_image_dir(version), "bin") } pub fn package_image_dir(&self, name: &str) -> PathBuf { path_buf!(self.package_image_root_dir.clone(), name) } pub fn default_package_config_file(&self, package_name: &str) -> PathBuf { path_buf!( self.default_package_dir.clone(), format!("{}.json", package_name) ) } pub fn default_tool_bin_config(&self, bin_name: &str) -> PathBuf { path_buf!(self.default_bin_dir.clone(), format!("{}.json", bin_name)) } pub fn node_npm_version_file(&self, version: &str) -> PathBuf { path_buf!( self.node_inventory_dir.clone(), format!("node-v{}-npm", version) ) } pub fn shim_file(&self, toolname: &str) -> PathBuf { // On Windows, shims are created as `.cmd` since they // are thin scripts that use `volta run` to execute the command #[cfg(windows)] let toolname = format!("{}{}", toolname, ".cmd"); path_buf!(self.shim_dir.clone(), toolname) } pub fn shared_lib_dir(&self, library: &str) -> PathBuf { path_buf!(self.shared_lib_root.clone(), library) } } #[cfg(windows)] impl VoltaHome { pub fn shim_git_bash_script_file(&self, toolname: &str) -> PathBuf { path_buf!(self.shim_dir.clone(), toolname) } pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { self.node_image_dir(node) } } #[cfg(unix)] impl VoltaHome { pub fn node_image_bin_dir(&self, node: &str) -> PathBuf { path_buf!(self.node_image_dir(node), "bin") } } ================================================ FILE: crates/volta-layout-macro/Cargo.toml ================================================ [package] name = "volta-layout-macro" version = "0.1.0" authors = ["David Herman "] edition = "2021" [lib] proc-macro = true [dependencies] syn = "1.0.5" quote = "1.0.2" proc-macro2 = "1.0.2" ================================================ FILE: crates/volta-layout-macro/src/ast.rs ================================================ use crate::ir::{Entry, Ir}; use proc_macro2::TokenStream; use std::collections::HashMap; use syn::parse::{self, Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{braced, Attribute, Ident, LitStr, Token, Visibility}; pub(crate) type Result = ::std::result::Result; /// Abstract syntax tree (AST) for the surface syntax of the `layout!` macro. /// /// The surface syntax of the `layout!` macro takes the form: /// /// ```text,no_run /// Attribute* Visibility "struct" Ident Directory /// ``` /// /// This AST gets lowered by the `flatten` method to a vector of intermediate /// representation (IR) trees. See the `Ir` type for details. pub(crate) struct Ast { decls: Vec, } impl Parse for Ast { fn parse(input: ParseStream) -> parse::Result { let mut decls = Vec::new(); while !input.is_empty() { let decl = input.call(LayoutStruct::parse)?; decls.push(decl); } Ok(Ast { decls }) } } impl Ast { /// Compiles (macro-expands) the AST. pub(crate) fn compile(self) -> TokenStream { self.decls .into_iter() .map(|decl| match decl.flatten() { Ok(ir) => ir.codegen(), Err(err) => err, }) .collect() } } /// Represents a single type LayoutStruct in the AST, which takes the form: /// /// ```text,no_run /// Attribute* Visibility "struct" Ident Directory /// ``` /// /// This AST gets lowered by the `flatten` method to a flat list of entries, /// organized by entry type. See the `Ir` type for details. pub(crate) struct LayoutStruct { attrs: Vec, visibility: Visibility, name: Ident, directory: Directory, } impl Parse for LayoutStruct { fn parse(input: ParseStream) -> parse::Result { let attrs: Vec = input.call(Attribute::parse_outer)?; let visibility: Visibility = input.parse()?; input.parse::()?; let name: Ident = input.parse()?; let directory: Directory = input.parse()?; Ok(LayoutStruct { attrs, visibility, name, directory, }) } } impl LayoutStruct { /// Lowers the AST to a flattened intermediate representation. fn flatten(self) -> Result { let mut results = Ir { name: self.name, attrs: self.attrs, visibility: self.visibility, dirs: vec![], files: vec![], exes: vec![], }; self.directory.flatten(&mut results, vec![])?; Ok(results) } } /// Represents a directory entry in the AST, which can recursively contain /// more entries. /// /// The surface syntax of a directory takes the form: /// /// ```text,no_run /// { /// (FieldPrefix)FieldContents* /// } /// ``` struct Directory { entries: Punctuated, } impl Parse for Directory { fn parse(input: ParseStream) -> parse::Result { let content; braced!(content in input); Ok(Directory { entries: content.parse_terminated(FieldPrefix::parse)?, }) } } enum EntryKind { Exe, File, Dir, } impl Directory { /// Lowers the directory to a flattened intermediate representation. fn flatten(self, results: &mut Ir, context: Vec) -> Result<()> { let mut visited_entries = HashMap::new(); for pair in self.entries.into_pairs() { let (prefix, punc) = pair.into_tuple(); let mut entry = Entry { name: prefix.name, context: context.clone(), filename: prefix.filename.clone(), }; match punc { Some(FieldContents::Dir(dir)) => { let filename = prefix.filename.value(); if filename.ends_with(".exe") || filename.ends_with("[.exe]") { let error = syn::Error::new( prefix.filename.span(), "the `.exe` extension is not allowed for directory names", ); return Err(error.to_compile_error()); } if let Some(kind) = visited_entries.get(&filename) { let message = match kind { EntryKind::Exe => { format!("filename `{}` is a duplicate of `{}` executable on non-Windows operating systems", filename, filename) } _ => { format!("duplicate filename `{}`", filename) } }; let error = syn::Error::new(prefix.filename.span(), message); return Err(error.to_compile_error()); } visited_entries.insert(filename.clone(), EntryKind::Dir); results.dirs.push(entry); let mut sub_context = context.clone(); sub_context.push(prefix.filename); dir.flatten(results, sub_context)?; } _ => { let filename = prefix.filename.value(); if filename.ends_with("[.exe]") { let filename = &filename[0..filename.len() - 6]; if let Some(kind) = visited_entries.get(filename) { let message = match kind { EntryKind::Exe => { format!("duplicate filename `{}.exe`", filename) } EntryKind::File => { format!("executable `{}` (on non-Windows operating systems) is a duplicate of `{}` filename", filename, filename) } EntryKind::Dir => { format!("executable `{}` (on non-Windows operating systems) is a duplicate of `{}` directory name", filename, filename) } }; let error = syn::Error::new(prefix.filename.span(), message); return Err(error.to_compile_error()); } visited_entries.insert(filename.to_string(), EntryKind::Exe); entry.filename = LitStr::new(filename, prefix.filename.span()); results.exes.push(entry); } else { if let Some(kind) = visited_entries.get(&filename) { let message = match kind { EntryKind::Exe => { format!("filename `{}` is a duplicate of `{}` executable on non-Windows operating systems", filename, filename) } _ => { format!("duplicate filename `{}`", filename) } }; let error = syn::Error::new(prefix.filename.span(), message); return Err(error.to_compile_error()); } visited_entries.insert(filename, EntryKind::File); results.files.push(entry); } } } } Ok(()) } } /// AST for the common prefix of a single field in a `layout!` struct declaration, /// which is of the form: /// /// ```text,no_run /// LitStr ":" Ident /// ``` /// /// This is followed either by a semicolon (`;`), indicating that the field is a /// file, or a braced directory entry, indicating that the field is a directory. /// /// If the `LitStr` contains the suffix `"[.exe]"` it is treated specially as an /// executable file, whose suffix (or lack thereof) is determined by the current /// operating system (using the `std::env::consts::EXE_SUFFIX` constant). struct FieldPrefix { filename: LitStr, name: Ident, } impl Parse for FieldPrefix { fn parse(input: ParseStream) -> parse::Result { let filename = input.parse()?; input.parse::()?; let name = input.parse()?; Ok(FieldPrefix { filename, name }) } } /// AST for the suffix of a field in a `layout!` struct declaration. enum FieldContents { /// A file field suffix, which consists of a single semicolon (`;`). File(Token![;]), /// A directory field suffix, which consists of a braced directory. Dir(Directory), } impl Parse for FieldContents { fn parse(input: ParseStream) -> parse::Result { let lookahead = input.lookahead1(); Ok(if lookahead.peek(Token![;]) { let semi = input.parse()?; FieldContents::File(semi) } else { let directory = input.parse()?; FieldContents::Dir(directory) }) } } ================================================ FILE: crates/volta-layout-macro/src/ir.rs ================================================ // The `proc_macro2` crate is a polyfill for advanced functionality of Rust's // procedural macros, not all of which have shipped in stable Rust. It's used by // the `syn` and `quote` crates to produce a shimmed version of the standard // `TokenStream` type. So internally that's the type we have to use for the // implementation of our macro. The actual front-end for the macro takes this // shimmed `TokenStream` type and converts it to the built-in `TokenStream` type // required by the Rust macro system. use proc_macro2::TokenStream; use quote::quote; use syn::{Attribute, Ident, LitStr, Visibility}; // These seem to be leaked implementation details of the `quote` macro that have // to be imported by users. You can ignore them; they simply pacify the compiler. #[allow(unused_imports)] use quote::{pounded_var_names, quote_each_token, quote_spanned}; /// The intermediate representation (IR) of a struct type defined by the `layout!` /// macro, which contains the flattened directory entries, organized into three /// categories: /// /// - Directories /// - Executable files /// - Other files pub(crate) struct Ir { pub(crate) name: Ident, pub(crate) attrs: Vec, pub(crate) visibility: Visibility, pub(crate) dirs: Vec, pub(crate) files: Vec, pub(crate) exes: Vec, } impl Ir { fn dir_names(&self) -> impl Iterator { self.dirs.iter().map(|entry| &entry.name) } fn file_names(&self) -> impl Iterator { self.files.iter().map(|entry| &entry.name) } fn exe_names(&self) -> impl Iterator { self.exes.iter().map(|entry| &entry.name) } fn field_names(&self) -> impl Iterator { let dir_names = self.dir_names(); let file_names = self.file_names(); let exe_names = self.exe_names(); dir_names.chain(file_names).chain(exe_names) } fn to_struct_decl(&self) -> TokenStream { let name = &self.name; let attrs = self.attrs.iter(); let visibility = self.visibility.clone(); let field_names = self.field_names().map(|field_name| { // Use the field name's span for good duplicate-field-name error messages. quote_spanned! {field_name.span()=> #field_name : ::std::path::PathBuf , } }); quote! { #(#attrs)* #visibility struct #name { #(#field_names)* root: ::std::path::PathBuf, } } } fn to_create_method(&self) -> TokenStream { let name = &self.name; let dir_names = self.dir_names(); quote! { impl #name { /// Creates all subdirectories in this directory layout. pub fn create(&self) -> ::std::io::Result<()> { #(::std::fs::create_dir_all(self.#dir_names())?;)* ::std::result::Result::Ok(()) } } } } fn to_item_methods(&self) -> TokenStream { let name = &self.name; let methods = self.field_names().map(|field_name| { // Markdown-formatted field name for the doc comment. let markdown_field_name = format!("`{}`", field_name); let markdown_field_name = LitStr::new(&markdown_field_name, field_name.span()); // Use the field name's span for good duplicate-method-name error messages. quote_spanned! {field_name.span()=> #[doc = "Returns the "] #[doc = #markdown_field_name] #[doc = " path."] pub fn #field_name(&self) -> &::std::path::Path { &self.#field_name } } }); quote! { impl #name { #(#methods)* /// Returns the root path for this directory layout. pub fn root(&self) -> &::std::path::Path { &self.root } } } } fn to_ctor(&self) -> TokenStream { let name = &self.name; let root = Ident::new("root", self.name.span()); let dir_names = self.dir_names(); let dir_inits = self.dirs.iter().map(|entry| entry.to_normal_init(&root)); let file_names = self.file_names(); let file_inits = self.files.iter().map(|entry| entry.to_normal_init(&root)); let exe_names = self.exe_names(); let exe_inits = self.exes.iter().map(|entry| entry.to_exe_init(&root)); let all_names = dir_names.chain(file_names).chain(exe_names); let all_inits = dir_inits.chain(file_inits).chain(exe_inits); let markdown_struct_name = format!("`{}`", name); let markdown_struct_name = LitStr::new(&markdown_struct_name, name.span()); quote! { impl #name { #[doc = "Constructs a new instance of the "] #[doc = #markdown_struct_name] #[doc = " layout, rooted at `root`."] pub fn new(#root: ::std::path::PathBuf) -> Self { Self { #(#all_names: #all_inits),* , #root: #root } } } } } pub(crate) fn codegen(&self) -> TokenStream { let struct_decl = self.to_struct_decl(); let ctor = self.to_ctor(); let item_methods = self.to_item_methods(); let create_method = self.to_create_method(); quote! { #struct_decl #ctor #item_methods #create_method } } } pub(crate) struct Entry { pub(crate) name: Ident, pub(crate) context: Vec, pub(crate) filename: LitStr, } impl Entry { fn to_normal_init(&self, root: &Ident) -> TokenStream { let name = &self.name; let path_items = self.context.iter(); let name_replicated = self.context.iter().map(|_| name); let filename = &self.filename; quote! { { let mut #name = #root.clone(); #(#name_replicated.push(#path_items);)* #name.push(#filename); #name } } } fn to_exe_init(&self, root: &Ident) -> TokenStream { let name = &self.name; let path_items = self.context.iter(); let name_replicated = self.context.iter().map(|_| name); let filename = &self.filename; quote! { { let mut #name = #root.clone(); #(#name_replicated.push(#path_items);)* #name.push(::std::format!("{}{}", #filename, ::std::env::consts::EXE_SUFFIX)); #name } } } } ================================================ FILE: crates/volta-layout-macro/src/lib.rs ================================================ #![recursion_limit = "128"] extern crate proc_macro; mod ast; mod ir; use crate::ast::Ast; use proc_macro::TokenStream; use syn::parse_macro_input; /// A macro for defining Volta directory layout hierarchies. /// /// The syntax of `layout!` takes the form: /// /// ```text,no_run /// layout! { /// LayoutStruct* /// } /// ``` /// /// The syntax of a `LayoutStruct` takes the form: /// /// ```text,no_run /// Attribute* Visibility "struct" Ident Directory /// ``` /// /// The syntax of a `Directory` takes the form: /// /// ```text,no_run /// { /// (FieldPrefix)FieldContents* /// } /// ``` /// /// The syntax of a `FieldPrefix` takes the form: /// /// ```text,no_run /// LitStr ":" Ident /// ``` /// /// The syntax of a `FieldContents` is either: /// /// ```text,no_run /// ";" /// ``` /// /// or: /// /// ```text,no_run /// Directory /// ``` #[proc_macro] pub fn layout(input: TokenStream) -> TokenStream { let ast = parse_macro_input!(input as Ast); let expanded = ast.compile(); TokenStream::from(expanded) } ================================================ FILE: crates/volta-migrate/Cargo.toml ================================================ [package] name = "volta-migrate" version = "0.1.0" authors = ["Charles Pierce "] edition = "2021" [dependencies] volta-core = { path = "../volta-core" } volta-layout = { path = "../volta-layout" } log = { version = "0.4", features = ["std"] } tempfile = "3.14.0" node-semver = "2" serde_json = { version = "1.0.135", features = ["preserve_order"] } serde = { version = "1.0.217", features = ["derive"] } walkdir = "2.5.0" ================================================ FILE: crates/volta-migrate/src/empty.rs ================================================ use std::path::PathBuf; /// Represents an Empty (or uninitialized) Volta layout, one that has never been used by any prior version /// /// This is the easiest to migrate from, as we simply need to create the current layout within the .volta /// directory pub struct Empty { pub home: PathBuf, } impl Empty { pub fn new(home: PathBuf) -> Self { Empty { home } } } ================================================ FILE: crates/volta-migrate/src/lib.rs ================================================ //! Provides types for modeling the current state of the Volta directory and for migrating between versions //! //! A new layout should be represented by its own struct (as in the existing v0 or v1 modules) //! Migrations between types should be represented by `TryFrom` implementations between the layout types //! (see v1.rs for examples) //! //! NOTE: Since the layout file is written once the migration is complete, all migration implementations //! need to be aware that they may be partially applied (if something fails in the process) and should be //! able to re-start gracefully from an interrupted migration use std::path::Path; mod empty; mod v0; mod v1; mod v2; mod v3; mod v4; use v0::V0; use v1::V1; use v2::V2; use v3::V3; use v4::V4; use log::{debug, info}; use volta_core::error::Fallible; use volta_core::layout::volta_home; #[cfg(unix)] use volta_core::layout::volta_install; use volta_core::shim::regenerate_shims_for_dir; use volta_core::sync::VoltaLock; /// Represents the state of the Volta directory at every point in the migration process /// /// Migrations should be applied sequentially, migrating from V0 to V1 to ... as needed, cycling /// through the possible MigrationState values. enum MigrationState { Empty(empty::Empty), V0(Box), V1(Box), V2(Box), V3(Box), V4(Box), } /// Macro to simplify the boilerplate associated with detecting a tagged state. /// /// Should be passed a series of tuples, each of which contains (in this order): /// /// * The layout version (module name from `volta_layout` crate, e.g. `v1`) /// * The `MigrationState` variant name (e.g. `V1`) /// * The migration object itself (e.g. `V1` from the v1 module in _this_ crate) /// /// The tuples should be in reverse chronological order, so that the newest is first, e.g.: /// /// detect_tagged!((v3, V3, V3), (v2, V2, V2), (v1, V1, V1)); macro_rules! detect_tagged { ($(($layout:ident, $variant:ident, $migration:ident)),*) => { impl MigrationState { fn detect_tagged_state(home: &::std::path::Path) -> Option { None $( .or_else(|| detect::$layout(home)) )* } } mod detect { $( pub(super) fn $layout(home: &::std::path::Path) -> Option { let volta_home = volta_layout::$layout::VoltaHome::new(home.to_owned()); if volta_home.layout_file().exists() { Some(super::MigrationState::$variant(Box::new(super::$migration::new(home.to_owned())))) } else { None } } )* } } } detect_tagged!((v4, V4, V4), (v3, V3, V3), (v2, V2, V2), (v1, V1, V1)); impl MigrationState { fn current() -> Fallible { // First look for a tagged version (V1+). If that can't be found, then go through the triage // for detecting a legacy version let home = volta_home()?; match MigrationState::detect_tagged_state(home.root()) { Some(state) => Ok(state), None => MigrationState::detect_legacy_state(home.root()), } } #[allow(clippy::unnecessary_wraps)] // Needs to be Fallible for Unix fn detect_legacy_state(home: &Path) -> Fallible { /* Triage for determining the legacy layout version: - Does Volta Home exist? - If yes (Windows) then V0 - If yes (Unix) then check if Volta Install is outside shim_dir? - If yes, then V0 - If no, then check if $VOLTA_HOME/load.sh exists? If yes then V0 - Else Empty The extra logic on Unix is necessary because Unix installs can be either inside or outside $VOLTA_HOME If it is inside, then the directory necessarily must exist, so we can't use that as a determination. If it is outside (and for Windows which is always outside), then if $VOLTA_HOME exists, it must be from a previous, V0 installation. */ let volta_home = home.to_owned(); if volta_home.exists() { #[cfg(windows)] return Ok(MigrationState::V0(Box::new(V0::new(volta_home)))); #[cfg(unix)] { let install = volta_install()?; if install.root().starts_with(&volta_home) { // Installed inside $VOLTA_HOME, so need to look for `load.sh` as a marker if volta_home.join("load.sh").exists() { return Ok(MigrationState::V0(Box::new(V0::new(volta_home)))); } } else { // Installed outside of $VOLTA_HOME, so it must exist from a previous V0 install return Ok(MigrationState::V0(Box::new(V0::new(volta_home)))); } } } Ok(MigrationState::Empty(empty::Empty::new(volta_home))) } } pub fn run_migration() -> Fallible<()> { // Acquire an exclusive lock on the Volta directory, to ensure that no other migrations are running. // If this fails, however, we still need to run the migration match VoltaLock::acquire() { Ok(_lock) => { // The lock was acquired, so we can be confident that no other migrations are running detect_and_migrate() } Err(_) => { debug!("Unable to acquire lock on Volta directory! Running migration anyway."); detect_and_migrate() } } } fn detect_and_migrate() -> Fallible<()> { info!("Updating your Volta directory. This may take a few moments..."); let mut state = MigrationState::current()?; // To keep the complexity of writing a new migration from continuously increasing, each new // layout version only needs to implement a migration from 2 states: Empty and the previously // latest version. We then apply the migrations sequentially here: V0 -> V1 -> ... -> VX loop { state = match state { MigrationState::Empty(e) => MigrationState::V3(Box::new(e.try_into()?)), MigrationState::V0(zero) => MigrationState::V1(Box::new((*zero).try_into()?)), MigrationState::V1(one) => MigrationState::V2(Box::new((*one).try_into()?)), MigrationState::V2(two) => MigrationState::V3(Box::new((*two).try_into()?)), MigrationState::V3(three) => MigrationState::V4(Box::new((*three).try_into()?)), MigrationState::V4(_) => { break; } }; } regenerate_shims_for_dir(volta_home()?.shim_dir())?; Ok(()) } ================================================ FILE: crates/volta-migrate/src/v0.rs ================================================ use std::path::PathBuf; use volta_layout::v0::VoltaHome; /// Represents a V0 Volta layout (from before v0.7.0) /// /// This needs some migration work to move up to V1, so we keep a reference to the V0 layout /// struct to allow for easy comparison between versions pub struct V0 { pub home: VoltaHome, } impl V0 { pub fn new(home: PathBuf) -> Self { V0 { home: VoltaHome::new(home), } } } ================================================ FILE: crates/volta-migrate/src/v1.rs ================================================ #[cfg(unix)] use std::fs::remove_file; use std::fs::File; use std::path::PathBuf; use super::empty::Empty; use super::v0::V0; use log::debug; use volta_core::error::{Context, ErrorKind, Fallible, VoltaError}; #[cfg(unix)] use volta_core::fs::{read_dir_eager, remove_file_if_exists}; use volta_layout::v1; /// Represents a V1 Volta Layout (used by Volta v0.7.0 - v0.7.2) /// /// Holds a reference to the V1 layout struct to support potential future migrations pub struct V1 { pub home: v1::VoltaHome, } impl V1 { pub fn new(home: PathBuf) -> Self { V1 { home: v1::VoltaHome::new(home), } } /// Write the layout file to mark migration to V1 as complete /// /// Should only be called once all other migration steps are finished, so that we don't /// accidentally mark an incomplete migration as completed fn complete_migration(home: v1::VoltaHome) -> Fallible { debug!("Writing layout marker file"); File::create(home.layout_file()).with_context(|| ErrorKind::CreateLayoutFileError { file: home.layout_file().to_owned(), })?; Ok(V1 { home }) } } impl TryFrom for V1 { type Error = VoltaError; fn try_from(old: Empty) -> Fallible { debug!("New Volta installation detected, creating fresh layout"); let home = v1::VoltaHome::new(old.home); home.create().with_context(|| ErrorKind::CreateDirError { dir: home.root().to_owned(), })?; V1::complete_migration(home) } } impl TryFrom for V1 { type Error = VoltaError; fn try_from(old: V0) -> Fallible { debug!("Existing Volta installation detected, migrating from V0 layout"); let new_home = v1::VoltaHome::new(old.home.root().to_owned()); new_home .create() .with_context(|| ErrorKind::CreateDirError { dir: new_home.root().to_owned(), })?; #[cfg(unix)] { debug!("Removing unnecessary 'load.*' files"); let root_contents = read_dir_eager(new_home.root()).with_context(|| ErrorKind::ReadDirError { dir: new_home.root().to_owned(), })?; for (entry, _) in root_contents { let path = entry.path(); if let Some(stem) = path.file_stem() { if stem == "load" && path.is_file() { remove_file(&path) .with_context(|| ErrorKind::DeleteFileError { file: path })?; } } } debug!("Removing old Volta binaries"); let old_volta_bin = new_home.root().join("volta"); remove_file_if_exists(old_volta_bin)?; let old_shim_bin = new_home.root().join("shim"); remove_file_if_exists(old_shim_bin)?; } V1::complete_migration(new_home) } } ================================================ FILE: crates/volta-migrate/src/v2.rs ================================================ use std::fs::{read_to_string, write, File}; use std::io; use std::path::{Path, PathBuf}; use super::empty::Empty; use super::v1::V1; use log::debug; use node_semver::Version; use tempfile::tempdir_in; use volta_core::error::{Context, ErrorKind, Fallible, VoltaError}; use volta_core::fs::{read_dir_eager, remove_dir_if_exists, remove_file_if_exists, rename}; use volta_core::tool::load_default_npm_version; use volta_core::toolchain::serial::Platform; use volta_core::version::parse_version; use volta_layout::{v1, v2}; /// Represents a V2 Volta Layout (used by Volta v0.7.3 and above) /// /// Holds a reference to the V2 layout struct to support potential future migrations pub struct V2 { pub home: v2::VoltaHome, } impl V2 { pub fn new(home: PathBuf) -> Self { V2 { home: v2::VoltaHome::new(home), } } /// Write the layout file to mark migration to V2 as complete /// /// Should only be called once all other migration steps are finished, so that we don't /// accidentally mark an incomplete migration as completed fn complete_migration(home: v2::VoltaHome) -> Fallible { debug!("Writing layout marker file"); File::create(home.layout_file()).with_context(|| ErrorKind::CreateLayoutFileError { file: home.layout_file().to_owned(), })?; Ok(V2 { home }) } } impl TryFrom for V2 { type Error = VoltaError; fn try_from(old: Empty) -> Fallible { debug!("New Volta installation detected, creating fresh layout"); let home = v2::VoltaHome::new(old.home); home.create().with_context(|| ErrorKind::CreateDirError { dir: home.root().to_owned(), })?; V2::complete_migration(home) } } impl TryFrom for V2 { type Error = VoltaError; fn try_from(old: V1) -> Fallible { debug!("Migrating from V1 layout"); let new_home = v2::VoltaHome::new(old.home.root().to_owned()); new_home .create() .with_context(|| ErrorKind::CreateDirError { dir: new_home.root().to_owned(), })?; // Perform the core of the migration clear_default_npm(old.home.default_platform_file())?; shift_node_images(&old.home, &new_home)?; // Complete the migration, writing the V2 layout file let layout = V2::complete_migration(new_home)?; // Remove the V1 layout file, since we're now on V2 (do this after writing the V2 so that we know the migration succeeded) let old_layout_file = old.home.layout_file(); remove_file_if_exists(old_layout_file)?; Ok(layout) } } /// Clear npm from the default `platform.json` file if it is set to the same value as that bundled with Node /// /// This will ensure that we don't treat the default npm from a prior version of Volta as a "custom" npm that /// the user explicitly requested fn clear_default_npm(platform_file: &Path) -> Fallible<()> { let platform_json = match read_to_string(platform_file) { Ok(json) => json, Err(error) => { if error.kind() == io::ErrorKind::NotFound { return Ok(()); } else { return Err(VoltaError::from_source( error, ErrorKind::ReadPlatformError { file: platform_file.to_path_buf(), }, )); } } }; let mut existing_platform = Platform::try_from(platform_json)?; if let Some(ref mut node_version) = &mut existing_platform.node { if let Some(npm) = &node_version.npm { if let Ok(default_npm) = load_default_npm_version(&node_version.runtime) { if *npm == default_npm { node_version.npm = None; write(platform_file, existing_platform.into_json()?).with_context(|| { ErrorKind::WritePlatformError { file: platform_file.to_owned(), } })?; } } } } Ok(()) } /// Move all Node images up one directory, removing the default npm version directory /// /// In the V1 layout, we kept all node images in ///, however we will be /// storing custom npm versions in a separate image directory, so there is no need to maintain the /// bundled npm version in the file structure any more. This also will make it slightly easier to access /// the Node image, as we no longer will need to look up the bundled npm version every time. fn shift_node_images(old_home: &v1::VoltaHome, new_home: &v2::VoltaHome) -> Fallible<()> { let temp_dir = tempdir_in(new_home.tmp_dir()).with_context(|| ErrorKind::CreateTempDirError { in_dir: new_home.tmp_dir().to_owned(), })?; let node_installs = read_dir_eager(old_home.node_image_root_dir()) .with_context(|| ErrorKind::ReadDirError { dir: old_home.node_image_root_dir().to_owned(), })? .filter_map(|(entry, metadata)| { if metadata.is_dir() { parse_version(entry.file_name().to_string_lossy()).ok() } else { None } }); for node_version in node_installs { remove_npm_version_from_node_image_dir(old_home, new_home, node_version, temp_dir.path())?; } Ok(()) } /// Move a single node image up a directory, if it currently has the npm version in its path fn remove_npm_version_from_node_image_dir( old_home: &v1::VoltaHome, new_home: &v2::VoltaHome, node_version: Version, temp_dir: &Path, ) -> Fallible<()> { let node_string = node_version.to_string(); let npm_version = load_default_npm_version(&node_version)?; let old_install = old_home.node_image_dir(&node_string, &npm_version.to_string()); if old_install.exists() { let temp_image = temp_dir.join(&node_string); let new_install = new_home.node_image_dir(&node_string); rename(&old_install, &temp_image).with_context(|| ErrorKind::SetupToolImageError { tool: "Node".into(), version: node_string.clone(), dir: temp_image.clone(), })?; remove_dir_if_exists(&new_install)?; rename(&temp_image, &new_install).with_context(|| ErrorKind::SetupToolImageError { tool: "Node".into(), version: node_string, dir: temp_image, })?; } Ok(()) } ================================================ FILE: crates/volta-migrate/src/v3/config.rs ================================================ use std::fs::File; use std::path::Path; use node_semver::Version; use volta_core::platform::PlatformSpec; use volta_core::version::{option_version_serde, version_serde}; #[derive(serde::Deserialize)] pub struct LegacyPackageConfig { pub name: String, #[serde(with = "version_serde")] pub version: Version, pub platform: LegacyPlatform, pub bins: Vec, } #[derive(serde::Deserialize)] pub struct LegacyPlatform { pub node: NodeVersion, #[serde(with = "option_version_serde")] pub yarn: Option, } #[derive(serde::Deserialize)] pub struct NodeVersion { #[serde(with = "version_serde")] pub runtime: Version, #[serde(with = "option_version_serde")] pub npm: Option, } impl LegacyPackageConfig { pub fn from_file(config_file: &Path) -> Option { let file = File::open(config_file).ok()?; serde_json::from_reader(file).ok() } } impl From for PlatformSpec { fn from(config_platform: LegacyPlatform) -> Self { PlatformSpec { node: config_platform.node.runtime, npm: config_platform.node.npm, // LegacyPlatform (layout.v2) doesn't have a pnpm field pnpm: None, yarn: config_platform.yarn, } } } ================================================ FILE: crates/volta-migrate/src/v3.rs ================================================ use std::fs::File; use std::path::{Path, PathBuf}; use crate::empty::Empty; use crate::v2::V2; use log::{debug, warn}; use volta_core::error::{Context, ErrorKind, Fallible, VoltaError}; use volta_core::fs::{remove_dir_if_exists, remove_file_if_exists}; use volta_core::platform::PlatformSpec; use volta_core::session::Session; use volta_core::tool::{Package, PackageConfig}; use volta_core::version::VersionSpec; use volta_layout::{v2, v3}; use walkdir::WalkDir; mod config; use config::LegacyPackageConfig; /// Represents a V3 Volta layout (used by Volta v0.9.0 and above) /// /// Holds a reference to the V3 layout struct to support future migrations pub struct V3 { pub home: v3::VoltaHome, } impl V3 { pub fn new(home: PathBuf) -> Self { V3 { home: v3::VoltaHome::new(home), } } /// Write the layout file to mark migration to V2 as complete /// /// Should only be called once all other migration steps are finished, so that we don't /// accidentally mark an incomplete migration as completed fn complete_migration(home: v3::VoltaHome) -> Fallible { debug!("Writing layout marker file"); File::create(home.layout_file()).with_context(|| ErrorKind::CreateLayoutFileError { file: home.layout_file().to_owned(), })?; Ok(V3 { home }) } } impl TryFrom for V3 { type Error = VoltaError; fn try_from(old: Empty) -> Fallible { debug!("New Volta installation detected, creating fresh layout"); let home = v3::VoltaHome::new(old.home); home.create().with_context(|| ErrorKind::CreateDirError { dir: home.root().to_owned(), })?; V3::complete_migration(home) } } impl TryFrom for V3 { type Error = VoltaError; fn try_from(old: V2) -> Fallible { debug!("Migrating from V2 layout"); let new_home = v3::VoltaHome::new(old.home.root().to_owned()); new_home .create() .with_context(|| ErrorKind::CreateDirError { dir: new_home.root().to_owned(), })?; // Migrate installed packages to the new workflow migrate_packages(&old.home)?; // Remove the package inventory directory, as we no longer cache package tarballs remove_dir_if_exists(old.home.package_inventory_dir())?; // Complete the migration, writing the V3 layout file let layout = V3::complete_migration(new_home)?; // Remove the V2 layout file, since we're now on V3 (do this after writing the V3 file so that we know the migration succeeded) remove_file_if_exists(old.home.layout_file())?; Ok(layout) } } fn migrate_packages(old_home: &v2::VoltaHome) -> Fallible<()> { let packages = get_installed_packages(old_home); let mut session = Session::init(); for package in packages { migrate_single_package(package, &mut session)?; } Ok(()) } /// Determine a list of all installed packages that are using the legacy package config fn get_installed_packages(old_home: &v2::VoltaHome) -> Vec { WalkDir::new(old_home.default_package_dir()) .max_depth(2) .into_iter() .filter_map(|res| match res { Ok(entry) => { if entry.file_type().is_file() { let config = LegacyPackageConfig::from_file(entry.path()); // If unable to parse the config file and this isn't an already-migrated // package, then show debug information and a warning for the user. if config.is_none() && !is_migrated_config(entry.path()) { debug!("Unable to parse config file: {}", entry.path().display()); if let Some(name) = entry.path().file_stem() { let name = name.to_string_lossy(); warn!( "Could not migrate {}. Please run `volta install {0}` to migrate the package manually.", name ); } } config } else { None } } Err(error) => { debug!("Error reading directory entry: {}", error); None } }) .collect() } /// Determine if a package has already been migrated by attempting to read the V3 PackageConfig fn is_migrated_config(config_path: &Path) -> bool { PackageConfig::from_file(config_path).is_ok() } /// Migrate a single package to the new workflow /// /// Note: This relies on the package install logic in `volta_core`. If that logic changes, then /// the end result may not be a valid V3 layout any more, and this migration will need to be /// updated. Specifically, the invariants we rely on are: /// /// - Package image directory is in the same location /// - Package config files are in the same location and the same format /// - Binary config files are in the same location and the same format /// /// If any of those are violated, this migration may be invalid and need to be reworked / scrapped fn migrate_single_package(config: LegacyPackageConfig, session: &mut Session) -> Fallible<()> { let tool = Package::new(config.name, VersionSpec::Exact(config.version))?; let platform: PlatformSpec = config.platform.into(); let image = platform.as_binary().checkout(session)?; // Run the global install command tool.run_install(&image)?; // Overwrite the config files and image directory tool.complete_install(&image)?; Ok(()) } ================================================ FILE: crates/volta-migrate/src/v4.rs ================================================ use std::fs::File; use std::path::PathBuf; use super::empty::Empty; use super::v3::V3; use log::debug; use volta_core::error::{Context, ErrorKind, Fallible, VoltaError}; #[cfg(windows)] use volta_core::fs::read_dir_eager; use volta_core::fs::remove_file_if_exists; use volta_layout::v4; /// Represents a V4 Volta Layout (used by Volta v2.0.0 and above) /// /// Holds a reference to the V4 layout struct to support potential future migrations pub struct V4 { pub home: v4::VoltaHome, } impl V4 { pub fn new(home: PathBuf) -> Self { V4 { home: v4::VoltaHome::new(home), } } /// Write the layout file to mark migration to V4 as complete /// /// Should only be called once all other migration steps are finished, so that we don't /// accidentally mark an incomplete migration as completed fn complete_migration(home: v4::VoltaHome) -> Fallible { debug!("Writing layout marker file"); File::create(home.layout_file()).with_context(|| ErrorKind::CreateLayoutFileError { file: home.layout_file().to_owned(), })?; Ok(V4 { home }) } } impl TryFrom for V4 { type Error = VoltaError; fn try_from(old: Empty) -> Fallible { debug!("New Volta installation detected, creating fresh layout"); let home = v4::VoltaHome::new(old.home); home.create().with_context(|| ErrorKind::CreateDirError { dir: home.root().to_owned(), })?; V4::complete_migration(home) } } impl TryFrom for V4 { type Error = VoltaError; fn try_from(old: V3) -> Fallible { debug!("Migrating from V3 layout"); let new_home = v4::VoltaHome::new(old.home.root().to_owned()); new_home .create() .with_context(|| ErrorKind::CreateDirError { dir: new_home.root().to_owned(), })?; // Perform the core of the migration #[cfg(windows)] { migrate_shims(&new_home)?; migrate_shared_directory(&new_home)?; } // Complete the migration, writing the V4 layout file let layout = V4::complete_migration(new_home)?; // Remove the V3 layout file, since we're now on V4 (do this after writing the V4 so that we know the migration succeeded) let old_layout_file = old.home.layout_file(); remove_file_if_exists(old_layout_file)?; Ok(layout) } } /// Migrate Windows shims to use the new non-symlink approach. Previously, shims were created in /// the same way as on Unix: With symlinks to the `volta-shim` executable. Now, we use scripts that /// call `volta run` to execute the underlying tool. This allows us to avoid needing developer /// mode, making Volta more broadly usable for Windows devs. /// /// To migrate the shims, we read the shim directory looking for symlinks, remove those, and then /// file stem (name without extension) to generate new shims. #[cfg(windows)] fn migrate_shims(new_home: &v4::VoltaHome) -> Fallible<()> { use std::ffi::OsStr; let entries = read_dir_eager(new_home.shim_dir()).with_context(|| ErrorKind::ReadDirError { dir: new_home.shim_dir().to_owned(), })?; for (entry, metadata) in entries { if metadata.is_symlink() { let path = entry.path(); remove_file_if_exists(&path)?; if let Some(shim_name) = path.file_stem().and_then(OsStr::to_str) { volta_core::shim::create(shim_name)?; } } } Ok(()) } /// Migrate Windows shared directory to use junctions rather than directory symlinks. Similar to /// the shims, we previously used symlinks to create the shared global package directory, which /// requires developer mode. By using junctions, we can avoid that requirement entirely. /// /// To migrate the directories, we read the shim directory, determine the target of each symlink, /// delete the link, and then create a junction (using volta_core::fs::symlink_dir which delegates /// to `junction` internally) #[cfg(windows)] fn migrate_shared_directory(new_home: &v4::VoltaHome) -> Fallible<()> { use std::fs::read_link; use volta_core::fs::{remove_dir_if_exists, symlink_dir}; let entries = read_dir_eager(new_home.shared_lib_root()).with_context(|| ErrorKind::ReadDirError { dir: new_home.shared_lib_root().to_owned(), })?; for (entry, metadata) in entries { if metadata.is_symlink() { let path = entry.path(); let source = read_link(&path).with_context(|| ErrorKind::ReadDirError { dir: new_home.shared_lib_root().to_owned(), })?; remove_dir_if_exists(&path)?; symlink_dir(source, path).with_context(|| ErrorKind::CreateSharedLinkError { name: entry.file_name().to_string_lossy().to_string(), })?; } } Ok(()) } ================================================ FILE: dev/package.json ================================================ { "name": "example", "version": "1.0", "description": "an example Node project using volta", "author": "Dave Herman ", "main": "lib/index.js", "devDependencies": { "ember-cli": "2.18.1" }, "volta": { "node": "6.11.1", "yarn": "1.7.0" } } ================================================ FILE: dev/rpm/build-rpm.sh ================================================ #!/usr/bin/env bash # Build an RPM package for Volta # using the directions from https://rpm-packaging-guide.github.io/ # exit on error set -e # only argument is the version number release_version="${1:?Must specify the release version, like \`build-rpm 1.2.3\`}" archive_filename="v${release_version}.tar.gz" # make sure these packages are installed # (https://rpm-packaging-guide.github.io/#prerequisites) sudo yum install gcc rpm-build rpm-devel rpmlint make python bash coreutils diffutils patch rpmdevtools # set up the directory layout for the RPM packaging workspace # (https://rpm-packaging-guide.github.io/#rpm-packaging-workspace) rpmdev-setuptree # create a tarball of the repo for the specified version # using prefix because the rpmbuild process expects a 'volta-' directory # (https://rpm-packaging-guide.github.io/#putting-source-code-into-tarball) git archive --format=tar.gz --output=$archive_filename --prefix="volta-${release_version}/" HEAD # move the archive to the SOURCES dir, after cleaning it up # (https://rpm-packaging-guide.github.io/#working-with-spec-files) rm -rf "$HOME/rmpbuild/SOURCES/"* mv "$archive_filename" "$HOME/rpmbuild/SOURCES/" # copy the .spec file to SPECS dir cp dev/rpm/volta.spec "$HOME/rpmbuild/SPECS/" # build it! # (https://rpm-packaging-guide.github.io/#binary-rpms) rpmbuild -bb "$HOME/rpmbuild/SPECS/volta.spec" # (there will be a lot of output) # then install it and verify everything worked... echo "" echo "Build finished!" echo "" echo "Run this to install:" echo " \`sudo yum install ~/rpmbuild/RPMS/x86_64/volta-${release_version}-1.el7.x86_64.rpm\`" echo "" echo "Then run this to uninstall after verifying:" echo " \`sudo yum erase volta-${release_version}-1.el7.x86_64\`" ================================================ FILE: dev/rpm/volta.spec ================================================ Name: volta Version: 0.8.2 Release: 1%{?dist} Summary: The JavaScript Launcher ⚡ License: BSD 2-CLAUSE URL: https://%{name}.sh Source0: https://github.com/volta-cli/volta/archive/v%{version}.tar.gz # cargo is required, but installing from RPM is failing with libcrypto dep error # so you will have to install cargo manually to build this #BuildRequires: cargo # because these are built with openssl Requires: openssl %description Volta’s job is to manage your JavaScript command-line tools, such as node, npm, yarn, or executables shipped as part of JavaScript packages. Similar to package managers, Volta keeps track of which project (if any) you’re working on based on your current directory. The tools in your Volta toolchain automatically detect when you’re in a project that’s using a particular version of the tools, and take care of routing to the right version of the tools for you. %prep # this unpacks the tarball to the build root %setup -q %build # build the release binaries # NOTE: build expects to `cd` into a volta- directory cargo build --release # this installs into a chroot directory resembling the user's root directory %install # BUILDROOT/usr/bin %define volta_install_dir %{buildroot}/%{_bindir} # setup the /usr/bin/volta-lib/ directory rm -rf %{buildroot} mkdir -p %{volta_install_dir} # install everything into into /usr/bin/, so it's on the PATH install -m 0755 target/release/%{name} %{volta_install_dir}/%{name} install -m 0755 target/release/volta-shim %{volta_install_dir}/volta-shim install -m 0755 target/release/volta-migrate %{volta_install_dir}/volta-migrate # files installed by this package %files %license LICENSE %{_bindir}/%{name} %{_bindir}/volta-shim %{_bindir}/volta-migrate # this runs before install %pre # make sure the /usr/bin/volta/ dir does not exist, from prev RPM installs (or this will fail) printf '\033[1;32m%12s\033[0m %s\n' "Running" "Volta pre-install..." 1>&2 rm -rf %{_bindir}/%{name} # this runs after install, and sets up VOLTA_HOME and the shell integration %post printf '\033[1;32m%12s\033[0m %s\n' "Running" "Volta post-install setup..." 1>&2 # run this as the user who invoked sudo (not as root, because we're writing to $HOME) /bin/su -c "%{_bindir}/volta setup" - $SUDO_USER %changelog * Tue Oct 22 2019 Charles Pierce - 0.6.5-1 - Update to use 'volta setup' as the postinstall script * Mon Jun 03 2019 Michael Stewart - 0.5.3-1 - First volta package ================================================ FILE: dev/unix/SHASUMS256.txt ================================================ fbdc4b8cb33fb6d19e5f07b22423265943d34e7e5c3d5a1efcecc9621854f9cb volta-install.sh ================================================ FILE: dev/unix/boot-install.sh ================================================ #!/usr/bin/env bash # This is the bootstrap Unix installer served by `https://get.volta.sh`. # Its responsibility is to query the system to determine what OS (and in the # case of Linux, what OpenSSL version) the system has, and then proceed to # fetch and install the appropriate build of Volta. volta_get_latest_release() { curl --silent https://volta.sh/latest-version } volta_eprintf() { command printf "$1\n" 1>&2 } volta_info() { local ACTION local DETAILS ACTION="$1" DETAILS="$2" command printf '\033[1;32m%12s\033[0m %s\n' "${ACTION}" "${DETAILS}" 1>&2 } volta_error() { command printf '\033[1;31mError\033[0m: ' 1>&2 volta_eprintf "$1" } volta_warning() { command printf '\033[1;33mWarning\033[0m: ' 1>&2 volta_eprintf "$1" volta_eprintf '' } volta_request() { command printf "\033[1m$1\033[0m" 1>&2 volta_eprintf '' } legacy_install_dir() { printf "%s" "${NOTION_HOME:-"$HOME/.notion"}" } # Check for a legacy installation from when the tool was named Notion. volta_check_legacy_installation() { local LEGACY_INSTALL_DIR="$(legacy_install_dir)" if [[ -d "$LEGACY_INSTALL_DIR" ]]; then volta_eprintf "" volta_error "You have an existing Notion install, which can't be automatically upgraded to Volta." volta_request " Please delete $LEGACY_INSTALL_DIR and try again." volta_eprintf "" volta_eprintf "(We plan to implement automatic upgrades in the future. Thanks for bearing with us!)" volta_eprintf "" exit 1 fi } volta_install_dir() { printf %s "${VOLTA_HOME:-"$HOME/.volta"}" } # Check for an existing installation that needs to be removed. volta_check_existing_installation() { local LATEST_VERSION="$1" local INSTALL_DIR="$(volta_install_dir)" local VOLTA_BIN="${INSTALL_DIR}/volta" if [[ -n "$INSTALL_DIR" && -x "$VOLTA_BIN" ]]; then local PREV_VOLTA_VERSION # Some 0.1.* builds would eagerly validate package.json even for benign commands, # so just to be safe we'll ignore errors and consider those to be 0.1 as well. PREV_VOLTA_VERSION="$( ($VOLTA_BIN --version 2>/dev/null || echo 0.1) | sed -E 's/^.*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')" if [ "$PREV_VOLTA_VERSION" == "$LATEST_VERSION" ]; then volta_eprintf "" volta_eprintf "Latest version $LATEST_VERSION already installed" exit 0 fi if [[ "$PREV_VOLTA_VERSION" == 0.1* || "$PREV_VOLTA_VERSION" == 0.2* || "$PREV_VOLTA_VERSION" == 0.3* ]]; then volta_eprintf "" volta_error "Your Volta installation is out of date and can't be automatically upgraded." volta_request " Please delete or move $INSTALL_DIR and try again." volta_eprintf "" volta_eprintf "(We plan to implement automatic upgrades in the future. Thanks for bearing with us!)" volta_eprintf "" exit 1 fi fi } # determines the major and minor version of OpenSSL on the system volta_get_openssl_version() { local LIB local LIBNAME local FULLVERSION local MAJOR local MINOR # By default, we'll guess OpenSSL 1.0.1. LIB="$(openssl version 2>/dev/null || echo 'OpenSSL 1.0.1')" LIBNAME="$(echo $LIB | awk '{print $1;}')" if [[ "$LIBNAME" != "OpenSSL" ]]; then volta_error "Your system SSL library ($LIBNAME) is not currently supported on this OS." volta_eprintf "" exit 1 fi FULLVERSION="$(echo $LIB | awk '{print $2;}')" MAJOR="$(echo ${FULLVERSION} | cut -d. -f1)" MINOR="$(echo ${FULLVERSION} | cut -d. -f2)" # If we have version 1.0.x, check for RHEL / CentOS style OpenSSL SONAME (.so.10) if [[ "${MAJOR}.${MINOR}" == "1.0" && -f "/usr/lib64/libcrypto.so.10" ]]; then echo "rhel" else echo "${MAJOR}.${MINOR}" fi } VOLTA_LATEST_VERSION=$(volta_get_latest_release) volta_info 'Checking' "for existing Volta installation" volta_check_legacy_installation volta_check_existing_installation "$VOLTA_LATEST_VERSION" case $(uname) in Linux) VOLTA_OS="linux-openssl-$(volta_get_openssl_version)" VOLTA_PRETTY_OS=Linux ;; Darwin) VOLTA_OS=macos VOLTA_PRETTY_OS=macOS ;; *) volta_error "The current operating system does not appear to be supported by Volta." volta_eprintf "" exit 1 esac VOLTA_INSTALLER="https://github.com/volta-cli/volta/releases/download/v${VOLTA_LATEST_VERSION}/volta-${VOLTA_LATEST_VERSION}-${VOLTA_OS}.sh" volta_info 'Fetching' "${VOLTA_PRETTY_OS} installer" curl -#SLf ${VOLTA_INSTALLER} | bash STATUS=$? exit $STATUS ================================================ FILE: dev/unix/build.sh ================================================ #!/usr/bin/env bash script_dir="$(dirname "$0")" usage() { cat <|" > $1.base64.txt cat $3 | base64 - | tr -d '\n' >> $1.base64.txt command printf "|\n" >> $1.base64.txt } encode_expand_sed_command() { # This atrocity is a combination of: # - https://unix.stackexchange.com/questions/141387/sed-replace-string-with-file-contents # - https://serverfault.com/questions/391360/remove-line-break-using-awk # - https://stackoverflow.com/questions/1421478/how-do-i-use-a-new-line-replacement-in-a-bsd-sed command printf "s||$(sed 's/|/\\|/g' $3 | awk '{printf "%s\\\n",$0} END {print ""}' )\\\n|\n" > $1.expand.txt } build_dir="$script_dir/../../target/$target_dir" shell_dir="$script_dir/../../shell" encode_base64_sed_command volta VOLTA "$build_dir/volta" encode_base64_sed_command shim SHIM "$build_dir/shim" encode_expand_sed_command bash_launcher BASH_LAUNCHER "$shell_dir/unix/load.sh" encode_expand_sed_command fish_launcher FISH_LAUNCHER "$shell_dir/unix/load.fish" sed -f volta.base64.txt \ -f shim.base64.txt \ -f bash_launcher.expand.txt \ -f fish_launcher.expand.txt \ < "$script_dir/install.sh.in" > "$script_dir/install.sh" chmod 755 "$script_dir/install.sh" rm volta.base64.txt \ shim.base64.txt \ bash_launcher.expand.txt \ fish_launcher.expand.txt ================================================ FILE: dev/unix/install.sh.in ================================================ #!/usr/bin/env bash { volta_unpack_volta() { base64 --decode <<'END_BINARY_PAYLOAD' END_BINARY_PAYLOAD } volta_unpack_shim() { base64 --decode <<'END_BINARY_PAYLOAD' END_BINARY_PAYLOAD } volta_unpack_bash_launcher() { cat <<'END_TEXT_PAYLOAD' END_TEXT_PAYLOAD } volta_unpack_fish_launcher() { cat <<'END_TEXT_PAYLOAD' END_TEXT_PAYLOAD } volta_install_dir() { printf %s "${VOLTA_HOME:-"$HOME/.volta"}" } volta_create_tree() { local INSTALL_DIR INSTALL_DIR="$(volta_install_dir)" mkdir -p "${INSTALL_DIR}" # ~/ # .volta/ # cache/ # node/ # tools/ # inventory/ # node/ # packages/ # yarn/ # image/ # node/ # yarn/ # user/ # bin/ # tmp/ mkdir -p "${INSTALL_DIR}"/cache/node mkdir -p "${INSTALL_DIR}"/tools/inventory/node mkdir -p "${INSTALL_DIR}"/tools/inventory/packages mkdir -p "${INSTALL_DIR}"/tools/inventory/yarn mkdir -p "${INSTALL_DIR}"/tools/image/node mkdir -p "${INSTALL_DIR}"/tools/image/yarn mkdir -p "${INSTALL_DIR}"/tools/user mkdir -p "${INSTALL_DIR}"/bin mkdir -p "${INSTALL_DIR}"/tmp } volta_create_binaries() { local INSTALL_DIR INSTALL_DIR="$(volta_install_dir)" volta_unpack_volta > "${INSTALL_DIR}"/volta volta_unpack_shim > "${INSTALL_DIR}"/shim volta_unpack_bash_launcher > "${INSTALL_DIR}"/load.sh volta_unpack_fish_launcher > "${INSTALL_DIR}"/load.fish # Remove any existing binaries for tools so that the symlinks can be installed # using -f so there is no error if the files don't exist rm -f "${INSTALL_DIR}"/bin/node rm -f "${INSTALL_DIR}"/bin/npm rm -f "${INSTALL_DIR}"/bin/npx rm -f "${INSTALL_DIR}"/bin/yarn for FILE_NAME in "${INSTALL_DIR}"/bin/*; do if [ -e "${FILE_NAME}" ] && ! [ -d "${FILE_NAME}" ]; then rm -f "${FILE_NAME}" ln -s "${INSTALL_DIR}"/shim "${FILE_NAME}" fi done ln -s "${INSTALL_DIR}"/shim "${INSTALL_DIR}"/bin/node ln -s "${INSTALL_DIR}"/shim "${INSTALL_DIR}"/bin/npm ln -s "${INSTALL_DIR}"/shim "${INSTALL_DIR}"/bin/npx ln -s "${INSTALL_DIR}"/shim "${INSTALL_DIR}"/bin/yarn ln -s "${INSTALL_DIR}"/shim "${INSTALL_DIR}"/bin/yarnpkg chmod 755 "${INSTALL_DIR}/"/volta "${INSTALL_DIR}/bin"/* "${INSTALL_DIR}"/shim } volta_try_profile() { if [ -z "${1-}" ] || [ ! -f "${1}" ]; then return 1 fi echo "${1}" } # If file exists, echo it echo_fexists() { [ -f "$1" ] && echo "$1" } volta_detect_profile() { if [ -n "${PROFILE}" ] && [ -f "${PROFILE}" ]; then echo "${PROFILE}" return fi # try to detect the current shell case "$(basename "/$SHELL")" in bash) # Shells on macOS default to opening with a login shell, while Linuxes # default to a *non*-login shell, so if this is macOS we look for # `.bash_profile` first; if it's Linux, we look for `.bashrc` first. The # `*` fallthrough covers more than just Linux: it's everything that is not # macOS (Darwin). It can be made narrower later if need be. case $(uname) in Darwin) echo_fexists "$HOME/.bash_profile" || echo_fexists "$HOME/.bashrc" ;; *) echo_fexists "$HOME/.bashrc" || echo_fexists "$HOME/.bash_profile" ;; esac ;; zsh) echo_fexists "$HOME/.zshenv" || echo_fexists "$HOME/.zshrc" ;; fish) echo "$HOME/.config/fish/config.fish" ;; *) # Fall back to checking for profile file existence. Once again, the order # differs between macOS and everything else. local profiles case $(uname) in Darwin) profiles=( .profile .bash_profile .bashrc .zshrc .config/fish/config.fish ) ;; *) profiles=( .profile .bashrc .bash_profile .zshrc .config/fish/config.fish ) ;; esac for profile in "${profiles[@]}"; do echo_fexists "$HOME/$profile" && break done ;; esac } volta_build_path_str() { local PROFILE PROFILE="$1" local PROFILE_INSTALL_DIR PROFILE_INSTALL_DIR="$2" local PATH_STR if [[ $PROFILE =~ \.fish$ ]]; then PATH_STR="\\nset -gx VOLTA_HOME \"${PROFILE_INSTALL_DIR}\"\\ntest -s \"\$VOLTA_HOME/load.fish\"; and source \"\$VOLTA_HOME/load.fish\"\\n\\nstring match -r \".volta\" \"\$PATH\" > /dev/null; or set -gx PATH \"\$VOLTA_HOME/bin\" \$PATH" else PATH_STR="\\nexport VOLTA_HOME=\"${PROFILE_INSTALL_DIR}\"\\n[ -s \"\$VOLTA_HOME/load.sh\" ] && \\. \"\$VOLTA_HOME/load.sh\"\\n\\nexport PATH=\"\${VOLTA_HOME}/bin:\$PATH\"" fi echo "$PATH_STR" } volta_eprintf() { command printf "$1\n" 1>&2 } volta_info() { local ACTION local DETAILS ACTION="$1" DETAILS="$2" command printf '\033[1;32m%12s\033[0m %s\n' "${ACTION}" "${DETAILS}" 1>&2 } volta_error() { command printf '\033[1;31mError\033[0m: ' 1>&2 volta_eprintf "$1" volta_eprintf '' } volta_warning() { command printf '\033[1;33mWarning\033[0m: ' 1>&2 volta_eprintf "$1" } volta_install() { if [ -n "${VOLTA_HOME-}" ] && [ -e "${VOLTA_HOME}" ] && ! [ -d "${VOLTA_HOME}" ]; then volta_error "\$VOLTA_HOME is set but is not a directory (${VOLTA_HOME})." volta_eprintf "Please check your profile scripts and environment." exit 1 fi volta_info 'Creating' "Volta directory tree ($(volta_install_dir))" volta_create_tree volta_info 'Unpacking' "\`volta\` executable and shims" volta_create_binaries local VOLTA_PROFILE VOLTA_PROFILE="$(volta_detect_profile)" volta_info 'Editing' "user profile ($VOLTA_PROFILE)" local PROFILE_INSTALL_DIR PROFILE_INSTALL_DIR=$(volta_install_dir | sed "s:^$HOME:\$HOME:") local PATH_STR PATH_STR="$(volta_build_path_str "$VOLTA_PROFILE" "$PROFILE_INSTALL_DIR")" if [ -z "${VOLTA_PROFILE-}" ] ; then local TRIED_PROFILE if [ -n "${PROFILE}" ]; then TRIED_PROFILE="${VOLTA_PROFILE} (as defined in \$PROFILE), " fi volta_error "No user profile found." volta_eprintf "Tried ${TRIED_PROFILE-}~/.bashrc, ~/.bash_profile, ~/.zshrc, ~/.profile, and ~.config/fish/config.fish." volta_eprintf '' volta_eprintf "You can either create one of these and try again or add this to the appropriate file:" volta_eprintf "${PATH_STR}" exit 1 else if ! command grep -qc 'VOLTA_HOME' "$VOLTA_PROFILE"; then command printf "${PATH_STR}" >> "$VOLTA_PROFILE" else volta_eprintf '' volta_warning "Your profile (${VOLTA_PROFILE}) already mentions" volta_eprintf " Volta and has not been changed." volta_eprintf '' fi fi if command grep -qc 'NOTION_HOME' "$VOLTA_PROFILE"; then volta_eprintf '' volta_warning "Your profile (${VOLTA_PROFILE}) mentions Notion." volta_eprintf " You probably want to remove that." volta_eprintf '' fi volta_info "Finished" 'installation. Open a new terminal to start using Volta!' exit 0 } volta_install } ================================================ FILE: dev/unix/release.sh ================================================ #!/usr/bin/env bash # Script to build the binaries and package them up for release. # This should be run from the top-level directory. # get the directory of this script # (from https://stackoverflow.com/a/246128) DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" # get shared functions from the volta-install.sh file source "$DIR/volta-install.sh" usage() { cat >&2 </dev/null 2>&1 && echo "$some_data" | jq '.' || echo "$some_data" i=0 while [ $i -lt 3 ] do sleep 2s echo "$my_pid still running!" let i=i+1 done echo "$my_pid done!!" ================================================ FILE: dev/unix/tests/install-script.bats ================================================ # test the volta-install.sh script # load the functions from the script source dev/unix/volta-install.sh # happy path test to parse the version from Cargo.toml @test "parse_cargo_version - normal Cargo.toml" { input=$(cat <<'END_CARGO_TOML' [package] name = "volta" version = "0.7.38" authors = ["David Herman "] license = "BSD-2-Clause" END_CARGO_TOML ) expected_output="0.7.38" run parse_cargo_version "$input" [ "$status" -eq 0 ] diff <(echo "$output") <(echo "$expected_output") } # it doesn't parse the version from other dependencies @test "parse_cargo_version - error" { input=$(cat <<'END_CARGO_TOML' [dependencies] volta-core = { path = "crates/volta-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.37" console = "0.6.1" END_CARGO_TOML ) expected_output=$(echo -e "\033[1;31mError\033[0m: Could not determine the current version from Cargo.toml") run parse_cargo_version "$input" [ "$status" -eq 1 ] diff <(echo "$output") <(echo "$expected_output") } # linux @test "parse_os_info - linux" { expected_output="linux" run parse_os_info "Linux" [ "$status" -eq 0 ] diff <(echo "$output") <(echo "$expected_output") } # macos @test "parse_os_info - macos" { expected_output="macos" run parse_os_info "Darwin" [ "$status" -eq 0 ] diff <(echo "$output") <(echo "$expected_output") } # unsupported OS @test "parse_os_info - unsupported OS" { expected_output="" run parse_os_info "DOS" [ "$status" -eq 1 ] diff <(echo "$output") <(echo "$expected_output") } # test element_in helper function @test "element_in works correctly" { run element_in "foo" "foo" "bar" "baz" [ "$status" -eq 0 ] array=( "foo" "bar" "baz" ) run element_in "foo" "${array[@]}" [ "$status" -eq 0 ] run element_in "bar" "${array[@]}" [ "$status" -eq 0 ] run element_in "baz" "${array[@]}" [ "$status" -eq 0 ] run element_in "fob" "${array[@]}" [ "$status" -eq 1 ] } # test VOLTA_HOME settings @test "volta_home_is_ok - true cases" { # unset is fine unset VOLTA_HOME run volta_home_is_ok [ "$status" -eq 0 ] # empty is fine VOLTA_HOME="" run volta_home_is_ok [ "$status" -eq 0 ] # non-existing dir is fine VOLTA_HOME="/some/dir/that/does/not/exist/anywhere" run volta_home_is_ok [ "$status" -eq 0 ] # existing dir is fine VOLTA_HOME="$HOME" run volta_home_is_ok [ "$status" -eq 0 ] } @test "volta_home_is_ok - not ok" { # file is not ok VOLTA_HOME="$(mktemp)" run volta_home_is_ok [ "$status" -eq 1 ] } # TODO: test creating symlinks ================================================ FILE: dev/unix/volta-install-legacy.sh ================================================ #!/usr/bin/env bash # This is the bootstrap Unix installer served by `https://get.volta.sh`. # Its responsibility is to query the system to determine what OS (and in the # case of Linux, what OpenSSL version) the system has, fetch and install the # appropriate build of Volta, and modify the user's profile. # NOTE: to use an internal company repo, change how this determines the latest version get_latest_release() { curl --silent "https://volta.sh/latest-version" } release_url() { echo "https://github.com/volta-cli/volta/releases" } download_release_from_repo() { local version="$1" local os_info="$2" local tmpdir="$3" local filename="volta-$version-$os_info.tar.gz" local download_file="$tmpdir/$filename" local archive_url="$(release_url)/download/v$version/$filename" curl --progress-bar --show-error --location --fail "$archive_url" --output "$download_file" && echo "$download_file" } usage() { cat >&2 < Install a specific release version of Volta END_USAGE } info() { local action="$1" local details="$2" command printf '\033[1;32m%12s\033[0m %s\n' "$action" "$details" 1>&2 } error() { command printf '\033[1;31mError\033[0m: %s\n\n' "$1" 1>&2 } warning() { command printf '\033[1;33mWarning\033[0m: %s\n\n' "$1" 1>&2 } request() { command printf '\033[1m%s\033[0m\n' "$1" 1>&2 } eprintf() { command printf '%s\n' "$1" 1>&2 } bold() { command printf '\033[1m%s\033[0m' "$1" } # create symlinks for shims in the bin/ dir create_symlinks() { local install_dir="$1" info 'Creating' "symlinks and shims" local main_shims=( node npm npx yarn yarnpkg ) local shim_exec="$install_dir/shim" local main_exec="$install_dir/volta" # remove these symlinks or binaries if they exist, so that the symlinks can be created later # (using -f so there is no error if the files don't exist) for shim in "${main_shims[@]}"; do rm -f "$install_dir/bin/$shim" done # update symlinks for any shims created by the user for file in "$install_dir"/bin/*; do if [ -e "$file" ] && ! [ -d "$file" ]; then rm -f "$file" ln -s "$shim_exec" "$file" chmod 755 "$file" fi done # re-link the non-user shims for shim in "${main_shims[@]}"; do ln -s "$shim_exec" "$install_dir/bin/$shim" chmod 755 "$install_dir/bin/$shim" done # and make sure these are executable chmod 755 "$shim_exec" "$main_exec" } # If file exists, echo it echo_fexists() { [ -f "$1" ] && echo "$1" } detect_profile() { local shellname="$1" local uname="$2" if [ -f "$PROFILE" ]; then echo "$PROFILE" return fi # try to detect the current shell case "$shellname" in bash) # Shells on macOS default to opening with a login shell, while Linuxes # default to a *non*-login shell, so if this is macOS we look for # `.bash_profile` first; if it's Linux, we look for `.bashrc` first. The # `*` fallthrough covers more than just Linux: it's everything that is not # macOS (Darwin). It can be made narrower later if need be. case $uname in Darwin) echo_fexists "$HOME/.bash_profile" || echo_fexists "$HOME/.bashrc" ;; *) echo_fexists "$HOME/.bashrc" || echo_fexists "$HOME/.bash_profile" ;; esac ;; zsh) echo_fexists "$HOME/.zshenv" || echo_fexists "$HOME/.zshrc" ;; fish) echo "$HOME/.config/fish/config.fish" ;; *) # Fall back to checking for profile file existence. Once again, the order # differs between macOS and everything else. local profiles case $uname in Darwin) profiles=( .profile .bash_profile .bashrc .zshrc .config/fish/config.fish ) ;; *) profiles=( .profile .bashrc .bash_profile .zshrc .config/fish/config.fish ) ;; esac for profile in "${profiles[@]}"; do echo_fexists "$HOME/$profile" && break done ;; esac } # generate shell code to source the loading script and modify the path for the input profile build_path_str() { local profile="$1" local profile_install_dir="$2" if [[ $profile =~ \.fish$ ]]; then # fish uses a little different syntax to load the shell integration script, and modify the PATH cat < /dev/null; or set -gx PATH "\$VOLTA_HOME/bin" \$PATH END_FISH_SCRIPT else # bash and zsh cat <> "$detected_profile" else warning "Your profile ($detected_profile) already mentions Volta and has not been changed." fi fi if command grep -qc 'NOTION_HOME' "$detected_profile"; then eprintf '' warning "Your profile ($detected_profile) mentions Notion." eprintf " You probably want to remove that." eprintf '' fi } legacy_dir() { echo "${NOTION_HOME:-"$HOME/.notion"}" } # Check for a legacy installation from when the tool was named Notion. no_legacy_install() { if [ -d "$(legacy_dir)" ]; then eprintf "" error "You have an existing Notion install, which can't be automatically upgraded to Volta." request " Please delete $(legacy_dir) and try again." eprintf "" eprintf "(We plan to implement automatic upgrades in the future. Thanks for bearing with us!)" eprintf "" return 1 fi return 0 } # Check if it is OK to upgrade to the new version upgrade_is_ok() { local will_install_version="$1" local install_dir="$2" local is_dev_install="$3" local volta_bin="$install_dir/volta" # this is not able to install Volta prior to 0.5.0 (when it was renamed) if [[ "$will_install_version" =~ ^([0-9]+\.[0-9]+) ]]; then local major_minor="${BASH_REMATCH[1]}" case "$major_minor" in 0.1|0.2|0.3|0.4) eprintf "" error "Cannot install Volta prior to version 0.5.0 (when it was named Notion)" request " To install Notion version $will_install_version, please check out the source and build manually." eprintf "" return 1 ;; esac fi if [[ -n "$install_dir" && -x "$volta_bin" ]]; then local prev_version="$( ($volta_bin --version 2>/dev/null || echo 0.1) | sed -E 's/^.*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')" # if this is a local dev install, skip the equality check # if installing the same version, this is a no-op if [ "$is_dev_install" != "true" ] && [ "$prev_version" == "$will_install_version" ]; then eprintf "Version $will_install_version already installed" return 1 fi # in the future, check $prev_version for incompatible upgrades fi return 0 } # returns the os name to be used in the packaged release, # including the openssl info if necessary parse_os_info() { local uname_str="$1" local openssl_version="$2" case "$uname_str" in Linux) parsed_version="$(parse_openssl_version "$openssl_version")" exit_code="$?" if [ "$exit_code" != 0 ]; then return "$exit_code" fi echo "linux-openssl-$parsed_version" ;; Darwin) echo "macos" ;; *) return 1 esac return 0 } parse_os_pretty() { local uname_str="$1" case "$uname_str" in Linux) echo "Linux" ;; Darwin) echo "macOS" ;; *) echo "$uname_str" esac } # return true(0) if the element is contained in the input arguments # called like: # if element_in "foo" "${array[@]}"; then ... element_in() { local match="$1"; shift local element; # loop over the input arguments and return when a match is found for element in "$@"; do [ "$element" == "$match" ] && return 0 done return 1 } # parse the OpenSSL version from the input text # for most distros, we only care about MAJOR.MINOR, with the exception of RHEL/CENTOS, parse_openssl_version() { local version_str="$1" # array containing the SSL libraries that are supported # would be nice to use a bash 4.x associative array, but bash 3.x is the default on OSX SUPPORTED_SSL_LIBS=( 'OpenSSL' ) # use regex to get the library name and version # typical version string looks like 'OpenSSL 1.0.1e-fips 11 Feb 2013' if [[ "$version_str" =~ ^([^\ ]*)\ ([0-9]+\.[0-9]+) ]] then # check that the lib is supported libname="${BASH_REMATCH[1]}" major_minor="${BASH_REMATCH[2]}" if ! element_in "$libname" "${SUPPORTED_SSL_LIBS[@]}" then error "Releases for '$libname' not currently supported. Supported libraries are: ${SUPPORTED_SSL_LIBS[@]}." return 1 fi # for version 1.0.x, check for RHEL/CentOS style OpenSSL SONAME (.so.10) if [ "$major_minor" == "1.0" ] && [ -f "/usr/lib64/libcrypto.so.10" ]; then echo "rhel" else echo "$major_minor" fi return 0 else error "Could not determine OpenSSL version for '$version_str'." return 1 fi } create_tree() { local install_dir="$1" info 'Creating' "directory layout" # .volta/ # bin/ # cache/ # node/ # log/ # tmp/ # tools/ # image/ # node/ # packages/ # yarn/ # inventory/ # node/ # packages/ # yarn/ # user/ mkdir -p "$install_dir" mkdir -p "$install_dir"/bin mkdir -p "$install_dir"/cache/node mkdir -p "$install_dir"/log mkdir -p "$install_dir"/tmp mkdir -p "$install_dir"/tools/image/{node,packages,yarn} mkdir -p "$install_dir"/tools/inventory/{node,packages,yarn} mkdir -p "$install_dir"/tools/user } install_version() { local version_to_install="$1" local install_dir="$2" if ! volta_home_is_ok; then exit 1 fi case "$version_to_install" in latest) local latest_version="$(get_latest_release)" info 'Installing' "latest version of Volta ($latest_version)" install_release "$latest_version" "$install_dir" ;; *) # assume anything else is a specific version info 'Installing' "Volta version $version_to_install" install_release "$version_to_install" "$install_dir" ;; esac if [ "$?" == 0 ] then create_symlinks "$install_dir" && update_profile "$install_dir" && info "Finished" 'installation. Open a new terminal to start using Volta!' fi } install_release() { local version="$1" local install_dir="$2" local is_dev_install="false" info 'Checking' "for existing Volta installation" if no_legacy_install && upgrade_is_ok "$version" "$install_dir" "$is_dev_install" then download_archive="$(download_release "$version"; exit "$?")" exit_status="$?" if [ "$exit_status" != 0 ] then error "Could not download Volta version '$version'. See $(release_url) for a list of available releases" return "$exit_status" fi install_from_file "$download_archive" "$install_dir" else # existing legacy install, or upgrade problem return 1 fi } download_release() { local version="$1" local uname_str="$(uname -s)" local openssl_version="$(openssl version)" local os_info os_info="$(parse_os_info "$uname_str" "$openssl_version")" if [ "$?" != 0 ]; then error "The current operating system ($uname_str) does not appear to be supported by Volta." return 1 fi local pretty_os_name="$(parse_os_pretty "$uname_str")" info 'Fetching' "archive for $pretty_os_name, version $version" # store the downloaded archive in a temporary directory local download_dir="$(mktemp -d)" download_release_from_repo "$version" "$os_info" "$download_dir" } install_from_file() { local archive="$1" local extract_to="$2" create_tree "$extract_to" info 'Extracting' "Volta binaries and launchers" # extract the files to the specified directory tar -xzvf "$archive" -C "$extract_to" } check_architecture() { local version="$1" local arch="$2" if [[ "$version" != "local"* ]]; then if [ "$arch" != "x86_64" ]; then error "Sorry! Volta currently only provides pre-built binaries for x86_64 architectures." return 1 fi fi } # return if sourced (for testing the functions above) return 0 2>/dev/null # default to installing the latest available version version_to_install="latest" # install to VOLTA_HOME, defaulting to ~/.volta install_dir="${VOLTA_HOME:-"$HOME/.volta"}" # parse command line options while [ $# -gt 0 ] do arg="$1" case "$arg" in -h|--help) usage exit 0 ;; --version) shift # shift off the argument version_to_install="$1" shift # shift off the value ;; *) error "unknown option: '$arg'" usage exit 1 ;; esac done check_architecture "$version_to_install" "$(uname -m)" || exit 1 install_version "$version_to_install" "$install_dir" ================================================ FILE: dev/unix/volta-install.sh ================================================ #!/usr/bin/env bash # This is the bootstrap Unix installer served by `https://get.volta.sh`. # Its responsibility is to query the system to determine what OS the system # has, fetch and install the appropriate build of Volta, and modify the user's # profile. # NOTE: to use an internal company repo, change how this determines the latest version get_latest_release() { curl --silent "https://volta.sh/latest-version" } release_url() { echo "https://github.com/volta-cli/volta/releases" } download_release_from_repo() { local version="$1" local os_info="$2" local tmpdir="$3" local filename="volta-$version-$os_info.tar.gz" local download_file="$tmpdir/$filename" local archive_url="$(release_url)/download/v$version/$filename" curl --progress-bar --show-error --location --fail "$archive_url" --output "$download_file" --write-out "$download_file" } usage() { cat >&2 < Install a specific release version of Volta END_USAGE } info() { local action="$1" local details="$2" command printf '\033[1;32m%12s\033[0m %s\n' "$action" "$details" 1>&2 } error() { command printf '\033[1;31mError\033[0m: %s\n\n' "$1" 1>&2 } warning() { command printf '\033[1;33mWarning\033[0m: %s\n\n' "$1" 1>&2 } request() { command printf '\033[1m%s\033[0m\n' "$1" 1>&2 } eprintf() { command printf '%s\n' "$1" 1>&2 } bold() { command printf '\033[1m%s\033[0m' "$1" } # check for issue with VOLTA_HOME # if it is set, and exists, but is not a directory, the install will fail volta_home_is_ok() { if [ -n "${VOLTA_HOME-}" ] && [ -e "$VOLTA_HOME" ] && ! [ -d "$VOLTA_HOME" ]; then error "\$VOLTA_HOME is set but is not a directory ($VOLTA_HOME)." eprintf "Please check your profile scripts and environment." return 1 fi return 0 } # Check if it is OK to upgrade to the new version upgrade_is_ok() { local will_install_version="$1" local install_dir="$2" local is_dev_install="$3" # check for Volta in both the old location and the new 0.7.0 location local volta_bin="$install_dir/volta" if [ ! -x "$volta_bin" ]; then volta_bin="$install_dir/bin/volta" fi # this is not able to install Volta prior to 0.5.0 (when it was renamed) if [[ "$will_install_version" =~ ^([0-9]+\.[0-9]+) ]]; then local major_minor="${BASH_REMATCH[1]}" case "$major_minor" in 0.1|0.2|0.3|0.4|0.5) eprintf "" error "Cannot install Volta prior to version 0.6.0" request " To install Volta version $will_install_version, please check out the source and build manually." eprintf "" return 1 ;; esac fi if [[ -n "$install_dir" && -x "$volta_bin" ]]; then local prev_version="$( ($volta_bin --version 2>/dev/null || echo 0.1) | sed -E 's/^.*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')" # if this is a local dev install, skip the equality check # if installing the same version, this is a no-op if [ "$is_dev_install" != "true" ] && [ "$prev_version" == "$will_install_version" ]; then eprintf "Version $will_install_version already installed" return 1 fi # in the future, check $prev_version for incompatible upgrades fi return 0 } # returns the os name to be used in the packaged release parse_os_info() { local uname_str="$1" local arch="$(uname -m)" case "$uname_str" in Linux) if [ "$arch" == "x86_64" ]; then echo "linux" elif [ "$arch" == "aarch64" ]; then echo "linux-arm" else error "Releases for architectures other than x64 and arm are not currently supported." return 1 fi ;; Darwin) echo "macos" ;; *) return 1 esac return 0 } parse_os_pretty() { local uname_str="$1" case "$uname_str" in Linux) echo "Linux" ;; Darwin) echo "macOS" ;; *) echo "$uname_str" esac } # return true(0) if the element is contained in the input arguments # called like: # if element_in "foo" "${array[@]}"; then ... element_in() { local match="$1"; shift local element; # loop over the input arguments and return when a match is found for element in "$@"; do [ "$element" == "$match" ] && return 0 done return 1 } create_tree() { local install_dir="$1" info 'Creating' "directory layout" # .volta/ # bin/ mkdir -p "$install_dir" && mkdir -p "$install_dir"/bin if [ "$?" != 0 ] then error "Could not create directory layout. Please make sure the target directory is writeable: $install_dir" exit 1 fi } install_version() { local version_to_install="$1" local install_dir="$2" local should_run_setup="$3" if ! volta_home_is_ok; then exit 1 fi case "$version_to_install" in latest) local latest_version="$(get_latest_release)" info 'Installing' "latest version of Volta ($latest_version)" install_release "$latest_version" "$install_dir" ;; local-dev) info 'Installing' "Volta locally after compiling" install_local "dev" "$install_dir" ;; local-release) info 'Installing' "Volta locally after compiling with '--release'" install_local "release" "$install_dir" ;; *) # assume anything else is a specific version info 'Installing' "Volta version $version_to_install" install_release "$version_to_install" "$install_dir" ;; esac if [ "$?" == 0 ] then if [ "$should_run_setup" == "true" ]; then info 'Finished' "installation. Updating user profile settings." "$install_dir"/bin/volta setup else "$install_dir"/bin/volta --version &>/dev/null # creates the default shims info 'Finished' "installation. No changes were made to user profile settings." fi fi } # parse the 'version = "X.Y.Z"' line from the input Cargo.toml contents # and return the version string parse_cargo_version() { local contents="$1" while read -r line do if [[ "$line" =~ ^version\ =\ \"(.*)\" ]] then echo "${BASH_REMATCH[1]}" return 0 fi done <<< "$contents" error "Could not determine the current version from Cargo.toml" return 1 } install_release() { local version="$1" local install_dir="$2" local is_dev_install="false" info 'Checking' "for existing Volta installation" if upgrade_is_ok "$version" "$install_dir" "$is_dev_install" then download_archive="$(download_release "$version"; exit "$?")" exit_status="$?" if [ "$exit_status" != 0 ] then error "Could not download Volta version '$version'. See $(release_url) for a list of available releases" return "$exit_status" fi install_from_file "$download_archive" "$install_dir" else # existing legacy install, or upgrade problem return 1 fi } install_local() { local dev_or_release="$1" local install_dir="$2" # this is a local install, so skip the version equality check local is_dev_install="true" info 'Checking' "for existing Volta installation" install_version="$(parse_cargo_version "$(/dev/null 2>&1 && pwd )" # call the release script to create the packaged archive file # '2> >(tee /dev/stderr)' copies stderr to stdout, to collect it and parse the filename release_output="$( "$DIR/release.sh" "--$dev_or_release" 2> >(tee /dev/stderr) )" [ "$?" != 0 ] && return 1 # parse the release filename and return that if [[ "$release_output" =~ release\ in\ file\ (target[^\ ]+) ]]; then echo "${BASH_REMATCH[1]}" else error "Could not determine output filename" return 1 fi } download_release() { local version="$1" local uname_str="$(uname -s)" local os_info os_info="$(parse_os_info "$uname_str")" if [ "$?" != 0 ]; then error "The current operating system ($uname_str) does not appear to be supported by Volta." return 1 fi local pretty_os_name="$(parse_os_pretty "$uname_str")" info 'Fetching' "archive for $pretty_os_name, version $version" # store the downloaded archive in a temporary directory local download_dir="$(mktemp -d)" download_release_from_repo "$version" "$os_info" "$download_dir" } install_from_file() { local archive="$1" local install_dir="$2" create_tree "$install_dir" info 'Extracting' "Volta binaries and launchers" # extract the files to the specified directory tar -xf "$archive" -C "$install_dir"/bin } # return if sourced (for testing the functions above) return 0 2>/dev/null # default to installing the latest available version version_to_install="latest" # default to running setup after installing should_run_setup="true" # install to VOLTA_HOME, defaulting to ~/.volta install_dir="${VOLTA_HOME:-"$HOME/.volta"}" # parse command line options while [ $# -gt 0 ] do arg="$1" case "$arg" in -h|--help) usage exit 0 ;; --dev) shift # shift off the argument version_to_install="local-dev" ;; --release) shift # shift off the argument version_to_install="local-release" ;; --version) shift # shift off the argument version_to_install="$1" shift # shift off the value ;; --skip-setup) shift # shift off the argument should_run_setup="false" ;; *) error "unknown option: '$arg'" usage exit 1 ;; esac done install_version "$version_to_install" "$install_dir" "$should_run_setup" ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "1.75" components = ["clippy", "rustfmt"] profile = "minimal" ================================================ FILE: src/cli.rs ================================================ use clap::{builder::styling, ColorChoice, Parser}; use crate::command::{self, Command}; use volta_core::error::{ExitCode, Fallible}; use volta_core::session::Session; use volta_core::style::{text_width, MAX_WIDTH}; #[derive(Parser)] #[command( about = "The JavaScript Launcher ⚡", long_about = "The JavaScript Launcher ⚡ To install a tool in your toolchain, use `volta install`. To pin your project's runtime or package manager, use `volta pin`.", color = ColorChoice::Auto, disable_version_flag = true, styles = styles(), term_width = text_width().unwrap_or(MAX_WIDTH), )] pub(crate) struct Volta { #[command(subcommand)] pub(crate) command: Option, /// Enables verbose diagnostics #[arg(long, global = true)] pub(crate) verbose: bool, /// Enables trace-level diagnostics. #[arg(long, global = true, requires = "verbose")] pub(crate) very_verbose: bool, /// Prevents unnecessary output #[arg( long, global = true, conflicts_with = "verbose", aliases = &["silent"] )] pub(crate) quiet: bool, /// Prints the current version of Volta #[arg(short, long)] pub(crate) version: bool, } impl Volta { pub(crate) fn run(self, session: &mut Session) -> Fallible { if self.version { // suffix indicator for dev build if cfg!(debug_assertions) { println!("{}-dev", env!("CARGO_PKG_VERSION")); } else { println!("{}", env!("CARGO_PKG_VERSION")); } Ok(ExitCode::Success) } else if let Some(command) = self.command { command.run(session) } else { Volta::parse_from(["volta", "help"].iter()).run(session) } } } #[derive(clap::Subcommand)] pub(crate) enum Subcommand { /// Fetches a tool to the local machine Fetch(command::Fetch), /// Installs a tool in your toolchain Install(command::Install), /// Uninstalls a tool from your toolchain Uninstall(command::Uninstall), /// Pins your project's runtime or package manager Pin(command::Pin), /// Displays the current toolchain #[command(alias = "ls")] List(command::List), /// Generates Volta completions /// /// By default, completions will be generated for the value of your current shell, /// shell, i.e. the value of `SHELL`. If you set the `` option, completions /// will be generated for that shell instead. /// /// If you specify a directory, the completions will be written to a file there; /// otherwise, they will be written to `stdout`. #[command(arg_required_else_help = true)] Completions(command::Completions), /// Locates the actual binary that will be called by Volta Which(command::Which), #[command(long_about = crate::command::r#use::USAGE, hide = true)] Use(command::Use), /// Enables Volta for the current user / shell Setup(command::Setup), /// Run a command with custom Node, npm, pnpm, and/or Yarn versions Run(command::Run), } impl Subcommand { pub(crate) fn run(self, session: &mut Session) -> Fallible { match self { Subcommand::Fetch(fetch) => fetch.run(session), Subcommand::Install(install) => install.run(session), Subcommand::Uninstall(uninstall) => uninstall.run(session), Subcommand::Pin(pin) => pin.run(session), Subcommand::List(list) => list.run(session), Subcommand::Completions(completions) => completions.run(session), Subcommand::Which(which) => which.run(session), Subcommand::Use(r#use) => r#use.run(session), Subcommand::Setup(setup) => setup.run(session), Subcommand::Run(run) => run.run(session), } } } fn styles() -> styling::Styles { styling::Styles::plain() .header( styling::AnsiColor::Yellow.on_default() | styling::Effects::BOLD | styling::Effects::ITALIC, ) .usage( styling::AnsiColor::Yellow.on_default() | styling::Effects::BOLD | styling::Effects::ITALIC, ) .literal(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD) .placeholder(styling::AnsiColor::BrightBlue.on_default()) } ================================================ FILE: src/command/completions.rs ================================================ use std::path::PathBuf; use clap::CommandFactory; use clap_complete::Shell; use log::info; use volta_core::{ error::{Context, ErrorKind, ExitCode, Fallible}, session::{ActivityKind, Session}, style::{note_prefix, success_prefix}, }; use crate::command::Command; #[derive(Debug, clap::Args)] pub(crate) struct Completions { /// Shell to generate completions for #[arg(index = 1, ignore_case = true, required = true)] shell: Shell, /// File to write generated completions to #[arg(short, long = "output")] out_file: Option, /// Write over an existing file, if any. #[arg(short, long)] force: bool, } impl Command for Completions { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Completions); let mut app = crate::cli::Volta::command(); let app_name = app.get_name().to_owned(); match self.out_file { Some(path) => { if path.is_file() && !self.force { return Err(ErrorKind::CompletionsOutFileError { path }.into()); } // The user may have passed a path that does not yet exist. If // so, we create it, informing the user we have done so. if let Some(parent) = path.parent() { if !parent.is_dir() { info!( "{} {} does not exist, creating it", note_prefix(), parent.display() ); std::fs::create_dir_all(parent).with_context(|| { ErrorKind::CreateDirError { dir: parent.to_path_buf(), } })?; } } let mut file = &std::fs::File::create(&path).with_context(|| { ErrorKind::CompletionsOutFileError { path: path.to_path_buf(), } })?; clap_complete::generate(self.shell, &mut app, app_name, &mut file); info!( "{} installed completions to {}", success_prefix(), path.display() ); } None => clap_complete::generate(self.shell, &mut app, app_name, &mut std::io::stdout()), }; session.add_event_end(ActivityKind::Completions, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/fetch.rs ================================================ use volta_core::error::{ExitCode, Fallible}; use volta_core::session::{ActivityKind, Session}; use volta_core::tool; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Fetch { /// Tools to fetch, like `node`, `yarn@latest` or `your-package@^14.4.3`. #[arg(value_name = "tool[@version]", required = true)] tools: Vec, } impl Command for Fetch { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Fetch); for tool in tool::Spec::from_strings(&self.tools, "fetch")? { tool.resolve(session)?.fetch(session)?; } session.add_event_end(ActivityKind::Fetch, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/install.rs ================================================ use volta_core::error::{ExitCode, Fallible}; use volta_core::session::{ActivityKind, Session}; use volta_core::tool::Spec; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Install { /// Tools to install, like `node`, `yarn@latest` or `your-package@^14.4.3`. #[arg(value_name = "tool[@version]", required = true)] tools: Vec, } impl Command for Install { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Install); for tool in Spec::from_strings(&self.tools, "install")? { tool.resolve(session)?.install(session)?; } session.add_event_end(ActivityKind::Install, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/list/human.rs ================================================ //! Define the "human" format style for list commands. use std::collections::BTreeMap; use super::{Node, Package, PackageManager, PackageManagerKind, Toolchain}; use once_cell::sync::Lazy; use textwrap::{fill, Options}; use volta_core::style::{text_width, tool_version, MAX_WIDTH}; static INDENTATION: &str = " "; static NO_RUNTIME: &str = "⚡️ No Node runtimes installed! You can install a runtime by running `volta install node`. See `volta help install` for details and more options."; static TEXT_WIDTH: Lazy = Lazy::new(|| text_width().unwrap_or(MAX_WIDTH)); #[allow(clippy::unnecessary_wraps)] // Needs to match the API of `plain::format` pub(super) fn format(toolchain: &Toolchain) -> Option { // Formatting here depends on the toolchain: we do different degrees of // indentation Some(match toolchain { Toolchain::Node(runtimes) => display_node(runtimes), Toolchain::Active { runtime, package_managers, packages, } => display_active(runtime, package_managers, packages), Toolchain::All { runtimes, package_managers, packages, } => display_all(runtimes, package_managers, packages), Toolchain::PackageManagers { kind, managers } => display_package_managers(*kind, managers), Toolchain::Packages(packages) => display_packages(packages), Toolchain::Tool { name, host_packages, } => display_tool(name, host_packages), }) } /// Format the output for `Toolchain::Active`. /// /// Accepts the components *from* the toolchain rather than the item itself so /// that fn display_active( runtime: &Option>, package_managers: &[PackageManager], packages: &[Package], ) -> String { match runtime { None => NO_RUNTIME.to_string(), Some(node) => { let runtime_version = wrap(format!("Node: {}", format_runtime(node))); let package_manager_versions = if package_managers.is_empty() { String::new() } else { format!( "\n{}", format_package_manager_list_condensed(package_managers) ) }; let package_versions = if packages.is_empty() { wrap("Tool binaries available: NONE") } else { wrap(format!( "Tool binaries available:\n{}", format_tool_list(packages) )) }; format!( "⚡️ Currently active tools:\n\n{}{}\n{}\n\n{}", runtime_version, package_manager_versions, package_versions, "See options for more detailed reports by running `volta list --help`." ) } } } /// Format the output for `Toolchain::All`. fn display_all( runtimes: &[Node], package_managers: &[PackageManager], packages: &[Package], ) -> String { if runtimes.is_empty() { NO_RUNTIME.to_string() } else { let runtime_versions: String = wrap(format!("Node runtimes:\n{}", format_runtime_list(runtimes))); let package_manager_versions: String = wrap(format!( "Package managers:\n{}", format_package_manager_list_verbose(package_managers) )); let package_versions = wrap(format!("Packages:\n{}", format_package_list(packages))); format!( "⚡️ User toolchain:\n\n{}\n\n{}\n\n{}", runtime_versions, package_manager_versions, package_versions ) } } /// Format a set of `Toolchain::Node`s. fn display_node(runtimes: &[Node]) -> String { if runtimes.is_empty() { NO_RUNTIME.to_string() } else { format!( "⚡️ Node runtimes in your toolchain:\n\n{}", format_runtime_list(runtimes) ) } } /// Format a set of `Toolchain::PackageManager`s for `volta list npm` fn display_npms(managers: &[PackageManager]) -> String { if managers.is_empty() { "⚡️ No custom npm versions installed (npm is still available bundled with Node). You can install an npm version by running `volta install npm`. See `volta help install` for details and more options." .into() } else { let versions = wrap( managers .iter() .map(format_package_manager) .collect::>() .join("\n"), ); format!("⚡️ Custom npm versions in your toolchain:\n\n{}", versions) } } /// Format a set of `Toolchain::PackageManager`s. fn display_package_managers(kind: PackageManagerKind, managers: &[PackageManager]) -> String { match kind { PackageManagerKind::Npm => display_npms(managers), _ => { if managers.is_empty() { // Note: Using `format_package_manager_kind` to get the properly capitalized version of the tool // Then using the `Display` impl on the kind to get the version to show in the command format!( "⚡️ No {} versions installed. You can install a {0} version by running `volta install {}`. See `volta help install` for details and more options.", format_package_manager_kind(kind), kind ) } else { let versions = wrap( managers .iter() .map(format_package_manager) .collect::>() .join("\n"), ); format!( "⚡️ {} versions in your toolchain:\n\n{}", format_package_manager_kind(kind), versions ) } } } } /// Format a set of `Toolchain::Package`s and their associated tools. fn display_packages(packages: &[Package]) -> String { if packages.is_empty() { String::from( "⚡️ No tools or packages installed. You can safely install packages by running `volta install `. See `volta help install` for details and more options.", ) } else { format!( "⚡️ Package versions in your toolchain:\n\n{}", format_package_list(packages) ) } } /// Format a single `Toolchain::Tool` with associated `Toolchain::Package` fn display_tool(tool: &str, host_packages: &[Package]) -> String { if host_packages.is_empty() { format!( "⚡️ No tools or packages named `{}` installed. You can safely install packages by running `volta install `. See `volta help install` for details and more options.", tool ) } else { let versions = wrap( host_packages .iter() .map(format_package) .collect::>() .join("\n"), ); format!("⚡️ Tool `{}` available from:\n\n{}", tool, versions) } } /// Format a list of `Toolchain::Package`s without detail information fn format_tool_list(packages: &[Package]) -> String { packages .iter() .map(format_tool) .collect::>() .join("\n") } /// Format a single `Toolchain::Package` without detail information fn format_tool(package: &Package) -> String { match package { Package::Default { tools, .. } | Package::Project { tools, .. } => { let tools = match tools.len() { 0 => String::from(""), _ => tools.join(", "), }; wrap(format!("{}{}", tools, list_package_source(package))) } Package::Fetched(..) => String::new(), } } /// format a list of `Toolchain::Node`s. fn format_runtime_list(runtimes: &[Node]) -> String { wrap( runtimes .iter() .map(format_runtime) .collect::>() .join("\n"), ) } /// format a single version of `Toolchain::Node`. fn format_runtime(runtime: &Node) -> String { format!("v{}{}", runtime.version, runtime.source) } /// format a list of `Toolchain::PackageManager`s in condensed form fn format_package_manager_list_condensed(package_managers: &[PackageManager]) -> String { wrap( package_managers .iter() .map(|manager| { format!( "{}: {}", format_package_manager_kind(manager.kind), format_package_manager(manager) ) }) .collect::>() .join("\n"), ) } /// format a list of `Toolchain::PackageManager`s in verbose form fn format_package_manager_list_verbose(package_managers: &[PackageManager]) -> String { let mut manager_lists = BTreeMap::new(); for manager in package_managers { manager_lists .entry(manager.kind) .or_insert_with(Vec::new) .push(format_package_manager(manager)); } wrap( manager_lists .iter() .map(|(kind, list)| { format!( "{}:\n{}", format_package_manager_kind(*kind), wrap(list.join("\n")) ) }) .collect::>() .join("\n"), ) } /// format a single `Toolchain::PackageManager`. fn format_package_manager(package_manager: &PackageManager) -> String { format!("v{}{}", package_manager.version, package_manager.source) } /// format the title for a kind of package manager /// /// This is distinct from the `Display` impl, because we need 'Yarn' to be capitalized for human output fn format_package_manager_kind(kind: PackageManagerKind) -> String { match kind { PackageManagerKind::Npm => "npm".into(), PackageManagerKind::Pnpm => "pnpm".into(), PackageManagerKind::Yarn => "Yarn".into(), } } /// format a list of `Toolchain::Package`s and their associated tools. fn format_package_list(packages: &[Package]) -> String { wrap( packages .iter() .map(format_package) .collect::>() .join("\n"), ) } /// Format a single `Toolchain::Package` and its associated tools. fn format_package(package: &Package) -> String { match package { Package::Default { details, node, tools, .. } => { let tools = match tools.len() { 0 => String::from(""), _ => tools.join(", "), }; let version = format!("{}{}", details.version, list_package_source(package)); let binaries = wrap(format!("binary tools: {}", tools)); let platform_detail = wrap(format!( "runtime: {}\npackage manager: {}", tool_version("node", node), // TODO: Should be updated when we support installing with custom package_managers, // whether Yarn or non-built-in versions of npm "npm@built-in" )); let platform = wrap(format!("platform:\n{}", platform_detail)); format!("{}@{}\n{}\n{}", details.name, version, binaries, platform) } Package::Project { name, tools, .. } => { let tools = match tools.len() { 0 => String::from(""), _ => tools.join(", "), }; let binaries = wrap(format!("binary tools: {}", tools)); format!("{}{}\n{}", name, list_package_source(package), binaries) } Package::Fetched(details) => { let package_info = format!("{}@{}", details.name, details.version); let footer_message = format!( "To make it available to execute, run `volta install {}`.", package_info ); format!("{}\n\n{}", package_info, footer_message) } } } /// List a the source from a `Toolchain::Package`. fn list_package_source(package: &Package) -> String { match package { Package::Default { .. } => String::from(" (default)"), Package::Project { path, .. } => format!(" (current @ {})", path.display()), Package::Fetched(..) => String::new(), } } /// Wrap and indent the output fn wrap(text: S) -> String where S: AsRef, { let options = Options::new(*TEXT_WIDTH) .initial_indent(INDENTATION) .subsequent_indent(INDENTATION); let mut wrapped = fill(text.as_ref(), options); // The `fill` method in the latest `textwrap` version does not trim the indentation whitespace // from the final line. wrapped.truncate(wrapped.trim_end_matches(INDENTATION).len()); wrapped } // These tests are organized by way of the *commands* supplied to `list`, unlike // in the `plain` module, because the formatting varies by command here, as it // does not there. #[cfg(test)] mod tests { use std::path::PathBuf; use node_semver::Version; use once_cell::sync::Lazy; use super::*; static NODE_12: Lazy = Lazy::new(|| Version::from((12, 2, 0))); static NODE_11: Lazy = Lazy::new(|| Version::from((11, 9, 0))); static NODE_10: Lazy = Lazy::new(|| Version::from((10, 15, 3))); static YARN_VERSION: Lazy = Lazy::new(|| Version::from((1, 16, 0))); static NPM_VERSION: Lazy = Lazy::new(|| Version::from((6, 13, 1))); static PROJECT_PATH: Lazy = Lazy::new(|| PathBuf::from("~/path/to/project.json")); mod active { use super::*; use crate::command::list::{ human::display_active, Node, PackageDetails, PackageManager, PackageManagerKind, Source, }; #[test] fn no_runtimes() { let runtime = None; let package_managers = vec![]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), NO_RUNTIME ); } #[test] fn runtime_only_default() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_only_project() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_npm_default() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) npm: v6.13.1 (default) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_yarn_default() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) Yarn: v1.16.0 (default) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_npm_mixed() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) npm: v6.13.1 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_yarn_mixed() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) Yarn: v1.16.0 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_npm_project() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) npm: v6.13.1 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_and_yarn_project() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) Yarn: v1.16.0 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_npm_and_yarn_default() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) npm: v6.13.1 (default) Yarn: v1.16.0 (default) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }, ]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_npm_and_yarn_project() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) npm: v6.13.1 (current @ ~/path/to/project.json) Yarn: v1.16.0 (current @ ~/path/to/project.json) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }, ]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn runtime_npm_and_yarn_mixed() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (default) npm: v6.13.1 (current @ ~/path/to/project.json) Yarn: v1.16.0 (default) Tool binaries available: NONE See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Default, version: NODE_12.clone(), })); let package_managers = vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }, ]; let packages = vec![]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn with_default_tools() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) npm: v6.13.1 (current @ ~/path/to/project.json) Yarn: v1.16.0 (current @ ~/path/to/project.json) Tool binaries available: create-react-app (default) tsc, tsserver (default) See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }, ]; let packages = vec![ Package::Default { details: PackageDetails { name: "create-react-app".to_string(), version: Version::from((3, 0, 1)), }, node: NODE_12.clone(), tools: vec!["create-react-app".to_string()], }, Package::Default { details: PackageDetails { name: "typescript".to_string(), version: Version::from((3, 4, 3)), }, node: NODE_12.clone(), tools: vec!["tsc".to_string(), "tsserver".to_string()], }, ]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } #[test] fn with_project_tools() { let expected = "⚡️ Currently active tools: Node: v12.2.0 (current @ ~/path/to/project.json) npm: v6.13.1 (current @ ~/path/to/project.json) Yarn: v1.16.0 (current @ ~/path/to/project.json) Tool binaries available: create-react-app (current @ ~/path/to/project.json) tsc, tsserver (default) See options for more detailed reports by running `volta list --help`."; let runtime = Some(Box::new(Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), })); let package_managers = vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }, ]; let packages = vec![ Package::Project { name: "create-react-app".to_string(), path: PROJECT_PATH.clone(), tools: vec!["create-react-app".to_string()], }, Package::Default { details: PackageDetails { name: "typescript".to_string(), version: Version::from((3, 4, 3)), }, node: NODE_12.clone(), tools: vec!["tsc".to_string(), "tsserver".to_string()], }, ]; assert_eq!( display_active(&runtime, &package_managers, &packages), expected ); } } mod node { use super::super::*; use super::*; use crate::command::list::Source; #[test] fn no_runtimes() { let expected = NO_RUNTIME; let runtimes = []; assert_eq!(display_node(&runtimes).as_str(), expected); } #[test] fn single_default() { let expected = "⚡️ Node runtimes in your toolchain: v10.15.3 (default)"; let runtimes = [Node { source: Source::Default, version: NODE_10.clone(), }]; assert_eq!(display_node(&runtimes).as_str(), expected); } #[test] fn single_project() { let expected = "⚡️ Node runtimes in your toolchain: v12.2.0 (current @ ~/path/to/project.json)"; let runtimes = [Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), }]; assert_eq!(display_node(&runtimes).as_str(), expected); } #[test] fn single_installed() { let expected = "⚡️ Node runtimes in your toolchain: v11.9.0"; let runtimes = [Node { source: Source::None, version: NODE_11.clone(), }]; assert_eq!(display_node(&runtimes).as_str(), expected); } #[test] fn multi() { let expected = "⚡️ Node runtimes in your toolchain: v12.2.0 (current @ ~/path/to/project.json) v11.9.0 v10.15.3 (default)"; let runtimes = [ Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), }, Node { source: Source::None, version: NODE_11.clone(), }, Node { source: Source::Default, version: NODE_10.clone(), }, ]; assert_eq!(display_node(&runtimes), expected); } } mod package_managers { use super::*; use crate::command::list::{PackageManager, PackageManagerKind, Source}; #[test] fn none_installed_npm() { let expected = "⚡️ No custom npm versions installed (npm is still available bundled with Node). You can install an npm version by running `volta install npm`. See `volta help install` for details and more options."; assert_eq!( display_package_managers(PackageManagerKind::Npm, &[]), expected ); } #[test] fn none_installed_yarn() { let expected = "⚡️ No Yarn versions installed. You can install a Yarn version by running `volta install yarn`. See `volta help install` for details and more options."; assert_eq!( display_package_managers(PackageManagerKind::Yarn, &[]), expected ); } #[test] fn single_default_npm() { let expected = "⚡️ Custom npm versions in your toolchain: v6.13.1 (default)"; let package_managers = [PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Npm, &package_managers), expected ); } #[test] fn single_default_yarn() { let expected = "⚡️ Yarn versions in your toolchain: v1.16.0 (default)"; let package_managers = [PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Yarn, &package_managers), expected ); } #[test] fn single_project_npm() { let expected = "⚡️ Custom npm versions in your toolchain: v6.13.1 (current @ ~/path/to/project.json)"; let package_managers = [PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Npm, &package_managers), expected ); } #[test] fn single_project_yarn() { let expected = "⚡️ Yarn versions in your toolchain: v1.16.0 (current @ ~/path/to/project.json)"; let package_managers = [PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Yarn, &package_managers), expected ); } #[test] fn single_installed_npm() { let expected = "⚡️ Custom npm versions in your toolchain: v6.13.1"; let package_managers = [PackageManager { kind: PackageManagerKind::Npm, source: Source::None, version: NPM_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Npm, &package_managers), expected ); } #[test] fn single_installed_yarn() { let expected = "⚡️ Yarn versions in your toolchain: v1.16.0"; let package_managers = [PackageManager { kind: PackageManagerKind::Yarn, source: Source::None, version: YARN_VERSION.clone(), }]; assert_eq!( display_package_managers(PackageManagerKind::Yarn, &package_managers), expected ); } #[test] fn multi_npm() { let expected = "⚡️ Custom npm versions in your toolchain: v5.6.0 v6.13.1 (default) v6.14.2 (current @ ~/path/to/project.json)"; let package_managers = [ PackageManager { kind: PackageManagerKind::Npm, source: Source::None, version: Version::from((5, 6, 0)), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((6, 14, 2)), }, ]; assert_eq!( display_package_managers(PackageManagerKind::Npm, &package_managers), expected ); } #[test] fn multi_yarn() { let expected = "⚡️ Yarn versions in your toolchain: v1.3.0 v1.16.0 (default) v1.17.0 (current @ ~/path/to/project.json)"; let package_managers = [ PackageManager { kind: PackageManagerKind::Yarn, source: Source::None, version: Version::from((1, 3, 0)), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((1, 17, 0)), }, ]; assert_eq!( display_package_managers(PackageManagerKind::Yarn, &package_managers), expected ); } } mod packages { use super::*; use crate::command::list::{Package, PackageDetails}; use node_semver::Version; #[test] fn none() { let expected = "⚡️ No tools or packages installed. You can safely install packages by running `volta install `. See `volta help install` for details and more options."; assert_eq!(display_packages(&[]), expected); } #[test] fn single_default() { let expected = "⚡️ Package versions in your toolchain: ember-cli@3.10.1 (default) binary tools: ember platform: runtime: node@12.2.0 package manager: npm@built-in"; let packages = [Package::Default { details: PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }, node: NODE_12.clone(), tools: vec!["ember".to_string()], }]; assert_eq!(display_packages(&packages), expected); } #[test] fn single_project() { let expected = "⚡️ Package versions in your toolchain: ember-cli (current @ ~/path/to/project.json) binary tools: ember"; let packages = [Package::Project { name: "ember-cli".to_string(), path: PROJECT_PATH.clone(), tools: vec!["ember".to_string()], }]; assert_eq!(display_packages(&packages), expected); } #[test] fn single_fetched() { let expected = "⚡️ Package versions in your toolchain: ember-cli@3.10.1 To make it available to execute, run `volta install ember-cli@3.10.1`."; let packages = [Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), })]; assert_eq!(display_packages(&packages), expected); } #[test] fn multi_fetched() { let expected = "⚡️ Package versions in your toolchain: ember-cli@3.10.1 To make it available to execute, run `volta install ember-cli@3.10.1`. ember-cli@3.8.2 To make it available to execute, run `volta install ember-cli@3.8.2`."; let packages = [ Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }), Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 8, 2)), }), ]; assert_eq!(display_packages(&packages), expected); } #[test] fn multi() { let expected = "⚡️ Package versions in your toolchain: ember-cli@3.10.1 (default) binary tools: ember platform: runtime: node@12.2.0 package manager: npm@built-in ember-cli (current @ ~/path/to/project.json) binary tools: ember"; let packages = [ Package::Default { details: PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }, node: NODE_12.clone(), tools: vec!["ember".to_string()], }, Package::Project { name: "ember-cli".to_string(), path: PROJECT_PATH.clone(), tools: vec!["ember".to_string()], }, ]; assert_eq!(display_packages(&packages), expected); } } mod tools { use super::*; use crate::command::list::{Package, PackageDetails}; use node_semver::Version; #[test] fn none() { let expected = "⚡️ No tools or packages named `ember` installed. You can safely install packages by running `volta install `. See `volta help install` for details and more options."; assert_eq!(display_tool("ember", &[]), expected); } #[test] fn single_default() { let expected = "⚡️ Tool `ember` available from: ember-cli@3.10.1 (default) binary tools: ember platform: runtime: node@12.2.0 package manager: npm@built-in"; let packages = [Package::Default { details: PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }, node: NODE_12.clone(), tools: vec!["ember".to_string()], }]; assert_eq!(display_tool("ember", &packages), expected); } #[test] fn single_project() { let expected = "⚡️ Tool `ember` available from: ember-cli (current @ ~/path/to/project.json) binary tools: ember"; let packages = [Package::Project { name: "ember-cli".to_string(), path: PROJECT_PATH.clone(), tools: vec!["ember".to_string()], }]; assert_eq!(display_tool("ember", &packages), expected); } #[test] fn single_fetched() { let expected = "⚡️ Tool `ember` available from: ember-cli@3.10.1 To make it available to execute, run `volta install ember-cli@3.10.1`."; let packages = [Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), })]; assert_eq!(display_tool("ember", &packages), expected); } #[test] fn multi_fetched() { let expected = "⚡️ Tool `ember` available from: ember-cli@3.10.1 To make it available to execute, run `volta install ember-cli@3.10.1`. ember-cli@3.8.2 To make it available to execute, run `volta install ember-cli@3.8.2`."; let packages = [ Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }), Package::Fetched(PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 8, 2)), }), ]; assert_eq!(display_tool("ember", &packages), expected); } #[test] fn multi() { let expected = "⚡️ Tool `ember` available from: ember-cli@3.10.1 (default) binary tools: ember platform: runtime: node@12.2.0 package manager: npm@built-in ember-cli (current @ ~/path/to/project.json) binary tools: ember"; let packages = [ Package::Default { details: PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 10, 1)), }, node: NODE_12.clone(), tools: vec!["ember".to_string()], }, Package::Project { name: "ember-cli".to_string(), path: PROJECT_PATH.clone(), tools: vec!["ember".to_string()], }, ]; assert_eq!(display_tool("ember", &packages), expected); } } mod all { use super::*; use crate::command::list::{PackageDetails, PackageManagerKind, Source}; #[test] fn empty() { let runtimes = []; let package_managers = []; let packages = []; assert_eq!( display_all(&runtimes, &package_managers, &packages), NO_RUNTIME ); } #[test] fn runtime_and_npm() { let expected = "⚡️ User toolchain: Node runtimes: v12.2.0 (current @ ~/path/to/project.json) v11.9.0 v10.15.3 (default) Package managers: npm: v6.13.1 (default) v6.12.0 (current @ ~/path/to/project.json) v5.6.0 Packages: "; let runtimes = [ Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), }, Node { source: Source::None, version: NODE_11.clone(), }, Node { source: Source::Default, version: NODE_10.clone(), }, ]; let package_managers = [ PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((6, 12, 0)), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::None, version: Version::from((5, 6, 0)), }, ]; let packages = vec![]; assert_eq!( display_all(&runtimes, &package_managers, &packages), expected ); } #[test] fn runtime_and_yarn() { let expected = "⚡️ User toolchain: Node runtimes: v12.2.0 (current @ ~/path/to/project.json) v11.9.0 v10.15.3 (default) Package managers: Yarn: v1.16.0 (default) v1.17.0 (current @ ~/path/to/project.json) v1.4.0 Packages: "; let runtimes = [ Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), }, Node { source: Source::None, version: NODE_11.clone(), }, Node { source: Source::Default, version: NODE_10.clone(), }, ]; let package_managers = [ PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((1, 17, 0)), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::None, version: Version::from((1, 4, 0)), }, ]; let packages = vec![]; assert_eq!( display_all(&runtimes, &package_managers, &packages), expected ); } #[test] fn full() { let expected = "⚡️ User toolchain: Node runtimes: v12.2.0 (current @ ~/path/to/project.json) v11.9.0 v10.15.3 (default) Package managers: npm: v6.13.1 (default) v6.12.0 (current @ ~/path/to/project.json) v5.6.0 Yarn: v1.16.0 (default) v1.17.0 (current @ ~/path/to/project.json) v1.4.0 Packages: typescript@3.4.3 (default) binary tools: tsc, tsserver platform: runtime: node@12.2.0 package manager: npm@built-in typescript (current @ ~/path/to/project.json) binary tools: tsc, tsserver ember-cli (current @ ~/path/to/project.json) binary tools: ember ember-cli@3.8.2 (default) binary tools: ember platform: runtime: node@12.2.0 package manager: npm@built-in"; let runtimes = [ Node { source: Source::Project(PROJECT_PATH.clone()), version: NODE_12.clone(), }, Node { source: Source::None, version: NODE_11.clone(), }, Node { source: Source::Default, version: NODE_10.clone(), }, ]; let package_managers = [ PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((6, 12, 0)), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::None, version: Version::from((5, 6, 0)), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: Version::from((1, 17, 0)), }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::None, version: Version::from((1, 4, 0)), }, ]; let packages = [ Package::Default { details: PackageDetails { name: "typescript".to_string(), version: Version::from((3, 4, 3)), }, node: NODE_12.clone(), tools: vec!["tsc".to_string(), "tsserver".to_string()], }, Package::Project { name: "typescript".to_string(), path: PROJECT_PATH.clone(), tools: vec!["tsc".to_string(), "tsserver".to_string()], }, Package::Project { name: "ember-cli".to_string(), path: PROJECT_PATH.clone(), tools: vec!["ember".to_string()], }, Package::Default { details: PackageDetails { name: "ember-cli".to_string(), version: Version::from((3, 8, 2)), }, node: NODE_12.clone(), tools: vec!["ember".to_string()], }, ]; assert_eq!( display_all(&runtimes, &package_managers, &packages), expected ); } } } ================================================ FILE: src/command/list/mod.rs ================================================ mod human; mod plain; mod toolchain; use std::io::IsTerminal as _; use std::{fmt, path::PathBuf, str::FromStr}; use node_semver::Version; use crate::command::Command; use toolchain::Toolchain; use volta_core::error::{ExitCode, Fallible}; use volta_core::inventory::package_configs; use volta_core::project::Project; use volta_core::session::{ActivityKind, Session}; use volta_core::tool::PackageConfig; #[derive(clap::ValueEnum, Copy, Clone)] enum Format { Human, Plain, } /// The source of a given item, from the perspective of a user. /// /// Note: this is distinct from `volta_core::platform::sourced::Source`, which /// represents the source only of a `Platform`, which is a composite structure. /// By contrast, this `Source` is concerned *only* with a single item. #[derive(Clone, PartialEq, Debug)] enum Source { /// The item is from a project. The wrapped `PathBuf` is the path to the /// project's `package.json`. Project(PathBuf), /// The item is the user's default. Default, /// The item is one that has been *fetched* but is not *installed* anywhere. None, } impl Source { fn allowed_with(&self, filter: &Filter) -> bool { match filter { Filter::Default => self == &Source::Default, Filter::Current => matches!(self, Source::Default | Source::Project(_)), Filter::None => true, } } } impl fmt::Display for Source { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", match self { Source::Project(path) => format!(" (current @ {})", path.display()), Source::Default => String::from(" (default)"), Source::None => String::from(""), } ) } } /// A package and its associated tools, for displaying to the user as part of /// their toolchain. struct PackageDetails { /// The name of the package. pub name: String, /// The package's own version. pub version: Version, } enum Package { Default { details: PackageDetails, /// The version of Node the package is installed against. node: Version, /// The names of the tools associated with the package. tools: Vec, }, Project { name: String, /// The names of the tools associated with the package. tools: Vec, path: PathBuf, }, Fetched(PackageDetails), } impl Package { fn new(config: &PackageConfig, source: &Source) -> Package { let details = PackageDetails { name: config.name.clone(), version: config.version.clone(), }; match source { Source::Default => Package::Default { details, node: config.platform.node.clone(), tools: config.bins.clone(), }, Source::Project(path) => Package::Project { name: details.name, tools: config.bins.clone(), path: path.clone(), }, Source::None => Package::Fetched(details), } } fn from_inventory_and_project(project: Option<&Project>) -> Fallible> { package_configs().map(|configs| { configs .iter() .map(|config| { let source = Self::source(&config.name, project); Package::new(config, &source) }) .collect() }) } fn source(name: &str, project: Option<&Project>) -> Source { match project { Some(project) if project.has_direct_dependency(name) => { Source::Project(project.manifest_file().to_owned()) } _ => Source::Default, } } } #[derive(Clone)] struct Node { pub source: Source, pub version: Version, } #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum PackageManagerKind { Npm, Pnpm, Yarn, } impl fmt::Display for PackageManagerKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", match self { PackageManagerKind::Npm => "npm", PackageManagerKind::Pnpm => "pnpm", PackageManagerKind::Yarn => "yarn", } ) } } #[derive(Clone)] struct PackageManager { kind: PackageManagerKind, source: Source, version: Version, } /// How (if at all) should the list query be narrowed? enum Filter { /// Display only the currently active tool(s). /// /// For example, if the user queries `volta list --current yarn`, show only /// the version of Yarn currently in use: project, default, or none. Current, /// Show only the user's default tool(s). /// /// For example, if the user queries `volta list --default node`, show only /// the user's default Node version. Default, /// Do not filter at all. Show all tool(s) matching the query. None, } #[derive(clap::Args)] pub(crate) struct List { /// The tool to lookup - `all`, `node`, `npm`, `yarn`, `pnpm`, or the name /// of a package or binary. #[arg(value_name = "tool")] subcommand: Option, /// Specify the output format. /// /// Defaults to `human` for TTYs, `plain` otherwise. #[arg(long)] format: Option, /// Show the currently-active tool(s). /// /// Equivalent to `volta list` when not specifying a specific tool. #[arg(short, long, conflicts_with = "default")] current: bool, /// Show your default tool(s). #[arg(short, long, conflicts_with = "current")] default: bool, } /// Which tool should we look up? #[derive(Clone)] enum Subcommand { /// Show every item in the toolchain. All, /// Show locally cached Node versions. Node, /// Show locally cached npm versions. Npm, /// Show locally cached pnpm versions. Pnpm, /// Show locally cached Yarn versions. Yarn, /// Show locally cached versions of a package or a package binary. PackageOrTool { name: String }, } impl FromStr for Subcommand { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(match s { "all" => Subcommand::All, "node" => Subcommand::Node, "npm" => Subcommand::Npm, "pnpm" => Subcommand::Pnpm, "yarn" => Subcommand::Yarn, s => Subcommand::PackageOrTool { name: s.into() }, }) } } impl List { fn output_format(&self) -> Format { // We start by checking if the user has explicitly set a value: if they // have, that trumps our TTY-checking. Then, if the user has *not* // specified an option, we use `Human` mode for TTYs and `Plain` for // non-TTY contexts. self.format.unwrap_or(if std::io::stdout().is_terminal() { Format::Human } else { Format::Plain }) } } impl Command for List { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::List); let project = session.project()?; let default_platform = session.default_platform()?; let format = match self.output_format() { Format::Human => human::format, Format::Plain => plain::format, }; let filter = match (self.current, self.default) { (true, false) => Filter::Current, (false, true) => Filter::Default, (true, true) => unreachable!("simultaneous `current` and `default` forbidden by clap"), _ => Filter::None, }; let toolchain = match self.subcommand { // For no subcommand, show the user's current toolchain None => Toolchain::active(project, default_platform)?, Some(Subcommand::All) => Toolchain::all(project, default_platform)?, Some(Subcommand::Node) => Toolchain::node(project, default_platform, &filter)?, Some(Subcommand::Npm) => Toolchain::npm(project, default_platform, &filter)?, Some(Subcommand::Pnpm) => Toolchain::pnpm(project, default_platform, &filter)?, Some(Subcommand::Yarn) => Toolchain::yarn(project, default_platform, &filter)?, Some(Subcommand::PackageOrTool { name }) => { Toolchain::package_or_tool(&name, project, &filter)? } }; if let Some(string) = format(&toolchain) { println!("{}", string) }; session.add_event_end(ActivityKind::List, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/list/plain.rs ================================================ //! Define the "plain" format style for list commands. use node_semver::Version; use volta_core::style::tool_version; use super::{Node, Package, PackageManager, Source, Toolchain}; pub(super) fn format(toolchain: &Toolchain) -> Option { let (runtimes, package_managers, packages) = match toolchain { Toolchain::Node(runtimes) => (describe_runtimes(runtimes), None, None), Toolchain::PackageManagers { managers, .. } => { (None, describe_package_managers(managers), None) } Toolchain::Packages(packages) => (None, None, describe_packages(packages)), Toolchain::Tool { name, host_packages, } => (None, None, Some(describe_tool_set(name, host_packages))), Toolchain::Active { runtime, package_managers, packages, } => ( runtime .as_ref() .and_then(|r| describe_runtimes(&[(**r).clone()])), describe_package_managers(package_managers), describe_packages(packages), ), Toolchain::All { runtimes, package_managers, packages, } => ( describe_runtimes(runtimes), describe_package_managers(package_managers), describe_packages(packages), ), }; match (runtimes, package_managers, packages) { (Some(runtimes), Some(package_managers), Some(packages)) => { Some(format!("{}\n{}\n{}", runtimes, package_managers, packages)) } (Some(runtimes), Some(package_managers), None) => { Some(format!("{}\n{}", runtimes, package_managers)) } (Some(runtimes), None, Some(packages)) => Some(format!("{}\n{}", runtimes, packages)), (Some(runtimes), None, None) => Some(runtimes), (None, Some(package_managers), Some(packages)) => { Some(format!("{}\n{}", package_managers, packages)) } (None, Some(package_managers), None) => Some(package_managers), (None, None, Some(packages)) => Some(packages), (None, None, None) => None, } } fn describe_runtimes(runtimes: &[Node]) -> Option { if runtimes.is_empty() { None } else { Some( runtimes .iter() .map(|runtime| display_node(&runtime.source, &runtime.version)) .collect::>() .join("\n"), ) } } fn describe_package_managers(package_managers: &[PackageManager]) -> Option { if package_managers.is_empty() { None } else { Some( package_managers .iter() .map(display_package_manager) .collect::>() .join("\n"), ) } } fn describe_packages(packages: &[Package]) -> Option { if packages.is_empty() { None } else { Some( packages .iter() .map(display_package) .collect::>() .join("\n"), ) } } fn describe_tool_set(name: &str, hosts: &[Package]) -> String { hosts .iter() .filter_map(|package| display_tool(name, package)) .collect::>() .join("\n") } fn display_node(source: &Source, version: &Version) -> String { format!("runtime {}{}", tool_version("node", version), source) } fn display_package_manager(package_manager: &PackageManager) -> String { format!( "package-manager {}{}", tool_version(package_manager.kind, &package_manager.version), package_manager.source ) } fn package_source(package: &Package) -> String { match package { Package::Default { .. } => String::from(" (default)"), Package::Project { path, .. } => format!(" (current @ {})", path.display()), Package::Fetched(..) => String::new(), } } fn display_package(package: &Package) -> String { match package { Package::Default { details, node, tools, .. } => { let tools = match tools.len() { 0 => String::from(" "), _ => format!(" {} ", tools.join(", ")), }; format!( "package {} /{}/ {} {}{}", tool_version(&details.name, &details.version), tools, tool_version("node", node), // Should be updated when we support installing with custom package_managers, // whether Yarn or non-built-in versions of npm "npm@built-in", package_source(package) ) } Package::Project { name, tools, .. } => { let tools = match tools.len() { 0 => String::from(" "), _ => format!(" {} ", tools.join(", ")), }; format!( "package {} /{}/ {} {}{}", tool_version(name, "project"), tools, "node@project", "npm@project", package_source(package) ) } Package::Fetched(details) => format!( "package {} (fetched)", tool_version(&details.name, &details.version) ), } } fn display_tool(name: &str, host: &Package) -> Option { match host { Package::Default { details, node, .. } => Some(format!( "tool {} / {} / {} {}{}", name, tool_version(&details.name, &details.version), tool_version("node", node), "npm@built-in", package_source(host) )), Package::Project { name: host_name, .. } => Some(format!( "tool {} / {} / {} {}{}", name, tool_version(host_name, "project"), "node@project", "npm@project", package_source(host) )), Package::Fetched(..) => None, } } // These tests are organized by way of the *item* being printed, unlike in the // `human` module, because the formatting is consistent across command formats. #[cfg(test)] mod tests { use std::path::PathBuf; use node_semver::Version; use once_cell::sync::Lazy; use crate::command::list::PackageDetails; static NODE_VERSION: Lazy = Lazy::new(|| Version::from((12, 4, 0))); static TYPESCRIPT_VERSION: Lazy = Lazy::new(|| Version::from((3, 4, 1))); static NPM_VERSION: Lazy = Lazy::new(|| Version::from((6, 13, 4))); static YARN_VERSION: Lazy = Lazy::new(|| Version::from((1, 16, 0))); static PROJECT_PATH: Lazy = Lazy::new(|| PathBuf::from("/a/b/c")); mod node { use super::super::*; use super::*; #[test] fn default() { let source = Source::Default; assert_eq!( display_node(&source, &NODE_VERSION).as_str(), "runtime node@12.4.0 (default)" ); } #[test] fn project() { let source = Source::Project(PROJECT_PATH.clone()); assert_eq!( display_node(&source, &NODE_VERSION).as_str(), "runtime node@12.4.0 (current @ /a/b/c)" ); } #[test] fn installed_not_set() { let source = Source::None; assert_eq!( display_node(&source, &NODE_VERSION).as_str(), "runtime node@12.4.0" ); } } mod npm { use super::super::*; use super::*; use crate::command::list::*; #[test] fn default() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: NPM_VERSION.clone(), }) .as_str(), "package-manager npm@6.13.4 (default)" ); } #[test] fn project() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }) .as_str(), "package-manager npm@6.13.4 (current @ /a/b/c)" ); } #[test] fn installed_not_set() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Npm, source: Source::None, version: NPM_VERSION.clone(), }) .as_str(), "package-manager npm@6.13.4" ); } } mod yarn { use super::super::*; use super::*; use crate::command::list::*; #[test] fn default() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: YARN_VERSION.clone(), }) .as_str(), "package-manager yarn@1.16.0 (default)" ); } #[test] fn project() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone() }) .as_str(), "package-manager yarn@1.16.0 (current @ /a/b/c)" ); } #[test] fn installed_not_set() { assert_eq!( display_package_manager(&PackageManager { kind: PackageManagerKind::Yarn, source: Source::None, version: YARN_VERSION.clone() }) .as_str(), "package-manager yarn@1.16.0" ); } } mod package { use super::super::*; use super::*; #[test] fn single_default() { assert_eq!( describe_packages(&[Package::Default { details: PackageDetails { name: "typescript".into(), version: TYPESCRIPT_VERSION.clone(), }, node: NODE_VERSION.clone(), tools: vec!["tsc".into(), "tsserver".into()] }]) .expect("Should always return a `String` if given a non-empty set") .as_str(), "package typescript@3.4.1 / tsc, tsserver / node@12.4.0 npm@built-in (default)" ); } #[test] fn single_project() { assert_eq!( describe_packages(&[Package::Project { name: "typescript".into(), path: PROJECT_PATH.clone(), tools: vec!["tsc".into(), "tsserver".into()] }]) .expect("Should always return a `String` if given a non-empty set") .as_str(), "package typescript@project / tsc, tsserver / node@project npm@project (current @ /a/b/c)" ); } #[test] fn mixed() { assert_eq!( describe_packages(&[ Package::Project { name: "typescript".into(), path: PROJECT_PATH.clone(), tools: vec!["tsc".into(), "tsserver".into()] }, Package::Default { details: PackageDetails { name: "ember-cli".into(), version: Version::from((3, 10, 0)), }, node: NODE_VERSION.clone(), tools: vec!["ember".into()], }, Package::Fetched(PackageDetails { name: "create-react-app".into(), version: Version::from((1, 0, 0)), }) ]) .expect("Should always return a `String` if given a non-empty set") .as_str(), "package typescript@project / tsc, tsserver / node@project npm@project (current @ /a/b/c)\n\ package ember-cli@3.10.0 / ember / node@12.4.0 npm@built-in (default)\n\ package create-react-app@1.0.0 (fetched)" ); } #[test] fn installed_not_set() { assert_eq!( describe_packages(&[Package::Fetched(PackageDetails { name: "typescript".into(), version: TYPESCRIPT_VERSION.clone(), })]) .expect("Should always return a `String` if given a non-empty set") .as_str(), "package typescript@3.4.1 (fetched)" ); } } mod tool { use super::super::*; use super::*; #[test] fn default() { assert_eq!( display_tool( "tsc", &Package::Default { details: PackageDetails { name: "typescript".into(), version: TYPESCRIPT_VERSION.clone(), }, node: NODE_VERSION.clone(), tools: vec!["tsc".into(), "tsserver".into()], } ) .expect("should always return `Some` for `Default`") .as_str(), "tool tsc / typescript@3.4.1 / node@12.4.0 npm@built-in (default)" ); } #[test] fn project() { assert_eq!( display_tool( "tsc", &Package::Project { name: "typescript".into(), path: PROJECT_PATH.clone(), tools: vec!["tsc".into(), "tsserver".into()], } ) .expect("should always return `Some` for `Project`") .as_str(), "tool tsc / typescript@project / node@project npm@project (current @ /a/b/c)" ); } #[test] fn fetched() { assert_eq!( display_tool( "tsc", &Package::Fetched(PackageDetails { name: "typescript".into(), version: TYPESCRIPT_VERSION.clone() }) ), None ); } } mod toolchain { use super::super::*; use super::*; use crate::command::list::{Node, PackageManager, PackageManagerKind, Toolchain}; #[test] fn full() { assert_eq!( format(&Toolchain::All { runtimes: vec![ Node { source: Source::Default, version: NODE_VERSION.clone() }, Node { source: Source::None, version: Version::from((8, 2, 4)) } ], package_managers: vec![ PackageManager { kind: PackageManagerKind::Npm, source: Source::Project(PROJECT_PATH.clone()), version: NPM_VERSION.clone(), }, PackageManager { kind: PackageManagerKind::Npm, source: Source::Default, version: Version::from((5, 10, 0)) }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Project(PROJECT_PATH.clone()), version: YARN_VERSION.clone() }, PackageManager { kind: PackageManagerKind::Yarn, source: Source::Default, version: Version::from((1, 17, 0)) } ], packages: vec![ Package::Default { details: PackageDetails { name: "ember-cli".into(), version: Version::from((3, 10, 2)), }, node: NODE_VERSION.clone(), tools: vec!["ember".into()] }, Package::Project { name: "ember-cli".into(), path: PROJECT_PATH.clone(), tools: vec!["ember".into()] }, Package::Default { details: PackageDetails { name: "typescript".into(), version: TYPESCRIPT_VERSION.clone(), }, node: NODE_VERSION.clone(), tools: vec!["tsc".into(), "tsserver".into()] } ] }) .expect("`format` with a non-empty toolchain returns `Some`") .as_str(), "runtime node@12.4.0 (default)\n\ runtime node@8.2.4\n\ package-manager npm@6.13.4 (current @ /a/b/c)\n\ package-manager npm@5.10.0 (default)\n\ package-manager yarn@1.16.0 (current @ /a/b/c)\n\ package-manager yarn@1.17.0 (default)\n\ package ember-cli@3.10.2 / ember / node@12.4.0 npm@built-in (default)\n\ package ember-cli@project / ember / node@project npm@project (current @ /a/b/c)\n\ package typescript@3.4.1 / tsc, tsserver / node@12.4.0 npm@built-in (default)" ) } } } ================================================ FILE: src/command/list/toolchain.rs ================================================ use super::{Filter, Node, Package, PackageManager, Source}; use crate::command::list::PackageManagerKind; use node_semver::Version; use volta_core::error::Fallible; use volta_core::inventory::{ node_versions, npm_versions, package_configs, pnpm_versions, yarn_versions, }; use volta_core::platform::PlatformSpec; use volta_core::project::Project; use volta_core::tool::PackageConfig; pub(super) enum Toolchain { Node(Vec), PackageManagers { kind: PackageManagerKind, managers: Vec, }, Packages(Vec), Tool { name: String, host_packages: Vec, }, Active { runtime: Option>, package_managers: Vec, packages: Vec, }, All { runtimes: Vec, package_managers: Vec, packages: Vec, }, } /// Lightweight rule for which item to get the `Source` for. enum Lookup { /// Look up the Node runtime Runtime, /// Look up the npm package manager Npm, /// Look up the pnpm package manager Pnpm, /// Look up the Yarn package manager Yarn, } impl Lookup { fn version_from_spec(&self) -> impl Fn(&PlatformSpec) -> Option + '_ { move |spec| match self { Lookup::Runtime => Some(spec.node.clone()), Lookup::Npm => spec.npm.clone(), Lookup::Pnpm => spec.pnpm.clone(), Lookup::Yarn => spec.yarn.clone(), } } fn version_source( self, project: Option<&Project>, default_platform: Option<&PlatformSpec>, version: &Version, ) -> Source { project .and_then(|proj| { proj.platform() .and_then(self.version_from_spec()) .and_then(|project_version| { if &project_version == version { Some(Source::Project(proj.manifest_file().to_owned())) } else { None } }) }) .or_else(|| { default_platform .and_then(self.version_from_spec()) .and_then(|default_version| { if &default_version == version { Some(Source::Default) } else { None } }) }) .unwrap_or(Source::None) } /// Determine the `Source` for a given kind of tool (`Lookup`). fn active_tool( self, project: Option<&Project>, default: Option<&PlatformSpec>, ) -> Option<(Source, Version)> { project .and_then(|proj| { proj.platform() .and_then(self.version_from_spec()) .map(|version| (Source::Project(proj.manifest_file().to_owned()), version)) }) .or_else(|| { default .and_then(self.version_from_spec()) .map(|version| (Source::Default, version)) }) } } /// Look up the `Source` for a tool with a given name. fn tool_source(name: &str, project: Option<&Project>) -> Fallible { match project { Some(project) => { if project.has_direct_bin(name.as_ref())? { Ok(Source::Project(project.manifest_file().to_owned())) } else { Ok(Source::Default) } } _ => Ok(Source::Default), } } impl Toolchain { pub(super) fn active( project: Option<&Project>, default_platform: Option<&PlatformSpec>, ) -> Fallible { let runtime = Lookup::Runtime .active_tool(project, default_platform) .map(|(source, version)| Box::new(Node { source, version })); let package_managers = Lookup::Npm .active_tool(project, default_platform) .map(|(source, version)| PackageManager { kind: PackageManagerKind::Npm, source, version, }) .into_iter() .chain(Lookup::Pnpm.active_tool(project, default_platform).map( |(source, version)| PackageManager { kind: PackageManagerKind::Pnpm, source, version, }, )) .chain(Lookup::Yarn.active_tool(project, default_platform).map( |(source, version)| PackageManager { kind: PackageManagerKind::Yarn, source, version, }, )) .collect(); let packages = Package::from_inventory_and_project(project)?; Ok(Toolchain::Active { runtime, package_managers, packages, }) } pub(super) fn all( project: Option<&Project>, default_platform: Option<&PlatformSpec>, ) -> Fallible { let runtimes = node_versions()? .iter() .map(|version| Node { source: Lookup::Runtime.version_source(project, default_platform, version), version: version.clone(), }) .collect(); let package_managers = npm_versions()? .iter() .map(|version| PackageManager { kind: PackageManagerKind::Npm, source: Lookup::Npm.version_source(project, default_platform, version), version: version.clone(), }) .chain(pnpm_versions()?.iter().map(|version| PackageManager { kind: PackageManagerKind::Pnpm, source: Lookup::Pnpm.version_source(project, default_platform, version), version: version.clone(), })) .chain(yarn_versions()?.iter().map(|version| PackageManager { kind: PackageManagerKind::Yarn, source: Lookup::Yarn.version_source(project, default_platform, version), version: version.clone(), })) .collect(); let packages = Package::from_inventory_and_project(project)?; Ok(Toolchain::All { runtimes, package_managers, packages, }) } pub(super) fn node( project: Option<&Project>, default_platform: Option<&PlatformSpec>, filter: &Filter, ) -> Fallible { let runtimes = node_versions()? .iter() .filter_map(|version| { let source = Lookup::Runtime.version_source(project, default_platform, version); if source.allowed_with(filter) { let version = version.clone(); Some(Node { source, version }) } else { None } }) .collect(); Ok(Toolchain::Node(runtimes)) } pub(super) fn npm( project: Option<&Project>, default_platform: Option<&PlatformSpec>, filter: &Filter, ) -> Fallible { let managers = npm_versions()? .iter() .filter_map(|version| { let source = Lookup::Npm.version_source(project, default_platform, version); if source.allowed_with(filter) { Some(PackageManager { kind: PackageManagerKind::Npm, source, version: version.clone(), }) } else { None } }) .collect(); Ok(Toolchain::PackageManagers { kind: PackageManagerKind::Npm, managers, }) } pub(super) fn pnpm( project: Option<&Project>, default_platform: Option<&PlatformSpec>, filter: &Filter, ) -> Fallible { let managers = pnpm_versions()? .iter() .filter_map(|version| { let source = Lookup::Pnpm.version_source(project, default_platform, version); if source.allowed_with(filter) { Some(PackageManager { kind: PackageManagerKind::Pnpm, source, version: version.clone(), }) } else { None } }) .collect(); Ok(Toolchain::PackageManagers { kind: PackageManagerKind::Pnpm, managers, }) } pub(super) fn yarn( project: Option<&Project>, default_platform: Option<&PlatformSpec>, filter: &Filter, ) -> Fallible { let managers = yarn_versions()? .iter() .filter_map(|version| { let source = Lookup::Yarn.version_source(project, default_platform, version); if source.allowed_with(filter) { Some(PackageManager { kind: PackageManagerKind::Yarn, source, version: version.clone(), }) } else { None } }) .collect(); Ok(Toolchain::PackageManagers { kind: PackageManagerKind::Yarn, managers, }) } pub(super) fn package_or_tool( name: &str, project: Option<&Project>, filter: &Filter, ) -> Fallible { /// An internal-only helper for tracking whether we found a given item /// from the `PackageCollection` as a *package* or as a *tool*. #[derive(PartialEq, Debug)] enum Kind { Package, Tool, } /// A convenient name for this tuple, since we have to name it in a few /// spots below. type Triple<'p> = (Kind, &'p PackageConfig, Source); let configs = package_configs()?; let packages_and_tools = configs .iter() .filter_map(|config| { // Start with the package itself, since tools often match // the package name and we prioritize packages. if config.name == name { let source = Package::source(name, project); if source.allowed_with(filter) { Some(Ok((Kind::Package, config, source))) } else { None } // Then check if the passed name matches an installed package's // binaries. If it does, we have a tool. } else if config.bins.iter().any(|bin| bin.as_str() == name) { tool_source(name, project) .map(|source| { if source.allowed_with(filter) { Some((Kind::Tool, config, source)) } else { None } }) .transpose() // Otherwise, we don't have any match all. } else { None } }) // Then eagerly collect the first error (if there are any) and // return it; otherwise we have a totally valid collection. .collect::>>()?; let (has_packages, has_tools) = packages_and_tools .iter() .fold((false, false), |(packages, tools), (kind, ..)| { ( packages || kind == &Kind::Package, tools || kind == &Kind::Tool, ) }); let toolchain = match (has_packages, has_tools) { // If there are neither packages nor tools, treat it as `Packages`, // but don't re-process the data just to construct an empty `Vec`! (false, false) => Toolchain::Packages(vec![]), // If there are any packages, we resolve this *as* `Packages`, even // if there are also matching tools, since we give priority to // listing packages between packages and tools. (true, _) => { let packages = packages_and_tools .into_iter() .filter_map(|(kind, config, source)| match kind { Kind::Package => Some(Package::new(config, &source)), Kind::Tool => None, }) .collect(); Toolchain::Packages(packages) } // If there are no packages matching, but we do have tools matching, // we return `Tool`. (false, true) => { let host_packages = packages_and_tools .into_iter() .filter_map(|(kind, config, source)| match kind { Kind::Tool => Some(Package::new(config, &source)), Kind::Package => None, // should be none of these! }) .collect(); Toolchain::Tool { name: name.into(), host_packages, } } }; Ok(toolchain) } } ================================================ FILE: src/command/mod.rs ================================================ pub(crate) mod completions; pub(crate) mod fetch; pub(crate) mod install; pub(crate) mod list; pub(crate) mod pin; pub(crate) mod run; pub(crate) mod setup; pub(crate) mod uninstall; pub(crate) mod r#use; pub(crate) mod which; pub(crate) use self::which::Which; pub(crate) use completions::Completions; pub(crate) use fetch::Fetch; pub(crate) use install::Install; pub(crate) use list::List; pub(crate) use pin::Pin; pub(crate) use r#use::Use; pub(crate) use run::Run; pub(crate) use setup::Setup; pub(crate) use uninstall::Uninstall; use volta_core::error::{ExitCode, Fallible}; use volta_core::session::Session; /// A Volta command. pub(crate) trait Command: Sized { /// Executes the command. Returns `Ok(true)` if the process should return 0, /// `Ok(false)` if the process should return 1, and `Err(e)` if the process /// should return `e.exit_code()`. fn run(self, session: &mut Session) -> Fallible; } ================================================ FILE: src/command/pin.rs ================================================ use volta_core::error::{ExitCode, Fallible}; use volta_core::session::{ActivityKind, Session}; use volta_core::tool::Spec; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Pin { /// Tools to pin, like `node@lts` or `yarn@^1.14`. #[arg(value_name = "tool[@version]", required = true)] tools: Vec, } impl Command for Pin { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Pin); for tool in Spec::from_strings(&self.tools, "pin")? { tool.resolve(session)?.pin(session)?; } session.add_event_end(ActivityKind::Pin, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/run.rs ================================================ use std::collections::HashMap; use std::ffi::OsString; use crate::command::Command; use crate::common::{Error, IntoResult}; use log::warn; use volta_core::error::{report_error, ExitCode, Fallible}; use volta_core::platform::{CliPlatform, InheritOption}; use volta_core::run::execute_tool; use volta_core::session::{ActivityKind, Session}; use volta_core::tool::{node, npm, pnpm, yarn}; #[derive(Debug, clap::Args)] pub(crate) struct Run { /// Set the custom Node version #[arg(long, value_name = "version")] node: Option, /// Set the custom npm version #[arg(long, value_name = "version", conflicts_with = "bundled_npm")] npm: Option, /// Forces npm to be the version bundled with Node #[arg(long, conflicts_with = "npm")] bundled_npm: bool, /// Set the custon pnpm version #[arg(long, value_name = "version", conflicts_with = "no_pnpm")] pnpm: Option, /// Disables pnpm #[arg(long, conflicts_with = "pnpm")] no_pnpm: bool, /// Set the custom Yarn version #[arg(long, value_name = "version", conflicts_with = "no_yarn")] yarn: Option, /// Disables Yarn #[arg(long, conflicts_with = "yarn")] no_yarn: bool, /// Set an environment variable (can be used multiple times) #[arg(long = "env", value_name = "NAME=value", num_args = 1)] envs: Vec, /// The command to run, along with any arguments #[arg( allow_hyphen_values = true, trailing_var_arg = true, value_name = "COMMAND", required = true )] command_and_args: Vec, } impl Command for Run { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Run); let envs = self.parse_envs(); let platform = self.parse_platform(session)?; // Safety: At least one value is required for `command_and_args`, so there must be at // least one value in the list. If no value is provided, Clap will show a "required // argument missing" message and this function won't be called. let command = &self.command_and_args[0]; let args = &self.command_and_args[1..]; match execute_tool(command, args, &envs, platform, session).into_result() { Ok(()) => { session.add_event_end(ActivityKind::Run, ExitCode::Success); Ok(ExitCode::Success) } Err(Error::Tool(code)) => { session.add_event_tool_end(ActivityKind::Run, code); Ok(ExitCode::ExecutionFailure) } Err(Error::Volta(err)) => { report_error(env!("CARGO_PKG_VERSION"), &err); session.add_event_error(ActivityKind::Run, &err); session.add_event_end(ActivityKind::Run, err.exit_code()); Ok(err.exit_code()) } } } } impl Run { /// Builds a CliPlatform from the provided cli options /// /// Will resolve a semver / tag version if necessary fn parse_platform(&self, session: &mut Session) -> Fallible { let node = self .node .as_ref() .map(|version| node::resolve(version.parse()?, session)) .transpose()?; let npm = match (self.bundled_npm, &self.npm) { (true, _) => InheritOption::None, (false, None) => InheritOption::Inherit, (false, Some(version)) => match npm::resolve(version.parse()?, session)? { None => InheritOption::Inherit, Some(npm) => InheritOption::Some(npm), }, }; let pnpm = match (self.no_pnpm, &self.pnpm) { (true, _) => InheritOption::None, (false, None) => InheritOption::Inherit, (false, Some(version)) => { InheritOption::Some(pnpm::resolve(version.parse()?, session)?) } }; let yarn = match (self.no_yarn, &self.yarn) { (true, _) => InheritOption::None, (false, None) => InheritOption::Inherit, (false, Some(version)) => { InheritOption::Some(yarn::resolve(version.parse()?, session)?) } }; Ok(CliPlatform { node, npm, pnpm, yarn, }) } /// Convert the environment variable settings passed to the command line into a map /// /// We ignore any setting that doesn't have a value associated with it /// We also ignore the PATH environment variable as that is set when running a command fn parse_envs(&self) -> HashMap<&str, &str> { self.envs.iter().filter_map(|entry| { let mut key_value = entry.splitn(2, '='); match (key_value.next(), key_value.next()) { (None, _) => None, (Some(_), None) => None, (Some(key), _) if key.eq_ignore_ascii_case("PATH") => { warn!("Ignoring {} environment variable as it will be overwritten when executing the command", key); None } (Some(key), Some(value)) => Some((key, value)), } }).collect() } } ================================================ FILE: src/command/setup.rs ================================================ use log::info; use volta_core::error::{ExitCode, Fallible}; use volta_core::layout::volta_home; use volta_core::session::{ActivityKind, Session}; use volta_core::shim::regenerate_shims_for_dir; use volta_core::style::success_prefix; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Setup {} impl Command for Setup { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Setup); os::setup_environment()?; regenerate_shims_for_dir(volta_home()?.shim_dir())?; info!( "{} Setup complete. Open a new terminal to start using Volta!", success_prefix() ); session.add_event_end(ActivityKind::Setup, ExitCode::Success); Ok(ExitCode::Success) } } #[cfg(unix)] mod os { use std::env; use std::fs::File; use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use log::{debug, warn}; use volta_core::error::{ErrorKind, Fallible}; use volta_core::layout::volta_home; pub fn setup_environment() -> Fallible<()> { let home = volta_home()?; let formatted_home = format_home(home.root()); // Don't update the user's shell config files if VOLTA_HOME and PATH already contain what we need. let home_in_path = match env::var_os("PATH") { Some(paths) => env::split_paths(&paths).find(|p| p == home.shim_dir()), None => None, }; if env::var_os("VOLTA_HOME").is_some() && home_in_path.is_some() { debug!( "Skipping dot-file modification as VOLTA_HOME is set, and included in the PATH." ); return Ok(()); } debug!("Searching for profiles to update"); let profiles = determine_profiles()?; let found_profile = profiles.into_iter().fold(false, |prev, profile| { let contents = read_profile_without_volta(&profile).unwrap_or_default(); let write_profile = match profile.extension() { Some(ext) if ext == "fish" => write_profile_fish, _ => write_profile_sh, }; match write_profile(&profile, contents, &formatted_home) { Ok(()) => true, Err(err) => { warn!( "Found profile script, but could not modify it: {}", profile.display() ); debug!("Profile modification error: {}", err); prev } } }); if found_profile { Ok(()) } else { Err(ErrorKind::NoShellProfile { env_profile: String::new(), bin_dir: home.shim_dir().to_owned(), } .into()) } } /// Returns a list of profile files to modify / create. /// /// Any file in the list should be created if it doesn't already exist fn determine_profiles() -> Fallible> { let home_dir = dirs::home_dir().ok_or(ErrorKind::NoHomeEnvironmentVar)?; let shell = env::var("SHELL").unwrap_or_else(|_| String::new()); // Always include `~/.profile` let mut profiles = vec![home_dir.join(".profile")]; // PROFILE environment variable, if set if let Ok(profile_env) = env::var("PROFILE") { if !profile_env.is_empty() { profiles.push(profile_env.into()); } } add_zsh_profile(&home_dir, &shell, &mut profiles); add_bash_profiles(&home_dir, &shell, &mut profiles); add_fish_profile(&home_dir, &shell, &mut profiles); Ok(profiles) } /// Add zsh profile script, if necessary fn add_zsh_profile(home_dir: &Path, shell: &str, profiles: &mut Vec) { let zdotdir_env = env::var("ZDOTDIR").unwrap_or_else(|_| String::new()); let zdotdir = if zdotdir_env.is_empty() { home_dir } else { Path::new(&zdotdir_env) }; let zshenv = zdotdir.join(".zshenv"); let zshrc = zdotdir.join(".zshrc"); if shell.contains("zsh") || zshenv.exists() { profiles.push(zshenv); } else if zshrc.exists() { profiles.push(zshrc); } } /// Add bash profile scripts, if necessary /// /// Note: We only add the bash scripts if they already exist, as creating new files can impact /// the processing of existing files in bash (e.g. preventing ~/.profile from being loaded) fn add_bash_profiles(home_dir: &Path, shell: &str, profiles: &mut Vec) { let mut bash_added = false; let bashrc = home_dir.join(".bashrc"); if bashrc.exists() { bash_added = true; profiles.push(bashrc); } let bash_profile = home_dir.join(".bash_profile"); if bash_profile.exists() { bash_added = true; profiles.push(bash_profile); } if shell.contains("bash") && !bash_added { let suggested_bash_profile = if cfg!(target_os = "macos") { "~/.bash_profile" } else { "~/.bashrc" }; warn!( "We detected that you are using bash, however we couldn't find any bash profile scripts. If you run into problems running Volta, create {} and run `volta setup` again.", suggested_bash_profile ); } } /// Add fish profile scripts, if necessary fn add_fish_profile(home_dir: &Path, shell: &str, profiles: &mut Vec) { let fish_config = home_dir.join(".config/fish/config.fish"); if shell.contains("fish") || fish_config.exists() { profiles.push(fish_config); } } fn read_profile_without_volta(path: &Path) -> Option { let file = File::open(path).ok()?; let reader = BufReader::new(file); reader .lines() .filter(|line_result| match line_result { Ok(line) if !line.contains("VOLTA") => true, Ok(_) => false, Err(_) => true, }) .collect::>>() .map(|lines| lines.join("\n")) .ok() } fn format_home(volta_home: &Path) -> String { if let Some(home_dir) = env::var_os("HOME") { if let Ok(suffix) = volta_home.strip_prefix(home_dir) { // If the HOME environment variable is set _and_ the proposed VOLTA_HOME starts // with that value, use $HOME when writing the profile scripts return format!("$HOME/{}", suffix.display()); } } volta_home.display().to_string() } fn write_profile_sh(path: &Path, contents: String, volta_home: &str) -> io::Result<()> { let mut file = File::create(path)?; write!( file, "{}\nexport VOLTA_HOME=\"{}\"\nexport PATH=\"$VOLTA_HOME/bin:$PATH\"\n", contents, volta_home, ) } fn write_profile_fish(path: &Path, contents: String, volta_home: &str) -> io::Result<()> { let mut file = File::create(path)?; write!( file, "{}\nset -gx VOLTA_HOME \"{}\"\nset -gx PATH \"$VOLTA_HOME/bin\" $PATH\n", contents, volta_home, ) } } #[cfg(windows)] mod os { use std::process::Command; use log::debug; use volta_core::error::{Context, ErrorKind, Fallible}; use volta_core::layout::volta_home; use winreg::enums::HKEY_CURRENT_USER; use winreg::RegKey; pub fn setup_environment() -> Fallible<()> { let shim_dir = volta_home()?.shim_dir().to_string_lossy().to_string(); let hkcu = RegKey::predef(HKEY_CURRENT_USER); let env = hkcu .open_subkey("Environment") .with_context(|| ErrorKind::ReadUserPathError)?; let path: String = env .get_value("Path") .with_context(|| ErrorKind::ReadUserPathError)?; if !path.contains(&shim_dir) { // Use `setx` command to edit the user Path environment variable let mut command = Command::new("setx"); command.arg("Path"); command.arg(format!("{};{}", shim_dir, path)); debug!("Modifying User Path with command: {:?}", command); let output = command .output() .with_context(|| ErrorKind::WriteUserPathError)?; if !output.status.success() { debug!("[setx stderr]\n{}", String::from_utf8_lossy(&output.stderr)); debug!("[setx stdout]\n{}", String::from_utf8_lossy(&output.stdout)); return Err(ErrorKind::WriteUserPathError.into()); } } Ok(()) } } ================================================ FILE: src/command/uninstall.rs ================================================ use volta_core::error::{ErrorKind, ExitCode, Fallible}; use volta_core::session::{ActivityKind, Session}; use volta_core::tool; use volta_core::version::VersionSpec; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Uninstall { /// The tool to uninstall, like `ember-cli-update`, `typescript`, or tool: String, } impl Command for Uninstall { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Uninstall); let tool = tool::Spec::try_from_str(&self.tool)?; // For packages, specifically report that we do not support uninstalling // specific versions. For runtimes and package managers, we currently // *intentionally* let this fall through to inform the user that we do // not support uninstalling those *at all*. if let tool::Spec::Package(_name, version) = &tool { let VersionSpec::None = version else { return Err(ErrorKind::Unimplemented { feature: "uninstalling specific versions of tools".into(), } .into()); }; } tool.uninstall()?; session.add_event_end(ActivityKind::Uninstall, ExitCode::Success); Ok(ExitCode::Success) } } ================================================ FILE: src/command/use.rs ================================================ use crate::command::Command; use volta_core::error::{ErrorKind, ExitCode, Fallible}; use volta_core::session::{ActivityKind, Session}; // NOTE: These use the same text as the `long_about` in crate::cli. // It's hard to abstract since it's in an attribute string. pub(crate) const USAGE: &str = "The subcommand `use` is deprecated. To install a tool in your toolchain, use `volta install`. To pin your project's runtime or package manager, use `volta pin`. "; const ADVICE: &str = " To install a tool in your toolchain, use `volta install`. To pin your project's runtime or package manager, use `volta pin`. "; #[derive(clap::Args)] pub(crate) struct Use { #[allow(dead_code)] anything: Vec, // Prevent Clap argument errors when invoking e.g. `volta use node` } impl Command for Use { fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Help); let result = Err(ErrorKind::DeprecatedCommandError { command: "use".to_string(), advice: ADVICE.to_string(), } .into()); session.add_event_end(ActivityKind::Help, ExitCode::InvalidArguments); result } } ================================================ FILE: src/command/which.rs ================================================ use std::env; use std::ffi::OsString; use which::which_in; use volta_core::error::{Context, ErrorKind, ExitCode, Fallible}; use volta_core::platform::{Platform, System}; use volta_core::run::binary::DefaultBinary; use volta_core::session::{ActivityKind, Session}; use crate::command::Command; #[derive(clap::Args)] pub(crate) struct Which { /// The binary to find, e.g. `node` or `npm` binary: OsString, } impl Command for Which { // 1. Start by checking if the user has a tool installed in the project or // as a user default. If so, we're done. // 2. Otherwise, use the platform image and/or the system environment to // determine a lookup path to run `which` in. fn run(self, session: &mut Session) -> Fallible { session.add_event_start(ActivityKind::Which); let default_tool = DefaultBinary::from_name(&self.binary, session)?; let project_bin_path = session .project()? .and_then(|project| project.find_bin(&self.binary)); let tool_path = match (default_tool, project_bin_path) { (Some(_), Some(bin_path)) => Some(bin_path), (Some(tool), _) => Some(tool.bin_path), _ => None, }; if let Some(path) = tool_path { println!("{}", path.to_string_lossy()); let exit_code = ExitCode::Success; session.add_event_end(ActivityKind::Which, exit_code); return Ok(exit_code); } // Treat any error with obtaining the current platform image as if the image doesn't exist // However, errors in obtaining the current working directory or the System path should // still be treated as errors. let path = match Platform::current(session) .unwrap_or(None) .and_then(|platform| platform.checkout(session).ok()) .and_then(|image| image.path().ok()) { Some(path) => path, None => System::path()?, }; let cwd = env::current_dir().with_context(|| ErrorKind::CurrentDirError)?; let exit_code = match which_in(&self.binary, Some(path), cwd) { Ok(result) => { println!("{}", result.to_string_lossy()); ExitCode::Success } Err(_) => { // `which_in` Will return an Err if it can't find the binary in the path // In that case, we don't want to print anything out, but we want to return // Exit Code 1 (ExitCode::UnknownError) ExitCode::UnknownError } }; session.add_event_end(ActivityKind::Which, exit_code); Ok(exit_code) } } ================================================ FILE: src/common.rs ================================================ use std::process::{Command, ExitStatus}; use volta_core::error::{Context, ErrorKind, VoltaError}; use volta_core::layout::{volta_home, volta_install}; pub enum Error { Volta(VoltaError), Tool(i32), } pub fn ensure_layout() -> Result<(), Error> { let home = volta_home().map_err(Error::Volta)?; if !home.layout_file().exists() { let install = volta_install().map_err(Error::Volta)?; Command::new(install.migrate_executable()) .env("VOLTA_LOGLEVEL", format!("{}", log::max_level())) .status() .with_context(|| ErrorKind::CouldNotStartMigration) .into_result()?; } Ok(()) } pub trait IntoResult { fn into_result(self) -> Result; } impl IntoResult<()> for Result { fn into_result(self) -> Result<(), Error> { match self { Ok(status) => { if status.success() { Ok(()) } else { let code = status.code().unwrap_or(1); Err(Error::Tool(code)) } } Err(err) => Err(Error::Volta(err)), } } } ================================================ FILE: src/main.rs ================================================ #[macro_use] mod command; mod cli; use clap::Parser; use volta_core::error::report_error; use volta_core::log::{LogContext, LogVerbosity, Logger}; use volta_core::session::{ActivityKind, Session}; mod common; use common::{ensure_layout, Error}; /// The entry point for the `volta` CLI. pub fn main() { let volta = cli::Volta::parse(); let verbosity = match (&volta.verbose, &volta.quiet) { (false, false) => LogVerbosity::Default, (true, false) => { if volta.very_verbose { LogVerbosity::VeryVerbose } else { LogVerbosity::Verbose } } (false, true) => LogVerbosity::Quiet, (true, true) => { unreachable!("Clap should prevent the user from providing both --verbose and --quiet") } }; Logger::init(LogContext::Volta, verbosity).expect("Only a single logger should be initialized"); log::trace!("log level: {verbosity:?}"); let mut session = Session::init(); session.add_event_start(ActivityKind::Volta); let result = ensure_layout().and_then(|()| volta.run(&mut session).map_err(Error::Volta)); match result { Ok(exit_code) => { session.add_event_end(ActivityKind::Volta, exit_code); session.exit(exit_code); } Err(Error::Tool(code)) => { session.add_event_tool_end(ActivityKind::Volta, code); session.exit_tool(code); } Err(Error::Volta(err)) => { report_error(env!("CARGO_PKG_VERSION"), &err); session.add_event_error(ActivityKind::Volta, &err); let code = err.exit_code(); session.add_event_end(ActivityKind::Volta, code); session.exit(code); } } } ================================================ FILE: src/volta-migrate.rs ================================================ use volta_core::error::{report_error, ExitCode}; use volta_core::layout::volta_home; use volta_core::log::{LogContext, LogVerbosity, Logger}; use volta_migrate::run_migration; pub fn main() { Logger::init(LogContext::Migration, LogVerbosity::Default) .expect("Only a single Logger should be initialized"); // In order to migrate the existing Volta directory while avoiding unconditional changes to the user's system, // the Homebrew formula runs volta-migrate with `--no-create` flag in the post-install phase. let no_create = matches!(std::env::args_os().nth(1), Some(flag) if flag == "--no-create"); if no_create && volta_home().map_or(true, |home| !home.root().exists()) { ExitCode::Success.exit(); } let exit_code = match run_migration() { Ok(()) => ExitCode::Success, Err(err) => { report_error(env!("CARGO_PKG_VERSION"), &err); err.exit_code() } }; exit_code.exit(); } ================================================ FILE: src/volta-shim.rs ================================================ mod common; use common::{ensure_layout, Error, IntoResult}; use volta_core::error::{report_error, ExitCode}; use volta_core::log::{LogContext, LogVerbosity, Logger}; use volta_core::run::execute_shim; use volta_core::session::{ActivityKind, Session}; use volta_core::signal::setup_signal_handler; pub fn main() { Logger::init(LogContext::Shim, LogVerbosity::Default) .expect("Only a single Logger should be initialized"); setup_signal_handler(); let mut session = Session::init(); session.add_event_start(ActivityKind::Tool); let result = ensure_layout().and_then(|()| execute_shim(&mut session).into_result()); match result { Ok(()) => { session.add_event_end(ActivityKind::Tool, ExitCode::Success); session.exit(ExitCode::Success); } Err(Error::Tool(code)) => { session.add_event_tool_end(ActivityKind::Tool, code); session.exit_tool(code); } Err(Error::Volta(err)) => { report_error(env!("CARGO_PKG_VERSION"), &err); session.add_event_error(ActivityKind::Tool, &err); session.add_event_end(ActivityKind::Tool, err.exit_code()); session.exit(ExitCode::ExecutionFailure); } } } ================================================ FILE: tests/acceptance/corrupted_download.rs ================================================ use crate::support::sandbox::{sandbox, DistroMetadata, NodeFixture, PnpmFixture, Yarn1Fixture}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use node_semver::Version; use test_support::matchers::execs; use volta_core::error::ExitCode; const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v0.0.1","npm":"0.0.2","lts": "Sure","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]} ] "#; const NODE_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "0.0.1", compressed_size: 10, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; const PNPM_VERSION_INFO: &str = r#" { "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } } "#; const PNPM_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "0.0.1", compressed_size: 10, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "7.7.1", compressed_size: 518, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_1_VERSION_INFO: &str = r#"{ "name":"yarn", "dist-tags": { "latest": "1.2.42" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "1.2.42": { "version":"1.2.42", "dist": { "shasum:"", "tarball":"" }} } }"#; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "0.0.1", compressed_size: 10, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; #[test] fn install_corrupted_node_leaves_inventory_unchanged() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("install node@0.0.1"), execs().with_status(ExitCode::UnknownError as i32) ); assert!(!s.node_inventory_archive_exists(&Version::parse("0.0.1").unwrap())); } #[test] fn install_valid_node_saves_to_inventory() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("install node@10.99.1040"), execs().with_status(ExitCode::Success as i32) ); assert!(s.node_inventory_archive_exists(&Version::parse("10.99.1040").unwrap())); } #[test] fn install_corrupted_pnpm_leaves_inventory_unchanged() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("install pnpm@0.0.1"), execs().with_status(ExitCode::UnknownError as i32) ); assert!(!s.pnpm_inventory_archive_exists("0.0.1")); } #[test] fn install_valid_pnpm_saves_to_inventory() { let s = sandbox() .platform(r#"{ "node": { "runtime": "1.2.3", "npm": null }, "yarn": null }"#) .node_available_versions(NODE_VERSION_INFO) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("install pnpm@7.7.1"), execs().with_status(ExitCode::Success as i32) ); assert!(s.pnpm_inventory_archive_exists("7.7.1")); } #[test] fn install_corrupted_yarn_leaves_inventory_unchanged() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("install yarn@0.0.1"), execs().with_status(ExitCode::UnknownError as i32) ); assert!(!s.yarn_inventory_archive_exists("0.0.1")); } #[test] fn install_valid_yarn_saves_to_inventory() { let s = sandbox() .platform(r#"{ "node": { "runtime": "1.2.3", "npm": null }, "yarn": null }"#) .node_available_versions(NODE_VERSION_INFO) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("install yarn@1.2.42"), execs().with_status(ExitCode::Success as i32) ); assert!(s.yarn_inventory_archive_exists("1.2.42")); } ================================================ FILE: tests/acceptance/direct_install.rs ================================================ use crate::support::sandbox::{sandbox, DistroMetadata, NodeFixture, NpmFixture, Yarn1Fixture}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; fn platform_with_node(node: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": null }}, "yarn": null }}"#, node ) } fn platform_with_node_yarn(node: &str, yarn: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": null }}, "yarn": "{}" }}"#, node, yarn ) } const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v9.27.6","npm":"5.6.17","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v8.9.10","npm":"5.6.7","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v6.19.62","npm":"3.10.1066","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]} ] "#; cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 270, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, DistroMetadata { version: "9.27.6", compressed_size: 1068, uncompressed_size: None, }, DistroMetadata { version: "8.9.10", compressed_size: 1055, uncompressed_size: None, }, DistroMetadata { version: "6.19.62", compressed_size: 1056, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const YARN_1_VERSION_INFO: &str = r#"[ {"tag_name":"v1.2.42","assets":[{"name":"yarn-v1.2.42.tar.gz"}]}, {"tag_name":"v1.3.1","assets":[{"name":"yarn-v1.3.1.msi"}]}, {"tag_name":"v1.4.159","assets":[{"name":"yarn-v1.4.159.tar.gz"}]}, {"tag_name":"v1.7.71","assets":[{"name":"yarn-v1.7.71.tar.gz"}]}, {"tag_name":"v1.12.99","assets":[{"name":"yarn-v1.12.99.tar.gz"}]} ]"#; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "1.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_INFO: &str = r#" { "name":"npm", "dist-tags": { "latest":"8.1.5" }, "versions": { "1.2.3": { "version":"1.2.3", "dist": { "shasum":"", "tarball":"" }}, "4.5.6": { "version":"4.5.6", "dist": { "shasum":"", "tarball":"" }}, "8.1.5": { "version":"8.1.5", "dist": { "shasum":"", "tarball":"" }} } } "#; const NPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "1.2.3", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "4.5.6", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.1.5", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, ]; #[test] fn npm_global_install_node_intercepts() { let s = sandbox() .platform(&platform_with_node("6.19.62")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("i -g node@10.99.1040"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install Node") .with_stdout_contains("[..]installed and set node@10.99.1040[..]") ); } #[test] fn yarn_global_add_node_intercepts() { let s = sandbox() .platform(&platform_with_node("6.19.62")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global add node@9.27.6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install Node") .with_stdout_contains("[..]installed and set node@9.27.6[..]") ); } #[test] fn npm_global_install_npm_intercepts() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("i -g npm@8.1.5"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install npm") .with_stdout_contains("[..]installed and set npm@8.1.5 as default") ); } #[test] fn yarn_global_add_npm_intercepts() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global add npm@4.5.6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install npm") .with_stdout_contains("[..]installed and set npm@4.5.6 as default") ); } #[test] fn npm_global_install_yarn_intercepts() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("i -g yarn@1.12.99"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install Yarn") .with_stdout_contains("[..]installed and set yarn@1.12.99 as default") ); } #[test] fn yarn_global_add_yarn_intercepts() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global add yarn@1.7.71"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]using Volta to install Yarn") .with_stdout_contains("[..]installed and set yarn@1.7.71 as default") ); } #[test] fn npm_global_install_supports_multiples() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("i -g npm@8.1.5 yarn@1.12.99"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]Volta is processing each package separately") .with_stdout_contains("[..]using Volta to install npm") .with_stdout_contains("[..]installed and set npm@8.1.5 as default") .with_stdout_contains("[..]using Volta to install Yarn") .with_stdout_contains("[..]installed and set yarn@1.12.99 as default") ); } #[test] fn npm_global_install_without_packages_is_treated_as_not_global() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("i --global"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..]Volta is processing each package separately") ); } #[test] fn yarn_global_add_supports_multiples() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global add npm@8.1.5 yarn@1.12.99"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]Volta is processing each package separately") .with_stdout_contains("[..]using Volta to install npm") .with_stdout_contains("[..]installed and set npm@8.1.5 as default") .with_stdout_contains("[..]using Volta to install Yarn") .with_stdout_contains("[..]installed and set yarn@1.12.99 as default") ); } #[test] fn yarn_global_add_without_packages_is_treated_as_not_global() { let s = sandbox() .platform(&platform_with_node_yarn("10.99.1040", "1.2.42")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global add"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..]Volta is processing each package separately") ); } #[test] fn npm_global_with_override_does_not_intercept() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .env("VOLTA_UNSAFE_GLOBAL", "1") .build(); assert_that!( s.npm("install --global npm@8"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..]using Volta to install npm") ); } #[test] fn yarn_global_with_override_does_not_intercept() { let s = sandbox() .platform(&platform_with_node_yarn("10.99.1040", "1.12.99")) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .env("VOLTA_UNSAFE_GLOBAL", "1") .build(); assert_that!( s.yarn("global add npm@8"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..]using Volta to install npm") ); } ================================================ FILE: tests/acceptance/direct_uninstall.rs ================================================ //! Tests for `npm uninstall`, `npm uninstall --global`, `yarn remove`, and //! `yarn global remove`, which we support as alternatives to `volta uninstall` //! and which should use its logic. use crate::support::sandbox::{sandbox, DistroMetadata, NodeFixture, Sandbox, Yarn1Fixture}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; fn platform_with_node(node: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": null }}, "yarn": null }}"#, node ) } fn platform_with_node_yarn(node: &str, yarn: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": null }}, "yarn": "{}" }}"#, node, yarn ) } const PKG_CONFIG_COWSAY: &str = r#"{ "name": "cowsay", "version": "1.4.0", "platform": { "node": "11.10.1", "npm": "6.7.0", "yarn": null }, "bins": [ "cowsay", "cowthink" ], "manager": "Npm" }"#; const PKG_CONFIG_TYPESCRIPT: &str = r#"{ "name": "typescript", "version": "1.4.0", "platform": { "node": "11.10.1", "npm": "6.7.0", "yarn": null }, "bins": [ "tsc", "tsserver" ], "manager": "Npm" }"#; fn bin_config(name: &str, pkg: &str) -> String { format!( r#"{{ "name": "{}", "package": "{}", "version": "1.4.0", "platform": {{ "node": "11.10.1", "npm": "6.7.0", "yarn": null }}, "manager": "Npm" }}"#, name, pkg ) } cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 1] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 1] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 1] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const YARN_1_VERSION_FIXTURES: [DistroMetadata; 1] = [DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }]; #[test] fn npm_uninstall_uses_volta_logic() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .package_config("cowsay", PKG_CONFIG_COWSAY) .binary_config("cowsay", &bin_config("cowsay", "cowsay")) .binary_config("cowthink", &bin_config("cowthink", "cowsay")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("uninstall --global cowsay"), execs() .with_status(0) .with_stdout_contains("[..]using Volta to uninstall cowsay") .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); } #[test] fn npm_uninstall_supports_multiples() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .package_config("cowsay", PKG_CONFIG_COWSAY) .binary_config("cowsay", &bin_config("cowsay", "cowsay")) .binary_config("cowthink", &bin_config("cowthink", "cowsay")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .package_config("typescript", PKG_CONFIG_TYPESCRIPT) .binary_config("tsc", &bin_config("tsc", "typescript")) .binary_config("tsserver", &bin_config("tsserver", "typescript")) .shim("tsc") .shim("tsserver") .package_image("typescript", "1.4.0", None) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("uninstall --global cowsay typescript"), execs() .with_status(0) .with_stdout_contains("[..]using Volta to uninstall cowsay") .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") .with_stdout_contains("[..]using Volta to uninstall typescript") .with_stdout_contains("Removed executable 'tsc' installed by 'typescript'") .with_stdout_contains("Removed executable 'tsserver' installed by 'typescript'") .with_stdout_contains("[..]package 'typescript' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); assert!(!Sandbox::package_config_exists("typescript")); assert!(!Sandbox::bin_config_exists("tsc")); assert!(!Sandbox::bin_config_exists("tsserver")); assert!(!Sandbox::shim_exists("tsc")); assert!(!Sandbox::shim_exists("tsserver")); assert!(!Sandbox::package_image_exists("typescript")); } #[test] fn npm_uninstall_without_packages_skips_volta_logic() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.npm("uninstall -g"), execs() .with_status(0) .with_stdout_does_not_contain("[..]Volta is processing each package separately") ); } #[test] fn yarn_remove_uses_volta_logic() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .package_config("cowsay", PKG_CONFIG_COWSAY) .binary_config("cowsay", &bin_config("cowsay", "cowsay")) .binary_config("cowthink", &bin_config("cowthink", "cowsay")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global remove cowsay"), execs() .with_status(0) .with_stdout_contains("[..]using Volta to uninstall cowsay") .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); } #[test] fn yarn_remove_supports_multiples() { let s = sandbox() .platform(&platform_with_node("10.99.1040")) .package_config("cowsay", PKG_CONFIG_COWSAY) .binary_config("cowsay", &bin_config("cowsay", "cowsay")) .binary_config("cowthink", &bin_config("cowthink", "cowsay")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .package_config("typescript", PKG_CONFIG_TYPESCRIPT) .binary_config("tsc", &bin_config("tsc", "typescript")) .binary_config("tsserver", &bin_config("tsserver", "typescript")) .shim("tsc") .shim("tsserver") .package_image("typescript", "1.4.0", None) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global remove cowsay typescript"), execs() .with_status(0) .with_stdout_contains("[..]using Volta to uninstall cowsay") .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") .with_stdout_contains("[..]using Volta to uninstall typescript") .with_stdout_contains("Removed executable 'tsc' installed by 'typescript'") .with_stdout_contains("Removed executable 'tsserver' installed by 'typescript'") .with_stdout_contains("[..]package 'typescript' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); assert!(!Sandbox::package_config_exists("typescript")); assert!(!Sandbox::bin_config_exists("tsc")); assert!(!Sandbox::bin_config_exists("tsserver")); assert!(!Sandbox::shim_exists("tsc")); assert!(!Sandbox::shim_exists("tsserver")); assert!(!Sandbox::package_image_exists("typescript")); } #[test] fn yarn_remove_without_packages_skips_volta_logic() { let s = sandbox() .platform(&platform_with_node_yarn("10.99.1040", "1.2.42")) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.yarn("global remove"), execs() .with_status(0) .with_stdout_does_not_contain("[..]Volta is processing each package separately") ); } ================================================ FILE: tests/acceptance/execute_binary.rs ================================================ use std::path::PathBuf; use crate::support::sandbox::{sandbox, PackageBinInfo}; use cfg_if::cfg_if; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; const PKG_CONFIG_BASIC: &str = r#"{ "name": "cowsay", "version": "1.4.0", "platform": { "node": "11.10.1", "npm": "6.7.0", "yarn": null }, "bins": [ "cowsay", "cowthink" ], "manager": "Npm" }"#; fn node_bin(version: &str) -> String { cfg_if! { if #[cfg(target_os = "windows")] { format!( r#"@echo off echo Node version {} echo node args: %* "#, version ) } else { format!( r#"#!/bin/sh echo "Node version {}" echo "node args: $@" "#, version ) } } } fn npm_bin(version: &str) -> String { cfg_if! { if #[cfg(target_os = "windows")] { format!( r#"@echo off echo Npm version {} echo npm args: %* "#, version ) } else { format!( r#"#!/bin/sh echo "Npm version {}" echo "npm args: $@" "#, version ) } } } fn pnpm_bin(version: &str) -> String { cfg_if! { if #[cfg(target_os = "windows")] { format!( r#"@echo off echo pnpm version {} echo pnpm args: %* "#, version ) } else { format!( r#"#!/bin/sh echo "pnpm version {}" echo "pnpm args: $@" "#, version ) } } } fn yarn_bin(version: &str) -> String { cfg_if! { if #[cfg(target_os = "windows")] { format!( r#"@echo off echo Yarn version {} echo yarn args: %* "#, version ) } else { format!( r#"#!/bin/sh echo "Yarn version {}" echo "yarn args: $@" "#, version ) } } } fn cowsay_bin(name: &str, version: &str) -> String { cfg_if! { if #[cfg(target_os = "windows")] { format!( r#"@echo off echo {} version {} echo {} args: %* "#, name, version, name ) } else { format!( r#"#!/bin/sh echo "{} version {}" echo "{} args: $@" "#, name, version, name ) } } } fn cowsay_bin_info(version: &str) -> Vec { vec![ PackageBinInfo { name: "cowsay".to_string(), contents: cowsay_bin("cowsay", version), }, PackageBinInfo { name: "cowthink".to_string(), contents: cowsay_bin("cowthink", version), }, ] } fn bin_config(name: &str) -> String { format!( r#"{{ "name": "{}", "package": "cowsay", "version": "1.4.0", "platform": {{ "node": "11.10.1", "npm": "6.7.0", "yarn": null }}, "manager": "Npm" }}"#, name ) } const PACKAGE_JSON_NPM_NO_DEP: &str = r#"{ "name": "no-deps", "volta": { "node": "10.99.1040" } }"#; const PACKAGE_JSON_NPM_WITH_DEP: &str = r#"{ "name": "with-deps", "dependencies": { "cowsay": "1.5.0" }, "volta": { "node": "10.99.1040" } }"#; const PACKAGE_JSON_YARN_PNP_WITH_DEP: &str = r#"{ "name": "with-deps", "dependencies": { "cowsay": "1.5.0" }, "volta": { "node": "10.99.1040", "yarn": "3.12.1092" } }"#; const PLATFORM_NODE_NPM: &str = r#"{ "node":{ "runtime":"11.10.1", "npm":"6.7.0" } }"#; #[test] fn default_binary_no_project() { // platform node is 11.10.1, npm is 6.7.0 // package cowsay is 1.4.0, installed with platform node // default yarn is 1.23.483 // default pnpm is 7.7.1 // there is no local project, so it should run the default bin let s = sandbox() .platform(PLATFORM_NODE_NPM) .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", Some(cowsay_bin_info("1.4.0"))) .setup_node_binary("11.10.1", "6.7.0", &node_bin("11.10.1")) .setup_npm_binary("6.7.0", &npm_bin("6.7.0")) .setup_yarn_binary("1.23.483", &yarn_bin("1.23.483")) .setup_pnpm_binary("7.7.1", &pnpm_bin("7.7.1")) .add_dir_to_path(PathBuf::from("/bin")) .build(); // control should be passed directly to the bin assert_that!( s.exec_shim("cowsay", "foo"), execs() .with_status(0) .with_stdout_contains("cowsay version 1.4.0") .with_stdout_contains("cowsay args: foo") .with_stdout_does_not_contain("Node version") .with_stdout_does_not_contain("Npm version") .with_stdout_does_not_contain("Yarn version") .with_stdout_does_not_contain("pnpm version") ); } #[test] fn default_binary_no_project_dep() { // platform node is 11.10.1, npm is 6.7.0 // package cowsay is 1.4.0, installed with platform node // default yarn is 1.23.483 // default pnpm is 7.7.1 // local project does not have cowsay dep, so it should run the default bin let s = sandbox() .platform(PLATFORM_NODE_NPM) .package_json(PACKAGE_JSON_NPM_NO_DEP) .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", Some(cowsay_bin_info("1.4.0"))) .setup_node_binary("11.10.1", "6.7.0", &node_bin("11.10.1")) .setup_npm_binary("6.7.0", &npm_bin("6.7.0")) .setup_yarn_binary("1.23.483", &yarn_bin("1.23.483")) .setup_pnpm_binary("7.7.1", &pnpm_bin("7.7.1")) .add_dir_to_path(PathBuf::from("/bin")) .build(); assert_that!( s.exec_shim("cowsay", "foo"), execs() .with_status(0) .with_stdout_contains("cowsay version 1.4.0") .with_stdout_contains("cowsay args: foo") .with_stdout_does_not_contain("Node version") .with_stdout_does_not_contain("Npm version") .with_stdout_does_not_contain("Yarn version") .with_stdout_does_not_contain("pnpm version") ); } #[test] fn project_local_binary() { // platform node is 11.10.1, npm is 6.7.0 // package cowsay is 1.4.0, installed with platform node // default yarn is 1.23.483 // default pnpm is 7.7.1 // local project has cowsay as a dep, so it should run that binary let s = sandbox() .platform(PLATFORM_NODE_NPM) .package_json(PACKAGE_JSON_NPM_WITH_DEP) .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", Some(cowsay_bin_info("1.4.0"))) .setup_node_binary("11.10.1", "6.7.0", &node_bin("11.10.1")) .setup_node_binary("10.99.1040", "6.7.0", &node_bin("10.99.1040")) .setup_npm_binary("6.7.0", &npm_bin("6.7.0")) .setup_yarn_binary("1.23.483", &yarn_bin("1.23.483")) .setup_pnpm_binary("7.7.1", &pnpm_bin("7.7.1")) .project_bins(cowsay_bin_info("1.5.0")) .add_dir_to_path(PathBuf::from("/bin")) .build(); // control should be passed directly to the local bin assert_that!( s.exec_shim("cowsay", "bar"), execs() .with_status(0) .with_stdout_contains("cowsay version 1.5.0") .with_stdout_contains("cowsay args: bar") .with_stdout_does_not_contain("Node version") .with_stdout_does_not_contain("Npm version") .with_stdout_does_not_contain("Yarn version") .with_stdout_does_not_contain("pnpm version") ); } #[test] fn project_local_binary_pnp() { // platform node is 11.10.1, npm is 6.7.0 // package cowsay is 1.4.0, installed with platform node // default yarn is 1.23.483 // project is Yarn PnP, with cowsay as a dep, so it should run 'yarn cowsay' let s = sandbox() .platform(PLATFORM_NODE_NPM) .package_json(PACKAGE_JSON_YARN_PNP_WITH_DEP) .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", Some(cowsay_bin_info("1.4.0"))) .setup_node_binary("11.10.1", "6.7.0", &node_bin("11.10.1")) .setup_node_binary("10.99.1040", "6.7.0", &node_bin("10.99.1040")) .setup_npm_binary("6.7.0", &npm_bin("6.7.0")) .setup_yarn_binary("1.23.483", &yarn_bin("1.23.483")) .setup_yarn_binary("3.12.1092", &yarn_bin("3.12.1092")) .project_pnp() .add_dir_to_path(PathBuf::from("/bin")) .build(); // this should run 'yarn cowsay' to execute the binary assert_that!( s.exec_shim("cowsay", "baz"), execs() .with_status(0) .with_stdout_contains("Yarn version 3.12.1092") .with_stdout_contains("yarn args: cowsay baz") .with_stdout_does_not_contain("cowsay version") .with_stdout_does_not_contain("cowsay args") .with_stdout_does_not_contain("Node version") .with_stdout_does_not_contain("Npm version") .with_stdout_does_not_contain("Yarn version 1.23.483") ); } ================================================ FILE: tests/acceptance/hooks.rs ================================================ use std::path::PathBuf; use std::{thread, time}; use crate::support::events_helpers::{ assert_events, match_args, match_end, match_error, match_start, }; use crate::support::sandbox::sandbox; use hamcrest2::assert_that; use hamcrest2::prelude::*; use mockito::mock; use test_support::matchers::execs; use volta_core::error::ExitCode; const WORKSPACE_PACKAGE_JSON: &str = r#" { "volta": { "node": "10.11.12" } }"#; const PROJECT_PACKAGE_JSON: &str = r#" { "volta": { "extends": "./workspace/package.json" } }"#; // scripts that write events to file 'events.json' cfg_if::cfg_if! { if #[cfg(windows)] { // have not been able to read events from stdin with batch, powershell, etc. // so just copy the tempfile (path in EVENTS_FILE env var) to events.json const EVENTS_EXECUTABLE: &str = r#"@echo off copy %EVENTS_FILE% events.json :: executables should clean up the temp file del %EVENTS_FILE% "#; const SCRIPT_FILENAME: &str = "write-events.bat"; const VOLTA_BINARY: &str = "volta.exe"; } else if #[cfg(unix)] { // read events from stdin const EVENTS_EXECUTABLE: &str = r#"#!/bin/bash # read Volta events from stdin, and write to events.json # (but first clear it out) echo -n "" >events.json while read line do echo "$line" >>events.json done # executables should clean up the temp file /bin/rm "$EVENTS_FILE" "#; const SCRIPT_FILENAME: &str = "write-events.sh"; const VOLTA_BINARY: &str = "volta"; } else { compile_error!("Unsupported platform for tests (expected 'unix' or 'windows')."); } } fn default_hooks_json() -> String { format!( r#" {{ "node": {{ "distro": {{ "template": "{}/hook/default/node/{{{{version}}}}" }} }}, "npm": {{ "distro": {{ "template": "{0}/hook/default/npm/{{{{version}}}}" }} }}, "yarn": {{ "distro": {{ "template": "{0}/hook/default/yarn/{{{{version}}}}" }} }}, "events": {{ "publish": {{ "bin": "{}" }} }} }}"#, mockito::server_url(), SCRIPT_FILENAME ) } fn project_hooks_json() -> String { format!( r#" {{ "yarn": {{ "distro": {{ "template": "{0}/hook/project/yarn/{{{{version}}}}" }} }} }}"#, mockito::server_url() ) } fn workspace_hooks_json() -> String { format!( r#" {{ "npm": {{ "distro": {{ "template": "{0}/hook/workspace/npm/{{{{version}}}}" }} }}, "yarn": {{ "distro": {{ "template": "{0}/hook/workspace/yarn/{{{{version}}}}" }} }} }}"#, mockito::server_url() ) } fn pnpm_hooks_json() -> String { format!( r#" {{ "pnpm": {{ "index": {{ "template": "{0}/pnpm/index" }}, "distro": {{ "template": "{0}/pnpm/{{{{version}}}}" }} }} }}"#, mockito::server_url() ) } fn yarn_hooks_json() -> String { format!( r#" {{ "yarn": {{ "latest": {{ "template": "{0}/yarn-old/latest" }}, "index": {{ "template": "{0}/yarn-old/index" }} }} }}"#, mockito::server_url() ) } fn yarn_hooks_format_json(format: &str) -> String { format!( r#" {{ "yarn": {{ "latest": {{ "template": "{0}/yarn-new/latest" }}, "index": {{ "template": "{0}/yarn-new/index", "format": "{1}" }} }} }}"#, mockito::server_url(), format ) } #[test] fn redirects_download() { let s = sandbox() .default_hooks(&default_hooks_json()) .env("VOLTA_WRITE_EVENTS_FILE", "true") .executable_file(SCRIPT_FILENAME, EVENTS_EXECUTABLE) .build(); assert_that!( s.volta("install node@1.2.3"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download node@1.2.3") .with_stderr_contains("[..]/hook/default/node/1.2.3") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("volta", match_start()), ("install", match_start()), ("volta", match_error(5, "Could not download node")), ("volta", match_end(5)), ( "args", match_args(format!("{} install node@1.2.3", VOLTA_BINARY).as_str()), ), ], ); } #[test] fn merges_project_and_default_hooks() { let local_hooks: PathBuf = [".volta", "hooks.json"].iter().collect(); let s = sandbox() .package_json("{}") .default_hooks(&default_hooks_json()) .project_file(&local_hooks.to_string_lossy(), &project_hooks_json()) .env("VOLTA_WRITE_EVENTS_FILE", "true") .executable_file(SCRIPT_FILENAME, EVENTS_EXECUTABLE) .build(); // Project defines yarn hooks, so those should be used assert_that!( s.volta("install yarn@3.2.1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download yarn@3.2.1") .with_stderr_contains("[..]/hook/project/yarn/3.2.1") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("volta", match_start()), ("install", match_start()), ("volta", match_error(5, "Could not download yarn")), ("volta", match_end(5)), ( "args", match_args(format!("{} install yarn@3.2.1", VOLTA_BINARY).as_str()), ), ], ); // Project doesn't define node hooks, so should inherit from the default assert_that!( s.volta("install node@10.12.1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download node@10.12.1") .with_stderr_contains("[..]/hook/default/node/10.12.1") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("volta", match_start()), ("install", match_start()), ("volta", match_error(5, "Could not download node")), ("volta", match_end(5)), ( "args", match_args(format!("{} install node@10.12.1", VOLTA_BINARY).as_str()), ), ], ); } #[test] fn merges_workspace_hooks() { let workspace_hooks: PathBuf = ["workspace", ".volta", "hooks.json"].iter().collect(); let workspace_package_json: PathBuf = ["workspace", "package.json"].iter().collect(); let project_hooks: PathBuf = [".volta", "hooks.json"].iter().collect(); let s = sandbox() .default_hooks(&default_hooks_json()) .package_json(PROJECT_PACKAGE_JSON) .project_file(&project_hooks.to_string_lossy(), &project_hooks_json()) .project_file( &workspace_package_json.to_string_lossy(), WORKSPACE_PACKAGE_JSON, ) .project_file(&workspace_hooks.to_string_lossy(), &workspace_hooks_json()) .env("VOLTA_WRITE_EVENTS_FILE", "true") .executable_file(SCRIPT_FILENAME, EVENTS_EXECUTABLE) .build(); // Project defines yarn hooks, so those should be used assert_that!( s.volta("pin yarn@3.1.4"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download yarn@3.1.4") .with_stderr_contains("[..]/hook/project/yarn/3.1.4") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("volta", match_start()), ("pin", match_start()), ("volta", match_error(5, "Could not download yarn")), ("volta", match_end(5)), ( "args", match_args(format!("{} pin yarn@3.1.4", VOLTA_BINARY).as_str()), ), ], ); // Workspace defines npm hooks, so those should be inherited assert_that!( s.volta("pin npm@5.6.7"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download npm@5.6.7") .with_stderr_contains("[..]/hook/workspace/npm/5.6.7") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("volta", match_start()), ("pin", match_start()), ("volta", match_error(5, "Could not download npm")), ("volta", match_end(5)), ( "args", match_args(format!("{} pin npm@5.6.7", VOLTA_BINARY).as_str()), ), ], ); // Neither project nor workspace defines node hooks, so should inherit from the default assert_that!( s.volta("install node@11.11.2"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download node@11.11.2") .with_stderr_contains("[..]/hook/default/node/11.11.2") ); } #[test] fn pnpm_latest_with_hook_reads_index() { let s = sandbox() .default_hooks(&pnpm_hooks_json()) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); let _mock = mock("GET", "/pnpm/index") .with_status(200) .with_header("Content-Type", "application/json") .with_body( // Npm format for pnpm r#"{ "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "6.34.0": { "version":"6.34.0", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } }"#, ) .create(); assert_that!( s.volta("install pnpm@latest"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using pnpm.index hook to determine pnpm index URL") .with_stderr_contains("[..]Found pnpm@7.7.1 matching tag 'latest'[..]") .with_stderr_contains("[..]Downloading pnpm@7.7.1 from[..]/pnpm/7.7.1[..]") .with_stderr_contains("[..]Could not download pnpm@7.7.1") ); } #[test] fn pnpm_no_version_with_hook_reads_index() { let s = sandbox() .default_hooks(&pnpm_hooks_json()) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); let _mock = mock("GET", "/pnpm/index") .with_status(200) .with_header("Content-Type", "application/json") .with_body( // Npm format for pnpm r#"{ "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "6.34.0": { "version":"6.34.0", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } }"#, ) .create(); assert_that!( s.volta("install pnpm"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using pnpm.index hook to determine pnpm index URL") .with_stderr_contains("[..]Found pnpm@7.7.1 matching tag 'latest'[..]") .with_stderr_contains("[..]Downloading pnpm@7.7.1 from[..]/pnpm/7.7.1[..]") .with_stderr_contains("[..]Could not download pnpm@7.7.1") ); } #[test] fn yarn_latest_with_hook_reads_latest() { let s = sandbox() .default_hooks(&yarn_hooks_json()) .env("VOLTA_LOGLEVEL", "debug") .build(); let _mock = mock("GET", "/yarn-old/latest") .with_status(200) .with_body("4.2.9") .create(); assert_that!( s.volta("install yarn@latest"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using yarn.latest hook to determine latest-version URL") .with_stderr_contains("[..]Found yarn latest version (4.2.9)[..]") .with_stderr_contains("[..]Could not download yarn@4.2.9") ); } #[test] fn yarn_no_version_with_hook_reads_latest() { let s = sandbox() .default_hooks(&yarn_hooks_json()) .env("VOLTA_LOGLEVEL", "debug") .build(); let _mock = mock("GET", "/yarn-old/latest") .with_status(200) .with_body("4.2.9") .create(); assert_that!( s.volta("install yarn"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using yarn.latest hook to determine latest-version URL") .with_stderr_contains("[..]Found yarn latest version (4.2.9)[..]") .with_stderr_contains("[..]Could not download yarn@4.2.9") ); } #[test] fn yarn_semver_with_hook_uses_old_format() { let s = sandbox() .default_hooks(&yarn_hooks_json()) .env("VOLTA_LOGLEVEL", "debug") .build(); let _mock = mock("GET", "/yarn-old/index") .with_status(200) .with_header("Content-Type", "application/json") .with_body( // Yarn Index hook expects the "old" (Github API) format r#"[ {"tag_name":"v1.22.4","assets":[{"name":"yarn-v1.22.4.tar.gz"}]}, {"tag_name":"v2.0.0","assets":[{"name":"yarn-v2.0.0.tar.gz"}]}, {"tag_name":"v3.9.2","assets":[{"name":"yarn-v3.9.2.tar.gz"}]}, {"tag_name":"v4.1.1","assets":[{"name":"yarn-v4.1.1.tar.gz"}]} ]"#, ) .create(); assert_that!( s.volta("install yarn@3"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using yarn.index hook to determine yarn index URL") .with_stderr_contains("[..]Found yarn@3.9.2 matching requirement[..]") .with_stderr_contains("[..]Could not download yarn@3.9.2") ); } #[test] fn yarn_semver_with_hook_uses_configured_format() { let s = sandbox() .default_hooks(&yarn_hooks_format_json("npm")) .env("VOLTA_LOGLEVEL", "debug") .build(); let _mock = mock("GET", "/yarn-new/index") .with_status(200) .with_header("Content-Type", "application/json") .with_body( // Should be using the Npm format r#"{ "name":"@yarnpkg/cli-dist", "dist-tags": { "latest":"3.12.99" }, "versions": { "2.4.159": { "version":"2.4.159", "dist": { "shasum":"", "tarball":"" }}, "3.2.42": { "version":"3.2.42", "dist": { "shasum":"", "tarball":"" }}, "3.7.71": { "version":"3.7.71", "dist": { "shasum":"", "tarball":"" }}, "3.12.99": { "version":"3.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#, ) .create(); assert_that!( s.volta("install yarn@3"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Using yarn.index hook to determine yarn index URL") .with_stderr_contains("[..]Found yarn@3.12.99 matching requirement[..]") .with_stderr_contains("[..]Could not download yarn@3.12.99") ); } ================================================ FILE: tests/acceptance/main.rs ================================================ use cfg_if::cfg_if; cfg_if! { if #[cfg(feature = "mock-network")] { mod support; // test files mod corrupted_download; mod direct_install; mod direct_uninstall; mod execute_binary; mod hooks; mod merged_platform; mod migrations; mod run_shim_directly; mod verbose_errors; mod volta_bypass; mod volta_install; mod volta_pin; mod volta_run; mod volta_uninstall; } } ================================================ FILE: tests/acceptance/merged_platform.rs ================================================ use std::{thread, time}; use crate::support::events_helpers::{assert_events, match_args, match_start, match_tool_end}; use crate::support::sandbox::{ sandbox, DistroMetadata, NodeFixture, NpmFixture, PnpmFixture, Yarn1Fixture, }; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; const PACKAGE_JSON_NODE_ONLY: &str = r#"{ "name": "node-only", "volta": { "node": "10.99.1040" } }"#; const PACKAGE_JSON_WITH_NPM: &str = r#"{ "name": "with-npm", "volta": { "node": "10.99.1040", "npm": "4.5.6" } }"#; const PACKAGE_JSON_WITH_PNPM: &str = r#"{ "name": "with-pnpm", "volta": { "node": "10.99.1040", "pnpm": "7.7.1" } }"#; const PACKAGE_JSON_WITH_YARN: &str = r#"{ "name": "with-yarn", "volta": { "node": "10.99.1040", "yarn": "1.12.99" } }"#; const PLATFORM_NODE_ONLY: &str = r#"{ "node":{ "runtime":"9.27.6", "npm":null } }"#; const PLATFORM_WITH_NPM: &str = r#"{ "node":{ "runtime":"9.27.6", "npm":"1.2.3" } }"#; const PLATFORM_WITH_PNPM: &str = r#"{ "node":{ "runtime":"9.27.6", "npm":null }, "pnpm": "7.7.1" }"#; const PLATFORM_WITH_YARN: &str = r#"{ "node":{ "runtime":"9.27.6", "npm":null }, "yarn": "1.7.71" }"#; cfg_if::cfg_if! { if #[cfg(windows)] { // copy the tempfile (path in EVENTS_FILE env var) to events.json const EVENTS_EXECUTABLE: &str = r#"@echo off copy %EVENTS_FILE% events.json :: executables should clean up the temp file del %EVENTS_FILE% "#; const SCRIPT_FILENAME: &str = "write-events.bat"; const PNPM_SHIM: &str = "pnpm.exe"; const YARN_SHIM: &str = "yarn.exe"; } else if #[cfg(unix)] { // copy the tempfile (path in EVENTS_FILE env var) to events.json const EVENTS_EXECUTABLE: &str = r#"#!/bin/bash /bin/cp "$EVENTS_FILE" events.json # executables should clean up the temp file /bin/rm "$EVENTS_FILE" "#; const SCRIPT_FILENAME: &str = "write-events.sh"; const PNPM_SHIM: &str = "pnpm"; const YARN_SHIM: &str = "yarn"; } else { compile_error!("Unsupported platform for tests (expected 'unix' or 'windows')."); } } fn events_hooks_json() -> String { format!( r#" {{ "events": {{ "publish": {{ "bin": "{}" }} }} }}"#, SCRIPT_FILENAME ) } cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, DistroMetadata { version: "9.27.6", compressed_size: 1068, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const NPM_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "1.2.3", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "4.5.6", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, ]; const PNPM_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "6.34.0", compressed_size: 500, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "7.7.1", compressed_size: 518, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "1.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, ]; #[test] fn uses_project_npm_if_available() { let s = sandbox() .platform(PLATFORM_WITH_NPM) .package_json(PACKAGE_JSON_WITH_NPM) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.npm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 10.99.1040 from project configuration") .with_stderr_contains("[..]npm: 4.5.6 from project configuration") ); } #[test] fn uses_bundled_npm_in_project_without_npm() { let s = sandbox() .platform(PLATFORM_WITH_NPM) .package_json(PACKAGE_JSON_NODE_ONLY) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.npm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 10.99.1040 from project configuration") .with_stderr_contains("[..]npm: 6.2.26 from project configuration") ); } #[test] fn uses_default_npm_outside_project() { let s = sandbox() .platform(PLATFORM_WITH_NPM) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.npm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 9.27.6 from default configuration") .with_stderr_contains("[..]npm: 1.2.3 from default configuration") ); } #[test] fn uses_bundled_npm_outside_project() { let s = sandbox() .platform(PLATFORM_NODE_ONLY) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&NPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.npm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 9.27.6 from default configuration") .with_stderr_contains("[..]npm: 5.6.17 from default configuration") ); } #[test] fn uses_project_yarn_if_available() { let s = sandbox() .platform(PLATFORM_WITH_YARN) .package_json(PACKAGE_JSON_WITH_YARN) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_WRITE_EVENTS_FILE", "true") .default_hooks(&events_hooks_json()) .executable_file(SCRIPT_FILENAME, EVENTS_EXECUTABLE) .build(); assert_that!( s.yarn("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]Yarn is not available.") .with_stderr_does_not_contain("[..]No Yarn version found in this project.") .with_stderr_contains("[..]Yarn: 1.12.99 from project configuration") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("tool", match_start()), ("yarn", match_start()), ("tool", match_tool_end(0)), ( "args", match_args(format!("{} --version", YARN_SHIM).as_str()), ), ], ); } #[test] fn uses_default_yarn_in_project_without_yarn() { let s = sandbox() .platform(PLATFORM_WITH_YARN) .package_json(PACKAGE_JSON_NODE_ONLY) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.yarn("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]Yarn is not available.") .with_stderr_does_not_contain("[..]No Yarn version found in this project.") .with_stderr_contains("[..]Yarn: 1.7.71 from default configuration") ); } #[test] fn uses_default_yarn_outside_project() { let s = sandbox() .platform(PLATFORM_WITH_YARN) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .build(); assert_that!( s.yarn("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]Yarn is not available.") .with_stderr_does_not_contain("[..]No Yarn version found in this project.") .with_stderr_contains("[..]Yarn: 1.7.71 from default configuration") ); } #[test] fn throws_project_error_in_project() { let s = sandbox() .platform(PLATFORM_NODE_ONLY) .package_json(PACKAGE_JSON_NODE_ONLY) .build(); assert_that!( s.yarn("--version"), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]No Yarn version found in this project.") ); } #[test] fn throws_default_error_outside_project() { let s = sandbox().platform(PLATFORM_NODE_ONLY).build(); assert_that!( s.yarn("--version"), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]Yarn is not available.") ); } #[test] fn uses_project_pnpm_if_available() { let s = sandbox() .platform(PLATFORM_WITH_PNPM) .package_json(PACKAGE_JSON_WITH_PNPM) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_WRITE_EVENTS_FILE", "true") .env("VOLTA_FEATURE_PNPM", "1") .default_hooks(&events_hooks_json()) .executable_file(SCRIPT_FILENAME, EVENTS_EXECUTABLE) .build(); assert_that!( s.pnpm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]pnpm is not available.") .with_stderr_does_not_contain("[..]No pnpm version found in this project.") .with_stderr_contains("[..]pnpm: 7.7.1 from project configuration") ); thread::sleep(time::Duration::from_millis(500)); assert_events( &s, vec![ ("tool", match_start()), ("pnpm", match_start()), ("tool", match_tool_end(0)), ( "args", match_args(format!("{} --version", PNPM_SHIM).as_str()), ), ], ); } #[test] fn uses_default_pnpm_in_project_without_pnpm() { let s = sandbox() .platform(PLATFORM_WITH_PNPM) .package_json(PACKAGE_JSON_NODE_ONLY) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.pnpm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]pnpm is not available.") .with_stderr_does_not_contain("[..]No pnpm version found in this project.") .with_stderr_contains("[..]pnpm: 7.7.1 from default configuration") ); } #[test] fn uses_default_pnpm_outside_project() { let s = sandbox() .platform(PLATFORM_WITH_PNPM) .distro_mocks::(&NODE_VERSION_FIXTURES) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.pnpm("--version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_does_not_contain("[..]pnpm is not available.") .with_stderr_does_not_contain("[..]No pnpm version found in this project.") .with_stderr_contains("[..]pnpm: 7.7.1 from default configuration") ); } #[test] fn uses_pnpm_throws_project_error_in_project() { let s = sandbox() .platform(PLATFORM_NODE_ONLY) .package_json(PACKAGE_JSON_NODE_ONLY) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.pnpm("--version"), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]No pnpm version found in this project.") ); } ================================================ FILE: tests/acceptance/migrations.rs ================================================ use crate::support::sandbox::{sandbox, Sandbox}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; #[test] fn empty_volta_home_is_created() { let s = sandbox().build(); // clear out the .volta dir s.remove_volta_home(); // VOLTA_HOME starts out non-existent, with no shims assert!(!Sandbox::path_exists(".volta")); assert!(!Sandbox::shim_exists("node")); // running volta triggers automatic creation assert_that!(s.volta("--version"), execs().with_status(0)); // home directories should all be created assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/bin")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/log")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/image/node")); assert!(Sandbox::path_exists(".volta/tools/image/yarn")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); assert!(Sandbox::path_exists(".volta/tools/user")); // Layout file should now exist assert!(Sandbox::path_exists(".volta/layout.v4")); // shims should all be created // NOTE: this doesn't work in Windows, because the default shims are stored separately #[cfg(unix)] { assert!(Sandbox::shim_exists("node")); assert!(Sandbox::shim_exists("yarn")); assert!(Sandbox::shim_exists("npm")); assert!(Sandbox::shim_exists("npx")); } } #[test] fn legacy_v0_volta_home_is_upgraded() { let s = sandbox().build(); // directories that are already created by the test framework assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(Sandbox::path_exists(".volta/tools/inventory/packages")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); // Layout file is not there assert!(!Sandbox::path_exists(".volta/layout.v1")); assert!(!Sandbox::path_exists(".volta/layout.v2")); assert!(!Sandbox::path_exists(".volta/layout.v3")); // running volta should not create anything else assert_that!(s.volta("--version"), execs().with_status(0)); // Layout should be updated to the most recent assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(!Sandbox::path_exists(".volta/tools/inventory/packages")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); // Most recent layout file should exist, others should not assert!(!Sandbox::path_exists(".volta/layout.v1")); assert!(!Sandbox::path_exists(".volta/layout.v2")); assert!(!Sandbox::path_exists(".volta/layout.v3")); assert!(Sandbox::path_exists(".volta/layout.v4")); // shims should all be created // NOTE: this doesn't work in Windows, because the default shims are stored separately #[cfg(unix)] { assert!(Sandbox::shim_exists("node")); assert!(Sandbox::shim_exists("yarn")); assert!(Sandbox::shim_exists("npm")); assert!(Sandbox::shim_exists("npx")); } } #[test] fn tagged_v1_volta_home_is_upgraded() { let s = sandbox() .layout_file("v1") .file( ".volta/tools/image/node/10.6.0/6.1.0/README.md", "Irrelevant Contents", ) .node_npm_version_file("10.6.0", "6.1.0") .platform( r#"{ "node": { "runtime": "10.6.0", "npm": "6.1.0" }, "yarn": null }"#, ) .build(); // We are already tagged as a v1 layout assert!(Sandbox::path_exists(".volta/layout.v1")); // Node image directory exists assert!(Sandbox::path_exists( ".volta/tools/image/node/10.6.0/6.1.0/README.md" )); assert!(Sandbox::path_exists( ".volta/tools/inventory/node/node-v10.6.0-npm" )); // Default platform includes npm version assert!(Sandbox::read_default_platform().contains(r#""npm": "6.1.0""#)); // running volta should run the migration assert_that!(s.volta("--version"), execs().with_status(0)); // Default platform should not include an npm version assert!(Sandbox::read_default_platform().contains(r#""npm": null"#)); // Node image directory should be moved up and no longer contain the npm version assert!(Sandbox::path_exists( ".volta/tools/image/node/10.6.0/README.md" )); // Directory structure should exist assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); // Most recent layout file should exist, others should not assert!(!Sandbox::path_exists(".volta/layout.v1")); assert!(!Sandbox::path_exists(".volta/layout.v2")); assert!(!Sandbox::path_exists(".volta/layout.v3")); assert!(Sandbox::path_exists(".volta/layout.v4")); // shims should all be created // NOTE: this doesn't work in Windows, because the default shims are stored separately #[cfg(unix)] { assert!(Sandbox::shim_exists("node")); assert!(Sandbox::shim_exists("yarn")); assert!(Sandbox::shim_exists("npm")); assert!(Sandbox::shim_exists("npx")); } } #[test] fn tagged_v1_to_v2_keeps_custom_npm() { let s = sandbox() .layout_file("v1") .node_npm_version_file("10.6.0", "6.1.0") .platform( r#"{ "node": { "runtime": "10.6.0", "npm": "6.3.0" }, "yarn": null }"#, ) .build(); // Default platform includes npm version assert!(Sandbox::read_default_platform().contains(r#""npm": "6.3.0""#)); // running volta should run the migration assert_that!(s.volta("--version"), execs().with_status(0)); // Default platform still includes custom npm version assert!(Sandbox::read_default_platform().contains(r#""npm": "6.3.0""#)); } #[test] fn tagged_v1_to_v2_keeps_migrated_node_images() { let s = sandbox() .layout_file("v1") .file( ".volta/tools/image/node/10.6.0/README.md", "Irrelevant Contents", ) .node_npm_version_file("10.6.0", "6.1.0") .build(); // Migrated Node image directory exists assert!(Sandbox::path_exists( ".volta/tools/image/node/10.6.0/README.md" )); // running volta should run the migration assert_that!(s.volta("--version"), execs().with_status(0)); // Migrated Node image directory is unchanged assert!(Sandbox::path_exists( ".volta/tools/image/node/10.6.0/README.md" )); } #[test] fn current_v4_volta_home_is_unchanged() { let s = sandbox().layout_file("v4").build(); // directories that are already created by the test framework assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/layout.v4")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); // running volta should not create anything else assert_that!(s.volta("--version"), execs().with_status(0)); // everything should be the same as before running the command assert!(Sandbox::path_exists(".volta")); assert!(Sandbox::path_exists(".volta/layout.v4")); assert!(Sandbox::path_exists(".volta/cache/node")); assert!(Sandbox::path_exists(".volta/tmp")); assert!(Sandbox::path_exists(".volta/tools/inventory/node")); assert!(Sandbox::path_exists(".volta/tools/inventory/yarn")); } ================================================ FILE: tests/acceptance/run_shim_directly.rs ================================================ use crate::support::sandbox::{sandbox, shim_exe}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; #[test] fn shows_pretty_error_when_calling_shim_directly() { let s = sandbox().build(); assert_that!( s.process(shim_exe()), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]should not be called directly[..]") ); } ================================================ FILE: tests/acceptance/support/events_helpers.rs ================================================ use std::fs::File; use crate::support::sandbox::Sandbox; use hamcrest2::assert_that; use hamcrest2::prelude::*; use volta_core::event::{Event, EventKind}; pub enum EventKindMatcher<'a> { Start, End { exit_code: i32 }, Error { exit_code: i32, error: &'a str }, ToolEnd { exit_code: i32 }, Args { argv: &'a str }, } pub fn match_start() -> EventKindMatcher<'static> { EventKindMatcher::Start } pub fn match_error(exit_code: i32, error: &str) -> EventKindMatcher { EventKindMatcher::Error { exit_code, error } } pub fn match_end(exit_code: i32) -> EventKindMatcher<'static> { EventKindMatcher::End { exit_code } } pub fn match_tool_end(exit_code: i32) -> EventKindMatcher<'static> { EventKindMatcher::ToolEnd { exit_code } } pub fn match_args(argv: &str) -> EventKindMatcher { EventKindMatcher::Args { argv } } pub fn assert_events(sandbox: &Sandbox, matchers: Vec<(&str, EventKindMatcher)>) { let events_path = sandbox.root().join("events.json"); assert_that!(&events_path, file_exists()); let events_file = File::open(events_path).expect("Error reading 'events.json' file in sandbox"); let events: Vec = serde_json::de::from_reader(events_file) .expect("Error parsing 'events.json' file in sandbox"); assert_that!(events.len(), eq(matchers.len())); for (i, matcher) in matchers.iter().enumerate() { assert_that!(&events[i].name, eq(matcher.0)); match matcher.1 { EventKindMatcher::Start => { assert_that!(&events[i].event, eq(&EventKind::Start)); } EventKindMatcher::End { exit_code: expected_exit_code, } => { if let EventKind::End { exit_code } = &events[i].event { assert_that!(*exit_code, eq(expected_exit_code)); } else { panic!( "Expected: End {{ exit_code: {} }}, Got: {:?}", expected_exit_code, events[i].event ); } } EventKindMatcher::Error { exit_code: expected_exit_code, error: expected_error, } => { if let EventKind::Error { exit_code, error, .. } = &events[i].event { assert_that!(*exit_code, eq(expected_exit_code)); assert_that!(error.clone(), matches_regex(expected_error)); } else { panic!( "Expected: Error {{ exit_code: {}, error: {} }}, Got: {:?}", expected_exit_code, expected_error, events[i].event ); } } EventKindMatcher::ToolEnd { exit_code: expected_exit_code, } => { if let EventKind::End { exit_code } = &events[i].event { assert_that!(*exit_code, eq(expected_exit_code)); } else { panic!( "Expected: ToolEnd {{ exit_code: {} }}, Got: {:?}", expected_exit_code, events[i].event ); } } EventKindMatcher::Args { argv: expected_argv, } => { if let EventKind::Args { argv } = &events[i].event { assert_that!(argv.clone(), matches_regex(expected_argv)); } else { panic!( "Expected: Args {{ argv: {} }}, Got: {:?}", expected_argv, events[i].event ); } } } } } ================================================ FILE: tests/acceptance/support/mod.rs ================================================ pub mod events_helpers; pub mod sandbox; ================================================ FILE: tests/acceptance/support/sandbox.rs ================================================ use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use cfg_if::cfg_if; use headers::{Expires, Header}; use mockito::{self, mock, Matcher}; use node_semver::Version; use test_support::{self, ok_or_panic, paths, paths::PathExt, process::ProcessBuilder}; use volta_core::fs::{set_executable, symlink_file}; use volta_core::tool::{Node, Pnpm, Yarn}; // version cache for node and yarn #[derive(PartialEq, Clone)] struct CacheBuilder { path: PathBuf, expiry_path: PathBuf, contents: String, expired: bool, } impl CacheBuilder { #[allow(dead_code)] pub fn new(path: PathBuf, expiry_path: PathBuf, contents: &str, expired: bool) -> CacheBuilder { CacheBuilder { path, expiry_path, contents: contents.to_string(), expired, } } fn build(&self) { self.dirname().mkdir_p(); // write cache file let mut cache_file = File::create(&self.path).unwrap_or_else(|e| { panic!("could not create cache file {}: {}", self.path.display(), e) }); ok_or_panic! { cache_file.write_all(self.contents.as_bytes()) }; // write expiry file let one_day = Duration::from_secs(24 * 60 * 60); let expiry_date = Expires::from(if self.expired { SystemTime::now() - one_day } else { SystemTime::now() + one_day }); let mut header_values = Vec::with_capacity(1); expiry_date.encode(&mut header_values); // Since we just `.encode()`d into `header_values, it is guaranteed to // have a `.first()`. let encoded_expiry_date = header_values.first().unwrap(); let mut expiry_file = File::create(&self.expiry_path).unwrap_or_else(|e| { panic!( "could not create cache expiry file {}: {}", self.expiry_path.display(), e ) }); ok_or_panic! { expiry_file.write_all(encoded_expiry_date.as_bytes()) }; } fn dirname(&self) -> &Path { self.path.parent().unwrap() } } // environment variables pub struct EnvVar { name: String, value: String, } impl EnvVar { pub fn new(name: &str, value: &str) -> Self { EnvVar { name: name.to_string(), value: value.to_string(), } } } // used to construct sandboxed files like package.json, platform.json, etc. #[derive(PartialEq, Eq, Clone)] pub struct FileBuilder { path: PathBuf, contents: String, executable: bool, } impl FileBuilder { pub fn new(path: PathBuf, contents: &str) -> FileBuilder { FileBuilder { path, contents: contents.to_string(), executable: false, } } pub fn make_executable(mut self) -> Self { self.executable = true; self } pub fn build(&self) { self.dirname().mkdir_p(); let mut file = File::create(&self.path) .unwrap_or_else(|e| panic!("could not create file {}: {}", self.path.display(), e)); ok_or_panic! { file.write_all(self.contents.as_bytes()) }; if self.executable { ok_or_panic! { set_executable(&self.path) }; } } fn dirname(&self) -> &Path { self.path.parent().unwrap() } } struct ShimBuilder { name: String, } impl ShimBuilder { fn new(name: String) -> ShimBuilder { ShimBuilder { name } } fn build(&self) { ok_or_panic! { symlink_file(shim_exe(), shim_file(&self.name)) }; } } // used to setup executable binaries in installed packages pub struct PackageBinInfo { pub name: String, pub contents: String, } #[must_use] pub struct SandboxBuilder { root: Sandbox, files: Vec, caches: Vec, path_dirs: Vec, shims: Vec, has_exec_path: bool, } pub trait DistroFixture: From { fn server_path(&self) -> String; fn fixture_path(&self) -> String; fn metadata(&self) -> &DistroMetadata; } #[derive(Clone)] pub struct DistroMetadata { pub version: &'static str, pub compressed_size: u32, pub uncompressed_size: Option, } pub struct NodeFixture { pub metadata: DistroMetadata, } pub struct NpmFixture { pub metadata: DistroMetadata, } pub struct PnpmFixture { pub metadata: DistroMetadata, } pub struct Yarn1Fixture { pub metadata: DistroMetadata, } pub struct YarnBerryFixture { pub metadata: DistroMetadata, } impl From for NodeFixture { fn from(metadata: DistroMetadata) -> Self { Self { metadata } } } impl From for NpmFixture { fn from(metadata: DistroMetadata) -> Self { Self { metadata } } } impl From for PnpmFixture { fn from(metadata: DistroMetadata) -> Self { Self { metadata } } } impl From for Yarn1Fixture { fn from(metadata: DistroMetadata) -> Self { Self { metadata } } } impl From for YarnBerryFixture { fn from(metadata: DistroMetadata) -> Self { Self { metadata } } } impl DistroFixture for NodeFixture { fn server_path(&self) -> String { let version = Version::parse(self.metadata.version).unwrap(); let filename = Node::archive_filename(&version); format!("/v{version}/{filename}") } fn fixture_path(&self) -> String { let version = Version::parse(self.metadata.version).unwrap(); let filename = Node::archive_filename(&version); format!("tests/fixtures/{filename}") } fn metadata(&self) -> &DistroMetadata { &self.metadata } } impl DistroFixture for NpmFixture { fn server_path(&self) -> String { format!("/npm/-/npm-{}.tgz", self.metadata.version) } fn fixture_path(&self) -> String { format!("tests/fixtures/npm-{}.tgz", self.metadata.version) } fn metadata(&self) -> &DistroMetadata { &self.metadata } } impl DistroFixture for PnpmFixture { fn server_path(&self) -> String { format!("/pnpm/-/pnpm-{}.tgz", self.metadata.version) } fn fixture_path(&self) -> String { format!("tests/fixtures/pnpm-{}.tgz", self.metadata.version) } fn metadata(&self) -> &DistroMetadata { &self.metadata } } impl DistroFixture for Yarn1Fixture { fn server_path(&self) -> String { format!("/yarn/-/yarn-{}.tgz", self.metadata.version) } fn fixture_path(&self) -> String { format!("tests/fixtures/yarn-{}.tgz", self.metadata.version) } fn metadata(&self) -> &DistroMetadata { &self.metadata } } impl DistroFixture for YarnBerryFixture { fn server_path(&self) -> String { format!( "/@yarnpkg/cli-dist/-/cli-dist-{}.tgz", self.metadata.version ) } fn fixture_path(&self) -> String { format!("tests/fixtures/cli-dist-{}.tgz", self.metadata.version) } fn metadata(&self) -> &DistroMetadata { &self.metadata } } impl SandboxBuilder { /// Root of the project, ex: `/path/to/cargo/target/integration_test/t0/foo` pub fn root(&self) -> PathBuf { self.root.root() } pub fn new(root: PathBuf) -> SandboxBuilder { SandboxBuilder { root: Sandbox { root, mocks: vec![], env_vars: vec![], env_vars_remove: vec![], path: OsString::new(), }, files: vec![], caches: vec![], path_dirs: vec![volta_bin_dir()], shims: vec![ ShimBuilder::new("npm".to_string()), ShimBuilder::new("pnpm".to_string()), ShimBuilder::new("yarn".to_string()), ], has_exec_path: false, } } #[allow(dead_code)] /// Set the Node cache for the sandbox (chainable) pub fn node_cache(mut self, cache: &str, expired: bool) -> Self { self.caches.push(CacheBuilder::new( node_index_file(), node_index_expiry_file(), cache, expired, )); self } /// Set the package.json for the sandbox (chainable) pub fn package_json(mut self, contents: &str) -> Self { let package_file = package_json_file(self.root()); self.files.push(FileBuilder::new(package_file, contents)); self } /// Set the platform.json for the sandbox (chainable) pub fn platform(mut self, contents: &str) -> Self { self.files .push(FileBuilder::new(default_platform_file(), contents)); self } /// Set the hooks.json for the sandbox pub fn default_hooks(mut self, contents: &str) -> Self { self.files .push(FileBuilder::new(default_hooks_file(), contents)); self } /// Set a layout version file for the sandbox (chainable) pub fn layout_file(mut self, version: &str) -> Self { self.files.push(FileBuilder::new(layout_file(version), "")); self } /// Set an environment variable for the sandbox (chainable) pub fn env(mut self, name: &str, value: &str) -> Self { self.root.env_vars.push(EnvVar::new(name, value)); self } /// Setup mock to return the available node versions (chainable) pub fn node_available_versions(mut self, body: &str) -> Self { let mock = mock("GET", "/node-dist/index.json") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); self.root.mocks.push(mock); self } /// Setup mock to return the available Yarn@1 versions (chainable) pub fn yarn_1_available_versions(mut self, body: &str) -> Self { let mock = mock("GET", "/yarn") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); self.root.mocks.push(mock); self } /// Setup mock to return the available Yarn@2+ versions (chainable) pub fn yarn_berry_available_versions(mut self, body: &str) -> Self { let mock = mock("GET", "/@yarnpkg/cli-dist") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); self.root.mocks.push(mock); self } /// Setup mock to return the available npm versions (chainable) pub fn npm_available_versions(mut self, body: &str) -> Self { let mock = mock("GET", "/npm") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); self.root.mocks.push(mock); self } /// Setup mock to return the available pnpm versions (chainable) pub fn pnpm_available_versions(mut self, body: &str) -> Self { let mock = mock("GET", "/pnpm") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); self.root.mocks.push(mock); self } /// Setup mock to return a 404 for any GET request /// Note: Mocks are matched in reverse order, so any created _after_ this will work /// While those created before will not pub fn mock_not_found(mut self) -> Self { let mock = mock("GET", Matcher::Any).with_status(404).create(); self.root.mocks.push(mock); self } fn distro_mock(mut self, fx: &T) -> Self { // ISSUE(#145): this should actually use a real http server instead of these mocks let server_path = fx.server_path(); let fixture_path = fx.fixture_path(); let metadata = fx.metadata(); if let Some(uncompressed_size) = metadata.uncompressed_size { // This can be abstracted when https://github.com/rust-lang/rust/issues/52963 lands. let uncompressed_size_bytes: [u8; 4] = [ ((uncompressed_size & 0xff00_0000) >> 24) as u8, ((uncompressed_size & 0x00ff_0000) >> 16) as u8, ((uncompressed_size & 0x0000_ff00) >> 8) as u8, (uncompressed_size & 0x0000_00ff) as u8, ]; let range_mock = mock("GET", &server_path[..]) .match_header("Range", Matcher::Any) .with_body(uncompressed_size_bytes) .create(); self.root.mocks.push(range_mock); } let file_mock = mock("GET", &server_path[..]) .match_header("Range", Matcher::Missing) .with_header("Accept-Ranges", "bytes") .with_body_from_file(fixture_path) .create(); self.root.mocks.push(file_mock); self } pub fn distro_mocks(self, fixtures: &[DistroMetadata]) -> Self { let mut this = self; for fixture in fixtures { this = this.distro_mock::(&fixture.clone().into()); } this } /// Add an arbitrary file to the sandbox (chainable) pub fn file(mut self, path: &str, contents: &str) -> Self { let file_name = sandbox_path(path); self.files.push(FileBuilder::new(file_name, contents)); self } /// Add an arbitrary file to the test project within the sandbox (chainable) pub fn project_file(mut self, path: &str, contents: &str) -> Self { let file_name = self.root().join(path); self.files.push(FileBuilder::new(file_name, contents)); self } /// Add an arbitrary file to the test project within the sandbox, /// give it executable permissions, /// and add its directory to the PATH /// (chainable) pub fn executable_file(mut self, path: &str, contents: &str) -> Self { let file_name = self.root().join("exec").join(path); self.files .push(FileBuilder::new(file_name, contents).make_executable()); self.add_exec_dir_to_path() } /// Prepend executable directory to the beginning of the PATH (chainable) /// /// This is useful to test binaries shadowing volta shims. /// /// Cannot be used in combination with `add_exec_dir_to_path`, and will panic if called twice. /// No particular reason except it's likely a programming error. pub fn prepend_exec_dir_to_path(mut self) -> Self { if self.has_exec_path { panic!("need to call prepend_exec_dir_to_path before anything else"); } let exec_path = self.root().join("exec"); self.path_dirs.insert(0, exec_path); self.has_exec_path = true; self } /// Set a package config file for the sandbox (chainable) pub fn package_config(mut self, name: &str, contents: &str) -> Self { let package_cfg_file = package_config_file(name); self.files .push(FileBuilder::new(package_cfg_file, contents)); self } /// Set a bin config file for the sandbox (chainable) pub fn binary_config(mut self, name: &str, contents: &str) -> Self { let bin_cfg_file = binary_config_file(name); self.files.push(FileBuilder::new(bin_cfg_file, contents)); self } /// Set a shim file for the sandbox (chainable) pub fn shim(mut self, name: &str) -> Self { self.shims.push(ShimBuilder::new(name.to_string())); self } /// Set an unpackaged package for the sandbox (chainable) pub fn package_image( mut self, name: &str, version: &str, bins: Option>, ) -> Self { let package_img_dir = package_image_dir(name); let package_json = package_img_dir.join("package.json"); self.files.push(FileBuilder::new( package_json, &format!(r#"{{"name":"{}","version":"{}"}}"#, name, version), )); if let Some(bin_infos) = bins { for bin_info in bin_infos.iter() { cfg_if! { if #[cfg(target_os = "windows")] { let bin_path = package_img_dir.join(format!("{}.cmd", &bin_info.name)); } else { let bin_path = package_img_dir.join("bin").join(&bin_info.name); } } self.files .push(FileBuilder::new(bin_path, &bin_info.contents).make_executable()); } } self } /// Write executable project binaries into node_modules/.bin/ (chainable) pub fn project_bins(mut self, bins: Vec) -> Self { let project_bin_dir = self.root().join("node_modules").join(".bin"); for bin_info in bins.iter() { cfg_if! { if #[cfg(target_os = "windows")] { // in Windows, binaries have an extra file with an executable extension let win_bin_path = project_bin_dir.join(format!("{}.cmd", &bin_info.name)); self.files.push(FileBuilder::new(win_bin_path, &bin_info.contents).make_executable()); } } // Volta on both Windows and Unix checks for the existence of the binary with no extension let bin_path = project_bin_dir.join(&bin_info.name); self.files .push(FileBuilder::new(bin_path, &bin_info.contents).make_executable()); } self } /// Write '.pnp.cjs' file in local project to mark as Plug-n-Play (chainable) pub fn project_pnp(mut self) -> Self { let pnp_path = self.root().join(".pnp.cjs"); self.files.push(FileBuilder::new(pnp_path, "blegh")); self } /// Write an executable node binary with the input contents (chainable) pub fn setup_node_binary( mut self, node_version: &str, npm_version: &str, contents: &str, ) -> Self { cfg_if! { if #[cfg(target_os = "windows")] { let node_file = "node.cmd"; } else { let node_file = "node"; } } let node_bin_file = node_image_dir(node_version).join("bin").join(node_file); self.files .push(FileBuilder::new(node_bin_file, contents).make_executable()); self.node_npm_version_file(node_version, npm_version) } /// Write an executable npm binary with the input contents (chainable) pub fn setup_npm_binary(mut self, version: &str, contents: &str) -> Self { cfg_if! { if #[cfg(target_os = "windows")] { let npm_file = "npm.cmd"; } else { let npm_file = "npm"; } } let npm_bin_file = npm_image_dir(version).join("bin").join(npm_file); self.files .push(FileBuilder::new(npm_bin_file, contents).make_executable()); self } /// Write an executable pnpm binary with the input contents (chainable) pub fn setup_pnpm_binary(mut self, version: &str, contents: &str) -> Self { cfg_if! { if #[cfg(target_os = "windows")] { let pnpm_file = "pnpm.cmd"; } else { let pnpm_file = "pnpm"; } } let pnpm_bin_file = pnpm_image_dir(version).join("bin").join(pnpm_file); self.files .push(FileBuilder::new(pnpm_bin_file, contents).make_executable()); self } /// Write an executable yarn binary with the input contents (chainable) pub fn setup_yarn_binary(mut self, version: &str, contents: &str) -> Self { cfg_if! { if #[cfg(target_os = "windows")] { let yarn_file = "yarn.cmd"; } else { let yarn_file = "yarn"; } } let yarn_bin_file = yarn_image_dir(version).join("bin").join(yarn_file); self.files .push(FileBuilder::new(yarn_bin_file, contents).make_executable()); self } /// Write the "default npm" file for a node version (chainable) pub fn node_npm_version_file(mut self, node_version: &str, npm_version: &str) -> Self { let npm_file = node_npm_version_file(node_version); self.files.push(FileBuilder::new(npm_file, npm_version)); self } /// Add directory to the PATH (chainable) pub fn add_dir_to_path(mut self, dir: PathBuf) -> Self { self.path_dirs.push(dir); self } /// Add executable directory to the PATH (chainable) pub fn add_exec_dir_to_path(mut self) -> Self { if !self.has_exec_path { let exec_path = self.root().join("exec"); self.path_dirs.push(exec_path); self.has_exec_path = true; } self } /// Create the project pub fn build(mut self) -> Sandbox { // First, clean the directory if it already exists self.rm_root(); // Create the empty directory self.root.root().mkdir_p(); // make sure these directories exist ok_or_panic! { fs::create_dir_all(volta_bin_dir()) }; ok_or_panic! { fs::create_dir_all(node_cache_dir()) }; ok_or_panic! { fs::create_dir_all(node_inventory_dir()) }; ok_or_panic! { fs::create_dir_all(package_inventory_dir()) }; ok_or_panic! { fs::create_dir_all(pnpm_inventory_dir()) }; ok_or_panic! { fs::create_dir_all(yarn_inventory_dir()) }; ok_or_panic! { fs::create_dir_all(volta_tmp_dir()) }; // write node and yarn caches for cache in self.caches.iter() { cache.build(); } // write files for file_builder in self.files { file_builder.build(); } // write shims for shim_builder in self.shims { shim_builder.build(); } // join dirs for the path (volta bin path is already first) self.root.path = env::join_paths(self.path_dirs.iter()).unwrap(); let SandboxBuilder { root, .. } = self; root } fn rm_root(&self) { self.root.root().rm_rf() } } // files and dirs in the sandbox fn home_dir() -> PathBuf { paths::home() } fn volta_home() -> PathBuf { home_dir().join(".volta") } fn volta_tmp_dir() -> PathBuf { volta_home().join("tmp") } fn volta_bin_dir() -> PathBuf { volta_home().join("bin") } fn volta_log_dir() -> PathBuf { volta_home().join("log") } fn volta_postscript() -> PathBuf { volta_tmp_dir().join("volta_tmp_1234.sh") } fn volta_tools_dir() -> PathBuf { volta_home().join("tools") } fn inventory_dir() -> PathBuf { volta_tools_dir().join("inventory") } fn user_dir() -> PathBuf { volta_tools_dir().join("user") } fn image_dir() -> PathBuf { volta_tools_dir().join("image") } fn node_inventory_dir() -> PathBuf { inventory_dir().join("node") } fn pnpm_inventory_dir() -> PathBuf { inventory_dir().join("pnpm") } fn yarn_inventory_dir() -> PathBuf { inventory_dir().join("yarn") } fn package_inventory_dir() -> PathBuf { inventory_dir().join("packages") } fn cache_dir() -> PathBuf { volta_home().join("cache") } fn node_cache_dir() -> PathBuf { cache_dir().join("node") } #[allow(dead_code)] fn node_index_file() -> PathBuf { node_cache_dir().join("index.json") } #[allow(dead_code)] fn node_index_expiry_file() -> PathBuf { node_cache_dir().join("index.json.expires") } fn package_json_file(mut root: PathBuf) -> PathBuf { root.push("package.json"); root } fn package_config_file(name: &str) -> PathBuf { user_dir().join("packages").join(format!("{}.json", name)) } fn binary_config_file(name: &str) -> PathBuf { user_dir().join("bins").join(format!("{}.json", name)) } fn shim_file(name: &str) -> PathBuf { volta_bin_dir().join(format!("{}{}", name, env::consts::EXE_SUFFIX)) } fn package_image_dir(name: &str) -> PathBuf { image_dir().join("packages").join(name) } fn node_image_dir(version: &str) -> PathBuf { image_dir().join("node").join(version) } fn npm_image_dir(version: &str) -> PathBuf { image_dir().join("npm").join(version) } fn pnpm_image_dir(version: &str) -> PathBuf { image_dir().join("pnpm").join(version) } fn yarn_image_dir(version: &str) -> PathBuf { image_dir().join("yarn").join(version) } fn default_platform_file() -> PathBuf { user_dir().join("platform.json") } fn default_hooks_file() -> PathBuf { volta_home().join("hooks.json") } fn layout_file(version: &str) -> PathBuf { volta_home().join(format!("layout.{}", version)) } fn node_npm_version_file(node_version: &str) -> PathBuf { node_inventory_dir().join(format!("node-v{}-npm", node_version)) } fn sandbox_path(path: &str) -> PathBuf { home_dir().join(path) } pub struct Sandbox { root: PathBuf, mocks: Vec, env_vars: Vec, env_vars_remove: Vec, path: OsString, } impl Sandbox { /// Root of the project, ex: `/path/to/cargo/target/integration_test/t0/foo` pub fn root(&self) -> PathBuf { self.root.clone() } /// Create a `ProcessBuilder` to run a program in the project. /// Example: /// assert_that( /// p.process(&p.bin("foo")), /// execs().with_stdout("bar\n"), /// ); pub fn process>(&self, program: T) -> ProcessBuilder { let mut p = test_support::process::process(program); p.cwd(self.root()) // sandbox the Volta environment .env("VOLTA_HOME", volta_home()) .env("VOLTA_INSTALL_DIR", cargo_dir()) .env("PATH", &self.path) .env("VOLTA_POSTSCRIPT", volta_postscript()) .env_remove("VOLTA_SHELL") .env_remove("MSYSTEM"); // assume cmd.exe everywhere on windows // overrides for env vars for env_var in &self.env_vars { p.env(&env_var.name, &env_var.value); } for env_var_name in &self.env_vars_remove { p.env_remove(env_var_name); } p } /// Create a `ProcessBuilder` to run volta. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.volta("use node 9.5"), execs()); pub fn volta(&self, cmd: &str) -> ProcessBuilder { let mut p = self.process(volta_exe()); split_and_add_args(&mut p, cmd); p } /// Create a `ProcessBuilder` to run the volta npm shim. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.npm("install ember-cli"), execs()); pub fn npm(&self, cmd: &str) -> ProcessBuilder { self.exec_shim("npm", cmd) } /// Create a `ProcessBuilder` to run the volta pnpm shim. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.pnpm("add ember-cli"), execs()); pub fn pnpm(&self, cmd: &str) -> ProcessBuilder { self.exec_shim("pnpm", cmd) } /// Create a `ProcessBuilder` to run the volta yarn shim. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.yarn("add ember-cli"), execs()); pub fn yarn(&self, cmd: &str) -> ProcessBuilder { self.exec_shim("yarn", cmd) } /// Create a `ProcessBuilder` to run an arbitrary shim. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.exec_shim("cowsay", "foo bar"), execs()); pub fn exec_shim(&self, bin: &str, cmd: &str) -> ProcessBuilder { let mut p = self.process(shim_file(bin)); split_and_add_args(&mut p, cmd); p } pub fn read_package_json(&self) -> String { let package_file = package_json_file(self.root()); read_file_to_string(package_file) } pub fn read_log_dir(&self) -> Option { fs::read_dir(volta_log_dir()).ok() } pub fn remove_volta_home(&self) { volta_home().rm_rf(); } // check that files in the sandbox exist pub fn node_inventory_archive_exists(&self, version: &Version) -> bool { node_inventory_dir() .join(Node::archive_filename(version)) .exists() } pub fn pnpm_inventory_archive_exists(&self, version: &str) -> bool { pnpm_inventory_dir() .join(Pnpm::archive_filename(version)) .exists() } pub fn yarn_inventory_archive_exists(&self, version: &str) -> bool { yarn_inventory_dir() .join(Yarn::archive_filename(version)) .exists() } pub fn package_config_exists(name: &str) -> bool { package_config_file(name).exists() } pub fn bin_config_exists(name: &str) -> bool { binary_config_file(name).exists() } pub fn shim_exists(name: &str) -> bool { shim_file(name).exists() } pub fn path_exists(path: &str) -> bool { sandbox_path(path).exists() } pub fn package_image_exists(name: &str) -> bool { let package_img_dir = package_image_dir(name); package_img_dir.join("package.json").exists() } pub fn read_default_platform() -> String { read_file_to_string(default_platform_file()) } } impl Drop for Sandbox { fn drop(&mut self) { paths::root().rm_rf(); } } // Generates a sandboxed environment pub fn sandbox() -> SandboxBuilder { SandboxBuilder::new(paths::root().join("sandbox")) } // Path to compiled executables pub fn cargo_dir() -> PathBuf { env::var_os("CARGO_BIN_PATH") .map(PathBuf::from) .or_else(|| { env::current_exe().ok().map(|mut path| { path.pop(); if path.ends_with("deps") { path.pop(); } path }) }) .unwrap_or_else(|| panic!("CARGO_BIN_PATH wasn't set. Cannot continue running test")) } fn volta_exe() -> PathBuf { cargo_dir().join(format!("volta{}", env::consts::EXE_SUFFIX)) } pub fn shim_exe() -> PathBuf { cargo_dir().join(format!("volta-shim{}", env::consts::EXE_SUFFIX)) } fn split_and_add_args(p: &mut ProcessBuilder, s: &str) { for arg in s.split_whitespace() { if arg.contains('"') || arg.contains('\'') { panic!("shell-style argument parsing is not supported") } p.arg(arg); } } fn read_file_to_string(file_path: PathBuf) -> String { let mut contents = String::new(); let mut file = ok_or_panic! { File::open(file_path) }; ok_or_panic! { file.read_to_string(&mut contents) }; contents } ================================================ FILE: tests/acceptance/verbose_errors.rs ================================================ use crate::support::sandbox::sandbox; use ci_info::types::{CiInfo, Vendor}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip","linux-arm64"]}, {"version":"v9.27.6","npm":"5.6.17","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip","linux-arm64"]}, {"version":"v8.9.10","npm":"5.6.7","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip","linux-arm64"]}, {"version":"v6.19.62","npm":"3.10.1066","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip","linux-arm64"]} ] "#; #[test] fn no_cause_shown_if_no_verbose_flag() { let s = sandbox().node_available_versions(NODE_VERSION_INFO).build(); // Mock `is_ci` to false so that this works even when running in Volta's CI Test Suite ci_info::mock_ci(&CiInfo::new()); assert_that!( s.volta("install node@10"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_does_not_contain("[..]Error cause[..]") ); } #[test] fn cause_shown_if_verbose_flag() { let s = sandbox().node_available_versions(NODE_VERSION_INFO).build(); // Mock `is_ci` to false so that this correctly tests the verbose flag ci_info::mock_ci(&CiInfo::new()); assert_that!( s.volta("install node@10 --verbose"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Error cause[..]") ); } #[test] fn no_cause_if_no_underlying_error() { let s = sandbox().build(); assert_that!( s.volta("use --verbose"), execs() .with_status(ExitCode::InvalidArguments as i32) .with_stderr_does_not_contain("[..]Error cause[..]") ); } #[test] fn error_log_if_underlying_cause() { let s = sandbox().node_available_versions(NODE_VERSION_INFO).build(); // Mock `is_ci` to false so that this works even when running Volta's CI Test Suite ci_info::mock_ci(&CiInfo::new()); assert_that!( s.volta("install node@10"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("Error details written to[..]") ); let mut log_dir_contents = s.read_log_dir().expect("Could not read log directory"); assert_that!(log_dir_contents.next(), some()); } #[test] fn no_error_log_if_no_underlying_cause() { let s = sandbox().build(); assert_that!( s.volta("use"), execs() .with_status(ExitCode::InvalidArguments as i32) .with_stderr_does_not_contain("Error details written to[..]") ); // The log directory may not exist at all. If so, we know we didn't write to it if let Some(mut log_dir_contents) = s.read_log_dir() { assert_that!(log_dir_contents.next(), none()); } } #[test] fn cause_shown_in_ci() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .env("VOLTA_LOGLEVEL", "error") .build(); // Mock a CI environment so this works even when running locally let mut ci_mock = CiInfo::new(); ci_mock.vendor = Some(Vendor::GitHubActions); ci_mock.ci = true; ci_info::mock_ci(&ci_mock); assert_that!( s.volta("install node@10"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Error cause[..]") ); } #[test] fn no_error_log_in_ci() { let s = sandbox().node_available_versions(NODE_VERSION_INFO).build(); // Mock a CI environment so this works even when running locally let mut ci_mock = CiInfo::new(); ci_mock.vendor = Some(Vendor::GitHubActions); ci_mock.ci = true; ci_info::mock_ci(&ci_mock); assert_that!( s.volta("install node@10"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_does_not_contain("Error details written to[..]") ); // The log directory may not exist at all. If so, we know we didn't write to it if let Some(mut log_dir_contents) = s.read_log_dir() { assert_that!(log_dir_contents.next(), none()); } } ================================================ FILE: tests/acceptance/volta_bypass.rs ================================================ use crate::support::sandbox::{sandbox, shim_exe}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; #[test] fn shim_skips_platform_checks_on_bypass() { let s = sandbox() .env("VOLTA_BYPASS", "1") .env( "VOLTA_INSTALL_DIR", &shim_exe().parent().unwrap().to_string_lossy(), ) .build(); #[cfg(unix)] assert_that!( s.process(shim_exe()), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("VOLTA_BYPASS is enabled[..]") ); #[cfg(windows)] assert_that!( s.process(shim_exe()), execs() .with_status(ExitCode::UnknownError as i32) .with_stderr_contains("[..]is not recognized as an internal or external command[..]") ); } ================================================ FILE: tests/acceptance/volta_install.rs ================================================ use crate::support::sandbox::{ sandbox, DistroMetadata, NodeFixture, NpmFixture, PnpmFixture, Sandbox, Yarn1Fixture, YarnBerryFixture, }; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; fn platform_with_node(node: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": null }}, "pnpm": null, "yarn": null }}"#, node ) } fn platform_with_node_npm(node: &str, npm: &str) -> String { format!( r#"{{ "node": {{ "runtime": "{}", "npm": "{}" }}, "pnpm": null, "yarn": null }}"#, node, npm ) } const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v9.27.6","npm":"5.6.17","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v8.9.10","npm":"5.6.7","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v6.19.62","npm":"3.10.1066","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]} ] "#; cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 270, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, DistroMetadata { version: "9.27.6", compressed_size: 1068, uncompressed_size: None, }, DistroMetadata { version: "8.9.10", compressed_size: 1055, uncompressed_size: None, }, DistroMetadata { version: "6.19.62", compressed_size: 1056, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const YARN_1_VERSION_INFO: &str = r#"[ {"tag_name":"v1.2.42","assets":[{"name":"yarn-v1.2.42.tar.gz"}]}, {"tag_name":"v1.3.1","assets":[{"name":"yarn-v1.3.1.msi"}]}, {"tag_name":"v1.4.159","assets":[{"name":"yarn-v1.4.159.tar.gz"}]}, {"tag_name":"v1.7.71","assets":[{"name":"yarn-v1.7.71.tar.gz"}]}, {"tag_name":"v1.12.99","assets":[{"name":"yarn-v1.12.99.tar.gz"}]} ]"#; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "1.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_BERRY_VERSION_INFO: &str = r#"{ "name":"@yarnpkg/cli-dist", "dist-tags": { "latest":"3.12.99" }, "versions": { "2.4.159": { "version":"2.4.159", "dist": { "shasum":"", "tarball":"" }}, "3.2.42": { "version":"3.2.42", "dist": { "shasum":"", "tarball":"" }}, "3.7.71": { "version":"3.7.71", "dist": { "shasum":"", "tarball":"" }}, "3.12.99": { "version":"3.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#; const YARN_BERRY_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "2.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const PNPM_VERSION_INFO: &str = r#" { "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "6.34.0": { "version":"6.34.0", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } } "#; const PNPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "0.0.1", compressed_size: 10, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.34.0", compressed_size: 500, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "7.7.1", compressed_size: 518, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_INFO: &str = r#" { "name":"npm", "dist-tags": { "latest":"8.1.5" }, "versions": { "1.2.3": { "version":"1.2.3", "dist": { "shasum":"", "tarball":"" }}, "4.5.6": { "version":"4.5.6", "dist": { "shasum":"", "tarball":"" }}, "8.1.5": { "version":"8.1.5", "dist": { "shasum":"", "tarball":"" }} } } "#; const NPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "1.2.3", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "4.5.6", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.1.5", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, ]; #[test] fn install_node_informs_newer_npm() { let s = sandbox() .platform(&platform_with_node_npm("8.9.10", "5.6.17")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("install node@10.99.1040"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]this version of Node includes npm@6.2.26, which is higher than your default version (5.6.17).") .with_stdout_contains("[..]`volta install npm@bundled`[..]") ); } #[test] fn install_node_with_npm_hides_bundled_version() { let s = sandbox() .platform(&platform_with_node_npm("8.9.10", "6.2.26")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("install node@9.27.6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..](with npm@5.6.17)[..]") ); } #[test] fn install_npm_bundled_clears_npm() { let s = sandbox() .platform(&platform_with_node_npm("8.9.10", "6.2.26")) .node_npm_version_file("8.9.10", "5.6.7") .build(); assert_that!( s.volta("install npm@bundled"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( Sandbox::read_default_platform(), platform_with_node("8.9.10") ); } #[test] fn install_npm_bundled_reports_info() { let s = sandbox() .platform(&platform_with_node_npm("8.9.10", "6.2.26")) .node_npm_version_file("8.9.10", "5.6.7") .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("install npm@bundled"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]set bundled npm (currently 5.6.7)[..]") ); } #[test] fn install_npm_without_node_errors() { let s = sandbox() .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .build(); assert_that!( s.volta("install npm@4.5.6"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot install npm because the default Node version is not set." ) ); } #[test] fn install_pnpm_without_node_errors() { let s = sandbox() .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("install pnpm@7.7.1"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot install pnpm because the default Node version is not set." ) ); } #[test] fn install_yarn_without_node_errors() { let s = sandbox() .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("install yarn@1.2.42"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot install Yarn because the default Node version is not set." ) ); } #[test] fn install_yarn_3_without_node_errors() { let s = sandbox() .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("install yarn@3.2.42"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot install Yarn because the default Node version is not set." ) ); } #[test] fn install_node_with_shadowed_binary() { #[cfg(windows)] const SCRIPT_FILENAME: &str = "node.bat"; #[cfg(not(windows))] const SCRIPT_FILENAME: &str = "node"; let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .prepend_exec_dir_to_path() .executable_file(SCRIPT_FILENAME, "echo hello world") .build(); assert_that!( s.volta("install node"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]is shadowed by another binary of the same name at [..]") ); } ================================================ FILE: tests/acceptance/volta_pin.rs ================================================ use crate::support::sandbox::{ sandbox, DistroMetadata, NodeFixture, NpmFixture, PnpmFixture, Yarn1Fixture, YarnBerryFixture, }; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; const BASIC_PACKAGE_JSON: &str = r#"{ "name": "test-package" }"#; const PACKAGE_JSON_WITH_EMPTY_LINE: &str = r#"{ "name": "test-package" } "#; const PACKAGE_JSON_WITH_EXTENDS: &str = r#"{ "name": "test-package", "volta": { "node": "8.9.10", "extends": "./basic.json" } }"#; fn package_json_with_pinned_node(node: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}" }} }}"#, node ) } fn package_json_with_pinned_node_npm(node: &str, npm: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "npm": "{}" }} }}"#, node, npm ) } fn package_json_with_pinned_node_pnpm(node_version: &str, pnpm_version: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "pnpm": "{}" }} }}"#, node_version, pnpm_version ) } fn package_json_with_pinned_node_npm_pnpm( node_version: &str, npm_version: &str, pnpm_version: &str, ) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "npm": "{}", "pnpm": "{}" }} }}"#, node_version, npm_version, pnpm_version ) } fn package_json_with_pinned_node_yarn(node_version: &str, yarn_version: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "yarn": "{}" }} }}"#, node_version, yarn_version ) } fn package_json_with_pinned_node_npm_yarn( node_version: &str, npm_version: &str, yarn_version: &str, ) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "npm": "{}", "yarn": "{}" }} }}"#, node_version, npm_version, yarn_version ) } const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v9.27.6","npm":"5.6.17","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v8.9.10","npm":"5.6.7","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v6.19.62","npm":"3.10.1066","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]} ] "#; cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 270, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, DistroMetadata { version: "9.27.6", compressed_size: 1068, uncompressed_size: None, }, DistroMetadata { version: "8.9.10", compressed_size: 1055, uncompressed_size: None, }, DistroMetadata { version: "6.19.62", compressed_size: 1056, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const YARN_1_VERSION_INFO: &str = r#"{ "name":"yarn", "dist-tags": { "latest":"1.12.99" }, "versions": { "1.2.42": { "version":"1.2.42", "dist": { "shasum":"", "tarball":"" }}, "1.4.159": { "version":"1.4.159", "dist": { "shasum":"", "tarball":"" }}, "1.7.71": { "version":"1.7.71", "dist": { "shasum":"", "tarball":"" }}, "1.12.99": { "version":"1.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#; const YARN_BERRY_VERSION_INFO: &str = r#"{ "name":"@yarnpkg/cli-dist", "dist-tags": { "latest":"3.12.99" }, "versions": { "2.4.159": { "version":"2.4.159", "dist": { "shasum":"", "tarball":"" }}, "3.2.42": { "version":"3.2.42", "dist": { "shasum":"", "tarball":"" }}, "3.7.71": { "version":"3.7.71", "dist": { "shasum":"", "tarball":"" }}, "3.12.99": { "version":"3.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "1.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_BERRY_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "2.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const PNPM_VERSION_INFO: &str = r#" { "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "0.0.1": { "version":"0.0.1", "dist": { "shasum":"", "tarball":"" }}, "6.34.0": { "version":"6.34.0", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } } "#; const PNPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "0.0.1", compressed_size: 10, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.34.0", compressed_size: 500, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "7.7.1", compressed_size: 518, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "1.2.3", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "4.5.6", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.1.5", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_INFO: &str = r#" { "name":"npm", "dist-tags": { "latest":"8.1.5" }, "versions": { "1.2.3": { "version":"1.2.3", "dist": { "shasum":"", "tarball":"" }}, "4.5.6": { "version":"4.5.6", "dist": { "shasum":"", "tarball":"" }}, "8.1.5": { "version":"8.1.5", "dist": { "shasum":"", "tarball":"" }} } } "#; const VOLTA_LOGLEVEL: &str = "VOLTA_LOGLEVEL"; #[test] fn pin_node() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node@6"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("6.19.62"), ) } #[test] fn pin_node_reports_info() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("pin node@6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]pinned node@6.19.62 (with npm@3.10.1066) in package.json") ); } #[test] fn pin_node_latest() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node@latest"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("10.99.1040"), ) } #[test] fn pin_node_no_version() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("10.99.1040"), ) } #[test] fn pin_node_informs_newer_npm() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("8.9.10", "5.6.17")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("pin node@10.99.1040"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]this version of Node includes npm@6.2.26, which is higher than your pinned version (5.6.17).") .with_stdout_contains("[..]`volta pin npm@bundled`[..]") ); } #[test] fn pin_node_with_npm_hides_bundled_version() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("8.9.10", "6.2.26")) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("pin node@9.27.6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_does_not_contain("[..](with npm@5.6.17)[..]") ); } #[test] fn pin_yarn_no_node() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@1.4"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot pin Yarn because the Node version is not pinned in this project." ) ); assert_eq!(s.read_package_json(), BASIC_PACKAGE_JSON) } #[test] fn pin_yarn_1() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@1.4"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "1.4.159"), ) } #[test] fn pin_yarn_2_is_error() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@2"), execs() .with_status(ExitCode::NoVersionMatch as i32) .with_stderr_contains( "[..]Yarn version 2 is not recommended for use, and not supported by Volta[..]" ) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_yarn_3() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@3"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "3.12.99"), ) } #[test] fn pin_yarn_reports_info() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("pin yarn@1.4"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]pinned yarn@1.4.159 in package.json") ); } #[test] fn pin_yarn_latest() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@latest"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "3.12.99"), ) } #[test] fn pin_yarn_1_no_version() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@1"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "1.12.99"), ) } #[test] fn pin_yarn_3_no_version() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@3"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "3.12.99"), ) } #[test] fn pin_yarn_no_version() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("1.2.3", "3.12.99"), ) } #[test] fn pin_yarn_1_missing_release() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .mock_not_found() .build(); assert_that!( s.volta("pin yarn@1.3.1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download yarn@1.3.1") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_yarn_1_missing_release_v2() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .mock_not_found() .build(); assert_that!( s.volta("pin yarn@1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download Yarn version registry") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_yarn_3_missing_release() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .mock_not_found() .build(); assert_that!( s.volta("pin yarn@3.3.1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download yarn@3.3.1") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_yarn_3_missing_release_v2() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .mock_not_found() .build(); assert_that!( s.volta("pin yarn@3"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download Yarn version registry") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_yarn_leaves_npm() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("1.2.3", "3.4.5")) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin yarn@1.4"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_npm_yarn("1.2.3", "3.4.5", "1.4.159"), ) } #[test] fn pin_npm_no_node() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin npm@1.2.3"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot pin npm because the Node version is not pinned in this project." ) ); assert_eq!(s.read_package_json(), BASIC_PACKAGE_JSON) } #[test] fn pin_npm() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin npm@4.5"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_npm("1.2.3", "4.5.6"), ) } #[test] fn pin_npm_reports_info() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("pin npm@4.5"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]pinned npm@4.5.6 in package.json") ); } #[test] fn pin_npm_latest() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin npm@latest"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_npm("1.2.3", "8.1.5"), ); } #[test] fn pin_npm_no_version() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin npm"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_npm("1.2.3", "8.1.5"), ) } #[test] fn pin_npm_missing_release() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .mock_not_found() .build(); assert_that!( s.volta("pin npm@8.1.5"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download npm@8.1.5") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ); } #[test] fn pin_npm_bundled_removes_npm() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("1.2.3", "4.5.6")) .node_npm_version_file("1.2.3", "3.2.1") .build(); assert_that!( s.volta("pin npm@bundled"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ); } #[test] fn pin_npm_bundled_reports_info() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("1.2.3", "4.5.6")) .node_npm_version_file("1.2.3", "3.2.1") .env("VOLTA_LOGLEVEL", "info") .build(); assert_that!( s.volta("pin npm@bundled"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]set package.json to use bundled npm (currently 3.2.1)[..]") ); } #[test] fn pin_node_and_yarn1() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node@6 yarn@1.4"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("6.19.62", "1.4.159"), ) } #[test] fn pin_node_and_yarn3() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node@6 yarn@3"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_yarn("6.19.62", "3.12.99"), ) } #[test] fn pin_node_does_not_remove_trailing_newline() { let s = sandbox() .package_json(PACKAGE_JSON_WITH_EMPTY_LINE) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .build(); assert_that!( s.volta("pin node@6"), execs().with_status(ExitCode::Success as i32) ); assert!(s.read_package_json().ends_with('\n')) } #[test] fn pin_node_does_not_overwrite_extends() { let s = sandbox() .package_json(PACKAGE_JSON_WITH_EXTENDS) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .project_file("basic.json", BASIC_PACKAGE_JSON) .build(); assert_that!( s.volta("pin node@6"), execs().with_status(ExitCode::Success as i32) ); assert!(s .read_package_json() .contains(r#""extends": "./basic.json""#)); } #[test] fn pin_pnpm_no_node() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm@7"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains( "[..]Cannot pin pnpm because the Node version is not pinned in this project." ) ); assert_eq!(s.read_package_json(), BASIC_PACKAGE_JSON) } #[test] fn pin_pnpm() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm@7"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_pnpm("1.2.3", "7.7.1"), ) } #[test] fn pin_pnpm_reports_info() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "info") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm@6"), execs() .with_status(ExitCode::Success as i32) .with_stdout_contains("[..]pinned pnpm@6.34.0 in package.json") ); } #[test] fn pin_pnpm_latest() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm@latest"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_pnpm("1.2.3", "7.7.1"), ) } #[test] fn pin_pnpm_no_version() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_pnpm("1.2.3", "7.7.1"), ) } #[test] fn pin_pnpm_missing_release() { let s = sandbox() .package_json(&package_json_with_pinned_node("1.2.3")) .env("VOLTA_FEATURE_PNPM", "1") .mock_not_found() .build(); assert_that!( s.volta("pin pnpm@3.3.1"), execs() .with_status(ExitCode::NetworkError as i32) .with_stderr_contains("[..]Could not download pnpm@3.3.1") ); assert_eq!( s.read_package_json(), package_json_with_pinned_node("1.2.3"), ) } #[test] fn pin_node_and_pnpm() { let s = sandbox() .package_json(BASIC_PACKAGE_JSON) .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin node@10 pnpm@6"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_pnpm("10.99.1040", "6.34.0"), ) } #[test] fn pin_pnpm_leaves_npm() { let s = sandbox() .package_json(&package_json_with_pinned_node_npm("1.2.3", "3.4.5")) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("pin pnpm@6.34.0"), execs().with_status(ExitCode::Success as i32) ); assert_eq!( s.read_package_json(), package_json_with_pinned_node_npm_pnpm("1.2.3", "3.4.5", "6.34.0"), ) } ================================================ FILE: tests/acceptance/volta_run.rs ================================================ use crate::support::sandbox::{ sandbox, DistroMetadata, NodeFixture, NpmFixture, PnpmFixture, Yarn1Fixture, YarnBerryFixture, }; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; fn package_json_with_pinned_node(node: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}" }} }}"#, node ) } fn package_json_with_pinned_node_npm(node: &str, npm: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "npm": "{}" }} }}"#, node, npm ) } fn package_json_with_pinned_node_pnpm(node_version: &str, pnpm_version: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "pnpm": "{}" }} }}"#, node_version, pnpm_version ) } fn package_json_with_pinned_node_yarn(node_version: &str, yarn_version: &str) -> String { format!( r#"{{ "name": "test-package", "volta": {{ "node": "{}", "yarn": "{}" }} }}"#, node_version, yarn_version ) } const NODE_VERSION_INFO: &str = r#"[ {"version":"v10.99.1040","npm":"6.2.26","lts": "Dubnium","files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v9.27.6","npm":"5.6.17","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v8.9.10","npm":"5.6.7","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]}, {"version":"v6.19.62","npm":"3.10.1066","lts": false,"files":["linux-x64","osx-x64-tar","win-x64-zip","win-x86-zip", "linux-arm64"]} ] "#; cfg_if::cfg_if! { if #[cfg(target_os = "macos")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "linux")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "9.27.6", compressed_size: 272, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.9.10", compressed_size: 270, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "6.19.62", compressed_size: 273, uncompressed_size: Some(0x0028_0000), }, ]; } else if #[cfg(target_os = "windows")] { const NODE_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "10.99.1040", compressed_size: 1096, uncompressed_size: None, }, DistroMetadata { version: "9.27.6", compressed_size: 1068, uncompressed_size: None, }, DistroMetadata { version: "8.9.10", compressed_size: 1055, uncompressed_size: None, }, DistroMetadata { version: "6.19.62", compressed_size: 1056, uncompressed_size: None, }, ]; } else { compile_error!("Unsupported target_os for tests (expected 'macos', 'linux', or 'windows')."); } } const PNPM_VERSION_INFO: &str = r#" { "name":"pnpm", "dist-tags": { "latest":"7.7.1" }, "versions": { "6.34.0": { "version":"6.34.0", "dist": { "shasum":"", "tarball":"" }}, "7.7.1": { "version":"7.7.1", "dist": { "shasum":"", "tarball":"" }} } } "#; const PNPM_VERSION_FIXTURES: [DistroMetadata; 2] = [ DistroMetadata { version: "6.34.0", compressed_size: 500, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "7.7.1", compressed_size: 518, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_1_VERSION_INFO: &str = r#"{ "name":"yarn", "dist-tags": { "latest":"1.12.99" }, "versions": { "1.2.42": { "version":"1.2.42", "dist": { "shasum":"", "tarball":"" }}, "1.4.159": { "version":"1.4.159", "dist": { "shasum":"", "tarball":"" }}, "1.7.71": { "version":"1.7.71", "dist": { "shasum":"", "tarball":"" }}, "1.12.99": { "version":"1.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#; const YARN_1_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "1.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "1.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const YARN_BERRY_VERSION_INFO: &str = r#"{ "name":"@yarnpkg/cli-dist", "dist-tags": { "latest":"3.12.99" }, "versions": { "2.4.159": { "version":"2.4.159", "dist": { "shasum":"", "tarball":"" }}, "3.2.42": { "version":"3.2.42", "dist": { "shasum":"", "tarball":"" }}, "3.7.71": { "version":"3.7.71", "dist": { "shasum":"", "tarball":"" }}, "3.12.99": { "version":"3.12.99", "dist": { "shasum":"", "tarball":"" }} } }"#; const YARN_BERRY_VERSION_FIXTURES: [DistroMetadata; 4] = [ DistroMetadata { version: "2.4.159", compressed_size: 177, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.12.99", compressed_size: 178, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.7.71", compressed_size: 176, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "3.2.42", compressed_size: 174, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_FIXTURES: [DistroMetadata; 3] = [ DistroMetadata { version: "1.2.3", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "4.5.6", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, DistroMetadata { version: "8.1.5", compressed_size: 239, uncompressed_size: Some(0x0028_0000), }, ]; const NPM_VERSION_INFO: &str = r#" { "name":"npm", "dist-tags": { "latest":"8.1.5" }, "versions": { "1.2.3": { "version":"1.2.3", "dist": { "shasum":"", "tarball":"" }}, "4.5.6": { "version":"4.5.6", "dist": { "shasum":"", "tarball":"" }}, "8.1.5": { "version":"8.1.5", "dist": { "shasum":"", "tarball":"" }} } } "#; const VOLTA_LOGLEVEL: &str = "VOLTA_LOGLEVEL"; #[test] fn command_line_node() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 node --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 10.99.1040 from command-line configuration") ); } #[test] fn inherited_node() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node("9.27.6")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run node --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Node: 9.27.6 from project configuration") ); } #[test] fn command_line_npm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 --npm 8.1.5 npm --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]npm: 8.1.5 from command-line configuration") ); } #[test] fn inherited_npm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_npm("9.27.6", "4.5.6")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 npm --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]npm: 4.5.6 from project configuration") ); } #[test] fn force_bundled_npm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .npm_available_versions(NPM_VERSION_INFO) .distro_mocks::(&NPM_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_npm("9.27.6", "4.5.6")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --bundled-npm npm --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]npm: 5.6.17[..]") ); } #[test] fn command_line_yarn_1() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 --yarn 1.7.71 yarn --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Yarn: 1.7.71 from command-line configuration") ); assert_that!( s.volta("run --node 10.99.1040 --yarn 1.7.71 yarnpkg --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Yarn: 1.7.71 from command-line configuration") ); } #[test] fn command_line_yarn_3() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 --yarn 3.7.71 yarn --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Yarn: 3.7.71 from command-line configuration") ); } #[test] fn inherited_yarn_1() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_yarn("10.99.1040", "1.2.42")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 yarn --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Yarn: 1.2.42 from project configuration") ); } #[test] fn inherited_yarn_3() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .yarn_berry_available_versions(YARN_BERRY_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .distro_mocks::(&YARN_BERRY_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_yarn("10.99.1040", "3.2.42")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --node 10.99.1040 yarn --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]Yarn: 3.2.42 from project configuration") ); } #[test] fn force_no_yarn() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .yarn_1_available_versions(YARN_1_VERSION_INFO) .distro_mocks::(&YARN_1_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_yarn("10.99.1040", "1.2.42")) .env(VOLTA_LOGLEVEL, "debug") .build(); assert_that!( s.volta("run --no-yarn yarn --version"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains("[..]No Yarn version found in this project.") ); } #[test] fn command_line_pnpm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .env(VOLTA_LOGLEVEL, "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("run --node 10.99.1040 --pnpm 6.34.0 pnpm --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]pnpm: 6.34.0 from command-line configuration") ); } #[test] fn inherited_pnpm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_pnpm("10.99.1040", "7.7.1")) .env(VOLTA_LOGLEVEL, "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("run --node 10.99.1040 pnpm --version"), execs() .with_status(ExitCode::Success as i32) .with_stderr_contains("[..]pnpm: 7.7.1 from project configuration") ); } #[test] fn force_no_pnpm() { let s = sandbox() .node_available_versions(NODE_VERSION_INFO) .distro_mocks::(&NODE_VERSION_FIXTURES) .pnpm_available_versions(PNPM_VERSION_INFO) .distro_mocks::(&PNPM_VERSION_FIXTURES) .package_json(&package_json_with_pinned_node_pnpm("10.99.1040", "7.7.1")) .env(VOLTA_LOGLEVEL, "debug") .env("VOLTA_FEATURE_PNPM", "1") .build(); assert_that!( s.volta("run --no-pnpm pnpm --version"), execs() .with_status(ExitCode::ConfigurationError as i32) .with_stderr_contains("[..]No pnpm version found in this project.") ); } ================================================ FILE: tests/acceptance/volta_uninstall.rs ================================================ //! Tests for `volta uninstall`. use crate::support::sandbox::{sandbox, Sandbox}; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; const PKG_CONFIG_BASIC: &str = r#"{ "name": "cowsay", "version": "1.4.0", "platform": { "node": "11.10.1", "npm": "6.7.0", "yarn": null }, "bins": [ "cowsay", "cowthink" ], "manager": "Npm" }"#; const PKG_CONFIG_NO_BINS: &str = r#"{ "name": "cowsay", "version": "1.4.0", "platform": { "node": "11.10.1", "npm": "6.7.0", "yarn": null }, "bins": [], "manager": "Npm" }"#; fn bin_config(name: &str) -> String { format!( r#"{{ "name": "{}", "package": "cowsay", "version": "1.4.0", "platform": {{ "node": "11.10.1", "npm": "6.7.0", "yarn": null }}, "manager": "Npm" }}"#, name ) } const VOLTA_LOGLEVEL: &str = "VOLTA_LOGLEVEL"; #[test] fn uninstall_nonexistent_pkg() { // if the package doesn't exist, it should just inform the user but not throw an error let s = sandbox().env(VOLTA_LOGLEVEL, "info").build(); assert_that!( s.volta("uninstall cowsay"), execs() .with_status(0) .with_stderr_contains("[..]No package 'cowsay' found to uninstall") ); } #[test] fn uninstall_package_basic() { // basic uninstall - everything exists, and everything except the cached // inventory files should be deleted let s = sandbox() .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("uninstall cowsay"), execs() .with_status(0) .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); } // The setup here is the same as the above, but here we check to make sure that // if the user supplies a version, we error correctly. #[test] fn uninstall_package_basic_with_version() { // basic uninstall - everything exists, and everything except the cached // inventory files should be deleted let s = sandbox() .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .package_image("cowsay", "1.4.0", None) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("uninstall cowsay@1.4.0"), execs().with_status(1).with_stderr_contains( "[..]error: uninstalling specific versions of tools is not supported yet." ) ); } #[test] fn uninstall_package_no_bins() { // the package doesn't contain any executables, it should uninstall without error // (normally installing a package with no executables should not happen) let s = sandbox() .package_config("cowsay", PKG_CONFIG_NO_BINS) .package_image("cowsay", "1.4.0", None) .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("uninstall cowsay"), execs() .with_status(0) .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); } #[test] fn uninstall_package_no_image() { // there is no unpacked & initialized package, but everything should be removed // (without erroring and failing to remove everything) let s = sandbox() .package_config("cowsay", PKG_CONFIG_BASIC) .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("uninstall cowsay"), execs() .with_status(0) .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::package_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); assert!(!Sandbox::package_image_exists("cowsay")); } #[test] fn uninstall_package_orphaned_bins() { // the package config does not exist, but for some reason there are orphaned binaries // those should be removed let s = sandbox() .binary_config("cowsay", &bin_config("cowsay")) .binary_config("cowthink", &bin_config("cowthink")) .shim("cowsay") .shim("cowthink") .env(VOLTA_LOGLEVEL, "info") .build(); assert_that!( s.volta("uninstall cowsay"), execs() .with_status(0) .with_stdout_contains("Removed executable 'cowsay' installed by 'cowsay'") .with_stdout_contains("Removed executable 'cowthink' installed by 'cowsay'") .with_stdout_contains("[..]package 'cowsay' uninstalled") ); // check that everything is deleted assert!(!Sandbox::bin_config_exists("cowsay")); assert!(!Sandbox::bin_config_exists("cowthink")); assert!(!Sandbox::shim_exists("cowsay")); assert!(!Sandbox::shim_exists("cowthink")); } #[test] fn uninstall_runtime() { let s = sandbox().build(); assert_that!( s.volta("uninstall node"), execs() .with_status(1) .with_stderr_contains("[..]error: Uninstalling node is not supported yet.") ) } ================================================ FILE: tests/fixtures/pnpm-0.0.1.tgz ================================================ CORRUPTED ================================================ FILE: tests/fixtures/yarn-0.0.1.tgz ================================================ CORRUPTED ================================================ FILE: tests/smoke/autodownload.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; static PACKAGE_JSON_WITH_PINNED_NODE: &str = r#"{ "name": "test-package", "volta": { "node": "14.15.5" } }"#; static PACKAGE_JSON_WITH_PINNED_NODE_NPM: &str = r#"{ "name": "test-package", "volta": { "node": "17.3.0", "npm": "8.5.1" } }"#; static PACKAGE_JSON_WITH_PINNED_NODE_YARN_1: &str = r#"{ "name": "test-package", "volta": { "node": "16.11.1", "yarn": "1.22.16" } }"#; static PACKAGE_JSON_WITH_PINNED_NODE_YARN_3: &str = r#"{ "name": "test-package", "volta": { "node": "16.14.0", "yarn": "3.1.0" } }"#; #[test] fn autodownload_node() { let p = temp_project() .package_json(PACKAGE_JSON_WITH_PINNED_NODE) .build(); assert_that!( p.node("--version"), execs().with_status(0).with_stdout_contains("v14.15.5") ); } #[test] fn autodownload_npm() { let p = temp_project() .package_json(PACKAGE_JSON_WITH_PINNED_NODE_NPM) .build(); assert_that!( p.npm("--version"), execs().with_status(0).with_stdout_contains("8.5.1") ); } #[test] fn autodownload_yarn_1() { let p = temp_project() .package_json(PACKAGE_JSON_WITH_PINNED_NODE_YARN_1) .build(); assert_that!( p.yarn("--version"), execs().with_status(0).with_stdout_contains("1.22.16") ); } #[test] fn autodownload_yarn_3() { let p = temp_project() .package_json(PACKAGE_JSON_WITH_PINNED_NODE_YARN_3) .build(); assert_that!( p.yarn("--version"), execs().with_status(0).with_stdout_contains("3.1.0") ); } ================================================ FILE: tests/smoke/direct_install.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; #[test] fn npm_global_install() { let p = temp_project().build(); // Have to install node to ensure npm is available assert_that!(p.volta("install node@14.1.0"), execs().with_status(0)); assert_that!( p.npm("install --global typescript@3.9.4 yarn@1.16.0 ../../../../tests/fixtures/volta-test-1.0.0.tgz"), execs().with_status(0) ); assert!(p.shim_exists("tsc")); assert!(p.shim_exists("tsserver")); assert!(p.package_is_installed("typescript")); assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 3.9.4") ); assert!(p.yarn_version_is_fetched("1.16.0")); assert!(p.yarn_version_is_unpacked("1.16.0")); p.assert_yarn_version_is_installed("1.16.0"); assert_that!( p.yarn("--version"), execs().with_status(0).with_stdout_contains("1.16.0") ); assert!(p.shim_exists("volta-test")); assert!(p.package_is_installed("volta-test")); assert_that!( p.exec_shim("volta-test", ""), execs() .with_status(0) .with_stdout_contains("Volta test successful") ); } #[test] fn yarn_global_add() { let p = temp_project().build(); let tarball_path = p .root() .join("../../../../tests/fixtures/volta-test-1.0.0.tgz") .canonicalize() .unwrap(); // Have to install node and yarn first assert_that!( p.volta("install node@14.2.0 yarn@1.22.5"), execs().with_status(0) ); assert_that!( p.yarn(&format!( "global add typescript@4.0.2 npm@6.4.0 file:{}", tarball_path.display() )), execs().with_status(0) ); assert!(p.shim_exists("tsc")); assert!(p.shim_exists("tsserver")); assert!(p.package_is_installed("typescript")); assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 4.0.2") ); assert!(p.npm_version_is_fetched("6.4.0")); assert!(p.npm_version_is_unpacked("6.4.0")); p.assert_npm_version_is_installed("6.4.0"); assert_that!( p.npm("--version"), execs().with_status(0).with_stdout_contains("6.4.0") ); assert!(p.shim_exists("volta-test")); assert!(p.package_is_installed("volta-test")); assert_that!( p.exec_shim("volta-test", ""), execs() .with_status(0) .with_stdout_contains("Volta test successful") ); } ================================================ FILE: tests/smoke/direct_upgrade.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; use volta_core::error::ExitCode; #[test] fn npm_global_update() { let p = temp_project().build(); // Install Node and typescript assert_that!( p.volta("install node@14.10.1 typescript@2.8.4"), execs().with_status(0) ); // Confirm correct version of typescript installed assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 2.8.4") ); // Update typescript assert_that!(p.npm("update --global typescript"), execs().with_status(0)); // Confirm update completed successfully assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 2.9.2") ); // Revert typescript update assert_that!(p.npm("i -g typescript@2.8.4"), execs().with_status(0)); // Update all packages (should include typescript) assert_that!(p.npm("update --global"), execs().with_status(0)); // Confirm update assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 2.9.2") ); // Confirm that attempting to upgrade using `yarn` fails assert_that!( p.yarn("global upgrade typescript"), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]The package 'typescript' was installed using npm.") ); } #[test] fn yarn_global_update() { let p = temp_project().build(); // Install Node and Yarn assert_that!( p.volta("install node@14.10.1 yarn@1.22.5"), execs().with_status(0) ); // Install typescript assert_that!( p.yarn("global add typescript@2.8.4"), execs().with_status(0) ); // Confirm correct version of typescript installed assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 2.8.4") ); // Upgrade typescript assert_that!( p.yarn("global upgrade typescript@2.9"), execs().with_status(0) ); // Confirm upgrade completed successfully assert_that!( p.exec_shim("tsc", "--version"), execs().with_status(0).with_stdout_contains("Version 2.9.2") ); // Note: Since Yarn always installs the latest version that matches your requirements and // 'upgrade' also gets the latest version that matches (which can change over time), an // immediate call to 'yarn upgrade' without packages won't result in any change. // This is in contrast to npm, which treats your installed version as a caret specifier when // runnin `npm update` // Confirm that attempting to upgrade using `npm` fails assert_that!( p.npm("update -g typescript"), execs() .with_status(ExitCode::ExecutionFailure as i32) .with_stderr_contains("[..]The package 'typescript' was installed using Yarn.") ); } ================================================ FILE: tests/smoke/main.rs ================================================ // Smoke tests for Volta, that will be run in CI. // // To run these locally: // (CAUTION: this will destroy the Volta installation on the system where this is run) // // ``` // VOLTA_LOGLEVEL=debug cargo test --test smoke --features smoke-tests -- --test-threads 1 // ``` // // Also note that each test uses a different version of node and yarn. This is to prevent // false positives if the tests are not cleaned up correctly. Any new tests should use // different versions of node and yarn. cfg_if::cfg_if! { if #[cfg(all(unix, feature = "smoke-tests"))] { mod autodownload; mod direct_install; mod direct_upgrade; mod npm_link; mod package_migration; pub mod support; mod volta_fetch; mod volta_install; mod volta_run; } } ================================================ FILE: tests/smoke/npm_link.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; const PACKAGE_JSON: &str = r#" { "name": "my-library", "version": "1.0.0", "bin": { "mylibrary": "./index.js" } }"#; const INDEX_JS: &str = r#"#!/usr/bin/env node console.log('VOLTA TEST'); "#; #[test] fn link_unlink_local_project() { let p = temp_project() .package_json(PACKAGE_JSON) .project_file("index.js", INDEX_JS) .build(); // Install node to ensure npm is available assert_that!(p.volta("install node@14.15.1"), execs().with_status(0)); // Link the current project as a global assert_that!(p.npm("link"), execs().with_status(0)); // Executable should be available assert!(p.shim_exists("mylibrary")); assert!(p.package_is_installed("my-library")); assert_that!( p.exec_shim("mylibrary", ""), execs().with_status(0).with_stdout_contains("VOLTA TEST") ); // Unlink the current project assert_that!(p.npm("unlink"), execs().with_status(0)); // Executable should no longer be available assert!(!p.shim_exists("mylibrary")); assert!(!p.package_is_installed("my-library")); } #[test] fn link_global_into_current_project() { let p = temp_project().package_json(PACKAGE_JSON).build(); assert_that!( p.volta("install node@14.19.0 typescript@4.1.2"), execs().with_status(0) ); // Link typescript into the current project assert_that!(p.npm("link typescript"), execs().with_status(0)); // Typescript should now be available inside the node_modules directory assert!(p.project_path_exists("node_modules/typescript")); assert!(p.project_path_exists("node_modules/typescript/package.json")); } ================================================ FILE: tests/smoke/package_migration.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; const LEGACY_PACKAGE_CONFIG: &str = r#"{ "name": "cowsay", "version": "1.1.7", "platform": { "node": { "runtime": "14.18.2", "npm": null }, "yarn": null }, "bins": [ "cowsay", "cowthink" ] }"#; const LEGACY_BIN_CONFIG: &str = r#"{ "name": "cowsay", "package": "cowsay", "version": "1.1.7", "path": "./cli.js", "platform": { "node": { "runtime": "14.18.2", "npm": null }, "yarn": null }, "loader": { "command": "node", "args": [] } }"#; const COWSAY_HELLO: &str = r#" _______ < hello > ------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||"#; #[test] fn legacy_package_upgrade() { let p = temp_project() .volta_home_file("tools/user/packages/cowsay.json", LEGACY_PACKAGE_CONFIG) .volta_home_file("tools/user/bins/cowsay.json", LEGACY_BIN_CONFIG) .volta_home_file( "tools/image/packages/cowsay/1.3.1/README.md", "Mock of installed package", ) .volta_home_file("layout.v2", "") .build(); assert_that!(p.volta("--version"), execs().with_status(0)); assert!(p.package_is_installed("cowsay")); assert_that!( p.exec_shim("cowsay", "hello"), execs().with_status(0).with_stdout_contains(COWSAY_HELLO) ); } ================================================ FILE: tests/smoke/support/mod.rs ================================================ pub mod temp_project; ================================================ FILE: tests/smoke/support/temp_project.rs ================================================ use node_semver::Version; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use volta_core::fs::symlink_file; use volta_core::tool::Node; use test_support::{self, ok_or_panic, paths, paths::PathExt, process::ProcessBuilder}; #[derive(PartialEq, Clone)] pub struct FileBuilder { path: PathBuf, contents: String, } impl FileBuilder { pub fn new(path: PathBuf, contents: &str) -> FileBuilder { FileBuilder { path, contents: contents.to_string(), } } pub fn build(&self) { self.dirname().mkdir_p(); let mut file = File::create(&self.path) .unwrap_or_else(|e| panic!("could not create file {}: {}", self.path.display(), e)); ok_or_panic! { file.write_all(self.contents.as_bytes()) }; } fn dirname(&self) -> &Path { self.path.parent().unwrap() } } pub struct EnvVar { name: String, value: String, } impl EnvVar { pub fn new(name: &str, value: &str) -> Self { EnvVar { name: name.to_string(), value: value.to_string(), } } } #[must_use] pub struct TempProjectBuilder { root: TempProject, files: Vec, } impl TempProjectBuilder { /// Root of the project, ex: `/path/to/cargo/target/smoke_test/t0/foo` pub fn root(&self) -> PathBuf { self.root.root() } pub fn new(root: PathBuf) -> TempProjectBuilder { TempProjectBuilder { root: TempProject { root: root.clone(), path: OsString::new(), env_vars: vec![], }, files: vec![], } } /// Set the package.json for the temporary project (chainable) pub fn package_json(mut self, contents: &str) -> Self { let package_file = package_json_file(self.root()); self.files.push(FileBuilder::new(package_file, contents)); self } /// Create a file in the project directory (chainable) pub fn project_file(mut self, path: &str, contents: &str) -> Self { let path = self.root().join(path); self.files.push(FileBuilder::new(path, contents)); self } /// Create a file in the `volta_home` directory (chainable) pub fn volta_home_file(mut self, path: &str, contents: &str) -> Self { let path = volta_home(self.root()).join(path); self.files.push(FileBuilder::new(path, contents)); self } /// Set an environment variable (chainable) pub fn env(mut self, name: &str, value: &str) -> Self { self.root.env_vars.push(EnvVar::new(name, value)); self } /// Create the project pub fn build(mut self) -> TempProject { // First, clean the temporary project directory if it already exists self.rm_root(); // Create the empty directory self.root.root().mkdir_p(); // make sure these directories exist and are empty node_cache_dir(self.root()).ensure_empty(); volta_bin_dir(self.root()).ensure_empty(); node_inventory_dir(self.root()).ensure_empty(); yarn_inventory_dir(self.root()).ensure_empty(); package_inventory_dir(self.root()).ensure_empty(); node_image_root_dir(self.root()).ensure_empty(); yarn_image_root_dir(self.root()).ensure_empty(); package_image_root_dir(self.root()).ensure_empty(); default_toolchain_dir(self.root()).ensure_empty(); volta_tmp_dir(self.root()).ensure_empty(); // and these files do not exist volta_file(self.root()).rm(); shim_executable(self.root()).rm(); default_hooks_file(self.root()).rm(); default_platform_file(self.root()).rm(); // create symlinks to shim executable for node, yarn, npm, and packages ok_or_panic!(symlink_file(shim_exe(), self.root.node_exe())); ok_or_panic!(symlink_file(shim_exe(), self.root.yarn_exe())); ok_or_panic!(symlink_file(shim_exe(), self.root.npm_exe())); // write files for file_builder in self.files { file_builder.build(); } // prepend Volta bin dir to the PATH let current_path = envoy::path().expect("Could not get current PATH"); let new_path = current_path.split(); self.root.path = new_path .prefix_entry(volta_bin_dir(self.root.root())) .join() .expect("Failed to join paths"); let TempProjectBuilder { root, .. } = self; root } fn rm_root(&self) { self.root.root().rm_rf() } } // files and dirs in the temporary project fn home_dir(root: PathBuf) -> PathBuf { root.join("home") } fn volta_home(root: PathBuf) -> PathBuf { home_dir(root).join(".volta") } fn volta_file(root: PathBuf) -> PathBuf { volta_home(root).join("volta") } fn shim_executable(root: PathBuf) -> PathBuf { volta_bin_dir(root).join("volta-shim") } fn default_hooks_file(root: PathBuf) -> PathBuf { volta_home(root).join("hooks.json") } fn volta_tmp_dir(root: PathBuf) -> PathBuf { volta_home(root).join("tmp") } fn volta_bin_dir(root: PathBuf) -> PathBuf { volta_home(root).join("bin") } fn volta_tools_dir(root: PathBuf) -> PathBuf { volta_home(root).join("tools") } fn inventory_dir(root: PathBuf) -> PathBuf { volta_tools_dir(root).join("inventory") } fn default_toolchain_dir(root: PathBuf) -> PathBuf { volta_tools_dir(root).join("user") } fn image_dir(root: PathBuf) -> PathBuf { volta_tools_dir(root).join("image") } fn node_image_root_dir(root: PathBuf) -> PathBuf { image_dir(root).join("node") } fn node_image_dir(node: &str, root: PathBuf) -> PathBuf { node_image_root_dir(root).join(node) } fn node_image_bin_dir(node: &str, root: PathBuf) -> PathBuf { node_image_dir(node, root).join("bin") } fn npm_image_root_dir(root: PathBuf) -> PathBuf { image_dir(root).join("npm") } fn npm_image_dir(version: &str, root: PathBuf) -> PathBuf { npm_image_root_dir(root).join(version) } fn npm_image_bin_dir(version: &str, root: PathBuf) -> PathBuf { npm_image_dir(version, root).join("bin") } fn yarn_image_root_dir(root: PathBuf) -> PathBuf { image_dir(root).join("yarn") } fn yarn_image_dir(version: &str, root: PathBuf) -> PathBuf { yarn_image_root_dir(root).join(version) } fn package_image_root_dir(root: PathBuf) -> PathBuf { image_dir(root).join("packages") } fn node_inventory_dir(root: PathBuf) -> PathBuf { inventory_dir(root).join("node") } fn npm_inventory_dir(root: PathBuf) -> PathBuf { inventory_dir(root).join("npm") } fn yarn_inventory_dir(root: PathBuf) -> PathBuf { inventory_dir(root).join("yarn") } fn package_inventory_dir(root: PathBuf) -> PathBuf { inventory_dir(root).join("packages") } fn cache_dir(root: PathBuf) -> PathBuf { volta_home(root).join("cache") } fn node_cache_dir(root: PathBuf) -> PathBuf { cache_dir(root).join("node") } fn package_json_file(mut root: PathBuf) -> PathBuf { root.push("package.json"); root } fn shim_file(name: &str, root: PathBuf) -> PathBuf { volta_bin_dir(root).join(format!("{}{}", name, env::consts::EXE_SUFFIX)) } fn package_image_dir(name: &str, root: PathBuf) -> PathBuf { image_dir(root).join("packages").join(name) } fn default_platform_file(root: PathBuf) -> PathBuf { default_toolchain_dir(root).join("platform.json") } pub fn node_distro_file_name(version: &str) -> String { let version = Version::parse(version).unwrap(); Node::archive_filename(&version) } fn npm_distro_file_name(version: &str) -> String { package_distro_file_name("npm", version) } fn yarn_distro_file_name(version: &str) -> String { format!("yarn-v{}.tar.gz", version) } fn package_distro_file_name(name: &str, version: &str) -> String { format!("{}-{}.tgz", name, version) } pub struct TempProject { root: PathBuf, path: OsString, env_vars: Vec, } impl TempProject { /// Root of the project, ex: `/path/to/cargo/target/integration_test/t0/foo` pub fn root(&self) -> PathBuf { self.root.clone() } /// Create a `ProcessBuilder` to run a program in the project. /// Example: /// assert_that( /// p.process(&p.bin("foo")), /// execs().with_stdout("bar\n"), /// ); pub fn process>(&self, program: T) -> ProcessBuilder { let mut p = test_support::process::process(program); p.cwd(self.root()) // setup the Volta environment .env("PATH", &self.path) .env("HOME", home_dir(self.root())) .env("VOLTA_HOME", volta_home(self.root())) .env("VOLTA_INSTALL_DIR", cargo_dir()) .env_remove("VOLTA_NODE_VERSION") .env_remove("MSYSTEM"); // assume cmd.exe everywhere on windows // overrides for env vars for env_var in &self.env_vars { p.env(&env_var.name, &env_var.value); } p } /// Create a `ProcessBuilder` to run volta. /// Arguments can be separated by spaces. /// Example: /// assert_that(p.volta("use node 9.5"), execs()); pub fn volta(&self, cmd: &str) -> ProcessBuilder { let mut p = self.process(&volta_exe()); split_and_add_args(&mut p, cmd); p } /// Create a `ProcessBuilder` to run Node. pub fn node(&self, cmd: &str) -> ProcessBuilder { let mut p = self.process(&self.node_exe()); split_and_add_args(&mut p, cmd); p } pub fn node_exe(&self) -> PathBuf { volta_bin_dir(self.root()).join(format!("node{}", env::consts::EXE_SUFFIX)) } /// Create a `ProcessBuilder` to run Yarn. pub fn yarn(&self, cmd: &str) -> ProcessBuilder { let mut p = self.process(&self.yarn_exe()); split_and_add_args(&mut p, cmd); p } pub fn yarn_exe(&self) -> PathBuf { volta_bin_dir(self.root()).join(format!("yarn{}", env::consts::EXE_SUFFIX)) } /// Create a `ProcessBuilder` to run Npm. pub fn npm(&self, cmd: &str) -> ProcessBuilder { let mut p = self.process(&self.npm_exe()); split_and_add_args(&mut p, cmd); p } pub fn npm_exe(&self) -> PathBuf { volta_bin_dir(self.root()).join(format!("npm{}", env::consts::EXE_SUFFIX)) } /// Create a `ProcessBuilder` to run a package executable. pub fn exec_shim(&self, exe: &str, cmd: &str) -> ProcessBuilder { let shim_file = shim_file(exe, self.root()); let mut p = self.process(shim_file); split_and_add_args(&mut p, cmd); p } /// Verify that the input Node version has been fetched. pub fn node_version_is_fetched(&self, version: &str) -> bool { let distro_file_name = node_distro_file_name(version); let inventory_dir = node_inventory_dir(self.root()); inventory_dir.join(distro_file_name).exists() } /// Verify that the input Node version has been unpacked. pub fn node_version_is_unpacked(&self, version: &str) -> bool { let unpack_dir = node_image_bin_dir(version, self.root()); unpack_dir.exists() } /// Verify that the input Node version has been installed. pub fn assert_node_version_is_installed(&self, version: &str) -> () { let default_platform = default_platform_file(self.root()); let platform_contents = read_file_to_string(default_platform); let json_contents: serde_json::Value = serde_json::from_str(&platform_contents).expect("could not parse platform.json"); assert_eq!(json_contents["node"]["runtime"], version); } /// Verify that the input Yarn version has been fetched. pub fn yarn_version_is_fetched(&self, version: &str) -> bool { let distro_file_name = yarn_distro_file_name(version); let inventory_dir = yarn_inventory_dir(self.root()); inventory_dir.join(distro_file_name).exists() } /// Verify that the input Yarn version has been unpacked. pub fn yarn_version_is_unpacked(&self, version: &str) -> bool { let unpack_dir = yarn_image_dir(version, self.root()); unpack_dir.exists() } /// Verify that the input Yarn version has been installed. pub fn assert_yarn_version_is_installed(&self, version: &str) -> () { let default_platform = default_platform_file(self.root()); let platform_contents = read_file_to_string(default_platform); let json_contents: serde_json::Value = serde_json::from_str(&platform_contents).expect("could not parse platform.json"); assert_eq!(json_contents["yarn"], version); } /// Verify that the input Npm version has been fetched. pub fn npm_version_is_fetched(&self, version: &str) -> bool { let distro_file_name = npm_distro_file_name(version); let inventory_dir = npm_inventory_dir(self.root()); inventory_dir.join(distro_file_name).exists() } /// Verify that the input Npm version has been unpacked. pub fn npm_version_is_unpacked(&self, version: &str) -> bool { npm_image_bin_dir(version, self.root()).exists() } /// Verify that the input Npm version has been installed. pub fn assert_npm_version_is_installed(&self, version: &str) -> () { let default_platform = default_platform_file(self.root()); let platform_contents = read_file_to_string(default_platform); let json_contents: serde_json::Value = serde_json::from_str(&platform_contents).expect("could not parse platform.json"); assert_eq!(json_contents["node"]["npm"], version); } /// Verify that the input package has been installed pub fn package_is_installed(&self, name: &str) -> bool { let install_dir = package_image_dir(name, self.root()); install_dir.exists() } /// Verify that the input package version has been fetched. pub fn shim_exists(&self, name: &str) -> bool { shim_file(name, self.root()).exists() } /// Verify that a given path in the project directory exists pub fn project_path_exists(&self, path: &str) -> bool { self.root().join(path).exists() } } impl Drop for TempProject { fn drop(&mut self) { self.root().rm_rf(); } } // Generates a temporary project environment pub fn temp_project() -> TempProjectBuilder { TempProjectBuilder::new(paths::root().join("temp-project")) } // Path to compiled executables pub fn cargo_dir() -> PathBuf { env::var_os("CARGO_BIN_PATH") .map(PathBuf::from) .or_else(|| { env::current_exe().ok().map(|mut path| { path.pop(); if path.ends_with("deps") { path.pop(); } path }) }) .unwrap_or_else(|| panic!("CARGO_BIN_PATH wasn't set. Cannot continue running test")) } fn volta_exe() -> PathBuf { cargo_dir().join(format!("volta{}", env::consts::EXE_SUFFIX)) } fn shim_exe() -> PathBuf { cargo_dir().join(format!("volta-shim{}", env::consts::EXE_SUFFIX)) } fn split_and_add_args(p: &mut ProcessBuilder, s: &str) { for arg in s.split_whitespace() { if arg.contains('"') || arg.contains('\'') { panic!("shell-style argument parsing is not supported") } p.arg(arg); } } fn read_file_to_string(file_path: PathBuf) -> String { let mut contents = String::new(); let mut file = ok_or_panic! { File::open(file_path) }; ok_or_panic! { file.read_to_string(&mut contents) }; contents } ================================================ FILE: tests/smoke/volta_fetch.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; #[test] fn fetch_node() { let p = temp_project().build(); assert_that!(p.volta("fetch node@14.17.6"), execs().with_status(0)); assert!(p.node_version_is_fetched("14.17.6")); assert!(p.node_version_is_unpacked("14.17.6")); } #[test] fn fetch_yarn_1() { let p = temp_project().build(); assert_that!(p.volta("fetch yarn@1.22.1"), execs().with_status(0)); assert!(p.yarn_version_is_fetched("1.22.1")); assert!(p.yarn_version_is_unpacked("1.22.1")); } #[test] fn fetch_yarn_3() { let p = temp_project().build(); assert_that!(p.volta("fetch yarn@3.2.0"), execs().with_status(0)); assert!(p.yarn_version_is_fetched("3.2.0")); assert!(p.yarn_version_is_unpacked("3.2.0")); } #[test] fn fetch_npm() { let p = temp_project().build(); assert_that!(p.volta("fetch npm@8.3.1"), execs().with_status(0)); assert!(p.npm_version_is_fetched("8.3.1")); assert!(p.npm_version_is_unpacked("8.3.1")); } ================================================ FILE: tests/smoke/volta_install.rs ================================================ use std::thread; use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; #[test] fn install_node() { let p = temp_project().build(); assert_that!(p.volta("install node@14.15.4"), execs().with_status(0)); assert_that!( p.node("--version"), execs().with_status(0).with_stdout_contains("v14.15.4") ); assert!(p.node_version_is_fetched("14.15.4")); assert!(p.node_version_is_unpacked("14.15.4")); p.assert_node_version_is_installed("14.15.4"); } #[test] fn install_node_lts() { let p = temp_project().build(); assert_that!(p.volta("install node@lts"), execs().with_status(0)); assert_that!(p.node("--version"), execs().with_status(0)); } #[test] fn install_node_concurrent() { let p = temp_project().build(); let install = p.volta("install node@14.17.2"); let run = p.node("--version"); let concurrent_thread = thread::spawn(move || { assert_that!(install, execs().with_status(0)); assert_that!(run, execs().with_status(0)); }); assert_that!(p.volta("install node@14.17.2"), execs().with_status(0)); assert_that!(p.node("--version"), execs().with_status(0)); assert!(concurrent_thread.join().is_ok()); } #[test] fn install_yarn() { let p = temp_project().build(); assert_that!(p.volta("install node@14.15.2"), execs().with_status(0)); assert_that!(p.volta("install yarn@1.22.1"), execs().with_status(0)); assert_that!( p.yarn("--version"), execs().with_status(0).with_stdout_contains("1.22.1") ); assert!(p.yarn_version_is_fetched("1.22.1")); assert!(p.yarn_version_is_unpacked("1.22.1")); p.assert_yarn_version_is_installed("1.22.1"); } #[test] fn install_old_yarn() { let p = temp_project().build(); assert_that!(p.volta("install node@14.11.0"), execs().with_status(0)); // Yarn 1.9.2 is old enough that it is no longer on the first page of results from the GitHub API assert_that!(p.volta("install yarn@1.9.2"), execs().with_status(0)); assert_that!( p.yarn("--version"), execs().with_status(0).with_stdout_contains("1.9.2") ); assert!(p.yarn_version_is_fetched("1.9.2")); assert!(p.yarn_version_is_unpacked("1.9.2")); p.assert_yarn_version_is_installed("1.9.2"); } #[test] fn install_yarn_concurrent() { let p = temp_project().build(); assert_that!(p.volta("install node@14.19.0"), execs().with_status(0)); let install = p.volta("install yarn@1.17.0"); let run = p.yarn("--version"); let concurrent_thread = thread::spawn(move || { assert_that!(install, execs().with_status(0)); assert_that!(run, execs().with_status(0)); }); assert_that!(p.volta("install yarn@1.17.0"), execs().with_status(0)); assert_that!(p.yarn("--version"), execs().with_status(0)); assert!(concurrent_thread.join().is_ok()); } #[test] fn install_npm() { let p = temp_project().build(); // node 17.6.0 is bundled with npm 8.5.1 assert_that!(p.volta("install node@17.6.0"), execs().with_status(0)); assert_that!( p.npm("--version"), execs().with_status(0).with_stdout_contains("8.5.1") ); // install npm 6.8.0 and verify that is installed correctly assert_that!(p.volta("install npm@8.5.5"), execs().with_status(0)); assert!(p.npm_version_is_fetched("8.5.5")); assert!(p.npm_version_is_unpacked("8.5.5")); p.assert_npm_version_is_installed("8.5.5"); assert_that!( p.npm("--version"), execs().with_status(0).with_stdout_contains("8.5.5") ); } #[test] fn install_npm_concurrent() { let p = temp_project().build(); assert_that!(p.volta("install node@14.5.0"), execs().with_status(0)); let install = p.volta("install npm@6.14.2"); let run = p.npm("--version"); let concurrent_thread = thread::spawn(move || { assert_that!(install, execs().with_status(0)); assert_that!(run, execs().with_status(0)); }); assert_that!(p.volta("install npm@6.14.2"), execs().with_status(0)); assert_that!(p.npm("--version"), execs().with_status(0)); assert!(concurrent_thread.join().is_ok()); } const COWSAY_HELLO: &'static str = r#" _______ < hello > ------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||"#; #[test] fn install_package() { let p = temp_project().build(); // have to install node first, because we need npm assert_that!(p.volta("install node@14.11.0"), execs().with_status(0)); assert_that!(p.volta("install cowsay@1.4.0"), execs().with_status(0)); assert!(p.shim_exists("cowsay")); assert!(p.package_is_installed("cowsay")); assert_that!( p.exec_shim("cowsay", "hello"), execs().with_status(0).with_stdout_contains(COWSAY_HELLO) ); } #[test] fn install_package_concurrent() { let p = temp_project().build(); assert_that!(p.volta("install node@14.14.0"), execs().with_status(0)); let install = p.volta("install cowsay@1.3.0"); let run = p.exec_shim("cowsay", "hello"); let concurrent_thread = thread::spawn(move || { assert_that!(install, execs().with_status(0)); assert_that!(run, execs().with_status(0)); }); assert_that!(p.volta("install cowsay@1.3.0"), execs().with_status(0)); assert_that!(p.exec_shim("cowsay", "hello"), execs().with_status(0)); assert!(concurrent_thread.join().is_ok()); } #[test] fn install_scoped_package() { let p = temp_project().build(); // have to install node first, because we need npm assert_that!(p.volta("install node@14.15.0"), execs().with_status(0)); assert_that!(p.volta("install @wdio/cli@5.12.4"), execs().with_status(0)); assert!(p.shim_exists("wdio")); assert!(p.package_is_installed("@wdio/cli")); assert_that!( p.exec_shim("wdio", "--version"), execs().with_status(0).with_stdout_contains("5.12.4") ); } #[test] fn install_package_tag_version() { let p = temp_project().build(); // have to install node first, because we need npm assert_that!(p.volta("install node@14.8.0"), execs().with_status(0)); assert_that!(p.volta("install elm@elm0.19.0"), execs().with_status(0)); assert!(p.shim_exists("elm")); assert_that!( p.exec_shim("elm", "--version"), execs().with_status(0).with_stdout_contains("0.19.0") ); } ================================================ FILE: tests/smoke/volta_run.rs ================================================ use crate::support::temp_project::temp_project; use hamcrest2::assert_that; use hamcrest2::prelude::*; use test_support::matchers::execs; // Note: Node 14.11.0 is bundled with npm 6.14.8 const PACKAGE_JSON: &str = r#"{ "name": "test-package", "volta": { "node": "14.11.0", "npm": "6.14.15", "yarn": "1.22.10" } }"#; #[test] fn run_node() { let p = temp_project().build(); assert_that!( p.volta("run --node 14.16.0 node --version"), execs().with_status(0).with_stdout_contains("v14.16.0") ); } #[test] fn run_npm() { let p = temp_project().build(); assert_that!( p.volta("run --node 14.14.0 --npm 6.14.16 npm --version"), execs().with_status(0).with_stdout_contains("6.14.16") ) } #[test] fn run_yarn_1() { let p = temp_project().build(); assert_that!( p.volta("run --node 14.16.1 --yarn 1.22.0 yarn --version"), execs().with_status(0).with_stdout_contains("1.22.0") ); } #[test] fn run_yarn_3() { let p = temp_project().build(); assert_that!( p.volta("run --node 16.14.1 --yarn 3.1.1 yarn --version"), execs().with_status(0).with_stdout_contains("3.1.1") ); } #[test] fn inherits_project_platform() { let p = temp_project().package_json(PACKAGE_JSON).build(); assert_that!( p.volta("run --yarn 1.21.0 yarn --version"), execs().with_status(0).with_stdout_contains("1.21.0") ); } #[test] fn run_environment() { let p = temp_project().build(); assert_that!( p.volta("run --node 14.15.3 --env VOLTA_SMOKE_1234=hello node -e console.log(process.env.VOLTA_SMOKE_1234)"), execs().with_status(0).with_stdout_contains("hello") ); } ================================================ FILE: volta.iml ================================================ ================================================ FILE: wix/main.wxs ================================================ 1 1 ================================================ FILE: wix/shim.cmd ================================================ @echo off "%~dpn0.exe" %*