|
| 1 | +"""Project listing commands (`devbase project list` / `devbase list`). |
| 2 | +
|
| 3 | +PLAN06 Task 3。`$DEVBASE_ROOT/projects/` 配下を NAME / PLUGIN / STATUS で一覧表示し、 |
| 4 | +``--interactive`` で選択 → `project up` 起動を行う。 |
| 5 | +
|
| 6 | +ライフサイクル操作 (up/down/ps/login/logs/scale/build) は引き続き |
| 7 | +``commands/container.py`` の共有ハンドラが担当し、本モジュールは listing と |
| 8 | +interactive 起動のみを担う。 |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import os |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +from devbase.log import get_logger |
| 17 | + |
| 18 | +logger = get_logger(__name__) |
| 19 | + |
| 20 | + |
| 21 | +def _resolve_plugin_name(entry: Path) -> str | None: |
| 22 | + """projects/ 配下の entry が属する plugin 名を解決する。 |
| 23 | +
|
| 24 | + entry が symlink の場合、その **リンク先** (``../<plugin.path>/projects/<proj>``) |
| 25 | + から plugin 名を解決する。PLAN04 の同名衝突 suffix (例 ``carmo.takemi--carmo``) |
| 26 | + は **リンク名のみ** に付与され、リンク先 dir 名は素の ``<proj>`` のままであるため、 |
| 27 | + リンク名でなくリンク先を辿ることで suffix の有無に関わらず正しく解決できる。 |
| 28 | +
|
| 29 | + plugin 名はリンク先パスの ``projects`` セグメント直前の要素: |
| 30 | + - repos ベース: ``../repos/<owner>--<repo>/<plugin>/projects/<proj>`` → ``<plugin>`` |
| 31 | + - --link ベース: ``../plugins/<name>/projects/<proj>`` → ``<name>`` |
| 32 | +
|
| 33 | + symlink でない実ディレクトリ (plugin に属さない) や解決不能な場合は ``None``。 |
| 34 | + リンク先実体が存在しない (broken symlink) 場合もリンクテキストから解決する。 |
| 35 | + """ |
| 36 | + if not entry.is_symlink(): |
| 37 | + return None |
| 38 | + try: |
| 39 | + target = os.readlink(entry) |
| 40 | + except OSError: |
| 41 | + return None |
| 42 | + |
| 43 | + parts = Path(target).parts |
| 44 | + # `projects` の最後の出現位置 (proj 名の直前) を採用する。 |
| 45 | + # ただし直前要素が plugin 名として無効なパス区切り (`/` ルートや `..` 相対) の |
| 46 | + # 場合は解決失敗扱い (None)。例: `/projects/proj` → parts[0] が `/` になる。 |
| 47 | + for i in range(len(parts) - 1, 0, -1): |
| 48 | + if parts[i] == "projects": |
| 49 | + candidate = parts[i - 1] |
| 50 | + if candidate in (os.sep, "/", "..", "."): |
| 51 | + return None |
| 52 | + return candidate |
| 53 | + return None |
| 54 | + |
| 55 | + |
| 56 | +def list_projects(projects_dir: Path) -> list[dict]: |
| 57 | + """projects/ 配下のプロジェクトを NAME / PLUGIN / STATUS で列挙する。 |
| 58 | +
|
| 59 | + 各要素は ``{"name", "plugin", "status"}``。 |
| 60 | +
|
| 61 | + - ``name``: projects/ 内のエントリ名 (衝突 suffix 付きもそのまま) |
| 62 | + - ``plugin``: ``_resolve_plugin_name`` の結果。実ディレクトリ / 解決不能は ``"-"`` |
| 63 | + - ``status``: ``status._container_status_for`` の状態文字列。 |
| 64 | + compose.yml 無し / docker 不在等で取得できない場合は ``"unknown"`` |
| 65 | +
|
| 66 | + symlink (broken 含む) と実ディレクトリの両方を対象とする。 |
| 67 | + """ |
| 68 | + # status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry |
| 69 | + # 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。 |
| 70 | + from concurrent.futures import ThreadPoolExecutor |
| 71 | + |
| 72 | + from devbase.commands import status as status_mod |
| 73 | + |
| 74 | + if not projects_dir.exists(): |
| 75 | + return [] |
| 76 | + |
| 77 | + entries = [ |
| 78 | + # broken symlink は is_dir() が False になるため symlink 自体も拾う。 |
| 79 | + entry for entry in sorted(projects_dir.iterdir()) |
| 80 | + if entry.is_symlink() or entry.is_dir() |
| 81 | + ] |
| 82 | + |
| 83 | + def _status_for(entry: Path) -> str: |
| 84 | + # is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。 |
| 85 | + # _container_status_for は cwd= 引数で完結し global chdir を行わないため |
| 86 | + # スレッド安全。各 `docker compose ps` は I/O バウンドで 10s timeout を |
| 87 | + # 持つため、プロジェクト数が増えても並列化で総待ち時間を抑える。 |
| 88 | + if not entry.is_dir(): |
| 89 | + return "unknown" |
| 90 | + st = status_mod._container_status_for(entry) |
| 91 | + return st["status"] if st is not None else "unknown" |
| 92 | + |
| 93 | + # entries が空だと max_workers=0 で ValueError になるため早期 return。 |
| 94 | + if not entries: |
| 95 | + return [] |
| 96 | + |
| 97 | + with ThreadPoolExecutor(max_workers=min(8, len(entries))) as ex: |
| 98 | + statuses = list(ex.map(_status_for, entries)) |
| 99 | + |
| 100 | + return [ |
| 101 | + { |
| 102 | + "name": entry.name, |
| 103 | + "plugin": _resolve_plugin_name(entry) or "-", |
| 104 | + "status": status, |
| 105 | + } |
| 106 | + for entry, status in zip(entries, statuses) |
| 107 | + ] |
| 108 | + |
| 109 | + |
| 110 | +def _print_table(rows: list[dict]) -> None: |
| 111 | + """NAME / PLUGIN / STATUS の整列テーブルを標準出力に表示する。""" |
| 112 | + name_w = max(len("NAME"), *(len(r["name"]) for r in rows)) |
| 113 | + plugin_w = max(len("PLUGIN"), *(len(r["plugin"]) for r in rows)) |
| 114 | + print(f"{'NAME':<{name_w}} {'PLUGIN':<{plugin_w}} STATUS") |
| 115 | + for r in rows: |
| 116 | + print(f"{r['name']:<{name_w}} {r['plugin']:<{plugin_w}} {r['status']}") |
| 117 | + |
| 118 | + |
| 119 | +def _interactive_select_and_up(rows: list[dict]) -> int: |
| 120 | + """一覧から番号入力で 1 件選択し ``project up <name>`` を起動する。 |
| 121 | +
|
| 122 | + 外部依存 (simple_term_menu 等) を増やさず stdlib の ``input()`` で実装する。 |
| 123 | + 非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。 |
| 124 | + """ |
| 125 | + print("起動するプロジェクトを選択してください:") |
| 126 | + for i, r in enumerate(rows, 1): |
| 127 | + print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") |
| 128 | + |
| 129 | + # 一覧取得が重い場合があるため、誤入力 (数値以外 / 範囲外) では即終了せず |
| 130 | + # 再入力を促す。空入力は中止、非 TTY (EOFError) はエラー終了。 |
| 131 | + while True: |
| 132 | + try: |
| 133 | + raw = input("番号 (空で中止): ").strip() |
| 134 | + except EOFError: |
| 135 | + logger.error("対話入力ができません (非 TTY 環境)。" |
| 136 | + "`devbase project up <name>` で直接指定してください。") |
| 137 | + return 1 |
| 138 | + except KeyboardInterrupt: |
| 139 | + # Ctrl+C は traceback を出さず中止として扱う。 |
| 140 | + print() |
| 141 | + logger.info("中止しました。") |
| 142 | + return 0 |
| 143 | + |
| 144 | + if not raw: |
| 145 | + logger.info("中止しました。") |
| 146 | + return 0 |
| 147 | + |
| 148 | + try: |
| 149 | + idx = int(raw) |
| 150 | + except ValueError: |
| 151 | + logger.error("番号で指定してください: %r", raw) |
| 152 | + continue |
| 153 | + |
| 154 | + if not (1 <= idx <= len(rows)): |
| 155 | + logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) |
| 156 | + continue |
| 157 | + |
| 158 | + break |
| 159 | + |
| 160 | + name = rows[idx - 1]["name"] |
| 161 | + # name 解決 (chdir) + up は共有ハンドラ cmd_project に委譲する。 |
| 162 | + import types |
| 163 | + |
| 164 | + from devbase.commands.container import cmd_project |
| 165 | + return cmd_project(types.SimpleNamespace(subcommand="up", name=name, scale=None)) |
| 166 | + |
| 167 | + |
| 168 | +def cmd_project_list(devbase_root: Path, args) -> int: |
| 169 | + """`devbase project list [--interactive]` / `devbase list [--interactive]`。""" |
| 170 | + projects_dir = Path(devbase_root) / "projects" |
| 171 | + rows = list_projects(projects_dir) |
| 172 | + |
| 173 | + if not rows: |
| 174 | + logger.info("プロジェクトがありません (%s)。", projects_dir) |
| 175 | + return 0 |
| 176 | + |
| 177 | + if getattr(args, "interactive", False): |
| 178 | + return _interactive_select_and_up(rows) |
| 179 | + |
| 180 | + _print_table(rows) |
| 181 | + return 0 |
0 commit comments