diff --git a/.github/workflows/setup-check.yml b/.github/workflows/setup-check.yml index 6d88b97..e37b498 100644 --- a/.github/workflows/setup-check.yml +++ b/.github/workflows/setup-check.yml @@ -25,6 +25,10 @@ on: - 'herdr/**' - 'claude/hooks/**' - 'claude/settings.json' + # zshenv/zshrc は setup/tests/{zshenv,zshrc}.bats の検証対象(PATH 宣言の + # 単一性など)。setup/** を伴わない単独編集でもテストを走らせる。 + - 'zshenv' + - 'zshrc' - '.github/workflows/setup-check.yml' push: # feature branch への push は pull_request イベントだけで検証する @@ -36,6 +40,10 @@ on: - 'herdr/**' - 'claude/hooks/**' - 'claude/settings.json' + # zshenv/zshrc は setup/tests/{zshenv,zshrc}.bats の検証対象(PATH 宣言の + # 単一性など)。setup/** を伴わない単独編集でもテストを走らせる。 + - 'zshenv' + - 'zshrc' - '.github/workflows/setup-check.yml' concurrency: diff --git a/AGENTS.md b/AGENTS.md index ed665cd..84fba14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - `CONTEXT.md` — AI エージェント指示の層(規範フラグメント・グローバル / プロジェクト AGENTS.md・SOUL.md・CLAUDE.local.md・channel prompt)を区別する用語集 - `aliases` — alias 定義の SSOT(root 直下、`~/.aliases` にシンボリックリンク)。旧 `nix/modules/home/zsh.nix` の `shellAliases` から移行(`setup/link.zsh` が配置、Tier 1) - `scripts/` — 外部シェルスクリプト(`aliases` の alias から呼び出される。旧 `aliase/` から改名) -- `setup/` — Tier 1(リアルタイム symlink)・Tier 2(明示的スクリプト実行)・Tier 3(カットオーバー・ロールバック)の実装。`darwin-rebuild switch` を使わず、`zsh setup/link.zsh` で dotfiles を配置し(Tier 1)、`setup/languages.zsh`(mise による言語ランタイム + corepack + バージョン固定 CLI)/`setup/defaults.zsh`(macOS defaults・IME)/`setup/pam.zsh`(Touch ID for sudo)/`setup/claude-sync.zsh`(skills clone・plugin sync・MCP merge)/`setup/codex-sync.zsh`(config.toml seed-if-absent)/`setup/herdr-sync.zsh`(Herdr ローカルプラグインの link・allowlist 配置)を個別実行する(Tier 2)。既存 PC を home-manager 込みの旧構成から移行する場合は `setup/cutover.zsh`(pre-flight build 確認 + `darwin-rebuild switch`)/`setup/rollback.zsh`(`.before-nix` 衝突検出付きロールバック)を使う(Tier 3)。**実機での実行は `setup/migrate.zsh` が唯一のエントリポイント**(`--dry-run`/`--apply`。Tier 1/2/3 を依存順(Phase 1: link → Phase 2: cutover/pam [root] → Phase 3: languages/defaults/claude-sync/codex-sync/herdr-sync)で実行し、`~/.dotfiles-migrate/manifest.log` で部分適用を検出・再開する。fail-closed、rollback.zsh は自動では呼ばない)。個別スクリプトの直接実行はメンテナンス目的のみ。詳細は `setup/README.md` と `docs/superpowers/specs/2026-08-21-restore-script-management-inventory.md`・`docs/superpowers/specs/2026-08-22-restore-script-management-tier3-cutover-design.md`・`docs/superpowers/specs/2026-08-22-migrate-orchestrator-recovery-plan.md` を参照 +- `setup/` — Tier 1(リアルタイム symlink)・Tier 2(明示的スクリプト実行)・Tier 3(カットオーバー・ロールバック)の実装。`darwin-rebuild switch` を使わず、`zsh setup/link.zsh` で dotfiles を配置し(Tier 1)、`setup/languages.zsh`(mise による言語ランタイム + corepack + バージョン固定 CLI)/`setup/defaults.zsh`(macOS defaults・IME)/`setup/pam.zsh`(Touch ID for sudo)/`setup/claude-sync.zsh`(skills clone・plugin sync・MCP merge)/`setup/codex-sync.zsh`(config.toml seed-if-absent)/`setup/herdr-sync.zsh`(Herdr ローカルプラグインの link・allowlist 配置)/`setup/notion.zsh`(Notion CLI `ntn` を公式インストーラで `${HOME}/.local/bin` へ install-if-absent。宣言する版とパスは `setup/lib/notion.zsh` に集約し、`setup/migrate.zsh` の health check も同じ定義から引く)を個別実行する(Tier 2)。既存 PC を home-manager 込みの旧構成から移行する場合は `setup/cutover.zsh`(pre-flight build 確認 + `darwin-rebuild switch`)/`setup/rollback.zsh`(`.before-nix` 衝突検出付きロールバック)を使う(Tier 3)。**実機での実行は `setup/migrate.zsh` が唯一のエントリポイント**(`--dry-run`/`--apply`。Tier 1/2/3 を依存順(Phase 1: link → Phase 2: cutover/pam [root] → Phase 3: languages/defaults/claude-sync/codex-sync/herdr-sync/notion)で実行し、`~/.dotfiles-migrate/manifest.log` で部分適用を検出・再開する。fail-closed、rollback.zsh は自動では呼ばない)。個別スクリプトの直接実行はメンテナンス目的のみ。詳細は `setup/README.md` と `docs/superpowers/specs/2026-08-21-restore-script-management-inventory.md`・`docs/superpowers/specs/2026-08-22-restore-script-management-tier3-cutover-design.md`・`docs/superpowers/specs/2026-08-22-migrate-orchestrator-recovery-plan.md` を参照 - `claude/` — Claude Code 設定(`~/.claude/` にシンボリックリンク) - `claude/rules/` — 全 AI エージェント向けグローバル指示のフラグメント(SSOT)。`core` / `worker` / `orchestrator` / `hermes-identity` の 4 ファイルを `scripts/build-agent-rules.zsh`(旧 `aliase/build-agent-rules.zsh`)が結合して生成物を作る - `claude/hermes/SOUL.md` — Hermes Agent 用グローバル指示の生成物(`~/.hermes/SOUL.md` にシンボリックリンク)。直接編集しない @@ -26,7 +26,7 @@ - `ssh/` — SSH 設定(`~/.ssh/` にシンボリックリンク) - `zsh/` — zsh 補完ファイル(`~/.zsh/` にシンボリックリンク) - `zshrc` — zsh 設定(`~/.zshrc` にシンボリックリンク) -- `zshenv` — zsh 環境変数(`~/.zshenv` にシンボリックリンク) +- `zshenv` — zsh 環境変数(`~/.zshenv` にシンボリックリンク)。`${HOME}/.local/bin` を PATH へ載せる唯一の場所(pipx の出力先であり、Homebrew formula が無い CLI の導入先。例: `setup/notion.zsh` が入れる `ntn`)。追加は **append で 1 回だけ** — prepend にすると Homebrew/mise が供給する同名コマンドを横取りして既存の解決順が変わる。`zshrc` 側に同じ追加を書かない(`setup/tests/zshrc.bats` が重複を落とす) # シンボリックリンク管理 @@ -44,6 +44,7 @@ - `taps` / `brews` / `casks` / `masApps` の区分を守る - CLI tool は Homebrew (`homebrew.nix` の `brews`) で管理する(Tier 3 で home-manager の `packages.nix` から完全移行済み) - 例外として、**バージョンを固定する必要がある CLI tool だけは mise で供給する**(`setup/languages.zsh`、Tier 2)。Homebrew formula は任意バージョンの pin を表現できず、nix-darwin の `homebrew.brews` にも version フィールドが無いため、宣言的に版を固定できる経路が mise しかないことによる。現状の対象は standalone `dolt`(beads が要求する 2.2.0)。Homebrew が同名 formula を他パッケージの依存として引き込んでいてもそれは削除せず、PATH 先頭を mise が取ることで実行体だけを mise 側に寄せる(non-interactive shell は `zshenv` の `mise activate --shims`、interactive shell は `zshrc` の `mise activate zsh` が `brew shellenv` の後に走ることによる) +- もう 1 つの例外は **Homebrew formula が無く、かつ宣言的に供給できる経路がランタイム依存しか無い CLI tool** で、その場合だけ公式インストーラを呼ぶ専用の Tier 2 スクリプトを立てる。現状の対象は Notion CLI の `ntn`(`setup/notion.zsh`)。`ntn` は mise の npm backend でも導入できるが、グローバル CLI を Node ランタイムに依存させないため公式配布バイナリを選んでいる。版は宣言側(`setup/lib/notion.zsh` の `NTN_PINNED_VERSION`)で固定し、インストーラ既定の `latest`(導入した日で版が決まる)には倒さない。更新は dotfiles 側の明示変更で行い、実機の版ずれは `setup/migrate.zsh` の health check が fail-closed で検出する(自動差し替えはしない) - PC ローカル専用の cask は `~/.config/dotfiles/homebrew.local.nix`(リポジトリ外配置)で declarative に宣言する。`homebrew.nix` が絶対パスで `builtins.pathExists` + `import` する。用途は「git に追跡させたくないが `default` role の zap から守りたい cask」(例: 特定アカウントの個人用ツール、業務用アプリ)。別 PC では復元されないため、再現性が必要なものは `homebrew.nix` 本体に書くこと。現状 casks のみ対応(brews / taps / masApps の overlay が必要になったら `homebrew.nix` の `local` 解決を拡張する)。nix flake は git tree のみコピーするため、`.gitignore` で除外したリポジトリ内ファイルは flake から不可視になる点に注意(リポジトリ外配置を選んでいる理由) # zsh スクリプト規約 diff --git a/setup/README.md b/setup/README.md index 7318891..2fc50e1 100644 --- a/setup/README.md +++ b/setup/README.md @@ -16,7 +16,7 @@ Tier を跨いだ実行順序・部分適用の検出/復旧を担う `setup/mig ## 使い方(実機での唯一のエントリポイントは `setup/migrate.zsh`) Tier 1/2/3 の各スクリプト(`link.zsh`/`languages.zsh`/`defaults.zsh`/`pam.zsh`/ -`claude-sync.zsh`/`codex-sync.zsh`/`herdr-sync.zsh`/`cutover.zsh`)を実機で直接実行することは非推奨。 +`claude-sync.zsh`/`codex-sync.zsh`/`herdr-sync.zsh`/`notion.zsh`/`cutover.zsh`)を実機で直接実行することは非推奨。 順序管理なしに個別実行すると部分適用インシデントを再現する(過去に実際に発生した)。 実機での実行は必ず `setup/migrate.zsh` からのみ行う: @@ -33,7 +33,7 @@ alias・関数に依存しないため、これが標準の入口。 zsh ${HOME}/.dotfiles/setup/migrate.zsh --dry-run # 計画を実行する。単一の root 起動で全 Phase (link -> cutover/pam -> languages/defaults/ -# claude-sync/codex-sync/herdr-sync) が完結する。sudo が自動設定する SUDO_USER から元ユーザーを +# claude-sync/codex-sync/herdr-sync/notion) が完結する。sudo が自動設定する SUDO_USER から元ユーザーを # 特定し、非 root ステップは元ユーザーへ委譲実行する(詳細は # docs/superpowers/specs/2026-08-22-migrate-orchestrator-recovery-plan.md 参照) sudo zsh ${HOME}/.dotfiles/setup/migrate.zsh --apply @@ -67,6 +67,7 @@ zsh ${HOME}/.dotfiles/setup/pam.zsh zsh ${HOME}/.dotfiles/setup/claude-sync.zsh zsh ${HOME}/.dotfiles/setup/codex-sync.zsh zsh ${HOME}/.dotfiles/setup/herdr-sync.zsh +zsh ${HOME}/.dotfiles/setup/notion.zsh sudo USER=${USER} zsh ${HOME}/.dotfiles/setup/cutover.zsh ``` @@ -105,6 +106,42 @@ symlink 越しに即座に反映される。再実行が必要なのは「`setup primary 以外から実行された場合は両方まとめてスキップする。登録先パスが既に一致して いれば何もせず、`repos.local.json`(マシンローカルの allowlist)は seed-if-absent で 既存の中身に触れない。 +- `notion.zsh`: Notion CLI (`ntn`) に Homebrew formula が無いため、公式インストーラ + (`https://ntn.dev/install.sh`) を使う唯一の Tier 2 ステップ。mise の npm backend でも + 導入できるが、グローバル CLI を Node ランタイムに依存させないため公式配布バイナリを + 使う。インストーラには + 2 つの環境変数を渡して導入結果を宣言側で決めきる: + - `NTN_INSTALL_DIR` = `${HOME}/.local/bin`。インストーラ既定の導入先選択は実行時の PATH の + 形に依存して揺れるため、宣言側で固定して health check と一致させる + - `NTN_VERSION` = `setup/lib/notion.zsh` の `NTN_PINNED_VERSION`(現在 `0.23.4`)。既定の + `latest` だと「導入した日」で版が決まり PC ごとに別物が入る。版を上げるのは dotfiles + 側の明示変更で行う + + `${HOME}/.local/bin/ntn` が既に実行可能なファイルなら **インストーラを一切呼ばない** + (既存バイナリを上書きしない)。判定は `-x` 単独ではなく `-f && -x`(実行ビットの立った + ディレクトリを「導入済み」と誤判定しないため。health check も同じ条件)。 + + この install-if-absent だけだと、宣言の版を上げても既に実体のある PC は古いまま success に + なり続ける(版が効くのは新規導入時だけ)。そこで `migrate.zsh` の health check が + `ntn --version` を実行し、`NTN_PINNED_VERSION` と一致しなければ **fail-closed** で落とす。 + 自動では差し替えない — 実行中かもしれないバイナリを migrate が黙って置き換えないため、 + 人が実体を削除してから `--apply` を再実行する。宣言値とパスは `setup/lib/notion.zsh` に + 寄せてあり、導入する側と確認する側が同じ定義を引く(`setup/lib/herdr.zsh` と同じ理由)。 + + この probe は root 起動時に `sudo -u <元ユーザー> -H --` を前置して元ユーザーとして実行する + (`migrate::ntn_version`)。実体は元ユーザーの `$HOME` 配下にあって本人が書き換えられる + ファイルなので、検証のために root の権限で走らせる理由が無い。非 root ステップの委譲実行と + 同じ規則で、元ユーザーを特定できないときは probe せず fail-closed に落ちる。 + + `curl` は `bash` に直結せず一旦ファイルへ落とす(取得失敗時に空スクリプトを実行して + 「成功」に見えるのを防ぐ)。**初回ダウンロードの失敗は fail-closed** で migrate 全体を + 止める。`ntn` は恒久的に宣言したグローバル必須ツールなので「入らなかったが成功」を健全な + 状態として扱わない。既にバイナリがある PC では一切ネットワークに出ないため、オフラインでも + `--apply` は通る(ネットワークが要るのは初回導入のときだけ)。 + + トークン(`NOTION_API_KEY` 等)は読まない・要求しない・保存しない。導入後の認証は人間が + `ntn` 側の手順で行う。PATH への `${HOME}/.local/bin` 追加は Tier 1 の `zshenv` が + append で 1 箇所だけ行う(`zshrc` 側には書かない)。 - `cutover.zsh`: 実行前に `darwin-rebuild --list-generations` の出力を `~/.dotfiles-cutover-backup/pre-cutover-generations-.txt` へ記録してから `nix build`(副作用なし)で pre-flight 確認し、成功したときだけ `darwin-rebuild switch` @@ -115,7 +152,7 @@ symlink 越しに即座に反映される。再実行が必要なのは「`setup `fs::ensure_realfile` と同じ no-data-loss 方針で、自動退避はしない)。 - `migrate.zsh`: Tier 1/2/3 を跨いだ唯一のオーケストレーター。実行順序は Phase 1 (`link`) → Phase 2 (`cutover`/`pam`、root 必須) → Phase 3 (`languages`/`defaults`/ - `claude-sync`/`codex-sync`/`herdr-sync`)。`languages.zsh` 自身が「mise は darwin-switch で事前導入 + `claude-sync`/`codex-sync`/`herdr-sync`/`notion`)。`languages.zsh` 自身が「mise は darwin-switch で事前導入 済みが前提」と明記しているため、cutover を languages より先に置く。各ステップの結果は `~/.dotfiles-migrate/manifest.log` に永続化し、success 済みステップは再実行しない (idempotent な部分適用検出・再開)。ただし `cutover` だけは、必須バイナリの実在と @@ -134,6 +171,10 @@ bats setup/tests/*.bats bats herdr/plugins/*/tests/*.bats ``` +`setup/tests/` は `setup/**` だけでなく `zshenv`/`zshrc` も検証対象にしている(PATH 宣言を +1 箇所に保つ `zshenv.bats`/`zshrc.bats`)。`.github/workflows/setup-check.yml` の `paths` にも +この 2 ファイルを含めてあるので、`setup/**` を伴わない単独編集でも CI が走る。 + `setup/lib/herdr.zsh` は Herdr プラグインの識別子とパス解決だけを持つ共有ライブラリ。 配置する側(`herdr-sync.zsh`)と確認する側(`migrate.zsh` の health check)が別々に パスを組み立てると、Herdr が設定ディレクトリの位置を変えたときに health check だけが @@ -157,11 +198,17 @@ bats herdr/plugins/*/tests/*.bats 委譲実行を health check にまで広げるほどの利得が無いため)。 `fs::link_file`/`fs::ensure_realfile` は関数単位、`link.zsh`/`languages.zsh`/`defaults.zsh`/ -`pam.zsh`/`claude-sync.zsh`/`codex-sync.zsh`/`herdr-sync.zsh`/`cutover.zsh`/`rollback.zsh`/ +`pam.zsh`/`claude-sync.zsh`/`codex-sync.zsh`/`herdr-sync.zsh`/`notion.zsh`/`cutover.zsh`/`rollback.zsh`/ `migrate.zsh` は、 実コマンド(`defaults`/`mise`/`corepack`/`claude`/`herdr`/`git`/`darwin-rebuild`/`nix`)を PATH 上の stub 実行ファイルに差し替え、`$HOME` を一時ディレクトリに差し替えたサンドボックスでの統合テスト (実機・実ネットワーク・実パッケージマネージャ・実 `darwin-rebuild switch` には一切触れない)。 + +`notion.zsh` だけは `curl` の扱いが 2 通りある。単体テスト(`setup/tests/notion.bats`)は +`curl` を stub に差し替えて取得失敗の経路まで見る。`migrate.zsh` 経由の統合テストでは stub を +使わず、`NTN_INSTALLER_URL` に `file://` の偽インストーラを渡して **実 `curl` をオフラインで** +走らせる。`migrate.zsh` の委譲実行は Homebrew prefix を PATH 先頭に固定で差し込むため、 +そこでの `curl` stub は実機に Homebrew 版 `curl` があると負けて実ネットワークに出てしまう。 `migrate.zsh` のテストは Tier 1 が作る `~/.zshenv` symlink を経由して後続の子 `zsh` プロセスが 実際の zshenv を re-source する(Phase を跨いだ実行を初めて連結するテストのため、単独スクリプトの テストでは踏まなかった経路)。stub 実行ファイルを `#!/bin/bash` にしているのはこのため diff --git a/setup/lib/notion.zsh b/setup/lib/notion.zsh new file mode 100644 index 0000000..ab48625 --- /dev/null +++ b/setup/lib/notion.zsh @@ -0,0 +1,29 @@ +#!/bin/zsh +# setup/lib/notion.zsh +# +# Notion CLI (ntn) の宣言値とパス解決だけを持つ共有ライブラリ。導入する側 +# (setup/notion.zsh) と確認する側 (setup/migrate.zsh の health check) が同じ定義を +# 引くためにここへ寄せている。別々に書くと、版を上げたときに片方だけ古い値を見に行く +# (setup/lib/herdr.zsh を分けているのと同じ理由)。 + +# 宣言する版。上げるときはここを書き換える。 +# +# 既存バイナリの自動差し替えはしない。版を上げると health check が fail-closed で +# 落ちるので、人が実体(notion::bin のパス)を削除してから --apply を再実行する。 +# 実行中かもしれないバイナリを migrate が黙って置き換えないための取り決め。 +NTN_PINNED_VERSION="0.23.4" + +# notion::bin を持つユーザーの ntn 実体パス +notion::bin() { + echo "${1}/.local/bin/ntn" +} + +# notion::installed_version [...] 実体が報告する版を返す。取得できなければ +# 空文字列。`ntn --version` は `ntn 0.23.4` の形で出すので最終フィールドを取る。 +# +# 引数は「ntn を起動する argv 全体」であって実体パス 1 個とは限らない。呼び出し側が +# `sudo -u -H --` を前置して別ユーザーとして probe できるようにするため +# (migrate.zsh の health check は root で走りつつ元ユーザーの実体を見る)。 +notion::installed_version() { + "$@" --version 2>/dev/null | awk 'NR == 1 { print $NF }' +} diff --git a/setup/migrate.zsh b/setup/migrate.zsh index b9aeb5b..11d1c3b 100755 --- a/setup/migrate.zsh +++ b/setup/migrate.zsh @@ -31,8 +31,8 @@ # ため、Phase 3 より前に置く。pam は cutover と同じく # root 必須なので同じ Phase にまとめ、sudo プロンプトを # 1 回にまとめる) -# Phase 3: languages, defaults, claude-sync, codex-sync, herdr-sync (root 起動時は元ユーザーへ -# 委譲。 +# Phase 3: languages, defaults, claude-sync, codex-sync, herdr-sync, notion +# (root 起動時は元ユーザーへ委譲。 # mise は Phase 2 で導入済み) # # 安全設計: @@ -94,13 +94,14 @@ set -eu SETUP_DIR="${0:A:h}" source "${SETUP_DIR}/lib/util.zsh" source "${SETUP_DIR}/lib/herdr.zsh" +source "${SETUP_DIR}/lib/notion.zsh" # --------------------------------------------------------------------------- # ステップ定義(配列内の並び = 実行順序。Phase 番号が依存順序を表す) # --------------------------------------------------------------------------- PHASE1_STEPS=(link) PHASE2_STEPS=(cutover pam) -PHASE3_STEPS=(languages defaults claude-sync codex-sync herdr-sync) +PHASE3_STEPS=(languages defaults claude-sync codex-sync herdr-sync notion) # この --apply の中で cutover 直前に退避した Touch ID ファイルのパス( # migrate::pam_restore_pristine_if_safe が設定し、migrate::pam_discard_vacated が使う)。 @@ -728,6 +729,29 @@ migrate::run_phase() { return 0 } +# migrate::ntn_version 対象ユーザーの ntn が報告する版を返す。取得できなければ +# 空文字列(呼び出し側で fail-closed に扱うこと)。 +# +# root 起動のまま元ユーザー所有のバイナリを root で実行しない。health check は状態の +# 検証であって特権実行の場ではなく、`$HOME` 配下の書き換え可能なファイルを root の +# 権限で走らせる理由が無い。非 root ステップの実行と同じ規則に揃え、EUID 0 のときは +# 元ユーザーへ委譲する。元ユーザーを特定できなければ probe せず空を返す(各ステップの +# privilege_ok と同じ fail-closed)。 +migrate::ntn_version() { + local ntn_bin="${1}" + local euid_val orig_user + euid_val="$(migrate::euid)" + + if (( euid_val == 0 )); then + migrate::original_user_ok || return 0 + orig_user="$(migrate::original_user)" + notion::installed_version sudo -u "${orig_user}" -H -- "${ntn_bin}" + return 0 + fi + + notion::installed_version "${ntn_bin}" +} + # --------------------------------------------------------------------------- # health check(apply が全ステップ success を報告した後の独立検証) # manifest の自己申告を信用せず、実ファイル/実状態を確認する。 @@ -761,6 +785,27 @@ migrate::health_check() { [[ -f "${home_dir}/.codex/config.toml" ]] || failures+=("codex-sync: ${home_dir}/.codex/config.toml がありません") + # ntn は Homebrew 管理外なので migrate::command_available(Homebrew prefix + # フォールバック)では確認できない。パスと宣言する版は setup/lib/notion.zsh の + # 定義から引く(導入する側と同じ定義を見る)。-f も見るのは、-x だけだと実行ビットの + # 立ったディレクトリを「導入済み」と誤判定するため。 + # + # 版の probe は migrate::ntn_version 経由(root 起動時は元ユーザーへ委譲する)。 + # 版まで見るのは、notion.zsh の install-if-absent が「実体があれば何もしない」ため。 + # 宣言側の版を上げても実機のバイナリは古いまま success になり続ける(新規導入時だけ + # 版が効く状態)。実体を自動で差し替えはしない — ここで fail-closed に落として、人が + # 明示的に実体を削除してから --apply を再実行する経路に寄せる。 + local ntn_bin ntn_version + ntn_bin="$(notion::bin "${home_dir}")" + if [[ -f "${ntn_bin}" && -x "${ntn_bin}" ]]; then + ntn_version="$(migrate::ntn_version "${ntn_bin}")" + if [[ "${ntn_version}" != "${NTN_PINNED_VERSION}" ]]; then + failures+=("notion: ${ntn_bin} の版が宣言と一致しません(宣言: ${NTN_PINNED_VERSION} / 実体: ${ntn_version:-取得できませんでした})。実体を削除してから --apply を再実行してください") + fi + else + failures+=("notion: ${ntn_bin} が実行可能なファイルではありません") + fi + # herdr plugin の allowlist。パスは herdr-sync.zsh と同じ setup/lib/herdr.zsh の # 解決関数から引く(配置する側と確認する側で別々にパスを組み立てると、Herdr が # 設定ディレクトリの位置を変えたときに health check だけが古い場所を見に行く)。 @@ -889,7 +934,7 @@ migrate::usage() { Phase 1: link (root 起動時は元ユーザーへ委譲) Phase 2: cutover, pam (root 必須) -Phase 3: languages, defaults, claude-sync, codex-sync, herdr-sync (root 起動時は元ユーザーへ委譲) +Phase 3: languages, defaults, claude-sync, codex-sync, herdr-sync, notion (root 起動時は元ユーザーへ委譲) 個別スクリプト(link.zsh 等)は内部実装です。実機での実行はこのスクリプトからのみ 行ってください。詳細は setup/README.md を参照。 diff --git a/setup/notion.zsh b/setup/notion.zsh new file mode 100644 index 0000000..5b1f8d7 --- /dev/null +++ b/setup/notion.zsh @@ -0,0 +1,84 @@ +#!/bin/zsh +# setup/notion.zsh +# +# Tier 2: Notion CLI (ntn) を ${HOME}/.local/bin へ導入する。 +# +# Homebrew formula が存在しないため homebrew.nix では宣言できない。npm 版もあるが、 +# グローバルツールを Node ランタイムに依存させないため公式配布バイナリを使う。公式 +# インストーラ (https://ntn.dev/install.sh) に次の 2 つを渡して、導入結果を宣言側で +# 決めきる: +# - NTN_INSTALL_DIR: 導入先を ${HOME}/.local/bin に固定する(インストーラ既定の +# 導入先選択は実行時の PATH の形に依存して揺れ、health check と食い違うため) +# - NTN_VERSION: setup/lib/notion.zsh の NTN_PINNED_VERSION に固定する(既定の latest +# だと「導入した日」で版が決まり、PC ごとに別物が入る)。版を上げるのは dotfiles +# 側の明示変更 +# PATH への ${HOME}/.local/bin の追加は Tier 1 の zshenv が担当する。 +# +# 冪等性: 既に ntn が実行可能なら何もしない。NTN_PINNED_VERSION を上げても既存バイナリは +# 入れ替えない(実行中のバイナリを黙って差し替えないため)。入れ替えるときは実体を消して +# から再実行する。版のずれ自体は migrate.zsh の health check が fail-closed で検出する。 +# +# fail-closed: ntn は恒久的に宣言したグローバル必須ツールなので、初回ダウンロードの +# 失敗は migrate 全体の失敗として扱う(「入らなかったが成功」を健全な状態にしない)。 +# 既にバイナリがある PC では一切ネットワークに出ないため、オフラインでも --apply は通る。 +# +# 認証は扱わない: NOTION_API_KEY 等のトークンをこのスクリプトは読まない・要求しない・ +# 保存しない。導入後の認証は人間が ntn 側の手順で行う。 +# +# 使い方: +# zsh ${HOME}/.dotfiles/setup/notion.zsh +# +# 終了コード: +# 0 成功(既に導入済みで skip した場合も含む) +# 1 curl 不在、インストーラの取得/実行の失敗、または実行後に ntn が現れなかった + +set -eu + +SETUP_DIR="${0:A:h}" +source "${SETUP_DIR}/lib/util.zsh" +source "${SETUP_DIR}/lib/notion.zsh" + +NTN_BIN="$(notion::bin "${HOME}")" +NTN_INSTALL_DIR="${NTN_BIN:h}" +# テストではサンドボックス内のスタブを指す URL に差し替える(実ネットワークに触れない)。 +NTN_INSTALLER_URL="${NTN_INSTALLER_URL:-https://ntn.dev/install.sh}" + +util::info "=== Tier 2: Notion CLI (ntn) ===" + +# -f も見る: -x だけだとディレクトリ(実行ビットが立っている)を「導入済み」と +# 誤判定し、インストーラを呼ばないまま成功して抜けてしまう。 +if [[ -f "${NTN_BIN}" && -x "${NTN_BIN}" ]]; then + util::skip "${NTN_BIN} は既に実行可能です(インストーラを呼びません)" + exit 0 +fi + +if ! command -v curl &>/dev/null; then + util::error "curl が見つかりません。ntn の導入をスキップせず失敗として扱います" + exit 1 +fi + +installer="$(mktemp)" +trap '/bin/rm -f "${installer}"' EXIT + +util::action "公式インストーラを取得します: ${NTN_INSTALLER_URL}" +# curl を pipe で bash に直結しない。取得失敗時に空スクリプトを実行して「成功」に +# 見えてしまうのを防ぐため、ファイルへ落として取得の exit code を確かめる。 +if ! curl -fsSL -o "${installer}" "${NTN_INSTALLER_URL}"; then + util::error "インストーラの取得に失敗しました: ${NTN_INSTALLER_URL}" + exit 1 +fi + +util::action "ntn ${NTN_PINNED_VERSION} を ${NTN_INSTALL_DIR} へ導入します" +mkdir -p "${NTN_INSTALL_DIR}" +# インストーラは bash 前提(#!/usr/bin/env bash)なので zsh では実行しない。 +if ! NTN_INSTALL_DIR="${NTN_INSTALL_DIR}" NTN_VERSION="${NTN_PINNED_VERSION}" bash "${installer}"; then + util::error "インストーラの実行に失敗しました" + exit 1 +fi + +if [[ ! -f "${NTN_BIN}" || ! -x "${NTN_BIN}" ]]; then + util::error "インストーラは成功しましたが ${NTN_BIN} が実行可能になっていません" + exit 1 +fi + +util::info "${NTN_BIN} (${NTN_PINNED_VERSION}) を導入しました" diff --git a/setup/tests/herdr-sync.bats b/setup/tests/herdr-sync.bats index 64caaa1..cbf1fe7 100644 --- a/setup/tests/herdr-sync.bats +++ b/setup/tests/herdr-sync.bats @@ -127,8 +127,10 @@ assert_no_real_herdr() { } @test "migrate.zsh runs herdr-sync as part of phase 3" { - grep -q 'PHASE3_STEPS=(languages defaults claude-sync codex-sync herdr-sync)' \ - "${SETUP_DIR}/migrate.zsh" + # Assert membership, not the exact roster: this test is about herdr-sync + # being a phase 3 step, and pinning the whole line makes every unrelated + # step addition fail here instead of where it belongs. + grep -qE 'PHASE3_STEPS=\(.*\bherdr-sync\b.*\)' "${SETUP_DIR}/migrate.zsh" } @test "migrate health check verifies the allowlist symlink" { diff --git a/setup/tests/migrate.bats b/setup/tests/migrate.bats index 24783ac..58edb0e 100644 --- a/setup/tests/migrate.bats +++ b/setup/tests/migrate.bats @@ -4,11 +4,16 @@ SETUP_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)" REPO_ROOT="$(cd "${SETUP_DIR}/.." && pwd)" -# Full stub bin covering every external command the 7 underlying Tier scripts +# Full stub bin covering every external command the 9 underlying Tier scripts # call, so a real end-to-end `migrate.zsh --apply` run touches nothing real: # mise/corepack (languages), defaults (defaults), git/claude (claude-sync), # darwin-rebuild/nix (cutover). codex-sync/pam need no external command # (SUDO_LOCAL_PATH redirects pam's write target instead of /etc). +# notion uses the real curl, pointed at a file:// URL holding a fake installer +# (NTN_INSTALLER_URL below) -- no stub, no network, and still the real download +# path. A PATH stub would not be reliable here anyway: migrate.zsh prepends the +# hardcoded Homebrew prefixes when delegating, so a real /opt/homebrew/bin/curl +# would win over a stub. _install_full_stubs() { local bin_dir="${1}" mkdir -p "${bin_dir}" @@ -99,6 +104,7 @@ echo "$*" >> "${SUDO_LOG}" if [[ "$1" == "-u" ]]; then shift 2 [[ "$1" == "-H" ]] && shift + [[ "$1" == "--" ]] && shift fi exec "$@" EOF @@ -159,6 +165,24 @@ setup() { # the stub dir so the single-root-invocation test never resolves this # machine's real Homebrew mise (if installed) via the delegated step. export HOMEBREW_PATH_PREFIX_OVERRIDE="${STUB_BIN}" + + # notion.zsh's installer source. Models the one thing the real installer + # contract guarantees and notion.zsh relies on: it honors NTN_INSTALL_DIR. + NTN_INSTALLER_FILE="${BATS_TEST_TMPDIR}/ntn-install.sh" + cat > "${NTN_INSTALLER_FILE}" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +mkdir -p "${NTN_INSTALL_DIR}" +# Report the version that was requested, like the real installer does, so a +# clean sandbox run lands consistent with the declaration without this file +# restating the pinned value. +printf '#!/bin/sh\necho "ntn %s"\n' "${NTN_VERSION}" > "${NTN_INSTALL_DIR}/ntn" +chmod +x "${NTN_INSTALL_DIR}/ntn" +EOF + export NTN_INSTALLER_URL="file://${NTN_INSTALLER_FILE}" + # The single declaration of the pinned version, read from where both + # notion.zsh and migrate.zsh read it. + NTN_EXPECTED_VERSION="$(zsh -c "source '${SETUP_DIR}/lib/notion.zsh'; echo \${NTN_PINNED_VERSION}")" } @test "zsh -n syntax check passes" { @@ -197,11 +221,11 @@ setup() { [[ "${output}" != *"rollback"* ]] } -@test "dry-run lists all 7 steps and executes nothing" { +@test "dry-run lists all 9 steps and executes nothing" { run zsh "${SETUP_DIR}/migrate.zsh" --dry-run [ "${status}" -eq 0 ] - for step in link languages defaults pam claude-sync codex-sync cutover; do + for step in link languages defaults pam claude-sync codex-sync herdr-sync notion cutover; do [[ "${output}" == *"${step}"* ]] done @@ -224,7 +248,7 @@ setup() { MIGRATE_EUID_OVERRIDE=0 MIGRATE_SUDO_USER_OVERRIDE=testuser \ run zsh "${SETUP_DIR}/migrate.zsh" --dry-run [ "${status}" -eq 0 ] - for step in link languages defaults pam claude-sync codex-sync cutover; do + for step in link languages defaults pam claude-sync codex-sync herdr-sync notion cutover; do [[ "${output}" == *"[WOULD RUN] ${step}:"* ]] done [[ "${output}" != *"[BLOCKED]"* ]] @@ -237,7 +261,7 @@ setup() { # so it alone stays WOULD RUN. MIGRATE_EUID_OVERRIDE=0 USER= run zsh "${SETUP_DIR}/migrate.zsh" --dry-run [ "${status}" -eq 0 ] - for step in link languages defaults claude-sync codex-sync cutover; do + for step in link languages defaults claude-sync codex-sync herdr-sync notion cutover; do [[ "${output}" == *"[BLOCKED] ${step}:"* ]] done [[ "${output}" == *"[WOULD RUN] pam:"* ]] @@ -284,6 +308,7 @@ setup() { [ -f "${SUDO_LOCAL_PATH}" ] [ -f "${HOME}/.claude.json" ] [ -f "${HOME}/.codex/config.toml" ] + [ -x "${HOME}/.local/bin/ntn" ] run cat "${DARWIN_REBUILD_LOG}" [[ "${output}" == *"switch --flake"* ]] run cat "${MISE_LOG}" @@ -295,7 +320,7 @@ setup() { # `sudo -u testuser -H env PATH=... zsh