diff --git a/lib/config/dispatcher_test.bats b/lib/config/dispatcher_test.bats index 1045ea5..f5b9567 100644 --- a/lib/config/dispatcher_test.bats +++ b/lib/config/dispatcher_test.bats @@ -78,6 +78,42 @@ _run_config_dispatch() { [ "$status" -ne 0 ] } +# Regression: when `ckipper config set ` is invoked WITHOUT a value, +# the handler prompts via _core_prompt_input. Cancellation (Esc/Ctrl-C on +# gum, EOF on the read fallback) must abort the write rather than commit +# an empty value — otherwise the user's only out is to silently blank the +# key. _core_prompt_input now returns non-zero on cancel; this test pins +# the abort-on-cancel behavior in `_ckipper_config_set`. +@test "config set aborts the write when the value prompt is cancelled" { + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_REGISTRY_VERSION="${CKIPPER_REGISTRY_VERSION:-2}" \ + CKIPPER_NO_GUM=1 \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/schema.zsh\" + source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/core/style.zsh\" + source \"$REPO_ROOT/lib/core/help.zsh\" + source \"$REPO_ROOT/lib/core/prompt.zsh\" + source \"$REPO_ROOT/lib/config/set.zsh\" + source \"$REPO_ROOT/lib/config/get.zsh\" + _ckipper_config_set default_branch 2>/dev/null + echo \"set_rc=\$?\" + _ckipper_config_get default_branch + " /dev/null || return 1; } if [[ "$_CKIPPER_CONFIG_SET_HAS_VALUE" != "true" ]]; then local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" - value=$(_core_prompt_input "$prompt_label" "") + # Cancellation must abort the write, not commit an empty value. + # _core_prompt_input returns non-zero on Esc/Ctrl-C; without this + # check the user pressing cancel would silently blank the key. + if ! value=$(_core_prompt_input "$prompt_label" ""); then + return 1 + fi fi _core_config_set "$key" "$value" "$account" } diff --git a/lib/core/prompt.zsh b/lib/core/prompt.zsh index f6ccf9f..df7f0ba 100644 --- a/lib/core/prompt.zsh +++ b/lib/core/prompt.zsh @@ -25,18 +25,27 @@ _core_prompt_use_gum() { # Prompt for a free-form string. Returns the entered value, or the supplied # default when input is empty. # +# Distinguishes "user submitted empty" (rc=0, value=default) from "user +# cancelled" (rc != 0, no stdout). `gum input` exits non-zero on Esc/Ctrl-C, +# and we propagate that so callers can distinguish cancellation. Previously +# we collapsed both into "echo default", which meant a cancelled launcher +# branch prompt silently created a worktree on the default branch name. +# # Args: $1 — label shown to the user; $2 — default value used on empty input. -# Returns: 0 always; prints the resolved value to stdout. +# Returns: 0 on submit (including empty submit); non-zero on cancellation. +# Prints the resolved value to stdout on success; nothing on cancel. _core_prompt_input() { local label="$1" default="$2" if _core_prompt_use_gum; then - local out + local out rc out=$(gum input --placeholder "$default" --prompt "$label > ") + rc=$? + (( rc != 0 )) && return $rc echo "${out:-$default}" return 0 fi local val="" - read -r "val?$label [$default]: " + read -r "val?$label [$default]: " || return $? echo "${val:-$default}" } @@ -92,20 +101,3 @@ _core_prompt_choose() { (( choice >= 1 && choice <= $# )) || return 1 echo "${@[choice]}" } - -# Run a command with a spinner indicator. Forwards the command's exit status. -# -# Args: $1 — label shown alongside the spinner; $2..$N — command and args to -# execute. The label is consumed by this function and never reaches the -# wrapped command. -# Returns: the exit status of the wrapped command. -_core_prompt_spin() { - local label="$1" - shift - if _core_prompt_use_gum; then - gum spin --spinner dot --title "$label" -- "$@" - return $? - fi - echo "$label..." >&2 - "$@" -} diff --git a/lib/core/prompt_test.bats b/lib/core/prompt_test.bats index 814f171..0618e2f 100644 --- a/lib/core/prompt_test.bats +++ b/lib/core/prompt_test.bats @@ -46,6 +46,20 @@ _run_prompt() { [ "$output" = "thedefault" ] } +# Regression: previously cancellation (EOF / Ctrl-C / Esc on the gum form) +# was indistinguishable from "user submitted empty" because both echoed +# the default. The launcher's branch prompt then created a worktree on +# `feature/dev` even when the user pressed Ctrl-X to back out. The fix +# propagates rc from gum / read so callers can distinguish cancel via rc. +@test "_core_prompt_input returns non-zero with no stdout when read sees EOF" { + run env CKIPPER_NO_GUM=1 PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/prompt.zsh\"; \ + _core_prompt_input \"Q\" \"thedefault\" 2>/dev/null" `, `~/Developer//`, -# `~/Developer///`) without scanning entire user homedirs. -readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=3 +# `find -maxdepth` value for project discovery. The repository's `.git` +# directory sits one level below the repo root, so to surface +# `////.git` we need depth 4. Anything +# deeper than that is almost always a vendored sub-repo or a demo project, +# so depth 4 is the sweet spot between coverage and scan time. +readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=4 # Print the launcher banner: a styled "Ckipper" header, the product tagline, # and a trailing blank line that separates the banner from whatever prompt or diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index b408e29..229e9c0 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -4,8 +4,7 @@ # # Depends on: # - lib/core/style.zsh (`_core_style_header`) -# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`, -# `_core_prompt_spin`) +# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`) # - lib/setup/prereqs.zsh (`_ckipper_setup_prereqs`) # - lib/setup/prompts.zsh (`_ckipper_setup_prompts_*`) # - lib/setup/apply.zsh (`_ckipper_setup_apply_global`, @@ -37,10 +36,27 @@ _ckipper_setup() { echo "Using current values." fi _ckipper_setup_offer_account + _ckipper_setup_offer_existing_sync _ckipper_setup_offer_image_build _ckipper_setup_print_completion_summary } +# Offer a between-accounts sync when the user has 2+ accounts already and +# the wizard's add-account step did not just run one (the post-add path +# already offers the sync inline). Without this, a re-run of `ckipper setup` +# on an established multi-account install never surfaces the sync feature. +# +# Returns: 0 always. +_ckipper_setup_offer_existing_sync() { + local count + count=$(jq -r '.accounts | length' "$CKIPPER_REGISTRY" 2>/dev/null || echo 0) + (( count < 2 )) && return 0 + if ! _core_prompt_confirm "Sync settings between two existing accounts?"; then + return 0 + fi + _ckipper_account_sync_dispatch +} + # Print the post-setup hint block: review-settings command, two ways to launch # Claude (per-account aliases or `ckipper run`), and the bare-`ck` menu. # Extracted so `_ckipper_setup` stays under the 25-line cap. @@ -89,10 +105,16 @@ _ckipper_setup_run_customize_loop() { local -a picked picked=( ${(f)"$(_ckipper_setup_prompts_pick_keys)"} ) typeset -A updates - local key + local key value for key in "${picked[@]}"; do [[ -z "$key" ]] && continue - updates[$key]=$(_ckipper_setup_prompts_one_key "$key") + # If the per-key prompt returns non-zero the user cancelled it + # (Esc/Ctrl-C on gum). Skip the key rather than writing an empty + # override, which would silently blank out the value. + if ! value=$(_ckipper_setup_prompts_one_key "$key"); then + continue + fi + updates[$key]="$value" done _ckipper_setup_apply_global updates } @@ -128,7 +150,9 @@ _ckipper_setup_offer_account() { # Returns: 0 always (per-step failures are surfaced via the underlying calls). _ckipper_setup_add_account() { local name - name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME") + if ! name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME"); then + return 0 + fi _ckipper_account_add "$name" || return 0 typeset -A prefs _ckipper_setup_collect_account_prefs "$name" @@ -189,13 +213,16 @@ _ckipper_setup_collect_account_prefs() { "Forward host ~/.ssh into '$account' containers?" } -# Offer to build/rebuild the ckipper-dev Docker image now. Wraps the build in -# a gum spinner when available; the underlying helper streams docker output -# directly when running without gum. +# Offer to build/rebuild the ckipper-dev Docker image now. +# +# We invoke the build helper directly rather than wrapping it in a spinner. +# `gum spin -- ` execs its argv as a binary, so passing a shell function +# fails with "executable file not found in $PATH". The build also streams +# its own progress over ~5 min, which the user wants to see. # # Returns: 0 always. _ckipper_setup_offer_image_build() { if _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then - _core_prompt_spin "Building ckipper-dev image" _ckipper_worktree_build_image + _ckipper_worktree_build_image fi } diff --git a/lib/setup/dispatcher_test.bats b/lib/setup/dispatcher_test.bats index 9f8ebd4..bce5545 100644 --- a/lib/setup/dispatcher_test.bats +++ b/lib/setup/dispatcher_test.bats @@ -116,3 +116,54 @@ JSON [ "$status" -eq 0 ] [[ "$output" == *"STUB-BUILD"* ]] } + +# Regression: setup previously offered cross-account sync only after the +# user added a NEW account in the wizard. A user with 2+ existing accounts +# who declined "Add another?" never saw the sync feature surfaced. The +# behavioral signal we can assert (prompts written by zsh's `read "ans?…"` +# are suppressed when stdin is non-TTY, so we can't grep the label) is that +# the dispatch helper IS invoked on accept and SKIPPED otherwise. +@test "_ckipper_setup_offer_existing_sync skips when fewer than 2 accounts" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{"a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync invokes sync_dispatch on yes" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" == *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync skips sync_dispatch on no" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'n\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index a6199c9..f988f3a 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -30,6 +30,12 @@ readonly _CKIPPER_SETUP_PROMPTS_NO_GUM_SENTINEL="1" # Header rendered above the summary table. readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" +# Header for the multi-select picker. Mentions SPACE explicitly because the +# preceding y/N "customize?" prompt and the source-account picker are both +# single-select Enter — without the hint, users press Enter on the first +# row and silently advance with no overrides. +readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" + # Pipe-separated row builder for the summary table. Resolves the effective # value via _core_config_get and the source marker via _core_config_read_global # (empty return ⇒ default; otherwise ⇒ user override). @@ -69,12 +75,19 @@ _ckipper_setup_prompts_global_keys() { # Returns: 0 always. _ckipper_setup_prompts_summary() { _core_style_header "$_CKIPPER_SETUP_PROMPTS_HEADER" - local key - { - while IFS= read -r key; do - _ckipper_setup_prompts_summary_row "$key" - done < <(_ckipper_setup_prompts_global_keys) - } | _core_style_table SETTING VALUE SOURCE + _core_style_table_print_row "SETTING|VALUE|SOURCE" + # Per-key block: aligned three-column row + indented description on the + # next line. We render this manually rather than feeding a 4-column row + # to `_core_style_table` because the schema descriptions can run 90+ + # characters and the fixed-width column padding (22 chars) would leave + # them overflowing across the screen and breaking column alignment for + # every other column. + local key description + while IFS= read -r key; do + _core_style_table_print_row "$(_ckipper_setup_prompts_summary_row "$key")" + description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + [[ -n "$description" ]] && echo " $description" + done < <(_ckipper_setup_prompts_global_keys) _core_style_divider } @@ -106,8 +119,12 @@ _ckipper_setup_prompts_pick_keys_fallback() { # keys, one per line, in schema order. _ckipper_setup_prompts_pick_keys() { if _ckipper_setup_prompts_use_gum; then - _ckipper_setup_prompts_global_keys \ - | gum choose --no-limit --header "Pick keys to customize" + local key + while IFS= read -r key; do + printf '%s — %s\n' "$key" "${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + done < <(_ckipper_setup_prompts_global_keys) \ + | gum choose --no-limit --header "$_CKIPPER_SETUP_PROMPTS_PICKER_HEADER" \ + | awk '{print $1}' return 0 fi _ckipper_setup_prompts_pick_keys_fallback diff --git a/lib/setup/prompts_test.bats b/lib/setup/prompts_test.bats index 81ff702..6ac4f8e 100644 --- a/lib/setup/prompts_test.bats +++ b/lib/setup/prompts_test.bats @@ -67,6 +67,19 @@ _run_prompts() { [[ "$output" != *"ssh_forward"* ]] } +# Regression: descriptions used to live in a 4th column, which overflowed +# the fixed-width table because zsh's `printf '%-22s'` does not truncate. +# They now render on the line below each row, indented two spaces. +@test "_ckipper_setup_prompts_summary renders each schema description below its row" { + _run_prompts "" "_ckipper_setup_prompts_summary" + + [ "$status" -eq 0 ] + # Each schema description should appear verbatim somewhere in the output. + [[ "$output" == *"Bool. true = installer auto-adds the per-account aliases source line"* ]] + [[ "$output" == *"Path. Base directory containing your git projects."* ]] + [[ "$output" == *"Comma-separated int list. Container ports to forward to the host."* ]] +} + @test "_ckipper_setup_prompts_summary marks set values as (your config) and unset as (default)" { echo 'CKIPPER_NOTIFY_BELL="false"' >"$CKIPPER_DIR/docker/ckipper-config.zsh"