diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 70662d1..9ba8a6a 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -149,8 +149,22 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く(既定: OFF) | | `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | +| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。下記「跨ホスト」参照 | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | 跨ホスト時に ssh 先で使う docker context(既定: ホストの `docker context show`) | +| `DEVBASE_OPEN_TERMINAL` | 真で `up` 後に folderOpen ターミナル用 `.vscode/tasks.json` を配置(**既定: ON**) | -都度の上書きは CLI フラグで行います: `devbase up --open` / `devbase up --no-open` / `devbase up --open-index N`(env より優先)。 +都度の上書きは CLI フラグで行います: `devbase up --open` / `--no-open` / `--open-index N` / `--open-terminal` / `--no-open-terminal`(env より優先)。 + +### 起動時に統合ターミナルを自動表示(`DEVBASE_OPEN_TERMINAL`) + +`up` 時に、開く dev コンテナのワークスペース(`/work/$GIT_REPO`)へ folderOpen タスク(`.vscode/tasks.json`)を `docker exec` で配置します(既存があれば変更しません)。VS Code はフォルダを開いた時にこのタスクで統合ターミナルを前面表示します。 + +VS Code 公式には「起動時にターミナルを開く」専用設定が無く、folderOpen タスクが唯一の方法です。なお自動実行には次の 2 つの **VS Code クライアント側ユーザー設定**が関わり、devbase からは制御できません(いずれも application/user スコープ専用): + +- **Workspace Trust**: 信頼していないフォルダではタスクは自動実行されません(初回は「フォルダを信頼」が必要)。 +- **`task.allowAutomaticTasks`**: 既定 `off` ではフォルダごとに 1 回「自動タスクを許可」を尋ねます。`on` にするとプロンプト無しで実行されます。 + +→ 実際は「初回のみ信頼(+許可)クリック、以降は自動でターミナルが開く」挙動になります。無効化は `DEVBASE_OPEN_TERMINAL=0` または `devbase up --no-open-terminal`。 ### 実行コンテキスト別の挙動 @@ -158,11 +172,31 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 |------|------| | ローカル端末(Mac/Linux) | ローカル VS Code が開く | | WSL 端末 | Windows 側 VS Code が開く(`code` ラッパ経由) | -| VS Code の Remote-SSH 統合ターミナル | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | +| VS Code の Remote-SSH 統合ターミナル(同一ホストの Docker) | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | +| VS Code の Remote-SSH 統合ターミナル(**跨ホスト**: ssh 先の Docker にコンテナ) | `DEVBASE_EDITOR_SSH_HOST` 設定時にネスト URI で開く(下記「跨ホスト」参照) | | 手元から素の SSH(VS Code 外)で接続中 | クライアントへ自動で開く公式手段が無いため、手元で実行する `code --folder-uri ...` コマンドを提示 | | CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | -> SSH 越しに「手元の VS Code」を自動で開きたい場合は、手元の VS Code から **Remote-SSH で接続した統合ターミナル内**で `devbase up` を実行してください。そのターミナルの `code` はクライアント側 VS Code に委譲するため、リモートホスト上のコンテナへ接続した窓が手元に開きます。 +#### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) + +手元(例 Windows)の VS Code から Remote-SSH で別ホスト(例 Mac)へ入り、その統合ターミナルで `devbase up` を実行する構成では、コンテナは **ssh 先(Mac)の Docker** 上にあります。このとき `code` の開く要求はクライアント(Windows)へ委譲されるため、フラットな attach URI のままだと **クライアント側の Docker** を見に行きコンテナが見つかりません(「コンテナーにアタッチできません。すでに存在しません」)。 + +これを解決するには、ネスト URI `vscode-remote://attached-container+@ssh-remote+/work/...` を使い、docker ルックアップを ssh 先(コンテナのある Mac)で行わせます。`` は **手元 `~/.ssh/config` の `Host` 別名**(例 `mac2`)で、これは「今の VS Code 接続の authority ラベル」と完全一致する必要があります(ネスト attach は新規 ssh 接続を張らず既存接続を再利用するため。IP や `user@IP` は "Parent authority found without ExecServer" で不可)。 + +このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の VS Code 系サーバーディレクトリ(`~/.vscode-server` / `~/.cursor-server` / `~/.vscode-server-insiders` 等)の File History から自動検出**します(`DEVBASE_EDITOR` で cursor 等を使う場合も横断)。よって**通常は設定不要**です。docker context は `docker context show` から自動取得します。 + +自動検出が外れる場合(複数 ssh-remote ホストを使い分けている等)のみ明示します: + +```sh +# $DEVBASE_ROOT/env など(全プロジェクト共通にしたい場合) +DEVBASE_EDITOR_SSH_HOST=mac2 +# 必要なら docker context も明示 +# DEVBASE_EDITOR_DOCKER_CONTEXT=desktop-linux +``` + +解決順は **`DEVBASE_EDITOR_SSH_HOST` 明示 → `~/.vscode-server` 自動検出 → フラット URI**。 + +> 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では ssh-remote ホストは付かず、従来どおりフラット URI で開きます。 ## ソースファイル変更検出 diff --git a/issues/PLAN31_3_up-open-editor.md b/issues/PLAN31_3_up-open-editor.md index dbbc553..cd15965 100644 --- a/issues/PLAN31_3_up-open-editor.md +++ b/issues/PLAN31_3_up-open-editor.md @@ -41,11 +41,24 @@ vscode-remote://attached-container+/work/$GIT_REPO `` は **`{"containerName":"/<実コンテナ名>"}` を UTF-8 hex 化**した文字列。 (単純な名前の hex ではない点に注意。Docker 内部のコンテナ名は先頭 `/` 付き。) -### 2.3 ネスト authority は公式未サポート +### 2.3 ネスト authority(**実機で動作することを確認・当初想定を訂正**) -`ssh-remote+` と `attached-container+...` を 1 本の URI に合成する記法は -**存在しない**(microsoft/vscode#242489 は *Closed as not planned*)。 -→ 「リモートホスト上のコンテナ」を単発 `code` で直接指定する手段は無い。 +> ⚠️ 訂正(2026-06-13 実装時の実機検証)。当初は「合成記法は存在しない +> (microsoft/vscode#242489 *not planned*)」と記載していたが、**誤り**だった。 + +`attached-container+@ssh-remote+` という**ネスト authority は実際に +サポートされており動作する**(VS Code 1.124.2 / Dev Containers 0.459.1 で確認)。 +正常動作中の窓の resource URI を採取したところ: + +``` +vscode-remote://attached-container+@ssh-remote+mac2/work/... +hex = {"containerName":"/","settings":{"context":"desktop-linux"}} +``` + +`@ssh-remote+` を付けると docker ルックアップが **ssh 先(コンテナのある +ホスト)** で行われるため、跨ホスト(手元 Windows VS Code → ssh → Mac のコンテナ) +でも単発 `code --folder-uri` で直接アタッチできる。`settings.context` は ssh 先で +使う docker context を指定する。 ### 2.4 結論(実行コンテキスト別マトリクス) @@ -53,12 +66,19 @@ vscode-remote://attached-container+/work/$GIT_REPO |---|---|---| | 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` コマンドを提示するのが上限 | +| VS Code **Remote-SSH 統合端末**(リモート=Mac・**同一ホストの Docker**) | ✓ (クライアント側) | `code` シムが委譲。同一ホストの Docker にコンテナがある場合はフラット URI で解決 | +| VS Code **Remote-SSH 統合端末**(**跨ホスト**: ssh 先 Mac の Docker にコンテナ) | ✓ (host 自動検出) | フラット URI だとクライアント(Windows)の Docker を見て失敗。**ネスト URI `@ssh-remote+`(§2.3)で ssh 先の Docker を解決**。`` は **ssh 先 `~/.vscode-server` の File History から自動検出**(`DEVBASE_EDITOR_SSH_HOST` で上書き可)。IP/`user@IP` は既存 ExecServer 不一致で不可、別名一致が必須 | +| plain SSH(WSL→ssh→Mac 等、VS Code 外) | ✗ → コマンド表示 | IPC hook 無し。手元で叩く `code` コマンドを提示するのが上限 | | CI / 非TTY / `code` 不在 | ✗ → info スキップ | エディタ起動の前提を満たさない | -→ ユーザ理想チェーンは **「手元 VS Code で Remote-SSH→Mac に入った統合ターミナルで -`devbase up`」の場合に自動成立**。plain ssh の場合は正直にコマンド提示で degrade する。 +→ 跨ホスト(手元 Windows VS Code → Remote-SSH→Mac で `devbase up`、コンテナは Mac の +Docker)が最頻ユースケース。ssh ホスト名(クライアント `~/.ssh/config` の Host 別名)は +VS Code が ssh 先端末 env に渡さない(`SSH_CONNECTION` は IP のみ)が、**ssh 先 Mac の +`~/.vscode-server` の File History(resource URI の `ssh-remote%2B`)から自動検出** +できるため**通常は設定不要で自動成立**する。検出が外れる場合のみ `DEVBASE_EDITOR_SSH_HOST` +で上書き。IP/`user@IP` は「ネスト attach は新規 ssh 接続を張らず既存 ExecServer の authority +と一致が必須」のため不可(実機: "Parent authority found without ExecServer")。 +plain ssh はコマンド提示で degrade。 ## 3. 既存コード調査結果 @@ -156,12 +176,22 @@ env 解釈は既存 `_parse_env_assignment`(`container.py:121`)に合わせ `--no-open`/`DEVBASE_OPEN_EDITOR=0` で呼ばれないこと - 既存 706 passed を維持 -## 8. リスク・未確定 - -- **plain SSH では自動オープン不可**(§2.3 公式未サポート)。コマンド提示で degrade。 - この制約は README に明記する -- VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要 - (`/ndf:investigation-rules`: 実機未検証の挙動は「推定」と明示) +## 8. リスク・未確定(実機検証で更新) + +- ~~VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要~~ + → **検証済み(2026-06-13)**。跨ホストではフラット URI だと失敗し、ネスト URI + `@ssh-remote+` + `settings.context` で成立することを確認(§2.3/§2.4 を訂正)。 +- ssh ホスト名は env には来ないが **`~/.vscode-server` の File History から自動検出**する + ようにした(実機: ホストは `ssh-remote%2Bmac2` の 1 種のみで一意に取れた)。検出は VS Code + 内部データ依存のヒューリスティック(multi-host / バージョン差で外し得る)で、最新 mtime の + entries.json を優先し、外れる場合は `DEVBASE_EDITOR_SSH_HOST` 明示・最終的にフラット URI へ degrade。 +- IP / `user@IP` でのネスト attach は不可(既存 Remote-SSH 接続=ExecServer の authority ラベルと + 完全一致が必須。新規 ssh 接続は張らない)。実機で "Parent authority found without ExecServer" を確認。 +- **plain SSH(VS Code 外)では自動オープン不可**。コマンド提示で degrade(変更なし)。 +- 統合ターミナル自動表示は `.vscode/tasks.json`(folderOpen) 配置で実現。VS Code 公式に + 起動時ターミナル設定は無く(`hideOnStartup` は復元セッションの表示制御のみ)folderOpen + が唯一。自動実行は Workspace Trust と `task.allowAutomaticTasks`(共に user スコープ専用・ + devbase 制御外)に依存し、初回のみ承認クリックが要る。 - `code` ラッパの非ブロッキング起動が `up` プロセス終了をブロックしないこと確認 ## 9. 参考(一次情報) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 5248bdf..31c9702 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -112,6 +112,13 @@ def _add_open_args(parser): parser.add_argument('--open-index', dest='open_index', type=int, default=None, metavar='N', help='Container index to open (default: 1)') + parser.add_argument('--open-terminal', dest='open_terminal', action='store_true', + default=None, + help='Place .vscode/tasks.json so the integrated terminal ' + 'auto-opens on folder open (overrides DEVBASE_OPEN_TERMINAL)') + parser.add_argument('--no-open-terminal', dest='open_terminal', action='store_false', + help='Do not place the folderOpen terminal tasks.json ' + '(overrides DEVBASE_OPEN_TERMINAL)') return parser diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 6c617d1..99f9ace 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -290,7 +290,8 @@ def _dispatch_lifecycle(args) -> int: 'up': lambda: cmd_up(project_name=project_name, scale=getattr(args, 'scale', None), open_editor=getattr(args, 'open_editor', None), - open_index=getattr(args, 'open_index', None)), + open_index=getattr(args, 'open_index', None), + open_terminal=getattr(args, 'open_terminal', None)), 'down': lambda: cmd_down(), 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), @@ -355,9 +356,31 @@ def _auto_snapshot() -> None: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) +def _resolve_open_index(open_index: Optional[int], scale: int) -> int: + """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 + + ``1..scale`` の範囲外 (0・負数・``scale`` 超過) は存在しないコンテナを指し原因不明な + 起動失敗を招くため、警告を出して 1 へフォールバックする。:func:`_maybe_place_terminal_task` + と :func:`_maybe_open_editor` で共有し 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 + 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 + return open_index + + def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - compose_file=None) -> None: + compose_file=None, container_name: Optional[str] = None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env @@ -370,6 +393,10 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], ``compose_file`` は実コンテナ名問い合わせ用の override compose。``up`` 起動時と 同じファイルを渡さないと ``{dev}-{index}`` サービスが見えず実名取得に失敗する。 未指定なら ``.docker-compose.scale.yml`` が存在すればそれ、無ければ None。 + + ``container_name`` が渡されれば :func:`opener.open_editor` 内の + ``resolve_container_name`` (= ``docker compose ps``) をスキップして再利用する + (``_maybe_place_terminal_task`` が既に解決済みの名前を使い回し二重実行を避ける)。 """ from devbase.editor import opener @@ -377,20 +404,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], 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 - - # 起動済みインスタンス範囲 (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 + open_index = _resolve_open_index(open_index, scale) # 実コンテナ名問い合わせ用の compose file: 明示指定がなければ override が # 存在すればそれを使う (起動時と同じ file を docker compose ps へ渡す)。 @@ -407,14 +421,75 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], workdir=workdir, index=open_index, compose_file=compose_file, + container_name=container_name, ) except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) +def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], + open_index: Optional[int], scale: int, + compose_file=None) -> Optional[str]: + """`up` 後、開く dev コンテナの作業ディレクトリへ folderOpen ターミナル tasks.json を配置。 + + フォルダを開いた時に統合ターミナルを自動表示するための ``.vscode/tasks.json`` を、 + 対象 dev インスタンスのワークスペース (``/work/$GIT_REPO``) に置く。作業ディレクトリは + コンテナ内 (named volume) のためホストから直接書けず、起動済みコンテナへ ``docker exec`` + で書き込む。**既存の ``.vscode/tasks.json`` があれば一切触らない**。 + + 有効判定は ``open_flag`` (CLI ``--open-terminal``/``--no-open-terminal``) が優先、None なら + env ``DEVBASE_OPEN_TERMINAL`` (既定 ON)。配置失敗は warning に握り潰し ``up`` を倒さない。 + ``open_index`` は開くインスタンスに合わせる (範囲外は 1 へフォールバック)。 + + 解決した実コンテナ名を返す (無効時は None)。直後の :func:`_maybe_open_editor` へ渡して + ``resolve_container_name`` (= ``docker compose ps``) の二重実行を避けるため。 + """ + from devbase.editor import opener + + enabled = open_flag if open_flag is not None else opener.is_open_terminal_enabled() + if not enabled: + return None + + open_index = _resolve_open_index(open_index, scale) + + if compose_file is None and _SCALE_COMPOSE_FILE.exists(): + compose_file = _SCALE_COMPOSE_FILE + + dev_service_name = get_dev_service_name() + container = opener.resolve_container_name(dev_service_name, project_name, + open_index, compose_file=compose_file) + workdir = opener.resolve_workdir(os.environ, project_name) + content = opener.build_folder_open_tasks_json() + + # 既存があれば書かず、無ければ stdin から書き込む (冪等)。workdir は引数で渡し + # シェル内クォートを避ける ($1)。 + script = ( + 'set -e; d="$1/.vscode"; mkdir -p "$d"; ' + 'if [ -e "$d/tasks.json" ]; then echo keep; ' + 'else cat > "$d/tasks.json"; echo placed; fi' + ) + try: + proc = subprocess.run( + ["docker", "exec", "-i", container, "sh", "-c", script, "_", workdir], + input=content, text=True, capture_output=True, timeout=15, + ) + except Exception as e: # noqa: BLE001 - 配置失敗で up を倒さない + logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", e) + return container # 名前解決は済んでいるのでエディタ側で再利用させる + if proc.returncode != 0: + logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", + (proc.stderr or "").strip()) + return container + if (proc.stdout or "").strip() == "placed": + logger.info("[6/6] 統合ターミナル自動表示用 tasks.json を配置: %s/.vscode/tasks.json", + workdir) + return container + + def cmd_up(project_name: str = None, scale: int = None, open_editor: Optional[bool] = None, - open_index: Optional[int] = None) -> int: + open_index: Optional[int] = None, + open_terminal: Optional[bool] = None) -> int: """Deploy containers with specified scale""" if project_name is None: project_name = get_project_name() @@ -481,8 +556,12 @@ 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)) + # エディタを開く前に tasks.json を置く (開いた瞬間に folderOpen が効くように)。 + # 解決済みコンテナ名を editor 側へ渡し docker compose ps の二重実行を避ける。 + dev_container = _maybe_place_terminal_task(project_name, open_terminal, open_index, + scale, compose_file=override_file) _maybe_open_editor(project_name, open_editor, open_index, scale, - compose_file=override_file) + compose_file=override_file, container_name=dev_container) logger.info("=== Deploy completed successfully ===") return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 28b019c..7992664 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -10,9 +10,15 @@ 応じた正しいクライアントへ開ける。 - コンテナ attach URI は ``{"containerName":"/<実コンテナ名>"}`` を hex 化した authority を持つ (:func:`build_attach_uri`)。 -- ``ssh-remote+host`` と ``attached-container+...`` を 1 本に合成する記法は - 公式未サポート (microsoft/vscode#242489)。よって VS Code 外の plain SSH では - クライアントへ push できず、手元で叩くコマンドを提示する degrade に留める。 +- **跨ホスト (手元 VS Code → Remote-SSH(host) → ssh 先の Docker 上コンテナ) では + ネスト authority ``attached-container+...@ssh-remote+`` を用いる**。これは + 実機で動作する (VS Code 1.124 / Dev Containers 0.459 で確認。当初 microsoft/vscode#242489 + を「未サポート」と解釈していたが誤りだった)。```` は手元クライアントの ssh 接続 + ラベルで、env には現れないため ssh 先の ``~/.vscode-server`` 等の File History から + 自動検出する (:func:`resolve_editor_ssh_host`)。``settings.context`` で ssh 先 docker + context を指定する。 +- VS Code 外の plain SSH は既存 ExecServer を前提にできずネスト URI が動かないため、 + 自動検出は行わず (明示設定時のみ)、手元で叩くコマンドを提示する degrade に留める。 本モジュールの関数は実 docker / VS Code を必要とせず、``environ`` 等を引数で 差し替えてテストできるよう副作用を :func:`open_editor` に集約している。 @@ -23,6 +29,7 @@ import json import os import platform +import re import shlex import shutil import subprocess @@ -37,6 +44,23 @@ # DEVBASE_OPEN_EDITOR を真と解釈する値 (大小無視) _TRUTHY = {"1", "true", "yes", "on"} +# resource URI 中の ssh-remote authority ラベルを拾う ('+' は URL エンコードで %2B)。 +_SSH_REMOTE_RE = re.compile(r"ssh-remote(?:\+|%2[Bb])([A-Za-z0-9._@-]+)") + +# ssh host 自動検出で探索する VS Code 系サーバーディレクトリ (DEVBASE_EDITOR で +# code / code-insiders / cursor / vscodium 等を使い分けても拾えるよう横断する)。 +_SERVER_DIR_CANDIDATES = ( + "~/.vscode-server", + "~/.vscode-server-insiders", + "~/.cursor-server", + "~/.vscodium-server", + "~/.windsurf-server", +) + +# ssh host 自動検出で内容を読む entries.json の上限 (mtime 降順で新しい方から)。 +# 該当ホストは直近接続のファイルにあるため、無マッチ時に全件 read するのを防ぐ。 +_HISTORY_SCAN_LIMIT = 200 + @dataclass(frozen=True) class EditorContext: @@ -104,6 +128,56 @@ def is_open_enabled(environ=None) -> bool: return value.strip().lower() in _TRUTHY +def is_open_terminal_enabled(environ=None) -> bool: + """``DEVBASE_OPEN_TERMINAL`` env が真か (**未設定は True = 既定 ON**)。 + + ``DEVBASE_OPEN_EDITOR`` (既定 OFF) と既定が逆である点に注意。up 時の tasks.json 配置は + 暴発リスクが低く、ユーザ要望で既定 ON とする (PLAN31_3)。 + """ + env = os.environ if environ is None else environ + value = env.get("DEVBASE_OPEN_TERMINAL") + if value is None: + return True + return value.strip().lower() in _TRUTHY + + +def build_folder_open_tasks_json() -> str: + """フォルダを開いた時に統合ターミナルを表示する folderOpen タスク (.vscode/tasks.json)。 + + VS Code 公式には「起動時にターミナルを開く」単独設定が無く (``hideOnStartup`` は復元 + された永続セッションを隠すか否かに過ぎず新規生成はしない)、``runOn: folderOpen`` の + タスクが新規ターミナルを出せる唯一の方法 (docs/terminal/*, docs/debugtest/tasks)。 + ``reveal: always`` でパネルを前面に出し対話シェルを起動する。``type: process`` で + ``/bin/sh -lc 'exec "${SHELL:-/bin/sh}"'`` を直接起動し、``$SHELL`` 未設定のコンテナでも + ``/bin/sh`` にフォールバックして必ずシェルが立ち上がるようにする (``type: shell`` + + ``${env:SHELL}`` だと未設定時に command が空になりタスクが即失敗する)。 + + .. note:: 自動実行には2つの user 設定ゲートがあり devbase からは制御できない: + Workspace Trust (信頼済みフォルダのみ自動実行) と ``task.allowAutomaticTasks`` + (既定 off = フォルダ毎に1回許可確認)。いずれも application/user スコープ専用。 + """ + tasks = { + "version": "2.0.0", + "tasks": [ + { + "label": "devbase: open terminal", + "type": "process", + "command": "/bin/sh", + "args": ["-lc", 'exec "${SHELL:-/bin/sh}"'], + "isBackground": True, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": True, + }, + "runOptions": {"runOn": "folderOpen"}, + } + ], + } + return json.dumps(tasks, indent=2, ensure_ascii=False) + "\n" + + def resolve_editor_cmd(environ=None) -> Optional[list]: """起動に使うエディタコマンド (argv list) を解決する。 @@ -143,16 +217,33 @@ def resolve_editor_display(environ=None) -> list: return ["code"] -def build_attach_uri(container_name: str, workdir: str) -> str: - """``vscode-remote://attached-container+/`` を組む。 +def build_attach_uri(container_name: str, workdir: str, + ssh_host: Optional[str] = None, + docker_context: Optional[str] = None) -> str: + """``vscode-remote://attached-container+[@ssh-remote+]/`` を組む。 + + ```` は ``{"containerName":"/"[,"settings":{"context":}]}`` + を UTF-8 hex 化したもの (Docker 内部のコンテナ名は先頭 ``/`` 付き)。 + + ``ssh_host`` を渡すと authority に ``@ssh-remote+`` を付ける。**Windows VS Code + → Remote-SSH() → Mac 上のコンテナ** のような跨ホスト構成では、フラットな + ``attached-container+...`` だけだと委譲先クライアント (Windows) のローカル Docker を + 見に行きコンテナが見つからない。``@ssh-remote+`` を付けると docker ルックアップが + ssh 先 (コンテナのある Mac) で行われ解決できる (実機検証済み。PLAN31_3 §2.3/§2.4 を + 更新)。``docker_context`` を渡すと payload に ``settings.context`` を埋め、ssh 先で + 使う docker context を明示する。 - ```` は ``{"containerName":"/"}`` を UTF-8 hex 化したもの - (Docker 内部のコンテナ名は先頭 ``/`` 付き)。 + いずれも省略すると従来のフラット URI (ローカル / WSL / Remote-SSH 同一ホスト) を返す。 """ - payload = json.dumps({"containerName": f"/{container_name}"}, separators=(",", ":")) - hexname = payload.encode("utf-8").hex() + payload: dict = {"containerName": f"/{container_name}"} + if docker_context: + payload["settings"] = {"context": docker_context} + hexname = json.dumps(payload, separators=(",", ":")).encode("utf-8").hex() + authority = f"attached-container+{hexname}" + if ssh_host: + authority += f"@ssh-remote+{ssh_host}" path = workdir if workdir.startswith("/") else f"/{workdir}" - return f"vscode-remote://attached-container+{hexname}{path}" + return f"vscode-remote://{authority}{path}" def _parse_compose_ps_name(stdout: str) -> Optional[str]: @@ -260,6 +351,123 @@ def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: return f"/work/{repo}" if repo else "/work" +def _detect_ssh_host_from_dirs(server_dirs) -> Optional[str]: + """複数の VS Code 系サーバーディレクトリの File History を横断して ssh-remote + authority ラベルを推測する。 + + Remote-SSH / attached-container 窓で開いたファイルの resource URI が + ``/data/User/History/*/entries.json`` に ``ssh-remote%2B`` (URL + エンコード) / ``ssh-remote+`` 形で残るため、そこから ```` (= クライアントの + 接続ラベル。例 ``mac2``) を回収する。 + + 全ディレクトリの ``entries.json`` 候補を **mtime 降順**で集め、**新しい方から 1 ファイル + ずつ読み、最初に ssh-remote ホストが見つかった時点で即 return** する (History が数千 + ファイルに膨れても全読み込みを避け、devbase up の遅延を防ぐ)。mtime 収集は stat のみで安価。 + 見つからなければ None。 + + .. note:: VS Code 内部データ依存のヒューリスティックで、バージョン差や multi-host 運用で + 外し得る。確実性が要る場合は ``DEVBASE_EDITOR_SSH_HOST`` を明示する (本関数より優先)。 + """ + candidates = [] # (mtime, path) + for base in server_dirs: + history = os.path.join(base, "data", "User", "History") + # ローカル履歴は History//entries.json の固定深さなので、os.walk で + # 全階層を再帰せず os.scandir で 1 階層下のみ走査して I/O を抑える。 + try: + with os.scandir(history) as it: + subdirs = [e.path for e in it if e.is_dir()] + except OSError: + continue + for sub in subdirs: + path = os.path.join(sub, "entries.json") # resource authority はここに載る + try: + candidates.append((os.path.getmtime(path), path)) + except OSError: + continue + newest_first = sorted(candidates, key=lambda t: t[0], reverse=True) + for _mtime, path in newest_first[:_HISTORY_SCAN_LIMIT]: + try: + with open(path, encoding="utf-8", errors="ignore") as f: + text = f.read() + except OSError: + continue + match = _SSH_REMOTE_RE.search(text) + if match: + return match.group(1) + return None + + +def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: + """単一サーバーディレクトリ版 (:func:`_detect_ssh_host_from_dirs` の薄ラッパ)。""" + return _detect_ssh_host_from_dirs([vscode_server_dir]) + + +def resolve_editor_ssh_host(environ=None, + vscode_server_dir: Optional[str] = None, + auto_detect: bool = True) -> Optional[str]: + """Remote-SSH ネスト URI 用の ssh ホスト名 (authority ラベル) を解決する。 + + 優先順位: + + 1. ``DEVBASE_EDITOR_SSH_HOST`` 明示 (最優先・確実) + 2. VS Code 系サーバーディレクトリ (``~/.vscode-server`` / ``~/.cursor-server`` / + ``~/.vscode-server-insiders`` 等) の File History からの自動推測 + (:func:`_detect_ssh_host_from_dirs`) + + ネスト attach は新規 ssh 接続を張らず **既存 Remote-SSH 接続 (ExecServer) の authority + ラベルと完全一致**する必要がある (実機確認: IP / user@IP は "Parent authority found + without ExecServer" で不可)。そのラベル (例 ``mac2``) はクライアント側の名前で SSH_CONNECTION + 等の env には現れない (IP のみ) ため、自動取得は VS Code が残す痕跡からの回収に頼る。 + どちらでも得られなければ None で :func:`build_attach_uri` はフラット URI に degrade する。 + + ``vscode_server_dir`` はテスト用の単一ディレクトリ差し替え口 (指定時はそれだけを探索)。 + ``auto_detect`` を False にすると 2 (自動推測) を行わず明示設定のみで判定する。plain SSH + (VS Code 外) は既存 ExecServer を前提にできずネスト URI が動かないため、呼び出し側 + (:func:`open_editor`) は ``in_vscode`` の時だけ ``auto_detect=True`` で呼ぶ。 + """ + env = os.environ if environ is None else environ + explicit = env.get("DEVBASE_EDITOR_SSH_HOST") + if explicit is not None: + # 明示設定を最優先。空文字 ("") は **自動推測のオプトアウト** (= None → + # フラット URI 強制) として扱い、`~/.vscode-server` 探索へ進ませない。 + return explicit.strip() or None + if not auto_detect: + return None + if vscode_server_dir is not None: + server_dirs = [vscode_server_dir] + else: + server_dirs = [os.path.expanduser(d) for d in _SERVER_DIR_CANDIDATES] + try: + return _detect_ssh_host_from_dirs(server_dirs) + except Exception: # noqa: BLE001 - 自動推測失敗で up を倒さない + return None + + +def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> Optional[str]: + """ssh 先で使う docker context を解決する。 + + ``DEVBASE_EDITOR_DOCKER_CONTEXT`` 明示があればそれ。無ければ devbase up を実行して + いるホスト (= コンテナのある Mac) の現在の docker context を ``docker context show`` + で取得する。docker 不在・非0・例外・空はすべて None (settings.context を付けない)。 + """ + env = os.environ if environ is None else environ + explicit = env.get("DEVBASE_EDITOR_DOCKER_CONTEXT") + if explicit is not None: + # 空文字 ("") は明示的オプトアウト (settings.context を付けない) として扱い、 + # `docker context show` を呼ばない。 + return explicit.strip() or None + run = runner or subprocess.run + try: + proc = run(["docker", "context", "show"], + capture_output=True, text=True, timeout=10) + except Exception: # noqa: BLE001 - docker 不在等は best-effort + return None + if getattr(proc, "returncode", 1) != 0: + return None + out = (proc.stdout or "").strip() + return out or None + + _NO_EDITOR_REASON = ( "エディタ (code) が見つかりません。VS Code の `code` コマンドを PATH に " "通すか DEVBASE_EDITOR を設定してください" @@ -304,7 +512,8 @@ def _launch(cmd: list, env: dict) -> None: def open_editor(*, project_name: str, dev_service_name: str, workdir: str, - index: int = 1, compose_file=None, environ=None, + index: int = 1, compose_file=None, container_name: Optional[str] = None, + environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, launcher: Optional[Callable[[list, dict], None]] = None) -> str: """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 @@ -313,6 +522,8 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / ``system`` は :func:`detect_context` への差し替え口 (テスト用)。``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を ``-f`` で渡すため。 + ``container_name`` が渡されれば :func:`resolve_container_name` (= ``docker compose + ps``) をスキップしてそれを使う (呼び出し側で解決済みの名前を使い回す)。 """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system) @@ -320,14 +531,23 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, 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, - compose_file=compose_file) - uri = build_attach_uri(container, workdir) - + # skip は URI 解決の前に early return する。skip 経路 (非 TTY/CI・code 不在等) で + # docker compose ps / docker context show 等の外部コマンドを無駄に叩かないため。 if plan.action == "skip": logger.info("エディタの自動オープンをスキップ: %s", plan.reason) return "skip" + container = container_name or resolve_container_name( + dev_service_name, project_name, index, compose_file=compose_file) + # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。自動推測は + # VS Code Remote-SSH 統合端末 (in_vscode) の時だけ有効にする — plain SSH (VS Code 外) + # は既存 ExecServer を前提にできずネスト URI が動かないため、明示設定時のみ採用する。 + ssh_host = (resolve_editor_ssh_host(env, auto_detect=ctx.in_vscode) + if ctx.is_ssh else None) + docker_context = resolve_docker_context(env) if ssh_host else None + uri = build_attach_uri(container, workdir, + ssh_host=ssh_host, docker_context=docker_context) + if plan.action == "print_command": # 提示コマンドは手元 (ローカル) で実行する前提。ローカルに code が無くても # 提示できるよう display (which 非依存) を用いる。 diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index e8a8d36..e3dddf2 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -57,3 +57,7 @@ def gcp_credentials_key(profile: str) -> str: DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (既定 OFF) DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code) DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1) +# Remote-SSH 跨ホスト構成 (Windows VS Code → ssh → Mac のコンテナ) 用。 +DEVBASE_EDITOR_SSH_HOST = "DEVBASE_EDITOR_SSH_HOST" # 任意。ssh-remote ホスト名 (例 mac2)。通常は ~/.vscode-server から自動検出 +DEVBASE_EDITOR_DOCKER_CONTEXT = "DEVBASE_EDITOR_DOCKER_CONTEXT" # 任意。ssh 先 docker context (既定: docker context show) +DEVBASE_OPEN_TERMINAL = "DEVBASE_OPEN_TERMINAL" # 真偽。up 後に folderOpen ターミナル tasks.json を配置 (既定 ON) diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 287fd5a..6b6ea4b 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -340,15 +340,19 @@ def test_up_parser_open_flags_tri_state(): def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): - """up の open_editor / open_index が cmd_up まで伝播する。""" + """up の open_editor / open_index / open_terminal が 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) + monkeypatch.setattr( + container, 'cmd_up', + lambda project_name=None, scale=None, open_editor=None, open_index=None, + open_terminal=None: captured.update( + open_editor=open_editor, open_index=open_index, + open_terminal=open_terminal) or 0) + args = _args(subcommand='up', scale=None, open_editor=True, open_index=2, + open_terminal=False) assert container._dispatch_lifecycle(args) == 0 - assert captured == {'open_editor': True, 'open_index': 2} + assert captured == {'open_editor': True, 'open_index': 2, 'open_terminal': False} def test_maybe_open_editor_disabled_by_default(monkeypatch): @@ -419,6 +423,79 @@ def test_maybe_open_editor_valid_index_within_scale(monkeypatch): assert called[0]['index'] == 2 +class _DockerProc: + """subprocess.run 互換スタブ (docker exec 用)。""" + def __init__(self, returncode=0, stdout="placed", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_maybe_place_terminal_task_disabled(monkeypatch): + """DEVBASE_OPEN_TERMINAL 無効時は docker exec を呼ばない。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: False) + calls = [] + monkeypatch.setattr(container.subprocess, 'run', + lambda *a, **k: calls.append(a) or _DockerProc()) + container._maybe_place_terminal_task('carmo', None, None, 1) + assert calls == [] + + +def test_maybe_place_terminal_task_flag_off_overrides_env(monkeypatch): + """open_flag=False なら env が ON でも置かない。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + calls = [] + monkeypatch.setattr(container.subprocess, 'run', + lambda *a, **k: calls.append(a) or _DockerProc()) + container._maybe_place_terminal_task('carmo', False, None, 1) + assert calls == [] + + +def test_maybe_place_terminal_task_runs_docker_exec(monkeypatch): + """既定 ON で docker exec -i へ tasks.json を stdin 投入する。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'resolve_container_name', lambda *a, **k: 'carmo-dev-1') + monkeypatch.setattr(opener, 'resolve_workdir', lambda *a, **k: '/work/carmo') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + captured = {} + + def fake_run(cmd, **kw): + captured['cmd'] = cmd + captured['input'] = kw.get('input') + return _DockerProc(returncode=0, stdout="placed") + + monkeypatch.setattr(container.subprocess, 'run', fake_run) + result = container._maybe_place_terminal_task('carmo', None, 1, 1) + cmd = captured['cmd'] + assert cmd[:4] == ['docker', 'exec', '-i', 'carmo-dev-1'] + assert cmd[-1] == '/work/carmo' # workdir は $1 として末尾に渡す + assert '"runOn": "folderOpen"' in captured['input'] + # 解決済みコンテナ名を返し editor 側で再利用させる (docker compose ps 二重実行回避) + assert result == 'carmo-dev-1' + + +def test_maybe_place_terminal_task_failure_does_not_raise(monkeypatch): + """docker exec が例外でも up を倒さない (握り潰す)。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'resolve_container_name', lambda *a, **k: 'carmo-dev-1') + monkeypatch.setattr(opener, 'resolve_workdir', lambda *a, **k: '/work/carmo') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + + def boom(*a, **k): + raise OSError("docker missing") + + monkeypatch.setattr(container.subprocess, 'run', boom) + container._maybe_place_terminal_task('carmo', None, 1, 1) # 例外が出なければ OK + + def test_maybe_open_editor_forwards_compose_file(monkeypatch): """compose_file 引数が open_editor まで伝播する (実コンテナ名問い合わせ用)。""" from devbase.commands import container diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 62be75d..155a10c 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass import pytest @@ -17,6 +18,23 @@ class _Proc: stdout: str = "" +@pytest.fixture(autouse=True) +def _isolate_vscode_home(monkeypatch, tmp_path): + """``~/.vscode-server`` からの ssh host 自動推測がテスト実行環境に依存しないよう + HOME を空の tmp に隔離する (このリポジトリの dev コンテナ自体が実 .vscode-server を + 持つため、隔離しないと resolve_editor_ssh_host が実ホスト名を拾ってしまう)。""" + monkeypatch.setenv("HOME", str(tmp_path)) + + +def _write_history(base: str, subdir: str, content: str) -> str: + """``/data/User/History//entries.json`` を書いてパスを返す。""" + path = os.path.join(base, "data", "User", "History", subdir, "entries.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return path + + # --------------------------------------------------------------------------- # detect_context # --------------------------------------------------------------------------- @@ -129,6 +147,178 @@ def test_build_attach_uri_adds_leading_slash(): assert uri.endswith("/work/p") +def test_build_attach_uri_nested_ssh_remote_and_context(): + """ssh_host / docker_context 指定でネスト authority + settings.context を組む。""" + uri = opener.build_attach_uri("adminer-dev-1", "/work/adminer", + ssh_host="mac2", docker_context="desktop-linux") + prefix = "vscode-remote://attached-container+" + assert uri.startswith(prefix) + authority, _, path = uri[len(prefix):].partition("/") + assert path == "work/adminer" + hexpart, sep, ssh = authority.partition("@") + assert sep == "@" + assert ssh == "ssh-remote+mac2" + decoded = json.loads(bytes.fromhex(hexpart).decode("utf-8")) + assert decoded == {"containerName": "/adminer-dev-1", + "settings": {"context": "desktop-linux"}} + + +def test_build_attach_uri_ssh_host_only_no_settings(): + """docker_context 無しなら settings は付けず @ssh-remote のみ付く。""" + uri = opener.build_attach_uri("p-dev-1", "/work/p", ssh_host="mac2") + assert "@ssh-remote+mac2/work/p" in uri + hexpart = uri.split("attached-container+")[1].split("@")[0] + assert json.loads(bytes.fromhex(hexpart).decode()) == {"containerName": "/p-dev-1"} + + +# --------------------------------------------------------------------------- +# resolve_editor_ssh_host / resolve_docker_context +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + (None, None), ("", None), (" ", None), ("mac2", "mac2"), (" mac2 ", "mac2"), +]) +def test_resolve_editor_ssh_host_explicit_or_none(value, expected): + # HOME は autouse fixture で空 tmp に隔離済みのため自動推測は None。 + env = {} if value is None else {"DEVBASE_EDITOR_SSH_HOST": value} + assert opener.resolve_editor_ssh_host(env) == expected + + +def test_resolve_editor_ssh_host_autodetect_single(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "abc", json.dumps( + {"resource": "vscode-remote://attached-container%2Bxx@ssh-remote%2Bmac2/work/x"})) + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "mac2" + + +def test_resolve_editor_ssh_host_autodetect_plus_form(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "abc", '"vscode-remote://ssh-remote+devbox/work/p"') + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "devbox" + + +def test_resolve_editor_ssh_host_autodetect_picks_newest(tmp_path): + base = str(tmp_path / ".vscode-server") + old = _write_history(base, "a", "ssh-remote%2BmacOLD/work") + new = _write_history(base, "b", "ssh-remote%2BmacNEW/work") + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "macNEW" + + +def test_resolve_editor_ssh_host_autodetect_none_when_absent(tmp_path): + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=str(tmp_path / "nope")) is None + + +def test_resolve_editor_ssh_host_auto_detect_false_skips_history(tmp_path): + """auto_detect=False (plain SSH 相当) は history を見ず None (明示が無ければ)。""" + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bmac2/work") + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=base, auto_detect=False) is None + # auto_detect=True なら拾える (対比) + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=base, auto_detect=True) == "mac2" + + +def test_resolve_editor_ssh_host_auto_detect_false_honors_explicit(tmp_path): + """auto_detect=False でも明示設定は尊重する。""" + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": "mac2"}, auto_detect=False) == "mac2" + + +def test_detect_ssh_host_across_multiple_server_dirs(tmp_path): + """cursor-server / vscode-server を横断し最新 mtime のホストを返す。""" + vsc = str(tmp_path / ".vscode-server") + cur = str(tmp_path / ".cursor-server") + old = _write_history(vsc, "a", "ssh-remote%2BmacOLD/work") + new = _write_history(cur, "b", "ssh-remote%2BmacNEW/work") + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + assert opener._detect_ssh_host_from_dirs([vsc, cur]) == "macNEW" + + +def test_resolve_editor_ssh_host_explicit_beats_autodetect(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bauto/work") + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": "explicit"}, vscode_server_dir=base) == "explicit" + + +def test_resolve_editor_ssh_host_empty_string_opts_out(tmp_path): + """空文字は自動推測のオプトアウト (None) として扱い、history 探索しない。""" + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bauto/work") + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": ""}, vscode_server_dir=base) is None + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": " "}, vscode_server_dir=base) is None + + +def test_resolve_docker_context_empty_string_opts_out(): + """空文字は settings.context を付けないオプトアウト。runner を呼ばない。""" + def boom(cmd, **kw): + raise AssertionError("docker context show should not run") + + assert opener.resolve_docker_context( + {"DEVBASE_EDITOR_DOCKER_CONTEXT": ""}, runner=boom) is None + + +def test_resolve_docker_context_explicit_wins(): + assert opener.resolve_docker_context({"DEVBASE_EDITOR_DOCKER_CONTEXT": " desktop-linux "}) \ + == "desktop-linux" + + +def test_resolve_docker_context_from_docker_show(): + def runner(cmd, **kw): + assert cmd == ["docker", "context", "show"] + return _Proc(returncode=0, stdout="desktop-linux\n") + + assert opener.resolve_docker_context({}, runner=runner) == "desktop-linux" + + +def test_resolve_docker_context_none_when_docker_fails(): + assert opener.resolve_docker_context( + {}, runner=lambda cmd, **kw: _Proc(returncode=1, stdout="")) is None + + +def test_resolve_docker_context_none_when_docker_absent(): + def boom(cmd, **kw): + raise FileNotFoundError("docker") + + assert opener.resolve_docker_context({}, runner=boom) is None + + +# --------------------------------------------------------------------------- +# is_open_terminal_enabled (既定 ON) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + (None, True), ("", False), ("0", False), ("false", False), ("no", False), + ("1", True), ("true", True), ("on", True), ("YES", True), +]) +def test_is_open_terminal_enabled(value, expected): + env = {} if value is None else {"DEVBASE_OPEN_TERMINAL": value} + assert opener.is_open_terminal_enabled(env) is expected + + +# --------------------------------------------------------------------------- +# build_folder_open_tasks_json +# --------------------------------------------------------------------------- + +def test_build_folder_open_tasks_json_is_valid_folderopen_task(): + data = json.loads(opener.build_folder_open_tasks_json()) + assert data["version"] == "2.0.0" + task = data["tasks"][0] + assert task["runOptions"]["runOn"] == "folderOpen" + assert task["presentation"]["reveal"] == "always" + # SHELL 未設定でも /bin/sh にフォールバックして必ずシェルを起動する (空 command 回避) + assert task["type"] == "process" + assert task["command"] == "/bin/sh" + assert "SHELL:-/bin/sh" in " ".join(task["args"]) + + # --------------------------------------------------------------------------- # resolve_container_name / resolve_workdir # --------------------------------------------------------------------------- @@ -311,6 +501,61 @@ def test_open_editor_launch_invokes_launcher(monkeypatch): assert cmd[2].endswith("/work/carmo") +def test_open_editor_launch_nested_uri_under_remote_ssh(monkeypatch): + """Remote-SSH (in_vscode + ssh) かつ DEVBASE_EDITOR_SSH_HOST 設定時はネスト URI で launch。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + # docker context show を実行させず固定値に差し替え + monkeypatch.setattr(opener, "resolve_docker_context", + lambda *a, **kw: "desktop-linux") + calls = [] + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22", + "DEVBASE_EDITOR_SSH_HOST": "mac2"}, + isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "launch" + uri = calls[0][2] + assert "@ssh-remote+mac2/work/adminer" in uri + hexpart = uri.split("attached-container+")[1].split("@")[0] + decoded = json.loads(bytes.fromhex(hexpart).decode()) + assert decoded["containerName"] == "/adminer-dev-1" + assert decoded["settings"]["context"] == "desktop-linux" + + +def test_open_editor_flat_uri_when_ssh_host_unset(monkeypatch): + """Remote-SSH でも DEVBASE_EDITOR_SSH_HOST 未設定なら従来のフラット URI のまま。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22"}, + isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert "@ssh-remote" not in calls[0][2] + + +def test_open_editor_uses_given_container_name(monkeypatch): + """container_name を渡すと resolve_container_name を呼ばずそれを使う。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + + def boom(*a, **k): + raise AssertionError("resolve_container_name should not be called") + + monkeypatch.setattr(opener, "resolve_container_name", boom) + calls = [] + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + container_name="preresolved-dev-1", environ={}, isatty=True, + launcher=lambda cmd, env: calls.append(cmd), + ) + uri = calls[0][2] + hexpart = uri.split("attached-container+")[1].split("/work")[0] + assert json.loads(bytes.fromhex(hexpart).decode())["containerName"] == "/preresolved-dev-1" + + def test_open_editor_skip_when_no_editor(monkeypatch): monkeypatch.setattr(opener.shutil, "which", lambda c: None) calls = []