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
92 changes: 90 additions & 2 deletions bin/devbase
Original file line number Diff line number Diff line change
Expand Up @@ -180,21 +180,109 @@ resolve_command() {
fi
}

# ===================================================================
# Project name resolution (PLAN06 Task 2)
# ===================================================================
# `devbase project <sub> <name>` および同義のトップレベルシノニム
# `devbase <sub> <name>` の <name> が $DEVBASE_ROOT/projects/<name> に実在する
# 場合、そのディレクトリへ cd し COMPOSE_PROJECT_NAME / env を再設定する。
# これにより任意の CWD からプロジェクトを指定してコンテナ操作できる。
#
# 重要: `build` は shell 実装 (cmd_build) が CWD で動くため、この wrapper cd
# だけが build の name 解決手段になる (PLAN06 方針 A の核心)。Python 側 chdir
# フォールバックでは build を救えない。
#
# <name> 判定は projects/ 配下の実在性で行う。これにより `login <index>` /
# `build <image>` / `scale <N>` の既存 positional と曖昧にならない: 実在する
# プロジェクト名のときだけ name として解釈し cd + strip する。実在しなければ
# 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale
# あるいは「存在しない name」エラーとして扱わせる。

# name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。
maybe_cd_project() {
local name="${1:-}"
case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない
local target="${DEVBASE_ROOT}/projects/${name}"
[ -d "$target" ] || return 1
cd "$target" || return 1
export COMPOSE_PROJECT_NAME="$name"
# cd 後にプロジェクトの env を再 source (初期 CWD で読んだ値を上書き)。
# project の .env (dotfile) は CRLF / 特殊文字対策で意図的に source しない
# 方針を踏襲する (冒頭コメント参照)。
#
# 注意: env は環境変数定義のみを想定したファイルであり subshell ではなく
# 現プロセスで source する。これは wrapper 冒頭 L23-24 の env 読み込みと同一
# 意図 — set -a で export した変数を後続の run_python / cmd_build へ引き継ぐ
# ため、変数が親プロセスに残らない subshell 化はできない。代償として env 内に
# `exit` 等があると wrapper ごと終了するが、env は (a) プロジェクト所有者が
# 管理する信頼境界内のファイルで (b) 元々 L24 で初期 CWD でも source される
# ため、ここで新たなリスクが増えるわけではない。万一 exit を含む env を読んで
# も「該当プロジェクトの操作が中断する」だけで他プロジェクトへ波及しない。
[ -f "env" ] && set -a && source ./env && set +a
Comment thread
takemi-ohama marked this conversation as resolved.
return 0
}

# Resolve the command (skip flags like --version, -V, -h, --help)
_resolved_cmd="${1:-}"
case "$_resolved_cmd" in
--*|-*|"") ;; # flags and empty: don't resolve
*) _resolved_cmd="$(resolve_command "$_resolved_cmd")" ;;
esac

# name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から
# 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。
# name 候補の位置:
# project|container <sub> <name> -> $3 (サブコマンドは保持)
# トップレベルシノニム <sub> <name> -> $2
#
# 重要 (PLAN06 codex 指摘対応): `project`/`container` グループでは parser が
# `name` positional を持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`) に限定
# して $3 を name 解決する。`project login` / `project build` は単一 positional が
# index / image (旧 container 互換) であり parser が name を受け付けない
# (cli.py の _add_login_subparser / _add_build_subparser 参照)。これらで $3 を
# name strip すると、`project build web` の image=web や `project login web` の
# index 引数が実在プロジェクト名と一致した瞬間に消えて別操作へ化けるため除外する。
#
# トップレベルシノニム (`build`/`login` を含む) は従来どおり「実在 project なら
# cd」方針を維持する: トップレベル `build`/`login` は Python parser を経由せず
# shell cmd_build / wrapper cd だけが name 指定の手段であり、`build carmo` /
# `login carmo` を「そのプロジェクトを操作」と解釈する設計 (存在性ベース判定)。
_DEVBASE_ARGS=("${@:2}")
# 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。
# _PROJECT_NAME_SUBCOMMANDS = `project`/`container` で `name` positional を
# 受け付けるサブコマンド集合。cli.py の _add_project_parser で
# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale) と一致させる。
# login/build は index/image 互換のため意図的に除外 (上のコメント参照)。
# _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」
# を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される
# 集合 + shell 実装の build を含む。
# cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること
# (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。
_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale "
Comment thread
takemi-ohama marked this conversation as resolved.
_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build "
case "$_resolved_cmd" in
project|container)
if [[ "$_PROJECT_NAME_SUBCOMMANDS" == *" ${2:-} "* ]] \
&& maybe_cd_project "${3:-}"; then
_DEVBASE_ARGS=("${2:-}" "${@:4}")
fi
;;
*)
if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]] \
&& maybe_cd_project "${2:-}"; then
_DEVBASE_ARGS=("${@:3}")
fi
;;
esac

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)
run_python "${_resolved_cmd}" "${@:2}" ;;
run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;;
# Shell-implemented commands
build) shift; cmd_build "$@" ;;
build) cmd_build "${_DEVBASE_ARGS[@]}" ;;
# Help and unknown
-h|--help|help|"") run_python "--help" ;;
*) echo "Error: unknown command '$1'" >&2; exit 1 ;;
Expand Down
9 changes: 9 additions & 0 deletions lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
# Python の project build (単純な compose build) とは実装が異なるため。Python 側で
# `build` を project build ショートカットとして広告すると wrapper の実経路と乖離する。
# project build / container build サブコマンド自体は引き続き利用可能。
#
# 同期注意 (メンテナンス性): SHORTCUTS のキー集合と _add_project_parser の
# `name` positional 付きサブコマンドは bin/devbase の _NAME_RESOLVABLE_SHORTCUTS /
# _PROJECT_NAME_SUBCOMMANDS と対応している。サブコマンドを追加/削除する際は
# wrapper 側 (bin/devbase の該当リスト) の更新漏れに注意すること。
SHORTCUTS = {
'up': 'up',
'down': 'down',
Expand Down Expand Up @@ -129,6 +134,10 @@ def _add_project_parser(subparsers):
であり、`[name]` を足すと `project login 2` / `project build web` が誤解釈される
ため name を受け付けない。両者は project / container で定義が完全に一致するので
`_add_login_subparser` / `_add_build_subparser` に共通化している。

同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale)
は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は
wrapper 側リストの更新漏れに注意すること。
"""
pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)')
pj_sub = pj_parser.add_subparsers(dest='subcommand')
Expand Down
147 changes: 131 additions & 16 deletions lib/devbase/commands/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,33 +81,148 @@ def _run_pre_up_hook() -> bool:
# ディスパッチャ
# ---------------------------------------------------------------------------

def _projects_dir() -> Optional[Path]:
"""$DEVBASE_ROOT/projects を返す。DEVBASE_ROOT 未設定なら None。"""
root = os.environ.get('DEVBASE_ROOT')
if not root:
return None
return Path(root) / 'projects'


# 候補一覧に表示するプロジェクト数の上限。多数プロジェクト環境で iterdir 全件を
# 出力すると 1 行が極端に長くなるため、先頭 N 件 + 「... 他 M 件」で truncate する。
_MAX_PROJECT_CANDIDATES = 20


def _report_unknown_project(name: str, projects_dir: Path) -> None:
"""存在しない project name に対するエラーと候補一覧を出力する。

候補が多数の場合は先頭 ``_MAX_PROJECT_CANDIDATES`` 件のみ表示し、残りは
「... 他 M 件」と省略する。
"""
logger.error("プロジェクト '%s' が見つかりません (%s 配下に存在しません)。",
name, projects_dir)
try:
candidates = sorted(
Comment thread
takemi-ohama marked this conversation as resolved.
p.name for p in projects_dir.iterdir()
if p.is_dir() or p.is_symlink()
)
except OSError:
candidates = []
if candidates:
total = len(candidates)
shown = candidates[:_MAX_PROJECT_CANDIDATES]
listing = ', '.join(shown)
if total > _MAX_PROJECT_CANDIDATES:
listing += f', ... 他 {total - _MAX_PROJECT_CANDIDATES} 件'
logger.error("利用可能なプロジェクト: %s", listing)


def _load_project_env(env_file: Path) -> None:
"""プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。

wrapper (bin/devbase) は cd 後に ``source ./env`` で env を読み込むため、
Python フォールバック経路でも同じ KEY=VALUE を ``os.environ`` に載せて
変数欠落 (例: project 固有の ``CONTAINER_SCALE``) を防ぐ。

env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の
ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを
解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell
の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。
"""
if not env_file.is_file():
return
try:
lines = env_file.read_text().splitlines()
except OSError as e:
logger.warning("env ファイルを読み込めませんでした (%s): %s", env_file, e)
return
for raw in lines:
line = raw.strip()
if not line or line.startswith('#'):
continue
if line.startswith('export '):
line = line[len('export '):].lstrip()
if '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
if not key:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
os.environ[key] = value


def _resolve_project_name(project_name: str) -> bool:
Comment thread
takemi-ohama marked this conversation as resolved.
"""project name を $DEVBASE_ROOT/projects/<name> へ解決し chdir する。

通常は wrapper (bin/devbase) が起動前に cd 済みのため、ここは

- `python -m devbase.cli project up <name>` の直接起動
- wrapper を経ない経路 (`_ensure_env_files` 等)

に対する防御的フォールバックとして働く。wrapper が既に対象ディレクトリへ
cd 済みなら chdir は no-op になる (同一パス判定)。

chdir 後は wrapper の ``source ./env`` と同等に project の ``env`` を
``os.environ`` へ反映し、wrapper を経ない直接起動でも環境変数が欠落しない
ようにする (gemini round2 minor 指摘対応)。

Returns:
True: 解決成功 (または既に対象ディレクトリにいる)
False: DEVBASE_ROOT 未設定 / 対象が存在しない (呼び出し側で return 1)
"""
projects_dir = _projects_dir()
if projects_dir is None:
logger.error("DEVBASE_ROOT が未設定のため project name '%s' を解決できません。",
project_name)
return False

target = projects_dir / project_name
if not target.is_dir():
_report_unknown_project(project_name, projects_dir)
return False

try:
already_there = target.resolve() == Path.cwd().resolve()
except OSError:
already_there = False
if not already_there:
os.chdir(target)

# wrapper の `source ./env` と同等に project env を os.environ へ反映する。
# wrapper 経由なら既に同じ値が載っているため冪等。
_load_project_env(Path('env'))

# COMPOSE_PROJECT_NAME を name で上書き (wrapper が設定済みでも冪等)。
# env 由来の COMPOSE_PROJECT_NAME より name 指定を優先するため env 反映後に行う。
os.environ['COMPOSE_PROJECT_NAME'] = project_name
return True


def _dispatch_lifecycle(args) -> int:
"""`project` / `container` 共有のサブコマンドディスパッチャ。

`project <sub> [name]` の `name` を解決して project_name へ畳み込む。
`container` 経路には `name` 属性が無いため従来通り None になる。

NOTE (PLAN06): name によるディレクトリ解決の本体は Task 2 (PR2) で wrapper の
cd + Python フォールバックとして実装する。PR1 では project_name 引数を取れる
up / scale にのみ name を伝播するが、その name も compose のプロジェクトラベル
(COMPOSE_PROJECT_NAME 相当) として使われるだけで、操作対象はあくまで CWD の
compose.yml である点に注意 (ディレクトリ解決は未実装)。
name 指定時は handler 呼び出し前に一括で `$DEVBASE_ROOT/projects/<name>` へ
chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に
散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が
project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が
効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この
Python フォールバックの対象外 (name 属性も持たない)。
"""
subcmd = getattr(args, 'subcommand', None)
project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None)

# PR1 では name によるディレクトリ解決は未実装で、どのサブコマンドも CWD の
# compose.yml に対して動作する。name を指定されたまま黙って CWD に作用すると
# 「指定したプロジェクトに対して操作できた」と誤解させるため、明示的に警告する
# (name → ディレクトリ解決は PR2 で実装)。up / scale は name をプロジェクト
# ラベルには反映するが、対象ディレクトリは依然 CWD であるため同様に警告する。
# name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定
# / 存在しない name) は候補提示の上でエラー終了する。
if project_name:
logger.warning(
"project name '%s' によるディレクトリ解決は未実装です。"
"カレントディレクトリの compose に対して実行します "
"(name 指定は将来のリリースで対応予定)。",
project_name,
)
if not _resolve_project_name(project_name):
return 1

handlers = {
'up': lambda: cmd_up(project_name=project_name,
Expand Down
5 changes: 3 additions & 2 deletions tests/cli/test_build_shortcut_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ def test_wrapper_routes_build_to_shell_not_python():
# bin/devbase の dispatch で build は shell の cmd_build に委譲され、
# Python 用 run_python の case には含まれないことを確認する。
wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text()
# build は専用の shell ケースへ
assert "build) shift; cmd_build" in wrapper or "build) shift; cmd_build" in wrapper
# build は専用の shell ケースへ (PLAN06 Task 2 で name strip 後の _DEVBASE_ARGS
# を渡す形に変更。引数は wrapper 側の name 解決で既にコマンド/名を除去済み)。
assert "build) cmd_build" in wrapper or "build) cmd_build" in wrapper
# run_python に委譲する case 行に build が紛れ込んでいないこと
for line in wrapper.splitlines():
if "run_python" in line and "${_resolved_cmd}" in line:
Expand Down
Loading