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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。**
Expand Down
22 changes: 22 additions & 0 deletions containers/general/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions containers/php/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions containers/php85/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
121 changes: 121 additions & 0 deletions tests/containers/test_language_servers_dockerfile.py
Original file line number Diff line number Diff line change
@@ -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
Loading