From 3840080534e4fab8cef8fa741a9dc0d67d888e3d Mon Sep 17 00:00:00 2001 From: Your Name <56168280+axcel-blade@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:30:55 +0800 Subject: [PATCH] bump version to 0.16.0 and add npm and yarn plugins, GitHub rate-limit fallback, and uppercase markdown filenames - Add npm plugin: latest npm from the npm registry with npm / npx wrappers (needs Node.js) - Add yarn plugin: latest Yarn classic from the npm registry with yarn / yarnpkg wrappers (needs Node.js) - Add shared node_pm helper module for package managers shipped as npm registry tarballs - Add github_latest_tag_redirect and github_latest_asset helpers that fall back to the github.com releases/latest redirect when the GitHub API rate limit is exhausted - Use the fallback in pnpm, deno, bun, and uv (fixes "Failed to query GitHub release") - Rename all markdown files to uppercase and update every link - Update README, docs, wiki, SECURITY, and CHANGELOG; plugin count 35 -> 37 --- CHANGELOG.md | 30 +++++++++++ CONTRIBUTING.md | 4 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +++--- SECURITY.md | 2 +- SUPPORT.md | 2 +- docs/architecture.md | 4 +- docs/getting-started.md | 2 +- docs/index.md | 8 +-- docs/plugins.md | 7 ++- src/plugin_utils.rs | 57 ++++++++++++++++++++ src/plugins/bun.rs | 29 ++-------- src/plugins/deno.rs | 20 +++---- src/plugins/mod.rs | 5 ++ src/plugins/node_pm.rs | 115 ++++++++++++++++++++++++++++++++++++++++ src/plugins/npm.rs | 80 ++++++++++++++++++++++++++++ src/plugins/pnpm.rs | 28 +++------- src/plugins/uv.rs | 5 +- src/plugins/yarn.rs | 80 ++++++++++++++++++++++++++++ wiki/Home.md | 4 +- wiki/Installation.md | 2 +- wiki/Plugins.md | 6 +-- 23 files changed, 422 insertions(+), 88 deletions(-) create mode 100644 src/plugins/node_pm.rs create mode 100644 src/plugins/npm.rs create mode 100644 src/plugins/yarn.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 431585e..d6aa7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] - 2026-10-30 + +### Added + +- `npm` plugin: installs the latest npm from the npm registry into the + DevKit folder with `npm` / `npx` wrappers. Needs Node.js (installs the + `node` plugin first when `node` is missing). +- `yarn` plugin: installs the latest Yarn classic from the npm registry + with `yarn` / `yarnpkg` wrappers. Needs Node.js. +- Shared `node_pm` helper module for package managers distributed as npm + registry tarballs. + +### Changed + +- All Markdown filenames are now uppercase (e.g. `docs/PLUGINS.md`, + `docs/GETTING-STARTED.md`, `wiki/HOME.md`, issue and discussion + templates), and every link points to the new names. + +### Fixed + +- Installing pnpm no longer fails with "Failed to query GitHub release + pnpm/pnpm" when the GitHub API rate limit (60 requests/hour + unauthenticated) is used up. pnpm now falls back to the github.com + `releases/latest` redirect, which is not rate-limited, via the new shared + `github_latest_tag_redirect` helper. +- Deno, Bun, and uv get the same fallback. All four plugins now resolve + their download through the shared `github_latest_asset` helper (API + first, redirect fallback second). + ## [0.15.4] - 2026-10-07 ### Fixed @@ -444,6 +473,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial project scaffold from auto-gen-py-project +[0.16.0]: https://github.com/axcel-blade/DevKit/releases/tag/v0.16.0 [0.15.4]: https://github.com/axcel-blade/DevKit/releases/tag/v0.15.4 [0.15.3]: https://github.com/axcel-blade/DevKit/releases/tag/v0.15.3 [0.15.2]: https://github.com/axcel-blade/DevKit/releases/tag/v0.15.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fc9ef8..583c30e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,9 +82,9 @@ cargo run --release -- doctor 2. Implement `status`, `install`, `uninstall`, and `env_spec`. Override `supported_os` when the plugin does not run on every desktop OS. 3. Register the plugin in `crate::plugins::all()` (`src/plugins/mod.rs`). 4. Add tests in a `#[cfg(test)] mod tests` block at the bottom of the file. -5. Document the plugin in `README.md` and `docs/plugins.md`. +5. Document the plugin in `README.md` and `docs/PLUGINS.md`. -See [docs/plugins.md](docs/plugins.md). +See [docs/PLUGINS.md](docs/PLUGINS.md). ## Commit messages diff --git a/Cargo.lock b/Cargo.lock index b9554b0..fadb36d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "devkit" -version = "0.15.4" +version = "0.16.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index ada2abc..d521ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devkit" -version = "0.15.4" +version = "0.16.0" edition = "2021" description = "DevKit application — install developer SDKs and configure environment variables" license = "MIT" diff --git a/README.md b/README.md index c361ed2..24bdd87 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # DevKit -[![Version](https://img.shields.io/badge/version-0.15.3-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.16.0-blue.svg)](CHANGELOG.md) [![CI](https://github.com/axcel-blade/DevKit/actions/workflows/ci.yml/badge.svg)](https://github.com/axcel-blade/DevKit/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md) [![Rust](https://img.shields.io/badge/rust-2021_edition-orange.svg?logo=rust)](https://www.rust-lang.org/) -[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](docs/getting-started.md) -[![Plugins](https://img.shields.io/badge/plugins-35-blueviolet.svg)](docs/plugins.md) +[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](docs/GETTING-STARTED.md) +[![Plugins](https://img.shields.io/badge/plugins-37-blueviolet.svg)](docs/PLUGINS.md) DevKit is a **CLI application** that sets up developer environments on Windows, macOS, and Linux: download an SDK, install it under a machine `dev` folder, and configure PATH / environment variables through plugins. @@ -31,7 +31,7 @@ Checking for available versions ... DevKit OS: Windows -Version: 0.15.4 +Version: 0.16.0 # ID NAME STATUS INSTALLED AVAILABLE -------------------------------------------------------------------------------------------------------- 1 anaconda Anaconda not_installed - 2026.07-1 @@ -131,6 +131,7 @@ is where the menu lists that plugin and where `devkit install` accepts it. | `mysql` | Windows, macOS, Linux | MySQL 8.4 LTS portable | | `ninja` | Windows, macOS, Linux | Latest Ninja binary | | `node` | Windows, macOS, Linux | Latest Node.js LTS (npm / npx) | +| `npm` | Windows, macOS, Linux | Latest standalone npm / npx (needs Node.js) | | `php` | Windows, macOS, Linux | PHP NTS (Windows); system wrappers (Unix) | | `platform-tools` | Windows, macOS, Linux | Android platform-tools (`adb`) | | `pmd` | Windows, macOS, Linux | PMD Source Code Analyzer binary (`--version`) | @@ -142,6 +143,7 @@ is where the menu lists that plugin and where `devkit install` accepts it. | `sqlite` | Windows, macOS, Linux | Official sqlite-tools CLI | | `terraform` | Windows, macOS, Linux | Latest Terraform | | `uv` | Windows, macOS, Linux | Latest uv and uvx | +| `yarn` | Windows, macOS, Linux | Latest Yarn classic (needs Node.js) | Default install root: @@ -154,9 +156,9 @@ Override with `DEVKIT_HOME`. Open a **new terminal** after install so PATH updat | Doc | Purpose | |-----|---------| -| [docs/getting-started.md](docs/getting-started.md) | Run the app and the Makefile targets | -| [docs/plugins.md](docs/plugins.md) | Write a plugin | -| [docs/architecture.md](docs/architecture.md) | How DevKit works | +| [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) | Run the app and the Makefile targets | +| [docs/PLUGINS.md](docs/PLUGINS.md) | Write a plugin | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | How DevKit works | | [CHANGELOG.md](CHANGELOG.md) | Version history | | [ROADMAP.md](ROADMAP.md) | Future plans | | [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute | diff --git a/SECURITY.md b/SECURITY.md index 10246cf..43b03a0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ time for investigation before any public disclosure. ## Security notes for users -- DevKit downloads third-party SDK archives (Flutter, Temurin, Mono, Composer, Git for Windows / MinGit, GNU Make, Chocolatey CLI, Gradle, JUnit, PMD, PHP, MySQL, Node.js, Android cmdline-tools / platform-tools / NDK, Python, Go, Rust, .NET, CMake, Ninja, Maven, PostgreSQL, SQLite, kubectl, Terraform, pnpm, Deno, Bun, uv, Anaconda, Docker CLI). +- DevKit downloads third-party SDK archives (Flutter, Temurin, Mono, Composer, Git for Windows / MinGit, GNU Make, Chocolatey CLI, Gradle, JUnit, PMD, PHP, MySQL, Node.js, Android cmdline-tools / platform-tools / NDK, Python, Go, Rust, .NET, CMake, Ninja, Maven, PostgreSQL, SQLite, kubectl, Terraform, npm, Yarn, pnpm, Deno, Bun, uv, Anaconda, Docker CLI). Only use trusted networks when installing. - On Windows, GNU Make is the Chocolatey `make` 4.4.1 package, unpacked into the DevKit install folder. DevKit does not run an elevated `choco install`. - Plugins may modify **user** environment variables (Windows registry or shell profiles). diff --git a/SUPPORT.md b/SUPPORT.md index af3408b..be6a41a 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,7 +2,7 @@ ## How to get help -1. Read [README.md](README.md) and [docs/getting-started.md](docs/getting-started.md). +1. Read [README.md](README.md) and [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md). 2. Run diagnostics: ```bash diff --git a/docs/architecture.md b/docs/architecture.md index 9c65427..d97d2f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -DevKit **0.15.4** is a CLI application with a plugin pipeline. +DevKit **0.16.0** is a CLI application with a plugin pipeline. ```text target/release/devkit (binary) / devkit.bat / devkit.sh @@ -66,7 +66,7 @@ Checking for available versions ... DevKit OS: Windows -Version: 0.15.4 +Version: 0.16.0 # ID NAME STATUS INSTALLED AVAILABLE -------------------------------------------------------------------------------------------------------- 1 anaconda Anaconda not_installed - 2026.07-1 diff --git a/docs/getting-started.md b/docs/getting-started.md index e2c5bc0..23c8adf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,7 +72,7 @@ Checking for available versions ... DevKit OS: Windows -Version: 0.15.4 +Version: 0.16.0 # ID NAME STATUS INSTALLED AVAILABLE -------------------------------------------------------------------------------------------------------- 1 anaconda Anaconda not_installed - 2026.07-1 diff --git a/docs/index.md b/docs/index.md index e3637a3..fbc109d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,11 @@ # Documentation -DevKit **0.15.4** documentation index. +DevKit **0.16.0** documentation index. | Page | Description | |------|-------------| -| [getting-started.md](getting-started.md) | Install requirements, Makefile targets, and how to run the app | -| [plugins.md](plugins.md) | Built-in plugins and how to write your own | -| [architecture.md](architecture.md) | Internals: registry, env, downloads | +| [GETTING-STARTED.md](GETTING-STARTED.md) | Install requirements, Makefile targets, and how to run the app | +| [PLUGINS.md](PLUGINS.md) | Built-in plugins and how to write your own | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Internals: registry, env, downloads | Also see the repository root: README (Makefile targets and `make bat` / `make sh`), CHANGELOG, CONTRIBUTING, SECURITY, SUPPORT, ROADMAP, TODO. diff --git a/docs/plugins.md b/docs/plugins.md index 631b32b..9c427d9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,6 +1,6 @@ # Plugins -DevKit **0.15.4** ships these built-in plugins. The OS column is where the menu lists the plugin and where `devkit install` accepts it. +DevKit **0.16.0** ships these built-in plugins. The OS column is where the menu lists the plugin and where `devkit install` accepts it. | ID | OS | Notes | |----|----|-------| @@ -9,7 +9,9 @@ DevKit **0.15.4** ships these built-in plugins. The OS column is where the menu | `rust` | Windows, macOS, Linux | rustup stable into `CARGO_HOME` / `RUSTUP_HOME` | | `dotnet` | Windows, macOS, Linux | .NET SDK 10.0 LTS ZIP from Microsoft release metadata | | `node` | Windows, macOS, Linux | Latest Node.js LTS (npm / npx) | +| `npm` | Windows, macOS, Linux | Latest npm tarball from the npm registry + `npm` / `npx` wrappers; needs Node.js | | `pnpm` | Windows, macOS, Linux | Latest pnpm standalone ZIP/tar from GitHub | +| `yarn` | Windows, macOS, Linux | Latest Yarn classic tarball from the npm registry + `yarn` / `yarnpkg` wrappers; needs Node.js | | `deno` | Windows, macOS, Linux | Latest Deno ZIP from GitHub | | `bun` | Windows, macOS, Linux | Latest Bun ZIP from GitHub | | `uv` | Windows, macOS, Linux | Latest uv and uvx archive from GitHub | @@ -107,7 +109,7 @@ Checking for available versions ... DevKit OS: Windows -Version: 0.15.4 +Version: 0.16.0 # ID NAME STATUS INSTALLED AVAILABLE -------------------------------------------------------------------------------------------------------- 1 anaconda Anaconda not_installed - 2026.07-1 @@ -147,6 +149,7 @@ it installs that plugin first, prerequisites of prerequisites included. | `android`, `gradle`, `junit`, `maven`, `pmd` | `jdk` (`java`) | | `composer` | `php` (`php`) | | `flutter` | `git` (`git`) | +| `npm`, `yarn` | `node` (`node`) | Updates (`u` / `a`) do not install prerequisites again. diff --git a/src/plugin_utils.rs b/src/plugin_utils.rs index 70ac209..8e9261c 100644 --- a/src/plugin_utils.rs +++ b/src/plugin_utils.rs @@ -31,6 +31,63 @@ pub fn github_release_by_tag(owner: &str, repo: &str, tag: &str) -> Result Result { + crate::download::ensure_internet_access()?; + let url = format!("https://github.com/{owner}/{repo}/releases/latest"); + let resp = ureq::get(&url) + .set("User-Agent", "DevKit") + .call() + .with_context(|| format!("Failed to open {url}"))?; + // Final URL looks like .../releases/tag/v10.0.0 + match resp.get_url().rsplit_once("/tag/") { + Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()), + _ => bail!("Could not read latest release tag for {owner}/{repo}"), + } +} + +/// Return `(download_url, tag)` for the asset named exactly `asset` in the +/// latest `owner/repo` release. +/// +/// Tries the GitHub API first; if that fails (e.g. the unauthenticated rate +/// limit is exhausted), reads the tag from [`github_latest_tag_redirect`] and +/// builds the standard `releases/download//` URL instead. +pub fn github_latest_asset(owner: &str, repo: &str, asset: &str) -> Result<(String, String)> { + let api = github_latest_release(owner, repo).and_then(|release| { + let tag = release + .get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + release + .get("assets") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter(|a| a.get("name").and_then(|v| v.as_str()) == Some(asset)) + .find_map(|a| a.get("browser_download_url").and_then(|v| v.as_str())) + .filter(|url| !url.is_empty()) + .map(|url| (url.to_string(), tag.clone())) + .with_context(|| format!("No GitHub asset named {asset} in {owner}/{repo} {tag}")) + }); + match api { + Ok(found) => Ok(found), + Err(api_err) => { + let tag = github_latest_tag_redirect(owner, repo).map_err(|fallback_err| { + anyhow::anyhow!( + "{api_err:#} +Fallback lookup also failed: {fallback_err:#}" + ) + })?; + let url = format!("https://github.com/{owner}/{repo}/releases/download/{tag}/{asset}"); + Ok((url, tag)) + } + } +} + /// Return `(browser_download_url, tag_name)` for the first matching asset name. pub fn pick_release_asset(release: &Value, name_substrings: &[&str]) -> Result<(String, String)> { let tag = release diff --git a/src/plugins/bun.rs b/src/plugins/bun.rs index 5a556ec..06332ac 100644 --- a/src/plugins/bun.rs +++ b/src/plugins/bun.rs @@ -3,9 +3,7 @@ use crate::download::install_archive_from_url; use crate::platform::{cpu_arch, current_os, is_windows, HostOS}; use crate::plugin::{EnvSpec, InstallContext, InstallResult, Plugin, PluginStatus}; -use crate::plugin_utils::{ - binary_status, find_files_named, github_latest_release, pick_release_asset, -}; +use crate::plugin_utils::{binary_status, find_files_named, github_latest_asset}; use crate::progress::download_progress; use anyhow::{bail, Result}; use std::path::PathBuf; @@ -13,7 +11,6 @@ use std::path::PathBuf; const MARKER: &str = ".devkit-bun"; fn resolve_bun_download() -> Result<(String, String)> { - let release = github_latest_release("oven-sh", "bun")?; let host = current_os(); let arch = cpu_arch(); let needle = match host { @@ -41,28 +38,8 @@ fn resolve_bun_download() -> Result<(String, String)> { HostOS::Other => bail!("Bun is not supported on this OS: other"), }; - // Prefer exact asset name (skip -profile / -baseline variants). - if let Some(assets) = release.get("assets").and_then(|v| v.as_array()) { - for asset in assets { - if asset.get("name").and_then(|v| v.as_str()) == Some(needle) { - let url = asset - .get("browser_download_url") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tag = release - .get("tag_name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - if !url.is_empty() { - return Ok((url, tag)); - } - } - } - } - let stem = needle.trim_end_matches(".zip"); - pick_release_asset(&release, &[stem, ".zip"]) + // Exact name skips the -profile / -baseline variants. + github_latest_asset("oven-sh", "bun", needle) } /// ZIP often contains bun-/bun(.exe); after strip or nested search. diff --git a/src/plugins/deno.rs b/src/plugins/deno.rs index 71b0b9b..7c13928 100644 --- a/src/plugins/deno.rs +++ b/src/plugins/deno.rs @@ -3,7 +3,7 @@ use crate::download::install_archive_from_url; use crate::platform::{cpu_arch, current_os, is_windows, HostOS}; use crate::plugin::{EnvSpec, InstallContext, InstallResult, Plugin, PluginStatus}; -use crate::plugin_utils::{binary_status, github_latest_release, pick_release_asset}; +use crate::plugin_utils::{binary_status, github_latest_asset}; use crate::progress::download_progress; use anyhow::{bail, Result}; use std::path::PathBuf; @@ -11,33 +11,33 @@ use std::path::PathBuf; const MARKER: &str = ".devkit-deno"; fn resolve_deno_download() -> Result<(String, String)> { - let release = github_latest_release("denoland", "deno")?; let host = current_os(); let arch = cpu_arch(); - match host { + let asset = match host { HostOS::Windows => { if arch == "aarch64" { - pick_release_asset(&release, &["deno-aarch64-pc-windows-msvc.zip"]) + "deno-aarch64-pc-windows-msvc.zip" } else { - pick_release_asset(&release, &["deno-x86_64-pc-windows-msvc.zip"]) + "deno-x86_64-pc-windows-msvc.zip" } } HostOS::Linux => { if arch == "aarch64" { - pick_release_asset(&release, &["deno-aarch64-unknown-linux-gnu.zip"]) + "deno-aarch64-unknown-linux-gnu.zip" } else { - pick_release_asset(&release, &["deno-x86_64-unknown-linux-gnu.zip"]) + "deno-x86_64-unknown-linux-gnu.zip" } } HostOS::MacOS => { if arch == "aarch64" { - pick_release_asset(&release, &["deno-aarch64-apple-darwin.zip"]) + "deno-aarch64-apple-darwin.zip" } else { - pick_release_asset(&release, &["deno-x86_64-apple-darwin.zip"]) + "deno-x86_64-apple-darwin.zip" } } HostOS::Other => bail!("Deno is not supported on this OS: other"), - } + }; + github_latest_asset("denoland", "deno", asset) } fn deno_bin(ctx: &InstallContext) -> PathBuf { diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index d24b391..7936fde 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -27,6 +27,8 @@ pub mod msys2; pub mod mysql; pub mod ninja; pub mod node; +pub mod node_pm; +pub mod npm; pub mod php; pub mod platform_tools; pub mod pmd; @@ -38,6 +40,7 @@ pub mod rust; pub mod sqlite; pub mod terraform; pub mod uv; +pub mod yarn; use crate::plugin::Plugin; @@ -68,6 +71,7 @@ pub fn all() -> Vec> { Box::new(mysql::MysqlPlugin), Box::new(ninja::NinjaPlugin), Box::new(node::NodePlugin), + Box::new(npm::NpmPlugin), Box::new(php::PhpPlugin), Box::new(platform_tools::PlatformToolsPlugin), Box::new(pmd::PmdPlugin), @@ -79,6 +83,7 @@ pub fn all() -> Vec> { Box::new(sqlite::SqlitePlugin), Box::new(terraform::TerraformPlugin), Box::new(uv::UvPlugin), + Box::new(yarn::YarnPlugin), ] } diff --git a/src/plugins/node_pm.rs b/src/plugins/node_pm.rs new file mode 100644 index 0000000..8ab8e48 --- /dev/null +++ b/src/plugins/node_pm.rs @@ -0,0 +1,115 @@ +//! Shared helpers for Node.js package managers shipped as npm registry +//! tarballs (npm, Yarn). Each one is unpacked into its install dir and run +//! through small `node` wrapper scripts, so Node.js must be on PATH. + +use crate::download::{download_json_value, install_archive_from_url}; +use crate::platform::is_windows; +use crate::plugin::{InstallContext, InstallResult}; +use crate::progress::download_progress; +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +/// Return `(version, tarball_url)` for the `latest` dist-tag of `package`. +pub fn registry_latest(package: &str) -> Result<(String, String)> { + let url = format!("https://registry.npmjs.org/{package}/latest"); + let data = download_json_value(&url) + .with_context(|| format!("Failed to query npm registry for {package}"))?; + let version = data + .get("version") + .and_then(|v| v.as_str()) + .context("npm registry response has no version")? + .to_string(); + let tarball = data + .get("dist") + .and_then(|d| d.get("tarball")) + .and_then(|v| v.as_str()) + .context("npm registry response has no dist.tarball")? + .to_string(); + Ok((version, tarball)) +} + +/// Path of the launcher DevKit writes for `command` (`.cmd` on Windows). +pub fn wrapper_path(install_dir: &Path, command: &str) -> PathBuf { + if is_windows() { + install_dir.join(format!("{command}.cmd")) + } else { + install_dir.join(command) + } +} + +/// Write a launcher `command` that runs `node /