diff --git a/CHANGELOG.md b/CHANGELOG.md index b9525693..fb01a6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ 開発モードの OpenBao を手元に立てる試用の構成(`compose.yml`)は `docs/user/openbao/` にあります。 個人の形もアカウントグループごとの置き場(`version: 2`)を使い、プロジェクトで `DEVBASE_ACCOUNT_GROUP=personal` を 宣言します。接続のコマンドと引数、既存のサーバへの接続の手順は変わりません。 +- **`general`・`php`・`php85` の派生イメージに、Python・TypeScript・PHP の言語サーバを入れました(#236)。** + `pyright-langserver`(pyright 1.1.414)・`typescript-language-server`(6.0.2、TypeScript 5.9.3 を使う)・ + `intelephense`(1.18.5)が PATH の上にあり、コンテナに入った時点で Claude Code の公式 LSP プラグインから起動できます + (プラグインは利用者が入れます)。言語サーバは `/opt/language-servers` に root で入り、利用者の `npm i -g` の領域とは + 分かれます。`tsc` は base と同じ 7.0.2 のままです。イメージの展開後の大きさは約 148MB 増えます。 + base とほかの派生イメージは変わりません。届くのはイメージを建て直した後です(`devbase build`)。 + Serena の MCP は今までどおり、初回の起動で `serena-agent` と自分の言語サーバを取得します(ネットワークが要ります)。 ### Changed - **シェル補完(bash・zsh)が、`--context NAME` を受け付けるすべてのサブコマンドで `--context` を出すようにしました(#435)。** diff --git a/containers/general/Dockerfile b/containers/general/Dockerfile index e2e97e2e..5f4e4edb 100644 --- a/containers/general/Dockerfile +++ b/containers/general/Dockerfile @@ -5,3 +5,25 @@ FROM devbase-base:latest +USER root + +# 言語サーバ(Python・TypeScript・PHP)。Claude Code の公式 LSP プラグインが PATH の上から起動する。 +# base の npm のグローバル領域(tsc 7.0.2)に触れないよう、専用の置き場へ root で入れ、 +# typescript-language-server が同じ置き場の typescript 5 を解決する形にする。 +# PATH へ出すのは bin の 3 つのコマンドだけで、置き場の tsc は出さない。 +# 版は general・php・php85 で揃える(tests/containers/test_language_servers_dockerfile.py が縛る)。 +RUN set -eux; \ + npm install --prefix /opt/language-servers --omit=dev --no-audit --no-fund \ + pyright@1.1.414 \ + typescript-language-server@6.0.2 \ + typescript@5.9.3 \ + intelephense@1.18.5; \ + mkdir -p /opt/language-servers/bin; \ + ln -s /opt/language-servers/node_modules/.bin/pyright-langserver /opt/language-servers/bin/pyright-langserver; \ + ln -s /opt/language-servers/node_modules/.bin/typescript-language-server /opt/language-servers/bin/typescript-language-server; \ + ln -s /opt/language-servers/node_modules/.bin/intelephense /opt/language-servers/bin/intelephense; \ + /opt/language-servers/bin/typescript-language-server --version; \ + rm -rf /root/.npm +ENV PATH="/opt/language-servers/bin:${PATH}" + +USER ubuntu diff --git a/containers/php/Dockerfile b/containers/php/Dockerfile index 632dddf7..2cb91850 100644 --- a/containers/php/Dockerfile +++ b/containers/php/Dockerfile @@ -32,6 +32,25 @@ RUN set -eux; \ # Composerインストール COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +# 言語サーバ(Python・TypeScript・PHP)。Claude Code の公式 LSP プラグインが PATH の上から起動する。 +# base の npm のグローバル領域(tsc 7.0.2)に触れないよう、専用の置き場へ root で入れ、 +# typescript-language-server が同じ置き場の typescript 5 を解決する形にする。 +# PATH へ出すのは bin の 3 つのコマンドだけで、置き場の tsc は出さない。 +# 版は general・php・php85 で揃える(tests/containers/test_language_servers_dockerfile.py が縛る)。 +RUN set -eux; \ + npm install --prefix /opt/language-servers --omit=dev --no-audit --no-fund \ + pyright@1.1.414 \ + typescript-language-server@6.0.2 \ + typescript@5.9.3 \ + intelephense@1.18.5; \ + mkdir -p /opt/language-servers/bin; \ + ln -s /opt/language-servers/node_modules/.bin/pyright-langserver /opt/language-servers/bin/pyright-langserver; \ + ln -s /opt/language-servers/node_modules/.bin/typescript-language-server /opt/language-servers/bin/typescript-language-server; \ + ln -s /opt/language-servers/node_modules/.bin/intelephense /opt/language-servers/bin/intelephense; \ + /opt/language-servers/bin/typescript-language-server --version; \ + rm -rf /root/.npm +ENV PATH="/opt/language-servers/bin:${PATH}" + USER ubuntu WORKDIR /work diff --git a/containers/php85/Dockerfile b/containers/php85/Dockerfile index 37f194ec..76672067 100644 --- a/containers/php85/Dockerfile +++ b/containers/php85/Dockerfile @@ -33,6 +33,25 @@ RUN set -eux; \ # Composerインストール COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +# 言語サーバ(Python・TypeScript・PHP)。Claude Code の公式 LSP プラグインが PATH の上から起動する。 +# base の npm のグローバル領域(tsc 7.0.2)に触れないよう、専用の置き場へ root で入れ、 +# typescript-language-server が同じ置き場の typescript 5 を解決する形にする。 +# PATH へ出すのは bin の 3 つのコマンドだけで、置き場の tsc は出さない。 +# 版は general・php・php85 で揃える(tests/containers/test_language_servers_dockerfile.py が縛る)。 +RUN set -eux; \ + npm install --prefix /opt/language-servers --omit=dev --no-audit --no-fund \ + pyright@1.1.414 \ + typescript-language-server@6.0.2 \ + typescript@5.9.3 \ + intelephense@1.18.5; \ + mkdir -p /opt/language-servers/bin; \ + ln -s /opt/language-servers/node_modules/.bin/pyright-langserver /opt/language-servers/bin/pyright-langserver; \ + ln -s /opt/language-servers/node_modules/.bin/typescript-language-server /opt/language-servers/bin/typescript-language-server; \ + ln -s /opt/language-servers/node_modules/.bin/intelephense /opt/language-servers/bin/intelephense; \ + /opt/language-servers/bin/typescript-language-server --version; \ + rm -rf /root/.npm +ENV PATH="/opt/language-servers/bin:${PATH}" + USER ubuntu WORKDIR /work diff --git a/tests/containers/test_language_servers_dockerfile.py b/tests/containers/test_language_servers_dockerfile.py new file mode 100644 index 00000000..557162bd --- /dev/null +++ b/tests/containers/test_language_servers_dockerfile.py @@ -0,0 +1,121 @@ +"""派生イメージに言語サーバを専用の置き場へ入れる形 + +Docker を起動せず、``general``・``php``・``php85`` の Dockerfile の文字列だけを固定する。 +言語サーバは ``npm install --prefix /opt/language-servers`` で root が入れ、PATH へ出すのは +``bin`` に張る 3 つのコマンドだけにする。base の npm のグローバル領域(``tsc``)には触れず、 +版は厳密に固定して 3 つのイメージで揃える。Serena の uv のキャッシュは温めない。 +""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path + +import pytest + +from .dockerfile_parse import Instruction, parse + +REPO_ROOT = Path(__file__).resolve().parents[2] +IMAGES = ("general", "php", "php85") +PLACE = "/opt/language-servers" +PACKAGES = {"pyright", "typescript-language-server", "typescript", "intelephense"} +COMMANDS = {"pyright-langserver", "typescript-language-server", "intelephense"} +EXACT = re.compile(r"\d+\.\d+\.\d+") + + +def _instructions(image: str) -> list[Instruction]: + return parse((REPO_ROOT / "containers" / image / "Dockerfile").read_text()) + + +def _install_index(instructions: list[Instruction]) -> int: + """言語サーバを置き場へ入れる RUN の位置""" + found = [ + i for i, ins in enumerate(instructions) + if ins.keyword == "RUN" and f"--prefix {PLACE}" in ins.args + ] + assert len(found) == 1, f"言語サーバを入れる RUN が 1 つではない: {len(found)}" + return found[0] + + +def _install_args(run: str) -> list[str]: + """``npm install --prefix`` の 1 文の語""" + statement = next(s for s in run.split(";") if f"--prefix {PLACE}" in s) + words = shlex.split(statement) + assert words[:2] == ["npm", "install"], statement + return words[2:] + + +def _package_set(image: str) -> dict[str, str]: + """``npm install --prefix`` が入れるパッケージの名前と版""" + instructions = _instructions(image) + run = instructions[_install_index(instructions)].args + specs = [w for w in _install_args(run) if not w.startswith("-") and w != PLACE] + packages: dict[str, str] = {} + for spec in specs: + name, at, version = spec.rpartition("@") + assert at, f"{image}: 版の無い指定 {spec}" + packages[name] = version + return packages + + +@pytest.mark.parametrize("image", IMAGES) +def test_installs_the_four_packages_into_the_place(image): + assert set(_package_set(image)) == PACKAGES + + +@pytest.mark.parametrize("image", IMAGES) +def test_versions_are_exact_and_typescript_is_5(image): + packages = _package_set(image) + loose = {n: v for n, v in packages.items() if not EXACT.fullmatch(v)} + assert not loose, f"{image}: 厳密でない版 {loose}" + assert packages["typescript"].startswith("5."), f"{image}: typescript {packages['typescript']}" + + +def test_package_sets_match_across_images(): + sets = {image: _package_set(image) for image in IMAGES} + first = sets[IMAGES[0]] + differ = {image: s for image, s in sets.items() if s != first} + assert not differ, f"{IMAGES[0]} の組 {first} と違う: {differ}" + + +@pytest.mark.parametrize("image", IMAGES) +def test_does_not_touch_the_global_npm_and_links_only_three_commands(image): + instructions = _instructions(image) + run = instructions[_install_index(instructions)].args + assert not re.search(r"npm\s+(i|install)\b[^;]*\s(-g|--global)\b", run) + links = re.findall(r"ln\s+-s\S*\s+([^\s;]+)\s+([^\s;]+)", run) + assert {Path(dest).name for _, dest in links} == COMMANDS + for src, dest in links: + assert Path(dest).parent == Path(PLACE) / "bin", dest + assert src == f"{PLACE}/node_modules/.bin/{Path(dest).name}", src + + +@pytest.mark.parametrize("image", IMAGES) +def test_path_has_only_the_bin_of_the_place(image): + paths = [ + ins.args for ins in _instructions(image) + if ins.keyword == "ENV" and ins.args.startswith("PATH=") + ] + assert any(f"{PLACE}/bin:" in p for p in paths), paths + assert not any("node_modules" in p for p in paths), paths + + +@pytest.mark.parametrize("image", IMAGES) +def test_does_not_warm_the_uv_cache_for_serena(image): + text = (REPO_ROOT / "containers" / image / "Dockerfile").read_text() + body = "\n".join(ins.args for ins in parse(text)) + assert "UV_CACHE_DIR" not in body + assert "serena-agent" not in body + + +@pytest.mark.parametrize("image", IMAGES) +def test_installs_as_root_drops_npm_cache_and_returns_to_ubuntu(image): + instructions = _instructions(image) + index = _install_index(instructions) + users = [(i, ins.args) for i, ins in enumerate(instructions) if ins.keyword == "USER"] + before = [u for i, u in users if i < index] + after = [u for i, u in users if i > index] + assert before and before[-1] == "root", before + assert after and after[-1] == "ubuntu", after + assert "rm -rf /root/.npm" in instructions[index].args