diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 442b13ef..4a3de97e 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -178,6 +178,239 @@ devbase_enter_primary_dir() { fi } +# =================================================================== +# PLAN39: AI 設定の永続化 (共通 / アカウントグループの 2 層) +# =================================================================== +# /persistent/ai … 全コンテナ共通 (分類 A)。plugins / skills のように +# 契約やテナントに紐づかない資産。グループ数だけ重複させない +# /persistent/group … アカウントグループ単位 (分類 B)。認証情報と会話履歴のように +# 企業テナントへ紐づくもの。グループをまたいで共有しない +# +# ~/.claude の既定は**グループ側**にする。Claude Code は projects / sessions / +# tasks のようなディレクトリを随時作るため、永続化するエントリを列挙する方式だと +# 列挙漏れが黙って揮発する。既定をグループ側に倒し、共通にしたいものだけを +# 名指しで共通側へ張る。 + +# 分類 A: ホーム直下 +DEVBASE_SHARED_SETTINGS=( + ".codex" + ".serena" + ".ssh" + ".kiro" + "share" +) + +# 分類 B: ホーム直下 +DEVBASE_GROUP_SETTINGS=( + ".claude.json" + ".claude" + ".gemini" +) + +# 分類 A のうち ~/.claude 配下にあるもの (グループ側の .claude から共通側へ張る) +DEVBASE_SHARED_CLAUDE_SETTINGS=( + "plugins" + "skills" + "commands" + "CLAUDE.md" + "settings.json" +) + +# ファイルとして作るエントリ (末尾の要素名で判定する)。ここに無いものは +# ディレクトリとして作る。 +# +# 拡張子で判定していた頃は `.jsonl` が `*.json` にマッチせず、history.jsonl が +# **ディレクトリとして**作られて Claude Code が追記できなくなっていた。 +# 新しいファイルのエントリを足すときはこの一覧にも足すこと。 +DEVBASE_FILE_ENTRIES=( + ".claude.json" + ".credentials.json" + "history.jsonl" + "CLAUDE.md" + "settings.json" +) + +# パスの末尾要素がファイルとして作るエントリか判定する。 +devbase_is_file_entry() { + local name="${1##*/}" entry + for entry in "${DEVBASE_FILE_ENTRIES[@]}"; do + [ "$name" = "$entry" ] && return 0 + done + return 1 +} + +# 永続領域のルートを用意する。 +# +# 空の named volume は **root 所有**で作られ uid 1000 では書き込めないため、 +# 書けなければ chown する。テストのように最初から書ける場所では sudo を呼ばない。 +devbase_ensure_persistent_root() { + local root="$1" owner="${2:-${USERNAME:-ubuntu}}" + + if [ ! -d "$root" ]; then + mkdir -p "$root" 2>/dev/null || sudo mkdir -p "$root" + fi + if [ ! -w "$root" ]; then + sudo chown "${owner}:${owner}" "$root" + fi +} + +# 実体が無ければプレースホルダを作る (親ディレクトリごと)。 +devbase_ensure_entry() { + local path="$1" + + mkdir -p "$(dirname "$path")" + if [ -e "$path" ]; then + return 0 + fi + if devbase_is_file_entry "$path"; then + : > "$path" + else + mkdir -p "$path" + fi +} + +# を への symlink にする。 +# +# **link 側と実体側の双方**で親ディレクトリを作るのが要点。入れ子パス +# (.claude/plugins) ではどちらの親も無いことがあり、以前は実体側の作成が +# `No such file or directory` で落ちて壊れた symlink が残っていた。 +# +# 既存の実体は `rm -rf` してから張り直す。symlink に対する `rm -rf` は +# **リンクだけ**を消すので、共通側の実体は巻き添えにならない。 +devbase_link_setting() { + local link_path="$1" target_path="$2" owner="${3:-${USERNAME:-ubuntu}}" + + devbase_ensure_entry "$target_path" + + if [ -L "$link_path" ] && [ "$(readlink "$link_path")" = "$target_path" ]; then + echo " ✓ ${link_path} (symlink exists)" + return 0 + fi + + mkdir -p "$(dirname "$link_path")" + if [ -e "$link_path" ] || [ -L "$link_path" ]; then + echo " Removing existing ${link_path}..." + rm -rf "$link_path" + fi + + echo " Creating symlink: ${link_path} -> ${target_path}" + ln -s "$target_path" "$link_path" + chown -h "${owner}:${owner}" "$link_path" 2>/dev/null || true +} + +# シード元から 1 エントリを**コピー**する (既にあれば何もしない)。 +# +# 第 3 引数以降は「コピーしない直下の名前」。分類 A の共通資産をグループ側へ +# 複製しないために使う。 +devbase_seed_entry() { + local src="$1" dest="$2" + shift 2 + + if [ -e "$dest" ]; then + return 0 + fi + if [ ! -e "$src" ]; then + echo " skip (シード元なし): $src" + return 0 + fi + + mkdir -p "$(dirname "$dest")" + if [ ! -d "$src" ]; then + cp -a "$src" "$dest" + echo " seeded: $dest" + return 0 + fi + + mkdir -p "$dest" + local child name excluded skip + # `.[!.]*` と `..?*` で隠しファイルも拾う (`.credentials.json` 等)。 + for child in "$src"/* "$src"/.[!.]* "$src"/..?*; do + [ -e "$child" ] || [ -L "$child" ] || continue + name="${child##*/}" + skip=0 + for excluded in "$@"; do + if [ "$name" = "$excluded" ]; then + skip=1 + break + fi + done + [ "$skip" = "1" ] && continue + cp -a "$child" "$dest/$name" + done + echo " seeded: $dest" +} + +# default グループの初回シード。 +# +# 現行 /persistent/ai に実体がある分類 B のデータ (.claude.json / 認証 / 履歴 / +# .gemini) をグループ側へ **コピー** して初期化する。move ではないので切り戻し時に +# 元データが残る。非 default では走らせない — 走らせるとグループ分離の意味が +# 失われる。gcloud / gws はシード元が存在しないため対象外 (AC8)。 +devbase_seed_group_settings() { + local ai_root="$1" group_root="$2" group="$3" + local entry + + if [ "$group" != "default" ]; then + return 0 + fi + + echo "Seeding account group '${group}' from ${ai_root} (first run only)..." + for entry in "${DEVBASE_GROUP_SETTINGS[@]}"; do + if [ "$entry" = ".claude" ]; then + devbase_seed_entry "$ai_root/$entry" "$group_root/$entry" \ + "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}" + else + devbase_seed_entry "$ai_root/$entry" "$group_root/$entry" + fi + done +} + +# イメージが焼き込んだ ~/.claude の初期設定を共通側へ退避する。 +# +# Dockerfile は ~/.claude/settings.json に hooks 設定を書き込むが、この直後の +# symlink 張り替えは ~/.claude を `rm -rf` するため、拾わないと初回起動で失われる +# (共通側には空のプレースホルダだけが残る)。実体が入っているのは初回だけなので、 +# ~/.claude が既に symlink なら 2 回目以降の起動と見なして何もしない。 +devbase_seed_image_claude_settings() { + local home_root="$1" ai_root="$2" + local entry + + if [ -L "$home_root/.claude" ] || [ ! -d "$home_root/.claude" ]; then + return 0 + fi + + for entry in "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}"; do + [ -e "$home_root/.claude/$entry" ] || continue + devbase_seed_entry "$home_root/.claude/$entry" "$ai_root/.claude/$entry" + done +} + +# AI 設定の symlink を 2 系統ぶん張る (初回シードを含む)。 +devbase_setup_ai_settings() { + local home_root="$1" ai_root="$2" group_root="$3" group="${4:-default}" + local owner="${5:-${USERNAME:-ubuntu}}" + local entry + + devbase_ensure_persistent_root "$ai_root" "$owner" + devbase_ensure_persistent_root "$group_root" "$owner" + + # symlink を張る**前**にシードする。張ったあとに走らせると、共通側を指す + # symlink の中身へコピーしてしまう。 + devbase_seed_image_claude_settings "$home_root" "$ai_root" + devbase_seed_group_settings "$ai_root" "$group_root" "$group" + + for entry in "${DEVBASE_SHARED_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$ai_root/$entry" "$owner" + done + for entry in "${DEVBASE_GROUP_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$group_root/$entry" "$owner" + done + for entry in "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}"; do + devbase_link_setting "$group_root/.claude/$entry" \ + "$ai_root/.claude/$entry" "$owner" + done +} + # テストは関数定義だけを使う (source 時のみ有効な return で以降を読み飛ばす)。 if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then return 0 2>/dev/null || exit 0 @@ -380,65 +613,18 @@ if [ "$ENABLE_DIND" = "true" ] || [ "$ENABLE_DIND" = "1" ]; then fi # ======================================== -# AI Agent Settings Symlink Setup +# AI Agent Settings Symlink Setup (PLAN39: 共通 / グループの 2 層) # ======================================== -echo "Setting up AI agent settings symlinks..." - +# DEVBASE_ACCOUNT_GROUP はホスト (devbase up) が解決して渡す。ホスト側で +# 検証済みなので、ここでは未設定時に default へ落とすだけにする。 +DEVBASE_ACCOUNT_GROUP="${DEVBASE_ACCOUNT_GROUP:-default}" AI_PERSISTENT_DIR="/persistent/ai" -AI_SETTINGS=( - ".claude.json" - ".claude" - ".codex" - ".gemini" - ".serena" - ".ssh" - ".kiro" - "share" -) - -# Ensure /persistent/ai directory exists -if [ ! -d "$AI_PERSISTENT_DIR" ]; then - echo "Creating $AI_PERSISTENT_DIR directory..." - sudo mkdir -p "$AI_PERSISTENT_DIR" - sudo chown "${USERNAME}:${USERNAME}" "$AI_PERSISTENT_DIR" -fi - -# Create symlinks for each AI setting -for setting in "${AI_SETTINGS[@]}"; do - HOME_PATH="/home/${USERNAME}/${setting}" - PERSISTENT_PATH="${AI_PERSISTENT_DIR}/${setting}" - - # Skip if symlink already exists and points to correct location - if [ -L "$HOME_PATH" ] && [ "$(readlink -f "$HOME_PATH")" = "$PERSISTENT_PATH" ]; then - echo " ✓ ${setting} (symlink exists)" - continue - fi - - # Remove existing file/directory/broken symlink in home - if [ -e "$HOME_PATH" ] || [ -L "$HOME_PATH" ]; then - echo " Removing existing ${setting} from home..." - rm -rf "$HOME_PATH" - fi - - # If setting doesn't exist in persistent storage, create placeholder - if [ ! -e "$PERSISTENT_PATH" ]; then - # Determine if it's a file or directory based on extension - if [[ "$setting" == *.json ]]; then - echo " Creating empty file: ${setting}" - sudo touch "$PERSISTENT_PATH" - else - echo " Creating empty directory: ${setting}" - sudo mkdir -p "$PERSISTENT_PATH" - fi - sudo chown -R "${USERNAME}:${USERNAME}" "$PERSISTENT_PATH" - fi - - # Create symlink - echo " Creating symlink: ${setting} -> ${PERSISTENT_PATH}" - ln -s "$PERSISTENT_PATH" "$HOME_PATH" - chown -h "${USERNAME}:${USERNAME}" "$HOME_PATH" -done +GROUP_PERSISTENT_DIR="/persistent/group" +echo "Setting up AI agent settings symlinks (account group: ${DEVBASE_ACCOUNT_GROUP})..." +devbase_setup_ai_settings \ + "/home/${USERNAME}" "$AI_PERSISTENT_DIR" "$GROUP_PERSISTENT_DIR" \ + "$DEVBASE_ACCOUNT_GROUP" "$USERNAME" echo "AI agent settings symlinks setup completed" # ======================================== diff --git a/issues/PLAN39_account-group-volume-separation.md b/issues/PLAN39_account-group-volume-separation.md index 18937339..1d545ce9 100644 --- a/issues/PLAN39_account-group-volume-separation.md +++ b/issues/PLAN39_account-group-volume-separation.md @@ -153,6 +153,20 @@ issue #116 が `standard` 相当の Phase 分割で書かれていても、判 SQLite 自体は named volume 上で正常に動く(同じく実機で `create table` / `insert` を確認)ので `credentials.db` の置き場としては問題ない(並行実行は前提 13 の別件)。 +- 前提 20: **`~/.claude` の子要素は 30 件あり、プランが分類表で名指ししているのは 7 件だけ** + (実機 `carmo-ai-dev-1` で確認。`.credentials.json` / `.last-cleanup` / + `.last-update-result.json` / `.ndf-retention-checked` / `.ndf-retention.lock` / + `.ndf-statusline-backup.json` / `.ndf-statusline.lock` / `CLAUDE.md` / `backups` / + `cache` / `commands` / `daemon` / `daemon.log` / `debug` / `file-history` / + `history.jsonl` / `ide` / `jobs` / `logs` / `mcp-needs-auth-cache.json` / + `ndf-statusline.sh` / `paste-cache` / `plugins` / `projects` / `session-env` / + `sessions` / `settings.json` / `shell-snapshots` / `skills` / `tasks`)。 + 容量は `projects` 1.1GB・`plugins` 222MB・`file-history` 76MB・`session-env` 22MB・ + `jobs` 18MB で、`.claude` 全体は 1.5GB。`projects` は Claude Code の会話ログ実体であり、 + 分類表が `history.jsonl` を B とした理由(顧客情報が入りうる)がそのまま当てはまる。 + Claude Code は版が上がるたびに新しい子ディレクトリを作るため、**永続化するエントリを + 列挙する方式では列挙漏れが黙って揮発する**。 + - 前提 19: ADC の解決を実機(`carmo-ai-dev-1`、gcloud 同梱の `google.auth`)で確認した結果: (1) 鍵ありの現状は SA credentials が解決される(project `nyle-carmo-analysis`)、 (2) `GOOGLE_APPLICATION_CREDENTIALS` が存在しないパスを指すと @@ -172,9 +186,17 @@ issue #116 が `standard` 相当の Phase 分割で書かれていても、判 **同一の `/persistent/ai/.claude/plugins`** を指すこと。 - [ ] AC5: `DEVBASE_ACCOUNT_GROUP` 未設定のプロジェクトが `default` にフォールバックし、 これまでどおり起動する。検証: 既存プロジェクトを `up` して entrypoint がエラーを出さないこと。 -- [ ] AC6: 入れ子パスの symlink が正しく張られる。検証: `~/.claude/.credentials.json` が - **壊れていない** symlink であること、`~/.claude/history.jsonl` が**ディレクトリでない**こと - (前提 5 の退行を防ぐ)。 +- [ ] AC6: 入れ子パスの symlink が正しく張られる。検証: `~/.claude/CLAUDE.md` と + `~/.claude/settings.json` が**壊れていない**(実体に到達できる)symlink であり、かつ + **ファイル**であること。`~/.claude/.credentials.json` に書き込めること、 + `~/.claude/history.jsonl` が**ディレクトリでない**こと(前提 5 の退行を防ぐ)。 + 当初は `.credentials.json` / `history.jsonl` 自体を symlink にする想定だったが、 + 不変条件の反転(既定をグループ側へ)により両者はグループボリューム上の実ファイルになる。 + 入れ子 symlink として残るのは分類 A の 5 件で、うち `CLAUDE.md` / `settings.json` が + 「親ディレクトリが無い入れ子のファイルエントリ」という前提 5 と同じ条件を満たす。 + あわせて、Dockerfile が焼き込む `~/.claude/settings.json`(hooks 設定)は symlink 張り替えの + `rm -rf` で消えるため、**張る前に共通側へ退避**する。退避しないと `/persistent/ai` に + 空ファイルだけが残り、hooks が初回起動で失われる(既存 main からの挙動を修正)。 - [ ] AC7: Docker のボリューム名にできないグループ名、予約語 `ubuntu`(`devbase_home_ubuntu` と衝突する)、 および**数字のみの名前**(`devbase_home_` と衝突する。前提 6)を**起動前に拒否**し、 理由の分かるエラーを出す。 @@ -243,7 +265,8 @@ issue #116 の「検討が必要な点」3 件は次のとおり決定した。 | `.claude/history.jsonl`, `.claude/file-history` | **B** | 会話履歴に顧客情報が入りうる | | `.gemini` | **B** | `security.auth.selectedType = vertex-ai` で GCP プロジェクトに紐づく | | `.config/gcloud`, `.config/gws` | **B**(symlink ではなく env で差し替え) | 問題1の本体。`CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` をグループボリューム配下へ向ける(前提 8 / 12)。**symlink 対象にはしない** | -| `.claude/plugins`, `.claude/skills`, `.claude/commands`, `.claude/CLAUDE.md`, `.claude/settings.json` | **A** | 契約やテナントに紐づかない共通資産。238MB を重複させない | +| `.claude/plugins`, `.claude/skills`, `.claude/commands`, `.claude/CLAUDE.md`, `.claude/settings.json` | **A** | 契約やテナントに紐づかない共通資産。238MB を重複させない。**`.claude` 配下で A なのはこの 5 件だけ**で、残りはすべて B(既定)になる | +| `.claude/projects`, `.claude/sessions`, `.claude/tasks`, `.claude/session-env` ほか `.claude` 配下の未列挙エントリ | **B**(既定) | 会話ログとセッション状態。顧客情報が入りうる点は `history.jsonl` と同じ。列挙せず既定で B にすることで、Claude Code が将来増やす子ディレクトリも取りこぼさない(前提 20) | | `.codex`, `.kiro`, `.serena`, `share` | **A** | Codex は ChatGPT アカウント、Kiro は AWS 側(env 由来)で分離済み | | `.ssh` | **A**(現状維持) | entrypoint は `.ssh` を参照しておらず、git 認証は `GIT_CREDENTIALS_BASE64` / `GH_TOKEN` で完結している。企業テナントの境界になっていない。必要になれば配列間の 1 行移動で B へ移せる | | `.aws`, `.git-credentials`, `.gitconfig` | **C** | env から毎回復元(現行どおり) | @@ -253,8 +276,14 @@ issue #116 の「検討が必要な点」3 件は次のとおり決定した。 - 分類 A のエントリは、どのグループのコンテナから見ても `/persistent/ai` 配下の**同一実体**を指す。 - 分類 B のエントリは、異なるグループのコンテナから**互いに到達できない**。 - グループ名が未指定でも起動できる(`default` へフォールバック)。 -- `~/.claude` はシンボリックリンクではなく**実ディレクトリ**であり、その配下に A / B 双方への - シンボリックリンクが並ぶ。 +- `~/.claude` の**既定はグループ側**である。`~/.claude` は `/persistent/group/.claude` への + シンボリックリンクで、その配下に分類 A のエントリだけが共通側 (`/persistent/ai/.claude/`) + への シンボリックリンクとして並ぶ。 + 当初は「`~/.claude` を実ディレクトリにし、A / B 双方の symlink を並べる」としていたが、 + 前提 20 のとおり `.claude` の子要素は 30 件あり、列挙方式では `projects`(1.1GB の会話ログ) + のような**未列挙の子が黙って揮発する**。既定をグループ側へ倒し、共通にしたいものだけを + 名指しする向きに反転した。`~/.claude` が symlink であること自体は現行 `main` と同じで、 + 変わるのは向き先だけである。 - サービスアカウント鍵は**永続領域に置かない**。毎起動 env から書き直され、コンテナ層とともに消える。 ## 互換性 @@ -349,11 +378,16 @@ issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問 ### Task 4: AI_SETTINGS の 2 系統化と初回シード(PR2) - **対象ファイル:** `containers/base/entrypoint.sh`, `tests/containers/` -- **変更内容:** `AI_SETTINGS` を `AI_SETTINGS_SHARED`(→ `/persistent/ai`)と - `AI_SETTINGS_GROUP`(→ `/persistent/group`)に分ける。分類は上表のとおり。 - `~/.claude` を実ディレクトリとして作り(既存の symlink が残っていれば外す)、その配下に - 両系統の symlink を張る。symlink 生成の**前に**、`DEVBASE_ACCOUNT_GROUP` が `default` で +- **変更内容:** `AI_SETTINGS` を 3 つの配列に分ける。 + `DEVBASE_SHARED_SETTINGS`(ホーム直下・分類 A → `/persistent/ai`)、 + `DEVBASE_GROUP_SETTINGS`(ホーム直下・分類 B → `/persistent/group`)、 + `DEVBASE_SHARED_CLAUDE_SETTINGS`(`.claude` 配下の分類 A。グループ側の `.claude` から + 共通側へ張る)。`~/.claude` は symlink のまま向き先を `/persistent/group/.claude` へ変え、 + その配下に共通資産 5 件の symlink を張る(不変条件の反転。理由は前提 20)。 + symlink 生成の**前に**、`DEVBASE_ACCOUNT_GROUP` が `default` で かつグループ側に実体が無いエントリだけ、`/persistent/ai` から**コピー**して初期化する。 + `.claude` のシードでは分類 A の 5 件を**除外**する(共通資産を重複させないため。 + 除外しないと直後の symlink 生成が消すだけの無駄なコピーになる)。 `~/.config/gcloud` を symlink 対象に**しない**ため(Task 5 は env で差し替える)、 前提 14 の実行順序による事故は起きない。**symlink ブロックの移動は行わない**。 ただし将来 `~` 直下の生成物を symlink 対象へ加えると同じ衝突が起きるので、 @@ -517,7 +551,15 @@ issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問 sh -c 'for p in .claude.json .claude .gemini; do if [ ! -e "/from/$p" ]; then echo "skip (未作成): $p"; continue; fi if [ -d "/from/$p" ]; then - mkdir -p "/to/$p" && cp -a "/from/$p/." "/to/$p/" + mkdir -p "/to/$p" + # 分類 A への symlink (plugins / skills / commands / CLAUDE.md / + # settings.json) は書き戻さない。共通側の実体を指すリンクなので、 + # 書き戻すと実体が自分自身を指す symlink に置き換わる + for c in "/from/$p"/* "/from/$p"/.[!.]*; do + [ -e "$c" ] || [ -L "$c" ] || continue + [ -L "$c" ] && { echo "skip (共通側への link): ${c##*/}"; continue; } + cp -a "$c" "/to/$p/" + done else cp -a "/from/$p" "/to/$p" fi @@ -527,7 +569,9 @@ issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問 グループ内で一度も使っていないツールのエントリは存在しないことがあるため、各パスの存在を 確認してから `cp` し、無いものは `skip` として飛ばす(`&&` で連結すると 1 件目の欠落で - 以降の同期が止まる)。 + 以降の同期が止まる)。`/persistent/group/.claude` 配下には分類 A の実体へ向いた symlink が + 並ぶので(不変条件)、**symlink は書き戻さない**。書き戻すと共通側の実体 + (`/persistent/ai/.claude/plugins` 等) が自分自身を指す symlink に置き換わってしまう。 対象は分類 B のうち共通側に対応物があるものに限る。gcloud / gws は共通ボリュームに置き場が無く、 revert 後は永続化対象外(現行 main と同じ)へ戻るため書き戻さない。グループボリューム直下の diff --git a/tests/containers/test_entrypoint_ai_settings.py b/tests/containers/test_entrypoint_ai_settings.py new file mode 100644 index 00000000..530488f0 --- /dev/null +++ b/tests/containers/test_entrypoint_ai_settings.py @@ -0,0 +1,386 @@ +"""AI 設定の永続化 (共通 / アカウントグループの 2 層) — PLAN39 Task 3・4 + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +一時ディレクトリを ``/persistent/ai`` / ``/persistent/group`` / ``$HOME`` に見立てて +関数を直接呼ぶ。Docker には依存しない。 + +固定する契約: + +- 分類 A (共通) は ``/persistent/ai``、分類 B (グループ) は ``/persistent/group`` を指す +- ``~/.claude`` の既定はグループ側で、共通資産だけがその配下から共通側へ張られる +- 入れ子パスでも symlink が壊れない (親ディレクトリの作成 / ファイルとディレクトリの判別) +- 初回シードは ``default`` グループだけで、共通資産はコピーせず、2 回目は何もしない +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def run_entrypoint_fn(script: str, cwd: Path, env: dict | None = None): + """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。""" + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env={**base, **(env or {})}, + capture_output=True, text=True, + ) + + +@pytest.fixture +def roots(tmp_path: Path): + """home / persistent(ai) / persistent(group) の 3 つ組を作る。""" + home = tmp_path / "home" + ai = tmp_path / "persistent" / "ai" + group = tmp_path / "persistent" / "group" + home.mkdir(parents=True) + return home, ai, group + + +def setup(roots, group_name: str = "default", cwd: Path | None = None): + home, ai, grp = roots + result = run_entrypoint_fn( + f'devbase_setup_ai_settings "{home}" "{ai}" "{grp}" "{group_name}"', + cwd or home, + ) + assert result.returncode == 0, result.stderr or result.stdout + return result + + +# --------------------------------------------------------------------------- +# 2 系統の振り分け (AC3 / AC4) +# --------------------------------------------------------------------------- + +def test_shared_entries_point_at_the_shared_volume(roots): + home, ai, _ = roots + setup(roots) + + for entry in (".codex", ".serena", ".ssh", ".kiro", "share"): + link = home / entry + assert link.is_symlink(), f"{entry} が symlink ではない" + assert link.resolve() == (ai / entry).resolve() + + +def test_group_entries_point_at_the_group_volume(roots): + home, _, grp = roots + setup(roots, "kkg") + + for entry in (".claude.json", ".claude", ".gemini"): + link = home / entry + assert link.is_symlink(), f"{entry} が symlink ではない" + assert link.resolve() == (grp / entry).resolve() + + +def test_claude_defaults_to_the_group_volume(roots): + """``~/.claude`` 配下の既定はグループ側。 + + Claude Code は ``projects`` / ``sessions`` / ``tasks`` のようなディレクトリを + 随時作る。列挙したものだけを永続化すると列挙漏れが黙って揮発するため、 + 既定をグループ側に倒して共通にしたいものだけを名指しする。 + """ + home, _, grp = roots + setup(roots, "kkg") + + (home / ".claude" / "projects").mkdir(parents=True) + assert (grp / ".claude" / "projects").is_dir() + + +def test_shared_assets_under_claude_point_at_the_shared_volume(roots): + """AC4: どのグループから見ても共通資産は同一実体を指す。""" + home, ai, _ = roots + setup(roots, "kkg") + + for entry in ("plugins", "skills", "commands", "CLAUDE.md", "settings.json"): + path = home / ".claude" / entry + assert path.resolve() == (ai / ".claude" / entry).resolve(), entry + + +def test_two_groups_share_assets_but_not_credentials(roots, tmp_path): + """AC3 / AC4 をまとめて: 共通資産は同一、グループ別データは互いに見えない。""" + home_a, ai, group_a = roots + home_b = tmp_path / "home-b" + home_b.mkdir() + group_b = tmp_path / "persistent" / "kkg" + + setup((home_a, ai, group_a), "default") + setup((home_b, ai, group_b), "kkg") + + # 共通資産は同一実体 + assert (home_a / ".claude" / "plugins").resolve() == \ + (home_b / ".claude" / "plugins").resolve() + + # グループ別データは互いに到達できない + (home_a / ".claude" / ".credentials.json").write_text("default-secret") + assert not (home_b / ".claude" / ".credentials.json").exists() + + +# --------------------------------------------------------------------------- +# 入れ子パス (AC6 / 前提 5) +# --------------------------------------------------------------------------- + +def test_nested_file_entries_are_created_as_files(roots): + """``CLAUDE.md`` / ``settings.json`` はファイル。ディレクトリにすると書けない。""" + home, ai, _ = roots + setup(roots) + + for entry in ("CLAUDE.md", "settings.json"): + target = ai / ".claude" / entry + assert target.is_file(), f"{entry} がファイルとして作られていない" + assert not target.is_dir() + + +def test_nested_directory_entries_are_created_as_directories(roots): + home, ai, _ = roots + setup(roots) + + for entry in ("plugins", "skills", "commands"): + assert (ai / ".claude" / entry).is_dir(), entry + + +def test_nested_links_are_not_broken(roots): + """親ディレクトリが無くても壊れた symlink を残さない (前提 5)。""" + home, _, _ = roots + setup(roots) + + for entry in ("plugins", "CLAUDE.md"): + link = home / ".claude" / entry + assert link.is_symlink() + assert link.exists(), f"{entry} が壊れた symlink になっている" + + +def test_jsonl_entries_are_not_turned_into_directories(roots): + """``history.jsonl`` は ``*.json`` にマッチしないためディレクトリ化していた。""" + home, _, grp = roots + setup(roots) + + path = grp / ".claude" / "history.jsonl" + # 実体は Claude Code が作るので存在しないのが正常。存在するならファイルであること。 + assert not path.is_dir() + + # entrypoint がプレースホルダを作る経路でもディレクトリにしない + result = run_entrypoint_fn( + f'devbase_ensure_entry "{grp}/.claude/history.jsonl"', home) + assert result.returncode == 0, result.stderr + assert path.is_file(), "history.jsonl がファイルとして作られていない" + + +def test_credentials_json_is_reachable(roots): + """AC6: ``~/.claude/.credentials.json`` の親が無くても書き込める。""" + home, _, grp = roots + setup(roots) + + path = home / ".claude" / ".credentials.json" + path.write_text('{"ok": true}') + assert (grp / ".claude" / ".credentials.json").read_text() == '{"ok": true}' + + +# --------------------------------------------------------------------------- +# 既存状態からの張り替え +# --------------------------------------------------------------------------- + +def test_existing_wrong_symlink_is_replaced(roots): + """PLAN39 以前の ``~/.claude -> /persistent/ai/.claude`` を張り替える。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (home / ".claude").symlink_to(ai / ".claude") + + setup(roots, "kkg") + + assert (home / ".claude").resolve() == (grp / ".claude").resolve() + + +def test_existing_real_directory_in_home_is_replaced(roots): + home, _, grp = roots + (home / ".gemini").mkdir() + (home / ".gemini" / "leftover").write_text("x") + + setup(roots) + + assert (home / ".gemini").is_symlink() + assert (home / ".gemini").resolve() == (grp / ".gemini").resolve() + + +def test_broken_symlink_in_home_is_replaced(roots): + home, _, grp = roots + (home / ".codex").symlink_to(home / "does-not-exist") + + setup(roots) + + assert (home / ".codex").exists() + + +def test_setup_is_idempotent(roots): + home, ai, grp = roots + setup(roots) + (home / ".claude" / "projects").mkdir(parents=True) + (home / ".claude" / "projects" / "keep.txt").write_text("keep") + + setup(roots) + + assert (home / ".claude" / "projects" / "keep.txt").read_text() == "keep" + assert (home / ".claude" / "plugins").resolve() == (ai / ".claude" / "plugins").resolve() + + +# --------------------------------------------------------------------------- +# 初回シード (AC8) +# --------------------------------------------------------------------------- + +def _seed_source(ai: Path) -> None: + """現行 ``/persistent/ai`` に実体がある分類 B のデータを用意する。""" + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / ".credentials.json").write_text("token") + (ai / ".claude" / "history.jsonl").write_text('{"line": 1}\n') + (ai / ".claude" / "projects").mkdir() + (ai / ".claude" / "projects" / "a.jsonl").write_text("session") + (ai / ".claude" / "plugins").mkdir() + (ai / ".claude" / "plugins" / "big").write_text("x" * 100) + (ai / ".claude.json").write_text('{"oauthAccount": {}}') + (ai / ".gemini").mkdir() + (ai / ".gemini" / "settings.json").write_text('{"auth": "vertex-ai"}') + + +def test_default_group_is_seeded_from_the_shared_volume(roots): + """AC8: ``default`` は再ログインなしで移行できる。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (grp / ".claude" / ".credentials.json").read_text() == "token" + assert (grp / ".claude" / "history.jsonl").read_text() == '{"line": 1}\n' + assert (grp / ".claude" / "projects" / "a.jsonl").read_text() == "session" + assert (grp / ".claude.json").read_text() == '{"oauthAccount": {}}' + assert (grp / ".gemini" / "settings.json").read_text() == '{"auth": "vertex-ai"}' + + +def test_seed_does_not_copy_shared_assets(roots): + """共通資産はグループ数だけ重複させない (238MB の plugins をコピーしない)。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (grp / ".claude" / "plugins").is_symlink() + assert (grp / ".claude" / "plugins").resolve() == (ai / ".claude" / "plugins").resolve() + + +def test_seed_is_a_copy_not_a_move(roots): + """切り戻しの余地を残すため move ではなく copy にする。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (ai / ".claude" / ".credentials.json").read_text() == "token" + assert (ai / ".claude.json").exists() + + +def test_non_default_groups_are_not_seeded(roots): + """AC3: 分離の意味が失われるため非 default ではシードしない。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "kkg") + + assert not (grp / ".claude" / ".credentials.json").exists() + assert not (grp / ".claude" / "projects").exists() + assert not (grp / ".gemini" / "settings.json").exists() + # プレースホルダは作られるが、シード元の中身は入らない + assert (grp / ".claude.json").read_text() == "" + + +def test_seed_runs_only_once(roots): + """2 回目は何もしない (稼働後のデータをシード時点へ巻き戻さない)。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + (grp / ".claude" / ".credentials.json").write_text("refreshed") + (ai / ".claude" / ".credentials.json").write_text("stale") + + setup(roots, "default") + + assert (grp / ".claude" / ".credentials.json").read_text() == "refreshed" + + +def test_seed_skips_entries_without_a_source(roots): + """シード元が無いエントリ (gcloud / gws) があっても止まらない (AC8)。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / "history.jsonl").write_text("only-this\n") + + setup(roots, "default") + + assert (grp / ".claude" / "history.jsonl").read_text() == "only-this\n" + # .claude.json / .gemini はシード元が無いので空のプレースホルダのまま + assert (grp / ".claude.json").is_file() + assert (grp / ".gemini").is_dir() + + +def test_seed_copies_dotfiles(roots): + """``.credentials.json`` のような隠しファイルを取りこぼさない。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / ".last-cleanup").write_text("ts") + + setup(roots, "default") + + assert (grp / ".claude" / ".last-cleanup").read_text() == "ts" + + +# --------------------------------------------------------------------------- +# イメージ同梱の ~/.claude/settings.json の退避 +# --------------------------------------------------------------------------- + +HOOKS = '{"hooks":{"SessionStart":[]}}' + + +def test_image_claude_settings_are_kept_on_first_run(roots): + """Dockerfile が焼いた ``~/.claude/settings.json`` を空ファイルで潰さない。 + + symlink 張り替えは ``~/.claude`` を ``rm -rf`` するため、退避しないと + hooks 設定が初回起動で消えて共通側に空ファイルだけが残る。 + """ + home, ai, grp = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == HOOKS + # グループ側 -> 共通側の symlink 経由でも読める + assert (home / ".claude" / "settings.json").read_text() == HOOKS + + +def test_image_claude_settings_do_not_overwrite_the_shared_volume(roots): + """永続側に既存の設定があればイメージ側で上書きしない。""" + home, ai, _ = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / "settings.json").write_text('{"user": true}') + + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == '{"user": true}' + + +def test_second_run_does_not_seed_through_the_symlink(roots): + """2 回目以降 (``~/.claude`` が symlink) は退避を走らせない。""" + home, ai, _ = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + + setup(roots) + (ai / ".claude" / "settings.json").write_text('{"edited": true}') + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == '{"edited": true}' + assert (home / ".claude").is_symlink()