Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions bin/devbase
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,30 @@ run_python() {
# Resolve abbreviated command to full command name via unique prefix matching
resolve_command() {
local input="$1"
local commands="init status shell-rc project 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"
Comment thread
takemi-ohama marked this conversation as resolved.
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
}

# ===================================================================
Expand Down Expand Up @@ -279,7 +293,7 @@ case "$_resolved_cmd" in
# Python-implemented commands
--version|-V)
run_python "$@" ;;
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale)
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) cmd_build "${_DEVBASE_ARGS[@]}" ;;
Expand Down
44 changes: 41 additions & 3 deletions lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
SUBCMD_MAP = {
('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
('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'],
Expand All @@ -64,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',
Comment thread
takemi-ohama marked this conversation as resolved.
}


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

_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):
"""Env group parser"""
Expand Down Expand Up @@ -396,6 +418,11 @@ def _add_shortcuts(subparsers):
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"""
Expand Down Expand Up @@ -477,11 +504,11 @@ def _expand_argv():
# 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', 'help']
'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]
Expand Down Expand Up @@ -533,9 +560,20 @@ def _dispatch(cmd, 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
Expand Down
18 changes: 18 additions & 0 deletions lib/devbase/commands/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ def _load_project_env(env_file: Path) -> None:
ため、ここでは ``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 <name>``) のフォールバック時のみ影響し、
通常運用の wrapper 経路では shell が env を解釈するため差異は生じない。
"""
if not env_file.is_file():
return
Expand Down
181 changes: 181 additions & 0 deletions lib/devbase/commands/project.py
Original file line number Diff line number Diff line change
@@ -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 の場合、その **リンク先** (``../<plugin.path>/projects/<proj>``)
から plugin 名を解決する。PLAN04 の同名衝突 suffix (例 ``carmo.takemi--carmo``)
は **リンク名のみ** に付与され、リンク先 dir 名は素の ``<proj>`` のままであるため、
リンク名でなくリンク先を辿ることで suffix の有無に関わらず正しく解決できる。

plugin 名はリンク先パスの ``projects`` セグメント直前の要素:
- repos ベース: ``../repos/<owner>--<repo>/<plugin>/projects/<proj>`` → ``<plugin>``
- --link ベース: ``../plugins/<name>/projects/<proj>`` → ``<name>``

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 <name>`` を起動する。

外部依存 (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 環境)。"
Comment thread
takemi-ohama marked this conversation as resolved.
"`devbase project up <name>` で直接指定してください。")
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
Loading