From 49bbcefa3b89ef2730e35c28fc1b95597c8426ec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 16:47:36 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(PLAN59):=20=E5=86=8D=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=81=A7=20VS=20Code=20=E3=81=A0=E3=81=91?= =?UTF-8?q?=E9=96=8B=E3=81=8D=E7=9B=B4=E3=81=99=20devbase=20open=20?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81devbase=20list=20=E3=81=AE?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E4=B8=AD=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E5=85=88=E9=A0=AD=E3=81=B8=E7=BD=AE=E3=81=8F=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd_open: dev が動いていればコンテナに触らず開き、動いていなければ up --open へ委譲する - 起動中の判定は env token の列挙を utils/docker.running_dev_instances へ移して共有する - open / project open [name] / container open と、bin/devbase・補完・CLI リファレンスを揃える - TUI の起動中メニューの先頭を「エディタを開く (open)」にする Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 ++ bin/devbase | 10 +- docs/user/cli-reference/02-project.md | 33 +++- docs/user/environment-variables.md | 4 +- etc/_devbase | 20 ++ etc/devbase-completion.bash | 26 ++- issues/PLAN59_editor-open-impl.md | 93 +++++++++ lib/devbase/cli.py | 37 +++- lib/devbase/commands/container.py | 116 +++++++++-- lib/devbase/commands/env.py | 27 +-- lib/devbase/tui/actions_project.py | 14 +- lib/devbase/utils/docker.py | 38 ++++ tests/cli/test_completion.py | 29 +++ tests/cli/test_open_command.py | 140 +++++++++++++ tests/cli/test_rebuild.py | 9 +- tests/cli/tui/test_actions_project.py | 6 +- tests/cli/tui/test_open_menu.py | 30 +++ tests/commands/test_container_open.py | 230 ++++++++++++++++++++++ tests/utils/test_running_dev_instances.py | 60 ++++++ 19 files changed, 873 insertions(+), 61 deletions(-) create mode 100644 issues/PLAN59_editor-open-impl.md create mode 100644 tests/cli/test_open_command.py create mode 100644 tests/cli/tui/test_open_menu.py create mode 100644 tests/commands/test_container_open.py create mode 100644 tests/utils/test_running_dev_instances.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c46891..fe17319a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### Added +- **閉じた VS Code の窓を、コンテナを再起動せずに開き直す `devbase open` を足しました(PLAN59 / #197)。** + `devbase open [name]` / `devbase project open [name]` / `devbase container open` で、`devbase up` の + 最後の段と同じ窓を開きます。dev コンテナが動いていなければ `devbase up --open` と同じく起動してから + 開きます。`project.yml` の `open_editor` と `DEVBASE_OPEN_EDITOR` は見ません(明示のコマンドのため)。 + 開く番号は `--open-index N` で選び、上限は動いているコンテナで決まります。 +- `devbase list` の起動中のプロジェクトの操作メニューの先頭に「エディタを開く (open)」を置きました。 + +### Changed +- `devbase list` の起動中の操作メニューで、Enter 1 回で決まる項目が「再起動 (up)」から + 「エディタを開く (open)」に変わりました。再起動はその 1 つ下です。 + ### Fixed - **tmux の中で URL がクリックできなくなっていた**のを直しました。tmux は端末が `Hls` 能力を持つときだけハイパーリンク (OSC 8) を書き出し、持たない端末ではリンクを捨てて diff --git a/bin/devbase b/bin/devbase index 2f77ba32..55339803 100755 --- a/bin/devbase +++ b/bin/devbase @@ -272,7 +272,7 @@ run_python() { # Resolve abbreviated command to full command name via unique prefix matching resolve_command() { local input="$1" - local commands="init status project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" + local commands="init status project container ct env plugin pl snapshot ss up down login build rebuild ps scale open list help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") @@ -379,7 +379,7 @@ _DEVBASE_ARGS=("${@:2}") # 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。 # _PROJECT_NAME_SUBCOMMANDS = `project`/`container` で `name` positional を # 受け付けるサブコマンド集合。cli.py の _add_project_parser で -# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale) と一致させる。 +# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale/rebuild/open) と一致させる。 # login/build は index/image 互換のため意図的に除外 (上のコメント参照)。 # _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」 # を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される @@ -404,8 +404,8 @@ _DEVBASE_ARGS=("${@:2}") # そのプロジェクトへ切り替えてから (cd 済みの状態で) コマンドを実行すること。 # こうすれば name 解決トークンを与える必要がなくなり、index/image/service を # 意図どおり渡せる。 -_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild " -_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild " +_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild open " +_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild open " case "$_resolved_cmd" in project|container|ct) # `ct` は container の alias (cli.py: add_parser('container', aliases=['ct']))。 @@ -428,7 +428,7 @@ case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list) + init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|open|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands # diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index f8c4bc3d..726cf828 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -95,6 +95,34 @@ devbase up [name] [--context NAME] > ことがあります。確実に反映するには **`devbase build [name] --no-cache`** で再ビルドしてから > `devbase up` してください(`--no-cache` は `build` のオプションで、`rebuild` にはありません)。 +## `devbase project open` + +閉じた VS Code の窓を、コンテナを再起動せずに開き直します。`devbase up` の最後の段で開くのと同じ窓(dev コンテナへ接続したフォルダ、リポジトリが 2 件以上ならワークスペース)を開きます。 + +``` +devbase project open [name] [--open-index N] [--context NAME] +devbase open [name] [--open-index N] [--context NAME] +devbase container open [--open-index N] [--context NAME] +``` + +| パラメータ | 必須 | デフォルト | 説明 | +|-----------|------|-----------|------| +| `name` | いいえ | カレント | 対象プロジェクト名。`container open` では受け付けません | +| `--open-index N` | いいえ | `DEVBASE_OPEN_INDEX`、無ければ `1` | 開く dev コンテナの番号 | + +プロジェクトの状態で動きが変わります。 + +| 状態 | 動き | 終了コード | +|------|------|-----------| +| dev コンテナが 1 つ以上動いている | コンテナに触らずに窓を開く | 開いた・SSH で手元のコマンドを提示した: `0` / 開けなかった(非 TTY・`code` が無い): `1` | +| dev コンテナが 1 つも動いていない | `devbase up --open` と同じく起動してから窓を開く | `up` の終了コード | +| 指定した番号のコンテナが動いていない | 動いている番号を示して止まる(起動はしない) | `1` | +| コンテナの状態を取得できない(Docker のデーモンに届かない) | 起動せずに止まる | `1` | + +- 開くかどうかの設定(`project.yml` の `open_editor` / `DEVBASE_OPEN_EDITOR`)は見ません。明示のコマンドなので、自動オープンを無効にした端末でも開きます +- 番号の上限は `project.yml` の `scale` ではなく、動いているコンテナで決まります(`devbase scale` で増やした分も開けます) +- `--open` / `--no-open` は受け付けません(`up` の自動オープンのためのオプションです) + ## `devbase project down` コンテナを停止・削除します。 @@ -336,8 +364,9 @@ devbase list [--no-interactive|--plain|-P] | Esc / ← | サブメニューでは 1 つ前の画面へ戻る(トップでは Esc で終了) | | Ctrl-C | どの画面でも全体を中止 | -起動中プロジェクトの操作サブメニューでは up / down / login / ps / logs / scale / -build / rebuild を選べます。最下部のカテゴリメニューから実行できる操作 +起動中プロジェクトの操作サブメニューでは open / up / down / login / ps / logs / scale / +build / rebuild を選べます。先頭(Enter 1 回で決まる位置)は **エディタを開く (open)** で、 +閉じた VS Code の窓をコンテナに触らずに開き直します。再起動 (up) はその 1 つ下です。最下部のカテゴリメニューから実行できる操作 (実体は対応する CLI コマンドへの委譲): | カテゴリ | 選べる操作 | diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 41d8afa3..2674933c 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -298,11 +298,13 @@ DEVBASE_ACCOUNT_GROUP=kkg | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | | `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。**空文字(`DEVBASE_EDITOR_SSH_HOST=`)はネストのオプトアウト**で、フラット URI を強制する。下記「リモート Docker」参照 | | `DEVBASE_EDITOR_DOCKER_CONTEXT` | attach に使う docker context を手で決めたいときだけ明示する。未設定なら devbase が解決した context(`--context` / `DEVBASE_DOCKER_CONTEXT` / `project.local.yml`)、それも無ければ跨ホスト時にホストの `docker context show` | -| `DEVBASE_DOCKER_CONTEXT` | `devbase up/down/ps/logs/login/scale/build/rebuild` が向ける docker context。`project.local.yml` の `docker.context` より優先し、CLI `--context` に負ける。グローバル `.env` に書くと全プロジェクトが同じホストへ向くため、通常は `project.local.yml` に書く。下記「リモート Docker」参照 | +| `DEVBASE_DOCKER_CONTEXT` | `devbase up/down/ps/logs/login/scale/build/rebuild/open` が向ける docker context。`project.local.yml` の `docker.context` より優先し、CLI `--context` に負ける。グローバル `.env` に書くと全プロジェクトが同じホストへ向くため、通常は `project.local.yml` に書く。下記「リモート Docker」参照 | | `DEVBASE_WINDOW_TITLE` | attach 先 VS Code の `window.title` テンプレート。`{container}` が実コンテナ名(例 `nyle-dx-dev-1`)に置換される。既定は `{container}${separator}${dirty}${activeEditorShort}`。`0` / `false` / `off` / 空文字で無効化。下記「ウィンドウタイトル」参照 | 都度の上書きは CLI フラグで行います: `devbase up --open` / `--no-open` / `--open-index N`(env より優先)。 +閉じた窓を開き直すときは `devbase open [name] [--open-index N]` を使います。コンテナを再起動せず、`DEVBASE_OPEN_EDITOR` / `open_editor` の設定にかかわらず開きます([CLI リファレンス](cli-reference/02-project.md#devbase-project-open))。 + ### ウィンドウタイトル(どの窓がどのプロジェクトか) VS Code の既定タイトルは編集中ファイル名が先頭に来るため、複数プロジェクトの窓を並べるとどれがどれか判別できません。devbase は `up` のたびに各 dev コンテナへ **コンテナ名始まりのタイトル**を設定します。 diff --git a/etc/_devbase b/etc/_devbase index c47caa67..619be6e8 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -67,6 +67,7 @@ _devbase() { 'rebuild:Rebuild images without cache (shortcut)' 'ps:Show container status (shortcut)' 'scale:Scale containers online (shortcut)' + 'open:Open the editor without restarting (shortcut)' 'list:List projects (shortcut)' 'help:Show help' ) @@ -82,6 +83,7 @@ _devbase() { 'rebuild:Rebuild images without cache (docker compose build --no-cache)' 'list:List projects (NAME / PLUGIN / STATUS)' 'profile:Start / stop compose profile services' + 'open:Open the editor attached to the dev container' ) container_subcommands=( @@ -94,6 +96,7 @@ _devbase() { 'build:Build container images' 'rebuild:Rebuild images without cache (docker compose build --no-cache)' 'profile:Start / stop compose profile services' + 'open:Open the editor attached to the dev container' ) env_subcommands=( @@ -159,6 +162,12 @@ _devbase() { scale) _devbase_project_names ;; + open) + _arguments \ + '--open-index[Container index to open]:index:' \ + '--context[Docker context]:context:' \ + '1:name:_devbase_project_names' + ;; list) _arguments \ '--no-interactive[Just print the table without interactive selection]' \ @@ -208,6 +217,12 @@ _devbase() { profile) _values 'operation' up down list ;; + open) + _arguments \ + '--open-index[Container index to open]:index:' \ + '--context[Docker context]:context:' \ + '1:name:_devbase_project_names' + ;; *) _describe -t project-commands 'project command' project_subcommands ;; @@ -236,6 +251,11 @@ _devbase() { profile) _values 'operation' up down list ;; + open) + _arguments \ + '--open-index[Container index to open]:index:' \ + '--context[Docker context]:context:' + ;; *) _describe -t container-commands 'container command' container_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 06418b05..77b61505 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -31,10 +31,10 @@ _devbase_completions() { cword=$COMP_CWORD } - local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale open list help" # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 - local project_subcommands="up down ps login logs scale build rebuild list profile" - local container_subcommands="up down ps login logs scale build rebuild profile" + local project_subcommands="up down ps login logs scale build rebuild list profile open" + local container_subcommands="up down ps login logs scale build rebuild profile open" local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" @@ -54,6 +54,14 @@ _devbase_completions() { up|down|scale|rebuild) COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) ;; + # open は [name] と --open-index / --context を取る (PLAN59)。 + open) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 ps) if [[ "$cur" == -* ]]; then @@ -129,6 +137,13 @@ _devbase_completions() { profile) COMPREPLY=($(compgen -W "up down list" -- "$cur")) ;; + open) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; esac fi # container subcommand arguments (非推奨: project へ移行してください) @@ -143,6 +158,11 @@ _devbase_completions() { profile) COMPREPLY=($(compgen -W "up down list" -- "$cur")) ;; + open) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) + fi + ;; ps) if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--all -a" -- "$cur")) diff --git a/issues/PLAN59_editor-open-impl.md b/issues/PLAN59_editor-open-impl.md new file mode 100644 index 00000000..d7ba233e --- /dev/null +++ b/issues/PLAN59_editor-open-impl.md @@ -0,0 +1,93 @@ +# PLAN59: `devbase open` の実装計画 + +## 関連リンク + +- issue: devbasex/devbase#197 +- 要求仕様と受け入れ条件: [PLAN59_editor-open.md](PLAN59_editor-open.md) +- 設計: [PLAN59_editor-open-design.md](PLAN59_editor-open-design.md)(設計 Pull Request #198 でマージ済み) + +## モード + +`standard`(公開のコマンドと TUI のメニュー項目を足し、CLI・lifecycle・TUI・入口のシェル・補完にまたがる) + +## 受け入れ条件 + +仕様の 1〜20 をそのまま使う。番号は仕様の番号を指す。 + +## 修正対象 + +- `lib/devbase/utils/docker.py` +- `lib/devbase/commands/env.py` +- `lib/devbase/commands/container.py` +- `lib/devbase/cli.py` +- `bin/devbase` +- `lib/devbase/tui/actions_project.py` +- `etc/devbase-completion.bash` / `etc/_devbase` +- `docs/user/cli-reference/02-project.md` / `CHANGELOG.md` +- テスト: `tests/utils/test_running_dev_instances.py`(新規)/ `tests/commands/test_container_open.py`(新規)/ `tests/cli/test_open_command.py`(新規)/ `tests/cli/tui/test_open_menu.py`(新規)/ `tests/cli/test_completion.py` + +## タスク分解 + +### Task 1: 動いている dev インスタンスの列挙を共有の場所へ移す + +- **対象ファイル:** `lib/devbase/utils/docker.py`、`lib/devbase/commands/env.py`、`tests/utils/test_running_dev_instances.py` +- **変更内容:** `env.py` の `_running_dev_containers` の中身を `running_dev_instances(project, dev_service_name, runner=None) -> Optional[list[tuple[int, str]]]` として `utils/docker.py` へ移す。`_running_dev_containers` はコンテナ名だけを返す包みにする(設計の決定 8) +- **満たす受け入れ条件:** 20 の前半(失敗で `None`)。`env token` の既存テストが変更なしで通ること +- **進め方:** 失敗するテスト(`{dev}-{数字}` 以外を除く・index 順・失敗で `None`)→ 移設 → `tests/commands/test_env_token.py` を通す + +### Task 2: `cmd_open` 本体 + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/commands/test_container_open.py` +- **変更内容:** `_maybe_open_editor` から `_open_editor_at` を切り出す(決定 3)。`cmd_open(project_name=None, open_index=None, context=None)` を足し、`_dispatch_lifecycle` の `handlers` に `'open'` を加える。処理は設計の「処理の流れ」の順 +- **満たす受け入れ条件:** 1〜8、10、19、20 +- **進め方:** 条件ごとに失敗するテストを書き、`running_dev_instances`・`opener.open_editor`・`cmd_up` を差し替えて分岐を確かめる → 実装 → `up` の自動オープンの既存テストが通ることを確かめる + +### Task 3: CLI の登録と入口のシェル + +- **対象ファイル:** `lib/devbase/cli.py`、`bin/devbase`、`tests/cli/test_open_command.py` +- **変更内容:** `_add_open_subparser(sub, *, with_name)` を足し、`project`(name あり)・`container`(name なし)・トップレベル(name あり)へ登録する。`SHORTCUTS` / `SUBCMD_MAP` / epilog に `open` を足す。`bin/devbase` の `resolve_command` の候補・Python 実装のコマンドの `case`・`_PROJECT_NAME_SUBCOMMANDS`・`_NAME_RESOLVABLE_SHORTCUTS` に `open` を足す +- **満たす受け入れ条件:** 9、11、16 +- **進め方:** parse の結果・`--open` の拒否・前方一致・wrapper のリストを見る失敗するテスト → 登録 → 既存の `tests/cli/` を通す + +### Task 4: TUI の起動中メニュー + +- **対象ファイル:** `lib/devbase/tui/actions_project.py`、`tests/cli/tui/test_open_menu.py` +- **変更内容:** `_RUNNING_OPS` の先頭に `("エディタを開く (open)", "open")`、`_OP_HANDLERS["open"]` を足す。先頭の理由のコメントを書き替える +- **満たす受け入れ条件:** 12〜14 +- **進め方:** 並び・ハンドラ・`_BACK_TO_TOP_OPS` を見る失敗するテスト → 変更 → 既存の TUI テスト(先頭が `up` を前提にしたものがあれば、先頭に `open` が入った並びへ直す) + +### Task 5: 補完と文書 + +- **対象ファイル:** `etc/devbase-completion.bash`、`etc/_devbase`、`tests/cli/test_completion.py`、`docs/user/cli-reference/02-project.md`、`CHANGELOG.md` +- **変更内容:** bash / zsh の候補に `open` を足し、`[name]` の補完を `up` と同じにする。CLI リファレンスに `open` の節を足し、`devbase list` の起動中メニューの先頭が変わったことを書く。CHANGELOG の Unreleased に追記する +- **満たす受け入れ条件:** 17 +- **進め方:** 補完の候補を見る失敗するテスト → 補完の変更。文書はテスト駆動の対象外(振る舞いを持たないため) + +### Task 6: 全体の確認 + +- **満たす受け入れ条件:** 15、18 +- **進め方:** `uv run pytest` を通す。起動中・停止中のプロジェクトで `devbase open` を手で動かし、仕様の「手動確認」を行う + +## 影響範囲 + +- `devbase env token`: 列挙の関数が包みになる(振る舞いは変えない) +- `devbase up` の `[6/6]`: 開く処理が `_open_editor_at` を通る(振る舞いは変えない) +- `devbase list` の起動中サブメニュー: 先頭のハイライトが `open` になる + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `container.py`(2200 行超)への追加 | 触る範囲は `_maybe_open_editor` の切り出しと関数 2 つの追加に限られ、`up` の自動オープンに既存テストがある。タスクごとにテストを通す | +| `cli.py` と `bin/devbase` と補完の同期漏れ | Task 3・5 のテストで 3 か所に `open` があることを見る | +| TUI の既存テストが先頭を `up` と決め打ちしている | Task 4 で洗い出して直す | + +## 切り戻し手順 + +- 追加のみでデータ移行を持たないため、この Pull Request を revert すれば元へ戻る + +## 完了の定義 + +- [ ] 受け入れ条件 1〜20 をすべて満たし、条件ごとにテストか手動確認の結果が対応している +- [ ] `uv run pytest` が exit=0 +- [ ] 手動確認(起動中で窓が開きコンテナが変わらない / 停止中で起動から開く / TUI の先頭が `open`)の結果を Pull Request に書いた diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 3709b128..45e4cd71 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -42,6 +42,8 @@ # 完結するため `build` と異なりトップレベルショートカットに含めてよい # (build は shell 実装に委譲するため除外している。上の NOTE 参照)。 'rebuild': 'rebuild', + # `open` はコンテナに触らずエディタだけを開き直す (PLAN59)。up と同じく `[name]` を取る。 + 'open': 'open', } # Group aliases @@ -54,9 +56,9 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list', - 'profile'], + 'profile', 'open'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', - 'profile'], + 'profile', 'open'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', 'exec', 'token', 'encrypt', 'decrypt', 'rekey', 'doctor', 'export', 'import', 'backend'], @@ -153,6 +155,23 @@ def _add_open_args(parser): return parser +def _add_open_subparser(sub, *, with_name: bool): + """`open` を登録する (PLAN59)。`project` とトップレベルは `[name]` を取り、`container` は取らない。 + + `--open` / `--no-open` は登録しない。明示のコマンドは開く意思表示そのもので、`up` の + 自動オープンの可否とは別に扱う (決定 3)。`--open-index` の検査 (1 以上・動いている + インスタンス) は cmd_open が行う。 + """ + p = sub.add_parser('open', help='Open the editor attached to the dev container ' + '(starts the project first if it is stopped)') + if with_name: + _add_name_arg(p) + p.add_argument('--open-index', dest='open_index', type=int, default=None, metavar='N', + help='Container index to open (default: DEVBASE_OPEN_INDEX or 1)') + _add_context_arg(p) + return p + + def _add_login_subparser(sub): """`login` サブコマンドを登録する (project / container 共通)。 @@ -244,6 +263,8 @@ def _add_container_parser(subparsers): _add_profile_subparser(ct_sub, with_name=False) + _add_open_subparser(ct_sub, with_name=False) + def _add_project_parser(subparsers): """Project group parser (CWD 非依存のプロジェクト操作)。 @@ -258,7 +279,7 @@ def _add_project_parser(subparsers): ため name を受け付けない。両者は project / container で定義が完全に一致するので `_add_login_subparser` / `_add_build_subparser` に共通化している。 - 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale) + 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale/rebuild/open) は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は wrapper 側リストの更新漏れに注意すること。 """ @@ -303,6 +324,9 @@ def _add_project_parser(subparsers): # (PLAN58 決定 6)。 _add_profile_subparser(pj_sub, with_name=True) + # `open` の `[name]` は up と同じく wrapper の _PROJECT_NAME_SUBCOMMANDS で cd する (PLAN59)。 + _add_open_subparser(pj_sub, with_name=True) + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) @@ -724,6 +748,9 @@ def _add_shortcuts(subparsers): _add_context_arg(_add_name_arg(subparsers.add_parser( 'rebuild', help='Rebuild stale images (= build --expires=7)'))) + # `open` は project open のトップレベルシノニム。up と同じく `[name]` を受け付ける。 + _add_open_subparser(subparsers, with_name=True) + # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に # cmd_project_list へ振り分ける。 @@ -744,6 +771,7 @@ def _create_parser(): " ps project ps\n" " scale project scale\n" " rebuild project rebuild (= build --expires=7)\n" + " open project open (open the editor without restarting)\n" "\n" "Note: `container` is deprecated; use `project` instead.\n" ) @@ -807,7 +835,8 @@ def _expand_argv(): # bin/devbase が build を shell 実装に委譲するため Python 側には top-level # build parser が無い。project build / container build は引き続き利用可能。 commands = ['init', 'status', 'project', 'container', 'ct', 'env', 'plugin', 'pl', - 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help'] + 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'open', 'list', + 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index a543b813..5865334c 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -25,7 +25,8 @@ docker_compose_down, docker_compose_up, wait_for_containers_ready, - ensure_network + ensure_network, + running_dev_instances, ) from devbase.utils.config import get_project_name from devbase.utils import docker_context @@ -719,6 +720,9 @@ def _dispatch_lifecycle(args) -> int: expires=getattr(args, 'expires', None), **ctx), 'rebuild': lambda: cmd_rebuild(**ctx), 'profile': lambda: _dispatch_profile(args, ctx), + 'open': lambda: cmd_open(project_name=project_name, + open_index=getattr(args, 'open_index', None), + **ctx), } handler = handlers.get(subcmd) @@ -978,30 +982,112 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index = _resolve_open_index(open_index, scale) + logger.info("[6/6] Opening editor attached to the dev container...") + try: + _open_editor_at(project_name, open_index, config, compose_file=compose_file, + docker_context_name=docker_context_name) + except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない + logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) + + +def _open_editor_at(project_name: str, index: int, config, compose_file=None, + docker_context_name: Optional[str] = None) -> str: + """dev インスタンス ``index`` へ接続したエディタを開き、``opener`` の action を返す。 + + 開く対象は ``config`` (``project.yml``) から決める。repo が 1 件なら primary の + フォルダ、2 件以上なら entrypoint が書き出した ``*.code-workspace``。有効判定と + index の解決は持たない。``up`` の [6/6] (:func:`_maybe_open_editor`) と + ``devbase open`` (:func:`cmd_open`) がそれぞれ済ませてから呼ぶ (PLAN59 決定 3)。 + """ + from devbase.editor import opener + # 実コンテナ名問い合わせ用の compose file: 明示指定がなければ override が # 存在すればそれを使う (起動時と同じ file を docker compose ps へ渡す)。 if compose_file is None and _SCALE_COMPOSE_FILE.exists(): compose_file = _SCALE_COMPOSE_FILE - dev_service_name = get_dev_service_name() - workdir = config.resolved_work_dir() # repo が 2 件以上なら multi-root workspace を開く (entrypoint が同じパスへ # ファイルを書き出している)。1 件なら従来どおりフォルダを開く。 workspace = (project_runtime.workspace_path(project_name) if len(config.repos) > 1 else None) - logger.info("[6/6] Opening editor attached to the dev container...") + return opener.open_editor( + project_name=project_name, + dev_service_name=get_dev_service_name(), + workdir=config.resolved_work_dir(), + workspace=workspace, + index=index, + compose_file=compose_file, + docker_context=docker_context_name, + ) + + +def _explicit_open_index(open_index: Optional[int]) -> int: + """``devbase open`` が開く番号 (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 + + ``up`` の :func:`_resolve_open_index` と違い、範囲の検査は呼び出し側が動いている + インスタンスに対して行う。env が数でなければ ``up`` と同じく 1 とする。 + """ + if open_index is not None: + return open_index + raw = os.environ.get('DEVBASE_OPEN_INDEX') try: - opener.open_editor( - project_name=project_name, - dev_service_name=dev_service_name, - workdir=workdir, - workspace=workspace, - index=open_index, - compose_file=compose_file, - docker_context=docker_context_name, - ) - except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない - logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) + return int(raw) if raw else 1 + except ValueError: + return 1 + + +def cmd_open(project_name: Optional[str] = None, open_index: Optional[int] = None, + context: Optional[str] = None) -> int: + """dev コンテナへ接続したエディタを、コンテナに触らずに開く (PLAN59)。 + + dev のインスタンスが 1 つも動いていなければ ``up --open`` へ委譲し、起動から + 開くところまで通す。明示の操作なので ``DEVBASE_OPEN_EDITOR`` と ``project.yml`` の + ``open_editor`` は見ない (決定 3)。 + + 戻り値: 開いた (``launch``) かコマンドを提示した (``print_command``) なら 0。 + index が 0 以下・動いていない index・状態を取得できない・開けなかった (``skip``) + ときは 1。停止中は ``cmd_up`` の戻り値。 + """ + if project_name is None: + project_name = get_project_name() + + index = _explicit_open_index(open_index) + if index < 1: + logger.error("open index %d は 1 以上を指定してください", index) + return 1 + + config = project_runtime.current_project_config() + + # 接続先の反映は docker を呼ばない。エディタへ渡す context は up の [6/6] と同じく + # devbase が決めた値 (未指定なら None)。機密は opener が実コンテナ名を問い合わせる + # docker compose ps の補間のために載せる (login と同じ扱い)。 + choice = _choose_context(context) + docker_context.apply(choice) + _inject_secrets(required=False) + + dev_service_name = get_dev_service_name() + running = running_dev_instances(project_name, dev_service_name) + if running is None: + # 取得できないことは停止中を意味しない。up へ進むと動いている環境を + # 作り直しうるため、ここで止まる (決定 2)。 + logger.error("dev コンテナの状態を取得できないため、エディタを開けません") + return 1 + + if not running: + logger.info("dev コンテナが起動していないため up を実行します (起動後にエディタを開きます)") + return cmd_up(project_name=project_name, open_editor=True, + open_index=open_index, context=context) + + running_indices = [i for i, _name in running] + if index not in running_indices: + logger.error("%s-%d は起動していません。起動中: %s", dev_service_name, index, + ', '.join(str(i) for i in running_indices)) + return 1 + + logger.info("エディタを開きます: %s-%d", dev_service_name, index) + action = _open_editor_at(project_name, index, config, + docker_context_name=choice.context) + return 1 if action == 'skip' else 0 def _report_missing_repos(config, scale: int, dev_service_name: str, diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index f6c73a69..6160c758 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -341,31 +341,14 @@ def cmd_env_exec(devbase_root: Path, argv, context: Optional[str] = None) -> int def _running_dev_containers(project: str, dev_service_name: str, runner) -> Optional[List[str]]: """起動中の dev コンテナの名前を ``-`` の番号順に返す。docker を呼べなければ ``None``。 - ``up`` の構成は dev の各インスタンスをサービス ``-`` として定義する - (``volume/compose.py``)。プロジェクトのラベルだけで絞ると DB や snapshot にも届く。 + 列挙そのものは ``devbase open`` の起動中の判定と共有する (PLAN59 決定 8)。 """ - import re + from devbase.utils.docker import running_dev_instances - try: - result = runner( - ['docker', 'ps', '--filter', f'label=com.docker.compose.project={project}', - '--format', '{{.Names}}\t{{.Label "com.docker.compose.service"}}'], - capture_output=True, text=True, check=False) - except (OSError, subprocess.SubprocessError) as e: - logger.error("docker ps を実行できませんでした: %s", e) - return None - if result.returncode != 0: - logger.error("docker ps が失敗しました (exit=%d): %s", result.returncode, - (result.stderr or '').strip()) + instances = running_dev_instances(project, dev_service_name, runner=runner) + if instances is None: return None - pattern = re.compile(rf'^{re.escape(dev_service_name)}-([1-9][0-9]*)$') - found = [] - for line in (result.stdout or '').splitlines(): - name, _, service = line.partition('\t') - match = pattern.match(service.strip()) - if name and match: - found.append((int(match.group(1)), name.strip())) - return [name for _index, name in sorted(found)] + return [name for _index, name in instances] def _require_openbao_backend(store): diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 9ad83d3f..006ec742 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -6,7 +6,7 @@ PR1 で **一覧選択 → (running なら操作サブメニュー) → それ以外は直接 up** を移送し、 PR2 で running 操作サブメニューを **up/down/login/ps/logs/scale/build/rebuild の全操作** -へ拡張した。login/ps/logs/scale は running 中コンテナを対象とするため running 行限定、 +へ拡張し、PLAN59 で先頭にエディタを開き直す open を足した。login/ps/logs/scale は running 中コンテナを対象とするため running 行限定、 stopped/unknown は従来どおり直接 up (PR1 非回帰)。引数を要する操作は ``tui.menu`` の 収集ヘルパで CLI と同じ属性値を集める (plan 2.3 契約表)。down はデータを失わない (volume 保持) ためメニュー選択を意思表示とみなし、確認プロンプトは出さない。 @@ -27,9 +27,12 @@ logger = get_logger(__name__) -# running 行で選べる操作 (表示順 = ハイライト既定順)。up を先頭に置き、PR1 同様 -# Enter 連打で再起動へ到達できるようにする。各 value は cmd_project のサブコマンド名。 +# running 行で選べる操作 (表示順 = ハイライト既定順)。open を先頭に置き、閉じた +# エディタの窓を Enter 1 回で開き直せるようにする (PLAN59)。コンテナに触らない操作な +# ので、Enter 連打で届いても環境は変わらない。再起動 (up) はその 1 つ下。 +# 各 value は cmd_project のサブコマンド名。 _RUNNING_OPS: list[tuple[str, str]] = [ + ("エディタを開く (open)", "open"), ("再起動 (up)", "up"), ("停止 (down)", "down"), ("ログイン (login)", "login"), @@ -49,7 +52,7 @@ # 実行後にサブメニューへ留まらずトップ一覧へ戻る操作。up/down とプロファイルの # 起動・停止はコンテナの数が変わるため、最新状態の一覧を見せる方が自然 (それ以外の -# login/ps/logs/scale/build/rebuild は連続操作できるようサブメニューに留まる)。 +# open/login/ps/logs/scale/build/rebuild は連続操作できるようサブメニューに留まる)。 _BACK_TO_TOP_OPS = frozenset({"up", "down", "profile-up", "profile-down"}) # 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 @@ -172,6 +175,9 @@ def run(devbase_root: Path, name: str): # コマンドは無視する)。down はデータを失わない (volume 保持・up で復旧可能) # ためメニュー選択を意思表示とみなし、確認プロンプトを出さない。 # ps の --all は CLI 既定 (False) に揃える。 + # open はコンテナに触らずエディタだけを開く。index は尋ねず既定 + # (DEVBASE_OPEN_INDEX、無ければ 1) を開く (PLAN59 決定 6)。 + "open": lambda root, name: dispatch_lifecycle("open", name, open_index=None), "up": lambda root, name: dispatch_lifecycle("up", name, scale=None), # 「再ビルド (rebuild --no-cache)」はラベル通り base/project とも無条件 no-cache # で再ビルドする。CLI の `rebuild` は期限判定 (= build --expires=7) でキャッシュ diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index 5fc0e60d..3e9cd7c1 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -75,6 +75,44 @@ def docker_compose( raise +def running_dev_instances(project: str, dev_service_name: str, + runner=None) -> Optional[List[Tuple[int, str]]]: + """動いている dev インスタンスの ``(番号, コンテナ名)`` を番号順に返す。 + + ``up`` の構成は dev の各インスタンスをサービス ``-`` として定義する + (``volume/compose.py``)。プロジェクトのラベルだけで絞ると DB や snapshot にも届くため、 + サービスのラベルでも絞る。``-a`` を付けないので止まっているコンテナは出ない。 + + docker を呼べない・0 以外で終わったときは error ログを出して ``None`` を返す。 + 動いているものが無い ``[]`` と区別するためで、``devbase open`` はこの区別で「停止中」と + 「状態を取得できない」を分ける (PLAN59 決定 2・8)。Compose のファイルを読まないため、 + 生成物の有無と構成の補間に左右されない。接続先は環境変数 ``DOCKER_CONTEXT`` に従う。 + """ + import re + + run = runner or subprocess.run + try: + result = run( + ['docker', 'ps', '--filter', f'label=com.docker.compose.project={project}', + '--format', '{{.Names}}\t{{.Label "com.docker.compose.service"}}'], + capture_output=True, text=True, check=False) + except (OSError, subprocess.SubprocessError) as e: + logger.error("docker ps を実行できませんでした: %s", e) + return None + if result.returncode != 0: + logger.error("docker ps が失敗しました (exit=%d): %s", result.returncode, + (result.stderr or '').strip()) + return None + pattern = re.compile(rf'^{re.escape(dev_service_name)}-([1-9][0-9]*)$') + found = [] + for line in (result.stdout or '').splitlines(): + name, _, service = line.partition('\t') + match = pattern.match(service.strip()) + if name and match: + found.append((int(match.group(1)), name.strip())) + return sorted(found) + + def get_container_status( service_name: str, compose_file: Optional[Path] = None diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index 0bae82c5..80d2627a 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -188,3 +188,32 @@ def test_zsh_completion_mentions_profile(): text = ZSH_COMPLETION.read_text() assert "'profile:" in text assert "_values 'operation' up down list" in text + + +# PLAN59: `open` (コンテナに触らずエディタを開き直す) +def test_bash_open_is_a_top_level_command(fake_root): + assert "open" in _bash_complete("devbase ''", 1, fake_root) + + +@pytest.mark.parametrize("group", ["project", "container", "ct"]) +def test_bash_open_is_a_group_subcommand(fake_root, group): + assert "open" in _bash_complete(f"devbase {group} ''", 2, fake_root) + + +@pytest.mark.parametrize("words, cword", [("devbase open ''", 2), + ("devbase project open ''", 3)]) +def test_bash_open_completes_project_names(fake_root, words, cword): + assert set(_bash_complete(words, cword, fake_root)) == {"web", "api", "linked"} + + +@pytest.mark.parametrize("words, cword", [("devbase open '-'", 2), + ("devbase project open '-'", 3), + ("devbase container open '-'", 3)]) +def test_bash_open_completes_its_flags(fake_root, words, cword): + assert set(_bash_complete(words, cword, fake_root)) == {"--open-index", "--context"} + + +def test_zsh_completion_mentions_open(): + text = ZSH_COMPLETION.read_text() + assert text.count("'open:") == 3 # トップレベル / project / container + assert "--open-index" in text diff --git a/tests/cli/test_open_command.py b/tests/cli/test_open_command.py new file mode 100644 index 00000000..9af0f4a5 --- /dev/null +++ b/tests/cli/test_open_command.py @@ -0,0 +1,140 @@ +"""`devbase open` の CLI 登録と入口のシェル (PLAN59 / #197) + +parser・ショートカット・前方一致・`bin/devbase` の name 解決の 4 か所に `open` が揃っていることを +固定する。いずれかが欠けると、`devbase open` が unknown command になるか、`project open ` の +name が Python へ渡って別の意味に読まれる。 +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from devbase import cli + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +def _parse(*argv): + return cli._create_parser().parse_args(list(argv)) + + +# --- parser ------------------------------------------------------------------ + +@pytest.mark.parametrize('argv', [['open'], ['project', 'open']]) +def test_open_takes_name_index_and_context(argv): + ns = _parse(*argv, 'carmo', '--open-index', '2', '--context', 'remote') + assert ns.name == 'carmo' + assert ns.open_index == 2 + assert ns.context == 'remote' + + +def test_container_open_takes_no_name(): + ns = _parse('container', 'open', '--open-index', '2') + assert ns.subcommand == 'open' + assert ns.open_index == 2 + assert not hasattr(ns, 'name') + with pytest.raises(SystemExit): + _parse('container', 'open', 'carmo') + + +@pytest.mark.parametrize('flag', ['--open', '--no-open']) +@pytest.mark.parametrize('argv', [['open'], ['project', 'open'], ['container', 'open']]) +def test_open_rejects_the_auto_open_flags(argv, flag, capsys): + """受け入れ条件 11""" + with pytest.raises(SystemExit) as e: + _parse(*argv, flag) + assert e.value.code == 2 + + +def test_open_is_a_shortcut_to_project_open(): + assert cli.SHORTCUTS['open'] == 'open' + assert 'project open' in (cli._create_parser().epilog or '') + + +def test_open_is_in_the_subcommand_maps(): + assert 'open' in cli.SUBCMD_MAP[('project',)] + assert 'open' in cli.SUBCMD_MAP[('container', 'ct')] + + +def test_shortcut_dispatches_to_cmd_open(monkeypatch): + """受け入れ条件 9: トップレベルの name は project open と同じく下流へ渡る""" + from devbase.commands import container + + seen = {} + monkeypatch.setattr(container, '_enter_project', lambda name: seen.setdefault('entered', name)) + monkeypatch.setattr(container, 'cmd_open', + lambda **kw: seen.setdefault('open', kw) and 0) + ns = _parse('open', 'carmo', '--open-index', '2') + cli._dispatch('open', ns) + assert seen['entered'] == 'carmo' + assert seen['open'] == {'project_name': 'carmo', 'open_index': 2} + + +# --- 前方一致 (受け入れ条件 16) ------------------------------------------------- + +@pytest.mark.parametrize('argv, expected', [ + (['devbase', 'o'], ['devbase', 'open']), + (['devbase', 'op'], ['devbase', 'open']), + (['devbase', 'l'], ['devbase', 'login']), + (['devbase', 'project', 'o'], ['devbase', 'project', 'open']), + (['devbase', 'project', 'p'], ['devbase', 'project', 'ps']), + (['devbase', 'container', 'o'], ['devbase', 'container', 'open']), +]) +def test_prefix_resolution(monkeypatch, argv, expected): + monkeypatch.setattr(sys, 'argv', list(argv)) + cli._expand_argv() + assert sys.argv == expected + + +# --- bin/devbase ------------------------------------------------------------- + +def _run_wrapper(args, devbase_root): + harness = ( + 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "BUILD:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root), "WRAPPER_PATH": str(WRAPPER)} + return subprocess.run(["bash", "-c", harness, "devbase", *args], + capture_output=True, text=True, env=env, cwd=str(REPO_ROOT)) + + +def _field(result, prefix): + for line in result.stdout.splitlines(): + if line.startswith(prefix): + return line[len(prefix):] + return None + + +@pytest.fixture +def wrapper_root(tmp_path): + (tmp_path / "projects" / "carmo").mkdir(parents=True) + return tmp_path + + +def test_wrapper_top_level_open_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["open", "carmo", "--open-index", "2"], wrapper_root) + assert "unknown command" not in r.stderr.lower(), r.stderr + assert _field(r, "PWD:").endswith("/projects/carmo"), r.stdout + assert _field(r, "PYTHON:") == "open --open-index 2", r.stdout + + +def test_wrapper_project_open_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["project", "open", "carmo"], wrapper_root) + assert _field(r, "PWD:").endswith("/projects/carmo"), r.stdout + assert _field(r, "PYTHON:") == "project open", r.stdout + + +def test_wrapper_open_prefix_resolves(wrapper_root): + r = _run_wrapper(["o"], wrapper_root) + assert _field(r, "PYTHON:") == "open", (r.stdout, r.stderr) diff --git a/tests/cli/test_rebuild.py b/tests/cli/test_rebuild.py index f2c879cb..0a46fa28 100644 --- a/tests/cli/test_rebuild.py +++ b/tests/cli/test_rebuild.py @@ -144,5 +144,10 @@ def test_wrapper_routes_rebuild_to_python(): def test_wrapper_rebuild_in_name_resolvable(): wrapper = (Path(__file__).resolve().parents[2] / 'bin' / 'devbase').read_text() - assert '_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild "' in wrapper - assert '_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild "' in wrapper + import re + + def listed(var): + return re.search(rf'^{var}=" (.*) "$', wrapper, re.M).group(1).split() + + assert 'rebuild' in listed('_NAME_RESOLVABLE_SHORTCUTS') + assert 'rebuild' in listed('_PROJECT_NAME_SUBCOMMANDS') diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index ae731cbb..a72e42a7 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -162,10 +162,10 @@ def fake_select(message, choices, *, back, search): assert actions_project._select_action("carmo") == "logs" assert captured["back"] is True assert captured["search"] is False - # up を先頭にしつつ全8操作を提示する (PR2)。 + # open を先頭にしつつ全9操作を提示する (PR2 / PLAN59)。 assert captured["values"] == [ - "up", "down", "login", "ps", "logs", "scale", "build", "rebuild"] - assert captured["values"][0] == "up", "Enter 連打で up に到達できる" + "open", "up", "down", "login", "ps", "logs", "scale", "build", "rebuild"] + assert captured["values"][0] == "open", "Enter 1 回でエディタを開き直せる" # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_open_menu.py b/tests/cli/tui/test_open_menu.py new file mode 100644 index 00000000..a33ff897 --- /dev/null +++ b/tests/cli/tui/test_open_menu.py @@ -0,0 +1,30 @@ +"""PLAN59: `devbase list` の起動中メニューの先頭から、エディタだけを開き直す + +先頭は Enter 1 回で届く位置で、窓を閉じた後に開き直すための項目を置く。実行後は +コンテナの数が変わらないため、サブメニューに留まる (login / ps と同じ扱い)。 +""" + +from __future__ import annotations + +from devbase.tui import actions_project + + +def test_open_is_the_first_running_operation(): + """受け入れ条件 12""" + assert actions_project._RUNNING_OPS[0] == ("エディタを開く (open)", "open") + # 既存の操作は先頭の後ろにそのまま並ぶ + assert [v for _label, v in actions_project._RUNNING_OPS[1:4]] == ["up", "down", "login"] + + +def test_open_dispatches_to_the_shared_handler(monkeypatch, tmp_path): + """受け入れ条件 13""" + calls = [] + monkeypatch.setattr(actions_project, "dispatch_lifecycle", + lambda sub, name, **attrs: calls.append((sub, name, attrs)) or 0) + assert actions_project._OP_HANDLERS["open"](tmp_path, "carmo") == 0 + assert calls == [("open", "carmo", {"open_index": None})] + + +def test_open_stays_in_the_submenu(): + """受け入れ条件 14""" + assert "open" not in actions_project._BACK_TO_TOP_OPS diff --git a/tests/commands/test_container_open.py b/tests/commands/test_container_open.py new file mode 100644 index 00000000..2357c273 --- /dev/null +++ b/tests/commands/test_container_open.py @@ -0,0 +1,230 @@ +"""`devbase open`: コンテナに触らず dev コンテナへ接続した窓を開く (PLAN59 / #197) + +起動中の判定 (``running_dev_instances``)・開く処理 (``opener.open_editor``)・``cmd_up`` を +差し替え、仕様の受け入れ条件の分岐を 1 つずつ固定する。``up`` のパイプラインの部品は +呼ばれたら落ちるスタブにして、起動中の経路が触らないことを見る。 +""" + +from __future__ import annotations + +import logging +import os + +import pytest + +from devbase.commands import container +from devbase.editor import opener +from devbase.utils import docker_context as dc + +PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', 'DEVBASE_ROOT', + 'DEVBASE_OPEN_EDITOR', 'DEVBASE_OPEN_INDEX'): + monkeypatch.delenv(name, raising=False) + dc.reset() + saved = dict(os.environ) + yield + dc.reset() + os.environ.clear() + os.environ.update(saved) + + +class Harness: + """外部作用の差し替えと記録""" + + def __init__(self, monkeypatch, running): + self.running = running # list[(index, name)] / None (取得できない) + self.opened = [] # open_editor の kwargs + self.ups = [] # cmd_up の kwargs + self.ps_context = [] # running_dev_instances を呼んだ時点の DOCKER_CONTEXT + self.action = 'launch' + self.up_rc = 0 + + def fake_running(project, dev_service_name, runner=None): + self.ps_context.append(os.environ.get('DOCKER_CONTEXT')) + self.ps_args = (project, dev_service_name) + return self.running + + def fake_open(**kwargs): + self.opened.append(kwargs) + return self.action + + def fake_up(**kwargs): + self.ups.append(kwargs) + return self.up_rc + + def forbidden(name): + def _f(*a, **k): + raise AssertionError(f'{name} は open の起動中の経路で呼ばない') + return _f + + monkeypatch.setattr(container, 'running_dev_instances', fake_running) + monkeypatch.setattr(opener, 'open_editor', fake_open) + monkeypatch.setattr(container, 'cmd_up', fake_up) + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_inject_secrets', lambda **k: None) + for name in ('_run_deploy_pipeline', '_run_pre_up_checks', '_auto_snapshot', + 'ensure_volumes', 'ensure_network', 'docker_compose_up', + 'docker_compose_down', '_run_deploy_script_for_instances'): + monkeypatch.setattr(container, name, forbidden(name)) + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + return tmp_path + + +@pytest.fixture +def harness(project, monkeypatch): + return Harness(monkeypatch, running=[(1, 'proj-dev-1')]) + + +def _open(**kw): + return container.cmd_open(**kw) + + +# --- 起動中 ------------------------------------------------------------------- + +def test_running_opens_the_editor_without_touching_containers(harness): + """受け入れ条件 1""" + assert _open() == 0 + assert len(harness.opened) == 1 + assert harness.opened[0]['index'] == 1 + assert harness.opened[0]['project_name'] == 'proj' + assert harness.ups == [] + assert harness.ps_args == ('proj', 'dev') + + +@pytest.mark.parametrize('disabled', ['env', 'project_yml']) +def test_open_ignores_the_auto_open_switch(harness, project, monkeypatch, disabled): + """受け入れ条件 2""" + if disabled == 'env': + monkeypatch.setenv('DEVBASE_OPEN_EDITOR', '0') + else: + (project / 'project.yml').write_text(PROJECT_YML + "open_editor: false\n") + assert _open() == 0 + assert len(harness.opened) == 1 + + +def test_index_beyond_project_yml_scale_is_accepted_when_running(harness): + """受け入れ条件 6: 上限は project.yml ではなく動いている数から決まる""" + harness.running = [(1, 'proj-dev-1'), (2, 'proj-dev-2')] + assert _open(open_index=2) == 0 + assert harness.opened[0]['index'] == 2 + + +def test_index_from_env_is_used(harness, monkeypatch): + """受け入れ条件 8""" + harness.running = [(1, 'proj-dev-1'), (2, 'proj-dev-2')] + monkeypatch.setenv('DEVBASE_OPEN_INDEX', '2') + assert _open() == 0 + assert harness.opened[0]['index'] == 2 + + +def test_index_not_running_is_an_error(harness, caplog): + """受け入れ条件 5""" + with caplog.at_level(logging.ERROR): + assert _open(open_index=2) == 1 + assert harness.opened == [] and harness.ups == [] + assert 'dev-2' in caplog.text and '1' in caplog.text + + +@pytest.mark.parametrize('index', [0, -1]) +def test_index_below_one_is_an_error_before_docker(harness, index): + """受け入れ条件 7""" + assert _open(open_index=index) == 1 + assert harness.ps_context == [] + assert harness.opened == [] and harness.ups == [] + + +def test_context_reaches_the_state_query_and_the_editor(harness): + """受け入れ条件 10""" + assert _open(context='remote-x') == 0 + assert harness.ps_context == ['remote-x'] + assert harness.opened[0]['docker_context'] == 'remote-x' + + +def test_skip_is_a_failure(harness): + """受け入れ条件 19: 開けなかったことを終了コードで返す""" + harness.action = 'skip' + assert _open() == 1 + + +def test_print_command_is_a_success(harness): + """受け入れ条件 19: SSH でコマンドを提示したときは成功""" + harness.action = 'print_command' + assert _open() == 0 + + +def test_workspace_is_opened_for_multiple_repos(harness, project): + (project / 'project.yml').write_text( + PROJECT_YML + " - owner: volareinc\n repo: other\n") + assert _open() == 0 + assert harness.opened[0]['workspace'] + + +# --- 停止中 / 取得できない ---------------------------------------------------- + +def test_stopped_delegates_to_up_with_open(harness, caplog): + """受け入れ条件 3""" + harness.running = [] + harness.up_rc = 7 + with caplog.at_level(logging.INFO): + assert _open(open_index=2, context='ctx') == 7 + assert harness.opened == [] + assert harness.ups == [{'project_name': 'proj', 'open_editor': True, 'open_index': 2, + 'context': 'ctx'}] + assert 'up' in caplog.text + + +def test_stopped_opens_even_when_auto_open_is_disabled(harness, monkeypatch): + """受け入れ条件 4""" + harness.running = [] + monkeypatch.setenv('DEVBASE_OPEN_EDITOR', '0') + assert _open() == 0 + assert harness.ups[0]['open_editor'] is True + assert harness.ups[0]['open_index'] is None + + +def test_state_query_failure_does_not_start_up(harness): + """受け入れ条件 20""" + harness.running = None + assert _open() == 1 + assert harness.ups == [] and harness.opened == [] + + +# --- dispatch --------------------------------------------------------------- + +def test_dispatch_lifecycle_routes_open(harness): + import types + + ns = types.SimpleNamespace(subcommand='open', name=None, open_index=None, context=None) + assert container.cmd_project(ns) == 0 + assert len(harness.opened) == 1 + + +# --- up の自動オープンは変わらない (受け入れ条件 15) ----------------------------- + +def test_up_auto_open_still_falls_back_to_index_one(project, monkeypatch): + """up の [6/6] は範囲外の index を 1 へ落とす既存の振る舞いを保つ""" + opened = [] + monkeypatch.setattr(opener, 'open_editor', lambda **k: opened.append(k) or 'launch') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + config = container.project_runtime.current_project_config() + container._maybe_open_editor('proj', True, 5, 1, config) + assert opened[0]['index'] == 1 + + +def test_up_auto_open_respects_the_switch(project, monkeypatch): + opened = [] + monkeypatch.setattr(opener, 'open_editor', lambda **k: opened.append(k) or 'launch') + monkeypatch.setenv('DEVBASE_OPEN_EDITOR', '0') + config = container.project_runtime.current_project_config() + container._maybe_open_editor('proj', None, None, 1, config) + assert opened == [] diff --git a/tests/utils/test_running_dev_instances.py b/tests/utils/test_running_dev_instances.py new file mode 100644 index 00000000..044cd726 --- /dev/null +++ b/tests/utils/test_running_dev_instances.py @@ -0,0 +1,60 @@ +"""動いている dev インスタンスの列挙 (PLAN59 決定 8) + +``devbase open`` の起動中の判定と ``devbase env token`` の配り先が同じ列挙を使う。 +失敗 (``None``) と 0 個 (``[]``) を区別することが、``open`` が停止中と誤って ``up`` へ +進まないための契約である (決定 2)。 +""" + +from __future__ import annotations + +import subprocess + +from devbase.utils.docker import running_dev_instances + + +def _ps(stdout='', returncode=0, stderr=''): + calls = [] + + def run(argv, **kwargs): + calls.append(list(argv)) + return subprocess.CompletedProcess(argv, returncode, stdout=stdout, stderr=stderr) + + run.calls = calls + return run + + +def test_lists_dev_instances_in_index_order(): + run = _ps('web-dev-2\tdev-2\nweb-dev-1\tdev-1\nweb-dev-10\tdev-10\n') + assert running_dev_instances('web', 'dev', runner=run) == [ + (1, 'web-dev-1'), (2, 'web-dev-2'), (10, 'web-dev-10')] + + +def test_filters_by_the_compose_project_label(): + run = _ps('') + running_dev_instances('web', 'dev', runner=run) + argv = run.calls[0] + assert argv[:2] == ['docker', 'ps'] + assert 'label=com.docker.compose.project=web' in argv + # 止まっているコンテナを数えない (`-a` を付けない) + assert '-a' not in argv and '--all' not in argv + + +def test_ignores_services_other_than_dev_instances(): + run = _ps('web-db-1\tdb\nweb-dev-1\tdev-1\nweb-devx-1\tdevx-1\nweb-dev-0\tdev-0\n' + 'web-dev\tdev\n') + assert running_dev_instances('web', 'dev', runner=run) == [(1, 'web-dev-1')] + + +def test_nothing_running_is_an_empty_list(): + assert running_dev_instances('web', 'dev', runner=_ps('')) == [] + + +def test_non_zero_exit_is_none(): + assert running_dev_instances('web', 'dev', runner=_ps(returncode=1, stderr='boom')) is None + + +def test_os_error_is_none(): + def run(argv, **kwargs): + raise FileNotFoundError('docker') + + assert running_dev_instances('web', 'dev', runner=run) is None From 24207cf0dfdd86c879af859d33fe58140c0976a5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:04:48 +0900 Subject: [PATCH 2/7] test: characterize open command and running instance branches Add characterization coverage for open index resolution, project open dispatch, menu continuation after open, and Docker output parsing. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- tests/cli/test_open_command.py | 15 +++++++++++ tests/cli/tui/test_open_menu.py | 27 ++++++++++++++++++++ tests/commands/test_container_open.py | 31 +++++++++++++++++++++++ tests/utils/test_running_dev_instances.py | 13 ++++++++++ 4 files changed, 86 insertions(+) diff --git a/tests/cli/test_open_command.py b/tests/cli/test_open_command.py index 9af0f4a5..b33b3200 100644 --- a/tests/cli/test_open_command.py +++ b/tests/cli/test_open_command.py @@ -76,6 +76,21 @@ def test_shortcut_dispatches_to_cmd_open(monkeypatch): assert seen['open'] == {'project_name': 'carmo', 'open_index': 2} +def test_project_open_dispatches_to_cmd_open(monkeypatch): + """現状固定: 推奨の project open も対象名と番号を cmd_open に渡す。""" + from devbase.commands import container + + seen = {} + monkeypatch.setattr(container, '_enter_project', lambda name: seen.setdefault('entered', name)) + monkeypatch.setattr(container, 'cmd_open', + lambda **kw: seen.setdefault('open', kw) and 0) + ns = _parse('project', 'open', 'carmo', '--open-index', '2') + + assert cli._dispatch('project', ns) == 0 + assert seen['entered'] == 'carmo' + assert seen['open'] == {'project_name': 'carmo', 'open_index': 2} + + # --- 前方一致 (受け入れ条件 16) ------------------------------------------------- @pytest.mark.parametrize('argv, expected', [ diff --git a/tests/cli/tui/test_open_menu.py b/tests/cli/tui/test_open_menu.py index a33ff897..07803763 100644 --- a/tests/cli/tui/test_open_menu.py +++ b/tests/cli/tui/test_open_menu.py @@ -6,6 +6,8 @@ from __future__ import annotations +import pytest + from devbase.tui import actions_project @@ -28,3 +30,28 @@ def test_open_dispatches_to_the_shared_handler(monkeypatch, tmp_path): def test_open_stays_in_the_submenu(): """受け入れ条件 14""" assert "open" not in actions_project._BACK_TO_TOP_OPS + + +@pytest.mark.parametrize('open_rc', [0, 1], ids=['open-success', 'open-failure']) +def test_current_open_result_allows_next_operation(monkeypatch, tmp_path, open_rc): + """現状固定: open の成否によらず同じプロジェクトの ps を続けて選べる。""" + from devbase.commands import container + + calls = [] + choices = iter(['open', 'ps', actions_project.menu.MENU_BACK]) + + def dispatch(sub, name, **attrs): + calls.append((sub, name, attrs)) + return open_rc if sub == 'open' else 0 + + monkeypatch.setattr(container, 'project_profile_names', lambda name: []) + monkeypatch.setattr(actions_project.menu, 'select', lambda *a, **kw: next(choices)) + monkeypatch.setattr(actions_project.menu, 'clear_screen', lambda: None) + monkeypatch.setattr('builtins.input', lambda prompt: '') + monkeypatch.setattr(actions_project, 'dispatch_lifecycle', dispatch) + + result = actions_project.handle_row(tmp_path, {'name': 'carmo', 'status': 'running'}) + + assert result is actions_project.menu.MENU_BACK + assert calls == [('open', 'carmo', {'open_index': None}), + ('ps', 'carmo', {'all': False})] diff --git a/tests/commands/test_container_open.py b/tests/commands/test_container_open.py index 2357c273..6a88d7db 100644 --- a/tests/commands/test_container_open.py +++ b/tests/commands/test_container_open.py @@ -127,6 +127,37 @@ def test_index_from_env_is_used(harness, monkeypatch): assert harness.opened[0]['index'] == 2 +@pytest.mark.parametrize('env_index, kwargs, expected_index', [ + pytest.param('3', {'open_index': 1}, 1, id='explicit-overrides-env'), + pytest.param('', {}, 1, id='empty-env'), + pytest.param('invalid', {}, 1, id='non-integer-env'), + pytest.param(None, {'open_index': 3}, 3, id='non-contiguous-running-index'), +]) +def test_current_index_resolution_with_gaps(harness, monkeypatch, env_index, kwargs, + expected_index): + """現状固定: env のフォールバックと、飛び番号の起動一覧への接続。""" + harness.running = [(1, 'proj-dev-1'), (3, 'proj-dev-3')] + if env_index is None: + monkeypatch.delenv('DEVBASE_OPEN_INDEX', raising=False) + else: + monkeypatch.setenv('DEVBASE_OPEN_INDEX', env_index) + + assert container.cmd_open(**kwargs) == 0 + assert [(opened['project_name'], opened['index']) for opened in harness.opened] == [ + ('proj', expected_index)] + assert harness.ups == [] + + +def test_current_missing_index_between_running_instances_is_an_error(harness, monkeypatch): + """現状固定: 起動中の番号の間でも、存在しない番号は起動せず失敗する。""" + harness.running = [(1, 'proj-dev-1'), (3, 'proj-dev-3')] + monkeypatch.delenv('DEVBASE_OPEN_INDEX', raising=False) + + assert container.cmd_open(open_index=2) == 1 + assert harness.opened == [] + assert harness.ups == [] + + def test_index_not_running_is_an_error(harness, caplog): """受け入れ条件 5""" with caplog.at_level(logging.ERROR): diff --git a/tests/utils/test_running_dev_instances.py b/tests/utils/test_running_dev_instances.py index 044cd726..c4f250ff 100644 --- a/tests/utils/test_running_dev_instances.py +++ b/tests/utils/test_running_dev_instances.py @@ -58,3 +58,16 @@ def run(argv, **kwargs): raise FileNotFoundError('docker') assert running_dev_instances('web', 'dev', runner=run) is None + + +def test_current_parser_ignores_missing_separator_and_container_name(): + """現状固定: 不完全な行を混ぜても、有効な行だけを返す。""" + run = _ps('dev-1\n\tdev-2\nweb-dev-3\tdev-3\n') + assert running_dev_instances('web', 'dev', runner=run) == [(3, 'web-dev-3')] + + +def test_current_parser_matches_literal_service_name_and_positive_index(): + """現状固定: サービス名のドットは文字通り扱い、先頭ゼロの番号は除く。""" + run = _ps('web-dev.app-2\tdev.app-2\nweb-devXapp-1\tdevXapp-1\n' + 'web-dev.app-01\tdev.app-01\n') + assert running_dev_instances('web', 'dev.app', runner=run) == [(2, 'web-dev.app-2')] From fabb77d9b996ed019e8a581857103c0272df1cc7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:21:01 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor(PLAN59):=20DEVBASE=5FOPEN=5FINDEX?= =?UTF-8?q?=20=E3=81=AE=E8=AA=AD=E3=81=BF=E6=96=B9=E3=82=92=20up=20?= =?UTF-8?q?=E3=81=A8=20open=20=E3=81=A7=E5=85=B1=E6=9C=89=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-refactoring の提案ラウンドで kiro / codex が挙げた重複 (minor のため閾値で見送り) を解消する。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 29 +++++++++++++++------------ tests/commands/test_container_open.py | 11 ++++++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 5865334c..5b526063 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -838,6 +838,19 @@ def _auto_snapshot(remote: bool = False) -> None: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) +def _open_index_from_env() -> int: + """env ``DEVBASE_OPEN_INDEX`` が指す番号。未設定・空・数でなければ既定の 1。 + + ``up`` の [6/6] と ``devbase open`` が CLI 引数を省いたときに共有する。範囲の検査は + 持たない (呼び出し側で扱いが違うため)。 + """ + raw = os.environ.get('DEVBASE_OPEN_INDEX') + try: + return int(raw) if raw else 1 + except ValueError: + return 1 + + def _resolve_open_index(open_index: Optional[int], scale: int) -> int: """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 @@ -846,11 +859,7 @@ def _resolve_open_index(open_index: Optional[int], scale: int) -> int: で env フォールバック・範囲チェックを共有する。 """ if open_index is None: - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - open_index = int(raw) if raw else 1 - except ValueError: - open_index = 1 + open_index = _open_index_from_env() if not (1 <= open_index <= scale): logger.warning( "open index %d is out of range (1..%d); falling back to 1", @@ -1025,15 +1034,9 @@ def _explicit_open_index(open_index: Optional[int]) -> int: """``devbase open`` が開く番号 (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 ``up`` の :func:`_resolve_open_index` と違い、範囲の検査は呼び出し側が動いている - インスタンスに対して行う。env が数でなければ ``up`` と同じく 1 とする。 + インスタンスに対して行う。env の読み方は ``up`` と共有する (:func:`_open_index_from_env`)。 """ - if open_index is not None: - return open_index - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - return int(raw) if raw else 1 - except ValueError: - return 1 + return open_index if open_index is not None else _open_index_from_env() def cmd_open(project_name: Optional[str] = None, open_index: Optional[int] = None, diff --git a/tests/commands/test_container_open.py b/tests/commands/test_container_open.py index 6a88d7db..9ef3e5b5 100644 --- a/tests/commands/test_container_open.py +++ b/tests/commands/test_container_open.py @@ -259,3 +259,14 @@ def test_up_auto_open_respects_the_switch(project, monkeypatch): config = container.project_runtime.current_project_config() container._maybe_open_editor('proj', None, None, 1, config) assert opened == [] + + +@pytest.mark.parametrize('env_index, expected', [('2', 2), ('', 1), ('x', 1), (None, 1)]) +def test_up_reads_the_same_env_index_as_open(monkeypatch, env_index, expected): + """up の [6/6] と open は DEVBASE_OPEN_INDEX の読み方を共有する""" + if env_index is None: + monkeypatch.delenv('DEVBASE_OPEN_INDEX', raising=False) + else: + monkeypatch.setenv('DEVBASE_OPEN_INDEX', env_index) + assert container._resolve_open_index(None, 2) == expected + assert container._explicit_open_index(None) == expected From 00c4809d0f176d2d9d0cca58db0e5aedd97f70f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:36:12 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(PLAN59):=20open=20=E3=81=AE=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=81=A7=E5=89=8D=E6=96=B9=E4=B8=80=E8=87=B4=E3=82=92?= =?UTF-8?q?=E7=84=A1=E5=8A=B9=E3=81=AB=E3=81=97=20--open=202=20=E3=82=92?= =?UTF-8?q?=E6=8B=92=E5=90=A6=E3=81=99=E3=82=8B=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - open サブパーサーを allow_abbrev=False で作り、--open 2 / --open=2 が --open-index に吸われないようにする (受け入れ条件 11) - running_dev_instances の docstring をラベルと正規表現の役割分担に合わせて直す Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/cli.py | 5 +++-- lib/devbase/utils/docker.py | 5 +++-- tests/cli/test_open_command.py | 9 +++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 45e4cd71..28d6d617 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -160,9 +160,10 @@ def _add_open_subparser(sub, *, with_name: bool): `--open` / `--no-open` は登録しない。明示のコマンドは開く意思表示そのもので、`up` の 自動オープンの可否とは別に扱う (決定 3)。`--open-index` の検査 (1 以上・動いている - インスタンス) は cmd_open が行う。 + インスタンス) は cmd_open が行う。`allow_abbrev=False` は `--open 2` / `--open=2` が + 前方一致で `--open-index` に吸われるのを防ぐ (受け入れ条件 11)。 """ - p = sub.add_parser('open', help='Open the editor attached to the dev container ' + p = sub.add_parser('open', allow_abbrev=False, help='Open the editor attached to the dev container ' '(starts the project first if it is stopped)') if with_name: _add_name_arg(p) diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index 3e9cd7c1..dde0962a 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -80,8 +80,9 @@ def running_dev_instances(project: str, dev_service_name: str, """動いている dev インスタンスの ``(番号, コンテナ名)`` を番号順に返す。 ``up`` の構成は dev の各インスタンスをサービス ``-`` として定義する - (``volume/compose.py``)。プロジェクトのラベルだけで絞ると DB や snapshot にも届くため、 - サービスのラベルでも絞る。``-a`` を付けないので止まっているコンテナは出ない。 + (``volume/compose.py``)。``docker ps`` はプロジェクトのラベルで絞り、それだけでは DB や + snapshot にも届くため、サービス名 (``-``) は正規表現で選別する。``-a`` を付けないので + 止まっているコンテナは出ない。 docker を呼べない・0 以外で終わったときは error ログを出して ``None`` を返す。 動いているものが無い ``[]`` と区別するためで、``devbase open`` はこの区別で「停止中」と diff --git a/tests/cli/test_open_command.py b/tests/cli/test_open_command.py index b33b3200..9ef9eb6b 100644 --- a/tests/cli/test_open_command.py +++ b/tests/cli/test_open_command.py @@ -52,6 +52,15 @@ def test_open_rejects_the_auto_open_flags(argv, flag, capsys): assert e.value.code == 2 +@pytest.mark.parametrize('args', [('--open', '2'), ('--open=2',), ('--open-i', '2')]) +@pytest.mark.parametrize('argv', [['open'], ['project', 'open'], ['container', 'open']]) +def test_open_rejects_abbreviations_of_open_index(argv, args, capsys): + """受け入れ条件 11: 前方一致で --open 2 が --open-index 2 と解釈されない""" + with pytest.raises(SystemExit) as e: + _parse(*argv, *args) + assert e.value.code == 2 + + def test_open_is_a_shortcut_to_project_open(): assert cli.SHORTCUTS['open'] == 'open' assert 'project open' in (cli._create_parser().epilog or '') From 3af259b46e9c84e64319499d89b1a98354ccee3f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:36:48 +0900 Subject: [PATCH 5/7] =?UTF-8?q?style(PLAN59):=20open=20=E3=81=AE=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=83=91=E3=83=BC=E3=82=B5=E3=83=BC=E7=99=BB=E9=8C=B2?= =?UTF-8?q?=E3=81=AE=E7=B6=99=E7=B6=9A=E8=A1=8C=E3=82=92=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=82=8B=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 28d6d617..a52c3eb6 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -163,8 +163,9 @@ def _add_open_subparser(sub, *, with_name: bool): インスタンス) は cmd_open が行う。`allow_abbrev=False` は `--open 2` / `--open=2` が 前方一致で `--open-index` に吸われるのを防ぐ (受け入れ条件 11)。 """ - p = sub.add_parser('open', allow_abbrev=False, help='Open the editor attached to the dev container ' - '(starts the project first if it is stopped)') + p = sub.add_parser('open', allow_abbrev=False, + help='Open the editor attached to the dev container ' + '(starts the project first if it is stopped)') if with_name: _add_name_arg(p) p.add_argument('--open-index', dest='open_index', type=int, default=None, metavar='N', From 9b5caf66990969d88466b872ba3dbbe945c69143 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:50:54 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs(PLAN59):=20=E5=AE=9F=E8=A3=85=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=81=AB=E6=A4=9C=E8=A8=BC=E7=B5=90=E6=9E=9C=E3=81=A8?= =?UTF-8?q?=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=81=AE?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=E3=82=92=E6=9B=B8=E3=81=8F=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN59_editor-open-impl.md | 45 ++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/issues/PLAN59_editor-open-impl.md b/issues/PLAN59_editor-open-impl.md index d7ba233e..d975d93a 100644 --- a/issues/PLAN59_editor-open-impl.md +++ b/issues/PLAN59_editor-open-impl.md @@ -88,6 +88,45 @@ ## 完了の定義 -- [ ] 受け入れ条件 1〜20 をすべて満たし、条件ごとにテストか手動確認の結果が対応している -- [ ] `uv run pytest` が exit=0 -- [ ] 手動確認(起動中で窓が開きコンテナが変わらない / 停止中で起動から開く / TUI の先頭が `open`)の結果を Pull Request に書いた +- [x] 受け入れ条件をすべて満たし、条件ごとにテストか手動確認の結果が対応している(下の表。TTY で窓が開くことはリリース後の確認へ回す) +- [x] `uv run pytest` が exit=0 +- [x] 手動確認の結果を Pull Request に書いた + +## 検証結果 + +head 3af259b に対して実行した。 + +| 段階 | コマンド | 対象範囲 | 実行時刻 | 結果 | +| --- | --- | --- | --- | --- | +| 限定的な検証 | `uv run pytest -q tests/commands/test_container_open.py tests/cli/test_open_command.py tests/cli/tui/test_open_menu.py tests/utils/test_running_dev_instances.py tests/cli/test_completion.py` | 追加・変更したテスト | 2026-09-18 17:48 | 101 passed / exit=0 | +| 全体テスト | `uv run pytest -q` | 全体 | 2026-09-18 17:48 | 2682 passed / exit=0 | +| 静的解析 | `uvx ruff check --select=E9,F63,F7,F82 lib`(CI と同じ条件) | `lib` | 2026-09-18 17:50 | exit=0 | +| 構文 | `python3 -m compileall -q lib bin` / `bash -n bin/devbase` / `zsh -n etc/_devbase` | 全体 | 2026-09-18 17:50 | いずれも exit=0 | +| CI | Python syntax check (3.10 / 3.11 / 3.12)・Ruff lint・ShellCheck | 全体 | 2026-09-18 | 5 件 pass | +| 実機(非 TTY) | nyle-dx(dev-1 が起動中)で `open` / `open --open-index 3` / `open --context no-such-ctx` / `open --open 2` / `project open --open-index 2` | 起動中の経路と失敗の経路 | 2026-09-18 17:50 | exit=1(非 TTY で skip)/ 1 / 1 / 2 / 1。前後で `nyle-dx-dev-1` の ID と `StartedAt` は変わらない | + +カバレッジ: `pyproject.toml` に閾値の設定が無いため測っていない。 + +| 受け入れ条件 | 確かめたもの | +| --- | --- | +| 1 | `test_running_opens_the_editor_without_touching_containers`、実機(コンテナ不変) | +| 2 | `test_open_ignores_the_auto_open_switch` | +| 3・4 | `test_stopped_delegates_to_up_with_open` / `test_stopped_opens_even_when_auto_open_is_disabled` | +| 5 | `test_index_not_running_is_an_error`、実機(`--open-index 3`) | +| 6 | `test_index_beyond_project_yml_scale_is_accepted_when_running` | +| 7 | `test_index_below_one_is_an_error_before_docker` | +| 8 | `test_index_from_env_is_used` / `test_up_reads_the_same_env_index_as_open` | +| 9 | `test_shortcut_dispatches_to_cmd_open` / `test_project_open_dispatches_to_cmd_open` / `test_wrapper_*_open_name_cds_and_strips` | +| 10 | `test_context_reaches_the_state_query_and_the_editor` | +| 11 | `test_open_rejects_the_auto_open_flags` / `test_open_rejects_abbreviations_of_open_index`、実機(`--open 2` → exit=2) | +| 12〜14 | `tests/cli/tui/test_open_menu.py` / `test_select_action_lists_all_ops` | +| 15 | `test_up_auto_open_*` と既存の `tests/editor` / `tests/cli/test_up_roundtrips.py` | +| 16 | `test_prefix_resolution` | +| 17 | `tests/cli/test_completion.py` の `test_bash_open_*` / `test_zsh_completion_mentions_open` | +| 18 | 全体テスト | +| 19 | `test_skip_is_a_failure` / `test_print_command_is_a_success`、実機(非 TTY で exit=1) | +| 20 | `test_state_query_failure_does_not_start_up`、実機(`--context no-such-ctx`) | + +- 未検証の項目: TTY の端末から `devbase open` で窓が開くこと、`devbase list` の起動中の行で先頭が `open` であること(利用者のデスクトップに窓を出すため、リリース後の確認で行う) +- 既存の失敗: なし +- 範囲外と判断したもの: `container` / `ct` のサブコマンドが wrapper 経由で `[name]` を受け付ける既存の動き(#200) From 6ce9067bc7ecba2eec1873387674a6ba8930c0b1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 17:53:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(PLAN59):=20devbase=20open=20=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E8=BE=BC=E3=82=80=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issues/PLAN59_editor-open*.md を docs/specifications/editor-open.md の as-is 仕様へ書き直し、計画・設計の作業記録を削除する。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/specifications/editor-open.md | 181 +++++++++++++++++++++++ issues/PLAN59_editor-open-design.md | 216 ---------------------------- issues/PLAN59_editor-open-impl.md | 132 ----------------- issues/PLAN59_editor-open.md | 183 ----------------------- 4 files changed, 181 insertions(+), 531 deletions(-) create mode 100644 docs/specifications/editor-open.md delete mode 100644 issues/PLAN59_editor-open-design.md delete mode 100644 issues/PLAN59_editor-open-impl.md delete mode 100644 issues/PLAN59_editor-open.md diff --git a/docs/specifications/editor-open.md b/docs/specifications/editor-open.md new file mode 100644 index 00000000..bd504ed6 --- /dev/null +++ b/docs/specifications/editor-open.md @@ -0,0 +1,181 @@ +# エディタの窓の開き直し(`devbase open`) + +## 概要 + +閉じた VS Code の窓を、コンテナを再起動せずに開き直す。`devbase up` の最後の段(`[6/6]`)で開くのと +同じ窓(dev コンテナへ接続したフォルダ、リポジトリが 2 件以上ならワークスペース)を開く。 + +| 入口 | 形 | +| --- | --- | +| トップレベル | `devbase open [name] [--open-index N] [--context NAME]` | +| `project` グループ | `devbase project open [name] [--open-index N] [--context NAME]` | +| `container` / `ct` グループ(非推奨) | `devbase container open [--open-index N] [--context NAME]` | +| `devbase list` の TUI | 起動中のプロジェクトの操作メニューの先頭「エディタを開く (open)」 | + +dev コンテナが 1 つ以上動いていれば、コンテナ・ボリューム・ネットワークに触らずに窓を開く。1 つも +動いていなければ `devbase up --open` と同じく起動してから開く。`devbase up` の `--open` / +`--no-open` / `--open-index` と自動オープンの仕組みはそのまま残り、`open` はそれを置き換えない。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 窓 | dev コンテナへ Dev Containers 拡張で接続した VS Code のウィンドウ | +| 動いているインスタンス | `docker ps`(`-a` なし)に現れ、Compose のプロジェクトのラベルが対象のプロジェクトで、サービスのラベルが `<開発サービス名>-<1 以上の数字>` のコンテナ | +| 番号(index) | 開くインスタンスの番号(`dev-1` の `1`)。`--open-index N`、無ければ環境変数 `DEVBASE_OPEN_INDEX`、それも無ければ 1 | +| 開発サービス名 | `get_dev_service_name()` が返す名前(`DEV_SERVICE_NAME`、既定 `dev`) | + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 引数の受け口 | `lib/devbase/cli.py` | `_add_open_subparser(sub, *, with_name)`。`--open-index` と `--context` は常に、`[name]` は `project` とトップレベルだけ登録する。`allow_abbrev=False`。`SHORTCUTS` / `SUBCMD_MAP` / epilog / `_expand_argv` の候補 | +| 入口のシェル | `bin/devbase` | `resolve_command` の候補、Python 実装のコマンドの `case`、名前解決の `_PROJECT_NAME_SUBCOMMANDS` / `_NAME_RESOLVABLE_SHORTCUTS` | +| 振り分け | `lib/devbase/commands/container.py` | `_dispatch_lifecycle` の handlers の `open` | +| 本体 | `lib/devbase/commands/container.py` | `cmd_open`。番号の解決は `_explicit_open_index`(環境変数の読み方は `up` と共有する `_open_index_from_env`) | +| 開く処理 | `lib/devbase/commands/container.py` | `_open_editor_at`。開く対象(フォルダかワークスペースか)と compose file と接続先を組み、`opener.open_editor` の結果を返す。有効判定と番号の解決は持たない。`up` の `_maybe_open_editor` も同じ関数を通る | +| 起動中の判定 | `lib/devbase/utils/docker.py` | `running_dev_instances(project, dev_service_name, runner=None)`。`devbase env token` の配り先(`env.py` の `_running_dev_containers`)と共有する | +| 一覧の操作メニュー | `lib/devbase/tui/actions_project.py` | `_RUNNING_OPS` の先頭と `_OP_HANDLERS["open"]` | +| シェル補完 | `etc/devbase-completion.bash`、`etc/_devbase` | トップレベル・`project`・`container` の `open`、`[name]` と `--open-index` / `--context` | + +型(クラス)は追加していない。モジュール関数の並びで構成する。 + +```mermaid +graph TD + W[bin/devbase] --> P[cli.py] + P --> D[_dispatch_lifecycle] + T[TUI actions_project] --> D + D --> O[cmd_open] + O --> R[running_dev_instances] + O -->|起動中| E[_open_editor_at] + O -->|停止中| U[cmd_up] + U --> M[_maybe_open_editor] + M --> E + E --> V[opener.open_editor] +``` + +`open` の起動中の経路が docker daemon に対して行うのは、読み取りの `docker ps` と、`opener.open_editor` +が実コンテナ名を問い合わせる `docker compose ps` だけである。 + +## 仕様 + +### 処理の流れ + +```mermaid +graph TD + A[番号を解決
CLI → DEVBASE_OPEN_INDEX → 1] --> B{0 以下か} + B -->|はい| X1[終了コード 1] + B -->|いいえ| C[context を反映し
機密を注入] + C --> Q[docker ps で
動いている番号を得る] + Q -->|失敗| X2[終了コード 1
up へは進まない] + Q -->|0 個| U[cmd_up
open_editor=True] + U --> X3[cmd_up の戻り値] + Q -->|1 個以上| K{番号が
含まれるか} + K -->|いいえ| X4[終了コード 1
起動中の番号を示す] + K -->|はい| E[_open_editor_at] + E -->|skip| X5[終了コード 1] + E -->|launch / print_command| X6[終了コード 0] +``` + +- **起動中かどうかは「動いているインスタンスが 1 つ以上あるか」で決め、番号の検査と分ける。** 番号の + コンテナ名が解決できないことを停止中と読むと、scale 1 で動いているプロジェクトへ `--open-index 2` を + 渡しただけで `up` が環境を作り直す。`opener.resolve_container_name` は問い合わせに失敗すると決定的な + 名前へ落ちるため、判定には使わない +- **状態を取得できないときは `up` へ進まずに止まる。** `docker ps` の失敗は停止中を意味しないためである +- **停止中は `cmd_up(open_editor=True, open_index=<利用者の値>)` へ委譲し、自前で開き直さない。** 起動直後に + 開く compose file は `up` の側で決まる。番号の範囲外は `up` の既存の扱い(警告して 1)に従う +- `DEVBASE_OPEN_EDITOR` と `project.yml` の `open_editor` は見ない。明示のコマンドは開く意思表示そのもので + あり、`up` の自動オープンの可否とは別に扱う +- 番号の上限は `project.yml` の `scale` ではなく動いているインスタンスで決まる(`devbase scale` で増やした + 分も開ける) +- 接続先は `up` と同じ優先順位(`--context` > `DEVBASE_DOCKER_CONTEXT` > `project.local.yml`)で決め、 + `docker ps` の環境変数 `DOCKER_CONTEXT` と、`opener.open_editor` の `docker_context` の両方に使う +- 機密は `login` と同じく任意で注入する(`opener` の `docker compose ps` の補間のため) + +### 引数と終了コード + +| 状況 | 終了コード | 出力 | +| --- | --- | --- | +| 開いた(`launch`)・SSH で手元のコマンドを提示した(`print_command`) | 0 | `エディタを開きます: -` と `opener` の出力 | +| 停止中 | `cmd_up` の戻り値 | `dev コンテナが起動していないため up を実行します (起動後にエディタを開きます)` | +| 開けなかった(`skip`: 非 TTY・`code` が無い) | 1 | `opener` が出す理由 | +| 番号が 0 以下 | 1 | `open index N は 1 以上を指定してください`(docker を呼ばない) | +| 番号が動いていない | 1 | `- は起動していません。起動中: 1, 2` | +| `docker ps` が失敗した | 1 | `docker ps` の失敗の理由と `dev コンテナの状態を取得できないため、エディタを開けません` | +| `--open` / `--no-open`、およびその値付きの形(`--open 2` / `--open=2`) | 2 | argparse の usage エラー。サブパーサーの `allow_abbrev=False` により、`--open-index` の前方一致として受け付けない | +| `name` が解決できない | 1 | 既存の `_enter_project` の候補提示 | + +`container open` は `[name]` を取らない(`container` の他のサブコマンドと同じ)。前方一致では `devbase o` が +`open` に、`devbase project o` / `container o` も `open` に解決する。`devbase l` → `login`、`devbase project p` +→ `ps` は変わらない。 + +### `running_dev_instances` + +- `docker ps --filter label=com.docker.compose.project= --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}'` + を 1 回呼ぶ +- プロジェクトのラベルで絞り、サービス名が `^<開発サービス名>-([1-9][0-9]*)$` に合う行から `(番号, コンテナ名)` を + 集め、番号の昇順で返す。同じプロジェクトの DB などと、区切りやコンテナ名を欠く行は除く +- 呼び出しの例外・0 以外の終了コードは error ログを出して `None` を返し、0 個の `[]` と区別する +- Compose のファイルを読まないため、`.docker-compose.scale.yml` の有無と構成の補間に左右されない + +### `devbase list` の操作メニュー + +| 項目 | 内容 | +| --- | --- | +| 位置 | 起動中の行の操作メニューの先頭(Enter 1 回で決まる位置)。以下は再起動 (up)・停止 (down)・ログイン (login) … の順 | +| 委譲 | `dispatch_lifecycle("open", , open_index=None)`。番号は尋ねず、既定(`DEVBASE_OPEN_INDEX`、無ければ 1)を開く | +| 実行後 | コンテナの数が変わらないため、トップの一覧へ戻らずサブメニューに留まる(`_BACK_TO_TOP_OPS` に含めない)。成否によらず続けて別の操作を選べる | + +停止中の行は従来どおり選ぶと直接 `up` する。 + +## データ・設定 + +| 設定 | 扱い | +| --- | --- | +| `DEVBASE_OPEN_INDEX` | `--open-index` を省いたときの番号。数でなければ 1。`up` と読み方を共有する | +| `DEVBASE_OPEN_EDITOR` / `project.yml` の `open_editor` | `open` では見ない | +| `DEVBASE_EDITOR` / `DEVBASE_EDITOR_SSH_HOST` / `DEVBASE_EDITOR_DOCKER_CONTEXT` / `DEVBASE_WORKSPACE` | `opener.open_editor` が従来どおり読む | + +永続データは持たない。 + +## 運用 + +- 窓を閉じた後はコンテナを止めずに `devbase open` で開き直す +- 非 TTY(CI・パイプ)では `opener` が開くのを見送り、終了コード 1 になる +- `container` / `ct` グループのサブコマンドが `bin/devbase` の名前解決を通って `[name]` を受け付けてしまう + 既存の動きは `open` にも及ぶ(#200 で扱う) + +## テスト観点 + +自動テストは `running_dev_instances`・`opener.open_editor`・`cmd_up` を差し替え、`up` のパイプラインの部品は +呼ばれたら落ちるスタブにする。実 docker と実 `DEVBASE_ROOT` には触れない。 + +- 起動中の経路で `opener.open_editor` が 1 回だけ呼ばれ、compose up / down・ボリューム / ネットワークの作成・ + pre-up チェック・自動スナップショット・`deploy` フック・`cmd_up` が呼ばれないこと。自動オープンの無効化 + (環境変数・`project.yml`)が効かないこと。`project.yml` の `scale` を超える番号も動いていれば開けること、 + 環境変数の番号・空・数でない値・飛び番号の扱い。動いていない番号・0 以下・`docker ps` の失敗で `cmd_up` を + 呼ばずに 1 になること。`--context` が状態の照会とエディタの両方に届くこと。`skip` で 1、`print_command` で 0。 + 停止中に `cmd_up` へ `open_editor=True` と利用者の番号が渡ること。`up` の自動オープンの範囲外の扱いと有効判定が + 変わらないこと(`tests/commands/test_container_open.py`) +- `running_dev_instances` の並び・プロジェクトのラベルでの絞り込み・`-a` を付けないこと・dev 以外と不完全な + 行を除くこと・サービス名のドットを文字どおり扱い先頭ゼロを除くこと・失敗で `None` + (`tests/utils/test_running_dev_instances.py`)。`env token` の既存テストが変わらず通ること + (`tests/commands/test_env_token.py`) +- 3 入口の parse、`container open` が名前を拒むこと、`--open` / `--no-open` と値付きの前方一致の拒否、 + ショートカットと `project open` が名前を解決してから `cmd_open` へ渡すこと、前方一致、`bin/devbase` が名前を + 取り除いて移動すること(`tests/cli/test_open_command.py`) +- TUI の先頭・委譲の属性・サブメニューに留まること・成否によらず次の操作を選べること + (`tests/cli/tui/test_open_menu.py`、`tests/cli/tui/test_actions_project.py`) +- bash / zsh の補完(`tests/cli/test_completion.py`) + +実機(起動中のプロジェクト、非 TTY)では、`open` / `open --open-index <動いていない番号>` / +`open --context <存在しない context>` / `open --open 2` / `project open --open-index 2` の終了コードが上の表の +とおりで、前後で dev コンテナの ID と `StartedAt` が変わらないことを確かめる。TTY の端末から窓が開くこと、 +`devbase list` の先頭が `open` であることは配布後の確認の対象である。 + +## 関連リンク + +- [CLI リファレンス: `devbase project open`](../user/cli-reference/02-project.md#devbase-project-open) +- [環境変数ガイド: エディタ自動オープン](../user/environment-variables.md) +- [別ホストの Docker への dev コンテナ起動(docker context)](remote-docker-context.md) +- 課題: devbasex/devbase#197。設計 PR: #198、実装 PR: #199 diff --git a/issues/PLAN59_editor-open-design.md b/issues/PLAN59_editor-open-design.md deleted file mode 100644 index 907eb250..00000000 --- a/issues/PLAN59_editor-open-design.md +++ /dev/null @@ -1,216 +0,0 @@ -# PLAN59: `devbase open` の設計 - -要求と受け入れ条件は [PLAN59_editor-open.md](PLAN59_editor-open.md) にある。この文書は「どう作るか」だけを扱う。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 起動中のプロジェクトで、コンテナに触らず dev コンテナへ接続した窓を開く | devbase の利用者(CLI) | -| F2 | 停止中のプロジェクトで、起動してから窓を開く(`up --open` へ委譲) | devbase の利用者(CLI) | -| F3 | 別ディレクトリからプロジェクト名を指定して F1 / F2 を行う | devbase の利用者(CLI) | -| F4 | `devbase list` の起動中のサブメニューの先頭から F1 を選ぶ | devbase の利用者(TUI) | - -## 構成要素 - -| 要素 | 変更 | 責務 | -| --- | --- | --- | -| `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` と同じ形) | -| `cli.py` の `SHORTCUTS` / `SUBCMD_MAP` / parser の epilog | 変える | `open` をトップレベルのショートカットと、`project` / `container` のサブコマンドへ加える | -| `bin/devbase` | 変える | `resolve_command` の候補、Python 実装のコマンドの `case`、`_PROJECT_NAME_SUBCOMMANDS`、`_NAME_RESOLVABLE_SHORTCUTS` に `open` を加える | -| `container.py` の `_dispatch_lifecycle` | 変える | `handlers` に `'open'` を足す | -| `container.py` の `cmd_open`(新設) | 足す | 起動中の判定・index の検査・開く処理の呼び出し、または `cmd_up` への委譲 | -| `utils/docker.py` の `running_dev_instances`(`env.py` の `_running_dev_containers` から移す) | 変える | `docker ps` を 1 回呼び、動いている dev インスタンスの `(index, コンテナ名)` を index 順に返す。呼べなければ `None` | -| `env.py` の `_running_dev_containers` | 変える | `running_dev_instances` の結果からコンテナ名だけを返す薄い包みにする。`env token` から見た振る舞いは変えない | -| `container.py` の `_open_editor_at`(`_maybe_open_editor` から切り出し) | 変える | 開く対象(フォルダ / ワークスペース)と接続先を組み、`opener.open_editor` を呼んで action を返す。有効判定と index の解決は持たない | -| `container.py` の `_maybe_open_editor` | 変える | 有効判定と index の解決だけを残し、開く処理は `_open_editor_at` へ渡す。`up` から見た振る舞いは変えない | -| `tui/actions_project.py` の `_RUNNING_OPS` / `_OP_HANDLERS` | 変える | 先頭に `("エディタを開く (open)", "open")`、ハンドラに `dispatch_lifecycle("open", name, open_index=None)`。先頭の理由のコメントを書き替える | -| `etc/devbase-completion.bash` / `etc/_devbase` | 変える | トップレベル・`project`・`container` の候補に `open` を加え、`[name]` の補完を `up` と同じにする | -| `docs/user/cli-reference/02-project.md` / `CHANGELOG.md` | 変える | 利用者向けの説明と変更履歴 | - -`opener.open_editor` / `opener.decide_action` と `cmd_up` は変えない。 - -下の図は呼び出しの関係だけを描く。呼び出しを持たない補完(`etc/`)と文書(`docs/`・`CHANGELOG.md`)は図に含めない。 - -```mermaid -graph TD - W[bin/devbase] --> P[cli.py] - P --> D[_dispatch_lifecycle] - T[TUI actions_project] --> D - D --> O[cmd_open] - O --> R[running_dev_instances] - O -->|起動中| E[_open_editor_at] - O -->|停止中| U[cmd_up] - U --> M[_maybe_open_editor] - M --> E - E --> V[opener.open_editor] -``` - -## 配置 - -### システムの文脈 - -devbase はホストで動き、2 つの外部に触る。docker daemon(`--context` の先を含む)と、ホストの VS Code(`code` CLI)である。`open` の起動中の経路が docker daemon に対して行うのは、読み取りの `docker ps` と、`opener.open_editor` が既存で行う `docker compose ps` だけである。コンテナ・ボリューム・ネットワークは作らない。 - -```mermaid -graph LR - U[利用者の端末] --> C[devbase CLI / TUI(ホスト)] - C -->|docker ps(読み取り)| D[docker daemon] - C -->|code --folder-uri| V[VS Code] - V -->|Dev Containers で接続| D -``` - -### モジュールの置き場所 - -```text -bin/devbase # 入口のシェル。name 解決とコマンドの振り分け -etc/ -├── devbase-completion.bash # bash 補完 -└── _devbase # zsh 補完 -lib/devbase/ -├── cli.py # parser とショートカット -├── commands/container.py # cmd_open / _open_editor_at -├── commands/env.py # _running_dev_containers(包みにする) -├── editor/opener.py # 変えない -├── tui/actions_project.py # 起動中のサブメニュー -└── utils/docker.py # running_dev_instances(移す先) -``` - -## 入出力の契約 - -### コマンド `open` - -| 項目 | 内容 | -| --- | --- | -| 名前 | `devbase open [name]` / `devbase project open [name]` / `devbase container open`。前方一致の `devbase o` も `open` に解決する | -| 入力 | `name`(任意。`project` とトップレベルだけ)、`--open-index N`(任意の整数)、`--context NAME`(任意。空は usage エラー) | -| 出力(成功) | 起動中: `opener.open_editor` が `launch`(エディタを起動)または `print_command`(SSH で手元のコマンドを提示)を返し、終了コード 0。停止中: `cmd_up` の戻り値をそのまま返す | -| 失敗の形 | 下の表 | -| 互換性 | 追加のみ。既存のコマンドと前方一致の解決(`l` → `login`、`project p` → `ps`)は変わらない | - -| 状況 | 終了コード | 出力 | -| --- | --- | --- | -| `--open` / `--no-open` を渡した | 2 | argparse の usage エラー | -| `--open-index` が 0 以下 | 1 | `open index N は 1 以上を指定してください` | -| 起動中で、index が動いているインスタンスに無い | 1 | `dev-N は起動していません。起動中: 1, 2` | -| 起動中の判定の `docker ps` が 0 以外で終わった・呼べなかった | 1 | `docker ps` の失敗の理由(`running_dev_instances` が error で出す)。`up` へは委譲しない | -| 起動中で、`opener.open_editor` が `skip` を返した(非 TTY・`code` が無い) | 1 | `opener` が出す理由(info)。開けなかったことを終了コードで示す | -| `name` が解決できない | 1 | 既存の `_enter_project` の候補提示 | - -### `running_dev_instances(project, dev_service_name, runner=None) -> Optional[list[tuple[int, str]]]` - -`env.py` の `_running_dev_containers` の中身を `utils/docker.py` へ移したもの。判定の方法は変えない。 - -- `docker ps --filter label=com.docker.compose.project= --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}'` を 1 回呼ぶ。`-a` を付けないため、止まっているコンテナは出ない -- サービスのラベルが `{dev}-{1 以上の数字}` の行から `(数字, コンテナ名)` を集め、数字の昇順で返す。同じプロジェクトの DB などは除かれる -- 呼び出しの失敗(例外・0 以外の終了コード)は error ログを出して `None` を返す。動いているものが無い `[]` と区別する -- Compose のファイルを読まないため、`.docker-compose.scale.yml` の有無と構成の補間に左右されない。接続先は環境変数 `DOCKER_CONTEXT` に従う -- `runner` は差し替え口(既定は `subprocess.run`)。`env.py` の呼び出しは既存どおり渡す - -### TUI - -| 項目 | 変更後 | -| --- | --- | -| `_RUNNING_OPS` の先頭 | `("エディタを開く (open)", "open")`。以下は既存の `up` / `down` / `login` … の順 | -| `_OP_HANDLERS["open"]` | `lambda root, name: dispatch_lifecycle("open", name, open_index=None)` | -| `_BACK_TO_TOP_OPS` | 変えない(`open` を含めない) | - -## 処理の流れ - -分岐と合流が主題のため、呼び出しの相手ではなく判定の順に描く。 - -```mermaid -graph TD - A[index を解決
CLI → DEVBASE_OPEN_INDEX → 1] --> B{0 以下か} - B -->|はい| X1[終了コード 1] - B -->|いいえ| C[context を反映し
機密を注入] - C --> Q[docker ps で
動いている index を得る] - Q -->|失敗| X2[終了コード 1
up へは進まない] - Q -->|0 個| U[cmd_up
open_editor=True] - U --> X3[cmd_up の戻り値] - Q -->|1 個以上| K{index が
含まれるか} - K -->|いいえ| X4[終了コード 1
起動中の index を示す] - K -->|はい| E[_open_editor_at] - E -->|skip| X5[終了コード 1] - E -->|launch / print_command| X6[終了コード 0] -``` - -停止中から `cmd_up` へ委譲するときは、`open_index` に**利用者が渡した値**(未指定なら `None`)を渡す。`cmd_up` は既存どおり `DEVBASE_OPEN_INDEX` を読み、範囲外は警告して 1 へ落とす(仕様の前提 3)。 - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 起動中の経路で 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 回だけであることを見る | -| 運用・保守性 | 停止中から `up` へ委譲するときは、その旨を info ログに 1 行出す | `cmd_up` を呼ぶ直前に `dev コンテナが起動していないため up を実行します` を info で出す | 単体テストで caplog を見る | - -## 決定の記録 - -### 決定 1: 起動中の判定は「dev インスタンスが 1 つ以上動いているか」で行い、index の検査と分ける - -issue の案は「開く index のコンテナ名が解決できない → 停止中」だった。この判定では、scale 1 で動いているプロジェクトに `--open-index 2` を渡すと停止中と見なし、`up` で環境を作り直してしまう。窓を開くだけのつもりの操作が、起動済みのコンテナを止めて作り直す操作に化ける。0 個と「その index だけ無い」を分け、前者だけを `up` へ委譲する。 - -`opener.resolve_container_name` は使わない。問い合わせに失敗すると決定的な名前へ落ちる設計で、起動していなくても名前を返すため判定に使えない。 - -### 決定 2: 状態を取得できないときは `up` へ委譲せずに止まる - -起動中の判定の `docker ps` が失敗した(daemon に届かない)ことは、停止中を意味しない。ここで `up` へ進むと、動いている環境を作り直す可能性がある。`up` の側で同じ原因により失敗するとしても、利用者が「なぜ起動が走ったか」を読み違えないよう、`open` の入口で止める。 - -### 決定 3: 開く処理は `_maybe_open_editor` から切り出して共有し、有効判定を持たせない - -開く対象の組み立てだけを `_open_editor_at` に切り出す。組み立てるのは、フォルダかワークスペースか・compose file・接続先の 3 つである。有効判定と index の解決は、呼び出し側がそれぞれ持つ。 - -`open` は明示の操作なので、`DEVBASE_OPEN_EDITOR` と `project.yml` の `open_editor` を見ない(仕様の受け入れ条件 2)。`_maybe_open_editor` へ `open_flag=True` を渡す形は採らない。その経路は範囲外の index を 1 へ落とす(`up` のための既存の振る舞い)。そのため受け入れ条件 5 と両立しない。 - -### 決定 4: 停止中は `cmd_up(open_editor=True)` へ委譲し、自前で開き直さない - -`up` の `[6/6]` がすでに開く処理を持っている。起動の直後に開くための compose file(`_run_deploy_pipeline` が返したもの)も、そこで決まる。`cmd_open` が `up` の後にもう一度開くと、窓が 2 つ出るか、`up` 側の有効判定との二重管理になる。`open_editor=True` を渡すことで、`DEVBASE_OPEN_EDITOR=0` の端末でも窓が開く(受け入れ条件 4)。 - -### 決定 5: `opener.open_editor` が `skip` を返したら終了コード 1 にする - -`up` では窓を開けなくても成功とする(起動が主目的のため)。`open` は窓を開くことだけが目的なので、非 TTY や `code` が無いことで何もしなかった場合は失敗として返す。`print_command`(SSH でコマンドを提示)は利用者が次にすべきことを出しているため成功とする。 - -### 決定 6: TUI の `open` は index を尋ねない - -issue の指定(`open_index=None`)どおり、既定(`DEVBASE_OPEN_INDEX`、無ければ 1)を開く。scale が 2 以上のプロジェクトで別の index を開きたい場合は CLI の `--open-index` を使う。index を尋ねる入力を足すと、Enter 1 回で窓を出すという先頭に置く理由が失われる。 - -### 決定 7: `container open` も足す - -`container` は非推奨だが、`profile`(PLAN58)を含めて `project` と同じサブコマンドの集合を保っている。片方だけにすると、補完と `SUBCMD_MAP` の対応表に例外が 1 つ増える。`container open` は `[name]` を取らない(`container` の他のサブコマンドと同じ)。 - -### 決定 8: 起動中の判定は `env token` の列挙を共有の場所へ移して使う - -`env.py` の `_running_dev_containers` が、動いている dev インスタンスの列挙をすでに持っている。失敗を `None` で返し、0 個の `[]` と区別する契約も、決定 2 が求める形と一致する。`utils/docker.py` へ移して `cmd_open` と `env token` の両方から使う。 - -`docker compose ps` で数える形は採らない。Compose のファイル(`.docker-compose.scale.yml`)が無いと呼べず、構成の補間の失敗も「状態を取得できない」に混ざるためである。`docker ps` とラベルで数える既存の方法は、どちらにも左右されない。 - -## テスト設計 - -| 受け入れ条件 | 何で確かめるか | -| --- | --- | -| 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 回 | -| 2 | 単体: `DEVBASE_OPEN_EDITOR=0` と `open_editor: false` のそれぞれで `opener.open_editor` が呼ばれる | -| 3 | 単体: 動いているインスタンスが 0 個で `cmd_up` が `open_editor=True` で 1 回呼ばれ、`opener.open_editor` は呼ばれない。戻り値が `cmd_up` のもの | -| 4 | 単体: 3 と同じ状況で `DEVBASE_OPEN_EDITOR=0` でも `cmd_up` に `open_editor=True` が渡る | -| 5 | 単体: 起動中 `[1]` で index 2 → 1 を返し、`cmd_up` / `opener.open_editor` が呼ばれない。メッセージに `1` を含む | -| 6 | 単体: `project.yml` の scale 1、起動中 `[1, 2]` で index 2 → `opener.open_editor(index=2)` | -| 7 | 単体: index 0 と -1 で 1 を返し、docker を呼ばない | -| 8 | 単体: `DEVBASE_OPEN_INDEX=2`・起動中 `[1, 2]` で `index=2` | -| 9 | CLI(`tests/cli/`): `project open ` とトップレベル `open ` の parse 結果が `name` を持ち、`_dispatch_lifecycle` が `_enter_project` を呼ぶ。`bin/devbase` の 2 つのリストに `open` がある | -| 10 | 単体: `--context X` で `running_dev_instances` を呼ぶ時点の環境変数 `DOCKER_CONTEXT` が `X`、`opener.open_editor` の `docker_context` が `X` | -| 11 | CLI: `open --open` / `open --no-open` が `SystemExit(2)` | -| 12 | TUI(`tests/cli/tui/`): `_RUNNING_OPS[0][1] == "open"` | -| 13 | TUI: `_OP_HANDLERS["open"]` が `dispatch_lifecycle("open", name, open_index=None)` を呼ぶ | -| 14 | TUI: `"open" not in _BACK_TO_TOP_OPS` | -| 15 | 既存の `tests/editor/test_opener.py` と `up` の自動オープンのテストが変更なしで通る | -| 16 | CLI: 既存の前方一致のテストに `o` → `open` を足し、`l` → `login` / `project p` → `ps` が変わらない | -| 17 | 補完(`tests/cli/test_completion.py`): bash / zsh の候補に `open` がある | -| 18 | `uv run pytest` | -| 19 | 単体: `opener.open_editor` が `skip` を返すと 1、`print_command` で 0(決定 5) | -| 20 | 単体: `running_dev_instances` が `None` を返すと 1 を返し、`cmd_up` を呼ばない(決定 2)。`running_dev_instances` 自体は、`{dev}-{数字}` 以外のサービスを除くこと・index 順に並べること・失敗で `None` を返すことを見る。`env token` の既存テスト(`tests/commands/test_env_token.py`)が変更なしで通る | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 実機での窓の再表示 | VS Code が同じコンテナ・同じフォルダの窓をすでに開いているとき、`code --folder-uri` が既存の窓を前面に出すか新しい窓を開くかは VS Code 側の挙動で、devbase では決めない。リリース後テストで macOS のローカル端末で確かめる | diff --git a/issues/PLAN59_editor-open-impl.md b/issues/PLAN59_editor-open-impl.md deleted file mode 100644 index d975d93a..00000000 --- a/issues/PLAN59_editor-open-impl.md +++ /dev/null @@ -1,132 +0,0 @@ -# PLAN59: `devbase open` の実装計画 - -## 関連リンク - -- issue: devbasex/devbase#197 -- 要求仕様と受け入れ条件: [PLAN59_editor-open.md](PLAN59_editor-open.md) -- 設計: [PLAN59_editor-open-design.md](PLAN59_editor-open-design.md)(設計 Pull Request #198 でマージ済み) - -## モード - -`standard`(公開のコマンドと TUI のメニュー項目を足し、CLI・lifecycle・TUI・入口のシェル・補完にまたがる) - -## 受け入れ条件 - -仕様の 1〜20 をそのまま使う。番号は仕様の番号を指す。 - -## 修正対象 - -- `lib/devbase/utils/docker.py` -- `lib/devbase/commands/env.py` -- `lib/devbase/commands/container.py` -- `lib/devbase/cli.py` -- `bin/devbase` -- `lib/devbase/tui/actions_project.py` -- `etc/devbase-completion.bash` / `etc/_devbase` -- `docs/user/cli-reference/02-project.md` / `CHANGELOG.md` -- テスト: `tests/utils/test_running_dev_instances.py`(新規)/ `tests/commands/test_container_open.py`(新規)/ `tests/cli/test_open_command.py`(新規)/ `tests/cli/tui/test_open_menu.py`(新規)/ `tests/cli/test_completion.py` - -## タスク分解 - -### Task 1: 動いている dev インスタンスの列挙を共有の場所へ移す - -- **対象ファイル:** `lib/devbase/utils/docker.py`、`lib/devbase/commands/env.py`、`tests/utils/test_running_dev_instances.py` -- **変更内容:** `env.py` の `_running_dev_containers` の中身を `running_dev_instances(project, dev_service_name, runner=None) -> Optional[list[tuple[int, str]]]` として `utils/docker.py` へ移す。`_running_dev_containers` はコンテナ名だけを返す包みにする(設計の決定 8) -- **満たす受け入れ条件:** 20 の前半(失敗で `None`)。`env token` の既存テストが変更なしで通ること -- **進め方:** 失敗するテスト(`{dev}-{数字}` 以外を除く・index 順・失敗で `None`)→ 移設 → `tests/commands/test_env_token.py` を通す - -### Task 2: `cmd_open` 本体 - -- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/commands/test_container_open.py` -- **変更内容:** `_maybe_open_editor` から `_open_editor_at` を切り出す(決定 3)。`cmd_open(project_name=None, open_index=None, context=None)` を足し、`_dispatch_lifecycle` の `handlers` に `'open'` を加える。処理は設計の「処理の流れ」の順 -- **満たす受け入れ条件:** 1〜8、10、19、20 -- **進め方:** 条件ごとに失敗するテストを書き、`running_dev_instances`・`opener.open_editor`・`cmd_up` を差し替えて分岐を確かめる → 実装 → `up` の自動オープンの既存テストが通ることを確かめる - -### Task 3: CLI の登録と入口のシェル - -- **対象ファイル:** `lib/devbase/cli.py`、`bin/devbase`、`tests/cli/test_open_command.py` -- **変更内容:** `_add_open_subparser(sub, *, with_name)` を足し、`project`(name あり)・`container`(name なし)・トップレベル(name あり)へ登録する。`SHORTCUTS` / `SUBCMD_MAP` / epilog に `open` を足す。`bin/devbase` の `resolve_command` の候補・Python 実装のコマンドの `case`・`_PROJECT_NAME_SUBCOMMANDS`・`_NAME_RESOLVABLE_SHORTCUTS` に `open` を足す -- **満たす受け入れ条件:** 9、11、16 -- **進め方:** parse の結果・`--open` の拒否・前方一致・wrapper のリストを見る失敗するテスト → 登録 → 既存の `tests/cli/` を通す - -### Task 4: TUI の起動中メニュー - -- **対象ファイル:** `lib/devbase/tui/actions_project.py`、`tests/cli/tui/test_open_menu.py` -- **変更内容:** `_RUNNING_OPS` の先頭に `("エディタを開く (open)", "open")`、`_OP_HANDLERS["open"]` を足す。先頭の理由のコメントを書き替える -- **満たす受け入れ条件:** 12〜14 -- **進め方:** 並び・ハンドラ・`_BACK_TO_TOP_OPS` を見る失敗するテスト → 変更 → 既存の TUI テスト(先頭が `up` を前提にしたものがあれば、先頭に `open` が入った並びへ直す) - -### Task 5: 補完と文書 - -- **対象ファイル:** `etc/devbase-completion.bash`、`etc/_devbase`、`tests/cli/test_completion.py`、`docs/user/cli-reference/02-project.md`、`CHANGELOG.md` -- **変更内容:** bash / zsh の候補に `open` を足し、`[name]` の補完を `up` と同じにする。CLI リファレンスに `open` の節を足し、`devbase list` の起動中メニューの先頭が変わったことを書く。CHANGELOG の Unreleased に追記する -- **満たす受け入れ条件:** 17 -- **進め方:** 補完の候補を見る失敗するテスト → 補完の変更。文書はテスト駆動の対象外(振る舞いを持たないため) - -### Task 6: 全体の確認 - -- **満たす受け入れ条件:** 15、18 -- **進め方:** `uv run pytest` を通す。起動中・停止中のプロジェクトで `devbase open` を手で動かし、仕様の「手動確認」を行う - -## 影響範囲 - -- `devbase env token`: 列挙の関数が包みになる(振る舞いは変えない) -- `devbase up` の `[6/6]`: 開く処理が `_open_editor_at` を通る(振る舞いは変えない) -- `devbase list` の起動中サブメニュー: 先頭のハイライトが `open` になる - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| `container.py`(2200 行超)への追加 | 触る範囲は `_maybe_open_editor` の切り出しと関数 2 つの追加に限られ、`up` の自動オープンに既存テストがある。タスクごとにテストを通す | -| `cli.py` と `bin/devbase` と補完の同期漏れ | Task 3・5 のテストで 3 か所に `open` があることを見る | -| TUI の既存テストが先頭を `up` と決め打ちしている | Task 4 で洗い出して直す | - -## 切り戻し手順 - -- 追加のみでデータ移行を持たないため、この Pull Request を revert すれば元へ戻る - -## 完了の定義 - -- [x] 受け入れ条件をすべて満たし、条件ごとにテストか手動確認の結果が対応している(下の表。TTY で窓が開くことはリリース後の確認へ回す) -- [x] `uv run pytest` が exit=0 -- [x] 手動確認の結果を Pull Request に書いた - -## 検証結果 - -head 3af259b に対して実行した。 - -| 段階 | コマンド | 対象範囲 | 実行時刻 | 結果 | -| --- | --- | --- | --- | --- | -| 限定的な検証 | `uv run pytest -q tests/commands/test_container_open.py tests/cli/test_open_command.py tests/cli/tui/test_open_menu.py tests/utils/test_running_dev_instances.py tests/cli/test_completion.py` | 追加・変更したテスト | 2026-09-18 17:48 | 101 passed / exit=0 | -| 全体テスト | `uv run pytest -q` | 全体 | 2026-09-18 17:48 | 2682 passed / exit=0 | -| 静的解析 | `uvx ruff check --select=E9,F63,F7,F82 lib`(CI と同じ条件) | `lib` | 2026-09-18 17:50 | exit=0 | -| 構文 | `python3 -m compileall -q lib bin` / `bash -n bin/devbase` / `zsh -n etc/_devbase` | 全体 | 2026-09-18 17:50 | いずれも exit=0 | -| CI | Python syntax check (3.10 / 3.11 / 3.12)・Ruff lint・ShellCheck | 全体 | 2026-09-18 | 5 件 pass | -| 実機(非 TTY) | nyle-dx(dev-1 が起動中)で `open` / `open --open-index 3` / `open --context no-such-ctx` / `open --open 2` / `project open --open-index 2` | 起動中の経路と失敗の経路 | 2026-09-18 17:50 | exit=1(非 TTY で skip)/ 1 / 1 / 2 / 1。前後で `nyle-dx-dev-1` の ID と `StartedAt` は変わらない | - -カバレッジ: `pyproject.toml` に閾値の設定が無いため測っていない。 - -| 受け入れ条件 | 確かめたもの | -| --- | --- | -| 1 | `test_running_opens_the_editor_without_touching_containers`、実機(コンテナ不変) | -| 2 | `test_open_ignores_the_auto_open_switch` | -| 3・4 | `test_stopped_delegates_to_up_with_open` / `test_stopped_opens_even_when_auto_open_is_disabled` | -| 5 | `test_index_not_running_is_an_error`、実機(`--open-index 3`) | -| 6 | `test_index_beyond_project_yml_scale_is_accepted_when_running` | -| 7 | `test_index_below_one_is_an_error_before_docker` | -| 8 | `test_index_from_env_is_used` / `test_up_reads_the_same_env_index_as_open` | -| 9 | `test_shortcut_dispatches_to_cmd_open` / `test_project_open_dispatches_to_cmd_open` / `test_wrapper_*_open_name_cds_and_strips` | -| 10 | `test_context_reaches_the_state_query_and_the_editor` | -| 11 | `test_open_rejects_the_auto_open_flags` / `test_open_rejects_abbreviations_of_open_index`、実機(`--open 2` → exit=2) | -| 12〜14 | `tests/cli/tui/test_open_menu.py` / `test_select_action_lists_all_ops` | -| 15 | `test_up_auto_open_*` と既存の `tests/editor` / `tests/cli/test_up_roundtrips.py` | -| 16 | `test_prefix_resolution` | -| 17 | `tests/cli/test_completion.py` の `test_bash_open_*` / `test_zsh_completion_mentions_open` | -| 18 | 全体テスト | -| 19 | `test_skip_is_a_failure` / `test_print_command_is_a_success`、実機(非 TTY で exit=1) | -| 20 | `test_state_query_failure_does_not_start_up`、実機(`--context no-such-ctx`) | - -- 未検証の項目: TTY の端末から `devbase open` で窓が開くこと、`devbase list` の起動中の行で先頭が `open` であること(利用者のデスクトップに窓を出すため、リリース後の確認で行う) -- 既存の失敗: なし -- 範囲外と判断したもの: `container` / `ct` のサブコマンドが wrapper 経由で `[name]` を受け付ける既存の動き(#200) diff --git a/issues/PLAN59_editor-open.md b/issues/PLAN59_editor-open.md deleted file mode 100644 index 01f1dced..00000000 --- a/issues/PLAN59_editor-open.md +++ /dev/null @@ -1,183 +0,0 @@ -# PLAN59: 再起動なしで VS Code だけ開き直す `devbase open` - -対象 issue: devbasex/devbase#197 - -- ワークフローモード: `standard` - - 根拠: 公開インタフェース(CLI のコマンド `open` と `devbase list` の TUI のメニュー項目)を足す。 - `cli.py`・`commands/container.py`・`tui/actions_project.py`・`bin/devbase`・補完にまたがる - -## 目的 - -- `devbase up` で開いた VS Code の窓を手で閉じたあと、コンテナを再作成・再起動せずに同じ窓を出し直せるようにする -- `devbase list` の TUI で起動中のプロジェクトを選んだとき、Enter 1 回目で窓を出せるようにする - -## 前提 - -- 前提 1: コマンド名は `open` とする(issue の第 1 案。`editor` / `code` は採らない)。トップレベル `devbase open`、 - `devbase project open [name]`、`devbase container open` の 3 つの入口を持つ。トップレベルの `o` 始まりの - 既存コマンドは無いため、前方一致の短縮 `devbase o` も一意に `open` へ解決する -- 前提 2: 「起動している」の判定は、**dev サービスのインスタンスが 1 つ以上動いていること**で行う。 - 判定は 2 段に分ける。 - - | 動いているインスタンス | 扱い | - | --- | --- | - | 0 個 | 停止中として `up` へ委譲する | - | 1 個以上 | index が動いているものに含まれるかを検査する | - - issue の案(「コンテナ名が解決できないこと」)のままでは誤る場面がある。scale 1 で動いているプロジェクトへ - `--open-index 2` を渡すと、「停止中」と判定して `up` を走らせてしまう。2 段に分けるのは、issue の - 「index の上限は動いているコンテナの数から決める」「解決できない index はエラーにする」を両立させる解釈である -- 前提 3: 停止中のときは `devbase up --open [--open-index N]` と同じ動きにする。`up` 側の既存の index の扱い - (範囲外は警告して 1 へ落とす)は変えない -- 前提 4: エディタを開く処理(`opener.open_editor`)の既存の判断(非 TTY はスキップ、SSH ではコマンドを - 提示、`code` が無ければスキップ)は変えない。`open` が変えるのは「開くかどうか」の判定 - (`DEVBASE_OPEN_EDITOR` / `project.yml` の `open_editor` を見ない)だけである -- 前提 5: 起動中の判定・開く処理は、`up` と同じく `--context` と `project.local.yml` の docker context に従う - -## 対象範囲 - -含む: - -- コマンド `open`(トップレベル・`project`・`container`)と、その `--open-index N` / `--context NAME` -- `project open [name]` とトップレベル `open [name]` のプロジェクト名の解決(`bin/devbase` の name 解決と - Python 側の chdir) -- `devbase list` の TUI の起動中メニューの先頭に「エディタを開く (open)」を置くこと -- シェル補完(bash / zsh)と利用者向けの CLI リファレンス、CHANGELOG - -含まない: - -- `devbase up` の `--open` / `--no-open` / `--open-index` と `DEVBASE_OPEN_EDITOR` の挙動の変更 -- `opener.open_editor` の起動方針(launch / print_command / skip)の変更 -- 停止中の行(TUI で選ぶと直接 `up` する行)のメニュー追加。停止中の行はこれまでどおり直接 `up` する -- 複数インスタンスをまとめて開くこと(1 回に開くのは 1 つ) -- 新しい型・永続データ・画面の追加(そのためクラス図・ER 図・画面遷移図を作らない) - -## 受け入れ条件 - -コマンド: - -- [ ] 1. 前提: プロジェクトの dev が動いている - 操作: `devbase open` を実行する - 結果: `opener.open_editor` が 1 回だけ呼ばれる。compose up・compose down・ボリューム/ネットワークの作成・ - compose の再生成・`deploy` フック・pre-up チェック・自動スナップショットは、どれも呼ばれない。終了コード 0 -- [ ] 2. 前提: dev が動いており、`DEVBASE_OPEN_EDITOR=0`(または `project.yml` の `open_editor: false`) - 操作: `devbase open` を実行する - 結果: `opener.open_editor` が呼ばれる(自動オープンの無効化は `open` に効かない) -- [ ] 3. 前提: dev が動いていない(動いているインスタンスが 0 個) - 操作: `devbase open` を実行する - 結果: `cmd_up` が `open_editor=True` で 1 回呼ばれ、`open` 自身は `opener.open_editor` を呼ばない。 - 終了コードは `cmd_up` の戻り値 -- [ ] 4. 前提: dev が動いていない、`DEVBASE_OPEN_EDITOR=0` - 操作: `devbase open` を実行する - 結果: 起動後に窓が開く(`cmd_up` へ `open_editor=True` が渡る) -- [ ] 5. 前提: dev-1 だけが動いている - 操作: `devbase open --open-index 2` を実行する - 結果: `cmd_up` も `opener.open_editor` も呼ばれず、動いている index(`1`)を示すエラーを出して終了コード 1 -- [ ] 6. 前提: dev-1 と dev-2 が動いている(`project.yml` の `scale` は 1。`devbase scale` でオンライン変更した状態) - 操作: `devbase open --open-index 2` を実行する - 結果: `opener.open_editor` が `index=2` で呼ばれる(上限は `project.yml` ではなく動いている数から決まる) -- [ ] 7. `--open-index` に 0 以下を渡すと、`cmd_up` も `opener.open_editor` も呼ばれず終了コード 1 -- [ ] 8. `--open-index` を省き env `DEVBASE_OPEN_INDEX=2` があるとき、起動中の経路は `index=2` を使う -- [ ] 9. `devbase project open ` を別ディレクトリから実行すると、`` のプロジェクトを対象にする。 - 解決は `devbase project up ` と同じ。トップレベル `devbase open ` も同じ -- [ ] 10. `devbase open --context NAME` は、2 か所の両方に `NAME` を使う。起動中の判定の `docker ps` と、 - `opener.open_editor` の `docker_context` である -- [ ] 11. `open` は `--open` / `--no-open` を受け付けない(argparse の usage エラー、終了コード 2) -- [ ] 19. 前提: dev が動いており、端末が非 TTY(または `code` が無い) - 操作: `devbase open` を実行する - 結果: `opener.open_editor` が `skip` を返し、終了コード 1。SSH セッションでコマンドを提示した場合(`print_command`)は 0 -- [ ] 20. 前提: 起動中の判定の `docker ps` が 0 以外で終わる(daemon に届かないなど) - 操作: `devbase open` を実行する - 結果: `cmd_up` を呼ばず、状態を取得できない旨を出して終了コード 1 - -TUI: - -- [ ] 12. 起動中の行のサブメニューの先頭の項目が `open` である -- [ ] 13. 起動中の行のサブメニューで `open` を選ぶと、`dispatch_lifecycle("open", , ...)` が呼ばれる -- [ ] 14. `open` の実行後はトップ一覧へ戻らず、同じサブメニューに留まる(`_BACK_TO_TOP_OPS` に含まれない) - -退行しないこと: - -- [ ] 15. `devbase up` の自動オープン(`--open` / `--no-open` / `--open-index` / `DEVBASE_OPEN_EDITOR`)の既存テストが - 変更なしで通る -- [ ] 16. `devbase l` は引き続き `login` に、`devbase project p` は引き続き `ps` に解決する -- [ ] 17. bash / zsh の補完でトップレベル・`project`・`container` の候補に `open` が出る -- [ ] 18. 全体テスト(`uv run pytest`)が通る - -(19・20 は設計で決めた失敗の形を条件へ戻したもの。番号は追記順) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 起動中の経路で docker を呼ぶのは、起動中の判定の `docker ps` 1 回と、`opener.open_editor` 内の既存の呼び出しだけ(`up` のパイプラインを通らない) | -| 運用・保守性 | 停止中から `up` へ委譲するときは、その旨を info ログに 1 行出す(利用者が「なぜ起動が走ったか」を読める) | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 足す: `devbase open [name]` / `project open [name]` / `container open`(`--open-index N` / `--context NAME`)。既存は変えない | -| データ | 変わらない | -| 既存の振る舞い | `devbase list` の起動中サブメニューの既定のハイライトが「再起動 (up)」から「エディタを開く (open)」へ変わる。Enter 連打で再起動していた利用者は 1 つ下を選ぶことになる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run pytest` | -| 静的解析 | `uv run ruff check lib tests`(導入されていれば) | -| 手動確認 | 起動中のプロジェクトで窓を閉じ、`devbase open` で同じ窓が開き、`docker ps` の dev の `CreatedAt` / `Status` が変わらないことを見る。停止中のプロジェクトで `devbase open` が起動から窓を開くことを見る。`devbase list` で起動中の行を選び、先頭が `open` であることを見る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | CLI の登録は `lib/devbase/cli.py`、処理は `lib/devbase/commands/container.py`(lifecycle のサブコマンドの置き場)、TUI は `lib/devbase/tui/actions_project.py`。`bin/devbase` の name 解決の 2 つのリストと `cli.py` の対の注記に従う | -| コーディング規約 | 既存のモジュール関数の並びに足す。コメント・ログは日本語、PLAN 番号を引く既存の書き方に合わせる | -| テスト戦略 | 単体: `cmd_open` の分岐(起動中 / 停止中 / index 範囲外)を docker と `cmd_up` を差し替えて見る。CLI: parser と name 解決と補完。TUI: メニューの並びとハンドラ | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、`cli.py` と `bin/devbase` と補完の 3 か所の同期 | -| 確認してから行う | `opener.open_editor` の既存の判断の変更、`up` の挙動の変更 | -| 行わない | `up` の自動オープンの置き換え、依頼範囲外のリファクタリング | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| なし | | | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 窓 | dev コンテナへ Dev Containers 拡張で接続した VS Code のウィンドウ | -| 動いているインスタンス | `docker ps`(`-a` なし)に現れ、Compose のプロジェクトのラベルがこのプロジェクトで、サービスのラベルが `{dev}-{index}` のコンテナ | -| index | 開く dev インスタンスの番号(`dev-1` の `1`)。`--open-index N`、未指定なら env `DEVBASE_OPEN_INDEX`、それも無ければ 1 | - -## 依頼(原文) - -> /goal /ndf:development-workflow https://github.com/devbasex/devbase/issues/197 - -issue #197 の本文(抜粋。原文のまま): - -> **一度閉じた VS Code の窓を、コンテナを止めずに開き直す手段が無い。** - -> | やること | 期待 | -> | --- | --- | -> | 起動中のプロジェクトでコマンドを打つ | コンテナに一切触らず、dev コンテナへ接続した窓を開く | -> | 停止中のプロジェクトで打つ | `up` を実行して起動し、そのまま窓を開く(`devbase up` と同じ結果になる) | -> | `devbase list` の TUI | 起動中メニューの**先頭**(再起動 / 停止 / ログインの上)から同じ操作を選べる | - -> | 論点 | 案 | -> | --- | --- | -> | **既定のハイライトが変わる** | (略)**依頼どおり先頭に置くが、`:31` のコメントもあわせて書き替える** | -> | `DEVBASE_OPEN_EDITOR=0` の端末での扱い | **開く。** 明示のコマンド・明示のメニュー選択は意思表示であり、`up` のときの自動オープンの可否とは別に扱う(`opener.is_open_enabled` を見ない) | -> | index の上限 | `project.yml` の `scale` ではなく**動いているコンテナの数**から決める。(略)そこで解決できない index はエラーにする | -> | 停止中のときの動き | **`up` を実行する。** コンテナ名が解決できないことをもって「起動していない」と判定し、`cmd_up` へ委譲して起動から窓を開くところまで通す。 | - -> `devbase up` の `--open` / `--no-open` / `--open-index` は残す。**`open` の新設は `up` の自動オープンを置き換えるものではない**