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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,24 @@
### Changed
- `devbase list` の起動中の操作メニューで、Enter 1 回で決まる項目が「再起動 (up)」から
「エディタを開く (open)」に変わりました。再起動はその 1 つ下です。
- **位置引数のプロジェクト名は、英数字で始まり英数字・`.`・`-`・`_` だけからなる形に限りました
(PLAN61 / #146)。** `../etc` や `a/b` のように形に合わない値は名前として扱わず、
`$DEVBASE_ROOT/projects/` の外のディレクトリへ移動したり、そこの `env` を読んだりしません。
`bin/devbase` の名前解決と、Python 側の `project up <name>` などの名前の検証、
`build <image>` のイメージ名の検証が同じ規則を使います。
- **`devbase build <name>` で `containers/<name>` と `projects/<name>` が両方あるときは、イメージ
`<name>` をビルドします(PLAN61 / #142)。** これまではプロジェクトへの移動が優先され、
イメージ指定が消えていました。プロジェクトとしても読めたことと、プロジェクトをビルドする方法
(そのディレクトリで `devbase build`)を stderr に 1 行知らせます。
- **非推奨の `container` / `ct` グループは名前解決の対象外になりました(PLAN61 / #200)。**
`devbase container up <name>` は実在するプロジェクト名でも usage エラー(終了コード 2)です。
名前の指定は `devbase project up <name>` か `devbase up <name>` を使ってください。

### Fixed
- **`devbase build --help` / `-h` がビルドを始めてしまう**のを直しました(PLAN61 / #196)。
`build` の使い方(`--no-cache` / `--project-no-cache` / `--expires[=DAYS]` / `--context NAME` /
`<image>` 指定)を出して終了コード 0 で終わります。`devbase build carmo --help` のように
プロジェクト名の後ろに置いても、そのプロジェクトへ移動せずに使い方を出します。
- **tmux の中で URL がクリックできなくなっていた**のを直しました。tmux は端末が `Hls`
能力を持つときだけハイパーリンク (OSC 8) を書き出し、持たない端末ではリンクを捨てて
文字列だけを描きます。tmux が `xterm*` へ既定で与える機能に `hyperlinks` は含まれない
Expand Down
159 changes: 111 additions & 48 deletions bin/devbase
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ resolve_command() {
}

# ===================================================================
# Project name resolution (PLAN06 Task 2)
# Project name resolution (PLAN06 Task 2 / PLAN61)
# ===================================================================
# `devbase project <sub> <name>` および同義のトップレベルシノニム
# `devbase <sub> <name>` の <name> が $DEVBASE_ROOT/projects/<name> に実在する
Expand All @@ -310,16 +310,45 @@ resolve_command() {
# だけが 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> の判定 (PLAN61):
# 1. 名前の形 (is_single_segment_name) に合わない値は名前ではない。cd も strip も
# せず、そのまま下流の Python へ渡す。下流は位置引数の意味に応じて扱う
# (`[name]` を取るコマンドは名前の検証で 1、`build <image>` はイメージ名の検証で 1、
# `scale <値>` は argparse の型エラーで 2、`login <値>` は index として渡る)。
# 2. 形に合う値は projects/ 配下の実在性で判定する。実在するときだけ name として
# 解釈し cd + strip する。実在しなければそのまま下流へ渡す。
# 3. `build <x>` は containers/<x> が実在すればイメージとして扱い、name 解決を通さない
# (下の case の `build)` 分岐)。
# 4. `container` / `ct` グループは name 解決の対象外。parser が `[name]` を持たず、
# 実在するプロジェクト名でも usage エラー (終了コード 2) になる。名前の指定は
# `project <sub> <name>` が持つ。
# 残る衝突: `login <index>` / `scale <N>` の値が数字だけのプロジェクト名と一致する
# 場合は名前として解釈される (数字だけの名前は現在無く、衝突は偶発に限る)。

# プロジェクト名 / イメージ名として受け付ける形 (PLAN61 決定 1)。英数字で始まり、英数字・
# `.`・`-`・`_` だけからなる。先頭が英数字なので `.` `..` `-x` 空は当たらず、`/` `\` 空白
# 非 ASCII は含められない。$DEVBASE_ROOT/projects/ や containers/ へ連結する前にこれで弾き、
# `..` で $DEVBASE_ROOT の外へ出ないようにする。
#
# 同期注意: lib/devbase/utils/names.py の SINGLE_SEGMENT_NAME_PATTERN と同じ正規表現。
# Python を呼んで判定すると name 解決のたびに uv の起動が増えるため、shell 側にも文字列で
# 持つ。片方を変えたらもう片方も変える (tests/cli/test_project_name_resolution.py の
# 同期テストが一致を見る)。
_SINGLE_SEGMENT_NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'

# $1 が名前の形なら 0。bash 3.2 の `[[ =~ ]]` は引用した右辺を文字列として比べるため、
# 正規表現は変数で渡す。`[A-Za-z]` の範囲はロケールで変わりうるので、Python の定義
# (ASCII だけ) と同じ結果になるよう LC_ALL=C で比べる (決定 4)。
is_single_segment_name() {
local LC_ALL=C
[[ ${1:-} =~ $_SINGLE_SEGMENT_NAME_RE ]]
}

# name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。
# 名前の形に合わない値 (フラグ・空・`..` や `/` を含む値) は名前ではない。
maybe_cd_project() {
local name="${1:-}"
case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない
is_single_segment_name "$name" || return 1
local target="${DEVBASE_ROOT}/projects/${name}"
[ -d "$target" ] || return 1
cd "$target" || return 1
Expand Down Expand Up @@ -350,72 +379,106 @@ maybe_cd_project() {
return 0
}

# トップレベル `build` の使い方 (PLAN61 決定 7・9)。トップレベル build は shell の cmd_build と
# Python の project build に振り分けられ、受け付ける引数が両者で違う (--project-no-cache は
# shell にだけある) ため、argparse の --help に委ねず wrapper が出す。
build_usage() {
cat <<'EOF'
Usage: devbase build [<project> | <image>] [options]

Build devbase images.
(no argument) build the images of the current project (base image first)
<project> build the project in $DEVBASE_ROOT/projects/<project>
<image> build $DEVBASE_ROOT/containers/<image> alone as devbase-<image>:latest
(when both containers/<name> and projects/<name> exist, <name> is an image;
to build the project, run 'devbase build' in its directory)

Options:
--no-cache rebuild the base and project images without cache
--project-no-cache rebuild only the project image without cache (base uses cache)
--expires[=DAYS] rebuild without cache only if the image is older than DAYS days (default 7)
--context NAME run docker against the docker context NAME
-h, --help show this help
EOF
}

# 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

# `build` の -h / --help は name 解決より前に判定する (PLAN61 決定 7・8 / #196)。後に置くと
# `build carmo --help` で projects/carmo への cd とその env の読み込みが先に起きる。
# `--context --help` の `--help` も使い方 (argparse も `-` 始まりを値に取らない)。
# `--context=--help` は語が違うので使い方にならず、下流の argparse で usage エラーになる。
if [ "$_resolved_cmd" = "build" ]; then
for _ba in "${@:2}"; do
case "$_ba" in
-h|--help) build_usage; exit 0 ;;
esac
done
fi

# name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から
# 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。
# name 候補の位置:
# project|container <sub> <name> -> $3 (サブコマンドは保持)
# project <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 引数が実在プロジェクト名と一致した瞬間に消えて別操作へ化けるため除外する。
# 重要 (PLAN06 codex 指摘対応): `project` グループでは parser が `name` positional を
# 持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`/`rebuild`/`open`) に限定して $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 引数が実在プロジェクト名と
# 一致した瞬間に消えて別操作へ化けるため除外する。
#
# `container` / `ct` は name 解決を通さない (PLAN61 決定 10 / #200)。parser が `[name]` を
# 持たないため、wrapper だけが名前を取り除く形では「実在するときだけ受け付ける」動きに
# なり、受け付けるかどうかが projects/ の中身で変わる。非推奨のグループへ受け付けを
# 増やさず、`project <sub> <name>` へ誘導する。
#
# トップレベルシノニム (`build`/`login` を含む) は従来どおり「実在 project なら
# cd」方針を維持する: トップレベル `build`/`login` は Python parser を経由せず
# shell cmd_build / wrapper cd だけが name 指定の手段であり、`build carmo` /
# トップレベルシノニム (`login` を含む) は「実在 project なら cd」方針を維持する:
# トップレベル `login` は Python parser を経由せず wrapper cd だけが name 指定の手段であり、
# `login carmo` を「そのプロジェクトを操作」と解釈する設計 (存在性ベース判定)。
# `build` は containers/ との衝突をイメージ優先で分ける (下の `build)` 分岐)。
_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/rebuild/open) と一致させる。
# login/build は index/image 互換のため意図的に除外 (上のコメント参照)。
# _PROJECT_NAME_SUBCOMMANDS = `project` で `name` positional を受け付けるサブコマンド
# 集合。cli.py の _add_project_parser で `add_argument('name', ...)` を持つもの
# (up/down/ps/logs/scale/rebuild/open) と一致させる。login/build は index/image
# 互換のため意図的に除外 (上のコメント参照)。
# _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」
# を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される
# 集合 + shell 実装の build を含む。
# cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること
# (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。
#
# ⚠ 衝突注意 (footgun): トップレベルシノニムの name 解決は「存在性ベース」で
# 行うため、本来 positional 引数として渡したい値が実在プロジェクト名
# ($DEVBASE_ROOT/projects/<name>) と一致した場合、その引数が name と解釈され
# project 解決 (cd) が優先されて引数の意味が変わる。具体的には:
# - `devbase login <index>` の index が実在プロジェクト名と一致
# (例: projects/2 が存在する状態で `devbase login 2`) → index=2 ではなく
# project `2` への cd になり、login の対象が変わる。
# - `devbase build <image>` の image が実在プロジェクト名と一致
# (例: projects/web が存在する状態で `devbase build web`) → image=web では
# なく project `web` への cd になり、ビルド対象が変わる。
# - `devbase scale <service>` の service 引数も同様に化けうる。
# これはトップレベル build/login/scale を「そのプロジェクトを操作」と解釈する
# 意図的設計 (存在性ベース判定) のトレードオフであり、挙動としては仕様である。
# 回避策: 衝突時は対象プロジェクトのディレクトリ内で実行するか、明示的に
# そのプロジェクトへ切り替えてから (cd 済みの状態で) コマンドを実行すること。
# こうすれば name 解決トークンを与える必要がなくなり、index/image/service を
# 意図どおり渡せる。
_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild open "
_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild open "
case "$_resolved_cmd" in
project|container|ct)
# `ct` は container の alias (cli.py: add_parser('container', aliases=['ct']))。
# name 解決経路でも container と同じ strip/chdir を通すため分岐に含める。
# _resolved_cmd は `ct` のまま python に渡してよい (cli.py 側で alias 解決済み)。
project)
if [[ "$_PROJECT_NAME_SUBCOMMANDS" == *" ${2:-} "* ]] \
&& maybe_cd_project "${3:-}"; then
_DEVBASE_ARGS=("${2:-}" "${@:4}")
fi
;;
build)
# `build <x>` で containers/<x> が実在すれば <x> はイメージで、name 解決を通さない
# (PLAN61 決定 5・6 / #142)。projects/<x> もあれば、プロジェクトとしても読めたことと
# プロジェクトをビルドする方法を stderr に 1 行知らせる。判定は maybe_cd_project の
# 前に置く (後だと cd と env の読み込みが先に起き、戻す手段が無い)。名前の形を先に
# 見るのは、`containers/../x` のような値でディレクトリの実在を確かめないため。
if is_single_segment_name "${2:-}" && [ -d "${DEVBASE_ROOT}/containers/$2" ]; then
if [ -d "${DEVBASE_ROOT}/projects/$2" ]; then
echo "Note: '$2' is also a project (projects/$2); building image containers/$2." \
"To build the project, run 'devbase build' in ${DEVBASE_ROOT}/projects/$2" >&2
fi
elif maybe_cd_project "${2:-}"; then
_DEVBASE_ARGS=("${@:3}")
fi
;;
*)
if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]] \
&& maybe_cd_project "${2:-}"; then
Expand All @@ -436,10 +499,10 @@ case "$_resolved_cmd" in
# (devbase-base の 2 段ビルド) で処理する。次の 2 つは Python (project build)
# へ委譲する (PLAN49 / i07):
# - <image> 指定の単体ビルド: `devbase project build <image>` /
# `devbase container build <image>` と同じ実装へ届ける。逆向き (Python から
# shell を呼ぶ) にすると、この wrapper 冒頭の name 解決を通ってしまい、
# containers/ と projects/ に同名がある場合 (bi-tools) に別のものを
# ビルドしてしまう。
# `devbase container build <image>` と同じ実装へ届ける。containers/ と
# projects/ に同名がある場合 (bi-tools) は、上の name 解決の `build)` 分岐が
# イメージとして扱い cd しない (PLAN61 決定 5)。逆向き (Python から shell を
# 呼ぶ) にすると、この wrapper を再び通ることになるため採らない。
# - --expires: イメージ作成日の判定が必要で、shell では RFC3339 日付パースが
# 非可搬なため (build --expires=N / rebuild / up が共通の期限リゾルバを使う)。
build)
Expand Down
3 changes: 2 additions & 1 deletion docs/developer/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ flowchart TB

| 引数 | 実行する層 | 実体 | 理由 |
|------|-----------|------|------|
| `-h` / `--help`(引数のどこにあっても) | Bash | `build_usage()` | shell と Python で受け付ける引数が違う(`--project-no-cache` は shell だけ)ため wrapper が使い方を出す。name 解決より前に判定し、`build carmo --help` で `projects/carmo` への cd と `env` の読み込みを起こさない。`--context=--help` は語が違うので下流へ渡る(PLAN61 決定 7・8) |
| なし / `--no-cache` / `--project-no-cache` | Bash | `cmd_build()` | compose.yml のパースと `FROM devbase-*` の依存検出、2 段ビルドの制御がシェルで完結する |
| `<image>` | Python | `container._build_single_image()` | `devbase project build <image>` / `devbase container build <image>` と同じ実装へ届ける。逆向きに Python から `bin/devbase build <image>` を呼ぶと、wrapper 冒頭の name 解決を通ってしまい、`containers/` と `projects/` に同名がある場合に別のものをビルドする |
| `<image>` | Python | `container._build_single_image()` | `devbase project build <image>` / `devbase container build <image>` と同じ実装へ届ける。`containers/<x>` と `projects/<x>` が両方ある名前は wrapper の name 解決の `build)` 分岐がイメージとして扱い cd しない(stderr に 1 行知らせる。PLAN61 決定 5・6)。逆向きに Python から `bin/devbase build <image>` を呼ぶと wrapper を再び通るため採らない |
| `--expires[=DAYS]` | Python | `container.cmd_build()` → `_build_resolved()` | イメージ作成日の判定に RFC3339 の日付パースが要り、シェルでは非可搬 |

単体ビルド(`<image>` 指定)は `$DEVBASE_ROOT/containers/<image>` を
Expand Down
Loading
Loading