Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/config/dispatcher_test.bats
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ _run_config_dispatch() {
[ "$status" -ne 0 ]
}

# Regression: when `ckipper config set <key>` 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

[[ "$output" == *"set_rc=1"* ]]
# default_branch's schema default is empty; the get must still resolve to
# that, not to whatever empty value a missed cancel would have committed
# (an empty override would also resolve to ""; the more telling signal is
# that the prompt itself returned non-zero so the writer was bypassed).
[[ "$output" != *"set_rc=0"* ]]
}

@test "dispatcher unknown subcommand suggests help pointer" {
_run_config_dispatch "_ckipper_config_dispatch nope"

Expand Down
7 changes: 6 additions & 1 deletion lib/config/set.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ _ckipper_config_set() {
[[ -n "$account" ]] && { _core_account_dir "$account" >/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"
}
32 changes: 12 additions & 20 deletions lib/core/prompt.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}

Expand Down Expand Up @@ -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
"$@"
}
26 changes: 14 additions & 12 deletions lib/core/prompt_test.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" </dev/null

[ "$status" -ne 0 ]
[ -z "$output" ]
}

@test "_core_prompt_confirm returns 0 on y" {
_run_prompt "y" '_core_prompt_confirm "Proceed?"'

Expand Down Expand Up @@ -107,15 +121,3 @@ _run_prompt() {

[ "$status" -eq 1 ]
}

@test "_core_prompt_spin runs the command and forwards exit status 0" {
_run_prompt "" '_core_prompt_spin "Working" true'

[ "$status" -eq 0 ]
}

@test "_core_prompt_spin forwards non-zero exit status" {
_run_prompt "" '_core_prompt_spin "Working" false'

[ "$status" -eq 1 ]
}
24 changes: 13 additions & 11 deletions lib/core/schema.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,18 @@ typeset -gA _CKIPPER_SCHEMA_SCOPE=(
[ssh_forward]="account"
)

# One-line description shown by `ckipper config list` and the wizard.
# One-line description shown by `ckipper config list` and the wizard. For bool
# keys the description states what `true` does (the active behavior), so the
# user can read it and decide; `false` is just the inverse.
typeset -gA _CKIPPER_SCHEMA_DESCRIPTION=(
[projects_dir]="Base directory containing your git projects."
[worktrees_dir]="Where worktrees are created (default: \$projects_dir/.worktrees)."
[ports]="Comma-separated ports to forward from container to host."
[default_branch]="Fallback base branch when origin/HEAD is unset."
[dep_install_cmd]="Command run after worktree creation. Empty = skip."
[notify_bell]="Install notify-bell hook into account dirs."
[aliases_auto_source]="install.sh auto-adds aliases.zsh source line to .zshrc."
[always_docker]="Default --docker on for this account."
[always_firewall]="Default --firewall on for this account."
[ssh_forward]="Forward host ~/.ssh into containers run with this account."
[projects_dir]="Path. Base directory containing your git projects."
[worktrees_dir]="Path. Where worktrees live. Empty = \$projects_dir/.worktrees."
[ports]="Comma-separated int list. Container ports to forward to the host."
[default_branch]="String. Fallback base branch when origin/HEAD is unset (e.g. main, develop)."
[dep_install_cmd]="String. Command run after worktree creation. Empty = skip dep install."
[notify_bell]="Bool. true = play a terminal bell on Stop / Notification hooks."
[aliases_auto_source]="Bool. true = installer auto-adds the per-account aliases source line to ~/.zshrc."
[always_docker]="Bool. true = run Claude in Docker by default for this account (override with --no-docker)."
[always_firewall]="Bool. true = enable the egress firewall by default for this account (override with --no-firewall)."
[ssh_forward]="Bool. true = mount host ~/.ssh into containers launched for this account."
)
10 changes: 6 additions & 4 deletions lib/launcher/menu.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ typeset -gra _CKIPPER_LAUNCHER_OPTIONS=(
"Quit"
)

# `find -maxdepth` value for project discovery. Three levels covers the common
# layouts (`~/Developer/<repo>`, `~/Developer/<org>/<repo>`,
# `~/Developer/<group>/<org>/<repo>`) 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
# `<projects_dir>/<group>/<org>/<repo>/.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
Expand Down
45 changes: 36 additions & 9 deletions lib/setup/dispatcher.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 -- <fn>` 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
}
51 changes: 51 additions & 0 deletions lib/setup/dispatcher_test.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"* ]]
}
33 changes: 25 additions & 8 deletions lib/setup/prompts.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading