Skip to content

Commit 918249e

Browse files
takemi-ohamaclaude
andcommitted
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>
1 parent a532ff8 commit 918249e

6 files changed

Lines changed: 674 additions & 71 deletions

File tree

bin/devbase

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ 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")
@@ -279,7 +279,7 @@ case "$_resolved_cmd" in
279279
# Python-implemented commands
280280
--version|-V)
281281
run_python "$@" ;;
282-
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale)
282+
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|list)
283283
run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;;
284284
# Shell-implemented commands
285285
build) cmd_build "${_DEVBASE_ARGS[@]}" ;;

lib/devbase/cli.py

Lines changed: 32 additions & 2 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'],
@@ -167,6 +167,20 @@ def _add_project_parser(subparsers):
167167

168168
_add_build_subparser(pj_sub)
169169

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

171185
def _add_env_parser(subparsers):
172186
"""Env group parser"""
@@ -396,6 +410,11 @@ def _add_shortcuts(subparsers):
396410
scale_sc.add_argument('name', nargs='?', default=None, help='Project name')
397411
scale_sc.add_argument('new_scale', type=int, help='New number of containers')
398412

413+
# `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示
414+
# のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に
415+
# cmd_project_list へ振り分ける。
416+
_add_list_subparser(subparsers)
417+
399418

400419
def _create_parser():
401420
"""Create command line parser"""
@@ -477,7 +496,7 @@ def _expand_argv():
477496
# bin/devbase が build を shell 実装に委譲するため Python 側には top-level
478497
# build parser が無い。project build / container build は引き続き利用可能。
479498
commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl',
480-
'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'help']
499+
'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'list', 'help']
481500
repo_subcmds = ['add', 'remove', 'list', 'refresh']
482501

483502
if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'):
@@ -533,9 +552,20 @@ def _dispatch(cmd, args):
533552

534553
# --- Project group (推奨) ---
535554
if cmd == 'project':
555+
# `project list` は lifecycle ではなく一覧表示 (DEVBASE_ROOT 必須)。
556+
if getattr(args, 'subcommand', None) == 'list':
557+
devbase_root = _require_devbase_root()
558+
from devbase.commands.project import cmd_project_list
559+
return cmd_project_list(devbase_root, args)
536560
from devbase.commands.container import cmd_project
537561
return cmd_project(args)
538562

563+
# --- Top-level `list` synonym for `project list` ---
564+
if cmd == 'list':
565+
devbase_root = _require_devbase_root()
566+
from devbase.commands.project import cmd_project_list
567+
return cmd_project_list(devbase_root, args)
568+
539569
# --- Container group (非推奨: project へ委譲 + warning) ---
540570
if cmd == 'container':
541571
from devbase.commands.container import cmd_container

lib/devbase/commands/project.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
for i in range(len(parts) - 1, 0, -1):
46+
if parts[i] == "projects":
47+
return parts[i - 1]
48+
return None
49+
50+
51+
def list_projects(projects_dir: Path) -> list[dict]:
52+
"""projects/ 配下のプロジェクトを NAME / PLUGIN / STATUS で列挙する。
53+
54+
各要素は ``{"name", "plugin", "status"}``。
55+
56+
- ``name``: projects/ 内のエントリ名 (衝突 suffix 付きもそのまま)
57+
- ``plugin``: ``_resolve_plugin_name`` の結果。実ディレクトリ / 解決不能は ``"-"``
58+
- ``status``: ``status._container_status_for`` の状態文字列。
59+
compose.yml 無し / docker 不在等で取得できない場合は ``"unknown"``
60+
61+
symlink (broken 含む) と実ディレクトリの両方を対象とする。
62+
"""
63+
# status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry
64+
# 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。
65+
from devbase.commands import status as status_mod
66+
67+
results: list[dict] = []
68+
if not projects_dir.exists():
69+
return results
70+
71+
for entry in sorted(projects_dir.iterdir()):
72+
# broken symlink は is_dir() が False になるため symlink 自体も拾う。
73+
if not (entry.is_symlink() or entry.is_dir()):
74+
continue
75+
76+
plugin = _resolve_plugin_name(entry)
77+
78+
status = "unknown"
79+
# is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。
80+
if entry.is_dir():
81+
st = status_mod._container_status_for(entry)
82+
if st is not None:
83+
status = st["status"]
84+
85+
results.append({
86+
"name": entry.name,
87+
"plugin": plugin or "-",
88+
"status": status,
89+
})
90+
91+
return results
92+
93+
94+
def _print_table(rows: list[dict]) -> None:
95+
"""NAME / PLUGIN / STATUS の整列テーブルを標準出力に表示する。"""
96+
name_w = max(len("NAME"), *(len(r["name"]) for r in rows))
97+
plugin_w = max(len("PLUGIN"), *(len(r["plugin"]) for r in rows))
98+
print(f"{'NAME':<{name_w}} {'PLUGIN':<{plugin_w}} STATUS")
99+
for r in rows:
100+
print(f"{r['name']:<{name_w}} {r['plugin']:<{plugin_w}} {r['status']}")
101+
102+
103+
def _interactive_select_and_up(rows: list[dict]) -> int:
104+
"""一覧から番号入力で 1 件選択し ``project up <name>`` を起動する。
105+
106+
外部依存 (simple_term_menu 等) を増やさず stdlib の ``input()`` で実装する。
107+
非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。
108+
"""
109+
print("起動するプロジェクトを選択してください:")
110+
for i, r in enumerate(rows, 1):
111+
print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})")
112+
113+
try:
114+
raw = input("番号 (空で中止): ").strip()
115+
except EOFError:
116+
logger.error("対話入力ができません (非 TTY 環境)。"
117+
"`devbase project up <name>` で直接指定してください。")
118+
return 1
119+
120+
if not raw:
121+
logger.info("中止しました。")
122+
return 0
123+
124+
try:
125+
idx = int(raw)
126+
except ValueError:
127+
logger.error("番号で指定してください: %r", raw)
128+
return 1
129+
130+
if not (1 <= idx <= len(rows)):
131+
logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows))
132+
return 1
133+
134+
name = rows[idx - 1]["name"]
135+
# name 解決 (chdir) + up は共有ハンドラ cmd_project に委譲する。
136+
import types
137+
138+
from devbase.commands.container import cmd_project
139+
return cmd_project(types.SimpleNamespace(subcommand="up", name=name, scale=None))
140+
141+
142+
def cmd_project_list(devbase_root: Path, args) -> int:
143+
"""`devbase project list [--interactive]` / `devbase list [--interactive]`。"""
144+
projects_dir = Path(devbase_root) / "projects"
145+
rows = list_projects(projects_dir)
146+
147+
if not rows:
148+
logger.info("プロジェクトがありません (%s)。", projects_dir)
149+
return 0
150+
151+
if getattr(args, "interactive", False):
152+
return _interactive_select_and_up(rows)
153+
154+
_print_table(rows)
155+
return 0

lib/devbase/commands/status.py

Lines changed: 69 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -16,82 +16,84 @@
1616
logger = get_logger(__name__)
1717

1818

19-
def _get_container_status(projects_dir: Path) -> list[dict]:
20-
"""projects/ 配下の各プロジェクトのコンテナ状態を取得する"""
21-
results = []
22-
if not projects_dir.exists():
23-
return results
24-
25-
for entry in sorted(projects_dir.iterdir()):
26-
if not entry.is_dir():
27-
continue
28-
compose_file = entry / "compose.yml"
29-
if not compose_file.exists():
30-
continue
19+
def _container_status_for(entry: Path) -> dict | None:
20+
"""単一プロジェクトディレクトリのコンテナ状態を取得する。
21+
22+
`projects/<name>` (実ディレクトリ or plugin への symlink) を受け取り、
23+
``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker
24+
コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。
25+
26+
PLAN06 で ``project list`` (commands/project.py) が同じ per-entry ロジックを
27+
再利用するため、``_get_container_status`` のループ本体から分離した。挙動は
28+
分離前と同一 (None を返す条件 = 旧実装で ``continue`` していた条件)。
29+
"""
30+
compose_file = entry / "compose.yml"
31+
if not compose_file.exists():
32+
return None
3133

32-
try:
33-
proc = subprocess.run(
34-
["docker", "compose", "ps", "--format", "json"],
35-
cwd=str(entry),
36-
capture_output=True,
37-
text=True,
38-
timeout=10,
39-
)
40-
if proc.returncode != 0:
34+
try:
35+
proc = subprocess.run(
36+
["docker", "compose", "ps", "--format", "json"],
37+
cwd=str(entry),
38+
capture_output=True,
39+
text=True,
40+
timeout=10,
41+
)
42+
if proc.returncode != 0:
43+
return None
44+
45+
output = proc.stdout.strip()
46+
if not output:
47+
return {"name": entry.name, "status": "stopped", "count": 0}
48+
49+
# docker compose ps --format json は1行1JSONまたはJSON配列
50+
containers = []
51+
for line in output.splitlines():
52+
line = line.strip()
53+
if not line:
4154
continue
42-
43-
output = proc.stdout.strip()
44-
if not output:
45-
results.append({
46-
"name": entry.name,
47-
"status": "stopped",
48-
"count": 0,
49-
})
55+
try:
56+
parsed = json.loads(line)
57+
if isinstance(parsed, list):
58+
containers.extend(parsed)
59+
else:
60+
containers.append(parsed)
61+
except json.JSONDecodeError:
5062
continue
5163

52-
# docker compose ps --format json は1行1JSONまたはJSON配列
53-
containers = []
54-
for line in output.splitlines():
55-
line = line.strip()
56-
if not line:
57-
continue
58-
try:
59-
parsed = json.loads(line)
60-
if isinstance(parsed, list):
61-
containers.extend(parsed)
62-
else:
63-
containers.append(parsed)
64-
except json.JSONDecodeError:
65-
continue
66-
67-
if not containers:
68-
results.append({
69-
"name": entry.name,
70-
"status": "stopped",
71-
"count": 0,
72-
})
73-
continue
64+
if not containers:
65+
return {"name": entry.name, "status": "stopped", "count": 0}
7466

75-
running = sum(
76-
1 for c in containers
77-
if c.get("State", "").lower() == "running"
78-
)
79-
total = len(containers)
67+
running = sum(
68+
1 for c in containers
69+
if c.get("State", "").lower() == "running"
70+
)
71+
total = len(containers)
8072

81-
if running > 0:
82-
status = f"running ({total} containers)"
83-
else:
84-
status = "stopped"
73+
if running > 0:
74+
status = f"running ({total} containers)"
75+
else:
76+
status = "stopped"
8577

86-
results.append({
87-
"name": entry.name,
88-
"status": status,
89-
"count": total,
90-
})
78+
return {"name": entry.name, "status": status, "count": total}
9179

92-
except (subprocess.TimeoutExpired, OSError):
93-
# dockerコマンドが利用できない、またはタイムアウト
80+
except (subprocess.TimeoutExpired, OSError):
81+
# dockerコマンドが利用できない、またはタイムアウト
82+
return None
83+
84+
85+
def _get_container_status(projects_dir: Path) -> list[dict]:
86+
"""projects/ 配下の各プロジェクトのコンテナ状態を取得する"""
87+
results = []
88+
if not projects_dir.exists():
89+
return results
90+
91+
for entry in sorted(projects_dir.iterdir()):
92+
if not entry.is_dir():
9493
continue
94+
status = _container_status_for(entry)
95+
if status is not None:
96+
results.append(status)
9597

9698
return results
9799

0 commit comments

Comments
 (0)