diff --git a/.gitignore b/.gitignore index f96d56b..8fad591 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,4 @@ plugins/*/ projects/* !projects/.gitkeep .env.sources.yml -issues/ .cache/ diff --git a/issues/PLAN03-1.md b/issues/PLAN03-1.md new file mode 100644 index 0000000..c402970 --- /dev/null +++ b/issues/PLAN03-1.md @@ -0,0 +1,339 @@ +# PLAN03-1: devbase env export / import + +> 元 issue: `issues/i03.md` 第1項 (devbase env export / import) +> ステータス: 着手可(未決事項すべて確定済み、2026-05-21) +> 関連 skill: `/ndf:issue-plan-strategy`, `/ndf:implementation-plan` + +## 1. 背景と目的 + +devbase は以下の階層で環境変数を管理している: + +| ファイル | 役割 | 機密性 | +|---|---|---| +| `$DEVBASE_ROOT/.env` | グローバル(AWS / GCP / Git credentials を base64 化したもの、`*_BASE64` キー群) | 高 | +| `$DEVBASE_ROOT/projects//.env` | プロジェクト固有変数(API キー、DB パスワード等) | 高 | +| `$DEVBASE_ROOT/projects//env` | プロジェクト固有変数の **公開可能な雛形**(git 管理) | 低 | +| `$DEVBASE_ROOT/.env.sources.yml` | 各 source の元ファイル・ハッシュ・同期時刻のメタデータ | 中 | + +現状の課題: + +- `.env` がプロジェクトごとに分散しており、新しいマシン / WSL / コンテナで devbase を再構築する際に **個別にコピーする必要**がある。 +- バックアップ運用がユーザー個別であり、`devbase` 自身が責任を持って一括退避・復元できない。 +- チームで「同じ環境を別マシンに移植」する手段が `scp -r` か手動コピーしかない(しかも機密が暗号化されないまま残る)。 + +本タスクのゴール: + +- グローバル `.env` と全プロジェクトの `.env` を**1ファイルにまとめて export / import** できる CLI を追加する。 +- まとめ方は複数提案し、運用要件に応じて選択できるようにする。 +- 出力先 / 入力元としてローカルファイルだけでなく **外部ストレージ(S3 等)**を扱えるようにする。 +- 機密情報を扱うため、**暗号化を既定**とし、誤って平文を流出させない設計にする。 + +## 2. 要件 + +### 2.1 機能要件 + +- `devbase env export [options]` で以下の対象をひとまとめにする: + - `$DEVBASE_ROOT/.env` + - `$DEVBASE_ROOT/projects/*/.env`(存在するもののみ) + - `$DEVBASE_ROOT/.env.sources.yml`(メタデータ。任意で除外可) +- `devbase env import [options]` で上記を復元する: + - 既存 `.env` が存在する場合の merge / replace を選べる。 + - `--dry-run` で差分プレビューできる。 +- 入出力先: + - ローカルファイル(パス指定) + - S3 URL(`s3://bucket/key`) + - 標準入出力(パイプ運用 / GPG/age と組み合わせる用途) + - ~~GCS URL(`gs://bucket/object`)~~(**廃案**: 利用見込みが小さく、boto3 + google-cloud-storage の依存増加に見合わないと判断したため。必要になった時点で別 PLAN として切り出す) +- 暗号化: + - 既定で暗号化(後述、複数案) + - 平文出力は既定で **拒否**。`--force-unencrypted` を明示した場合のみ許可し、その際も `*_BASE64` 等の機密キー検知時は **強い警告**を出す + - 拡張子も暗号化有無で区別する: 暗号化済み `*.dbenv` / 平文 `*.dbenv.tar.gz`(ファイル名から判別可能にし、事故を防ぐ) + +### 2.2 非機能要件 + +- バンドルファイルは **自己記述的**(バージョン・生成時刻・対象一覧をヘッダに含む) +- 復号鍵・パスフレーズは **環境変数 or ファイル経由**で渡し、コマンドラインに直接書かせない(プロセス一覧に残るため) +- 既存の `devbase env *` コマンド体系・引数命名規則と整合させる +- 失敗時の部分適用を避けるため import は 2 フェーズ方式(全ファイルの一時書き出し完了後に rename を実行)。途中失敗時は backup から best-effort で復旧する(厳密な ACID は OS / FS 制約上保証しない旨を docs に明記) + +### 2.3 セキュリティ要件 + +- 出力ファイルのデフォルトパーミッションは `0600` +- S3 アップロード時は SSE-KMS or SSE-S3 を強制(バケット側ポリシーに依存しないよう SDK 引数でも指定) +- パスフレーズはエコー無しで対話入力させる選択肢を残す(環境変数を使えない CI 外シナリオ向け) + +## 3. まとめ方の提案(複数案) + +| 案 | 形式 | 暗号化 | 拡張性 | 既存ツール依存 | 推奨度 | +|---|---|---|---|---|---| +| **A: tar.gz + age 暗号化** | `*.dbenv` (実態は `.tar.gz.age`) | age(鍵 or パスフレーズ) | 高(任意のファイルを追加可) | `age` バイナリ or python `pyrage` | ★★★(既定推奨) | +| B: YAML 単一ファイル + age | `*.dbenv.yml.age` | age | 中(構造化済み、メタデータ表現が容易) | 同上 | ★★ | +| C: tar.gz + GPG | `*.dbenv.gpg` | GPG | 高 | `gpg` バイナリ | ★ | +| D: tar.gz + openssl AES-256-CBC | `*.dbenv` | openssl | 高 | `openssl` | ★ | +| E: 平文 tar.gz | `*.tar.gz` | なし | 高 | 標準 lib のみ | × (デバッグ用途のみ) | + +### 採用: 案 A(tar.gz + age) + +- age は OpenSSH 鍵 / X25519 鍵 / scrypt パスフレーズの 3 通りに対応し、CI でもローカルでも使いやすい +- python から `pyrage` で扱えるため外部バイナリ不要にできる +- 鍵運用が GPG より圧倒的に軽い +- 既定アルゴリズムを 1 つに固定することで実装・運用コストを抑える + +#### age が受け付ける鍵種別 + +| 鍵 | recipient (公開鍵) | identity (秘密鍵) | 備考 | +|---|---|---|---| +| age X25519 (`age-keygen` 生成) | `age1...` | `AGE-SECRET-KEY-1...` | age ネイティブ、最も推奨 | +| OpenSSH ed25519 (`~/.ssh/id_ed25519`) | `ssh-ed25519 AAAA...` | `~/.ssh/id_ed25519` | そのまま使える | +| OpenSSH RSA (`~/.ssh/id_rsa`) | `ssh-rsa AAAA...` | `~/.ssh/id_rsa` | そのまま使える | +| OpenSSH ECDSA (`~/.ssh/id_ecdsa`) | ✗ | ✗ | **age 非対応**。age 専用鍵を用意してもらう必要あり | +| OpenSSH DSA | ✗ | ✗ | 非対応 | +| scrypt パスフレーズ | (なし) | (なし) | `--passphrase-env` / `--passphrase-stdin` で渡す | + +`--recipient` には `ssh-ed25519 ...` / `ssh-rsa ...` 形式の **公開鍵文字列** を直接渡すか、`@~/.ssh/id_ed25519.pub` のようにファイル参照させる。`--identity` は秘密鍵ファイルパスをそのまま受け付ける。 + +ECDSA 鍵しか持たないユーザー向けに、docs に `age-keygen` での鍵作成手順を記載する。 + +#### 既定鍵 + +- export の `--recipient` 省略時: `~/.ssh/id_rsa.pub` を使用 +- import の `--identity` 省略時: `~/.ssh/id_rsa` を使用 +- いずれも存在しない場合はエラーとし、明示指定 or `age-keygen` での鍵生成を案内する + +YAML 構造化(案 B)は **ヘッダメタデータ** にのみ採用し、ペイロード本体はファイル丸ごと tar に詰める案 A をベースとする。 + +### バンドル内構造(案 A) + +``` +manifest.yml # version, created_at, devbase_version, files[].sha256 +env/global.env # $DEVBASE_ROOT/.env をそのままコピー +env/sources.yml # .env.sources.yml(任意、--no-metadata で除外可) +env/projects//.env +... +``` + +`manifest.yml` 例: + +```yaml +version: 1 +created_at: '2026-05-21T10:00:00+09:00' +devbase_version: 2.2.0 +files: + - path: env/global.env + sha256: + origin: $DEVBASE_ROOT/.env + - path: env/projects/carmo/.env + sha256: + origin: $DEVBASE_ROOT/projects/carmo/.env +``` + +これを tar.gz 化し、age で暗号化したものが最終バンドル。 + +**バージョン互換ポリシー:** + +- `version` は整数で単調増加。互換性の無い変更時にのみインクリメントする +- import 側は **未知の `version`(自身がサポートする最大値より大きい)を検知したら拒否**し、devbase 本体のアップデートを促す +- 同じ major version 内では後方互換を保つ +- `sha256` は age AEAD と役割重複するが、(a) 復号後の個別ファイル検証、(b) `--force-unencrypted` 時の改ざん検知、(c) 部分展開のサポート用途で残す + +## 4. CLI 仕様 + +### 4.1 export + +``` +devbase env export [DEST] [options] + +DEST: + ファイルパス(省略時は ./devbase-env-.dbenv) + s3://bucket/key, gs://bucket/object も指定可 + +options: + --include-project NAME 対象プロジェクトを限定(複数指定可) + --exclude-project NAME 除外プロジェクト(複数指定可) + --no-global グローバル .env を含めない + --no-metadata .env.sources.yml を含めない + --force-unencrypted 平文 tar.gz として書き出す(既定は拒否。指定時も機密キー検知で警告) + --recipient KEY age 公開鍵で暗号化(複数指定可) + 形式: 'age1...' / 'ssh-ed25519 AAAA...' / 'ssh-rsa AAAA...' + '@PATH' でファイル参照可(例: @~/.ssh/id_ed25519.pub) + ※ ssh-ecdsa は age 非対応 + 省略時の既定: ~/.ssh/id_rsa.pub(存在する場合) + --passphrase-env VAR 環境変数 VAR からパスフレーズ取得 + --passphrase-stdin stdin の最初の行をパスフレーズとして使用 + --format tar|yaml バンドル形式(既定 tar) + --print-manifest 書き出さず manifest を stdout に出す(プレビュー用) +``` + +> 注: `DEST='-'`(stdout)と `--passphrase-stdin` は併用不可(同様に import でも `SOURCE='-'` と `--passphrase-stdin` は併用不可)。CLI 側で明示エラーにする。 + +### 4.2 import + +``` +devbase env import SOURCE [options] + +SOURCE: + ファイルパス、s3://..., gs://..., または '-' で stdin + +options: + --merge MODE キー単位マージ。MODE は keep-existing (既定) | prefer-incoming + keep-existing: 既存キーを保持、新規キーのみ追加 + prefer-incoming: バンドル側の値で上書き(API キーのローテ配布用) + --replace-keys KEY,... 指定キーのみバンドル値で上書き(粒度の細かい運用向け) + --replace 既存 .env を丸ごと差し替え(バックアップは取る) + --dry-run 実際には書かず差分のみ表示 + --identity FILE age / OpenSSH 秘密鍵ファイル(複数指定可) + 例: ~/.ssh/id_ed25519, ~/.ssh/id_rsa, age 専用鍵ファイル + ※ ssh-ecdsa は age 非対応 + 省略時の既定: ~/.ssh/id_rsa(存在する場合) + --passphrase-env VAR 環境変数 VAR からパスフレーズ取得 + --passphrase-stdin stdin の最初の行をパスフレーズとして使用 + --include-project NAME 対象プロジェクトを限定 + --exclude-project NAME 除外プロジェクト + --no-global グローバル .env を import しない + --no-metadata .env.sources.yml を import しない + --backup-dir DIR 上書き前バックアップの保存先(既定: $DEVBASE_ROOT/backups/env-import/) + --keep-last N backup-dir 内の古い backup を最新 N 個に整理(既定 10、0 で無効) +``` + +### 4.3 既存コマンドとの整合 + +- `cli.py` の `SUBCMD_MAP[('env',)]` に `'export'`, `'import'` を追加 +- パーサは `_add_env_parser` 内で新規 sub-subparser として登録 +- 振り分けは `commands/env.py` の `handlers` dict に登録(既存パターン踏襲) + +## 5. 内部設計 + +### 5.1 モジュール構成(追加分のみ) + +``` +lib/devbase/env/ + bundle.py # Bundle 構築/展開、manifest 生成・検証、sha256 + cipher.py # age 暗号化/復号(pyrage 経由) + storage.py # local / s3 / gcs バックエンドの抽象化 + io_export.py # export 高レベル実装 + io_import.py # import 高レベル実装(merge/replace, dry-run) +``` + +`commands/env.py` には薄いハンドラ(引数解釈 + 呼び出し)のみ追加し、ロジックは上記モジュールに置く。 + +### 5.2 storage バックエンド抽象 + +```python +class StorageBackend(Protocol): + def write_bytes(self, dest: str, data: bytes) -> None: ... + def read_bytes(self, source: str) -> bytes: ... + +def resolve(uri: str) -> StorageBackend: + # 'file://' / no scheme -> LocalBackend + # 's3://' -> S3Backend (boto3, optional dep) + # '-' -> StdioBackend +``` + +初期 PR では `Local` と `Stdio` のみ実装し(依存を増やさないため)、S3 backend は PR3 で追加する。`gs://` は廃案(PR4 中止)。 + +### 5.3 merge/replace のセマンティクス + +- `--merge=keep-existing`(既定): + - 既存キーは保持、新規キーのみ追加 + - 衝突したキーは「skip」として stdout に列挙 +- `--merge=prefer-incoming`: + - バンドル側の値で既存キーを上書き(ローテ済みクレデンシャルの配布等) + - 上書き対象キーは stdout に列挙 +- `--replace-keys KEY,...`: + - 指定キーのみバンドル値で上書き、それ以外は keep-existing 相当 +- `--replace`: + - 対象 `.env` を `backups/env-import//` にコピーしてから差し替え + - 差し替え対象は **バンドルに含まれていたファイル単位**(バンドル外のファイルは触らない) +- どちらも `--dry-run` で「追加されるキー / skip されるキー / 上書きされるキー」を表示 + +#### `.env.sources.yml` の取り扱い + +`.env.sources.yml` にはマシン固有の絶対パス・同期時刻・元ファイルのハッシュが含まれるため、別マシンでそのまま上書きすると整合性が壊れる。以下のポリシーで扱う: + +- 既定: import 時は **既存 `.env.sources.yml` を上書きしない**。バンドル内の sources.yml は `backups/env-import//sources.yml.imported` として参照用にコピーするのみ。 +- `--no-metadata`: バンドル内 sources.yml を完全に無視する(既定挙動と等価だが明示用)。 +- `--merge-metadata`: バンドル側で新規に登場する source エントリのみ追加する(マシン固有値である `origin_path`, `synced_at` は import 先環境に合わせて再計算)。 + +### 5.4 バックアップとロールバック + +- import は 2 フェーズ方式で部分適用を最小化する: + 1. 全対象ファイルを `backups/env-import//` にコピー + 2. **Phase 1 (prepare)**: 全対象ファイルの新内容を `.import.tmp` として書き出し、全件成功するまで rename しない + 3. **Phase 2 (commit)**: 全 tmp の書き出し成功を確認してから、各ファイルを `os.replace` で順次差し替え + 4. Phase 2 の途中で失敗した場合は backup から best-effort で `_rollback()`(OS / FS の制約上、厳密な ACID は保証しない旨を docs にも明記) +- backup 整理: `--keep-last N`(既定 10)で古い backup ディレクトリを自動 GC(無限増殖を防ぐ) +- 既存の `EnvFile.backup()` / `backups/` ディレクトリの慣習に揃える + +## 6. PR 分割計画 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | `feature/PLAN03-1-export-local` | `bundle.py` / `cipher.py` / `storage.py` (Local+Stdio) の実装 + `devbase env export` サブコマンドを同時に公開。E2E でユーザーが触れる状態で merge する(死蔵コード回避)。 | なし | ○ | +| 2 | `feature/PLAN03-1-import-local` | `env import` サブコマンド追加(merge=keep-existing/prefer-incoming, --replace-keys, --replace, dry-run, 2 フェーズ書き出し, backup, --keep-last)。 | PR1 | × (PR1 merge 後) | +| 3 | `feature/PLAN03-1-s3-backend` | S3 backend (`s3://`) 追加。`boto3` を optional dep として導入。SSE-KMS/SSE-S3 強制、`GetBucketEncryption` での事前確認、`--unsafe-allow-unencrypted-bucket` の実装。 | PR1, PR2 | × (両方 merge 後) | +| ~~4~~ | ~~`feature/PLAN03-1-gcs-backend`~~ | **廃案**: GCS backend (`gs://`) は利用見込みが小さいため取りやめる。必要時は別 PLAN で切り出す。 | — | — | +| 5 | `feature/PLAN03-1-docs` | `docs/user/env-export-import.md` 新設、README リンク追加、CHANGELOG 更新。 | PR1, PR2 | ○ (PR1 merge 後に着手可) | + +release branch: `release/PLAN03-1` +base branch: `main` + +> PR3 は依存ライブラリ (`boto3`) が増えるため、コア (PR1-PR2) を先に merge してリリース価値を出す。 +> +> PR 分割方針変更点: 旧案では「bundle/cipher のみの PR1 (CLI 未公開)」を独立させていたが、レビュー指摘により死蔵コードとなりレビューしづらい問題があったため、PR1 に `env export` の最小実装を同梱して E2E で動く状態で merge する形に統合した。 +> +> PR4 (GCS backend) は 2026-05-23 に **廃案**。利用見込みが小さく、依存追加コストに見合わないため。必要になった時点で別 PLAN として切り出す。 + +## 7. テスト方針 + +- **ユニットテスト** (`tests/env/`): + - `bundle.py`: round-trip(dict → bundle → dict)で内容一致 / sha256 検証 + - `cipher.py`: passphrase / recipient 双方のラウンドトリップ、破損データのエラー + - `storage.py`: Local / Stdio のラウンドトリップ + - merge/replace の挙動表テスト(衝突あり/なし、空ファイル、存在しないプロジェクト) +- **統合テスト** (`tests/cli/test_env_export_import.py`): + - tmp_path に擬似 DEVBASE_ROOT を作り、export → import で完全一致を確認 + - `--dry-run` が `.env` を変更しないこと + - `--replace` で backup が作成されること + - import 後も対象ファイルのパーミッションが `0600` を維持すること + - 改行コード (LF) / 末尾改行が export → import で保持されること(CRLF 混入による diff を防ぐ) + - `--force-unencrypted` 未指定で平文 export を試みた場合に拒否されること + - `DEST='-'` と `--passphrase-stdin` の併用がエラーになること + - 未知の manifest version を含むバンドルの import が拒否されること + - `--keep-last N` 後に古い backup ディレクトリが削除されていること +- **手動シナリオ**: + - 本番相当の `.env`(AWS_CONFIG_BASE64 / GCP_CREDENTIALS_BASE64_default 含む)を export → 別マシンで import → `devbase env list` が一致 + - S3 export → 別 PC で import(PR3 完了後) + +## 8. リスクと対応 + +| リスク | 対応 | +|---|---| +| age バイナリの環境差異 | `pyrage` を pip 依存に加え、外部バイナリに依存しない実装にする | +| ssh-ecdsa 鍵しか持たないユーザー | エラーメッセージで age 非対応を明示し、`age-keygen` での鍵生成手順を docs に記載・案内する | +| パスフレーズの誤入力でファイル破損扱い | import 前に manifest 検証ステップを置き、復号エラーを明示メッセージで分離 | +| S3 SSE 設定漏れで平文保存 | SDK 引数で `ServerSideEncryption='aws:kms'` を強制、加えて export 前に `GetBucketEncryption` でバケット側設定を確認(`HeadBucket` だけでは暗号化要件を検証できない)。設定不可なバケットは export を拒否し、`--unsafe-allow-unencrypted-bucket` でのみ許可 | +| 巨大プロジェクト数で tar が膨らむ | `--include-project` / `--exclude-project` の運用ガイドを docs に記載 | +| 既存 `.env` の手作業上書きとの衝突 | 既定を `--merge` にし、`--replace` 時は backup 必須 | + +## 9. 完了基準 (Definition of Done) + +- [ ] PR1, PR2 が merge され、`devbase env export` / `devbase env import` がローカルで動作 +- [ ] `docs/user/env-export-import.md` がリリースされる +- [ ] CHANGELOG 更新済み +- [ ] 統合テストが CI で green +- [ ] 別マシンで「export → 転送 → import → `devbase env list` で一致」を 1 回手動検証 + +## 10. 未決事項 + +すべて確定済み(2026-05-21): + +1. ✅ **age を採用**(GPG/openssl 案は不採用) +2. ✅ **S3 対応を本 PLAN に含める**(PR3 として実装)。**GCS (PR4) は 2026-05-23 廃案** — 利用見込みが小さく、依存追加 (`google-cloud-storage`) に見合わないと判断。 +3. ✅ **recipient ベースを既定推奨** とする + - 既定の鍵は `~/.ssh/id_rsa.pub`(export 時の recipient)/ `~/.ssh/id_rsa`(import 時の identity) + - 存在しない場合はエラーにし、`--recipient` / `--identity` の明示指定 or `age-keygen` での鍵生成を案内 + - OpenSSH 鍵 (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`) をそのまま `--recipient` / `--identity` に渡せる + - ssh-ecdsa は age 非対応のため、該当ユーザーには `age-keygen` で age 専用鍵生成を案内 + - passphrase ベース (`--passphrase-env` / `--passphrase-stdin`) は CI など鍵配布が難しい環境向けにサポート継続 +4. ✅ backup 保管: `--keep-last N`(既定 10)で対応 +5. ✅ 元 issue `issues/i03.md` 第 1 項とのスコープ整合は本 PLAN で網羅 diff --git a/issues/PLAN04_plugin-repo-persistence.md b/issues/PLAN04_plugin-repo-persistence.md new file mode 100644 index 0000000..f040da5 --- /dev/null +++ b/issues/PLAN04_plugin-repo-persistence.md @@ -0,0 +1,400 @@ +# PLAN04: plugins の .git 保持 — repos/ 永続クローン + シンボリックリンク化 + +## 背景と目的 + +現在 `devbase plugin install` は対象プラグインを `plugins/` ディレクトリにファイルコピーしている。 +この方式では `.git` 情報が失われるため、プラグインの修正・commit・push が非常にやりづらい。 + +**目標**: `repos/` ディレクトリに git clone を永続保持し、`projects/` からシンボリックリンクで参照する構造に変更する。 +これにより repos/ ベースのプラグインは `plugins/` を経由せず直接参照され、プラグイン開発は `repos/` 内で直接 git 操作が可能になる (`plugins/` は `--link` インストール専用として存続)。 + +## 現状アーキテクチャ + +``` +devbase/ +├── plugins.yml # レジストリ (installed_plugins + repositories) +├── plugins/ # ファイルコピー (.git なし) +│ ├── adminer/ +│ │ ├── plugin.yml +│ │ └── projects/adminer/ +│ ├── carmo-web/ +│ └── ... +└── projects/ # シンボリックリンク → plugins/*/projects/* + ├── adminer → ../plugins/adminer/projects/adminer + └── ... +``` + +**フロー (現状)**: +1. `repo add` → temp clone → `registry.yml` 読み取り → 破棄 → `plugins.yml` にメタデータ保存 +2. `plugin install` → temp clone → `copy_plugin()` で `plugins//` へコピー +3. `sync_projects()` → `projects/` → `../plugins//projects/` シンボリックリンク作成 +4. `plugin update` → temp clone → `_sync_dir()` で差分同期 (ユーザ編集保持) + +## 新アーキテクチャ + +``` +devbase/ +├── plugins.yml # レジストリ (スキーマ拡張: repos にローカルパス追加) +├── repos/ # git clone 永続保持 (.git あり) +│ ├── github.com--devbasex--devbase-samples/ # ← github.com/devbasex/devbase-samples.git +│ │ ├── .git/ +│ │ ├── registry.yml +│ │ ├── adminer/ +│ │ │ ├── plugin.yml +│ │ │ └── projects/adminer/ +│ │ └── ... +│ ├── github.com--volareinc--devbase-ext/ # ← github.com/volareinc/devbase-ext.git +│ └── github.com--takemi-ohama--devbase-ext/ # ← github.com/takemi-ohama/devbase-ext.git +├── plugins/ # --link 専用 (repos/ ベース install では不使用。--link が 0 件なら削除) +└── projects/ # repos/ を直接参照 + ├── adminer → ../repos/github.com--devbasex--devbase-samples/adminer/projects/adminer + └── ... +``` + +`plugins/` 中間層は設けない。PR1 から `projects/` → `repos/` の直接リンクにする。 +既存の `plugins/` ベースインストールは PR2 のマイグレーションで repos/ ベースに変換する。 + +> **NOTE**: repos/ 内でユーザーが加えた変更は projects/ 経由で即座に反映される。 +> これは意図的な仕様であり、プラグイン開発時にローカル変更を即テストできる利点がある。 + +**フロー (新)**: +1. `repo add` → `repos/--/` に永続 git clone → `registry.yml` 読み取り → `plugins.yml` 保存 +2. `plugin install` → repos/ 内の既存クローンからシンボリックリンク作成 (コピー不要) +3. `sync_projects()` → `projects/` → `../repos///projects/` 直接リンク +4. `plugin update` → `repos//` で `git pull` → シンボリックリンクは自動追従 +5. `repo refresh` → `repos//` で `git pull` + `registry.yml` 再読み込み → `plugins.yml` メタデータ更新 +6. プラグイン開発 → `repos//` で直接 commit/push 可能 + +## 変更対象ファイル + +### PR1 (repos/ 永続クローン + 直接リンク install) + +| ファイル | 変更内容 | +|---|---| +| `lib/devbase/plugin/models.py` | `RegisteredRepository` に `local_path` フィールド追加 | +| `lib/devbase/plugin/registry.py` | `get_repos_dir()` 追加、`InstalledPlugin.path` を repos/ ベースに変更、`get_plugins_dir()` は `--link` 用に維持 | +| `lib/devbase/plugin/repo_manager.py` | `add_repository()`: 永続クローン、`refresh_repository()`: git pull + registry.yml 再読み込み、`remove_repository()`: repos/ 削除 (dirty check + `--force` 対応) | +| `lib/devbase/plugin/installer.py` | `git_clone()`: `--depth 1` を除去し full clone に変更 (永続クローン用)、`_install_from_repo()`: repos/ ベースのシンボリックリンク作成に変更、`uninstall_plugin()`: repos/ 内ファイル保護 (シンボリックリンク削除のみ、`shutil.rmtree()` を排除)、`_link_plugin()`: `InstalledPlugin.path` を devbase_root 相対に変更、`copy_plugin()` / `_sync_dir()` 削除 | +| `lib/devbase/plugin/syncer.py` | `sync_projects()`: `plugins_dir.iterdir()` フラット走査から `InstalledPlugin.path` ベースのネスト対応走査に変更、`projects/` → repos/ 直接リンクに変更 + 同名衝突時の suffix リンク追加 | +| `lib/devbase/plugin/updater.py` | `update_plugin()`: git pull ベースに変更、`_migrate_removed_plugin()`: repos/ ベースでの再インストールに変更 (`copy_plugin()` 呼び出しを排除) | +| `.gitignore` | `repos/` 追加、マイグレーション完了後に `plugins/*/` / `!plugins/.gitkeep` を削除 | +| `tests/plugin/` | 新規テスト追加 | + +### PR2 (既存 plugins/ → repos/ マイグレーション) + +| ファイル | 変更内容 | +|---|---| +| 新規 `lib/devbase/plugin/migrator.py` | マイグレーションロジック (plugins/ の差分検出・repos/ クローン・パス書き換え・リンク再作成) | +| `lib/devbase/commands/plugin.py` | `devbase plugin migrate` サブコマンド追加 | +| ドキュメント | 移行手順の説明 | +| `tests/plugin/` | マイグレーションのテスト | + +## 設計上の重要判断 + +### 1. repos/ のディレクトリ命名規則 + +常に `host--owner--repo` 形式 (ダブルハイフン区切り) で統一する: +- `github.com--devbasex--devbase-samples` ← `https://github.com/devbasex/devbase-samples.git` +- `github.com--volareinc--devbase-ext` ← `https://github.com/volareinc/devbase-ext.git` +- `gitlab.com--user--my-repo` ← `https://gitlab.com/user/my-repo.git` +- URL から `host` と `owner/repo` を抽出し、`/` を `--` に置換して `host--owner--repo` を生成 +- SSH 形式 (`git@github.com:owner/repo.git`) と HTTPS 形式は同一 dirname に正規化され、重複検出が機能する +- `plugins.yml` の `RegisteredRepository` に `local_path` を追記して追跡 + +**host を含める理由**: github.com と gitlab.com など異なるホストで同名の owner/repo を扱う場合に dirname が衝突するのを防ぐため (PR1 レビューで判明し対応)。 + +**ドット区切りではなくダブルハイフンを使う理由**: owner 名やリポジトリ名にドットを含むケース (例: `my.org/my.repo`) でパース時に曖昧になるため。`--` は GitHub の owner/repo 名に使用されない文字列なので一意に分割できる。host (`github.com` 等) のドットはそのまま含めるが、`--` 区切りで host / owner / repo を分離するため曖昧にならない。 + +### 2. install 時のシンボリックリンク戦略 + +`plugins/` 中間層は設けず、`projects/` から `repos/` へ直接リンクする。 + +``` +# 現状 (ファイルコピー) +plugins/adminer/ ← 実ファイル (.git なし) +projects/adminer → ../plugins/adminer/projects/adminer + +# 新方式 (repos/ 直接リンク) +projects/adminer → ../repos/github.com--devbasex--devbase-samples/adminer/projects/adminer +``` + +`InstalledPlugin.path` は `repos/github.com--devbasex--devbase-samples/adminer` のように repos/ 配下のパスを保持する。 +パス解決を `get_plugins_dir()` ベースから `devbase_root` ベースに変更する。 +変更対象: `registry.py` (`get_plugins_dir()` → `get_repos_dir()`)、`installer.py` / `syncer.py` / `updater.py` のパス結合ロジック。 + +### 3. 同名プロジェクト衝突時の suffix 付きシンボリックリンク + +複数プラグインが同名プロジェクトを提供する場合、現状は priority が高い方だけがリンクされ、低い方は無視される (warning ログのみ)。 + +**新方式**: 衝突時に `.` 形式の suffix 付きシンボリックリンクも作成する。suffix には repos/ ディレクトリ名 (`host--owner--repo`) 全体を使い、同一 owner が複数リポジトリを持つケースでも一意になるようにする。 + +``` +projects/ +├── carmo → ../repos/github.com--volareinc--devbase-ext/carmo-web/projects/carmo # winner (priority 高) +└── carmo.github.com--takemi-ohama--devbase-ext → ../repos/github.com--takemi-ohama--devbase-ext/personal/projects/carmo # 明示指定用 (loser のみ) +``` + +- 衝突がない場合は bare name のみ (suffix なし、suffix 版は作成しない) +- 衝突がある場合は winner が bare name を取得し、**loser のみ** suffix 版を作成 +- winner は bare name でアクセスできるため suffix 版は不要 (リンクの重複を避ける) +- loser を使うには `cd projects/carmo.github.com--takemi-ohama--devbase-ext && devbase up` のように suffix 付きディレクトリに移動して起動する (`devbase up` は CWD のディレクトリ名を `COMPOSE_PROJECT_NAME` として使用する) +- suffix 識別子は `syncer._extract_owner()` が生成: repos/ ベースは `repos//...` の dirname 部分 (`parts[1]`) を、`--link` プラグインは `source` パス末尾を返す +- **互換性確認済み**: `devbase up` は `basename "$PWD"` を `COMPOSE_PROJECT_NAME` に設定するだけでバリデーションなし。Docker Compose もドット・ハイフンを含むプロジェクト名を許容するため、`carmo.github.com--takemi-ohama--devbase-ext` 形式で問題なく動作する +- ログに衝突の全候補と suffix 付きアクセス方法を表示 + +**変更対象**: `syncer.py` の `sync_projects()` に suffix リンク生成ロジック追加 + +### 4. --link インストールとの共存 + +`devbase plugin install --link /path:plugin` (ローカルリンク) は repos/ を経由せず、従来どおり動作する。 +repos/ 経由のインストールとローカルリンクは `InstalledPlugin.linked` フラグで区別される。 + +`--link` インストール時の `InstalledPlugin.path` はローカルパスへの symlink を指すため、`plugins/` ではなく devbase_root 相対の実パスを保持する。 +具体的には `_link_plugin()` で `plugins_dir / name` に symlink を作成する現行ロジックを維持し、`InstalledPlugin.path` は `plugins/{name}` のまま据え置く。 +`plugins/` ディレクトリは `--link` 専用として存続させ、repos/ ベースのインストールとは明確に分離する。 +マイグレーション後も `plugins/` を完全削除するのは `--link` インストールが 0 件の場合のみとする。 + +同名プロジェクト衝突が `--link` プラグインと repos/ プラグインの間で起きた場合の suffix ルール: +- `--link` プラグインは owner 情報を持たないため、suffix は `.` 形式とする (例: `carmo.my-local-repo`) +- `source-basename` は `InstalledPlugin.source` のパス末尾から取得する + +### 5. ref 指定時の制約 + +同一リポジトリの異なる branch/tag を別々に参照するケース (例: `repo-a@main` と `repo-a@v2`) は **PLAN04 スコープ外** とする。 +現状 `repos/` には 1 リポジトリにつき 1 クローンのみ保持し、ref はデフォルトブランチに固定する。 + +将来 ref 別管理が必要になった場合は `repos/@/` 形式で分離する設計を検討するが、 +`RegisteredRepository` のモデル拡張 (ref 単位の管理) や `git pull` 対象の複数化など影響が大きいため別 PLAN とする。 + +### 6. .gitignore と機密情報保護 + +repos/ 配下のリポジトリは各自の `.gitignore` で管理されるが、devbase 側でも防御: +- `repos/` 自体を devbase の `.gitignore` に追加 (devbase リポジトリには含めない) +- 各プラグインの `.env`, `.docker-compose.scale.yml` 等は各リポジトリの `.gitignore` 責任 +- マイグレーション完了後、既存の `plugins/*/` / `!plugins/.gitkeep` エントリは `--link` インストールが残っている場合のみ維持し、なければ削除する + +### 7. git_clone の full clone 化 + +現在の `git_clone()` は `--depth 1` で shallow clone を行っている (installer.py:57)。 +repos/ 永続クローンでは `git pull` / `git push` / `git log` 等のフル git 操作が必要なため、永続クローン用には `--depth 1` を除去する。 + +**変更方針**: `git_clone()` に `shallow: bool = True` パラメータを追加する。 +- 既存の temp clone 呼び出し (残存する場合) → `shallow=True` (デフォルト、現行動作維持) +- repos/ 永続クローン → `shallow=False` で full clone + +### 8. update / refresh の動作変更 + +`plugin update` と `repo refresh` はどちらも `git pull` を実行するが、目的が異なる: +- **`plugin update`**: 特定プラグインの更新。`git pull` 後、プラグインが削除されていた場合の `_migrate_removed_plugin()` 処理を含む +- **`repo refresh`**: リポジトリ全体のメタデータ更新。`git pull` 後、`registry.yml` を再読み込みして `plugins.yml` のプラグイン一覧を同期する。インストール済みプラグインが `registry.yml` から削除されていた場合は warning を表示し、`plugin update` と同等の `_migrate_removed_plugin()` 検出・通知を行う + +**`refresh_repository()` の遷移詳細**: 現行実装 (repo_manager.py:157-206) は temp clone → `parse_registry_yml()` → `RegisteredRepository` 再構築 → `registry.add_repository()` というフロー。新方式では: +1. `repos/--/` で `git pull` を実行 +2. 同ディレクトリ内の `registry.yml` を `parse_registry_yml()` で再読み込み +3. `RegisteredRepository` を再構築して `registry.add_repository()` で更新 (既存ロジック流用) +4. `resolve_repo_url()` は repos/ 永続クローン時に `add_repository()` で既に処理済みのため不要 + +| 操作 | 現状 | 新方式 | +|---|---|---| +| `plugin update` | temp clone → `_sync_dir()` 差分同期 | `git pull` (repos/ 内) | +| ユーザ編集の保持 | `.new` ファイルで衝突解決 | git の通常の merge/conflict で解決 | +| orphan ファイル | 自動保持 | git 管理外ファイルは untracked として残る | + +**利点**: git 本来のバージョン管理がそのまま使える。`_sync_dir()` の独自衝突解決ロジックが不要になる。 + +`repos/` 内で未コミット変更がある状態で `plugin update` (= `git pull`) が実行された場合、git が通常のエラーを返すのでそのまま伝搬する (強制 reset はしない)。 + +### 9. `repo remove` の安全性 + +`repos/` 内に未コミット・未 push の変更がある場合、`repo remove` でディレクトリごと削除すると作業が失われる。 +- `repo remove` 実行前に `repos/` 内の `git status` をチェック +- dirty (未コミット変更あり) または unpushed commits がある場合はエラーで中断し、状態を表示 +- `--force` フラグで強制削除を許可 + +### 10. `uninstall_plugin()` の repos/ 保護 + +現行の `uninstall_plugin()` (installer.py:459-476) は `shutil.rmtree(plugin_dir)` で `plugins/` 内のディレクトリを物理削除する。 +新方式では `InstalledPlugin.path` が `repos/` 配下を指すため、`shutil.rmtree()` をそのまま実行すると repos/ 内のファイルが破壊される。 + +**変更方針**: +- `linked=False` かつ repos/ ベースのプラグイン → `registry.remove(name)` + `sync_projects()` のみ実行。repos/ 内のファイルは削除しない +- `linked=True` (`--link` インストール) → 従来どおり `plugins/` 内の symlink を `unlink()` で削除 +- repos/ 内のファイルを完全に削除したい場合は `repo remove` を使う + +### 11. `repo add` の冪等性 + +現行の `add_repository()` (repo_manager.py:48-53) は重複 URL を `RepositoryError` で拒否する。 +新方式では repos/ に永続クローンが残るため、「既に `repo add` 済みの URL を再度 `repo add` する」シナリオが増える。 + +**方針**: 重複 URL チェックは維持し、エラーメッセージに `repos/` の既存クローンを再利用する旨を案内する (現行動作と同じ)。 +テスト項目の「同一 URL を 2 回実行したとき、既存クローンを再利用してエラーにならない」は誤り — 正しくは「同一 URL を 2 回実行したとき、RepositoryError が返り、repos/ の既存クローンは破壊されない」。 + +### 12. `sync_projects()` の走査方式変更 + +現行の `sync_projects()` (syncer.py:79) は `plugins_dir.iterdir()` でフラット走査し、各プラグインディレクトリの `projects/` を探索する。 +新方式では repos/ が `repos/--//` のネスト構造を持つため、この走査ロジックは根本的に変わる。 + +**変更方針**: `plugins_dir.iterdir()` ベースの走査を廃止し、`registry.list_installed()` から各 `InstalledPlugin.path` を取得して走査する。 +これにより repos/ のディレクトリ構造に依存せず、`InstalledPlugin` のメタデータから直接プラグインを参照できる。 + +```python +# 現行: plugins/ フラット走査 +for plugin_entry in sorted(plugins_dir.iterdir()): + if plugin_entry.name not in installed_names: + continue + ... + +# 新方式: InstalledPlugin.path ベース走査 +for plugin in registry.list_installed(): + plugin_dir = registry.devbase_root / plugin.path + if not plugin_dir.is_dir(): + logger.warning("Plugin directory missing: %s", plugin.path) + continue + ... +``` + +### 13. `RegisteredRepository.local_path` の後方互換性 + +`RegisteredRepository` に `local_path` フィールドを追加するが、`from_dict()` は `.get()` ベースのため、旧バージョンの devbase が新形式の `plugins.yml` を読み込んでも `local_path` は無視される (後方互換)。 +逆に新バージョンが旧形式を読む場合も `local_path` は `""` にフォールバックするため問題ない。 + +PR1 + PR2 は同時リリース (release/PLAN04 → main 一括マージ) のため、`InstalledPlugin.path` の新旧フォーマット (`plugins/X` vs `repos/owner--repo/X`) が混在する運用期間は発生しない。 + +### 14. マイグレーション戦略 + +既存の `plugins/` インストール → `repos/` ベースへの移行: +1. `repo add` 済みのリポジトリ → `repos/` にクローン (まだない場合) +2. 各 `plugins//` と `repos/` 内の対応ディレクトリを比較し、`plugins/` 側に git 未追跡の差分 (ユーザー変更) がないか検出 +3. 差分がある場合 → warning を表示し、`plugins//` を `plugins/.bak/` にリネームして保全。ユーザーに手動マージを促す +4. 差分がない場合 → `plugins//` を削除 +5. 各インストール済みプラグイン → `InstalledPlugin.path` を更新 +6. `sync_projects()` で全シンボリックリンクを再作成 +7. `plugins/` ディレクトリ内に `--link` インストールが残っていなければ、`.gitkeep` のみ残して削除。`--link` インストールが残っている場合は `plugins/` を維持する + +マイグレーションは `devbase plugin migrate` コマンドまたは `plugin install/update` 初回実行時に自動で行う。 + +## PR 分割計画 + +| PR # | branch 名 | base | 概要 | 状態 | +|---|---|---|---|---| +| 1 | feature/PLAN04-repos-core | release/PLAN04 | repos/ 永続クローン + 直接リンク install + git pull update + plugins/ 廃止 | ✅ マージ済み (#29, merge 79b661f) | +| 2 | feature/PLAN04-migration | release/PLAN04 | 既存 plugins/ → repos/ マイグレーション + ドキュメント (PR1 マージ後に作成) | ✅ マージ済み (#31, merge 5a6158a) | + +release branch: `release/PLAN04` (base: `main`) +release PR: **#26 (release/PLAN04 → main) — OPEN / Ready / MERGEABLE。PR1・PR2 統合済み、main へのリリース待ち** + +> **PR2 の PR 番号について**: 当初 PR #30 として作成し `/ndf:cross-review` で 6 round 収束させたが、 +> マージ前に rotate (PR #30 を close → 同一ブランチ `feature/PLAN04-migration` から PR #31 を再作成) し、 +> #31 を `release/PLAN04` へマージした。コードは #30 のレビュー収束済み内容と同一。 + +**リリース戦略**: PR1 → PR2 の順に `release/PLAN04` へマージし、全体を `main` へ一括リリースする。 +PR1 と PR2 は必ず同時リリースとなるため、`InstalledPlugin.path` の新旧フォーマット混在は発生しない。 +**現状**: PR1・PR2 ともに `release/PLAN04` へマージ完了 (HEAD=5a6158a)。次工程は release PR #26 を main へマージするリリースフェーズ。 + +### PR 1: repos/ 永続クローン + 直接リンク install (core) + +**スコープ**: +- `models.py`: `RegisteredRepository.local_path` フィールド追加、`from_dict()` / `to_dict()` 対応 +- `registry.py`: `get_repos_dir()` 追加、`InstalledPlugin.path` を repos/ ベースに変更、`get_plugins_dir()` は `--link` 用に維持 +- `repo_manager.py`: `add_repository()` を `repos/` 永続クローンに変更、`refresh_repository()` を `repos/` 内 git pull + registry.yml 再読み込みに変更 (temp clone 廃止)、`remove_repository()` で `repos/` 削除追加 (dirty check + `--force` 対応) +- `installer.py`: `git_clone()` に `shallow` パラメータ追加 (永続クローンは full clone)、`_install_from_repo()` を repos/ ベースのシンボリックリンク作成に変更、`uninstall_plugin()` を repos/ 保護対応に変更 (registry 削除 + sync のみ、`shutil.rmtree()` 排除)、`copy_plugin()` / `_sync_dir()` / `_SyncReport` / `_hash_file()` / `_replace_entry()` / `_ALWAYS_OVERWRITE_AT_ROOT` 削除 +- `syncer.py`: `sync_projects()` を `plugins_dir.iterdir()` フラット走査から `InstalledPlugin.path` ベース走査に変更、`projects/` → repos/ 直接リンクに変更 + 同名衝突時の suffix リンク追加 +- `updater.py`: `update_plugin()` を git pull ベースに変更、`_migrate_removed_plugin()` を repos/ ベースに変更 +- `.gitignore`: `repos/` 追加 (ディレクトリ全体を ignore) +- `tests/plugin/`: コア機能のユニットテスト + +**差分見積**: ~850 行 (テスト含む。`_sync_dir` 系 ~160 行削除、shallow パラメータ・uninstall 保護・syncer 走査変更の追加分を含む) + +### PR 2: 既存 plugins/ → repos/ マイグレーション + +**スコープ**: +- 新規 `migrator.py`: マイグレーションロジック (plugins/ の差分検出 → repos/ クローン → `InstalledPlugin.path` 書き換え → リンク再作成 → plugins/ 削除) +- `commands/plugin.py`: `devbase plugin migrate` サブコマンド追加 +- ドキュメント更新 +- マイグレーションのテスト + +**差分見積**: ~300 行 (テスト含む) + +## テスト計画 + +### PR 1 テスト項目 + +> PR1 (#29) マージ済み。自動テストでカバーした項目を `[x]`、手動/結合確認が必要な +> 項目を `[ ]` (末尾に「手動」と注記) とする。テストは `tests/plugin/test_repos_core.py` (全 50 テスト)。 + +**基本機能**: +- [x] `repo add` → `repos//` にクローンされる、`.git/` が存在する (`test_add_creates_persistent_clone`) +- [x] `plugin install ` → `projects/` が repos/ 内のプロジェクトへ直接リンクされる (`test_install_creates_symlinks_via_repos`, `test_install_all_plugins`) +- [x] `sync_projects()` → `projects/` が `../repos///projects/` へリンクされる (`test_basic_sync_creates_symlinks`) +- [x] `plugin update` → `git pull` が実行される (`test_update_calls_git_pull`, `test_update_deduplicates_git_pull`) +- [x] `plugin uninstall` → `projects/` のシンボリックリンクが削除される、repos/ 内のファイルは残る (`test_uninstall_repos_plugin_preserves_files`) +- [x] `repo refresh` → git pull + registry.yml 再読み込み (`test_refresh_pulls_and_updates_metadata`) +- [x] `repo refresh` → インストール済みプラグインが registry.yml から削除されていた場合に warning 表示 (`test_refresh_warns_removed_installed_plugin`) +- [x] `repo add` → 同一 URL を 2 回実行したとき RepositoryError が返り、repos/ の既存クローンは破壊されない (`test_add_duplicate_url_raises`) +- [x] `repo add` → full clone (shallow でない) が作成される (`test_shallow_false_no_depth` / `test_shallow_true_adds_depth`) +- [x] `repo remove` → dirty check 後に `repos//` ディレクトリも削除される (`test_remove_deletes_clone_dir`) +- [x] `repo remove` → repos/ 内に未コミット変更がある場合はエラーで中断、`--force` で強制削除 (`test_remove_dirty_repo_raises_without_force`, `test_remove_dirty_repo_succeeds_with_force`) +- [x] `repo remove` → インストール済みプラグインの `projects/` シンボリックリンクが全て削除される (`test_remove_uninstalls_plugins_and_syncs`) +- [x] `repo remove` → `repos/` ディレクトリが削除される (`test_remove_deletes_clone_dir`) +- [x] `repo remove --force` → dirty な repos/ でも強制削除される (`test_remove_dirty_repo_succeeds_with_force`) +- [x] `--link` インストールは従来どおり `plugins/` 内に symlink が作成される (`test_uninstall_linked_plugin_removes_symlink`) +- [ ] repos/ 内で直接 `git commit` / `git push` が可能 (手動 — 永続クローンの自明な性質) + +**同名衝突**: +- [x] 同名プロジェクト衝突時に loser に suffix 付きシンボリックリンクが作成される (`test_collision_creates_suffix_links`) +- [x] winner は bare name のみ (suffix 版は作成されない) (`test_winner_has_no_suffix`) +- [x] 衝突がない場合は suffix なしの bare name のみ作成される (`test_no_collision_no_suffix`) +- [ ] suffix 付きディレクトリに `cd` して `devbase up` で loser プロジェクトを起動できる (手動 — devbase up 結合確認) +- [x] `--link` プラグインと repos/ プラグインの衝突時に `.` suffix が正しく生成される (`test_link_plugin_collision_uses_source_basename`) + +**追加対応 (PR1 レビューで判明・実装)**: +- [x] 異なるホスト (github.com / gitlab.com) の同名 owner/repo が dirname 衝突しない (`test_different_hosts_produce_different_dirnames`) +- [x] SSH / HTTPS 形式の同一リポジトリが同一 dirname に正規化される (`test_ssh_and_https_same_host_match`) +- [x] 登録済みリポジトリへの `@ref` 指定は PluginError で拒否 (`test_install_ref_rejected_for_registered_repo`, `test_install_ref_rejected_for_unregistered_repo`) +- [x] `local_path` 未設定の legacy repo は install 時に永続クローンへ自動移行 (`test_install_legacy_repo_without_local_path`) +- [x] `refresh_repository` は git pull 前の projects スナップショットを `_update_repo_plugins` に渡す (`test_refresh_passes_pre_pull_projects`, `test_snapshot_*`) + +**エッジケース**: +- [ ] `repos/` 内で未コミット変更がある状態で `plugin update` → git エラーが伝搬される (強制 reset しない) (手動 — 実 git 操作) +- [ ] `repos/` ディレクトリが手動削除された状態で `plugin install` → 適切なエラーメッセージ (手動) +- [x] `repos/` ディレクトリ/プラグインディレクトリが欠落した状態で `sync_projects()` → warning 表示してスキップ (`test_missing_plugin_dir_warns`, `test_real_directory_skipped`) +- [ ] `repo add` 済み + `plugin install` 前に `repos/` 内のファイルを手動変更 → シンボリックリンク作成のみ、変更は projects/ 経由で反映される (手動 — 意図的な仕様) + +### PR 2 テスト項目 + +> PR2 (#31, 旧 #30) マージ済み。自動テストでカバーした項目を `[x]`、手動/結合確認が必要な項目を +> `[ ]` (末尾に「手動」と注記) とする。テストは `tests/plugin/test_migrator.py`。 +> release/PLAN04 全体で pytest 281 件 green / compileall OK / ruff(E9,F63,F7,F82) クリーン (2026-05-28 検証)。 + +- [x] `devbase plugin migrate` → 既存 plugins/ → repos/ 移行が正常完了 (`TestMigrateClean`, `TestCmdPluginMigrate`) +- [x] マイグレーション後に `projects/` のシンボリックリンクが正しい (`test_clean_migration_creates_repos_symlink`) +- [ ] マイグレーション前後で `devbase up ` が正常動作 (手動 — devbase up 結合確認) +- [x] `plugins/` にユーザー変更がある場合 → warning 表示 + `.bak/` にリネームして保全 (`TestMigrateWithLocalChanges`) +- [x] `plugins/` にユーザー変更がない場合 → そのまま削除 (`test_clean_migration_updates_path_and_deletes_copy`) +- [x] `--link` インストールが残っている場合 → `plugins/` ディレクトリは維持される (`TestMigrateKeepsLinked`) +- [x] `--link` インストールが 0 件の場合 → `plugins/` ディレクトリは `.gitkeep` のみ残して削除 (`test_clean_migration_empties_plugins_dir_to_gitkeep`) + +**追加実装 (計画の「自動移行」要件)**: +- [x] `plugin install` 初回実行時に legacy plugins/ を自動移行 (`TestAutoMigrateOnInstall`) +- [x] `plugin update` 初回実行時に legacy plugins/ を自動移行 (`TestAutoMigrateOnUpdate`) +- [x] `local_path` 未設定の legacy repo は移行時に永続クローンを作成 (`TestMigrateClonesMissingRepo`) +- [x] source repo が未登録の plugin は skip し、コピー/メタデータを破壊しない (`TestMigrateSkips`) +- [x] 差分判定 (`_dirs_differ`): 内容変更・ユーザー追加ファイル・upstream 追加ファイルを差分として検出 (`TestDirsDiffer`) + +## リスクと対策 + +| リスク | 影響 | 対策 | +|---|---|---| +| repos/ のディスク使用量増加 (full clone 化) | ストレージ圧迫 | extension リポジトリは履歴が軽量なため full clone で問題なし (shallow → full で数 MB 程度の増加)。将来肥大化した場合は `git gc` や shallow 化を検討 | +| 既存ユーザの plugins/ にカスタム変更がある | マイグレーションでデータ消失 | マイグレーション前に `plugins/` の差分検出 → 差分がある場合は `.bak/` にリネームして保全 + warning 表示 | +| 同一リポジトリ内の異なるブランチ/タグ参照 | repos/ は 1 クローン | PLAN04 スコープ外。将来 `repos/@/` 形式で分離を検討 | +| オフライン環境での初回 install | git clone 不可 | 既存 repos/ があればオフラインでもシンボリックリンク作成可 | +| repos/ 内の未コミット変更と plugin update の競合 | git pull 失敗 | git エラーをそのまま伝搬し、ユーザに commit/stash を促すメッセージを表示 | +| repos/ と plugins.yml の不整合 (手動操作等) | 動作不安定 | `repo refresh` で repos/ の存在確認 + 再クローン機能を提供 | +| `repo remove` で未コミット作業の消失 | データ消失 | dirty check (未コミット変更・unpushed commits) でエラー中断、`--force` で強制削除 | + +## スコープ外 (将来の検討事項) + +- ref 指定による同一リポジトリの複数クローン管理 (別 PLAN) +- `repo dev` コマンド (repos/ 内での開発ヘルパ — ニーズが明確になってから設計) +- Windows ネイティブ対応 (シンボリックリンク主体の設計のため非対応。WSL2 上での利用は問題なし — 要動作確認) diff --git a/issues/PLAN06_project-subcommand.md b/issues/PLAN06_project-subcommand.md new file mode 100644 index 0000000..15e7dec --- /dev/null +++ b/issues/PLAN06_project-subcommand.md @@ -0,0 +1,256 @@ +# PLAN06: `devbase project` サブコマンド導入 — プロジェクト名指定起動 + 一覧選択 + +## 関連リンク + +- 元 issue: `issues/i06.md` +- 依存: PLAN04 (repos/ 永続クローン + 同名衝突 suffix) — #26 / #29 / #31 で **merge 済み** + +## 進捗状況 + +- release PR: **#33** (`release/PLAN06` → `main`, OPEN) + +| Task / PR | branch | 状態 | +|---|---|---| +| Task 1 / PR1 | `feature/PLAN06-project-group` | ✅ **merge 済み (#34, 2026-05-30 → release/PLAN06)** | +| Task 2 / PR2 | `feature/PLAN06-name-resolution` | ✅ **merge 済み (#35, 2026-05-30 → release/PLAN06)** | +| Task 3 / PR3 | `feature/PLAN06-list` | ✅ **merge 済み (#36, 2026-06-02 → release/PLAN06)** | +| Task 4 / PR4 | `feature/PLAN06-docs-completion` | 🔵 PR **#37** OPEN(レビュー / merge 待ち) | + +> PR1 は codex/gemini クロスレビュー 4 round で収束。`project` parser + ハンドラ共有 + +> `container` 非推奨委譲に加え、レビュー指摘対応として以下も実施した(**Task 2 本体の +> name 解決 / wrapper cd は未着手**、PR2 へ持ち越し): +> - `project login/build` の positional を `index` / `image` に整理し引数曖昧さを解消 +> - top-level `build` ショートカットを wrapper の shell 実経路に整合 +> - top-level ショートカット (up/down/ps/scale) が `[name]` positional を**受理・下流伝播**する +> 経路を先行整備(PR1 段階では name 未解決のため「未対応」warning を出す挙動) +> - `_dispatch_lifecycle` で name 指定時に全サブコマンドで warning を出すよう統一 +> - `_add_login_subparser` / `_add_build_subparser` による parser 重複排除 +> +> PR2 は codex/gemini クロスレビュー 2 round で収束(#35, squash merge `a532ff8`)。 +> wrapper cd による name 解決 + トップレベルシノニムを実装し、レビュー指摘対応として +> 以下も実施した(最終 pytest 366 passed): +> - name 解決対象を `name` positional を持つサブコマンド(up/down/ps/logs/scale)に限定し、 +> `project login/build` の既存 positional(index / image)との衝突を回避 +> - Python フォールバック `_resolve_project_name` に `env` 読み込みを追加し、wrapper を +> 経ない直接起動時の環境変数欠落(`CONTAINER_SCALE` 等)divergence を解消 +> - `container.py` の未知 project 候補一覧に表示上限(先頭 20 件 + 省略表記)を導入 +> - `_PROJECT_NAME_SUBCOMMANDS` / `_NAME_RESOLVABLE_SHORTCUTS` と `cli.py` parser 定義の +> 同期注記コメントを追記 +> +> PR3 (#36) は codex/gemini クロスレビュー 3 round で両者 APPROVE 収束(最終 head +> `5b666e0`、最終 pytest 405 passed)。`project list` 一覧 + `--interactive` 選択起動を +> 実装し、レビュー指摘対応として以下も実施した: +> - `devbase l` が prefix 解決で `login`/`list` の両方にマッチして `unknown command 'l'` +> となる回帰を解消(wrapper `resolve_command` と Python `TOP_PREFIX_PREFERENCES` の双方に +> `l`→`login` の prefix preference を追加。`li` は `list` のまま)。両定義の乖離を検出する +> 同期テストも追加 +> - `project list` の各プロジェクト STATUS 取得(`docker compose ps`)を逐次実行から +> `ThreadPoolExecutor` による並列実行へ(`_container_status_for` は `cwd=` 完結で +> グローバル chdir せずスレッドセーフ) +> - `_resolve_plugin_name` が絶対パス symlink 先で `parts[0]=='/'` を plugin 名として +> 誤返却する問題に対し `/`・`..`・`.` を `None` 扱いにガード +> - `--interactive` 選択で数値以外 / 範囲外入力時に即終了せず再入力ループ化。Ctrl+C +> (`KeyboardInterrupt`) は traceback を出さず rc=0 で中止 +> - `_load_project_env`(`container.py`)と shell `source` の env パース仕様乖離(変数展開 / +> コマンド置換 / 行中クォート / インラインコメント)を docstring に明文化し、乖離挙動を +> pin する回帰テストを追加(→ 後述「後日対応予定」参照) + +## 概要 + +`devbase project up ` のように、CWD に依存せずプロジェクト名でコンテナ操作できる +`project` サブコマンド群を導入する。あわせて: + +1. `devbase project up/down/ps/login/logs/scale/build [name]` を新設 +2. `devbase project list [--interactive]` で一覧表示 + 選択式起動 +3. 既存 `devbase container *` を非推奨化し `project` へ委譲(移行期間後に削除) +4. トップレベルショートカット (`devbase up [name]` 等) を `project *` のシノニムに整理 + +## 問題・背景 + +現状すべてのコンテナ操作は **CWD 依存**で実装されている: + +- `bin/devbase` (wrapper) が起動時に `COMPOSE_PROJECT_NAME=$(basename "$PWD")` を設定し、 + `./env` を source する +- `build` は wrapper 内の shell 関数 `cmd_build` が **CWD で** `docker compose build` を実行 +- Python 側 `cmd_up` 等も `docker compose` / `Path('./env')` / `Path('compose.yml')` / + `.docker-compose.scale.yml` をすべて **CWD 基準**で扱う + +そのため起動には毎回 `cd projects/` が必要で、`container` という命名も Docker の +実装詳細を露出している。 + +### アーキテクチャ方針(重要な設計判断) + +プロジェクト名解決は **wrapper (`bin/devbase`) レベルで cd する**方式を採用する。 + +| 方式 | 内容 | 採否 | +|---|---|---| +| **A: wrapper で cd** | `` → `$DEVBASE_ROOT/projects/` を解決し wrapper が `cd` してから従来通り起動 | **採用** | +| B: Python で `os.chdir` | Python だけで解決 | 不採用 | + +**A を採用する理由:** + +- `build` は wrapper の shell 実装で CWD 依存 → Python 側 chdir では build をカバーできない +- `COMPOSE_PROJECT_NAME` / `./env` source も wrapper が **Python 起動前**に実施するため、 + 単一地点 (wrapper) で cd すれば下流(Python / shell build 両方)が変更不要で動く +- Python 側を CWD ベースのまま維持でき、既存ロジックへの破壊的変更を最小化できる + +Python 側にも**防御的に** `name → chdir` フォールバックを実装する(`python -m devbase.cli` +直接起動や `_ensure_env_files` 経由の安全網。COMPOSE_PROJECT_NAME 上書きも行う)。 + +#### 親シェルの CWD は汚染されない(重要な安全性保証) + +wrapper が `cd` しても、**`devbase` を叩いた親インタラクティブシェルの CWD は変わらない**。 +`cd` (`chdir(2)`) はプロセスごとに独立で、子プロセスの cd は親へ伝播しないため。 + +- `init.py` は devbase を **PATH 上の実行ファイル**として登録する(alias / shell 関数では + ない)。よって `devbase` は常に子プロセスとして起動される +- **異常終了時も同様**: 子プロセスの CWD は fork 時に複製された別物であり、クラッシュ / + `set -e` exit / シグナル死のいずれでも「子の cd を親に反映する経路」自体が存在しない +- `run_python` の `exec` はプロセスを置換するが依然「親シェルの子」であり CWD は隔離される +- **唯一の前提**: この保証は devbase を *コマンドとして* 実行する限り成立する。手動で + `source bin/devbase` すると cd が親へ漏れるため、ドキュメントで sourcing を案内しない + (PATH 方式の維持が条件) + +```mermaid +flowchart LR + A["devbase project up adminer"] --> B{wrapper:
name 引数あり?} + B -- yes --> C["cd $DEVBASE_ROOT/projects/adminer
COMPOSE_PROJECT_NAME=adminer
source ./env"] + B -- no --> D["従来通り CWD ベース"] + C --> E["run_python project up
(name は strip 済み)"] + D --> E + E --> F["cmd_up (CWD で docker compose)"] +``` + +## 修正対象 + +- `lib/devbase/cli.py` — `project` parser / shortcuts / prefix 解決 / dispatch +- `lib/devbase/commands/container.py` — ハンドラ共有・deprecation・name フォールバック +- `lib/devbase/commands/project.py` — **新規**(`project list` 等の listing ロジック) +- `bin/devbase` — name 解決 + cd、command 一覧更新 +- `etc/devbase-completion.bash`, `etc/_devbase` — 補完更新 +- `docs/user/cli-reference.md`, `docs/user/container-operations.md` — リネーム反映 +- `CHANGELOG.md` +- `tests/cli/` — dispatch / 名前解決 / listing のテスト + +## タスク分解 + +### Task 1: `project` サブコマンド group + ハンドラ共有 (PR1) — ✅ merge 済み (#34) + +- **対象:** `lib/devbase/cli.py`, `lib/devbase/commands/container.py`, `tests/cli/` +- **変更内容:** + - `_add_project_parser` を追加(`container` と同じ subcommand 群: up/down/ps/login/logs/scale/build)。各コマンドに省略可能 `[name]` positional を追加 + - dispatch で `project` を `cmd_container` 相当の共有ハンドラへ振り分け(実装の重複を避けハンドラを共有) + - `container` は**非推奨 warning** を出して `project` ハンドラへ委譲するエイリアスに + - `SUBCMD_MAP` / `_expand_argv` の commands 一覧に `project` を追加(prefix 解決対応) + - **コマンドリストは 3 箇所で重複管理されている点に注意**: (1) Python `cli.py` の `SUBCMD_MAP`、 + (2) Python `cli.py:_expand_argv` の `commands` ハードコード配列、(3) wrapper `bin/devbase` の + `resolve_command` が持つ独自リスト + dispatch の `case` 文。`project` 追加時は **3 箇所すべて**を + 同期する(特に wrapper の `resolve_command` / `case` は別 PR(Task 2)で触るが、prefix 解決を + 効かせるなら Task 1 時点で wrapper 側 `case` への `project` 追加が必要かを切り分けること) + - この段階では runtime 挙動は従来と同等(リネーム + 委譲のみ、cd なし) + +### Task 2: プロジェクト名解決 + wrapper cd (PR2) — ✅ merge 済み (#35) + +- **対象:** `bin/devbase`, `lib/devbase/cli.py`, `lib/devbase/commands/container.py`, `tests/cli/` +- **変更内容:** + - wrapper: `project ` / 後述シノニム ` ` の `` を検出し + `$DEVBASE_ROOT/projects/` の存在を確認 → `cd` → `COMPOSE_PROJECT_NAME=` / + `source ./env` → argv から name を strip して Python へ + - **初期化順序に注意**: 現状 `bin/devbase` は dispatch より前・元の CWD で + `COMPOSE_PROJECT_NAME=$(basename "$PWD")`(`:17`)と `source ./env`(`:24`)を実行している。 + name 解決で cd する場合、この 2 行は **cd 後に再実行(上書き)** する必要があるため、 + name 検出 → cd → 再設定の順に組み替える。なお source 対象は devbase の `env` ファイルであり、 + プロジェクトの `.env`(dotfile)は CRLF / 特殊文字対策で**意図的に source しない**(`:18-22` の + コメント参照)ため、cd 後の再 source でもこの方針を踏襲する + - 不正な name(`projects/` に存在しない)はエラー終了し候補を提示 + - Python: `[name]` 受領時に未 cd なら `os.chdir` + `COMPOSE_PROJECT_NAME` 上書きする + 防御的フォールバック。**この chdir は各ハンドラに散らさず `cmd_container` ディスパッチャ + (`container.py:84`)で handler 呼び出し前に一括実施する**。`cmd_down()` 等は `project_name` + 引数を取らない(`container.py:91`)ため、per-handler 実装だと down/login/logs で名前解決が + 効かなくなる + - これにより `project up/down/login/logs/scale/build [name]` と + **トップレベルシノニム** `devbase up/down/login/build/scale [name]` が同時に成立する + (ショートカットも wrapper を経由するため) + - **`logs` はトップレベルシノニムを作らない**(現状 `SHORTCUTS`(`cli.py:20-27`)に `logs` が + 無いことと整合)。`logs` は `project logs [name]` 経由のみとする + - **`build` の name 解決は wrapper cd に 100% 依存する**(`build` は Python ではなく wrapper の + shell 関数 `cmd_build`(`bin/devbase:33-142,197`)で CWD 実行されるため、Python 側 chdir + フォールバックでは救えない)。これは方針 A 採用の核心であり、wrapper 経路のテストが必須 + +### Task 3: `project list` / `ps` 一覧表示 + `--interactive` (PR3) + +- **対象:** `lib/devbase/commands/project.py` (新規), `lib/devbase/cli.py`, `tests/cli/` +- **変更内容:** + - `project list`: `NAME / PLUGIN / STATUS` 列で一覧表示 + - 一覧元は `$DEVBASE_ROOT/projects/` の symlink 群(`status.py` の + `_get_container_status` ロジックを再利用) + - PLUGIN 列は symlink 先 (`repos///projects/`) から plugin 名を解決 + (`_get_container_status` は plugin 情報を返さないため、symlink 先解決ロジックの追加が必要) + - PLAN04 の同名衝突 suffix(例 `carmo.takemi`)もそのまま表示。**suffix がリンク名のみに + 付くのか、リンク先 dir 名 (`projects/`) にも付くのか**を PLAN04 の付与仕様で確認し、 + PLUGIN 列の解決ロジックがどちらでも壊れないことを担保する + - `project ps` は従来の `docker compose ps`(CWD/対象プロジェクト)と一覧の役割を整理 + - `--interactive`: 一覧から選択 → 選択プロジェクトで `project up` を起動 + (`simple_term_menu` 等。非 TTY / 依存無し環境では番号入力 fallback) + - トップレベルシノニム `devbase list` / `devbase ps` を整備 + +### Task 4: 後方互換 deprecation + ドキュメント + 補完 (PR4) + +- **対象:** `etc/devbase-completion.bash`, `etc/_devbase`, `docs/user/*.md`, `CHANGELOG.md` +- **変更内容:** + - bash / zsh 補完に `project` グループ + project 名補完(`projects/` 配下を列挙)を追加 + - `container` の非推奨告知を docs / 補完に反映(移行期間と削除予定を明記) + - `cli-reference.md` / `container-operations.md` を `project` 体系にリネーム反映 + - CHANGELOG 追記 + +## PR 分割計画 + +複数 PR に分割する根拠: (1) parser リネーム / (2) wrapper の cd という runtime 経路の +変更 / (3) 新規 listing + interactive UI / (4) 補完・docs という、**レビュー観点も +リスクプロファイルも異なる 4 領域**に分かれるため。特に Task 2 は wrapper の起動経路に +触れる高リスク変更で、parser リネーム (Task 1) と混ぜると差分が読みにくくなる。 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 ✅ | `feature/PLAN06-project-group` | `project` parser + ハンドラ共有 + `container` 非推奨委譲 | なし | **merge 済み (#34)** | +| 2 ✅ | `feature/PLAN06-name-resolution` | wrapper cd によるプロジェクト名解決 + シノニム | PR1 | **merge 済み (#35)** | +| 3 ✅ | `feature/PLAN06-list` | `project list` 一覧 + `--interactive` | PR1 (listing) / PR2 (interactive 起動) | **merge 済み (#36)** | +| 4 🔵 | `feature/PLAN06-docs-completion` | 補完 + docs + CHANGELOG + 非推奨告知 | PR1〜3 | **PR #37 OPEN** | + +``` +release branch: release/PLAN06 +base branch: main +``` + +## 影響範囲 + +- **後方互換:** `devbase container *` は非推奨 warning 付きで動作継続(移行期間 1〜2 + リリース後に削除)。既存の `up/down/...` ショートカットは引数なしで従来通り CWD + フォールバック +- **wrapper 起動経路:** Task 2 が `bin/devbase` の cd / env source 順序に影響するため、 + project ディレクトリ外での実行・name 解決失敗時のエラー挙動を要検証 +- `status.py` の listing ロジックを `project list` と共有するためのリファクタが入る可能性 + +## テスト計画 + +- [ ] `devbase project up ` が任意の CWD から対象プロジェクトを起動できる +- [ ] `devbase project build ` が任意の CWD から成立する(wrapper cd 依存経路の検証) +- [ ] 引数省略時 (`devbase project up` / `devbase up`) は従来通り CWD ベースで動作する +- [ ] 存在しない name でエラー + 候補提示になる +- [ ] `devbase project scale ` の positional 解析が曖昧にならない + (`[name]` optional + `new_scale` 必須 int の組合せ。Python 直接起動の防御フォールバック経路も含む) +- [ ] `devbase container up` が非推奨 warning を出しつつ従来通り起動する +- [ ] `devbase project list` が NAME/PLUGIN/STATUS を正しく表示する(衝突 suffix 含む) +- [ ] `--interactive` 選択 → 起動が成立する(非 TTY fallback 含む) +- [ ] トップレベルシノニム (`up/down/list/ps/login/build/scale [name]`) が `project *` と等価 +- [ ] bash / zsh 補完で `project` + project 名が補完される +- [ ] 既存コンテナ操作にリグレッションがない(`scale` の online 追加等) + +## 後日対応予定(クロスレビューで deferred とした指摘) + +将来 PR で扱う想定。現時点では実害が小さい / 仕様統一のリスクが大きいため見送ったもの。 + +| # | 由来 | 内容 | 現状の対応 | 後日対応方針 | +|---|---|---|---|---| +| 1 | PR #35 / #36 (gemini) | Python `_load_project_env` と shell `source` の env パース仕様乖離(`FOO=$BAR` の変数展開・`$(cmd)` のコマンド置換・行中クォート・インラインコメントを Python 側は解釈しない)。wrapper を経ない直接起動のフォールバック時のみ影響 | `container.py` の `_load_project_env` docstring に乖離ケースを明文化 + 乖離挙動を pin する回帰テストを追加(commit `5b666e0`)。**ドキュメント化で対応済み** | パーサの完全統一(shell `source` と一致させる)は影響範囲が大きいため見送り。需要が出た時点で別 PR で検討 | +``` \ No newline at end of file diff --git a/issues/i04.md b/issues/i04.md new file mode 100644 index 0000000..15b6bf7 --- /dev/null +++ b/issues/i04.md @@ -0,0 +1,15 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントは開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* aiからのレポートはこのファイルに書き込むのではなく、issues/にファイルを作って出力してください + +# 1. pluginsの .git保持 +* 現在、devbase plugin installでは、対象となるpluginをplugins/ディレクトリに格納しています。 +* しかし、これではgitリポジトリの情報(.git)を引き継がないため、プラグインの修正や機能追加が非常にやりづらいです。 +* そこで、devbase plugin repo add https://github.com/devbasex/devbase-samples.git でリポジトリを登録した際、repos/ディレクトリにgit cloneする仕様に変更して下さい + * devbase plugin install はこのリポジトリからprojectsフォルダにシンボリックリンクを張ることになります + * 必然的にpluginsディレクトリは廃止となるはずです +* シンボリックリンクなので、プラグインの変更commit/pushは、repos/のリポジトリをそのままcommit/pushするだけになります。 +* ただし、.envなどの機密情報や.docker-compose.scale.ymlなどの一時ファイルは厳密に.gitignoreする必要があります。 +* planを作成してissues/に保存してください。 diff --git a/issues/i05.md b/issues/i05.md new file mode 100644 index 0000000..e69de29 diff --git a/issues/i06.md b/issues/i06.md new file mode 100644 index 0000000..f6231b5 --- /dev/null +++ b/issues/i06.md @@ -0,0 +1,81 @@ +# i06: `devbase project` サブコマンド導入 — プロジェクト名指定起動 + 一覧選択 + +## 背景 + +現在 `devbase up` は CWD のディレクトリ名 (または `COMPOSE_PROJECT_NAME`) でプロジェクトを特定する。 +そのため、プロジェクトを起動するには毎回 `cd projects/` してから `devbase up` する必要がある。 + +また `devbase container up` というコマンド名は Docker コンテナの操作という実装詳細を露出しており、 +ユーザが意図する「プロジェクトを立ち上げる」という操作とずれている。 + +## 提案 + +### 1. `devbase project up ` でプロジェクト起動 + +```bash +# 現状: cd が必要 +cd projects/adminer +devbase up + +# 新方式: どこからでもプロジェクト名で起動 +devbase project up adminer +``` + +- `projects/` ディレクトリ内のシンボリックリンク名をプロジェクト名として解決する +- 引数省略時は現状と同じく CWD ベースにフォールバック + +### 2. `devbase project list` でプロジェクト一覧 + 選択式起動 + +```bash +devbase project list +# → プロジェクト一覧を表示 + +devbase project list --interactive +# → 一覧から選択して起動 (inquirer / simple_term_menu 等) +``` + +表示例: +``` + NAME PLUGIN STATUS + adminer adminer running + carmo carmo-web stopped + carmo.takemi personal stopped (※ PLAN04 衝突 suffix) +``` + +### 3. コマンド体系のリネーム + +| 現状 | 新方式 | 備考 | +|---|---|---| +| `devbase container up` | `devbase project up [name]` | プロジェクト名引数を追加 | +| `devbase container down` | `devbase project down [name]` | 同上 | +| `devbase container ps` | `devbase project ps` / `devbase project list` | 一覧と統合 | +| `devbase container login` | `devbase project login [name]` | 同上 | +| `devbase container logs` | `devbase project logs [name]` | 同上 | +| `devbase container scale` | `devbase project scale [name]` | 同上 | +| `devbase container build` | `devbase project build [name]` | 同上 | +| `devbase up [name]` (ショートカット) | `devbase project up [name]` のシノニム | 引数なし時は CWD フォールバック | +| `devbase list` (ショートカット) | `devbase project list` のシノニム | | +| `devbase container` (エイリアス `ct`) | 非推奨化 → 将来削除 | 移行期間中はエイリアスとして残す | + +## シノニム (トップレベルショートカット) + +トップレベルコマンドは `devbase project *` のシノニムとして同じ引数を受け入れる: + +| ショートカット | 実体 | +|---|---| +| `devbase up [name]` | `devbase project up [name]` | +| `devbase down [name]` | `devbase project down [name]` | +| `devbase list` | `devbase project list` | +| `devbase ps` | `devbase project ps` | +| `devbase login [name]` | `devbase project login [name]` | +| `devbase build [name]` | `devbase project build [name]` | +| `devbase scale [name] N` | `devbase project scale [name] N` | + +## 後方互換性 + +- `devbase container *` は非推奨 warning を出しつつ `devbase project *` に委譲 +- 移行期間 (1〜2 リリース) 後に `container` サブコマンドを削除 + +## 依存 + +- PLAN04 (repos/ 永続クローン) の同名衝突 suffix が `project list` の表示に影響するため、PLAN04 完了後が望ましい diff --git a/issues/i28_docker-init-zombie-reap.md b/issues/i28_docker-init-zombie-reap.md new file mode 100644 index 0000000..3d82aec --- /dev/null +++ b/issues/i28_docker-init-zombie-reap.md @@ -0,0 +1,82 @@ +# i28: Docker コンテナのゾンビプロセス蓄積を `init: true` 注入で解消する + +## 関連リンク + +- Issue: https://github.com/devbasex/devbase/issues/28 +- 関連 issue: https://github.com/devbasex/ai-plugins/issues/21 + +## 概要 + +`generate_scaled_compose()` が生成する各サービスに `init: true` を `setdefault` で注入し、 +docker がコンテナ PID 1 に tini を挿入するようにする。これにより orphan プロセスが自動 reap され、 +ゾンビ (``) の蓄積が解消される。 + +## 問題・背景 + +- devbase コンテナの PID 1 は entrypoint の `tail -f /dev/null` であり、SIGCHLD を受けても orphan を reap しない。 +- このため `nohup ... & disown` で起動・終了したプロセスがゾンビ化して蓄積する。 +- 特に `ndf:cross-review` skill は codex/gemini CLI を `nohup & disown` で起動し `monitor.py` で監視するため、 + 以下の二次被害が出る: + 1. ゾンビに対しても `kill -0` が成功し、`monitor.py` が「実行中」と誤判定 → hard timeout (420s) まで待たされる + 2. PID 1 が reap しないためコンテナ再起動まで蓄積し続ける +- Docker Compose の `init: true` は 20KB の軽量 init (tini) を PID 1 として挿入し、シグナル転送 + ゾンビ reap を行う。 + `PR_SET_CHILD_SUBREAPER` より単純で確実。 + +### 注入箇所が `generate_scaled_compose()` で十分な根拠 + +`devbase up` (`cmd_up`) は **常に** `generate_scaled_compose()` を呼び、生成した +`.docker-compose.scale.yml` **単独** で `docker compose up` する (`lib/devbase/commands/container.py:175,179`)。 +scale=1 でも同経路を通るため、ここへの注入で全 `devbase up` ケースを網羅できる。 +ベース `compose.yml` テンプレート側の変更は不要。 + +## 修正対象 + +- `lib/devbase/volume/compose.py` — `generate_scaled_compose()` の 2 つのサービス生成ループ +- `tests/volume/test_compose.py` (新規) — `init` 注入のユニットテスト +- `tests/volume/__init__.py` (新規) — テストパッケージ初期化 + +## タスク分解 + +### Task 1: dev インスタンスへの `init` 注入 + +- **対象ファイル:** `lib/devbase/volume/compose.py` +- **変更内容:** dev インスタンス複製ループ (`for i in range(1, scale + 1)` 内) で + `service.setdefault('init', True)` を追加する。`setdefault` のため、ユーザーが明示的に + `init: false` を指定していれば尊重して上書きしない。 + +### Task 2: non-dev サービスへの `init` 注入 + +- **対象ファイル:** `lib/devbase/volume/compose.py` +- **変更内容:** non-dev サービス複製ループ (`for service_name, service_config in services.items()` 内) で + `copied.setdefault('init', True)` を追加する。mysql / valkey 等にも tini を挿入し安全側に倒す。 + +### Task 3: ユニットテスト追加 + +- **対象ファイル:** `tests/volume/test_compose.py` (新規), `tests/volume/__init__.py` (新規) +- **変更内容:** 一時 `compose.yml` を用意して `generate_scaled_compose()` を呼び、生成された + `.docker-compose.scale.yml` を読み戻して以下を検証する: + - dev-1 (および scale>1 の各 dev-i) に `init: true` が付く + - non-dev サービス (例: mysql) に `init: true` が付く + - 明示的に `init: false` を指定したサービスは `false` のまま (setdefault の尊重) + +## 影響範囲 + +- `devbase up` / `devbase scale` 経由で生成される全コンテナ。挙動は「PID 1 が tini になる」のみで、 + entrypoint (`tail -f /dev/null`) は tini の子プロセスとして従来どおり動作する。後方互換。 +- `init: false` を明示指定済みのプロジェクトには影響しない。 + +## テスト計画 + +- [ ] `pytest tests/volume/test_compose.py` が通る (init 注入 / false 尊重) +- [ ] 既存テストにリグレッションがない (`pytest tests/`) +- [ ] (手動) `devbase up && devbase login` 後 `ps -p 1 -o comm=` が `tini` を返す +- [ ] (手動) `nohup sleep 1 & disown; sleep 3; ps aux | grep 'Z.*defunct'` がゾンビを出さない + +## PR 計画 (単一 PR) + +| 項目 | 値 | +|---|---| +| 種別 | 単一 PR (release ブランチ不要) | +| branch 名 | `fix/i28-docker-init-zombie-reap` | +| base | `main` | +| 根拠 | 変更は 1 実装ファイル + テストのみ・結合度低・依存タスクなし (差分 ~60 行) | diff --git a/issues/i29_list-tui-simple-term-menu.md b/issues/i29_list-tui-simple-term-menu.md new file mode 100644 index 0000000..847d2b2 --- /dev/null +++ b/issues/i29_list-tui-simple-term-menu.md @@ -0,0 +1,122 @@ +# `devbase list` 対話選択の TUI 化 設計書 + +- 日付: 2026-06-07 +- 対象: `devbase list` / `devbase project list --interactive` の対話選択 UI +- 関連: PR #39 (対話選択をデフォルト化), PR #40 (status 集計修正) + +## 1. 目的 / 背景 + +現在 `devbase list`(デフォルトで対話モード)は stdlib `input()` ベースで、 +`[1] name (plugin, status)` の番号一覧を表示し番号入力で 1 件選択 → `project up` を起動する。 +矢印キーによる行移動がなく、視認性・操作性が低い。 + +本変更では、CLI 用の著名ライブラリ **simple-term-menu** を導入し、以下を実現する。 + +- ↑↓ 矢印キーによる行移動 +- 番号(先頭 9 件のショートカット)による該当行への即ジャンプ&選択 +- `/` によるインクリメンタル検索(38 件規模での実用的な絞り込み) + +「自作せず著名ライブラリを使う」というユーザ方針に従う。 +(現コードのコメントにある「`simple_term_menu` 等の外部依存を増やさず」という旧方針を本変更で転換する。) + +## 2. 対象環境 / 制約 + +- macOS / Linux のみ対応(Windows ネイティブ端末は対象外)。 + simple-term-menu は Unix 系専用であり本制約と一致する。 +- 実行経路: `bin/devbase` → `uv run --project "$DEVBASE_ROOT" python -m devbase.cli`。 + 依存は `pyproject.toml` + `uv.lock` 管理で `.venv` に解決されるため、追加のブートストラップは不要。 +- プロジェクト数は実運用で数十件(現状 38 件)。多桁番号の任意行直接ジャンプは + どの著名ライブラリもネイティブ非対応であり、`[1-9]` ショートカット + `/` 検索で代替する。 + +## 3. 変更範囲 + +- `pyproject.toml`: `dependencies` に `simple-term-menu>=1.6` を追加。`uv lock` で `uv.lock` 更新。 +- `lib/devbase/commands/project.py`: `_interactive_select_and_up` を TUI 化。 + - `list_projects` / `_print_table` / `cmd_project_list` のディスパッチ・非 TTY 判定は現状維持。 +- `tests/cli/test_project_list.py`: 主経路を TUI ラッパ注入に更新 + 追加ケース。 + +`commands/status.py` 等の状態集計ロジックには手を入れない。 + +## 4. 詳細設計 + +### 4.1 関数構成 + +`commands/project.py` に以下を導入する。 + +- `_build_menu_entries(rows) -> tuple[list[str], list[str]]` + 桁揃えした表示文字列リストと、それに対応する name リストを返す純粋関数。 + 先頭 9 件には simple-term-menu のショートカット記法 `[1]`〜`[9]` を付与する。 + STATUS は色付け(4.3 参照)。テスト容易性のため副作用なし。 +- `_show_menu(rows) -> int | None` + `TerminalMenu` を構築し `show()` の結果(選択 index / 中止時 None)を返す薄いラッパ。 + テストはこの関数を monkeypatch して選択を注入する(TerminalMenu 自体は起動しない)。 +- `_fallback_select(rows) -> int | None` + 現行 `input()` 番号入力ロジックを関数として温存。選択 index / 中止 None を返す。 +- `_interactive_select_and_up(rows) -> int` + 上記を統合。simple-term-menu の import 可否で経路分岐し、選択 index から + `cmd_project up ` を起動(現状と同じ委譲)。 + +### 4.2 TerminalMenu の挙動 + +- 各行: `NAME PLUGIN STATUS`(桁揃え)。先頭 9 件は `[n]` ショートカット付き。 +- 設定: + - `cycle_cursor=True`(端で循環) + - `clear_screen=False`(スクロールバックを汚さない) + - 検索キー `/`、`show_search_hint=True` + - `status_bar` に操作ヒント(矢印 / 番号 / `/` 検索 / Enter / ESC) +- キー操作: + - ↑↓: 行移動 + - `1`〜`9`: 該当行へジャンプ(先頭 9 件) + - `/`: 名前のインクリメンタル検索 + - Enter: 確定 → `cmd_project up ` + - ESC / `q`: 中止(`show()` が None を返す → 戻り値 0) + +### 4.3 STATUS 色付け + +- `running (N containers)` 系 = 緑、`stopped` = 灰、`unknown` = 既定色、で視認性を上げる。 +- リスク: simple-term-menu はメニュー項目内の ANSI エスケープで表示幅計算や + ハイライトバーがずれる場合がある。 +- 方針: **実装時に実機(Unix TTY)で検証**し、 + - 桁揃え・ハイライト・検索が破綻しない → 色付きで採用 + - 破綻する → STATUS はプレーンに自動デグレード(機能優先・色は諦める) +- 色付けの有無に関わらず矢印 / 番号 / 検索の核機能を最優先で保証する。 +- 非 TTY フォールバックの `_print_table` は従来どおりプレーン(パイプ安全)。 + +### 4.4 フォールバック / 堅牢性 + +- 非 TTY(stdin/stdout いずれかが非 TTY): 既存 `isatty` ガードで `_print_table` 表示(現状維持)。 +- `import simple_term_menu` 失敗時: `logger.warning` の上で `_fallback_select`(現行 input 方式)へ。 + → simple-term-menu 未同期環境でも従来どおり番号入力で選択可能。 +- 非対話(EOFError)/ Ctrl+C / 空入力 / 範囲外: 現行と同じ中止・再入力挙動を維持。 + +## 5. テスト設計 + +`tests/cli/test_project_list.py` を更新する。 + +- 主経路(TUI): + - `_show_menu` を monkeypatch して index を返す → `cmd_project up ` が正しい name で呼ばれる。 + - `_show_menu` が None → 中止(戻り値 0、`cmd_project` 未呼出)。 + - `_build_menu_entries`: 先頭 9 件に `[1-9]` 付与 / 10 件目以降は無印 / name 対応が一致。 +- フォールバック経路: + - `import simple_term_menu` を失敗させ(`monkeypatch` で ImportError 注入)、 + `builtins.input` 経由で選択 → `cmd_project up` 起動(既存 input テストをこちらに移設)。 +- 非 TTY: 既存の table フォールバックテストを維持。 + +色付けの ANSI 有無はユニットテストでは検証しづらいため、`_build_menu_entries` は +「色付け関数を差し替え可能」または「name 抽出は色と独立」に設計し、テストは name/index の +対応とショートカット付与に集中する。色の見た目は実機検証(手動)で確認する。 + +## 6. 受け入れ基準 + +- `devbase list`(TTY)で矢印上下移動・`[1-9]` ジャンプ・`/` 検索・Enter で `up` 起動ができる。 +- ESC / `q` で何も起動せず終了(戻り値 0)。 +- 非 TTY(`devbase list | cat` 等)で従来どおりプレーンなテーブルが出る。 +- simple-term-menu 不在でも番号入力で選択できる(フォールバック)。 +- 既存・追加テストが green。 + +## 7. 非対象(YAGNI) + +- 複数選択 / 一括 up。 +- Windows ネイティブ対応。 +- 多桁番号の任意行直接ジャンプ(`/` 検索で代替)。 +- preview pane(将来拡張余地として残すが本変更では実装しない)。