diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d92d2dc..f490d88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,6 +226,8 @@ jobs: name: "Test User" email: "test@example.com" work_email: "test@work.example.com" + ghtkn_client_ids: + read: "Iv1.test" EOF - name: Test chezmoi init (dry-run) diff --git a/.gitignore b/.gitignore index a48acbe..b15a79b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,6 @@ home/dot_local/share/devbox/global/default/devbox.lock .claude/ .firecrawl/ -# Work-only ghtkn GitHub App configuration must not enter this public repository -home/dot_config/ghtkn/ - # Python tooling caches (pytest harness under tests/skills/) __pycache__/ .pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 228cd98..207dde8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,12 @@ just diff # Show pending chezmoi changes ### Key Data Variables (defined in `home/.chezmoi.yaml.tmpl`) -| Variable | Description | -| -------------------------------- | ---------------------------------------------- | -| `.business_use` | `true` if `BUSINESS_USE=1` was set during init | -| `.osid` | OS identifier (e.g., `darwin`, `linux-ubuntu`) | -| `.name`, `.email`, `.work_email` | User info prompted on first run | +| Variable | Description | +| -------------------------------- | ----------------------------------------------- | +| `.business_use` | `true` if `BUSINESS_USE=1` was set during init | +| `.osid` | OS identifier (e.g., `darwin`, `linux-ubuntu`) | +| `.name`, `.email`, `.work_email` | User info prompted on first run | +| `.ghtkn_client_ids` | ghtkn App client IDs, filled by hand (business) | ### Template Testing @@ -41,7 +42,7 @@ just diff # Show pending chezmoi changes ```bash tmpdir=$(mktemp -d) && mkdir -p "$tmpdir/chezmoi" -printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n' > "$tmpdir/chezmoi/chezmoi.yaml" +printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n ghtkn_client_ids: {read: "Iv1.test"}\n' > "$tmpdir/chezmoi/chezmoi.yaml" XDG_CONFIG_HOME="$tmpdir" BUSINESS_USE=1 chezmoi init --source=. --dry-run # business mode XDG_CONFIG_HOME="$tmpdir" env -u BUSINESS_USE chezmoi init --source=. --dry-run # personal mode ``` diff --git a/home/.chezmoi.yaml.tmpl b/home/.chezmoi.yaml.tmpl index 1257b0d..b513f60 100644 --- a/home/.chezmoi.yaml.tmpl +++ b/home/.chezmoi.yaml.tmpl @@ -10,6 +10,7 @@ {{- $email := "" -}} {{- $workEmail := "" -}} {{- $signingKey := "" -}} +{{- $ghtknClientIds := dict -}} {{- if and (not $businessUse) $hasOp -}} {{- $name = onepasswordRead "op://Dotfiles/Git/name" -}} @@ -22,6 +23,10 @@ {{- end -}} {{- end -}} +{{- if hasKey . "ghtkn_client_ids" -}} +{{- $ghtknClientIds = .ghtkn_client_ids -}} +{{- end -}} + {{- if and $hasOp (eq .chezmoi.os "darwin") -}} {{- $signingKey = onepasswordRead "op://Dotfiles/Git/signing_key" -}} {{- end -}} @@ -37,6 +42,9 @@ data: work_email: {{ $workEmail | quote }} signing_key: {{ $signingKey | quote }} + # ghtkn GitHub App client IDs (none/read/git/write), filled by hand in the local chezmoi.yaml + ghtkn_client_ids: {{ $ghtknClientIds | toJson }} + # Auto tmux on terminal startup auto_tmux: true diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 389b21a..2e0de56 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -30,6 +30,9 @@ homebrew/ .chezmoiscripts/generate-fdev-ca-bundle.sh .config/git/freee .config/colima/ +.config/ghtkn/ +Library/LaunchAgents/local.ghtkn-agent.plist +.chezmoiscripts/load-ghtkn-agent.sh homebrew/Brewfile.work .claude/skills/acli/ {{ end }} diff --git a/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl b/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl new file mode 100644 index 0000000..a424b7d --- /dev/null +++ b/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl @@ -0,0 +1,16 @@ +{{- if and .business_use (eq .chezmoi.os "darwin") -}} +#!/bin/bash +# plist hash: {{ include "Library/LaunchAgents/local.ghtkn-agent.plist.tmpl" | sha256sum }} +# ghtkn version: {{ include "dot_config/aquaproj-aqua/aqua.yaml.tmpl" | regexFind "suzuki-shunsuke/ghtkn@v[0-9.]+" }} +set -euo pipefail + +if ! command -v aqua >/dev/null 2>&1; then + echo "aqua not found — skipping ghtkn agent load" >&2 + exit 0 +fi + +label="local.ghtkn-agent" +launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$label.plist" +echo "ghtkn agent restarted locked — run: ghtkn agent unlock --enable-refresh" +{{- end }} diff --git a/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl b/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl index 7102aeb..b881a3f 100644 --- a/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl +++ b/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl @@ -12,6 +12,7 @@ packages: - name: helm/helm@v3.17.0 - name: helmfile/helmfile@v0.171.0 - name: hashicorp/terraform@v1.14.3 + - name: suzuki-shunsuke/ghtkn@v0.4.1 {{- else }} packages: [] diff --git a/home/dot_config/ghtkn/ghtkn.yaml.tmpl b/home/dot_config/ghtkn/ghtkn.yaml.tmpl new file mode 100644 index 0000000..1519cb5 --- /dev/null +++ b/home/dot_config/ghtkn/ghtkn.yaml.tmpl @@ -0,0 +1,34 @@ +{{- $ids := dict -}} +{{- if hasKey . "ghtkn_client_ids" -}} +{{- $ids = .ghtkn_client_ids -}} +{{- end -}} +{{- $none := get $ids "none" -}} +{{- $read := get $ids "read" -}} +{{- $git := get $ids "git" -}} +{{- $write := get $ids "write" -}} +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghtkn/main/json-schema/ghtkn.json +# ghtkn - https://github.com/suzuki-shunsuke/ghtkn +min_expiration: 30m # 有効期限が 30m 以下になったら更新される +clipboard: + enable: true # device flow で one-time code が clipboard にコピーされて便利 +backend: + type: agent # access token の更新を自動化したい場合 +apps: +{{- if not (or $none $read $git $write) }} []{{ end }} +{{- if $none }} + - name: C-FO/none # 先頭にこの App を持ってくることでデフォルトで secure にする + client_id: {{ $none | quote }} +{{- end }} +{{- if $read }} + - name: C-FO/read # read 権限のみ + client_id: {{ $read | quote }} +{{- end }} +{{- if $git }} + - name: C-FO/git # Git Credential Helper 用 + client_id: {{ $git | quote }} + git_owner: C-FO # Git Credential Helper により、 C-FO 配下のリポジトリに push/pull する際はこれが使用される +{{- end }} +{{- if $write }} + - name: C-FO/write # 開発に必要な基本的な権限 + client_id: {{ $write | quote }} +{{- end }} diff --git a/home/dot_config/git/config b/home/dot_config/git/config index 0120759..fd07b64 100644 --- a/home/dot_config/git/config +++ b/home/dot_config/git/config @@ -2,10 +2,6 @@ [include] path = ~/.config/git/main -# Work-specific config for C-FO repositories -[includeIf "gitdir:~/repos/github.com/C-FO/"] - path = ~/.config/git/work - # GitHub credential helper (gh auth) # Use env to find gh in PATH (works with devbox, homebrew, etc.) [credential "https://github.com"] @@ -16,6 +12,12 @@ helper = helper = !/usr/bin/env gh auth git-credential +# Work-specific config for C-FO repositories. Keep it below the credential +# sections: helper lists accumulate in file order, so work's `helper =` reset +# must come last to replace the gh helper. +[includeIf "gitdir:~/repos/github.com/C-FO/"] + path = ~/.config/git/work + # Secret detection: Use gitleaks (not configured in git config) # Usage: gitleaks detect --source . --verbose # Pre-commit: gitleaks protect --staged --verbose diff --git a/home/dot_config/git/work.tmpl b/home/dot_config/git/work.tmpl index 6f59910..7ecd85f 100644 --- a/home/dot_config/git/work.tmpl +++ b/home/dot_config/git/work.tmpl @@ -6,4 +6,11 @@ hooksPath = {{ .chezmoi.homeDir }}/.fdev-git-hooks [url "git@github.com:"] insteadOf = https://github.com/ +[url "https://github.com/C-FO/"] + insteadOf = https://github.com/C-FO/ +[credential "https://github.com"] + useHttpPath = true +[credential "https://github.com/C-FO"] + helper = + helper = !ghtkn git-credential {{- end }} diff --git a/home/dot_config/zsh/features/aqua-token-sync.zsh b/home/dot_config/zsh/features/aqua-token-sync.zsh deleted file mode 100644 index ad3036c..0000000 --- a/home/dot_config/zsh/features/aqua-token-sync.zsh +++ /dev/null @@ -1,31 +0,0 @@ -# aqua-token-sync.zsh — refresh aqua's keyring GitHub token from gh -# Provides: aqua-token-sync -# Requires: gh, aqua -# Side-effects: writes gh's OAuth token into the OS keychain via `aqua token set` - -function aqua-token-sync { - emulate -L zsh - setopt local_options no_xtrace - - if (( ! $+commands[gh] || ! $+commands[aqua] )); then - print -u2 -- "aqua-token-sync: gh and aqua are required" - return 127 - fi - - local token - token="$(command gh auth token 2>/dev/null)" || { - print -u2 -- "aqua-token-sync: gh auth token failed — run \`gh auth login\` first" - return 1 - } - if [[ -z "$token" ]]; then - print -u2 -- "aqua-token-sync: gh returned an empty token" - return 1 - fi - - if print -r -- "$token" | command aqua token set --stdin; then - print -- "aqua-token-sync: keyring token updated from gh" - else - print -u2 -- "aqua-token-sync: aqua token set failed" - return 1 - fi -} diff --git a/home/dot_config/zsh/features/ghtkn-gh.zsh b/home/dot_config/zsh/features/ghtkn-gh.zsh new file mode 100644 index 0000000..c719b93 --- /dev/null +++ b/home/dot_config/zsh/features/ghtkn-gh.zsh @@ -0,0 +1,30 @@ +# ghtkn-gh.zsh — run gh with a short-lived GitHub App token from the ghtkn agent +# Provides: gh +# Requires: gh, ghtkn (agent unlocked) +# Side-effects: runs gh through `ghtkn exec` unless GH_TOKEN or GITHUB_TOKEN is set + +if (( $+aliases[gh] )); then + unalias gh +fi + +function gh { + emulate -L zsh + setopt local_options no_xtrace + + if [[ -n ${GH_TOKEN:-} || -n ${GITHUB_TOKEN:-} ]]; then + command gh "$@" + return + fi + + if (( ! $+commands[ghtkn] )); then + print -u2 -- "gh: ghtkn is required when no explicit GitHub token is set" + return 127 + fi + + command ghtkn exec -e GH_TOKEN:C-FO/write -- gh "$@" + local exit_status=$? + if (( exit_status == 111 )); then + print -u2 -- "gh: no ghtkn token available — run \`ghtkn agent unlock --enable-refresh\`" + fi + return $exit_status +} diff --git a/home/dot_zshenv.tmpl b/home/dot_zshenv.tmpl index b557b20..00679fb 100644 --- a/home/dot_zshenv.tmpl +++ b/home/dot_zshenv.tmpl @@ -164,11 +164,14 @@ export PATH="$XDG_DATA_HOME/aquaproj-aqua/bin:$PATH" export BUSINESS_USE=1 export COLIMA_HOME="$XDG_CONFIG_HOME/colima" -# aqua reads its GitHub token from macOS Keychain — some C-FO repos resolve a -# private aqua-registry, which needs an SSO-authorized token. Store it with -# `aqua-token-sync` (features/aqua-token-sync.zsh); rerun after gh re-login -# rotates the token. A GITHUB_TOKEN env var takes precedence and disables this. -export AQUA_KEYRING_ENABLED=true +# aqua gets its GitHub token from the ghtkn agent (some C-FO repos resolve a +# private aqua-registry). The agent must be unlocked after each login with +# `ghtkn agent unlock --enable-refresh`. GITHUB_TOKEN, AQUA_GITHUB_TOKEN and +# AQUA_KEYRING_ENABLED must stay unset or aqua ignores ghtkn. GHTKN_APP picks +# the read app, since the first app (C-FO/none) cannot read the registry. +unset AQUA_KEYRING_ENABLED # shells under a long-lived multiplexer still inherit it, and aqua checks keyring first +export AQUA_GHTKN_ENABLED=true +export GHTKN_APP=C-FO/read # Corporate TLS inspection CA: wire palo-root.pem (in Homebrew's cert store) # into common SSL env vars so curl/Node/Python/Ruby-OpenSSL trust certificates diff --git a/home/private_dot_zshrc.tmpl b/home/private_dot_zshrc.tmpl index a005aa3..32e286e 100644 --- a/home/private_dot_zshrc.tmpl +++ b/home/private_dot_zshrc.tmpl @@ -28,7 +28,7 @@ # ============================================================================= {{ include "dot_config/zsh/features/ghq-fzf.zsh" }} {{- if .business_use }} -{{ include "dot_config/zsh/features/aqua-token-sync.zsh" }} +{{ include "dot_config/zsh/features/ghtkn-gh.zsh" }} {{- end }} {{ include "dot_config/zsh/features/git-branch-fzf.zsh" }} {{ include "dot_config/zsh/features/branch-cleanup.zsh" }} diff --git a/tests/zsh/aqua-token-sync.test.sh b/tests/zsh/aqua-token-sync.test.sh deleted file mode 100644 index 03481c5..0000000 --- a/tests/zsh/aqua-token-sync.test.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2016 -# Verify the business-only aqua-token-sync helper without consulting real credentials. -set -euo pipefail - -unset GH_TOKEN GITHUB_TOKEN - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -repo_root="$(cd "$script_dir/../.." && pwd -P)" -feature_source="$repo_root/home/dot_config/zsh/features/aqua-token-sync.zsh" -zshrc_source="$repo_root/home/private_dot_zshrc.tmpl" - -for required_command in chezmoi chmod grep mkdir zsh; do - command -v "$required_command" >/dev/null 2>&1 || { - echo "required command not found: $required_command" >&2 - exit 1 - } -done - -test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-aqua-token-sync.XXXXXX")" -cleanup() { - rm -rf -- "$test_root" -} -trap cleanup EXIT - -fail() { - echo "FAIL: $1" >&2 - exit 1 -} - -pass() { - echo "PASS: $1" -} - -fake_bin="$test_root/bin" -gh_only_bin="$test_root/gh-only-bin" -aqua_log="$test_root/aqua.log" -stdout_log="$test_root/stdout.log" -trace_log="$test_root/zsh.trace" -mkdir -p "$fake_bin" "$gh_only_bin" - -cat >"$fake_bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -case "${GH_MODE:-success}" in - success) - printf '%s\n' "fake-gh-token" - ;; - empty) - ;; - fail) - exit 4 - ;; - *) - exit 64 - ;; -esac -EOF - -cat >"$fake_bin/aqua" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -{ - printf 'args=%s\n' "$*" - printf 'stdin=%s\n' "$(cat)" -} >>"${AQUA_LOG:?}" -EOF - -chmod +x "$fake_bin/gh" "$fake_bin/aqua" -cp "$fake_bin/gh" "$gh_only_bin/gh" - -run_sync() { - env -u GH_TOKEN -u GITHUB_TOKEN \ - "PATH=$1:/usr/bin:/bin" \ - "FEATURE_SOURCE=$feature_source" \ - "AQUA_LOG=$aqua_log" \ - "GH_MODE=${2:-success}" \ - zsh -f -c ' - source "$FEATURE_SOURCE" - aqua-token-sync - ' -} - -run_sync "$fake_bin" success >"$stdout_log" -grep -Fxq "args=token set --stdin" "$aqua_log" || - fail "helper did not invoke aqua token set --stdin" -grep -Fxq "stdin=fake-gh-token" "$aqua_log" || - fail "helper did not pipe the gh token into aqua" -grep -Fq "fake-gh-token" "$stdout_log" && - fail "token appeared on the helper's stdout" -grep -Fq "keyring token updated" "$stdout_log" || - fail "helper did not report success" -pass "gh token reaches aqua via stdin and never stdout" - -: >"$aqua_log" -env -u GH_TOKEN -u GITHUB_TOKEN \ - "PATH=$fake_bin:/usr/bin:/bin" \ - "FEATURE_SOURCE=$feature_source" \ - "AQUA_LOG=$aqua_log" \ - zsh -f -c ' - set -x - source "$FEATURE_SOURCE" - aqua-token-sync - ' >/dev/null 2>"$trace_log" - -if grep -Fq "fake-gh-token" "$trace_log"; then - fail "token appeared in zsh xtrace output" -fi -pass "helper suppresses token-bearing xtrace output" - -: >"$aqua_log" -run_sync "$fake_bin" fail >/dev/null 2>&1 && fail "gh failure unexpectedly succeeded" -[[ ! -s "$aqua_log" ]] || - fail "helper called aqua after gh failed" -pass "gh failure prevents storing a token" - -: >"$aqua_log" -run_sync "$fake_bin" empty >/dev/null 2>&1 && fail "empty gh token unexpectedly succeeded" -[[ ! -s "$aqua_log" ]] || - fail "helper called aqua with an empty token" -pass "empty gh token prevents storing a token" - -: >"$aqua_log" -rc=0 -run_sync "$gh_only_bin" success >/dev/null 2>&1 || rc=$? -[[ "$rc" -eq 127 ]] || - fail "missing aqua did not return 127 (got $rc)" -pass "missing aqua returns 127" - -render_root="$test_root/rendered" -chezmoi_config="$test_root/chezmoi.json" -mkdir -p "$render_root" -printf '{}\n' >"$chezmoi_config" - -render_profile() { - local profile="$1" - local business_use="$2" - local rendered="$render_root/$profile.zshrc" - local data - - data="{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\"}" - env -u GH_TOKEN -u GITHUB_TOKEN \ - chezmoi \ - --config "$chezmoi_config" \ - --config-format json \ - --source "$repo_root" \ - --override-data "$data" \ - execute-template --file "$zshrc_source" >"$rendered" - zsh -n "$rendered" -} - -render_profile personal false -render_profile business true - -if grep -Fq "aqua-token-sync" "$render_root/personal.zshrc"; then - fail "personal zshrc unexpectedly contains aqua-token-sync" -fi -grep -Fq "aqua-token-sync" "$render_root/business.zshrc" || - fail "business zshrc is missing aqua-token-sync" -pass "helper is rendered only for the business profile" - -echo "All aqua-token-sync tests passed." diff --git a/tests/zsh/ghtkn-gh.test.sh b/tests/zsh/ghtkn-gh.test.sh new file mode 100644 index 0000000..36dfbd3 --- /dev/null +++ b/tests/zsh/ghtkn-gh.test.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +# Verify the business-only gh wrapper and ghtkn config rendering without consulting real credentials. +set -euo pipefail + +# Never inherit or inspect credentials from the shell running this test. +unset GH_TOKEN GITHUB_TOKEN + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +feature_source="$repo_root/home/dot_config/zsh/features/ghtkn-gh.zsh" +zshrc_source="$repo_root/home/private_dot_zshrc.tmpl" + +for required_command in chezmoi chmod cp grep mkdir zsh; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "required command not found: $required_command" >&2 + exit 1 + } +done + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-ghtkn-gh.XXXXXX")" +cleanup() { + rm -rf -- "$test_root" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +fake_bin="$test_root/bin" +gh_only_bin="$test_root/gh-only-bin" +ghtkn_log="$test_root/ghtkn.log" +gh_log="$test_root/gh.log" +stderr_log="$test_root/stderr.log" +mkdir -p "$fake_bin" "$gh_only_bin" + +cat >"$fake_bin/ghtkn" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${GHTKN_LOG:?}" +[[ "${GHTKN_MODE:-success}" == "no-token" ]] && exit 111 +# Mirror `ghtkn exec -e GH_TOKEN: -- cmd args...`: the token reaches the child env only. +shift 4 +GH_TOKEN="fake-ghtkn-token" exec "$@" +EOS + +cat >"$fake_bin/gh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +{ + printf 'argc=%s\n' "$#" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" + done + printf 'GH_TOKEN=%s\n' "${GH_TOKEN-}" + printf 'GITHUB_TOKEN=%s\n' "${GITHUB_TOKEN-}" +} >>"${GH_LOG:?}" +EOS + +chmod +x "$fake_bin/ghtkn" "$fake_bin/gh" +cp "$fake_bin/gh" "$gh_only_bin/gh" + +reset_logs() { + : >"$ghtkn_log" + : >"$gh_log" + : >"$stderr_log" +} + +run_wrapper() { + local path_dir="$1" + shift + env -u GH_TOKEN -u GITHUB_TOKEN \ + "PATH=$path_dir:/usr/bin:/bin" \ + "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" \ + "GH_LOG=$gh_log" \ + "$@" +} + +reset_logs +run_wrapper "$fake_bin" zsh -f -c ' + source "$FEATURE_SOURCE" + gh api "/repos/o/r" --jq ".name | length" + [[ ! -v GH_TOKEN ]] +' 2>"$stderr_log" +grep -Fxq "exec -e GH_TOKEN:C-FO/write -- gh api /repos/o/r --jq .name | length" "$ghtkn_log" || + fail "wrapper did not call ghtkn exec -e GH_TOKEN:C-FO/write -- gh " +grep -Fxq "argc=4" "$gh_log" || fail "gh received the wrong argument count" +grep -Fxq "arg=.name | length" "$gh_log" || + fail "wrapper did not preserve an argument containing spaces" +grep -Fxq "GH_TOKEN=fake-ghtkn-token" "$gh_log" || + fail "gh did not receive the token from ghtkn exec" +pass "normal path runs gh through ghtkn exec and keeps the token out of the shell" + +reset_logs +env -u GITHUB_TOKEN "GH_TOKEN=caller-token" \ + "PATH=$fake_bin:/usr/bin:/bin" "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" "GH_LOG=$gh_log" \ + zsh -f -c 'source "$FEATURE_SOURCE"; gh repo view' +[[ ! -s "$ghtkn_log" ]] || fail "wrapper called ghtkn despite an explicit GH_TOKEN" +grep -Fxq "GH_TOKEN=caller-token" "$gh_log" || + fail "wrapper did not preserve the explicit GH_TOKEN" +pass "explicit GH_TOKEN bypasses ghtkn" + +reset_logs +env -u GH_TOKEN "GITHUB_TOKEN=caller-token" \ + "PATH=$fake_bin:/usr/bin:/bin" "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" "GH_LOG=$gh_log" \ + zsh -f -c 'source "$FEATURE_SOURCE"; gh repo view' +[[ ! -s "$ghtkn_log" ]] || fail "wrapper called ghtkn despite an explicit GITHUB_TOKEN" +grep -Fxq "GITHUB_TOKEN=caller-token" "$gh_log" || + fail "wrapper did not preserve the explicit GITHUB_TOKEN" +pass "explicit GITHUB_TOKEN bypasses ghtkn" + +reset_logs +run_wrapper "$fake_bin" zsh -f -c ' + alias gh="print aliased" + source "$FEATURE_SOURCE" + (( ! $+aliases[gh] )) + eval "gh api /user" +' +grep -Fxq "arg=/user" "$gh_log" || fail "pre-existing gh alias shadowed the wrapper" +pass "pre-existing gh alias does not shadow the wrapper" + +reset_logs +rc=0 +run_wrapper "$gh_only_bin" zsh -f -c ' + source "$FEATURE_SOURCE" + gh api /user +' 2>"$stderr_log" || rc=$? +[[ "$rc" -eq 127 ]] || fail "missing ghtkn did not return 127 (got $rc)" +[[ ! -s "$gh_log" ]] || fail "wrapper ran gh without ghtkn" +pass "missing ghtkn returns 127 and does not run gh" + +reset_logs +rc=0 +run_wrapper "$fake_bin" GHTKN_MODE=no-token zsh -f -c ' + source "$FEATURE_SOURCE" + gh api /user +' 2>"$stderr_log" || rc=$? +[[ "$rc" -eq 111 ]] || fail "ghtkn exit 111 was not propagated (got $rc)" +grep -Fq "ghtkn agent unlock --enable-refresh" "$stderr_log" || + fail "no unlock hint on stderr after ghtkn exit 111" +[[ ! -s "$gh_log" ]] || fail "wrapper ran gh after ghtkn exited 111" +pass "ghtkn exit 111 is propagated with an unlock hint" + +render_root="$test_root/rendered" +chezmoi_config="$test_root/chezmoi.json" +mkdir -p "$render_root" +printf '{}\n' >"$chezmoi_config" + +client_ids_json='{"none":"Iv1.none","read":"Iv1.read","git":"Iv1.git","write":"Iv1.write"}' + +chezmoi_isolated() { + local business_use="$1" + shift + env -u GH_TOKEN -u GITHUB_TOKEN \ + chezmoi \ + --config "$chezmoi_config" \ + --config-format json \ + --source "$repo_root" \ + --destination "$test_root/destination" \ + --override-data "{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\",\"ghtkn_client_ids\":$client_ids_json}" \ + "$@" +} + +chezmoi_isolated false execute-template --file "$zshrc_source" >"$render_root/personal.zshrc" +chezmoi_isolated true execute-template --file "$zshrc_source" >"$render_root/business.zshrc" +zsh -n "$render_root/personal.zshrc" +zsh -n "$render_root/business.zshrc" + +if grep -Fq "gh: ghtkn is required" "$render_root/personal.zshrc"; then + fail "personal zshrc unexpectedly contains the ghtkn gh wrapper" +fi +grep -Fq "gh: ghtkn is required" "$render_root/business.zshrc" || + fail "business zshrc is missing the ghtkn gh wrapper" +pass "wrapper is rendered only for the business profile" + +chezmoi_isolated true execute-template --file "$repo_root/home/dot_zshenv.tmpl" >"$render_root/business.zshenv" +mkdir -p "$test_root/home" +aqua_auth_env="$( + env -i HOME="$test_root/home" PATH="/usr/bin:/bin" AQUA_KEYRING_ENABLED=true \ + zsh -f -c 'source "$1" >/dev/null 2>&1; print -r -- "keyring=${AQUA_KEYRING_ENABLED-unset} ghtkn=${AQUA_GHTKN_ENABLED-unset}"' \ + zsh "$render_root/business.zshenv" +)" +[[ "$aqua_auth_env" == "keyring=unset ghtkn=true" ]] || + fail "business zshenv must drop an inherited AQUA_KEYRING_ENABLED and enable ghtkn (got: $aqua_auth_env)" +pass "business zshenv clears an inherited AQUA_KEYRING_ENABLED" + +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn.yaml" +grep -Fxq " type: agent # access token の更新を自動化したい場合" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml does not select the agent backend" +for app in none read git write; do + grep -Fxq " client_id: \"Iv1.$app\"" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render the $app client id" + grep -Fq " - name: C-FO/$app " "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render the C-FO/$app app" +done +[[ "$(grep -c '^ - name:' "$render_root/ghtkn.yaml")" -eq 4 ]] || + fail "ghtkn.yaml did not render exactly four apps" +grep -Fxq " git_owner: C-FO # Git Credential Helper により、 C-FO 配下のリポジトリに push/pull する際はこれが使用される" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render git_owner for the git app" +pass "ghtkn.yaml renders every app with the agent backend" + +client_ids_json='{"read":"Iv1.read"}' +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn-partial.yaml" +[[ "$(grep -c '^ - name:' "$render_root/ghtkn-partial.yaml")" -eq 1 ]] || + fail "ghtkn.yaml rendered an app without a client id" +grep -Fq " - name: C-FO/read " "$render_root/ghtkn-partial.yaml" || + fail "ghtkn.yaml dropped the app that has a client id" +client_ids_json='{}' +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn-empty.yaml" +grep -Fxq "apps: []" "$render_root/ghtkn-empty.yaml" || + fail "ghtkn.yaml without client ids did not render apps: []" +pass "ghtkn.yaml renders only apps that have a client id" +client_ids_json='{"none":"Iv1.none","read":"Iv1.read","git":"Iv1.git","write":"Iv1.write"}' + +chezmoi_isolated false managed >"$render_root/managed-personal.txt" +chezmoi_isolated true managed >"$render_root/managed-business.txt" +if grep -Eq '^\.config/ghtkn(/|$)' "$render_root/managed-personal.txt"; then + fail "personal profile manages a ghtkn config target" +fi +grep -Fxq ".config/ghtkn/ghtkn.yaml" "$render_root/managed-business.txt" || + fail "business profile does not manage .config/ghtkn/ghtkn.yaml" +pass "ghtkn config is managed only for the business profile" + +chezmoi_isolated false ignored >"$render_root/ignored-personal.txt" +ghtkn_ignored_targets=(".config/ghtkn" ".chezmoiscripts/load-ghtkn-agent.sh") +# Non-darwin hosts ignore the whole Library/ tree, so the plist is not listed on its own there. +grep -Fxq "Library" "$render_root/ignored-personal.txt" || + ghtkn_ignored_targets+=("Library/LaunchAgents/local.ghtkn-agent.plist") +for ghtkn_target in "${ghtkn_ignored_targets[@]}"; do + grep -Fxq "$ghtkn_target" "$render_root/ignored-personal.txt" || + fail "personal profile does not ignore $ghtkn_target" +done +chezmoi_isolated true ignored >"$render_root/ignored-business.txt" +if grep -Fq "ghtkn" "$render_root/ignored-business.txt"; then + fail "business profile ignores a ghtkn target" +fi +pass "ghtkn agent targets are ignored only for the personal profile" + +echo "All ghtkn gh wrapper tests passed." diff --git a/tests/zsh/ghtkn-git-helper.test.sh b/tests/zsh/ghtkn-git-helper.test.sh new file mode 100644 index 0000000..5d1b77b --- /dev/null +++ b/tests/zsh/ghtkn-git-helper.test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Verify the business-only C-FO ghtkn helper wiring in the rendered git config +# without invoking any credential helper. +set -euo pipefail + +unset GH_TOKEN GITHUB_TOKEN + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +git_source_dir="$repo_root/home/dot_config/git" + +for required_command in chezmoi cp git grep mkdir tail; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "required command not found: $required_command" >&2 + exit 1 + } +done + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-ghtkn-git.XXXXXX")" +cleanup() { + rm -rf -- "$test_root" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +chezmoi_config="$test_root/chezmoi.json" +printf '{}\n' >"$chezmoi_config" + +render() { + local business_use="$1" + local template="$2" + local output="$3" + + chezmoi \ + --config "$chezmoi_config" \ + --config-format json \ + --source "$repo_root" \ + --override-data "{\"business_use\":$business_use,\"name\":\"Test\",\"email\":\"t@example.com\",\"work_email\":\"t@work.example.com\",\"signing_key\":\"\"}" \ + execute-template --file "$template" >"$output" +} + +run_git() { + local profile_home="$1" + shift + env -i \ + "PATH=$PATH" \ + "HOME=$profile_home" \ + "GIT_CONFIG_GLOBAL=$profile_home/.config/git/config" \ + GIT_CONFIG_NOSYSTEM=1 \ + git "$@" +} + +in_repo() { + local profile_home="$1" + local repo="$2" + shift 2 + run_git "$profile_home" -C "$profile_home/repos/github.com/$repo" "$@" +} + +# Deploy the rendered config into a throwaway HOME so `~/.config/git/...` includes resolve to it. +setup_profile() { + local profile="$1" + local business_use="$2" + local profile_home="$test_root/$profile" + + mkdir -p "$profile_home/.config/git" "$profile_home/repos/github.com/C-FO" "$profile_home/repos/github.com/other" + cp "$git_source_dir/config" "$profile_home/.config/git/config" + render "$business_use" "$git_source_dir/main.tmpl" "$profile_home/.config/git/main" + render "$business_use" "$git_source_dir/work.tmpl" "$profile_home/.config/git/work" + run_git "$profile_home" init -q "$profile_home/repos/github.com/C-FO/clusterops" + run_git "$profile_home" init -q "$profile_home/repos/github.com/other/project" +} + +setup_profile business true +setup_profile personal false +business_home="$test_root/business" +personal_home="$test_root/personal" + +cfo_url="https://github.com/C-FO/clusterops" +other_url="https://github.com/other/project" +helper_pattern='^credential\..*helper$' + +[[ "$(in_repo "$business_home" C-FO/clusterops ls-remote --get-url "$cfo_url")" == "$cfo_url" ]] || + fail "business: a C-FO URL was rewritten away from HTTPS" +[[ "$(in_repo "$business_home" C-FO/clusterops ls-remote --get-url "$other_url")" == "git@github.com:other/project" ]] || + fail "business: a non-C-FO URL was not rewritten to SSH" +pass "business: C-FO stays HTTPS while other owners use SSH" + +business_helpers="$(in_repo "$business_home" C-FO/clusterops config --get-regexp "$helper_pattern")" +[[ "$(tail -n 1 <<<"$business_helpers")" == "credential.https://github.com/C-FO.helper !ghtkn git-credential" ]] || + fail "business: the ghtkn helper is not the last helper entry, so a gh helper entry would be appended after the reset" +pass "business: the C-FO ghtkn helper comes after the gh helper entries" + +[[ "$(in_repo "$business_home" C-FO/clusterops config --type=bool --get-urlmatch credential.useHttpPath "$cfo_url")" == "true" ]] || + fail "business: useHttpPath is not enabled for C-FO URLs" +pass "business: helper lookup is path-aware" + +other_helpers="$(in_repo "$business_home" other/project config --get-regexp "$helper_pattern")" +if grep -Fq "ghtkn" <<<"$other_helpers"; then + fail "business: the ghtkn helper leaks outside the C-FO checkout" +fi +pass "business: the ghtkn helper is limited to the C-FO checkout" + +if grep -Fq "C-FO" "$personal_home/.config/git/work"; then + fail "personal: the work config mentions C-FO" +fi +personal_helpers="$(in_repo "$personal_home" C-FO/clusterops config --get-regexp "$helper_pattern")" +if grep -Fq "ghtkn" <<<"$personal_helpers"; then + fail "personal: a ghtkn helper is configured" +fi +[[ "$(in_repo "$personal_home" C-FO/clusterops ls-remote --get-url "$other_url")" == "$other_url" ]] || + fail "personal: GitHub URLs were rewritten" +pass "personal: no C-FO helper or insteadOf configuration" + +echo "All ghtkn git helper tests passed."