Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 <package> version` を使って npm レジストリ上の最新版と比較します。
`type = "npm"` では `npm install -g <package>` / `npm update -g <package>` を実行します。
`type = "bun"` では `bun install -g <package>` / `bun update -g <package>` を実行します。
どちらも npm registry の metadata API を使って最新版と比較します。

---

Expand Down
16 changes: 11 additions & 5 deletions src/ptm/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand All @@ -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 ''}")
Expand Down
8 changes: 5 additions & 3 deletions src/ptm/models.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -25,15 +27,15 @@ class ToolSpec:
url: str = ""
command: str = ""
update_command: str = ""
# npm 専用
# npm / bun 専用
package: str = ""

def __post_init__(self) -> None:
if not self.bin:
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()
Expand Down
24 changes: 24 additions & 0 deletions src/ptm/package_managers.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 23 additions & 10 deletions src/ptm/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -70,22 +71,30 @@ 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:
if spec.type == "installer":
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)
Expand All @@ -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

Expand Down Expand Up @@ -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}")
Expand Down
6 changes: 4 additions & 2 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"),
Expand Down
7 changes: 5 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 38 additions & 18 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------------

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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")
Expand Down
15 changes: 10 additions & 5 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import pytest

from ptm.models import ToolSpec
from ptm.package_managers import NPM_REGISTRY_PACKAGE_MANAGERS


class TestToolSpecVersionCmd:
Expand Down Expand Up @@ -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"
25 changes: 25 additions & 0 deletions tests/test_package_managers.py
Original file line number Diff line number Diff line change
@@ -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")
Loading