Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
### Added
- **`devbase project` サブコマンド群を新設**しました (PLAN06)。CWD に依存せずプロジェクト名でコンテナ操作ができます。
- `devbase project up/down/ps/logs/scale [name]` で、任意のディレクトリから `$DEVBASE_ROOT/projects/<name>` を対象に操作できます。名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行するため、シェル実装の `build` を含む全操作が名前指定で成立します(呼び出し元シェルの作業ディレクトリは変わりません)。存在しない名前はエラーになり候補が提示されます。
- `devbase project list [--interactive|-i]` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。`--interactive` では一覧から番号で選択して起動でき、非対話環境では番号入力にフォールバックします
- `devbase project list` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。**TTY ではデフォルトで対話選択**になり、一覧から番号で選んだプロジェクトを `project up` で起動します。`--no-interactive`(`--plain` / `-P`)で一覧表示のみに切り替えられ、パイプ・リダイレクト・CI などの非 TTY 環境では自動的に一覧表示へフォールバックします(`--interactive` / `-i` は後方互換として引き続き受け付けます)
- トップレベルシノニム `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` 体系に更新しました。
Expand Down
19 changes: 12 additions & 7 deletions docs/user/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ graph TD
D --> D1["up / down / ps / logs / scale [name]"]
D --> D3["login [index]"]
D --> D4["build [image]"]
D --> D2["list [--interactive]"]
D --> D2["list [--no-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]
Expand Down Expand Up @@ -292,21 +292,26 @@ devbase build [image]
`$DEVBASE_ROOT/projects/` 配下のプロジェクトを `NAME` / `PLUGIN` / `STATUS` の一覧で
表示します。

TTY(端末)では**デフォルトで対話選択**になり、番号入力で選んだプロジェクトを
`project up` で起動します。パイプ・リダイレクト・CI などの非 TTY 環境では自動的に
一覧表示のみへフォールバックします。

```
devbase project list [--interactive|-i]
devbase list [--interactive|-i]
devbase project list [--no-interactive|--plain|-P]
devbase list [--no-interactive|--plain|-P]
```

| オプション | 説明 |
|-----------|------|
| `--interactive` / `-i` | 一覧から番号で選択し、そのプロジェクトを `project up` で起動 |
| `--no-interactive` / `--plain` / `-P` | 対話選択せず一覧表示のみ |
| `--interactive` / `-i` | (後方互換)対話選択。デフォルトのため通常は不要 |

```bash
# 一覧表示
# 一覧を表示して番号で選択・起動(TTY デフォルト)
devbase list

# 一覧から選んで起動(非対話環境では番号入力にフォールバック
devbase list -i
# 一覧表示のみ(選択しない
devbase list --no-interactive
```

出力例:
Expand Down
10 changes: 7 additions & 3 deletions docs/user/container-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,17 @@ devbase project logs -f --tail 100
### プロジェクト一覧

```bash
# 全プロジェクトを NAME / PLUGIN / STATUS で一覧表示
# 一覧を表示し、番号で選択して起動(TTY ではこれがデフォルト)
devbase list

# 一覧から選択して起動(非対話環境では番号入力にフォールバック)
devbase list -i
# 選択せず NAME / PLUGIN / STATUS の一覧表示のみ
devbase list --no-interactive # --plain / -P も同義
```

> TTY(端末)では `devbase list` はデフォルトで対話選択になり、番号入力で
> そのプロジェクトを起動します。パイプ・リダイレクト・CI などの非 TTY 環境では
> 自動的に一覧表示のみにフォールバックします。

`devbase project ps` が「対象プロジェクト 1 つのコンテナ状態」を表示するのに対し、
`devbase list` は「全プロジェクトの横断一覧」を表示します。

Expand Down
14 changes: 10 additions & 4 deletions etc/_devbase
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,11 @@ _devbase() {
;;
list)
_arguments \
'--interactive[Select a project interactively and start it]' \
'-i[Select a project interactively and start it]'
'--no-interactive[Just print the table without interactive selection]' \
'--plain[Just print the table without interactive selection]' \
'-P[Just print the table without interactive selection]' \
'--interactive[(compat) interactive selection, default]' \
'-i[(compat) interactive selection, default]'
;;
project)
case "$words[3]" in
Expand Down Expand Up @@ -185,8 +188,11 @@ _devbase() {
;;
list)
_arguments \
'--interactive[Select a project interactively and start it]' \
'-i[Select a project interactively and start it]'
'--no-interactive[Just print the table without interactive selection]' \
'--plain[Just print the table without interactive selection]' \
'-P[Just print the table without interactive selection]' \
'--interactive[(compat) interactive selection, default]' \
'-i[(compat) interactive selection, default]'
;;
*)
_describe -t project-commands 'project command' project_subcommands
Expand Down
8 changes: 4 additions & 4 deletions etc/devbase-completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ _devbase_completions() {
COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur"))
fi
;;
# list は位置引数を取らず --interactive のみ。`-*` ガードを外し
# list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し
# 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。
list)
COMPREPLY=($(compgen -W "--interactive -i" -- "$cur"))
COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur"))
;;
project)
COMPREPLY=($(compgen -W "$project_subcommands" -- "$cur"))
Expand Down Expand Up @@ -121,10 +121,10 @@ _devbase_completions() {
COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur"))
fi
;;
# list は位置引数を取らず --interactive のみ。`-*` ガードを外し
# list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し
# 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。
list)
COMPREPLY=($(compgen -W "--interactive -i" -- "$cur"))
COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur"))
;;
esac
fi
Expand Down
13 changes: 10 additions & 3 deletions lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,18 @@ def _add_project_parser(subparsers):
def _add_list_subparser(sub):
"""`list` サブコマンドを登録する (project list / top-level list 共通)。

NAME / PLUGIN / STATUS の一覧表示。`--interactive` で選択 → `project up` 起動。
NAME / PLUGIN / STATUS の一覧表示。デフォルトで対話選択 → `project up` 起動。
`--no-interactive` (`--plain`) で一覧表示のみ。非 TTY では自動的に一覧のみ。
"""
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')
# 対話選択をデフォルト ON にする。`-i` / `--interactive` は後方互換のため
# 引き続き受け付ける (既に default=True なので実質 no-op)。
p.add_argument('--interactive', '-i', dest='interactive',
action='store_true', default=True,
help='Select a project interactively and start it (default)')
p.add_argument('--no-interactive', '--plain', '-P', dest='interactive',
action='store_false',
help='Just print the table without interactive selection')


def _add_env_parser(subparsers):
Expand Down
7 changes: 6 additions & 1 deletion lib/devbase/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import os
import sys
from pathlib import Path

from devbase.log import get_logger
Expand Down Expand Up @@ -174,7 +175,11 @@ def cmd_project_list(devbase_root: Path, args) -> int:
logger.info("プロジェクトがありません (%s)。", projects_dir)
return 0

if getattr(args, "interactive", False):
# 対話選択はデフォルト ON。ただし非 TTY (パイプ / CI / リダイレクト) では
# input() が EOFError になり実用にならないため、自動的に一覧表示へフォールバック。
# stdin / stdout のいずれかが非 TTY (`devbase list | cat`, `> out.txt` 等) なら
# 対話プロンプトが表示できない / 読めないため、確実に一覧表示へフォールバックする。
if getattr(args, "interactive", True) and sys.stdin.isatty() and sys.stdout.isatty():
return _interactive_select_and_up(rows)

_print_table(rows)
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def test_bash_top_level_ps_flag_after_name(fake_root):

def test_bash_project_list_flags(fake_root):
out = _bash_complete("devbase project list '-'", 3, fake_root)
assert set(out) == {"--interactive", "-i"}
assert set(out) == {"--no-interactive", "--plain", "-P", "--interactive", "-i"}


def test_bash_top_level_commands_include_project_and_list(fake_root):
Expand Down
75 changes: 74 additions & 1 deletion tests/cli/test_project_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,56 @@ def test_cmd_project_list_empty(tmp_path, capsys):
assert rc == 0


def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys):
"""interactive=True (デフォルト) でも非 TTY では一覧表示にフォールバックする。"""
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: {"name": entry.name, "status": "stopped", "count": 0})
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False)

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)
out = capsys.readouterr().out

assert rc == 0
assert called == [], "非 TTY では対話起動しない"
assert "alpha-proj" in out


def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys):
"""stdin が TTY でも stdout が非 TTY (`devbase list | cat` / `> out.txt`) なら
対話起動せず一覧表示へフォールバックする。"""
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: {"name": entry.name, "status": "stopped", "count": 0})
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False)

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)
out = capsys.readouterr().out

assert rc == 0
assert called == [], "stdout 非 TTY では対話起動しない"
assert "alpha-proj" in out


# ---------------------------------------------------------------------------
# cmd_project_list: --interactive
# ---------------------------------------------------------------------------
Expand All @@ -237,6 +287,9 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch):
_link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj")
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)

# 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
# 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2)
monkeypatch.setattr("builtins.input", lambda *a, **k: "2")

Expand All @@ -261,6 +314,8 @@ def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch):
_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(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda *a, **k: "")

called = []
Expand All @@ -285,6 +340,8 @@ def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch):
def raise_eof(*a, **k):
raise EOFError

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", raise_eof)
called = []
monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0)
Expand All @@ -308,6 +365,8 @@ def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkey
def raise_interrupt(*a, **k):
raise KeyboardInterrupt

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", raise_interrupt)
called = []
monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0)
Expand All @@ -328,6 +387,8 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
# "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する
inputs = iter(["99", "1"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
Expand All @@ -351,6 +412,8 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
# "abc" (数値以外) → "1" (有効)
inputs = iter(["abc", "1"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
Expand All @@ -369,20 +432,30 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc
# ---------------------------------------------------------------------------

def test_parser_project_list():
# 対話選択はデフォルト ON (フラグ無しで interactive=True)。
parser = cli._create_parser()
args = parser.parse_args(["project", "list"])
assert args.command == "project"
assert args.subcommand == "list"
assert args.interactive is False
assert args.interactive is True


def test_parser_project_list_interactive_flag():
# `-i` / `--interactive` は後方互換で受け付ける (実質 no-op、True のまま)。
parser = cli._create_parser()
for flag in ("--interactive", "-i"):
args = parser.parse_args(["project", "list", flag])
assert args.interactive is True


def test_parser_project_list_no_interactive_flag():
# `--no-interactive` / `--plain` / `-P` で一覧表示のみ (interactive=False)。
parser = cli._create_parser()
for flag in ("--no-interactive", "--plain", "-P"):
args = parser.parse_args(["project", "list", flag])
assert args.interactive is False


def test_parser_top_level_list_synonym():
parser = cli._create_parser()
args = parser.parse_args(["list", "-i"])
Expand Down
Loading