From e1a7203c3f761a15bb7eba4f41cca0b601d63b89 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 10 Jun 2026 03:54:10 +0000 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20PLAN31=5F2-env-ops=20Draft=20PR=20?= =?UTF-8?q?=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ebdd906b18866f18a411c1a3e376240478247154 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 11 Jun 2026 03:10:38 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20PLAN31=5F2-env-ops=20env=20?= =?UTF-8?q?=E5=85=A8=E6=93=8D=E4=BD=9C=E3=81=AE=20TUI=20=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tui/actions_env.py 新設: env init/list/set/get/delete/edit/sync/project/ export/import を選択メニュー + 引数収集で cmd_env へ委譲 (plan 2.3 契約) - project スコープ (set --project / project) は事前にプロジェクト選択 → chdir + PWD 切替で実行し、try/finally で必ず元の CWD/PWD へ復帰 (plan 3.3) - 破壊的な env delete は実行前に menu.confirm で確認 (plan 3.4) - export/import は主要引数 (dest/source) のみ収集し、残りは CLI parser 既定値と 同一の属性を明示付与 (parser との同期テスト付き) - tui/app.py の _route に env を配線、未実装カテゴリ前提のテストを plugin へ更新 - tests/cli/tui/test_actions_env.py 新設 (契約・chdir 復帰・confirm・ナビ遷移) Co-Authored-By: Claude Fable 5 --- lib/devbase/tui/actions_env.py | 362 +++++++++++++++++++ lib/devbase/tui/app.py | 10 +- tests/cli/tui/test_actions_env.py | 558 ++++++++++++++++++++++++++++++ tests/cli/tui/test_app.py | 15 +- 4 files changed, 937 insertions(+), 8 deletions(-) create mode 100644 lib/devbase/tui/actions_env.py create mode 100644 tests/cli/tui/test_actions_env.py diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py new file mode 100644 index 0000000..eba4b31 --- /dev/null +++ b/lib/devbase/tui/actions_env.py @@ -0,0 +1,362 @@ +"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3)。 + +``devbase env`` の全サブコマンド (init/list/set/get/delete/edit/sync/project/ +export/import) をトップ階層メニューから実行できるようにする。引数収集は +``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と同じ属性値を +集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_env`` へ委譲する +(plan 2.3 契約表 / ロジック二重実装なし)。 + +project スコープ依存の扱い (plan 3.3): +- ``set --project`` / ``project`` は CWD (環境変数 ``PWD``) のプロジェクト + ディレクトリで動くため、先にプロジェクト選択メニューで対象を選ばせて + chdir + ``PWD`` 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する + (``_run_in_project``)。``cmd_env_*`` は ``os.environ.get('PWD', os.getcwd())`` + で現在地を判定するため、``os.chdir`` だけでなく ``PWD`` も併せて切り替える。 +- ``edit`` は plan 3.3 で CWD スコープとされているが、実装 (``cmd_env_edit``) は + 常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、プロジェクト選択は + 行わない (plan 表と実装の乖離。parser / 実装を正とする)。 + +破壊的操作 ``delete`` は実行前に ``menu.confirm`` で確認する (plan 3.4)。 + +export/import は引数が多いため TUI では主要引数 (``dest`` / ``source``) のみ +収集し、残りは CLI parser の既定値と同一の属性を明示的に渡す (既定値の乖離を +防ぐ。細かい制御が必要な場合は CLI を使う想定)。 + +ナビ規約は actions_project と同一: +- サブメニューで Esc/← → カテゴリメニューへ戻る → (呼び出し元へ ``MENU_BACK``) +- Ctrl-C → ``None`` を伝搬して全体中止 +- 引数収集の中止 (``_ARG_CANCEL``) → サブメニューを再表示 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.commands.project import ( + _STATUS_COLOR, + _build_menu_entries, + list_projects, +) +from devbase.log import get_logger +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + +# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系の list を先頭に +# 置き、Enter 連打で安全な一覧表示へ到達できるようにする。各 value は cmd_env の +# サブコマンド名。 +_ENV_OPS: list[tuple[str, str]] = [ + ("変数一覧 (list)", "list"), + ("値の取得 (get)", "get"), + ("変数の設定 (set)", "set"), + ("変数の削除 (delete)", "delete"), + ("エディタで編集 (edit)", "edit"), + ("認証情報の再同期 (sync)", "sync"), + ("プロジェクト変数の対話設定 (project)", "project"), + ("初期セットアップ (init)", "init"), + ("暗号化バンドルへエクスポート (export)", "export"), + ("バンドルからインポート (import)", "import"), +] + +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 +_ARG_CANCEL = object() + + +def _dispatch(devbase_root: Path, subcommand: str, **attrs): + """``cmd_env`` への委譲 (dispatch_group の薄いラッパ)。 + + import を関数内で行うのは actions_project (dispatch_lifecycle) と同様、 + テストで ``devbase.commands.env.cmd_env`` を monkeypatch できるようにするため。 + """ + from devbase.commands import env as env_mod + + return dispatch_group(env_mod.cmd_env, devbase_root, subcommand, **attrs) + + +def _select_action(): + """env 操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` + (Ctrl-C 中止)。 + """ + return menu.select( + "環境変数の操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_ENV_OPS), back=True, search=False) + + +def _select_project(devbase_root: Path): + """project スコープ操作の対象プロジェクトを選ぶ。 + + actions_project と同じ一覧取得 (``list_projects`` + ``_build_menu_entries``) を + 流用する。戻り値: プロジェクト名 (``str``) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止、 + またはプロジェクト無し)。 + """ + projects_dir = Path(devbase_root) / "projects" + rows = list_projects(projects_dir) + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return _ARG_CANCEL + + entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) + choices = [(entry, i) for i, entry in enumerate(entries)] + idx = menu.select( + "対象プロジェクトを選択 " + "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=True) + if idx is menu.MENU_BACK or idx is None: + return _ARG_CANCEL + return rows[idx]["name"] + + +def _run_in_project(devbase_root: Path, project_name: str, fn): + """``projects/`` へ chdir + ``PWD`` を切り替えて fn を実行し、必ず復帰する。 + + ``cmd_env_set --project`` / ``cmd_env_project`` は + ``os.environ.get('PWD', os.getcwd())`` で現在地を判定する (wrapper の cd を + 前提とした PLAN06 機構) ため、``os.chdir`` だけでは不十分で ``PWD`` も + プロジェクトパスへ差し替える。``PWD`` は symlink を解決しない + ``projects/`` を指す (projects/ 配下判定を成立させるため)。 + + 戻り値: fn の rc / ``_ARG_CANCEL`` (対象ディレクトリへ移動できない場合)。 + """ + target = Path(devbase_root) / "projects" / project_name + old_cwd = Path.cwd() + old_pwd = os.environ.get("PWD") + try: + os.chdir(target) + except OSError as exc: + logger.error("プロジェクトディレクトリへ移動できません: %s (%s)", target, exc) + return _ARG_CANCEL + os.environ["PWD"] = str(target) + try: + return fn() + finally: + # 実行結果に関わらず必ず元の CWD / PWD へ復帰する (plan 3.3)。 + os.chdir(old_cwd) + if old_pwd is None: + os.environ.pop("PWD", None) + else: + os.environ["PWD"] = old_pwd + + +def _collect_assignment(): + """``env set`` の KEY=VALUE を収集する。 + + 形式エラー (``=`` 無し / キー名空) は ``cmd_env_set`` でも弾かれるが、TUI では + 実行前に再入力を促す。戻り値: 入力文字列 / ``None`` (Esc・Ctrl-C 中止)。 + """ + while True: + raw = menu.text("設定する変数 (KEY=VALUE 形式)", allow_empty=False) + if raw is None: + return None + if "=" not in raw or not raw.partition("=")[0].strip(): + logger.error("形式: KEY=VALUE (キー名は必須)") + continue + return raw + + +def _export_default_attrs() -> dict: + """``env export`` の CLI parser 既定値 (cli.py:246-279) と同一の属性セット。 + + TUI で収集しない引数も Namespace に明示的に載せ、CLI 実行と完全に同じ属性で + ハンドラを呼ぶ (getattr 既定値とのズレを防ぐ)。list は呼び出しごとに新規生成。 + """ + return { + "include_projects": None, + "exclude_projects": [], + "no_global": False, + "no_metadata": False, + "recipients": [], + "passphrase_env": None, + "passphrase_stdin": False, + "force_unencrypted": False, + "unsafe_allow_unencrypted_bucket": False, + } + + +def _import_default_attrs() -> dict: + """``env import`` の CLI parser 既定値 (cli.py:281-328) と同一の属性セット。""" + return { + "merge": "keep-existing", + "replace_keys": "", + "replace": False, + "dry_run": False, + "identities": [], + "passphrase_env": None, + "passphrase_stdin": False, + "include_projects": None, + "exclude_projects": [], + "no_global": False, + "no_metadata": False, + "merge_metadata": False, + "backup_dir": None, + "keep_last": 10, + } + + +def _run_list(devbase_root: Path): + """``env list``: 表示範囲 + 表示オプションを収集して一覧表示する。 + + プロジェクト欄は CLI と同じく CWD (PWD) が projects/ 配下のときのみ表示される + (TUI は通常 DEVBASE_ROOT で動くためグローバルのみになることが多い)。 + """ + scope = menu.select( + "表示範囲を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + [("グローバル + プロジェクト (既定)", "both"), + ("グローバルのみ (--global)", "global"), + ("プロジェクトのみ (--project)", "project")], + back=True, search=False) + if scope is menu.MENU_BACK or scope is None: + return _ARG_CANCEL + + reveal = menu.confirm("機密値を伏せ字にせず表示しますか (--reveal)?", default=False) + if reveal is None: + return _ARG_CANCEL + keys_only = menu.confirm("キー名のみ表示しますか (--keys)?", default=False) + if keys_only is None: + return _ARG_CANCEL + + return _dispatch(devbase_root, "list", + global_only=(scope == "global"), + project_only=(scope == "project"), + reveal=reveal, keys_only=keys_only) + + +def _run_set(devbase_root: Path): + """``env set``: 設定先 (グローバル / プロジェクト) と KEY=VALUE を収集して設定する。 + + プロジェクト設定 (--project) は対象を選ばせて chdir してから実行する (plan 3.3)。 + """ + scope = menu.select( + "設定先を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + [("グローバル ($DEVBASE_ROOT/.env)", "global"), + ("プロジェクト (projects//.env, --project)", "project")], + back=True, search=False) + if scope is menu.MENU_BACK or scope is None: + return _ARG_CANCEL + + name = None + if scope == "project": + name = _select_project(devbase_root) + if name is _ARG_CANCEL: + return _ARG_CANCEL + + assignment = _collect_assignment() + if assignment is None: + return _ARG_CANCEL + + if scope == "project": + return _run_in_project( + devbase_root, name, + lambda: _dispatch(devbase_root, "set", + assignment=assignment, project=True)) + return _dispatch(devbase_root, "set", assignment=assignment, project=False) + + +def _run_operation(devbase_root: Path, op: str): + """選択された env 操作の引数を収集して ``cmd_env`` へ委譲する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = + サブメニューへ戻る)。属性は plan 2.3 の契約表 (cli.py parser と同期確認済み) + に従う。 + """ + if op == "sync": + # 引数なし。ソースファイルから認証情報を再同期する。 + return _dispatch(devbase_root, "sync") + + if op == "edit": + # 引数なし。$DEVBASE_ROOT/.env を $EDITOR で開く (グローバル操作。 + # plan 3.3 は CWD スコープとするが実装はグローバルのため chdir しない)。 + return _dispatch(devbase_root, "edit") + + if op == "init": + # 既存設定がある場合は --reset でやり直し (既存はバックアップされる)。 + reset = menu.confirm( + "既存の設定をバックアップしてやり直しますか (--reset)?", default=False) + if reset is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "init", reset=reset) + + if op == "list": + return _run_list(devbase_root) + + if op == "set": + return _run_set(devbase_root) + + if op == "get": + key = menu.text("取得する変数名", allow_empty=False) + if key is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "get", key=key) + + if op == "delete": + key = menu.text("削除する変数名", allow_empty=False) + if key is None: + return _ARG_CANCEL + # 破壊的操作のため実行前に確認する (plan 3.4)。拒否 (False) / 中止 (None) + # は実行せずサブメニューへ戻る。 + ok = menu.confirm(f"変数 '{key}' をグローバル .env から削除しますか?", + default=False) + if not ok: + return _ARG_CANCEL + return _dispatch(devbase_root, "delete", key=key) + + if op == "project": + # プロジェクト固有変数の対話設定。projects/ 配下で動く CWD スコープ操作の + # ため、対象を選ばせて chdir してから実行する (plan 3.3)。 + name = _select_project(devbase_root) + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _run_in_project(devbase_root, name, + lambda: _dispatch(devbase_root, "project")) + + if op == "export": + # 主要引数 dest のみ収集。空入力は parser 既定 (./devbase-env-.dbenv)。 + dest = menu.path("出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)", + allow_empty=True) + if dest is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "export", dest=(dest or None), + **_export_default_attrs()) + + if op == "import": + # 主要引数 source のみ収集 (必須 positional)。merge は parser 既定の + # keep-existing (既存キー優先) で安全側。既存 .env はハンドラ側で + # バックアップされる。 + source = menu.path("インポートするバンドルのパス", allow_empty=False) + if source is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "import", source=source, + **_import_default_attrs()) + + # 到達しない (メニュー値は _ENV_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def run(devbase_root: Path): + """環境変数カテゴリのエントリ。操作選択 → 引数収集 → cmd_env へ委譲。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同一): + - **操作を実行した場合**: dispatch の rc (``int``) を返す。「実行したのでトップへ + 戻る、rc は呼び出し側が記憶」の意味で、失敗が ``devbase list`` の終了コードへ + 伝搬する。 + - ``menu.MENU_BACK``: サブメニューで Esc/← (操作なしでトップへ)。 + - ``None``: Ctrl-C による全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + """ + while True: + op = _select_action() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → サブメニューへ戻る + return rc # 実行 rc → トップへ復帰 (呼び出し側が記憶) diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 9a62158..8247245 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -4,8 +4,8 @@ プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ (project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 -PR1 では **project カテゴリのみ配線**し、env/plugin/snapshot/status は後続 PR -(PR3〜PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 +PR1 で project カテゴリ、PR3 で env カテゴリを配線済み。plugin/snapshot/status は +後続 PR (PR4〜PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 後方互換 (plan 3.2): - ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 @@ -26,7 +26,7 @@ from devbase.commands.project import _print_table, list_projects from devbase.log import get_logger -from devbase.tui import actions_project, menu +from devbase.tui import actions_env, actions_project, menu logger = get_logger(__name__) @@ -56,7 +56,9 @@ def _route(category: str, devbase_root: Path): """ if category == "project": return actions_project.run(devbase_root) - # PR3: env, PR4: plugin, PR5: snapshot/status をここに追加する。 + if category == "env": + return actions_env.run(devbase_root) + # PR4: plugin, PR5: snapshot/status をここに追加する。 logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category)) return menu.MENU_BACK diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py new file mode 100644 index 0000000..e7ffe36 --- /dev/null +++ b/tests/cli/tui/test_actions_env.py @@ -0,0 +1,558 @@ +"""PLAN31_2 PR3: tui.actions_env (env カテゴリ操作) のテスト。 + +test_actions_project.py のパターンを踏襲し、`menu.*` を monkeypatch して選択値を +注入、`cmd_env` を mock して **plan 2.3 の契約どおりの属性を持つ Namespace** で +呼ばれることを各サブコマンドで検証する。project スコープ操作の chdir →復帰、 +破壊的操作 (delete) の confirm、Esc/←/Ctrl-C の遷移も検証する。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from devbase.tui import actions_env, menu + + +def _make_plugin_project(root, plugin_path, proj): + target = root / plugin_path / "projects" / proj + target.mkdir(parents=True, exist_ok=True) + return target + + +def _link_project(root, link_name, plugin_path, proj): + projects_dir = root / "projects" + projects_dir.mkdir(exist_ok=True) + (projects_dir / link_name).symlink_to(Path("..") / plugin_path / "projects" / proj) + + +def _capture_dispatch(monkeypatch): + """cmd_env の呼び出しを (root, 全属性, 実行時 CWD/PWD) でキャプチャするヘルパ。""" + from devbase.commands import env as env_mod + captured = {} + + def _spy(devbase_root, args): + captured["root"] = devbase_root + captured["attrs"] = dict(vars(args)) + captured["cwd"] = os.getcwd() + captured["pwd"] = os.environ.get("PWD") + return 0 + + monkeypatch.setattr(env_mod, "cmd_env", _spy) + return captured + + +# --------------------------------------------------------------------------- +# run(): 操作選択 → 実行 / Esc / Ctrl-C / 引数収集中止 +# --------------------------------------------------------------------------- + +def test_run_executes_and_returns_rc(monkeypatch, tmp_path): + """操作を選んで実行したら dispatch の rc を返す (トップへ復帰)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + + assert actions_env.run(tmp_path) == 0 + assert captured["root"] == tmp_path + assert captured["attrs"] == {"subcommand": "sync"} + + +def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): + """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" + from devbase.commands import env as env_mod + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: 1) + monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + + assert actions_env.run(tmp_path) == 1 + + +def test_run_back_returns_to_top(monkeypatch, tmp_path): + """サブメニューで Esc/← (MENU_BACK) を押すとトップへ戻る (何も実行しない)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_action", lambda: menu.MENU_BACK) + + assert actions_env.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_ctrl_c_aborts(monkeypatch, tmp_path): + """サブメニューで Ctrl-C (None) を押すと全体中止 (None を返す)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_action", lambda: None) + + assert actions_env.run(tmp_path) is None + assert called == [] + + +def test_run_arg_cancel_reshows_submenu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: delete を選ぶ (→ 引数収集中止) / 2 回目: sync を選ぶ (→ 実行) + monkeypatch.setattr(actions_env, "_select_action", + lambda: (select_calls.append(1), + "delete" if len(select_calls) == 1 else "sync")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_env._ARG_CANCEL if op == "delete" else 0 + + monkeypatch.setattr(actions_env, "_run_operation", fake_run_op) + + assert actions_env.run(tmp_path) == 0 + assert run_calls == ["delete", "sync"] + assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" + + +# --------------------------------------------------------------------------- +# _select_action: menu.select への委譲 (全 10 サブコマンドの提示) +# --------------------------------------------------------------------------- + +def test_select_action_lists_all_ops(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + values=[c[1] for c in choices]) + return "list" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_env._select_action() == "list" + assert captured["back"] is True + assert captured["search"] is False + # 参照系の list を先頭にしつつ env の全 10 サブコマンドを提示する (PR3)。 + assert sorted(captured["values"]) == sorted([ + "init", "list", "set", "get", "delete", + "edit", "sync", "project", "export", "import"]) + assert captured["values"][0] == "list", "Enter 連打で安全な一覧表示に到達できる" + + +# --------------------------------------------------------------------------- +# _run_operation: 引数なし系 (sync / edit / init) +# --------------------------------------------------------------------------- + +def test_run_operation_sync_no_attrs(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_env._run_operation(tmp_path, "sync") == 0 + assert captured["attrs"] == {"subcommand": "sync"} + + +def test_run_operation_edit_is_global_no_project_select(monkeypatch, tmp_path): + """edit は $DEVBASE_ROOT/.env を開くグローバル操作。プロジェクト選択も chdir もしない。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_env, "_select_project", + lambda root: pytest.fail("edit でプロジェクト選択してはいけない")) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "edit") == 0 + assert captured["attrs"] == {"subcommand": "edit"} + assert captured["cwd"] == before, "edit は chdir しない" + + +@pytest.mark.parametrize("reset", [True, False]) +def test_run_operation_init_collects_reset(monkeypatch, tmp_path, reset): + """init は confirm の結果を --reset として渡す (plan 2.3: reset 既定 False)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: reset) + assert actions_env._run_operation(tmp_path, "init") == 0 + assert captured["attrs"] == {"subcommand": "init", "reset": reset} + + +def test_run_operation_init_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_env._run_operation(tmp_path, "init") is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: list (表示範囲 + reveal/keys) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("scope,global_only,project_only", [ + ("both", False, False), + ("global", True, False), + ("project", False, True), +]) +def test_run_operation_list_scope_flags(monkeypatch, tmp_path, + scope, global_only, project_only): + """list は表示範囲選択を --global/--project フラグへ写像する (plan 2.3)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "select", lambda *a, **k: scope) + confirms = iter([True, False]) # reveal=True, keys_only=False + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + + assert actions_env._run_operation(tmp_path, "list") == 0 + assert captured["attrs"] == { + "subcommand": "list", + "global_only": global_only, "project_only": project_only, + "reveal": True, "keys_only": False, + } + + +@pytest.mark.parametrize("scope_ret", ["BACK", None]) +def test_run_operation_list_scope_cancel(monkeypatch, tmp_path, scope_ret): + """表示範囲選択で Esc/← (MENU_BACK) / Ctrl-C (None) したら実行しない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if scope_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + assert actions_env._run_operation(tmp_path, "list") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_operation_list_confirm_cancel(monkeypatch, tmp_path): + """reveal の confirm を中止 (None) したら実行しない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "both") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: None) + assert actions_env._run_operation(tmp_path, "list") is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: get / delete +# --------------------------------------------------------------------------- + +def test_run_operation_get_collects_key(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "MY_KEY") + assert actions_env._run_operation(tmp_path, "get") == 0 + assert captured["attrs"] == {"subcommand": "get", "key": "MY_KEY"} + + +def test_run_operation_get_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_env._run_operation(tmp_path, "get") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_operation_delete_confirmed(monkeypatch, tmp_path): + """delete は confirm=True で削除を実行する (plan 3.4 破壊的操作)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_env._run_operation(tmp_path, "delete") == 0 + assert captured["attrs"] == {"subcommand": "delete", "key": "OLD_KEY"} + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_operation_delete_cancelled_does_not_dispatch(monkeypatch, tmp_path, + confirm_ret): + """delete の confirm を拒否 (False) / 中止 (None) したら削除しない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_env._run_operation(tmp_path, "delete") is actions_env._ARG_CANCEL + assert called == [], "確認を拒否/中止したら delete しない" + + +def test_run_operation_delete_key_cancel(monkeypatch, tmp_path): + """delete のキー入力を中止したら confirm にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("キー未入力で confirm しない")) + assert actions_env._run_operation(tmp_path, "delete") is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: set (グローバル / プロジェクト + chdir) +# --------------------------------------------------------------------------- + +def test_run_operation_set_global(monkeypatch, tmp_path): + """グローバル設定は chdir せず project=False で委譲する (plan 2.3)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(menu, "text", lambda *a, **k: "API_KEY=secret") + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "set") == 0 + assert captured["attrs"] == {"subcommand": "set", + "assignment": "API_KEY=secret", "project": False} + assert captured["cwd"] == before, "グローバル設定は chdir しない" + + +def test_run_operation_set_project_chdirs_and_restores(monkeypatch, tmp_path): + """プロジェクト設定は対象へ chdir + PWD 切替後に project=True で委譲し、復帰する。""" + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + monkeypatch.setattr(menu, "text", lambda *a, **k: "DB_HOST=localhost") + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "set") == 0 + assert captured["attrs"] == {"subcommand": "set", + "assignment": "DB_HOST=localhost", "project": True} + # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_set_project_select_cancel(monkeypatch, tmp_path): + """プロジェクト選択を中止したら assignment 入力にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + monkeypatch.setattr(menu, "text", + lambda *a, **k: pytest.fail("選択中止後に入力を求めない")) + assert actions_env._run_operation(tmp_path, "set") is actions_env._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("scope_ret", ["BACK", None]) +def test_run_operation_set_scope_cancel(monkeypatch, tmp_path, scope_ret): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if scope_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + assert actions_env._run_operation(tmp_path, "set") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_operation_set_assignment_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_env._run_operation(tmp_path, "set") is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: project (chdir + 復帰) +# --------------------------------------------------------------------------- + +def test_run_operation_project_chdirs_and_restores(monkeypatch, tmp_path): + """env project は対象プロジェクトへ chdir + PWD 切替後に実行し、復帰する (plan 3.3)。""" + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "project") == 0 + assert captured["attrs"] == {"subcommand": "project"} + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_project_select_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + assert actions_env._run_operation(tmp_path, "project") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_in_project_restores_cwd_on_exception(monkeypatch, tmp_path): + """ハンドラが例外を投げても CWD / PWD は復帰する (try/finally)。""" + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setenv("PWD", "/original/pwd") + + def _boom(): + raise RuntimeError("handler failed") + + before = os.getcwd() + with pytest.raises(RuntimeError): + actions_env._run_in_project(tmp_path, "carmo", _boom) + assert os.getcwd() == before + assert os.environ["PWD"] == "/original/pwd" + + +def test_run_in_project_restores_unset_pwd(monkeypatch, tmp_path): + """元の環境に PWD が無い場合は復帰時に PWD を残さない。""" + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.delenv("PWD", raising=False) + + seen = {} + + def _probe(): + seen["pwd"] = os.environ.get("PWD") + return 0 + + assert actions_env._run_in_project(tmp_path, "carmo", _probe) == 0 + assert seen["pwd"] == str(target) + assert "PWD" not in os.environ + + +def test_run_in_project_missing_dir_cancels(monkeypatch, tmp_path): + """対象ディレクトリへ移動できない場合は実行せず _ARG_CANCEL (メニューへ戻る)。""" + called = [] + result = actions_env._run_in_project(tmp_path, "ghost", + lambda: called.append(1) or 0) + assert result is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: export / import (parser 既定値との同期) +# --------------------------------------------------------------------------- + +def test_run_operation_export_default_dest(monkeypatch, tmp_path): + """export は空入力で dest=None、残り属性は CLI parser 既定値と一致する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "") + assert actions_env._run_operation(tmp_path, "export") == 0 + assert captured["attrs"] == { + "subcommand": "export", "dest": None, + "include_projects": None, "exclude_projects": [], + "no_global": False, "no_metadata": False, "recipients": [], + "passphrase_env": None, "passphrase_stdin": False, + "force_unencrypted": False, "unsafe_allow_unencrypted_bucket": False, + } + + +def test_run_operation_export_explicit_dest(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") + assert actions_env._run_operation(tmp_path, "export") == 0 + assert captured["attrs"]["dest"] == "/tmp/bundle.dbenv" + + +def test_run_operation_export_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "path", lambda *a, **k: None) + assert actions_env._run_operation(tmp_path, "export") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_operation_import_collects_source(monkeypatch, tmp_path): + """import は source を収集し、残り属性は CLI parser 既定値と一致する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") + assert actions_env._run_operation(tmp_path, "import") == 0 + assert captured["attrs"] == { + "subcommand": "import", "source": "/tmp/bundle.dbenv", + "merge": "keep-existing", "replace_keys": "", "replace": False, + "dry_run": False, "identities": [], + "passphrase_env": None, "passphrase_stdin": False, + "include_projects": None, "exclude_projects": [], + "no_global": False, "no_metadata": False, "merge_metadata": False, + "backup_dir": None, "keep_last": 10, + } + + +def test_run_operation_import_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "path", lambda *a, **k: None) + assert actions_env._run_operation(tmp_path, "import") is actions_env._ARG_CANCEL + assert called == [] + + +def test_export_defaults_match_cli_parser(tmp_path): + """TUI が補う export 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" + from devbase import cli + parsed = vars(cli._create_parser().parse_args(["env", "export"])) + tui_attrs = {"dest": None, **actions_env._export_default_attrs()} + for key, value in tui_attrs.items(): + assert parsed[key] == value, f"export 属性 {key} が parser 既定値と乖離" + + +def test_import_defaults_match_cli_parser(tmp_path): + """TUI が補う import 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" + from devbase import cli + parsed = vars(cli._create_parser().parse_args(["env", "import", "b.dbenv"])) + tui_attrs = {"source": "b.dbenv", **actions_env._import_default_attrs()} + for key, value in tui_attrs.items(): + assert parsed[key] == value, f"import 属性 {key} が parser 既定値と乖離" + + +# --------------------------------------------------------------------------- +# _collect_assignment / _select_project +# --------------------------------------------------------------------------- + +def test_collect_assignment_valid(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "K=V") + assert actions_env._collect_assignment() == "K=V" + + +def test_collect_assignment_reprompts_invalid(monkeypatch): + """`=` 無し / キー名空は再入力を促す (cmd_env_set 到達前に弾く)。""" + vals = iter(["NOEQUAL", "=value", "K=V"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_env._collect_assignment() == "K=V" + + +def test_collect_assignment_cancel(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_env._collect_assignment() is None + + +def test_select_project_returns_name(monkeypatch, tmp_path): + """一覧 (actions_project と同じ取得方法) から選んだ行の name を返す。""" + from devbase.commands import status as status_mod + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: None) + + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, n=len(choices)) + return 0 + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_env._select_project(tmp_path) == "carmo" + assert captured == {"back": True, "search": True, "n": 1} + + +@pytest.mark.parametrize("sel_ret", ["BACK", None]) +def test_select_project_cancel(monkeypatch, tmp_path, sel_ret): + from devbase.commands import status as status_mod + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: None) + ret = menu.MENU_BACK if sel_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + assert actions_env._select_project(tmp_path) is actions_env._ARG_CANCEL + + +def test_select_project_empty_cancels(monkeypatch, tmp_path): + """プロジェクトが無いときは選択メニューを出さず _ARG_CANCEL。""" + monkeypatch.setattr(menu, "select", + lambda *a, **k: pytest.fail("空一覧でメニューを出さない")) + assert actions_env._select_project(tmp_path) is actions_env._ARG_CANCEL diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index eeae959..538dba1 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -165,11 +165,11 @@ def test_top_menu_propagates_executed_rc(monkeypatch, tmp_path): def test_top_menu_back_does_not_overwrite_last_rc(monkeypatch, tmp_path): """実行 rc を記憶後、別カテゴリが MENU_BACK を返しても last_rc は上書きされない。""" - selects = iter(["project", "env", None]) + selects = iter(["project", "plugin", None]) monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) runs = iter([1]) # project 実行 → rc=1 monkeypatch.setattr(actions_project, "run", lambda root: next(runs)) - # env は未実装カテゴリ (_route が MENU_BACK) → last_rc を維持 + # plugin は未実装カテゴリ (_route が MENU_BACK) → last_rc を維持 assert app._top_menu_loop(tmp_path) == 1 @@ -200,8 +200,8 @@ def test_top_menu_category_ctrl_c_aborts_whole_app(monkeypatch, tmp_path): def test_top_menu_unimplemented_category_returns_to_top(monkeypatch, tmp_path): - """未実装カテゴリ (env 等) はプレースホルダ案内を出してトップへ戻る。""" - selects = iter(["env", None]) + """未実装カテゴリ (plugin 等) はプレースホルダ案内を出してトップへ戻る。""" + selects = iter(["plugin", None]) monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) # _route が MENU_BACK を返してループ継続 → 2 回目 None で終了 rc = app._top_menu_loop(tmp_path) @@ -213,5 +213,12 @@ def test_route_project_delegates(monkeypatch, tmp_path): assert app._route("project", tmp_path) == "RESULT" +def test_route_env_delegates(monkeypatch, tmp_path): + """env カテゴリは actions_env.run へ routing される (PR3)。""" + from devbase.tui import actions_env + monkeypatch.setattr(actions_env, "run", lambda root: "ENV_RESULT") + assert app._route("env", tmp_path) == "ENV_RESULT" + + def test_route_unimplemented_returns_menu_back(tmp_path): assert app._route("plugin", tmp_path) is menu.MENU_BACK From 03bbf6825ab9f4b84dca7011a99eee6bbd1b2f38 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 11 Jun 2026 03:54:29 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20env=20list=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE?= =?UTF-8?q?=E3=81=BF=E3=80=8D=E3=81=A7=E5=AF=BE=E8=B1=A1=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E9=81=B8=E6=8A=9E=20+=20ch?= =?UTF-8?q?dir/PWD=20=E5=88=87=E6=9B=BF=E3=82=92=E8=A1=8C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する ため、TUI (通常 DEVBASE_ROOT で稼働) から project スコープを選んでも何も 表示されなかった (codex round1 major 指摘)。set/project と同様に _select_project で対象を選ばせ、_run_in_project 経由で実行するよう修正。 回帰テスト (chdir + 復帰 / 選択中止) を追加。 Co-Authored-By: Claude Fable 5 --- lib/devbase/tui/actions_env.py | 25 ++++++++++--- tests/cli/tui/test_actions_env.py | 60 ++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index eba4b31..32683b7 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -7,7 +7,8 @@ (plan 2.3 契約表 / ロジック二重実装なし)。 project スコープ依存の扱い (plan 3.3): -- ``set --project`` / ``project`` は CWD (環境変数 ``PWD``) のプロジェクト +- ``set --project`` / ``project`` / ``list --project`` は CWD (環境変数 ``PWD``) + のプロジェクト ディレクトリで動くため、先にプロジェクト選択メニューで対象を選ばせて chdir + ``PWD`` 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する (``_run_in_project``)。``cmd_env_*`` は ``os.environ.get('PWD', os.getcwd())`` @@ -201,8 +202,11 @@ def _import_default_attrs() -> dict: def _run_list(devbase_root: Path): """``env list``: 表示範囲 + 表示オプションを収集して一覧表示する。 - プロジェクト欄は CLI と同じく CWD (PWD) が projects/ 配下のときのみ表示される - (TUI は通常 DEVBASE_ROOT で動くためグローバルのみになることが多い)。 + 「プロジェクトのみ」はハンドラ (``cmd_env_list``) が CWD (PWD) でプロジェクト + .env を判定するため、対象プロジェクトを選ばせて chdir + ``PWD`` 切替後に + 実行する (plan 3.3。TUI は通常 DEVBASE_ROOT で動くので切替なしでは何も + 表示されない)。「グローバル + プロジェクト」は CLI 既定と同じ CWD 判定の + ままとする (TUI ではグローバルのみになることが多い)。 """ scope = menu.select( "表示範囲を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", @@ -213,6 +217,12 @@ def _run_list(devbase_root: Path): if scope is menu.MENU_BACK or scope is None: return _ARG_CANCEL + name = None + if scope == "project": + name = _select_project(devbase_root) + if name is _ARG_CANCEL: + return _ARG_CANCEL + reveal = menu.confirm("機密値を伏せ字にせず表示しますか (--reveal)?", default=False) if reveal is None: return _ARG_CANCEL @@ -220,9 +230,14 @@ def _run_list(devbase_root: Path): if keys_only is None: return _ARG_CANCEL + if scope == "project": + return _run_in_project( + devbase_root, name, + lambda: _dispatch(devbase_root, "list", + global_only=False, project_only=True, + reveal=reveal, keys_only=keys_only)) return _dispatch(devbase_root, "list", - global_only=(scope == "global"), - project_only=(scope == "project"), + global_only=(scope == "global"), project_only=False, reveal=reveal, keys_only=keys_only) diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py index e7ffe36..25e41bc 100644 --- a/tests/cli/tui/test_actions_env.py +++ b/tests/cli/tui/test_actions_env.py @@ -177,25 +177,69 @@ def test_run_operation_init_cancel(monkeypatch, tmp_path): # _run_operation: list (表示範囲 + reveal/keys) # --------------------------------------------------------------------------- -@pytest.mark.parametrize("scope,global_only,project_only", [ - ("both", False, False), - ("global", True, False), - ("project", False, True), +@pytest.mark.parametrize("scope,global_only", [ + ("both", False), + ("global", True), ]) -def test_run_operation_list_scope_flags(monkeypatch, tmp_path, - scope, global_only, project_only): - """list は表示範囲選択を --global/--project フラグへ写像する (plan 2.3)。""" +def test_run_operation_list_scope_flags(monkeypatch, tmp_path, scope, global_only): + """list は表示範囲選択を --global フラグへ写像する (plan 2.3)。chdir しない。""" captured = _capture_dispatch(monkeypatch) monkeypatch.setattr(menu, "select", lambda *a, **k: scope) confirms = iter([True, False]) # reveal=True, keys_only=False monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + before = os.getcwd() assert actions_env._run_operation(tmp_path, "list") == 0 assert captured["attrs"] == { "subcommand": "list", - "global_only": global_only, "project_only": project_only, + "global_only": global_only, "project_only": False, "reveal": True, "keys_only": False, } + assert captured["cwd"] == before, "global/both スコープは chdir しない" + + +def test_run_operation_list_project_chdirs_and_restores(monkeypatch, tmp_path): + """list の「プロジェクトのみ」は対象を選ばせて chdir + PWD 切替後に実行し、復帰する。 + + cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する + ため、切替なしでは何も表示されない (codex round1 指摘の回帰テスト)。 + """ + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + confirms = iter([False, True]) # reveal=False, keys_only=True + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "list") == 0 + assert captured["attrs"] == { + "subcommand": "list", + "global_only": False, "project_only": True, + "reveal": False, "keys_only": True, + } + # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_list_project_select_cancel(monkeypatch, tmp_path): + """list のプロジェクト選択を中止したら表示オプション収集にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("選択中止後に確認を求めない")) + assert actions_env._run_operation(tmp_path, "list") is actions_env._ARG_CANCEL + assert called == [] @pytest.mark.parametrize("scope_ret", ["BACK", None])