From 2f7511d55ed2024aa9187a5f62d62487a485edea Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 12:28:27 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(up):=20devbase=20up=20=E5=BE=8C?= =?UTF-8?q?=E3=81=AB=20dev=20=E3=82=B3=E3=83=B3=E3=83=86=E3=83=8A=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E3=81=AE=20VS=20Code=20=E3=82=92=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E3=82=AA=E3=83=BC=E3=83=97=E3=83=B3=20(PLAN31=5F3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issues/i31.md 第3項。`devbase up` 完了後、dev コンテナへ接続した VS Code を 自動で開けるようにする (Attach to Running Container を CLI から起動)。 - 新規 lib/devbase/editor/opener.py: code CLI 委譲を一貫機構とし、ローカル/ WSL(Windows 側)/VS Code Remote-SSH 統合端末(クライアント側)を自動判別。 plain SSH では手元で叩くコマンドを提示、非TTY/code 不在はスキップ。 attach URI は {"containerName":"/"} を hex 化 (vscode#242489 のネスト authority は未サポートのため code シム/IPC hook の委譲に依拠)。 - cmd_up に [6/6] Opening editor を追加。env DEVBASE_OPEN_EDITOR(既定 OFF) / CLI --open/--no-open/--open-index で制御。起動失敗は up の rc を変えない。 - env/keys.py に DEVBASE_OPEN_EDITOR/DEVBASE_EDITOR/DEVBASE_OPEN_INDEX。 - docs/CHANGELOG 更新、opener 単体 37 + dispatch 統合テスト追加 (748 passed)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 + docs/user/environment-variables.md | 26 ++++ issues/PLAN31_3_up-open-editor.md | 171 ++++++++++++++++++++++ lib/devbase/cli.py | 26 +++- lib/devbase/commands/container.py | 44 +++++- lib/devbase/editor/__init__.py | 4 + lib/devbase/editor/opener.py | 225 +++++++++++++++++++++++++++++ lib/devbase/env/keys.py | 7 + tests/cli/test_project_dispatch.py | 77 +++++++++- tests/editor/__init__.py | 0 tests/editor/test_opener.py | 219 ++++++++++++++++++++++++++++ 11 files changed, 796 insertions(+), 11 deletions(-) create mode 100644 issues/PLAN31_3_up-open-editor.md create mode 100644 lib/devbase/editor/__init__.py create mode 100644 lib/devbase/editor/opener.py create mode 100644 tests/editor/__init__.py create mode 100644 tests/editor/test_opener.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 902bf22..779ea24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### Added +- **`devbase up` 後に dev コンテナへ接続した VS Code を自動オープン**できるように + しました (PLAN31_3)。`DEVBASE_OPEN_EDITOR=1`(既定 OFF)で有効化、`devbase up + --open` / `--no-open` で都度上書き。`/work/$GIT_REPO` をワークスペースとして開きます。 + ローカル / WSL(Windows 側)/ VS Code Remote-SSH 統合ターミナル(手元クライアント側) + を自動判別し、素の SSH では手元で実行するコマンドを提示します。エディタは + `DEVBASE_EDITOR`(既定 `code`)で変更可能。詳細: `docs/user/environment-variables.md`。 + ### Changed - **シェル有効化を `bin/rc` の source に統一**しました (PLAN31_1)。`devbase init` 後に いま開いているシェルへ devbase(PATH / 補完)を即時適用するには diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 1bebd49..70662d1 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -138,6 +138,32 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 ユーザー名のみで秘密情報ではありません。SSH 鍵やリモートログインの有効化はホスト側でユーザーが別途設定する前提です。`devbase env sync` 実行時には、未設定のキーのみ既定値で補完されます(既存値は上書きしません)。 +## `devbase up` 後のエディタ自動オープン + +`devbase up` 完了後、dev コンテナへ接続した VS Code を自動で開けます(VS Code の「Attach to Running Container」を CLI から起動)。`/work/$GIT_REPO` をワークスペースとして開きます。 + +これらは `devbase env init` の収集対象外で、プロジェクトの `env` か `$DEVBASE_ROOT/.env` に手書きする devbase 動作設定です。 + +| キー | 説明 | +|------|------| +| `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く(既定: OFF) | +| `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | +| `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | + +都度の上書きは CLI フラグで行います: `devbase up --open` / `devbase up --no-open` / `devbase up --open-index N`(env より優先)。 + +### 実行コンテキスト別の挙動 + +| コンテキスト | 挙動 | +|------|------| +| ローカル端末(Mac/Linux) | ローカル VS Code が開く | +| WSL 端末 | Windows 側 VS Code が開く(`code` ラッパ経由) | +| VS Code の Remote-SSH 統合ターミナル | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | +| 手元から素の SSH(VS Code 外)で接続中 | クライアントへ自動で開く公式手段が無いため、手元で実行する `code --folder-uri ...` コマンドを提示 | +| CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | + +> SSH 越しに「手元の VS Code」を自動で開きたい場合は、手元の VS Code から **Remote-SSH で接続した統合ターミナル内**で `devbase up` を実行してください。そのターミナルの `code` はクライアント側 VS Code に委譲するため、リモートホスト上のコンテナへ接続した窓が手元に開きます。 + ## ソースファイル変更検出 devbase はソースファイル(`~/.aws/config` 等)のハッシュを `.env.sources.yml` で管理しています。 diff --git a/issues/PLAN31_3_up-open-editor.md b/issues/PLAN31_3_up-open-editor.md new file mode 100644 index 0000000..dbbc553 --- /dev/null +++ b/issues/PLAN31_3_up-open-editor.md @@ -0,0 +1,171 @@ +# PLAN31_3: `devbase up` 後に dev コンテナへ接続した VS Code を自動で開く + +> 元 issue: `issues/i31.md` 第3項 +> ステータス: 計画(2026-06-13 作成 / 未着手) +> 関連: PLAN31_1 (installer)、PLAN31_2 (list TUI 統合)、PLAN06 (`project` 群) +> 関連 skill: `/ndf:issue-plan-strategy`, `/ndf:implementation-plan`, `/ndf:investigation-rules` + +## 1. 背景と目的 + +`devbase up` でコンテナ起動後、ユーザは別途 VS Code を開いて手動で +「Attach to Running Container」する必要がある。これを **`up` 完了時に自動で +dev コンテナへ接続した VS Code を開く**ことで起動〜開発開始の導線を短縮する。 + +ゴール(issue 文言): + +- コンテナ起動後、devcontainer 機能で dev コンテナに接続した VS Code を開く +- `/work/{repository name}`(= `/work/$GIT_REPO`)をワークスペースとして開く +- **WSL 環境では Windows 側の VS Code を開く** +- **(ユーザ追加要件 2026-06-13)SSH 接続時は SSH クライアント側の VS Code を開く。** + 例: Windows→WSL→SSH で Mac に接続し Mac 側で `devbase` を実行 → Windows の + VS Code が dev コンテナに繋がって開く(可能な範囲で)。 + +## 2. 実現可否調査結果(エビデンス) + +> `/ndf:investigation-rules`: 「できる/できない」の結論には一次情報の裏取りを必須とする。 + +### 2.1 一貫機構 — `code` CLI への委譲 + +VS Code は **統合ターミナル内で `VSCODE_IPC_HOOK_CLI`(unix socket)を自動設定**し、 +リモート/ローカルの `code` コマンドはこの socket 経由で**「このフォルダを開け」を +クライアント側 VS Code に IPC で委譲**する。WSL では `code` ラッパが `code.exe` +(Windows 側)を起動する。したがって **`code --folder-uri ` を PATH 上の +`code` で叩く**だけで、実行コンテキストに応じて正しいクライアントへ窓が開く。 + +### 2.2 コンテナ attach URI + +``` +vscode-remote://attached-container+/work/$GIT_REPO +``` + +`` は **`{"containerName":"/<実コンテナ名>"}` を UTF-8 hex 化**した文字列。 +(単純な名前の hex ではない点に注意。Docker 内部のコンテナ名は先頭 `/` 付き。) + +### 2.3 ネスト authority は公式未サポート + +`ssh-remote+` と `attached-container+...` を 1 本の URI に合成する記法は +**存在しない**(microsoft/vscode#242489 は *Closed as not planned*)。 +→ 「リモートホスト上のコンテナ」を単発 `code` で直接指定する手段は無い。 + +### 2.4 結論(実行コンテキスト別マトリクス) + +| コンテキスト | 自動オープン | 機構 / 根拠 | +|---|---|---| +| Mac/Linux ローカル端末 | ✓ | ローカル `code` が attach URI を解決 | +| WSL 端末 | ✓ (Windows VS Code) | `code` ラッパ→`code.exe`、Docker Desktop のコンテナへ attach | +| VS Code **Remote-SSH 統合端末**(リモート=Mac) | ✓ (クライアント側) | `code` シム + `VSCODE_IPC_HOOK_CLI`。シムは既にクライアントへ接続済みなのでネスト URI 不要で attached-container を解決し、**クライアント(Windows)に窓が開く** | +| plain SSH(WSL→ssh→Mac 等、VS Code 外) | ✗ → コマンド表示 | IPC hook 無し。公式にクライアントへ push 不可(§2.3)。手元で叩く `code` コマンドを提示するのが上限 | +| CI / 非TTY / `code` 不在 | ✗ → info スキップ | エディタ起動の前提を満たさない | + +→ ユーザ理想チェーンは **「手元 VS Code で Remote-SSH→Mac に入った統合ターミナルで +`devbase up`」の場合に自動成立**。plain ssh の場合は正直にコマンド提示で degrade する。 + +## 3. 既存コード調査結果 + +| 項目 | 事実 | 出典 | +|---|---|---| +| `up` の最終工程 | `[5/6]` 後に deploy script→「Deploy completed」で終了。**`[6/6]` を追加**して開く | `commands/container.py:407-424` | +| 実コンテナ名 | scale 生成 compose が `container_name = ${COMPOSE_PROJECT_NAME}-{dev}-{index}` を全インスタンスに設定(決定的) | `volume/compose.py:149` | +| ワークスペース | `WORK_DIR=/work/$GIT_REPO`(例 `/work/adminer`) | project `env`, `docs/plugin-dev/quickstart.md:103` | +| dev service 名 | `DEV_SERVICE_NAME` or `dev` | `volume/compose.py:16-18` | +| エディタ既定 | `env edit` は `$EDITOR`(既定 `vi`) を使用 | `commands/env.py:333` | +| TUI 委譲 | ハンドラは `SimpleNamespace` 駆動で TUI から直呼び可能 | PLAN31_2 §2.1 | + +実コンテナ名は決定的だが、compose バージョン差異への保険として +**`docker compose ps --format json` で instance 1 の `Name` を取得**し、失敗時は +`{COMPOSE_PROJECT_NAME}-{dev}-1` へフォールバックする。scale>1 では既定で +**instance 1** を開く(`--open-index N` で上書き可)。 + +## 4. 設計 + +### 4.1 新規モジュール `lib/devbase/editor/opener.py` + +責務を純粋関数に分離してテスト可能にする: + +- `detect_context() -> EditorContext` — env から判定: + `is_tty`(stdout.isatty), `in_vscode`(`VSCODE_IPC_HOOK_CLI`), + `is_wsl`(`WSL_DISTRO_NAME` or `/proc/version` に `microsoft`), + `is_ssh`(`SSH_CONNECTION`/`SSH_CLIENT`/`SSH_TTY`), `is_darwin`(`uname`) +- `resolve_editor_cmd() -> list[str] | None` — 既定 `code`。`DEVBASE_EDITOR` + 優先、なければ `code`→(無ければ `$EDITOR`)。`shutil.which` で実在確認 +- `build_attach_uri(container_name, workdir) -> str` — + `{"containerName":"/"}` を hex 化し attach URI を組む +- `resolve_container_name(...) -> str` — `docker compose ps` 優先+決定的フォールバック +- `decide_action(ctx, editor) -> OpenPlan` — マトリクス(§2.4)を 1 関数に集約。 + 返り値は `launch`(直接起動) / `print_command`(コマンド提示) / `skip`(理由付き) +- `open_editor(...)` — `decide_action` に従い `subprocess.Popen`(非ブロッキング) / + メッセージ出力。例外は warning に握り潰し `up` 本体を絶対に失敗させない + +```mermaid +flowchart TD + A[up 完了 / open 要求] --> B{code/editor 実在?} + B -- no --> S1[skip: 導入を案内] + B -- yes --> C{非TTY/CI?} + C -- yes --> S2[skip: info] + C -- no --> D{in_vscode?
VSCODE_IPC_HOOK_CLI} + D -- yes --> L[launch: code --folder-uri
ローカル/WSL/Remote-SSHシムが委譲] + D -- no --> E{is_ssh?} + E -- no --> L + E -- yes --> P[print_command:
手元VS Codeで実行するattach URLを提示
+Remote-SSH端末からの実行を案内] +``` + +### 4.2 `cmd_up` への統合 + +`[5/6]` 後、deploy script 実行後に `[6/6] Opening editor...` を追加。 +`open_editor(project_name, scale, index=open_index, mode=open_mode)` を呼ぶ。 +**戻り値で `up` の rc を変えない**(エディタ起動失敗はデプロイ成功を覆さない)。 + +### 4.3 設定・CLI・TUI + +| 層 | 追加 | 既定 | +|---|---|---| +| env/config | `DEVBASE_OPEN_EDITOR`(真偽), `DEVBASE_EDITOR`(コマンド), `DEVBASE_OPEN_INDEX` | §6 の決定に従う | +| CLI flag | `up` / `project up` に `--open` / `--no-open`, `--open-index N` | env を上書き | +| TUI | project up アクションに「起動後エディタを開く」を反映(PLAN31_2 経路) | env 既定踏襲 | + +env 解釈は既存 `_parse_env_assignment`(`container.py:121`)に合わせる。 +新キーは `env/keys.py` と `docs/user/environment-variables.md` に追記。 + +## 5. 決定事項(2026-06-13 ユーザ確認済み) + +| # | 論点 | 決定 | 備考 | +|---|---|---|---| +| D1 | 自動オープンの既定 | ✅ **env `DEVBASE_OPEN_EDITOR` で制御(未設定時 OFF)+ `--open`/`--no-open` で都度上書き** | 暴発回避を最優先。プロジェクト env に 1 行書けば常時 ON にできる | +| D2 | 接続方式 | ✅ **Attach to Running Container(§2.2 URI)** | devbase project は devcontainer.json 非依存。現構成にそのまま乗る | +| D3 | エディタ | ✅ 既定 `code`、`DEVBASE_EDITOR` で上書き | `cursor` 等も同 URI スキームで動作 | +| D4 | scale>1 の対象 | ✅ instance 1(`--open-index` で変更) | | +| D5 | PR 構成 | ✅ **単一 PR(案A)** | §6 参照 | + +## 6. PR 構成(単一 PR) + +差分は cmd_up 統合+新規 editor モジュール+CLI/env+docs+テストで中規模 +(~400 行目安・結合度高)。Step 2 の単一 PR 条件に合致するため release 運用は取らない。 + +| branch | 概要 | +|---|---| +| `feature/PLAN31_3-up-open-editor` | editor モジュール+cmd_up `[6/6]`+CLI/env+docs+テスト一括 → main へ | + +## 7. テスト計画 + +- 単体(実 docker/VS Code 不要・env monkeypatch): + `detect_context`(WSL/SSH/VSCODE/Darwin の各組合せ)、 + `build_attach_uri`(hex が `{"containerName":"/name"}` と一致)、 + `resolve_editor_cmd`(`DEVBASE_EDITOR`/`code`/不在)、 + `decide_action`(§2.4 マトリクス全分岐=launch/print/skip) +- `cmd_up` 統合: `open_editor` を mock し **rc が常に 0 のまま**であること、 + `--no-open`/`DEVBASE_OPEN_EDITOR=0` で呼ばれないこと +- 既存 706 passed を維持 + +## 8. リスク・未確定 + +- **plain SSH では自動オープン不可**(§2.3 公式未サポート)。コマンド提示で degrade。 + この制約は README に明記する +- VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要 + (`/ndf:investigation-rules`: 実機未検証の挙動は「推定」と明示) +- `code` ラッパの非ブロッキング起動が `up` プロセス終了をブロックしないこと確認 + +## 9. 参考(一次情報) + +- microsoft/vscode#242489(ネスト authority not planned) +- attach URI / hex payload: cspotcode.com "Attach VSCode to container from CLI" +- `VSCODE_IPC_HOOK_CLI` の委譲挙動: VS Code remote troubleshooting docs diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e370673..5248bdf 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -93,6 +93,26 @@ def _add_name_arg(parser): `project [name]` とトップレベルショートカットで同一定義を共有する。 """ parser.add_argument('name', nargs='?', default=None, help='Project name') + return parser + + +def _add_open_args(parser): + """`up` に エディタ自動オープン関連フラグを登録する (PLAN31_3)。 + + 三状態フラグ: ``--open`` (True) / ``--no-open`` (False) / 未指定 (None → + env ``DEVBASE_OPEN_EDITOR`` に委ねる)。``project up`` / ``container up`` / + トップレベル ``up`` で共有する。 + """ + parser.add_argument('--open', dest='open_editor', action='store_true', + default=None, + help='Open editor attached to the dev container after start ' + '(overrides DEVBASE_OPEN_EDITOR)') + parser.add_argument('--no-open', dest='open_editor', action='store_false', + help='Do not open editor (overrides DEVBASE_OPEN_EDITOR)') + parser.add_argument('--open-index', dest='open_index', type=int, default=None, + metavar='N', + help='Container index to open (default: 1)') + return parser def _add_login_subparser(sub): @@ -125,7 +145,7 @@ def _add_container_parser(subparsers): help='Manage containers') ct_sub = ct_parser.add_subparsers(dest='subcommand') - ct_sub.add_parser('up', help='Start containers') + _add_open_args(ct_sub.add_parser('up', help='Start containers')) ct_sub.add_parser('down', help='Stop and remove containers') _add_login_subparser(ct_sub) @@ -165,7 +185,7 @@ def _add_project_parser(subparsers): pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') - _add_name_arg(pj_sub.add_parser('up', help='Start containers')) + _add_open_args(_add_name_arg(pj_sub.add_parser('up', help='Start containers'))) _add_name_arg(pj_sub.add_parser('down', help='Stop and remove containers')) _add_login_subparser(pj_sub) @@ -441,7 +461,7 @@ def _add_shortcuts(subparsers): _add_name_arg(ps_sc) ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') - _add_name_arg(subparsers.add_parser('up', help='Start containers')) + _add_open_args(_add_name_arg(subparsers.add_parser('up', help='Start containers'))) _add_name_arg(subparsers.add_parser('down', help='Stop and remove containers')) # `[name]` optional + `new_scale` 必須 int の順 (project scale と同じ規則)。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index ac39922..de50ab6 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -288,7 +288,9 @@ def _dispatch_lifecycle(args) -> int: handlers = { 'up': lambda: cmd_up(project_name=project_name, - scale=getattr(args, 'scale', None)), + scale=getattr(args, 'scale', None), + open_editor=getattr(args, 'open_editor', None), + open_index=getattr(args, 'open_index', None)), 'down': lambda: cmd_down(), 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), @@ -353,7 +355,43 @@ def _auto_snapshot() -> None: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) -def cmd_up(project_name: str = None, scale: int = None) -> int: +def _maybe_open_editor(project_name: str, open_flag: Optional[bool], + open_index: Optional[int]) -> None: + """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 + + 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env + ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 + """ + from devbase.editor import opener + + enabled = open_flag if open_flag is not None else opener.is_open_enabled() + if not enabled: + return + + 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 + + dev_service_name = get_dev_service_name() + workdir = opener.resolve_workdir(os.environ, project_name) + logger.info("[6/6] Opening editor attached to the dev container...") + try: + opener.open_editor( + project_name=project_name, + dev_service_name=dev_service_name, + workdir=workdir, + index=open_index, + ) + except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない + logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) + + +def cmd_up(project_name: str = None, scale: int = None, + open_editor: Optional[bool] = None, + open_index: Optional[int] = None) -> int: """Deploy containers with specified scale""" if project_name is None: project_name = get_project_name() @@ -420,6 +458,8 @@ def cmd_up(project_name: str = None, scale: int = None) -> int: if deploy_script.exists() and deploy_script.is_file(): _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) + _maybe_open_editor(project_name, open_editor, open_index) + logger.info("=== Deploy completed successfully ===") return 0 diff --git a/lib/devbase/editor/__init__.py b/lib/devbase/editor/__init__.py new file mode 100644 index 0000000..9ac1b45 --- /dev/null +++ b/lib/devbase/editor/__init__.py @@ -0,0 +1,4 @@ +"""エディタ自動オープン (devbase up 後の dev コンテナ接続)。 + +詳細設計は issues/PLAN31_3_up-open-editor.md を参照。 +""" diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py new file mode 100644 index 0000000..a643f4f --- /dev/null +++ b/lib/devbase/editor/opener.py @@ -0,0 +1,225 @@ +"""`devbase up` 後に dev コンテナへ接続した VS Code を自動で開く。 + +設計の核心 (issues/PLAN31_3_up-open-editor.md §2): + +- 一貫機構は **PATH 上の ``code`` への委譲**。VS Code 統合ターミナルでは + ``VSCODE_IPC_HOOK_CLI`` 経由でクライアント側 VS Code に「このフォルダを開け」を + IPC 委譲する。WSL では ``code`` ラッパが Windows 側 VS Code を起動する。 + Remote-SSH 統合ターミナルでは ``code`` シムがクライアント (例: Windows) に窓を + 開く。よって ``code --folder-uri `` を叩くだけで実行コンテキストに + 応じた正しいクライアントへ開ける。 +- コンテナ attach URI は ``{"containerName":"/<実コンテナ名>"}`` を hex 化した + authority を持つ (:func:`build_attach_uri`)。 +- ``ssh-remote+host`` と ``attached-container+...`` を 1 本に合成する記法は + 公式未サポート (microsoft/vscode#242489)。よって VS Code 外の plain SSH では + クライアントへ push できず、手元で叩くコマンドを提示する degrade に留める。 + +本モジュールの関数は実 docker / VS Code を必要とせず、``environ`` 等を引数で +差し替えてテストできるよう副作用を :func:`open_editor` に集約している。 +""" + +from __future__ import annotations + +import json +import os +import platform +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from typing import Callable, Optional + +from devbase.log import get_logger + +logger = get_logger(__name__) + +# DEVBASE_OPEN_EDITOR を真と解釈する値 (大小無視) +_TRUTHY = {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class EditorContext: + """エディタ起動先の判定に使う実行コンテキスト。""" + + is_tty: bool + in_vscode: bool # VSCODE_IPC_HOOK_CLI が設定されている + is_wsl: bool + is_ssh: bool + is_darwin: bool + + +@dataclass(frozen=True) +class OpenPlan: + """:func:`decide_action` の判定結果。""" + + action: str # 'launch' | 'print_command' | 'skip' + reason: str + + +def _stdout_isatty() -> bool: + try: + return bool(sys.stdout.isatty()) + except Exception: + return False + + +def _detect_wsl(environ) -> bool: + if environ.get("WSL_DISTRO_NAME") or environ.get("WSL_INTEROP"): + return True + try: + with open("/proc/version", encoding="utf-8") as f: + return "microsoft" in f.read().lower() + except OSError: + return False + + +def detect_context(environ=None, isatty: Optional[bool] = None, + system: Optional[str] = None) -> EditorContext: + """env / OS からエディタ起動先判定に必要なコンテキストを抽出する。 + + 引数はテスト用の差し替え口。未指定なら ``os.environ`` / ``sys.stdout`` / + ``platform.system()`` を用いる。 + """ + env = os.environ if environ is None else environ + if isatty is None: + isatty = _stdout_isatty() + if system is None: + system = platform.system() + return EditorContext( + is_tty=bool(isatty), + in_vscode=bool(env.get("VSCODE_IPC_HOOK_CLI")), + is_wsl=_detect_wsl(env), + is_ssh=any(env.get(k) for k in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")), + is_darwin=(system == "Darwin"), + ) + + +def is_open_enabled(environ=None) -> bool: + """``DEVBASE_OPEN_EDITOR`` env が真かどうか (未設定は False)。""" + env = os.environ if environ is None else environ + value = env.get("DEVBASE_OPEN_EDITOR") + if value is None: + return False + return value.strip().lower() in _TRUTHY + + +def resolve_editor_cmd(environ=None) -> Optional[list]: + """起動に使うエディタコマンド (argv list) を解決する。 + + ``DEVBASE_EDITOR`` があればそれを (シェル風に分割して) 優先。なければ既定の + ``code``。attach URI は VS Code 系 CLI でのみ解釈できるため、``$EDITOR`` + (vi 等) へのフォールバックは意図的に行わない。実在しなければ None。 + """ + env = os.environ if environ is None else environ + explicit = env.get("DEVBASE_EDITOR") + if explicit: + parts = shlex.split(explicit) + if parts and shutil.which(parts[0]): + return parts + return None + if shutil.which("code"): + return ["code"] + return None + + +def build_attach_uri(container_name: str, workdir: str) -> str: + """``vscode-remote://attached-container+/`` を組む。 + + ```` は ``{"containerName":"/"}`` を UTF-8 hex 化したもの + (Docker 内部のコンテナ名は先頭 ``/`` 付き)。 + """ + payload = json.dumps({"containerName": f"/{container_name}"}, separators=(",", ":")) + hexname = payload.encode("utf-8").hex() + path = workdir if workdir.startswith("/") else f"/{workdir}" + return f"vscode-remote://attached-container+{hexname}{path}" + + +def resolve_container_name(dev_service_name: str, project_name: str, index: int = 1) -> str: + """dev コンテナの実コンテナ名を返す。 + + scale 生成 compose が ``container_name = ${COMPOSE_PROJECT_NAME}-{dev}-{index}`` + を全インスタンスへ設定する (volume/compose.py)。COMPOSE_PROJECT_NAME は + project_name と一致するため決定的に組み立てられる。 + """ + return f"{project_name}-{dev_service_name}-{index}" + + +def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: + """コンテナ内で開くワークスペースパス (``/work/$GIT_REPO``) を返す。""" + env = os.environ if environ is None else environ + workdir = env.get("WORK_DIR") + if workdir: + return workdir + repo = env.get("GIT_REPO") or project_name + return f"/work/{repo}" if repo else "/work" + + +def decide_action(ctx: EditorContext, editor_cmd: Optional[list]) -> OpenPlan: + """コンテキストとエディタ可用性から起動方針を決める (§2.4 マトリクス)。""" + if not editor_cmd: + return OpenPlan( + "skip", + "エディタ (code) が見つかりません。VS Code の `code` コマンドを PATH に " + "通すか DEVBASE_EDITOR を設定してください", + ) + if not ctx.is_tty: + return OpenPlan("skip", "非対話 (非TTY/CI) 環境のため") + if ctx.in_vscode: + # VS Code 統合ターミナル (ローカル / WSL / Remote-SSH シム)。code が + # クライアント側へ委譲するため直接起動でよい。 + return OpenPlan("launch", "VS Code 統合ターミナル経由") + if ctx.is_ssh: + # plain SSH (VS Code 外)。クライアントへ push する公式手段が無いため + # コマンドを提示する degrade。 + return OpenPlan("print_command", "SSH セッション (VS Code 外) のため") + return OpenPlan("launch", "ローカル/WSL 端末") + + +def _launch(cmd: list, env: dict) -> None: + """エディタを非ブロッキングで起動する (up プロセスを待たせない)。""" + subprocess.Popen( # noqa: S603 - argv はコード生成で外部入力を渡さない + cmd, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def open_editor(*, project_name: str, dev_service_name: str, workdir: str, + index: int = 1, environ=None, + isatty: Optional[bool] = None, system: Optional[str] = None, + launcher: Optional[Callable[[list, dict], None]] = None) -> str: + """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 + + 戻り値は実行された action ('launch' | 'print_command' | 'skip')。例外は + 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / + ``system`` は :func:`detect_context` への差し替え口 (テスト用)。 + """ + env = os.environ if environ is None else environ + ctx = detect_context(env, isatty=isatty, system=system) + editor = resolve_editor_cmd(env) + plan = decide_action(ctx, editor) + + container = resolve_container_name(dev_service_name, project_name, index) + uri = build_attach_uri(container, workdir) + + if plan.action == "skip": + logger.info("エディタの自動オープンをスキップ: %s", plan.reason) + return "skip" + + quoted = " ".join(shlex.quote(c) for c in editor) + if plan.action == "print_command": + logger.info("SSH セッションを検出しました (%s)。", plan.reason) + logger.info( + "手元の VS Code で次を実行するか、VS Code の Remote-SSH 統合ターミナルから " + "`devbase up` を実行すると自動で開きます:" + ) + logger.info(" %s --folder-uri '%s'", quoted, uri) + return "print_command" + + logger.info("[editor] %s を起動します (%s)", quoted, plan.reason) + cmd = [*editor, "--folder-uri", uri] + try: + (launcher or _launch)(cmd, dict(env)) + except Exception as e: # noqa: BLE001 - 起動失敗で up を倒さない + logger.warning("エディタの起動に失敗しましたが処理は続行します: %s", e) + return "launch" diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index e85cb6e..e8a8d36 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -50,3 +50,10 @@ def gcp_credentials_key(profile: str) -> str: # --- Host (コンテナ→ホスト SSH 接続) --- HOST_SSH_USER = "HOST_SSH_USER" HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal + +# --- Editor (devbase up 後の自動オープン / PLAN31_3) --- +# いずれも env collection (env init) の対象外で、プロジェクト env / グローバル +# .env に手書きする devbase 動作設定。詳細: docs/user/environment-variables.md +DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (既定 OFF) +DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code) +DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1) diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index bec846b..7cdcc07 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -123,7 +123,7 @@ def test_lifecycle_passes_name_to_cmd_up(monkeypatch): # name 解決 (chdir) は別テストで検証するためここでは no-op 化し、伝播のみ見る。 monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None: + lambda project_name=None, scale=None, **kwargs: captured.update(project_name=project_name) or 0) args = _args(subcommand='up', name='carmo', scale=None) assert container._dispatch_lifecycle(args) == 0 @@ -135,7 +135,7 @@ def test_lifecycle_container_path_has_no_name(monkeypatch): from devbase.commands import container captured = {} monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None: + lambda project_name=None, scale=None, **kwargs: captured.update(project_name=project_name) or 0) args = _args(subcommand='up', scale=None) # name 属性なし assert container._dispatch_lifecycle(args) == 0 @@ -156,7 +156,7 @@ def test_lifecycle_resolves_name_before_handler(monkeypatch): monkeypatch.setattr(container, '_resolve_project_name', lambda name: order.append(('resolve', name)) or True) monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None: + lambda project_name=None, scale=None, **kwargs: order.append(('up', project_name)) or 0) args = _args(subcommand='up', name='carmo', scale=None) assert container._dispatch_lifecycle(args) == 0 @@ -169,7 +169,7 @@ def test_lifecycle_aborts_when_name_unresolved(monkeypatch): called = [] monkeypatch.setattr(container, '_resolve_project_name', lambda name: False) monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None: + lambda project_name=None, scale=None, **kwargs: called.append('up') or 0) args = _args(subcommand='up', name='bogus', scale=None) assert container._dispatch_lifecycle(args) == 1 @@ -182,7 +182,7 @@ def test_lifecycle_no_resolution_without_name(monkeypatch): resolved = [] monkeypatch.setattr(container, '_resolve_project_name', lambda name: resolved.append(name) or True) - monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0) + monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None, **kwargs: 0) args = _args(subcommand='up', scale=None) # name 属性なし assert container._dispatch_lifecycle(args) == 0 assert resolved == [] @@ -304,7 +304,7 @@ def test_shortcut_up_propagates_name_through_dispatch(monkeypatch): captured = {} monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None: + lambda project_name=None, scale=None, **kwargs: captured.update(project_name=project_name) or 0) # ショートカット parser が生成する namespace を再現 (name 属性を持つ) args = _args(command='up', name='carmo', scale=None) @@ -324,3 +324,68 @@ def test_shortcut_scale_propagates_name_through_dispatch(monkeypatch): assert cli._dispatch('scale', args) == 0 assert captured['project_name'] == 'carmo' assert captured['new_scale'] == 3 + + +# --------------------------------------------------------------------------- +# PLAN31_3: up のエディタ自動オープン引数の伝播 / gating +# --------------------------------------------------------------------------- + +def test_up_parser_open_flags_tri_state(): + """`--open` / `--no-open` / 未指定 が open_editor=True/False/None になる。""" + parser = cli._create_parser() + assert parser.parse_args(['up', '--open']).open_editor is True + assert parser.parse_args(['up', '--no-open']).open_editor is False + assert parser.parse_args(['up']).open_editor is None + assert parser.parse_args(['up', '--open-index', '2']).open_index == 2 + + +def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): + """up の open_editor / open_index が cmd_up まで伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None, open_editor=None, open_index=None: + captured.update(open_editor=open_editor, open_index=open_index) or 0) + args = _args(subcommand='up', scale=None, open_editor=True, open_index=2) + assert container._dispatch_lifecycle(args) == 0 + assert captured == {'open_editor': True, 'open_index': 2} + + +def test_maybe_open_editor_disabled_by_default(monkeypatch): + """open_flag=None かつ env 未設定なら open_editor を呼ばない。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + called = [] + monkeypatch.setattr(opener, 'open_editor', + lambda **kw: called.append(kw) or 'launch') + container._maybe_open_editor('carmo', None, None) + assert called == [] + + +def test_maybe_open_editor_flag_overrides_env(monkeypatch): + """open_flag=True なら env が False でも開く。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + called = [] + monkeypatch.setattr(opener, 'open_editor', + lambda **kw: called.append(kw) or 'launch') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + container._maybe_open_editor('carmo', True, 1) + assert len(called) == 1 + assert called[0]['project_name'] == 'carmo' + + +def test_maybe_open_editor_failure_does_not_raise(monkeypatch): + """open_editor が例外でも _maybe_open_editor は伝播させない (up を倒さない)。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + + def boom(**kw): + raise RuntimeError("x") + + monkeypatch.setattr(opener, 'open_editor', boom) + container._maybe_open_editor('carmo', None, None) # 例外が出なければ OK diff --git a/tests/editor/__init__.py b/tests/editor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py new file mode 100644 index 0000000..906a9e9 --- /dev/null +++ b/tests/editor/test_opener.py @@ -0,0 +1,219 @@ +"""opener.py: エディタ自動オープンの判定・URI 組み立て (実 docker/VS Code 不要)。""" + +from __future__ import annotations + +import json + +import pytest + +from devbase.editor import opener + + +# --------------------------------------------------------------------------- +# detect_context +# --------------------------------------------------------------------------- + +def test_detect_context_plain_local(): + ctx = opener.detect_context(environ={}, isatty=True, system="Linux") + assert ctx.is_tty is True + assert ctx.in_vscode is False + assert ctx.is_wsl is False + assert ctx.is_ssh is False + assert ctx.is_darwin is False + + +def test_detect_context_darwin(): + ctx = opener.detect_context(environ={}, isatty=True, system="Darwin") + assert ctx.is_darwin is True + + +def test_detect_context_wsl_via_env(): + ctx = opener.detect_context(environ={"WSL_DISTRO_NAME": "Ubuntu"}, + isatty=True, system="Linux") + assert ctx.is_wsl is True + + +@pytest.mark.parametrize("key", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]) +def test_detect_context_ssh(key): + ctx = opener.detect_context(environ={key: "x"}, isatty=True, system="Linux") + assert ctx.is_ssh is True + + +def test_detect_context_in_vscode(): + ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock"}, + isatty=True, system="Linux") + assert ctx.in_vscode is True + + +# --------------------------------------------------------------------------- +# is_open_enabled +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + (None, False), ("", False), ("0", False), ("false", False), ("no", False), + ("1", True), ("true", True), ("TRUE", True), ("yes", True), ("on", True), +]) +def test_is_open_enabled(value, expected): + env = {} if value is None else {"DEVBASE_OPEN_EDITOR": value} + assert opener.is_open_enabled(env) is expected + + +# --------------------------------------------------------------------------- +# resolve_editor_cmd +# --------------------------------------------------------------------------- + +def test_resolve_editor_cmd_default_code(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", + lambda c: "/usr/bin/code" if c == "code" else None) + assert opener.resolve_editor_cmd({}) == ["code"] + + +def test_resolve_editor_cmd_missing(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + assert opener.resolve_editor_cmd({}) is None + + +def test_resolve_editor_cmd_explicit_with_args(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", + lambda c: "/usr/bin/cursor" if c == "cursor" else None) + assert opener.resolve_editor_cmd({"DEVBASE_EDITOR": "cursor --reuse-window"}) \ + == ["cursor", "--reuse-window"] + + +def test_resolve_editor_cmd_explicit_missing(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + assert opener.resolve_editor_cmd({"DEVBASE_EDITOR": "nope"}) is None + + +# --------------------------------------------------------------------------- +# build_attach_uri +# --------------------------------------------------------------------------- + +def test_build_attach_uri_hex_payload(): + uri = opener.build_attach_uri("adminer-dev-1", "/work/adminer") + prefix = "vscode-remote://attached-container+" + assert uri.startswith(prefix) + authority, _, path = uri[len(prefix):].partition("/") + assert path == "work/adminer" + decoded = bytes.fromhex(authority).decode("utf-8") + assert json.loads(decoded) == {"containerName": "/adminer-dev-1"} + + +def test_build_attach_uri_adds_leading_slash(): + uri = opener.build_attach_uri("p-dev-1", "work/p") # スラッシュ無し + assert uri.endswith("/work/p") + + +# --------------------------------------------------------------------------- +# resolve_container_name / resolve_workdir +# --------------------------------------------------------------------------- + +def test_resolve_container_name_deterministic(): + assert opener.resolve_container_name("dev", "carmo", 1) == "carmo-dev-1" + assert opener.resolve_container_name("app", "carmo", 3) == "carmo-app-3" + + +def test_resolve_workdir_prefers_work_dir_env(): + assert opener.resolve_workdir({"WORK_DIR": "/work/x"}, "y") == "/work/x" + + +def test_resolve_workdir_from_git_repo(): + assert opener.resolve_workdir({"GIT_REPO": "myrepo"}, None) == "/work/myrepo" + + +def test_resolve_workdir_fallback_project_name(): + assert opener.resolve_workdir({}, "proj") == "/work/proj" + + +# --------------------------------------------------------------------------- +# decide_action (§2.4 マトリクス全分岐) +# --------------------------------------------------------------------------- + +def _ctx(**kw): + base = dict(is_tty=True, in_vscode=False, is_wsl=False, is_ssh=False, is_darwin=False) + base.update(kw) + return opener.EditorContext(**base) + + +def test_decide_skip_no_editor(): + assert opener.decide_action(_ctx(), None).action == "skip" + + +def test_decide_skip_non_tty(): + assert opener.decide_action(_ctx(is_tty=False), ["code"]).action == "skip" + + +def test_decide_launch_local(): + assert opener.decide_action(_ctx(), ["code"]).action == "launch" + + +def test_decide_launch_wsl(): + assert opener.decide_action(_ctx(is_wsl=True), ["code"]).action == "launch" + + +def test_decide_launch_in_vscode_even_under_ssh(): + # Remote-SSH 統合端末: in_vscode が ssh より優先され launch + plan = opener.decide_action(_ctx(in_vscode=True, is_ssh=True), ["code"]) + assert plan.action == "launch" + + +def test_decide_print_command_plain_ssh(): + plan = opener.decide_action(_ctx(is_ssh=True), ["code"]) + assert plan.action == "print_command" + + +# --------------------------------------------------------------------------- +# open_editor (orchestration; launcher を差し替えて副作用を観測) +# --------------------------------------------------------------------------- + +def test_open_editor_launch_invokes_launcher(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={}, isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "launch" + assert len(calls) == 1 + cmd = calls[0] + assert cmd[0] == "code" + assert cmd[1] == "--folder-uri" + assert cmd[2].startswith("vscode-remote://attached-container+") + assert cmd[2].endswith("/work/carmo") + + +def test_open_editor_skip_when_no_editor(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + calls = [] + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={}, launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "skip" + assert calls == [] + + +def test_open_editor_print_command_under_plain_ssh(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, isatty=True, + launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "print_command" + assert calls == [] + + +def test_open_editor_launch_failure_is_swallowed(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + + def boom(cmd, env): + raise OSError("cannot exec") + + # 例外を握り潰し launch を返す (up を倒さない) + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={}, isatty=True, launcher=boom, + ) + assert result == "launch" From abad035922734df555834dc7992bb12dd0068ed5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 13:12:44 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(up):=20=E5=AE=9F=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=83=86=E3=83=8A=E5=90=8D=20docker=20=E5=8F=96=E5=BE=97+?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=A8=20open=5Findex=20=E7=AF=84=E5=9B=B2=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘 (PR #69) 対応: - 指摘A (major): PLAN31_3 §3 が約束した「compose バージョン差異への保険」を実装。 opener._query_container_name で docker compose ps --format json を実行し、 NDJSON / JSON 配列の両形式から実 .Name を取得。失敗・非0・例外・空は None に 握り潰し、resolve_container_name が決定的名へフォールバックする。runner 差替で テスト可能。 - 指摘B (minor): _maybe_open_editor に scale を渡し open_index を 1..scale で 検証。0・負数・scale 超過は warning を出して既定 (1) へフォールバックする。 DEVBASE_OPEN_INDEX 経由も同じ検証を通る。 756 passed (+8 tests)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 16 +++++- lib/devbase/editor/opener.py | 80 ++++++++++++++++++++++++++++-- tests/cli/test_project_dispatch.py | 34 +++++++++++-- tests/editor/test_opener.py | 52 ++++++++++++++++++- 4 files changed, 173 insertions(+), 9 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index de50ab6..ccf91ab 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -356,11 +356,15 @@ def _auto_snapshot() -> None: def _maybe_open_editor(project_name: str, open_flag: Optional[bool], - open_index: Optional[int]) -> None: + open_index: Optional[int], scale: int) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 + + ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 + 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 + 警告を出して既定 (1) へフォールバックする。 """ from devbase.editor import opener @@ -375,6 +379,14 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], except ValueError: open_index = 1 + # 起動済みインスタンス範囲 (1..scale) の検証。範囲外は既定 (1) へフォールバック。 + if not (1 <= open_index <= scale): + logger.warning( + "open index %d is out of range (1..%d); falling back to 1", + open_index, scale, + ) + open_index = 1 + dev_service_name = get_dev_service_name() workdir = opener.resolve_workdir(os.environ, project_name) logger.info("[6/6] Opening editor attached to the dev container...") @@ -458,7 +470,7 @@ def cmd_up(project_name: str = None, scale: int = None, if deploy_script.exists() and deploy_script.is_file(): _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) - _maybe_open_editor(project_name, open_editor, open_index) + _maybe_open_editor(project_name, open_editor, open_index, scale) logger.info("=== Deploy completed successfully ===") return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index a643f4f..188e1e1 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -135,13 +135,87 @@ def build_attach_uri(container_name: str, workdir: str) -> str: return f"vscode-remote://attached-container+{hexname}{path}" -def resolve_container_name(dev_service_name: str, project_name: str, index: int = 1) -> str: +def _parse_compose_ps_name(stdout: str) -> Optional[str]: + """``docker compose ps --format json`` の出力から ``.Name`` を 1 件取り出す。 + + docker compose のバージョン差で出力形式が異なる: + + - 新しめ: 1 行 1 JSON オブジェクト (改行区切り NDJSON) + - 古め: JSON 配列 ``[{...}, ...]`` + + どちらでも先頭インスタンスの ``Name`` を返す。解釈不能・空なら None。 + """ + text = (stdout or "").strip() + if not text: + return None + # まず JSON 配列としてのパースを試す。 + try: + obj = json.loads(text) + except ValueError: + obj = None + if isinstance(obj, list): + for item in obj: + if isinstance(item, dict) and item.get("Name"): + return item["Name"] + return None + if isinstance(obj, dict) and obj.get("Name"): + return obj["Name"] + # NDJSON (1 行 1 JSON) として行ごとにパース。 + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except ValueError: + continue + if isinstance(item, dict) and item.get("Name"): + return item["Name"] + return None + + +def _query_container_name(dev_service_name: str, index: int, + runner: Optional[Callable] = None) -> Optional[str]: + """実 docker へ問い合わせて dev インスタンスの実コンテナ名を取得する (保険)。 + + scale 生成 compose ではサービス名が ``{dev}-{index}`` (例 ``dev-1``) になるため + その service token を指定して ``docker compose ps --format json`` を実行する。 + 取得できなければ None。docker 不在・非0・例外・空はすべて None に握り潰し、 + 呼び出し側が決定的名へフォールバックできるようにする。 + """ + run = runner or subprocess.run + service_token = f"{dev_service_name}-{index}" + try: + proc = run( + ["docker", "compose", "ps", "--format", "json", service_token], + capture_output=True, text=True, timeout=10, + ) + except Exception: # noqa: BLE001 - docker 不在等は保険なので握り潰す + return None + if getattr(proc, "returncode", 1) != 0: + return None + try: + return _parse_compose_ps_name(proc.stdout) + except Exception: # noqa: BLE001 - パース失敗も決定的名へフォールバック + return None + + +def resolve_container_name(dev_service_name: str, project_name: str, index: int = 1, + runner: Optional[Callable] = None) -> str: """dev コンテナの実コンテナ名を返す。 - scale 生成 compose が ``container_name = ${COMPOSE_PROJECT_NAME}-{dev}-{index}`` + PLAN31_3 §3: compose バージョン差異への保険として、まず実 docker へ + ``docker compose ps --format json`` で問い合わせて実 ``Name`` を取得する。 + 取得できなければ決定的名 ``{project_name}-{dev_service_name}-{index}`` へ + フォールバックする。 + + scale 生成 compose は ``container_name = ${COMPOSE_PROJECT_NAME}-{dev}-{index}`` を全インスタンスへ設定する (volume/compose.py)。COMPOSE_PROJECT_NAME は - project_name と一致するため決定的に組み立てられる。 + project_name と一致するため、docker 問い合わせに失敗しても決定的に組み立てられる。 """ + queried = _query_container_name(dev_service_name, index, runner=runner) + if queried: + return queried return f"{project_name}-{dev_service_name}-{index}" diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 7cdcc07..1756ae6 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -359,7 +359,7 @@ def test_maybe_open_editor_disabled_by_default(monkeypatch): called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', None, None) + container._maybe_open_editor('carmo', None, None, 1) assert called == [] @@ -372,7 +372,7 @@ def test_maybe_open_editor_flag_overrides_env(monkeypatch): monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') - container._maybe_open_editor('carmo', True, 1) + container._maybe_open_editor('carmo', True, 1, 1) assert len(called) == 1 assert called[0]['project_name'] == 'carmo' @@ -388,4 +388,32 @@ def boom(**kw): raise RuntimeError("x") monkeypatch.setattr(opener, 'open_editor', boom) - container._maybe_open_editor('carmo', None, None) # 例外が出なければ OK + container._maybe_open_editor('carmo', None, None, 1) # 例外が出なければ OK + + +@pytest.mark.parametrize('bad_index', [0, -1, 3]) +def test_maybe_open_editor_out_of_range_index_falls_back(monkeypatch, bad_index): + """0・負数・scale 超過の index は既定 (1) へフォールバックする (scale=2)。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + called = [] + monkeypatch.setattr(opener, 'open_editor', + lambda **kw: called.append(kw) or 'launch') + container._maybe_open_editor('carmo', True, bad_index, 2) + assert len(called) == 1 + assert called[0]['index'] == 1 + + +def test_maybe_open_editor_valid_index_within_scale(monkeypatch): + """範囲内 (1..scale) の index はそのまま使われる。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + called = [] + monkeypatch.setattr(opener, 'open_editor', + lambda **kw: called.append(kw) or 'launch') + container._maybe_open_editor('carmo', True, 2, 3) + assert called[0]['index'] == 2 diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 906a9e9..d9796ab 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -3,12 +3,20 @@ from __future__ import annotations import json +from dataclasses import dataclass import pytest from devbase.editor import opener +@dataclass +class _Proc: + """subprocess.run 互換の軽量スタブ (returncode / stdout のみ)。""" + returncode: int = 0 + stdout: str = "" + + # --------------------------------------------------------------------------- # detect_context # --------------------------------------------------------------------------- @@ -109,8 +117,50 @@ def test_build_attach_uri_adds_leading_slash(): # --------------------------------------------------------------------------- def test_resolve_container_name_deterministic(): + """docker 問い合わせが失敗 (非0) する場合は決定的名へフォールバックする。""" + def failing_runner(cmd, **kw): + return _Proc(returncode=1, stdout="") + + assert opener.resolve_container_name("dev", "carmo", 1, runner=failing_runner) \ + == "carmo-dev-1" + assert opener.resolve_container_name("app", "carmo", 3, runner=failing_runner) \ + == "carmo-app-3" + + +def test_resolve_container_name_falls_back_when_docker_absent(monkeypatch): + """docker 不在 (例外) でも決定的名で必ず動く。""" + monkeypatch.setattr(opener, "_query_container_name", + lambda *a, **kw: None) assert opener.resolve_container_name("dev", "carmo", 1) == "carmo-dev-1" - assert opener.resolve_container_name("app", "carmo", 3) == "carmo-app-3" + + +def test_resolve_container_name_prefers_docker_name_ndjson(): + """docker から取得できた実 Name (NDJSON) を決定的名より優先する。""" + def runner(cmd, **kw): + # service token は dev-2 を指定しているはず + assert cmd[:4] == ["docker", "compose", "ps", "--format"] + assert cmd[-1] == "dev-2" + return _Proc(returncode=0, + stdout='{"Name":"real-dev-2","Service":"dev-2"}\n') + + assert opener.resolve_container_name("dev", "carmo", 2, runner=runner) \ + == "real-dev-2" + + +def test_resolve_container_name_prefers_docker_name_json_array(): + """JSON 配列形式の docker compose ps 出力にも対応する。""" + def runner(cmd, **kw): + return _Proc(returncode=0, + stdout='[{"Name":"real-dev-1","Service":"dev-1"}]') + + assert opener.resolve_container_name("dev", "carmo", 1, runner=runner) \ + == "real-dev-1" + + +def test_parse_compose_ps_name_empty_and_invalid(): + assert opener._parse_compose_ps_name("") is None + assert opener._parse_compose_ps_name("not json") is None + assert opener._parse_compose_ps_name("[]") is None def test_resolve_workdir_prefers_work_dir_env(): From 4da65f45f01ecd083b3566ad4a432b23aee12ec7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 13:21:51 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(up):=20plain=20SSH=20=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E6=8F=90=E7=A4=BA=E3=82=92=20which=20?= =?UTF-8?q?=E9=9D=9E=E4=BE=9D=E5=AD=98=E5=8C=96=20+=20=E5=AE=9F=E5=90=8D?= =?UTF-8?q?=E5=95=8F=E3=81=84=E5=90=88=E3=82=8F=E3=81=9B=E3=81=AB=20compos?= =?UTF-8?q?e=20file=20(-f)=20=E3=82=92=E6=B8=A1=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-review round2 codex 指摘 C/D 対応。 C[正確性]: plain SSH の degrade (手元コマンド提示) は decide_action が editor_cmd None で先頭 skip するため、リモートに code が無いと print_command へ到達せず skip していた。提示コマンドは手元(ローカル)で実行する前提なので リモートの code 実在に依存させない。 - resolve_editor_display() を追加 (which 非依存・必ず非None) - decide_action(ctx, editor_available: bool) へシグネチャ変更。SSH の print_command は editor_available に依存せず到達、launch 系経路のみ editor 不在で skip。 - open_editor は launch=editor / print=display を使い分け。 D[正確性]: _query_container_name が docker compose ps に override compose を 渡さず base compose.yml に無い {dev}-{index} を見てほぼ常にフォールバック していた。 - _query_container_name / resolve_container_name / open_editor / _maybe_open_editor に compose_file を追加し起動時と同じ -f を伝播。 - cmd_up は [6/6] で override_file を渡す。 テスト追加・更新 (766 passed)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 16 +++- lib/devbase/editor/opener.py | 86 +++++++++++++++---- tests/cli/test_project_dispatch.py | 14 ++++ tests/editor/test_opener.py | 127 +++++++++++++++++++++++++++-- 4 files changed, 216 insertions(+), 27 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index ccf91ab..6c617d1 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -356,7 +356,8 @@ def _auto_snapshot() -> None: def _maybe_open_editor(project_name: str, open_flag: Optional[bool], - open_index: Optional[int], scale: int) -> None: + open_index: Optional[int], scale: int, + compose_file=None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env @@ -365,6 +366,10 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 警告を出して既定 (1) へフォールバックする。 + + ``compose_file`` は実コンテナ名問い合わせ用の override compose。``up`` 起動時と + 同じファイルを渡さないと ``{dev}-{index}`` サービスが見えず実名取得に失敗する。 + 未指定なら ``.docker-compose.scale.yml`` が存在すればそれ、無ければ None。 """ from devbase.editor import opener @@ -387,6 +392,11 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], ) open_index = 1 + # 実コンテナ名問い合わせ用の 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 = opener.resolve_workdir(os.environ, project_name) logger.info("[6/6] Opening editor attached to the dev container...") @@ -396,6 +406,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], dev_service_name=dev_service_name, workdir=workdir, index=open_index, + compose_file=compose_file, ) except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) @@ -470,7 +481,8 @@ def cmd_up(project_name: str = None, scale: int = None, if deploy_script.exists() and deploy_script.is_file(): _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) - _maybe_open_editor(project_name, open_editor, open_index, scale) + _maybe_open_editor(project_name, open_editor, open_index, scale, + compose_file=override_file) logger.info("=== Deploy completed successfully ===") return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 188e1e1..23ab74d 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -123,6 +123,26 @@ def resolve_editor_cmd(environ=None) -> Optional[list]: return None +def resolve_editor_display(environ=None) -> list: + """コマンド提示 (print_command) 用のエディタ argv を解決する。 + + :func:`resolve_editor_cmd` と異なり ``shutil.which`` による実在チェックは + 行わない。plain SSH では提示コマンドを実行するのは「ユーザの手元 (ローカル)」 + であり、コマンドを実行している側 (リモート) に ``code`` が存在する必要は無い + ため、リモートの実在に依存せず必ず非 None を返す。 + + ``DEVBASE_EDITOR`` があればそれを (シェル風に分割して) 用い、無ければ既定の + ``["code"]`` を返す。 + """ + env = os.environ if environ is None else environ + explicit = env.get("DEVBASE_EDITOR") + if explicit: + parts = shlex.split(explicit) + if parts: + return parts + return ["code"] + + def build_attach_uri(container_name: str, workdir: str) -> str: """``vscode-remote://attached-container+/`` を組む。 @@ -175,19 +195,28 @@ def _parse_compose_ps_name(stdout: str) -> Optional[str]: def _query_container_name(dev_service_name: str, index: int, + compose_file=None, runner: Optional[Callable] = None) -> Optional[str]: """実 docker へ問い合わせて dev インスタンスの実コンテナ名を取得する (保険)。 scale 生成 compose ではサービス名が ``{dev}-{index}`` (例 ``dev-1``) になるため その service token を指定して ``docker compose ps --format json`` を実行する。 + ``{dev}-{index}`` サービスは override compose (``.docker-compose.scale.yml``) + 側にしか存在しないため、``compose_file`` が与えられた場合は起動時と同じ + ``-f `` を付与しないと base ``compose.yml`` には無いサービスを + 見に行きほぼ常にフォールバックになる。 取得できなければ None。docker 不在・非0・例外・空はすべて None に握り潰し、 呼び出し側が決定的名へフォールバックできるようにする。 """ run = runner or subprocess.run service_token = f"{dev_service_name}-{index}" + cmd = ["docker", "compose"] + if compose_file is not None: + cmd += ["-f", str(compose_file)] + cmd += ["ps", "--format", "json", service_token] try: proc = run( - ["docker", "compose", "ps", "--format", "json", service_token], + cmd, capture_output=True, text=True, timeout=10, ) except Exception: # noqa: BLE001 - docker 不在等は保険なので握り潰す @@ -201,6 +230,7 @@ def _query_container_name(dev_service_name: str, index: int, def resolve_container_name(dev_service_name: str, project_name: str, index: int = 1, + compose_file=None, runner: Optional[Callable] = None) -> str: """dev コンテナの実コンテナ名を返す。 @@ -213,7 +243,8 @@ def resolve_container_name(dev_service_name: str, project_name: str, index: int を全インスタンスへ設定する (volume/compose.py)。COMPOSE_PROJECT_NAME は project_name と一致するため、docker 問い合わせに失敗しても決定的に組み立てられる。 """ - queried = _query_container_name(dev_service_name, index, runner=runner) + queried = _query_container_name(dev_service_name, index, + compose_file=compose_file, runner=runner) if queried: return queried return f"{project_name}-{dev_service_name}-{index}" @@ -229,24 +260,37 @@ def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: return f"/work/{repo}" if repo else "/work" -def decide_action(ctx: EditorContext, editor_cmd: Optional[list]) -> OpenPlan: - """コンテキストとエディタ可用性から起動方針を決める (§2.4 マトリクス)。""" - if not editor_cmd: - return OpenPlan( - "skip", - "エディタ (code) が見つかりません。VS Code の `code` コマンドを PATH に " - "通すか DEVBASE_EDITOR を設定してください", - ) +_NO_EDITOR_REASON = ( + "エディタ (code) が見つかりません。VS Code の `code` コマンドを PATH に " + "通すか DEVBASE_EDITOR を設定してください" +) + + +def decide_action(ctx: EditorContext, editor_available: bool) -> OpenPlan: + """コンテキストとエディタ可用性から起動方針を決める (§2.4 マトリクス)。 + + ``editor_available`` はローカルに launch 可能な ``code`` 系コマンドが実在するか + (``resolve_editor_cmd`` が非 None か) を表す。plain SSH の print_command 経路は + 「ユーザの手元 (ローカル) でコマンドを実行する」前提のため、コマンドを実行して + いる側 (リモート) の editor 実在には依存させない (``editor_available`` を見ない)。 + """ if not ctx.is_tty: return OpenPlan("skip", "非対話 (非TTY/CI) 環境のため") if ctx.in_vscode: # VS Code 統合ターミナル (ローカル / WSL / Remote-SSH シム)。code が - # クライアント側へ委譲するため直接起動でよい。 + # クライアント側へ委譲するため直接起動でよい。code シムが無いと委譲 + # できないため editor が無ければ skip。 + if not editor_available: + return OpenPlan("skip", _NO_EDITOR_REASON) return OpenPlan("launch", "VS Code 統合ターミナル経由") if ctx.is_ssh: # plain SSH (VS Code 外)。クライアントへ push する公式手段が無いため - # コマンドを提示する degrade。 + # 手元で叩くコマンドを提示する degrade。提示先はローカルなのでリモートの + # editor 実在には依存しない。 return OpenPlan("print_command", "SSH セッション (VS Code 外) のため") + # ローカル/WSL 端末。直接 launch するため editor が無ければ skip。 + if not editor_available: + return OpenPlan("skip", _NO_EDITOR_REASON) return OpenPlan("launch", "ローカル/WSL 端末") @@ -259,29 +303,34 @@ def _launch(cmd: list, env: dict) -> None: def open_editor(*, project_name: str, dev_service_name: str, workdir: str, - index: int = 1, environ=None, + index: int = 1, compose_file=None, environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, launcher: Optional[Callable[[list, dict], None]] = None) -> str: """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 戻り値は実行された action ('launch' | 'print_command' | 'skip')。例外は 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / - ``system`` は :func:`detect_context` への差し替え口 (テスト用)。 + ``system`` は :func:`detect_context` への差し替え口 (テスト用)。``compose_file`` + は実コンテナ名問い合わせ時に起動と同じ override compose を ``-f`` で渡すため。 """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system) - editor = resolve_editor_cmd(env) - plan = decide_action(ctx, editor) + editor = resolve_editor_cmd(env) # launch 用 (which 込み・None あり得る) + display = resolve_editor_display(env) # print 用 (必ず非 None) + plan = decide_action(ctx, editor_available=bool(editor)) - container = resolve_container_name(dev_service_name, project_name, index) + container = resolve_container_name(dev_service_name, project_name, index, + compose_file=compose_file) uri = build_attach_uri(container, workdir) if plan.action == "skip": logger.info("エディタの自動オープンをスキップ: %s", plan.reason) return "skip" - quoted = " ".join(shlex.quote(c) for c in editor) if plan.action == "print_command": + # 提示コマンドは手元 (ローカル) で実行する前提。ローカルに code が無くても + # 提示できるよう display (which 非依存) を用いる。 + quoted = " ".join(shlex.quote(c) for c in display) logger.info("SSH セッションを検出しました (%s)。", plan.reason) logger.info( "手元の VS Code で次を実行するか、VS Code の Remote-SSH 統合ターミナルから " @@ -290,6 +339,7 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, logger.info(" %s --folder-uri '%s'", quoted, uri) return "print_command" + quoted = " ".join(shlex.quote(c) for c in editor) logger.info("[editor] %s を起動します (%s)", quoted, plan.reason) cmd = [*editor, "--folder-uri", uri] try: diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 1756ae6..287fd5a 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -417,3 +417,17 @@ def test_maybe_open_editor_valid_index_within_scale(monkeypatch): lambda **kw: called.append(kw) or 'launch') container._maybe_open_editor('carmo', True, 2, 3) assert called[0]['index'] == 2 + + +def test_maybe_open_editor_forwards_compose_file(monkeypatch): + """compose_file 引数が open_editor まで伝播する (実コンテナ名問い合わせ用)。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + called = [] + monkeypatch.setattr(opener, 'open_editor', + lambda **kw: called.append(kw) or 'launch') + container._maybe_open_editor('carmo', True, 1, 1, + compose_file='override.yml') + assert called[0]['compose_file'] == 'override.yml' diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index d9796ab..62be75d 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -93,6 +93,23 @@ def test_resolve_editor_cmd_explicit_missing(monkeypatch): assert opener.resolve_editor_cmd({"DEVBASE_EDITOR": "nope"}) is None +# --------------------------------------------------------------------------- +# resolve_editor_display (print_command 用・which 非依存) +# --------------------------------------------------------------------------- + +def test_resolve_editor_display_default_code(monkeypatch): + # ローカルに code が無くても (which=None) 既定の ["code"] を返す + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + assert opener.resolve_editor_display({}) == ["code"] + + +def test_resolve_editor_display_explicit_without_which(monkeypatch): + # DEVBASE_EDITOR があれば実在チェックなしでそのまま分割して返す + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + assert opener.resolve_editor_display({"DEVBASE_EDITOR": "cursor --reuse-window"}) \ + == ["cursor", "--reuse-window"] + + # --------------------------------------------------------------------------- # build_attach_uri # --------------------------------------------------------------------------- @@ -157,6 +174,55 @@ def runner(cmd, **kw): == "real-dev-1" +def test_query_container_name_passes_compose_file_f_flag(): + """compose_file を渡すと docker compose ps argv に -f が差し込まれる。""" + captured = {} + + def runner(cmd, **kw): + captured["cmd"] = cmd + return _Proc(returncode=0, stdout='{"Name":"real-dev-1"}\n') + + name = opener._query_container_name( + "dev", 1, compose_file=".docker-compose.scale.yml", runner=runner) + assert name == "real-dev-1" + cmd = captured["cmd"] + assert cmd[:2] == ["docker", "compose"] + assert "-f" in cmd + assert cmd[cmd.index("-f") + 1] == ".docker-compose.scale.yml" + # service token は最後尾に置かれる + assert cmd[-1] == "dev-1" + # -f は ps サブコマンドより前 + assert cmd.index("-f") < cmd.index("ps") + + +def test_query_container_name_omits_f_flag_when_no_compose_file(): + """compose_file 未指定なら -f を付けない (従来挙動)。""" + captured = {} + + def runner(cmd, **kw): + captured["cmd"] = cmd + return _Proc(returncode=0, stdout='{"Name":"real-dev-1"}\n') + + opener._query_container_name("dev", 1, runner=runner) + assert "-f" not in captured["cmd"] + + +def test_resolve_container_name_forwards_compose_file(): + """resolve_container_name が compose_file を _query_container_name へ伝播する。""" + captured = {} + + def runner(cmd, **kw): + captured["cmd"] = cmd + return _Proc(returncode=0, stdout='{"Name":"real-dev-2"}\n') + + name = opener.resolve_container_name( + "dev", "carmo", 2, compose_file="override.yml", runner=runner) + assert name == "real-dev-2" + cmd = captured["cmd"] + assert "-f" in cmd + assert cmd[cmd.index("-f") + 1] == "override.yml" + + def test_parse_compose_ps_name_empty_and_invalid(): assert opener._parse_compose_ps_name("") is None assert opener._parse_compose_ps_name("not json") is None @@ -185,30 +251,43 @@ def _ctx(**kw): return opener.EditorContext(**base) -def test_decide_skip_no_editor(): - assert opener.decide_action(_ctx(), None).action == "skip" +def test_decide_skip_no_editor_local(): + # ローカル (launch 経路) で editor が無ければ skip + assert opener.decide_action(_ctx(), editor_available=False).action == "skip" + + +def test_decide_skip_no_editor_in_vscode(): + # VS Code 統合端末でも code シムが無ければ委譲できないため skip + plan = opener.decide_action(_ctx(in_vscode=True), editor_available=False) + assert plan.action == "skip" def test_decide_skip_non_tty(): - assert opener.decide_action(_ctx(is_tty=False), ["code"]).action == "skip" + assert opener.decide_action(_ctx(is_tty=False), editor_available=True).action == "skip" def test_decide_launch_local(): - assert opener.decide_action(_ctx(), ["code"]).action == "launch" + assert opener.decide_action(_ctx(), editor_available=True).action == "launch" def test_decide_launch_wsl(): - assert opener.decide_action(_ctx(is_wsl=True), ["code"]).action == "launch" + assert opener.decide_action(_ctx(is_wsl=True), editor_available=True).action == "launch" def test_decide_launch_in_vscode_even_under_ssh(): # Remote-SSH 統合端末: in_vscode が ssh より優先され launch - plan = opener.decide_action(_ctx(in_vscode=True, is_ssh=True), ["code"]) + plan = opener.decide_action(_ctx(in_vscode=True, is_ssh=True), editor_available=True) assert plan.action == "launch" def test_decide_print_command_plain_ssh(): - plan = opener.decide_action(_ctx(is_ssh=True), ["code"]) + plan = opener.decide_action(_ctx(is_ssh=True), editor_available=True) + assert plan.action == "print_command" + + +def test_decide_print_command_plain_ssh_without_local_editor(): + # plain SSH の提示は手元で実行する前提のためローカル editor 不在でも print_command + plan = opener.decide_action(_ctx(is_ssh=True), editor_available=False) assert plan.action == "print_command" @@ -255,6 +334,40 @@ def test_open_editor_print_command_under_plain_ssh(monkeypatch): assert calls == [] +def test_open_editor_print_command_without_local_editor(monkeypatch, caplog): + """plain SSH でローカルに code が無くても print_command になり提示コマンドを出す。""" + import logging + monkeypatch.setattr(opener.shutil, "which", lambda c: None) # ローカルに code なし + calls = [] + with caplog.at_level(logging.INFO): + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, isatty=True, + launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "print_command" + assert calls == [] + # 提示コマンドに code --folder-uri が含まれる + text = "\n".join(r.getMessage() for r in caplog.records) + assert "code --folder-uri" in text + + +def test_open_editor_print_command_uses_explicit_display_editor(monkeypatch, caplog): + """DEVBASE_EDITOR があれば print_command でも which 非依存でそれを提示する。""" + import logging + monkeypatch.setattr(opener.shutil, "which", lambda c: None) + with caplog.at_level(logging.INFO): + result = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22", + "DEVBASE_EDITOR": "cursor"}, + isatty=True, launcher=lambda cmd, env: None, + ) + assert result == "print_command" + text = "\n".join(r.getMessage() for r in caplog.records) + assert "cursor --folder-uri" in text + + def test_open_editor_launch_failure_is_swallowed(monkeypatch): monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") From 974e08f035edd419cdb7a6c67c27c576b103ff45 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 13:27:35 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(editor):=20=5Flaunch=20=E3=81=AB=20stdi?= =?UTF-8?q?n=3Dsubprocess.DEVNULL=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エディタをバックグラウンド起動する際 stdin を DEVNULL に向け、 エディタ側が stdin を要求しても親プロセス (devbase 実行ターミナル) の 入力を奪う/ハングするのを防ぐ。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 23ab74d..28b019c 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -298,6 +298,7 @@ def _launch(cmd: list, env: dict) -> None: """エディタを非ブロッキングで起動する (up プロセスを待たせない)。""" subprocess.Popen( # noqa: S603 - argv はコード生成で外部入力を渡さない cmd, env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )