Skip to content

Commit 610abe0

Browse files
takemi-ohamaclaude
andauthored
feat: PLAN06-3 project list 一覧表示 + --interactive 選択起動 (#36)
* feat: PLAN06-3 project list 一覧表示 + --interactive 選択起動 `devbase project list` / トップレベルシノニム `devbase list` を新設し、 $DEVBASE_ROOT/projects/ 配下を NAME / PLUGIN / STATUS で一覧表示する。 - lib/devbase/commands/project.py (新規): - _resolve_plugin_name: symlink 先から plugin 名を解決。PLAN04 の同名衝突 suffix (carmo.takemi) はリンク名のみに付きリンク先 dir は素の <proj> の ままなので、リンク先を辿ることで suffix 有無に関わらず正しく解決する。 - list_projects: projects/ 配下 (symlink/実dir/broken symlink) を列挙。 status は status._container_status_for を共有 (取得不能は unknown)。 - cmd_project_list: 整列テーブル表示 / --interactive で番号入力選択 → project up 起動 (新規依存を足さず stdlib input、非TTY は EOFError graceful)。 - lib/devbase/commands/status.py: - per-entry の _container_status_for を抽出し project list と共有 (cmd_status の挙動は不変)。 - lib/devbase/cli.py: - project list サブコマンド (--interactive/-i) + トップレベル list シノニム - SUBCMD_MAP / _expand_argv / dispatch に list を同期 (DEVBASE_ROOT 必須) - bin/devbase: - resolve_command 候補 + dispatch case に list を追加 (name 解決対象外) - tests: test_project_list.py 新規 (25件) + wrapper dispatch に list 経路 4件 pytest: 395 passed (baseline 366 + 29) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(project): PR#36 レビュー対応 — l→login 互換 / status 並列化 / 堅牢性 / 再入力 - [major/互換性] `list` 追加で ambiguous になった `devbase l` を `login` に維持 (bin/devbase resolve_command + cli.py TOP_PREFIX_PREFERENCES)。`li` は list のまま。 - [major/性能] list_projects の `docker compose ps` を ThreadPoolExecutor で並列化 (cwd= で完結し global chdir せずスレッド安全)。 - [minor/堅牢性] _resolve_plugin_name が `/projects/proj` 等で `/`・`..` を plugin 名に返さず None に。 - [minor/UX] _interactive_select_and_up を誤入力 (数値以外/範囲外) で再入力ループに。 - 回帰テスト 6 件追加。pytest 402 passed。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(project): PR#36 round2 — KeyboardInterrupt 中止 / prefix preference 同期テスト - _interactive_select_and_up: Ctrl+C (KeyboardInterrupt) を捕捉し traceback を出さず中止 (rc=0) として扱う (minor / 堅牢性) - TOP_PREFIX_PREFERENCES の bin/devbase と cli.py の乖離防止のため、 両者の preference 対応表が一致することを検証する同期テストを追加 (major / 正確性) - 回帰テスト 2 件追加 (keyboard_interrupt_aborts / synced_with_cli) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(project): PR#36 deferred nit — _load_project_env と shell source の仕様乖離を明文化 gemini round2 review-body の将来課題推奨 (Python パーサと shell source の env 解釈が乖離し得る) に対し、仕様統一はリスクが大きいため制約のドキュメント化で 対応する。 - _load_project_env docstring に shell ``source`` との具体的な乖離ケース (変数展開 / コマンド置換 / 行中クォート / インラインコメント) を note 追記。 いずれも wrapper を経ない直接起動のフォールバック時のみ影響する旨を明記。 - 乖離挙動を pin する回帰テスト test_load_project_env_diverges_from_shell_source を追加。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a532ff8 commit 610abe0

8 files changed

Lines changed: 906 additions & 74 deletions

File tree

bin/devbase

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,30 @@ run_python() {
168168
# Resolve abbreviated command to full command name via unique prefix matching
169169
resolve_command() {
170170
local input="$1"
171-
local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale help"
171+
local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help"
172172
local matches=()
173173
for cmd in $commands; do
174174
[[ "$cmd" == "$input"* ]] && matches+=("$cmd")
175175
done
176176
if [ ${#matches[@]} -eq 1 ]; then
177177
echo "${matches[0]}"
178-
else
179-
echo "$input" # no match or ambiguous -> return as-is
178+
return
179+
fi
180+
# ambiguous の場合の後方互換 preference。`list` 追加で `l` が login/list の
181+
# 両方にマッチするようになったため、既存の `devbase l` → `login` を維持する。
182+
# cli.py の TOP_PREFIX_PREFERENCES と同期させること。
183+
if [ ${#matches[@]} -gt 1 ]; then
184+
local preferred=""
185+
case "$input" in
186+
l) preferred="login" ;;
187+
esac
188+
if [ -n "$preferred" ]; then
189+
for m in "${matches[@]}"; do
190+
[ "$m" = "$preferred" ] && { echo "$preferred"; return; }
191+
done
192+
fi
180193
fi
194+
echo "$input" # no match or ambiguous -> return as-is
181195
}
182196

183197
# ===================================================================
@@ -279,7 +293,7 @@ case "$_resolved_cmd" in
279293
# Python-implemented commands
280294
--version|-V)
281295
run_python "$@" ;;
282-
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale)
296+
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|list)
283297
run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;;
284298
# Shell-implemented commands
285299
build) cmd_build "${_DEVBASE_ARGS[@]}" ;;

lib/devbase/cli.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
4747
SUBCMD_MAP = {
48-
('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
48+
('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'list'],
4949
('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
5050
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'],
5151
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'],
@@ -64,6 +64,14 @@
6464
},
6565
}
6666

67+
# トップレベルコマンドの ambiguous prefix 後方互換 preference。
68+
# `list` (PLAN06 Task 3) 追加で `l` が `login` / `list` の両方にマッチして
69+
# ambiguous になったため、既存ショートカット (`devbase l` → `login`) を維持する。
70+
# bin/devbase の resolve_command 内 preference と同期させること。
71+
TOP_PREFIX_PREFERENCES = {
72+
'l': 'login',
73+
}
74+
6775

6876
def _require_devbase_root() -> Path:
6977
"""Get DEVBASE_ROOT from environment, exiting if not set."""
@@ -167,6 +175,20 @@ def _add_project_parser(subparsers):
167175

168176
_add_build_subparser(pj_sub)
169177

178+
# `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は
179+
# 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。
180+
_add_list_subparser(pj_sub)
181+
182+
183+
def _add_list_subparser(sub):
184+
"""`list` サブコマンドを登録する (project list / top-level list 共通)。
185+
186+
NAME / PLUGIN / STATUS の一覧表示。`--interactive` で選択 → `project up` 起動。
187+
"""
188+
p = sub.add_parser('list', help='List projects (NAME / PLUGIN / STATUS)')
189+
p.add_argument('--interactive', '-i', action='store_true',
190+
help='Select a project interactively and start it')
191+
170192

171193
def _add_env_parser(subparsers):
172194
"""Env group parser"""
@@ -396,6 +418,11 @@ def _add_shortcuts(subparsers):
396418
scale_sc.add_argument('name', nargs='?', default=None, help='Project name')
397419
scale_sc.add_argument('new_scale', type=int, help='New number of containers')
398420

421+
# `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示
422+
# のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に
423+
# cmd_project_list へ振り分ける。
424+
_add_list_subparser(subparsers)
425+
399426

400427
def _create_parser():
401428
"""Create command line parser"""
@@ -477,11 +504,11 @@ def _expand_argv():
477504
# bin/devbase が build を shell 実装に委譲するため Python 側には top-level
478505
# build parser が無い。project build / container build は引き続き利用可能。
479506
commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl',
480-
'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'help']
507+
'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'list', 'help']
481508
repo_subcmds = ['add', 'remove', 'list', 'refresh']
482509

483510
if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'):
484-
sys.argv[1] = _resolve_prefix(sys.argv[1], commands)
511+
sys.argv[1] = _resolve_prefix(sys.argv[1], commands, TOP_PREFIX_PREFERENCES)
485512

486513
if len(sys.argv) >= 3 and not sys.argv[2].startswith('-'):
487514
cmd = sys.argv[1]
@@ -533,9 +560,20 @@ def _dispatch(cmd, args):
533560

534561
# --- Project group (推奨) ---
535562
if cmd == 'project':
563+
# `project list` は lifecycle ではなく一覧表示 (DEVBASE_ROOT 必須)。
564+
if getattr(args, 'subcommand', None) == 'list':
565+
devbase_root = _require_devbase_root()
566+
from devbase.commands.project import cmd_project_list
567+
return cmd_project_list(devbase_root, args)
536568
from devbase.commands.container import cmd_project
537569
return cmd_project(args)
538570

571+
# --- Top-level `list` synonym for `project list` ---
572+
if cmd == 'list':
573+
devbase_root = _require_devbase_root()
574+
from devbase.commands.project import cmd_project_list
575+
return cmd_project_list(devbase_root, args)
576+
539577
# --- Container group (非推奨: project へ委譲 + warning) ---
540578
if cmd == 'container':
541579
from devbase.commands.container import cmd_container

lib/devbase/commands/container.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,24 @@ def _load_project_env(env_file: Path) -> None:
129129
ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを
130130
解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell
131131
の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。
132+
133+
.. note:: shell ``source`` との仕様乖離について
134+
135+
本パーサは完全な POSIX shell パーサではなく、shell ``source ./env``
136+
(wrapper 経路) とは以下のケースで挙動が乖離する。env は単純な
137+
``KEY=VALUE`` 定義に限定する運用前提のため、これらは意図的な制約として
138+
受容し、ファイル側で利用しない方針とする (仕様統一ではなく制約の明示)::
139+
140+
FOO=$BAR # shell: 展開 → 本実装: リテラル文字列 "$BAR"
141+
FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)"
142+
FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の
143+
# クォートは除去せず "a\"b\"c"
144+
FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") →
145+
# 本実装も値は "bar # x" (行頭 # のみコメント扱い)
146+
147+
いずれも wrapper を経ない直接起動 (例:
148+
``python -m devbase.cli project up <name>``) のフォールバック時のみ影響し、
149+
通常運用の wrapper 経路では shell が env を解釈するため差異は生じない。
132150
"""
133151
if not env_file.is_file():
134152
return

lib/devbase/commands/project.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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

Comments
 (0)