diff --git a/README.md b/README.md index 17b6bc3..65ce981 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,9 @@ version_regex = 'uv ([\d.]+)' --- -### `type = "npm"` — npm グローバルパッケージ +### `type = "npm"` / `type = "bun"` — npm / Bun グローバルパッケージ -`npm install -g` / `npm update -g` で管理したいツールに使用します。 +npm または Bun のグローバルパッケージとして管理したいツールに使用します。 ```toml [tools.markdownlint-cli2] @@ -200,16 +200,21 @@ type = "npm" package = "typescript" version_cmd = ["tsc", "--version"] version_regex = 'Version ([\d.]+)' + +[tools.prettier] +type = "bun" ``` | フィールド | 必須 | 説明 | | --- | --- | --- | | `bin` | ✓ | バイナリ名 | -| `package` | | npm パッケージ名(省略時は `bin` を使用) | +| `package` | | npm / Bun パッケージ名(省略時は `bin` を使用) | | `version_cmd` | | バージョン確認コマンド(デフォルト: `[bin, "--version"]`) | | `version_regex` | | バージョン文字列を抽出する正規表現 | -`ptm check` / `ptm update` では `npm view version` を使って npm レジストリ上の最新版と比較します。 +`type = "npm"` では `npm install -g ` / `npm update -g ` を実行します。 +`type = "bun"` では `bun install -g ` / `bun update -g ` を実行します。 +どちらも npm registry の metadata API を使って最新版と比較します。 --- diff --git a/src/ptm/installer.py b/src/ptm/installer.py index ad0df47..8dd82a8 100644 --- a/src/ptm/installer.py +++ b/src/ptm/installer.py @@ -15,6 +15,7 @@ from ptm.config import BIN_DIR from ptm.console import console from ptm.models import InstallPlan, ToolSpec +from ptm.package_managers import get_package_manager, is_npm_registry_package_type from ptm.resolver import get_installed_version, resolve_install_plan @@ -183,9 +184,14 @@ def _run_installer(spec: ToolSpec, update: bool = False) -> None: ) -def _run_npm_install(spec: ToolSpec, update: bool = False) -> None: - action = "update" if update else "install" - cmd = ["npm", action, "-g", spec.package] +def _run_package_manager_install( + spec: ToolSpec, manager: str, update: bool = False +) -> None: + package_manager = get_package_manager(manager) + action = ( + package_manager.update_command if update else package_manager.install_command + ) + cmd = [manager, action, "-g", spec.package] console.print(f" Running: {shlex.join(cmd)}") subprocess.run(cmd, check=True) @@ -206,8 +212,8 @@ def do_install( _install_release_plan(resolved_plan, client) case "installer": _run_installer(spec, update=update) - case "npm": - _run_npm_install(spec, update=update) + case _ if is_npm_registry_package_type(spec.type): + _run_package_manager_install(spec, spec.type, update=update) case _: raise ValueError(f"Unknown type: {spec.type}") console.print(f" [green]Done.[/green] {get_installed_version(spec) or ''}") diff --git a/src/ptm/models.py b/src/ptm/models.py index daa27e4..a18c03f 100644 --- a/src/ptm/models.py +++ b/src/ptm/models.py @@ -1,10 +1,12 @@ from dataclasses import dataclass, field +from ptm.package_managers import is_npm_registry_package_type + @dataclass class ToolSpec: bin: str = "" - # "github_release" | "url_release" | "installer" | "npm" + # "github_release" | "url_release" | "installer" | "npm" | "bun" type: str = "github_release" version: str = "latest" version_cmd: list[str] = field(default_factory=list) @@ -25,7 +27,7 @@ class ToolSpec: url: str = "" command: str = "" update_command: str = "" - # npm 専用 + # npm / bun 専用 package: str = "" def __post_init__(self) -> None: @@ -33,7 +35,7 @@ def __post_init__(self) -> None: raise ValueError("ToolSpec.bin must not be empty") if not self.version_cmd: self.version_cmd = [self.bin, "--version"] - if self.type == "npm" and not self.package: + if is_npm_registry_package_type(self.type) and not self.package: self.package = self.bin if not self.extract: self.extract = self._infer_extract() diff --git a/src/ptm/package_managers.py b/src/ptm/package_managers.py new file mode 100644 index 0000000..d76f951 --- /dev/null +++ b/src/ptm/package_managers.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PackageManager: + install_command: str + update_command: str + + +NPM_REGISTRY_PACKAGE_MANAGERS: dict[str, PackageManager] = { + "npm": PackageManager(install_command="install", update_command="update"), + "bun": PackageManager(install_command="install", update_command="update"), +} + + +def is_npm_registry_package_type(tool_type: str) -> bool: + return tool_type in NPM_REGISTRY_PACKAGE_MANAGERS + + +def get_package_manager(tool_type: str) -> PackageManager: + try: + return NPM_REGISTRY_PACKAGE_MANAGERS[tool_type] + except KeyError as e: + raise ValueError(f"Unknown package manager type: {tool_type}") from e diff --git a/src/ptm/resolver.py b/src/ptm/resolver.py index cc2c3ca..e32a094 100644 --- a/src/ptm/resolver.py +++ b/src/ptm/resolver.py @@ -7,6 +7,7 @@ import httpx from ptm.models import InstallPlan, ToolSpec +from ptm.package_managers import is_npm_registry_package_type @dataclass(frozen=True) @@ -70,13 +71,21 @@ def version_status(installed: str | None, latest: str) -> str: return "[yellow]outdated[/yellow]" -def get_npm_latest_version(spec: ToolSpec) -> str: - out = subprocess.check_output( - ["npm", "view", spec.package, "version"], - stderr=subprocess.STDOUT, - text=True, +def _get_package_registry_latest_version(spec: ToolSpec, client: httpx.Client) -> str: + package = quote(spec.package, safe="@") + resp = client.get( + f"https://registry.npmjs.org/{package}", + headers={"Accept": "application/vnd.npm.install-v1+json"}, ) - return out.strip().removeprefix("v") + resp.raise_for_status() + data = resp.json() + dist_tags = data.get("dist-tags") + if not isinstance(dist_tags, dict): + raise RuntimeError(f"{spec.package}: invalid npm registry metadata") + latest = dist_tags.get("latest") + if not isinstance(latest, str) or not latest: + raise RuntimeError(f"{spec.package}: invalid npm registry metadata") + return latest.removeprefix("v") def resolve_latest_version(spec: ToolSpec, client: httpx.Client) -> str | None: @@ -84,8 +93,8 @@ def resolve_latest_version(spec: ToolSpec, client: httpx.Client) -> str | None: if not spec.version_url: return None return get_url_release_version(spec, client) - if spec.type == "npm": - return get_npm_latest_version(spec) + if is_npm_registry_package_type(spec.type): + return _get_package_registry_latest_version(spec, client) if spec.type == "url_release": return get_url_release_version(spec, client) return get_latest_tag(spec, client) @@ -94,7 +103,9 @@ def resolve_latest_version(spec: ToolSpec, client: httpx.Client) -> str | None: def get_comparable_version(spec: ToolSpec, version: str | None) -> str | None: if version is None or spec.version == "nightly": return None - if spec.type in {"github_release", "url_release", "installer", "npm"}: + if spec.type in {"github_release", "url_release", "installer"} or ( + is_npm_registry_package_type(spec.type) + ): return version.removeprefix("v") return version @@ -128,7 +139,9 @@ def resolve_install_plan(spec: ToolSpec, client: httpx.Client) -> InstallPlan: url=asset.url, extract=asset.extract, ) - case "installer" | "npm": + case "installer": + return InstallPlan(spec=spec, version=resolve_latest_version(spec, client)) + case _ if is_npm_registry_package_type(spec.type): return InstallPlan(spec=spec, version=resolve_latest_version(spec, client)) case _: raise ValueError(f"Unknown type: {spec.type}") diff --git a/tests/test_commands.py b/tests/test_commands.py index e8f46ce..89a67fe 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,7 @@ from ptm.commands import cmd_check, cmd_install, cmd_list, cmd_update from ptm.models import InstallPlan, ToolSpec +from ptm.package_managers import NPM_REGISTRY_PACKAGE_MANAGERS class ToolSpecOverrides(TypedDict): @@ -273,8 +274,9 @@ def test_installer_type_fetches_version_when_version_url_is_set(self): cmd_check(tools, client) mock_plan.assert_called_once_with(tools[0], client) - def test_npm_type_fetches_version(self): - tools = [ToolSpec(bin="markdownlint-cli2", type="npm")] + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_package_manager_type_fetches_version(self, tool_type: str): + tools = [ToolSpec(bin="markdownlint-cli2", type=tool_type)] client = MagicMock() with ( patch("ptm.commands.get_installed_version", return_value="0.15.0"), diff --git a/tests/test_config.py b/tests/test_config.py index 5e83f7d..c6f1460 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -44,13 +44,16 @@ def test_load_tools_named_table_all_types(tmp_path: Path) -> None: [tools.markdownlint-cli2] type = "npm" + + [tools.prettier] + type = "bun" """), encoding="utf-8", ) tools = load_tools(config) - assert len(tools) == 4 + assert len(tools) == 5 types = {t.type for t in tools} - assert types == {"github_release", "url_release", "installer", "npm"} + assert types == {"github_release", "url_release", "installer", "npm", "bun"} def test_load_tools_named_table_uses_key_as_default_bin(tmp_path: Path) -> None: diff --git a/tests/test_installer.py b/tests/test_installer.py index be42059..e5dc547 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -19,11 +19,12 @@ _install_tar_binary, _install_zip_binary, _run_installer, - _run_npm_install, + _run_package_manager_install, _strip_components, do_install, ) from ptm.models import InstallPlan, ToolSpec +from ptm.package_managers import NPM_REGISTRY_PACKAGE_MANAGERS # ---- helpers ---------------------------------------------------------------- @@ -312,31 +313,49 @@ def test_uses_url_when_no_command(self): assert "https://astral.sh/uv/install.sh" in args -class TestRunNpmInstall: - def test_runs_npm_install(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") +class TestRunPackageManagerInstall: + @pytest.mark.parametrize("manager", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_runs_install(self, manager: str): + spec = ToolSpec(bin="markdownlint-cli2", type=manager) with patch("subprocess.run") as mock_run: - _run_npm_install(spec) + _run_package_manager_install(spec, manager) mock_run.assert_called_once_with( - ["npm", "install", "-g", "markdownlint-cli2"], + [ + manager, + NPM_REGISTRY_PACKAGE_MANAGERS[manager].install_command, + "-g", + "markdownlint-cli2", + ], check=True, ) - def test_runs_npm_update_when_updating(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") + @pytest.mark.parametrize("manager", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_runs_update_when_updating(self, manager: str): + spec = ToolSpec(bin="markdownlint-cli2", type=manager) with patch("subprocess.run") as mock_run: - _run_npm_install(spec, update=True) + _run_package_manager_install(spec, manager, update=True) mock_run.assert_called_once_with( - ["npm", "update", "-g", "markdownlint-cli2"], + [ + manager, + NPM_REGISTRY_PACKAGE_MANAGERS[manager].update_command, + "-g", + "markdownlint-cli2", + ], check=True, ) - def test_uses_package_name_when_set(self): - spec = ToolSpec(bin="tsc", type="npm", package="typescript") + @pytest.mark.parametrize("manager", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_uses_package_name_when_set(self, manager: str): + spec = ToolSpec(bin="tsc", type=manager, package="typescript") with patch("subprocess.run") as mock_run: - _run_npm_install(spec) + _run_package_manager_install(spec, manager) mock_run.assert_called_once_with( - ["npm", "install", "-g", "typescript"], + [ + manager, + NPM_REGISTRY_PACKAGE_MANAGERS[manager].install_command, + "-g", + "typescript", + ], check=True, ) @@ -459,15 +478,16 @@ def test_passes_update_flag(self): do_install(spec, client, update=True) mock_run.assert_called_once_with(spec, update=True) - def test_runs_npm_installer(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_runs_package_manager_installer(self, tool_type: str): + spec = ToolSpec(bin="markdownlint-cli2", type=tool_type) client = MagicMock() with ( - patch("ptm.installer._run_npm_install") as mock_run, + patch("ptm.installer._run_package_manager_install") as mock_run, patch("ptm.installer.get_installed_version", return_value="0.15.0"), ): do_install(spec, client) - mock_run.assert_called_once_with(spec, update=False) + mock_run.assert_called_once_with(spec, tool_type, update=False) def test_prints_error_on_failure(self, capsys: pytest.CaptureFixture): spec = ToolSpec(bin="rg", type="github_release") diff --git a/tests/test_models.py b/tests/test_models.py index ef2b582..fb28991 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,7 @@ +import pytest + from ptm.models import ToolSpec +from ptm.package_managers import NPM_REGISTRY_PACKAGE_MANAGERS class TestToolSpecVersionCmd: @@ -70,11 +73,13 @@ def test_type_field_is_set(self): assert spec.type == "github_release" -class TestToolSpecNpm: - def test_defaults_package_to_bin(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") +class TestToolSpecNpmRegistryPackage: + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_defaults_package_to_bin(self, tool_type: str): + spec = ToolSpec(bin="markdownlint-cli2", type=tool_type) assert spec.package == "markdownlint-cli2" - def test_preserves_explicit_package(self): - spec = ToolSpec(bin="tsc", type="npm", package="typescript") + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_preserves_explicit_package(self, tool_type: str): + spec = ToolSpec(bin="tsc", type=tool_type, package="typescript") assert spec.package == "typescript" diff --git a/tests/test_package_managers.py b/tests/test_package_managers.py new file mode 100644 index 0000000..b8820f7 --- /dev/null +++ b/tests/test_package_managers.py @@ -0,0 +1,25 @@ +import pytest + +from ptm.package_managers import ( + NPM_REGISTRY_PACKAGE_MANAGERS, + get_package_manager, + is_npm_registry_package_type, +) + + +def test_npm_registry_package_managers_define_commands() -> None: + assert NPM_REGISTRY_PACKAGE_MANAGERS["npm"].install_command == "install" + assert NPM_REGISTRY_PACKAGE_MANAGERS["npm"].update_command == "update" + assert NPM_REGISTRY_PACKAGE_MANAGERS["bun"].install_command == "install" + assert NPM_REGISTRY_PACKAGE_MANAGERS["bun"].update_command == "update" + + +@pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) +def test_detects_npm_registry_package_type(tool_type: str) -> None: + assert is_npm_registry_package_type(tool_type) + + +def test_rejects_unknown_package_manager_type() -> None: + assert not is_npm_registry_package_type("pnpm") + with pytest.raises(ValueError, match="Unknown package manager type"): + get_package_manager("pnpm") diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 8e19982..caeb389 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -4,15 +4,16 @@ import pytest from ptm.models import ToolSpec +from ptm.package_managers import NPM_REGISTRY_PACKAGE_MANAGERS from ptm.resolver import ( _get_latest_tag_via_gh, + _get_package_registry_latest_version, _score_asset_name, detect_platform, get_comparable_latest_version, get_comparable_version, get_installed_version, get_latest_tag, - get_npm_latest_version, get_url_release_version, resolve_asset_url, resolve_github_release_asset, @@ -174,16 +175,42 @@ def test_raises_when_version_not_found(self): get_url_release_version(spec, client) -class TestGetNpmLatestVersion: - def test_fetches_version_from_npm(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") - with patch("subprocess.check_output", return_value="0.15.0\n") as mock_check: - assert get_npm_latest_version(spec) == "0.15.0" - mock_check.assert_called_once_with( - ["npm", "view", "markdownlint-cli2", "version"], - stderr=subprocess.STDOUT, - text=True, +class TestGetNpmRegistryLatestVersion: + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_fetches_version(self, tool_type: str): + spec = ToolSpec(bin="markdownlint-cli2", type=tool_type) + client = MagicMock() + client.get.return_value.json.return_value = {"dist-tags": {"latest": "0.15.0"}} + + assert _get_package_registry_latest_version(spec, client) == "0.15.0" + + client.get.assert_called_once_with( + "https://registry.npmjs.org/markdownlint-cli2", + headers={"Accept": "application/vnd.npm.install-v1+json"}, ) + client.get.return_value.raise_for_status.assert_called_once() + + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_fetches_scoped_package_version(self, tool_type: str): + spec = ToolSpec(bin="eslint", type=tool_type, package="@eslint/js") + client = MagicMock() + client.get.return_value.json.return_value = {"dist-tags": {"latest": "9.39.1"}} + + assert _get_package_registry_latest_version(spec, client) == "9.39.1" + + client.get.assert_called_once_with( + "https://registry.npmjs.org/@eslint%2Fjs", + headers={"Accept": "application/vnd.npm.install-v1+json"}, + ) + + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_raises_when_latest_version_is_missing(self, tool_type: str): + spec = ToolSpec(bin="tool", type=tool_type) + client = MagicMock() + client.get.return_value.json.return_value = {"dist-tags": {}} + + with pytest.raises(RuntimeError, match="invalid npm registry metadata"): + _get_package_registry_latest_version(spec, client) class TestGetComparableLatestVersion: @@ -222,10 +249,13 @@ def test_fetches_url_release_version(self): with patch("ptm.resolver.get_url_release_version", return_value="v22.0.0"): assert get_comparable_latest_version(spec, client) == "22.0.0" - def test_fetches_npm_version(self): - spec = ToolSpec(bin="markdownlint-cli2", type="npm") + @pytest.mark.parametrize("tool_type", NPM_REGISTRY_PACKAGE_MANAGERS) + def test_fetches_npm_registry_version(self, tool_type: str): + spec = ToolSpec(bin="markdownlint-cli2", type=tool_type) client = MagicMock() - with patch("ptm.resolver.get_npm_latest_version", return_value="0.15.0"): + with patch( + "ptm.resolver._get_package_registry_latest_version", return_value="0.15.0" + ): assert get_comparable_latest_version(spec, client) == "0.15.0"