Skip to content

Commit e3bba69

Browse files
takemi-ohamaclaude
andcommitted
feat: PLAN06-2 wrapper cd によるプロジェクト名解決 + トップレベルシノニム
任意の CWD から `devbase project up <name>` / `devbase up <name>` でプロジェクトを 指定操作できるよう、プロジェクト名解決を実装する (PLAN06 Task 2 / 方針 A: wrapper cd)。 bin/devbase (核心): - maybe_cd_project を追加。project/container <sub> <name> 及びトップレベルシノニム <sub> <name> の <name> が $DEVBASE_ROOT/projects/<name> に実在する場合のみ cd し、 COMPOSE_PROJECT_NAME / ./env を cd 後に再設定、argv から name を strip して下流へ。 - 実在性ベースの判定により login <index> / build <image> / scale <N> の既存 positional と曖昧にならない (実在プロジェクト名のときだけ name 扱い)。 - build は shell 実装 (cmd_build) のため、この wrapper cd だけが build の name 解決手段になる (方針 A の核心)。dispatch を name strip 後の _DEVBASE_ARGS 経由に変更。 lib/devbase/commands/container.py (Python 防御フォールバック): - PR1 の「name 解決は未実装」warning を撤去し、_resolve_project_name で projects/<name> 解決 → os.chdir (wrapper が cd 済みなら冪等 no-op) + COMPOSE_PROJECT_NAME 上書き。chdir は _dispatch_lifecycle で一括実施 (down/login/logs は project_name 引数を持たないため per-handler では救えない)。 - 存在しない name はエラー + 候補一覧を提示。 tests: - 新規 test_project_name_resolution.py: wrapper cd / argv strip / 曖昧性回避 / Python 解決 / 候補提示を検証。 - PR1 の未実装 warning 系テストを新挙動 (解決 → handler / 解決失敗 → abort) へ更新。 - build dispatch の文字列アサートを _DEVBASE_ARGS 形式へ追従。 全 359 テスト pass。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fb07d5f commit e3bba69

5 files changed

Lines changed: 380 additions & 44 deletions

File tree

bin/devbase

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,21 +180,76 @@ resolve_command() {
180180
fi
181181
}
182182

183+
# ===================================================================
184+
# Project name resolution (PLAN06 Task 2)
185+
# ===================================================================
186+
# `devbase project <sub> <name>` および同義のトップレベルシノニム
187+
# `devbase <sub> <name>` の <name> が $DEVBASE_ROOT/projects/<name> に実在する
188+
# 場合、そのディレクトリへ cd し COMPOSE_PROJECT_NAME / env を再設定する。
189+
# これにより任意の CWD からプロジェクトを指定してコンテナ操作できる。
190+
#
191+
# 重要: `build` は shell 実装 (cmd_build) が CWD で動くため、この wrapper cd
192+
# だけが build の name 解決手段になる (PLAN06 方針 A の核心)。Python 側 chdir
193+
# フォールバックでは build を救えない。
194+
#
195+
# <name> 判定は projects/ 配下の実在性で行う。これにより `login <index>` /
196+
# `build <image>` / `scale <N>` の既存 positional と曖昧にならない: 実在する
197+
# プロジェクト名のときだけ name として解釈し cd + strip する。実在しなければ
198+
# 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale
199+
# あるいは「存在しない name」エラーとして扱わせる。
200+
201+
# name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。
202+
maybe_cd_project() {
203+
local name="${1:-}"
204+
case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない
205+
local target="${DEVBASE_ROOT}/projects/${name}"
206+
[ -d "$target" ] || return 1
207+
cd "$target" || return 1
208+
export COMPOSE_PROJECT_NAME="$name"
209+
# cd 後にプロジェクトの env を再 source (初期 CWD で読んだ値を上書き)。
210+
# project の .env (dotfile) は CRLF / 特殊文字対策で意図的に source しない
211+
# 方針を踏襲する (冒頭コメント参照)。
212+
[ -f "env" ] && set -a && source ./env && set +a
213+
return 0
214+
}
215+
183216
# Resolve the command (skip flags like --version, -V, -h, --help)
184217
_resolved_cmd="${1:-}"
185218
case "$_resolved_cmd" in
186219
--*|-*|"") ;; # flags and empty: don't resolve
187220
*) _resolved_cmd="$(resolve_command "$_resolved_cmd")" ;;
188221
esac
189222

223+
# name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から
224+
# 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。
225+
# name 候補の位置:
226+
# project|container <sub> <name> -> $3 (サブコマンドは保持)
227+
# トップレベルシノニム <sub> <name> -> $2
228+
_DEVBASE_ARGS=("${@:2}")
229+
_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build "
230+
case "$_resolved_cmd" in
231+
project|container)
232+
if maybe_cd_project "${3:-}"; then
233+
_DEVBASE_ARGS=("${2:-}" "${@:4}")
234+
fi
235+
;;
236+
*)
237+
if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]]; then
238+
if maybe_cd_project "${2:-}"; then
239+
_DEVBASE_ARGS=("${@:3}")
240+
fi
241+
fi
242+
;;
243+
esac
244+
190245
case "$_resolved_cmd" in
191246
# Python-implemented commands
192247
--version|-V)
193248
run_python "$@" ;;
194249
init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale)
195-
run_python "${_resolved_cmd}" "${@:2}" ;;
250+
run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;;
196251
# Shell-implemented commands
197-
build) shift; cmd_build "$@" ;;
252+
build) cmd_build "${_DEVBASE_ARGS[@]}" ;;
198253
# Help and unknown
199254
-h|--help|help|"") run_python "--help" ;;
200255
*) echo "Error: unknown command '$1'" >&2; exit 1 ;;

lib/devbase/commands/container.py

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,33 +81,88 @@ def _run_pre_up_hook() -> bool:
8181
# ディスパッチャ
8282
# ---------------------------------------------------------------------------
8383

84+
def _projects_dir() -> Optional[Path]:
85+
"""$DEVBASE_ROOT/projects を返す。DEVBASE_ROOT 未設定なら None。"""
86+
root = os.environ.get('DEVBASE_ROOT')
87+
if not root:
88+
return None
89+
return Path(root) / 'projects'
90+
91+
92+
def _report_unknown_project(name: str, projects_dir: Path) -> None:
93+
"""存在しない project name に対するエラーと候補一覧を出力する。"""
94+
logger.error("プロジェクト '%s' が見つかりません (%s 配下に存在しません)。",
95+
name, projects_dir)
96+
try:
97+
candidates = sorted(
98+
p.name for p in projects_dir.iterdir()
99+
if p.is_dir() or p.is_symlink()
100+
)
101+
except OSError:
102+
candidates = []
103+
if candidates:
104+
logger.error("利用可能なプロジェクト: %s", ', '.join(candidates))
105+
106+
107+
def _resolve_project_name(project_name: str) -> bool:
108+
"""project name を $DEVBASE_ROOT/projects/<name> へ解決し chdir する。
109+
110+
通常は wrapper (bin/devbase) が起動前に cd 済みのため、ここは
111+
112+
- `python -m devbase.cli project up <name>` の直接起動
113+
- wrapper を経ない経路 (`_ensure_env_files` 等)
114+
115+
に対する防御的フォールバックとして働く。wrapper が既に対象ディレクトリへ
116+
cd 済みなら chdir は no-op になる (同一パス判定)。
117+
118+
Returns:
119+
True: 解決成功 (または既に対象ディレクトリにいる)
120+
False: DEVBASE_ROOT 未設定 / 対象が存在しない (呼び出し側で return 1)
121+
"""
122+
projects_dir = _projects_dir()
123+
if projects_dir is None:
124+
logger.error("DEVBASE_ROOT が未設定のため project name '%s' を解決できません。",
125+
project_name)
126+
return False
127+
128+
target = projects_dir / project_name
129+
if not target.is_dir():
130+
_report_unknown_project(project_name, projects_dir)
131+
return False
132+
133+
try:
134+
already_there = target.resolve() == Path.cwd().resolve()
135+
except OSError:
136+
already_there = False
137+
if not already_there:
138+
os.chdir(target)
139+
140+
# COMPOSE_PROJECT_NAME を name で上書き (wrapper が設定済みでも冪等)。
141+
os.environ['COMPOSE_PROJECT_NAME'] = project_name
142+
return True
143+
144+
84145
def _dispatch_lifecycle(args) -> int:
85146
"""`project` / `container` 共有のサブコマンドディスパッチャ。
86147
87148
`project <sub> [name]` の `name` を解決して project_name へ畳み込む。
88149
`container` 経路には `name` 属性が無いため従来通り None になる。
89150
90-
NOTE (PLAN06): name によるディレクトリ解決の本体は Task 2 (PR2) で wrapper の
91-
cd + Python フォールバックとして実装する。PR1 では project_name 引数を取れる
92-
up / scale にのみ name を伝播するが、その name も compose のプロジェクトラベル
93-
(COMPOSE_PROJECT_NAME 相当) として使われるだけで、操作対象はあくまで CWD の
94-
compose.yml である点に注意 (ディレクトリ解決は未実装)。
151+
name 指定時は handler 呼び出し前に一括で `$DEVBASE_ROOT/projects/<name>` へ
152+
chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に
153+
散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が
154+
project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が
155+
効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この
156+
Python フォールバックの対象外 (name 属性も持たない)。
95157
"""
96158
subcmd = getattr(args, 'subcommand', None)
97159
project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None)
98160

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

112167
handlers = {
113168
'up': lambda: cmd_up(project_name=project_name,

tests/cli/test_build_shortcut_consistency.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ def test_wrapper_routes_build_to_shell_not_python():
5656
# bin/devbase の dispatch で build は shell の cmd_build に委譲され、
5757
# Python 用 run_python の case には含まれないことを確認する。
5858
wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text()
59-
# build は専用の shell ケースへ
60-
assert "build) shift; cmd_build" in wrapper or "build) shift; cmd_build" in wrapper
59+
# build は専用の shell ケースへ (PLAN06 Task 2 で name strip 後の _DEVBASE_ARGS
60+
# を渡す形に変更。引数は wrapper 側の name 解決で既にコマンド/名を除去済み)。
61+
assert "build) cmd_build" in wrapper or "build) cmd_build" in wrapper
6162
# run_python に委譲する case 行に build が紛れ込んでいないこと
6263
for line in wrapper.splitlines():
6364
if "run_python" in line and "${_resolved_cmd}" in line:

tests/cli/test_project_dispatch.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def test_lifecycle_passes_name_to_cmd_up(monkeypatch):
120120
"""`project up <name>` の name は project_name として up に伝播する。"""
121121
from devbase.commands import container
122122
captured = {}
123+
# name 解決 (chdir) は別テストで検証するためここでは no-op 化し、伝播のみ見る。
124+
monkeypatch.setattr(container, '_resolve_project_name', lambda name: True)
123125
monkeypatch.setattr(container, 'cmd_up',
124126
lambda project_name=None, scale=None:
125127
captured.update(project_name=project_name) or 0)
@@ -141,42 +143,49 @@ def test_lifecycle_container_path_has_no_name(monkeypatch):
141143

142144

143145
# ---------------------------------------------------------------------------
144-
# _dispatch_lifecycle: name 未実装 warning
145-
# (PR1 では up/scale も含め全サブコマンドが CWD の compose に作用するため、
146-
# name 指定時はサブコマンドに関わらず警告する)
146+
# _dispatch_lifecycle: name 解決 (PR2 で wrapper cd の Python フォールバックを実装)
147+
# name 指定時は handler 呼び出し前に _resolve_project_name で chdir する。
148+
# 解決失敗時は handler を呼ばずに 1 を返す。詳細な解決ロジックは
149+
# test_project_name_resolution.py を参照。
147150
# ---------------------------------------------------------------------------
148151

149-
def test_lifecycle_warns_for_up_with_name(monkeypatch, caplog):
150-
"""`project up <name>` は name 指定時に未実装 warning を出す。"""
152+
def test_lifecycle_resolves_name_before_handler(monkeypatch):
153+
"""name 指定時は handler 前に _resolve_project_name を呼ぶ。"""
151154
from devbase.commands import container
152-
monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0)
155+
order = []
156+
monkeypatch.setattr(container, '_resolve_project_name',
157+
lambda name: order.append(('resolve', name)) or True)
158+
monkeypatch.setattr(container, 'cmd_up',
159+
lambda project_name=None, scale=None:
160+
order.append(('up', project_name)) or 0)
153161
args = _args(subcommand='up', name='carmo', scale=None)
154-
with caplog.at_level(logging.WARNING, logger='devbase.commands.container'):
155-
assert container._dispatch_lifecycle(args) == 0
156-
assert any('未実装' in r.message for r in caplog.records), \
157-
'up でも name 指定時は警告しなければならない'
162+
assert container._dispatch_lifecycle(args) == 0
163+
assert order == [('resolve', 'carmo'), ('up', 'carmo')]
158164

159165

160-
def test_lifecycle_warns_for_scale_with_name(monkeypatch, caplog):
161-
"""`project scale <name> N` も name 指定時に未実装 warning を出す。"""
166+
def test_lifecycle_aborts_when_name_unresolved(monkeypatch):
167+
"""name 解決に失敗したら handler を呼ばず 1 を返す。"""
162168
from devbase.commands import container
163-
monkeypatch.setattr(container, 'cmd_scale',
164-
lambda new_scale=None, project_name=None: 0)
165-
args = _args(subcommand='scale', name='carmo', new_scale=3)
166-
with caplog.at_level(logging.WARNING, logger='devbase.commands.container'):
167-
assert container._dispatch_lifecycle(args) == 0
168-
assert any('未実装' in r.message for r in caplog.records), \
169-
'scale でも name 指定時は警告しなければならない'
169+
called = []
170+
monkeypatch.setattr(container, '_resolve_project_name', lambda name: False)
171+
monkeypatch.setattr(container, 'cmd_up',
172+
lambda project_name=None, scale=None:
173+
called.append('up') or 0)
174+
args = _args(subcommand='up', name='bogus', scale=None)
175+
assert container._dispatch_lifecycle(args) == 1
176+
assert called == [], '解決失敗時は handler を呼んではならない'
170177

171178

172-
def test_lifecycle_no_warning_without_name(monkeypatch, caplog):
173-
"""name 未指定なら警告を出さない。"""
179+
def test_lifecycle_no_resolution_without_name(monkeypatch):
180+
"""name 未指定なら _resolve_project_name を呼ばない。"""
174181
from devbase.commands import container
182+
resolved = []
183+
monkeypatch.setattr(container, '_resolve_project_name',
184+
lambda name: resolved.append(name) or True)
175185
monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0)
176186
args = _args(subcommand='up', scale=None) # name 属性なし
177-
with caplog.at_level(logging.WARNING, logger='devbase.commands.container'):
178-
assert container._dispatch_lifecycle(args) == 0
179-
assert not any('未実装' in r.message for r in caplog.records)
187+
assert container._dispatch_lifecycle(args) == 0
188+
assert resolved == []
180189

181190

182191
# ---------------------------------------------------------------------------
@@ -293,6 +302,7 @@ def test_shortcut_up_propagates_name_through_dispatch(monkeypatch):
293302
"""`devbase up <name>` の name がショートカット経由で cmd_up まで伝播する。"""
294303
from devbase.commands import container
295304
captured = {}
305+
monkeypatch.setattr(container, '_resolve_project_name', lambda name: True)
296306
monkeypatch.setattr(container, 'cmd_up',
297307
lambda project_name=None, scale=None:
298308
captured.update(project_name=project_name) or 0)
@@ -306,6 +316,7 @@ def test_shortcut_scale_propagates_name_through_dispatch(monkeypatch):
306316
"""`devbase scale <name> N` の name がショートカット経由で cmd_scale まで伝播する。"""
307317
from devbase.commands import container
308318
captured = {}
319+
monkeypatch.setattr(container, '_resolve_project_name', lambda name: True)
309320
monkeypatch.setattr(container, 'cmd_scale',
310321
lambda new_scale=None, project_name=None:
311322
captured.update(project_name=project_name, new_scale=new_scale) or 0)

0 commit comments

Comments
 (0)