From fd29d26fb4badffcbfcfe79e3ca3642fc1c7c620 Mon Sep 17 00:00:00 2001 From: okash1n <48118431+okash1n@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:40:29 +0900 Subject: [PATCH 1/4] docs: add macOS launchd native daemon design spec --- .../specs/2026-07-07-macos-launchd-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-macos-launchd-design.md diff --git a/docs/superpowers/specs/2026-07-07-macos-launchd-design.md b/docs/superpowers/specs/2026-07-07-macos-launchd-design.md new file mode 100644 index 0000000..f67f382 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-macos-launchd-design.md @@ -0,0 +1,73 @@ +# calsync macOS ネイティブ常駐(launchd)設計書 + +作成日: 2026-07-07 +ステータス: 承認済みドラフト(実装計画の入力) +関連: [2026-07-03-calsync-design.md](2026-07-03-calsync-design.md)(v1 全体設計) + +## 1. 概要・動機 + +macOS では Docker Desktop 経由の常駐をやめ、**calsync バイナリを launchd の LaunchAgent として直接常駐**できるようにする。Linux では従来どおり Docker / compose 運用を維持する(本機能で Docker サポートは一切変更しない)。 + +動機(2026-07-06〜07 実障害): Docker Desktop の自動アップデートで仮想ネットワークが壊れ、calsync が 16 時間無言停止した。macOS の Docker Desktop は「VM+GUI アプリ+自動更新」という不安定要素の積み重ねであり、純 Go・CGO なし・単一静的バイナリの calsync にとってコンテナの利点はほぼない。ネイティブ化で以下が根本解消する: + +- Docker Desktop / VM 境界起因の停止(今回の障害クラス) +- VirtioFS 境界の SQLite 破損リスク(CLAUDE.md 既知の落とし穴 — ネイティブでは flock / WAL が同一ホスト内で正しく機能する) +- スリープ復帰: launchd 常駐ならプロセスは起床後の最初の tick で継続し、当日分のダイジェストは遅延送信される(nextAt 方式の `now >= next` 判定は wall clock のため。日付を跨いだ分は既存仕様どおり放棄) + +**Go コードの変更はゼロ**。追加するのはインストールスクリプト・plist テンプレート・ドキュメントのみ。 + +## 2. 成果物 + +| ファイル | 内容 | +| --- | --- | +| `scripts/macos/com.btajp.calsync.plist.template` | LaunchAgent テンプレート(プレースホルダ置換方式) | +| `scripts/macos/install-launchd.sh` | ビルド → 配置 → plist 生成 → `launchctl bootstrap` まで行う冪等スクリプト | +| `scripts/macos/uninstall-launchd.sh` | `launchctl bootout` + plist 削除(バイナリ・data/ は残す) | +| README / CLAUDE.md / calsync-setup スキル / CHANGELOG | 運用手順と注意の更新 | + +## 3. 設計 + +### 3.1 LaunchAgent(plist) + +- ラベル: `com.btajp.calsync`。配置先: `~/Library/LaunchAgents/com.btajp.calsync.plist`(**chmod 600** — トークンを含むため) +- `ProgramArguments`: `/calsync run --config /calsync.yaml --data `(すべて絶対パス) +- `RunAtLoad: true` + `KeepAlive: true` — ログイン時自動起動+クラッシュ自動再起動(`restart: unless-stopped` 相当) +- `EnvironmentVariables`: `SLACK_TOKEN_CALSYNC`(または `bot_token_env` で指定した名前)を**plist に直接埋め込む**。launchd の LaunchAgent はシェルプロファイルを読まないため env はここで渡すしかない。ファイルは 600 で保護し、インストールスクリプトが現在のシェル環境変数から値を転記する +- `TZ` は指定しない(システムのタイムゾーンを継承 — `reconcile_at` / `morning_digest` はシステム TZ で解釈される。compose の `TZ: Asia/Tokyo` と等価になることを README に明記) +- ログ: `StandardOutPath` / `StandardErrorPath` → `~/Library/Logs/calsync.log`(単一ファイルに集約) +- `ThrottleInterval: 10`(クラッシュループ時の再起動間隔) + +### 3.2 install-launchd.sh(冪等) + +1. 前提チェック: macOS であること / リポジトリルートで実行 / `bot_token_env` の環境変数が現在のシェルに存在すること(なければ名前を表示してエラー) +2. `go build -o ~/.local/bin/calsync ./cmd/calsync`(配置先は `CALSYNC_BIN` で上書き可。リポジトリ内の作業ツリーを指さない — checkout に影響されない安定パスに置く) +3. data ディレクトリ(既定 `/data`、`CALSYNC_DATA` で上書き可)の存在確認 +4. **稼働中の Docker コンテナがあれば警告して中断**(`docker compose ps` が calsync を返す場合。二重起動は flock で防がれるが、明示的に「先に `docker compose down` せよ」と案内する — 移行事故防止) +5. テンプレートからプレースホルダ(`__BIN__` / `__DATA__` / `__TOKEN_ENV_NAME__` / `__TOKEN_VALUE__` / `__LOG__`)を置換して plist 生成・600 で配置 +6. `launchctl bootout gui/$UID/com.btajp.calsync 2>/dev/null || true` → `launchctl bootstrap gui/$UID ` → `launchctl kickstart gui/$UID/com.btajp.calsync` +7. 起動確認(`launchctl print gui/$UID/com.btajp.calsync` の state と、ログ末尾のエラー有無)を表示して終了 + +更新フロー(README に明記): `git pull` → `install-launchd.sh` 再実行(再ビルド+plist 再生成+再起動まで冪等にやり直す)。 + +### 3.3 運用上の注意(ドキュメントに明記) + +- **移行**: `docker compose down` → `install-launchd.sh`。data/ は同一ファイルをそのまま使う(単一プロセスの flock 前提は維持される) +- CLAUDE.md の「コンテナ稼働中はホストから SQLite に触るな」は**コンテナ運用時の注意**である旨に更新(ネイティブ運用では同一ホストの `status` / `doctor`(OpenReadOnly)は安全 — 従来どおり。書き込み系はデーモン停止中のみ、は共通) +- スリープ: Mac がスリープ中は同期・通知とも停止し、起床後の最初の tick で再開する。`morning_digest` 時刻にスリープしていた場合、**同日中に起床すれば遅延送信される**(日付を跨いだら既存仕様どおり放棄) +- トークンを変更した場合は `install-launchd.sh` を再実行(plist に転記し直す) +- アンインストール: `uninstall-launchd.sh`(データは消さない) + +## 4. テスト計画 + +Go コード変更ゼロのため単体テストは不要。検証は実機で行う: + +1. `bash -n` による両スクリプトの構文チェック+`shellcheck`(あれば) +2. 実機で install → `launchctl print` で running 確認 → `calsync status`(OpenReadOnly)で全アカウント ok → Slack テスト発火(ダイジェスト一時時刻) +3. `kill` でプロセスを落とし、KeepAlive による自動再起動を確認 +4. uninstall → プロセス消滅と plist 削除を確認 → 再 install(冪等性) + +## 5. スコープ外 + +- Linux の systemd unit(需要が出たら同型で追加可能) +- Apple Container 対応(VM 境界が残るため見送り — 検討記録として本スペックに残す) +- Docker サポートの変更(一切なし。Linux とリモート運用の標準は compose のまま) From 4ed708ff9b451d09fe1ff2bcc0f1f6f48e1dbf72 Mon Sep 17 00:00:00 2001 From: okash1n <48118431+okash1n@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:46:37 +0900 Subject: [PATCH 2/4] feat: add native launchd daemon support for macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Desktop の VM/自動更新起因の停止(2026-07-06〜07 実障害)を回避するため、 macOS では calsync バイナリを launchd の LaunchAgent として直接常駐できるように した。Go コードの変更はゼロで、追加は plist テンプレートとインストール/アンイ ンストールスクリプト、ドキュメントのみ。Linux / Docker 運用は変更なし。 --- .agents/skills/calsync-setup/SKILL.md | 10 +- AGENTS.md | 2 +- CHANGELOG.md | 1 + README.md | 58 ++++++ .../macos/com.btajp.calsync.plist.template | 69 +++++++ scripts/macos/install-launchd.sh | 174 ++++++++++++++++++ scripts/macos/uninstall-launchd.sh | 26 +++ 7 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 scripts/macos/com.btajp.calsync.plist.template create mode 100755 scripts/macos/install-launchd.sh create mode 100755 scripts/macos/uninstall-launchd.sh diff --git a/.agents/skills/calsync-setup/SKILL.md b/.agents/skills/calsync-setup/SKILL.md index 9afb7f1..9f7d263 100644 --- a/.agents/skills/calsync-setup/SKILL.md +++ b/.agents/skills/calsync-setup/SKILL.md @@ -74,9 +74,13 @@ curl -s -H "Authorization: Bearer $TOKEN" \ ## Step 6: 常駐化 -- 推奨(配布形態): `docker compose up -d --build`。設定は `./data/calsync.yaml`、TZ は compose の `TZ` 環境変数(reconcile_at の解釈に使われる) -- コンテナ稼働中の状態確認は `docker compose logs` と `docker exec calsync /calsync status --config /data/calsync.yaml --data /data`。**コンテナ稼働中はホストから data/ の SQLite に一切触れない(読み取りの sqlite3 も禁止 — WAL は VM 境界を跨げず DB 破損の実績あり)。書き込み系コマンドはコンテナ停止中のみ** -- アカウントを後から追加する場合: 認可(Step 4)はホストで行い、コンテナを再起動 +常駐方式はユーザーの OS で分岐する。**先にどちらか確認すること**。 + +- **macOS**: `launchd` によるネイティブ常駐を推奨する(Docker Desktop の VM/自動更新起因の停止を避けられる)。`./scripts/macos/install-launchd.sh` を実行するだけ(冪等。ビルド → plist 生成 → 登録 → 起動まで自動)。トークンを変更したときも同じスクリプトを再実行する。アンインストールは `./scripts/macos/uninstall-launchd.sh`(バイナリ・data/ は残る)。詳細は README「macOS ネイティブ常駐(推奨)」節を参照 + - この場合、同一ホストの `status` / `doctor`(OpenReadOnly)はデーモン稼働中でも安全(VirtioFS 境界が存在しないため)。書き込み系コマンド(`sync` / `reconcile` / `accounts remove`)はデーモン停止中のみ、という制約はコンテナ運用と共通 +- **Linux / その他(標準)**: `docker compose up -d --build`。設定は `./data/calsync.yaml`、TZ は compose の `TZ` 環境変数(reconcile_at の解釈に使われる) + - コンテナ稼働中の状態確認は `docker compose logs` と `docker exec calsync /calsync status --config /data/calsync.yaml --data /data`。**コンテナ稼働中はホストから data/ の SQLite に一切触れない(読み取りの sqlite3 も禁止 — WAL は VM 境界を跨げず DB 破損の実績あり)。書き込み系コマンドはコンテナ停止中のみ** +- アカウントを後から追加する場合: 認可(Step 4)はホストで行い、常駐プロセス(launchd または コンテナ)を再起動 ## Step 7: Slack 通知のセットアップ(オプション) diff --git a/AGENTS.md b/AGENTS.md index 7dcb962..50a78a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,5 +55,5 @@ docker compose config -q # compose 構文チェック - GCP OAuth 同意画面が Testing のままだと refresh token が7日失効 → External は必ず「In production」に publish - Google Workspace 管理対象アカウントは未検証アプリ+機微スコープを強制ブロック(個人アカウントと違いクリックスルー不可)→ 管理コンソールの「API の制御 → アプリのアクセス制御」で client ID を「信頼できる」に登録 - Entra アプリ登録直後は MSA(login.live.com)への伝播ラグで `invalid_request` が出ることがある → 数分待って再試行 -- **コンテナ稼働中、ホストから data/ の SQLite に一切アクセスしない(読み取りも禁止)**。WAL の共有メモリ/ロックは VirtioFS 境界を跨いで機能せず、ホスト側 sqlite3 の読み取りだけで DB が物理破損した実績がある(2026-07-04)。稼働中の状態確認は `docker compose logs` と `docker exec calsync /calsync status --config /data/calsync.yaml --data /data` を使う。flock も境界を跨いで効かないため、ホストの書き込み系コマンドはコンテナ停止中のみ +- **(コンテナ運用時)コンテナ稼働中、ホストから data/ の SQLite に一切アクセスしない(読み取りも禁止)**。WAL の共有メモリ/ロックは VirtioFS 境界を跨いで機能せず、ホスト側 sqlite3 の読み取りだけで DB が物理破損した実績がある(2026-07-04)。稼働中の状態確認は `docker compose logs` と `docker exec calsync /calsync status --config /data/calsync.yaml --data /data` を使う。flock も境界を跨いで効かないため、ホストの書き込み系コマンドはコンテナ停止中のみ。**macOS ネイティブ運用(launchd)では VirtioFS 境界自体が存在しないため、同一ホストの `status` / `doctor`(OpenReadOnly)はデーモン稼働中でも安全**。書き込み系コマンド(`sync` / `reconcile` / `accounts remove`)はネイティブ・コンテナ問わずデーモン停止中のみ、という制約は共通 - DB を失っても `calsync reconcile` がタグから全再構築する(フェーズ0が mappings を先行復元)。再構築系の変更をするときは「Graph delta はタグを返せない」前提を絶対に崩さないこと diff --git a/CHANGELOG.md b/CHANGELOG.md index 2465e90..9d07405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- **macOS ネイティブ常駐(launchd)**: Docker Desktop の VM/自動更新起因の停止を避けるため、calsync バイナリを `launchd` の LaunchAgent として直接常駐できるように(`scripts/macos/install-launchd.sh` / `uninstall-launchd.sh` / plist テンプレート)。Go コードの変更なし。Linux / Docker 運用は変更なし - **通知専用カレンダー(`digest_calendars`)**: ブロッカー配布せずダイジェストにだけ載せるカレンダーを指定可能に(google のみ・リマインド対象外) - **Slack 通知の Block Kit 化(v2)**: ダイジェストを予定ごとのブロック表示にし、件名をカレンダーの当該予定へのリンクに、Zoom / Meet / Teams の会議 URL を「参加」ボタンにした(conferenceData / onlineMeeting → location・本文の URL 検出の順で抽出)。開始前リマインドに会議参加ボタンと本文全文(プレーンテキスト化・3,000 字制限内に切り詰め)を追加。通知プレビューには従来のテキスト形式を fallback として維持し、blocks が不正な場合はテキストのみで 1 回縮退再送する - **Slack 通知 v2.1(実表示フィードバック反映)**: ダイジェストの予定行を予定ごとの色付き attachment(`accounts` の定義順で固定パレットを巡回割当、未知アカウントは灰色)に変更し、表示上限を 46 件から 20 件に短縮(超過は「他 N 件」)。リマインドも単一 attachment 化。`chat.postMessage` に `unfurl_links` / `unfurl_media` を常に付与し htmlLink・会議 URL のプレビュー展開を抑止。縮退再送のトリガーに `invalid_attachments` を追加 diff --git a/README.md b/README.md index e364499..2a4d74b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,64 @@ docker compose run --rm --entrypoint /calsync calsync \ ## 起動 +常駐方式は OS によって使い分けます。**macOS では launchd によるネイティブ常駐が推奨**です。Linux やリモートサーバーでは従来どおり Docker を使います。 + +### macOS ネイティブ常駐(推奨) + +Docker Desktop(VM + GUI アプリ + 自動更新)は不安定要素が多く、純 Go・CGO なしの単一バイナリである calsync にとってコンテナの利点はほとんどありません。macOS では `launchd` の LaunchAgent として直接常駐させます。 + +```bash +./scripts/macos/install-launchd.sh +``` + +このスクリプトは冪等です(何度実行してもビルド → plist 再生成 → 再登録 → 再起動をやり直すだけ)。内部で行うこと: + +1. macOS であること・データディレクトリ(既定 `./data`)・`calsync.yaml` の存在を確認 +2. `calsync.yaml` に `notifications` が設定されていれば、`bot_token_env`(既定 `SLACK_BOT_TOKEN`)の環境変数が現在のシェルに存在することを確認(なければエラーで中断) +3. `go build` でバイナリを `~/.local/bin/calsync` に配置(`CALSYNC_BIN` で上書き可) +4. Docker の calsync コンテナが稼働中なら中断し、`docker compose down` を案内(移行時の二重運用事故防止。docker コマンドが無い/デーモン停止中は黙ってスキップ) +5. `scripts/macos/com.btajp.calsync.plist.template` からプレースホルダを置換して `~/Library/LaunchAgents/com.btajp.calsync.plist` を生成(`chmod 600` — トークンを含むため) +6. `launchctl bootout` → `bootstrap` → `kickstart` で登録・起動 +7. `launchctl print` の起動状態と、ログ(`~/Library/Logs/calsync.log`)末尾を表示 + +環境変数による上書き: + +| 変数 | 内容 | 既定値 | +| --- | --- | --- | +| `CALSYNC_BIN` | バイナリの配置ディレクトリ | `~/.local/bin` | +| `CALSYNC_DATA` | データディレクトリ(`calsync.yaml` の場所) | `<リポジトリ>/data` | + +**更新フロー**: `git pull` してから `./scripts/macos/install-launchd.sh` を再実行するだけです(再ビルド・plist 再生成・再起動まで冪等にやり直します)。**トークンを変更した場合も同じスクリプトを再実行**してください(plist に転記し直されます)。 + +**スリープ挙動**: Mac がスリープ中は同期・通知とも停止し、起床後の最初の tick で再開します。`morning_digest` の時刻にスリープしていた場合、**同日中に起床すれば遅延送信されます**(日付を跨いだ分は放棄される既存仕様どおりです)。 + +状態確認・アンインストール: + +```bash +launchctl print gui/$(id -u)/com.btajp.calsync # 稼働状態 +tail -f ~/Library/Logs/calsync.log # ログ +./scripts/macos/uninstall-launchd.sh # アンインストール(バイナリ・data/ は残る) +``` + +`calsync status` / `calsync doctor` はデータディレクトリの排他ロックを取得しない読み取り専用コマンドなので、ネイティブ運用では**デーモン稼働中でもホストからそのまま実行できます**(コンテナ運用時の制約とは異なります。詳細は [CLAUDE.md](CLAUDE.md) を参照): + +```bash +./calsync status --data ./data +./calsync doctor --config ./data/calsync.yaml --data ./data +``` + +一方 `calsync sync` / `calsync reconcile` / `calsync accounts remove` はデータディレクトリの排他ロックを取得するため、実行前にデーモンを停止してください: + +```bash +launchctl bootout gui/$(id -u)/com.btajp.calsync +./calsync reconcile --config ./data/calsync.yaml --data ./data +./scripts/macos/install-launchd.sh # 再起動(再ビルドと再登録も兼ねる) +``` + +### Linux / その他(標準) + +macOS 以外(および macOS 上での動作確認・検証用途)では Docker Compose で常駐します。calsync 本体への変更は一切ないため、この節の手順は従来どおりです。 + ```bash docker compose up -d --build docker compose logs -f calsync diff --git a/scripts/macos/com.btajp.calsync.plist.template b/scripts/macos/com.btajp.calsync.plist.template new file mode 100644 index 0000000..6ec7f24 --- /dev/null +++ b/scripts/macos/com.btajp.calsync.plist.template @@ -0,0 +1,69 @@ + + + + + + Label + com.btajp.calsync + + + ProgramArguments + + __BIN__/calsync + run + --config + __DATA__/calsync.yaml + --data + __DATA__ + + + + RunAtLoad + + + + KeepAlive + + + + + EnvironmentVariables + + __TOKEN_ENV_NAME__ + __TOKEN_VALUE__ + + + + + + + StandardOutPath + __LOG__ + + StandardErrorPath + __LOG__ + + + ThrottleInterval + 10 + + diff --git a/scripts/macos/install-launchd.sh b/scripts/macos/install-launchd.sh new file mode 100755 index 0000000..33faf66 --- /dev/null +++ b/scripts/macos/install-launchd.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# calsync macOS ネイティブ常駐(launchd)インストーラ +# +# 設計の正: docs/superpowers/specs/2026-07-07-macos-launchd-design.md (3.2節) +# 冪等: 何度実行してもビルド → plist 再生成 → 再登録 → 再起動をやり直すだけで安全。 +# +# 環境変数による上書き: +# CALSYNC_BIN calsync バイナリの配置ディレクトリ(既定 ~/.local/bin) +# CALSYNC_DATA データディレクトリ(既定 /data。calsync.yaml もここに置く) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +LABEL="com.btajp.calsync" +BIN_DIR="${CALSYNC_BIN:-${HOME}/.local/bin}" +DATA_DIR="${CALSYNC_DATA:-${REPO_ROOT}/data}" +LOG_FILE="${HOME}/Library/Logs/calsync.log" +PLIST_DIR="${HOME}/Library/LaunchAgents" +PLIST_PATH="${PLIST_DIR}/${LABEL}.plist" +TEMPLATE_PATH="${SCRIPT_DIR}/com.btajp.calsync.plist.template" +CONFIG_PATH="${DATA_DIR}/calsync.yaml" +COMPOSE_FILE="${REPO_ROOT}/docker-compose.yaml" + +log() { echo "[install-launchd] $*"; } +die() { echo "[install-launchd] エラー: $*" >&2; exit 1; } + +# --- 1. 前提チェック ------------------------------------------------------- + +if [[ "$(uname -s)" != "Darwin" ]]; then + die "このスクリプトは macOS 専用です" +fi + +if [[ ! -d "${DATA_DIR}" ]]; then + die "データディレクトリが見つかりません: ${DATA_DIR}(CALSYNC_DATA で変更可)" +fi + +if [[ ! -f "${CONFIG_PATH}" ]]; then + die "設定ファイルが見つかりません: ${CONFIG_PATH}" +fi + +if [[ ! -f "${TEMPLATE_PATH}" ]]; then + die "plist テンプレートが見つかりません: ${TEMPLATE_PATH}" +fi + +TOKEN_ENV_NAME="" +TOKEN_VALUE="" +if grep -Eq '^[[:space:]]*notifications:[[:space:]]*$' "${CONFIG_PATH}"; then + TOKEN_ENV_NAME="$(grep -m1 -E '^[[:space:]]*bot_token_env:' "${CONFIG_PATH}" \ + | sed -E 's/^[^:]*:[[:space:]]*"?([A-Za-z_][A-Za-z0-9_]*)"?.*/\1/')" + if [[ -z "${TOKEN_ENV_NAME}" ]]; then + TOKEN_ENV_NAME="SLACK_BOT_TOKEN" + log "bot_token_env の指定がないため既定値 ${TOKEN_ENV_NAME} を使います" + fi + + TOKEN_VALUE="${!TOKEN_ENV_NAME:-}" + if [[ -z "${TOKEN_VALUE}" ]]; then + die "notifications が設定されていますが、環境変数 ${TOKEN_ENV_NAME} が現在のシェルに存在しません(または空です)。export ${TOKEN_ENV_NAME}=xoxb-... のあと再実行してください" + fi + log "Slack 通知用トークンを環境変数 ${TOKEN_ENV_NAME} から取得しました" +else + log "notifications 未設定のため、トークンチェックをスキップします(EnvironmentVariables なしの plist を生成します)" +fi + +# --- 2. ビルド -------------------------------------------------------------- + +mkdir -p "${BIN_DIR}" +log "calsync をビルドします: ${BIN_DIR}/calsync" +( cd "${REPO_ROOT}" && go build -o "${BIN_DIR}/calsync" ./cmd/calsync ) + +# --- 3. データディレクトリ確認(1.で実施済み) -------------------------------- +log "データディレクトリ: ${DATA_DIR}" + +# --- 4. Docker 稼働中コンテナのチェック -------------------------------------- + +if command -v docker >/dev/null 2>&1; then + if docker info >/dev/null 2>&1; then + running_services="" + if [[ -f "${COMPOSE_FILE}" ]]; then + running_services="$(docker compose -f "${COMPOSE_FILE}" ps --status running --services 2>/dev/null || true)" + fi + if grep -qx "calsync" <<< "${running_services}"; then + die "Docker の calsync コンテナが稼働中です。先に 'docker compose down' を実行してから再実行してください(移行時の二重運用事故防止)" + fi + log "Docker の calsync コンテナは稼働していません" + else + log "Docker デーモンが起動していないため、コンテナチェックをスキップします" + fi +else + log "docker コマンドが見つからないため、コンテナチェックをスキップします" +fi + +# --- 5. plist 生成(sed ではなく python3。トークンの特殊文字対策) ----------- + +mkdir -p "${PLIST_DIR}" +mkdir -p "$(dirname "${LOG_FILE}")" +touch "${LOG_FILE}" + +log "plist を生成します: ${PLIST_PATH}" +CALSYNC_BIN_DIR="${BIN_DIR}" \ +CALSYNC_DATA_DIR="${DATA_DIR}" \ +CALSYNC_LOG_FILE="${LOG_FILE}" \ +CALSYNC_TOKEN_ENV_NAME="${TOKEN_ENV_NAME}" \ +CALSYNC_TOKEN_VALUE="${TOKEN_VALUE}" \ +CALSYNC_TEMPLATE_PATH="${TEMPLATE_PATH}" \ +CALSYNC_PLIST_PATH="${PLIST_PATH}" \ +python3 - <<'PYEOF' +import os + +template_path = os.environ["CALSYNC_TEMPLATE_PATH"] +plist_path = os.environ["CALSYNC_PLIST_PATH"] +bin_dir = os.environ["CALSYNC_BIN_DIR"] +data_dir = os.environ["CALSYNC_DATA_DIR"] +log_file = os.environ["CALSYNC_LOG_FILE"] +token_env_name = os.environ.get("CALSYNC_TOKEN_ENV_NAME", "") +token_value = os.environ.get("CALSYNC_TOKEN_VALUE", "") + +with open(template_path, "r", encoding="utf-8") as f: + content = f.read() + +start_marker = "" +end_marker = "" +start = content.index(start_marker) +end = content.index(end_marker) + len(end_marker) + +if token_env_name: + # notifications 設定あり: マーカーだけ剥がしてトークン名・値を埋め込む + block = content[start:end] + block = block.replace(start_marker, "").replace(end_marker, "") + block = block.replace("__TOKEN_ENV_NAME__", token_env_name) + block = block.replace("__TOKEN_VALUE__", token_value) + content = content[:start] + block + content[end:] +else: + # notifications 未設定: EnvironmentVariables ブロックごと削除 + content = content[:start] + content[end:] + +content = content.replace("__BIN__", bin_dir) +content = content.replace("__DATA__", data_dir) +content = content.replace("__LOG__", log_file) + +# トークンを含みうるため、最初から 600 で作成する(作成後の chmod だと +# 一瞬でも既定パーミッションの窓が空くため os.open の mode で直接指定) +fd = os.open(plist_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) +os.chmod(plist_path, 0o600) +PYEOF + +log "plist を配置しました(600): ${PLIST_PATH}" + +# --- 6. 登録・起動 ----------------------------------------------------------- + +UID_NUM="$(id -u)" +DOMAIN="gui/${UID_NUM}" + +log "既存の LaunchAgent を解除します(未登録なら何もしません)" +launchctl bootout "${DOMAIN}/${LABEL}" >/dev/null 2>&1 || true + +log "LaunchAgent を登録します" +launchctl bootstrap "${DOMAIN}" "${PLIST_PATH}" + +log "起動します" +launchctl kickstart "${DOMAIN}/${LABEL}" + +# --- 7. 起動確認 -------------------------------------------------------------- + +sleep 1 +log "起動状態:" +launchctl print "${DOMAIN}/${LABEL}" 2>/dev/null | grep -E "state = " || log " (状態を取得できませんでした。launchctl print ${DOMAIN}/${LABEL} で確認してください)" + +log "ログ末尾(${LOG_FILE}):" +tail -n 3 "${LOG_FILE}" 2>/dev/null || true + +log "完了しました" diff --git a/scripts/macos/uninstall-launchd.sh b/scripts/macos/uninstall-launchd.sh new file mode 100755 index 0000000..bd35990 --- /dev/null +++ b/scripts/macos/uninstall-launchd.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# calsync macOS ネイティブ常駐(launchd)アンインストーラ +# +# 設計の正: docs/superpowers/specs/2026-07-07-macos-launchd-design.md (3.3節) +# launchctl bootout + plist 削除のみ行う。バイナリ(CALSYNC_BIN)と +# データディレクトリ(CALSYNC_DATA)は削除しない。冪等(未登録・未配置でも成功する)。 +set -euo pipefail + +LABEL="com.btajp.calsync" +PLIST_PATH="${HOME}/Library/LaunchAgents/${LABEL}.plist" +UID_NUM="$(id -u)" +DOMAIN="gui/${UID_NUM}" + +log() { echo "[uninstall-launchd] $*"; } + +log "LaunchAgent を解除します(未登録なら何もしません)" +launchctl bootout "${DOMAIN}/${LABEL}" >/dev/null 2>&1 || true + +if [[ -f "${PLIST_PATH}" ]]; then + log "plist を削除します: ${PLIST_PATH}" + rm -f "${PLIST_PATH}" +else + log "plist は既に存在しません(スキップ): ${PLIST_PATH}" +fi + +log "完了しました(バイナリと data/ は削除していません)" From ce7a328cc8560c4a7f4a8ac761fb5b5735ea7b16 Mon Sep 17 00:00:00 2001 From: okash1n <48118431+okash1n@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:52:53 +0900 Subject: [PATCH 3/4] fix: xml-escape plist values and make launchctl kickstart idempotent --- scripts/macos/install-launchd.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/macos/install-launchd.sh b/scripts/macos/install-launchd.sh index 33faf66..a98f707 100755 --- a/scripts/macos/install-launchd.sh +++ b/scripts/macos/install-launchd.sh @@ -31,6 +31,10 @@ if [[ "$(uname -s)" != "Darwin" ]]; then die "このスクリプトは macOS 専用です" fi +if ! command -v python3 >/dev/null 2>&1; then + die "python3 が見つかりません。Xcode Command Line Tools をインストールしてください(xcode-select --install)" +fi + if [[ ! -d "${DATA_DIR}" ]]; then die "データディレクトリが見つかりません: ${DATA_DIR}(CALSYNC_DATA で変更可)" fi @@ -45,6 +49,9 @@ fi TOKEN_ENV_NAME="" TOKEN_VALUE="" +# 注意: この正規表現はトップレベルのブロック形式キー(`notifications:` が行末で +# 単独)のみを検知する。フロースタイル(`notifications: {...}`)やコメントアウト・ +# インデント違いなど YAML の他の書き方には対応していない。 if grep -Eq '^[[:space:]]*notifications:[[:space:]]*$' "${CONFIG_PATH}"; then TOKEN_ENV_NAME="$(grep -m1 -E '^[[:space:]]*bot_token_env:' "${CONFIG_PATH}" \ | sed -E 's/^[^:]*:[[:space:]]*"?([A-Za-z_][A-Za-z0-9_]*)"?.*/\1/')" @@ -106,14 +113,17 @@ CALSYNC_TEMPLATE_PATH="${TEMPLATE_PATH}" \ CALSYNC_PLIST_PATH="${PLIST_PATH}" \ python3 - <<'PYEOF' import os +from xml.sax.saxutils import escape template_path = os.environ["CALSYNC_TEMPLATE_PATH"] plist_path = os.environ["CALSYNC_PLIST_PATH"] -bin_dir = os.environ["CALSYNC_BIN_DIR"] -data_dir = os.environ["CALSYNC_DATA_DIR"] -log_file = os.environ["CALSYNC_LOG_FILE"] -token_env_name = os.environ.get("CALSYNC_TOKEN_ENV_NAME", "") -token_value = os.environ.get("CALSYNC_TOKEN_VALUE", "") +# plist は XML なので、埋め込み値に & < > が含まれると構造が壊れる。 +# xml.sax.saxutils.escape() で実体参照化してから置換する。 +bin_dir = escape(os.environ["CALSYNC_BIN_DIR"]) +data_dir = escape(os.environ["CALSYNC_DATA_DIR"]) +log_file = escape(os.environ["CALSYNC_LOG_FILE"]) +token_env_name = escape(os.environ.get("CALSYNC_TOKEN_ENV_NAME", "")) +token_value = escape(os.environ.get("CALSYNC_TOKEN_VALUE", "")) with open(template_path, "r", encoding="utf-8") as f: content = f.read() @@ -160,7 +170,9 @@ log "LaunchAgent を登録します" launchctl bootstrap "${DOMAIN}" "${PLIST_PATH}" log "起動します" -launchctl kickstart "${DOMAIN}/${LABEL}" +# -k: 既に稼働中なら kill してから再起動、未稼働なら起動する。 +# bootstrap の RunAtLoad で既に起動済みの状態でも set -e で落ちないための冪等化。 +launchctl kickstart -k "${DOMAIN}/${LABEL}" # --- 7. 起動確認 -------------------------------------------------------------- From 3b10a033eec3ea85b70f3e62446ca7a29f53a7d7 Mon Sep 17 00:00:00 2001 From: okash1n <48118431+okash1n@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:58:19 +0900 Subject: [PATCH 4/4] fix: wait for bootout completion and reject container credential paths --- README.md | 2 +- scripts/macos/install-launchd.sh | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a4d74b..7e65bcf 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Docker Desktop(VM + GUI アプリ + 自動更新)は不安定要素が多く、 このスクリプトは冪等です(何度実行してもビルド → plist 再生成 → 再登録 → 再起動をやり直すだけ)。内部で行うこと: -1. macOS であること・データディレクトリ(既定 `./data`)・`calsync.yaml` の存在を確認 +1. macOS であること・データディレクトリ(既定 `./data`)・`calsync.yaml` の存在を確認。**コンテナ運用から移行する場合は `calsync.yaml` の `credentials_file` をコンテナ内パス(`/data/google-client.json`)からホストの絶対パスに変更しておくこと**(スクリプトが検知してエラーにします — 実測でクラッシュループになった移行時の落とし穴) 2. `calsync.yaml` に `notifications` が設定されていれば、`bot_token_env`(既定 `SLACK_BOT_TOKEN`)の環境変数が現在のシェルに存在することを確認(なければエラーで中断) 3. `go build` でバイナリを `~/.local/bin/calsync` に配置(`CALSYNC_BIN` で上書き可) 4. Docker の calsync コンテナが稼働中なら中断し、`docker compose down` を案内(移行時の二重運用事故防止。docker コマンドが無い/デーモン停止中は黙ってスキップ) diff --git a/scripts/macos/install-launchd.sh b/scripts/macos/install-launchd.sh index a98f707..c5bf397 100755 --- a/scripts/macos/install-launchd.sh +++ b/scripts/macos/install-launchd.sh @@ -43,6 +43,12 @@ if [[ ! -f "${CONFIG_PATH}" ]]; then die "設定ファイルが見つかりません: ${CONFIG_PATH}" fi +# コンテナ運用からの移行時の落とし穴(実測 2026-07-07): credentials_file が +# コンテナ内パス(/data/...)のままだとネイティブでは読めずクラッシュループする。 +if grep -qE '^[[:space:]]*credentials_file:[[:space:]]*/data/' "${CONFIG_PATH}"; then + die "calsync.yaml の credentials_file がコンテナ内パス(/data/...)のままです。ホストの絶対パス(例: ${DATA_DIR}/google-client.json)に変更してください" +fi + if [[ ! -f "${TEMPLATE_PATH}" ]]; then die "plist テンプレートが見つかりません: ${TEMPLATE_PATH}" fi @@ -165,9 +171,25 @@ DOMAIN="gui/${UID_NUM}" log "既存の LaunchAgent を解除します(未登録なら何もしません)" launchctl bootout "${DOMAIN}/${LABEL}" >/dev/null 2>&1 || true +# bootout は非同期に完了することがあり、直後の bootstrap が EIO(5)で失敗する +# (実測 2026-07-07)。解除の完了(print が失敗する状態)を待ってから登録する。 +for _ in $(seq 1 20); do + launchctl print "${DOMAIN}/${LABEL}" >/dev/null 2>&1 || break + sleep 0.5 +done log "LaunchAgent を登録します" -launchctl bootstrap "${DOMAIN}" "${PLIST_PATH}" +bootstrap_ok=0 +for _ in $(seq 1 5); do + if launchctl bootstrap "${DOMAIN}" "${PLIST_PATH}" 2>/dev/null; then + bootstrap_ok=1 + break + fi + sleep 1 +done +if [[ "${bootstrap_ok}" != 1 ]]; then + die "launchctl bootstrap に失敗しました(数秒おいて再実行してください)" +fi log "起動します" # -k: 既に稼働中なら kill してから再起動、未稼働なら起動する。