diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f576c26..77fb0f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,5 @@ jobs: with: scandir: ./bin severity: error + - name: Run ShellCheck on install.sh + run: shellcheck --severity=error install.sh diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f3c7008 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Deploy installer to Pages + +# install.sh を GitHub Pages (dl.basex.jp) で配信する。 +# 配信仕様: docs/developer/installer-hosting.md +# リポジトリ全体は晒さず、install.sh のみを成果物として公開する。 + +on: + push: + branches: [main] + paths: + - install.sh + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - name: Assemble site (install.sh only) + run: | + mkdir -p _site + cp install.sh _site/install.sh + echo 'dl.basex.jp' > _site/CNAME + cat > _site/index.html <<'HTML' + + + devbase installer + +

devbase installer

+

ワンライナーインストール:

+
curl -fsSL https://dl.basex.jp/install.sh | bash
+

ソース: github.com/devbasex/devbase

+ + + HTML + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 153b2a6..2c1255b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ ## [Unreleased] ### Added +- **ワンライナー installer (`install.sh`) を新設**しました (PLAN31_1)。 + `curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh | bash` + で `~/devbase` への clone(既存なら `git pull --ff-only`)と `devbase init` まで + 自動完了します(uv の自動導入・PATH/補完の登録・`plugins.yml` 生成を含む)。 + - 配置先 / clone 元 / ref を `DEVBASE_INSTALL_DIR` / `DEVBASE_INSTALL_REPO` / + `DEVBASE_INSTALL_REF` で上書きできます。`DEVBASE_INSTALL_REF` は branch/tag 名 + として妥当な文字のみ許可し、オプション注入を防ぎます。 + - 非 TTY (`curl | bash`) で対話プロンプトを出しません。`env init` は対話必須のため + 実行せず、完了後に次の手順(`shell-rc` 再読み込み / `plugin install` / `env init` + / `build` / `up` / `login`)を案内します。 + - 配置先が devbase 以外の非空ディレクトリの場合は誤上書きを避けて中止します。 + - CI に `install.sh` の ShellCheck (`severity=error`) を追加しました。 - **`devbase list` の対話選択を TUI 化**しました。`questionary` 導入により、↑↓ の 矢印キーで行移動、文字入力でプロジェクト名のインクリメンタル絞り込みができます (全項目に通し番号を表示)。Enter で決定、Ctrl-C で中止します。 diff --git a/README.md b/README.md index 305392d..2a55dc2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,40 @@ devbaseは、Docker Composeを使った再現性の高い開発環境を提供 ## クイックスタート +### ワンライナーインストール(推奨) + +```bash +curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh | bash +``` + +`~/devbase` に clone(既存なら更新)し、`devbase init` まで自動実行します(uv の自動導入・PATH/補完の登録・`plugins.yml` 生成を含む)。**新しく開くターミナルでは自動で有効**です。 + +**いま開いている端末で即使う**なら、末尾に `&& source "$(~/devbase/bin/devbase shell-rc)"` を付けます(`&&` 以降は呼び出し元シェルで実行されるため、その場で PATH が通ります)。配置先を `DEVBASE_INSTALL_DIR` で変えた場合は同パスに合わせてください。 + +環境変数で挙動を上書きできます。 + +| 変数 | 既定値 | 用途 | +|------|--------|------| +| `DEVBASE_INSTALL_DIR` | `$HOME/devbase` | 配置先ディレクトリ | +| `DEVBASE_INSTALL_REPO` | `https://github.com/devbasex/devbase.git` | clone 元(fork / テスト用) | +| `DEVBASE_INSTALL_REF` | `main` | チェックアウトする branch / tag | + +```bash +# 例: 別ディレクトリへ特定タグを入れる(パイプではなく保存実行でも env は同様に効きます) +DEVBASE_INSTALL_DIR=~/work/devbase DEVBASE_INSTALL_REF=v1.2.3 \ + bash -c "$(curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh)" +``` + +> **⚠ `curl | bash` を実行する前に**: 中身を確認したい場合は、いったん保存してから実行してください。 +> +> ```bash +> curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh -o install.sh +> less install.sh # 内容を確認 +> bash install.sh +> ``` + +### 手動セットアップ + ```bash # 1. クローンと初期化 git clone https://github.com/devbasex/devbase.git diff --git a/docs/developer/installer-hosting.md b/docs/developer/installer-hosting.md new file mode 100644 index 0000000..c4cc251 --- /dev/null +++ b/docs/developer/installer-hosting.md @@ -0,0 +1,183 @@ +# installer 配信ホスティング仕様 + +`install.sh`(ワンライナー installer, PLAN31_1)を短いカスタムドメイン URL で +配信するためのホスティング仕様。plan §8 の未決事項「正規ホスティング」をここで +確定する。対象は devbase メンテナ(インフラ/リリース担当)。 + +## 1. 目的とスコープ + +- `curl -fsSL | bash` で実行する静的ファイル `install.sh` を、安定した + 短い HTTPS URL で配信する。 +- 配信 URL を `https://raw.githubusercontent.com/devbasex/devbase/main/install.sh` + (約 73 文字)から `https://dl.basex.jp/install.sh`(約 33 文字)へ短縮する。 +- スコープ外: installer 自体の挙動(`issues/PLAN31_1_devbase-installer.md` 参照)。 + +## 2. 決定事項 + +| 項目 | 決定 | +|---|---| +| ホスティング | **GitHub Pages**(devbasex/devbase リポジトリ) | +| カスタムドメイン | **`dl.basex.jp`** | +| 配信 URL | **`https://dl.basex.jp/install.sh`** | +| ドメインレジストラ | お名前.com(basex.jp) | +| DNS | お名前.com DNS(`01〜04.dnsv.jp`)に `dl` の CNAME | +| 月額コスト | **$0**(GitHub Pages 無料枠) | +| 配信方式 | GitHub Actions で `install.sh` のみを Pages 公開(single source of truth) | + +### 2.1 なぜ GitHub Pages か(コスト比較) + +| 選択肢 | 月額 | 帯域 | 手間 | 備考 | +|---|---|---|---|---| +| **GitHub Pages** | **$0** | 100GB/月(ソフト) | 最小 | install.sh が既に repo にある | +| Cloudflare Pages | $0 | 無制限 | 中 | DNS を Cloudflare へ寄せる前提 | +| S3 + CloudFront | 実質 $0〜 | CloudFront always-free 1TB/10M req | 大 | Route53 利用時 +$0.50/月・運用増 | + +installer 程度のトラフィックでは GitHub Pages の無料枠で十分。`install.sh` が +リポジトリにあるため二重管理が不要で、運用が最も軽い。出典は本書末尾参照。 + +## 3. アーキテクチャ + +```mermaid +flowchart LR + U["ユーザー
curl -fsSL https://dl.basex.jp/install.sh | bash"] + R[".jp レジストリ"] + O["お名前.com DNS
01〜04.dnsv.jp"] + GP["GitHub Pages
185.199.108-111.153"] + F["install.sh
(devbasex/devbase)"] + + U -->|"1. 名前解決"| R + R -->|"basex.jp を委譲"| O + O -->|"2. dl.basex.jp CNAME → devbasex.github.io"| GP + GP -->|"3. /install.sh を HTTPS で配信"| F + F -->|"4. 本文を bash に渡す"| U +``` + +## 4. DNS 仕様(お名前.com) + +| 種別 | ホスト | 値 | 用途 | +|---|---|---|---| +| CNAME | `dl` | `devbasex.github.io.` | Pages へのルーティング | +| TXT | `_github-pages-challenge-devbasex` | GitHub 発行トークン | ドメイン検証(乗っ取り防止・任意だが推奨) | + +- basex.jp のネームサーバーは `01.dnsv.jp`〜`04.dnsv.jp`(お名前.com の DNS レコード + 設定用サーバー)。NS 変更は不要。 +- **お名前.com には CNAME/TXT を操作する公式 REST API は無い**。レコード設定は + Navi の Web 画面のみ(ダイナミック DNS は A レコード専用で本用途に使えない)。 + IaC で DNS を回したい場合は NS を Cloudflare DNS(無料・API あり)または + Route53($0.50/月)へ委譲する必要がある(本書では採用しない)。 +- 反映には時間がかかる場合がある。ゾーン未活性時は権威サーバーが `REFUSED` + (SOA を含む全レコードが lame delegation)を返すため、レコード追加後は + 「設定する」まで完走し、伝播を待って §8 のコマンドで確認する。 + +## 5. GitHub Pages 配信仕様 + +### 5.1 方針 + +- リポジトリ全体を Pages で晒さないよう、**GitHub Actions で `install.sh` のみ**を + 成果物(artifact)として公開する。`install.sh` はリポジトリ root の 1 ファイルが + 正本(single source of truth)であり、Pages 用に複製しない。 +- 成果物に `CNAME`(内容 `dl.basex.jp`)を含め、custom domain を固定する。 +- ルート(`/`)には簡単な案内 HTML を置く(任意)。 + +### 5.2 配信ワークフロー(`.github/workflows/pages.yml`) + +```mermaid +flowchart TD + A["push to main
(install.sh 変更時)"] --> B["checkout"] + B --> C["_site/ 組み立て
install.sh / CNAME / index.html"] + C --> D["upload-pages-artifact"] + D --> E["deploy-pages"] + E --> F["https://dl.basex.jp/install.sh 更新"] +``` + +要点: + +- トリガ: `push`(`paths: [install.sh, .github/workflows/pages.yml]`)+ `workflow_dispatch`。 +- 権限: `pages: write` / `id-token: write` / `contents: read`。 +- 並行制御: `concurrency: { group: pages, cancel-in-progress: true }`。 +- `_site/` に `install.sh` をコピーし、`CNAME` に `dl.basex.jp` を書き出す。 + +### 5.3 HTTPS + +- GitHub Pages が Let's Encrypt 証明書を自動発行・更新する。 +- custom domain 設定後、証明書が発行され次第 **Enforce HTTPS** を有効化する。 +- 証明書発行前は `dl.basex.jp` への HTTPS アクセスが cert 不一致になるため、 + custom domain 登録 → 発行待ち(数分〜)→ Enforce の順で進める。 + +## 6. 初回セットアップ手順 + +```mermaid +flowchart TD + D["1. お名前.com: dl CNAME 追加"] --> M["2. install.sh を main へ (PR #47 マージ)"] + M --> W["3. pages.yml デプロイ実行"] + W --> C["4. custom domain = dl.basex.jp 登録"] + C --> H["5. 証明書発行待ち → Enforce HTTPS"] + H --> V["6. URL 動作確認"] + V --> U["7. README/docs/install.sh の URL 更新"] +``` + +| # | 作業 | 実施者 | 方法 | +|---|---|---|---| +| 1 | `dl` CNAME 追加 | メンテナ | お名前.com Navi(API 不可) | +| 2 | `install.sh` を main へ | メンテナ | PR #47 をマージ | +| 3 | Pages 有効化・デプロイ | 自動/CLI | Actions 実行、または `gh api -X POST repos/devbasex/devbase/pages -f build_type=workflow` | +| 4 | custom domain 登録 | CLI/UI | `gh api -X PUT repos/devbasex/devbase/pages -f cname=dl.basex.jp` または Settings→Pages | +| 5 | Enforce HTTPS | CLI/UI | 証明書発行後に `https_enforced=true` | +| 6 | 動作確認 | 任意 | §8 のコマンド | +| 7 | URL 更新 | メンテナ | README / docs / install.sh のコメント | + +URL 更新(#7)は **配信が生きてから**行う。先に書くと一時的に 404 になる。 + +## 7. 更新・運用フロー + +- `install.sh` を変更して main にマージすると、`pages.yml` が走り `dl.basex.jp` の + 配信が自動更新される(手動の cache invalidation 不要)。 +- 正本はリポジトリ root の `install.sh` の 1 つだけ。Pages 用コピーは作らない。 +- ドメイン検証 TXT を入れておくと、サブドメイン乗っ取り(dangling DNS)を防げる。 + +## 8. 検証コマンド + +```bash +# DNS: CNAME が devbasex.github.io を指すか +curl -fsS 'https://dns.google/resolve?name=dl.basex.jp&type=CNAME' + +# 配信: install.sh が 200 で取得でき、HTTPS 証明書が一致するか +curl -fsSL -o /dev/null -w 'HTTP %{http_code} ssl_verify=%{ssl_verify_result}\n' \ + https://dl.basex.jp/install.sh + +# 中身の先頭確認 +curl -fsSL https://dl.basex.jp/install.sh | head -20 +``` + +- DNS 成功時は `Status:0` で `data` が `devbasex.github.io.`。 +- 配信成功時は `HTTP 200` かつ `ssl_verify=0`。 + +## 9. セキュリティ + +- 配信は HTTPS 強制(Enforce HTTPS)。`curl -fsSL` の `-f` で HTTP エラーを fail させる。 +- ドメイン検証 TXT で dangling DNS による乗っ取りを防止する。 +- `curl | bash` の一般的注意(実行前にスクリプトを確認する代替手順)は + README / getting-started に記載済み。 +- installer 自身の安全策(前提チェック・REF サニタイズ・誤上書き防止)は + `issues/PLAN31_1_devbase-installer.md` を参照。 + +## 10. 既知の制約・代替 + +- お名前.com の DNS は公式 API が無く手動運用。DNS を IaC 化したい場合は + Cloudflare DNS(無料)/ Route53($0.50/月)への委譲を検討する。 +- 帯域が 100GB/月を大きく超える見込みになった場合は、帯域無制限の + Cloudflare Pages への移行を検討する(URL・配信方式は据え置き可能)。 + +## 11. ステータス + +- DNS(`dl.basex.jp` CNAME): **設定済み・確認済み**(GitHub Pages IP に解決)。 +- GitHub Pages 有効化 / custom domain / Enforce HTTPS: **未(§6 の #3〜#5)**。 +- URL 更新(README/docs/install.sh): **配信開始後に実施**。 + +## 12. 出典 + +- [Amazon CloudFront Pricing](https://aws.amazon.com/cloudfront/pricing/) +- [GitHub Pages limits](https://docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits) +- [Securing your GitHub Pages site with HTTPS](https://docs.github.com/en/pages/getting-started-with-github-pages/securing-your-github-pages-site-with-https) +- [Cloudflare Pages limits](https://developers.cloudflare.com/pages/platform/limits/) +- [お名前.com DNSレコード設定ガイド](https://www.onamae.com/guide/p/70) diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index c996505..b560769 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -17,6 +17,44 @@ devbase を利用するには、以下のソフトウェアがホストマシン > **Note:** Docker Desktop を使用している場合、Docker Engine と Docker Compose の両方が含まれています。Linux では Docker Engine を直接インストールし、Docker Compose プラグインを追加してください。 +## クイックインストール(ワンライナー) + +手順 1〜2(クローンと初期化)を 1 コマンドで自動化できます。`git` と `curl` があれば実行できます。 + +```bash +curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh | bash +``` + +このコマンドは次を行います。 + +1. `~/devbase` に devbase を clone します(既に devbase が clone 済みなら `git pull --ff-only` で更新)。 +2. clone 先で `devbase init` を 1 回実行します(uv の自動導入・PATH/補完の登録・`plugins.yml` 生成を含む)。 +3. 完了後、シェル再読み込み(手順 3)以降の次の手順を表示します。**新しく開くターミナルでは自動で有効**です。 + +**いま開いている端末で即使う**なら、末尾に `&& source "$(~/devbase/bin/devbase shell-rc)"` を付けます(`&&` 以降は呼び出し元シェルで実行されるため、その場で PATH が通ります)。 + +`env init`(手順 7)は対話が必要なため、ワンライナーでは**実行せず案内のみ**です。完了後に手動で実行してください。配置先を `DEVBASE_INSTALL_DIR` で変えた場合は、`~/devbase/...` を同じパスに合わせてください。 + +環境変数で挙動を上書きできます。 + +| 変数 | 既定値 | 用途 | +|------|--------|------| +| `DEVBASE_INSTALL_DIR` | `$HOME/devbase` | 配置先ディレクトリ | +| `DEVBASE_INSTALL_REPO` | `https://github.com/devbasex/devbase.git` | clone 元(fork / テスト用) | +| `DEVBASE_INSTALL_REF` | `main` | チェックアウトする branch / tag | + +> **既存ディレクトリの扱い**: 配置先が devbase 以外の非空ディレクトリだった場合、誤上書きを避けるためスクリプトは中止します。別の場所に入れるには `DEVBASE_INSTALL_DIR=/path/to/dir` を指定してください。 + +> **⚠ `curl | bash` を実行する前に**: 信頼できないスクリプトをそのままパイプ実行するのが不安な場合は、保存して内容を確認してから実行してください。 +> +> ```bash +> curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh -o install.sh +> less install.sh # 内容を確認 +> bash install.sh +> ``` + +ワンライナーを使わず手動で進める場合は、以下の手順に従ってください。 + ## セットアップ手順 ### 1. リポジトリのクローン diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..91b6496 --- /dev/null +++ b/install.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# devbase ワンライナー installer (PLAN31_1) +# +# curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh | bash +# +# clone (または pull) して `bin/devbase init` を 1 回呼ぶだけの薄い導入スクリプト。 +# uv の自動導入・rc 追記・補完登録・plugins.yml 生成・冪等性・旧版移行はすべて +# `devbase init` 側の既存処理に委譲する。env init は対話必須のため案内のみ。 +# +# 環境変数で上書き可能: +# DEVBASE_INSTALL_DIR 配置先 (既定: $HOME/devbase) +# DEVBASE_INSTALL_REPO clone 元 URL (既定: https://github.com/devbasex/devbase.git) +# DEVBASE_INSTALL_REF branch / tag (既定: main) +# +set -euo pipefail + +INSTALL_DIR="${DEVBASE_INSTALL_DIR:-$HOME/devbase}" +REPO="${DEVBASE_INSTALL_REPO:-https://github.com/devbasex/devbase.git}" +REF="${DEVBASE_INSTALL_REF:-main}" + +# --- 出力ヘルパ -------------------------------------------------------------- +info() { printf '==> %s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; } +err() { printf 'error: %s\n' "$*" >&2; } +die() { err "$*"; exit 1; } + +# --- 前提チェック ------------------------------------------------------------ +require_commands() { + local missing=() + local cmd + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") + done + if [ "${#missing[@]}" -gt 0 ]; then + err "required command(s) not found: ${missing[*]}" + die "git と curl をインストールしてから再実行してください。" + fi +} + +# --- REF サニタイズ ---------------------------------------------------------- +# REF は `git clone --branch` に渡るため、オプション注入 (先頭 '-') やシェル +# メタ文字混入を防ぐ。branch/tag 名として妥当な文字 (英数 . _ - /) のみ許可。 +validate_ref() { + local ref="$1" + case "$ref" in + ""|-*) die "invalid DEVBASE_INSTALL_REF: ${ref:-}" ;; + esac + if [[ ! "$ref" =~ ^[A-Za-z0-9._/-]+$ ]]; then + die "invalid DEVBASE_INSTALL_REF: ${ref}" + fi +} + +# --- 配置先判定 -------------------------------------------------------------- +# 既存ディレクトリが devbase repo か (bin/devbase を持つ git work tree か) 判定。 +is_devbase_repo() { + local dir="$1" + [ -f "$dir/bin/devbase" ] \ + && git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +dir_is_empty() { + local dir="$1" + local entry + for entry in "$dir"/* "$dir"/.*; do + [ -e "$entry" ] || continue + case "${entry##*/}" in .|..) continue ;; esac + return 1 + done + return 0 +} + +# --- 完了案内 ---------------------------------------------------------------- +print_next_steps() { + cat < + 3. プロジェクトへ移動して env を初期化 (対話): + cd "${INSTALL_DIR}/projects/" + devbase env init + 4. ビルドして起動: + devbase build && devbase up && devbase login +============================================================ +EOF +} + +main() { + info "devbase installer" + + require_commands git curl + command -v docker >/dev/null 2>&1 \ + || warn "docker が見つかりません。build/up 実行時に Docker と Compose が必要です。" + validate_ref "$REF" + + if [ -e "$INSTALL_DIR" ] && ! dir_is_empty "$INSTALL_DIR"; then + if is_devbase_repo "$INSTALL_DIR"; then + info "既存の devbase を更新します (git pull --ff-only): ${INSTALL_DIR}" + git -C "$INSTALL_DIR" pull --ff-only + else + err "配置先が devbase ではない既存ディレクトリです: ${INSTALL_DIR}" + err "誤上書きを避けるため中止します。別の配置先を指定してください:" + die " DEVBASE_INSTALL_DIR=/path/to/dir で再実行してください。" + fi + else + info "devbase を clone します: ${REPO} (${REF}) -> ${INSTALL_DIR}" + git clone --branch "$REF" -- "$REPO" "$INSTALL_DIR" + fi + + info "初期化を実行します (uv 自動導入 / rc / 補完 / plugins)..." + cd "$INSTALL_DIR" + "$INSTALL_DIR/bin/devbase" init + + print_next_steps +} + +main "$@" diff --git a/issues/PLAN31_1_devbase-installer.md b/issues/PLAN31_1_devbase-installer.md new file mode 100644 index 0000000..e5f5b8e --- /dev/null +++ b/issues/PLAN31_1_devbase-installer.md @@ -0,0 +1,121 @@ +# PLAN31_1: devbase ワンライナー installer (`curl | bash`) + +> 元 issue: `issues/i31.md` 第1項 +> ステータス: 着手可(設計確定 2026-06-09・既存コード精読済み) +> 関連 skill: `/ndf:issue-plan-strategy`, `/ndf:implementation-plan` + +## 1. 背景と目的 + +現状の導入は手動多段階(`docs/user/getting-started.md` の手順 1〜9)。ゴールは +Claude CLI の `curl -fsSL https://claude.ai/install.sh | bash` 相当で +**`devbase init` 相当まで自動完了**させること。env init は対話必須のため案内のみ。 + +```text +curl -fsSL https://raw.githubusercontent.com/devbasex/devbase/main/install.sh | bash +``` + +## 2. 確定した設計判断(2026-06-09 ユーザー確認済み) + +| 項目 | 決定 | +|---|---| +| 配置先 (DEVBASE_ROOT) | **`~/devbase`** に clone(`DEVBASE_INSTALL_DIR` で上書き可) | +| env init の扱い | install では実行せず、完了後に手順を案内 | +| TUI 範囲 | 本 plan 対象外(→ `PLAN31_2`) | + +## 3. 既存コード調査結果(installer 実装の前提) + +| 事実 | 出典 | installer への含意 | +|---|---|---| +| `bin/devbase` は `BASH_SOURCE` から symlink 解決して **DEVBASE_ROOT を自己解決** | `bin/devbase:6-13` | clone 後に `~/devbase/bin/devbase` を呼ぶだけで DEVBASE_ROOT は自動確定。env 設定不要 | +| `ensure_uv()` が **uv を自動導入** (`astral.sh/uv/install.sh`) し `~/.local/bin` を PATH 追加 | `bin/devbase:180-191` | installer 側で uv を入れる必要なし。`run_python` は `uv run --project "$DEVBASE_ROOT"` (`bin/devbase:194-197`) | +| `init` は Python 実装で wrapper 経由実行 (`run_python init`) | `bin/devbase:358-359` | installer は `bin/devbase init` を 1 回呼ぶだけ | +| `cmd_init` は rc 追記に **marker `# devbase` + check_string `export DEVBASE_ROOT=`** で冪等 | `init.py:298-306` | 再実行しても二重追記しない。installer の冪等性は init に委譲できる | +| 補完登録も **marker `# devbase completion`** で冪等、zsh/bash 自動判定 | `init.py:215-247`, `237-242` | 同上 | +| `_setup_plugins_config` が `~/.devbase/config.yml` / `plugins/` / `projects/` / `plugins.yml` を作成。official repo 取得は**ネットワーク失敗時に空構造へ graceful fallback** | `init.py:84-134` | オフライン/失敗でも init は成功する。installer はネットワーク前提にしなくてよい | +| 旧 rc ブロック (`DEVBASE_PARENT_ROOT` 等) は **自動マイグレーション** | `init.py:138-212` | 旧版からの移行も init 任せ | +| `pyproject.toml` は `package = false`・deps=pyyaml/pyrage/boto3/questionary・`requires-python>=3.10`・`uv.lock` 同梱 | `pyproject.toml` | PyPI 配布なし。clone 必須。deps は `uv run --project` が解決 | +| 前提ソフト: Docker/Compose/Bash4+/Python3.10+/Git(uv 以外) | `getting-started.md:11-16` | installer は git/curl を必須チェック、docker は利用時に必要な旨を案内 | + +**結論**: installer の本質は `clone(または pull)→ ~/devbase/bin/devbase init` の 2 手。 +uv 導入・rc 追記・補完・plugins.yml・冪等性・旧版移行はすべて既存資産が担う。 + +## 4. 要件 + +### 4.1 機能要件 + +- `install.sh` をリポジトリ root に置き、`raw.githubusercontent.com/devbasex/devbase/main/install.sh` で配布する。 +- 前提チェック: `git` / `curl` の存在。無ければ案内して `exit 1`。`docker` は警告のみ(install は通す)。 +- 配置先解決: `INSTALL_DIR="${DEVBASE_INSTALL_DIR:-$HOME/devbase}"`。 +- clone 元: `REPO="${DEVBASE_INSTALL_REPO:-https://github.com/devbasex/devbase.git}"`(fork/テスト用に上書き可)。 +- バージョン: `REF="${DEVBASE_INSTALL_REF:-main}"`(branch/tag)。 +- 既存 `INSTALL_DIR` の扱い: + - `$INSTALL_DIR/bin/devbase` が存在し devbase git repo → `git -C "$INSTALL_DIR" pull`(更新)。 + - 存在するが devbase でない → **中止**して別ディレクトリ指定を案内(誤上書き防止)。 + - 無し → `git clone --branch "$REF" "$REPO" "$INSTALL_DIR"`。 +- `"$INSTALL_DIR/bin/devbase" init` を実行(uv 自動導入+rc/補完/plugins 初期化)。 +- 完了メッセージ(実パス埋め込み): + `source "$("$INSTALL_DIR/bin/devbase" shell-rc)"` → `devbase plugin install ` + → `cd "$INSTALL_DIR/projects/"` → `devbase env init` → `build` → `up` → `login`。 + +### 4.2 非機能要件 + +- **非 TTY 安全**: `curl | bash` では stdin がスクリプト本体。対話プロンプトを出さない + (確認が要る分岐は env 既定値で回避、どうしても要るなら `< /dev/tty`)。 +- `#!/usr/bin/env bash` + `set -euo pipefail`。失敗時に途中状態を残さない。 +- shellcheck 通過を CI 条件に含める。 +- 配置先 / clone 元 / REF はサニタイズ(任意コマンド混入防止。特に `REF`)。 + +## 5. インストールフロー + +```mermaid +flowchart TD + A["curl | bash 実行"] --> B{git / curl あり?} + B -- no --> Z["案内して exit 1"] + B -- yes --> C{"INSTALL_DIR 存在?"} + C -- no --> D["git clone --branch REF REPO INSTALL_DIR"] + C -- devbase repo --> E["git -C INSTALL_DIR pull"] + C -- 別物 --> Z2["中止: DEVBASE_INSTALL_DIR 指定を案内"] + D --> F["INSTALL_DIR/bin/devbase init
(uv自動導入 + rc/補完/plugins)"] + E --> F + F --> G["次手順を表示
source shell-rc / env init / build / up"] + G --> H["exit 0"] +``` + +## 6. PR 分割計画 + +installer 本体(スクリプト + テスト)とドキュメントで関心が分かれるため 2 PR。 +PR1 が小さく収束すれば PR2 を畳む判断も可(着手時に再評価)。 + +| PR # | branch 名 | 概要 | 主な変更ファイル | 依存 | +|---|---|---|---|---| +| 1 | `feature/PLAN31_1-install-script` | `install.sh`(前提チェック / 配置先・REF 解決 / clone・pull / 既存判定 / `bin/devbase init` 起動 / 完了案内 / 冪等)+ サンドボックステスト + shellcheck CI | `install.sh`(新規), `tests/test_install_sh.py`(新規), `.github/workflows/*` | なし | +| 2 | `feature/PLAN31_1-docs` | README/getting-started のワンライナー化・`DEVBASE_INSTALL_DIR`/`_REF`/`_REPO` 説明・CHANGELOG | `README.md`, `docs/user/getting-started.md`, `CHANGELOG.md` | PR1 | + +```text +release branch: release/PLAN31_1 +base branch: main +``` + +## 7. テスト計画 + +- **PR1(ネットワーク非依存)**: `DEVBASE_INSTALL_REPO` をローカル bare repo + (`git init --bare` + 雛形 push) に向け、一時 `HOME` / 一時 `DEVBASE_INSTALL_DIR` + で `install.sh` を実行し以下を検証: + 1. 新規 clone → `$INSTALL_DIR/bin/devbase` が存在し実行可能。 + 2. 2 回目実行で pull 経路に入り **冪等**(rc に `# devbase` ブロックが 1 つだけ)。 + 3. 既存の非 devbase ディレクトリ指定で **中止**(exit 非0・上書きしない)。 + 4. `git` 不在を `PATH` 細工で再現しエラー終了。 + - `bin/devbase init` 呼び出しは uv/network を伴うため、テストでは `init` をスタブ + (`PATH` 先頭に偽 `devbase` を置く / `--dry-run` 相当の env で skip)して + スクリプトの分岐ロジックのみを検証する方針。 +- **結合(release)**: 実 `~/devbase` 相当へ通し、`init` 後に + `source "$(bin/devbase shell-rc)"` → `devbase --help` が PATH 経由で解決すること、 + および 2 回目 install が冪等であることを確認。 + +## 8. 留意点 / 未決事項 + +- `install.sh` の正規ホスティング(raw main 固定 か releases 資産化)はドキュメントで最終確定。 +- `bin/devbase init` はテストでスタブするため、installer 単体テストは「init を正しい + 引数・CWD で 1 回呼ぶ」ことの検証に留める(init 自体の挙動は既存テストの責務)。 +- `curl | bash` 警告として「実行前にスクリプトを確認する」代替手順 + (`curl -fsSL … -o install.sh` → 確認 → `bash install.sh`)を README に併記。 diff --git a/tests/cli/test_install_sh.py b/tests/cli/test_install_sh.py new file mode 100644 index 0000000..ce1ae28 --- /dev/null +++ b/tests/cli/test_install_sh.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""install.sh (ワンライナー installer) の分岐ロジックのテスト (PLAN31_1)。 + +`curl | bash` 相当の非対話導入を検証する。実際の clone 元・init の挙動に依存 +しないよう、以下をスタブする: + +- clone 元 (`DEVBASE_INSTALL_REPO`): ローカルに作った雛形 git repo を指す。 + 雛形の `bin/devbase` は init をスタブする薄いスクリプトで、呼び出し引数と + CWD を `DEVBASE_TEST_INIT_LOG` に追記するだけ (uv / network を起動しない)。 +- 配置先 (`DEVBASE_INSTALL_DIR`) と `HOME`: 一時ディレクトリに隔離する。 + +これにより install.sh 自身の「前提チェック / 配置先解決 / clone・pull / +既存判定 / init を 1 回正しく呼ぶ / サニタイズ」だけを検証する。init 本体の +挙動は既存 init テストの責務 (plan §8)。 +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALL_SH = REPO_ROOT / "install.sh" +BASH = shutil.which("bash") or "/bin/bash" + +# 雛形 repo の bin/devbase。init をスタブし、呼び出しを記録するだけ。 +_STUB_DEVBASE = """#!/usr/bin/env bash +{ + echo "called:$*" + echo "cwd:$PWD" +} >> "${DEVBASE_TEST_INIT_LOG:-/dev/null}" +""" + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(cwd), + "-c", "user.email=test@example.com", "-c", "user.name=test", + *args], + check=True, capture_output=True, text=True, + ) + + +def _make_source_repo(root: Path) -> Path: + """clone 元となる雛形 devbase repo (branch=main) を作る。""" + src = root / "src-devbase" + (src / "bin").mkdir(parents=True) + stub = src / "bin" / "devbase" + stub.write_text(_STUB_DEVBASE) + stub.chmod(0o755) + # clone でファイルが運ばれたことを確認するためのマーカ + (src / "MARKER").write_text("devbase-source\n") + _git(src, "init", "-q") + _git(src, "add", "-A") + _git(src, "commit", "-qm", "init") + _git(src, "branch", "-M", "main") + return src + + +def _run_install(*, home: Path, install_dir: Path | None, repo: Path, + ref: str = "main", init_log: Path | None = None, + path: str | None = None) -> subprocess.CompletedProcess: + env = { + **os.environ, + "HOME": str(home), + "DEVBASE_INSTALL_REPO": str(repo), + "DEVBASE_INSTALL_REF": ref, + } + if install_dir is not None: + env["DEVBASE_INSTALL_DIR"] = str(install_dir) + else: + env.pop("DEVBASE_INSTALL_DIR", None) + if init_log is not None: + env["DEVBASE_TEST_INIT_LOG"] = str(init_log) + if path is not None: + env["PATH"] = path + # bash 自体は絶対パスで起動し、PATH 制限が install.sh 内部にだけ効くようにする。 + return subprocess.run( + [BASH, str(INSTALL_SH)], + capture_output=True, text=True, env=env, + ) + + +@pytest.fixture +def source_repo(tmp_path: Path) -> Path: + return _make_source_repo(tmp_path) + + +class TestFreshInstall: + def test_clones_into_install_dir(self, tmp_path, source_repo): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + log = tmp_path / "init.log" + + result = _run_install(home=home, install_dir=install_dir, + repo=source_repo, init_log=log) + + assert result.returncode == 0, result.stderr + devbase = install_dir / "bin" / "devbase" + assert devbase.exists(), "clone 後に bin/devbase が存在すること" + assert os.access(devbase, os.X_OK), "bin/devbase が実行可能であること" + assert (install_dir / "MARKER").read_text() == "devbase-source\n" + + def test_invokes_init_once_in_install_dir(self, tmp_path, source_repo): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + log = tmp_path / "init.log" + + result = _run_install(home=home, install_dir=install_dir, + repo=source_repo, init_log=log) + + assert result.returncode == 0, result.stderr + logged = log.read_text() + # init が 1 回だけ呼ばれる + assert logged.count("called:init") == 1, logged + # init の引数は厳密に `init` のみ (余計な引数を渡さない) + assert "called:init\n" in logged, logged + # CWD が install_dir 配下であること + assert f"cwd:{install_dir}" in logged, logged + + def test_default_install_dir_is_home_devbase(self, tmp_path, source_repo): + """DEVBASE_INSTALL_DIR 未指定なら $HOME/devbase に clone する。""" + home = tmp_path / "home"; home.mkdir() + log = tmp_path / "init.log" + + result = _run_install(home=home, install_dir=None, + repo=source_repo, init_log=log) + + assert result.returncode == 0, result.stderr + assert (home / "devbase" / "bin" / "devbase").exists() + + +class TestIdempotentRerun: + def test_second_run_uses_pull_and_succeeds(self, tmp_path, source_repo): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + log = tmp_path / "init.log" + + first = _run_install(home=home, install_dir=install_dir, + repo=source_repo, init_log=log) + assert first.returncode == 0, first.stderr + + second = _run_install(home=home, install_dir=install_dir, + repo=source_repo, init_log=log) + assert second.returncode == 0, second.stderr + # 2 回目は再 clone せず pull 経路に入る (出力にその旨) + combined = (second.stdout + second.stderr).lower() + assert "pull" in combined or "updat" in combined, combined + # init は各回 1 度ずつ → 計 2 回 + assert log.read_text().count("called:init") == 2 + + +class TestExistingNonDevbaseDir: + def test_aborts_without_overwriting(self, tmp_path, source_repo): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + install_dir.mkdir() + sentinel = install_dir / "important.txt" + sentinel.write_text("DO_NOT_TOUCH") + log = tmp_path / "init.log" + + result = _run_install(home=home, install_dir=install_dir, + repo=source_repo, init_log=log) + + assert result.returncode != 0, "非 devbase ディレクトリでは中止すること" + # 既存ファイルを上書き/削除しない + assert sentinel.read_text() == "DO_NOT_TOUCH" + assert not (install_dir / "bin" / "devbase").exists() + # 別ディレクトリ指定方法を案内する + assert "DEVBASE_INSTALL_DIR" in (result.stdout + result.stderr) + # init は呼ばれない + assert not log.exists() or "called:init" not in log.read_text() + + +class TestPrerequisiteCheck: + def test_missing_git_errors(self, tmp_path, source_repo): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + + # git を除いた PATH を構築する。 + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + seen: set[str] = set() + for d in os.environ.get("PATH", "").split(os.pathsep): + if not d or not os.path.isdir(d): + continue + for name in os.listdir(d): + if name == "git" or name in seen: + continue + try: + (fakebin / name).symlink_to(Path(d) / name) + seen.add(name) + except OSError: + pass + + result = _run_install(home=home, install_dir=install_dir, + repo=source_repo, path=str(fakebin)) + + assert result.returncode != 0, "git 不在ではエラー終了すること" + assert "git" in (result.stdout + result.stderr).lower() + assert not (install_dir / "bin" / "devbase").exists() + + +class TestRefSanitization: + @pytest.mark.parametrize("bad_ref", [ + "--upload-pack=touch /tmp/pwn", # オプション注入 + "-x", # 先頭ハイフン + "main;rm -rf /", # シェルメタ文字 + "$(touch /tmp/pwn)", # コマンド置換 + ]) + def test_rejects_unsafe_ref(self, tmp_path, source_repo, bad_ref): + home = tmp_path / "home"; home.mkdir() + install_dir = tmp_path / "install" + + result = _run_install(home=home, install_dir=install_dir, + repo=source_repo, ref=bad_ref) + + assert result.returncode != 0, f"不正な REF を拒否すること: {bad_ref!r}" + assert not (install_dir / "bin" / "devbase").exists() + # git の「branch not found」ではなく install.sh のサニタイズが拒否したこと + # を確認する (clone に到達する前に弾く)。 + out = (result.stdout + result.stderr) + assert "REF" in out and "invalid" in out.lower(), out + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))