From 7706f9c68591e7b806781cbf76f81ad8da461f7a Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:35:45 -0600 Subject: [PATCH 01/18] feat(sync): registry + engine/dispatcher skeletons (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative type registry mirroring lib/config/schema.zsh: - 10 syncable types in parallel arrays (label/kind/bundles) - Bundle resolver expands all/customizations/claude-config/preferences - _core_account_sync_resolve_includes mixes types+bundles, subtracts excludes Engine skeleton documents the 5-function strategy contract per type. Dispatcher skeleton parses --include/--exclude/--dry-run/--yes/--force. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §3, §5.2, §7.1. --- lib/account/sync/dispatcher.zsh | 70 +++++++++++++++ lib/account/sync/dispatcher_test.bats | 75 ++++++++++++++++ lib/account/sync/engine.zsh | 48 +++++++++++ lib/account/sync/engine_test.bats | 31 +++++++ lib/account/sync/registry.zsh | 114 ++++++++++++++++++++++++ lib/account/sync/registry_test.bats | 119 ++++++++++++++++++++++++++ 6 files changed, 457 insertions(+) create mode 100644 lib/account/sync/dispatcher.zsh create mode 100644 lib/account/sync/dispatcher_test.bats create mode 100644 lib/account/sync/engine.zsh create mode 100644 lib/account/sync/engine_test.bats create mode 100644 lib/account/sync/registry.zsh create mode 100644 lib/account/sync/registry_test.bats diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh new file mode 100644 index 0000000..25f11ff --- /dev/null +++ b/lib/account/sync/dispatcher.zsh @@ -0,0 +1,70 @@ +#!/usr/bin/env zsh +# Dispatcher for `ckipper account sync` and `ckipper account sync undo`. +# Routes top-level args to either the sync flow or the undo flow. +# Owns argument parsing; delegates execution to engine.zsh / interactive.zsh. + +# Module-level parsed-arg holders. Populated by _ckipper_account_sync_parse_args +# before any handler reads them. +typeset -g _SYNC_FROM="" +typeset -ga _SYNC_TARGETS=() +typeset -g _SYNC_INCLUDE="" +typeset -g _SYNC_EXCLUDE="" +typeset -g _SYNC_DRY_RUN="false" +typeset -g _SYNC_YES="false" +typeset -g _SYNC_FORCE="false" + +# Reset all module-level _SYNC_* holders. Called at the top of every +# parse_args invocation so re-running the dispatcher in the same shell +# doesn't see stale state from the previous call. +# +# Returns: 0 always. +_ckipper_account_sync_reset_args() { + _SYNC_FROM="" + _SYNC_TARGETS=() + _SYNC_INCLUDE="" + _SYNC_EXCLUDE="" + _SYNC_DRY_RUN="false" + _SYNC_YES="false" + _SYNC_FORCE="false" +} + +# Parse `ckipper account sync` arguments into the module-level _SYNC_* vars. +# Positional args: [...]. Flags: --include , --exclude , +# --dry-run, --yes, --force. +# +# Args: $@ — raw argv after `ckipper account sync` is stripped. +# Returns: 0 on success; 1 on unknown flag. +# Errors (stderr): "Unknown flag: " — when an unrecognized --foo appears. +_ckipper_account_sync_parse_args() { + _ckipper_account_sync_reset_args + while [[ $# -gt 0 ]]; do + case "$1" in + --include) _SYNC_INCLUDE="$2"; shift 2 ;; + --exclude) _SYNC_EXCLUDE="$2"; shift 2 ;; + --dry-run) _SYNC_DRY_RUN="true"; shift ;; + --yes) _SYNC_YES="true"; shift ;; + --force) _SYNC_FORCE="true"; shift ;; + -h|--help) _ckipper_account_sync_help_text; return 0 ;; + --*) echo "Unknown flag: $1" >&2; return 1 ;; + *) + if [[ -z "$_SYNC_FROM" ]]; then + _SYNC_FROM="$1" + else + _SYNC_TARGETS+=("$1") + fi + shift + ;; + esac + done + return 0 +} + +# Print --help body for `ckipper account sync`. Filled in fully in Phase 9. +# +# Returns: 0 always. +_ckipper_account_sync_help_text() { + echo "ckipper account sync [] [...] [options]" + echo "" + echo " See docs/plans/2026-05-02-sync-overhaul-design.md §5 for the" + echo " complete flag and bundle catalog. Wired up in Phase 9." +} diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats new file mode 100644 index 0000000..dba2d02 --- /dev/null +++ b/lib/account/sync/dispatcher_test.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/dispatcher.zsh — arg parsing skeleton. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env CKIPPER_DIR="$CKIPPER_DIR" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/dispatcher.zsh\"; $*" +} + +@test "parse_args identifies --dry-run flag" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work --dry-run + echo "from=$_SYNC_FROM" + echo "targets=${_SYNC_TARGETS[*]}" + echo "dry_run=$_SYNC_DRY_RUN"' + [[ "$output" == *"from=personal"* ]] + [[ "$output" == *"targets=work"* ]] + [[ "$output" == *"dry_run=true"* ]] +} + +@test "parse_args identifies --yes flag" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work --yes + echo "yes=$_SYNC_YES"' + [[ "$output" == *"yes=true"* ]] +} + +@test "parse_args identifies multiple targets" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work client1 client2 + echo "targets=${(j:,:)_SYNC_TARGETS}"' + [[ "$output" == *"targets=work,client1,client2"* ]] +} + +@test "parse_args identifies --include with comma list" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work --include mcp,settings + echo "include=$_SYNC_INCLUDE"' + [[ "$output" == *"include=mcp,settings"* ]] +} + +@test "parse_args identifies --exclude with comma list" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work --include all --exclude prefs + echo "include=$_SYNC_INCLUDE" + echo "exclude=$_SYNC_EXCLUDE"' + [[ "$output" == *"include=all"* ]] + [[ "$output" == *"exclude=prefs"* ]] +} + +@test "parse_args identifies --force" { + run_in_zsh ' + _ckipper_account_sync_parse_args personal work --force + echo "force=$_SYNC_FORCE"' + [[ "$output" == *"force=true"* ]] +} + +@test "parse_args returns 1 on unknown flag" { + run_in_zsh '_ckipper_account_sync_parse_args personal work --bogus' + [ "$status" -ne 0 ] +} + +@test "parse_args allows empty positionals (drop-to-picker)" { + run_in_zsh ' + _ckipper_account_sync_parse_args + echo "from=${_SYNC_FROM:-EMPTY}" + echo "n_targets=${#_SYNC_TARGETS[@]}"' + [[ "$output" == *"from=EMPTY"* ]] + [[ "$output" == *"n_targets=0"* ]] +} diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh new file mode 100644 index 0000000..e516225 --- /dev/null +++ b/lib/account/sync/engine.zsh @@ -0,0 +1,48 @@ +#!/usr/bin/env zsh +# Sync engine — type-agnostic main loop. +# +# This module implements the source × targets × types loop. It NEVER +# references concrete type semantics (MCP servers, agents, etc.) — instead +# it dispatches to per-type strategy functions following a fixed naming +# convention. Adding a new type does NOT require touching this file. +# +# ── Strategy contract ──────────────────────────────────────────────────── +# +# Every type registered in lib/account/sync/registry.zsh MUST implement +# these five functions, named `_ckipper_account_sync__`: +# +# _enumerate +# List every syncable item the source has, one per line, as +# "iddisplay". `id` is whatever the apply/compare/diff +# functions need to look the item up; `display` is the picker label. +# Empty stdout means "nothing to sync" (no items in source). +# +# _compare +# Print one of: "new" | "overwrite" | "unchanged". +# +# _summary +# Print a one-line summary of the change for the preview table +# (e.g. "+12/-3 lines", "command changed", "false → true"). +# +# _diff +# Print a full diff for drill-down view. Files use `diff -u`; +# JSON values use side-by-side `jq` pretty-print. May be empty for +# new items (drill-down skips status==new in the preview UI). +# +# _apply +# Perform the merge. MUST call _ckipper_account_sync_backup_file +# before any destructive write. Returns 0 on success; non-zero on +# failure (engine then triggers per-target rollback). +# +# All five functions take their arguments in the same order so the engine +# can call them through _core_account_sync_strategy_fn uniformly. + +# Compute the strategy function name for a (type, verb) pair. +# +# Args: $1 — type id (e.g. "mcp", "claude-md"); $2 — verb (enumerate, compare, +# summary, diff, apply). +# Returns: 0; prints the function name (e.g. "_ckipper_account_sync_mcp_enumerate"). +_core_account_sync_strategy_fn() { + local type="$1" verb="$2" + echo "_ckipper_account_sync_${type}_${verb}" +} diff --git a/lib/account/sync/engine_test.bats b/lib/account/sync/engine_test.bats new file mode 100644 index 0000000..ae6e1d2 --- /dev/null +++ b/lib/account/sync/engine_test.bats @@ -0,0 +1,31 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/engine.zsh skeleton. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env CKIPPER_DIR="$CKIPPER_DIR" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; $*" +} + +@test "_core_account_sync_strategy_fn returns the expected naming convention" { + run_in_zsh 'echo "$(_core_account_sync_strategy_fn mcp enumerate)"' + [ "$status" -eq 0 ] + [[ "$output" == "_ckipper_account_sync_mcp_enumerate" ]] +} + +@test "_core_account_sync_strategy_fn handles hyphenated type ids" { + run_in_zsh 'echo "$(_core_account_sync_strategy_fn claude-md compare)"' + [ "$status" -eq 0 ] + [[ "$output" == "_ckipper_account_sync_claude-md_compare" ]] +} + +@test "engine sources without errors" { + run_in_zsh 'echo OK' + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} diff --git a/lib/account/sync/registry.zsh b/lib/account/sync/registry.zsh new file mode 100644 index 0000000..d6482ab --- /dev/null +++ b/lib/account/sync/registry.zsh @@ -0,0 +1,114 @@ +#!/usr/bin/env zsh +# Declarative registry of syncable types for `ckipper account sync`. +# +# This is the single source of truth for "what can be synced." Adding a new +# syncable type is: append to all three parallel arrays AND implement the +# strategy contract (see lib/account/sync/engine.zsh for the contract). +# +# Mirrors the parallel-array idiom used by lib/config/schema.zsh. + +# Human-readable label, shown in pickers and the summary table. +typeset -gA _CKIPPER_SYNC_TYPE_LABEL=( + [mcp]="MCP servers" + [settings]="Claude settings" + [claude-md]="CLAUDE.md (user memory)" + [agents]="Sub-agents" + [commands]="Custom slash commands" + [output-styles]="Output styles" + [skills]="Skills" + [statusline]="Status line" + [hooks]="User hooks" + [prefs]="Account preferences" +) + +# Implementation kind. Drives which strategy module each type's functions +# live in. Allowed values: "structured" (JSON-key merges), "files-flat" +# (flat .md files), "files-dir" (per-directory items), "special" (custom +# logic — statusline split-detection, hooks install-allowlist filter). +typeset -gA _CKIPPER_SYNC_TYPE_KIND=( + [mcp]=structured [settings]=structured [prefs]=structured + [claude-md]=files-flat [agents]=files-flat + [commands]=files-flat [output-styles]=files-flat + [skills]=files-dir + [statusline]=special [hooks]=special +) + +# Space-separated list of bundles the type belongs to. Bundles are aliases +# users may pass to --include / --exclude (see _core_account_sync_resolve_*). +typeset -gA _CKIPPER_SYNC_TYPE_BUNDLES=( + [mcp]="all customizations claude-config" + [settings]="all customizations claude-config" + [claude-md]="all customizations" + [agents]="all customizations" + [commands]="all customizations" + [output-styles]="all customizations" + [skills]="all customizations" + [statusline]="all customizations" + [hooks]="all customizations claude-config" + [prefs]="all preferences" +) + +# Known bundle aliases. Asserted disjoint from type ids by registry_test. +typeset -gra _CKIPPER_SYNC_BUNDLE_ALIASES=(all customizations claude-config preferences) + +# Return 0 if $1 is a known sync type id; 1 otherwise. +# +# Args: $1 — candidate type id. +# Returns: 0 if known; 1 otherwise. +_core_account_sync_is_known_type() { + (( ${+_CKIPPER_SYNC_TYPE_LABEL[$1]} )) +} + +# Return 0 if $1 is a known bundle alias; 1 otherwise. +# +# Args: $1 — candidate bundle alias. +# Returns: 0 if known; 1 otherwise. +_core_account_sync_is_known_bundle() { + local b="$1" alias + for alias in "${_CKIPPER_SYNC_BUNDLE_ALIASES[@]}"; do + [[ "$alias" == "$b" ]] && return 0 + done + return 1 +} + +# Expand a bundle alias to its constituent type ids (one per line). +# A non-bundle token is echoed back unchanged (so callers can resolve a +# mixed list uniformly). +# +# Args: $1 — bundle alias OR raw type id. +# Returns: 0 always; prints expanded list to stdout, one type id per line. +_core_account_sync_resolve_bundle() { + local token="$1" t + if ! _core_account_sync_is_known_bundle "$token"; then + echo "$token" + return 0 + fi + for t in "${(@k)_CKIPPER_SYNC_TYPE_BUNDLES}"; do + [[ " ${_CKIPPER_SYNC_TYPE_BUNDLES[$t]} " == *" $token "* ]] && echo "$t" + done +} + +# Resolve a comma-separated --include / --exclude pair into a deduplicated +# sorted list of type ids. `include` may mix bundle aliases and bare type +# ids; bundles are expanded first, then `exclude` (also mixed) is subtracted. +# +# Args: $1 — comma-separated include list; $2 — comma-separated exclude list. +# Returns: 0 always; prints the final type ids one per line, lexically sorted. +_core_account_sync_resolve_includes() { + local include="$1" exclude="$2" + local -A keep + local token expanded + for token in ${(s:,:)include}; do + [[ -z "$token" ]] && continue + for expanded in $(_core_account_sync_resolve_bundle "$token"); do + keep[$expanded]=1 + done + done + for token in ${(s:,:)exclude}; do + [[ -z "$token" ]] && continue + for expanded in $(_core_account_sync_resolve_bundle "$token"); do + unset 'keep['"$expanded"']' + done + done + print -l ${(ko)keep} +} diff --git a/lib/account/sync/registry_test.bats b/lib/account/sync/registry_test.bats new file mode 100644 index 0000000..f3e9e1b --- /dev/null +++ b/lib/account/sync/registry_test.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/registry.zsh — declarative type registry. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: source registry.zsh in a zsh subshell and run an expression. +run_in_zsh() { + run env CKIPPER_DIR="$CKIPPER_DIR" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; $*" +} + +@test "registry declares all 10 sync types" { + run_in_zsh 'echo ${(k)_CKIPPER_SYNC_TYPE_LABEL} | tr " " "\n" | sort | tr "\n" ","' + [ "$status" -eq 0 ] + [[ "$output" == *"agents,claude-md,commands,hooks,mcp,output-styles,prefs,settings,skills,statusline,"* ]] +} + +@test "every type has a label" { + run_in_zsh ' + for t in mcp settings claude-md agents commands output-styles skills statusline hooks prefs; do + [[ -n "${_CKIPPER_SYNC_TYPE_LABEL[$t]}" ]] || { echo "missing label for $t"; exit 1; } + done + echo OK' + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "every type has a kind in {structured, files-flat, files-dir, special}" { + run_in_zsh ' + for t in mcp settings claude-md agents commands output-styles skills statusline hooks prefs; do + kind="${_CKIPPER_SYNC_TYPE_KIND[$t]}" + case "$kind" in + structured|files-flat|files-dir|special) ;; + *) echo "bad kind $kind for $t"; exit 1 ;; + esac + done + echo OK' + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "every type has at least one bundle membership" { + run_in_zsh ' + for t in mcp settings claude-md agents commands output-styles skills statusline hooks prefs; do + [[ -n "${_CKIPPER_SYNC_TYPE_BUNDLES[$t]}" ]] || { echo "missing bundles for $t"; exit 1; } + done + echo OK' + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "_core_account_sync_resolve_bundle expands all to all 10 types" { + run_in_zsh '_core_account_sync_resolve_bundle all | sort | tr "\n" ","' + [ "$status" -eq 0 ] + [[ "$output" == "agents,claude-md,commands,hooks,mcp,output-styles,prefs,settings,skills,statusline," ]] +} + +@test "_core_account_sync_resolve_bundle expands customizations" { + run_in_zsh '_core_account_sync_resolve_bundle customizations | sort | tr "\n" ","' + [ "$status" -eq 0 ] + [[ "$output" == "agents,claude-md,commands,hooks,mcp,output-styles,settings,skills,statusline," ]] +} + +@test "_core_account_sync_resolve_bundle preferences = prefs" { + run_in_zsh '_core_account_sync_resolve_bundle preferences | tr "\n" ","' + [[ "$output" == "prefs," ]] +} + +@test "_core_account_sync_resolve_bundle claude-config = mcp,settings,hooks" { + run_in_zsh '_core_account_sync_resolve_bundle claude-config | sort | tr "\n" ","' + [[ "$output" == "hooks,mcp,settings," ]] +} + +@test "_core_account_sync_resolve_bundle returns input unchanged for non-bundle token" { + run_in_zsh '_core_account_sync_resolve_bundle mcp | tr "\n" ","' + [[ "$output" == "mcp," ]] +} + +@test "_core_account_sync_resolve_includes mixes types and bundles, dedups" { + run_in_zsh '_core_account_sync_resolve_includes "preferences,mcp" "" | sort | tr "\n" ","' + [[ "$output" == "mcp,prefs," ]] +} + +@test "_core_account_sync_resolve_includes subtracts excludes" { + run_in_zsh '_core_account_sync_resolve_includes "all" "prefs,hooks" | sort | tr "\n" ","' + [[ "$output" == "agents,claude-md,commands,mcp,output-styles,settings,skills,statusline," ]] +} + +@test "_core_account_sync_is_known_type returns 0 for known type" { + run_in_zsh '_core_account_sync_is_known_type mcp && echo ok' + [[ "$output" == "ok" ]] +} + +@test "_core_account_sync_is_known_type returns 1 for unknown" { + run_in_zsh '_core_account_sync_is_known_type bogus && echo wrongly_ok || true' + [ "$status" -eq 0 ] + [[ "$output" != *"wrongly_ok"* ]] +} + +@test "bundle names never collide with type ids" { + run_in_zsh ' + for b in all customizations claude-config preferences; do + if (( ${+_CKIPPER_SYNC_TYPE_LABEL[$b]} )); then + echo "bundle $b collides with type id" + exit 1 + fi + done + echo OK' + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} From d4dab311b78fad34da0afec4266a165f7ffe1b92 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:37:25 -0600 Subject: [PATCH 02/18] feat(sync): backup primitives (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-invocation backup dir at /.ckipper-sync-backups/-from-/ with 0700 perms; 0600 on backed-up files. JSON manifest with version, source, target, timestamp, files[]. Per-target rollback reads manifest and reverses each entry (create→rm, overwrite→restore). Undo wraps rollback + cleans backup dir on success. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §7.4-§7.6. --- lib/account/sync/backup.zsh | 169 ++++++++++++++++++++++++++++++ lib/account/sync/backup_test.bats | 154 +++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 lib/account/sync/backup.zsh create mode 100644 lib/account/sync/backup_test.bats diff --git a/lib/account/sync/backup.zsh b/lib/account/sync/backup.zsh new file mode 100644 index 0000000..29b0f65 --- /dev/null +++ b/lib/account/sync/backup.zsh @@ -0,0 +1,169 @@ +#!/usr/bin/env zsh +# Backup primitives for the sync engine. +# Every destructive write goes through _core_account_sync_backup_file +# (called by strategy apply functions BEFORE the merge) so any failure can +# be rolled back from the backup dir. +# +# Layout: +# /.ckipper-sync-backups/-from-/ +# +# .ckipper-sync-manifest.json +# +# 0700 perms on every backup dir; 0600 on every backed-up file (mirrors +# the registry permission discipline in lib/core/registry.zsh). + +readonly _CKIPPER_SYNC_BACKUP_SUBDIR=".ckipper-sync-backups" +readonly _CKIPPER_SYNC_BACKUP_DIR_PERMS=700 +readonly _CKIPPER_SYNC_BACKUP_FILE_PERMS=600 +readonly _CKIPPER_SYNC_MANIFEST_FILE=".ckipper-sync-manifest.json" +readonly _CKIPPER_SYNC_MANIFEST_VERSION=1 + +# Compute the backup-dir path (does NOT create it). Pure function; no IO. +# +# Args: $1 — destination account dir; $2 — source account name. +# Returns: 0; prints absolute path of the to-be-created backup dir. +_core_account_sync_backup_dir_path() { + local dst_dir="$1" source_name="$2" + local ts; ts=$(date -u +"%Y-%m-%dT%H-%M-%SZ") + echo "$dst_dir/$_CKIPPER_SYNC_BACKUP_SUBDIR/$ts-from-$source_name" +} + +# Create the backup root dir for one sync invocation. Idempotent: a +# concurrent caller picking the same timestamp will see the existing dir. +# +# Args: $1 — destination account dir; $2 — source account name. +# Returns: 0; prints the created path on stdout. +_core_account_sync_backup_create() { + local dst_dir="$1" source_name="$2" + local backup_dir + backup_dir=$(_core_account_sync_backup_dir_path "$dst_dir" "$source_name") + mkdir -p "$backup_dir" + chmod "$_CKIPPER_SYNC_BACKUP_DIR_PERMS" "$backup_dir" + echo "$backup_dir" +} + +# Copy a single file or directory from the destination into the backup +# dir at the given relative path. No-op when the source path does not +# exist (i.e. operation is "create" — there's nothing to back up). +# +# Args: $1 — backup_dir; $2 — absolute source path; $3 — relative destination path. +# Returns: 0 on success or no-op; 1 if cp fails. +_core_account_sync_backup_file() { + local backup_dir="$1" src="$2" rel="$3" + [[ ! -e "$src" ]] && return 0 + local dst="$backup_dir/$rel" + mkdir -p "${dst:h}" + cp -a "$src" "$dst" || return 1 + [[ -f "$dst" ]] && chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$dst" + return 0 +} + +# Initialize the per-invocation manifest with an empty `files` array. +# +# Args: $1 — backup_dir; $2 — source name; $3 — target name. +# Returns: 0; writes manifest JSON to /. +_core_account_sync_manifest_init() { + local backup_dir="$1" source_name="$2" target_name="$3" + local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" + local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + jq -n --argjson v "$_CKIPPER_SYNC_MANIFEST_VERSION" \ + --arg s "$source_name" --arg t "$target_name" --arg ts "$ts" \ + '{version: $v, source: $s, target: $t, timestamp: $ts, files: []}' \ + > "$manifest" + chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$manifest" +} + +# Append one entry to the manifest. Items field is a comma-separated list +# (the strategy decides what counts as an item — JSON keys, file paths, etc.). +# +# Args: $1 — backup_dir; $2 — relative path; $3 — operation (create|overwrite); +# $4 — type id; $5 — items (comma-separated, optional). +# Returns: 0 on success; 1 on jq failure. +_core_account_sync_manifest_append() { + local backup_dir="$1" rel="$2" op="$3" type="$4" items="${5:-}" + local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" + local tmp; tmp=$(mktemp "$manifest.XXXXXX") + jq --arg p "$rel" --arg o "$op" --arg t "$type" --arg i "$items" \ + '.files += [{path: $p, operation: $o, type: $t, items: ($i | split(",") | map(select(length > 0)))}]' \ + "$manifest" > "$tmp" && mv "$tmp" "$manifest" \ + && chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$manifest" +} + +# List backup directories under , newest first. Returns absolute paths. +# Sort key is the directory basename — the ts prefix sorts lexicographically +# by design so plain `sort -r` gives newest-first ordering. +# +# Args: $1 — destination account dir. +# Returns: 0 always; prints absolute backup-dir paths, one per line. +_core_account_sync_manifest_list_backups() { + local dst_dir="$1" + local root="$dst_dir/$_CKIPPER_SYNC_BACKUP_SUBDIR" + [[ ! -d "$root" ]] && return 0 + local d + for d in "$root"/*(N/); do + echo "$d" + done | sort -r +} + +# Roll back a single target by reversing every entry in its manifest: +# - operation=create → delete the file at / (the sync put it there) +# - operation=overwrite → restore from / via atomic rename +# +# Best-effort per-entry: a missing backup or a permission error is logged +# (stderr) but does not stop subsequent entries from rolling back. +# +# Args: $1 — backup_dir for this target; $2 — destination account dir. +# Returns: 0 on full success; 1 if any per-entry rollback failed. +# Errors (stderr): "rollback failed: " — per-entry failures. +_core_account_sync_rollback_target() { + local backup_dir="$1" dst_dir="$2" + local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" + [[ ! -f "$manifest" ]] && return 0 + local rc=0 + while IFS=$'\t' read -r op rel; do + _core_account_sync_rollback_one "$backup_dir" "$dst_dir" "$op" "$rel" || rc=1 + done < <(jq -r '.files[] | "\(.operation)\t\(.path)"' "$manifest") + return $rc +} + +# Per-entry rollback helper. Kept separate so _rollback_target stays under +# the 25-line cap and the per-entry logic is independently unit-testable. +# +# Args: $1 — backup_dir; $2 — dst_dir; $3 — operation; $4 — relative path. +# Returns: 0 on success; 1 on rm/mv failure. +# Errors (stderr): "rollback failed: " +_core_account_sync_rollback_one() { + local backup_dir="$1" dst_dir="$2" op="$3" rel="$4" + local live="$dst_dir/$rel" + if [[ "$op" == "create" ]]; then + rm -rf "$live" 2>/dev/null || { + echo "rollback failed: $rel — could not remove created file" >&2 + return 1 + } + return 0 + fi + local backup_path="$backup_dir/$rel" + [[ ! -e "$backup_path" ]] && return 0 + rm -rf "$live" 2>/dev/null + mkdir -p "${live:h}" + cp -a "$backup_path" "$live" || { + echo "rollback failed: $rel — could not restore from backup" >&2 + return 1 + } +} + +# Public undo entry — restore from a specific backup dir, then delete it. +# Caller is responsible for refusing if Claude is running on the destination +# (engine.zsh handles that gate). +# +# Args: $1 — backup_dir; $2 — destination account dir. +# Returns: 0 on full restore + cleanup; 1 if restore had failures +# (backup dir is preserved on partial failure for inspection). +_core_account_sync_undo_from_backup() { + local backup_dir="$1" dst_dir="$2" + if ! _core_account_sync_rollback_target "$backup_dir" "$dst_dir"; then + return 1 + fi + rm -rf "$backup_dir" + return 0 +} diff --git a/lib/account/sync/backup_test.bats b/lib/account/sync/backup_test.bats new file mode 100644 index 0000000..6c4be72 --- /dev/null +++ b/lib/account/sync/backup_test.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/backup.zsh — backup creation, manifest, undo. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; $*" +} + +@test "_core_account_sync_backup_dir_path generates a UTC ISO timestamp" { + run_in_zsh ' + path=$(_core_account_sync_backup_dir_path "/tmp/dst" "personal") + echo "$path"' + [ "$status" -eq 0 ] + [[ "$output" == */tmp/dst/.ckipper-sync-backups/*-from-personal* ]] +} + +@test "_core_account_sync_backup_create makes the dir 0700" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + echo \"\$backup_dir\"" + [ "$status" -eq 0 ] + local dir; dir=$(echo "$output" | tail -1) + [[ -d "$dir" ]] + local mode; mode=$(stat -f '%Lp' "$dir" 2>/dev/null || stat -c '%a' "$dir" 2>/dev/null) + [[ "$mode" == "700" ]] +} + +@test "_core_account_sync_backup_file copies a regular file with 0600" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/hooks" + echo "original content" > "$dst/hooks/foo.sh" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + cat \"\$backup_dir/hooks/foo.sh\"" + [ "$status" -eq 0 ] + [[ "$output" == *"original content"* ]] +} + +@test "_core_account_sync_backup_file is a no-op for missing source (operation == create)" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_backup_file \"\$backup_dir\" '$dst/does-not-exist' 'phantom' && echo OK" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "_core_account_sync_backup_file copies directories recursively (cp -a)" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/skills/foo" + echo "a" > "$dst/skills/foo/SKILL.md" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_backup_file \"\$backup_dir\" '$dst/skills/foo' 'skills/foo' + cat \"\$backup_dir/skills/foo/SKILL.md\"" + [ "$status" -eq 0 ] + [[ "$output" == *"a"* ]] +} + +@test "_core_account_sync_manifest_init writes a valid empty manifest" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_manifest_init \"\$backup_dir\" personal work + cat \"\$backup_dir/.ckipper-sync-manifest.json\" | jq -r '.version, .source, .target'" + [ "$status" -eq 0 ] + [[ "$output" == *"1"* ]] + [[ "$output" == *"personal"* ]] + [[ "$output" == *"work"* ]] +} + +@test "_core_account_sync_manifest_append adds an entry" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_manifest_init \"\$backup_dir\" personal work + _core_account_sync_manifest_append \"\$backup_dir\" 'settings.json' overwrite mcp 'github,vibma' + jq -r '.files | length' \"\$backup_dir/.ckipper-sync-manifest.json\"" + [ "$status" -eq 0 ] + [[ "$output" == *"1"* ]] +} + +@test "_core_account_sync_manifest_list_backups sorts newest first" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/.ckipper-sync-backups/2026-01-01T00-00-00Z-from-a" + mkdir -p "$dst/.ckipper-sync-backups/2026-05-02T00-00-00Z-from-b" + mkdir -p "$dst/.ckipper-sync-backups/2026-03-15T00-00-00Z-from-c" + run_in_zsh "_core_account_sync_manifest_list_backups '$dst' | head -1" + [ "$status" -eq 0 ] + [[ "$output" == *"2026-05-02T00-00-00Z-from-b"* ]] +} + +@test "_core_account_sync_rollback_target restores backed-up files atomically" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/hooks" + echo "original" > "$dst/hooks/foo.sh" + + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_manifest_init \"\$backup_dir\" personal work + _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh + echo modified > '$dst/hooks/foo.sh' + _core_account_sync_rollback_target \"\$backup_dir\" '$dst' + cat '$dst/hooks/foo.sh'" + [ "$status" -eq 0 ] + [[ "$output" == *"original"* ]] + [[ "$output" != *"modified"* ]] +} + +@test "_core_account_sync_rollback_target removes files marked operation=create" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/hooks" + + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_manifest_init \"\$backup_dir\" personal work + _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/new.sh' create hooks new.sh + echo new-content > '$dst/hooks/new.sh' + _core_account_sync_rollback_target \"\$backup_dir\" '$dst' + [[ -e '$dst/hooks/new.sh' ]] && echo STILL_THERE || echo GONE" + [[ "$output" == *"GONE"* ]] +} + +@test "_core_account_sync_undo_from_backup restores and removes backup dir" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst/hooks" + echo "original" > "$dst/hooks/foo.sh" + + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + _core_account_sync_manifest_init \"\$backup_dir\" personal work + _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh + echo modified > '$dst/hooks/foo.sh' + _core_account_sync_undo_from_backup \"\$backup_dir\" '$dst' + echo \"--\" + cat '$dst/hooks/foo.sh' + echo \"--\" + [[ -d \"\$backup_dir\" ]] && echo BACKUP_KEPT || echo BACKUP_REMOVED" + [[ "$output" == *"original"* ]] + [[ "$output" == *"BACKUP_REMOVED"* ]] +} From b2f538dab0eba5b47083caf0de4333f26d12fffc Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:45:05 -0600 Subject: [PATCH 03/18] =?UTF-8?q?feat(sync):=20structured=20strategies=20?= =?UTF-8?q?=E2=80=94=20mcp/settings/prefs=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three structured (JSON-key merge) types share json_validate + json_atomic_write helpers. mcp enumerates .mcpServers entries; settings enumerates jq leaf paths excluding .hooks; prefs operates on the registry via _core_config_get/_core_config_set. Plan-level fixes: - settings_enumerate jq filter rewritten (the original paths(scalars+objects+arrays) addition does not work — replaced with a paths walk that filters out array-index components and object-typed leaves). - Renamed local var `path` to `id` in settings strategy: zsh ties lowercase `path` to `$PATH` as an array, corrupting the env when the function does `local path=...`. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §3, §7.1. --- lib/account/sync/strategies/structured.zsh | 299 ++++++++++++++++++ .../sync/strategies/structured_test.bats | 217 +++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 lib/account/sync/strategies/structured.zsh create mode 100644 lib/account/sync/strategies/structured_test.bats diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh new file mode 100644 index 0000000..4e21c7f --- /dev/null +++ b/lib/account/sync/strategies/structured.zsh @@ -0,0 +1,299 @@ +#!/usr/bin/env zsh +# Strategy module for "structured" sync types (JSON-key merges): +# - mcp → /.claude.json `.mcpServers` +# - settings → /settings.json (top-level + nested keys; .hooks excluded) +# - prefs → $CKIPPER_REGISTRY `.accounts..preferences` +# +# Each type implements the strategy contract documented in engine.zsh: +# _ckipper_account_sync__{enumerate,compare,summary,diff,apply} +# +# All apply functions go through _core_sync_json_atomic_write which: +# 1. Writes the candidate JSON to a tmpfile +# 2. Validates with `jq -e .` +# 3. mv's into place ONLY if validation passes (Safeguard #4) + +# Validate a JSON file with jq. No output; exit code is the signal. +# +# Args: $1 — path to a JSON file (must exist). +# Returns: 0 if valid; non-zero if invalid or jq unavailable. +_core_sync_json_validate() { + jq -e . "$1" >/dev/null 2>&1 +} + +# Write JSON to a target path atomically with validation. Steps: +# 1. mktemp peer of target +# 2. write the candidate JSON pretty-printed via jq +# 3. validate via _core_sync_json_validate; abort on failure (no clobber) +# 4. mv into place +# +# Args: $1 — target path; $2 — candidate JSON string. +# Returns: 0 on commit; 1 on jq parse error; 2 on mv failure. +# Errors (stderr): "Refusing to write invalid JSON to " +_core_sync_json_atomic_write() { + local target="$1" json="$2" + mkdir -p "${target:h}" + local tmp; tmp=$(mktemp "${target}.XXXXXX") + echo "$json" | jq '.' > "$tmp" 2>/dev/null + if ! _core_sync_json_validate "$tmp"; then + echo "Refusing to write invalid JSON to $target" >&2 + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$target" || return 2 +} + +# ── MCP strategy ───────────────────────────────────────────────────────── + +# Enumerate every MCP server name in the source's .claude.json. Empty stdout +# when the file is missing or .mcpServers is empty/null. +# +# Args: $1 — source account dir. +# Returns: 0; prints "\t" per line (id and display are the same here). +_ckipper_account_sync_mcp_enumerate() { + local src="$1" + local file="$src/.claude.json" + [[ ! -f "$file" ]] && return 0 + jq -r '.mcpServers // {} | keys[]? | "\(.)\t\(.)"' "$file" 2>/dev/null +} + +# Compare a single MCP server between source and destination. +# +# Args: $1 — src; $2 — dst; $3 — server name. +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_ckipper_account_sync_mcp_compare() { + local src="$1" dst="$2" name="$3" + local s d + s=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$src/.claude.json" 2>/dev/null) + d=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$dst/.claude.json" 2>/dev/null) + if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi + echo "overwrite" +} + +# One-line summary of the change for the preview table. +# +# Args: $1 — src; $2 — dst; $3 — server name. +# Returns: 0; prints summary text. +_ckipper_account_sync_mcp_summary() { + local src="$1" dst="$2" name="$3" + local status; status=$(_ckipper_account_sync_mcp_compare "$src" "$dst" "$name") + case "$status" in + new) echo "new" ;; + overwrite) echo "overwrite — server config changed" ;; + unchanged) echo "unchanged" ;; + esac +} + +# Full diff for drill-down view: jq pretty-print of source vs destination. +# +# Args: $1 — src; $2 — dst; $3 — server name. +# Returns: 0; prints labeled before/after blocks. +_ckipper_account_sync_mcp_diff() { + local src="$1" dst="$2" name="$3" + echo "── source ($src/.claude.json:.mcpServers.$name) ──" + jq --arg n "$name" '.mcpServers[$n] // null' "$src/.claude.json" + echo "── destination ($dst/.claude.json:.mcpServers.$name) ──" + jq --arg n "$name" '.mcpServers[$n] // null' "$dst/.claude.json" 2>/dev/null +} + +# Merge a single server from src into dst's .claude.json. Backs up the +# destination's .claude.json before writing. +# +# Args: $1 — src; $2 — dst; $3 — server name; $4 — backup_dir. +# Returns: 0 on success; non-zero on jq/write failure. +_ckipper_account_sync_mcp_apply() { + local src="$1" dst="$2" name="$3" backup_dir="$4" + _core_account_sync_backup_file "$backup_dir" "$dst/.claude.json" ".claude.json" || return 1 + local server_obj + server_obj=$(jq -c --arg n "$name" '.mcpServers[$n]' "$src/.claude.json") + [[ -f "$dst/.claude.json" ]] || echo '{}' > "$dst/.claude.json" + local merged + merged=$(jq --arg n "$name" --argjson v "$server_obj" \ + '.mcpServers = (.mcpServers // {}) | .mcpServers[$n] = $v' "$dst/.claude.json") + _core_sync_json_atomic_write "$dst/.claude.json" "$merged" +} + +# ── Settings strategy ──────────────────────────────────────────────────── + +# Enumerate jq paths the user can sync. Recursion stops at scalars or +# top-level keys whose value is a primitive; objects are enumerated as +# their leaf paths so the user can sync just `.permissions.allow` without +# touching `.permissions.deny`. +# +# Excludes the `.hooks` block — the user-hooks sync type owns it. +# +# Args: $1 — source account dir. +# Returns: 0; prints "\t" per line. +_ckipper_account_sync_settings_enumerate() { + local src="$1" + local file="$src/settings.json" + [[ ! -f "$file" ]] && return 0 + jq -r ' + . as $root + | [paths] + | map(select(([.[]] | map(type == "number") | any | not))) + | map(select(length > 0)) + | .[] + | . as $p + | select(($root | getpath($p)) | type != "object") + | ($p | map(tostring) | join(".")) as $k + | select($k | startswith("hooks") | not) + | "\($k)\t\($k)" + ' "$file" 2>/dev/null +} + +# Compare a jq-path between source and destination. +# +# Args: $1 — src; $2 — dst; $3 — jq path (no leading dot). +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_ckipper_account_sync_settings_compare() { + local src="$1" dst="$2" id="$3" + local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + local s d + s=$(jq -c "$jq_path // null" "$src/settings.json" 2>/dev/null) + d=$(jq -c "$jq_path // null" "$dst/settings.json" 2>/dev/null) + if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi + echo "overwrite" +} + +# Convert a dotted id like "permissions.allow" into a jq filter ".permissions.allow". +# Bare id goes to the empty filter "." which selects the document root — +# never used in practice because enumerate filters to leaves. +# +# Note: arg variable is `id` (not `path`) — zsh ties lowercase `path` to `$PATH` +# as an array, which corrupts the env if used as a local var. +# +# Args: $1 — dotted id (no leading dot). +# Returns: 0; prints jq filter string. +_core_sync_settings_jq_path() { + local id="$1" + [[ -z "$id" ]] && { echo "."; return 0; } + echo ".$id" +} + +# One-line summary for the preview table. +# +# Args: $1 — src; $2 — dst; $3 — jq path. +# Returns: 0; prints summary. +_ckipper_account_sync_settings_summary() { + local src="$1" dst="$2" id="$3" + local status; status=$(_ckipper_account_sync_settings_compare "$src" "$dst" "$id") + case "$status" in + new) echo "new key" ;; + overwrite) echo "overwrite — value changed" ;; + unchanged) echo "unchanged" ;; + esac +} + +# Full diff for drill-down. +# +# Args: $1 — src; $2 — dst; $3 — jq path. +# Returns: 0; prints labeled before/after. +_ckipper_account_sync_settings_diff() { + local src="$1" dst="$2" id="$3" + local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + echo "── source ($src/settings.json:$id) ──" + jq "$jq_path" "$src/settings.json" + echo "── destination ($dst/settings.json:$id) ──" + jq "$jq_path" "$dst/settings.json" 2>/dev/null +} + +# Apply: write the source value at jq-path into the destination's settings.json +# using jq's `setpath`, preserving all sibling content. Uses atomic write + +# JSON validation gate. +# +# Args: $1 — src; $2 — dst; $3 — jq path; $4 — backup_dir. +# Returns: 0 on success; non-zero on jq/write failure. +_ckipper_account_sync_settings_apply() { + local src="$1" dst="$2" id="$3" backup_dir="$4" + _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + [[ -f "$dst/settings.json" ]] || echo '{}' > "$dst/settings.json" + local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + local val_json + val_json=$(jq -c "$jq_path" "$src/settings.json") + local id_array + id_array=$(jq -c -n --arg p "$id" '$p | split(".")') + local merged + merged=$(jq --argjson p "$id_array" --argjson v "$val_json" \ + 'setpath($p; $v)' "$dst/settings.json") + _core_sync_json_atomic_write "$dst/settings.json" "$merged" +} + +# ── Prefs strategy ─────────────────────────────────────────────────────── +# +# Operates on the registry (accounts.json), not per-account dirs. The engine +# passes account NAMES through the dir args. This is the only strategy that +# uses CKIPPER_REGISTRY rather than the dir paths. +# +# Depends on lib/config/schema.zsh (account-scope key list) and +# lib/core/config.zsh (_core_config_get/_core_config_set). + +# Enumerate every account-scope schema key. +# +# Args: $1 — source account name (unused; kept for contract uniformity). +# Returns: 0; prints "\t" per line. +_ckipper_account_sync_prefs_enumerate() { + local key + for key in "${(@k)_CKIPPER_SCHEMA_TYPE}"; do + [[ "${_CKIPPER_SCHEMA_SCOPE[$key]}" == "account" ]] || continue + echo "$key\t$key" + done +} + +# Compare one preference key. +# +# Args: $1 — source account name; $2 — dst account name; $3 — schema key. +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_ckipper_account_sync_prefs_compare() { + local src="$1" dst="$2" key="$3" + local s d + s=$(_core_config_get "$key" "$src") + d=$(_core_config_get "$key" "$dst") + [[ "$s" == "$d" ]] && { echo "unchanged"; return 0; } + # `new` is rare for prefs — the v2 migration ensures every account has + # all keys with defaults. We still distinguish: if the destination has + # no override (raw read empty), call it new. + local raw; raw=$(_core_config_read_account "$key" "$dst") + [[ -z "$raw" ]] && { echo "new"; return 0; } + echo "overwrite" +} + +# Summary: for prefs the value is short, render inline. +# +# Args: $1 — src name; $2 — dst name; $3 — key. +# Returns: 0; prints e.g. "false → true" or "(default) → true". +_ckipper_account_sync_prefs_summary() { + local src="$1" dst="$2" key="$3" + local s d_raw d_eff + s=$(_core_config_get "$key" "$src") + d_raw=$(_core_config_read_account "$key" "$dst") + d_eff=$(_core_config_get "$key" "$dst") + if [[ -z "$d_raw" ]]; then + echo "(default $d_eff) → $s" + else + echo "$d_eff → $s" + fi +} + +# Diff: prefs are scalar — diff is the same as summary. +# +# Args: $1 — src name; $2 — dst name; $3 — key. +# Returns: 0; prints summary. +_ckipper_account_sync_prefs_diff() { + _ckipper_account_sync_prefs_summary "$@" +} + +# Apply via _core_config_set (uses registry locking). The registry write +# itself is its own atomic operation, so we do NOT need the JSON validation +# gate here. The backup is the file copy of CKIPPER_REGISTRY into the +# backup dir — recorded in the manifest as path "accounts.json". +# +# Args: $1 — src name; $2 — dst name; $3 — key; $4 — backup_dir. +# Returns: 0 on success; non-zero on read/write failure. +_ckipper_account_sync_prefs_apply() { + local src="$1" dst="$2" key="$3" backup_dir="$4" + _core_account_sync_backup_file "$backup_dir" "$CKIPPER_REGISTRY" "accounts.json" || return 1 + local val; val=$(_core_config_get "$key" "$src") + _core_config_set "$key" "$val" "$dst" +} diff --git a/lib/account/sync/strategies/structured_test.bats b/lib/account/sync/strategies/structured_test.bats new file mode 100644 index 0000000..b48f4bd --- /dev/null +++ b/lib/account/sync/strategies/structured_test.bats @@ -0,0 +1,217 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/strategies/structured.zsh. + +load "${BATS_TEST_DIRNAME}/../../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; $*" +} + +@test "_core_sync_json_validate accepts valid JSON" { + local f="$TMP_HOME/ok.json" + echo '{"a": 1}' > "$f" + run_in_zsh "_core_sync_json_validate '$f' && echo OK" + [[ "$output" == *"OK"* ]] +} + +@test "_core_sync_json_validate rejects invalid JSON" { + local f="$TMP_HOME/bad.json" + echo '{"a": 1' > "$f" + run_in_zsh "_core_sync_json_validate '$f'" + [ "$status" -ne 0 ] +} + +@test "_core_sync_json_atomic_write writes via tmp + mv" { + local f="$TMP_HOME/out.json" + run_in_zsh "_core_sync_json_atomic_write '$f' '{\"x\":42}'; cat '$f'" + [[ "$output" == *'"x": 42'* ]] +} + +@test "_core_sync_json_atomic_write refuses to commit invalid JSON" { + local f="$TMP_HOME/out2.json" + run_in_zsh "_core_sync_json_atomic_write '$f' 'not-json'" + [ "$status" -ne 0 ] + [[ ! -f "$f" ]] +} + +# ── MCP strategy ───────────────────────────────────────────────────────── + +@test "mcp_enumerate lists every server name" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"mcpServers":{"github":{"command":"x"},"vibma":{"command":"y"}}}' > "$src/.claude.json" + run_in_zsh "_ckipper_account_sync_mcp_enumerate '$src' | sort" + [[ "$output" == *"github"* ]] + [[ "$output" == *"vibma"* ]] +} + +@test "mcp_enumerate emits empty when no .claude.json" { + local src="$TMP_HOME/src" + mkdir -p "$src" + run_in_zsh "_ckipper_account_sync_mcp_enumerate '$src' | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "mcp_compare: new when destination lacks the server" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + echo '{"mcpServers":{}}' > "$dst/.claude.json" + run_in_zsh "_ckipper_account_sync_mcp_compare '$src' '$dst' github" + [[ "$output" == *"new"* ]] +} + +@test "mcp_compare: unchanged when both sides match" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + local both='{"mcpServers":{"github":{"command":"x","args":["a"]}}}' + echo "$both" > "$src/.claude.json" + echo "$both" > "$dst/.claude.json" + run_in_zsh "_ckipper_account_sync_mcp_compare '$src' '$dst' github" + [[ "$output" == *"unchanged"* ]] +} + +@test "mcp_compare: overwrite when contents differ" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"new"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"github":{"command":"old"}}}' > "$dst/.claude.json" + run_in_zsh "_ckipper_account_sync_mcp_compare '$src' '$dst' github" + [[ "$output" == *"overwrite"* ]] +} + +@test "mcp_apply merges into destination preserving other servers" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"other":{"command":"y"}},"foo":"bar"}' > "$dst/.claude.json" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_mcp_apply '$src' '$dst' github \"\$backup_dir\" + jq '.mcpServers | keys | sort | join(\",\")' '$dst/.claude.json' + jq -r '.foo' '$dst/.claude.json'" + [[ "$output" == *'"github,other"'* ]] + [[ "$output" == *"bar"* ]] +} + +# ── Settings strategy ──────────────────────────────────────────────────── + +@test "settings_enumerate emits top-level keys" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"statusLine":{"command":"x"},"env":{"FOO":"1"},"model":"opus"}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_settings_enumerate '$src' | cut -f1 | sort | tr '\n' ','" + [[ "$output" == *"env.FOO"* ]] + [[ "$output" == *"model"* ]] + [[ "$output" == *"statusLine.command"* ]] +} + +@test "settings_enumerate excludes the .hooks block" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"statusLine":{"command":"x"},"hooks":{"PreToolUse":[]}}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_settings_enumerate '$src' | cut -f1" + [[ "$output" != *"hooks"* ]] + [[ "$output" == *"statusLine"* ]] +} + +@test "settings_enumerate produces nested jq paths for object-typed values" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"permissions":{"allow":["Bash(ls:*)"],"deny":["Bash(rm:*)"]}}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_settings_enumerate '$src' | cut -f1 | sort | tr '\n' ','" + [[ "$output" == *"permissions.allow,permissions.deny,"* ]] +} + +@test "settings_compare: new when path missing in destination" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"model":"opus"}' > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh "_ckipper_account_sync_settings_compare '$src' '$dst' model" + [[ "$output" == *"new"* ]] +} + +@test "settings_compare: unchanged when values match" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"model":"opus"}' > "$src/settings.json" + echo '{"model":"opus"}' > "$dst/settings.json" + run_in_zsh "_ckipper_account_sync_settings_compare '$src' '$dst' model" + [[ "$output" == *"unchanged"* ]] +} + +@test "settings_apply writes nested path without disturbing siblings" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"permissions":{"allow":["Bash(ls:*)"]}}' > "$src/settings.json" + echo '{"permissions":{"deny":["Bash(rm:*)"]},"unrelated":"keep"}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_settings_apply '$src' '$dst' 'permissions.allow' \"\$backup_dir\" + jq -c '.permissions.allow' '$dst/settings.json' + jq -c '.permissions.deny' '$dst/settings.json' + jq -r '.unrelated' '$dst/settings.json'" + [[ "$output" == *'["Bash(ls:*)"]'* ]] + [[ "$output" == *'["Bash(rm:*)"]'* ]] + [[ "$output" == *"keep"* ]] +} + +# ── Prefs strategy ─────────────────────────────────────────────────────── + +setup_prefs_registry() { + cat > "$CKIPPER_REGISTRY" < Date: Sat, 2 May 2026 01:49:13 -0600 Subject: [PATCH 04/18] feat(sync): files-based strategies (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit files_flat covers claude-md, agents, commands, output-styles via shared helpers parameterized by _CKIPPER_SYNC_FILES_FLAT_PATH; per-type wrappers satisfy the strategy naming convention. Comparison via sha256. files_dir covers skills with cp -a (preserves symlinks) and a recursive content hash that compares symlinks by target path, regular dirs by concatenated per-file hashes. Plan-level fix: renamed local var `status` to `cmp_status` in summary functions — `status` is read-only in zsh (alias for $?). Same rename applied to mcp/settings strategies. Also renamed `path` to `target` in _core_sync_dir_hash (zsh ties lowercase `path` to $PATH). Refs: docs/plans/2026-05-02-sync-overhaul-design.md §3, §7.1. --- lib/account/sync/strategies/files_dir.zsh | 130 +++++++++++++++++ .../sync/strategies/files_dir_test.bats | 73 ++++++++++ lib/account/sync/strategies/files_flat.zsh | 133 ++++++++++++++++++ .../sync/strategies/files_flat_test.bats | 97 +++++++++++++ lib/account/sync/strategies/structured.zsh | 8 +- 5 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 lib/account/sync/strategies/files_dir.zsh create mode 100644 lib/account/sync/strategies/files_dir_test.bats create mode 100644 lib/account/sync/strategies/files_flat.zsh create mode 100644 lib/account/sync/strategies/files_flat_test.bats diff --git a/lib/account/sync/strategies/files_dir.zsh b/lib/account/sync/strategies/files_dir.zsh new file mode 100644 index 0000000..6f65efc --- /dev/null +++ b/lib/account/sync/strategies/files_dir.zsh @@ -0,0 +1,130 @@ +#!/usr/bin/env zsh +# Strategy module for "files-dir" sync types — per-directory items: +# - skills → /skills// +# +# Each item is a top-level entry under the type's subdir (regular dir OR +# symlink). cp -a preserves symlink semantics, so a destination's symlink +# resolves to the same target as the source's. +# +# Comparison is a recursive content hash (concatenation of per-file hashes +# in lexical order, then hashed). Symlinks are compared by their target +# path, NOT by the contents of the target (so two symlinks pointing at +# the same dir compare equal even if the shared target diverges later). + +# Per-type relative subdir under the account dir. +typeset -gA _CKIPPER_SYNC_FILES_DIR_PATH=( + [skills]="skills" +) + +# Compute a content hash for a directory or symlink. For symlinks: the +# target path. For regular dirs: concatenated sha256 of every file in +# lexical order, hashed once more. +# +# Note: arg variable is `target` (not `path`) — zsh ties lowercase `path` to +# `$PATH` as an array, which corrupts the env if used as a local var. +# +# Args: $1 — path to directory or symlink. +# Returns: 0; prints hex hash or empty if path is missing. +_core_sync_dir_hash() { + local target="$1" + [[ ! -e "$target" ]] && { echo ""; return 0; } + if [[ -L "$target" ]]; then + readlink "$target" | shasum -a 256 | cut -d' ' -f1 + return 0 + fi + [[ ! -d "$target" ]] && { echo ""; return 0; } + (cd "$target" && find . -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 shasum -a 256 2>/dev/null) \ + | shasum -a 256 | cut -d' ' -f1 +} + +# Enumerate top-level items under the type's subdir. Picks up dirs AND +# symlinks. We use a manual loop so broken symlinks also enumerate, with +# apply later catching the failure. +# +# Args: $1 — type id; $2 — source account dir. +# Returns: 0; prints "\t" per item. +_core_sync_files_dir_enumerate() { + local type="$1" src="$2" + local sub="${_CKIPPER_SYNC_FILES_DIR_PATH[$type]}" + local root="$src/$sub" + [[ ! -d "$root" ]] && return 0 + local entry + for entry in "$root"/*(NDoN); do + [[ -d "$entry" || -L "$entry" ]] || continue + echo "$sub/${entry:t}\t${entry:t}" + done +} + +# Compare item by directory hash. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_core_sync_files_dir_compare() { + local type="$1" src="$2" dst="$3" rel="$4" + [[ ! -e "$dst/$rel" ]] && { echo "new"; return 0; } + local sh dh + sh=$(_core_sync_dir_hash "$src/$rel") + dh=$(_core_sync_dir_hash "$dst/$rel") + [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } + echo "overwrite" +} + +# Summary: file count delta if both sides exist, else status word. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. +# Returns: 0; prints summary. +_core_sync_files_dir_summary() { + local type="$1" src="$2" dst="$3" rel="$4" + local cmp_status; cmp_status=$(_core_sync_files_dir_compare "$type" "$src" "$dst" "$rel") + case "$cmp_status" in + new) echo "new directory" ;; + overwrite) + local sn dn + sn=$(find "$src/$rel" -type f 2>/dev/null | wc -l | tr -d ' ') + dn=$(find "$dst/$rel" -type f 2>/dev/null | wc -l | tr -d ' ') + echo "overwrite — $dn → $sn files" + ;; + unchanged) echo "unchanged" ;; + esac +} + +# Diff: list of file changes via diff -rq between trees. For symlinks, +# print the target paths. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. +# Returns: 0 always. +_core_sync_files_dir_diff() { + local type="$1" src="$2" dst="$3" rel="$4" + if [[ -L "$src/$rel" || -L "$dst/$rel" ]]; then + echo "── source symlink ──" + [[ -L "$src/$rel" ]] && readlink "$src/$rel" + echo "── destination symlink ──" + [[ -L "$dst/$rel" ]] && readlink "$dst/$rel" + return 0 + fi + diff -ruN "$dst/$rel" "$src/$rel" 2>/dev/null + return 0 +} + +# Apply: backup the destination dir (if present), remove it, then cp -a. +# `rm -rf` is safe because the prior copy is in the backup dir; rollback +# restores it. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath; $5 — backup_dir. +# Returns: 0 on success; 1 on cp failure. +_core_sync_files_dir_apply() { + local type="$1" src="$2" dst="$3" rel="$4" backup_dir="$5" + _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + rm -rf "$dst/$rel" + mkdir -p "$dst/${rel:h}" + cp -a "$src/$rel" "$dst/$rel" +} + +# Per-type wrappers for the strategy contract. +_ckipper_account_sync_skills_enumerate() { _core_sync_files_dir_enumerate skills "$@"; } +_ckipper_account_sync_skills_compare() { _core_sync_files_dir_compare skills "$@"; } +_ckipper_account_sync_skills_summary() { _core_sync_files_dir_summary skills "$@"; } +_ckipper_account_sync_skills_diff() { _core_sync_files_dir_diff skills "$@"; } +_ckipper_account_sync_skills_apply() { _core_sync_files_dir_apply skills "$@"; } diff --git a/lib/account/sync/strategies/files_dir_test.bats b/lib/account/sync/strategies/files_dir_test.bats new file mode 100644 index 0000000..add2624 --- /dev/null +++ b/lib/account/sync/strategies/files_dir_test.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/strategies/files_dir.zsh. + +load "${BATS_TEST_DIRNAME}/../../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/files_dir.zsh\"; $*" +} + +@test "skills_enumerate lists each subdir under skills/" { + local src="$TMP_HOME/src" + mkdir -p "$src/skills/foo" "$src/skills/bar" + run_in_zsh "_ckipper_account_sync_skills_enumerate '$src' | cut -f2 | sort | tr '\n' ','" + [[ "$output" == *"bar,foo,"* ]] +} + +@test "skills_enumerate includes symlinks (treated as items)" { + local src="$TMP_HOME/src" + mkdir -p "$src/skills" "$TMP_HOME/shared/some-skill" + ln -s "$TMP_HOME/shared/some-skill" "$src/skills/some-skill" + run_in_zsh "_ckipper_account_sync_skills_enumerate '$src' | cut -f2" + [[ "$output" == *"some-skill"* ]] +} + +@test "skills_enumerate emits empty when no skills/ dir" { + local src="$TMP_HOME/src" + mkdir -p "$src" + run_in_zsh "_ckipper_account_sync_skills_enumerate '$src' | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "skills_compare: new when dst lacks the dir" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/skills/foo" "$dst/skills" + run_in_zsh "_ckipper_account_sync_skills_compare '$src' '$dst' skills/foo" + [[ "$output" == *"new"* ]] +} + +@test "skills_apply preserves symlinks via cp -a" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/skills" "$dst/skills" + mkdir -p "$TMP_HOME/shared/sk1" + echo "skill content" > "$TMP_HOME/shared/sk1/SKILL.md" + ln -s "$TMP_HOME/shared/sk1" "$src/skills/sk1" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_skills_apply '$src' '$dst' skills/sk1 \"\$backup_dir\" + [[ -L '$dst/skills/sk1' ]] && echo IS_SYMLINK || echo NOT_SYMLINK + readlink '$dst/skills/sk1'" + [[ "$output" == *"IS_SYMLINK"* ]] + [[ "$output" == *"$TMP_HOME/shared/sk1"* ]] +} + +@test "skills_apply copies regular directory recursively" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/skills/foo" "$dst/skills" + echo "x" > "$src/skills/foo/SKILL.md" + echo "y" > "$src/skills/foo/extra.md" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_skills_apply '$src' '$dst' skills/foo \"\$backup_dir\" + cat '$dst/skills/foo/SKILL.md' + cat '$dst/skills/foo/extra.md'" + [[ "$output" == *"x"* ]] + [[ "$output" == *"y"* ]] +} diff --git a/lib/account/sync/strategies/files_flat.zsh b/lib/account/sync/strategies/files_flat.zsh new file mode 100644 index 0000000..ad25c7b --- /dev/null +++ b/lib/account/sync/strategies/files_flat.zsh @@ -0,0 +1,133 @@ +#!/usr/bin/env zsh +# Strategy module for "files-flat" sync types — flat .md file collections +# under the account dir: +# - claude-md → /CLAUDE.md (single file, not a directory) +# - agents → /agents/*.md +# - commands → /commands/*.md +# - output-styles → /output_styles/*.md +# +# All four types share the contract implementations; the only thing that +# varies is the subpath, declared in _CKIPPER_SYNC_FILES_FLAT_PATH. +# +# Items are identified by their relative path from the account dir +# (e.g. "agents/foo.md"). claude-md's single item id is "CLAUDE.md". + +# Per-type relative path under the account dir. +typeset -gA _CKIPPER_SYNC_FILES_FLAT_PATH=( + [claude-md]="CLAUDE.md" + [agents]="agents" + [commands]="commands" + [output-styles]="output_styles" +) + +# Compute sha256 of a file. Uses shasum (macOS-friendly) which is available +# in both macOS and Linux containers. Empty stdout if file missing. +# +# Args: $1 — file path. +# Returns: 0; prints hex hash or empty string. +_core_sync_file_hash() { + local f="$1" + [[ ! -f "$f" ]] && { echo ""; return 0; } + shasum -a 256 "$f" | cut -d' ' -f1 +} + +# Generic enumerator. Lists items as "\t". +# - For claude-md: a single line iff CLAUDE.md exists. +# - For others: every *.md file under the subdir. +# +# Args: $1 — type id; $2 — source account dir. +# Returns: 0; prints items one per line. +_core_sync_files_flat_enumerate() { + local type="$1" src="$2" + local sub="${_CKIPPER_SYNC_FILES_FLAT_PATH[$type]}" + local target="$src/$sub" + if [[ "$type" == "claude-md" ]]; then + [[ ! -f "$target" ]] && return 0 + echo "$sub\t$sub" + return 0 + fi + [[ ! -d "$target" ]] && return 0 + local f + for f in "$target"/*.md(N); do + echo "${f#$src/}\t${f:t}" + done +} + +# Generic compare via content hash. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath (the item id). +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_core_sync_files_flat_compare() { + local type="$1" src="$2" dst="$3" rel="$4" + [[ ! -f "$dst/$rel" ]] && { echo "new"; return 0; } + local sh dh + sh=$(_core_sync_file_hash "$src/$rel") + dh=$(_core_sync_file_hash "$dst/$rel") + [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } + echo "overwrite" +} + +# Generic summary: line-count diff via diff --stat-equivalent. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. +# Returns: 0; prints "new" | "overwrite — +A/-D lines" | "unchanged". +_core_sync_files_flat_summary() { + local type="$1" src="$2" dst="$3" rel="$4" + local cmp_status; cmp_status=$(_core_sync_files_flat_compare "$type" "$src" "$dst" "$rel") + [[ "$cmp_status" != "overwrite" ]] && { echo "$cmp_status"; return 0; } + local stats; stats=$(diff "$dst/$rel" "$src/$rel" 2>/dev/null \ + | awk 'BEGIN{a=0;d=0} /^>/{a++} /^/dev/null + return 0 +} + +# Generic apply: backup destination if present, then cp. +# +# Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath; $5 — backup_dir. +# Returns: 0 on success; 1 on cp failure. +_core_sync_files_flat_apply() { + local type="$1" src="$2" dst="$3" rel="$4" backup_dir="$5" + _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + mkdir -p "$dst/${rel:h}" + cp -a "$src/$rel" "$dst/$rel" +} + +# Per-type contract bindings — one-line wrappers over the generic helpers +# so each type satisfies the strategy naming convention. + +# claude-md wrappers +_ckipper_account_sync_claude-md_enumerate() { _core_sync_files_flat_enumerate claude-md "$@"; } +_ckipper_account_sync_claude-md_compare() { _core_sync_files_flat_compare claude-md "$@"; } +_ckipper_account_sync_claude-md_summary() { _core_sync_files_flat_summary claude-md "$@"; } +_ckipper_account_sync_claude-md_diff() { _core_sync_files_flat_diff claude-md "$@"; } +_ckipper_account_sync_claude-md_apply() { _core_sync_files_flat_apply claude-md "$@"; } + +# agents wrappers +_ckipper_account_sync_agents_enumerate() { _core_sync_files_flat_enumerate agents "$@"; } +_ckipper_account_sync_agents_compare() { _core_sync_files_flat_compare agents "$@"; } +_ckipper_account_sync_agents_summary() { _core_sync_files_flat_summary agents "$@"; } +_ckipper_account_sync_agents_diff() { _core_sync_files_flat_diff agents "$@"; } +_ckipper_account_sync_agents_apply() { _core_sync_files_flat_apply agents "$@"; } + +# commands wrappers +_ckipper_account_sync_commands_enumerate() { _core_sync_files_flat_enumerate commands "$@"; } +_ckipper_account_sync_commands_compare() { _core_sync_files_flat_compare commands "$@"; } +_ckipper_account_sync_commands_summary() { _core_sync_files_flat_summary commands "$@"; } +_ckipper_account_sync_commands_diff() { _core_sync_files_flat_diff commands "$@"; } +_ckipper_account_sync_commands_apply() { _core_sync_files_flat_apply commands "$@"; } + +# output-styles wrappers +_ckipper_account_sync_output-styles_enumerate() { _core_sync_files_flat_enumerate output-styles "$@"; } +_ckipper_account_sync_output-styles_compare() { _core_sync_files_flat_compare output-styles "$@"; } +_ckipper_account_sync_output-styles_summary() { _core_sync_files_flat_summary output-styles "$@"; } +_ckipper_account_sync_output-styles_diff() { _core_sync_files_flat_diff output-styles "$@"; } +_ckipper_account_sync_output-styles_apply() { _core_sync_files_flat_apply output-styles "$@"; } diff --git a/lib/account/sync/strategies/files_flat_test.bats b/lib/account/sync/strategies/files_flat_test.bats new file mode 100644 index 0000000..5877c1e --- /dev/null +++ b/lib/account/sync/strategies/files_flat_test.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/strategies/files_flat.zsh. + +load "${BATS_TEST_DIRNAME}/../../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/files_flat.zsh\"; $*" +} + +@test "agents_enumerate lists .md files in agents/" { + local src="$TMP_HOME/src" + mkdir -p "$src/agents" + echo "a" > "$src/agents/foo.md" + echo "b" > "$src/agents/bar.md" + run_in_zsh "_ckipper_account_sync_agents_enumerate '$src' | cut -f1 | sort | tr '\n' ','" + [[ "$output" == *"agents/bar.md,agents/foo.md,"* ]] +} + +@test "commands_enumerate lists .md files in commands/" { + local src="$TMP_HOME/src" + mkdir -p "$src/commands" + echo "x" > "$src/commands/deploy.md" + run_in_zsh "_ckipper_account_sync_commands_enumerate '$src' | cut -f1" + [[ "$output" == *"commands/deploy.md"* ]] +} + +@test "claude-md_enumerate emits a single CLAUDE.md entry when present" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo "user memory" > "$src/CLAUDE.md" + run_in_zsh "_ckipper_account_sync_claude-md_enumerate '$src' | cut -f1" + [[ "$output" == *"CLAUDE.md"* ]] +} + +@test "claude-md_enumerate is empty when CLAUDE.md absent" { + local src="$TMP_HOME/src" + mkdir -p "$src" + run_in_zsh "_ckipper_account_sync_claude-md_enumerate '$src' | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "files_flat_compare: new when destination lacks the file" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/agents" "$dst/agents" + echo "x" > "$src/agents/foo.md" + run_in_zsh "_ckipper_account_sync_agents_compare '$src' '$dst' agents/foo.md" + [[ "$output" == *"new"* ]] +} + +@test "files_flat_compare: unchanged when contents match" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/agents" "$dst/agents" + echo "x" > "$src/agents/foo.md" + echo "x" > "$dst/agents/foo.md" + run_in_zsh "_ckipper_account_sync_agents_compare '$src' '$dst' agents/foo.md" + [[ "$output" == *"unchanged"* ]] +} + +@test "files_flat_compare: overwrite when contents differ" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/agents" "$dst/agents" + echo "new" > "$src/agents/foo.md" + echo "old" > "$dst/agents/foo.md" + run_in_zsh "_ckipper_account_sync_agents_compare '$src' '$dst' agents/foo.md" + [[ "$output" == *"overwrite"* ]] +} + +@test "files_flat_summary returns +N/-N line stats for overwrite" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + printf 'a\nb\nc\n' > "$src/CLAUDE.md" + printf 'a\nx\n' > "$dst/CLAUDE.md" + run_in_zsh "_ckipper_account_sync_claude-md_summary '$src' '$dst' CLAUDE.md" + [[ "$output" == *"+"* ]] + [[ "$output" == *"-"* ]] + [[ "$output" == *"lines"* ]] +} + +@test "files_flat_apply copies file with backup of prior content" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/commands" "$dst/commands" + echo "new content" > "$src/commands/deploy.md" + echo "old content" > "$dst/commands/deploy.md" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_commands_apply '$src' '$dst' commands/deploy.md \"\$backup_dir\" + cat '$dst/commands/deploy.md' + cat \"\$backup_dir/commands/deploy.md\"" + [[ "$output" == *"new content"* ]] + [[ "$output" == *"old content"* ]] +} diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh index 4e21c7f..289e9e2 100644 --- a/lib/account/sync/strategies/structured.zsh +++ b/lib/account/sync/strategies/structured.zsh @@ -76,8 +76,8 @@ _ckipper_account_sync_mcp_compare() { # Returns: 0; prints summary text. _ckipper_account_sync_mcp_summary() { local src="$1" dst="$2" name="$3" - local status; status=$(_ckipper_account_sync_mcp_compare "$src" "$dst" "$name") - case "$status" in + local cmp_status; cmp_status=$(_ckipper_account_sync_mcp_compare "$src" "$dst" "$name") + case "$cmp_status" in new) echo "new" ;; overwrite) echo "overwrite — server config changed" ;; unchanged) echo "unchanged" ;; @@ -178,8 +178,8 @@ _core_sync_settings_jq_path() { # Returns: 0; prints summary. _ckipper_account_sync_settings_summary() { local src="$1" dst="$2" id="$3" - local status; status=$(_ckipper_account_sync_settings_compare "$src" "$dst" "$id") - case "$status" in + local cmp_status; cmp_status=$(_ckipper_account_sync_settings_compare "$src" "$dst" "$id") + case "$cmp_status" in new) echo "new key" ;; overwrite) echo "overwrite — value changed" ;; unchanged) echo "unchanged" ;; From 2b346ad31515797216254c981352ced81a37d185 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:52:36 -0600 Subject: [PATCH 05/18] =?UTF-8?q?feat(sync):=20special=20strategies=20?= =?UTF-8?q?=E2=80=94=20statusline=20+=20hooks=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusline detects internal vs external script (path resolves under $src or not). Internal: backup + copy script + rewrite .command path. External: settings reference only. hooks filters $src/hooks/* against $CKIPPER_DIR/hooks/ (install allowlist) so only user-written hooks enumerate. Apply copies script + rewrites paired settings.hooks entries with src→dst path substitution. Plan-level fixes: - statusline replaced `local -n obj_ref` (bash nameref idiom that works inconsistently in zsh subshells) with a function that prints the rewritten JSON instead. - hooks rewrote the merge jq filter — original used `--slurpfile` (yields a 1-elem array) but indexed it as an object directly. Split into a filter + merge two-step, both expressed as ordinary jq. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §3, §7.1. --- lib/account/sync/strategies/hooks.zsh | 161 ++++++++++++++++++ lib/account/sync/strategies/hooks_test.bats | 72 ++++++++ lib/account/sync/strategies/statusline.zsh | 128 ++++++++++++++ .../sync/strategies/statusline_test.bats | 79 +++++++++ 4 files changed, 440 insertions(+) create mode 100644 lib/account/sync/strategies/hooks.zsh create mode 100644 lib/account/sync/strategies/hooks_test.bats create mode 100644 lib/account/sync/strategies/statusline.zsh create mode 100644 lib/account/sync/strategies/statusline_test.bats diff --git a/lib/account/sync/strategies/hooks.zsh b/lib/account/sync/strategies/hooks.zsh new file mode 100644 index 0000000..0a07f25 --- /dev/null +++ b/lib/account/sync/strategies/hooks.zsh @@ -0,0 +1,161 @@ +#!/usr/bin/env zsh +# Strategy module for "hooks" sync type — USER-WRITTEN HOOKS ONLY. +# +# A hook script /hooks/ is a sync candidate iff the filename +# does NOT match a ckipper-managed install hook in $CKIPPER_DIR/hooks/. +# This filter is computed at runtime so adding a new ckipper safety hook +# automatically excludes it from sync. +# +# Each enumerated item is (script-file, paired-settings-entry). The apply +# function does both: +# 1. Copy the script to the destination (with backup). +# 2. Rewrite the .hooks block in /settings.json to include the +# paired entry from /settings.json, with command paths rewritten +# to point at the destination's hooks dir. + +# Build the install-managed allowlist as a newline-separated set of basenames. +# +# Returns: 0; prints one filename per line. Empty if install hooks dir +# doesn't exist. +_core_sync_hooks_install_allowlist() { + local install_dir="$CKIPPER_DIR/hooks" + [[ ! -d "$install_dir" ]] && return 0 + local f + for f in "$install_dir"/*(N); do + [[ -f "$f" ]] || continue + echo "${f:t}" + done +} + +# Enumerate user-written hooks in /hooks/ — files NOT in the install +# allowlist. +# +# Args: $1 — source account dir. +# Returns: 0; prints "\t" per item. +_ckipper_account_sync_hooks_enumerate() { + local src="$1" + local hooks_dir="$src/hooks" + [[ ! -d "$hooks_dir" ]] && return 0 + local allowlist; allowlist=$(_core_sync_hooks_install_allowlist) + local f base + for f in "$hooks_dir"/*(N); do + [[ -f "$f" ]] || continue + base="${f:t}" + if [[ -n "$allowlist" ]] && echo "$allowlist" | grep -qx "$base"; then + continue + fi + echo "hooks/$base\t$base" + done +} + +# Compare: file content hash for the script. Settings entry coupling is +# transparent — if the script differs, the whole pair is treated as +# overwrite even if the .hooks entry is identical. +# +# Args: $1 — src; $2 — dst; $3 — relpath (hooks/). +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_ckipper_account_sync_hooks_compare() { + local src="$1" dst="$2" rel="$3" + [[ ! -f "$dst/$rel" ]] && { echo "new"; return 0; } + local sh dh + sh=$(_core_sync_file_hash "$src/$rel" 2>/dev/null) + dh=$(_core_sync_file_hash "$dst/$rel" 2>/dev/null) + [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } + echo "overwrite" +} + +# Summary: line-count delta + " (paired settings entry)" annotation. +# +# Args: $1 — src; $2 — dst; $3 — relpath. +# Returns: 0; prints summary. +_ckipper_account_sync_hooks_summary() { + local src="$1" dst="$2" rel="$3" + local cmp_status; cmp_status=$(_ckipper_account_sync_hooks_compare "$src" "$dst" "$rel") + case "$cmp_status" in + new) echo "new — paired with settings.hooks entry" ;; + overwrite) + local stats; stats=$(diff "$dst/$rel" "$src/$rel" 2>/dev/null \ + | awk 'BEGIN{a=0;d=0} /^>/{a++} /^/dev/null + echo "── paired settings.json entry: rewritten for destination dir on apply ──" + return 0 +} + +# Apply: copy script + write paired settings.hooks entry with rewritten paths. +# +# Args: $1 — src; $2 — dst; $3 — relpath; $4 — backup_dir. +# Returns: 0 on success; non-zero on cp/jq/write failure. +_ckipper_account_sync_hooks_apply() { + local src="$1" dst="$2" rel="$3" backup_dir="$4" + _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + mkdir -p "$dst/${rel:h}" + cp -a "$src/$rel" "$dst/$rel" || return 1 + chmod +x "$dst/$rel" 2>/dev/null + _core_sync_hooks_merge_settings "$src" "$dst" "$rel" +} + +# Merge a single user-hook's paired settings.json entries from src into dst, +# rewriting absolute paths from src dir → dst dir. Operates by: +# 1. Filtering src settings.hooks entries to those whose .command +# references "/" (the user hook being synced). +# 2. Rewriting each .command path's src prefix → dst prefix. +# 3. Appending the filtered+rewritten entries to dst's per-event arrays +# (creating events that didn't exist on dst). +# +# Args: $1 — src; $2 — dst; $3 — script relpath (hooks/). +# Returns: 0 on success; non-zero on jq/write failure. +_core_sync_hooks_merge_settings() { + local src="$1" dst="$2" rel="$3" + local src_settings="$src/settings.json" + local dst_settings="$dst/settings.json" + [[ ! -f "$src_settings" ]] && return 0 + [[ ! -f "$dst_settings" ]] && echo '{}' > "$dst_settings" + local script_basename="${rel:t}" + local filtered_src_hooks + filtered_src_hooks=$(_core_sync_hooks_filter_src "$src_settings" "$script_basename" "$src" "$dst") + local merged + merged=$(jq --argjson src_hooks "$filtered_src_hooks" ' + .hooks //= {} | + reduce ($src_hooks | to_entries[]) as $event ( + .; + .hooks[$event.key] //= [] | + .hooks[$event.key] += $event.value + ) + ' "$dst_settings") + _core_sync_json_atomic_write "$dst_settings" "$merged" +} + +# Helper: filter src settings.hooks to only those entries that reference +# the given script basename, with the src→dst path rewrite applied. +# +# Args: $1 — src settings.json path; $2 — script basename; +# $3 — src dir; $4 — dst dir. +# Returns: 0; prints filtered hooks JSON object (may be empty {}). +_core_sync_hooks_filter_src() { + local src_settings="$1" script_basename="$2" src="$3" dst="$4" + jq --arg sb "$script_basename" --arg src "$src" --arg dst "$dst" ' + (.hooks // {}) + | to_entries + | map({ + key: .key, + value: ( + .value + | map(.hooks |= map(select(.command | tostring | contains("/" + $sb)))) + | map(select(.hooks | length > 0)) + | map(.hooks |= map(.command |= gsub($src; $dst))) + ) + }) + | map(select(.value | length > 0)) + | from_entries + ' "$src_settings" +} diff --git a/lib/account/sync/strategies/hooks_test.bats b/lib/account/sync/strategies/hooks_test.bats new file mode 100644 index 0000000..945352f --- /dev/null +++ b/lib/account/sync/strategies/hooks_test.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/strategies/hooks.zsh. + +load "${BATS_TEST_DIRNAME}/../../../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + # Simulate ckipper install hook set so the allowlist filter has data. + mkdir -p "$CKIPPER_DIR/hooks" + touch "$CKIPPER_DIR/hooks/bash-guardrails.sh" + touch "$CKIPPER_DIR/hooks/protect-claude-config.sh" + touch "$CKIPPER_DIR/hooks/docker-context.sh" + touch "$CKIPPER_DIR/hooks/notify-bell.sh" +} +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/files_flat.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/hooks.zsh\"; $*" +} + +@test "hooks_enumerate skips ckipper safety hooks (filename allowlist)" { + local src="$TMP_HOME/src" + mkdir -p "$src/hooks" + # Two safety hooks (mirroring install dir) — should be filtered. + echo "x" > "$src/hooks/bash-guardrails.sh" + echo "x" > "$src/hooks/notify-bell.sh" + # One user hook — should appear. + echo "x" > "$src/hooks/lint-on-save.sh" + run_in_zsh "_ckipper_account_sync_hooks_enumerate '$src' | cut -f1" + [[ "$output" == *"hooks/lint-on-save.sh"* ]] + [[ "$output" != *"bash-guardrails.sh"* ]] + [[ "$output" != *"notify-bell.sh"* ]] +} + +@test "hooks_enumerate emits empty when no user hooks present" { + local src="$TMP_HOME/src" + mkdir -p "$src/hooks" + echo "x" > "$src/hooks/bash-guardrails.sh" + run_in_zsh "_ckipper_account_sync_hooks_enumerate '$src' | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "hooks_compare: new when destination lacks the script" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/hooks" "$dst/hooks" + echo "x" > "$src/hooks/lint.sh" + run_in_zsh "_ckipper_account_sync_hooks_compare '$src' '$dst' hooks/lint.sh" + [[ "$output" == *"new"* ]] +} + +@test "hooks_apply copies the script AND adds the paired settings entry" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/hooks" "$dst/hooks" + echo "#!/bin/bash" > "$src/hooks/lint.sh" + cat > "$src/settings.json" < "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_hooks_apply '$src' '$dst' hooks/lint.sh \"\$backup_dir\" + cat '$dst/hooks/lint.sh' + jq -r '.hooks.PostToolUse[0].hooks[0].command' '$dst/settings.json'" + [[ "$output" == *"#!/bin/bash"* ]] + [[ "$output" == *"$dst/hooks/lint.sh"* ]] + [[ "$output" != *"$src/hooks/lint.sh"* ]] +} diff --git a/lib/account/sync/strategies/statusline.zsh b/lib/account/sync/strategies/statusline.zsh new file mode 100644 index 0000000..84717b6 --- /dev/null +++ b/lib/account/sync/strategies/statusline.zsh @@ -0,0 +1,128 @@ +#!/usr/bin/env zsh +# Strategy module for "statusline" sync type. +# +# Statusline lives at /settings.json `.statusLine` and may reference +# an executable script via .statusLine.command. Sync semantics: +# +# - settings reference (.statusLine.*): always copied +# - referenced script: copied IFF the path resolves to inside /. +# Otherwise (system path, shared script, etc.) the reference is copied +# verbatim without touching any file on the destination. +# +# Implementation depends on lib/account/sync/strategies/structured.zsh +# for _core_sync_json_atomic_write and on lib/account/sync/backup.zsh +# for _core_account_sync_backup_file. + +# Single item id constant — statusline is not enumerable per-element. +readonly _CKIPPER_SYNC_STATUSLINE_ID="statusLine" + +# Enumerate: emit a single "statusLine" entry iff source has one. +# +# Args: $1 — source account dir. +# Returns: 0; prints one line on hit, empty on miss. +_ckipper_account_sync_statusline_enumerate() { + local src="$1" + local file="$src/settings.json" + [[ ! -f "$file" ]] && return 0 + local has; has=$(jq -r '.statusLine // null' "$file" 2>/dev/null) + [[ "$has" == "null" ]] && return 0 + echo "$_CKIPPER_SYNC_STATUSLINE_ID\tStatus line" +} + +# Compare: shape varies. We treat the .statusLine subtree as one structured +# value (same approach as settings, but always at the .statusLine path). +# +# Args: $1 — src; $2 — dst; $3 — id (always "statusLine"). +# Returns: 0; prints "new" | "overwrite" | "unchanged". +_ckipper_account_sync_statusline_compare() { + local src="$1" dst="$2" + local s d + s=$(jq -c '.statusLine // null' "$src/settings.json" 2>/dev/null) + d=$(jq -c '.statusLine // null' "$dst/settings.json" 2>/dev/null) + if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi + echo "overwrite" +} + +# Resolve and detect whether the referenced script lives under /. +# Empty stdout = external (or no command); non-empty = absolute path +# inside src. +# +# Args: $1 — src dir. +# Returns: 0; prints internal-script path or empty. +_core_sync_statusline_internal_path() { + local src="$1" + local file="$src/settings.json" + [[ ! -f "$file" ]] && return 0 + local cmd; cmd=$(jq -r '.statusLine.command // empty' "$file" 2>/dev/null) + [[ -z "$cmd" ]] && return 0 + # Take the first whitespace-delimited token as the executable. + local exe="${cmd%% *}" + case "$exe" in + "$src"/*) echo "$exe" ;; + esac +} + +# Summary: combines internal/external indicator with overwrite-or-new. +# +# Args: $1 — src; $2 — dst; $3 — id. +# Returns: 0; prints summary text. +_ckipper_account_sync_statusline_summary() { + local src="$1" dst="$2" + local cmp_status; cmp_status=$(_ckipper_account_sync_statusline_compare "$src" "$dst" "$_CKIPPER_SYNC_STATUSLINE_ID") + local internal; internal=$(_core_sync_statusline_internal_path "$src") + local kind="external" + [[ -n "$internal" ]] && kind="internal (will copy script)" + case "$cmp_status" in + new) echo "new — $kind" ;; + overwrite) echo "overwrite — $kind" ;; + unchanged) echo "unchanged" ;; + esac +} + +# Diff: jq before/after of .statusLine. +_ckipper_account_sync_statusline_diff() { + local src="$1" dst="$2" + echo "── source ($src/settings.json:.statusLine) ──" + jq '.statusLine // null' "$src/settings.json" + echo "── destination ($dst/settings.json:.statusLine) ──" + jq '.statusLine // null' "$dst/settings.json" 2>/dev/null +} + +# Apply: settings.statusLine subtree is written via setpath (same approach +# as the settings strategy). If the referenced script is internal, copy +# it under the destination dir AND rewrite the .command path to the +# destination's location. +# +# Args: $1 — src; $2 — dst; $3 — id; $4 — backup_dir. +# Returns: 0 on success; non-zero on jq/cp/write failure. +_ckipper_account_sync_statusline_apply() { + local src="$1" dst="$2" id="$3" backup_dir="$4" + _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + [[ -f "$dst/settings.json" ]] || echo '{}' > "$dst/settings.json" + local internal; internal=$(_core_sync_statusline_internal_path "$src") + local statusline_obj; statusline_obj=$(jq -c '.statusLine' "$src/settings.json") + if [[ -n "$internal" ]]; then + statusline_obj=$(_core_sync_statusline_copy_and_rewrite \ + "$src" "$dst" "$internal" "$backup_dir" "$statusline_obj") || return 1 + fi + local merged + merged=$(jq --argjson v "$statusline_obj" '.statusLine = $v' "$dst/settings.json") + _core_sync_json_atomic_write "$dst/settings.json" "$merged" +} + +# Internal-script branch: copies the script then rewrites .command in the +# given JSON to point at the destination's path. +# +# Args: $1 — src dir; $2 — dst dir; $3 — internal script abs path; +# $4 — backup_dir; $5 — statusline JSON object. +# Returns: 0 on success (prints rewritten JSON); 1 on cp failure. +_core_sync_statusline_copy_and_rewrite() { + local src="$1" dst="$2" internal="$3" backup_dir="$4" obj="$5" + local rel="${internal#$src/}" + _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + mkdir -p "$dst/${rel:h}" + cp -a "$internal" "$dst/$rel" || return 1 + echo "$obj" | jq --arg src "$src" --arg dst "$dst" \ + '.command = (.command | sub("^" + $src; $dst))' +} diff --git a/lib/account/sync/strategies/statusline_test.bats b/lib/account/sync/strategies/statusline_test.bats new file mode 100644 index 0000000..aa326d3 --- /dev/null +++ b/lib/account/sync/strategies/statusline_test.bats @@ -0,0 +1,79 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/strategies/statusline.zsh. + +load "${BATS_TEST_DIRNAME}/../../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/statusline.zsh\"; $*" +} + +@test "statusline_enumerate emits a single entry when statusLine is set" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"statusLine":{"command":"/usr/bin/echo hi"}}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_statusline_enumerate '$src' | cut -f1" + [[ "$output" == *"statusLine"* ]] +} + +@test "statusline_enumerate empty when statusLine missing" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_statusline_enumerate '$src' | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "statusline_internal_script_path detects script inside src dir" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" + echo "#!/bin/bash" > "$src/my-statusline.sh" + run_in_zsh "_core_sync_statusline_internal_path '$src'" + [[ "$output" == *"$src/my-statusline.sh"* ]] +} + +@test "statusline_internal_script_path returns empty when external" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo '{"statusLine":{"command":"/usr/bin/echo hi"}}' > "$src/settings.json" + run_in_zsh "out=\$(_core_sync_statusline_internal_path '$src'); echo \"[\$out]\"" + [[ "$output" == *"[]"* ]] +} + +@test "statusline_apply: external script — settings only, no file copy" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"statusLine":{"command":"/usr/bin/echo hi"}}' > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" + jq -r '.statusLine.command' '$dst/settings.json' + ls '$dst' | grep -c statusline.sh || true" + [[ "$output" == *"/usr/bin/echo hi"* ]] + [[ "$output" == *"0"* ]] +} + +@test "statusline_apply: internal script — copy file + rewrite reference" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo "#!/bin/bash" > "$src/my-statusline.sh" + chmod +x "$src/my-statusline.sh" + echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_core_account_sync_backup_create '$dst' src) + _core_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" + jq -r '.statusLine.command' '$dst/settings.json' + ls '$dst/my-statusline.sh' && echo COPIED" + [[ "$output" == *"$dst/my-statusline.sh"* ]] + [[ "$output" == *"COPIED"* ]] +} From 54aa19dcdcb47c34b97cfef4de015f55ac0dc4a5 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:55:51 -0600 Subject: [PATCH 06/18] feat(sync): engine core (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _core_account_sync_assert_dst_idle hard-refuses when Claude is running on destination (`--force` bypass) - _core_account_sync_validate_pair rejects src==tgt - _core_account_sync_build_change_set walks each requested type via the strategy contract (enumerate then compare per item) - _core_account_sync_apply_target reads change set on stdin, calls apply per row, rolls back from backup dir on any failure - _core_account_sync_apply_one bridges strategy contract to manifest schema; prefs uses account names, others use dirs - _core_account_sync_manifest_rel maps (type,id) → manifest path field — mcp→.claude.json, settings/statusline→settings.json, prefs→accounts.json, files→id Plan-level fix: renamed local var `status` to `change_status` — `status` is read-only in zsh (alias for $?), causing the read-loop and apply-one parameter binding to fail. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §7.3, §8 #5. --- lib/account/sync/engine.zsh | 146 +++++++++++++++++++++++++++++- lib/account/sync/engine_test.bats | 84 +++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index e516225..87767f2 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -29,8 +29,8 @@ # JSON values use side-by-side `jq` pretty-print. May be empty for # new items (drill-down skips status==new in the preview UI). # -# _apply -# Perform the merge. MUST call _ckipper_account_sync_backup_file +# _apply +# Perform the merge. MUST call _core_account_sync_backup_file # before any destructive write. Returns 0 on success; non-zero on # failure (engine then triggers per-target rollback). # @@ -46,3 +46,145 @@ _core_account_sync_strategy_fn() { local type="$1" verb="$2" echo "_ckipper_account_sync_${type}_${verb}" } + +# Refuse to sync when Claude is running with the destination's config dir. +# Reuses lib/core/keychain.zsh::_core_running_claude_processes and filters by +# whether any line references the destination directory. +# +# Args: $1 — destination dir; $2 — force flag ("true" | "false"). +# Returns: 0 if safe to proceed; 1 if Claude is running on dst (unless force). +# Errors (stderr): a multiline message identifying the running process(es) +# and the suggested launcher command. +_core_account_sync_assert_dst_idle() { + local dst_dir="$1" force="$2" + [[ "$force" == "true" ]] && return 0 + local procs; procs=$(_core_running_claude_processes 2>/dev/null) + [[ -z "$procs" ]] && return 0 + local matching; matching=$(echo "$procs" | grep -F "$dst_dir" || true) + [[ -z "$matching" ]] && return 0 + { + echo "Refusing to sync: Claude is running on the destination config dir." + echo "$matching" | sed 's/^/ /' + echo "" + echo "Quit the session, or pass --force to override (risk of file races)." + } >&2 + return 1 +} + +# Validate a single (source, target) pair. Identity check only; account +# existence is handled by _core_account_dir from lib/core/registry.zsh +# at the dispatcher layer. +# +# Args: $1 — source name; $2 — target name. +# Returns: 0 if valid; 1 if names match. +# Errors (stderr): "Source and target must differ: " +_core_account_sync_validate_pair() { + local src="$1" tgt="$2" + if [[ "$src" == "$tgt" ]]; then + echo "Source and target must differ: $src" >&2 + return 1 + fi + return 0 +} + +# Build the per-(target, type) change set for a single sync slice. +# For each type, runs that strategy's enumerate then compare per item; +# emits one TSV row per item with the resulting status appended. +# +# Args: $1 — src dir; $2 — dst dir; $3 — src account name; $4 — dst account name; +# $5..$N — type ids to walk (already resolved from --include/--exclude). +# Returns: 0 always; prints "\t\t\t" per line. +_core_account_sync_build_change_set() { + local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" + shift 4 + local type + for type in "$@"; do + _core_account_sync_walk_type "$type" "$src_dir" "$dst_dir" "$src_name" "$dst_name" + done +} + +# Walk a single type's items. prefs uses account names instead of dirs. +# +# Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name. +# Returns: 0 always; prints rows. +_core_account_sync_walk_type() { + local type="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" + local enumerate_fn compare_fn + enumerate_fn=$(_core_account_sync_strategy_fn "$type" enumerate) + compare_fn=$(_core_account_sync_strategy_fn "$type" compare) + local arg_a="$src_dir" arg_b="$dst_dir" + [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + local id display change_status + while IFS=$'\t' read -r id display; do + [[ -z "$id" ]] && continue + change_status=$("$compare_fn" "$arg_a" "$arg_b" "$id") + echo "$type"$'\t'"$id"$'\t'"$display"$'\t'"$change_status" + done < <("$enumerate_fn" "$arg_a") +} + +# Apply a change set to a single target. Steps: +# 1. Create backup dir + manifest. +# 2. For each change, call the strategy's apply (which itself calls +# _core_account_sync_backup_file before writing). +# 3. On any failure: roll back via _core_account_sync_rollback_target, +# print the partial manifest's path, and return non-zero. +# +# Reads the change set on stdin: TSV rows of "\t\t\t" +# where status is one of "new" | "overwrite" (unchanged rows are filtered upstream). +# +# Args: $1 — src dir; $2 — dst dir; $3 — src name; $4 — dst name. +# Returns: 0 on success; 1 if any apply failed (after rollback completed). +_core_account_sync_apply_target() { + local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" + local backup_dir + backup_dir=$(_core_account_sync_backup_create "$dst_dir" "$src_name") + _core_account_sync_manifest_init "$backup_dir" "$src_name" "$dst_name" + local rc=0 type id display change_status + while IFS=$'\t' read -r type id display change_status; do + [[ -z "$type" || "$change_status" == "unchanged" ]] && continue + if ! _core_account_sync_apply_one "$type" "$src_dir" "$dst_dir" \ + "$src_name" "$dst_name" "$id" \ + "$change_status" "$backup_dir"; then + rc=1 + break + fi + done + if (( rc != 0 )); then + _core_account_sync_rollback_target "$backup_dir" "$dst_dir" >&2 + echo "Rolled back. Backup preserved at: $backup_dir" >&2 + fi + return $rc +} + +# Apply one change set entry. Bridges between the strategy contract and +# the manifest schema. prefs uses names; everything else uses dirs. +# +# Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name; +# $6 — id; $7 — change status; $8 — backup_dir. +# Returns: 0 on success; non-zero on apply failure. +_core_account_sync_apply_one() { + local type="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" + local id="$6" change_status="$7" backup_dir="$8" + local apply_fn; apply_fn=$(_core_account_sync_strategy_fn "$type" apply) + local arg_a="$src_dir" arg_b="$dst_dir" + [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + local op="overwrite"; [[ "$change_status" == "new" ]] && op="create" + local rel; rel=$(_core_account_sync_manifest_rel "$type" "$id") + "$apply_fn" "$arg_a" "$arg_b" "$id" "$backup_dir" || return 1 + _core_account_sync_manifest_append "$backup_dir" "$rel" "$op" "$type" "$id" +} + +# Compute the manifest's path field for a given (type, id). The relpath +# is what _core_account_sync_rollback_one operates on. +# +# Args: $1 — type; $2 — id. +# Returns: 0; prints relpath. +_core_account_sync_manifest_rel() { + local type="$1" id="$2" + case "$type" in + mcp) echo ".claude.json" ;; + settings|statusline) echo "settings.json" ;; + prefs) echo "accounts.json" ;; + *) echo "$id" ;; + esac +} diff --git a/lib/account/sync/engine_test.bats b/lib/account/sync/engine_test.bats index ae6e1d2..96b5f54 100644 --- a/lib/account/sync/engine_test.bats +++ b/lib/account/sync/engine_test.bats @@ -29,3 +29,87 @@ run_in_zsh() { [ "$status" -eq 0 ] [[ "$output" == *"OK"* ]] } + +@test "_core_account_sync_assert_dst_idle returns 0 when no claude running" { + # The pgrep stub returns no matches by default in the test env. + run env CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/keychain.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + _core_account_sync_assert_dst_idle '$TMP_HOME/dst' false && echo OK" + [[ "$output" == *"OK"* ]] +} + +@test "_core_account_sync_assert_dst_idle returns 1 when claude is running on dst" { + # Stage a fake pgrep stub that prints a process referencing dst. + local fake_pgrep="$TMP_HOME/bin/pgrep" + mkdir -p "$TMP_HOME/bin" + cat > "$fake_pgrep" < "$fake_pgrep" < "$src/.claude.json" + echo '{"mcpServers":{}}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + _core_account_sync_build_change_set '$src' '$dst' src dst mcp" + [[ "$output" == *"mcp"$'\t'"github"$'\t'* ]] + [[ "$output" == *"new"* ]] +} + +@test "_core_account_sync_apply_target writes changes and records manifest" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + echo '{"mcpServers":{}}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + printf 'mcp\tgithub\tgithub\tnew\n' \ + | _core_account_sync_apply_target '$src' '$dst' src dst + jq '.mcpServers.github.command' '$dst/.claude.json' + ls '$dst/.ckipper-sync-backups'/*-from-src/.ckipper-sync-manifest.json" + [[ "$output" == *'"x"'* ]] + [[ "$output" == *".ckipper-sync-manifest.json"* ]] +} From 086d52d6f7925be4dc058bd03b0c99dd10c64ded Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:57:34 -0600 Subject: [PATCH 07/18] =?UTF-8?q?feat(sync):=20preview=20UX=20=E2=80=94=20?= =?UTF-8?q?summary=20table=20+=20drill-down=20picker=20(Phase=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary renderer reads change-set on stdin, groups by type, prints [+] new / [~] overwrite badges with strategy-supplied summaries and the backup dir path. Drill-down picker filters to overwrite-only rows and dispatches to _diff via _core_prompt_choose. Plan-level fix: renamed local var `status` to `change_status` / `cmp_status` in render funcs (read-only collision again). Refs: docs/plans/2026-05-02-sync-overhaul-design.md §6. --- lib/account/sync/preview.zsh | 133 +++++++++++++++++++++++++++++ lib/account/sync/preview_test.bats | 63 ++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 lib/account/sync/preview.zsh create mode 100644 lib/account/sync/preview_test.bats diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh new file mode 100644 index 0000000..b6c73c1 --- /dev/null +++ b/lib/account/sync/preview.zsh @@ -0,0 +1,133 @@ +#!/usr/bin/env zsh +# Preview UX for sync — summary table renderer + drill-down picker. +# +# The summary table groups change-set rows by type and prints a one-line +# entry per item with a status badge. The drill-down picker (interactive +# only, gum-driven) lets the user select an [~] item and see its full +# diff via the strategy's _diff function. + +readonly _CKIPPER_SYNC_BADGE_NEW="[+]" +readonly _CKIPPER_SYNC_BADGE_OVERWRITE="[~]" +readonly _CKIPPER_SYNC_DIVIDER_WIDTH=45 + +# Print the divider line for the summary table. +# +# Returns: 0 always. +_core_account_sync_print_divider() { + printf '%*s\n' "$_CKIPPER_SYNC_DIVIDER_WIDTH" '' | tr ' ' '─' +} + +# Print the per-item line with badge + display + summary. +# +# Args: $1 — change status; $2 — display; $3 — summary. +# Returns: 0; suppresses unchanged rows. +_core_account_sync_render_row() { + local cmp_status="$1" display="$2" summary="$3" + case "$cmp_status" in + new) printf ' %s %-26s (%s)\n' "$_CKIPPER_SYNC_BADGE_NEW" "$display" "${summary:-new}" ;; + overwrite) printf ' %s %-26s (%s)\n' "$_CKIPPER_SYNC_BADGE_OVERWRITE" "$display" "${summary:-overwrite}" ;; + unchanged) ;; + esac +} + +# Render the summary table. Reads a change-set on stdin (TSV rows), groups +# by type, and prints the §6.1 layout to stdout. The 4th positional arg +# is a path to a precomputed summaries file (one "type\tid\tsummary" per +# line) — built by the engine before this is called so we don't re-call +# every strategy's summary function inside the renderer. +# +# Args: $1 — src name; $2 — dst name; $3 — backup_dir path; $4 — summaries file. +# Returns: 0 always. +_core_account_sync_render_summary() { + local src_name="$1" dst_name="$2" backup_dir="$3" summaries="$4" + echo "" + echo "Sync $src_name → $dst_name" + _core_account_sync_print_divider + local current_type="" + local type id display change_status + while IFS=$'\t' read -r type id display change_status; do + [[ -z "$type" ]] && continue + if [[ "$type" != "$current_type" ]]; then + echo " ${_CKIPPER_SYNC_TYPE_LABEL[$type]:-$type}" + current_type="$type" + fi + local summary="" + if [[ -f "$summaries" ]]; then + summary=$(awk -F'\t' -v t="$type" -v i="$id" '$1==t && $2==i {print $3; exit}' "$summaries") + fi + _core_account_sync_render_row "$change_status" "$display" "$summary" + done + _core_account_sync_print_divider + echo "Backup → $backup_dir" +} + +# Count totals from the change-set on stdin. +# +# Returns: 0; prints " " on a single line. +_core_account_sync_count_changes() { + awk -F'\t' ' + $4 == "new" { n++; total++ } + $4 == "overwrite" { o++; total++ } + END { printf "%d %d %d\n", total+0, n+0, o+0 } + ' +} + +# Filter a change-set on stdin to only [~] (overwrite) rows. The drill- +# down picker only makes sense for overwrites (new items have no +# destination value to diff against). +# +# Returns: 0; prints "\t\t" per line for overwrites. +_core_account_sync_drill_down_items() { + awk -F'\t' '$4 == "overwrite" { print $1 "\t" $2 "\t" $3 }' +} + +# Open the drill-down loop (gum-driven). User picks an item; we print its +# full diff via the strategy's _diff function. Loops until the user +# picks "Back" or hits EOF. +# +# Args: $1 — src dir; $2 — dst dir; $3 — src name; $4 — dst name; $5 — items file. +# Returns: 0 always. +_core_account_sync_drill_down_loop() { + local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" items_file="$5" + [[ ! -s "$items_file" ]] && { echo "No items to drill into."; return 0; } + while true; do + local choice + choice=$(_core_account_sync_drill_down_pick "$items_file") || return 0 + [[ "$choice" == "Back" || -z "$choice" ]] && return 0 + _core_account_sync_drill_down_show "$choice" "$src_dir" "$dst_dir" "$src_name" "$dst_name" + echo "" + echo "(Press enter to return to picker)" + local _ack; read -r _ack + done +} + +# Pick one drill-down item. Uses gum if available; otherwise prints +# numbered list. +# +# Args: $1 — items file (TSV). +# Returns: gum exit; prints "" or "Back". +_core_account_sync_drill_down_pick() { + local items_file="$1" + local -a labels=("Back") + local type id display + while IFS=$'\t' read -r type id display; do + labels+=("[$type] $display") + done < "$items_file" + _core_prompt_choose "View diff for which item?" "${labels[@]}" +} + +# Render the diff for one selected item. Strips the leading "[type] " marker +# from the picker label and looks the row back up. +# +# Args: $1 — picker choice (e.g. "[mcp] github"); $2..$5 — src/dst dir/name. +# Returns: 0; prints the strategy's diff output. +_core_account_sync_drill_down_show() { + local choice="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" + local type="${choice#\[}"; type="${type%%]*}" + local display="${choice#*] }" + local id="$display" + local diff_fn; diff_fn=$(_core_account_sync_strategy_fn "$type" diff) + local arg_a="$src_dir" arg_b="$dst_dir" + [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + "$diff_fn" "$arg_a" "$arg_b" "$id" +} diff --git a/lib/account/sync/preview_test.bats b/lib/account/sync/preview_test.bats new file mode 100644 index 0000000..e4010a0 --- /dev/null +++ b/lib/account/sync/preview_test.bats @@ -0,0 +1,63 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/preview.zsh — summary table + drill-down. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +run_in_zsh() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_NO_GUM=1 TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/core/style.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/preview.zsh\"; $*" +} + +@test "_core_account_sync_render_summary groups by type with status badges" { + run_in_zsh ' + printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ + printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ + printf "claude-md\tCLAUDE.md\tCLAUDE.md\tnew\n" >>/tmp/cs.$$ + cat /tmp/cs.$$ \ + | _core_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries + rm -f /tmp/cs.$$' + [[ "$output" == *"MCP servers"* ]] + [[ "$output" == *"[+]"* ]] + [[ "$output" == *"github"* ]] + [[ "$output" == *"[~]"* ]] + [[ "$output" == *"vibma"* ]] + [[ "$output" == *"CLAUDE.md (user memory)"* ]] + [[ "$output" == *"Backup →"* ]] +} + +@test "_core_account_sync_render_summary suppresses unchanged rows by default" { + run_in_zsh ' + printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ + printf "mcp\tunchanged-srv\tunchanged-srv\tunchanged\n" >>/tmp/cs.$$ + cat /tmp/cs.$$ \ + | _core_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries + rm -f /tmp/cs.$$' + [[ "$output" != *"unchanged-srv"* ]] +} + +@test "_core_account_sync_count_changes returns total + new + overwrite" { + run_in_zsh ' + printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ + printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ + printf "settings\tmodel\tmodel\tnew\n" >>/tmp/cs.$$ + cat /tmp/cs.$$ | _core_account_sync_count_changes + rm -f /tmp/cs.$$' + [[ "$output" == *"3 2 1"* ]] +} + +@test "_core_account_sync_drill_down_items emits only [~] (overwrite) rows" { + run_in_zsh ' + printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ + printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ + printf "settings\tmodel\tmodel\toverwrite\n" >>/tmp/cs.$$ + cat /tmp/cs.$$ | _core_account_sync_drill_down_items + rm -f /tmp/cs.$$' + [[ "$output" != *"github"* ]] + [[ "$output" == *"vibma"* ]] + [[ "$output" == *"model"* ]] +} From 4d46b5a2672240bfb74ef849c005a9b8d8ba2ded Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 01:58:38 -0600 Subject: [PATCH 08/18] =?UTF-8?q?feat(sync):=20interactive=20wizard=20?= =?UTF-8?q?=E2=80=94=20gum=20pickers=20(Phase=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source picker (single), target picker (multi-select via gum --no-limit), type picker (multi-select). All honor CKIPPER_NO_GUM via fallback to read prompts. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §5.3, §6. --- lib/account/sync/interactive.zsh | 95 ++++++++++++++++++++++++++ lib/account/sync/interactive_test.bats | 36 ++++++++++ 2 files changed, 131 insertions(+) create mode 100644 lib/account/sync/interactive.zsh create mode 100644 lib/account/sync/interactive_test.bats diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh new file mode 100644 index 0000000..1fd4033 --- /dev/null +++ b/lib/account/sync/interactive.zsh @@ -0,0 +1,95 @@ +#!/usr/bin/env zsh +# Interactive wizard for `ckipper account sync` — gum-driven pickers for +# source account, target accounts (multi-select), and types (multi-select). +# +# All pickers honor CKIPPER_NO_GUM via lib/core/prompt.zsh helpers +# (_core_prompt_choose etc.) so non-TTY callers and tests have a fallback. + +# List every registered account name (sorted by registry insertion order). +# +# Returns: 0; prints account names, one per line. +_core_account_sync_list_accounts() { + [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 + jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY" 2>/dev/null +} + +# List every registered account name EXCEPT the given one. +# +# Args: $1 — account name to exclude. +# Returns: 0; prints filtered list. +_core_account_sync_list_accounts_except() { + local exclude="$1" + _core_account_sync_list_accounts | grep -vxF "$exclude" 2>/dev/null +} + +# Prompt the user to pick the source account. +# +# Returns: 0 with chosen name on stdout; 1 if user cancels or no accounts +# are registered. +_core_account_sync_pick_source() { + local -a accounts + accounts=( ${(f)"$(_core_account_sync_list_accounts)"} ) + if (( ${#accounts} == 0 )); then + echo "No accounts registered. Run: ckipper account add " >&2 + return 1 + fi + _core_prompt_choose "Sync FROM which account?" "${accounts[@]}" +} + +# Prompt to multi-select target accounts. With gum, uses --no-limit. +# Without gum, falls back to a comma-separated input prompt. +# +# Args: $1 — source account name (excluded from candidates). +# Returns: 0 with chosen names (one per line) on stdout; 1 if user cancels. +_core_account_sync_pick_targets() { + local source="$1" + local -a candidates + candidates=( ${(f)"$(_core_account_sync_list_accounts_except "$source")"} ) + if (( ${#candidates} == 0 )); then + echo "No other accounts to sync to." >&2 + return 1 + fi + if [[ "$CKIPPER_NO_GUM" != "1" ]] && command -v gum >/dev/null 2>&1; then + printf '%s\n' "${candidates[@]}" | gum choose --no-limit --header "Sync TO which accounts? (space to multi-select)" + return $? + fi + _core_account_sync_pick_targets_fallback "${candidates[@]}" +} + +# Pure-zsh fallback: comma-separated input, validated against candidates. +# +# Args: $@ — candidate account names. +# Returns: 0; prints chosen names. +_core_account_sync_pick_targets_fallback() { + echo "Available targets: $*" >&2 + local input + read -r "input?Enter comma-separated targets: " + local name + for name in ${(s:,:)input}; do + echo "$name" + done +} + +# Prompt to multi-select sync types from the registry. +# +# Returns: 0; prints chosen type ids. +_core_account_sync_pick_types() { + local -a labels + local t + for t in "${(@k)_CKIPPER_SYNC_TYPE_LABEL}"; do + labels+=("$t — ${_CKIPPER_SYNC_TYPE_LABEL[$t]}") + done + if [[ "$CKIPPER_NO_GUM" != "1" ]] && command -v gum >/dev/null 2>&1; then + printf '%s\n' "${labels[@]}" \ + | gum choose --no-limit --header "Pick types to sync (space to multi-select)" \ + | awk '{print $1}' + return $? + fi + echo "Type tokens: ${(@k)_CKIPPER_SYNC_TYPE_LABEL}" >&2 + local input + read -r "input?Enter comma-separated types: " + local name + for name in ${(s:,:)input}; do + echo "$name" + done +} diff --git a/lib/account/sync/interactive_test.bats b/lib/account/sync/interactive_test.bats new file mode 100644 index 0000000..5be2846 --- /dev/null +++ b/lib/account/sync/interactive_test.bats @@ -0,0 +1,36 @@ +#!/usr/bin/env bats +# Unit tests for lib/account/sync/interactive.zsh — gum pickers. + +load "${BATS_TEST_DIRNAME}/../../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + cat > "$CKIPPER_REGISTRY" < Date: Sat, 2 May 2026 02:05:33 -0600 Subject: [PATCH 09/18] feat(sync): wire dispatcher end-to-end + remove old monolith (Phase 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ckipper.zsh sources the new sync subsystem (registry, backup, engine, preview, interactive, dispatcher, then strategy modules) instead of the deleted lib/account/sync.zsh. lib/account/dispatcher.zsh routes `sync` to the new _ckipper_account_sync_dispatch. The other arms (add/list/default/ remove/rename/sync-hooks) keep their existing case branch. Old lib/account/sync.zsh (--mcp/--settings/--all flag surface, no preview, no backup) and its tests are deleted now rather than waiting for Phase 12.1: their tests reference functions whose orchestration was moved into the new sync/ module, so leaving them in causes the test suite to break. Integration tests cover --include mcp --yes (apply), --dry-run (no apply), unregistered-source rejection, and src==tgt rejection. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §5, §7.2-7.6. --- ckipper.zsh | 14 +- lib/account/dispatcher.zsh | 5 +- lib/account/sync.zsh | 239 -------------------------- lib/account/sync/dispatcher.zsh | 219 ++++++++++++++++++++--- lib/account/sync/dispatcher_test.bats | 50 ++++++ lib/account/sync_test.bats | 184 -------------------- 6 files changed, 260 insertions(+), 451 deletions(-) delete mode 100644 lib/account/sync.zsh delete mode 100644 lib/account/sync_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 6298fe5..568a0cc 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -29,7 +29,19 @@ source "$CKIPPER_REPO_DIR/lib/core/prompt.zsh" source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh" source "$CKIPPER_REPO_DIR/lib/account/cleanup.zsh" source "$CKIPPER_REPO_DIR/lib/account/aliases.zsh" -source "$CKIPPER_REPO_DIR/lib/account/sync.zsh" +# Sync subsystem — registry, backup, engine, preview, interactive, +# dispatcher, then the strategy modules. +source "$CKIPPER_REPO_DIR/lib/account/sync/registry.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/backup.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/engine.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/preview.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/interactive.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/dispatcher.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/structured.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/files_flat.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/files_dir.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/statusline.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/hooks.zsh" source "$CKIPPER_REPO_DIR/lib/account/doctor.zsh" source "$CKIPPER_REPO_DIR/lib/account/dispatcher.zsh" diff --git a/lib/account/dispatcher.zsh b/lib/account/dispatcher.zsh index bc23265..7effe13 100644 --- a/lib/account/dispatcher.zsh +++ b/lib/account/dispatcher.zsh @@ -29,13 +29,16 @@ _ckipper_account_dispatch() { local cmd="$1" shift 2>/dev/null case "$cmd" in - add|list|default|remove|rename|sync|sync-hooks) + add|list|default|remove|rename|sync-hooks) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_account_help_for "$cmd" return 0 fi "_ckipper_account_${cmd//-/_}" "$@" ;; + sync) + _ckipper_account_sync_dispatch "$@" + ;; ""|help|-h|--help) _ckipper_account_help ;; *) _ckipper_account_unknown "$cmd"; return 1 ;; esac diff --git a/lib/account/sync.zsh b/lib/account/sync.zsh deleted file mode 100644 index a5cdb46..0000000 --- a/lib/account/sync.zsh +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env zsh -# Account settings sync subcommand: sync MCP servers and settings.json keys between accounts. - -# Module-level context for the in-progress sync operation. -# Populated by _ckipper_account_sync before any helper reads it. -# Fields: from_dir, to_dir, dry_run -typeset -gA _CKIPPER_SYNC_CTX - -# Parse sync subcommand flags into named variables in the caller's scope. -# Populates: mode_mcp, mcp_names, mode_settings, settings_keys, is_dry_run, mode_all. -# -# Args: -# $@ — remaining args after and have been shifted -# -# Returns: -# 0 on success; 1 on unknown flag. -_ckipper_account_sync_parse_flags() { - mode_mcp="false"; mcp_names=""; mode_settings="false"; settings_keys=""; is_dry_run="false"; mode_all="false" - while [[ $# -gt 0 ]]; do - case "$1" in - --mcp) - mode_mcp="true" - if [[ -n "$2" && "$2" != --* ]]; then mcp_names="$2"; shift; fi - shift ;; - --settings) - mode_settings="true" - if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi - shift ;; - --all) mode_all="true"; shift ;; - --dry-run) is_dry_run="true"; shift ;; - *) echo "Unknown flag: $1"; return 1 ;; - esac - done - if [[ "$mode_mcp" = "false" && "$mode_settings" = "false" ]]; then - mode_all="true" - fi - if [[ "$mode_all" = "true" ]]; then - mode_mcp="true" - mode_settings="true" - [[ -z "$settings_keys" ]] && \ - settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" - fi -} - -# Warn if Claude is running and the sync would race with its writes. -# Prompts the user to confirm continuation. -# -# Args: -# $1 — from account name -# $2 — to account name -# -# Returns: -# 0 to proceed; 1 if user aborts. -_ckipper_account_sync_warn_running_claude() { - local from="$1" to="$2" - local running_procs - running_procs=$(_core_running_claude_processes) - [[ -z "$running_procs" ]] && return 0 - echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 - echo "sync may race with its writes (Claude doesn't lock these files)." >&2 - echo "$running_procs" | sed 's/^/ /' >&2 - local user_choice - read -r "?Continue anyway? [y/N] " user_choice - [[ "$user_choice" != "y" && "$user_choice" != "Y" ]] && { echo "Aborted."; return 1; } -} - -# Build the jq filter args used by sync_mcp_servers to project a server subset. -# Populates the caller-scope array `jq_filter_args` (dynamic scope). -# -# Args: -# $1 — comma-separated MCP server names; empty means "all servers". -# -# Returns: -# 0 always. -_ckipper_account_sync_build_mcp_filter_args() { - local mcp_names="$1" - if [[ -z "$mcp_names" ]]; then - jq_filter_args=('.mcpServers // {}') - return 0 - fi - local jq_array - jq_array=$(printf '%s' "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - jq_filter_args=( - --argjson keys "$jq_array" - '.mcpServers // {} | with_entries(select(.key as $k | $keys | index($k)))' - ) -} - -# Sync MCP servers from one account to another. Appends a summary line to pending_msgs. -# Reads from_dir, to_dir, and dry_run from _CKIPPER_SYNC_CTX module global. -# -# Args: -# $1 — to account name (for message) -# $2 — comma-separated MCP server names to sync (empty = all) -# -# Returns: -# 0 always. -_ckipper_account_sync_mcp_servers() { - local to="$1" mcp_names="$2" - local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" - local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" - local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" - local -a jq_filter_args - _ckipper_account_sync_build_mcp_filter_args "$mcp_names" - local servers; servers=$(jq "${jq_filter_args[@]}" "$from_dir/.claude.json") - local server_keys; server_keys=$(printf '%s' "$servers" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$server_keys" || "$server_keys" == " " ]]; then - pending_msgs+=("MCP: nothing to sync (no matching servers in source)") - return 0 - fi - pending_msgs+=("MCP servers → $to: $server_keys") - [[ "$dry_run" = "true" ]] && return 0 - local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") - jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ - "$to_dir/.claude.json" > "$sync_tmpfile" && mv "$sync_tmpfile" "$to_dir/.claude.json" -} - -# Sync settings.json keys from one account to another. Appends summary line to pending_msgs. -# Reads from_dir, to_dir, and dry_run from _CKIPPER_SYNC_CTX module global. -# -# Args: -# $1 — from account name (for message) -# $2 — to account name (for message) -# $3 — comma-separated settings keys to sync -# -# Returns: -# 0 always. -_ckipper_account_sync_settings_keys() { - local from="$1" to="$2" settings_keys="$3" - local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" - local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" - local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" - if [[ ! -f "$from_dir/settings.json" ]]; then - pending_msgs+=("Settings: $from has no settings.json (skipping)") - return 0 - fi - local jq_keys; jq_keys=$(printf '%s' "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - local subset; subset=$(jq --argjson keys "$jq_keys" \ - 'with_entries(select(.key as $k | $keys | index($k)))' "$from_dir/settings.json") - local copied_keys; copied_keys=$(printf '%s' "$subset" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then - pending_msgs+=("Settings: no matching keys in $from/settings.json") - return 0 - fi - pending_msgs+=("Settings keys → $to: $copied_keys") - [[ "$dry_run" = "true" ]] && return 0 - [[ ! -f "$to_dir/settings.json" ]] && echo '{}' > "$to_dir/settings.json" - local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") - jq --argjson new "$subset" '. + $new' \ - "$to_dir/settings.json" > "$sync_tmpfile" && mv "$sync_tmpfile" "$to_dir/settings.json" -} - -# Print the sync summary lines and restart reminder. -# -# Args: -# $1 — to account name -# $2 — is_dry_run flag ("true" = dry run, "false" = write) -# -# Returns: -# 0 always. -_ckipper_account_sync_print_summary() { - local to="$1" is_dry_run="$2" - if [[ "$is_dry_run" = "true" ]]; then - echo "Dry run — would apply:" - else - echo "Synced:" - fi - local m - for m in "${pending_msgs[@]}"; do - echo " - $m" - done - if [[ "$is_dry_run" != "true" ]]; then - echo "" - echo "Restart any running '$to' Claude session for changes to take effect." - fi -} - -# Resolve and validate sync source and destination account directories. -# Prints from_dir and to_dir as tab-separated values to stdout on success. -# -# Args: -# $1 — from account name -# $2 — to account name -# -# Returns: -# 0 with "from_dir\tto_dir" on stdout; 1 on validation failure. -_ckipper_account_sync_resolve_dirs() { - local from="$1" to="$2" - local from_dir to_dir - from_dir=$(_core_account_dir "$from") || return 1 - to_dir=$(_core_account_dir "$to") || return 1 - if [[ ! -f "$from_dir/.claude.json" ]]; then - echo "Source has no .claude.json: $from_dir"; return 1 - fi - if [[ ! -f "$to_dir/.claude.json" ]]; then - echo "Destination has no .claude.json: $to_dir"; return 1 - fi - printf '%s\t%s' "$from_dir" "$to_dir" -} - -# Copy MCP servers and/or settings.json keys from one registered account to another. -# -# Args: -# $1 — from account name -# $2 — to account name -# $@ — options: [--mcp [names]] [--settings keys] [--all] [--dry-run] -# -# Returns: -# 0 on success; 1 on validation failure or user abort. -# -# Errors (stderr): -# "Usage: ckipper account sync ..." — when arguments are missing. -# " and must differ." — when both accounts are the same. -_ckipper_account_sync() { - _core_registry_check_version || return 1 - local from="$1" to="$2" - shift 2 2>/dev/null - if [[ -z "$from" || -z "$to" ]]; then - echo "Usage: ckipper account sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" >&2 - return 1 - fi - [[ "$from" == "$to" ]] && { echo " and must differ." >&2; return 1; } - local dirs_line; dirs_line=$(_ckipper_account_sync_resolve_dirs "$from" "$to") || return 1 - local from_dir="${dirs_line%% *}" to_dir="${dirs_line##* }" - local mode_mcp mcp_names mode_settings settings_keys is_dry_run mode_all - _ckipper_account_sync_parse_flags "$@" || return 1 - if [[ "$is_dry_run" != "true" ]]; then - _ckipper_account_sync_warn_running_claude "$from" "$to" || return 1 - fi - _CKIPPER_SYNC_CTX[from_dir]="$from_dir" - _CKIPPER_SYNC_CTX[to_dir]="$to_dir" - _CKIPPER_SYNC_CTX[dry_run]="$is_dry_run" - local pending_msgs=() - [[ "$mode_mcp" = "true" ]] && \ - _ckipper_account_sync_mcp_servers "$to" "$mcp_names" - [[ "$mode_settings" = "true" && ${#settings_keys} -gt 0 ]] && \ - _ckipper_account_sync_settings_keys "$from" "$to" "$settings_keys" - _ckipper_account_sync_print_summary "$to" "$is_dry_run" -} diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index 25f11ff..4497bf1 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -1,10 +1,13 @@ #!/usr/bin/env zsh # Dispatcher for `ckipper account sync` and `ckipper account sync undo`. -# Routes top-level args to either the sync flow or the undo flow. -# Owns argument parsing; delegates execution to engine.zsh / interactive.zsh. +# +# Owns argument parsing and high-level mode routing. Delegates to: +# - lib/account/sync/engine.zsh (build_change_set, apply_target) +# - lib/account/sync/preview.zsh (render_summary, drill_down_loop) +# - lib/account/sync/interactive.zsh (pickers when args are missing) +# - lib/account/sync/registry.zsh (resolve_includes, type validation) +# - lib/core/registry.zsh (_core_account_dir) -# Module-level parsed-arg holders. Populated by _ckipper_account_sync_parse_args -# before any handler reads them. typeset -g _SYNC_FROM="" typeset -ga _SYNC_TARGETS=() typeset -g _SYNC_INCLUDE="" @@ -19,21 +22,16 @@ typeset -g _SYNC_FORCE="false" # # Returns: 0 always. _ckipper_account_sync_reset_args() { - _SYNC_FROM="" - _SYNC_TARGETS=() - _SYNC_INCLUDE="" - _SYNC_EXCLUDE="" - _SYNC_DRY_RUN="false" - _SYNC_YES="false" - _SYNC_FORCE="false" + _SYNC_FROM=""; _SYNC_TARGETS=() + _SYNC_INCLUDE=""; _SYNC_EXCLUDE="" + _SYNC_DRY_RUN="false"; _SYNC_YES="false"; _SYNC_FORCE="false" } -# Parse `ckipper account sync` arguments into the module-level _SYNC_* vars. -# Positional args: [...]. Flags: --include , --exclude , -# --dry-run, --yes, --force. +# Parse `ckipper account sync` arguments into module-level _SYNC_* vars. +# Positional: [...]. Flags: --include/--exclude/--dry-run/--yes/--force. # # Args: $@ — raw argv after `ckipper account sync` is stripped. -# Returns: 0 on success; 1 on unknown flag. +# Returns: 0 on success; 1 on unknown flag; 2 if --help printed. # Errors (stderr): "Unknown flag: " — when an unrecognized --foo appears. _ckipper_account_sync_parse_args() { _ckipper_account_sync_reset_args @@ -44,14 +42,11 @@ _ckipper_account_sync_parse_args() { --dry-run) _SYNC_DRY_RUN="true"; shift ;; --yes) _SYNC_YES="true"; shift ;; --force) _SYNC_FORCE="true"; shift ;; - -h|--help) _ckipper_account_sync_help_text; return 0 ;; + -h|--help) _ckipper_account_sync_help_text; return 2 ;; --*) echo "Unknown flag: $1" >&2; return 1 ;; *) - if [[ -z "$_SYNC_FROM" ]]; then - _SYNC_FROM="$1" - else - _SYNC_TARGETS+=("$1") - fi + if [[ -z "$_SYNC_FROM" ]]; then _SYNC_FROM="$1" + else _SYNC_TARGETS+=("$1"); fi shift ;; esac @@ -59,12 +54,184 @@ _ckipper_account_sync_parse_args() { return 0 } -# Print --help body for `ckipper account sync`. Filled in fully in Phase 9. +# Top-level dispatcher. Routes to undo if first arg is "undo"; otherwise +# falls through to the sync flow. +# +# Args: $@ — args after `ckipper account sync`. +# Returns: 0 on success; 1 on user-visible failure. +_ckipper_account_sync_dispatch() { + if [[ "$1" == "undo" ]]; then + shift + _ckipper_account_sync_undo_dispatch "$@" + return $? + fi + _ckipper_account_sync_parse_args "$@" + local rc=$? + (( rc == 2 )) && return 0 + (( rc != 0 )) && return $rc + _ckipper_account_sync_run +} + +# Resolve missing positionals via interactive pickers, then run the engine +# for each target. +# +# Returns: 0 on success across all targets; 1 if any target failed. +_ckipper_account_sync_run() { + if [[ -z "$_SYNC_FROM" ]]; then + _SYNC_FROM=$(_core_account_sync_pick_source) || return 1 + fi + if (( ${#_SYNC_TARGETS} == 0 )); then + _SYNC_TARGETS=( ${(f)"$(_core_account_sync_pick_targets "$_SYNC_FROM")"} ) + (( ${#_SYNC_TARGETS} == 0 )) && return 1 + fi + _ckipper_account_sync_validate_accounts || return 1 + local -a types + types=( ${(f)"$(_ckipper_account_sync_resolve_types)"} ) + (( ${#types} == 0 )) && { echo "No types selected." >&2; return 1; } + _ckipper_account_sync_run_targets types +} + +# Walk every target and apply the resolved type list. +# +# Args: $1 — name of array variable holding type ids. +# Returns: 0 if every target succeeded; 1 if any failed. +_ckipper_account_sync_run_targets() { + local types_var="$1" + local -a types_local; types_local=( "${(@P)types_var}" ) + local rc=0 target + for target in "${_SYNC_TARGETS[@]}"; do + _core_account_sync_validate_pair "$_SYNC_FROM" "$target" || { rc=1; continue; } + _ckipper_account_sync_run_one_target "$target" "${types_local[@]}" || rc=1 + done + return $rc +} + +# Validate that source + every target are registered accounts. +# +# Returns: 0 if all registered; 1 if any unknown (error printed by _core_account_dir). +_ckipper_account_sync_validate_accounts() { + _core_account_dir "$_SYNC_FROM" >/dev/null || return 1 + local t + for t in "${_SYNC_TARGETS[@]}"; do + _core_account_dir "$t" >/dev/null || return 1 + done +} + +# Resolve --include/--exclude into a final type list. Empty include with +# no flags drops to interactive type picker. +# +# Returns: 0; prints type ids one per line. +_ckipper_account_sync_resolve_types() { + if [[ -z "$_SYNC_INCLUDE" && "$_SYNC_YES" != "true" && "$_SYNC_DRY_RUN" != "true" ]]; then + _core_account_sync_pick_types + return 0 + fi + [[ -z "$_SYNC_INCLUDE" ]] && _SYNC_INCLUDE="all" + _core_account_sync_resolve_includes "$_SYNC_INCLUDE" "$_SYNC_EXCLUDE" +} + +# One-target slice: build change set, render preview, prompt, apply. +# +# Args: $1 — target name; $2..$N — types. +# Returns: 0 on success; 1 on failure. +_ckipper_account_sync_run_one_target() { + local target="$1"; shift + local src_dir dst_dir + src_dir=$(_core_account_dir "$_SYNC_FROM") + dst_dir=$(_core_account_dir "$target") + _core_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 + local changeset summaries + changeset=$(mktemp) + summaries=$(mktemp) + _core_account_sync_build_change_set "$src_dir" "$dst_dir" \ + "$_SYNC_FROM" "$target" "$@" > "$changeset" + _core_account_sync_render_summary "$_SYNC_FROM" "$target" \ + "$(_core_account_sync_backup_dir_path "$dst_dir" "$_SYNC_FROM")" \ + "$summaries" < "$changeset" + if [[ "$_SYNC_DRY_RUN" == "true" ]]; then + rm -f "$changeset" "$summaries" + return 0 + fi + if [[ "$_SYNC_YES" != "true" ]]; then + _core_prompt_confirm "Apply changes to $target?" || { rm -f "$changeset" "$summaries"; return 0; } + fi + _core_account_sync_apply_target "$src_dir" "$dst_dir" "$_SYNC_FROM" "$target" < "$changeset" + local rc=$? + rm -f "$changeset" "$summaries" + return $rc +} + +# Help text for `ckipper account sync`. # # Returns: 0 always. _ckipper_account_sync_help_text() { - echo "ckipper account sync [] [...] [options]" - echo "" - echo " See docs/plans/2026-05-02-sync-overhaul-design.md §5 for the" - echo " complete flag and bundle catalog. Wired up in Phase 9." + _core_help_render "ckipper account sync [] [...] [options]" \ + "" \ + "Sync state between registered Claude accounts. Empty positionals drop" \ + "into interactive pickers (gum-driven)." \ + "" \ + "Options:" \ + " --include Comma-separated types or named bundle:" \ + " all | customizations | claude-config | preferences" \ + " Type tokens: mcp, settings, claude-md, agents," \ + " commands, output-styles, skills, statusline," \ + " hooks, prefs" \ + " --exclude Subtract from --include." \ + " --dry-run Print summary, exit (no prompt, no writes)." \ + " --yes Skip the confirm prompt; apply directly." \ + " --force Bypass the destination-Claude-running refusal." \ + "" \ + "Subcommand:" \ + " ckipper account sync undo [--pick | --list]" \ + "" \ + "Examples:" \ + " ckipper account sync (full wizard)" \ + " ckipper account sync personal work --include mcp" \ + " ckipper account sync personal work --include all --yes" \ + " ckipper account sync personal work client1 --include customizations" +} + +# Undo subcommand dispatcher. +# +# Args: $1 — account name; flags: --pick | --list | --force. +# Returns: 0 on success; 1 on user-visible failure. +_ckipper_account_sync_undo_dispatch() { + local account="$1"; shift 2>/dev/null + [[ -z "$account" ]] && { echo "Usage: ckipper account sync undo " >&2; return 1; } + local dst_dir; dst_dir=$(_core_account_dir "$account") || return 1 + local mode="latest" + while [[ $# -gt 0 ]]; do + case "$1" in + --pick) mode="pick"; shift ;; + --list) mode="list"; shift ;; + --force) _SYNC_FORCE="true"; shift ;; + *) echo "Unknown flag: $1" >&2; return 1 ;; + esac + done + _core_account_sync_assert_dst_idle "$dst_dir" "${_SYNC_FORCE:-false}" || return 1 + _ckipper_account_sync_undo_run "$account" "$dst_dir" "$mode" +} + +# Run the chosen undo mode (latest / pick / list). +# +# Args: $1 — account name; $2 — dst_dir; $3 — mode (latest|pick|list). +# Returns: 0 on success; 1 on failure or no backups. +_ckipper_account_sync_undo_run() { + local account="$1" dst_dir="$2" mode="$3" + local -a backups + backups=( ${(f)"$(_core_account_sync_manifest_list_backups "$dst_dir")"} ) + if (( ${#backups} == 0 )); then + echo "No backups for $account." + return 1 + fi + local target_backup="${backups[1]}" + if [[ "$mode" == "list" ]]; then + printf '%s\n' "${backups[@]}" + return 0 + fi + if [[ "$mode" == "pick" ]]; then + target_backup=$(_core_prompt_choose "Pick a backup to restore" "${backups[@]}") + [[ -z "$target_backup" ]] && return 1 + fi + _core_account_sync_undo_from_backup "$target_backup" "$dst_dir" } diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index dba2d02..cd60587 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -73,3 +73,53 @@ run_in_zsh() { [[ "$output" == *"from=EMPTY"* ]] [[ "$output" == *"n_targets=0"* ]] } + +# ── Integration: end-to-end dispatch with seeded accounts ──────────────── + +setup_two_accounts() { + cat > "$CKIPPER_REGISTRY" < "$TMP_HOME/src/.claude.json" + echo '{"mcpServers":{}}' > "$TMP_HOME/dst/.claude.json" +} + +run_full() { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_NO_GUM=1 CKIPPER_FORCE=1 TMP_HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +@test "ckipper account sync src dst --include mcp --yes applies the merge" { + setup_two_accounts + run_full 'ckipper account sync src dst --include mcp --yes' + [ "$status" -eq 0 ] + local merged + merged=$(jq -r '.mcpServers.github.command' "$TMP_HOME/dst/.claude.json") + [[ "$merged" == "x" ]] +} + +@test "ckipper account sync src dst --include mcp --dry-run does not apply" { + setup_two_accounts + run_full 'ckipper account sync src dst --include mcp --dry-run' + [ "$status" -eq 0 ] + local n; n=$(jq '.mcpServers | length' "$TMP_HOME/dst/.claude.json") + [[ "$n" == "0" ]] +} + +@test "ckipper account sync rejects unregistered source" { + setup_two_accounts + run_full 'ckipper account sync ghost dst --include mcp --yes' + [ "$status" -ne 0 ] +} + +@test "ckipper account sync src src is rejected (identity)" { + setup_two_accounts + run_full 'ckipper account sync src src --include mcp --yes' + [ "$status" -ne 0 ] +} diff --git a/lib/account/sync_test.bats b/lib/account/sync_test.bats deleted file mode 100644 index 0b7b068..0000000 --- a/lib/account/sync_test.bats +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bats -# Unit tests for lib/account/sync.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). - -load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" - -setup() { - setup_isolated_env -} - -teardown() { - teardown_isolated_env -} - -# Helper: run a zsh expression with the full ckipper environment sourced. -run_helper() { - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" -} - -# ── _ckipper_account_sync_parse_flags ───────────────────────────────────────── - -@test "parse_flags sets mode_all=true when no flags given" { - run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_account_sync_parse_flags - echo "mode_all=$mode_all"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "mode_all=true" ]] -} - -@test "parse_flags sets is_dry_run=true for --dry-run flag" { - run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_account_sync_parse_flags --dry-run - echo "is_dry_run=$is_dry_run"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "is_dry_run=true" ]] -} - -@test "parse_flags sets mode_mcp=true for --mcp flag" { - run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_account_sync_parse_flags --mcp - echo "mode_mcp=$mode_mcp"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "mode_mcp=true" ]] -} - -@test "parse_flags sets mode_settings=true for --settings flag" { - run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_account_sync_parse_flags --settings "enabledPlugins" - echo "mode_settings=$mode_settings"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "mode_settings=true" ]] -} - -@test "parse_flags returns 1 and prints error for unknown flag" { - run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_account_sync_parse_flags --bogus-flag' - - [ "$status" -ne 0 ] - [[ "$output" =~ "Unknown flag" ]] -} - -# ── _ckipper_account_sync_mcp_servers ───────────────────────────────────────── - -@test "sync_mcp_servers merges MCP servers into the destination claude.json" { - local from_dir="$TMP_HOME/.claude-src" - local to_dir="$TMP_HOME/.claude-dst" - mkdir -p "$from_dir" "$to_dir" - printf '{"mcpServers":{"testserver":{"command":"npx","args":["-y","test-mcp"]}}}' \ - > "$from_dir/.claude.json" - printf '{"mcpServers":{}}' > "$to_dir/.claude.json" - - run_helper 'pending_msgs=() - typeset -gA _CKIPPER_SYNC_CTX - _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" - _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="false" - _ckipper_account_sync_mcp_servers "dst" "" - echo "${pending_msgs[@]}"' - - [ "$status" -eq 0 ] - # The destination should now contain the server from the source. - local dst_content; dst_content=$(cat "$to_dir/.claude.json") - [[ "$dst_content" =~ "testserver" ]] -} - -@test "sync_mcp_servers is a no-op in dry-run mode (no writes)" { - local from_dir="$TMP_HOME/.claude-src" - local to_dir="$TMP_HOME/.claude-dst" - mkdir -p "$from_dir" "$to_dir" - printf '{"mcpServers":{"myserver":{"command":"node","args":[]}}}' \ - > "$from_dir/.claude.json" - printf '{"mcpServers":{}}' > "$to_dir/.claude.json" - - local before_dst; before_dst=$(cat "$to_dir/.claude.json") - - run_helper 'pending_msgs=() - typeset -gA _CKIPPER_SYNC_CTX - _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" - _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="true" - _ckipper_account_sync_mcp_servers "dst" ""' - - [ "$status" -eq 0 ] - # Destination must be unchanged in dry-run mode. - local after_dst; after_dst=$(cat "$to_dir/.claude.json") - [ "$before_dst" = "$after_dst" ] -} - -# ── _ckipper_account_sync_settings_keys ─────────────────────────────────────── - -@test "sync_settings_keys copies matching keys from source settings.json to destination" { - local from_dir="$TMP_HOME/.claude-src" - local to_dir="$TMP_HOME/.claude-dst" - mkdir -p "$from_dir" "$to_dir" - printf '{"model":"claude-opus-4-5","enabledPlugins":["myplugin"],"other":"value"}' \ - > "$from_dir/settings.json" - printf '{}' > "$to_dir/settings.json" - - run_helper 'pending_msgs=() - typeset -gA _CKIPPER_SYNC_CTX - _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" - _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="false" - _ckipper_account_sync_settings_keys "src" "dst" "model,enabledPlugins"' - - [ "$status" -eq 0 ] - local dst; dst=$(cat "$to_dir/settings.json") - [[ "$dst" =~ "model" ]] - [[ "$dst" =~ "enabledPlugins" ]] - # The "other" key was not in the sync list — it must not appear in the destination. - [[ ! "$dst" =~ '"other"' ]] -} - -@test "sync_settings_keys is a no-op in dry-run mode (no writes)" { - local from_dir="$TMP_HOME/.claude-src" - local to_dir="$TMP_HOME/.claude-dst" - mkdir -p "$from_dir" "$to_dir" - printf '{"model":"claude-opus-4-5"}' > "$from_dir/settings.json" - printf '{}' > "$to_dir/settings.json" - - local before_dst; before_dst=$(cat "$to_dir/settings.json") - - run_helper 'pending_msgs=() - typeset -gA _CKIPPER_SYNC_CTX - _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" - _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="true" - _ckipper_account_sync_settings_keys "src" "dst" "model"' - - [ "$status" -eq 0 ] - local after_dst; after_dst=$(cat "$to_dir/settings.json") - [ "$before_dst" = "$after_dst" ] -} - -# ── _ckipper_account_sync_print_summary ─────────────────────────────────────── - -@test "print_summary prints 'Synced' header and lists all pending messages" { - run_helper 'pending_msgs=("MCP servers → dst: server1 " "Settings keys → dst: model ") - _ckipper_account_sync_print_summary "dst" "false"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "Synced" ]] - [[ "$output" =~ "MCP servers" ]] - [[ "$output" =~ "Settings keys" ]] -} - -@test "print_summary prints 'Dry run' header in dry-run mode" { - run_helper 'pending_msgs=("MCP servers → dst: server1 ") - _ckipper_account_sync_print_summary "dst" "true"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "Dry run" ]] -} From 765d824dc720a8060b6ae663083b42a44f553f13 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 02:09:12 -0600 Subject: [PATCH 10/18] =?UTF-8?q?refactor(account):=20rename=20sync-hooks?= =?UTF-8?q?=20=E2=86=92=20redeploy-hooks=20(Phase=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4 ckipper-managed safety hooks (bash-guardrails, protect-claude-config, docker-context, notify-bell) live at $CKIPPER_DIR/hooks/ and are deployed to every account by construction. That's install→all redeploy, NOT peer-to-peer sync — the rename to redeploy-hooks makes the semantics explicit so the new `account sync ... --include hooks` (user-hook-only, peer-to-peer) doesn't conflict. - _ckipper_account_sync_hooks_for → _ckipper_account_redeploy_hooks_for - _ckipper_account_sync_hooks → _ckipper_account_redeploy_hooks - account dispatcher arm 'sync-hooks' → 'redeploy-hooks' - help text rewritten to flag the install-vs-peer distinction - account-management.zsh + aliases_test.bats updated to track renames Refs: docs/plans/2026-05-02-sync-overhaul-design.md §2 (non-goals), §7.2 (rename rationale). --- lib/account/account-management.zsh | 6 +-- lib/account/aliases.zsh | 27 ++++++---- lib/account/aliases_test.bats | 16 +++--- lib/account/dispatcher.zsh | 84 ++++++++++++------------------ 4 files changed, 62 insertions(+), 71 deletions(-) diff --git a/lib/account/account-management.zsh b/lib/account/account-management.zsh index 41f2539..57ad8b5 100644 --- a/lib/account/account-management.zsh +++ b/lib/account/account-management.zsh @@ -105,7 +105,7 @@ _ckipper_account_add_fresh_flow() { if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" fi - _ckipper_account_sync_hooks_for "$name" "$dir" + _ckipper_account_redeploy_hooks_for "$name" "$dir" local before_snapshot before_snapshot=$(_core_keychain_snapshot) || return 1 _ckipper_account_add_launch_claude "$name" "$dir" || return 1 @@ -256,7 +256,7 @@ _ckipper_account_finalize_registration() { _ckipper_account_finalize_announce() { local name="$1" mode="$2" _ckipper_account_regenerate_aliases - _ckipper_account_sync_hooks_for "$name" + _ckipper_account_redeploy_hooks_for "$name" echo "Registered '$name' (mode: $mode)." if _ckipper_account_bare_alias_safe "$name"; then echo "Use it via: claude-$name (or just: $name)" @@ -538,7 +538,7 @@ _ckipper_account_rename() { unset -f "claude-$old" 2>/dev/null unset -f "$old" 2>/dev/null _ckipper_account_regenerate_aliases - _ckipper_account_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir + _ckipper_account_redeploy_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir echo "Renamed '$old' → '$new'." echo "Directory: $old_dir → $new_dir" if _ckipper_account_bare_alias_safe "$new"; then diff --git a/lib/account/aliases.zsh b/lib/account/aliases.zsh index 28d9125..9fdc0f9 100644 --- a/lib/account/aliases.zsh +++ b/lib/account/aliases.zsh @@ -1,5 +1,6 @@ #!/usr/bin/env zsh -# Alias generation and hook sync subcommands: regenerate_aliases, sync_hooks_for, sync_hooks. +# Alias generation and install-hook redeploy subcommands: +# regenerate_aliases, redeploy_hooks_for, redeploy_hooks. readonly ALIASES_FILE_PERMS=644 @@ -110,9 +111,12 @@ _ckipper_account_rewrite_settings_json_hooks() { ' "$dir/settings.json" > "$settings_tmpfile" && mv "$settings_tmpfile" "$dir/settings.json" } -# Copy hook scripts into an account directory and rewrite settings.json hook paths. -# Allows callers (e.g. _ckipper_account_add) to pass the dir directly before the account -# is registered in the registry. +# Redeploy ckipper-managed safety hooks from $CKIPPER_DIR/hooks/ into one +# account dir and rewrite that account's settings.json hook paths to absolute +# destination paths. This is install→one redeploy, not peer-to-peer sync. +# +# Allows callers (e.g. _ckipper_account_add) to pass the dir directly before +# the account is registered in the registry. # # Args: # $1 — account name @@ -120,7 +124,7 @@ _ckipper_account_rewrite_settings_json_hooks() { # # Returns: # 0 on success; 1 if directory cannot be resolved. -_ckipper_account_sync_hooks_for() { +_ckipper_account_redeploy_hooks_for() { local name="$1" dir="${2:-}" if [[ -z "$dir" ]]; then _core_registry_check_version || return 1 @@ -132,11 +136,16 @@ _ckipper_account_sync_hooks_for() { _ckipper_account_rewrite_settings_json_hooks "$dir" } -# Copy hooks into all registered accounts. +# Redeploy ckipper-managed safety hooks into every registered account dir. +# +# This is install→all redeploy (NOT peer-to-peer sync). Run after editing +# anything under $CKIPPER_DIR/hooks/ so the change propagates everywhere. +# For peer-to-peer sync of user-written hooks, see `ckipper account sync +# --include hooks`. # # Returns: # 0 on success; 1 if registry version check fails. -_ckipper_account_sync_hooks() { +_ckipper_account_redeploy_hooks() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered." return 0 @@ -144,7 +153,7 @@ _ckipper_account_sync_hooks() { _core_registry_check_version || return 1 local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") while IFS= read -r name; do - echo "Syncing hooks → $name" - _ckipper_account_sync_hooks_for "$name" + echo "Redeploying install hooks → $name" + _ckipper_account_redeploy_hooks_for "$name" done <<< "$names" } diff --git a/lib/account/aliases_test.bats b/lib/account/aliases_test.bats index f64d8e0..67f8d01 100644 --- a/lib/account/aliases_test.bats +++ b/lib/account/aliases_test.bats @@ -63,37 +63,37 @@ run_helper() { [[ "$output" =~ "/tmp/.claude-work" ]] } -# ── _ckipper_account_sync_hooks_for ────────────────────────────────────────── +# ── _ckipper_account_redeploy_hooks_for ────────────────────────────────────────── -@test "sync_hooks_for copies hooks into the account directory" { +@test "redeploy_hooks_for copies hooks into the account directory" { echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"'"$TMP_HOME"'/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-dev" # Seed the shared hooks directory with a test hook. mkdir -p "$CKIPPER_DIR/hooks" echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/test-hook.sh" - run_helper '_ckipper_account_sync_hooks_for "dev"' + run_helper '_ckipper_account_redeploy_hooks_for "dev"' [ "$status" -eq 0 ] [ -f "$TMP_HOME/.claude-dev/hooks/test-hook.sh" ] } -@test "sync_hooks_for rewrites dollar-HOME hook paths in settings.json" { +@test "redeploy_hooks_for rewrites dollar-HOME hook paths in settings.json" { echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"'"$TMP_HOME"'/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-dev/hooks" # settings.json has a literal $HOME placeholder in a hook path. printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"$HOME/.ckipper/hooks/pre.sh"}]}]}}' \ > "$TMP_HOME/.claude-dev/settings.json" - run_helper '_ckipper_account_sync_hooks_for "dev"' + run_helper '_ckipper_account_redeploy_hooks_for "dev"' # After rewriting, the path should point to the account's hooks dir. grep -q "$TMP_HOME/.claude-dev/hooks/pre.sh" "$TMP_HOME/.claude-dev/settings.json" } -# ── _ckipper_account_sync_hooks ─────────────────────────────────────────────── +# ── _ckipper_account_redeploy_hooks ─────────────────────────────────────────── -@test "sync_hooks iterates all registered accounts and copies hooks to each" { +@test "redeploy_hooks iterates all registered accounts and copies hooks to each" { local dir_a="$TMP_HOME/.claude-alpha" local dir_b="$TMP_HOME/.claude-beta" mkdir -p "$dir_a" "$dir_b" @@ -101,7 +101,7 @@ run_helper() { mkdir -p "$CKIPPER_DIR/hooks" echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/shared-hook.sh" - run_helper '_ckipper_account_sync_hooks' + run_helper '_ckipper_account_redeploy_hooks' [ "$status" -eq 0 ] [ -f "$dir_a/hooks/shared-hook.sh" ] diff --git a/lib/account/dispatcher.zsh b/lib/account/dispatcher.zsh index 7effe13..b6fecad 100644 --- a/lib/account/dispatcher.zsh +++ b/lib/account/dispatcher.zsh @@ -7,11 +7,9 @@ # Known account subcommands. Used both for routing and for fuzzy-suggest. # -# Note: `sync-hooks` is intentionally omitted — the function is still callable -# (via the case statement below) but is hidden from public help and fuzzy -# suggestions. `repair-plugins` was retired in favour of `ckipper doctor --fix`. +# `repair-plugins` was retired in favour of `ckipper doctor --fix`. _CKIPPER_ACCOUNT_SUBCOMMANDS=( - add list default remove rename sync help + add list default remove rename sync redeploy-hooks help ) # Dispatch an `account` subcommand. @@ -29,7 +27,7 @@ _ckipper_account_dispatch() { local cmd="$1" shift 2>/dev/null case "$cmd" in - add|list|default|remove|rename|sync-hooks) + add|list|default|remove|rename|redeploy-hooks) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_account_help_for "$cmd" return 0 @@ -62,13 +60,14 @@ _ckipper_account_help() { _core_help_render "ckipper account — manage registered Claude accounts" \ "" \ "Usage:" \ - " ckipper account add Register a new account (interactive /login)" \ - " ckipper account add --adopt Register an existing populated config dir" \ - " ckipper account list Show registered accounts" \ - " ckipper account default Set the default account" \ - " ckipper account remove Unregister; prompts to delete dir + Keychain" \ - " ckipper account rename Rename an account in place" \ - " ckipper account sync Copy MCP/settings between accounts" \ + " ckipper account add Register a new account (interactive /login)" \ + " ckipper account add --adopt Register an existing populated config dir" \ + " ckipper account list Show registered accounts" \ + " ckipper account default Set the default account" \ + " ckipper account remove Unregister; prompts to delete dir + Keychain" \ + " ckipper account rename Rename an account in place" \ + " ckipper account sync [ ] Sync state peer-to-peer between accounts" \ + " ckipper account redeploy-hooks Redeploy install safety hooks to all accounts" \ "" \ "Short form: \`ckipper acct ...\` is equivalent." \ "" \ @@ -77,20 +76,21 @@ _ckipper_account_help() { # Per-subcommand help text router. Each arm prints a focused usage block. # -# Note: `sync-hooks` is hidden from public help summaries but is still routed -# here so `ckipper account sync-hooks --help` continues to work. +# Note: the `sync` arm dispatches to the new sync subsystem's help via +# parse_args before reaching this router, so `_ckipper_account_help_text_sync` +# is no longer used here — kept only as a fallback. # # Args: $1 — subcommand name. # Returns: 0 always. _ckipper_account_help_for() { case "$1" in - add) _ckipper_account_help_text_add ;; - list) _ckipper_account_help_text_list ;; - default) _ckipper_account_help_text_default ;; - remove) _ckipper_account_help_text_remove ;; - rename) _ckipper_account_help_text_rename ;; - sync) _ckipper_account_help_text_sync ;; - sync-hooks) _ckipper_account_help_text_sync_hooks ;; + add) _ckipper_account_help_text_add ;; + list) _ckipper_account_help_text_list ;; + default) _ckipper_account_help_text_default ;; + remove) _ckipper_account_help_text_remove ;; + rename) _ckipper_account_help_text_rename ;; + sync) _ckipper_account_sync_help_text ;; + redeploy-hooks) _ckipper_account_help_text_redeploy_hooks ;; esac } @@ -138,37 +138,19 @@ _ckipper_account_help_text_rename() { "Keychain service name is NOT changed — only the dir + registry mapping." } -_ckipper_account_help_text_sync() { - _core_help_render "ckipper account sync [options]" \ +_ckipper_account_help_text_redeploy_hooks() { + _core_help_render "ckipper account redeploy-hooks" \ "" \ - "Copy state from one registered account to another. Useful for sharing MCP" \ - "servers, plugin lists, status line, env vars, etc. across accounts." \ + "Redeploy ckipper-managed safety hooks from \$CKIPPER_DIR/hooks/ into" \ + "every registered account dir, then rewrite each account's settings.json" \ + "hook paths to absolute paths." \ "" \ - "By default (no flags) syncs a sensible bundle: mcpServers + enabledPlugins +" \ - "extraKnownMarketplaces + statusLine + env." \ + "These hooks (bash-guardrails, protect-claude-config, docker-context," \ + "notify-bell) are docker safety guardrails — identical across every" \ + "account by construction. Run after editing any hook script under" \ + "\$CKIPPER_DIR/hooks/ so the change propagates to every account." \ "" \ - "Options:" \ - " --mcp [name1,name2,...] Sync mcpServers. Without arg: all servers." \ - " With arg: only the named servers." \ - " --settings Sync specific top-level keys from settings.json." \ - " Comma-separated. Examples: enabledPlugins," \ - " extraKnownMarketplaces, statusLine, env, model." \ - " --all Sync the default bundle (same as no flags)." \ - " --dry-run Show what would change without writing." \ - "" \ - "Examples:" \ - " ckipper account sync personal work" \ - " ckipper account sync personal work --mcp" \ - " ckipper account sync personal work --mcp Vibma,github" \ - " ckipper account sync personal work --settings statusLine,env --dry-run" -} - -_ckipper_account_help_text_sync_hooks() { - _core_help_render "ckipper account sync-hooks" \ - "" \ - "Copy ~/.ckipper/hooks/* into each registered account's /hooks/ and" \ - "rewrite the per-account settings.json to point at those copies." \ - "" \ - "Run after editing any hook script under ~/.ckipper/hooks/ so the change" \ - "propagates to every account." + "Note: this is NOT peer-to-peer sync. To sync user-written hooks" \ + "(scripts you authored that live outside the install set) between" \ + "accounts, use \`ckipper account sync --include hooks\`." } From 5c641edd78ee39e75acb5d7498e9fce5a1419e6c Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 02:10:38 -0600 Subject: [PATCH 11/18] feat(setup): offer initial sync after adding 2nd-or-later account (Phase 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After _ckipper_setup_apply_account returns, _ckipper_setup_offer_initial_sync checks the registry account count and prompts the user to sync from an existing account into the new one. Silently skipped on first account. Refs: docs/plans/2026-05-02-sync-overhaul-design.md §9. --- lib/setup/dispatcher.zsh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index 6028c80..1adfce0 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -115,6 +115,29 @@ _ckipper_setup_add_account() { typeset -A prefs _ckipper_setup_collect_account_prefs "$name" _ckipper_setup_apply_account "$name" prefs + _ckipper_setup_offer_initial_sync "$name" +} + +# After a successful 2nd-or-later account add, offer to sync from an existing +# account into the freshly-added one. Skips when no other accounts exist. +# +# Args: $1 — newly-added account name. +# Returns: 0 always (cancellation is silent). +_ckipper_setup_offer_initial_sync() { + local new_account="$1" + local count + count=$(jq -r '.accounts | length' "$CKIPPER_REGISTRY" 2>/dev/null || echo 0) + (( count < 2 )) && return 0 + if ! _core_prompt_confirm "Sync settings from an existing account into '$new_account'?"; then + return 0 + fi + local -a others + others=( ${(f)"$(_core_account_sync_list_accounts_except "$new_account")"} ) + (( ${#others} == 0 )) && return 0 + local source_name + source_name=$(_core_prompt_choose "Sync from which account?" "${others[@]}") + [[ -z "$source_name" ]] && return 0 + _ckipper_account_sync_dispatch "$source_name" "$new_account" } # Map a single y/N confirmation to a "true"/"false" entry in the parent-scope From f1c5b09103db3a1376f052e41cb298ee64b94c28 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 02:14:55 -0600 Subject: [PATCH 12/18] docs(sync): docs sweep + tab completion bump (Phase 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab completion bumped to v7: adds redeploy-hooks subcommand, sync flags (--include/--exclude/--dry-run/--yes/--force), and multi-target account-name completion at arg4. - README: new 'Sync state between accounts' section with the 10 syncable types, common commands, named bundles, safeguards/undo, and a sync-vs-redeploy-hooks distinction. The MCP-per-account section now points readers at the new sync section instead of describing a flag surface that no longer exists. - CHANGELOG: dedicated 'Sync system overhaul' block under Unreleased — every new capability + the rename + the removed flags listed. - CONTRIBUTING.md unchanged (no stale sync references). Refs: docs/plans/2026-05-02-sync-overhaul-design.md §11. --- CHANGELOG.md | 13 ++++++++++ README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++- ckipper.zsh | 25 +++++++++++++++--- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bab1128..c986106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] — CLI + onboarding overhaul +### Sync system overhaul + +- **New:** `ckipper account sync` is fully interactive by default. Run with no args to pick source, targets, and types via gum pickers; pass positional args to skip the relevant pickers. +- **New:** 10 syncable types covering every shareable Claude Code config category: MCP servers, settings (top-level + nested keys), CLAUDE.md, agents, commands, output-styles, skills, statusline (with internal/external script detection), user-written hooks (filtered against the install allowlist), and account preferences. +- **New:** Named bundles `all`, `customizations`, `claude-config`, `preferences` for `--include` / `--exclude`. +- **New:** Multi-destination support — sync from one account to many in a single invocation. +- **New:** Summary table preview with `git status`-style status badges (`[+]` / `[~]`) and on-demand drill-down for any per-item diff. +- **New:** Timestamped backups before any destructive write — `/.ckipper-sync-backups/-from-/` — with manifest-driven restore via `ckipper account sync undo [--pick | --list]`. +- **New:** Hard refusal when Claude is running with the destination's config dir (override with `--force`). +- **New:** Setup wizard offers initial sync after adding a 2nd-or-later account. +- **Renamed:** `ckipper account sync-hooks` → `ckipper account redeploy-hooks`. The new name reflects that it deploys the ckipper-managed safety hooks from the install dir to every account; it is NOT peer-to-peer sync. +- **Removed:** Old flag surface (`--mcp [names]`, `--settings `, `--all`). The new `--include` / `--exclude` model with bundles supersedes these. + ### Added - `ckipper setup` — interactive wizard for configuring Ckipper. Re-runnable. - `ckipper config get / set / unset / list / edit` — view and modify settings. diff --git a/README.md b/README.md index 159b2e0..b8b1820 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,77 @@ Two terminals running the **same** account simultaneously will hit a known OAuth If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each. +## Sync state between accounts + +`ckipper account sync` copies state between registered accounts — MCP servers, settings, agents, commands, skills, user hooks, etc. — interactively by default, with one source and one or more destinations. + +### Syncable types + +| Type | What it covers | +|---|---| +| `mcp` | `.claude.json` `.mcpServers` (per server) | +| `settings` | `settings.json` top-level + nested keys (excludes `.hooks`) | +| `claude-md` | `CLAUDE.md` (user memory) | +| `agents` | `agents/*.md` | +| `commands` | `commands/*.md` | +| `output-styles` | `output_styles/*.md` | +| `skills` | `skills//` (per-directory; symlinks preserved) | +| `statusline` | `settings.json` `.statusLine` + referenced script (if internal to the account dir) | +| `hooks` | User-written hooks under `/hooks/` (filtered against the install allowlist) + paired `settings.json` `.hooks` entries | +| `prefs` | Account preferences in `accounts.json` (`always_docker`, `always_firewall`, `ssh_forward`) | + +Plugins are not a separate type — sync `enabledPlugins` + `extraKnownMarketplaces` (both in the `settings` type) and Claude Code re-fetches the plugins on the destination's next launch. + +### Common commands + +```sh +# Full interactive wizard — picks source, targets, and types +ckipper account sync + +# One-shot single type +ckipper account sync personal work --include mcp + +# Bundle: every user-customization (no prefs) +ckipper account sync personal work --include customizations + +# Multi-destination +ckipper account sync personal work client1 client2 --include all + +# Dry run (summary only, no writes) +ckipper account sync personal work --include all --dry-run + +# Apply without confirm prompt (for scripting) +ckipper account sync personal work --include all --yes +``` + +### Named bundles + +| Bundle | Resolves to | +|---|---| +| `all` | every type | +| `customizations` | mcp, settings, claude-md, agents, commands, output-styles, skills, statusline, hooks | +| `claude-config` | mcp, settings, hooks | +| `preferences` | prefs | + +### Safeguards & undo + +Every destructive write is preceded by a copy to `/.ckipper-sync-backups/-from-/`. The summary table prints the backup directory path before applying. To restore: + +```sh +ckipper account sync undo work # restore most recent backup +ckipper account sync undo work --pick # gum-pick from backup ledger +ckipper account sync undo work --list # print backup directory paths +``` + +Sync refuses to write when Claude is running with the destination's config dir (override with `--force` if you understand the risk). + +### Sync vs. redeploy-hooks + +These two commands sound similar but do different things: + +- **`ckipper account sync ... --include hooks`** — peer-to-peer copy of *user-written* hooks (any hook file in `/hooks/` whose filename does NOT match a ckipper-managed install hook). Includes the paired `settings.json` `.hooks` entry. +- **`ckipper account redeploy-hooks`** — pushes the ckipper safety hooks (`bash-guardrails`, `protect-claude-config`, `docker-context`, `notify-bell`) from `~/.ckipper/hooks/` to every registered account. Run after editing a script in the install dir. + ## Security ### Docker isolation @@ -301,7 +372,7 @@ This is usually a feature — your `personal` and `work` accounts working in the ### MCP servers are per-account (user-scoped only) -`mcpServers` lives in each account's `.claude.json`. When you `ckipper account add `, the new account starts with **zero** user-scoped MCP servers. Use `ckipper account sync` to copy MCP/settings/plugins between accounts. +`mcpServers` lives in each account's `.claude.json`. When you `ckipper account add `, the new account starts with **zero** user-scoped MCP servers. Use `ckipper account sync --include mcp` (or run the wizard with no args) to copy them — see [Sync state between accounts](#sync-state-between-accounts). ### Plugins and marketplaces are per-account diff --git a/ckipper.zsh b/ckipper.zsh index 568a0cc..f953849 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -221,7 +221,7 @@ fpath=(~/.zsh/completions $fpath) # Bump this when the heredoc body below changes so existing installs # regenerate the cached completion file. The version is embedded as a literal # comment in the generated file and matched here. -CKIPPER_COMPLETION_VERSION=6 +CKIPPER_COMPLETION_VERSION=7 if [[ ! -f ~/.zsh/completions/_ckipper ]] \ || ! grep -q "# ckipper-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_ckipper 2>/dev/null; then # Note: `_ckipper()` below is a zsh tab-completion definition embedded in @@ -231,7 +231,7 @@ if [[ ! -f ~/.zsh/completions/_ckipper ]] \ # a completion file, not maintained shell logic). cat > ~/.zsh/completions/_ckipper << 'COMPEOF' #compdef ckipper ck -# ckipper-completion-version=6 +# ckipper-completion-version=7 _ckipper() { local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" @@ -255,7 +255,8 @@ _ckipper() { 'default:Set the default account' 'remove:Unregister an account' 'rename:Rename an account in place' - 'sync:Copy state between accounts' + 'sync:Copy state between accounts (interactive or via --include)' + 'redeploy-hooks:Redeploy ckipper safety hooks from install to all accounts' 'help:Show account-namespace help' ) worktree_subs=( @@ -362,6 +363,13 @@ _ckipper() { _message 'new worktree branch name' fi ;; + account/sync|acct/sync) + local -a accounts + if [[ -f "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" ]]; then + accounts=( $(jq -r '.accounts | keys[]' "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" 2>/dev/null) ) + fi + _describe -t accounts 'additional sync target' accounts && return 0 + ;; esac ;; args) @@ -380,6 +388,17 @@ _ckipper() { _describe -t flags 'flag' flags _command_names -e ;; + account/sync|acct/sync) + local -a sync_flags + sync_flags=( + '--include:Comma-separated types or named bundle (all/customizations/claude-config/preferences)' + '--exclude:Subtract from --include' + '--dry-run:Print summary; no writes' + '--yes:Skip the confirm prompt; apply directly' + '--force:Bypass the destination-Claude-running refusal' + ) + _describe -t flags 'sync flag' sync_flags + ;; esac case "${words[2]}" in run) From 7df8a4343d1ee222983802985302b0789dd3889b Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 03:17:43 -0600 Subject: [PATCH 13/18] fix(sync): code-review feedback (PR #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - statusline: detect interpreter-prefix commands like "bash /script.sh" by walking every whitespace token, not just the first. The apply function's path rewrite switched from sub() (anchored, regex) to literal split+join — safe with paths that contain regex metacharacters. - preview UX wired end-to-end. _summary functions are now called per changeset row to populate the summaries TSV. The Apply prompt is a 3-way Apply / View changes / Abort chooser; View loops through the drill-down picker until Apply or Abort. _ckipper_account_sync_count_changes prints the trailing "N changes (a new, b overwrite)" line per §6.1. Important: - _CKIPPER_LEGACY_COMMANDS[sync-hooks] now points at the renamed redeploy-hooks subcommand. - Drill-down id/display fix: items file is the source of truth for the original id, recovered via _drill_down_resolve_id(items, type, display). Without this, files-based diffs would resolve to display ('foo.md') instead of id ('agents/foo.md'). - Per-target rollback safety: manifest_append fires BEFORE the apply call. A strategy that backs up + writes + then errors mid-write now has its entry in the manifest, so rollback restores from the backup dir. Test added that simulates a crashing apply and verifies the destination is restored to its pre-sync state. - Renamed every _core_* function defined in lib/account/sync/ to _ckipper_account_sync_* (55 functions). Per .claude/rules/shell-conventions.md, _core_* is reserved for lib/core/. Sed pass: _core_account_sync_ → _ckipper_account_sync_, then _core_sync_ → _ckipper_account_sync_. - Added lint-merge-guards rule that catches future _core_* definitions outside lib/core/. The guard would have failed this PR's pre-fix state — it now guards against the same drift recurring. --- Makefile | 1 + ckipper.zsh | 2 +- lib/account/sync/backup.zsh | 26 ++--- lib/account/sync/backup_test.bats | 78 +++++++-------- lib/account/sync/dispatcher.zsh | 98 ++++++++++++++----- lib/account/sync/engine.zsh | 73 +++++++++----- lib/account/sync/engine_test.bats | 94 ++++++++++++++---- lib/account/sync/interactive.zsh | 20 ++-- lib/account/sync/interactive_test.bats | 8 +- lib/account/sync/preview.zsh | 65 +++++++----- lib/account/sync/preview_test.bats | 29 ++++-- lib/account/sync/registry.zsh | 16 +-- lib/account/sync/registry_test.bats | 36 +++---- lib/account/sync/strategies/files_dir.zsh | 30 +++--- .../sync/strategies/files_dir_test.bats | 8 +- lib/account/sync/strategies/files_flat.zsh | 60 ++++++------ .../sync/strategies/files_flat_test.bats | 4 +- lib/account/sync/strategies/hooks.zsh | 22 ++--- lib/account/sync/strategies/hooks_test.bats | 4 +- lib/account/sync/strategies/statusline.zsh | 41 +++++--- .../sync/strategies/statusline_test.bats | 40 ++++++-- lib/account/sync/strategies/structured.zsh | 28 +++--- .../sync/strategies/structured_test.bats | 28 +++--- lib/setup/dispatcher.zsh | 2 +- 24 files changed, 508 insertions(+), 305 deletions(-) diff --git a/Makefile b/Makefile index be5c9a1..06273b5 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ lint-merge-guards: @! grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: setup-namespace reference outside lib/setup/" >&2 && exit 1) @! grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: run-namespace reference outside lib/run/" >&2 && exit 1) @! grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: launcher-namespace reference outside lib/launcher/" >&2 && exit 1) + @! grep -rE '^_core_[a-z_]+\(\)' lib/account/ lib/worktree/ lib/config/ lib/setup/ lib/run/ lib/launcher/ --include='*.zsh' 2>/dev/null || (echo "lint-merge-guards: _core_* function defined outside lib/core/ (see .claude/rules/shell-conventions.md — _core_* is reserved for lib/core/)" >&2 && exit 1) install: ./install.sh diff --git a/ckipper.zsh b/ckipper.zsh index f953849..befb7d6 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -103,7 +103,7 @@ typeset -gA _CKIPPER_LEGACY_COMMANDS=( [remove]='account remove' [rename]='account rename' [sync]='account sync' - [sync-hooks]='account sync-hooks' + [sync-hooks]='account redeploy-hooks' [repair-plugins]='doctor --fix' [migrate]='' ) diff --git a/lib/account/sync/backup.zsh b/lib/account/sync/backup.zsh index 29b0f65..5700605 100644 --- a/lib/account/sync/backup.zsh +++ b/lib/account/sync/backup.zsh @@ -1,6 +1,6 @@ #!/usr/bin/env zsh # Backup primitives for the sync engine. -# Every destructive write goes through _core_account_sync_backup_file +# Every destructive write goes through _ckipper_account_sync_backup_file # (called by strategy apply functions BEFORE the merge) so any failure can # be rolled back from the backup dir. # @@ -22,7 +22,7 @@ readonly _CKIPPER_SYNC_MANIFEST_VERSION=1 # # Args: $1 — destination account dir; $2 — source account name. # Returns: 0; prints absolute path of the to-be-created backup dir. -_core_account_sync_backup_dir_path() { +_ckipper_account_sync_backup_dir_path() { local dst_dir="$1" source_name="$2" local ts; ts=$(date -u +"%Y-%m-%dT%H-%M-%SZ") echo "$dst_dir/$_CKIPPER_SYNC_BACKUP_SUBDIR/$ts-from-$source_name" @@ -33,10 +33,10 @@ _core_account_sync_backup_dir_path() { # # Args: $1 — destination account dir; $2 — source account name. # Returns: 0; prints the created path on stdout. -_core_account_sync_backup_create() { +_ckipper_account_sync_backup_create() { local dst_dir="$1" source_name="$2" local backup_dir - backup_dir=$(_core_account_sync_backup_dir_path "$dst_dir" "$source_name") + backup_dir=$(_ckipper_account_sync_backup_dir_path "$dst_dir" "$source_name") mkdir -p "$backup_dir" chmod "$_CKIPPER_SYNC_BACKUP_DIR_PERMS" "$backup_dir" echo "$backup_dir" @@ -48,7 +48,7 @@ _core_account_sync_backup_create() { # # Args: $1 — backup_dir; $2 — absolute source path; $3 — relative destination path. # Returns: 0 on success or no-op; 1 if cp fails. -_core_account_sync_backup_file() { +_ckipper_account_sync_backup_file() { local backup_dir="$1" src="$2" rel="$3" [[ ! -e "$src" ]] && return 0 local dst="$backup_dir/$rel" @@ -62,7 +62,7 @@ _core_account_sync_backup_file() { # # Args: $1 — backup_dir; $2 — source name; $3 — target name. # Returns: 0; writes manifest JSON to /. -_core_account_sync_manifest_init() { +_ckipper_account_sync_manifest_init() { local backup_dir="$1" source_name="$2" target_name="$3" local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") @@ -79,7 +79,7 @@ _core_account_sync_manifest_init() { # Args: $1 — backup_dir; $2 — relative path; $3 — operation (create|overwrite); # $4 — type id; $5 — items (comma-separated, optional). # Returns: 0 on success; 1 on jq failure. -_core_account_sync_manifest_append() { +_ckipper_account_sync_manifest_append() { local backup_dir="$1" rel="$2" op="$3" type="$4" items="${5:-}" local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" local tmp; tmp=$(mktemp "$manifest.XXXXXX") @@ -95,7 +95,7 @@ _core_account_sync_manifest_append() { # # Args: $1 — destination account dir. # Returns: 0 always; prints absolute backup-dir paths, one per line. -_core_account_sync_manifest_list_backups() { +_ckipper_account_sync_manifest_list_backups() { local dst_dir="$1" local root="$dst_dir/$_CKIPPER_SYNC_BACKUP_SUBDIR" [[ ! -d "$root" ]] && return 0 @@ -115,13 +115,13 @@ _core_account_sync_manifest_list_backups() { # Args: $1 — backup_dir for this target; $2 — destination account dir. # Returns: 0 on full success; 1 if any per-entry rollback failed. # Errors (stderr): "rollback failed: " — per-entry failures. -_core_account_sync_rollback_target() { +_ckipper_account_sync_rollback_target() { local backup_dir="$1" dst_dir="$2" local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" [[ ! -f "$manifest" ]] && return 0 local rc=0 while IFS=$'\t' read -r op rel; do - _core_account_sync_rollback_one "$backup_dir" "$dst_dir" "$op" "$rel" || rc=1 + _ckipper_account_sync_rollback_one "$backup_dir" "$dst_dir" "$op" "$rel" || rc=1 done < <(jq -r '.files[] | "\(.operation)\t\(.path)"' "$manifest") return $rc } @@ -132,7 +132,7 @@ _core_account_sync_rollback_target() { # Args: $1 — backup_dir; $2 — dst_dir; $3 — operation; $4 — relative path. # Returns: 0 on success; 1 on rm/mv failure. # Errors (stderr): "rollback failed: " -_core_account_sync_rollback_one() { +_ckipper_account_sync_rollback_one() { local backup_dir="$1" dst_dir="$2" op="$3" rel="$4" local live="$dst_dir/$rel" if [[ "$op" == "create" ]]; then @@ -159,9 +159,9 @@ _core_account_sync_rollback_one() { # Args: $1 — backup_dir; $2 — destination account dir. # Returns: 0 on full restore + cleanup; 1 if restore had failures # (backup dir is preserved on partial failure for inspection). -_core_account_sync_undo_from_backup() { +_ckipper_account_sync_undo_from_backup() { local backup_dir="$1" dst_dir="$2" - if ! _core_account_sync_rollback_target "$backup_dir" "$dst_dir"; then + if ! _ckipper_account_sync_rollback_target "$backup_dir" "$dst_dir"; then return 1 fi rm -rf "$backup_dir" diff --git a/lib/account/sync/backup_test.bats b/lib/account/sync/backup_test.bats index 6c4be72..7b0d3a8 100644 --- a/lib/account/sync/backup_test.bats +++ b/lib/account/sync/backup_test.bats @@ -11,19 +11,19 @@ run_in_zsh() { zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; $*" } -@test "_core_account_sync_backup_dir_path generates a UTC ISO timestamp" { +@test "_ckipper_account_sync_backup_dir_path generates a UTC ISO timestamp" { run_in_zsh ' - path=$(_core_account_sync_backup_dir_path "/tmp/dst" "personal") + path=$(_ckipper_account_sync_backup_dir_path "/tmp/dst" "personal") echo "$path"' [ "$status" -eq 0 ] [[ "$output" == */tmp/dst/.ckipper-sync-backups/*-from-personal* ]] } -@test "_core_account_sync_backup_create makes the dir 0700" { +@test "_ckipper_account_sync_backup_create makes the dir 0700" { local dst="$TMP_HOME/dest" mkdir -p "$dst" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) echo \"\$backup_dir\"" [ "$status" -eq 0 ] local dir; dir=$(echo "$output" | tail -1) @@ -32,46 +32,46 @@ run_in_zsh() { [[ "$mode" == "700" ]] } -@test "_core_account_sync_backup_file copies a regular file with 0600" { +@test "_ckipper_account_sync_backup_file copies a regular file with 0600" { local dst="$TMP_HOME/dest" mkdir -p "$dst/hooks" echo "original content" > "$dst/hooks/foo.sh" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' cat \"\$backup_dir/hooks/foo.sh\"" [ "$status" -eq 0 ] [[ "$output" == *"original content"* ]] } -@test "_core_account_sync_backup_file is a no-op for missing source (operation == create)" { +@test "_ckipper_account_sync_backup_file is a no-op for missing source (operation == create)" { local dst="$TMP_HOME/dest" mkdir -p "$dst" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_backup_file \"\$backup_dir\" '$dst/does-not-exist' 'phantom' && echo OK" + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/does-not-exist' 'phantom' && echo OK" [ "$status" -eq 0 ] [[ "$output" == *"OK"* ]] } -@test "_core_account_sync_backup_file copies directories recursively (cp -a)" { +@test "_ckipper_account_sync_backup_file copies directories recursively (cp -a)" { local dst="$TMP_HOME/dest" mkdir -p "$dst/skills/foo" echo "a" > "$dst/skills/foo/SKILL.md" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_backup_file \"\$backup_dir\" '$dst/skills/foo' 'skills/foo' + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/skills/foo' 'skills/foo' cat \"\$backup_dir/skills/foo/SKILL.md\"" [ "$status" -eq 0 ] [[ "$output" == *"a"* ]] } -@test "_core_account_sync_manifest_init writes a valid empty manifest" { +@test "_ckipper_account_sync_manifest_init writes a valid empty manifest" { local dst="$TMP_HOME/dest" mkdir -p "$dst" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_manifest_init \"\$backup_dir\" personal work + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work cat \"\$backup_dir/.ckipper-sync-manifest.json\" | jq -r '.version, .source, .target'" [ "$status" -eq 0 ] [[ "$output" == *"1"* ]] @@ -79,72 +79,72 @@ run_in_zsh() { [[ "$output" == *"work"* ]] } -@test "_core_account_sync_manifest_append adds an entry" { +@test "_ckipper_account_sync_manifest_append adds an entry" { local dst="$TMP_HOME/dest" mkdir -p "$dst" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_manifest_init \"\$backup_dir\" personal work - _core_account_sync_manifest_append \"\$backup_dir\" 'settings.json' overwrite mcp 'github,vibma' + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'settings.json' overwrite mcp 'github,vibma' jq -r '.files | length' \"\$backup_dir/.ckipper-sync-manifest.json\"" [ "$status" -eq 0 ] [[ "$output" == *"1"* ]] } -@test "_core_account_sync_manifest_list_backups sorts newest first" { +@test "_ckipper_account_sync_manifest_list_backups sorts newest first" { local dst="$TMP_HOME/dest" mkdir -p "$dst/.ckipper-sync-backups/2026-01-01T00-00-00Z-from-a" mkdir -p "$dst/.ckipper-sync-backups/2026-05-02T00-00-00Z-from-b" mkdir -p "$dst/.ckipper-sync-backups/2026-03-15T00-00-00Z-from-c" - run_in_zsh "_core_account_sync_manifest_list_backups '$dst' | head -1" + run_in_zsh "_ckipper_account_sync_manifest_list_backups '$dst' | head -1" [ "$status" -eq 0 ] [[ "$output" == *"2026-05-02T00-00-00Z-from-b"* ]] } -@test "_core_account_sync_rollback_target restores backed-up files atomically" { +@test "_ckipper_account_sync_rollback_target restores backed-up files atomically" { local dst="$TMP_HOME/dest" mkdir -p "$dst/hooks" echo "original" > "$dst/hooks/foo.sh" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_manifest_init \"\$backup_dir\" personal work - _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' - _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh echo modified > '$dst/hooks/foo.sh' - _core_account_sync_rollback_target \"\$backup_dir\" '$dst' + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst' cat '$dst/hooks/foo.sh'" [ "$status" -eq 0 ] [[ "$output" == *"original"* ]] [[ "$output" != *"modified"* ]] } -@test "_core_account_sync_rollback_target removes files marked operation=create" { +@test "_ckipper_account_sync_rollback_target removes files marked operation=create" { local dst="$TMP_HOME/dest" mkdir -p "$dst/hooks" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_manifest_init \"\$backup_dir\" personal work - _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/new.sh' create hooks new.sh + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'hooks/new.sh' create hooks new.sh echo new-content > '$dst/hooks/new.sh' - _core_account_sync_rollback_target \"\$backup_dir\" '$dst' + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst' [[ -e '$dst/hooks/new.sh' ]] && echo STILL_THERE || echo GONE" [[ "$output" == *"GONE"* ]] } -@test "_core_account_sync_undo_from_backup restores and removes backup dir" { +@test "_ckipper_account_sync_undo_from_backup restores and removes backup dir" { local dst="$TMP_HOME/dest" mkdir -p "$dst/hooks" echo "original" > "$dst/hooks/foo.sh" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' personal) - _core_account_sync_manifest_init \"\$backup_dir\" personal work - _core_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' - _core_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/hooks/foo.sh' 'hooks/foo.sh' + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'hooks/foo.sh' overwrite hooks foo.sh echo modified > '$dst/hooks/foo.sh' - _core_account_sync_undo_from_backup \"\$backup_dir\" '$dst' + _ckipper_account_sync_undo_from_backup \"\$backup_dir\" '$dst' echo \"--\" cat '$dst/hooks/foo.sh' echo \"--\" diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index 4497bf1..a136fe5 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -78,10 +78,10 @@ _ckipper_account_sync_dispatch() { # Returns: 0 on success across all targets; 1 if any target failed. _ckipper_account_sync_run() { if [[ -z "$_SYNC_FROM" ]]; then - _SYNC_FROM=$(_core_account_sync_pick_source) || return 1 + _SYNC_FROM=$(_ckipper_account_sync_pick_source) || return 1 fi if (( ${#_SYNC_TARGETS} == 0 )); then - _SYNC_TARGETS=( ${(f)"$(_core_account_sync_pick_targets "$_SYNC_FROM")"} ) + _SYNC_TARGETS=( ${(f)"$(_ckipper_account_sync_pick_targets "$_SYNC_FROM")"} ) (( ${#_SYNC_TARGETS} == 0 )) && return 1 fi _ckipper_account_sync_validate_accounts || return 1 @@ -100,7 +100,7 @@ _ckipper_account_sync_run_targets() { local -a types_local; types_local=( "${(@P)types_var}" ) local rc=0 target for target in "${_SYNC_TARGETS[@]}"; do - _core_account_sync_validate_pair "$_SYNC_FROM" "$target" || { rc=1; continue; } + _ckipper_account_sync_validate_pair "$_SYNC_FROM" "$target" || { rc=1; continue; } _ckipper_account_sync_run_one_target "$target" "${types_local[@]}" || rc=1 done return $rc @@ -123,11 +123,11 @@ _ckipper_account_sync_validate_accounts() { # Returns: 0; prints type ids one per line. _ckipper_account_sync_resolve_types() { if [[ -z "$_SYNC_INCLUDE" && "$_SYNC_YES" != "true" && "$_SYNC_DRY_RUN" != "true" ]]; then - _core_account_sync_pick_types + _ckipper_account_sync_pick_types return 0 fi [[ -z "$_SYNC_INCLUDE" ]] && _SYNC_INCLUDE="all" - _core_account_sync_resolve_includes "$_SYNC_INCLUDE" "$_SYNC_EXCLUDE" + _ckipper_account_sync_resolve_includes "$_SYNC_INCLUDE" "$_SYNC_EXCLUDE" } # One-target slice: build change set, render preview, prompt, apply. @@ -139,25 +139,75 @@ _ckipper_account_sync_run_one_target() { local src_dir dst_dir src_dir=$(_core_account_dir "$_SYNC_FROM") dst_dir=$(_core_account_dir "$target") - _core_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 - local changeset summaries - changeset=$(mktemp) - summaries=$(mktemp) - _core_account_sync_build_change_set "$src_dir" "$dst_dir" \ + _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 + local changeset summaries items + changeset=$(mktemp); summaries=$(mktemp); items=$(mktemp) + _ckipper_account_sync_build_change_set "$src_dir" "$dst_dir" \ "$_SYNC_FROM" "$target" "$@" > "$changeset" - _core_account_sync_render_summary "$_SYNC_FROM" "$target" \ - "$(_core_account_sync_backup_dir_path "$dst_dir" "$_SYNC_FROM")" \ - "$summaries" < "$changeset" - if [[ "$_SYNC_DRY_RUN" == "true" ]]; then - rm -f "$changeset" "$summaries" - return 0 + _ckipper_account_sync_build_summaries "$src_dir" "$dst_dir" \ + "$_SYNC_FROM" "$target" < "$changeset" > "$summaries" + _ckipper_account_sync_drill_down_items < "$changeset" > "$items" + _ckipper_account_sync_show_preview "$target" "$dst_dir" "$changeset" "$summaries" + local action="apply" + if [[ "$_SYNC_DRY_RUN" != "true" && "$_SYNC_YES" != "true" ]]; then + action=$(_ckipper_account_sync_preview_prompt "$src_dir" "$dst_dir" "$target" "$items") fi - if [[ "$_SYNC_YES" != "true" ]]; then - _core_prompt_confirm "Apply changes to $target?" || { rm -f "$changeset" "$summaries"; return 0; } + [[ "$_SYNC_DRY_RUN" == "true" ]] && action="dry-run" + _ckipper_account_sync_finalize "$action" "$src_dir" "$dst_dir" \ + "$target" "$changeset" "$summaries" "$items" +} + +# Render the §6.1 preview block — header, summary table, change count. +# +# Args: $1 — target; $2 — dst_dir; $3 — changeset file; $4 — summaries file. +# Returns: 0 always. +_ckipper_account_sync_show_preview() { + local target="$1" dst_dir="$2" changeset="$3" summaries="$4" + local backup_path + backup_path=$(_ckipper_account_sync_backup_dir_path "$dst_dir" "$_SYNC_FROM") + _ckipper_account_sync_render_summary "$_SYNC_FROM" "$target" \ + "$backup_path" "$summaries" < "$changeset" + local counts; counts=$(_ckipper_account_sync_count_changes < "$changeset") + local total new ow; read -r total new ow <<< "$counts" + echo "$total changes ($new new, $ow overwrite)." +} + +# Apply / View changes / Abort prompt, with View looping back through +# the drill-down picker until Apply or Abort. +# +# Args: $1 — src_dir; $2 — dst_dir; $3 — target; $4 — items file. +# Returns: 0; prints "apply" | "abort". +_ckipper_account_sync_preview_prompt() { + local src_dir="$1" dst_dir="$2" target="$3" items="$4" + while true; do + local choice + choice=$(_core_prompt_choose "Apply changes to $target?" "Apply" "View changes" "Abort") + case "$choice" in + Apply) echo "apply"; return 0 ;; + Abort|"") echo "abort"; return 0 ;; + "View changes") + _ckipper_account_sync_drill_down_loop "$src_dir" "$dst_dir" \ + "$_SYNC_FROM" "$target" "$items" + ;; + esac + done +} + +# Run the chosen action, clean up tmpfiles, return the apply rc. +# +# Args: $1 — action; $2 — src_dir; $3 — dst_dir; $4 — target; +# $5 — changeset; $6 — summaries; $7 — items. +# Returns: 0 unless action=apply and the apply failed. +_ckipper_account_sync_finalize() { + local action="$1" src_dir="$2" dst_dir="$3" target="$4" + local changeset="$5" summaries="$6" items="$7" + local rc=0 + if [[ "$action" == "apply" ]]; then + _ckipper_account_sync_apply_target "$src_dir" "$dst_dir" \ + "$_SYNC_FROM" "$target" < "$changeset" + rc=$? fi - _core_account_sync_apply_target "$src_dir" "$dst_dir" "$_SYNC_FROM" "$target" < "$changeset" - local rc=$? - rm -f "$changeset" "$summaries" + rm -f "$changeset" "$summaries" "$items" return $rc } @@ -208,7 +258,7 @@ _ckipper_account_sync_undo_dispatch() { *) echo "Unknown flag: $1" >&2; return 1 ;; esac done - _core_account_sync_assert_dst_idle "$dst_dir" "${_SYNC_FORCE:-false}" || return 1 + _ckipper_account_sync_assert_dst_idle "$dst_dir" "${_SYNC_FORCE:-false}" || return 1 _ckipper_account_sync_undo_run "$account" "$dst_dir" "$mode" } @@ -219,7 +269,7 @@ _ckipper_account_sync_undo_dispatch() { _ckipper_account_sync_undo_run() { local account="$1" dst_dir="$2" mode="$3" local -a backups - backups=( ${(f)"$(_core_account_sync_manifest_list_backups "$dst_dir")"} ) + backups=( ${(f)"$(_ckipper_account_sync_manifest_list_backups "$dst_dir")"} ) if (( ${#backups} == 0 )); then echo "No backups for $account." return 1 @@ -233,5 +283,5 @@ _ckipper_account_sync_undo_run() { target_backup=$(_core_prompt_choose "Pick a backup to restore" "${backups[@]}") [[ -z "$target_backup" ]] && return 1 fi - _core_account_sync_undo_from_backup "$target_backup" "$dst_dir" + _ckipper_account_sync_undo_from_backup "$target_backup" "$dst_dir" } diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index 87767f2..5f82bba 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -30,19 +30,19 @@ # new items (drill-down skips status==new in the preview UI). # # _apply -# Perform the merge. MUST call _core_account_sync_backup_file +# Perform the merge. MUST call _ckipper_account_sync_backup_file # before any destructive write. Returns 0 on success; non-zero on # failure (engine then triggers per-target rollback). # # All five functions take their arguments in the same order so the engine -# can call them through _core_account_sync_strategy_fn uniformly. +# can call them through _ckipper_account_sync_strategy_fn uniformly. # Compute the strategy function name for a (type, verb) pair. # # Args: $1 — type id (e.g. "mcp", "claude-md"); $2 — verb (enumerate, compare, # summary, diff, apply). # Returns: 0; prints the function name (e.g. "_ckipper_account_sync_mcp_enumerate"). -_core_account_sync_strategy_fn() { +_ckipper_account_sync_strategy_fn() { local type="$1" verb="$2" echo "_ckipper_account_sync_${type}_${verb}" } @@ -55,7 +55,7 @@ _core_account_sync_strategy_fn() { # Returns: 0 if safe to proceed; 1 if Claude is running on dst (unless force). # Errors (stderr): a multiline message identifying the running process(es) # and the suggested launcher command. -_core_account_sync_assert_dst_idle() { +_ckipper_account_sync_assert_dst_idle() { local dst_dir="$1" force="$2" [[ "$force" == "true" ]] && return 0 local procs; procs=$(_core_running_claude_processes 2>/dev/null) @@ -78,7 +78,7 @@ _core_account_sync_assert_dst_idle() { # Args: $1 — source name; $2 — target name. # Returns: 0 if valid; 1 if names match. # Errors (stderr): "Source and target must differ: " -_core_account_sync_validate_pair() { +_ckipper_account_sync_validate_pair() { local src="$1" tgt="$2" if [[ "$src" == "$tgt" ]]; then echo "Source and target must differ: $src" >&2 @@ -94,12 +94,12 @@ _core_account_sync_validate_pair() { # Args: $1 — src dir; $2 — dst dir; $3 — src account name; $4 — dst account name; # $5..$N — type ids to walk (already resolved from --include/--exclude). # Returns: 0 always; prints "\t\t\t" per line. -_core_account_sync_build_change_set() { +_ckipper_account_sync_build_change_set() { local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" shift 4 local type for type in "$@"; do - _core_account_sync_walk_type "$type" "$src_dir" "$dst_dir" "$src_name" "$dst_name" + _ckipper_account_sync_walk_type "$type" "$src_dir" "$dst_dir" "$src_name" "$dst_name" done } @@ -107,11 +107,11 @@ _core_account_sync_build_change_set() { # # Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name. # Returns: 0 always; prints rows. -_core_account_sync_walk_type() { +_ckipper_account_sync_walk_type() { local type="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" local enumerate_fn compare_fn - enumerate_fn=$(_core_account_sync_strategy_fn "$type" enumerate) - compare_fn=$(_core_account_sync_strategy_fn "$type" compare) + enumerate_fn=$(_ckipper_account_sync_strategy_fn "$type" enumerate) + compare_fn=$(_ckipper_account_sync_strategy_fn "$type" compare) local arg_a="$src_dir" arg_b="$dst_dir" [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } local id display change_status @@ -125,8 +125,8 @@ _core_account_sync_walk_type() { # Apply a change set to a single target. Steps: # 1. Create backup dir + manifest. # 2. For each change, call the strategy's apply (which itself calls -# _core_account_sync_backup_file before writing). -# 3. On any failure: roll back via _core_account_sync_rollback_target, +# _ckipper_account_sync_backup_file before writing). +# 3. On any failure: roll back via _ckipper_account_sync_rollback_target, # print the partial manifest's path, and return non-zero. # # Reads the change set on stdin: TSV rows of "\t\t\t" @@ -134,15 +134,15 @@ _core_account_sync_walk_type() { # # Args: $1 — src dir; $2 — dst dir; $3 — src name; $4 — dst name. # Returns: 0 on success; 1 if any apply failed (after rollback completed). -_core_account_sync_apply_target() { +_ckipper_account_sync_apply_target() { local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" local backup_dir - backup_dir=$(_core_account_sync_backup_create "$dst_dir" "$src_name") - _core_account_sync_manifest_init "$backup_dir" "$src_name" "$dst_name" + backup_dir=$(_ckipper_account_sync_backup_create "$dst_dir" "$src_name") + _ckipper_account_sync_manifest_init "$backup_dir" "$src_name" "$dst_name" local rc=0 type id display change_status while IFS=$'\t' read -r type id display change_status; do [[ -z "$type" || "$change_status" == "unchanged" ]] && continue - if ! _core_account_sync_apply_one "$type" "$src_dir" "$dst_dir" \ + if ! _ckipper_account_sync_apply_one "$type" "$src_dir" "$dst_dir" \ "$src_name" "$dst_name" "$id" \ "$change_status" "$backup_dir"; then rc=1 @@ -150,7 +150,7 @@ _core_account_sync_apply_target() { fi done if (( rc != 0 )); then - _core_account_sync_rollback_target "$backup_dir" "$dst_dir" >&2 + _ckipper_account_sync_rollback_target "$backup_dir" "$dst_dir" >&2 echo "Rolled back. Backup preserved at: $backup_dir" >&2 fi return $rc @@ -159,27 +159,33 @@ _core_account_sync_apply_target() { # Apply one change set entry. Bridges between the strategy contract and # the manifest schema. prefs uses names; everything else uses dirs. # +# Manifest is appended BEFORE the apply call, not after. If the apply +# crashes mid-write (backed-up the file, started writing, errored), the +# manifest still contains the entry so rollback can restore from the +# backup dir. Without this, mid-write failures leave the destination +# half-written with no manifest record (rollback would skip the file). +# # Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name; # $6 — id; $7 — change status; $8 — backup_dir. # Returns: 0 on success; non-zero on apply failure. -_core_account_sync_apply_one() { +_ckipper_account_sync_apply_one() { local type="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" local id="$6" change_status="$7" backup_dir="$8" - local apply_fn; apply_fn=$(_core_account_sync_strategy_fn "$type" apply) + local apply_fn; apply_fn=$(_ckipper_account_sync_strategy_fn "$type" apply) local arg_a="$src_dir" arg_b="$dst_dir" [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } local op="overwrite"; [[ "$change_status" == "new" ]] && op="create" - local rel; rel=$(_core_account_sync_manifest_rel "$type" "$id") - "$apply_fn" "$arg_a" "$arg_b" "$id" "$backup_dir" || return 1 - _core_account_sync_manifest_append "$backup_dir" "$rel" "$op" "$type" "$id" + local rel; rel=$(_ckipper_account_sync_manifest_rel "$type" "$id") + _ckipper_account_sync_manifest_append "$backup_dir" "$rel" "$op" "$type" "$id" + "$apply_fn" "$arg_a" "$arg_b" "$id" "$backup_dir" } # Compute the manifest's path field for a given (type, id). The relpath -# is what _core_account_sync_rollback_one operates on. +# is what _ckipper_account_sync_rollback_one operates on. # # Args: $1 — type; $2 — id. # Returns: 0; prints relpath. -_core_account_sync_manifest_rel() { +_ckipper_account_sync_manifest_rel() { local type="$1" id="$2" case "$type" in mcp) echo ".claude.json" ;; @@ -188,3 +194,22 @@ _core_account_sync_manifest_rel() { *) echo "$id" ;; esac } + +# Build a TSV of (type, id, summary) by calling each strategy's _summary +# function for every changeset row. Reads the changeset on stdin; writes +# to stdout. Skips unchanged rows so the picker only sees actionable items. +# +# Args: $1 — src_dir; $2 — dst_dir; $3 — src_name; $4 — dst_name. +# Returns: 0 always. +_ckipper_account_sync_build_summaries() { + local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" + local type id display change_status summary_fn arg_a arg_b summary + while IFS=$'\t' read -r type id display change_status; do + [[ -z "$type" || "$change_status" == "unchanged" ]] && continue + summary_fn=$(_ckipper_account_sync_strategy_fn "$type" summary) + arg_a="$src_dir"; arg_b="$dst_dir" + [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + summary=$("$summary_fn" "$arg_a" "$arg_b" "$id") + echo "$type"$'\t'"$id"$'\t'"$summary" + done +} diff --git a/lib/account/sync/engine_test.bats b/lib/account/sync/engine_test.bats index 96b5f54..c7424d7 100644 --- a/lib/account/sync/engine_test.bats +++ b/lib/account/sync/engine_test.bats @@ -12,14 +12,14 @@ run_in_zsh() { source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; $*" } -@test "_core_account_sync_strategy_fn returns the expected naming convention" { - run_in_zsh 'echo "$(_core_account_sync_strategy_fn mcp enumerate)"' +@test "_ckipper_account_sync_strategy_fn returns the expected naming convention" { + run_in_zsh 'echo "$(_ckipper_account_sync_strategy_fn mcp enumerate)"' [ "$status" -eq 0 ] [[ "$output" == "_ckipper_account_sync_mcp_enumerate" ]] } -@test "_core_account_sync_strategy_fn handles hyphenated type ids" { - run_in_zsh 'echo "$(_core_account_sync_strategy_fn claude-md compare)"' +@test "_ckipper_account_sync_strategy_fn handles hyphenated type ids" { + run_in_zsh 'echo "$(_ckipper_account_sync_strategy_fn claude-md compare)"' [ "$status" -eq 0 ] [[ "$output" == "_ckipper_account_sync_claude-md_compare" ]] } @@ -30,16 +30,16 @@ run_in_zsh() { [[ "$output" == *"OK"* ]] } -@test "_core_account_sync_assert_dst_idle returns 0 when no claude running" { +@test "_ckipper_account_sync_assert_dst_idle returns 0 when no claude running" { # The pgrep stub returns no matches by default in the test env. run env CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" PATH="$PATH" \ zsh -c "source \"$REPO_ROOT/lib/core/keychain.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ - _core_account_sync_assert_dst_idle '$TMP_HOME/dst' false && echo OK" + _ckipper_account_sync_assert_dst_idle '$TMP_HOME/dst' false && echo OK" [[ "$output" == *"OK"* ]] } -@test "_core_account_sync_assert_dst_idle returns 1 when claude is running on dst" { +@test "_ckipper_account_sync_assert_dst_idle returns 1 when claude is running on dst" { # Stage a fake pgrep stub that prints a process referencing dst. local fake_pgrep="$TMP_HOME/bin/pgrep" mkdir -p "$TMP_HOME/bin" @@ -51,11 +51,11 @@ EOH run env PATH="$TMP_HOME/bin:$PATH" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/core/keychain.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ - _core_account_sync_assert_dst_idle '$TMP_HOME/dst' false" + _ckipper_account_sync_assert_dst_idle '$TMP_HOME/dst' false" [ "$status" -ne 0 ] } -@test "_core_account_sync_assert_dst_idle bypassed by --force" { +@test "_ckipper_account_sync_assert_dst_idle bypassed by --force" { local fake_pgrep="$TMP_HOME/bin/pgrep" mkdir -p "$TMP_HOME/bin" cat > "$fake_pgrep" < "$src/.claude.json" @@ -90,12 +90,70 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ - _core_account_sync_build_change_set '$src' '$dst' src dst mcp" + _ckipper_account_sync_build_change_set '$src' '$dst' src dst mcp" [[ "$output" == *"mcp"$'\t'"github"$'\t'* ]] [[ "$output" == *"new"* ]] } -@test "_core_account_sync_apply_target writes changes and records manifest" { +@test "_ckipper_account_sync_build_summaries dispatches each type's _summary fn" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"new"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"github":{"command":"old"}}}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + printf 'mcp\tgithub\tgithub\toverwrite\n' \ + | _ckipper_account_sync_build_summaries '$src' '$dst' src dst" + [[ "$output" == *"mcp"$'\t'"github"$'\t'*"overwrite"* ]] + [[ "$output" == *"server config changed"* ]] +} + +@test "_ckipper_account_sync_build_summaries skips unchanged rows" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{}}' > "$src/.claude.json" + echo '{"mcpServers":{}}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + printf 'mcp\tunchanged-srv\tunchanged-srv\tunchanged\n' \ + | _ckipper_account_sync_build_summaries '$src' '$dst' src dst | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + +@test "_ckipper_account_sync_apply_target rolls back via manifest after mid-write failure" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"new"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"github":{"command":"old"}},"keep":"this"}' > "$dst/.claude.json" + # Stage a fake apply that backs up + then fails — simulates a mid-write crash. + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + # Override mcp_apply to simulate a crashing strategy. + _ckipper_account_sync_mcp_apply() { + _ckipper_account_sync_backup_file \"\$4\" \"\$2/.claude.json\" \".claude.json\" + echo 'corrupt-mid-write' > \"\$2/.claude.json\" + return 1 + } + printf 'mcp\tgithub\tgithub\toverwrite\n' \ + | _ckipper_account_sync_apply_target '$src' '$dst' src dst + # Rollback should have restored the original. + jq -r '.mcpServers.github.command' '$dst/.claude.json' 2>&1 + jq -r '.keep' '$dst/.claude.json' 2>&1" + [[ "$output" == *"old"* ]] + [[ "$output" == *"this"* ]] +} + +@test "_ckipper_account_sync_apply_target writes changes and records manifest" { local src="$TMP_HOME/src" dst="$TMP_HOME/dst" mkdir -p "$src" "$dst" echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" @@ -107,7 +165,7 @@ EOH source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tgithub\tgithub\tnew\n' \ - | _core_account_sync_apply_target '$src' '$dst' src dst + | _ckipper_account_sync_apply_target '$src' '$dst' src dst jq '.mcpServers.github.command' '$dst/.claude.json' ls '$dst/.ckipper-sync-backups'/*-from-src/.ckipper-sync-manifest.json" [[ "$output" == *'"x"'* ]] diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh index 1fd4033..455435f 100644 --- a/lib/account/sync/interactive.zsh +++ b/lib/account/sync/interactive.zsh @@ -8,7 +8,7 @@ # List every registered account name (sorted by registry insertion order). # # Returns: 0; prints account names, one per line. -_core_account_sync_list_accounts() { +_ckipper_account_sync_list_accounts() { [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY" 2>/dev/null } @@ -17,18 +17,18 @@ _core_account_sync_list_accounts() { # # Args: $1 — account name to exclude. # Returns: 0; prints filtered list. -_core_account_sync_list_accounts_except() { +_ckipper_account_sync_list_accounts_except() { local exclude="$1" - _core_account_sync_list_accounts | grep -vxF "$exclude" 2>/dev/null + _ckipper_account_sync_list_accounts | grep -vxF "$exclude" 2>/dev/null } # Prompt the user to pick the source account. # # Returns: 0 with chosen name on stdout; 1 if user cancels or no accounts # are registered. -_core_account_sync_pick_source() { +_ckipper_account_sync_pick_source() { local -a accounts - accounts=( ${(f)"$(_core_account_sync_list_accounts)"} ) + accounts=( ${(f)"$(_ckipper_account_sync_list_accounts)"} ) if (( ${#accounts} == 0 )); then echo "No accounts registered. Run: ckipper account add " >&2 return 1 @@ -41,10 +41,10 @@ _core_account_sync_pick_source() { # # Args: $1 — source account name (excluded from candidates). # Returns: 0 with chosen names (one per line) on stdout; 1 if user cancels. -_core_account_sync_pick_targets() { +_ckipper_account_sync_pick_targets() { local source="$1" local -a candidates - candidates=( ${(f)"$(_core_account_sync_list_accounts_except "$source")"} ) + candidates=( ${(f)"$(_ckipper_account_sync_list_accounts_except "$source")"} ) if (( ${#candidates} == 0 )); then echo "No other accounts to sync to." >&2 return 1 @@ -53,14 +53,14 @@ _core_account_sync_pick_targets() { printf '%s\n' "${candidates[@]}" | gum choose --no-limit --header "Sync TO which accounts? (space to multi-select)" return $? fi - _core_account_sync_pick_targets_fallback "${candidates[@]}" + _ckipper_account_sync_pick_targets_fallback "${candidates[@]}" } # Pure-zsh fallback: comma-separated input, validated against candidates. # # Args: $@ — candidate account names. # Returns: 0; prints chosen names. -_core_account_sync_pick_targets_fallback() { +_ckipper_account_sync_pick_targets_fallback() { echo "Available targets: $*" >&2 local input read -r "input?Enter comma-separated targets: " @@ -73,7 +73,7 @@ _core_account_sync_pick_targets_fallback() { # Prompt to multi-select sync types from the registry. # # Returns: 0; prints chosen type ids. -_core_account_sync_pick_types() { +_ckipper_account_sync_pick_types() { local -a labels local t for t in "${(@k)_CKIPPER_SYNC_TYPE_LABEL}"; do diff --git a/lib/account/sync/interactive_test.bats b/lib/account/sync/interactive_test.bats index 5be2846..1146059 100644 --- a/lib/account/sync/interactive_test.bats +++ b/lib/account/sync/interactive_test.bats @@ -24,13 +24,13 @@ run_in_zsh() { source \"$REPO_ROOT/lib/account/sync/interactive.zsh\"; $*" } -@test "_core_account_sync_list_accounts returns all account names" { - run_in_zsh "_core_account_sync_list_accounts | sort | tr '\n' ','" +@test "_ckipper_account_sync_list_accounts returns all account names" { + run_in_zsh "_ckipper_account_sync_list_accounts | sort | tr '\n' ','" [[ "$output" == *"client1,personal,work,"* ]] } -@test "_core_account_sync_list_accounts_except filters source" { - run_in_zsh "_core_account_sync_list_accounts_except personal | sort | tr '\n' ','" +@test "_ckipper_account_sync_list_accounts_except filters source" { + run_in_zsh "_ckipper_account_sync_list_accounts_except personal | sort | tr '\n' ','" [[ "$output" == *"client1,work,"* ]] [[ "$output" != *"personal"* ]] } diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh index b6c73c1..493c1ef 100644 --- a/lib/account/sync/preview.zsh +++ b/lib/account/sync/preview.zsh @@ -13,7 +13,7 @@ readonly _CKIPPER_SYNC_DIVIDER_WIDTH=45 # Print the divider line for the summary table. # # Returns: 0 always. -_core_account_sync_print_divider() { +_ckipper_account_sync_print_divider() { printf '%*s\n' "$_CKIPPER_SYNC_DIVIDER_WIDTH" '' | tr ' ' '─' } @@ -21,7 +21,7 @@ _core_account_sync_print_divider() { # # Args: $1 — change status; $2 — display; $3 — summary. # Returns: 0; suppresses unchanged rows. -_core_account_sync_render_row() { +_ckipper_account_sync_render_row() { local cmp_status="$1" display="$2" summary="$3" case "$cmp_status" in new) printf ' %s %-26s (%s)\n' "$_CKIPPER_SYNC_BADGE_NEW" "$display" "${summary:-new}" ;; @@ -38,11 +38,11 @@ _core_account_sync_render_row() { # # Args: $1 — src name; $2 — dst name; $3 — backup_dir path; $4 — summaries file. # Returns: 0 always. -_core_account_sync_render_summary() { +_ckipper_account_sync_render_summary() { local src_name="$1" dst_name="$2" backup_dir="$3" summaries="$4" echo "" echo "Sync $src_name → $dst_name" - _core_account_sync_print_divider + _ckipper_account_sync_print_divider local current_type="" local type id display change_status while IFS=$'\t' read -r type id display change_status; do @@ -55,16 +55,16 @@ _core_account_sync_render_summary() { if [[ -f "$summaries" ]]; then summary=$(awk -F'\t' -v t="$type" -v i="$id" '$1==t && $2==i {print $3; exit}' "$summaries") fi - _core_account_sync_render_row "$change_status" "$display" "$summary" + _ckipper_account_sync_render_row "$change_status" "$display" "$summary" done - _core_account_sync_print_divider + _ckipper_account_sync_print_divider echo "Backup → $backup_dir" } # Count totals from the change-set on stdin. # # Returns: 0; prints " " on a single line. -_core_account_sync_count_changes() { +_ckipper_account_sync_count_changes() { awk -F'\t' ' $4 == "new" { n++; total++ } $4 == "overwrite" { o++; total++ } @@ -77,7 +77,7 @@ _core_account_sync_count_changes() { # destination value to diff against). # # Returns: 0; prints "\t\t" per line for overwrites. -_core_account_sync_drill_down_items() { +_ckipper_account_sync_drill_down_items() { awk -F'\t' '$4 == "overwrite" { print $1 "\t" $2 "\t" $3 }' } @@ -87,14 +87,15 @@ _core_account_sync_drill_down_items() { # # Args: $1 — src dir; $2 — dst dir; $3 — src name; $4 — dst name; $5 — items file. # Returns: 0 always. -_core_account_sync_drill_down_loop() { +_ckipper_account_sync_drill_down_loop() { local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" items_file="$5" - [[ ! -s "$items_file" ]] && { echo "No items to drill into."; return 0; } + [[ ! -s "$items_file" ]] && { echo "No overwrites to drill into."; return 0; } while true; do local choice - choice=$(_core_account_sync_drill_down_pick "$items_file") || return 0 + choice=$(_ckipper_account_sync_drill_down_pick "$items_file") || return 0 [[ "$choice" == "Back" || -z "$choice" ]] && return 0 - _core_account_sync_drill_down_show "$choice" "$src_dir" "$dst_dir" "$src_name" "$dst_name" + _ckipper_account_sync_drill_down_show "$choice" "$items_file" \ + "$src_dir" "$dst_dir" "$src_name" "$dst_name" echo "" echo "(Press enter to return to picker)" local _ack; read -r _ack @@ -102,11 +103,12 @@ _core_account_sync_drill_down_loop() { } # Pick one drill-down item. Uses gum if available; otherwise prints -# numbered list. +# numbered list. The label encodes the type so the show function can +# look up the id from the items file. # -# Args: $1 — items file (TSV). -# Returns: gum exit; prints "" or "Back". -_core_account_sync_drill_down_pick() { +# Args: $1 — items file (TSV: type\tid\tdisplay). +# Returns: gum exit; prints chosen label or "Back". +_ckipper_account_sync_drill_down_pick() { local items_file="$1" local -a labels=("Back") local type id display @@ -116,18 +118,35 @@ _core_account_sync_drill_down_pick() { _core_prompt_choose "View diff for which item?" "${labels[@]}" } -# Render the diff for one selected item. Strips the leading "[type] " marker -# from the picker label and looks the row back up. +# Render the diff for one selected item. Looks the row up by (type, display) +# in the items file to recover the original id (which may differ from +# display, e.g. files-flat: id=agents/foo.md, display=foo.md). # -# Args: $1 — picker choice (e.g. "[mcp] github"); $2..$5 — src/dst dir/name. +# Args: $1 — picker choice (e.g. "[mcp] github"); $2 — items file; +# $3 — src dir; $4 — dst dir; $5 — src name; $6 — dst name. # Returns: 0; prints the strategy's diff output. -_core_account_sync_drill_down_show() { - local choice="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" +_ckipper_account_sync_drill_down_show() { + local choice="$1" items_file="$2" + local src_dir="$3" dst_dir="$4" src_name="$5" dst_name="$6" local type="${choice#\[}"; type="${type%%]*}" local display="${choice#*] }" - local id="$display" - local diff_fn; diff_fn=$(_core_account_sync_strategy_fn "$type" diff) + local id; id=$(_ckipper_account_sync_drill_down_resolve_id "$items_file" "$type" "$display") + local diff_fn; diff_fn=$(_ckipper_account_sync_strategy_fn "$type" diff) local arg_a="$src_dir" arg_b="$dst_dir" [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } "$diff_fn" "$arg_a" "$arg_b" "$id" } + +# Recover the original id for a (type, display) pair by looking it up +# in the items file. Falls back to display when no row matches (defensive +# default — keeps drill-down working even if the items file is stale). +# +# Args: $1 — items file; $2 — type; $3 — display. +# Returns: 0; prints the id (or display on miss). +_ckipper_account_sync_drill_down_resolve_id() { + local items_file="$1" type="$2" display="$3" + local resolved + resolved=$(awk -F'\t' -v t="$type" -v d="$display" \ + '$1 == t && $3 == d { print $2; exit }' "$items_file") + echo "${resolved:-$display}" +} diff --git a/lib/account/sync/preview_test.bats b/lib/account/sync/preview_test.bats index e4010a0..ef1a65a 100644 --- a/lib/account/sync/preview_test.bats +++ b/lib/account/sync/preview_test.bats @@ -13,13 +13,13 @@ run_in_zsh() { source \"$REPO_ROOT/lib/account/sync/preview.zsh\"; $*" } -@test "_core_account_sync_render_summary groups by type with status badges" { +@test "_ckipper_account_sync_render_summary groups by type with status badges" { run_in_zsh ' printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ printf "claude-md\tCLAUDE.md\tCLAUDE.md\tnew\n" >>/tmp/cs.$$ cat /tmp/cs.$$ \ - | _core_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries + | _ckipper_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries rm -f /tmp/cs.$$' [[ "$output" == *"MCP servers"* ]] [[ "$output" == *"[+]"* ]] @@ -30,34 +30,47 @@ run_in_zsh() { [[ "$output" == *"Backup →"* ]] } -@test "_core_account_sync_render_summary suppresses unchanged rows by default" { +@test "_ckipper_account_sync_render_summary suppresses unchanged rows by default" { run_in_zsh ' printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ printf "mcp\tunchanged-srv\tunchanged-srv\tunchanged\n" >>/tmp/cs.$$ cat /tmp/cs.$$ \ - | _core_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries + | _ckipper_account_sync_render_summary src dst /tmp/backup-dir-stub /tmp/no-summaries rm -f /tmp/cs.$$' [[ "$output" != *"unchanged-srv"* ]] } -@test "_core_account_sync_count_changes returns total + new + overwrite" { +@test "_ckipper_account_sync_count_changes returns total + new + overwrite" { run_in_zsh ' printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ printf "settings\tmodel\tmodel\tnew\n" >>/tmp/cs.$$ - cat /tmp/cs.$$ | _core_account_sync_count_changes + cat /tmp/cs.$$ | _ckipper_account_sync_count_changes rm -f /tmp/cs.$$' [[ "$output" == *"3 2 1"* ]] } -@test "_core_account_sync_drill_down_items emits only [~] (overwrite) rows" { +@test "_ckipper_account_sync_drill_down_items emits only [~] (overwrite) rows" { run_in_zsh ' printf "mcp\tgithub\tgithub\tnew\n" >/tmp/cs.$$ printf "mcp\tvibma\tvibma\toverwrite\n" >>/tmp/cs.$$ printf "settings\tmodel\tmodel\toverwrite\n" >>/tmp/cs.$$ - cat /tmp/cs.$$ | _core_account_sync_drill_down_items + cat /tmp/cs.$$ | _ckipper_account_sync_drill_down_items rm -f /tmp/cs.$$' [[ "$output" != *"github"* ]] [[ "$output" == *"vibma"* ]] [[ "$output" == *"model"* ]] } + +@test "_ckipper_account_sync_drill_down_resolve_id recovers id from (type, display)" { + run_in_zsh ' + printf "agents\tagents/foo.md\tfoo.md\n" >/tmp/items.$$ + printf "mcp\tgithub\tgithub\n" >>/tmp/items.$$ + result=$(_ckipper_account_sync_drill_down_resolve_id /tmp/items.$$ agents foo.md) + echo "agents:$result" + result=$(_ckipper_account_sync_drill_down_resolve_id /tmp/items.$$ mcp github) + echo "mcp:$result" + rm -f /tmp/items.$$' + [[ "$output" == *"agents:agents/foo.md"* ]] + [[ "$output" == *"mcp:github"* ]] +} diff --git a/lib/account/sync/registry.zsh b/lib/account/sync/registry.zsh index d6482ab..5ef34c0 100644 --- a/lib/account/sync/registry.zsh +++ b/lib/account/sync/registry.zsh @@ -34,7 +34,7 @@ typeset -gA _CKIPPER_SYNC_TYPE_KIND=( ) # Space-separated list of bundles the type belongs to. Bundles are aliases -# users may pass to --include / --exclude (see _core_account_sync_resolve_*). +# users may pass to --include / --exclude (see _ckipper_account_sync_resolve_*). typeset -gA _CKIPPER_SYNC_TYPE_BUNDLES=( [mcp]="all customizations claude-config" [settings]="all customizations claude-config" @@ -55,7 +55,7 @@ typeset -gra _CKIPPER_SYNC_BUNDLE_ALIASES=(all customizations claude-config pref # # Args: $1 — candidate type id. # Returns: 0 if known; 1 otherwise. -_core_account_sync_is_known_type() { +_ckipper_account_sync_is_known_type() { (( ${+_CKIPPER_SYNC_TYPE_LABEL[$1]} )) } @@ -63,7 +63,7 @@ _core_account_sync_is_known_type() { # # Args: $1 — candidate bundle alias. # Returns: 0 if known; 1 otherwise. -_core_account_sync_is_known_bundle() { +_ckipper_account_sync_is_known_bundle() { local b="$1" alias for alias in "${_CKIPPER_SYNC_BUNDLE_ALIASES[@]}"; do [[ "$alias" == "$b" ]] && return 0 @@ -77,9 +77,9 @@ _core_account_sync_is_known_bundle() { # # Args: $1 — bundle alias OR raw type id. # Returns: 0 always; prints expanded list to stdout, one type id per line. -_core_account_sync_resolve_bundle() { +_ckipper_account_sync_resolve_bundle() { local token="$1" t - if ! _core_account_sync_is_known_bundle "$token"; then + if ! _ckipper_account_sync_is_known_bundle "$token"; then echo "$token" return 0 fi @@ -94,19 +94,19 @@ _core_account_sync_resolve_bundle() { # # Args: $1 — comma-separated include list; $2 — comma-separated exclude list. # Returns: 0 always; prints the final type ids one per line, lexically sorted. -_core_account_sync_resolve_includes() { +_ckipper_account_sync_resolve_includes() { local include="$1" exclude="$2" local -A keep local token expanded for token in ${(s:,:)include}; do [[ -z "$token" ]] && continue - for expanded in $(_core_account_sync_resolve_bundle "$token"); do + for expanded in $(_ckipper_account_sync_resolve_bundle "$token"); do keep[$expanded]=1 done done for token in ${(s:,:)exclude}; do [[ -z "$token" ]] && continue - for expanded in $(_core_account_sync_resolve_bundle "$token"); do + for expanded in $(_ckipper_account_sync_resolve_bundle "$token"); do unset 'keep['"$expanded"']' done done diff --git a/lib/account/sync/registry_test.bats b/lib/account/sync/registry_test.bats index f3e9e1b..b449f21 100644 --- a/lib/account/sync/registry_test.bats +++ b/lib/account/sync/registry_test.bats @@ -57,50 +57,50 @@ run_in_zsh() { [[ "$output" == *"OK"* ]] } -@test "_core_account_sync_resolve_bundle expands all to all 10 types" { - run_in_zsh '_core_account_sync_resolve_bundle all | sort | tr "\n" ","' +@test "_ckipper_account_sync_resolve_bundle expands all to all 10 types" { + run_in_zsh '_ckipper_account_sync_resolve_bundle all | sort | tr "\n" ","' [ "$status" -eq 0 ] [[ "$output" == "agents,claude-md,commands,hooks,mcp,output-styles,prefs,settings,skills,statusline," ]] } -@test "_core_account_sync_resolve_bundle expands customizations" { - run_in_zsh '_core_account_sync_resolve_bundle customizations | sort | tr "\n" ","' +@test "_ckipper_account_sync_resolve_bundle expands customizations" { + run_in_zsh '_ckipper_account_sync_resolve_bundle customizations | sort | tr "\n" ","' [ "$status" -eq 0 ] [[ "$output" == "agents,claude-md,commands,hooks,mcp,output-styles,settings,skills,statusline," ]] } -@test "_core_account_sync_resolve_bundle preferences = prefs" { - run_in_zsh '_core_account_sync_resolve_bundle preferences | tr "\n" ","' +@test "_ckipper_account_sync_resolve_bundle preferences = prefs" { + run_in_zsh '_ckipper_account_sync_resolve_bundle preferences | tr "\n" ","' [[ "$output" == "prefs," ]] } -@test "_core_account_sync_resolve_bundle claude-config = mcp,settings,hooks" { - run_in_zsh '_core_account_sync_resolve_bundle claude-config | sort | tr "\n" ","' +@test "_ckipper_account_sync_resolve_bundle claude-config = mcp,settings,hooks" { + run_in_zsh '_ckipper_account_sync_resolve_bundle claude-config | sort | tr "\n" ","' [[ "$output" == "hooks,mcp,settings," ]] } -@test "_core_account_sync_resolve_bundle returns input unchanged for non-bundle token" { - run_in_zsh '_core_account_sync_resolve_bundle mcp | tr "\n" ","' +@test "_ckipper_account_sync_resolve_bundle returns input unchanged for non-bundle token" { + run_in_zsh '_ckipper_account_sync_resolve_bundle mcp | tr "\n" ","' [[ "$output" == "mcp," ]] } -@test "_core_account_sync_resolve_includes mixes types and bundles, dedups" { - run_in_zsh '_core_account_sync_resolve_includes "preferences,mcp" "" | sort | tr "\n" ","' +@test "_ckipper_account_sync_resolve_includes mixes types and bundles, dedups" { + run_in_zsh '_ckipper_account_sync_resolve_includes "preferences,mcp" "" | sort | tr "\n" ","' [[ "$output" == "mcp,prefs," ]] } -@test "_core_account_sync_resolve_includes subtracts excludes" { - run_in_zsh '_core_account_sync_resolve_includes "all" "prefs,hooks" | sort | tr "\n" ","' +@test "_ckipper_account_sync_resolve_includes subtracts excludes" { + run_in_zsh '_ckipper_account_sync_resolve_includes "all" "prefs,hooks" | sort | tr "\n" ","' [[ "$output" == "agents,claude-md,commands,mcp,output-styles,settings,skills,statusline," ]] } -@test "_core_account_sync_is_known_type returns 0 for known type" { - run_in_zsh '_core_account_sync_is_known_type mcp && echo ok' +@test "_ckipper_account_sync_is_known_type returns 0 for known type" { + run_in_zsh '_ckipper_account_sync_is_known_type mcp && echo ok' [[ "$output" == "ok" ]] } -@test "_core_account_sync_is_known_type returns 1 for unknown" { - run_in_zsh '_core_account_sync_is_known_type bogus && echo wrongly_ok || true' +@test "_ckipper_account_sync_is_known_type returns 1 for unknown" { + run_in_zsh '_ckipper_account_sync_is_known_type bogus && echo wrongly_ok || true' [ "$status" -eq 0 ] [[ "$output" != *"wrongly_ok"* ]] } diff --git a/lib/account/sync/strategies/files_dir.zsh b/lib/account/sync/strategies/files_dir.zsh index 6f65efc..97a18de 100644 --- a/lib/account/sync/strategies/files_dir.zsh +++ b/lib/account/sync/strategies/files_dir.zsh @@ -25,7 +25,7 @@ typeset -gA _CKIPPER_SYNC_FILES_DIR_PATH=( # # Args: $1 — path to directory or symlink. # Returns: 0; prints hex hash or empty if path is missing. -_core_sync_dir_hash() { +_ckipper_account_sync_dir_hash() { local target="$1" [[ ! -e "$target" ]] && { echo ""; return 0; } if [[ -L "$target" ]]; then @@ -45,7 +45,7 @@ _core_sync_dir_hash() { # # Args: $1 — type id; $2 — source account dir. # Returns: 0; prints "\t" per item. -_core_sync_files_dir_enumerate() { +_ckipper_account_sync_files_dir_enumerate() { local type="$1" src="$2" local sub="${_CKIPPER_SYNC_FILES_DIR_PATH[$type]}" local root="$src/$sub" @@ -61,12 +61,12 @@ _core_sync_files_dir_enumerate() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. # Returns: 0; prints "new" | "overwrite" | "unchanged". -_core_sync_files_dir_compare() { +_ckipper_account_sync_files_dir_compare() { local type="$1" src="$2" dst="$3" rel="$4" [[ ! -e "$dst/$rel" ]] && { echo "new"; return 0; } local sh dh - sh=$(_core_sync_dir_hash "$src/$rel") - dh=$(_core_sync_dir_hash "$dst/$rel") + sh=$(_ckipper_account_sync_dir_hash "$src/$rel") + dh=$(_ckipper_account_sync_dir_hash "$dst/$rel") [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } @@ -75,9 +75,9 @@ _core_sync_files_dir_compare() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. # Returns: 0; prints summary. -_core_sync_files_dir_summary() { +_ckipper_account_sync_files_dir_summary() { local type="$1" src="$2" dst="$3" rel="$4" - local cmp_status; cmp_status=$(_core_sync_files_dir_compare "$type" "$src" "$dst" "$rel") + local cmp_status; cmp_status=$(_ckipper_account_sync_files_dir_compare "$type" "$src" "$dst" "$rel") case "$cmp_status" in new) echo "new directory" ;; overwrite) @@ -95,7 +95,7 @@ _core_sync_files_dir_summary() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. # Returns: 0 always. -_core_sync_files_dir_diff() { +_ckipper_account_sync_files_dir_diff() { local type="$1" src="$2" dst="$3" rel="$4" if [[ -L "$src/$rel" || -L "$dst/$rel" ]]; then echo "── source symlink ──" @@ -114,17 +114,17 @@ _core_sync_files_dir_diff() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath; $5 — backup_dir. # Returns: 0 on success; 1 on cp failure. -_core_sync_files_dir_apply() { +_ckipper_account_sync_files_dir_apply() { local type="$1" src="$2" dst="$3" rel="$4" backup_dir="$5" - _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 rm -rf "$dst/$rel" mkdir -p "$dst/${rel:h}" cp -a "$src/$rel" "$dst/$rel" } # Per-type wrappers for the strategy contract. -_ckipper_account_sync_skills_enumerate() { _core_sync_files_dir_enumerate skills "$@"; } -_ckipper_account_sync_skills_compare() { _core_sync_files_dir_compare skills "$@"; } -_ckipper_account_sync_skills_summary() { _core_sync_files_dir_summary skills "$@"; } -_ckipper_account_sync_skills_diff() { _core_sync_files_dir_diff skills "$@"; } -_ckipper_account_sync_skills_apply() { _core_sync_files_dir_apply skills "$@"; } +_ckipper_account_sync_skills_enumerate() { _ckipper_account_sync_files_dir_enumerate skills "$@"; } +_ckipper_account_sync_skills_compare() { _ckipper_account_sync_files_dir_compare skills "$@"; } +_ckipper_account_sync_skills_summary() { _ckipper_account_sync_files_dir_summary skills "$@"; } +_ckipper_account_sync_skills_diff() { _ckipper_account_sync_files_dir_diff skills "$@"; } +_ckipper_account_sync_skills_apply() { _ckipper_account_sync_files_dir_apply skills "$@"; } diff --git a/lib/account/sync/strategies/files_dir_test.bats b/lib/account/sync/strategies/files_dir_test.bats index add2624..4d0c227 100644 --- a/lib/account/sync/strategies/files_dir_test.bats +++ b/lib/account/sync/strategies/files_dir_test.bats @@ -48,8 +48,8 @@ run_in_zsh() { echo "skill content" > "$TMP_HOME/shared/sk1/SKILL.md" ln -s "$TMP_HOME/shared/sk1" "$src/skills/sk1" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_skills_apply '$src' '$dst' skills/sk1 \"\$backup_dir\" [[ -L '$dst/skills/sk1' ]] && echo IS_SYMLINK || echo NOT_SYMLINK readlink '$dst/skills/sk1'" @@ -63,8 +63,8 @@ run_in_zsh() { echo "x" > "$src/skills/foo/SKILL.md" echo "y" > "$src/skills/foo/extra.md" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_skills_apply '$src' '$dst' skills/foo \"\$backup_dir\" cat '$dst/skills/foo/SKILL.md' cat '$dst/skills/foo/extra.md'" diff --git a/lib/account/sync/strategies/files_flat.zsh b/lib/account/sync/strategies/files_flat.zsh index ad25c7b..614e086 100644 --- a/lib/account/sync/strategies/files_flat.zsh +++ b/lib/account/sync/strategies/files_flat.zsh @@ -25,7 +25,7 @@ typeset -gA _CKIPPER_SYNC_FILES_FLAT_PATH=( # # Args: $1 — file path. # Returns: 0; prints hex hash or empty string. -_core_sync_file_hash() { +_ckipper_account_sync_file_hash() { local f="$1" [[ ! -f "$f" ]] && { echo ""; return 0; } shasum -a 256 "$f" | cut -d' ' -f1 @@ -37,7 +37,7 @@ _core_sync_file_hash() { # # Args: $1 — type id; $2 — source account dir. # Returns: 0; prints items one per line. -_core_sync_files_flat_enumerate() { +_ckipper_account_sync_files_flat_enumerate() { local type="$1" src="$2" local sub="${_CKIPPER_SYNC_FILES_FLAT_PATH[$type]}" local target="$src/$sub" @@ -57,12 +57,12 @@ _core_sync_files_flat_enumerate() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath (the item id). # Returns: 0; prints "new" | "overwrite" | "unchanged". -_core_sync_files_flat_compare() { +_ckipper_account_sync_files_flat_compare() { local type="$1" src="$2" dst="$3" rel="$4" [[ ! -f "$dst/$rel" ]] && { echo "new"; return 0; } local sh dh - sh=$(_core_sync_file_hash "$src/$rel") - dh=$(_core_sync_file_hash "$dst/$rel") + sh=$(_ckipper_account_sync_file_hash "$src/$rel") + dh=$(_ckipper_account_sync_file_hash "$dst/$rel") [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } @@ -71,9 +71,9 @@ _core_sync_files_flat_compare() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath. # Returns: 0; prints "new" | "overwrite — +A/-D lines" | "unchanged". -_core_sync_files_flat_summary() { +_ckipper_account_sync_files_flat_summary() { local type="$1" src="$2" dst="$3" rel="$4" - local cmp_status; cmp_status=$(_core_sync_files_flat_compare "$type" "$src" "$dst" "$rel") + local cmp_status; cmp_status=$(_ckipper_account_sync_files_flat_compare "$type" "$src" "$dst" "$rel") [[ "$cmp_status" != "overwrite" ]] && { echo "$cmp_status"; return 0; } local stats; stats=$(diff "$dst/$rel" "$src/$rel" 2>/dev/null \ | awk 'BEGIN{a=0;d=0} /^>/{a++} /^/dev/null return 0 @@ -94,9 +94,9 @@ _core_sync_files_flat_diff() { # # Args: $1 — type id; $2 — src; $3 — dst; $4 — relpath; $5 — backup_dir. # Returns: 0 on success; 1 on cp failure. -_core_sync_files_flat_apply() { +_ckipper_account_sync_files_flat_apply() { local type="$1" src="$2" dst="$3" rel="$4" backup_dir="$5" - _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 mkdir -p "$dst/${rel:h}" cp -a "$src/$rel" "$dst/$rel" } @@ -105,29 +105,29 @@ _core_sync_files_flat_apply() { # so each type satisfies the strategy naming convention. # claude-md wrappers -_ckipper_account_sync_claude-md_enumerate() { _core_sync_files_flat_enumerate claude-md "$@"; } -_ckipper_account_sync_claude-md_compare() { _core_sync_files_flat_compare claude-md "$@"; } -_ckipper_account_sync_claude-md_summary() { _core_sync_files_flat_summary claude-md "$@"; } -_ckipper_account_sync_claude-md_diff() { _core_sync_files_flat_diff claude-md "$@"; } -_ckipper_account_sync_claude-md_apply() { _core_sync_files_flat_apply claude-md "$@"; } +_ckipper_account_sync_claude-md_enumerate() { _ckipper_account_sync_files_flat_enumerate claude-md "$@"; } +_ckipper_account_sync_claude-md_compare() { _ckipper_account_sync_files_flat_compare claude-md "$@"; } +_ckipper_account_sync_claude-md_summary() { _ckipper_account_sync_files_flat_summary claude-md "$@"; } +_ckipper_account_sync_claude-md_diff() { _ckipper_account_sync_files_flat_diff claude-md "$@"; } +_ckipper_account_sync_claude-md_apply() { _ckipper_account_sync_files_flat_apply claude-md "$@"; } # agents wrappers -_ckipper_account_sync_agents_enumerate() { _core_sync_files_flat_enumerate agents "$@"; } -_ckipper_account_sync_agents_compare() { _core_sync_files_flat_compare agents "$@"; } -_ckipper_account_sync_agents_summary() { _core_sync_files_flat_summary agents "$@"; } -_ckipper_account_sync_agents_diff() { _core_sync_files_flat_diff agents "$@"; } -_ckipper_account_sync_agents_apply() { _core_sync_files_flat_apply agents "$@"; } +_ckipper_account_sync_agents_enumerate() { _ckipper_account_sync_files_flat_enumerate agents "$@"; } +_ckipper_account_sync_agents_compare() { _ckipper_account_sync_files_flat_compare agents "$@"; } +_ckipper_account_sync_agents_summary() { _ckipper_account_sync_files_flat_summary agents "$@"; } +_ckipper_account_sync_agents_diff() { _ckipper_account_sync_files_flat_diff agents "$@"; } +_ckipper_account_sync_agents_apply() { _ckipper_account_sync_files_flat_apply agents "$@"; } # commands wrappers -_ckipper_account_sync_commands_enumerate() { _core_sync_files_flat_enumerate commands "$@"; } -_ckipper_account_sync_commands_compare() { _core_sync_files_flat_compare commands "$@"; } -_ckipper_account_sync_commands_summary() { _core_sync_files_flat_summary commands "$@"; } -_ckipper_account_sync_commands_diff() { _core_sync_files_flat_diff commands "$@"; } -_ckipper_account_sync_commands_apply() { _core_sync_files_flat_apply commands "$@"; } +_ckipper_account_sync_commands_enumerate() { _ckipper_account_sync_files_flat_enumerate commands "$@"; } +_ckipper_account_sync_commands_compare() { _ckipper_account_sync_files_flat_compare commands "$@"; } +_ckipper_account_sync_commands_summary() { _ckipper_account_sync_files_flat_summary commands "$@"; } +_ckipper_account_sync_commands_diff() { _ckipper_account_sync_files_flat_diff commands "$@"; } +_ckipper_account_sync_commands_apply() { _ckipper_account_sync_files_flat_apply commands "$@"; } # output-styles wrappers -_ckipper_account_sync_output-styles_enumerate() { _core_sync_files_flat_enumerate output-styles "$@"; } -_ckipper_account_sync_output-styles_compare() { _core_sync_files_flat_compare output-styles "$@"; } -_ckipper_account_sync_output-styles_summary() { _core_sync_files_flat_summary output-styles "$@"; } -_ckipper_account_sync_output-styles_diff() { _core_sync_files_flat_diff output-styles "$@"; } -_ckipper_account_sync_output-styles_apply() { _core_sync_files_flat_apply output-styles "$@"; } +_ckipper_account_sync_output-styles_enumerate() { _ckipper_account_sync_files_flat_enumerate output-styles "$@"; } +_ckipper_account_sync_output-styles_compare() { _ckipper_account_sync_files_flat_compare output-styles "$@"; } +_ckipper_account_sync_output-styles_summary() { _ckipper_account_sync_files_flat_summary output-styles "$@"; } +_ckipper_account_sync_output-styles_diff() { _ckipper_account_sync_files_flat_diff output-styles "$@"; } +_ckipper_account_sync_output-styles_apply() { _ckipper_account_sync_files_flat_apply output-styles "$@"; } diff --git a/lib/account/sync/strategies/files_flat_test.bats b/lib/account/sync/strategies/files_flat_test.bats index 5877c1e..90acfc6 100644 --- a/lib/account/sync/strategies/files_flat_test.bats +++ b/lib/account/sync/strategies/files_flat_test.bats @@ -87,8 +87,8 @@ run_in_zsh() { echo "new content" > "$src/commands/deploy.md" echo "old content" > "$dst/commands/deploy.md" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_commands_apply '$src' '$dst' commands/deploy.md \"\$backup_dir\" cat '$dst/commands/deploy.md' cat \"\$backup_dir/commands/deploy.md\"" diff --git a/lib/account/sync/strategies/hooks.zsh b/lib/account/sync/strategies/hooks.zsh index 0a07f25..319b234 100644 --- a/lib/account/sync/strategies/hooks.zsh +++ b/lib/account/sync/strategies/hooks.zsh @@ -17,7 +17,7 @@ # # Returns: 0; prints one filename per line. Empty if install hooks dir # doesn't exist. -_core_sync_hooks_install_allowlist() { +_ckipper_account_sync_hooks_install_allowlist() { local install_dir="$CKIPPER_DIR/hooks" [[ ! -d "$install_dir" ]] && return 0 local f @@ -36,7 +36,7 @@ _ckipper_account_sync_hooks_enumerate() { local src="$1" local hooks_dir="$src/hooks" [[ ! -d "$hooks_dir" ]] && return 0 - local allowlist; allowlist=$(_core_sync_hooks_install_allowlist) + local allowlist; allowlist=$(_ckipper_account_sync_hooks_install_allowlist) local f base for f in "$hooks_dir"/*(N); do [[ -f "$f" ]] || continue @@ -58,8 +58,8 @@ _ckipper_account_sync_hooks_compare() { local src="$1" dst="$2" rel="$3" [[ ! -f "$dst/$rel" ]] && { echo "new"; return 0; } local sh dh - sh=$(_core_sync_file_hash "$src/$rel" 2>/dev/null) - dh=$(_core_sync_file_hash "$dst/$rel" 2>/dev/null) + sh=$(_ckipper_account_sync_file_hash "$src/$rel" 2>/dev/null) + dh=$(_ckipper_account_sync_file_hash "$dst/$rel" 2>/dev/null) [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } @@ -96,12 +96,12 @@ _ckipper_account_sync_hooks_diff() { # Returns: 0 on success; non-zero on cp/jq/write failure. _ckipper_account_sync_hooks_apply() { local src="$1" dst="$2" rel="$3" backup_dir="$4" - _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 - _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 mkdir -p "$dst/${rel:h}" cp -a "$src/$rel" "$dst/$rel" || return 1 chmod +x "$dst/$rel" 2>/dev/null - _core_sync_hooks_merge_settings "$src" "$dst" "$rel" + _ckipper_account_sync_hooks_merge_settings "$src" "$dst" "$rel" } # Merge a single user-hook's paired settings.json entries from src into dst, @@ -114,7 +114,7 @@ _ckipper_account_sync_hooks_apply() { # # Args: $1 — src; $2 — dst; $3 — script relpath (hooks/). # Returns: 0 on success; non-zero on jq/write failure. -_core_sync_hooks_merge_settings() { +_ckipper_account_sync_hooks_merge_settings() { local src="$1" dst="$2" rel="$3" local src_settings="$src/settings.json" local dst_settings="$dst/settings.json" @@ -122,7 +122,7 @@ _core_sync_hooks_merge_settings() { [[ ! -f "$dst_settings" ]] && echo '{}' > "$dst_settings" local script_basename="${rel:t}" local filtered_src_hooks - filtered_src_hooks=$(_core_sync_hooks_filter_src "$src_settings" "$script_basename" "$src" "$dst") + filtered_src_hooks=$(_ckipper_account_sync_hooks_filter_src "$src_settings" "$script_basename" "$src" "$dst") local merged merged=$(jq --argjson src_hooks "$filtered_src_hooks" ' .hooks //= {} | @@ -132,7 +132,7 @@ _core_sync_hooks_merge_settings() { .hooks[$event.key] += $event.value ) ' "$dst_settings") - _core_sync_json_atomic_write "$dst_settings" "$merged" + _ckipper_account_sync_json_atomic_write "$dst_settings" "$merged" } # Helper: filter src settings.hooks to only those entries that reference @@ -141,7 +141,7 @@ _core_sync_hooks_merge_settings() { # Args: $1 — src settings.json path; $2 — script basename; # $3 — src dir; $4 — dst dir. # Returns: 0; prints filtered hooks JSON object (may be empty {}). -_core_sync_hooks_filter_src() { +_ckipper_account_sync_hooks_filter_src() { local src_settings="$1" script_basename="$2" src="$3" dst="$4" jq --arg sb "$script_basename" --arg src "$src" --arg dst "$dst" ' (.hooks // {}) diff --git a/lib/account/sync/strategies/hooks_test.bats b/lib/account/sync/strategies/hooks_test.bats index 945352f..054e00d 100644 --- a/lib/account/sync/strategies/hooks_test.bats +++ b/lib/account/sync/strategies/hooks_test.bats @@ -61,8 +61,8 @@ run_in_zsh() { JSON echo '{}' > "$dst/settings.json" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_hooks_apply '$src' '$dst' hooks/lint.sh \"\$backup_dir\" cat '$dst/hooks/lint.sh' jq -r '.hooks.PostToolUse[0].hooks[0].command' '$dst/settings.json'" diff --git a/lib/account/sync/strategies/statusline.zsh b/lib/account/sync/strategies/statusline.zsh index 84717b6..75cbdfb 100644 --- a/lib/account/sync/strategies/statusline.zsh +++ b/lib/account/sync/strategies/statusline.zsh @@ -10,8 +10,8 @@ # verbatim without touching any file on the destination. # # Implementation depends on lib/account/sync/strategies/structured.zsh -# for _core_sync_json_atomic_write and on lib/account/sync/backup.zsh -# for _core_account_sync_backup_file. +# for _ckipper_account_sync_json_atomic_write and on lib/account/sync/backup.zsh +# for _ckipper_account_sync_backup_file. # Single item id constant — statusline is not enumerable per-element. readonly _CKIPPER_SYNC_STATUSLINE_ID="statusLine" @@ -48,19 +48,25 @@ _ckipper_account_sync_statusline_compare() { # Empty stdout = external (or no command); non-empty = absolute path # inside src. # +# Walks every whitespace-delimited token because real-world commands often +# use an interpreter prefix (e.g. "bash /path/to/script.sh", "node x.js", +# "python3 statusline.py"). Returns the first token that resolves to a path +# under the source dir. +# # Args: $1 — src dir. # Returns: 0; prints internal-script path or empty. -_core_sync_statusline_internal_path() { +_ckipper_account_sync_statusline_internal_path() { local src="$1" local file="$src/settings.json" [[ ! -f "$file" ]] && return 0 local cmd; cmd=$(jq -r '.statusLine.command // empty' "$file" 2>/dev/null) [[ -z "$cmd" ]] && return 0 - # Take the first whitespace-delimited token as the executable. - local exe="${cmd%% *}" - case "$exe" in - "$src"/*) echo "$exe" ;; - esac + local token + for token in ${(z)cmd}; do + case "$token" in + "$src"/*) echo "$token"; return 0 ;; + esac + done } # Summary: combines internal/external indicator with overwrite-or-new. @@ -70,7 +76,7 @@ _core_sync_statusline_internal_path() { _ckipper_account_sync_statusline_summary() { local src="$1" dst="$2" local cmp_status; cmp_status=$(_ckipper_account_sync_statusline_compare "$src" "$dst" "$_CKIPPER_SYNC_STATUSLINE_ID") - local internal; internal=$(_core_sync_statusline_internal_path "$src") + local internal; internal=$(_ckipper_account_sync_statusline_internal_path "$src") local kind="external" [[ -n "$internal" ]] && kind="internal (will copy script)" case "$cmp_status" in @@ -98,17 +104,17 @@ _ckipper_account_sync_statusline_diff() { # Returns: 0 on success; non-zero on jq/cp/write failure. _ckipper_account_sync_statusline_apply() { local src="$1" dst="$2" id="$3" backup_dir="$4" - _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 [[ -f "$dst/settings.json" ]] || echo '{}' > "$dst/settings.json" - local internal; internal=$(_core_sync_statusline_internal_path "$src") + local internal; internal=$(_ckipper_account_sync_statusline_internal_path "$src") local statusline_obj; statusline_obj=$(jq -c '.statusLine' "$src/settings.json") if [[ -n "$internal" ]]; then - statusline_obj=$(_core_sync_statusline_copy_and_rewrite \ + statusline_obj=$(_ckipper_account_sync_statusline_copy_and_rewrite \ "$src" "$dst" "$internal" "$backup_dir" "$statusline_obj") || return 1 fi local merged merged=$(jq --argjson v "$statusline_obj" '.statusLine = $v' "$dst/settings.json") - _core_sync_json_atomic_write "$dst/settings.json" "$merged" + _ckipper_account_sync_json_atomic_write "$dst/settings.json" "$merged" } # Internal-script branch: copies the script then rewrites .command in the @@ -117,12 +123,15 @@ _ckipper_account_sync_statusline_apply() { # Args: $1 — src dir; $2 — dst dir; $3 — internal script abs path; # $4 — backup_dir; $5 — statusline JSON object. # Returns: 0 on success (prints rewritten JSON); 1 on cp failure. -_core_sync_statusline_copy_and_rewrite() { +_ckipper_account_sync_statusline_copy_and_rewrite() { local src="$1" dst="$2" internal="$3" backup_dir="$4" obj="$5" local rel="${internal#$src/}" - _core_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 mkdir -p "$dst/${rel:h}" cp -a "$internal" "$dst/$rel" || return 1 + # Literal split+join (NOT sub/gsub) — paths often contain regex + # metacharacters (`.`, `-`) and the interpreter-prefix form means + # the `$src` substring is not necessarily at position 0. echo "$obj" | jq --arg src "$src" --arg dst "$dst" \ - '.command = (.command | sub("^" + $src; $dst))' + '.command = (.command | split($src) | join($dst))' } diff --git a/lib/account/sync/strategies/statusline_test.bats b/lib/account/sync/strategies/statusline_test.bats index aa326d3..6bf431c 100644 --- a/lib/account/sync/strategies/statusline_test.bats +++ b/lib/account/sync/strategies/statusline_test.bats @@ -34,7 +34,7 @@ run_in_zsh() { mkdir -p "$src" echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" echo "#!/bin/bash" > "$src/my-statusline.sh" - run_in_zsh "_core_sync_statusline_internal_path '$src'" + run_in_zsh "_ckipper_account_sync_statusline_internal_path '$src'" [[ "$output" == *"$src/my-statusline.sh"* ]] } @@ -42,18 +42,46 @@ run_in_zsh() { local src="$TMP_HOME/src" mkdir -p "$src" echo '{"statusLine":{"command":"/usr/bin/echo hi"}}' > "$src/settings.json" - run_in_zsh "out=\$(_core_sync_statusline_internal_path '$src'); echo \"[\$out]\"" + run_in_zsh "out=\$(_ckipper_account_sync_statusline_internal_path '$src'); echo \"[\$out]\"" [[ "$output" == *"[]"* ]] } +@test "statusline_internal_script_path detects interpreter-prefix command" { + local src="$TMP_HOME/src" + mkdir -p "$src" + echo "#!/bin/bash" > "$src/my-statusline.sh" + # The common real-world form: "bash " with an interpreter prefix. + echo "{\"statusLine\":{\"command\":\"bash $src/my-statusline.sh\"}}" > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_statusline_internal_path '$src'" + [[ "$output" == *"$src/my-statusline.sh"* ]] +} + +@test "statusline_apply: interpreter-prefix internal — copy + rewrite path" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo "#!/bin/bash" > "$src/my-statusline.sh" + chmod +x "$src/my-statusline.sh" + echo "{\"statusLine\":{\"command\":\"bash $src/my-statusline.sh\"}}" > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" + jq -r '.statusLine.command' '$dst/settings.json' + ls '$dst/my-statusline.sh' && echo COPIED" + [[ "$output" == *"bash $dst/my-statusline.sh"* ]] + [[ "$output" == *"COPIED"* ]] + [[ "$output" != *"$src/my-statusline.sh"* ]] +} + @test "statusline_apply: external script — settings only, no file copy" { local src="$TMP_HOME/src" dst="$TMP_HOME/dst" mkdir -p "$src" "$dst" echo '{"statusLine":{"command":"/usr/bin/echo hi"}}' > "$src/settings.json" echo '{}' > "$dst/settings.json" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" jq -r '.statusLine.command' '$dst/settings.json' ls '$dst' | grep -c statusline.sh || true" @@ -69,8 +97,8 @@ run_in_zsh() { echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" echo '{}' > "$dst/settings.json" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" jq -r '.statusLine.command' '$dst/settings.json' ls '$dst/my-statusline.sh' && echo COPIED" diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh index 289e9e2..bbac34d 100644 --- a/lib/account/sync/strategies/structured.zsh +++ b/lib/account/sync/strategies/structured.zsh @@ -7,7 +7,7 @@ # Each type implements the strategy contract documented in engine.zsh: # _ckipper_account_sync__{enumerate,compare,summary,diff,apply} # -# All apply functions go through _core_sync_json_atomic_write which: +# All apply functions go through _ckipper_account_sync_json_atomic_write which: # 1. Writes the candidate JSON to a tmpfile # 2. Validates with `jq -e .` # 3. mv's into place ONLY if validation passes (Safeguard #4) @@ -16,25 +16,25 @@ # # Args: $1 — path to a JSON file (must exist). # Returns: 0 if valid; non-zero if invalid or jq unavailable. -_core_sync_json_validate() { +_ckipper_account_sync_json_validate() { jq -e . "$1" >/dev/null 2>&1 } # Write JSON to a target path atomically with validation. Steps: # 1. mktemp peer of target # 2. write the candidate JSON pretty-printed via jq -# 3. validate via _core_sync_json_validate; abort on failure (no clobber) +# 3. validate via _ckipper_account_sync_json_validate; abort on failure (no clobber) # 4. mv into place # # Args: $1 — target path; $2 — candidate JSON string. # Returns: 0 on commit; 1 on jq parse error; 2 on mv failure. # Errors (stderr): "Refusing to write invalid JSON to " -_core_sync_json_atomic_write() { +_ckipper_account_sync_json_atomic_write() { local target="$1" json="$2" mkdir -p "${target:h}" local tmp; tmp=$(mktemp "${target}.XXXXXX") echo "$json" | jq '.' > "$tmp" 2>/dev/null - if ! _core_sync_json_validate "$tmp"; then + if ! _ckipper_account_sync_json_validate "$tmp"; then echo "Refusing to write invalid JSON to $target" >&2 rm -f "$tmp" return 1 @@ -103,14 +103,14 @@ _ckipper_account_sync_mcp_diff() { # Returns: 0 on success; non-zero on jq/write failure. _ckipper_account_sync_mcp_apply() { local src="$1" dst="$2" name="$3" backup_dir="$4" - _core_account_sync_backup_file "$backup_dir" "$dst/.claude.json" ".claude.json" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/.claude.json" ".claude.json" || return 1 local server_obj server_obj=$(jq -c --arg n "$name" '.mcpServers[$n]' "$src/.claude.json") [[ -f "$dst/.claude.json" ]] || echo '{}' > "$dst/.claude.json" local merged merged=$(jq --arg n "$name" --argjson v "$server_obj" \ '.mcpServers = (.mcpServers // {}) | .mcpServers[$n] = $v' "$dst/.claude.json") - _core_sync_json_atomic_write "$dst/.claude.json" "$merged" + _ckipper_account_sync_json_atomic_write "$dst/.claude.json" "$merged" } # ── Settings strategy ──────────────────────────────────────────────────── @@ -148,7 +148,7 @@ _ckipper_account_sync_settings_enumerate() { # Returns: 0; prints "new" | "overwrite" | "unchanged". _ckipper_account_sync_settings_compare() { local src="$1" dst="$2" id="$3" - local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + local jq_path; jq_path=$(_ckipper_account_sync_settings_jq_path "$id") local s d s=$(jq -c "$jq_path // null" "$src/settings.json" 2>/dev/null) d=$(jq -c "$jq_path // null" "$dst/settings.json" 2>/dev/null) @@ -166,7 +166,7 @@ _ckipper_account_sync_settings_compare() { # # Args: $1 — dotted id (no leading dot). # Returns: 0; prints jq filter string. -_core_sync_settings_jq_path() { +_ckipper_account_sync_settings_jq_path() { local id="$1" [[ -z "$id" ]] && { echo "."; return 0; } echo ".$id" @@ -192,7 +192,7 @@ _ckipper_account_sync_settings_summary() { # Returns: 0; prints labeled before/after. _ckipper_account_sync_settings_diff() { local src="$1" dst="$2" id="$3" - local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + local jq_path; jq_path=$(_ckipper_account_sync_settings_jq_path "$id") echo "── source ($src/settings.json:$id) ──" jq "$jq_path" "$src/settings.json" echo "── destination ($dst/settings.json:$id) ──" @@ -207,9 +207,9 @@ _ckipper_account_sync_settings_diff() { # Returns: 0 on success; non-zero on jq/write failure. _ckipper_account_sync_settings_apply() { local src="$1" dst="$2" id="$3" backup_dir="$4" - _core_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 [[ -f "$dst/settings.json" ]] || echo '{}' > "$dst/settings.json" - local jq_path; jq_path=$(_core_sync_settings_jq_path "$id") + local jq_path; jq_path=$(_ckipper_account_sync_settings_jq_path "$id") local val_json val_json=$(jq -c "$jq_path" "$src/settings.json") local id_array @@ -217,7 +217,7 @@ _ckipper_account_sync_settings_apply() { local merged merged=$(jq --argjson p "$id_array" --argjson v "$val_json" \ 'setpath($p; $v)' "$dst/settings.json") - _core_sync_json_atomic_write "$dst/settings.json" "$merged" + _ckipper_account_sync_json_atomic_write "$dst/settings.json" "$merged" } # ── Prefs strategy ─────────────────────────────────────────────────────── @@ -293,7 +293,7 @@ _ckipper_account_sync_prefs_diff() { # Returns: 0 on success; non-zero on read/write failure. _ckipper_account_sync_prefs_apply() { local src="$1" dst="$2" key="$3" backup_dir="$4" - _core_account_sync_backup_file "$backup_dir" "$CKIPPER_REGISTRY" "accounts.json" || return 1 + _ckipper_account_sync_backup_file "$backup_dir" "$CKIPPER_REGISTRY" "accounts.json" || return 1 local val; val=$(_core_config_get "$key" "$src") _core_config_set "$key" "$val" "$dst" } diff --git a/lib/account/sync/strategies/structured_test.bats b/lib/account/sync/strategies/structured_test.bats index b48f4bd..ced3d6c 100644 --- a/lib/account/sync/strategies/structured_test.bats +++ b/lib/account/sync/strategies/structured_test.bats @@ -13,29 +13,29 @@ run_in_zsh() { source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; $*" } -@test "_core_sync_json_validate accepts valid JSON" { +@test "_ckipper_account_sync_json_validate accepts valid JSON" { local f="$TMP_HOME/ok.json" echo '{"a": 1}' > "$f" - run_in_zsh "_core_sync_json_validate '$f' && echo OK" + run_in_zsh "_ckipper_account_sync_json_validate '$f' && echo OK" [[ "$output" == *"OK"* ]] } -@test "_core_sync_json_validate rejects invalid JSON" { +@test "_ckipper_account_sync_json_validate rejects invalid JSON" { local f="$TMP_HOME/bad.json" echo '{"a": 1' > "$f" - run_in_zsh "_core_sync_json_validate '$f'" + run_in_zsh "_ckipper_account_sync_json_validate '$f'" [ "$status" -ne 0 ] } -@test "_core_sync_json_atomic_write writes via tmp + mv" { +@test "_ckipper_account_sync_json_atomic_write writes via tmp + mv" { local f="$TMP_HOME/out.json" - run_in_zsh "_core_sync_json_atomic_write '$f' '{\"x\":42}'; cat '$f'" + run_in_zsh "_ckipper_account_sync_json_atomic_write '$f' '{\"x\":42}'; cat '$f'" [[ "$output" == *'"x": 42'* ]] } -@test "_core_sync_json_atomic_write refuses to commit invalid JSON" { +@test "_ckipper_account_sync_json_atomic_write refuses to commit invalid JSON" { local f="$TMP_HOME/out2.json" - run_in_zsh "_core_sync_json_atomic_write '$f' 'not-json'" + run_in_zsh "_ckipper_account_sync_json_atomic_write '$f' 'not-json'" [ "$status" -ne 0 ] [[ ! -f "$f" ]] } @@ -92,8 +92,8 @@ run_in_zsh() { echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" echo '{"mcpServers":{"other":{"command":"y"}},"foo":"bar"}' > "$dst/.claude.json" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_mcp_apply '$src' '$dst' github \"\$backup_dir\" jq '.mcpServers | keys | sort | join(\",\")' '$dst/.claude.json' jq -r '.foo' '$dst/.claude.json'" @@ -154,8 +154,8 @@ run_in_zsh() { echo '{"permissions":{"allow":["Bash(ls:*)"]}}' > "$src/settings.json" echo '{"permissions":{"deny":["Bash(rm:*)"]},"unrelated":"keep"}' > "$dst/settings.json" run_in_zsh " - backup_dir=\$(_core_account_sync_backup_create '$dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_settings_apply '$src' '$dst' 'permissions.allow' \"\$backup_dir\" jq -c '.permissions.allow' '$dst/settings.json' jq -c '.permissions.deny' '$dst/settings.json' @@ -209,8 +209,8 @@ JSON source \"$REPO_ROOT/lib/config/schema.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" source \"$REPO_ROOT/lib/core/config.zsh\" - backup_dir=\$(_core_account_sync_backup_create '$TMP_HOME/dst' src) - _core_account_sync_manifest_init \"\$backup_dir\" src dst + backup_dir=\$(_ckipper_account_sync_backup_create '$TMP_HOME/dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst _ckipper_account_sync_prefs_apply 'src' 'dst' always_docker \"\$backup_dir\" jq '.accounts.dst.preferences.always_docker' '$CKIPPER_REGISTRY'" [[ "$output" == *"true"* ]] diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index 1adfce0..43ee57a 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -132,7 +132,7 @@ _ckipper_setup_offer_initial_sync() { return 0 fi local -a others - others=( ${(f)"$(_core_account_sync_list_accounts_except "$new_account")"} ) + others=( ${(f)"$(_ckipper_account_sync_list_accounts_except "$new_account")"} ) (( ${#others} == 0 )) && return 0 local source_name source_name=$(_core_prompt_choose "Sync from which account?" "${others[@]}") From 0c3724f8de86c42961465200c295d2620d73aa72 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 21:59:08 -0600 Subject: [PATCH 14/18] fix(sync): second-round code-review feedback (PR #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issues from the latest code review of PR #37: Critical - preview_prompt stdout pollution: when the user picked "View changes" then "Apply", $action ended up as "...apply" not "apply", so the [[ "$action" == "apply" ]] gate at finalize silently skipped the apply. Two distinct causes: 1. drill_down_loop wrote diffs and the press-enter prompt to stdout, which was captured by the outer $() in run_one_target. 2. `local choice` inside preview_prompt's loop printed `choice='...'` on every iteration after the first — zsh re-declares behaviour. Fix: redirect drill_down_loop output to stderr; hoist `local choice=""` outside the loop; same hoist for `local _ack=""` in drill_down_loop. Regression test added to dispatcher_test.bats. 3-parameter cap (.claude/rules/code-style.md) - Introduced _SYNC_CTX associative array as the per-target context object. apply_one 8→3, finalize 7→1, drill_down_show 6→1, drill_down_loop 5→1, preview_prompt 4→0. Keys: src_dir, dst_dir, src_name, dst_name, backup_dir, changeset, summaries, items. Nesting cap (.claude/rules/code-style.md) - Extracted _ckipper_account_sync_accumulate_positional from parse_args's case `*)` arm, dropping the function from 3 nesting levels to 2. undo_dispatch doc-header (.claude/rules/shell-conventions.md) - Added the missing `Errors (stderr):` line for the "Unknown flag" and "Usage:" messages. CHANGELOG - Removed the false note claiming `lib/account/sync.zsh` is "intentionally untouched" — the file was deleted in this PR (commit ea61ee6) and replaced with the lib/account/sync/ tree. Verification - 445 bats + 6 pytest, all green - make lint-merge-guards green --- CHANGELOG.md | 3 - lib/account/sync/dispatcher.zsh | 79 +++++++++++++++++++-------- lib/account/sync/dispatcher_test.bats | 31 +++++++++++ lib/account/sync/engine.zsh | 43 ++++++++++----- lib/account/sync/preview.zsh | 34 ++++++++---- 5 files changed, 137 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c986106..e1c4b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ckipper account repair-plugins` (folded into `ckipper doctor --fix`). - `ckipper account sync-hooks` from public help (still callable; auto-runs after install/setup). -### Notes -- `lib/account/sync.zsh` is intentionally untouched — full sync overhaul ships in a separate future PR. - ## [0.2.0] — 2026-04-30 — Breaking changes: merge `w` into `ckipper` ### Removed diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index a136fe5..f89f97a 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -16,6 +16,18 @@ typeset -g _SYNC_DRY_RUN="false" typeset -g _SYNC_YES="false" typeset -g _SYNC_FORCE="false" +# Per-target sync context. Populated once per target by run_one_target (and +# augmented by apply_target with backup_dir). Engine/preview/finalize helpers +# read from this map instead of taking 5–8 positional arguments, per the +# .claude/rules/code-style.md 3-parameter cap. Keys: src_dir, dst_dir, +# src_name, dst_name, backup_dir, changeset, summaries, items. +# +# Re-declaration without `=()` is a no-op so engine.zsh's matching +# declaration (sourced first in production) keeps any state set there; +# reset between sync invocations is handled by reset_args, not by the +# declaration line. +typeset -gA _SYNC_CTX + # Reset all module-level _SYNC_* holders. Called at the top of every # parse_args invocation so re-running the dispatcher in the same shell # doesn't see stale state from the previous call. @@ -25,6 +37,18 @@ _ckipper_account_sync_reset_args() { _SYNC_FROM=""; _SYNC_TARGETS=() _SYNC_INCLUDE=""; _SYNC_EXCLUDE="" _SYNC_DRY_RUN="false"; _SYNC_YES="false"; _SYNC_FORCE="false" + _SYNC_CTX=() +} + +# Append a positional arg to _SYNC_FROM (first time) or _SYNC_TARGETS (every +# subsequent positional). Extracted from parse_args so the case body stays +# at 2 levels of nesting per .claude/rules/code-style.md. +# +# Args: $1 — positional value. +# Returns: 0 always. +_ckipper_account_sync_accumulate_positional() { + [[ -z "$_SYNC_FROM" ]] && { _SYNC_FROM="$1"; return 0; } + _SYNC_TARGETS+=("$1") } # Parse `ckipper account sync` arguments into module-level _SYNC_* vars. @@ -44,11 +68,7 @@ _ckipper_account_sync_parse_args() { --force) _SYNC_FORCE="true"; shift ;; -h|--help) _ckipper_account_sync_help_text; return 2 ;; --*) echo "Unknown flag: $1" >&2; return 1 ;; - *) - if [[ -z "$_SYNC_FROM" ]]; then _SYNC_FROM="$1" - else _SYNC_TARGETS+=("$1"); fi - shift - ;; + *) _ckipper_account_sync_accumulate_positional "$1"; shift ;; esac done return 0 @@ -142,6 +162,11 @@ _ckipper_account_sync_run_one_target() { _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 local changeset summaries items changeset=$(mktemp); summaries=$(mktemp); items=$(mktemp) + _SYNC_CTX=( + src_dir "$src_dir" dst_dir "$dst_dir" + src_name "$_SYNC_FROM" dst_name "$target" + changeset "$changeset" summaries "$summaries" items "$items" + ) _ckipper_account_sync_build_change_set "$src_dir" "$dst_dir" \ "$_SYNC_FROM" "$target" "$@" > "$changeset" _ckipper_account_sync_build_summaries "$src_dir" "$dst_dir" \ @@ -150,11 +175,10 @@ _ckipper_account_sync_run_one_target() { _ckipper_account_sync_show_preview "$target" "$dst_dir" "$changeset" "$summaries" local action="apply" if [[ "$_SYNC_DRY_RUN" != "true" && "$_SYNC_YES" != "true" ]]; then - action=$(_ckipper_account_sync_preview_prompt "$src_dir" "$dst_dir" "$target" "$items") + action=$(_ckipper_account_sync_preview_prompt) fi [[ "$_SYNC_DRY_RUN" == "true" ]] && action="dry-run" - _ckipper_account_sync_finalize "$action" "$src_dir" "$dst_dir" \ - "$target" "$changeset" "$summaries" "$items" + _ckipper_account_sync_finalize "$action" } # Render the §6.1 preview block — header, summary table, change count. @@ -175,39 +199,45 @@ _ckipper_account_sync_show_preview() { # Apply / View changes / Abort prompt, with View looping back through # the drill-down picker until Apply or Abort. # -# Args: $1 — src_dir; $2 — dst_dir; $3 — target; $4 — items file. +# Reads _SYNC_CTX[dst_name] for the prompt label. Callers capture this +# function's stdout via $() to read the action token, so two precautions +# keep "$action" clean: (1) drill_down_loop is redirected to stderr — its +# diff output and "press enter" prompts are user-facing terminal output, +# not data; (2) `local choice` is declared once outside the loop, because +# re-declaring `local choice` inside the loop after the first iteration +# causes zsh to print the prior value as `choice='...'`, which would also +# leak into "$action". +# # Returns: 0; prints "apply" | "abort". _ckipper_account_sync_preview_prompt() { - local src_dir="$1" dst_dir="$2" target="$3" items="$4" + local target="${_SYNC_CTX[dst_name]}" + local choice="" while true; do - local choice choice=$(_core_prompt_choose "Apply changes to $target?" "Apply" "View changes" "Abort") case "$choice" in Apply) echo "apply"; return 0 ;; Abort|"") echo "abort"; return 0 ;; - "View changes") - _ckipper_account_sync_drill_down_loop "$src_dir" "$dst_dir" \ - "$_SYNC_FROM" "$target" "$items" - ;; + "View changes") _ckipper_account_sync_drill_down_loop >&2 ;; esac done } -# Run the chosen action, clean up tmpfiles, return the apply rc. +# Run the chosen action, clean up tmpfiles, return the apply rc. Reads the +# tmpfile paths and apply args from _SYNC_CTX. # -# Args: $1 — action; $2 — src_dir; $3 — dst_dir; $4 — target; -# $5 — changeset; $6 — summaries; $7 — items. +# Args: $1 — action. # Returns: 0 unless action=apply and the apply failed. _ckipper_account_sync_finalize() { - local action="$1" src_dir="$2" dst_dir="$3" target="$4" - local changeset="$5" summaries="$6" items="$7" + local action="$1" local rc=0 if [[ "$action" == "apply" ]]; then - _ckipper_account_sync_apply_target "$src_dir" "$dst_dir" \ - "$_SYNC_FROM" "$target" < "$changeset" + _ckipper_account_sync_apply_target \ + "${_SYNC_CTX[src_dir]}" "${_SYNC_CTX[dst_dir]}" \ + "${_SYNC_CTX[src_name]}" "${_SYNC_CTX[dst_name]}" \ + < "${_SYNC_CTX[changeset]}" rc=$? fi - rm -f "$changeset" "$summaries" "$items" + rm -f "${_SYNC_CTX[changeset]}" "${_SYNC_CTX[summaries]}" "${_SYNC_CTX[items]}" return $rc } @@ -245,6 +275,9 @@ _ckipper_account_sync_help_text() { # # Args: $1 — account name; flags: --pick | --list | --force. # Returns: 0 on success; 1 on user-visible failure. +# Errors (stderr): "Usage: ckipper account sync undo " — when the +# account positional is missing; "Unknown flag: " — when an +# unrecognized --foo is passed. _ckipper_account_sync_undo_dispatch() { local account="$1"; shift 2>/dev/null [[ -z "$account" ]] && { echo "Usage: ckipper account sync undo " >&2; return 1; } diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index cd60587..f4cfc18 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -123,3 +123,34 @@ run_full() { run_full 'ckipper account sync src src --include mcp --yes' [ "$status" -ne 0 ] } + +# Regression: when the user picks "View changes" then "Apply", the diff +# output written by drill_down_loop must NOT pollute the captured action, +# else the [[ "$action" == "apply" ]] check downstream silently skips apply. +# The mock _core_prompt_choose persists state via a flag file because each +# choice=$(...) call inside preview_prompt opens a fresh subshell. +@test "preview_prompt View changes then Apply yields exactly 'apply'" { + run_in_zsh ' + _SYNC_FROM=src + items=$(mktemp); echo "x" > "$items" + _SYNC_CTX[dst_name]=dst + _SYNC_CTX[items]=$items + export _PROMPT_FLAG=$(mktemp) + _core_prompt_choose() { + if [[ -e "$_PROMPT_FLAG" ]]; then + rm "$_PROMPT_FLAG" + echo "View changes" + else + echo "Apply" + fi + } + _ckipper_account_sync_drill_down_loop() { + echo "── source diff ──" + echo "+++ added line" + echo "(Press enter to return to picker)" + } + action=$(_ckipper_account_sync_preview_prompt) + rm -f "$items" + echo "ACTION=[$action]"' + [[ "$output" == *"ACTION=[apply]"* ]] +} diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index 5f82bba..2153efd 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -37,6 +37,15 @@ # All five functions take their arguments in the same order so the engine # can call them through _ckipper_account_sync_strategy_fn uniformly. +# Per-target context shared by apply_target → apply_one and the preview/ +# finalize helpers in dispatcher.zsh. Declared here too because engine.zsh +# is sourced before dispatcher.zsh, and engine_test.bats sources only the +# engine. Re-declaration without `=()` is a no-op so dispatcher.zsh's +# matching declaration doesn't reset state. Keys: src_dir, dst_dir, +# src_name, dst_name, backup_dir (and from dispatcher: changeset, summaries, +# items). +typeset -gA _SYNC_CTX + # Compute the strategy function name for a (type, verb) pair. # # Args: $1 — type id (e.g. "mcp", "claude-md"); $2 — verb (enumerate, compare, @@ -123,12 +132,16 @@ _ckipper_account_sync_walk_type() { } # Apply a change set to a single target. Steps: -# 1. Create backup dir + manifest. +# 1. Create backup dir + manifest, populate _SYNC_CTX[backup_dir]. # 2. For each change, call the strategy's apply (which itself calls # _ckipper_account_sync_backup_file before writing). # 3. On any failure: roll back via _ckipper_account_sync_rollback_target, # print the partial manifest's path, and return non-zero. # +# Also (re)populates _SYNC_CTX with the four name/dir args so the function +# is callable on its own (engine_test.bats invokes it directly without +# going through run_one_target). +# # Reads the change set on stdin: TSV rows of "\t\t\t" # where status is one of "new" | "overwrite" (unchanged rows are filtered upstream). # @@ -139,15 +152,13 @@ _ckipper_account_sync_apply_target() { local backup_dir backup_dir=$(_ckipper_account_sync_backup_create "$dst_dir" "$src_name") _ckipper_account_sync_manifest_init "$backup_dir" "$src_name" "$dst_name" + _SYNC_CTX[src_dir]="$src_dir"; _SYNC_CTX[dst_dir]="$dst_dir" + _SYNC_CTX[src_name]="$src_name"; _SYNC_CTX[dst_name]="$dst_name" + _SYNC_CTX[backup_dir]="$backup_dir" local rc=0 type id display change_status while IFS=$'\t' read -r type id display change_status; do [[ -z "$type" || "$change_status" == "unchanged" ]] && continue - if ! _ckipper_account_sync_apply_one "$type" "$src_dir" "$dst_dir" \ - "$src_name" "$dst_name" "$id" \ - "$change_status" "$backup_dir"; then - rc=1 - break - fi + _ckipper_account_sync_apply_one "$type" "$id" "$change_status" || { rc=1; break; } done if (( rc != 0 )); then _ckipper_account_sync_rollback_target "$backup_dir" "$dst_dir" >&2 @@ -159,25 +170,27 @@ _ckipper_account_sync_apply_target() { # Apply one change set entry. Bridges between the strategy contract and # the manifest schema. prefs uses names; everything else uses dirs. # +# Reads src_dir/dst_dir/src_name/dst_name/backup_dir from _SYNC_CTX (set +# by apply_target). Keeping these in context drops the parameter count +# from 8 to 3, satisfying the .claude/rules/code-style.md cap. +# # Manifest is appended BEFORE the apply call, not after. If the apply # crashes mid-write (backed-up the file, started writing, errored), the # manifest still contains the entry so rollback can restore from the # backup dir. Without this, mid-write failures leave the destination # half-written with no manifest record (rollback would skip the file). # -# Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name; -# $6 — id; $7 — change status; $8 — backup_dir. +# Args: $1 — type; $2 — id; $3 — change status. # Returns: 0 on success; non-zero on apply failure. _ckipper_account_sync_apply_one() { - local type="$1" src_dir="$2" dst_dir="$3" src_name="$4" dst_name="$5" - local id="$6" change_status="$7" backup_dir="$8" + local type="$1" id="$2" change_status="$3" local apply_fn; apply_fn=$(_ckipper_account_sync_strategy_fn "$type" apply) - local arg_a="$src_dir" arg_b="$dst_dir" - [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" + [[ "$type" == "prefs" ]] && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } local op="overwrite"; [[ "$change_status" == "new" ]] && op="create" local rel; rel=$(_ckipper_account_sync_manifest_rel "$type" "$id") - _ckipper_account_sync_manifest_append "$backup_dir" "$rel" "$op" "$type" "$id" - "$apply_fn" "$arg_a" "$arg_b" "$id" "$backup_dir" + _ckipper_account_sync_manifest_append "${_SYNC_CTX[backup_dir]}" "$rel" "$op" "$type" "$id" + "$apply_fn" "$arg_a" "$arg_b" "$id" "${_SYNC_CTX[backup_dir]}" } # Compute the manifest's path field for a given (type, id). The relpath diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh index 493c1ef..3e4199e 100644 --- a/lib/account/sync/preview.zsh +++ b/lib/account/sync/preview.zsh @@ -10,6 +10,11 @@ readonly _CKIPPER_SYNC_BADGE_NEW="[+]" readonly _CKIPPER_SYNC_BADGE_OVERWRITE="[~]" readonly _CKIPPER_SYNC_DIVIDER_WIDTH=45 +# Per-target context (declared here too because preview_test.bats sources +# only this module). See engine.zsh for the full key list. Re-declaration +# without `=()` is a no-op so we don't reset state set by earlier modules. +typeset -gA _SYNC_CTX + # Print the divider line for the summary table. # # Returns: 0 always. @@ -85,20 +90,24 @@ _ckipper_account_sync_drill_down_items() { # full diff via the strategy's _diff function. Loops until the user # picks "Back" or hits EOF. # -# Args: $1 — src dir; $2 — dst dir; $3 — src name; $4 — dst name; $5 — items file. +# Reads _SYNC_CTX[items] for the items-file path; drill_down_show reads +# the rest of the per-target dirs/names directly. +# # Returns: 0 always. _ckipper_account_sync_drill_down_loop() { - local src_dir="$1" dst_dir="$2" src_name="$3" dst_name="$4" items_file="$5" + local items_file="${_SYNC_CTX[items]}" [[ ! -s "$items_file" ]] && { echo "No overwrites to drill into."; return 0; } + # Hoist `local choice` and `local _ack` out of the loop: re-declaring + # `local var` (no =value) on a subsequent iteration causes zsh to + # print `var='prior_value'`, which would surface as terminal noise. + local choice="" _ack="" while true; do - local choice choice=$(_ckipper_account_sync_drill_down_pick "$items_file") || return 0 [[ "$choice" == "Back" || -z "$choice" ]] && return 0 - _ckipper_account_sync_drill_down_show "$choice" "$items_file" \ - "$src_dir" "$dst_dir" "$src_name" "$dst_name" + _ckipper_account_sync_drill_down_show "$choice" echo "" echo "(Press enter to return to picker)" - local _ack; read -r _ack + read -r _ack done } @@ -122,18 +131,19 @@ _ckipper_account_sync_drill_down_pick() { # in the items file to recover the original id (which may differ from # display, e.g. files-flat: id=agents/foo.md, display=foo.md). # -# Args: $1 — picker choice (e.g. "[mcp] github"); $2 — items file; -# $3 — src dir; $4 — dst dir; $5 — src name; $6 — dst name. +# Reads items file path and src/dst dirs/names from _SYNC_CTX. +# +# Args: $1 — picker choice (e.g. "[mcp] github"). # Returns: 0; prints the strategy's diff output. _ckipper_account_sync_drill_down_show() { - local choice="$1" items_file="$2" - local src_dir="$3" dst_dir="$4" src_name="$5" dst_name="$6" + local choice="$1" + local items_file="${_SYNC_CTX[items]}" local type="${choice#\[}"; type="${type%%]*}" local display="${choice#*] }" local id; id=$(_ckipper_account_sync_drill_down_resolve_id "$items_file" "$type" "$display") local diff_fn; diff_fn=$(_ckipper_account_sync_strategy_fn "$type" diff) - local arg_a="$src_dir" arg_b="$dst_dir" - [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" + [[ "$type" == "prefs" ]] && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } "$diff_fn" "$arg_a" "$arg_b" "$id" } From 2c296d196b0ddaee323945a68ebd9ffe660961ea Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 May 2026 22:56:36 -0600 Subject: [PATCH 15/18] fix(sync): third-round code-review feedback (PR #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven verified bugs from the latest review, each with a regression test that watched the bug fail before the fix landed. - structured: mcp_compare/settings_compare returned "overwrite" when the destination JSON file was absent (jq -> empty, not "null"); manifest recorded op=overwrite and rollback would not delete the new file. Treat empty as the same case as "null". - hooks: filter_src jq gsub treated $src as a regex pattern; switched to literal split($src) | join($dst) so paths with `.`/`-` (e.g. ~/.claude-personal) don't mangle unrelated commands. - statusline: copy_and_rewrite now appends a manifest entry for the copied script file. Mid-write crash + rollback used to leave the script orphaned because manifest_rel only emitted settings.json. - backup: backup_file is now idempotent — a second call for the same rel within one invocation is a no-op so the original-state snapshot survives when settings + statusline both write settings.json. - dispatcher: skip assert_dst_idle for --dry-run; preview-only runs shouldn't be blocked by a still-open Claude session. - account dispatcher: 'sync-hooks' now prints a rename hint pointing at 'redeploy-hooks' (mirrors the top-level legacy-command pattern in ckipper.zsh). Removed the misleading "[hidden but callable]" doc and the contradictory CHANGELOG line, plus the stale comment about a non-existent fallback function. - backup: manifest_append cleans up its tmp file on jq failure (was using `&&` chain that left the tmp file in the backup dir). --- CHANGELOG.md | 1 - lib/account/dispatcher.zsh | 28 ++++++++++----- lib/account/dispatcher_test.bats | 11 ++++++ lib/account/sync/backup.zsh | 16 ++++++--- lib/account/sync/backup_test.bats | 32 +++++++++++++++++ lib/account/sync/dispatcher.zsh | 6 +++- lib/account/sync/dispatcher_test.bats | 24 +++++++++++++ lib/account/sync/strategies/hooks.zsh | 5 ++- lib/account/sync/strategies/hooks_test.bats | 21 ++++++++++++ lib/account/sync/strategies/statusline.zsh | 7 +++- .../sync/strategies/statusline_test.bats | 34 +++++++++++++++++++ lib/account/sync/strategies/structured.zsh | 9 +++-- .../sync/strategies/structured_test.bats | 18 ++++++++++ 13 files changed, 194 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c4b91..fb890f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - `ckipper account repair-plugins` (folded into `ckipper doctor --fix`). -- `ckipper account sync-hooks` from public help (still callable; auto-runs after install/setup). ## [0.2.0] — 2026-04-30 — Breaking changes: merge `w` into `ckipper` diff --git a/lib/account/dispatcher.zsh b/lib/account/dispatcher.zsh index b6fecad..568d8ac 100644 --- a/lib/account/dispatcher.zsh +++ b/lib/account/dispatcher.zsh @@ -12,11 +12,18 @@ _CKIPPER_ACCOUNT_SUBCOMMANDS=( add list default remove rename sync redeploy-hooks help ) +# Account-namespace renames. Used by _ckipper_account_unknown to print a +# rename hint instead of a bare unknown-command line, since fuzzy distance +# is too far for "sync-hooks" → "redeploy-hooks" to be auto-suggested. +typeset -gA _CKIPPER_ACCOUNT_LEGACY_COMMANDS=( + [sync-hooks]='redeploy-hooks' +) + # Dispatch an `account` subcommand. # # Args: # $1 — subcommand name (add, list, default, remove, rename, sync, -# sync-hooks [hidden but callable], help, -h, --help, or empty) +# redeploy-hooks, help, -h, --help, or empty) # $2..$N — arguments forwarded to the subcommand handler # # Returns: 0 on success; 1 on unknown subcommand. @@ -42,13 +49,20 @@ _ckipper_account_dispatch() { esac } -# Print the closest-match suggestion (or a bare unknown-command line) and -# point the user at help. Always writes to stderr. +# Print a rename hint for a retired account-namespace name, or fall through +# to the standard unknown-command + fuzzy-suggest path. Always writes to +# stderr. # # Args: $1 — the unknown subcommand the user typed. # Returns: 0 always. _ckipper_account_unknown() { - _core_unknown_command "$1" \ + local cmd="$1" + if (( ${+_CKIPPER_ACCOUNT_LEGACY_COMMANDS[$cmd]} )); then + echo "'ckipper account $cmd' was renamed to 'ckipper account ${_CKIPPER_ACCOUNT_LEGACY_COMMANDS[$cmd]}' — pass the same arguments." >&2 + echo "Run 'ckipper account help' for the current command list." >&2 + return 0 + fi + _core_unknown_command "$cmd" \ "Run 'ckipper account help' for available commands." \ "${_CKIPPER_ACCOUNT_SUBCOMMANDS[@]}" } @@ -75,10 +89,8 @@ _ckipper_account_help() { } # Per-subcommand help text router. Each arm prints a focused usage block. -# -# Note: the `sync` arm dispatches to the new sync subsystem's help via -# parse_args before reaching this router, so `_ckipper_account_help_text_sync` -# is no longer used here — kept only as a fallback. +# The `sync` arm forwards to the sync subsystem's own help text since that +# module owns its CLI surface. # # Args: $1 — subcommand name. # Returns: 0 always. diff --git a/lib/account/dispatcher_test.bats b/lib/account/dispatcher_test.bats index c9c6a18..73194ab 100644 --- a/lib/account/dispatcher_test.bats +++ b/lib/account/dispatcher_test.bats @@ -86,3 +86,14 @@ _run_dispatch() { [[ "$output" =~ "Unknown command: 'xyzzy'." ]] [[ ! "$output" =~ "Did you mean" ]] } + +# `sync-hooks` was renamed to `redeploy-hooks`. Old name must error AND +# point the user at the new name. Mirrors the top-level `_ckipper_unknown` +# legacy-command pattern in ckipper.zsh. +@test "dispatch points 'sync-hooks' at the new 'redeploy-hooks' name" { + _run_dispatch sync-hooks + + [ "$status" -ne 0 ] + [[ "$output" == *"sync-hooks"* ]] + [[ "$output" == *"redeploy-hooks"* ]] +} diff --git a/lib/account/sync/backup.zsh b/lib/account/sync/backup.zsh index 5700605..a03def5 100644 --- a/lib/account/sync/backup.zsh +++ b/lib/account/sync/backup.zsh @@ -45,6 +45,9 @@ _ckipper_account_sync_backup_create() { # Copy a single file or directory from the destination into the backup # dir at the given relative path. No-op when the source path does not # exist (i.e. operation is "create" — there's nothing to back up). +# Idempotent: a second call for the same rel within one invocation is a +# no-op so the original-state snapshot is preserved when two strategies +# (e.g. settings + statusline) write to the same destination file. # # Args: $1 — backup_dir; $2 — absolute source path; $3 — relative destination path. # Returns: 0 on success or no-op; 1 if cp fails. @@ -52,6 +55,7 @@ _ckipper_account_sync_backup_file() { local backup_dir="$1" src="$2" rel="$3" [[ ! -e "$src" ]] && return 0 local dst="$backup_dir/$rel" + [[ -e "$dst" ]] && return 0 mkdir -p "${dst:h}" cp -a "$src" "$dst" || return 1 [[ -f "$dst" ]] && chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$dst" @@ -78,15 +82,19 @@ _ckipper_account_sync_manifest_init() { # # Args: $1 — backup_dir; $2 — relative path; $3 — operation (create|overwrite); # $4 — type id; $5 — items (comma-separated, optional). -# Returns: 0 on success; 1 on jq failure. +# Returns: 0 on success; 1 on jq failure (tmp file is cleaned up on every path). _ckipper_account_sync_manifest_append() { local backup_dir="$1" rel="$2" op="$3" type="$4" items="${5:-}" local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" local tmp; tmp=$(mktemp "$manifest.XXXXXX") - jq --arg p "$rel" --arg o "$op" --arg t "$type" --arg i "$items" \ + if jq --arg p "$rel" --arg o "$op" --arg t "$type" --arg i "$items" \ '.files += [{path: $p, operation: $o, type: $t, items: ($i | split(",") | map(select(length > 0)))}]' \ - "$manifest" > "$tmp" && mv "$tmp" "$manifest" \ - && chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$manifest" + "$manifest" > "$tmp"; then + mv "$tmp" "$manifest" && chmod "$_CKIPPER_SYNC_BACKUP_FILE_PERMS" "$manifest" + return $? + fi + rm -f "$tmp" + return 1 } # List backup directories under , newest first. Returns absolute paths. diff --git a/lib/account/sync/backup_test.bats b/lib/account/sync/backup_test.bats index 7b0d3a8..96f8689 100644 --- a/lib/account/sync/backup_test.bats +++ b/lib/account/sync/backup_test.bats @@ -54,6 +54,24 @@ run_in_zsh() { [[ "$output" == *"OK"* ]] } +@test "_ckipper_account_sync_backup_file is idempotent: second call preserves original snapshot" { + # Two strategies (e.g. settings + statusline) both back up settings.json. + # The first call must capture the pre-sync state; the second must NOT + # overwrite it with the post-first-write intermediate state, otherwise + # rollback restores a corrupted baseline. + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + echo "ORIGINAL" > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/settings.json' 'settings.json' + echo MODIFIED > '$dst/settings.json' + _ckipper_account_sync_backup_file \"\$backup_dir\" '$dst/settings.json' 'settings.json' + cat \"\$backup_dir/settings.json\"" + [[ "$output" == *"ORIGINAL"* ]] + [[ "$output" != *"MODIFIED"* ]] +} + @test "_ckipper_account_sync_backup_file copies directories recursively (cp -a)" { local dst="$TMP_HOME/dest" mkdir -p "$dst/skills/foo" @@ -79,6 +97,20 @@ run_in_zsh() { [[ "$output" == *"work"* ]] } +@test "_ckipper_account_sync_manifest_append cleans up its tmp file when jq fails" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' personal) + _ckipper_account_sync_manifest_init \"\$backup_dir\" personal work + # Corrupt the manifest so jq exits non-zero on the next append. + echo 'not-json' > \"\$backup_dir/.ckipper-sync-manifest.json\" + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'x' overwrite mcp 'a' 2>/dev/null + # Tmp files match .ckipper-sync-manifest.json.XXXXXX in the backup dir. + ls \"\$backup_dir\"/.ckipper-sync-manifest.json.* 2>/dev/null | wc -l | tr -d ' '" + [[ "$output" == *"0"* ]] +} + @test "_ckipper_account_sync_manifest_append adds an entry" { local dst="$TMP_HOME/dest" mkdir -p "$dst" diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index f89f97a..d496f3b 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -159,7 +159,11 @@ _ckipper_account_sync_run_one_target() { local src_dir dst_dir src_dir=$(_core_account_dir "$_SYNC_FROM") dst_dir=$(_core_account_dir "$target") - _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 + # Dry-run is read-only; the running-Claude refusal exists to prevent + # races with writes, so let preview-only invocations through. + if [[ "$_SYNC_DRY_RUN" != "true" ]]; then + _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 + fi local changeset summaries items changeset=$(mktemp); summaries=$(mktemp); items=$(mktemp) _SYNC_CTX=( diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index f4cfc18..c023853 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -112,6 +112,30 @@ run_full() { [[ "$n" == "0" ]] } +# Regression: dry-run is read-only; the running-Claude refusal exists to +# prevent races with writes. A user previewing changes while their session +# is still open must not be blocked. +@test "ckipper account sync --dry-run is not blocked when Claude is running on dst" { + setup_two_accounts + run_full ' + # Stub: pretend claude is running and its argv includes the dst path. + _core_running_claude_processes() { echo "12345 claude $TMP_HOME/dst"; } + ckipper account sync src dst --include mcp --dry-run' + [ "$status" -eq 0 ] + [[ "$output" != *"Refusing to sync"* ]] +} + +# Companion check: without --dry-run (and without --force), the same scenario +# must still abort — the dry-run skip is the only carve-out. +@test "ckipper account sync without --dry-run IS blocked when Claude is running on dst" { + setup_two_accounts + run_full ' + _core_running_claude_processes() { echo "12345 claude $TMP_HOME/dst"; } + ckipper account sync src dst --include mcp --yes' + [ "$status" -ne 0 ] + [[ "$output" == *"Refusing to sync"* ]] +} + @test "ckipper account sync rejects unregistered source" { setup_two_accounts run_full 'ckipper account sync ghost dst --include mcp --yes' diff --git a/lib/account/sync/strategies/hooks.zsh b/lib/account/sync/strategies/hooks.zsh index 319b234..74aa77c 100644 --- a/lib/account/sync/strategies/hooks.zsh +++ b/lib/account/sync/strategies/hooks.zsh @@ -143,6 +143,9 @@ _ckipper_account_sync_hooks_merge_settings() { # Returns: 0; prints filtered hooks JSON object (may be empty {}). _ckipper_account_sync_hooks_filter_src() { local src_settings="$1" script_basename="$2" src="$3" dst="$4" + # Literal split+join (NOT sub/gsub) — paths often contain regex + # metacharacters (`.`, `-`) and gsub would treat the src path as a regex, + # silently rewriting unrelated commands that happen to match the pattern. jq --arg sb "$script_basename" --arg src "$src" --arg dst "$dst" ' (.hooks // {}) | to_entries @@ -152,7 +155,7 @@ _ckipper_account_sync_hooks_filter_src() { .value | map(.hooks |= map(select(.command | tostring | contains("/" + $sb)))) | map(select(.hooks | length > 0)) - | map(.hooks |= map(.command |= gsub($src; $dst))) + | map(.hooks |= map(.command |= (split($src) | join($dst)))) ) }) | map(select(.value | length > 0)) diff --git a/lib/account/sync/strategies/hooks_test.bats b/lib/account/sync/strategies/hooks_test.bats index 054e00d..4b630ce 100644 --- a/lib/account/sync/strategies/hooks_test.bats +++ b/lib/account/sync/strategies/hooks_test.bats @@ -52,6 +52,27 @@ run_in_zsh() { [[ "$output" == *"new"* ]] } +@test "hooks_filter_src: literal substring rewrite (not regex) when src path contains a dot" { + # src path has `.` (regex metachar); a separate command that happens to + # match the regex but NOT the literal substring must be left alone. + local src="$TMP_HOME/.claude-personal" dst="$TMP_HOME/.claude-work" + mkdir -p "$src/hooks" "$dst/hooks" + touch "$src/hooks/lint.sh" + # Settings hook command references TWO paths: + # A) the literal src path — should be rewritten to dst + # B) a different path that matches the src regex (`.` matches `-`) + # — must NOT be rewritten + cat > "$src/settings.json" < "$src/my-statusline.sh" + chmod +x "$src/my-statusline.sh" + echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" + jq -r '.files[] | \"\(.operation)\t\(.path)\"' \"\$backup_dir\"/.ckipper-sync-manifest.json | sort" + # Both files must be in the manifest so a later rollback can restore them. + [[ "$output" == *"settings.json"* ]] + [[ "$output" == *"my-statusline.sh"* ]] +} + +@test "statusline rollback removes orphaned script when op=create" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo "#!/bin/bash" > "$src/my-statusline.sh" + chmod +x "$src/my-statusline.sh" + echo "{\"statusLine\":{\"command\":\"$src/my-statusline.sh\"}}" > "$src/settings.json" + echo '{}' > "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_statusline_apply '$src' '$dst' statusLine \"\$backup_dir\" + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst' + [[ -f '$dst/my-statusline.sh' ]] && echo STILL_THERE || echo GONE" + [[ "$output" == *"GONE"* ]] + [[ "$output" != *"STILL_THERE"* ]] +} diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh index bbac34d..5489057 100644 --- a/lib/account/sync/strategies/structured.zsh +++ b/lib/account/sync/strategies/structured.zsh @@ -65,7 +65,10 @@ _ckipper_account_sync_mcp_compare() { local s d s=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$src/.claude.json" 2>/dev/null) d=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$dst/.claude.json" 2>/dev/null) - if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + # Empty `d` means the destination file is missing entirely (jq exited + # non-zero); treat the same as "key absent" so the manifest records `op=create` + # and rollback knows to delete (not restore-overwrite) the new file. + if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi echo "overwrite" } @@ -152,7 +155,9 @@ _ckipper_account_sync_settings_compare() { local s d s=$(jq -c "$jq_path // null" "$src/settings.json" 2>/dev/null) d=$(jq -c "$jq_path // null" "$dst/settings.json" 2>/dev/null) - if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + # Empty `d` means the destination file is missing entirely; see the + # equivalent guard in `_ckipper_account_sync_mcp_compare` for rationale. + if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi echo "overwrite" } diff --git a/lib/account/sync/strategies/structured_test.bats b/lib/account/sync/strategies/structured_test.bats index ced3d6c..c31d534 100644 --- a/lib/account/sync/strategies/structured_test.bats +++ b/lib/account/sync/strategies/structured_test.bats @@ -86,6 +86,15 @@ run_in_zsh() { [[ "$output" == *"overwrite"* ]] } +@test "mcp_compare: new when destination .claude.json does not exist" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + run_in_zsh "_ckipper_account_sync_mcp_compare '$src' '$dst' github" + [[ "$output" == *"new"* ]] + [[ "$output" != *"overwrite"* ]] +} + @test "mcp_apply merges into destination preserving other servers" { local src="$TMP_HOME/src" dst="$TMP_HOME/dst" mkdir -p "$src" "$dst" @@ -148,6 +157,15 @@ run_in_zsh() { [[ "$output" == *"unchanged"* ]] } +@test "settings_compare: new when destination settings.json does not exist" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"model":"opus"}' > "$src/settings.json" + run_in_zsh "_ckipper_account_sync_settings_compare '$src' '$dst' model" + [[ "$output" == *"new"* ]] + [[ "$output" != *"overwrite"* ]] +} + @test "settings_apply writes nested path without disturbing siblings" { local src="$TMP_HOME/src" dst="$TMP_HOME/dst" mkdir -p "$src" "$dst" From 56f28377432fef2763b6a218c7a2ed6446f75473 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 12:46:52 -0600 Subject: [PATCH 16/18] fix(sync): fourth-round code-review bug fixes (PR #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six confirmed defects in the new sync engine, all uncovered by a fresh multi-agent review. Each fix is paired with a regression test that fails on develop and passes after the change. E. apply_one used to derive op (create|overwrite) from change_status. A new SUB-KEY (e.g. one new MCP server) maps to change_status="new" even when the destination FILE already exists with unrelated content. The manifest then recorded op=create, and rollback's `rm -rf $live` would destroy the entire .claude.json on `sync undo`. Fix: derive op from `[[ ! -e $live ]]` at apply time, where $live is computed via a new live_path helper. (lib/account/sync/engine.zsh, backup.zsh) G. prefs rollback resolved the live path as `$dst_dir/$rel`, which for prefs is the destination ACCOUNT dir, not $CKIPPER_REGISTRY. Rollback wrote a stray `accounts.json` into the account dir and silently left the registry corrupted. Fix: live_path returns $CKIPPER_REGISTRY for type=prefs; rollback_target reads the type field from the manifest and forwards it to rollback_one. (lib/account/sync/backup.zsh, engine.zsh) A. assert_dst_idle filtered `_core_running_claude_processes` output via `grep -F $dst_dir`, but `pgrep -lx claude` only emits " claude" on macOS — no env, no argv. The grep could never match and the safeguard was dead code. macOS does not expose foreign processes' `CLAUDE_CONFIG_DIR` env var, so dst-specific filtering is not achievable; revert to refusing on any running Claude CLI. (lib/account/sync/engine.zsh, CHANGELOG.md, dispatcher.zsh help text) B. undo_dispatch read the module-level _SYNC_FORCE directly, which leaks across invocations: a prior `sync ... --force` left _SYNC_FORCE=true, and a subsequent `sync undo` (no --force) silently bypassed the running-Claude refusal because parse_args resets the flag only on the sync path. Fix: undo_dispatch uses a local force var. (lib/account/sync/dispatcher.zsh) D. statusline_compare lacked the empty-string guard that mcp_compare and settings_compare have. When the destination's settings.json was missing entirely, jq exited non-zero and d="" — the [[ "$d" == "null" ]] guard missed and compare returned "overwrite" instead of "new", mislabeling the preview UI. (lib/account/sync/strategies/statusline.zsh) F. hooks_apply mutates settings.json (adding the paired .hooks entry) but the engine's apply_one only recorded a manifest entry for the script file (manifest_rel returns "" for hooks). The settings.json mutation was untracked, so rollback after a hook sync left a phantom .hooks entry pointing at a deleted script. Fix: hooks_apply now appends a settings.json manifest entry alongside the script entry; duplicate entries from multi-hook syncs are harmless because rollback is idempotent. (lib/account/sync/strategies/hooks.zsh) Tests: 455 → 467 (+12 regression tests across all 6 fixes). All green. --- CHANGELOG.md | 2 +- lib/account/sync/backup.zsh | 35 +++++++-- lib/account/sync/backup_test.bats | 45 ++++++++++++ lib/account/sync/dispatcher.zsh | 15 +++- lib/account/sync/dispatcher_test.bats | 31 ++++++++ lib/account/sync/engine.zsh | 46 +++++++----- lib/account/sync/engine_test.bats | 73 +++++++++++++++++++ lib/account/sync/strategies/hooks.zsh | 13 ++++ lib/account/sync/strategies/hooks_test.bats | 53 ++++++++++++++ lib/account/sync/strategies/statusline.zsh | 4 +- .../sync/strategies/statusline_test.bats | 17 +++++ 11 files changed, 304 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb890f7..b55debc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New:** Multi-destination support — sync from one account to many in a single invocation. - **New:** Summary table preview with `git status`-style status badges (`[+]` / `[~]`) and on-demand drill-down for any per-item diff. - **New:** Timestamped backups before any destructive write — `/.ckipper-sync-backups/-from-/` — with manifest-driven restore via `ckipper account sync undo [--pick | --list]`. -- **New:** Hard refusal when Claude is running with the destination's config dir (override with `--force`). +- **New:** Hard refusal when any Claude CLI process is running (override with `--force`). macOS does not expose other processes' `CLAUDE_CONFIG_DIR` env var to non-privileged callers, so the refusal cannot be filtered down to "Claude on this destination dir specifically" — it is conservative by design. - **New:** Setup wizard offers initial sync after adding a 2nd-or-later account. - **Renamed:** `ckipper account sync-hooks` → `ckipper account redeploy-hooks`. The new name reflects that it deploys the ckipper-managed safety hooks from the install dir to every account; it is NOT peer-to-peer sync. - **Removed:** Old flag surface (`--mcp [names]`, `--settings `, `--all`). The new `--include` / `--exclude` model with bundles supersedes these. diff --git a/lib/account/sync/backup.zsh b/lib/account/sync/backup.zsh index a03def5..35f0b8f 100644 --- a/lib/account/sync/backup.zsh +++ b/lib/account/sync/backup.zsh @@ -18,6 +18,23 @@ readonly _CKIPPER_SYNC_BACKUP_FILE_PERMS=600 readonly _CKIPPER_SYNC_MANIFEST_FILE=".ckipper-sync-manifest.json" readonly _CKIPPER_SYNC_MANIFEST_VERSION=1 +# Compute the LIVE absolute path for a manifest entry. Most types live +# under /; prefs operates on $CKIPPER_REGISTRY (which is +# usually outside the destination account dir). Used by both apply_one +# (op-derivation in engine.zsh) and rollback_one so the two agree on +# what file is being touched. +# +# Args: $1 — type (may be empty for legacy callers); $2 — dst_dir; +# $3 — relpath from the manifest. +# Returns: 0; prints absolute path. +_ckipper_account_sync_live_path() { + local type="$1" dst_dir="$2" rel="$3" + case "$type" in + prefs) echo "$CKIPPER_REGISTRY" ;; + *) echo "$dst_dir/$rel" ;; + esac +} + # Compute the backup-dir path (does NOT create it). Pure function; no IO. # # Args: $1 — destination account dir; $2 — source account name. @@ -114,9 +131,12 @@ _ckipper_account_sync_manifest_list_backups() { } # Roll back a single target by reversing every entry in its manifest: -# - operation=create → delete the file at / (the sync put it there) +# - operation=create → delete the file at the live path (the sync put it there) # - operation=overwrite → restore from / via atomic rename # +# The live path is normally /, but for type=prefs it's +# $CKIPPER_REGISTRY. _ckipper_account_sync_live_path encapsulates that. +# # Best-effort per-entry: a missing backup or a permission error is logged # (stderr) but does not stop subsequent entries from rolling back. # @@ -128,21 +148,22 @@ _ckipper_account_sync_rollback_target() { local manifest="$backup_dir/$_CKIPPER_SYNC_MANIFEST_FILE" [[ ! -f "$manifest" ]] && return 0 local rc=0 - while IFS=$'\t' read -r op rel; do - _ckipper_account_sync_rollback_one "$backup_dir" "$dst_dir" "$op" "$rel" || rc=1 - done < <(jq -r '.files[] | "\(.operation)\t\(.path)"' "$manifest") + while IFS=$'\t' read -r op rel type; do + _ckipper_account_sync_rollback_one "$backup_dir" "$dst_dir" "$op" "$rel" "$type" || rc=1 + done < <(jq -r '.files[] | "\(.operation)\t\(.path)\t\(.type // "")"' "$manifest") return $rc } # Per-entry rollback helper. Kept separate so _rollback_target stays under # the 25-line cap and the per-entry logic is independently unit-testable. # -# Args: $1 — backup_dir; $2 — dst_dir; $3 — operation; $4 — relative path. +# Args: $1 — backup_dir; $2 — dst_dir; $3 — operation; $4 — relative path; +# $5 — type (optional; routes prefs to $CKIPPER_REGISTRY instead of dst_dir). # Returns: 0 on success; 1 on rm/mv failure. # Errors (stderr): "rollback failed: " _ckipper_account_sync_rollback_one() { - local backup_dir="$1" dst_dir="$2" op="$3" rel="$4" - local live="$dst_dir/$rel" + local backup_dir="$1" dst_dir="$2" op="$3" rel="$4" type="${5:-}" + local live; live=$(_ckipper_account_sync_live_path "$type" "$dst_dir" "$rel") if [[ "$op" == "create" ]]; then rm -rf "$live" 2>/dev/null || { echo "rollback failed: $rel — could not remove created file" >&2 diff --git a/lib/account/sync/backup_test.bats b/lib/account/sync/backup_test.bats index 96f8689..1f1a0e2 100644 --- a/lib/account/sync/backup_test.bats +++ b/lib/account/sync/backup_test.bats @@ -184,3 +184,48 @@ run_in_zsh() { [[ "$output" == *"original"* ]] [[ "$output" == *"BACKUP_REMOVED"* ]] } + +# ── live_path + type-aware rollback (Bug G fix) ────────────────────────── + +@test "_ckipper_account_sync_live_path returns CKIPPER_REGISTRY for type=prefs" { + run_in_zsh " + export CKIPPER_REGISTRY='$TMP_HOME/.ckipper/accounts.json' + _ckipper_account_sync_live_path prefs '$TMP_HOME/dst' 'accounts.json'" + [[ "$output" == *".ckipper/accounts.json"* ]] + [[ "$output" != *"/dst/accounts.json"* ]] +} + +@test "_ckipper_account_sync_live_path returns dst/rel for non-prefs types" { + run_in_zsh "_ckipper_account_sync_live_path mcp '$TMP_HOME/dst' '.claude.json'" + [[ "$output" == *"$TMP_HOME/dst/.claude.json"* ]] +} + +@test "_ckipper_account_sync_live_path falls back to dst/rel when type is empty" { + run_in_zsh "_ckipper_account_sync_live_path '' '$TMP_HOME/dst' 'foo'" + [[ "$output" == *"$TMP_HOME/dst/foo"* ]] +} + +# Bug G: prefs rollback used to write to $dst_dir/accounts.json, NOT to +# $CKIPPER_REGISTRY — silently corrupting the registry restore. Fix passes +# the type field from the manifest through to rollback_one so prefs is +# correctly routed to the registry. +@test "_ckipper_account_sync_rollback_target restores prefs to CKIPPER_REGISTRY (Bug G)" { + local dst="$TMP_HOME/dest" + mkdir -p "$dst" "$TMP_HOME/.ckipper" + local registry="$TMP_HOME/.ckipper/accounts.json" + echo '{"version":2,"original":"yes"}' > "$registry" + + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ + CKIPPER_REGISTRY="$registry" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src); \ + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst; \ + _ckipper_account_sync_backup_file \"\$backup_dir\" '$registry' 'accounts.json'; \ + _ckipper_account_sync_manifest_append \"\$backup_dir\" 'accounts.json' overwrite prefs always_docker; \ + echo '{\"version\":2,\"corrupted\":\"yes\"}' > '$registry'; \ + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst'; \ + jq -r '.original // \"missing\"' '$registry'; \ + [[ -e '$dst/accounts.json' ]] && echo STRAY_DST_FILE || echo NO_STRAY" + [[ "$output" == *"yes"* ]] + [[ "$output" == *"NO_STRAY"* ]] +} diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index d496f3b..727922b 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -263,7 +263,7 @@ _ckipper_account_sync_help_text() { " --exclude Subtract from --include." \ " --dry-run Print summary, exit (no prompt, no writes)." \ " --yes Skip the confirm prompt; apply directly." \ - " --force Bypass the destination-Claude-running refusal." \ + " --force Bypass the running-Claude refusal." \ "" \ "Subcommand:" \ " ckipper account sync undo [--pick | --list]" \ @@ -277,6 +277,13 @@ _ckipper_account_sync_help_text() { # Undo subcommand dispatcher. # +# Force is read from a LOCAL var, not the module-level _SYNC_FORCE. The +# previous design read _SYNC_FORCE directly, which leaked across +# invocations: a prior `sync ... --force` left _SYNC_FORCE="true" in the +# shell, and a subsequent `sync undo` (without --force) inherited it, +# silently bypassing the running-Claude refusal. parse_args resets +# _SYNC_FORCE on the sync path, but the undo path skips parse_args. +# # Args: $1 — account name; flags: --pick | --list | --force. # Returns: 0 on success; 1 on user-visible failure. # Errors (stderr): "Usage: ckipper account sync undo " — when the @@ -286,16 +293,16 @@ _ckipper_account_sync_undo_dispatch() { local account="$1"; shift 2>/dev/null [[ -z "$account" ]] && { echo "Usage: ckipper account sync undo " >&2; return 1; } local dst_dir; dst_dir=$(_core_account_dir "$account") || return 1 - local mode="latest" + local mode="latest" force="false" while [[ $# -gt 0 ]]; do case "$1" in --pick) mode="pick"; shift ;; --list) mode="list"; shift ;; - --force) _SYNC_FORCE="true"; shift ;; + --force) force="true"; shift ;; *) echo "Unknown flag: $1" >&2; return 1 ;; esac done - _ckipper_account_sync_assert_dst_idle "$dst_dir" "${_SYNC_FORCE:-false}" || return 1 + _ckipper_account_sync_assert_dst_idle "$dst_dir" "$force" || return 1 _ckipper_account_sync_undo_run "$account" "$dst_dir" "$mode" } diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index c023853..546948e 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -148,6 +148,37 @@ run_full() { [ "$status" -ne 0 ] } +# Bug B: undo_dispatch used to read $_SYNC_FORCE directly, which leaked +# state from a prior sync invocation in the same shell. A user who ran +# `sync ... --force` (setting _SYNC_FORCE=true) and then ran `sync undo` +# without --force would silently bypass the running-Claude refusal because +# parse_args resets _SYNC_FORCE only on the sync path. Fix: undo uses a +# local force var, defaulting to false. +@test "sync undo does NOT inherit --force from a prior sync invocation (Bug B)" { + setup_two_accounts + run_full ' + # Apply a sync first WITH --force so _SYNC_FORCE leaks to module state. + _core_running_claude_processes() { return 0; } + ckipper account sync src dst --include mcp --yes --force >/dev/null 2>&1 + # Now undo without --force; running Claude should block it again. + _core_running_claude_processes() { echo "12345 claude"; } + ckipper account sync undo dst' + [ "$status" -ne 0 ] + [[ "$output" == *"Refusing to sync"* ]] +} + +# Bug B (companion): sync undo --force still works on its own. +@test "sync undo --force bypasses the running-Claude refusal" { + setup_two_accounts + # Need at least one backup to exist for undo to do anything past the gate. + run_full ' + _core_running_claude_processes() { return 0; } + ckipper account sync src dst --include mcp --yes >/dev/null 2>&1 + _core_running_claude_processes() { echo "12345 claude"; } + ckipper account sync undo dst --force' + [ "$status" -eq 0 ] +} + # Regression: when the user picks "View changes" then "Apply", the diff # output written by drill_down_loop must NOT pollute the captured action, # else the [[ "$action" == "apply" ]] check downstream silently skips apply. diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index 2153efd..0e590ff 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -56,26 +56,31 @@ _ckipper_account_sync_strategy_fn() { echo "_ckipper_account_sync_${type}_${verb}" } -# Refuse to sync when Claude is running with the destination's config dir. -# Reuses lib/core/keychain.zsh::_core_running_claude_processes and filters by -# whether any line references the destination directory. -# -# Args: $1 — destination dir; $2 — force flag ("true" | "false"). -# Returns: 0 if safe to proceed; 1 if Claude is running on dst (unless force). -# Errors (stderr): a multiline message identifying the running process(es) -# and the suggested launcher command. +# Refuse to sync when any Claude CLI is running. +# +# We previously tried to filter by "Claude running on this destination dir," +# but that requires reading another process's CLAUDE_CONFIG_DIR env var — +# macOS does not expose that to non-privileged callers (`ps -E` is a no-op +# for foreign processes; `pgrep -lx` only shows PID + basename). The only +# reliable signal we have is "is any claude CLI running at all," so we +# refuse on that. Coarser than designed, but the original sync.zsh on +# develop did the same; --force is the documented escape hatch. +# +# Args: $1 — destination dir (kept in the signature for forward +# compatibility once we have a dst-specific signal); $2 — force flag +# ("true" | "false"). +# Returns: 0 if safe to proceed; 1 if any Claude CLI is running (unless force). +# Errors (stderr): multiline message identifying the running process(es). _ckipper_account_sync_assert_dst_idle() { local dst_dir="$1" force="$2" [[ "$force" == "true" ]] && return 0 local procs; procs=$(_core_running_claude_processes 2>/dev/null) [[ -z "$procs" ]] && return 0 - local matching; matching=$(echo "$procs" | grep -F "$dst_dir" || true) - [[ -z "$matching" ]] && return 0 { - echo "Refusing to sync: Claude is running on the destination config dir." - echo "$matching" | sed 's/^/ /' + echo "Refusing to sync: a Claude CLI process is running." + echo "$procs" | sed 's/^/ /' echo "" - echo "Quit the session, or pass --force to override (risk of file races)." + echo "Quit running Claude (or pass --force to override; risk of file races)." } >&2 return 1 } @@ -158,7 +163,7 @@ _ckipper_account_sync_apply_target() { local rc=0 type id display change_status while IFS=$'\t' read -r type id display change_status; do [[ -z "$type" || "$change_status" == "unchanged" ]] && continue - _ckipper_account_sync_apply_one "$type" "$id" "$change_status" || { rc=1; break; } + _ckipper_account_sync_apply_one "$type" "$id" || { rc=1; break; } done if (( rc != 0 )); then _ckipper_account_sync_rollback_target "$backup_dir" "$dst_dir" >&2 @@ -180,15 +185,22 @@ _ckipper_account_sync_apply_target() { # backup dir. Without this, mid-write failures leave the destination # half-written with no manifest record (rollback would skip the file). # -# Args: $1 — type; $2 — id; $3 — change status. +# `op` is derived from whether the live file exists at apply time, NOT +# from change_status. change_status="new" can fire when a sub-key is +# absent from a file that already exists (e.g. adding one MCP server to a +# .claude.json that already has others); recording op=create there would +# make rollback rm-rf the whole file, destroying unrelated data. +# +# Args: $1 — type; $2 — id. # Returns: 0 on success; non-zero on apply failure. _ckipper_account_sync_apply_one() { - local type="$1" id="$2" change_status="$3" + local type="$1" id="$2" local apply_fn; apply_fn=$(_ckipper_account_sync_strategy_fn "$type" apply) local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" [[ "$type" == "prefs" ]] && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } - local op="overwrite"; [[ "$change_status" == "new" ]] && op="create" local rel; rel=$(_ckipper_account_sync_manifest_rel "$type" "$id") + local live; live=$(_ckipper_account_sync_live_path "$type" "${_SYNC_CTX[dst_dir]}" "$rel") + local op="overwrite"; [[ ! -e "$live" ]] && op="create" _ckipper_account_sync_manifest_append "${_SYNC_CTX[backup_dir]}" "$rel" "$op" "$type" "$id" "$apply_fn" "$arg_a" "$arg_b" "$id" "${_SYNC_CTX[backup_dir]}" } diff --git a/lib/account/sync/engine_test.bats b/lib/account/sync/engine_test.bats index c7424d7..c97dfa6 100644 --- a/lib/account/sync/engine_test.bats +++ b/lib/account/sync/engine_test.bats @@ -171,3 +171,76 @@ EOH [[ "$output" == *'"x"'* ]] [[ "$output" == *".ckipper-sync-manifest.json"* ]] } + +# Bug E: change_status="new" (the SUB-KEY is new) used to be conflated with +# op="create" (the FILE didn't exist). When a user added a new MCP server +# to a destination that already had .claude.json with unrelated servers, +# the manifest recorded op=create, and rollback rm-rf'd the entire file — +# wiping unrelated servers. The fix derives op from file existence at apply +# time, so this scenario records op=overwrite and rollback restores from +# backup instead of deleting. +@test "apply_one records op=overwrite when destination file exists pre-apply (Bug E)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"existing":{"command":"y"}},"keep":"this"}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + # change_status='new' but the FILE exists with unrelated content. + printf 'mcp\tgithub\tgithub\tnew\n' \ + | _ckipper_account_sync_apply_target '$src' '$dst' src dst + # The manifest entry must record op=overwrite, NOT create. + jq -r '.files[0].operation' \"\$(ls -d '$dst/.ckipper-sync-backups'/*-from-src)/.ckipper-sync-manifest.json\"" + [[ "$output" == *"overwrite"* ]] + [[ "$output" != *"create"* ]] +} + +# Bug E (companion): ensure op=create still fires when the file is genuinely absent. +@test "apply_one records op=create when destination file is absent pre-apply (Bug E)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + # No .claude.json on dst — file genuinely doesn't exist. + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + printf 'mcp\tgithub\tgithub\tnew\n' \ + | _ckipper_account_sync_apply_target '$src' '$dst' src dst + jq -r '.files[0].operation' \"\$(ls -d '$dst/.ckipper-sync-backups'/*-from-src)/.ckipper-sync-manifest.json\"" + [[ "$output" == *"create"* ]] +} + +# Bug E end-to-end: rollback after a "new sub-key into existing file" sync +# must preserve the destination file's other content. +@test "rollback after new-sub-key sync preserves unrelated dst data (Bug E)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"mcpServers":{"github":{"command":"x"}}}' > "$src/.claude.json" + echo '{"mcpServers":{"existing":{"command":"y"}},"keep":"this"}' > "$dst/.claude.json" + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + TMP_HOME="$TMP_HOME" \ + zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ + printf 'mcp\tgithub\tgithub\tnew\n' \ + | _ckipper_account_sync_apply_target '$src' '$dst' src dst + # Sync succeeded; now run undo via rollback_target. + backup_dir=\$(ls -d '$dst/.ckipper-sync-backups'/*-from-src) + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst' + # The file MUST still exist with the original content intact. + [[ -e '$dst/.claude.json' ]] && echo FILE_PRESENT || echo FILE_DELETED + jq -r '.keep' '$dst/.claude.json' + jq -r '.mcpServers | keys | sort | join(\",\")' '$dst/.claude.json'" + [[ "$output" == *"FILE_PRESENT"* ]] + [[ "$output" == *"this"* ]] + [[ "$output" == *"existing"* ]] + [[ "$output" != *"github"* ]] +} diff --git a/lib/account/sync/strategies/hooks.zsh b/lib/account/sync/strategies/hooks.zsh index 74aa77c..a6e37a0 100644 --- a/lib/account/sync/strategies/hooks.zsh +++ b/lib/account/sync/strategies/hooks.zsh @@ -92,12 +92,25 @@ _ckipper_account_sync_hooks_diff() { # Apply: copy script + write paired settings.hooks entry with rewritten paths. # +# Records the settings.json mutation in the manifest in addition to the +# script file. The engine's apply_one only records the script entry (its +# manifest_rel returns "" for hooks), but hooks_apply ALSO mutates +# settings.json — without an explicit manifest entry, rollback wouldn't +# restore settings.json and would leave dangling .hooks entries pointing +# at scripts that have just been deleted/restored. +# +# Multiple hooks in one sync each append a settings.json entry; rollback +# is idempotent so duplicate entries are harmless (each restore from the +# same backup yields the same pre-sync state). +# # Args: $1 — src; $2 — dst; $3 — relpath; $4 — backup_dir. # Returns: 0 on success; non-zero on cp/jq/write failure. _ckipper_account_sync_hooks_apply() { local src="$1" dst="$2" rel="$3" backup_dir="$4" _ckipper_account_sync_backup_file "$backup_dir" "$dst/$rel" "$rel" || return 1 + local settings_op="overwrite"; [[ -e "$dst/settings.json" ]] || settings_op="create" _ckipper_account_sync_backup_file "$backup_dir" "$dst/settings.json" "settings.json" || return 1 + _ckipper_account_sync_manifest_append "$backup_dir" "settings.json" "$settings_op" hooks "$rel" mkdir -p "$dst/${rel:h}" cp -a "$src/$rel" "$dst/$rel" || return 1 chmod +x "$dst/$rel" 2>/dev/null diff --git a/lib/account/sync/strategies/hooks_test.bats b/lib/account/sync/strategies/hooks_test.bats index 4b630ce..f526d7a 100644 --- a/lib/account/sync/strategies/hooks_test.bats +++ b/lib/account/sync/strategies/hooks_test.bats @@ -91,3 +91,56 @@ JSON [[ "$output" == *"$dst/hooks/lint.sh"* ]] [[ "$output" != *"$src/hooks/lint.sh"* ]] } + +# Bug F: hooks_apply mutates settings.json (adding the paired .hooks entry) +# but the engine's apply_one only recorded a manifest entry for the script +# file (manifest_rel returns "" for hooks, which is the script relpath). +# The settings.json mutation was untracked, so a rollback after a hook sync +# left the destination with a phantom .hooks entry pointing at a deleted +# script. Fix: hooks_apply explicitly appends a settings.json manifest +# entry alongside the script entry. +@test "hooks_apply records settings.json mutation in the manifest (Bug F)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/hooks" "$dst/hooks" + echo "#!/bin/bash" > "$src/hooks/lint.sh" + cat > "$src/settings.json" < "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_hooks_apply '$src' '$dst' hooks/lint.sh \"\$backup_dir\" + jq -r '.files[].path' \"\$backup_dir/.ckipper-sync-manifest.json\" | sort | tr '\n' ','" + [[ "$output" == *"settings.json"* ]] +} + +# Bug F end-to-end: rollback after hook sync restores settings.json — without +# the manifest entry from the fix, rollback would leave the .hooks block +# polluted with the synced entry pointing at a (deleted) script. +@test "rollback after hook sync removes script AND restores settings.json (Bug F)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src/hooks" "$dst/hooks" + echo "#!/bin/bash" > "$src/hooks/lint.sh" + cat > "$src/settings.json" < "$dst/settings.json" + run_in_zsh " + backup_dir=\$(_ckipper_account_sync_backup_create '$dst' src) + _ckipper_account_sync_manifest_init \"\$backup_dir\" src dst + _ckipper_account_sync_hooks_apply '$src' '$dst' hooks/lint.sh \"\$backup_dir\" + # Sanity: post-apply, settings.json has the synced .hooks entry. + jq -r '.hooks.PostToolUse | length' '$dst/settings.json' + # Roll back. + _ckipper_account_sync_rollback_target \"\$backup_dir\" '$dst' + # The script must be gone… + [[ -f '$dst/hooks/lint.sh' ]] && echo SCRIPT_KEPT || echo SCRIPT_GONE + # …and settings.json restored to the pre-sync state (no .hooks block). + jq -r '.keep' '$dst/settings.json' + jq -e '.hooks' '$dst/settings.json' >/dev/null 2>&1 && echo STILL_HOOKED || echo CLEAN" + [[ "$output" == *"SCRIPT_GONE"* ]] + [[ "$output" == *"this"* ]] + [[ "$output" == *"CLEAN"* ]] +} diff --git a/lib/account/sync/strategies/statusline.zsh b/lib/account/sync/strategies/statusline.zsh index c2f589d..89841f0 100644 --- a/lib/account/sync/strategies/statusline.zsh +++ b/lib/account/sync/strategies/statusline.zsh @@ -39,7 +39,9 @@ _ckipper_account_sync_statusline_compare() { local s d s=$(jq -c '.statusLine // null' "$src/settings.json" 2>/dev/null) d=$(jq -c '.statusLine // null' "$dst/settings.json" 2>/dev/null) - if [[ "$d" == "null" ]]; then echo "new"; return 0; fi + # Empty `d` means the destination file is missing entirely (jq exited + # non-zero); see the equivalent guard in `_ckipper_account_sync_mcp_compare`. + if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi echo "overwrite" } diff --git a/lib/account/sync/strategies/statusline_test.bats b/lib/account/sync/strategies/statusline_test.bats index 9c9b966..de01813 100644 --- a/lib/account/sync/strategies/statusline_test.bats +++ b/lib/account/sync/strategies/statusline_test.bats @@ -123,6 +123,23 @@ run_in_zsh() { [[ "$output" == *"my-statusline.sh"* ]] } +# Bug D: statusline_compare lacked the empty-string guard that mcp_compare +# and settings_compare have. When the destination's settings.json was +# missing entirely (jq exits non-zero, d=""), the [[ "$d" == "null" ]] +# branch missed and the function returned "overwrite" instead of "new". +# That mislabeled the preview UI; with Bug E fixed, op-derivation in +# apply_one is independent of compare's verdict, so rollback remained +# correct — but the user-visible label was still wrong. +@test "statusline_compare returns 'new' when destination settings.json is missing (Bug D)" { + local src="$TMP_HOME/src" dst="$TMP_HOME/dst" + mkdir -p "$src" "$dst" + echo '{"statusLine":{"command":"/usr/bin/echo"}}' > "$src/settings.json" + # No settings.json on dst. + run_in_zsh "_ckipper_account_sync_statusline_compare '$src' '$dst' statusLine" + [[ "$output" == *"new"* ]] + [[ "$output" != *"overwrite"* ]] +} + @test "statusline rollback removes orphaned script when op=create" { local src="$TMP_HOME/src" dst="$TMP_HOME/dst" mkdir -p "$src" "$dst" From 8adf336bc10b2aab9f6d7d709992586210f3975b Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 17:02:03 -0600 Subject: [PATCH 17/18] =?UTF-8?q?refactor(sync):=20/simplify=20pass=20?= =?UTF-8?q?=E2=80=94=20extract=20=5Fshared.zsh,=20dedup=20helpers=20(PR=20?= =?UTF-8?q?#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidations driven by a parallel reuse / quality / efficiency review. - Extract lib/account/sync/_shared.zsh with hash_file / hash_dir / diff_line_stats / json_status. Removes the cross-strategy reach where hooks.zsh called _file_hash defined in files_flat.zsh, and the three copies of the new|unchanged|overwrite block in mcp/settings/statusline compare functions. - Refactor run_one_target (28 → 16 body lines) by extracting prepare_target_artifacts. Both helpers under the 25-line cap. - Replace [[ \"\$type\" == \"prefs\" ]] hardcoding (4 sites in engine.zsh + preview.zsh) with the new _CKIPPER_SYNC_TYPE_USES_NAMES registry array — adding a name-typed sync type now requires only a registry entry, not engine + preview edits. - Fix O(N²) preview summary lookup in render_summary — read summaries TSV into an assoc-array once instead of awk-scanning per row. - Replace 2 inline gum-detect blocks in interactive.zsh with _core_prompt_use_gum. - Delete stale §6.1 references in preview.zsh and dispatcher.zsh comments. Test files updated to source _shared.zsh in the same load order as production. 467/467 bats tests pass; make lint clean. --- ckipper.zsh | 5 +- lib/account/sync/_shared.zsh | 66 +++++++++++++++++++ lib/account/sync/dispatcher.zsh | 27 ++++++-- lib/account/sync/engine.zsh | 12 ++-- lib/account/sync/engine_test.bats | 8 +++ lib/account/sync/interactive.zsh | 4 +- lib/account/sync/preview.zsh | 20 +++--- lib/account/sync/registry.zsh | 9 +++ lib/account/sync/strategies/files_dir.zsh | 27 +------- .../sync/strategies/files_dir_test.bats | 1 + lib/account/sync/strategies/files_flat.zsh | 18 +---- .../sync/strategies/files_flat_test.bats | 1 + lib/account/sync/strategies/hooks.zsh | 7 +- lib/account/sync/strategies/hooks_test.bats | 2 +- lib/account/sync/strategies/statusline.zsh | 6 +- .../sync/strategies/statusline_test.bats | 1 + lib/account/sync/strategies/structured.zsh | 13 +--- .../sync/strategies/structured_test.bats | 1 + 18 files changed, 142 insertions(+), 86 deletions(-) create mode 100644 lib/account/sync/_shared.zsh diff --git a/ckipper.zsh b/ckipper.zsh index befb7d6..a395465 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -29,10 +29,11 @@ source "$CKIPPER_REPO_DIR/lib/core/prompt.zsh" source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh" source "$CKIPPER_REPO_DIR/lib/account/cleanup.zsh" source "$CKIPPER_REPO_DIR/lib/account/aliases.zsh" -# Sync subsystem — registry, backup, engine, preview, interactive, -# dispatcher, then the strategy modules. +# Sync subsystem — registry, backup, shared helpers, engine, preview, +# interactive, dispatcher, then the strategy modules. source "$CKIPPER_REPO_DIR/lib/account/sync/registry.zsh" source "$CKIPPER_REPO_DIR/lib/account/sync/backup.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync/_shared.zsh" source "$CKIPPER_REPO_DIR/lib/account/sync/engine.zsh" source "$CKIPPER_REPO_DIR/lib/account/sync/preview.zsh" source "$CKIPPER_REPO_DIR/lib/account/sync/interactive.zsh" diff --git a/lib/account/sync/_shared.zsh b/lib/account/sync/_shared.zsh new file mode 100644 index 0000000..2a3b61b --- /dev/null +++ b/lib/account/sync/_shared.zsh @@ -0,0 +1,66 @@ +#!/usr/bin/env zsh +# Strategy-shared helpers — content hashing, diff stats, JSON status compare. +# +# Sourced from ckipper.zsh BEFORE any strategy module so every strategy can +# call these without a sibling import. Keep this file dependency-free +# (no calls into other sync modules) so the load order stays trivial. + +# Compute sha256 of a file. Uses shasum (macOS- and Linux-friendly). +# +# Args: $1 — file path. +# Returns: 0; prints hex hash, or empty string if path is missing. +_ckipper_account_sync_hash_file() { + local f="$1" + [[ ! -f "$f" ]] && { echo ""; return 0; } + shasum -a 256 "$f" | cut -d' ' -f1 +} + +# Compute a content hash for a directory or symlink. For symlinks: the +# target path. For regular dirs: concatenated sha256 of every file in +# lexical order, hashed once more. +# +# Note: arg variable is `target` (not `path`) — zsh ties lowercase `path` to +# `$PATH` as an array, which corrupts the env if used as a local var. +# +# Args: $1 — path to directory or symlink. +# Returns: 0; prints hex hash, or empty string if path is missing. +_ckipper_account_sync_hash_dir() { + local target="$1" + [[ ! -e "$target" ]] && { echo ""; return 0; } + if [[ -L "$target" ]]; then + readlink "$target" | shasum -a 256 | cut -d' ' -f1 + return 0 + fi + [[ ! -d "$target" ]] && { echo ""; return 0; } + (cd "$target" && find . -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 shasum -a 256 2>/dev/null) \ + | shasum -a 256 | cut -d' ' -f1 +} + +# Run a unified `diff` on two files and emit a "+A/-D" line-stat string. +# Used by file-shaped strategies (files-flat, hooks) for the preview summary. +# +# Args: $1 — destination file (left side); $2 — source file (right side). +# Returns: 0 always (diff exit 1 means "files differ", expected); prints "+A/-D". +_ckipper_account_sync_diff_line_stats() { + diff "$1" "$2" 2>/dev/null \ + | awk 'BEGIN{a=0;d=0} /^>/{a++} /^ "$summaries" _ckipper_account_sync_drill_down_items < "$changeset" > "$items" _ckipper_account_sync_show_preview "$target" "$dst_dir" "$changeset" "$summaries" - local action="apply" - if [[ "$_SYNC_DRY_RUN" != "true" && "$_SYNC_YES" != "true" ]]; then - action=$(_ckipper_account_sync_preview_prompt) - fi - [[ "$_SYNC_DRY_RUN" == "true" ]] && action="dry-run" - _ckipper_account_sync_finalize "$action" } -# Render the §6.1 preview block — header, summary table, change count. +# Render the preview block — header, summary table, change count. # # Args: $1 — target; $2 — dst_dir; $3 — changeset file; $4 — summaries file. # Returns: 0 always. diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index 0e590ff..8e75db8 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -117,7 +117,8 @@ _ckipper_account_sync_build_change_set() { done } -# Walk a single type's items. prefs uses account names instead of dirs. +# Walk a single type's items. Types in _CKIPPER_SYNC_TYPE_USES_NAMES use +# account names instead of dirs. # # Args: $1 — type; $2 — src_dir; $3 — dst_dir; $4 — src_name; $5 — dst_name. # Returns: 0 always; prints rows. @@ -127,7 +128,7 @@ _ckipper_account_sync_walk_type() { enumerate_fn=$(_ckipper_account_sync_strategy_fn "$type" enumerate) compare_fn=$(_ckipper_account_sync_strategy_fn "$type" compare) local arg_a="$src_dir" arg_b="$dst_dir" - [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="$src_name"; arg_b="$dst_name"; } local id display change_status while IFS=$'\t' read -r id display; do [[ -z "$id" ]] && continue @@ -173,7 +174,8 @@ _ckipper_account_sync_apply_target() { } # Apply one change set entry. Bridges between the strategy contract and -# the manifest schema. prefs uses names; everything else uses dirs. +# the manifest schema. Types in _CKIPPER_SYNC_TYPE_USES_NAMES use names; +# everything else uses dirs. # # Reads src_dir/dst_dir/src_name/dst_name/backup_dir from _SYNC_CTX (set # by apply_target). Keeping these in context drops the parameter count @@ -197,7 +199,7 @@ _ckipper_account_sync_apply_one() { local type="$1" id="$2" local apply_fn; apply_fn=$(_ckipper_account_sync_strategy_fn "$type" apply) local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" - [[ "$type" == "prefs" ]] && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } local rel; rel=$(_ckipper_account_sync_manifest_rel "$type" "$id") local live; live=$(_ckipper_account_sync_live_path "$type" "${_SYNC_CTX[dst_dir]}" "$rel") local op="overwrite"; [[ ! -e "$live" ]] && op="create" @@ -233,7 +235,7 @@ _ckipper_account_sync_build_summaries() { [[ -z "$type" || "$change_status" == "unchanged" ]] && continue summary_fn=$(_ckipper_account_sync_strategy_fn "$type" summary) arg_a="$src_dir"; arg_b="$dst_dir" - [[ "$type" == "prefs" ]] && { arg_a="$src_name"; arg_b="$dst_name"; } + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="$src_name"; arg_b="$dst_name"; } summary=$("$summary_fn" "$arg_a" "$arg_b" "$id") echo "$type"$'\t'"$id"$'\t'"$summary" done diff --git a/lib/account/sync/engine_test.bats b/lib/account/sync/engine_test.bats index c97dfa6..f6dc655 100644 --- a/lib/account/sync/engine_test.bats +++ b/lib/account/sync/engine_test.bats @@ -89,6 +89,7 @@ EOH TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ _ckipper_account_sync_build_change_set '$src' '$dst' src dst mcp" [[ "$output" == *"mcp"$'\t'"github"$'\t'* ]] @@ -104,6 +105,7 @@ EOH TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tgithub\tgithub\toverwrite\n' \ | _ckipper_account_sync_build_summaries '$src' '$dst' src dst" @@ -120,6 +122,7 @@ EOH TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tunchanged-srv\tunchanged-srv\tunchanged\n' \ | _ckipper_account_sync_build_summaries '$src' '$dst' src dst | wc -l | tr -d ' '" @@ -137,6 +140,7 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ # Override mcp_apply to simulate a crashing strategy. _ckipper_account_sync_mcp_apply() { @@ -163,6 +167,7 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tgithub\tgithub\tnew\n' \ | _ckipper_account_sync_apply_target '$src' '$dst' src dst @@ -189,6 +194,7 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ # change_status='new' but the FILE exists with unrelated content. printf 'mcp\tgithub\tgithub\tnew\n' \ @@ -210,6 +216,7 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tgithub\tgithub\tnew\n' \ | _ckipper_account_sync_apply_target '$src' '$dst' src dst @@ -229,6 +236,7 @@ EOH zsh -c "source \"$REPO_ROOT/lib/account/sync/registry.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/engine.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ printf 'mcp\tgithub\tgithub\tnew\n' \ | _ckipper_account_sync_apply_target '$src' '$dst' src dst diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh index 455435f..de851c0 100644 --- a/lib/account/sync/interactive.zsh +++ b/lib/account/sync/interactive.zsh @@ -49,7 +49,7 @@ _ckipper_account_sync_pick_targets() { echo "No other accounts to sync to." >&2 return 1 fi - if [[ "$CKIPPER_NO_GUM" != "1" ]] && command -v gum >/dev/null 2>&1; then + if _core_prompt_use_gum; then printf '%s\n' "${candidates[@]}" | gum choose --no-limit --header "Sync TO which accounts? (space to multi-select)" return $? fi @@ -79,7 +79,7 @@ _ckipper_account_sync_pick_types() { for t in "${(@k)_CKIPPER_SYNC_TYPE_LABEL}"; do labels+=("$t — ${_CKIPPER_SYNC_TYPE_LABEL[$t]}") done - if [[ "$CKIPPER_NO_GUM" != "1" ]] && command -v gum >/dev/null 2>&1; then + if _core_prompt_use_gum; then printf '%s\n' "${labels[@]}" \ | gum choose --no-limit --header "Pick types to sync (space to multi-select)" \ | awk '{print $1}' diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh index 3e4199e..9f69ebd 100644 --- a/lib/account/sync/preview.zsh +++ b/lib/account/sync/preview.zsh @@ -36,7 +36,7 @@ _ckipper_account_sync_render_row() { } # Render the summary table. Reads a change-set on stdin (TSV rows), groups -# by type, and prints the §6.1 layout to stdout. The 4th positional arg +# by type, and prints the preview layout to stdout. The 4th positional arg # is a path to a precomputed summaries file (one "type\tid\tsummary" per # line) — built by the engine before this is called so we don't re-call # every strategy's summary function inside the renderer. @@ -45,22 +45,24 @@ _ckipper_account_sync_render_row() { # Returns: 0 always. _ckipper_account_sync_render_summary() { local src_name="$1" dst_name="$2" backup_dir="$3" summaries="$4" + local -A summary_map + if [[ -f "$summaries" ]]; then + local s_type s_id s_text + while IFS=$'\t' read -r s_type s_id s_text; do + summary_map["${s_type}"$'\t'"${s_id}"]="$s_text" + done < "$summaries" + fi echo "" echo "Sync $src_name → $dst_name" _ckipper_account_sync_print_divider - local current_type="" - local type id display change_status + local current_type="" type id display change_status while IFS=$'\t' read -r type id display change_status; do [[ -z "$type" ]] && continue if [[ "$type" != "$current_type" ]]; then echo " ${_CKIPPER_SYNC_TYPE_LABEL[$type]:-$type}" current_type="$type" fi - local summary="" - if [[ -f "$summaries" ]]; then - summary=$(awk -F'\t' -v t="$type" -v i="$id" '$1==t && $2==i {print $3; exit}' "$summaries") - fi - _ckipper_account_sync_render_row "$change_status" "$display" "$summary" + _ckipper_account_sync_render_row "$change_status" "$display" "${summary_map["${type}"$'\t'"${id}"]}" done _ckipper_account_sync_print_divider echo "Backup → $backup_dir" @@ -143,7 +145,7 @@ _ckipper_account_sync_drill_down_show() { local id; id=$(_ckipper_account_sync_drill_down_resolve_id "$items_file" "$type" "$display") local diff_fn; diff_fn=$(_ckipper_account_sync_strategy_fn "$type" diff) local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" - [[ "$type" == "prefs" ]] && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } "$diff_fn" "$arg_a" "$arg_b" "$id" } diff --git a/lib/account/sync/registry.zsh b/lib/account/sync/registry.zsh index 5ef34c0..6bf84d4 100644 --- a/lib/account/sync/registry.zsh +++ b/lib/account/sync/registry.zsh @@ -33,6 +33,15 @@ typeset -gA _CKIPPER_SYNC_TYPE_KIND=( [statusline]=special [hooks]=special ) +# Types whose strategy functions take account NAMES (not dirs) as their +# first two positional args. Currently only `prefs`, which operates on the +# registry (CKIPPER_REGISTRY) keyed by account name. Engine and preview +# consult this set to pick the right (a, b) pair when invoking strategy +# functions; without it they would have to hard-code "prefs" semantics. +typeset -gA _CKIPPER_SYNC_TYPE_USES_NAMES=( + [prefs]=1 +) + # Space-separated list of bundles the type belongs to. Bundles are aliases # users may pass to --include / --exclude (see _ckipper_account_sync_resolve_*). typeset -gA _CKIPPER_SYNC_TYPE_BUNDLES=( diff --git a/lib/account/sync/strategies/files_dir.zsh b/lib/account/sync/strategies/files_dir.zsh index 97a18de..5f1c36f 100644 --- a/lib/account/sync/strategies/files_dir.zsh +++ b/lib/account/sync/strategies/files_dir.zsh @@ -16,29 +16,6 @@ typeset -gA _CKIPPER_SYNC_FILES_DIR_PATH=( [skills]="skills" ) -# Compute a content hash for a directory or symlink. For symlinks: the -# target path. For regular dirs: concatenated sha256 of every file in -# lexical order, hashed once more. -# -# Note: arg variable is `target` (not `path`) — zsh ties lowercase `path` to -# `$PATH` as an array, which corrupts the env if used as a local var. -# -# Args: $1 — path to directory or symlink. -# Returns: 0; prints hex hash or empty if path is missing. -_ckipper_account_sync_dir_hash() { - local target="$1" - [[ ! -e "$target" ]] && { echo ""; return 0; } - if [[ -L "$target" ]]; then - readlink "$target" | shasum -a 256 | cut -d' ' -f1 - return 0 - fi - [[ ! -d "$target" ]] && { echo ""; return 0; } - (cd "$target" && find . -type f -print0 2>/dev/null \ - | sort -z \ - | xargs -0 shasum -a 256 2>/dev/null) \ - | shasum -a 256 | cut -d' ' -f1 -} - # Enumerate top-level items under the type's subdir. Picks up dirs AND # symlinks. We use a manual loop so broken symlinks also enumerate, with # apply later catching the failure. @@ -65,8 +42,8 @@ _ckipper_account_sync_files_dir_compare() { local type="$1" src="$2" dst="$3" rel="$4" [[ ! -e "$dst/$rel" ]] && { echo "new"; return 0; } local sh dh - sh=$(_ckipper_account_sync_dir_hash "$src/$rel") - dh=$(_ckipper_account_sync_dir_hash "$dst/$rel") + sh=$(_ckipper_account_sync_hash_dir "$src/$rel") + dh=$(_ckipper_account_sync_hash_dir "$dst/$rel") [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } diff --git a/lib/account/sync/strategies/files_dir_test.bats b/lib/account/sync/strategies/files_dir_test.bats index 4d0c227..eb79d09 100644 --- a/lib/account/sync/strategies/files_dir_test.bats +++ b/lib/account/sync/strategies/files_dir_test.bats @@ -9,6 +9,7 @@ teardown() { teardown_isolated_env; } run_in_zsh() { run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/files_dir.zsh\"; $*" } diff --git a/lib/account/sync/strategies/files_flat.zsh b/lib/account/sync/strategies/files_flat.zsh index 614e086..0db33cd 100644 --- a/lib/account/sync/strategies/files_flat.zsh +++ b/lib/account/sync/strategies/files_flat.zsh @@ -20,17 +20,6 @@ typeset -gA _CKIPPER_SYNC_FILES_FLAT_PATH=( [output-styles]="output_styles" ) -# Compute sha256 of a file. Uses shasum (macOS-friendly) which is available -# in both macOS and Linux containers. Empty stdout if file missing. -# -# Args: $1 — file path. -# Returns: 0; prints hex hash or empty string. -_ckipper_account_sync_file_hash() { - local f="$1" - [[ ! -f "$f" ]] && { echo ""; return 0; } - shasum -a 256 "$f" | cut -d' ' -f1 -} - # Generic enumerator. Lists items as "\t". # - For claude-md: a single line iff CLAUDE.md exists. # - For others: every *.md file under the subdir. @@ -61,8 +50,8 @@ _ckipper_account_sync_files_flat_compare() { local type="$1" src="$2" dst="$3" rel="$4" [[ ! -f "$dst/$rel" ]] && { echo "new"; return 0; } local sh dh - sh=$(_ckipper_account_sync_file_hash "$src/$rel") - dh=$(_ckipper_account_sync_file_hash "$dst/$rel") + sh=$(_ckipper_account_sync_hash_file "$src/$rel") + dh=$(_ckipper_account_sync_hash_file "$dst/$rel") [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } @@ -75,8 +64,7 @@ _ckipper_account_sync_files_flat_summary() { local type="$1" src="$2" dst="$3" rel="$4" local cmp_status; cmp_status=$(_ckipper_account_sync_files_flat_compare "$type" "$src" "$dst" "$rel") [[ "$cmp_status" != "overwrite" ]] && { echo "$cmp_status"; return 0; } - local stats; stats=$(diff "$dst/$rel" "$src/$rel" 2>/dev/null \ - | awk 'BEGIN{a=0;d=0} /^>/{a++} /^/dev/null) - dh=$(_ckipper_account_sync_file_hash "$dst/$rel" 2>/dev/null) + sh=$(_ckipper_account_sync_hash_file "$src/$rel" 2>/dev/null) + dh=$(_ckipper_account_sync_hash_file "$dst/$rel" 2>/dev/null) [[ "$sh" == "$dh" ]] && { echo "unchanged"; return 0; } echo "overwrite" } @@ -74,8 +74,7 @@ _ckipper_account_sync_hooks_summary() { case "$cmp_status" in new) echo "new — paired with settings.hooks entry" ;; overwrite) - local stats; stats=$(diff "$dst/$rel" "$src/$rel" 2>/dev/null \ - | awk 'BEGIN{a=0;d=0} /^>/{a++} /^/dev/null) d=$(jq -c '.statusLine // null' "$dst/settings.json" 2>/dev/null) - # Empty `d` means the destination file is missing entirely (jq exited - # non-zero); see the equivalent guard in `_ckipper_account_sync_mcp_compare`. - if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi - if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi - echo "overwrite" + _ckipper_account_sync_json_status "$s" "$d" } # Resolve and detect whether the referenced script lives under /. diff --git a/lib/account/sync/strategies/statusline_test.bats b/lib/account/sync/strategies/statusline_test.bats index de01813..9d78b94 100644 --- a/lib/account/sync/strategies/statusline_test.bats +++ b/lib/account/sync/strategies/statusline_test.bats @@ -9,6 +9,7 @@ teardown() { teardown_isolated_env; } run_in_zsh() { run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/statusline.zsh\"; $*" } diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh index 5489057..af07aaa 100644 --- a/lib/account/sync/strategies/structured.zsh +++ b/lib/account/sync/strategies/structured.zsh @@ -65,12 +65,7 @@ _ckipper_account_sync_mcp_compare() { local s d s=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$src/.claude.json" 2>/dev/null) d=$(jq -c --arg n "$name" '.mcpServers[$n] // null' "$dst/.claude.json" 2>/dev/null) - # Empty `d` means the destination file is missing entirely (jq exited - # non-zero); treat the same as "key absent" so the manifest records `op=create` - # and rollback knows to delete (not restore-overwrite) the new file. - if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi - if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi - echo "overwrite" + _ckipper_account_sync_json_status "$s" "$d" } # One-line summary of the change for the preview table. @@ -155,11 +150,7 @@ _ckipper_account_sync_settings_compare() { local s d s=$(jq -c "$jq_path // null" "$src/settings.json" 2>/dev/null) d=$(jq -c "$jq_path // null" "$dst/settings.json" 2>/dev/null) - # Empty `d` means the destination file is missing entirely; see the - # equivalent guard in `_ckipper_account_sync_mcp_compare` for rationale. - if [[ -z "$d" || "$d" == "null" ]]; then echo "new"; return 0; fi - if [[ "$s" == "$d" ]]; then echo "unchanged"; return 0; fi - echo "overwrite" + _ckipper_account_sync_json_status "$s" "$d" } # Convert a dotted id like "permissions.allow" into a jq filter ".permissions.allow". diff --git a/lib/account/sync/strategies/structured_test.bats b/lib/account/sync/strategies/structured_test.bats index c31d534..736afed 100644 --- a/lib/account/sync/strategies/structured_test.bats +++ b/lib/account/sync/strategies/structured_test.bats @@ -10,6 +10,7 @@ run_in_zsh() { run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ TMP_HOME="$TMP_HOME" \ zsh -c "source \"$REPO_ROOT/lib/account/sync/backup.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/_shared.zsh\"; \ source \"$REPO_ROOT/lib/account/sync/strategies/structured.zsh\"; $*" } From ea2427c1eed9f1674cf562d38e90cb777d1c1cc9 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 17:59:58 -0600 Subject: [PATCH 18/18] fix(sync): fifth-round code-review bug fixes (PR #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings strategy: enumerator now also excludes `.statusLine.*` paths (in addition to `.hooks`). Without this, `--include settings` (or `--include claude-config`) silently planted the source's absolute `statusLine.command` path on the destination, breaking the destination's statusline. The dedicated `statusline` strategy owns path-rewrite + internal-script copy, so it must be the only enumerator for that subtree. Updated `structured_test.bats` to assert exclusion. - preview: extract magic number 26 → `_CKIPPER_SYNC_DISPLAY_COL_WIDTH`, matching the existing `_CKIPPER_SYNC_DIVIDER_WIDTH=45` pattern. - rename sweep cleanup: update stale `sync-hooks` → `redeploy-hooks` references missed in the original rename — `install.sh` (echo + comments), `doctor.zsh` WARN message, and `templates/settings-template.json` comment. --- install.sh | 6 +++--- lib/account/doctor.zsh | 2 +- lib/account/sync/preview.zsh | 6 ++++-- lib/account/sync/strategies/structured.zsh | 7 ++++++- lib/account/sync/strategies/structured_test.bats | 10 +++++----- templates/settings-template.json | 2 +- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/install.sh b/install.sh index 659875a..ddff32d 100755 --- a/install.sh +++ b/install.sh @@ -41,7 +41,7 @@ chmod +x "$CKIPPER_DIR/docker/entrypoint.sh" chmod +x "$CKIPPER_DIR/docker/init-firewall.sh" chmod +x "$CKIPPER_DIR/docker/cleanup-projects.py" -# 3. Copy hooks (canonical source for ckipper account sync-hooks) +# 3. Copy hooks (canonical source for ckipper account redeploy-hooks) echo "Copying hooks to $CKIPPER_DIR/hooks/..." mkdir -p "$CKIPPER_DIR/hooks" cp "$REPO_DIR/hooks/protect-claude-config.sh" "$CKIPPER_DIR/hooks/" @@ -118,10 +118,10 @@ fi [[ -f "$CKIPPER_DIR/accounts.json" ]] && echo " accounts.json already exists (not overwritten — managed by ckipper)" [[ -f "$CKIPPER_DIR/aliases.zsh" ]] && echo " aliases.zsh already exists (not overwritten — auto-generated)" -# 6. Deploy settings-template.json (consumed by ckipper account add / sync-hooks per-account) +# 6. Deploy settings-template.json (consumed by ckipper account add / redeploy-hooks per-account) echo "Copying settings-template.json to $CKIPPER_DIR/..." cp "$REPO_DIR/templates/settings-template.json" "$CKIPPER_DIR/settings-template.json" -echo " Settings template deployed. ckipper account sync-hooks applies it per-account." +echo " Settings template deployed. ckipper account redeploy-hooks applies it per-account." # 7. Add or update source line in .zshrc # Pre-merge installs sourced w-function.zsh from ~/.claude/docker/ or diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index 4d65813..70aaebf 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -413,7 +413,7 @@ _ckipper_doctor_account() { _ckipper_doctor_check WARN " .claude.json missing in $dir" fi if [[ -f "$dir/settings.json" ]]; then _ckipper_doctor_check PASS " settings.json present"; else _ckipper_doctor_check WARN " settings.json missing"; fi - if [[ -d "$dir/hooks" ]]; then _ckipper_doctor_check PASS " hooks/ deployed"; else _ckipper_doctor_check WARN " hooks/ missing — run: ckipper account sync-hooks"; fi + if [[ -d "$dir/hooks" ]]; then _ckipper_doctor_check PASS " hooks/ deployed"; else _ckipper_doctor_check WARN " hooks/ missing — run: ckipper account redeploy-hooks"; fi _ckipper_doctor_account_plugins "$name" "$dir" _ckipper_doctor_account_keychain "$svc" "$name" } diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh index 9f69ebd..1b32599 100644 --- a/lib/account/sync/preview.zsh +++ b/lib/account/sync/preview.zsh @@ -9,6 +9,7 @@ readonly _CKIPPER_SYNC_BADGE_NEW="[+]" readonly _CKIPPER_SYNC_BADGE_OVERWRITE="[~]" readonly _CKIPPER_SYNC_DIVIDER_WIDTH=45 +readonly _CKIPPER_SYNC_DISPLAY_COL_WIDTH=26 # Per-target context (declared here too because preview_test.bats sources # only this module). See engine.zsh for the full key list. Re-declaration @@ -28,9 +29,10 @@ _ckipper_account_sync_print_divider() { # Returns: 0; suppresses unchanged rows. _ckipper_account_sync_render_row() { local cmp_status="$1" display="$2" summary="$3" + local w="$_CKIPPER_SYNC_DISPLAY_COL_WIDTH" case "$cmp_status" in - new) printf ' %s %-26s (%s)\n' "$_CKIPPER_SYNC_BADGE_NEW" "$display" "${summary:-new}" ;; - overwrite) printf ' %s %-26s (%s)\n' "$_CKIPPER_SYNC_BADGE_OVERWRITE" "$display" "${summary:-overwrite}" ;; + new) printf ' %s %-*s (%s)\n' "$_CKIPPER_SYNC_BADGE_NEW" "$w" "$display" "${summary:-new}" ;; + overwrite) printf ' %s %-*s (%s)\n' "$_CKIPPER_SYNC_BADGE_OVERWRITE" "$w" "$display" "${summary:-overwrite}" ;; unchanged) ;; esac } diff --git a/lib/account/sync/strategies/structured.zsh b/lib/account/sync/strategies/structured.zsh index af07aaa..6bbe172 100644 --- a/lib/account/sync/strategies/structured.zsh +++ b/lib/account/sync/strategies/structured.zsh @@ -118,7 +118,11 @@ _ckipper_account_sync_mcp_apply() { # their leaf paths so the user can sync just `.permissions.allow` without # touching `.permissions.deny`. # -# Excludes the `.hooks` block — the user-hooks sync type owns it. +# Excludes the `.hooks` block (the user-hooks sync type owns it) and the +# `.statusLine` subtree (the statusline sync type owns it — the dedicated +# strategy handles internal-script copy + path rewrite, which `settings` +# cannot do, so enumerating it here would silently plant broken absolute +# paths on the destination when sync runs without `statusline` included). # # Args: $1 — source account dir. # Returns: 0; prints "\t" per line. @@ -136,6 +140,7 @@ _ckipper_account_sync_settings_enumerate() { | select(($root | getpath($p)) | type != "object") | ($p | map(tostring) | join(".")) as $k | select($k | startswith("hooks") | not) + | select($k | startswith("statusLine") | not) | "\($k)\t\($k)" ' "$file" 2>/dev/null } diff --git a/lib/account/sync/strategies/structured_test.bats b/lib/account/sync/strategies/structured_test.bats index 736afed..858f9da 100644 --- a/lib/account/sync/strategies/structured_test.bats +++ b/lib/account/sync/strategies/structured_test.bats @@ -116,20 +116,20 @@ run_in_zsh() { @test "settings_enumerate emits top-level keys" { local src="$TMP_HOME/src" mkdir -p "$src" - echo '{"statusLine":{"command":"x"},"env":{"FOO":"1"},"model":"opus"}' > "$src/settings.json" + echo '{"env":{"FOO":"1"},"model":"opus"}' > "$src/settings.json" run_in_zsh "_ckipper_account_sync_settings_enumerate '$src' | cut -f1 | sort | tr '\n' ','" [[ "$output" == *"env.FOO"* ]] [[ "$output" == *"model"* ]] - [[ "$output" == *"statusLine.command"* ]] } -@test "settings_enumerate excludes the .hooks block" { +@test "settings_enumerate excludes .hooks and .statusLine (owned by other strategies)" { local src="$TMP_HOME/src" mkdir -p "$src" - echo '{"statusLine":{"command":"x"},"hooks":{"PreToolUse":[]}}' > "$src/settings.json" + echo '{"statusLine":{"command":"x"},"hooks":{"PreToolUse":[]},"model":"opus"}' > "$src/settings.json" run_in_zsh "_ckipper_account_sync_settings_enumerate '$src' | cut -f1" [[ "$output" != *"hooks"* ]] - [[ "$output" == *"statusLine"* ]] + [[ "$output" != *"statusLine"* ]] + [[ "$output" == *"model"* ]] } @test "settings_enumerate produces nested jq paths for object-typed values" { diff --git a/templates/settings-template.json b/templates/settings-template.json index 88a5e6e..96bf70c 100644 --- a/templates/settings-template.json +++ b/templates/settings-template.json @@ -1,5 +1,5 @@ { - "_comment": "Per-account settings.json template. Copied verbatim by ckipper account add; hook paths are rewritten to the account dir by ckipper account sync-hooks.", + "_comment": "Per-account settings.json template. Copied verbatim by ckipper account add; hook paths are rewritten to the account dir by ckipper account redeploy-hooks.", "hooks": { "PreToolUse": [ {