diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..7074405 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,177 @@ +name: Benchmark + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: benchmark-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + zsh-startup: + name: Zsh startup + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install benchmark environment + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + sudo apt-get install -y hyperfine zsh + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install atuin fnm fzf zoxide + + mapfile -t insecure_dirs < <( + zsh -fc 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; autoload -Uz compaudit; compaudit' + ) + if (( ${#insecure_dirs[@]} )); then + sudo chmod go-w "${insecure_dirs[@]}" + fi + + - name: Prepare benchmark homes + shell: bash + run: | + set -euo pipefail + + clone_at() { + local repository="$1" + local revision="$2" + local destination="$3" + + git init --quiet "$destination" + git -C "$destination" remote add origin "$repository" + git -C "$destination" fetch --quiet --depth=1 origin "$revision" + git -C "$destination" checkout --quiet --detach FETCH_HEAD + } + + fixture_template="$RUNNER_TEMP/fixture-template" + zinit_template="$fixture_template/.local/share/zinit" + mkdir -p "$zinit_template/plugins" + clone_at https://github.com/zdharma-continuum/zinit.git fcd2501fa347c51a7048452becad61f86ed0ad0c "$zinit_template/zinit.git" + clone_at https://github.com/zsh-users/zsh-autosuggestions.git 85919cd1ffa7d2d5412f6d3fe437ebdbeeec4fc5 "$zinit_template/plugins/zsh-users---zsh-autosuggestions" + clone_at https://github.com/zsh-users/zsh-syntax-highlighting.git 1d85c692615a25fe2293bdd44b34c217d5d2bf04 "$zinit_template/plugins/zsh-users---zsh-syntax-highlighting" + clone_at https://github.com/romkatv/powerlevel10k.git 9253fb1c5034410c43a0c681ff8294181c54016c "$zinit_template/plugins/romkatv---powerlevel10k" + + ( + export FNM_DIR="$fixture_template/.local/share/fnm" + eval "$(/home/linuxbrew/.linuxbrew/bin/fnm env --shell bash)" + /home/linuxbrew/.linuxbrew/bin/fnm install --lts --use + /home/linuxbrew/.linuxbrew/bin/fnm default "$(/home/linuxbrew/.linuxbrew/bin/fnm current)" + ) + + prepare_ref() { + local ref="$1" + local label="$2" + local source="$RUNNER_TEMP/suitup-$label" + local home="$RUNNER_TEMP/home-$label" + + git worktree add --quiet --detach "$source" "$ref" + mkdir -p "$home/.config/zsh" "$home/.local/share" + cp -R "$source/configs/core" "$source/configs/shared" "$source/configs/local" "$home/.config/zsh/" + cp -R "$zinit_template" "$fixture_template/.local/share/fnm" "$home/.local/share/" + cp "$source/configs/zshrc.template" "$home/.zshrc" + cp "$source/configs/zshenv.template" "$home/.zshenv" + } + + prepare_ref HEAD current + prepare_ref origin/main main + prepare_ref HEAD^ previous + + for label in current main previous; do + home="$RUNNER_TEMP/home-$label" + startup_log="$RUNNER_TEMP/$label-startup.log" + + if ! env HOME="$home" ZDOTDIR="$home" SUITUP_STARTUP_REPORT_THRESHOLD_MS=999999 zsh -i -c exit >"$startup_log" 2>&1; then + cat "$startup_log" + exit 1 + fi + + for cache in .zcompdump atuin-init.zsh fnm-init.zsh zoxide-init.zsh; do + if [[ ! -s "$home/.cache/zsh/$cache" ]]; then + echo "::error::Missing $cache after warming $label" + env HOME="$home" ZDOTDIR="$home" zsh -i -c 'command -v brew atuin fnm fzf zoxide' + ls -la "$home/.cache/zsh" || true + cat "$startup_log" + exit 1 + fi + done + done + + sleep 1 + + - name: Compare startup time + shell: bash + run: | + set -euo pipefail + + current="current ($(git rev-parse --short HEAD))" + main="main ($(git rev-parse --short origin/main))" + previous="previous commit ($(git rev-parse --short HEAD^))" + current_command="env HOME='$RUNNER_TEMP/home-current' ZDOTDIR='$RUNNER_TEMP/home-current' SUITUP_STARTUP_REPORT_THRESHOLD_MS=999999 zsh -i -c exit" + main_command="env HOME='$RUNNER_TEMP/home-main' ZDOTDIR='$RUNNER_TEMP/home-main' SUITUP_STARTUP_REPORT_THRESHOLD_MS=999999 zsh -i -c exit" + previous_command="env HOME='$RUNNER_TEMP/home-previous' ZDOTDIR='$RUNNER_TEMP/home-previous' SUITUP_STARTUP_REPORT_THRESHOLD_MS=999999 zsh -i -c exit" + + hyperfine \ + --warmup 5 \ + --runs 30 \ + --export-json warm-benchmark.json \ + --export-markdown warm-benchmark.md \ + --command-name "$current" "$current_command" \ + --command-name "$main" "$main_command" \ + --command-name "$previous" "$previous_command" + + hyperfine \ + --prepare "rm -rf '$RUNNER_TEMP/home-current/.cache/zsh' '$RUNNER_TEMP/home-main/.cache/zsh' '$RUNNER_TEMP/home-previous/.cache/zsh'" \ + --warmup 2 \ + --runs 10 \ + --export-json cache-miss-benchmark.json \ + --export-markdown cache-miss-benchmark.md \ + --command-name "$current" "$current_command" \ + --command-name "$main" "$main_command" \ + --command-name "$previous" "$previous_command" + + { + echo "## Zsh startup benchmark" + echo + echo "Ubuntu 24.04 with Linuxbrew, atuin, fnm, fzf, zoxide, zinit, and the shipped plugins. Lower is better." + echo + echo "### Warm startup (daily use)" + echo + cat warm-benchmark.md + echo + echo "### Cache-miss startup" + echo + echo "Completion and tool-init caches are removed before every run; installed tools and plugins remain." + echo + cat cache-miss-benchmark.md + } | tee benchmark-comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment benchmark on PR + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + run: gh pr comment "${{ github.event.pull_request.number }}" --edit-last --create-if-none --body-file benchmark-comment.md + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: zsh-startup-benchmark + path: | + warm-benchmark.json + warm-benchmark.md + cache-miss-benchmark.json + cache-miss-benchmark.md + retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 77c05d5..eee213b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,8 +11,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 with: node-version: lts/* cache: npm diff --git a/AGENTS.md b/AGENTS.md index 788af58..041d8e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ The actual config lives under `~/.config/zsh/`. ### Local files - `configs/local/machine.zsh`: machine-specific overrides placeholder +- `configs/local/aliases.zsh`: user-owned aliases, initialized once and preserved across clean/reset - `~/.config/zsh/local/secrets.zsh` is user-managed and intentionally not shipped by suitup ## Templates diff --git a/README.md b/README.md index 506d25f..02b33fe 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Named after Barney's catchphrase from [How I Met Your Mother](https://www.themov - **Clean mode** — remove suitup config files - `--help` output for quick command discovery - Backs up existing zsh startup files to `~/.config/zsh/backups/` before changing shell startup config +- Keeps personal aliases in `~/.config/zsh/local/aliases.zsh`, outside suitup-managed updates and clean/reset removal - Powerlevel10k is optional; recommended because its async git status stays responsive in large repositories - Idempotent — safe to run multiple times - No private/company-specific content — clean, generic configs @@ -152,6 +153,7 @@ node src/cli.js append Uses idempotent marker blocks (`# >>> suitup/... >>>`) to safely append selected configs and re-run related installers when required tools are missing: - Suitup aliases +- User aliases (`edit-user-alias` opens `~/.config/zsh/local/aliases.zsh`) - Zinit plugins - Powerlevel10k prompt or basic prompt preset (replaces `~/.config/zsh/shared/prompt.zsh`) - Tool initialization (atuin, fzf, zoxide, fnm) @@ -160,6 +162,8 @@ Uses idempotent marker blocks (`# >>> suitup/... >>>`) to safely append selected - Startup performance monitor - FZF configuration +The startup timing table is shown only when startup takes at least `100ms`. Set `SUITUP_STARTUP_REPORT_THRESHOLD_MS=0` to always show it. + For suitup-managed shared config files such as aliases and zinit plugins, append mode also previews safe line additions before applying them. Prompt/theme files are not line-merged because they often contain generated or user-tuned state that cannot be reliably reconciled. ### Verify @@ -198,6 +202,7 @@ Attempts a safe uninstall of suitup-managed config: - removes suitup-generated `~/.config/zsh/` files when they still match shipped templates - strips `# >>> suitup/... >>>` blocks from an existing `~/.zshrc` if you used `append` - preserves user-modified files instead of deleting them blindly +- preserves `~/.config/zsh/local/aliases.zsh` ### Help @@ -265,6 +270,7 @@ After setup, your shell config looks like: prompt.zsh # Prompt/theme (p10k) local/ machine.zsh # Machine-specific overrides + aliases.zsh # User aliases, preserved across updates/reset config.vim # Vim config secrets.zsh # API keys (create manually, gitignored) ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 6050036..e698e44 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,7 @@ - **清理模式**:删除 suitup 生成的配置 - 提供 `--help`,方便快速查看命令 - 修改 Shell 启动配置前,会先把现有 zsh 启动文件备份到 `~/.config/zsh/backups/` +- 个人 alias 保存在 `~/.config/zsh/local/aliases.zsh`,不会被 suitup 更新或 clean/reset 删除 - Powerlevel10k 为可选项;推荐开启,因为它在大型 Git 仓库里的异步 git 状态会更流畅 - 幂等执行,可安全重复运行 - 不包含私有/公司特定内容 @@ -152,6 +153,7 @@ node src/cli.js append 通过幂等标记块(`# >>> suitup/... >>>`)安全追加;如果相关工具缺失,也会一起重试安装: - aliases +- 用户 alias(`edit-user-alias` 会打开 `~/.config/zsh/local/aliases.zsh`) - zinit 插件 - Powerlevel10k prompt 或基础 prompt 预设(会替换 `~/.config/zsh/shared/prompt.zsh`) - 工具初始化(atuin、fzf、zoxide、fnm) @@ -160,6 +162,8 @@ node src/cli.js append - 启动性能报告 - FZF 配置 +启动耗时达到 `100ms` 时才会显示计时表;设置 `SUITUP_STARTUP_REPORT_THRESHOLD_MS=0` 可始终显示。 + 对于 aliases、zinit plugins 等 suitup 管理的共享配置文件,append 模式也会在应用安全的行级增量前展示 diff 预览。Prompt / theme 文件不会做行级合并,因为其中常包含生成内容或用户调校状态,无法可靠自动协调。 ### Verify(验证) @@ -198,6 +202,7 @@ node src/cli.js clean - 对仍与项目模板一致的文件,删除 `~/.config/zsh/` 下的 suitup 生成内容 - 如果你用过 `append`,会从现有 `~/.zshrc` 中移除 `# >>> suitup/... >>>` 标记块 - 对用户自己改过的文件会保留,不会盲删 +- 保留 `~/.config/zsh/local/aliases.zsh` ### Help(帮助) @@ -263,6 +268,7 @@ node src/cli.js --help prompt.zsh # 提示符主题(p10k) local/ machine.zsh # 机器本地覆盖 + aliases.zsh # 用户 alias,更新/reset 时保留 config.vim # Vim 配置 secrets.zsh # 个人密钥(手动创建,不纳入 git) ``` diff --git a/configs/core/paths.zsh b/configs/core/paths.zsh index 197dc43..499429b 100644 --- a/configs/core/paths.zsh +++ b/configs/core/paths.zsh @@ -4,17 +4,22 @@ # Homebrew can live outside the default PATH on fresh macOS/Linux installs. # Load its shellenv early so later tool detection works after suitup rewrites ~/.zshrc. -for _suitup_brew_bin in \ - "${HOMEBREW_PREFIX:+$HOMEBREW_PREFIX/bin/brew}" \ - /opt/homebrew/bin/brew \ - /home/linuxbrew/.linuxbrew/bin/brew \ - /usr/local/bin/brew -do - [[ -n "$_suitup_brew_bin" && -x "$_suitup_brew_bin" ]] || continue - eval "$("$_suitup_brew_bin" shellenv zsh)" - break -done -unset _suitup_brew_bin +if [[ -z "${_SUITUP_BREW_ENV_CHECKED:-}" ]]; then + typeset -g _SUITUP_BREW_ENV_CHECKED=1 + if (( ! $+commands[brew] )); then + for _suitup_brew_bin in \ + "${HOMEBREW_PREFIX:+$HOMEBREW_PREFIX/bin/brew}" \ + /opt/homebrew/bin/brew \ + /home/linuxbrew/.linuxbrew/bin/brew \ + /usr/local/bin/brew + do + [[ -n "$_suitup_brew_bin" && -x "$_suitup_brew_bin" ]] || continue + eval "$("$_suitup_brew_bin" shellenv zsh)" + break + done + unset _suitup_brew_bin + fi +fi # fnm (Fast Node Manager) — keep the fnm binary itself on PATH after suitup # rewrites ~/.zshrc, then expose the default Node installation to all shells diff --git a/configs/core/perf.zsh b/configs/core/perf.zsh index cd6587c..2e6e17c 100644 --- a/configs/core/perf.zsh +++ b/configs/core/perf.zsh @@ -63,8 +63,19 @@ _zsh_report() { local -F 6 end=${EPOCHREALTIME:-0} local -F 6 total_ms=$(( (end - _zsh_start_time) * 1000.0 )) + local -F 6 threshold_ms=${SUITUP_STARTUP_REPORT_THRESHOLD_MS:-100} + local show_completion_hint=false local i + if [[ "${SUITUP_SHOW_COMPLETION_CACHE_HINT:-0}" == '1' && "${_zsh_completion_cache_mode:-}" == 'cache-hit' && -n "${_zsh_compdump_file:-}" ]]; then + show_completion_hint=true + fi + + if (( total_ms < threshold_ms )); then + [[ "$show_completion_hint" == 'true' ]] && printf 'completion cache hit; remove %s to rebuild\n' "$_zsh_compdump_file" + return 0 + fi + echo '' echo '┌────────────────────────────┐' @@ -76,7 +87,7 @@ _zsh_report() { _print_duration_row 'total' "$total_ms" echo '└────────────────────────────┘' - if [[ "${SUITUP_SHOW_COMPLETION_CACHE_HINT:-0}" == '1' && "${_zsh_completion_cache_mode:-}" == 'cache-hit' && -n "${_zsh_compdump_file:-}" ]]; then + if [[ "$show_completion_hint" == 'true' ]]; then printf 'completion cache hit; remove %s to rebuild\n' "$_zsh_compdump_file" fi } diff --git a/configs/local/aliases.zsh b/configs/local/aliases.zsh new file mode 100644 index 0000000..84d4474 --- /dev/null +++ b/configs/local/aliases.zsh @@ -0,0 +1,8 @@ +# ============================================================================ +# User aliases +# ============================================================================ +# Add personal aliases here. Suitup creates this file once and never overwrites +# or removes it during updates and clean/reset flows. +# +# Example: +# alias work="cd ~/workspace" diff --git a/configs/shared/aliases.zsh b/configs/shared/aliases.zsh index b40f062..a1dae2d 100644 --- a/configs/shared/aliases.zsh +++ b/configs/shared/aliases.zsh @@ -8,6 +8,7 @@ alias reload-zsh="source ~/.zshrc" alias edit-zsh="${EDITOR:-vi} ~/.zshrc" alias edit-plugins="${EDITOR:-vi} ~/.config/zsh/shared/plugins.zsh" alias edit-aliases="${EDITOR:-vi} ~/.config/zsh/shared/aliases.zsh" +alias edit-user-alias="${EDITOR:-vi} ~/.config/zsh/local/aliases.zsh" alias ll="eza -abghlS --color=always --icons=always" alias ls="eza -s=name --group-directories-first --color=always --icons=always" alias ltree="eza -abghS --icons=always --tree --git-ignore" diff --git a/configs/shared/completion.zsh b/configs/shared/completion.zsh index 7a17d0d..bf9b6c3 100644 --- a/configs/shared/completion.zsh +++ b/configs/shared/completion.zsh @@ -7,13 +7,16 @@ autoload -Uz compinit _zsh_compdump_file="${XDG_CACHE_HOME:-$HOME/.cache}/zsh/.zcompdump" typeset -g _zsh_completion_cache_mode='refresh' +typeset -a _zsh_fresh_compdump=("$_zsh_compdump_file"(N.m-7L+0)) -if [[ -s "$_zsh_compdump_file" && -n "$(command find "$_zsh_compdump_file" -mtime -7 -print 2>/dev/null)" ]]; then +if (( ${#_zsh_fresh_compdump} )); then _zsh_completion_cache_mode='cache-hit' compinit -C -d "$_zsh_compdump_file" else compinit -d "$_zsh_compdump_file" + zcompile "$_zsh_compdump_file" fi +unset _zsh_fresh_compdump bindkey -M emacs '^I' expand-or-complete bindkey -M viins '^I' expand-or-complete diff --git a/configs/shared/tools/fzf.zsh b/configs/shared/tools/fzf.zsh index b8c6c93..a924fe7 100644 --- a/configs/shared/tools/fzf.zsh +++ b/configs/shared/tools/fzf.zsh @@ -50,8 +50,6 @@ export FZF_CTRL_T_OPTS=" --bind 'ctrl-u:preview-page-up' " -_source_cached_tool_init fzf-init fzf 'fzf --zsh' - _fzf_ctrl_t_command='if [[ -d "$FZF_CTRL_T_BASE" ]]; then fd --type d --hidden --follow \ --base-directory "$FZF_CTRL_T_BASE" \ @@ -108,27 +106,38 @@ _fzf_ctrl_t_select_from() { local base_dir="$1" local insert_prefix="$2" local query="$3" - local item + local item selected + local -a fzf_command=(fzf) + + if [[ -n "${TMUX_PANE-}" && ( "${FZF_TMUX:-0}" != 0 || -n "${FZF_TMUX_OPTS-}" ) ]] && (( $+commands[fzf-tmux] )); then + fzf_command=(fzf-tmux ${(z)${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}}} --) + fi FZF_CTRL_T_BASE="$base_dir" \ FZF_CTRL_T_PREVIEW_ROOT="$base_dir" \ FZF_DEFAULT_COMMAND="$_fzf_ctrl_t_command" \ - FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_CTRL_T_OPTS-} -m --query=${(qqq)query}") \ - FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) < /dev/tty | while read -r item; do - echo -n -E "${(q)insert_prefix$item} " + FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS-} --reverse --scheme=path ${FZF_CTRL_T_OPTS-} -m --query=${(qqq)query}" \ + FZF_DEFAULT_OPTS_FILE='' command "${fzf_command[@]}" < /dev/tty | while read -r item; do + selected="${insert_prefix}${item}" + print -rn -- "${(q)selected} " done local ret=$? echo return $ret } +_fzf_ctrl_t_select_default() { + [[ "$PWD" == "$HOME" ]] && return + _fzf_ctrl_t_select_from "$PWD" "" "" +} + fzf-file-widget() { setopt localoptions noshwordsplit noksh_arrays noposixbuiltins pipefail no_aliases 2>/dev/null local -a tokens local token base_dir insert_prefix query lbuf selected ret if [[ ${LBUFFER[-1]} == ' ' ]]; then - LBUFFER="${LBUFFER}$(__fzf_select)" + LBUFFER="${LBUFFER}$(_fzf_ctrl_t_select_default)" ret=$? zle reset-prompt return $ret @@ -137,7 +146,7 @@ fzf-file-widget() { tokens=(${(z)LBUFFER}) token="${tokens[-1]-}" if [[ -z "$token" ]] || ! _fzf_ctrl_t_path_context "$token"; then - LBUFFER="${LBUFFER}$(__fzf_select)" + LBUFFER="${LBUFFER}$(_fzf_ctrl_t_select_default)" ret=$? zle reset-prompt return $ret diff --git a/configs/zshrc.template b/configs/zshrc.template index 34d9e1d..084c4a1 100644 --- a/configs/zshrc.template +++ b/configs/zshrc.template @@ -51,6 +51,7 @@ source_if_exists "$ZSH_CONFIG/shared/completion.zsh" # --------------------------------------------------------------------------- source_if_exists "$ZSH_CONFIG/local/secrets.zsh" source_if_exists "$ZSH_CONFIG/local/machine.zsh" +source_if_exists "$ZSH_CONFIG/local/aliases.zsh" # --------------------------------------------------------------------------- # Prompt (last — must not be affected by earlier setup noise) diff --git a/src/append.js b/src/append.js index 2e1ad01..5649bd2 100644 --- a/src/append.js +++ b/src/append.js @@ -11,6 +11,7 @@ import { installZinit } from "./steps/plugin-manager.js"; import { installCliTools } from "./steps/cli-tools.js"; import { installFrontendTools } from "./steps/frontend.js"; import { commandExists } from "./utils/shell.js"; +import { initializeUserAliases, redactAliasValues } from "./steps/aliases.js"; const ZSHRC = join(homedir(), ".zshrc"); const ZSH_SHARED_DIR = join(homedir(), ".config", "zsh", "shared"); @@ -67,6 +68,20 @@ export function ensurePluginsSource({ home } = {}) { ); } +export function ensureUserAliasesSource({ home } = {}) { + const base = home || homedir(); + initializeUserAliases({ home: base }); + const zshrc = join(base, ".zshrc"); + if (readFileSafe(zshrc).includes(".config/zsh/local/aliases.zsh")) { + return false; + } + return appendIfMissing( + zshrc, + '\n# >>> suitup/user-aliases >>>\nsource_if_exists "$HOME/.config/zsh/local/aliases.zsh"\n# <<< suitup/user-aliases <<<\n', + "suitup/user-aliases" + ); +} + export function getMissingToolsInitCommands(commandExistsFn = commandExists) { return TOOLS_INIT_COMMANDS.filter((tool) => !commandExistsFn(tool)); } @@ -111,12 +126,14 @@ const BLOCKS = [ source: join(CONFIGS_DIR, "shared", "aliases.zsh"), dest: join(ZSH_SHARED_DIR, "aliases.zsh"), label: "aliases", + redactPreview: redactAliasValues, }); - return appendIfMissing( + const sharedChanged = appendIfMissing( ZSHRC, '\n# >>> suitup/aliases >>>\nsource_if_exists "$HOME/.config/zsh/shared/aliases.zsh"\n# <<< suitup/aliases <<<\n', "suitup/aliases" ); + return ensureUserAliasesSource() || sharedChanged; }, }, { diff --git a/src/setup.js b/src/setup.js index 1264a3d..3e2b702 100644 --- a/src/setup.js +++ b/src/setup.js @@ -139,7 +139,8 @@ export function detectCompletedSteps({ existsSync(join(zshConfigDir, "shared", "completion.zsh")) && existsSync(join(zshConfigDir, "shared", "highlighting.zsh")) && existsSync(join(zshConfigDir, "shared", "prompt.zsh")) && - existsSync(join(zshConfigDir, "local", "machine.zsh")) + existsSync(join(zshConfigDir, "local", "machine.zsh")) && + existsSync(join(zshConfigDir, "local", "aliases.zsh")) ) { completed.add("zsh-config"); } diff --git a/src/steps/aliases.js b/src/steps/aliases.js index 7393acb..2b197d6 100644 --- a/src/steps/aliases.js +++ b/src/steps/aliases.js @@ -1,9 +1,103 @@ +import * as p from "@clack/prompts"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { ensureDir } from "../utils/fs.js"; +import { ensureDir, readFileSafe, writeFile } from "../utils/fs.js"; import { applyManagedConfigUpdate } from "../utils/config-diff.js"; import { CONFIGS_DIR } from "../constants.js"; +const ALIAS_NAME_RE = /^\s*alias\s+(?:-[A-Za-z]+\s+)*([A-Za-z0-9_.-]+)=/; + +function getAliasName(line) { + return line.match(ALIAS_NAME_RE)?.[1]; +} + +export function redactAliasValues(content) { + return content + .split(/\r?\n/) + .map((line) => { + const name = getAliasName(line); + if (name) return `alias ${name}=`; + if (!line.trim() || line.trimStart().startsWith("#")) return line; + return ""; + }) + .join("\n"); +} + +/** + * Create the user-owned aliases file once. Legacy Suitup aliases that do not + * overlap current shared aliases are migrated without printing their values. + * @param {object} [opts] + * @param {string} [opts.home] - override home directory (for testing) + */ +export function initializeUserAliases({ home } = {}) { + const base = home || homedir(); + const dest = join(base, ".config", "zsh", "local", "aliases.zsh"); + const destExists = existsSync(dest); + const template = readFileSafe(join(CONFIGS_DIR, "local", "aliases.zsh")); + const existing = destExists ? readFileSafe(dest) : template; + const legacyPath = join(base, ".config", "suitup", "aliases"); + const legacyLines = readFileSafe(legacyPath).split(/\r?\n/); + const sharedAliasNames = new Set( + readFileSafe(join(CONFIGS_DIR, "shared", "aliases.zsh")) + .split(/\r?\n/) + .map(getAliasName) + .filter(Boolean) + ); + const existingAliasNames = new Set(existing.split(/\r?\n/).map(getAliasName).filter(Boolean)); + const conflictingAliases = new Set( + legacyLines.map(getAliasName).filter((name) => name && sharedAliasNames.has(name)) + ); + let migratedLines = legacyLines.filter((line) => { + const name = getAliasName(line); + return name && !line.trimEnd().endsWith("\\") && !sharedAliasNames.has(name) && !existingAliasNames.has(name); + }); + const skippedLines = legacyLines.filter((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#") && (!getAliasName(line) || trimmed.endsWith("\\")); + }).length; + let content = existing; + + if (migratedLines.length > 0) { + const header = existing.includes("# Migrated from ~/.config/suitup/aliases") + ? "" + : "# Migrated from ~/.config/suitup/aliases\n"; + content = `${existing.trimEnd()}\n\n${header}${migratedLines.join("\n")}\n`; + + if (spawnSync("zsh", ["-n"], { input: content, encoding: "utf-8" }).status !== 0) { + p.log.warn(`Skipped ${migratedLines.length} legacy aliases because the merged file failed zsh syntax validation.`); + migratedLines = []; + content = existing; + } + } + + if (!destExists || content !== existing) { + writeFile(dest, content); + } + chmodSync(dest, 0o600); + + if (migratedLines.length > 0) { + p.log.success(`Migrated ${migratedLines.length} user aliases to ~/.config/zsh/local/aliases.zsh`); + } else if (!destExists) { + p.log.success("User aliases initialized at ~/.config/zsh/local/aliases.zsh"); + } + if (conflictingAliases.size > 0) { + p.log.warn(`Left ${conflictingAliases.size} aliases that overlap current Suitup aliases in ~/.config/suitup/aliases; review them before removing the legacy file.`); + } + if (skippedLines > 0) { + p.log.warn(`Left ${skippedLines} non-alias lines in ~/.config/suitup/aliases; move them to local/machine.zsh or local/secrets.zsh if still needed.`); + } + + return { + changed: !destExists || content !== existing, + reason: migratedLines.length > 0 ? "migrated" : destExists ? "exists" : "created", + migratedAliases: migratedLines.map(getAliasName), + conflictingAliases: conflictingAliases.size, + skippedLines, + }; +} + /** * Set up shell aliases. * @param {object} [opts] @@ -12,11 +106,13 @@ import { CONFIGS_DIR } from "../constants.js"; export async function setupAliases({ home } = {}) { const base = home || homedir(); const dest = join(base, ".config", "zsh", "shared", "aliases.zsh"); + initializeUserAliases({ home: base }); ensureDir(join(base, ".config", "zsh", "shared")); await applyManagedConfigUpdate({ source: join(CONFIGS_DIR, "shared", "aliases.zsh"), dest, label: "aliases", home: base, + redactPreview: redactAliasValues, }); } diff --git a/src/steps/frontend.js b/src/steps/frontend.js index a85c486..b77e905 100644 --- a/src/steps/frontend.js +++ b/src/steps/frontend.js @@ -2,7 +2,7 @@ import * as p from "@clack/prompts"; import { existsSync, lstatSync, readlinkSync, rmSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { brewInstall, commandExists, run, runStream } from "../utils/shell.js"; +import { brewInstall, commandExists, runStream } from "../utils/shell.js"; const BOOTSTRAP_NODE_BINARIES = ["node", "npm", "npx", "corepack"]; @@ -130,26 +130,12 @@ export async function installFrontendTools(selectedTools = getAllFrontendToolVal } } - // Fetch latest LTS version - let ltsVersion = "22"; - if (wanted.has("node")) { - try { - const raw = run( - 'curl -sf https://nodejs.org/dist/index.json | jq -r \'[.[] | select(.lts != false)][0].version\' | sed \'s/^v//\'', - { quiet: true } - ); - if (raw) ltsVersion = raw; - } catch { - p.log.warn(`Could not fetch latest LTS version, defaulting to ${ltsVersion}`); - } - } - // Install Node via fnm if (wanted.has("node") && fnmReady) { - p.log.step(`Installing Node.js v${ltsVersion} via fnm...`); + p.log.step("Installing the latest Node.js LTS via fnm..."); try { - await runStream(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${ltsVersion}`); - p.log.success(`Node.js v${ltsVersion} installed`); + await runStream('fnm install --lts --use && fnm default "$(fnm current)"'); + p.log.success("Latest Node.js LTS installed"); cleanupBootstrapNodeShims(home); } catch { p.log.warn("Could not install Node.js — fnm may need a shell restart first"); diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js index 2f28c83..d3e724d 100644 --- a/src/steps/zsh-config.js +++ b/src/steps/zsh-config.js @@ -5,6 +5,7 @@ import { join } from "node:path"; import { copyFile, ensureDir, writeIfNotExists, readFileSafe, writeFile } from "../utils/fs.js"; import { applyManagedConfigUpdate } from "../utils/config-diff.js"; import { CONFIGS_DIR } from "../constants.js"; +import { initializeUserAliases } from "./aliases.js"; const SHELL_RC_FILES = [ ".zshrc", @@ -114,6 +115,7 @@ export async function setupZshConfig({ home, promptTheme = "p10k" } = {}) { join(zshConfig, "local", "machine.zsh"), readFileSafe(join(CONFIGS_DIR, "local", "machine.zsh")) ); + initializeUserAliases({ home: base }); p.log.success("Zsh config structure created at ~/.config/zsh/"); } diff --git a/src/utils/config-diff.js b/src/utils/config-diff.js index c5fd2b3..e520345 100644 --- a/src/utils/config-diff.js +++ b/src/utils/config-diff.js @@ -161,6 +161,7 @@ export async function applyManagedConfigUpdate({ unsupportedReason = "not marked as suitup-managed", confirm = true, home = homedir(), + redactPreview, } = {}) { if (!existsSync(dest)) { copyFile(source, dest); @@ -187,7 +188,9 @@ export async function applyManagedConfigUpdate({ } const shownPath = displayPath(dest, home); - p.log.info(`Previewing suitup additions for ${shownPath}:\n${renderUnifiedDiff(existing, merged, { from: `${shownPath} current`, to: `${shownPath} proposed` })}`); + const previewExisting = redactPreview ? redactPreview(existing) : existing; + const previewMerged = redactPreview ? redactPreview(merged) : merged; + p.log.info(`Previewing suitup additions for ${shownPath}:\n${renderUnifiedDiff(previewExisting, previewMerged, { from: `${shownPath} current`, to: `${shownPath} proposed` })}`); if (confirm) { const shouldApply = await p.confirm({ diff --git a/src/verify.js b/src/verify.js index 01ee559..47b6e6a 100644 --- a/src/verify.js +++ b/src/verify.js @@ -29,6 +29,7 @@ const CHECKS = { { path: ".config/zsh/shared/aliases.zsh", label: "~/.config/zsh/shared/aliases.zsh" }, { path: ".config/zsh/shared/prompt.zsh", label: "~/.config/zsh/shared/prompt.zsh" }, { path: ".config/zsh/local/machine.zsh", label: "~/.config/zsh/local/machine.zsh" }, + { path: ".config/zsh/local/aliases.zsh", label: "~/.config/zsh/local/aliases.zsh" }, ], tools: [ { cmd: "brew", label: "Homebrew" }, diff --git a/tests/append.test.js b/tests/append.test.js index 0137313..b7cabce 100644 --- a/tests/append.test.js +++ b/tests/append.test.js @@ -36,6 +36,7 @@ vi.mock("../src/utils/shell.js", () => ({ import { ensurePromptSource, + ensureUserAliasesSource, ensureToolsInitDependencies, getMissingToolsInitCommands, needsToolsInitRepair, @@ -116,6 +117,22 @@ describe("Append mode utilities", () => { expect(readFileSync(newFile, "utf-8")).toContain("new content"); }); + test("ensureUserAliasesSource initializes and sources the user aliases file", () => { + writeFileSync(zshrcPath, "# existing config\n", "utf-8"); + + expect(ensureUserAliasesSource({ home: sandbox })).toBe(true); + expect(existsSync(join(sandbox, ".config", "zsh", "local", "aliases.zsh"))).toBe(true); + expect(readFileSync(zshrcPath, "utf-8")).toContain("suitup/user-aliases"); + expect(ensureUserAliasesSource({ home: sandbox })).toBe(false); + }); + + test("ensureUserAliasesSource does not duplicate a manually sourced file", () => { + writeFileSync(zshrcPath, 'source "$HOME/.config/zsh/local/aliases.zsh"\n', "utf-8"); + + expect(ensureUserAliasesSource({ home: sandbox })).toBe(false); + expect(readFileSync(zshrcPath, "utf-8").match(/local\/aliases\.zsh/g)).toHaveLength(1); + }); + test("multiple different blocks can be appended independently", () => { writeFileSync(zshrcPath, "# base\n", "utf-8"); diff --git a/tests/clean.test.js b/tests/clean.test.js index 176b835..6967f87 100644 --- a/tests/clean.test.js +++ b/tests/clean.test.js @@ -133,14 +133,17 @@ describe("clean command", () => { test("preserves user-modified managed files", () => { mkdirSync(join(sandbox.path, ".config", "zsh", "core"), { recursive: true }); mkdirSync(join(sandbox.path, ".config", "zsh", "shared"), { recursive: true }); + mkdirSync(join(sandbox.path, ".config", "zsh", "local"), { recursive: true }); writeFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "# custom env\n", "utf-8"); writeFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "# custom aliases\n", "utf-8"); + writeFileSync(join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"), 'alias mine="echo mine"\n', "utf-8"); writeFileSync(join(sandbox.path, ".zshenv"), "# my zshenv\nexport FOO=bar\n", "utf-8"); const summary = cleanSandbox(sandbox.path); expect(readFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "utf-8")).toBe("# custom env\n"); expect(readFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "utf-8")).toBe("# custom aliases\n"); + expect(readFileSync(join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"), "utf-8")).toBe('alias mine="echo mine"\n'); expect(readFileSync(join(sandbox.path, ".zshenv"), "utf-8")).toContain("export FOO=bar"); expect(summary.preserved).toContain("~/.config/zsh/core/env.zsh"); expect(summary.preserved).toContain("~/.config/zsh/shared/aliases.zsh"); diff --git a/tests/config-diff.test.js b/tests/config-diff.test.js index a68f04c..ed6674d 100644 --- a/tests/config-diff.test.js +++ b/tests/config-diff.test.js @@ -17,6 +17,7 @@ vi.mock("@clack/prompts", () => ({ })); import { applyManagedConfigUpdate, mergeLineAdditions, renderUnifiedDiff } from "../src/utils/config-diff.js"; +import { redactAliasValues } from "../src/steps/aliases.js"; function stripAnsiCodes(line) { return line.replace(/\u001b\[[0-9;]*m/g, ""); @@ -88,6 +89,25 @@ describe("managed config diff updates", () => { expect(mockWarn).toHaveBeenCalledWith("Skipped aliases; user changes were preserved."); }); + test("redacts alias values from managed diff previews", async () => { + writeFileSync(source, "# Generated by suitup\nalias safe='echo safe'\nalias added='echo added'\n", "utf-8"); + writeFileSync(dest, "# Generated by suitup\nalias safe='TOKEN=secret command'\n", "utf-8"); + mockConfirm.mockResolvedValue(false); + + await applyManagedConfigUpdate({ + source, + dest, + label: "aliases", + home: join(sandbox.path, "home"), + redactPreview: redactAliasValues, + }); + + const preview = mockInfo.mock.calls.map((call) => call[0]).join("\n"); + expect(preview).toContain("alias added="); + expect(preview).not.toContain("TOKEN"); + expect(preview).not.toContain("secret"); + }); + test("given an unmarked local config, when suitup has additions, then the file is skipped with a reason", async () => { const unmarkedContent = "# my aliases\nalias mine=''\n"; writeFileSync(source, "# Generated by suitup\nalias boo=''\n", "utf-8"); diff --git a/tests/configs.test.js b/tests/configs.test.js index 6f93cbd..bd76319 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -1,7 +1,8 @@ import { describe, test, expect, beforeEach } from "vitest"; -import { readFileSync, existsSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; const CONFIGS_DIR = join(import.meta.dirname, "..", "configs"); @@ -40,6 +41,7 @@ describe("Static config templates", () => { expect(content).toContain("gph"); expect(content).toContain("eza"); expect(content).toContain("bat"); + expect(content).toContain("edit-user-alias"); }); test("shared/aliases.zsh does not contain private/company content", () => { @@ -121,6 +123,60 @@ describe("Static config templates", () => { expect(content).toContain("shellenv zsh"); }); + test("core/paths.zsh initializes Homebrew at most once", () => { + const home = mkdtempSync(join(tmpdir(), "suitup-paths-")); + const binDir = join(home, "bin"); + const callsFile = join(home, "brew-calls"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, "brew"), + '#!/bin/sh\nprintf x >> "$CALLS_FILE"\nprintf \'export PATH="%s/bin:$PATH"\\n\' "$HOMEBREW_PREFIX"\n', + "utf-8" + ); + chmodSync(join(binDir, "brew"), 0o755); + + try { + execFileSync("zsh", ["-f", "-c", 'source "$PATHS_FILE"; source "$PATHS_FILE"'], { + env: { + ...process.env, + HOME: home, + HOMEBREW_PREFIX: home, + PATH: "/usr/bin:/bin", + PATHS_FILE: join(CONFIGS_DIR, "core", "paths.zsh"), + CALLS_FILE: callsFile, + }, + }); + expect(readFileSync(callsFile, "utf-8")).toBe("x"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("core/paths.zsh skips shellenv when brew is already available", () => { + const home = mkdtempSync(join(tmpdir(), "suitup-paths-")); + const binDir = join(home, "bin"); + const callsFile = join(home, "brew-calls"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, "brew"), '#!/bin/sh\nprintf x >> "$CALLS_FILE"\n', "utf-8"); + chmodSync(join(binDir, "brew"), 0o755); + + try { + execFileSync("zsh", ["-f", "-c", 'source "$PATHS_FILE"'], { + env: { + ...process.env, + HOME: home, + HOMEBREW_PREFIX: home, + PATH: `${binDir}:/usr/bin:/bin`, + PATHS_FILE: join(CONFIGS_DIR, "core", "paths.zsh"), + CALLS_FILE: callsFile, + }, + }); + expect(existsSync(callsFile)).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("core/paths.zsh keeps fnm and its default node on PATH for non-interactive shells", () => { const content = readFileSync(join(CONFIGS_DIR, "core", "paths.zsh"), "utf-8"); expect(content).toContain("fnm"); @@ -144,6 +200,11 @@ describe("Static config templates", () => { expect(fzfContent).toContain("FZF_DEFAULT_COMMAND"); expect(fzfContent).toContain("FZF_CTRL_T_COMMAND"); expect(fzfContent).toContain("FZF_CTRL_T_OPTS"); + expect(fzfContent).toContain("command \"${fzf_command[@]}\""); + expect(fzfContent).toContain("fzf-tmux"); + expect(fzfContent).toContain('selected="${insert_prefix}${item}"'); + expect(fzfContent).not.toContain('${(q)insert_prefix$item}'); + expect(fzfContent).not.toContain("fzf --zsh"); }); test("shared/tools.zsh is a thin orchestrator that loads tool configs", () => { @@ -160,6 +221,9 @@ describe("Static config templates", () => { const content = readFileSync(join(CONFIGS_DIR, "shared", "completion.zsh"), "utf-8"); expect(content).toContain("compinit"); expect(content).toContain(".zcompdump"); + expect(content).toContain("zcompile"); + expect(content).toContain("(N.m-7L+0)"); + expect(content).not.toContain("command find"); expect(content).toContain("expand-or-complete"); }); @@ -185,6 +249,12 @@ describe("Static config templates", () => { } }); + test("local/aliases.zsh is a user-owned placeholder", () => { + const content = readFileSync(join(CONFIGS_DIR, "local", "aliases.zsh"), "utf-8"); + expect(content).toContain("User aliases"); + expect(content).not.toContain("Generated by suitup"); + }); + test("shared/tools.zsh loads fzf before atuin so atuin keeps the Ctrl-R binding", () => { const content = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); const fzfIdx = content.indexOf("_load_tool_config fzf"); @@ -213,6 +283,7 @@ describe("Static config templates", () => { "shared/aliases.zsh", "shared/prompt.zsh", "local/machine.zsh", + "local/aliases.zsh", ]; for (const file of zshFiles) { @@ -253,6 +324,7 @@ describe("Static config templates", () => { "shared/plugins.zsh", "shared/aliases.zsh", "shared/prompt.zsh", + "local/aliases.zsh", "zshrc.template", "zshenv.template", ]; diff --git a/tests/frontend.test.js b/tests/frontend.test.js index b7915b8..f436428 100644 --- a/tests/frontend.test.js +++ b/tests/frontend.test.js @@ -12,12 +12,11 @@ vi.mock("../src/utils/shell.js", () => ({ commandExists: vi.fn(), brewInstalled: vi.fn(), brewInstall: vi.fn(() => true), - run: vi.fn(() => ""), runStream: vi.fn(() => Promise.resolve(0)), })); import { installFrontendTools } from "../src/steps/frontend.js"; -import { brewInstall, commandExists, run, runStream } from "../src/utils/shell.js"; +import { brewInstall, commandExists, runStream } from "../src/utils/shell.js"; import * as p from "@clack/prompts"; const CURL_HTTP_ERROR = "curl exited with HTTP error 22"; @@ -30,8 +29,6 @@ describe("frontend step", () => { beforeEach(() => { vi.clearAllMocks(); sandbox = mkdtempSync(join(tmpdir(), "suitup-frontend-")); - // Default: fetch LTS version fails gracefully - run.mockImplementation(() => { throw new Error("no curl"); }); // Isolate from host fnm installation so tests use the sandbox path originalFnmDir = process.env.FNM_DIR; originalXdgDataHome = process.env.XDG_DATA_HOME; @@ -104,13 +101,13 @@ describe("frontend step", () => { expect(p.log.warn).toHaveBeenCalledWith("Skipping Node.js install because fnm is unavailable"); }); - test("sets fnm default after installing node", async () => { + test("installs the latest LTS and sets it as the fnm default", async () => { commandExists.mockReturnValue(true); await installFrontendTools(["node"], { home: sandbox }); const calls = runStream.mock.calls.map((c) => c[0]); - expect(calls.some((c) => c.includes("fnm default"))).toBe(true); + expect(calls).toContain('fnm install --lts --use && fnm default "$(fnm current)"'); }); test("installs pnpm when not present", async () => { diff --git a/tests/perf.zsh b/tests/perf.zsh index 5ac1db9..3bb611f 100644 --- a/tests/perf.zsh +++ b/tests/perf.zsh @@ -129,13 +129,32 @@ else (( \${_zsh_stage_durations[1]:-0} > 0 )) " - _run "_zsh_report outputs a timing table containing 'total'" " - source '$PERF_ZSH' - _stage 'env' - _stage 'tools' - out=\$(_zsh_report 2>&1) - [[ \"\$out\" == *'total'* ]] - " +_run "_zsh_report outputs a timing table containing 'total'" " + source '$PERF_ZSH' + SUITUP_STARTUP_REPORT_THRESHOLD_MS=0 + _stage 'env' + _stage 'tools' + out=\$(_zsh_report 2>&1) + [[ \"\$out\" == *'total'* ]] +" + +_run "_zsh_report hides fast startup timings by default" " + source '$PERF_ZSH' + _stage 'env' + out=\$(_zsh_report 2>&1) + rc=\$? + [[ -z \"\$out\" && \$rc == 0 ]] +" + +_run "explicit completion cache hint remains visible for fast startup" " + source '$PERF_ZSH' + SUITUP_SHOW_COMPLETION_CACHE_HINT=1 + _zsh_completion_cache_mode='cache-hit' + _zsh_compdump_file='/tmp/.zcompdump' + _stage 'completion' + out=\$(_zsh_report 2>&1) + [[ \"\$out\" == *'completion cache hit'* && \"\$out\" != *'total'* ]] +" _run "_zsh_report is idempotent (second call produces no extra output)" " source '$PERF_ZSH' diff --git a/tests/setup.test.js b/tests/setup.test.js index f662975..fc9fc13 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -79,6 +79,10 @@ describe("Setup simulation in sandbox", () => { join(CONFIGS_DIR, "local", "machine.zsh"), join(sandbox, ".config/zsh/local", "machine.zsh") ); + copyFileSync( + join(CONFIGS_DIR, "local", "aliases.zsh"), + join(sandbox, ".config/zsh/local", "aliases.zsh") + ); // Copy .zshrc copyFileSync( @@ -105,6 +109,7 @@ describe("Setup simulation in sandbox", () => { ".config/zsh/shared/plugins.zsh", ".config/zsh/shared/prompt.zsh", ".config/zsh/local/machine.zsh", + ".config/zsh/local/aliases.zsh", ]; for (const file of expectedFiles) { @@ -128,6 +133,7 @@ describe("Setup simulation in sandbox", () => { expect(content).toContain("shared/aliases.zsh"); expect(content).toContain("shared/completion.zsh"); expect(content).toContain("shared/prompt.zsh"); + expect(content).toContain("local/aliases.zsh"); expect(content).toContain("_zsh_report"); expect(content).toContain('source_if_exists "${ZINIT_HOME}/zinit.zsh"'); @@ -258,6 +264,7 @@ describe("Setup simulation in sandbox", () => { writeFileSync(join(sandbox, ".config", "zsh", "shared", "aliases.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "local", "aliases.zsh"), "", "utf-8"); const detected = detectCompletedSteps({ home: sandbox, @@ -297,6 +304,7 @@ describe("Setup simulation in sandbox", () => { } writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "aliases.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "", "utf-8"); const initialSteps = getInitialStepValues({ @@ -337,6 +345,7 @@ describe("Setup simulation in sandbox", () => { } writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "aliases.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "", "utf-8"); writeFileSync( join(completedSandbox.path, ".config", "zsh", "shared", "completion.zsh"), @@ -392,6 +401,10 @@ describe("Setup simulation in sandbox", () => { join(CONFIGS_DIR, "local", "machine.zsh"), join(completedSandbox.path, ".config", "zsh", "local", "machine.zsh") ); + copyFileSync( + join(CONFIGS_DIR, "local", "aliases.zsh"), + join(completedSandbox.path, ".config", "zsh", "local", "aliases.zsh") + ); const loaderPath = join(completedSandbox.path, ".config", "zsh", "shared", "tools", "_loader.zsh"); const loader = readFileSync(loaderPath, "utf-8"); diff --git a/tests/verify.test.js b/tests/verify.test.js index 7103fcc..71ad7a0 100644 --- a/tests/verify.test.js +++ b/tests/verify.test.js @@ -60,6 +60,7 @@ describe("Verify in sandbox", () => { ["shared/aliases.zsh", ".config/zsh/shared/aliases.zsh"], ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"], ["local/machine.zsh", ".config/zsh/local/machine.zsh"], + ["local/aliases.zsh", ".config/zsh/local/aliases.zsh"], ]; for (const [src, dest] of fileMappings) { @@ -80,6 +81,7 @@ describe("Verify in sandbox", () => { const dirs = [ ".config/zsh/core", ".config/zsh/shared", + ".config/zsh/local", ]; for (const dir of dirs) { mkdirSync(join(sandbox, dir), { recursive: true }); @@ -95,6 +97,7 @@ describe("Verify in sandbox", () => { ["shared/plugins.zsh", ".config/zsh/shared/plugins.zsh"], ["shared/aliases.zsh", ".config/zsh/shared/aliases.zsh"], ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"], + ["local/aliases.zsh", ".config/zsh/local/aliases.zsh"], ]; for (const [src, dest] of zshFiles) { diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js index 42818d8..1827460 100644 --- a/tests/zsh-config-steps.test.js +++ b/tests/zsh-config-steps.test.js @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { createSandbox } from "./helpers.js"; @@ -16,8 +16,10 @@ vi.mock("@clack/prompts", () => ({ })); import { backupShellRcFiles, setupZshConfig, writeZshrc, writeZshenv } from "../src/steps/zsh-config.js"; +import { initializeUserAliases, redactAliasValues } from "../src/steps/aliases.js"; import { setupAliases } from "../src/steps/aliases.js"; import { setupVim } from "../src/steps/vim.js"; +import * as p from "@clack/prompts"; describe("zsh-config step", () => { let sandbox; @@ -48,6 +50,72 @@ describe("zsh-config step", () => { expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "highlighting.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "prompt.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "local", "machine.zsh"))).toBe(true); + expect(existsSync(join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"))).toBe(true); + }); + + test("migrates only user aliases from the legacy Suitup file", async () => { + const legacyDir = join(sandbox.path, ".config", "suitup"); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync( + join(legacyDir, "aliases"), + 'alias gst="git status"\nalias my-project="cd ~/project"\nexport TOKEN="secret"\n', + "utf-8" + ); + + await setupZshConfig({ home: sandbox.path }); + + const userAliases = join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"); + const content = readFileSync(userAliases, "utf-8"); + expect(content).toContain('alias my-project="cd ~/project"'); + expect(content).not.toContain('alias gst="git status"'); + expect(content).not.toContain("TOKEN"); + expect(statSync(userAliases).mode & 0o777).toBe(0o600); + expect(p.log.warn).toHaveBeenCalledWith(expect.stringContaining("aliases that overlap current Suitup aliases")); + }); + + test("preserves an existing user aliases file", async () => { + const userAliases = join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"); + mkdirSync(join(sandbox.path, ".config", "zsh", "local"), { recursive: true }); + writeFileSync(userAliases, 'alias mine="echo mine"\n', "utf-8"); + + await setupZshConfig({ home: sandbox.path }); + + expect(readFileSync(userAliases, "utf-8")).toBe('alias mine="echo mine"\n'); + expect(statSync(userAliases).mode & 0o777).toBe(0o600); + }); + + test("merges missing legacy aliases into an existing user aliases file once", () => { + const userAliases = join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"); + const legacyDir = join(sandbox.path, ".config", "suitup"); + mkdirSync(join(sandbox.path, ".config", "zsh", "local"), { recursive: true }); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(userAliases, 'alias mine="echo mine"\n', "utf-8"); + writeFileSync(join(legacyDir, "aliases"), 'alias mine="old"\nalias another="echo another"\n', "utf-8"); + + const first = initializeUserAliases({ home: sandbox.path }); + expect(first.migratedAliases).toEqual(["another"]); + expect(first.conflictingAliases).toBe(0); + expect(initializeUserAliases({ home: sandbox.path }).migratedAliases).toEqual([]); + const content = readFileSync(userAliases, "utf-8"); + expect(content).toContain('alias mine="echo mine"'); + expect(content.match(/alias another=/g)).toHaveLength(1); + }); + + test("rejects legacy aliases that would make the user file invalid", () => { + const legacyDir = join(sandbox.path, ".config", "suitup"); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, "aliases"), "alias broken='unterminated\n", "utf-8"); + + const result = initializeUserAliases({ home: sandbox.path }); + const content = readFileSync(join(sandbox.path, ".config", "zsh", "local", "aliases.zsh"), "utf-8"); + expect(result.migratedAliases).toEqual([]); + expect(content).not.toContain("unterminated"); + }); + + test("redacts alias values from previews", () => { + const redacted = redactAliasValues('alias deploy="TOKEN=secret deploy"\nexport TOKEN=secret\n'); + expect(redacted).toContain("alias deploy="); + expect(redacted).not.toContain("secret"); }); test("copies the optimized startup config files", async () => {