Skip to content

Commit b46d7e3

Browse files
takemi-ohamaclaude
andauthored
docs(PLAN59): 再起動なしで VS Code だけ開き直す devbase open の要求仕様と設計 (#197) (#198)
* docs(PLAN59): 再起動なしで VS Code だけ開き直す devbase open の要求仕様と設計 (#197) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(PLAN59): 起動中の判定に env token の列挙を共有し、open の引数登録を with_name で分ける (#197) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 87f5978 commit b46d7e3

2 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)