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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ Managed defaults are copied under `~/.config/selfishell`. User-facing
integration uses either managed links or bounded blocks in user-owned
configuration files.

`~/.zshrc` and `~/.zprofile` remain user-owned, each holding one bounded
Selfishell block. `~/.zshrc`'s block sources the managed platform entrypoint;
personal aliases, exports, PATH entries, and functions belong outside it. On
`~/.zshrc`, `~/.zprofile`, and `~/.vimrc` remain user-owned, each holding
one bounded Selfishell block. `~/.zshrc`'s block sources the managed platform
entrypoint; `~/.vimrc`'s block sources the managed Vim entrypoint; personal
aliases, exports, PATH entries, and functions belong outside it. On
Ubuntu/WSL, `~/.zshenv` is also user-owned and contains only Selfishell's
bounded zshenv block (`skip_global_compinit=1`); macOS `~/.zshenv` is not
managed.
Expand Down
1 change: 1 addition & 0 deletions lib/commands/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ command_install() {
managed_preflight_block_target user-zshenv "$HOME/.zshenv" "$assume_yes" "$dry_run" || return
;;
esac
managed_preflight_block_target user-vimrc "$HOME/.vimrc" "$assume_yes" "$dry_run" || return
profile_load "$profile"
if [[ "$profile" == "developer" ]]; then
preflight_mise_global_config || return
Expand Down
1 change: 1 addition & 0 deletions lib/commands/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ update_tools_and_configuration() {
managed_preflight_block_target user-zshenv "$HOME/.zshenv" "$assume_yes" "$dry_run" || return
;;
esac
managed_preflight_block_target user-vimrc "$HOME/.vimrc" "$assume_yes" "$dry_run" || return

if [[ "$platform" == "macos" && "$ghostty_enabled" == "1" ]]; then
managed_preflight_block_target user-ghostty \
Expand Down
28 changes: 23 additions & 5 deletions lib/managed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ managed_select_block_conflict_action() {
managed_block_definition() {
local resource="$1"

MANAGED_BLOCK_COMMENT='#'
case "$resource" in
user-zshrc)
MANAGED_BLOCK_LABEL='Selfishell initialize'
Expand Down Expand Up @@ -207,6 +208,16 @@ fi'
# To override a Selfishell default above, add it to user.ghostty instead.
config-file = ?user.ghostty"
;;
user-vimrc)
MANAGED_BLOCK_LABEL='Selfishell vimrc'
MANAGED_BLOCK_COMMENT='"'
# shellcheck disable=SC2016 # Literal for vim to evaluate at startup, not now.
MANAGED_BLOCK_BODY='if !empty($XDG_CONFIG_HOME) && filereadable($XDG_CONFIG_HOME . "/selfishell/vim/vimrc")
source $XDG_CONFIG_HOME/selfishell/vim/vimrc
elseif filereadable(expand("~/.config/selfishell/vim/vimrc"))
source ~/.config/selfishell/vim/vimrc
endif'
;;
*)
cli_error "Unknown managed block resource: $resource"
return "$SELFISHELL_EXIT_ERROR"
Expand All @@ -215,18 +226,25 @@ config-file = ?user.ghostty"
}

managed_block_begin() {
printf '# >>> %s >>>\n' "$1"
local label="$1"
local comment="${2:-${MANAGED_BLOCK_COMMENT:-#}}"
printf '%s >>> %s >>>\n' "$comment" "$label"
}

managed_block_end() {
printf '# <<< %s <<<\n' "$1"
local label="$1"
local comment="${2:-${MANAGED_BLOCK_COMMENT:-#}}"
printf '%s <<< %s <<<\n' "$comment" "$label"
}

managed_block_content() {
local resource="$1"

managed_block_definition "$resource" || return
printf '%s\n%s\n%s\n' "$(managed_block_begin "$MANAGED_BLOCK_LABEL")" "$MANAGED_BLOCK_BODY" "$(managed_block_end "$MANAGED_BLOCK_LABEL")"
printf '%s\n%s\n%s\n' \
"$(managed_block_begin "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" \
"$MANAGED_BLOCK_BODY" \
"$(managed_block_end "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")"
}

# Sets MANAGED_BLOCK_STATUS to absent/malformed/intact based purely on marker
Expand All @@ -252,8 +270,8 @@ managed_inspect_block() {
managed_block_definition "$resource" || return

[[ -f "$target_file" && ! -L "$target_file" ]] || return 0
begin_marker="$(managed_block_begin "$MANAGED_BLOCK_LABEL")"
end_marker="$(managed_block_end "$MANAGED_BLOCK_LABEL")"
begin_marker="$(managed_block_begin "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")"
end_marker="$(managed_block_end "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")"
metadata="$(LC_ALL=C awk -v begin="$begin_marker" -v end="$end_marker" -v label="$MANAGED_BLOCK_LABEL" '
BEGIN { offset = 0; begin_count = 0; end_count = 0; related_count = 0; start = 0; finish = 0 }
{
Expand Down
2 changes: 1 addition & 1 deletion lib/resources.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ block user-zshrc $HOME/.zshrc -
block user-zprofile $HOME/.zprofile -
block user-zshenv $HOME/.zshenv -
link user-starship ${XDG_CONFIG_HOME:-$HOME/.config}/starship.toml $SELFISHELL_CONFIG_DIR/starship.toml
link user-vimrc ${XDG_CONFIG_HOME:-$HOME/.config}/vim/vimrc $SELFISHELL_CONFIG_DIR/vim/vimrc
block user-vimrc $HOME/.vimrc -
link user-nvim ${XDG_CONFIG_HOME:-$HOME/.config}/nvim $SELFISHELL_CONFIG_DIR/nvim
block user-ghostty ${XDG_CONFIG_HOME:-$HOME/.config}/ghostty/config.ghostty -
EOF
Expand Down
31 changes: 19 additions & 12 deletions scripts/macos-configuration-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ publish_fixture "$NEXT_VERSION"
run_primary_lifecycle() {
local home="$TEST_ROOT/home-primary"
local prefix="$home/.local"
local backups_before backups_after loader_count starship_backup vimrc_backup
local backups_before backups_after loader_count starship_backup
local zshrc_mode_before zshrc_mode_after

export HOME="$home"
Expand All @@ -93,11 +93,10 @@ run_primary_lifecycle() {
# target) must be moved to a timestamped backup, not silently replaced.
printf 'format = "user starship config"\n' >"$XDG_CONFIG_HOME/starship.toml"

# A dangling symlink at another managed link target must also be treated
# as user data (the -L check in managed_install_link covers this), not
# silently followed or deleted.
mkdir -p "$XDG_CONFIG_HOME/vim"
ln -s /nonexistent-target "$XDG_CONFIG_HOME/vim/vimrc"
# Pre-existing user .vimrc with CRLF style, verifying block insertion and restoration.
printf 'set nocompatible\r\nset background=dark' >"$HOME/.vimrc"
local vimrc_before
vimrc_before="$(cat "$HOME/.vimrc")"

SELFISHELL_RELEASE_ROOT="file://$RELEASE_ROOT" \
bash "$ROOT_DIR/install.sh" --version "$INITIAL_VERSION" --prefix "$prefix" \
Expand All @@ -107,9 +106,13 @@ run_primary_lifecycle() {
[[ -f "$HOME/.zshrc" && ! -L "$HOME/.zshrc" ]] || fail "install did not leave .zshrc as a regular, user-owned file"
loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")"
[[ "$loader_count" == 1 ]] || fail "install did not add exactly one loader block (found $loader_count)"
[[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "install did not leave .vimrc as a regular, user-owned file"
local vimrc_block_count
vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")"
[[ "$vimrc_block_count" == 1 ]] || fail "install did not add exactly one vimrc block (found $vimrc_block_count)"
[[ -d "$XDG_CONFIG_HOME/selfishell" ]] || fail "managed configuration was not created under XDG_CONFIG_HOME"
[[ -d "$XDG_STATE_HOME/selfishell" ]] || fail "managed state was not created under XDG_STATE_HOME"
# The managed *links* (starship.toml, vim/vimrc, mise's conf.d entry) live
# The managed *links* (starship.toml, nvim, mise's conf.d entry) live
# under $HOME itself, pointing into the copied $XDG_CONFIG_HOME/selfishell
# tree -- never directly at the source checkout that this script runs from.
while IFS= read -r -d '' link; do
Expand All @@ -125,8 +128,6 @@ run_primary_lifecycle() {
starship_backup="$(find "$XDG_CONFIG_HOME" -maxdepth 1 -name 'starship.toml.backup.*')"
[[ -n "$starship_backup" ]] || fail "a pre-existing starship.toml was not backed up"
grep -Fq 'user starship config' "$starship_backup" || fail "the starship.toml backup does not hold the original content"
vimrc_backup="$(find "$XDG_CONFIG_HOME/vim" -maxdepth 1 -name 'vimrc.backup.*')"
[[ -n "$vimrc_backup" ]] || fail "a pre-existing dangling vimrc symlink was not treated as user data"

zshrc_mode_after="$(stat -f '%Lp' "$HOME/.zshrc")"
[[ "$zshrc_mode_after" == "$zshrc_mode_before" ]] ||
Expand All @@ -139,6 +140,8 @@ run_primary_lifecycle() {
"$prefix/bin/selfishell" install --profile minimal --skip-packages --yes >/dev/null
loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")"
[[ "$loader_count" == 1 ]] || fail "a second install duplicated the loader block (found $loader_count)"
vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")"
[[ "$vimrc_block_count" == 1 ]] || fail "a second install duplicated the vimrc block (found $vimrc_block_count)"
backups_after="$(find "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" -name '*.backup.*' | sort)"
[[ "$backups_before" == "$backups_after" ]] || fail "a second install created an unnecessary backup"
assert_managed_resources_clean "$prefix" "after an idempotent reinstall"
Expand All @@ -158,21 +161,25 @@ run_primary_lifecycle() {
"$prefix/bin/selfishell" update --tools-only --skip-packages --yes >/dev/null
loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")"
[[ "$loader_count" == 1 ]] || fail "update duplicated the loader block (found $loader_count)"
# The loader block is prepended, so the user's original content is a
vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")"
[[ "$vimrc_block_count" == 1 ]] || fail "update duplicated the vimrc block (found $vimrc_block_count)"
# The loader/vimrc block is prepended, so the user's original content is a
# suffix of the file, not a prefix.
[[ "$(cat "$HOME/.zshrc")" == *"$zshrc_before" ]] ||
fail "update did not preserve the user's original .zshrc content"
[[ "$(cat "$HOME/.vimrc")" == *"$vimrc_before" ]] ||
fail "update did not preserve the user's original .vimrc content"
assert_managed_resources_clean "$prefix" "after a configuration update"

# --- uninstall --restore ---
"$prefix/bin/selfishell" uninstall --restore --yes >/dev/null
[[ "$(cat "$HOME/.zshrc")" == "$zshrc_before" ]] ||
fail "uninstall --restore did not preserve the user's .zshrc byte-for-byte"
[[ "$(cat "$HOME/.vimrc")" == "$vimrc_before" ]] ||
fail "uninstall --restore did not preserve the user's .vimrc byte-for-byte"
[[ ! -e "$XDG_CONFIG_HOME/selfishell/zsh/zshrc" ]] || fail "uninstall left managed configuration behind"
grep -Fq 'user starship config' "$XDG_CONFIG_HOME/starship.toml" ||
fail "uninstall --restore did not restore the original starship.toml"
[[ -L "$XDG_CONFIG_HOME/vim/vimrc" && "$(readlink "$XDG_CONFIG_HOME/vim/vimrc")" == /nonexistent-target ]] ||
fail "uninstall --restore did not restore the original dangling vimrc symlink"

printf 'PASS: primary configuration lifecycle (clean install, idempotent reinstall, status, update, uninstall --restore)\n'
}
Expand Down
3 changes: 3 additions & 0 deletions scripts/ubuntu-container-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ SELFISHELL_RELEASE_ROOT="file://$RELEASE_ROOT" \
"$PREFIX/bin/selfishell" status >/dev/null
"$PREFIX/bin/selfishell" doctor >/dev/null

vim --not-a-term -c 'if !&number || !&relativenumber | cquit 1 | endif' -c 'q' </dev/null ||
fail "Vim did not load Selfishell vimrc on default startup"

SELFISHELL_RELEASE_ROOT="file://$RELEASE_ROOT" \
"$PREFIX/bin/selfishell" update --cli-only --version "$NEXT_VERSION" --yes
[[ "$("$PREFIX/bin/selfishell" version)" == "selfishell $NEXT_VERSION" ]] || fail "CLI update failed"
Expand Down
52 changes: 51 additions & 1 deletion tests/managed_install_test.bash
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ test_install_copies_configuration_and_tracks_resources() {
grep -Fqx '# >>> Selfishell zshenv >>>' "$HOME/.zshenv" || fail "Zshenv block start marker is missing"
grep -Fqx 'skip_global_compinit=1' "$HOME/.zshenv" || fail "Zshenv block body is missing"
assert_symlink_to "$XDG_CONFIG_HOME/selfishell/starship.toml" "$XDG_CONFIG_HOME/starship.toml"
assert_symlink_to "$XDG_CONFIG_HOME/selfishell/vim/vimrc" "$XDG_CONFIG_HOME/vim/vimrc"
[[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "Vim startup file is not user-owned"
grep -Fqx '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Vim block start marker is missing"
[[ "$(sed -n '2p' "$XDG_STATE_HOME/selfishell/resources/user-vimrc.state")" == block ]] ||
fail "Vim resource was not recorded as a managed block"
cmp -s "$ROOT_DIR/config/shared/zsh/common.zsh" "$XDG_CONFIG_HOME/selfishell/zsh/common.zsh" ||
fail "Common Zsh configuration was not copied"
cmp -s "$ROOT_DIR/config/shared/zsh/runtime.zsh" "$XDG_CONFIG_HOME/selfishell/zsh/runtime.zsh" ||
Expand Down Expand Up @@ -804,6 +807,53 @@ test_macos_lifecycle_never_touches_existing_zshenv() {
fail "macOS lifecycle created a user-zshenv managed state for an existing file"
}

test_vimrc_user_content_survives_lifecycle() {
local state_file="$XDG_STATE_HOME/selfishell/resources/user-vimrc.state"

printf 'set background=dark\nset nocompatible\n' >"$HOME/.vimrc"

run_selfishell install --profile minimal --skip-packages --yes >/dev/null

[[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "Vim startup file is not user-owned"
grep -Fqx '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Vim block start marker is missing"
grep -Fqx '" <<< Selfishell vimrc <<<' "$HOME/.vimrc" || fail "Vim block end marker is missing"
grep -Fq 'set background=dark' "$HOME/.vimrc" || fail "Original .vimrc content was not preserved"
[[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] ||
fail "Vim block marker appears more than once"
[[ "$(sed -n '2p' "$state_file")" == block ]] || fail "Vim resource was not recorded as a managed block"

run_selfishell install --profile minimal --skip-packages --yes >/dev/null
[[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] ||
fail "Reinstall duplicated the Vim block marker"

run_selfishell update --tools-only --skip-packages --yes >/dev/null
[[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] ||
fail "Update duplicated the Vim block marker"
grep -Fq 'set background=dark' "$HOME/.vimrc" || fail "Original .vimrc content was lost across reinstall/update"

run_selfishell uninstall --yes >/dev/null
! grep -Fq '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Uninstall did not remove the Vim block"
assert_file_content $'set background=dark\nset nocompatible' "$HOME/.vimrc"
}

test_unrelated_vimrc_symlink_is_rejected_without_changes() {
local status

printf 'dotfiles vimrc\n' >"$TEST_ROOT/dotfiles-vimrc"
ln -s "$TEST_ROOT/dotfiles-vimrc" "$HOME/.vimrc"

set +e
run_selfishell install --skip-packages --yes >/dev/null 2>&1
status=$?
set -e

[[ "$status" -eq 1 ]] || fail "Vimrc symlink should stop installation"
assert_symlink_to "$TEST_ROOT/dotfiles-vimrc" "$HOME/.vimrc"
assert_file_content 'dotfiles vimrc' "$TEST_ROOT/dotfiles-vimrc"
[[ ! -e "$XDG_CONFIG_HOME/selfishell" ]] || fail "Rejected symlink created configuration"
[[ ! -e "$XDG_STATE_HOME/selfishell" ]] || fail "Rejected symlink created state"
}

test_malformed_managed_file_state_variants_are_rejected_without_changes() {
run_selfishell install --profile minimal --skip-packages --yes >/dev/null

Expand Down
Loading