Constructor Pattern fix replacing v0.47-v0.48 patch series. The "is the
user interactive?" logic was previously duplicated across 15+ places:
bootstrap.sh x4 ([ -t 0 ] gates on profile/onboard/launch/etc)
install.sh x1 (PATH wiring decision)
install/lib-hooks.sh (activate-hooks prompt)
install/lib-plan.sh (auto-confirm gate)
install/lib-menu.sh (skip-menu gate)
install/lib-wizard.sh (sleep-wizard gate)
install/lib-onboarding.sh x2 (onboarding_should_run + preflight retry)
install/lib-preflight.sh (install-tool prompt)
Every duplicated check was a chance to get curl|bash semantics wrong.
v0.47 used `[ -t 1 ]` (broke under tee'd stdout). v0.48 used `[ -t 0 ]`
(broke under curl pipe stdin). Each fix was a patch on top of the same
architectural defect: scattered truth.
ARCHITECTURAL FIX (Rule Zero — 1 cube = 1 responsibility):
scripts/kei-prompt.sh (NEW, ~110 LOC, public API):
kei_is_interactive → 0 if user is at a terminal, 1 if headless
kei_prompt Q [DEFAULT] → answer or default to stdout
kei_prompt_yn Q [Y|N] → exit 0=yes, 1=no, with [Y/n] hint
kei_prompt_secret Q → no-echo input (tokens, keys)
Truth signal: /dev/tty accessibility, with [ -t 0 ] as second-choice
fallback. KEI_NONINTERACTIVE=1 for CI override. Same contract as the
inline rules — now in ONE place.
bootstrap.sh + install.sh: source the cube at the top, with self-
contained inline fallback (mirrors the kei_is_interactive contract
only) so they remain self-bootable even if scripts/ is missing.
All 15+ inline gates replaced with `kei_is_interactive` calls.
All 3 `read -r -p` prompts in installer cubes replaced with
`kei_prompt` / `kei_prompt_yn`.
Existing copy_pet_scripts() in lib-scaffold.sh installs scripts/*.sh
into ~/.claude/scripts/ automatically — no install logic change needed.
WHAT THIS PREVENTS:
- Next time someone writes a prompt in installer code, the only path
is `kei_prompt`. They CANNOT accidentally type `[ -t 0 ]` because
there is no `[ -t 0 ]` to copy-paste anymore (except inside the
cube itself).
- The v2 tty-interactivity-gate-guard.sh hook (added 2026-05-27)
becomes a regression net rather than the first line of defence.
- Two real install incidents this month (May 2026, 7 prompts each)
do not happen a third time.
VERIFIED:
- Syntax check passes on all 9 modified files + new cube.
- Primitive functions smoke-tested across 8 cases: headless,
KEI_NONINTERACTIVE override, default fallback, yn convenience,
re-source guard, /dev/tty available, /dev/tty open() fails.
- 2 remaining [ -t 0 ] in tree: BOTH inside kei_is_interactive
fallback in bootstrap.sh + install.sh (single-source contract,
not patches).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
4.4 KiB
Bash
104 lines
4.4 KiB
Bash
# shellcheck shell=bash
|
||
# lib-preflight.sh — диспетчер preflight-проверок CLI.
|
||
#
|
||
# Контракт:
|
||
# preflight_run <provider-id>
|
||
# 1. Ищет файл install/preflight/<provider-id>.sh
|
||
# 2. Если есть — source'ит и вызывает `preflight_check_<sanitized-id>`
|
||
# 3. Функция возвращает 0 (ok) / 1 (missing, инструкция напечатана)
|
||
# 4. Если файла нет — провайдеру CLI не нужен, тихо exit 0
|
||
#
|
||
# Файл per-provider должен экспортировать ОДНУ функцию:
|
||
# preflight_check_<id>() — печатает инструкцию в stderr, exit 0/1
|
||
#
|
||
# Sanitize: dashes в id заменяются на underscores для имени функции
|
||
# (bash не любит dashes в идентификаторах).
|
||
|
||
PREFLIGHT_DIR="${LIB_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/preflight"
|
||
|
||
# Печатает инструкцию по установке, спрашивает действие.
|
||
# Аргументы: $1 — имя CLI, $2 — команда установки.
|
||
preflight_offer_install() {
|
||
local cli="$1"
|
||
local install_cmd="$2"
|
||
echo "" >&2
|
||
echo " ⚠ $cli не найден." >&2
|
||
echo " Установить: $install_cmd" >&2
|
||
echo "" >&2
|
||
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
|
||
echo " ⓘ команда: $install_cmd" >&2
|
||
ans=$(kei_prompt " Поставить сейчас? [y/N/skip] " "N")
|
||
case "$ans" in
|
||
y|Y|yes)
|
||
# bash -c вместо eval — explicit subshell, не word-splitting'тся
|
||
# лишний раз в текущем процессе.
|
||
bash -c "$install_cmd"
|
||
return $?
|
||
;;
|
||
skip|s|S)
|
||
echo " пропускаю — поставите вручную позже." >&2
|
||
return 0
|
||
;;
|
||
*)
|
||
echo " пропуск (по умолчанию)." >&2
|
||
return 1
|
||
;;
|
||
esac
|
||
else
|
||
# non-TTY: только печатаем инструкцию.
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Универсальный helper для типового CLI-чека (command -v + offer-install + version).
|
||
# Используется per-provider preflight файлами чтобы убрать boilerplate.
|
||
#
|
||
# Аргументы:
|
||
# $1 — имя CLI (для сообщений), например "aws CLI"
|
||
# $2 — бинарь (для command -v), например "aws"
|
||
# $3 — install_cmd (для preflight_offer_install)
|
||
# $4 — version_cmd (для печати при success), например "aws --version"
|
||
#
|
||
# Возврат: 0 если CLI есть, 1 если нет и юзер не поставил.
|
||
preflight_check_cli() {
|
||
local label="$1"
|
||
local bin="$2"
|
||
local install_cmd="$3"
|
||
local version_cmd="$4"
|
||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||
preflight_offer_install "$label" "$install_cmd" || return 1
|
||
# После install проверяем что бинарь появился в PATH.
|
||
command -v "$bin" >/dev/null 2>&1 || return 1
|
||
fi
|
||
# bash -c вместо eval: explicit subshell, не word-splitится в текущем
|
||
# процессе (security MEDIUM-3 audit 2026-05-18).
|
||
echo " ✓ $label: $(bash -c "$version_cmd" 2>&1 | head -1)" >&2
|
||
return 0
|
||
}
|
||
|
||
# Главный диспетчер. Вызывается из onboarding между pick_model и collect_auth.
|
||
preflight_run() {
|
||
local provider="$1"
|
||
[ -z "$provider" ] && return 0
|
||
# Whitelist символов в provider-id: только [a-z0-9_-], длина 1..64.
|
||
# Защищает от path-traversal (../) и shell-инъекций через имя файла.
|
||
if ! [[ "$provider" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ ]]; then
|
||
echo " ⚠ preflight: provider id '$provider' содержит недопустимые символы — пропуск" >&2
|
||
return 0
|
||
fi
|
||
local script="$PREFLIGHT_DIR/${provider}.sh"
|
||
# Проверяем что resolved путь не вышел за PREFLIGHT_DIR (на случай symlink'ов).
|
||
local resolved
|
||
resolved="$(cd "$PREFLIGHT_DIR" 2>/dev/null && pwd -P)/${provider}.sh"
|
||
if [ ! -f "$script" ] || [ ! -f "$resolved" ]; then
|
||
return 0 # CLI не нужен — direct-api, ключ собирается ниже
|
||
fi
|
||
# shellcheck disable=SC1090
|
||
source "$script"
|
||
local fn="preflight_check_${provider//-/_}"
|
||
if command -v "$fn" >/dev/null 2>&1; then
|
||
"$fn"
|
||
return $?
|
||
fi
|
||
return 0
|
||
}
|