|
| 1 | +# PLAN59: `devbase open` の設計 |
| 2 | + |
| 3 | +要求と受け入れ条件は [PLAN59_editor-open.md](PLAN59_editor-open.md) にある。この文書は「どう作るか」だけを扱う。 |
| 4 | + |
| 5 | +## 機能一覧 |
| 6 | + |
| 7 | +| # | 機能 | 誰が使うか | |
| 8 | +| --- | --- | --- | |
| 9 | +| F1 | 起動中のプロジェクトで、コンテナに触らず dev コンテナへ接続した窓を開く | devbase の利用者(CLI) | |
| 10 | +| F2 | 停止中のプロジェクトで、起動してから窓を開く(`up --open` へ委譲) | devbase の利用者(CLI) | |
| 11 | +| F3 | 別ディレクトリからプロジェクト名を指定して F1 / F2 を行う | devbase の利用者(CLI) | |
| 12 | +| F4 | `devbase list` の起動中のサブメニューの先頭から F1 を選ぶ | devbase の利用者(TUI) | |
| 13 | + |
| 14 | +## 構成要素 |
| 15 | + |
| 16 | +| 要素 | 変更 | 責務 | |
| 17 | +| --- | --- | --- | |
| 18 | +| `cli.py` の `_add_open_subparser(sub, *, with_name)`(新設) | 足す | `open` の引数を登録する。`--open-index N` と `--context NAME` は常に、`[name]` は `with_name=True` のときだけ登録する。`project` とトップレベルは `True`、`container` は `False` で呼ぶ(`_add_profile_subparser` と同じ形) | |
| 19 | +| `cli.py` の `SHORTCUTS` / `SUBCMD_MAP` / parser の epilog | 変える | `open` をトップレベルのショートカットと、`project` / `container` のサブコマンドへ加える | |
| 20 | +| `bin/devbase` | 変える | `resolve_command` の候補、Python 実装のコマンドの `case`、`_PROJECT_NAME_SUBCOMMANDS`、`_NAME_RESOLVABLE_SHORTCUTS` に `open` を加える | |
| 21 | +| `container.py` の `_dispatch_lifecycle` | 変える | `handlers` に `'open'` を足す | |
| 22 | +| `container.py` の `cmd_open`(新設) | 足す | 起動中の判定・index の検査・開く処理の呼び出し、または `cmd_up` への委譲 | |
| 23 | +| `utils/docker.py` の `running_dev_instances`(`env.py` の `_running_dev_containers` から移す) | 変える | `docker ps` を 1 回呼び、動いている dev インスタンスの `(index, コンテナ名)` を index 順に返す。呼べなければ `None` | |
| 24 | +| `env.py` の `_running_dev_containers` | 変える | `running_dev_instances` の結果からコンテナ名だけを返す薄い包みにする。`env token` から見た振る舞いは変えない | |
| 25 | +| `container.py` の `_open_editor_at`(`_maybe_open_editor` から切り出し) | 変える | 開く対象(フォルダ / ワークスペース)と接続先を組み、`opener.open_editor` を呼んで action を返す。有効判定と index の解決は持たない | |
| 26 | +| `container.py` の `_maybe_open_editor` | 変える | 有効判定と index の解決だけを残し、開く処理は `_open_editor_at` へ渡す。`up` から見た振る舞いは変えない | |
| 27 | +| `tui/actions_project.py` の `_RUNNING_OPS` / `_OP_HANDLERS` | 変える | 先頭に `("エディタを開く (open)", "open")`、ハンドラに `dispatch_lifecycle("open", name, open_index=None)`。先頭の理由のコメントを書き替える | |
| 28 | +| `etc/devbase-completion.bash` / `etc/_devbase` | 変える | トップレベル・`project`・`container` の候補に `open` を加え、`[name]` の補完を `up` と同じにする | |
| 29 | +| `docs/user/cli-reference/02-project.md` / `CHANGELOG.md` | 変える | 利用者向けの説明と変更履歴 | |
| 30 | + |
| 31 | +`opener.open_editor` / `opener.decide_action` と `cmd_up` は変えない。 |
| 32 | + |
| 33 | +下の図は呼び出しの関係だけを描く。呼び出しを持たない補完(`etc/`)と文書(`docs/`・`CHANGELOG.md`)は図に含めない。 |
| 34 | + |
| 35 | +```mermaid |
| 36 | +graph TD |
| 37 | + W[bin/devbase] --> P[cli.py] |
| 38 | + P --> D[_dispatch_lifecycle] |
| 39 | + T[TUI actions_project] --> D |
| 40 | + D --> O[cmd_open] |
| 41 | + O --> R[running_dev_instances] |
| 42 | + O -->|起動中| E[_open_editor_at] |
| 43 | + O -->|停止中| U[cmd_up] |
| 44 | + U --> M[_maybe_open_editor] |
| 45 | + M --> E |
| 46 | + E --> V[opener.open_editor] |
| 47 | +``` |
| 48 | + |
| 49 | +## 配置 |
| 50 | + |
| 51 | +### システムの文脈 |
| 52 | + |
| 53 | +devbase はホストで動き、2 つの外部に触る。docker daemon(`--context` の先を含む)と、ホストの VS Code(`code` CLI)である。`open` の起動中の経路が docker daemon に対して行うのは、読み取りの `docker ps` と、`opener.open_editor` が既存で行う `docker compose ps` だけである。コンテナ・ボリューム・ネットワークは作らない。 |
| 54 | + |
| 55 | +```mermaid |
| 56 | +graph LR |
| 57 | + U[利用者の端末] --> C[devbase CLI / TUI(ホスト)] |
| 58 | + C -->|docker ps(読み取り)| D[docker daemon] |
| 59 | + C -->|code --folder-uri| V[VS Code] |
| 60 | + V -->|Dev Containers で接続| D |
| 61 | +``` |
| 62 | + |
| 63 | +### モジュールの置き場所 |
| 64 | + |
| 65 | +```text |
| 66 | +bin/devbase # 入口のシェル。name 解決とコマンドの振り分け |
| 67 | +etc/ |
| 68 | +├── devbase-completion.bash # bash 補完 |
| 69 | +└── _devbase # zsh 補完 |
| 70 | +lib/devbase/ |
| 71 | +├── cli.py # parser とショートカット |
| 72 | +├── commands/container.py # cmd_open / _open_editor_at |
| 73 | +├── commands/env.py # _running_dev_containers(包みにする) |
| 74 | +├── editor/opener.py # 変えない |
| 75 | +├── tui/actions_project.py # 起動中のサブメニュー |
| 76 | +└── utils/docker.py # running_dev_instances(移す先) |
| 77 | +``` |
| 78 | + |
| 79 | +## 入出力の契約 |
| 80 | + |
| 81 | +### コマンド `open` |
| 82 | + |
| 83 | +| 項目 | 内容 | |
| 84 | +| --- | --- | |
| 85 | +| 名前 | `devbase open [name]` / `devbase project open [name]` / `devbase container open`。前方一致の `devbase o` も `open` に解決する | |
| 86 | +| 入力 | `name`(任意。`project` とトップレベルだけ)、`--open-index N`(任意の整数)、`--context NAME`(任意。空は usage エラー) | |
| 87 | +| 出力(成功) | 起動中: `opener.open_editor` が `launch`(エディタを起動)または `print_command`(SSH で手元のコマンドを提示)を返し、終了コード 0。停止中: `cmd_up` の戻り値をそのまま返す | |
| 88 | +| 失敗の形 | 下の表 | |
| 89 | +| 互換性 | 追加のみ。既存のコマンドと前方一致の解決(`l` → `login`、`project p` → `ps`)は変わらない | |
| 90 | + |
| 91 | +| 状況 | 終了コード | 出力 | |
| 92 | +| --- | --- | --- | |
| 93 | +| `--open` / `--no-open` を渡した | 2 | argparse の usage エラー | |
| 94 | +| `--open-index` が 0 以下 | 1 | `open index N は 1 以上を指定してください` | |
| 95 | +| 起動中で、index が動いているインスタンスに無い | 1 | `dev-N は起動していません。起動中: 1, 2` | |
| 96 | +| 起動中の判定の `docker ps` が 0 以外で終わった・呼べなかった | 1 | `docker ps` の失敗の理由(`running_dev_instances` が error で出す)。`up` へは委譲しない | |
| 97 | +| 起動中で、`opener.open_editor` が `skip` を返した(非 TTY・`code` が無い) | 1 | `opener` が出す理由(info)。開けなかったことを終了コードで示す | |
| 98 | +| `name` が解決できない | 1 | 既存の `_enter_project` の候補提示 | |
| 99 | + |
| 100 | +### `running_dev_instances(project, dev_service_name, runner=None) -> Optional[list[tuple[int, str]]]` |
| 101 | + |
| 102 | +`env.py` の `_running_dev_containers` の中身を `utils/docker.py` へ移したもの。判定の方法は変えない。 |
| 103 | + |
| 104 | +- `docker ps --filter label=com.docker.compose.project=<project> --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}'` を 1 回呼ぶ。`-a` を付けないため、止まっているコンテナは出ない |
| 105 | +- サービスのラベルが `{dev}-{1 以上の数字}` の行から `(数字, コンテナ名)` を集め、数字の昇順で返す。同じプロジェクトの DB などは除かれる |
| 106 | +- 呼び出しの失敗(例外・0 以外の終了コード)は error ログを出して `None` を返す。動いているものが無い `[]` と区別する |
| 107 | +- Compose のファイルを読まないため、`.docker-compose.scale.yml` の有無と構成の補間に左右されない。接続先は環境変数 `DOCKER_CONTEXT` に従う |
| 108 | +- `runner` は差し替え口(既定は `subprocess.run`)。`env.py` の呼び出しは既存どおり渡す |
| 109 | + |
| 110 | +### TUI |
| 111 | + |
| 112 | +| 項目 | 変更後 | |
| 113 | +| --- | --- | |
| 114 | +| `_RUNNING_OPS` の先頭 | `("エディタを開く (open)", "open")`。以下は既存の `up` / `down` / `login` … の順 | |
| 115 | +| `_OP_HANDLERS["open"]` | `lambda root, name: dispatch_lifecycle("open", name, open_index=None)` | |
| 116 | +| `_BACK_TO_TOP_OPS` | 変えない(`open` を含めない) | |
| 117 | + |
| 118 | +## 処理の流れ |
| 119 | + |
| 120 | +分岐と合流が主題のため、呼び出しの相手ではなく判定の順に描く。 |
| 121 | + |
| 122 | +```mermaid |
| 123 | +graph TD |
| 124 | + A[index を解決<br/>CLI → DEVBASE_OPEN_INDEX → 1] --> B{0 以下か} |
| 125 | + B -->|はい| X1[終了コード 1] |
| 126 | + B -->|いいえ| C[context を反映し<br/>機密を注入] |
| 127 | + C --> Q[docker ps で<br/>動いている index を得る] |
| 128 | + Q -->|失敗| X2[終了コード 1<br/>up へは進まない] |
| 129 | + Q -->|0 個| U[cmd_up<br/>open_editor=True] |
| 130 | + U --> X3[cmd_up の戻り値] |
| 131 | + Q -->|1 個以上| K{index が<br/>含まれるか} |
| 132 | + K -->|いいえ| X4[終了コード 1<br/>起動中の index を示す] |
| 133 | + K -->|はい| E[_open_editor_at] |
| 134 | + E -->|skip| X5[終了コード 1] |
| 135 | + E -->|launch / print_command| X6[終了コード 0] |
| 136 | +``` |
| 137 | + |
| 138 | +停止中から `cmd_up` へ委譲するときは、`open_index` に**利用者が渡した値**(未指定なら `None`)を渡す。`cmd_up` は既存どおり `DEVBASE_OPEN_INDEX` を読み、範囲外は警告して 1 へ落とす(仕様の前提 3)。 |
| 139 | + |
| 140 | +## 非機能の実現方式 |
| 141 | + |
| 142 | +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | |
| 143 | +| --- | --- | --- | --- | |
| 144 | +| 性能・拡張性 | 起動中の経路で docker を呼ぶのは、起動中の判定の `docker ps` 1 回と、`opener.open_editor` 内の既存の呼び出しだけ | `cmd_open` は `_run_deploy_pipeline`・`_run_pre_up_checks`・`_auto_snapshot` を呼ばない。起動中の判定は `running_dev_instances` の 1 回に集める | 単体テストで `running_dev_instances` の `runner` と `cmd_up` 系の関数を差し替え、起動中の経路で docker の呼び出しが `docker ps` 1 回だけであることを見る | |
| 145 | +| 運用・保守性 | 停止中から `up` へ委譲するときは、その旨を info ログに 1 行出す | `cmd_up` を呼ぶ直前に `dev コンテナが起動していないため up を実行します` を info で出す | 単体テストで caplog を見る | |
| 146 | + |
| 147 | +## 決定の記録 |
| 148 | + |
| 149 | +### 決定 1: 起動中の判定は「dev インスタンスが 1 つ以上動いているか」で行い、index の検査と分ける |
| 150 | + |
| 151 | +issue の案は「開く index のコンテナ名が解決できない → 停止中」だった。この判定では、scale 1 で動いているプロジェクトに `--open-index 2` を渡すと停止中と見なし、`up` で環境を作り直してしまう。窓を開くだけのつもりの操作が、起動済みのコンテナを止めて作り直す操作に化ける。0 個と「その index だけ無い」を分け、前者だけを `up` へ委譲する。 |
| 152 | + |
| 153 | +`opener.resolve_container_name` は使わない。問い合わせに失敗すると決定的な名前へ落ちる設計で、起動していなくても名前を返すため判定に使えない。 |
| 154 | + |
| 155 | +### 決定 2: 状態を取得できないときは `up` へ委譲せずに止まる |
| 156 | + |
| 157 | +起動中の判定の `docker ps` が失敗した(daemon に届かない)ことは、停止中を意味しない。ここで `up` へ進むと、動いている環境を作り直す可能性がある。`up` の側で同じ原因により失敗するとしても、利用者が「なぜ起動が走ったか」を読み違えないよう、`open` の入口で止める。 |
| 158 | + |
| 159 | +### 決定 3: 開く処理は `_maybe_open_editor` から切り出して共有し、有効判定を持たせない |
| 160 | + |
| 161 | +開く対象の組み立てだけを `_open_editor_at` に切り出す。組み立てるのは、フォルダかワークスペースか・compose file・接続先の 3 つである。有効判定と index の解決は、呼び出し側がそれぞれ持つ。 |
| 162 | + |
| 163 | +`open` は明示の操作なので、`DEVBASE_OPEN_EDITOR` と `project.yml` の `open_editor` を見ない(仕様の受け入れ条件 2)。`_maybe_open_editor` へ `open_flag=True` を渡す形は採らない。その経路は範囲外の index を 1 へ落とす(`up` のための既存の振る舞い)。そのため受け入れ条件 5 と両立しない。 |
| 164 | + |
| 165 | +### 決定 4: 停止中は `cmd_up(open_editor=True)` へ委譲し、自前で開き直さない |
| 166 | + |
| 167 | +`up` の `[6/6]` がすでに開く処理を持っている。起動の直後に開くための compose file(`_run_deploy_pipeline` が返したもの)も、そこで決まる。`cmd_open` が `up` の後にもう一度開くと、窓が 2 つ出るか、`up` 側の有効判定との二重管理になる。`open_editor=True` を渡すことで、`DEVBASE_OPEN_EDITOR=0` の端末でも窓が開く(受け入れ条件 4)。 |
| 168 | + |
| 169 | +### 決定 5: `opener.open_editor` が `skip` を返したら終了コード 1 にする |
| 170 | + |
| 171 | +`up` では窓を開けなくても成功とする(起動が主目的のため)。`open` は窓を開くことだけが目的なので、非 TTY や `code` が無いことで何もしなかった場合は失敗として返す。`print_command`(SSH でコマンドを提示)は利用者が次にすべきことを出しているため成功とする。 |
| 172 | + |
| 173 | +### 決定 6: TUI の `open` は index を尋ねない |
| 174 | + |
| 175 | +issue の指定(`open_index=None`)どおり、既定(`DEVBASE_OPEN_INDEX`、無ければ 1)を開く。scale が 2 以上のプロジェクトで別の index を開きたい場合は CLI の `--open-index` を使う。index を尋ねる入力を足すと、Enter 1 回で窓を出すという先頭に置く理由が失われる。 |
| 176 | + |
| 177 | +### 決定 7: `container open` も足す |
| 178 | + |
| 179 | +`container` は非推奨だが、`profile`(PLAN58)を含めて `project` と同じサブコマンドの集合を保っている。片方だけにすると、補完と `SUBCMD_MAP` の対応表に例外が 1 つ増える。`container open` は `[name]` を取らない(`container` の他のサブコマンドと同じ)。 |
| 180 | + |
| 181 | +### 決定 8: 起動中の判定は `env token` の列挙を共有の場所へ移して使う |
| 182 | + |
| 183 | +`env.py` の `_running_dev_containers` が、動いている dev インスタンスの列挙をすでに持っている。失敗を `None` で返し、0 個の `[]` と区別する契約も、決定 2 が求める形と一致する。`utils/docker.py` へ移して `cmd_open` と `env token` の両方から使う。 |
| 184 | + |
| 185 | +`docker compose ps` で数える形は採らない。Compose のファイル(`.docker-compose.scale.yml`)が無いと呼べず、構成の補間の失敗も「状態を取得できない」に混ざるためである。`docker ps` とラベルで数える既存の方法は、どちらにも左右されない。 |
| 186 | + |
| 187 | +## テスト設計 |
| 188 | + |
| 189 | +| 受け入れ条件 | 何で確かめるか | |
| 190 | +| --- | --- | |
| 191 | +| 1 | 単体(`tests/commands/test_container_open.py`): 起動中で `opener.open_editor` が 1 回呼ばれ、`_run_deploy_pipeline` / `_run_pre_up_checks` / `_auto_snapshot` / `cmd_up` が呼ばれない。compose の呼び出しは `ps` 1 回 | |
| 192 | +| 2 | 単体: `DEVBASE_OPEN_EDITOR=0` と `open_editor: false` のそれぞれで `opener.open_editor` が呼ばれる | |
| 193 | +| 3 | 単体: 動いているインスタンスが 0 個で `cmd_up` が `open_editor=True` で 1 回呼ばれ、`opener.open_editor` は呼ばれない。戻り値が `cmd_up` のもの | |
| 194 | +| 4 | 単体: 3 と同じ状況で `DEVBASE_OPEN_EDITOR=0` でも `cmd_up` に `open_editor=True` が渡る | |
| 195 | +| 5 | 単体: 起動中 `[1]` で index 2 → 1 を返し、`cmd_up` / `opener.open_editor` が呼ばれない。メッセージに `1` を含む | |
| 196 | +| 6 | 単体: `project.yml` の scale 1、起動中 `[1, 2]` で index 2 → `opener.open_editor(index=2)` | |
| 197 | +| 7 | 単体: index 0 と -1 で 1 を返し、docker を呼ばない | |
| 198 | +| 8 | 単体: `DEVBASE_OPEN_INDEX=2`・起動中 `[1, 2]` で `index=2` | |
| 199 | +| 9 | CLI(`tests/cli/`): `project open <name>` とトップレベル `open <name>` の parse 結果が `name` を持ち、`_dispatch_lifecycle` が `_enter_project` を呼ぶ。`bin/devbase` の 2 つのリストに `open` がある | |
| 200 | +| 10 | 単体: `--context X` で `running_dev_instances` を呼ぶ時点の環境変数 `DOCKER_CONTEXT` が `X`、`opener.open_editor` の `docker_context` が `X` | |
| 201 | +| 11 | CLI: `open --open` / `open --no-open` が `SystemExit(2)` | |
| 202 | +| 12 | TUI(`tests/cli/tui/`): `_RUNNING_OPS[0][1] == "open"` | |
| 203 | +| 13 | TUI: `_OP_HANDLERS["open"]` が `dispatch_lifecycle("open", name, open_index=None)` を呼ぶ | |
| 204 | +| 14 | TUI: `"open" not in _BACK_TO_TOP_OPS` | |
| 205 | +| 15 | 既存の `tests/editor/test_opener.py` と `up` の自動オープンのテストが変更なしで通る | |
| 206 | +| 16 | CLI: 既存の前方一致のテストに `o` → `open` を足し、`l` → `login` / `project p` → `ps` が変わらない | |
| 207 | +| 17 | 補完(`tests/cli/test_completion.py`): bash / zsh の候補に `open` がある | |
| 208 | +| 18 | `uv run pytest` | |
| 209 | +| 19 | 単体: `opener.open_editor` が `skip` を返すと 1、`print_command` で 0(決定 5) | |
| 210 | +| 20 | 単体: `running_dev_instances` が `None` を返すと 1 を返し、`cmd_up` を呼ばない(決定 2)。`running_dev_instances` 自体は、`{dev}-{数字}` 以外のサービスを除くこと・index 順に並べること・失敗で `None` を返すことを見る。`env token` の既存テスト(`tests/commands/test_env_token.py`)が変更なしで通る | |
| 211 | + |
| 212 | +## 未確認のまま残ること |
| 213 | + |
| 214 | +| 項目 | 内容 | |
| 215 | +| --- | --- | |
| 216 | +| 実機での窓の再表示 | VS Code が同じコンテナ・同じフォルダの窓をすでに開いているとき、`code --folder-uri` が既存の窓を前面に出すか新しい窓を開くかは VS Code 側の挙動で、devbase では決めない。リリース後テストで macOS のローカル端末で確かめる | |
0 commit comments