diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e950a7..17e571e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ## [Unreleased] ### Added +- **`devbase project` サブコマンド群を新設**しました (PLAN06)。CWD に依存せずプロジェクト名でコンテナ操作ができます。 + - `devbase project up/down/ps/logs/scale [name]` で、任意のディレクトリから `$DEVBASE_ROOT/projects/` を対象に操作できます。名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行するため、シェル実装の `build` を含む全操作が名前指定で成立します(呼び出し元シェルの作業ディレクトリは変わりません)。存在しない名前はエラーになり候補が提示されます。 + - `devbase project list [--interactive|-i]` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。`--interactive` では一覧から番号で選択して起動でき、非対話環境では番号入力にフォールバックします。 + - トップレベルシノニム `devbase up/down/ps/scale [name]` / `devbase build [image]` / `devbase login [index]` / `devbase list` を整備しました(`logs` はシノニムを持たず `devbase project logs` のみ)。 + - bash / zsh のシェル補完に `project` グループとプロジェクト名補完(`$DEVBASE_ROOT/projects/` 配下を列挙)を追加しました。 + - 利用者向けドキュメント [`docs/user/cli-reference.md`](docs/user/cli-reference.md) / [`docs/user/container-operations.md`](docs/user/container-operations.md) を `project` 体系に更新しました。 - `devbase env export` / `devbase env import` で **S3 URI (`s3://bucket/key`) を入出力先として指定**できるようになりました (PLAN03-1 PR3)。 - 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。 - 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。 @@ -15,6 +21,7 @@ - README と環境変数ガイドからのリンクも追加しました。 ### Changed +- **`devbase container` グループを非推奨化**しました (PLAN06)。`devbase container ` は `devbase project ` のエイリアスとして当面動作しますが、実行時に非推奨警告を表示します(移行期間後のリリースで削除予定)。`[name]` 指定や `list` などの新機能は `project` 側のみで提供されます。トップレベルショートカット (`devbase up` 等) の転送先も `container` から `project` へ変更しました。 - `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 - `lib/devbase/env/` 配下の export / import モジュールをリファクタリングしました (PLAN03-1 PR5)。公開 API (`ExportOptions`, `ImportOptions`, `export`, `import_bundle`) に互換性のない変更はありません。 - export / import で重複していた passphrase 読み取り / 既定鍵 fallback / セキュアな bytes 書き込みを `io_common.py` に集約。 diff --git a/README.md b/README.md index 9635598..d53941b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ devbaseは、Docker Composeを使った再現性の高い開発環境を提供 - **Pluginベースのプロジェクト管理**: 外部リポジトリからプロジェクト設定をインストール・更新 - **コンテナ化された開発環境**: Docker Composeベースで再現性の高い環境を提供 - **豊富なツールセット**: Docker CLI、AWS CLI、gcloud SDK、Terraform、Node.js、AI CLIツールがプリインストール -- **複数コンテナの並行開発**: `devbase container scale`で既存コンテナを再起動せずにスケール可能 +- **複数コンテナの並行開発**: `devbase project scale`で既存コンテナを再起動せずにスケール可能 - **データ永続化**: 名前付きボリュームでコンテナ再起動後もデータを保持 - **スナップショット管理**: `/home/ubuntu` 共通ボリュームの増分バックアップ・復元・世代管理 - **環境変数の自動収集**: `devbase env init`でAWS/Git/GCP認証情報を対話的に設定 @@ -75,12 +75,14 @@ devbaseのコマンドは4つのグループにまとめられています。 | グループ | 略記 | 説明 | |---------|------|------| -| `container` | `ct` | コンテナ管理(up / down / login / ps / logs / scale / build) | +| `project` | — | プロジェクト管理(up / down / login / ps / logs / scale / build / list) | | `env` | — | 環境変数管理(init / sync / list / set / get / delete / edit / project / export / import) | | `plugin` | `pl` | プラグイン管理(list / install / uninstall / update / info / sync / repo) | | `snapshot` | `ss` | スナップショット管理(create / list / restore / copy / delete / rotate) | -- **ショートカット**: `up`, `down`, `login`, `build`, `ps` はトップレベルから直接使用可能 +> **`container`(略記 `ct`)グループは非推奨です。** `devbase project ` のエイリアスとして当面動作しますが、非推奨警告を表示します。新しいコマンドは `project` を使用してください。 + +- **ショートカット**: `up [name]`, `down [name]`, `login [index]`, `build [image]`, `ps [name]`, `scale [name] `, `list` はトップレベルから直接使用可能(`project` グループへ自動転送。`logs` はシノニムを持ちません)。ただし `build` のみ例外で、`project` グループ(Python 実装)ではなく `bin/devbase` のシェル実装 `cmd_build` へ直接委譲されます(詳細は [CLI リファレンス](docs/user/cli-reference.md#ショートカットコマンド)) - **プレフィックス略記**: `devbase p l` → `devbase plugin list` - **トップレベルコマンド**: `init`, `status`, `shell-rc` diff --git a/bin/devbase b/bin/devbase index a4b6222..aabad9d 100755 --- a/bin/devbase +++ b/bin/devbase @@ -168,16 +168,72 @@ run_python() { # Resolve abbreviated command to full command name via unique prefix matching resolve_command() { local input="$1" - local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps scale help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") done if [ ${#matches[@]} -eq 1 ]; then echo "${matches[0]}" - else - echo "$input" # no match or ambiguous -> return as-is + return + fi + # ambiguous の場合の後方互換 preference。`list` 追加で `l` が login/list の + # 両方にマッチするようになったため、既存の `devbase l` → `login` を維持する。 + # cli.py の TOP_PREFIX_PREFERENCES と同期させること。 + if [ ${#matches[@]} -gt 1 ]; then + local preferred="" + case "$input" in + l) preferred="login" ;; + esac + if [ -n "$preferred" ]; then + for m in "${matches[@]}"; do + [ "$m" = "$preferred" ] && { echo "$preferred"; return; } + done + fi fi + echo "$input" # no match or ambiguous -> return as-is +} + +# =================================================================== +# Project name resolution (PLAN06 Task 2) +# =================================================================== +# `devbase project ` および同義のトップレベルシノニム +# `devbase ` の が $DEVBASE_ROOT/projects/ に実在する +# 場合、そのディレクトリへ cd し COMPOSE_PROJECT_NAME / env を再設定する。 +# これにより任意の CWD からプロジェクトを指定してコンテナ操作できる。 +# +# 重要: `build` は shell 実装 (cmd_build) が CWD で動くため、この wrapper cd +# だけが build の name 解決手段になる (PLAN06 方針 A の核心)。Python 側 chdir +# フォールバックでは build を救えない。 +# +# 判定は projects/ 配下の実在性で行う。これにより `login ` / +# `build ` / `scale ` の既存 positional と曖昧にならない: 実在する +# プロジェクト名のときだけ name として解釈し cd + strip する。実在しなければ +# 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale +# あるいは「存在しない name」エラーとして扱わせる。 + +# name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。 +maybe_cd_project() { + local name="${1:-}" + case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない + local target="${DEVBASE_ROOT}/projects/${name}" + [ -d "$target" ] || return 1 + cd "$target" || return 1 + export COMPOSE_PROJECT_NAME="$name" + # cd 後にプロジェクトの env を再 source (初期 CWD で読んだ値を上書き)。 + # project の .env (dotfile) は CRLF / 特殊文字対策で意図的に source しない + # 方針を踏襲する (冒頭コメント参照)。 + # + # 注意: env は環境変数定義のみを想定したファイルであり subshell ではなく + # 現プロセスで source する。これは wrapper 冒頭 L23-24 の env 読み込みと同一 + # 意図 — set -a で export した変数を後続の run_python / cmd_build へ引き継ぐ + # ため、変数が親プロセスに残らない subshell 化はできない。代償として env 内に + # `exit` 等があると wrapper ごと終了するが、env は (a) プロジェクト所有者が + # 管理する信頼境界内のファイルで (b) 元々 L24 で初期 CWD でも source される + # ため、ここで新たなリスクが増えるわけではない。万一 exit を含む env を読んで + # も「該当プロジェクトの操作が中断する」だけで他プロジェクトへ波及しない。 + [ -f "env" ] && set -a && source ./env && set +a + return 0 } # Resolve the command (skip flags like --version, -V, -h, --help) @@ -187,14 +243,60 @@ case "$_resolved_cmd" in *) _resolved_cmd="$(resolve_command "$_resolved_cmd")" ;; esac +# name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から +# 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。 +# name 候補の位置: +# project|container -> $3 (サブコマンドは保持) +# トップレベルシノニム -> $2 +# +# 重要 (PLAN06 codex 指摘対応): `project`/`container` グループでは parser が +# `name` positional を持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`) に限定 +# して $3 を name 解決する。`project login` / `project build` は単一 positional が +# index / image (旧 container 互換) であり parser が name を受け付けない +# (cli.py の _add_login_subparser / _add_build_subparser 参照)。これらで $3 を +# name strip すると、`project build web` の image=web や `project login web` の +# index 引数が実在プロジェクト名と一致した瞬間に消えて別操作へ化けるため除外する。 +# +# トップレベルシノニム (`build`/`login` を含む) は従来どおり「実在 project なら +# cd」方針を維持する: トップレベル `build`/`login` は Python parser を経由せず +# shell cmd_build / wrapper cd だけが name 指定の手段であり、`build carmo` / +# `login carmo` を「そのプロジェクトを操作」と解釈する設計 (存在性ベース判定)。 +_DEVBASE_ARGS=("${@:2}") +# 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。 +# _PROJECT_NAME_SUBCOMMANDS = `project`/`container` で `name` positional を +# 受け付けるサブコマンド集合。cli.py の _add_project_parser で +# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale) と一致させる。 +# login/build は index/image 互換のため意図的に除外 (上のコメント参照)。 +# _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」 +# を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される +# 集合 + shell 実装の build を含む。 +# cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること +# (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。 +_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale " +_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build " +case "$_resolved_cmd" in + project|container) + if [[ "$_PROJECT_NAME_SUBCOMMANDS" == *" ${2:-} "* ]] \ + && maybe_cd_project "${3:-}"; then + _DEVBASE_ARGS=("${2:-}" "${@:4}") + fi + ;; + *) + if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]] \ + && maybe_cd_project "${2:-}"; then + _DEVBASE_ARGS=("${@:3}") + fi + ;; +esac + case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|shell-rc|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale) - run_python "${_resolved_cmd}" "${@:2}" ;; + init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|list) + run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands - build) shift; cmd_build "$@" ;; + build) cmd_build "${_DEVBASE_ARGS[@]}" ;; # Help and unknown -h|--help|help|"") run_python "--help" ;; *) echo "Error: unknown command '$1'" >&2; exit 1 ;; diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index d099d0a..9e2f8d0 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -11,38 +11,54 @@ graph TD A[devbase] --> B[init] A --> C[status] A --> H[shell-rc] - A --> D[container / ct] + A --> D[project] A --> E[env] A --> F[plugin / pl] A --> G[snapshot / ss] - D --> D1[up / down / login / ps / logs / scale / build] + D --> D1["up / down / ps / logs / scale [name]"] + D --> D3["login [index]"] + D --> D4["build [image]"] + D --> D2["list [--interactive]"] E --> E1[init / sync / list / set / get / delete / edit / project] F --> F1[list / install / uninstall / update / info / sync] F --> F2[repo add / repo remove / repo list / repo refresh] G --> G1[create / list / restore / copy / delete / rotate] ``` +> **`container` グループは非推奨になりました。** 旧 `devbase container ` は +> `devbase project ` のエイリアスとして当面動作しますが、実行時に非推奨警告を +> 表示します(移行期間後のリリースで削除予定)。新しいコマンドは `project` を使用してください。 + ### グループエイリアス 各グループには短縮形が用意されています。 -| グループ名 | エイリアス | -|-----------|-----------| -| `container` | `ct` | -| `plugin` | `pl` | -| `snapshot` | `ss` | +| グループ名 | エイリアス | 備考 | +|-----------|-----------|------| +| `plugin` | `pl` | | +| `snapshot` | `ss` | | +| `container` | `ct` | **非推奨**(`project` へ移行してください) | ### ショートカットコマンド -頻繁に使用するコンテナ操作はトップレベルから直接実行できます。これらは `container` グループに自動転送されます。 +頻繁に使用するプロジェクト操作はトップレベルから直接実行できます。これらは `project` グループに自動転送されます。 | ショートカット | 転送先 | |--------------|--------| -| `devbase up` | `devbase container up` | -| `devbase down` | `devbase container down` | -| `devbase login` | `devbase container login` | -| `devbase build` | `devbase container build` | -| `devbase ps` | `devbase container ps` | +| `devbase up [name]` | `devbase project up [name]` | +| `devbase down [name]` | `devbase project down [name]` | +| `devbase login [index]` | `devbase project login [index]` | +| `devbase build [image]` | `bin/devbase` の `cmd_build`(シェル実装)※ | +| `devbase ps [name]` | `devbase project ps [name]` | +| `devbase scale [name] ` | `devbase project scale [name] ` | +| `devbase list` | `devbase project list` | + +> **Note:** `logs` はトップレベルシノニムを持ちません。`devbase project logs` を使用してください。 +> +> **※ `build` の転送先について:** `devbase build` は他のショートカットのように `project` グループ +> (Python 実装)へ転送されるのではなく、`bin/devbase` のシェル実装 `cmd_build` に直接委譲されます。 +> base イメージの段階ビルド等を CWD で行う必要があるためで、`devbase project build` とは実装経路が +> 異なります(名前指定はラッパーの `cd` で解決)。挙動上の入出力は同等ですが、実装は別物です。 ### ユニークプレフィックスマッチング @@ -113,17 +129,44 @@ source "$(./bin/devbase shell-rc)" > **⚠ 引用符は必須**: `source $(devbase shell-rc)` のように引用符を省くと、ホームディレクトリ名に空白を含む環境(例: `/Users/foo bar/.zshrc`)で word splitting が起き `source` が失敗します。必ず `source "$(devbase shell-rc)"` の形で書いてください。 -## container (ct) グループ +## project グループ + +プロジェクト(コンテナ)のライフサイクル管理と一覧表示を行うコマンド群です。 + +### プロジェクト名指定(CWD 非依存) + +`up` / `down` / `ps` / `logs` / `scale` は省略可能な `[name]` 引数を取ります。`[name]` +を指定すると、**現在のディレクトリに依存せず** `$DEVBASE_ROOT/projects/` を対象に +操作できます。 + +```bash +# 任意のディレクトリから adminer プロジェクトを起動 +devbase project up adminer + +# 省略時は従来どおりカレントディレクトリのプロジェクトを対象にする +cd $DEVBASE_ROOT/projects/adminer && devbase project up +``` + +- `` は `$DEVBASE_ROOT/projects/` 配下のプロジェクト名(`devbase project list` で確認可能) +- 存在しない名前を指定するとエラーになり、利用可能なプロジェクト候補が表示されます +- 名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行します。 + これにより `build`(シェル実装)を含む全操作が名前指定で成立します +- `devbase` は PATH 上の実行ファイルとして子プロセスで起動されるため、この `cd` が + **呼び出し元シェルの作業ディレクトリを変えることはありません** -コンテナのライフサイクル管理を行うコマンド群です。 +> **`project login` / `project build` は `[name]` を取りません。** これらの単一引数はそれぞれ +> `index` / `image` であり、`[name]` を許すと `project login 2` / `project build web` が誤解釈される +> ため除外しています。一方、トップレベルシノニム `devbase build ` / `devbase login ` は +> ラッパー (`bin/devbase`) の存在性判定(`$DEVBASE_ROOT/projects/` が実在すれば cd)で +> 名前解決されます(実在しない場合は従来どおり `index` / `image` として下流へ渡されます)。 -### `devbase container up` +### `devbase project up` コンテナを起動します。 ``` -devbase container up -devbase up +devbase project up [name] +devbase up [name] ``` - 起動時にスナップショットを自動作成(新世代 or 差分追加) @@ -136,23 +179,23 @@ devbase up (前回 pull 日時は `${DEVBASE_ROOT}/.cache/pulls/` の touch-file mtime で判定) - 閾値は `DEVBASE_IMAGE_MAX_AGE_DAYS` 環境変数で上書き可能(既定 7、不正値は警告して既定値) -### `devbase container down` +### `devbase project down` コンテナを停止・削除します。 ``` -devbase container down -devbase down +devbase project down [name] +devbase down [name] ``` - 停止時にスナップショットのローテーションを自動実行 -### `devbase container login` +### `devbase project login` コンテナにログインします。 ``` -devbase container login [index] +devbase project login [index] devbase login [index] ``` @@ -168,25 +211,26 @@ devbase login devbase login 2 ``` -### `devbase container ps` +### `devbase project ps` -コンテナの状態を表示します。 +対象プロジェクトのコンテナ状態を `docker compose ps` で表示します。複数プロジェクトの +横断一覧は `devbase project list` を使用してください。 ``` -devbase container ps [-a] -devbase ps [-a] +devbase project ps [name] [-a] +devbase ps [name] [-a] ``` | オプション | 説明 | |-----------|------| | `-a` | 停止中のコンテナも表示 | -### `devbase container logs` +### `devbase project logs` -コンテナのログを表示します。 +コンテナのログを表示します(トップレベルシノニムはありません)。 ``` -devbase container logs [-f] [--tail N] +devbase project logs [name] [-f] [--tail N] ``` | オプション | 説明 | @@ -196,35 +240,37 @@ devbase container logs [-f] [--tail N] ```bash # 最新50行をリアルタイムで追跡 -devbase container logs -f --tail 50 +devbase project logs -f --tail 50 ``` -### `devbase container scale` +### `devbase project scale` 既存のコンテナを再起動せずにスケールします。 ``` -devbase container scale +devbase project scale [name] +devbase scale [name] ``` | パラメータ | 必須 | 説明 | |-----------|------|------| +| `name` | いいえ | 対象プロジェクト名(省略時はカレント) | | `` | はい | コンテナ数 | ```bash # コンテナを3台に増やす -devbase container scale 3 +devbase project scale 3 -# コンテナを1台に減らす -devbase container scale 1 +# 任意のディレクトリから adminer を3台に +devbase project scale adminer 3 ``` -### `devbase container build` +### `devbase project build` コンテナイメージをビルドします。 ``` -devbase container build [image] +devbase project build [image] devbase build [image] ``` @@ -232,6 +278,58 @@ devbase build [image] |-----------|------|------| | `image` | いいえ | ビルドするイメージ名(省略時は全イメージ) | +### `devbase project list` + +`$DEVBASE_ROOT/projects/` 配下のプロジェクトを `NAME` / `PLUGIN` / `STATUS` の一覧で +表示します。 + +``` +devbase project list [--interactive|-i] +devbase list [--interactive|-i] +``` + +| オプション | 説明 | +|-----------|------| +| `--interactive` / `-i` | 一覧から番号で選択し、そのプロジェクトを `project up` で起動 | + +```bash +# 一覧表示 +devbase list + +# 一覧から選んで起動(非対話環境では番号入力にフォールバック) +devbase list -i +``` + +出力例: + +``` +NAME PLUGIN STATUS +adminer adminer running (2 containers) +carmo carmo stopped +carmo.takemi carmo-fork stopped +``` + +- `PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix + (例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します +- `STATUS` は `running (N containers)` / `stopped` / `unknown`(docker 未起動・ + `compose.yml` 不在等で判定不能)のいずれか + +## container (ct) グループ(非推奨) + +> **非推奨:** `container` グループは `project` グループへ移行しました。`devbase container +> ` は当面 `devbase project ` のエイリアスとして動作しますが、実行時に非推奨警告を +> 表示します(移行期間後のリリースで削除予定)。`[name]` 指定や `list` などの新機能は +> `project` 側のみで提供されます。 + +```bash +# 旧(非推奨・警告が出ます) +devbase container up + +# 新(推奨) +devbase project up +devbase up +``` + ## env グループ 環境変数の管理を行うコマンド群です。詳細は [環境変数ガイド](environment-variables.md) を参照してください。 diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 9e76fcd..d15889d 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -2,6 +2,13 @@ devbase のコンテナ管理機能について、ライフサイクル、並行開発、ボリューム構造、イメージ階層を解説します。 +> **コマンド体系について:** コンテナ操作は `devbase project ` グループ(および +> トップレベルショートカット `devbase up` 等)で行います。旧 `devbase container ` は +> 非推奨となり、`project` へのエイリアスとして警告付きで当面動作します。`project` では +> `up` / `down` / `ps` / `logs` / `scale` に `[name]` を指定することで **任意のディレクトリ +> から** 対象プロジェクトを操作できます。プロジェクト一覧は `devbase project list` を参照 +> してください。詳細は [CLI リファレンス](cli-reference.md#project-グループ) を参照。 + ## コンテナライフサイクル devbase のコンテナは以下のライフサイクルで管理されます。 @@ -75,10 +82,13 @@ CONTAINER_SCALE=2 ```bash # コンテナを3台に増やす(既存コンテナは再起動しない) -devbase container scale 3 +devbase project scale 3 # コンテナを1台に減らす -devbase container scale 1 +devbase project scale 1 + +# 任意のディレクトリから adminer を3台に +devbase project scale adminer 3 ``` ### 各コンテナへのログイン @@ -216,15 +226,28 @@ devbase ps -a ```bash # 最新のログを表示 -devbase container logs +devbase project logs # リアルタイムでログを追跡 -devbase container logs -f +devbase project logs -f # 末尾100行のみ追跡 -devbase container logs -f --tail 100 +devbase project logs -f --tail 100 +``` + +### プロジェクト一覧 + +```bash +# 全プロジェクトを NAME / PLUGIN / STATUS で一覧表示 +devbase list + +# 一覧から選択して起動(非対話環境では番号入力にフォールバック) +devbase list -i ``` +`devbase project ps` が「対象プロジェクト 1 つのコンテナ状態」を表示するのに対し、 +`devbase list` は「全プロジェクトの横断一覧」を表示します。 + ### 環境の全体像 ```bash diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index b2f5cf8..c996505 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -163,7 +163,7 @@ devbase login devbase ps # ログの確認 -devbase container logs -f +devbase project logs -f # 2番目のコンテナにログイン(並行作業) devbase login 2 diff --git a/etc/_devbase b/etc/_devbase index cb3a5cb..6d9c53e 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -13,6 +13,19 @@ _devbase_plugin_names() { fi } +_devbase_project_names() { + local devbase_root projects_dir + devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" + projects_dir="${devbase_root}/projects" + if [[ -d "$projects_dir" ]]; then + local -a projects + # 実ディレクトリ + symlink 先がディレクトリのものを対象 + # (N: nullglob, -: symlink 追従, /: ディレクトリのみ, :t: tail)。 + projects=(${projects_dir}/*(N-/:t)) + _describe 'project' projects + fi +} + _devbase_repo_names() { local devbase_root yml devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" @@ -32,15 +45,16 @@ except Exception: } _devbase() { - local -a commands container_subcommands env_subcommands + local -a commands project_subcommands container_subcommands env_subcommands local -a plugin_subcommands repo_subcommands snapshot_subcommands commands=( 'init:Initialize devbase environment' 'status:Show overall status' 'shell-rc:Print shell RC file path (for source ...)' - 'container:Manage containers' - 'ct:Manage containers (alias)' + 'project:Manage projects (CWD-independent)' + 'container:Manage containers (deprecated: use project)' + 'ct:Manage containers (deprecated alias)' 'env:Manage environment variables' 'plugin:Manage plugins' 'pl:Manage plugins (alias)' @@ -51,9 +65,22 @@ _devbase() { 'login:Login to container (shortcut)' 'build:Build container images' 'ps:Show container status (shortcut)' + 'scale:Scale containers online (shortcut)' + 'list:List projects (shortcut)' 'help:Show help' ) + project_subcommands=( + 'up:Start containers' + 'down:Stop and remove containers' + 'ps:Show container status' + 'login:Login to container' + 'logs:Show container logs' + 'scale:Scale containers online' + 'build:Build container images' + 'list:List projects (NAME / PLUGIN / STATUS)' + ) + container_subcommands=( 'up:Start containers' 'down:Stop and remove containers' @@ -107,6 +134,65 @@ _devbase() { login) _values 'index' 1 2 ;; + # トップレベルシノニム: up/down/ps/scale は [name] を取るためプロジェクト名を補完。 + up|down) + _devbase_project_names + ;; + ps) + if [[ "$words[3]" == -* || -n "$words[3]" ]]; then + _arguments '--all[Show all containers]' '-a[Show all containers]' + else + _devbase_project_names + fi + ;; + scale) + _devbase_project_names + ;; + list) + _arguments \ + '--interactive[Select a project interactively and start it]' \ + '-i[Select a project interactively and start it]' + ;; + project) + case "$words[3]" in + up|down) + _devbase_project_names + ;; + login) + _values 'index' 1 2 + ;; + scale) + _devbase_project_names + ;; + ps) + # name (positional) と -a フラグを _arguments に統合。 + # name 入力済みなら positional は補完されず、フラグのみ残る + # (bash 側の排他分岐と挙動を揃える)。 + _arguments \ + '--all[Show all containers]' \ + '-a[Show all containers]' \ + '1:name:_devbase_project_names' + ;; + logs) + _arguments \ + '--follow[Follow log output]' \ + '-f[Follow log output]' \ + '--tail[Number of lines]:lines:' \ + '1:name:_devbase_project_names' + ;; + build) + _arguments '1:image:' + ;; + list) + _arguments \ + '--interactive[Select a project interactively and start it]' \ + '-i[Select a project interactively and start it]' + ;; + *) + _describe -t project-commands 'project command' project_subcommands + ;; + esac + ;; container|ct) case "$words[3]" in login) diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index fdba9df..bf45245 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -1,5 +1,26 @@ # bash completion for devbase +# projects/ 配下のプロジェクト名 (symlink / 実ディレクトリ) を列挙する。 +# `devbase project up ` やトップレベルシノニム `devbase up ` の +# name 補完に使う。 +_devbase_project_names() { + local devbase_root + devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" + local projects_dir="${devbase_root}/projects" + if [ -d "$projects_dir" ]; then + # -L で symlink を辿り -type d で判定することで、実ディレクトリと + # 「ディレクトリへの symlink」のみを列挙する (壊れた symlink / ファイルへの + # symlink は除外)。zsh 側 (*(N-/:t)) と挙動を揃える。GNU 専用の -xtype は + # BSD/macOS find で動かないため避け、POSIX/BSD/GNU 共通の -L を使う。 + # basename 化は `xargs -r`(GNU 拡張) を避け、各行を POSIX パラメータ展開 + # ${p##*/} で処理する (外部プロセス不要・空入力でも安全・BSD/macOS 互換)。 + find -L "$projects_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null \ + | while IFS= read -r p; do + printf '%s\n' "${p##*/}" + done + fi +} + _devbase_completions() { local cur prev words cword _init_completion 2>/dev/null || { @@ -10,7 +31,9 @@ _devbase_completions() { cword=$COMP_CWORD } - local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help" + # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 + local project_subcommands="up down ps login logs scale build list" local container_subcommands="up down ps login logs scale build" local env_subcommands="init sync list set get delete edit project export import" local plugin_subcommands="list install uninstall update info sync repo" @@ -26,6 +49,27 @@ _devbase_completions() { login) COMPREPLY=($(compgen -W "1 2" -- "$cur")) ;; + # トップレベルシノニム: up/down/scale は [name] を取るため + # プロジェクト名を補完する (login=index / build=image は対象外)。 + up|down|scale) + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + ;; + # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 + ps) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--all -a" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; + # list は位置引数を取らず --interactive のみ。`-*` ガードを外し + # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 + list) + COMPREPLY=($(compgen -W "--interactive -i" -- "$cur")) + ;; + project) + COMPREPLY=($(compgen -W "$project_subcommands" -- "$cur")) + ;; container|ct) COMPREPLY=($(compgen -W "$container_subcommands" -- "$cur")) ;; @@ -42,7 +86,49 @@ _devbase_completions() { ;; 3) local group="${words[1]}" - # container subcommand arguments + # トップレベルシノニム ps: `devbase ps web -` (group=ps, cword=3) + # でも name 位置が埋まった後にフラグを補完する。project ps と対称化。 + if [ "$group" = "ps" ]; then + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--all -a" -- "$cur")) + fi + fi + # project subcommand arguments (推奨グループ) + if [ "$group" = "project" ]; then + case "$prev" in + up|down) + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + ;; + login) + COMPREPLY=($(compgen -W "1 2" -- "$cur")) + ;; + scale) + # `project scale N` / `project scale N` の両形。 + # name 補完を提示する (数値はユーザが直接入力)。 + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + ;; + ps) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--all -a" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; + logs) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--follow -f --tail" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; + # list は位置引数を取らず --interactive のみ。`-*` ガードを外し + # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 + list) + COMPREPLY=($(compgen -W "--interactive -i" -- "$cur")) + ;; + esac + fi + # container subcommand arguments (非推奨: project へ移行してください) if [ "$group" = "container" ] || [ "$group" = "ct" ]; then case "$prev" in login) @@ -142,6 +228,22 @@ _devbase_completions() { ;; 4) local group="${words[1]}" + # project ps/logs: name 位置が埋まった後 (例: `project ps web -`) + # でもフラグを補完する。subcommand は words[2]。 + if [ "$group" = "project" ]; then + case "${words[2]}" in + ps) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--all -a" -- "$cur")) + fi + ;; + logs) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--follow -f --tail" -- "$cur")) + fi + ;; + esac + fi # plugin install flags after source argument if [ "$group" = "plugin" ] || [ "$group" = "pl" ]; then if [ "${words[2]}" = "install" ]; then diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index f185419..d22773e 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -16,14 +16,24 @@ logger = get_logger("devbase.cli") -# Shortcuts: top-level command -> (group, subcommand) +# Shortcuts: top-level command -> project subcommand +# 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。 +# NOTE: `build` はここに含めない。配布入口 bin/devbase が `build` を shell の +# cmd_build (devbase-base 依存検出 + 2 段ビルド + --no-cache 対応) に委譲しており、 +# Python の project build (単純な compose build) とは実装が異なるため。Python 側で +# `build` を project build ショートカットとして広告すると wrapper の実経路と乖離する。 +# project build / container build サブコマンド自体は引き続き利用可能。 +# +# 同期注意 (メンテナンス性): SHORTCUTS のキー集合と _add_project_parser の +# `name` positional 付きサブコマンドは bin/devbase の _NAME_RESOLVABLE_SHORTCUTS / +# _PROJECT_NAME_SUBCOMMANDS と対応している。サブコマンドを追加/削除する際は +# wrapper 側 (bin/devbase の該当リスト) の更新漏れに注意すること。 SHORTCUTS = { - 'up': ('container', 'up'), - 'down': ('container', 'down'), - 'login': ('container', 'login'), - 'build': ('container', 'build'), - 'ps': ('container', 'ps'), - 'scale': ('container', 'scale'), + 'up': 'up', + 'down': 'down', + 'login': 'login', + 'ps': 'ps', + 'scale': 'scale', } # Group aliases @@ -35,6 +45,7 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { + ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], @@ -53,6 +64,14 @@ }, } +# トップレベルコマンドの ambiguous prefix 後方互換 preference。 +# `list` (PLAN06 Task 3) 追加で `l` が `login` / `list` の両方にマッチして +# ambiguous になったため、既存ショートカット (`devbase l` → `login`) を維持する。 +# bin/devbase の resolve_command 内 preference と同期させること。 +TOP_PREFIX_PREFERENCES = { + 'l': 'login', +} + def _require_devbase_root() -> Path: """Get DEVBASE_ROOT from environment, exiting if not set.""" @@ -63,6 +82,30 @@ def _require_devbase_root() -> Path: return Path(root) +def _add_login_subparser(sub): + """`login` サブコマンドを登録する (project / container 共通)。 + + 単一 positional `index` の意味は両グループで完全に同一。`[name]` を足すと + `project login 2` を name='2' と誤解釈して index=1 にログインしてしまう曖昧さ + (旧 `container login ` との非互換) が生じるため、project でも name を + 受け付けない。PR2 で project name 解決を導入する際は曖昧さのない `--name` + オプションで対応する方針。 + """ + p = sub.add_parser('login', help='Login to container') + p.add_argument('index', nargs='?', default='1', help='Container index') + + +def _add_build_subparser(sub): + """`build` サブコマンドを登録する (project / container 共通)。 + + 単一 positional `image` の意味は両グループで同一。`[name]` を許すと + `project build web` が name='web', image=None となり image 指定ビルドが + compose build に化けるため、project でも name を受け付けない (login 参照)。 + """ + p = sub.add_parser('build', help='Build container images') + p.add_argument('image', nargs='?', default=None, help='Image name') + + def _add_container_parser(subparsers): """Container group parser""" ct_parser = subparsers.add_parser('container', aliases=['ct'], @@ -72,8 +115,7 @@ def _add_container_parser(subparsers): ct_sub.add_parser('up', help='Start containers') ct_sub.add_parser('down', help='Stop and remove containers') - ct_login = ct_sub.add_parser('login', help='Login to container') - ct_login.add_argument('index', nargs='?', default='1', help='Container index') + _add_login_subparser(ct_sub) ct_ps = ct_sub.add_parser('ps', help='Show container status') ct_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') @@ -85,8 +127,67 @@ def _add_container_parser(subparsers): ct_scale = ct_sub.add_parser('scale', help='Scale containers online') ct_scale.add_argument('new_scale', type=int, help='New number of containers') - ct_build = ct_sub.add_parser('build', help='Build container images') - ct_build.add_argument('image', nargs='?', default=None, help='Image name') + _add_build_subparser(ct_sub) + + +def _add_project_parser(subparsers): + """Project group parser (CWD 非依存のプロジェクト操作)。 + + `container` と同じ subcommand 群に、省略可能な `[name]` positional を加える。 + name によるディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きは PLAN06 Task 2 (PR2) + で wrapper の cd + Python フォールバックとして実装する。PR1 では parser 構造と + name のパースまでを用意する。 + + 例外: `login` / `build` は単一 positional が旧 `container` と同義 (index / image) + であり、`[name]` を足すと `project login 2` / `project build web` が誤解釈される + ため name を受け付けない。両者は project / container で定義が完全に一致するので + `_add_login_subparser` / `_add_build_subparser` に共通化している。 + + 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale) + は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は + wrapper 側リストの更新漏れに注意すること。 + """ + pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') + pj_sub = pj_parser.add_subparsers(dest='subcommand') + + pj_up = pj_sub.add_parser('up', help='Start containers') + pj_up.add_argument('name', nargs='?', default=None, help='Project name') + + pj_down = pj_sub.add_parser('down', help='Stop and remove containers') + pj_down.add_argument('name', nargs='?', default=None, help='Project name') + + _add_login_subparser(pj_sub) + + pj_ps = pj_sub.add_parser('ps', help='Show container status') + pj_ps.add_argument('name', nargs='?', default=None, help='Project name') + pj_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') + + pj_logs = pj_sub.add_parser('logs', help='Show container logs') + pj_logs.add_argument('name', nargs='?', default=None, help='Project name') + pj_logs.add_argument('--follow', '-f', action='store_true', help='Follow log output') + pj_logs.add_argument('--tail', type=int, default=None, help='Number of lines') + + # NOTE: `[name]` optional + `new_scale` 必須 int の順。値が 1 個なら new_scale に、 + # 2 個なら (name, new_scale) に割り当てられ曖昧にならない (tests/cli 参照)。 + pj_scale = pj_sub.add_parser('scale', help='Scale containers online') + pj_scale.add_argument('name', nargs='?', default=None, help='Project name') + pj_scale.add_argument('new_scale', type=int, help='New number of containers') + + _add_build_subparser(pj_sub) + + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は + # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 + _add_list_subparser(pj_sub) + + +def _add_list_subparser(sub): + """`list` サブコマンドを登録する (project list / top-level list 共通)。 + + NAME / PLUGIN / STATUS の一覧表示。`--interactive` で選択 → `project up` 起動。 + """ + p = sub.add_parser('list', help='List projects (NAME / PLUGIN / STATUS)') + p.add_argument('--interactive', '-i', action='store_true', + help='Select a project interactively and start it') def _add_env_parser(subparsers): @@ -285,22 +386,43 @@ def _add_snapshot_parser(subparsers): def _add_shortcuts(subparsers): - """Top-level shortcut parsers""" + """Top-level shortcut parsers. + + 委譲先の `project` サブコマンドと引数体系を揃えるため、`up` / `down` / `ps` / + `scale` は `project [name]` と同じく省略可能な `[name]` positional を + 受け付ける (`devbase up carmo` ≡ `devbase project up carmo`)。受理した name は + _dispatch でショートカット経由でも下流 (cmd_project → _dispatch_lifecycle) へ + 伝播する。name の実解決は PLAN06 Task 2 (PR2) で実装するため、PR1 では up/scale + も含め name 指定時に未対応 warning を出す (container.py 参照)。 + + `login` は project login と同様に単一 positional を `index` として扱い `[name]` + は受け付けない (曖昧さ回避)。`build` はショートカットに含めない (SHORTCUTS の + 注記参照): bin/devbase が build を shell 実装 (cmd_build) に委譲するため、 + Python 側でトップレベル build を広告すると実経路と乖離する。 + """ login_sc = subparsers.add_parser('login', help='Login to container') login_sc.add_argument('index', nargs='?', default='1', help='Container index') - build_sc = subparsers.add_parser('build', help='Build container images') - build_sc.add_argument('image', nargs='?', default=None, help='Image name') - ps_sc = subparsers.add_parser('ps', help='Show container status') + ps_sc.add_argument('name', nargs='?', default=None, help='Project name') ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') - subparsers.add_parser('up', help='Start containers') - subparsers.add_parser('down', help='Stop and remove containers') + up_sc = subparsers.add_parser('up', help='Start containers') + up_sc.add_argument('name', nargs='?', default=None, help='Project name') + + down_sc = subparsers.add_parser('down', help='Stop and remove containers') + down_sc.add_argument('name', nargs='?', default=None, help='Project name') + # `[name]` optional + `new_scale` 必須 int の順 (project scale と同じ規則)。 scale_sc = subparsers.add_parser('scale', help='Scale containers online') + scale_sc.add_argument('name', nargs='?', default=None, help='Project name') scale_sc.add_argument('new_scale', type=int, help='New number of containers') + # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 + # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に + # cmd_project_list へ振り分ける。 + _add_list_subparser(subparsers) + def _create_parser(): """Create command line parser""" @@ -310,12 +432,13 @@ def _create_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Shortcuts:\n" - " up container up\n" - " down container down\n" - " login container login\n" - " build container build\n" - " ps container ps\n" - " scale container scale\n" + " up project up\n" + " down project down\n" + " login project login\n" + " ps project ps\n" + " scale project scale\n" + "\n" + "Note: `container` is deprecated; use `project` instead.\n" ) ) @@ -342,6 +465,7 @@ def _create_parser(): help='Print shell RC file path (e.g. source "$(devbase shell-rc)")' ) + _add_project_parser(subparsers) _add_container_parser(subparsers) _add_env_parser(subparsers) _add_plugin_parser(subparsers) @@ -371,12 +495,20 @@ def _resolve_prefix(input_cmd, candidates, preferences=None): def _expand_argv(): """Expand abbreviated command/subcommand names in sys.argv in-place.""" - commands = ['init', 'status', 'shell-rc', 'container', 'ct', 'env', 'plugin', 'pl', - 'snapshot', 'ss', 'up', 'down', 'login', 'build', 'ps', 'scale', 'help'] + # この `commands` リストの並びは _create_parser のグループ登録順と一致させる: + # トップレベル → グループ (各 group の直後にその alias を隣接配置: container/ct, + # plugin/pl, snapshot/ss) → ショートカット。`project` (推奨) を `container` + # (非推奨) より前に置くのは登録順と揃えた意図的な並びで、prefix 解決は + # _resolve_prefix が一意一致のみ採用するため順序に機能的影響はない。 + # `build` はトップレベルショートカットから除外 (SHORTCUTS の注記参照)。 + # bin/devbase が build を shell 実装に委譲するため Python 側には top-level + # build parser が無い。project build / container build は引き続き利用可能。 + commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl', + 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'list', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): - sys.argv[1] = _resolve_prefix(sys.argv[1], commands) + sys.argv[1] = _resolve_prefix(sys.argv[1], commands, TOP_PREFIX_PREFERENCES) if len(sys.argv) >= 3 and not sys.argv[2].startswith('-'): cmd = sys.argv[1] @@ -418,13 +550,31 @@ def _dispatch(cmd, args): # Resolve group aliases cmd = GROUP_ALIASES.get(cmd, cmd) - # --- Shortcuts (top-level -> container subcommand) --- + # --- Shortcuts (top-level -> project subcommand) --- + # ショートカットは非推奨ではないため、warning を出す cmd_container ではなく + # 共有の cmd_project へ委譲する。 if cmd in SHORTCUTS: - args.subcommand = SHORTCUTS[cmd][1] - from devbase.commands.container import cmd_container - return cmd_container(args) - - # --- Container group --- + args.subcommand = SHORTCUTS[cmd] + from devbase.commands.container import cmd_project + return cmd_project(args) + + # --- Project group (推奨) --- + if cmd == 'project': + # `project list` は lifecycle ではなく一覧表示 (DEVBASE_ROOT 必須)。 + if getattr(args, 'subcommand', None) == 'list': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_list + return cmd_project_list(devbase_root, args) + from devbase.commands.container import cmd_project + return cmd_project(args) + + # --- Top-level `list` synonym for `project list` --- + if cmd == 'list': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_list + return cmd_project_list(devbase_root, args) + + # --- Container group (非推奨: project へ委譲 + warning) --- if cmd == 'container': from devbase.commands.container import cmd_container return cmd_container(args) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 528a143..25c886f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -81,12 +81,169 @@ def _run_pre_up_hook() -> bool: # ディスパッチャ # --------------------------------------------------------------------------- -def cmd_container(args) -> int: - """サブコマンドディスパッチャ""" +def _projects_dir() -> Optional[Path]: + """$DEVBASE_ROOT/projects を返す。DEVBASE_ROOT 未設定なら None。""" + root = os.environ.get('DEVBASE_ROOT') + if not root: + return None + return Path(root) / 'projects' + + +# 候補一覧に表示するプロジェクト数の上限。多数プロジェクト環境で iterdir 全件を +# 出力すると 1 行が極端に長くなるため、先頭 N 件 + 「... 他 M 件」で truncate する。 +_MAX_PROJECT_CANDIDATES = 20 + + +def _report_unknown_project(name: str, projects_dir: Path) -> None: + """存在しない project name に対するエラーと候補一覧を出力する。 + + 候補が多数の場合は先頭 ``_MAX_PROJECT_CANDIDATES`` 件のみ表示し、残りは + 「... 他 M 件」と省略する。 + """ + logger.error("プロジェクト '%s' が見つかりません (%s 配下に存在しません)。", + name, projects_dir) + try: + candidates = sorted( + p.name for p in projects_dir.iterdir() + if p.is_dir() or p.is_symlink() + ) + except OSError: + candidates = [] + if candidates: + total = len(candidates) + shown = candidates[:_MAX_PROJECT_CANDIDATES] + listing = ', '.join(shown) + if total > _MAX_PROJECT_CANDIDATES: + listing += f', ... 他 {total - _MAX_PROJECT_CANDIDATES} 件' + logger.error("利用可能なプロジェクト: %s", listing) + + +def _load_project_env(env_file: Path) -> None: + """プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。 + + wrapper (bin/devbase) は cd 後に ``source ./env`` で env を読み込むため、 + Python フォールバック経路でも同じ KEY=VALUE を ``os.environ`` に載せて + 変数欠落 (例: project 固有の ``CONTAINER_SCALE``) を防ぐ。 + + env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の + ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを + 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell + の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。 + + .. note:: shell ``source`` との仕様乖離について + + 本パーサは完全な POSIX shell パーサではなく、shell ``source ./env`` + (wrapper 経路) とは以下のケースで挙動が乖離する。env は単純な + ``KEY=VALUE`` 定義に限定する運用前提のため、これらは意図的な制約として + 受容し、ファイル側で利用しない方針とする (仕様統一ではなく制約の明示):: + + FOO=$BAR # shell: 展開 → 本実装: リテラル文字列 "$BAR" + FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)" + FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の + # クォートは除去せず "a\"b\"c" + FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") → + # 本実装も値は "bar # x" (行頭 # のみコメント扱い) + + いずれも wrapper を経ない直接起動 (例: + ``python -m devbase.cli project up ``) のフォールバック時のみ影響し、 + 通常運用の wrapper 経路では shell が env を解釈するため差異は生じない。 + """ + if not env_file.is_file(): + return + try: + lines = env_file.read_text().splitlines() + except OSError as e: + logger.warning("env ファイルを読み込めませんでした (%s): %s", env_file, e) + return + for raw in lines: + line = raw.strip() + if not line or line.startswith('#'): + continue + if line.startswith('export '): + line = line[len('export '):].lstrip() + if '=' not in line: + continue + key, value = line.split('=', 1) + key = key.strip() + if not key: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + os.environ[key] = value + + +def _resolve_project_name(project_name: str) -> bool: + """project name を $DEVBASE_ROOT/projects/ へ解決し chdir する。 + + 通常は wrapper (bin/devbase) が起動前に cd 済みのため、ここは + + - `python -m devbase.cli project up ` の直接起動 + - wrapper を経ない経路 (`_ensure_env_files` 等) + + に対する防御的フォールバックとして働く。wrapper が既に対象ディレクトリへ + cd 済みなら chdir は no-op になる (同一パス判定)。 + + chdir 後は wrapper の ``source ./env`` と同等に project の ``env`` を + ``os.environ`` へ反映し、wrapper を経ない直接起動でも環境変数が欠落しない + ようにする (gemini round2 minor 指摘対応)。 + + Returns: + True: 解決成功 (または既に対象ディレクトリにいる) + False: DEVBASE_ROOT 未設定 / 対象が存在しない (呼び出し側で return 1) + """ + projects_dir = _projects_dir() + if projects_dir is None: + logger.error("DEVBASE_ROOT が未設定のため project name '%s' を解決できません。", + project_name) + return False + + target = projects_dir / project_name + if not target.is_dir(): + _report_unknown_project(project_name, projects_dir) + return False + + try: + already_there = target.resolve() == Path.cwd().resolve() + except OSError: + already_there = False + if not already_there: + os.chdir(target) + + # wrapper の `source ./env` と同等に project env を os.environ へ反映する。 + # wrapper 経由なら既に同じ値が載っているため冪等。 + _load_project_env(Path('env')) + + # COMPOSE_PROJECT_NAME を name で上書き (wrapper が設定済みでも冪等)。 + # env 由来の COMPOSE_PROJECT_NAME より name 指定を優先するため env 反映後に行う。 + os.environ['COMPOSE_PROJECT_NAME'] = project_name + return True + + +def _dispatch_lifecycle(args) -> int: + """`project` / `container` 共有のサブコマンドディスパッチャ。 + + `project [name]` の `name` を解決して project_name へ畳み込む。 + `container` 経路には `name` 属性が無いため従来通り None になる。 + + name 指定時は handler 呼び出し前に一括で `$DEVBASE_ROOT/projects/` へ + chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に + 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が + project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が + 効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この + Python フォールバックの対象外 (name 属性も持たない)。 + """ subcmd = getattr(args, 'subcommand', None) + project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) + + # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 + # / 存在しない name) は候補提示の上でエラー終了する。 + if project_name: + if not _resolve_project_name(project_name): + return 1 handlers = { - 'up': lambda: cmd_up(project_name=getattr(args, 'project_name', None), + 'up': lambda: cmd_up(project_name=project_name, scale=getattr(args, 'scale', None)), 'down': lambda: cmd_down(), 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), @@ -94,7 +251,7 @@ def cmd_container(args) -> int: 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), tail=getattr(args, 'tail', None)), 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), - project_name=getattr(args, 'project_name', None)), + project_name=project_name), 'build': lambda: cmd_build(image=getattr(args, 'image', None)), } @@ -106,6 +263,24 @@ def cmd_container(args) -> int: return 1 +def cmd_project(args) -> int: + """`devbase project [name]` ディスパッチャ (推奨エントリ)。""" + return _dispatch_lifecycle(args) + + +def cmd_container(args) -> int: + """`devbase container ` ディスパッチャ。 + + 非推奨: `devbase project` に移行してください (移行期間後に削除予定)。 + 挙動は `cmd_project` と同一で、警告のみ追加する。 + """ + logger.warning( + "`devbase container` は非推奨です。`devbase project` を使用してください " + "(将来のリリースで削除されます)。" + ) + return _dispatch_lifecycle(args) + + # --------------------------------------------------------------------------- # cmd_up (deploy.py の cmd_deploy を移植) # --------------------------------------------------------------------------- diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py new file mode 100644 index 0000000..b0a4368 --- /dev/null +++ b/lib/devbase/commands/project.py @@ -0,0 +1,181 @@ +"""Project listing commands (`devbase project list` / `devbase list`). + +PLAN06 Task 3。`$DEVBASE_ROOT/projects/` 配下を NAME / PLUGIN / STATUS で一覧表示し、 +``--interactive`` で選択 → `project up` 起動を行う。 + +ライフサイクル操作 (up/down/ps/login/logs/scale/build) は引き続き +``commands/container.py`` の共有ハンドラが担当し、本モジュールは listing と +interactive 起動のみを担う。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.log import get_logger + +logger = get_logger(__name__) + + +def _resolve_plugin_name(entry: Path) -> str | None: + """projects/ 配下の entry が属する plugin 名を解決する。 + + entry が symlink の場合、その **リンク先** (``..//projects/``) + から plugin 名を解決する。PLAN04 の同名衝突 suffix (例 ``carmo.takemi--carmo``) + は **リンク名のみ** に付与され、リンク先 dir 名は素の ```` のままであるため、 + リンク名でなくリンク先を辿ることで suffix の有無に関わらず正しく解決できる。 + + plugin 名はリンク先パスの ``projects`` セグメント直前の要素: + - repos ベース: ``../repos/--//projects/`` → ```` + - --link ベース: ``../plugins//projects/`` → ```` + + symlink でない実ディレクトリ (plugin に属さない) や解決不能な場合は ``None``。 + リンク先実体が存在しない (broken symlink) 場合もリンクテキストから解決する。 + """ + if not entry.is_symlink(): + return None + try: + target = os.readlink(entry) + except OSError: + return None + + parts = Path(target).parts + # `projects` の最後の出現位置 (proj 名の直前) を採用する。 + # ただし直前要素が plugin 名として無効なパス区切り (`/` ルートや `..` 相対) の + # 場合は解決失敗扱い (None)。例: `/projects/proj` → parts[0] が `/` になる。 + for i in range(len(parts) - 1, 0, -1): + if parts[i] == "projects": + candidate = parts[i - 1] + if candidate in (os.sep, "/", "..", "."): + return None + return candidate + return None + + +def list_projects(projects_dir: Path) -> list[dict]: + """projects/ 配下のプロジェクトを NAME / PLUGIN / STATUS で列挙する。 + + 各要素は ``{"name", "plugin", "status"}``。 + + - ``name``: projects/ 内のエントリ名 (衝突 suffix 付きもそのまま) + - ``plugin``: ``_resolve_plugin_name`` の結果。実ディレクトリ / 解決不能は ``"-"`` + - ``status``: ``status._container_status_for`` の状態文字列。 + compose.yml 無し / docker 不在等で取得できない場合は ``"unknown"`` + + symlink (broken 含む) と実ディレクトリの両方を対象とする。 + """ + # status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry + # 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。 + from concurrent.futures import ThreadPoolExecutor + + from devbase.commands import status as status_mod + + if not projects_dir.exists(): + return [] + + entries = [ + # broken symlink は is_dir() が False になるため symlink 自体も拾う。 + entry for entry in sorted(projects_dir.iterdir()) + if entry.is_symlink() or entry.is_dir() + ] + + def _status_for(entry: Path) -> str: + # is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。 + # _container_status_for は cwd= 引数で完結し global chdir を行わないため + # スレッド安全。各 `docker compose ps` は I/O バウンドで 10s timeout を + # 持つため、プロジェクト数が増えても並列化で総待ち時間を抑える。 + if not entry.is_dir(): + return "unknown" + st = status_mod._container_status_for(entry) + return st["status"] if st is not None else "unknown" + + # entries が空だと max_workers=0 で ValueError になるため早期 return。 + if not entries: + return [] + + with ThreadPoolExecutor(max_workers=min(8, len(entries))) as ex: + statuses = list(ex.map(_status_for, entries)) + + return [ + { + "name": entry.name, + "plugin": _resolve_plugin_name(entry) or "-", + "status": status, + } + for entry, status in zip(entries, statuses) + ] + + +def _print_table(rows: list[dict]) -> None: + """NAME / PLUGIN / STATUS の整列テーブルを標準出力に表示する。""" + name_w = max(len("NAME"), *(len(r["name"]) for r in rows)) + plugin_w = max(len("PLUGIN"), *(len(r["plugin"]) for r in rows)) + print(f"{'NAME':<{name_w}} {'PLUGIN':<{plugin_w}} STATUS") + for r in rows: + print(f"{r['name']:<{name_w}} {r['plugin']:<{plugin_w}} {r['status']}") + + +def _interactive_select_and_up(rows: list[dict]) -> int: + """一覧から番号入力で 1 件選択し ``project up `` を起動する。 + + 外部依存 (simple_term_menu 等) を増やさず stdlib の ``input()`` で実装する。 + 非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。 + """ + print("起動するプロジェクトを選択してください:") + for i, r in enumerate(rows, 1): + print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") + + # 一覧取得が重い場合があるため、誤入力 (数値以外 / 範囲外) では即終了せず + # 再入力を促す。空入力は中止、非 TTY (EOFError) はエラー終了。 + while True: + try: + raw = input("番号 (空で中止): ").strip() + except EOFError: + logger.error("対話入力ができません (非 TTY 環境)。" + "`devbase project up ` で直接指定してください。") + return 1 + except KeyboardInterrupt: + # Ctrl+C は traceback を出さず中止として扱う。 + print() + logger.info("中止しました。") + return 0 + + if not raw: + logger.info("中止しました。") + return 0 + + try: + idx = int(raw) + except ValueError: + logger.error("番号で指定してください: %r", raw) + continue + + if not (1 <= idx <= len(rows)): + logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) + continue + + break + + name = rows[idx - 1]["name"] + # name 解決 (chdir) + up は共有ハンドラ cmd_project に委譲する。 + import types + + from devbase.commands.container import cmd_project + return cmd_project(types.SimpleNamespace(subcommand="up", name=name, scale=None)) + + +def cmd_project_list(devbase_root: Path, args) -> int: + """`devbase project list [--interactive]` / `devbase list [--interactive]`。""" + projects_dir = Path(devbase_root) / "projects" + rows = list_projects(projects_dir) + + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return 0 + + if getattr(args, "interactive", False): + return _interactive_select_and_up(rows) + + _print_table(rows) + return 0 diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index 1416483..3f3081c 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -16,82 +16,84 @@ logger = get_logger(__name__) -def _get_container_status(projects_dir: Path) -> list[dict]: - """projects/ 配下の各プロジェクトのコンテナ状態を取得する""" - results = [] - if not projects_dir.exists(): - return results - - for entry in sorted(projects_dir.iterdir()): - if not entry.is_dir(): - continue - compose_file = entry / "compose.yml" - if not compose_file.exists(): - continue +def _container_status_for(entry: Path) -> dict | None: + """単一プロジェクトディレクトリのコンテナ状態を取得する。 + + `projects/` (実ディレクトリ or plugin への symlink) を受け取り、 + ``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker + コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。 + + PLAN06 で ``project list`` (commands/project.py) が同じ per-entry ロジックを + 再利用するため、``_get_container_status`` のループ本体から分離した。挙動は + 分離前と同一 (None を返す条件 = 旧実装で ``continue`` していた条件)。 + """ + compose_file = entry / "compose.yml" + if not compose_file.exists(): + return None - try: - proc = subprocess.run( - ["docker", "compose", "ps", "--format", "json"], - cwd=str(entry), - capture_output=True, - text=True, - timeout=10, - ) - if proc.returncode != 0: + try: + proc = subprocess.run( + ["docker", "compose", "ps", "--format", "json"], + cwd=str(entry), + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode != 0: + return None + + output = proc.stdout.strip() + if not output: + return {"name": entry.name, "status": "stopped", "count": 0} + + # docker compose ps --format json は1行1JSONまたはJSON配列 + containers = [] + for line in output.splitlines(): + line = line.strip() + if not line: continue - - output = proc.stdout.strip() - if not output: - results.append({ - "name": entry.name, - "status": "stopped", - "count": 0, - }) + try: + parsed = json.loads(line) + if isinstance(parsed, list): + containers.extend(parsed) + else: + containers.append(parsed) + except json.JSONDecodeError: continue - # docker compose ps --format json は1行1JSONまたはJSON配列 - containers = [] - for line in output.splitlines(): - line = line.strip() - if not line: - continue - try: - parsed = json.loads(line) - if isinstance(parsed, list): - containers.extend(parsed) - else: - containers.append(parsed) - except json.JSONDecodeError: - continue - - if not containers: - results.append({ - "name": entry.name, - "status": "stopped", - "count": 0, - }) - continue + if not containers: + return {"name": entry.name, "status": "stopped", "count": 0} - running = sum( - 1 for c in containers - if c.get("State", "").lower() == "running" - ) - total = len(containers) + running = sum( + 1 for c in containers + if c.get("State", "").lower() == "running" + ) + total = len(containers) - if running > 0: - status = f"running ({total} containers)" - else: - status = "stopped" + if running > 0: + status = f"running ({total} containers)" + else: + status = "stopped" - results.append({ - "name": entry.name, - "status": status, - "count": total, - }) + return {"name": entry.name, "status": status, "count": total} - except (subprocess.TimeoutExpired, OSError): - # dockerコマンドが利用できない、またはタイムアウト + except (subprocess.TimeoutExpired, OSError): + # dockerコマンドが利用できない、またはタイムアウト + return None + + +def _get_container_status(projects_dir: Path) -> list[dict]: + """projects/ 配下の各プロジェクトのコンテナ状態を取得する""" + results = [] + if not projects_dir.exists(): + return results + + for entry in sorted(projects_dir.iterdir()): + if not entry.is_dir(): continue + status = _container_status_for(entry) + if status is not None: + results.append(status) return results diff --git a/tests/cli/test_build_shortcut_consistency.py b/tests/cli/test_build_shortcut_consistency.py new file mode 100644 index 0000000..d49182e --- /dev/null +++ b/tests/cli/test_build_shortcut_consistency.py @@ -0,0 +1,65 @@ +"""bin/devbase の build dispatch と Python 側 build ショートカットの整合性テスト。 + +配布入口 bin/devbase は top-level `build` を shell 実装 (cmd_build: devbase-base +依存検出 + 2 段ビルド + --no-cache 対応) に委譲する。Python の project build は +単純な `compose build` であり実装が異なるため、Python 側が top-level `build` を +`project build` ショートカットとして広告すると、wrapper の実経路と乖離してしまう。 + +このテストは「Python は top-level build ショートカットを持たない / 広告しない」 +ことを固定し、wrapper の build) ケースが shell 経路を保つことを検証する。 +project build / container build サブコマンド自体は引き続き利用可能。 +""" + +from pathlib import Path + +from devbase import cli + + +def test_build_not_in_shortcuts(): + # top-level build は SHORTCUTS から除外されている (wrapper が shell へ委譲するため) + assert "build" not in cli.SHORTCUTS + # 他のショートカットは維持されている + for sc in ("up", "down", "login", "ps", "scale"): + assert sc in cli.SHORTCUTS + + +def test_top_level_build_has_no_python_parser(): + # top-level `build` には Python parser が無く、parse_args はエラー終了する + # (wrapper が build を shell の cmd_build に委譲し Python に渡さないため) + parser = cli._create_parser() + import pytest + + with pytest.raises(SystemExit): + parser.parse_args(["build"]) + + +def test_help_epilog_does_not_advertise_build_shortcut(): + parser = cli._create_parser() + epilog = parser.epilog or "" + # "build project build" のショートカット広告が無いこと + assert "project build" not in epilog + # 残りのショートカット広告は維持 + assert "project up" in epilog + assert "project scale" in epilog + + +def test_project_build_subcommand_still_available(): + # project build / container build サブコマンド自体は削除していない + parser = cli._create_parser() + ns = parser.parse_args(["project", "build", "myimage"]) + assert ns.command == "project" + assert ns.subcommand == "build" + assert ns.image == "myimage" + + +def test_wrapper_routes_build_to_shell_not_python(): + # bin/devbase の dispatch で build は shell の cmd_build に委譲され、 + # Python 用 run_python の case には含まれないことを確認する。 + wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() + # build は専用の shell ケースへ (PLAN06 Task 2 で name strip 後の _DEVBASE_ARGS + # を渡す形に変更。引数は wrapper 側の name 解決で既にコマンド/名を除去済み)。 + assert "build) cmd_build" in wrapper or "build) cmd_build" in wrapper + # run_python に委譲する case 行に build が紛れ込んでいないこと + for line in wrapper.splitlines(): + if "run_python" in line and "${_resolved_cmd}" in line: + assert "build" not in line diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py new file mode 100644 index 0000000..0f52b14 --- /dev/null +++ b/tests/cli/test_completion.py @@ -0,0 +1,169 @@ +"""PLAN06 Task 4: シェル補完 (bash / zsh) の回帰テスト。 + +- bash 補完を実際に source して `project` サブコマンド補完 / プロジェクト名補完 / + トップレベルシノニム補完が機能することを検証する (test_wrapper_dispatch と同方式)。 +- zsh 補完はランナー非依存にするため静的内容チェックのみ。 +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" +ZSH_COMPLETION = REPO_ROOT / "etc" / "_devbase" + + +def _bash_complete(words, cword, devbase_root): + """bash 補完を source して COMPREPLY を改行区切りで返す。""" + script = f""" +set -e +source "{BASH_COMPLETION}" +COMP_WORDS=({words}) +COMP_CWORD={cword} +_devbase_completions +printf '%s\\n' "${{COMPREPLY[@]}}" +""" + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} + proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env) + assert proc.returncode == 0, proc.stderr + return [line for line in proc.stdout.splitlines() if line] + + +@pytest.fixture +def fake_root(tmp_path): + projects = tmp_path / "projects" + projects.mkdir() + (projects / "web").mkdir() + (projects / "api").mkdir() + # symlink プロジェクト + (tmp_path / "target").mkdir() + (projects / "linked").symlink_to(tmp_path / "target") + return tmp_path + + +@pytest.fixture +def fake_root_with_bad_links(tmp_path): + """壊れた symlink / ファイルへの symlink を含む projects/ を作る。 + + ディレクトリ / ディレクトリへの symlink のみが補完候補に出ることを検証するため。 + """ + projects = tmp_path / "projects" + projects.mkdir() + (projects / "web").mkdir() + # ディレクトリへの symlink (候補に出るべき) + (tmp_path / "target").mkdir() + (projects / "linked").symlink_to(tmp_path / "target") + # ファイルへの symlink (候補に出ない) + a_file = tmp_path / "afile" + a_file.write_text("x") + (projects / "file_link").symlink_to(a_file) + # 壊れた symlink (候補に出ない) + (projects / "broken_link").symlink_to(tmp_path / "does_not_exist") + return tmp_path + + +# --------------------------------------------------------------------------- +# bash: 構文 / 動作 +# --------------------------------------------------------------------------- + +def test_bash_completion_syntax_ok(): + proc = subprocess.run(["bash", "-n", str(BASH_COMPLETION)], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + + +def test_bash_project_subcommands(fake_root): + out = _bash_complete("devbase project ''", 2, fake_root) + assert set(out) >= {"up", "down", "ps", "login", "logs", "scale", "build", "list"} + + +def test_bash_project_name_completion(fake_root): + out = _bash_complete("devbase project up ''", 3, fake_root) + assert sorted(out) == ["api", "linked", "web"] + + +def test_bash_top_level_synonym_name_completion(fake_root): + """`devbase up ` がプロジェクト名を補完する。""" + out = _bash_complete("devbase up ''", 2, fake_root) + assert sorted(out) == ["api", "linked", "web"] + + +def test_bash_top_level_ps_name_completion(fake_root): + """`devbase ps ` (フラグなし) はプロジェクト名を補完する。""" + out = _bash_complete("devbase ps ''", 2, fake_root) + assert sorted(out) == ["api", "linked", "web"] + + +def test_bash_top_level_ps_flag_completion(fake_root): + """`devbase ps -` は -a / --all を補完する (project ps と対称)。""" + out = _bash_complete("devbase ps '-'", 2, fake_root) + assert set(out) == {"--all", "-a"} + + +def test_bash_project_name_excludes_bad_symlinks(fake_root_with_bad_links): + """壊れた symlink / ファイルへの symlink は name 補完候補に出ない。 + + 実ディレクトリ (web) とディレクトリへの symlink (linked) のみ。zsh 側と整合。 + """ + out = _bash_complete("devbase project up ''", 3, fake_root_with_bad_links) + assert sorted(out) == ["linked", "web"] + + +def test_bash_project_ps_flag_after_name(fake_root): + """`devbase project ps web -` (cword 4) で -a / --all を補完する。""" + out = _bash_complete("devbase project ps web '-'", 4, fake_root) + assert set(out) == {"--all", "-a"} + + +def test_bash_project_logs_flag_after_name(fake_root): + """`devbase project logs web -` (cword 4) で -f/--follow/--tail を補完する。""" + out = _bash_complete("devbase project logs web '-'", 4, fake_root) + assert set(out) == {"--follow", "-f", "--tail"} + + +def test_bash_top_level_ps_flag_after_name(fake_root): + """`devbase ps web -` (cword 3) で -a / --all を補完する (project ps と対称)。""" + out = _bash_complete("devbase ps web '-'", 3, fake_root) + assert set(out) == {"--all", "-a"} + + +def test_bash_project_list_flags(fake_root): + out = _bash_complete("devbase project list '-'", 3, fake_root) + assert set(out) == {"--interactive", "-i"} + + +def test_bash_top_level_commands_include_project_and_list(fake_root): + out = _bash_complete("devbase ''", 1, fake_root) + assert "project" in out + assert "list" in out + # 後方互換: container も補完候補に残る + assert "container" in out + + +# --------------------------------------------------------------------------- +# 静的内容チェック (zsh は実行環境非依存にするため内容のみ確認) +# --------------------------------------------------------------------------- + +def test_zsh_completion_mentions_project_and_list(): + text = ZSH_COMPLETION.read_text() + assert "'project:Manage projects" in text + assert "_devbase_project_names" in text + assert "list:List projects" in text + + +def test_zsh_completion_marks_container_deprecated(): + text = ZSH_COMPLETION.read_text() + assert "deprecated" in text.lower() + + +@pytest.mark.skipif(shutil.which("zsh") is None, reason="zsh 未インストール") +def test_zsh_completion_syntax_ok(): + proc = subprocess.run(["zsh", "-n", str(ZSH_COMPLETION)], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py new file mode 100644 index 0000000..bec846b --- /dev/null +++ b/tests/cli/test_project_dispatch.py @@ -0,0 +1,326 @@ +"""PLAN06 Task 1: `project` サブコマンド group / 共有ハンドラ / `container` 非推奨委譲のテスト + +PR1 の範囲は Python レベルのリネーム + 委譲のみ(wrapper の cd / name 解決は PR2)。 +ここでは parser の構造・prefix 解決・dispatch ルーティング・非推奨 warning を検証する。 +""" + +from __future__ import annotations + +import logging +import sys +import types + +import pytest + +from devbase import cli + + +# --------------------------------------------------------------------------- +# parser: project サブコマンド群と [name] positional +# --------------------------------------------------------------------------- + +LIFECYCLE_SUBCMDS = ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'] + + +@pytest.mark.parametrize('sub', LIFECYCLE_SUBCMDS) +def test_create_parser_accepts_project_subcommands(sub): + parser = cli._create_parser() + argv = ['project', sub] + if sub == 'scale': + argv.append('1') # scale は new_scale (必須) を要求する + args = parser.parse_args(argv) + assert args.command == 'project' + assert args.subcommand == sub + + +def test_project_up_accepts_optional_name(): + parser = cli._create_parser() + with_name = parser.parse_args(['project', 'up', 'carmo']) + assert with_name.subcommand == 'up' + assert with_name.name == 'carmo' + + without_name = parser.parse_args(['project', 'up']) + assert without_name.name is None + + +def test_project_scale_positional_is_unambiguous(): + """`[name]` optional + `new_scale` 必須 int の組合せが曖昧にならない。""" + parser = cli._create_parser() + + only_scale = parser.parse_args(['project', 'scale', '3']) + assert only_scale.name is None + assert only_scale.new_scale == 3 + + name_and_scale = parser.parse_args(['project', 'scale', 'carmo', '3']) + assert name_and_scale.name == 'carmo' + assert name_and_scale.new_scale == 3 + + +# --------------------------------------------------------------------------- +# prefix 解決: project を 3 箇所同期した結果の検証 +# --------------------------------------------------------------------------- + +def test_expand_argv_resolves_project_command_prefix(monkeypatch): + """`devbase pr ...` は一意なので `project` に解決される。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'pr', 'up']) + cli._expand_argv() + assert sys.argv[1] == 'project' + + +def test_expand_argv_resolves_project_subcommand_prefix(monkeypatch): + """`devbase project u` は `up` に解決される (SUBCMD_MAP に project を追加した結果)。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'project', 'u']) + cli._expand_argv() + assert sys.argv == ['devbase', 'project', 'up'] + + +# --------------------------------------------------------------------------- +# container.py: 共有 lifecycle dispatcher と非推奨委譲 +# --------------------------------------------------------------------------- + +def _args(**kwargs): + return types.SimpleNamespace(**kwargs) + + +def test_cmd_project_delegates_to_lifecycle(monkeypatch): + from devbase.commands import container + captured = {} + + def fake_lifecycle(args): + captured['args'] = args + return 0 + + monkeypatch.setattr(container, '_dispatch_lifecycle', fake_lifecycle) + args = _args(subcommand='ps') + assert container.cmd_project(args) == 0 + assert captured['args'] is args + + +def test_cmd_container_warns_and_delegates(monkeypatch, caplog): + from devbase.commands import container + monkeypatch.setattr(container, '_dispatch_lifecycle', lambda args: 0) + args = _args(subcommand='ps') + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert container.cmd_container(args) == 0 + assert any('非推奨' in r.message for r in caplog.records), \ + '`container` は非推奨 warning を出さなければならない' + + +def test_cmd_project_does_not_warn(monkeypatch, caplog): + from devbase.commands import container + monkeypatch.setattr(container, '_dispatch_lifecycle', lambda args: 0) + args = _args(subcommand='ps') + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + container.cmd_project(args) + assert not any('非推奨' in r.message for r in caplog.records), \ + '`project` は非推奨 warning を出してはならない' + + +def test_lifecycle_passes_name_to_cmd_up(monkeypatch): + """`project up ` の name は project_name として up に伝播する。""" + from devbase.commands import container + captured = {} + # name 解決 (chdir) は別テストで検証するためここでは no-op 化し、伝播のみ見る。 + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + args = _args(subcommand='up', name='carmo', scale=None) + assert container._dispatch_lifecycle(args) == 0 + assert captured['project_name'] == 'carmo' + + +def test_lifecycle_container_path_has_no_name(monkeypatch): + """container 経路には name 属性が無く、従来通り project_name=None になる。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + args = _args(subcommand='up', scale=None) # name 属性なし + assert container._dispatch_lifecycle(args) == 0 + assert captured['project_name'] is None + + +# --------------------------------------------------------------------------- +# _dispatch_lifecycle: name 解決 (PR2 で wrapper cd の Python フォールバックを実装) +# name 指定時は handler 呼び出し前に _resolve_project_name で chdir する。 +# 解決失敗時は handler を呼ばずに 1 を返す。詳細な解決ロジックは +# test_project_name_resolution.py を参照。 +# --------------------------------------------------------------------------- + +def test_lifecycle_resolves_name_before_handler(monkeypatch): + """name 指定時は handler 前に _resolve_project_name を呼ぶ。""" + from devbase.commands import container + order = [] + 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: + order.append(('up', project_name)) or 0) + args = _args(subcommand='up', name='carmo', scale=None) + assert container._dispatch_lifecycle(args) == 0 + assert order == [('resolve', 'carmo'), ('up', 'carmo')] + + +def test_lifecycle_aborts_when_name_unresolved(monkeypatch): + """name 解決に失敗したら handler を呼ばず 1 を返す。""" + from devbase.commands import container + called = [] + monkeypatch.setattr(container, '_resolve_project_name', lambda name: False) + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + called.append('up') or 0) + args = _args(subcommand='up', name='bogus', scale=None) + assert container._dispatch_lifecycle(args) == 1 + assert called == [], '解決失敗時は handler を呼んではならない' + + +def test_lifecycle_no_resolution_without_name(monkeypatch): + """name 未指定なら _resolve_project_name を呼ばない。""" + from devbase.commands import container + 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) + args = _args(subcommand='up', scale=None) # name 属性なし + assert container._dispatch_lifecycle(args) == 0 + assert resolved == [] + + +# --------------------------------------------------------------------------- +# cli._dispatch: ルーティング +# --------------------------------------------------------------------------- + +def test_dispatch_project_routes_to_cmd_project(monkeypatch): + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_project', lambda args: calls.append('project') or 0) + args = _args(command='project', subcommand='ps') + assert cli._dispatch('project', args) == 0 + assert calls == ['project'] + + +def test_dispatch_container_routes_to_cmd_container(monkeypatch): + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_container', lambda args: calls.append('container') or 0) + args = _args(command='container', subcommand='ps') + assert cli._dispatch('container', args) == 0 + assert calls == ['container'] + + +def test_dispatch_shortcut_routes_to_cmd_project_not_container(monkeypatch): + """トップレベルショートカット (up 等) は非推奨の container ではなく project へ。""" + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_project', lambda args: calls.append('project') or 0) + monkeypatch.setattr(container, 'cmd_container', lambda args: calls.append('container') or 0) + args = _args(command='up') + assert cli._dispatch('up', args) == 0 + assert calls == ['project'] + + +# --------------------------------------------------------------------------- +# parser: 共通サブコマンド (login / build) の project / container 一致 +# (重複定義を _add_login_subparser / _add_build_subparser に共通化した結果の検証) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_login_positional_is_index_in_both_groups(group): + """login は project / container いずれでも単一 positional を index として扱う。""" + parser = cli._create_parser() + args = parser.parse_args([group, 'login', '2']) + assert args.subcommand == 'login' + assert args.index == '2' + # name positional は存在しない (曖昧さ回避) + assert not hasattr(args, 'name') + + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_login_index_defaults_in_both_groups(group): + parser = cli._create_parser() + args = parser.parse_args([group, 'login']) + assert args.index == '1' + + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_build_positional_is_image_in_both_groups(group): + """build は project / container いずれでも単一 positional を image として扱う。""" + parser = cli._create_parser() + args = parser.parse_args([group, 'build', 'web']) + assert args.subcommand == 'build' + assert args.image == 'web' + assert not hasattr(args, 'name') + + +# --------------------------------------------------------------------------- +# top-level ショートカットの [name] 受理と伝播 +# (up/down/ps/scale が project サブコマンドと同様に [name] を受理し、 +# ショートカット経由でも name が _dispatch_lifecycle まで伝播する) +# --------------------------------------------------------------------------- + +def test_shortcut_up_accepts_optional_name(): + parser = cli._create_parser() + with_name = parser.parse_args(['up', 'carmo']) + assert with_name.command == 'up' + assert with_name.name == 'carmo' + + without_name = parser.parse_args(['up']) + assert without_name.name is None + + +def test_shortcut_down_accepts_optional_name(): + parser = cli._create_parser() + args = parser.parse_args(['down', 'carmo']) + assert args.command == 'down' + assert args.name == 'carmo' + + +def test_shortcut_ps_accepts_optional_name(): + parser = cli._create_parser() + args = parser.parse_args(['ps', 'carmo', '--all']) + assert args.command == 'ps' + assert args.name == 'carmo' + assert args.all is True + + +def test_shortcut_scale_positional_is_unambiguous(): + """`scale [name] ` は project scale と同じく曖昧にならない。""" + parser = cli._create_parser() + + only_scale = parser.parse_args(['scale', '3']) + assert only_scale.name is None + assert only_scale.new_scale == 3 + + name_and_scale = parser.parse_args(['scale', 'carmo', '3']) + assert name_and_scale.name == 'carmo' + assert name_and_scale.new_scale == 3 + + +def test_shortcut_up_propagates_name_through_dispatch(monkeypatch): + """`devbase up ` の name がショートカット経由で cmd_up まで伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + # ショートカット parser が生成する namespace を再現 (name 属性を持つ) + args = _args(command='up', name='carmo', scale=None) + assert cli._dispatch('up', args) == 0 + assert captured['project_name'] == 'carmo' + + +def test_shortcut_scale_propagates_name_through_dispatch(monkeypatch): + """`devbase scale N` の name がショートカット経由で cmd_scale まで伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) + monkeypatch.setattr(container, 'cmd_scale', + lambda new_scale=None, project_name=None: + captured.update(project_name=project_name, new_scale=new_scale) or 0) + args = _args(command='scale', name='carmo', new_scale=3) + assert cli._dispatch('scale', args) == 0 + assert captured['project_name'] == 'carmo' + assert captured['new_scale'] == 3 diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py new file mode 100644 index 0000000..f0e264c --- /dev/null +++ b/tests/cli/test_project_list.py @@ -0,0 +1,492 @@ +"""PLAN06 Task 3: `project list` 一覧表示 + `--interactive` 選択起動のテスト + +検証対象: +- `lib/devbase/commands/project.py` + - `_resolve_plugin_name`: symlink 先から plugin 名を解決する (衝突 suffix 耐性) + - `list_projects`: projects/ 配下を NAME/PLUGIN/STATUS で列挙する + - `cmd_project_list`: table 表示 / `--interactive` での選択起動 +- `lib/devbase/commands/status.py` + - `_container_status_for`: per-entry status 抽出後の回帰 +- `lib/devbase/cli.py` + - `project list` parser / dispatch ルーティング / トップレベル `list` シノニム / prefix 解決 +""" + +from __future__ import annotations + +import os +import types +from pathlib import Path + +import pytest + +from devbase import cli + + +# --------------------------------------------------------------------------- +# 補助: projects/ 配下に plugin project への symlink を作る +# --------------------------------------------------------------------------- + +def _make_plugin_project(devbase_root: Path, plugin_path: str, proj: str) -> Path: + """repos/ or plugins/ 配下に plugin の projects/ 実体を作って返す。""" + target_dir = devbase_root / plugin_path / "projects" / proj + target_dir.mkdir(parents=True, exist_ok=True) + return target_dir + + +def _link_project(devbase_root: Path, link_name: str, plugin_path: str, proj: str) -> Path: + """projects/ -> ..//projects/ の相対 symlink を作る。 + + syncer.sync_projects と同じ相対ターゲット形式 (衝突時は link_name に suffix が + 付くが、ターゲット dir 名は素の proj のまま) を再現する。 + """ + projects_dir = devbase_root / "projects" + projects_dir.mkdir(exist_ok=True) + target = Path("..") / plugin_path / "projects" / proj + link = projects_dir / link_name + link.symlink_to(target) + return link + + +# --------------------------------------------------------------------------- +# _resolve_plugin_name +# --------------------------------------------------------------------------- + +def test_resolve_plugin_name_repos_based(tmp_path): + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "repos/owner--repo/myplugin", "carmo") + link = _link_project(tmp_path, "carmo", "repos/owner--repo/myplugin", "carmo") + + assert _resolve_plugin_name(link) == "myplugin" + + +def test_resolve_plugin_name_linked(tmp_path): + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "plugins/foo", "carmo") + link = _link_project(tmp_path, "carmo", "plugins/foo", "carmo") + + assert _resolve_plugin_name(link) == "foo" + + +def test_resolve_plugin_name_collision_suffix_uses_target_not_linkname(tmp_path): + """衝突 suffix (carmo.takemi) はリンク名のみに付き、ターゲット dir は素の carmo。 + + PLUGIN 解決は link 名でなく symlink 先から行うため suffix で壊れてはならない。 + """ + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "repos/takemi--carmo/carmo-plugin", "carmo") + link = _link_project(tmp_path, "carmo.takemi--carmo", + "repos/takemi--carmo/carmo-plugin", "carmo") + + assert _resolve_plugin_name(link) == "carmo-plugin" + + +def test_resolve_plugin_name_real_dir_returns_none(tmp_path): + """symlink でない実ディレクトリは plugin に属さないため None。""" + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + real = projects_dir / "standalone" + real.mkdir() + + assert _resolve_plugin_name(real) is None + + +def test_resolve_plugin_name_absolute_root_target_returns_none(tmp_path): + """symlink 先が `/projects/proj` のような絶対パスだと parts[0] が '/' になる。 + + plugin 名として無効な root 区切りを返さず None にする (堅牢性指摘 #36)。 + """ + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + link = projects_dir / "rooted" + link.symlink_to("/projects/proj") # 先頭 '/' で parts[0] == '/' + assert _resolve_plugin_name(link) is None + + +def test_resolve_plugin_name_relative_dotdot_target_returns_none(tmp_path): + """`../projects/proj` だと直前要素が '..' になり plugin 名として無効 → None。""" + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + link = projects_dir / "dotdot" + link.symlink_to(Path("..") / "projects" / "proj") + assert _resolve_plugin_name(link) is None + + +def test_resolve_plugin_name_broken_symlink(tmp_path): + """ターゲットが存在しない symlink でも link テキストから plugin を解決できる。""" + from devbase.commands.project import _resolve_plugin_name + + link = _link_project(tmp_path, "ghost", "repos/o--r/ghostplugin", "ghost") + # ターゲット実体は作らない (broken) + assert not link.exists() + assert _resolve_plugin_name(link) == "ghostplugin" + + +# --------------------------------------------------------------------------- +# list_projects +# --------------------------------------------------------------------------- + +def test_list_projects_enumerates_name_plugin_status(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") + _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") + + # status は docker に依存させず固定値を返す + def fake_status(entry: Path): + return {"name": entry.name, "status": "running (2 containers)", "count": 2} + + monkeypatch.setattr(status_mod, "_container_status_for", fake_status) + + rows = project_mod.list_projects(tmp_path / "projects") + by_name = {r["name"]: r for r in rows} + + assert by_name["alpha-proj"]["plugin"] == "alpha" + assert by_name["alpha-proj"]["status"] == "running (2 containers)" + assert by_name["beta-proj"]["plugin"] == "beta" + + +def test_list_projects_unknown_status_when_none(tmp_path, monkeypatch): + """_container_status_for が None (compose.yml 無し/docker 不在) なら 'unknown'。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + rows = project_mod.list_projects(tmp_path / "projects") + assert rows[0]["status"] == "unknown" + + +def test_list_projects_real_dir_plugin_dash(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + (projects_dir / "standalone").mkdir() + + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + rows = project_mod.list_projects(projects_dir) + assert rows[0]["name"] == "standalone" + assert rows[0]["plugin"] == "-" + + +def test_list_projects_empty_when_no_projects_dir(tmp_path): + from devbase.commands import project as project_mod + assert project_mod.list_projects(tmp_path / "projects") == [] + + +# --------------------------------------------------------------------------- +# cmd_project_list: table 出力 +# --------------------------------------------------------------------------- + +def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + + args = types.SimpleNamespace(interactive=False) + rc = project_mod.cmd_project_list(tmp_path, args) + out = capsys.readouterr().out + + assert rc == 0 + assert "NAME" in out and "PLUGIN" in out and "STATUS" in out + assert "alpha-proj" in out + assert "alpha" in out + assert "stopped" in out + + +def test_cmd_project_list_empty(tmp_path, capsys): + from devbase.commands import project as project_mod + args = types.SimpleNamespace(interactive=False) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + + +# --------------------------------------------------------------------------- +# cmd_project_list: --interactive +# --------------------------------------------------------------------------- + +def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") + _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + # 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2) + monkeypatch.setattr("builtins.input", lambda *a, **k: "2") + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + + assert rc == 0 + assert captured["subcommand"] == "up" + assert captured["name"] == "beta-proj" + + +def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert called == [], "空入力では up を起動しない" + + +def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch): + """非対話環境 (input が EOFError) では up を起動せずエラー終了する。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + def raise_eof(*a, **k): + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 1 + assert called == [] + + +def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkeypatch): + """Ctrl+C (KeyboardInterrupt) は traceback を出さず中止 (rc=0) として扱う。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + def raise_interrupt(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", raise_interrupt) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert called == [] + + +def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypatch): + """範囲外の番号では即終了せず再入力を促す。有効入力で最終的に up する。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + # "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する + inputs = iter(["99", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert captured["name"] == "alpha-proj" + + +def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatch): + """数値以外の入力では即終了せず再入力を促す。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + # "abc" (数値以外) → "1" (有効) + inputs = iter(["abc", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert captured["name"] == "alpha-proj" + + +# --------------------------------------------------------------------------- +# parser: project list / --interactive +# --------------------------------------------------------------------------- + +def test_parser_project_list(): + parser = cli._create_parser() + args = parser.parse_args(["project", "list"]) + assert args.command == "project" + assert args.subcommand == "list" + assert args.interactive is False + + +def test_parser_project_list_interactive_flag(): + parser = cli._create_parser() + for flag in ("--interactive", "-i"): + args = parser.parse_args(["project", "list", flag]) + assert args.interactive is True + + +def test_parser_top_level_list_synonym(): + parser = cli._create_parser() + args = parser.parse_args(["list", "-i"]) + assert args.command == "list" + assert args.interactive is True + + +# --------------------------------------------------------------------------- +# prefix 解決: project list / 単独 list +# --------------------------------------------------------------------------- + +def test_expand_argv_project_list_prefix(monkeypatch): + """`devbase project li` は `list` に解決される。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "project", "li"]) + cli._expand_argv() + assert sys.argv == ["devbase", "project", "list"] + + +def test_expand_argv_top_level_list_prefix(monkeypatch): + """`devbase li` は一意に `list` へ解決される (login とは li/lo で分離)。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "li"]) + cli._expand_argv() + assert sys.argv[1] == "list" + + +def test_expand_argv_top_level_l_resolves_to_login(monkeypatch): + """後方互換: `list` 追加で ambiguous になった `devbase l` を `login` に維持する。 + + `l` は `login` / `list` の両方にマッチするが TOP_PREFIX_PREFERENCES で + 既存挙動 (`l` → `login`) を保つ (互換性指摘 #36)。 + """ + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "l"]) + cli._expand_argv() + assert sys.argv[1] == "login" + + +def test_expand_argv_top_level_lo_resolves_to_login(monkeypatch): + """`devbase lo` は一意に `login` へ解決される (回帰確認)。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "lo"]) + cli._expand_argv() + assert sys.argv[1] == "login" + + +# --------------------------------------------------------------------------- +# dispatch ルーティング +# --------------------------------------------------------------------------- + +def test_dispatch_project_list_routes_to_cmd_project_list(monkeypatch): + from devbase.commands import project as project_mod + monkeypatch.setenv("DEVBASE_ROOT", "/tmp/devbase-root-test") + calls = [] + monkeypatch.setattr(project_mod, "cmd_project_list", + lambda root, args: calls.append(str(root)) or 0) + args = types.SimpleNamespace(command="project", subcommand="list", interactive=False) + assert cli._dispatch("project", args) == 0 + assert calls == ["/tmp/devbase-root-test"] + + +def test_dispatch_project_up_still_routes_to_lifecycle(monkeypatch): + """project list 追加後も up 等は従来通り cmd_project (lifecycle) へ。""" + from devbase.commands import container as container_mod + calls = [] + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: calls.append(args.subcommand) or 0) + args = types.SimpleNamespace(command="project", subcommand="up", name=None, scale=None) + assert cli._dispatch("project", args) == 0 + assert calls == ["up"] + + +def test_dispatch_top_level_list_routes_to_cmd_project_list(monkeypatch): + from devbase.commands import project as project_mod + monkeypatch.setenv("DEVBASE_ROOT", "/tmp/devbase-root-test") + calls = [] + monkeypatch.setattr(project_mod, "cmd_project_list", + lambda root, args: calls.append("list") or 0) + args = types.SimpleNamespace(command="list", interactive=False) + assert cli._dispatch("list", args) == 0 + assert calls == ["list"] + + +# --------------------------------------------------------------------------- +# status.py リファクタ回帰: _container_status_for / _get_container_status +# --------------------------------------------------------------------------- + +def test_container_status_for_none_without_compose(tmp_path): + from devbase.commands.status import _container_status_for + entry = tmp_path / "proj" + entry.mkdir() + assert _container_status_for(entry) is None + + +def test_get_container_status_uses_per_entry(tmp_path, monkeypatch): + from devbase.commands import status as status_mod + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + (projects_dir / "a").mkdir() + (projects_dir / "b").mkdir() + + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + results = status_mod._get_container_status(projects_dir) + names = sorted(r["name"] for r in results) + assert names == ["a", "b"] diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py new file mode 100644 index 0000000..23fa066 --- /dev/null +++ b/tests/cli/test_project_name_resolution.py @@ -0,0 +1,343 @@ +"""PLAN06 Task 2: プロジェクト名解決 (wrapper cd + Python フォールバック) のテスト。 + +検証対象: + - Python `container._resolve_project_name`: $DEVBASE_ROOT/projects/ への + chdir + COMPOSE_PROJECT_NAME 上書き、存在しない name のエラー + 候補提示。 + - wrapper (bin/devbase): project/container サブコマンド及びトップレベルシノニムで + 実在するプロジェクト名のみ cd + argv strip し、login / build / + scale の既存 positional と曖昧にならないこと (存在性ベースの判定)。 + +wrapper テストは実際の `uv run` を避けるため run_python / cmd_build をスタブに +差し替え、DEVBASE_ROOT を一時ディレクトリへ向けた薄いハーネスで dispatch のみ +実行する (wrapper 末尾の DEVBASE_ROOT 自動解決行も sed で除去する)。 +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from devbase.commands import container + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +# =========================================================================== +# Python: _resolve_project_name +# =========================================================================== + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + """projects/carmo を持つ一時 DEVBASE_ROOT を用意し、CWD/環境を復元する。""" + (tmp_path / "projects" / "carmo").mkdir(parents=True) + (tmp_path / "projects" / "shop").mkdir(parents=True) + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + monkeypatch.delenv("COMPOSE_PROJECT_NAME", raising=False) + origin = Path.cwd() + monkeypatch.chdir(tmp_path) + yield tmp_path + os.chdir(origin) + + +def test_resolve_chdirs_into_project(fake_root): + assert container._resolve_project_name("carmo") is True + assert Path.cwd().resolve() == (fake_root / "projects" / "carmo").resolve() + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_unknown_name_errors_with_candidates(fake_root, caplog): + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert container._resolve_project_name("nope") is False + messages = " ".join(r.message for r in caplog.records) + assert "nope" in messages + # 候補一覧に既存プロジェクトが提示される + assert "carmo" in messages and "shop" in messages + + +def test_report_unknown_truncates_many_candidates(tmp_path, monkeypatch, caplog): + """候補が上限を超える場合は先頭 N 件 + 「... 他 M 件」に truncate される。""" + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + total = container._MAX_PROJECT_CANDIDATES + 5 + # ゼロ埋めで sorted 順を安定させる (p000, p001, ...)。 + for i in range(total): + (projects_dir / f"p{i:03d}").mkdir() + + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + container._report_unknown_project("nope", projects_dir) + + messages = " ".join(r.message for r in caplog.records) + # 先頭 N 件は表示される + assert "p000" in messages + assert f"p{container._MAX_PROJECT_CANDIDATES - 1:03d}" in messages + # 上限超過分は表示されず、省略表記に集約される + assert f"p{container._MAX_PROJECT_CANDIDATES:03d}" not in messages + assert f"... 他 {total - container._MAX_PROJECT_CANDIDATES} 件" in messages + + +def test_report_unknown_no_truncation_when_within_limit(tmp_path, monkeypatch, caplog): + """候補が上限以内なら省略表記は付かず全件表示される。""" + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + for n in ("carmo", "shop"): + (projects_dir / n).mkdir() + + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + container._report_unknown_project("nope", projects_dir) + + messages = " ".join(r.message for r in caplog.records) + assert "carmo" in messages and "shop" in messages + assert "他" not in messages + + +def test_resolve_without_devbase_root(tmp_path, monkeypatch, caplog): + monkeypatch.delenv("DEVBASE_ROOT", raising=False) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert container._resolve_project_name("carmo") is False + assert any("DEVBASE_ROOT" in r.message for r in caplog.records) + + +def test_resolve_noop_when_already_in_target(fake_root, monkeypatch): + """wrapper が既に cd 済みなら chdir を呼ばない (冪等)。""" + target = fake_root / "projects" / "carmo" + monkeypatch.chdir(target) + + called = [] + monkeypatch.setattr(container.os, "chdir", lambda p: called.append(p)) + assert container._resolve_project_name("carmo") is True + assert called == [], "既に対象ディレクトリにいる場合 chdir は呼ばれない" + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_loads_project_env(fake_root, monkeypatch): + """wrapper を経ない直接起動でも project env が os.environ へ反映される。 + + gemini round2 minor 指摘 (wrapper の `source ./env` 相当) の回帰テスト。 + """ + monkeypatch.delenv("CONTAINER_SCALE", raising=False) + monkeypatch.delenv("CUSTOM_VAR", raising=False) + env_path = fake_root / "projects" / "carmo" / "env" + env_path.write_text( + "# comment line\n" + "\n" + "CONTAINER_SCALE=5\n" + "export CUSTOM_VAR=hello\n" + 'QUOTED="dq value"\n' + "SQUOTED='sq value'\n" + ) + + assert container._resolve_project_name("carmo") is True + assert os.environ["CONTAINER_SCALE"] == "5" + assert os.environ["CUSTOM_VAR"] == "hello" + assert os.environ["QUOTED"] == "dq value" + assert os.environ["SQUOTED"] == "sq value" + # name 指定は env 由来値より優先される + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_env_name_overrides_env_file_compose_project_name(fake_root, monkeypatch): + """env に COMPOSE_PROJECT_NAME があっても name 指定が優先される。""" + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "stale") + env_path = fake_root / "projects" / "carmo" / "env" + env_path.write_text("COMPOSE_PROJECT_NAME=from_env\n") + + assert container._resolve_project_name("carmo") is True + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_missing_env_file_is_noop(fake_root): + """env ファイルが無くても解決は成功する (フォールバックの堅牢性)。""" + assert not (fake_root / "projects" / "carmo" / "env").exists() + assert container._resolve_project_name("carmo") is True + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): + """shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。 + + 本パーサは変数展開・コマンド置換・行中クォート除去・インラインコメントを + 解釈せず、値を安全側にリテラルとして扱う。この意図的な制約を pin する。 + """ + for k in ("LIT_VAR", "LIT_CMD", "INNER_Q", "INLINE_C"): + monkeypatch.delenv(k, raising=False) + env_path = tmp_path / "env" + env_path.write_text( + "LIT_VAR=$HOME\n" # 変数展開しない (リテラル "$HOME") + "LIT_CMD=$(echo x)\n" # コマンド置換しない (リテラル "$(echo x)") + 'INNER_Q=a"b"c\n' # 行中クォートは除去しない + "INLINE_C=bar # note\n" # 行頭以外の # はコメント扱いしない + ) + + container._load_project_env(env_path) + + assert os.environ["LIT_VAR"] == "$HOME" + assert os.environ["LIT_CMD"] == "$(echo x)" + assert os.environ["INNER_Q"] == 'a"b"c' + assert os.environ["INLINE_C"] == "bar # note" + + +# =========================================================================== +# wrapper: cd + argv strip + 存在性ベースの曖昧性回避 +# =========================================================================== + +def _run_wrapper(args, devbase_root): + """run_python / cmd_build をスタブ化し wrapper の dispatch だけを実行する。 + + - run_python -> "PWD:" と "PYTHON:" を出力 + - cmd_build -> "PWD:" と "BUILD:" を出力 + 実際の wrapper が DEVBASE_ROOT を自身のパスから再計算してしまうため、その + 代入行 (`DEVBASE_ROOT=...`) も sed で除去し、環境変数で渡した値を使わせる。 + """ + harness = ( + 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "PWD:$PWD"; echo "BUILD:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = { + **os.environ, + "DEVBASE_ROOT": str(devbase_root), + "WRAPPER_PATH": str(WRAPPER), + } + return subprocess.run( + ["bash", "-c", harness, "devbase", *args], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + ) + + +@pytest.fixture +def wrapper_root(tmp_path): + (tmp_path / "projects" / "carmo").mkdir(parents=True) + return tmp_path + + +def _pwd(result): + for line in result.stdout.splitlines(): + if line.startswith("PWD:"): + return line[len("PWD:"):] + return None + + +def _python_args(result): + for line in result.stdout.splitlines(): + if line.startswith("PYTHON:"): + return line[len("PYTHON:"):] + return None + + +def _build_args(result): + for line in result.stdout.splitlines(): + if line.startswith("BUILD:"): + return line[len("BUILD:"):] + return None + + +def test_wrapper_project_up_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["project", "up", "carmo"], wrapper_root) + assert "unknown command" not in r.stderr.lower(), r.stderr + assert _pwd(r).endswith("/projects/carmo"), r.stdout + # name は strip され Python へは渡らない + assert _python_args(r) == "project up", r.stdout + + +def test_wrapper_shortcut_up_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["up", "carmo"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "up", r.stdout + + +def test_wrapper_unknown_name_not_stripped_no_cd(wrapper_root): + """存在しない name は cd せず素通し (Python 側でエラー処理させる)。""" + r = _run_wrapper(["up", "bogus"], wrapper_root) + assert not _pwd(r).endswith("/projects/bogus"), r.stdout + assert _python_args(r) == "up bogus", r.stdout + + +def test_wrapper_build_name_cds_via_shell(wrapper_root): + """build は shell cmd_build 経路。wrapper cd で対象プロジェクトへ移動する。""" + r = _run_wrapper(["build", "carmo"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _build_args(r) == "", r.stdout # name は strip + + +def test_wrapper_build_flag_not_treated_as_name(wrapper_root): + """`build --no-cache` のフラグは name とみなさず CWD でビルド。""" + r = _run_wrapper(["build", "--no-cache"], wrapper_root) + assert not _pwd(r).endswith("/projects/"), r.stdout + assert _build_args(r) == "--no-cache", r.stdout + + +def test_wrapper_scale_name_disambiguation(wrapper_root): + """`scale carmo 3` は name+N、`scale 3` は N のみ (存在性で判定)。""" + r1 = _run_wrapper(["scale", "carmo", "3"], wrapper_root) + assert _pwd(r1).endswith("/projects/carmo"), r1.stdout + assert _python_args(r1) == "scale 3", r1.stdout + + r2 = _run_wrapper(["scale", "3"], wrapper_root) + assert not _pwd(r2).endswith("/projects/3"), r2.stdout + assert _python_args(r2) == "scale 3", r2.stdout + + +def test_wrapper_login_index_not_treated_as_name(wrapper_root): + """`login 2` の 2 は index。projects/2 が無いので cd せず素通し。""" + r = _run_wrapper(["login", "2"], wrapper_root) + assert _python_args(r) == "login 2", r.stdout + + # 一方 `login carmo` は実在プロジェクトなので cd + strip (index=1 既定) + r2 = _run_wrapper(["login", "carmo"], wrapper_root) + assert _pwd(r2).endswith("/projects/carmo"), r2.stdout + assert _python_args(r2) == "login", r2.stdout + + +def test_wrapper_project_scale_name_strips_keeps_subcommand(wrapper_root): + r = _run_wrapper(["project", "scale", "carmo", "3"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project scale 3", r.stdout + + +def test_wrapper_no_name_uses_cwd(wrapper_root): + """name を渡さなければ cd せず従来通り (引数素通し)。""" + r = _run_wrapper(["project", "up"], wrapper_root) + assert _python_args(r) == "project up", r.stdout + + +def test_wrapper_project_build_keeps_image_positional(wrapper_root): + """`project build carmo` の carmo は image positional。 + + `project build` parser は name を持たず image を取る (cli.py 参照)。実在 + プロジェクト名 carmo が image と衝突しても name strip せず素通しし、Python + 側で image=carmo として解釈させる (codex 指摘の衝突回避)。 + """ + r = _run_wrapper(["project", "build", "carmo"], wrapper_root) + # cd せず (image 解決は Python 側)、carmo を strip しない + assert not _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project build carmo", r.stdout + + +def test_wrapper_project_login_keeps_index_positional(wrapper_root): + """`project login carmo` の carmo は index positional として素通しする。 + + `project login` parser は name を持たず index を取る。実在プロジェクト名と + 一致しても name strip せず、Python パーサに委ねる (codex 指摘の衝突回避)。 + """ + r = _run_wrapper(["project", "login", "carmo"], wrapper_root) + assert not _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project login carmo", r.stdout + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py new file mode 100644 index 0000000..9973930 --- /dev/null +++ b/tests/cli/test_wrapper_dispatch.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""bin/devbase wrapper の command dispatch のテスト。 + +`project` サブコマンドが wrapper の resolve_command 候補と case dispatch に +含まれており、`devbase project ...` が Python 実装へルーティングされることを +検証する (含まれていないと `*)` 節で `unknown command` で終了してしまう)。 + +実際の `uv run` を起動すると環境依存になるため、run_python / ensure_uv を +差し替えた薄いハーネス経由で wrapper の dispatch ロジックだけを実行する。 +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +def _parse_wrapper_top_prefix_preferences() -> dict[str, str]: + """bin/devbase の resolve_command 内 ambiguous preference を抽出する。 + + `case "$input" in` ... `) preferred="" ;;` 形式の対を + 辞書に変換する。cli.py の TOP_PREFIX_PREFERENCES と同期検証するため。 + """ + text = WRAPPER.read_text() + # resolve_command の case ブロックを切り出す。 + block = text.split('case "$input" in', 1)[1].split("esac", 1)[0] + prefs: dict[str, str] = {} + for inp, cmd in re.findall(r'(\w+)\)\s*preferred="(\w+)"', block): + prefs[inp] = cmd + return prefs + + +def _run_wrapper(*args): + """run_python を no-op に差し替えて wrapper の dispatch だけを実行する。 + + wrapper を関数定義のみ読み込む形にできないため、`run_python` / + `ensure_uv` を export -f で先に定義し、wrapper 末尾の dispatch を + 別プロセスで評価する。wrapper は自身の run_python を再定義するので、 + `sed` で wrapper の run_python / ensure_uv 定義を取り除いてから評価する。 + """ + harness = ( + 'run_python() { echo "PYTHON:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + # wrapper から関数再定義を除いた本体を読み込む + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + '-e \'/^ensure_uv()/,/^}/d\' "$WRAPPER_PATH")"\n' + ) + env = { + **os.environ, + "DEVBASE_ROOT": str(REPO_ROOT), + "WRAPPER_PATH": str(WRAPPER), + } + return subprocess.run( + ["bash", "-c", harness, "devbase", *args], + capture_output=True, + text=True, + env=env, + ) + + +class TestWrapperStaticContent: + """静的に project が両所に登録されていることを確認 (回帰防止)。""" + + def test_project_in_resolve_command_list(self): + text = WRAPPER.read_text() + # resolve_command の候補リスト + assert " project " in text.split('local commands="', 1)[1].split('"', 1)[0] + " " + + def test_project_in_dispatch_case(self): + text = WRAPPER.read_text() + # Python-implemented commands の case ラベルに project が含まれる + case_labels = [ + line for line in text.splitlines() + if "run_python " in line and "_resolved_cmd" in line + ] + # 直前行 (case パターン) に project があること + assert any("project|" in line or "|project|" in line + for line in text.splitlines()) + + def test_top_prefix_preferences_synced_with_cli(self): + """wrapper と cli.py の top-level ambiguous preference が一致すること。 + + `l` → `login` の後方互換 preference は bin/devbase の resolve_command と + cli.py の TOP_PREFIX_PREFERENCES の 2 箇所に独立して定義されている。 + 片方だけ更新して乖離すると個別テストは通るのに挙動が割れるため、 + 両者の対応表が完全一致することをここで検証する (正確性指摘 #36)。 + """ + from devbase.cli import TOP_PREFIX_PREFERENCES + + wrapper_prefs = _parse_wrapper_top_prefix_preferences() + assert wrapper_prefs, "wrapper の preference 抽出に失敗" + assert wrapper_prefs == TOP_PREFIX_PREFERENCES, ( + f"wrapper={wrapper_prefs} vs cli.py={TOP_PREFIX_PREFERENCES} が乖離" + ) + + +class TestWrapperDispatch: + def test_project_reaches_python(self): + result = _run_wrapper("project", "--help") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project --help" in result.stdout, result.stdout + + def test_project_subcommand_reaches_python(self): + result = _run_wrapper("project", "up") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project up" in result.stdout, result.stdout + + def test_project_prefix_resolves_to_project(self): + # `proj` は project に一意に解決される。 + result = _run_wrapper("proj", "up") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project up" in result.stdout, result.stdout + + def test_unknown_command_still_errors(self): + result = _run_wrapper("bogus") + assert "unknown command" in result.stderr.lower() + assert result.returncode != 0 + + def test_top_level_list_reaches_python(self): + """PLAN06 Task 3: `devbase list` シノニムが Python へルーティングされる。""" + result = _run_wrapper("list") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:list" in result.stdout, result.stdout + + def test_top_level_list_interactive_flag_passthrough(self): + result = _run_wrapper("list", "--interactive") + assert "PYTHON:list --interactive" in result.stdout, result.stdout + + def test_project_list_reaches_python(self): + result = _run_wrapper("project", "list") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project list" in result.stdout, result.stdout + + def test_list_prefix_resolves(self): + # `li` は list に一意解決される (login は lo)。 + result = _run_wrapper("li") + assert "PYTHON:list" in result.stdout, result.stdout + + def test_l_prefix_resolves_to_login(self): + # 後方互換: `list` 追加で ambiguous になった `devbase l` を login に維持する + # (互換性指摘 #36)。preference 無しだと unknown command 'l' になる。 + result = _run_wrapper("l") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:login" in result.stdout, result.stdout + + def test_lo_prefix_resolves_to_login(self): + result = _run_wrapper("lo") + assert "PYTHON:login" in result.stdout, result.stdout + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))